Is there a way to ensure that both sides of a belongs_to
+ has_many
association will automatically hydrate according to changes made on the other side of the association, without reloading the other side?
I have a self-join implementation of has_many + belongs to, like so:
class Activity < ApplicationRecord
belongs_to :combined_activity_parent, class_name: 'Activity',
inverse_of: :combined_activity_children, optional: true
has_many :combined_activity_children, class_name: 'Activity',
inverse_of: :combined_activity_parent, foreign_key: 'combined_activity_parent_id'
end
After configuring inverse_of
on both sides of the association, my expectation was that, once I assign a parent to a child then that child would automatically appear on the parent side under children
without reloading the parent, and vice versa.
However in practice it seems that I have to reload the parent in order to see the inverse of the association hydrate:
irb(main):006:0> parent = Activity.create(friend: Friend.first, region: Region.first, activity_type: ActivityType.first, occur_at: 1.day.from_now)
=> #<Activity id: 63, event: nil, location_id: nil, friend_id: 1, judge_id: nil, occur_at: "2019-10-11 12:31:01", notes: nil, created_at: "2019-10-10 12:31:01", updated_at: "2019-10-10 12:31:01", region_id: 1, confirmed: nil, public_notes: nil, activity_type_id: 1, combined_activity_parent_id: nil>
irb(main):007:0> child = Activity.create(friend: Friend.first, region: Region.first, activity_type: ActivityType.first, occur_at: parent.occur_at + 1.hour)
=> #<Activity id: 64, event: nil, location_id: nil, friend_id: 1, judge_id: nil, occur_at: "2019-10-11 13:31:01", notes: nil, created_at: "2019-10-10 12:31:45", updated_at: "2019-10-10 12:31:45", region_id: 1, confirmed: nil, public_notes: nil, activity_type_id: 1, combined_activity_parent_id: nil>
irb(main):009:0> child.combined_activity_parent = parent
=> #<Activity id: 63, event: nil, location_id: nil, friend_id: 1, judge_id: nil, occur_at: "2019-10-11 12:31:01", notes: nil, created_at: "2019-10-10 12:31:01", updated_at: "2019-10-10 12:31:01", region_id: 1, confirmed: nil, public_notes: nil, activity_type_id: 1, combined_activity_parent_id: nil>
irb(main):011:0> parent.combined_activity_children
=> #<ActiveRecord::Associations::CollectionProxy []>
irb(main):012:0> child.save!
=> true
irb(main):013:0> parent.combined_activity_children
=> #<ActiveRecord::Associations::CollectionProxy []>
irb(main):014:0> parent.reload
=> #<Activity id: 63, event: nil, location_id: nil, friend_id: 1, judge_id: nil, occur_at: "2019-10-11 12:31:01", notes: nil, created_at: "2019-10-10 12:31:01", updated_at: "2019-10-10 12:31:01", region_id: 1, confirmed: nil, public_notes: nil, activity_type_id: 1, combined_activity_parent_id: nil>
irb(main):015:0> parent.combined_activity_children
=> #<ActiveRecord::Associations::CollectionProxy [#<Activity id: 64, event: nil, location_id: nil, friend_id: 1, judge_id: nil, occur_at: "2019-10-11 13:31:01", notes: nil, created_at: "2019-10-10 12:31:45", updated_at: "2019-10-10 12:32:35", region_id: 1, confirmed: nil, public_notes: nil, activity_type_id: 1, combined_activity_parent_id: 63>]>
Strangely, the second test below succeeds (the belongs_to
gets automatically hydrated in memory when the has_many
side is modified), but the first fails. This leaves me wondering if the association is not properly configured.
context 'associations' do
it 'should automatically hydrate the other side of a belongs_to' do
equivalent_time = 1.day.from_now - 1.hour
activity_1 = create(:activity, occur_at: equivalent_time, public_notes: 'parent activity')
activity_2 = create(:activity, occur_at: equivalent_time, public_notes: 'child activity',
combined_activity_parent: activity_1)
expect(activity_1.combined_activity_children.first).to eq activity_2
end
it 'should automatically hydrate the other side of a has_many' do
equivalent_time = 1.day.from_now - 1.hour
activity_1 = create(:activity, occur_at: equivalent_time, public_notes: 'child activity')
activity_2 = create(:activity, occur_at: equivalent_time, public_notes: 'parent activity',
combined_activity_children: [activity_1])
expect(activity_1.combined_activity_parent).to eq activity_2
end
end
(See full tests here.)
my expectation was that, once I assign a parent to a child then that child would automatically appear on the parent side under children without reloading the parent
There are actually two questions:
1) Should a child automatically appear on the parent side before the information about the child's parent is persisted in DB (before child.save!
)
I think it shouldn't, and there are reasons for that. In order to properly show children in that case, Rails should be able to merge two arrays: 1) children calculated via inverse_of
(not yet persisted in DB) and 2) children that are already persisted in DB. This can lead to conflicts and misunderstandings.
2) Should a child automatically appear on the parent side after the information about the child's parent is persisted in DB (after child.save!
)
Of course it should! And you don't even need inverse_of
for that. Since all the data is persisted in DB, you should be able to see it. The reason you didn't see the children was not because inverse_of
hadn't worked, but because the combined_activity_children
association had been cached when you accessed it for the first time (before child.save!
).
parent.children # queries children from DB and cached the result
child.save!
parent.children # the cached result is the same, no queries to DB
So, there is another question:
3) Should the cached association have been recalculated on the second call in that case?
I think it should, but maybe it is too hard and expensive to take into account all such cases.
Strangely, the second test below succeeds (the belongs_to gets automatically hydrated in memory when the has_many side is modified)
When you accessed the parent, the information about it was already persisted in DB. Furthermore, it is much easier for Rails to handle a single parent than multiple children. So, the inverse_of
had no right and no chance not to work in that case.
However, it would be interesting to check if the second case works if you access the parent not only after but also before creating it:
activity_1 = create(:activity)
expect(activity_1.combined_activity_parent).to eq nil
activity_2 = create(:activity, combined_activity_children: [activity_1])
expect(activity_1.combined_activity_parent).to eq activity_2
Also I would like to draw attention to the fact that your expectation about inverse_of
(for both the first and the second case) has never been documented. There is a completely different case in the documentation https://guides.rubyonrails.org/association_basics.html#bi-directional-associations
From the documentation it looks like Active Record is focused on proper records loading rather than on changing already loaded records. However, the latter works in some cases.