verquest 0.6.2 → 0.6.3

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.
@@ -1,4 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Verquest
4
+ # Sentinel value to distinguish between "key not found" and "key is nil"
5
+ NOT_FOUND = Object.new.freeze
6
+
2
7
  # Transforms parameters based on path mappings
3
8
  #
4
9
  # The Transformer class handles the conversion of parameter structures based on
@@ -35,34 +40,66 @@ module Verquest
35
40
  # # { postal_code: "67890" }
36
41
  # # ]
37
42
  # # }
43
+ #
44
+ # @example Discriminator-based transformation (oneOf)
45
+ # # For oneOf schemas with a discriminator, the mapping is keyed by discriminator value
46
+ # mapping = {
47
+ # "dog" => { "name" => "name", "bark" => "bark" },
48
+ # "cat" => { "name" => "name", "meow" => "meow" }
49
+ # }
50
+ #
51
+ # transformer = Verquest::Transformer.new(mapping: mapping, discriminator: "type")
52
+ # result = transformer.call({ "type" => "dog", "name" => "Rex", "bark" => true })
53
+ # # Uses the "dog" mapping
54
+ #
55
+ # @example Schema-based variant inference (oneOf without discriminator)
56
+ # # When no discriminator is present, the transformer infers the variant by validating
57
+ # # against each schema and selecting the one that matches
58
+ # mapping = {
59
+ # "_variant_schemas" => {
60
+ # "with_id" => { "type" => "object", "required" => ["id"], ... },
61
+ # "without_id" => { "type" => "object", ... }
62
+ # },
63
+ # "with_id" => { "id" => "id", "name" => "name" },
64
+ # "without_id" => { "name" => "name" }
65
+ # }
66
+ #
67
+ # transformer = Verquest::Transformer.new(mapping: mapping)
68
+ # result = transformer.call({ "id" => "123", "name" => "Test" })
69
+ # # Infers "with_id" variant and uses its mapping
38
70
  class Transformer
39
71
  # Creates a new Transformer with the specified mapping
40
72
  #
41
- # @param mapping [Hash] A hash where keys are source paths and values are target paths
73
+ # @param mapping [Hash] A hash where keys are source paths and values are target paths,
74
+ # or for discriminator-based schemas, keys are discriminator values and values are mapping hashes
75
+ # @param discriminator [String, nil] The property name used to discriminate between schemas (for oneOf)
42
76
  # @return [Transformer] A new transformer instance
43
- def initialize(mapping:)
77
+ def initialize(mapping:, discriminator: nil)
44
78
  @mapping = mapping
79
+ @discriminator = discriminator
45
80
  @path_cache = {} # Cache for parsed paths to improve performance
81
+ @schemer_cache = {} # Cache for JSONSchemer instances
46
82
  precompile_paths # Prepare cache during initialization
83
+ precompile_schemers # Prepare schemer cache during initialization
47
84
  end
48
85
 
49
86
  # Transforms input parameters according to the provided mapping
50
87
  #
51
88
  # @param params [Hash] The input parameters to transform
52
- # @return [Hash] The transformed parameters with symbol keys
89
+ # @return [Hash] The transformed parameters with string keys
53
90
  def call(params)
54
- result = {}
91
+ # Handle collection with oneOf (per-item variant inference)
92
+ if collection_with_one_of?
93
+ return transform_collection_with_one_of(params)
94
+ end
55
95
 
56
- mapping.each do |source_path, target_path|
57
- # Extract value using the source path
58
- value = extract_value(params, parse_path(source_path.to_s))
59
- next if value.nil?
96
+ active_mapping = resolve_mapping(params)
97
+ return {} if active_mapping.nil?
60
98
 
61
- # Set the extracted value at the target path
62
- set_value(result, parse_path(target_path.to_s), value)
63
- end
99
+ # Handle nullable oneOf with null value
100
+ return transform_null_value(params) if active_mapping == :null_value
64
101
 
65
- result
102
+ apply_mapping_with_null_propagation(active_mapping, params)
66
103
  end
67
104
 
68
105
  private
@@ -71,13 +108,743 @@ module Verquest
71
108
  # @return [Hash] The source-to-target path mapping
72
109
  # @!attribute [r] path_cache
73
110
  # @return [Hash] Cache for parsed paths
74
- attr_reader :mapping, :path_cache
111
+ # @!attribute [r] discriminator
112
+ # @return [String, nil] The discriminator property name for oneOf schemas
113
+ # @!attribute [r] schemer_cache
114
+ # @return [Hash] Cache for JSONSchemer instances
115
+ attr_reader :mapping, :path_cache, :discriminator, :schemer_cache
116
+
117
+ # Finds the depth of the first null parent in the path
118
+ #
119
+ # Traverses the path parts and checks if any intermediate value is explicitly null
120
+ # (the key exists but the value is nil). Returns the depth (1-indexed) of the first
121
+ # null parent found, or nil if no null parent exists.
122
+ #
123
+ # @param params [Hash] The input parameters
124
+ # @param path_parts [Array<Hash>] The parsed path parts
125
+ # @return [Integer, nil] The depth of the null parent, or nil if none found
126
+ def find_null_parent_depth(params, path_parts)
127
+ current = params
128
+
129
+ path_parts.each_with_index do |part, index|
130
+ return nil unless current.is_a?(Hash)
131
+
132
+ key = part[:key]
133
+ return nil unless current.key?(key)
134
+
135
+ value = current[key]
136
+ # Found an explicit null - return depth (1-indexed, so index + 1)
137
+ return index + 1 if value.nil?
138
+
139
+ current = value
140
+ end
141
+
142
+ nil
143
+ end
144
+
145
+ # Applies a mapping to params, extracting values and preserving null parents
146
+ #
147
+ # When a source path's parent is explicitly null, the corresponding target
148
+ # path is set to null in the result instead of being omitted.
149
+ #
150
+ # @param active_mapping [Hash] Source-to-target path mapping
151
+ # @param params [Hash] The input parameters
152
+ # @return [Hash] The transformed result
153
+ def apply_mapping_with_null_propagation(active_mapping, params)
154
+ result = {}
155
+ null_parent_targets = {}
156
+
157
+ active_mapping.each do |source_path, target_path|
158
+ source_parts = parse_path(source_path.to_s)
159
+ target_parts = parse_path(target_path.to_s)
160
+
161
+ value = extract_value(params, source_parts)
162
+
163
+ if value.equal?(NOT_FOUND)
164
+ track_null_parent(params, source_parts, target_parts, null_parent_targets)
165
+ next
166
+ end
167
+
168
+ set_value(result, target_parts, value)
169
+ end
170
+
171
+ flush_null_parents(result, null_parent_targets)
172
+
173
+ result
174
+ end
175
+
176
+ # Checks if a source path has a null parent and records the target path to nullify
177
+ #
178
+ # @param params [Hash] The input parameters
179
+ # @param source_parts [Array<Hash>] Parsed source path parts
180
+ # @param target_parts [Array<Hash>] Parsed target path parts
181
+ # @param null_parent_targets [Hash] Accumulator for target paths to set null
182
+ # @return [void]
183
+ def track_null_parent(params, source_parts, target_parts, null_parent_targets)
184
+ null_depth = find_null_parent_depth(params, source_parts)
185
+ return unless null_depth
186
+
187
+ # Number of path segments after the null position in the source
188
+ parts_after_null = source_parts.length - null_depth
189
+
190
+ # The target depth at which to set null. When target is shallower than
191
+ # the remaining source segments, clamp to the full target path.
192
+ target_null_depth = [target_parts.length - parts_after_null, target_parts.length].min
193
+ target_null_depth = [target_null_depth, 1].max
194
+
195
+ target_prefix = target_parts[0...target_null_depth].map { |p| p[:key] }.join("/")
196
+ null_parent_targets[target_prefix] = true
197
+ end
198
+
199
+ # Sets null values in result for tracked null parent targets
200
+ #
201
+ # @param result [Hash] The result hash to update
202
+ # @param null_parent_targets [Hash] Target paths to set null
203
+ # @return [void]
204
+ def flush_null_parents(result, null_parent_targets)
205
+ null_parent_targets.each_key do |target_prefix|
206
+ target_parts = parse_path(target_prefix)
207
+ existing = extract_value(result, target_parts)
208
+ set_value(result, target_parts, nil) if existing.equal?(NOT_FOUND)
209
+ end
210
+ end
211
+
212
+ # Resolves which mapping to use based on the discriminator value or schema inference
213
+ #
214
+ # For nested oneOf, the discriminator can be a path (e.g., "payment/method")
215
+ # and the mapping contains a "_discriminator" key with the path.
216
+ #
217
+ # When no discriminator is present but "_variant_schemas" exists, the variant
218
+ # is inferred by validating the input against each schema.
219
+ #
220
+ # For multiple oneOf, the mapping contains a "_oneOfs" array and base properties.
221
+ #
222
+ # @param params [Hash] The input parameters
223
+ # @return [Hash, nil] The resolved mapping to use for transformation
224
+ def resolve_mapping(params)
225
+ # Handle multiple oneOf
226
+ return resolve_multiple_one_of(params) if multiple_one_of?
227
+
228
+ # Handle nullable oneOf - if value is null, skip variant resolution
229
+ return :null_value if nullable_one_of_with_null_value?(params)
230
+
231
+ disc_path = effective_discriminator
232
+ return mapping unless disc_path || variant_schemas
233
+
234
+ if disc_path
235
+ result = resolve_by_discriminator(params, disc_path)
236
+ return result if result
237
+
238
+ # If discriminator not found, check if oneOf property is absent
239
+ # In that case, return base mapping without oneOf properties
240
+ one_of_property = disc_path.split("/").first
241
+ return extract_base_mapping_without_one_of(one_of_property) if one_of_property_absent?(params, one_of_property)
242
+
243
+ disc_value = extract_value(params, parse_path(disc_path))
244
+ raise Verquest::MappingError, "Unknown discriminator value \"#{disc_value}\" for oneOf. " \
245
+ "No matching variant found."
246
+ else
247
+ resolve_by_schema_inference(params)
248
+ end
249
+ end
250
+
251
+ # Checks if this mapping has multiple oneOf definitions
252
+ #
253
+ # @return [Boolean] True if _oneOfs array is present
254
+ def multiple_one_of?
255
+ mapping.key?("_oneOfs")
256
+ end
257
+
258
+ # Resolves mapping for multiple oneOf by resolving each independently and combining
259
+ #
260
+ # @param params [Hash] The input parameters
261
+ # @return [Hash] Combined mapping from base properties + all resolved variants
262
+ def resolve_multiple_one_of(params)
263
+ result = {}
264
+
265
+ # Add base (non-oneOf) properties
266
+ mapping.each do |key, value|
267
+ next if key == "_oneOfs"
268
+ next if key.start_with?("_")
269
+
270
+ result[key] = value
271
+ end
272
+
273
+ # Resolve each oneOf and add its variant mapping
274
+ mapping["_oneOfs"].each do |one_of_mapping|
275
+ variant_mapping = resolve_single_one_of(params, one_of_mapping)
276
+ result.merge!(variant_mapping) if variant_mapping
277
+ end
278
+
279
+ result
280
+ end
281
+
282
+ # Resolves a single oneOf from the _oneOfs array
283
+ #
284
+ # @param params [Hash] The input parameters
285
+ # @param one_of_mapping [Hash] The mapping for this oneOf
286
+ # @return [Hash, nil] The resolved variant mapping
287
+ def resolve_single_one_of(params, one_of_mapping)
288
+ disc_path = one_of_mapping["_discriminator"]
289
+
290
+ if disc_path
291
+ resolve_one_of_by_discriminator(params, one_of_mapping, disc_path)
292
+ elsif one_of_mapping["_variant_schemas"]
293
+ resolve_one_of_by_schema_inference(params, one_of_mapping)
294
+ end
295
+ end
296
+
297
+ # Resolves a oneOf variant using discriminator
298
+ #
299
+ # @param params [Hash] The input parameters
300
+ # @param one_of_mapping [Hash] The mapping for this oneOf
301
+ # @param disc_path [String] The discriminator path
302
+ # @return [Hash, nil] The resolved variant mapping
303
+ def resolve_one_of_by_discriminator(params, one_of_mapping, disc_path)
304
+ discriminator_value = extract_value(params, parse_path(disc_path))
305
+ return nil if discriminator_value.equal?(NOT_FOUND) || discriminator_value.nil?
306
+
307
+ one_of_mapping[discriminator_value.to_s] || one_of_mapping[discriminator_value]
308
+ end
309
+
310
+ # Resolves a oneOf variant by schema inference
311
+ #
312
+ # @param params [Hash] The input parameters
313
+ # @param one_of_mapping [Hash] The mapping for this oneOf
314
+ # @return [Hash, nil] The resolved variant mapping
315
+ # @raise [Verquest::MappingError] If no schema matches or multiple schemas match
316
+ def resolve_one_of_by_schema_inference(params, one_of_mapping)
317
+ variant_path = one_of_mapping["_variant_path"]
318
+ variant_schemas = one_of_mapping["_variant_schemas"]
319
+
320
+ data_to_validate = if variant_path
321
+ extracted = extract_value(params, parse_path(variant_path))
322
+ # If the oneOf field is not present, skip it (optional field)
323
+ return nil if extracted.equal?(NOT_FOUND)
324
+
325
+ extracted
326
+ else
327
+ params
328
+ end
329
+
330
+ matching_variants = find_matching_variants_for(data_to_validate, variant_schemas)
331
+
332
+ case matching_variants.size
333
+ when 0
334
+ raise Verquest::MappingError, "No matching schema found for oneOf. " \
335
+ "Input does not match any of the defined schemas."
336
+ when 1
337
+ one_of_mapping[matching_variants.first]
338
+ else
339
+ raise Verquest::MappingError, "Ambiguous oneOf match. " \
340
+ "Input matches multiple schemas: #{matching_variants.join(", ")}. " \
341
+ "Consider adding a discriminator or making schemas mutually exclusive."
342
+ end
343
+ end
344
+
345
+ # Finds matching variants for given data and schemas
346
+ #
347
+ # Uses cached JSONSchemer instances for performance and exits early
348
+ # if more than one match is found (ambiguous case).
349
+ #
350
+ # @param data [Hash] The data to validate
351
+ # @param variant_schemas [Hash] The variant schemas to validate against
352
+ # @return [Array<String>] Names of matching variants
353
+ def find_matching_variants_for(data, variant_schemas)
354
+ schemers = schemers_for(variant_schemas)
355
+ matches = []
356
+ schemers.each do |name, schemer|
357
+ matches << name if schemer.valid?(data)
358
+ break if matches.size > 1 # Early exit on ambiguity
359
+ end
360
+ matches
361
+ end
362
+
363
+ # Returns cached schemers for a given variant_schemas hash
364
+ #
365
+ # @param variant_schemas [Hash] The variant schemas
366
+ # @return [Hash] Cached schemer instances keyed by variant name
367
+ def schemers_for(variant_schemas)
368
+ # Use object_id as cache key since variant_schemas is a frozen hash
369
+ cache_key = variant_schemas.object_id
370
+ @one_of_schemer_caches ||= {}
371
+ @one_of_schemer_caches[cache_key] ||= variant_schemas.transform_values do |schema|
372
+ JSONSchemer.schema(schema)
373
+ end
374
+ end
375
+
376
+ # Resolves variant mapping using discriminator value
377
+ #
378
+ # @param params [Hash] The input parameters
379
+ # @param disc_path [String] The discriminator path
380
+ # @return [Hash, nil] The resolved mapping
381
+ def resolve_by_discriminator(params, disc_path)
382
+ discriminator_value = extract_value(params, parse_path(disc_path))
383
+ return nil if discriminator_value.equal?(NOT_FOUND) || discriminator_value.nil?
384
+
385
+ mapping[discriminator_value.to_s] || mapping[discriminator_value]
386
+ end
387
+
388
+ # Checks if the oneOf property is absent from params
389
+ #
390
+ # @param params [Hash] The input parameters
391
+ # @param one_of_property [String] The oneOf property name
392
+ # @return [Boolean] True if the oneOf property is not present in params
393
+ def one_of_property_absent?(params, one_of_property)
394
+ !params.key?(one_of_property)
395
+ end
396
+
397
+ # Extracts base mapping for non-oneOf properties when oneOf is absent
398
+ #
399
+ # When the oneOf property is optional and not provided, we still need to
400
+ # transform the non-oneOf properties. This method extracts those mappings
401
+ # from any variant (they should all have the same non-oneOf properties).
402
+ #
403
+ # @param one_of_property [String] The oneOf property name to exclude
404
+ # @return [Hash] Mapping containing only non-oneOf properties
405
+ def extract_base_mapping_without_one_of(one_of_property)
406
+ sample_variant = first_variant_mapping
407
+ return {} unless sample_variant
408
+
409
+ sample_variant[1].reject { |k, _| k.start_with?("#{one_of_property}/") }
410
+ end
411
+
412
+ # Resolves variant mapping by validating against each schema
413
+ #
414
+ # @param params [Hash] The input parameters
415
+ # @return [Hash, nil] The resolved mapping
416
+ # @raise [Verquest::MappingError] If no schema matches or multiple schemas match
417
+ def resolve_by_schema_inference(params)
418
+ # Check if oneOf property is absent or null (optional/nullable oneOf)
419
+ variant_path = mapping["_variant_path"]
420
+ if variant_path
421
+ return extract_base_mapping_without_one_of(variant_path) if one_of_property_absent?(params, variant_path)
422
+
423
+ if params.key?(variant_path) && params[variant_path].nil?
424
+ base = extract_base_mapping_without_one_of(variant_path)
425
+ return base.merge(variant_path => variant_path)
426
+ end
427
+ end
428
+
429
+ data_to_validate = extract_variant_data(params)
430
+ matching_variants = find_matching_variants(data_to_validate)
431
+
432
+ case matching_variants.size
433
+ when 0
434
+ raise Verquest::MappingError, "No matching schema found for oneOf. " \
435
+ "Input does not match any of the defined schemas."
436
+ when 1
437
+ mapping[matching_variants.first]
438
+ else
439
+ raise Verquest::MappingError, "Ambiguous oneOf match. " \
440
+ "Input matches multiple schemas: #{matching_variants.join(", ")}. " \
441
+ "Consider adding a discriminator or making schemas mutually exclusive."
442
+ end
443
+ end
444
+
445
+ # Extracts the data portion to validate for nested oneOf
446
+ #
447
+ # @param params [Hash] The full input parameters
448
+ # @return [Hash] The data to validate against variant schemas
449
+ def extract_variant_data(params)
450
+ variant_path = mapping["_variant_path"]
451
+ return params unless variant_path
452
+
453
+ result = extract_value(params, parse_path(variant_path))
454
+ result.equal?(NOT_FOUND) ? {} : result
455
+ end
456
+
457
+ # Finds all variants whose schema validates the input
458
+ #
459
+ # Uses cached JSONSchemer instances for performance and exits early
460
+ # if more than one match is found (ambiguous case).
461
+ #
462
+ # @param data [Hash] The data to validate
463
+ # @return [Array<String>] Names of matching variants
464
+ def find_matching_variants(data)
465
+ matches = []
466
+ schemer_cache.each do |name, schemer|
467
+ matches << name if schemer.valid?(data)
468
+ break if matches.size > 1 # Early exit on ambiguity
469
+ end
470
+ matches
471
+ end
472
+
473
+ # Returns the variant schemas for schema-based inference
474
+ #
475
+ # @return [Hash, nil] The variant schemas hash or nil if not present
476
+ def variant_schemas
477
+ mapping["_variant_schemas"]
478
+ end
479
+
480
+ # Checks if this is a collection with oneOf (requires per-item variant resolution)
481
+ #
482
+ # @return [Boolean] True if mapping contains array paths with variant mappings
483
+ def collection_with_one_of?
484
+ # Check for discriminator-less oneOf with variant schemas
485
+ has_schema_based = variant_schemas &&
486
+ variant_mappings.any? { |_, m| m.keys.any? { |path| path.include?("[]") } }
487
+
488
+ # Check for discriminator-based oneOf in collection (discriminator path contains [])
489
+ has_discriminator_based = effective_discriminator&.include?("[]") &&
490
+ variant_mappings.any? { |_, m| m.keys.any? { |path| path.include?("[]") } }
491
+
492
+ has_schema_based || has_discriminator_based
493
+ end
494
+
495
+ # Checks if this uses a discriminator for collection items
496
+ #
497
+ # @return [Boolean] True if discriminator is inside a collection
498
+ def discriminator_in_collection?
499
+ effective_discriminator&.include?("[]")
500
+ end
501
+
502
+ # Returns only the variant mapping entries (excludes metadata keys)
503
+ #
504
+ # @return [Hash] Hash of variant name => mapping pairs
505
+ def variant_mappings
506
+ mapping.select { |key, value| !key.start_with?("_") && value.is_a?(Hash) }
507
+ end
508
+
509
+ # Transforms a collection where each item may match different oneOf variants
510
+ #
511
+ # @param params [Hash] The input parameters to transform
512
+ # @return [Hash] The transformed parameters
513
+ def transform_collection_with_one_of(params)
514
+ # Find the collection path from the variant mappings
515
+ sample_variant = first_variant_mapping
516
+ return {} unless sample_variant
517
+
518
+ sample_path = sample_variant[1].keys.first
519
+ collection_path = sample_path.split("[]").first
520
+
521
+ result = {}
522
+
523
+ # Transform non-collection properties first (root-level properties outside the oneOf)
524
+ transform_non_collection_properties(params, result)
525
+
526
+ # Extract the collection
527
+ collection = extract_value(params, parse_path(collection_path))
528
+ return result unless collection.is_a?(Array)
529
+
530
+ # Transform each item in the collection
531
+ transformed_items = collection.map do |item|
532
+ transform_collection_item(item, collection_path)
533
+ end
534
+
535
+ # Set the transformed collection in the result
536
+ set_value(result, parse_path(target_collection_path(collection_path)), transformed_items)
537
+
538
+ result
539
+ end
540
+
541
+ # Transforms non-collection properties from the mapping
542
+ #
543
+ # These are root-level properties that exist outside the variant-keyed mappings.
544
+ #
545
+ # @param params [Hash] The input parameters
546
+ # @param result [Hash] The result hash to update
547
+ # @return [void]
548
+ def transform_non_collection_properties(params, result)
549
+ non_collection_mapping = {}
550
+ mapping.each do |key, value|
551
+ next if key.start_with?("_")
552
+ next if value.is_a?(Hash)
553
+
554
+ non_collection_mapping[key] = value
555
+ end
556
+
557
+ merged = apply_mapping_with_null_propagation(non_collection_mapping, params)
558
+ result.merge!(merged)
559
+ end
560
+
561
+ # Returns the target collection path, accounting for any mapping
562
+ #
563
+ # @param source_path [String] The source collection path
564
+ # @return [String] The target collection path
565
+ def target_collection_path(source_path)
566
+ # Check if there's a custom mapping for the collection path
567
+ # by looking at the target paths in any variant
568
+ sample_variant = first_variant_mapping
569
+ return source_path unless sample_variant
570
+
571
+ sample_target = sample_variant[1].values.first
572
+ sample_target.split("[]").first
573
+ end
574
+
575
+ # Transforms a single item from a collection using variant resolution
576
+ #
577
+ # Uses discriminator if present, otherwise infers variant by schema validation.
578
+ #
579
+ # @param item [Hash] The item to transform
580
+ # @param collection_path [String] The path to the collection
581
+ # @return [Hash] The transformed item
582
+ def transform_collection_item(item, collection_path)
583
+ if discriminator_in_collection?
584
+ transform_item_with_discriminator(item, collection_path)
585
+ else
586
+ transform_item_with_schema_inference(item, collection_path)
587
+ end
588
+ end
589
+
590
+ # Transforms an item using discriminator-based variant resolution
591
+ #
592
+ # @param item [Hash] The item to transform
593
+ # @param collection_path [String] The path to the collection
594
+ # @return [Hash] The transformed item
595
+ def transform_item_with_discriminator(item, collection_path)
596
+ # Extract discriminator field name from the path (e.g., "pets[]/type" -> "type")
597
+ disc_field = effective_discriminator.split("/").last
598
+ disc_value = item[disc_field]
599
+
600
+ return {} if disc_value.nil?
601
+
602
+ variant_name = disc_value.to_s
603
+ variant_mapping = mapping[variant_name]
604
+ return {} unless variant_mapping
605
+
606
+ item_mapping = extract_item_mapping(variant_name, collection_path)
607
+ transform_item_with_mapping(item, item_mapping)
608
+ end
609
+
610
+ # Transforms an item using schema-based variant inference
611
+ #
612
+ # @param item [Hash] The item to transform
613
+ # @param collection_path [String] The path to the collection
614
+ # @return [Hash] The transformed item
615
+ def transform_item_with_schema_inference(item, collection_path)
616
+ # When there's a variant_path, validate just that nested portion
617
+ data_to_validate = extract_item_variant_data(item)
618
+ matching_variants = find_matching_variants(data_to_validate)
619
+
620
+ case matching_variants.size
621
+ when 0
622
+ raise Verquest::MappingError, "No matching schema found for oneOf. " \
623
+ "Input does not match any of the defined schemas."
624
+ when 1
625
+ variant_name = matching_variants.first
626
+ item_mapping = extract_item_mapping(variant_name, collection_path)
627
+ transform_item_with_mapping(item, item_mapping)
628
+ else
629
+ raise Verquest::MappingError, "Ambiguous oneOf match. " \
630
+ "Input matches multiple schemas: #{matching_variants.join(", ")}. " \
631
+ "Consider adding a discriminator or making schemas mutually exclusive."
632
+ end
633
+ end
634
+
635
+ # Extracts the variant data from a collection item for schema validation
636
+ #
637
+ # @param item [Hash] The collection item
638
+ # @return [Hash] The data to validate against variant schemas
639
+ def extract_item_variant_data(item)
640
+ variant_path = mapping["_variant_path"]
641
+ return item unless variant_path
642
+
643
+ result = extract_value(item, parse_path(variant_path))
644
+ result.equal?(NOT_FOUND) ? {} : result
645
+ end
646
+
647
+ # Extracts the item-level mapping from a variant mapping
648
+ #
649
+ # Converts paths like "items[]/id" => "items[]/id" to "id" => "id"
650
+ # Also includes non-variant collection properties (e.g., fields alongside oneOf).
651
+ #
652
+ # @param variant_name [String] The variant name
653
+ # @param collection_path [String] The collection path prefix
654
+ # @return [Hash] The item-level mapping
655
+ def extract_item_mapping(variant_name, collection_path)
656
+ variant_mapping = mapping[variant_name]
657
+ prefix = "#{collection_path}[]/"
658
+
659
+ item_map = {}
660
+
661
+ # Include non-variant properties from the collection (e.g., entry_id alongside oneOf)
662
+ mapping.each do |source, target|
663
+ next if source.start_with?("_")
664
+ next if target.is_a?(Hash) # Skip variant mappings
665
+
666
+ if source.start_with?(prefix)
667
+ item_source = source.delete_prefix(prefix)
668
+ item_target = target.start_with?(prefix) ? target.delete_prefix(prefix) : target
669
+ item_map[item_source] = item_target
670
+ end
671
+ end
672
+
673
+ # Include variant-specific properties
674
+ variant_mapping.each do |source, target|
675
+ if source.start_with?(prefix)
676
+ item_source = source.delete_prefix(prefix)
677
+ item_target = target.start_with?(prefix) ? target.delete_prefix(prefix) : target
678
+ item_map[item_source] = item_target
679
+ end
680
+ end
681
+
682
+ item_map
683
+ end
684
+
685
+ # Transforms an item using a specific mapping
686
+ #
687
+ # @param item [Hash] The item to transform
688
+ # @param item_mapping [Hash] The mapping to use
689
+ # @return [Hash] The transformed item
690
+ def transform_item_with_mapping(item, item_mapping)
691
+ apply_mapping_with_null_propagation(item_mapping, item)
692
+ end
75
693
 
76
694
  # Precompiles all paths from the mapping to improve performance
77
695
  # This is called during initialization to prepare the cache
78
696
  #
79
697
  # @return [void]
80
698
  def precompile_paths
699
+ if multiple_one_of?
700
+ precompile_multiple_one_of_paths
701
+ elsif effective_discriminator || variant_schemas
702
+ parse_path(effective_discriminator) if effective_discriminator
703
+ parse_path(mapping["_variant_path"]) if mapping["_variant_path"]
704
+ precompile_variant_mappings
705
+ else
706
+ precompile_flat_mapping
707
+ end
708
+ end
709
+
710
+ # Precompiles paths for multiple oneOf mappings
711
+ #
712
+ # @return [void]
713
+ def precompile_multiple_one_of_paths
714
+ # Precompile base property paths
715
+ mapping.each do |key, value|
716
+ next if key == "_oneOfs"
717
+ next if key.start_with?("_")
718
+
719
+ parse_path(key.to_s)
720
+ parse_path(value.to_s)
721
+ end
722
+
723
+ # Precompile each oneOf's paths
724
+ mapping["_oneOfs"].each do |one_of_mapping|
725
+ parse_path(one_of_mapping["_discriminator"]) if one_of_mapping["_discriminator"]
726
+ parse_path(one_of_mapping["_variant_path"]) if one_of_mapping["_variant_path"]
727
+
728
+ one_of_mapping.each do |key, variant_mapping|
729
+ next if key.start_with?("_")
730
+ next unless variant_mapping.is_a?(Hash)
731
+
732
+ variant_mapping.each do |source_path, target_path|
733
+ parse_path(source_path.to_s)
734
+ parse_path(target_path.to_s)
735
+ end
736
+ end
737
+ end
738
+ end
739
+
740
+ # Precompiles JSONSchemer instances for variant schemas
741
+ # This is called during initialization to prepare the schemer cache
742
+ #
743
+ # @return [void]
744
+ def precompile_schemers
745
+ # Precompile for single oneOf
746
+ variant_schemas&.each do |name, schema|
747
+ schemer_cache[name] = JSONSchemer.schema(schema)
748
+ end
749
+
750
+ # Precompile for multiple oneOf
751
+ return unless multiple_one_of?
752
+
753
+ mapping["_oneOfs"].each do |one_of_mapping|
754
+ next unless one_of_mapping["_variant_schemas"]
755
+
756
+ schemers_for(one_of_mapping["_variant_schemas"])
757
+ end
758
+ end
759
+
760
+ # Returns the effective discriminator path
761
+ #
762
+ # @return [String, nil] The discriminator path from constructor or mapping
763
+ def effective_discriminator
764
+ discriminator || mapping["_discriminator"]
765
+ end
766
+
767
+ # Checks if this is a nullable oneOf and the value is null
768
+ #
769
+ # @param params [Hash] The input parameters
770
+ # @return [Boolean] True if nullable oneOf with null value
771
+ def nullable_one_of_with_null_value?(params)
772
+ return false unless mapping["_nullable"]
773
+
774
+ nullable_path = mapping["_nullable_path"]
775
+
776
+ if nullable_path
777
+ # Nested oneOf - check if the property exists and is null
778
+ params.key?(nullable_path) && params[nullable_path].nil?
779
+ else
780
+ # Root-level oneOf - check if params itself is null
781
+ params.nil?
782
+ end
783
+ end
784
+
785
+ # Transforms a null value for nullable oneOf
786
+ #
787
+ # For nested oneOf, we need to transform non-oneOf properties as well,
788
+ # then add the null value for the oneOf property.
789
+ #
790
+ # @param params [Hash] The input parameters
791
+ # @return [Hash] The result with null value at the appropriate path
792
+ def transform_null_value(params)
793
+ nullable_path = mapping["_nullable_path"]
794
+
795
+ if nullable_path
796
+ # Nested oneOf - transform non-oneOf properties plus null for the oneOf property
797
+ non_one_of_mapping = extract_non_one_of_mapping(nullable_path)
798
+ result = apply_mapping_with_null_propagation(non_one_of_mapping, params)
799
+
800
+ # Add the null value for the oneOf property using target path (respects map: option)
801
+ nullable_target_path = mapping["_nullable_target_path"] || nullable_path
802
+ result[nullable_target_path] = nil
803
+ result
804
+ else
805
+ # Root-level oneOf - return empty hash (null at root)
806
+ {}
807
+ end
808
+ end
809
+
810
+ # Extracts non-oneOf property mappings from a variant for nullable oneOf handling
811
+ #
812
+ # @param nullable_path [String] The oneOf property path to exclude
813
+ # @return [Hash] Mapping containing only non-oneOf properties
814
+ def extract_non_one_of_mapping(nullable_path)
815
+ sample_variant = first_variant_mapping
816
+ return {} unless sample_variant
817
+
818
+ prefix = "#{nullable_path}/"
819
+ sample_variant[1].reject { |source, _| source.start_with?(prefix) }
820
+ end
821
+
822
+ # Returns the first non-metadata variant mapping entry
823
+ #
824
+ # @return [Array, nil] The [key, value] pair of the first variant, or nil
825
+ def first_variant_mapping
826
+ mapping.find { |k, v| !k.start_with?("_") && v.is_a?(Hash) }
827
+ end
828
+
829
+ # Precompiles paths for discriminator-based variant mappings
830
+ #
831
+ # @return [void]
832
+ def precompile_variant_mappings
833
+ mapping.each do |key, variant_mapping|
834
+ next if key.start_with?("_") # Skip metadata keys like _discriminator, _variant_schemas, _variant_path
835
+ next unless variant_mapping.is_a?(Hash)
836
+
837
+ variant_mapping.each do |source_path, target_path|
838
+ parse_path(source_path.to_s)
839
+ parse_path(target_path.to_s)
840
+ end
841
+ end
842
+ end
843
+
844
+ # Precompiles paths for flat (non-discriminator) mappings
845
+ #
846
+ # @return [void]
847
+ def precompile_flat_mapping
81
848
  mapping.each do |source_path, target_path|
82
849
  parse_path(source_path.to_s)
83
850
  parse_path(target_path.to_s)
@@ -88,49 +855,51 @@ module Verquest
88
855
  # Uses memoization for performance optimization
89
856
  #
90
857
  # @param path [String] The slash-notation path (e.g., "user/address/street")
91
- # @return [Array<Hash>] Array of path parts with :key and :array attributes
858
+ # @return [Array<Hash>] Array of frozen path parts with :key and :array attributes
92
859
  def parse_path(path)
93
860
  path_cache[path] ||= path.split("/").map do |part|
94
861
  if part.end_with?("[]")
95
- {key: part[0...-2], array: true}
862
+ {key: part[0...-2], array: true}.freeze
96
863
  else
97
- {key: part, array: false}
864
+ {key: part, array: false}.freeze
98
865
  end
99
- end
866
+ end.freeze
100
867
  end
101
868
 
102
869
  # Extracts a value from nested data structure using the parsed path parts
103
870
  #
104
871
  # @param data [Hash, Array, Object] The data to extract value from
105
872
  # @param path_parts [Array<Hash>] The parsed path parts
106
- # @return [Object, nil] The extracted value or nil if not found
107
- def extract_value(data, path_parts)
108
- return data if path_parts.empty?
873
+ # @param index [Integer] Current position in path_parts (avoids array slicing)
874
+ # @return [Object, NOT_FOUND] The extracted value or NOT_FOUND if key doesn't exist
875
+ def extract_value(data, path_parts, index = 0)
876
+ return data if index >= path_parts.length
109
877
 
110
- current_part = path_parts.first
111
- remaining_path = path_parts[1..]
878
+ current_part = path_parts[index]
112
879
  key = current_part[:key]
113
880
 
114
881
  case data
115
882
  when Hash
116
- return nil unless data.key?(key.to_s)
883
+ return NOT_FOUND unless data.key?(key.to_s)
117
884
  value = data[key.to_s]
118
885
  if current_part[:array] && value.is_a?(Array)
119
886
  # Process each object in the array separately
120
- value.map { |item| extract_value(item, remaining_path) }
887
+ value.map { |item| extract_value(item, path_parts, index + 1) }
121
888
  else
122
- extract_value(value, remaining_path)
889
+ extract_value(value, path_parts, index + 1)
123
890
  end
124
891
  when Array
125
892
  if current_part[:array]
126
893
  # Map through array elements with remaining path
127
- data.map { |item| extract_value(item, remaining_path) }
894
+ data.map { |item| extract_value(item, path_parts, index + 1) }
128
895
  else
129
896
  # Try to extract from each array element with the full path
130
- data.map { |item| extract_value(item, path_parts) }
897
+ data.map { |item| extract_value(item, path_parts, index) }
131
898
  end
132
899
  else
133
- remaining_path.empty? ? data : nil
900
+ # If data is not a Hash or Array (e.g., nil, string, number), we cannot
901
+ # traverse further to extract nested values. Return NOT_FOUND.
902
+ NOT_FOUND
134
903
  end
135
904
  end
136
905
 
@@ -139,37 +908,33 @@ module Verquest
139
908
  # @param result [Hash] The result hash to modify
140
909
  # @param path_parts [Array<Hash>] The parsed path parts
141
910
  # @param value [Object] The value to set
142
- # @return [Hash] The modified result hash with symbol keys
143
- def set_value(result, path_parts, value)
144
- return result if path_parts.empty?
911
+ # @param index [Integer] Current position in path_parts (avoids array slicing)
912
+ # @return [Hash] The modified result hash with string keys
913
+ def set_value(result, path_parts, value, index = 0)
914
+ return result if index >= path_parts.length
145
915
 
146
- current_part = path_parts.first
147
- remaining_path = path_parts[1..]
916
+ current_part = path_parts[index]
148
917
  key = current_part[:key].to_s
918
+ last_part = index == path_parts.length - 1
149
919
 
150
- if value.nil?
151
- # Skip setting nil values
152
- return result
153
- end
154
-
155
- if remaining_path.empty?
920
+ if last_part
156
921
  result[key] = value
157
922
  elsif current_part[:array] && value.is_a?(Array)
158
923
  result[key] ||= []
159
924
  value.each_with_index do |v, i|
160
- next if v.nil? # Skip nil items in array
925
+ next if v.equal?(NOT_FOUND) # Skip NOT_FOUND items in array
161
926
  result[key][i] ||= {}
162
- set_value(result[key][i], remaining_path, v)
163
- # Remove keys with nil values from each object
164
- result[key][i].delete_if { |_, val| val.nil? }
927
+ set_value(result[key][i], path_parts, v, index + 1)
928
+ # Remove keys with NOT_FOUND values from each object
929
+ result[key][i].delete_if { |_, val| val.equal?(NOT_FOUND) }
165
930
  end
166
- # Remove nils and compact the array
167
- result[key] = result[key].compact
931
+ # Remove NOT_FOUND entries and compact the array
932
+ result[key] = result[key].reject { |item| item.equal?(NOT_FOUND) }
168
933
  else
169
934
  result[key] ||= {}
170
- set_value(result[key], remaining_path, value)
171
- # Remove keys with nil values from nested object
172
- result[key].delete_if { |_, val| val.nil? }
935
+ set_value(result[key], path_parts, value, index + 1)
936
+ # Remove keys with NOT_FOUND values from nested object
937
+ result[key].delete_if { |_, val| val.equal?(NOT_FOUND) }
173
938
  end
174
939
  result
175
940
  end