class T::Props::Decorator

NB: This is not actually a decorator. It's just named that way for consistency with DocumentDecorator and ModelDecorator (which both seem to have been written with an incorrect understanding of the decorator pattern). These “decorators” should really just be static methods on private modules (we'd also want/need to replace decorator overrides in plugins with class methods that expose the necessary functionality).

Constants

BANNED_METHOD_NAMES

TODO: we should really be checking all the methods on `cls`, not just Object

DecoratedInstance
EMPTY_PROPS
PropType
PropTypeOrClass
Rules
SAFE_NAME
VALID_RULE_KEYS

Attributes

props[R]

Public Class Methods

new(klass) click to toggle source
# File lib/types/props/decorator.rb, line 24
def initialize(klass)
  @class = T.let(klass, T.all(Module, T::Props::ClassMethods))
  @class.plugins.each do |mod|
    T::Props::Plugin::Private.apply_decorator_methods(mod, self)
  end
  @props = T.let(EMPTY_PROPS, T::Hash[Symbol, Rules])
end

Public Instance Methods

add_prop_definition(prop, rules) click to toggle source
# File lib/types/props/decorator.rb, line 49
def add_prop_definition(prop, rules)
  override = rules.delete(:override)

  if props.include?(prop) && !override
    raise ArgumentError.new("Attempted to redefine prop #{prop.inspect} that's already defined without specifying :override => true: #{prop_rules(prop)}")
  elsif !props.include?(prop) && override
    raise ArgumentError.new("Attempted to override a prop #{prop.inspect} that doesn't already exist")
  end

  @props = @props.merge(prop => rules.freeze).freeze
end
all_props() click to toggle source
# File lib/types/props/decorator.rb, line 37
def all_props
  props.keys
end
decorated_class() click to toggle source
# File lib/types/props/decorator.rb, line 85
def decorated_class
  @class
end
foreign_prop_get(instance, prop, foreign_class, rules=prop_rules(prop), opts={}) click to toggle source
# File lib/types/props/decorator.rb, line 195
def foreign_prop_get(instance, prop, foreign_class, rules=prop_rules(prop), opts={})
  return if !(value = prop_get(instance, prop, rules))
  T.unsafe(foreign_class).load(value, {}, opts)
end
get(instance, prop, rules=prop_rules(prop))
Alias for: prop_get_if_set
model_inherited(child) click to toggle source
# File lib/types/props/decorator.rb, line 624
def model_inherited(child)
  child.extend(T::Props::ClassMethods)
  child = T.cast(child, T.all(Module, T::Props::ClassMethods))

  child.plugins.concat(decorated_class.plugins)
  decorated_class.plugins.each do |mod|
    # NB: apply_class_methods must not be an instance method on the decorator itself,
    # otherwise we'd have to call child.decorator here, which would create the decorator
    # before any `decorator_class` override has a chance to take effect (see the comment below).
    T::Props::Plugin::Private.apply_class_methods(mod, child)
  end

  props.each do |name, rules|
    copied_rules = rules.dup
    # NB: Calling `child.decorator` here is a timb bomb that's going to give someone a really bad
    # time. Any class that defines props and also overrides the `decorator_class` method is going
    # to reach this line before its override take effect, turning it into a no-op.
    child.decorator.add_prop_definition(name, copied_rules)

    # It's a bit tricky to support `prop_get` hooks added by plugins without
    # sacrificing the `attr_reader` fast path or clobbering customized getters
    # defined manually on a child.
    #
    # To make this work, we _do_ clobber getters defined on the child, but only if:
    # (a) it's needed in order to support a `prop_get` hook, and
    # (b) it's safe because the getter was defined by this file.
    #
    unless rules[:without_accessors]
      if clobber_getter?(child, name)
        child.send(:define_method, name) do
          T.unsafe(self.class).decorator.prop_get(self, name, rules)
        end
      end

      if !rules[:immutable] && clobber_setter?(child, name)
        child.send(:define_method, "#{name}=") do |val|
          T.unsafe(self.class).decorator.prop_set(self, name, val, rules)
        end
      end
    end
  end
end
plugin(mod) click to toggle source
# File lib/types/props/decorator.rb, line 680
def plugin(mod)
  decorated_class.plugins << mod
  T::Props::Plugin::Private.apply_class_methods(mod, decorated_class)
  T::Props::Plugin::Private.apply_decorator_methods(mod, self)
end
prop_defined(name, cls, rules={}) click to toggle source
# File lib/types/props/decorator.rb, line 291
def prop_defined(name, cls, rules={})
  cls = T::Utils.resolve_alias(cls)

  if T::Utils::Nilable.is_union_with_nilclass(cls)
    # :_tnilable is introduced internally for performance purpose so that clients do not need to call
    # T::Utils::Nilable.is_tnilable(cls) again.
    # It is strictly internal: clients should always use T::Props::Utils.required_prop?() or
    # T::Props::Utils.optional_prop?() for checking whether a field is required or optional.
    rules[:_tnilable] = true
  end

  name = name.to_sym
  type = cls
  if !cls.is_a?(Module)
    cls = convert_type_to_class(cls)
  end
  type_object = smart_coerce(type, enum: rules[:enum])

  prop_validate_definition!(name, cls, rules, type_object)

  # Retrive the possible underlying object with T.nilable.
  type = T::Utils::Nilable.get_underlying_type(type)

  sensitivity_and_pii = {sensitivity: rules[:sensitivity]}
  normalize = T::Configuration.normalize_sensitivity_and_pii_handler
  if normalize
    sensitivity_and_pii = normalize.call(sensitivity_and_pii)

    # We check for Class so this is only applied on concrete
    # documents/models; We allow mixins containing props to not
    # specify their PII nature, as long as every class into which they
    # are ultimately included does.
    #
    if sensitivity_and_pii[:pii] && @class.is_a?(Class) && !T.unsafe(@class).contains_pii?
      raise ArgumentError.new(
        'Cannot include a pii prop in a class that declares `contains_no_pii`'
      )
    end
  end

  rules = rules.merge(
    # TODO: The type of this element is confusing. We should refactor so that
    # it can be always `type_object` (a PropType) or always `cls` (a Module)
    type: type,
    type_object: type_object,
    accessor_key: "@#{name}".to_sym,
    sensitivity: sensitivity_and_pii[:sensitivity],
    pii: sensitivity_and_pii[:pii],
    # extra arbitrary metadata attached by the code defining this property
    extra: rules[:extra]&.freeze,
  )

  validate_not_missing_sensitivity(name, rules)

  # for backcompat (the `:array` key is deprecated but because the name is
  # so generic it's really hard to be sure it's not being relied on anymore)
  if type.is_a?(T::Types::TypedArray)
    inner = T::Utils::Nilable.get_underlying_type(type.type)
    if inner.is_a?(Module)
      rules[:array] = inner
    end
  end

  rules[:setter_proc] = T::Props::Private::SetterFactory.build_setter_proc(@class, name, rules).freeze

  add_prop_definition(name, rules)

  # NB: using `without_accessors` doesn't make much sense unless you also define some other way to
  # get at the property (e.g., Chalk::ODM::Document exposes `get` and `set`).
  define_getter_and_setter(name, rules) unless rules[:without_accessors]

  if rules[:foreign] && rules[:foreign_hint_only]
    raise ArgumentError.new(":foreign and :foreign_hint_only are mutually exclusive.")
  end

  handle_foreign_option(name, cls, rules, rules[:foreign]) if rules[:foreign]
  handle_foreign_hint_only_option(name, cls, rules[:foreign_hint_only]) if rules[:foreign_hint_only]
  handle_redaction_option(name, rules[:redaction]) if rules[:redaction]
end
prop_get(instance, prop, rules=prop_rules(prop)) click to toggle source
# File lib/types/props/decorator.rb, line 158
def prop_get(instance, prop, rules=prop_rules(prop))
  val = instance.instance_variable_get(rules[:accessor_key])
  if !val.nil?
    val
  elsif (d = rules[:ifunset])
    T::Props::Utils.deep_clone_object(d)
  else
    nil
  end
end
prop_get_if_set(instance, prop, rules=prop_rules(prop)) click to toggle source
# File lib/types/props/decorator.rb, line 178
def prop_get_if_set(instance, prop, rules=prop_rules(prop))
  instance.instance_variable_get(rules[:accessor_key])
end
Also aliased as: get
prop_get_logic(instance, prop, value) click to toggle source
# File lib/types/props/decorator.rb, line 138
def prop_get_logic(instance, prop, value)
  value
end
prop_rules(prop) click to toggle source
# File lib/types/props/decorator.rb, line 43
def prop_rules(prop)
  props[prop.to_sym] || raise("No such prop: #{prop.inspect}")
end
prop_set(instance, prop, val, rules=prop_rules(prop)) click to toggle source
# File lib/types/props/decorator.rb, line 122
def prop_set(instance, prop, val, rules=prop_rules(prop))
  instance.instance_exec(val, &rules.fetch(:setter_proc))
end
Also aliased as: set
prop_validate_definition!(name, cls, rules, type) click to toggle source
# File lib/types/props/decorator.rb, line 214
def prop_validate_definition!(name, cls, rules, type)
  validate_prop_name(name)

  if rules.key?(:pii)
    raise ArgumentError.new("The 'pii:' option for props has been renamed " \
      "to 'sensitivity:' (in prop #{@class.name}.#{name})")
  end

  if rules.keys.any? {|k| !valid_rule_key?(k)}
    raise ArgumentError.new("At least one invalid prop arg supplied in #{self}: #{rules.keys.inspect}")
  end

  if !rules[:clobber_existing_method!] && !rules[:without_accessors] && BANNED_METHOD_NAMES.include?(name.to_sym)
    raise ArgumentError.new(
      "#{name} can't be used as a prop in #{@class} because a method with " \
      "that name already exists (defined by #{@class.instance_method(name).owner} " \
      "at #{@class.instance_method(name).source_location || '<unknown>'}). " \
      "(If using this name is unavoidable, try `without_accessors: true`.)"
    )
  end

  extra = rules[:extra]
  if !extra.nil? && !extra.is_a?(Hash)
    raise ArgumentError.new("Extra metadata must be a Hash in prop #{@class.name}.#{name}")
  end

  nil
end
set(instance, prop, val, rules=prop_rules(prop))
Alias for: prop_set
valid_rule_key?(key) click to toggle source
# File lib/types/props/decorator.rb, line 79
def valid_rule_key?(key)
  !!VALID_RULE_KEYS[key]
end
validate_prop_value(prop, val) click to toggle source
# File lib/types/props/decorator.rb, line 95
def validate_prop_value(prop, val)
  # We call `setter_proc` here without binding to an instance, so it'll run
  # `instance_variable_set` if validation passes, but nothing will care.
  # We only care about the validation.
  prop_rules(prop).fetch(:setter_proc).call(val)
end

Private Instance Methods

clobber_getter?(child, prop) click to toggle source
# File lib/types/props/decorator.rb, line 668
        def clobber_getter?(child, prop)
  !!(child.decorator.method(:prop_get).owner != method(:prop_get).owner &&
     child.instance_method(prop).source_location&.first == __FILE__)
end
clobber_setter?(child, prop) click to toggle source
# File lib/types/props/decorator.rb, line 674
        def clobber_setter?(child, prop)
  !!(child.decorator.method(:prop_set).owner != method(:prop_set).owner &&
     child.instance_method("#{prop}=").source_location&.first == __FILE__)
end
convert_type_to_class(type) click to toggle source
# File lib/types/props/decorator.rb, line 255
        def convert_type_to_class(type)
  case type
  when T::Types::TypedArray, T::Types::FixedArray
    Array
  when T::Types::TypedHash, T::Types::FixedHash
    Hash
  when T::Types::TypedSet
    Set
  when T::Types::Union
    # The below unwraps our T.nilable types for T::Props if we can.
    # This lets us do things like specify: const T.nilable(String), foreign: Opus::DB::Model::Merchant
    non_nil_type = T::Utils.unwrap_nilable(type)
    if non_nil_type
      convert_type_to_class(non_nil_type)
    else
      Object
    end
  when T::Types::Simple
    type.raw_type
  else
    # This isn't allowed unless whitelisted_for_underspecification is
    # true, due to the check in prop_validate_definition
    Object
  end
end
define_foreign_method(prop_name, rules, foreign) click to toggle source
# File lib/types/props/decorator.rb, line 533
        def define_foreign_method(prop_name, rules, foreign)
  fk_method = "#{prop_name}_"

  # n.b. there's no clear reason *not* to allow additional options
  # here, but we're baking in `allow_direct_mutation` since we
  # *haven't* allowed additional options in the past and want to
  # default to keeping this interface narrow.
  @class.send(:define_method, fk_method) do |allow_direct_mutation: nil|
    foreign = T.let(foreign, T.untyped)
    if foreign.is_a?(Proc)
      resolved_foreign = foreign.call
      if !resolved_foreign.respond_to?(:load)
        raise ArgumentError.new(
          "The `foreign` proc for `#{prop_name}` must return a model class. " \
          "Got `#{resolved_foreign.inspect}` instead."
        )
      end
      # `foreign` is part of the closure state, so this will persist to future invocations
      # of the method, optimizing it so this only runs on the first invocation.
      foreign = resolved_foreign
    end
    opts = if allow_direct_mutation.nil?
      {}
    else
      {allow_direct_mutation: allow_direct_mutation}
    end

    T.unsafe(self.class).decorator.foreign_prop_get(self, prop_name, foreign, rules, opts)
  end

  force_fk_method = "#{fk_method}!"
  @class.send(:define_method, force_fk_method) do |allow_direct_mutation: nil|
    loaded_foreign = send(fk_method, allow_direct_mutation: allow_direct_mutation)
    if !loaded_foreign
      T::Configuration.hard_assert_handler(
        'Failed to load foreign model',
        storytime: {method: force_fk_method, class: self.class}
      )
    end
    loaded_foreign
  end
end
define_getter_and_setter(name, rules) click to toggle source
# File lib/types/props/decorator.rb, line 373
        def define_getter_and_setter(name, rules)
  T::Configuration.without_ruby_warnings do
    if !rules[:immutable]
      if method(:prop_set).owner != T::Props::Decorator
        @class.send(:define_method, "#{name}=") do |val|
          T.unsafe(self.class).decorator.prop_set(self, name, val, rules)
        end
      else
        # Fast path (~4x faster as of Ruby 2.6)
        @class.send(:define_method, "#{name}=", &rules.fetch(:setter_proc))
      end
    end

    if method(:prop_get).owner != T::Props::Decorator || rules.key?(:ifunset)
      @class.send(:define_method, name) do
        T.unsafe(self.class).decorator.prop_get(self, name, rules)
      end
    else
      # Fast path (~30x faster as of Ruby 2.6)
      @class.send(:attr_reader, name) # send is used because `attr_reader` is private in 2.4
    end
  end
end
handle_foreign_hint_only_option(prop_name, prop_cls, foreign_hint_only) click to toggle source
# File lib/types/props/decorator.rb, line 495
          def handle_foreign_hint_only_option(prop_name, prop_cls, foreign_hint_only)
    if ![String, Array].include?(prop_cls) && !prop_cls.is_a?(T::Props::CustomType)
      raise ArgumentError.new(
        "`foreign_hint_only` can only be used with String or Array prop types"
      )
    end

    validate_foreign_option(
      :foreign_hint_only, foreign_hint_only,
      valid_type_msg: "an individual or array of a model class, or a Proc returning such."
    )

    unless foreign_hint_only.is_a?(Proc)
      T::Configuration.soft_assert_handler(<<~MESSAGE, storytime: {prop: prop_name, value: foreign_hint_only}, notify: 'jerry')
        Please use a Proc that returns a model class instead of the model class itself as the argument to `foreign_hint_only`. In other words:

          instead of `prop :foo, String, foreign_hint_only: FooModel`
          use `prop :foo, String, foreign_hint_only: -> {FooModel}`

          OR

          instead of `prop :foo, String, foreign_hint_only: [FooModel, BarModel]`
          use `prop :foo, String, foreign_hint_only: -> {[FooModel, BarModel]}`

      MESSAGE
    end
  end
handle_foreign_option(prop_name, prop_cls, rules, foreign) click to toggle source
# File lib/types/props/decorator.rb, line 587
          def handle_foreign_option(prop_name, prop_cls, rules, foreign)
    validate_foreign_option(
      :foreign, foreign, valid_type_msg: "a model class or a Proc that returns one"
    )

    if prop_cls != String
      raise ArgumentError.new("`foreign` can only be used with a prop type of String")
    end

    if foreign.is_a?(Array)
      # We don't support arrays with `foreign` because it's hard to both preserve ordering and
      # keep them from being lurky performance hits by issuing a bunch of un-batched DB queries.
      # We could potentially address that by porting over something like AmbiguousIDLoader.
      raise ArgumentError.new(
        "Using an array for `foreign` is no longer supported. Instead, use `foreign_hint_only` " \
        "with an array or a Proc that returns an array, e.g., foreign_hint_only: -> {[Foo, Bar]}"
      )
    end

    unless foreign.is_a?(Proc)
      T::Configuration.soft_assert_handler(<<~MESSAGE, storytime: {prop: prop_name, value: foreign}, notify: 'jerry')
        Please use a Proc that returns a model class instead of the model class itself as the argument to `foreign`. In other words:

          instead of `prop :foo, String, foreign: FooModel`
          use `prop :foo, String, foreign: -> {FooModel}`

      MESSAGE
    end

    define_foreign_method(prop_name, rules, foreign)
  end
handle_redaction_option(prop_name, redaction) click to toggle source
# File lib/types/props/decorator.rb, line 453
        def handle_redaction_option(prop_name, redaction)
  redacted_method = "#{prop_name}_redacted"

  @class.send(:define_method, redacted_method) do
    value = self.public_send(prop_name)
    handler = T::Configuration.redaction_handler
    if !handler
      raise "Using `redaction:` on a prop requires specifying `T::Configuration.redaction_handler`"
    end
    handler.call(value, redaction)
  end
end
smart_coerce(type, enum:) click to toggle source
# File lib/types/props/decorator.rb, line 401
        def smart_coerce(type, enum:)
  # Backwards compatibility for pre-T::Types style
  type = T::Utils.coerce(type)
  if enum.nil?
    type
  else
    nonnil_type = T::Utils.unwrap_nilable(type)
    if nonnil_type
      T.nilable(T.all(nonnil_type, T.enum(enum)))
    else
      T.all(type, T.enum(enum))
    end
  end
end
validate_foreign_option(option_sym, foreign, valid_type_msg:) click to toggle source
# File lib/types/props/decorator.rb, line 474
        def validate_foreign_option(option_sym, foreign, valid_type_msg:)
  if foreign.is_a?(Symbol) || foreign.is_a?(String)
    raise ArgumentError.new(
      "Using a symbol/string for `#{option_sym}` is no longer supported. Instead, use a Proc " \
      "that returns the class, e.g., foreign: -> {Foo}"
    )
  end

  if !foreign.is_a?(Proc) && !foreign.is_a?(Array) && !foreign.respond_to?(:load)
    raise ArgumentError.new("The `#{option_sym}` option must be #{valid_type_msg}")
  end
end
validate_not_missing_sensitivity(prop_name, rules) click to toggle source
# File lib/types/props/decorator.rb, line 418
        def validate_not_missing_sensitivity(prop_name, rules)
  if rules[:sensitivity].nil?
    if rules[:redaction]
      T::Configuration.hard_assert_handler(
        "#{@class}##{prop_name} has a 'redaction:' annotation but no " \
        "'sensitivity:' annotation. This is probably wrong, because if a " \
        "prop needs redaction then it is probably sensitive. Add a " \
        "sensitivity annotation like 'sensitivity: Opus::Sensitivity::PII." \
        "whatever', or explicitly override this check with 'sensitivity: []'."
      )
    end
    # TODO(PRIVACYENG-982) Ideally we'd also check for 'password' and possibly
    # other terms, but this interacts badly with ProtoDefinedDocument because
    # the proto syntax currently can't declare "sensitivity: []"
    if /\bsecret\b/.match?(prop_name)
      T::Configuration.hard_assert_handler(
        "#{@class}##{prop_name} has the word 'secret' in its name, but no " \
        "'sensitivity:' annotation. This is probably wrong, because if a " \
        "prop is named 'secret' then it is probably sensitive. Add a " \
        "sensitivity annotation like 'sensitivity: Opus::Sensitivity::NonPII." \
        "security_token', or explicitly override this check with " \
        "'sensitivity: []'."
      )
    end
  end
end
validate_prop_name(name) click to toggle source
# File lib/types/props/decorator.rb, line 247
        def validate_prop_name(name)
  if !name.match?(SAFE_NAME)
    raise ArgumentError.new("Invalid prop name in #{@class.name}: #{name}")
  end
end