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
Public Class Methods
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
# File lib/view_model/active_record.rb, line 57 def _list_member? _list_attribute_name.present? end
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
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 anActiveRecord
association that will be used like anActiveRecord
has_many:through:
. -
through_order_attr
the through model is ordered by the given attribute (only applies to whenthrough
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
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
# File lib/view_model/active_record.rb, line 244 def cacheable!(**opts) include ViewModel::ActiveRecord::Cache::CacheableView create_viewmodel_cache!(**opts) end
# 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
# 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
# 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
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
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 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
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
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
# 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
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
# 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
# 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
# File lib/view_model/active_record.rb, line 305 def associations_changed? @changed_associations.present? end
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
ViewModel::Record#clear_changes!
# File lib/view_model/active_record.rb, line 320 def clear_changes! super.tap do @changed_associations = [] end end
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
# 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
# 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