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,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
|