acts_as_brand_new_copy 1.0.0

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