Skip to content

Commit

Permalink
Added support for has_and_belongs_to_many association tracking.
Browse files Browse the repository at this point in the history
  • Loading branch information
dblock committed Jan 18, 2018
1 parent c2ff8d2 commit 659698b
Show file tree
Hide file tree
Showing 7 changed files with 208 additions and 79 deletions.
38 changes: 28 additions & 10 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -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:47:30 -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
Expand Down Expand Up @@ -31,9 +31,9 @@ Lint/ParenthesesAsGroupedExpression:
Exclude:
- 'spec/integration/integration_spec.rb'

# Offense count: 21
# Offense count: 22
Metrics/AbcSize:
Max: 45
Max: 62

# Offense count: 114
# Configuration parameters: CountComments, ExcludedMethods.
Expand All @@ -43,29 +43,29 @@ Metrics/BlockLength:
# Offense count: 1
# Configuration parameters: CountComments.
Metrics/ClassLength:
Max: 114
Max: 123

# Offense count: 5
# Offense count: 6
Metrics/CyclomaticComplexity:
Max: 10

# Offense count: 457
# Offense count: 461
# 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: 25

# Offense count: 2
# Configuration parameters: CountComments.
Metrics/ModuleLength:
Max: 180
Max: 200

# Offense count: 5
# Offense count: 6
Metrics/PerceivedComplexity:
Max: 12

Expand Down Expand Up @@ -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'
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
:rereferenced_relations
] # only embedded and references relations will be tracked
end
```

Expand Down
14 changes: 12 additions & 2 deletions lib/mongoid/history/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,25 @@ 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)
end

if options[:on].include?(:embedded_relations)
@options[:on] = options[:on].reject { |opt| opt == :embedded_relations }
p trackable.embedded_relations.keys
@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)
Expand Down Expand Up @@ -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
Expand Down
89 changes: 50 additions & 39 deletions lib/mongoid/history/trackable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -19,7 +26,6 @@ def track_history(options = {})
end

include MyInstanceMethods
include HasAndBelongsToManyMethods
extend SingletonMethods

delegate :history_trackable_options, to: 'self.class'
Expand Down Expand Up @@ -249,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
Expand Down Expand Up @@ -300,44 +334,6 @@ def track_history_for_action(action)
end
end

module HasAndBelongsToManyMethods
def track_has_and_belongs_to_many(related)
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
self.class.tracker_class.create!(
history_tracker_attributes(action.to_sym)
.merge(version: increment_and_set_version,
action: action.to_s,
original: original,
modified: modified,
trackable: self)
)
end

private

def increment_and_set_version
if Mongoid::Compatibility::Version.mongoid3?
inc(:version, 1)
else
current_version = (version || 0) + 1
set(version: current_version)
current_version
end
end
end

module RelationMethods
# Returns a relation class for the given field.
#
Expand Down Expand Up @@ -367,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.
Expand Down Expand Up @@ -477,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
Expand Down
87 changes: 73 additions & 14 deletions spec/integration/has_and_belongs_to_many_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,6 @@

describe Mongoid::History 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[all], track_create: false, track_update: false
end

class Tag
include Mongoid::Document

Expand All @@ -22,6 +11,19 @@ class Tag
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
Expand All @@ -35,8 +37,8 @@ class Tag
expect(post.history_tracks.first.modified).to include('tag_ids' => [tag.id])
end

it 'should assign empty tag_ids on original' do
expect(post.history_tracks.first.original).to include('tag_ids' => [])
it 'should be empty on original' do
expect(post.history_tracks.first.original).to eq({})
end
end

Expand Down Expand Up @@ -110,10 +112,67 @@ class Tag
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, :Post)
Object.send(:remove_const, :Tag)
end
end
Loading

0 comments on commit 659698b

Please sign in to comment.