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.
@@ -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