dry-schema 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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