dry-validation 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.travis.yml +1 -1
- data/CHANGELOG.md +39 -1
- data/benchmarks/benchmark_schema_invalid_huge.rb +52 -0
- data/benchmarks/profile_schema_huge_invalid.rb +30 -0
- data/config/errors.yml +3 -2
- data/dry-validation.gemspec +2 -2
- data/lib/dry/validation.rb +20 -32
- data/lib/dry/validation/constants.rb +6 -0
- data/lib/dry/validation/error.rb +5 -2
- data/lib/dry/validation/error_compiler.rb +46 -116
- data/lib/dry/validation/executor.rb +105 -0
- data/lib/dry/validation/hint_compiler.rb +36 -68
- data/lib/dry/validation/message.rb +86 -0
- data/lib/dry/validation/message_compiler.rb +141 -0
- data/lib/dry/validation/message_set.rb +70 -0
- data/lib/dry/validation/messages/abstract.rb +1 -1
- data/lib/dry/validation/messages/i18n.rb +5 -0
- data/lib/dry/validation/predicate_registry.rb +8 -3
- data/lib/dry/validation/result.rb +6 -7
- data/lib/dry/validation/schema.rb +21 -227
- data/lib/dry/validation/schema/check.rb +1 -1
- data/lib/dry/validation/schema/class_interface.rb +193 -0
- data/lib/dry/validation/schema/deprecated.rb +1 -2
- data/lib/dry/validation/schema/key.rb +4 -0
- data/lib/dry/validation/schema/value.rb +12 -7
- data/lib/dry/validation/schema_compiler.rb +20 -1
- data/lib/dry/validation/type_specs.rb +70 -0
- data/lib/dry/validation/version.rb +1 -1
- data/spec/fixtures/locales/pl.yml +1 -1
- data/spec/integration/custom_predicates_spec.rb +37 -0
- data/spec/integration/error_compiler_spec.rb +39 -39
- data/spec/integration/form/predicates/key_spec.rb +10 -18
- data/spec/integration/form/predicates/size/fixed_spec.rb +8 -12
- data/spec/integration/form/predicates/size/range_spec.rb +7 -7
- data/spec/integration/hints_spec.rb +17 -0
- data/spec/integration/messages/i18n_spec.rb +2 -2
- data/spec/integration/schema/check_rules_spec.rb +2 -2
- data/spec/integration/schema/defining_base_schema_spec.rb +38 -0
- data/spec/integration/schema/dynamic_predicate_args_spec.rb +18 -0
- data/spec/integration/schema/macros/each_spec.rb +2 -2
- data/spec/integration/schema/macros/input_spec.rb +102 -10
- data/spec/integration/schema/macros/maybe_spec.rb +30 -0
- data/spec/integration/schema/nested_schemas_spec.rb +200 -0
- data/spec/integration/schema/nested_values_spec.rb +3 -1
- data/spec/integration/schema/option_with_default_spec.rb +54 -20
- data/spec/integration/schema/predicates/size/fixed_spec.rb +10 -10
- data/spec/integration/schema/predicates/size/range_spec.rb +8 -10
- data/spec/unit/error_compiler_spec.rb +1 -1
- data/spec/unit/hint_compiler_spec.rb +2 -2
- metadata +18 -7
- data/examples/rule_ast.rb +0 -25
- data/lib/dry/validation/error_compiler/input.rb +0 -135
@@ -0,0 +1,105 @@
|
|
1
|
+
module Dry
|
2
|
+
module Validation
|
3
|
+
class ProcessInput
|
4
|
+
attr_reader :processor
|
5
|
+
|
6
|
+
def initialize(processor)
|
7
|
+
@processor = processor
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(input, *)
|
11
|
+
processor.(input)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
class ApplyInputRule
|
16
|
+
attr_reader :rule
|
17
|
+
|
18
|
+
def initialize(rule)
|
19
|
+
@rule = rule
|
20
|
+
end
|
21
|
+
|
22
|
+
def call(input, result)
|
23
|
+
rule_res = rule.(input)
|
24
|
+
result.update(nil => rule_res) unless rule_res.success?
|
25
|
+
input
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class ApplyRules
|
30
|
+
attr_reader :rules
|
31
|
+
|
32
|
+
def initialize(rules)
|
33
|
+
@rules = rules
|
34
|
+
end
|
35
|
+
|
36
|
+
def call(input, result)
|
37
|
+
rules.each_with_object(result) do |(name, rule), hash|
|
38
|
+
hash[name] = rule.(input)
|
39
|
+
end
|
40
|
+
input
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class ApplyChecks < ApplyRules
|
45
|
+
def call(input, result)
|
46
|
+
rules.each_with_object(result) do |(name, check), hash|
|
47
|
+
check_res = check.is_a?(Guard) ? check.(input, result) : check.(input)
|
48
|
+
hash[name] = check_res if check_res
|
49
|
+
end
|
50
|
+
input
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
class BuildErrors
|
55
|
+
attr_reader :path
|
56
|
+
|
57
|
+
def self.[](path)
|
58
|
+
path.nil? || path.empty? ? Flat.new : Nested.new(path)
|
59
|
+
end
|
60
|
+
|
61
|
+
class Flat < BuildErrors
|
62
|
+
def error_path(name)
|
63
|
+
name
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class Nested < BuildErrors
|
68
|
+
def initialize(path)
|
69
|
+
@path = path
|
70
|
+
end
|
71
|
+
|
72
|
+
def error_path(name)
|
73
|
+
[*path, name]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def call(result)
|
78
|
+
result
|
79
|
+
.select { |_, r| r.failure? }
|
80
|
+
.map { |name, r| Error.new(error_path(name), r) }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
class Executor
|
85
|
+
attr_reader :steps, :final
|
86
|
+
|
87
|
+
def self.new(path, &block)
|
88
|
+
super(BuildErrors[path]).tap { |executor| yield(executor.steps) }.freeze
|
89
|
+
end
|
90
|
+
|
91
|
+
def initialize(final)
|
92
|
+
@steps = []
|
93
|
+
@final = final
|
94
|
+
end
|
95
|
+
|
96
|
+
def call(input, result = {})
|
97
|
+
output = steps.reduce(input) do |a, e|
|
98
|
+
return [a, final.(result)] if result.key?(nil)
|
99
|
+
e.call(a, result)
|
100
|
+
end
|
101
|
+
[output, final.(result)]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
@@ -1,8 +1,8 @@
|
|
1
|
-
require 'dry/validation/
|
1
|
+
require 'dry/validation/message_compiler'
|
2
2
|
|
3
3
|
module Dry
|
4
4
|
module Validation
|
5
|
-
class HintCompiler <
|
5
|
+
class HintCompiler < MessageCompiler
|
6
6
|
include Dry::Equalizer(:messages, :rules, :options)
|
7
7
|
|
8
8
|
attr_reader :rules, :excluded, :cache
|
@@ -21,115 +21,83 @@ module Dry
|
|
21
21
|
array?: Array
|
22
22
|
}.freeze
|
23
23
|
|
24
|
-
EXCLUDED =
|
25
|
-
|
26
|
-
DEFAULT_OPTIONS = { name: nil, input: nil, message_type: :hint }.freeze
|
27
|
-
|
28
|
-
EMPTY_MESSAGES = {}.freeze
|
24
|
+
EXCLUDED = (%i(key? none? filled?) + TYPES.keys).freeze
|
29
25
|
|
30
26
|
def self.cache
|
31
27
|
@cache ||= Concurrent::Map.new
|
32
28
|
end
|
33
29
|
|
34
30
|
def initialize(messages, options = {})
|
35
|
-
super(messages,
|
36
|
-
@rules =
|
37
|
-
@excluded =
|
38
|
-
@val_type = options[:val_type]
|
31
|
+
super(messages, options)
|
32
|
+
@rules = options.fetch(:rules, EMPTY_ARRAY)
|
33
|
+
@excluded = options.fetch(:excluded, EXCLUDED)
|
39
34
|
@cache = self.class.cache
|
40
35
|
end
|
41
36
|
|
42
|
-
def
|
43
|
-
|
37
|
+
def message_type
|
38
|
+
:hint
|
44
39
|
end
|
45
40
|
|
46
|
-
def
|
47
|
-
|
48
|
-
super(new_options.merge(rules: rules))
|
41
|
+
def message_class
|
42
|
+
Hint
|
49
43
|
end
|
50
44
|
|
51
|
-
def
|
52
|
-
|
53
|
-
end
|
54
|
-
|
55
|
-
def visit_predicate(node)
|
56
|
-
predicate, _ = node
|
57
|
-
|
58
|
-
val_type = TYPES[predicate]
|
59
|
-
|
60
|
-
return with(val_type: val_type) if val_type
|
61
|
-
return EMPTY_MESSAGES if excluded.include?(predicate)
|
62
|
-
|
63
|
-
super
|
45
|
+
def hash
|
46
|
+
@hash ||= [messages, rules, options].hash
|
64
47
|
end
|
65
48
|
|
66
|
-
def
|
67
|
-
|
68
|
-
visit(el)
|
69
|
-
end
|
70
|
-
merge(result)
|
49
|
+
def call
|
50
|
+
cache.fetch_or_store(hash) { super(rules) }
|
71
51
|
end
|
72
52
|
|
73
|
-
def
|
74
|
-
|
53
|
+
def visit_predicate(node, opts = EMPTY_HASH)
|
54
|
+
predicate, args = node
|
55
|
+
return EMPTY_ARRAY if excluded.include?(predicate) || dyn_args?(args)
|
56
|
+
super(node, opts.merge(val_type: TYPES[predicate]))
|
75
57
|
end
|
76
58
|
|
77
|
-
def
|
78
|
-
|
79
|
-
merge([visit(left), visit(right)])
|
59
|
+
def visit_each(node, opts = EMPTY_HASH)
|
60
|
+
visit(node, opts.merge(each: true))
|
80
61
|
end
|
81
62
|
|
82
|
-
def
|
63
|
+
def visit_or(node, *args)
|
83
64
|
left, right = node
|
84
|
-
|
85
|
-
result = visit(left)
|
86
|
-
|
87
|
-
if result.is_a?(self.class)
|
88
|
-
result.visit(right)
|
89
|
-
else
|
90
|
-
visit(right)
|
91
|
-
end
|
65
|
+
[Array[visit(left, *args)], Array[visit(right, *args)]].flatten
|
92
66
|
end
|
93
67
|
|
94
|
-
def
|
68
|
+
def visit_and(node, *args)
|
95
69
|
_, right = node
|
96
|
-
visit(right)
|
70
|
+
visit(right, *args)
|
97
71
|
end
|
98
72
|
|
99
|
-
def
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
alias_method :visit_attr, :visit_key
|
104
|
-
|
105
|
-
def visit_val(node)
|
106
|
-
visit(node)
|
107
|
-
end
|
73
|
+
def visit_schema(node, opts = EMPTY_HASH)
|
74
|
+
path = node.config.path
|
75
|
+
rules = node.rule_ast
|
76
|
+
schema_opts = opts.merge(path: [path])
|
108
77
|
|
109
|
-
|
110
|
-
merge(node.rule_ast.map(&method(:visit)))
|
78
|
+
rules.map { |rule| visit(rule, schema_opts) }
|
111
79
|
end
|
112
80
|
|
113
81
|
def visit_check(node)
|
114
|
-
|
82
|
+
EMPTY_ARRAY
|
115
83
|
end
|
116
84
|
|
117
85
|
def visit_xor(node)
|
118
|
-
|
86
|
+
EMPTY_ARRAY
|
119
87
|
end
|
120
88
|
|
121
89
|
def visit_not(node)
|
122
|
-
|
90
|
+
EMPTY_ARRAY
|
123
91
|
end
|
124
92
|
|
125
|
-
def visit_type(node)
|
126
|
-
visit(node.rule.to_ast)
|
93
|
+
def visit_type(node, *args)
|
94
|
+
visit(node.rule.to_ast, *args)
|
127
95
|
end
|
128
96
|
|
129
97
|
private
|
130
98
|
|
131
|
-
def
|
132
|
-
|
99
|
+
def dyn_args?(args)
|
100
|
+
args.map(&:last).any? { |a| a.is_a?(UnboundMethod) }
|
133
101
|
end
|
134
102
|
end
|
135
103
|
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
require 'dry/validation/constants'
|
2
|
+
|
3
|
+
module Dry
|
4
|
+
module Validation
|
5
|
+
class Message
|
6
|
+
include Dry::Equalizer(:predicate, :path, :text, :options)
|
7
|
+
|
8
|
+
Index = Class.new {
|
9
|
+
def inspect
|
10
|
+
"index"
|
11
|
+
end
|
12
|
+
alias_method :to_s, :inspect
|
13
|
+
}.new
|
14
|
+
|
15
|
+
attr_reader :predicate, :path, :text, :rule, :args, :options
|
16
|
+
|
17
|
+
class Each < Message
|
18
|
+
def index_path
|
19
|
+
@index_path ||= [*path[0..path.size-2], Index]
|
20
|
+
end
|
21
|
+
|
22
|
+
def each?
|
23
|
+
true
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.[](predicate, path, text, options)
|
28
|
+
klass = options[:each] ? Message::Each : Message
|
29
|
+
klass.new(predicate, path, text, options)
|
30
|
+
end
|
31
|
+
|
32
|
+
def initialize(predicate, path, text, options)
|
33
|
+
@predicate = predicate
|
34
|
+
@path = path
|
35
|
+
@text = text
|
36
|
+
@options = options
|
37
|
+
@rule = options[:rule]
|
38
|
+
@each = options[:each] || false
|
39
|
+
@args = options[:args] || EMPTY_ARRAY
|
40
|
+
end
|
41
|
+
|
42
|
+
alias_method :index_path, :path
|
43
|
+
|
44
|
+
def to_s
|
45
|
+
text
|
46
|
+
end
|
47
|
+
|
48
|
+
def signature
|
49
|
+
@signature ||= [predicate, args, index_path].hash
|
50
|
+
end
|
51
|
+
|
52
|
+
def each?
|
53
|
+
@each
|
54
|
+
end
|
55
|
+
|
56
|
+
def hint?
|
57
|
+
false
|
58
|
+
end
|
59
|
+
|
60
|
+
def root?
|
61
|
+
path.empty?
|
62
|
+
end
|
63
|
+
|
64
|
+
def eql?(other)
|
65
|
+
other.is_a?(String) ? text == other : super
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
class Hint < Message
|
70
|
+
def self.[](predicate, path, text, options)
|
71
|
+
klass = options[:each] ? Hint::Each : Hint
|
72
|
+
klass.new(predicate, path, text, options)
|
73
|
+
end
|
74
|
+
|
75
|
+
class Each < Hint
|
76
|
+
def index_path
|
77
|
+
@index_path ||= [*path, Index]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def hint?
|
82
|
+
true
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'dry/validation/constants'
|
2
|
+
require 'dry/validation/message'
|
3
|
+
require 'dry/validation/message_set'
|
4
|
+
|
5
|
+
module Dry
|
6
|
+
module Validation
|
7
|
+
class MessageCompiler
|
8
|
+
attr_reader :messages, :options, :locale, :default_lookup_options
|
9
|
+
|
10
|
+
def initialize(messages, options = {})
|
11
|
+
@messages = messages
|
12
|
+
@options = options
|
13
|
+
@full = @options.fetch(:full, false)
|
14
|
+
@locale = @options.fetch(:locale, :en)
|
15
|
+
@default_lookup_options = { message_type: message_type, locale: locale }
|
16
|
+
end
|
17
|
+
|
18
|
+
def call(ast)
|
19
|
+
MessageSet[ast.map { |node| visit(node) }]
|
20
|
+
end
|
21
|
+
|
22
|
+
def full?
|
23
|
+
@full
|
24
|
+
end
|
25
|
+
|
26
|
+
def with(new_options)
|
27
|
+
return self if new_options.empty?
|
28
|
+
self.class.new(messages, options.merge(new_options))
|
29
|
+
end
|
30
|
+
|
31
|
+
def visit(node, *args)
|
32
|
+
__send__(:"visit_#{node[0]}", node[1], *args)
|
33
|
+
end
|
34
|
+
|
35
|
+
def visit_predicate(node, base_opts = EMPTY_HASH)
|
36
|
+
predicate, args = node
|
37
|
+
|
38
|
+
*arg_vals, _ = args.map(&:last)
|
39
|
+
|
40
|
+
tokens = message_tokens(args)
|
41
|
+
|
42
|
+
if base_opts[:message] == false
|
43
|
+
return [predicate, arg_vals, tokens]
|
44
|
+
end
|
45
|
+
|
46
|
+
options = base_opts.update(lookup_options(base_opts, arg_vals))
|
47
|
+
msg_opts = options.update(tokens)
|
48
|
+
|
49
|
+
name = msg_opts[:name]
|
50
|
+
rule = msg_opts[:rule] || name
|
51
|
+
|
52
|
+
template = messages[predicate, msg_opts]
|
53
|
+
|
54
|
+
unless template
|
55
|
+
raise MissingMessageError, "message for #{predicate} was not found"
|
56
|
+
end
|
57
|
+
|
58
|
+
text = message_text(rule, template, tokens, options)
|
59
|
+
path = message_path(msg_opts, name)
|
60
|
+
|
61
|
+
message_class[
|
62
|
+
predicate, path, text,
|
63
|
+
args: arg_vals, rule: rule, each: base_opts[:each] == true
|
64
|
+
]
|
65
|
+
end
|
66
|
+
|
67
|
+
def visit_key(node, opts = EMPTY_HASH)
|
68
|
+
name, predicate = node
|
69
|
+
visit(predicate, opts.merge(name: name))
|
70
|
+
end
|
71
|
+
|
72
|
+
def visit_val(node, opts = EMPTY_HASH)
|
73
|
+
visit(node, opts)
|
74
|
+
end
|
75
|
+
|
76
|
+
def visit_set(node, opts = EMPTY_HASH)
|
77
|
+
node.map { |input| visit(input, opts) }
|
78
|
+
end
|
79
|
+
|
80
|
+
def visit_el(node, opts = EMPTY_HASH)
|
81
|
+
idx, el = node
|
82
|
+
visit(el, opts.merge(path: opts[:path] + [idx]))
|
83
|
+
end
|
84
|
+
|
85
|
+
def visit_implication(node, *args)
|
86
|
+
_, right = node
|
87
|
+
visit(right, *args)
|
88
|
+
end
|
89
|
+
|
90
|
+
def visit_xor(node, *args)
|
91
|
+
_, right = node
|
92
|
+
visit(right, *args)
|
93
|
+
end
|
94
|
+
|
95
|
+
def lookup_options(_opts, arg_vals = [])
|
96
|
+
default_lookup_options.merge(
|
97
|
+
arg_type: arg_vals.size == 1 && arg_vals[0].class
|
98
|
+
)
|
99
|
+
end
|
100
|
+
|
101
|
+
def message_text(rule, template, tokens, opts)
|
102
|
+
text = template % tokens
|
103
|
+
|
104
|
+
if full?
|
105
|
+
rule_name = messages.rule(rule, opts) || rule
|
106
|
+
"#{rule_name} #{text}"
|
107
|
+
else
|
108
|
+
text
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def message_path(opts, name)
|
113
|
+
if name.is_a?(Array)
|
114
|
+
name
|
115
|
+
else
|
116
|
+
path = opts[:path] || Array(name)
|
117
|
+
|
118
|
+
if name && path.last != name
|
119
|
+
path += [name]
|
120
|
+
end
|
121
|
+
|
122
|
+
path
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def message_tokens(args)
|
127
|
+
args.each_with_object({}) { |arg, hash|
|
128
|
+
case arg[1]
|
129
|
+
when Array
|
130
|
+
hash[arg[0]] = arg[1].join(', ')
|
131
|
+
when Range
|
132
|
+
hash["#{arg[0]}_left".to_sym] = arg[1].first
|
133
|
+
hash["#{arg[0]}_right".to_sym] = arg[1].last
|
134
|
+
else
|
135
|
+
hash[arg[0]] = arg[1]
|
136
|
+
end
|
137
|
+
}
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|