class T::Props::Decorator
functionality).
replace decorator overrides in plugins with class methods that expose the necessary
should really just be static methods on private modules (we’d also want/need to
with an incorrect understanding of the decorator pattern). These “decorators”
with DocumentDecorator and ModelDecorator (which both seem to have been written
NB: This is not actually a decorator. It’s just named that way for consistency
def add_prop_definition(prop, rules)
def add_prop_definition(prop, rules) prop = prop.to_sym 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
def all_props; props.keys; end
def all_props; props.keys; end
def array_subdoc_type(type)
def array_subdoc_type(type) e.is_a?(T::Types::TypedArray) ype = T::Utils.unwrap_nilable(type.type) || type.type l_type.is_a?(T::Types::Simple) && (el_type.raw_type < T::Props::Serializable || el_type.raw_type.is_a?(T::Props::CustomType)) turn el_type.raw_type
def check_prop_type(prop, val, rules=prop_rules(prop))
def check_prop_type(prop, val, rules=prop_rules(prop)) bject = rules.fetch(:type_object) rules.fetch(:type) : ideally we'd add `&& rules[:optional] != :existing` to this check makes sense to treat those props required in this context), but we'd need e sure that doesn't break any existing code first. .nil? T::Props::Utils.need_nil_write_check?(rules) || (rules.key?(:default) && rules[:default].nil?) turn ules[:raise_on_nil_write] ise T::Props::InvalidValueError.new("Can't set #{@class.name}.#{prop} to #{val.inspect} " \ instance of #{val.class}) - need a #{type}") rops::CustomType is not a real object based class so that we can not run real type check call. rops::CustomType.valid?() is only a helper function call. = ype.is_a?(T::Props::CustomType) && T::Props::Utils.optional_prop?(rules) pe.valid?(val) pe_object.valid?(val) lid e T::Props::InvalidValueError.new("Can't set #{@class.name}.#{prop} to #{val.inspect} " \ instance of #{val.class}) - need a #{type_object}") ::Props::InvalidValueError => err _loc = T.must(caller_locations(8, 1))[0] _message = "Parameter '#{prop}': #{err.message}\n" \ ler: #{caller_loc.path}:#{caller_loc.lineno}\n" figuration.call_validation_error_handler( age: err.message, ty_message: pretty_message, : 'Parameter', : prop, : type, e: val, tion: caller_loc,
def convert_type_to_class(type)
def convert_type_to_class(type) ype ::Types::TypedArray, T::Types::FixedArray y ::Types::TypedHash, T::Types::FixedHash ::Types::TypedSet ::Types::Union e below unwraps our T.nilable types for T::Props if we can. is lets us do things like specify: const T.nilable(String), foreign: Opus::DB::Model::Merchant nil_type = T::Utils.unwrap_nilable(type) on_nil_type nvert_type_to_class(non_nil_type) ject ::Types::Simple .raw_type is isn't allowed unless whitelisted_for_underspecification is ue, due to the check in prop_validate_definition ct
def decorated_class; @class; end
def decorated_class; @class; end
def define_foreign_method(prop_name, rules, foreign)
def define_foreign_method(prop_name, rules, foreign) hod = "#{prop_name}_" there's no clear reason *not* to allow additional options , but we're baking in `allow_direct_mutation` since we en't* allowed additional options in the past and want to ult to keeping this interface narrow. .send(:define_method, fk_method) do |allow_direct_mutation: nil| oreign.is_a?(Proc) solved_foreign = foreign.call !resolved_foreign.respond_to?(:load) raise ArgumentError.new( "The `foreign` proc for `#{prop_name}` must return a model class. " \ "Got `#{resolved_foreign.inspect}` instead." ) d `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. reign = resolved_foreign llow_direct_mutation.nil? ts = {} ts = {allow_direct_mutation: allow_direct_mutation} .class.decorator.foreign_prop_get(self, prop_name, foreign, rules, opts) fk_method = "#{fk_method}!" .send(:define_method, force_fk_method) do |allow_direct_mutation: nil| ed_foreign = send(fk_method, allow_direct_mutation: allow_direct_mutation) loaded_foreign :Configuration.hard_assert_handler( 'Failed to load foreign model', storytime: {method: force_fk_method, class: self.class} st(loaded_foreign) .send(:define_method, "#{prop_name}_record") do |allow_direct_mutation: nil| onfiguration.soft_assert_handler( sing deprecated 'model.#{prop_name}_record' foreign key syntax. You should replace this with 'model.#{prop_name}_'", tify: 'vasi' (fk_method, allow_direct_mutation: allow_direct_mutation)
def define_getter_and_setter(name, rules)
def define_getter_and_setter(name, rules) figuration.without_ruby_warnings do rules[:immutable] lass.send(:define_method, "#{name}=") do |x| self.class.decorator.prop_set(self, name, x, rules) d ss.send(:define_method, name) do lf.class.decorator.prop_get(self, name, rules)
def foreign_prop_get(instance, prop, foreign_class, rules=props[prop.to_sym], opts={})
def foreign_prop_get(instance, prop, foreign_class, rules=props[prop.to_sym], opts={}) return if !(value = prop_get(instance, prop, rules)) foreign_class.load(value, {}, opts) end
def get(instance, prop, rules=props[prop.to_sym])
def get(instance, prop, rules=props[prop.to_sym]) # For backwards compatibility, fall back to reconstructing the accessor key # (though it would probably make more sense to raise in that case). instance.instance_variable_get(rules ? rules[:accessor_key] : '@' + prop.to_s) # rubocop:disable PrisonGuard/NoLurkyInstanceVariableAccess end
def handle_foreign_hint_only_option(prop_cls, foreign_hint_only)
def handle_foreign_hint_only_option(prop_cls, foreign_hint_only) tring, Array].include?(prop_cls) && !(prop_cls.is_a?(T::Props::CustomType)) e ArgumentError.new( foreign_hint_only` can only be used with String or Array prop types" te_foreign_option( eign_hint_only, foreign_hint_only, d_type_msg: "an individual or array of a model class, or a Proc returning such."
def handle_foreign_option(prop_name, prop_cls, rules, foreign)
def handle_foreign_option(prop_name, prop_cls, rules, foreign) te_foreign_option( eign, foreign, valid_type_msg: "a model class or a Proc that returns one" p_cls != String e ArgumentError.new("`foreign` can only be used with a prop type of String") eign.is_a?(Array) don't support arrays with `foreign` because it's hard to both preserve ordering and ep them from being lurky performance hits by issuing a bunch of un-batched DB queries. could potentially address that by porting over something like AmbiguousIDLoader. e ArgumentError.new( sing an array for `foreign` is no longer supported. Instead, use `foreign_hint_only` " \ ith an array or a Proc that returns an array, e.g., foreign_hint_only: -> {[Foo, Bar]}" _foreign_method(prop_name, rules, foreign)
def handle_redaction_option(prop_name, redaction)
def handle_redaction_option(prop_name, redaction) ed_method = "#{prop_name}_redacted" .send(:define_method, redacted_method) do e = self.public_send(prop_name) k::Tools::RedactionUtils.redact_with_directive( lue, redaction)
def hash_key_custom_type(type)
def hash_key_custom_type(type) e.is_a?(T::Types::TypedHash) _type = T::Utils.unwrap_nilable(type.keys) || type.keys eys_type.is_a?(T::Types::Simple) && keys_type.raw_type.is_a?(T::Props::CustomType) turn keys_type.raw_type
def hash_value_subdoc_type(type)
def hash_value_subdoc_type(type) e.is_a?(T::Types::TypedHash) es_type = T::Utils.unwrap_nilable(type.values) || type.values alues_type.is_a?(T::Types::Simple) && (values_type.raw_type < T::Props::Serializable || values_type.raw_type.is_a?(T::Props::CustomType)) turn values_type.raw_type
def initialize(klass)
def initialize(klass) @class = klass klass.plugins.each do |mod| Private.apply_decorator_methods(mod, self) end end
def is_nilable?(type)
def is_nilable?(type) false if !type.is_a?(T::Types::Union) ypes.any? {|t| t == T::Utils.coerce(NilClass)}
def model_inherited(child)
def model_inherited(child) child.extend(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). 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) end end
def mutate_prop_backdoor!(prop, key, value)
def mutate_prop_backdoor!(prop, key, value) @props = props.merge( prop => props.fetch(prop).merge(key => value).freeze ).freeze end
def plugin(mod)
def plugin(mod) decorated_class.plugins << mod Private.apply_class_methods(mod, decorated_class) Private.apply_decorator_methods(mod, self) end
def prop_defined(name, cls, rules={})
def prop_defined(name, cls, rules={}) cls = T::Utils.resolve_alias(cls) if rules[:optional] == true T::Configuration.hard_assert_handler( 'Use of `optional: true` is deprecated, please use `T.nilable(...)` instead.', storytime: { name: name, cls_or_args: cls.to_s, args: rules, klass: decorated_class.name, }, ) elsif rules[:optional] == false T::Configuration.hard_assert_handler( 'Use of `optional: :false` is deprecated as it\'s the default value.', storytime: { name: name, cls_or_args: cls.to_s, args: rules, klass: decorated_class.name, }, ) elsif rules[:optional] == :on_load T::Configuration.hard_assert_handler( 'Use of `optional: :on_load` is deprecated. You probably want `T.nilable(...)` with :raise_on_nil_write instead.', storytime: { name: name, cls_or_args: cls.to_s, args: rules, klass: decorated_class.name, }, ) elsif rules[:optional] == :existing T::Configuration.hard_assert_handler( 'Use of `optional: :existing` is not allowed: you should use use T.nilable (http://go/optional)', storytime: { name: name, cls_or_args: cls.to_s, args: rules, klass: decorated_class.name, }, ) end 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 = type if !(type_object.singleton_class < T::Props::CustomType) type_object = smart_coerce(type_object, array: rules[:array], enum: rules[:enum]) end prop_validate_definition!(name, cls, rules, type_object) # Retrive the possible underlying object with T.nilable. underlying_type_object = T::Utils::Nilable.get_underlying_type_object(type_object) type = T::Utils::Nilable.get_underlying_type(type) array_subdoc_type = array_subdoc_type(underlying_type_object) hash_value_subdoc_type = hash_value_subdoc_type(underlying_type_object) hash_key_custom_type = hash_key_custom_type(underlying_type_object) sensitivity_and_pii = {sensitivity: rules[:sensitivity]} if defined?(Opus) && defined?(Opus::Sensitivity) && defined?(Opus::Sensitivity::Utils) sensitivity_and_pii = Opus::Sensitivity::Utils.normalize_sensitivity_and_pii_annotation(sensitivity_and_pii) end # 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 defined?(Opus) && defined?(Opus::Sensitivity) && defined?(Opus::Sensitivity::PIIable) if sensitivity_and_pii[:pii] && @class.is_a?(Class) && !@class.contains_pii? raise ArgumentError.new( 'Cannot include a pii prop in a class that declares `contains_no_pii`' ) end end needs_clone = if cls <= Array || cls <= Hash || cls <= Set shallow_clone_ok(underlying_type_object) ? :shallow : true else false 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, # These are precomputed for performance # TODO: A lot of these are only needed by T::Props::Serializable or T::Struct # and can/should be moved accordingly. type_is_custom_type: cls.singleton_class < T::Props::CustomType, type_is_serializable: cls < T::Props::Serializable, type_is_array_of_serializable: !array_subdoc_type.nil?, type_is_hash_of_serializable_values: !hash_value_subdoc_type.nil?, type_is_hash_of_custom_type_keys: !hash_key_custom_type.nil?, type_object: type_object, type_needs_clone: needs_clone, 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 if type.is_a?(T::Types::TypedArray) && type.type.is_a?(T::Types::Simple) rules[:array] = type.type.raw_type elsif array_subdoc_type rules[:array] = array_subdoc_type end if rules[:type_is_serializable] rules[:serializable_subtype] = cls elsif array_subdoc_type rules[:serializable_subtype] = array_subdoc_type elsif hash_value_subdoc_type && hash_key_custom_type rules[:serializable_subtype] = { keys: hash_key_custom_type, values: hash_value_subdoc_type, } elsif hash_value_subdoc_type rules[:serializable_subtype] = hash_value_subdoc_type elsif hash_key_custom_type rules[:serializable_subtype] = hash_key_custom_type end 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(cls, rules[:foreign_hint_only]) if rules[:foreign_hint_only] handle_redaction_option(name, rules[:redaction]) if rules[:redaction] end
def prop_get(instance, prop, rules=props[prop.to_sym])
def prop_get(instance, prop, rules=props[prop.to_sym]) val = get(instance, prop, rules) # NB: Do NOT change this to check `val.nil?` instead. BSON::ByteBuffer overrides `==` such # that `== nil` can return true while `.nil?` returns false. Tests will break in mysterious # ways. A special thanks to Ruby for enabling this type of bug. # # One side effect here is that _if_ a class (like BSON::ByteBuffer) defines == # in such a way that instances which are not `nil`, ie are not NilClass, nevertheless # are `== nil`, then we will transparently convert such instances to `nil` on read. # Yes, our code relies on this behavior (as of writing). :thisisfine: if val != nil # rubocop:disable Style/NonNilCheck val else raise NoRulesError.new if !rules d = rules[:ifunset] if d T::Props::Utils.deep_clone_object(d) else nil end end end
def prop_rules(prop); props[prop.to_sym] || raise("No such prop: #{prop.inspect}"); end
def prop_rules(prop); props[prop.to_sym] || raise("No such prop: #{prop.inspect}"); end
def prop_set(instance, prop, val, rules=prop_rules(prop))
def prop_set(instance, prop, val, rules=prop_rules(prop)) check_prop_type(prop, val, T.must(rules)) set(instance, prop, val, rules) end
def prop_validate_definition!(name, cls, rules, type)
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 - valid_props).empty? raise ArgumentError.new("At least one invalid prop arg supplied in #{self}: #{rules.keys.inspect}") end if (array = rules[:array]) unless array.is_a?(Module) raise ArgumentError.new("Bad class as subtype in prop #{@class.name}.#{name}: #{array.inspect}") end end if !(rules[:clobber_existing_method!]) && !(rules[:without_accessors]) # TODO: we should really be checking all the methods on `cls`, not just Object if Object.instance_methods.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 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
def props
def props @props ||= {}.freeze end
def set(instance, prop, value, rules=props[prop.to_sym])
def set(instance, prop, value, rules=props[prop.to_sym]) # For backwards compatibility, fall back to reconstructing the accessor key # (though it would probably make more sense to raise in that case). instance.instance_variable_set(rules ? rules[:accessor_key] : '@' + prop.to_s, value) # rubocop:disable PrisonGuard/NoLurkyInstanceVariableAccess end
def shallow_clone_ok(type)
def shallow_clone_ok(type) type = ype.is_a?(T::Types::TypedArray) pe.type f type.is_a?(T::Types::TypedSet) pe.type f type.is_a?(T::Types::TypedHash) pe.values type.is_a?(T::Types::Simple) && TYPES_NOT_NEEDING_CLONE.any? do |cls| r_type.raw_type <= cls
def smart_coerce(type, array:, enum:)
def smart_coerce(type, array:, enum:) wards compatibility for pre-T::Types style ray.nil? && !enum.nil? e ArgumentError.new("Cannot specify both :array and :enum options") !array.nil? ype == Set :Set[array] :Array[array] !enum.nil? ::Utils.unwrap_nilable(type) nilable(T.enum(enum)) enum(enum) tils.coerce(type)
def valid_props
def valid_props %i{ enum foreign foreign_hint_only ifunset immutable override redaction sensitivity without_accessors clobber_existing_method! extra optional _tnilable } end
def validate_foreign_option(option_sym, foreign, valid_type_msg:)
def validate_foreign_option(option_sym, foreign, valid_type_msg:) eign.is_a?(Symbol) || foreign.is_a?(String) e ArgumentError.new( sing a symbol/string for `#{option_sym}` is no longer supported. Instead, use a Proc " \ hat returns the class, e.g., foreign: -> {Foo}" reign.is_a?(Proc) && !foreign.is_a?(Array) && !foreign.respond_to?(:load) e ArgumentError.new("The `#{option_sym}` option must be #{valid_type_msg}")
def validate_not_missing_sensitivity(prop_name, rules)
def validate_not_missing_sensitivity(prop_name, rules) es[:sensitivity].nil? ules[:redaction] :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: []'." DO(PRIVACYENG-982) Ideally we'd also check for 'password' and possibly her terms, but this interacts badly with ProtoDefinedDocument because e proto syntax currently can't declare "sensitivity: []" rop_name =~ /\bsecret\b/ :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: []'."
def validate_prop_name(name)
def validate_prop_name(name) e !~ /\A[A-Za-z_][A-Za-z0-9_-]*\z/ e ArgumentError.new("Invalid prop name in #{@class.name}: #{name}")
def validate_prop_value(prop, val)
def validate_prop_value(prop, val) # This implements a 'public api' on document so that we don't allow callers to pass in rules # Rules seem like an implementation detail so it seems good to now allow people to specify them manually. check_prop_type(prop, val) end