copyable 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|