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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 4c32d813761a0de036a2ca27bc77200ef2d4a2ac
4
+ data.tar.gz: dff39390302c0407009525d15daa493abaf58665
5
+ SHA512:
6
+ metadata.gz: b2943740d29f92450b8c1d62b5973459148606c9b6b05d227232bb9ddb7dcddfafd1091830590556a0105656d3dbe45c7e7fddba652992d50383a131215aecc0
7
+ data.tar.gz: f40e400af14620fa92ecf7d5b1fa783e354ed1e25d4a4726e441efedfe77df69e8ac002652883ce1f6d651b960d47662b9d196fadb9a65cad222904d77e9de63
@@ -0,0 +1,23 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ *.bundle
19
+ *.so
20
+ *.o
21
+ *.a
22
+ mkmf.log
23
+ .idea
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,3 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.0.0
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in copyable.gemspec
4
+ gemspec
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2016 Dennis Chan
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,264 @@
1
+ # Copyable
2
+
3
+ Copyable makes it easy to copy ActiveRecord models.
4
+
5
+ # Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'copyable'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install copyable
18
+
19
+ # Usage
20
+
21
+
22
+ ## Basic Usage
23
+
24
+ Copyable gives you a create_copy! method that you can use to copy ActiveRecord models:
25
+
26
+ isaiah = Book.create!(title: "Isaiah")
27
+ copy_of_isaiah = isaiah.create_copy!
28
+
29
+ Now there are two books called "Isaiah" in the database.
30
+
31
+
32
+
33
+ ## The Copyable Declaration
34
+
35
+ In order for an ActiveRecord model class to have the create_copy! method defined on it, you need to add a copyable declaration:
36
+
37
+ class Book < ActiveRecord::Base
38
+
39
+ copyable do
40
+ ...
41
+ end
42
+
43
+ end
44
+
45
+ The copyable declaration has its own DSL for describing how this model should be copied.
46
+
47
+
48
+
49
+ ## The Columns Declaration
50
+
51
+ The columns declaration specifies how each individual column should be copied:
52
+
53
+ copyable do
54
+ ...
55
+ columns({
56
+ title: :copy,
57
+ isbn: :copy,
58
+ author_id: :copy,
59
+ })
60
+ ...
61
+ end
62
+
63
+ Every column *must* be listed here (with the exception of id, created_at, created_on, updated_at or updated_on).
64
+
65
+ After each column name, give advice on how to copy that column. The advice must be one of the following:
66
+
67
+ * :copy
68
+ * :do_not_copy
69
+ * lambda { |orig| ... }
70
+
71
+ :copy copies the value from the original model. :do_not_copy simply places nil in the column. Using a block lets you calculate the value of the column. The block is passed the original ActiveRecord model object that is being copied.
72
+
73
+ Here's another example:
74
+
75
+ copyable do
76
+ ...
77
+ columns({
78
+ title: lambda { |orig| "Copy of #{orig.title}" },
79
+ isbn: :do_not_copy,
80
+ author_id: :copy,
81
+ })
82
+ ...
83
+ end
84
+
85
+
86
+
87
+ ## The Associations Declaration
88
+
89
+ The associations declaration specifies whether to copy the associated models:
90
+
91
+ copyable do
92
+ ...
93
+ associations({
94
+ pages: :copy,
95
+ pictures: :copy,
96
+ readers: :do_not_copy,
97
+ })
98
+ ...
99
+ end
100
+
101
+ Every association *must* be listed here, with two exceptions:
102
+
103
+ * belongs_to associations must not be listed here. Since belongs_to associations will have a foreign key column, the association will be copied when its column is copied.
104
+ * has_many :through associations must not be listed here, because they are always associated with a related has_many association that will already have been listed.
105
+
106
+ The advice must be one of the following:
107
+
108
+ * :copy
109
+ * :do_not_copy
110
+ * :copy_only_habtm_join_records
111
+
112
+ :copy will iterate through each model in the association, creating a copy. Note that the associated model class must also have a copyable declaration, so that we know how to copy it!
113
+
114
+ :do_not_copy does nothing.
115
+
116
+ :copy_only_habtm_join_records can only be used on has_and_belongs_to_many associations. In fact, you can't use :copy on has_and_belongs_to_many associations. Models associated via habtm are never actually copied, but their associations in the relevant join table can be.
117
+
118
+
119
+
120
+ ## Callbacks
121
+
122
+ It depends on the situation as to whether you would want a particular callback to be fired when a model is copied. Since the logic of callbacks is situational, Copyable makes the decision to completely disable all callbacks and observers for the duration of the create_copy! method. The only exception are callbacks and observers related to validation.
123
+
124
+ To make it easier to reason about the code, and for the sake of being obvious, every copyable declaration *must* include a declaration called disable_all_callbacks_and_observers_except_validate. This declaration itself does not do anything; it exists as documentation.
125
+
126
+ copyable do
127
+ disable_all_callbacks_and_observers_except_validate
128
+ ...
129
+ end
130
+
131
+
132
+
133
+ ## The After Copy Declaration
134
+
135
+ In case you wanted to make sure a particular callback is run, or in case you had some special custom copying behavior, an after_copy declaration is provided that is called after the model has been copied. It is passed the original model and the newly copied model. Note that all callbacks and observers are disabled during the execution of the after_copy block, so you must call them explicitly if you want them to run.
136
+
137
+ copyable do
138
+ ...
139
+ after_copy do |original_model, new_model|
140
+ new_model.foo = "bar"
141
+ new_model.save!
142
+ end
143
+ end
144
+
145
+
146
+
147
+ ## Putting It All Together
148
+
149
+ Here is an example of all four declarations being used in a copyable declaration. Note that all declarations are required except for after_copy.
150
+
151
+ copyable do
152
+ disable_all_callbacks_and_observers_except_validate
153
+ columns({
154
+ title: lambda { |orig| "Copy of #{orig.title}" },
155
+ isbn: :do_not_copy,
156
+ author_id: :copy,
157
+ })
158
+ associations({
159
+ pages: :copy,
160
+ pictures: :copy,
161
+ readers: :do_not_copy,
162
+ })
163
+ after_copy do |original_book, new_book|
164
+ puts "There is now a new book: #{new_book.inspect}."
165
+ end
166
+ end
167
+
168
+
169
+
170
+ ## create_copy!
171
+
172
+ The create_copy! method allows you to override column values by passing in a hash.
173
+
174
+ isaiah = Book.create!(title: "Isaiah")
175
+ copy_of_isaiah = isaiah.create_copy!
176
+ copy_of_isaiah2 = isaiah.create_copy!(override: { title: "Foo" })
177
+
178
+ copy_of_isaiah.title will be "Copy of Isaiah" (or whatever the advice was given in the columns declaration).
179
+
180
+ copy_of_isaiah2.title will be "Foo".
181
+
182
+ Note that you pass in column names only, so if you want to update a belongs to association, you must pass in the column name, not the association name.
183
+
184
+ isaiah.create_copy!(override: { author: "Isaiah" }) # NO, bad programmer
185
+ isaiah.create_copy!(override: { author_id: 34 }) # YES
186
+
187
+ You can skip the running of validations on the copied model and its associated models. While this is not usually advisable, if you are copying a large data structure (which can take a while), you can increase the performance by skipping validations:
188
+
189
+ # turn off validations
190
+ isaiah.create_copy!(skip_validations: true)
191
+
192
+ # turn off validations and override columns
193
+ isaiah.create_copy!(override: { title: "Foo" }, skip_validations: true)
194
+
195
+
196
+
197
+ ## Configuring
198
+
199
+ You can configure copyable's behavior by setting a configuration parameter after you've loaded copyable.
200
+
201
+ Currently copyable only has one configuration setting:
202
+
203
+ Copyable.config.suppress_schema_errors = false
204
+
205
+ This is false by default. Set this to true if you don't want Copyable to complain with a ColumnError or AssociationError if your database schema does not match your copyable declarations.
206
+
207
+ You can also set an environment variable called SUPPRESS_SCHEMA_ERRORS to true.
208
+
209
+
210
+
211
+ ## Design Approach
212
+
213
+ ***Future-proof:*** Since Copyable forces you to declare the copying behavior for each and every column and association, when you add columns or associations to a model you are forced to revisit the copying behavior. This keeps the copying logic up-to-date with the model as it grows and changes.
214
+
215
+ ***Declarative:*** The declarative DSL style, although harder to debug under-the-hood, makes it much easier to work with in the model. It allows you to forget about all of the intricacies and edge cases of ActiveRecord and instead focus on describing the copying logic of your model.
216
+
217
+ ***Helpful Error Messages:*** Because DSLs can be hard to debug, a concerted effort was made to provide clear error messages for user-friendly debugging.
218
+
219
+
220
+
221
+ ## Strengths
222
+
223
+ * handles polymorphic associations
224
+ * create_copy! is run in a database transaction
225
+ * keeps track of which models have already been copied so that it does not re-copy them if it comes across them again (helpful for complex model hierarchies with redundant associations)
226
+
227
+
228
+
229
+ ## Limitations
230
+
231
+ * not thread-safe
232
+ * copying very large data structures may use a lot of memory
233
+ * not designed with performance (CPU or database) in mind
234
+ * had to monkey-patch Rails, so only guaranteed to work with Rails 3.2 at the moment
235
+ * postponed support for single table inheritance until needed
236
+ * postponed support for keeping counter_cache correct until needed
237
+
238
+
239
+
240
+ ## Convenience
241
+
242
+ A rake task is included that will output a basic copyable declaration given a model name. Basically, this saves you some typing.
243
+
244
+ $ rake copyable model=User
245
+
246
+
247
+
248
+ ## Gotchas
249
+
250
+ ### Creating Objects in after_copy
251
+
252
+ copyable keeps track of which models have already been copied so as not to reduplicate models if it comes across the same model through different associations. If you are creating new objects in after_copy! (such as manually copying an association instead of letting copyable do it), you do not have the benefit of copyable's checking whether the models have already been copied and may end up creating too many copies.
253
+
254
+ So the recommended approach is to use after_copy to tweak the columns of the copied record but to avoid creating new records here.
255
+
256
+
257
+
258
+ ## Contributing
259
+
260
+ 1. Fork it ( https://github.com/[my-github-username]/copyable/fork )
261
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
262
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
263
+ 4. Push to the branch (`git push origin my-new-feature`)
264
+ 5. Create a new Pull Request
@@ -0,0 +1,7 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'copyable/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "copyable"
8
+ spec.version = Copyable::VERSION
9
+ spec.authors = ["Wyatt Greene", "Dennis Chan"]
10
+ spec.email = ["dchan@dmcouncil.org"]
11
+ spec.summary = %q{ActiveRecord copier}
12
+ spec.description = %q{Copyable makes it easy to copy ActiveRecord models.}
13
+ spec.homepage = "https://github.com/dmcouncil/copyable"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "activerecord", "= 4.1.12"
22
+
23
+ spec.add_development_dependency "database_cleaner", "~> 1.4.0"
24
+ spec.add_development_dependency "bundler", "~> 1.6"
25
+ spec.add_development_dependency "rake"
26
+ spec.add_development_dependency "rspec"
27
+ spec.add_development_dependency "sqlite3"
28
+ end
@@ -0,0 +1,32 @@
1
+ require "copyable/version"
2
+ require 'active_record'
3
+
4
+ require_relative 'copyable/railtie' if defined?(Rails)
5
+
6
+ require_relative 'copyable/config'
7
+ require_relative 'copyable/copy_registry'
8
+ require_relative 'copyable/exceptions'
9
+ require_relative 'copyable/model_hooks'
10
+ require_relative 'copyable/option_checker'
11
+ require_relative 'copyable/saver'
12
+ require_relative 'copyable/single_copy_enforcer'
13
+
14
+ require_relative 'copyable/declarations/declaration'
15
+ require_relative 'copyable/declarations/after_copy'
16
+ require_relative 'copyable/declarations/associations'
17
+ require_relative 'copyable/declarations/columns'
18
+ require_relative 'copyable/declarations/disable_all_callbacks_and_observers_except_validate'
19
+ require_relative 'copyable/declarations/main'
20
+ require_relative 'copyable/declarations/declarations'
21
+
22
+ require_relative 'copyable/syntax_checking/declaration_stubber'
23
+ require_relative 'copyable/syntax_checking/completeness_checker'
24
+ require_relative 'copyable/syntax_checking/association_checker'
25
+ require_relative 'copyable/syntax_checking/column_checker'
26
+ require_relative 'copyable/syntax_checking/declaration_checker'
27
+ require_relative 'copyable/syntax_checking/syntax_checker'
28
+
29
+ require_relative 'copyable/copyable_extension'
30
+
31
+ # make the copyable declaration available to all ActiveRecord classes
32
+ ActiveRecord::Base.send :include, Copyable::CopyableExtension
@@ -0,0 +1,19 @@
1
+ # Allow users of copyable to alter the behavior.
2
+
3
+ module Copyable
4
+
5
+ class Config < Struct.new(:suppress_schema_errors); end
6
+
7
+ def self.config
8
+ @@config ||= Config.new
9
+ end
10
+
11
+ end
12
+
13
+ if ENV['SUPPRESS_SCHEMA_ERRORS'].nil?
14
+ Copyable.config.suppress_schema_errors = false
15
+ else
16
+ Copyable.config.suppress_schema_errors =
17
+ (ENV['SUPPRESS_SCHEMA_ERRORS'] == 'true' ||
18
+ ENV['SUPPRESS_SCHEMA_ERRORS'] == true)
19
+ end
@@ -0,0 +1,50 @@
1
+ # Refer to the comments in SingleCopyEnforcer to understand why we need
2
+ # this class.
3
+ #
4
+ # Also note that the way this class is implemented, all records being copied
5
+ # are kept in memory. For copying extremely large record trees, memory
6
+ # could be an issue, in which case this algorithm may need refactoring.
7
+ #
8
+ module Copyable
9
+ class CopyRegistry
10
+
11
+ class << self
12
+
13
+ def register(original_record, new_record)
14
+ @registry ||= {}
15
+ key = make_hash(record: original_record)
16
+ @registry[key] = new_record
17
+ end
18
+
19
+ def already_copied?(options)
20
+ fetch_copy(options).present?
21
+ end
22
+
23
+ def fetch_copy(options)
24
+ @registry ||= {}
25
+ key = make_hash(options)
26
+ @registry[key]
27
+ end
28
+
29
+ def clear
30
+ @registry = {}
31
+ end
32
+
33
+ private
34
+
35
+ def make_hash(options)
36
+ if options[:record]
37
+ id = options[:record].id
38
+ klass = options[:record].class
39
+ else
40
+ id = options[:id]
41
+ klass = options[:class]
42
+ end
43
+ raise "Record has no id" if id.nil?
44
+ "#{klass.name}-#{id}"
45
+ end
46
+
47
+ end
48
+
49
+ end
50
+ end