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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -1
- data/README.md +299 -45
- data/Rakefile +5 -0
- data/docs/rails.md +169 -0
- data/lib/verquest/base/condition_class_methods.rb +211 -0
- data/lib/verquest/base/private_class_methods.rb +31 -14
- data/lib/verquest/base/public_class_methods.rb +51 -1
- data/lib/verquest/base.rb +1 -0
- data/lib/verquest/conditions/branch.rb +111 -0
- data/lib/verquest/conditions/condition.rb +194 -0
- data/lib/verquest/conditions/conjunction.rb +39 -0
- data/lib/verquest/conditions/fragment_merge.rb +52 -0
- data/lib/verquest/conditions/property_merge.rb +47 -0
- data/lib/verquest/conditions/rule_set.rb +58 -0
- data/lib/verquest/conditions/rules/absent.rb +25 -0
- data/lib/verquest/conditions/rules/alternatives.rb +28 -0
- data/lib/verquest/conditions/rules/any.rb +27 -0
- data/lib/verquest/conditions/rules/equals.rb +22 -0
- data/lib/verquest/conditions/rules/names_rule.rb +23 -0
- data/lib/verquest/conditions/rules/none.rb +24 -0
- data/lib/verquest/conditions/rules/present.rb +22 -0
- data/lib/verquest/conditions/rules/property_rule.rb +48 -0
- data/lib/verquest/conditions/rules/satisfies.rb +22 -0
- data/lib/verquest/conditions/rules/value_in.rb +22 -0
- data/lib/verquest/conditions/rules.rb +17 -0
- data/lib/verquest/conditions.rb +36 -0
- data/lib/verquest/configuration.rb +19 -1
- data/lib/verquest/gem_version.rb +1 -1
- data/lib/verquest/helper_methods/conditional_properties.rb +126 -0
- data/lib/verquest/helper_methods/unnamed_one_of.rb +35 -0
- data/lib/verquest/properties/array.rb +15 -1
- data/lib/verquest/properties/base.rb +104 -10
- data/lib/verquest/properties/collection.rb +51 -18
- data/lib/verquest/properties/const.rb +2 -1
- data/lib/verquest/properties/object.rb +38 -4
- data/lib/verquest/properties/one_of/discriminated_schema.rb +84 -0
- data/lib/verquest/properties/one_of.rb +83 -44
- data/lib/verquest/properties/reference.rb +47 -16
- data/lib/verquest/query/coercer.rb +165 -0
- data/lib/verquest/query/definition_check.rb +91 -0
- data/lib/verquest/query/parameters.rb +79 -0
- data/lib/verquest/query.rb +25 -0
- data/lib/verquest/transformer/collection.rb +32 -0
- data/lib/verquest/transformer/const_as_enum.rb +47 -0
- data/lib/verquest/transformer/layout.rb +83 -0
- data/lib/verquest/transformer/mapping_layout.rb +166 -0
- data/lib/verquest/transformer/one_of.rb +111 -0
- data/lib/verquest/transformer/path.rb +188 -0
- data/lib/verquest/transformer/plan.rb +183 -0
- data/lib/verquest/transformer/scope.rb +64 -0
- data/lib/verquest/transformer.rb +43 -880
- data/lib/verquest/version.rb +152 -79
- data/lib/verquest.rb +6 -0
- metadata +42 -7
data/lib/verquest/transformer.rb
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Verquest
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
#
|
|
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
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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
|
-
# #
|
|
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
|
-
#
|
|
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
|
|
74
|
-
#
|
|
75
|
-
# @param discriminator [String, nil]
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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]
|
|
108
|
-
# @return [
|
|
109
|
-
|
|
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
|