ActiveFields
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
- Customizable: A record that has custom fields (Entity).
- Active Field: A record with the definition of a custom field (Attribute).
- Active Value: A record that stores the value of an Active Field for a specific Customizable (Value).
Models Structure
classDiagram
ActiveValue "*" --> "1" ActiveField
ActiveValue "*" --> "1" Customizable
class ActiveField {
+ string name
+ string type
+ string customizable_type
+ json default_value_meta
+ json options
}
class ActiveValue {
+ json value_meta
}
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
-
Install the gem and add it to your application's Gemfile by running:
bundle add active_fields
-
Run install generator, then run migrations:
bin/rails generate active_fields:install bin/rails db:migrate
-
Add the
has_active_fields
method to any models where you want to enable custom fields:class Post < ApplicationRecord has_active_fields end
-
Run scaffold generator.
This plugin provides a convenient API, allowing you to write code that meets your specific needs without being forced to use predefined implementations that is hard to extend.
However, for a quick start, you can generate a scaffold by running the following command:
bin/rails generate active_fields:scaffold
This command generates a controller, routes and views for managing Active Fields, along with form inputs for Active Values and some useful helper methods.
Note: Don't forget to add available Customizable types in generated Active Fields forms.
Note: The array field helper uses Stimulus for interactivity. If your app doesn't already include Stimulus, you can easily add it. Alternatively, if you prefer not to use Stimulus, you should implement your own JavaScript code.
-
Add Active Fields inputs in Customizables forms and permit their params in controllers.
There are two methods available on Customizable models for retrieving Active Values:
-
active_values
returns collection of only existing Active Values. -
initialize_active_values
builds any missing Active Values and returns the full collection.
Choose the method that suits your requirements. In most cases, however,
initialize_active_values
is the more suitable option.# app/views/posts/_form.html.erb # ... <%= form.fields_for :active_fields, post.initialize_active_values.sort_by(&:active_field_id), include_id: false do |active_fields_form| %> <%= active_fields_form.hidden_field :name %> <%= render_active_value_input(form: active_fields_form, active_value: active_fields_form.object) %> <% end %> # ...
Finally, permit the Active Fields attributes in your Customizables controllers:
# app/controllers/posts_controller.rb # ... 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 # Removes an empty string from the beginning of the array parameter def compact_array_param(value) if value.first == "" value[1..-1] else value end end
Note: Here we use the
active_fields_attributes=
method (as a permitted parameter), that integrates well with Railsfields_for
to generate appropriate form fields. Alternatively, the aliasactive_fields=
can be used in contexts withoutfields_for
, such as API controllers.That's it! You can now add Active Fields to Customizables at
http://localhost:3000/active_fields
and fill in Active Values within Customizable forms.You can also explore the Demo app where the plugin is fully integrated into a full-stack Rails application. Feel free to explore the source code and run it locally:
spec/dummy/bin/setup bin/rails s
-
Field Types
The plugin comes with a structured set of Active Fields types:
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_meta
(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 allowedmax (date ) - maximum value allowed |
**::DateArray |
date_array |
default_value ( array[date] ) |
min (date ) - minimum value allowed, for each elementmax (date ) - maximum value allowed, for each elementmin_size (integer ) - minimum value sizemax_size (integer ) - maximum value size |
**::DateTime |
datetime |
default_value ( datetime or nil ) |
required (boolean ) - the value must not be nil min (datetime ) - minimum value allowedmax (datetime ) - maximum value allowedprecision (integer ) - the number of digits in fractional seconds |
**::DateTimeArray |
datetime_array |
default_value ( array[datetime] ) |
min (datetime ) - minimum value allowed, for each elementmax (datetime ) - maximum value allowed, for each elementprecision (integer ) - the number of digits in fractional seconds, for each elementmin_size (integer ) - minimum value sizemax_size (integer ) - maximum value size |
**::Decimal |
decimal |
default_value ( decimal or nil ) |
required (boolean ) - the value must not be nil min (decimal ) - minimum value allowedmax (decimal ) - maximum value allowedprecision (integer ) - the number of digits after the decimal point |
**::DecimalArray |
decimal_array |
default_value ( array[decimal] ) |
min (decimal ) - minimum value allowed, for each elementmax (decimal ) - maximum value allowed, for each elementprecision (integer ) - the number of digits after the decimal point, for each elementmin_size (integer ) - minimum value sizemax_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 valuesmin_size (integer ) - minimum value sizemax_size (integer ) - maximum value size |
**::Integer |
integer |
default_value ( integer or nil ) |
required (boolean ) - the value must not be nil min (integer ) - minimum value allowedmax (integer ) - maximum value allowed |
**::IntegerArray |
integer_array |
default_value ( array[integer] ) |
min (integer ) - minimum value allowed, for each elementmax (integer ) - maximum value allowed, for each elementmin_size (integer ) - minimum value sizemax_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 allowedmax_length (integer ) - maximum value length allowed |
**::TextArray |
text_array |
default_value ( array[string] ) |
min_length (integer ) - minimum value length allowed, for each elementmax_length (integer ) - maximum value length allowed, for each elementmin_size (integer ) - minimum value sizemax_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:
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:
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.
# 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
.
# 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?
.
# 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.
# 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
.
# 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.
Current Restrictions
-
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! :)
-
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
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 # JSON column 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("Post") # Collection of Active Fields registered for the specified Customizable type
Values API
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
customizable = Post.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 not existing Active Values, with the default value for each Active Field.
# Returns full collection of Active Values.
# 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_fields, customizable.initialize_active_values`.
customizable.initialize_active_values
Global Config
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
customizable_model = Post
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.
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.
License
The gem is available as open source under the terms of the MIT License.
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.