Easy cloning of active_record objects including associations and several operations under associations and attributes.
See here.
The goal was to be able to easily and quickly reproduce ActiveRecord objects including their children, for example copying a blog post maintaining its associated tags or categories.
This gem is named "Amoeba" because amoebas are (small life forms that are) good at reproducing. Their children and grandchildren also reproduce themselves quickly and easily.
An ActiveRecord extension gem to allow the duplication of associated child record objects when duplicating an active record model.
Rails 5.2, 6.0, 6.1 compatible. For Rails 4.2 to 5.1 use version 3.x.
Supports the following association types
has_many
has_one :through
has_many :through
has_and_belongs_to_many
A simple DSL for configuration of which fields to copy. The DSL can be applied to your rails models or used on the fly.
Supports STI (Single Table Inheritance) children inheriting their parent amoeba settings.
Multiple configuration styles such as inclusive, exclusive and indiscriminate (aka copy everything).
Supports cloning of the children of Many-to-Many records or merely maintaining original associations
Supports automatic drill-down i.e. recursive copying of child and grandchild records.
Supports preprocessing of fields to help indicate uniqueness and ensure the integrity of your data depending on your business logic needs, e.g. prepending "Copy of " or similar text.
Supports preprocessing of fields with custom lambda blocks so you can do basically whatever you want if, for example, you need some custom logic while making copies.
Amoeba can perform the following preprocessing operations on fields of copied records
set
prepend
append
nullify
customize
regex
is hopefully as you would expect:
gem install amoeba
or just add it to your Gemfile:
gem 'amoeba'
Configure your models with one of the styles below and then just run the amoeba_dup
method on your model where you would run the dup
method normally:
p = Post.create(:title => "Hello World!", :content => "Lorum ipsum dolor")p.comments.create(:content => "I love it!")p.comments.create(:content => "This sucks!")puts Comment.all.count # should be 2my_copy = p.amoeba_dupmy_copy.saveputs Comment.all.count # should be 4
By default, when enabled, amoeba will copy any and all associated child records automatically and associate them with the new parent record.
You can configure the behavior to only include fields that you list or to only include fields that you don't exclude. Of the three, the most performant will be the indiscriminate style, followed by the inclusive style, and the exclusive style will be the slowest because of the need for an extra explicit check on each field. This performance difference is likely negligible enough that you can choose the style to use based on which is easiest to read and write, however, if your data tree is large enough and you need control over what fields get copied, inclusive style is probably a better choice than exclusive style.
Please note that these examples are only loose approximations of real world scenarios and may not be particularly realistic, they are only for the purpose of demonstrating feature usage.
This is the most basic usage case and will simply enable the copying of any known associations.
If you have some models for a blog about like this:
class Post < ActiveRecord::Base has_many :commentsendclass Comment < ActiveRecord::Base belongs_to :postend
simply add the amoeba configuration block to your model and call the enable method to enable the copying of child records, like this:
class Post < ActiveRecord::Base has_many :comments amoeba doenable endendclass Comment < ActiveRecord::Base belongs_to :postend
Child records will be automatically copied when you run the amoeba_dup
method.
If you only want some of the associations copied but not others, you may use the inclusive style:
class Post < ActiveRecord::Base has_many :comments has_many :tags has_many :authors amoeba doenableinclude_association :tagsinclude_association :authors endendclass Comment < ActiveRecord::Base belongs_to :postend
Using the inclusive style within the amoeba block actually implies that you wish to enable amoeba, so there is no need to run the enable method, though it won't hurt either:
class Post < ActiveRecord::Base has_many :comments has_many :tags has_many :authors amoeba doinclude_association :tagsinclude_association :authors endendclass Comment < ActiveRecord::Base belongs_to :postend
You may also specify fields to be copied by passing an array. If you call the include_association
with a single value, it will be appended to the list of already included fields. If you pass an array, your array will overwrite the original values.
class Post < ActiveRecord::Base has_many :comments has_many :tags has_many :authors amoeba doinclude_association [:tags, :authors] endendclass Comment < ActiveRecord::Base belongs_to :postend
These examples will copy the post's tags and authors but not its comments.
The inclusive style, when used, will automatically disable any other style that was previously selected.
If you have more fields to include than to exclude, you may wish to shorten the amount of typing and reading you need to do by using the exclusive style. All fields that are not explicitly excluded will be copied:
class Post < ActiveRecord::Base has_many :comments has_many :tags has_many :authors amoeba doexclude_association :comments endendclass Comment < ActiveRecord::Base belongs_to :postend
This example does the same thing as the inclusive style example, it will copy the post's tags and authors but not its comments. As with inclusive style, there is no need to explicitly enable amoeba when specifying fields to exclude.
The exclusive style, when used, will automatically disable any other style that was previously selected, so if you selected include fields, and then you choose some exclude fields, the exclude_association
method will disable the previously selected inclusive style and wipe out any corresponding include fields.
Also if you need to path extra condition for include or exclude relationship you can path method name to :if
option.
class Post < ActiveRecord::Base has_many :comments has_many :tags amoeba doinclude_association :comments, if: :popular? end def popular?likes > 15 endend
After call Post.first.amoeba_dup
if likes
is larger 15 than all comments will be duplicated too, but in another situation - no relations will be cloned. Same behavior will be for exclude_association
.
Be aware! If you wrote:
class Post < ActiveRecord::Base has_many :comments has_many :tags amoeba doexclude_association :tagsinclude_association :comments, if: :popular? end def popular?likes > 15 endend
inclusion strategy will be chosen regardless of the result of popular?
method call (the same for reverse situation).
If you are using a Many-to-Many relationship, you may tell amoeba to actually make duplicates of the original related records rather than merely maintaining association with the original records. Cloning is easy, merely tell amoeba which fields to clone in the same way you tell it which fields to include or exclude.
class Post < ActiveRecord::Base has_and_belongs_to_many :warnings has_many :post_widgets has_many :widgets, :through => :post_widgets amoeba doenableclone [:widgets, :warnings] endendclass Warning < ActiveRecord::Base has_and_belongs_to_many :postsendclass PostWidget < ActiveRecord::Base belongs_to :widget belongs_to :postendclass Widget < ActiveRecord::Base has_many :post_widgets has_many :posts, :through => :post_widgetsend
This example will actually duplicate the warnings and widgets in the database. If there were originally 3 warnings in the database then, upon duplicating a post, you will end up with 6 warnings in the database. This is in contrast to the default behavior where your new post would merely be re-associated with any previously existing warnings and those warnings themselves would not be duplicated.
By default, amoeba recognizes and attempts to copy any children of the following association types:
has one
has many
has and belongs to many
You may control which association types amoeba applies itself to by using the recognize
method within the amoeba configuration block.
class Post < ActiveRecord::Base has_one :config has_many :comments has_and_belongs_to_many :tags amoeba dorecognize [:has_one, :has_and_belongs_to_many] endendclass Comment < ActiveRecord::Base belongs_to :postendclass Tag < ActiveRecord::Base has_and_belongs_to_many :postsend
This example will copy the post's configuration data and keep tags associated with the new post, but will not copy the post's comments because amoeba will only recognize and copy children of has_one
and has_and_belongs_to_many
associations and in this example, comments are not an has_and_belongs_to_many
association.
If you wish to prevent a regular (non has_*
association based) field from retaining it's value when copied, you may "zero out" or "nullify" the field, like this:
class Topic < ActiveRecord::Base has_many :postsendclass Post < ActiveRecord::Base belongs_to :topic has_many :comments amoeba doenablenullify :date_publishednullify :topic_id endendclass Comment < ActiveRecord::Base belongs_to :postend
This example will copy all of a post's comments. It will also nullify the publishing date and dissociate the post from its original topic.
Unlike inclusive and exclusive styles, specifying null fields will not automatically enable amoeba to copy all child records. As with any active record object, the default field value will be used instead of nil
if a default value exists on the migration.
If you wish to just set a field to an arbitrary value on all duplicated objects you may use the set
directive. For example, if you wanted to copy an object that has some kind of approval process associated with it, you likely may wish to set the new object's state to be open or "in progress" again.
class Post < ActiveRecord::Base amoeba doset :state_tracker => "open_for_editing" endend
In this example, when a post is duplicated, it's state_tracker
field will always be given a value of open_for_editing
to start.
You may add a string to the beginning of a copied object's field during the copy phase:
class Post < ActiveRecord::Base amoeba doenableprepend :title => "Copy of " endend
You may add a string to the end of a copied object's field during the copy phase:
class Post < ActiveRecord::Base amoeba doenableappend :title => "Copy of " endend
You may run a search and replace query on a copied object's field during the copy phase:
class Post < ActiveRecord::Base amoeba doenableregex :contents => {:replace => /dog/, :with => 'cat'} endend
You may run a custom method or methods to do basically anything you like, simply pass a lambda block, or an array of lambda blocks to the customize
directive. Each block must have the same form, meaning that each block must accept two parameters, the original object and the newly copied object. You may then do whatever you wish, like this:
class Post < ActiveRecord::Base amoeba doprepend :title => "Hello world! "customize(lambda { |original_post,new_post| if original_post.foo == "bar"new_post.baz = "qux" end})append :comments => "... know what I'm sayin?" endend
or this, using an array:
class Post < ActiveRecord::Base has_and_belongs_to_many :tags amoeba doinclude_association :tagscustomize([ lambda do |orig_obj,copy_of_obj|# good stuff goes here end, lambda do |orig_obj,copy_of_obj|# more good stuff goes here end]) endend
Lambda blocks passed to customize run, by default, after all copying and field pre-processing. If you wish to run a method before any customization or field pre-processing, you may use override
the cousin of customize
. Usage is the same as above.
class Post < ActiveRecord::Base amoeba doprepend :title => "Hello world! "override(lambda { |original_post,new_post| if original_post.foo == "bar"new_post.baz = "qux" end})append :comments => "... know what I'm sayin?" endend
You may apply a single preprocessor to multiple fields at once.
class Post < ActiveRecord::Base amoeba doenableprepend :title => "Copy of ", :contents => "Copied contents: " endend
You may apply multiple preprocessing directives to a single model at once.
class Post < ActiveRecord::Base amoeba doprepend :title => "Copy of ", :contents => "Original contents: "append :contents => " (copied version)"regex :contents => {:replace => /dog/, :with => 'cat'} endend
This example should result in something like this:
post = Post.create( :title => "Hello world", :contents => "I like dogs, dogs are awesome.")new_post = post.amoeba_dupnew_post.title # "Copy of Hello world"new_post.contents # "Original contents: I like cats, cats are awesome. (copied version)"
Like nullify
, the preprocessing directives do not automatically enable the copying of associated child records. If only preprocessing directives are used and you do want to copy child records and no include_association
or exclude_association
list is provided, you must still explicitly enable the copying of child records by calling the enable method from within the amoeba block on your model.
You may use a combination of configuration methods within each model's amoeba block. Recognized association types take precedence over inclusion or exclusion lists. Inclusive style takes precedence over exclusive style, and these two explicit styles take precedence over the indiscriminate style. In other words, if you list fields to copy, amoeba will only copy the fields you list, or only copy the fields you don't exclude as the case may be. Additionally, if a field type is not recognized it will not be copied, regardless of whether it appears in an inclusion list. If you want amoeba to automatically copy all of your child records, do not list any fields using either include_association
or exclude_association
.
The following example syntax is perfectly valid, and will result in the usage of inclusive style. The order in which you call the configuration methods within the amoeba block does not matter:
class Topic < ActiveRecord::Base has_many :postsendclass Post < ActiveRecord::Base belongs_to :topic has_many :comments has_many :tags has_many :authors amoeba doexclude_association :authorsinclude_association :tagsnullify :date_publishedprepend :title => "Copy of "append :contents => " (copied version)"regex :contents => {:replace => /dog/, :with => 'cat'}include_association :authorsenablenullify :topic_id endendclass Comment < ActiveRecord::Base belongs_to :postend
This example will copy all of a post's tags and authors, but not its comments. It will also nullify the publishing date and dissociate the post from its original topic. It will also preprocess the post's fields as in the previous preprocessing example.
Note that, because of precedence, inclusive style is used and the list of exclude fields is never consulted. Additionally, the enable
method is redundant because amoeba is automatically enabled when using include_association
.
The preprocessing directives are run after child records are copied and are run in this order.
Null fields
Prepends
Appends
Search and Replace
Preprocessing directives do not affect inclusion and exclusion lists.
You may cause amoeba to keep copying down the chain as far as you like, simply add amoeba blocks to each model you wish to have copy its children. Amoeba will automatically recurse into any enabled grandchildren and copy them as well.
class Post < ActiveRecord::Base has_many :comments amoeba doenable endendclass Comment < ActiveRecord::Base belongs_to :post has_many :ratings amoeba doenable endendclass Rating < ActiveRecord::Base belongs_to :commentend
In this example, when a post is copied, amoeba will copy each all of a post's comments and will also copy each comment's ratings.
Using the has_one :through
association is simple, just be sure to enable amoeba on the each model with a has_one
association and amoeba will automatically and recursively drill down, like so:
class Supplier < ActiveRecord::Base has_one :account has_one :history, :through => :account amoeba doenable endendclass Account < ActiveRecord::Base belongs_to :supplier has_one :history amoeba doenable endendclass History < ActiveRecord::Base belongs_to :accountend
Copying of has_many :through
associations works automatically. They perform the copy in the same way as the has_and_belongs_to_many
association, meaning the actual child records are not copied, but rather the associations are simply maintained. You can add some field preprocessors to the middle model if you like but this is not strictly necessary:
class Assembly < ActiveRecord::Base has_many :manifests has_many :parts, :through => :manifests amoeba doenable endendclass Manifest < ActiveRecord::Base belongs_to :assembly belongs_to :part amoeba doprepend :notes => "Copy of " endendclass Part < ActiveRecord::Base has_many :manifests has_many :assemblies, :through => :manifests amoeba doenable endend
You may control how amoeba copies your object, on the fly, by passing a configuration block to the model's amoeba method. The configuration method is static but the configuration is applied on a per instance basis.
class Post < ActiveRecord::Base has_many :comments amoeba doenableprepend :title => "Copy of " endendclass Comment < ActiveRecord::Base belongs_to :postendclass PostsController < ActionController def duplicate_a_postold_post = Post.create( :title => "Hello world", :contents => "Lorum ipsum")old_post.class.amoeba do prepend :contents => "Here's a copy: "endnew_post = old_post.amoeba_dupnew_post.title # should be "Copy of Hello world"new_post.contents # should be "Here's a copy: Lorum ipsum"new_post.save endend
If you are using the Single Table Inheritance provided by ActiveRecord, you may cause amoeba to automatically process child classes in the same way as their parents. All you need to do is call the propagate
method within the amoeba block of the parent class and all child classes should copy in a similar manner.
create_table :products, :force => true do |t| t.string :type # this is the STI column # these belong to all products t.string :title t.decimal :price # these are for shirts only t.decimal :sleeve_length t.decimal :collar_size # these are for computers only t.integer :ram_size t.integer :hard_drive_sizeendclass Product < ActiveRecord::Base has_many :images has_and_belongs_to_many :categories amoeba doenablepropagate endendclass Shirt < Productendclass Computer < Productendclass ProductsController def some_methodmy_shirt = Shirt.find(1)my_shirt.amoeba_dupmy_shirt.save# this shirt should now:# - have its own copy of all parent images# - be in the same categories as the parent endend
This example should duplicate all the images and sections associated with this Shirt, which is a child of Product
By default, propagation uses submissive parenting, meaning the config settings on the parent will be applied, but any child settings, if present, will either add to or overwrite the parent settings depending on how you call the DSL methods.
You may change this behavior, the so called "parenting style", to give preference to the parent settings or to ignore any and all child settings.
The :relaxed
parenting style will prefer parent settings.
class Product < ActiveRecord::Base has_many :images has_and_belongs_to_many :sections amoeba doexclude_association :imagespropagate :relaxed endendclass Shirt < Product include_association :images include_association :sections prepend :title => "Copy of "end
In this example, the conflicting include_association
settings on the child will be ignored and the parent exclude_association
setting will be used,