Search code examples
ruby-on-rails-4virtual-attribute

Rails 4: composed_of mapping to JSON store attribute


I have the following models set up

# task.rb
class Task << AR
  # everything all task objects have in common
end

# login_request.rb
class Tasks::LoginRequest < Task
  store :data, accessors: [:email, :first_name, :last_name, :expires_at]

  composed_of :valid_until, class_name: 'DateTime', mapping: %w(expires_at to_s), constructor: Proc.new { |date| (date && date.to_datetime) || DateTime.now }, converter: Proc.new { |value| value.to_s.to_datetime }
end

I'm using the datetime_select helper in my form:

# _form.html.haml
= f.datetime_select :valid_until

This works quite well, but when I call update in my controller with the submitted form data I get the following error message:

1 error(s) on assignment of multiparameter attributes [error on assignment [2014, 4, 2, 9, 48] to valid_until (can't write unknown attribute 'expires_at')]

So, I'm guessing the updated method tries to manipulate the attributes hash directly, but obviously it can't find the attribute expires_at, since it's a simple accessor method of the JSON column data.

I know I could simply add this field to the DB and it would probably work - although there's no need then to have a composed_of statement. But I'd rather not go this route, because not every task has a expires_at column.

How can I overcome this error? Or did I miss something?


Solution

  • Currently compose_of is not supporting this scenario since it writes directly to attributes that are assumed to be in the database. I wrote a tweaked compose_of version that does (based of Rails 4.0.2 version)

    Putting this in initialize folder:

    #/initialize/support_store_in_composed_of.rb
    module ActiveRecord
      module Aggregations
        extend ActiveSupport::Concern
    
        def clear_aggregation_cache #:nodoc:
          @aggregation_cache.clear if persisted?
        end
    
        module ClassMethods
    
          def composed_of_with_store_support(part_id, options = {})
            options.assert_valid_keys(:class_name, :mapping, :allow_nil, :constructor, :converter, :store)
    
            name        = part_id.id2name
            class_name  = options[:class_name]  || name.camelize
            mapping     = options[:mapping]     || [ name, name ]
            mapping     = [ mapping ] unless mapping.first.is_a?(Array)
            allow_nil   = options[:allow_nil]   || false
            constructor = options[:constructor] || :new
            converter   = options[:converter]
    
            reader_method(name, class_name, mapping, allow_nil, constructor, options[:store])
            writer_method(name, class_name, mapping, allow_nil, converter, options[:store])
    
            create_reflection(:composed_of, part_id, nil, options, self)
          end
    
          private
            def reader_method(name, class_name, mapping, allow_nil, constructor, store=nil)
              define_method(name) do
                if @aggregation_cache[name].nil? && (!allow_nil || mapping.any? {|pair| !read_attribute(pair.first).nil? })
                  if store.present?
                    attrs = mapping.collect {|pair| send(pair.first)}
                  else
                    attrs = mapping.collect {|pair| read_attribute(pair.first)}
                  end
    
                  object = constructor.respond_to?(:call) ?
                  constructor.call(*attrs) :
                  class_name.constantize.send(constructor, *attrs)
    
                  @aggregation_cache[name] = object
                end
                @aggregation_cache[name]
              end
            end
    
            def writer_method(name, class_name, mapping, allow_nil, converter, store=nil)
    
              define_method("#{name}=") do |part|
                klass = class_name.constantize
                unless part.is_a?(klass) || converter.nil? || part.nil?
                  part = converter.respond_to?(:call) ? converter.call(part) : klass.send(converter, part)
                end
                if part.nil? && allow_nil
                  mapping.each { |pair| self[pair.first] = nil }
                  @aggregation_cache[name] = nil
                else
                  if store.present?      
                    mapping.each { |pair| send("#{pair.first}=", part.send(pair.last)) } 
                  else
                    mapping.each { |pair| self[pair.first] = part.send(pair.last) }
                  end
                  @aggregation_cache[name] = part.freeze
    
                end
              end
            end
        end
      end
    end
    

    And using it like this would solve your problem.

    class Task < ActiveRecord::Base
    
      store :data, accessors: [:email, :first_name, :last_name, :expires_at]
    
    
      composed_of_with_store_support :valid_until,  class_name: 'DateTime', mapping: %w(expires_at to_s),
       constructor: Proc.new { |date| (date && date.to_datetime) || DateTime.now },
        converter: Proc.new { |value| value.to_s.to_datetime },
        store: true
    
    end