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,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./base"
4
+
5
+ module Verse
6
+ module Schema
7
+ class Collection < Base
8
+ attr_accessor :values
9
+
10
+ # Initialize a new collection schema.
11
+ #
12
+ # @param values [Array<Class>] The scalar classes of the schema, if type is :array, :dictionary.or :scalar.
13
+ # @param post_processors [PostProcessor] The post processors to apply.
14
+ #
15
+ # @return [Collection] 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_array(input, error_builder, locals)
38
+ end
39
+
40
+ def dup
41
+ Collection.new(
42
+ values: @values.dup,
43
+ post_processors: @post_processors&.dup
44
+ )
45
+ end
46
+
47
+ def inherit?(parent_schema)
48
+ # Check if parent_schema is a Collection
49
+ return false unless parent_schema.is_a?(Collection)
50
+
51
+ # If the child collection allows nothing (@values empty), it trivially inherits from any parent collection.
52
+ return true if @values.empty?
53
+ # If the parent collection allows nothing, but the child allows something, it cannot inherit.
54
+ return false if parent_schema.values.empty?
55
+
56
+ # Check if *every* type allowed by this child collection (`@values`)...
57
+ @values.all? do |child_type|
58
+ # ...is a subtype (`<=`) of *at least one* type allowed by the parent collection.
59
+ parent_schema.values.any? do |parent_type|
60
+ # Use the existing `<=` operator defined on schema types (Scalar, Struct, etc.)
61
+ # This assumes the `<=` operator correctly handles class inheritance (e.g., Integer <= Object)
62
+ # and schema type compatibility.
63
+ child_type <= parent_type
64
+ end
65
+ end
66
+ end
67
+
68
+ def <=(other)
69
+ other == self || inherit?(other)
70
+ end
71
+
72
+ def <(other)
73
+ other != self && inherit?(other)
74
+ end
75
+
76
+ # rubocop:disable Style/InverseMethods
77
+ def >(other)
78
+ !self.<=(other)
79
+ end
80
+ # rubocop:enable Style/InverseMethods
81
+
82
+ # Aggregation of two schemas.
83
+ def +(other)
84
+ raise ArgumentError, "aggregate must be a collection" unless other.is_a?(Collection)
85
+
86
+ new_classes = @values + other.values
87
+ new_post_processors = @post_processors&.dup
88
+
89
+ if other.post_processors
90
+ if new_post_processors
91
+ new_post_processors.attach(other.post_processors)
92
+ else
93
+ new_post_processors = other.post_processors.dup
94
+ end
95
+ end
96
+
97
+ Collection.new(
98
+ values: new_classes,
99
+ post_processors: new_post_processors
100
+ )
101
+ end
102
+
103
+ def dataclass_schema
104
+ return @dataclass_schema if @dataclass_schema
105
+
106
+ @dataclass_schema = dup
107
+
108
+ values = @dataclass_schema.values
109
+
110
+ if values.is_a?(Array)
111
+ @dataclass_schema.values = values.map do |value|
112
+ if value.is_a?(Base)
113
+ value.dataclass_schema
114
+ else
115
+ value
116
+ end
117
+ end
118
+ elsif values.is_a?(Base)
119
+ @dataclass_schema.values = values.dataclass_schema
120
+ end
121
+
122
+ @dataclass_schema
123
+ end
124
+
125
+ def inspect
126
+ types_string = @values.map(&:inspect).join("|")
127
+ # Use ::collection to distinguish from Scalar's inspect
128
+ "#<collection<#{types_string}> 0x#{object_id.to_s(16)}>"
129
+ end
130
+
131
+ protected
132
+
133
+ def validate_array(input, error_builder, locals)
134
+ locals[:__path__] ||= []
135
+
136
+ output = []
137
+
138
+ unless input.is_a?(Array)
139
+ error_builder.add(nil, "must be an array")
140
+ return Result.new(output, error_builder.errors)
141
+ end
142
+
143
+ input.each_with_index do |value, index|
144
+ locals[:__path__].push(index)
145
+
146
+ coalesced_value =
147
+ Coalescer.transform(
148
+ value,
149
+ @values,
150
+ @opts,
151
+ locals:
152
+ )
153
+
154
+ if coalesced_value.is_a?(Result)
155
+ error_builder.combine(index, coalesced_value.errors)
156
+ coalesced_value = coalesced_value.value
157
+ end
158
+
159
+ output << coalesced_value
160
+
161
+ locals[:__path__].pop
162
+ rescue Coalescer::Error => e
163
+ error_builder.add(index, e.message, **locals)
164
+ end
165
+
166
+ if @post_processors && error_builder.errors.empty?
167
+ output = @post_processors.call(output, nil, error_builder, **locals)
168
+ end
169
+
170
+ Result.new(output, error_builder.errors)
171
+ end
172
+
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./base"
4
+
5
+ module Verse
6
+ module Schema
7
+ class Dictionary < Base
8
+ attr_accessor :values
9
+
10
+ # Initialize a new dictionary.
11
+ # @param values [Array<Class>] The allowed values of the dictionary.
12
+ # @param post_processors [PostProcessor] The post processors to apply.
13
+ # @return [Dictionary] The new dictionary.
14
+ def initialize(values:, post_processors: nil)
15
+ super(post_processors:)
16
+
17
+ @values = values
18
+ end
19
+
20
+ def validate(input, error_builder: nil, locals: {})
21
+ locals = locals.dup # Ensure they are not modified
22
+
23
+ error_builder = \
24
+ case error_builder
25
+ when String
26
+ ErrorBuilder.new(error_builder)
27
+ when ErrorBuilder
28
+ error_builder
29
+ else
30
+ ErrorBuilder.new
31
+ end
32
+
33
+ locals[:__path__] ||= []
34
+
35
+ validate_dictionary(input, error_builder, locals)
36
+ end
37
+
38
+ def dup
39
+ Dictionary.new(
40
+ values: @values.dup,
41
+ post_processors: @post_processors&.dup
42
+ )
43
+ end
44
+
45
+ def inherit?(parent_schema)
46
+ # Check if parent_schema is a Base and if all parent fields are present in this schema
47
+ parent_schema.is_a?(Dictionary) &&
48
+ (
49
+ parent_schema.values & @values
50
+ ).size == parent_schema.values.size
51
+ end
52
+
53
+ def <=(other)
54
+ other == self || inherit?(other)
55
+ end
56
+
57
+ def <(other)
58
+ other != self && inherit?(other)
59
+ end
60
+
61
+ # rubocop:disable Style/InverseMethods
62
+ def >(other)
63
+ !self.<=(other)
64
+ end
65
+ # rubocop:enable Style/InverseMethods
66
+
67
+ # Aggregation of two schemas.
68
+ def +(other)
69
+ raise ArgumentError, "aggregate must be a dictionary" unless other.is_a?(Dictionary)
70
+
71
+ new_classes = @values + other.values
72
+ new_post_processors = @post_processors&.dup
73
+
74
+ if other.post_processors
75
+ if new_post_processors
76
+ new_post_processors.attach(other.post_processors)
77
+ else
78
+ new_post_processors = other.post_processors.dup
79
+ end
80
+ end
81
+
82
+ Dictionary.new(
83
+ values: new_classes,
84
+ post_processors: new_post_processors
85
+ )
86
+ end
87
+
88
+ def dataclass_schema
89
+ return @dataclass_schema if @dataclass_schema
90
+
91
+ @dataclass_schema = dup
92
+
93
+ values = @dataclass_schema.values
94
+
95
+ if values.is_a?(Array)
96
+ @dataclass_schema.values = values.map do |value|
97
+ if value.is_a?(Base)
98
+ value.dataclass_schema
99
+ else
100
+ value
101
+ end
102
+ end
103
+ elsif values.is_a?(Base)
104
+ @dataclass_schema.values = values.dataclass_schema
105
+ end
106
+
107
+ @dataclass_schema
108
+ end
109
+
110
+ def inspect
111
+ # Keys are always symbols, so only show value types.
112
+ # Handle cases where values might be arrays (unions) or schema objects.
113
+ value_types_string = (@values || [Object]).map(&:inspect).join("|")
114
+
115
+ "#<dictionary<#{value_types_string}> 0x#{object_id.to_s(16)}>"
116
+ end
117
+
118
+ protected
119
+
120
+ def validate_dictionary(input, error_builder, locals)
121
+ output = {}
122
+
123
+ unless input.is_a?(Hash)
124
+ error_builder.add(nil, "must be a hash")
125
+ return Result.new(output, error_builder.errors)
126
+ end
127
+
128
+ input.each do |key, value|
129
+ key_sym = key.to_sym
130
+ locals[:__path__].push(key_sym)
131
+
132
+ coalesced_value =
133
+ Coalescer.transform(
134
+ value,
135
+ @values,
136
+ @opts,
137
+ locals:
138
+ )
139
+
140
+ if coalesced_value.is_a?(Result)
141
+ error_builder.combine(key, coalesced_value.errors)
142
+ coalesced_value = coalesced_value.value
143
+ end
144
+
145
+ output[key.to_sym] = coalesced_value
146
+ locals[:__path__].pop
147
+ rescue Coalescer::Error => e
148
+ error_builder.add(key, e.message, **locals)
149
+ end
150
+
151
+ if @post_processors && error_builder.errors.empty?
152
+ output = @post_processors.call(output, nil, error_builder, **locals)
153
+ end
154
+
155
+ Result.new(output, error_builder.errors)
156
+ end
157
+
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verse
4
+ module Schema
5
+ class ErrorBuilder
6
+ attr_reader :errors, :root
7
+
8
+ def initialize(root = nil, errors = {})
9
+ @errors = errors
10
+ @root = root
11
+ end
12
+
13
+ def context(key_name)
14
+ new_root = [@root, key_name].compact.join(".")
15
+ yield(
16
+ ErrorBuilder.new(new_root, @errors)
17
+ )
18
+ end
19
+
20
+ def combine(key, errors)
21
+ errors.each do |k, v|
22
+ real_key = [@root, key, k].compact.join(".").to_sym
23
+ (@errors[real_key] ||= []).concat(v)
24
+ end
25
+ end
26
+
27
+ def add(keys, message = "validation_failed", **locals)
28
+ case keys
29
+ when Array
30
+ keys.each { |key| add(key, message, **locals) }
31
+ else
32
+ path = [@root, keys].compact
33
+
34
+ real_key = path.any? ? path.join(".").to_sym : nil
35
+
36
+ message %= locals if locals.any?
37
+
38
+ (@errors[real_key] ||= []) << message
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verse
4
+ module Schema
5
+ class Field
6
+ def filled(message = "must be filled")
7
+ rule(message) do |value, _output|
8
+ next false if value.nil?
9
+ next !value.empty? if value.respond_to?(:empty?)
10
+
11
+ next true
12
+ end
13
+ end
14
+
15
+ def in?(values, message = "must be one of %s")
16
+ values = [values] unless values.is_a?(Array)
17
+
18
+ rule(message % values.join(", ")) do |value, _output|
19
+ values.include?(value)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end