0

I have a Media model that has a bunch of standard metadata attributes and is persisted in the database as normal. What I want to do now is to add some configurable metadata attributes to this model on top of the existing attributes. A list of these attributes will be defined in a config file and loaded in at runtime. They'll be stored in the database in a different table as a series of property-value pairs with an association to the main model.

So, my code currently is,

class Media < ActiveRecord::Base
  has_many :custom_metadata

  attr_accessible :title, :language, :copyright, :description
end

and

class CustomMetadata < ActiveRecord::Base
  belongs_to :media

  attr_accessible :name, :value
end

What I want to do is to be able to access and update the custom metadata attributes on the Media model in the same way as the standard metadata attributes. For example, if the custom metadata attributes are called publisher and contributor, then I want to access them in the Media model as @media.publisher and @media.contributor even though they will be in the association @media.custom_metadata where its values would be something like [{:name => 'publisher', :value => 'Fred'}, {:name => 'contributor', :value => 'Bill'}]

It seems to be that virtual attributes would be the best way of achieving this but all of the examples I can find of people using virtual attributes is where the names of the attributes are static and known rather than dynamic from a run-time configuration, so they can define methods such as publisher and publisher= which would then contain code to write to the relevant associated property-value record.

I can define attributes on the class with attr_accessor *Settings.custom_metadata_fields (assuming Settings.custom_metadata_fields returns [:publisher, :contributor]) and also allow mass-assignment using a similar technique with attr_accessible.

The part I get stuck on is how to populate the virtual attributes from the association when loading the data from the record and then, in reverse, how to pass the data in the virtual attributes back into the association before the record is saved.

The two ways I currently see this working are either using method_missing or attribute_missing, or perhaps via initialize and a before_save callback? In either case, I'm not sure how I would define it given that my model has a mix of normal attributes and virtual attributes.

Any suggestions?

richard
  • 1,565
  • 2
  • 18
  • 35

2 Answers2

1

Using callbacks sounds reasonable. What database are you using? If PostgreSQL, maybe you should take a look at HStore extension (http://www.postgresql.org/docs/9.2/static/hstore.html) it will perform better, and there are some gems making it easy to use.

cthulhu
  • 3,749
  • 1
  • 21
  • 25
  • Thanks for the suggestion. Unfortunately we're standardised here on MySQL as our database so changing to PostgreSQL is not really an option. How would you write the callbacks given the mixture of normal and virtual attributes? – richard May 23 '13 at 23:12
  • The callbacks suggestion was a good one. I posted the code on how I implemented the callbacks in the answer below. – richard May 24 '13 at 00:17
0

After looking into the callbacks some more I discovered the after_initialize callback and this is much better than using the initialize method as I'd first planned.

In the end, this was the final code for the Media model and I didn't change anything in the CustomMetadata model from what I defined in the question,

class Media < ActiveRecord::Base
  has_many :custom_metadata

  attr_accessor *Settings.custom_metadata_fields

  attr_accessible :title, :language, :copyright, :description
  attr_accessible *Settings.custom_metadata_fields

  validates_presence_of *Settings.required_custom_fields

  before_save :save_custom_metadata
  after_initialize :load_custom_metadata

  def load_custom_metadata
    MediaMetadata.custom_all_fields.each do |field|
      custom_record = custom_metadata.where(:name => field.to_s).first_or_initialize()
      send("#{field}=", custom_record.value)
    end
  end

  def save_custom_metadata
    MediaMetadata.custom_all_fields.each do |field|
      custom_record = custom_metadata.where(:name => field.to_s).first_or_initialize()
      custom_record.value = send(field)
      if custom_record.value.blank?
        custom_record.destroy
      else
        custom_record.save
      end
    end
  end
end

This solution had a couple of nice benefits. Firstly, it doesn't affect any of the normal attributes on the Media model. Secondly, only custom metadata with actual values are stored in the custom metadata table. If the value is blank, the record is removed completely. Finally, I can use standard validations on the model attributes as shown for my required custom metadata attributes.

richard
  • 1,565
  • 2
  • 18
  • 35
  • One more thought - are you going to do SQL queries against CustomMetadata records ? If not, maybe you could just serialize a hash of attributes into single column using #serialize (http://apidock.com/rails/ActiveRecord/Base/serialize/class). The point is that using one-to-many relationship may cause performance problems. AND using after_initialize is not triggered when you query multiple records using Media#find - take a look at after_find hook. – cthulhu May 24 '13 at 10:01