module ViewModel::ActiveRecord::AssociationManipulation
def add_reference_indirection(update_hash, association_data:, references:, key:)
def add_reference_indirection(update_hash, association_data:, references:, key:) raise ArgumentError.new('Not a referenced association') unless association_data.referenced? is_fupdate = association_data.collection? && update_hash.is_a?(Hash) && update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE if is_fupdate update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i| action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME # Remove actions are always type/id refs; others need to be translated to proper refs next end association_references = convert_updates_to_references( action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE], key: "#{key}_#{action_type_name}_#{i}") references.merge!(association_references) action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] = association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } } end update_hash else ViewModel::Utils.wrap_one_or_many(update_hash) do |sh| association_references = convert_updates_to_references(sh, key: "#{key}_replace") references.merge!(association_references) association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } } end end end
def append_associated(association_name, subtree_hash_or_hashes, references: {}, before: nil, after: nil, deserialize_context: self.class.new_deserialize_context)
collection, the items are inserted either before `before`, after `after`, or
Create or update members of a associated collection. For an ordered
def append_associated(association_name, subtree_hash_or_hashes, references: {}, before: nil, after: nil, deserialize_context: self.class.new_deserialize_context) if self.changes.changed? raise ArgumentError.new('Invalid call to append_associated on viewmodel with pending changes') end association_data = self.class._association_data(association_name) direct_reflection = association_data.direct_reflection raise ArgumentError.new("Cannot append to single association '#{association_name}'") unless association_data.collection? ViewModel::Utils.wrap_one_or_many(subtree_hash_or_hashes) do |subtree_hashes| model_class.transaction do ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control| association_changed!(association_name) deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, self) if association_data.through? raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic? direct_viewmodel_class = association_data.direct_viewmodel root_update_data, referenced_update_data = construct_indirect_append_updates(association_data, subtree_hashes, references) else raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic? direct_viewmodel_class = association_data.viewmodel_class root_update_data, referenced_update_data = construct_direct_append_updates(association_data, subtree_hashes, references) end update_context = ViewModel::ActiveRecord::UpdateContext.build!(root_update_data, referenced_update_data, root_type: direct_viewmodel_class) # Set new parent new_parent = ViewModel::ActiveRecord::UpdateOperation::ParentData.new(direct_reflection.inverse_of, self) update_context.root_updates.each { |update| update.reparent_to = new_parent } # Set place in list. if association_data.ordered? new_positions = select_append_positions(association_data, direct_viewmodel_class._list_attribute_name, update_context.root_updates.count, before: before, after: after) update_context.root_updates.zip(new_positions).each do |update, new_pos| update.reposition_to = new_pos end end # Because append_associated can take from other parents, edit-check previous parents (other than this model) unless association_data.through? inverse_assoc_name = direct_reflection.inverse_of.name previous_parent_ids = Set.new update_context.root_updates.each do |update| update_model = update.viewmodel.model parent_model_id = update_model.read_attribute(update_model .association(inverse_assoc_name) .reflection.foreign_key) if parent_model_id && parent_model_id != self.id previous_parent_ids << parent_model_id end end if previous_parent_ids.present? previous_parents = self.class.find(previous_parent_ids.to_a, eager_include: false) previous_parents.each do |parent_view| ViewModel::Callbacks.wrap_deserialize(parent_view, deserialize_context: deserialize_context) do |pp_hook_control| changes = ViewModel::Changes.new(changed_associations: [association_name]) deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, parent_view, changes: changes) pp_hook_control.record_changes(changes) end end end end child_context = self.context_for_child(association_name, context: deserialize_context) updated_viewmodels = update_context.run!(deserialize_context: child_context) # Propagate changes and finalize the parent updated_viewmodels.each do |child| child_changes = child.previous_changes if association_data.nested? nested_children_changed! if child_changes.changed_nested_tree? referenced_children_changed! if child_changes.changed_referenced_children? elsif association_data.owned? referenced_children_changed! if child_changes.changed_owned_tree? end end final_changes = self.clear_changes! if association_data.through? updated_viewmodels.map! do |direct_vm| direct_vm._read_association(association_data.indirect_reflection.name) end end # Could happen if hooks attempted to change the parent, which aren't # valid since we're only editing children here. unless final_changes.contained_to?(associations: [association_name.to_s]) raise ViewModel::DeserializationError::InvalidParentEdit.new(final_changes, blame_reference) end deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: final_changes) hook_control.record_changes(final_changes) updated_viewmodels end end end end
def check_association_type!(association_data, type)
def check_association_type!(association_data, type) if type && !association_data.accepts?(type) raise ViewModel::SerializationError.new( "Type error: association '#{association_data.association_name}' can't refer to viewmodel #{type.view_name}") elsif association_data.polymorphic? && !type raise ViewModel::SerializationError.new( "Need to specify target viewmodel type for polymorphic association '#{association_data.association_name}'") end end
def construct_direct_append_updates(_association_data, subtree_hashes, references)
def construct_direct_append_updates(_association_data, subtree_hashes, references) ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references) end
def construct_indirect_append_updates(association_data, subtree_hashes, references)
def construct_indirect_append_updates(association_data, subtree_hashes, references) indirect_reflection = association_data.indirect_reflection direct_viewmodel_class = association_data.direct_viewmodel # Construct updates for the provided indirectly-associated hashes indirect_update_data, referenced_update_data = ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references) # Convert associated update data to references indirect_references = self.class.convert_updates_to_references( indirect_update_data, key: 'indirect_append') referenced_update_data.merge!(indirect_references) # Find any existing models for the direct association: need to re-use any # existing join-table entries, to maintain single membership of each # associate. # TODO: this won't handle polymorphic associations! In the case of polymorphism, # need to join on (type, id) pairs instead. if association_data.polymorphic? raise ArgumentError.new('Internal error: append_association is not yet supported for polymorphic indirect associations') end existing_indirect_associates = indirect_update_data.map { |upd| upd.id unless upd.new? }.compact direct_association_scope = model.association(association_data.direct_reflection.name).scope existing_direct_ids = direct_association_scope .where(indirect_reflection.foreign_key => existing_indirect_associates) .pluck(indirect_reflection.foreign_key, :id) .to_h direct_update_data = indirect_references.map do |ref_name, update| existing_id = existing_direct_ids[update.id] unless update.new? metadata = ViewModel::Metadata.new(existing_id, direct_viewmodel_class.view_name, direct_viewmodel_class.schema_version, existing_id.nil?) ViewModel::ActiveRecord::UpdateData.new( direct_viewmodel_class, metadata, { indirect_reflection.name.to_s => { ViewModel::REFERENCE_ATTRIBUTE => ref_name } }, [ref_name]) end return direct_update_data, referenced_update_data end
def convert_updates_to_references(indirect_update_data, key:)
def convert_updates_to_references(indirect_update_data, key:) indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references| indirect_references["__#{key}_ref_#{i}"] = update end end
def delete_associated(association_name, associated_id, type: nil, deserialize_context: self.class.new_deserialize_context)
garbage-collected if the assocation is specified with `dependent: :destroy`
the provided associated viewmodel. The associated model will be
Removes the association between the models represented by this viewmodel and
def delete_associated(association_name, associated_id, type: nil, deserialize_context: self.class.new_deserialize_context) if self.changes.changed? raise ArgumentError.new('Invalid call to delete_associated on viewmodel with pending changes') end association_data = self.class._association_data(association_name) direct_reflection = association_data.direct_reflection unless association_data.collection? raise ArgumentError.new("Cannot remove element from single association '#{association_name}'") end check_association_type!(association_data, type) target_ref = ViewModel::Reference.new(type || association_data.viewmodel_class, associated_id) model_class.transaction do ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control| association_changed!(association_name) deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, self) association = self.model.association(direct_reflection.name) association_scope = association.scope if association_data.through? raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic? direct_viewmodel = association_data.direct_viewmodel association_scope = association_scope.where(association_data.indirect_reflection.foreign_key => associated_id) else raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic? # viewmodel type for current association: nil in case of empty polymorphic association direct_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) } if association_data.pointer_location == :local # If we hold the pointer, we can immediately check if the type and id match. if target_ref != ViewModel::Reference.new(direct_viewmodel, model.read_attribute(direct_reflection.foreign_key)) raise ViewModel::DeserializationError::AssociatedNotFound.new(association_name.to_s, target_ref, blame_reference) end else # otherwise add the target constraint to the association scope association_scope = association_scope.where(id: associated_id) end end models = association_scope.to_a if models.blank? raise ViewModel::DeserializationError::AssociatedNotFound.new(association_name.to_s, target_ref, blame_reference) elsif models.size > 1 raise ViewModel::DeserializationError::Internal.new( "Internal error: encountered multiple records for #{target_ref} in association #{association_name}", blame_reference) end child_context = self.context_for_child(association_name, context: deserialize_context) child_vm = direct_viewmodel.new(models.first) ViewModel::Callbacks.wrap_deserialize(child_vm, deserialize_context: child_context) do |child_hook_control| changes = ViewModel::Changes.new(deleted: true) child_context.run_callback(ViewModel::Callbacks::Hook::OnChange, child_vm, changes: changes) child_hook_control.record_changes(changes) association.delete(child_vm.model) end if association_data.nested? nested_children_changed! elsif association_data.owned? referenced_children_changed! end final_changes = self.clear_changes! unless final_changes.contained_to?(associations: [association_name.to_s]) raise ViewModel::DeserializationError::InvalidParentEdit.new(final_changes, blame_reference) end deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: final_changes) hook_control.record_changes(final_changes) child_vm end end end
def each_child_hash(assoc_update, association_data:)
Traverses literals and fupdates to return referenced children.
def each_child_hash(assoc_update, association_data:) return enum_for(__method__, assoc_update, association_data: association_data) unless block_given? is_fupdate = association_data.collection? && assoc_update.is_a?(Hash) && assoc_update[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE if is_fupdate assoc_update.fetch(ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE).each do |action| action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] if action_type_name.nil? raise ViewModel::DeserializationError::InvalidSyntax.new( "Functional update missing '#{ViewModel::ActiveRecord::TYPE_ATTRIBUTE}'" ) end if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME # Remove actions are not considered children of the action. next end values = action.fetch(ViewModel::ActiveRecord::VALUES_ATTRIBUTE) { raise ViewModel::DeserializationError::InvalidSyntax.new( "Functional update missing '#{ViewModel::ActiveRecord::VALUES_ATTRIBUTE}'" ) } values.each { |x| yield x } end else ViewModel::Utils.wrap_one_or_many(assoc_update) do |assoc_updates| assoc_updates.each { |u| yield u } end end end
def load_associated(association_name, scope: nil, eager_include: true, serialize_context: self.class.new_serialize_context)
def load_associated(association_name, scope: nil, eager_include: true, serialize_context: self.class.new_serialize_context) association_data = self.class._association_data(association_name) direct_reflection = association_data.direct_reflection association = self.model.association(direct_reflection.name) association_scope = association.scope if association_data.through? raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic? associated_viewmodel = association_data.viewmodel_class direct_viewmodel = association_data.direct_viewmodel else raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.polymorphic? associated_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) } direct_viewmodel = associated_viewmodel end if association_data.ordered? association_scope = association_scope.order(direct_viewmodel._list_attribute_name) end if association_data.through? association_scope = associated_viewmodel.model_class .joins(association_data.indirect_reflection.inverse_of.name) .merge(association_scope) end association_scope = association_scope.merge(scope) if scope vms = association_scope.map { |model| associated_viewmodel.new(model) } ViewModel.preload_for_serialization(vms) if eager_include if association_data.collection? vms else if vms.size > 1 raise ViewModel::DeserializationError::Internal.new("Internal error: encountered multiple records for single association #{association_name}", self.blame_reference) end vms.first end end
def mentioned_children(assoc_update, references:, association_data:)
Collects the ids of children that are mentioned in the update data.
def mentioned_children(assoc_update, references:, association_data:) return enum_for(__method__, assoc_update, references: references, association_data: association_data) unless block_given? each_child_hash(assoc_update, association_data: association_data).each do |child_hash| unless child_hash.is_a?(Hash) raise ViewModel::DeserializationError::InvalidSyntax.new( "Expected update hash, received: #{child_hash}" ) end if association_data.referenced? ref_handle = child_hash.fetch(ViewModel::REFERENCE_ATTRIBUTE) { raise ViewModel::DeserializationError::InvalidSyntax.new( "Reference hash missing '#{ViewModel::REFERENCE_ATTRIBUTE}'" ) } ref_update_hash = references.fetch(ref_handle) { raise ViewModel::DeserializationError::InvalidSyntax.new( "Reference '#{ref_handle}' does not exist in references" ) } unless ref_update_hash.is_a?(Hash) raise ViewModel::DeserializationError::InvalidSyntax.new( "Expected update hash, received: #{child_hash}" ) end if (id = ref_update_hash[ViewModel::ID_ATTRIBUTE]) yield id end else if (id = child_hash[ViewModel::ID_ATTRIBUTE]) yield id end end end end
def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
ensure that the update hash for a shared entity is unique, and
referencing. Reference and shared associations are excluded to
of direct update hashes, regardless of their
is to say, that owned associations should be presented in the form
with the notable exception of referenced+shared associations. That
This interface deals with associations directly where reasonable,
hash(es). Only mentioned member(s) will be returned.
Replace the current member(s) of an association with the provided
def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context) _updated_parent, changed_children = self.class.replace_associated_bulk( association_name, { self.id => update_hash }, references: references, deserialize_context: deserialize_context ).first changed_children end
def replace_associated_bulk(association_name, updates_by_parent_id, references:, deserialize_context: self.class.new_deserialize_context)
even for functional updates. This is in contrast to append_associated,
all collections into memory and filtering for the mentioned entities,
This is an interim implementation that requires loading the contents of
hash(es) for many viewmodels. Only mentioned members will be returned.
Replace the current member(s) of an association with the provided
def replace_associated_bulk(association_name, updates_by_parent_id, references:, deserialize_context: self.class.new_deserialize_context) association_data = _association_data(association_name) touched_ids = updates_by_parent_id.each_with_object({}) do |(parent_id, update_hash), acc| acc[parent_id] = mentioned_children( update_hash, references: references, association_data: association_data, ).to_set end root_update_hashes = updates_by_parent_id.map do |parent_id, update_hash| { ViewModel::ID_ATTRIBUTE => parent_id, ViewModel::TYPE_ATTRIBUTE => view_name, association_name.to_s => update_hash, } end root_update_viewmodels = deserialize_from_view( root_update_hashes, references: references, deserialize_context: deserialize_context) root_update_viewmodels.each_with_object({}) do |updated, acc| acc[updated] = updated._read_association_touched(association_name, touched_ids: touched_ids.fetch(updated.id)) end end
def select_append_positions(association_data, position_attr, append_count, before:, after:)
def select_append_positions(association_data, position_attr, append_count, before:, after:) direct_reflection = association_data.direct_reflection association_scope = model.association(direct_reflection.name).scope search_key = if association_data.through? association_data.indirect_reflection.foreign_key else :id end if (relative_ref = (before || after)) relative_target = association_scope.where(search_key => relative_ref.model_id).select(:position) if before end_pos, start_pos = association_scope.where("#{position_attr} <= (?)", relative_target).order("#{position_attr} DESC").limit(2).pluck(:position) else start_pos, end_pos = association_scope.where("#{position_attr} >= (?)", relative_target).order("#{position_attr} ASC").limit(2).pluck(:position) end if start_pos.nil? && end_pos.nil? # Attempted to insert relative to ref that's not in the association raise ViewModel::DeserializationError::AssociatedNotFound.new(association_data.association_name.to_s, relative_ref, blame_reference) end else start_pos = association_scope.maximum(position_attr) end_pos = nil end ActsAsManualList.select_positions(start_pos, end_pos, append_count) end