# frozen_string_literal: true module Dry module Types # The built-in Hash type can be defined in terms of keys and associated types # its values can contain. Such definitions are named {Schema}s and defined # as lists of {Key} types. # # @see Dry::Types::Schema::Key # # {Schema} evaluates default values for keys missing in input hash # # @see Dry::Types::Default#evaluate # @see Dry::Types::Default::Callable#evaluate # # {Schema} implements Enumerable using its keys as collection. # # @api public class Schema < Hash NO_TRANSFORM = ::Dry::Types::FnContainer.register { |x| x } SYMBOLIZE_KEY = ::Dry::Types::FnContainer.register(:to_sym.to_proc) include ::Enumerable # @return [Array[Dry::Types::Schema::Key]] attr_reader :keys # @return [Hash[Symbol, Dry::Types::Schema::Key]] attr_reader :name_key_map # @return [#call] attr_reader :transform_key # @param [Class] _primitive # @param [Hash] options # # @option options [Array[Dry::Types::Schema::Key]] :keys # @option options [String] :key_transform_fn # # @api private def initialize(_primitive, **options) @keys = options.fetch(:keys) @name_key_map = keys.each_with_object({}) do |key, idx| idx[key.name] = key end key_fn = options.fetch(:key_transform_fn, NO_TRANSFORM) @transform_key = ::Dry::Types::FnContainer[key_fn] super end # @param [Hash] hash # # @return [Hash{Symbol => Object}] # # @api private def call_unsafe(hash, options = EMPTY_HASH) resolve_unsafe(coerce(hash), options) end # @param [Hash] hash # # @return [Hash{Symbol => Object}] # # @api private def call_safe(hash, options = EMPTY_HASH) resolve_safe(coerce(hash) { return yield }, options) { return yield } end # @param [Hash] hash # # @option options [Boolean] :skip_missing If true don't raise error if on missing keys # @option options [Boolean] :resolve_defaults If false default value # won't be evaluated for missing key # @return [Hash{Symbol => Object}] # # @api public def apply(hash, options = EMPTY_HASH) = call_unsafe(hash, options) # @param input [Hash] hash # # @yieldparam [Failure] failure # @yieldreturn [Result] # # @return [Logic::Result] # @return [Object] if coercion fails and a block is given # # @api public # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/PerceivedComplexity def try(input) if primitive?(input) success = true output = {} result = {} input.each do |key, value| k = @transform_key.(key) type = @name_key_map[k] if type key_result = type.try(value) result[k] = key_result output[k] = key_result.input success &&= key_result.success? elsif strict? success = false end end if output.size < keys.size resolve_missing_keys(output, options) do success = false end end success &&= primitive?(output) if success failure = nil else error = CoercionError.new("#{input} doesn't conform schema", meta: result) failure = failure(output, error) end else failure = failure(input, CoercionError.new("#{input} must be a hash")) end if failure.nil? success(output) elsif block_given? yield(failure) else failure end end # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/PerceivedComplexity # @param meta [Boolean] Whether to dump the meta to the AST # # @return [Array] An AST representation # # @api public def to_ast(meta: true) [ :schema, [keys.map { |key| key.to_ast(meta: meta) }, options.slice(:key_transform_fn, :type_transform_fn, :strict), meta ? self.meta : EMPTY_HASH] ] end # Whether the schema rejects unknown keys # # @return [Boolean] # # @api public def strict? options.fetch(:strict, false) end # Make the schema intolerant to unknown keys # # @return [Schema] # # @api public def strict(strict = true) # rubocop:disable Style/OptionalBooleanParameter with(strict: strict) end # Inject a key transformation function # # @param [#call,nil] proc # @param [#call,nil] block # # @return [Schema] # # @api public def with_key_transform(proc = nil, &block) fn = proc || block raise ::ArgumentError, "a block or callable argument is required" if fn.nil? handle = ::Dry::Types::FnContainer.register(fn) with(key_transform_fn: handle) end # Whether the schema transforms input keys # # @return [Boolean] # # @api public def transform_keys? !options[:key_transform_fn].nil? end # @overload schema(type_map, meta = EMPTY_HASH) # @param [{Symbol => Dry::Types::Nominal}] type_map # @param [Hash] meta # @return [Dry::Types::Schema] # # @overload schema(keys) # @param [Array] key List of schema keys # @param [Hash] meta # @return [Dry::Types::Schema] # # @api public def schema(keys_or_map) if keys_or_map.is_a?(::Array) new_keys = keys_or_map else new_keys = build_keys(keys_or_map) end keys = merge_keys(self.keys, new_keys) Schema.new(primitive, **options, keys: keys, meta: meta) end # Iterate over each key type # # @return [Array,Enumerator] # # @api public def each(&) keys.each(&) end # Whether the schema has the given key # # @param [Symbol] name Key name # # @return [Boolean] # # @api public def key?(name) name_key_map.key?(name) end # Fetch key type by a key name # # Behaves as ::Hash#fetch # # @overload key(name, fallback = Undefined) # @param [Symbol] name Key name # @param [Object] fallback Optional fallback, returned if key is missing # @return [Dry::Types::Schema::Key,Object] key type or fallback if key is not in schema # # @overload key(name, &block) # @param [Symbol] name Key name # @param [Proc] block Fallback block, runs if key is missing # @return [Dry::Types::Schema::Key,Object] key type or block value if key is not in schema # # @api public def key(name, fallback = Undefined, &) if Undefined.equal?(fallback) name_key_map.fetch(name, &) else name_key_map.fetch(name, fallback) end end # @return [Boolean] # # @api public def constrained? true end # @return [Lax] # # @api public def lax Lax.new(schema(keys.map(&:lax))) end # Merge given schema keys into current schema # # A new instance is returned. # # @param other [Schema] schema # @return [Schema] # # @api public def merge(other) schema(other.keys) end # Empty schema with the same options # # @return [Schema] # # @api public def clear with(keys: EMPTY_ARRAY) end private # @param [Array] keys # # @return [Dry::Types::Schema] # # @api private def merge_keys(*keys) keys .flatten(1) .each_with_object({}) { |key, merged| merged[key.name] = key } .values end # Validate and coerce a hash. Raise an exception on any error # # @api private # # @return [Hash] def resolve_unsafe(hash, options = EMPTY_HASH) result = {} hash.each do |key, value| k = @transform_key.(key) type = @name_key_map[k] if type begin result[k] = type.call_unsafe(value) rescue ConstraintError => e raise SchemaError.new(type.name, value, e.result) rescue CoercionError => e raise SchemaError.new(type.name, value, e.message) end elsif strict? raise unexpected_keys(hash.keys) end end resolve_missing_keys(result, options) if result.size < keys.size result end # Validate and coerce a hash. Call a block and halt on any error # # @api private # # @return [Hash] def resolve_safe(hash, options = EMPTY_HASH, &block) result = {} hash.each do |key, value| k = @transform_key.(key) type = @name_key_map[k] if type result[k] = type.call_safe(value, &block) elsif strict? yield end end resolve_missing_keys(result, options, &block) if result.size < keys.size result end # Try to add missing keys to the hash # # @api private def resolve_missing_keys(hash, options) # rubocop:disable Metrics/PerceivedComplexity skip_missing = options.fetch(:skip_missing, false) resolve_defaults = options.fetch(:resolve_defaults, true) keys.each do |key| next if hash.key?(key.name) if key.default? && resolve_defaults hash[key.name] = key.call_unsafe(Undefined) elsif key.required? && !skip_missing if block_given? return yield else raise missing_key(key.name) end end end end # @param hash_keys [Array] # # @return [UnknownKeysError] # # @api private def unexpected_keys(hash_keys) extra_keys = hash_keys.map(&transform_key) - name_key_map.keys UnknownKeysError.new(extra_keys) end # @return [MissingKeyError] # # @api private def missing_key(key) MissingKeyError.new(key) end end end end