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.
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