dry-schema 0.1.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 +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE +20 -0
- data/README.md +21 -0
- data/config/errors.yml +91 -0
- data/lib/dry-schema.rb +1 -0
- data/lib/dry/schema.rb +51 -0
- data/lib/dry/schema/compiler.rb +31 -0
- data/lib/dry/schema/config.rb +52 -0
- data/lib/dry/schema/constants.rb +13 -0
- data/lib/dry/schema/dsl.rb +382 -0
- data/lib/dry/schema/extensions.rb +3 -0
- data/lib/dry/schema/extensions/monads.rb +18 -0
- data/lib/dry/schema/json.rb +16 -0
- data/lib/dry/schema/key.rb +166 -0
- data/lib/dry/schema/key_coercer.rb +37 -0
- data/lib/dry/schema/key_map.rb +133 -0
- data/lib/dry/schema/macros.rb +6 -0
- data/lib/dry/schema/macros/core.rb +51 -0
- data/lib/dry/schema/macros/dsl.rb +74 -0
- data/lib/dry/schema/macros/each.rb +18 -0
- data/lib/dry/schema/macros/filled.rb +24 -0
- data/lib/dry/schema/macros/hash.rb +46 -0
- data/lib/dry/schema/macros/key.rb +137 -0
- data/lib/dry/schema/macros/maybe.rb +37 -0
- data/lib/dry/schema/macros/optional.rb +17 -0
- data/lib/dry/schema/macros/required.rb +17 -0
- data/lib/dry/schema/macros/value.rb +41 -0
- data/lib/dry/schema/message.rb +103 -0
- data/lib/dry/schema/message_compiler.rb +193 -0
- data/lib/dry/schema/message_compiler/visitor_opts.rb +30 -0
- data/lib/dry/schema/message_set.rb +123 -0
- data/lib/dry/schema/messages.rb +42 -0
- data/lib/dry/schema/messages/abstract.rb +143 -0
- data/lib/dry/schema/messages/i18n.rb +60 -0
- data/lib/dry/schema/messages/namespaced.rb +53 -0
- data/lib/dry/schema/messages/yaml.rb +82 -0
- data/lib/dry/schema/params.rb +16 -0
- data/lib/dry/schema/predicate.rb +80 -0
- data/lib/dry/schema/predicate_inferrer.rb +49 -0
- data/lib/dry/schema/predicate_registry.rb +38 -0
- data/lib/dry/schema/processor.rb +151 -0
- data/lib/dry/schema/result.rb +164 -0
- data/lib/dry/schema/rule_applier.rb +45 -0
- data/lib/dry/schema/trace.rb +103 -0
- data/lib/dry/schema/type_registry.rb +42 -0
- data/lib/dry/schema/types.rb +12 -0
- data/lib/dry/schema/value_coercer.rb +27 -0
- data/lib/dry/schema/version.rb +5 -0
- metadata +255 -0
@@ -0,0 +1,193 @@
|
|
1
|
+
require 'dry/schema/constants'
|
2
|
+
require 'dry/schema/message'
|
3
|
+
require 'dry/schema/message_set'
|
4
|
+
require 'dry/schema/message_compiler/visitor_opts'
|
5
|
+
|
6
|
+
module Dry
|
7
|
+
module Schema
|
8
|
+
# Compiles rule results AST into human-readable format
|
9
|
+
#
|
10
|
+
# @api private
|
11
|
+
class MessageCompiler
|
12
|
+
attr_reader :messages, :options, :locale, :default_lookup_options
|
13
|
+
|
14
|
+
EMPTY_OPTS = VisitorOpts.new
|
15
|
+
LIST_SEPARATOR = ', '.freeze
|
16
|
+
|
17
|
+
# @api private
|
18
|
+
def initialize(messages, options = {})
|
19
|
+
@messages = messages
|
20
|
+
@options = options
|
21
|
+
@full = @options.fetch(:full, false)
|
22
|
+
@hints = @options.fetch(:hints, true)
|
23
|
+
@locale = @options.fetch(:locale, messages.default_locale)
|
24
|
+
@default_lookup_options = { locale: locale }
|
25
|
+
end
|
26
|
+
|
27
|
+
# @api private
|
28
|
+
def full?
|
29
|
+
@full
|
30
|
+
end
|
31
|
+
|
32
|
+
# @api private
|
33
|
+
def hints?
|
34
|
+
@hints
|
35
|
+
end
|
36
|
+
|
37
|
+
# @api private
|
38
|
+
def with(new_options)
|
39
|
+
return self if new_options.empty?
|
40
|
+
self.class.new(messages, options.merge(new_options))
|
41
|
+
end
|
42
|
+
|
43
|
+
# @api private
|
44
|
+
def call(ast)
|
45
|
+
MessageSet[ast.map { |node| visit(node) }, failures: options.fetch(:failures, true)]
|
46
|
+
end
|
47
|
+
|
48
|
+
# @api private
|
49
|
+
def visit(node, *args)
|
50
|
+
__send__(:"visit_#{node[0]}", node[1], *args)
|
51
|
+
end
|
52
|
+
|
53
|
+
# @api private
|
54
|
+
def visit_failure(node, opts = EMPTY_OPTS.dup)
|
55
|
+
rule, other = node
|
56
|
+
visit(other, opts.(rule: rule))
|
57
|
+
end
|
58
|
+
|
59
|
+
# @api private
|
60
|
+
def visit_hint(node, opts = EMPTY_OPTS.dup)
|
61
|
+
if hints?
|
62
|
+
visit(node, opts.(message_type: :hint))
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# @api private
|
67
|
+
def visit_each(node, opts = EMPTY_OPTS.dup)
|
68
|
+
# TODO: we can still generate a hint for elements here!
|
69
|
+
[]
|
70
|
+
end
|
71
|
+
|
72
|
+
# @api private
|
73
|
+
def visit_not(node, opts = EMPTY_OPTS.dup)
|
74
|
+
visit(node, opts.(not: true))
|
75
|
+
end
|
76
|
+
|
77
|
+
# @api private
|
78
|
+
def visit_and(node, opts = EMPTY_OPTS.dup)
|
79
|
+
left, right = node.map { |n| visit(n, opts) }
|
80
|
+
|
81
|
+
if right
|
82
|
+
[left, right]
|
83
|
+
else
|
84
|
+
left
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# @api private
|
89
|
+
def visit_or(node, opts = EMPTY_OPTS.dup)
|
90
|
+
left, right = node.map { |n| visit(n, opts) }
|
91
|
+
|
92
|
+
if [left, right].flatten.map(&:path).uniq.size == 1
|
93
|
+
Message::Or.new(left, right, -> k { messages[k, default_lookup_options] })
|
94
|
+
elsif right.is_a?(Array)
|
95
|
+
right
|
96
|
+
else
|
97
|
+
[left, right]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# @api private
|
102
|
+
def visit_predicate(node, base_opts = EMPTY_OPTS.dup)
|
103
|
+
predicate, args = node
|
104
|
+
|
105
|
+
*arg_vals, val = args.map(&:last)
|
106
|
+
tokens = message_tokens(args)
|
107
|
+
|
108
|
+
input = val != Undefined ? val : nil
|
109
|
+
|
110
|
+
options = base_opts.update(lookup_options(arg_vals: arg_vals, input: input))
|
111
|
+
msg_opts = options.update(tokens)
|
112
|
+
|
113
|
+
rule = msg_opts[:rule]
|
114
|
+
path = msg_opts[:path]
|
115
|
+
|
116
|
+
template = messages[rule] || messages[predicate, msg_opts]
|
117
|
+
|
118
|
+
unless template
|
119
|
+
raise MissingMessageError, "message for #{predicate} was not found"
|
120
|
+
end
|
121
|
+
|
122
|
+
text = message_text(rule, template, tokens, options)
|
123
|
+
|
124
|
+
message_class = options[:message_type] == :hint ? Hint : Message
|
125
|
+
|
126
|
+
message_class[
|
127
|
+
predicate, path, text,
|
128
|
+
args: arg_vals,
|
129
|
+
input: input,
|
130
|
+
rule: rule || msg_opts[:name]
|
131
|
+
]
|
132
|
+
end
|
133
|
+
|
134
|
+
# @api private
|
135
|
+
def visit_key(node, opts = EMPTY_OPTS.dup)
|
136
|
+
name, other = node
|
137
|
+
visit(other, opts.(path: name))
|
138
|
+
end
|
139
|
+
|
140
|
+
# @api private
|
141
|
+
def visit_set(node, opts = EMPTY_OPTS.dup)
|
142
|
+
node.map { |el| visit(el, opts) }
|
143
|
+
end
|
144
|
+
|
145
|
+
# @api private
|
146
|
+
def visit_implication(node, *args)
|
147
|
+
_, right = node
|
148
|
+
visit(right, *args)
|
149
|
+
end
|
150
|
+
|
151
|
+
# @api private
|
152
|
+
def visit_xor(node, opts = EMPTY_OPTS.dup)
|
153
|
+
left, right = node
|
154
|
+
[visit(left, opts), visit(right, opts)].uniq
|
155
|
+
end
|
156
|
+
|
157
|
+
# @api private
|
158
|
+
def lookup_options(arg_vals: [], input: nil)
|
159
|
+
default_lookup_options.merge(
|
160
|
+
arg_type: arg_vals.size == 1 && arg_vals[0].class,
|
161
|
+
val_type: input.class
|
162
|
+
)
|
163
|
+
end
|
164
|
+
|
165
|
+
# @api private
|
166
|
+
def message_text(rule, template, tokens, opts)
|
167
|
+
text = template % tokens
|
168
|
+
|
169
|
+
if full?
|
170
|
+
rule_name = rule ? (messages.rule(rule, opts) || rule) : (opts[:name] || opts[:path].last)
|
171
|
+
"#{rule_name} #{text}"
|
172
|
+
else
|
173
|
+
text
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# @api private
|
178
|
+
def message_tokens(args)
|
179
|
+
args.each_with_object({}) { |arg, hash|
|
180
|
+
case arg[1]
|
181
|
+
when Array
|
182
|
+
hash[arg[0]] = arg[1].join(LIST_SEPARATOR)
|
183
|
+
when Range
|
184
|
+
hash["#{arg[0]}_left".to_sym] = arg[1].first
|
185
|
+
hash["#{arg[0]}_right".to_sym] = arg[1].last
|
186
|
+
else
|
187
|
+
hash[arg[0]] = arg[1]
|
188
|
+
end
|
189
|
+
}
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Dry
|
2
|
+
module Schema
|
3
|
+
# @api private
|
4
|
+
class MessageCompiler
|
5
|
+
# Optimized option hash used by visitor methods in message compiler
|
6
|
+
#
|
7
|
+
# @api private
|
8
|
+
class VisitorOpts < Hash
|
9
|
+
# @api private
|
10
|
+
def self.new
|
11
|
+
opts = super
|
12
|
+
opts[:path] = EMPTY_ARRAY
|
13
|
+
opts[:rule] = nil
|
14
|
+
opts[:message_type] = :failure
|
15
|
+
opts
|
16
|
+
end
|
17
|
+
|
18
|
+
# @api private
|
19
|
+
def path
|
20
|
+
self[:path]
|
21
|
+
end
|
22
|
+
|
23
|
+
# @api private
|
24
|
+
def call(other)
|
25
|
+
merge(other.update(path: [*path, *other[:path]]))
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
module Dry
|
2
|
+
module Schema
|
3
|
+
# A set of messages used to generate errors
|
4
|
+
#
|
5
|
+
# @see Result#message_set
|
6
|
+
#
|
7
|
+
# @api public
|
8
|
+
class MessageSet
|
9
|
+
include Enumerable
|
10
|
+
|
11
|
+
HINT_EXCLUSION = %i(
|
12
|
+
key? filled? nil? bool?
|
13
|
+
str? int? float? decimal?
|
14
|
+
date? date_time? time? hash?
|
15
|
+
array? format?
|
16
|
+
).freeze
|
17
|
+
|
18
|
+
attr_reader :messages, :failures, :hints, :paths, :placeholders, :options
|
19
|
+
|
20
|
+
# @api private
|
21
|
+
def self.[](messages, options = EMPTY_HASH)
|
22
|
+
new(messages.flatten, options)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @api private
|
26
|
+
def initialize(messages, options = EMPTY_HASH)
|
27
|
+
@messages = messages
|
28
|
+
@hints = messages.select(&:hint?)
|
29
|
+
@failures = messages - hints
|
30
|
+
@paths = failures.map(&:path).uniq
|
31
|
+
@options = options
|
32
|
+
|
33
|
+
initialize_hints!
|
34
|
+
initialize_placeholders!
|
35
|
+
end
|
36
|
+
|
37
|
+
# @api public
|
38
|
+
def each(&block)
|
39
|
+
return to_enum unless block
|
40
|
+
messages.each(&block)
|
41
|
+
end
|
42
|
+
|
43
|
+
# @api public
|
44
|
+
def to_h
|
45
|
+
failures? ? messages_map : hints_map
|
46
|
+
end
|
47
|
+
alias_method :to_hash, :to_h
|
48
|
+
alias_method :dump, :to_h
|
49
|
+
|
50
|
+
# @api private
|
51
|
+
def failures?
|
52
|
+
options[:failures].equal?(true)
|
53
|
+
end
|
54
|
+
|
55
|
+
# @api private
|
56
|
+
def empty?
|
57
|
+
messages.empty?
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
# @api private
|
63
|
+
def messages_map
|
64
|
+
failures.group_by(&:path).reduce(placeholders) do |hash, (path, msgs)|
|
65
|
+
node = path.reduce(hash) { |a, e| a[e] }
|
66
|
+
|
67
|
+
msgs.each do |msg|
|
68
|
+
node << msg
|
69
|
+
end
|
70
|
+
|
71
|
+
msg_hints = hint_groups[path]
|
72
|
+
node.concat(msg_hints) if msg_hints
|
73
|
+
|
74
|
+
node.map!(&:to_s)
|
75
|
+
|
76
|
+
hash
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# @api private
|
81
|
+
def hints_map
|
82
|
+
hints.group_by(&:path).reduce(placeholders) do |hash, (path, msgs)|
|
83
|
+
node = path.reduce(hash) { |a, e| a[e] }
|
84
|
+
|
85
|
+
msgs.each do |msg|
|
86
|
+
node << msg
|
87
|
+
end
|
88
|
+
|
89
|
+
node.map!(&:to_s)
|
90
|
+
|
91
|
+
hash
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# @api private
|
96
|
+
def hint_groups
|
97
|
+
@hint_groups ||= hints.group_by(&:path)
|
98
|
+
end
|
99
|
+
|
100
|
+
# @api private
|
101
|
+
def initialize_hints!
|
102
|
+
hints.reject! { |hint| HINT_EXCLUSION.include?(hint.predicate) }
|
103
|
+
end
|
104
|
+
|
105
|
+
# @api private
|
106
|
+
def initialize_placeholders!
|
107
|
+
@placeholders = paths.reduce({}) do |hash, path|
|
108
|
+
curr_idx = 0
|
109
|
+
last_idx = path.size - 1
|
110
|
+
node = hash
|
111
|
+
|
112
|
+
while curr_idx <= last_idx do
|
113
|
+
key = path[curr_idx]
|
114
|
+
node = (node[key] || node[key] = curr_idx < last_idx ? {} : [])
|
115
|
+
curr_idx += 1
|
116
|
+
end
|
117
|
+
|
118
|
+
hash
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Dry
|
2
|
+
module Schema
|
3
|
+
# An API for configuring message backends
|
4
|
+
#
|
5
|
+
# @api private
|
6
|
+
module Messages
|
7
|
+
def self.setup(config)
|
8
|
+
messages = build(config)
|
9
|
+
|
10
|
+
if config.messages_file && config.namespace
|
11
|
+
messages.merge(config.messages_file).namespaced(config.namespace)
|
12
|
+
elsif config.messages_file
|
13
|
+
messages.merge(config.messages_file)
|
14
|
+
elsif config.namespace
|
15
|
+
messages.namespaced(config.namespace)
|
16
|
+
else
|
17
|
+
messages
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# @api private
|
22
|
+
def self.build(config)
|
23
|
+
case config.messages
|
24
|
+
when :yaml then default
|
25
|
+
when :i18n then Messages::I18n.new
|
26
|
+
else
|
27
|
+
raise "+#{config.messages}+ is not a valid messages identifier"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# @api private
|
32
|
+
def self.default
|
33
|
+
Messages::YAML.load
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
require 'dry/schema/messages/abstract'
|
40
|
+
require 'dry/schema/messages/namespaced'
|
41
|
+
require 'dry/schema/messages/yaml'
|
42
|
+
require 'dry/schema/messages/i18n' if defined?(I18n)
|
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'concurrent/map'
|
3
|
+
require 'dry/equalizer'
|
4
|
+
require 'dry/configurable'
|
5
|
+
|
6
|
+
require 'dry/schema/constants'
|
7
|
+
|
8
|
+
module Dry
|
9
|
+
module Schema
|
10
|
+
module Messages
|
11
|
+
# Abstract class for message backends
|
12
|
+
#
|
13
|
+
# @api public
|
14
|
+
class Abstract
|
15
|
+
extend Dry::Configurable
|
16
|
+
include Dry::Equalizer(:config)
|
17
|
+
|
18
|
+
DEFAULT_PATH = Pathname(__dir__).join('../../../../config/errors.yml').realpath.freeze
|
19
|
+
|
20
|
+
setting :paths, [DEFAULT_PATH]
|
21
|
+
setting :root, 'errors'.freeze
|
22
|
+
setting :lookup_options, [:root, :predicate, :path, :val_type, :arg_type].freeze
|
23
|
+
|
24
|
+
setting :lookup_paths, %w(
|
25
|
+
%{root}.rules.%{path}.%{predicate}.arg.%{arg_type}
|
26
|
+
%{root}.rules.%{path}.%{predicate}
|
27
|
+
%{root}.%{predicate}.%{message_type}
|
28
|
+
%{root}.%{predicate}.value.%{path}.arg.%{arg_type}
|
29
|
+
%{root}.%{predicate}.value.%{path}
|
30
|
+
%{root}.%{predicate}.value.%{val_type}.arg.%{arg_type}
|
31
|
+
%{root}.%{predicate}.value.%{val_type}
|
32
|
+
%{root}.%{predicate}.arg.%{arg_type}
|
33
|
+
%{root}.%{predicate}
|
34
|
+
).freeze
|
35
|
+
|
36
|
+
setting :arg_type_default, 'default'.freeze
|
37
|
+
setting :val_type_default, 'default'.freeze
|
38
|
+
|
39
|
+
setting :arg_types, Hash.new { |*| config.arg_type_default }.update(
|
40
|
+
Range => 'range'
|
41
|
+
)
|
42
|
+
|
43
|
+
setting :val_types, Hash.new { |*| config.val_type_default }.update(
|
44
|
+
Range => 'range',
|
45
|
+
String => 'string'
|
46
|
+
)
|
47
|
+
|
48
|
+
# @api private
|
49
|
+
def self.cache
|
50
|
+
@cache ||= Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new }
|
51
|
+
end
|
52
|
+
|
53
|
+
# @api private
|
54
|
+
attr_reader :config
|
55
|
+
|
56
|
+
# @api private
|
57
|
+
def initialize
|
58
|
+
@config = self.class.config
|
59
|
+
end
|
60
|
+
|
61
|
+
# @api private
|
62
|
+
def hash
|
63
|
+
@hash ||= config.hash
|
64
|
+
end
|
65
|
+
|
66
|
+
# @api private
|
67
|
+
def rule(name, options = {})
|
68
|
+
path = "%{locale}.rules.#{name}"
|
69
|
+
get(path, options) if key?(path, options)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Retrieve a message
|
73
|
+
#
|
74
|
+
# @return [String]
|
75
|
+
#
|
76
|
+
# @api public
|
77
|
+
def call(*args)
|
78
|
+
cache.fetch_or_store(args.hash) do
|
79
|
+
path, opts = lookup(*args)
|
80
|
+
get(path, opts) if path
|
81
|
+
end
|
82
|
+
end
|
83
|
+
alias_method :[], :call
|
84
|
+
|
85
|
+
# Try to find a message for the given predicate and its options
|
86
|
+
#
|
87
|
+
# @api private
|
88
|
+
def lookup(predicate, options = {})
|
89
|
+
tokens = options.merge(
|
90
|
+
root: options[:not] ? "#{root}.not" : root,
|
91
|
+
predicate: predicate,
|
92
|
+
arg_type: config.arg_types[options[:arg_type]],
|
93
|
+
val_type: config.val_types[options[:val_type]],
|
94
|
+
message_type: options[:message_type] || :failure
|
95
|
+
)
|
96
|
+
|
97
|
+
tokens[:path] = options[:rule] || Array(options[:path]).join(DOT)
|
98
|
+
|
99
|
+
opts = options.reject { |k, _| config.lookup_options.include?(k) }
|
100
|
+
|
101
|
+
path = lookup_paths(tokens).detect do |key|
|
102
|
+
key?(key, opts) && get(key, opts).is_a?(String)
|
103
|
+
end
|
104
|
+
|
105
|
+
[path, opts]
|
106
|
+
end
|
107
|
+
|
108
|
+
# @api private
|
109
|
+
def lookup_paths(tokens)
|
110
|
+
config.lookup_paths.map { |path| path % tokens }
|
111
|
+
end
|
112
|
+
|
113
|
+
# Return a new message backend that will look for messages under provided namespace
|
114
|
+
#
|
115
|
+
# @param [Symbol,String] namespace
|
116
|
+
#
|
117
|
+
# @api public
|
118
|
+
def namespaced(namespace)
|
119
|
+
Dry::Schema::Messages::Namespaced.new(namespace, self)
|
120
|
+
end
|
121
|
+
|
122
|
+
# Return root path to messages file
|
123
|
+
#
|
124
|
+
# @return [Pathname]
|
125
|
+
#
|
126
|
+
# @api public
|
127
|
+
def root
|
128
|
+
config.root
|
129
|
+
end
|
130
|
+
|
131
|
+
# @api private
|
132
|
+
def cache
|
133
|
+
@cache ||= self.class.cache[self]
|
134
|
+
end
|
135
|
+
|
136
|
+
# @api private
|
137
|
+
def default_locale
|
138
|
+
:en
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|