class ViewModel::Record
real or calculated attributes.
A record viewmodel wraps a single underlying model, exposing a fixed set of
Abstract ViewModel type for serializing a subset of attributes from a record.
def ==(other)
def ==(other) self.class == other.class && self.model == other.model end
def _deserialize_attribute(attr_data, serialized_value, references:, deserialize_context:)
def _deserialize_attribute(attr_data, serialized_value, references:, deserialize_context:) vm_attr_name = attr_data.name if attr_data.array? && !serialized_value.nil? expect_type!(vm_attr_name, Array, serialized_value) end value = case when serialized_value.nil? serialized_value when attr_data.using_viewmodel? ctx = self.context_for_child(vm_attr_name, context: deserialize_context) attr_data.map_value(serialized_value) do |sv| attr_data.attribute_viewmodel.deserialize_from_view(sv, references: references, deserialize_context: ctx) end when attr_data.using_serializer? attr_data.map_value(serialized_value) do |sv| attr_data.attribute_serializer.load(sv) rescue IknowParams::Serializer::LoadError => ex reason = "could not be deserialized because #{ex.message}" raise ViewModel::DeserializationError::Validation.new(vm_attr_name, reason, {}, blame_reference) end else serialized_value end # Detect changes with ==. In the case of `using_viewmodel?`, this compares viewmodels or arrays of viewmodels. if value != self.public_send(vm_attr_name) if attr_data.read_only? && !(attr_data.write_once? && new_model?) raise ViewModel::DeserializationError::ReadOnlyAttribute.new(vm_attr_name, blame_reference) end attribute_changed!(vm_attr_name) model_value = if attr_data.using_viewmodel? && !value.nil? # Extract model from target viewmodel(s) to attach to our model attr_data.map_value(value) { |vm| vm.model } else value end model.public_send("#{attr_data.model_attr_name}=", model_value) elsif new_model? # Record attribute_changed for mutable values asserted on a new model, even where # they match the ActiveRecord default. attribute_changed!(vm_attr_name) unless attr_data.read_only? && !attr_data.write_once? end 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
def _get_attribute(attr_data)
def _get_attribute(attr_data) value = model.public_send(attr_data.model_attr_name) if attr_data.using_viewmodel? && !value.nil? # Where an attribute uses a viewmodel, the associated viewmodel type is # significant and may have behaviour: like with VM::ActiveRecord # associations it's useful to return the value wrapped in its viewmodel # type even when not serializing. value = attr_data.map_value(value) do |v| attr_data.attribute_viewmodel.new(v) end end value end
def _serialize_attribute(attr_data, json, serialize_context:)
def _serialize_attribute(attr_data, json, serialize_context:) vm_attr_name = attr_data.name value = self.public_send(vm_attr_name) if attr_data.using_serializer? && !value.nil? # Where an attribute uses a low level serializer (rather than another # viewmodel), it's only desired for converting the value to and from wire # format, so conversion is deferred to serialization time. value = attr_data.map_value(value) do |v| attr_data.attribute_serializer.dump(v, json: true) rescue IknowParams::Serializer::DumpError => ex raise ViewModel::SerializationError.new( "Could not serialize invalid value '#{vm_attr_name}': #{ex.message}") end end json.set! vm_attr_name do serialize_context = self.context_for_child(vm_attr_name, context: serialize_context) if attr_data.using_viewmodel? self.class.serialize(value, json, serialize_context: serialize_context) end end
def attribute(attr, as: nil, read_only: false, write_once: false, using: nil, format: nil, array: 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") 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(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 define_method vm_attribute_name do _get_attribute(attr_data) end define_method "serialize_#{vm_attribute_name}" do |json, serialize_context: self.class.new_serialize_context| _serialize_attribute(attr_data, json, serialize_context: serialize_context) end define_method "deserialize_#{vm_attribute_name}" do |value, references: {}, deserialize_context: self.class.new_deserialize_context| _deserialize_attribute(attr_data, value, references: references, deserialize_context: deserialize_context) end end end
def attribute_changed!(attr_name)
def attribute_changed!(attr_name) @changed_attributes << attr_name.to_s end
def changed_nested_children?
def changed_nested_children? @changed_nested_children end
def changed_referenced_children?
def changed_referenced_children? @changed_referenced_children end
def changes
def changes ViewModel::Changes.new( new: new_model?, changed_attributes: changed_attributes, changed_nested_children: changed_nested_children?, changed_referenced_children: changed_referenced_children?, ) end
def clear_changes!
def clear_changes! @previous_changes = changes @new_model = false @changed_attributes = [] @changed_nested_children = false @changed_referenced_children = false previous_changes end
def customize_deserialization_error(e)
#deserialize_from_view. If a value is returned, that exception is raised
errors in individual viewmodel types. This method is called on error from
Overriding this method allows matching and customization of deserialization
def customize_deserialization_error(e) nil end
def deserialize_from_view(view_hashes, references: {}, deserialize_context: new_deserialize_context)
def deserialize_from_view(view_hashes, references: {}, deserialize_context: new_deserialize_context) ViewModel::Utils.map_one_or_many(view_hashes) do |view_hash| view_hash = view_hash.dup metadata = ViewModel.extract_viewmodel_metadata(view_hash) unless self.view_name == metadata.view_name || self.view_aliases.include?(metadata.view_name) raise ViewModel::DeserializationError::InvalidViewType.new( self.view_name, ViewModel::Reference.new(self, metadata.id)) end if metadata.schema_version && !self.accepts_schema_version?(metadata.schema_version) raise ViewModel::DeserializationError::SchemaVersionMismatch.new( self, metadata.schema_version, ViewModel::Reference.new(self, metadata.id)) end viewmodel = resolve_viewmodel(metadata, view_hash, deserialize_context: deserialize_context) deserialize_members_from_view(viewmodel, view_hash, references: references, deserialize_context: deserialize_context) viewmodel end rescue ViewModel::DeserializationError => e if (new_error = customize_deserialization_error(e)) raise new_error else raise end end
def deserialize_members_from_view(viewmodel, view_hash, references:, deserialize_context:)
def deserialize_members_from_view(viewmodel, view_hash, references:, deserialize_context:) super do |hook_control| final_changes = viewmodel.clear_changes! if final_changes.changed? deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, viewmodel, changes: final_changes) end hook_control.record_changes(final_changes) end end
def expect_type!(attribute, type, serialized_value)
Helper for type-checking input in hand-rolled deserialization: raises
def expect_type!(attribute, type, serialized_value) unless serialized_value.is_a?(type) raise ViewModel::DeserializationError::InvalidAttributeType.new(attribute.to_s, type.name, serialized_value.class.name, blame_reference) end end
def for_new_model(*model_args)
def for_new_model(*model_args) self.new(model_class.new(*model_args)).tap { |v| v.model_is_new! } end
def hash
Use ActiveRecord style identity for viewmodels. This allows serialization to
def hash [self.class, self.model].hash end
def id
VM::Record identity matches the identity of its model. If the model has a
def id if stable_id? model.id else model.object_id end end
def inherited(subclass)
def inherited(subclass) super subclass.initialize_as_viewmodel_record ViewModel::Registry.register(subclass) end
def initialize(model)
def initialize(model) unless model.is_a?(model_class) raise ArgumentError.new("'#{model.inspect}' is not an instance of #{model_class.name}") end self.model = model @new_model = false @changed_attributes = [] @changed_nested_children = false @changed_referenced_children = false super() end
def initialize_as_viewmodel_record
def initialize_as_viewmodel_record @_members = {} @abstract_class = false @unregistered = false @generated_accessor_module = Module.new include @generated_accessor_module end
def member_names
def member_names self._members.keys end
def model_class
set via `model_class_name=`, attempt to automatically resolve based on the
Returns the AR model class wrapped by this viewmodel. If this has not been
def model_class unless instance_variable_defined?(:@model_class) # try to auto-detect the model class based on our name self.model_class_name = ViewModel::Registry.infer_model_class_name(self.view_name) end @model_class end
def model_class=(type)
def model_class=(type) if instance_variable_defined?(:@model_class) raise ArgumentError.new("Model class for ViewModel '#{self.name}' already set") end @model_class = type end
def model_class_name=(name)
def model_class_name=(name) name = name.to_s type = name.safe_constantize if type.nil? raise ArgumentError.new("Could not find model class with name '#{name}'") end self.model_class = type end
def model_is_new!
def model_is_new! @new_model = true end
def nested_children_changed!
def nested_children_changed! @changed_nested_children = true end
def new_model?
def new_model? @new_model end
def referenced_children_changed!
def referenced_children_changed! @changed_referenced_children = true end
def resolve_viewmodel(_metadata, _view_hash, deserialize_context:)
def resolve_viewmodel(_metadata, _view_hash, deserialize_context:) self.for_new_model end
def serialize_members(json, serialize_context:)
def serialize_members(json, serialize_context:) self.class._members.each do |member_name, _member_data| self.public_send("serialize_#{member_name}", json, serialize_context: serialize_context) end end
def serialize_view(json, serialize_context: self.class.new_serialize_context)
def serialize_view(json, serialize_context: self.class.new_serialize_context) json.set!(ViewModel::ID_ATTRIBUTE, self.id) if stable_id? json.set!(ViewModel::TYPE_ATTRIBUTE, self.view_name) json.set!(ViewModel::VERSION_ATTRIBUTE, self.class.schema_version) serialize_members(json, serialize_context: serialize_context) end
def should_register?
def should_register? !abstract_class && !unregistered && !synthetic end
def stable_id?
def stable_id? model.respond_to?(:id) end
def validate!
be overridden by subclasses for other types of validation. Must raise
AR validations. Default implementation handles ActiveModel::Validations, may
Check that the model backing this view is consistent, for example by calling
def validate! if model_class < ActiveModel::Validations && !model.valid? raise ViewModel::DeserializationError::Validation.from_active_model(model.errors, self.blame_reference) end end