I was recently working on an app that required duplicating an Active Record model and its associations. But the complex structure of these associations meant the existing gems I tried to use were not up to the task. Here’s how I ended up solving it.
This is the model I needed to duplicate (a simplified version of it). I started off by installing the popular deep_cloneable gem and assumed it would be up to the task. However, I quickly ran into an issue. The circular and nested associations were not cloned properly. This was my first attempt at duplicating the associations:
project.deep_clone include: [ { card_types: :cards }, { sections: :cards } ]
However, because a Card
belongs to both a Card::Type
and a Section
, two new cards were created in a tree-like structure. But actually I only wanted to create one new card and maintain the circular reference. I was also not updating the parent_id
of the cards, meaning the nested cards still belonged to parents of the old project, not the new one.
Looking for expert guidance or developers to work on your project? We love working on existing codebases – let’s chat.
After a few more attempts to get something working correctly, I decided to go for a custom solution:
1. Use a gem to get most of the way there
Rather than bother with a fully custom solution, I decided to use a gem to get most of the way there. First I changed the associations I was duplicating to:
[ :card_types, { sections: :cards } ]
Now the correct number of models are duplicated, and all that is missing is the parent_id
and card_type_id
foreign keys need fixing.
2. Fix the foreign keys
To achieve this I switched from deep_cloneable to amoeba. Rather than declaring all the association upfront, amoeba works differently whereby the associations are declared in the models themselves. It also has a feature that would come in hand; cloning with a custom method. That enables me to save the ID of the old Card::Type
in a virtual attribute of the new Card::Type
, like this:
class Card class Type < ApplicationRecord belongs_to :project has_many :cards attr_accessor :legacy_id amoeba do through :custom_dup exclude_association :cards end def custom_dup dup = self.dup dup.legacy_id = self.id return dup end end end
And similarly, I can save the foreign keys of the old Card
on the new one.
class Card < ApplicationRecord belongs_to :section belongs_to :card_type belongs_to :parent has_many :nested_cards attr_accessor :legacy_id, :legacy_card_type_id, :legacy_parent_id, amoeba do through :custom_dup exclude_association :nested_cards end def custom_dup dup = self.dup dup.legacy_id = self.id dup.legacy_parent_id = self.parent_id dup.legacy_card_type_id = self.card_type_id return dup end end
Now that this information is stored in the newly cloned project, I can loop through the newly cloned cards and fix any broken associations. For example, to fix the parent reference on a card, I just have to find the card whose legacy_id
matches the legacy_parent_id
of the card in question.
clone = project.amoeba_dup clone.sections.each do |section| section.cards.each do |card| card.parent = section.cards.find { |c| c.legacy_id == card.legacy_parent_id } card.card_type = clone.card_types.find { |t| t.legacy_id == card.legacy_type_id } end end
Now finally, I can save the newly cloned project to the database, and all the relationships will be an exact replica of the old project. No more foreign keys that still reference models from the old project.
Conclusion
By storing the ID and foreign key IDs in a virtual attribute, I have been able to successfully duplicate models with nested and circular associations.
While this solution does exactly what I needed it to, it is by no means perfect. If the relationship of models within a Project
were to evolve, the duplication service would have to be correctly maintained too, a task which is now significantly more complex.
If you have solved this problem in a different way, or think you know a better solution, let me know!