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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +1 -1
  3. data/CHANGELOG.md +39 -1
  4. data/benchmarks/benchmark_schema_invalid_huge.rb +52 -0
  5. data/benchmarks/profile_schema_huge_invalid.rb +30 -0
  6. data/config/errors.yml +3 -2
  7. data/dry-validation.gemspec +2 -2
  8. data/lib/dry/validation.rb +20 -32
  9. data/lib/dry/validation/constants.rb +6 -0
  10. data/lib/dry/validation/error.rb +5 -2
  11. data/lib/dry/validation/error_compiler.rb +46 -116
  12. data/lib/dry/validation/executor.rb +105 -0
  13. data/lib/dry/validation/hint_compiler.rb +36 -68
  14. data/lib/dry/validation/message.rb +86 -0
  15. data/lib/dry/validation/message_compiler.rb +141 -0
  16. data/lib/dry/validation/message_set.rb +70 -0
  17. data/lib/dry/validation/messages/abstract.rb +1 -1
  18. data/lib/dry/validation/messages/i18n.rb +5 -0
  19. data/lib/dry/validation/predicate_registry.rb +8 -3
  20. data/lib/dry/validation/result.rb +6 -7
  21. data/lib/dry/validation/schema.rb +21 -227
  22. data/lib/dry/validation/schema/check.rb +1 -1
  23. data/lib/dry/validation/schema/class_interface.rb +193 -0
  24. data/lib/dry/validation/schema/deprecated.rb +1 -2
  25. data/lib/dry/validation/schema/key.rb +4 -0
  26. data/lib/dry/validation/schema/value.rb +12 -7
  27. data/lib/dry/validation/schema_compiler.rb +20 -1
  28. data/lib/dry/validation/type_specs.rb +70 -0
  29. data/lib/dry/validation/version.rb +1 -1
  30. data/spec/fixtures/locales/pl.yml +1 -1
  31. data/spec/integration/custom_predicates_spec.rb +37 -0
  32. data/spec/integration/error_compiler_spec.rb +39 -39
  33. data/spec/integration/form/predicates/key_spec.rb +10 -18
  34. data/spec/integration/form/predicates/size/fixed_spec.rb +8 -12
  35. data/spec/integration/form/predicates/size/range_spec.rb +7 -7
  36. data/spec/integration/hints_spec.rb +17 -0
  37. data/spec/integration/messages/i18n_spec.rb +2 -2
  38. data/spec/integration/schema/check_rules_spec.rb +2 -2
  39. data/spec/integration/schema/defining_base_schema_spec.rb +38 -0
  40. data/spec/integration/schema/dynamic_predicate_args_spec.rb +18 -0
  41. data/spec/integration/schema/macros/each_spec.rb +2 -2
  42. data/spec/integration/schema/macros/input_spec.rb +102 -10
  43. data/spec/integration/schema/macros/maybe_spec.rb +30 -0
  44. data/spec/integration/schema/nested_schemas_spec.rb +200 -0
  45. data/spec/integration/schema/nested_values_spec.rb +3 -1
  46. data/spec/integration/schema/option_with_default_spec.rb +54 -20
  47. data/spec/integration/schema/predicates/size/fixed_spec.rb +10 -10
  48. data/spec/integration/schema/predicates/size/range_spec.rb +8 -10
  49. data/spec/unit/error_compiler_spec.rb +1 -1
  50. data/spec/unit/hint_compiler_spec.rb +2 -2
  51. metadata +18 -7
  52. data/examples/rule_ast.rb +0 -25
  53. 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/error_compiler/input'
1
+ require 'dry/validation/message_compiler'
2
2
 
3
3
  module Dry
4
4
  module Validation
5
- class HintCompiler < ErrorCompiler::Input
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 = [:none?, :filled?, :key?].freeze
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, DEFAULT_OPTIONS.merge(options))
36
- @rules = @options.delete(:rules)
37
- @excluded = @options.fetch(:excluded, 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 hash
43
- @hash ||= [messages, rules, options].hash
37
+ def message_type
38
+ :hint
44
39
  end
45
40
 
46
- def with(new_options)
47
- return self if new_options.empty?
48
- super(new_options.merge(rules: rules))
41
+ def message_class
42
+ Hint
49
43
  end
50
44
 
51
- def call
52
- cache.fetch_or_store(hash) { super(rules) }
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 visit_set(node)
67
- result = node.map do |el|
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 visit_each(node)
74
- visit(node)
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 visit_or(node)
78
- left, right = node
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 visit_and(node)
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 visit_implication(node)
68
+ def visit_and(node, *args)
95
69
  _, right = node
96
- visit(right)
70
+ visit(right, *args)
97
71
  end
98
72
 
99
- def visit_key(node)
100
- name, predicate = node
101
- with(name: Array([*self.name, name])).visit(predicate)
102
- end
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
- def visit_schema(node)
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
- DEFAULT_RESULT
82
+ EMPTY_ARRAY
115
83
  end
116
84
 
117
85
  def visit_xor(node)
118
- DEFAULT_RESULT
86
+ EMPTY_ARRAY
119
87
  end
120
88
 
121
89
  def visit_not(node)
122
- DEFAULT_RESULT
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 merge(result)
132
- super(result.reject { |el| el.is_a?(self.class) })
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