Skip to content

Commit

Permalink
Added has_and_belongs_to_many reification
Browse files Browse the repository at this point in the history
HABTM associations are saved as attributes with the name
association_ids as an array of the associated model IDs. Changes
made to HABTM associations are stored on the model so on save the
original associations can be stored in the serialized object. On
reification, if the has_and_belongs_to_many option is not passed,
the serialized attributes are removed such that they are not
reified. If it is passed, the association is reified and the
version of the associated model at the time is used for
reification.
  • Loading branch information
Sam Boylett committed Apr 11, 2016
1 parent 7733894 commit 677e67a
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 7 deletions.
2 changes: 1 addition & 1 deletion .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Metrics/CyclomaticComplexity:
# Offense count: 2
# Configuration parameters: CountComments.
Metrics/ModuleLength:
Max: 281
Max: 285

# Offense count: 6
Metrics/PerceivedComplexity:
Expand Down
47 changes: 47 additions & 0 deletions lib/paper_trail/has_paper_trail.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ def has_paper_trail(options = {})
setup_model_for_paper_trail(options)

setup_callbacks_from_options options[:on]

setup_callbacks_for_habtm
end

def update_for_callback(name, callback, model, assoc)
paper_trail_habtm = model.instance_variable_get(:@paper_trail_habtm) || {}
paper_trail_habtm.reverse_merge!(name => { removed: [], added: [] })
case callback
when :before_add
paper_trail_habtm[name][:added] |= [assoc.id]
paper_trail_habtm[name][:removed] -= [assoc.id]
when :before_remove
paper_trail_habtm[name][:removed] |= [assoc.id]
paper_trail_habtm[name][:added] -= [assoc.id]
end
model.instance_variable_set(:@paper_trail_habtm, paper_trail_habtm)
end

def setup_callbacks_for_habtm
# Adds callbacks to record changes to habtm associations such that on
# save the previous version of the association (if changed) can be
# interpreted
reflect_on_all_associations(:has_and_belongs_to_many).
reject { |a| paper_trail_options[:skip].include?(a.name.to_s) }.
each do |a|
added_callback = lambda do |*args|
update_for_callback(a.name, :before_add, args[-2], args.last)
end
removed_callback = lambda do |*args|
update_for_callback(a.name, :before_remove, args[-2], args.last)
end
send(:"before_add_for_#{a.name}").send(:<<, added_callback)
send(:"before_remove_for_#{a.name}").send(:<<, removed_callback)
end
end

def setup_model_for_paper_trail(options = {})
Expand Down Expand Up @@ -492,6 +526,19 @@ def merge_metadata(data)

def attributes_before_change
changed = changed_attributes.select { |k, _v| self.class.column_names.include?(k) }

# Add the attribute_ids for habtm associations and use the :added and
# :removed keys to extrapolate the association to before any changes
# were made
self.class.reflect_on_all_associations(:has_and_belongs_to_many).
reject { |a| paper_trail_options[:skip].include?(a.name.to_s) }.
each { |a|
changed["#{a.name.to_s.singularize}_ids"] ||=
send(a.name).to_a.map(&:id) +
(@paper_trail_habtm.try(:[], a.name).try(:[], :removed) || []) -
(@paper_trail_habtm.try(:[], a.name).try(:[], :added) || [])
}

attributes.merge(changed)
end

Expand Down
52 changes: 46 additions & 6 deletions lib/paper_trail/reifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def reify(version, options)
has_one: false,
has_many: false,
belongs_to: false,
has_and_belongs_to_many: false,
unversioned_attributes: :nil
)

Expand Down Expand Up @@ -51,7 +52,7 @@ def reify(version, options)
end
end

reify_attributes(model, version, attrs)
reify_attributes(model, version, attrs, options)
model.send "#{model.class.version_association_name}=", version
reify_associations(model, options, version)
model
Expand All @@ -60,10 +61,14 @@ def reify(version, options)
private

# Set all the attributes in this version on the model.
def reify_attributes(model, version, attrs)
def reify_attributes(model, version, attrs, options)
enums = model.class.respond_to?(:defined_enums) ? model.class.defined_enums : {}
model.class.unserialize_attributes_for_paper_trail! attrs

# Reifies habtm if the option is passed, otherwise deletes the habtm
# attributes so they are not reified
reify_has_and_belongs_to_many(version.transaction_id, model, attrs, options)

attrs.each do |k, v|
# `unserialize_attributes_for_paper_trail!` will return the mapped enum value
# and in Rails < 5, the []= uses the integer type caster from the column
Expand All @@ -83,6 +88,37 @@ def reify_attributes(model, version, attrs)
end
end

def reify_has_and_belongs_to_many(transaction_id, model, attrs, options)
# has_and_belongs_to_many associations are always saved, but if the
# option is not set, the associated attributes must be removed so they
# are not reified by reify_attributes
model.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
# Don't let habtm associations be reified as attributes
ids = attrs.delete("#{a.name.to_s.singularize}_ids")
next unless options[:has_and_belongs_to_many]
model.send(a.name).proxy_association.target =
if ids.present?
ids.map do |id|
klass = a.name.to_s.singularize.classify.constantize
version = klass.paper_trail_version_class.
where("item_type = ?", a.name.to_s.singularize.classify).
where("item_id = ?", id).
where("created_at >= ? OR transaction_id = ?",
options[:version_at], transaction_id).
order("id").limit(1).first
if version
version.reify(options.merge(has_many: false, has_one: false,
belongs_to: false, has_and_belongs_to_many: false))
else
klass.where(klass.primary_key => id).first
end
end
else
[]
end
end
end

# Replaces each record in `array` with its reified version, if present
# in `versions`.
#
Expand All @@ -104,7 +140,8 @@ def prepare_array_for_has_many(array, options, versions)
elsif version.event == "create"
options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
else
version.reify(options.merge(has_many: false, has_one: false, belongs_to: false))
version.reify(options.merge(has_many: false, has_one: false, belongs_to: false,
has_and_belongs_to_many: false))
end
end

Expand All @@ -113,7 +150,8 @@ def prepare_array_for_has_many(array, options, versions)
# associations.
array.concat(
versions.values.map { |v|
v.reify(options.merge(has_many: false, has_one: false, belongs_to: false))
v.reify(options.merge(has_many: false, has_one: false, belongs_to: false,
has_and_belongs_to_many: false))
}
)

Expand Down Expand Up @@ -154,7 +192,8 @@ def reify_has_ones(transaction_id, model, options = {})
end
end
else
child = version.reify(options.merge(has_many: false, has_one: false, belongs_to: false))
child = version.reify(options.merge(has_many: false, has_one: false, belongs_to: false,
has_and_belongs_to_many: false))
model.appear_as_new_record do
without_persisting(child) do
model.send "#{assoc.name}=", child
Expand All @@ -181,7 +220,8 @@ def reify_belongs_tos(transaction_id, model, options = {})
assoc.klass.where(assoc.klass.primary_key => collection_key).first
else
version.reify(options.merge(has_many: false, has_one: false,
belongs_to: false))
belongs_to: false,
has_and_belongs_to_many: false))
end

model.send("#{assoc.name}=".to_sym, collection)
Expand Down
4 changes: 4 additions & 0 deletions test/dummy/app/models/bar_habtm.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class BarHabtm < ActiveRecord::Base
has_and_belongs_to_many :foo_habtms
has_paper_trail
end
4 changes: 4 additions & 0 deletions test/dummy/app/models/foo_habtm.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class FooHabtm < ActiveRecord::Base
has_and_belongs_to_many :bar_habtms
has_paper_trail
end
18 changes: 18 additions & 0 deletions test/dummy/db/migrate/20110208155312_set_up_test_tables.rb
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,27 @@ def up
create_table :citations, force: true do |t|
t.integer :quotation_id
end

create_table :foo_habtms, force: true do |t|
t.string :name
end

create_table :bar_habtms, force: true do |t|
t.string :name
end

create_table :bar_habtms_foo_habtms, force: true, id: false do |t|
t.integer :foo_habtm_id
t.integer :bar_habtm_id
end
add_index :bar_habtms_foo_habtms, [:foo_habtm_id]
add_index :bar_habtms_foo_habtms, [:bar_habtm_id]
end

def down
drop_table :bar_habtms_foo_habtms
drop_table :foo_habtms
drop_table :bar_habtms
drop_table :citations
drop_table :quotations
drop_table :animals
Expand Down
98 changes: 98 additions & 0 deletions test/unit/associations_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -891,4 +891,102 @@ class AssociationsTest < ActiveSupport::TestCase
end
end
end

context "has_and_belongs_to_many associations" do
context "foo and bar" do
setup do
@foo = FooHabtm.create(name: "foo")
Timecop.travel 1.second.since
end

context "where the association is created between model versions" do
setup do
@foo.update_attributes(name: "foo1", bar_habtms: [BarHabtm.create(name: "bar")])
end

context "when reified" do
setup { @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) }

should "see the associated as it was at the time" do
assert_equal 0, @reified.bar_habtms.length
end

should "not persist changes to the live association" do
assert_not_equal @reified.bar_habtms, @foo.reload.bar_habtms
end
end
end

context "where the association is changed between model versions" do
setup do
@foo.update_attributes(name: "foo2", bar_habtms: [BarHabtm.create(name: "bar2")])
Timecop.travel 1.second.since
@foo.update_attributes(name: "foo3", bar_habtms: [BarHabtm.create(name: "bar3")])
end

context "when reified" do
setup { @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) }

should "see the association as it was at the time" do
assert_equal "bar2", @reified.bar_habtms.first.name
end

should "not persist changes to the live association" do
assert_not_equal @reified.bar_habtms.first, @foo.reload.bar_habtms.first
end
end

context "when reified with has_and_belongs_to_many: false" do
setup { @reified = @foo.versions.last.reify }

should "see the association as it is now" do
assert_equal "bar3", @reified.bar_habtms.first.name
end
end
end

context "where the association is destroyed between model versions" do
setup do
@foo.update_attributes(name: "foo2", bar_habtms: [BarHabtm.create(name: "bar2")])
Timecop.travel 1.second.since
@foo.update_attributes(name: "foo3", bar_habtms: [])
end

context "when reified" do
setup { @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) }

should "see the association as it was at the time" do
assert_equal "bar2", @reified.bar_habtms.first.name
end

should "not persist changes to the live association" do
assert_not_equal @reified.bar_habtms.first, @foo.reload.bar_habtms.first
end
end
end

context "where the unassociated model changes" do
setup do
@bar = BarHabtm.create(name: "bar2")
@foo.update_attributes(name: "foo2", bar_habtms: [@bar])
Timecop.travel 1.second.since
@foo.update_attributes(name: "foo3", bar_habtms: [BarHabtm.create(name: "bar4")])
Timecop.travel 1.second.since
@bar.update_attributes(name: "bar3")
end

context "when reified" do
setup { @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) }

should "see the association as it was at the time" do
assert_equal "bar2", @reified.bar_habtms.first.name
end

should "not persist changes to the live association" do
assert_not_equal @reified.bar_habtms.first, @foo.reload.bar_habtms.first
end
end
end
end
end
end

0 comments on commit 677e67a

Please sign in to comment.