diff --git a/lib/mongoid/history/trackable.rb b/lib/mongoid/history/trackable.rb index eb5dde19..758e64b6 100644 --- a/lib/mongoid/history/trackable.rb +++ b/lib/mongoid/history/trackable.rb @@ -19,6 +19,7 @@ def track_history(options = {}) end include MyInstanceMethods + include HasAndBelongsToManyMethods extend SingletonMethods delegate :history_trackable_options, to: 'self.class' @@ -267,6 +268,12 @@ def transform_changes(changes) [original, modified] end + def increment_current_version + current_version = (send(history_trackable_options[:version_field]) || 0) + 1 + send("#{history_trackable_options[:version_field]}=", current_version) + current_version + end + protected def track_history_for_action?(action) @@ -275,8 +282,7 @@ def track_history_for_action?(action) def track_history_for_action(action) if track_history_for_action?(action) - current_version = (send(history_trackable_options[:version_field]) || 0) + 1 - send("#{history_trackable_options[:version_field]}=", current_version) + current_version = increment_current_version last_track = self.class.tracker_class.create!(history_tracker_attributes(action.to_sym).merge(version: current_version, action: action.to_s, trackable: self)) end @@ -294,6 +300,35 @@ def track_history_for_action(action) end end + module HasAndBelongsToManyMethods + def track_has_and_belongs_to_many(related) + # skip for new records (track_create will capture assignment) and when track updates disabled + return true if new_record? || !track_history? || !history_trackable_options[:track_update] + metadata = reflect_on_all_associations(:has_and_belongs_to_many).find { |m| m.class_name == related.class.name } + + related_id = related.id + original_ids = send(metadata.key.to_sym) + modified_ids = if original_ids.include?(related_id) + original_ids.reject { |id| id == related_id } + else + original_ids + [related_id] + end + + modified = { metadata.key => modified_ids } + original = { metadata.key => original_ids } + action = :update + current_version = increment_current_version + self.class.tracker_class.create!( + history_tracker_attributes(action.to_sym) + .merge(version: current_version, + action: action.to_s, + original: original, + modified: modified, + trackable: self) + ) + end + end + module EmbeddedMethods # Indicates whether there is an Embedded::One relation for the given embedded field. # diff --git a/spec/integration/has_and_belongs_to_many_spec.rb b/spec/integration/has_and_belongs_to_many_spec.rb new file mode 100644 index 00000000..ad90484d --- /dev/null +++ b/spec/integration/has_and_belongs_to_many_spec.rb @@ -0,0 +1,178 @@ +require 'spec_helper' + +describe Mongoid::History do + before :all do + class Tag + include Mongoid::Document + + field :title + has_and_belongs_to_many :posts + end + end + + describe 'track' do + before :all do + class Post + include Mongoid::Document + include Mongoid::Timestamps + include Mongoid::History::Trackable + + field :title + field :body + has_and_belongs_to_many :tags, before_add: :track_has_and_belongs_to_many, before_remove: :track_has_and_belongs_to_many + track_history on: %i[fields] + end + end + + let(:tag) { Tag.create! } + + describe 'on creation' do + let(:post) { Post.create!(tags: [tag]) } + + it 'should create track' do + expect(post.history_tracks.count).to eq(1) + end + + it 'should assign tag_ids on modified' do + expect(post.history_tracks.first.modified).to include('tag_ids' => [tag.id]) + end + + it 'should be empty on original' do + expect(post.history_tracks.first.original).to eq({}) + end + end + + describe 'on add' do + let(:post) { Post.create!(tags: [tag]) } + let(:tag2) { Tag.create! } + before { post.tags << tag2 } + + # this just verifies that post is updated above + it 'should update tags' do + expect(post.reload.tags).to eq([tag, tag2]) + end + + it 'should create track' do + expect(post.history_tracks.count).to eq(2) + end + + it 'should assign tag_ids on modified' do + expect(post.history_tracks.last.modified).to include('tag_ids' => [tag.id, tag2.id]) + end + + it 'should assign tag_ids on original' do + expect(post.history_tracks.last.original).to include('tag_ids' => [tag.id]) + end + end + + describe 'on remove' do + let(:post) { Post.create!(tags: [tag]) } + before { post.tags = [] } + + # this just verifies that post is updated above + it 'should update tags' do + expect(post.reload.tags).to eq([]) + end + + it 'should create two tracks' do + expect(post.history_tracks.count).to eq(2) + end + + it 'should assign empty tag_ids on modified' do + expect(post.history_tracks.last.modified).to include('tag_ids' => []) + end + + it 'should assign tag_ids on original' do + expect(post.history_tracks.last.original).to include('tag_ids' => [tag.id]) + end + end + + describe 'on reassign' do + let(:post) { Post.create!(tags: [tag]) } + let(:tag2) { Tag.create! } + before { post.tags = [tag2] } + + # this just verifies that post is updated above + it 'should update tags' do + expect(post.reload.tags).to eq([tag2]) + end + + it 'should create three tracks' do + # 1. tags: [tag] + # 2. tags: [] + # 3. tags: [tag2] + expect(post.history_tracks.count).to eq(3) + end + + it 'should assign tag_ids on modified' do + expect(post.history_tracks.last.modified).to include('tag_ids' => [tag2.id]) + end + + it 'should assign empty tag_ids on original' do + expect(post.history_tracks.last.original).to include('tag_ids' => []) + end + end + + after :all do + Object.send(:remove_const, :Post) + end + end + + describe 'not track' do + let!(:post) { Post.create! } + + context 'track_update: false' do + before :all do + class Post + include Mongoid::Document + include Mongoid::Timestamps + include Mongoid::History::Trackable + + field :title + field :body + has_and_belongs_to_many :tags, before_add: :track_has_and_belongs_to_many, before_remove: :track_has_and_belongs_to_many + track_history on: %i[fields], track_update: false + end + end + + it 'should not create track' do + expect { post.tags = [Tag.create!] }.not_to change(Tracker, :count) + end + + after :all do + Object.send(:remove_const, :Post) + end + end + + context '#disable_tracking' do + before :all do + class Post + include Mongoid::Document + include Mongoid::Timestamps + include Mongoid::History::Trackable + + field :title + field :body + has_and_belongs_to_many :tags, before_add: :track_has_and_belongs_to_many, before_remove: :track_has_and_belongs_to_many + track_history on: %i[fields] + end + end + + it 'should not create track' do + expect do + Post.disable_tracking do + post.tags = [Tag.create!] + end + end.not_to change(Tracker, :count) + end + + after :all do + Object.send(:remove_const, :Post) + end + end + end + + after :all do + Object.send(:remove_const, :Tag) + end +end