Leafy

A toolkit for dynamic custom attributes for Ruby applications.
Table of Contents
- Features
- Supported Data Types
- Installation
- Requirements
- Quick Start
- Configuration
- Custom Field Types
- Best Practices
- API Reference
- Troubleshooting
- Contributing
- License
Features
- Simple modular design - Load only what you need
- JSON-backed storage - Store custom fields as JSON with your models, avoiding expensive JOIN queries
- PostgreSQL support - Native support for
jsonandjsonbcolumn types - Type safety - Automatic type inference and validation for custom field data
- Extensible - Add your own custom field types with converters
- Thread-safe - Safe for concurrent access
Supported Data Types
string- String valuesinteger- Integer numbersdouble- Floating point numbersdatetime-Timeinstances (stored as ISO8601)date-Dateinstances (stored as ISO8601)bool- Boolean values (true/false)dummy- Pass-through type (no conversion)
Installation
Add Leafy to your Gemfile:
gem 'leafy-ruby'
Then run:
bundle install
Requirements
- Ruby 2.7 or higher
- ActiveRecord 6.0+ (optional, only if using ActiveRecord integration)
Quick Start
Quick Start
Plain Ruby Objects (PORO)
For plain Ruby objects, include the :poro mixin and provide a leafy_data accessor:
class SchemaHost
include Leafy::Mixin::Schema[:poro]
attr_accessor :leafy_data
end
class FieldsHost
include Leafy::Mixin::Fields[:poro]
attr_accessor :leafy_data
attr_accessor :leafy_fields
end
Schema mixin provides:
#leafy_fields- Returns aLeafy::Schemainstance for iterating through custom field definitions#leafy_fields=- Schema setter method#leafy_fields_attributes=- Nested attributes setter method
Fields mixin provides:
#leafy_values- Returns a hash of field values#leafy_values=- Assigns custom field values#leafy_field_values- Returns aLeafy::FieldValueCollectionfor fine-grained control
Important: Leafy is stateless. Changing a Schema instance won't automatically update your model. You must explicitly assign the schema or attributes to persist changes.
Example Usage
# Create a schema host
host = SchemaHost.new
# Define custom fields using attributes
host.leafy_fields_attributes = [
{
name: "Field 1",
type: :integer,
id: "id_1",
metadata: { default: 1, placeholder: "Enter an integer", required: true }
},
{
name: "Field 2",
type: :string,
id: "id_2",
metadata: { default: "", placeholder: "Enter value" }
},
{
name: "Field 3",
type: :datetime,
id: "id_3",
metadata: { order: 10000 }
}
]
# Or build the schema manually
field_1 = Leafy::Field.new(
name: "Field 1",
type: :integer,
id: "id_1",
metadata: { default: 1, placeholder: "Enter an integer", required: true }
)
field_2 = Leafy::Field.new(
name: "Field 2",
type: :string,
id: "id_2",
metadata: { default: "", placeholder: "Enter value" }
)
field_3 = Leafy::Field.new(
name: "Field 3",
type: :datetime,
id: "id_3",
metadata: { order: 10000 }
)
schema = Leafy::Schema.new
schema << field_1
schema << field_2
schema << field_3
host.leafy_fields = schema
# Use the schema with a fields host
target = FieldsHost.new
target.leafy_fields = host.leafy_fields
# Initial values are nil
target.leafy_values
# => { "id_1" => nil, "id_2" => nil, "id_3" => nil }
# Set values (unknown fields are ignored)
target.leafy_values = {
"id_1" => 123,
"id_2" => "test",
"id_3" => Time.new(2018, 10, 10, 10, 10, 10, "+03:00"),
"junk" => "ignored"
}
target.leafy_values
# => { "id_1" => 123, "id_2" => "test", "id_3" => 2018-10-10 07:10:10 UTC }
ActiveRecord Integration
1. Create a migration
class AddLeafyData < ActiveRecord::Migration[6.1]
def change
# For text/string storage (all databases)
add_column :schema_hosts, :leafy_data, :text, null: false, default: "{}"
add_column :fields_hosts, :leafy_data, :text, null: false, default: "{}"
# For PostgreSQL with native JSON support (recommended)
# add_column :schema_hosts, :leafy_data, :jsonb, null: false, default: {}
# add_column :fields_hosts, :leafy_data, :jsonb, null: false, default: {}
# add_index :schema_hosts, :leafy_data, using: :gin
# add_index :fields_hosts, :leafy_data, using: :gin
end
end
2. Update your models
class SchemaHost < ActiveRecord::Base
include Leafy::Mixin::Schema[:active_record]
end
class FieldsHost < ActiveRecord::Base
include Leafy::Mixin::Fields[:active_record]
belongs_to :schema_host, required: true
delegate :leafy_fields, to: :schema_host
end
3. Usage
# Create a schema host with custom fields
host = SchemaHost.create(
leafy_fields_attributes: [
{
name: "Field 1",
type: :integer,
id: "id_1",
metadata: { default: 1, placeholder: "Enter an integer", required: true }
},
{
name: "Field 2",
type: :string,
id: "id_2",
metadata: { default: "", placeholder: "Enter value" }
},
{
name: "Field 3",
type: :datetime,
id: "id_3",
metadata: { order: 10000 }
}
]
)
# Create a fields host and set values
target = FieldsHost.create(schema_host: host)
target.leafy_values
# => { "id_1" => nil, "id_2" => nil, "id_3" => nil }
target.leafy_values = {
"id_1" => 123,
"id_2" => "test",
"id_3" => Time.new(2018, 10, 10, 10, 10, 10, "+03:00"),
"junk" => "ignored"
}
target.save!
target.reload
target.leafy_values
# => { "id_1" => 123, "id_2" => "test", "id_3" => 2018-10-10 07:10:10 UTC }
Configuration
Rails Setup
If you get a NameError: uninitialized constant error in Rails, create an initializer:
# config/initializers/leafy.rb
require 'leafy'
Custom Coder
By default, Leafy uses the JSON module for serialization. You can configure a custom coder (e.g., Oj for better performance):
# config/initializers/leafy.rb
require 'leafy'
require 'oj'
class OjCoder
def dump(data)
Oj.dump(data)
end
def load(data)
Oj.load(data)
end
end
Leafy.configure do |config|
config.coder = OjCoder.new
end
Note: Your coder must implement both #dump and #load instance methods.
Custom Field Types
Leafy allows you to add your own custom data types by registering converters.
Creating a Converter
A converter is responsible for serializing (dump) and deserializing (load) your custom type. It must implement both #dump and #load instance methods:
class MoneyConverter
def dump(value)
return nil if value.nil?
# Convert Money object to cents for storage
value.cents.to_s
end
def load(value)
return nil if value.nil?
# Convert cents back to Money object
Money.new(value.to_i)
end
end
# Register the converter
Leafy.register_converter(:money, MoneyConverter.new)
Using Custom Types
schema = Leafy::Schema.new
schema << Leafy::Field.new(
name: "Price",
type: :money, # Your custom type
id: "price_field"
)
host.leafy_fields = schema
target.leafy_fields = schema
target.leafy_values = { "price_field" => Money.new(1999) }
target.leafy_values["price_field"]
# => #<Money cents=1999>
Best Practices
Field IDs
- Use stable, unique IDs for fields (UUIDs are generated automatically if not provided)
- Don't change field IDs after data has been stored
- Field IDs are the key for storing values - changing them will lose existing data
Metadata
The metadata hash is completely flexible - store any additional information you need:
metadata: {
default: "some default",
placeholder: "Help text",
required: true,
order: 100,
validation_rules: { min: 0, max: 100 },
custom_property: "anything you want"
}
Performance Tips
- Use PostgreSQL
jsonbcolumns for better query performance and indexing - Keep the number of custom fields reasonable (< 100 per model)
- Use GIN indexes on jsonb columns for field queries
- Consider using Oj or other fast JSON libraries as your coder
Thread Safety
Leafy's class-level configuration and converter registry are thread-safe. You can safely register converters and configure Leafy from multiple threads or in multi-threaded web servers (Puma, Sidekiq, etc.).
API Reference
Schema Methods
Leafy::Schema.new(fields_array)- Create a new schema#push(field)/#<<(field)- Add a field to the schema#[](identifier)- Find a field by ID#ids- Get array of all field IDs#each- Iterate through fields (Enumerable)#serializable_hash- Convert to hash representationLeafy::Schema.dump(schema)- Serialize to JSON stringLeafy::Schema.load(json_string)- Deserialize from JSON string
Field Methods
Leafy::Field.new(name:, type:, id:, metadata:)- Create a new field#name- Field display name#type- Field type symbol#id- Unique field identifier#metadata- Custom metadata hash#serializable_hash- Convert to hash representation
FieldValueCollection Methods
#values- Get hash of all field values#values=- Set field values from hash#each- Iterate through field values (Enumerable)#[](index)- Access by array index#size/#count- Number of fields
Troubleshooting
NameError: uninitialized constant Leafy
Solution: Add require 'leafy' to your initializer file.
Values not persisting in ActiveRecord
Solution: Make sure you call save or save! after setting leafy_values. Leafy setters update the model but don't automatically save.
Custom converter not working
Solution: Ensure your converter:
- Implements both
#dumpand#loadas instance methods (not class methods) - Is registered before use:
Leafy.register_converter(:my_type, MyConverter.new) - Handles
nilvalues appropriately
Type mismatch errors
Solution: Converters will attempt to coerce values. For strict validation, implement it in your converter's #dump or #load methods.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/estepnv/leafy.
License
The gem is available as open source under the terms of the MIT License.