activerecord-duplicator 0.5.0 → 0.6.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 +49 -10
- data/README.md +62 -35
- data/lib/active_record/duplicator/errors.rb +16 -6
- data/lib/active_record/duplicator/handler_api.rb +10 -10
- data/lib/active_record/duplicator/session.rb +64 -42
- data/lib/active_record/duplicator/version.rb +1 -1
- data/lib/active_record/duplicator.rb +11 -11
- data/sig/generated/active_record/duplicator/errors.rbs +17 -6
- data/sig/generated/active_record/duplicator/handler_api.rbs +5 -5
- data/sig/generated/active_record/duplicator/session.rbs +40 -30
- data/sig/generated/active_record/duplicator.rbs +7 -7
- metadata +1 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e0c02f84a340928dc7b42bf07eec278113241d6e710c73fed7e2d60c5623617
|
|
4
|
+
data.tar.gz: 3f9a0fad4af18f5d107358c9b17fc757bada05d0ce432b765820db2e39841dff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9271005195b8291621f9e86cb61e79ffabcc67de31519af7167b4538600b5cd7551812079b358def18f399d68b63470ae26345a4981a27be64c340c50ed186c0
|
|
7
|
+
data.tar.gz: 7ad05e575e643f43dba00993b2d835f5e9d3fa00e421da285b0c90617d835efc8a8c4eb271614d71300d228cb6f7fbc2161fbe8f322bdf5dc7204ce494c3637e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,51 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-07-08
|
|
4
|
+
|
|
5
|
+
### Changed (breaking)
|
|
6
|
+
|
|
7
|
+
- Dropped the hard `pg` runtime dependency. The gem is now
|
|
8
|
+
adapter-agnostic: any adapter whose connection reports
|
|
9
|
+
`supports_insert_returning? == true` is supported (PostgreSQL and
|
|
10
|
+
SQLite >= 3.35 today). Applications add the adapter gem they use
|
|
11
|
+
(`pg`, `sqlite3`, ...) to their own Gemfile.
|
|
12
|
+
- `Session#bulk_insert` now raises the new
|
|
13
|
+
`ActiveRecord::Duplicator::UnsupportedAdapterError` when the target
|
|
14
|
+
connection does not implement `supports_insert_returning?`. Previously the
|
|
15
|
+
bulk insert silently returned no rows and left the id map incomplete on
|
|
16
|
+
mysql2 / trilogy. Handlers registered via `Duplicator#on` / `Session#on`
|
|
17
|
+
are unaffected as long as they do not delegate to `bulk_insert`.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `ActiveRecord::Duplicator::UnsupportedAdapterError`: signals that the
|
|
22
|
+
default duplication path was invoked against a RETURNING-less adapter.
|
|
23
|
+
- The spec suite now runs against SQLite as well as PostgreSQL, driven by an
|
|
24
|
+
`ARCONN` environment variable (`ARCONN=sqlite3 bundle exec rspec`). CI
|
|
25
|
+
exercises both adapters on every push and pull request.
|
|
26
|
+
|
|
27
|
+
### Renamed (breaking)
|
|
28
|
+
|
|
29
|
+
Public API methods have been renamed to use `duplicate`-prefixed vocabulary
|
|
30
|
+
that matches the gem name and makes the intent clearer:
|
|
31
|
+
|
|
32
|
+
| Old name | New name |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `mark_skip(record)` | `no_duplicate(record)` |
|
|
35
|
+
| `mark_skip_id(klass, *ids)` | `no_duplicate_id(klass, *ids)` |
|
|
36
|
+
| `mark_skip_class(*klasses)` | `no_duplicate_class(*klasses)` |
|
|
37
|
+
| `store_new_id(klass, old_id:, new_id:)` | `store_duplicate_id(klass, old_id:, new_id:)` |
|
|
38
|
+
| `fetch_new_id(klass, old_id:)` | `fetch_duplicate_id(klass, old_id:)` |
|
|
39
|
+
|
|
40
|
+
Applies to `Session`, `HandlerApi`, and `Duplicator` (for `no_duplicate_class`).
|
|
41
|
+
|
|
3
42
|
## [0.5.0] - 2026-07-07
|
|
4
43
|
|
|
5
44
|
### Added
|
|
6
45
|
|
|
7
|
-
- `Session#
|
|
8
|
-
|
|
9
|
-
calling `
|
|
46
|
+
- `Session#no_duplicate_id(klass, *ids)` and `HandlerApi#no_duplicate_id`:
|
|
47
|
+
exclude specific ids from duplication without loading records. Equivalent to
|
|
48
|
+
calling `no_duplicate` on each record but avoids the database round-trip
|
|
10
49
|
when the ids are already known. Composite primary key ids are passed as
|
|
11
50
|
Arrays.
|
|
12
51
|
|
|
@@ -32,12 +71,12 @@
|
|
|
32
71
|
|
|
33
72
|
### Added
|
|
34
73
|
|
|
35
|
-
- `Duplicator#
|
|
36
|
-
`HandlerApi#
|
|
74
|
+
- `Duplicator#no_duplicate_class(*klasses)` / `Session#no_duplicate_class` /
|
|
75
|
+
`HandlerApi#no_duplicate_class`: class-wide shorthand for `no_duplicate`.
|
|
37
76
|
Behaviour is exactly as if every record of the class had been passed
|
|
38
|
-
to `
|
|
77
|
+
to `no_duplicate` beforehand: handlers registered for the class still
|
|
39
78
|
fire, the default `bulk_insert` path becomes a no-op, and
|
|
40
|
-
`
|
|
79
|
+
`fetch_duplicate_id(klass, old_id: ...)` echoes `old_id` back unchanged.
|
|
41
80
|
The Duplicator-level registration is inherited by every subsequent
|
|
42
81
|
`duplicate` run; Session-level additions apply only to the current run.
|
|
43
82
|
|
|
@@ -72,8 +111,8 @@
|
|
|
72
111
|
|
|
73
112
|
## [0.1.0] - 2026-07-02
|
|
74
113
|
|
|
75
|
-
- Add `ActiveRecord::Duplicator::AssociationTraversal
|
|
76
|
-
- Add `ActiveRecord::Duplicator::Session
|
|
77
|
-
- Add `ActiveRecord::Duplicator::HandlerApi
|
|
114
|
+
- Add `ActiveRecord::Duplicator::AssociationTraversal`: breadth-first traversal of preload-style association specifications
|
|
115
|
+
- Add `ActiveRecord::Duplicator::Session`: coordinates one duplication run (record id map, handler registry, `duplicate` / `bulk_insert` / `attributes_for`)
|
|
116
|
+
- Add `ActiveRecord::Duplicator::HandlerApi`: facade passed to `Session#on` handler blocks
|
|
78
117
|
- Add error types under `ActiveRecord::Duplicator::Error`: `InvalidRecordIdError` / `MissingNewIdError` / `DuplicateHandlerError`
|
|
79
118
|
- Support composite primary keys (Rails 7.1+)
|
data/README.md
CHANGED
|
@@ -23,8 +23,20 @@ gem install activerecord-duplicator
|
|
|
23
23
|
|
|
24
24
|
- Ruby >= 3.3
|
|
25
25
|
- ActiveRecord >= 8.0 (tested against 8.0, 8.1)
|
|
26
|
-
-
|
|
27
|
-
|
|
26
|
+
- A database adapter whose connection reports
|
|
27
|
+
`supports_insert_returning? == true`. The default duplication path uses
|
|
28
|
+
`insert_all!` and needs the adapter to echo inserted primary keys back so
|
|
29
|
+
the id map can be populated in a single round trip.
|
|
30
|
+
- PostgreSQL (via `pg`): always supported.
|
|
31
|
+
- SQLite (via `sqlite3`): SQLite >= 3.35 is required; the modern
|
|
32
|
+
`sqlite3` gem ships a compatible native binary.
|
|
33
|
+
- MySQL / Trilogy: not supported by the default path because those
|
|
34
|
+
adapters do not implement RETURNING. Duplication of specific models can
|
|
35
|
+
still be done through a custom handler registered with `Duplicator#on`
|
|
36
|
+
(see below).
|
|
37
|
+
|
|
38
|
+
The gem does not depend on a specific adapter gem; applications add whichever
|
|
39
|
+
one they use (`pg`, `sqlite3`, ...) to their own Gemfile.
|
|
28
40
|
|
|
29
41
|
## Non-goals
|
|
30
42
|
|
|
@@ -54,16 +66,16 @@ new_post = duplicator.duplicate(
|
|
|
54
66
|
`associations`), rewires every foreign key to point at the freshly created
|
|
55
67
|
parents, and returns the new root record.
|
|
56
68
|
|
|
57
|
-
### Keeping records referenced but not copied (`
|
|
69
|
+
### Keeping records referenced but not copied (`no_duplicate`)
|
|
58
70
|
|
|
59
71
|
When you want the duplicated rows to keep pointing at an existing record
|
|
60
72
|
(for example the current tenant, current user, or a shared lookup table),
|
|
61
|
-
|
|
73
|
+
call no_duplicate on that record before calling `duplicate`:
|
|
62
74
|
|
|
63
75
|
```ruby
|
|
64
76
|
duplicator = ActiveRecord::Duplicator.new
|
|
65
|
-
duplicator.
|
|
66
|
-
duplicator.
|
|
77
|
+
duplicator.no_duplicate(tenant)
|
|
78
|
+
duplicator.no_duplicate(current_user)
|
|
67
79
|
|
|
68
80
|
duplicator.duplicate(old_project, associations: [tasks: :attachments])
|
|
69
81
|
```
|
|
@@ -71,55 +83,55 @@ duplicator.duplicate(old_project, associations: [tasks: :attachments])
|
|
|
71
83
|
Skipped records get `new_id == old_id` in the internal map, so children that
|
|
72
84
|
reference them are inserted with the original id unchanged.
|
|
73
85
|
|
|
74
|
-
### Skipping a whole model (`
|
|
86
|
+
### Skipping a whole model (`no_duplicate_class`)
|
|
75
87
|
|
|
76
88
|
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),
|
|
78
|
-
the class itself instead of enumerating rows. `
|
|
89
|
+
a tenant-wide catalog, an enum-backed table with dozens of entries), call no_duplicate_class
|
|
90
|
+
the class itself instead of enumerating rows. `no_duplicate_class` exists at
|
|
79
91
|
both scopes and mirrors `on`: register once on the Duplicator to inherit
|
|
80
92
|
across every run, or add it in the Session block for a single run.
|
|
81
93
|
|
|
82
94
|
```ruby
|
|
83
95
|
duplicator = ActiveRecord::Duplicator.new
|
|
84
|
-
duplicator.
|
|
96
|
+
duplicator.no_duplicate_class(Tag) # applies to every subsequent run
|
|
85
97
|
|
|
86
98
|
duplicator.duplicate(old_post, associations: [:taggings])
|
|
87
99
|
|
|
88
100
|
duplicator.duplicate(other_post, associations: [:taggings]) do |session|
|
|
89
|
-
session.
|
|
101
|
+
session.no_duplicate_class(Currency) # extra exclusion, this run only
|
|
90
102
|
end
|
|
91
103
|
```
|
|
92
104
|
|
|
93
|
-
Behaviour is exactly as if you had passed every row to `
|
|
105
|
+
Behaviour is exactly as if you had passed every row to `no_duplicate` up front:
|
|
94
106
|
handlers registered for the class still fire (so custom copy logic keeps
|
|
95
107
|
working), but the default `bulk_insert` path becomes a no-op and any
|
|
96
|
-
`
|
|
97
|
-
echoes the old id back unchanged. `
|
|
98
|
-
per-record `
|
|
108
|
+
`fetch_duplicate_id(klass, old_id: ...)` from a child's foreign-key reassignment
|
|
109
|
+
echoes the old id back unchanged. `no_duplicate_class` only differs from
|
|
110
|
+
per-record `no_duplicate` in cost: no enumeration is needed to opt each row in.
|
|
99
111
|
|
|
100
|
-
### Skipping by id without loading records (`
|
|
112
|
+
### Skipping by id without loading records (`no_duplicate_id`)
|
|
101
113
|
|
|
102
|
-
When the ids
|
|
103
|
-
pluck, a config constant), use `
|
|
114
|
+
When the ids are already known (a foreign key column, a prior
|
|
115
|
+
pluck, a config constant), use `no_duplicate_id` to register them directly
|
|
104
116
|
without issuing a `SELECT`:
|
|
105
117
|
|
|
106
118
|
```ruby
|
|
107
119
|
duplicator.duplicate(old_post, associations: [:taggings]) do |session|
|
|
108
|
-
session.
|
|
120
|
+
session.no_duplicate_id(Tag, 1, 2, 3)
|
|
109
121
|
end
|
|
110
122
|
```
|
|
111
123
|
|
|
112
124
|
For composite primary keys pass each id as an Array:
|
|
113
125
|
|
|
114
126
|
```ruby
|
|
115
|
-
session.
|
|
127
|
+
session.no_duplicate_id(Item, [tenant_id, 10], [tenant_id, 11])
|
|
116
128
|
```
|
|
117
129
|
|
|
118
|
-
`
|
|
119
|
-
corresponding records: `
|
|
130
|
+
`no_duplicate_id` has the same effect as calling `no_duplicate` on the
|
|
131
|
+
corresponding records: `fetch_duplicate_id` returns the old id unchanged for
|
|
120
132
|
each registered id.
|
|
121
133
|
|
|
122
|
-
### Registering an id from outside (`
|
|
134
|
+
### Registering an id from outside (`store_duplicate_id` / `fetch_duplicate_id`)
|
|
123
135
|
|
|
124
136
|
If you produce a duplicate yourself (for example a model with a uniqueness
|
|
125
137
|
constraint that has to be reshaped before insert), tell the session about the
|
|
@@ -131,11 +143,11 @@ new_project.slug = "#{old_project.slug}-copy"
|
|
|
131
143
|
new_project.save!
|
|
132
144
|
|
|
133
145
|
duplicator = ActiveRecord::Duplicator.new
|
|
134
|
-
duplicator.
|
|
146
|
+
duplicator.store_duplicate_id(Project, old_id: old_project.id, new_id: new_project.id)
|
|
135
147
|
duplicator.duplicate(old_project, associations: [:tasks])
|
|
136
148
|
```
|
|
137
149
|
|
|
138
|
-
`
|
|
150
|
+
`fetch_duplicate_id(klass, old_id:)` returns the mapping (scalar for a single-column
|
|
139
151
|
primary key, Array for a composite one) and raises `MissingNewIdError` if the
|
|
140
152
|
parent has not been duplicated yet.
|
|
141
153
|
|
|
@@ -150,16 +162,16 @@ called instead of the default duplication path for that class:
|
|
|
150
162
|
duplicator.on(Attachment) do |api, klass, records|
|
|
151
163
|
records.find_each do |record|
|
|
152
164
|
new_record = klass.create!(
|
|
153
|
-
post_id: api.
|
|
165
|
+
post_id: api.fetch_duplicate_id(Post, old_id: record.post_id),
|
|
154
166
|
)
|
|
155
167
|
new_record.file.attach(record.file.blob)
|
|
156
|
-
api.
|
|
168
|
+
api.store_duplicate_id(klass, old_id: record.id, new_id: new_record.id)
|
|
157
169
|
end
|
|
158
170
|
end
|
|
159
171
|
```
|
|
160
172
|
|
|
161
173
|
`api` is a `HandlerApi` facade over the session that exposes
|
|
162
|
-
`
|
|
174
|
+
`no_duplicate` / `store_duplicate_id` / `fetch_duplicate_id` / `bulk_insert` /
|
|
163
175
|
`attributes_for`. Registering two handlers for the same class raises
|
|
164
176
|
`DuplicateHandlerError`.
|
|
165
177
|
|
|
@@ -175,11 +187,16 @@ tuple `WHERE (a, b) IN ((v1, v2), ...)` syntax.
|
|
|
175
187
|
|
|
176
188
|
All errors inherit from `ActiveRecord::Duplicator::Error`:
|
|
177
189
|
|
|
178
|
-
- `InvalidRecordIdError
|
|
179
|
-
- `MissingNewIdError
|
|
190
|
+
- `InvalidRecordIdError`: the same `old_id` was registered with a different `new_id`.
|
|
191
|
+
- `MissingNewIdError`: `fetch_duplicate_id` had no mapping. Usually means a
|
|
180
192
|
belongs_to parent was not duplicated yet; place it earlier in the
|
|
181
|
-
associations tree or
|
|
182
|
-
- `DuplicateHandlerError
|
|
193
|
+
associations tree, or call no_duplicate / no_duplicate_id on it.
|
|
194
|
+
- `DuplicateHandlerError`: `on(klass)` was called twice for the same class.
|
|
195
|
+
- `UnsupportedAdapterError`: the default `bulk_insert` was invoked against a
|
|
196
|
+
connection whose adapter does not implement `supports_insert_returning?`
|
|
197
|
+
(for example mysql2 or trilogy). Register a handler with
|
|
198
|
+
`Duplicator#on` / `Session#on` that uses `create!` (or another
|
|
199
|
+
RETURNING-free path) for that model.
|
|
183
200
|
|
|
184
201
|
Because they share a common ancestor, one `rescue` clause catches any
|
|
185
202
|
duplication-specific failure:
|
|
@@ -202,8 +219,8 @@ smaller root records or file an issue.
|
|
|
202
219
|
|
|
203
220
|
## Development
|
|
204
221
|
|
|
205
|
-
The gem tests against a real
|
|
206
|
-
|
|
222
|
+
The gem tests against a real database. `compose.yml` at the repo root starts a
|
|
223
|
+
PostgreSQL instance for you on port 55432:
|
|
207
224
|
|
|
208
225
|
```bash
|
|
209
226
|
docker compose up -d
|
|
@@ -213,12 +230,22 @@ bundle exec rake # rspec + rubocop
|
|
|
213
230
|
bundle exec rspec # spec only
|
|
214
231
|
```
|
|
215
232
|
|
|
216
|
-
|
|
233
|
+
Switch adapters with `ARCONN`; SQLite runs against an in-memory database and
|
|
234
|
+
needs no external service:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
ARCONN=postgresql bundle exec rspec # default (uses DATABASE_URL)
|
|
238
|
+
ARCONN=sqlite3 bundle exec rspec
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Override the PostgreSQL connection with `DATABASE_URL` (used by CI):
|
|
217
242
|
|
|
218
243
|
```bash
|
|
219
244
|
DATABASE_URL=postgres://user:pass@host:port/db bundle exec rspec
|
|
220
245
|
```
|
|
221
246
|
|
|
247
|
+
Both adapters are exercised in CI on every push and pull request.
|
|
248
|
+
|
|
222
249
|
## Contributing
|
|
223
250
|
|
|
224
251
|
Bug reports and pull requests are welcome on GitHub at
|
|
@@ -4,18 +4,28 @@ module ActiveRecord
|
|
|
4
4
|
class Duplicator
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
-
# Raised when
|
|
8
|
-
# mapping with a different new_id. This typically indicates a bug
|
|
9
|
-
# calling code or a handler.
|
|
7
|
+
# Raised when store_duplicate_id (or no_duplicate) tries to overwrite an
|
|
8
|
+
# existing mapping with a different new_id. This typically indicates a bug
|
|
9
|
+
# in the calling code or a handler.
|
|
10
10
|
class InvalidRecordIdError < Error; end
|
|
11
11
|
|
|
12
|
-
# Raised when
|
|
12
|
+
# Raised when fetch_duplicate_id cannot find a mapping for the given old_id.
|
|
13
13
|
# This usually means a belongs_to parent has not been duplicated yet;
|
|
14
|
-
# place the parent earlier in the associations tree, or
|
|
15
|
-
# explicitly.
|
|
14
|
+
# place the parent earlier in the associations tree, or call no_duplicate /
|
|
15
|
+
# no_duplicate_id on it explicitly.
|
|
16
16
|
class MissingNewIdError < Error; end
|
|
17
17
|
|
|
18
18
|
# Raised when Session#on is called twice for the same model class.
|
|
19
19
|
class DuplicateHandlerError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when a bulk_insert is attempted against a connection whose adapter
|
|
22
|
+
# does not implement `supports_insert_returning?`. The default duplication
|
|
23
|
+
# path relies on `insert_all!` returning the inserted primary keys so the
|
|
24
|
+
# id map can be populated in one round trip; adapters that do not support
|
|
25
|
+
# RETURNING (e.g. mysql2, trilogy) cannot participate in this flow.
|
|
26
|
+
#
|
|
27
|
+
# Register a handler with `Duplicator#on` / `Session#on` that uses
|
|
28
|
+
# `create!` (or similar) if you need to duplicate rows on such an adapter.
|
|
29
|
+
class UnsupportedAdapterError < Error; end
|
|
20
30
|
end
|
|
21
31
|
end
|
|
@@ -15,28 +15,28 @@ module ActiveRecord
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
#: (*ActiveRecord::Base) -> void
|
|
18
|
-
def
|
|
19
|
-
@session.
|
|
18
|
+
def no_duplicate(...)
|
|
19
|
+
@session.no_duplicate(...)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
#: (Class, *untyped) -> void
|
|
23
|
-
def
|
|
24
|
-
@session.
|
|
23
|
+
def no_duplicate_id(...)
|
|
24
|
+
@session.no_duplicate_id(...)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
#: (*Class) -> void
|
|
28
|
-
def
|
|
29
|
-
@session.
|
|
28
|
+
def no_duplicate_class(...)
|
|
29
|
+
@session.no_duplicate_class(...)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
#: (Class, old_id: untyped, new_id: untyped) -> void
|
|
33
|
-
def
|
|
34
|
-
@session.
|
|
33
|
+
def store_duplicate_id(...)
|
|
34
|
+
@session.store_duplicate_id(...)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
#: (Class, old_id: untyped) -> untyped
|
|
38
|
-
def
|
|
39
|
-
@session.
|
|
38
|
+
def fetch_duplicate_id(...)
|
|
39
|
+
@session.fetch_duplicate_id(...)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
#: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
@@ -5,13 +5,13 @@ module ActiveRecord
|
|
|
5
5
|
# One-shot execution context for a duplication run.
|
|
6
6
|
#
|
|
7
7
|
# A Session owns the record id map (old_id -> new_id, per class) and
|
|
8
|
-
# exposes the API a handler needs:
|
|
9
|
-
# / bulk_insert / attributes_for.
|
|
8
|
+
# exposes the API a handler needs: no_duplicate / store_duplicate_id /
|
|
9
|
+
# fetch_duplicate_id / bulk_insert / attributes_for.
|
|
10
10
|
#
|
|
11
11
|
# Users normally do not instantiate Session directly; a fresh Session is
|
|
12
12
|
# created by Duplicator#duplicate for every run, so state does not leak
|
|
13
13
|
# across runs. Duplicator#duplicate yields the Session to its block so
|
|
14
|
-
# callers can
|
|
14
|
+
# callers can no_duplicate / store_duplicate_id before the run starts.
|
|
15
15
|
#
|
|
16
16
|
# Composite primary keys (Rails 7.1+ `self.primary_key = [:a, :b]`) are
|
|
17
17
|
# supported. An id is a scalar for a single-column primary key and an
|
|
@@ -24,16 +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 @
|
|
27
|
+
# @rbs @no_duplicate_classes: Set[Class]
|
|
28
28
|
|
|
29
|
-
#: (?handlers: Hash[Class, Proc], ?
|
|
30
|
-
def initialize(handlers: {},
|
|
29
|
+
#: (?handlers: Hash[Class, Proc], ?no_duplicate_classes: Set[Class]) -> void
|
|
30
|
+
def initialize(handlers: {}, no_duplicate_classes: Set.new)
|
|
31
31
|
@record_id_map = Hash.new { |hash, klass| hash[klass] = {} }
|
|
32
32
|
@handlers = handlers
|
|
33
33
|
@user_handlers = {}
|
|
34
|
-
# dup so that Session#
|
|
34
|
+
# dup so that Session#no_duplicate_class additions never leak back to the
|
|
35
35
|
# Duplicator-level set shared across every run.
|
|
36
|
-
@
|
|
36
|
+
@no_duplicate_classes = no_duplicate_classes.dup
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Register a handler that overrides Duplicator#on for the current session
|
|
@@ -54,36 +54,36 @@ module ActiveRecord
|
|
|
54
54
|
# is set equal to their old_id, so any record duplicated later that
|
|
55
55
|
# references them will keep pointing at the same rows.
|
|
56
56
|
#: (*ActiveRecord::Base) -> void
|
|
57
|
-
def
|
|
57
|
+
def no_duplicate(*records)
|
|
58
58
|
records.each do |record|
|
|
59
59
|
id = current_id(record)
|
|
60
|
-
|
|
60
|
+
store_duplicate_id(record.class, old_id: id, new_id: id)
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# Declare that the given ids of the given class must not be duplicated,
|
|
65
|
-
# without loading the records. Equivalent to calling
|
|
65
|
+
# without loading the records. Equivalent to calling no_duplicate on each
|
|
66
66
|
# record but avoids the database round-trip when the ids are already
|
|
67
67
|
# known. Each id should have the same shape as the class's primary key
|
|
68
68
|
# (scalar for a single-column pk, Array for a composite pk).
|
|
69
69
|
#: (Class, *untyped) -> void
|
|
70
|
-
def
|
|
71
|
-
ids.each { |id|
|
|
70
|
+
def no_duplicate_id(klass, *ids)
|
|
71
|
+
ids.each { |id| store_duplicate_id(klass, old_id: id, new_id: id) }
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
# Class-wide equivalent of
|
|
75
|
-
# each given class had been passed to
|
|
76
|
-
# (matching the
|
|
77
|
-
# with a Relation that happens to contain
|
|
78
|
-
# default bulk_insert path becomes a no-op, and
|
|
79
|
-
# queried old_id back unchanged.
|
|
74
|
+
# Class-wide equivalent of no_duplicate: it is exactly as if every record
|
|
75
|
+
# of each given class had been passed to no_duplicate. Handlers still fire
|
|
76
|
+
# (matching the no_duplicate case where a handler for the class is invoked
|
|
77
|
+
# with a Relation that happens to contain no_duplicate records); the
|
|
78
|
+
# default bulk_insert path becomes a no-op, and fetch_duplicate_id echoes
|
|
79
|
+
# the queried old_id back unchanged.
|
|
80
80
|
#
|
|
81
81
|
# Use when the target database is expected to already hold every
|
|
82
82
|
# referenced row (shared lookup tables, tenant-wide catalogs, etc.), so
|
|
83
|
-
# per-record
|
|
83
|
+
# per-record no_duplicate would only differ from this in cost, not effect.
|
|
84
84
|
#: (*Class) -> void
|
|
85
|
-
def
|
|
86
|
-
@
|
|
85
|
+
def no_duplicate_class(*klasses)
|
|
86
|
+
@no_duplicate_classes.merge(klasses)
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# Record a mapping from an old_id to a new_id. Called automatically from
|
|
@@ -96,7 +96,7 @@ module ActiveRecord
|
|
|
96
96
|
# Raises InvalidRecordIdError when the same old_id is already mapped to
|
|
97
97
|
# a different new_id.
|
|
98
98
|
#: (Class, old_id: untyped, new_id: untyped) -> void
|
|
99
|
-
def
|
|
99
|
+
def store_duplicate_id(klass, old_id:, new_id:)
|
|
100
100
|
map = @record_id_map[klass]
|
|
101
101
|
|
|
102
102
|
if !map.key?(old_id)
|
|
@@ -113,26 +113,28 @@ module ActiveRecord
|
|
|
113
113
|
#
|
|
114
114
|
# Raises MissingNewIdError when no mapping exists. This usually means a
|
|
115
115
|
# belongs_to parent has not been duplicated yet; place it earlier in the
|
|
116
|
-
# associations tree or
|
|
116
|
+
# associations tree, or call no_duplicate / no_duplicate_id explicitly.
|
|
117
117
|
#: (Class, old_id: untyped) -> untyped
|
|
118
|
-
def
|
|
119
|
-
return old_id if @
|
|
118
|
+
def fetch_duplicate_id(klass, old_id:)
|
|
119
|
+
return old_id if @no_duplicate_classes.include?(klass)
|
|
120
120
|
|
|
121
121
|
@record_id_map[klass].fetch(old_id) do
|
|
122
|
-
raise MissingNewIdError, "no
|
|
122
|
+
raise MissingNewIdError, "no duplicate id registered for #{klass}##{old_id.inspect}"
|
|
123
123
|
end
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
-
# Bulk-insert copies of the given records into klass,
|
|
126
|
+
# Bulk-insert copies of the given records into klass, omitting any that
|
|
127
127
|
# are already present in the id map. Handlers can reuse this via HandlerApi
|
|
128
128
|
# to defer a subset of records to the default duplication path.
|
|
129
129
|
#
|
|
130
|
-
# When klass has been passed to
|
|
130
|
+
# When klass has been passed to no_duplicate_class the call is a no-op that
|
|
131
131
|
# never touches the DB: it is equivalent to having pre-registered every
|
|
132
|
-
# incoming record via
|
|
132
|
+
# incoming record via no_duplicate, only without the per-record enumeration.
|
|
133
133
|
#: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
134
134
|
def bulk_insert(klass, records, batch_size: DEFAULT_BATCH_SIZE, cursor: nil)
|
|
135
|
-
return if @
|
|
135
|
+
return if @no_duplicate_classes.include?(klass)
|
|
136
|
+
|
|
137
|
+
ensure_insert_returning_supported!(klass)
|
|
136
138
|
|
|
137
139
|
batches = enumerate_batches(records, batch_size:, cursor:)
|
|
138
140
|
|
|
@@ -147,7 +149,7 @@ module ActiveRecord
|
|
|
147
149
|
inserted.to_a.each.with_index do |returned_row, i|
|
|
148
150
|
new_id = extract_id_from_row(klass, returned_row)
|
|
149
151
|
old_id = current_id(old_records[i])
|
|
150
|
-
|
|
152
|
+
store_duplicate_id(klass, old_id:, new_id:)
|
|
151
153
|
end
|
|
152
154
|
end
|
|
153
155
|
end
|
|
@@ -189,7 +191,7 @@ module ActiveRecord
|
|
|
189
191
|
duplicate_records(relation.klass, relation)
|
|
190
192
|
end
|
|
191
193
|
|
|
192
|
-
new_id =
|
|
194
|
+
new_id = fetch_duplicate_id(root_record.class, old_id: current_id(root_record))
|
|
193
195
|
root_record.class.find(new_id)
|
|
194
196
|
end
|
|
195
197
|
end
|
|
@@ -201,9 +203,9 @@ module ActiveRecord
|
|
|
201
203
|
# caller is responsible for splitting mixed inputs (e.g. STI subclasses)
|
|
202
204
|
# into per-class Relations and passing the matching class in.
|
|
203
205
|
#
|
|
204
|
-
#
|
|
205
|
-
# (mirroring the per-record
|
|
206
|
-
# applied inside bulk_insert instead.
|
|
206
|
+
# no_duplicate_class does NOT short-circuit here: handlers must still fire
|
|
207
|
+
# (mirroring the per-record no_duplicate case), and the class-wide exclusion
|
|
208
|
+
# is applied inside bulk_insert instead.
|
|
207
209
|
#: (Class, untyped) -> void
|
|
208
210
|
def duplicate_records(klass, relation_or_records)
|
|
209
211
|
handler = resolve_handler(klass)
|
|
@@ -266,26 +268,46 @@ module ActiveRecord
|
|
|
266
268
|
if old_values.all?(&:nil?)
|
|
267
269
|
fk.each { |c| attrs[c] = nil }
|
|
268
270
|
else
|
|
269
|
-
new_values =
|
|
271
|
+
new_values = fetch_duplicate_id(reflection.klass, old_id: old_values)
|
|
270
272
|
fk.zip(new_values).each { |c, v| attrs[c] = v }
|
|
271
273
|
end
|
|
272
274
|
else
|
|
273
275
|
old_value = old_record[fk]
|
|
274
|
-
attrs[fk] = old_value.nil? ? nil :
|
|
276
|
+
attrs[fk] = old_value.nil? ? nil : fetch_duplicate_id(reflection.klass, old_id: old_value)
|
|
275
277
|
end
|
|
276
278
|
end
|
|
277
279
|
|
|
278
280
|
# Primary key columns whose values are filled in by the database (e.g. a
|
|
279
|
-
# bigserial `id
|
|
280
|
-
#
|
|
281
|
-
#
|
|
281
|
+
# bigserial `id`, a SQLite INTEGER PRIMARY KEY / ROWID alias, a column
|
|
282
|
+
# with a `default_function` such as `gen_random_uuid()`). These must be
|
|
283
|
+
# stripped from the attributes before calling insert_all! so the DB can
|
|
284
|
+
# assign fresh values. Composite pk columns without a default (e.g.
|
|
285
|
+
# tenant_id) are kept.
|
|
282
286
|
#: (Class) -> Array[String]
|
|
283
287
|
def auto_generated_pk_columns(klass)
|
|
284
288
|
Array.wrap(klass.primary_key).select do |key|
|
|
285
289
|
column = klass.column_for_attribute(key)
|
|
286
|
-
column.
|
|
290
|
+
column.auto_populated?
|
|
287
291
|
end
|
|
288
292
|
end
|
|
293
|
+
|
|
294
|
+
# Guard against adapters that cannot return inserted primary keys.
|
|
295
|
+
# The default path relies on `insert_all!(...)` echoing back the pk of
|
|
296
|
+
# every inserted row so the id map can be populated in one round trip;
|
|
297
|
+
# adapters that do not support RETURNING silently return an empty
|
|
298
|
+
# inserted-row set and would leave the map incomplete.
|
|
299
|
+
#: (Class) -> void
|
|
300
|
+
def ensure_insert_returning_supported!(klass)
|
|
301
|
+
connection = klass.connection
|
|
302
|
+
return if connection.supports_insert_returning?
|
|
303
|
+
|
|
304
|
+
raise UnsupportedAdapterError,
|
|
305
|
+
"#{connection.adapter_name} does not support insert with RETURNING. " \
|
|
306
|
+
"activerecord-duplicator requires an adapter whose " \
|
|
307
|
+
"`supports_insert_returning?` returns true (e.g. PostgreSQL, or " \
|
|
308
|
+
"SQLite >= 3.35). Register a custom handler with `on(#{klass})` " \
|
|
309
|
+
"if you need to duplicate #{klass} on this adapter."
|
|
310
|
+
end
|
|
289
311
|
end
|
|
290
312
|
end
|
|
291
313
|
end
|
|
@@ -15,16 +15,16 @@ module ActiveRecord
|
|
|
15
15
|
# duplicator.freeze
|
|
16
16
|
#
|
|
17
17
|
# new_root = duplicator.duplicate(old_root, associations: [...]) do |session|
|
|
18
|
-
# session.
|
|
18
|
+
# session.no_duplicate(tenant)
|
|
19
19
|
# end
|
|
20
20
|
class Duplicator
|
|
21
21
|
# @rbs @handlers: Hash[Class, Proc]
|
|
22
|
-
# @rbs @
|
|
22
|
+
# @rbs @no_duplicate_classes: Set[Class]
|
|
23
23
|
|
|
24
24
|
#: () -> void
|
|
25
25
|
def initialize
|
|
26
26
|
@handlers = {}
|
|
27
|
-
@
|
|
27
|
+
@no_duplicate_classes = Set.new
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
# Register a custom handler for the given model class. See HandlerApi for
|
|
@@ -39,13 +39,13 @@ module ActiveRecord
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
# Declare that every record of the given classes must not be duplicated in
|
|
42
|
-
# any subsequent run. See Session#
|
|
42
|
+
# any subsequent run. See Session#no_duplicate_class for the semantics; the
|
|
43
43
|
# Duplicator-level registration is applied to every fresh Session as its
|
|
44
|
-
# baseline, and Session#
|
|
44
|
+
# baseline, and Session#no_duplicate_class can extend it for a single run.
|
|
45
45
|
# Repeated calls with the same class are idempotent.
|
|
46
46
|
#: (*Class) -> void
|
|
47
|
-
def
|
|
48
|
-
@
|
|
47
|
+
def no_duplicate_class(*klasses)
|
|
48
|
+
@no_duplicate_classes.merge(klasses)
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# Prevent further registrations. Also freezes the internal handler map so
|
|
@@ -53,7 +53,7 @@ module ActiveRecord
|
|
|
53
53
|
#: () -> self
|
|
54
54
|
def freeze
|
|
55
55
|
@handlers.freeze
|
|
56
|
-
@
|
|
56
|
+
@no_duplicate_classes.freeze
|
|
57
57
|
super
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -61,15 +61,15 @@ module ActiveRecord
|
|
|
61
61
|
# created for every invocation, so state does not leak between runs.
|
|
62
62
|
#
|
|
63
63
|
# If a block is given it is called with the new Session before duplication
|
|
64
|
-
# starts, letting the caller
|
|
65
|
-
# were duplicated elsewhere.
|
|
64
|
+
# starts, letting the caller no_duplicate or store_duplicate_id for records
|
|
65
|
+
# that were duplicated elsewhere.
|
|
66
66
|
#
|
|
67
67
|
# transaction_options is forwarded verbatim to Session#run, which forwards
|
|
68
68
|
# it to `root_record.transaction`. Default is `{}` (plain
|
|
69
69
|
# `transaction do ... end`).
|
|
70
70
|
#: (ActiveRecord::Base, ?associations: untyped, ?transaction_options: Hash[Symbol, untyped]) ?{ (Session) -> void } -> ActiveRecord::Base
|
|
71
71
|
def duplicate(root_record, associations: [], transaction_options: {}, &block)
|
|
72
|
-
session = Session.new(handlers: @handlers,
|
|
72
|
+
session = Session.new(handlers: @handlers, no_duplicate_classes: @no_duplicate_classes)
|
|
73
73
|
block&.call(session)
|
|
74
74
|
session.run(root_record, associations:, transaction_options:)
|
|
75
75
|
end
|
|
@@ -5,21 +5,32 @@ module ActiveRecord
|
|
|
5
5
|
class Error < StandardError
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
-
# Raised when
|
|
9
|
-
# mapping with a different new_id. This typically indicates a bug
|
|
10
|
-
# calling code or a handler.
|
|
8
|
+
# Raised when store_duplicate_id (or no_duplicate) tries to overwrite an
|
|
9
|
+
# existing mapping with a different new_id. This typically indicates a bug
|
|
10
|
+
# in the calling code or a handler.
|
|
11
11
|
class InvalidRecordIdError < Error
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
# Raised when
|
|
14
|
+
# Raised when fetch_duplicate_id cannot find a mapping for the given old_id.
|
|
15
15
|
# This usually means a belongs_to parent has not been duplicated yet;
|
|
16
|
-
# place the parent earlier in the associations tree, or
|
|
17
|
-
# explicitly.
|
|
16
|
+
# place the parent earlier in the associations tree, or call no_duplicate /
|
|
17
|
+
# no_duplicate_id on it explicitly.
|
|
18
18
|
class MissingNewIdError < Error
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Raised when Session#on is called twice for the same model class.
|
|
22
22
|
class DuplicateHandlerError < Error
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
# Raised when a bulk_insert is attempted against a connection whose adapter
|
|
26
|
+
# does not implement `supports_insert_returning?`. The default duplication
|
|
27
|
+
# path relies on `insert_all!` returning the inserted primary keys so the
|
|
28
|
+
# id map can be populated in one round trip; adapters that do not support
|
|
29
|
+
# RETURNING (e.g. mysql2, trilogy) cannot participate in this flow.
|
|
30
|
+
#
|
|
31
|
+
# Register a handler with `Duplicator#on` / `Session#on` that uses
|
|
32
|
+
# `create!` (or similar) if you need to duplicate rows on such an adapter.
|
|
33
|
+
class UnsupportedAdapterError < Error
|
|
34
|
+
end
|
|
24
35
|
end
|
|
25
36
|
end
|
|
@@ -13,19 +13,19 @@ module ActiveRecord
|
|
|
13
13
|
def initialize: (Session) -> void
|
|
14
14
|
|
|
15
15
|
# : (*ActiveRecord::Base) -> void
|
|
16
|
-
def
|
|
16
|
+
def no_duplicate: (*ActiveRecord::Base) -> void
|
|
17
17
|
|
|
18
18
|
# : (Class, *untyped) -> void
|
|
19
|
-
def
|
|
19
|
+
def no_duplicate_id: (Class, *untyped) -> void
|
|
20
20
|
|
|
21
21
|
# : (*Class) -> void
|
|
22
|
-
def
|
|
22
|
+
def no_duplicate_class: (*Class) -> void
|
|
23
23
|
|
|
24
24
|
# : (Class, old_id: untyped, new_id: untyped) -> void
|
|
25
|
-
def
|
|
25
|
+
def store_duplicate_id: (Class, old_id: untyped, new_id: untyped) -> void
|
|
26
26
|
|
|
27
27
|
# : (Class, old_id: untyped) -> untyped
|
|
28
|
-
def
|
|
28
|
+
def fetch_duplicate_id: (Class, old_id: untyped) -> untyped
|
|
29
29
|
|
|
30
30
|
# : (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
31
31
|
def bulk_insert: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
@@ -5,13 +5,13 @@ module ActiveRecord
|
|
|
5
5
|
# One-shot execution context for a duplication run.
|
|
6
6
|
#
|
|
7
7
|
# A Session owns the record id map (old_id -> new_id, per class) and
|
|
8
|
-
# exposes the API a handler needs:
|
|
9
|
-
# / bulk_insert / attributes_for.
|
|
8
|
+
# exposes the API a handler needs: no_duplicate / store_duplicate_id /
|
|
9
|
+
# fetch_duplicate_id / bulk_insert / attributes_for.
|
|
10
10
|
#
|
|
11
11
|
# Users normally do not instantiate Session directly; a fresh Session is
|
|
12
12
|
# created by Duplicator#duplicate for every run, so state does not leak
|
|
13
13
|
# across runs. Duplicator#duplicate yields the Session to its block so
|
|
14
|
-
# callers can
|
|
14
|
+
# callers can no_duplicate / store_duplicate_id before the run starts.
|
|
15
15
|
#
|
|
16
16
|
# Composite primary keys (Rails 7.1+ `self.primary_key = [:a, :b]`) are
|
|
17
17
|
# supported. An id is a scalar for a single-column primary key and an
|
|
@@ -20,7 +20,7 @@ module ActiveRecord
|
|
|
20
20
|
class Session
|
|
21
21
|
DEFAULT_BATCH_SIZE: ::Integer
|
|
22
22
|
|
|
23
|
-
@
|
|
23
|
+
@no_duplicate_classes: Set[Class]
|
|
24
24
|
|
|
25
25
|
@user_handlers: Hash[Class, Proc]
|
|
26
26
|
|
|
@@ -28,8 +28,8 @@ module ActiveRecord
|
|
|
28
28
|
|
|
29
29
|
@record_id_map: Hash[Class, Hash[untyped, untyped]]
|
|
30
30
|
|
|
31
|
-
# : (?handlers: Hash[Class, Proc], ?
|
|
32
|
-
def initialize: (?handlers: Hash[Class, Proc], ?
|
|
31
|
+
# : (?handlers: Hash[Class, Proc], ?no_duplicate_classes: Set[Class]) -> void
|
|
32
|
+
def initialize: (?handlers: Hash[Class, Proc], ?no_duplicate_classes: Set[Class]) -> void
|
|
33
33
|
|
|
34
34
|
# Register a handler that overrides Duplicator#on for the current session
|
|
35
35
|
# only. Useful when a single duplication run needs a special copy of a
|
|
@@ -45,28 +45,28 @@ module ActiveRecord
|
|
|
45
45
|
# is set equal to their old_id, so any record duplicated later that
|
|
46
46
|
# references them will keep pointing at the same rows.
|
|
47
47
|
# : (*ActiveRecord::Base) -> void
|
|
48
|
-
def
|
|
48
|
+
def no_duplicate: (*ActiveRecord::Base) -> void
|
|
49
49
|
|
|
50
50
|
# Declare that the given ids of the given class must not be duplicated,
|
|
51
|
-
# without loading the records. Equivalent to calling
|
|
51
|
+
# without loading the records. Equivalent to calling no_duplicate on each
|
|
52
52
|
# record but avoids the database round-trip when the ids are already
|
|
53
53
|
# known. Each id should have the same shape as the class's primary key
|
|
54
54
|
# (scalar for a single-column pk, Array for a composite pk).
|
|
55
55
|
# : (Class, *untyped) -> void
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
# Class-wide equivalent of
|
|
59
|
-
# each given class had been passed to
|
|
60
|
-
# (matching the
|
|
61
|
-
# with a Relation that happens to contain
|
|
62
|
-
# default bulk_insert path becomes a no-op, and
|
|
63
|
-
# queried old_id back unchanged.
|
|
56
|
+
def no_duplicate_id: (Class, *untyped) -> void
|
|
57
|
+
|
|
58
|
+
# Class-wide equivalent of no_duplicate: it is exactly as if every record
|
|
59
|
+
# of each given class had been passed to no_duplicate. Handlers still fire
|
|
60
|
+
# (matching the no_duplicate case where a handler for the class is invoked
|
|
61
|
+
# with a Relation that happens to contain no_duplicate records); the
|
|
62
|
+
# default bulk_insert path becomes a no-op, and fetch_duplicate_id echoes
|
|
63
|
+
# the queried old_id back unchanged.
|
|
64
64
|
#
|
|
65
65
|
# Use when the target database is expected to already hold every
|
|
66
66
|
# referenced row (shared lookup tables, tenant-wide catalogs, etc.), so
|
|
67
|
-
# per-record
|
|
67
|
+
# per-record no_duplicate would only differ from this in cost, not effect.
|
|
68
68
|
# : (*Class) -> void
|
|
69
|
-
def
|
|
69
|
+
def no_duplicate_class: (*Class) -> void
|
|
70
70
|
|
|
71
71
|
# Record a mapping from an old_id to a new_id. Called automatically from
|
|
72
72
|
# bulk_insert; called explicitly by user code (or a handler) when new
|
|
@@ -78,24 +78,24 @@ module ActiveRecord
|
|
|
78
78
|
# Raises InvalidRecordIdError when the same old_id is already mapped to
|
|
79
79
|
# a different new_id.
|
|
80
80
|
# : (Class, old_id: untyped, new_id: untyped) -> void
|
|
81
|
-
def
|
|
81
|
+
def store_duplicate_id: (Class, old_id: untyped, new_id: untyped) -> void
|
|
82
82
|
|
|
83
83
|
# Look up a new_id by (klass, old_id). Returns a scalar for a
|
|
84
84
|
# single-column pk and an Array for a composite pk.
|
|
85
85
|
#
|
|
86
86
|
# Raises MissingNewIdError when no mapping exists. This usually means a
|
|
87
87
|
# belongs_to parent has not been duplicated yet; place it earlier in the
|
|
88
|
-
# associations tree or
|
|
88
|
+
# associations tree, or call no_duplicate / no_duplicate_id explicitly.
|
|
89
89
|
# : (Class, old_id: untyped) -> untyped
|
|
90
|
-
def
|
|
90
|
+
def fetch_duplicate_id: (Class, old_id: untyped) -> untyped
|
|
91
91
|
|
|
92
|
-
# Bulk-insert copies of the given records into klass,
|
|
92
|
+
# Bulk-insert copies of the given records into klass, omitting any that
|
|
93
93
|
# are already present in the id map. Handlers can reuse this via HandlerApi
|
|
94
94
|
# to defer a subset of records to the default duplication path.
|
|
95
95
|
#
|
|
96
|
-
# When klass has been passed to
|
|
96
|
+
# When klass has been passed to no_duplicate_class the call is a no-op that
|
|
97
97
|
# never touches the DB: it is equivalent to having pre-registered every
|
|
98
|
-
# incoming record via
|
|
98
|
+
# incoming record via no_duplicate, only without the per-record enumeration.
|
|
99
99
|
# : (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
100
100
|
def bulk_insert: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
101
101
|
|
|
@@ -128,9 +128,9 @@ module ActiveRecord
|
|
|
128
128
|
# caller is responsible for splitting mixed inputs (e.g. STI subclasses)
|
|
129
129
|
# into per-class Relations and passing the matching class in.
|
|
130
130
|
#
|
|
131
|
-
#
|
|
132
|
-
# (mirroring the per-record
|
|
133
|
-
# applied inside bulk_insert instead.
|
|
131
|
+
# no_duplicate_class does NOT short-circuit here: handlers must still fire
|
|
132
|
+
# (mirroring the per-record no_duplicate case), and the class-wide exclusion
|
|
133
|
+
# is applied inside bulk_insert instead.
|
|
134
134
|
# : (Class, untyped) -> void
|
|
135
135
|
def duplicate_records: (Class, untyped) -> void
|
|
136
136
|
|
|
@@ -156,11 +156,21 @@ module ActiveRecord
|
|
|
156
156
|
def assign_foreign_key: (Hash[String, untyped], untyped, ActiveRecord::Base) -> void
|
|
157
157
|
|
|
158
158
|
# Primary key columns whose values are filled in by the database (e.g. a
|
|
159
|
-
# bigserial `id
|
|
160
|
-
#
|
|
161
|
-
#
|
|
159
|
+
# bigserial `id`, a SQLite INTEGER PRIMARY KEY / ROWID alias, a column
|
|
160
|
+
# with a `default_function` such as `gen_random_uuid()`). These must be
|
|
161
|
+
# stripped from the attributes before calling insert_all! so the DB can
|
|
162
|
+
# assign fresh values. Composite pk columns without a default (e.g.
|
|
163
|
+
# tenant_id) are kept.
|
|
162
164
|
# : (Class) -> Array[String]
|
|
163
165
|
def auto_generated_pk_columns: (Class) -> Array[String]
|
|
166
|
+
|
|
167
|
+
# Guard against adapters that cannot return inserted primary keys.
|
|
168
|
+
# The default path relies on `insert_all!(...)` echoing back the pk of
|
|
169
|
+
# every inserted row so the id map can be populated in one round trip;
|
|
170
|
+
# adapters that do not support RETURNING silently return an empty
|
|
171
|
+
# inserted-row set and would leave the map incomplete.
|
|
172
|
+
# : (Class) -> void
|
|
173
|
+
def ensure_insert_returning_supported!: (Class) -> void
|
|
164
174
|
end
|
|
165
175
|
end
|
|
166
176
|
end
|
|
@@ -13,12 +13,12 @@ module ActiveRecord
|
|
|
13
13
|
# duplicator.freeze
|
|
14
14
|
#
|
|
15
15
|
# new_root = duplicator.duplicate(old_root, associations: [...]) do |session|
|
|
16
|
-
# session.
|
|
16
|
+
# session.no_duplicate(tenant)
|
|
17
17
|
# end
|
|
18
18
|
class Duplicator
|
|
19
19
|
@handlers: Hash[Class, Proc]
|
|
20
20
|
|
|
21
|
-
@
|
|
21
|
+
@no_duplicate_classes: Set[Class]
|
|
22
22
|
|
|
23
23
|
# : () -> void
|
|
24
24
|
def initialize: () -> void
|
|
@@ -31,12 +31,12 @@ module ActiveRecord
|
|
|
31
31
|
def on: (Class) { (HandlerApi, Class, untyped) -> void } -> void
|
|
32
32
|
|
|
33
33
|
# Declare that every record of the given classes must not be duplicated in
|
|
34
|
-
# any subsequent run. See Session#
|
|
34
|
+
# any subsequent run. See Session#no_duplicate_class for the semantics; the
|
|
35
35
|
# Duplicator-level registration is applied to every fresh Session as its
|
|
36
|
-
# baseline, and Session#
|
|
36
|
+
# baseline, and Session#no_duplicate_class can extend it for a single run.
|
|
37
37
|
# Repeated calls with the same class are idempotent.
|
|
38
38
|
# : (*Class) -> void
|
|
39
|
-
def
|
|
39
|
+
def no_duplicate_class: (*Class) -> void
|
|
40
40
|
|
|
41
41
|
# Prevent further registrations. Also freezes the internal handler map so
|
|
42
42
|
# accidental mutation later fails loudly.
|
|
@@ -47,8 +47,8 @@ module ActiveRecord
|
|
|
47
47
|
# created for every invocation, so state does not leak between runs.
|
|
48
48
|
#
|
|
49
49
|
# If a block is given it is called with the new Session before duplication
|
|
50
|
-
# starts, letting the caller
|
|
51
|
-
# were duplicated elsewhere.
|
|
50
|
+
# starts, letting the caller no_duplicate or store_duplicate_id for records
|
|
51
|
+
# that were duplicated elsewhere.
|
|
52
52
|
#
|
|
53
53
|
# transaction_options is forwarded verbatim to Session#run, which forwards
|
|
54
54
|
# it to `root_record.transaction`. Default is `{}` (plain
|
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.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SmartHR
|
|
@@ -23,20 +23,6 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '8.0'
|
|
26
|
-
- !ruby/object:Gem::Dependency
|
|
27
|
-
name: pg
|
|
28
|
-
requirement: !ruby/object:Gem::Requirement
|
|
29
|
-
requirements:
|
|
30
|
-
- - ">="
|
|
31
|
-
- !ruby/object:Gem::Version
|
|
32
|
-
version: '1.5'
|
|
33
|
-
type: :runtime
|
|
34
|
-
prerelease: false
|
|
35
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
-
requirements:
|
|
37
|
-
- - ">="
|
|
38
|
-
- !ruby/object:Gem::Version
|
|
39
|
-
version: '1.5'
|
|
40
26
|
description: |
|
|
41
27
|
activerecord-duplicator copies an ActiveRecord record and its associated rows
|
|
42
28
|
(has_many, has_one, belongs_to, has_many :through) in one call. It rewires
|