dry-schema 1.4.3 → 1.5.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +170 -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 +4 -4
  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 +88 -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 +66 -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 +37 -17
  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 +2 -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 +43 -5
  60. data/lib/dry/schema/rule_applier.rb +8 -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,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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/schema/processor'
3
+ require "dry/schema/processor"
4
4
 
5
5
  module Dry
6
6
  module Schema
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/schema/constants'
3
+ require "dry/schema/constants"
4
4
 
5
5
  module Dry
6
6
  module Schema
@@ -8,6 +8,7 @@ module Dry
8
8
  #
9
9
  # @api private
10
10
  class Path
11
+ include Dry.Equalizer(:keys)
11
12
  include Comparable
12
13
  include Enumerable
13
14
 
@@ -23,7 +24,7 @@ module Dry
23
24
  # @return [Path]
24
25
  #
25
26
  # @api private
26
- def self.[](spec)
27
+ def self.call(spec)
27
28
  case spec
28
29
  when Symbol, Array
29
30
  new(Array[*spec])
@@ -34,10 +35,15 @@ module Dry
34
35
  when Path
35
36
  spec
36
37
  else
37
- raise ArgumentError, '+spec+ must be either a Symbol, Array, Hash or a Path'
38
+ raise ArgumentError, "+spec+ must be either a Symbol, Array, Hash or a Path"
38
39
  end
39
40
  end
40
41
 
42
+ # @api private
43
+ def self.[](spec)
44
+ call(spec)
45
+ end
46
+
41
47
  # Extract a list of keys from a hash
42
48
  #
43
49
  # @api private
@@ -52,6 +58,28 @@ module Dry
52
58
  @keys = keys
53
59
  end
54
60
 
61
+ # @api private
62
+ def to_h(value = EMPTY_ARRAY.dup)
63
+ curr_idx = 0
64
+ last_idx = keys.size - 1
65
+ hash = EMPTY_HASH.dup
66
+ node = hash
67
+
68
+ while curr_idx <= last_idx
69
+ node =
70
+ node[keys[curr_idx]] =
71
+ if curr_idx == last_idx
72
+ value.is_a?(Array) ? value : [value]
73
+ else
74
+ EMPTY_HASH.dup
75
+ end
76
+
77
+ curr_idx += 1
78
+ end
79
+
80
+ hash
81
+ end
82
+
55
83
  # @api private
56
84
  def each(&block)
57
85
  keys.each(&block)
@@ -83,8 +111,19 @@ module Dry
83
111
  end
84
112
 
85
113
  # @api private
86
- def key_matches(other)
87
- map { |key| (idx = other.index(key)) && keys[idx].equal?(key) }
114
+ def &(other)
115
+ unless same_root?(other)
116
+ raise ArgumentError, "#{other.inspect} doesn't have the same root #{inspect}"
117
+ end
118
+
119
+ self.class.new(
120
+ key_matches(other, :select).compact.reject { |value| value.equal?(false) }
121
+ )
122
+ end
123
+
124
+ # @api private
125
+ def key_matches(other, meth = :map)
126
+ public_send(meth) { |key| (idx = other.index(key)) && keys[idx].equal?(key) }
88
127
  end
89
128
 
90
129
  # @api private
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'dry/equalizer'
4
- require 'dry/logic/operators'
3
+ require "dry/equalizer"
4
+ require "dry/logic/operators"
5
5
 
6
6
  module Dry
7
7
  module Schema