Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
111 changes: 108 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class SampleDocument
include Mongoid::RelationsDirtyTracking

embeds_one :foo
has_many :bars
embeds_many :bars

field :title, type: String
end
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions lib/mongoid/relations_dirty_tracking.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -32,9 +44,17 @@ def thread_store
end

module ClassMethods
# Configure relations dirty tracking for this class.
#
# @option options [Array<Symbol,String>] :only Track only the listed relations.
# @option options [Array<Symbol,String>] :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)
Expand All @@ -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
Expand All @@ -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 = {}
Expand Down
2 changes: 1 addition & 1 deletion lib/mongoid/relations_dirty_tracking/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module Mongoid
module RelationsDirtyTracking
VERSION = '0.3.0'
VERSION = '1.0.0'
end
end
49 changes: 49 additions & 0 deletions spec/lib/mongoid/relations_dirty_tracking_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Loading