class ViewModel::ActiveRecord
def self.model_previously_new?(model)
Rails 6.1 introduced "previously_new_record?", but this library still
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
def _association_data(association_name)
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
def _list_member?
def _list_member? _list_attribute_name.present? end
def _read_association(association_name)
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
def _read_association_touched(association_name, touched_ids:)
large collections that must not be returned fully. Since the collection is
Intended to be used by replace_associated style methods which may touch very
inefficient.
interface is complex due to the data requirements, and the implementation is
Helper to return entities that were part of the last deserialization. The
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
def _serialize_association(association_name, json, serialize_context:)
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 acts_as_list(attr = :position)
Specifies that the model backing this viewmodel is a member of an
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
def association(association_name,
- +through_order_attr+ the through model is ordered by the given attribute
ActiveRecord +has_many:through:+.
- +through+ names an ActiveRecord association that will be used like an
External associations may only be made to root viewmodels.
must be independently manipulated using `AssociationManipulation`.
associations are not included in (de)serializations of the parent, and
- +external+ indicates an association external to the view. Externalized
association
- +viewmodel+, +viewmodels+ specifies the viewmodel(s) to use for the
- +as+ sets the name of the association in the viewmodel
indirect reference, while a child viewmodel type will be directly nested.
An association to a root viewmodel type will be serialized with an
be inferred from the model name, or may be explicitly specified.
will be recursively (de)serialized by its own viewmodel type, which will
Adds an association from the model to this viewmodel. The associated model
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
def association_changed!(association_name)
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
def associations(*assocs, **args)
def associations(*assocs, **args) assocs.each { |assoc| association(assoc, **args) } end
def associations_changed?
def associations_changed? @changed_associations.present? end
def cacheable!(**opts)
def cacheable!(**opts) include ViewModel::ActiveRecord::Cache::CacheableView create_viewmodel_cache!(**opts) end
def changes
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
def clear_changes!
def clear_changes! super.tap do @changed_associations = [] end end
def context_for_child(member_name, context:)
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
def deep_schema_version(include_referenced: true, include_external: true)
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
def dependent_viewmodels(seen = Set.new, include_referenced: true, include_external: true)
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
def deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_context: new_deserialize_context)
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 rescue ViewModel::DeserializationError => e if (new_error = customize_deserialization_error(e)) raise new_error else raise end end
def destroy!(deserialize_context: self.class.new_deserialize_context)
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
def eager_includes(include_referenced: true, vm_path: [])
serializing/deserializing this view. Cycles in the schema will be broken
Constructs a preload specification of the required models for
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
def find(id_or_ids, scope: nil, lock: nil, eager_include: true)
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
def initialize(*)
def initialize(*) super model_is_new! if model.new_record? @changed_associations = [] end
def load(scope: nil, eager_include: true, lock: nil)
# Load instances of the viewmodel by scope
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
def serialize_members(json, serialize_context: self.class.new_serialize_context)
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