dry-schema 1.4.2 → 1.5.3

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +217 -78
  3. data/LICENSE +1 -1
  4. data/README.md +4 -4
  5. data/config/errors.yml +4 -0
  6. data/dry-schema.gemspec +46 -0
  7. data/lib/dry-schema.rb +1 -1
  8. data/lib/dry/schema.rb +19 -6
  9. data/lib/dry/schema/compiler.rb +5 -5
  10. data/lib/dry/schema/config.rb +15 -6
  11. data/lib/dry/schema/constants.rb +16 -7
  12. data/lib/dry/schema/dsl.rb +87 -27
  13. data/lib/dry/schema/extensions.rb +10 -2
  14. data/lib/dry/schema/extensions/hints.rb +15 -8
  15. data/lib/dry/schema/extensions/hints/message_compiler_methods.rb +1 -1
  16. data/lib/dry/schema/extensions/hints/message_set_methods.rb +0 -47
  17. data/lib/dry/schema/extensions/info.rb +27 -0
  18. data/lib/dry/schema/extensions/info/schema_compiler.rb +105 -0
  19. data/lib/dry/schema/extensions/monads.rb +1 -1
  20. data/lib/dry/schema/extensions/struct.rb +32 -0
  21. data/lib/dry/schema/json.rb +1 -1
  22. data/lib/dry/schema/key.rb +16 -1
  23. data/lib/dry/schema/key_coercer.rb +4 -4
  24. data/lib/dry/schema/key_map.rb +9 -4
  25. data/lib/dry/schema/key_validator.rb +67 -0
  26. data/lib/dry/schema/macros.rb +8 -8
  27. data/lib/dry/schema/macros/array.rb +17 -4
  28. data/lib/dry/schema/macros/core.rb +9 -4
  29. data/lib/dry/schema/macros/dsl.rb +34 -19
  30. data/lib/dry/schema/macros/each.rb +4 -4
  31. data/lib/dry/schema/macros/filled.rb +5 -5
  32. data/lib/dry/schema/macros/hash.rb +21 -3
  33. data/lib/dry/schema/macros/key.rb +9 -9
  34. data/lib/dry/schema/macros/maybe.rb +4 -5
  35. data/lib/dry/schema/macros/optional.rb +1 -1
  36. data/lib/dry/schema/macros/required.rb +1 -1
  37. data/lib/dry/schema/macros/schema.rb +23 -2
  38. data/lib/dry/schema/macros/value.rb +34 -7
  39. data/lib/dry/schema/message.rb +35 -9
  40. data/lib/dry/schema/message/or.rb +18 -39
  41. data/lib/dry/schema/message/or/abstract.rb +28 -0
  42. data/lib/dry/schema/message/or/multi_path.rb +37 -0
  43. data/lib/dry/schema/message/or/single_path.rb +64 -0
  44. data/lib/dry/schema/message_compiler.rb +55 -19
  45. data/lib/dry/schema/message_compiler/visitor_opts.rb +2 -2
  46. data/lib/dry/schema/message_set.rb +26 -37
  47. data/lib/dry/schema/messages.rb +6 -6
  48. data/lib/dry/schema/messages/abstract.rb +54 -56
  49. data/lib/dry/schema/messages/i18n.rb +29 -27
  50. data/lib/dry/schema/messages/namespaced.rb +12 -2
  51. data/lib/dry/schema/messages/template.rb +19 -44
  52. data/lib/dry/schema/messages/yaml.rb +61 -14
  53. data/lib/dry/schema/params.rb +1 -1
  54. data/lib/dry/schema/path.rb +44 -5
  55. data/lib/dry/schema/predicate.rb +4 -2
  56. data/lib/dry/schema/predicate_inferrer.rb +4 -184
  57. data/lib/dry/schema/predicate_registry.rb +2 -2
  58. data/lib/dry/schema/primitive_inferrer.rb +16 -0
  59. data/lib/dry/schema/processor.rb +49 -28
  60. data/lib/dry/schema/processor_steps.rb +50 -27
  61. data/lib/dry/schema/result.rb +52 -5
  62. data/lib/dry/schema/rule_applier.rb +7 -7
  63. data/lib/dry/schema/step.rb +79 -0
  64. data/lib/dry/schema/trace.rb +5 -4
  65. data/lib/dry/schema/type_container.rb +3 -3
  66. data/lib/dry/schema/type_registry.rb +2 -2
  67. data/lib/dry/schema/types.rb +1 -1
  68. data/lib/dry/schema/value_coercer.rb +2 -2
  69. data/lib/dry/schema/version.rb +1 -1
  70. metadata +21 -7
@@ -7,8 +7,8 @@ module Dry
7
7
  # @api private
8
8
  module Messages
9
9
  BACKENDS = {
10
- i18n: 'I18n',
11
- yaml: 'YAML'
10
+ i18n: "I18n",
11
+ yaml: "YAML"
12
12
  }.freeze
13
13
 
14
14
  module_function
@@ -31,7 +31,7 @@ module Dry
31
31
  end
32
32
  end
33
33
 
34
- require 'dry/schema/messages/abstract'
35
- require 'dry/schema/messages/namespaced'
36
- require 'dry/schema/messages/yaml'
37
- require 'dry/schema/messages/i18n' if defined?(I18n)
34
+ require "dry/schema/messages/abstract"
35
+ require "dry/schema/messages/namespaced"
36
+ require "dry/schema/messages/yaml"
37
+ require "dry/schema/messages/i18n" if defined?(I18n)
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
- require 'concurrent/map'
5
- require 'dry/equalizer'
6
- require 'dry/configurable'
3
+ require "set"
4
+ require "concurrent/map"
5
+ require "dry/equalizer"
6
+ require "dry/configurable"
7
7
 
8
- require 'dry/schema/constants'
9
- require 'dry/schema/messages/template'
8
+ require "dry/schema/constants"
9
+ require "dry/schema/messages/template"
10
10
 
11
11
  module Dry
12
12
  module Schema
@@ -21,36 +21,31 @@ module Dry
21
21
  setting :default_locale, nil
22
22
  setting :load_paths, Set[DEFAULT_MESSAGES_PATH]
23
23
  setting :top_namespace, DEFAULT_MESSAGES_ROOT
24
- setting :root, 'errors'
24
+ setting :root, "errors"
25
25
  setting :lookup_options, %i[root predicate path val_type arg_type].freeze
26
26
 
27
27
  setting :lookup_paths, [
28
- '%<root>s.rules.%<path>s.%<predicate>s.arg.%<arg_type>s',
29
- '%<root>s.rules.%<path>s.%<predicate>s',
30
- '%<root>s.%<predicate>s.%<message_type>s',
31
- '%<root>s.%<predicate>s.value.%<path>s',
32
- '%<root>s.%<predicate>s.value.%<val_type>s.arg.%<arg_type>s',
33
- '%<root>s.%<predicate>s.value.%<val_type>s',
34
- '%<root>s.%<predicate>s.arg.%<arg_type>s',
35
- '%<root>s.%<predicate>s'
28
+ "%<root>s.rules.%<path>s.%<predicate>s.arg.%<arg_type>s",
29
+ "%<root>s.rules.%<path>s.%<predicate>s",
30
+ "%<root>s.%<predicate>s.%<message_type>s",
31
+ "%<root>s.%<predicate>s.value.%<path>s",
32
+ "%<root>s.%<predicate>s.value.%<val_type>s.arg.%<arg_type>s",
33
+ "%<root>s.%<predicate>s.value.%<val_type>s",
34
+ "%<root>s.%<predicate>s.arg.%<arg_type>s",
35
+ "%<root>s.%<predicate>s"
36
36
  ].freeze
37
37
 
38
- setting :rule_lookup_paths, ['rules.%<name>s'].freeze
38
+ setting :rule_lookup_paths, ["rules.%<name>s"].freeze
39
39
 
40
- setting :arg_types, Hash.new { |*| 'default' }.update(
41
- Range => 'range'
40
+ setting :arg_types, Hash.new { |*| "default" }.update(
41
+ Range => "range"
42
42
  )
43
43
 
44
- setting :val_types, Hash.new { |*| 'default' }.update(
45
- Range => 'range',
46
- String => 'string'
44
+ setting :val_types, Hash.new { |*| "default" }.update(
45
+ Range => "range",
46
+ String => "string"
47
47
  )
48
48
 
49
- # @api private
50
- def self.cache
51
- @cache ||= Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new }
52
- end
53
-
54
49
  # @api private
55
50
  def self.build(options = EMPTY_HASH)
56
51
  messages = new
@@ -79,7 +74,7 @@ module Dry
79
74
 
80
75
  # @api private
81
76
  def rule(name, options = {})
82
- tokens = { name: name, locale: options.fetch(:locale, default_locale) }
77
+ tokens = {name: name, locale: options.fetch(:locale, default_locale)}
83
78
  path = rule_lookup_paths(tokens).detect { |key| key?(key, options) }
84
79
 
85
80
  rule = get(path, options) if path
@@ -92,13 +87,35 @@ module Dry
92
87
  #
93
88
  # @api public
94
89
  def call(predicate, options)
95
- cache.fetch_or_store(cache_key(predicate, options)) do
96
- text, meta = lookup(predicate, options)
97
- [Template[text], meta] if text
98
- end
90
+ options = {locale: default_locale, **options}
91
+ opts = options.reject { |k,| config.lookup_options.include?(k) }
92
+ path = lookup_paths(predicate, options).detect { |key| key?(key, opts) }
93
+
94
+ return unless path
95
+
96
+ result = get(path, opts)
97
+
98
+ [
99
+ Template.new(
100
+ messages: self,
101
+ key: path,
102
+ options: opts
103
+ ),
104
+ result[:meta]
105
+ ]
99
106
  end
107
+
100
108
  alias_method :[], :call
101
109
 
110
+ # Check if given key is defined
111
+ #
112
+ # @return [Boolean]
113
+ #
114
+ # @api public
115
+ def key?(_key, _options = EMPTY_HASH)
116
+ raise NotImplementedError
117
+ end
118
+
102
119
  # Retrieve an array of looked up paths
103
120
  #
104
121
  # @param [Symbol] predicate
@@ -112,21 +129,6 @@ module Dry
112
129
  filled_lookup_paths(tokens)
113
130
  end
114
131
 
115
- # Try to find a message for the given predicate and its options
116
- #
117
- # @api private
118
- #
119
- # rubocop:disable Metrics/AbcSize
120
- def lookup(predicate, options)
121
- opts = options.reject { |k, _| config.lookup_options.include?(k) }
122
- path = lookup_paths(predicate, options).detect { |key| key?(key, opts) }
123
-
124
- return unless path
125
-
126
- get(path, opts).values_at(:text, :meta)
127
- end
128
- # rubocop:enable Metrics/AbcSize
129
-
130
132
  # @api private
131
133
  def lookup_paths(predicate, options)
132
134
  tokens = lookup_tokens(predicate, options)
@@ -162,22 +164,18 @@ module Dry
162
164
  end
163
165
 
164
166
  # @api private
165
- def cache
166
- @cache ||= self.class.cache[self]
167
+ def default_locale
168
+ config.default_locale
167
169
  end
168
170
 
169
171
  # @api private
170
- def default_locale
171
- config.default_locale
172
+ def interpolatable_data(_key, _options, **_data)
173
+ raise NotImplementedError
172
174
  end
173
175
 
174
176
  # @api private
175
- def cache_key(predicate, options)
176
- if options.key?(:input)
177
- [predicate, options.reject { |k,| k.equal?(:input) }]
178
- else
179
- [predicate, options]
180
- end
177
+ def interpolate(_key, _options, **_data)
178
+ raise NotImplementedError
181
179
  end
182
180
 
183
181
  private
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'i18n'
4
- require 'dry/schema/messages/abstract'
3
+ require "i18n"
4
+ require "dry/schema/messages/abstract"
5
5
 
6
6
  module Dry
7
7
  module Schema
@@ -31,21 +31,19 @@ module Dry
31
31
  def get(key, options = EMPTY_HASH)
32
32
  return unless key
33
33
 
34
- opts = { locale: options.fetch(:locale, default_locale) }
34
+ result = t.(key, locale: options.fetch(:locale, default_locale))
35
35
 
36
- translation = t.(key, opts)
37
- text_key = "#{key}.text"
38
-
39
- if !translation.is_a?(Hash) || !key?(text_key, opts)
40
- return {
41
- text: translation,
42
- meta: EMPTY_HASH
43
- }
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
44
42
  end
45
43
 
46
44
  {
47
- text: t.(text_key, opts),
48
- meta: extract_meta(key, translation, opts)
45
+ text: text,
46
+ meta: meta
49
47
  }
50
48
  end
51
49
 
@@ -84,7 +82,7 @@ module Dry
84
82
  top_namespace = config.top_namespace
85
83
 
86
84
  mapped_data = data
87
- .map { |k, v| [k, { top_namespace => v[DEFAULT_MESSAGES_ROOT] }] }
85
+ .map { |k, v| [k, {top_namespace => v[DEFAULT_MESSAGES_ROOT]}] }
88
86
  .to_h
89
87
 
90
88
  store_translations(mapped_data)
@@ -97,12 +95,23 @@ module Dry
97
95
  end
98
96
 
99
97
  # @api private
100
- def cache_key(predicate, options)
101
- if options[:locale]
102
- super
103
- else
104
- [*super, I18n.locale]
105
- end
98
+ def interpolatable_data(_key, _options, **data)
99
+ data
100
+ end
101
+
102
+ # @api private
103
+ def interpolate(key, options, **data)
104
+ text_key = "#{key}.text"
105
+
106
+ opts = {
107
+ locale: default_locale,
108
+ **options,
109
+ **data
110
+ }
111
+
112
+ resolved_key = key?(text_key, opts) ? text_key : key
113
+
114
+ t.(resolved_key, **opts)
106
115
  end
107
116
 
108
117
  private
@@ -117,13 +126,6 @@ module Dry
117
126
  I18n.backend.store_translations(locale, data[locale.to_s])
118
127
  end
119
128
  end
120
-
121
- def extract_meta(parent_key, translation, options)
122
- translation.keys.each_with_object({}) do |k, meta|
123
- meta_key = "#{parent_key}.#{k}"
124
- meta[k] = t.(meta_key, options) if k != :text && key?(meta_key, options)
125
- end
126
- end
127
129
  end
128
130
  end
129
131
  end
@@ -24,7 +24,7 @@ module Dry
24
24
  @config = messages.config
25
25
  @namespace = namespace
26
26
  @messages = messages
27
- @call_opts = { namespace: namespace }.freeze
27
+ @call_opts = {namespace: namespace}.freeze
28
28
  end
29
29
 
30
30
  # Get a message for the given key and its options
@@ -62,13 +62,23 @@ module Dry
62
62
  # @api private
63
63
  def rule_lookup_paths(tokens)
64
64
  base_paths = messages.rule_lookup_paths(tokens)
65
- base_paths.map { |key| key.gsub('dry_schema', "dry_schema.#{namespace}") } + base_paths
65
+ base_paths.map { |key| key.gsub("dry_schema", "dry_schema.#{namespace}") } + base_paths
66
66
  end
67
67
 
68
68
  # @api private
69
69
  def cache_key(predicate, options)
70
70
  messages.cache_key(predicate, options)
71
71
  end
72
+
73
+ # @api private
74
+ def interpolatable_data(key, options, **data)
75
+ messages.interpolatable_data(key, options, **data)
76
+ end
77
+
78
+ # @api private
79
+ def interpolate(key, options, **data)
80
+ messages.interpolate(key, options, **data)
81
+ end
72
82
  end
73
83
  end
74
84
  end
@@ -1,67 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/equalizer'
3
+ require "dry/initializer"
4
+ require "dry/equalizer"
4
5
 
5
- require 'dry/schema/constants'
6
+ require "dry/schema/constants"
6
7
 
7
8
  module Dry
8
9
  module Schema
9
10
  module Messages
10
- # Template wraps a string with interpolation tokens and defines evaluator function
11
- # dynamically
12
- #
13
11
  # @api private
14
12
  class Template
15
- include Dry::Equalizer(:text)
13
+ extend Dry::Initializer
14
+ include Dry::Equalizer(:messages, :key, :options)
16
15
 
17
- TOKEN_REGEXP = /%{(\w*)}/
18
-
19
- # !@attribute [r] text
20
- # @return [String]
21
- attr_reader :text
22
-
23
- # !@attribute [r] tokens
24
- # @return [Hash]
25
- attr_reader :tokens
26
-
27
- # !@attribute [r] evaluator
28
- # @return [Proc]
29
- attr_reader :evaluator
16
+ option :messages
17
+ option :key
18
+ option :options
30
19
 
31
20
  # @api private
32
- def self.[](input)
33
- new(*parse(input))
21
+ def data(data = EMPTY_HASH)
22
+ ensure_message!
23
+ messages.interpolatable_data(key, options, **options, **data)
34
24
  end
35
25
 
36
26
  # @api private
37
- def self.parse(input)
38
- tokens = input.scan(TOKEN_REGEXP).flatten(1).map(&:to_sym)
39
- text = input.gsub('%', '#')
40
-
41
- evaluator = <<-RUBY.strip
42
- -> (#{tokens.map { |token| "#{token}:" }.join(", ")}) { "#{text}" }
43
- RUBY
44
-
45
- [text, tokens, eval(evaluator, binding, __FILE__, __LINE__ - 3)]
27
+ def call(data = EMPTY_HASH)
28
+ ensure_message!
29
+ messages.interpolate(key, options, **data)
46
30
  end
31
+ alias_method :[], :call
47
32
 
48
- # @api private
49
- def initialize(text, tokens, evaluator)
50
- @text = text
51
- @tokens = tokens
52
- @evaluator = evaluator
53
- end
33
+ private
54
34
 
55
- # @api private
56
- def data(input)
57
- tokens.each_with_object({}) { |k, h| h[k] = input[k] }
58
- end
35
+ def ensure_message!
36
+ return if messages.key?(key, options)
59
37
 
60
- # @api private
61
- def call(data = EMPTY_HASH)
62
- data.empty? ? evaluator.() : evaluator.(**data)
38
+ raise KeyError, "No message found for template, template=#{inspect}"
63
39
  end
64
- alias_method :[], :call
65
40
  end
66
41
  end
67
42
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
- require 'pathname'
3
+ require "yaml"
4
+ require "pathname"
5
5
 
6
- require 'dry/equalizer'
7
- require 'dry/schema/constants'
8
- require 'dry/schema/messages/abstract'
6
+ require "dry/equalizer"
7
+ require "dry/schema/constants"
8
+ require "dry/schema/messages/abstract"
9
9
 
10
10
  module Dry
11
11
  module Schema
@@ -13,7 +13,13 @@ module Dry
13
13
  #
14
14
  # @api public
15
15
  class Messages::YAML < Messages::Abstract
16
- LOCALE_TOKEN = '%<locale>s'
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
17
23
 
18
24
  include Dry::Equalizer(:data)
19
25
 
@@ -22,7 +28,7 @@ module Dry
22
28
  # @return [Hash]
23
29
  attr_reader :data
24
30
 
25
- # Translation function
31
+ # Translation function
26
32
  #
27
33
  # @return [Proc]
28
34
  attr_reader :t
@@ -45,21 +51,26 @@ module Dry
45
51
  hash.each do |key, value|
46
52
  flat_hash(value, [*path, key], keys) if value.is_a?(Hash)
47
53
 
48
- if value.is_a?(String) && hash['text'] != value
54
+ if value.is_a?(String) && hash["text"] != value
49
55
  keys[[*path, key].join(DOT)] = {
50
56
  text: value,
51
57
  meta: EMPTY_HASH
52
58
  }
53
- elsif value.is_a?(Hash) && value['text'].is_a?(String)
59
+ elsif value.is_a?(Hash) && value["text"].is_a?(String)
54
60
  keys[[*path, key].join(DOT)] = {
55
- text: value['text'],
56
- meta: value.dup.delete_if { |k| k == 'text' }.map { |k, v| [k.to_sym, v] }.to_h
61
+ text: value["text"],
62
+ meta: value.dup.delete_if { |k| k == "text" }.map { |k, v| [k.to_sym, v] }.to_h
57
63
  }
58
64
  end
59
65
  end
60
66
  keys
61
67
  end
62
68
 
69
+ # @api private
70
+ def self.cache
71
+ @cache ||= Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new }
72
+ end
73
+
63
74
  # @api private
64
75
  def initialize(data: EMPTY_HASH, config: nil)
65
76
  super()
@@ -77,7 +88,7 @@ module Dry
77
88
  #
78
89
  # @api public
79
90
  def looked_up_paths(predicate, options)
80
- super.map { |path| path % { locale: options[:locale] || default_locale } }
91
+ super.map { |path| path % {locale: options[:locale] || default_locale} }
81
92
  end
82
93
 
83
94
  # Get a message for the given key and its options
@@ -124,12 +135,48 @@ module Dry
124
135
 
125
136
  # @api private
126
137
  def prepare
127
- @data = config.load_paths.map { |path| load_translations(path) }.reduce(:merge)
138
+ @data = config.load_paths.map { |path| load_translations(path) }.reduce({}, :merge)
128
139
  self
129
140
  end
130
141
 
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
147
+
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
153
+
131
154
  private
132
155
 
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("%", "#")
161
+
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
167
+
168
+ {
169
+ tokens: tokens,
170
+ evaluator: evaluator
171
+ }
172
+ end
173
+ end
174
+
175
+ # @api private
176
+ def cache
177
+ @cache ||= self.class.cache[self]
178
+ end
179
+
133
180
  # @api private
134
181
  def load_translations(path)
135
182
  data = self.class.flat_hash(YAML.load_file(path))
@@ -143,7 +190,7 @@ module Dry
143
190
  def evaluated_key(key, options)
144
191
  return key unless key.include?(LOCALE_TOKEN)
145
192
 
146
- key % { locale: options[:locale] || default_locale }
193
+ key % {locale: options[:locale] || default_locale}
147
194
  end
148
195
  end
149
196
  end