Project

leafy-ruby

0.0
No release in over 3 years
Low commit activity in last 3 years
Leafy is toolkit that allows you to integrate dynamic custom attributes functionality into your ruby application. It provides high level API you can use for any suitable use-case. It ships with several basic data types and allows you to add your own data type converters.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 6.1.0
< 2.0
~> 3.0
~> 0.17.1
~> 1.4
 Project Readme

Leafy CI codecov Maintainability

A toolkit for dynamic custom attributes for Ruby applications.

Table of Contents

  • Features
  • Supported Data Types
  • Installation
  • Requirements
  • Quick Start
    • Plain Ruby Objects (PORO)
    • ActiveRecord Integration
  • 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 json and jsonb column 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 values
  • integer - Integer numbers
  • double - Floating point numbers
  • datetime - Time instances (stored as ISO8601)
  • date - Date instances (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 a Leafy::Schema instance 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 a Leafy::FieldValueCollection for 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 jsonb columns 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 representation
  • Leafy::Schema.dump(schema) - Serialize to JSON string
  • Leafy::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:

  1. Implements both #dump and #load as instance methods (not class methods)
  2. Is registered before use: Leafy.register_converter(:my_type, MyConverter.new)
  3. Handles nil values 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.