Skip to content

Repository files navigation

ActiveFields

Gem VersionGem downloads countGithub Actions CI

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 or application code.

It may look similar to other gems like attr_json or store_attribute, but it solves a fundamentally different problem. While those tools allow you to add fields without migrations, they still require developer work - you must write code to define each field.

The main use case of EAV in general, and ActiveFields in particular, is to enable any application user (not just developers) to add their own fields. Not just without migrations, but without touching the source code at all. These are truly data-driven fields that can be created, modified, and managed entirely through your application's interface.

Table of Contents

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
}
Loading

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.

Requirements

  • Ruby 3.1+
  • Rails 7.1+
  • Postgres 15+ (17+ for search functionality)

Installation

  1. Install the gem and add it to your application's Gemfile by running:

    bundle add active_fields
  2. Run install generator, then run migrations:

    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:

    classPost < ApplicationRecordhas_active_fieldsend
  4. 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, views for managing Active Fields, along with form inputs for Active Values, search form and some useful helper methods that will be used in next steps.

    Note: The array field helper and search form use 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.

  5. 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 %>
    # ...

    Permit the Active Fields attributes in your Customizables controllers:

    # app/controllers/posts_controller.rb# ...defpost_paramspermitted_params=params.require(:post).permit(# ...active_fields_attributes: [:name,:value,:_destroy,value: []],)permitted_params[:active_fields_attributes]&.eachdo |_index,value_attrs|
    value_attrs[:value]=compact_array_param(value_attrs[:value])ifvalue_attrs[:value].is_a?(Array)endpermitted_paramsend

    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 alias active_fields= can be used in contexts without fields_for, such as API controllers.

    Note:compact_array_param is a helper method, that was added by scaffold generator. It removes an empty string from the beginning of the array parameter.

  6. Use the where_active_fields query method to filter records and add a search form in Customizables index actions.

    # app/controllers/posts_controller.rb# ...defindex@posts=Post.where_active_fields(active_fields_finders_params)end

    Note:active_fields_finders_params is a helper method, that was added by scaffold generator. It permits params from search form.

    # app/views/posts/index.html.erb
    # ...
    <%= render_active_fields_finders_form(active_fields: Post.active_fields, url: posts_path) %>
    # ...

That's it! You can now add Active Fields to Customizables at http://localhost:3000/active_fields, fill in Active Values within Customizable forms and search Customizables using their index actions.

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
Loading

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.

Table
Active Field modelType nameAttributesOptions
**::Booleanbooleandefault_value
(boolean or nil)
required(boolean) - the value must not be false
nullable(boolean) - the value could be nil
**::Datedatedefault_value
(date or nil)
required(boolean) - the value must not be nil
min(date) - minimum value allowed
max(date) - maximum value allowed
**::DateArraydate_arraydefault_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
**::DateTimedatetimedefault_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
**::DateTimeArraydatetime_arraydefault_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
**::Decimaldecimaldefault_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
**::DecimalArraydecimal_arraydefault_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
**::Enumenumdefault_value
(string or nil)
required(boolean) - the value must not be nil
*allowed_values(array[string]) - a list of allowed values
**::EnumArrayenum_arraydefault_value
(array[string])
*allowed_values(array[string]) - a list of allowed values
min_size(integer) - minimum value size
max_size(integer) - maximum value size
**::Integerintegerdefault_value
(integer or nil)
required(boolean) - the value must not be nil
min(integer) - minimum value allowed
max(integer) - maximum value allowed
**::IntegerArrayinteger_arraydefault_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
**::Texttextdefault_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
**::TextArraytext_arraydefault_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.

Search Functionality

Note: This feature is compatible with PostgreSQL 17 and above.

The gem provides a built-in search capability. Like Rails nested attributes functionality, it accepts the following argument types:

  • An array of hashes.

    Post.where_active_fields([{name: "integer_array",operator: "any_gteq",value: 5},# symbol keys{"name"=>"text",operator: "=","value"=>"Lasso"},# string keys{n: "boolean",op: "!=",v: false},# compact form (string or symbol keys)],)
  • A hash of hashes (typically generated by Railsfields_for form helper).

    Post.where_active_fields({"0"=>{name: "integer_array",operator: "any_gteq",value: 5},"1"=>{"name"=>"text",operator: "=","value"=>"Lasso"},"2"=>{n: "boolean",op: "!=",v: false},},)
  • Permitted parameters (can contain either an array of hashes or a hash of hashes).

    Post.where_active_fields(permitted_params)

Key details:

  • n/name argument must specify the name of an Active Field.
  • v/value argument will be automatically cast to the appropriate type.
  • op/operator argument can contain either operation or operator.

Supported operations and operators for each Active Field type are listed below.

Boolean
OperationOperatorDescription
eq=Value is equal to given
not_eq!=Value is not equal to given
Date
OperationOperatorDescription
eq=Value is equal to given
not_eq!=Value is not equal to given
gt>Value is greater than given
gteq>=Value is greater than or equal to given
lt<Value is less than given
lteq<=Value is less than or equal to given
DateArray
OperationOperatorDescription
include|=Array value includes given element
not_include!|=Array value doesn't include given element
any_gt|>Array value contains an element greater than given
any_gteq|>=Array value contains an element greater than or equal to given
any_lt|<Array value contains an element less than given
any_lteq|<=Array value contains an element less than or equal to given
all_gt&>All elements of array value are greater than given
all_gteq&>=All elements of array value are greater than or equal to given
all_lt&<All elements of array value are less than given
all_lteq&<=All elements of array value are less than or equal to given
size_eq#=Array value size is equal to given
size_not_eq#!=Array value size is not equal to given
size_gt#>Array value size is greater than given
size_gteq#>=Array value size is greater than or equal to given
size_lt#<Array value size is less than given
size_lteq#<=Array value size is less than or equal to given
DateTime
OperationOperatorDescription
eq=Value is equal to given
not_eq!=Value is not equal to given
gt>Value is greater than given
gteq>=Value is greater than or equal to given
lt<Value is less than given
lteq<=Value is greater than or equal to given
DateTimeArray
OperationOperatorDescription
include|=Array value includes given element
not_include!|=Array value doesn't include given element
any_gt|>Array value contains an element greater than given
any_gteq|>=Array value contains an element greater than or equal to given
any_lt|<Array value contains an element less than given
any_lteq|<=Array value contains an element less than or equal to given
all_gt&>All elements of array value are greater than given
all_gteq&>=All elements of array value are greater than or equal to given
all_lt&<All elements of array value are less than given
all_lteq&<=All elements of array value are less than or equal to given
size_eq#=Array value size is equal to given
size_not_eq#!=Array value size is not equal to given
size_gt#>Array value size is greater than given
size_gteq#>=Array value size is greater than or equal to given
size_lt#<Array value size is less than given
size_lteq#<=Array value size is less than or equal to given
Decimal
OperationOperatorDescription
eq=Value is equal to given
not_eq!=Value is not equal to given
gt>Value is greater than given
gteq>=Value is greater than or equal to given
lt<Value is less than given
lteq<=Value is greater than or equal to given
DecimalArray
OperationOperatorDescription
include|=Array value includes given element
not_include!|=Array value doesn't include given element
any_gt|>Array value contains an element greater than given
any_gteq|>=Array value contains an element greater than or equal to given
any_lt|<Array value contains an element less than given
any_lteq|<=Array value contains an element less than or equal to given
all_gt&>All elements of array value are greater than given
all_gteq&>=All elements of array value are greater than or equal to given
all_lt&<All elements of array value are less than given
all_lteq&<=All elements of array value are less than or equal to given
size_eq#=Array value size is equal to given
size_not_eq#!=Array value size is not equal to given
size_gt#>Array value size is greater than given
size_gteq#>=Array value size is greater than or equal to given
size_lt#<Array value size is less than given
size_lteq#<=Array value size is less than or equal to given
Enum
OperationOperatorDescription
eq=Value is equal to given
not_eq!=Value is not equal to given
EnumArray
OperationOperatorDescription
include|=Array value includes given element
not_include!|=Array value doesn't include given element
size_eq#=Array value size is equal to given
size_not_eq#!=Array value size is not equal to given
size_gt#>Array value size is greater than given
size_gteq#>=Array value size is greater than or equal to given
size_lt#<Array value size is less than given
size_lteq#<=Array value size is less than or equal to given
Integer
OperationOperatorDescription
eq=Value is equal to given
not_eq!=Value is not equal to given
gt>Value is greater than given
gteq>=Value is greater than or equal to given
lt<Value is less than given
lteq<=Value is greater than or equal to given
IntegerArray
OperationOperatorDescription
include|=Array value includes given element
not_include!|=Array value doesn't include given element
any_gt|>Array value contains an element greater than given
any_gteq|>=Array value contains an element greater than or equal to given
any_lt|<Array value contains an element less than given
any_lteq|<=Array value contains an element less than or equal to given
all_gt&>All elements of array value are greater than given
all_gteq&>=All elements of array value are greater than or equal to given
all_lt&<All elements of array value are less than given
all_lteq&<=All elements of array value are less than or equal to given
size_eq#=Array value size is equal to given
size_not_eq#!=Array value size is not equal to given
size_gt#>Array value size is greater than given
size_gteq#>=Array value size is greater than or equal to given
size_lt#<Array value size is less than given
size_lteq#<=Array value size is less than or equal to given
Text
OperationOperatorDescription
eq=Value is equal to given
not_eq!=Value is not equal to given
start_with^Value starts with given substring
end_with$Value ends with given substring
contain~Value contains given substring
not_start_with!^Value doesn't start with given substring
not_end_with!$Value doesn't end with given substring
not_contain!~Value doesn't contain given substring
istart_with^*Value starts with given substring (case-insensitive)
iend_with$*Value ends with given substring (case-insensitive)
icontain~*Value contains given substring (case-insensitive)
not_istart_with!^*Value doesn't start with given substring (case-insensitive)
not_iend_with!$*Value doesn't end with given substring (case-insensitive)
not_icontain!~*Value doesn't contain given substring (case-insensitive)
TextArray
OperationOperatorDescription
include|=Array value includes given element
not_include!|=Array value doesn't include given element
any_start_with|^Array value contains an element starts with given substring
all_start_with&^All elements of array value starts with given substring
size_eq#=Array value size is equal to given
size_not_eq#!=Array value size is not equal to given
size_gt#>Array value size is greater than given
size_gteq#>=Array value size is greater than or equal to given
size_lt#<Array value size is less than given
size_lteq#<=Array value size is less than or equal to given

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:

classPost < ApplicationRecordhas_active_fieldstypes: %i[booleandate_arrayintegeryour_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?#=> falseactive_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.rbActiveFields.configuredo |config|
config.field_base_class_name="CustomField"config.value_class_name="CustomValue"end# app/models/custom_field.rbclassCustomField < ApplicationRecordself.table_name="active_fields"# Ensure the model uses the correct tableincludeActiveFields::FieldConcern# Your custom code to extend Active Fieldsdeflabel=name.titleize# ...end# app/models/custom_value.rbclassCustomValue < ApplicationRecordself.table_name="active_fields_values"# Ensure the model uses the correct tableincludeActiveFields::ValueConcern# Your custom code to extend Active Valuesdeflabel=active_field.label# ...end

Note: To avoid STI (Single Table Inheritance) issues in environments with code reloading (config.enable_reloading = true), you should ensure that your custom model classes, along with all their superclasses and mix-ins, are non-reloadable. Follow these steps:

  • Move your custom model classes to a separate folder, such as app/models/active_fields.
  • If your custom model classes subclass ApplicationRecord (or other reloadable class) or mix-in reloadable modules, move those superclasses and modules to another folder, such as app/models/core.
  • After organizing your files, add the following code to your config/application.rb:
    # Disable custom models reloading to avoid STI issues.custom_models_dir="#{root}/app/models/active_fields"models_core_dir="#{root}/app/models/core"Rails.autoloaders.main.ignore(custom_models_dir,models_core_dir)Rails.autoloaders.once.collapse(custom_models_dir,models_core_dir)config.autoload_once_paths += [custom_models_dir,models_core_dir]config.eager_load_paths += [custom_models_dir,models_core_dir]
    This configuration disables namespaces for these folders and adds them to autoload_once_paths, ensuring they are not reloaded.

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.rbActiveFields.configuredo |config|
# The first argument - field type name, the second - field class nameconfig.register_field:ip,"IpField"end# app/models/ip_field.rbclassIpField < ActiveFields.config.field_base_class# Configure the fieldacts_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},finder: {# Optionalclass_name: "IpFinder",},)# Store specific attributes in `options`store_accessor:options,:required,:strip# You can use built-in casters to cast your options%i[requiredstrip].eachdo |column|
define_method(column)doActiveFields::Casters::BooleanCaster.new.deserialize(super())enddefine_method(:"#{column}?")do
!!public_send(column)enddefine_method(:"#{column}=")do |other|
super(ActiveFields::Casters::BooleanCaster.new.serialize(other))endendprivate# This method allows you to assign default values to your options.# It is automatically executed within the `after_initialize` callback.defset_defaultsself.required ||= falseself.strip ||= trueendend

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.rbActiveFields.configuredo |config|
config.register_field:ip_array,"IpArrayField"end# app/models/ip_array_field.rbclassIpArrayField < ActiveFields.config.field_base_classacts_as_active_field(array: true,validator: {class_name: "IpArrayValidator",options: ->{{min_size: min_size,max_size: max_size}},},caster: {class_name: "IpArrayCaster",},finder: {# Optionalclass_name: "IpArrayFinder",},)# ...end

Note: Similar to custom model classes, you should disable code reloading for custom Active Field type models. Place them in the app/models/active_fields folder too.

For each custom Active Field type, you must define a validator, a caster and optionally a finder:

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 ActiveModelerrors.add method.

# lib/ip_validator.rb (or anywhere you want)classIpValidator < ActiveFields::Validators::BaseValidatorprivatedefperform_validation(value)ifvalue.nil?ifoptions[:required]errors << :required# type onlyendelsifvalue.is_a?(String)unlessvalue.match?(Resolv::IPv4::Regex)errors << [:invalid,message: "doesn't match the IPv4 format"]# type with options endelseerrors << :invalidendendend

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)classIpCaster < ActiveFields::Casters::BaseCasterdefserialize(value)value=value&.to_svalue=value&.stripifoptions[:strip]valueenddefdeserialize(value)value=value&.to_svalue=value&.stripifoptions[:strip]valueendend

Finder

To create your custom finder, you should define a class that inherits from one of the following base classes:

  • ActiveFields::Finders::SingularFinder - for singular values,
  • ActiveFields::Finders::ArrayFinder - for array values,
  • ActiveFields::Finders::BaseCaster - if you don’t need built-in helper methods.

Finder classes include a DSL for defining search operations and provide helper methods to simplify query building. Explore the source code to discover all these methods.

# lib/ip_finder.rb (or anywhere you want)classIpFinder < ActiveFields::Finders::SingularFinderoperation:eq,operator: "="do |value|
scope.where(eq(casted_value_field("text"),cast(value)))# Equivalent to:# if value.is_a?(TrueClass) || value.is_a?(FalseClass) || value.is_a?(NilClass)# scope.where("CAST(active_fields_values.value_meta ->> 'const' AS text) IS ?)", cast(value))# else# scope.where("CAST(active_fields_values.value_meta ->> 'const' AS text) = ?)", cast(value))# endendoperation:not_eq,operator: "!="do |value|
scope.where(not_eq(casted_value_field("text"),cast(value)))enddefcast(value)IpCaster.new.deserialize(value)endend# lib/ip_array_finder.rb (or anywhere you want)classIpArrayFinder < ActiveFields::Finders::ArrayFinderoperation:include,operator: "|="do |value|
scope.where(value_match_any("==",cast(value)))# Equivalent to:# scope.where("jsonb_path_exists(active_fields_values.value_meta -> 'const', ?, ?)", "$[*] ? (@ == $value)", { value: cast(value) }.to_json)endoperation:not_include,operator: "!|="do |value|
scope.where.not(value_match_any("==",cast(value)))endoperation:size_eq,operator: "#="do |value|
scope.where(value_size_eq(value))# Equivalent to:# scope.where("jsonb_array_length(active_fields_values.value_meta -> 'const') = ?", value&.to_i)endoperation:size_not_eq,operator: "#!="do |value|
scope.where(value_size_not_eq(value))endoperation:size_gt,operator: "#>"do |value|
scope.where(value_size_gt(value))endoperation:size_gteq,operator: "#>="do |value|
scope.where(value_size_gteq(value))endoperation:size_lt,operator: "#<"do |value|
scope.where(value_size_lt(value))endoperation:size_lteq,operator: "#<="do |value|
scope.where(value_size_lteq(value))endprivatedefcast(value)caster=IpCaster.newcaster.serialize(caster.deserialize(value))end# This method must be defined to utilize the `value_match_any` and `value_match_all` helper methods in your class.# It should return a valid JSONPath expression for use in PostgreSQL jsonb query functions.defjsonpath(operator)="$[*] ? (@ #{operator} $value)"end

Once defined, every Active Value of this type will support the specified search operations!

# Find customizablesAuthor.where_active_fields([{name: "main_ip",operator: "eq",value: "127.0.0.1"},{n: "all_ips",op: "#>=",v: 5},{name: "all_ips",operator: "|=",value: "0.0.0.0"},])# Find Active ValuesIpFinder.new(active_field: ip_active_field).search(op: "eq",value: "127.0.0.1")IpArrayFinder.new(active_field: ip_array_active_field).search(op: "#>=",value: 5)

Multi-tenancy (scoping)

The scoping feature enables multi-tenancy or context-based field definitions per model. It allows you to define different sets of Active Fields for different scopes (e.g., different tenants, organizations, or contexts).

How it works:

  • Pass a scope_method parameter to has_active_fields to enable scoping for a Customizable model. The method should return a value that identifies the scope (e.g., tenant_id, organization_id).
  • The scope method's return value is automatically converted to a string and exposed as active_fields_scope on each Customizable record. This value is used to match against Active Fieldscope values.
  • When an Active Field has scope = nil (global field), it is available to all Customizable records, regardless of their scope value.
  • When an Active Field has a scope != nil (scope field), it is only available to Customizable records where active_fields_scope matches the scope.
classUser < ApplicationRecordhas_active_fieldsscope_method: :tenant_idend# Global active field (available to all users)ActiveFields::Field::Text.create!(name: "note",customizable_type: "User",scope: nil,)# Scoped active field (only available to users with tenant_id = "tenant_1")ActiveFields::Field::Integer.create!(name: "age",customizable_type: "User",scope: "tenant_1",)# Scoped active field (only available to users with tenant_id = "tenant_2")ActiveFields::Field::Date.create!(name: "registered_on",customizable_type: "User",scope: "tenant_2",)# Usageuser_1=User.create!(tenant_id: "tenant_1")user_1.active_fields# Returns `note` and `age`user_2=User.create!(tenant_id: "tenant_2")user_2.active_fields# Returns `note` and `registered_on`user_3=User.create!(tenant_id: nil)user_3.active_fields# Returns only `note`# Query with scopeUser.active_fields# Returns only `note`User.active_fields(scope: "tenant_1")# Returns `note` and `age`User.where_active_fields(filters)# Search by global fields only (`note`)User.where_active_fields(filters,scope: "tenant_1")# Search by `note` and `registered_on`

Handling scope changes:

If you change the scope value of a Customizable record (e.g., changing tenant_id), you must manually destroy Active Values that are no longer available for that record. The gem does not automatically handle this because the scope_method implementation is up to you, and therefore its change tracking is your responsibility. However, there is a helper method that you could use to clear the Active Values list: clear_unavailable_active_values.

Example 1:scope_method is a single database column.

classUser < ApplicationRecordhas_active_fieldsscope_method: :tenant_idafter_update:clear_unavailable_active_values,if: :saved_change_to_tenant_id?end

Example 2:scope_method is a computed value from multiple columns.

classUser < ApplicationRecordhas_active_fieldsscope_method: :tenant_and_department_scope# The scope method should return a stringdeftenant_and_department_scope"#{tenant_id}-#{department_id}"endafter_update:clear_unavailable_active_values,if: :tenant_and_department_scope_changed?privatedeftenant_and_department_scope_changed?saved_change_to_tenant_id? || saved_change_to_department_id?endend

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

  1. This gem requires PostgreSQL and is not designed to support other database systems.

  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

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 toactive_field.name# Identifier of this Active Field, it should be unique in scope of customizable_typeactive_field.default_value_meta# JSON column declaring the default value. Consider using `default_value` insteadactive_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 Fieldactive_field.array?# Returns whether the Active Field type is an arrayactive_field.value_validator_class# Class used for values validationactive_field.value_validator# Validator object that performs values validationactive_field.value_caster_class# Class used for values castingactive_field.value_caster# Caster object that performs values castingactive_field.customizable_model# Customizable model classactive_field.type_name# Identifier of the type of this Active Field (instead of class name)active_field.available_customizable_types# Available Customizable types for this Active Field# Scopes:ActiveFields::Field::Boolean.for("Post")# Collection of Active Fields registered for the specified Customizable typeActiveFields::Field::Integer.for("User",scope: "main_tenant")# Collection of Active Fields available for the specified Customizable type with given scope

Values API

active_value=ActiveFields::Value.take# Associations:active_value.active_field# `belongs_to` association with the associated Active Fieldactive_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 Valueactive_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 available for this recordcustomizable.active_fields_scope# Scope value for this recordPost.active_fields# Collection of Active Fields available for this modelUser.active_fields(scope: "main_tenant")# Collection of Active Fields available for this model and given scopePost.allowed_active_fields_type_names# Active Fields type names allowed for this Customizable modelPost.allowed_active_fields_class_names# Active Fields class names allowed for this Customizable modelUser.active_fields_scope_method# Scope method for this model# 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# Destroys Active Values that are no longer associated with Active Fields available for this record.# Call this method after changing the scope value to ensure all Active Values are valid.customizable.clear_unavailable_active_values# Query Customizables by Active Values.Post.where_active_fields([{name: "integer_array",operator: "any_gteq",value: 5},# symbol keys{"name"=>"text",operator: "=","value"=>"Lasso"},# string keys{n: "boolean",op: "!=",v: false},# compact form (string or symbol keys)],)# Search with given scope.User.where_active_fields(filters,scope: "main_tenant",)

Global Config

ActiveFields.config# Access the plugin's global configurationActiveFields.config.fields# Registered Active Fields types (type_name => field_class)ActiveFields.config.field_base_class# Base class for all Active FieldsActiveFields.config.field_base_class_name# Name of the Active Fields base classActiveFields.config.value_class# Active Values classActiveFields.config.value_class_name# Name of the Active Values classActiveFields.config.field_base_class_changed?# Check if the Active Fields base class has changedActiveFields.config.value_class_changed?# Check if the Active Values class has changedActiveFields.config.type_names# Registered Active Fields type namesActiveFields.config.type_class_names# Registered Active Fields class namesActiveFields.config.register_field(:ip,"IpField")# Register a custom Active Field type

Registry

ActiveFields.registry.add(:boolean,"Post")# Stores relation between Active Field type and customizable type. Please do not use directly.ActiveFields.registry.customizable_types_for(:boolean)# Returns Customizable types that allow provided Active Field type nameActiveFields.registry.field_type_names_for("Post")# Returns Active Field type names, allowed for given Customizable type

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.

License

The gem is available as open source under the terms of the MIT License.

About

Add custom fields to ActiveRecord models at runtime.

Resources

Stars

59 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages