Added has_and_belongs_to_many reification
HABTM associations are saved in the version_assocations table with the foreign_key_name set to the association name. They are saved if either the associated model is being paper trailed, or if the option join_tables: [:association_name] is passed to the has_paper_trail declaration. If the option is passed but the associated model is not paper trailed, only the join model will be saved. This means reification of HABTM would reify the associated objects but in their current state. If the associated model is paper trailed, this option does nothing, and the version of the model at the time is reified.
This commit is contained in:
parent
7733894b0d
commit
8b528ca9de
|
@ -12,7 +12,7 @@ Metrics/CyclomaticComplexity:
|
||||||
# Offense count: 2
|
# Offense count: 2
|
||||||
# Configuration parameters: CountComments.
|
# Configuration parameters: CountComments.
|
||||||
Metrics/ModuleLength:
|
Metrics/ModuleLength:
|
||||||
Max: 281
|
Max: 299
|
||||||
|
|
||||||
# Offense count: 6
|
# Offense count: 6
|
||||||
Metrics/PerceivedComplexity:
|
Metrics/PerceivedComplexity:
|
||||||
|
|
|
@ -20,6 +20,8 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- [#771](https://github.com/airblade/paper_trail/pull/771) -
|
||||||
|
Added support for has_and_belongs_to_many associations
|
||||||
- [#741](https://github.com/airblade/paper_trail/issues/741) /
|
- [#741](https://github.com/airblade/paper_trail/issues/741) /
|
||||||
[#681](https://github.com/airblade/paper_trail/pull/681)
|
[#681](https://github.com/airblade/paper_trail/pull/681)
|
||||||
MySQL unicode support in migration generator
|
MySQL unicode support in migration generator
|
||||||
|
|
|
@ -6,6 +6,7 @@ module PaperTrail
|
||||||
module Model
|
module Model
|
||||||
def self.included(base)
|
def self.included(base)
|
||||||
base.send :extend, ClassMethods
|
base.send :extend, ClassMethods
|
||||||
|
base.send :attr_accessor, :paper_trail_habtm
|
||||||
end
|
end
|
||||||
|
|
||||||
# :nodoc:
|
# :nodoc:
|
||||||
|
@ -46,6 +47,12 @@ module PaperTrail
|
||||||
# the instance was reified from. Default is `:version`.
|
# the instance was reified from. Default is `:version`.
|
||||||
# - :save_changes - Whether or not to save changes to the object_changes
|
# - :save_changes - Whether or not to save changes to the object_changes
|
||||||
# column if it exists. Default is true
|
# column if it exists. Default is true
|
||||||
|
# - :join_tables - If the model has a has_and_belongs_to_many relation
|
||||||
|
# with an unpapertrailed model, passing the name of the association to
|
||||||
|
# the join_tables option will paper trail the join table but not save
|
||||||
|
# the other model, allowing reification of the association but with the
|
||||||
|
# other models latest state (if the other model is paper trailed, this
|
||||||
|
# option does nothing)
|
||||||
#
|
#
|
||||||
def has_paper_trail(options = {})
|
def has_paper_trail(options = {})
|
||||||
options[:on] ||= [:create, :update, :destroy]
|
options[:on] ||= [:create, :update, :destroy]
|
||||||
|
@ -57,6 +64,42 @@ module PaperTrail
|
||||||
setup_model_for_paper_trail(options)
|
setup_model_for_paper_trail(options)
|
||||||
|
|
||||||
setup_callbacks_from_options options[:on]
|
setup_callbacks_from_options options[:on]
|
||||||
|
|
||||||
|
setup_callbacks_for_habtm options[:join_tables]
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_for_callback(name, callback, model, assoc)
|
||||||
|
model.paper_trail_habtm ||= {}
|
||||||
|
model.paper_trail_habtm.reverse_merge!(name => { removed: [], added: [] })
|
||||||
|
case callback
|
||||||
|
when :before_add
|
||||||
|
model.paper_trail_habtm[name][:added] |= [assoc.id]
|
||||||
|
model.paper_trail_habtm[name][:removed] -= [assoc.id]
|
||||||
|
when :before_remove
|
||||||
|
model.paper_trail_habtm[name][:removed] |= [assoc.id]
|
||||||
|
model.paper_trail_habtm[name][:added] -= [assoc.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
attr_reader :paper_trail_save_join_tables
|
||||||
|
|
||||||
|
def setup_callbacks_for_habtm(join_tables)
|
||||||
|
@paper_trail_save_join_tables = Array.wrap(join_tables)
|
||||||
|
# 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
|
end
|
||||||
|
|
||||||
def setup_model_for_paper_trail(options = {})
|
def setup_model_for_paper_trail(options = {})
|
||||||
|
@ -442,6 +485,11 @@ module PaperTrail
|
||||||
# Saves associations if the join table for `VersionAssociation` exists.
|
# Saves associations if the join table for `VersionAssociation` exists.
|
||||||
def save_associations(version)
|
def save_associations(version)
|
||||||
return unless PaperTrail.config.track_associations?
|
return unless PaperTrail.config.track_associations?
|
||||||
|
save_associations_belongs_to(version)
|
||||||
|
save_associations_has_and_belongs_to_many(version)
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_associations_belongs_to(version)
|
||||||
self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
||||||
assoc_version_args = {
|
assoc_version_args = {
|
||||||
version_id: version.id,
|
version_id: version.id,
|
||||||
|
@ -463,6 +511,27 @@ module PaperTrail
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def save_associations_has_and_belongs_to_many(version)
|
||||||
|
# Use the :added and :removed keys to extrapolate the HABTM associations
|
||||||
|
# to before any changes were made
|
||||||
|
self.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
|
||||||
|
next unless
|
||||||
|
self.class.paper_trail_save_join_tables.include?(a.name) ||
|
||||||
|
a.klass.paper_trail_enabled_for_model?
|
||||||
|
assoc_version_args = {
|
||||||
|
version_id: version.id,
|
||||||
|
foreign_key_name: a.name
|
||||||
|
}
|
||||||
|
assoc_ids =
|
||||||
|
send(a.name).to_a.map(&:id) +
|
||||||
|
(@paper_trail_habtm.try(:[], a.name).try(:[], :removed) || []) -
|
||||||
|
(@paper_trail_habtm.try(:[], a.name).try(:[], :added) || [])
|
||||||
|
assoc_ids.each do |id|
|
||||||
|
PaperTrail::VersionAssociation.create(assoc_version_args.merge(foreign_key_id: id))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def reset_transaction_id
|
def reset_transaction_id
|
||||||
PaperTrail.transaction_id = nil
|
PaperTrail.transaction_id = nil
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,6 +14,7 @@ module PaperTrail
|
||||||
has_one: false,
|
has_one: false,
|
||||||
has_many: false,
|
has_many: false,
|
||||||
belongs_to: false,
|
belongs_to: false,
|
||||||
|
has_and_belongs_to_many: false,
|
||||||
unversioned_attributes: :nil
|
unversioned_attributes: :nil
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -104,7 +105,8 @@ module PaperTrail
|
||||||
elsif version.event == "create"
|
elsif version.event == "create"
|
||||||
options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
|
options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
|
||||||
else
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -113,7 +115,8 @@ module PaperTrail
|
||||||
# associations.
|
# associations.
|
||||||
array.concat(
|
array.concat(
|
||||||
versions.values.map { |v|
|
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))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -128,6 +131,10 @@ module PaperTrail
|
||||||
reify_belongs_tos version.transaction_id, model, options if options[:belongs_to]
|
reify_belongs_tos version.transaction_id, model, options if options[:belongs_to]
|
||||||
|
|
||||||
reify_has_manys version.transaction_id, model, options if options[:has_many]
|
reify_has_manys version.transaction_id, model, options if options[:has_many]
|
||||||
|
|
||||||
|
if options[:has_and_belongs_to_many]
|
||||||
|
reify_has_and_belongs_to_many version.transaction_id, model, options
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Restore the `model`'s has_one associations as they were when this
|
# Restore the `model`'s has_one associations as they were when this
|
||||||
|
@ -154,7 +161,8 @@ module PaperTrail
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
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
|
model.appear_as_new_record do
|
||||||
without_persisting(child) do
|
without_persisting(child) do
|
||||||
model.send "#{assoc.name}=", child
|
model.send "#{assoc.name}=", child
|
||||||
|
@ -181,7 +189,8 @@ module PaperTrail
|
||||||
assoc.klass.where(assoc.klass.primary_key => collection_key).first
|
assoc.klass.where(assoc.klass.primary_key => collection_key).first
|
||||||
else
|
else
|
||||||
version.reify(options.merge(has_many: false, has_one: false,
|
version.reify(options.merge(has_many: false, has_one: false,
|
||||||
belongs_to: false))
|
belongs_to: false,
|
||||||
|
has_and_belongs_to_many: false))
|
||||||
end
|
end
|
||||||
|
|
||||||
model.send("#{assoc.name}=".to_sym, collection)
|
model.send("#{assoc.name}=".to_sym, collection)
|
||||||
|
@ -276,6 +285,38 @@ module PaperTrail
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reify_has_and_belongs_to_many(transaction_id, model, options = {})
|
||||||
|
model.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
|
||||||
|
papertrail_enabled = a.klass.paper_trail_enabled_for_model?
|
||||||
|
next unless
|
||||||
|
model.class.paper_trail_save_join_tables.include?(a.name) ||
|
||||||
|
papertrail_enabled
|
||||||
|
|
||||||
|
version_ids = PaperTrail::VersionAssociation.
|
||||||
|
where("foreign_key_name = ?", a.name).
|
||||||
|
where("version_id = ?", transaction_id).
|
||||||
|
pluck(:foreign_key_id)
|
||||||
|
|
||||||
|
model.send(a.name).proxy_association.target =
|
||||||
|
version_ids.map do |id|
|
||||||
|
if papertrail_enabled
|
||||||
|
version = a.klass.paper_trail_version_class.
|
||||||
|
where("item_type = ?", a.klass.name).
|
||||||
|
where("item_id = ?", id).
|
||||||
|
where("created_at >= ? OR transaction_id = ?",
|
||||||
|
options[:version_at], transaction_id).
|
||||||
|
order("id").limit(1).first
|
||||||
|
if version
|
||||||
|
next version.reify(options.merge(has_many: false, has_one: false,
|
||||||
|
belongs_to: false,
|
||||||
|
has_and_belongs_to_many: false))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
a.klass.where(a.klass.primary_key => id).first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Given a `version`, return the class to reify. This method supports
|
# Given a `version`, return the class to reify. This method supports
|
||||||
# Single Table Inheritance (STI) with custom inheritance columns.
|
# Single Table Inheritance (STI) with custom inheritance columns.
|
||||||
#
|
#
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
class BarHabtm < ActiveRecord::Base
|
||||||
|
has_and_belongs_to_many :foo_habtms
|
||||||
|
has_paper_trail
|
||||||
|
end
|
|
@ -0,0 +1,4 @@
|
||||||
|
class FooHabtm < ActiveRecord::Base
|
||||||
|
has_and_belongs_to_many :bar_habtms
|
||||||
|
has_paper_trail
|
||||||
|
end
|
|
@ -249,9 +249,27 @@ class SetUpTestTables < ActiveRecord::Migration
|
||||||
create_table :citations, force: true do |t|
|
create_table :citations, force: true do |t|
|
||||||
t.integer :quotation_id
|
t.integer :quotation_id
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def down
|
def down
|
||||||
|
drop_table :bar_habtms_foo_habtms
|
||||||
|
drop_table :foo_habtms
|
||||||
|
drop_table :bar_habtms
|
||||||
drop_table :citations
|
drop_table :citations
|
||||||
drop_table :quotations
|
drop_table :quotations
|
||||||
drop_table :animals
|
drop_table :animals
|
||||||
|
|
|
@ -891,4 +891,102 @@ class AssociationsTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|
Loading…
Reference in New Issue