dry-validation 1.3.1

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.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/validation/constants'
4
+ require 'dry/validation/function'
5
+
6
+ module Dry
7
+ module Validation
8
+ # A wrapper for macro validation blocks
9
+ #
10
+ # @api public
11
+ class Macro < Function
12
+ # @!attribute [r] name
13
+ # @return [Symbol]
14
+ # @api public
15
+ param :name
16
+
17
+ # @!attribute [r] args
18
+ # @return [Array]
19
+ # @api public
20
+ option :args
21
+
22
+ # @!attribute [r] block
23
+ # @return [Proc]
24
+ # @api private
25
+ option :block
26
+
27
+ # @api private
28
+ def with(args)
29
+ self.class.new(name, args: args, block: block)
30
+ end
31
+
32
+ # @api private
33
+ def extract_block_options(options)
34
+ block_options.map { |key, value| [key, options[value]] }.to_h
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/container'
4
+ require 'dry/validation/macro'
5
+
6
+ module Dry
7
+ module Validation
8
+ # API for registering and accessing Rule macros
9
+ #
10
+ # @api public
11
+ module Macros
12
+ module Registrar
13
+ # Register a macro
14
+ #
15
+ # @example register a global macro
16
+ # Dry::Validation.register_macro(:even_numbers) do
17
+ # key.failure('all numbers must be even') unless values[key_name].all?(&:even?)
18
+ # end
19
+ #
20
+ # @example register a contract macro
21
+ # class MyContract < Dry::Validation::Contract
22
+ # register_macro(:even_numbers) do
23
+ # key.failure('all numbers must be even') unless values[key_name].all?(&:even?)
24
+ # end
25
+ # end
26
+ #
27
+ # @param [Symbol] name The name of the macro
28
+ # @param [Array] *args Optional default arguments for the macro
29
+ #
30
+ # @return [self]
31
+ #
32
+ # @see Macro
33
+ #
34
+ # @api public
35
+ def register_macro(name, *args, &block)
36
+ macros.register(name, *args, &block)
37
+ self
38
+ end
39
+ end
40
+
41
+ # Registry for macros
42
+ #
43
+ # @api public
44
+ class Container
45
+ include Dry::Container::Mixin
46
+
47
+ # Register a new macro
48
+ #
49
+ # @example in a contract class
50
+ # class MyContract < Dry::Validation::Contract
51
+ # register_macro(:even_numbers) do
52
+ # key.failure('all numbers must be even') unless values[key_name].all?(&:even?)
53
+ # end
54
+ # end
55
+ #
56
+ # @param [Symbol] name The name of the macro
57
+ #
58
+ # @return [self]
59
+ #
60
+ # @api public
61
+ def register(name, *args, &block)
62
+ macro = Macro.new(name, args: args, block: block)
63
+ super(name, macro, call: false, &nil)
64
+ self
65
+ end
66
+ end
67
+
68
+ # Return a registered macro
69
+ #
70
+ # @param [Symbol] name The name of the macro
71
+ #
72
+ # @return [Proc]
73
+ #
74
+ # @api public
75
+ def self.[](name)
76
+ container[name]
77
+ end
78
+
79
+ # Register a global macro
80
+ #
81
+ # @see Container#register
82
+ #
83
+ # @return [Macros]
84
+ #
85
+ # @api public
86
+ def self.register(name, *args, &block)
87
+ container.register(name, *args, &block)
88
+ self
89
+ end
90
+
91
+ # @api private
92
+ def self.container
93
+ @container ||= Container.new
94
+ end
95
+ end
96
+
97
+ # Acceptance macro
98
+ #
99
+ # @api public
100
+ Macros.register(:acceptance) do
101
+ key.failure(:acceptance, key: key_name) unless values[key_name].equal?(true)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/equalizer'
4
+
5
+ require 'dry/schema/constants'
6
+ require 'dry/schema/message'
7
+
8
+ module Dry
9
+ module Validation
10
+ # Message message
11
+ #
12
+ # @api public
13
+ class Message < Schema::Message
14
+ include Dry::Equalizer(:text, :path, :meta)
15
+
16
+ # The error message text
17
+ #
18
+ # @return [String] text
19
+ #
20
+ # @api public
21
+ attr_reader :text
22
+
23
+ # The path to the value with the error
24
+ #
25
+ # @return [Array<Symbol, Integer>]
26
+ #
27
+ # @api public
28
+ attr_reader :path
29
+
30
+ # Optional hash with meta-data
31
+ #
32
+ # @return [Hash]
33
+ #
34
+ # @api public
35
+ attr_reader :meta
36
+
37
+ # A localized message type
38
+ #
39
+ # Localized messsages can be translated to other languages at run-time
40
+ #
41
+ # @api public
42
+ class Localized < Message
43
+ # Evaluate message text using provided locale
44
+ #
45
+ # @example
46
+ # result.errors[:email].evaluate(locale: :en, full: true)
47
+ # # "email is invalid"
48
+ #
49
+ # @param [Hash] opts
50
+ # @option opts [Symbol] :locale Which locale to use
51
+ # @option opts [Boolean] :full Whether message text should include the key name
52
+ #
53
+ # @api public
54
+ def evaluate(**opts)
55
+ evaluated_text, rest = text.(opts)
56
+ Message.new(evaluated_text, path: path, meta: rest.merge(meta))
57
+ end
58
+ end
59
+
60
+ # Build an error
61
+ #
62
+ # @return [Message, Message::Localized]
63
+ #
64
+ # @api private
65
+ def self.[](text, path, meta)
66
+ klass = text.respond_to?(:call) ? Localized : Message
67
+ klass.new(text, path: path, meta: meta)
68
+ end
69
+
70
+ # Initialize a new error object
71
+ #
72
+ # @api private
73
+ def initialize(text, path:, meta: EMPTY_HASH)
74
+ @text = text
75
+ @path = Array(path)
76
+ @meta = meta
77
+ end
78
+
79
+ # Check if this is a base error not associated with any key
80
+ #
81
+ # @return [Boolean]
82
+ #
83
+ # @api public
84
+ def base?
85
+ @base ||= path.compact.empty?
86
+ end
87
+
88
+ # Dump error to a string
89
+ #
90
+ # @return [String]
91
+ #
92
+ # @api public
93
+ def to_s
94
+ text
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/schema/message_set'
4
+
5
+ require 'dry/validation/constants'
6
+ require 'dry/validation/message'
7
+
8
+ module Dry
9
+ module Validation
10
+ # MessageSet is a specialized message set for handling validation messages
11
+ #
12
+ # @api public
13
+ class MessageSet < Schema::MessageSet
14
+ # Return the source set of messages used to produce final evaluated messages
15
+ #
16
+ # @return [Array<Message, Message::Localized, Schema::Message>]
17
+ #
18
+ # @api private
19
+ attr_reader :source_messages
20
+
21
+ # Configured locale
22
+ #
23
+ # @return [Symbol]
24
+ #
25
+ # @api public
26
+ attr_reader :locale
27
+
28
+ # @api private
29
+ def initialize(messages, options = EMPTY_HASH)
30
+ @locale = options[:locale]
31
+ @source_messages = options.fetch(:source) { messages.dup }
32
+ super
33
+ end
34
+
35
+ # Return a new message set using updated options
36
+ #
37
+ # @return [MessageSet]
38
+ #
39
+ # @api private
40
+ def with(other, new_options = EMPTY_HASH)
41
+ return self if new_options.empty? && other.eql?(messages)
42
+
43
+ self.class.new(
44
+ (other + select { |err| err.is_a?(Message) }).uniq,
45
+ options.merge(source: source_messages, **new_options)
46
+ ).freeze
47
+ end
48
+
49
+ # Add a new message
50
+ #
51
+ # This is used when result is being prepared
52
+ #
53
+ # @return [MessageSet]
54
+ #
55
+ # @api private
56
+ def add(message)
57
+ source_messages << message
58
+ messages << message
59
+ initialize_placeholders!
60
+ self
61
+ end
62
+
63
+ # Filter message set using provided predicates
64
+ #
65
+ # This method is open to any predicate because messages can be anything that
66
+ # implements Message API, thus they can implement whatever predicates you
67
+ # may need.
68
+ #
69
+ # @example get a list of base messages
70
+ # message_set = contract.(input).errors
71
+ # message_set.filter(:base?)
72
+ #
73
+ # @param [Array<Symbol>] predicates
74
+ #
75
+ # @return [MessageSet]
76
+ #
77
+ # @api public
78
+ def filter(*predicates)
79
+ messages = select { |msg|
80
+ predicates.all? { |predicate| msg.respond_to?(predicate) && msg.public_send(predicate) }
81
+ }
82
+ self.class.new(messages)
83
+ end
84
+
85
+ # @api private
86
+ def freeze
87
+ source_messages.select { |err| err.respond_to?(:evaluate) }.each do |err|
88
+ idx = source_messages.index(err)
89
+ msg = err.evaluate(locale: locale, full: options[:full])
90
+ messages[idx] = msg
91
+ end
92
+ to_h
93
+ self
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
+ end
141
+ end
142
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/validation/message'
4
+
5
+ module Dry
6
+ module Validation
7
+ module Messages
8
+ # Resolve translated messages from failure arguments
9
+ #
10
+ # @api public
11
+ class Resolver
12
+ # @!attribute [r] messages
13
+ # @return [Messages::I18n, Messages::YAML] messages backend
14
+ # @api private
15
+ attr_reader :messages
16
+
17
+ # @api private
18
+ def initialize(messages)
19
+ @messages = messages
20
+ end
21
+
22
+ # Resolve Message object from provided args and path
23
+ #
24
+ # This is used internally by contracts when rules are applied
25
+ #
26
+ # @return [Message, Message::Localized]
27
+ #
28
+ # @api public
29
+ def call(message:, tokens:, path:, meta: EMPTY_HASH)
30
+ case message
31
+ when Symbol
32
+ Message[->(**opts) { message(message, path: path, tokens: tokens, **opts) }, path, meta]
33
+ when String
34
+ Message[message, path, meta]
35
+ when Hash
36
+ meta = message.dup
37
+ text = meta.delete(:text)
38
+ call(message: text, tokens: tokens, path: path, meta: meta)
39
+ else
40
+ raise ArgumentError, <<~STR
41
+ +message+ must be either a Symbol, String or Hash (#{message.inspect} given)
42
+ STR
43
+ end
44
+ end
45
+ alias_method :[], :call
46
+
47
+ # Resolve a message
48
+ #
49
+ # @return [String]
50
+ #
51
+ # @api public
52
+ #
53
+ # rubocop:disable Metrics/AbcSize
54
+ def message(rule, tokens: EMPTY_HASH, locale: nil, full: false, path:)
55
+ keys = path.to_a.compact
56
+ msg_opts = tokens.merge(path: keys, locale: locale || messages.default_locale)
57
+
58
+ if keys.empty?
59
+ template, meta = messages["rules.#{rule}", msg_opts]
60
+ else
61
+ template, meta = messages[rule, msg_opts.merge(path: keys.join(DOT))]
62
+ template, meta = messages[rule, msg_opts.merge(path: keys.last)] unless template
63
+ end
64
+
65
+ unless template
66
+ raise MissingMessageError, <<~STR
67
+ Message template for #{rule.inspect} under #{keys.join(DOT).inspect} was not found
68
+ STR
69
+ end
70
+
71
+ text = template.(template.data(tokens))
72
+
73
+ [full ? "#{messages.rule(keys.last, msg_opts)} #{text}" : text, meta]
74
+ end
75
+ # rubocop:enable Metrics/AbcSize
76
+ end
77
+ end
78
+ end
79
+ end