dry-schema 0.1.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE +20 -0
  4. data/README.md +21 -0
  5. data/config/errors.yml +91 -0
  6. data/lib/dry-schema.rb +1 -0
  7. data/lib/dry/schema.rb +51 -0
  8. data/lib/dry/schema/compiler.rb +31 -0
  9. data/lib/dry/schema/config.rb +52 -0
  10. data/lib/dry/schema/constants.rb +13 -0
  11. data/lib/dry/schema/dsl.rb +382 -0
  12. data/lib/dry/schema/extensions.rb +3 -0
  13. data/lib/dry/schema/extensions/monads.rb +18 -0
  14. data/lib/dry/schema/json.rb +16 -0
  15. data/lib/dry/schema/key.rb +166 -0
  16. data/lib/dry/schema/key_coercer.rb +37 -0
  17. data/lib/dry/schema/key_map.rb +133 -0
  18. data/lib/dry/schema/macros.rb +6 -0
  19. data/lib/dry/schema/macros/core.rb +51 -0
  20. data/lib/dry/schema/macros/dsl.rb +74 -0
  21. data/lib/dry/schema/macros/each.rb +18 -0
  22. data/lib/dry/schema/macros/filled.rb +24 -0
  23. data/lib/dry/schema/macros/hash.rb +46 -0
  24. data/lib/dry/schema/macros/key.rb +137 -0
  25. data/lib/dry/schema/macros/maybe.rb +37 -0
  26. data/lib/dry/schema/macros/optional.rb +17 -0
  27. data/lib/dry/schema/macros/required.rb +17 -0
  28. data/lib/dry/schema/macros/value.rb +41 -0
  29. data/lib/dry/schema/message.rb +103 -0
  30. data/lib/dry/schema/message_compiler.rb +193 -0
  31. data/lib/dry/schema/message_compiler/visitor_opts.rb +30 -0
  32. data/lib/dry/schema/message_set.rb +123 -0
  33. data/lib/dry/schema/messages.rb +42 -0
  34. data/lib/dry/schema/messages/abstract.rb +143 -0
  35. data/lib/dry/schema/messages/i18n.rb +60 -0
  36. data/lib/dry/schema/messages/namespaced.rb +53 -0
  37. data/lib/dry/schema/messages/yaml.rb +82 -0
  38. data/lib/dry/schema/params.rb +16 -0
  39. data/lib/dry/schema/predicate.rb +80 -0
  40. data/lib/dry/schema/predicate_inferrer.rb +49 -0
  41. data/lib/dry/schema/predicate_registry.rb +38 -0
  42. data/lib/dry/schema/processor.rb +151 -0
  43. data/lib/dry/schema/result.rb +164 -0
  44. data/lib/dry/schema/rule_applier.rb +45 -0
  45. data/lib/dry/schema/trace.rb +103 -0
  46. data/lib/dry/schema/type_registry.rb +42 -0
  47. data/lib/dry/schema/types.rb +12 -0
  48. data/lib/dry/schema/value_coercer.rb +27 -0
  49. data/lib/dry/schema/version.rb +5 -0
  50. metadata +255 -0
@@ -0,0 +1,193 @@
1
+ require 'dry/schema/constants'
2
+ require 'dry/schema/message'
3
+ require 'dry/schema/message_set'
4
+ require 'dry/schema/message_compiler/visitor_opts'
5
+
6
+ module Dry
7
+ module Schema
8
+ # Compiles rule results AST into human-readable format
9
+ #
10
+ # @api private
11
+ class MessageCompiler
12
+ attr_reader :messages, :options, :locale, :default_lookup_options
13
+
14
+ EMPTY_OPTS = VisitorOpts.new
15
+ LIST_SEPARATOR = ', '.freeze
16
+
17
+ # @api private
18
+ def initialize(messages, options = {})
19
+ @messages = messages
20
+ @options = options
21
+ @full = @options.fetch(:full, false)
22
+ @hints = @options.fetch(:hints, true)
23
+ @locale = @options.fetch(:locale, messages.default_locale)
24
+ @default_lookup_options = { locale: locale }
25
+ end
26
+
27
+ # @api private
28
+ def full?
29
+ @full
30
+ end
31
+
32
+ # @api private
33
+ def hints?
34
+ @hints
35
+ end
36
+
37
+ # @api private
38
+ def with(new_options)
39
+ return self if new_options.empty?
40
+ self.class.new(messages, options.merge(new_options))
41
+ end
42
+
43
+ # @api private
44
+ def call(ast)
45
+ MessageSet[ast.map { |node| visit(node) }, failures: options.fetch(:failures, true)]
46
+ end
47
+
48
+ # @api private
49
+ def visit(node, *args)
50
+ __send__(:"visit_#{node[0]}", node[1], *args)
51
+ end
52
+
53
+ # @api private
54
+ def visit_failure(node, opts = EMPTY_OPTS.dup)
55
+ rule, other = node
56
+ visit(other, opts.(rule: rule))
57
+ end
58
+
59
+ # @api private
60
+ def visit_hint(node, opts = EMPTY_OPTS.dup)
61
+ if hints?
62
+ visit(node, opts.(message_type: :hint))
63
+ end
64
+ end
65
+
66
+ # @api private
67
+ def visit_each(node, opts = EMPTY_OPTS.dup)
68
+ # TODO: we can still generate a hint for elements here!
69
+ []
70
+ end
71
+
72
+ # @api private
73
+ def visit_not(node, opts = EMPTY_OPTS.dup)
74
+ visit(node, opts.(not: true))
75
+ end
76
+
77
+ # @api private
78
+ def visit_and(node, opts = EMPTY_OPTS.dup)
79
+ left, right = node.map { |n| visit(n, opts) }
80
+
81
+ if right
82
+ [left, right]
83
+ else
84
+ left
85
+ end
86
+ end
87
+
88
+ # @api private
89
+ def visit_or(node, opts = EMPTY_OPTS.dup)
90
+ left, right = node.map { |n| visit(n, opts) }
91
+
92
+ if [left, right].flatten.map(&:path).uniq.size == 1
93
+ Message::Or.new(left, right, -> k { messages[k, default_lookup_options] })
94
+ elsif right.is_a?(Array)
95
+ right
96
+ else
97
+ [left, right]
98
+ end
99
+ end
100
+
101
+ # @api private
102
+ def visit_predicate(node, base_opts = EMPTY_OPTS.dup)
103
+ predicate, args = node
104
+
105
+ *arg_vals, val = args.map(&:last)
106
+ tokens = message_tokens(args)
107
+
108
+ input = val != Undefined ? val : nil
109
+
110
+ options = base_opts.update(lookup_options(arg_vals: arg_vals, input: input))
111
+ msg_opts = options.update(tokens)
112
+
113
+ rule = msg_opts[:rule]
114
+ path = msg_opts[:path]
115
+
116
+ template = messages[rule] || messages[predicate, msg_opts]
117
+
118
+ unless template
119
+ raise MissingMessageError, "message for #{predicate} was not found"
120
+ end
121
+
122
+ text = message_text(rule, template, tokens, options)
123
+
124
+ message_class = options[:message_type] == :hint ? Hint : Message
125
+
126
+ message_class[
127
+ predicate, path, text,
128
+ args: arg_vals,
129
+ input: input,
130
+ rule: rule || msg_opts[:name]
131
+ ]
132
+ end
133
+
134
+ # @api private
135
+ def visit_key(node, opts = EMPTY_OPTS.dup)
136
+ name, other = node
137
+ visit(other, opts.(path: name))
138
+ end
139
+
140
+ # @api private
141
+ def visit_set(node, opts = EMPTY_OPTS.dup)
142
+ node.map { |el| visit(el, opts) }
143
+ end
144
+
145
+ # @api private
146
+ def visit_implication(node, *args)
147
+ _, right = node
148
+ visit(right, *args)
149
+ end
150
+
151
+ # @api private
152
+ def visit_xor(node, opts = EMPTY_OPTS.dup)
153
+ left, right = node
154
+ [visit(left, opts), visit(right, opts)].uniq
155
+ end
156
+
157
+ # @api private
158
+ def lookup_options(arg_vals: [], input: nil)
159
+ default_lookup_options.merge(
160
+ arg_type: arg_vals.size == 1 && arg_vals[0].class,
161
+ val_type: input.class
162
+ )
163
+ end
164
+
165
+ # @api private
166
+ def message_text(rule, template, tokens, opts)
167
+ text = template % tokens
168
+
169
+ if full?
170
+ rule_name = rule ? (messages.rule(rule, opts) || rule) : (opts[:name] || opts[:path].last)
171
+ "#{rule_name} #{text}"
172
+ else
173
+ text
174
+ end
175
+ end
176
+
177
+ # @api private
178
+ def message_tokens(args)
179
+ args.each_with_object({}) { |arg, hash|
180
+ case arg[1]
181
+ when Array
182
+ hash[arg[0]] = arg[1].join(LIST_SEPARATOR)
183
+ when Range
184
+ hash["#{arg[0]}_left".to_sym] = arg[1].first
185
+ hash["#{arg[0]}_right".to_sym] = arg[1].last
186
+ else
187
+ hash[arg[0]] = arg[1]
188
+ end
189
+ }
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,30 @@
1
+ module Dry
2
+ module Schema
3
+ # @api private
4
+ class MessageCompiler
5
+ # Optimized option hash used by visitor methods in message compiler
6
+ #
7
+ # @api private
8
+ class VisitorOpts < Hash
9
+ # @api private
10
+ def self.new
11
+ opts = super
12
+ opts[:path] = EMPTY_ARRAY
13
+ opts[:rule] = nil
14
+ opts[:message_type] = :failure
15
+ opts
16
+ end
17
+
18
+ # @api private
19
+ def path
20
+ self[:path]
21
+ end
22
+
23
+ # @api private
24
+ def call(other)
25
+ merge(other.update(path: [*path, *other[:path]]))
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,123 @@
1
+ module Dry
2
+ module Schema
3
+ # A set of messages used to generate errors
4
+ #
5
+ # @see Result#message_set
6
+ #
7
+ # @api public
8
+ class MessageSet
9
+ include Enumerable
10
+
11
+ HINT_EXCLUSION = %i(
12
+ key? filled? nil? bool?
13
+ str? int? float? decimal?
14
+ date? date_time? time? hash?
15
+ array? format?
16
+ ).freeze
17
+
18
+ attr_reader :messages, :failures, :hints, :paths, :placeholders, :options
19
+
20
+ # @api private
21
+ def self.[](messages, options = EMPTY_HASH)
22
+ new(messages.flatten, options)
23
+ end
24
+
25
+ # @api private
26
+ def initialize(messages, options = EMPTY_HASH)
27
+ @messages = messages
28
+ @hints = messages.select(&:hint?)
29
+ @failures = messages - hints
30
+ @paths = failures.map(&:path).uniq
31
+ @options = options
32
+
33
+ initialize_hints!
34
+ initialize_placeholders!
35
+ end
36
+
37
+ # @api public
38
+ def each(&block)
39
+ return to_enum unless block
40
+ messages.each(&block)
41
+ end
42
+
43
+ # @api public
44
+ def to_h
45
+ failures? ? messages_map : hints_map
46
+ end
47
+ alias_method :to_hash, :to_h
48
+ alias_method :dump, :to_h
49
+
50
+ # @api private
51
+ def failures?
52
+ options[:failures].equal?(true)
53
+ end
54
+
55
+ # @api private
56
+ def empty?
57
+ messages.empty?
58
+ end
59
+
60
+ private
61
+
62
+ # @api private
63
+ def messages_map
64
+ failures.group_by(&:path).reduce(placeholders) do |hash, (path, msgs)|
65
+ node = path.reduce(hash) { |a, e| a[e] }
66
+
67
+ msgs.each do |msg|
68
+ node << msg
69
+ end
70
+
71
+ msg_hints = hint_groups[path]
72
+ node.concat(msg_hints) if msg_hints
73
+
74
+ node.map!(&:to_s)
75
+
76
+ hash
77
+ end
78
+ end
79
+
80
+ # @api private
81
+ def hints_map
82
+ hints.group_by(&:path).reduce(placeholders) do |hash, (path, msgs)|
83
+ node = path.reduce(hash) { |a, e| a[e] }
84
+
85
+ msgs.each do |msg|
86
+ node << msg
87
+ end
88
+
89
+ node.map!(&:to_s)
90
+
91
+ hash
92
+ end
93
+ end
94
+
95
+ # @api private
96
+ def hint_groups
97
+ @hint_groups ||= hints.group_by(&:path)
98
+ end
99
+
100
+ # @api private
101
+ def initialize_hints!
102
+ hints.reject! { |hint| HINT_EXCLUSION.include?(hint.predicate) }
103
+ end
104
+
105
+ # @api private
106
+ def initialize_placeholders!
107
+ @placeholders = paths.reduce({}) do |hash, path|
108
+ curr_idx = 0
109
+ last_idx = path.size - 1
110
+ node = hash
111
+
112
+ while curr_idx <= last_idx do
113
+ key = path[curr_idx]
114
+ node = (node[key] || node[key] = curr_idx < last_idx ? {} : [])
115
+ curr_idx += 1
116
+ end
117
+
118
+ hash
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,42 @@
1
+ module Dry
2
+ module Schema
3
+ # An API for configuring message backends
4
+ #
5
+ # @api private
6
+ module Messages
7
+ def self.setup(config)
8
+ messages = build(config)
9
+
10
+ if config.messages_file && config.namespace
11
+ messages.merge(config.messages_file).namespaced(config.namespace)
12
+ elsif config.messages_file
13
+ messages.merge(config.messages_file)
14
+ elsif config.namespace
15
+ messages.namespaced(config.namespace)
16
+ else
17
+ messages
18
+ end
19
+ end
20
+
21
+ # @api private
22
+ def self.build(config)
23
+ case config.messages
24
+ when :yaml then default
25
+ when :i18n then Messages::I18n.new
26
+ else
27
+ raise "+#{config.messages}+ is not a valid messages identifier"
28
+ end
29
+ end
30
+
31
+ # @api private
32
+ def self.default
33
+ Messages::YAML.load
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ require 'dry/schema/messages/abstract'
40
+ require 'dry/schema/messages/namespaced'
41
+ require 'dry/schema/messages/yaml'
42
+ require 'dry/schema/messages/i18n' if defined?(I18n)
@@ -0,0 +1,143 @@
1
+ require 'pathname'
2
+ require 'concurrent/map'
3
+ require 'dry/equalizer'
4
+ require 'dry/configurable'
5
+
6
+ require 'dry/schema/constants'
7
+
8
+ module Dry
9
+ module Schema
10
+ module Messages
11
+ # Abstract class for message backends
12
+ #
13
+ # @api public
14
+ class Abstract
15
+ extend Dry::Configurable
16
+ include Dry::Equalizer(:config)
17
+
18
+ DEFAULT_PATH = Pathname(__dir__).join('../../../../config/errors.yml').realpath.freeze
19
+
20
+ setting :paths, [DEFAULT_PATH]
21
+ setting :root, 'errors'.freeze
22
+ setting :lookup_options, [:root, :predicate, :path, :val_type, :arg_type].freeze
23
+
24
+ setting :lookup_paths, %w(
25
+ %{root}.rules.%{path}.%{predicate}.arg.%{arg_type}
26
+ %{root}.rules.%{path}.%{predicate}
27
+ %{root}.%{predicate}.%{message_type}
28
+ %{root}.%{predicate}.value.%{path}.arg.%{arg_type}
29
+ %{root}.%{predicate}.value.%{path}
30
+ %{root}.%{predicate}.value.%{val_type}.arg.%{arg_type}
31
+ %{root}.%{predicate}.value.%{val_type}
32
+ %{root}.%{predicate}.arg.%{arg_type}
33
+ %{root}.%{predicate}
34
+ ).freeze
35
+
36
+ setting :arg_type_default, 'default'.freeze
37
+ setting :val_type_default, 'default'.freeze
38
+
39
+ setting :arg_types, Hash.new { |*| config.arg_type_default }.update(
40
+ Range => 'range'
41
+ )
42
+
43
+ setting :val_types, Hash.new { |*| config.val_type_default }.update(
44
+ Range => 'range',
45
+ String => 'string'
46
+ )
47
+
48
+ # @api private
49
+ def self.cache
50
+ @cache ||= Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new }
51
+ end
52
+
53
+ # @api private
54
+ attr_reader :config
55
+
56
+ # @api private
57
+ def initialize
58
+ @config = self.class.config
59
+ end
60
+
61
+ # @api private
62
+ def hash
63
+ @hash ||= config.hash
64
+ end
65
+
66
+ # @api private
67
+ def rule(name, options = {})
68
+ path = "%{locale}.rules.#{name}"
69
+ get(path, options) if key?(path, options)
70
+ end
71
+
72
+ # Retrieve a message
73
+ #
74
+ # @return [String]
75
+ #
76
+ # @api public
77
+ def call(*args)
78
+ cache.fetch_or_store(args.hash) do
79
+ path, opts = lookup(*args)
80
+ get(path, opts) if path
81
+ end
82
+ end
83
+ alias_method :[], :call
84
+
85
+ # Try to find a message for the given predicate and its options
86
+ #
87
+ # @api private
88
+ def lookup(predicate, options = {})
89
+ tokens = options.merge(
90
+ root: options[:not] ? "#{root}.not" : root,
91
+ predicate: predicate,
92
+ arg_type: config.arg_types[options[:arg_type]],
93
+ val_type: config.val_types[options[:val_type]],
94
+ message_type: options[:message_type] || :failure
95
+ )
96
+
97
+ tokens[:path] = options[:rule] || Array(options[:path]).join(DOT)
98
+
99
+ opts = options.reject { |k, _| config.lookup_options.include?(k) }
100
+
101
+ path = lookup_paths(tokens).detect do |key|
102
+ key?(key, opts) && get(key, opts).is_a?(String)
103
+ end
104
+
105
+ [path, opts]
106
+ end
107
+
108
+ # @api private
109
+ def lookup_paths(tokens)
110
+ config.lookup_paths.map { |path| path % tokens }
111
+ end
112
+
113
+ # Return a new message backend that will look for messages under provided namespace
114
+ #
115
+ # @param [Symbol,String] namespace
116
+ #
117
+ # @api public
118
+ def namespaced(namespace)
119
+ Dry::Schema::Messages::Namespaced.new(namespace, self)
120
+ end
121
+
122
+ # Return root path to messages file
123
+ #
124
+ # @return [Pathname]
125
+ #
126
+ # @api public
127
+ def root
128
+ config.root
129
+ end
130
+
131
+ # @api private
132
+ def cache
133
+ @cache ||= self.class.cache[self]
134
+ end
135
+
136
+ # @api private
137
+ def default_locale
138
+ :en
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end