# 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`
(`boolean` or `nil`) | `required`(`boolean`) - the value must not be `false`
`nullable`(`boolean`) - the value could be `nil` |
| `**::Date` | `date` | `default_value`
(`date` or `nil`) | `required`(`boolean`) - the value must not be `nil`
`min`(`date`) - minimum value allowed
`max`(`date`) - maximum value allowed |
| `**::DateArray` | `date_array` | `default_value`
(`array[date]`) | `min`(`date`) - minimum value allowed, for each element
`max`(`date`) - maximum value allowed, for each element
`min_size`(`integer`) - minimum value size
`max_size`(`integer`) - maximum value size |
| `**::DateTime` | `datetime` | `default_value`
(`datetime` or `nil`) | `required`(`boolean`) - the value must not be `nil`
`min`(`datetime`) - minimum value allowed
`max`(`datetime`) - maximum value allowed
`precision`(`integer`) - the number of digits in fractional seconds |
| `**::DateTimeArray` | `datetime_array` | `default_value`
(`array[datetime]`) | `min`(`datetime`) - minimum value allowed, for each element
`max`(`datetime`) - maximum value allowed, for each element
`precision`(`integer`) - the number of digits in fractional seconds, for each element
`min_size`(`integer`) - minimum value size
`max_size`(`integer`) - maximum value size |
| `**::Decimal` | `decimal` | `default_value`
(`decimal` or `nil`) | `required`(`boolean`) - the value must not be `nil`
`min`(`decimal`) - minimum value allowed
`max`(`decimal`) - maximum value allowed
`precision`(`integer`) - the number of digits after the decimal point |
| `**::DecimalArray` | `decimal_array` | `default_value`
(`array[decimal]`) | `min`(`decimal`) - minimum value allowed, for each element
`max`(`decimal`) - maximum value allowed, for each element
`precision`(`integer`) - the number of digits after the decimal point, for each element
`min_size`(`integer`) - minimum value size
`max_size`(`integer`) - maximum value size |
| `**::Enum` | `enum` | `default_value`
(`string` or `nil`) | `required`(`boolean`) - the value must not be `nil`
**\***`allowed_values`(`array[string]`) - a list of allowed values |
| `**::EnumArray` | `enum_array` | `default_value`
(`array[string]`) | **\***`allowed_values`(`array[string]`) - a list of allowed values
`min_size`(`integer`) - minimum value size
`max_size`(`integer`) - maximum value size |
| `**::Integer` | `integer` | `default_value`
(`integer` or `nil`) | `required`(`boolean`) - the value must not be `nil`
`min`(`integer`) - minimum value allowed
`max`(`integer`) - maximum value allowed |
| `**::IntegerArray` | `integer_array` | `default_value`
(`array[integer]`) | `min`(`integer`) - minimum value allowed, for each element
`max`(`integer`) - maximum value allowed, for each element
`min_size`(`integer`) - minimum value size
`max_size`(`integer`) - maximum value size |
| `**::Text` | `text` | `default_value`
(`string` or `nil`) | `required`(`boolean`) - the value must not be `nil`
`min_length`(`integer`) - minimum value length allowed
`max_length`(`integer`) - maximum value length allowed |
| `**::TextArray` | `text_array` | `default_value`
(`array[string]`) | `min_length`(`integer`) - minimum value length allowed, for each element
`max_length`(`integer`) - maximum value length allowed, for each element
`min_size`(`integer`) - minimum value size
`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).