clone_kit 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +2 -0
- data/.rspec +2 -0
- data/.rubocop.kapost.yml +99 -0
- data/.rubocop.yml +31 -0
- data/.ruby-version +1 -0
- data/Gemfile +6 -0
- data/README.md +79 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/clone_kit.gemspec +37 -0
- data/lib/clone_kit.rb +46 -0
- data/lib/clone_kit/cloners/mongoid_merging_ruleset_cloner.rb +106 -0
- data/lib/clone_kit/cloners/mongoid_ruleset_cloner.rb +135 -0
- data/lib/clone_kit/cloners/no_op.rb +11 -0
- data/lib/clone_kit/decorators/embedded_cloner_decorator.rb +31 -0
- data/lib/clone_kit/emitters/empty.rb +15 -0
- data/lib/clone_kit/event_outlet.rb +27 -0
- data/lib/clone_kit/graph.rb +44 -0
- data/lib/clone_kit/merge_attributes_tool.rb +99 -0
- data/lib/clone_kit/operation.rb +79 -0
- data/lib/clone_kit/rule.rb +30 -0
- data/lib/clone_kit/rules/allow_only_mongoid_fields.rb +45 -0
- data/lib/clone_kit/rules/except.rb +21 -0
- data/lib/clone_kit/rules/remap.rb +44 -0
- data/lib/clone_kit/rules/safe_remap.rb +26 -0
- data/lib/clone_kit/shared_id_map.rb +55 -0
- data/lib/clone_kit/specification.rb +52 -0
- data/lib/clone_kit/strategies/synchronous.rb +33 -0
- data/lib/clone_kit/version.rb +5 -0
- metadata +227 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: b62cef9da193908351d660a1089e8778553a963e
|
4
|
+
data.tar.gz: ff375e6bce0abff22230fb94ec0be5bd3e31adc0
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3417281d9a7f0c7d6f701f9185f187165d766263215b4faf5f8b9ca06916f29f8eadd61ae34724c83c6e75e28564aa62153d65dca0e940dafb7f3c2b6964109b
|
7
|
+
data.tar.gz: 9d34d0962ce7a93b60eb5e1688be2b8e07e39d7b0aa695eb70e2a974a57b4ed4a541cdb0151bf1017dd122298933d732076b0b5be1db142419d04ac3fb6f16fa
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.kapost.yml
ADDED
@@ -0,0 +1,99 @@
|
|
1
|
+
# Please don't modify this file, without first checking if it makes sense to
|
2
|
+
# adopt for most other Kapost projects. Ideally, we would keep these files as
|
3
|
+
# similar as possible to ease maintainability. Instead, open a PR in the
|
4
|
+
# codeclimate-common repo at https://github.com/kapost/codeclimate-common
|
5
|
+
|
6
|
+
Rails:
|
7
|
+
Enabled: true
|
8
|
+
|
9
|
+
AllCops:
|
10
|
+
DisplayCopNames: true
|
11
|
+
DisplayStyleGuide: true
|
12
|
+
Include:
|
13
|
+
- "**/Rakefile"
|
14
|
+
- "**/config.ru"
|
15
|
+
Exclude:
|
16
|
+
- "vendor/**/*"
|
17
|
+
- "spec/fixtures/**/*"
|
18
|
+
- "bin/**/*"
|
19
|
+
- "script/**/*"
|
20
|
+
|
21
|
+
Metrics/LineLength:
|
22
|
+
Max: 120
|
23
|
+
Rails/Date:
|
24
|
+
Enabled: false
|
25
|
+
Rails/TimeZone:
|
26
|
+
Enabled: false
|
27
|
+
AllCops:
|
28
|
+
TargetRubyVersion: 2.3
|
29
|
+
Style/AndOr:
|
30
|
+
EnforcedStyle: conditionals
|
31
|
+
Style/CaseIndentation:
|
32
|
+
IndentOneStep: true
|
33
|
+
Style/Documentation:
|
34
|
+
Enabled: false
|
35
|
+
Style/EachWithObject:
|
36
|
+
Enabled: false
|
37
|
+
Style/ExtraSpacing:
|
38
|
+
Exclude:
|
39
|
+
- "config/routes.rb"
|
40
|
+
Style/HashSyntax:
|
41
|
+
Exclude:
|
42
|
+
- "lib/tasks/**/*"
|
43
|
+
Style/MultilineOperationIndentation:
|
44
|
+
EnforcedStyle: indented
|
45
|
+
Style/NumericLiterals:
|
46
|
+
Enabled: false
|
47
|
+
Style/PercentLiteralDelimiters:
|
48
|
+
PreferredDelimiters:
|
49
|
+
"%w": "[]"
|
50
|
+
"%W": "[]"
|
51
|
+
"%i": "[]"
|
52
|
+
"%I": "[]"
|
53
|
+
"%r": "()"
|
54
|
+
Style/SignalException:
|
55
|
+
EnforcedStyle: semantic
|
56
|
+
Style/SingleLineBlockParams:
|
57
|
+
Enabled: false
|
58
|
+
Style/StringLiterals:
|
59
|
+
EnforcedStyle: double_quotes
|
60
|
+
Style/MultilineMethodCallIndentation:
|
61
|
+
Exclude:
|
62
|
+
- "spec/**/*.rb"
|
63
|
+
|
64
|
+
# These are better handled by reek
|
65
|
+
Metrics/MethodLength:
|
66
|
+
Enabled: false
|
67
|
+
Metrics/ParameterLists:
|
68
|
+
Enabled: false
|
69
|
+
|
70
|
+
# Rubocop's global exclude seems to fail to exclude the bin/ dir, so set all
|
71
|
+
# the cops that are failing manually, since most of these files are
|
72
|
+
# auto-generated anyways. Also, if the rules also appear above, we need to copy
|
73
|
+
# the same attrs, because YAML won't merge, only overwrite.
|
74
|
+
|
75
|
+
Style/StringLiterals:
|
76
|
+
EnforcedStyle: double_quotes
|
77
|
+
Exclude:
|
78
|
+
- "bin/**/*"
|
79
|
+
Style/FrozenStringLiteralComment:
|
80
|
+
Exclude:
|
81
|
+
- "bin/**/*"
|
82
|
+
Style/LeadingCommentSpace:
|
83
|
+
Exclude:
|
84
|
+
- "bin/**/*"
|
85
|
+
Style/SpaceInsideParens:
|
86
|
+
Exclude:
|
87
|
+
- "bin/**/*"
|
88
|
+
Style/AlignParameters:
|
89
|
+
Exclude:
|
90
|
+
- "bin/**/*"
|
91
|
+
Style/ExtraSpacing:
|
92
|
+
Exclude:
|
93
|
+
- "bin/**/*"
|
94
|
+
Exclude:
|
95
|
+
- "config/routes.rb"
|
96
|
+
Lint/PercentStringArray:
|
97
|
+
Exclude:
|
98
|
+
# SecureHeaders needs the single quotes in `%w[https: 'self']`
|
99
|
+
- config/initializers/secure_headers.rb
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# Please don't modify this file, without first checking if it makes sense to
|
2
|
+
# adopt for most other Kapost projects. Ideally, we would keep these files as
|
3
|
+
# similar as possible to ease maintainability. Instead, open a PR in the
|
4
|
+
# codeclimate-common repo at https://github.com/kapost/codeclimate-common
|
5
|
+
|
6
|
+
# This file is where app-specific Rubocop configs go. I wish every tool supported inherited configs...
|
7
|
+
|
8
|
+
inherit_from:
|
9
|
+
- .rubocop.kapost.yml
|
10
|
+
|
11
|
+
# Frozen string break everything in napa. This is gonna be a big project to replace
|
12
|
+
Style/FrozenStringLiteralComment:
|
13
|
+
Exclude:
|
14
|
+
- "**/*.rb"
|
15
|
+
|
16
|
+
Rails/ActionFilter:
|
17
|
+
EnforcedStyle: filter
|
18
|
+
|
19
|
+
Style/TrailingCommaInArguments:
|
20
|
+
Enabled: false
|
21
|
+
|
22
|
+
Style/NumericPredicate:
|
23
|
+
Enabled: false
|
24
|
+
|
25
|
+
Style/BlockDelimiters:
|
26
|
+
EnforcedStyle: braces_for_chaining
|
27
|
+
|
28
|
+
# Disabled since it only applies to Rails 5. Reenable when we migrate our apps to Rails 5.
|
29
|
+
# https://github.com/bbatsov/rubocop/issues/3629
|
30
|
+
Rails/HttpPositionalArguments:
|
31
|
+
Enabled: false
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.3.1
|
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
# CloneKit!
|
2
|
+
|
3
|
+
An ActiveRecord-ish toolkit library for building database record cloning without the business logic and executing cloning operations, especially for multi-tenant applications using Mongoid.
|
4
|
+
|
5
|
+
## Why does cloning require a special toolkit?
|
6
|
+
|
7
|
+
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.
|
8
|
+
|
9
|
+
There is likely an alternative business logic required when records are copied and merged. CloneKit can help you assemble that logic.
|
10
|
+
|
11
|
+
## How do I clone?
|
12
|
+
|
13
|
+
Let's pretend you have a account that you want to clone to a new account.
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
class BlogPost
|
17
|
+
include Mongoid::Document
|
18
|
+
|
19
|
+
field :account_id, type: BSON::ObjectId
|
20
|
+
field :blog_type_id, type: BSON::ObjectId
|
21
|
+
field :body, type: String
|
22
|
+
end
|
23
|
+
```
|
24
|
+
|
25
|
+
You can specify the dependency order of cloning, the scope of the operation, and the specific cloning behavior inside a specification:
|
26
|
+
|
27
|
+
```ruby
|
28
|
+
CloneKit::Specification.new(BlogPost) do |spec|
|
29
|
+
spec.dependencies = %w(Account BlogType) # Helps derive the cloning order
|
30
|
+
spec.emitter = TenantEmitter.new(BlogPost) # The scope of the operation for this collection
|
31
|
+
spec.cloner = CloneKit::Cloners::MongoidRulesetCloner.new( # The cloning behavior
|
32
|
+
BlogPost,
|
33
|
+
rules: [
|
34
|
+
ReTenantRule.new,
|
35
|
+
CloneKit::Rules::Remap.new("BlogPost", "Account" => "account_id", "BlogType" => "blog_type_id")
|
36
|
+
]
|
37
|
+
)
|
38
|
+
end
|
39
|
+
```
|
40
|
+
|
41
|
+
## Writing an Emitter
|
42
|
+
|
43
|
+
You have to write some emitters for your app. By default, CloneKit specifications utilize and empty emitter, making all clones no-operations.
|
44
|
+
|
45
|
+
TODO
|
46
|
+
|
47
|
+
## Writing a Cloner
|
48
|
+
|
49
|
+
TODO
|
50
|
+
|
51
|
+
## Extending the built-in Cloners
|
52
|
+
|
53
|
+
TODO
|
54
|
+
|
55
|
+
## Installation
|
56
|
+
|
57
|
+
Add this line to your application's Gemfile:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
gem 'clone_kit'
|
61
|
+
```
|
62
|
+
|
63
|
+
And then execute:
|
64
|
+
|
65
|
+
$ bundle
|
66
|
+
|
67
|
+
Or install it yourself as:
|
68
|
+
|
69
|
+
$ gem install clone_kit
|
70
|
+
|
71
|
+
## Development
|
72
|
+
|
73
|
+
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.
|
74
|
+
|
75
|
+
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](https://rubygems.org).
|
76
|
+
|
77
|
+
## Contributing
|
78
|
+
|
79
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/clone_kit.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "clone_kit"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/clone_kit.gemspec
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
# coding: utf-8
|
3
|
+
lib = File.expand_path("../lib", __FILE__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require "clone_kit/version"
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = "clone_kit"
|
9
|
+
spec.version = CloneKit::VERSION
|
10
|
+
spec.authors = ["Brandon Croft"]
|
11
|
+
spec.email = ["brandon@kapost.com"]
|
12
|
+
|
13
|
+
spec.summary = "A toolkit to assist in complex cloning operations"
|
14
|
+
spec.description = "Supports rules-based cloning, Mongoid, and distributed operations"
|
15
|
+
spec.homepage = "https://github.com/kapost/clone_kit"
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
18
|
+
f.match(%r{^(spec)/})
|
19
|
+
end
|
20
|
+
|
21
|
+
spec.bindir = "exe"
|
22
|
+
spec.executables = spec.files.grep(%r(^exe/)) { |f| File.basename(f) }
|
23
|
+
spec.require_paths = ["lib"]
|
24
|
+
|
25
|
+
spec.add_runtime_dependency "redis"
|
26
|
+
spec.add_runtime_dependency "activesupport", "> 3.0.0" # For core ext Array#wrap and Object#blank?
|
27
|
+
|
28
|
+
spec.add_development_dependency "bundler", "~> 1.13"
|
29
|
+
spec.add_development_dependency "mongoid", "~> 4.0.2"
|
30
|
+
spec.add_development_dependency "database_cleaner", "1.5.3"
|
31
|
+
spec.add_development_dependency "rake", "~> 11.0"
|
32
|
+
spec.add_development_dependency "rspec", "~> 3.4"
|
33
|
+
spec.add_development_dependency "rspec-collection_matchers"
|
34
|
+
spec.add_development_dependency "pry-byebug"
|
35
|
+
spec.add_development_dependency "pry"
|
36
|
+
spec.add_development_dependency "fakeredis"
|
37
|
+
end
|
data/lib/clone_kit.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "clone_kit/version"
|
4
|
+
require "clone_kit/graph"
|
5
|
+
require "clone_kit/specification"
|
6
|
+
require "clone_kit/operation"
|
7
|
+
require "clone_kit/shared_id_map"
|
8
|
+
require "clone_kit/rule"
|
9
|
+
|
10
|
+
require "active_support/core_ext/array/wrap"
|
11
|
+
require "active_support/core_ext/object/blank"
|
12
|
+
|
13
|
+
module CloneKit
|
14
|
+
def self.graph
|
15
|
+
@graph ||= CloneKit::Graph.new
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.load_rails_models!
|
19
|
+
Rails.application.eager_load! if defined?(Rails) && !defined?(@eager_loaded_once)
|
20
|
+
@eager_loaded_once = true
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.spec
|
24
|
+
@spec ||= {}
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.add_specification(specification)
|
28
|
+
spec[specification.model.name] = specification
|
29
|
+
refresh_specification(specification)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.refresh_specification(specification)
|
33
|
+
graph.add_vertex(specification.model.name, *specification.dependencies)
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.cloneable_models(already_cloned)
|
37
|
+
result = []
|
38
|
+
graph.nodes.each do |model_name, deps|
|
39
|
+
next if already_cloned.include?(model_name)
|
40
|
+
next unless deps.all? { |dep| already_cloned.include?(dep) }
|
41
|
+
|
42
|
+
result << model_name
|
43
|
+
end
|
44
|
+
result
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "clone_kit/cloners/mongoid_ruleset_cloner"
|
4
|
+
|
5
|
+
module CloneKit
|
6
|
+
module Cloners
|
7
|
+
class MongoidMergingRulesetCloner < MongoidRulesetCloner
|
8
|
+
def initialize(model_klass, rules: [], merge_fields: ["name"])
|
9
|
+
super(model_klass, rules: rules)
|
10
|
+
self.merge_fields = merge_fields
|
11
|
+
end
|
12
|
+
|
13
|
+
def clone_ids(ids, operation)
|
14
|
+
@saved_id_map = {}
|
15
|
+
initialize_cloner(operation)
|
16
|
+
apply_rules_and_save(find_and_merge_existing_records(ids))
|
17
|
+
end
|
18
|
+
|
19
|
+
protected
|
20
|
+
|
21
|
+
# These methods are super simple and should usually be overridden to merge records in a more nuanced fashion
|
22
|
+
|
23
|
+
def compare(first, second)
|
24
|
+
merge_fields.all? { |name| first[name] == second[name] }
|
25
|
+
end
|
26
|
+
|
27
|
+
def merge(records)
|
28
|
+
result = nil
|
29
|
+
records.each do |rec|
|
30
|
+
clone_all_embedded_fields(rec)
|
31
|
+
result = if result.nil?
|
32
|
+
rec.deep_dup
|
33
|
+
else
|
34
|
+
result.merge(rec)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
result
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
attr_accessor :merge_fields
|
44
|
+
|
45
|
+
def find_and_merge_existing_records(ids)
|
46
|
+
all_records = []
|
47
|
+
each_existing_record(ids) do |rec|
|
48
|
+
all_records << rec
|
49
|
+
end
|
50
|
+
|
51
|
+
result = []
|
52
|
+
skip = Set.new
|
53
|
+
|
54
|
+
all_records.each_with_index do |record, i|
|
55
|
+
next if skip.include?(record["_id"])
|
56
|
+
mergeable = [record]
|
57
|
+
|
58
|
+
all_records[i + 1..all_records.length].each do |other_record|
|
59
|
+
next unless compare(record, other_record)
|
60
|
+
|
61
|
+
mergeable << other_record
|
62
|
+
skip << other_record["_id"]
|
63
|
+
end
|
64
|
+
|
65
|
+
new_id = BSON::ObjectId.new
|
66
|
+
new_record = if mergeable.length == 1
|
67
|
+
copy = clone(mergeable[0])
|
68
|
+
@saved_id_map[copy["_id"]] = new_id
|
69
|
+
copy
|
70
|
+
else
|
71
|
+
merged = merge(mergeable)
|
72
|
+
|
73
|
+
mergeable.each do |m|
|
74
|
+
@saved_id_map[m["_id"]] = new_id
|
75
|
+
end
|
76
|
+
merged
|
77
|
+
end
|
78
|
+
|
79
|
+
new_record["_id"] = new_id
|
80
|
+
result << new_record
|
81
|
+
end
|
82
|
+
|
83
|
+
CloneKit::SharedIdMap.new(current_operation.id).insert_many(model_klass, @saved_id_map)
|
84
|
+
|
85
|
+
result
|
86
|
+
end
|
87
|
+
|
88
|
+
def apply_rules_and_save(records)
|
89
|
+
records.each do |attributes|
|
90
|
+
id = attributes["_id"]
|
91
|
+
rules.each do |rule|
|
92
|
+
begin
|
93
|
+
rule.fix(@saved_id_map.key(id), attributes)
|
94
|
+
rescue StandardError => e
|
95
|
+
id = attributes["_id"]
|
96
|
+
message = "Unhandled error when applying rule #{rule.class.name} to #{model_klass} #{id}: #{e.class}"
|
97
|
+
current_operation.error(message)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
save_or_fail(attributes)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|