lutaml-model 0.8.18 → 0.8.22

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 (123) 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/builder-dsl.adoc +127 -0
  11. data/docs/_guides/value-transformations.adoc +27 -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/format.rb +61 -0
  18. data/lib/lutaml/json.rb +4 -59
  19. data/lib/lutaml/jsonl/format.rb +22 -0
  20. data/lib/lutaml/jsonl.rb +5 -19
  21. data/lib/lutaml/key_value/format.rb +26 -0
  22. data/lib/lutaml/key_value/transform.rb +4 -1
  23. data/lib/lutaml/key_value/transformation/value_serializer.rb +0 -2
  24. data/lib/lutaml/key_value.rb +4 -21
  25. data/lib/lutaml/model/attribute.rb +38 -4
  26. data/lib/lutaml/model/collection.rb +2 -2
  27. data/lib/lutaml/model/compiled_rule.rb +1 -1
  28. data/lib/lutaml/model/config.rb +2 -2
  29. data/lib/lutaml/model/consolidation_map.rb +2 -2
  30. data/lib/lutaml/model/consolidation_rule/dispatch_block.rb +2 -2
  31. data/lib/lutaml/model/errors.rb +2 -2
  32. data/lib/lutaml/model/global_context.rb +4 -2
  33. data/lib/lutaml/model/mapping/mapping_rule.rb +4 -1
  34. data/lib/lutaml/model/mapping/model_mapping.rb +2 -2
  35. data/lib/lutaml/model/runtime_compatibility.rb +2 -2
  36. data/lib/lutaml/model/schema/class_loader.rb +2 -1
  37. data/lib/lutaml/model/schema/decorators/definition_collection.rb +4 -4
  38. data/lib/lutaml/model/schema/definitions/namespace.rb +2 -1
  39. data/lib/lutaml/model/schema/file_writer.rb +5 -2
  40. data/lib/lutaml/model/schema/renderers/base.rb +4 -3
  41. data/lib/lutaml/model/schema/renderers/mappings.rb +9 -3
  42. data/lib/lutaml/model/schema/renderers/member_decls.rb +9 -3
  43. data/lib/lutaml/model/schema/renderers/model.rb +18 -6
  44. data/lib/lutaml/model/schema/renderers/required_files_calculator.rb +2 -1
  45. data/lib/lutaml/model/schema/renderers.rb +2 -1
  46. data/lib/lutaml/model/schema/rnc_compiler/source_resolver.rb +6 -2
  47. data/lib/lutaml/model/schema/rnc_compiler.rb +3 -1
  48. data/lib/lutaml/model/schema/rng_compiler/element_visitor.rb +35 -15
  49. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +4 -2
  50. data/lib/lutaml/model/schema/rng_compiler.rb +10 -5
  51. data/lib/lutaml/model/schema/xml_compiler/element_order.rb +2 -1
  52. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +4 -2
  53. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +39 -13
  54. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +29 -10
  55. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +2 -2
  56. data/lib/lutaml/model/schema/xml_compiler.rb +10 -5
  57. data/lib/lutaml/model/sequence.rb +4 -4
  58. data/lib/lutaml/model/serialize/attribute_definition.rb +10 -9
  59. data/lib/lutaml/model/serialize/builder.rb +55 -0
  60. data/lib/lutaml/model/serialize/format_conversion.rb +2 -2
  61. data/lib/lutaml/model/serialize/initialization.rb +12 -3
  62. data/lib/lutaml/model/serialize/model_import.rb +1 -0
  63. data/lib/lutaml/model/serialize.rb +4 -3
  64. data/lib/lutaml/model/type/uninitialized_class_guard.rb +1 -1
  65. data/lib/lutaml/model/type.rb +9 -0
  66. data/lib/lutaml/model/type_registry.rb +2 -2
  67. data/lib/lutaml/model/uninitialized_class.rb +1 -1
  68. data/lib/lutaml/model/union.rb +5 -2
  69. data/lib/lutaml/model/utils.rb +2 -2
  70. data/lib/lutaml/model/value_policy.rb +27 -0
  71. data/lib/lutaml/model/version.rb +1 -1
  72. data/lib/lutaml/model.rb +17 -16
  73. data/lib/lutaml/rdf/mapping.rb +2 -2
  74. data/lib/lutaml/rdf/namespace_set.rb +2 -2
  75. data/lib/lutaml/rdf/transform.rb +2 -2
  76. data/lib/lutaml/toml/format.rb +48 -0
  77. data/lib/lutaml/toml.rb +5 -46
  78. data/lib/lutaml/xml/adapter/base_adapter.rb +2 -2
  79. data/lib/lutaml/xml/builder/base.rb +2 -2
  80. data/lib/lutaml/xml/format.rb +331 -0
  81. data/lib/lutaml/xml/mapping.rb +2 -2
  82. data/lib/lutaml/xml/mapping_rule.rb +62 -3
  83. data/lib/lutaml/xml/model_transform.rb +132 -55
  84. data/lib/lutaml/xml/namespace_collector.rb +3 -3
  85. data/lib/lutaml/xml/parse_session.rb +59 -0
  86. data/lib/lutaml/xml/parsed_namespace_set.rb +2 -2
  87. data/lib/lutaml/xml/schema/builder.rb +5 -5
  88. data/lib/lutaml/xml/schema/xsd/schema_location_mapping.rb +2 -2
  89. data/lib/lutaml/xml/schema/xsd.rb +0 -2
  90. data/lib/lutaml/xml/schema/xsd_schema.rb +2 -1
  91. data/lib/lutaml/xml/serialization/format_conversion.rb +3 -3
  92. data/lib/lutaml/xml/transformation/element_builder.rb +2 -1
  93. data/lib/lutaml/xml/transformation/ordered_applier.rb +99 -4
  94. data/lib/lutaml/xml/xml_element.rb +2 -0
  95. data/lib/lutaml/xml.rb +4 -328
  96. data/lib/lutaml/yaml/format.rb +30 -0
  97. data/lib/lutaml/yaml.rb +5 -28
  98. data/lib/lutaml/yamls/adapter/mapping.rb +2 -2
  99. data/lib/lutaml/yamls/format.rb +22 -0
  100. data/lib/lutaml/yamls.rb +5 -19
  101. data/lib/tasks/benchmark_runner.rb +2 -2
  102. data/lutaml-model.gemspec +1 -1
  103. data/spec/lutaml/model/attribute_apply_value_map_spec.rb +26 -13
  104. data/spec/lutaml/model/attribute_spec.rb +8 -3
  105. data/spec/lutaml/model/custom_bibtex_adapter_spec.rb +2 -2
  106. data/spec/lutaml/model/custom_from_empty_values_spec.rb +74 -0
  107. data/spec/lutaml/model/custom_vobject_adapter_spec.rb +8 -8
  108. data/spec/lutaml/model/defaults_spec.rb +45 -0
  109. data/spec/lutaml/model/json_spec.rb +1 -2
  110. data/spec/lutaml/model/ordered_content_spec.rb +18 -7
  111. data/spec/lutaml/model/serialize/builder_spec.rb +361 -0
  112. data/spec/lutaml/model/store_spec.rb +3 -1
  113. data/spec/lutaml/model/transform_apply_value_map_spec.rb +6 -3
  114. data/spec/lutaml/model/union_attribute_spec.rb +4 -2
  115. data/spec/lutaml/model/value_policy_spec.rb +82 -0
  116. data/spec/lutaml/require_load_order_spec.rb +25 -0
  117. data/spec/lutaml/xml/attribute_element_namespace_disjoint_spec.rb +88 -0
  118. data/spec/lutaml/xml/attribute_namespace_disjointness_spec.rb +125 -0
  119. data/spec/lutaml/xml/out_of_namespace_parsing_spec.rb +123 -0
  120. data/spec/lutaml/xml/schema/rnc_compiler_spec.rb +17 -6
  121. data/spec/lutaml/xml/schema/rng_compiler_spec.rb +15 -6
  122. data/spec/lutaml/xml/w3c_types_spec.rb +3 -6
  123. metadata +22 -4
@@ -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)
@@ -303,8 +298,7 @@ effective_register = nil, instance_is_serialize = nil)
303
298
  end
304
299
  else
305
300
  # 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)
301
+ val = value_for_rule(session, rule, new_opts, attr)
308
302
 
309
303
  if (val.nil? || ::Lutaml::Model::Utils.uninitialized?(val)) &&
310
304
  (instance.using_default?(rule_to) || rule.render_default)
@@ -437,13 +431,20 @@ _effective_register)
437
431
  # - URI format: "http://.../namespace:attr" or "urn:...:attr"
438
432
  # - Simple prefix format: "my-ns:attr" (namespace URI is a simple string, not a URI)
439
433
  # - 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|
434
+ # Performance: most rules carry a single name — skip the map and
435
+ # its result array entirely on that path.
436
+ if rule_names.size == 1
437
+ rn = rule_names[0]
442
438
  converted = convert_rule_name_to_attribute_name(doc, rn)
443
- converted || rn
444
- end
439
+ value = doc.root.find_attribute_value(converted || rn)
440
+ else
441
+ attribute_names = rule_names.map do |rn|
442
+ converted = convert_rule_name_to_attribute_name(doc, rn)
443
+ converted || rn
444
+ end
445
445
 
446
- value = doc.root.find_attribute_value(attribute_names)
446
+ value = doc.root.find_attribute_value(attribute_names)
447
+ end
447
448
 
448
449
  # Fallback: if value is nil, try to match by local name only.
449
450
  # This handles the case where the namespace prefix is not declared in the document
@@ -468,25 +469,36 @@ _effective_register)
468
469
  def convert_rule_name_to_attribute_name(doc, rule_name)
469
470
  return nil unless rule_name.include?(":")
470
471
 
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:")
472
+ # URI-vs-prefix detection works on the whole rule name: the
473
+ # namespace part is a prefix of rule_name, so "://" anywhere in
474
+ # the name means a URI-form namespace, and a "urn:" namespace
475
+ # makes the whole name start with "urn:". Slicing the parts out
476
+ # first would allocate two strings per call on this hot path.
477
+ is_uri_format = rule_name.include?("://") || rule_name.start_with?("urn:")
480
478
 
479
+ last_colon_index = rule_name.rindex(":")
481
480
  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
481
+ # URI format: find the attribute whose resolved namespace and
482
+ # local name spell the rule name. Comparison is by namespace
483
+ # prefix + local suffix so prefixed attributes (whose
484
+ # namespaced_name is "prefix:local", never "uri:local") still
485
+ # match, and unprefixed_name is not called per attribute
486
+ # (it splits prefixed names and allocates).
487
+ local_name = rule_name[(last_colon_index + 1)..]
488
+ doc.root.attributes.each_value.find do |attr|
489
+ ns = attr.namespace
490
+ next false if ns.nil? || ns.length > last_colon_index
491
+ next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
492
+
493
+ attr.unprefixed_name == local_name
485
494
  end&.name
486
495
  else
487
496
  # Simple prefix format: look up the actual prefix from document's namespace declarations
488
497
  # The namespace_part is the namespace URI declared in the document (e.g., "my-ns").
489
498
  # Find the corresponding prefix (e.g., "my") and build "my:val".
499
+ namespace_part = rule_name[0...last_colon_index]
500
+ local_name = rule_name[(last_colon_index + 1)..]
501
+
490
502
  ns_data = doc.root.namespaces.values.find do |nd|
491
503
  nd.uri == namespace_part
492
504
  end
@@ -507,31 +519,43 @@ _effective_register)
507
519
 
508
520
  last_colon_index = rn.rindex(":")
509
521
  local_name = rn[(last_colon_index + 1)..]
522
+ rule_uri = rn[0...last_colon_index]
523
+
524
+ matched_attr = doc.root.attributes.each_value.find do |attr|
525
+ # Local-name fallback serves two lenient cases only:
526
+ # - an attribute with an undeclared prefix (raw name keeps the
527
+ # colon, namespace unresolvable), the historical purpose;
528
+ # - an attribute whose resolved namespace IS one of the rule's
529
+ # namespace URIs (prefix rebinding, lutaml-model#744).
530
+ # Everything else — including a definitively namespace-less
531
+ # attribute and an attribute from a foreign namespace — must
532
+ # not satisfy the fallback, or a plain rule steals a
533
+ # namespaced sibling attribute (lutaml-model#758). An
534
+ # XmlAttribute with a resolved namespace has namespaced_name
535
+ # "URI:local", so this comparison is allocation-free.
536
+ if attr.namespace.nil?
537
+ next false if attr.namespace_prefix.nil? &&
538
+ !attr.name.include?(":")
510
539
 
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
540
  attr.unprefixed_name == local_name ||
516
- (attr.namespace.nil? && ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name)
541
+ ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
542
+ else
543
+ # Prefixed attributes carry namespaced_name "prefix:local",
544
+ # which a URI-form rule name can never equal — match by the
545
+ # attribute's resolved namespace instead.
546
+ attr.namespace == rule_uri && attr.unprefixed_name == local_name
547
+ end
517
548
  end
518
549
  return matched_attr&.value if matched_attr
519
550
  end
520
551
  nil
521
552
  end
522
553
 
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
554
+ def value_for_rule(session, rule, options, cached_attr = nil)
555
+ doc = session.doc
556
+ instance = session.instance
557
+ instance_is_serialize = session.instance_is_serialize
558
+ effective_register = session.register
535
559
 
536
560
  # Performance: Use cached attr from caller if available
537
561
  attr = cached_attr || attribute_for_rule(rule)
@@ -544,9 +568,14 @@ _effective_register)
544
568
  rule_namespace = rule_namespace_set ? rule.namespace : nil
545
569
  options[:default_namespace]
546
570
 
547
- # Enhanced namespace resolution with type support
548
- rule_names = resolve_rule_names_with_type(rule, attr, options,
549
- effective_register, attr_type)
571
+ rule_names = if rule.attribute?
572
+ resolve_attribute_rule_names(rule, attr, options,
573
+ effective_register,
574
+ instance, instance_is_serialize)
575
+ else
576
+ resolve_rule_names_with_type(rule, attr, options,
577
+ effective_register, attr_type)
578
+ end
550
579
 
551
580
  return value_for_xml_attribute(doc, rule, rule_names) if rule.attribute?
552
581
 
@@ -565,15 +594,25 @@ _effective_register)
565
594
  end
566
595
  type_ns_prefix_str = type_ns_class&.prefix_default&.to_s
567
596
  type_ns_all_uris = type_ns_class&.all_uris
597
+ # Adopt the parsed document's namespace (memoized on the session):
598
+ # when the whole document is out-of-namespace, treat it as an
599
+ # implicit alias so lenient parsing is lossless (#754).
600
+ adopted_uri = session.adopted_namespace_uri
601
+ if adopted_uri && !type_ns_all_uris&.include?(adopted_uri)
602
+ type_ns_all_uris = (type_ns_all_uris || []) + [adopted_uri]
603
+ end
568
604
  end
569
605
 
570
606
  # 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)
607
+ # (Third branch in select loop — memoized on the session)
572
608
  model_ns_class = if instance_is_serialize &&
573
609
  !rule_namespace_set && !attr_type_is_serializable
574
- instance.class.mappings_for(:xml)&.namespace_class
610
+ session.xml_mapping&.namespace_class
575
611
  end
576
- model_ns_all_uris = model_ns_class&.all_uris
612
+ # Includes the adopted document namespace (#754): a prefixed child
613
+ # in the document's (foreign) namespace must still bind by local
614
+ # name, or its content is silently dropped.
615
+ model_ns_all_uris = model_ns_class ? session.model_namespace_uris : nil
577
616
 
578
617
  # Performance: Use cached element children from XmlElement
579
618
  # This avoids O(children * rules) scans by pre-filtering once per element
@@ -879,6 +918,44 @@ effective_register = lutaml_register)
879
918
  end
880
919
  end
881
920
 
921
+ # Match names for an attribute rule, mirroring how serialization
922
+ # qualifies the same attribute (MappingRule#attribute_match_names):
923
+ # a rule serialized with a prefix matches only attributes in its
924
+ # namespace; a rule serialized unprefixed matches only namespace-less
925
+ # attributes.
926
+ def resolve_attribute_rule_names(rule, attr, options, effective_register,
927
+ instance, instance_is_serialize)
928
+ return rule.namespaced_names(options[:default_namespace]) if attr.nil? ||
929
+ rule.multiple_mappings?
930
+
931
+ if instance_is_serialize
932
+ parent_ns_class, form_default = parent_attribute_context(instance)
933
+ else
934
+ parent_ns_class = nil
935
+ form_default = :unqualified
936
+ end
937
+ rule.attribute_match_names(attr, effective_register, parent_ns_class,
938
+ form_default,
939
+ options[:default_namespace])
940
+ end
941
+
942
+ # [namespace_class, attribute_form_default] for the instance's model,
943
+ # keyed on the model class. Attribute rules of one element share the
944
+ # model, so a 1-entry cache avoids a mappings_for lookup per rule.
945
+ def parent_attribute_context(instance)
946
+ klass = instance.class
947
+ if @parent_ns_klass == klass && @parent_ns_value
948
+ return @parent_ns_value
949
+ end
950
+
951
+ ns_class = klass.mappings_for(:xml)&.namespace_class
952
+ value = [ns_class,
953
+ ns_class&.attribute_form_default || :unqualified]
954
+ @parent_ns_klass = klass
955
+ @parent_ns_value = value
956
+ value
957
+ end
958
+
882
959
  # Resolve rule names with type namespace support
883
960
  #
884
961
  # @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)
@@ -297,7 +297,16 @@ _options)
297
297
  end
298
298
  end
299
299
 
300
- # Apply remaining rules (attributes and content/raw)
300
+ # Apply remaining rules (attributes, content/raw, and any element
301
+ # rules not represented in element_order).
302
+ #
303
+ # The element-type skip that used to live here caused silent data
304
+ # loss whenever an element-typed attribute was missing from
305
+ # element_order (e.g. when a direct setter forgot to call
306
+ # track_order). After the builder-side fix all mutation paths
307
+ # record into element_order, so this branch is a defense-in-depth
308
+ # safety net: emit any element-typed rule whose value would
309
+ # otherwise vanish.
301
310
  #
302
311
  # @param root [XmlElement] Root element
303
312
  # @param model_instance [Object] The model instance
@@ -317,21 +326,107 @@ compiled_rules, mapping, processed_text_nodes)
317
326
  compiled_rules
318
327
  end
319
328
 
329
+ emitted_counts = element_order_coverage(model_instance,
330
+ compiled_rules)
331
+
320
332
  rules_to_apply.each do |rule|
321
- next if rule.option(:mapping_type) == :element
333
+ mapping_type = rule.option(:mapping_type)
322
334
 
323
- # Skip content rules if we processed text nodes from element_order
324
- if %i[content raw].include?(rule.option(:mapping_type)) &&
335
+ # Skip content/raw if mixed or text nodes were processed
336
+ if %i[content raw].include?(mapping_type) &&
325
337
  (mapping&.mixed_content? || processed_text_nodes)
326
338
  next
327
339
  end
328
340
 
341
+ if mapping_type == :element &&
342
+ element_rule_already_emitted?(rule, model_instance,
343
+ emitted_counts)
344
+ next
345
+ end
346
+
329
347
  next unless valid_mapping?(rule, options)
330
348
 
331
349
  yield(:apply_rule, rule, nil) if block_given?
332
350
  end
333
351
  end
334
352
 
353
+ # Count, per element-typed rule, how many entries in element_order
354
+ # already cover it. Returns a Hash keyed by CompiledRule identity.
355
+ #
356
+ # @param model_instance [Object] The model instance
357
+ # @param compiled_rules [Array<CompiledRule>] The compiled rules
358
+ # @return [Hash<CompiledRule, Integer>] Coverage counts
359
+ def element_order_coverage(model_instance, compiled_rules)
360
+ counts = ::Hash.new(0)
361
+ return counts unless model_instance.respond_to?(:element_order)
362
+ return counts unless (order = model_instance.element_order)
363
+
364
+ element_rules = compiled_rules.select do |r|
365
+ r.is_a?(::Lutaml::Model::CompiledRule) &&
366
+ r.option(:mapping_type) == :element
367
+ end
368
+
369
+ order.each do |object|
370
+ next unless object.type == "Element"
371
+
372
+ object_ns_uri = object.namespace_uri
373
+ matched = element_rules.find do |r|
374
+ matches_element_rule?(r, object.name, object_ns_uri)
375
+ end
376
+ counts[matched] += 1 if matched
377
+ end
378
+
379
+ counts
380
+ end
381
+
382
+ # Whether an element-typed rule has already been fully emitted
383
+ # via element_order (or should not be emitted at all by the safety
384
+ # net). Returns true when:
385
+ # - the model was not constructed via builder block
386
+ # (`@__order_tracking__` is nil/false): parsed models trust
387
+ # element_order as the complete source of truth, so the safety
388
+ # net must not second-guess it by emitting defaults/uninitialized
389
+ # values that the standard path would otherwise have skipped.
390
+ # - the standard skip logic says the value should be skipped
391
+ # (handles defaults, render_nil/render_empty, value_map, etc.)
392
+ # - or the rule was fully covered by element_order entries
393
+ #
394
+ # The safety net targets the bug class where a builder-block
395
+ # construction bypassed element_order tracking (e.g. via a
396
+ # mutation path that forgot to call record_mutation). After
397
+ # Option A, all setter/getter paths record into element_order,
398
+ # so this branch is defense-in-depth rather than the common
399
+ # path.
400
+ #
401
+ # @param rule [CompiledRule] The element rule
402
+ # @param model_instance [Object] The model instance
403
+ # @param emitted_counts [Hash<CompiledRule, Integer>] Coverage map
404
+ # @return [Boolean]
405
+ def element_rule_already_emitted?(rule, model_instance,
406
+ emitted_counts)
407
+ return true unless model_order_tracking_enabled?(model_instance)
408
+
409
+ value = extract_ordered_rule_value(rule, model_instance)
410
+ return true if should_skip_value?(value, rule, model_instance)
411
+
412
+ emitted = emitted_counts[rule]
413
+ if rule.collection?
414
+ value_length = value.respond_to?(:length) ? value.length : 0
415
+ emitted >= value_length
416
+ else
417
+ emitted.positive?
418
+ end
419
+ end
420
+
421
+ # Whether the model was constructed via a builder block and thus
422
+ # has order tracking enabled. Only such models are candidates for
423
+ # the safety net; parsed models trust element_order as-is.
424
+ def model_order_tracking_enabled?(model_instance)
425
+ return false unless model_instance.respond_to?(:order_tracking_enabled?)
426
+
427
+ model_instance.order_tracking_enabled?
428
+ end
429
+
335
430
  # Sort compiled rules so attribute rules follow the captured attribute_order.
336
431
  # Non-attribute rules (content, raw) maintain their original position.
337
432
  #
@@ -360,6 +360,8 @@ module Lutaml
360
360
  end
361
361
 
362
362
  def find_attribute_value(attribute_name)
363
+ return if attributes.empty?
364
+
363
365
  # Performance: Use hash index for O(1) lookup instead of linear scan
364
366
  ensure_attribute_index
365
367