diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index edadcd84..ff9267d6 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2018-01-16 15:37:33 -0500 using RuboCop version 0.48.1. +# on 2018-01-17 21:53:45 -0500 using RuboCop version 0.48.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -31,9 +31,9 @@ Lint/ParenthesesAsGroupedExpression: Exclude: - 'spec/integration/integration_spec.rb' -# Offense count: 21 +# Offense count: 22 Metrics/AbcSize: - Max: 45 + Max: 69 # Offense count: 114 # Configuration parameters: CountComments, ExcludedMethods. @@ -43,29 +43,29 @@ Metrics/BlockLength: # Offense count: 1 # Configuration parameters: CountComments. Metrics/ClassLength: - Max: 114 + Max: 124 -# Offense count: 5 +# Offense count: 6 Metrics/CyclomaticComplexity: Max: 10 -# Offense count: 457 +# Offense count: 462 # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. # URISchemes: http, https Metrics/LineLength: Max: 688 -# Offense count: 15 +# Offense count: 16 # Configuration parameters: CountComments. Metrics/MethodLength: - Max: 23 + Max: 26 # Offense count: 2 # Configuration parameters: CountComments. Metrics/ModuleLength: - Max: 180 + Max: 200 -# Offense count: 5 +# Offense count: 6 Metrics/PerceivedComplexity: Max: 12 @@ -107,3 +107,21 @@ Style/IfInsideElse: Style/MultilineBlockChain: Exclude: - 'lib/mongoid/history/tracker.rb' + +# Offense count: 1 +# Cop supports --auto-correct. +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: symmetrical, new_line, same_line +Style/MultilineMethodCallBraceLayout: + Exclude: + - 'spec/unit/options_spec.rb' + +# Offense count: 2 +# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist. +# NamePrefix: is_, has_, have_ +# NamePrefixBlacklist: is_, has_, have_ +# NameWhitelist: is_a? +Style/PredicateName: + Exclude: + - 'spec/**/*' + - 'lib/mongoid/history/trackable.rb' diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cf347e..3c540691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 0.8.1 (Next) +* [#217](https://github.com/mongoid/mongoid-history/pull/217): Support for tracking `has_and_belongs_to_many` associations - [@dblock](https://github.com/dblock). * Your contribution here. ### 0.8.0 (2018/01/16) diff --git a/README.md b/README.md index 9540d85a..21fc2553 100644 --- a/README.md +++ b/README.md @@ -168,11 +168,11 @@ class Post field :body field :rating - track_history :on => [:fields] # all fields will be tracked + track_history :on => [ :fields ] # only fields will be tracked end ``` -You can also track changes on all embedded relations. +You can also track changes on all embedded (`embeds_one` and `embeds_many`) or referenced (`has_and_belongs_to_many`) relations. ```ruby class Post @@ -182,7 +182,10 @@ class Post embeds_many :comments embeds_one :content - track_history :on => [:embedded_relations] # all embedded relations will be tracked + track_history :on => [ + :embedded_relations, + :referenced_relations + ] # only embedded and references relations will be tracked end ``` diff --git a/lib/mongoid/history/attributes/create.rb b/lib/mongoid/history/attributes/create.rb index 31c123ca..6b334e0c 100644 --- a/lib/mongoid/history/attributes/create.rb +++ b/lib/mongoid/history/attributes/create.rb @@ -4,6 +4,16 @@ module Attributes class Create < ::Mongoid::History::Attributes::Base def attributes @attributes = {} + insert_attributes + insert_embeds_one_changes + insert_embeds_many_changes + insert_has_or_belongs_to_many_changes + @attributes + end + + private + + def insert_attributes trackable.attributes.each do |k, v| next unless trackable_class.tracked_field?(k, :create) modified = if changes[k] @@ -13,13 +23,8 @@ def attributes end @attributes[k] = [nil, format_field(k, modified)] end - insert_embeds_one_changes - insert_embeds_many_changes - @attributes end - private - def insert_embeds_one_changes trackable_class.tracked_embeds_one.each do |rel| rel_class = trackable_class.relation_class_of(rel) @@ -44,6 +49,13 @@ def insert_embeds_many_changes .map { |obj| format_embeds_many_relation(rel, obj.attributes) }] end end + + def insert_has_or_belongs_to_many_changes + trackable_class.referenced_relations.values.each do |rel| + k = rel.key + @attributes[k] = [nil, format_field(k, trackable.send(k))] + end + end end end end diff --git a/lib/mongoid/history/options.rb b/lib/mongoid/history/options.rb index 615e3682..86319bbb 100644 --- a/lib/mongoid/history/options.rb +++ b/lib/mongoid/history/options.rb @@ -82,7 +82,10 @@ def parse_tracked_fields_and_relations if options[:on].include?(:fields) @options[:on] = options[:on].reject { |opt| opt == :fields } - @options[:on] = options[:on] | trackable.fields.keys.map(&:to_sym) - reserved_fields.map(&:to_sym) + @options[:on] = options[:on] | + trackable.fields.keys.map(&:to_sym) - + reserved_fields.map(&:to_sym) - + trackable.referenced_relations.values.map { |r| r.key.to_sym } end if options[:on].include?(:embedded_relations) @@ -90,9 +93,14 @@ def parse_tracked_fields_and_relations @options[:on] = options[:on] | trackable.embedded_relations.keys end + if options[:on].include?(:referenced_relations) + @options[:on] = options[:on].reject { |opt| opt == :referenced_relations } + @options[:on] = options[:on] | trackable.referenced_relations.keys + end + @options[:fields] = [] @options[:dynamic] = [] - @options[:relations] = { embeds_one: {}, embeds_many: {} } + @options[:relations] = { embeds_one: {}, embeds_many: {}, has_and_belongs_to_many: {} } options[:on].each do |option| field = get_database_field_name(option) @@ -146,6 +154,8 @@ def categorize_tracked_option(field, field_options = nil) track_relation(field, :embeds_one, field_options) elsif trackable.embeds_many?(field) track_relation(field, :embeds_many, field_options) + elsif trackable.has_and_belongs_to_many?(field) + track_relation(field, :has_and_belongs_to_many, field_options) elsif trackable.fields.keys.include?(field) @options[:fields] << field else diff --git a/lib/mongoid/history/trackable.rb b/lib/mongoid/history/trackable.rb index 2c0fec78..20500e93 100644 --- a/lib/mongoid/history/trackable.rb +++ b/lib/mongoid/history/trackable.rb @@ -4,6 +4,13 @@ module Trackable extend ActiveSupport::Concern module ClassMethods + def has_and_belongs_to_many(field, opts = {}) + super field, { + before_add: :track_references, + before_remove: :track_references + }.merge(opts) + end + def track_history(options = {}) extend RelationMethods @@ -248,6 +255,34 @@ def track_destroy(&block) track_history_for_action(:destroy, &block) unless destroyed? end + def track_references(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 + def clear_trackable_memoization @history_tracker_attributes = nil @modified_attributes_for_create = nil @@ -328,6 +363,15 @@ def embeds_many?(field) relation_of(field) == Mongoid::Relations::Embedded::Many end + # Indicates whether there is an Referenced::ManyToMany relation for the given embedded field. + # + # @param [ String | Symbol ] field The name of the referenced field. + # + # @return [ Boolean ] true if there is an Referenced::ManyToMany relation for the given referenced field. + def has_and_belongs_to_many?(field) + relation_of(field) == Mongoid::Relations::Referenced::ManyToMany + end + # Retrieves the database representation of an embedded field name, in case the :store_as option is used. # # @param [ String | Symbol ] embed The name or alias of the embedded field. @@ -438,6 +482,12 @@ def reserved_tracked_fields end end + def referenced_relations + relations.select do |_, r| + r.relation == Mongoid::Relations::Referenced::ManyToMany + end + end + def field_formats @field_formats ||= history_trackable_options[:format] end 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..4c63a2ac --- /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 + 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 + 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 + 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 diff --git a/spec/unit/options_spec.rb b/spec/unit/options_spec.rb index ac4a25ee..3057439f 100644 --- a/spec/unit/options_spec.rb +++ b/spec/unit/options_spec.rb @@ -12,6 +12,7 @@ embeds_one :emb_two, store_as: :emtw, inverse_class_name: 'EmbTwo' embeds_many :emb_threes, inverse_class_name: 'EmbThree' embeds_many :emb_fours, store_as: :emfs, inverse_class_name: 'EmbFour' + has_and_belongs_to_many :hatbms, inverse_class_name: 'Hatbm' track_history end @@ -40,6 +41,13 @@ field :f_em_baz embedded_in :model_one end + + Hatbm = Class.new do + include Mongoid::Document + + field :f_hatbm + has_and_belongs_to_many :model_one + end end let(:options) { {} } @@ -141,7 +149,7 @@ track_destroy: true, fields: %w[foo b], dynamic: [], - relations: { embeds_one: {}, embeds_many: {} }, + relations: { embeds_one: {}, embeds_many: {}, has_and_belongs_to_many: {} }, format: {} } end it { expect(service.prepared).to eq expected_options } @@ -256,13 +264,32 @@ it { expect(subject[:dynamic]).to eq %w[my_field] } end - context 'with relations' do + context 'with embedded relations' do let(:options) { { on: :embedded_relations } } it do - expect(subject[:relations]).to eq(embeds_many: { 'emb_threes' => %w[_id f_em_foo fmb], - 'emfs' => %w[_id f_em_baz] }, - embeds_one: { 'emb_one' => %w[_id f_em_foo fmb], - 'emtw' => %w[_id f_em_baz] }) + expect(subject[:relations]).to eq( + embeds_many: { + 'emb_threes' => %w[_id f_em_foo fmb], + 'emfs' => %w[_id f_em_baz] + }, + embeds_one: { + 'emb_one' => %w[_id f_em_foo fmb], + 'emtw' => %w[_id f_em_baz] + }, + has_and_belongs_to_many: {} + ) + end + end + + context 'with referenced relations' do + let(:options) { { on: :referenced_relations } } + it do + expect(subject[:relations]).to eq( + embeds_many: {}, + embeds_one: {}, + has_and_belongs_to_many: { + 'hatbms' => %w[_id f_hatbm model_one_ids] + }) end end end @@ -323,5 +350,6 @@ Object.send(:remove_const, :EmbTwo) Object.send(:remove_const, :EmbThree) Object.send(:remove_const, :EmbFour) + Object.send(:remove_const, :Hatbm) end end diff --git a/spec/unit/trackable_spec.rb b/spec/unit/trackable_spec.rb index 66d194c0..86bd0f5c 100644 --- a/spec/unit/trackable_spec.rb +++ b/spec/unit/trackable_spec.rb @@ -52,7 +52,7 @@ class MyModelWithNoModifier track_update: true, track_destroy: true, fields: %w[foo], - relations: { embeds_one: {}, embeds_many: {} }, + relations: { embeds_one: {}, embeds_many: {}, has_and_belongs_to_many: {} }, dynamic: [], format: {} } end