lib/acts_as_brand_new_copy/builder.rb
require 'json'
require 'acts_as_brand_new_copy/merge'
require 'acts_as_brand_new_copy/standard'
module ActsAsBrandNewCopy
class Builder
def initialize(serialized_hash)
@hash_origin = serialized_hash
@hash_copy = JSON.parse(serialized_hash.to_json) # a way to do deep clone
@save_order = calculate_save_order
@instances = extract_instances
@queue = prepare_copy_queue
@full_context = {
:save_order => @save_order,
:save_queue => @queue,
:instances => @instances
}
end
def invoke_callback(callbacks)
invoke_callback_recursively([@hash_origin], callbacks)
end
def save
@queue.each_pair do |klass_name, hash_copy_array|
next if hash_copy_array.blank?
# belongs_to
hash_copy_array.each{ |hash_copy| update_key_on_self_table(klass_name, hash_copy) }
# AN OPTIMIZED APPROACH IS TO BATCH INSERT THE SAME LEVEL
# BUT NOTE CURRENT IMPLEMENTATION ONLY HANDLED MYSQL SYNTAX
batch_insert_copy(klass_name.constantize, hash_copy_array)
# has_one, has_many and has_and_belongs_to_many
hash_copy_array.each{ |hash_copy| update_key_on_association_tables(klass_name, hash_copy) }
end
end
def all_saved_instances
@instances.values
end
def new_id(klass_name, old_id)
copy_object = find_object_by_old_id(klass_name, old_id)
return nil if copy_object.nil?
if copy_object['id'] == copy_object['id_before_copy']
raise 'copy object not saved to db!'
end
copy_object['id']
end
private
def invoke_callback_recursively(hash_origin_array, callbacks)
hash_origin_array.each do |hash_origin|
callbacks.each do |callback|
if callback.is_a?(Symbol)
invoke_single_callback(callback, hash_origin)
else
child_association = callback.keys.first
child_callbacks = callback.values.first
invoke_callback_recursively(
hash_child(hash_origin, child_association),
child_callbacks
)
end
end
end
end
def invoke_single_callback(callback, hash_origin)
hash_klass = hash_origin['klass'].constantize
hash_copy = find_object_by_old_id(hash_origin['klass'], hash_origin['id_before_copy'])
unless hash_klass.send(callback, hash_origin, hash_copy, @full_context)
raise "run callback '#{callback}' failed on #{Standard.object_hash_to_s(hash_origin)}"
end
end
def hash_child(hash_origin, association)
hash_klass = hash_origin['klass'].constantize
name = Standard.association_klass_name(hash_klass, association)
hash_origin['associations'][name]
end
def calculate_save_order
constraints, klasses = Set.new, Set.new
traverse do |current_hash|
klasses.add(current_hash['klass']) unless klasses.include?(current_hash['klass'])
current_hash['dependencies'].each_pair do |_, aso_dependencies|
aso_dependencies.each do |aso_dependency|
aso_dependency['save_order_constraints'].each do |constraint_string|
front, back = constraint_string.split('_')
klasses.add(front) unless klasses.include?(front)
klasses.add(back) unless klasses.include?(back)
# same active record class
unless front == back || constraints.include?(constraint_string)
constraints.add(constraint_string)
end
end
end
end
end
# construct an order which can ensure all constraints are met
valid_save_order = klasses.to_a.sort
has_modifications = true
while has_modifications
has_modifications = false
constraints.each do |constraint|
front, back = constraint.split('_')
front_index = valid_save_order.index(front)
back_index = valid_save_order.index(back)
if front_index > back_index
valid_save_order[back_index], valid_save_order[front_index] = front, back
has_modifications = true
end
end
end
valid_save_order
end
# breadth first
def traverse
not_visit = [@hash_copy]
while (not_visit.size > 0)
current_hash = not_visit.shift
yield current_hash
current_hash['associations'].each_pair do |_, aso_hash_array|
aso_hash_array.each{ |aso_hash| not_visit << aso_hash }
end
end
end
def extract_instances
unique_instances = {}
traverse do |current_hash|
key = Standard.object_key(current_hash['klass'], current_hash['id'])
if unique_instances.key?(key)
unique_instances[key] = merge_associations_dependencies(unique_instances[key], current_hash)
else
unique_instances[key] = current_hash
end
end
unique_instances
end
def merge_associations_dependencies(hash, another_hash)
dup = hash.reject { |k, _| ['associations', 'dependencies'].include?(k) }
dup['associations'] = Merge.deep_merge(hash['associations'], another_hash['associations'])
dup['dependencies'] = Merge.deep_merge(hash['dependencies'], another_hash['dependencies'])
dup
end
def prepare_copy_queue
queue = ActiveSupport::OrderedHash.new
@save_order.each{ |item| queue[item] = [] }
@instances.each_pair{ |_, hash| queue[hash['klass']] << hash }
queue
end
def update_key_on_self_table(klass_name, hash_copy)
hash_copy['dependencies'].each_pair do |aso_name, aso_dependencies|
aso_dependencies.each do |aso_dependency|
if aso_dependency['key_position'] == 'self_table'
foreign_key = aso_dependency['association_key_on_self_table']
update_object_by_old_id(klass_name, hash_copy['id'], {
foreign_key => new_id(aso_name, hash_copy[foreign_key])
})
end
end
end
end
def update_key_on_association_tables(klass_name, hash_copy)
hash_copy['dependencies'].each_pair do |aso_name, aso_dependencies|
aso_dependencies.each do |aso_dependency|
if aso_dependency['key_position'] == 'association_table'
aso_foreign_key = aso_dependency['self_key_on_association_table']
hash_copy['associations'][aso_name].each do |instance|
update_object_by_old_id(aso_name, instance['id'], {
aso_foreign_key => new_id(klass_name, instance[aso_foreign_key])
})
end
elsif aso_dependency['key_position'] == 'join_table'
foreign_key_to_self = aso_dependency['self_key_on_join_table']
foreign_key_to_association = aso_dependency['association_key_on_join_table']
join_table_instances = hash_copy['associations'][aso_dependency['join_table_class']]
join_table_instances.each do |instance|
update_object_by_old_id(aso_dependency['join_table_class'], instance['id'], {
foreign_key_to_self => new_id(klass_name, instance[foreign_key_to_self]),
foreign_key_to_association => new_id(aso_name, instance[foreign_key_to_association])
})
end
end
end
end
end
def do_insert(klass, columns, hash_copies)
connection = klass.connection
value_list = hash_copies.map do |hash_copy|
columns.map do |column|
case column.name
when 'updated_at', 'created_at'
'NOW()'
else
connection.quote(hash_copy[column.name], column)
end
end
end
column_list = columns.map do |column|
connection.quote_column_name(column.name)
end
adapter = ActiveRecord::Base.connection.instance_values["config"][:adapter]
sql =
if adapter.start_with?("sqlite")
sqlite_batch_insert(connection.quote_table_name(klass.table_name), column_list, value_list)
else
mysql_batch_insert(connection.quote_table_name(klass.table_name), column_list, value_list)
end
result = connection.execute(sql)
connection.last_inserted_id(result)
end
def mysql_batch_insert(table_name, column_list, value_list)
<<-eos
INSERT INTO #{table_name} (#{column_list.join(', ')})
VALUES #{value_list.map{ |v| "(#{v.join(', ')})" }.join(', ')}
eos
end
def sqlite_batch_insert(table_name, column_list, value_list)
head, *tail = value_list
first_line = "SELECT #{head.map.with_index { |v, idx| "#{v} AS #{column_list[idx]}" }.join(', ')}"
remain_lines = tail.map do |values|
"UNION ALL SELECT #{values.join(', ')}"
end
"INSERT INTO #{table_name} (#{column_list.join(', ')}) #{first_line} #{remain_lines.join(" ")}"
end
def batch_insert_copy_auto_generate_ids(klass, hash_copy_slice)
columns = klass.columns.reject { |k, _| 'id' == k.name }
last_id = do_insert(klass, columns, hash_copy_slice)
first_id = last_id - hash_copy_slice.size + 1
hash_copy_slice.each_with_index{ |hash_copy, index| hash_copy['id'] = first_id + index }
end
def batch_insert_copy(klass, hash_copy_array)
# in case of array too large
hash_copy_array.each_slice(50) do |hash_copy_slice|
batch_insert_copy_auto_generate_ids(klass, hash_copy_slice)
end
end
def find_object_by_old_id(klass_name, old_id)
@instances[Standard.object_key(klass_name, old_id)]
end
def update_object_by_old_id(klass_name, old_id, new_attributes)
copy_object = find_object_by_old_id(klass_name, old_id)
new_attributes.each_pair do |key, value|
copy_object[key] = value
end
if copy_object['id'] != copy_object['id_before_copy']
# object already saved, this may happens when the same level copies has dependencies
update_inserted_copy(copy_object['klass'].constantize, copy_object['id'], new_attributes)
end
end
def update_inserted_copy(klass, id, new_attributes)
sub_conditions = []
new_attributes.each_pair do |key, value|
sub_conditions << "#{key}=#{value}"
end
klass.connection.execute("UPDATE #{klass.table_name} SET #{sub_conditions.join(',')} WHERE id=#{id}")
end
end
end