activerecord-duplicator 0.1.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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE +13 -0
- data/README.md +172 -0
- data/lib/active_record/duplicator/association_traversal.rb +143 -0
- data/lib/active_record/duplicator/errors.rb +21 -0
- data/lib/active_record/duplicator/handler_api.rb +43 -0
- data/lib/active_record/duplicator/session.rb +268 -0
- data/lib/active_record/duplicator/version.rb +7 -0
- data/lib/active_record/duplicator.rb +66 -0
- data/lib/activerecord-duplicator.rb +3 -0
- data/sig/generated/active_record/duplicator/association_traversal.rbs +72 -0
- data/sig/generated/active_record/duplicator/errors.rbs +25 -0
- data/sig/generated/active_record/duplicator/handler_api.rbs +31 -0
- data/sig/generated/active_record/duplicator/session.rbs +130 -0
- data/sig/generated/active_record/duplicator/version.rbs +7 -0
- data/sig/generated/active_record/duplicator.rbs +45 -0
- data/sig/generated/activerecord-duplicator.rbs +2 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4bf58194f42894cf69c9ff83e02cd0f9f76fe38565e671e24ebb232221e8fa31
|
|
4
|
+
data.tar.gz: 1d20e02d28ef2bffcf831654d2b71fe73461d3eee0fa3b11ce5e95a15bac259a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ba02707cbdfdf479e2663bfcbf9b7d4012e1b1d43a9a483a041b76debff00d013e8b4f36efd7e81cb8e2e09d30a60b374a87345f625216dad0c75fee6898f1f0
|
|
7
|
+
data.tar.gz: 6871e902554ea9fc435b17bfa83e978a1034898bae00cf744edb61b191a89dc05bc479d316c6785a172a722f7d87b794114e173b819ed8176ccdffca380bd430
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
- Drop support for Ruby 3.2 (EOL)
|
|
4
|
+
- Drop support for Rails 7.2; assume ActiveRecord >= 8.0 so `in_batches(cursor:)` is always available
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-07-02
|
|
7
|
+
|
|
8
|
+
- Add `ActiveRecord::Duplicator::AssociationTraversal` — breadth-first traversal of preload-style association specifications
|
|
9
|
+
- Add `ActiveRecord::Duplicator::Session` — coordinates one duplication run: record id map, handler registry, `duplicate` / `bulk_insert` / `attributes_for`
|
|
10
|
+
- Add `ActiveRecord::Duplicator::HandlerApi` — facade passed to `Session#on` handler blocks
|
|
11
|
+
- Add error types under `ActiveRecord::Duplicator::Error`: `InvalidRecordIdError` / `MissingNewIdError` / `DuplicateHandlerError`
|
|
12
|
+
- Support composite primary keys (Rails 7.1+)
|
data/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright 2026 SmartHR, Inc.
|
|
2
|
+
|
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
you may not use this file except in compliance with the License.
|
|
5
|
+
You may obtain a copy of the License at
|
|
6
|
+
|
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
|
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
See the License for the specific language governing permissions and
|
|
13
|
+
limitations under the License.
|
data/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# activerecord-duplicator
|
|
2
|
+
|
|
3
|
+
Duplicate an ActiveRecord record together with its associated rows (has_many,
|
|
4
|
+
has_one, belongs_to, has_many :through) in one call. Foreign keys are rewired
|
|
5
|
+
through an internal id map; callbacks are bypassed via `insert_all!` so
|
|
6
|
+
duplication does not fire `after_create` and friends. Per-model handlers let
|
|
7
|
+
you plug in custom logic when a plain copy is not enough. Composite primary
|
|
8
|
+
keys (Rails 7.1+) are supported.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bundle add activerecord-duplicator
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or without Bundler:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install activerecord-duplicator
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Requirements
|
|
23
|
+
|
|
24
|
+
- Ruby >= 3.3
|
|
25
|
+
- ActiveRecord >= 8.0 (tested against 8.0, 8.1)
|
|
26
|
+
- PostgreSQL (via `pg` gem). The gem relies on `insert_all!` returning inserted
|
|
27
|
+
primary keys.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Basic duplication
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
duplicator = ActiveRecord::Duplicator.new
|
|
35
|
+
|
|
36
|
+
new_post = duplicator.duplicate(
|
|
37
|
+
old_post,
|
|
38
|
+
associations: [:comments, tags: :taggings],
|
|
39
|
+
)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`duplicate` creates a new `Post` (and all rows reachable via the given
|
|
43
|
+
`associations`), rewires every foreign key to point at the freshly created
|
|
44
|
+
parents, and returns the new root record.
|
|
45
|
+
|
|
46
|
+
### Keeping records referenced but not copied (`mark_skip`)
|
|
47
|
+
|
|
48
|
+
When you want the duplicated rows to keep pointing at an existing record
|
|
49
|
+
(for example the current tenant, current user, or a shared lookup table),
|
|
50
|
+
mark that record as skipped before calling `duplicate`:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
duplicator = ActiveRecord::Duplicator.new
|
|
54
|
+
duplicator.mark_skip(tenant)
|
|
55
|
+
duplicator.mark_skip(current_user)
|
|
56
|
+
|
|
57
|
+
duplicator.duplicate(old_project, associations: [tasks: :attachments])
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Skipped records get `new_id == old_id` in the internal map, so children that
|
|
61
|
+
reference them are inserted with the original id unchanged.
|
|
62
|
+
|
|
63
|
+
### Registering an id from outside (`store_new_id` / `fetch_new_id`)
|
|
64
|
+
|
|
65
|
+
If you produce a duplicate yourself (for example a model with a uniqueness
|
|
66
|
+
constraint that has to be reshaped before insert), tell the session about the
|
|
67
|
+
mapping so downstream children can find the new parent id:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
new_project = old_project.dup
|
|
71
|
+
new_project.slug = "#{old_project.slug}-copy"
|
|
72
|
+
new_project.save!
|
|
73
|
+
|
|
74
|
+
duplicator = ActiveRecord::Duplicator.new
|
|
75
|
+
duplicator.store_new_id(Project, old_id: old_project.id, new_id: new_project.id)
|
|
76
|
+
duplicator.duplicate(old_project, associations: [:tasks])
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`fetch_new_id(klass, old_id:)` returns the mapping (scalar for a single-column
|
|
80
|
+
primary key, Array for a composite one) and raises `MissingNewIdError` if the
|
|
81
|
+
parent has not been duplicated yet.
|
|
82
|
+
|
|
83
|
+
### Per-model handlers (`on`)
|
|
84
|
+
|
|
85
|
+
Some models need custom logic that the default `insert_all!` path cannot
|
|
86
|
+
express: ActiveStorage attachments, unique constraint rewrites, values that
|
|
87
|
+
depend on the target tenant, and so on. Register a handler and it will be
|
|
88
|
+
called instead of the default duplication path for that class:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
duplicator.on(Attachment) do |api, klass, records|
|
|
92
|
+
records.find_each do |record|
|
|
93
|
+
new_record = klass.create!(
|
|
94
|
+
post_id: api.fetch_new_id(Post, old_id: record.post_id),
|
|
95
|
+
)
|
|
96
|
+
new_record.file.attach(record.file.blob)
|
|
97
|
+
api.store_new_id(klass, old_id: record.id, new_id: new_record.id)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`api` is a `HandlerApi` facade over the session that exposes
|
|
103
|
+
`mark_skip` / `store_new_id` / `fetch_new_id` / `bulk_insert` /
|
|
104
|
+
`attributes_for`. Registering two handlers for the same class raises
|
|
105
|
+
`DuplicateHandlerError`.
|
|
106
|
+
|
|
107
|
+
### Composite primary keys
|
|
108
|
+
|
|
109
|
+
`self.primary_key = [:tenant_id, :id]` classes are handled the same way.
|
|
110
|
+
Non-auto columns of the composite pk (e.g. `tenant_id`) are carried over from
|
|
111
|
+
the source record; only DB-populated columns (e.g. a `bigserial id`) get fresh
|
|
112
|
+
values. `foreign_key: [:tenant_id, :owner_id]` associations are traversed with
|
|
113
|
+
tuple `WHERE (a, b) IN ((v1, v2), ...)` syntax.
|
|
114
|
+
|
|
115
|
+
### Errors
|
|
116
|
+
|
|
117
|
+
All errors inherit from `ActiveRecord::Duplicator::Error`:
|
|
118
|
+
|
|
119
|
+
- `InvalidRecordIdError` — the same `old_id` was registered with a different `new_id`.
|
|
120
|
+
- `MissingNewIdError` — `fetch_new_id` had no mapping. Usually means a
|
|
121
|
+
belongs_to parent was not duplicated yet; place it earlier in the
|
|
122
|
+
associations tree or mark it skipped.
|
|
123
|
+
- `DuplicateHandlerError` — `on(klass)` was called twice for the same class.
|
|
124
|
+
|
|
125
|
+
Because they share a common ancestor, one `rescue` clause catches any
|
|
126
|
+
duplication-specific failure:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
begin
|
|
130
|
+
duplicator.duplicate(old_post, associations: [:comments])
|
|
131
|
+
rescue ActiveRecord::Duplicator::Error => e
|
|
132
|
+
Rails.logger.error("duplication failed: #{e.class.name} #{e.message}")
|
|
133
|
+
raise
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Performance notes
|
|
138
|
+
|
|
139
|
+
`AssociationTraversal` currently pluck-collects parent ids and emits
|
|
140
|
+
`WHERE fk IN (id1, ..., idN)`. Very large N (tens of thousands) may hit
|
|
141
|
+
PostgreSQL planner degradation; if you run into this, split your work into
|
|
142
|
+
smaller root records or file an issue.
|
|
143
|
+
|
|
144
|
+
## Development
|
|
145
|
+
|
|
146
|
+
The gem tests against a real PostgreSQL instance. `compose.yml` at the repo
|
|
147
|
+
root starts one for you on port 55432:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
docker compose up -d
|
|
151
|
+
|
|
152
|
+
bundle install
|
|
153
|
+
bundle exec rake # rspec + rubocop
|
|
154
|
+
bundle exec rspec # spec only
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Override the connection with `DATABASE_URL` (used by CI):
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
DATABASE_URL=postgres://user:pass@host:port/db bundle exec rspec
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Contributing
|
|
164
|
+
|
|
165
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
166
|
+
<https://github.com/kufu/activerecord-duplicator>. This project is
|
|
167
|
+
intended to be a safe, welcoming space for collaboration; contributors are
|
|
168
|
+
expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
Released under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
# Breadth-first traversal of associations declared with preload-style syntax.
|
|
6
|
+
# Yields ActiveRecord::Relation objects in the order they would be needed to
|
|
7
|
+
# duplicate records level by level.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
# traversal = ActiveRecord::Duplicator::AssociationTraversal.new(
|
|
11
|
+
# owner,
|
|
12
|
+
# [items: :taggings],
|
|
13
|
+
# )
|
|
14
|
+
# traversal.each do |relation|
|
|
15
|
+
# # 1st: Relation of items belonging to owner
|
|
16
|
+
# # 2nd: Relation of taggings belonging to those items
|
|
17
|
+
# end
|
|
18
|
+
class AssociationTraversal
|
|
19
|
+
include Enumerable
|
|
20
|
+
|
|
21
|
+
# @rbs @record: untyped
|
|
22
|
+
# @rbs @associations: untyped
|
|
23
|
+
|
|
24
|
+
#: (untyped record, untyped associations) -> void
|
|
25
|
+
def initialize(record, associations)
|
|
26
|
+
@record = record
|
|
27
|
+
@associations = associations
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
#: () { (ActiveRecord::Relation) -> void } -> self
|
|
31
|
+
#: () -> Enumerator[ActiveRecord::Relation, self]
|
|
32
|
+
def each(&)
|
|
33
|
+
return enum_for(:each) unless block_given?
|
|
34
|
+
|
|
35
|
+
Array.wrap(@associations).each do |association|
|
|
36
|
+
traverse(@record.class, association, Array.wrap(@record), &)
|
|
37
|
+
end
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
#: (Class, untyped, untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
44
|
+
def traverse(klass, association, records, &)
|
|
45
|
+
case association
|
|
46
|
+
when Hash
|
|
47
|
+
traverse_hash(klass, association, records, &)
|
|
48
|
+
when Symbol, String
|
|
49
|
+
traverse_leaf(klass, association, records, &)
|
|
50
|
+
else
|
|
51
|
+
raise ArgumentError, "#{association.inspect} is not a valid association form"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: (Class, Symbol | String, untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
56
|
+
def traverse_leaf(klass, association, records)
|
|
57
|
+
reflection = find_reflection(klass, association)
|
|
58
|
+
yield build_relation(reflection.klass, reflection, records)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#: (Class, Hash[Symbol | String, untyped], untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
62
|
+
def traverse_hash(klass, association, records, &)
|
|
63
|
+
association.each do |parent, children|
|
|
64
|
+
reflection = find_reflection(klass, parent)
|
|
65
|
+
relation = build_relation(reflection.klass, reflection, records)
|
|
66
|
+
yield relation
|
|
67
|
+
|
|
68
|
+
Array.wrap(children).each do |child|
|
|
69
|
+
traverse(relation.klass, child, relation, &)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Resolves the intermediate through_reflection first, then replaces it with the
|
|
75
|
+
# source_reflection. For has_many/has_one it looks up children by matching the
|
|
76
|
+
# parent's primary key against the child's foreign key. For belongs_to it looks
|
|
77
|
+
# up the parent by matching the child's foreign key against the parent's primary key.
|
|
78
|
+
#
|
|
79
|
+
# For composite primary keys the foreign_key / active_record_primary_key of a
|
|
80
|
+
# reflection is an Array of column names; the corresponding WHERE clause uses
|
|
81
|
+
# tuple syntax like `where([:a, :b] => [[v1, v2], [v3, v4]])`.
|
|
82
|
+
#: (Class, untyped, untyped) -> ActiveRecord::Relation
|
|
83
|
+
def build_relation(klass, reflection, records)
|
|
84
|
+
if reflection.through_reflection?
|
|
85
|
+
records = build_relation(reflection.through_reflection.klass, reflection.through_reflection, records)
|
|
86
|
+
reflection = reflection.source_reflection
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if reflection.collection? || reflection.has_one?
|
|
90
|
+
lookup_columns = Array.wrap(reflection.active_record_primary_key)
|
|
91
|
+
filter_columns = Array.wrap(reflection.foreign_key)
|
|
92
|
+
elsif reflection.belongs_to?
|
|
93
|
+
lookup_columns = Array.wrap(reflection.foreign_key)
|
|
94
|
+
filter_columns = Array.wrap(reflection.active_record_primary_key)
|
|
95
|
+
else
|
|
96
|
+
raise NotImplementedError, "reflection kind #{reflection.class.name} is not supported"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
ids = collect_ids(records, lookup_columns)
|
|
100
|
+
klass.where(where_key(filter_columns) => ids)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
#: (Class, Symbol | String) -> untyped
|
|
104
|
+
def find_reflection(klass, association)
|
|
105
|
+
reflection = klass.reflect_on_association(association)
|
|
106
|
+
raise ArgumentError, "#{klass.name} has no association #{association}" if reflection.nil?
|
|
107
|
+
|
|
108
|
+
reflection
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Rails accepts either a scalar column name or an Array of column names
|
|
112
|
+
# as the LHS of a WHERE clause. For single-column pk we hand it a scalar
|
|
113
|
+
# so the generated SQL stays as `WHERE col IN (...)`.
|
|
114
|
+
#: (Array[Symbol | String]) -> (Symbol | String | Array[Symbol | String])
|
|
115
|
+
def where_key(columns)
|
|
116
|
+
columns.length == 1 ? columns.first : columns
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Avoid instantiating ActiveRecord objects just to read ids:
|
|
120
|
+
# use pluck for Relations and map for plain arrays. For composite pk
|
|
121
|
+
# returns an Array of Arrays (`[[v1, v2], ...]`); for single-column pk
|
|
122
|
+
# returns a flat Array (`[v1, v2, ...]`).
|
|
123
|
+
#: (untyped, Array[Symbol | String]) -> Array[untyped]
|
|
124
|
+
def collect_ids(records, columns)
|
|
125
|
+
if records.is_a?(ActiveRecord::Relation)
|
|
126
|
+
if columns.length == 1
|
|
127
|
+
records.pluck(columns.first)
|
|
128
|
+
else
|
|
129
|
+
records.pluck(*columns)
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
records.map do |r|
|
|
133
|
+
if columns.length == 1
|
|
134
|
+
r.public_send(columns.first)
|
|
135
|
+
else
|
|
136
|
+
columns.map { |c| r.public_send(c) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when store_new_id (or mark_skip) tries to overwrite an existing
|
|
8
|
+
# mapping with a different new_id. This typically indicates a bug in the
|
|
9
|
+
# calling code or a handler.
|
|
10
|
+
class InvalidRecordIdError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when fetch_new_id cannot find a mapping for the given old_id.
|
|
13
|
+
# This usually means a belongs_to parent has not been duplicated yet;
|
|
14
|
+
# place the parent earlier in the associations tree, or register/skip it
|
|
15
|
+
# explicitly.
|
|
16
|
+
class MissingNewIdError < Error; end
|
|
17
|
+
|
|
18
|
+
# Raised when Session#on is called twice for the same model class.
|
|
19
|
+
class DuplicateHandlerError < Error; end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
# The public API surface handed to blocks registered via Session#on.
|
|
6
|
+
# Wraps the current Session and exposes only the subset of methods a
|
|
7
|
+
# handler should call. Session-level methods such as on and duplicate are
|
|
8
|
+
# intentionally not surfaced here.
|
|
9
|
+
class HandlerApi
|
|
10
|
+
# @rbs @session: Session
|
|
11
|
+
|
|
12
|
+
#: (Session) -> void
|
|
13
|
+
def initialize(session)
|
|
14
|
+
@session = session
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (*ActiveRecord::Base) -> void
|
|
18
|
+
def mark_skip(...)
|
|
19
|
+
@session.mark_skip(...)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
#: (Class, old_id: untyped, new_id: untyped) -> void
|
|
23
|
+
def store_new_id(...)
|
|
24
|
+
@session.store_new_id(...)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: (Class, old_id: untyped) -> untyped
|
|
28
|
+
def fetch_new_id(...)
|
|
29
|
+
@session.fetch_new_id(...)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
33
|
+
def bulk_insert(...)
|
|
34
|
+
@session.bulk_insert(...)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: (Class, ActiveRecord::Base) -> Hash[String, untyped]
|
|
38
|
+
def attributes_for(...)
|
|
39
|
+
@session.attributes_for(...)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
# One-shot execution context for a duplication run.
|
|
6
|
+
#
|
|
7
|
+
# A Session owns the record id map (old_id -> new_id, per class) and
|
|
8
|
+
# exposes the API a handler needs: mark_skip / store_new_id / fetch_new_id
|
|
9
|
+
# / bulk_insert / attributes_for.
|
|
10
|
+
#
|
|
11
|
+
# Users normally do not instantiate Session directly; a fresh Session is
|
|
12
|
+
# created by Duplicator#duplicate for every run, so state does not leak
|
|
13
|
+
# across runs. Duplicator#duplicate yields the Session to its block so
|
|
14
|
+
# callers can mark_skip / store_new_id before the run starts.
|
|
15
|
+
#
|
|
16
|
+
# Composite primary keys (Rails 7.1+ `self.primary_key = [:a, :b]`) are
|
|
17
|
+
# supported. An id is a scalar for a single-column primary key and an
|
|
18
|
+
# Array for a composite one; the shape simply mirrors what
|
|
19
|
+
# `record.class.primary_key` reports.
|
|
20
|
+
class Session
|
|
21
|
+
DEFAULT_BATCH_SIZE = 1000
|
|
22
|
+
private_constant :DEFAULT_BATCH_SIZE
|
|
23
|
+
|
|
24
|
+
# @rbs @record_id_map: Hash[Class, Hash[untyped, untyped]]
|
|
25
|
+
# @rbs @handlers: Hash[Class, Proc]
|
|
26
|
+
# @rbs @user_handlers: Hash[Class, Proc]
|
|
27
|
+
|
|
28
|
+
#: (?handlers: Hash[Class, Proc]) -> void
|
|
29
|
+
def initialize(handlers: {})
|
|
30
|
+
@record_id_map = Hash.new { |hash, klass| hash[klass] = {} }
|
|
31
|
+
@handlers = handlers
|
|
32
|
+
@user_handlers = {}
|
|
33
|
+
@handlers_cache = {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Register a handler that overrides Duplicator#on for the current session
|
|
37
|
+
# only. Useful when a single duplication run needs a special copy of a
|
|
38
|
+
# model that the shared Duplicator config should not know about.
|
|
39
|
+
#
|
|
40
|
+
# Raises DuplicateHandlerError when the same class is registered twice
|
|
41
|
+
# via Session#on in the same session. Overriding a class that also has a
|
|
42
|
+
# Duplicator-level handler is intentional and does not raise.
|
|
43
|
+
#: (Class) { (HandlerApi, Class, untyped) -> void } -> void
|
|
44
|
+
def on(klass, &block)
|
|
45
|
+
@handlers_cache.clear
|
|
46
|
+
|
|
47
|
+
if @user_handlers.key?(klass)
|
|
48
|
+
raise DuplicateHandlerError, "handler for #{klass} is already overridden in this session"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@user_handlers[klass] = block
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Declare that the given records must not be duplicated: their new_id
|
|
55
|
+
# is set equal to their old_id, so any record duplicated later that
|
|
56
|
+
# references them will keep pointing at the same rows.
|
|
57
|
+
#: (*ActiveRecord::Base) -> void
|
|
58
|
+
def mark_skip(*records)
|
|
59
|
+
records.each do |record|
|
|
60
|
+
id = current_id(record)
|
|
61
|
+
store_new_id(record.class, old_id: id, new_id: id)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Record a mapping from an old_id to a new_id. Called automatically from
|
|
66
|
+
# bulk_insert; called explicitly by user code (or a handler) when new
|
|
67
|
+
# records are produced outside of the default duplication path.
|
|
68
|
+
#
|
|
69
|
+
# For a single-column primary key pass a scalar; for a composite primary
|
|
70
|
+
# key pass an Array matching the shape of the class's primary_key.
|
|
71
|
+
#
|
|
72
|
+
# Raises InvalidRecordIdError when the same old_id is already mapped to
|
|
73
|
+
# a different new_id.
|
|
74
|
+
#: (Class, old_id: untyped, new_id: untyped) -> void
|
|
75
|
+
def store_new_id(klass, old_id:, new_id:)
|
|
76
|
+
map = @record_id_map[klass]
|
|
77
|
+
|
|
78
|
+
if !map.key?(old_id)
|
|
79
|
+
map[old_id] = new_id
|
|
80
|
+
elsif map[old_id] != new_id
|
|
81
|
+
raise InvalidRecordIdError,
|
|
82
|
+
"#{klass} old_id=#{old_id.inspect} is already mapped to " \
|
|
83
|
+
"#{map[old_id].inspect}, cannot overwrite with #{new_id.inspect}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Look up a new_id by (klass, old_id). Returns a scalar for a
|
|
88
|
+
# single-column pk and an Array for a composite pk.
|
|
89
|
+
#
|
|
90
|
+
# Raises MissingNewIdError when no mapping exists. This usually means a
|
|
91
|
+
# belongs_to parent has not been duplicated yet; place it earlier in the
|
|
92
|
+
# associations tree or register/skip it explicitly.
|
|
93
|
+
#: (Class, old_id: untyped) -> untyped
|
|
94
|
+
def fetch_new_id(klass, old_id:)
|
|
95
|
+
@record_id_map[klass].fetch(old_id) do
|
|
96
|
+
raise MissingNewIdError, "no new_id registered for #{klass}##{old_id.inspect}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Bulk-insert copies of the given records into klass, skipping any that
|
|
101
|
+
# are already present in the id map. Handlers can reuse this via HandlerApi
|
|
102
|
+
# to defer a subset of records to the default duplication path.
|
|
103
|
+
#: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
104
|
+
def bulk_insert(klass, records, batch_size: DEFAULT_BATCH_SIZE, cursor: nil)
|
|
105
|
+
batches = enumerate_batches(records, batch_size:, cursor:)
|
|
106
|
+
|
|
107
|
+
batches.each do |batch|
|
|
108
|
+
old_records = batch.to_a
|
|
109
|
+
old_records.reject! { |r| @record_id_map[klass].key?(current_id(r)) }
|
|
110
|
+
next if old_records.empty?
|
|
111
|
+
|
|
112
|
+
rows = old_records.map { |r| attributes_for(klass, r) }
|
|
113
|
+
inserted = klass.insert_all!(rows)
|
|
114
|
+
|
|
115
|
+
inserted.to_a.each.with_index do |returned_row, i|
|
|
116
|
+
new_id = extract_id_from_row(klass, returned_row)
|
|
117
|
+
old_id = current_id(old_records[i])
|
|
118
|
+
store_new_id(klass, old_id:, new_id:)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Build the attribute Hash for a single record, ready to be handed to
|
|
124
|
+
# insert_all!. Timestamps (as reported by
|
|
125
|
+
# klass.all_timestamp_attributes_in_model, which covers created_at /
|
|
126
|
+
# updated_at / created_on / updated_on), auto-generated primary key
|
|
127
|
+
# columns, and every belongs_to foreign key column are stripped; each
|
|
128
|
+
# belongs_to foreign key is then re-populated from the id map so it
|
|
129
|
+
# points to the new parent. Non-auto columns of a composite primary key
|
|
130
|
+
# (e.g. tenant_id) are carried over from the source record unchanged.
|
|
131
|
+
#: (Class, ActiveRecord::Base) -> Hash[String, untyped]
|
|
132
|
+
def attributes_for(klass, old_record)
|
|
133
|
+
belongs_to = klass.reflect_on_all_associations(:belongs_to)
|
|
134
|
+
fk_columns = belongs_to.flat_map { |r| Array.wrap(r.foreign_key) }
|
|
135
|
+
ignored = auto_generated_pk_columns(klass) + klass.all_timestamp_attributes_in_model + fk_columns
|
|
136
|
+
attrs = old_record.attributes.except(*ignored)
|
|
137
|
+
|
|
138
|
+
belongs_to.each { |reflection| assign_foreign_key(attrs, reflection, old_record) }
|
|
139
|
+
|
|
140
|
+
attrs
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Run the duplication inside a new, non-joinable transaction and return
|
|
144
|
+
# the freshly loaded root record. Normally invoked by Duplicator#duplicate.
|
|
145
|
+
#: (ActiveRecord::Base, ?associations: untyped) -> ActiveRecord::Base
|
|
146
|
+
def run(root_record, associations: [])
|
|
147
|
+
root_record.transaction(requires_new: true, joinable: false) do
|
|
148
|
+
duplicate_records([root_record])
|
|
149
|
+
|
|
150
|
+
AssociationTraversal.new(root_record, associations).each do |relation|
|
|
151
|
+
duplicate_records(relation)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
new_id = fetch_new_id(root_record.class, old_id: current_id(root_record))
|
|
155
|
+
root_record.class.find(new_id)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
#: (untyped) -> void
|
|
162
|
+
def duplicate_records(relation_or_records)
|
|
163
|
+
# Group by the real class of each record so a handler registered on an
|
|
164
|
+
# STI subclass fires only for records of that subclass. For non-STI
|
|
165
|
+
# models every record shares the given `klass`, so a single group is
|
|
166
|
+
# produced.
|
|
167
|
+
relation_or_records.group_by(&:class).each do |real_class, records|
|
|
168
|
+
handler = cache_or_resolve_handler(real_class)
|
|
169
|
+
|
|
170
|
+
if handler
|
|
171
|
+
handler.call(HandlerApi.new(self), real_class, records)
|
|
172
|
+
else
|
|
173
|
+
bulk_insert(real_class, records)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
#: (Class) -> Proc?
|
|
179
|
+
def cache_or_resolve_handler(klass)
|
|
180
|
+
@handlers_cache[klass] = resolve_handler(klass) unless @handlers_cache.key?(klass)
|
|
181
|
+
|
|
182
|
+
@handlers_cache[klass]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Look up a handler for `klass` by walking its ancestor chain. Session
|
|
186
|
+
# -local handlers (Session#on) take precedence over any Duplicator-level
|
|
187
|
+
# handler, even one registered closer in the ancestor chain: the two
|
|
188
|
+
# passes below are deliberate, not a refactor opportunity.
|
|
189
|
+
#: (Class) -> Proc?
|
|
190
|
+
def resolve_handler(klass)
|
|
191
|
+
# rubocop:disable Style/CombinableLoops
|
|
192
|
+
klass.ancestors.each do |ancestor|
|
|
193
|
+
return @user_handlers[ancestor] if @user_handlers.key?(ancestor)
|
|
194
|
+
end
|
|
195
|
+
klass.ancestors.each do |ancestor|
|
|
196
|
+
return @handlers[ancestor] if @handlers.key?(ancestor)
|
|
197
|
+
end
|
|
198
|
+
# rubocop:enable Style/CombinableLoops
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
#: (untyped, batch_size: Integer, cursor: untyped) -> untyped
|
|
203
|
+
def enumerate_batches(records, batch_size:, cursor:)
|
|
204
|
+
if records.respond_to?(:in_batches) && cursor
|
|
205
|
+
records.in_batches(of: batch_size, cursor:)
|
|
206
|
+
elsif records.respond_to?(:find_in_batches)
|
|
207
|
+
records.find_in_batches(batch_size:)
|
|
208
|
+
else
|
|
209
|
+
records.each_slice(batch_size)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Return the id of the given record shaped like the class's primary_key:
|
|
214
|
+
# a scalar for a single-column pk, an Array for a composite pk.
|
|
215
|
+
#: (ActiveRecord::Base) -> untyped
|
|
216
|
+
def current_id(record)
|
|
217
|
+
pk = record.class.primary_key
|
|
218
|
+
if pk.is_a?(Array)
|
|
219
|
+
pk.map { |c| record[c] }
|
|
220
|
+
else
|
|
221
|
+
record[pk]
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Extract the primary key value from a row returned by insert_all!.
|
|
226
|
+
# Scalar for single-column pk, Array for composite pk.
|
|
227
|
+
#: (Class, Hash[String, untyped]) -> untyped
|
|
228
|
+
def extract_id_from_row(klass, row)
|
|
229
|
+
pk = klass.primary_key
|
|
230
|
+
if pk.is_a?(Array)
|
|
231
|
+
pk.map { |c| row[c.to_s] }
|
|
232
|
+
else
|
|
233
|
+
row[pk.to_s]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
#: (Hash[String, untyped], untyped, ActiveRecord::Base) -> void
|
|
238
|
+
def assign_foreign_key(attrs, reflection, old_record)
|
|
239
|
+
fk = reflection.foreign_key
|
|
240
|
+
|
|
241
|
+
if fk.is_a?(Array)
|
|
242
|
+
old_values = fk.map { |c| old_record[c] }
|
|
243
|
+
if old_values.all?(&:nil?)
|
|
244
|
+
fk.each { |c| attrs[c] = nil }
|
|
245
|
+
else
|
|
246
|
+
new_values = fetch_new_id(reflection.klass, old_id: old_values)
|
|
247
|
+
fk.zip(new_values).each { |c, v| attrs[c] = v }
|
|
248
|
+
end
|
|
249
|
+
else
|
|
250
|
+
old_value = old_record[fk]
|
|
251
|
+
attrs[fk] = old_value.nil? ? nil : fetch_new_id(reflection.klass, old_id: old_value)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Primary key columns whose values are filled in by the database (e.g. a
|
|
256
|
+
# bigserial `id`). These must be stripped from the attributes before
|
|
257
|
+
# calling insert_all! so the DB can assign fresh values. Composite pk
|
|
258
|
+
# columns without a default_function (e.g. tenant_id) are kept.
|
|
259
|
+
#: (Class) -> Array[String]
|
|
260
|
+
def auto_generated_pk_columns(klass)
|
|
261
|
+
Array.wrap(klass.primary_key).select do |key|
|
|
262
|
+
column = klass.column_for_attribute(key)
|
|
263
|
+
column.default_function.present?
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
# Reusable duplication configuration and entrypoint.
|
|
7
|
+
#
|
|
8
|
+
# A Duplicator holds only per-model handler registrations; it does not hold
|
|
9
|
+
# any per-run state (the id map lives on Session). Freeze after registering
|
|
10
|
+
# handlers to enforce immutability across many duplication runs.
|
|
11
|
+
#
|
|
12
|
+
# Example:
|
|
13
|
+
# duplicator = ActiveRecord::Duplicator.new
|
|
14
|
+
# duplicator.on(Attachment) { |api, klass, records| ... }
|
|
15
|
+
# duplicator.freeze
|
|
16
|
+
#
|
|
17
|
+
# new_root = duplicator.duplicate(old_root, associations: [...]) do |session|
|
|
18
|
+
# session.mark_skip(tenant)
|
|
19
|
+
# end
|
|
20
|
+
class Duplicator
|
|
21
|
+
# @rbs @handlers: Hash[Class, Proc]
|
|
22
|
+
|
|
23
|
+
#: () -> void
|
|
24
|
+
def initialize
|
|
25
|
+
@handlers = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Register a custom handler for the given model class. See HandlerApi for
|
|
29
|
+
# the API the block receives.
|
|
30
|
+
#
|
|
31
|
+
# Raises DuplicateHandlerError when the same class is registered twice.
|
|
32
|
+
#: (Class) { (HandlerApi, Class, untyped) -> void } -> void
|
|
33
|
+
def on(klass, &block)
|
|
34
|
+
raise DuplicateHandlerError, "handler for #{klass} is already registered" if @handlers.key?(klass)
|
|
35
|
+
|
|
36
|
+
@handlers[klass] = block
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Prevent further registrations. Also freezes the internal handler map so
|
|
40
|
+
# accidental mutation later fails loudly.
|
|
41
|
+
#: () -> self
|
|
42
|
+
def freeze
|
|
43
|
+
@handlers.freeze
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Run one duplication. A fresh Session (with a fresh record id map) is
|
|
48
|
+
# created for every invocation, so state does not leak between runs.
|
|
49
|
+
#
|
|
50
|
+
# If a block is given it is called with the new Session before duplication
|
|
51
|
+
# starts, letting the caller mark_skip or store_new_id for records that
|
|
52
|
+
# 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)
|
|
56
|
+
block&.call(session)
|
|
57
|
+
session.run(root_record, associations:)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
require_relative "duplicator/version"
|
|
63
|
+
require_relative "duplicator/errors"
|
|
64
|
+
require_relative "duplicator/association_traversal"
|
|
65
|
+
require_relative "duplicator/handler_api"
|
|
66
|
+
require_relative "duplicator/session"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Generated from lib/active_record/duplicator/association_traversal.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
# Breadth-first traversal of associations declared with preload-style syntax.
|
|
6
|
+
# Yields ActiveRecord::Relation objects in the order they would be needed to
|
|
7
|
+
# duplicate records level by level.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
# traversal = ActiveRecord::Duplicator::AssociationTraversal.new(
|
|
11
|
+
# owner,
|
|
12
|
+
# [items: :taggings],
|
|
13
|
+
# )
|
|
14
|
+
# traversal.each do |relation|
|
|
15
|
+
# # 1st: Relation of items belonging to owner
|
|
16
|
+
# # 2nd: Relation of taggings belonging to those items
|
|
17
|
+
# end
|
|
18
|
+
class AssociationTraversal
|
|
19
|
+
include Enumerable
|
|
20
|
+
|
|
21
|
+
@record: untyped
|
|
22
|
+
|
|
23
|
+
@associations: untyped
|
|
24
|
+
|
|
25
|
+
# : (untyped record, untyped associations) -> void
|
|
26
|
+
def initialize: (untyped record, untyped associations) -> void
|
|
27
|
+
|
|
28
|
+
# : () { (ActiveRecord::Relation) -> void } -> self
|
|
29
|
+
# : () -> Enumerator[ActiveRecord::Relation, self]
|
|
30
|
+
def each: () { (ActiveRecord::Relation) -> void } -> self
|
|
31
|
+
| () -> Enumerator[ActiveRecord::Relation, self]
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# : (Class, untyped, untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
36
|
+
def traverse: (Class, untyped, untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
37
|
+
|
|
38
|
+
# : (Class, Symbol | String, untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
39
|
+
def traverse_leaf: (Class, Symbol | String, untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
40
|
+
|
|
41
|
+
# : (Class, Hash[Symbol | String, untyped], untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
42
|
+
def traverse_hash: (Class, Hash[Symbol | String, untyped], untyped) { (ActiveRecord::Relation) -> void } -> void
|
|
43
|
+
|
|
44
|
+
# Resolves the intermediate through_reflection first, then replaces it with the
|
|
45
|
+
# source_reflection. For has_many/has_one it looks up children by matching the
|
|
46
|
+
# parent's primary key against the child's foreign key. For belongs_to it looks
|
|
47
|
+
# up the parent by matching the child's foreign key against the parent's primary key.
|
|
48
|
+
#
|
|
49
|
+
# For composite primary keys the foreign_key / active_record_primary_key of a
|
|
50
|
+
# reflection is an Array of column names; the corresponding WHERE clause uses
|
|
51
|
+
# tuple syntax like `where([:a, :b] => [[v1, v2], [v3, v4]])`.
|
|
52
|
+
# : (Class, untyped, untyped) -> ActiveRecord::Relation
|
|
53
|
+
def build_relation: (Class, untyped, untyped) -> ActiveRecord::Relation
|
|
54
|
+
|
|
55
|
+
# : (Class, Symbol | String) -> untyped
|
|
56
|
+
def find_reflection: (Class, Symbol | String) -> untyped
|
|
57
|
+
|
|
58
|
+
# Rails accepts either a scalar column name or an Array of column names
|
|
59
|
+
# as the LHS of a WHERE clause. For single-column pk we hand it a scalar
|
|
60
|
+
# so the generated SQL stays as `WHERE col IN (...)`.
|
|
61
|
+
# : (Array[Symbol | String]) -> (Symbol | String | Array[Symbol | String])
|
|
62
|
+
def where_key: (Array[Symbol | String]) -> (Symbol | String | Array[Symbol | String])
|
|
63
|
+
|
|
64
|
+
# Avoid instantiating ActiveRecord objects just to read ids:
|
|
65
|
+
# use pluck for Relations and map for plain arrays. For composite pk
|
|
66
|
+
# returns an Array of Arrays (`[[v1, v2], ...]`); for single-column pk
|
|
67
|
+
# returns a flat Array (`[v1, v2, ...]`).
|
|
68
|
+
# : (untyped, Array[Symbol | String]) -> Array[untyped]
|
|
69
|
+
def collect_ids: (untyped, Array[Symbol | String]) -> Array[untyped]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated from lib/active_record/duplicator/errors.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
# Raised when store_new_id (or mark_skip) tries to overwrite an existing
|
|
9
|
+
# mapping with a different new_id. This typically indicates a bug in the
|
|
10
|
+
# calling code or a handler.
|
|
11
|
+
class InvalidRecordIdError < Error
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Raised when fetch_new_id cannot find a mapping for the given old_id.
|
|
15
|
+
# This usually means a belongs_to parent has not been duplicated yet;
|
|
16
|
+
# place the parent earlier in the associations tree, or register/skip it
|
|
17
|
+
# explicitly.
|
|
18
|
+
class MissingNewIdError < Error
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when Session#on is called twice for the same model class.
|
|
22
|
+
class DuplicateHandlerError < Error
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Generated from lib/active_record/duplicator/handler_api.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
# The public API surface handed to blocks registered via Session#on.
|
|
6
|
+
# Wraps the current Session and exposes only the subset of methods a
|
|
7
|
+
# handler should call. Session-level methods such as on and duplicate are
|
|
8
|
+
# intentionally not surfaced here.
|
|
9
|
+
class HandlerApi
|
|
10
|
+
@session: Session
|
|
11
|
+
|
|
12
|
+
# : (Session) -> void
|
|
13
|
+
def initialize: (Session) -> void
|
|
14
|
+
|
|
15
|
+
# : (*ActiveRecord::Base) -> void
|
|
16
|
+
def mark_skip: (*ActiveRecord::Base) -> void
|
|
17
|
+
|
|
18
|
+
# : (Class, old_id: untyped, new_id: untyped) -> void
|
|
19
|
+
def store_new_id: (Class, old_id: untyped, new_id: untyped) -> void
|
|
20
|
+
|
|
21
|
+
# : (Class, old_id: untyped) -> untyped
|
|
22
|
+
def fetch_new_id: (Class, old_id: untyped) -> untyped
|
|
23
|
+
|
|
24
|
+
# : (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
25
|
+
def bulk_insert: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
26
|
+
|
|
27
|
+
# : (Class, ActiveRecord::Base) -> Hash[String, untyped]
|
|
28
|
+
def attributes_for: (Class, ActiveRecord::Base) -> Hash[String, untyped]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Generated from lib/active_record/duplicator/session.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
class Duplicator
|
|
5
|
+
# One-shot execution context for a duplication run.
|
|
6
|
+
#
|
|
7
|
+
# A Session owns the record id map (old_id -> new_id, per class) and
|
|
8
|
+
# exposes the API a handler needs: mark_skip / store_new_id / fetch_new_id
|
|
9
|
+
# / bulk_insert / attributes_for.
|
|
10
|
+
#
|
|
11
|
+
# Users normally do not instantiate Session directly; a fresh Session is
|
|
12
|
+
# created by Duplicator#duplicate for every run, so state does not leak
|
|
13
|
+
# across runs. Duplicator#duplicate yields the Session to its block so
|
|
14
|
+
# callers can mark_skip / store_new_id before the run starts.
|
|
15
|
+
#
|
|
16
|
+
# Composite primary keys (Rails 7.1+ `self.primary_key = [:a, :b]`) are
|
|
17
|
+
# supported. An id is a scalar for a single-column primary key and an
|
|
18
|
+
# Array for a composite one; the shape simply mirrors what
|
|
19
|
+
# `record.class.primary_key` reports.
|
|
20
|
+
class Session
|
|
21
|
+
DEFAULT_BATCH_SIZE: ::Integer
|
|
22
|
+
|
|
23
|
+
@user_handlers: Hash[Class, Proc]
|
|
24
|
+
|
|
25
|
+
@handlers: Hash[Class, Proc]
|
|
26
|
+
|
|
27
|
+
@record_id_map: Hash[Class, Hash[untyped, untyped]]
|
|
28
|
+
|
|
29
|
+
# : (?handlers: Hash[Class, Proc]) -> void
|
|
30
|
+
def initialize: (?handlers: Hash[Class, Proc]) -> void
|
|
31
|
+
|
|
32
|
+
# Register a handler that overrides Duplicator#on for the current session
|
|
33
|
+
# only. Useful when a single duplication run needs a special copy of a
|
|
34
|
+
# model that the shared Duplicator config should not know about.
|
|
35
|
+
#
|
|
36
|
+
# Raises DuplicateHandlerError when the same class is registered twice
|
|
37
|
+
# via Session#on in the same session. Overriding a class that also has a
|
|
38
|
+
# Duplicator-level handler is intentional and does not raise.
|
|
39
|
+
# : (Class) { (HandlerApi, Class, untyped) -> void } -> void
|
|
40
|
+
def on: (Class) { (HandlerApi, Class, untyped) -> void } -> void
|
|
41
|
+
|
|
42
|
+
# Declare that the given records must not be duplicated: their new_id
|
|
43
|
+
# is set equal to their old_id, so any record duplicated later that
|
|
44
|
+
# references them will keep pointing at the same rows.
|
|
45
|
+
# : (*ActiveRecord::Base) -> void
|
|
46
|
+
def mark_skip: (*ActiveRecord::Base) -> void
|
|
47
|
+
|
|
48
|
+
# Record a mapping from an old_id to a new_id. Called automatically from
|
|
49
|
+
# bulk_insert; called explicitly by user code (or a handler) when new
|
|
50
|
+
# records are produced outside of the default duplication path.
|
|
51
|
+
#
|
|
52
|
+
# For a single-column primary key pass a scalar; for a composite primary
|
|
53
|
+
# key pass an Array matching the shape of the class's primary_key.
|
|
54
|
+
#
|
|
55
|
+
# Raises InvalidRecordIdError when the same old_id is already mapped to
|
|
56
|
+
# a different new_id.
|
|
57
|
+
# : (Class, old_id: untyped, new_id: untyped) -> void
|
|
58
|
+
def store_new_id: (Class, old_id: untyped, new_id: untyped) -> void
|
|
59
|
+
|
|
60
|
+
# Look up a new_id by (klass, old_id). Returns a scalar for a
|
|
61
|
+
# single-column pk and an Array for a composite pk.
|
|
62
|
+
#
|
|
63
|
+
# Raises MissingNewIdError when no mapping exists. This usually means a
|
|
64
|
+
# belongs_to parent has not been duplicated yet; place it earlier in the
|
|
65
|
+
# associations tree or register/skip it explicitly.
|
|
66
|
+
# : (Class, old_id: untyped) -> untyped
|
|
67
|
+
def fetch_new_id: (Class, old_id: untyped) -> untyped
|
|
68
|
+
|
|
69
|
+
# Bulk-insert copies of the given records into klass, skipping any that
|
|
70
|
+
# are already present in the id map. Handlers can reuse this via HandlerApi
|
|
71
|
+
# to defer a subset of records to the default duplication path.
|
|
72
|
+
# : (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
73
|
+
def bulk_insert: (Class, untyped, ?batch_size: Integer, ?cursor: untyped) -> void
|
|
74
|
+
|
|
75
|
+
# Build the attribute Hash for a single record, ready to be handed to
|
|
76
|
+
# insert_all!. Timestamps (as reported by
|
|
77
|
+
# klass.all_timestamp_attributes_in_model, which covers created_at /
|
|
78
|
+
# updated_at / created_on / updated_on), auto-generated primary key
|
|
79
|
+
# columns, and every belongs_to foreign key column are stripped; each
|
|
80
|
+
# belongs_to foreign key is then re-populated from the id map so it
|
|
81
|
+
# points to the new parent. Non-auto columns of a composite primary key
|
|
82
|
+
# (e.g. tenant_id) are carried over from the source record unchanged.
|
|
83
|
+
# : (Class, ActiveRecord::Base) -> Hash[String, untyped]
|
|
84
|
+
def attributes_for: (Class, ActiveRecord::Base) -> Hash[String, untyped]
|
|
85
|
+
|
|
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
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# : (untyped) -> void
|
|
94
|
+
def duplicate_records: (untyped) -> void
|
|
95
|
+
|
|
96
|
+
# : (Class) -> Proc?
|
|
97
|
+
def cache_or_resolve_handler: (Class) -> Proc?
|
|
98
|
+
|
|
99
|
+
# Look up a handler for `klass` by walking its ancestor chain. Session
|
|
100
|
+
# -local handlers (Session#on) take precedence over any Duplicator-level
|
|
101
|
+
# handler, even one registered closer in the ancestor chain: the two
|
|
102
|
+
# passes below are deliberate, not a refactor opportunity.
|
|
103
|
+
# : (Class) -> Proc?
|
|
104
|
+
def resolve_handler: (Class) -> Proc?
|
|
105
|
+
|
|
106
|
+
# : (untyped, batch_size: Integer, cursor: untyped) -> untyped
|
|
107
|
+
def enumerate_batches: (untyped, batch_size: Integer, cursor: untyped) -> untyped
|
|
108
|
+
|
|
109
|
+
# Return the id of the given record shaped like the class's primary_key:
|
|
110
|
+
# a scalar for a single-column pk, an Array for a composite pk.
|
|
111
|
+
# : (ActiveRecord::Base) -> untyped
|
|
112
|
+
def current_id: (ActiveRecord::Base) -> untyped
|
|
113
|
+
|
|
114
|
+
# Extract the primary key value from a row returned by insert_all!.
|
|
115
|
+
# Scalar for single-column pk, Array for composite pk.
|
|
116
|
+
# : (Class, Hash[String, untyped]) -> untyped
|
|
117
|
+
def extract_id_from_row: (Class, Hash[String, untyped]) -> untyped
|
|
118
|
+
|
|
119
|
+
# : (Hash[String, untyped], untyped, ActiveRecord::Base) -> void
|
|
120
|
+
def assign_foreign_key: (Hash[String, untyped], untyped, ActiveRecord::Base) -> void
|
|
121
|
+
|
|
122
|
+
# Primary key columns whose values are filled in by the database (e.g. a
|
|
123
|
+
# bigserial `id`). These must be stripped from the attributes before
|
|
124
|
+
# calling insert_all! so the DB can assign fresh values. Composite pk
|
|
125
|
+
# columns without a default_function (e.g. tenant_id) are kept.
|
|
126
|
+
# : (Class) -> Array[String]
|
|
127
|
+
def auto_generated_pk_columns: (Class) -> Array[String]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Generated from lib/active_record/duplicator.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
# Reusable duplication configuration and entrypoint.
|
|
5
|
+
#
|
|
6
|
+
# A Duplicator holds only per-model handler registrations; it does not hold
|
|
7
|
+
# any per-run state (the id map lives on Session). Freeze after registering
|
|
8
|
+
# handlers to enforce immutability across many duplication runs.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# duplicator = ActiveRecord::Duplicator.new
|
|
12
|
+
# duplicator.on(Attachment) { |api, klass, records| ... }
|
|
13
|
+
# duplicator.freeze
|
|
14
|
+
#
|
|
15
|
+
# new_root = duplicator.duplicate(old_root, associations: [...]) do |session|
|
|
16
|
+
# session.mark_skip(tenant)
|
|
17
|
+
# end
|
|
18
|
+
class Duplicator
|
|
19
|
+
@handlers: Hash[Class, Proc]
|
|
20
|
+
|
|
21
|
+
# : () -> void
|
|
22
|
+
def initialize: () -> void
|
|
23
|
+
|
|
24
|
+
# Register a custom handler for the given model class. See HandlerApi for
|
|
25
|
+
# the API the block receives.
|
|
26
|
+
#
|
|
27
|
+
# Raises DuplicateHandlerError when the same class is registered twice.
|
|
28
|
+
# : (Class) { (HandlerApi, Class, untyped) -> void } -> void
|
|
29
|
+
def on: (Class) { (HandlerApi, Class, untyped) -> void } -> void
|
|
30
|
+
|
|
31
|
+
# Prevent further registrations. Also freezes the internal handler map so
|
|
32
|
+
# accidental mutation later fails loudly.
|
|
33
|
+
# : () -> self
|
|
34
|
+
def freeze: () -> self
|
|
35
|
+
|
|
36
|
+
# Run one duplication. A fresh Session (with a fresh record id map) is
|
|
37
|
+
# created for every invocation, so state does not leak between runs.
|
|
38
|
+
#
|
|
39
|
+
# If a block is given it is called with the new Session before duplication
|
|
40
|
+
# starts, letting the caller mark_skip or store_new_id for records that
|
|
41
|
+
# were duplicated elsewhere.
|
|
42
|
+
# : (ActiveRecord::Base, ?associations: untyped) ?{ (Session) -> void } -> ActiveRecord::Base
|
|
43
|
+
def duplicate: (ActiveRecord::Base, ?associations: untyped) ?{ (Session) -> void } -> ActiveRecord::Base
|
|
44
|
+
end
|
|
45
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: activerecord-duplicator
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- SmartHR
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
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
|
+
description: |
|
|
41
|
+
activerecord-duplicator copies an ActiveRecord record and its associated rows
|
|
42
|
+
(has_many, has_one, belongs_to, has_many :through) in one call. It rewires
|
|
43
|
+
foreign keys through an internal id map, bypasses callbacks by using
|
|
44
|
+
insert_all!, and lets you plug in per-model handlers for records that need
|
|
45
|
+
custom logic. Composite primary keys (Rails 7.1+) are supported.
|
|
46
|
+
email:
|
|
47
|
+
- oss@smarthr.co.jp
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- LICENSE
|
|
54
|
+
- README.md
|
|
55
|
+
- lib/active_record/duplicator.rb
|
|
56
|
+
- lib/active_record/duplicator/association_traversal.rb
|
|
57
|
+
- lib/active_record/duplicator/errors.rb
|
|
58
|
+
- lib/active_record/duplicator/handler_api.rb
|
|
59
|
+
- lib/active_record/duplicator/session.rb
|
|
60
|
+
- lib/active_record/duplicator/version.rb
|
|
61
|
+
- lib/activerecord-duplicator.rb
|
|
62
|
+
- sig/generated/active_record/duplicator.rbs
|
|
63
|
+
- sig/generated/active_record/duplicator/association_traversal.rbs
|
|
64
|
+
- sig/generated/active_record/duplicator/errors.rbs
|
|
65
|
+
- sig/generated/active_record/duplicator/handler_api.rbs
|
|
66
|
+
- sig/generated/active_record/duplicator/session.rbs
|
|
67
|
+
- sig/generated/active_record/duplicator/version.rbs
|
|
68
|
+
- sig/generated/activerecord-duplicator.rbs
|
|
69
|
+
homepage: https://github.com/kufu/activerecord-duplicator
|
|
70
|
+
licenses:
|
|
71
|
+
- Apache-2.0
|
|
72
|
+
metadata:
|
|
73
|
+
allowed_push_host: https://rubygems.org
|
|
74
|
+
homepage_uri: https://github.com/kufu/activerecord-duplicator
|
|
75
|
+
source_code_uri: https://github.com/kufu/activerecord-duplicator
|
|
76
|
+
changelog_uri: https://github.com/kufu/activerecord-duplicator/blob/main/CHANGELOG.md
|
|
77
|
+
rubygems_mfa_required: 'true'
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: 3.3.0
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 4.0.15
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: Duplicate ActiveRecord records together with their associations, backed by
|
|
95
|
+
bulk inserts.
|
|
96
|
+
test_files: []
|