lib/view_model.rb



# frozen_string_literal: true

# A ViewModel encapsulates a particular aggregation of data calculated via the
# underlying models and provides a means of serializing it into views.
require 'jbuilder'
require 'base64'
require 'deep_preloader'

class ViewModel
  REFERENCE_ATTRIBUTE = '_ref'
  ID_ATTRIBUTE        = 'id'
  TYPE_ATTRIBUTE      = '_type'
  VERSION_ATTRIBUTE   = '_version'
  NEW_ATTRIBUTE       = '_new'

  BULK_UPDATE_TYPE       = '_bulk_update'
  BULK_UPDATES_ATTRIBUTE = 'updates'
  BULK_UPDATE_ATTRIBUTE  = 'update'

  # Migrations leave a metadata attribute _migrated on any views that they
  # alter. This attribute is accessible as metadata when deserializing migrated
  # input, and is included in the output serialization sent to clients.
  MIGRATED_ATTRIBUTE  = '_migrated'

  Metadata = Struct.new(:id, :view_name, :schema_version, :new, :migrated) do
    alias_method :new?, :new
  end

  class << self
    attr_accessor :_attributes
    attr_accessor :schema_version
    attr_reader   :view_aliases
    attr_writer   :view_name

    # Boolean to indicate if the viewmodel is synthetic. Synthetic
    # viewmodels are nearly-invisible glue. They're full viewmodels,
    # but do not participate in hooks or registration. For example, a
    # join table connecting A and B through T has a synthetic
    # viewmodel T to represent the join model, but the external
    # interface is a relationship of A to a list of Bs.
    attr_accessor :synthetic

    def inherited(subclass)
      super
      subclass.initialize_as_viewmodel
    end

    def initialize_as_viewmodel
      @_attributes    = []
      @schema_version = 1
      @view_aliases   = []
    end

    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 add_view_alias(as)
      view_aliases << as
      ViewModel::Registry.register(self, as: as)
    end

    # ViewModels are either roots or children. Root viewmodels may be
    # (de)serialized directly, whereas child viewmodels are always nested within
    # their parent. Associations to root viewmodel types always use indirect
    # references.
    def root?
      false
    end

    def root!
      define_singleton_method(:root?) { true }
    end

    # ViewModels are typically going to be pretty simple structures. Make it a
    # bit easier to define them: attributes specified this way are given
    # accessors and assigned in order by the default constructor.
    def attributes(*attrs, **args)
      attrs.each { |attr| attribute(attr, **args) }
    end

    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

    # An abstract viewmodel may want to define attributes to be shared by their
    # subclasses. Redefine `_attributes` to close over the current class's
    # _attributes and ignore children.
    def lock_attribute_inheritance
      _attributes.tap do |attrs|
        define_singleton_method(:_attributes) { attrs }
        attrs.freeze
      end
    end

    def member_names
      _attributes.map(&:to_s)
    end

    # In deserialization, verify and extract metadata from a provided 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 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_reference_metadata(hash)
      ViewModel::Schemas.verify_schema!(ViewModel::Schemas::VIEWMODEL_REFERENCE, hash)
      hash.delete(ViewModel::REFERENCE_ATTRIBUTE)
    end

    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

    # If this viewmodel represents an AR model, what associations does it make
    # use of? Returns a includes spec appropriate for DeepPreloader, either as
    # AR-style nested hashes or DeepPreloader::Spec.
    def eager_includes(include_referenced: true)
      {}
    end

    # ViewModel can serialize ViewModels, Arrays and Hashes of ViewModels, and
    # relies on Jbuilder#merge! for other values (e.g. primitives).
    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_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_to_hash(viewmodel, serialize_context: new_serialize_context)
      Jbuilder.new { |json| serialize(viewmodel, json, serialize_context: serialize_context) }.attributes!
    end

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

    # Rebuild this viewmodel from a serialized hash.
    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:)
      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 serialize_context_class
      ViewModel::SerializeContext
    end

    def new_serialize_context(...)
      serialize_context_class.new(...)
    end

    def deserialize_context_class
      ViewModel::DeserializeContext
    end

    def new_deserialize_context(...)
      deserialize_context_class.new(...)
    end

    def accepts_schema_version?(schema_version)
      schema_version == self.schema_version
    end

    def schema_versions(viewmodels)
      viewmodels.each_with_object({}) do |view, h|
        h[view.view_name] = view.schema_version
      end
    end

    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 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
  end

  def initialize(*args)
    self.class._attributes.each_with_index do |attr, idx|
      self.public_send(:"#{attr}=", args[idx])
    end
  end

  # Serialize this viewmodel to a jBuilder by calling serialize_view. May be
  # overridden in subclasses to (for example) implement caching.
  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 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)
    ViewModel.encode_json(self.to_hash(serialize_context: serialize_context))
  end

  # Render this viewmodel to a jBuilder. Usually overridden in subclasses.
  # Default implementation visits each attribute with Viewmodel.serialize.
  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

  # ViewModels are often used to serialize ActiveRecord models. For convenience,
  # if necessary we assume that the wrapped model is the first attribute. To
  # change this, override this method.
  def model
    self.public_send(self.class._attributes.first)
  end

  # Provide a stable way to identify this view through attribute changes. By
  # default views cannot make assumptions about the identity of our attributes,
  # so we fall back on the view's `object_id`. If a viewmodel is backed by a
  # model with a concept of identity, this method should be overridden to use
  # it.
  def id
    object_id
  end

  # Is this viewmodel backed by a model with a stable identity? Used to decide
  # whether the id is included when constructing a ViewModel::Reference from
  # this view.
  def stable_id?
    false
  end

  def validate!; end

  def to_reference
    ViewModel::Reference.new(self.class, (id if stable_id?))
  end

  # Delegate view_name to class in most cases. Polymorphic views may wish to
  # override this to select a specific alias.
  def view_name
    self.class.view_name
  end

  # When deserializing, if an error occurs within this viewmodel, what viewmodel
  # is reported as to blame. Can be overridden for example when a viewmodel is
  # merged with its parent.
  def blame_reference
    to_reference
  end

  def context_for_child(member_name, context:)
    context.for_child(self, association_name: member_name)
  end

  def preload_for_serialization(lock: nil)
    ViewModel.preload_for_serialization([self], lock: lock)
  end

  def ==(other)
    other.class == self.class && self.class._attributes.all? do |attr|
      other.send(attr) == self.send(attr)
    end
  end

  alias eql? ==

  def hash
    features = self.class._attributes.map { |attr| self.send(attr) }
    features << self.class
    features.hash
  end
end

require 'view_model/config'
require 'view_model/utils'
require 'view_model/error'
require 'view_model/callbacks'
require 'view_model/access_control'
require 'view_model/deserialization_error'
require 'view_model/serialization_error'
require 'view_model/registry'
require 'view_model/references'
require 'view_model/reference'
require 'view_model/serialize_context'
require 'view_model/deserialize_context'
require 'view_model/changes'
require 'view_model/schemas'
require 'view_model/error_view'
require 'view_model/garbage_collection'