dry-schema 1.6.2 → 1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +4 -3
- data/dry-schema.gemspec +16 -14
- data/lib/dry/schema/compiler.rb +1 -1
- data/lib/dry/schema/config.rb +9 -9
- data/lib/dry/schema/dsl.rb +7 -4
- data/lib/dry/schema/extensions/hints/message_compiler_methods.rb +9 -4
- data/lib/dry/schema/extensions/hints.rb +11 -9
- data/lib/dry/schema/extensions/info/schema_compiler.rb +10 -1
- data/lib/dry/schema/extensions/json_schema/schema_compiler.rb +232 -0
- data/lib/dry/schema/extensions/json_schema.rb +29 -0
- data/lib/dry/schema/extensions/struct.rb +1 -1
- data/lib/dry/schema/extensions.rb +4 -0
- data/lib/dry/schema/key.rb +75 -70
- data/lib/dry/schema/key_coercer.rb +2 -2
- data/lib/dry/schema/key_validator.rb +46 -20
- data/lib/dry/schema/macros/array.rb +4 -0
- data/lib/dry/schema/macros/core.rb +1 -1
- data/lib/dry/schema/macros/dsl.rb +17 -15
- data/lib/dry/schema/macros/hash.rb +1 -1
- data/lib/dry/schema/macros/key.rb +2 -2
- data/lib/dry/schema/macros/schema.rb +2 -0
- data/lib/dry/schema/macros/value.rb +13 -1
- data/lib/dry/schema/message/or/multi_path.rb +7 -5
- data/lib/dry/schema/message_compiler.rb +13 -10
- data/lib/dry/schema/messages/abstract.rb +9 -9
- data/lib/dry/schema/messages/i18n.rb +98 -96
- data/lib/dry/schema/messages/namespaced.rb +1 -0
- data/lib/dry/schema/messages/yaml.rb +165 -151
- data/lib/dry/schema/path.rb +10 -60
- data/lib/dry/schema/predicate.rb +2 -2
- data/lib/dry/schema/predicate_inferrer.rb +2 -0
- data/lib/dry/schema/primitive_inferrer.rb +2 -0
- data/lib/dry/schema/processor.rb +6 -6
- data/lib/dry/schema/processor_steps.rb +7 -3
- data/lib/dry/schema/result.rb +38 -31
- data/lib/dry/schema/step.rb +14 -33
- data/lib/dry/schema/trace.rb +5 -1
- data/lib/dry/schema/type_registry.rb +1 -2
- data/lib/dry/schema/version.rb +1 -1
- metadata +11 -8
@@ -5,125 +5,127 @@ require "dry/schema/messages/abstract"
|
|
5
5
|
|
6
6
|
module Dry
|
7
7
|
module Schema
|
8
|
-
|
9
|
-
|
10
|
-
# @api public
|
11
|
-
class Messages::I18n < Messages::Abstract
|
12
|
-
# Translation function
|
13
|
-
#
|
14
|
-
# @return [Method]
|
15
|
-
attr_reader :t
|
16
|
-
|
17
|
-
# @api private
|
18
|
-
def initialize
|
19
|
-
super
|
20
|
-
@t = I18n.method(:t)
|
21
|
-
end
|
22
|
-
|
23
|
-
# Get a message for the given key and its options
|
24
|
-
#
|
25
|
-
# @param [Symbol] key
|
26
|
-
# @param [Hash] options
|
27
|
-
#
|
28
|
-
# @return [String]
|
8
|
+
module Messages
|
9
|
+
# I18n message backend
|
29
10
|
#
|
30
11
|
# @api public
|
31
|
-
|
32
|
-
|
12
|
+
class I18n < Abstract
|
13
|
+
# Translation function
|
14
|
+
#
|
15
|
+
# @return [Method]
|
16
|
+
attr_reader :t
|
17
|
+
|
18
|
+
# @api private
|
19
|
+
def initialize
|
20
|
+
super
|
21
|
+
@t = ::I18n.method(:t)
|
22
|
+
end
|
33
23
|
|
34
|
-
|
24
|
+
# Get a message for the given key and its options
|
25
|
+
#
|
26
|
+
# @param [Symbol] key
|
27
|
+
# @param [Hash] options
|
28
|
+
#
|
29
|
+
# @return [String]
|
30
|
+
#
|
31
|
+
# @api public
|
32
|
+
def get(key, options = EMPTY_HASH)
|
33
|
+
return unless key
|
34
|
+
|
35
|
+
result = t.(key, locale: options.fetch(:locale, default_locale))
|
36
|
+
|
37
|
+
if result.is_a?(Hash)
|
38
|
+
text = result[:text]
|
39
|
+
meta = result.dup.tap { |h| h.delete(:text) }
|
40
|
+
else
|
41
|
+
text = result
|
42
|
+
meta = EMPTY_HASH.dup
|
43
|
+
end
|
35
44
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
text = result
|
41
|
-
meta = EMPTY_HASH.dup
|
45
|
+
{
|
46
|
+
text: text,
|
47
|
+
meta: meta
|
48
|
+
}
|
42
49
|
end
|
43
50
|
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
#
|
54
|
-
# @api public
|
55
|
-
def key?(key, options)
|
56
|
-
I18n.exists?(key, options.fetch(:locale, default_locale)) ||
|
57
|
-
I18n.exists?(key, I18n.default_locale)
|
58
|
-
end
|
51
|
+
# Check if given key is defined
|
52
|
+
#
|
53
|
+
# @return [Boolean]
|
54
|
+
#
|
55
|
+
# @api public
|
56
|
+
def key?(key, options)
|
57
|
+
::I18n.exists?(key, options.fetch(:locale, default_locale)) ||
|
58
|
+
::I18n.exists?(key, ::I18n.default_locale)
|
59
|
+
end
|
59
60
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
61
|
+
# Merge messages from an additional path
|
62
|
+
#
|
63
|
+
# @param [String, Array<String>] paths
|
64
|
+
#
|
65
|
+
# @return [Messages::I18n]
|
66
|
+
#
|
67
|
+
# @api public
|
68
|
+
def merge(paths)
|
69
|
+
prepare(paths)
|
70
|
+
end
|
70
71
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
72
|
+
# @api private
|
73
|
+
def default_locale
|
74
|
+
super || ::I18n.locale || ::I18n.default_locale
|
75
|
+
end
|
75
76
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
77
|
+
# @api private
|
78
|
+
def prepare(paths = config.load_paths)
|
79
|
+
paths.each do |path|
|
80
|
+
data = ::YAML.load_file(path)
|
80
81
|
|
81
|
-
|
82
|
-
|
82
|
+
if custom_top_namespace?(path)
|
83
|
+
top_namespace = config.top_namespace
|
83
84
|
|
84
|
-
|
85
|
-
|
86
|
-
|
85
|
+
mapped_data = data.transform_values { |v|
|
86
|
+
{top_namespace => v[DEFAULT_MESSAGES_ROOT]}
|
87
|
+
}
|
87
88
|
|
88
|
-
|
89
|
-
|
90
|
-
|
89
|
+
store_translations(mapped_data)
|
90
|
+
else
|
91
|
+
store_translations(data)
|
92
|
+
end
|
91
93
|
end
|
92
|
-
end
|
93
94
|
|
94
|
-
|
95
|
-
|
95
|
+
self
|
96
|
+
end
|
96
97
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
98
|
+
# @api private
|
99
|
+
def interpolatable_data(_key, _options, **data)
|
100
|
+
data
|
101
|
+
end
|
101
102
|
|
102
|
-
|
103
|
-
|
104
|
-
|
103
|
+
# @api private
|
104
|
+
def interpolate(key, options, **data)
|
105
|
+
text_key = "#{key}.text"
|
105
106
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
107
|
+
opts = {
|
108
|
+
locale: default_locale,
|
109
|
+
**options,
|
110
|
+
**data
|
111
|
+
}
|
111
112
|
|
112
|
-
|
113
|
+
resolved_key = key?(text_key, opts) ? text_key : key
|
113
114
|
|
114
|
-
|
115
|
-
|
115
|
+
t.(resolved_key, **opts)
|
116
|
+
end
|
116
117
|
|
117
|
-
|
118
|
+
private
|
118
119
|
|
119
|
-
|
120
|
-
|
121
|
-
|
120
|
+
# @api private
|
121
|
+
def store_translations(data)
|
122
|
+
locales = data.keys.map(&:to_sym)
|
122
123
|
|
123
|
-
|
124
|
+
::I18n.available_locales |= locales
|
124
125
|
|
125
|
-
|
126
|
-
|
126
|
+
locales.each do |locale|
|
127
|
+
::I18n.backend.store_translations(locale, data[locale.to_s])
|
128
|
+
end
|
127
129
|
end
|
128
130
|
end
|
129
131
|
end
|
@@ -9,188 +9,202 @@ require "dry/schema/messages/abstract"
|
|
9
9
|
|
10
10
|
module Dry
|
11
11
|
module Schema
|
12
|
-
|
13
|
-
|
14
|
-
# @api public
|
15
|
-
class Messages::YAML < Messages::Abstract
|
16
|
-
LOCALE_TOKEN = "%<locale>s"
|
17
|
-
TOKEN_REGEXP = /%{(\w*)}/.freeze
|
18
|
-
EMPTY_CONTEXT = Object.new.tap { |ctx|
|
19
|
-
def ctx.context
|
20
|
-
binding
|
21
|
-
end
|
22
|
-
}.freeze.context
|
23
|
-
|
24
|
-
include Dry::Equalizer(:data)
|
25
|
-
|
26
|
-
# Loaded localized message templates
|
12
|
+
module Messages
|
13
|
+
# Plain YAML message backend
|
27
14
|
#
|
28
|
-
# @
|
29
|
-
|
15
|
+
# @api public
|
16
|
+
class YAML < Abstract
|
17
|
+
LOCALE_TOKEN = "%<locale>s"
|
18
|
+
TOKEN_REGEXP = /%{(\w*)}/.freeze
|
19
|
+
EMPTY_CONTEXT = Object.new.tap { |ctx|
|
20
|
+
def ctx.context
|
21
|
+
binding
|
22
|
+
end
|
23
|
+
}.freeze.context
|
30
24
|
|
31
|
-
|
32
|
-
#
|
33
|
-
# @return [Proc]
|
34
|
-
attr_reader :t
|
25
|
+
include ::Dry::Equalizer(:data)
|
35
26
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
27
|
+
# Loaded localized message templates
|
28
|
+
#
|
29
|
+
# @return [Hash]
|
30
|
+
attr_reader :data
|
40
31
|
|
41
|
-
|
32
|
+
# Translation function
|
33
|
+
#
|
34
|
+
# @return [Proc]
|
35
|
+
attr_reader :t
|
42
36
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
end
|
37
|
+
# @api private
|
38
|
+
def self.build(options = EMPTY_HASH)
|
39
|
+
super do |config|
|
40
|
+
config.default_locale = :en unless config.default_locale
|
48
41
|
|
49
|
-
|
50
|
-
def self.flat_hash(hash, path = [], keys = {})
|
51
|
-
hash.each do |key, value|
|
52
|
-
flat_hash(value, [*path, key], keys) if value.is_a?(Hash)
|
42
|
+
config.root = "%<locale>s.#{config.root}"
|
53
43
|
|
54
|
-
|
55
|
-
|
56
|
-
text: value,
|
57
|
-
meta: EMPTY_HASH
|
58
|
-
}
|
59
|
-
elsif value.is_a?(Hash) && value["text"].is_a?(String)
|
60
|
-
keys[[*path, key].join(DOT)] = {
|
61
|
-
text: value["text"],
|
62
|
-
meta: value.dup.delete_if { |k| k == "text" }.map { |k, v| [k.to_sym, v] }.to_h
|
44
|
+
config.rule_lookup_paths = config.rule_lookup_paths.map { |path|
|
45
|
+
"%<locale>s.#{path}"
|
63
46
|
}
|
64
47
|
end
|
65
48
|
end
|
66
|
-
keys
|
67
|
-
end
|
68
49
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
50
|
+
# @api private
|
51
|
+
# rubocop: disable Metrics/PerceivedComplexity
|
52
|
+
def self.flat_hash(hash, path = EMPTY_ARRAY, keys = {})
|
53
|
+
hash.each do |key, value|
|
54
|
+
flat_hash(value, [*path, key], keys) if value.is_a?(Hash)
|
55
|
+
|
56
|
+
if value.is_a?(String) && hash["text"] != value
|
57
|
+
keys[[*path, key].join(DOT)] = {
|
58
|
+
text: value,
|
59
|
+
meta: EMPTY_HASH
|
60
|
+
}
|
61
|
+
elsif value.is_a?(Hash) && value["text"].is_a?(String)
|
62
|
+
keys[[*path, key].join(DOT)] = {
|
63
|
+
text: value["text"],
|
64
|
+
meta: value.reject { _1.eql?("text") }.transform_keys(&:to_sym)
|
65
|
+
}
|
66
|
+
end
|
67
|
+
end
|
73
68
|
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
@data = data
|
78
|
-
@config = config if config
|
79
|
-
@t = proc { |key, locale: default_locale| get("%<locale>s.#{key}", locale: locale) }
|
80
|
-
end
|
69
|
+
keys
|
70
|
+
end
|
71
|
+
# rubocop: enable Metrics/PerceivedComplexity
|
81
72
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
#
|
87
|
-
# @return [String]
|
88
|
-
#
|
89
|
-
# @api public
|
90
|
-
def looked_up_paths(predicate, options)
|
91
|
-
super.map { |path| path % {locale: options[:locale] || default_locale} }
|
92
|
-
end
|
73
|
+
# @api private
|
74
|
+
def self.cache
|
75
|
+
@cache ||= Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new }
|
76
|
+
end
|
93
77
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
#
|
99
|
-
# @return [String]
|
100
|
-
#
|
101
|
-
# @api public
|
102
|
-
def get(key, options = EMPTY_HASH)
|
103
|
-
data[evaluated_key(key, options)]
|
104
|
-
end
|
78
|
+
# @api private
|
79
|
+
def self.source_cache
|
80
|
+
@source_cache ||= Concurrent::Map.new
|
81
|
+
end
|
105
82
|
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
end
|
83
|
+
# @api private
|
84
|
+
def initialize(data: EMPTY_HASH, config: nil)
|
85
|
+
super()
|
86
|
+
@data = data
|
87
|
+
@config = config if config
|
88
|
+
@t = proc { |key, locale: default_locale| get("%<locale>s.#{key}", locale: locale) }
|
89
|
+
end
|
114
90
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
data: data.merge(self.class.flat_hash(overrides)),
|
126
|
-
config: config
|
127
|
-
)
|
128
|
-
else
|
129
|
-
self.class.new(
|
130
|
-
data: Array(overrides).reduce(data) { |a, e| a.merge(load_translations(e)) },
|
131
|
-
config: config
|
132
|
-
)
|
91
|
+
# Get an array of looked up paths
|
92
|
+
#
|
93
|
+
# @param [Symbol] predicate
|
94
|
+
# @param [Hash] options
|
95
|
+
#
|
96
|
+
# @return [String]
|
97
|
+
#
|
98
|
+
# @api public
|
99
|
+
def looked_up_paths(predicate, options)
|
100
|
+
super.map { |path| path % {locale: options[:locale] || default_locale} }
|
133
101
|
end
|
134
|
-
end
|
135
102
|
|
136
|
-
|
137
|
-
|
138
|
-
@
|
139
|
-
|
140
|
-
|
103
|
+
# Get a message for the given key and its options
|
104
|
+
#
|
105
|
+
# @param [Symbol] key
|
106
|
+
# @param [Hash] options
|
107
|
+
#
|
108
|
+
# @return [String]
|
109
|
+
#
|
110
|
+
# @api public
|
111
|
+
def get(key, options = EMPTY_HASH)
|
112
|
+
data[evaluated_key(key, options)]
|
113
|
+
end
|
141
114
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
115
|
+
# Check if given key is defined
|
116
|
+
#
|
117
|
+
# @return [Boolean]
|
118
|
+
#
|
119
|
+
# @api public
|
120
|
+
def key?(key, options = EMPTY_HASH)
|
121
|
+
data.key?(evaluated_key(key, options))
|
122
|
+
end
|
147
123
|
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
124
|
+
# Merge messages from an additional path
|
125
|
+
#
|
126
|
+
# @param [String] overrides
|
127
|
+
#
|
128
|
+
# @return [Messages::I18n]
|
129
|
+
#
|
130
|
+
# @api public
|
131
|
+
def merge(overrides)
|
132
|
+
if overrides.is_a?(Hash)
|
133
|
+
self.class.new(
|
134
|
+
data: data.merge(self.class.flat_hash(overrides)),
|
135
|
+
config: config
|
136
|
+
)
|
137
|
+
else
|
138
|
+
self.class.new(
|
139
|
+
data: Array(overrides).reduce(data) { |a, e| a.merge(load_translations(e)) },
|
140
|
+
config: config
|
141
|
+
)
|
142
|
+
end
|
143
|
+
end
|
153
144
|
|
154
|
-
|
145
|
+
# @api private
|
146
|
+
def prepare
|
147
|
+
@data = config.load_paths.map { |path| load_translations(path) }.reduce({}, :merge)
|
148
|
+
self
|
149
|
+
end
|
155
150
|
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
151
|
+
# @api private
|
152
|
+
def interpolatable_data(key, options, **data)
|
153
|
+
tokens = evaluation_context(key, options).fetch(:tokens)
|
154
|
+
data.select { |k,| tokens.include?(k) }
|
155
|
+
end
|
161
156
|
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
157
|
+
# @api private
|
158
|
+
def interpolate(key, options, **data)
|
159
|
+
evaluator = evaluation_context(key, options).fetch(:evaluator)
|
160
|
+
data.empty? ? evaluator.() : evaluator.(**data)
|
161
|
+
end
|
167
162
|
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
163
|
+
private
|
164
|
+
|
165
|
+
# @api private
|
166
|
+
def evaluation_context(key, options)
|
167
|
+
cache.fetch_or_store(get(key, options).fetch(:text)) do |input|
|
168
|
+
tokens = input.scan(TOKEN_REGEXP).flatten(1).map(&:to_sym).to_set
|
169
|
+
text = input.gsub("%", "#")
|
170
|
+
|
171
|
+
# rubocop:disable Security/Eval
|
172
|
+
# rubocop:disable Style/DocumentDynamicEvalDefinition
|
173
|
+
evaluator = eval(<<~RUBY, EMPTY_CONTEXT, __FILE__, __LINE__ + 1)
|
174
|
+
-> (#{tokens.map { |token| "#{token}:" }.join(", ")}) { "#{text}" }
|
175
|
+
RUBY
|
176
|
+
# rubocop:enable Style/DocumentDynamicEvalDefinition
|
177
|
+
# rubocop:enable Security/Eval
|
178
|
+
|
179
|
+
{
|
180
|
+
tokens: tokens,
|
181
|
+
evaluator: evaluator
|
182
|
+
}
|
183
|
+
end
|
172
184
|
end
|
173
|
-
end
|
174
185
|
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
186
|
+
# @api private
|
187
|
+
def cache
|
188
|
+
@cache ||= self.class.cache[self]
|
189
|
+
end
|
179
190
|
|
180
|
-
|
181
|
-
|
182
|
-
|
191
|
+
# @api private
|
192
|
+
def load_translations(path)
|
193
|
+
data = self.class.source_cache.fetch_or_store(path) do
|
194
|
+
self.class.flat_hash(::YAML.load_file(path)).freeze
|
195
|
+
end
|
183
196
|
|
184
|
-
|
197
|
+
return data unless custom_top_namespace?(path)
|
185
198
|
|
186
|
-
|
187
|
-
|
199
|
+
data.transform_keys { _1.gsub(DEFAULT_MESSAGES_ROOT, config.top_namespace) }
|
200
|
+
end
|
188
201
|
|
189
|
-
|
190
|
-
|
191
|
-
|
202
|
+
# @api private
|
203
|
+
def evaluated_key(key, options)
|
204
|
+
return key unless key.include?(LOCALE_TOKEN)
|
192
205
|
|
193
|
-
|
206
|
+
key % {locale: options[:locale] || default_locale}
|
207
|
+
end
|
194
208
|
end
|
195
209
|
end
|
196
210
|
end
|