diff --git a/CHANGELOG.md b/CHANGELOG.md index 088dce3..5e01e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v1.0.0 - May 2026 + +### Breaking changes + +- **Default trackables narrowed to embedded relations only.** Including + `Mongoid::RelationsDirtyTracking` now tracks `embeds_one` and `embeds_many` + relations only. Tracking `has_one`, `has_many`, `has_and_belongs_to_many`, + and `belongs_to` requires opt-in via `relations_dirty_tracking track_referenced: true`. + This is a performance change — tracking referenced relations forces them to be + loaded on every `after_initialize` and `after_save` callback. See the + [Upgrading from 0.x](README.md#upgrading-from-0x) section of the README. + +### Features + +- Add `:track_referenced` option to `relations_dirty_tracking` to re-enable + tracking of referenced relations on a per-class basis. +- Expose `EMBEDDED_TRACKABLES` and `REFERENCED_TRACKABLES` constants for + applications that need to introspect or override the trackable set. + ## v0.3.0 - May 2025 - Support Mongoid 8.0, 8.1, and 9.0 (drop support for earlier versions) diff --git a/README.md b/README.md index 5a7c224..b43cbd4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ class SampleDocument include Mongoid::RelationsDirtyTracking embeds_one :foo - has_many :bars + embeds_many :bars field :title, type: String end @@ -39,9 +39,9 @@ doc.bars << Bar.new(title: 'bar') doc.title = 'New title' doc.relations_changed? # => true -doc.relation_changes # => {"foo" => [nil, {"_id"=>"524c35ad1ac1c23084000040", "title" => "foo"}], "bars" => [nil, [{"_id"=>"524c35ad1ac1c23084000083"}]]} +doc.relation_changes # => {"foo" => [nil, {"_id"=>"524c35ad1ac1c23084000040", "title" => "foo"}], "bars" => [[], [{"_id"=>"524c35ad1ac1c23084000083", "title"=>"bar"}]]} doc.changed_with_relations? # => true -doc.changes_with_relations # => {"title" => [nil, "New title"], "foo" => [nil, {"_id"=>"524c35ad1ac1c23084000040", "title" => "foo"}], "bars" => [nil, [{"_id"=>"524c35ad1ac1c23084000083"}]]} +doc.changes_with_relations # => {"title" => [nil, "New title"], "foo" => [nil, {"_id"=>"524c35ad1ac1c23084000040", "title" => "foo"}], "bars" => [[], [{"_id"=>"524c35ad1ac1c23084000083", "title"=>"bar"}]]} doc.save doc.relations_changed? # => false @@ -50,6 +50,33 @@ doc.changed_with_relations? # => false doc.changes_with_relations # => {} ``` +By default only **embedded** relations (`embeds_one`, `embeds_many`) are +tracked. To also track referenced relations (`has_one`, `has_many`, +`has_and_belongs_to_many`, `belongs_to`), opt in per class: + +```ruby +class SampleDocument + include Mongoid::Document + include Mongoid::RelationsDirtyTracking + + embeds_many :tags + has_many :comments + belongs_to :author + + relations_dirty_tracking track_referenced: true +end +``` + +You can still narrow tracking further with `:only` and `:except`. These +filters are applied **within** the set of trackable relations selected by +`:track_referenced`: + +```ruby +relations_dirty_tracking only: %i[tags] # embedded only, only :tags +relations_dirty_tracking only: %i[tags comments], track_referenced: true +relations_dirty_tracking except: %i[tags], track_referenced: true # everything except :tags +``` + ## Disablement Relations dirty tracking can be resource intensive, @@ -91,6 +118,84 @@ Mongoid::RelationsDirtyTracking.disable do end ``` +## Upgrading from 0.x + +**v1.0.0 narrowed the default trackables to embedded relations only.** Earlier +versions tracked every kind of relation (embedded, has_one, has_many, HABTM, +belongs_to) by default. We changed the default because tracking referenced +relations forces those associations to be loaded on every `after_initialize` +and `after_save` callback — a significant cost when documents are read in bulk +or via background jobs. + +### How to tell if you are affected + +You are affected if any class in your application includes +`Mongoid::RelationsDirtyTracking` **and** relies on dirty tracking for +`has_one`, `has_many`, `has_and_belongs_to_many`, or `belongs_to` associations. +A class is *not* affected if it only uses dirty tracking for `embeds_one` / +`embeds_many` associations. + +A quick way to audit your codebase: + +```bash +grep -rln "include Mongoid::RelationsDirtyTracking" app/ lib/ +``` + +For each match, check whether the class actually consumes `relation_changes` / +`changes_with_relations` / `relations_changed?` for a referenced relation +(typically read by audit logs, `mongoid-history` callbacks, push-sync hooks, +etc.). + +### Restoring the v0.x behaviour + +If you were depending on referenced relation tracking, add +`track_referenced: true` to the macro call: + +```ruby +# Before (v0.x — tracked everything by default) +class Order + include Mongoid::Document + include Mongoid::RelationsDirtyTracking + + embeds_many :items + has_many :payments +end + +# After (v1.0 — restore previous behaviour for this class) +class Order + include Mongoid::Document + include Mongoid::RelationsDirtyTracking + + embeds_many :items + has_many :payments + + relations_dirty_tracking track_referenced: true +end +``` + +If you already call `relations_dirty_tracking` with `:only` or `:except`, add +the option to that call: + +```ruby +# Before (v0.x) +relations_dirty_tracking only: %i[items payments] + +# After (v1.0) +relations_dirty_tracking only: %i[items payments], track_referenced: true +``` + +### When you can leave the new default + +Most callers can leave the new default and benefit from the speedup. Common +cases where referenced tracking is *not* needed: + +- You only track embedded sub-documents (e.g. line items, addresses). +- You use the gem to invalidate a cache or trigger a webhook when an embedded + collection changes. +- Your referenced relations are also tracked through their own + `mongoid-history` / audit hooks on the **referenced** side, making the + parent-side tracking redundant. + ## Contributing 1. Fork it diff --git a/lib/mongoid/relations_dirty_tracking.rb b/lib/mongoid/relations_dirty_tracking.rb index ed0c963..cc674b0 100644 --- a/lib/mongoid/relations_dirty_tracking.rb +++ b/lib/mongoid/relations_dirty_tracking.rb @@ -7,6 +7,18 @@ module Mongoid module RelationsDirtyTracking extend ActiveSupport::Concern + EMBEDDED_TRACKABLES = [ + Mongoid::Association::Embedded::EmbedsOne::Proxy, + Mongoid::Association::Embedded::EmbedsMany::Proxy + ].freeze + + REFERENCED_TRACKABLES = [ + Mongoid::Association::Referenced::HasOne::Proxy, + Mongoid::Association::Referenced::HasMany::Proxy, + Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy, + Mongoid::Association::Referenced::BelongsTo::Proxy + ].freeze + class << self DISABLED_KEY = 'mongoid/relations_dirty_tracking/disabled' @@ -32,9 +44,17 @@ def thread_store end module ClassMethods + # Configure relations dirty tracking for this class. + # + # @option options [Array] :only Track only the listed relations. + # @option options [Array] :except Skip the listed relations. + # @option options [Boolean] :track_referenced Also track referenced relations + # (has_one, has_many, has_and_belongs_to_many, belongs_to). Defaults to + # `false` — only embedded relations are tracked by default for performance. def relations_dirty_tracking(options = {}) relations_dirty_tracking_options[:only] += [options[:only] || []].flatten.map(&:to_s) relations_dirty_tracking_options[:except] += [options[:except] || []].flatten.map(&:to_s) + relations_dirty_tracking_options[:track_referenced] = true if options[:track_referenced] end def track_relation?(rel_name) @@ -43,12 +63,8 @@ def track_relation?(rel_name) to_track = (!options[:only].blank? && options[:only].include?(rel_name)) || (options[:only].blank? && !options[:except].include?(rel_name)) - trackables = [Mongoid::Association::Embedded::EmbedsOne::Proxy, - Mongoid::Association::Embedded::EmbedsMany::Proxy, - Mongoid::Association::Referenced::HasOne::Proxy, - Mongoid::Association::Referenced::HasMany::Proxy, - Mongoid::Association::Referenced::HasAndBelongsToMany::Proxy, - Mongoid::Association::Referenced::BelongsTo::Proxy] + trackables = EMBEDDED_TRACKABLES + trackables += REFERENCED_TRACKABLES if options[:track_referenced] to_track && trackables.include?(relations[rel_name].try(:relation)) end @@ -63,7 +79,7 @@ def tracked_relations after_save :store_relations_shadow cattr_accessor :relations_dirty_tracking_options - self.relations_dirty_tracking_options = { only: [], except: ['versions'] } + self.relations_dirty_tracking_options = { only: [], except: ['versions'], track_referenced: false } def store_relations_shadow @relations_shadow = {} diff --git a/lib/mongoid/relations_dirty_tracking/version.rb b/lib/mongoid/relations_dirty_tracking/version.rb index 36f14ea..1ff5bac 100644 --- a/lib/mongoid/relations_dirty_tracking/version.rb +++ b/lib/mongoid/relations_dirty_tracking/version.rb @@ -2,6 +2,6 @@ module Mongoid module RelationsDirtyTracking - VERSION = '0.3.0' + VERSION = '1.0.0' end end diff --git a/spec/lib/mongoid/relations_dirty_tracking_spec.rb b/spec/lib/mongoid/relations_dirty_tracking_spec.rb index 320499b..9573a58 100644 --- a/spec/lib/mongoid/relations_dirty_tracking_spec.rb +++ b/spec/lib/mongoid/relations_dirty_tracking_spec.rb @@ -348,6 +348,55 @@ expect(TestDocumentWithExceptOption.track_relation?('one_related')).to be true end end + + context 'with the v1.0 default (no track_referenced option)' do + it 'tracks embedded relations' do + expect(TestDocumentDefault.track_relation?(:many_documents)).to be true + end + + it 'does not track has_one relations' do + expect(TestDocumentDefault.track_relation?(:one_related)).to be false + end + + it 'does not track has_many relations' do + expect(TestDocumentDefault.track_relation?(:many_related)).to be false + end + end + end + + describe 'v1.0 default behaviour — embedded only' do + describe '.tracked_relations' do + subject { TestDocumentDefault.tracked_relations } + + it { is_expected.to include('many_documents') } + it { is_expected.to_not include('one_related') } + it { is_expected.to_not include('many_related') } + end + + describe '#relations_dirty_tracking_options' do + it 'defaults track_referenced to false' do + expect(TestDocumentDefault.relations_dirty_tracking_options[:track_referenced]).to be false + end + + it 'is true when the macro was called with track_referenced: true' do + expect(TestDocument.relations_dirty_tracking_options[:track_referenced]).to be true + end + end + + context 'when an embedded document is added' do + subject(:doc) { TestDocumentDefault.create } + + before do + @embedded_doc = TestEmbeddedDocument.new + doc.many_documents << @embedded_doc + end + + its(:relations_changed?) { is_expected.to be true } + + it 'reports the embedded change' do + expect(doc.relation_changes['many_documents']).to eq([[], [@embedded_doc.attributes]]) + end + end end describe 'by default the versions relation is not tracked' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9d2add1..1ac3e2e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,7 +28,8 @@ end Mongoid.configure do |config| - config.connect_to('mongoid_relations_dirty_tracking_test') + hosts = ENV['MONGODB_HOSTS']&.split(',') || %w[localhost:27017] + config.clients.default = { hosts: hosts, database: 'mongoid_relations_dirty_tracking_test' } config.belongs_to_required_by_default = false end @@ -42,6 +43,9 @@ class TestDocument has_one :one_related, class_name: 'TestRelatedDocument' has_many :many_related, class_name: 'TestRelatedDocument' has_and_belongs_to_many :many_to_many_related, class_name: 'TestRelatedDocument' + + # Tests cover both embedded and referenced behaviour, so opt in. + relations_dirty_tracking track_referenced: true end class TestEmbeddedDocument @@ -59,6 +63,8 @@ class TestRelatedDocument belongs_to :test_document, inverse_of: :one_related field :title, type: String + + relations_dirty_tracking track_referenced: true end class TestDocumentWithOnlyOption @@ -78,5 +84,15 @@ class TestDocumentWithExceptOption embeds_many :many_documents, class_name: 'TestEmbeddedDocument' has_one :one_related, class_name: 'TestRelatedDocument' - relations_dirty_tracking except: 'many_documents' + relations_dirty_tracking except: 'many_documents', track_referenced: true +end + +# Default behaviour as of v1.0.0: embedded relations tracked, referenced ignored. +class TestDocumentDefault + include Mongoid::Document + include Mongoid::RelationsDirtyTracking + + embeds_many :many_documents, class_name: 'TestEmbeddedDocument' + has_one :one_related, class_name: 'TestRelatedDocument' + has_many :many_related, class_name: 'TestRelatedDocument' end