activerecord-duplicator 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +26 -0
- data/lib/active_record/duplicator/handler_api.rb +5 -0
- data/lib/active_record/duplicator/session.rb +44 -7
- data/lib/active_record/duplicator/version.rb +1 -1
- data/lib/active_record/duplicator.rb +14 -1
- data/sig/generated/active_record/duplicator/handler_api.rbs +3 -0
- data/sig/generated/active_record/duplicator/session.rbs +35 -6
- data/sig/generated/active_record/duplicator.rbs +10 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee612e84f80aefec6371c6e900d058e9934b7b7d095a981e2f52d22291586210
|
|
4
|
+
data.tar.gz: 2aafb86816d74188a18a82e36c74e1a6c1dcb65b52523c3ee4bafa9db45e1fa3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f167f08df7e50cc605ff85d3663f3e51cb1b2a11ea88201bf50726b20721e263b8b9f96fee38ad64e42b97cd895f2217fb1968760bdf085351959632c7fec0d1
|
|
7
|
+
data.tar.gz: 652d33cc38a670dc0eafad296157d6f56c5fd3e4a435082b1bec45706c9a01624730be36992ebd22ac88f335a70def57aad3234480d77816297f879460462cda
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-07-06
|
|
4
|
+
|
|
5
|
+
### Changed (breaking)
|
|
6
|
+
|
|
7
|
+
- `Session#run` now takes a `transaction_options: {}` keyword and forwards
|
|
8
|
+
it verbatim to `root_record.transaction`. The previous hard-coded
|
|
9
|
+
`requires_new: true, joinable: false` defaults are removed; callers that
|
|
10
|
+
relied on them must pass those flags explicitly:
|
|
11
|
+
`session.run(root, transaction_options: { requires_new: true, joinable: false })`.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `Duplicator#mark_skip_class(*klasses)` / `Session#mark_skip_class` /
|
|
16
|
+
`HandlerApi#mark_skip_class` — class-wide shorthand for `mark_skip`.
|
|
17
|
+
Behaviour is exactly as if every record of the class had been passed
|
|
18
|
+
to `mark_skip` beforehand: handlers registered for the class still
|
|
19
|
+
fire, the default `bulk_insert` path becomes a no-op, and
|
|
20
|
+
`fetch_new_id(klass, old_id: ...)` echoes `old_id` back unchanged.
|
|
21
|
+
The Duplicator-level registration is inherited by every subsequent
|
|
22
|
+
`duplicate` run; Session-level additions apply only to the current run.
|
|
23
|
+
|
|
3
24
|
## [0.2.0] - 2026-07-06
|
|
4
25
|
|
|
5
26
|
- 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
|
|
@@ -59,6 +63,21 @@ module ActiveRecord
|
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
# Class-wide equivalent of mark_skip: it is exactly as if every record of
|
|
67
|
+
# each given class had been passed to mark_skip. Handlers still fire
|
|
68
|
+
# (matching the mark_skip case where a handler for the class is invoked
|
|
69
|
+
# with a Relation that happens to contain pre-skipped records); the
|
|
70
|
+
# default bulk_insert path becomes a no-op, and fetch_new_id echoes the
|
|
71
|
+
# queried old_id back unchanged.
|
|
72
|
+
#
|
|
73
|
+
# Use when the target database is expected to already hold every
|
|
74
|
+
# referenced row (shared lookup tables, tenant-wide catalogs, etc.), so
|
|
75
|
+
# per-record mark_skip would only differ from this in cost, not effect.
|
|
76
|
+
#: (*Class) -> void
|
|
77
|
+
def mark_skip_class(*klasses)
|
|
78
|
+
@skipped_classes.merge(klasses)
|
|
79
|
+
end
|
|
80
|
+
|
|
62
81
|
# Record a mapping from an old_id to a new_id. Called automatically from
|
|
63
82
|
# bulk_insert; called explicitly by user code (or a handler) when new
|
|
64
83
|
# records are produced outside of the default duplication path.
|
|
@@ -89,6 +108,8 @@ module ActiveRecord
|
|
|
89
108
|
# associations tree or register/skip it explicitly.
|
|
90
109
|
#: (Class, old_id: untyped) -> untyped
|
|
91
110
|
def fetch_new_id(klass, old_id:)
|
|
111
|
+
return old_id if @skipped_classes.include?(klass)
|
|
112
|
+
|
|
92
113
|
@record_id_map[klass].fetch(old_id) do
|
|
93
114
|
raise MissingNewIdError, "no new_id registered for #{klass}##{old_id.inspect}"
|
|
94
115
|
end
|
|
@@ -97,8 +118,14 @@ module ActiveRecord
|
|
|
97
118
|
# Bulk-insert copies of the given records into klass, skipping any that
|
|
98
119
|
# are already present in the id map. Handlers can reuse this via HandlerApi
|
|
99
120
|
# to defer a subset of records to the default duplication path.
|
|
121
|
+
#
|
|
122
|
+
# When klass has been passed to mark_skip_class the call is a no-op that
|
|
123
|
+
# never touches the DB: it is equivalent to having pre-registered every
|
|
124
|
+
# incoming record via mark_skip, only without the per-record enumeration.
|
|
100
125
|
#: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
101
126
|
def bulk_insert(klass, records, batch_size: DEFAULT_BATCH_SIZE, cursor: nil)
|
|
127
|
+
return if @skipped_classes.include?(klass)
|
|
128
|
+
|
|
102
129
|
batches = enumerate_batches(records, batch_size:, cursor:)
|
|
103
130
|
|
|
104
131
|
batches.each do |batch|
|
|
@@ -137,11 +164,17 @@ module ActiveRecord
|
|
|
137
164
|
attrs
|
|
138
165
|
end
|
|
139
166
|
|
|
140
|
-
# Run the duplication inside a
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
167
|
+
# Run the duplication inside a transaction and return the freshly loaded
|
|
168
|
+
# root record. Normally invoked by Duplicator#duplicate.
|
|
169
|
+
#
|
|
170
|
+
# transaction_options is passed through verbatim to
|
|
171
|
+
# `root_record.transaction`, so callers can request `requires_new: true`
|
|
172
|
+
# / `joinable: false` / `isolation: :serializable` etc. as fits the
|
|
173
|
+
# surrounding call site. It defaults to `{}`, i.e. plain
|
|
174
|
+
# `transaction do ... end` behaviour.
|
|
175
|
+
#: (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) -> ActiveRecord::Base
|
|
176
|
+
def run(root_record, associations: [], transaction_options: {})
|
|
177
|
+
root_record.transaction(**transaction_options) do
|
|
145
178
|
duplicate_records(root_record.class, [root_record])
|
|
146
179
|
|
|
147
180
|
AssociationTraversal.new(root_record, associations).each do |relation|
|
|
@@ -159,6 +192,10 @@ module ActiveRecord
|
|
|
159
192
|
# All records in `relation_or_records` are assumed to be of `klass`; the
|
|
160
193
|
# caller is responsible for splitting mixed inputs (e.g. STI subclasses)
|
|
161
194
|
# into per-class Relations and passing the matching class in.
|
|
195
|
+
#
|
|
196
|
+
# mark_skip_class does NOT short-circuit here: handlers must still fire
|
|
197
|
+
# (mirroring the per-record mark_skip case), and the class-wide skip is
|
|
198
|
+
# applied inside bulk_insert instead.
|
|
162
199
|
#: (Class, untyped) -> void
|
|
163
200
|
def duplicate_records(klass, relation_or_records)
|
|
164
201
|
handler = resolve_handler(klass)
|
|
@@ -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
|
|
|
@@ -52,7 +65,7 @@ module ActiveRecord
|
|
|
52
65
|
# were duplicated elsewhere.
|
|
53
66
|
#: (ActiveRecord::Base, ?associations: untyped) ?{ (Session) -> void } -> ActiveRecord::Base
|
|
54
67
|
def duplicate(root_record, associations: [], &block)
|
|
55
|
-
session = Session.new(handlers: @handlers)
|
|
68
|
+
session = Session.new(handlers: @handlers, skipped_classes: @skipped_classes)
|
|
56
69
|
block&.call(session)
|
|
57
70
|
session.run(root_record, associations:)
|
|
58
71
|
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
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
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
|