dry-schema 1.8.0 → 1.9.3

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