dry-schema 1.4.3 → 1.5.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +219 -97
  3. data/config/errors.yml +4 -0
  4. data/dry-schema.gemspec +46 -0
  5. data/lib/dry-schema.rb +1 -1
  6. data/lib/dry/schema.rb +19 -6
  7. data/lib/dry/schema/compiler.rb +5 -5
  8. data/lib/dry/schema/config.rb +15 -6
  9. data/lib/dry/schema/constants.rb +16 -7
  10. data/lib/dry/schema/dsl.rb +87 -27
  11. data/lib/dry/schema/extensions.rb +10 -2
  12. data/lib/dry/schema/extensions/hints.rb +15 -8
  13. data/lib/dry/schema/extensions/hints/message_compiler_methods.rb +1 -1
  14. data/lib/dry/schema/extensions/hints/message_set_methods.rb +0 -47
  15. data/lib/dry/schema/extensions/info.rb +27 -0
  16. data/lib/dry/schema/extensions/info/schema_compiler.rb +105 -0
  17. data/lib/dry/schema/extensions/monads.rb +1 -1
  18. data/lib/dry/schema/extensions/struct.rb +32 -0
  19. data/lib/dry/schema/json.rb +1 -1
  20. data/lib/dry/schema/key.rb +16 -1
  21. data/lib/dry/schema/key_coercer.rb +4 -4
  22. data/lib/dry/schema/key_map.rb +9 -4
  23. data/lib/dry/schema/key_validator.rb +67 -0
  24. data/lib/dry/schema/macros.rb +8 -8
  25. data/lib/dry/schema/macros/array.rb +17 -4
  26. data/lib/dry/schema/macros/core.rb +9 -4
  27. data/lib/dry/schema/macros/dsl.rb +34 -19
  28. data/lib/dry/schema/macros/each.rb +4 -4
  29. data/lib/dry/schema/macros/filled.rb +5 -5
  30. data/lib/dry/schema/macros/hash.rb +21 -3
  31. data/lib/dry/schema/macros/key.rb +9 -9
  32. data/lib/dry/schema/macros/maybe.rb +3 -3
  33. data/lib/dry/schema/macros/optional.rb +1 -1
  34. data/lib/dry/schema/macros/required.rb +1 -1
  35. data/lib/dry/schema/macros/schema.rb +23 -2
  36. data/lib/dry/schema/macros/value.rb +32 -10
  37. data/lib/dry/schema/message.rb +35 -9
  38. data/lib/dry/schema/message/or.rb +18 -39
  39. data/lib/dry/schema/message/or/abstract.rb +28 -0
  40. data/lib/dry/schema/message/or/multi_path.rb +37 -0
  41. data/lib/dry/schema/message/or/single_path.rb +64 -0
  42. data/lib/dry/schema/message_compiler.rb +55 -19
  43. data/lib/dry/schema/message_compiler/visitor_opts.rb +2 -2
  44. data/lib/dry/schema/message_set.rb +25 -36
  45. data/lib/dry/schema/messages.rb +6 -6
  46. data/lib/dry/schema/messages/abstract.rb +54 -56
  47. data/lib/dry/schema/messages/i18n.rb +29 -27
  48. data/lib/dry/schema/messages/namespaced.rb +12 -2
  49. data/lib/dry/schema/messages/template.rb +19 -44
  50. data/lib/dry/schema/messages/yaml.rb +60 -13
  51. data/lib/dry/schema/params.rb +1 -1
  52. data/lib/dry/schema/path.rb +44 -5
  53. data/lib/dry/schema/predicate.rb +4 -2
  54. data/lib/dry/schema/predicate_inferrer.rb +4 -184
  55. data/lib/dry/schema/predicate_registry.rb +2 -2
  56. data/lib/dry/schema/primitive_inferrer.rb +16 -0
  57. data/lib/dry/schema/processor.rb +49 -28
  58. data/lib/dry/schema/processor_steps.rb +50 -27
  59. data/lib/dry/schema/result.rb +44 -6
  60. data/lib/dry/schema/rule_applier.rb +7 -7
  61. data/lib/dry/schema/step.rb +79 -0
  62. data/lib/dry/schema/trace.rb +5 -4
  63. data/lib/dry/schema/type_container.rb +3 -3
  64. data/lib/dry/schema/type_registry.rb +2 -2
  65. data/lib/dry/schema/types.rb +1 -1
  66. data/lib/dry/schema/value_coercer.rb +2 -2
  67. data/lib/dry/schema/version.rb +1 -1
  68. metadata +22 -8
@@ -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
 
@@ -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