activerecord-duplicator 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc6a883e8a354d6b41962cb96eff90d95f083918fee428b0f865d5d254117f5c
4
- data.tar.gz: 6b04468f4352d8d82f7a3cee8ccf684d19672d86715c4899313992a65519f362
3
+ metadata.gz: 4139cd9d1fa3041e02e9c17a2cf97c5f435230955c63b4ed29453b685fb6a985
4
+ data.tar.gz: 8acef30197ddd818ffc3e957bf7db58821e8ae8b34f848d72fdfd5f1713680f4
5
5
  SHA512:
6
- metadata.gz: f3ee11352e9d5612730e3a86f3f11d50e9becff4ac25651f1efef18dc9a6207da379e71751af1cf983e1bd880725200ce44c8c6f86da774242144942076996eb
7
- data.tar.gz: 90472d33b56c36ab4d3688284cee4f6cacb2d6d3fa1f5cbfc3b44ba8ca5e5f52bcf176da680937d815cc2da0df97c558caee71fb196d03f1f816a2e53ab10f23
6
+ metadata.gz: 22a435ec6ebc808da2a87bb20a4b19c82188c80ec73db48c39f33868f1729dc80775bae437f0b7e7b6d6dd86ae0e47862a72a3fb34e9e96d0c70ea50b5991bab
7
+ data.tar.gz: 5a5978f8259fde2473f220e8b2a953d649ae9c5824502ecb9a4c15bbfbbb9af9b2ae6cd5e9ebe89c5d5ea4a5d8c4de29e7c4efabe2058455d0197965f84fae3e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-07-06
4
+
5
+ ### Fixed
6
+
7
+ - `Duplicator#duplicate` now accepts `transaction_options: {}` and forwards
8
+ it to `Session#run`, which forwards it to `root_record.transaction`. In
9
+ v0.3.0 this plumbing was only wired at the Session#run layer, so callers
10
+ using the documented `Duplicator#duplicate` entry point had no way to
11
+ override the outer transaction options.
12
+
13
+ ## [0.3.0] - 2026-07-06
14
+
15
+ ### Changed (breaking)
16
+
17
+ - `Session#run` now takes a `transaction_options: {}` keyword and forwards
18
+ it verbatim to `root_record.transaction`. The previous hard-coded
19
+ `requires_new: true, joinable: false` defaults are removed; callers that
20
+ relied on them must pass those flags explicitly:
21
+ `session.run(root, transaction_options: { requires_new: true, joinable: false })`.
22
+
23
+ ### Added
24
+
25
+ - `Duplicator#mark_skip_class(*klasses)` / `Session#mark_skip_class` /
26
+ `HandlerApi#mark_skip_class` — class-wide shorthand for `mark_skip`.
27
+ Behaviour is exactly as if every record of the class had been passed
28
+ to `mark_skip` beforehand: handlers registered for the class still
29
+ fire, the default `bulk_insert` path becomes a no-op, and
30
+ `fetch_new_id(klass, old_id: ...)` echoes `old_id` back unchanged.
31
+ The Duplicator-level registration is inherited by every subsequent
32
+ `duplicate` run; Session-level additions apply only to the current run.
33
+
3
34
  ## [0.2.0] - 2026-07-06
4
35
 
5
36
  - Drop support for Ruby 3.2 (EOL)
data/README.md CHANGED
@@ -71,6 +71,32 @@ duplicator.duplicate(old_project, associations: [tasks: :attachments])
71
71
  Skipped records get `new_id == old_id` in the internal map, so children that
72
72
  reference them are inserted with the original id unchanged.
73
73
 
74
+ ### Skipping a whole model (`mark_skip_class`)
75
+
76
+ When every row of a model should be reused by id (a shared lookup table,
77
+ a tenant-wide catalog, an enum-backed table with dozens of entries), skip
78
+ the class itself instead of enumerating rows. `mark_skip_class` exists at
79
+ both scopes and mirrors `on`: register once on the Duplicator to inherit
80
+ across every run, or add it in the Session block for a single run.
81
+
82
+ ```ruby
83
+ duplicator = ActiveRecord::Duplicator.new
84
+ duplicator.mark_skip_class(Tag) # applies to every subsequent run
85
+
86
+ duplicator.duplicate(old_post, associations: [:taggings])
87
+
88
+ duplicator.duplicate(other_post, associations: [:taggings]) do |session|
89
+ session.mark_skip_class(Currency) # extra skip, this run only
90
+ end
91
+ ```
92
+
93
+ Behaviour is exactly as if you had passed every row to `mark_skip` up front:
94
+ handlers registered for the class still fire (so custom copy logic keeps
95
+ working), but the default `bulk_insert` path becomes a no-op and any
96
+ `fetch_new_id(klass, old_id: ...)` from a child's foreign-key reassignment
97
+ echoes the old id back unchanged. `mark_skip_class` only differs from
98
+ per-record `mark_skip` in cost: no enumeration is needed to opt each row in.
99
+
74
100
  ### Registering an id from outside (`store_new_id` / `fetch_new_id`)
75
101
 
76
102
  If you produce a duplicate yourself (for example a model with a uniqueness
@@ -19,6 +19,11 @@ module ActiveRecord
19
19
  @session.mark_skip(...)
20
20
  end
21
21
 
22
+ #: (*Class) -> void
23
+ def mark_skip_class(...)
24
+ @session.mark_skip_class(...)
25
+ end
26
+
22
27
  #: (Class, old_id: untyped, new_id: untyped) -> void
23
28
  def store_new_id(...)
24
29
  @session.store_new_id(...)
@@ -24,12 +24,16 @@ module ActiveRecord
24
24
  # @rbs @record_id_map: Hash[Class, Hash[untyped, untyped]]
25
25
  # @rbs @handlers: Hash[Class, Proc]
26
26
  # @rbs @user_handlers: Hash[Class, Proc]
27
+ # @rbs @skipped_classes: Set[Class]
27
28
 
28
- #: (?handlers: Hash[Class, Proc]) -> void
29
- def initialize(handlers: {})
29
+ #: (?handlers: Hash[Class, Proc], ?skipped_classes: Set[Class]) -> void
30
+ def initialize(handlers: {}, skipped_classes: Set.new)
30
31
  @record_id_map = Hash.new { |hash, klass| hash[klass] = {} }
31
32
  @handlers = handlers
32
33
  @user_handlers = {}
34
+ # dup so that Session#mark_skip_class additions never leak back to the
35
+ # Duplicator-level set shared across every run.
36
+ @skipped_classes = skipped_classes.dup
33
37
  end
34
38
 
35
39
  # Register a handler that overrides Duplicator#on for the current session
@@ -41,9 +45,7 @@ module ActiveRecord
41
45
  # Duplicator-level handler is intentional and does not raise.
42
46
  #: (Class) { (HandlerApi, Class, untyped) -> void } -> void
43
47
  def on(klass, &block)
44
- if @user_handlers.key?(klass)
45
- raise DuplicateHandlerError, "handler for #{klass} is already overridden in this session"
46
- end
48
+ raise DuplicateHandlerError, "handler for #{klass} is already overridden in this session" if @user_handlers.key?(klass)
47
49
 
48
50
  @user_handlers[klass] = block
49
51
  end
@@ -59,6 +61,21 @@ module ActiveRecord
59
61
  end
60
62
  end
61
63
 
64
+ # Class-wide equivalent of mark_skip: it is exactly as if every record of
65
+ # each given class had been passed to mark_skip. Handlers still fire
66
+ # (matching the mark_skip case where a handler for the class is invoked
67
+ # with a Relation that happens to contain pre-skipped records); the
68
+ # default bulk_insert path becomes a no-op, and fetch_new_id echoes the
69
+ # queried old_id back unchanged.
70
+ #
71
+ # Use when the target database is expected to already hold every
72
+ # referenced row (shared lookup tables, tenant-wide catalogs, etc.), so
73
+ # per-record mark_skip would only differ from this in cost, not effect.
74
+ #: (*Class) -> void
75
+ def mark_skip_class(*klasses)
76
+ @skipped_classes.merge(klasses)
77
+ end
78
+
62
79
  # Record a mapping from an old_id to a new_id. Called automatically from
63
80
  # bulk_insert; called explicitly by user code (or a handler) when new
64
81
  # records are produced outside of the default duplication path.
@@ -89,6 +106,8 @@ module ActiveRecord
89
106
  # associations tree or register/skip it explicitly.
90
107
  #: (Class, old_id: untyped) -> untyped
91
108
  def fetch_new_id(klass, old_id:)
109
+ return old_id if @skipped_classes.include?(klass)
110
+
92
111
  @record_id_map[klass].fetch(old_id) do
93
112
  raise MissingNewIdError, "no new_id registered for #{klass}##{old_id.inspect}"
94
113
  end
@@ -97,8 +116,14 @@ module ActiveRecord
97
116
  # Bulk-insert copies of the given records into klass, skipping any that
98
117
  # are already present in the id map. Handlers can reuse this via HandlerApi
99
118
  # to defer a subset of records to the default duplication path.
119
+ #
120
+ # When klass has been passed to mark_skip_class the call is a no-op that
121
+ # never touches the DB: it is equivalent to having pre-registered every
122
+ # incoming record via mark_skip, only without the per-record enumeration.
100
123
  #: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
101
124
  def bulk_insert(klass, records, batch_size: DEFAULT_BATCH_SIZE, cursor: nil)
125
+ return if @skipped_classes.include?(klass)
126
+
102
127
  batches = enumerate_batches(records, batch_size:, cursor:)
103
128
 
104
129
  batches.each do |batch|
@@ -137,11 +162,17 @@ module ActiveRecord
137
162
  attrs
138
163
  end
139
164
 
140
- # Run the duplication inside a new, non-joinable transaction and return
141
- # the freshly loaded root record. Normally invoked by Duplicator#duplicate.
142
- #: (ActiveRecord::Base, ?associations: untyped) -> ActiveRecord::Base
143
- def run(root_record, associations: [])
144
- root_record.transaction(requires_new: true, joinable: false) do
165
+ # Run the duplication inside a transaction and return the freshly loaded
166
+ # root record. Normally invoked by Duplicator#duplicate.
167
+ #
168
+ # transaction_options is passed through verbatim to
169
+ # `root_record.transaction`, so callers can request `requires_new: true`
170
+ # / `joinable: false` / `isolation: :serializable` etc. as fits the
171
+ # surrounding call site. It defaults to `{}`, i.e. plain
172
+ # `transaction do ... end` behaviour.
173
+ #: (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) -> ActiveRecord::Base
174
+ def run(root_record, associations: [], transaction_options: {})
175
+ root_record.transaction(**transaction_options) do
145
176
  duplicate_records(root_record.class, [root_record])
146
177
 
147
178
  AssociationTraversal.new(root_record, associations).each do |relation|
@@ -159,6 +190,10 @@ module ActiveRecord
159
190
  # All records in `relation_or_records` are assumed to be of `klass`; the
160
191
  # caller is responsible for splitting mixed inputs (e.g. STI subclasses)
161
192
  # into per-class Relations and passing the matching class in.
193
+ #
194
+ # mark_skip_class does NOT short-circuit here: handlers must still fire
195
+ # (mirroring the per-record mark_skip case), and the class-wide skip is
196
+ # applied inside bulk_insert instead.
162
197
  #: (Class, untyped) -> void
163
198
  def duplicate_records(klass, relation_or_records)
164
199
  handler = resolve_handler(klass)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  class Duplicator
5
- VERSION = "0.2.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -19,10 +19,12 @@ module ActiveRecord
19
19
  # end
20
20
  class Duplicator
21
21
  # @rbs @handlers: Hash[Class, Proc]
22
+ # @rbs @skipped_classes: Set[Class]
22
23
 
23
24
  #: () -> void
24
25
  def initialize
25
26
  @handlers = {}
27
+ @skipped_classes = Set.new
26
28
  end
27
29
 
28
30
  # Register a custom handler for the given model class. See HandlerApi for
@@ -36,11 +38,22 @@ module ActiveRecord
36
38
  @handlers[klass] = block
37
39
  end
38
40
 
41
+ # Declare that every record of the given classes must not be duplicated in
42
+ # any subsequent run. See Session#mark_skip_class for the semantics; the
43
+ # Duplicator-level registration is applied to every fresh Session as its
44
+ # baseline, and Session#mark_skip_class can extend it for a single run.
45
+ # Repeated calls with the same class are idempotent.
46
+ #: (*Class) -> void
47
+ def mark_skip_class(*klasses)
48
+ @skipped_classes.merge(klasses)
49
+ end
50
+
39
51
  # Prevent further registrations. Also freezes the internal handler map so
40
52
  # accidental mutation later fails loudly.
41
53
  #: () -> self
42
54
  def freeze
43
55
  @handlers.freeze
56
+ @skipped_classes.freeze
44
57
  super
45
58
  end
46
59
 
@@ -50,11 +63,15 @@ module ActiveRecord
50
63
  # If a block is given it is called with the new Session before duplication
51
64
  # starts, letting the caller mark_skip or store_new_id for records that
52
65
  # were duplicated elsewhere.
53
- #: (ActiveRecord::Base, ?associations: untyped) ?{ (Session) -> void } -> ActiveRecord::Base
54
- def duplicate(root_record, associations: [], &block)
55
- session = Session.new(handlers: @handlers)
66
+ #
67
+ # transaction_options is forwarded verbatim to Session#run, which forwards
68
+ # it to `root_record.transaction`. Default is `{}` (plain
69
+ # `transaction do ... end`).
70
+ #: (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) ?{ (Session) -> void } -> ActiveRecord::Base
71
+ def duplicate(root_record, associations: [], transaction_options: {}, &block)
72
+ session = Session.new(handlers: @handlers, skipped_classes: @skipped_classes)
56
73
  block&.call(session)
57
- session.run(root_record, associations:)
74
+ session.run(root_record, associations:, transaction_options:)
58
75
  end
59
76
  end
60
77
  end
@@ -15,6 +15,9 @@ module ActiveRecord
15
15
  # : (*ActiveRecord::Base) -> void
16
16
  def mark_skip: (*ActiveRecord::Base) -> void
17
17
 
18
+ # : (*Class) -> void
19
+ def mark_skip_class: (*Class) -> void
20
+
18
21
  # : (Class, old_id: untyped, new_id: untyped) -> void
19
22
  def store_new_id: (Class, old_id: untyped, new_id: untyped) -> void
20
23
 
@@ -20,14 +20,16 @@ module ActiveRecord
20
20
  class Session
21
21
  DEFAULT_BATCH_SIZE: ::Integer
22
22
 
23
+ @skipped_classes: Set[Class]
24
+
23
25
  @user_handlers: Hash[Class, Proc]
24
26
 
25
27
  @handlers: Hash[Class, Proc]
26
28
 
27
29
  @record_id_map: Hash[Class, Hash[untyped, untyped]]
28
30
 
29
- # : (?handlers: Hash[Class, Proc]) -> void
30
- def initialize: (?handlers: Hash[Class, Proc]) -> void
31
+ # : (?handlers: Hash[Class, Proc], ?skipped_classes: Set[Class]) -> void
32
+ def initialize: (?handlers: Hash[Class, Proc], ?skipped_classes: Set[Class]) -> void
31
33
 
32
34
  # Register a handler that overrides Duplicator#on for the current session
33
35
  # only. Useful when a single duplication run needs a special copy of a
@@ -45,6 +47,19 @@ module ActiveRecord
45
47
  # : (*ActiveRecord::Base) -> void
46
48
  def mark_skip: (*ActiveRecord::Base) -> void
47
49
 
50
+ # Class-wide equivalent of mark_skip: it is exactly as if every record of
51
+ # each given class had been passed to mark_skip. Handlers still fire
52
+ # (matching the mark_skip case where a handler for the class is invoked
53
+ # with a Relation that happens to contain pre-skipped records); the
54
+ # default bulk_insert path becomes a no-op, and fetch_new_id echoes the
55
+ # queried old_id back unchanged.
56
+ #
57
+ # Use when the target database is expected to already hold every
58
+ # referenced row (shared lookup tables, tenant-wide catalogs, etc.), so
59
+ # per-record mark_skip would only differ from this in cost, not effect.
60
+ # : (*Class) -> void
61
+ def mark_skip_class: (*Class) -> void
62
+
48
63
  # Record a mapping from an old_id to a new_id. Called automatically from
49
64
  # bulk_insert; called explicitly by user code (or a handler) when new
50
65
  # records are produced outside of the default duplication path.
@@ -69,6 +84,10 @@ module ActiveRecord
69
84
  # Bulk-insert copies of the given records into klass, skipping any that
70
85
  # are already present in the id map. Handlers can reuse this via HandlerApi
71
86
  # to defer a subset of records to the default duplication path.
87
+ #
88
+ # When klass has been passed to mark_skip_class the call is a no-op that
89
+ # never touches the DB: it is equivalent to having pre-registered every
90
+ # incoming record via mark_skip, only without the per-record enumeration.
72
91
  # : (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
73
92
  def bulk_insert: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
74
93
 
@@ -83,10 +102,16 @@ module ActiveRecord
83
102
  # : (Class, ActiveRecord::Base) -> Hash[String, untyped]
84
103
  def attributes_for: (Class, ActiveRecord::Base) -> Hash[String, untyped]
85
104
 
86
- # Run the duplication inside a new, non-joinable transaction and return
87
- # the freshly loaded root record. Normally invoked by Duplicator#duplicate.
88
- # : (ActiveRecord::Base, ?associations: untyped) -> ActiveRecord::Base
89
- def run: (ActiveRecord::Base, ?associations: untyped) -> ActiveRecord::Base
105
+ # Run the duplication inside a transaction and return the freshly loaded
106
+ # root record. Normally invoked by Duplicator#duplicate.
107
+ #
108
+ # transaction_options is passed through verbatim to
109
+ # `root_record.transaction`, so callers can request `requires_new: true`
110
+ # / `joinable: false` / `isolation: :serializable` etc. as fits the
111
+ # surrounding call site. It defaults to `{}`, i.e. plain
112
+ # `transaction do ... end` behaviour.
113
+ # : (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) -> ActiveRecord::Base
114
+ def run: (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) -> ActiveRecord::Base
90
115
 
91
116
  private
92
117
 
@@ -94,6 +119,10 @@ module ActiveRecord
94
119
  # All records in `relation_or_records` are assumed to be of `klass`; the
95
120
  # caller is responsible for splitting mixed inputs (e.g. STI subclasses)
96
121
  # into per-class Relations and passing the matching class in.
122
+ #
123
+ # mark_skip_class does NOT short-circuit here: handlers must still fire
124
+ # (mirroring the per-record mark_skip case), and the class-wide skip is
125
+ # applied inside bulk_insert instead.
97
126
  # : (Class, untyped) -> void
98
127
  def duplicate_records: (Class, untyped) -> void
99
128
 
@@ -18,6 +18,8 @@ module ActiveRecord
18
18
  class Duplicator
19
19
  @handlers: Hash[Class, Proc]
20
20
 
21
+ @skipped_classes: Set[Class]
22
+
21
23
  # : () -> void
22
24
  def initialize: () -> void
23
25
 
@@ -28,6 +30,14 @@ module ActiveRecord
28
30
  # : (Class) { (HandlerApi, Class, untyped) -> void } -> void
29
31
  def on: (Class) { (HandlerApi, Class, untyped) -> void } -> void
30
32
 
33
+ # Declare that every record of the given classes must not be duplicated in
34
+ # any subsequent run. See Session#mark_skip_class for the semantics; the
35
+ # Duplicator-level registration is applied to every fresh Session as its
36
+ # baseline, and Session#mark_skip_class can extend it for a single run.
37
+ # Repeated calls with the same class are idempotent.
38
+ # : (*Class) -> void
39
+ def mark_skip_class: (*Class) -> void
40
+
31
41
  # Prevent further registrations. Also freezes the internal handler map so
32
42
  # accidental mutation later fails loudly.
33
43
  # : () -> self
@@ -39,7 +49,11 @@ module ActiveRecord
39
49
  # If a block is given it is called with the new Session before duplication
40
50
  # starts, letting the caller mark_skip or store_new_id for records that
41
51
  # were duplicated elsewhere.
42
- # : (ActiveRecord::Base, ?associations: untyped) ?{ (Session) -> void } -> ActiveRecord::Base
43
- def duplicate: (ActiveRecord::Base, ?associations: untyped) ?{ (Session) -> void } -> ActiveRecord::Base
52
+ #
53
+ # transaction_options is forwarded verbatim to Session#run, which forwards
54
+ # it to `root_record.transaction`. Default is `{}` (plain
55
+ # `transaction do ... end`).
56
+ # : (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) ?{ (Session) -> void } -> ActiveRecord::Base
57
+ def duplicate: (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) ?{ (Session) -> void } -> ActiveRecord::Base
44
58
  end
45
59
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-duplicator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SmartHR