class ViewModel::ActiveRecord

Assembles an update operation tree from user input. Handles the interlinking and model of update operations, but does not handle the actual user data nor the mechanism by which it is applied to models.

Partially parsed tree of user-specified update hashes, created during deserialization.

Constants

ACTIONS_ATTRIBUTE
AFTER_ATTRIBUTE
BEFORE_ATTRIBUTE
FUNCTIONAL_UPDATE_TYPE

for functional updates

VALUES_ATTRIBUTE

Attributes

_list_attribute_name[R]
changed_associations[R]

Public Class Methods

_association_data(association_name) click to toggle source

internal

# File lib/view_model/active_record.rb, line 250
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
_list_member?() click to toggle source
# File lib/view_model/active_record.rb, line 57
def _list_member?
  _list_attribute_name.present?
end
acts_as_list(attr = :position) click to toggle source

Specifies that the model backing this viewmodel is a member of an `acts_as_manual_list` collection.

# File lib/view_model/active_record.rb, line 43
def acts_as_list(attr = :position)
  @_list_attribute_name = attr

  @generated_accessor_module.module_eval do
    define_method('_list_attribute') do
      model.public_send(attr)
    end

    define_method('_list_attribute=') do |x|
      model.public_send(:"#{attr}=", x)
    end
  end
end
association(association_name, as: nil, viewmodel: nil, viewmodels: nil, external: false, read_only: false, through: nil, through_order_attr: nil) click to toggle source

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).

# File lib/view_model/active_record.rb, line 83
def association(association_name,
                as: nil,
                viewmodel: nil,
                viewmodels: nil,
                external: false,
                read_only: false,
                through: nil,
                through_order_attr: nil)

  vm_association_name = (as || association_name).to_s

  if through
    direct_association_name   = through
    indirect_association_name = association_name
  else
    direct_association_name   = association_name
    indirect_association_name = nil
  end

  target_viewmodels = Array.wrap(viewmodel || viewmodels)

  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

  @generated_accessor_module.module_eval do
    define_method vm_association_name do
      _read_association(vm_association_name)
    end

    define_method :"serialize_#{vm_association_name}" do |json, serialize_context: self.class.new_serialize_context|
      _serialize_association(vm_association_name, json, serialize_context: serialize_context)
    end
  end
end
associations(*assocs, **args) click to toggle source

Specify multiple associations at once

# File lib/view_model/active_record.rb, line 128
def associations(*assocs, **args)
  assocs.each { |assoc| association(assoc, **args) }
end
cacheable!(**opts) click to toggle source
# File lib/view_model/active_record.rb, line 244
def cacheable!(**opts)
  include ViewModel::ActiveRecord::Cache::CacheableView
  create_viewmodel_cache!(**opts)
end
deep_schema_version(include_referenced: true, include_external: true) click to toggle source
# File lib/view_model/active_record.rb, line 236
def deep_schema_version(include_referenced: true, include_external: true)
  (@deep_schema_version ||= {})[[include_referenced, include_external]] ||=
    begin
      vms = dependent_viewmodels(include_referenced: include_referenced, include_external: include_external)
      ViewModel.schema_versions(vms).freeze
    end
end
dependent_viewmodels(seen = Set.new, include_referenced: true, include_external: true) click to toggle source
# File lib/view_model/active_record.rb, line 218
def dependent_viewmodels(seen = Set.new, include_referenced: true, include_external: true)
  return if seen.include?(self)

  seen << self

  _members.each_value do |data|
    next unless data.is_a?(AssociationData)
    next unless include_referenced || !data.referenced?
    next unless include_external   || !data.external?

    data.viewmodel_classes.each do |vm|
      vm.dependent_viewmodels(seen, include_referenced: include_referenced, include_external: include_external)
    end
  end

  seen
end
deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_context: new_deserialize_context) click to toggle source
# File lib/view_model/active_record.rb, line 166
def deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_context: new_deserialize_context)
  model_class.transaction do
    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)

      _updated_viewmodels =
        UpdateContext
          .build!(root_update_data, referenced_update_data, root_type: self)
          .run!(deserialize_context: deserialize_context)
    end
  end
end
eager_includes(include_referenced: true, vm_path: []) click to toggle source

Constructs a preload specification of the required models for serializing/deserializing this view. Cycles in the schema will be broken after two layers of eager loading.

# File lib/view_model/active_record.rb, line 182
def eager_includes(include_referenced: true, vm_path: [])
  association_specs = {}

  return nil if vm_path.count(self) > 2

  child_path = vm_path + [self]
  _members.each do |assoc_name, association_data|
    next unless association_data.is_a?(AssociationData)
    next if association_data.external?

    case
    when association_data.through?
      viewmodel = association_data.direct_viewmodel
      children = viewmodel.eager_includes(include_referenced: include_referenced, vm_path: child_path)

    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(include_referenced: include_referenced, vm_path: child_path)
      end
      children = DeepPreloader::PolymorphicSpec.new(children_by_klass)

    else
      viewmodel = association_data.viewmodel_class
      children = viewmodel.eager_includes(include_referenced: include_referenced, vm_path: child_path)
    end

    association_specs[association_data.direct_reflection.name.to_s] = children
  end
  DeepPreloader::Spec.new(association_specs)
end
find(id_or_ids, scope: nil, lock: nil, eager_include: true) click to toggle source

Load instances of the viewmodel by id(s)

# File lib/view_model/active_record.rb, line 133
def find(id_or_ids, scope: nil, lock: nil, eager_include: true)
  find_scope = self.model_class.all
  find_scope = find_scope.order(:id).lock(lock) if lock
  find_scope = find_scope.merge(scope) if scope

  ViewModel::Utils.wrap_one_or_many(id_or_ids) do |ids|
    models = find_scope.where(id: ids).to_a

    if models.size < ids.size
      missing_ids = ids - models.map(&:id)
      if missing_ids.present?
        raise ViewModel::DeserializationError::NotFound.new(
                missing_ids.map { |id| ViewModel::Reference.new(self, id) })
      end
    end

    vms = models.map { |m| self.new(m) }
    ViewModel.preload_for_serialization(vms, lock: lock) if eager_include
    vms
  end
end
load(scope: nil, eager_include: true, lock: nil) click to toggle source

Load instances of the viewmodel by scope TODO: is this too much of a encapsulation violation?

# File lib/view_model/active_record.rb, line 157
def load(scope: nil, eager_include: true, lock: nil)
  load_scope = self.model_class.all
  load_scope = load_scope.lock(lock) if lock
  load_scope = load_scope.merge(scope) if scope
  vms = load_scope.map { |model| self.new(model) }
  ViewModel.preload_for_serialization(vms, lock: lock) if eager_include
  vms
end
model_previously_new?(model) click to toggle source

Rails 6.1 introduced “previously_new_record?”, but this library still supports activerecord >= 5.0. This is an approximation.

# File lib/view_model/active_record.rb, line 368
def self.model_previously_new?(model)
  if (id_changes = model.saved_change_to_id)
    old_id, _new_id = id_changes
    return true if old_id.nil?
  end
  false
end
new(*) click to toggle source
Calls superclass method ViewModel::Record::new
# File lib/view_model/active_record.rb, line 258
def initialize(*)
  super
  model_is_new! if model.new_record?
  @changed_associations = []
end

Public Instance Methods

_read_association(association_name) click to toggle source
# File lib/view_model/active_record.rb, line 326
def _read_association(association_name)
  association_data = self.class._association_data(association_name)

  associated = model.public_send(association_data.direct_reflection.name)
  return nil if associated.nil?

  case
  when association_data.through?
    # associated here are join-table models; we need to get the far side out
    join_models = associated

    if association_data.ordered?
      attr = association_data.direct_viewmodel._list_attribute_name
      join_models = join_models.sort_by { |j| j[attr] }
    end

    join_models.map do |through_model|
      model = through_model.public_send(association_data.indirect_reflection.name)
      association_data.viewmodel_class_for_model!(model.class).new(model)
    end

  when association_data.collection?
    associated_viewmodels = associated.map do |x|
      associated_viewmodel_class = association_data.viewmodel_class_for_model!(x.class)
      associated_viewmodel_class.new(x)
    end

    # If any associated type is a list member, they must all be
    if association_data.ordered?
      associated_viewmodels.sort_by!(&:_list_attribute)
    end

    associated_viewmodels

  else
    associated_viewmodel_class = association_data.viewmodel_class_for_model!(associated.class)
    associated_viewmodel_class.new(associated)
  end
end
_read_association_touched(association_name, touched_ids:) click to toggle source

Helper to return entities that were part of the last deserialization. The interface is complex due to the data requirements, and the implementation is inefficient.

Intended to be used by replace_associated style methods which may touch very large collections that must not be returned fully. Since the collection is not being returned, order is also ignored.

# File lib/view_model/active_record.rb, line 383
def _read_association_touched(association_name, touched_ids:)
  association_data = self.class._association_data(association_name)

  associated = model.public_send(association_data.direct_reflection.name)
  return nil if associated.nil?

  case
  when association_data.through?
    # associated here are join-table models; we need to get the far side out
    associated.map do |through_model|
      model = through_model.public_send(association_data.indirect_reflection.name)

      next unless self.class.model_previously_new?(through_model) || touched_ids.include?(model.id)

      association_data.viewmodel_class_for_model!(model.class).new(model)
    end.reject(&:nil?)
  when association_data.collection?
    associated.map do |model|
      next unless self.class.model_previously_new?(model) || touched_ids.include?(model.id)

      association_data.viewmodel_class_for_model!(model.class).new(model)
    end.reject(&:nil?)
  else
    # singleton always touched by definition
    model = associated
    association_data.viewmodel_class_for_model!(model.class).new(model)
  end
end
_serialize_association(association_name, json, serialize_context:) click to toggle source
# File lib/view_model/active_record.rb, line 412
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
association_changed!(association_name) click to toggle source
# File lib/view_model/active_record.rb, line 291
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
end
associations_changed?() click to toggle source
# File lib/view_model/active_record.rb, line 305
def associations_changed?
  @changed_associations.present?
end
changes() click to toggle source

Additionally pass `changed_associations` while constructing changes.

# File lib/view_model/active_record.rb, line 310
def changes
  ViewModel::Changes.new(
    new:                         new_model?,
    changed_attributes:          changed_attributes,
    changed_associations:        changed_associations,
    changed_nested_children:     changed_nested_children?,
    changed_referenced_children: changed_referenced_children?,
  )
end
clear_changes!() click to toggle source
Calls superclass method ViewModel::Record#clear_changes!
# File lib/view_model/active_record.rb, line 320
def clear_changes!
  super.tap do
    @changed_associations = []
  end
end
context_for_child(member_name, context:) click to toggle source
Calls superclass method ViewModel#context_for_child
# File lib/view_model/active_record.rb, line 434
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

  # associations to roots start a new tree
  member_data = self.class._members[member_name.to_s]
  if member_data.association? && member_data.referenced?
    return context.for_references
  end

  super
end
destroy!(deserialize_context: self.class.new_deserialize_context) click to toggle source
# File lib/view_model/active_record.rb, line 280
def destroy!(deserialize_context: self.class.new_deserialize_context)
  model_class.transaction do
    ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
      changes = ViewModel::Changes.new(deleted: true)
      deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: changes)
      hook_control.record_changes(changes)
      model.destroy!
    end
  end
end
serialize_members(json, serialize_context: self.class.new_serialize_context) click to toggle source
# File lib/view_model/active_record.rb, line 264
def serialize_members(json, serialize_context: self.class.new_serialize_context)
  self.class._members.each do |member_name, member_data|
    next if member_data.association? && member_data.external?

    member_context =
      case member_data
      when AssociationData
        self.context_for_child(member_name, context: serialize_context)
      else
        serialize_context
      end

    self.public_send("serialize_#{member_name}", json, serialize_context: member_context)
  end
end