New on edge: Magic join model creation

— August 19, 2006 at 14:17 PDT


Mark the date: August 18, 2006 is the day that has_many :through finally beat has_and_belongs_to_many into submission. Yesterday, Rails core member Jeremy Kemper checked in a change to Rails trunk ActiveRecord that allows using << and friends on has_many :through associations. If you don't know why this is cool (or needed!), take a moment and read this for background.

With this change, ActiveRecord will automagically create the join model record needed to link the associated object. What does that mean? Check this out...

post.tags << Tag.find_or_create_by_name("magic")

In the old world, that would have been

post.taggings.create!(:tag => Tag.find_or_create_by_name("magic"))

And then you'd still need to do a post.tags.reset or post.tags(true) to reload the tags collection so you could see the new tags in the association.

Kudos to Jeremy for getting this to work. I'd been working on my own hack to achieve this, but couldn't come up with an approach that was clean enough to satisfy me. Jeremy did the expedient thing and cut corners, defining the semantics on failure to throw an exception instead of returning false. That differs from how << works on has_many, which returns false on failure (and how does that make sense?). I'm lobbying to fix has_many << to do the same thing now.

Obligatory word of caution: As with any new edge feature, this may be unstable for a short time. I've been helping Jeremy stomp some bugs and fill in holes, but it's looking pretty solid now. If you're curious, give it a shot.

Now, the big bonus. This new functionality lets you easily set extra attributes when creating join model records, just like the deprecated push_with_attributes did for has_and_belongs_to_many. And of course I've got a trick for customizing default values based on associations.

Read on for more details...

(By the way, I'm entering this post in Peter Cooper's Ruby/Rails blogging contest. There are some good entries already, and he's always got good links on his site, so check it out.)

First, the basics. Here's what's new:

  • Add records to has_many :through associations using <<, push, and concat by creating the join record. Raises an error if base or associate are new records since both ids are required to create the join record.
  • #build raises and error since you can't associate an unsaved record (one without an id).
  • #create! takes an attributes hash and creates both the associated record and the join record in a transaction.

And of course, I have some tricks for you as well. Here are some models you may have seen before.

create_table "contributions" do |t|
  t.column "book_id",         :integer
  t.column "contributor_id",  :integer
  t.column "role",            :string
end

class Contribution < ActiveRecord::Base
  belongs_to :book
  belongs_to :contributor
end
class Book < ActiveRecord::Base
  has_many :contributions, :dependent => :destroy
  has_many :contributors, :through => :contributions
end
class Contributor < ActiveRecord::Base
  has_many :contributions, :dependent => :destroy
  has_many :books, :through => :contributions
end

Let me just spruce them up to take advantage of the new features.

class Contributor < ActiveRecord::Base
  has_many :contributions, :dependent => :destroy
  has_many :books, :through => :contributions do
    def push_with_attributes(book, join_attrs)
      Contribution.with_scope(:create => join_attrs) { self << book }
    end
  end
end

Here I've created an association extension with the method push_with_attributes. That lets me do this:

dave = Contributor.create(:name => "Dave")
awdr = Book.create(:title => "Agile Web Development with Rails")
dave.books.push_with_attributes(awdr, :role => "author")

That automagically creates the join record with the role attribute set to "author". Pretty nifty, eh? But we can do better.

class Contribution < ActiveRecord::Base
  belongs_to :book
  belongs_to :contributor
  belongs_to :author, :class_name => "Contributor"
  belongs_to :editor, :class_name => "Contributor"
end
class Book < ActiveRecord::Base
  has_many :contributions, :dependent => :destroy
  has_many :contributors, :through => :contributions, :uniq => true
  has_many :authors,      :through => :contributions, :source => :author,
                          :conditions => "contributions.role = 'author'" do
      def <<(author)
        Contribution.with_scope(:create => {:role => "author"}) { self.concat author }
      end
    end
  has_many :editors,      :through => :contributions, :source => :editor,
                          :conditions => "contributions.role = 'editor'" do
      def <<(editor)
        Contribution.with_scope(:create => {:role => "editor"}) { self.concat editor }
      end
    end
end

Then give this a shot...

dave = Contributor.create(:name => "Dave")
chad = Contributor.create(:name => "Chad")
awdr = Book.create(:title => "Agile Web Development with Rails")
awdr.authors << dave
awdr.editors << chad

The above code creates a join record with the role attribute set to "author", and another with the role set to "editor". The trick is using the association extension to define a new << method. Since that method is defined in the context of a specialized association, it can assume correctly that it knows what the role should be. This example code isn't very DRY, but it shouldn't be much work to come up with a way to generate those extensions dryly.

I think I'm happy now.

25 commentsassociations, rails

Comments
  1. michi2006-08-19 15:31:43

    After one month of getting into creating rich joins "by hand", i now have a REAL time-saver and hopefully delete a lot of code. Thank You !!!

  2. Paulo Köch2006-08-19 15:57:26

    So, when will it be dry'ed up in trunk? =P I guess it's quite easy to spot the patter here.

  3. Ted2006-08-19 16:03:37

    Spoogetastic!

  4. Brittain2006-08-19 17:10:09

    What are the semantics for << when the item already exists in the collection?

    Right now, if I remember correctly, the HABTM throws an exception. Which is pretty severe (though not a bug) considering that the end state would be what the programmer intended. e.g.

    group.members << userA group.members << userA ==> ActiveRecordStatementInvalid

    Any thought to how we could achieve this outcome with the new code?

    Thanks, well written article.

  5. Josh Susser2006-08-19 19:21:28

    @Brittain: Your join model can enjorce uniqueness or not, as you choose. You can set up a validation to check for a unique pairing of records. If you don't want that, skip it and dupes will work just fine.

    @Ted: I figured you'd like that :-)

  6. Bruce2006-08-20 08:38:32

    Would you have to do anything special to make the contributions ordered (actsas_list); e.g. so that you using the << methods adds the appropriate position to the row and I could then do mybook.authors.first and such?

  7. Josh Susser2006-08-20 08:54:07

    @Bruce: If you want to do something like let a user manually sort a list of a book's authors, you could do it by putting the list position in the join model and setting the scope to be book_id. If you want to support multiple lists of for different roles, the scope would be book_id AND role. Beyond that, it's just a matter of getting your hands on the join model object when manipulating the associated object - you can see how to do that in this article.

  8. Mislav2006-08-21 04:39:56

    Excellent writeup! Thanks ;)

  9. bh2006-08-27 19:11:57

    how can i apply that changeset to my rails lib for enabling that functions? thanks

  10. bh2006-08-27 19:12:15

    how can i apply that changeset to my rails lib for enabling that functions? thanks

  11. jeff2006-08-28 11:57:15

    Very hip - what would it take to wire up the objectsingularids method?

  12. jeff2006-08-28 11:58:54

    Sorry - that got textalized. That should be object _ singular _ ids (like @object.childids=somearray)

  13. jeff2006-08-28 12:02:32
    like @object.child_ids=some_array
    
  14. Josh Susser2006-08-31 12:04:01

    bh: take a look on the Rails wiki for information about how to use edge Rails

    jeff: I haven't looked at doing child_ids = array. It could get slightly complicated if you want to do it efficiently, since you would need to decide if you want to trash all the existing join model records then recreate the, or delete the ones no longer used, keep the ones still in use, then create the new ones as needed.

  15. Vince2006-09-02 05:17:57

    Tried to implement that as a plugin: http://pastie.caboo.se/11358

    That is neither clean nor absolutely correct ruby code, but that's all right since my Ruby/Rails experience is about a week only :)

  16. cesium2006-09-04 22:14:15

    You say (roughly) that "<<" should throw errors instead of returning them. Which raises the question: under what conditions should errors be thrown instead of returned? When calling a function that might either return an error or throw an error, don't you have to wrap that function call in both a test to see if it returned an error as well as wrapping it in code that checks to see if it threw an error? If you can't tell whether the function will throw or return, doesn't this make these new object-oriented languagues less useful (or more difficult to use) than, say, C?

  17. Josh Susser2006-09-04 23:07:34

    @cesium: The non-error behavior of << is to return the collection so that you can chain the methods. Ex: users << joe << bob << kim. There is no graceful way to test for nil returns while preserving the utility of chained messages. That's why it's a perfect reason for raising an exception. In general, you raise exceptions for exceptional situations (thus the name), and return nil or whatever when it fits well with the usage of the method. And the way to know what a method does is to consult the documentation, just like with any system. Your comment smells a bit like trolling to me.

  18. martin2006-09-07 01:27:36

    Nice writeup. Saved me some time implementing fancy join/leave methods for a polymorphic has_many :through :-)

    To make life even better, I've implemented delete support for has_many :through as well. See http://dev.rubyonrails.org/ticket/6049. Hope it makes its way into trunk soon.

    Looking forward to the day when validating m:n relationships comes without a price in Rails (we're almost there...)

  19. knowuh2006-09-13 12:13:20

    This is very snazzy.

    I am going to quote you here, and then ask a question about it:

    "Your join model can enjorce uniqueness or not, as you choose. You can set up a validation to check for a unique pairing of records. If you don't want that, skip it and dupes will work just fine."

    Hmn, do you mind showing me what you mean by this?

    What I really want to do is to have a contributor change roles in your example above. Eg:

    writers << bob  # bob is a writer
    
    illustrators << bob 
    # bob is no longer a writer, he is an illustrator.
    

    That's what I am looking for. Any suggestions on how to make that magic work?

    Thanks!

  20. Adam2006-10-02 17:47:59

    Great article ! I moved to edge rails because of this feature and of cource the restful additions.

    One question. I have come to the point in my project where i am adding and removing items from my relationship table. eg Products - classifications - categories.

    To add i go: @category.products << @product

    But ive tried my old habtm remove and it does nothing: eg product.parts.delete(Part.find(@params[:id]))

    I read martin's post regarding his fix for this. Nice work btw martin. Until the time this is added to the trunk what is the best method for removing/deleting relationships ?

  21. Adam2006-10-02 17:48:02

    Great article ! I moved to edge rails because of this feature and of cource the restful additions.

    One question. I have come to the point in my project where i am adding and removing items from my relationship table. eg Products - classifications - categories.

    To add i go: @category.products << @product

    But ive tried my old habtm remove and it does nothing: eg product.parts.delete(Part.find(@params[:id]))

    I read martin's post regarding his fix for this. Nice work btw martin. Until the time this is added to the trunk what is the best method for removing/deleting relationships ?

  22. Adam2006-10-02 17:48:18

    Great article ! I moved to edge rails because of this feature and of cource the restful additions.

    One question. I have come to the point in my project where i am adding and removing items from my relationship table. eg Products - classifications - categories.

    To add i go: @category.products << @product

    But ive tried my old habtm remove and it does nothing: eg product.parts.delete(Part.find(@params[:id]))

    I read martin's post regarding his fix for this. Nice work btw martin. Until the time this is added to the trunk what is the best method for removing/deleting relationships ?

  23. Adam2006-10-02 17:48:50

    crap, sorry about the multiposting !

  24. Walter McGinnis2006-10-12 15:18:53

    I've been working on how to integrate this sort of thing with acts _ as _ list in the join model. Finally figured out how to achieve what I wanted. Essentially the trick is to do your acts _ as _ list operations on the straight join model collection. In your example code it would be awdr.contributions rather than any associated collections such as awdr.authors.

    Of course, in a list output, you probably want the attributes associated with awdr.authors as well as the ordering via positions of awdr.contributions. Grabbing the collections for both (not very DRY) and then matching attributes across them in display via the fact that I ordered both collections by position (in my code it's a one to one between the equivalent of "contributions" and "authors") via some extra "select distinct contributions.position, contributors.*" special sauce and NOT using ":uniq => :true" in my "authors" association.

    Doesn't make me very happy to run two queries for what could be done with one and doing joining of attributes on the Rails side of things, but it gives me what I want.

    I suppose there might be a way to use ":select =>" to get the extra attributes on the has_many :contributions line in the Contributors model, but I'm sure this would be any cleaner.

    I'll probably write this up in a blog post at some point, over at http://blog.katipo.co.nz.

    Cheers, Walter

  25. Walter McGinnis2006-10-30 15:06:00

    As promised:

    http://blog.katipo.co.nz/?p=25

    Thanks for all the help.

    Cheers, Walter

Sorry, comments for this article are closed.