dry-schema 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|