lutaml-model 0.8.125 → 0.8.127

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +52 -0
  3. data/docs/_guides/keyvalue-serialization.adoc +75 -0
  4. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  5. data/lib/lutaml/key_value/transform.rb +126 -26
  6. data/lib/lutaml/model/error/unknown_mapping_error.rb +18 -0
  7. data/lib/lutaml/model/serialize/format_conversion.rb +115 -2
  8. data/lib/lutaml/model/serialize/transformation_builder.rb +4 -3
  9. data/lib/lutaml/model/transform.rb +29 -4
  10. data/lib/lutaml/model/transformation_registry.rb +7 -5
  11. data/lib/lutaml/model/type/date_time_with_timezone.rb +24 -0
  12. data/lib/lutaml/model/type/date_with_timezone.rb +24 -0
  13. data/lib/lutaml/model/type/day_time_duration.rb +31 -0
  14. data/lib/lutaml/model/type/email_address.rb +22 -0
  15. data/lib/lutaml/model/type/hostname.rb +24 -0
  16. data/lib/lutaml/model/type/ip_v4_address.rb +23 -0
  17. data/lib/lutaml/model/type/ip_v6_address.rb +42 -0
  18. data/lib/lutaml/model/type/metaschema_pattern.rb +36 -0
  19. data/lib/lutaml/model/type/non_negative_integer.rb +24 -0
  20. data/lib/lutaml/model/type/positive_integer.rb +24 -0
  21. data/lib/lutaml/model/type/token.rb +23 -0
  22. data/lib/lutaml/model/type/uri_reference.rb +23 -0
  23. data/lib/lutaml/model/type/uuid.rb +23 -0
  24. data/lib/lutaml/model/type/year_month_duration.rb +25 -0
  25. data/lib/lutaml/model/type.rb +27 -0
  26. data/lib/lutaml/model/version.rb +1 -1
  27. data/lib/lutaml/model.rb +2 -0
  28. data/lib/lutaml/xml/adapter/xml_serializer.rb +8 -6
  29. data/lib/lutaml/xml/model_transform.rb +8 -4
  30. data/lib/lutaml/xml/schema/xsd_schema.rb +30 -9
  31. data/lib/lutaml/xml/transformation.rb +3 -2
  32. data/spec/lutaml/key_value/child_map_plan_spec.rb +169 -0
  33. data/spec/lutaml/model/metaschema_types_spec.rb +224 -0
  34. data/spec/lutaml/model/serialization_profiles_spec.rb +276 -0
  35. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd52cd1acd33af2b1f8abe39a9d57c4412618d63ee65b516204ef4fa9f7ff047
4
- data.tar.gz: 903e6d8ed42b95b0c5f79616f362d856b79ef54c90b3bbc1ba854c3345e79bd2
3
+ metadata.gz: e11de7c2a53b5c720a4d8066af0a1b0bfef2107e6c853bfb441d478b32c0d794
4
+ data.tar.gz: 6f8e67b8df0ab6ee2635b3b2c83a7dc86332e7883d951df45f8422f303b1ca61
5
5
  SHA512:
6
- metadata.gz: 125f630b5d0816ad0dbbf98e1650f9a221a19c83793fe05c38e384fa884ec251774e97b0ce9636be1a014f84f678fc2c4d42d9cbea108fc615051fbd1bf4112c
7
- data.tar.gz: 9eec80327bca08b7cc3bb649c77c1b956356baa190d434ae86c5eebacaacfda95c5e2ed2392d6c1d2db7a98fa4a7e32d6d416eb74185d5f0ba8ba416cef66d2a
6
+ metadata.gz: 447cbac13172eab2c4fcfa38f03f6777a164dfd31108e9aa3ce46ca8eed631863c9bd584f88a83dae005d80836c6be5ee4ff046676a77492a8c52f509b2cb4e8
7
+ data.tar.gz: 7b13afe6b4d08b89b4e79143599ac90da0fb6536583f8e315e829f66a6579e6fd22ed8aa87acc46359e06741eada456f7e5ee97db897df5e172fbcb4c43705db
data/README.adoc CHANGED
@@ -830,6 +830,19 @@ Every type has a corresponding Ruby class and a serialization format type.
830
830
  | `:qname` | `Lutaml::Model::Type::QName` | `xs:QName` | `string` | `string` | `"prefix:localName"`
831
831
  | `:base64_binary` | `String` | `xs:base64Binary` | `string` | `string` | `"SGVsbG8gV29ybGQ="`
832
832
  | `:hex_binary` | `String` | `xs:hexBinary` | `string` | `string` | `"48656c6c6f"`
833
+ | `:email_address` | `String` | `xs:string` | `string` | `string` | `"user@example.com"`
834
+ | `:hostname` | `String` | `xs:string` | `string` | `string` | `"example.com"`
835
+ | `:ip_v4_address` | `String` | `xs:string` | `string` | `string` | `"192.168.0.1"`
836
+ | `:ip_v6_address` | `String` | `xs:string` | `string` | `string` | `"2001:db8::2:1"`
837
+ | `:token` | `String` | `xs:string` | `string` | `string` | `"label_3.v2"`
838
+ | `:uri_reference` | `String` | `xs:anyURI` | `string` | `string` | `"path/to/resource"`
839
+ | `:uuid` | `String` | `xs:string` | `string` | `string` | `"a3bb189e-8bf9-4888-9912-ace4e6543002"`
840
+ | `:non_negative_integer` | `Integer` | `xs:nonNegativeInteger` | `number` | `integer` | `7`
841
+ | `:positive_integer` | `Integer` | `xs:positiveInteger` | `number` | `integer` | `7`
842
+ | `:date_with_timezone` | `Date`/`DateTime` | `xs:date` | `string` | `string` | `"2019-12-02-08:00"`
843
+ | `:date_time_with_timezone` | `DateTime` | `xs:dateTime` | `string` | `string` | `"2019-12-02T16:39:57-08:00"`
844
+ | `:day_time_duration` | `Lutaml::Model::Type::Duration` | `xs:dayTimeDuration` | `string` | `string` | `"P1DT12H45M"`
845
+ | `:year_month_duration` | `Lutaml::Model::Type::Duration` | `xs:yearMonthDuration` | `string` | `string` | `"P1Y6M"`
833
846
  | `:hash` | `Hash` | complex element | object | map | `{key: "value"}`
834
847
  | (nil value) | `nil` | `xs:anyType` | `null` | `null` | `null`
835
848
  | `{ ref: [Model, :attr] }` | `Reference` | `xs:string` | `string` | `string` | `"model-id"`
@@ -840,6 +853,45 @@ Every type has a corresponding Ruby class and a serialization format type.
840
853
  |===
841
854
 
842
855
 
856
+ === NIST Metaschema types
857
+
858
+ The NIST Metaschema simple data types are supported as first-class value
859
+ types (https://pages.nist.gov/metaschema/specification/datatypes/). Each
860
+ type carries the normative metaschema-datatypes pattern and answers
861
+ `valid?` for a value; casting follows the lenient base-type behavior.
862
+
863
+ .Source types
864
+ [cols="3,4,2"]
865
+ |===
866
+ | Type code | Metaschema datatype | Restriction
867
+
868
+ | `:email_address` | `email-address` | contains an at-sign
869
+ | `:hostname` | `hostname` | non-empty, trimmed
870
+ | `:ip_v4_address` | `ip-v4-address` | dotted-quad IPv4
871
+ | `:ip_v6_address` | `ip-v6-address` | RFC3513 IPv6 (incl. zones, embedded IPv4)
872
+ | `:token` | `token` | NMTOKEN-like letter/number/punctuation run
873
+ | `:uri_reference` | `uri-reference` | relative or absolute URI reference
874
+ | `:uuid` | `uuid` | RFC4122 UUID, version 4 or 5
875
+ | `:non_negative_integer` | `non-negative-integer` | integer >= 0
876
+ | `:positive_integer` | `positive-integer` | integer >= 1
877
+ | `:date_with_timezone` | `date-with-timezone` | date with `Z` or offset
878
+ | `:date_time_with_timezone` | `date-time-with-timezone` | date-time with `Z` or offset
879
+ | `:day_time_duration` | `day-time-duration` | duration without years/months
880
+ | `:year_month_duration` | `year-month-duration` | duration without time components
881
+ |===
882
+
883
+ .Usage
884
+ [source,ruby]
885
+ ----
886
+ class Asset < Lutaml::Model::Serializable
887
+ attribute :id, :uuid
888
+ attribute :owner, :email_address
889
+ attribute :host, :hostname
890
+ attribute :address, :ip_v4_address
891
+ attribute :renewal, :year_month_duration
892
+ end
893
+ ----
894
+
843
895
  === Decimal type
844
896
 
845
897
  The Decimal type uses Ruby's `BigDecimal` for arbitrary-precision decimal
@@ -1188,3 +1188,78 @@ In this example:
1188
1188
  `name` attributes, respectively.
1189
1189
  ====
1190
1190
 
1191
+
1192
+ == Serialization mapping profiles
1193
+
1194
+ A model can declare multiple serialization mappings for the same format —
1195
+ *serialization mapping profiles* (also called projections or lenses). Each
1196
+ profile is a complete mapping: what it maps is the projection, and
1197
+ anything it does not map stays outside it. The base mapping (declared
1198
+ without a name) remains the default.
1199
+
1200
+ === Declaring a profile
1201
+
1202
+ [source,ruby]
1203
+ ----
1204
+ class Person < Lutaml::Model::Serializable
1205
+ attribute :name, :string
1206
+ attribute :email, :string
1207
+ attribute :internal_id, :string
1208
+
1209
+ json do
1210
+ map "name", to: :name
1211
+ map "email", to: :email
1212
+ map "internal_id", to: :internal_id
1213
+ end
1214
+
1215
+ json :compact do
1216
+ map "name", to: :name
1217
+ end
1218
+ end
1219
+ ----
1220
+
1221
+ === Selecting a profile
1222
+
1223
+ Profiles are selected per call with the `mapping:` option, for both
1224
+ serialization and parsing. An unknown profile name raises
1225
+ `Lutaml::Model::UnknownMappingError`.
1226
+
1227
+ [source,ruby]
1228
+ ----
1229
+ person.to_json # => {"name"=>"Ed", ...}
1230
+ person.to_json(mapping: :compact) # => {"name"=>"Ed"}
1231
+ Person.from_json(json, mapping: :compact)
1232
+ ----
1233
+
1234
+ `only:` and `except:` compose with the selected profile. Child models
1235
+ always serialize through their own mappings; a parent's profile does not
1236
+ propagate to them.
1237
+
1238
+ === Runtime-constructed mappings
1239
+
1240
+ A mapping object can be built at runtime and wired to the model, or used
1241
+ directly for a single call. This supports projections that are not known
1242
+ at class-definition time.
1243
+
1244
+ [source,ruby]
1245
+ ----
1246
+ mapping = Lutaml::Model::Config.mappings_class_for(:yaml).new
1247
+ mapping.map "full_name", to: :name
1248
+
1249
+ Person.mapping :public, mapping # register as a named profile
1250
+ person.to_yaml(mapping: :public) # by name
1251
+
1252
+ person.to_yaml(mapping: mapping) # ephemeral, unwired single use
1253
+ ----
1254
+
1255
+ `Person.mapping :name, nil, format: :yaml` removes a registered profile.
1256
+ `Person.serialization_mappings(:yaml)` returns the registered profiles of
1257
+ a format. Re-declaring or re-registering a name replaces the previous
1258
+ mapping.
1259
+
1260
+ XML profiles follow the same shape: `xml :registry do ... end` declares a
1261
+ profile with its own root element, and `to_xml(mapping: :registry)` /
1262
+ `from_xml(data, mapping: :registry)` select it. XSD generation honors
1263
+ the selection too: `Lutaml::Model::Schema.to_xsd(Record, mapping:
1264
+ :registry)` generates the schema of that projection, leaving unmapped
1265
+ attributes outside it.
@@ -281,6 +281,7 @@ require "lutaml/model/error/undefined_attribute_error"
281
281
  require "lutaml/model/error/union_schema_unsupported_error"
282
282
  require "lutaml/model/error/unknown_adapter_type_error"
283
283
  require "lutaml/model/error/unknown_discriminator_error"
284
+ require "lutaml/model/error/unknown_mapping_error"
284
285
  require "lutaml/model/error/unknown_sequence_mapping_error"
285
286
  require "lutaml/model/error/unknown_type_error"
286
287
  require "lutaml/model/error/unresolvable_type_error"
@@ -333,21 +334,35 @@ require "lutaml/model/type/base64_binary"
333
334
  require "lutaml/model/type/boolean"
334
335
  require "lutaml/model/type/date"
335
336
  require "lutaml/model/type/date_time"
337
+ require "lutaml/model/type/date_time_with_timezone"
338
+ require "lutaml/model/type/date_with_timezone"
339
+ require "lutaml/model/type/day_time_duration"
336
340
  require "lutaml/model/type/decimal"
337
341
  require "lutaml/model/type/duration"
342
+ require "lutaml/model/type/email_address"
338
343
  require "lutaml/model/type/float"
339
344
  require "lutaml/model/type/hash"
340
345
  require "lutaml/model/type/hex_binary"
346
+ require "lutaml/model/type/hostname"
341
347
  require "lutaml/model/type/integer"
348
+ require "lutaml/model/type/ip_v4_address"
349
+ require "lutaml/model/type/ip_v6_address"
350
+ require "lutaml/model/type/metaschema_pattern"
351
+ require "lutaml/model/type/non_negative_integer"
352
+ require "lutaml/model/type/positive_integer"
342
353
  require "lutaml/model/type/qname"
343
354
  require "lutaml/model/type/reference"
344
355
  require "lutaml/model/type/string"
345
356
  require "lutaml/model/type/symbol"
346
357
  require "lutaml/model/type/time"
347
358
  require "lutaml/model/type/time_without_date"
359
+ require "lutaml/model/type/token"
348
360
  require "lutaml/model/type/uninitialized_class_guard"
349
361
  require "lutaml/model/type/uri"
362
+ require "lutaml/model/type/uri_reference"
363
+ require "lutaml/model/type/uuid"
350
364
  require "lutaml/model/type/value"
365
+ require "lutaml/model/type/year_month_duration"
351
366
  require "lutaml/rdf/namespaces/dcterms_namespace"
352
367
  require "lutaml/rdf/namespaces/owl_namespace"
353
368
  require "lutaml/rdf/namespaces/rdf_namespace"
@@ -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
@@ -475,12 +477,14 @@ options)
475
477
  generate_remaining_mappings_for_value(child_mappings, value, format)
476
478
 
477
479
  attr_type = attr.type(lutaml_register)
478
- value.each do |child_obj|
479
- rules = attr_type.mappings_for(format)
480
+ rules = attr_type.mappings_for(format)
481
+ pairs = child_mappings.map do |attr_name, path|
482
+ [rules.find_by_to!(attr_name), path]
483
+ end
480
484
 
485
+ value.each do |child_obj|
481
486
  hash.merge!(
482
- extract_hash_for_child_mapping(child_mappings, child_obj, rules,
483
- format),
487
+ extract_hash_for_child_mapping(pairs, child_obj, format),
484
488
  )
485
489
  end
486
490
 
@@ -508,14 +512,12 @@ options)
508
512
  mappings.find_by_to(name)&.name.to_s
509
513
  end
510
514
 
511
- def extract_hash_for_child_mapping(child_mappings, child_obj, rules,
512
- format)
515
+ def extract_hash_for_child_mapping(pairs, child_obj, format)
513
516
  key = nil
514
517
  value = {}
515
518
 
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)
519
+ pairs.each do |rule, path|
520
+ attr_value = normalize_attribute_value(child_obj, rule, format)
519
521
 
520
522
  next unless rule&.render?(attr_value, nil)
521
523
  next key = attr_value if path == :key
@@ -527,10 +529,16 @@ format)
527
529
  { key => value }
528
530
  end
529
531
 
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]
532
+ # The child's wire-form value for one attribute, produced through
533
+ # the same pipeline the child's own serializer uses for a rendered
534
+ # value (rule transform, then attribute serialization) — without
535
+ # the serialize-parse string round-trip this read previously paid
536
+ # per child per attribute.
537
+ def normalize_attribute_value(child_obj, rule, format)
538
+ child_attr = child_obj.class.attributes(lutaml_register)[rule.to]
539
+ value = child_obj.public_send(rule.to)
540
+ value = rule.transform_value(child_attr, value, :to, format)
541
+ child_attr.serialize(value, format, lutaml_register)
534
542
  end
535
543
 
536
544
  def extract_hash_value_for_child_mapping(path, value, map_value)
@@ -764,8 +772,14 @@ partition = nil)
764
772
  # crashed translate_mappings).
765
773
  return if value.nil? && kind == :hash_map
766
774
 
767
- value = translate_mappings(value, rule.hash_mappings, attr, format,
768
- instance)
775
+ child_plan = plan.child_map
776
+ value = if child_plan
777
+ translate_child_map_compiled(value, child_plan, attr,
778
+ format, instance)
779
+ else
780
+ translate_mappings(value, rule.hash_mappings, attr, format,
781
+ instance)
782
+ end
769
783
  unless kind == :hash_map
770
784
  value = cast_value(value, attr, format, rule,
771
785
  instance)
@@ -798,12 +812,86 @@ partition = nil)
798
812
  # (custom methods, transforms, value maps, delegates, hash mappings,
799
813
  # unions, polymorphism) return nil and keep the interpretive path.
800
814
  WalkPlan = ::Struct.new(:kind, :attr, :valid, :wire, :klass,
801
- :collection)
815
+ :collection, :child_map)
816
+
817
+ # Compiled child-map translation (#986b): the per-node registry
818
+ # lookups (attribute type, child mapping rules, per-attribute wire
819
+ # names, path string casts) collapse to per-rule constants. nil
820
+ # child_map keeps the interpretive translate path (including its
821
+ # declaration-error timing).
822
+ ChildMapPlan = ::Struct.new(:child_mappings, :wire_names, :paths,
823
+ :keys_only, :child_rules, :attr_type)
802
824
 
803
825
  # Memoized (per transform) wire name for plain rules — the from
804
826
  # spelling never changes after definition.
805
827
  KvSerializePlan = ::Struct.new(:wire)
806
828
 
829
+ def build_child_map_plan(format, rule, attr)
830
+ child_mappings = rule.hash_mappings
831
+ return nil unless child_mappings.is_a?(::Hash)
832
+
833
+ attr_type = attr.type(lutaml_register)
834
+ return nil unless attr_type.is_a?(Class) &&
835
+ attr_type.include?(Lutaml::Model::Serialize)
836
+
837
+ child_rules = attr_type.mappings_for(format, lutaml_register)
838
+ wire_names = {}
839
+ paths = {}
840
+ child_mappings.each do |attr_name, path|
841
+ attr_rule = child_rules.find_by_to!(attr_name)
842
+ wire_names[attr_name] = attr_rule.from.to_s
843
+ paths[attr_name] = compile_child_map_path(path)
844
+ end
845
+ ChildMapPlan.new(child_mappings, wire_names, paths,
846
+ child_mappings.values == [:key], child_rules,
847
+ attr_type)
848
+ rescue Lutaml::Model::NoMappingFoundError
849
+ nil
850
+ end
851
+
852
+ def compile_child_map_path(path)
853
+ case path
854
+ when :key then :key
855
+ when :value then :value
856
+ else Array(path).map(&:to_s).freeze
857
+ end
858
+ end
859
+
860
+ # Compiled translate_mappings (#986b). Same shape as the
861
+ # interpretive pair: keys_only is decided once per rule VALUE (the
862
+ # original tests the whole document hash, not each child).
863
+ def translate_child_map_compiled(value, child_plan, attr, format,
864
+ instance)
865
+ child_options = {
866
+ mappings: child_plan.child_rules,
867
+ lutaml_parent: instance,
868
+ lutaml_root: instance.lutaml_root || instance,
869
+ }
870
+ keys_only = child_plan.keys_only && value.values.all?(::Hash)
871
+ wire_names = child_plan.wire_names
872
+ paths = child_plan.paths
873
+ child_mappings = child_plan.child_mappings
874
+
875
+ value.map do |key, child_value|
876
+ child_hash = {}
877
+ child_mappings.each_key do |attr_name|
878
+ child_hash[wire_names[attr_name]] =
879
+ extract_compiled_child_value(paths[attr_name], key, child_value)
880
+ end
881
+ child_hash.merge!(child_value) if keys_only
882
+ map_child_data(child_hash, attr, format, instance, child_plan,
883
+ child_options)
884
+ end
885
+ end
886
+
887
+ def extract_compiled_child_value(compiled_path, key, value)
888
+ case compiled_path
889
+ when :key then key
890
+ when :value then value
891
+ else value.dig(*compiled_path)
892
+ end
893
+ end
894
+
807
895
  def kv_serialize_plan(rule, attr, _instance)
808
896
  return nil if rule.delegate || rule.raw_mapping? || rule.root_mapping? ||
809
897
  rule.hash_mappings || rule.child_mappings ||
@@ -824,7 +912,8 @@ partition = nil)
824
912
  generation = Lutaml::Model::GlobalContext.context_generation
825
913
  if @lutaml_walk_plans.nil? || @lutaml_walk_generation != generation ||
826
914
  @lutaml_walk_format != format
827
- key = [model_class, lutaml_register, format, generation]
915
+ key = [model_class, lutaml_register, format, generation,
916
+ @lutaml_mapping_override]
828
917
  @lutaml_walk_plans =
829
918
  (KV_WALK_PLANS[key] ||= build_walk_plans(format))
830
919
  @lutaml_walk_generation = generation
@@ -861,6 +950,11 @@ partition = nil)
861
950
  plan ? :scalar : :interpretive
862
951
  end
863
952
 
953
+ if kind == :hash_map && attr
954
+ child = build_child_map_plan(format, rule, attr)
955
+ return WalkPlan.new(:hash_map, attr, valid, nil, nil, false, child)
956
+ end
957
+
864
958
  plan || WalkPlan.new(kind, attr, valid, nil, nil, false)
865
959
  end
866
960
 
@@ -979,14 +1073,20 @@ instance)
979
1073
  child_mappings.values == [:key] && hash.values.all?(::Hash)
980
1074
  end
981
1075
 
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)
1076
+ def map_child_data(child_hash, attr, format, instance,
1077
+ child_plan = nil, child_options = nil)
1078
+ if child_plan
1079
+ self.class.data_to_model(child_plan.attr_type, child_hash, format,
1080
+ child_options)
1081
+ else
1082
+ attr_type = attr.type(lutaml_register)
1083
+ options = {
1084
+ mappings: attr_type.mappings_for(format, lutaml_register),
1085
+ lutaml_parent: instance,
1086
+ lutaml_root: instance.lutaml_root || instance,
1087
+ }
1088
+ self.class.data_to_model(attr_type, child_hash, format, options)
1089
+ end
990
1090
  end
991
1091
 
992
1092
  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
@@ -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