Skip to content

Commit

Permalink
iKnow ViewModels 3.0.0
Browse files Browse the repository at this point in the history
Removes support for explicit shared on associations. ViewModels are now
explicitly declared as either roots or nested children.

Removes support for `optional` associations and dynamic prune/include in
seralization. Associations may now be `external`. External associations are
never (de)serialized as part of the parent, and may only be manipulated
externally with `AssociationManipulation`.

Fixes #100
  • Loading branch information
chrisandreae committed Jul 8, 2019
1 parent 2f16dac commit c270380
Show file tree
Hide file tree
Showing 42 changed files with 2,046 additions and 1,544 deletions.
2 changes: 1 addition & 1 deletion iknow_view_models.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion lib/iknow_view_models/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module IknowViewModels
VERSION = '2.8.9'
VERSION = '3.0.0'
end
25 changes: 17 additions & 8 deletions lib/view_model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
168 changes: 94 additions & 74 deletions lib/view_model/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -213,49 +201,49 @@ 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
end
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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!
Expand Down Expand Up @@ -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

Expand Down
Loading

0 comments on commit c270380

Please sign in to comment.