verquest 0.6.4 → 0.7.0

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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -1
  3. data/README.md +299 -45
  4. data/Rakefile +5 -0
  5. data/docs/rails.md +169 -0
  6. data/lib/verquest/base/condition_class_methods.rb +211 -0
  7. data/lib/verquest/base/private_class_methods.rb +31 -14
  8. data/lib/verquest/base/public_class_methods.rb +51 -1
  9. data/lib/verquest/base.rb +1 -0
  10. data/lib/verquest/conditions/branch.rb +111 -0
  11. data/lib/verquest/conditions/condition.rb +194 -0
  12. data/lib/verquest/conditions/conjunction.rb +39 -0
  13. data/lib/verquest/conditions/fragment_merge.rb +52 -0
  14. data/lib/verquest/conditions/property_merge.rb +47 -0
  15. data/lib/verquest/conditions/rule_set.rb +58 -0
  16. data/lib/verquest/conditions/rules/absent.rb +25 -0
  17. data/lib/verquest/conditions/rules/alternatives.rb +28 -0
  18. data/lib/verquest/conditions/rules/any.rb +27 -0
  19. data/lib/verquest/conditions/rules/equals.rb +22 -0
  20. data/lib/verquest/conditions/rules/names_rule.rb +23 -0
  21. data/lib/verquest/conditions/rules/none.rb +24 -0
  22. data/lib/verquest/conditions/rules/present.rb +22 -0
  23. data/lib/verquest/conditions/rules/property_rule.rb +48 -0
  24. data/lib/verquest/conditions/rules/satisfies.rb +22 -0
  25. data/lib/verquest/conditions/rules/value_in.rb +22 -0
  26. data/lib/verquest/conditions/rules.rb +17 -0
  27. data/lib/verquest/conditions.rb +36 -0
  28. data/lib/verquest/configuration.rb +19 -1
  29. data/lib/verquest/gem_version.rb +1 -1
  30. data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
  31. data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
  32. data/lib/verquest/properties/array.rb +15 -1
  33. data/lib/verquest/properties/base.rb +104 -10
  34. data/lib/verquest/properties/collection.rb +51 -18
  35. data/lib/verquest/properties/const.rb +2 -1
  36. data/lib/verquest/properties/object.rb +38 -4
  37. data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
  38. data/lib/verquest/properties/one_of.rb +83 -44
  39. data/lib/verquest/properties/reference.rb +47 -16
  40. data/lib/verquest/query/coercer.rb +165 -0
  41. data/lib/verquest/query/definition_check.rb +91 -0
  42. data/lib/verquest/query/parameters.rb +79 -0
  43. data/lib/verquest/query.rb +25 -0
  44. data/lib/verquest/transformer/collection.rb +32 -0
  45. data/lib/verquest/transformer/const_as_enum.rb +47 -0
  46. data/lib/verquest/transformer/layout.rb +83 -0
  47. data/lib/verquest/transformer/mapping_layout.rb +166 -0
  48. data/lib/verquest/transformer/one_of.rb +111 -0
  49. data/lib/verquest/transformer/path.rb +188 -0
  50. data/lib/verquest/transformer/plan.rb +183 -0
  51. data/lib/verquest/transformer/scope.rb +64 -0
  52. data/lib/verquest/transformer.rb +43 -880
  53. data/lib/verquest/version.rb +152 -79
  54. data/lib/verquest.rb +6 -0
  55. metadata +42 -7
@@ -1,14 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Verquest
4
- # Sentinel value to distinguish between "key not found" and "key is nil"
5
- NOT_FOUND = Object.new.freeze
6
-
7
- # Transforms parameters based on path mappings
4
+ # Transforms parameters from the external request structure to the internal one
5
+ #
6
+ # A version builds its transformer from its properties: each property adds
7
+ # its full source and target paths to a Layout, and Plan groups the layout
8
+ # into scopes (the params, the items of each collection that holds a oneOf,
9
+ # and each oneOf variant). Transforming only applies those scopes. The
10
+ # deprecated mapping constructor turns a mapping hash into a layout through
11
+ # MappingLayout instead.
12
+ #
13
+ # @example Transforming a request's params
14
+ # OrderRequest.process(params, version: "2025-06")
15
+ # # or, without validation:
16
+ # OrderRequest.resolve("2025-06").transformer.call(params)
8
17
  #
9
- # The Transformer class handles the conversion of parameter structures based on
10
- # a mapping of source paths to target paths. It supports deep nested structures,
11
- # array notations, and complex path expressions using slash notation.
18
+ # The examples below show the mapping format with the mapping constructor,
19
+ # which is deprecated and removed in 1.0.0.
12
20
  #
13
21
  # @example Basic transformation
14
22
  # mapping = {
@@ -19,26 +27,14 @@ module Verquest
19
27
  #
20
28
  # transformer = Verquest::Transformer.new(mapping: mapping)
21
29
  # result = transformer.call({
22
- # user: {
23
- # firstName: "John",
24
- # lastName: "Doe"
25
- # },
26
- # addresses: [
27
- # { zip: "12345" },
28
- # { zip: "67890" }
29
- # ]
30
+ # "user" => {"firstName" => "John", "lastName" => "Doe"},
31
+ # "addresses" => [{"zip" => "12345"}, {"zip" => "67890"}]
30
32
  # })
31
33
  #
32
34
  # # Result will be:
33
35
  # # {
34
- # # user: {
35
- # # first_name: "John",
36
- # # last_name: "Doe"
37
- # # },
38
- # # addresses: [
39
- # # { postal_code: "12345" },
40
- # # { postal_code: "67890" }
41
- # # ]
36
+ # # "user" => {"first_name" => "John", "last_name" => "Doe"},
37
+ # # "addresses" => [{"postal_code" => "12345"}, {"postal_code" => "67890"}]
42
38
  # # }
43
39
  #
44
40
  # @example Discriminator-based transformation (oneOf)
@@ -68,875 +64,42 @@ module Verquest
68
64
  # result = transformer.call({ "id" => "123", "name" => "Test" })
69
65
  # # Infers "with_id" variant and uses its mapping
70
66
  class Transformer
71
- # Creates a new Transformer with the specified mapping
67
+ # Mapping keys that hold oneOf metadata, not property paths or variant names
68
+ METADATA_KEYS = %w[_discriminator _variant_schemas _variant_path _nullable _nullable_path _nullable_target_path _oneOfs].freeze
69
+
70
+ # Creates a new Transformer
72
71
  #
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)
72
+ # @param layout [Layout, nil] The full paths of the request; versions pass it, it is not public API
73
+ # @param mapping [Hash, nil] Deprecated: a mapping hash, read when no layout is given
74
+ # @param discriminator [String, Symbol, nil] Deprecated: the discriminator of a root-level oneOf in the mapping
76
75
  # @return [Transformer] A new transformer instance
77
- def initialize(mapping:, discriminator: nil)
78
- @mapping = mapping
79
- @discriminator = discriminator
80
- @path_cache = {} # Cache for parsed paths to improve performance
81
- @schemer_cache = {} # Cache for JSONSchemer instances
82
- precompile_paths # Prepare cache during initialization
83
- precompile_schemers # Prepare schemer cache during initialization
76
+ # @raise [ArgumentError] If neither layout nor mapping is given
77
+ def initialize(layout: nil, mapping: nil, discriminator: nil)
78
+ raise ArgumentError, "Transformer.new needs layout: (or the deprecated mapping:)" unless layout || mapping
79
+
80
+ if layout.nil?
81
+ warn "Verquest::Transformer.new(mapping:) is deprecated and will be removed in 1.0.0. " \
82
+ "Use MyRequest.process, or MyRequest.resolve(version).transformer.", category: :deprecated, uplevel: 1
83
+ layout = MappingLayout.call(mapping, discriminator:)
84
+ end
85
+
86
+ @root = Plan.new(layout).root
87
+ freeze
84
88
  end
85
89
 
86
90
  # Transforms input parameters according to the provided mapping
87
91
  #
88
92
  # @param params [Hash] The input parameters to transform
89
93
  # @return [Hash] The transformed parameters with string keys
94
+ # @raise [Verquest::MappingError] If a oneOf matches no variant or more than one
90
95
  def call(params)
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
95
-
96
- active_mapping = resolve_mapping(params)
97
- return {} if active_mapping.nil?
98
-
99
- # Handle nullable oneOf with null value
100
- return transform_null_value(params) if active_mapping == :null_value
101
-
102
- apply_mapping_with_null_propagation(active_mapping, params)
96
+ root.call(params)
103
97
  end
104
98
 
105
99
  private
106
100
 
107
- # @!attribute [r] mapping
108
- # @return [Hash] The source-to-target path mapping
109
- # @!attribute [r] path_cache
110
- # @return [Hash] Cache for parsed paths
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
693
-
694
- # Precompiles all paths from the mapping to improve performance
695
- # This is called during initialization to prepare the cache
696
- #
697
- # @return [void]
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
848
- mapping.each do |source_path, target_path|
849
- parse_path(source_path.to_s)
850
- parse_path(target_path.to_s)
851
- end
852
- end
853
-
854
- # Parses a slash-notation path into structured path parts
855
- # Uses memoization for performance optimization
856
- #
857
- # @param path [String] The slash-notation path (e.g., "user/address/street")
858
- # @return [Array<Hash>] Array of frozen path parts with :key and :array attributes
859
- def parse_path(path)
860
- path_cache[path] ||= path.split("/").map do |part|
861
- if part.end_with?("[]")
862
- {key: part[0...-2], array: true}.freeze
863
- else
864
- {key: part, array: false}.freeze
865
- end
866
- end.freeze
867
- end
868
-
869
- # Extracts a value from nested data structure using the parsed path parts
870
- #
871
- # @param data [Hash, Array, Object] The data to extract value from
872
- # @param path_parts [Array<Hash>] The parsed path parts
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
877
-
878
- current_part = path_parts[index]
879
- key = current_part[:key]
880
-
881
- case data
882
- when Hash
883
- return NOT_FOUND unless data.key?(key.to_s)
884
- value = data[key.to_s]
885
- if current_part[:array] && value.is_a?(Array)
886
- # Process each object in the array separately
887
- value.map { |item| extract_value(item, path_parts, index + 1) }
888
- else
889
- extract_value(value, path_parts, index + 1)
890
- end
891
- when Array
892
- if current_part[:array]
893
- # Map through array elements with remaining path
894
- data.map { |item| extract_value(item, path_parts, index + 1) }
895
- else
896
- # Try to extract from each array element with the full path
897
- data.map { |item| extract_value(item, path_parts, index) }
898
- end
899
- else
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
903
- end
904
- end
905
-
906
- # Sets a value in a result hash at the specified path
907
- #
908
- # @param result [Hash] The result hash to modify
909
- # @param path_parts [Array<Hash>] The parsed path parts
910
- # @param value [Object] The value to set
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
915
-
916
- current_part = path_parts[index]
917
- key = current_part[:key].to_s
918
- last_part = index == path_parts.length - 1
919
-
920
- if last_part
921
- result[key] = value
922
- elsif current_part[:array] && value.is_a?(Array)
923
- result[key] ||= []
924
- value.each_with_index do |v, i|
925
- next if v.equal?(NOT_FOUND) # Skip NOT_FOUND items in array
926
- result[key][i] ||= {}
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) }
930
- end
931
- # Remove NOT_FOUND entries and compact the array
932
- result[key] = result[key].reject { |item| item.equal?(NOT_FOUND) }
933
- else
934
- result[key] ||= {}
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) }
938
- end
939
- result
940
- end
101
+ # @!attribute [r] root
102
+ # @return [Scope] The mapping of the params
103
+ attr_reader :root
941
104
  end
942
105
  end