dry-validation 1.0.0 → 1.5.6

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.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/monads/result'
4
-
5
3
  module Dry
6
4
  module Validation
7
5
  # Hints extension
@@ -46,7 +44,7 @@ module Dry
46
44
  #
47
45
  # @api public
48
46
  def messages(new_options = EMPTY_HASH)
49
- errors.with(hints.to_a, options.merge(**new_options))
47
+ errors.with(hints(new_options).to_a, options.merge(**new_options))
50
48
  end
51
49
 
52
50
  # Return hint messages
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/monads/result'
3
+ require "dry/monads/result"
4
4
 
5
5
  module Dry
6
6
  module Validation
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/schema/predicate_registry"
4
+ require "dry/validation/contract"
5
+
6
+ module Dry
7
+ module Validation
8
+ # Predicate registry with additional needed methods.
9
+ class PredicateRegistry < Schema::PredicateRegistry
10
+ # List of predicates to be imported by `:predicates_as_macros`
11
+ # extension.
12
+ #
13
+ # @see Dry::Validation::Contract
14
+ WHITELIST = %i[
15
+ filled? format? gt? gteq? included_in? includes? inclusion? is? lt?
16
+ lteq? max_size? min_size? not_eql? odd? respond_to? size? true?
17
+ uuid_v4?
18
+ ].freeze
19
+
20
+ # @api private
21
+ def arg_names(name)
22
+ arg_list(name).map(&:first)
23
+ end
24
+
25
+ # @api private
26
+ def call(name, args)
27
+ self[name].(*args)
28
+ end
29
+
30
+ # @api private
31
+ def message_opts(name, arg_values)
32
+ arg_names(name).zip(arg_values).to_h
33
+ end
34
+ end
35
+
36
+ # Extension to use dry-logic predicates as macros.
37
+ #
38
+ # @see Dry::Validation::PredicateRegistry::WHITELIST Available predicates
39
+ #
40
+ # @example
41
+ # Dry::Validation.load_extensions(:predicates_as_macros)
42
+ #
43
+ # class ApplicationContract < Dry::Validation::Contract
44
+ # import_predicates_as_macros
45
+ # end
46
+ #
47
+ # class AgeContract < ApplicationContract
48
+ # schema do
49
+ # required(:age).filled(:integer)
50
+ # end
51
+ #
52
+ # rule(:age).validate(gteq?: 18)
53
+ # end
54
+ #
55
+ # AgeContract.new.(age: 17).errors.first.text
56
+ # # => 'must be greater than or equal to 18'
57
+ #
58
+ # @api public
59
+ class Contract
60
+ # Make macros available for self and its descendants.
61
+ def self.import_predicates_as_macros
62
+ registry = PredicateRegistry.new
63
+
64
+ PredicateRegistry::WHITELIST.each do |name|
65
+ register_macro(name) do |macro:|
66
+ predicate_args = [*macro.args, value]
67
+ message_opts = registry.message_opts(name, predicate_args)
68
+
69
+ key.failure(name, message_opts) unless registry.(name, predicate_args)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/schema/path'
4
- require 'dry/validation/constants'
3
+ require "dry/schema/path"
4
+ require "dry/validation/constants"
5
5
 
6
6
  module Dry
7
7
  module Validation
@@ -45,14 +45,26 @@ module Dry
45
45
  # @example
46
46
  # failure(:taken)
47
47
  #
48
+ # @overload failure(meta_hash)
49
+ # Use meta_hash[:text] as a message (either explicitely or as an identifier),
50
+ # setting the rest of the hash as error meta attribute
51
+ # @param meta [Hash] The hash containing the message as value for the :text key
52
+ # @example
53
+ # failure({text: :invalid, key: value})
54
+ #
48
55
  # @see Evaluator#key
49
56
  # @see Evaluator#base
50
57
  #
51
58
  # @api public
52
59
  def failure(message, tokens = EMPTY_HASH)
53
- opts << { message: message, tokens: tokens, path: path }
60
+ opts << {message: message, tokens: tokens, path: path}
54
61
  self
55
62
  end
63
+
64
+ # @api private
65
+ def empty?
66
+ opts.empty?
67
+ end
56
68
  end
57
69
  end
58
70
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/initializer'
4
- require 'dry/validation/constants'
3
+ require "dry/initializer"
4
+ require "dry/validation/constants"
5
5
 
6
6
  module Dry
7
7
  module Validation
@@ -19,21 +19,24 @@ module Dry
19
19
  # @api private
20
20
  option :block
21
21
 
22
+ # @!attribute [r] block_options
23
+ # @return [Hash]
24
+ # @api private
25
+ option :block_options, default: -> { block ? map_keywords(block) : EMPTY_HASH }
26
+
22
27
  private
23
28
 
24
29
  # Extract options for the block kwargs
25
30
  #
26
- # @return [Hash]
31
+ # @param [Proc] block Callable
32
+ # @return Hash
27
33
  #
28
34
  # @api private
29
- def block_options
30
- return EMPTY_HASH unless block
31
-
32
- @block_options ||= block
35
+ def map_keywords(block)
36
+ block
33
37
  .parameters
34
- .select { |arg| arg[0].equal?(:keyreq) }
35
- .map(&:last)
36
- .map { |name| [name, BLOCK_OPTIONS_MAPPINGS[name]] }
38
+ .select { |arg,| arg.equal?(:keyreq) }
39
+ .map { |_, name| [name, BLOCK_OPTIONS_MAPPINGS[name]] }
37
40
  .to_h
38
41
  end
39
42
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/validation/constants'
4
- require 'dry/validation/function'
3
+ require "dry/validation/constants"
4
+ require "dry/validation/function"
5
5
 
6
6
  module Dry
7
7
  module Validation
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/container'
4
- require 'dry/validation/macro'
3
+ require "dry/container"
4
+ require "dry/validation/macro"
5
5
 
6
6
  module Dry
7
7
  module Validation
@@ -25,7 +25,7 @@ module Dry
25
25
  # end
26
26
  #
27
27
  # @param [Symbol] name The name of the macro
28
- # @param [Array] *args Optional default arguments for the macro
28
+ # @param [Array] args Optional default positional arguments for the macro
29
29
  #
30
30
  # @return [self]
31
31
  #
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/equalizer'
3
+ require "dry/equalizer"
4
4
 
5
- require 'dry/schema/constants'
6
- require 'dry/schema/message'
5
+ require "dry/schema/constants"
6
+ require "dry/schema/message"
7
7
 
8
8
  module Dry
9
9
  module Validation
@@ -52,7 +52,7 @@ module Dry
52
52
  #
53
53
  # @api public
54
54
  def evaluate(**opts)
55
- evaluated_text, rest = text.(opts)
55
+ evaluated_text, rest = text.(**opts)
56
56
  Message.new(evaluated_text, path: path, meta: rest.merge(meta))
57
57
  end
58
58
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/schema/message_set'
3
+ require "dry/schema/message_set"
4
4
 
5
- require 'dry/validation/constants'
6
- require 'dry/validation/message'
5
+ require "dry/validation/constants"
6
+ require "dry/validation/message"
7
7
 
8
8
  module Dry
9
9
  module Validation
@@ -41,7 +41,7 @@ module Dry
41
41
  return self if new_options.empty? && other.eql?(messages)
42
42
 
43
43
  self.class.new(
44
- (other + select { |err| err.is_a?(Message) }).uniq,
44
+ other | select { |err| err.is_a?(Message) },
45
45
  options.merge(source: source_messages, **new_options)
46
46
  ).freeze
47
47
  end
@@ -54,9 +54,9 @@ module Dry
54
54
  #
55
55
  # @api private
56
56
  def add(message)
57
+ @empty = nil
57
58
  source_messages << message
58
59
  messages << message
59
- initialize_placeholders!
60
60
  self
61
61
  end
62
62
 
@@ -85,58 +85,13 @@ module Dry
85
85
  # @api private
86
86
  def freeze
87
87
  source_messages.select { |err| err.respond_to?(:evaluate) }.each do |err|
88
- idx = source_messages.index(err)
88
+ idx = messages.index(err) || source_messages.index(err)
89
89
  msg = err.evaluate(locale: locale, full: options[:full])
90
90
  messages[idx] = msg
91
91
  end
92
92
  to_h
93
93
  self
94
94
  end
95
-
96
- private
97
-
98
- # @api private
99
- def unique_paths
100
- source_messages.uniq(&:path).map(&:path)
101
- end
102
-
103
- # @api private
104
- def messages_map
105
- @messages_map ||= reduce(placeholders) { |hash, msg|
106
- node = msg.path.reduce(hash) { |a, e| a.is_a?(Hash) ? a[e] : a.last[e] }
107
- (node[0].is_a?(::Array) ? node[0] : node) << msg.dump
108
- hash
109
- }
110
- end
111
-
112
- # @api private
113
- #
114
- # rubocop:disable Metrics/AbcSize
115
- # rubocop:disable Metrics/PerceivedComplexity
116
- def initialize_placeholders!
117
- @placeholders = unique_paths.each_with_object(EMPTY_HASH.dup) { |path, hash|
118
- curr_idx = 0
119
- last_idx = path.size - 1
120
- node = hash
121
-
122
- while curr_idx <= last_idx
123
- key = path[curr_idx]
124
-
125
- next_node =
126
- if node.is_a?(Array) && key.is_a?(Symbol)
127
- node_hash = (node << [] << {}).last
128
- node_hash[key] || (node_hash[key] = curr_idx < last_idx ? {} : [])
129
- else
130
- node[key] || (node[key] = curr_idx < last_idx ? {} : [])
131
- end
132
-
133
- node = next_node
134
- curr_idx += 1
135
- end
136
- }
137
- end
138
- # rubocop:enable Metrics/AbcSize
139
- # rubocop:enable Metrics/PerceivedComplexity
140
95
  end
141
96
  end
142
97
  end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/validation/message'
3
+ require "dry/validation/message"
4
+ require "dry/schema/message_compiler"
4
5
 
5
6
  module Dry
6
7
  module Validation
7
8
  module Messages
9
+ FULL_MESSAGE_WHITESPACE = Dry::Schema::MessageCompiler::FULL_MESSAGE_WHITESPACE
10
+
8
11
  # Resolve translated messages from failure arguments
9
12
  #
10
13
  # @api public
@@ -22,6 +25,8 @@ module Dry
22
25
  # Resolve Message object from provided args and path
23
26
  #
24
27
  # This is used internally by contracts when rules are applied
28
+ # If message argument is a Hash, then it MUST have a :text key,
29
+ # which value will be used as the message value
25
30
  #
26
31
  # @return [Message, Message::Localized]
27
32
  #
@@ -31,10 +36,15 @@ module Dry
31
36
  when Symbol
32
37
  Message[->(**opts) { message(message, path: path, tokens: tokens, **opts) }, path, meta]
33
38
  when String
34
- Message[message, path, meta]
39
+ Message[->(**opts) { [message_text(message, path: path, **opts), meta] }, path, meta]
35
40
  when Hash
36
41
  meta = message.dup
37
- text = meta.delete(:text)
42
+ text = meta.delete(:text) { |key|
43
+ raise ArgumentError, <<~STR
44
+ +message+ Hash must contain :#{key} key (#{message.inspect} given)
45
+ STR
46
+ }
47
+
38
48
  call(message: text, tokens: tokens, path: path, meta: meta)
39
49
  else
40
50
  raise ArgumentError, <<~STR
@@ -49,6 +59,8 @@ module Dry
49
59
  # @return [String]
50
60
  #
51
61
  # @api public
62
+ #
63
+ # rubocop:disable Metrics/AbcSize
52
64
  def message(rule, tokens: EMPTY_HASH, locale: nil, full: false, path:)
53
65
  keys = path.to_a.compact
54
66
  msg_opts = tokens.merge(path: keys, locale: locale || messages.default_locale)
@@ -60,15 +72,58 @@ module Dry
60
72
  template, meta = messages[rule, msg_opts.merge(path: keys.last)] unless template
61
73
  end
62
74
 
75
+ if !template && keys.size > 1
76
+ non_index_keys = keys.reject { |k| k.is_a?(Integer) }
77
+ template, meta = messages[rule, msg_opts.merge(path: non_index_keys.join(DOT))]
78
+ end
79
+
63
80
  unless template
64
81
  raise MissingMessageError, <<~STR
65
82
  Message template for #{rule.inspect} under #{keys.join(DOT).inspect} was not found
66
83
  STR
67
84
  end
68
85
 
69
- text = template.(template.data(tokens))
86
+ parsed_tokens = parse_tokens(tokens)
87
+ text = template.(template.data(parsed_tokens))
70
88
 
71
- [full ? "#{messages.rule(keys.last, msg_opts)} #{text}" : text, meta]
89
+ [message_text(text, path: path, locale: locale, full: full), meta]
90
+ end
91
+ # rubocop:enable Metrics/AbcSize
92
+
93
+ private
94
+
95
+ def message_text(text, path:, locale: nil, full: false)
96
+ return text unless full
97
+
98
+ key = key_text(path: path, locale: locale)
99
+
100
+ [key, text].compact.join(FULL_MESSAGE_WHITESPACE[locale])
101
+ end
102
+
103
+ def key_text(path:, locale: nil)
104
+ locale ||= messages.default_locale
105
+
106
+ keys = path.to_a.compact
107
+ msg_opts = {path: keys, locale: locale}
108
+
109
+ messages.rule(keys.last, msg_opts) || keys.last
110
+ end
111
+
112
+ def parse_tokens(tokens)
113
+ Hash[
114
+ tokens.map do |key, token|
115
+ [key, parse_token(token)]
116
+ end
117
+ ]
118
+ end
119
+
120
+ def parse_token(token)
121
+ case token
122
+ when Array
123
+ token.join(", ")
124
+ else
125
+ token
126
+ end
72
127
  end
73
128
  end
74
129
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'concurrent/map'
4
- require 'dry/equalizer'
3
+ require "concurrent/map"
4
+ require "dry/equalizer"
5
5
 
6
- require 'dry/validation/constants'
7
- require 'dry/validation/message_set'
8
- require 'dry/validation/values'
6
+ require "dry/validation/constants"
7
+ require "dry/validation/message_set"
8
+ require "dry/validation/values"
9
9
 
10
10
  module Dry
11
11
  module Validation
@@ -101,11 +101,34 @@ module Dry
101
101
 
102
102
  # Check if values include an error for the provided key
103
103
  #
104
- # @api private
104
+ # @api public
105
105
  def error?(key)
106
+ errors.any? { |msg| Schema::Path[msg.path].include?(Schema::Path[key]) }
107
+ end
108
+
109
+ # Check if the base schema (without rules) includes an error for the provided key
110
+ #
111
+ # @api private
112
+ def schema_error?(key)
106
113
  schema_result.error?(key)
107
114
  end
108
115
 
116
+ # Check if there's any error for the provided key
117
+ #
118
+ # This does not consider errors from the nested values
119
+ #
120
+ # @api private
121
+ def base_error?(key)
122
+ schema_result.errors.any? { |error|
123
+ key_path = Schema::Path[key]
124
+ err_path = Schema::Path[error.path]
125
+
126
+ next unless key_path.same_root?(err_path)
127
+
128
+ key_path == err_path
129
+ }
130
+ end
131
+
109
132
  # Add a new error for the provided key
110
133
  #
111
134
  # @api private
@@ -148,9 +171,9 @@ module Dry
148
171
  # @api public
149
172
  def inspect
150
173
  if context.empty?
151
- "#<#{self.class}#{to_h.inspect} errors=#{errors.to_h.inspect}>"
174
+ "#<#{self.class}#{to_h} errors=#{errors.to_h}>"
152
175
  else
153
- "#<#{self.class}#{to_h.inspect} errors=#{errors.to_h.inspect} context=#{context.each.to_h.inspect}>"
176
+ "#<#{self.class}#{to_h} errors=#{errors.to_h} context=#{context.each.to_h}>"
154
177
  end
155
178
  end
156
179
 
@@ -163,6 +186,22 @@ module Dry
163
186
  super
164
187
  end
165
188
 
189
+ if RUBY_VERSION >= "2.7"
190
+ # Pattern matching
191
+ #
192
+ # @api private
193
+ def deconstruct_keys(keys)
194
+ values.deconstruct_keys(keys)
195
+ end
196
+
197
+ # Pattern matching
198
+ #
199
+ # @api private
200
+ def deconstruct
201
+ [values, context.each.to_h]
202
+ end
203
+ end
204
+
166
205
  private
167
206
 
168
207
  # @api private