copyable 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|