diff --git a/iknow_view_models.gemspec b/iknow_view_models.gemspec index 51ba7915..7496ea70 100644 --- a/iknow_view_models.gemspec +++ b/iknow_view_models.gemspec @@ -24,7 +24,7 @@ Gem::Specification.new do |spec| spec.add_dependency "activesupport", ">= 5.0" spec.add_dependency "acts_as_manual_list" - spec.add_dependency "deep_preloader" + spec.add_dependency "deep_preloader", ">= 1.0.1" spec.add_dependency "iknow_cache" spec.add_dependency "iknow_params", "~> 2.2.0" spec.add_dependency "safe_values" diff --git a/lib/iknow_view_models/version.rb b/lib/iknow_view_models/version.rb index 55ce3a17..128a1919 100644 --- a/lib/iknow_view_models/version.rb +++ b/lib/iknow_view_models/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module IknowViewModels - VERSION = '2.8.9' + VERSION = '3.0.0' end diff --git a/lib/view_model.rb b/lib/view_model.rb index d73ac3ea..0b6536ed 100644 --- a/lib/view_model.rb +++ b/lib/view_model.rb @@ -20,6 +20,7 @@ class << self attr_accessor :_attributes attr_accessor :schema_version attr_reader :view_aliases + attr_writer :view_name def inherited(subclass) subclass.initialize_as_viewmodel @@ -41,15 +42,23 @@ def view_name end end - def view_name=(name) - @view_name = name - end - def add_view_alias(as) view_aliases << as ViewModel::Registry.register(self, as: as) end + # ViewModels are either roots or children. Root viewmodels may be + # (de)serialized directly, whereas child viewmodels are always nested within + # their parent. Associations to root viewmodel types always use indirect + # references. + def root? + false + end + + def root! + define_singleton_method(:root?) { true } + end + # ViewModels are typically going to be pretty simple structures. Make it a # bit easier to define them: attributes specified this way are given # accessors and assigned in order by the default constructor. @@ -59,7 +68,7 @@ def attributes(*attrs, **args) def attribute(attr, **_args) unless attr.is_a?(Symbol) - raise ArgumentError.new("ViewModel attributes must be symbols") + raise ArgumentError.new('ViewModel attributes must be symbols') end attr_accessor attr @@ -116,7 +125,7 @@ def is_update_hash?(hash) # If this viewmodel represents an AR model, what associations does it make # use of? Returns a includes spec appropriate for DeepPreloader, either as # AR-style nested hashes or DeepPreloader::Spec. - def eager_includes(serialize_context: new_serialize_context, include_shared: true) + def eager_includes(serialize_context: new_serialize_context, include_referenced: true) {} end @@ -225,10 +234,10 @@ def accepts_schema_version?(schema_version) schema_version == self.schema_version end - def preload_for_serialization(viewmodels, serialize_context: new_serialize_context, include_shared: true, lock: nil) + def preload_for_serialization(viewmodels, serialize_context: new_serialize_context, include_referenced: true, lock: nil) Array.wrap(viewmodels).group_by(&:class).each do |type, views| DeepPreloader.preload(views.map(&:model), - type.eager_includes(serialize_context: serialize_context, include_shared: include_shared), + type.eager_includes(serialize_context: serialize_context, include_referenced: include_referenced), lock: lock) end end diff --git a/lib/view_model/active_record.rb b/lib/view_model/active_record.rb index bee9f38a..4f9e47d0 100644 --- a/lib/view_model/active_record.rb +++ b/lib/view_model/active_record.rb @@ -63,42 +63,58 @@ def _list_member? _list_attribute_name.present? end - # Specifies an association from the model to be recursively serialized using - # another viewmodel. If the target viewmodel is not specified, attempt to - # locate a default viewmodel based on the name of the associated model. - # TODO document harder + # Adds an association from the model to this viewmodel. The associated model + # will be recursively (de)serialized by its own viewmodel type, which will + # be inferred from the model name, or may be explicitly specified. + # + # An association to a root viewmodel type will be serialized with an + # indirect reference, while a child viewmodel type will be directly nested. + # + # - +as+ sets the name of the association in the viewmodel + # + # - +viewmodel+, +viewmodels+ specifies the viewmodel(s) to use for the + # association + # + # - +external+ indicates an association external to the view. Externalized + # associations are not included in (de)serializations of the parent, and + # must be independently manipulated using `AssociationManipulation`. + # External associations may only be made to root viewmodels. + # # - +through+ names an ActiveRecord association that will be used like an # ActiveRecord +has_many:through:+. + # # - +through_order_attr+ the through model is ordered by the given attribute # (only applies to when +through+ is set). def association(association_name, + as: nil, viewmodel: nil, viewmodels: nil, - shared: false, - optional: false, + external: false, + read_only: false, through: nil, - through_order_attr: nil, - as: nil) - - if through - model_association_name = through - through_to = association_name - else - model_association_name = association_name - through_to = nil - end + through_order_attr: nil) vm_association_name = (as || association_name).to_s - reflection = model_class.reflect_on_association(model_association_name) - - if reflection.nil? - raise ArgumentError.new("Association #{model_association_name} not found in #{model_class.name} model") + if through + direct_association_name = through + indirect_association_name = association_name + else + direct_association_name = association_name + indirect_association_name = nil end - viewmodel_spec = viewmodel || viewmodels + target_viewmodels = Array.wrap(viewmodel || viewmodels) - association_data = AssociationData.new(vm_association_name, reflection, viewmodel_spec, shared, optional, through_to, through_order_attr) + association_data = AssociationData.new( + owner: self, + association_name: vm_association_name, + direct_association_name: direct_association_name, + indirect_association_name: indirect_association_name, + target_viewmodels: target_viewmodels, + external: external, + read_only: read_only, + through_order_attr: through_order_attr) _members[vm_association_name] = association_data @@ -108,21 +124,7 @@ def association(association_name, end define_method :"serialize_#{vm_association_name}" do |json, serialize_context: self.class.new_serialize_context| - associated = self.public_send(vm_association_name) - json.set! vm_association_name do - case - when associated.nil? - json.null! - when association_data.through? - json.array!(associated) do |through_target| - self.class.serialize_as_reference(through_target, json, serialize_context: serialize_context) - end - when shared - self.class.serialize_as_reference(associated, json, serialize_context: serialize_context) - else - self.class.serialize(associated, json, serialize_context: serialize_context) - end - end + _serialize_association(vm_association_name, json, serialize_context: serialize_context) end end end @@ -171,11 +173,6 @@ def deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_co ViewModel::Utils.wrap_one_or_many(subtree_hash_or_hashes) do |subtree_hashes| root_update_data, referenced_update_data = UpdateData.parse_hashes(subtree_hashes, references) - # Provide information about what was updated - deserialize_context.updated_associations = root_update_data - .map { |upd| upd.updated_associations } - .inject({}) { |acc, assocs| acc.deep_merge(assocs) } - _updated_viewmodels = UpdateContext .build!(root_update_data, referenced_update_data, root_type: self) @@ -184,27 +181,18 @@ def deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_co end end - def eager_includes(serialize_context: new_serialize_context, include_shared: true) - # When serializing, we need to (recursively) include all intrinsic - # associations and also those optional (incl. shared) associations - # specified in the serialize_context. - - # when deserializing, we start with intrinsic non-shared associations. We - # then traverse the structure of the tree to deserialize to map out which - # optional or shared associations are used from each type. We then explore - # from the root type to build an preload specification that will include - # them all. (We can subsequently use this same structure to build a - # serialization context featuring the same associations.) - + # Constructs a preload specification of the required models for + # serializing/deserializing this view. + def eager_includes(serialize_context: new_serialize_context, include_referenced: true) association_specs = {} _members.each do |assoc_name, association_data| next unless association_data.is_a?(AssociationData) - next unless serialize_context.includes_member?(assoc_name, !association_data.optional?) + next if association_data.external? child_context = if self.synthetic serialize_context - elsif association_data.shared? + elsif association_data.referenced? serialize_context.for_references else serialize_context.for_child(nil, association_name: assoc_name) @@ -213,22 +201,22 @@ def eager_includes(serialize_context: new_serialize_context, include_shared: tru case when association_data.through? viewmodel = association_data.direct_viewmodel - children = viewmodel.eager_includes(serialize_context: child_context, include_shared: include_shared) + children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced) - when !include_shared && association_data.shared? - children = nil # Load up to the shared model, but no further + when !include_referenced && association_data.referenced? + children = nil # Load up to the root viewmodel, but no further when association_data.polymorphic? children_by_klass = {} association_data.viewmodel_classes.each do |vm_class| klass = vm_class.model_class.name - children_by_klass[klass] = vm_class.eager_includes(serialize_context: child_context, include_shared: include_shared) + children_by_klass[klass] = vm_class.eager_includes(serialize_context: child_context, include_referenced: include_referenced) end children = DeepPreloader::PolymorphicSpec.new(children_by_klass) else viewmodel = association_data.viewmodel_class - children = viewmodel.eager_includes(serialize_context: child_context, include_shared: include_shared) + children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced) end association_specs[association_data.direct_reflection.name.to_s] = children @@ -236,26 +224,26 @@ def eager_includes(serialize_context: new_serialize_context, include_shared: tru DeepPreloader::Spec.new(association_specs) end - def dependent_viewmodels(seen = Set.new, include_shared: true) + def dependent_viewmodels(seen = Set.new, include_referenced: true) return if seen.include?(self) seen << self _members.each_value do |data| next unless data.is_a?(AssociationData) - next unless include_shared || !data.shared? + next unless include_referenced || !data.referenced? data.viewmodel_classes.each do |vm| - vm.dependent_viewmodels(seen, include_shared: include_shared) + vm.dependent_viewmodels(seen, include_referenced: include_referenced) end end seen end - def deep_schema_version(include_shared: true) - (@deep_schema_version ||= {})[include_shared] ||= + def deep_schema_version(include_referenced: true) + (@deep_schema_version ||= {})[include_referenced] ||= begin - dependent_viewmodels(include_shared: include_shared).each_with_object({}) do |view, h| + dependent_viewmodels(include_referenced: include_referenced).each_with_object({}) do |view, h| h[view.view_name] = view.schema_version end end @@ -270,6 +258,7 @@ def cacheable!(**opts) def _association_data(association_name) association_data = self._members[association_name.to_s] raise ArgumentError.new("Invalid association '#{association_name}'") unless association_data.is_a?(AssociationData) + association_data end end @@ -282,7 +271,7 @@ def initialize(*) def serialize_members(json, serialize_context: self.class.new_serialize_context) self.class._members.each do |member_name, member_data| - next unless serialize_context.includes_member?(member_name, !member_data.optional?) + next if member_data.association? && member_data.external? member_context = case member_data @@ -309,6 +298,13 @@ def destroy!(deserialize_context: self.class.new_deserialize_context) def association_changed!(association_name) association_name = association_name.to_s + + association_data = self.class._association_data(association_name) + + if association_data.read_only? + raise ViewModel::DeserializationError::ReadOnlyAssociation.new(association_name, blame_reference) + end + unless @changed_associations.include?(association_name) @changed_associations << association_name end @@ -321,10 +317,12 @@ def associations_changed? # Additionally pass `changed_associations` while constructing changes. def changes ViewModel::Changes.new( - new: new_model?, - changed_attributes: changed_attributes, - changed_associations: changed_associations, - changed_children: changed_children?) + new: new_model?, + changed_attributes: changed_attributes, + changed_associations: changed_associations, + changed_nested_children: changed_nested_children?, + changed_referenced_children: changed_referenced_children?, + ) end def clear_changes! @@ -365,14 +363,36 @@ def _read_association(association_name) end end + def _serialize_association(association_name, json, serialize_context:) + associated = self.public_send(association_name) + association_data = self.class._association_data(association_name) + + json.set! association_name do + case + when associated.nil? + json.null! + when association_data.referenced? + if association_data.collection? + json.array!(associated) do |target| + self.class.serialize_as_reference(target, json, serialize_context: serialize_context) + end + else + self.class.serialize_as_reference(associated, json, serialize_context: serialize_context) + end + else + self.class.serialize(associated, json, serialize_context: serialize_context) + end + end + end + def context_for_child(member_name, context:) # Synthetic viewmodels don't exist as far as the traversal context is # concerned: pass through the child context received from the parent return context if self.class.synthetic - # Shared associations start a new tree + # associations to roots start a new tree member_data = self.class._members[member_name.to_s] - if member_data.is_a?(AssociationData) && member_data.shared? + if member_data.association? && member_data.referenced? return context.for_references end diff --git a/lib/view_model/active_record/association_data.rb b/lib/view_model/active_record/association_data.rb index 258cf92d..5cc25a09 100644 --- a/lib/view_model/active_record/association_data.rb +++ b/lib/view_model/active_record/association_data.rb @@ -1,95 +1,137 @@ # frozen_string_literal: true -# TODO consider rephrase scope for consistency class ViewModel::ActiveRecord::AssociationData - attr_reader :direct_reflection, :association_name - - def initialize(association_name, direct_reflection, viewmodel_classes, shared, optional, through_to, through_order_attr) - @association_name = association_name - @direct_reflection = direct_reflection - @shared = shared - @optional = optional - @through_to = through_to - @through_order_attr = through_order_attr - - if viewmodel_classes - @viewmodel_classes = Array.wrap(viewmodel_classes).map! do |v| - case v - when String, Symbol - ViewModel::Registry.for_view_name(v.to_s) - when Class - v + class InvalidAssociation < RuntimeError; end + + attr_reader :association_name, :direct_reflection + + def initialize(owner:, + association_name:, + direct_association_name:, + indirect_association_name:, + target_viewmodels:, + external:, + through_order_attr:, + read_only:) + @association_name = association_name + + @direct_reflection = owner.model_class.reflect_on_association(direct_association_name) + if @direct_reflection.nil? + raise InvalidAssociation.new("Association '#{direct_association_name}' not found in model '#{model_class.name}'") + end + + @indirect_association_name = indirect_association_name + + @read_only = read_only + @external = external + @through_order_attr = through_order_attr + @target_viewmodels = target_viewmodels + + # Target models/reflections/viewmodels are lazily evaluated so that we can + # safely express cycles. + @initialized = false + @mutex = Mutex.new + end + + def lazy_initialize! + @mutex.synchronize do + return if @initialized + + if through? + intermediate_model = @direct_reflection.klass + @indirect_reflection = load_indirect_reflection(intermediate_model, @indirect_association_name) + target_reflection = @indirect_reflection + else + target_reflection = @direct_reflection + end + + @viewmodel_classes = + if @target_viewmodels.present? + # Explicitly named + @target_viewmodels.map { |v| resolve_viewmodel_class(v) } else - raise ArgumentError.new("Invalid viewmodel class: #{v.inspect}") + # Infer name from name of model + if target_reflection.polymorphic? + raise InvalidAssociation.new( + 'Cannot automatically infer target viewmodels from polymorphic association') + end + infer_viewmodel_class(target_reflection.klass) end + + @referenced = @viewmodel_classes.first.root? + + # Non-referenced viewmodels must be owned. For referenced viewmodels, we + # own it if it points to us. Through associations aren't considered + # `owned?`: while we do own the implicit direct viewmodel, we don't own + # the target of the association. + @owned = !@referenced || (target_reflection.macro != :belongs_to) + + unless @viewmodel_classes.all? { |v| v.root? == @referenced } + raise InvalidAssociation.new('Invalid association target: mixed root and non-root viewmodels') end - end - if through? - # Through associations must always be an owned direct association to a - # shared indirect target. We expect the user to set shared: true to - # express the ownership of the indirect target, but this direct - # association to the intermediate is in fact owned. This ownership - # property isn't directly used anywhere: the synthetic intermediate - # viewmodel is only used in the deserialization update operations, which - # directly understands the semantics of through associations. - raise ArgumentError.new("Through associations must be to a shared target") unless @shared - raise ArgumentError.new("Through associations must be `has_many`") unless direct_reflection.macro == :has_many - end - end + if external? && !@referenced + raise InvalidAssociation.new('External associations must be to root viewmodels') + end - # reflection for the target of this association: indirect if through, direct otherwise - def target_reflection - if through? - indirect_reflection - else - direct_reflection + if through? + unless @referenced + raise InvalidAssociation.new('Through associations must be to root viewmodels') + end + + @direct_viewmodel = build_direct_viewmodel(@direct_reflection, @indirect_reflection, + @viewmodel_classes, @through_order_attr) + end + + @initialized = true end end - def polymorphic? - target_reflection.polymorphic? + def association? + true end - def viewmodel_classes - # If we weren't given explicit viewmodel classes, try to work out from the - # names. This should work unless the association is polymorphic. - @viewmodel_classes ||= - begin - model_class = target_reflection.klass - if model_class.nil? - raise "Couldn't derive target class for association '#{target_reflection.name}" - end - inferred_view_name = ViewModel::Registry.default_view_name(model_class.name) - viewmodel_class = ViewModel::Registry.for_view_name(inferred_view_name) # TODO: improve error message to show it's looking for default name - [viewmodel_class] - end + def referenced? + lazy_initialize! unless @initialized + @referenced end - private def model_to_viewmodel - @model_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h| - h[vm.model_class] = vm - end + def nested? + !referenced? end - private def name_to_viewmodel - @name_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h| - h[vm.view_name] = vm - vm.view_aliases.each do |view_alias| - h[view_alias] = vm - end - end + def owned? + lazy_initialize! unless @initialized + @owned end def shared? - @shared + !owned? + end + + def external? + @external + end + + def read_only? + @read_only + end + + # reflection for the target of this association: indirect if through, direct otherwise + def target_reflection + if through? + indirect_reflection + else + direct_reflection + end end - def optional? - @optional + def polymorphic? + target_reflection.polymorphic? end - def pointer_location # TODO name + # The side of the immediate association that holds the pointer. + def pointer_location case direct_reflection.macro when :belongs_to :local @@ -98,6 +140,24 @@ def pointer_location # TODO name end end + def indirect_reflection + lazy_initialize! unless @initialized + @indirect_reflection + end + + def direct_reflection_inverse(foreign_class = nil) + if direct_reflection.polymorphic? + direct_reflection.polymorphic_inverse_of(foreign_class) + else + direct_reflection.inverse_of + end + end + + def viewmodel_classes + lazy_initialize! unless @initialized + @viewmodel_classes + end + def viewmodel_class_for_model(model_class) model_to_viewmodel[model_class] end @@ -132,47 +192,101 @@ def viewmodel_class unless viewmodel_classes.size == 1 raise ArgumentError.new("More than one possible class for association '#{target_reflection.name}'") end + viewmodel_classes.first end def through? - @through_to.present? + @indirect_association_name.present? end def direct_viewmodel - @direct_viewmodel ||= begin - raise 'not a through association' unless through? + raise ArgumentError.new('not a through association') unless through? + lazy_initialize! unless @initialized + @direct_viewmodel + end + + def collection? + through? || direct_reflection.collection? + end - # Join table viewmodel class + def indirect_association_data + direct_viewmodel._association_data(indirect_reflection.name) + end - # For A has_many B through T; where this association is defined on A + private - # Copy into scope for new class block - direct_reflection = self.direct_reflection # A -> T - indirect_reflection = self.indirect_reflection # T -> B - through_order_attr = @through_order_attr - viewmodel_classes = self.viewmodel_classes + # Through associations must always be to a root viewmodel, via an owned + # has_many association to an intermediate model. A synthetic viewmodel is + # created to represent this intermediate, but is used only internally by the + # deserialization update operations, which directly understands the semantics + # of through associations. + def load_indirect_reflection(intermediate_model, indirect_association_name) + indirect_reflection = + intermediate_model.reflect_on_association(ActiveSupport::Inflector.singularize(indirect_association_name)) - Class.new(ViewModel::ActiveRecord) do - self.synthetic = true - self.model_class = direct_reflection.klass - self.view_name = direct_reflection.klass.name - association indirect_reflection.name, shared: true, optional: false, viewmodels: viewmodel_classes - acts_as_list through_order_attr if through_order_attr - end + if indirect_reflection.nil? + raise InvalidAssociation.new( + "Indirect association '#{@indirect_association_name}' not found in "\ + "intermediate model '#{intermediate_model.name}'") + end + + unless direct_reflection.macro == :has_many + raise InvalidAssociation.new('Through associations must be `has_many`') end + + indirect_reflection end - def indirect_reflection - @indirect_reflection ||= - direct_reflection.klass.reflect_on_association(ActiveSupport::Inflector.singularize(@through_to)) + def build_direct_viewmodel(direct_reflection, indirect_reflection, viewmodel_classes, through_order_attr) + # Join table viewmodel class. For A has_many B through T; where this association is defined on A + # direct_reflection = A -> T + # indirect_reflection = T -> B + + Class.new(ViewModel::ActiveRecord) do + self.synthetic = true + self.model_class = direct_reflection.klass + self.view_name = direct_reflection.klass.name + association indirect_reflection.name, viewmodels: viewmodel_classes + acts_as_list through_order_attr if through_order_attr + end end - def collection? - through? || direct_reflection.collection? + def resolve_viewmodel_class(v) + case v + when String, Symbol + ViewModel::Registry.for_view_name(v.to_s) + when Class + v + else + raise InvalidAssociation.new("Invalid viewmodel class: #{v.inspect}") + end end - def indirect_association_data - direct_viewmodel._association_data(indirect_reflection.name) + def infer_viewmodel_class(model_class) + # If we weren't given explicit viewmodel classes, try to work out from the + # names. This should work unless the association is polymorphic. + if model_class.nil? + raise InvalidAssociation.new("Couldn't derive target class for model association '#{target_reflection.name}'") + end + + inferred_view_name = ViewModel::Registry.default_view_name(model_class.name) + viewmodel_class = ViewModel::Registry.for_view_name(inferred_view_name) # TODO: improve error message to show it's looking for default name + [viewmodel_class] + end + + def model_to_viewmodel + @model_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h| + h[vm.model_class] = vm + end + end + + def name_to_viewmodel + @name_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h| + h[vm.view_name] = vm + vm.view_aliases.each do |view_alias| + h[view_alias] = vm + end + end end end diff --git a/lib/view_model/active_record/association_manipulation.rb b/lib/view_model/active_record/association_manipulation.rb index 5d2f01e8..72da73d8 100644 --- a/lib/view_model/active_record/association_manipulation.rb +++ b/lib/view_model/active_record/association_manipulation.rb @@ -52,9 +52,7 @@ def load_associated(association_name, scope: nil, eager_include: true, serialize def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context) association_data = self.class._association_data(association_name) - # TODO: structure checking - - if association_data.through? || association_data.shared? + if association_data.referenced? is_fupdate = association_data.collection? && update_hash.is_a?(Hash) && @@ -125,10 +123,6 @@ def append_associated(association_name, subtree_hash_or_hashes, references: {}, update_context = ViewModel::ActiveRecord::UpdateContext.build!(root_update_data, referenced_update_data, root_type: direct_viewmodel_class) - # Provide information about what was updated - deserialize_context.updated_associations = root_update_data.map(&:updated_associations) - .inject({}) { |acc, assocs| acc.deep_merge(assocs) } - # 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 } @@ -177,15 +171,26 @@ def append_associated(association_name, subtree_hash_or_hashes, references: {}, 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 - # Finalize the parent - final_changes = self.clear_changes! - # 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]) @@ -269,7 +274,12 @@ def delete_associated(association_name, associated_id, type: nil, deserialize_co association.delete(child_vm.model) end - self.children_changed! + 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]) @@ -286,7 +296,7 @@ def delete_associated(association_name, associated_id, type: nil, deserialize_co private - 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 diff --git a/lib/view_model/active_record/cache.rb b/lib/view_model/active_record/cache.rb index a0abc9f6..bcebc38b 100644 --- a/lib/view_model/active_record/cache.rb +++ b/lib/view_model/active_record/cache.rb @@ -198,7 +198,7 @@ def find_and_preload_viewmodels(viewmodel_class, ids, available_viewmodels: nil) end ViewModel.preload_for_serialization(viewmodels, - include_shared: false, + include_referenced: false, lock: "FOR SHARE", serialize_context: serialize_context) @@ -259,7 +259,7 @@ def cache_name end def cache_version - version_string = @viewmodel_class.deep_schema_version(include_shared: false).to_a.sort.join(',') + version_string = @viewmodel_class.deep_schema_version(include_referenced: false).to_a.sort.join(',') Base64.urlsafe_encode64(Digest::MD5.digest(version_string)) end end diff --git a/lib/view_model/active_record/cache/cacheable_view.rb b/lib/view_model/active_record/cache/cacheable_view.rb index 318d9436..9d7c3dae 100644 --- a/lib/view_model/active_record/cache/cacheable_view.rb +++ b/lib/view_model/active_record/cache/cacheable_view.rb @@ -39,12 +39,12 @@ def serialize_from_cache(views, serialize_context:) end end - # Clear the cache if the view or its owned children were changed during + # Clear the cache if the view or its nested children were changed during # deserialization def after_deserialize(deserialize_context:, changes:) super if defined?(super) - if !changes.new? && changes.changed_tree? + if !changes.new? && changes.changed_nested_tree? CacheClearer.new(self.class.viewmodel_cache, id).add_to_transaction end end diff --git a/lib/view_model/active_record/cloner.rb b/lib/view_model/active_record/cloner.rb index 78dd6e25..07052b5f 100644 --- a/lib/view_model/active_record/cloner.rb +++ b/lib/view_model/active_record/cloner.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + # Simple visitor for cloning models through the tree structure defined by # ViewModel::ActiveRecord. Owned associations will be followed and cloned, while -# shared associations will be copied directly. Attributes (including association -# foreign keys not covered by ViewModel `association`s) will be copied from the -# original. +# non-owned referenced associations will be copied directly as references. +# Attributes (including association foreign keys not covered by ViewModel +# `association`s) will be copied from the original. # # To customize, subclasses may define methods `visit_x_view(node, new_model)` # for each type they wish to affect. These callbacks may update attributes of @@ -19,9 +21,9 @@ def clone(node) return nil if ignored? if node.class.name - class_name = node.class.name.underscore.gsub('/', '__') - visit = :"visit_#{class_name}" - end_visit = :"end_visit_#{class_name}" + class_name = node.class.name.underscore.gsub('/', '__') + visit = :"visit_#{class_name}" + end_visit = :"end_visit_#{class_name}" end if visit && respond_to?(visit, true) @@ -44,7 +46,7 @@ def clone(node) if associated.nil? new_associated = nil - elsif association_data.shared? && !association_data.through? + elsif !association_data.owned? && !association_data.through? # simply attach the associated target to the new model new_associated = associated else @@ -82,11 +84,9 @@ def clone(node) new_model end - def pre_visit(node, new_model) - end + def pre_visit(node, new_model); end - def post_visit(node, new_model) - end + def post_visit(node, new_model); end private diff --git a/lib/view_model/active_record/controller.rb b/lib/view_model/active_record/controller.rb index fd8a4306..aff1d249 100644 --- a/lib/view_model/active_record/controller.rb +++ b/lib/view_model/active_record/controller.rb @@ -46,8 +46,6 @@ def create(serialize_context: new_serialize_context, deserialize_context: new_de pre_rendered = viewmodel_class.transaction do view = viewmodel_class.deserialize_from_view(update_hash, references: refs, deserialize_context: deserialize_context) - serialize_context.add_includes(deserialize_context.updated_associations) - view = yield(view) if block_given? ViewModel.preload_for_serialization(view, serialize_context: serialize_context) diff --git a/lib/view_model/active_record/update_context.rb b/lib/view_model/active_record/update_context.rb index 6a9e6b67..35227f8e 100644 --- a/lib/view_model/active_record/update_context.rb +++ b/lib/view_model/active_record/update_context.rb @@ -74,7 +74,7 @@ def root_updates def initialize @root_update_operations = [] # The subject(s) of this update - @referenced_update_operations = {} # Shared data updates, referred to by a ref hash + @referenced_update_operations = {} # data updates to other root models, referred to by a ref hash # Set of ViewModel::Reference used to assert only a single update is # present for each viewmodel @@ -178,8 +178,20 @@ def assemble_update_tree raise ViewModel::DeserializationError::ParentNotFound.new(@worklist.keys) end - deferred_update = @worklist.delete(key) - deferred_update.viewmodel = @release_pool.claim_from_pool(key) + deferred_update = @worklist.delete(key) + released_viewmodel = @release_pool.claim_from_pool(key) + + if deferred_update.viewmodel + # Deferred reference updates already have a viewmodel: ensure it + # matches the tree + unless deferred_update.viewmodel == released_viewmodel + raise ViewModel::DeserializationError::Internal.new( + "Released viewmodel doesn't match reference update", blame_reference) + end + else + deferred_update.viewmodel = released_viewmodel + end + deferred_update.build!(self) end @@ -201,6 +213,12 @@ def new_deferred_update(viewmodel_reference, update_data, reparent_to: nil, repo update_operation = ViewModel::ActiveRecord::UpdateOperation.new( nil, update_data, reparent_to: reparent_to, reposition_to: reposition_to) check_unique_update!(viewmodel_reference) + defer_update(viewmodel_reference, update_operation) + end + + # Defer an existing update: used if we need to ensure that an owned + # reference has been freed before we use it. + def defer_update(viewmodel_reference, update_operation) @worklist[viewmodel_reference] = update_operation end diff --git a/lib/view_model/active_record/update_data.rb b/lib/view_model/active_record/update_data.rb index 02d2d31e..7346cc3e 100644 --- a/lib/view_model/active_record/update_data.rb +++ b/lib/view_model/active_record/update_data.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'renum' require 'view_model/schemas' @@ -45,14 +47,13 @@ def initialize(removed_vm_refs) end end - # Parser for collection updates. Collection updates have a regular - # structure, but vary based on the contents. Parsing a {direct, - # owned} collection recurses deeply and creates a tree of - # UpdateDatas, while parsing a {through, shared} collection collects - # reference strings. + # Parser for collection updates. Collection updates have a regular structure, + # but vary based on the contents. Parsing a nested collection recurses deeply + # and creates a tree of UpdateDatas, while parsing a referenced collection + # collects reference strings. class AbstractCollectionUpdate - # Wraps a complete collection of new data: either UpdateDatas for owned - # collections or reference strings for shared. + # Wraps a complete collection of new data: either UpdateDatas for non-root + # associations or reference strings for root. class Replace attr_reader :contents def initialize(contents) @@ -61,7 +62,8 @@ def initialize(contents) end # Wraps an ordered list of FunctionalUpdates, each of whose `contents` are - # either UpdateData for owned collections or references for shared. + # either UpdateData for nested associations or references for referenced + # associations. class Functional attr_reader :actions def initialize(actions) @@ -81,8 +83,8 @@ def vm_references(update_context) # Resolve ViewModel::References used in the update's contents, whether by # reference or value. - def used_vm_refs(update_context) - raise "abstract" + def used_vm_refs(_update_context) + raise RuntimeError.new('abstract method') end def removed_vm_refs @@ -153,7 +155,8 @@ def parse_action(action) # The shape of the actions are always the same # Parse an anchor for a functional_update, before/after - # May only contain type and id fields, is never a reference even for shared collections. + # May only contain type and id fields, is never a reference even for + # referenced associations. def parse_anchor(child_hash) # final child_metadata = ViewModel.extract_reference_only_metadata(child_hash) @@ -271,9 +274,13 @@ class Functional < AbstractCollectionUpdate::Functional def used_vm_refs(update_context) update_datas - .map { |upd| upd.viewmodel_reference if upd.id } + .map { |upd| resolve_vm_reference(upd, update_context) } .compact end + + def resolve_vm_reference(update_data, _update_context) + update_data.viewmodel_reference if update_data.id + end end class Parser < AbstractCollectionUpdate::Parser @@ -319,9 +326,13 @@ class Functional < AbstractCollectionUpdate::Functional def used_vm_refs(update_context) references.map do |ref| - update_context.resolve_reference(ref, nil).viewmodel_reference + resolve_vm_reference(ref, update_context) end end + + def resolve_vm_reference(ref, update_context) + update_context.resolve_reference(ref, nil).viewmodel_reference + end end class Parser < AbstractCollectionUpdate::Parser @@ -356,6 +367,7 @@ def parse_contents(values) unless valid_reference_keys.include?(ref) raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference) end + ref end end @@ -421,7 +433,7 @@ module Schemas fupdate_owned = fupdate_base.(ViewModel::Schemas::VIEWMODEL_UPDATE_SCHEMA) - fupdate_shared = + fupdate_shared = fupdate_base.({ 'oneOf' => [ViewModel::Schemas::VIEWMODEL_REFERENCE_SCHEMA, viewmodel_reference_only] }) @@ -571,30 +583,15 @@ def to_sequence(name, value) case when value.nil? [] - when association_data.shared? + when association_data.referenced? [] - when association_data.collection? # not shared, because of shared? check above + when association_data.collection? # nested, because of referenced? check above value.update_datas else [value] end end - # Updates in terms of viewmodel associations - def updated_associations - deps = {} - - (associations.merge(referenced_associations)).each do |assoc_name, assoc_update| - deps[assoc_name] = - to_sequence(assoc_name, assoc_update) - .each_with_object({}) do |update_data, updated_associations| - updated_associations.deep_merge!(update_data.updated_associations) - end - end - - deps - end - def build_preload_specs(association_data, updates) if association_data.polymorphic? updates.map do |update_data| @@ -682,6 +679,7 @@ def parse(hash_data, valid_reference_keys) when AssociationData association_data = member_data + case when value.nil? if association_data.collection? @@ -689,28 +687,28 @@ def parse(hash_data, valid_reference_keys) "Invalid collection update value 'nil' for association '#{name}'", blame_reference) end - if association_data.shared? + if association_data.referenced? referenced_associations[name] = nil else associations[name] = nil end - when association_data.through? - referenced_associations[name] = - ReferencedCollectionUpdate::Parser - .new(association_data, blame_reference, valid_reference_keys) - .parse(value) + when association_data.referenced? + if association_data.collection? + referenced_associations[name] = + ReferencedCollectionUpdate::Parser + .new(association_data, blame_reference, valid_reference_keys) + .parse(value) + else + # Extract and check reference + ref = ViewModel.extract_reference_metadata(value) - when association_data.shared? - # Extract and check reference - ref = ViewModel.extract_reference_metadata(value) + unless valid_reference_keys.include?(ref) + raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference) + end - unless valid_reference_keys.include?(ref) - raise ViewModel::DeserializationError::InvalidSharedReference.new(ref, blame_reference) + referenced_associations[name] = ref end - - referenced_associations[name] = ref - else if association_data.collection? associations[name] = diff --git a/lib/view_model/active_record/update_operation.rb b/lib/view_model/active_record/update_operation.rb index 9b1e2c45..ada95837 100644 --- a/lib/view_model/active_record/update_operation.rb +++ b/lib/view_model/active_record/update_operation.rb @@ -40,10 +40,6 @@ def viewmodel_reference end end - def deferred? - viewmodel.nil? - end - def built? @built end @@ -112,9 +108,8 @@ def run!(deserialize_context:) if child_operation child_ctx = viewmodel.context_for_child(association_data.association_name, context: deserialize_context) child_viewmodel = child_operation.run!(deserialize_context: child_ctx) - if !association_data.shared? && child_viewmodel.previous_changes.changed_tree? - viewmodel.children_changed! - end + propagate_tree_changes(association_data, child_viewmodel.previous_changes) + child_viewmodel.model end association.writer(new_target) @@ -153,9 +148,8 @@ def run!(deserialize_context:) if child_operation ViewModel::Utils.map_one_or_many(child_operation) do |op| child_viewmodel = op.run!(deserialize_context: child_ctx) - if !association_data.shared? && child_viewmodel.previous_changes.changed_tree? - viewmodel.children_changed! - end + propagate_tree_changes(association_data, child_viewmodel.previous_changes) + child_viewmodel.model end end @@ -183,7 +177,11 @@ def run!(deserialize_context:) child_hook_control.record_changes(changes) end - viewmodel.children_changed! unless child_association_data.shared? + if child_association_data.nested? + viewmodel.nested_children_changed! + elsif child_association_data.owned? + viewmodel.referenced_children_changed! + end end debug "<- #{debug_name}: Finished checking released children permissions" end @@ -208,9 +206,18 @@ def run!(deserialize_context:) raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex, blame_reference) end + def propagate_tree_changes(association_data, child_changes) + if association_data.nested? + viewmodel.nested_children_changed! if child_changes.changed_nested_tree? + viewmodel.referenced_children_changed! if child_changes.changed_referenced_children? + elsif association_data.owned? + viewmodel.referenced_children_changed! if child_changes.changed_owned_tree? + end + end + # Recursively builds UpdateOperations for the associations in our UpdateData def build!(update_context) - raise ViewModel::DeserializationError::Internal.new("Internal error: UpdateOperation cannot build a deferred update") if deferred? + raise ViewModel::DeserializationError::Internal.new("Internal error: UpdateOperation cannot build a deferred update") if viewmodel.nil? return self if built? update_data.associations.each do |association_name, association_update_data| @@ -231,8 +238,10 @@ def build!(update_context) update = if association_data.through? build_updates_for_collection_referenced_association(association_data, reference_string, update_context) + elsif association_data.collection? + build_updates_for_collection_association(association_data, reference_string, update_context) else - build_update_for_single_referenced_association(association_data, reference_string, update_context) + build_update_for_single_association(association_data, reference_string, update_context) end add_update(association_data, update) @@ -254,38 +263,6 @@ def add_update(association_data, update) private - def build_update_for_single_referenced_association(association_data, reference_string, update_context) - # TODO intern loads for shared items so we only load them once - model = self.viewmodel.model - previous_child_viewmodel = model.public_send(association_data.direct_reflection.name).try do |previous_child_model| - vm_class = association_data.viewmodel_class_for_model!(previous_child_model.class) - vm_class.new(previous_child_model) - end - - if reference_string.nil? - referred_update = nil - referred_viewmodel = nil - else - referred_update = update_context.resolve_reference(reference_string, blame_reference) - referred_viewmodel = referred_update.viewmodel - - unless association_data.accepts?(referred_viewmodel.class) - raise ViewModel::DeserializationError::InvalidAssociationType.new( - association_data.association_name.to_s, - referred_viewmodel.class.view_name, - blame_reference) - end - - referred_update.build!(update_context) - end - - if previous_child_viewmodel != referred_viewmodel - viewmodel.association_changed!(association_data.association_name) - end - - referred_update - end - # Resolve or construct viewmodels for incoming update data. Where a child # hash references an existing model not currently attached to this parent, # it must be found before recursing into that child. If the model is @@ -321,6 +298,60 @@ def resolve_child_viewmodels(association_data, update_datas, previous_child_view end end + def resolve_referenced_viewmodels(association_data, update_datas, previous_child_viewmodels, update_context) + previous_child_viewmodels = Array.wrap(previous_child_viewmodels).index_by(&:to_reference) + + ViewModel::Utils.map_one_or_many(update_datas) do |update_data| + if update_data.is_a?(UpdateData) + # Dummy child update data for an unmodified previous child of a + # functional update; create it an empty update operation. + viewmodel = previous_child_viewmodels.fetch(update_data.viewmodel_reference) + update = update_context.new_update(viewmodel, update_data) + next [update, viewmodel] + end + + reference_string = update_data + child_update = update_context.resolve_reference(reference_string, blame_reference) + child_viewmodel = child_update.viewmodel + + unless association_data.accepts?(child_viewmodel.class) + raise ViewModel::DeserializationError::InvalidAssociationType.new( + association_data.association_name.to_s, + child_viewmodel.class.view_name, + blame_reference) + end + + child_ref = child_viewmodel.to_reference + + # The case of two potential owners trying to claim a new referenced + # child is covered by set_reference_update_parent. + claimed = !association_data.owned? || + child_update.update_data.new? || + previous_child_viewmodels.has_key?(child_ref) || + update_context.try_take_released_viewmodel(child_ref).present? + + if claimed + [child_update, child_viewmodel] + else + # Return the reference to indicate a deferred update + [child_update, child_ref] + end + end + end + + def set_reference_update_parent(association_data, update, parent_data) + if update.reparent_to + # Another parent has already tried to take this (probably new) + # owned referenced view. It can only be claimed by one of them. + other_parent = update.reparent_to.viewmodel.to_reference + raise ViewModel::DeserializationError::DuplicateOwner.new( + association_data.association_name, + [blame_reference, other_parent]) + end + + update.reparent_to = parent_data + end + def build_update_for_single_association(association_data, association_update_data, update_context) model = self.viewmodel.model @@ -329,25 +360,63 @@ def build_update_for_single_association(association_data, association_update_dat vm_class.new(previous_child_model) end - if previous_child_viewmodel.present? - # Clear the cached association so that AR's save behaviour doesn't - # conflict with our explicit parent updates. If we still have a child - # after the update, we'll either call `Association#writer` or manually - # fix the target cache after recursing in run!(). If we don't, we promise - # that the child will no longer be attached in the database, so the new - # cached data of nil will be correct. - clear_association_cache(model, association_data.direct_reflection) + if association_data.pointer_location == :remote + if previous_child_viewmodel.present? + # Clear the cached association so that AR's save behaviour doesn't + # conflict with our explicit parent updates. If we still have a child + # after the update, we'll either call `Association#writer` or manually + # fix the target cache after recursing in run!(). If we don't, we promise + # that the child will no longer be attached in the database, so the new + # cached data of nil will be correct. + clear_association_cache(model, association_data.direct_reflection) + end + + reparent_data = + ParentData.new(association_data.direct_reflection.inverse_of, viewmodel) end - child_viewmodel = - if association_update_data.present? - resolve_child_viewmodels(association_data, association_update_data, previous_child_viewmodel, update_context) + if association_update_data.present? + if association_data.referenced? + # resolve reference string + reference_string = association_update_data + child_update, child_viewmodel = resolve_referenced_viewmodels(association_data, reference_string, + previous_child_viewmodel, update_context) + + if reparent_data + set_reference_update_parent(association_data, child_update, reparent_data) + end + + if child_viewmodel.is_a?(ViewModel::Reference) + update_context.defer_update(child_viewmodel, child_update) + end + else + # Resolve direct children + child_viewmodel = + resolve_child_viewmodels(association_data, association_update_data, previous_child_viewmodel, update_context) + + child_update = + if child_viewmodel.is_a?(ViewModel::Reference) + update_context.new_deferred_update(child_viewmodel, association_update_data, reparent_to: reparent_data) + else + update_context.new_update(child_viewmodel, association_update_data, reparent_to: reparent_data) + end end + # Build the update if we've claimed it + unless child_viewmodel.is_a?(ViewModel::Reference) + child_update.build!(update_context) + end + else + child_update = nil + child_viewmodel = nil + end + + # Handle changes if previous_child_viewmodel != child_viewmodel viewmodel.association_changed!(association_data.association_name) - # free previous child if present - if previous_child_viewmodel.present? + + # free previous child if present and owned + if previous_child_viewmodel.present? && association_data.owned? if association_data.pointer_location == :local # When we free a child that's pointed to from its old parent, we need to # clear the cached association to that old parent. If we don't do this, @@ -358,12 +427,7 @@ def build_update_for_single_association(association_data, association_update_dat # model.association(...).inverse_reflection_for(previous_child_model), but # that's private. - inverse_reflection = - if association_data.direct_reflection.polymorphic? - association_data.direct_reflection.polymorphic_inverse_of(previous_child_viewmodel.model.class) - else - association_data.direct_reflection.inverse_of - end + inverse_reflection = association_data.direct_reflection_inverse(previous_child_viewmodel.model.class) if inverse_reflection.present? clear_association_cache(previous_child_viewmodel.model, inverse_reflection) @@ -374,25 +438,7 @@ def build_update_for_single_association(association_data, association_update_dat end end - # Construct and return update for new child viewmodel - if child_viewmodel.present? - # If the association's pointer is in the child, need to provide it with a - # ParentData to update - parent_data = - if association_data.pointer_location == :remote - ParentData.new(association_data.direct_reflection.inverse_of, viewmodel) - else - nil - end - - case child_viewmodel - when ViewModel::Reference # deferred - vm_ref = child_viewmodel - update_context.new_deferred_update(vm_ref, association_update_data, reparent_to: parent_data) - else - update_context.new_update(child_viewmodel, association_update_data, reparent_to: parent_data).build!(update_context) - end - end + child_update end def build_updates_for_collection_association(association_data, association_update, update_context) @@ -402,11 +448,13 @@ def build_updates_for_collection_association(association_data, association_updat parent_data = ParentData.new(association_data.direct_reflection.inverse_of, viewmodel) # load children already attached to this model - child_viewmodel_class = association_data.viewmodel_class + child_viewmodel_class = association_data.viewmodel_class + previous_child_viewmodels = model.public_send(association_data.direct_reflection.name).map do |child_model| child_viewmodel_class.new(child_model) end + if child_viewmodel_class._list_member? previous_child_viewmodels.sort_by!(&:_list_attribute) end @@ -421,114 +469,178 @@ def build_updates_for_collection_association(association_data, association_updat clear_association_cache(model, association_data.direct_reflection) end - child_datas = - case association_update - when OwnedCollectionUpdate::Replace - association_update.update_datas + # Update contents are either UpdateData in the case of a nested + # association, or reference strings in the case of a reference association. + # The former are resolved with resolve_child_viewmodels, the latter with + # resolve_referenced_viewmodels. + resolve_child_data_reference = ->(child_data) do + case child_data + when UpdateData + child_data.viewmodel_reference if child_data.id + when String + update_context.resolve_reference(child_data, nil).viewmodel_reference + else + raise ViewModel::DeserializationError::Internal.new( + "Unexpected child data type in collection update: #{child_data.class.name}") + end + end - when OwnedCollectionUpdate::Functional - child_datas = - previous_child_viewmodels.map do |previous_child_viewmodel| - UpdateData.empty_update_for(previous_child_viewmodel) - end + case association_update + when AbstractCollectionUpdate::Replace + child_datas = association_update.contents - association_update.check_for_duplicates!(update_context, self.viewmodel.blame_reference) + when AbstractCollectionUpdate::Functional + # A fupdate isn't permitted to edit the same model twice. + association_update.check_for_duplicates!(update_context, blame_reference) - association_update.actions.each do |fupdate| - case fupdate - when FunctionalUpdate::Append - if fupdate.before || fupdate.after - moved_refs = fupdate.contents.map(&:viewmodel_reference).to_set - child_datas = child_datas.reject { |child| moved_refs.include?(child.viewmodel_reference) } + # Construct empty updates for previous children + child_datas = + previous_child_viewmodels.map do |previous_child_viewmodel| + UpdateData.empty_update_for(previous_child_viewmodel) + end - ref = fupdate.before || fupdate.after - index = child_datas.find_index { |cd| cd.viewmodel_reference == ref } - unless index - raise ViewModel::DeserializationError::AssociatedNotFound.new( - association_data.association_name.to_s, ref, blame_reference) - end + # Insert or replace with either real UpdateData or reference strings + association_update.actions.each do |fupdate| + case fupdate + when FunctionalUpdate::Append + # If we're referring to existing members, ensure that they're removed before we append/insert + existing_refs = fupdate.contents + .map(&resolve_child_data_reference) + .to_set + + child_datas.reject! do |child_data| + child_ref = resolve_child_data_reference.(child_data) + child_ref && existing_refs.include?(child_ref) + end - index += 1 if fupdate.after - child_datas.insert(index, *fupdate.contents) + if fupdate.before || fupdate.after + rel_ref = fupdate.before || fupdate.after - else - child_datas.concat(fupdate.contents) + # Find the relative insert location. This might be an empty + # UpdateData from a previous child or an already-fupdated + # reference string. + index = child_datas.find_index do |child_data| + rel_ref == resolve_child_data_reference.(child_data) + end + unless index + raise ViewModel::DeserializationError::AssociatedNotFound.new( + association_data.association_name.to_s, rel_ref, blame_reference) end - when FunctionalUpdate::Remove - removed_refs = fupdate.removed_vm_refs.to_set - child_datas.reject! { |child_data| removed_refs.include?(child_data.viewmodel_reference) } + index += 1 if fupdate.after + child_datas.insert(index, *fupdate.contents) + + else + child_datas.concat(fupdate.contents) + end - when FunctionalUpdate::Update - # Already guaranteed that each ref has a single data attached - new_datas = fupdate.contents.index_by(&:viewmodel_reference) + when FunctionalUpdate::Remove + removed_refs = fupdate.removed_vm_refs.to_set + child_datas.reject! do |child_data| + child_ref = resolve_child_data_reference.(child_data) + removed_refs.include?(child_ref) + end - child_datas = child_datas.map do |child_data| - ref = child_data.viewmodel_reference - new_datas.delete(ref) { child_data } - end + when FunctionalUpdate::Update + # Already guaranteed that each ref has a single existing child attached + new_child_datas = fupdate.contents.index_by(&resolve_child_data_reference) - # Assertion that all values in update_op.values are present in the collection - unless new_datas.empty? - raise ViewModel::DeserializationError::AssociatedNotFound.new( - association_data.association_name.to_s, new_datas.keys, blame_reference) - end - else - raise ViewModel::DeserializationError::InvalidSyntax.new( - "Unknown functional update type: '#{fupdate.type}'", - blame_reference) + # Replace matched child_datas with the update contents. + child_datas.map! do |child_data| + child_ref = resolve_child_data_reference.(child_data) + new_child_datas.delete(child_ref) { child_data } end + + # Assertion that all values in the update were found in child_datas + unless new_child_datas.empty? + raise ViewModel::DeserializationError::AssociatedNotFound.new( + association_data.association_name.to_s, new_child_datas.keys, blame_reference) + end + else + raise ViewModel::DeserializationError::InvalidSyntax.new( + "Unknown functional update type: '#{fupdate.type}'", + blame_reference) end + end + end + + if association_data.referenced? + # child_datas are either pre-resolved UpdateData (for non-fupdated + # existing members) or reference strings. Resolve into pairs of + # [UpdateOperation, ViewModel] if we can create or claim the + # UpdateOperation or [UpdateOperation, ViewModelReference] otherwise. + resolved_children = + resolve_referenced_viewmodels(association_data, child_datas, previous_child_viewmodels, update_context) - child_datas + resolved_children.each do |child_update, child_viewmodel| + set_reference_update_parent(association_data, child_update, parent_data) + + if child_viewmodel.is_a?(ViewModel::Reference) + update_context.defer_update(child_viewmodel, child_update) + end end - child_viewmodels = resolve_child_viewmodels(association_data, child_datas, previous_child_viewmodels, update_context) + else + # child datas are all UpdateData + child_viewmodels = resolve_child_viewmodels(association_data, child_datas, previous_child_viewmodels, update_context) - # if the new children differ, including in order, mark that one of our - # associations has changed and release any no-longer-attached children - if child_viewmodels != previous_child_viewmodels - viewmodel.association_changed!(association_data.association_name) - released_child_viewmodels = previous_child_viewmodels - child_viewmodels - released_child_viewmodels.each do |vm| - release_viewmodel(vm, association_data, update_context) + resolved_children = child_datas.zip(child_viewmodels).map do |child_data, child_viewmodel| + child_update = + if child_viewmodel.is_a?(ViewModel::Reference) + update_context.new_deferred_update(child_viewmodel, child_data, reparent_to: parent_data) + else + update_context.new_update(child_viewmodel, child_data, reparent_to: parent_data) + end + + [child_update, child_viewmodel] end end # Calculate new positions for children if in a list. Ignore previous - # positions for unresolved references: they'll always need to be updated - # anyway since their parent pointer will change. - new_positions = Array.new(child_viewmodels.length) + # positions (i.e. return nil) for unresolved references: they'll always + # need to be updated anyway since their parent pointer will change. + new_positions = Array.new(resolved_children.length) if association_data.viewmodel_class._list_member? set_position = ->(index, pos) { new_positions[index] = pos } + get_previous_position = ->(index) do - vm = child_viewmodels[index] + vm = resolved_children[index][1] vm._list_attribute unless vm.is_a?(ViewModel::Reference) end ActsAsManualList.update_positions( - (0...child_viewmodels.size).to_a, # indexes + (0...resolved_children.size).to_a, # indexes position_getter: get_previous_position, position_setter: set_position) end - # Recursively build update operations for children - child_updates = child_viewmodels.zip(child_datas, new_positions).map do |child_viewmodel, association_update_data, position| - case child_viewmodel - when ViewModel::Reference # deferred - reference = child_viewmodel - update_context.new_deferred_update(reference, association_update_data, reparent_to: parent_data, reposition_to: position) - else - update_context.new_update(child_viewmodel, association_update_data, reparent_to: parent_data, reposition_to: position).build!(update_context) + resolved_children.zip(new_positions).each do |(child_update, child_viewmodel), new_position| + child_update.reposition_to = new_position + + # Recurse into building child updates that we've claimed + unless child_viewmodel.is_a?(ViewModel::Reference) + child_update.build!(update_context) + end + end + + child_updates, child_viewmodels = resolved_children.transpose.presence || [[], []] + + # if the new children differ, including in order, mark that this + # association has changed and release any no-longer-attached children + if child_viewmodels != previous_child_viewmodels + viewmodel.association_changed!(association_data.association_name) + + released_child_viewmodels = previous_child_viewmodels - child_viewmodels + released_child_viewmodels.each do |vm| + release_viewmodel(vm, association_data, update_context) end end child_updates end - class ReferencedCollectionMember attr_reader :indirect_viewmodel_reference, :direct_viewmodel attr_accessor :ref_string, :position @@ -694,7 +806,7 @@ def build_updates_for_collection_referenced_association(association_data, associ target_collection = MutableReferencedCollection.new( association_data, update_context, previous_members, blame_reference) - # All updates to shared collections produce a complete target list of + # All updates to referenced collections produce a complete target list of # ReferencedCollectionMembers including a ViewModel::Reference to the # indirect child, and an existing (from previous) or new ViewModel for the # direct child. diff --git a/lib/view_model/active_record/visitor.rb b/lib/view_model/active_record/visitor.rb index 6d74e692..d22d22d9 100644 --- a/lib/view_model/active_record/visitor.rb +++ b/lib/view_model/active_record/visitor.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true class ViewModel::ActiveRecord::Visitor - attr_reader :visit_shared, :for_edit + attr_reader :visit_referenced, :visit_shared, :for_edit - def initialize(visit_shared: true, for_edit: false) - @visit_shared = visit_shared - @for_edit = for_edit + def initialize(visit_referenced: true, visit_shared: true, for_edit: false) + @visit_referenced = visit_referenced + @visit_shared = visit_shared + @for_edit = for_edit end def visit(view, context: nil) @@ -29,8 +30,10 @@ def visit(view, context: nil) # visit the underlying viewmodel for each association, ignoring any # customization view.class._members.each do |name, member_data| - next unless member_data.is_a?(ViewModel::ActiveRecord::AssociationData) - next if member_data.shared? && !visit_shared + next unless member_data.association? + next if member_data.referenced? && !visit_referenced + next if !member_data.owned? && !visit_shared + children = Array.wrap(view._read_association(name)) children.each do |child| if context diff --git a/lib/view_model/changes.rb b/lib/view_model/changes.rb index 00153f9a..3b662fa7 100644 --- a/lib/view_model/changes.rb +++ b/lib/view_model/changes.rb @@ -5,18 +5,20 @@ class ViewModel::Changes using ViewModel::Utils::Collections - attr_reader :new, :changed_attributes, :changed_associations, :changed_children, :deleted + attr_reader :new, :changed_attributes, :changed_associations, :changed_nested_children, :changed_referenced_children, :deleted alias new? new alias deleted? deleted - alias changed_children? changed_children + alias changed_nested_children? changed_nested_children + alias changed_referenced_children? changed_referenced_children - def initialize(new: false, changed_attributes: [], changed_associations: [], changed_children: false, deleted: false) - @new = new - @changed_attributes = changed_attributes.map(&:to_s) - @changed_associations = changed_associations.map(&:to_s) - @changed_children = changed_children - @deleted = deleted + def initialize(new: false, changed_attributes: [], changed_associations: [], changed_nested_children: false, changed_referenced_children: false, deleted: false) + @new = new + @changed_attributes = changed_attributes.map(&:to_s) + @changed_associations = changed_associations.map(&:to_s) + @changed_nested_children = changed_nested_children + @changed_referenced_children = changed_referenced_children + @deleted = deleted end def contained_to?(associations: [], attributes: []) @@ -34,17 +36,22 @@ def changed? new? || deleted? || changed_attributes.present? || changed_associations.present? end - def changed_tree? - changed? || changed_children? + def changed_nested_tree? + changed? || changed_nested_children? + end + + def changed_owned_tree? + changed? || changed_nested_children? || changed_referenced_children? end def to_h { - 'changed_attributes' => changed_attributes.dup, - 'changed_associations' => changed_associations.dup, - 'new' => new?, - 'changed_children' => changed_children?, - 'deleted' => deleted?, + 'changed_attributes' => changed_attributes.dup, + 'changed_associations' => changed_associations.dup, + 'new' => new?, + 'changed_nested_children' => changed_nested_children?, + 'changed_referenced_children' => changed_referenced_children?, + 'deleted' => deleted?, } end @@ -52,7 +59,8 @@ def ==(other) return false unless other.is_a?(ViewModel::Changes) self.new? == other.new? && - self.changed_children? == other.changed_children? && + self.changed_nested_children? == other.changed_nested_children? && + self.changed_referenced_children? == other.changed_referenced_children? && self.deleted? == other.deleted? && self.changed_attributes.contains_exactly?(other.changed_attributes) && self.changed_associations.contains_exactly?(other.changed_associations) diff --git a/lib/view_model/config.rb b/lib/view_model/config.rb index ca9baa25..a6c2bc1b 100644 --- a/lib/view_model/config.rb +++ b/lib/view_model/config.rb @@ -10,7 +10,7 @@ class ViewModel::Config def self.configure!(&block) - if instance_variable_defined?(:@instance) + if configured? raise ArgumentError.new('ViewModel library already configured') end @@ -18,8 +18,12 @@ def self.configure!(&block) @instance = builder.build!(&block) end + def self.configured? + instance_variable_defined?(:@instance) + end + def self._option(opt) - configure! unless instance_variable_defined?(:@instance) + configure! unless configured? @instance[opt] end diff --git a/lib/view_model/deserialization_error.rb b/lib/view_model/deserialization_error.rb index 3e06f7ff..6c776261 100644 --- a/lib/view_model/deserialization_error.rb +++ b/lib/view_model/deserialization_error.rb @@ -226,6 +226,19 @@ def meta end end + class DuplicateOwner < InvalidRequest + attr_reader :association_name + + def initialize(association_name, parents) + @association_name = association_name + super(parents) + end + + def detail + "Multiple parents attempted to claim the same owned '#{association_name}' reference: " + nodes.map(&:to_s).join(", ") + end + end + class ParentNotFound < NotFound def detail "Could not resolve release from previous parent for the following owned viewmodel(s): " + @@ -251,6 +264,24 @@ def meta end end + class ReadOnlyAssociation < DeserializationError + status 400 + attr_reader :association + + def initialize(association, node) + @association = association + super([node]) + end + + def detail + "Cannot edit read only association '#{association}'" + end + + def meta + super.merge(association: association) + end + end + class ReadOnlyType < DeserializationError status 400 detail "Deserialization not defined for view type" diff --git a/lib/view_model/deserialize_context.rb b/lib/view_model/deserialize_context.rb index f91041be..1b99112f 100644 --- a/lib/view_model/deserialize_context.rb +++ b/lib/view_model/deserialize_context.rb @@ -1,16 +1,12 @@ +# frozen_string_literal: true + require 'view_model/traversal_context' class ViewModel::DeserializeContext < ViewModel::TraversalContext class SharedContext < ViewModel::TraversalContext::SharedContext - # During deserialization, collects a tree of viewmodel association names that - # were updated. Used to ensure that updated associations are always included - # in response serialization after deserialization, even if hidden by default. - attr_accessor :updated_associations end def self.shared_context_class SharedContext end - - delegate :updated_associations, :"updated_associations=", to: :shared_context end diff --git a/lib/view_model/record.rb b/lib/view_model/record.rb index 9c62d1f6..ef84437a 100644 --- a/lib/view_model/record.rb +++ b/lib/view_model/record.rb @@ -36,22 +36,30 @@ def should_register? end # Specifies an attribute from the model to be serialized in this view - def attribute(attr, as: nil, read_only: false, write_once: false, using: nil, format: nil, array: false, optional: false) + def attribute(attr, as: nil, read_only: false, write_once: false, using: nil, format: nil, array: false) model_attribute_name = attr.to_s vm_attribute_name = (as || attr).to_s if using && format - raise ArgumentError.new("Only one of :using and :format may be specified") + raise ArgumentError.new("Only one of ':using' and ':format' may be specified") end if using && !(using.is_a?(Class) && using < ViewModel) raise ArgumentError.new("Invalid 'using:' viewmodel: not a viewmodel class") end + if using && using.root? + raise ArgumentError.new("Invalid 'using:' viewmodel: is a root") + end if format && !format.respond_to?(:dump) && !format.respond_to?(:load) raise ArgumentError.new("Invalid 'format:' serializer: must respond to :dump and :load") end - attr_data = AttributeData.new(vm_attribute_name, model_attribute_name, using, format, - array, optional, read_only, write_once) + attr_data = AttributeData.new(name: vm_attribute_name, + model_attr_name: model_attribute_name, + attribute_viewmodel: using, + attribute_serializer: format, + array: array, + read_only: read_only, + write_once: write_once) _members[vm_attribute_name] = attr_data @generated_accessor_module.module_eval do @@ -166,9 +174,10 @@ def initialize(model) self.model = model - @new_model = false - @changed_attributes = [] - @changed_children = false + @new_model = false + @changed_attributes = [] + @changed_nested_children = false + @changed_referenced_children = false end # VM::Record identity matches the identity of its model. If the model has a @@ -189,8 +198,12 @@ def new_model? @new_model end - def changed_children? - @changed_children + def changed_nested_children? + @changed_nested_children + end + + def changed_referenced_children? + @changed_referenced_children end def serialize_view(json, serialize_context: self.class.new_serialize_context) @@ -202,9 +215,7 @@ def serialize_view(json, serialize_context: self.class.new_serialize_context) end def serialize_members(json, serialize_context:) - self.class._members.each do |member_name, member_data| - next unless serialize_context.includes_member?(member_name, !member_data.optional?) - + self.class._members.each do |member_name, _member_data| self.public_send("serialize_#{member_name}", json, serialize_context: serialize_context) end end @@ -227,22 +238,29 @@ def attribute_changed!(attr_name) @changed_attributes << attr_name.to_s end - def children_changed! - @changed_children = true + def nested_children_changed! + @changed_nested_children = true + end + + def referenced_children_changed! + @changed_referenced_children = true end def changes ViewModel::Changes.new( - new: new_model?, - changed_attributes: changed_attributes, - changed_children: changed_children?) + new: new_model?, + changed_attributes: changed_attributes, + changed_nested_children: changed_nested_children?, + changed_referenced_children: changed_referenced_children?, + ) end def clear_changes! - @previous_changes = changes - @new_model = false - @changed_attributes = [] - @changed_children = false + @previous_changes = changes + @new_model = false + @changed_attributes = [] + @changed_nested_children = false + @changed_referenced_children = false previous_changes end @@ -348,9 +366,11 @@ def _deserialize_attribute(attr_data, serialized_value, references:, deserialize model.public_send("#{attr_data.model_attr_name}=", value) end - if attr_data.using_viewmodel? && - Array.wrap(value).any? { |v| v.respond_to?(:previous_changes) && v.previous_changes.changed_tree? } - self.children_changed! + if attr_data.using_viewmodel? + previous_changes = Array.wrap(value).select { |v| v.respond_to?(:previous_changes) }.map!(&:previous_changes) + + self.nested_children_changed! if previous_changes.any? { |pc| pc.changed_nested_tree? } + self.referenced_children_changed! if previous_changes.any? { |pc| pc.changed_referenced_children? } end end diff --git a/lib/view_model/record/attribute_data.rb b/lib/view_model/record/attribute_data.rb index 14a4a375..37582f72 100644 --- a/lib/view_model/record/attribute_data.rb +++ b/lib/view_model/record/attribute_data.rb @@ -3,23 +3,28 @@ class ViewModel::Record::AttributeData attr_reader :name, :model_attr_name, :attribute_viewmodel, :attribute_serializer - def initialize(name, model_attr_name, attribute_viewmodel, attribute_serializer, array, optional, read_only, write_once) + def initialize(name:, + model_attr_name:, + attribute_viewmodel:, + attribute_serializer:, + array:, + read_only:, + write_once:) @name = name @model_attr_name = model_attr_name @attribute_viewmodel = attribute_viewmodel @attribute_serializer = attribute_serializer @array = array - @optional = optional @read_only = read_only @write_once = write_once end - def array? - @array + def association? + false end - def optional? - @optional + def array? + @array end def read_only? diff --git a/lib/view_model/serialize_context.rb b/lib/view_model/serialize_context.rb index 61ac18a5..5d08cb06 100644 --- a/lib/view_model/serialize_context.rb +++ b/lib/view_model/serialize_context.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_support/core_ext' require 'view_model/traversal_context' @@ -17,52 +19,6 @@ def self.shared_context_class end delegate :references, :flatten_references, to: :shared_context - - attr_reader :include, :prune - - def initialize(include: nil, prune: nil, **rest) - super(**rest) - @include = self.class.normalize_includes(include) - @prune = self.class.normalize_includes(prune) - end - - def initialize_as_child(include:, prune:, **rest) - super(**rest) - @include = include - @prune = prune - end - - def for_child(parent_viewmodel, association_name:, **rest) - super(parent_viewmodel, - association_name: association_name, - include: @include.try { |i| i[association_name] }, - prune: @prune.try { |p| p[association_name] }, - **rest) - end - - def includes_member?(member_name, default) - member_name = member_name.to_s - - # Every node in the include tree is to be included - included = @include.try { |is| is.has_key?(member_name) } - # whereas only the leaves of the prune tree are to be removed - pruned = @prune.try { |ps| ps.fetch(member_name, :sentinel).nil? } - - (default || included) && !pruned - end - - def add_includes(includes) - return if includes.blank? - @include ||= {} - @include.deep_merge!(self.class.normalize_includes(includes)) - end - - def add_prunes(prunes) - return if prunes.blank? - @prune ||= {} - @prune.deep_merge!(self.class.normalize_includes(prunes)) - end - delegate :add_reference, :has_references?, to: :references # Return viewmodels referenced during serialization and clear @references. @@ -98,21 +54,4 @@ def serialize_references(json) def serialize_references_to_hash Jbuilder.new { |json| serialize_references(json) }.attributes! end - - def self.normalize_includes(includes) - case includes - when Array - includes.each_with_object({}) do |v, new_includes| - new_includes.merge!(normalize_includes(v)) - end - when Hash - includes.each_with_object({}) do |(k,v), new_includes| - new_includes[k.to_s] = normalize_includes(v) - end - when nil - nil - else - { includes.to_s => nil } - end - end end diff --git a/shell.nix b/shell.nix index 2890df23..8b840064 100644 --- a/shell.nix +++ b/shell.nix @@ -1,7 +1,7 @@ with import {}; (bundlerEnv { - name = "dev"; + name = "iknow-view-models-shell"; gemdir = ./nix/gem; gemConfig = (defaultGemConfig.override { postgresql = postgresql_11; }); diff --git a/test/helpers/arvm_test_utilities.rb b/test/helpers/arvm_test_utilities.rb index 4dbed66a..4a957a7c 100644 --- a/test/helpers/arvm_test_utilities.rb +++ b/test/helpers/arvm_test_utilities.rb @@ -4,6 +4,12 @@ require 'view_model' require 'view_model/test_helpers' +unless ViewModel::Config.configured? + ViewModel::Config.configure! do + debug_deserialization true + end +end + require_relative 'query_logging.rb' ActiveSupport::TestCase.include(Minitest::Hooks) diff --git a/test/helpers/controller_test_helpers.rb b/test/helpers/controller_test_helpers.rb index 69885837..626a7dbb 100644 --- a/test/helpers/controller_test_helpers.rb +++ b/test/helpers/controller_test_helpers.rb @@ -33,6 +33,7 @@ def before_all has_many :parents end define_viewmodel do + root! attributes :name end end @@ -77,11 +78,12 @@ def before_all belongs_to :category end define_viewmodel do + root! attributes :name associations :label, :target - association :children, optional: true + association :children association :poly, viewmodels: [:PolyOne, :PolyTwo] - association :category, shared: true + association :category, external: true end end @@ -99,7 +101,7 @@ def before_all define_model do belongs_to :parent, inverse_of: :children acts_as_manual_list scope: :parent - validates :age, numericality: {less_than: 42}, allow_nil: true + validates :age, numericality: { less_than: 42 }, allow_nil: true end define_viewmodel do attributes :name, :age diff --git a/test/helpers/viewmodel_spec_helpers.rb b/test/helpers/viewmodel_spec_helpers.rb index 1d1fc2c8..43fb38c5 100644 --- a/test/helpers/viewmodel_spec_helpers.rb +++ b/test/helpers/viewmodel_spec_helpers.rb @@ -77,7 +77,7 @@ def model_attributes ViewModel::TestHelpers::ARVMBuilder::Spec.new( schema: ->(t) { t.string :name }, model: ->(m) {}, - viewmodel: ->(v) { attribute :name } + viewmodel: ->(v) { root!; attribute :name } ) end @@ -99,6 +99,10 @@ def subject_association raise RuntimeError.new('Test model does not have a child association') end + def subject_association_features + {} + end + def subject_association_name subject_association.association_name end @@ -114,9 +118,10 @@ module ParentAndBelongsToChild include ViewModelSpecHelpers::Base def model_attributes + f = subject_association_features super.merge(schema: ->(t) { t.references :child, foreign_key: true }, model: ->(m) { belongs_to :child, inverse_of: :model, dependent: :destroy }, - viewmodel: ->(v) { association :child }) + viewmodel: ->(v) { association :child, **f }) end def child_attributes @@ -134,17 +139,33 @@ def subject_association end end + module ParentAndSharedBelongsToChild + extend ActiveSupport::Concern + include ViewModelSpecHelpers::ParentAndBelongsToChild + def child_attributes + super.merge(viewmodel: ->(v) { root! }) + end + end + module List extend ActiveSupport::Concern include ViewModelSpecHelpers::Base def model_attributes - super.merge(schema: ->(t) { t.integer :next_id }, - model: ->(m) { - belongs_to :next, class_name: self.name, inverse_of: :previous, dependent: :destroy - has_one :previous, class_name: self.name, foreign_key: :next_id, inverse_of: :next - }, - viewmodel: ->(v) { association :next }) + ViewModel::TestHelpers::ARVMBuilder::Spec.new( + schema: ->(t) { + t.string :name + t.integer :next_id + }, + model: ->(m) { + belongs_to :next, class_name: self.name, inverse_of: :previous, dependent: :destroy + has_one :previous, class_name: self.name, foreign_key: :next_id, inverse_of: :next + }, + viewmodel: ->(v) { + # Not a root + association :next + attribute :name + }) end def subject_association @@ -157,9 +178,10 @@ module ParentAndHasOneChild include ViewModelSpecHelpers::Base def model_attributes + f = subject_association_features super.merge( model: ->(m) { has_one :child, inverse_of: :model, dependent: :destroy }, - viewmodel: ->(v) { association :child } + viewmodel: ->(v) { association :child, **f } ) end @@ -181,14 +203,23 @@ def subject_association end end + module ParentAndReferencedHasOneChild + extend ActiveSupport::Concern + include ViewModelSpecHelpers::ParentAndHasOneChild + def child_attributes + super.merge(viewmodel: ->(v) { root! }) + end + end + module ParentAndHasManyChildren extend ActiveSupport::Concern include ViewModelSpecHelpers::Base def model_attributes + f = subject_association_features super.merge( model: ->(m) { has_many :children, inverse_of: :model, dependent: :destroy }, - viewmodel: ->(v) { association :children } + viewmodel: ->(v) { association :children, **f } ) end @@ -210,22 +241,23 @@ def subject_association end end - module ParentAndOrderedChildren + module ParentAndSharedHasManyChildren extend ActiveSupport::Concern - include ViewModelSpecHelpers::Base - - def model_attributes - super.merge( - model: ->(m) { has_many :children, inverse_of: :model, dependent: :destroy }, - viewmodel: ->(v) { association :children }, - ) + include ViewModelSpecHelpers::ParentAndHasManyChildren + def child_attributes + super.merge(viewmodel: ->(v) { root! }) end + end + + module ParentAndOrderedChildren + extend ActiveSupport::Concern + include ViewModelSpecHelpers::ParentAndHasManyChildren def child_attributes super.merge( - schema: ->(t) { t.references :model, foreign_key: true; t.float :position, null: false }, - model: ->(m) { belongs_to :model, inverse_of: :children }, - viewmodel: ->(v) { acts_as_list :position }, + schema: ->(t) { t.float :position, null: false }, + model: ->(_m) { acts_as_manual_list scope: :model }, + viewmodel: ->(_v) { acts_as_list :position }, ) end @@ -233,7 +265,7 @@ def child_viewmodel_class # child depends on parent, ensure it's touched first viewmodel_class - # Add a deferrable unique position constraiont + # Add a deferrable unique position constraint super do |klass| model = klass.model_class table = model.table_name @@ -242,38 +274,15 @@ def child_viewmodel_class SQL end end - - def subject_association - viewmodel_class._association_data('children') - end end - module ParentAndSharedChild - extend ActiveSupport::Concern - include ViewModelSpecHelpers::Base - def model_attributes - super.merge( - schema: ->(t) { t.references :child, foreign_key: true }, - model: ->(m) { belongs_to :child, inverse_of: :model, dependent: :destroy }, - viewmodel: ->(v) { association :child, shared: true } - ) - end - - def child_attributes - super.merge( - model: ->(m) { has_one :model, inverse_of: :child } - ) - end - - # parent depends on child, ensure it's touched first - def viewmodel_class - child_viewmodel_class - super - end + module ParentAndExternalSharedChild + extend ActiveSupport::Concern + include ViewModelSpecHelpers::ParentAndSharedBelongsToChild - def subject_association - viewmodel_class._association_data('child') + def subject_association_features + { external: true } end end @@ -282,15 +291,17 @@ module ParentAndHasManyThroughChildren include ViewModelSpecHelpers::Base def model_attributes + f = subject_association_features super.merge( model: ->(m) { has_many :model_children, inverse_of: :model, dependent: :destroy }, - viewmodel: ->(v) { association :children, shared: true, through: :model_children, through_order_attr: :position } + viewmodel: ->(v) { association :children, through: :model_children, through_order_attr: :position, **f } ) end def child_attributes super.merge( - model: ->(m) { has_many :model_children, inverse_of: :child, dependent: :destroy } + model: ->(m) { has_many :model_children, inverse_of: :child, dependent: :destroy }, + viewmodel: ->(v) { root! } ) end diff --git a/test/unit/view_model/access_control_test.rb b/test/unit/view_model/access_control_test.rb index 1b35dcb6..d80dd8e3 100644 --- a/test/unit/view_model/access_control_test.rb +++ b/test/unit/view_model/access_control_test.rb @@ -190,6 +190,7 @@ def before_all end define_viewmodel do + root! attribute :val association :tree2 @@ -215,7 +216,7 @@ def self.new_deserialize_context(**args) define_viewmodel do attribute :val - association :tree1, shared: true, optional: false + association :tree1 end end end @@ -447,15 +448,16 @@ class ChangeTrackingTest < ActiveSupport::TestCase include ViewModelSpecHelpers::List extend Minitest::Spec::DSL - def assert_changes_match(changes, new, deleted, children, attributes, associations) + def assert_changes_match(changes, n: false, d: false, nstc: false, refc: false, att: [], ass: []) assert_equal( changes, ViewModel::Changes.new( - new: new, - deleted: deleted, - changed_children: children, - changed_attributes: attributes, - changed_associations: associations)) + new: n, + deleted: d, + changed_nested_children: nstc, + changed_referenced_children: refc, + changed_attributes: att, + changed_associations: ass)) end describe 'with parent and points-to child test models' do @@ -479,7 +481,7 @@ def new_model_with_child vm = viewmodel_class.deserialize_from_view(view, deserialize_context: ctx) vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, true, false, false, ['name'], []) + assert_changes_match(vm_changes, n: true, att: ['name']) end it 'records a destroyed model' do @@ -489,7 +491,7 @@ def new_model_with_child vm.destroy!(deserialize_context: ctx) vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, true, false, [], []) + assert_changes_match(vm_changes, d: true) end it 'records a change to an attribute' do @@ -498,7 +500,7 @@ def new_model_with_child end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, false, ['name'], []) + assert_changes_match(vm_changes, att: ['name']) end it 'records a new child' do @@ -507,10 +509,10 @@ def new_model_with_child end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, true, [], ['child']) + assert_changes_match(vm_changes, nstc: true, ass: ['child']) c_changes = ctx.valid_edit_changes(vm.child.to_reference) - assert_changes_match(c_changes, true, false, false, ['name'], []) + assert_changes_match(c_changes, n: true, att: ['name']) end it 'records a replaced child' do @@ -522,14 +524,14 @@ def new_model_with_child end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, true, [], ['child']) + assert_changes_match(vm_changes, nstc: true, ass: ['child']) c_changes = ctx.valid_edit_changes(vm.child.to_reference) - assert_changes_match(c_changes, true, false, false, ['name'], []) + assert_changes_match(c_changes, n: true, att: ['name']) oc_changes = ctx.valid_edit_changes( ViewModel::Reference.new(child_viewmodel_class, old_child.id)) - assert_changes_match(oc_changes, false, true, false, [], []) + assert_changes_match(oc_changes, d: true) end it 'records an edited child' do @@ -542,10 +544,10 @@ def new_model_with_child # The parent node itself wasn't changed, so must not have been # valid_edit checked refute(ctx.was_edited?(vm.to_reference)) - assert_changes_match(vm.previous_changes, false, false, true, [], []) + assert_changes_match(vm.previous_changes, nstc: true) c_changes = ctx.valid_edit_changes(vm.child.to_reference) - assert_changes_match(c_changes, false, false, false, ['name'], []) + assert_changes_match(c_changes, att: ['name']) end it 'records a deleted child' do @@ -557,11 +559,11 @@ def new_model_with_child end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, true, [], ['child']) + assert_changes_match(vm_changes, nstc: true, ass: ['child']) oc_changes = ctx.valid_edit_changes( ViewModel::Reference.new(child_viewmodel_class, old_child.id)) - assert_changes_match(oc_changes, false, true, false, [], []) + assert_changes_match(oc_changes, d: true) end end @@ -585,7 +587,7 @@ def new_model end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, true, [], ['children']) + assert_changes_match(vm_changes, nstc: true, ass: ['children']) new_children, existing_children = vm.children.partition do |c| c.name < 'm' @@ -593,7 +595,7 @@ def new_model new_children.each do |c| c_changes = ctx.valid_edit_changes(c.to_reference) - assert_changes_match(c_changes, true, false, false, ['name'], []) + assert_changes_match(c_changes, n: true, att: ['name']) end existing_children.each do |c| @@ -613,15 +615,15 @@ def new_model refute(vm.children.include?(replaced_child)) vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, true, [], ['children']) + assert_changes_match(vm_changes, nstc: true, ass: ['children']) new_child = vm.children.detect { |c| c.name == 'b' } c_changes = ctx.valid_edit_changes(new_child.to_reference) - assert_changes_match(c_changes, true, false, false, ['name'], []) + assert_changes_match(c_changes, n:true, att: ['name']) oc_changes = ctx.valid_edit_changes( ViewModel::Reference.new(child_viewmodel_class, replaced_child.id)) - assert_changes_match(oc_changes, false, true, false, [], []) + assert_changes_match(oc_changes, d: true) end it 'records reordered children' do @@ -630,7 +632,7 @@ def new_model end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, false, [], ['children']) + assert_changes_match(vm_changes, ass: ['children']) vm.children.each do |c| refute(ctx.was_edited?(c.to_reference)) @@ -639,23 +641,23 @@ def new_model end describe 'with parent and shared child test models' do - include ViewModelSpecHelpers::ParentAndSharedChild + include ViewModelSpecHelpers::ParentAndSharedBelongsToChild def new_model model_class.new(name: 'a', child: child_model_class.new(name: 'z')) end - it 'records an change to child without a tree change' do + it 'records a change to child without a tree change' do vm, ctx = alter_by_view!(viewmodel_class, create_model!) do |view, refs| view['child'] = { '_ref' => 'cref' } refs.clear['cref'] = { '_type' => child_view_name, 'name' => 'b' } end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, false, [], ['child']) + assert_changes_match(vm_changes, ass: ['child']) c_changes = ctx.valid_edit_changes(vm.child.to_reference) - assert_changes_match(c_changes, true, false, false, ['name'], []) + assert_changes_match(c_changes, n: true, att: ['name']) end it 'records an edited child without a tree change' do @@ -664,10 +666,10 @@ def new_model end refute(ctx.was_edited?(vm.to_reference)) - assert_changes_match(vm.previous_changes, false, false, false, [], []) + assert_changes_match(vm.previous_changes) c_changes = ctx.valid_edit_changes(vm.child.to_reference) - assert_changes_match(c_changes, false, false, false, ['name'], []) + assert_changes_match(c_changes, att: ['name']) end it 'records a deleted child' do @@ -680,12 +682,61 @@ def new_model end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, false, [], ['child']) + assert_changes_match(vm_changes, ass: ['child']) refute(ctx.was_edited?(old_child.to_reference)) end end + describe 'with parent and owned referenced child test models' do + include ViewModelSpecHelpers::ParentAndReferencedHasOneChild + + def new_model + model_class.new(name: 'a', child: child_model_class.new(name: 'z')) + end + + it 'records a change to child with referenced tree change' do + vm, ctx = alter_by_view!(viewmodel_class, create_model!) do |view, refs| + view['child'] = { '_ref' => 'cref' } + refs.clear['cref'] = { '_type' => child_view_name, 'name' => 'b' } + end + + vm_changes = ctx.valid_edit_changes(vm.to_reference) + assert_changes_match(vm_changes, refc: true, ass: ['child']) + + c_changes = ctx.valid_edit_changes(vm.child.to_reference) + assert_changes_match(c_changes, n: true, att: ['name']) + end + + it 'records an edited child with referenced tree change' do + vm, ctx = alter_by_view!(viewmodel_class, create_model!) do |_view, refs| + refs.values.first.merge!('name' => 'b') + end + + refute(ctx.was_edited?(vm.to_reference)) + assert_changes_match(vm.previous_changes, refc: true) + + c_changes = ctx.valid_edit_changes(vm.child.to_reference) + assert_changes_match(c_changes, att: ['name']) + end + + it 'records a deleted child' do + vm = create_viewmodel! + old_child = vm.child + + vm, ctx = alter_by_view!(viewmodel_class, vm.model) do |view, refs| + view['child'] = nil + refs.clear + end + + vm_changes = ctx.valid_edit_changes(vm.to_reference) + assert_changes_match(vm_changes, refc: true, ass: ['child']) + + c_changes = ctx.valid_edit_changes(old_child.to_reference) + assert_changes_match(c_changes, d: true) + end + end + describe 'with has_many_through children test models' do include ViewModelSpecHelpers::ParentAndHasManyThroughChildren @@ -706,7 +757,7 @@ def new_model end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, false, [], ['children']) + assert_changes_match(vm_changes, ass: ['children']) new_children, existing_children = vm.children.partition do |c| c.name < 'm' @@ -714,7 +765,7 @@ def new_model new_children.each do |c| c_changes = ctx.valid_edit_changes(c.to_reference) - assert_changes_match(c_changes, true, false, false, ['name'], []) + assert_changes_match(c_changes, n: true, att: ['name']) end existing_children.each do |c| @@ -734,7 +785,7 @@ def new_model end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, false, [], ['children']) + assert_changes_match(vm_changes, ass: ['children']) new_children, existing_children = vm.children.partition do |c| c.name < 'm' @@ -742,7 +793,7 @@ def new_model new_children.each do |c| c_changes = ctx.valid_edit_changes(c.to_reference) - assert_changes_match(c_changes, true, false, false, ['name'], []) + assert_changes_match(c_changes, n: true, att: ['name']) end existing_children.each do |c| @@ -758,7 +809,7 @@ def new_model end vm_changes = ctx.valid_edit_changes(vm.to_reference) - assert_changes_match(vm_changes, false, false, false, [], ['children']) + assert_changes_match(vm_changes, ass: ['children']) vm.children.each do |c| refute(ctx.was_edited?(c.to_reference)) diff --git a/test/unit/view_model/active_record/belongs_to_test.rb b/test/unit/view_model/active_record/belongs_to_test.rb index 22034e82..356e870c 100644 --- a/test/unit/view_model/active_record/belongs_to_test.rb +++ b/test/unit/view_model/active_record/belongs_to_test.rb @@ -1,5 +1,6 @@ require_relative "../../../helpers/arvm_test_utilities.rb" require_relative "../../../helpers/arvm_test_models.rb" +require_relative '../../../helpers/viewmodel_spec_helpers.rb' require "minitest/autorun" @@ -7,122 +8,57 @@ class ViewModel::ActiveRecord::BelongsToTest < ActiveSupport::TestCase include ARVMTestUtilities - - module WithLabel - def before_all - super - - build_viewmodel(:Label) do - define_schema do |t| - t.string :text - end - - define_model do - has_one :parent, inverse_of: :label - end - - define_viewmodel do - attributes :text - end - end - end - end - - module WithParent - def before_all - super - - build_viewmodel(:Parent) do - define_schema do |t| - t.string :name - t.references :label, foreign_key: true - end - - define_model do - belongs_to :label, inverse_of: :parent, dependent: :destroy - end - - define_viewmodel do - attributes :name - associations :label - end - end - end - end - - module WithOwner - def before_all - super - - build_viewmodel(:Owner) do - define_schema do |t| - t.integer :deleted_id - t.integer :ignored_id - end - - define_model do - belongs_to :deleted, class_name: Label.name, dependent: :delete - belongs_to :ignored, class_name: Label.name - end - - define_viewmodel do - associations :deleted, :ignored - end - end - end - end - - include WithLabel - include WithParent + extend Minitest::Spec::DSL + include ViewModelSpecHelpers::ParentAndBelongsToChild def setup super # TODO make a `has_list?` that allows a parent to set all children as an array - @parent1 = Parent.new(name: "p1", - label: Label.new(text: "p1l")) - @parent1.save! + @model1 = model_class.new(name: "p1", + child: child_model_class.new(name: "p1l")) + @model1.save! - @parent2 = Parent.new(name: "p2", - label: Label.new(text: "p2l")) + @model2 = model_class.new(name: "p2", + child: child_model_class.new(name: "p2l")) - @parent2.save! + @model2.save! enable_logging! end def test_serialize_view - view, _refs = serialize_with_references(ParentView.new(@parent1)) + view, _refs = serialize_with_references(ModelView.new(@model1)) - assert_equal({ "_type" => "Parent", + assert_equal({ "_type" => "Model", "_version" => 1, - "id" => @parent1.id, - "name" => @parent1.name, - "label" => { "_type" => "Label", + "id" => @model1.id, + "name" => @model1.name, + "child" => { "_type" => "Child", "_version" => 1, - "id" => @parent1.label.id, - "text" => @parent1.label.text }, + "id" => @model1.child.id, + "name" => @model1.child.name }, }, view) end def test_loading_batching log_queries do - serialize(ParentView.load) + serialize(ModelView.load) end - assert_equal(['Parent Load', 'Label Load'], + assert_equal(['Model Load', 'Child Load'], logged_load_queries) end def test_create_from_view view = { - "_type" => "Parent", + "_type" => "Model", "name" => "p", - "label" => { "_type" => "Label", "text" => "l" }, + "child" => { "_type" => "Child", "name" => "l" }, } - pv = ParentView.deserialize_from_view(view) + pv = ModelView.deserialize_from_view(view) p = pv.model assert(!p.changed?) @@ -130,185 +66,181 @@ def test_create_from_view assert_equal("p", p.name) - assert(p.label.present?) - assert_equal("l", p.label.text) + assert(p.child.present?) + assert_equal("l", p.child.name) end def test_create_belongs_to_nil - view = { '_type' => 'Parent', 'name' => 'p', 'label' => nil } - pv = ParentView.deserialize_from_view(view) - assert_nil(pv.model.label) + view = { '_type' => 'Model', 'name' => 'p', 'child' => nil } + pv = ModelView.deserialize_from_view(view) + assert_nil(pv.model.child) end def test_create_invalid_child_type - view = { '_type' => 'Parent', 'name' => 'p', 'label' => { '_type' => 'Parent', 'name' => 'q' } } + view = { '_type' => 'Model', 'name' => 'p', 'child' => { '_type' => 'Model', 'name' => 'q' } } assert_raises(ViewModel::DeserializationError::InvalidAssociationType) do - ParentView.deserialize_from_view(view) + ModelView.deserialize_from_view(view) end end def test_belongs_to_create - @parent1.update(label: nil) + @model1.update(child: nil) - alter_by_view!(ParentView, @parent1) do |view, refs| - view['label'] = { '_type' => 'Label', 'text' => 'cheese' } + alter_by_view!(ModelView, @model1) do |view, refs| + view['child'] = { '_type' => 'Child', 'name' => 'cheese' } end - assert_equal('cheese', @parent1.label.text) + assert_equal('cheese', @model1.child.name) end def test_belongs_to_replace - old_label = @parent1.label + old_child = @model1.child - alter_by_view!(ParentView, @parent1) do |view, refs| - view['label'] = { '_type' => 'Label', 'text' => 'cheese' } + alter_by_view!(ModelView, @model1) do |view, refs| + view['child'] = { '_type' => 'Child', 'name' => 'cheese' } end - assert_equal('cheese', @parent1.label.text) - assert(Label.where(id: old_label).blank?) + assert_equal('cheese', @model1.child.name) + assert(Child.where(id: old_child).blank?) end def test_belongs_to_move_and_replace - old_p1_label = @parent1.label - old_p2_label = @parent2.label + old_p1_child = @model1.child + old_p2_child = @model2.child - set_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), refs| - p1['label'] = nil - p2['label'] = update_hash_for(LabelView, old_p1_label) + set_by_view!(ModelView, [@model1, @model2]) do |(p1, p2), refs| + p1['child'] = nil + p2['child'] = update_hash_for(ChildView, old_p1_child) end - assert(@parent1.label.blank?, 'l1 label reference removed') - assert_equal(old_p1_label, @parent2.label, 'p2 has label from p1') - assert(Label.where(id: old_p2_label).blank?, 'p2 old label deleted') + assert(@model1.child.blank?, 'l1 child reference removed') + assert_equal(old_p1_child, @model2.child, 'p2 has child from p1') + assert(Child.where(id: old_p2_child).blank?, 'p2 old child deleted') end def test_belongs_to_swap - old_p1_label = @parent1.label - old_p2_label = @parent2.label + old_p1_child = @model1.child + old_p2_child = @model2.child - alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), refs| - p1['label'] = update_hash_for(LabelView, old_p2_label) - p2['label'] = update_hash_for(LabelView, old_p1_label) + alter_by_view!(ModelView, [@model1, @model2]) do |(p1, p2), refs| + p1['child'] = update_hash_for(ChildView, old_p2_child) + p2['child'] = update_hash_for(ChildView, old_p1_child) end - assert_equal(old_p2_label, @parent1.label, 'p1 has label from p2') - assert_equal(old_p1_label, @parent2.label, 'p2 has label from p1') + assert_equal(old_p2_child, @model1.child, 'p1 has child from p2') + assert_equal(old_p1_child, @model2.child, 'p2 has child from p1') end def test_moved_child_is_not_delete_checked # move from p1 to p3 - d_context = ParentView.new_deserialize_context + d_context = ModelView.new_deserialize_context - target_label = Label.create - from_parent = Parent.create(name: 'from', label: target_label) - to_parent = Parent.create(name: 'p3') + target_child = Child.create + from_model = Model.create(name: 'from', child: target_child) + to_model = Model.create(name: 'p3') alter_by_view!( - ParentView, [from_parent, to_parent], + ModelView, [from_model, to_model], deserialize_context: d_context ) do |(from, to), refs| - from['label'] = nil - to['label'] = update_hash_for(LabelView, target_label) + from['child'] = nil + to['child'] = update_hash_for(ChildView, target_child) end - assert_equal(target_label, to_parent.label, 'target label moved') - assert_equal([ViewModel::Reference.new(ParentView, from_parent.id), - ViewModel::Reference.new(ParentView, to_parent.id)], + assert_equal(target_child, to_model.child, 'target child moved') + assert_equal([ViewModel::Reference.new(ModelView, from_model.id), + ViewModel::Reference.new(ModelView, to_model.id)], d_context.valid_edit_refs, - "only parents are checked for change; child was not") + "only models are checked for change; child was not") end def test_implicit_release_invalid_belongs_to - taken_label_ref = update_hash_for(LabelView, @parent1.label) + taken_child_ref = update_hash_for(ChildView, @model1.child) assert_raises(ViewModel::DeserializationError::ParentNotFound) do - ParentView.deserialize_from_view( - [{ '_type' => 'Parent', + ModelView.deserialize_from_view( + [{ '_type' => 'Model', 'name' => 'newp', - 'label' => taken_label_ref }]) + 'child' => taken_child_ref }]) end end class GCTests < ActiveSupport::TestCase include ARVMTestUtilities - include WithLabel - include WithOwner - include WithParent + include ViewModelSpecHelpers::ParentAndBelongsToChild + + def model_attributes + super.merge( + schema: ->(t) do + t.integer :deleted_child_id + t.integer :ignored_child_id + end, + model: ->(m) do + belongs_to :deleted_child, class_name: Child.name, dependent: :delete + belongs_to :ignored_child, class_name: Child.name + end, + viewmodel: ->(v) do + associations :deleted_child, :ignored_child + end) + end # test belongs_to garbage collection - dependent: delete_all def test_gc_dependent_delete_all - owner = Owner.create(deleted: Label.new(text: 'one')) - old_label = owner.deleted + model = model_class.create(deleted_child: Child.new(name: 'one')) + old_child = model.deleted_child - alter_by_view!(OwnerView, owner) do |ov, refs| - ov['deleted'] = { '_type' => 'Label', 'text' => 'two' } + alter_by_view!(ModelView, model) do |ov, _refs| + ov['deleted_child'] = { '_type' => 'Child', 'name' => 'two' } end - assert_equal('two', owner.deleted.text) - refute_equal(old_label, owner.deleted) - assert(Label.where(id: old_label.id).blank?) + assert_equal('two', model.deleted_child.name) + refute_equal(old_child, model.deleted_child) + assert(Child.where(id: old_child.id).blank?) end def test_no_gc_dependent_ignore - owner = Owner.create(ignored: Label.new(text: "one")) - old_label = owner.ignored + model = model_class.create(ignored_child: Child.new(name: "one")) + old_child = model.ignored_child - alter_by_view!(OwnerView, owner) do |ov, refs| - ov['ignored'] = { '_type' => 'Label', 'text' => 'two' } + alter_by_view!(ModelView, model) do |ov, _refs| + ov['ignored_child'] = { '_type' => 'Child', 'name' => 'two' } end - assert_equal('two', owner.ignored.text) - refute_equal(old_label, owner.ignored) - assert_equal(1, Label.where(id: old_label.id).count) + assert_equal('two', model.ignored_child.name) + refute_equal(old_child, model.ignored_child) + assert_equal(1, Child.where(id: old_child.id).count) end end class RenamedTest < ActiveSupport::TestCase include ARVMTestUtilities - include WithLabel - - def before_all - super - - build_viewmodel(:Parent) do - define_schema do |t| - t.string :name - t.references :label, foreign_key: true - end - - define_model do - belongs_to :label, inverse_of: :parent, dependent: :destroy - end + include ViewModelSpecHelpers::ParentAndBelongsToChild - define_viewmodel do - attributes :name - association :label, as: :something_else - end - end + def subject_association_features + { as: :something_else } end def setup super - @parent = Parent.create(name: 'p1', label: Label.new(text: 'l1')) + @model = model_class.create(name: 'p1', child: child_model_class.new(name: 'l1')) enable_logging! end def test_dependencies - root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Parent', 'something_else' => nil }]) - assert_equal(DeepPreloader::Spec.new('label' => DeepPreloader::Spec.new), root_updates.first.preload_dependencies) - assert_equal({ 'something_else' => {} }, root_updates.first.updated_associations) + root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Model', 'something_else' => nil }]) + assert_equal(DeepPreloader::Spec.new('child' => DeepPreloader::Spec.new), root_updates.first.preload_dependencies) end def test_renamed_roundtrip - alter_by_view!(ParentView, @parent) do |view, refs| - assert_equal({ 'id' => @parent.label.id, - '_type' => 'Label', + alter_by_view!(ModelView, @model) do |view, refs| + assert_equal({ 'id' => @model.child.id, + '_type' => 'Child', '_version' => 1, - 'text' => 'l1' }, + 'name' => 'l1' }, view['something_else']) - view['something_else']['text'] = 'new l1 text' + view['something_else']['name'] = 'new l1 name' end - assert_equal('new l1 text', @parent.label.text) + assert_equal('new l1 name', @model.child.name) end end @@ -353,7 +285,7 @@ def before_all end - # Do we support replacing a node in the tree and reparenting its children + # Do we support replacing a node in the tree and remodeling its children # back to it? In theory we want to, but currently we don't: the child node # is unresolvable. diff --git a/test/unit/view_model/active_record/cache_test.rb b/test/unit/view_model/active_record/cache_test.rb index bf23c6f9..7781eee0 100644 --- a/test/unit/view_model/active_record/cache_test.rb +++ b/test/unit/view_model/active_record/cache_test.rb @@ -41,7 +41,7 @@ def model_attributes schema: ->(t) { t.references :shared, foreign_key: true }, model: ->(_) { belongs_to :shared, inverse_of: :models }, viewmodel: ->(_) { - association :shared, shared: true, optional: false + association :shared cacheable! } ) @@ -63,6 +63,7 @@ def shared_viewmodel_class end define_viewmodel do + root! attributes :name cacheable!(cache_group: shared_cache_group) end @@ -326,7 +327,7 @@ def fetch_with_cache end describe "with a non-cacheable shared child" do - include ViewModelSpecHelpers::ParentAndSharedChild + include ViewModelSpecHelpers::ParentAndSharedBelongsToChild def model_attributes super.merge(viewmodel: ->(_) { cacheable! }) end diff --git a/test/unit/view_model/active_record/cloner_test.rb b/test/unit/view_model/active_record/cloner_test.rb index 7d4cf63c..89c7c289 100644 --- a/test/unit/view_model/active_record/cloner_test.rb +++ b/test/unit/view_model/active_record/cloner_test.rb @@ -173,7 +173,7 @@ module BehavesLikeCloningAChild end describe "as belongs_to shared child" do - include ViewModelSpecHelpers::ParentAndSharedChild + include ViewModelSpecHelpers::ParentAndSharedBelongsToChild include BehavesLikeConstructingAChild it "can clone the model but not the child" do clone_model = Cloner.new.clone(viewmodel) diff --git a/test/unit/view_model/active_record/controller_test.rb b/test/unit/view_model/active_record/controller_test.rb index 52085f04..ca1f4159 100644 --- a/test/unit/view_model/active_record/controller_test.rb +++ b/test/unit/view_model/active_record/controller_test.rb @@ -1,15 +1,14 @@ # -*- coding: utf-8 -*- -require "bundler/setup" -Bundler.require - -require_relative "../../../helpers/callback_tracer.rb" -require_relative "../../../helpers/controller_test_helpers.rb" - -require 'byebug' - require "minitest/autorun" require 'minitest/unit' +require 'minitest/hooks' + +require "view_model" +require "view_model/active_record" + +require_relative "../../../helpers/controller_test_helpers.rb" +require_relative "../../../helpers/callback_tracer.rb" class ViewModel::ActiveRecord::ControllerTest < ActiveSupport::TestCase include ARVMTestUtilities @@ -144,9 +143,7 @@ def test_create p2_view = ParentView.new(p2) assert(p2.present?, 'p2 created') - context = ParentView.new_serialize_context(include: 'children') - assert_equal({ 'data' => p2_view.to_hash(serialize_context: context) }, - parentcontroller.hash_response) + assert_equal({ 'data' => p2_view.to_hash }, parentcontroller.hash_response) assert_all_hooks_nested_inside_parent_hook(parentcontroller.hook_trace) end @@ -331,6 +328,7 @@ def test_nested_collection_append_many assert_all_hooks_nested_inside_parent_hook(childcontroller.hook_trace) end + # FIXME: nested controllers really need to be to other roots; children aren't roots. def test_nested_collection_replace # Parent.children old_children = @parent.children diff --git a/test/unit/view_model/active_record/has_many_test.rb b/test/unit/view_model/active_record/has_many_test.rb index b89d26b0..cbe0fce3 100644 --- a/test/unit/view_model/active_record/has_many_test.rb +++ b/test/unit/view_model/active_record/has_many_test.rb @@ -1,5 +1,6 @@ require_relative "../../../helpers/arvm_test_utilities.rb" require_relative "../../../helpers/arvm_test_models.rb" +require_relative '../../../helpers/viewmodel_spec_helpers.rb' require "minitest/autorun" @@ -8,69 +9,29 @@ class ViewModel::ActiveRecord::HasManyTest < ActiveSupport::TestCase include ARVMTestUtilities - def self.build_parent(arvm_test_case) - arvm_test_case.build_viewmodel(:Parent) do - define_schema do |t| - t.string :name - end - - define_model do - has_many :children, dependent: :destroy, inverse_of: :parent - end - - define_viewmodel do - attributes :name - associations :children - end - end - end - - def self.build_child(arvm_test_case) - arvm_test_case.build_viewmodel(:Child) do - define_schema do |t| - t.references :parent, null: false, foreign_key: true - t.string :name - t.float :position - end - - define_model do - belongs_to :parent, inverse_of: :children - acts_as_manual_list scope: :parent - end - - define_viewmodel do - attributes :name - acts_as_list :position - end - end - - end - - def before_all - self.class.build_parent(self) - self.class.build_child(self) - end + extend Minitest::Spec::DSL + include ViewModelSpecHelpers::ParentAndOrderedChildren def setup super - @parent1 = Parent.new(name: "p1", - children: [Child.new(name: "p1c1", position: 1), - Child.new(name: "p1c2", position: 2), - Child.new(name: "p1c3", position: 3)]) - @parent1.save! + @model1 = model_class.new(name: "p1", + children: [child_model_class.new(name: "p1c1", position: 1), + child_model_class.new(name: "p1c2", position: 2), + child_model_class.new(name: "p1c3", position: 3)]) + @model1.save! - @parent2 = Parent.new(name: "p2", - children: [Child.new(name: "p2c1").tap { |c| c.position = 1 }, - Child.new(name: "p2c2").tap { |c| c.position = 2 }]) + @model2 = model_class.new(name: "p2", + children: [child_model_class.new(name: "p2c1").tap { |c| c.position = 1 }, + child_model_class.new(name: "p2c2").tap { |c| c.position = 2 }]) - @parent2.save! + @model2.save! enable_logging! end def test_load_associated - parentview = ParentView.new(@parent1) + parentview = viewmodel_class.new(@model1) childviews = parentview.load_associated(:children) assert_equal(3, childviews.size) @@ -79,14 +40,14 @@ def test_load_associated end def test_serialize_view - view, _refs = serialize_with_references(ParentView.new(@parent1)) + view, _refs = serialize_with_references(viewmodel_class.new(@model1)) - assert_equal({ "_type" => "Parent", + assert_equal({ "_type" => "Model", "_version" => 1, - "id" => @parent1.id, - "name" => @parent1.name, - "children" => @parent1.children.map { |child| { "_type" => "Child", + "id" => @model1.id, + "name" => @model1.name, + "children" => @model1.children.map { |child| { "_type" => "Child", "_version" => 1, "id" => child.id, "name" => child.name } } }, @@ -95,21 +56,21 @@ def test_serialize_view def test_loading_batching log_queries do - serialize(ParentView.load) + serialize(viewmodel_class.load) end - assert_equal(['Parent Load', 'Child Load'], + assert_equal(['Model Load', 'Child Load'], logged_load_queries) end def test_create_from_view view = { - "_type" => "Parent", + "_type" => "Model", "name" => "p", "children" => [{ "_type" => "Child", "name" => "c1" }, { "_type" => "Child", "name" => "c2" }] } - pv = ParentView.deserialize_from_view(view) + pv = viewmodel_class.deserialize_from_view(view) p = pv.model assert(!p.changed?) @@ -126,33 +87,33 @@ def test_create_from_view end def test_editability_raises - no_edit_context = ParentView.new_deserialize_context(can_edit: false) + no_edit_context = viewmodel_class.new_deserialize_context(can_edit: false) assert_raises(ViewModel::AccessControlError) do # append child - ParentView.new(@parent1).append_associated(:children, { "_type" => "Child", "name" => "hi" }, deserialize_context: no_edit_context) + viewmodel_class.new(@model1).append_associated(:children, { "_type" => "Child", "name" => "hi" }, deserialize_context: no_edit_context) end assert_raises(ViewModel::AccessControlError) do # destroy child - ParentView.new(@parent1).delete_associated(:children, @parent1.children.first.id, deserialize_context: no_edit_context) + viewmodel_class.new(@model1).delete_associated(:children, @model1.children.first.id, deserialize_context: no_edit_context) end end def test_create_has_many_empty - view = { '_type' => 'Parent', 'name' => 'p', 'children' => [] } - pv = ParentView.deserialize_from_view(view) + view = { '_type' => 'Model', 'name' => 'p', 'children' => [] } + pv = viewmodel_class.deserialize_from_view(view) assert(pv.model.children.blank?) end def test_create_has_many - view = { '_type' => 'Parent', + view = { '_type' => 'Model', 'name' => 'p', 'children' => [{ '_type' => 'Child', 'name' => 'c1' }, { '_type' => 'Child', 'name' => 'c2' }] } - context = ParentView.new_deserialize_context - pv = ParentView.deserialize_from_view(view, deserialize_context: context) + context = viewmodel_class.new_deserialize_context + pv = viewmodel_class.deserialize_from_view(view, deserialize_context: context) assert_contains_exactly( [pv.to_reference, pv.children[0].to_reference, pv.children[1].to_reference], @@ -163,11 +124,11 @@ def test_create_has_many def test_nil_multiple_association view = { - "_type" => "Parent", + "_type" => "Model", "children" => nil } ex = assert_raises(ViewModel::DeserializationError::InvalidSyntax) do - ParentView.deserialize_from_view(view) + viewmodel_class.deserialize_from_view(view) end assert_match(/Invalid collection update value 'nil'/, ex.message) @@ -175,39 +136,39 @@ def test_nil_multiple_association def test_non_array_multiple_association view = { - "_type" => "Parent", + "_type" => "Model", "children" => { '_type' => 'Child', 'name' => 'c1' } } ex = assert_raises(ViewModel::DeserializationError::InvalidSyntax) do - ParentView.deserialize_from_view(view) + viewmodel_class.deserialize_from_view(view) end assert_match(/Errors parsing collection functional update/, ex.message) end def test_replace_has_many - old_children = @parent1.children + old_children = @model1.children - alter_by_view!(ParentView, @parent1) do |view, refs| + alter_by_view!(viewmodel_class, @model1) do |view, refs| view['children'] = [{ '_type' => 'Child', 'name' => 'new_child' }] end - assert_equal(['new_child'], @parent1.children.map(&:name)) - assert_equal([], Child.where(id: old_children.map(&:id))) + assert_equal(['new_child'], @model1.children.map(&:name)) + assert_equal([], child_model_class.where(id: old_children.map(&:id))) end def test_replace_associated_has_many - old_children = @parent1.children + old_children = @model1.children - pv = ParentView.new(@parent1) - context = ParentView.new_deserialize_context + pv = viewmodel_class.new(@model1) + context = viewmodel_class.new_deserialize_context nc = pv.replace_associated(:children, [{ '_type' => 'Child', 'name' => 'new_child' }], deserialize_context: context) expected_edit_checks = [pv.to_reference, - *old_children.map { |x| ViewModel::Reference.new(ChildView, x.id) }, + *old_children.map { |x| ViewModel::Reference.new(child_viewmodel_class, x.id) }, *nc.map(&:to_reference)] assert_contains_exactly(expected_edit_checks, @@ -216,16 +177,16 @@ def test_replace_associated_has_many assert_equal(1, nc.size) assert_equal('new_child', nc[0].name) - @parent1.reload - assert_equal(['new_child'], @parent1.children.map(&:name)) - assert_equal([], Child.where(id: old_children.map(&:id))) + @model1.reload + assert_equal(['new_child'], @model1.children.map(&:name)) + assert_equal([], child_model_class.where(id: old_children.map(&:id))) end def test_replace_associated_has_many_functional - old_children = @parent1.children + old_children = @model1.children - pv = ParentView.new(@parent1) - context = ParentView.new_deserialize_context + pv = viewmodel_class.new(@model1) + context = viewmodel_class.new_deserialize_context update = build_fupdate do append([{ '_type' => 'Child', 'name' => 'new_child' }]) @@ -237,9 +198,9 @@ def test_replace_associated_has_many_functional new_child = nc.detect { |c| c.name == 'new_child' } expected_edit_checks = [pv.to_reference, - ViewModel::Reference.new(ChildView, new_child.id), - ViewModel::Reference.new(ChildView, old_children.first.id), - ViewModel::Reference.new(ChildView, old_children.last.id)] + ViewModel::Reference.new(child_viewmodel_class, new_child.id), + ViewModel::Reference.new(child_viewmodel_class, old_children.first.id), + ViewModel::Reference.new(child_viewmodel_class, old_children.last.id)] assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) @@ -247,187 +208,187 @@ def test_replace_associated_has_many_functional assert_equal(3, nc.size) assert_equal('renamed p1c1', nc[0].name) - @parent1.reload - assert_equal(['renamed p1c1', 'p1c2', 'new_child'], @parent1.children.order(:position).map(&:name)) - assert_equal([], Child.where(id: old_children.last.id)) + @model1.reload + assert_equal(['renamed p1c1', 'p1c2', 'new_child'], @model1.children.order(:position).map(&:name)) + assert_equal([], child_model_class.where(id: old_children.last.id)) end def test_remove_has_many - old_children = @parent1.children - _, context = alter_by_view!(ParentView, @parent1) do |view, refs| + old_children = @model1.children + _, context = alter_by_view!(viewmodel_class, @model1) do |view, refs| view['children'] = [] end - expected_edit_checks = [ViewModel::Reference.new(ParentView, @parent1.id)] + - old_children.map { |x| ViewModel::Reference.new(ChildView, x.id) } + expected_edit_checks = [ViewModel::Reference.new(viewmodel_class, @model1.id)] + + old_children.map { |x| ViewModel::Reference.new(child_viewmodel_class, x.id) } assert_equal(Set.new(expected_edit_checks), context.valid_edit_refs.to_set) - assert_equal([], @parent1.children, 'no children associated with parent1') - assert(Child.where(id: old_children.map(&:id)).blank?, 'all children deleted') + assert_equal([], @model1.children, 'no children associated with parent1') + assert(child_model_class.where(id: old_children.map(&:id)).blank?, 'all children deleted') end def test_delete_associated_has_many - c1, c2, c3 = @parent1.children.order(:position).to_a + c1, c2, c3 = @model1.children.order(:position).to_a - pv = ParentView.new(@parent1) - context = ParentView.new_deserialize_context + pv = viewmodel_class.new(@model1) + context = viewmodel_class.new_deserialize_context pv.delete_associated(:children, c1.id, deserialize_context: context) - expected_edit_checks = [ViewModel::Reference.new(ParentView, @parent1.id), - ViewModel::Reference.new(ChildView, c1.id)].to_set + expected_edit_checks = [ViewModel::Reference.new(viewmodel_class, @model1.id), + ViewModel::Reference.new(child_viewmodel_class, c1.id)].to_set assert_equal(expected_edit_checks, context.valid_edit_refs.to_set) - @parent1.reload - assert_equal([c2, c3], @parent1.children.order(:position)) - assert(Child.where(id: c1.id).blank?, 'old child deleted') + @model1.reload + assert_equal([c2, c3], @model1.children.order(:position)) + assert(child_model_class.where(id: c1.id).blank?, 'old child deleted') end def test_edit_has_many - c1, c2, c3 = @parent1.children.order(:position).to_a + c1, c2, c3 = @model1.children.order(:position).to_a - pv, context = alter_by_view!(ParentView, @parent1) do |view, _refs| + pv, context = alter_by_view!(viewmodel_class, @model1) do |view, _refs| view['children'].shift view['children'] << { '_type' => 'Child', 'name' => 'new_c' } end nc = pv.children.detect { |c| c.name == 'new_c' } assert_contains_exactly( - [ViewModel::Reference.new(ParentView, @parent1.id), - ViewModel::Reference.new(ChildView, c1.id), # deleted child - ViewModel::Reference.new(ChildView, nc.id)], # created child + [ViewModel::Reference.new(viewmodel_class, @model1.id), + ViewModel::Reference.new(child_viewmodel_class, c1.id), # deleted child + ViewModel::Reference.new(child_viewmodel_class, nc.id)], # created child context.valid_edit_refs) - assert_equal([c2, c3, Child.find_by_name('new_c')], - @parent1.children.order(:position)) - assert(Child.where(id: c1.id).blank?) + assert_equal([c2, c3, child_model_class.find_by_name('new_c')], + @model1.children.order(:position)) + assert(child_model_class.where(id: c1.id).blank?) end def test_append_associated_move_has_many - c1, c2, c3 = @parent1.children.order(:position).to_a - pv = ParentView.new(@parent1) + c1, c2, c3 = @model1.children.order(:position).to_a + pv = viewmodel_class.new(@model1) # insert before pv.append_associated(:children, { '_type' => 'Child', 'id' => c3.id }, - before: ViewModel::Reference.new(ChildView, c1.id), - deserialize_context: (context = ParentView.new_deserialize_context)) + before: ViewModel::Reference.new(child_viewmodel_class, c1.id), + deserialize_context: (context = viewmodel_class.new_deserialize_context)) expected_edit_checks = [pv.to_reference] assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) assert_equal([c3, c1, c2], - @parent1.children.order(:position)) + @model1.children.order(:position)) # insert after pv.append_associated(:children, { '_type' => 'Child', 'id' => c3.id }, - after: ViewModel::Reference.new(ChildView, c1.id), - deserialize_context: (context = ParentView.new_deserialize_context)) + after: ViewModel::Reference.new(child_viewmodel_class, c1.id), + deserialize_context: (context = viewmodel_class.new_deserialize_context)) assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) assert_equal([c1, c3, c2], - @parent1.children.order(:position)) + @model1.children.order(:position)) # append pv.append_associated(:children, { '_type' => 'Child', 'id' => c3.id }, - deserialize_context: (context = ParentView.new_deserialize_context)) + deserialize_context: (context = viewmodel_class.new_deserialize_context)) assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) assert_equal([c1, c2, c3], - @parent1.children.order(:position)) + @model1.children.order(:position)) # move from another parent - p2c1 = @parent2.children.order(:position).first + p2c1 = @model2.children.order(:position).first pv.append_associated(:children, { '_type' => 'Child', 'id' => p2c1.id }, - deserialize_context: (context = ParentView.new_deserialize_context)) + deserialize_context: (context = viewmodel_class.new_deserialize_context)) - expected_edit_checks = [ViewModel::Reference.new(ParentView, @parent1.id), - ViewModel::Reference.new(ParentView, @parent2.id)] + expected_edit_checks = [ViewModel::Reference.new(viewmodel_class, @model1.id), + ViewModel::Reference.new(viewmodel_class, @model2.id)] assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) assert_equal([c1, c2, c3, p2c1], - @parent1.children.order(:position)) + @model1.children.order(:position)) end def test_append_associated_insert_has_many - c1, c2, c3 = @parent1.children.order(:position).to_a - pv = ParentView.new(@parent1) + c1, c2, c3 = @model1.children.order(:position).to_a + pv = viewmodel_class.new(@model1) # insert before pv.append_associated(:children, { '_type' => 'Child', 'name' => 'new1' }, - before: ViewModel::Reference.new(ChildView, c2.id), - deserialize_context: (context = ParentView.new_deserialize_context)) + before: ViewModel::Reference.new(child_viewmodel_class, c2.id), + deserialize_context: (context = viewmodel_class.new_deserialize_context)) - n1 = Child.find_by_name('new1') + n1 = child_model_class.find_by_name('new1') - expected_edit_checks = [ViewModel::Reference.new(ParentView, @parent1.id), - ViewModel::Reference.new(ChildView, n1.id)] + expected_edit_checks = [ViewModel::Reference.new(viewmodel_class, @model1.id), + ViewModel::Reference.new(child_viewmodel_class, n1.id)] assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) assert_equal([c1, n1, c2, c3], - @parent1.children.order(:position)) + @model1.children.order(:position)) # insert after pv.append_associated(:children, { '_type' => 'Child', 'name' => 'new2' }, - after: ViewModel::Reference.new(ChildView, c2.id), - deserialize_context: (context = ParentView.new_deserialize_context)) + after: ViewModel::Reference.new(child_viewmodel_class, c2.id), + deserialize_context: (context = viewmodel_class.new_deserialize_context)) - n2 = Child.find_by_name('new2') + n2 = child_model_class.find_by_name('new2') - expected_edit_checks = [ViewModel::Reference.new(ParentView, @parent1.id), - ViewModel::Reference.new(ChildView, n2.id)] + expected_edit_checks = [ViewModel::Reference.new(viewmodel_class, @model1.id), + ViewModel::Reference.new(child_viewmodel_class, n2.id)] assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) assert_equal([c1, n1, c2, n2, c3], - @parent1.children.order(:position)) + @model1.children.order(:position)) # append pv.append_associated(:children, { '_type' => 'Child', 'name' => 'new3' }, - deserialize_context: (context = ParentView.new_deserialize_context)) + deserialize_context: (context = viewmodel_class.new_deserialize_context)) - n3 = Child.find_by_name('new3') + n3 = child_model_class.find_by_name('new3') - expected_edit_checks = [ViewModel::Reference.new(ParentView, @parent1.id), - ViewModel::Reference.new(ChildView, n3.id)] + expected_edit_checks = [ViewModel::Reference.new(viewmodel_class, @model1.id), + ViewModel::Reference.new(child_viewmodel_class, n3.id)] assert_contains_exactly(expected_edit_checks, context.valid_edit_refs) assert_equal([c1, n1, c2, n2, c3, n3], - @parent1.children.order(:position)) + @model1.children.order(:position)) end def test_edit_implicit_list_position - c1, c2, c3 = @parent1.children.order(:position).to_a + c1, c2, c3 = @model1.children.order(:position).to_a - alter_by_view!(ParentView, @parent1) do |view, refs| + alter_by_view!(viewmodel_class, @model1) do |view, refs| view['children'].reverse! view['children'].insert(1, { '_type' => 'Child', 'name' => 'new_c' }) end - assert_equal([c3, Child.find_by_name('new_c'), c2, c1], - @parent1.children.order(:position)) + assert_equal([c3, child_model_class.find_by_name('new_c'), c2, c1], + @model1.children.order(:position)) end def test_edit_missing_child view = { - "_type" => "Parent", + "_type" => "Model", "children" => [{ "_type" => "Child", "id" => 9999 @@ -435,37 +396,37 @@ def test_edit_missing_child } ex = assert_raises(ViewModel::DeserializationError::NotFound) do - ParentView.deserialize_from_view(view) + viewmodel_class.deserialize_from_view(view) end - assert_equal(ex.nodes, [ViewModel::Reference.new(ChildView, 9999)]) + assert_equal(ex.nodes, [ViewModel::Reference.new(child_viewmodel_class, 9999)]) end def test_move_child_to_new - old_children = @parent1.children.order(:position) + old_children = @model1.children.order(:position) moved_child = old_children[1] - moved_child_ref = update_hash_for(ChildView, moved_child) + moved_child_ref = update_hash_for(child_viewmodel_class, moved_child) - view = { '_type' => 'Parent', + view = { '_type' => 'Model', 'name' => 'new_p', 'children' => [moved_child_ref, { '_type' => 'Child', 'name' => 'new' }] } retained_children = old_children - [moved_child] - release_view = { '_type' => 'Parent', - 'id' => @parent1.id, - 'children' => retained_children.map { |c| update_hash_for(ChildView, c) } } + release_view = { '_type' => 'Model', + 'id' => @model1.id, + 'children' => retained_children.map { |c| update_hash_for(child_viewmodel_class, c) } } - pv = ParentView.deserialize_from_view([view, release_view]) + pv = viewmodel_class.deserialize_from_view([view, release_view]) new_parent = pv.first.model new_parent.reload # child should be removed from old parent - @parent1.reload + @model1.reload assert_equal(retained_children, - @parent1.children.order(:position)) + @model1.children.order(:position)) # child should be added to new parent new_children = new_parent.children.order(:position) @@ -474,18 +435,18 @@ def test_move_child_to_new end def test_has_many_cannot_take_from_outside_tree - old_children = @parent1.children.order(:position) + old_children = @model1.children.order(:position) assert_raises(ViewModel::DeserializationError::ParentNotFound) do - alter_by_view!(ParentView, @parent2) do |p2, _refs| - p2['children'] = old_children.map { |x| update_hash_for(ChildView, x) } + alter_by_view!(viewmodel_class, @model2) do |p2, _refs| + p2['children'] = old_children.map { |x| update_hash_for(child_viewmodel_class, x) } end end end def test_has_many_cannot_duplicate_unreleased_children assert_raises(ViewModel::DeserializationError::DuplicateNodes) do - alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), _refs| + alter_by_view!(viewmodel_class, [@model1, @model2]) do |(p1, p2), _refs| p2['children'] = p1['children'].deep_dup end end @@ -493,7 +454,7 @@ def test_has_many_cannot_duplicate_unreleased_children def test_has_many_cannot_duplicate_implicitly_unreleased_children assert_raises(ViewModel::DeserializationError::ParentNotFound) do - alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), _refs| + alter_by_view!(viewmodel_class, [@model1, @model2]) do |(p1, p2), _refs| p2['children'] = p1['children'] p1.delete('children') end @@ -501,74 +462,73 @@ def test_has_many_cannot_duplicate_implicitly_unreleased_children end def test_move_child_to_existing - old_children = @parent1.children.order(:position) + old_children = @model1.children.order(:position) moved_child = old_children[1] - view = ParentView.new(@parent2).to_hash - view['children'] << ChildView.new(moved_child).to_hash + view = viewmodel_class.new(@model2).to_hash + view['children'] << child_viewmodel_class.new(moved_child).to_hash retained_children = old_children - [moved_child] - release_view = { '_type' => 'Parent', 'id' => @parent1.id, - 'children' => retained_children.map { |c| update_hash_for(ChildView, c) }} + release_view = { '_type' => 'Model', 'id' => @model1.id, + 'children' => retained_children.map { |c| update_hash_for(child_viewmodel_class, c) }} - ParentView.deserialize_from_view([view, release_view]) + viewmodel_class.deserialize_from_view([view, release_view]) - @parent1.reload - @parent2.reload + @model1.reload + @model2.reload # child should be removed from old parent and positions updated - assert_equal(retained_children, @parent1.children.order(:position)) + assert_equal(retained_children, @model1.children.order(:position)) # child should be added to new parent with valid position - new_children = @parent2.children.order(:position) + new_children = @model2.children.order(:position) assert_equal(%w(p2c1 p2c2 p1c2), new_children.map(&:name)) assert_equal(moved_child, new_children.last) end def test_has_many_append_child - ParentView.new(@parent1).append_associated(:children, { "_type" => "Child", "name" => "new" }) + viewmodel_class.new(@model1).append_associated(:children, { "_type" => "Child", "name" => "new" }) - @parent1.reload + @model1.reload - assert_equal(4, @parent1.children.size) - lc = @parent1.children.order(:position).last + assert_equal(4, @model1.children.size) + lc = @model1.children.order(:position).last assert_equal("new", lc.name) end def test_has_many_append_and_update_existing_association - child = @parent1.children[1] + child = @model1.children[1] - cv = ChildView.new(child).to_hash + cv = child_viewmodel_class.new(child).to_hash cv["name"] = "newname" - ParentView.new(@parent1).append_associated(:children, cv) + viewmodel_class.new(@model1).append_associated(:children, cv) - @parent1.reload + @model1.reload # Child should have been moved to the end (and edited) - assert_equal(3, @parent1.children.size) - c1, c2, c3 = @parent1.children.order(:position) + assert_equal(3, @model1.children.size) + c1, c2, c3 = @model1.children.order(:position) assert_equal("p1c1", c1.name) assert_equal("p1c3", c2.name) assert_equal(child, c3) assert_equal("newname", c3.name) - end def test_has_many_move_existing_association - p1c2 = @parent1.children[1] + p1c2 = @model1.children[1] assert_equal(2, p1c2.position) - ParentView.new(@parent2).append_associated("children", { "_type" => "Child", "id" => p1c2.id }) + viewmodel_class.new(@model2).append_associated("children", { "_type" => "Child", "id" => p1c2.id }) - @parent1.reload - @parent2.reload + @model1.reload + @model2.reload - p1c = @parent1.children.order(:position) + p1c = @model1.children.order(:position) assert_equal(2, p1c.size) assert_equal(["p1c1", "p1c3"], p1c.map(&:name)) - p2c = @parent2.children.order(:position) + p2c = @model2.children.order(:position) assert_equal(3, p2c.size) assert_equal(["p2c1", "p2c2", "p1c2"], p2c.map(&:name)) assert_equal(p1c2, p2c[2]) @@ -576,44 +536,44 @@ def test_has_many_move_existing_association end def test_has_many_remove_existing_association - child = @parent1.children[1] + child = @model1.children[1] - ParentView.new(@parent1).delete_associated(:children, child.id) + viewmodel_class.new(@model1).delete_associated(:children, child.id) - @parent1.reload + @model1.reload # Child should have been removed - assert_equal(2, @parent1.children.size) - c1, c2 = @parent1.children.order(:position) + assert_equal(2, @model1.children.size) + c1, c2 = @model1.children.order(:position) assert_equal("p1c1", c1.name) assert_equal("p1c3", c2.name) - assert_equal(0, Child.where(id: child.id).size) + assert_equal(0, child_model_class.where(id: child.id).size) end def test_move_and_edit_child_to_new - child = @parent1.children[1] + child = @model1.children[1] - child_view = ChildView.new(child).to_hash + child_view = child_viewmodel_class.new(child).to_hash child_view["name"] = "changed" - view = { "_type" => "Parent", + view = { "_type" => "Model", "name" => "new_p", "children" => [child_view, { "_type" => "Child", "name" => "new" }]} # TODO this is as awkward here as it is in the application - release_view = { "_type" => "Parent", - "id" => @parent1.id, - "children" => [{ "_type" => "Child", "id" => @parent1.children[0].id }, - { "_type" => "Child", "id" => @parent1.children[2].id }]} + release_view = { "_type" => "Model", + "id" => @model1.id, + "children" => [{ "_type" => "Child", "id" => @model1.children[0].id }, + { "_type" => "Child", "id" => @model1.children[2].id }]} - pv = ParentView.deserialize_from_view([view, release_view]) + pv = viewmodel_class.deserialize_from_view([view, release_view]) new_parent = pv.first.model # child should be removed from old parent and positions updated - @parent1.reload - assert_equal(2, @parent1.children.size, "database has 2 children") - oc1, oc2 = @parent1.children.order(:position) + @model1.reload + assert_equal(2, @model1.children.size, "database has 2 children") + oc1, oc2 = @model1.children.order(:position) assert_equal("p1c1", oc1.name, "database c1 unchanged") assert_equal("p1c3", oc2.name, "database c2 unchanged") @@ -626,32 +586,32 @@ def test_move_and_edit_child_to_new end def test_move_and_edit_child_to_existing - old_child = @parent1.children[1] + old_child = @model1.children[1] - old_child_view = ChildView.new(old_child).to_hash + old_child_view = child_viewmodel_class.new(old_child).to_hash old_child_view["name"] = "changed" - view = ParentView.new(@parent2).to_hash + view = viewmodel_class.new(@model2).to_hash view["children"] << old_child_view - release_view = {"_type" => "Parent", "id" => @parent1.id, - "children" => [{"_type" => "Child", "id" => @parent1.children[0].id}, - {"_type" => "Child", "id" => @parent1.children[2].id}]} + release_view = {"_type" => "Model", "id" => @model1.id, + "children" => [{"_type" => "Child", "id" => @model1.children[0].id}, + {"_type" => "Child", "id" => @model1.children[2].id}]} - ParentView.deserialize_from_view([view, release_view]) + viewmodel_class.deserialize_from_view([view, release_view]) - @parent1.reload - @parent2.reload + @model1.reload + @model2.reload # child should be removed from old parent and positions updated - assert_equal(2, @parent1.children.size) - oc1, oc2 = @parent1.children.order(:position) + assert_equal(2, @model1.children.size) + oc1, oc2 = @model1.children.order(:position) assert_equal("p1c1", oc1.name) assert_equal("p1c3", oc2.name) # child should be added to new parent with valid position - assert_equal(3, @parent2.children.size) - nc1, _, nc3 = @parent2.children.order(:position) + assert_equal(3, @model2.children.size) + nc1, _, nc3 = @model2.children.order(:position) assert_equal("p2c1", nc1.name) assert_equal("p2c1", nc1.name) @@ -661,27 +621,27 @@ def test_move_and_edit_child_to_existing end def test_functional_update_append - children_before = @parent1.children.order(:position).pluck(:id) + children_before = @model1.children.order(:position).pluck(:id) fupdate = build_fupdate do append([{ '_type' => 'Child' }, { '_type' => 'Child' }]) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - result = ParentView.deserialize_from_view(append_view) - @parent1.reload + result = viewmodel_class.deserialize_from_view(append_view) + @model1.reload created_children = result.children[-2,2].map(&:id) assert_equal(children_before + created_children, - @parent1.children.order(:position).pluck(:id)) + @model1.children.order(:position).pluck(:id)) end def test_functional_update_append_before_mid - c1, c2, c3 = @parent1.children.order(:position) + c1, c2, c3 = @model1.children.order(:position) fupdate = build_fupdate do append([{ '_type' => 'Child', 'name' => 'new c1' }, @@ -689,36 +649,36 @@ def test_functional_update_append_before_mid before: { '_type' => 'Child', 'id' => c2.id }) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - ParentView.deserialize_from_view(append_view) - @parent1.reload + viewmodel_class.deserialize_from_view(append_view) + @model1.reload assert_equal([c1.name, 'new c1', 'new c2', c2.name, c3.name], - @parent1.children.order(:position).pluck(:name)) + @model1.children.order(:position).pluck(:name)) end def test_functional_update_append_before_reorder - c1, c2, c3 = @parent1.children.order(:position) + c1, c2, c3 = @model1.children.order(:position) fupdate = build_fupdate do append([{ '_type' => 'Child', 'id' => c3.id }], before: { '_type' => 'Child', 'id' => c2.id }) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - ParentView.deserialize_from_view(append_view) - @parent1.reload + viewmodel_class.deserialize_from_view(append_view) + @model1.reload assert_equal([c1.name, c3.name, c2.name], - @parent1.children.order(:position).pluck(:name)) + @model1.children.order(:position).pluck(:name)) end def test_functional_update_append_before_beginning - c1, c2, c3 = @parent1.children.order(:position) + c1, c2, c3 = @model1.children.order(:position) fupdate = build_fupdate do append([{ '_type' => 'Child', 'name' => 'new c1' }, @@ -726,18 +686,18 @@ def test_functional_update_append_before_beginning before: { '_type' => 'Child', 'id' => c1.id }) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - ParentView.deserialize_from_view(append_view) - @parent1.reload + viewmodel_class.deserialize_from_view(append_view) + @model1.reload assert_equal(['new c1', 'new c2', c1.name, c2.name, c3.name], - @parent1.children.order(:position).pluck(:name)) + @model1.children.order(:position).pluck(:name)) end def test_functional_update_append_before_corpse - _, c2, _ = @parent1.children.order(:position) + _, c2, _ = @model1.children.order(:position) c2.destroy fupdate = build_fupdate do @@ -746,16 +706,16 @@ def test_functional_update_append_before_corpse before: { '_type' => 'Child', 'id' => c2.id }) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } assert_raises(ViewModel::DeserializationError::AssociatedNotFound) do - ParentView.deserialize_from_view(append_view) + viewmodel_class.deserialize_from_view(append_view) end end def test_functional_update_append_after_mid - c1, c2, c3 = @parent1.children.order(:position) + c1, c2, c3 = @model1.children.order(:position) fupdate = build_fupdate do append([{ '_type' => 'Child', 'name' => 'new c1' }, @@ -763,18 +723,18 @@ def test_functional_update_append_after_mid after: { '_type' => 'Child', 'id' => c2.id }) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - ParentView.deserialize_from_view(append_view) - @parent1.reload + viewmodel_class.deserialize_from_view(append_view) + @model1.reload assert_equal([c1.name, c2.name, 'new c1', 'new c2', c3.name], - @parent1.children.order(:position).pluck(:name)) + @model1.children.order(:position).pluck(:name)) end def test_functional_update_append_after_end - c1, c2, c3 = @parent1.children.order(:position) + c1, c2, c3 = @model1.children.order(:position) fupdate = build_fupdate do append([{ '_type' => 'Child', 'name' => 'new c1' }, @@ -782,18 +742,18 @@ def test_functional_update_append_after_end after: { '_type' => 'Child', 'id' => c3.id, }) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - ParentView.deserialize_from_view(append_view) - @parent1.reload + viewmodel_class.deserialize_from_view(append_view) + @model1.reload assert_equal([c1.name, c2.name, c3.name, 'new c1', 'new c2'], - @parent1.children.order(:position).pluck(:name)) + @model1.children.order(:position).pluck(:name)) end def test_functional_update_append_after_corpse - _, c2, _ = @parent1.children.order(:position) + _, c2, _ = @model1.children.order(:position) c2.destroy fupdate = build_fupdate do @@ -803,32 +763,32 @@ def test_functional_update_append_after_corpse ) end - append_view = { '_type' => 'Parent', - 'id' => @parent1.id, + append_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } assert_raises(ViewModel::DeserializationError::AssociatedNotFound) do - ParentView.deserialize_from_view(append_view) + viewmodel_class.deserialize_from_view(append_view) end end def test_functional_update_remove_success - c1_id, c2_id, c3_id = @parent1.children.pluck(:id) + c1_id, c2_id, c3_id = @model1.children.pluck(:id) fupdate = build_fupdate do remove([{ '_type' => 'Child', 'id' => c2_id }]) end - remove_view = { '_type' => 'Parent', - 'id' => @parent1.id, + remove_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - ParentView.deserialize_from_view(remove_view) - @parent1.reload + viewmodel_class.deserialize_from_view(remove_view) + @model1.reload - assert_equal([c1_id, c3_id], @parent1.children.pluck(:id)) + assert_equal([c1_id, c3_id], @model1.children.pluck(:id)) end def test_functional_update_remove_failure - c_id = @parent1.children.pluck(:id).first + c_id = @model1.children.pluck(:id).first fupdate = build_fupdate do remove([{ '_type' => 'Child', @@ -836,19 +796,19 @@ def test_functional_update_remove_failure 'name' => 'remove and update disallowed' }]) end - remove_view = { '_type' => 'Parent', - 'id' => @parent1.id, + remove_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } ex = assert_raises(ViewModel::DeserializationError::InvalidSyntax) do - ParentView.deserialize_from_view(remove_view) + viewmodel_class.deserialize_from_view(remove_view) end assert_match(/Removed entities must have only _type and id fields/, ex.message) end def test_functional_update_update_success - c1_id, c2_id, c3_id = @parent1.children.pluck(:id) + c1_id, c2_id, c3_id = @model1.children.pluck(:id) fupdate = build_fupdate do update([{ '_type' => 'Child', @@ -856,34 +816,36 @@ def test_functional_update_update_success 'name' => 'Functionally Updated Child' }]) end - update_view = { '_type' => 'Parent', - 'id' => @parent1.id, + update_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } - ParentView.deserialize_from_view(update_view) - @parent1.reload + viewmodel_class.deserialize_from_view(update_view) + @model1.reload - assert_equal([c1_id, c2_id, c3_id], @parent1.children.pluck(:id)) - assert_equal('Functionally Updated Child', Child.find(c2_id).name) + assert_equal([c1_id, c2_id, c3_id], @model1.children.pluck(:id)) + assert_equal('Functionally Updated Child', child_model_class.find(c2_id).name) end def test_functional_update_update_failure - cnew = Child.create(parent: Parent.create).id + cnew = child_model_class.create(model: model_class.create, position: 0).id fupdate = build_fupdate do update([{ '_type' => 'Child', 'id' => cnew }]) end - update_view = { '_type' => 'Parent', - 'id' => @parent1.id, - 'children' => fupdate } + update_view = { + '_type' => 'Model', + 'id' => @model1.id, + 'children' => fupdate, + } assert_raises(ViewModel::DeserializationError::AssociatedNotFound) do - ParentView.deserialize_from_view(update_view) + viewmodel_class.deserialize_from_view(update_view) end end def test_functional_update_duplicate_refs - child_id = @parent1.children.pluck(:id).first + child_id = @model1.children.pluck(:id).first fupdate = build_fupdate do # remove and append the same child @@ -891,67 +853,329 @@ def test_functional_update_duplicate_refs append([{ '_type' => 'Child', 'id' => child_id }]) end - update_view = { '_type' => 'Parent', - 'id' => @parent1.id, + update_view = { '_type' => 'Model', + 'id' => @model1.id, 'children' => fupdate } ex = assert_raises(ViewModel::DeserializationError::InvalidStructure) do - ParentView.deserialize_from_view(update_view) + viewmodel_class.deserialize_from_view(update_view) end assert_match(/Duplicate functional update targets\b.*\bChild\b/, ex.message) end + describe 'owned reference children' do + def child_attributes + super.merge(viewmodel: ->(v) { root! }) + end + + def new_model + new_children = (1 .. 2).map { |n| child_model_class.new(name: "c#{n}", position: n) } + model_class.new(name: 'm1', children: new_children) + end - class RenamedTest < ActiveSupport::TestCase - include ARVMTestUtilities + it 'makes a reference association' do + assert(subject_association.referenced?) + end - def before_all - super + it 'makes an owned association' do + assert(subject_association.owned?) + end + + it 'loads and batches' do + create_model! - build_viewmodel(:Parent) do - define_schema do |t| - t.string :name + log_queries do + serialize(ModelView.load) + end + + assert_equal(['Model Load', 'Child Load'], logged_load_queries) + end + + it 'serializes' do + model = create_model! + view, refs = serialize_with_references(ModelView.new(model)) + + children = model.children.sort_by(&:position) + assert_equal(children.size, view['children'].size) + + child_refs = view['children'].map { |c| c['_ref'] } + child_views = child_refs.map { |r| refs[r] } + + children.zip(child_views).each do |child, child_view| + assert_equal(child_view, + { '_type' => 'Child', + '_version' => 1, + 'id' => child.id, + 'name' => child.name }) + end + + assert_equal({ '_type' => 'Model', + '_version' => 1, + 'id' => model.id, + 'name' => model.name, + 'children' => view['children'] }, + view) + end + + it 'creates from view' do + view = { + '_type' => 'Model', + 'name' => 'p', + 'children' => [{ '_ref' => 'r1' }], + } + + refs = { + 'r1' => { '_type' => 'Child', 'name' => 'newkid' }, + } + + pv = ModelView.deserialize_from_view(view, references: refs) + p = pv.model + + assert(!p.changed?) + assert(!p.new_record?) + + assert_equal('p', p.name) + + assert(p.children.present?) + assert_equal('newkid', p.children[0].name) + end + + it 'updates with adding a child' do + model = create_model! + + alter_by_view!(ModelView, model) do |view, refs| + view['children'] << { '_ref' => 'ref1' } + refs['ref1'] = { + '_type' => 'Child', + 'name' => 'newchildname', + } + end + + assert_equal(3, model.children.size) + assert_equal('newchildname', model.children.last.name) + end + + it 'updates with adding a child functionally' do + model = create_model! + + alter_by_view!(ModelView, model) do |view, refs| + refs.clear + + view['children'] = build_fupdate do + append([{ '_ref' => 'ref1' }]) end - define_model do - has_many :children, dependent: :destroy, inverse_of: :parent + refs['ref1'] = { + '_type' => 'Child', + 'name' => 'newchildname', + } + end + + assert_equal(3, model.children.size) + assert_equal('newchildname', model.children.last.name) + end + + it 'updates with removing a child' do + model = create_model! + old_child = model.children.last + + alter_by_view!(ModelView, model) do |view, refs| + removed = view['children'].pop['_ref'] + refs.delete(removed) + end + + assert_equal(1, model.children.size) + assert_equal('c1', model.children.first.name) + assert_empty(child_model_class.where(id: old_child.id)) + end + + it 'updates with removing a child functionally' do + model = create_model! + old_child = model.children.last + + alter_by_view!(ModelView, model) do |view, refs| + removed_ref = view['children'].pop['_ref'] + removed_id = refs[removed_ref]['id'] + refs.clear + + view['children'] = build_fupdate do + remove([{ '_type' => 'Child', 'id' => removed_id }]) + end + end + + assert_equal(1, model.children.size) + assert_equal('c1', model.children.first.name) + assert_empty(child_model_class.where(id: old_child.id)) + end + + it 'updates with replacing a child' do + model = create_model! + old_child = model.children.last + + alter_by_view!(ModelView, model) do |view, refs| + exchange_ref = view['children'].last['_ref'] + refs[exchange_ref] = { + '_type' => 'Child', + 'name' => 'newchildname', + } + end + + children = model.children.sort_by(&:position) + assert_equal(2, children.size) + refute_equal(old_child.id, children.last.id) + assert_equal('newchildname', children.last.name) + assert_empty(child_model_class.where(id: old_child.id)) + end + + it 'updates with replacing a child functionally' do + model = create_model! + old_child = model.children.first + + alter_by_view!(ModelView, model) do |view, refs| + removed_ref = view['children'].shift['_ref'] + removed_id = refs[removed_ref]['id'] + refs.clear + + view['children'] = build_fupdate do + append([{ '_ref' => 'repl_ref' }], + after: { '_type' => 'Child', 'id' => removed_id }) + remove([{ '_type' => 'Child', 'id' => removed_id }]) end - define_viewmodel do - attributes :name - association :children, as: :something_else + refs['repl_ref'] = { + '_type' => 'Child', + 'name' => 'newchildname', + } + end + + children = model.children.sort_by(&:position) + assert_equal(2, children.size) + refute_equal(old_child.id, children.first.id) + assert_equal('newchildname', children.first.name) + assert_empty(child_model_class.where(id: old_child.id)) + end + + it 'updates with editing a child' do + model = create_model! + + alter_by_view!(ModelView, model) do |view, refs| + c1ref = view['children'].first['_ref'] + refs[c1ref]['name'] = 'renamed' + end + + assert_equal(2, model.children.size) + assert_equal('renamed', model.children.first.name) + end + + it 'updates with editing a child functionally' do + model = create_model! + + alter_by_view!(ModelView, model) do |view, refs| + edit_ref = view['children'].shift['_ref'] + refs.slice!(edit_ref) + + view['children'] = build_fupdate do + update([{ '_ref' => edit_ref }]) end + + refs[edit_ref]['name'] = 'renamed' + end + + assert_equal(2, model.children.size) + assert_equal('renamed', model.children.first.name) + end + + describe 'with association manipulation' do + it 'appends a child' do + view = create_viewmodel! + + view.append_associated(:children, { '_type' => 'Child', 'name' => 'newchildname' }) + + view.model.reload + assert_equal(3, view.children.size) + assert_equal('newchildname', view.children.last.name) + end + + it 'inserts a child' do + view = create_viewmodel! + c1 = view.children.first + + view.append_associated(:children, + { '_type' => 'Child', 'name' => 'newchildname' }, + after: c1.to_reference) + view.model.reload + + assert_equal(3, view.children.size) + assert_equal('newchildname', view.children[1].name) + end + + it 'moves a child' do + view = create_viewmodel! + c1, c2 = view.children + + view.append_associated(:children, + { '_type' => 'Child', 'id' => c2.id }, + before: c1.to_reference) + view.model.reload + + assert_equal(2, view.children.size) + assert_equal(['c2', 'c1'], view.children.map(&:name)) + end + + it 'replaces children' do + view = create_viewmodel! + view.replace_associated(:children, + [{ '_type' => 'Child', 'name' => 'newchildname' }]) + + view.model.reload + + assert_equal(1, view.children.size) + assert_equal('newchildname', view.children[0].name) end - ViewModel::ActiveRecord::HasManyTest.build_child(self) + it 'deletes a child' do + view = create_viewmodel! + view.delete_associated(:children, view.children.first.id) + + view.model.reload + + assert_equal(1, view.children.size) + assert_equal('c2', view.children[0].name) + end + end + end + + describe 'renaming associations' do + def subject_association_features + { as: :something_else } end def setup super - @parent = Parent.create(name: 'p1', children: [Child.new(name: 'c1')]) + @model = model_class.create(name: 'p1', children: [child_model_class.new(name: 'c1', position: 0)]) enable_logging! end def test_dependencies - root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Parent', 'something_else' => [] }]) + root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Model', 'something_else' => [] }]) assert_equal(DeepPreloader::Spec.new('children' => DeepPreloader::Spec.new), root_updates.first.preload_dependencies) - assert_equal({ 'something_else' => {} }, root_updates.first.updated_associations) end def test_renamed_roundtrip - alter_by_view!(ParentView, @parent) do |view, _refs| - assert_equal([{ 'id' => @parent.children.first.id, + alter_by_view!(viewmodel_class, @model) do |view, _refs| + assert_equal([{ 'id' => @model.children.first.id, '_type' => 'Child', '_version' => 1, 'name' => 'c1' }], view['something_else']) + view['something_else'][0]['name'] = 'new c1 name' end - assert_equal('new c1 name', @parent.children.first.name) + assert_equal('new c1 name', @model.children.first.name) end end end diff --git a/test/unit/view_model/active_record/has_many_through_poly_test.rb b/test/unit/view_model/active_record/has_many_through_poly_test.rb index 09cbec07..fb6023e5 100644 --- a/test/unit/view_model/active_record/has_many_through_poly_test.rb +++ b/test/unit/view_model/active_record/has_many_through_poly_test.rb @@ -20,6 +20,7 @@ def self.build_tag_a(arvm_test_case) end define_viewmodel do + root! attributes :name end end @@ -37,6 +38,7 @@ def self.build_tag_b(arvm_test_case) end define_viewmodel do + root! attributes :name end end @@ -53,8 +55,9 @@ def self.build_parent(arvm_test_case) end define_viewmodel do + root! attributes :name - association :tags, shared: true, through: :parents_tags, through_order_attr: :position, viewmodels: [TagAView, TagBView] + association :tags, through: :parents_tags, through_order_attr: :position, viewmodels: [TagAView, TagBView] end end end @@ -86,10 +89,6 @@ def before_all self.class.build_parent_tag_join_model(self) end - private def context_with(*args) - ParentView.new_serialize_context(include: args) - end - def setup super @@ -108,14 +107,14 @@ def setup def test_roundtrip # Objects are serialized to a view and deserialized, and should not be different when complete. - alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) {} + alter_by_view!(ParentView, @parent1) {} assert_equal('p1', @parent1.name) assert_equal([@tag_a1, @tag_a2, @tag_b1, @tag_b2], @parent1.parents_tags.order(:position).map(&:tag)) end def test_loading_batching - context = context_with(:tags) + context = ParentView.new_serialize_context log_queries do parent_views = ParentView.load(serialize_context: context) serialize(parent_views, serialize_context: context) @@ -126,7 +125,7 @@ def test_loading_batching end def test_eager_includes - includes = ParentView.eager_includes(serialize_context: context_with(:tags)) + includes = ParentView.eager_includes assert_equal(DeepPreloader::Spec.new( 'parents_tags' => DeepPreloader::Spec.new( 'tag' => DeepPreloader::PolymorphicSpec.new( @@ -157,8 +156,7 @@ def test_preload_dependencies def test_serialize - view, refs = serialize_with_references(ParentView.new(@parent1), - serialize_context: context_with(:tags)) + view, refs = serialize_with_references(ParentView.new(@parent1)) tag_data = view['tags'].map { |hash| refs[hash['_ref']] } assert_equal([{ 'id' => @tag_a1.id, '_type' => 'TagA', '_version' => 1, 'name' => 'tag A1' }, @@ -188,7 +186,7 @@ def test_create_has_many_through end def test_reordering_swap_type - alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) do |view, refs| + alter_by_view!(ParentView, @parent1) do |view, refs| t1, t2, t3, t4 = view['tags'] view['tags'] = [t3, t2, t1, t4] end @@ -223,8 +221,9 @@ def before_all end define_viewmodel do + root! attributes :name - association :tags, shared: true, through: :parents_tags, through_order_attr: :position, viewmodels: [TagAView, TagBView], as: :something_else + association :tags, through: :parents_tags, through_order_attr: :position, viewmodels: [TagAView, TagBView], as: :something_else end end @@ -244,12 +243,10 @@ def test_dependencies # Compare to non-polymorphic, which will also load the tags deps = root_updates.first.preload_dependencies assert_equal(DeepPreloader::Spec.new('parents_tags' => DeepPreloader::Spec.new('tag' => DeepPreloader::PolymorphicSpec.new)), deps) - assert_equal({ 'something_else' => {} }, root_updates.first.updated_associations) end - def test_renamed_roundtrip - context = ParentView.new_serialize_context(include: :something_else) + context = ParentView.new_serialize_context alter_by_view!(ParentView, @parent, serialize_context: context) do |view, refs| assert_equal({refs.keys.first => { 'id' => @parent.parents_tags.first.tag.id, '_type' => 'TagA', diff --git a/test/unit/view_model/active_record/has_many_through_test.rb b/test/unit/view_model/active_record/has_many_through_test.rb index 4e114636..47a80845 100644 --- a/test/unit/view_model/active_record/has_many_through_test.rb +++ b/test/unit/view_model/active_record/has_many_through_test.rb @@ -19,8 +19,9 @@ def self.build_parent(arvm_test_case) end define_viewmodel do + root! attributes :name - association :tags, shared: true, through: :parents_tags, through_order_attr: :position + association :tags, through: :parents_tags, through_order_attr: :position end end end @@ -40,6 +41,7 @@ def self.build_tag(arvm_test_case, with: []) end define_viewmodel do + root! attributes :name if use_childtag associations :child_tags @@ -91,10 +93,6 @@ def before_all self.class.build_join_table_model(self) end - private def context_with(*args) - ParentView.new_serialize_context(include: args) - end - def setup super @@ -108,7 +106,7 @@ def setup end def test_loading_batching - context = context_with(:tags) + context = ParentView.new_serialize_context log_queries do parent_views = ParentView.load(serialize_context: context) serialize(parent_views, serialize_context: context) @@ -121,13 +119,13 @@ def test_loading_batching def test_roundtrip # Objects are serialized to a view and deserialized, and should not be different when complete. - alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) {} + alter_by_view!(ParentView, @parent1) {} assert_equal('p1', @parent1.name) assert_equal([@tag1, @tag2], @parent1.parents_tags.order(:position).map(&:tag)) end def test_eager_includes - includes = ParentView.eager_includes(serialize_context: context_with(:tags)) + includes = ParentView.eager_includes assert_equal(DeepPreloader::Spec.new('parents_tags' => DeepPreloader::Spec.new('tag' => DeepPreloader::Spec.new)), includes) end @@ -150,21 +148,8 @@ def test_preload_dependencies 'mentioning tags and child_tags causes through association loading') end - def test_updated_associations - root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes( - [{ '_type' => 'Parent', - 'tags' => [{ '_ref' => 'r1' }] }], - { 'r1' => { '_type' => 'Tag', } }) - - assert_equal({ 'tags' => {} }, - root_updates.first.updated_associations, - 'mentioning tags causes through association loading') - - end - def test_serialize - view, refs = serialize_with_references(ParentView.new(@parent1), - serialize_context: context_with(:tags)) + view, refs = serialize_with_references(ParentView.new(@parent1)) tag_data = view['tags'].map { |hash| refs[hash['_ref']] } assert_equal([{ 'id' => @tag1.id, '_type' => 'Tag', '_version' => 1, 'name' => 'tag1' }, @@ -198,7 +183,7 @@ def test_delete end def test_reordering - pv, ctx = alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) do |view, _refs| + pv, ctx = alter_by_view!(ParentView, @parent1) do |view, _refs| view['tags'].reverse! end @@ -211,7 +196,7 @@ def test_reordering def test_child_edit_doesnt_editcheck_parent # editing child doesn't edit check parent - pv, d_context = alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) do |view, refs| + pv, d_context = alter_by_view!(ParentView, @parent1) do |view, refs| refs[view['tags'][0]["_ref"]]["name"] = "changed" end @@ -223,7 +208,7 @@ def test_child_edit_doesnt_editcheck_parent end def test_child_reordering_editchecks_parent - pv, d_context = alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) do |view, _refs| + pv, d_context = alter_by_view!(ParentView, @parent1) do |view, _refs| view['tags'].reverse! end @@ -232,7 +217,7 @@ def test_child_reordering_editchecks_parent end def test_child_deletion_editchecks_parent - pv, d_context = alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) do |view, refs| + pv, d_context = alter_by_view!(ParentView, @parent1) do |view, refs| removed = view['tags'].pop['_ref'] refs.delete(removed) end @@ -242,7 +227,7 @@ def test_child_deletion_editchecks_parent end def test_child_addition_editchecks_parent - pv, d_context = alter_by_view!(ParentView, @parent1, serialize_context: context_with(:tags)) do |view, refs| + pv, d_context = alter_by_view!(ParentView, @parent1) do |view, refs| view['tags'] << { '_ref' => 't_new' } refs['t_new'] = { '_type' => 'Tag', 'name' => 'newest tag' } end @@ -617,8 +602,9 @@ def before_all end define_viewmodel do + root! attributes :name - association :tags, shared: true, through: :parents_tags, through_order_attr: :position, as: :something_else + association :tags, through: :parents_tags, through_order_attr: :position, as: :something_else end end @@ -638,11 +624,10 @@ def test_dependencies root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Parent', 'something_else' => [] }]) assert_equal(DeepPreloader::Spec.new('parents_tags' => DeepPreloader::Spec.new('tag' => DeepPreloader::Spec.new)), root_updates.first.preload_dependencies) - assert_equal({ 'something_else' => {} }, root_updates.first.updated_associations) end def test_renamed_roundtrip - context = ParentView.new_serialize_context(include: :something_else) + context = ParentView.new_serialize_context alter_by_view!(ParentView, @parent, serialize_context: context) do |view, refs| assert_equal({refs.keys.first => { 'id' => @parent.parents_tags.first.tag.id, '_type' => 'Tag', @@ -703,34 +688,6 @@ def test_preload_dependencies_functional root_updates.first.preload_dependencies, 'mentioning tags and child_tags in functional update value causes through association loading, ' \ 'excluding shared') - - end - - def test_updated_associations - root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes( - [{ '_type' => 'Parent', - 'tags' => [{ '_ref' => 'r1' }] }], - { 'r1' => { '_type' => 'Tag', 'child_tags' => [] } }) - - assert_equal({ 'tags' => { } }, - root_updates.first.updated_associations, - 'mentioning tags and child_tags causes through association loading, excluding shared') - end - - def test_updated_associations_functional - fupdate = build_fupdate do - append([{ '_ref' => 'r1' }]) - end - - root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes( - [{ '_type' => 'Parent', - 'tags' => fupdate }], - { 'r1' => { '_type' => 'Tag', 'child_tags' => [] } }) - - assert_equal({ 'tags' => { } }, - root_updates.first.updated_associations, - 'mentioning tags and child_tags in functional_update causes through association loading, ' \ - 'excluding shared') end end end diff --git a/test/unit/view_model/active_record/has_one_test.rb b/test/unit/view_model/active_record/has_one_test.rb index 81d83915..6bace6f0 100644 --- a/test/unit/view_model/active_record/has_one_test.rb +++ b/test/unit/view_model/active_record/has_one_test.rb @@ -1,5 +1,6 @@ require_relative "../../../helpers/arvm_test_utilities.rb" require_relative "../../../helpers/arvm_test_models.rb" +require_relative '../../../helpers/viewmodel_spec_helpers.rb' require "minitest/autorun" @@ -8,80 +9,41 @@ class ViewModel::ActiveRecord::HasOneTest < ActiveSupport::TestCase include ARVMTestUtilities - def self.build_target(arvm_test_case) - arvm_test_case.build_viewmodel(:Target) do - define_schema do |t| - t.string :text - t.references :parent, foreign_key: true - end - - define_model do - belongs_to :parent, inverse_of: :target - end - - define_viewmodel do - attributes :text - end - end - end - - - def self.build_parent(arvm_test_case) - arvm_test_case.build_viewmodel(:Parent) do - define_schema do |t| - t.string :name - end - - define_model do - has_one :target, dependent: :destroy, inverse_of: :parent - end - - define_viewmodel do - attributes :name - associations :target - end - end - end - - def before_all - super - - self.class.build_parent(self) - self.class.build_target(self) - end + extend Minitest::Spec::DSL + include ViewModelSpecHelpers::ParentAndHasOneChild def setup super - # TODO make a `has_list?` that allows a parent to set all children as an array - @parent1 = Parent.new(name: "p1", - target: Target.new(text: "p1t")) - @parent1.save! + # TODO make a `has_list?` that allows a model to set all children as an array + @model1 = model_class.new(name: "p1", + child: child_model_class.new(name: "p1t")) + @model1.save! - @parent2 = Parent.new(name: "p2", - target: Target.new(text: "p2t")) + @model2 = model_class.new(name: "p2", + child: child_model_class.new(name: "p2t")) - @parent2.save! + @model2.save! enable_logging! end def test_loading_batching log_queries do - serialize(ParentView.load) + serialize(ModelView.load) end - assert_equal(['Parent Load', 'Target Load'], + assert_equal(['Model Load', 'Child Load'], logged_load_queries) end def test_create_from_view view = { - "_type" => "Parent", + "_type" => "Model", "name" => "p", - "target" => { "_type" => "Target", "text" => "t" }, + "child" => { "_type" => "Child", "name" => "t" }, } - pv = ParentView.deserialize_from_view(view) + pv = ModelView.deserialize_from_view(view) p = pv.model assert(!p.changed?) @@ -90,192 +52,383 @@ def test_create_from_view assert_equal("p", p.name) - assert(p.target.present?) - assert_equal("t", p.target.text) + assert(p.child.present?) + assert_equal("t", p.child.name) end def test_serialize_view - view, _refs = serialize_with_references(ParentView.new(@parent1)) - assert_equal({ "_type" => "Parent", + view, _refs = serialize_with_references(ModelView.new(@model1)) + assert_equal({ "_type" => "Model", "_version" => 1, - "id" => @parent1.id, - "name" => @parent1.name, - "target" => { "_type" => "Target", + "id" => @model1.id, + "name" => @model1.name, + "child" => { "_type" => "Child", "_version" => 1, - "id" => @parent1.target.id, - "text" => @parent1.target.text } }, + "id" => @model1.child.id, + "name" => @model1.child.name } }, view) end def test_swap_has_one - @parent1.update(target: t1 = Target.new) - @parent2.update(target: t2 = Target.new) + @model1.update(child: t1 = Child.new) + @model2.update(child: t2 = Child.new) deserialize_context = ViewModelBase.new_deserialize_context - ParentView.deserialize_from_view( - [update_hash_for(ParentView, @parent1) { |p| p['target'] = update_hash_for(TargetView, t2) }, - update_hash_for(ParentView, @parent2) { |p| p['target'] = update_hash_for(TargetView, t1) }], + ModelView.deserialize_from_view( + [update_hash_for(ModelView, @model1) { |p| p['child'] = update_hash_for(ChildView, t2) }, + update_hash_for(ModelView, @model2) { |p| p['child'] = update_hash_for(ChildView, t1) }], deserialize_context: deserialize_context) - assert_equal(Set.new([ViewModel::Reference.new(ParentView, @parent1.id), - ViewModel::Reference.new(ParentView, @parent2.id)]), + assert_equal(Set.new([ViewModel::Reference.new(ModelView, @model1.id), + ViewModel::Reference.new(ModelView, @model2.id)]), deserialize_context.valid_edit_refs.to_set) - @parent1.reload - @parent2.reload + @model1.reload + @model2.reload - assert_equal(@parent1.target, t2) - assert_equal(@parent2.target, t1) + assert_equal(@model1.child, t2) + assert_equal(@model2.child, t1) end def test_has_one_create_nil - view = { '_type' => 'Parent', 'name' => 'p', 'target' => nil } - pv = ParentView.deserialize_from_view(view) - assert_nil(pv.model.target) + view = { '_type' => 'Model', 'name' => 'p', 'child' => nil } + pv = ModelView.deserialize_from_view(view) + assert_nil(pv.model.child) end def test_has_one_create - @parent1.update(target: nil) + @model1.update(child: nil) - alter_by_view!(ParentView, @parent1) do |view, refs| - view['target'] = { '_type' => 'Target', 'text' => 't' } + alter_by_view!(ModelView, @model1) do |view, refs| + view['child'] = { '_type' => 'Child', 'name' => 't' } end - assert_equal('t', @parent1.target.text) + assert_equal('t', @model1.child.name) end def test_has_one_update - alter_by_view!(ParentView, @parent1) do |view, refs| - view['target']['text'] = "hello" + alter_by_view!(ModelView, @model1) do |view, refs| + view['child']['name'] = "hello" end - assert_equal('hello', @parent1.target.text) + assert_equal('hello', @model1.child.name) end def test_has_one_destroy - old_target = @parent1.target - alter_by_view!(ParentView, @parent1) do |view, refs| - view['target'] = nil + old_child = @model1.child + alter_by_view!(ModelView, @model1) do |view, refs| + view['child'] = nil end - assert(Target.where(id: old_target.id).blank?) + assert(Child.where(id: old_child.id).blank?) end def test_has_one_move_and_replace - old_parent1_target = @parent1.target - old_parent2_target = @parent2.target + old_model1_child = @model1.child + old_model2_child = @model2.child - alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), refs| - p2['target'] = p1['target'] - p1['target'] = nil + alter_by_view!(ModelView, [@model1, @model2]) do |(p1, p2), refs| + p2['child'] = p1['child'] + p1['child'] = nil end - assert(@parent1.target.blank?) - assert_equal(old_parent1_target, @parent2.target) - assert(Target.where(id: old_parent2_target).blank?) + assert(@model1.child.blank?) + assert_equal(old_model1_child, @model2.child) + assert(Child.where(id: old_model2_child).blank?) end def test_has_one_cannot_duplicate_unreleased_child - # p2 shouldn't be able to copy p1's target + # p2 shouldn't be able to copy p1's child assert_raises(ViewModel::DeserializationError::DuplicateNodes) do - alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), _refs| - p2['target'] = p1['target'].dup + alter_by_view!(ModelView, [@model1, @model2]) do |(p1, p2), _refs| + p2['child'] = p1['child'].dup end end end def test_has_one_cannot_duplicate_implicitly_unreleased_child - # p2 shouldn't be able to copy p1's target, even when p1 doesn't explicitly + # p2 shouldn't be able to copy p1's child, even when p1 doesn't explicitly # specify the association assert_raises(ViewModel::DeserializationError::ParentNotFound) do - alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), _refs| - p2['target'] = p1['target'] - p1.delete('target') + alter_by_view!(ModelView, [@model1, @model2]) do |(p1, p2), _refs| + p2['child'] = p1['child'] + p1.delete('child') end end end def test_has_one_cannot_take_from_outside_tree - t3 = Parent.create(target: Target.new(text: 'hi')).target + t3 = Model.create(child: Child.new(name: 'hi')).child assert_raises(ViewModel::DeserializationError::ParentNotFound) do - alter_by_view!(ParentView, [@parent1]) do |(p1), _refs| - p1['target'] = update_hash_for(TargetView, t3) + alter_by_view!(ModelView, [@model1]) do |(p1), _refs| + p1['child'] = update_hash_for(ChildView, t3) end end end - def test_has_one_cannot_take_unparented_from_outside_tree - t3 = Target.create(text: 'hi') # no parent + def test_has_one_cannot_take_unmodeled_from_outside_tree + t3 = Child.create(name: 'hi') # no model assert_raises(ViewModel::DeserializationError::ParentNotFound) do - alter_by_view!(ParentView, @parent1) do |p1, _refs| - p1['target'] = update_hash_for(TargetView, t3) + alter_by_view!(ModelView, @model1) do |p1, _refs| + p1['child'] = update_hash_for(ChildView, t3) end end end def test_bad_single_association view = { - "_type" => "Parent", - "target" => [] + "_type" => "Model", + "child" => [] } ex = assert_raises(ViewModel::DeserializationError::InvalidSyntax) do - ParentView.deserialize_from_view(view) + ModelView.deserialize_from_view(view) end assert_match(/not an object/, ex.message) end + describe 'owned reference child' do + def child_attributes + super.merge(viewmodel: ->(v) { root! }) + end - class RenameTest < ActiveSupport::TestCase - include ARVMTestUtilities + def new_model + model_class.new(name: 'm1', child: child_model_class.new(name: 'c1')) + end - def before_all - super + it 'makes a reference association' do + assert(subject_association.referenced?) + end - build_viewmodel(:Parent) do - define_schema do |t| - t.string :name + it 'makes an owned association' do + assert(subject_association.owned?) + end + + it 'loads and batches' do + create_model! + + log_queries do + serialize(ModelView.load) + end + + assert_equal(['Model Load', 'Child Load'], logged_load_queries) + end + + it 'serializes' do + model = create_model! + view, refs = serialize_with_references(ModelView.new(model)) + child1_ref = refs.detect { |_, v| v['_type'] == 'Child' }.first + + assert_equal({ child1_ref => { '_type' => 'Child', + '_version' => 1, + 'id' => model.child.id, + 'name' => model.child.name } }, + refs) + + assert_equal({ '_type' => 'Model', + '_version' => 1, + 'id' => model.id, + 'name' => model.name, + 'child' => { '_ref' => child1_ref } }, + view) + end + + it 'creates from view' do + view = { + '_type' => 'Model', + 'name' => 'p', + 'child' => { '_ref' => 'r1' }, + } + + refs = { + 'r1' => { '_type' => 'Child', 'name' => 'newkid' }, + } + + pv = ModelView.deserialize_from_view(view, references: refs) + p = pv.model + + assert(!p.changed?) + assert(!p.new_record?) + + assert_equal('p', p.name) + + assert(p.child.present?) + assert_equal('newkid', p.child.name) + end + + it 'updates' do + model = create_model! + + alter_by_view!(ModelView, model) do |view, refs| + ref = view['child']['_ref'] + refs[ref]['name'] = 'newchildname' + end + + assert_equal('newchildname', model.child.name) + end + + describe 'without a child' do + let(:new_model) { + model_class.new(name: 'm1', child: nil) + } + + it 'can add a child' do + model = create_model! + + alter_by_view!(ModelView, model) do |view, refs| + view['child'] = { '_ref' => 'ref1' } + refs['ref1'] = { + '_type' => 'Child', + 'name' => 'newchildname', + } end - define_model do - has_one :target, dependent: :destroy, inverse_of: :parent + assert(model.child.present?) + assert_equal('newchildname', model.child.name) + end + end + + it 'replaces a child with a new child' do + model = create_model! + old_child = model.child + + alter_by_view!(ModelView, model) do |view, refs| + ref = view['child']['_ref'] + refs[ref] = { '_type' => 'Child', 'name' => 'newchildname' } + end + model.reload + + assert_equal('newchildname', model.child.name) + refute_equal(old_child, model.child) + assert(Child.where(id: old_child.id).blank?) + end + + it 'takes a released child from another parent' do + model1 = create_model! + model2 = create_model! + + old_child1 = model1.child + old_child2 = model2.child + + alter_by_view!(ModelView, [model1, model2]) do |(view1, view2), refs| + ref1 = view1['child']['_ref'] + ref2 = view2['child']['_ref'] + refs.delete(ref1) + view1['child'] = { '_ref' => ref2 } + view2['child'] = nil + end + + assert_equal(model1.child, old_child2) + assert_nil(model2.child) + assert(Child.where(id: old_child1.id).blank?) + end + + it 'prevents taking an unreleased reference out-of-tree' do + model1 = create_model! + child2 = Child.create!(name: 'dummy') + + assert_raises(ViewModel::DeserializationError::ParentNotFound) do + alter_by_view!(ModelView, model1) do |view, refs| + refs.clear + view['child']['_ref'] = 'r1' + refs['r1'] = { '_type' => 'Child', 'id' => child2.id } end + end + end - define_viewmodel do - attributes :name - association :target, as: :something_else + it 'prevents taking an unreleased reference in-tree' do + model1 = create_model! + model2 = create_model! + + assert_raises(ViewModel::DeserializationError::DuplicateOwner) do + alter_by_view!(ModelView, [model1, model2]) do |(view1, view2), refs| + refs.delete(view1['child']['_ref']) + view1['child']['_ref'] = view2['child']['_ref'] end end + end + + it 'prevents two parents taking the same new reference' do + model1 = create_model! + model2 = create_model! + + assert_raises(ViewModel::DeserializationError::DuplicateOwner) do + alter_by_view!(ModelView, [model1, model2]) do |(view1, view2), refs| + refs.clear + refs['ref1'] = { '_type' => 'Child', 'name' => 'new' } + view1['child']['_ref'] = 'ref1' + view2['child']['_ref'] = 'ref1' + end + end + end + + it 'swaps children' do + model1 = create_model! + model2 = create_model! + + old_child1 = model1.child + old_child2 = model2.child + + alter_by_view!(ModelView, [model1, model2]) do |(view1, view2), _refs| + ref1 = view1['child'] + ref2 = view2['child'] + view1['child'] = ref2 + view2['child'] = ref1 + end + + assert_equal(model1.child, old_child2) + assert_equal(model2.child, old_child1) + end + + it 'deletes a child' do + model = create_model! + old_child = model.child + + alter_by_view!(ModelView, model) do |view, refs| + refs.clear + view['child'] = nil + end + + assert_nil(model.child) + assert(Child.where(id: old_child.id).blank?) + end + + it 'eager includes' do + includes = viewmodel_class.eager_includes + assert_equal(DeepPreloader::Spec.new('child' => DeepPreloader::Spec.new), includes) + end + end - ViewModel::ActiveRecord::HasOneTest.build_target(self) + describe 'renaming associations' do + def subject_association_features + { as: :something_else } end def setup super - @parent = Parent.create(target: Target.new(text: 'target text')) + @model = model_class.create(child: child_model_class.new(name: 'child name')) enable_logging! end def test_dependencies - root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Parent', 'something_else' => nil }]) - assert_equal(DeepPreloader::Spec.new('target' => DeepPreloader::Spec.new), root_updates.first.preload_dependencies) - assert_equal({ 'something_else' => {} }, root_updates.first.updated_associations) + root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Model', 'something_else' => nil }]) + assert_equal(DeepPreloader::Spec.new('child' => DeepPreloader::Spec.new), root_updates.first.preload_dependencies) end def test_renamed_roundtrip - alter_by_view!(ParentView, @parent) do |view, refs| - assert_equal({ 'id' => @parent.target.id, - '_type' => 'Target', + alter_by_view!(ModelView, @model) do |view, refs| + assert_equal({ 'id' => @model.child.id, + '_type' => 'Child', '_version' => 1, - 'text' => 'target text' }, + 'name' => 'child name' }, view['something_else']) - view['something_else']['text'] = 'target new text' + view['something_else']['name'] = 'child new name' end - assert_equal('target new text', @parent.target.text) + assert_equal('child new name', @model.child.name) end end diff --git a/test/unit/view_model/active_record/optional_attribute_view_test.rb b/test/unit/view_model/active_record/optional_attribute_view_test.rb deleted file mode 100644 index fd4e3bd3..00000000 --- a/test/unit/view_model/active_record/optional_attribute_view_test.rb +++ /dev/null @@ -1,58 +0,0 @@ -require_relative "../../../helpers/arvm_test_utilities.rb" -require_relative "../../../helpers/arvm_test_models.rb" - -require "minitest/autorun" - -require "view_model/active_record" - -class ViewModel::ActiveRecord::AttributeViewTest < ActiveSupport::TestCase - include ARVMTestUtilities - - def before_all - super - - build_viewmodel(:Thing) do - define_schema do |t| - t.integer :a - t.integer :b - end - - define_model do - end - - define_viewmodel do - attribute :a - attribute :b, optional: true - end - end - end - - def setup - super - @thing = Thing.create!(a: 1, b: 2) - - @skel = { "_type" => "Thing", - "_version" => 1, - "id" => @thing.id } - end - - def test_optional_not_serialized - view, _refs = serialize_with_references(ThingView.new(@thing)) - - assert_equal(@skel.merge("a" => 1), view) - end - - def test_optional_included - view, _refs = serialize_with_references(ThingView.new(@thing), - serialize_context: ThingView.new_serialize_context(include: :b)) - - assert_equal(@skel.merge("a" => 1, "b" => 2), view) - end - - def test_pruned_not_included - view, _refs = serialize_with_references(ThingView.new(@thing), - serialize_context: ThingView.new_serialize_context(include: :b, prune: :a)) - - assert_equal(@skel.merge("b" => 2), view) - end -end diff --git a/test/unit/view_model/active_record/poly_test.rb b/test/unit/view_model/active_record/poly_test.rb index 83bdea2f..9cb6352a 100644 --- a/test/unit/view_model/active_record/poly_test.rb +++ b/test/unit/view_model/active_record/poly_test.rb @@ -299,7 +299,6 @@ def setup def test_dependencies root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Parent', 'something_else' => nil }]) assert_equal(DeepPreloader::Spec.new('poly' => DeepPreloader::PolymorphicSpec.new), root_updates.first.preload_dependencies) - assert_equal({ 'something_else' => {} }, root_updates.first.updated_associations) end def test_renamed_roundtrip diff --git a/test/unit/view_model/active_record/shared_test.rb b/test/unit/view_model/active_record/shared_test.rb index 324ac0c1..4a5f134d 100644 --- a/test/unit/view_model/active_record/shared_test.rb +++ b/test/unit/view_model/active_record/shared_test.rb @@ -22,6 +22,7 @@ def before_all end define_viewmodel do + root! attributes :name end end @@ -43,8 +44,9 @@ def before_all end define_viewmodel do + root! attributes :name - association :category, shared: true, optional: true + association :category end end end @@ -66,16 +68,11 @@ def setup enable_logging! end - def serialize_context - ParentView.new_serialize_context(include: :category) - end - def test_loading_batching Parent.create(category: Category.new) log_queries do - serialize(ParentView.load(serialize_context: serialize_context), - serialize_context: serialize_context) + serialize(ParentView.load()) end assert_equal(['Parent Load', 'Category Load'], logged_load_queries) @@ -104,7 +101,7 @@ def test_create_from_view end def test_serialize_view - view, refs = serialize_with_references(ParentView.new(@parent1), serialize_context: serialize_context) + view, refs = serialize_with_references(ParentView.new(@parent1)) cat1_ref = refs.detect { |_, v| v['_type'] == 'Category' }.first assert_equal({cat1_ref => { '_type' => "Category", @@ -122,20 +119,14 @@ def test_serialize_view end def test_shared_eager_include - parent_includes = ParentView.eager_includes - - assert_equal(DeepPreloader::Spec.new, parent_includes) - - extra_includes = ParentView.eager_includes(serialize_context: ParentView.new_serialize_context(include: :category)) - - assert_equal(DeepPreloader::Spec.new('category' => DeepPreloader::Spec.new), extra_includes) + includes = ParentView.eager_includes + assert_equal(DeepPreloader::Spec.new('category' => DeepPreloader::Spec.new), includes) end def test_shared_serialize_interning @parent2.update(category: @parent1.category) view, refs = serialize_with_references([ParentView.new(@parent1), - ParentView.new(@parent2)], - serialize_context: ParentView.new_serialize_context(include: :category)) + ParentView.new(@parent2)]) category_ref = view.first['category']['_ref'] @@ -144,7 +135,7 @@ def test_shared_serialize_interning end def test_shared_add_reference - alter_by_view!(ParentView, @parent2, serialize_context: serialize_context) do |p2view, refs| + alter_by_view!(ParentView, @parent2) do |p2view, refs| p2view['category'] = { '_ref' => 'myref' } refs['myref'] = update_hash_for(CategoryView, @category1) end @@ -153,7 +144,7 @@ def test_shared_add_reference end def test_shared_add_multiple_references - alter_by_view!(ParentView, [@parent1, @parent2], serialize_context: serialize_context) do |(p1view, p2view), refs| + alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1view, p2view), refs| refs.delete(p1view['category']['_ref']) refs['myref'] = update_hash_for(CategoryView, @category1) @@ -167,7 +158,7 @@ def test_shared_add_multiple_references def test_shared_requires_all_references ex = assert_raises(ViewModel::DeserializationError::InvalidStructure) do - alter_by_view!(ParentView, @parent2, serialize_context: serialize_context) do |p2view, refs| + alter_by_view!(ParentView, @parent2) do |p2view, refs| refs['spurious_ref'] = { '_type' => 'Parent', 'id' => @parent1.id } end end @@ -176,8 +167,7 @@ def test_shared_requires_all_references def test_shared_requires_valid_references assert_raises(ViewModel::DeserializationError::InvalidSharedReference) do - serialize_context = ParentView.new_serialize_context(include: :category) - alter_by_view!(ParentView, @parent1, serialize_context: serialize_context) do |p1view, refs| + alter_by_view!(ParentView, @parent1) do |p1view, refs| refs.clear # remove the expected serialized refs end end @@ -185,8 +175,7 @@ def test_shared_requires_valid_references def test_shared_requires_assignable_type ex = assert_raises(ViewModel::DeserializationError::InvalidAssociationType) do - serialize_context = ParentView.new_serialize_context(include: :category) - alter_by_view!(ParentView, @parent1, serialize_context: serialize_context) do |p1view, refs| + alter_by_view!(ParentView, @parent1) do |p1view, refs| p1view['category'] = { '_ref' => 'p2' } refs['p2'] = update_hash_for(ParentView, @parent2) end @@ -195,10 +184,9 @@ def test_shared_requires_assignable_type end def test_shared_requires_unique_references - serialize_context = ParentView.new_serialize_context(include: :category) c1_ref = update_hash_for(CategoryView, @category1) assert_raises(ViewModel::DeserializationError::DuplicateNodes) do - alter_by_view!(ParentView, [@parent1, @parent2], serialize_context: serialize_context) do |(p1view, p2view), refs| + alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1view, p2view), refs| refs['c_a'] = c1_ref.dup refs['c_b'] = c1_ref.dup p1view['category'] = { '_ref' => 'c_a' } @@ -208,8 +196,7 @@ def test_shared_requires_unique_references end def test_shared_updates_shared_data - serialize_context = ParentView.new_serialize_context(include: :category) - alter_by_view!(ParentView, @parent1, serialize_context: serialize_context) do |p1view, refs| + alter_by_view!(ParentView, @parent1) do |p1view, refs| category_ref = p1view['category']['_ref'] refs[category_ref]['name'] = 'newcatname' end @@ -217,8 +204,7 @@ def test_shared_updates_shared_data end def test_shared_delete_reference - serialize_context = ParentView.new_serialize_context(include: :category) - alter_by_view!(ParentView, @parent1, serialize_context: serialize_context) do |p1view, refs| + alter_by_view!(ParentView, @parent1) do |p1view, refs| category_ref = p1view['category']['_ref'] refs.delete(category_ref) p1view['category'] = nil @@ -228,10 +214,9 @@ def test_shared_delete_reference end def test_child_edit_doesnt_editcheck_parent - serialize_context = ParentView.new_serialize_context(include: :category) d_context = ParentView.new_deserialize_context - alter_by_view!(ParentView, @parent1, serialize_context: serialize_context, deserialize_context: d_context) do |view, refs| + alter_by_view!(ParentView, @parent1, deserialize_context: d_context) do |view, refs| refs[view['category']["_ref"]]["name"] = "changed" end @@ -240,9 +225,7 @@ def test_child_edit_doesnt_editcheck_parent end def test_child_change_editchecks_parent - s_context = ParentView.new_serialize_context(include: :category) - - nv, d_context = alter_by_view!(ParentView, @parent1, serialize_context: s_context) do |view, refs| + nv, d_context = alter_by_view!(ParentView, @parent1) do |view, refs| refs.delete(view['category']['_ref']) view['category']['_ref'] = 'new_cat' refs['new_cat'] = { '_type' => 'Category', 'name' => 'new category' } @@ -253,10 +236,9 @@ def test_child_change_editchecks_parent end def test_child_delete_editchecks_parent - serialize_context = ParentView.new_serialize_context(include: :category) d_context = ParentView.new_deserialize_context - alter_by_view!(ParentView, @parent1, serialize_context: serialize_context, deserialize_context: d_context) do |view, refs| + alter_by_view!(ParentView, @parent1, deserialize_context: d_context) do |view, refs| refs.delete(view['category']['_ref']) view['category'] = nil end @@ -268,7 +250,7 @@ def test_dependent_viewmodels deps = ParentView.dependent_viewmodels assert_equal([ParentView, CategoryView].to_set, deps) - deps = ParentView.dependent_viewmodels(include_shared: false) + deps = ParentView.dependent_viewmodels(include_referenced: false) assert_equal([ParentView].to_set, deps) end @@ -278,7 +260,7 @@ def test_deep_schema_version CategoryView.view_name => CategoryView.schema_version }, vers) - vers = ParentView.deep_schema_version(include_shared: false) + vers = ParentView.deep_schema_version(include_referenced: false) assert_equal({ ParentView.view_name => ParentView.schema_version }, vers) end diff --git a/test/unit/view_model/active_record/version_test.rb b/test/unit/view_model/active_record/version_test.rb index 588df0ab..3b04176e 100644 --- a/test/unit/view_model/active_record/version_test.rb +++ b/test/unit/view_model/active_record/version_test.rb @@ -21,13 +21,13 @@ def before_all end end - build_viewmodel(:Target) do define_schema {} define_model do has_one :parent, inverse_of: :target end define_viewmodel do + root! self.schema_version = 20 end end @@ -44,8 +44,9 @@ def before_all end define_viewmodel do self.schema_version = 5 + root! association :child, viewmodels: [:ChildA] - association :target, shared: true, optional: false + association :target end end end diff --git a/test/unit/view_model/active_record_test.rb b/test/unit/view_model/active_record_test.rb index b10a4846..099cc8c5 100644 --- a/test/unit/view_model/active_record_test.rb +++ b/test/unit/view_model/active_record_test.rb @@ -17,7 +17,7 @@ def before_all build_viewmodel(:Trivial) do define_schema define_model {} - define_viewmodel {} + define_viewmodel { root! } end build_viewmodel(:Parent) do @@ -35,6 +35,7 @@ def before_all end define_viewmodel do + root! attributes :name, :lock_version attribute :one, read_only: true end @@ -376,66 +377,6 @@ def test_create end end - # Tests for functionality common to all ARVM instances, but require some kind - # of relationship. - class RelationshipTests < ActiveSupport::TestCase - include ARVMTestUtilities - - def before_all - super - - build_viewmodel(:Parent) do - define_schema do |t| - t.string :name - end - - define_model do - has_many :children, dependent: :destroy, inverse_of: :parent - end - - define_viewmodel do - attributes :name - associations :children - end - end - - build_viewmodel(:Child) do - define_schema do |t| - t.references :parent, null: false, foreign_key: true - t.string :name - end - - define_model do - belongs_to :parent, inverse_of: :children - end - - define_viewmodel do - attributes :name - end - end - end - - def test_updated_associations_returned - # This test ensures the data is passed back through the context. The tests - # for the values are in the relationship-specific tests. - - updated_by_view = ->(view) do - context = ViewModelBase.new_deserialize_context - ParentView.deserialize_from_view(view, deserialize_context: context) - context.updated_associations - end - - assert_equal({}, - updated_by_view.({ '_type' => 'Parent', - 'name' => 'p' })) - - assert_equal({ 'children' => {} }, - updated_by_view.({ '_type' => 'Parent', - 'name' => 'p', - 'children' => [] })) - end - end - # Parent view should be correctly passed down the tree when deserializing class DeserializationParentContextTest < ActiveSupport::TestCase include ARVMTestUtilities @@ -499,7 +440,7 @@ def test_deserialize_context end end - # Parent view should be correctly passed down the tree when deserializing + # Parent view should be correctly passed down the tree when deserializing class DeferredConstraintTest < ActiveSupport::TestCase include ARVMTestUtilities @@ -516,7 +457,8 @@ def before_all end define_viewmodel do - association :child, shared: true + root! + association :child end end List.connection.execute("ALTER TABLE lists ADD CONSTRAINT unique_child UNIQUE (child_id) DEFERRABLE INITIALLY DEFERRED") diff --git a/test/unit/view_model/callbacks_test.rb b/test/unit/view_model/callbacks_test.rb index 18e9d001..aae3a68b 100644 --- a/test/unit/view_model/callbacks_test.rb +++ b/test/unit/view_model/callbacks_test.rb @@ -348,6 +348,7 @@ def new_model alter_by_view!(viewmodel_class, vm.model) do |view, _refs| view['next'] = nil end + value(callback.hook_trace).must_equal( [ visit(ViewModel::Callbacks::Hook::BeforeVisit, vm), diff --git a/test/unit/view_model/record_test.rb b/test/unit/view_model/record_test.rb index 967406fe..cdec9c32 100644 --- a/test/unit/view_model/record_test.rb +++ b/test/unit/view_model/record_test.rb @@ -204,12 +204,6 @@ def self.included(base) assert_equal("unknown", ex.attribute) end - it "can prune an attribute" do - h = viewmodel_class.new(default_model).to_hash(serialize_context: TestSerializeContext.new(prune: [:simple])) - pruned_view = default_view.tap { |v| v.delete("simple") } - assert_equal(pruned_view, h) - end - it "edit checks when creating empty" do vm = viewmodel_class.deserialize_from_view(view_base, deserialize_context: create_context) refute(default_model.equal?(vm.model), "returned model was the same") @@ -377,24 +371,6 @@ def deserialize_overridden(value, references:, deserialize_context:) end end - describe "with optional attributes" do - let(:attributes) { { optional: { optional: true } } } - - include CanDeserializeToNew - include CanDeserializeToExisting - - it "can serialize with the optional attribute" do - h = viewmodel_class.new(default_model).to_hash(serialize_context: TestSerializeContext.new(include: [:optional])) - assert_equal(default_view, h) - end - - it "can serialize without the optional attribute" do - h = viewmodel_class.new(default_model).to_hash - pruned_view = default_view.tap { |v| v.delete("optional") } - assert_equal(pruned_view, h) - end - end - Nested = Struct.new(:member) class NestedView < TestViewModel @@ -448,14 +424,6 @@ class NestedView < TestViewModel assert_edited(vm, new: false, changed_attributes: [:nested]) assert_edited(vm.nested, new: true, changed_attributes: [:member]) end - - it "can prune attributes in the nested value" do - h = viewmodel_class.new(default_model).to_hash( - serialize_context: TestSerializeContext.new(prune: { nested: [:member] })) - - pruned_view = default_view.tap { |v| v["nested"].delete("member") } - assert_equal(pruned_view, h) - end end describe "with array of nested viewmodel" do diff --git a/test/unit/view_model/traversal_context_test.rb b/test/unit/view_model/traversal_context_test.rb index a4dc926c..4997bbc6 100644 --- a/test/unit/view_model/traversal_context_test.rb +++ b/test/unit/view_model/traversal_context_test.rb @@ -35,6 +35,7 @@ def initialize after_visit do ref = view.to_reference raise RuntimeError.new('Visited twice') if details.has_key?(ref) + details[ref] = ContextDetail.new( context.parent_viewmodel&.to_reference, context.parent_association, @@ -87,12 +88,12 @@ def assert_traversal_matches(expected_details, recorded_details) # models have no more than one shared association, and if present it is the # one under test. def clear_subject_association(view, refs) - refs.clear if subject_association.shared? + refs.clear if subject_association.referenced? view[subject_association_name] = subject_association.collection? ? [] : nil end def set_subject_association(view, refs, value) - if subject_association.shared? + if subject_association.referenced? refs.clear value = convert_to_refs(refs, value) end @@ -100,7 +101,7 @@ def set_subject_association(view, refs, value) end def add_to_subject_association(view, refs, value) - if subject_association.shared? + if subject_association.referenced? value = convert_to_refs(refs, value) end view[subject_association_name] << value @@ -108,12 +109,12 @@ def add_to_subject_association(view, refs, value) def remove_from_subject_association(view, refs) view[subject_association_name].reject! do |child| - if subject_association.shared? + if subject_association.referenced? ref = child[ViewModel::REFERENCE_ATTRIBUTE] child = refs[ref] end match = yield(child) - if match && subject_association.shared? + if match && subject_association.referenced? refs.delete(ref) end match @@ -167,7 +168,7 @@ def self.included(base) end expected = expected_parent_details - expected = expected.merge(expected_children_details) unless subject_association.shared? + expected = expected.merge(expected_children_details) unless subject_association.referenced? assert_traversal_matches(expected, context_recorder.details) end @@ -179,7 +180,7 @@ def self.included(base) end expected = expected_parent_details - expected = expected.merge(expected_children_details) unless subject_association.shared? + expected = expected.merge(expected_children_details) unless subject_association.referenced? expected = expected.merge(new_child_expected_details) assert_traversal_matches(expected, context_recorder.details) end @@ -190,7 +191,7 @@ def self.included(base) vm.replace_associated(subject_association_name, replacement, deserialize_context: ctx) expected = expected_parent_details - expected = expected.merge(expected_children_details) unless subject_association.shared? + expected = expected.merge(expected_children_details) unless subject_association.referenced? expected = expected.merge(new_child_expected_details) assert_traversal_matches(expected, context_recorder.details) end @@ -224,7 +225,7 @@ def self.included(base) end expected = expected_parent_details.merge(expected_children_details) - expected = expected.except(removed_child.to_reference) if subject_association.shared? + expected = expected.except(removed_child.to_reference) if subject_association.referenced? assert_traversal_matches(expected, context_recorder.details) end @@ -241,7 +242,7 @@ def self.included(base) vm.delete_associated(subject_association_name, removed_child.id, deserialize_context: ctx) expected = expected_parent_details - expected = expected.merge(removed_child_expected_details) unless subject_association.shared? + expected = expected.merge(removed_child_expected_details) unless subject_association.referenced? assert_traversal_matches(expected, context_recorder.details) end end @@ -265,7 +266,7 @@ def self.included(base) let(:root_detail) { ContextDetail.new(nil, nil, true) } let(:child_detail) do - if subject_association.shared? + if subject_association.referenced? root_detail else ContextDetail.new(vm.to_reference, subject_association_name, false) @@ -346,7 +347,7 @@ def self.included(base) end describe 'with parent and shared child' do - include ViewModelSpecHelpers::ParentAndSharedChild + include ViewModelSpecHelpers::ParentAndSharedBelongsToChild include BehavesLikeSerialization include BehavesLikeDeserialization