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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61eb99562317fd43b8ea59c1ed4e48c13320ff04334d372c07b5c78efe7ebd22
4
- data.tar.gz: ee09741be15edf7ff3082b285ae16549029e995eb5841518e7f4394f5c131049
3
+ metadata.gz: e4c6ddb5aba085ae7a2c3d0adf46164ecaf9bf3d3e7d6a0ec6891d48a0af0b77
4
+ data.tar.gz: 3612f9a678802ed4353d964734932951fb5a09fdceaf52eca76090907210a225
5
5
  SHA512:
6
- metadata.gz: 9c994b498b30870bb501890ecb44fe007d8ea49ae0ab2737033b70388ba36036b12a6795cf392a712e8b2d5121aa86e875280c080cf9a4ac4af953db2784c1fd
7
- data.tar.gz: 55e8c26767574c8090d144465c23be6fe0a170470f736173cbde5beb059a8adcd1ca4aa52ad9a56038bc487b00e4595e7257d31ad84227df5720fe276c964012
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_.
@@ -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
- r = sc.schema.visit(meta_key, &visitor)
75
- (meta_data[:type] == :array) ? [r] : r
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
@@ -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
- if field.meta_data[:schema].is_a?(Parametric::Schema)
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
- cons ? cons.new(value) : value.freeze
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parametric
4
- VERSION = '0.2.21'
4
+ VERSION = '0.2.23'
5
5
  end
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.21
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: 2024-01-30 00:00:00.000000000 Z
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.4.22
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