lutaml-model 0.8.126 → 0.8.128
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/README.adoc +60 -0
- data/docs/_guides/keyvalue-serialization.adoc +75 -0
- data/docs/_pages/value_types.adoc +102 -0
- data/lib/compat/opal/lutaml_model_boot.rb +15 -0
- data/lib/lutaml/key_value/transform.rb +238 -30
- data/lib/lutaml/model/error/unknown_mapping_error.rb +18 -0
- data/lib/lutaml/model/schema/generator/property.rb +60 -9
- data/lib/lutaml/model/serialize/format_conversion.rb +115 -2
- data/lib/lutaml/model/serialize/transformation_builder.rb +4 -3
- data/lib/lutaml/model/transform.rb +29 -4
- data/lib/lutaml/model/transformation_registry.rb +7 -5
- data/lib/lutaml/model/type/date_time_with_timezone.rb +28 -0
- data/lib/lutaml/model/type/date_with_timezone.rb +28 -0
- data/lib/lutaml/model/type/day_time_duration.rb +35 -0
- data/lib/lutaml/model/type/email_address.rb +28 -0
- data/lib/lutaml/model/type/hostname.rb +30 -0
- data/lib/lutaml/model/type/ip_v4_address.rb +27 -0
- data/lib/lutaml/model/type/ip_v6_address.rb +47 -0
- data/lib/lutaml/model/type/metaschema_pattern.rb +34 -0
- data/lib/lutaml/model/type/non_negative_integer.rb +25 -0
- data/lib/lutaml/model/type/positive_integer.rb +25 -0
- data/lib/lutaml/model/type/token.rb +25 -0
- data/lib/lutaml/model/type/uri_reference.rb +29 -0
- data/lib/lutaml/model/type/uuid.rb +25 -0
- data/lib/lutaml/model/type/value.rb +9 -0
- data/lib/lutaml/model/type/year_month_duration.rb +29 -0
- data/lib/lutaml/model/type.rb +27 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/model.rb +2 -0
- data/lib/lutaml/xml/adapter/xml_serializer.rb +8 -6
- data/lib/lutaml/xml/model_transform.rb +8 -4
- data/lib/lutaml/xml/schema/xsd_schema.rb +30 -9
- data/lib/lutaml/xml/transformation.rb +3 -2
- data/spec/lutaml/key_value/child_map_plan_spec.rb +169 -0
- data/spec/lutaml/model/metaschema_types_spec.rb +367 -0
- data/spec/lutaml/model/serialization_profiles_spec.rb +276 -0
- metadata +20 -2
|
@@ -97,7 +97,9 @@ module Lutaml
|
|
|
97
97
|
# NEW ARCHITECTURE: Use KeyValue::Transformation if available
|
|
98
98
|
# This provides symmetric OOP architecture with XmlDataModel
|
|
99
99
|
if context.is_a?(Class) && context.include?(Lutaml::Model::Serialize)
|
|
100
|
-
transformation = context.transformation_for(
|
|
100
|
+
transformation = context.transformation_for(
|
|
101
|
+
format, lutaml_register, mapping: @lutaml_mapping_override
|
|
102
|
+
)
|
|
101
103
|
|
|
102
104
|
# transformation_for returns nil for cyclic dependencies or :building sentinel
|
|
103
105
|
# Fall back to legacy approach in these cases
|
|
@@ -213,18 +215,26 @@ module Lutaml
|
|
|
213
215
|
|
|
214
216
|
mapping = model_class.mappings_for(format, register)
|
|
215
217
|
return false if mapping.nil?
|
|
216
|
-
# The catch-all collect runs on the interpretive walk only —
|
|
217
|
-
# the compiled hydration must not skip unknown keys silently.
|
|
218
|
-
return false if mapping.unmapped_attribute
|
|
219
218
|
|
|
220
219
|
attrs = model_class.attributes(register)
|
|
220
|
+
# The catch-all compiles as a row: unclaimed document keys are
|
|
221
|
+
# collected into the target attribute after the rules hydrate,
|
|
222
|
+
# mirroring the interpretive collect's position and merge. A
|
|
223
|
+
# typo'd target raises here, exactly as the walk raises at
|
|
224
|
+
# first parse.
|
|
225
|
+
unmapped_target = mapping.unmapped_attribute
|
|
226
|
+
if unmapped_target && !attrs.key?(unmapped_target)
|
|
227
|
+
raise ArgumentError,
|
|
228
|
+
"map_unmapped target :#{unmapped_target} is not a " \
|
|
229
|
+
"declared attribute of #{model_class}"
|
|
230
|
+
end
|
|
221
231
|
rows = nil
|
|
232
|
+
claimed = nil
|
|
222
233
|
mapping.mappings(register).each do |rule|
|
|
223
234
|
eligible = !rule.name.nil? && !rule.multiple_mappings? &&
|
|
224
235
|
rule.delegate.nil? &&
|
|
225
236
|
!rule.has_custom_method_for_deserialization? &&
|
|
226
237
|
!rule.raw_mapping? && !rule.root_mapping? &&
|
|
227
|
-
!rule.hash_mappings && rule.child_mappings.nil? &&
|
|
228
238
|
rule.when_attribute.empty? &&
|
|
229
239
|
!(if rule.polymorphic.is_a?(::Hash)
|
|
230
240
|
!rule.polymorphic.empty?
|
|
@@ -250,6 +260,24 @@ module Lutaml
|
|
|
250
260
|
return false if Lutaml::Model::GlobalContext.context(register)
|
|
251
261
|
.substitution_for(type).any?
|
|
252
262
|
|
|
263
|
+
if rule.hash_mappings
|
|
264
|
+
# #1003: compiled child-map row. The compiled ChildMapPlan
|
|
265
|
+
# reproduces translate_mappings' per-child lookups; shapes
|
|
266
|
+
# the compile cannot cover (unmappable child attribute,
|
|
267
|
+
# singular target, non-model child type) keep the whole
|
|
268
|
+
# model interpretive.
|
|
269
|
+
return false unless attr.collection? &&
|
|
270
|
+
type.is_a?(Class) && type.include?(Lutaml::Model::Serialize)
|
|
271
|
+
|
|
272
|
+
child_plan = compile_group_child_plan(type, format, register,
|
|
273
|
+
rule.hash_mappings)
|
|
274
|
+
return false unless child_plan
|
|
275
|
+
|
|
276
|
+
rows ||= []
|
|
277
|
+
rows << [rule, attr, :child_map, child_plan, nil, nil]
|
|
278
|
+
next
|
|
279
|
+
end
|
|
280
|
+
|
|
253
281
|
if type.is_a?(Class) && type.include?(Lutaml::Model::Serialize)
|
|
254
282
|
child_register = Lutaml::Model::Register
|
|
255
283
|
.resolve_for_child(type, register)
|
|
@@ -270,10 +298,45 @@ module Lutaml
|
|
|
270
298
|
return false
|
|
271
299
|
end
|
|
272
300
|
end
|
|
301
|
+
if unmapped_target
|
|
302
|
+
claimed = {}
|
|
303
|
+
mapping.mappings(register).each do |rule|
|
|
304
|
+
Array(rule.name).each { |name| claimed[name.to_s] = true } if rule.name
|
|
305
|
+
end
|
|
306
|
+
claimed.freeze
|
|
307
|
+
(rows ||= []) << [nil, nil, :unmapped, unmapped_target, claimed,
|
|
308
|
+
nil]
|
|
309
|
+
end
|
|
273
310
|
# No eligible rows at all (empty mapping) — nothing to gain.
|
|
274
311
|
rows
|
|
275
312
|
end
|
|
276
313
|
|
|
314
|
+
# Class-level twin of the walk's child-map compile: the registry
|
|
315
|
+
# lookups (child rules, per-attribute wire names, path casts)
|
|
316
|
+
# resolve once per plan instead of once per node per child. nil
|
|
317
|
+
# keeps the model interpretive (preserving the walk's raise
|
|
318
|
+
# timing for declaration errors).
|
|
319
|
+
def self.compile_group_child_plan(child_class, format, register,
|
|
320
|
+
child_mappings)
|
|
321
|
+
child_rules = child_class.mappings_for(format, register)
|
|
322
|
+
wire_names = {}
|
|
323
|
+
paths = {}
|
|
324
|
+
child_mappings.each do |attr_name, path|
|
|
325
|
+
attr_rule = child_rules.find_by_to!(attr_name)
|
|
326
|
+
wire_names[attr_name] = attr_rule.from.to_s
|
|
327
|
+
paths[attr_name] = case path
|
|
328
|
+
when :key then :key
|
|
329
|
+
when :value then :value
|
|
330
|
+
else Array(path).map(&:to_s).freeze
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
ChildMapPlan.new(child_mappings, wire_names, paths,
|
|
334
|
+
child_mappings.values == [:key], child_rules,
|
|
335
|
+
child_class)
|
|
336
|
+
rescue Lutaml::Model::NoMappingFoundError
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
|
|
277
340
|
# Build values bottom-up, then ONE instance per model: present
|
|
278
341
|
# keys through the casting setters, ABSENT rules through the real
|
|
279
342
|
# per-rule walk (their extractor/defaults/sentinel semantics are
|
|
@@ -287,7 +350,13 @@ module Lutaml
|
|
|
287
350
|
# Lazy: the present-key hot path never pushes, and this runs
|
|
288
351
|
# once per hydrated model instance.
|
|
289
352
|
absent = nil
|
|
353
|
+
unmapped = nil
|
|
290
354
|
rows.each do |rule, attr, kind, type, child_rows, child_register|
|
|
355
|
+
if kind == :unmapped
|
|
356
|
+
unmapped = [type, child_rows]
|
|
357
|
+
next
|
|
358
|
+
end
|
|
359
|
+
|
|
291
360
|
# One hash pass per row: fetch with a sentinel distinguishes
|
|
292
361
|
# absent keys from present-nil values (both read as nil), so
|
|
293
362
|
# the separate string_or_symbol_key? probe goes away.
|
|
@@ -297,6 +366,33 @@ module Lutaml
|
|
|
297
366
|
next
|
|
298
367
|
end
|
|
299
368
|
|
|
369
|
+
if kind == :child_map
|
|
370
|
+
# Compiled translate_mappings (#1003): per-child lookups
|
|
371
|
+
# are plan constants. Children hydrate through their own
|
|
372
|
+
# compiled walk (the mappings: option routes them there);
|
|
373
|
+
# parent/root wiring rides the shared children pass. A
|
|
374
|
+
# non-Hash value falls back to the walk, which owns that
|
|
375
|
+
# error.
|
|
376
|
+
return nil unless v.is_a?(::Hash)
|
|
377
|
+
|
|
378
|
+
built = []
|
|
379
|
+
keys_only = type.keys_only && v.values.all?(::Hash)
|
|
380
|
+
v.each do |child_key, child_value|
|
|
381
|
+
child_hash = {}
|
|
382
|
+
type.child_mappings.each_key do |attr_name|
|
|
383
|
+
child_hash[type.wire_names[attr_name]] =
|
|
384
|
+
extract_compiled_child_value(type.paths[attr_name],
|
|
385
|
+
child_key, child_value)
|
|
386
|
+
end
|
|
387
|
+
child_hash.update(child_value) if keys_only
|
|
388
|
+
built << self.class.data_to_model(type.attr_type,
|
|
389
|
+
child_hash, format, { mappings: type.child_rules })
|
|
390
|
+
end
|
|
391
|
+
setters << [rule.kv_setter, built]
|
|
392
|
+
children.concat(built)
|
|
393
|
+
next
|
|
394
|
+
end
|
|
395
|
+
|
|
300
396
|
if kind == :scalar
|
|
301
397
|
setters << [rule.kv_setter, v]
|
|
302
398
|
next
|
|
@@ -343,6 +439,20 @@ module Lutaml
|
|
|
343
439
|
child.lutaml_parent = instance
|
|
344
440
|
child.lutaml_root ||= instance.lutaml_root || instance
|
|
345
441
|
end
|
|
442
|
+
if unmapped
|
|
443
|
+
target, claimed = unmapped
|
|
444
|
+
extras = nil
|
|
445
|
+
doc.each do |key, value|
|
|
446
|
+
next if claimed.key?(key.to_s)
|
|
447
|
+
|
|
448
|
+
(extras ||= {})[key] = value
|
|
449
|
+
end
|
|
450
|
+
if extras
|
|
451
|
+
existing = instance.public_send(target)
|
|
452
|
+
merged = existing.is_a?(::Hash) ? existing.merge(extras) : extras
|
|
453
|
+
instance.public_send(:"#{target}=", merged)
|
|
454
|
+
end
|
|
455
|
+
end
|
|
346
456
|
instance
|
|
347
457
|
end
|
|
348
458
|
|
|
@@ -475,12 +585,14 @@ options)
|
|
|
475
585
|
generate_remaining_mappings_for_value(child_mappings, value, format)
|
|
476
586
|
|
|
477
587
|
attr_type = attr.type(lutaml_register)
|
|
478
|
-
|
|
479
|
-
|
|
588
|
+
rules = attr_type.mappings_for(format)
|
|
589
|
+
pairs = child_mappings.map do |attr_name, path|
|
|
590
|
+
[rules.find_by_to!(attr_name), path]
|
|
591
|
+
end
|
|
480
592
|
|
|
593
|
+
value.each do |child_obj|
|
|
481
594
|
hash.merge!(
|
|
482
|
-
extract_hash_for_child_mapping(
|
|
483
|
-
format),
|
|
595
|
+
extract_hash_for_child_mapping(pairs, child_obj, format),
|
|
484
596
|
)
|
|
485
597
|
end
|
|
486
598
|
|
|
@@ -508,14 +620,12 @@ options)
|
|
|
508
620
|
mappings.find_by_to(name)&.name.to_s
|
|
509
621
|
end
|
|
510
622
|
|
|
511
|
-
def extract_hash_for_child_mapping(
|
|
512
|
-
format)
|
|
623
|
+
def extract_hash_for_child_mapping(pairs, child_obj, format)
|
|
513
624
|
key = nil
|
|
514
625
|
value = {}
|
|
515
626
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
attr_value = normalize_attribute_value(child_obj, rule.from, format)
|
|
627
|
+
pairs.each do |rule, path|
|
|
628
|
+
attr_value = normalize_attribute_value(child_obj, rule, format)
|
|
519
629
|
|
|
520
630
|
next unless rule&.render?(attr_value, nil)
|
|
521
631
|
next key = attr_value if path == :key
|
|
@@ -527,10 +637,16 @@ format)
|
|
|
527
637
|
{ key => value }
|
|
528
638
|
end
|
|
529
639
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
640
|
+
# The child's wire-form value for one attribute, produced through
|
|
641
|
+
# the same pipeline the child's own serializer uses for a rendered
|
|
642
|
+
# value (rule transform, then attribute serialization) — without
|
|
643
|
+
# the serialize-parse string round-trip this read previously paid
|
|
644
|
+
# per child per attribute.
|
|
645
|
+
def normalize_attribute_value(child_obj, rule, format)
|
|
646
|
+
child_attr = child_obj.class.attributes(lutaml_register)[rule.to]
|
|
647
|
+
value = child_obj.public_send(rule.to)
|
|
648
|
+
value = rule.transform_value(child_attr, value, :to, format)
|
|
649
|
+
child_attr.serialize(value, format, lutaml_register)
|
|
534
650
|
end
|
|
535
651
|
|
|
536
652
|
def extract_hash_value_for_child_mapping(path, value, map_value)
|
|
@@ -764,8 +880,14 @@ partition = nil)
|
|
|
764
880
|
# crashed translate_mappings).
|
|
765
881
|
return if value.nil? && kind == :hash_map
|
|
766
882
|
|
|
767
|
-
|
|
768
|
-
|
|
883
|
+
child_plan = plan.child_map
|
|
884
|
+
value = if child_plan
|
|
885
|
+
translate_child_map_compiled(value, child_plan, attr,
|
|
886
|
+
format, instance)
|
|
887
|
+
else
|
|
888
|
+
translate_mappings(value, rule.hash_mappings, attr, format,
|
|
889
|
+
instance)
|
|
890
|
+
end
|
|
769
891
|
unless kind == :hash_map
|
|
770
892
|
value = cast_value(value, attr, format, rule,
|
|
771
893
|
instance)
|
|
@@ -798,12 +920,86 @@ partition = nil)
|
|
|
798
920
|
# (custom methods, transforms, value maps, delegates, hash mappings,
|
|
799
921
|
# unions, polymorphism) return nil and keep the interpretive path.
|
|
800
922
|
WalkPlan = ::Struct.new(:kind, :attr, :valid, :wire, :klass,
|
|
801
|
-
:collection)
|
|
923
|
+
:collection, :child_map)
|
|
924
|
+
|
|
925
|
+
# Compiled child-map translation (#986b): the per-node registry
|
|
926
|
+
# lookups (attribute type, child mapping rules, per-attribute wire
|
|
927
|
+
# names, path string casts) collapse to per-rule constants. nil
|
|
928
|
+
# child_map keeps the interpretive translate path (including its
|
|
929
|
+
# declaration-error timing).
|
|
930
|
+
ChildMapPlan = ::Struct.new(:child_mappings, :wire_names, :paths,
|
|
931
|
+
:keys_only, :child_rules, :attr_type)
|
|
802
932
|
|
|
803
933
|
# Memoized (per transform) wire name for plain rules — the from
|
|
804
934
|
# spelling never changes after definition.
|
|
805
935
|
KvSerializePlan = ::Struct.new(:wire)
|
|
806
936
|
|
|
937
|
+
def build_child_map_plan(format, rule, attr)
|
|
938
|
+
child_mappings = rule.hash_mappings
|
|
939
|
+
return nil unless child_mappings.is_a?(::Hash)
|
|
940
|
+
|
|
941
|
+
attr_type = attr.type(lutaml_register)
|
|
942
|
+
return nil unless attr_type.is_a?(Class) &&
|
|
943
|
+
attr_type.include?(Lutaml::Model::Serialize)
|
|
944
|
+
|
|
945
|
+
child_rules = attr_type.mappings_for(format, lutaml_register)
|
|
946
|
+
wire_names = {}
|
|
947
|
+
paths = {}
|
|
948
|
+
child_mappings.each do |attr_name, path|
|
|
949
|
+
attr_rule = child_rules.find_by_to!(attr_name)
|
|
950
|
+
wire_names[attr_name] = attr_rule.from.to_s
|
|
951
|
+
paths[attr_name] = compile_child_map_path(path)
|
|
952
|
+
end
|
|
953
|
+
ChildMapPlan.new(child_mappings, wire_names, paths,
|
|
954
|
+
child_mappings.values == [:key], child_rules,
|
|
955
|
+
attr_type)
|
|
956
|
+
rescue Lutaml::Model::NoMappingFoundError
|
|
957
|
+
nil
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
def compile_child_map_path(path)
|
|
961
|
+
case path
|
|
962
|
+
when :key then :key
|
|
963
|
+
when :value then :value
|
|
964
|
+
else Array(path).map(&:to_s).freeze
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# Compiled translate_mappings (#986b). Same shape as the
|
|
969
|
+
# interpretive pair: keys_only is decided once per rule VALUE (the
|
|
970
|
+
# original tests the whole document hash, not each child).
|
|
971
|
+
def translate_child_map_compiled(value, child_plan, attr, format,
|
|
972
|
+
instance)
|
|
973
|
+
child_options = {
|
|
974
|
+
mappings: child_plan.child_rules,
|
|
975
|
+
lutaml_parent: instance,
|
|
976
|
+
lutaml_root: instance.lutaml_root || instance,
|
|
977
|
+
}
|
|
978
|
+
keys_only = child_plan.keys_only && value.values.all?(::Hash)
|
|
979
|
+
wire_names = child_plan.wire_names
|
|
980
|
+
paths = child_plan.paths
|
|
981
|
+
child_mappings = child_plan.child_mappings
|
|
982
|
+
|
|
983
|
+
value.map do |key, child_value|
|
|
984
|
+
child_hash = {}
|
|
985
|
+
child_mappings.each_key do |attr_name|
|
|
986
|
+
child_hash[wire_names[attr_name]] =
|
|
987
|
+
extract_compiled_child_value(paths[attr_name], key, child_value)
|
|
988
|
+
end
|
|
989
|
+
child_hash.merge!(child_value) if keys_only
|
|
990
|
+
map_child_data(child_hash, attr, format, instance, child_plan,
|
|
991
|
+
child_options)
|
|
992
|
+
end
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def extract_compiled_child_value(compiled_path, key, value)
|
|
996
|
+
case compiled_path
|
|
997
|
+
when :key then key
|
|
998
|
+
when :value then value
|
|
999
|
+
else value.dig(*compiled_path)
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
|
|
807
1003
|
def kv_serialize_plan(rule, attr, _instance)
|
|
808
1004
|
return nil if rule.delegate || rule.raw_mapping? || rule.root_mapping? ||
|
|
809
1005
|
rule.hash_mappings || rule.child_mappings ||
|
|
@@ -824,7 +1020,8 @@ partition = nil)
|
|
|
824
1020
|
generation = Lutaml::Model::GlobalContext.context_generation
|
|
825
1021
|
if @lutaml_walk_plans.nil? || @lutaml_walk_generation != generation ||
|
|
826
1022
|
@lutaml_walk_format != format
|
|
827
|
-
key = [model_class, lutaml_register, format, generation
|
|
1023
|
+
key = [model_class, lutaml_register, format, generation,
|
|
1024
|
+
@lutaml_mapping_override]
|
|
828
1025
|
@lutaml_walk_plans =
|
|
829
1026
|
(KV_WALK_PLANS[key] ||= build_walk_plans(format))
|
|
830
1027
|
@lutaml_walk_generation = generation
|
|
@@ -861,6 +1058,11 @@ partition = nil)
|
|
|
861
1058
|
plan ? :scalar : :interpretive
|
|
862
1059
|
end
|
|
863
1060
|
|
|
1061
|
+
if kind == :hash_map && attr
|
|
1062
|
+
child = build_child_map_plan(format, rule, attr)
|
|
1063
|
+
return WalkPlan.new(:hash_map, attr, valid, nil, nil, false, child)
|
|
1064
|
+
end
|
|
1065
|
+
|
|
864
1066
|
plan || WalkPlan.new(kind, attr, valid, nil, nil, false)
|
|
865
1067
|
end
|
|
866
1068
|
|
|
@@ -979,14 +1181,20 @@ instance)
|
|
|
979
1181
|
child_mappings.values == [:key] && hash.values.all?(::Hash)
|
|
980
1182
|
end
|
|
981
1183
|
|
|
982
|
-
def map_child_data(child_hash, attr, format, instance
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1184
|
+
def map_child_data(child_hash, attr, format, instance,
|
|
1185
|
+
child_plan = nil, child_options = nil)
|
|
1186
|
+
if child_plan
|
|
1187
|
+
self.class.data_to_model(child_plan.attr_type, child_hash, format,
|
|
1188
|
+
child_options)
|
|
1189
|
+
else
|
|
1190
|
+
attr_type = attr.type(lutaml_register)
|
|
1191
|
+
options = {
|
|
1192
|
+
mappings: attr_type.mappings_for(format, lutaml_register),
|
|
1193
|
+
lutaml_parent: instance,
|
|
1194
|
+
lutaml_root: instance.lutaml_root || instance,
|
|
1195
|
+
}
|
|
1196
|
+
self.class.data_to_model(attr_type, child_hash, format, options)
|
|
1197
|
+
end
|
|
990
1198
|
end
|
|
991
1199
|
|
|
992
1200
|
def rule_value_extractor_class
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Model
|
|
5
|
+
# Raised when a `mapping:` selection names a serialization mapping
|
|
6
|
+
# profile the model does not declare (#176).
|
|
7
|
+
class UnknownMappingError < Error
|
|
8
|
+
def initialize(name, format)
|
|
9
|
+
dsl = format == :hash ? :hsh : format
|
|
10
|
+
super(
|
|
11
|
+
"unknown #{format} mapping profile #{name.inspect} — declare it " \
|
|
12
|
+
"with `#{dsl} :#{name} do ... end` or wire it with " \
|
|
13
|
+
"mapping :#{name}, mapping",
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -96,8 +96,35 @@ module Lutaml
|
|
|
96
96
|
def get_constraints(attr)
|
|
97
97
|
constraints = {}
|
|
98
98
|
|
|
99
|
-
# Add pattern validation
|
|
100
|
-
|
|
99
|
+
# Add pattern validation (attribute option conjunctive with the
|
|
100
|
+
# resolved type's restriction facets; a single JSON Schema
|
|
101
|
+
# pattern cannot express conjunction, mirroring the XSD emitter)
|
|
102
|
+
patterns = constraint_patterns(attr)
|
|
103
|
+
if patterns.size > 1
|
|
104
|
+
raise Lutaml::Model::Error,
|
|
105
|
+
"Cannot export #{patterns.size} conjunctive patterns on " \
|
|
106
|
+
"#{attr.name} to a single JSON Schema pattern: sibling " \
|
|
107
|
+
"patterns are alternatives (OR), and JSON Schema cannot " \
|
|
108
|
+
"express conjunction (AND) without allOf, which is not " \
|
|
109
|
+
"yet supported."
|
|
110
|
+
end
|
|
111
|
+
# Attribute-option patterns export verbatim (author contract);
|
|
112
|
+
# type-facet patterns carry Ruby \A/\z anchoring, which ECMA
|
|
113
|
+
# regexes lack — express the full-match intent with ^...$.
|
|
114
|
+
if (pattern, from_facet = patterns.first)
|
|
115
|
+
constraints["pattern"] =
|
|
116
|
+
from_facet ? json_schema_pattern(pattern) : pattern.source
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Add the resolved type's restriction facets and format
|
|
120
|
+
type = attr.type(register)
|
|
121
|
+
if type.is_a?(Class) && type <= Lutaml::Model::Type::Value
|
|
122
|
+
facets = type.facets
|
|
123
|
+
constraints["minimum"] = facets[:min_inclusive] if facets[:min_inclusive]
|
|
124
|
+
constraints["maximum"] = facets[:max_inclusive] if facets[:max_inclusive]
|
|
125
|
+
constraints["exclusiveMinimum"] = facets[:min_exclusive] if facets[:min_exclusive]
|
|
126
|
+
constraints["exclusiveMaximum"] = facets[:max_exclusive] if facets[:max_exclusive]
|
|
127
|
+
end
|
|
101
128
|
|
|
102
129
|
# Add default value
|
|
103
130
|
resolved = attr.default_value(register, nil)
|
|
@@ -108,21 +135,45 @@ module Lutaml
|
|
|
108
135
|
# Add enumeration values
|
|
109
136
|
constraints["enum"] = attr.enum_values if attr.enum?
|
|
110
137
|
|
|
138
|
+
# Add the format keyword for types that declare one (e.g. the
|
|
139
|
+
# NIST Metaschema types: ipv4, ipv6, email, uri-reference)
|
|
140
|
+
format = type.json_schema_format if type.is_a?(Class) &&
|
|
141
|
+
type <= Lutaml::Model::Type::Value
|
|
142
|
+
constraints["format"] = format if format
|
|
143
|
+
|
|
111
144
|
constraints
|
|
112
145
|
end
|
|
113
146
|
|
|
147
|
+
def constraint_patterns(attr)
|
|
148
|
+
patterns = []
|
|
149
|
+
patterns << [attr.pattern, false] if attr.pattern
|
|
150
|
+
type = attr.type(register)
|
|
151
|
+
if type.is_a?(Class) && type <= Lutaml::Model::Type::Value
|
|
152
|
+
type.facets[:pattern].to_a.each { |re| patterns << [re, true] }
|
|
153
|
+
end
|
|
154
|
+
patterns
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# JSON Schema (ECMA) regexes have no \A/\z; full-match intent is
|
|
158
|
+
# expressed with ^...$ anchoring (the NIST JSON forms do the same).
|
|
159
|
+
def json_schema_pattern(regexp)
|
|
160
|
+
source = regexp.source
|
|
161
|
+
source = source.delete_prefix('\\A').delete_suffix('\\z')
|
|
162
|
+
"^#{source}$"
|
|
163
|
+
end
|
|
164
|
+
|
|
114
165
|
def polymorphic?(attr)
|
|
115
166
|
Utils.present?(attr.options[:polymorphic])
|
|
116
167
|
end
|
|
117
168
|
|
|
118
169
|
def get_type(type)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
170
|
+
return "integer" if type.is_a?(Class) && type <= Lutaml::Model::Type::Integer
|
|
171
|
+
return "boolean" if type.is_a?(Class) && type <= Lutaml::Model::Type::Boolean
|
|
172
|
+
return "number" if type.is_a?(Class) && type <= Lutaml::Model::Type::Float
|
|
173
|
+
return "number" if type.is_a?(Class) && type <= Lutaml::Model::Type::Decimal
|
|
174
|
+
return "object" if type.is_a?(Class) && type <= Lutaml::Model::Type::Hash
|
|
175
|
+
|
|
176
|
+
"string" # Strings and all other scalar types
|
|
126
177
|
end
|
|
127
178
|
end
|
|
128
179
|
end
|
|
@@ -11,9 +11,16 @@ module Lutaml
|
|
|
11
11
|
# Process mapping DSL for a format
|
|
12
12
|
#
|
|
13
13
|
# @param format [Symbol] The format (:xml, :json, etc.)
|
|
14
|
-
# @param args [Array]
|
|
14
|
+
# @param args [Array] Optional profile name (Symbol/String) naming a
|
|
15
|
+
# serialization mapping (#176); other arguments keep their
|
|
16
|
+
# historical no-op behavior
|
|
15
17
|
# @param block [Proc] The DSL block to evaluate
|
|
16
|
-
def process_mapping(format, *
|
|
18
|
+
def process_mapping(format, *args, &block)
|
|
19
|
+
name = args.first
|
|
20
|
+
if name.is_a?(::Symbol) || name.is_a?(::String)
|
|
21
|
+
return declare_serialization_mapping(format, name.to_sym, &block)
|
|
22
|
+
end
|
|
23
|
+
|
|
17
24
|
klass = ::Lutaml::Model::Config.mappings_class_for(format)
|
|
18
25
|
existing = mappings[format]
|
|
19
26
|
mappings[format] = if existing.nil? || !existing.is_a?(klass)
|
|
@@ -30,6 +37,68 @@ module Lutaml
|
|
|
30
37
|
post_process_mapping(format)
|
|
31
38
|
end
|
|
32
39
|
|
|
40
|
+
# Declare a named serialization mapping (#176): a complete mapping
|
|
41
|
+
# for one format, selectable per call via `mapping:`. Re-declaring
|
|
42
|
+
# the same name replaces.
|
|
43
|
+
def declare_serialization_mapping(format, name, &block)
|
|
44
|
+
klass = ::Lutaml::Model::Config.mappings_class_for(format)
|
|
45
|
+
mapping = klass.new
|
|
46
|
+
mapping.instance_eval(&block)
|
|
47
|
+
mapping.finalize(self) if mapping.is_a?(Lutaml::Xml::Mapping)
|
|
48
|
+
post_process_mapping(format)
|
|
49
|
+
lutaml_named_mappings[format][name] = mapping
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Wire a runtime-constructed serialization mapping object under a
|
|
53
|
+
# name (#176). The format is inferred from the mapping's class;
|
|
54
|
+
# pass `format:` when the class does not identify one. `nil`
|
|
55
|
+
# mapping removes the name (requires `format:`).
|
|
56
|
+
def mapping(name, mapping_object, format: nil)
|
|
57
|
+
if mapping_object.nil?
|
|
58
|
+
raise ::ArgumentError, "removal requires format:" unless format
|
|
59
|
+
|
|
60
|
+
lutaml_named_mappings[format].delete(name)
|
|
61
|
+
return
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
format ||= format_of_mapping_object(mapping_object)
|
|
65
|
+
klass = ::Lutaml::Model::Config.mappings_class_for(format)
|
|
66
|
+
unless mapping_object.is_a?(klass)
|
|
67
|
+
raise ::ArgumentError,
|
|
68
|
+
"mapping is a #{mapping_object.class} but the #{format} " \
|
|
69
|
+
"mapping class is #{klass}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
lutaml_named_mappings[format][name] = mapping_object
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Named serialization mappings declared for a format (#176).
|
|
76
|
+
#
|
|
77
|
+
# @param format [Symbol] The format
|
|
78
|
+
# @return [Hash] name -> mapping (a shallow copy)
|
|
79
|
+
def serialization_mappings(format)
|
|
80
|
+
named = lutaml_named_mappings[format]
|
|
81
|
+
named ? named.dup : {}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def lutaml_named_mappings
|
|
85
|
+
@lutaml_named_mappings ||= ::Hash.new { |h, k| h[k] = {} }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Resolve the `mapping:` selection option (#176) into the
|
|
89
|
+
# transform seam option the transforms already read. A user-level
|
|
90
|
+
# `mappings:` takes precedence; a nil `mapping:` selects the base
|
|
91
|
+
# mapping. Mutates and returns options.
|
|
92
|
+
def resolve_serialization_mapping_option(format, options)
|
|
93
|
+
return options unless options.is_a?(::Hash) && options.key?(:mapping)
|
|
94
|
+
|
|
95
|
+
selected = options.delete(:mapping)
|
|
96
|
+
return options if selected.nil? || options.key?(:mappings)
|
|
97
|
+
|
|
98
|
+
options[:mappings] = serialization_mapping_for(format, selected)
|
|
99
|
+
options
|
|
100
|
+
end
|
|
101
|
+
|
|
33
102
|
# Hook for format-specific post-processing after mapping DSL evaluation.
|
|
34
103
|
# XML overrides this to call check_sort_configs!.
|
|
35
104
|
#
|
|
@@ -45,6 +114,8 @@ module Lutaml
|
|
|
45
114
|
# @param options [Hash] Additional options
|
|
46
115
|
# @return [Object] The deserialized model instance
|
|
47
116
|
def from(format, data, options = {})
|
|
117
|
+
resolve_serialization_mapping_option(format, options)
|
|
118
|
+
|
|
48
119
|
if format == :xml && Lutaml::Model::Config.instance.xml_plan_fast_path
|
|
49
120
|
fast = xml_plan_fast_path(data, options)
|
|
50
121
|
return fast if fast
|
|
@@ -223,6 +294,8 @@ module Lutaml
|
|
|
223
294
|
# @param options [Hash] Additional options
|
|
224
295
|
# @return [Object] The model instance
|
|
225
296
|
def of(format, doc, options = {})
|
|
297
|
+
resolve_serialization_mapping_option(format, options)
|
|
298
|
+
|
|
226
299
|
if doc.is_a?(Array) && !array_passthrough_format?(format)
|
|
227
300
|
return doc.map { |item| send(:"of_#{format}", item) }
|
|
228
301
|
end
|
|
@@ -280,6 +353,8 @@ module Lutaml
|
|
|
280
353
|
# is always preserved when available, regardless of this option.
|
|
281
354
|
# @return [String] The serialized output
|
|
282
355
|
def to(format, instance, options = {})
|
|
356
|
+
resolve_serialization_mapping_option(format, options)
|
|
357
|
+
|
|
283
358
|
# The rescue sits on the parse block, not the method, so cache
|
|
284
359
|
# store failures propagate instead of becoming InvalidFormatError.
|
|
285
360
|
with_conversion_cache(:to, format, instance, options) do
|
|
@@ -355,6 +430,8 @@ module Lutaml
|
|
|
355
430
|
# @param options [Hash] Additional options
|
|
356
431
|
# @return [Object] The format-specific data structure
|
|
357
432
|
def as(format, instance, options = {})
|
|
433
|
+
resolve_serialization_mapping_option(format, options)
|
|
434
|
+
|
|
358
435
|
if instance.is_a?(Array)
|
|
359
436
|
return instance.map { |item| public_send(:"as_#{format}", item) }
|
|
360
437
|
end
|
|
@@ -458,8 +535,44 @@ module Lutaml
|
|
|
458
535
|
end
|
|
459
536
|
end
|
|
460
537
|
|
|
538
|
+
# Resolve a mapping selection (#176): a registered profile name
|
|
539
|
+
# or a mapping instance of the format, validated. Schema
|
|
540
|
+
# generation and other mapping consumers share this resolution.
|
|
541
|
+
#
|
|
542
|
+
# @param format [Symbol] The format
|
|
543
|
+
# @param selected [Symbol, String, Mapping] The selection
|
|
544
|
+
# @return [Mapping] The resolved mapping
|
|
545
|
+
def serialization_mapping_for(format, selected)
|
|
546
|
+
if selected.is_a?(::Symbol) || selected.is_a?(::String)
|
|
547
|
+
name = selected.to_sym
|
|
548
|
+
named = lutaml_named_mappings[format]
|
|
549
|
+
return named[name] if named.key?(name)
|
|
550
|
+
|
|
551
|
+
raise Lutaml::Model::UnknownMappingError.new(name, format)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
klass = ::Lutaml::Model::Config.mappings_class_for(format)
|
|
555
|
+
return selected if selected.is_a?(klass)
|
|
556
|
+
|
|
557
|
+
raise ::ArgumentError,
|
|
558
|
+
"mapping must be a #{klass} or a registered profile " \
|
|
559
|
+
"name, got #{selected.class}"
|
|
560
|
+
end
|
|
561
|
+
|
|
461
562
|
private
|
|
462
563
|
|
|
564
|
+
def format_of_mapping_object(mapping_object)
|
|
565
|
+
format = ::Lutaml::Model::FormatRegistry.formats.find do |f|
|
|
566
|
+
::Lutaml::Model::FormatRegistry.mappings_class_for(f) ==
|
|
567
|
+
mapping_object.class
|
|
568
|
+
end
|
|
569
|
+
return format if format
|
|
570
|
+
|
|
571
|
+
raise ::ArgumentError,
|
|
572
|
+
"cannot infer the format of a #{mapping_object.class}; " \
|
|
573
|
+
"pass format:"
|
|
574
|
+
end
|
|
575
|
+
|
|
463
576
|
# Resolve adapter class for a format operation.
|
|
464
577
|
#
|
|
465
578
|
# @param format [Symbol] the format name
|
|
@@ -29,11 +29,12 @@ module Lutaml
|
|
|
29
29
|
# @param format [Symbol] The format (:xml, :json, :yaml, etc.)
|
|
30
30
|
# @param register [Symbol, Register, nil] The register for type resolution
|
|
31
31
|
# @return [Transformation, nil] The pre-compiled transformation, or nil if cycle detected
|
|
32
|
-
def transformation_for(format, register = nil)
|
|
32
|
+
def transformation_for(format, register = nil, mapping: nil)
|
|
33
33
|
resolved_register = Lutaml::Model::Register.resolve_for_child(self,
|
|
34
34
|
register)
|
|
35
|
-
TransformationRegistry.instance.get_or_build_transformation(
|
|
36
|
-
|
|
35
|
+
TransformationRegistry.instance.get_or_build_transformation(
|
|
36
|
+
self, format, resolved_register, mapping: mapping
|
|
37
|
+
)
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# Build a new transformation instance for the format
|