ActiveRecord behaviour with associations

Posted by Tom on 2008-04-29

The interaction between ActiveRecord and the database is very simple when working with a single record - it’s always pretty clear when the database is going to be changed. What about when you’re working with multiple records and associations? I did some experiments way back at the start of the Hobo project, but recently I wanted to check if anything had changed.

So I threw together some simple experiments, and turned on logging in the console. It’s a bit rough and certainly not exhaustive, but I formatted it in markdown out of habit and then though hey, I should post this, so here it is.

Is this stuff documented somewhere? I never found it if it is. I wonder if most Rails devs know about all this already.

This is all in Rail 2.0.2 BTW.

Some simple models

class Post < ActiveRecord::Base
  has_many :comments
  has_many :categorisations
  has_many :categories, :through => :categorisations
end

class Comment < ActiveRecord::Base
  belongs_to :post
end

class Category < ActiveRecord::Base
  has_many :categorisations
end

class Categorisation < ActiveRecord::Base
  belongs_to :post
  belongs_to :category
end

has_many (not through)

Assigning to the array on a new record

New comments are created along with a new post:

>> p = Post.new
=> #<Post id: nil>
>> p.comments = [Comment.new]
=> [#<Comment id: nil, post_id: nil>]
>> p.save
  Post Create (0.000601)   INSERT INTO posts VALUES(NULL)
  Comment Create (0.000195)   INSERT INTO comments ("post_id") VALUES(1)
=> true

Appending to the array

For a post that exists, the appended comments are created immediately:

>> p
=> #<Post id: 1>
>> p.comments << Comment.new
  Comment Create (0.000481)   INSERT INTO comments ("post_id") VALUES(1)
=> [#<Comment id: 1, post_id: 1>, #<Comment id: 2, post_id: 1>]

Assigning to the array on an existing record

Comments no longer in the array have their foreign_key set to NULL. (I’d guess this changes if you declare :dependent => :destroy, but I didn’t try it)

>> p.comments
=> [#<Comment id: 1, post_id: 1>, #<Comment id: 2, post_id: 1>]
>> p.comments = []
  Comment Update (0.001335)   UPDATE comments SET post_id = NULL WHERE (post_id = 1 AND id IN (1,2))
=> []

New comments in the array are created immediately:

>> p.comments = [Comment.new]
  Comment Create (0.000504)   INSERT INTO comments ("post_id") VALUES(1)
=> [#<Comment id: 3, post_id: 1>]

Existing comments have their foreign key set

>> p2 = Post.create
  Post Create (0.000820)   INSERT INTO posts VALUES(NULL)
=> #<Post id: 2>
>> c = p.comments.first
=> #<Comment id: 3, post_id: 1>
>> p2.comments = [c]
  Comment Load (0.000292)   SELECT * FROM comments WHERE (comments.post_id = 2) 
  Comment Update (0.000684)   UPDATE comments SET "post_id" = 2 WHERE "id" = 3
=> [#<Comment id: 3, post_id: 2>]

belongs_to

When assigning c.post on an existing comment, the change is saved when the comment is saved:

>> c.post == p2
=> true
>> c.post = p
=> #<Post id: 1>
>> c.save
  Comment Update (0.000778)   UPDATE comments SET "post_id" = 1 WHERE "id" = 3
=> true

When assigning a c.post to a new post, the post is created when the comment is saved:

>> c
=> #<Comment id: 3, post_id: 1>
>> c.post = Post.new
=> #<Post id: nil>
>> c.save
  Post Create (0.000464)   INSERT INTO posts VALUES(NULL)
  Comment Update (0.000148)   UPDATE comments SET "post_id" = 3 WHERE "id" = 3
=> true

This happens the same way when the comment is new – both are created together:

>> c = Comment.new
=> #<Comment id: nil, post_id: nil>
>> c.post = Post.new
=> #<Post id: nil>
>> c.save
  Post Create (0.000499)   INSERT INTO posts VALUES(NULL)
  Comment Create (0.000161)   INSERT INTO comments ("post_id") VALUES(4)
=> true

has_many :through

Assigning to the array has no effect:

Assignment to p.categories where p is an existing post:

>> p
=> #<Post id: 1>
>> cat = Category.create
  Category Create (0.000427)   INSERT INTO categories VALUES(NULL)
=> #<Category id: 1>
>> p.categories = [cat]
  Category Load (0.000289)   SELECT categories.* FROM categories INNER JOIN categorisations ON categories.id = categorisations.category_id WHERE ((categorisations.post_id = 1)) 
=> [#<Category id: 1>]
>> p.save
=> true

Note there were no changes to the categories table.

Assignment to p.categories where p is a new post:

>> p = Post.new
=> #<Post id: nil>
>> p.categories = [cat]
=> [#<Category id: 1>]
>> p.save
  Post Create (0.000513)   INSERT INTO posts VALUES(NULL)
=> true

Again, nothing happens to the categories table

Appending to the array does have an effect

Can’t append to a has-many-through on a new record:

>> p = Post.new
=> #<Post id: nil>
>> p.categories << cat
ActiveRecord::HasManyThroughCantAssociateNewRecords: Cannot associate new records through 'Post#categorisations' on '#'. Both records must have an id in order to create the has_many :through record associating them.

Can append to a has-many-through on an existing record. The joining record is created immediately:

>> p = Post.find(:first)
  Post Load (0.000365)   SELECT * FROM posts LIMIT 1
=> #<Post id: 1>
>> p.categories
  Category Load (0.000294)   SELECT categories.* FROM categories INNER JOIN categorisations ON categories.id = categorisations.category_id WHERE ((categorisations.post_id = 1)) 
=> []
>> p.categories << cat
  Categorisation Create (0.000479)   INSERT INTO categorisations ("post_id", "category_id") VALUES(1, 1)
=> [#<Category id: 1>]

But this is not allowed if the category is new:

>> p.categories << Category.new
ActiveRecord::HasManyThroughCantAssociateNewRecords: Cannot associate new records through 'Post#categorisations' on '#'. Both records must have an id in order to create the has_many :through record associating them.

Did you learn something?



(edit)