activerecord-duplicator
Duplicate an ActiveRecord record together with its associated rows (has_many,
has_one, belongs_to, has_many :through) in one call. Foreign keys are rewired
through an internal id map; callbacks are bypassed via insert_all! so
duplication does not fire after_create and friends. Per-model handlers let
you plug in custom logic when a plain copy is not enough. Composite primary
keys (Rails 7.1+) are supported.
Installation
bundle add activerecord-duplicator
Or without Bundler:
gem install activerecord-duplicator
Requirements
- Ruby >= 3.3
- ActiveRecord >= 8.0 (tested against 8.0, 8.1)
- PostgreSQL (via
pggem). The gem relies oninsert_all!returning inserted primary keys.
Non-goals
STI dispatch
This gem dispatches based on the class of the input Relation (or the first element for Arrays). If your Relation mixes STI subclasses and you need per-subclass handlers, split the input into per-subclass Relations before passing them in. Ancestor-walking dispatch (register a handler on a base class, have it fire for subclass records mixed in one Relation) is intentionally not supported; see CHANGELOG for details.
Usage
Basic duplication
duplicator = ActiveRecord::Duplicator.new
new_post = duplicator.duplicate(
old_post,
associations: [:comments, tags: :taggings],
)
duplicate creates a new Post (and all rows reachable via the given
associations), rewires every foreign key to point at the freshly created
parents, and returns the new root record.
Keeping records referenced but not copied (mark_skip)
When you want the duplicated rows to keep pointing at an existing record
(for example the current tenant, current user, or a shared lookup table),
mark that record as skipped before calling duplicate:
duplicator = ActiveRecord::Duplicator.new
duplicator.mark_skip(tenant)
duplicator.mark_skip(current_user)
duplicator.duplicate(old_project, associations: [tasks: :attachments])
Skipped records get new_id == old_id in the internal map, so children that
reference them are inserted with the original id unchanged.
Skipping a whole model (mark_skip_class)
When every row of a model should be reused by id (a shared lookup table,
a tenant-wide catalog, an enum-backed table with dozens of entries), skip
the class itself instead of enumerating rows. mark_skip_class exists at
both scopes and mirrors on: register once on the Duplicator to inherit
across every run, or add it in the Session block for a single run.
duplicator = ActiveRecord::Duplicator.new
duplicator.mark_skip_class(Tag) # applies to every subsequent run
duplicator.duplicate(old_post, associations: [:taggings])
duplicator.duplicate(other_post, associations: [:taggings]) do |session|
session.mark_skip_class(Currency) # extra skip, this run only
end
Behaviour is exactly as if you had passed every row to mark_skip up front:
handlers registered for the class still fire (so custom copy logic keeps
working), but the default bulk_insert path becomes a no-op and any
fetch_new_id(klass, old_id: ...) from a child's foreign-key reassignment
echoes the old id back unchanged. mark_skip_class only differs from
per-record mark_skip in cost: no enumeration is needed to opt each row in.
Skipping by id without loading records (mark_skip_id)
When the ids to skip are already known (a foreign key column, a prior
pluck, a config constant), use mark_skip_id to register them directly
without issuing a SELECT:
duplicator.duplicate(old_post, associations: [:taggings]) do |session|
session.mark_skip_id(Tag, 1, 2, 3)
end
For composite primary keys pass each id as an Array:
session.mark_skip_id(Item, [tenant_id, 10], [tenant_id, 11])
mark_skip_id has the same effect as calling mark_skip on the
corresponding records: fetch_new_id returns the old id unchanged for
each registered id.
Registering an id from outside (store_new_id / fetch_new_id)
If you produce a duplicate yourself (for example a model with a uniqueness constraint that has to be reshaped before insert), tell the session about the mapping so downstream children can find the new parent id:
new_project = old_project.dup
new_project.slug = "#{old_project.slug}-copy"
new_project.save!
duplicator = ActiveRecord::Duplicator.new
duplicator.store_new_id(Project, old_id: old_project.id, new_id: new_project.id)
duplicator.duplicate(old_project, associations: [:tasks])
fetch_new_id(klass, old_id:) returns the mapping (scalar for a single-column
primary key, Array for a composite one) and raises MissingNewIdError if the
parent has not been duplicated yet.
Per-model handlers (on)
Some models need custom logic that the default insert_all! path cannot
express: ActiveStorage attachments, unique constraint rewrites, values that
depend on the target tenant, and so on. Register a handler and it will be
called instead of the default duplication path for that class:
duplicator.on(Attachment) do |api, klass, records|
records.find_each do |record|
new_record = klass.create!(
post_id: api.fetch_new_id(Post, old_id: record.post_id),
)
new_record.file.attach(record.file.blob)
api.store_new_id(klass, old_id: record.id, new_id: new_record.id)
end
end
api is a HandlerApi facade over the session that exposes
mark_skip / store_new_id / fetch_new_id / bulk_insert /
attributes_for. Registering two handlers for the same class raises
DuplicateHandlerError.
Composite primary keys
self.primary_key = [:tenant_id, :id] classes are handled the same way.
Non-auto columns of the composite pk (e.g. tenant_id) are carried over from
the source record; only DB-populated columns (e.g. a bigserial id) get fresh
values. foreign_key: [:tenant_id, :owner_id] associations are traversed with
tuple WHERE (a, b) IN ((v1, v2), ...) syntax.
Errors
All errors inherit from ActiveRecord::Duplicator::Error:
InvalidRecordIdError— the sameold_idwas registered with a differentnew_id.MissingNewIdError—fetch_new_idhad no mapping. Usually means a belongs_to parent was not duplicated yet; place it earlier in the associations tree or mark it skipped.DuplicateHandlerError—on(klass)was called twice for the same class.
Because they share a common ancestor, one rescue clause catches any
duplication-specific failure:
begin
duplicator.duplicate(old_post, associations: [:comments])
rescue ActiveRecord::Duplicator::Error => e
Rails.logger.error("duplication failed: #{e.class.name} #{e.}")
raise
end
Performance notes
AssociationTraversal currently pluck-collects parent ids and emits
WHERE fk IN (id1, ..., idN). Very large N (tens of thousands) may hit
PostgreSQL planner degradation; if you run into this, split your work into
smaller root records or file an issue.
Development
The gem tests against a real PostgreSQL instance. compose.yml at the repo
root starts one for you on port 55432:
docker compose up -d
bundle install
bundle exec rake # rspec + rubocop
bundle exec rspec # spec only
Override the connection with DATABASE_URL (used by CI):
DATABASE_URL=postgres://user:pass@host:port/db bundle exec rspec
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/kufu/activerecord-duplicator. This project is intended to be a safe, welcoming space for collaboration; contributors are expected to adhere to the code of conduct.
License
Released under the Apache License 2.0.