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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +4 -3
  4. data/dry-schema.gemspec +16 -14
  5. data/lib/dry/schema/compiler.rb +1 -1
  6. data/lib/dry/schema/config.rb +9 -9
  7. data/lib/dry/schema/dsl.rb +7 -4
  8. data/lib/dry/schema/extensions/hints/message_compiler_methods.rb +9 -4
  9. data/lib/dry/schema/extensions/hints.rb +11 -9
  10. data/lib/dry/schema/extensions/info/schema_compiler.rb +10 -1
  11. data/lib/dry/schema/extensions/json_schema/schema_compiler.rb +232 -0
  12. data/lib/dry/schema/extensions/json_schema.rb +29 -0
  13. data/lib/dry/schema/extensions/struct.rb +1 -1
  14. data/lib/dry/schema/extensions.rb +4 -0
  15. data/lib/dry/schema/key.rb +75 -70
  16. data/lib/dry/schema/key_coercer.rb +2 -2
  17. data/lib/dry/schema/key_validator.rb +46 -20
  18. data/lib/dry/schema/macros/array.rb +4 -0
  19. data/lib/dry/schema/macros/core.rb +1 -1
  20. data/lib/dry/schema/macros/dsl.rb +17 -15
  21. data/lib/dry/schema/macros/hash.rb +1 -1
  22. data/lib/dry/schema/macros/key.rb +2 -2
  23. data/lib/dry/schema/macros/schema.rb +2 -0
  24. data/lib/dry/schema/macros/value.rb +13 -1
  25. data/lib/dry/schema/message/or/multi_path.rb +7 -5
  26. data/lib/dry/schema/message_compiler.rb +13 -10
  27. data/lib/dry/schema/messages/abstract.rb +9 -9
  28. data/lib/dry/schema/messages/i18n.rb +98 -96
  29. data/lib/dry/schema/messages/namespaced.rb +1 -0
  30. data/lib/dry/schema/messages/yaml.rb +165 -151
  31. data/lib/dry/schema/path.rb +10 -60
  32. data/lib/dry/schema/predicate.rb +2 -2
  33. data/lib/dry/schema/predicate_inferrer.rb +2 -0
  34. data/lib/dry/schema/primitive_inferrer.rb +2 -0
  35. data/lib/dry/schema/processor.rb +6 -6
  36. data/lib/dry/schema/processor_steps.rb +7 -3
  37. data/lib/dry/schema/result.rb +38 -31
  38. data/lib/dry/schema/step.rb +14 -33
  39. data/lib/dry/schema/trace.rb +5 -1
  40. data/lib/dry/schema/type_registry.rb +1 -2
  41. data/lib/dry/schema/version.rb +1 -1
  42. metadata +11 -8
@@ -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
@@ -9,188 +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
49
 
69
- # @api private
70
- def self.cache
71
- @cache ||= Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new }
72
- 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
73
68
 
74
- # @api private
75
- def initialize(data: EMPTY_HASH, config: nil)
76
- super()
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
- # Get an array of looked up paths
83
- #
84
- # @param [Symbol] predicate
85
- # @param [Hash] options
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
- # Get a message for the given key and its options
95
- #
96
- # @param [Symbol] key
97
- # @param [Hash] options
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
- # Check if given key is defined
107
- #
108
- # @return [Boolean]
109
- #
110
- # @api public
111
- def key?(key, options = EMPTY_HASH)
112
- data.key?(evaluated_key(key, options))
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
- # Merge messages from an additional path
116
- #
117
- # @param [String] overrides
118
- #
119
- # @return [Messages::I18n]
120
- #
121
- # @api public
122
- def merge(overrides)
123
- if overrides.is_a?(Hash)
124
- self.class.new(
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
- # @api private
137
- def prepare
138
- @data = config.load_paths.map { |path| load_translations(path) }.reduce({}, :merge)
139
- self
140
- 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
141
114
 
142
- # @api private
143
- def interpolatable_data(key, options, **data)
144
- tokens = evaluation_context(key, options).fetch(:tokens)
145
- data.select { |k,| tokens.include?(k) }
146
- 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
147
123
 
148
- # @api private
149
- def interpolate(key, options, **data)
150
- evaluator = evaluation_context(key, options).fetch(:evaluator)
151
- data.empty? ? evaluator.() : evaluator.(**data)
152
- end
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
- private
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
- # @api private
157
- def evaluation_context(key, options)
158
- cache.fetch_or_store(get(key, options).fetch(:text)) do |input|
159
- tokens = input.scan(TOKEN_REGEXP).flatten(1).map(&:to_sym).to_set
160
- text = input.gsub("%", "#")
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
- # rubocop:disable Security/Eval
163
- evaluator = eval(<<~RUBY, EMPTY_CONTEXT, __FILE__, __LINE__ + 1)
164
- -> (#{tokens.map { |token| "#{token}:" }.join(", ")}) { "#{text}" }
165
- RUBY
166
- # rubocop:enable Security/Eval
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
- tokens: tokens,
170
- evaluator: evaluator
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
- # @api private
176
- def cache
177
- @cache ||= self.class.cache[self]
178
- end
186
+ # @api private
187
+ def cache
188
+ @cache ||= self.class.cache[self]
189
+ end
179
190
 
180
- # @api private
181
- def load_translations(path)
182
- data = self.class.flat_hash(YAML.load_file(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
183
196
 
184
- return data unless custom_top_namespace?(path)
197
+ return data unless custom_top_namespace?(path)
185
198
 
186
- data.map { |k, v| [k.gsub(DEFAULT_MESSAGES_ROOT, config.top_namespace), v] }.to_h
187
- end
199
+ data.transform_keys { _1.gsub(DEFAULT_MESSAGES_ROOT, config.top_namespace) }
200
+ end
188
201
 
189
- # @api private
190
- def evaluated_key(key, options)
191
- return key unless key.include?(LOCALE_TOKEN)
202
+ # @api private
203
+ def evaluated_key(key, options)
204
+ return key unless key.include?(LOCALE_TOKEN)
192
205
 
193
- key % {locale: options[:locale] || default_locale}
206
+ key % {locale: options[:locale] || default_locale}
207
+ end
194
208
  end
195
209
  end
196
210
  end