lutaml-model 0.8.19 → 0.8.23

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 (139) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +1 -1
  3. data/.github/workflows/rake.yml +1 -0
  4. data/.rubocop.yml +1 -1
  5. data/.rubocop_todo.yml +133 -72
  6. data/bench/bench_common.rb +8 -5
  7. data/bench/bench_niso.rb +1 -2
  8. data/bench/gate_config.rb +4 -1
  9. data/benchmark/serialization_benchmark.rb +4 -4
  10. data/docs/_guides/value-transformations.adoc +27 -0
  11. data/docs/_guides/xml-mapping.adoc +31 -0
  12. data/lib/compat/opal/generate_boot.rb +3 -1
  13. data/lib/compat/opal/lutaml_model_boot.rb +10 -7
  14. data/lib/compat/opal/moxml_boot.rb +6 -1
  15. data/lib/lutaml/hash_format/format.rb +29 -0
  16. data/lib/lutaml/hash_format.rb +5 -27
  17. data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
  18. data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
  19. data/lib/lutaml/json/format.rb +61 -0
  20. data/lib/lutaml/json/generator_options.rb +62 -0
  21. data/lib/lutaml/json.rb +4 -59
  22. data/lib/lutaml/jsonl/adapter/standard_adapter.rb +1 -1
  23. data/lib/lutaml/jsonl/format.rb +22 -0
  24. data/lib/lutaml/jsonl.rb +5 -19
  25. data/lib/lutaml/jsonld/adapter.rb +12 -4
  26. data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +7 -0
  27. data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +17 -5
  28. data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
  29. data/lib/lutaml/key_value/format.rb +26 -0
  30. data/lib/lutaml/key_value/transform.rb +4 -1
  31. data/lib/lutaml/key_value/transformation/value_serializer.rb +0 -2
  32. data/lib/lutaml/key_value.rb +4 -21
  33. data/lib/lutaml/model/attribute.rb +38 -4
  34. data/lib/lutaml/model/collection.rb +7 -2
  35. data/lib/lutaml/model/compiled_rule.rb +1 -1
  36. data/lib/lutaml/model/config.rb +2 -2
  37. data/lib/lutaml/model/consolidation_map.rb +2 -2
  38. data/lib/lutaml/model/consolidation_rule/dispatch_block.rb +2 -2
  39. data/lib/lutaml/model/errors.rb +2 -2
  40. data/lib/lutaml/model/global_context.rb +4 -2
  41. data/lib/lutaml/model/mapping/mapping_rule.rb +4 -1
  42. data/lib/lutaml/model/mapping/model_mapping.rb +2 -2
  43. data/lib/lutaml/model/runtime_compatibility.rb +2 -2
  44. data/lib/lutaml/model/schema/class_loader.rb +2 -1
  45. data/lib/lutaml/model/schema/decorators/definition_collection.rb +4 -4
  46. data/lib/lutaml/model/schema/definitions/namespace.rb +2 -1
  47. data/lib/lutaml/model/schema/file_writer.rb +5 -2
  48. data/lib/lutaml/model/schema/renderers/base.rb +4 -3
  49. data/lib/lutaml/model/schema/renderers/mappings.rb +9 -3
  50. data/lib/lutaml/model/schema/renderers/member_decls.rb +9 -3
  51. data/lib/lutaml/model/schema/renderers/model.rb +18 -6
  52. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +2 -1
  53. data/lib/lutaml/model/schema/renderers.rb +2 -1
  54. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +6 -2
  55. data/lib/lutaml/model/schema/rnc_compiler.rb +3 -1
  56. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +35 -15
  57. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +4 -2
  58. data/lib/lutaml/model/schema/rng_compiler.rb +10 -5
  59. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +2 -1
  60. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +4 -2
  61. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +39 -13
  62. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +29 -10
  63. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +2 -2
  64. data/lib/lutaml/model/schema/xml_compiler.rb +10 -5
  65. data/lib/lutaml/model/sequence.rb +4 -4
  66. data/lib/lutaml/model/serialization_adapter.rb +1 -1
  67. data/lib/lutaml/model/serialize/attribute_definition.rb +1 -0
  68. data/lib/lutaml/model/serialize/format_conversion.rb +40 -9
  69. data/lib/lutaml/model/serialize/initialization.rb +9 -1
  70. data/lib/lutaml/model/serialize/model_import.rb +1 -0
  71. data/lib/lutaml/model/serialize.rb +22 -3
  72. data/lib/lutaml/model/transformation.rb +5 -0
  73. data/lib/lutaml/model/type/uninitialized_class_guard.rb +1 -1
  74. data/lib/lutaml/model/type.rb +9 -0
  75. data/lib/lutaml/model/type_registry.rb +2 -2
  76. data/lib/lutaml/model/uninitialized_class.rb +1 -1
  77. data/lib/lutaml/model/union.rb +5 -2
  78. data/lib/lutaml/model/utils.rb +2 -2
  79. data/lib/lutaml/model/value_policy.rb +27 -0
  80. data/lib/lutaml/model/version.rb +1 -1
  81. data/lib/lutaml/model.rb +17 -16
  82. data/lib/lutaml/rdf/mapping.rb +2 -2
  83. data/lib/lutaml/rdf/namespace_set.rb +2 -2
  84. data/lib/lutaml/rdf/transform.rb +2 -2
  85. data/lib/lutaml/toml/format.rb +48 -0
  86. data/lib/lutaml/toml.rb +5 -46
  87. data/lib/lutaml/xml/adapter/base_adapter.rb +2 -2
  88. data/lib/lutaml/xml/builder/base.rb +2 -2
  89. data/lib/lutaml/xml/format.rb +331 -0
  90. data/lib/lutaml/xml/mapping.rb +26 -2
  91. data/lib/lutaml/xml/mapping_rule.rb +62 -3
  92. data/lib/lutaml/xml/model_transform.rb +183 -55
  93. data/lib/lutaml/xml/namespace_collector.rb +3 -3
  94. data/lib/lutaml/xml/parse_session.rb +59 -0
  95. data/lib/lutaml/xml/parsed_namespace_set.rb +2 -2
  96. data/lib/lutaml/xml/schema/builder.rb +5 -5
  97. data/lib/lutaml/xml/schema/xsd/schema_location_mapping.rb +2 -2
  98. data/lib/lutaml/xml/schema/xsd.rb +0 -2
  99. data/lib/lutaml/xml/schema/xsd_schema.rb +2 -1
  100. data/lib/lutaml/xml/serialization/format_conversion.rb +3 -3
  101. data/lib/lutaml/xml/transformation/element_builder.rb +2 -1
  102. data/lib/lutaml/xml/transformation/ordered_applier.rb +2 -1
  103. data/lib/lutaml/xml/transformation.rb +29 -0
  104. data/lib/lutaml/xml/xml_element.rb +2 -0
  105. data/lib/lutaml/xml.rb +4 -328
  106. data/lib/lutaml/yaml/format.rb +30 -0
  107. data/lib/lutaml/yaml.rb +5 -28
  108. data/lib/lutaml/yamls/adapter/mapping.rb +2 -2
  109. data/lib/lutaml/yamls/format.rb +22 -0
  110. data/lib/lutaml/yamls.rb +5 -19
  111. data/lib/tasks/benchmark_runner.rb +2 -2
  112. data/lutaml-model.gemspec +1 -1
  113. data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
  114. data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
  115. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +26 -13
  116. data/spec/lutaml/model/attribute_spec.rb +8 -3
  117. data/spec/lutaml/model/collection_spec.rb +1 -1
  118. data/spec/lutaml/model/collection_state_nesting_spec.rb +44 -0
  119. data/spec/lutaml/model/custom_bibtex_adapter_spec.rb +2 -2
  120. data/spec/lutaml/model/custom_from_empty_values_spec.rb +74 -0
  121. data/spec/lutaml/model/custom_vobject_adapter_spec.rb +8 -8
  122. data/spec/lutaml/model/defaults_spec.rb +45 -0
  123. data/spec/lutaml/model/generator_defaults_spec.rb +101 -0
  124. data/spec/lutaml/model/json_spec.rb +1 -2
  125. data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
  126. data/spec/lutaml/model/serialize/builder_spec.rb +6 -3
  127. data/spec/lutaml/model/store_spec.rb +3 -1
  128. data/spec/lutaml/model/transform_apply_value_map_spec.rb +6 -3
  129. data/spec/lutaml/model/union_attribute_spec.rb +4 -2
  130. data/spec/lutaml/model/value_policy_spec.rb +82 -0
  131. data/spec/lutaml/require_load_order_spec.rb +25 -0
  132. data/spec/lutaml/xml/attribute_element_namespace_disjoint_spec.rb +88 -0
  133. data/spec/lutaml/xml/attribute_namespace_disjointness_spec.rb +125 -0
  134. data/spec/lutaml/xml/out_of_namespace_parsing_spec.rb +123 -0
  135. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +17 -6
  136. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +15 -6
  137. data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
  138. data/spec/lutaml/xml/w3c_types_spec.rb +3 -6
  139. metadata +26 -4
@@ -183,6 +183,34 @@ module Lutaml
183
183
  result
184
184
  end
185
185
 
186
+ # Match names an attribute rule consumes during parsing, mirroring how
187
+ # serialization qualifies the same attribute (resolve_namespace):
188
+ # a rule serialized with a prefix matches only attributes in its
189
+ # namespace; a rule serialized unprefixed matches only namespace-less
190
+ # attributes.
191
+ #
192
+ # Performance: memoized on the rule with zero-allocation cache hits.
193
+ # The inputs are constant for a given rule, register and parent model,
194
+ # so every element of a collection reuses the first resolution.
195
+ def attribute_match_names(attr, register, parent_ns_class, form_default,
196
+ default_namespace)
197
+ if defined?(@_amn_names) && @_amn_register == register &&
198
+ @_amn_parent_ns == parent_ns_class &&
199
+ @_amn_form_default == form_default &&
200
+ @_amn_default_ns == default_namespace
201
+ return @_amn_names
202
+ end
203
+
204
+ result = compute_attribute_match_names(attr, register, parent_ns_class,
205
+ form_default, default_namespace)
206
+ @_amn_register = register
207
+ @_amn_parent_ns = parent_ns_class
208
+ @_amn_form_default = form_default
209
+ @_amn_default_ns = default_namespace
210
+ @_amn_names = result
211
+ result
212
+ end
213
+
186
214
  def namespaced_name(parent_namespace = nil, name = self.name)
187
215
  if name.to_s == "lang"
188
216
  ::Lutaml::Model::Utils.blank?(prefix) ? name.to_s : "#{prefix}:#{name}"
@@ -274,6 +302,33 @@ module Lutaml
274
302
 
275
303
  private
276
304
 
305
+ # Extracted body of attribute_match_names for caching
306
+ def compute_attribute_match_names(attr, register, parent_ns_class,
307
+ form_default, default_namespace)
308
+ ns_info = resolve_namespace(
309
+ attr: attr,
310
+ register: register,
311
+ parent_ns_class: parent_ns_class,
312
+ form_default: form_default,
313
+ )
314
+ uri = ns_info[:uri]
315
+ # Per W3C, an unprefixed attribute is in NO namespace regardless of
316
+ # the element's namespace. The qualified alias is only legitimate
317
+ # under schema-level attribute_form_default :qualified — the
318
+ # via_schema_default leniency (preserves ogc-gml's srsName handling
319
+ # under :qualified schema defaults). Without this gate, a plain
320
+ # attribute rule on an element-namespaced class inherits the
321
+ # element's namespace as default_namespace and steals a
322
+ # namespaced-sibling attribute whose local name matches (lutaml-model#758).
323
+ strict_match = uri && ns_info[:prefix] &&
324
+ !ns_info[:unqualified_same_ns] && !ns_info[:via_schema_default]
325
+ if strict_match
326
+ ["#{uri}:#{name}"].freeze
327
+ else
328
+ namespaced_names(default_namespace)
329
+ end
330
+ end
331
+
277
332
  # Extracted body of namespaced_names for caching
278
333
  def compute_namespaced_names(parent_namespace)
279
334
  if multiple_mappings?
@@ -413,7 +468,8 @@ form_default = :unqualified)
413
468
  end
414
469
 
415
470
  # 2. Type-level namespace
416
- if attr && (type_ns_class = attr.type_namespace_class(register))
471
+ if attr && (type_ns_class = attr.type_namespace_class(register)) &&
472
+ type_ns_class != :blank
417
473
  result = build_namespace_result_from_class(type_ns_class)
418
474
  # CRITICAL W3C FLAG: Mark when attribute is in same namespace as parent with :unqualified
419
475
  # Serialization code will check this to omit prefix per attributeFormDefault
@@ -427,7 +483,9 @@ form_default = :unqualified)
427
483
  # When schema specifies attributeFormDefault="qualified", attributes
428
484
  # must be qualified (prefixed) with the parent element's namespace
429
485
  if form_default == :qualified && parent_ns_class
430
- return build_namespace_result_from_class(parent_ns_class)
486
+ result = build_namespace_result_from_class(parent_ns_class)
487
+ result[:via_schema_default] = true
488
+ return result
431
489
  end
432
490
 
433
491
  # 4. No namespace (W3C default for unprefixed attributes)
@@ -490,7 +548,8 @@ form_default = :unqualified)
490
548
  end
491
549
 
492
550
  # 4. Type-level namespace
493
- if attr && (type_ns_class = attr.type_namespace_class(register))
551
+ if attr && (type_ns_class = attr.type_namespace_class(register)) &&
552
+ type_ns_class != :blank
494
553
  # Check if Type namespace matches parent default namespace
495
554
  # If so, use parent's namespace with parent's actual prefix
496
555
  if type_ns_class.uri == parent_ns_uri && parent_ns_class
@@ -86,8 +86,8 @@ module Lutaml
86
86
  end
87
87
  end
88
88
  root_and_parent_assignment(instance, options)
89
- apply_xml_mapping(data, instance, options, child_register,
90
- instance_is_serialize)
89
+ apply_xml_mapping(ParseSession.new(data, instance, options,
90
+ child_register))
91
91
  end
92
92
 
93
93
  # Build a DeclarationPlan from the parsed element tree's namespace declarations.
@@ -206,17 +206,12 @@ module Lutaml
206
206
 
207
207
  private
208
208
 
209
- def apply_xml_mapping(doc, instance, options = {},
210
- effective_register = nil, instance_is_serialize = nil)
211
- # Use threaded flag from data_to_model if provided, otherwise compute
212
- instance_is_serialize = instance.is_a?(::Lutaml::Model::Serialize) if instance_is_serialize.nil?
213
-
214
- # Use threaded register from data_to_model if provided, otherwise derive
215
- effective_register ||= if instance_is_serialize && instance.lutaml_register
216
- instance.lutaml_register
217
- else
218
- lutaml_register
219
- end
209
+ def apply_xml_mapping(session)
210
+ doc = session.doc
211
+ instance = session.instance
212
+ options = session.options
213
+ effective_register = session.register
214
+ instance_is_serialize = session.instance_is_serialize
220
215
 
221
216
  # Performance: Resolve xml_mapping once — used for both rules and metadata
222
217
  xml_mapping = mappings_for(:xml, effective_register)
@@ -263,6 +258,18 @@ effective_register = nil, instance_is_serialize = nil)
263
258
  end
264
259
  end
265
260
 
261
+ # lutaml-model#765: several plain element rules may target one
262
+ # collection attribute (spelling tolerance, e.g. editorial-group vs
263
+ # editorialgroup). The first rule of such a group performs matching
264
+ # widened to every spelling so content merges in document order;
265
+ # the remaining rules are no-ops so their assignment cannot
266
+ # silently overwrite the merged value.
267
+ # Grouping is cached on the mapping DSL (per register); only the
268
+ # collection filter runs here.
269
+ grouped_plain_rules = xml_mapping.plain_element_rules_by_attr(
270
+ effective_register,
271
+ )
272
+
266
273
  mappings.each do |rule|
267
274
  # Performance: Cache rule properties accessed multiple times
268
275
  rule.name
@@ -273,6 +280,11 @@ effective_register = nil, instance_is_serialize = nil)
273
280
  attr = attribute_for_rule(rule)
274
281
  next if attr&.derived?
275
282
 
283
+ if (group = grouped_plain_rules[rule_to]) &&
284
+ group.size > 1 && attr&.collection? && !group.first.equal?(rule)
285
+ next
286
+ end
287
+
276
288
  raise "Attribute '#{rule_to}' not found in #{context}" unless valid_rule?(
277
289
  rule, attr
278
290
  )
@@ -290,6 +302,26 @@ effective_register = nil, instance_is_serialize = nil)
290
302
  doc.root.inner_xml
291
303
  elsif rule.content_mapping?
292
304
  rule.cdata ? doc.cdata : doc.text
305
+ elsif (group = grouped_plain_rules[rule_to]) &&
306
+ group.size > 1 && attr&.collection?
307
+ # First rule of a #765 group: match every spelling of
308
+ # the group so the children merge in document order.
309
+ if child_names_set &&
310
+ group.drop(1).none? do |s|
311
+ child_matches_rule?(s, child_names_set,
312
+ default_namespace)
313
+ end && !child_matches_rule?(rule, child_names_set,
314
+ default_namespace)
315
+ ::Lutaml::Model::UninitializedClass.instance
316
+ else
317
+ extra_names = group.drop(1).flat_map do |s|
318
+ resolve_rule_names_with_type(s, attr, new_opts,
319
+ effective_register,
320
+ attr&.type(effective_register))
321
+ end
322
+ value_for_rule(session, rule, new_opts, attr,
323
+ extra_names)
324
+ end
293
325
  elsif child_names_set && !rule.attribute? &&
294
326
  !child_matches_rule?(rule, child_names_set,
295
327
  default_namespace)
@@ -303,8 +335,7 @@ effective_register = nil, instance_is_serialize = nil)
303
335
  end
304
336
  else
305
337
  # Performance: Pass cached attr to avoid recomputing attribute_for_rule
306
- val = value_for_rule(doc, rule, new_opts, instance, attr,
307
- effective_register, instance_is_serialize)
338
+ val = value_for_rule(session, rule, new_opts, attr)
308
339
 
309
340
  if (val.nil? || ::Lutaml::Model::Utils.uninitialized?(val)) &&
310
341
  (instance.using_default?(rule_to) || rule.render_default)
@@ -437,13 +468,20 @@ _effective_register)
437
468
  # - URI format: "http://.../namespace:attr" or "urn:...:attr"
438
469
  # - Simple prefix format: "my-ns:attr" (namespace URI is a simple string, not a URI)
439
470
  # - Unprefixed: "attr" (no namespace)
440
- # Performance: Use map instead of filter_map — converted || rn is always truthy
441
- attribute_names = rule_names.map do |rn|
471
+ # Performance: most rules carry a single name — skip the map and
472
+ # its result array entirely on that path.
473
+ if rule_names.size == 1
474
+ rn = rule_names[0]
442
475
  converted = convert_rule_name_to_attribute_name(doc, rn)
443
- converted || rn
444
- end
476
+ value = doc.root.find_attribute_value(converted || rn)
477
+ else
478
+ attribute_names = rule_names.map do |rn|
479
+ converted = convert_rule_name_to_attribute_name(doc, rn)
480
+ converted || rn
481
+ end
445
482
 
446
- value = doc.root.find_attribute_value(attribute_names)
483
+ value = doc.root.find_attribute_value(attribute_names)
484
+ end
447
485
 
448
486
  # Fallback: if value is nil, try to match by local name only.
449
487
  # This handles the case where the namespace prefix is not declared in the document
@@ -468,25 +506,36 @@ _effective_register)
468
506
  def convert_rule_name_to_attribute_name(doc, rule_name)
469
507
  return nil unless rule_name.include?(":")
470
508
 
471
- # Split on last colon to get namespace/localname
472
- # This correctly handles URIs with colons (http://... or urn:...)
473
- last_colon_index = rule_name.rindex(":")
474
- namespace_part = rule_name[0...last_colon_index]
475
- local_name = rule_name[(last_colon_index + 1)..]
476
-
477
- # Determine if namespace_part is a URI format or a simple prefix
478
- # URI formats: contains "://" (http/https) or starts with "urn:"
479
- is_uri_format = namespace_part.include?("://") || namespace_part.start_with?("urn:")
509
+ # URI-vs-prefix detection works on the whole rule name: the
510
+ # namespace part is a prefix of rule_name, so "://" anywhere in
511
+ # the name means a URI-form namespace, and a "urn:" namespace
512
+ # makes the whole name start with "urn:". Slicing the parts out
513
+ # first would allocate two strings per call on this hot path.
514
+ is_uri_format = rule_name.include?("://") || rule_name.start_with?("urn:")
480
515
 
516
+ last_colon_index = rule_name.rindex(":")
481
517
  if is_uri_format
482
- # URI format: look up attribute by namespace URI and local name
483
- doc.root.attributes.values.find do |attr|
484
- attr.namespace == namespace_part && attr.unprefixed_name == local_name
518
+ # URI format: find the attribute whose resolved namespace and
519
+ # local name spell the rule name. Comparison is by namespace
520
+ # prefix + local suffix so prefixed attributes (whose
521
+ # namespaced_name is "prefix:local", never "uri:local") still
522
+ # match, and unprefixed_name is not called per attribute
523
+ # (it splits prefixed names and allocates).
524
+ local_name = rule_name[(last_colon_index + 1)..]
525
+ doc.root.attributes.each_value.find do |attr|
526
+ ns = attr.namespace
527
+ next false if ns.nil? || ns.length > last_colon_index
528
+ next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
529
+
530
+ attr.unprefixed_name == local_name
485
531
  end&.name
486
532
  else
487
533
  # Simple prefix format: look up the actual prefix from document's namespace declarations
488
534
  # The namespace_part is the namespace URI declared in the document (e.g., "my-ns").
489
535
  # Find the corresponding prefix (e.g., "my") and build "my:val".
536
+ namespace_part = rule_name[0...last_colon_index]
537
+ local_name = rule_name[(last_colon_index + 1)..]
538
+
490
539
  ns_data = doc.root.namespaces.values.find do |nd|
491
540
  nd.uri == namespace_part
492
541
  end
@@ -507,31 +556,44 @@ _effective_register)
507
556
 
508
557
  last_colon_index = rn.rindex(":")
509
558
  local_name = rn[(last_colon_index + 1)..]
559
+ rule_uri = rn[0...last_colon_index]
560
+
561
+ matched_attr = doc.root.attributes.each_value.find do |attr|
562
+ # Local-name fallback serves two lenient cases only:
563
+ # - an attribute with an undeclared prefix (raw name keeps the
564
+ # colon, namespace unresolvable), the historical purpose;
565
+ # - an attribute whose resolved namespace IS one of the rule's
566
+ # namespace URIs (prefix rebinding, lutaml-model#744).
567
+ # Everything else — including a definitively namespace-less
568
+ # attribute and an attribute from a foreign namespace — must
569
+ # not satisfy the fallback, or a plain rule steals a
570
+ # namespaced sibling attribute (lutaml-model#758). An
571
+ # XmlAttribute with a resolved namespace has namespaced_name
572
+ # "URI:local", so this comparison is allocation-free.
573
+ if attr.namespace.nil?
574
+ next false if attr.namespace_prefix.nil? &&
575
+ !attr.name.include?(":")
510
576
 
511
- matched_attr = doc.root.attributes.values.find do |attr|
512
- # Match by the attribute's namespaced_name, unprefixed_name, or by extracting
513
- # local name from the attribute's key (handles unresolved prefixes).
514
- attr.namespaced_name == local_name ||
515
577
  attr.unprefixed_name == local_name ||
516
- (attr.namespace.nil? && ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name)
578
+ ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
579
+ else
580
+ # Prefixed attributes carry namespaced_name "prefix:local",
581
+ # which a URI-form rule name can never equal — match by the
582
+ # attribute's resolved namespace instead.
583
+ attr.namespace == rule_uri && attr.unprefixed_name == local_name
584
+ end
517
585
  end
518
586
  return matched_attr&.value if matched_attr
519
587
  end
520
588
  nil
521
589
  end
522
590
 
523
- def value_for_rule(doc, rule, options, instance, cached_attr = nil,
524
- effective_register = nil,
525
- instance_is_serialize = nil)
526
- # Use threaded flag from apply_xml_mapping if provided, otherwise compute
527
- instance_is_serialize = instance.is_a?(::Lutaml::Model::Serialize) if instance_is_serialize.nil?
528
-
529
- # Use threaded register from apply_xml_mapping if provided, otherwise derive
530
- effective_register ||= if instance_is_serialize && instance.lutaml_register
531
- instance.lutaml_register
532
- else
533
- lutaml_register
534
- end
591
+ def value_for_rule(session, rule, options, cached_attr = nil,
592
+ extra_rule_names = nil)
593
+ doc = session.doc
594
+ instance = session.instance
595
+ instance_is_serialize = session.instance_is_serialize
596
+ effective_register = session.register
535
597
 
536
598
  # Performance: Use cached attr from caller if available
537
599
  attr = cached_attr || attribute_for_rule(rule)
@@ -544,9 +606,20 @@ _effective_register)
544
606
  rule_namespace = rule_namespace_set ? rule.namespace : nil
545
607
  options[:default_namespace]
546
608
 
547
- # Enhanced namespace resolution with type support
548
- rule_names = resolve_rule_names_with_type(rule, attr, options,
549
- effective_register, attr_type)
609
+ rule_names = if rule.attribute?
610
+ resolve_attribute_rule_names(rule, attr, options,
611
+ effective_register,
612
+ instance, instance_is_serialize)
613
+ else
614
+ resolve_rule_names_with_type(rule, attr, options,
615
+ effective_register, attr_type)
616
+ end
617
+ # lutaml-model#765: widened matching for the first rule of a
618
+ # shared-attribute group — children of sibling spellings must be
619
+ # selected too, in document order. Deduped: sibling resolution can
620
+ # yield the same spelling twice, and the child index would then
621
+ # return the same children again.
622
+ rule_names = rule_names.concat(extra_rule_names).uniq if extra_rule_names
550
623
 
551
624
  return value_for_xml_attribute(doc, rule, rule_names) if rule.attribute?
552
625
 
@@ -565,15 +638,25 @@ _effective_register)
565
638
  end
566
639
  type_ns_prefix_str = type_ns_class&.prefix_default&.to_s
567
640
  type_ns_all_uris = type_ns_class&.all_uris
641
+ # Adopt the parsed document's namespace (memoized on the session):
642
+ # when the whole document is out-of-namespace, treat it as an
643
+ # implicit alias so lenient parsing is lossless (#754).
644
+ adopted_uri = session.adopted_namespace_uri
645
+ if adopted_uri && !type_ns_all_uris&.include?(adopted_uri)
646
+ type_ns_all_uris = (type_ns_all_uris || []) + [adopted_uri]
647
+ end
568
648
  end
569
649
 
570
650
  # Pre-compute model's namespace class for simple-type alias matching
571
- # (Third branch in select loop — was calling instance.class.mappings_for(:xml) per child)
651
+ # (Third branch in select loop — memoized on the session)
572
652
  model_ns_class = if instance_is_serialize &&
573
653
  !rule_namespace_set && !attr_type_is_serializable
574
- instance.class.mappings_for(:xml)&.namespace_class
654
+ session.xml_mapping&.namespace_class
575
655
  end
576
- model_ns_all_uris = model_ns_class&.all_uris
656
+ # Includes the adopted document namespace (#754): a prefixed child
657
+ # in the document's (foreign) namespace must still bind by local
658
+ # name, or its content is silently dropped.
659
+ model_ns_all_uris = model_ns_class ? session.model_namespace_uris : nil
577
660
 
578
661
  # Performance: Use cached element children from XmlElement
579
662
  # This avoids O(children * rules) scans by pre-filtering once per element
@@ -604,6 +687,13 @@ _effective_register)
604
687
  children = nil # fall through to select for alias matching
605
688
  else
606
689
  children = indexed.flatten(1)
690
+ # #765 widened matching: rule_names spans several spellings, so
691
+ # the flatten above yields rule-name order; restore document
692
+ # order for the merged group.
693
+ if extra_rule_names
694
+ position = element_children.each_with_index.to_h
695
+ children = children.sort_by { |c| position[c] }
696
+ end
607
697
  end
608
698
  else
609
699
  children = nil
@@ -879,6 +969,44 @@ effective_register = lutaml_register)
879
969
  end
880
970
  end
881
971
 
972
+ # Match names for an attribute rule, mirroring how serialization
973
+ # qualifies the same attribute (MappingRule#attribute_match_names):
974
+ # a rule serialized with a prefix matches only attributes in its
975
+ # namespace; a rule serialized unprefixed matches only namespace-less
976
+ # attributes.
977
+ def resolve_attribute_rule_names(rule, attr, options, effective_register,
978
+ instance, instance_is_serialize)
979
+ return rule.namespaced_names(options[:default_namespace]) if attr.nil? ||
980
+ rule.multiple_mappings?
981
+
982
+ if instance_is_serialize
983
+ parent_ns_class, form_default = parent_attribute_context(instance)
984
+ else
985
+ parent_ns_class = nil
986
+ form_default = :unqualified
987
+ end
988
+ rule.attribute_match_names(attr, effective_register, parent_ns_class,
989
+ form_default,
990
+ options[:default_namespace])
991
+ end
992
+
993
+ # [namespace_class, attribute_form_default] for the instance's model,
994
+ # keyed on the model class. Attribute rules of one element share the
995
+ # model, so a 1-entry cache avoids a mappings_for lookup per rule.
996
+ def parent_attribute_context(instance)
997
+ klass = instance.class
998
+ if @parent_ns_klass == klass && @parent_ns_value
999
+ return @parent_ns_value
1000
+ end
1001
+
1002
+ ns_class = klass.mappings_for(:xml)&.namespace_class
1003
+ value = [ns_class,
1004
+ ns_class&.attribute_form_default || :unqualified]
1005
+ @parent_ns_klass = klass
1006
+ @parent_ns_value = value
1007
+ value
1008
+ end
1009
+
882
1010
  # Resolve rule names with type namespace support
883
1011
  #
884
1012
  # @param rule [MappingRule] the mapping rule
@@ -37,18 +37,18 @@ module Lutaml
37
37
  # @param mapping [Xml::Mapping] the XML mapping for this element
38
38
  # @param options [Hash] additional options including mapper_class for recursive calls
39
39
  # @return [NamespaceNeeds] namespace needs structure
40
- def collect(element, mapping, visited: nil, **)
40
+ def collect(element, mapping, visited: nil, **options)
41
41
  # If this is a top-level call (no visited yet), ensure cleanup
42
42
  if visited.nil?
43
43
  Thread.current[:namespace_collector_call_stack] = []
44
44
  begin
45
- collect_internal(element, mapping, visited: nil, **)
45
+ collect_internal(element, mapping, visited: nil, **options)
46
46
  ensure
47
47
  Thread.current[:namespace_collector_call_stack] = nil
48
48
  end
49
49
  else
50
50
  # Nested call - don't clean up yet
51
- collect_internal(element, mapping, visited: visited, **)
51
+ collect_internal(element, mapping, visited: visited, **options)
52
52
  end
53
53
  end
54
54
 
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Per-parse context threaded through ModelTransform's mapping passes.
6
+ #
7
+ # Bundles everything one data_to_model call needs — the parsed doc, the
8
+ # instance under construction, the effective register, and the flags
9
+ # derived from them — so apply_xml_mapping and value_for_rule take one
10
+ # object instead of a growing positional parameter list. Memoizes the
11
+ # per-parse lookups (mapping, namespace class, adopted document
12
+ # namespace) that every rule would otherwise recompute.
13
+ class ParseSession
14
+ attr_reader :doc, :instance, :options, :register
15
+
16
+ def initialize(doc, instance, options, register)
17
+ @doc = doc
18
+ @instance = instance
19
+ @options = options
20
+ @register = register
21
+ end
22
+
23
+ def instance_is_serialize
24
+ @instance_is_serialize ||= instance.is_a?(::Lutaml::Model::Serialize)
25
+ end
26
+
27
+ def model_class
28
+ @model_class ||= instance.class
29
+ end
30
+
31
+ def xml_mapping
32
+ @xml_mapping ||= model_class.mappings_for(:xml, register)
33
+ end
34
+
35
+ # The document's own namespace when it differs from the model's
36
+ # bound namespace (lenient, out-of-namespace documents). Adopted as
37
+ # an implicit alias for element matching (lutaml-model#754).
38
+ def adopted_namespace_uri
39
+ @adopted_namespace_uri ||=
40
+ instance_is_serialize ? instance.original_namespace_uri : nil
41
+ end
42
+
43
+ # Namespace URIs accepted for children of this model: the model's
44
+ # namespace URIs plus the adopted document namespace when present.
45
+ def model_namespace_uris
46
+ @model_namespace_uris ||=
47
+ begin
48
+ ns_class = instance_is_serialize ? xml_mapping&.namespace_class : nil
49
+ uris = ns_class&.all_uris
50
+ adopted = adopted_namespace_uri
51
+ if ns_class && adopted && !uris&.include?(adopted)
52
+ uris = (uris || []) + [adopted]
53
+ end
54
+ uris
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -135,8 +135,8 @@ module Lutaml
135
135
 
136
136
  # @yield [declaration] Yields each unique declaration
137
137
  # @return [self]
138
- def each(&)
139
- @by_prefix.values.flatten.uniq.each(&)
138
+ def each(&block)
139
+ @by_prefix.values.flatten.uniq.each(&block)
140
140
  self
141
141
  end
142
142
 
@@ -20,7 +20,7 @@ module Lutaml
20
20
  # Supported schema builders (separate from XML parsing adapters)
21
21
  SUPPORTED_BUILDERS = %i[nokogiri oga].freeze
22
22
 
23
- def initialize(adapter_type: nil, options: {}, &)
23
+ def initialize(adapter_type: nil, options: {}, &block)
24
24
  # Use specified adapter, or configured XML adapter, or default to Nokogiri
25
25
  requested_adapter = adapter_type || Lutaml::Model::Config.xml_adapter_type || :nokogiri
26
26
 
@@ -32,7 +32,7 @@ module Lutaml
32
32
  :nokogiri
33
33
  end
34
34
  @options = options
35
- @builder = create_builder(&)
35
+ @builder = create_builder(&block)
36
36
  end
37
37
 
38
38
  # Generate the XSD schema XML string
@@ -45,12 +45,12 @@ module Lutaml
45
45
 
46
46
  private
47
47
 
48
- def create_builder(&)
48
+ def create_builder(&block)
49
49
  case @adapter_type
50
50
  when :nokogiri
51
- Nokogiri.new(@options, &)
51
+ Nokogiri.new(@options, &block)
52
52
  when :oga
53
- Oga.new(@options, &)
53
+ Oga.new(@options, &block)
54
54
  end
55
55
  end
56
56
  end
@@ -28,7 +28,7 @@ module Lutaml
28
28
  # @param to [String] The target path
29
29
  # @param pattern [Boolean, nil] Explicit pattern flag (optional)
30
30
  def initialize(attributes = nil, from: nil, to: nil, pattern: nil,
31
- **)
31
+ **options)
32
32
  # Handle hash argument from Lutaml::Model deserialization
33
33
  if attributes.is_a?(Hash)
34
34
  from = attributes[:from] || attributes["from"]
@@ -45,7 +45,7 @@ module Lutaml
45
45
  # Use explicit pattern flag if provided, otherwise use detected value
46
46
  final_pattern = pattern.nil? ? detected_pattern : pattern
47
47
 
48
- super(from: from_str, to: to, pattern: final_pattern, **)
48
+ super(from: from_str, to: to, pattern: final_pattern, **options)
49
49
  end
50
50
 
51
51
  # Convert hash-based mapping to SchemaLocationMapping instance
@@ -141,9 +141,7 @@ module Lutaml
141
141
  # Trigger autoloads for all XSD model classes to ensure they are
142
142
  # registered before parsing begins. This is necessary because
143
143
  # type resolution during parsing looks up classes in the register.
144
- # rubocop:disable Style/RedundantFetchOverride
145
144
  XSD_AUTOLOAD_CLASSES.each { |c| const_get(c) unless c == :VERSION }
146
- # rubocop:enable Style/RedundantFetchOverride
147
145
 
148
146
  # Validate XSD schema structure before parsing (unless disabled)
149
147
  if validate_schema && !nested_schema
@@ -410,7 +410,8 @@ attr_name)
410
410
  def self.get_attribute_xsd_type(attr, attr_type, register,
411
411
  _mapping_rule = nil)
412
412
  if attr.union?
413
- raise Lutaml::Model::UnionSchemaUnsupportedError.new(attr.name, "XSD")
413
+ raise Lutaml::Model::UnionSchemaUnsupportedError.new(attr.name,
414
+ "XSD")
414
415
  end
415
416
 
416
417
  # 1. Check for deprecated attribute-level xsd_type override
@@ -89,7 +89,7 @@ module Lutaml
89
89
  end
90
90
 
91
91
  # Override choice to set format: :xml so Choice knows which format it belongs to.
92
- def choice(min: 1, max: 1, format: :xml, &)
92
+ def choice(min: 1, max: 1, format: :xml, &block)
93
93
  super
94
94
  end
95
95
 
@@ -101,9 +101,9 @@ module Lutaml
101
101
  # @param format [Symbol] The format
102
102
  # @param args [Array] Additional arguments (mapping class for XML)
103
103
  # @param block [Proc] The DSL block
104
- def process_mapping(format, *args, &)
104
+ def process_mapping(format, *args, &block)
105
105
  if format == :xml && args.any? && args.first.is_a?(Class) && args.first < Lutaml::Xml::Mapping
106
- process_xml_mapping_class_inheritance(args.first, &)
106
+ process_xml_mapping_class_inheritance(args.first, &block)
107
107
  else
108
108
  super
109
109
  end
@@ -143,7 +143,8 @@ parent_element_form_default)
143
143
  # Get transformation for the actual class. Unions resolve the member
144
144
  # from the value's own class, like polymorphism.
145
145
  child_transformation = if union
146
- value.class.transformation_for(:xml, register)
146
+ value.class.transformation_for(:xml,
147
+ register)
147
148
  elsif is_polymorphic || is_polymorphic_subtype
148
149
  actual_class.transformation_for(:xml,
149
150
  register)
@@ -326,7 +326,8 @@ compiled_rules, mapping, processed_text_nodes)
326
326
  compiled_rules
327
327
  end
328
328
 
329
- emitted_counts = element_order_coverage(model_instance, compiled_rules)
329
+ emitted_counts = element_order_coverage(model_instance,
330
+ compiled_rules)
330
331
 
331
332
  rules_to_apply.each do |rule|
332
333
  mapping_type = rule.option(:mapping_type)