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