class ViewModel
def ==(other)
def ==(other) other.class == self.class && self.class._attributes.all? do |attr| other.send(attr) == self.send(attr) end end
def accepts_schema_version?(schema_version)
def accepts_schema_version?(schema_version) schema_version == self.schema_version end
def add_view_alias(as)
def add_view_alias(as) view_aliases << as ViewModel::Registry.register(self, as: as) end
def attribute(attr, **_args)
def attribute(attr, **_args) unless attr.is_a?(Symbol) raise ArgumentError.new('ViewModel attributes must be symbols') end attr_accessor attr define_method("deserialize_#{attr}") do |value, references: {}, deserialize_context: self.class.new_deserialize_context| self.public_send("#{attr}=", value) end _attributes << attr end
def attributes(*attrs, **args)
bit easier to define them: attributes specified this way are given
ViewModels are typically going to be pretty simple structures. Make it a
def attributes(*attrs, **args) attrs.each { |attr| attribute(attr, **args) } end
def blame_reference
is reported as to blame. Can be overridden for example when a viewmodel is
When deserializing, if an error occurs within this viewmodel, what viewmodel
def blame_reference to_reference end
def context_for_child(member_name, context:)
def context_for_child(member_name, context:) context.for_child(self, association_name: member_name) end
def deserialize_context_class
def deserialize_context_class ViewModel::DeserializeContext end
def deserialize_from_view(hash_data, references: {}, deserialize_context: new_deserialize_context)
def deserialize_from_view(hash_data, references: {}, deserialize_context: new_deserialize_context) viewmodel = self.new deserialize_members_from_view(viewmodel, hash_data, references: references, deserialize_context: deserialize_context) viewmodel end
def deserialize_members_from_view(viewmodel, view_hash, references:, deserialize_context:)
def deserialize_members_from_view(viewmodel, view_hash, references:, deserialize_context:) ViewModel::Callbacks.wrap_deserialize(viewmodel, deserialize_context: deserialize_context) do |hook_control| if (bad_attrs = view_hash.keys - member_names).present? causes = bad_attrs.map do |bad_attr| ViewModel::DeserializationError::UnknownAttribute.new(bad_attr, viewmodel.blame_reference) end raise ViewModel::DeserializationError::Collection.for_errors(causes) end member_names.each do |attr| next unless view_hash.has_key?(attr) viewmodel.public_send("deserialize_#{attr}", view_hash[attr], references: references, deserialize_context: deserialize_context) end deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, viewmodel) viewmodel.validate! # More complex viewmodels can use this hook to track changes to # persistent backing models, and record the results. Primitive # viewmodels record no changes. if block_given? yield(hook_control) else hook_control.record_changes(Changes.new) end end end
def eager_includes(include_referenced: true)
use of? Returns a includes spec appropriate for DeepPreloader, either as
If this viewmodel represents an AR model, what associations does it make
def eager_includes(include_referenced: true) {} end
def encode_json(value)
def encode_json(value) # Jbuilder#encode no longer uses MultiJson, but instead calls `.to_json`. In # the context of ActiveSupport, we don't want this, because AS replaces the # .to_json interface with its own .as_json, which demands that everything is # reduced to a Hash before it can be JSON encoded. Using this is not only # slightly more expensive in terms of allocations, but also defeats the # purpose of our precompiled `CompiledJson` terminals. Instead serialize # using OJ with options equivalent to those used by MultiJson. Oj.dump(value, mode: :compat, time_format: :ruby, use_to_json: true) end
def extract_reference_metadata(hash)
def extract_reference_metadata(hash) ViewModel::Schemas.verify_schema!(ViewModel::Schemas::VIEWMODEL_REFERENCE, hash) hash.delete(ViewModel::REFERENCE_ATTRIBUTE) end
def extract_reference_only_metadata(hash)
def extract_reference_only_metadata(hash) ViewModel::Schemas.verify_schema!(ViewModel::Schemas::VIEWMODEL_UPDATE, hash) id = hash.delete(ViewModel::ID_ATTRIBUTE) type_name = hash.delete(ViewModel::TYPE_ATTRIBUTE) Metadata.new(id, type_name, nil, false, false) end
def extract_viewmodel_metadata(hash)
def extract_viewmodel_metadata(hash) ViewModel::Schemas.verify_schema!(ViewModel::Schemas::VIEWMODEL_UPDATE, hash) id = hash.delete(ViewModel::ID_ATTRIBUTE) type_name = hash.delete(ViewModel::TYPE_ATTRIBUTE) schema_version = hash.delete(ViewModel::VERSION_ATTRIBUTE) new = hash.delete(ViewModel::NEW_ATTRIBUTE) { false } migrated = hash.delete(ViewModel::MIGRATED_ATTRIBUTE) { false } Metadata.new(id, type_name, schema_version, new, migrated) end
def hash
def hash features = self.class._attributes.map { |attr| self.send(attr) } features << self.class features.hash end
def id
model with a concept of identity, this method should be overridden to use
so we fall back on the view's `object_id`. If a viewmodel is backed by a
default views cannot make assumptions about the identity of our attributes,
Provide a stable way to identify this view through attribute changes. By
def id object_id end
def inherited(subclass)
def inherited(subclass) super subclass.initialize_as_viewmodel end
def initialize(*args)
def initialize(*args) self.class._attributes.each_with_index do |attr, idx| self.public_send(:"#{attr}=", args[idx]) end end
def initialize_as_viewmodel
def initialize_as_viewmodel @_attributes = [] @schema_version = 1 @view_aliases = [] end
def is_update_hash?(hash) # rubocop:disable Naming/PredicateName
def is_update_hash?(hash) # rubocop:disable Naming/PredicateName ViewModel::Schemas.verify_schema!(ViewModel::Schemas::VIEWMODEL_UPDATE, hash) hash.has_key?(ViewModel::ID_ATTRIBUTE) && !hash.fetch(ViewModel::ActiveRecord::NEW_ATTRIBUTE, false) end
def lock_attribute_inheritance
subclasses. Redefine `_attributes` to close over the current class's
An abstract viewmodel may want to define attributes to be shared by their
def lock_attribute_inheritance _attributes.tap do |attrs| define_singleton_method(:_attributes) { attrs } attrs.freeze end end
def member_names
def member_names _attributes.map(&:to_s) end
def model
if necessary we assume that the wrapped model is the first attribute. To
ViewModels are often used to serialize ActiveRecord models. For convenience,
def model self.public_send(self.class._attributes.first) end
def new_deserialize_context(...)
def new_deserialize_context(...) deserialize_context_class.new(...) end
def new_serialize_context(...)
def new_serialize_context(...) serialize_context_class.new(...) end
def preload_for_serialization(viewmodels, include_referenced: true, lock: nil)
def preload_for_serialization(viewmodels, include_referenced: true, lock: nil) Array.wrap(viewmodels).group_by(&:class).each do |type, views| DeepPreloader.preload(views.map(&:model), type.eager_includes(include_referenced: include_referenced), lock: lock) end end
def preload_for_serialization(lock: nil)
def preload_for_serialization(lock: nil) ViewModel.preload_for_serialization([self], lock: lock) end
def root!
def root! define_singleton_method(:root?) { true } end
def root?
their parent. Associations to root viewmodel types always use indirect
(de)serialized directly, whereas child viewmodels are always nested within
ViewModels are either roots or children. Root viewmodels may be
def root? false end
def schema_hash(schema_versions)
def schema_hash(schema_versions) version_string = schema_versions.to_a.sort.join(',') # We want a short hash value, as this will be used in cache keys hash = Digest::SHA256.digest(version_string).byteslice(0, 16) Base64.urlsafe_encode64(hash, padding: false) end
def schema_versions(viewmodels)
def schema_versions(viewmodels) viewmodels.each_with_object({}) do |view, h| h[view.view_name] = view.schema_version end end
def serialize(target, json, serialize_context: new_serialize_context)
ViewModel can serialize ViewModels, Arrays and Hashes of ViewModels, and
def serialize(target, json, serialize_context: new_serialize_context) case target when ViewModel target.serialize(json, serialize_context: serialize_context) when Array json.array! target do |elt| serialize(elt, json, serialize_context: serialize_context) end when Hash, Struct json.merge!({}) target.each_pair do |key, value| json.set! key do serialize(value, json, serialize_context: serialize_context) end end else json.merge! target end end
def serialize(json, serialize_context: self.class.new_serialize_context)
Serialize this viewmodel to a jBuilder by calling serialize_view. May be
def serialize(json, serialize_context: self.class.new_serialize_context) ViewModel::Callbacks.wrap_serialize(self, context: serialize_context) do serialize_view(json, serialize_context: serialize_context) end end
def serialize_as_reference(target, json, serialize_context: new_serialize_context)
def serialize_as_reference(target, json, serialize_context: new_serialize_context) if serialize_context.flatten_references serialize(target, json, serialize_context: serialize_context) else ref = serialize_context.add_reference(target) json.set!(REFERENCE_ATTRIBUTE, ref) end end
def serialize_context_class
def serialize_context_class ViewModel::SerializeContext end
def serialize_from_cache(views, migration_versions: {}, locked: false, serialize_context:)
def serialize_from_cache(views, migration_versions: {}, locked: false, serialize_context:) plural = views.is_a?(Array) views = Array.wrap(views) json_views, json_refs = ViewModel::ActiveRecord::Cache.render_viewmodels_from_cache( views, locked: locked, migration_versions: migration_versions, serialize_context: serialize_context) json_views = json_views.first unless plural return json_views, json_refs end
def serialize_to_hash(viewmodel, serialize_context: new_serialize_context)
def serialize_to_hash(viewmodel, serialize_context: new_serialize_context) Jbuilder.new { |json| serialize(viewmodel, json, serialize_context: serialize_context) }.attributes! end
def serialize_view(json, serialize_context: self.class.new_serialize_context)
Render this viewmodel to a jBuilder. Usually overridden in subclasses.
def serialize_view(json, serialize_context: self.class.new_serialize_context) self.class._attributes.each do |attr| json.set! attr do ViewModel.serialize(self.send(attr), json, serialize_context: serialize_context) end end end
def stable_id?
whether the id is included when constructing a ViewModel::Reference from
Is this viewmodel backed by a model with a stable identity? Used to decide
def stable_id? false end
def to_hash(serialize_context: self.class.new_serialize_context)
def to_hash(serialize_context: self.class.new_serialize_context) Jbuilder.new { |json| serialize(json, serialize_context: serialize_context) }.attributes! end
def to_json(serialize_context: self.class.new_serialize_context)
def to_json(serialize_context: self.class.new_serialize_context) ViewModel.encode_json(self.to_hash(serialize_context: serialize_context)) end
def to_reference
def to_reference ViewModel::Reference.new(self.class, (id if stable_id?)) end
def validate!; end
def validate!; end
def view_name
def view_name @view_name ||= begin # try to auto-detect based on class name match = /(.*)View$/.match(self.name) raise ArgumentError.new("Could not auto-determine ViewModel name from class name '#{self.name}'") if match.nil? ViewModel::Registry.default_view_name(match[1]) end end
def view_name
Delegate view_name to class in most cases. Polymorphic views may wish to
def view_name self.class.view_name end