polymorphs 25: total insanity branch

I merged and released the ActiveRecord compatibility branch today for has_many_polymorphs, which represents about a 60% rewrite and a 100% code audit. Custom finders! This is huge.

new features overview

  • Custom find-er support (:conditions, :order, :limit, :offset, and :group).
  • Custom conditions (same options as above) on the has_many_polymorphs macro itself, which also get propogated to the individual relationships in a reasonable way.
  • ActiveRecord-derived :has_many_polymorphs reflection type, which appears in .reflect_on_all_associations, so you can dynamically reason about the polymorphic relationship.
  • New extension parameters (:extend, :join_extend, :parent_extend). Toss around modules, procs, and blocks like you just don’t care.
  • Option support for double polymorphic relationships. Everything from single relationships, with the exception of :parent_extend, can be defined on one or both of the sides of the double join.
  • :ignore_duplicates option, which can be set to false if you want to associate more than once (or to handle duplicates with model validations).

Rails 1.2 or greater is now required.

custom finder usage

Some examples are in order. For instance, we can sort the target records by the join record’s creation date:

zoo = Zoo.find(:first)
zoo.animals.find(:all,
  :order => "animals_zoos.created_at ASC")

Or perhaps we have a decorated join model, and want to select on that, with a limit:

zoo.animals.find(:all, :limit => 2,
  :conditions =>
    "animals_zoos.last_cleaned IS NOT NULL")

We can even select on attributes of the targets, rather than the join, even though the targets are different models and don’t have overlapping fields. For example, we can order by the date the animals were born:

zoo.eaters.find(:all,
  :order => "IFNULL(monkeys.created_at,
  (IFNULL(elephants.created_at,
  (IFNULL(snakes.created_at))))) ASC") 

How can this work? In the polymorphic query, fields that aren’t present in a particular child table get nulled out, so you can conditionally traverse them for your query. This example is MySQL specific, but every RDBMS has a similar equivalent. You can use any custom logic on any set of fields you want. Perform math, truncate strings, whatever.

explanation of the optional parameters

There are a lot of options now, so I have made a table to explain which relationships will receive which parameters, what they mean, and what values are allowed. The general rule is that the polymorphic association receives everything, while the individual and join associations receive less, depending on how orthogonal they are to the polymorphic association.

Note that when I say “parent” and “child” I am referring to the polymorphic association’s owner class and target classes, and not to some kind of class inheritance. The owner and target could have an inheritance relationship, but it is irrelevant to this discussion.

single polymorphism parameters

key meaning/value affected classes
:from an array of symbols for the child classes the polymorph, and then is split to create the individual collections
:as a symbol for the relationship the parent acts as in the join the parent model, the join model
:through a symbol for the join model everything (the polymorph, the individual associations, the join associations, and the parent associations as seen from the children)
:join_class_
name
the name of the join model’s class the join model
:foreign_key the column for the parent’s id in the join the join model
:foreign_type_
key
the column for the parent’s class in the join, if the parent itself is polymorphic the join model, although usually only useful for double polymorphs
:polymorphic_
key
the column for the child’s id in the join the join model
:polymorphic_
type_key
the column for the child’s class in the join the join model
:dependent :destroy, :nullify, :delete_all how the join record gets treated on any associate delete (whether from the polymorph or from an individual collection); defaults to :destroy
:ignore_
duplicates
if true, will silently ignore pushing of already associated records the polymorph and individual associations; defaults to true
:rename_
individual_
collections
if true, all individual collections are prepended with the polymorph name, and the children’s parent collection becomes :as + “_of_” + polymorph; for example, zoos_of_animals the names of the individual collections everywhere
:extend one or an array of mixed modules and procs the polymorph, the individual collections
:join_extend one or an array of mixed modules and procs the join associations for both the parent and the children
:parent_extend one or an array of mixed modules and procs the parent associations as seen from the children
:table_aliases a hash of ambiguous table/column names to disambiguated temporary names the instantiation of the polymorphic query; never change this
:select a string containing the SELECT portion of the polymorphic SQL query the polymorph only; never change this
:conditions an array or string of conditions for the SQL WHERE clause everything
:order a string for the SQL ORDER BY clause everything
:group an array or string of conditions for the SQL GROUP BY clause the polymorph, the individual collections
:limit an integer the polymorph, the individual collections
:offset an integer the polymorph only
:uniq if true, the records returned are passed through a pure-Ruby uniq() before they are given to you the polymorph; almost never useful (inherited from has_many :through)

Additionally, if you pass a block to the macro, it gets converted to a Proc and added to :extend.

Phew. Actually, the only required parameter, aside from the association name, is :from.

double polymorphism parameters

Double polymorphism allows the following general keys: :conditions, :order, :limit, :offset, :extend, :join_extend, :dependent, :rename_individual_collections. The general keys get applied to both sides of the relationship. Say you have two sides like:

acts_as_double_polymorphic_join :mammals => [...],
                                :amphibians => [...]

If you want to :extend just the :amphibians, use the key :amphibians_extend. If you have both :extend and :amphibians_extend present, the :amphibians will receive the extensions from both. Technically the general extensions take precedence, but it doesn’t matter unless you are doing something totally bizarre with module inclusion callbacks.

macro conditions devolution

Above, I mentioned that if you set a :conditions parameter on the has_many_polymorphs macro, those same conditions are used on the individual class collections. However, the individual collections are regular has_many :through’s (more or less) and don’t query the superset of fields that a polymorphic SELECT has. How does that not explode?

The plugin is your friend, here. When it builds the conditions for the individual collections, it checks which tables and columns will be still available. If your :conditions or :order or :group contains fields from irrelevant target tables, the plugin will devolve them to nulls. Take this single polymorphism example:

has_many_polymorphs :animals,
  :from => [:monkeys, :elephants],
  :conditions => "monkeys.volume > 1 OR elephants.weight > 10"

The :conditions string will become "NULL > 1 OR elephants.weight > 10" when you are dealing with zoo.elephants, but "monkeys.volume > 1 OR NULL > 10" when you are dealing with zoo.monkeys.

new polymorphic reflection type

Just an example:

>> Tag.reflect_on_association(:taggables)
=> #<ActiveRecord::Reflection::PolymorphicReflection:0x2
  @active_record=Tag, @macro=:has_many_polymorphs,
  @join_class=Tagging, @options={:extend=>[],
  :join_class_name=>"Tagging", :dependent=>:destroy,
  :from=>[:categories, :recipes, :tags],
  :foreign_key=>"tagger_id", :join_extend=>[],
  :select=>"...", :as=>:tagger, :table_aliases=>{...},
  name:taggables

wishlist

Wait, what?

A few people asked if I had any wishlist or donation box set up so they could show their appreciation. So, I made an Amazon list and put some games and books on it:

Small price to pay :).

looking forward

We are moving closer and closer to supporting every possible polymorphic operation in O(1) time. The only big things left to implement are with_scope and infinite meta-:include (:include-ing polymorphs that :include other polymorphs). Also, I might roll in a tagging system generator, since people have shown so much interest in the flexible tagging that has_many_polymorphs supports.

Exciting times.

12 responses

  1. Hi

    I might miss something here but could you outline an example on how to find, lets say tagged posts of a certain tag collection.

    Lets say we would like the posts tagged with tags 1 and 4.

    tag = Tag.find(1)
    assets = tag.assets
    tag = Tag.find(4)
    assets << tag.assets
    assets.uniq!
    

    Or is there a another way you would recommend doing this?

    Cheers Mam

  2. The way you proposed is fastest, at least until I get arbitrary :include up and running.

    Tag.find([1,4]).map(&:assets).flatten.uniq
    
  3. You mention “:include-ing polymorphs that :include other polymorphs”.

    I’m am trying to include just one polymorph. Is this currently working, or is that part of the :include feature as well?

    I have:

    class Page < ActiveRecord::Base
      has_many_polymorphs :controls,
        :from => [:image_controls, :swf_controls, :text_controls],
            :through => :page_controls,
            :order => "page_controls.position ASC"
    end
    

    And when I do this:

    pages = Page.find(:all, :include => [:controls])
    

    I get this error:

    ActiveRecord::StatementInvalid: Mysql::Error: #42S22Unknown column 'page_controls.id' in 'field list': SELECT pages.`id` AS t0_r0, pages.`site_id` AS t0_r1, pages.`name` AS t0_r2, pages.`created_at` AS t0_r3, pages.`updated_at` AS t0_r4, page_controls.`id` AS t1_r0, page_controls.`page_id` AS t1_r1, page_controls.`control_id` AS t1_r2, page_controls.`control_type` AS t1_r3, page_controls.`position` AS t1_r4, page_controls.`created_at` AS t1_r5, page_controls.`updated_at` AS t1_r6 FROM pages
    

    Thanks for any help any this great plugin!

  4. Forest: yes, explicit use of :include in any way is currently not implemented.

    Bernie: unfortunately the way on that page is still the best. Check back in a few weeks…

  5. Pretty neat stuff. You mentioned a decorated join model. Can you elaborate on how you would go about using a rich join model with the plugin? Thanks.

  6. :include doesn’t work; is there some alternative I can use until it’s fully implemented to eager load associations within the controller? The number of queries I got running during rendering is insane.

  7. First, this plugin rocks. Thank you.

    Second, a question. Say we have:

    User < ActiveRecord::Base:
      has_many :comments, :order => "created_at ASC"
      has_many_polymorphs :commentables, :from => [:items],
        :through => :comments
    end
    

    In this case it looks like has_many_polymorphs overwrites the default user.comments finder with one that ignores the :order parameter.

    Is this a bug or intended behavior? And is there an easy way to get the default user.comments behavior while still using HMP?

  8. Macro devolution seems to take effect if :conditions refers to the parent table, though I don’t see why this should explode. The use case is a typical tagging setup where tags have an extra field language_code, and children can be tagged with separate tag lists depending on the current locale setting. Should this be handled by the polymorph conditions or extensions to the child associations? Thanks for this awesome plugin.

Follow

Get every new post delivered to your Inbox.

Join 511 other followers