Rails: Why “collection=” doesn't update records with existing id?

大憨熊 提交于 2020-01-15 03:47:07

问题


User can have many posts:

class User < ActiveRecord::Base
  has_many :posts
  accepts_nested_attributes_for :posts
end

class Post < ActiveRecord::Base
  belongs_to :user
end

Why the following sequence doesn't update the first post?

$ rails c

> user = User.create(name: 'Misha')
 => #<User id: 7, name: "Misha", ... >
> user.posts << Post.create(description: 'hello')
 => #<ActiveRecord::Associations::CollectionProxy [#<Post id: 9, description: "hello", user_id: 7, ... >]> 
> post1 = Post.find(9)
> post1.assign_attributes(description: 'world')
> post1
 => #<Post id: 9, description: "world", user_id: 7, ... >
> post2 = Post.new(description: 'new post')
> user.posts = [post1, post2]
> user.posts.second.description
 => "new post"   # As expected
> user.posts.first.description
 => "hello"      # Why not "world"?

回答1:


You're mixing up saving the post object with saving the association from posts to users.

Like @zeantsoi said, assign_attributes never saves it -- and looking at the executed SQL, collection= doesn't save anything either.

> user.posts = [post1, post2]
   (0.1ms)  begin transaction
  SQL (0.7ms)  INSERT INTO "posts" ("created_at", "description", "updated_at", "user_id") VALUES (?, ?, ?, ?)  [["created_at", Mon, 17 Jun 2013 10:48:13 UTC +00:00], ["des
cription", "p2"], ["updated_at", Mon, 17 Jun 2013 10:48:13 UTC +00:00], ["user_id", 2]]
   (22.8ms)  commit transaction
=> [#<Post id: 3, description: "p1 modified", user_id: 2, created_at: "2013-06-17 10:46:43", updated_at: "2013-06-17 10:46:43">, #<Post id: 4, description: "p2", user_id: 
2, created_at: "2013-06-17 10:48:13", updated_at: "2013-06-17 10:48:13">]
>

post2 is inserted only because it has to be in order for a relationship to be set; the User object can't know that a particular Post belongs to it if there's no way to identify the Post uniquely.

Looking at the source for CollectionAssociation, upon which has_many is built, observe how wholesale replacement is implemented:

# Replace this collection with +other_array+. This will perform a diff
# and delete/add only records that have changed.
def replace(other_array)
  other_array.each { |val| raise_on_type_mismatch!(val) }
  original_target = load_target.dup

  if owner.new_record?
    replace_records(other_array, original_target)
  else
    transaction { replace_records(other_array, original_target) }
  end
end

The core of the work is in replace_records:

def replace_records(new_target, original_target)
  delete(target - new_target)

  unless concat(new_target - target)
    @target = original_target
    raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \
                          "new records could not be saved."
  end

  target
end

In other words, it deletes items not in the target list, then adds items not in the new list; the result is that any item that was in both target and new list (post1) isn't touched at all during collection assignment.

Per the above code, the target as passed in to the argument is what's returned, which seems to reflect the change:

=> [#<Post id: 3, description: "p1 modified", user_id: 2, created_at: "2013-06-17 10:46:43", updated_at: "2013-06-17 10:46:43">, #<Post id: 4, description: "p2", user_id: 
2, created_at: "2013-06-17 10:48:13", updated_at: "2013-06-17 10:48:13">]

But upon reaccessing the collection, the change isn't reflected:

> post1
=> #<Post id: 3, description: "p1 modified", user_id: 2, created_at: "2013-06-17 10:46:43", updated_at: "2013-06-17 10:46:43">
> user.posts
=> #<ActiveRecord::Associations::CollectionProxy [#<Post id: 3, description: "p1", user_id: 2, created_at: "2013-06-17 10:46:43", updated_at: "2013-06-17 10:46:43">, #<Pos
t id: 4, description: "p2", user_id: 2, created_at: "2013-06-17 10:48:13", updated_at: "2013-06-17 10:48:13">]>
>

Note that the return here is slightly different; the return value from the assignment was the array object you passed in; this here is an ActiveRecord::Associations::CollectionProxy. The reader function is called here:

# Implements the reader method, e.g. foo.items for Foo.has_many :items
def reader(force_reload = false)
  if force_reload
    klass.uncached { reload }
  elsif stale_target?
    reload
  end

  @proxy ||= CollectionProxy.new(klass, self)
end

This, then, creates the collection proxy based on the has_many relation, whose values are filled in from what we knew when we assigned the options. The only undiscovered part of this answer is why the resulting object is cleaned of dirty values -- I've done a bit of code reading, and figure it'll be easiest to answer with a debugger, which I'm not in the mood for. :) But it's clear that it's loading either from cache, or the objects passed in are having their changes discarded.

Either way, if you want the change to appear in the target object, you should save it first -- merely assigning the collection isn't good enough, as if it was already a member, it won't be touched.


Update: it's interesting to note this happens only because we use Post.find to obtain post1; if we instead say post1 = (user.posts << Post.create(description: 'p1')), the collection as observed in user.posts at the end actually has the dirty object.

This unveils how it came into existence in the first place. Watch the object_ids:

>
u = User.create; p1 = (u.posts << Post.create(description: 'p1'))[0]; p1.assign_attributes(description: 'p1 mod'); p2 = Post.new(description: 'p2'); u.posts = [p1, p2]; u.posts
...
=> #<ActiveRecord::Associations::CollectionProxy [#<Post id: 21, description: "p1 mod", user_id: 10, created_at: "2013-06-17 11:43:30", updated_at: "2013-06-17 11:43:30">, #<Post id: 22, description: "p2", user_id: 10, created_at: "2013-06-17 11:43:30", updated_at: "2013-06-17 11:43:30">]>
> _[0].object_id
=> 70160940234280
> p1.object_id
=> 70160940234280
>

Note the returned object in the collection proxy is the same object as that we created. If we re-find it:

> u = User.create; u.posts << Post.create(description: 'p1'); p1 = Post.find(u.posts.first.id); p1.assign_attributes(description: 'p1 mod'); p2 = Post.new(description: 'p2'); u.posts = [p1, p2]; u.posts
...=> #<ActiveRecord::Associations::CollectionProxy [#<Post id: 23, description: "p1", user_id: 11, created_at: "2013-06-17 11:43:47", updated_at: "2013-06-17 11:43:47">, #<Post id: 24, description: "p2", user_id: 11, created_at: "2013-06-17 11:43:47", updated_at: "2013-06-17 11:43:47">]>
> _[0].object_id
=> 70264436302820
> p1.object_id
=> 70264441827000
>

The part of the original question that confused me was where the object without the dirty data was coming from; no SQL occurred, not even a cache hit, so it had to come from somewhere. I had supposed it was either some other cache source, or it was explicitly taking the objects given and cleaning them.

The above makes it clear that the cache is in fact the Post we created when inserting it. To be 100% sure, let's see if the returned Post is the same as the created one:

> u = User.create; p0 = (u.posts << Post.create(description: 'p1'))[0]; p1 = Post.find(u.posts.first.id); p1.assign_attributes(description: 'p1 mod'); p2 = Post.new(description: 'p2'); u.posts = [p1, p2]; u.posts
...
=> #<ActiveRecord::Associations::CollectionProxy [#<Post id: 27, description: "p1", user_id: 13, created_at: "2013-06-17 12:01:05", updated_at: "2013-06-17 12:01:05">, #<Post id: 28, description: "p2", user_id: 13, created_at: "2013-06-17 12:01:07", updated_at: "2013-06-17 12:01:07">]>
> _[0].object_id
=> 70306779571100
> p0.object_id
=> 70306779571100
> p1.object_id
=> 70306779727620
>

So the object in the CollectionProxy which doesn't reflect the change is in fact the same object we created when appending to the collection in the first place; that explains the source of the cached data. We then permute a copy, which doesn't get reflected post-collection-assignment.



来源:https://stackoverflow.com/questions/17142290/rails-why-collection-doesnt-update-records-with-existing-id

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!