Last active
June 30, 2021 12:37
-
-
Save zimuliu/7973d449798546d2f76ae3ab668ca75d to your computer and use it in GitHub Desktop.
#accepts_nested_attributes_sync_for: Instead of manually specifying _destroy: true for deletion, or id for update, it supports synchronizing an association, with respect to the given unique index in the association.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module Concerns | |
module NestedAttributesSync | |
extend ActiveSupport::Concern | |
included do | |
class_attribute :nested_attributes_sync_options, instance_writer: false | |
self.nested_attributes_sync_options = {} | |
end | |
class_methods do | |
# accepts_nested_attributes_sync_for is based on | |
# accepts_nested_attributes_for from nested attributes assignment. | |
# | |
# Instead of manually specifying _destroy: true for deletion, or id for | |
# update, it supports synchronizing an association, with respect to the | |
# given unique index in the association. After reconciling with | |
# existing rows in the association, it automatically decides rows which | |
# need to be added/removed/updated. When the unique index is not given, it | |
# will try to make the best guess using the composite primary key. | |
# | |
# Examples: | |
# # sync structured_user_equipments using explicit index | |
# accepts_nested_attributes_sync_for structured_user_equipments: | |
# %i(equipmentable_id equipmentable_type) | |
# # sync availabilities using implicit composite PK | |
# accepts_nested_attributes_sync_for :availabilities | |
def accepts_nested_attributes_sync_for(*attr_names) | |
attr_names.each do |item| | |
if item.is_a?(Hash) | |
association_name = item.keys.first | |
unique_column_names = item.values.first | |
else | |
association_name = item | |
unique_column_names = nil | |
end | |
if reflection = reflect_on_association(association_name) | |
raise ArgumentError, "The association #{association_name} must be has_many" unless reflection.collection? | |
accepts_nested_attributes_for association_name, allow_destroy: true | |
unique_column_names ||= default_unique_column_names_from_reflection(reflection) | |
nested_attributes_sync_options = self.nested_attributes_sync_options.dup | |
nested_attributes_sync_options[association_name.to_sym] = { | |
unique_column_names: unique_column_names.map(&:to_sym), | |
associated_class_name: reflection.class_name, | |
} | |
self.nested_attributes_sync_options = nested_attributes_sync_options | |
generate_association_sync_chain(association_name) | |
else | |
raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?" | |
end | |
end | |
end | |
private | |
def default_unique_column_names_from_reflection(reflection) | |
associated_class = Object.const_get(reflection.class_name) | |
col_names = associated_class.primary_key | |
raise ArgumentError, "Cannot infer unique index from surrogate key in #{associated_class}. Please explicitly define a unique index." unless col_names.is_a?(Array) | |
col_names - [ "#{self.name.underscore}_id" ] | |
end | |
def generate_association_sync_chain(association_name) | |
generated_association_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 | |
if method_defined?(:#{association_name}_attributes_with_sync=) | |
remove_method(:#{association_name}_attributes_with_sync=) | |
end | |
def #{association_name}_attributes_with_sync=(attributes) | |
self.#{association_name}_attributes_without_sync= attributes_for_association_sync(:#{association_name}, attributes) | |
end | |
alias_method_chain :#{association_name}_attributes=, :sync | |
RUBY | |
end | |
end | |
private | |
def attributes_for_association_sync(association_name, attributes_collection) | |
if attributes_collection.respond_to?(:permitted?) | |
attributes_collection = attributes_collection.to_h | |
end | |
unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array) | |
raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})" | |
end | |
if attributes_collection.is_a?(Hash) | |
keys = attributes_collection.keys | |
attributes_collection = if keys.include?("id") || keys.include?(:id) | |
[attributes_collection] | |
else | |
attributes_collection.values | |
end | |
end | |
options = self.nested_attributes_sync_options[association_name] | |
unique_column_names = options[:unique_column_names] | |
associated_class = Object.const_get(options[:associated_class_name]) | |
existing_rows = send(association_name).to_a | |
keyed_existing_rows = Hash[existing_rows.map do |row| | |
[ unique_column_names.map { |col_name| row.send(col_name) }, row ] | |
end] | |
keyed_new_items = Hash[attributes_collection.map do |item| | |
item.except!(:id, 'id') | |
mock = associated_class.new(item) | |
[ unique_column_names.map { |col_name| mock.send(col_name) }, item ] | |
end] | |
keys_to_add = keyed_new_items.keys - keyed_existing_rows.keys | |
keys_to_remove = keyed_existing_rows.keys - keyed_new_items.keys | |
keys_to_update = keyed_existing_rows.keys & keyed_new_items.keys | |
sync_attributes = [] | |
keys_to_add.each { |key| sync_attributes << keyed_new_items[key] } | |
keys_to_remove.each { |key| sync_attributes << { id: keyed_existing_rows[key].id.to_s, _destroy: true } } | |
keys_to_update.each { |key| sync_attributes << keyed_new_items[key].merge(id: keyed_existing_rows[key].id.to_s) } | |
sync_attributes | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment