README.md in active_fields-0.2.0 vs README.md in active_fields-1.0.0

- old
+ new

@@ -1,5 +1,638 @@ # ActiveFields [![Gem Version](https://img.shields.io/gem/v/active_fields?color=blue&label=version)](https://rubygems.org/gems/active_fields) [![Gem downloads count](https://img.shields.io/gem/dt/active_fields)](https://rubygems.org/gems/active_fields) [![Github Actions CI](https://github.com/lassoid/active_fields/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/lassoid/active_fields/actions/workflows/main.yml) + +**ActiveFields** is a Rails plugin that implements the Entity-Attribute-Value (EAV) pattern, +enabling the addition of custom fields to any model at runtime without requiring changes to the database schema. + +## Key Concepts + +- **Active Field**: A record with the definition of a custom field. +- **Active Value**: A record that stores the value of an _Active Field_ for a specific _Customizable_. +- **Customizable**: A record that has custom fields. + +## Models Structure + +```mermaid +classDiagram + ActiveValue "*" --> "1" ActiveField + ActiveValue "*" --> "1" Customizable + + class ActiveField { + + string name + + string type + + string customizable_type + + json default_value + + json options + } + class ActiveValue { + + json value + } + class Customizable { + // This is your model + } +``` + +All values are stored in a JSON (jsonb) field, which is a highly flexible column type capable of storing various data types, +such as booleans, strings, numbers, arrays, etc. + +## Installation + +1. Install the gem and add it to your application's Gemfile by running: + + ```shell + bundle add active_fields + ``` + +2. Run install generator, then run migrations: + + ```shell + bin/rails generate active_fields:install + bin/rails db:migrate + ``` + +3. Add the `has_active_fields` method to any models where you want to enable custom fields: + + ```ruby + class Author < ApplicationRecord + has_active_fields + end + ``` + +4. Implement the necessary code to work with _Active Fields_. + + This plugin provides a convenient API and helpers, allowing you to write code that meets your specific needs + without being forced to use predefined implementations that is hard to extend. + + Generally, you should: + - Implement a controller and UI for managing _Active Fields_. + - Add inputs for _Active Values_ in _Customizable_ forms and permit their params in the controller. + + To set _Active Values_ for your _Customizable_, use the `active_fields_attributes=` method, + that integrates with Rails `fields_for` to generate appropriate form fields. + Alternatively, the alias `active_fields=` can be used in contexts without `fields_for`, such as APIs. + + To prepare a collection of _Active Values_ for use with the `fields_for` builder, + call the `initialize_active_values` method. + + **Note:** By default, Rails form fields insert an empty string into array (multiple) parameters. + You’ll need to handle the removal of these empty strings. + + ```ruby + # app/controllers/posts_controller.rb + # ... + + def new + @post = Post.new + @post.initialize_active_values + end + + def edit + @post.initialize_active_values + end + + def post_params + permitted_params = params.require(:post).permit( + # ... + active_fields_attributes: [:name, :value, :_destroy, value: []], + ) + permitted_params[:active_fields_attributes]&.each do |_index, value_attrs| + value_attrs[:value] = compact_array_param(value_attrs[:value]) if value_attrs[:value].is_a?(Array) + end + + permitted_params + end + + def compact_array_param(value) + if value.first == "" + value[1..-1] + else + value + end + end + ``` + + ```erb + # app/views/posts/_form.html.erb + # ... + + <%= form.fields_for :active_fields, post.active_values.sort_by(&:active_field_id), include_id: false do |active_fields_form| %> + <%= active_fields_form.hidden_field :name %> + # Render appropriate Active Value input and (optionally) destroy flag here + <% end %> + + # ... + ``` + + You can find a detailed [example](https://github.com/lassoid/active_fields/blob/main/spec/dummy) + of how to implement this in a full-stack Rails application. + Feel free to explore the source code and run it locally: + + ```shell + spec/dummy/bin/setup + bin/rails s + ``` + +## Field Types + +The plugin comes with a structured set of _Active Fields_ types: + +```mermaid +classDiagram + class ActiveField { + + string name + + string type + + string customizable_type + } + class Boolean { + + boolean default_value + + boolean required + + boolean nullable + } + class Date { + + date default_value + + boolean required + + date min + + date max + } + class DateArray { + + array~date~ default_value + + date min + + date max + + integer min_size + + integer max_size + } + class DateTime { + + datetime default_value + + boolean required + + datetime min + + datetime max + + integer precision + } + class DateTimeArray { + + array~datetime~ default_value + + datetime min + + datetime max + + integer precision + + integer min_size + + integer max_size + } + class Decimal { + + decimal default_value + + boolean required + + decimal min + + decimal max + + integer precision + } + class DecimalArray { + + array~decimal~ default_value + + decimal min + + decimal max + + integer precision + + integer min_size + + integer max_size + } + class Enum { + + string default_value + + boolean required + + array~string~ allowed_values + } + class EnumArray { + + array~string~ default_value + + array~string~ allowed_values + + integer min_size + + integer max_size + } + class Integer { + + integer default_value + + boolean required + + integer min + + integer max + } + class IntegerArray { + + array~integer~ default_value + + integer min + + integer max + + integer min_size + + integer max_size + } + class Text { + + string default_value + + boolean required + + integer min_length + + integer max_length + } + class TextArray { + + array~string~ default_value + + integer min_length + + integer max_length + + integer min_size + + integer max_size + } + + ActiveField <|-- Boolean + ActiveField <|-- Date + ActiveField <|-- DateArray + ActiveField <|-- DateTime + ActiveField <|-- DateTimeArray + ActiveField <|-- Decimal + ActiveField <|-- DecimalArray + ActiveField <|-- Enum + ActiveField <|-- EnumArray + ActiveField <|-- Integer + ActiveField <|-- IntegerArray + ActiveField <|-- Text + ActiveField <|-- TextArray +``` + +### Fields Base Attributes +- `name`(`string`) +- `type`(`string`) +- `customizable_type`(`string`) +- `default_value` (`json`) + +### Field Types Summary + +All _Active Field_ model names start with `ActiveFields::Field`. +We replace it with `**` for conciseness. + +| Active Field model | Type name | Attributes | Options | +|---------------------------------|------------------|------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `**::Boolean` | `boolean` | `default_value`<br>(`boolean` or `nil`) | `required`(`boolean`) - the value must not be `false`<br>`nullable`(`boolean`) - the value could be `nil` | +| `**::Date` | `date` | `default_value`<br>(`date` or `nil`) | `required`(`boolean`) - the value must not be `nil`<br>`min`(`date`) - minimum value allowed<br>`max`(`date`) - maximum value allowed | +| `**::DateArray` | `date_array` | `default_value`<br>(`array[date]`) | `min`(`date`) - minimum value allowed, for each element<br>`max`(`date`) - maximum value allowed, for each element<br>`min_size`(`integer`) - minimum value size<br>`max_size`(`integer`) - maximum value size | +| `**::DateTime` | `datetime` | `default_value`<br>(`datetime` or `nil`) | `required`(`boolean`) - the value must not be `nil`<br>`min`(`datetime`) - minimum value allowed<br>`max`(`datetime`) - maximum value allowed<br>`precision`(`integer`) - the number of digits in fractional seconds | +| `**::DateTimeArray` | `datetime_array` | `default_value`<br>(`array[datetime]`) | `min`(`datetime`) - minimum value allowed, for each element<br>`max`(`datetime`) - maximum value allowed, for each element<br>`precision`(`integer`) - the number of digits in fractional seconds, for each element<br>`min_size`(`integer`) - minimum value size<br>`max_size`(`integer`) - maximum value size | +| `**::Decimal` | `decimal` | `default_value`<br>(`decimal` or `nil`) | `required`(`boolean`) - the value must not be `nil`<br>`min`(`decimal`) - minimum value allowed<br>`max`(`decimal`) - maximum value allowed<br>`precision`(`integer`) - the number of digits after the decimal point | +| `**::DecimalArray` | `decimal_array` | `default_value`<br>(`array[decimal]`) | `min`(`decimal`) - minimum value allowed, for each element<br>`max`(`decimal`) - maximum value allowed, for each element<br>`precision`(`integer`) - the number of digits after the decimal point, for each element<br>`min_size`(`integer`) - minimum value size<br>`max_size`(`integer`) - maximum value size | +| `**::Enum` | `enum` | `default_value`<br>(`string` or `nil`) | `required`(`boolean`) - the value must not be `nil`<br>**\***`allowed_values`(`array[string]`) - a list of allowed values | +| `**::EnumArray` | `enum_array` | `default_value`<br>(`array[string]`) | **\***`allowed_values`(`array[string]`) - a list of allowed values<br>`min_size`(`integer`) - minimum value size<br>`max_size`(`integer`) - maximum value size | +| `**::Integer` | `integer` | `default_value`<br>(`integer` or `nil`) | `required`(`boolean`) - the value must not be `nil`<br>`min`(`integer`) - minimum value allowed<br>`max`(`integer`) - maximum value allowed | +| `**::IntegerArray` | `integer_array` | `default_value`<br>(`array[integer]`) | `min`(`integer`) - minimum value allowed, for each element<br>`max`(`integer`) - maximum value allowed, for each element<br>`min_size`(`integer`) - minimum value size<br>`max_size`(`integer`) - maximum value size | +| `**::Text` | `text` | `default_value`<br>(`string` or `nil`) | `required`(`boolean`) - the value must not be `nil`<br>`min_length`(`integer`) - minimum value length allowed<br>`max_length`(`integer`) - maximum value length allowed | +| `**::TextArray` | `text_array` | `default_value`<br>(`array[string]`) | `min_length`(`integer`) - minimum value length allowed, for each element<br>`max_length`(`integer`) - maximum value length allowed, for each element<br>`min_size`(`integer`) - minimum value size<br>`max_size`(`integer`) - maximum value size | +| _Your custom class can be here_ | _..._ | _..._ | _..._ | + +**Note:** Options marked with **\*** are mandatory. + +## Configuration + +### Limiting Field Types for a Customizable + +You can restrict the allowed _Active Field_ types for a _Customizable_ by passing _type names_ to the `types` argument in the `has_active_fields` method: + +```ruby +class Post < ApplicationRecord + has_active_fields types: %i[boolean date_array integer your_custom_field_type_name] + # ... +end +``` + +Attempting to save an _Active Field_ with a disallowed type will result in a validation error: + +```ruby +active_field = ActiveFields::Field::Date.new(name: "date", customizable_type: "Post") +active_field.valid? #=> false +active_field.errors.messages #=> {:customizable_type=>["is not included in the list"]} +``` + +### Customizing Internal Model Classes + +You can extend the functionality of _Active Fields_ and _Active Values_ by changing their classes. +By default, _Active Fields_ inherit from `ActiveFields::Field::Base` (utilizing STI), +and _Active Values_ class is `ActiveFields::Value`. +You should include the mix-ins `ActiveFields::FieldConcern` and `ActiveFields::ValueConcern` +in your custom models to add the necessary functionality. + +```ruby +# config/initializers/active_fields.rb +ActiveFields.configure do |config| + config.field_base_class_name = "CustomField" + config.value_class_name = "CustomValue" +end + +# app/models/custom_field.rb +class CustomField < ApplicationRecord + self.table_name = "active_fields" # Ensure the model uses the correct table + + include ActiveFields::FieldConcern + + # Your custom code to extend Active Fields + def label = name.titleize + # ... +end + +# app/models/custom_value.rb +class CustomValue < ApplicationRecord + self.table_name = "active_fields_values" # Ensure the model uses the correct table + + include ActiveFields::ValueConcern + + # Your custom code to extend Active Values + def label = active_field.label + # ... +end +``` + +### Adding Custom Field Types + +To add a custom _Active Field_ type, create a subclass of the `ActiveFields.config.field_base_class`, +register it in the global configuration and configure the field by calling `acts_as_active_field`. + +```ruby +# config/initializers/active_fields.rb +ActiveFields.configure do |config| + # The first argument - field type name, the second - field class name + config.register_field :ip, "IpField" +end + +# app/models/ip_field.rb +class IpField < ActiveFields.config.field_base_class + # Configure the field + acts_as_active_field( + validator: { + class_name: "IpValidator", + options: -> { { required: required? } }, # options that will be passed to the validator + }, + caster: { + class_name: "IpCaster", + options: -> { { strip: strip? } }, # options that will be passed to the caster + }, + ) + + # Store specific attributes in `options` + store_accessor :options, :required, :strip + + # You can use built-in casters to cast your options + %i[required strip].each do |column| + define_method(column) do + ActiveFields::Casters::BooleanCaster.new.deserialize(super()) + end + + define_method(:"#{column}?") do + !!public_send(column) + end + + define_method(:"#{column}=") do |other| + super(ActiveFields::Casters::BooleanCaster.new.serialize(other)) + end + end + + private + + # This method allows you to assign default values to your options. + # It is automatically executed within the `after_initialize` callback. + def set_defaults + self.required ||= false + self.strip ||= true + end +end +``` + +To create an array _Active Field_ type, pass the `array: true` option to `acts_as_active_field`. +This will add `min_size` and `max_size` options, as well as some important internal methods such as `array?`. + +```ruby +# config/initializers/active_fields.rb +ActiveFields.configure do |config| + config.register_field :ip_array, "IpArrayField" +end + +# app/models/ip_array_field.rb +class IpArrayField < ActiveFields.config.field_base_class + acts_as_active_field( + array: true, + validator: { + class_name: "IpArrayValidator", + options: -> { { min_size: min_size, max_size: max_size } }, + }, + caster: { + class_name: "IpArrayCaster", + }, + ) + # ... +end +``` + +For each custom _Active Field_ type, you must define a **validator** and a **caster**: + +#### Validator + +Create a class that inherits from `ActiveFields::Validators::BaseValidator` and implements the `perform_validation` method. +This method is responsible for validating `active_field.default_value` and `active_value.value`, and adding any errors to the `errors` set. +These errors will then propagate to the corresponding record. +Each error should match the arguments format of the _ActiveModel_ `errors.add` method. + +```ruby +# lib/ip_validator.rb (or anywhere you want) +class IpValidator < ActiveFields::Validators::BaseValidator + private + + def perform_validation(value) + if value.nil? + if options[:required] + errors << :required # type only + end + elsif value.is_a?(String) + unless value.match?(Resolv::IPv4::Regex) + errors << [:invalid, message: "doesn't match the IPv4 format"] # type with options + end + else + errors << :invalid + end + end +end +``` + +#### Caster + +Create a class that inherits from `ActiveFields::Casters::BaseCaster` +and implements methods `serialize` (used when setting a value) and `deserialize` (used when retrieving a value). +These methods handle the conversion of `active_field.default_value` and `active_value.value`. + +```ruby +# lib/ip_caster.rb (or anywhere you want) +class IpCaster < ActiveFields::Casters::BaseCaster + def serialize(value) + value = value&.to_s + value = value&.strip if options[:strip] + + value + end + + def deserialize(value) + value = value&.to_s + value = value&.strip if options[:strip] + + value + end +end +``` + +### Localization (I18n) + +The built-in _validators_ primarily use _Rails_ default error types. +However, there are some custom error types that you’ll need to handle in your locale files: +- `size_too_short` (args: `count`): Triggered when the size of an array _Active Field_ value is smaller than the allowed minimum. +- `size_too_long` (args: `count`): Triggered when the size of an array _Active Field_ value exceeds the allowed maximum. +- `duplicate`: Triggered when an enum array _Active Field_ contains duplicate elements. + +For an example, refer to the [locale file](https://github.com/lassoid/active_fields/blob/main/spec/dummy/config/locales/en.yml). + +## Current Restrictions + +1. Only _PostgreSQL_ is fully supported. + + The gem is tested exclusively with _PostgreSQL_. Support for other databases is not guaranteed. + + However, you can give it a try! :) + +2. Updating some _Active Fields_ options may be unsafe. + + This could cause existing _Active Values_ to become invalid, + leading to the associated _Customizables_ also becoming invalid, + which could potentially result in update failures. + +## API Overview + +### Fields API + +```ruby +active_field = ActiveFields::Field::Boolean.take + +# Associations: +active_field.active_values # `has_many` association with Active Values associated with this Active Field + +# Attributes: +active_field.type # Class name of this Active Field (utilizing STI) +active_field.customizable_type # Name of the Customizable model this Active Field is registered to +active_field.name # Identifier of this Active Field, it should be unique in scope of customizable_type +active_field.default_value_meta # JSON column declaring the default value. Consider using `default_value` instead +active_field.options # A hash (json) containing type-specific attributes for this Active Field + +# Methods: +active_field.default_value # Default value for all Active Values associated with this Active Field +active_field.array? # Returns whether the Active Field type is an array +active_field.value_validator_class # Class used for values validation +active_field.value_validator # Validator object that performs values validation +active_field.value_caster_class # Class used for values casting +active_field.value_caster # Caster object that performs values casting +active_field.customizable_model # Customizable model class +active_field.type_name # Identifier of the type of this Active Field (instead of class name) + +# Scopes: +ActiveFields::Field::Boolean.for("Author") # Collection of Active Fields registered for the specified Customizable type +``` + +### Values API + +```ruby +active_value = ActiveFields::Value.take + +# Associations: +active_value.active_field # `belongs_to` association with the associated Active Field +active_value.customizable # `belongs_to` association with the associated Customizable + +# Attributes: +active_value.value_meta # JSON column declaring the value. Consider using `value` instead + +# Methods: +active_value.value # The value of this Active Value +active_value.name # Name of the associated Active Field +``` + +### Customizable API + +```ruby +customizable = Author.take + +# Associations: +customizable.active_values # `has_many` association with Active Values linked to this Customizable + +# Methods: +customizable.active_fields # Collection of Active Fields registered for this record + +# Create, update or destroy Active Values. +customizable.active_fields_attributes = [ + { name: "integer_array", value: [1, 4, 5, 5, 0] }, # create or update (symbol keys) + { "name" => "text", "value" => "Lasso" }, # create or update (string keys) + { name: "date", _destroy: true }, # destroy (symbol keys) + { "name" => "boolean", "_destroy" => true }, # destroy (string keys) + permitted_params, # params could be passed, but they must be permitted +] + +# Alias of `#active_fields_attributes=`. +customizable.active_fields = [ + { name: "integer_array", value: [1, 4, 5, 5, 0] }, # create or update (symbol keys) + { "name" => "text", "value" => "Lasso" }, # create or update (string keys) + { name: "date", _destroy: true }, # destroy (symbol keys) + { "name" => "boolean", "_destroy" => true }, # destroy (string keys) + permitted_params, # params could be passed, but they must be permitted +] + +# Create, update or destroy Active Values. +# Implemented by `accepts_nested_attributes_for`. +# Please use `active_fields_attributes=`/`active_fields=` instead. +customizable.active_values_attributes = attributes + +# Build an Active Value, if it doesn't exist, with the default value for each Active Field. +# This method is useful with `fields_for`, allowing you to pass the collection as an argument to render new Active Values: +# `form.fields_for :active_values, customizable.active_values`. +customizable.initialize_active_values +``` + +### Global Config + +```ruby +ActiveFields.config # Access the plugin's global configuration +ActiveFields.config.fields # Registered Active Fields types (type_name => field_class) +ActiveFields.config.field_base_class # Base class for all Active Fields +ActiveFields.config.field_base_class_name # Name of the Active Fields base class +ActiveFields.config.value_class # Active Values class +ActiveFields.config.value_class_name # Name of the Active Values class +ActiveFields.config.field_base_class_changed? # Check if the Active Fields base class has changed +ActiveFields.config.value_class_changed? # Check if the Active Values class has changed +ActiveFields.config.type_names # Registered Active Fields type names +ActiveFields.config.type_class_names # Registered Active Fields class names +ActiveFields.config.register_field(:ip, "IpField") # Register a custom Active Field type +``` + +### Customizable Config + +```ruby +customizable_model = Author +customizable_model.active_fields_config # Access the Customizable's configuration +customizable_model.active_fields_config.customizable_model # The Customizable model itself +customizable_model.active_fields_config.types # Allowed Active Field types (e.g., `[:boolean]`) +customizable_model.active_fields_config.types_class_names # Allowed Active Field class names (e.g., `[ActiveFields::Field::Boolean]`) +``` + +## Development + +After checking out the repo, run `spec/dummy/bin/setup` to setup the environment. +Then, run `bin/rspec` to run the tests. +You can also run `bin/rubocop` to lint the source code, +`bin/rails c` for an interactive prompt that will allow you to experiment +and `bin/rails s` to start the Dummy app with plugin already enabled and configured. + +To install this gem onto your local machine, run `bin/rake install`. +To release a new version, update the version number in `version.rb`, and then run `bin/rake release`, +which will create a git tag for the version, push git commits and the created tag, +and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/lassoid/active_fields. +This project is intended to be a safe, welcoming space for collaboration, and contributors +are expected to adhere to the [code of conduct](https://github.com/lassoid/active_fields/blob/main/CODE_OF_CONDUCT.md). + +## License + +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). + +## Code of Conduct + +Everyone interacting in the ActiveFields project's codebases, issue trackers, chat rooms and mailing lists +is expected to follow the [code of conduct](https://github.com/lassoid/active_fields/blob/main/CODE_OF_CONDUCT.md).