copyable 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +23 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +3 -0
  5. data/Gemfile +4 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +264 -0
  8. data/Rakefile +7 -0
  9. data/copyable.gemspec +28 -0
  10. data/lib/copyable.rb +32 -0
  11. data/lib/copyable/config.rb +19 -0
  12. data/lib/copyable/copy_registry.rb +50 -0
  13. data/lib/copyable/copyable_extension.rb +118 -0
  14. data/lib/copyable/declarations/after_copy.rb +15 -0
  15. data/lib/copyable/declarations/associations.rb +116 -0
  16. data/lib/copyable/declarations/columns.rb +34 -0
  17. data/lib/copyable/declarations/declaration.rb +15 -0
  18. data/lib/copyable/declarations/declarations.rb +14 -0
  19. data/lib/copyable/declarations/disable_all_callbacks_and_observers_except_validate.rb +9 -0
  20. data/lib/copyable/declarations/main.rb +32 -0
  21. data/lib/copyable/exceptions.rb +10 -0
  22. data/lib/copyable/model_hooks.rb +40 -0
  23. data/lib/copyable/option_checker.rb +23 -0
  24. data/lib/copyable/railtie.rb +9 -0
  25. data/lib/copyable/saver.rb +19 -0
  26. data/lib/copyable/single_copy_enforcer.rb +68 -0
  27. data/lib/copyable/syntax_checking/association_checker.rb +41 -0
  28. data/lib/copyable/syntax_checking/column_checker.rb +43 -0
  29. data/lib/copyable/syntax_checking/completeness_checker.rb +27 -0
  30. data/lib/copyable/syntax_checking/declaration_checker.rb +26 -0
  31. data/lib/copyable/syntax_checking/declaration_stubber.rb +18 -0
  32. data/lib/copyable/syntax_checking/syntax_checker.rb +15 -0
  33. data/lib/copyable/version.rb +3 -0
  34. data/lib/tasks/copyable.rake +55 -0
  35. data/spec/config_spec.rb +132 -0
  36. data/spec/copy_registry_spec.rb +55 -0
  37. data/spec/copyable_after_copy_spec.rb +28 -0
  38. data/spec/copyable_associations_spec.rb +366 -0
  39. data/spec/copyable_columns_spec.rb +116 -0
  40. data/spec/copyable_spec.rb +7 -0
  41. data/spec/create_copy_spec.rb +136 -0
  42. data/spec/deep_structure_copy_spec.rb +169 -0
  43. data/spec/helper/copyable_spec_helper.rb +15 -0
  44. data/spec/helper/test_models.rb +136 -0
  45. data/spec/helper/test_tables.rb +135 -0
  46. data/spec/model_hooks_spec.rb +66 -0
  47. data/spec/spec_helper.rb +29 -0
  48. data/spec/stress_test_spec.rb +261 -0
  49. data/spec/syntax_checking/association_checker_spec.rb +80 -0
  50. data/spec/syntax_checking/column_checker_spec.rb +49 -0
  51. data/spec/syntax_checking/declaration_checker_spec.rb +58 -0
  52. data/spec/syntax_checking_spec.rb +258 -0
  53. data/spec/transaction_spec.rb +78 -0
  54. 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,3 @@
1
+ module Copyable
2
+ VERSION = "0.0.1"
3
+ 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
@@ -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