Project

clone_kit

0.0
Low commit activity in last 3 years
A long-lived project that still receives updates
Supports rules-based cloning, Mongoid, and distributed operations
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

 Project Readme

CloneKit!

An ActiveRecord-ish toolkit library for building database record cloning without the business logic and executing cloning operations, especially for multi-tenant applications using ActiveRecord or Mongoid.

Why does cloning require a special toolkit?

When operating a multi-tenant system, copying database records is fraught with perils that can wreak havoc on customer integrity. Failing to remap foreign ids does not usually trigger database integrity errors (especially in MongoDB :trollface:) but are even more insidious.

There is likely an alternative business logic required when records are copied and merged. CloneKit can help you assemble that logic.

How do I clone?

Let's pretend you have a account that you want to clone to a new account.

class BlogPost
  include Mongoid::Document

  field :account_id, type: BSON::ObjectId
  field :blog_type_id, type: BSON::ObjectId
  field :body, type: String
end

You can specify the dependency order of cloning, the scope of the operation, and the specific cloning behavior inside a specification:

CloneKit::MongoSpecification.new(BlogPost) do |spec|
  spec.dependencies = %w(Account BlogType)                   # Helps derive the cloning order
  spec.emitter = TenantEmitter.new(BlogPost)                 # The scope of the operation for this collection
  spec.cloner = CloneKit::Cloners::MongoidRulesetCloner.new( # The cloning behavior
    BlogPost,
    rules: [
      ReTenantRule.new,
      CloneKit::Rules::Remap.new("BlogPost", "Account" => "account_id", "BlogType" => "blog_type_id")
    ]
  )
  spec.after_operation do |operation|
    ...
  end
end

Dependencies can also be dynamically defined by using a proc or lambda:

CloneKit::MongoSpecification.new(BlogPost) do |spec|
  spec.dependencies = ->{ env.test? ? %w(Foo) : %w(Bar)  }
end

Writing an Emitter

By default, CloneKit specifications utilize an empty emitter, making all clones no-ops. Emitters are expected to make db calls using logic defined in the emitter.

Emitter rules

  • Emitters must respond to #emit_all and #scope.
  • emit_all must return an object that responds to #pluck.
CloneKit::ActiveRecordSpecification.new(BlogPost) do |spec|
  ...
  spec.emitter = ActiveRecordEmitter.new(BlogPost)
  ...
end

class ActiveRecordEmitter
  def initialize(klass)
    self.klass = klass
  end

  def scope(_args)
    klass.all # add any scope restrictions here
  end

  def emit_all(args) # the method that will be used to pluck the record ids
    scope(args)
  end

  private

  attr_accessor :klass
end

Custom Cloners

Cloners are the classes that determine what model class is cloned and how. There are several built-in cloners that can be extended. See lib/clone_kit/cloners for a list.

Custom cloners will need to define:

  1. The Mongoid or ActiveRecord model class, which will be used to make db calls
  2. Rules, which are executed in the defined order and determine how the ids are mapped from source to destination records. See more in next section.
  3. Merge fields, which allow two records to be merged into one provided all listed fields are equal.

Optionally, if you are merging records you will probably want to override the compare and merge methods with custom logic, though basic logic comes for free.

CloneKit::ActiveRecordSpecification.new(self) do |spec|
  ...
  spec.cloner = BlogPostCloner.new
  ...
end

class BlogPostCloner < ActiveRecordRulesetCloner
  OMIT_ATTRIBUTES = [:created_at, :updated_at]

  def initialize
    super(
      BlogPost,                                           # model class
      rules: [                                            # rules
        CloneKit::Rules::Except.new(*OMIT_ATTRIBUTES),
        CloneKit::Rules::Remap.new(BlogPost)
      ],
      merge_fields: [])                                   # merge fields
  end

  def compare(first, second)
    # returns a boolean to determine if two records are mergeable
  end

  def merge(records)
    # returns a single record that is the merged result
    # of all argument `records`,
    # e.g. [{ a: 1, b: 1 }, { a: 2, b: 1}] => { a: 2, b: 1 }
  end
end

Writing a Cloner rule

Rules respond to a single #fix method. #fix mutates a record's attributes, allowing the same attributes object to be passed down a pipeline of rules.

# given the following rules
rules: [
  CloneKit::Rules::Except.new(:title),
  CloneKit::Rules::Remap.new(BlogPost, "Author" => "author_id" )
]

# a blog post's attributes will be changed
{ title: "Title", content: "Content", author_id: 5 }
{ content: "Content", author_id: 5 } # CloneKit::Rules::Except
{ content: "Content", author_id: 6 } # CloneKit::Rules::Remap

See lib/clone_kit/rules for examples with documentation.

Installation

Add this line to your application's Gemfile:

gem 'clone_kit'

And then execute:

$ bundle

Or install it yourself as:

$ gem install clone_kit

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/kapost/clone_kit.