DataComposer

Gem Version

DataComposer provides a set of helpers to elegantly compose native Ruby Data objects into your ActiveRecord models.

This gem lets you turn this:

# db/migrate/xxxx.rb
add_column :products, :price_amount, :decimal
add_column :products, :price_currency, :string

# app/models/product.rb
Money = Data.define(:amount, :currency)
composed_of :price, class_name: "Money", mapping: [%w[price_amount amount], %w[price_currency currency]]

Into this:

# db/migrate/xxxx.rb
t.data_object :price, members: { amount: :decimal, currency: :string }

# app/models/product.rb
Money = Data.define(:amount, :currency)
compose_data :price, Money

Product.first.price.amount #=> 100.0

Core Concept

The philosophy is simple:

  1. Define Value Objects with Data.define: Use pure, native Ruby Data objects to represent concepts in your domain.
  2. Declare Schema in Migrations: Use the t.data_object helper to explicitly define the database columns required to store your value object.
  3. Compose in the Model: Use the compose_data helper in your model to map the database columns to your Data object.# DataComposer

Installation

Add this line to your application's Gemfile:

gem 'data_composer'

And then execute:

$ bundle install

Usage

Let's walk through an example of storing a Height value object, composed of an amount and a unit, on a User model.

Step 1: Define Your Value Object

Create a file for your value object. It's just a plain Ruby Data object—no special wrappers are needed.

# app/models/height.rb
Height = Data.define(:amount, :unit) do
  # You can add any relevant domain logic here
  def to_s
    "#{amount} #{unit}"
  end

  def in_inches
    unit == 'cm' ? (amount * 0.393701).round(2) : amount
  end
end

Step 2: Create a Migration

Generate a migration to add the columns to your table.

rails g migration AddHeightToUsers

Inside the migration file, use the t.data_object helper. You must explicitly define the member names and their corresponding database types.

# db/migrate/xxxx_add_height_to_users.rb
class AddHeightToUsers < ActiveRecord::Migration[7.0]
  def change
    change_table :users do |t|
      t.data_object :height,
                    members: { amount: :decimal, unit: :string },
                    null: false,
                    default: { amount: 0, unit: 'cm' }
    end
  end
end

When you run rails db:migrate, this will create two columns: height_amount (DECIMAL) and height_unit (STRING).

Step 3: Compose it in Your Model

In your ActiveRecord model, use compose_data to tie everything together. This method automatically infers that :height will be mapped from the height_amount and height_unit columns.

# app/models/user.rb
class User < ApplicationRecord
  compose_data :height, Height
end

Step 4: Use It!

Your User model now has a rich height attribute.

# Create a user with a Height object
user = User.create!(height: Height.new(amount: 180, unit: 'cm'))

# Access the object and its members
puts user.height
#=> #<data Height amount=180, unit="cm">

puts user.height.amount
#=> 180

puts user.height.to_s
#=> "180 cm"

puts user.height.in_inches
#=> 70.87

# The object is transparently saved to the database columns
user.reload
puts user.read_attribute(:height_amount) #=> 180
puts user.read_attribute(:height_unit)   #=> "cm"

# Assigning nil or invalid data also works as expected
user.update!(height: nil)
puts user.height #=> nil

Advanced Features

Default Values

You can specify default values for individual members:

t.data_object :price,
              members: { amount: :decimal, currency: :string },
              default: { amount: 0, currency: "USD" }

Multiple Data Objects

You can have multiple data objects on the same model:

class Product < ApplicationRecord
  compose_data :price, Money
  compose_data :dimensions, Dimensions
  compose_data :weight, Weight
end

Custom Validation

Data objects support standard ActiveRecord validations:

class User < ApplicationRecord
  compose_data :height, Height

  validates :height, presence: true
  validate :height_must_be_reasonable

  private

  def height_must_be_reasonable
    return unless height

    errors.add(:height, "must be positive") if height.amount <= 0
    errors.add(:height, "seems too tall") if height.amount > 300
  end
end

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/your-username/data_composer.

License

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