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,118 @@
1
+ module Copyable
2
+ module CopyableExtension
3
+
4
+ def self.included(klass)
5
+ klass.extend ClassMethods
6
+ end
7
+
8
+ module ClassMethods
9
+
10
+ # Use this copyable declaration in an ActiveRecord model to instruct
11
+ # the model how to copy itself. This declaration will create a
12
+ # create_copy! method that follows the instructions in the copyable
13
+ # declaration.
14
+ def copyable(&block)
15
+
16
+ begin
17
+ model_class = self
18
+ # raise an error if the copyable declaration is stated incorrectly
19
+ SyntaxChecker.check!(model_class, block)
20
+ # "execute" the copyable declaration, which basically saves the
21
+ # information listed in the declaration for later use by the
22
+ # create_copy! method
23
+ main = Declarations::Main.new
24
+ main.execute(block)
25
+ rescue => e
26
+ # if suppressing schema errors, don't raise an error, but also don't define a create_copy! method since it would have broken behavior
27
+ return if (e.is_a?(Copyable::ColumnError) || e.is_a?(Copyable::AssociationError) || e.is_a?(ActiveRecord::StatementInvalid)) && Copyable.config.suppress_schema_errors == true
28
+ raise
29
+ end
30
+
31
+ # define a create_copy! method for use on model objects
32
+ define_method(:create_copy!) do |options={}|
33
+
34
+ # raise an error if passed invalid options
35
+ OptionChecker.check!(options)
36
+
37
+ # we basically wrap the method in a lambda to help us manage
38
+ # running it in a transaction
39
+ do_the_copy = lambda do |options|
40
+ new_model = nil
41
+ begin
42
+ # start by disabling all callbacks and observers (except for
43
+ # validation callbacks and observers)
44
+ ModelHooks.disable!(model_class)
45
+ # rename self for clarity
46
+ original_model = self
47
+ # create a brand new, empty model
48
+ new_model = model_class.new
49
+ # fill in each column of this brand new model according to the
50
+ # instructions given in the copyable declaration
51
+ column_overrides = options[:override] || {}
52
+ Declarations::Columns.execute(main.column_list, original_model, new_model, column_overrides)
53
+ # save that sucker!
54
+ Copyable::Saver.save!(new_model, options[:skip_validations])
55
+ # tell the registry that we've created a new model (the registry
56
+ # helps keep us from creating another copy of a model we've
57
+ # already copied)
58
+ CopyRegistry.register(original_model, new_model)
59
+ # for this brand new model, visit all of the associated models,
60
+ # making new copies according to the instructions in the copyable
61
+ # declaration
62
+ Declarations::Associations.execute(main.association_list, original_model, new_model, options[:skip_validations])
63
+ # run the after_copy block if it exists
64
+ Declarations::AfterCopy.execute(main.after_copy_block, original_model, new_model)
65
+ ensure
66
+ # it's critically important to re-enable the callbacks and
67
+ # observers or they will stay disabled for future web
68
+ # requests
69
+ ModelHooks.reenable!(model_class)
70
+ end
71
+ # it's polite to return the newly created model
72
+ new_model
73
+ end
74
+
75
+
76
+ # create_copy! can end up calling itself (by copying associations).
77
+ # There is some behavior that we want to be slightly different if
78
+ # create_copy! is called from within another create_copy! call.
79
+ # (This means that any create_copy! call in copyable's internal
80
+ # code should pass { __called_recursively: true } to create_copy!.
81
+ if options[:__called_recursively]
82
+ do_the_copy.call(options)
83
+ else
84
+ # Imagine the case where you have a model hierarchy such as
85
+ # a Book that has many Sections that has many Pages.
86
+ #
87
+ # When @book.create_copy! is called, the CopyRegistry will keep
88
+ # track of all of the copied models, making sure no model is
89
+ # re-duplicated (such as in an unusual case where book sections
90
+ # actually overlapped, and therefore two different sections
91
+ # contained some of the same pages--you wouldn't want to re-copy
92
+ # the pages).
93
+ #
94
+ # If we don't clear the registry before we start @book.create_copy!,
95
+ # then we can't do this:
96
+ #
97
+ # copy1 = @book.create_copy!
98
+ # copy2 = @book.create_copy!
99
+ #
100
+ # since when copying copy2, the CopyRegistry will remember the
101
+ # Sections and Pages that it copied for copy1 and therefore
102
+ # they won't get recopied. So we have to clear the CopyRegistry's
103
+ # memory each time before create_copy! is called.
104
+ CopyRegistry.clear
105
+ # Nested transactions can end up swallowing ActiveRecord::Rollback
106
+ # errors in surprising ways. create_copy! can eventually call
107
+ # create_copy! when copying associated objects, which can result
108
+ # in nested transactions. We use this option to avoid the nesting.
109
+ ActiveRecord::Base.transaction do
110
+ do_the_copy.call(options)
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,15 @@
1
+ module Copyable
2
+ module Declarations
3
+ class AfterCopy < Declaration
4
+
5
+ def self.execute(after_copy_block, original_model, new_model)
6
+ after_copy_block.call(original_model, new_model) if after_copy_block
7
+ end
8
+
9
+ def self.required?
10
+ false
11
+ end
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,116 @@
1
+ module Copyable
2
+ module Declarations
3
+ class Associations < Declaration
4
+
5
+ class << self
6
+
7
+ # this is the algorithm for copying associated records according to the
8
+ # instructions given in the copyable declaration
9
+ def execute(association_list, original_model, new_model, skip_validations)
10
+ @skip_validations = skip_validations
11
+ association_list.each do |assoc_name, advice|
12
+ association = original_model.class.reflections[assoc_name.to_sym]
13
+ check_advice(association, advice, original_model)
14
+ unless advice == :do_not_copy
15
+ copy_association(association, original_model, new_model)
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def check_advice(association, advice, original_model)
23
+ if ![:copy, :do_not_copy, :copy_only_habtm_join_records].include?(advice)
24
+ message = "Error in copyable:associations of "
25
+ message << "#{original_model.class.name}: the association '#{association.name}' "
26
+ message << "has unrecognized advice '#{advice}'."
27
+ raise AssociationError.new(message)
28
+ end
29
+ if association.macro == :has_and_belongs_to_many && advice == :copy
30
+ message = "Error in copyable:associations of "
31
+ message << "#{original_model.class.name}: the association '#{association.name}' "
32
+ message << "only supports the :copy_only_habtm_join_records advice, not the :copy advice, "
33
+ message << "because it is a has_and_belongs_to_many association."
34
+ raise AssociationError.new(message)
35
+ end
36
+ if association.macro != :has_and_belongs_to_many && advice == :copy_only_habtm_join_records
37
+ message = "Error in copyable:associations of "
38
+ message << "#{original_model.class.name}: the association '#{association.name}' "
39
+ message << "only supports the :copy advice, not the :copy_only_habtm_join_records advice, "
40
+ message << "because it is not a has_and_belongs_to_many association."
41
+ raise AssociationError.new(message)
42
+ end
43
+ end
44
+
45
+ def copy_association(association, original_model, new_model)
46
+ case association.macro
47
+ when :has_many
48
+ copy_has_many(association, original_model, new_model)
49
+ when :has_one
50
+ copy_has_one(association, original_model, new_model)
51
+ when :has_and_belongs_to_many
52
+ copy_habtm(association, original_model, new_model)
53
+ else
54
+ raise "Unsupported association #{association.macro}" # should never happen, since we filter out belongs_to
55
+ end
56
+ end
57
+
58
+ def copy_has_many(association, original_model, new_model)
59
+ original_model.send(association.name).each do |child|
60
+ copy_record(association, child, new_model)
61
+ end
62
+ end
63
+
64
+ def copy_has_one(association, original_model, new_model)
65
+ child = original_model.send(association.name)
66
+ copy_record(association, child, new_model) if child
67
+ end
68
+
69
+ def copy_habtm(association, original_model, new_model)
70
+ original_model.send(association.name).each do |child|
71
+ new_model.send(association.name) << child
72
+ end
73
+ end
74
+
75
+ def copy_record(association, original_record, parent_model)
76
+ if SingleCopyEnforcer.can_copy?(original_record)
77
+ if original_record.respond_to? :create_copy!
78
+ copied_record = original_record.create_copy!(
79
+ override: { association.foreign_key => parent_model.id },
80
+ __called_recursively: true,
81
+ skip_validations: @skip_validations)
82
+ else
83
+ message = "Could not copy #{parent_model.class.name}#id:#{parent_model.id} "
84
+ message << "because #{original_record.class.name} does not have a copyable declaration."
85
+ raise Copyable::CopyableError.new(message)
86
+ end
87
+ else
88
+ copied_record = CopyRegistry.fetch_copy(record: original_record)
89
+ copied_record.update_column(association.foreign_key, parent_model.id)
90
+ end
91
+ update_other_belongs_to_associations(association.foreign_key, copied_record)
92
+ end
93
+
94
+ def update_other_belongs_to_associations(already_updated_key, copied_record)
95
+ copied_record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
96
+ next if assoc.foreign_key == already_updated_key
97
+ id = copied_record.send(assoc.foreign_key)
98
+ if id
99
+ if assoc.options.key? :polymorphic
100
+ klass = copied_record.send("#{assoc.name}_type").constantize
101
+ else
102
+ klass = assoc.klass
103
+ end
104
+ copied_child = CopyRegistry.fetch_copy(class: klass, id: id)
105
+ if copied_child
106
+ copied_record.update_column(assoc.foreign_key, copied_child.id)
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ end
113
+
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,34 @@
1
+ module Copyable
2
+ module Declarations
3
+ class Columns < Declaration
4
+
5
+ # this is the algorithm for copying columns from the original record
6
+ # to the brand new copy of the record according to the instructions
7
+ # given in the copyable declaration
8
+ def self.execute(column_list, original_model, new_model, overrides)
9
+ column_list.each do |column, advice|
10
+ # when create_copy! is called, you can pass in a hash of
11
+ # overrides that trumps the instructions in the copyable
12
+ # declaration
13
+ if overrides[column.to_sym].present?
14
+ value = overrides[column.to_sym]
15
+ elsif overrides[column.to_s].present?
16
+ value = overrides[column.to_s]
17
+ elsif advice == :copy
18
+ value = original_model.send(column)
19
+ elsif advice == :do_not_copy
20
+ value = nil
21
+ elsif advice.is_a? Proc
22
+ value = advice.call(original_model)
23
+ else
24
+ message = "Error in copyable:columns of #{original_model.class.name}: "
25
+ message << "the column '#{column}' must be :copy, :do_not_copy, or a lambda."
26
+ raise ColumnError.new(message)
27
+ end
28
+ new_model.send("#{column}=", value)
29
+ end
30
+ end
31
+
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,15 @@
1
+ module Copyable
2
+ module Declarations
3
+ class Declaration
4
+
5
+ def self.method_name
6
+ self.name.demodulize.underscore
7
+ end
8
+
9
+ def self.required?
10
+ true
11
+ end
12
+
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Copyable
2
+ module Declarations
3
+
4
+ ALL = [ DisableAllCallbacksAndObserversExceptValidate,
5
+ Columns,
6
+ Associations,
7
+ AfterCopy ]
8
+
9
+ def self.include?(method_name)
10
+ ALL.map(&:method_name).include?(method_name.to_s)
11
+ end
12
+
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ module Copyable
2
+ module Declarations
3
+ class DisableAllCallbacksAndObserversExceptValidate < Declaration
4
+
5
+ # intentionally left blank
6
+
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,32 @@
1
+ module Copyable
2
+ module Declarations
3
+ class Main
4
+
5
+ attr_reader :column_list, :association_list, :after_copy_block
6
+
7
+ def execute(block)
8
+ self.instance_eval(&block)
9
+ end
10
+
11
+ # This declaration doesn't actually *do* anything. It exists
12
+ # so that any copyable declaration must explicitly state that
13
+ # callbacks and observers are skipped (to make it easier to reason
14
+ # about the code when it is read).
15
+ def disable_all_callbacks_and_observers_except_validate
16
+ end
17
+
18
+ def columns(columns)
19
+ @column_list = columns
20
+ end
21
+
22
+ def associations(associations)
23
+ @association_list = associations
24
+ end
25
+
26
+ def after_copy(&block)
27
+ @after_copy_block = block
28
+ end
29
+
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ module Copyable
2
+
3
+ class CopyableError < StandardError; end
4
+
5
+ class DeclarationError < CopyableError; end
6
+ class ColumnError < CopyableError; end
7
+ class AssociationError < CopyableError; end
8
+ class CallbackError < CopyableError; end
9
+
10
+ end
@@ -0,0 +1,40 @@
1
+ module Copyable
2
+ class ModelHooks
3
+
4
+ # Disabling callbacks automatically disables any registered observers,
5
+ # since observers use the callback mechanism internally.
6
+
7
+ def self.disable!(klass)
8
+ disable_all_callbacks(klass)
9
+ end
10
+
11
+ def self.reenable!(klass)
12
+ reenable_all_callbacks(klass)
13
+ end
14
+
15
+ private
16
+
17
+ def self.disable_all_callbacks(klass)
18
+ klass.class_eval do
19
+ alias_method :__disabled__run_callbacks, :run_callbacks
20
+ # We are violently duck-punching ActiveRecord because ActiveRecord
21
+ # gives us no way to turn off callbacks. My apologies to the
22
+ # squeamish.
23
+ def run_callbacks(kind, *args, &block)
24
+ if block_given?
25
+ yield
26
+ else
27
+ true
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ def self.reenable_all_callbacks(klass)
34
+ klass.class_eval do
35
+ alias_method :run_callbacks, :__disabled__run_callbacks
36
+ end
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,23 @@
1
+ module Copyable
2
+ class OptionChecker
3
+
4
+ VALID_OPTIONS = [:override, :skip_validations]
5
+ VALID_PRIVATE_OPTIONS = [:__called_recursively] # for copyable's internal use only
6
+
7
+ def self.check!(options)
8
+ unrecognized_options = options.keys - VALID_OPTIONS - VALID_PRIVATE_OPTIONS
9
+ if unrecognized_options.any?
10
+ message = "Unrecognized options passed to create_copy!:\n"
11
+ unrecognized_options.each do |opt|
12
+ message << " #{opt.inspect}\n"
13
+ end
14
+ message << "The options passed to create_copy! can only be one of the following:\n"
15
+ VALID_OPTIONS.each do |opt|
16
+ message << " #{opt.inspect}\n"
17
+ end
18
+ raise CopyableError.new(message)
19
+ end
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,9 @@
1
+ module Copyable
2
+ class Railtie < Rails::Railtie
3
+ railtie_name :copyable
4
+
5
+ rake_tasks do
6
+ load "tasks/copyable.rake"
7
+ end
8
+ end
9
+ end