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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +60 -0
  3. data/docs/_guides/keyvalue-serialization.adoc +75 -0
  4. data/docs/_pages/value_types.adoc +102 -0
  5. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  6. data/lib/lutaml/key_value/transform.rb +238 -30
  7. data/lib/lutaml/model/error/unknown_mapping_error.rb +18 -0
  8. data/lib/lutaml/model/schema/generator/property.rb +60 -9
  9. data/lib/lutaml/model/serialize/format_conversion.rb +115 -2
  10. data/lib/lutaml/model/serialize/transformation_builder.rb +4 -3
  11. data/lib/lutaml/model/transform.rb +29 -4
  12. data/lib/lutaml/model/transformation_registry.rb +7 -5
  13. data/lib/lutaml/model/type/date_time_with_timezone.rb +28 -0
  14. data/lib/lutaml/model/type/date_with_timezone.rb +28 -0
  15. data/lib/lutaml/model/type/day_time_duration.rb +35 -0
  16. data/lib/lutaml/model/type/email_address.rb +28 -0
  17. data/lib/lutaml/model/type/hostname.rb +30 -0
  18. data/lib/lutaml/model/type/ip_v4_address.rb +27 -0
  19. data/lib/lutaml/model/type/ip_v6_address.rb +47 -0
  20. data/lib/lutaml/model/type/metaschema_pattern.rb +34 -0
  21. data/lib/lutaml/model/type/non_negative_integer.rb +25 -0
  22. data/lib/lutaml/model/type/positive_integer.rb +25 -0
  23. data/lib/lutaml/model/type/token.rb +25 -0
  24. data/lib/lutaml/model/type/uri_reference.rb +29 -0
  25. data/lib/lutaml/model/type/uuid.rb +25 -0
  26. data/lib/lutaml/model/type/value.rb +9 -0
  27. data/lib/lutaml/model/type/year_month_duration.rb +29 -0
  28. data/lib/lutaml/model/type.rb +27 -0
  29. data/lib/lutaml/model/version.rb +1 -1
  30. data/lib/lutaml/model.rb +2 -0
  31. data/lib/lutaml/xml/adapter/xml_serializer.rb +8 -6
  32. data/lib/lutaml/xml/model_transform.rb +8 -4
  33. data/lib/lutaml/xml/schema/xsd_schema.rb +30 -9
  34. data/lib/lutaml/xml/transformation.rb +3 -2
  35. data/spec/lutaml/key_value/child_map_plan_spec.rb +169 -0
  36. data/spec/lutaml/model/metaschema_types_spec.rb +367 -0
  37. data/spec/lutaml/model/serialization_profiles_spec.rb +276 -0
  38. 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(format, lutaml_register)
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
- value.each do |child_obj|
479
- rules = attr_type.mappings_for(format)
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(child_mappings, child_obj, rules,
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(child_mappings, child_obj, rules,
512
- format)
623
+ def extract_hash_for_child_mapping(pairs, child_obj, format)
513
624
  key = nil
514
625
  value = {}
515
626
 
516
- child_mappings.each do |attr_name, path|
517
- rule = rules.find_by_to!(attr_name)
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
- def normalize_attribute_value(value, attr_name, format)
531
- Lutaml::Model::Config.adapter_for(format).parse(
532
- value.public_send(:"to_#{format}"),
533
- )[attr_name.to_s]
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
- value = translate_mappings(value, rule.hash_mappings, attr, format,
768
- instance)
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
- attr_type = attr.type(lutaml_register)
984
- options = {
985
- mappings: attr_type.mappings_for(format, lutaml_register),
986
- lutaml_parent: instance,
987
- lutaml_root: instance.lutaml_root || instance,
988
- }
989
- self.class.data_to_model(attr_type, child_hash, format, options)
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
- constraints["pattern"] = attr.pattern.source if attr.pattern
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
- Lutaml::Model::Type::String => "string",
121
- Lutaml::Model::Type::Integer => "integer",
122
- Lutaml::Model::Type::Boolean => "boolean",
123
- Lutaml::Model::Type::Float => "number",
124
- Lutaml::Model::Type::Hash => "object",
125
- }[type] || "string" # Default to string for unknown types
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] Additional arguments (e.g., mapping class for XML)
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, *_args, &block)
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(self,
36
- format, resolved_register)
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