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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/Rakefile +26 -3
- data/lib/verquest/base/private_class_methods.rb +30 -0
- data/lib/verquest/base/public_class_methods.rb +18 -9
- data/lib/verquest/configuration.rb +2 -0
- data/lib/verquest/gem_version.rb +1 -1
- data/lib/verquest/helper_methods/required_properties.rb +1 -1
- data/lib/verquest/properties/array.rb +2 -2
- data/lib/verquest/properties/base.rb +1 -1
- data/lib/verquest/properties/collection.rb +44 -5
- data/lib/verquest/properties/const.rb +2 -2
- data/lib/verquest/properties/enum.rb +2 -2
- data/lib/verquest/properties/field.rb +2 -2
- data/lib/verquest/properties/object.rb +1 -1
- data/lib/verquest/properties/one_of.rb +437 -0
- data/lib/verquest/properties/reference.rb +2 -2
- data/lib/verquest/transformer.rb +813 -48
- data/lib/verquest/version.rb +240 -11
- metadata +2 -1
data/lib/verquest/transformer.rb
CHANGED
|
@@ -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
|
|
89
|
+
# @return [Hash] The transformed parameters with string keys
|
|
53
90
|
def call(params)
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
99
|
+
# Handle nullable oneOf with null value
|
|
100
|
+
return transform_null_value(params) if active_mapping == :null_value
|
|
64
101
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
# @
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
|
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,
|
|
887
|
+
value.map { |item| extract_value(item, path_parts, index + 1) }
|
|
121
888
|
else
|
|
122
|
-
extract_value(value,
|
|
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,
|
|
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
|
-
|
|
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
|
-
# @
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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.
|
|
925
|
+
next if v.equal?(NOT_FOUND) # Skip NOT_FOUND items in array
|
|
161
926
|
result[key][i] ||= {}
|
|
162
|
-
set_value(result[key][i],
|
|
163
|
-
# Remove keys with
|
|
164
|
-
result[key][i].delete_if { |_, val| val.
|
|
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
|
|
167
|
-
result[key] = result[key].
|
|
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],
|
|
171
|
-
# Remove keys with
|
|
172
|
-
result[key].delete_if { |_, val| val.
|
|
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
|