copyable 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +23 -0
- data/.rspec +2 -0
- data/.travis.yml +3 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +264 -0
- data/Rakefile +7 -0
- data/copyable.gemspec +28 -0
- data/lib/copyable.rb +32 -0
- data/lib/copyable/config.rb +19 -0
- data/lib/copyable/copy_registry.rb +50 -0
- data/lib/copyable/copyable_extension.rb +118 -0
- data/lib/copyable/declarations/after_copy.rb +15 -0
- data/lib/copyable/declarations/associations.rb +116 -0
- data/lib/copyable/declarations/columns.rb +34 -0
- data/lib/copyable/declarations/declaration.rb +15 -0
- data/lib/copyable/declarations/declarations.rb +14 -0
- data/lib/copyable/declarations/disable_all_callbacks_and_observers_except_validate.rb +9 -0
- data/lib/copyable/declarations/main.rb +32 -0
- data/lib/copyable/exceptions.rb +10 -0
- data/lib/copyable/model_hooks.rb +40 -0
- data/lib/copyable/option_checker.rb +23 -0
- data/lib/copyable/railtie.rb +9 -0
- data/lib/copyable/saver.rb +19 -0
- data/lib/copyable/single_copy_enforcer.rb +68 -0
- data/lib/copyable/syntax_checking/association_checker.rb +41 -0
- data/lib/copyable/syntax_checking/column_checker.rb +43 -0
- data/lib/copyable/syntax_checking/completeness_checker.rb +27 -0
- data/lib/copyable/syntax_checking/declaration_checker.rb +26 -0
- data/lib/copyable/syntax_checking/declaration_stubber.rb +18 -0
- data/lib/copyable/syntax_checking/syntax_checker.rb +15 -0
- data/lib/copyable/version.rb +3 -0
- data/lib/tasks/copyable.rake +55 -0
- data/spec/config_spec.rb +132 -0
- data/spec/copy_registry_spec.rb +55 -0
- data/spec/copyable_after_copy_spec.rb +28 -0
- data/spec/copyable_associations_spec.rb +366 -0
- data/spec/copyable_columns_spec.rb +116 -0
- data/spec/copyable_spec.rb +7 -0
- data/spec/create_copy_spec.rb +136 -0
- data/spec/deep_structure_copy_spec.rb +169 -0
- data/spec/helper/copyable_spec_helper.rb +15 -0
- data/spec/helper/test_models.rb +136 -0
- data/spec/helper/test_tables.rb +135 -0
- data/spec/model_hooks_spec.rb +66 -0
- data/spec/spec_helper.rb +29 -0
- data/spec/stress_test_spec.rb +261 -0
- data/spec/syntax_checking/association_checker_spec.rb +80 -0
- data/spec/syntax_checking/column_checker_spec.rb +49 -0
- data/spec/syntax_checking/declaration_checker_spec.rb +58 -0
- data/spec/syntax_checking_spec.rb +258 -0
- data/spec/transaction_spec.rb +78 -0
- metadata +200 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
module Copyable
|
2
|
+
class Saver
|
3
|
+
|
4
|
+
# this is the algorithm for saving the new record
|
5
|
+
def self.save!(new_model, skip_validations)
|
6
|
+
unless skip_validations
|
7
|
+
ModelHooks.reenable!(new_model.class) # we must re-enable or validation does not work
|
8
|
+
if !new_model.valid?(:create)
|
9
|
+
ModelHooks.disable!(new_model.class)
|
10
|
+
raise(ActiveRecord::RecordInvalid.new(new_model))
|
11
|
+
else
|
12
|
+
ModelHooks.disable!(new_model.class)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
new_model.save!
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# When we call some_model.create_copy! and we wish to copy the associated
|
2
|
+
# has_many models, we could end up copying a complex tree of models since
|
3
|
+
# create_copy! will be called on each associated model.
|
4
|
+
#
|
5
|
+
# Consider a data model where an owner can have many vehicles and a vehicle
|
6
|
+
# can have many amenities. If an owner has a car with two amenities, the
|
7
|
+
# models may look like this:
|
8
|
+
#
|
9
|
+
# +-------------+
|
10
|
+
# | OWNER |
|
11
|
+
# +------^------+
|
12
|
+
# |
|
13
|
+
# |
|
14
|
+
# |
|
15
|
+
# +------+------+
|
16
|
+
# | VEHICLE |
|
17
|
+
# +--^-------^--+
|
18
|
+
# | |
|
19
|
+
# | |
|
20
|
+
# | |
|
21
|
+
# +--+-------+-+ +-+-------+--+
|
22
|
+
# | AMENITY | | AMENITY |
|
23
|
+
# +------------+ +------------+
|
24
|
+
#
|
25
|
+
# If we call owner.create_copy!, and the copyable declaration of the Owner
|
26
|
+
# class and the Vehicle class instruct us to make copies of the associated
|
27
|
+
# models, we will end up with a new owner model, which has a new vehicle model,
|
28
|
+
# which has two new amenity models. The whole tree structure gets copied.
|
29
|
+
#
|
30
|
+
# Now here's a twist. Consider the data model is not well normalized in that
|
31
|
+
# even though an amenity belongs to a vehicle which belongs to an owner,
|
32
|
+
# there is a redundant belongs to relationship between an amenity and an owner.
|
33
|
+
# So an owner can know his amenities through his vehicles or directly:
|
34
|
+
#
|
35
|
+
# +-------------+
|
36
|
+
# +----> OWNER <----+
|
37
|
+
# | +------^------+ |
|
38
|
+
# | | |
|
39
|
+
# | | |
|
40
|
+
# | | |
|
41
|
+
# | +------+------+ |
|
42
|
+
# | | VEHICLE | |
|
43
|
+
# | +--^-------^--+ |
|
44
|
+
# | | | |
|
45
|
+
# | | | |
|
46
|
+
# | | | |
|
47
|
+
# +--+-------+-+ +-+-------+--+
|
48
|
+
# | AMENITY | | AMENITY |
|
49
|
+
# +------------+ +------------+
|
50
|
+
#
|
51
|
+
# Now, a copying algorithm that treats this as a "tree" and simply walks through
|
52
|
+
# the associations will end up creating too many amenity records because it
|
53
|
+
# will copy the amenities belonging to the owner and then the amenities
|
54
|
+
# belonging to the vehicle (which are the same amenities).
|
55
|
+
#
|
56
|
+
# Therefore, we need a way to keep track of records we have already duplicated
|
57
|
+
# so that we don't duplicate them again in the database. The CopyRegistry
|
58
|
+
# class keeps track of what we've already copied, and the SingleCopyEnforcer
|
59
|
+
# simply tells us whether we can go ahead and duplicate a record in the
|
60
|
+
# database (because it hasn't been duplicated yet).
|
61
|
+
|
62
|
+
module Copyable
|
63
|
+
class SingleCopyEnforcer
|
64
|
+
def self.can_copy?(record)
|
65
|
+
!CopyRegistry.already_copied?(record: record)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Copyable
|
2
|
+
class AssociationChecker < Copyable::CompletenessChecker
|
3
|
+
|
4
|
+
def associations(associations)
|
5
|
+
@associations = associations.keys.map(&:to_s)
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def expected_entries
|
11
|
+
all_associations = model_class.reflect_on_all_associations
|
12
|
+
required_associations = all_associations.select do |ass|
|
13
|
+
(ass.macro != :belongs_to) && ass.options[:through].blank?
|
14
|
+
end
|
15
|
+
required_associations.map(&:name).map(&:to_s)
|
16
|
+
end
|
17
|
+
|
18
|
+
def provided_entries
|
19
|
+
@associations
|
20
|
+
end
|
21
|
+
|
22
|
+
def missing_entries_found(missing_entries)
|
23
|
+
message = "The following associations were not listed "
|
24
|
+
message << "in copyable's associations in the model '#{model_class.name}':\n"
|
25
|
+
missing_entries.each {|ass| message << " association: #{ass}\n" }
|
26
|
+
message << "Basically, if you just added a new association to this model, you need to update "
|
27
|
+
message << "the copyable declaration to instruct it how to deal with copying the associated models.\n"
|
28
|
+
raise AssociationError.new(message)
|
29
|
+
end
|
30
|
+
|
31
|
+
def extra_entries_found(extra_entries)
|
32
|
+
message = "The following associations were listed in copyable's associations in the model '#{model_class.name}' "
|
33
|
+
message << "but are either (1) not actually associations on this model or "
|
34
|
+
message << "(2) an association that does not need to be listed "
|
35
|
+
message << "(belongs to, has many through, or has one through):\n"
|
36
|
+
extra_entries.each {|ass| message << " association: #{ass}\n" }
|
37
|
+
raise AssociationError.new(message)
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
module Copyable
|
2
|
+
class ColumnChecker < Copyable::CompletenessChecker
|
3
|
+
|
4
|
+
def columns(columns)
|
5
|
+
@columns = columns.keys.map(&:to_s)
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def columns_to_skip
|
11
|
+
['id', 'created_at', 'updated_at', 'created_on', 'updated_on']
|
12
|
+
end
|
13
|
+
|
14
|
+
def expected_entries
|
15
|
+
columns_in_database = model_class.column_names
|
16
|
+
columns_in_database -= columns_to_skip
|
17
|
+
columns_in_database
|
18
|
+
end
|
19
|
+
|
20
|
+
def provided_entries
|
21
|
+
@columns
|
22
|
+
end
|
23
|
+
|
24
|
+
def missing_entries_found(missing_entries)
|
25
|
+
message = "The following columns were found in the database table '#{model_class.table_name}' "
|
26
|
+
message << "but not found in copyable's columns in the model '#{model_class.name}':\n"
|
27
|
+
missing_entries.each {|c| message << " column: #{c}\n" }
|
28
|
+
message << "Basically, if you just added columns to this database table, you need to update "
|
29
|
+
message << "the copyable declaration to instruct it how to copy the new columns.\n"
|
30
|
+
raise ColumnError.new(message)
|
31
|
+
end
|
32
|
+
|
33
|
+
def extra_entries_found(extra_entries)
|
34
|
+
message = "The following columns were found in copyable's columns in the model '#{model_class.name}' "
|
35
|
+
message << "but not found in the database table '#{model_class.table_name}':\n"
|
36
|
+
extra_entries.each {|c| message << " column: #{c}\n" }
|
37
|
+
message << "Note that the #{columns_to_skip.join(', ')} columns are handled "
|
38
|
+
message << "automatically and should not be listed.\n"
|
39
|
+
raise ColumnError.new(message)
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Copyable
|
2
|
+
class CompletenessChecker
|
3
|
+
|
4
|
+
include DeclarationStubber
|
5
|
+
|
6
|
+
def initialize(model_class)
|
7
|
+
@model_class = model_class
|
8
|
+
end
|
9
|
+
|
10
|
+
# an algorithm for ensuring that the expected entries are listed
|
11
|
+
# in a declaration -- no more, and no less
|
12
|
+
def verify!(block)
|
13
|
+
self.instance_eval(&block)
|
14
|
+
expected = Set.new(expected_entries)
|
15
|
+
provided = Set.new(provided_entries)
|
16
|
+
missing_entries = expected - provided
|
17
|
+
extra_entries = provided - expected
|
18
|
+
missing_entries_found(missing_entries) if missing_entries.any?
|
19
|
+
extra_entries_found(extra_entries) if extra_entries.any?
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def model_class; @model_class; end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Copyable
|
2
|
+
class DeclarationChecker
|
3
|
+
|
4
|
+
def verify!(declaration_block)
|
5
|
+
@declarations_that_were_called = []
|
6
|
+
self.instance_eval(&declaration_block)
|
7
|
+
|
8
|
+
Copyable::Declarations::ALL.each do |declaration|
|
9
|
+
if declaration.required? && !@declarations_that_were_called.include?(declaration.method_name)
|
10
|
+
message = "The copyable declaration must include #{declaration.name}."
|
11
|
+
raise DeclarationError.new(message)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def method_missing(method_name, *args, &block)
|
17
|
+
method = method_name.to_s
|
18
|
+
if Copyable::Declarations.include?(method)
|
19
|
+
@declarations_that_were_called << method
|
20
|
+
else
|
21
|
+
raise DeclarationError.new("Unknown declaration '#{method}' in copyable.")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Copyable
|
2
|
+
module DeclarationStubber
|
3
|
+
|
4
|
+
# This creates dummy methods for each declaration, which is useful
|
5
|
+
# if you are creating a class that merely wants to check that
|
6
|
+
# a particular declaration is called correctly. It's useful to
|
7
|
+
# have the other declarations that you don't care about available
|
8
|
+
# as stubs.
|
9
|
+
def self.included(klass)
|
10
|
+
Declarations::ALL.each do |decl|
|
11
|
+
klass.send(:define_method, decl.method_name) do |*args|
|
12
|
+
# intentionally empty
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Copyable
|
2
|
+
class SyntaxChecker
|
3
|
+
|
4
|
+
def self.check!(model_class, declaration_block)
|
5
|
+
raise CopyableError.new("You must pass copyable a block") if declaration_block.nil?
|
6
|
+
declaration_checker = DeclarationChecker.new
|
7
|
+
declaration_checker.verify!(declaration_block)
|
8
|
+
column_checker = ColumnChecker.new(model_class)
|
9
|
+
column_checker.verify!(declaration_block)
|
10
|
+
association_checker = AssociationChecker.new(model_class)
|
11
|
+
association_checker.verify!(declaration_block)
|
12
|
+
end
|
13
|
+
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# This is just a quick-and-dirty convenience task that will output a
|
2
|
+
# default copyable declaration based on the columns and associations
|
3
|
+
# of a given model. It saves you some typing when adding a new
|
4
|
+
# copyable declaration to a model.
|
5
|
+
#
|
6
|
+
# $ rake copyable model=ModelClassName
|
7
|
+
#
|
8
|
+
desc "generate a copyable declaration for a model"
|
9
|
+
task :copyable => :environment do
|
10
|
+
|
11
|
+
if ENV['model'].blank?
|
12
|
+
puts "Usage: rake copyable model=ModelClassName"
|
13
|
+
exit
|
14
|
+
end
|
15
|
+
|
16
|
+
begin
|
17
|
+
model_class = ENV['model'].constantize
|
18
|
+
rescue NameError
|
19
|
+
puts "Error: unknown model '#{ENV['model']}'"
|
20
|
+
puts "aborting"
|
21
|
+
exit
|
22
|
+
end
|
23
|
+
|
24
|
+
puts
|
25
|
+
puts "copyable do"
|
26
|
+
puts " disable_all_callbacks_and_observers_except_validate"
|
27
|
+
puts " columns({"
|
28
|
+
|
29
|
+
columns = model_class.column_names - ['id', 'created_at', 'updated_at', 'created_on', 'updated_on']
|
30
|
+
max_length = columns.map(&:length).max
|
31
|
+
columns.sort.each do |column|
|
32
|
+
column += ":"
|
33
|
+
column = column.ljust(max_length+1)
|
34
|
+
puts " #{column} :copy,"
|
35
|
+
end
|
36
|
+
|
37
|
+
puts " })"
|
38
|
+
puts " associations({"
|
39
|
+
|
40
|
+
all_associations = model_class.reflect_on_all_associations
|
41
|
+
required_associations = all_associations.select do |ass|
|
42
|
+
(ass.macro != :belongs_to) && ass.options[:through].blank?
|
43
|
+
end
|
44
|
+
associations = required_associations.map(&:name).map(&:to_s)
|
45
|
+
max_length = associations.map(&:length).max
|
46
|
+
associations.sort.each do |ass|
|
47
|
+
ass += ":"
|
48
|
+
ass = ass.ljust(max_length+1)
|
49
|
+
puts " #{ass} :copy,"
|
50
|
+
end
|
51
|
+
|
52
|
+
puts " })"
|
53
|
+
puts "end"
|
54
|
+
puts
|
55
|
+
end
|
data/spec/config_spec.rb
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
require_relative 'helper/copyable_spec_helper'
|
2
|
+
|
3
|
+
describe 'Copyable.config' do
|
4
|
+
it 'should be defined' do
|
5
|
+
expect(Copyable).to respond_to(:config)
|
6
|
+
end
|
7
|
+
|
8
|
+
describe 'suppress_schema_errors' do
|
9
|
+
it 'should default to false' do
|
10
|
+
expect(Copyable.config.suppress_schema_errors).to be_falsey
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'should be changeable' do
|
14
|
+
Copyable.config.suppress_schema_errors = true
|
15
|
+
expect(Copyable.config.suppress_schema_errors).to be_truthy
|
16
|
+
Copyable.config.suppress_schema_errors = false
|
17
|
+
end
|
18
|
+
|
19
|
+
context 'when set to true' do
|
20
|
+
before(:each) do
|
21
|
+
Copyable.config.suppress_schema_errors = true
|
22
|
+
end
|
23
|
+
|
24
|
+
after(:each) do
|
25
|
+
Copyable.config.suppress_schema_errors = false
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'when missing columns' do
|
29
|
+
before(:each) do
|
30
|
+
@model_definition = lambda do
|
31
|
+
undefine_copyable_in CopyableCoin
|
32
|
+
class CopyableCoin < ActiveRecord::Base
|
33
|
+
copyable do
|
34
|
+
disable_all_callbacks_and_observers_except_validate
|
35
|
+
columns({
|
36
|
+
kind: :copy,
|
37
|
+
})
|
38
|
+
associations({
|
39
|
+
})
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'should not throw an error' do
|
46
|
+
expect(@model_definition).to_not raise_error
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'with unknown columns' do
|
51
|
+
before(:each) do
|
52
|
+
@model_definition = lambda do
|
53
|
+
undefine_copyable_in CopyableCoin
|
54
|
+
class CopyableCoin < ActiveRecord::Base
|
55
|
+
copyable do
|
56
|
+
disable_all_callbacks_and_observers_except_validate
|
57
|
+
columns({
|
58
|
+
what_is_this_column_doing_here: :copy,
|
59
|
+
kind: :copy,
|
60
|
+
year: :copy,
|
61
|
+
})
|
62
|
+
associations({
|
63
|
+
})
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'should not throw an error' do
|
70
|
+
expect(@model_definition).to_not raise_error
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
context 'when missing associations' do
|
75
|
+
before(:each) do
|
76
|
+
@model_definition = lambda do
|
77
|
+
class CopyablePet < ActiveRecord::Base
|
78
|
+
copyable do
|
79
|
+
disable_all_callbacks_and_observers_except_validate
|
80
|
+
columns({
|
81
|
+
name: :copy,
|
82
|
+
kind: :copy,
|
83
|
+
birth_year: :copy,
|
84
|
+
})
|
85
|
+
associations({
|
86
|
+
# MISSING copyable_toys: :copy,
|
87
|
+
copyable_pet_tag: :copy,
|
88
|
+
copyable_pet_profile: :copy,
|
89
|
+
copyable_pet_foods: :copy,
|
90
|
+
copyable_pet_sitting_patronages: :copy,
|
91
|
+
})
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'should not throw an error' do
|
98
|
+
expect(@model_definition).to_not raise_error
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
context 'with unknown associations' do
|
103
|
+
before(:each) do
|
104
|
+
@model_definition = lambda do
|
105
|
+
class CopyablePet < ActiveRecord::Base
|
106
|
+
copyable do
|
107
|
+
disable_all_callbacks_and_observers_except_validate
|
108
|
+
columns({
|
109
|
+
name: :copy,
|
110
|
+
kind: :copy,
|
111
|
+
birth_year: :copy,
|
112
|
+
})
|
113
|
+
associations({
|
114
|
+
this_assoc_should_not_be_here: :copy,
|
115
|
+
copyable_toys: :copy,
|
116
|
+
copyable_pet_tag: :copy,
|
117
|
+
copyable_pet_profile: :copy,
|
118
|
+
copyable_pet_foods: :copy,
|
119
|
+
copyable_pet_sitting_patronages: :copy,
|
120
|
+
})
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'should not throw an error' do
|
127
|
+
expect(@model_definition).to_not raise_error
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|