Skip to content

Commit

Permalink
Fix has_many though associations when the through association is sing…
Browse files Browse the repository at this point in the history
…ular
  • Loading branch information
pi-plus-45x23 authored and westonganger committed Aug 22, 2024
1 parent d8db279 commit 754fcc5
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def reify(assoc, model, options, transaction_id)
# Load the collection of through-models. For example, if `model` is a
# Chapter, having many Paragraphs through Sections, then
# `through_collection` will contain Sections.
through_collection = model.send(assoc.options[:through])
through_collection = through_collection(assoc, model, options, transaction_id)

# Now, given the collection of "through" models (e.g. sections), load
# the collection of "target" models (e.g. paragraphs)
Expand All @@ -24,6 +24,27 @@ def reify(assoc, model, options, transaction_id)

private

# Examine the `through_reflection`, i.e., the "through:" option on the association.
#
# @api private
def through_collection(assoc, model, options, transaction_id)
through_reflection = assoc.through_reflection
# If the through association is has_many, we can just return the reified association
return model.send(assoc.options[:through]) if through_reflection.collection?

# If the model wasn't reified with belongs_to: true/has_one: true, then
# the through association hasn't been reified yet.
unless model.association(assoc.options[:through]).loaded?
if through_reflection.belongs_to?
BelongsTo.reify(through_reflection, model, options, transaction_id)
else
HasOne.reify(through_reflection, model, options, transaction_id)
end
end
# Wrap the association in a collection for `collection_through_has_many`
[*model.send(assoc.options[:through])]
end

# Examine the `source_reflection`, i.e. the "source" of `assoc` the
# `ThroughReflection`. The source can be a `BelongsToReflection`
# or a `HasManyReflection`.
Expand Down
1 change: 1 addition & 0 deletions spec/dummy_app/app/models/bizzo.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ class Bizzo < ActiveRecord::Base
has_paper_trail

belongs_to :widget
has_many :notes, as: :object, dependent: :destroy
end
1 change: 1 addition & 0 deletions spec/dummy_app/app/models/widget.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ class Widget < ActiveRecord::Base
has_one :bizzo, dependent: :destroy
has_many(:fluxors, -> { order(:name) })
has_many :whatchamajiggers, as: :owner
has_many :notes, through: :bizzo
validates :name, exclusion: { in: [EXCLUDED_NAME] }
end
107 changes: 107 additions & 0 deletions spec/paper_trail/associations/has_many_through_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -432,4 +432,111 @@
end
end
end

context "Widgets, bizzos, and notes" do
before { @widget = Widget.create(name: 'widget_0') }

context "before any associations are created" do
before { @widget.update(name: 'widget_1') }

it "not reify any associations" do
widget_v1 = @widget.versions[1].reify(has_many: true)
expect(widget_v1.name).to(eq('widget_0'))
expect(widget_v1.bizzo).to(eq(nil))
expect(widget_v1.notes).to(eq([]))
end
end

context "after the first has_many through relationship is created" do
before do
@widget.update(name: 'widget_1')
Timecop.travel(1.second.since)
@widget.create_bizzo(name: 'bizzo_1')
Timecop.travel(1.second.since)
@widget.bizzo.update(name: 'bizzo_2')
Timecop.travel(1.second.since)
@widget.update(name: 'widget_2')
Timecop.travel(1.second.since)
@widget.bizzo.update(name: "bizzo_3")
end

context "after creating a note" do
before do
@bizzo = @widget.bizzo
Timecop.travel(1.second.since)
@note = @bizzo.notes.create(body: "note1")
end

context "new widget version" do
it "have one note" do
initial_bizzo_name = @bizzo.name
initial_note_body = @note.body
Timecop.travel(1.second.since)
@widget.update(name: 'widget_4')
expect(@widget.versions.size).to(eq(4))
Timecop.travel(1.second.since)
@note.update(body: "note3")
widget_v3 = @widget.versions[3].reify(has_many: true)
expect(widget_v3.bizzo.name).to(eq(initial_bizzo_name))
notes = widget_v3.bizzo.notes
expect(notes.size).to(eq(1))
expect(notes.map(&:body)).to(eq([initial_note_body]))
end
end

context "the version before a bizzo is destroyed" do
it "have the bizzo and note" do
Timecop.travel(1.second.since)
@widget.update(name: 'widget_3')
expect(@widget.versions.size).to(eq(4))
Timecop.travel(1.second.since)
@bizzo.destroy
expect(@widget.versions.size).to(eq(4))
widget_v3 = @widget.versions[3].reify(has_many: true)
expect(widget_v3.name).to(eq('widget_2'))
expect(widget_v3.bizzo).to(eq(@bizzo))
expect(widget_v3.bizzo.notes).to(eq([@note]))
expect(widget_v3.notes).to(eq([@note]))
end
end

context "the version after a bizzo is destroyed" do
it "not have any bizzos or notes" do
@bizzo.destroy
Timecop.travel(1.second.since)
@widget.update(name: 'widget_5')
expect(@widget.versions.size).to(eq(4))
widget_v3 = @widget.versions[3].reify(has_many: true)
expect(widget_v3.bizzo).to(be_nil)
expect(widget_v3.notes.size).to(eq(0))
end
end

context "the version before a note is destroyed" do
it "have the one note" do
initial_note_body = @bizzo.notes.first.body
Timecop.travel(1.second.since)
@widget.update(name: 'widget_5')
Timecop.travel(1.second.since)
@note.destroy
widget_v3 = @widget.versions[3].reify(has_many: true)
notes = widget_v3.bizzo.notes
expect(notes.size).to(eq(1))
expect(notes.first.body).to(eq(initial_note_body))
end
end

context "the version after a note is destroyed" do
it "have no notes" do
@note.destroy
Timecop.travel(1.second.since)
@widget.update(name: 'widget_5')
widget_v3 = @widget.versions[3].reify(has_many: true)
expect(widget_v3.notes.size).to(eq(0))
expect(widget_v3.bizzo.notes).to(eq([]))
end
end
end
end
end
end

0 comments on commit 754fcc5

Please sign in to comment.