acts_as_brand_new_copy 1.0.0

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.
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ ._*
2
+ *.gem
3
+ *.rbc
4
+ .bundle
5
+ .config
6
+ .yardoc
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
19
+ db/*sqlite3
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ language: ruby
2
+ rvm:
3
+ - "1.8.7"
4
+ - "1.9.3"
5
+ script: bundle exec rspec spec
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+ #source 'http://ruby.taobao.org'
3
+
4
+ # Specify your gem's dependencies in acts_as_brand_new_copy.gemspec
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 BenCao
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.
data/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # acts_as_brand_new_copy
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/acts_as_brand_new_copy.png)](http://badge.fury.io/rb/acts_as_brand_new_copy)
4
+ [![Build Status](https://travis-ci.org/bencao/acts_as_brand_new_copy.png)](https://travis-ci.org/bencao/acts_as_brand_new_copy)
5
+
6
+ Copy an active record with its associated records are not easy.
7
+
8
+ For example, if we have defined these classes:
9
+
10
+ ```ruby
11
+ class StudentTeacherAssignment < ActiveRecord::Base
12
+ belongs_to :teacher
13
+ belongs_to :student
14
+ end
15
+
16
+ class GradeTeacherAssignment < ActiveRecord::Base
17
+ belongs_to :grade
18
+ belongs_to :teacher
19
+ end
20
+
21
+ class GradeStudentAssignment < ActiveRecord::Base
22
+ belongs_to :grade
23
+ belongs_to :student
24
+ end
25
+
26
+ class Grade < ActiveRecord::Base
27
+ has_and_belongs_to_many :teachers, :join_table => ::GradeTeacherAssignment.table_name
28
+ has_and_belongs_to_many :students, :join_table => ::GradeStudentAssignment.table_name
29
+ end
30
+
31
+ class Teacher < ActiveRecord::Base
32
+ has_many :student_teacher_assignments
33
+ has_many :students,
34
+ :through => :student_teacher_assignments,
35
+ :source => :student
36
+ end
37
+
38
+ class Student < ActiveRecord::Base
39
+ has_many :student_teacher_assignments
40
+ has_many :teachers,
41
+ :through => :student_teacher_assignments,
42
+ :source => :teacher
43
+ has_many :scores
44
+ end
45
+
46
+ class Score < ActiveRecord::Base
47
+ belongs_to :student
48
+ end
49
+ ```
50
+
51
+ Can you copy a grade with its teachers and students to another grade in a few lines of code?
52
+ To me, it's no, consequently acts_as_brand_new_copy was born.
53
+
54
+ ## Usage
55
+
56
+ ### copy an active record with its associations
57
+
58
+ ```ruby
59
+ # copy student itself, return the id for copied student
60
+ copy_id = @student.brand_new_copy
61
+
62
+ # copy student with their scores
63
+ copy_id = @student.brand_new_copy({:associations => [:scores]})
64
+
65
+ # copy the whole grade and all the relationships between grade to students, teachers to students
66
+ # NOTE here shows the convenience bought by this gem, we've ensured that a same student won't be copied twice!
67
+ copy_id = @grade.brand_new_copy({:associations => [{:teachers => [:students]}, :students]})
68
+ ```
69
+
70
+ ### i'd like to do some modifications to records during copy process
71
+
72
+ Don't worry, we've already supported that!
73
+
74
+ ```
75
+ # prefix student name with a 'Copy Of ' during copy
76
+ # a callback defined as a class method is needed
77
+ Student.class_eval do
78
+ def self.update_name_when_copy(hash_origin, hash_copy, full_context)
79
+ hash_copy['name'] = 'Copy of ' + hash_origin['name']
80
+ true
81
+ end
82
+ end
83
+ copy_id = @student.brand_new_copy({:callbacks => [:update_name_when_copy]})
84
+
85
+ # prefix grade, students, teachers name with 'Copy of ', and reset students score to nil during copy
86
+ [Grade, Teacher, Student].each do |klass|
87
+ klass.class_eval do
88
+ def self.update_name_when_copy(hash_origin, hash_copy, full_context)
89
+ hash_copy['name'] = 'Copy Of ' + hash_origin['name']
90
+ true
91
+ end
92
+ end
93
+ end
94
+
95
+ Score.class_eval do
96
+ def self.reset_value_when_copy(hash_origin, hash_copy, full_context)
97
+ hash_copy['value'] = nil
98
+ true
99
+ end
100
+ end
101
+ copy_id = @grade.brand_new_copy({
102
+ :associations => [{:teachers => [:students]}, {:students => [:scores]}],
103
+ :callbacks => [
104
+ :update_name_when_copy,
105
+ {:teachers => [:update_name_when_copy]},
106
+ {:students => [:update_name_when_copy, {:scores => [:reset_value_when_copy]}]}
107
+ ]
108
+ })
109
+ ```
110
+
111
+ ## Installation
112
+
113
+ Add this line to your application's Gemfile:
114
+
115
+ gem 'acts_as_brand_new_copy'
116
+
117
+ And then execute:
118
+
119
+ $ bundle
120
+
121
+ Or install it yourself as:
122
+
123
+ $ gem install acts_as_brand_new_copy
124
+
125
+ ## Current Limitation
126
+ - do not support has_many_and_belongs_to_many associations when join table class has a strange table_name(I mean, table_name not in [Class.name.underscore, Class.name.underscore.pluralize])
127
+
128
+ ## Contribute
129
+
130
+ You're highly welcome to improve this gem.
131
+
132
+ ### Checkout source code to local
133
+ say you git clone the source code to /tmp/acts_as_brand_new_copy
134
+
135
+ ### Install dev bundle
136
+ ```bash
137
+ $ cd /tmp/acts_as_brand_new_copy
138
+ $ bundle install
139
+ ```
140
+
141
+ ### Do some changes
142
+ ```bash
143
+ $ vi lib/acts_as_brand_new_copy.rb
144
+ ```
145
+
146
+ ### Run test
147
+ ```bash
148
+ $ bundle exec rspec spec
149
+ ```
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'acts_as_brand_new_copy/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "acts_as_brand_new_copy"
8
+ spec.version = ActsAsBrandNewCopy::VERSION
9
+ spec.authors = ["Ben Cao"]
10
+ spec.email = ["benb88@gmail.com"]
11
+ spec.description = "A ruby gem for active record which simplify the copy of very complex tree data."
12
+ spec.summary = "Just give me the object tree specification and callbacks, I will do all the rest for you."
13
+ spec.homepage = "https://github.com/bencao/acts_as_brand_new_copy"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files`.split($/)
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_development_dependency "bundler", "~> 1.3"
22
+ spec.add_development_dependency "rake", "~> 10.0.4"
23
+ spec.add_development_dependency "rspec", "~> 2.13.0"
24
+ spec.add_development_dependency "mocha", "~> 0.13.3"
25
+ spec.add_development_dependency "sqlite3", "~> 1.3.7"
26
+ spec.add_development_dependency "database_cleaner"
27
+ spec.add_development_dependency "factory_girl"
28
+ spec.add_development_dependency "factory_girl_rails"
29
+ spec.add_development_dependency "pry"
30
+ spec.add_development_dependency "pry-theme"
31
+ spec.add_development_dependency "pry-nav"
32
+ spec.add_dependency "activesupport", "~> 3.2.13"
33
+ spec.add_dependency "activerecord", "~> 3.2.13"
34
+ end
data/db/database.yml ADDED
@@ -0,0 +1,5 @@
1
+ development:
2
+ adapter: sqlite3
3
+ database: db/data.sqlite3
4
+ pool: 5
5
+ timeout: 5000
@@ -0,0 +1,439 @@
1
+ require "acts_as_brand_new_copy/version"
2
+
3
+ module ActsAsBrandNewCopy
4
+ extend ActiveSupport::Concern
5
+
6
+ class BrandNewCopyBuilder
7
+
8
+ def initialize(hash_origin, hash_copy)
9
+ @hash_origin = hash_origin
10
+ @hash_copy = hash_copy
11
+ @save_order = calculate_save_order
12
+ @instances = extract_instances
13
+ @queue = prepare_copy_queue
14
+ @full_context = {
15
+ :root_hash_origin => @hash_origin,
16
+ :root_hash_copy => @hash_copy,
17
+ :save_order => @save_order,
18
+ :save_queue => @queue,
19
+ :instances => @instances
20
+ }
21
+ end
22
+
23
+ def invoke_callback(callbacks)
24
+ invoke_callback_recursively([@hash_origin], callbacks)
25
+ end
26
+
27
+ def save
28
+ @queue.each_pair do |klass_name, hash_copy_array|
29
+ next if hash_copy_array.blank?
30
+
31
+ # belongs_to
32
+ hash_copy_array.each{ |hash_copy| update_key_on_self_table(klass_name, hash_copy) }
33
+
34
+ # AN OPTIMIZED APPROACH IS TO BATCH INSERT THE SAME LEVEL
35
+ # BUT NOTE CURRENT IMPLEMENTATION IS IS FOR MYSQL ONLY
36
+ batch_insert_copy(klass_name.constantize, hash_copy_array)
37
+
38
+ # has_one, has_many and has_and_belongs_to_many
39
+ hash_copy_array.each{ |hash_copy| update_key_on_association_tables(klass_name, hash_copy) }
40
+ end
41
+ end
42
+
43
+ def all_saved_instances
44
+ @instances.values
45
+ end
46
+
47
+ private
48
+
49
+ def invoke_callback_recursively(hash_origin_array, callbacks)
50
+ raise 'callbacks option must be an array' unless callbacks.is_a?(Array)
51
+
52
+ hash_origin_array.each_with_index do |hash_origin, index|
53
+ raise 'hash_origin must be an hash' unless hash_origin.is_a?(Hash)
54
+
55
+ hash_klass = hash_origin['klass'].constantize
56
+
57
+ callbacks.each do |callback|
58
+ if callback.is_a?(Symbol)
59
+ unless hash_klass.send(callback, hash_origin, find_object_by_old_id(hash_origin['klass'], hash_origin['id_before_copy']), @full_context)
60
+ raise "run #{callback} callback failed, " +
61
+ " hash_origin=#{find_object_by_old_id(hash_origin['klass'], hash_origin['id_before_copy'])}"
62
+ end
63
+ elsif callback.is_a?(Hash)
64
+ path, path_callbacks = hash_klass.reflect_on_association(callback.keys.first).class_name, callback.values.first
65
+ invoke_callback_recursively(hash_partial_by_path(hash_origin, [path]), path_callbacks)
66
+ else
67
+ raise 'the callback param value must be a symbol or hash'
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def hash_partial_by_path(hash, path)
74
+ path_dup = path.dup
75
+ hash_partial = hash
76
+ hash_partial = hash_partial['associations'][path_dup.shift] while path_dup.present?
77
+ hash_partial
78
+ end
79
+
80
+ def calculate_save_order
81
+ constraints, klasses = Set.new, Set.new
82
+ traverse do |current_hash|
83
+ klasses.add(current_hash['klass']) unless klasses.include?(current_hash['klass'])
84
+
85
+ current_hash['dependencies'].each_pair do |aso_name, aso_dependencies|
86
+ aso_dependencies.each do |aso_dependency|
87
+ aso_dependency['save_order_constraints'].each do |constraint_string|
88
+ front, back = constraint_string.split('_')
89
+ klasses.add(front) unless klasses.include?(front)
90
+ klasses.add(back) unless klasses.include?(back)
91
+ # same active record class
92
+ unless front == back || constraints.include?(constraint_string)
93
+ constraints.add(constraint_string)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ # construct an order which can ensure all constraints are met
101
+ valid_save_order = klasses.to_a.sort
102
+ has_modifications = true
103
+ while has_modifications
104
+ has_modifications = false
105
+ constraints.each do |constraint|
106
+ front, back = constraint.split('_')
107
+ front_index = valid_save_order.index(front)
108
+ back_index = valid_save_order.index(back)
109
+ if front_index > back_index
110
+ valid_save_order[back_index], valid_save_order[front_index] = front, back
111
+ has_modifications = true
112
+ end
113
+ end
114
+ end
115
+ valid_save_order
116
+ end
117
+
118
+ # breadth first
119
+ def traverse(&block)
120
+ not_visit = [@hash_copy]
121
+ while (not_visit.size > 0)
122
+ current_hash = not_visit.shift
123
+ yield current_hash
124
+ current_hash['associations'].each_pair do |aso_name, aso_hash_array|
125
+ aso_hash_array.each{ |aso_hash| not_visit << aso_hash }
126
+ end
127
+ end
128
+ end
129
+
130
+ def extract_instances
131
+ unique_instances = {}
132
+ traverse do |current_hash|
133
+ key = object_key(current_hash['klass'], current_hash['id'])
134
+ if unique_instances.has_key?(key)
135
+ unique_instances[key] = merge_associations_dependencies_for_copy(unique_instances[key], current_hash)
136
+ else
137
+ unique_instances[key] = current_hash
138
+ end
139
+ end
140
+ unique_instances
141
+ end
142
+
143
+ def merge_associations_dependencies_for_copy(hash, another_hash)
144
+ dup = hash.reject{ |k, v| ['associations', 'dependencies'].include?(k) }
145
+
146
+ dup['associations'] = {}
147
+ (hash['associations'].keys + another_hash['associations'].keys).each do |aso_name|
148
+ dup['associations'][aso_name] = (hash['associations'][aso_name] || []) + (another_hash['associations'][aso_name] || [])
149
+ end
150
+
151
+ dup['dependencies'] = {}
152
+ (hash['dependencies'].keys + another_hash['dependencies'].keys).each do |aso_name|
153
+ dup['dependencies'][aso_name] = (hash['dependencies'][aso_name] || []) + (another_hash['dependencies'][aso_name] || [])
154
+ end
155
+
156
+ dup
157
+ end
158
+
159
+ def prepare_copy_queue
160
+ queue = ActiveSupport::OrderedHash.new
161
+ @save_order.each{ |item| queue[item] = [] }
162
+ @instances.each_pair{ |key, hash| queue[hash['klass']] << hash }
163
+ queue
164
+ end
165
+
166
+ def update_key_on_self_table(klass_name, hash_copy)
167
+ hash_copy['dependencies'].each_pair do |aso_name, aso_dependencies|
168
+ aso_dependencies.each do |aso_dependency|
169
+ if aso_dependency['key_position'] == 'self_table'
170
+ foreign_key = aso_dependency['association_key_on_self_table']
171
+ update_object_by_old_id(klass_name, hash_copy['id'], {
172
+ foreign_key => new_id(aso_name, hash_copy[foreign_key])
173
+ })
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ def update_key_on_association_tables(klass_name, hash_copy)
180
+ hash_copy['dependencies'].each_pair do |aso_name, aso_dependencies|
181
+ aso_dependencies.each do |aso_dependency|
182
+ if aso_dependency['key_position'] == 'association_table'
183
+ aso_foreign_key = aso_dependency['self_key_on_association_table']
184
+ hash_copy['associations'][aso_name].each do |instance|
185
+ update_object_by_old_id(aso_name, instance['id'], {
186
+ aso_foreign_key => new_id(klass_name, instance[aso_foreign_key])
187
+ })
188
+ end
189
+ elsif aso_dependency['key_position'] == 'join_table'
190
+ foreign_key_to_self = aso_dependency['self_key_on_join_table']
191
+ foreign_key_to_association = aso_dependency['association_key_on_join_table']
192
+ join_table_instances = hash_copy['associations'][aso_dependency['join_table_class']]
193
+ join_table_instances.each do |instance|
194
+ update_object_by_old_id(aso_dependency['join_table_class'], instance['id'], {
195
+ foreign_key_to_self => new_id(klass_name, instance[foreign_key_to_self]),
196
+ foreign_key_to_association => new_id(aso_name, instance[foreign_key_to_association])
197
+ })
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+
204
+ def do_insert(klass, columns, hash_copies)
205
+ connection = klass.connection
206
+ values_string = hash_copies.map do |hash_copy|
207
+ quoted_copy_values = columns.map do |column|
208
+ case column.name
209
+ when 'updated_at', 'created_at'
210
+ 'NOW()'
211
+ else
212
+ connection.quote(hash_copy[column.name], column)
213
+ end
214
+ end
215
+ "(#{quoted_copy_values.join(',')})"
216
+ end.join(',')
217
+ result = connection.execute("INSERT INTO #{klass.table_name} (#{columns.map(&:name).join(',')}) VALUES #{values_string}")
218
+ connection.last_inserted_id(result)
219
+ end
220
+
221
+ def batch_insert_copy_auto_generate_ids(klass, hash_copy_slice)
222
+ columns = klass.columns.reject {|k, v| 'id' == k.name}
223
+ last_id = do_insert(klass, columns, hash_copy_slice)
224
+ first_id = last_id - hash_copy_slice.size + 1
225
+ hash_copy_slice.each_with_index{ |hash_copy, index| hash_copy['id'] = first_id + index }
226
+ end
227
+
228
+ def batch_insert_copy(klass, hash_copy_array)
229
+ # in case of array too large
230
+ hash_copy_array.each_slice(50) do |hash_copy_slice|
231
+ batch_insert_copy_auto_generate_ids(klass, hash_copy_slice)
232
+ end
233
+ end
234
+
235
+ def self.object_key(klass, id)
236
+ absolute_klass_name = klass.start_with?("::") ? "#{klass}" : "::#{klass}"
237
+ absolute_klass_name.constantize.table_name + "_#{id}"
238
+ end
239
+
240
+ def object_key(klass, id)
241
+ self.class.object_key(klass, id)
242
+ end
243
+
244
+ def find_object_by_old_id(klass_name, old_id)
245
+ @instances[object_key(klass_name, old_id)]
246
+ end
247
+
248
+ def new_id(klass_name, old_id)
249
+ copy_object = find_object_by_old_id(klass_name, old_id)
250
+ return nil if copy_object.nil?
251
+ raise 'copy object not saved to db!' if copy_object['id'] == copy_object['id_before_copy']
252
+ copy_object['id']
253
+ end
254
+
255
+ def update_object_by_old_id(klass_name, old_id, new_attributes)
256
+ copy_object = find_object_by_old_id(klass_name, old_id)
257
+ new_attributes.each_pair do |key, value|
258
+ copy_object[key] = value
259
+ end
260
+ if copy_object['id'] != copy_object['id_before_copy']
261
+ # object already saved, this may happens when the same level copies has dependencies
262
+ update_inserted_copy(copy_object['klass'].constantize, copy_object['id'], new_attributes)
263
+ end
264
+ end
265
+
266
+ def update_inserted_copy(klass, id, new_attributes)
267
+ sub_conditions = []
268
+ new_attributes.each_pair do |key, value|
269
+ sub_conditions << "#{key}=#{value}"
270
+ end
271
+ klass.connection.execute("UPDATE #{klass.table_name} SET #{sub_conditions.join(',')} WHERE id=#{id}")
272
+ end
273
+
274
+ end
275
+
276
+ module ClassMethods
277
+ def brand_new_copy_object_key(klass, id)
278
+ BrandNewCopyBuilder.object_key(klass, id)
279
+ end
280
+
281
+ def brand_new_copy_guess_join_table_class(join_table_name)
282
+ final_name = [join_table_name.singularize, join_table_name.pluralize].detect do |possible_name|
283
+ Object.const_defined?(possible_name.camelize)
284
+ end
285
+ if final_name
286
+ final_name.camelize
287
+ else
288
+ raise "Do not support has_and_belongs_to_many associations" +
289
+ "that can not guess join table class from join table name"
290
+ end
291
+ end
292
+ end
293
+
294
+ def quoted_attributes_for_copy
295
+ quoted_attributes = {}
296
+ attributes.each_pair do |key, value|
297
+ case value.class.to_s
298
+ when "Date", "Time", "ActiveSupport::TimeWithZone"
299
+ quoted_attributes[key] = connection.quoted_date(value)
300
+ when "BigDecimal"
301
+ quoted_attributes[key] = value.to_s("F")
302
+ else
303
+ quoted_attributes[key] = value
304
+ end
305
+ end
306
+ # in case the table has no id
307
+ unless quoted_attributes.has_key?('id')
308
+ quoted_attributes['id'] = quoted_attributes.values.join('_')
309
+ end
310
+ quoted_attributes['id_before_copy'] = quoted_attributes['id']
311
+ quoted_attributes['klass'] = self.class.to_s
312
+ quoted_attributes
313
+ end
314
+
315
+ # sample associations = [:billing_term_condition, {:line_items => [:targeting_criteria]}, :resource_user_assignments]
316
+ def serialize_hash_for_copy(associations=nil)
317
+ associations = [] if associations.nil?
318
+ raise "associations param (#{associations}) must be inside an array" unless associations.is_a?(Array)
319
+
320
+ result = quoted_attributes_for_copy.merge({
321
+ 'associations' => {},
322
+ 'dependencies' => {}
323
+ })
324
+
325
+ associations.uniq.each do |association|
326
+ if association.is_a?(Symbol)
327
+ aso_name_underscore, aso_instances, aso_option = association.to_s, self.send(association), nil
328
+ elsif association.is_a?(Hash)
329
+ aso_name_underscore, aso_instances, aso_option = association.keys.first.to_s, self.send(association.keys.first), association.values.first
330
+ else
331
+ raise 'association param value must be a symbol or hash'
332
+ end
333
+
334
+ reflection = self.class.reflect_on_association(aso_name_underscore.to_sym)
335
+ unless result['dependencies'].has_key?(reflection.class_name)
336
+ result['dependencies'][reflection.class_name] = []
337
+ end
338
+ result['dependencies'][reflection.class_name] << resolve_copy_dependencies(reflection)
339
+
340
+ unless result['associations'].has_key?(reflection.class_name)
341
+ result['associations'][reflection.class_name] = if aso_instances.is_a?(Array)
342
+ aso_instances.map{ |ass| ass.serialize_hash_for_copy(aso_option) }
343
+ else
344
+ aso_instances.present? ? [aso_instances.serialize_hash_for_copy(aso_option)] : []
345
+ end
346
+ end
347
+
348
+ # has_and_belongs_to_many and has_one(through), has_many(through) may introduce an implicit association
349
+ implicit_association_class, implicit_association_hash = implicit_join_table_association(reflection)
350
+ if implicit_association_class.present? && (not result['associations'].has_key?(implicit_association_class))
351
+ result['associations'][implicit_association_class] = implicit_association_hash
352
+ end
353
+ end
354
+ result
355
+ end
356
+
357
+ def resolve_copy_dependencies(reflection)
358
+ self_class = reflection.active_record.to_s
359
+ association_class = reflection.class_name
360
+ if (reflection.through_reflection.present?)
361
+ join_table_class = reflection.through_reflection.class_name
362
+ # has_one through OR has_many through
363
+ {
364
+ 'key_position' => 'join_table',
365
+ 'save_order_constraints' => [
366
+ "#{self_class}_#{join_table_class}",
367
+ "#{association_class}_#{join_table_class}",
368
+ "#{association_class}_#{self_class}"
369
+ ],
370
+ 'join_table_class' => join_table_class,
371
+ 'self_key_on_join_table' => reflection.through_reflection.foreign_key,
372
+ 'association_key_on_join_table' => reflection.source_reflection.foreign_key
373
+ }
374
+ else
375
+ case (reflection.macro)
376
+ when :has_and_belongs_to_many
377
+ join_table_class = self.class.brand_new_copy_guess_join_table_class(reflection.options[:join_table])
378
+ {
379
+ 'key_position' => 'join_table',
380
+ 'save_order_constraints' => [
381
+ "#{self_class}_#{join_table_class}",
382
+ "#{association_class}_#{join_table_class}",
383
+ "#{association_class}_#{self_class}"
384
+ ],
385
+ 'join_table_class' => join_table_class,
386
+ 'self_key_on_join_table' => reflection.foreign_key,
387
+ 'association_key_on_join_table' => reflection.association_foreign_key
388
+ }
389
+ when :has_one, :has_many
390
+ {
391
+ 'key_position' => 'association_table',
392
+ 'save_order_constraints' => ["#{self_class}_#{association_class}"],
393
+ 'self_key_on_association_table' => reflection.foreign_key
394
+ }
395
+ when :belongs_to
396
+ {
397
+ 'key_position' => 'self_table',
398
+ 'save_order_constraints' => ["#{association_class}_#{self_class}"],
399
+ 'association_key_on_self_table' => reflection.foreign_key
400
+ }
401
+ else
402
+ raise 'should not have reflection macro other than belongs_to has_and_belongs_to_many has_one has_many'
403
+ end
404
+ end
405
+ end
406
+
407
+ def implicit_join_table_association(reflection)
408
+ # belongs_to, has_one(without through), has_many(without through) have no implicit join table
409
+ return nil, nil if (reflection.through_reflection.blank? && reflection.macro != :has_and_belongs_to_many)
410
+
411
+ if reflection.macro == :has_and_belongs_to_many
412
+ join_table_class = self.class.brand_new_copy_guess_join_table_class(reflection.options[:join_table])
413
+ self_key_on_join_table = reflection.foreign_key
414
+ else
415
+ join_table_class = reflection.through_reflection.class_name
416
+ self_key_on_join_table = reflection.through_reflection.foreign_key
417
+ end
418
+ join_table_instances = join_table_class.constantize.where("#{self_key_on_join_table} = #{self.id}").to_a
419
+
420
+ return join_table_class, join_table_instances.map{ |instance| instance.serialize_hash_for_copy(nil) }
421
+ end
422
+
423
+ def brand_new_copy(options={})
424
+ final_options = {:callbacks => [], :associations => nil}.merge(options)
425
+
426
+ eager_loaded_self = self.class.includes(final_options[:associations]).find(id)
427
+
428
+ hash_origin = eager_loaded_self.serialize_hash_for_copy(final_options[:associations])
429
+ hash_copy = JSON.parse(hash_origin.to_json) # a way to do deep clone
430
+
431
+ builder = BrandNewCopyBuilder.new(hash_origin, hash_copy)
432
+ builder.invoke_callback(final_options[:callbacks])
433
+ builder.save
434
+
435
+ return hash_copy['id']
436
+ end
437
+ end
438
+
439
+ ActiveRecord::Base.send(:include, ActsAsBrandNewCopy)
@@ -0,0 +1,3 @@
1
+ module ActsAsBrandNewCopy
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,252 @@
1
+ require 'spec_helper'
2
+ require 'acts_as_brand_new_copy'
3
+
4
+ class Schema < ActiveRecord::Migration
5
+ def change
6
+ create_table :grades do |t|
7
+ t.string :name
8
+ end
9
+
10
+ create_table :teachers do |t|
11
+ t.string :name
12
+ end
13
+
14
+ create_table :students do |t|
15
+ t.string :name
16
+ end
17
+
18
+ create_table :scores do |t|
19
+ t.string :value
20
+ t.references :student
21
+ end
22
+
23
+ create_table :grade_teacher_assignments, :id => false do |t|
24
+ t.references :grade
25
+ t.references :teacher
26
+ end
27
+
28
+ create_table :grade_student_assignments, :id => false do |t|
29
+ t.references :grade
30
+ t.references :student
31
+ end
32
+
33
+ create_table :student_teacher_assignments do |t|
34
+ t.references :teacher
35
+ t.references :student
36
+ end
37
+ end
38
+
39
+ end
40
+
41
+ Schema.new.change
42
+
43
+ class StudentTeacherAssignment < ActiveRecord::Base
44
+ belongs_to :teacher
45
+ belongs_to :student
46
+ end
47
+
48
+ class GradeTeacherAssignment < ActiveRecord::Base
49
+ belongs_to :grade
50
+ belongs_to :teacher
51
+ end
52
+
53
+ class GradeStudentAssignment < ActiveRecord::Base
54
+ belongs_to :grade
55
+ belongs_to :student
56
+ end
57
+
58
+ class Grade < ActiveRecord::Base
59
+ has_and_belongs_to_many :teachers, :join_table => ::GradeTeacherAssignment.table_name
60
+ has_and_belongs_to_many :students, :join_table => ::GradeStudentAssignment.table_name
61
+ end
62
+
63
+ class Teacher < ActiveRecord::Base
64
+ has_many :student_teacher_assignments
65
+ has_many :students,
66
+ :through => :student_teacher_assignments,
67
+ :source => :student
68
+ end
69
+
70
+ class Student < ActiveRecord::Base
71
+ has_many :student_teacher_assignments
72
+ has_many :teachers,
73
+ :through => :student_teacher_assignments,
74
+ :source => :teacher
75
+ has_many :scores
76
+ end
77
+
78
+ class Score < ActiveRecord::Base
79
+ belongs_to :student
80
+ end
81
+
82
+
83
+ FactoryGirl.define do
84
+ factory :grade do
85
+ sequence(:name) {|n| "Grade #{n}"}
86
+ end
87
+
88
+ factory :teacher do
89
+ sequence(:name) {|n| "Teacher #{n}"}
90
+ end
91
+
92
+ factory :student do
93
+ sequence(:name) {|n| "Student #{n}"}
94
+
95
+ trait :with_scores do
96
+ after(:create) do |student, evaluator|
97
+ FactoryGirl.create(:score, {:student => student, :value => "70"})
98
+ FactoryGirl.create(:score, {:student => student, :value => "80"})
99
+ end
100
+ end
101
+ end
102
+
103
+ factory :score do
104
+ end
105
+ end
106
+
107
+ describe ActsAsBrandNewCopy do
108
+ context "copy associations" do
109
+ it "should copy self" do
110
+ @student = create(:student)
111
+ copy_id = @student.brand_new_copy
112
+ copied_student = Student.find copy_id
113
+ copied_student.name.should == @student.name
114
+ copied_student.id.should_not == @student.id
115
+ end
116
+
117
+ it "should copy belongs_to associations" do
118
+ # NOTE although brand new copy support belongs_to association copy
119
+ # it's not common in real world that we need to copy belongs_to association
120
+ # You should avoid doing that or you should check your design
121
+ @student = create(:student, :with_scores)
122
+ @score1 = @student.scores.first
123
+
124
+ copy_id = @score1.brand_new_copy({:associations => [:student]})
125
+
126
+ copied_score = Score.find(copy_id)
127
+
128
+ copied_score.student.name.should == @student.name
129
+ copied_score.student.id.should_not == @student.id
130
+ end
131
+
132
+ it "should copy has_many associations" do
133
+ @student = create(:student, :with_scores)
134
+ @score1, @score2 = @student.scores
135
+
136
+ copy_id = @student.brand_new_copy({:associations => [:scores]})
137
+
138
+ copied_student = Student.find copy_id
139
+ copied_student.scores.size.should == @student.scores.size
140
+ copied_student.scores.map(&:value).sort.should == @student.scores.map(&:value).sort
141
+ (copied_student.scores.map(&:id) - @student.scores.map(&:id)).size.should == 2
142
+ end
143
+
144
+ it "should copy has_many through associations" do
145
+ @teacher = create(:teacher)
146
+ @student1, @student2 = create_list(:student, 2)
147
+ @teacher.students << @student1
148
+ @teacher.students << @student2
149
+
150
+ copy_id = @teacher.brand_new_copy({:associations => [:students]})
151
+ copied_teacher = Teacher.find copy_id
152
+ copied_teacher.students.size.should == @teacher.students.size
153
+ copied_teacher.students.map(&:name).sort.should == @teacher.students.map(&:name).sort
154
+ (copied_teacher.students.map(&:id) - @teacher.students.map(&:id)).size.should == 2
155
+ end
156
+
157
+ it "should copy has_and_belongs_to_many associations" do
158
+ @grade = create(:grade)
159
+ @student1, @student2 = create_list(:student, 2)
160
+ @grade.students << @student1
161
+ @grade.students << @student2
162
+
163
+ copy_id = @grade.brand_new_copy({:associations => [:students]})
164
+ copied_grade = Grade.find copy_id
165
+ copied_grade.students.size.should == @grade.students.size
166
+ copied_grade.students.map(&:name).sort.should == @grade.students.map(&:name).sort
167
+ (copied_grade.students.map(&:id) - @grade.students.map(&:id)).size.should == 2
168
+ end
169
+
170
+ it "should not copy a same association instance twice" do
171
+ @grade = create(:grade)
172
+ @teacher = create(:teacher)
173
+ @grade.teachers << @teacher
174
+ @student1, @student2 = create_list(:student, 2)
175
+ @grade.students << @student1
176
+ @grade.students << @student2
177
+ @teacher.students << @student1
178
+ @teacher.students << @student2
179
+
180
+ copy_id = @grade.brand_new_copy({:associations => [{:teachers => [:students]}, :students]})
181
+ copied_grade = Grade.find copy_id
182
+ copied_grade.students.size.should == @grade.students.size
183
+ (copied_grade.students.map(&:id) - @grade.students.map(&:id)).size.should == 2
184
+ copied_grade.teachers.size.should == @grade.teachers.size
185
+ copied_grade.students.map(&:name).sort.should == @grade.students.map(&:name).sort
186
+ Student.where(:name => @student1.name).size.should == 2
187
+ Student.where(:name => @student2.name).size.should == 2
188
+ copied_grade.teachers.first.students.size.should == @teacher.students.size
189
+ copied_grade.teachers.first.students.map(&:name).sort.should == @teacher.students.map(&:name).sort
190
+ end
191
+
192
+ end
193
+
194
+ context "callbacks" do
195
+ [Grade, Teacher, Student].each do |klass|
196
+ klass.class_eval do
197
+ def self.update_name_when_copy(hash_origin, hash_copy, full_context)
198
+ hash_copy['name'] = copy_of_name(hash_origin['name'])
199
+ true
200
+ end
201
+
202
+ def self.copy_of_name(name)
203
+ 'Copy of ' + name
204
+ end
205
+ end
206
+ end
207
+
208
+ Score.class_eval do
209
+ def self.reset_value_when_copy(hash_origin, hash_copy, full_context)
210
+ hash_copy['value'] = nil
211
+ true
212
+ end
213
+ end
214
+
215
+ it "should invoke callbacks on self" do
216
+ @student = create(:student)
217
+ copy_id = @student.brand_new_copy({:callbacks => [:update_name_when_copy]})
218
+ copied_student = Student.find copy_id
219
+ copied_student.name.should == Student.copy_of_name(@student.name)
220
+ end
221
+
222
+ it "should invoke callbacks on associations" do
223
+ @grade = create(:grade)
224
+ @teacher = create(:teacher)
225
+ @grade.teachers << @teacher
226
+ @student1, @student2 = create_list(:student, 2, :with_scores)
227
+ @grade.students << @student1
228
+ @grade.students << @student2
229
+ @teacher.students << @student1
230
+ @teacher.students << @student2
231
+
232
+ copy_id = @grade.brand_new_copy({
233
+ :associations => [{:teachers => [:students]}, {:students => [:scores]}],
234
+ :callbacks => [
235
+ :update_name_when_copy,
236
+ {:teachers => [:update_name_when_copy]},
237
+ {:students => [:update_name_when_copy, {:scores => [:reset_value_when_copy]}]}
238
+ ]
239
+ })
240
+ copied_grade = Grade.find copy_id
241
+ copied_grade.name.should == Grade.copy_of_name(@grade.name)
242
+ copied_grade.teachers.first.name.should == Teacher.copy_of_name(@teacher.name)
243
+ copied_grade.students.map(&:name).sort.should == [
244
+ Student.copy_of_name(@student1.name),
245
+ Student.copy_of_name(@student2.name)
246
+ ].sort
247
+ copied_grade.students.map(&:scores).flatten.map(&:value).compact.should be_blank
248
+ end
249
+
250
+ end
251
+
252
+ end
@@ -0,0 +1,33 @@
1
+ require 'active_support'
2
+ require 'active_record'
3
+ require 'sqlite3'
4
+ require 'pry'
5
+ require 'database_cleaner'
6
+ require 'factory_girl'
7
+
8
+ db_config = YAML::load(IO.read('db/database.yml'))
9
+ db_file = db_config['development']['database']
10
+ File.delete(db_file) if File.exists?(db_file)
11
+ ActiveRecord::Base.configurations = db_config
12
+ ActiveRecord::Base.establish_connection('development')
13
+
14
+ RSpec.configure do |config|
15
+ # == Mock Framework
16
+ # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line:
17
+ config.mock_with :mocha
18
+
19
+ config.before(:suite) do
20
+ DatabaseCleaner.strategy = :transaction
21
+ DatabaseCleaner.clean_with(:truncation)
22
+ end
23
+
24
+ config.before(:each) do
25
+ DatabaseCleaner.start
26
+ end
27
+
28
+ config.after(:each) do
29
+ DatabaseCleaner.clean
30
+ end
31
+
32
+ config.include FactoryGirl::Syntax::Methods
33
+ end
metadata ADDED
@@ -0,0 +1,270 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_brand_new_copy
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Ben Cao
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-11-03 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '1.3'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: '1.3'
30
+ - !ruby/object:Gem::Dependency
31
+ name: rake
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: 10.0.4
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: 10.0.4
46
+ - !ruby/object:Gem::Dependency
47
+ name: rspec
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ~>
52
+ - !ruby/object:Gem::Version
53
+ version: 2.13.0
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ version: 2.13.0
62
+ - !ruby/object:Gem::Dependency
63
+ name: mocha
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ~>
68
+ - !ruby/object:Gem::Version
69
+ version: 0.13.3
70
+ type: :development
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ~>
76
+ - !ruby/object:Gem::Version
77
+ version: 0.13.3
78
+ - !ruby/object:Gem::Dependency
79
+ name: sqlite3
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ~>
84
+ - !ruby/object:Gem::Version
85
+ version: 1.3.7
86
+ type: :development
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ~>
92
+ - !ruby/object:Gem::Version
93
+ version: 1.3.7
94
+ - !ruby/object:Gem::Dependency
95
+ name: database_cleaner
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: factory_girl
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: factory_girl_rails
128
+ requirement: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ type: :development
135
+ prerelease: false
136
+ version_requirements: !ruby/object:Gem::Requirement
137
+ none: false
138
+ requirements:
139
+ - - ! '>='
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ - !ruby/object:Gem::Dependency
143
+ name: pry
144
+ requirement: !ruby/object:Gem::Requirement
145
+ none: false
146
+ requirements:
147
+ - - ! '>='
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ type: :development
151
+ prerelease: false
152
+ version_requirements: !ruby/object:Gem::Requirement
153
+ none: false
154
+ requirements:
155
+ - - ! '>='
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ - !ruby/object:Gem::Dependency
159
+ name: pry-theme
160
+ requirement: !ruby/object:Gem::Requirement
161
+ none: false
162
+ requirements:
163
+ - - ! '>='
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ none: false
170
+ requirements:
171
+ - - ! '>='
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ - !ruby/object:Gem::Dependency
175
+ name: pry-nav
176
+ requirement: !ruby/object:Gem::Requirement
177
+ none: false
178
+ requirements:
179
+ - - ! '>='
180
+ - !ruby/object:Gem::Version
181
+ version: '0'
182
+ type: :development
183
+ prerelease: false
184
+ version_requirements: !ruby/object:Gem::Requirement
185
+ none: false
186
+ requirements:
187
+ - - ! '>='
188
+ - !ruby/object:Gem::Version
189
+ version: '0'
190
+ - !ruby/object:Gem::Dependency
191
+ name: activesupport
192
+ requirement: !ruby/object:Gem::Requirement
193
+ none: false
194
+ requirements:
195
+ - - ~>
196
+ - !ruby/object:Gem::Version
197
+ version: 3.2.13
198
+ type: :runtime
199
+ prerelease: false
200
+ version_requirements: !ruby/object:Gem::Requirement
201
+ none: false
202
+ requirements:
203
+ - - ~>
204
+ - !ruby/object:Gem::Version
205
+ version: 3.2.13
206
+ - !ruby/object:Gem::Dependency
207
+ name: activerecord
208
+ requirement: !ruby/object:Gem::Requirement
209
+ none: false
210
+ requirements:
211
+ - - ~>
212
+ - !ruby/object:Gem::Version
213
+ version: 3.2.13
214
+ type: :runtime
215
+ prerelease: false
216
+ version_requirements: !ruby/object:Gem::Requirement
217
+ none: false
218
+ requirements:
219
+ - - ~>
220
+ - !ruby/object:Gem::Version
221
+ version: 3.2.13
222
+ description: A ruby gem for active record which simplify the copy of very complex
223
+ tree data.
224
+ email:
225
+ - benb88@gmail.com
226
+ executables: []
227
+ extensions: []
228
+ extra_rdoc_files: []
229
+ files:
230
+ - .gitignore
231
+ - .travis.yml
232
+ - Gemfile
233
+ - LICENSE.txt
234
+ - README.md
235
+ - Rakefile
236
+ - acts_as_brand_new_copy.gemspec
237
+ - db/database.yml
238
+ - lib/acts_as_brand_new_copy.rb
239
+ - lib/acts_as_brand_new_copy/version.rb
240
+ - spec/acts_as_brand_new_copy_spec.rb
241
+ - spec/spec_helper.rb
242
+ homepage: https://github.com/bencao/acts_as_brand_new_copy
243
+ licenses:
244
+ - MIT
245
+ post_install_message:
246
+ rdoc_options: []
247
+ require_paths:
248
+ - lib
249
+ required_ruby_version: !ruby/object:Gem::Requirement
250
+ none: false
251
+ requirements:
252
+ - - ! '>='
253
+ - !ruby/object:Gem::Version
254
+ version: '0'
255
+ required_rubygems_version: !ruby/object:Gem::Requirement
256
+ none: false
257
+ requirements:
258
+ - - ! '>='
259
+ - !ruby/object:Gem::Version
260
+ version: '0'
261
+ requirements: []
262
+ rubyforge_project:
263
+ rubygems_version: 1.8.25
264
+ signing_key:
265
+ specification_version: 3
266
+ summary: Just give me the object tree specification and callbacks, I will do all the
267
+ rest for you.
268
+ test_files:
269
+ - spec/acts_as_brand_new_copy_spec.rb
270
+ - spec/spec_helper.rb