verse-schema 1.0.0 → 1.1.1
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 +4 -4
- data/CHANGELOG.md +9 -0
- data/Gemfile.lock +1 -1
- data/README.md +187 -0
- data/lib/tasks/readme_doc_extractor.rb +1 -1
- data/lib/verse/schema/base.rb +2 -2
- data/lib/verse/schema/coalescer/register.rb +1 -1
- data/lib/verse/schema/coalescer.rb +6 -10
- data/lib/verse/schema/collection.rb +29 -9
- data/lib/verse/schema/dictionary.rb +6 -6
- data/lib/verse/schema/field.rb +6 -6
- data/lib/verse/schema/scalar.rb +41 -15
- data/lib/verse/schema/selector.rb +5 -4
- data/lib/verse/schema/struct.rb +55 -45
- data/lib/verse/schema/version.rb +1 -1
- data/templates/README.md.erb +9 -0
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6608dcff00a7bef2d8313866053ac7d37d82933d9b597052a222f0bcad693f91
|
4
|
+
data.tar.gz: dfcecf7d576b599b3c39d552cfbbf082ceda4707f0ec532d94d4a0db3dbfa620
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d3bc6f97bf7b683d4e45ee578af5a1cd57375d56f54161a93ed3b725c2f783cbfedb2583fa9dc6470e68243bdaa3cda93f703dc82df4a63bd912d386695933bc
|
7
|
+
data.tar.gz: 49c7ae54ea24d5a4e7a8304e1361f70feb81f68a4993beddc153fabd1c88fa1d63e5eb5bd5bbcb41eea83bd863f7188bcfc73cdf31d98c9a0b08b32d749c0ecc
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
## 1.1
|
2
|
+
|
3
|
+
- Add `strict` mode to `validate` which will raise an error if the input has
|
4
|
+
extra fields (in case of schema with extra_fields: false)
|
5
|
+
- Fix issue with query params by allowing coercion of String `'null'` into `nil` for `NilClass` field type
|
6
|
+
|
7
|
+
## 1.0
|
8
|
+
|
9
|
+
- First release of the Gem
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
@@ -64,6 +64,8 @@ These examples are extracted directly from the gem's specs, ensuring they are ac
|
|
64
64
|
|
65
65
|
- [Open Hash](#open-hash)
|
66
66
|
|
67
|
+
- [Strict Validation Mode](#strict-validation-mode)
|
68
|
+
|
67
69
|
|
68
70
|
- [2. Complex Structures](#2-complex-structures)
|
69
71
|
|
@@ -118,6 +120,8 @@ These examples are extracted directly from the gem's specs, ensuring they are ac
|
|
118
120
|
|
119
121
|
- [Complex Example](#complex-example)
|
120
122
|
|
123
|
+
- [Polymorphic Schema](#polymorphic-schema)
|
124
|
+
|
121
125
|
|
122
126
|
|
123
127
|
|
@@ -434,6 +438,54 @@ end
|
|
434
438
|
```
|
435
439
|
|
436
440
|
|
441
|
+
### Strict Validation Mode
|
442
|
+
|
443
|
+
|
444
|
+
```ruby
|
445
|
+
it "demonstrates strict validation for extra fields during validation" do
|
446
|
+
# By default, schemas ignore fields not defined in the schema unless `extra_fields` is used.
|
447
|
+
# You can enforce strict validation by passing `strict: true` to the `validate` method.
|
448
|
+
# This will cause validation to fail if extra fields are provided and the schema
|
449
|
+
# does not explicitly allow them via `extra_fields`.
|
450
|
+
|
451
|
+
# Please note: `strict` mode is propagated to the children schemas, when you have
|
452
|
+
# nested structures.
|
453
|
+
|
454
|
+
# Define a standard schema (extra_fields is false by default)
|
455
|
+
schema = Verse::Schema.define do
|
456
|
+
field(:name, String)
|
457
|
+
end
|
458
|
+
|
459
|
+
# Default validation (strict: false) ignores extra fields
|
460
|
+
result_default = schema.validate({ name: "John", age: 30 })
|
461
|
+
expect(result_default.success?).to be true
|
462
|
+
expect(result_default.value).to eq({ name: "John" }) # 'age' is ignored
|
463
|
+
|
464
|
+
# Strict validation (strict: true) fails with extra fields
|
465
|
+
result_strict_fail = schema.validate({ name: "John", age: 30 }, strict: true)
|
466
|
+
expect(result_strict_fail.success?).to be false
|
467
|
+
expect(result_strict_fail.errors).to eq({ age: ["is not allowed"] }) # Error on extra field 'age'
|
468
|
+
|
469
|
+
# Strict validation succeeds if no extra fields are provided
|
470
|
+
result_strict_ok = schema.validate({ name: "John" }, strict: true)
|
471
|
+
expect(result_strict_ok.success?).to be true
|
472
|
+
expect(result_strict_ok.value).to eq({ name: "John" })
|
473
|
+
|
474
|
+
# Now, define a schema that explicitly allows extra fields
|
475
|
+
schema_with_extra = Verse::Schema.define do
|
476
|
+
field(:name, String)
|
477
|
+
extra_fields # Explicitly allow extra fields
|
478
|
+
end
|
479
|
+
|
480
|
+
# Strict validation has no effect if `extra_fields` is enabled in the schema definition
|
481
|
+
result_strict_extra_ok = schema_with_extra.validate({ name: "John", age: 30 }, strict: true)
|
482
|
+
expect(result_strict_extra_ok.success?).to be true
|
483
|
+
expect(result_strict_extra_ok.value).to eq({ name: "John", age: 30 }) # Extra field 'age' is allowed and included
|
484
|
+
end
|
485
|
+
|
486
|
+
```
|
487
|
+
|
488
|
+
|
437
489
|
|
438
490
|
## 2. Complex Structures
|
439
491
|
|
@@ -1584,7 +1636,142 @@ end
|
|
1584
1636
|
```
|
1585
1637
|
|
1586
1638
|
|
1639
|
+
### Polymorphic Schema
|
1640
|
+
|
1641
|
+
|
1642
|
+
```ruby
|
1643
|
+
it "demonstrates a polymorphic schema" do
|
1644
|
+
# Polymorphism without selector model can be achieved using a builder
|
1645
|
+
# virtual object which will convert the input schema to the correct
|
1646
|
+
# schema based on the type of the input.
|
1647
|
+
#
|
1648
|
+
# Here is an example on how to do:
|
1649
|
+
#
|
1650
|
+
# 1. Define the base schema for the polymorphic structure:
|
1651
|
+
base_schema = Verse::Schema.define do
|
1652
|
+
field(:type, Symbol)
|
1653
|
+
end
|
1654
|
+
|
1655
|
+
# 2. Define the specific schemas for each type:
|
1656
|
+
facebook_schema = Verse::Schema.define(base_schema) do
|
1657
|
+
field(:url, String)
|
1658
|
+
field(:title, String)
|
1659
|
+
end
|
1660
|
+
|
1661
|
+
google_schema = Verse::Schema.define(base_schema) do
|
1662
|
+
field(:search, String)
|
1663
|
+
field(:location, String)
|
1664
|
+
end
|
1665
|
+
|
1666
|
+
# 3. Define a builder schema. The best way to do this is to use the
|
1667
|
+
# scalar type:
|
1668
|
+
builder_schema = Verse::Schema.scalar(Hash).transform do |input, error_builder|
|
1669
|
+
|
1670
|
+
type = input[:type]
|
1671
|
+
|
1672
|
+
if type.respond_to?(:to_sym)
|
1673
|
+
type = type.to_sym
|
1674
|
+
else
|
1675
|
+
error_builder.add(:type, "invalid type")
|
1676
|
+
stop
|
1677
|
+
end
|
1678
|
+
|
1679
|
+
schema = case type
|
1680
|
+
when :facebook
|
1681
|
+
facebook_schema
|
1682
|
+
when :google
|
1683
|
+
google_schema
|
1684
|
+
else
|
1685
|
+
error_builder.add(:type, "invalid type")
|
1686
|
+
stop
|
1687
|
+
end
|
1688
|
+
|
1689
|
+
# Validate the input against the selected schema
|
1690
|
+
result = schema.validate(input, error_builder:)
|
1691
|
+
|
1692
|
+
result.value if result.success?
|
1693
|
+
end
|
1694
|
+
|
1695
|
+
# 4. Now, you can use the builder schema as placeholder for your
|
1696
|
+
# polymorphic schema:
|
1697
|
+
schema = Verse::Schema.define do
|
1698
|
+
field(:events, Array, of: builder_schema)
|
1699
|
+
end
|
1700
|
+
|
1701
|
+
# 5. Create a complex data structure to validate
|
1702
|
+
data = {
|
1703
|
+
events: [
|
1704
|
+
{
|
1705
|
+
type: "facebook",
|
1706
|
+
url: "https://facebook.com/event/123",
|
1707
|
+
title: "Facebook Event"
|
1708
|
+
},
|
1709
|
+
{
|
1710
|
+
type: "google",
|
1711
|
+
search: "conference 2023",
|
1712
|
+
location: "New York"
|
1713
|
+
}
|
1714
|
+
]
|
1715
|
+
}
|
1716
|
+
|
1717
|
+
# 6. Validate the complex data
|
1718
|
+
result = schema.validate(data)
|
1719
|
+
# The validation succeeds
|
1720
|
+
expect(result.success?).to be true
|
1721
|
+
# The output maintains the structure with coerced values
|
1722
|
+
expect(result.value[:events][0][:type]).to eq(:facebook)
|
1723
|
+
expect(result.value[:events][0][:url]).to eq("https://facebook.com/event/123")
|
1724
|
+
expect(result.value[:events][0][:title]).to eq("Facebook Event")
|
1725
|
+
|
1726
|
+
expect(result.value[:events][1][:type]).to eq(:google)
|
1727
|
+
expect(result.value[:events][1][:search]).to eq("conference 2023")
|
1728
|
+
expect(result.value[:events][1][:location]).to eq("New York")
|
1729
|
+
|
1730
|
+
# 6.1 Invalid data
|
1731
|
+
invalid_data = {
|
1732
|
+
events: [
|
1733
|
+
{
|
1734
|
+
type: "facebook",
|
1735
|
+
# missing required url field
|
1736
|
+
title: "Facebook Event"
|
1737
|
+
},
|
1738
|
+
{
|
1739
|
+
type: "google",
|
1740
|
+
search: "conference 2023",
|
1741
|
+
# missing required location field
|
1742
|
+
},
|
1743
|
+
{
|
1744
|
+
type: "invalid",
|
1745
|
+
url: "https://invalid.com/event/123",
|
1746
|
+
title: "Invalid Event"
|
1747
|
+
}
|
1748
|
+
]
|
1749
|
+
}
|
1750
|
+
# Validate the invalid data
|
1751
|
+
invalid_result = schema.validate(invalid_data)
|
1752
|
+
# The validation fails
|
1753
|
+
expect(invalid_result.success?).to be false
|
1754
|
+
# The errors are collected
|
1755
|
+
expect(invalid_result.errors).to eq({
|
1756
|
+
:"events.0.url" => ["is required"],
|
1757
|
+
:"events.1.location" => ["is required"],
|
1758
|
+
:"events.2.type" => ["invalid type"]
|
1759
|
+
})
|
1760
|
+
end
|
1761
|
+
|
1762
|
+
```
|
1763
|
+
|
1764
|
+
|
1765
|
+
|
1766
|
+
|
1767
|
+
## License
|
1768
|
+
|
1769
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
1770
|
+
|
1771
|
+
## Sponsorship
|
1587
1772
|
|
1773
|
+
This gem was made possible thanks to the support of [Ingedata](https://ingedata.ai).
|
1774
|
+
In Ingedata, we build expert teams to support and enhance delivery of your data projects.
|
1588
1775
|
|
1589
1776
|
## Contributing
|
1590
1777
|
|
@@ -109,7 +109,7 @@ class ReadmeDocExtractor
|
|
109
109
|
end
|
110
110
|
end
|
111
111
|
|
112
|
-
|
112
|
+
# Extract code from an example
|
113
113
|
def extract_code_from_example(example)
|
114
114
|
# Get the example block directly using instance_variable_get
|
115
115
|
example_block = example.instance_variable_get(:@example_block)
|
data/lib/verse/schema/base.rb
CHANGED
@@ -14,7 +14,7 @@ module Verse
|
|
14
14
|
|
15
15
|
# Initialize a new schema.
|
16
16
|
def initialize(post_processors: nil)
|
17
|
-
@post_processors
|
17
|
+
@post_processors = post_processors
|
18
18
|
end
|
19
19
|
|
20
20
|
def rule(fields = nil, message = "rule failed", &block)
|
@@ -61,7 +61,7 @@ module Verse
|
|
61
61
|
|
62
62
|
def valid?(input) = validate(input).success?
|
63
63
|
|
64
|
-
def validate(input, error_builder: nil, locals: {}) = raise NotImplementedError
|
64
|
+
def validate(input, error_builder: nil, locals: {}, strict: false) = raise NotImplementedError
|
65
65
|
|
66
66
|
def new(arg)
|
67
67
|
result = validate(arg)
|
@@ -8,13 +8,9 @@ module Verse
|
|
8
8
|
@mapping = {}
|
9
9
|
|
10
10
|
DEFAULT_MAPPER = lambda do |type|
|
11
|
-
if type
|
12
|
-
proc do |value,
|
13
|
-
|
14
|
-
end
|
15
|
-
elsif type.is_a?(Base)
|
16
|
-
proc do |value, _opts, locals:|
|
17
|
-
type.validate(value, locals:)
|
11
|
+
if type.is_a?(Base)
|
12
|
+
proc do |value, _opts, locals:, strict:|
|
13
|
+
type.validate(value, locals:, strict:)
|
18
14
|
end
|
19
15
|
elsif type.is_a?(Class)
|
20
16
|
proc do |value|
|
@@ -36,7 +32,7 @@ module Verse
|
|
36
32
|
end
|
37
33
|
end
|
38
34
|
|
39
|
-
def transform(value, type, opts = {}, locals: {})
|
35
|
+
def transform(value, type, opts = {}, locals: {}, strict: false)
|
40
36
|
if type.is_a?(Array)
|
41
37
|
# fast-path for when the type match already
|
42
38
|
type.each do |t|
|
@@ -52,7 +48,7 @@ module Verse
|
|
52
48
|
type.each do |t|
|
53
49
|
converted = @mapping.fetch(t) do
|
54
50
|
DEFAULT_MAPPER.call(t)
|
55
|
-
end.call(value, opts, locals:)
|
51
|
+
end.call(value, opts, locals:, strict:)
|
56
52
|
|
57
53
|
if !converted.is_a?(Result) ||
|
58
54
|
(converted.is_a?(Result) && converted.success?)
|
@@ -70,7 +66,7 @@ module Verse
|
|
70
66
|
else
|
71
67
|
@mapping.fetch(type) do
|
72
68
|
DEFAULT_MAPPER.call(type)
|
73
|
-
end.call(value, opts, locals:)
|
69
|
+
end.call(value, opts, locals:, strict:)
|
74
70
|
end
|
75
71
|
end
|
76
72
|
end
|
@@ -21,7 +21,7 @@ module Verse
|
|
21
21
|
@values = values
|
22
22
|
end
|
23
23
|
|
24
|
-
def validate(input, error_builder: nil, locals: {})
|
24
|
+
def validate(input, error_builder: nil, locals: {}, strict: false)
|
25
25
|
locals = locals.dup # Ensure they are not modified
|
26
26
|
|
27
27
|
error_builder = \
|
@@ -34,7 +34,7 @@ module Verse
|
|
34
34
|
ErrorBuilder.new
|
35
35
|
end
|
36
36
|
|
37
|
-
validate_array(input, error_builder, locals)
|
37
|
+
validate_array(input, error_builder, locals, strict)
|
38
38
|
end
|
39
39
|
|
40
40
|
def dup
|
@@ -57,10 +57,30 @@ module Verse
|
|
57
57
|
@values.all? do |child_type|
|
58
58
|
# ...is a subtype (`<=`) of *at least one* type allowed by the parent collection.
|
59
59
|
parent_schema.values.any? do |parent_type|
|
60
|
-
#
|
61
|
-
#
|
62
|
-
|
63
|
-
|
60
|
+
# Special handling for when child_type is a primitive class (like Integer)
|
61
|
+
# and parent_type is a Scalar
|
62
|
+
if !child_type.is_a?(Base) && parent_type.is_a?(Scalar)
|
63
|
+
# Check if the primitive class is compatible with any of the Scalar's values
|
64
|
+
parent_type.values.any? do |scalar_value|
|
65
|
+
begin
|
66
|
+
# Use standard Ruby `<=` for comparison
|
67
|
+
child_type <= scalar_value
|
68
|
+
rescue TypeError
|
69
|
+
# Handle cases where <= is not defined between types
|
70
|
+
false
|
71
|
+
end
|
72
|
+
end
|
73
|
+
else
|
74
|
+
# Use the existing `<=` operator defined on schema types (Scalar, Struct, etc.)
|
75
|
+
# This assumes the `<=` operator correctly handles class inheritance (e.g., Integer <= Object)
|
76
|
+
# and schema type compatibility.
|
77
|
+
begin
|
78
|
+
child_type <= parent_type
|
79
|
+
rescue TypeError
|
80
|
+
# Handle cases where <= is not defined between types
|
81
|
+
false
|
82
|
+
end
|
83
|
+
end
|
64
84
|
end
|
65
85
|
end
|
66
86
|
end
|
@@ -130,7 +150,7 @@ module Verse
|
|
130
150
|
|
131
151
|
protected
|
132
152
|
|
133
|
-
def validate_array(input, error_builder, locals)
|
153
|
+
def validate_array(input, error_builder, locals, strict)
|
134
154
|
locals[:__path__] ||= []
|
135
155
|
|
136
156
|
output = []
|
@@ -148,7 +168,8 @@ module Verse
|
|
148
168
|
value,
|
149
169
|
@values,
|
150
170
|
@opts,
|
151
|
-
locals
|
171
|
+
locals:,
|
172
|
+
strict:
|
152
173
|
)
|
153
174
|
|
154
175
|
if coalesced_value.is_a?(Result)
|
@@ -169,7 +190,6 @@ module Verse
|
|
169
190
|
|
170
191
|
Result.new(output, error_builder.errors)
|
171
192
|
end
|
172
|
-
|
173
193
|
end
|
174
194
|
end
|
175
195
|
end
|
@@ -14,10 +14,10 @@ module Verse
|
|
14
14
|
def initialize(values:, post_processors: nil)
|
15
15
|
super(post_processors:)
|
16
16
|
|
17
|
-
@values
|
17
|
+
@values = values
|
18
18
|
end
|
19
19
|
|
20
|
-
def validate(input, error_builder: nil, locals: {})
|
20
|
+
def validate(input, error_builder: nil, locals: {}, strict: false)
|
21
21
|
locals = locals.dup # Ensure they are not modified
|
22
22
|
|
23
23
|
error_builder = \
|
@@ -32,7 +32,7 @@ module Verse
|
|
32
32
|
|
33
33
|
locals[:__path__] ||= []
|
34
34
|
|
35
|
-
validate_dictionary(input, error_builder, locals)
|
35
|
+
validate_dictionary(input, error_builder, locals, strict)
|
36
36
|
end
|
37
37
|
|
38
38
|
def dup
|
@@ -117,7 +117,7 @@ module Verse
|
|
117
117
|
|
118
118
|
protected
|
119
119
|
|
120
|
-
def validate_dictionary(input, error_builder, locals)
|
120
|
+
def validate_dictionary(input, error_builder, locals, strict)
|
121
121
|
output = {}
|
122
122
|
|
123
123
|
unless input.is_a?(Hash)
|
@@ -134,7 +134,8 @@ module Verse
|
|
134
134
|
value,
|
135
135
|
@values,
|
136
136
|
@opts,
|
137
|
-
locals
|
137
|
+
locals:,
|
138
|
+
strict:
|
138
139
|
)
|
139
140
|
|
140
141
|
if coalesced_value.is_a?(Result)
|
@@ -154,7 +155,6 @@ module Verse
|
|
154
155
|
|
155
156
|
Result.new(output, error_builder.errors)
|
156
157
|
end
|
157
|
-
|
158
158
|
end
|
159
159
|
end
|
160
160
|
end
|
data/lib/verse/schema/field.rb
CHANGED
@@ -8,7 +8,7 @@ module Verse
|
|
8
8
|
module Schema
|
9
9
|
# A field in a schema
|
10
10
|
class Field
|
11
|
-
attr_reader :opts, :post_processors, :name
|
11
|
+
attr_reader :opts, :post_processors, :name
|
12
12
|
|
13
13
|
def initialize(name, type, opts, post_processors: nil, &block)
|
14
14
|
@name = name
|
@@ -53,7 +53,7 @@ module Verse
|
|
53
53
|
of_arg = @opts[:of] # For array and dictionary
|
54
54
|
of_arg = [of_arg] unless of_arg.nil? || of_arg.is_a?(Array)
|
55
55
|
|
56
|
-
if
|
56
|
+
if [Hash, Object].include?(type)
|
57
57
|
type = Schema.dictionary(*of_arg) if of_arg # dictionary
|
58
58
|
elsif type == Array
|
59
59
|
type = Schema.array(*of_arg) if of_arg
|
@@ -269,7 +269,7 @@ module Verse
|
|
269
269
|
elsif c.is_a?(Class) && p.is_a?(Verse::Schema::Scalar)
|
270
270
|
p.values.any? { |p_val| c <= p_val }
|
271
271
|
elsif c.is_a?(Verse::Schema::Base) && p.is_a?(Verse::Schema::Base)
|
272
|
-
|
272
|
+
c <= p
|
273
273
|
elsif c.is_a?(Class) && p.is_a?(Class)
|
274
274
|
c <= p
|
275
275
|
else
|
@@ -347,12 +347,12 @@ module Verse
|
|
347
347
|
alias_method :<, :inherit?
|
348
348
|
|
349
349
|
# :nodoc:
|
350
|
-
def apply(value, output, error_builder, locals)
|
350
|
+
def apply(value, output, error_builder, locals, strict)
|
351
351
|
locals[:__path__].push(@name)
|
352
352
|
|
353
353
|
if @type.is_a?(Base)
|
354
354
|
error_builder.context(@name) do |error_builder|
|
355
|
-
result = @type.validate(value, error_builder:, locals:)
|
355
|
+
result = @type.validate(value, error_builder:, locals:, strict:)
|
356
356
|
|
357
357
|
# Apply field-level post-processors to the result of the nested schema validation
|
358
358
|
output[@name] = if @post_processors && error_builder.errors.empty?
|
@@ -365,7 +365,7 @@ module Verse
|
|
365
365
|
end
|
366
366
|
else
|
367
367
|
coalesced_value =
|
368
|
-
Coalescer.transform(value, @type, @opts, locals:)
|
368
|
+
Coalescer.transform(value, @type, @opts, locals:, strict:)
|
369
369
|
|
370
370
|
if coalesced_value.is_a?(Result)
|
371
371
|
error_builder.combine(@name, coalesced_value.errors)
|
data/lib/verse/schema/scalar.rb
CHANGED
@@ -21,7 +21,7 @@ module Verse
|
|
21
21
|
@values = values
|
22
22
|
end
|
23
23
|
|
24
|
-
def validate(input, error_builder: nil, locals: {})
|
24
|
+
def validate(input, error_builder: nil, locals: {}, strict: false)
|
25
25
|
locals = locals.dup # Ensure they are not modified
|
26
26
|
|
27
27
|
error_builder = \
|
@@ -34,7 +34,7 @@ module Verse
|
|
34
34
|
ErrorBuilder.new
|
35
35
|
end
|
36
36
|
|
37
|
-
validate_scalar(input, error_builder, locals)
|
37
|
+
validate_scalar(input, error_builder, locals, strict)
|
38
38
|
end
|
39
39
|
|
40
40
|
def dup
|
@@ -64,19 +64,45 @@ module Verse
|
|
64
64
|
end
|
65
65
|
end
|
66
66
|
|
67
|
-
|
68
|
-
|
67
|
+
def <=(other)
|
68
|
+
# 1. Identical check: Is it the exact same object?
|
69
|
+
return true if other == self
|
70
|
+
|
71
|
+
# 2. Check if inheriting from another Scalar:
|
72
|
+
# Use the existing inherit? method which correctly handles Scalar-to-Scalar inheritance.
|
73
|
+
# (inherit? implicitly checks `other.is_a?(Scalar)`)
|
74
|
+
return true if inherit?(other)
|
75
|
+
|
76
|
+
# 3. NEW: Check compatibility with non-Scalar types:
|
77
|
+
# If 'other' is not a Scalar, check if any type *wrapped* by this Scalar
|
78
|
+
# is a subtype of 'other'. This handles `Scalar<Integer> <= Integer`.
|
79
|
+
# We rely on the `<=` operator of the wrapped types themselves.
|
80
|
+
@values.any? do |wrapped_type|
|
81
|
+
begin
|
82
|
+
# Use standard Ruby `<=` for comparison.
|
83
|
+
# This works for Class <= Class (e.g., Integer <= Integer, Integer <= Numeric)
|
84
|
+
# and potentially for SchemaType <= SchemaType if defined.
|
85
|
+
wrapped_type <= other
|
86
|
+
rescue TypeError
|
87
|
+
# Handle cases where <= is not defined between wrapped_type and other
|
88
|
+
false
|
69
89
|
end
|
90
|
+
end
|
91
|
+
end
|
70
92
|
|
71
|
-
|
72
|
-
|
73
|
-
|
93
|
+
def <(other)
|
94
|
+
other != self && self <= other
|
95
|
+
end
|
74
96
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
97
|
+
# rubocop:disable Style/InverseMethods
|
98
|
+
def >(other)
|
99
|
+
!(self <= other)
|
100
|
+
end
|
101
|
+
|
102
|
+
def >=(other)
|
103
|
+
other <= self
|
104
|
+
end
|
105
|
+
# rubocop:enable Style/InverseMethods
|
80
106
|
|
81
107
|
# Aggregation of two schemas.
|
82
108
|
def +(other)
|
@@ -122,7 +148,7 @@ module Verse
|
|
122
148
|
|
123
149
|
protected
|
124
150
|
|
125
|
-
def validate_scalar(input, error_builder, locals)
|
151
|
+
def validate_scalar(input, error_builder, locals, strict)
|
126
152
|
coalesced_value = nil
|
127
153
|
|
128
154
|
begin
|
@@ -131,7 +157,8 @@ module Verse
|
|
131
157
|
input,
|
132
158
|
@values,
|
133
159
|
nil,
|
134
|
-
locals
|
160
|
+
locals:,
|
161
|
+
strict:
|
135
162
|
)
|
136
163
|
|
137
164
|
if coalesced_value.is_a?(Result)
|
@@ -148,7 +175,6 @@ module Verse
|
|
148
175
|
|
149
176
|
Result.new(coalesced_value, error_builder.errors)
|
150
177
|
end
|
151
|
-
|
152
178
|
end
|
153
179
|
end
|
154
180
|
end
|
@@ -20,7 +20,7 @@ module Verse
|
|
20
20
|
@values = values.transform_values{ |v| v.is_a?(Array) ? v : [v] }
|
21
21
|
end
|
22
22
|
|
23
|
-
def validate(input, error_builder: nil, locals: {})
|
23
|
+
def validate(input, error_builder: nil, locals: {}, strict: false)
|
24
24
|
locals = locals.dup # Ensure they are not modified
|
25
25
|
|
26
26
|
error_builder = \
|
@@ -35,7 +35,7 @@ module Verse
|
|
35
35
|
|
36
36
|
locals[:__path__] ||= []
|
37
37
|
|
38
|
-
validate_selector(input, error_builder, locals)
|
38
|
+
validate_selector(input, error_builder, locals, strict)
|
39
39
|
end
|
40
40
|
|
41
41
|
def dup
|
@@ -123,7 +123,7 @@ module Verse
|
|
123
123
|
|
124
124
|
protected
|
125
125
|
|
126
|
-
def validate_selector(input, error_builder, locals)
|
126
|
+
def validate_selector(input, error_builder, locals, strict)
|
127
127
|
output = {}
|
128
128
|
|
129
129
|
selector = locals.fetch(:selector) do
|
@@ -154,7 +154,8 @@ module Verse
|
|
154
154
|
input,
|
155
155
|
fetched_values,
|
156
156
|
nil,
|
157
|
-
locals
|
157
|
+
locals:,
|
158
|
+
strict:
|
158
159
|
)
|
159
160
|
|
160
161
|
if coalesced_value.is_a?(Result)
|
data/lib/verse/schema/struct.rb
CHANGED
@@ -69,7 +69,7 @@ module Verse
|
|
69
69
|
|
70
70
|
def valid?(input) = validate(input).success?
|
71
71
|
|
72
|
-
def validate(input, error_builder: nil, locals: {})
|
72
|
+
def validate(input, error_builder: nil, locals: {}, strict: false)
|
73
73
|
error_builder = \
|
74
74
|
case error_builder
|
75
75
|
when String
|
@@ -87,7 +87,7 @@ module Verse
|
|
87
87
|
|
88
88
|
locals = locals.dup # Ensure they are not modified
|
89
89
|
|
90
|
-
validate_hash(input, error_builder, locals)
|
90
|
+
validate_hash(input, error_builder, locals, strict)
|
91
91
|
end
|
92
92
|
|
93
93
|
def dup
|
@@ -221,49 +221,49 @@ module Verse
|
|
221
221
|
fields << :extra_fields if extra_fields?
|
222
222
|
|
223
223
|
# Special case for empty schema (yeah, I know, it happens in my production code...)
|
224
|
-
if fields.empty?
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
224
|
+
@dataclass = if fields.empty?
|
225
|
+
Class.new do
|
226
|
+
def self.from_raw(*)=new
|
227
|
+
def self.schema = dataclass_schema
|
228
|
+
|
229
|
+
class_eval(&block) if block
|
230
|
+
end
|
231
|
+
else
|
232
|
+
::Struct.new(*fields, keyword_init: true) do
|
233
|
+
# Redefine new method
|
234
|
+
define_singleton_method(:from_raw, &method(:new))
|
235
|
+
|
236
|
+
define_singleton_method(:new) do |*args, **kwargs|
|
237
|
+
# Use the schema to generate the hash for our record
|
238
|
+
if args.size > 1
|
239
|
+
raise ArgumentError, "You cannot pass more than one argument"
|
240
|
+
end
|
241
|
+
|
242
|
+
if args.size == 1
|
243
|
+
if kwargs.any?
|
244
|
+
raise ArgumentError, "You cannot pass both a hash and keyword arguments"
|
245
|
+
end
|
246
|
+
|
247
|
+
kwargs = args.first
|
248
|
+
end
|
249
|
+
|
250
|
+
dataclass_schema.new(kwargs)
|
251
|
+
end
|
252
|
+
|
253
|
+
define_singleton_method(:schema){ dataclass_schema }
|
254
|
+
|
255
|
+
class_eval(&block) if block
|
256
|
+
end
|
257
|
+
end
|
258
258
|
end
|
259
259
|
|
260
260
|
def inspect
|
261
261
|
fields_string = @fields.map do |field|
|
262
|
-
if field.type.is_a?(Array)
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
262
|
+
type_str = if field.type.is_a?(Array)
|
263
|
+
field.type.map(&:inspect).join("|")
|
264
|
+
else
|
265
|
+
field.type.inspect
|
266
|
+
end
|
267
267
|
|
268
268
|
optional_marker = field.optional? ? "?" : ""
|
269
269
|
"#{field.name}#{optional_marker}: #{type_str}"
|
@@ -276,7 +276,7 @@ module Verse
|
|
276
276
|
|
277
277
|
protected
|
278
278
|
|
279
|
-
def validate_hash(input, error_builder, locals)
|
279
|
+
def validate_hash(input, error_builder, locals, strict)
|
280
280
|
locals[:__path__] ||= []
|
281
281
|
|
282
282
|
input = input.transform_keys(&:to_sym)
|
@@ -295,21 +295,31 @@ module Verse
|
|
295
295
|
end
|
296
296
|
|
297
297
|
if exists
|
298
|
-
field.apply(value, output, error_builder, locals)
|
298
|
+
field.apply(value, output, error_builder, locals, strict)
|
299
299
|
elsif field.default?
|
300
|
-
field.apply(field.default, output, error_builder, locals)
|
300
|
+
field.apply(field.default, output, error_builder, locals, strict)
|
301
301
|
elsif field.required?
|
302
302
|
error_builder.add(field.key, "is required")
|
303
303
|
end
|
304
304
|
end
|
305
305
|
|
306
|
+
# If strict mode is enabled, check for extra fields
|
307
|
+
# that are not defined in the schema.
|
308
|
+
if !@extra_fields && strict
|
309
|
+
extra_keys = input.keys - @cache_field_name
|
310
|
+
if extra_keys.any?
|
311
|
+
extra_keys.each do |key|
|
312
|
+
error_builder.add(key, "is not allowed")
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
306
317
|
if @post_processors && error_builder.errors.empty?
|
307
318
|
output = @post_processors.call(output, nil, error_builder, **locals)
|
308
319
|
end
|
309
320
|
|
310
321
|
Result.new(output, error_builder.errors)
|
311
322
|
end
|
312
|
-
|
313
323
|
end
|
314
324
|
end
|
315
325
|
end
|
data/lib/verse/schema/version.rb
CHANGED
data/templates/README.md.erb
CHANGED
@@ -68,6 +68,15 @@ These examples are extracted directly from the gem's specs, ensuring they are ac
|
|
68
68
|
<% end %>
|
69
69
|
<% end %>
|
70
70
|
|
71
|
+
## License
|
72
|
+
|
73
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
74
|
+
|
75
|
+
## Sponsorship
|
76
|
+
|
77
|
+
This gem was made possible thanks to the support of [Ingedata](https://ingedata.ai).
|
78
|
+
In Ingedata, we build expert teams to support and enhance delivery of your data projects.
|
79
|
+
|
71
80
|
## Contributing
|
72
81
|
|
73
82
|
Bug reports and pull requests are welcome on GitHub at https://github.com/verse-rb/verse-schema.
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: verse-schema
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.1.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yacine Petitprez
|
8
8
|
bindir: exe
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-04-
|
10
|
+
date: 2025-04-25 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: attr_chainable
|
@@ -33,6 +33,7 @@ files:
|
|
33
33
|
- ".rspec"
|
34
34
|
- ".rubocop-https---relaxed-ruby-style-rubocop-yml"
|
35
35
|
- ".rubocop.yml"
|
36
|
+
- CHANGELOG.md
|
36
37
|
- Gemfile
|
37
38
|
- Gemfile.lock
|
38
39
|
- README.md
|