verse-schema 1.0.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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop-https---relaxed-ruby-style-rubocop-yml +153 -0
- data/.rubocop.yml +32 -0
- data/Gemfile +23 -0
- data/Gemfile.lock +87 -0
- data/README.md +1591 -0
- data/Rakefile +15 -0
- data/benchmarks/troubleshoot.rb +73 -0
- data/lib/tasks/readme.rake +94 -0
- data/lib/tasks/readme_doc_extractor.rb +158 -0
- data/lib/verse/schema/base.rb +80 -0
- data/lib/verse/schema/coalescer/register.rb +132 -0
- data/lib/verse/schema/coalescer.rb +81 -0
- data/lib/verse/schema/collection.rb +175 -0
- data/lib/verse/schema/dictionary.rb +160 -0
- data/lib/verse/schema/error_builder.rb +43 -0
- data/lib/verse/schema/field/ext.rb +24 -0
- data/lib/verse/schema/field.rb +394 -0
- data/lib/verse/schema/invalid_schema_error.rb +18 -0
- data/lib/verse/schema/optionable.rb +47 -0
- data/lib/verse/schema/post_processor.rb +56 -0
- data/lib/verse/schema/result.rb +30 -0
- data/lib/verse/schema/scalar.rb +154 -0
- data/lib/verse/schema/selector.rb +172 -0
- data/lib/verse/schema/struct.rb +315 -0
- data/lib/verse/schema/version.rb +7 -0
- data/lib/verse/schema.rb +75 -0
- data/sig/verse/schema.rbs +6 -0
- data/templates/README.md.erb +73 -0
- metadata +83 -0
@@ -0,0 +1,394 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "attr_chainable"
|
4
|
+
require_relative "./coalescer"
|
5
|
+
require_relative "./post_processor"
|
6
|
+
|
7
|
+
module Verse
|
8
|
+
module Schema
|
9
|
+
# A field in a schema
|
10
|
+
class Field
|
11
|
+
attr_reader :opts, :post_processors, :name, :type
|
12
|
+
|
13
|
+
def initialize(name, type, opts, post_processors: nil, &block)
|
14
|
+
@name = name
|
15
|
+
@opts = opts
|
16
|
+
# Setup identity processor
|
17
|
+
@post_processors = post_processors
|
18
|
+
|
19
|
+
of_arg = opts[:of] # For array and dictionary
|
20
|
+
of_arg = [of_arg] unless of_arg.nil? || of_arg.is_a?(Array)
|
21
|
+
|
22
|
+
nested_schema = nil
|
23
|
+
|
24
|
+
if block_given?
|
25
|
+
if of_arg
|
26
|
+
raise ArgumentError, "cannot pass `of` and a block at the same time"
|
27
|
+
end
|
28
|
+
|
29
|
+
if type != Hash && type != Object && type != Array
|
30
|
+
raise ArgumentError, "block can only be used with Hash, Object or Array type"
|
31
|
+
end
|
32
|
+
|
33
|
+
nested_schema = Schema.define(&block)
|
34
|
+
|
35
|
+
if type == Array
|
36
|
+
self.type(
|
37
|
+
Schema.array(nested_schema)
|
38
|
+
)
|
39
|
+
else
|
40
|
+
self.type(nested_schema)
|
41
|
+
end
|
42
|
+
else
|
43
|
+
self.type(type)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def type(type = Nothing, of: Nothing, over: Nothing)
|
48
|
+
return @type if type == Nothing
|
49
|
+
|
50
|
+
@opts[:of] = of if of != Nothing
|
51
|
+
@opts[:over] = over if over != Nothing
|
52
|
+
|
53
|
+
of_arg = @opts[:of] # For array and dictionary
|
54
|
+
of_arg = [of_arg] unless of_arg.nil? || of_arg.is_a?(Array)
|
55
|
+
|
56
|
+
if type == Hash || type == Object
|
57
|
+
type = Schema.dictionary(*of_arg) if of_arg # dictionary
|
58
|
+
elsif type == Array
|
59
|
+
type = Schema.array(*of_arg) if of_arg
|
60
|
+
elsif type.is_a?(Hash) # Selector structure
|
61
|
+
@opts[:over] => Symbol # Ensure there is an over field
|
62
|
+
|
63
|
+
type = Schema.selector(**type)
|
64
|
+
end
|
65
|
+
|
66
|
+
@type = type
|
67
|
+
|
68
|
+
self
|
69
|
+
end
|
70
|
+
|
71
|
+
# Set the field as optional. This will validate schema where the
|
72
|
+
# field key is missing.
|
73
|
+
#
|
74
|
+
# Note: If the key is nil, the field will be considered as
|
75
|
+
# existing, and your schema might fail. To allow field to be nil,
|
76
|
+
# you must use union of type:
|
77
|
+
#
|
78
|
+
# field(:name, [String, NilClass]).optional
|
79
|
+
#
|
80
|
+
# This will allow the field to be nil, and will not raise an error
|
81
|
+
# if the field is missing.
|
82
|
+
#
|
83
|
+
# @return [self]
|
84
|
+
def optional
|
85
|
+
@opts[:optional] = true
|
86
|
+
|
87
|
+
self
|
88
|
+
end
|
89
|
+
|
90
|
+
def key(value = Nothing)
|
91
|
+
if value == Nothing
|
92
|
+
@opts[:key] ||= @name
|
93
|
+
else
|
94
|
+
@opts[:key] = value
|
95
|
+
self
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def dup
|
100
|
+
Field.new(
|
101
|
+
@name,
|
102
|
+
@type,
|
103
|
+
@opts.dup,
|
104
|
+
post_processors: @post_processors&.dup
|
105
|
+
)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Add metadata to the field. Useful for documentation
|
109
|
+
# purpose
|
110
|
+
# @param [Hash] opts the fields to add to the meta hash
|
111
|
+
# @return [self]
|
112
|
+
# @example
|
113
|
+
#
|
114
|
+
# field(:name, String).meta(description: "The name of the user")
|
115
|
+
#
|
116
|
+
def meta(**opts)
|
117
|
+
@opts[:meta] ||= {}
|
118
|
+
@opts[:meta].merge!(opts)
|
119
|
+
|
120
|
+
self
|
121
|
+
end
|
122
|
+
|
123
|
+
# Set the default value for the field
|
124
|
+
#
|
125
|
+
# Please note that the default value is applied BEFORE any post processor
|
126
|
+
# such as `transform` or `rule` are ran.
|
127
|
+
#
|
128
|
+
# For example, if you have this:
|
129
|
+
#
|
130
|
+
# field(:age, Integer).default(17).rule("must be greater than 18") { |v| v > 18 }
|
131
|
+
#
|
132
|
+
# This will fail if the field is not present, because the default value
|
133
|
+
# is applied before the rule is ran.
|
134
|
+
#
|
135
|
+
# @param [Object] value the default value
|
136
|
+
# @param [Proc] block the block to call to get the default value, if any.
|
137
|
+
# @return [self]
|
138
|
+
def default(value = Nothing, &block)
|
139
|
+
if value == Nothing && !block_given?
|
140
|
+
if @opts[:default].is_a?(Proc)
|
141
|
+
@opts[:default].call
|
142
|
+
else
|
143
|
+
@opts[:default]
|
144
|
+
end
|
145
|
+
else
|
146
|
+
@opts[:default] = block || value
|
147
|
+
optional
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Check if the field has a default value
|
152
|
+
# @return [Boolean] true if the field has a default value
|
153
|
+
def default?
|
154
|
+
@opts.key?(:default)
|
155
|
+
end
|
156
|
+
|
157
|
+
# Mark the field as required. This will make the field mandatory.
|
158
|
+
# Remove any default value.
|
159
|
+
# @return [self]
|
160
|
+
def required
|
161
|
+
@opts[:optional] = false
|
162
|
+
@opts.delete(:default)
|
163
|
+
|
164
|
+
self
|
165
|
+
end
|
166
|
+
|
167
|
+
# Check if the field is required
|
168
|
+
# @return [Boolean] true if the field is required
|
169
|
+
def required?
|
170
|
+
!@opts[:optional]
|
171
|
+
end
|
172
|
+
|
173
|
+
# Check if the field is optional
|
174
|
+
# @return [Boolean] true if the field is optional
|
175
|
+
def optional?
|
176
|
+
!!@opts[:optional]
|
177
|
+
end
|
178
|
+
|
179
|
+
def array?
|
180
|
+
@type == Array || ( @type.is_a?(Schema::Base) && @type.type == :array )
|
181
|
+
end
|
182
|
+
|
183
|
+
def dictionary?
|
184
|
+
@type.is_a?(Schema::Base) && @type.type == :dictionary
|
185
|
+
end
|
186
|
+
|
187
|
+
# Add a rule to the field. A rule is a block that will be called
|
188
|
+
# with the value of the field. If the block returns false, an error
|
189
|
+
# will be added to the error builder.
|
190
|
+
#
|
191
|
+
# `rule` and `transform` can be chained together to add multiple rules.
|
192
|
+
# They are called in the order they are added.
|
193
|
+
#
|
194
|
+
# @param [String] error message if the rule is failing
|
195
|
+
# @param [Proc] block the block to call to validate the value
|
196
|
+
# @return [self]
|
197
|
+
def rule(rule, &block)
|
198
|
+
rule_processor = \
|
199
|
+
case rule
|
200
|
+
when String
|
201
|
+
PostProcessor.new(key:) do |value, error|
|
202
|
+
case block.arity
|
203
|
+
when 1, -1, -2 # -1/-2 are for dealing with &:method block.
|
204
|
+
error.add(opts[:key], rule, **locals) unless instance_exec(value, &block)
|
205
|
+
when 2
|
206
|
+
error.add(opts[:key], rule, **locals) unless instance_exec(value, error, &block)
|
207
|
+
else
|
208
|
+
# :nocov:
|
209
|
+
raise ArgumentError, "invalid block arity"
|
210
|
+
# :nocov:
|
211
|
+
end
|
212
|
+
|
213
|
+
value
|
214
|
+
end
|
215
|
+
when PostProcessor
|
216
|
+
rule.opts[:key] = key
|
217
|
+
rule.dup
|
218
|
+
else
|
219
|
+
# :nocov:
|
220
|
+
raise ArgumentError, "invalid rule type #{rule}"
|
221
|
+
# :nocov:
|
222
|
+
end
|
223
|
+
|
224
|
+
attach_post_processor(rule_processor)
|
225
|
+
|
226
|
+
self
|
227
|
+
end
|
228
|
+
|
229
|
+
def attach_post_processor(processor)
|
230
|
+
if @post_processors
|
231
|
+
@post_processors.attach(processor)
|
232
|
+
else
|
233
|
+
@post_processors = processor
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# Add a transformation to the field. A transformation is a block that
|
238
|
+
# will be called with the value of the field. The return value of the
|
239
|
+
# block will be the new value of the field.
|
240
|
+
#
|
241
|
+
# If the block raises an error, the error will be added to the error
|
242
|
+
# builder.
|
243
|
+
#
|
244
|
+
# @param [Proc] block the block to call to transform the value
|
245
|
+
# @return [self]
|
246
|
+
def transform(&block)
|
247
|
+
callback = proc do |value, _name, error_builder|
|
248
|
+
next self if error_builder.errors.any?
|
249
|
+
|
250
|
+
instance_exec(value, error_builder, &block)
|
251
|
+
end
|
252
|
+
|
253
|
+
attach_post_processor(callback)
|
254
|
+
|
255
|
+
self
|
256
|
+
end
|
257
|
+
|
258
|
+
# Check whether the field is matching the condition of the parent field.
|
259
|
+
def inherit?(parent_field)
|
260
|
+
child_type = @type
|
261
|
+
parent_type = parent_field.type
|
262
|
+
|
263
|
+
# Helper lambda to check subtype relationship between two NON-UNION types
|
264
|
+
is_subtype_single = lambda do |c, p|
|
265
|
+
if c.is_a?(Verse::Schema::Scalar) && p.is_a?(Verse::Schema::Scalar)
|
266
|
+
c.values.all? { |c_val| p.values.any? { |p_val| c_val <= p_val } }
|
267
|
+
elsif c.is_a?(Verse::Schema::Scalar) && p.is_a?(Class)
|
268
|
+
c.values.all? { |c_val| c_val <= p }
|
269
|
+
elsif c.is_a?(Class) && p.is_a?(Verse::Schema::Scalar)
|
270
|
+
p.values.any? { |p_val| c <= p_val }
|
271
|
+
elsif c.is_a?(Verse::Schema::Base) && p.is_a?(Verse::Schema::Base)
|
272
|
+
c <= p
|
273
|
+
elsif c.is_a?(Class) && p.is_a?(Class)
|
274
|
+
c <= p
|
275
|
+
else
|
276
|
+
false # Incompatible types
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
# Determine basic type compatibility based on single/union combinations
|
281
|
+
types_compatible = \
|
282
|
+
if child_type.is_a?(Array) && parent_type.is_a?(Array) # Union <= Union
|
283
|
+
child_type.all? { |c_type| parent_type.any? { |p_type| is_subtype_single.call(c_type, p_type) } }
|
284
|
+
elsif child_type.is_a?(Array) && !parent_type.is_a?(Array) # Union <= Single
|
285
|
+
child_type.all? { |c_type| is_subtype_single.call(c_type, parent_type) }
|
286
|
+
elsif !child_type.is_a?(Array) && parent_type.is_a?(Array) # Single <= Union
|
287
|
+
parent_type.any? { |p_type| is_subtype_single.call(child_type, p_type) }
|
288
|
+
else # Single <= Single
|
289
|
+
is_subtype_single.call(child_type, parent_type)
|
290
|
+
end
|
291
|
+
|
292
|
+
# If basic types are not compatible, inheritance fails immediately.
|
293
|
+
return false unless types_compatible
|
294
|
+
|
295
|
+
# If basic types ARE compatible, proceed with option checks for refinement.
|
296
|
+
if parent_field.opts[:schema]
|
297
|
+
# Parent expects a specific nested schema structure (defined via block)
|
298
|
+
# Child must be compatible (Hash or specific Struct/Dictionary) and its schema must inherit.
|
299
|
+
(child_type.is_a?(Verse::Schema::Struct) || child_type.is_a?(Verse::Schema::Dictionary) || child_type == Hash) &&
|
300
|
+
(
|
301
|
+
!@opts[:schema] || # Child doesn't have a specific block schema (implicitly inherits)
|
302
|
+
@opts[:schema] <= parent_field.opts[:schema] # Child's block schema inherits from parent's
|
303
|
+
)
|
304
|
+
elsif parent_field.opts[:of]
|
305
|
+
# Parent is Collection/Dictionary defined via `of:`
|
306
|
+
parent_of = parent_field.opts[:of]
|
307
|
+
child_of = @opts[:of]
|
308
|
+
|
309
|
+
# If parent expects specific contents (`of:`), child must comply.
|
310
|
+
if parent_of
|
311
|
+
# Child must also specify contents (`of:`) if parent does.
|
312
|
+
return false unless child_of
|
313
|
+
|
314
|
+
# Normalize `of` types to arrays for comparison
|
315
|
+
child_of_array = child_of.is_a?(Array) ? child_of : [child_of]
|
316
|
+
parent_of_array = parent_of.is_a?(Array) ? parent_of : [parent_of]
|
317
|
+
|
318
|
+
# Check if child's `of` types inherit from parent's `of` types (recursive check)
|
319
|
+
# This needs a recursive call to a method that can handle the full inheritance logic,
|
320
|
+
# including the `of` checks. Let's assume `inherit?` can be called recursively here,
|
321
|
+
# or we might need a dedicated helper. For simplicity, let's reuse `is_subtype_single`
|
322
|
+
# for the `of` types for now, assuming `of` usually contains simple types or single schema types.
|
323
|
+
# A more robust solution might need a full recursive `inherit?` check on temporary Field objects.
|
324
|
+
child_of_array.all? do |c_of|
|
325
|
+
parent_of_array.any? do |p_of|
|
326
|
+
is_subtype_single.call(c_of, p_of) # Simplified check for `of` types
|
327
|
+
end
|
328
|
+
end
|
329
|
+
else
|
330
|
+
# Parent does not specify `of`, so child is compatible regardless of its `of`.
|
331
|
+
true
|
332
|
+
end
|
333
|
+
else
|
334
|
+
# Parent is not a block schema and not defined with `of:`. Basic type compatibility is enough.
|
335
|
+
true
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
def <=(other)
|
340
|
+
(
|
341
|
+
other.type == type &&
|
342
|
+
other.opts[:schema] == opts[:schema] &&
|
343
|
+
other.opts[:of] == opts[:of]
|
344
|
+
) || inherit?(other)
|
345
|
+
end
|
346
|
+
|
347
|
+
alias_method :<, :inherit?
|
348
|
+
|
349
|
+
# :nodoc:
|
350
|
+
def apply(value, output, error_builder, locals)
|
351
|
+
locals[:__path__].push(@name)
|
352
|
+
|
353
|
+
if @type.is_a?(Base)
|
354
|
+
error_builder.context(@name) do |error_builder|
|
355
|
+
result = @type.validate(value, error_builder:, locals:)
|
356
|
+
|
357
|
+
# Apply field-level post-processors to the result of the nested schema validation
|
358
|
+
output[@name] = if @post_processors && error_builder.errors.empty?
|
359
|
+
@post_processors.call(
|
360
|
+
result.value, @name, error_builder, **locals
|
361
|
+
)
|
362
|
+
else
|
363
|
+
result.value
|
364
|
+
end
|
365
|
+
end
|
366
|
+
else
|
367
|
+
coalesced_value =
|
368
|
+
Coalescer.transform(value, @type, @opts, locals:)
|
369
|
+
|
370
|
+
if coalesced_value.is_a?(Result)
|
371
|
+
error_builder.combine(@name, coalesced_value.errors)
|
372
|
+
coalesced_value = coalesced_value.value
|
373
|
+
end
|
374
|
+
|
375
|
+
pp = @post_processors
|
376
|
+
|
377
|
+
output[@name] = if pp
|
378
|
+
pp.call(
|
379
|
+
coalesced_value, @name, error_builder, **locals
|
380
|
+
)
|
381
|
+
else
|
382
|
+
coalesced_value
|
383
|
+
end
|
384
|
+
end
|
385
|
+
rescue Coalescer::Error => e
|
386
|
+
error_builder.add(@name, e.message, **locals)
|
387
|
+
ensure
|
388
|
+
locals[:__path__].pop
|
389
|
+
end
|
390
|
+
end
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
394
|
+
require_relative "./field/ext"
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Verse
|
4
|
+
module Schema
|
5
|
+
class InvalidSchemaError < StandardError
|
6
|
+
attr_reader :errors
|
7
|
+
|
8
|
+
def initialize(errors)
|
9
|
+
@errors = errors
|
10
|
+
|
11
|
+
errors = errors.map{ |k, v| "#{k}: #{v}" }.join("\n")
|
12
|
+
message = "Invalid schema:\n#{errors}"
|
13
|
+
|
14
|
+
super(message)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Verse
|
4
|
+
module Schema
|
5
|
+
module Optionable
|
6
|
+
NOTHING = Object.new.freeze
|
7
|
+
|
8
|
+
def self.included(base)
|
9
|
+
base.extend ClassMethods
|
10
|
+
end
|
11
|
+
|
12
|
+
module ClassMethods
|
13
|
+
def option(name, default: nil)
|
14
|
+
name = name.to_sym
|
15
|
+
iv_name = "@#{name}".to_sym
|
16
|
+
|
17
|
+
case default
|
18
|
+
when Proc
|
19
|
+
define_method(name) do |value = Verse::Schema::Optionable::NOTHING|
|
20
|
+
if value == Verse::Schema::Optionable::NOTHING
|
21
|
+
return instance_variable_get(iv_name) if instance_variable_defined?(iv_name)
|
22
|
+
|
23
|
+
value = instance_exec(&default)
|
24
|
+
instance_variable_set(iv_name, value)
|
25
|
+
value
|
26
|
+
else
|
27
|
+
instance_variable_set(iv_name, block_given? ? yield(value) : value)
|
28
|
+
self
|
29
|
+
end
|
30
|
+
end
|
31
|
+
else
|
32
|
+
define_method(name) do |value = Verse::Schema::Optionable::NOTHING|
|
33
|
+
if value == Verse::Schema::Optionable::NOTHING
|
34
|
+
return instance_variable_get(iv_name) if instance_variable_defined?(iv_name)
|
35
|
+
|
36
|
+
default
|
37
|
+
else
|
38
|
+
instance_variable_set(iv_name, block_given? ? yield(value) : value)
|
39
|
+
self
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Verse
|
4
|
+
module Schema
|
5
|
+
# Post type validation / coercion processor. Can act as semantic rule or
|
6
|
+
# as a transformer.
|
7
|
+
class PostProcessor
|
8
|
+
END_OF_CHAIN = :end_of_chain
|
9
|
+
|
10
|
+
attr_reader :next, :opts, :locals
|
11
|
+
|
12
|
+
def initialize(**opts, &block)
|
13
|
+
@opts = opts
|
14
|
+
@block = block
|
15
|
+
end
|
16
|
+
|
17
|
+
def attach(processor)
|
18
|
+
if @next
|
19
|
+
@next.attach(processor)
|
20
|
+
else
|
21
|
+
@next = processor
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def stop
|
26
|
+
throw END_OF_CHAIN, END_OF_CHAIN
|
27
|
+
end
|
28
|
+
|
29
|
+
def dup
|
30
|
+
PostProcessor.new(**opts.dup, &@block).tap do |new_pp|
|
31
|
+
new_pp.attach(@next.dup) if @next
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def then(&block)
|
36
|
+
attach(PostProcessor.new(&block))
|
37
|
+
end
|
38
|
+
|
39
|
+
def call(value, key, error_builder, **locals)
|
40
|
+
output = catch(END_OF_CHAIN) do
|
41
|
+
@locals = locals
|
42
|
+
instance_exec(value, error_builder, @opts, @locals, &@block)
|
43
|
+
end
|
44
|
+
|
45
|
+
return value if output == END_OF_CHAIN
|
46
|
+
|
47
|
+
has_error = error_builder.errors.any?
|
48
|
+
|
49
|
+
return value if has_error
|
50
|
+
return output unless @next
|
51
|
+
|
52
|
+
@next.call(output, key, error_builder, **locals)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Verse
|
4
|
+
module Schema
|
5
|
+
# Result is a simple value object that holds the result of a validation
|
6
|
+
# process. It has a value and can have a list of errors.
|
7
|
+
# When the list of errors is empty, the result is considered successful and
|
8
|
+
# passed through all the steps of validation and transformation.
|
9
|
+
# When the list of errors is not empty, the result is considered failed and
|
10
|
+
# the value might not have passed through all the transformation steps.
|
11
|
+
class Result
|
12
|
+
attr_reader :value, :errors
|
13
|
+
|
14
|
+
def initialize(value, errors)
|
15
|
+
@value = value
|
16
|
+
@errors = errors
|
17
|
+
end
|
18
|
+
|
19
|
+
def success?
|
20
|
+
errors.empty?
|
21
|
+
end
|
22
|
+
|
23
|
+
alias_method :valid?, :success?
|
24
|
+
|
25
|
+
def fail?
|
26
|
+
!success?
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "./base"
|
4
|
+
|
5
|
+
module Verse
|
6
|
+
module Schema
|
7
|
+
class Scalar < Base
|
8
|
+
attr_accessor :values
|
9
|
+
|
10
|
+
# Initialize a new schema.
|
11
|
+
#
|
12
|
+
# @param values [Array<Class>] The classes allowed for this scalar.
|
13
|
+
# @param post_processors [PostProcessor] The post processors to apply.
|
14
|
+
#
|
15
|
+
# @return [Scalar] The new schema.
|
16
|
+
def initialize(
|
17
|
+
values:,
|
18
|
+
post_processors: nil
|
19
|
+
)
|
20
|
+
super(post_processors:)
|
21
|
+
@values = values
|
22
|
+
end
|
23
|
+
|
24
|
+
def validate(input, error_builder: nil, locals: {})
|
25
|
+
locals = locals.dup # Ensure they are not modified
|
26
|
+
|
27
|
+
error_builder = \
|
28
|
+
case error_builder
|
29
|
+
when String
|
30
|
+
ErrorBuilder.new(error_builder)
|
31
|
+
when ErrorBuilder
|
32
|
+
error_builder
|
33
|
+
else
|
34
|
+
ErrorBuilder.new
|
35
|
+
end
|
36
|
+
|
37
|
+
validate_scalar(input, error_builder, locals)
|
38
|
+
end
|
39
|
+
|
40
|
+
def dup
|
41
|
+
Scalar.new(
|
42
|
+
values: @values.dup,
|
43
|
+
post_processors: @post_processors&.dup
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
def inherit?(parent_schema)
|
48
|
+
return false unless parent_schema.is_a?(Scalar)
|
49
|
+
|
50
|
+
# Check that all the values in the parent schema are present in the
|
51
|
+
# current schema
|
52
|
+
parent_schema.values.all? do |parent_value|
|
53
|
+
@values.any? do |child_value|
|
54
|
+
if (child_value.is_a?(Base) && parent_value.is_a?(Base)) ||
|
55
|
+
(child_value.is_a?(Class) && parent_value.is_a?(Class))
|
56
|
+
puts "#{child_value} <= #{parent_value}: #{child_value <= parent_value}"
|
57
|
+
# Both are schema instances, use their inherit? method
|
58
|
+
child_value <= parent_value
|
59
|
+
else
|
60
|
+
# Mixed types or non-inheritable types, cannot inherit
|
61
|
+
false
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def <=(other)
|
68
|
+
other == self || inherit?(other)
|
69
|
+
end
|
70
|
+
|
71
|
+
def <(other)
|
72
|
+
other != self && inherit?(other)
|
73
|
+
end
|
74
|
+
|
75
|
+
# rubocop:disable Style/InverseMethods
|
76
|
+
def >(other)
|
77
|
+
!self.<=(other)
|
78
|
+
end
|
79
|
+
# rubocop:enable Style/InverseMethods
|
80
|
+
|
81
|
+
# Aggregation of two schemas.
|
82
|
+
def +(other)
|
83
|
+
raise ArgumentError, "aggregate must be a scalar" unless other.is_a?(Scalar)
|
84
|
+
|
85
|
+
new_classes = (@values + other.values).uniq
|
86
|
+
new_post_processors = @post_processors&.dup
|
87
|
+
|
88
|
+
if other.post_processors
|
89
|
+
if new_post_processors
|
90
|
+
new_post_processors.attach(other.post_processors)
|
91
|
+
else
|
92
|
+
new_post_processors = other.post_processors.dup
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
Scalar.new(
|
97
|
+
values: new_classes,
|
98
|
+
post_processors: new_post_processors
|
99
|
+
)
|
100
|
+
end
|
101
|
+
|
102
|
+
def dataclass_schema
|
103
|
+
return @dataclass_schema if @dataclass_schema
|
104
|
+
|
105
|
+
@dataclass_schema = dup
|
106
|
+
|
107
|
+
values = @dataclass_schema.values
|
108
|
+
|
109
|
+
@dataclass_schema.values = values.map do |value|
|
110
|
+
next value unless value.is_a?(Base)
|
111
|
+
|
112
|
+
value.dataclass_schema
|
113
|
+
end
|
114
|
+
|
115
|
+
@dataclass_schema
|
116
|
+
end
|
117
|
+
|
118
|
+
def inspect
|
119
|
+
types_string = @values.map(&:inspect).join("|")
|
120
|
+
"#<scalar<#{types_string}> 0x#{object_id.to_s(16)}>"
|
121
|
+
end
|
122
|
+
|
123
|
+
protected
|
124
|
+
|
125
|
+
def validate_scalar(input, error_builder, locals)
|
126
|
+
coalesced_value = nil
|
127
|
+
|
128
|
+
begin
|
129
|
+
coalesced_value =
|
130
|
+
Coalescer.transform(
|
131
|
+
input,
|
132
|
+
@values,
|
133
|
+
nil,
|
134
|
+
locals:
|
135
|
+
)
|
136
|
+
|
137
|
+
if coalesced_value.is_a?(Result)
|
138
|
+
error_builder.combine(nil, coalesced_value.errors)
|
139
|
+
coalesced_value = coalesced_value.value
|
140
|
+
end
|
141
|
+
rescue Coalescer::Error => e
|
142
|
+
error_builder.add(nil, e.message, **locals)
|
143
|
+
end
|
144
|
+
|
145
|
+
if @post_processors && error_builder.errors.empty?
|
146
|
+
coalesced_value = @post_processors.call(coalesced_value, nil, error_builder, **locals)
|
147
|
+
end
|
148
|
+
|
149
|
+
Result.new(coalesced_value, error_builder.errors)
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|