[Proposal] `ActiveRecord composed_of` Support KeywordArgument Constructor

ValueObject whose constructor is a keyword argument.

class Address
 attr_reader :street, :city

 def initialize(street:, city:)
   @street, @city = street, city
 end

  # some methods...
end

To use this ValueObject with compsoed_of requires this redundant configuration.

class Customer < ActiveRecord::Base
  composed_of :address,
              mapping: [%w(address_street street), %w(address_city city)],
              constructor: Proc.new { |address_street, address_city| Address.new(street: address_street, city: address_city) }

For example, how about adding a new option keyword_init.

The option name is based on keyword_init (option) of Struct, but there is a possibility of mismatch.

class Customer < ActiveRecord::Base
  composed_of :address,
              mapping: [%w(address_street street), %w(address_city city)],
              keyword_init: true

The implementation will look like this. https://gist.github.com/tmimura39/1ab5303f86cba4682c411cc30755e10d#file-keyword_argument_composed_of-rb-L245-L263

def reader_method(name, class_name, mapping, allow_nil, constructor)
  define_method(name) do
    if @aggregation_cache[name].nil? && (!allow_nil || mapping.any? { |key, _| !read_attribute(key).nil? })
      attrs = mapping.to_h { |entity_attr, value_object_attr| [value_object_attr.to_sym, read_attribute(entity_attr)] }
      object =
        if constructor.respond_to?(:call)
          constructor.call(*attrs.values)
        else
          if keyword_init
            class_name.constantize.send(constructor, **attrs)
          else
            class_name.constantize.send(constructor, *attrs.values)
          end
        end
      @aggregation_cache[name] = object
    end
    @aggregation_cache[name]
  end
end

What do you think?

1 Like

I think this is a great idea! I was considering an implementation for this, but was pleasantly surprised to see there was a pre-existing proposal here.

Supporting keyword arguments also plays nice with Ruby 3.2’s new Data class, which supports keyword arguments in its constructor by default. For example:

Point = Data.define(:x, :y)

class Pin < ActiveRecord::Base
  composed_of :point,
              mapping: { point_x: :x, point_y: :y },
              keyword_init: true
end