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 pg gem). The gem relies on insert_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 same old_id was registered with a different new_id.
  • MissingNewIdErrorfetch_new_id had no mapping. Usually means a belongs_to parent was not duplicated yet; place it earlier in the associations tree or mark it skipped.
  • DuplicateHandlerErroron(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.message}")
  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.