Merge pull request #189 from dry-rb/improved-input-processor-handling
Separate type specs / coercions from predicates
This commit is contained in:
commit
d6bb0dec86
|
@ -33,6 +33,7 @@ module Dry
|
|||
config.rules = config.rules + (options.fetch(:rules, []) + dsl.rules)
|
||||
config.checks = config.checks + dsl.checks
|
||||
config.path = dsl.path
|
||||
config.type_map = klass.build_type_map(dsl.type_map) if config.type_specs
|
||||
end
|
||||
|
||||
if options[:build] == false
|
||||
|
|
|
@ -12,7 +12,7 @@ require 'dry/validation/messages'
|
|||
require 'dry/validation/error_compiler'
|
||||
require 'dry/validation/hint_compiler'
|
||||
|
||||
require 'dry/validation/input_processor_compiler'
|
||||
require 'dry/validation/schema/deprecated'
|
||||
|
||||
module Dry
|
||||
module Validation
|
||||
|
@ -30,25 +30,76 @@ module Dry
|
|||
setting :rules, []
|
||||
setting :checks, []
|
||||
setting :options, {}
|
||||
setting :type_map, {}
|
||||
setting :hash_type, :weak
|
||||
|
||||
setting :input_processor, :noop
|
||||
|
||||
setting :input_processor_map, {
|
||||
sanitizer: InputProcessorCompiler::Sanitizer.new,
|
||||
json: InputProcessorCompiler::JSON.new,
|
||||
form: InputProcessorCompiler::Form.new,
|
||||
}.freeze
|
||||
|
||||
setting :type_specs, false
|
||||
|
||||
def self.inherited(klass)
|
||||
super
|
||||
klass.config.options = klass.config.options.dup
|
||||
klass.set_registry!
|
||||
end
|
||||
|
||||
def self.clone
|
||||
klass = Class.new(self)
|
||||
klass.config.rules = []
|
||||
klass
|
||||
end
|
||||
|
||||
def self.set_registry!
|
||||
config.registry = PredicateRegistry[self, config.predicates]
|
||||
end
|
||||
|
||||
def self.registry
|
||||
config.registry
|
||||
end
|
||||
|
||||
def self.build_type_map(type_specs, category = config.input_processor)
|
||||
hash_type = config.hash_type
|
||||
|
||||
type_specs.each_with_object({}) do |(name, spec), result|
|
||||
result[name] =
|
||||
case spec
|
||||
when Hash
|
||||
lookup_type("hash", category).public_send(hash_type, spec)
|
||||
when Array
|
||||
if spec.size == 1 && spec[0].is_a?(Hash)
|
||||
member_schema = build_type_map(spec[0], category)
|
||||
member_type = lookup_type("hash", category)
|
||||
.public_send(hash_type, member_schema)
|
||||
|
||||
lookup_type("array", category).member(member_type)
|
||||
else
|
||||
spec
|
||||
.map { |id| id.is_a?(Symbol) ? lookup_type(id, category) : id }
|
||||
.reduce(:|)
|
||||
end
|
||||
when Symbol
|
||||
lookup_type(spec, category)
|
||||
else
|
||||
spec
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.lookup_type(name, category)
|
||||
id = "#{category}.#{name}"
|
||||
Types.type_keys.include?(id) ? Types[id] : Types[name.to_s]
|
||||
end
|
||||
|
||||
|
||||
def self.type_map
|
||||
config.type_map
|
||||
end
|
||||
|
||||
def self.predicates(predicate_set = nil)
|
||||
if predicate_set
|
||||
config.predicates = predicate_set
|
||||
|
@ -130,33 +181,10 @@ module Dry
|
|||
@hint_compiler ||= HintCompiler.new(messages, rules: rule_ast)
|
||||
end
|
||||
|
||||
def self.input_processor
|
||||
@input_processor ||=
|
||||
begin
|
||||
if input_processor_compiler
|
||||
input_processor_compiler.(rule_ast)
|
||||
else
|
||||
NOOP_INPUT_PROCESSOR
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.input_processor_ast(type)
|
||||
config.input_processor_map.fetch(type).schema_ast(rule_ast)
|
||||
end
|
||||
|
||||
def self.input_processor_compiler
|
||||
@input_processor_comp ||= config.input_processor_map[config.input_processor]
|
||||
end
|
||||
|
||||
def self.rule_ast
|
||||
@rule_ast ||= config.rules.flat_map(&:rules).map(&:to_ast)
|
||||
end
|
||||
|
||||
def self.registry
|
||||
config.registry
|
||||
end
|
||||
|
||||
def self.default_options
|
||||
{ predicate_registry: registry,
|
||||
error_compiler: error_compiler,
|
||||
|
@ -181,7 +209,10 @@ module Dry
|
|||
|
||||
attr_reader :options
|
||||
|
||||
attr_reader :type_map
|
||||
|
||||
def initialize(rules, options)
|
||||
@type_map = self.class.type_map
|
||||
@predicates = options.fetch(:predicate_registry).bind(self)
|
||||
@rule_compiler = SchemaCompiler.new(predicates)
|
||||
@error_compiler = options.fetch(:error_compiler)
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
require 'dry/validation/input_processor_compiler'
|
||||
|
||||
module Dry
|
||||
module Validation
|
||||
class Schema
|
||||
def self.input_processor
|
||||
@input_processor ||=
|
||||
begin
|
||||
if type_map.size > 0 && config.input_processor != :noop
|
||||
lookup_type("hash", config.input_processor)
|
||||
.public_send(config.hash_type, type_map)
|
||||
elsif input_processor_compiler
|
||||
input_processor_compiler.(rule_ast)
|
||||
else
|
||||
NOOP_INPUT_PROCESSOR
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.input_processor_ast(type)
|
||||
config.input_processor_map.fetch(type).schema_ast(rule_ast)
|
||||
end
|
||||
|
||||
def self.input_processor_compiler
|
||||
@input_processor_comp ||= config.input_processor_map[config.input_processor]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -24,8 +24,14 @@ module Dry
|
|||
end
|
||||
alias_method :to_s, :inspect
|
||||
|
||||
def optional(name, &block)
|
||||
define(name, Key, :then, &block)
|
||||
def optional(name, type_spec = nil, &block)
|
||||
rule = define(name, Key, :then, &block)
|
||||
|
||||
if type_spec
|
||||
type_map[name] = type_spec
|
||||
end
|
||||
|
||||
rule
|
||||
end
|
||||
|
||||
def not
|
||||
|
@ -72,8 +78,8 @@ module Dry
|
|||
type = key_class.type
|
||||
|
||||
val = Value[
|
||||
name, registry: registry, type: type, parent: self,
|
||||
rules: rules, checks: checks
|
||||
name, registry: registry, type: type, parent: self, rules: rules,
|
||||
checks: checks, schema_class: schema_class.clone
|
||||
].__send__(:"#{type}?", name)
|
||||
|
||||
if block
|
||||
|
|
|
@ -5,6 +5,7 @@ module Dry
|
|||
class Schema::Form < Schema
|
||||
configure do |config|
|
||||
config.input_processor = :form
|
||||
config.hash_type = :symbolized
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,7 @@ module Dry
|
|||
class Schema::JSON < Schema
|
||||
configure do |config|
|
||||
config.input_processor = :json
|
||||
config.hash_type = :symbolized
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -26,6 +26,11 @@ module Dry
|
|||
|
||||
def schema(other = nil, &block)
|
||||
schema = Schema.create_class(target, other, &block)
|
||||
|
||||
if schema.config.type_specs
|
||||
target.type_map[name] = schema.type_map
|
||||
end
|
||||
|
||||
rule = __send__(type, key(:hash?).and(key(schema)))
|
||||
add_rule(rule)
|
||||
end
|
||||
|
@ -34,6 +39,14 @@ module Dry
|
|||
target.schema?
|
||||
end
|
||||
|
||||
def type_map
|
||||
target.type_map
|
||||
end
|
||||
|
||||
def type_map?
|
||||
target.type_map?
|
||||
end
|
||||
|
||||
def required(*predicates)
|
||||
::Kernel.warn 'required is deprecated - use filled instead.'
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ module Dry
|
|||
super
|
||||
@type = options.fetch(:type, :key)
|
||||
@schema_class = options.fetch(:schema_class, ::Class.new(Schema))
|
||||
@type_map = {}
|
||||
@options = options.merge(type: @type, schema_class: @schema_class)
|
||||
@type_map = parent && parent.root? ? parent.type_map : {}
|
||||
end
|
||||
|
||||
def key(name, &block)
|
||||
|
@ -19,12 +20,19 @@ module Dry
|
|||
required(name, &block)
|
||||
end
|
||||
|
||||
def required(name, &block)
|
||||
define(name, Key, &block)
|
||||
def required(name, type_spec = nil, &block)
|
||||
rule = define(name, Key, &block)
|
||||
|
||||
if type_spec
|
||||
type_map[name] = type_spec
|
||||
end
|
||||
|
||||
rule
|
||||
end
|
||||
|
||||
def schema(other = nil, &block)
|
||||
@schema = Schema.create_class(self, other, &block)
|
||||
type_map.update(@schema.type_map)
|
||||
hash?.and(@schema)
|
||||
end
|
||||
|
||||
|
@ -35,7 +43,12 @@ module Dry
|
|||
if predicates.size > 0
|
||||
create_rule([:each, infer_predicates(predicates, new).to_ast])
|
||||
else
|
||||
val = Value[name, registry: registry].instance_eval(&block)
|
||||
val = Value[
|
||||
name, registry: registry, schema_class: schema_class.clone
|
||||
].instance_eval(&block)
|
||||
|
||||
type_map[name] = [val.type_map] if val.schema? && val.type_map?
|
||||
|
||||
create_rule([:each, val.to_ast])
|
||||
end
|
||||
|
||||
|
@ -97,6 +110,10 @@ module Dry
|
|||
name.nil?
|
||||
end
|
||||
|
||||
def type_map?
|
||||
! type_map.empty?
|
||||
end
|
||||
|
||||
def schema?
|
||||
! @schema.nil?
|
||||
end
|
||||
|
|
|
@ -0,0 +1,169 @@
|
|||
RSpec.describe Dry::Validation::Schema::Form, 'explicit types' do
|
||||
context 'single type spec without rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Form do
|
||||
configure { config.type_specs = true }
|
||||
required(:age, :int)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion' do
|
||||
expect(schema.('age' => '19').to_h).to eql(age: 19)
|
||||
end
|
||||
end
|
||||
|
||||
context 'single type spec with rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Form do
|
||||
configure { config.type_specs = true }
|
||||
required(:age, :int).value(:int?, gt?: 18)
|
||||
end
|
||||
end
|
||||
|
||||
it 'applies rules to coerced value' do
|
||||
expect(schema.(age: 19).messages).to be_empty
|
||||
expect(schema.(age: 18).messages).to eql(age: ['must be greater than 18'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'sum type spec without rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Form do
|
||||
configure { config.type_specs = true }
|
||||
required(:age, [:nil, :int])
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion' do
|
||||
expect(schema.('age' => '19').to_h).to eql(age: 19)
|
||||
expect(schema.('age' => '').to_h).to eql(age: nil)
|
||||
end
|
||||
end
|
||||
|
||||
context 'sum type spec with rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Form do
|
||||
configure { config.type_specs = true }
|
||||
required(:age, [:nil, :int]).maybe(:int?, gt?: 18)
|
||||
end
|
||||
end
|
||||
|
||||
it 'applies rules to coerced value' do
|
||||
expect(schema.(age: nil).messages).to be_empty
|
||||
expect(schema.(age: 19).messages).to be_empty
|
||||
expect(schema.(age: 18).messages).to eql(age: ['must be greater than 18'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'using a type object' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Form do
|
||||
configure { config.type_specs = true }
|
||||
required(:age, Types::Form::Nil | Types::Form::Int)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion' do
|
||||
expect(schema.('age' => '').to_h).to eql(age: nil)
|
||||
expect(schema.('age' => '19').to_h).to eql(age: 19)
|
||||
end
|
||||
end
|
||||
|
||||
context 'nested schema' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Form do
|
||||
configure { config.type_specs = true }
|
||||
|
||||
required(:user).schema do
|
||||
required(:email, :string)
|
||||
required(:age, :int)
|
||||
|
||||
required(:address).schema do
|
||||
required(:street, :string)
|
||||
required(:city, :string)
|
||||
required(:zipcode, :string)
|
||||
|
||||
required(:location).schema do
|
||||
required(:lat, :float)
|
||||
required(:lng, :float)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion for nested input' do
|
||||
input = {
|
||||
'user' => {
|
||||
'email' => 'jane@doe.org',
|
||||
'age' => '21',
|
||||
'address' => {
|
||||
'street' => 'Street 1',
|
||||
'city' => 'NYC',
|
||||
'zipcode' => '1234',
|
||||
'location' => { 'lat' => '1.23', 'lng' => '4.56' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(schema.(input).to_h).to eql(
|
||||
user: {
|
||||
email: 'jane@doe.org',
|
||||
age: 21,
|
||||
address: {
|
||||
street: 'Street 1',
|
||||
city: 'NYC',
|
||||
zipcode: '1234',
|
||||
location: { lat: 1.23, lng: 4.56 }
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'nested schema with arrays' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Form do
|
||||
configure { config.type_specs = true }
|
||||
|
||||
required(:song).schema do
|
||||
required(:title, :string)
|
||||
|
||||
required(:tags).each do
|
||||
schema do
|
||||
required(:name, :string)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'fails to coerce gracefuly' do
|
||||
result = schema.(song: nil)
|
||||
|
||||
expect(result.messages).to eql(song: ['must be a hash'])
|
||||
expect(result.to_h).to eql(song: nil)
|
||||
|
||||
result = schema.(song: { tags: nil })
|
||||
|
||||
expect(result.messages).to eql(song: { tags: ['must be an array'] })
|
||||
expect(result.to_h).to eql(song: { tags: nil })
|
||||
end
|
||||
|
||||
it 'uses form coercion for nested input' do
|
||||
input = {
|
||||
'song' => {
|
||||
'title' => 'dry-rb is awesome lala',
|
||||
'tags' => [{ 'name' => 'red' }, { 'name' => 'blue' }]
|
||||
}
|
||||
}
|
||||
|
||||
expect(schema.(input).to_h).to eql(
|
||||
song: {
|
||||
title: 'dry-rb is awesome lala',
|
||||
tags: [{ name: 'red' }, { name: 'blue' }]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,157 @@
|
|||
RSpec.describe Dry::Validation::Schema::JSON, 'explicit types' do
|
||||
context 'single type spec without rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.JSON do
|
||||
configure { config.type_specs = true }
|
||||
required(:bdate, :date)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses json coercion' do
|
||||
expect(schema.('bdate' => '2010-09-08').to_h).to eql(bdate: Date.new(2010, 9, 8))
|
||||
end
|
||||
end
|
||||
|
||||
context 'single type spec with rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.JSON do
|
||||
configure { config.type_specs = true }
|
||||
required(:bdate, :date).value(:date?, gt?: Date.new(2009))
|
||||
end
|
||||
end
|
||||
|
||||
it 'applies rules to coerced value' do
|
||||
expect(schema.(bdate: "2010-09-07").messages).to be_empty
|
||||
expect(schema.(bdate: "2008-01-01").messages).to eql(bdate: ['must be greater than 2009-01-01'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'sum type spec without rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.JSON do
|
||||
configure { config.type_specs = true }
|
||||
required(:bdate, [:nil, :date])
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion' do
|
||||
expect(schema.('bdate' => '2010-09-08').to_h).to eql(bdate: Date.new(2010, 9, 8))
|
||||
expect(schema.('bdate' => '').to_h).to eql(bdate: nil)
|
||||
end
|
||||
end
|
||||
|
||||
context 'sum type spec with rules' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.JSON do
|
||||
configure { config.type_specs = true }
|
||||
required(:bdate, [:nil, :date]).maybe(:date?, gt?: Date.new(2008))
|
||||
end
|
||||
end
|
||||
|
||||
it 'applies rules to coerced value' do
|
||||
expect(schema.(bdate: nil).messages).to be_empty
|
||||
expect(schema.(bdate: "2010-09-07").messages).to be_empty
|
||||
expect(schema.(bdate: "2008-01-01").messages).to eql(bdate: ['must be greater than 2008-01-01'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'using a type object' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.JSON do
|
||||
configure { config.type_specs = true }
|
||||
required(:bdate, Types::Json::Nil | Types::Json::Date)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion' do
|
||||
expect(schema.('bdate' => '').to_h).to eql(bdate: nil)
|
||||
expect(schema.('bdate' => '2010-09-08').to_h).to eql(bdate: Date.new(2010, 9, 8))
|
||||
end
|
||||
end
|
||||
|
||||
context 'nested schema' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.JSON do
|
||||
configure { config.type_specs = true }
|
||||
|
||||
required(:user).schema do
|
||||
required(:email, :string)
|
||||
required(:bdate, :date)
|
||||
|
||||
required(:address).schema do
|
||||
required(:street, :string)
|
||||
required(:city, :string)
|
||||
required(:zipcode, :string)
|
||||
|
||||
required(:location).schema do
|
||||
required(:lat, :float)
|
||||
required(:lng, :float)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion for nested input' do
|
||||
input = {
|
||||
'user' => {
|
||||
'email' => 'jane@doe.org',
|
||||
'bdate' => '2010-09-08',
|
||||
'address' => {
|
||||
'street' => 'Street 1',
|
||||
'city' => 'NYC',
|
||||
'zipcode' => '1234',
|
||||
'location' => { 'lat' => 1.23, 'lng' => 4.56 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(schema.(input).to_h).to eql(
|
||||
user: {
|
||||
email: 'jane@doe.org',
|
||||
bdate: Date.new(2010, 9, 8),
|
||||
address: {
|
||||
street: 'Street 1',
|
||||
city: 'NYC',
|
||||
zipcode: '1234',
|
||||
location: { lat: 1.23, lng: 4.56 }
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'nested schema with arrays' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.JSON do
|
||||
configure { config.type_specs = true }
|
||||
|
||||
required(:song).schema do
|
||||
required(:title, :string)
|
||||
|
||||
required(:tags).each do
|
||||
schema do
|
||||
required(:name, :string)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses form coercion for nested input' do
|
||||
input = {
|
||||
'song' => {
|
||||
'title' => 'dry-rb is awesome lala',
|
||||
'tags' => [{ 'name' => 'red' }, { 'name' => 'blue' }]
|
||||
}
|
||||
}
|
||||
|
||||
expect(schema.(input).to_h).to eql(
|
||||
song: {
|
||||
title: 'dry-rb is awesome lala',
|
||||
tags: [{ name: 'red' }, { name: 'blue' }]
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,8 +2,13 @@ RSpec.describe Dry::Validation::Schema, 'defining key-based schema' do
|
|||
describe 'with a flat structure' do
|
||||
subject(:schema) do
|
||||
Dry::Validation.Schema do
|
||||
required(:email).filled
|
||||
required(:age) { none? | (int? & gt?(18)) }
|
||||
configure do
|
||||
config.input_processor = :form
|
||||
config.type_specs = true
|
||||
end
|
||||
|
||||
required(:email, :string).filled
|
||||
required(:age, [:nil, :int]) { none? | (int? & gt?(18)) }
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -25,6 +30,20 @@ RSpec.describe Dry::Validation::Schema, 'defining key-based schema' do
|
|||
|
||||
expect(result.to_a).to eql([[:email, 'jane@doe'], [:age, 19]])
|
||||
end
|
||||
|
||||
describe '#type_map' do
|
||||
it 'returns key=>type map' do
|
||||
expect(schema.type_map).to eql(
|
||||
email: Types::String, age: Types::Form::Nil | Types::Form::Int
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses type_map for input processor when it is not empty' do
|
||||
expect(schema.(email: 'jane@doe.org', age: '18').to_h).to eql(
|
||||
email: 'jane@doe.org', age: 18
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'with nested structures' do
|
||||
|
|
|
@ -19,6 +19,10 @@ Dir[SPEC_ROOT.join('support/**/*.rb')].each(&method(:require))
|
|||
|
||||
include Dry::Validation
|
||||
|
||||
module Types
|
||||
include Dry::Types.module
|
||||
end
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.disable_monkey_patching!
|
||||
|
||||
|
|
Loading…
Reference in New Issue