35

I'm .clone -ing a record in rails...

  new_blerg = Blerg.find(1).clone

This record has loads and loads of associations, and those associations even have associations.

Is there a way to deep-copy a record and clone it so it is cloned with all of those associations too?

benzado
  • 82,288
  • 22
  • 110
  • 138
CafeHey
  • 5,699
  • 19
  • 82
  • 145

3 Answers3

32

You may get some good use out of the Amoeba gem for ActiveRecord 3.2.

It supports easy and automatic recursive duplication of has_one, has_many and has_and_belongs_to_many associations, field preprocessing and a highly flexible and powerful configuration DSL that can be applied both to the model and on the fly.

be sure to check out the Amoeba Documentation but usage is pretty easy...

just

gem install amoeba

or add

gem 'amoeba'

to your Gemfile

then add the amoeba block to your model and run the dup method as usual

class Post < ActiveRecord::Base
  has_many :comments
  has_and_belongs_to_many :tags

  amoeba do
    enable
  end
end

class Comment < ActiveRecord::Base
  belongs_to :post
end

class Tag < ActiveRecord::Base
  has_and_belongs_to_many :posts
end

class PostsController < ActionController
  def some_method
    my_post = Post.find(params[:id])
    new_post = my_post.dup
    new_post.save
  end
end

Your new post should have all the tags that were originally associated with it, and all the comments should be duplicated as well. You can disable the duplication of various records through the DSL, which you can read about in the documentation, but for example, if you wanted to keep the tags, but not the comments you could do something like this:

class Post < ActiveRecord::Base
  has_many :comments
  has_and_belongs_to_many :tags

  amoeba do
    include_field :comments
  end
end

or using the exclusive syntax

class Post < ActiveRecord::Base
  has_many :comments
  has_and_belongs_to_many :tags

  amoeba do
    exclude_field :comments
  end
end

or by specifying which field types to recognize (and thusly copy)

class Post < ActiveRecord::Base
  has_many :comments
  has_and_belongs_to_many :tags

  amoeba do
    recognize :has_and_belongs_to_many
  end
end

each of these various options should result in re-associating the new post with the same tags as the old post, but without duplicating the comments.

Amoeba will also automatically recurse in to child records if you enable them

class Post < ActiveRecord::Base
  has_many :comments

  amoeba do
    enable
  end
end

class Comment < ActiveRecord::Base
  belongs_to :post
  has_many :ratings

  amoeba do
    enable
  end
end

class Rating < ActiveRecord::Base
  belongs_to :comment
end

You can also prefix fields with some extra data to indicate uniqueness

class Post < ActiveRecord::Base
  has_many :comments

  amoeba do
    enable
    prepend :title => "Copy of "
  end
end

and in addition to prepend you can also append to or run a regex on a given field

Enjoy! :)

Eduardo
  • 508
  • 6
  • 18
Vaughn Draughon
  • 1,380
  • 14
  • 8
  • 2
    Wow this gem is really amazing. I had to roll my own duplicating system and it wasn't working but your gem worked really well. – Amala Apr 12 '12 at 01:05
  • 3
    Should the .dup in the example be "new_post = my_post.amoeba_dup", as defined in the docs? – kibaekr Jan 31 '14 at 08:04
  • 6
    @kibaekr from the README history I found "As of December 11th, 2012 Amoeba no longer overrides the built in `ActiveRecord::Base#dup` method and instead implements its own method called `amoeba_dup`..." – Sam Mar 31 '16 at 13:44
23

You'd need to write your own clone_with_associations method which goes through a specific listed set of associations. Theoretically you could write something generic which uses reflect_on_all_associations but you would need to do the same on the associated objects, and this would inevitably end up creating a loop that generates an infinite amount of records.

So, just write your own. Something like

  #in Blerg
  has_many :foos
  has_many :bars #bars also have many chickens which we want to copy over as well
  def clone_with_associations
    new_blerg = self.dup
    new_blerg.save
    #simple association
    new_blerg.foos = self.foos
    #two-level association 
    self.bars.each do |bar|
      new_bar = bar.clone
      new_bar.save
      new_bar.chickens = bar.chickens 
      new_blerg.bars << bar
    end
    new_blerg
  end

Now you can do

@new_blerg = Blerg.find(1).clone_with_associations
Panagiotis Panagi
  • 9,927
  • 7
  • 55
  • 103
Max Williams
  • 32,435
  • 31
  • 130
  • 197
  • 25
    You'll get a broken original object as this `new_blerg.foos = self.foos` steals your associations. You'd need to clone them as well. – RocketR Oct 26 '11 at 10:48
  • 1
    This is the best answer. In my humble opinion, rolling your own in this case, is so much cleaner, easier, less magical than using a gem. – hellion May 17 '14 at 02:59
  • RocketR - good point. I think i was assuming the .foos relationship was a "has and belongs to many", in which case it would be fine, but if foo belongs_to blerg then yes, it would alter the associated foos. – Max Williams May 19 '14 at 09:40
  • Thx @MaxWilliams ! I used this approach. – Ardian Nov 19 '16 at 07:01
  • Why do you have to save after each dup? Could you just save at the end? – lucas Jul 24 '19 at 14:51
  • Stuck at the same point that @RocketR mentioned as first comment. What should be the work around if I need to update column in has_many association? `def duplicate_records new_course = self.deep_clone new_course.image = File.open(self.image.file.file) if self.image.present? new_course.save new_course.chapters = self.chapters new_course.chapters.each do |chapter| new_chapter = chapter.clone new_chapter.image = File.open(chapter.image.file.file) if chapter.image.present? new_chapter.save end new_course end` – LearningROR Apr 25 '21 at 11:02
  • @LearningROR I think you need to replace `new_course.chapters.each do |chapter|` with `self.chapters.each do |chapter|`. Remember that `self` is the thing being cloned. – Max Williams Apr 26 '21 at 10:24
  • Thanks & +1 @MaxWilliams for reply. Well, that also created new records but do not assign to created course (parent model) but this below code worked well. `def duplicate_records new_course = self.dup new_course.image = File.open(self.image.file.file) if self.image.present? new_course.save new_course.chapters = self.chapters new_course.chapters.each do |chapter| new_chapter = chapter.dup new_chapter.image = File.open(chapter.image.file.file) if chapter.image.present? new_chapter.save chapters << new_chapter end new_course end` – LearningROR Apr 26 '21 at 18:18
20

Equally, this gem seems to work well: https://github.com/moiristo/deep_cloneable, and is pretty easy to use.

Just

gem ‘deep_cloneable’, ‘~> 1.4.0’

and then:

pirate.deep_clone :include => :mateys

Rob
  • 4,404
  • 2
  • 32
  • 33