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