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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ class Duplicator
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/duplicator"
@@ -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,7 @@
1
+ # Generated from lib/active_record/duplicator/version.rb with RBS::Inline
2
+
3
+ module ActiveRecord
4
+ class Duplicator
5
+ VERSION: ::String
6
+ end
7
+ 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
@@ -0,0 +1,2 @@
1
+ # Generated from lib/activerecord-duplicator.rb with RBS::Inline
2
+
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: []