parametric 0.2.21 → 0.2.23
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/README.md +62 -0
- data/lib/parametric/field.rb +45 -2
- data/lib/parametric/one_of.rb +125 -0
- data/lib/parametric/struct.rb +28 -2
- data/lib/parametric/version.rb +1 -1
- data/spec/schema_spec.rb +54 -0
- data/spec/struct_spec.rb +26 -0
- metadata +4 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e4c6ddb5aba085ae7a2c3d0adf46164ecaf9bf3d3e7d6a0ec6891d48a0af0b77
|
4
|
+
data.tar.gz: 3612f9a678802ed4353d964734932951fb5a09fdceaf52eca76090907210a225
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7181f7d9cd318fb362ab77a6bc9da5b9bf6bc41373b6de6d8e3e41ddb92d23b9a2a46c073d88570692edf644ab748c9f7fdb70e565372e710ece850978ed3cf0
|
7
|
+
data.tar.gz: 7998b13d22814989ebcd0cda3fab50b05a522dc853157dde131a0e0bb4b53f06aabb25c84c120136f2204ba289cc274a05793beb039edc466c172835bf3ceca5
|
data/README.md
CHANGED
@@ -181,6 +181,68 @@ sc.field(:sub).type(:object).tagged_one_of do |sub|
|
|
181
181
|
end
|
182
182
|
```
|
183
183
|
|
184
|
+
## One Of (multiple nested schemas, pick first valid match)
|
185
|
+
|
186
|
+
You can use `Field#one_of` to validate a field against multiple possible schemas and accept the first one that validates successfully.
|
187
|
+
|
188
|
+
Unlike `tagged_one_of`, this doesn't require a discriminator field - it tries each schema in order until it finds one that validates the input data.
|
189
|
+
|
190
|
+
```ruby
|
191
|
+
user_schema = Parametric::Schema.new do |sc, _|
|
192
|
+
sc.field(:name).type(:string).present
|
193
|
+
sc.field(:age).type(:integer).present
|
194
|
+
end
|
195
|
+
|
196
|
+
company_schema = Parametric::Schema.new do |sc, _|
|
197
|
+
sc.field(:company_name).type(:string).present
|
198
|
+
sc.field(:company_code).type(:string).present
|
199
|
+
end
|
200
|
+
|
201
|
+
schema = Parametric::Schema.new do |sc, _|
|
202
|
+
# This field can be either a user or company object
|
203
|
+
# The schema will try user_schema first, then company_schema
|
204
|
+
sc.field(:entity).type(:object).one_of(user_schema, company_schema)
|
205
|
+
end
|
206
|
+
|
207
|
+
# This will validate against user_schema (first match)
|
208
|
+
result = schema.resolve(entity: { name: 'Joe', age: 30 })
|
209
|
+
result.output # => { entity: { name: 'Joe', age: 30 } }
|
210
|
+
|
211
|
+
# This will validate against company_schema (user_schema fails, so it tries company_schema)
|
212
|
+
result = schema.resolve(entity: { company_name: 'Acme Corp', company_code: 'ACME' })
|
213
|
+
result.output # => { entity: { company_name: 'Acme Corp', company_code: 'ACME' } }
|
214
|
+
```
|
215
|
+
|
216
|
+
The validation fails if:
|
217
|
+
- No schemas match the input data
|
218
|
+
- Multiple schemas match the input data (ambiguous)
|
219
|
+
|
220
|
+
```ruby
|
221
|
+
# This fails because it doesn't match either schema
|
222
|
+
result = schema.resolve(entity: { invalid_field: 'value' })
|
223
|
+
result.valid? # => false
|
224
|
+
result.errors # => { "$.entity" => ["No valid sub-schema found"] }
|
225
|
+
```
|
226
|
+
|
227
|
+
### Usage with Structs
|
228
|
+
|
229
|
+
When used with `Parametric::Struct`, `one_of` automatically creates multiple nested struct classes:
|
230
|
+
|
231
|
+
```ruby
|
232
|
+
class MyStruct
|
233
|
+
include Parametric::Struct
|
234
|
+
|
235
|
+
schema do |sc, _|
|
236
|
+
sc.field(:data).type(:object).one_of(user_schema, company_schema)
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
# The appropriate struct class will be instantiated based on which schema validates
|
241
|
+
instance = MyStruct.new!(data: { name: 'Joe', age: 30 })
|
242
|
+
instance.data # => #<MyStruct::Data2:...> (User struct)
|
243
|
+
instance.data.name # => 'Joe'
|
244
|
+
```
|
245
|
+
|
184
246
|
## Built-in policies
|
185
247
|
|
186
248
|
Type coercions (the `type` method) and validations (the `validate` method) are all _policies_.
|
data/lib/parametric/field.rb
CHANGED
@@ -3,6 +3,7 @@
|
|
3
3
|
require 'delegate'
|
4
4
|
require 'parametric/field_dsl'
|
5
5
|
require 'parametric/policy_adapter'
|
6
|
+
require 'parametric/one_of'
|
6
7
|
require 'parametric/tagged_one_of'
|
7
8
|
|
8
9
|
module Parametric
|
@@ -50,6 +51,44 @@ module Parametric
|
|
50
51
|
policy(instance || Parametric::TaggedOneOf.new(&block))
|
51
52
|
end
|
52
53
|
|
54
|
+
# Validate field value against multiple schemas, accepting the first valid match.
|
55
|
+
#
|
56
|
+
# This method allows a field to accept one of several possible object structures.
|
57
|
+
# It validates the input against each provided schema in order and uses the output
|
58
|
+
# from the first schema that successfully validates the input.
|
59
|
+
#
|
60
|
+
# The validation fails if:
|
61
|
+
# - No schemas match the input (invalid data)
|
62
|
+
# - Multiple schemas match the input (ambiguous structure)
|
63
|
+
#
|
64
|
+
# @param schemas [Array<Schema>] Variable number of Schema objects to validate against
|
65
|
+
# @return [Field] Returns self for method chaining
|
66
|
+
#
|
67
|
+
# @example Define a field that can be either a user or admin object
|
68
|
+
# user_schema = Schema.new { field(:name).type(:string).present }
|
69
|
+
# admin_schema = Schema.new { field(:role).type(:string).options(['admin']) }
|
70
|
+
#
|
71
|
+
# schema = Schema.new do |sc, _|
|
72
|
+
# sc.field(:person).type(:object).one_of(user_schema, admin_schema)
|
73
|
+
# end
|
74
|
+
#
|
75
|
+
# @example With different data structures
|
76
|
+
# payment_schema = Schema.new do
|
77
|
+
# field(:amount).type(:number).present
|
78
|
+
# field(:currency).type(:string).present
|
79
|
+
# end
|
80
|
+
#
|
81
|
+
# credit_schema = Schema.new do
|
82
|
+
# field(:credits).type(:integer).present
|
83
|
+
# end
|
84
|
+
#
|
85
|
+
# schema = Schema.new do |sc, _|
|
86
|
+
# sc.field(:transaction).type(:object).one_of(payment_schema, credit_schema)
|
87
|
+
# end
|
88
|
+
def one_of(*schemas)
|
89
|
+
policy OneOf.new(schemas)
|
90
|
+
end
|
91
|
+
|
53
92
|
def schema(sc = nil, &block)
|
54
93
|
sc = (sc ? sc : Schema.new(&block))
|
55
94
|
meta schema: sc
|
@@ -71,8 +110,12 @@ module Parametric
|
|
71
110
|
|
72
111
|
def visit(meta_key = nil, &visitor)
|
73
112
|
if sc = meta_data[:schema]
|
74
|
-
|
75
|
-
|
113
|
+
if sc.is_a?(Array)
|
114
|
+
sc.map { |s| s.schema.visit(meta_key, &visitor) }
|
115
|
+
else
|
116
|
+
r = sc.schema.visit(meta_key, &visitor)
|
117
|
+
(meta_data[:type] == :array) ? [r] : r
|
118
|
+
end
|
76
119
|
else
|
77
120
|
meta_key ? meta_data[meta_key] : yield(self)
|
78
121
|
end
|
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Parametric
|
4
|
+
# Policy that validates a value against multiple schemas and chooses the first valid match.
|
5
|
+
#
|
6
|
+
# OneOf is useful for polymorphic validation where a field can be one of several
|
7
|
+
# different object structures. It tries each schema in order and returns the output
|
8
|
+
# of the first schema that successfully validates the input.
|
9
|
+
#
|
10
|
+
# @example Basic usage
|
11
|
+
# user_schema = Schema.new { field(:name).type(:string).present }
|
12
|
+
# admin_schema = Schema.new { field(:role).type(:string).options(['admin']) }
|
13
|
+
#
|
14
|
+
# schema = Schema.new do |sc, _|
|
15
|
+
# sc.field(:person).type(:object).one_of(user_schema, admin_schema)
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# @example With Struct
|
19
|
+
# class MyStruct
|
20
|
+
# include Parametric::Struct
|
21
|
+
#
|
22
|
+
# schema do
|
23
|
+
# field(:data).type(:object).one_of(schema1, schema2, schema3)
|
24
|
+
# end
|
25
|
+
# end
|
26
|
+
class OneOf
|
27
|
+
# Initialize with an array of schemas to validate against
|
28
|
+
#
|
29
|
+
# @param schemas [Array<Schema>] Array of Parametric::Schema objects
|
30
|
+
def initialize(schemas = [])
|
31
|
+
@schemas = schemas
|
32
|
+
end
|
33
|
+
|
34
|
+
# Build a Runner instance for this policy (PolicyFactory interface)
|
35
|
+
#
|
36
|
+
# @param key [Symbol] The field key being validated
|
37
|
+
# @param value [Object] The value to validate
|
38
|
+
# @param payload [Hash] The full input payload
|
39
|
+
# @param context [Object] Validation context
|
40
|
+
# @return [Runner] A new Runner instance
|
41
|
+
def build(key, value, payload:, context:)
|
42
|
+
Runner.new(@schemas, key, value, payload, context)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Return metadata about this policy
|
46
|
+
#
|
47
|
+
# @return [Hash] Metadata hash with type and schema information
|
48
|
+
def meta_data
|
49
|
+
{ type: :object, schema: @schemas }
|
50
|
+
end
|
51
|
+
|
52
|
+
# Runner handles the actual validation logic for OneOf policy.
|
53
|
+
#
|
54
|
+
# It validates the input value against each schema in order and determines
|
55
|
+
# which one(s) are valid. The policy succeeds if exactly one schema validates
|
56
|
+
# the input, and fails if zero or multiple schemas are valid.
|
57
|
+
class Runner
|
58
|
+
# Initialize the runner with validation parameters
|
59
|
+
#
|
60
|
+
# @param schemas [Array<Schema>] Schemas to validate against
|
61
|
+
# @param key [Symbol] Field key being validated
|
62
|
+
# @param value [Object] Value to validate
|
63
|
+
# @param payload [Hash] Full input payload
|
64
|
+
# @param context [Object] Validation context
|
65
|
+
def initialize(schemas, key, value, payload, context)
|
66
|
+
@schemas = schemas
|
67
|
+
@key = key
|
68
|
+
@raw_value = value
|
69
|
+
@payload = payload
|
70
|
+
@context = context
|
71
|
+
@results = []
|
72
|
+
@message = nil
|
73
|
+
end
|
74
|
+
|
75
|
+
# Should this policy run at all?
|
76
|
+
# returning [false] halts the field policy chain.
|
77
|
+
# @return [Boolean]
|
78
|
+
def eligible?
|
79
|
+
true
|
80
|
+
end
|
81
|
+
|
82
|
+
# Validates that exactly one schema matches the input value.
|
83
|
+
#
|
84
|
+
# The validation fails if:
|
85
|
+
# - No schemas validate the input (invalid data)
|
86
|
+
# - Multiple schemas validate the input (ambiguous match)
|
87
|
+
#
|
88
|
+
# @return [Boolean] true if exactly one schema validates the input
|
89
|
+
def valid?
|
90
|
+
value
|
91
|
+
valids = @results.select(&:valid?)
|
92
|
+
if valids.size > 1
|
93
|
+
@message = "#{@raw_value} is invalid. Multiple valid sub-schemas found"
|
94
|
+
elsif valids.empty?
|
95
|
+
@message = "#{@raw_value} is invalid. No valid sub-schema found"
|
96
|
+
end
|
97
|
+
@message.nil?
|
98
|
+
end
|
99
|
+
|
100
|
+
# Returns the validated and coerced value from the first matching schema.
|
101
|
+
#
|
102
|
+
# This method triggers the validation process by resolving the input value
|
103
|
+
# against each schema. If a valid schema is found, its coerced output is
|
104
|
+
# returned. Otherwise, the raw value is returned unchanged.
|
105
|
+
#
|
106
|
+
# @return [Object] The coerced value from the first valid schema, or raw value if none match
|
107
|
+
def value
|
108
|
+
@value ||= begin
|
109
|
+
@results = @schemas.map do |schema|
|
110
|
+
schema.resolve(@raw_value)
|
111
|
+
end
|
112
|
+
first_valid = @results.find(&:valid?)
|
113
|
+
first_valid ? first_valid.output : @raw_value
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Error message when validation fails
|
118
|
+
#
|
119
|
+
# @return [String, nil] Error message if validation failed, nil if valid
|
120
|
+
def message
|
121
|
+
@message
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
data/lib/parametric/struct.rb
CHANGED
@@ -64,17 +64,33 @@ module Parametric
|
|
64
64
|
def parametric_after_define_schema(schema)
|
65
65
|
schema.fields.values.each do |field|
|
66
66
|
if field.meta_data[:schema]
|
67
|
-
|
67
|
+
case field.meta_data[:schema]
|
68
|
+
when Parametric::Schema
|
68
69
|
klass = Class.new do
|
69
70
|
include Struct
|
70
71
|
end
|
71
72
|
klass.schema = field.meta_data[:schema]
|
72
73
|
self.const_set(__class_name(field.key), klass)
|
73
74
|
klass.parametric_after_define_schema(field.meta_data[:schema])
|
75
|
+
when Array
|
76
|
+
# Handle one_of fields: create multiple struct classes, one for each possible schema
|
77
|
+
# This allows the field to instantiate the appropriate nested struct based on which schema validates
|
78
|
+
classes = field.meta_data[:schema].map.with_index(1) do |sc, idx|
|
79
|
+
klass = Class.new do
|
80
|
+
include Struct
|
81
|
+
end
|
82
|
+
klass.schema = sc
|
83
|
+
class_name = "#{__class_name(field.key)}#{idx}"
|
84
|
+
self.const_set(__class_name(class_name), klass)
|
85
|
+
klass.parametric_after_define_schema(sc)
|
86
|
+
klass
|
87
|
+
end
|
88
|
+
self.const_set(__class_name(field.key), classes)
|
74
89
|
else
|
75
90
|
self.const_set(__class_name(field.key), field.meta_data[:schema])
|
76
91
|
end
|
77
92
|
end
|
93
|
+
|
78
94
|
self.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
79
95
|
def #{field.key}
|
80
96
|
_graph[:#{field.key}]
|
@@ -94,7 +110,17 @@ module Parametric
|
|
94
110
|
when Hash
|
95
111
|
# find constructor for field
|
96
112
|
cons = self.const_get(__class_name(key))
|
97
|
-
|
113
|
+
case cons
|
114
|
+
when Class # Single nested struct
|
115
|
+
cons.new(value)
|
116
|
+
when Array # Array of possible nested structs (one_of)
|
117
|
+
# For one_of fields: instantiate all possible struct classes and return the first valid one
|
118
|
+
# This allows the struct to automatically choose the correct nested structure based on the data
|
119
|
+
structs = cons.map{|c| c.new(value) }
|
120
|
+
structs.find(&:valid?) || structs.first
|
121
|
+
else
|
122
|
+
value.freeze
|
123
|
+
end
|
98
124
|
when Array
|
99
125
|
value.map{|v| wrap(key, v) }.freeze
|
100
126
|
else
|
data/lib/parametric/version.rb
CHANGED
data/spec/schema_spec.rb
CHANGED
@@ -274,6 +274,60 @@ describe Parametric::Schema do
|
|
274
274
|
end
|
275
275
|
end
|
276
276
|
|
277
|
+
describe '#one_of' do
|
278
|
+
let(:kwh_schema) do
|
279
|
+
described_class.new do
|
280
|
+
field(:unit).type(:string).present.default('kwh').options(['kwh'])
|
281
|
+
field(:value).type(:integer).present
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
let(:euro_schema) do
|
286
|
+
described_class.new do
|
287
|
+
field(:unit).type(:string).present.default('euro').options(['euro'])
|
288
|
+
field(:value).type(:integer).present
|
289
|
+
field(:period).type(:string).options(['month', 'year'])
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
let(:schema) do
|
294
|
+
described_class.new do |sc, _|
|
295
|
+
sc.field(:consumption).type(:object).one_of(kwh_schema, euro_schema)
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
it 'picks the valid sub-schema' do
|
300
|
+
result = schema.resolve(consumption: { unit: 'kwh', value: 100 })
|
301
|
+
expect(result.valid?).to be true
|
302
|
+
expect(result.output).to eq({ consumption: { unit: 'kwh', value: 100 } })
|
303
|
+
|
304
|
+
result = schema.resolve(consumption: { unit: 'euro', value: 100, period: 'month' })
|
305
|
+
expect(result.valid?).to be true
|
306
|
+
expect(result.output).to eq({ consumption: { unit: 'euro', value: 100, period: 'month' } })
|
307
|
+
|
308
|
+
result = schema.resolve(consumption: { unit: 'euro', value: 100, period: 'nope' })
|
309
|
+
expect(result.valid?).to be false
|
310
|
+
end
|
311
|
+
|
312
|
+
it 'is invalid is more than one valid sub-schema' do
|
313
|
+
sub1 = described_class.new do
|
314
|
+
field(:name).type(:string).present
|
315
|
+
end
|
316
|
+
sub2 = described_class.new do
|
317
|
+
field(:name).type(:string).present
|
318
|
+
end
|
319
|
+
schema = described_class.new do |sc, _|
|
320
|
+
sc.field(:obj).type(:object).one_of(sub1, sub2)
|
321
|
+
end
|
322
|
+
result = schema.resolve(obj: { name: 'Joe' })
|
323
|
+
expect(result.valid?).to be false
|
324
|
+
end
|
325
|
+
|
326
|
+
it 'does not break #walk' do
|
327
|
+
expect(schema.walk(:default).output).to be_a(Hash)
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
277
331
|
describe '#tagged_one_of for multiple sub-schemas' do
|
278
332
|
let(:user_schema) do
|
279
333
|
described_class.new do
|
data/spec/struct_spec.rb
CHANGED
@@ -318,4 +318,30 @@ describe Parametric::Struct do
|
|
318
318
|
expect(valid.title).to eq 'foo'
|
319
319
|
end
|
320
320
|
end
|
321
|
+
|
322
|
+
it 'works with Field#one_of(*schemas)' do
|
323
|
+
sub1 = Parametric::Schema.new do
|
324
|
+
field(:name).type(:string).present
|
325
|
+
end
|
326
|
+
|
327
|
+
sub2 = Parametric::Schema.new do
|
328
|
+
field(:age).type(:integer).present
|
329
|
+
end
|
330
|
+
|
331
|
+
klass = Class.new do
|
332
|
+
include Parametric::Struct
|
333
|
+
|
334
|
+
schema do
|
335
|
+
field(:user).type(:object).one_of(sub1, sub2)
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
valid = klass.new!(user: { age: '20' })
|
340
|
+
expect(valid.user.age).to eq 20
|
341
|
+
expect(valid.user).to be_a(klass.const_get('User2'))
|
342
|
+
|
343
|
+
invalid = klass.new(user: { nope: '20' })
|
344
|
+
expect(invalid.valid?).to be false
|
345
|
+
expect(invalid.errors['$.user']).not_to be_nil
|
346
|
+
end
|
321
347
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: parametric
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.23
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ismael Celis
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2025-09-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rake
|
@@ -77,6 +77,7 @@ files:
|
|
77
77
|
- lib/parametric/field.rb
|
78
78
|
- lib/parametric/field_dsl.rb
|
79
79
|
- lib/parametric/nullable_policy.rb
|
80
|
+
- lib/parametric/one_of.rb
|
80
81
|
- lib/parametric/policies.rb
|
81
82
|
- lib/parametric/policy_adapter.rb
|
82
83
|
- lib/parametric/registry.rb
|
@@ -117,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
117
118
|
- !ruby/object:Gem::Version
|
118
119
|
version: '0'
|
119
120
|
requirements: []
|
120
|
-
rubygems_version: 3.
|
121
|
+
rubygems_version: 3.5.21
|
121
122
|
signing_key:
|
122
123
|
specification_version: 4
|
123
124
|
summary: DSL for declaring allowed parameters with options, regexp patern and default
|