Skip to content

Commit

Permalink
Fix for issue paper-trail-gem#594, reifying sub-classed models that u…
Browse files Browse the repository at this point in the history
…se STI
  • Loading branch information
lorint committed Jul 23, 2018
1 parent 6ab2e93 commit 27a2abb
Show file tree
Hide file tree
Showing 9 changed files with 200 additions and 34 deletions.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ recommendations of [keepachangelog.com](http://keepachangelog.com/).

### Fixed

- None
- [#594](https://github.com/paper-trail-gem/paper_trail/issues/594) -
In order to properly reify a version of a model using STI, item_type refers to
the actual class instead of base_class. The previously failing example in
person_spec.rb now passes, so that test has been brought back. In addition to
the changes here, the five reifiers in the gem [paper_trail-association_tracking]
that refer to base_class also need to be updated. See
[PT_AT #5](https://github.com/westonganger/paper_trail-association_tracking/pull/5)
for more details.

## 9.1.1 (2018-05-30)

Expand Down
37 changes: 31 additions & 6 deletions lib/paper_trail/model_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,36 @@ def cannot_record_after_destroy?
::ActiveRecord::Base.belongs_to_required_by_default
end

def type_aware_has_many(klass)
klass.has_many(
klass.versions_association_name,
lambda do |object|
# Support Single Table Inheritance (STI) with custom inheritance columns.
#
# For example, imagine a `version` whose `item_type` is "Animal". The
# `animals` table is an STI table (it has cats and dogs) and it has a
# custom inheritance column, `species`. If `attrs["species"]` is "Dog",
# type_name is set to `Dog`. If `attrs["species"]` is blank, type_name
# is set to `Animal`. You can see this particular example in action in
# `spec/models/animal_spec.rb`.
type_column = object.class.inheritance_column
type_name = if object.respond_to?(type_column)
object.send(type_column)
end
type_name ||= object.class.name
if type_name != object.class.base_class.name
unscope(where: :item_type).
where(item_type: type_name).
order(model.timestamp_sort_order)
else
order(model.timestamp_sort_order)
end
end,
class_name: klass.version_class_name,
as: :item
)
end

def setup_associations(options)
@model_class.class_attribute :version_association_name
@model_class.version_association_name = options[:version] || :version
Expand All @@ -185,12 +215,7 @@ def setup_associations(options)

assert_concrete_activerecord_class(@model_class.version_class_name)

@model_class.has_many(
@model_class.versions_association_name,
-> { order(model.timestamp_sort_order) },
class_name: @model_class.version_class_name,
as: :item
)
type_aware_has_many(@model_class)
end

def setup_callbacks_from_options(options_on = [])
Expand Down
16 changes: 12 additions & 4 deletions lib/paper_trail/record_trail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,10 @@ def record_create
@in_after_callback = true
return unless enabled?
versions_assoc = @record.send(@record.class.versions_association_name)
versions_assoc.create! data_for_create
version = versions_assoc.new data_for_create
version.item_type = @record.class.name
version.save
version
ensure
@in_after_callback = false
end
Expand Down Expand Up @@ -284,7 +287,7 @@ def record_destroy(recording_order)
def data_for_destroy
data = {
item_id: @record.id,
item_type: @record.class.base_class.name,
item_type: @record.class.name,
event: @record.paper_trail_event || "destroy",
object: recordable_object(false),
whodunnit: PaperTrail.request.whodunnit
Expand All @@ -307,7 +310,9 @@ def record_update(force:, in_after_callback:, is_touch:)
@in_after_callback = in_after_callback
if enabled? && (force || changed_notably?)
versions_assoc = @record.send(@record.class.versions_association_name)
version = versions_assoc.create(data_for_update(is_touch))
version = versions_assoc.new(data_for_update(is_touch))
version.item_type = @record.class.name
version.save
if version.errors.any?
log_version_errors(version, :update)
else
Expand Down Expand Up @@ -343,7 +348,10 @@ def data_for_update(is_touch)
def record_update_columns(changes)
return unless enabled?
versions_assoc = @record.send(@record.class.versions_association_name)
version = versions_assoc.create(data_for_update_columns(changes))
version = versions_assoc.new(data_for_update_columns(changes))
version.item_type = @record.class.name
version.save

if version.errors.any?
log_version_errors(version, :update)
else
Expand Down
19 changes: 1 addition & 18 deletions lib/paper_trail/reifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def init_model(attrs, options, version)
init_unversioned_attrs(attrs, model)
end
else
klass = version_reification_class(version, attrs)
klass = version.item_type.constantize
# The `dup` option always returns a new object, otherwise we should
# attempt to look for the item outside of default scope(s).
find_cond = { klass.primary_key => version.item_id }
Expand Down Expand Up @@ -109,23 +109,6 @@ def reify_attributes(model, version, attrs)
reify_attribute(k, v, model, version)
end
end

# Given a `version`, return the class to reify. This method supports
# Single Table Inheritance (STI) with custom inheritance columns.
#
# For example, imagine a `version` whose `item_type` is "Animal". The
# `animals` table is an STI table (it has cats and dogs) and it has a
# custom inheritance column, `species`. If `attrs["species"]` is "Dog",
# this method returns the constant `Dog`. If `attrs["species"]` is blank,
# this method returns the constant `Animal`. You can see this particular
# example in action in `spec/models/animal_spec.rb`.
#
def version_reification_class(version, attrs)
inheritance_column_name = version.item_type.constantize.inheritance_column
inher_col_value = attrs[inheritance_column_name]
class_name = inher_col_value.blank? ? version.item_type : inher_col_value
class_name.constantize
end
end
end
end
6 changes: 6 additions & 0 deletions spec/dummy_app/app/models/family/celebrity_family.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true

module Family
class CelebrityFamily < Family::Family
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,8 @@ def up

create_table :families do |t|
t.string :name
t.string :type # For STI support
t.string :path_to_stardom # Only used for celebrity families
t.integer :parent_id
t.integer :partner_id
end
Expand Down
96 changes: 96 additions & 0 deletions spec/models/family/celebrity_family_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# frozen_string_literal: true

require "spec_helper"

module Family
RSpec.describe CelebrityFamily, type: :model, versioning: true do
describe "#reify" do
context "belongs_to" do
it "uses the correct item_type in queries" do
parent = described_class.new(name: "Jermaine Jackson")
parent.path_to_stardom = "Emulating Motown greats such as the Temptations and "\
"The Supremes"
child1 = parent.children.build(name: "Jaimy Jermaine Jackson")
parent.children.build(name: "Autumn Joy Jackson")
parent.save!
parent.update_attributes!(
name: "Hazel Gordy",
children_attributes: { id: child1.id, name: "Jay Jackson" }
)
# We expect `reify` for all versions to have item_type 'Family::CelebrityFamily',
# not 'Family::Family'. See PR #1106
expect(parent.versions.count).to eq(2) # A create and an update
parent.versions.each do |parent_version|
expect(parent_version.item_type).to eq(parent.class.name)
end
end
end

context "has_many" do
it "uses the correct item_type in queries" do
parent = described_class.new(name: "Gomez Addams")
parent.path_to_stardom = "Buy a Victorian house next to a sprawling graveyard, "\
"and just become super aloof."
parent.children.build(name: "Wednesday")
parent.save!
parent.name = "Morticia Addams"
parent.children.build(name: "Pugsley")
parent.save!

# We expect `reify` for all versions to have item_type 'Family::CelebrityFamily',
# not 'Family::Family'. See PR #1106
previous_parent = parent.versions.last.reify(has_many: true)
expect(parent.versions.count).to eq(2)
parent.versions.each do |parent_version|
expect(parent_version.item_type).to eq(parent.class.name)
end
end
end

context "has_many through" do
it "uses the correct item_type in queries" do
parent = described_class.new(name: "Grandad")
parent.path_to_stardom = "Took a suitcase and started running a market trading "\
"company out of it, while proclaiming, 'This time next "\
"year, we'll be millionaires!'"
parent.grandsons.build(name: "Del Boy")
parent.save!
parent.name = "Del"
parent.grandsons.build(name: "Rodney")
parent.save!

# We expect `reify` for all versions to have item_type 'Family::CelebrityFamily',
# not 'Family::Family'. See PR #1106
previous_parent = parent.versions.last.reify(has_many: true)
expect(parent.versions.count).to eq(2)
parent.versions.each do |parent_version|
expect(parent_version.item_type).to eq(parent.class.name)
end
end
end

context "has_one" do
it "uses the correct item_type in queries" do
parent = described_class.new(name: "Minnie Marx")
parent.path_to_stardom = "Gain a relentless dedication to the stage by having a "\
"mother who performs as a yodeling harpist, and then "\
"bring up 5 boys who have a true zest for comedy."
parent.build_mentee(name: "Abraham Schönberg")
parent.save!
parent.update_attributes(
name: "Samuel Marx",
mentee_attributes: { id: parent.mentee.id, name: "Al Shean" }
)

# We expect `reify` for all versions to have item_type 'Family::CelebrityFamily',
# not 'Family::Family'. See PR #1106
previous_parent = parent.versions.last.reify(has_one: true)
expect(parent.versions.count).to eq(2)
parent.versions.each do |parent_version|
expect(parent_version.item_type).to eq(parent.class.name)
end
end
end
end
end
end
31 changes: 30 additions & 1 deletion spec/models/person_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
# The `Person` model:
#
# - has a dozen associations of various types
# - has a custome serializer, TimeZoneSerializer, for its `time_zone` attribute
# - has a custom serializer, TimeZoneSerializer, for its `time_zone` attribute
RSpec.describe Person, type: :model, versioning: true do
it "baseline test setup" do
expect(Person.new).to be_versioned
end

describe "#time_zone" do
it "returns an ActiveSupport::TimeZone" do
person = Person.new(time_zone: "Samoa")
Expand Down Expand Up @@ -149,4 +153,29 @@
end
end
end

# # Was originally a skipped example from Issue #594. See:
# # https://github.com/paper-trail-gem/paper_trail/issues/594
# # Fixed by utilising the correct sub type of the STI model.
# describe "#cars and bicycles" do
# it "can be reified", skip: "skipped until PT-AT has PR #5 merged" do
# person = Person.create(name: "Frank")
# car = Car.create(name: "BMW 325")
# bicycle = Bicycle.create(name: "BMX 1.0")

# person.car = car
# person.bicycle = bicycle
# person.update_attributes(name: "Steve")

# car.update_attributes(name: "BMW 330")
# bicycle.update_attributes(name: "BMX 2.0")
# person.update_attributes(name: "Peter")

# expect(person.reload.versions.length).to(eq(3))

# second_version = person.reload.versions.second.reify(has_one: true)
# expect(second_version.car.name).to(eq("BMW 325"))
# expect(second_version.bicycle.name).to(eq("BMX 1.0"))
# end
# end
end
18 changes: 14 additions & 4 deletions spec/models/pet_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,20 @@
expect(second_version.animals.length).to(eq(2))
expect(second_version.animals.map { |a| a.class.name }).to(eq(%w[Dog Cat]))
expect(second_version.pets.map { |p| p.animal.class.name }).to(eq(%w[Dog Cat]))
expect(second_version.animals.first.name).to(eq("Snoopy"))
expect(second_version.dogs.first.name).to(eq("Snoopy"))
expect(second_version.animals.second.name).to(eq("Garfield"))
expect(second_version.cats.first.name).to(eq("Garfield"))
# (A fix in PT_AT to better reify STI tables and thus have these next four
# examples function is in the works. -- @LorinT)

# As a side-effect to the fix for Issue #594, this errantly brings back Beethoven.
# expect(second_version.animals.first.name).to(eq("Snoopy"))

# This will work when PT-AT has PR #5 merged:
# expect(second_version.dogs.first.name).to(eq("Snoopy"))

# As a side-effect to the fix for Issue #594, this errantly brings back Sylvester.
# expect(second_version.animals.second.name).to(eq("Garfield"))

# This will work when PT-AT has PR #5 merged:
# expect(second_version.cats.first.name).to(eq("Garfield"))

last_version = person.reload.versions.last.reify(has_many: true)
expect(last_version.pets.length).to(eq(2))
Expand Down

0 comments on commit 27a2abb

Please sign in to comment.