Merge pull request #189 from dry-rb/improved-input-processor-handling

Separate type specs / coercions from predicates
This commit is contained in:
Piotr Solnica 2016-06-19 18:40:08 +02:00 committed by GitHub
commit d6bb0dec86
12 changed files with 483 additions and 35 deletions

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -5,6 +5,7 @@ module Dry
class Schema::Form < Schema
configure do |config|
config.input_processor = :form
config.hash_type = :symbolized
end
end
end

View File

@ -5,6 +5,7 @@ module Dry
class Schema::JSON < Schema
configure do |config|
config.input_processor = :json
config.hash_type = :symbolized
end
end
end

View File

@ -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.'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!