lib/types/props/serializable.rb



# frozen_string_literal: true
# typed: false

module T::Props::Serializable
  include T::Props::Plugin
  # Required because we have special handling for `optional: false`
  include T::Props::Optional
  # Required because we have special handling for extra_props
  include T::Props::PrettyPrintable

  # Serializes this object to a hash, suitable for conversion to
  # JSON/BSON.
  #
  # @param strict [T::Boolean] (true) If false, do not raise an
  #   exception if this object has mandatory props with missing
  #   values.
  # @return [Hash] A serialization of this object.
  def serialize(strict=true)
    decorator = self.class.decorator
    h = {}

    decorator.props.each do |prop, rules|
      hkey = rules[:serialized_form]

      val = decorator.get(self, prop, rules)

      if val.nil? && strict && !rules[:fully_optional]
        # If the prop was already missing during deserialization, that means the application
        # code already had to deal with a nil value, which means we wouldn't be accomplishing
        # much by raising here (other than causing an unnecessary breakage).
        if self.required_prop_missing_from_deserialize?(prop)
          T::Configuration.log_info_handler(
            "chalk-odm: missing required property in serialize",
            prop: prop, class: self.class.name, id: decorator.get_id(self)
          )
        else
          raise T::Props::InvalidValueError.new("#{self.class.name}.#{prop} not set for non-optional prop")
        end
      end

      # Don't serialize values that are nil to save space (both the
      # nil value itself and the field name in the serialized BSON
      # document)
      next if rules[:dont_store] || val.nil?

      if rules[:serializable_subtype]
        if rules[:type_is_serializable]
          val = val.serialize(strict)
        elsif rules[:type_is_array_of_serializable]
          if (subtype = rules[:serializable_subtype]).is_a?(T::Props::CustomType)
            val = val.map {|el| el && subtype.serialize(el)}
          else
            val = val.map {|el| el && el.serialize(strict)}
          end
        elsif rules[:type_is_hash_of_serializable_values] && rules[:type_is_hash_of_custom_type_keys]
          key_subtype = rules[:serializable_subtype][:keys]
          value_subtype = rules[:serializable_subtype][:values]
          if value_subtype.is_a?(T::Props::CustomType)
            val = val.each_with_object({}) do |(key, value), result|
              result[key_subtype.serialize(key)] = value && value_subtype.serialize(value)
            end
          else
            val = val.each_with_object({}) do |(key, value), result|
              result[key_subtype.serialize(key)] = value && value.serialize(strict)
            end
          end
        elsif rules[:type_is_hash_of_serializable_values]
          value_subtype = rules[:serializable_subtype]
          if value_subtype.is_a?(T::Props::CustomType)
            val = val.transform_values {|v| v && value_subtype.serialize(v)}
          else
            val = val.transform_values {|v| v && v.serialize(strict)}
          end
        elsif rules[:type_is_hash_of_custom_type_keys]
          key_subtype = rules[:serializable_subtype]
          val = val.each_with_object({}) do |(key, value), result|
            result[key_subtype.serialize(key)] = value
          end
        end
      elsif rules[:type_is_custom_type]
        val = rules[:type].serialize(val)

        unless T::Props::CustomType.valid_serialization?(val, rules[:type])
          msg = "#{rules[:type]} did not serialize to a valid scalar type. It became a: #{val.class}"
          if val.is_a?(Hash)
            msg += "\nIf you want to store a structured Hash, consider using a T::Struct as your type."
          end
          raise T::Props::InvalidValueError.new(msg)
        end
      end

      needs_clone = rules[:type_needs_clone]
      if needs_clone
        if needs_clone == :shallow
          val = val.dup
        else
          val = T::Props::Utils.deep_clone_object(val)
        end
      end

      h[hkey] = val
    end

    h.merge!(@_extra_props) if @_extra_props

    h
  end

  # Populates the property values on this object with the values
  # from a hash. In general, prefer to use {.from_hash} to construct
  # a new instance, instead of loading into an existing instance.
  #
  # @param hash [Hash<String, Object>] The hash to take property
  #  values from.
  # @param strict [T::Boolean] (false) If true, raise an exception if
  #  the hash contains keys that do not correspond to any known
  #  props on this instance.
  # @return [void]
  def deserialize(hash, strict=false)
    decorator = self.class.decorator

    matching_props = 0

    decorator.props.each do |p, rules|
      hkey = rules[:serialized_form]
      val = hash[hkey]
      if val.nil?
        if T::Props::Utils.required_prop?(rules)
          val = decorator.get_default(rules, self.class)
          if val.nil?
            msg = "Tried to deserialize a required prop from a nil value. It's "\
              "possible that a nil value exists in the database, so you should "\
              "provide a `default: or factory:` for this prop (see go/optional "\
              "for more details). If this is already the case, you probably "\
              "omitted a required prop from the `fields:` option when doing a "\
              "partial load."
            storytime = {prop: hkey, klass: self.class.name}

            # Notify the model owner if it exists, and always notify the API owner.
            begin
              if defined?(Opus) && defined?(Opus::Ownership) && decorator.decorated_class < Opus::Ownership
                T::Configuration.hard_assert_handler(
                  msg,
                  storytime: storytime,
                  project: decorator.decorated_class.get_owner
                )
              end
            ensure
              T::Configuration.hard_assert_handler(msg, storytime: storytime)
            end
          end
        elsif rules[:need_nil_read_check]
          self.required_prop_missing_from_deserialize(p)
        end

        matching_props += 1 if hash.key?(hkey)
      else
        if (subtype = rules[:serializable_subtype])
          val =
            if rules[:type_is_array_of_serializable]
              if subtype.is_a?(T::Props::CustomType)
                val.map {|el| el && subtype.deserialize(el)}
              else
                val.map {|el| el && subtype.from_hash(el)}
              end
            elsif rules[:type_is_hash_of_serializable_values] && rules[:type_is_hash_of_custom_type_keys]
              key_subtype = subtype[:keys]
              values_subtype = subtype[:values]
              if values_subtype.is_a?(T::Props::CustomType)
                val.each_with_object({}) do |(key, value), result|
                  result[key_subtype.deserialize(key)] = value && values_subtype.deserialize(value)
                end
              else
                val.each_with_object({}) do |(key, value), result|
                  result[key_subtype.deserialize(key)] = value && values_subtype.from_hash(value)
                end
              end
            elsif rules[:type_is_hash_of_serializable_values]
              if subtype.is_a?(T::Props::CustomType)
                val.transform_values {|v| v && subtype.deserialize(v)}
              else
                val.transform_values {|v| v && subtype.from_hash(v)}
              end
            elsif rules[:type_is_hash_of_custom_type_keys]
              val.map do |key, value|
                [subtype.deserialize(key), value]
              end.to_h
            else
              subtype.from_hash(val)
            end
        elsif (needs_clone = rules[:type_needs_clone])
          val =
            if needs_clone == :shallow
              val.dup
            else
              T::Props::Utils.deep_clone_object(val)
            end
        elsif rules[:type_is_custom_type]
          val = rules[:type].deserialize(val)
        end

        matching_props += 1
      end

      self.instance_variable_set(rules[:accessor_key], val) # rubocop:disable PrisonGuard/NoLurkyInstanceVariableAccess
    end

    # We compute extra_props this way specifically for performance
    if matching_props < hash.size
      pbsf = decorator.prop_by_serialized_forms
      h = hash.reject {|k, _| pbsf.key?(k)}

      if strict
        raise "Unknown properties for #{self.class.name}: #{h.keys.inspect}"
      else
        @_extra_props = h
      end
    end
  end

  # with() will clone the old object to the new object and merge the specified props to the new object.
  def with(changed_props)
    with_existing_hash(changed_props, existing_hash: self.serialize)
  end

  private def recursive_stringify_keys(obj)
    if obj.is_a?(Hash)
      new_obj = obj.class.new
      obj.each do |k, v|
        new_obj[k.to_s] = recursive_stringify_keys(v)
      end
    elsif obj.is_a?(Array)
      new_obj = obj.map {|v| recursive_stringify_keys(v)}
    else
      new_obj = obj
    end
    new_obj
  end

  private def with_existing_hash(changed_props, existing_hash:)
    serialized = existing_hash
    new_val = self.class.from_hash(serialized.merge(recursive_stringify_keys(changed_props)))
    old_extra = self.instance_variable_get(:@_extra_props) # rubocop:disable PrisonGuard/NoLurkyInstanceVariableAccess
    new_extra = new_val.instance_variable_get(:@_extra_props) # rubocop:disable PrisonGuard/NoLurkyInstanceVariableAccess
    if old_extra != new_extra
      difference =
        if old_extra
          new_extra.reject {|k, v| old_extra[k] == v}
        else
          new_extra
        end
      raise ArgumentError.new("Unexpected arguments: input(#{changed_props}), unexpected(#{difference})")
    end
    new_val
  end

  # @return [T::Boolean] Was this property missing during deserialize?
  def required_prop_missing_from_deserialize?(prop)
    return false if @_required_props_missing_from_deserialize.nil?
    @_required_props_missing_from_deserialize.include?(prop)
  end

  # @return Marks this property as missing during deserialize
  def required_prop_missing_from_deserialize(prop)
    @_required_props_missing_from_deserialize ||= Set[]
    @_required_props_missing_from_deserialize << prop
    nil
  end
end


##############################################

# NB: This must stay in the same file where T::Props::Serializable is defined due to
# T::Props::Decorator#apply_plugin; see https://git.corp.stripe.com/stripe-internal/pay-server/blob/fc7f15593b49875f2d0499ffecfd19798bac05b3/chalk/odm/lib/chalk-odm/document_decorator.rb#L716-L717
module T::Props::Serializable::DecoratorMethods
  def valid_props
    super + [
      :dont_store,
      :name,
      :raise_on_nil_write,
    ]
  end

  def required_props
    @class.props.select {|_, v| T::Props::Utils.required_prop?(v)}.keys
  end

  def prop_dont_store?(prop); prop_rules(prop)[:dont_store]; end
  def prop_by_serialized_forms; @class.prop_by_serialized_forms; end

  def from_hash(hash, strict=false)
    raise ArgumentError.new("#{hash.inspect} provided to from_hash") if !(hash && hash.is_a?(Hash))

    i = @class.allocate
    i.deserialize(hash, strict)

    i
  end

  def prop_serialized_form(prop)
    prop_rules(prop)[:serialized_form]
  end

  def serialized_form_prop(serialized_form)
    prop_by_serialized_forms[serialized_form.to_s] || raise("No such serialized form: #{serialized_form.inspect}")
  end

  def add_prop_definition(prop, rules)
    rules[:serialized_form] = rules.fetch(:name, prop.to_s)
    res = super
    prop_by_serialized_forms[rules[:serialized_form]] = prop
    res
  end

  def prop_validate_definition!(name, cls, rules, type)
    result = super

    if (rules_name = rules[:name])
      unless rules_name.is_a?(String)
        raise ArgumentError.new("Invalid name in prop #{@class.name}.#{name}: #{rules_name.inspect}")
      end

      validate_prop_name(rules_name)
    end

    if !rules[:raise_on_nil_write].nil? && rules[:raise_on_nil_write] != true
        raise ArgumentError.new("The value of `raise_on_nil_write` if specified must be `true` (given: #{rules[:raise_on_nil_write]}).")
    end

    result
  end

  def get_id(instance)
    prop = prop_by_serialized_forms['_id']
    if prop
      get(instance, prop)
    else
      nil
    end
  end

  EMPTY_EXTRA_PROPS = {}.freeze
  private_constant :EMPTY_EXTRA_PROPS

  def extra_props(instance)
    get(instance, '_extra_props') || EMPTY_EXTRA_PROPS
  end

  # @override T::Props::PrettyPrintable
  private def inspect_instance_components(instance, multiline:, indent:)
    if (extra_props = extra_props(instance)) && !extra_props.empty?
      pretty_kvs = extra_props.map {|k, v| [k.to_sym, v.inspect]}
      extra = join_props_with_pretty_values(pretty_kvs, multiline: false)
      super + ["@_extra_props=<#{extra}>"]
    else
      super
    end
  end
end


##############################################


# NB: This must stay in the same file where T::Props::Serializable is defined due to
# T::Props::Decorator#apply_plugin; see https://git.corp.stripe.com/stripe-internal/pay-server/blob/fc7f15593b49875f2d0499ffecfd19798bac05b3/chalk/odm/lib/chalk-odm/document_decorator.rb#L716-L717
module T::Props::Serializable::ClassMethods
  def prop_by_serialized_forms; @prop_by_serialized_forms ||= {}; end

  # @!method self.from_hash(hash, strict)
  #
  # Allocate a new instance and call {#deserialize} to load a new
  # object from a hash.
  # @return [Serializable]
  def from_hash(hash, strict=false)
    self.decorator.from_hash(hash, strict)
  end

  # Equivalent to {.from_hash} with `strict` set to true.
  # @return [Serializable]
  def from_hash!(hash)
    self.decorator.from_hash(hash, true)
  end
end