Copyable

Code Climate

Copyable makes it easy to copy ActiveRecord models.

Installation

Add this line to your application's Gemfile:

gem 'copyable'

And then execute:

$ bundle

Or install it yourself as:

$ gem install copyable

Usage

Basic Usage

Copyable gives you a create_copy! method that you can use to copy ActiveRecord models:

isaiah = Book.create!(title: "Isaiah")
copy_of_isaiah = isaiah.create_copy!

Now there are two books called "Isaiah" in the database.

The Copyable Declaration

In order for an ActiveRecord model class to have the create_copy! method defined on it, you need to add a copyable declaration:

class Book < ActiveRecord::Base

  copyable do
    ...
  end

end

The copyable declaration has its own DSL for describing how this model should be copied.

The Columns Declaration

The columns declaration specifies how each individual column should be copied:

copyable do
  ...
  columns({
    title:      :copy,
    isbn:       :copy,
    author_id:  :copy,
  })
  ...
end

Every column must be listed here (with the exception of id, created_at, created_on, updated_at or updated_on).

After each column name, give advice on how to copy that column. The advice must be one of the following:

  • :copy
  • :do_not_copy
  • lambda { |orig| ... }

:copy copies the value from the original model. :do_not_copy simply places nil in the column. Using a block lets you calculate the value of the column. The block is passed the original ActiveRecord model object that is being copied.

Here's another example:

copyable do
  ...
  columns({
    title:      lambda { |orig| "Copy of #{orig.title}" },
    isbn:       :do_not_copy,
    author_id:  :copy,
  })
  ...
end

The Associations Declaration

The associations declaration specifies whether to copy the associated models:

copyable do
  ...
  associations({
    pages:     :copy,
    pictures:  :copy,
    readers:   :do_not_copy,
  })
  ...
end

Every association must be listed here, with two exceptions:

  • belongs_to associations must not be listed here. Since belongs_to associations will have a foreign key column, the association will be copied when its column is copied.
  • has_many :through associations must not be listed here, because they are always associated with a related has_many association that will already have been listed.

The advice must be one of the following:

  • :copy
  • :do_not_copy
  • :copy_only_habtm_join_records

:copy will iterate through each model in the association, creating a copy. Note that the associated model class must also have a copyable declaration, so that we know how to copy it!

:do_not_copy does nothing.

:copy_only_habtm_join_records can only be used on has_and_belongs_to_many associations. In fact, you can't use :copy on has_and_belongs_to_many associations. Models associated via habtm are never actually copied, but their associations in the relevant join table can be.

Callbacks

It depends on the situation as to whether you would want a particular callback to be fired when a model is copied. Since the logic of callbacks is situational, Copyable makes the decision to bypass callbacks when saving the copied models by using raw SQL insert statements during the copy. It will check validations unless skip_validations is passed to create_copy.

The After Copy Declaration

In case you wanted to make sure a particular callback is run, or in case you had some special custom copying behavior, an after_copy declaration is provided that is called after the model has been copied. It is passed the original model and the newly copied model. Note that all callbacks and observers are disabled during the execution of the after_copy block, so you must call them explicitly if you want them to run.

copyable do
  ...
  after_copy do |original_model, new_model|
    new_model.foo = "bar"
    new_model.save!
  end
end

Putting It All Together

Here is an example of all four declarations being used in a copyable declaration. Note that all declarations are required except for after_copy.

copyable do
  disable_all_callbacks_and_observers_except_validate
  columns({
    title:      lambda { |orig| "Copy of #{orig.title}" },
    isbn:       :do_not_copy,
    author_id:  :copy,
  })
  associations({
    pages:     :copy,
    pictures:  :copy,
    readers:   :do_not_copy,
  })
  after_copy do |original_book, new_book|
    puts "There is now a new book: #{new_book.inspect}."
  end
end

create_copy!

The create_copy! method allows you to override column values by passing in a hash.

isaiah = Book.create!(title: "Isaiah")
copy_of_isaiah = isaiah.create_copy!
copy_of_isaiah2 = isaiah.create_copy!(override: { title: "Foo" })

copy_of_isaiah.title will be "Copy of Isaiah" (or whatever the advice was given in the columns declaration).

copy_of_isaiah2.title will be "Foo".

Note that you pass in column names only, so if you want to update a belongs_to association, you must pass in the column name, not the association name.

isaiah.create_copy!(override: { author: "Isaiah" })   # NO, bad programmer
isaiah.create_copy!(override: { author_id: 34 })      # YES

You can skip the running of validations on the copied model and its associated models. While this is not usually advisable, if you are copying a large data structure (which can take a while), you can increase the performance by skipping validations:

# turn off validations
isaiah.create_copy!(skip_validations: true)

# turn off validations and override columns
isaiah.create_copy!(override: { title: "Foo" }, skip_validations: true)

Configuring

You can configure copyable's behavior by setting a configuration parameter after you've loaded copyable.

Currently copyable only has one configuration setting:

Copyable.config.suppress_schema_errors = false

This is false by default. Set this to true if you don't want Copyable to complain with a ColumnError or AssociationError if your database schema does not match your copyable declarations.

You can also set an environment variable called SUPPRESS_SCHEMA_ERRORS to true.

Design Approach

Future-proof: Since Copyable forces you to declare the copying behavior for each and every column and association, when you add columns or associations to a model you are forced to revisit the copying behavior. This keeps the copying logic up-to-date with the model as it grows and changes.

Declarative: The declarative DSL style, although harder to debug under-the-hood, makes it much easier to work with in the model. It allows you to forget about all of the intricacies and edge cases of ActiveRecord and instead focus on describing the copying logic of your model.

Helpful Error Messages: Because DSLs can be hard to debug, a concerted effort was made to provide clear error messages for user-friendly debugging.

Strengths

  • handles polymorphic associations
  • create_copy! is run in a database transaction
  • keeps track of which models have already been copied so that it does not re-copy them if it comes across them again (helpful for complex model hierarchies with redundant associations)

Limitations

  • not thread-safe
  • copying very large data structures may use a lot of memory
  • not designed with performance (CPU or database) in mind
  • had to monkey-patch Rails
  • postponed support for single table inheritance until needed
  • postponed support for keeping counter_cache correct until needed

Convenience

A rake task is included that will output a basic copyable declaration given a model name. Basically, this saves you some typing.

$ rake copyable model=User

Gotchas

Creating Objects in after_copy

copyable keeps track of which models have already been copied so as not to reduplicate models if it comes across the same model through different associations. If you are creating new objects in after_copy! (such as manually copying an association instead of letting copyable do it), you do not have the benefit of copyable's checking whether the models have already been copied and may end up creating too many copies.

So the recommended approach is to use after_copy to tweak the columns of the copied record but to avoid creating new records here.

Contributing

  1. Fork it ( https://github.com/[my-github-username]/copyable/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

About

Copyable was developed at District Management Group.