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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b980154b603e6ac267ee886cbbbdf68914f98ce3a327ea15d1a1b0fb28d50f25
4
- data.tar.gz: 5d8b23b022cc019c28fb9b4c6576bcae32fa33d747b804ea721076f9198df230
3
+ metadata.gz: 6608dcff00a7bef2d8313866053ac7d37d82933d9b597052a222f0bcad693f91
4
+ data.tar.gz: dfcecf7d576b599b3c39d552cfbbf082ceda4707f0ec532d94d4a0db3dbfa620
5
5
  SHA512:
6
- metadata.gz: b246be324304a3c3d0b61323fcea9bcfa1abd4dbe496d9fa0915f4a8d421663a44560bdfebc068c0e8f51899a8fba375b2ce51276d20055626b9e9ffa90c8c3b
7
- data.tar.gz: 95e3babf47e78975c5e6c044e7544b2ae0bed61dd2b8b589eab4bce2ee9db2fb24cd3fdd72fc2921d07b531fbdeec7cb3bb51665c7a2719a04dd78cded49c616
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- verse-schema (1.0.0)
4
+ verse-schema (1.1.1)
5
5
  attr_chainable (~> 1.0)
6
6
 
7
7
  GEM
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
- # Extract code from an example
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)
@@ -14,7 +14,7 @@ module Verse
14
14
 
15
15
  # Initialize a new schema.
16
16
  def initialize(post_processors: nil)
17
- @post_processors = 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)
@@ -107,7 +107,7 @@ module Verse
107
107
  end
108
108
 
109
109
  register(nil, NilClass) do |value|
110
- next nil if value.nil? || value == ""
110
+ next nil if value.nil? || value == "" || value == "null"
111
111
 
112
112
  raise Coalescer::Error, "must be nil"
113
113
  end
@@ -8,13 +8,9 @@ module Verse
8
8
  @mapping = {}
9
9
 
10
10
  DEFAULT_MAPPER = lambda do |type|
11
- if type == Base
12
- proc do |value, opts, locals:|
13
- opts[:schema].validate(value, locals:)
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
- # 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
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 = 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
@@ -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, :type
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 type == Hash || type == Object
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
- c <= p
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)
@@ -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
- def <=(other)
68
- other == self || inherit?(other)
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
- def <(other)
72
- other != self && inherit?(other)
73
- end
93
+ def <(other)
94
+ other != self && self <= other
95
+ end
74
96
 
75
- # rubocop:disable Style/InverseMethods
76
- def >(other)
77
- !self.<=(other)
78
- end
79
- # rubocop:enable Style/InverseMethods
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)
@@ -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
- @dataclass = 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
- @dataclass = ::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
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
- type_str = field.type.map(&:inspect).join("|")
264
- else
265
- type_str = field.type.inspect
266
- end
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Verse
4
4
  module Schema
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.1"
6
6
  end
7
7
  end
@@ -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.0.0
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-16 00:00:00.000000000 Z
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