lutaml-model 0.8.56 → 0.8.106

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/.github/scripts/test_installed_gem.rb +58 -0
  3. data/.github/workflows/downstream-performance.yml +28 -3
  4. data/.github/workflows/js-pr-check.yml +4 -0
  5. data/.github/workflows/js-sync.yml +4 -0
  6. data/.github/workflows/opal.yml +9 -0
  7. data/.github/workflows/rake.yml +6 -41
  8. data/.github/workflows/release.yml +41 -0
  9. data/Gemfile +17 -3
  10. data/bench/bench_alloc_audit.rb +56 -0
  11. data/bench/bench_common.rb +11 -0
  12. data/bench/bench_merge_attempts.rb +49 -0
  13. data/docs/_guides/gem-integration.adoc +85 -0
  14. data/docs/_guides/grammar-backed-formats.adoc +69 -0
  15. data/docs/_guides/index.adoc +8 -1
  16. data/docs/_guides/native-engines.adoc +23 -5
  17. data/docs/_guides/schema-generation.adoc +25 -0
  18. data/docs/_guides/type-registers-contexts.adoc +126 -0
  19. data/lib/compat/opal/lutaml_model_boot.rb +7 -5
  20. data/lib/lutaml/json/adapter/yeptris_adapter.rb +24 -1
  21. data/lib/lutaml/key_value/data_model/element.rb +5 -1
  22. data/lib/lutaml/key_value/mapping_rule.rb +7 -0
  23. data/lib/lutaml/key_value/transform.rb +29 -24
  24. data/lib/lutaml/key_value/transformation/collection_serializer.rb +12 -29
  25. data/lib/lutaml/key_value/transformation/value_serializer.rb +29 -6
  26. data/lib/lutaml/key_value/transformation.rb +64 -116
  27. data/lib/lutaml/model/adapter_resolver.rb +7 -3
  28. data/lib/lutaml/model/attribute.rb +74 -15
  29. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  30. data/lib/lutaml/model/configuration.rb +13 -0
  31. data/lib/lutaml/model/mapping/mapping.rb +13 -0
  32. data/lib/lutaml/model/mapping/mapping_rule.rb +6 -1
  33. data/lib/lutaml/model/render_policy.rb +26 -8
  34. data/lib/lutaml/model/schema/base_schema.rb +15 -0
  35. data/lib/lutaml/model/schema/generator/definition.rb +52 -8
  36. data/lib/lutaml/model/schema/generator/definitions_collection.rb +35 -14
  37. data/lib/lutaml/model/schema.rb +4 -2
  38. data/lib/lutaml/model/serializable.rb +33 -0
  39. data/lib/lutaml/model/serialize/attribute_definition.rb +196 -2
  40. data/lib/lutaml/model/serialize/builder.rb +22 -0
  41. data/lib/lutaml/model/serialize/conversion_caching.rb +5 -0
  42. data/lib/lutaml/model/serialize/format_conversion.rb +49 -41
  43. data/lib/lutaml/model/serialize/initialization.rb +54 -7
  44. data/lib/lutaml/model/serialize/model_import.rb +60 -0
  45. data/lib/lutaml/model/serialize.rb +14 -6
  46. data/lib/lutaml/model/type/time.rb +6 -0
  47. data/lib/lutaml/model/type/value.rb +9 -7
  48. data/lib/lutaml/model/type_resolver.rb +7 -5
  49. data/lib/lutaml/model/utils.rb +18 -5
  50. data/lib/lutaml/model/version.rb +1 -1
  51. data/lib/lutaml/model.rb +7 -0
  52. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -2
  53. data/lib/lutaml/xml/adapter_loader.rb +5 -2
  54. data/lib/lutaml/xml/format.rb +18 -16
  55. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  56. data/lib/lutaml/xml/leptris/plan_compiler.rb +650 -0
  57. data/lib/lutaml/xml/leptris/plan_hydrator.rb +748 -0
  58. data/lib/lutaml/xml/leptris/plan_order.rb +55 -0
  59. data/lib/lutaml/xml/leptris/plan_serializer.rb +702 -0
  60. data/lib/lutaml/xml/leptris/plan_walk.rb +36 -0
  61. data/lib/lutaml/xml/leptris.rb +46 -0
  62. data/lib/lutaml/xml/mapping.rb +71 -18
  63. data/lib/lutaml/xml/model_transform.rb +231 -8
  64. data/lib/lutaml/xml/namespace.rb +18 -8
  65. data/lib/lutaml/xml/namespace_collector.rb +17 -1
  66. data/lib/lutaml/xml/namespace_needs.rb +77 -49
  67. data/lib/lutaml/xml/parse_session.rb +7 -19
  68. data/lib/lutaml/xml/schema/xsd/schema.rb +2 -1
  69. data/lib/lutaml/xml/serialization/format_conversion.rb +5 -1
  70. data/lib/lutaml/xml/transformation/ordered_applier.rb +21 -15
  71. data/lib/lutaml/xml/transformation.rb +15 -1
  72. data/lib/lutaml/xml/type/serializers.rb +8 -1
  73. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +34 -2
  74. data/lib/tasks/performance_probe_plan_walk.rb +1 -1
  75. data/lutaml-model.gemspec +7 -4
  76. data/spec/lutaml/json/yeptris_adapter_spec.rb +7 -2
  77. data/spec/lutaml/key_value/transformation/value_serializer_spec.rb +9 -5
  78. data/spec/lutaml/key_value/transformation_spec.rb +87 -21
  79. data/spec/lutaml/model/any_element_mapping_spec.rb +41 -0
  80. data/spec/lutaml/model/collection_validation_spec.rb +265 -0
  81. data/spec/lutaml/model/desc_spec.rb +50 -0
  82. data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
  83. data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
  84. data/spec/lutaml/model/opal_smoke_spec.rb +17 -0
  85. data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
  86. data/spec/lutaml/model/schema/generation_regressions_spec.rb +139 -0
  87. data/spec/lutaml/model/schema/json_schema_spec.rb +1 -13
  88. data/spec/lutaml/model/schema/yaml_schema_spec.rb +0 -11
  89. data/spec/lutaml/model/serialize/attribute_definition_block_form_spec.rb +244 -0
  90. data/spec/lutaml/model/store_spec.rb +6 -2
  91. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  92. data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
  93. data/spec/lutaml/model/yeptris_error_parity_spec.rb +95 -0
  94. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  95. data/spec/lutaml/xml/adapter/over_cap_parse_spec.rb +105 -0
  96. data/spec/lutaml/xml/adapter_loader_spec.rb +14 -0
  97. data/spec/lutaml/xml/adapter_metadata_default_spec.rb +16 -0
  98. data/spec/lutaml/xml/attribute_ambiguity_surface_spec.rb +5 -0
  99. data/spec/lutaml/xml/clear_parse_state_spec.rb +6 -1
  100. data/spec/lutaml/xml/compiled_rule_namespace_spec.rb +5 -0
  101. data/spec/lutaml/xml/cross_register_plan_spec.rb +75 -0
  102. data/spec/lutaml/xml/doubly_defined_namespace_spec.rb +16 -8
  103. data/spec/lutaml/xml/element_order_tracking_spec.rb +37 -0
  104. data/spec/lutaml/xml/leptris_string_encoding_spec.rb +65 -0
  105. data/spec/lutaml/xml/opal_xml_spec.rb +47 -0
  106. data/spec/lutaml/xml/parse_dom_retention_spec.rb +34 -0
  107. data/spec/lutaml/xml/plan_fast_path_spec.rb +122 -14
  108. data/spec/lutaml/xml/plan_namespaced_serialize_spec.rb +157 -0
  109. data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
  110. data/spec/lutaml/xml/plan_partition_spec.rb +1 -1
  111. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +131 -0
  112. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  113. data/spec/lutaml/xml/plan_walk_spec.rb +6 -6
  114. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +96 -0
  115. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +29 -0
  116. data/spec/person_spec.rb +8 -0
  117. metadata +48 -17
  118. data/lib/lutaml/xml/plan_compiler.rb +0 -332
  119. data/lib/lutaml/xml/plan_hydrator.rb +0 -367
  120. data/lib/lutaml/xml/plan_order.rb +0 -53
  121. data/lib/lutaml/xml/plan_serializer.rb +0 -252
  122. data/lib/lutaml/xml/plan_walk.rb +0 -30
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Leptris
6
+ # Public consumer surface for the plan fast path: one engine
7
+ # parse + descriptor walk and direct model hydration, bypassing
8
+ # the wrapper-tree construction that dominates large-document
9
+ # consumer paths. The returned object is the mapped model root;
10
+ # collection attributes remain collections on that model.
11
+ module PlanWalk
12
+ class << self
13
+ # Build the plan, walk the document, and hydrate the mapped
14
+ # root. Returns nil when the model is not plan-compilable.
15
+ def call(model_class, xml, options = {})
16
+ register = Lutaml::Model::Config.default_register
17
+ plan = PlanCompiler.compile(model_class, register)
18
+ return nil unless plan
19
+
20
+ document = ::Leptris::XML.parse(xml)
21
+ root = document.root
22
+ return nil unless root && root.name == plan[:tree][:name]
23
+
24
+ PlanHydrator.call(model_class, plan,
25
+ plan[:descriptor].walk(root),
26
+ node: root,
27
+ declaration_plan_mode:
28
+ options.fetch(
29
+ :import_declaration_plan, :eager
30
+ ))
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # The leptris plan fast path: model mapping to libleptris descriptor
6
+ # plan, native walk, direct hydration. Everything under this
7
+ # namespace is bound to the leptris gem; nothing above it may
8
+ # reference leptris types directly (the moxml adapters and the
9
+ # interpretive path never enter here).
10
+ module Leptris
11
+ # Single capability floor (leptris 1.9.336.0): the child-handles
12
+ # snapshot (1.9.273), exact-URI attribute rows (#1486, 1.9.289),
13
+ # nested-attribute capture (#1563, 1.9.313), wildcard rows
14
+ # (#1552, 1.9.313), child-row ns_uri retention and qualified
15
+ # attribute leniency (#1585/#1586, 1.9.320), the :unqualified ns
16
+ # form (#1560, 1.9.323), and build_result with the address-based
17
+ # text attach (#408, 1.9.334/1.9.336). Bundles resolving an
18
+ # older gem keep the interpretive path — PlanCompiler.compile
19
+ # answers nil — the opt-out protocol.
20
+ MIN_LEPTRIS_VERSION = "1.9.336.0"
21
+
22
+ # Memoized: the compiler consults this on every cache hit (per
23
+ # child row per node), and the comparison minted a Gem::Version
24
+ # per call — visible in the niso ordered-lane allocation profile.
25
+ # The loaded gem cannot change mid-process.
26
+ def self.plan_path_compatible?
27
+ return @plan_path_compatible if defined?(@plan_path_compatible)
28
+
29
+ @plan_path_compatible =
30
+ if defined?(Gem)
31
+ spec = Gem.loaded_specs["leptris"]
32
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
33
+ else
34
+ false
35
+ end
36
+ end
37
+
38
+ autoload :NativeEmission, "lutaml/xml/leptris/native_emission"
39
+ autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
40
+ autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
41
+ autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
42
+ autoload :PlanSerializer, "lutaml/xml/leptris/plan_serializer"
43
+ autoload :PlanWalk, "lutaml/xml/leptris/plan_walk"
44
+ end
45
+ end
46
+ end
@@ -38,27 +38,14 @@ module Lutaml
38
38
  @xml_instance
39
39
  end
40
40
 
41
- attr_reader :root_element,
42
- :namespace_uri,
43
- :namespace_prefix,
44
- :element_sequence,
45
- :mappings_imported,
46
- :namespace_class,
47
- :namespace_param,
48
- :element_name,
49
- :documentation_text,
50
- :type_name_value,
51
- :namespace_scope,
52
- :mapper_class,
53
- :xml_space,
54
- :consolidation_maps,
55
- :processing_instruction_mappings
41
+ attr_reader :root_element, :namespace_uri, :namespace_prefix, :element_sequence, :mappings_imported, :namespace_class, :namespace_param, :element_name, :documentation_text, :type_name_value, :namespace_scope, :mapper_class, :xml_space, :consolidation_maps, :processing_instruction_mappings, :any_element_rule
56
42
 
57
43
  def initialize
58
44
  super
59
45
 
60
46
  @elements = {}
61
47
  @attributes = {}
48
+ @any_element_rule = nil
62
49
  @element_sequence = []
63
50
  @content_mapping = nil
64
51
  @raw_mapping = nil
@@ -504,6 +491,24 @@ module Lutaml
504
491
  # @return [String, nil] the type name
505
492
  alias xsd_type type_name
506
493
 
494
+ # Catch-all element mapping: every child element with no
495
+ # explicit rule routes to one collection attribute. For
496
+ # parse-side consumption of dynamic vocabularies (semantic__
497
+ # trees); element_order preserves the original tag names.
498
+ # Serialization of a catch-all attribute emits the attribute's
499
+ # declared element name (not per-item originals).
500
+ def map_any_element(to:, render_nil: false, render_default: false)
501
+ validate!(nil, to, {}, render_nil, false, type: TYPES[:element])
502
+ @any_element_rule = MappingRule.new(
503
+ nil,
504
+ to: to,
505
+ render_nil: render_nil,
506
+ render_default: render_default,
507
+ )
508
+ @element_rules_index = nil if instance_variable_defined?(:@element_rules_index)
509
+ @any_element_rule
510
+ end
511
+
507
512
  def map_instances(to:, polymorphic: {})
508
513
  map_element(to, to: to, polymorphic: polymorphic)
509
514
  end
@@ -1192,6 +1197,31 @@ module Lutaml
1192
1197
  # transform applies the collection-attribute filter.
1193
1198
  #
1194
1199
  # @return [Hash{Symbol, String => Array<MappingRule>}]
1200
+ # The when_attribute siblings-vs-plain-names partition of the
1201
+ # element rules. A ParseSession used to rebuild this per parsed
1202
+ # node (sessions are per-element), paying an O(rules) pass on
1203
+ # every node of large documents; it is a pure function of the
1204
+ # rules, so it memoizes here — same lifetime as
1205
+ # plain_element_rules_by_attr.
1206
+ def when_attribute_partition
1207
+ @when_attribute_partition ||= begin
1208
+ siblings = {}
1209
+ plain_names = {}
1210
+ mappings.each do |rule|
1211
+ next if rule.attribute? || rule.content_mapping? ||
1212
+ rule.raw_mapping? || rule.cdata
1213
+
1214
+ pairs = rule.when_attribute
1215
+ if pairs && !pairs.empty?
1216
+ (siblings[rule.name.to_s] ||= []) << rule
1217
+ else
1218
+ plain_names[rule.name.to_s] = true
1219
+ end
1220
+ end
1221
+ [siblings, plain_names]
1222
+ end
1223
+ end
1224
+
1195
1225
  def plain_element_rules_by_attr(register_id = nil)
1196
1226
  reg_key = register_id || :default
1197
1227
  @plain_element_rules_by_attr ||= {}
@@ -1342,9 +1372,9 @@ module Lutaml
1342
1372
  elsif ["text", "#cdata-section"].include?(name.to_s) && type == "Text"
1343
1373
  content_mapping
1344
1374
  else
1345
- candidates = mappings.select do |rule|
1346
- rule.name == name.to_s || rule.name == name.to_sym
1347
- end
1375
+ candidates = element_rules_index[name.to_s]
1376
+ return @any_element_rule if candidates.nil? && @any_element_rule
1377
+ return nil if candidates.nil?
1348
1378
  return candidates.first if namespace_uri.nil? || candidates.one?
1349
1379
 
1350
1380
  candidates.find do |r|
@@ -1353,6 +1383,29 @@ module Lutaml
1353
1383
  end
1354
1384
  end
1355
1385
 
1386
+ # O(1) name -> rules lookup. Parsing consults this once per
1387
+ # child element; the linear scan over every rule made large
1388
+ # documents quadratic (155s on a 7MB vocabulary tree). Keyed on
1389
+ # the stable source hashes (@elements/@attributes mutate only at
1390
+ # DSL time), never on the per-call mappings array.
1391
+ def element_rules_index
1392
+ cache_key = [@elements.object_id, @attributes.object_id]
1393
+ cached = (@element_rules_index ||= {})
1394
+ idx = cached[cache_key]
1395
+ return idx if idx
1396
+
1397
+ idx = {}
1398
+ ([content_mapping, raw_mapping].compact + @elements.values +
1399
+ @attributes.values).flatten.each do |rule|
1400
+ next if rule.is_a?(::Hash)
1401
+
1402
+ (idx[rule.name.to_s] ||= []) << rule
1403
+ end
1404
+ cached[cache_key] = idx
1405
+ idx
1406
+ end
1407
+ private :element_rules_index
1408
+
1356
1409
  def find_by_to(to)
1357
1410
  mappings.detect { |rule| rule.to.to_s == to.to_s }
1358
1411
  end
@@ -114,7 +114,14 @@ module Lutaml
114
114
  # Only needed for root elements (no lutaml_parent in options).
115
115
  # Three modes: :lazy (default), :eager, :skip.
116
116
  if !options.key?(:lutaml_parent)
117
- plan_mode = options.fetch(:import_declaration_plan, :lazy)
117
+ # Default :eager — the plan data is tiny, but :lazy pins the
118
+ # adapter root element on every parsed instance, which keeps the
119
+ # entire native DOM (moxml wrappers + engine nodes) alive until
120
+ # first serialization. Models that are never serialized retain
121
+ # the DOM forever: a 10.5MB XMI held 578MB of wrappers this
122
+ # way. :lazy remains available for parse-serialize-immediately
123
+ # workflows that want to skip the namespace walk.
124
+ plan_mode = options.fetch(:import_declaration_plan, :eager)
118
125
  case plan_mode
119
126
  when :skip
120
127
  # Skip plan building entirely (fastest)
@@ -205,7 +212,9 @@ module Lutaml
205
212
  }
206
213
  end
207
214
 
208
- result[path] = input_namespaces unless input_namespaces.empty?
215
+ # The path array is shared and mutated during the descent;
216
+ # snapshot it as a result key only where a declaration lives.
217
+ result[path.dup] = input_namespaces unless input_namespaces.empty?
209
218
  end
210
219
 
211
220
  # Recurse into children with path extended by child local name
@@ -223,8 +232,9 @@ module Lutaml
223
232
  else
224
233
  full_name
225
234
  end
226
- child_path = path + [child_local_name]
227
- collect_element_namespaces(child, child_path, result, visited)
235
+ path.push(child_local_name)
236
+ collect_element_namespaces(child, path, result, visited)
237
+ path.pop
228
238
  end
229
239
 
230
240
  result
@@ -263,6 +273,9 @@ module Lutaml
263
273
  # Performance: Resolve xml_mapping once — used for both rules and metadata
264
274
  xml_mapping = mappings_for(:xml, effective_register)
265
275
  mappings = options[:mappings] || xml_mapping.mappings(effective_register)
276
+ # Catch-all rule (map_any_element): iterated like any other,
277
+ # claiming every child no explicit rule matches.
278
+ mappings += [xml_mapping.any_element_rule].compact if options[:mappings].nil?
266
279
  default_namespace = options[:default_namespace]
267
280
  ordered_option = options[:ordered]
268
281
  mixed_content_option = options[:mixed_content]
@@ -331,6 +344,13 @@ module Lutaml
331
344
  deserialize_attributes = attributes
332
345
  mappings.each do |rule|
333
346
  record = records&.[](rule)
347
+
348
+ # #903/#922: replay-or-drop unmatched rules; never clobber
349
+ # a mid-parse custom-writer assignment.
350
+ next if replay_unmatched_rule(child_names_set, record, rule,
351
+ instance, defaults_used,
352
+ xml_mapping)
353
+
334
354
  rule_to = rule.to
335
355
 
336
356
  if record
@@ -361,7 +381,19 @@ module Lutaml
361
381
  options
362
382
  end
363
383
 
364
- value = if rule.raw_mapping?
384
+ value = if !rule.attribute? && rule.name.nil? &&
385
+ xml_mapping.any_element_rule.equal?(rule)
386
+ # map_any_element: every child element no explicit
387
+ # element rule claims, in document order (claimed
388
+ # set + scalar text extraction live in
389
+ # any_element_members). A name-less MappingRule is
390
+ # otherwise indistinguishable from a content rule;
391
+ # name-derived lookup re-finds by name, losing
392
+ # order and double-picking.
393
+ any_element_members(
394
+ doc, xml_mapping, attr, effective_register
395
+ )
396
+ elsif rule.raw_mapping?
365
397
  scoped_raw_inner_xml(doc, xml_mapping)
366
398
  elsif rule.content_mapping?
367
399
  rule.cdata ? doc.cdata : doc.text
@@ -369,6 +401,8 @@ module Lutaml
369
401
  group.size > 1 && attr&.collection?
370
402
  # First rule of a #765 group: match every spelling of
371
403
  # the group so the children merge in document order.
404
+ raise "GROUPED-BODY" if ENV["CATCHDBG"] && rule.name.nil?
405
+
372
406
  if child_names_set &&
373
407
  group.drop(1).none? do |s|
374
408
  child_matches_rule?(s, child_names_set,
@@ -385,21 +419,33 @@ module Lutaml
385
419
  value_for_rule(session, rule, new_opts, attr,
386
420
  extra_names)
387
421
  end
388
- elsif child_names_set && !rule.attribute? &&
422
+ elsif child_names_set && !rule.attribute? && rule.name &&
389
423
  !child_matches_rule?(rule, child_names_set,
390
424
  default_namespace)
425
+ raise "FASTSKIP-BODY" if ENV["CATCHDBG"] && rule.name.nil?
426
+
391
427
  # Pre-match: no child element matches this rule.
392
428
  # Skip expensive value_for_rule, handle defaults inline.
393
429
  # NOTE: do not "read the instance" here (to_value_for) —
394
430
  # the compiled getters materialize lazy collections on
395
431
  # first read, which tripled parse allocations when tried.
432
+ if writer_populated?(record, instance)
433
+ instance.value_set_for(rule_to)
434
+ next
435
+ end
396
436
  if instance.using_default?(rule_to) || rule.render_default
397
437
  defaults_used << rule_to
398
438
  attr&.default(effective_register) || rule.to_value_for(instance)
399
439
  else
400
- ::Lutaml::Model::UninitializedClass.instance
440
+ # The attribute already holds an explicit value --
441
+ # assigned by a matched rule or by a custom writer
442
+ # mid-parse (lutaml-model#922). An unmatched rule
443
+ # must not overwrite it with nil.
444
+ next
401
445
  end
402
446
  else
447
+ raise "ELSE-BODY" if ENV["CATCHDBG"] && rule.name.nil?
448
+
403
449
  # Performance: Pass cached attr to avoid recomputing attribute_for_rule
404
450
  val = value_for_rule(session, rule, new_opts, attr)
405
451
 
@@ -465,6 +511,11 @@ module Lutaml
465
511
  def set_instance_ordering(instance, doc, ordered_option,
466
512
  mixed_content_option, xml_mapping = nil,
467
513
  instance_is_serialize = nil)
514
+ # #909: read-only consumers skip the per-instance order summaries
515
+ # entirely — element_order stays nil and to_xml falls back to
516
+ # mapping-defined order for untracked instances.
517
+ return unless Lutaml::Model::Config.instance.element_order_tracking
518
+
468
519
  # dup: XmlElement#order hands back a frozen cache shared with the
469
520
  # DOM. The model's copy has to stay mutable so callers can maintain
470
521
  # element_order themselves.
@@ -805,7 +856,9 @@ _effective_register)
805
856
  Lutaml::Xml::HtmlEntities.decode(value)
806
857
  end
807
858
 
808
- Record = Struct.new(:attr, :derived, :group_skip, :valid)
859
+ Record = Struct.new(:attr, :derived, :group_skip, :valid,
860
+ :unmatched_mode, :replay_writer,
861
+ :unmatched_names, :raw_reader)
809
862
 
810
863
  def compiled_rule_records(model_class, register, grouped_plain_rules)
811
864
  return {}.compare_by_identity unless model_class.include?(Lutaml::Model::Serialize)
@@ -824,12 +877,85 @@ _effective_register)
824
877
  mapping&.mappings(register)&.each do |rule| # rubocop:disable Style/SafeNavigationChain,Style/SafeNavigation
825
878
  attr = attribute_for_rule_static(model_class, rule, register)
826
879
  group = grouped_plain_rules[rule.to]
880
+ # #903: for a plain rule, what an unmatched visit writes is
881
+ # known statically. The fast-skip branch hands the attribute's
882
+ # uninitialized sentinel to the pipeline; with no value-map
883
+ # overrides and no transformers every step is identity except
884
+ # apply_value_map, which maps the :omitted key: the default
885
+ # map (:nil) yields nil and the visit writes nil through the
886
+ # setter; an explicit omitted: :omitted map treats it as
887
+ # treat_omitted? == false and the omission gate ends the
888
+ # visit untouched. Both endings replay without the walk:
889
+ # :write_nil (setter write + flag churn) or :drop (nothing).
890
+ # Everything else -- defaults, aliases, custom from:,
891
+ # delegates, when_attribute dispatch, transformers, value
892
+ # maps beyond those two -- keeps its visit.
893
+ unmatched_mode = nil
894
+ replay_writer = nil
895
+ if attr && !attr.derived? &&
896
+ !rule.render_default && !rule.multiple_mappings? &&
897
+ !rule.has_custom_method_for_deserialization? &&
898
+ !rule.delegate &&
899
+ !(rule.when_attribute && !rule.when_attribute.empty?)
900
+ rule_transform = rule.transform
901
+ attr_transform = attr.transform
902
+ no_transforms =
903
+ (rule_transform.nil? ||
904
+ (rule_transform.respond_to?(:empty?) &&
905
+ rule_transform.empty?)) &&
906
+ (attr_transform.nil? ||
907
+ (attr_transform.respond_to?(:empty?) &&
908
+ attr_transform.empty?))
909
+ rule_default = attr.default(register)
910
+ if no_transforms &&
911
+ (rule_default.nil? ||
912
+ rule_default.is_a?(::Lutaml::Model::UninitializedClass)) &&
913
+ rule.class.transform_dispatch(rule, attr) == :assign
914
+ if group && group.size > 1 && attr.collection? &&
915
+ !group.first.equal?(rule)
916
+ # Non-first spelling of a #765 group: the visit is a
917
+ # guarded next (the first member does the merged match).
918
+ unmatched_mode = :drop
919
+ else
920
+ omitted_val = rule.value_map(:from)[:omitted]
921
+ if omitted_val == :nil
922
+ unmatched_mode = :write_nil
923
+ elsif omitted_val == :omitted
924
+ unmatched_mode = :drop
925
+ end
926
+ end
927
+ end
928
+ end
929
+ # A first group member does the merged match for every
930
+ # spelling, so its name-miss test spans the whole group.
931
+ unmatched_names = if group && group.size > 1
932
+ group.map { |r| r.name.to_s }
933
+ else
934
+ [rule.name.to_s]
935
+ end
936
+ if unmatched_mode == :write_nil
937
+ replay_writer =
938
+ rule.class.parsed_assign_writer(model_class, rule.to)
939
+ unmatched_mode = nil unless replay_writer
940
+ end
827
941
  records[rule] = Record.new(
828
942
  attr,
829
943
  attr&.derived? || false,
830
944
  !!(group && group.size > 1 && attr&.collection? &&
831
945
  !group.first.equal?(rule)),
832
946
  !!(attr || rule.custom_methods[:from]),
947
+ unmatched_mode,
948
+ replay_writer,
949
+ unmatched_names,
950
+ # lutaml-model#922: raw-state reader for the attribute,
951
+ # when the compiled init seeds it (plain names -- the same
952
+ # set compile_state_defaults! writes). Delegated rules name
953
+ # an attribute on the delegate object, not on this
954
+ # instance, so they get no raw reader.
955
+ if attr && rule.delegate.nil? &&
956
+ rule.to.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
957
+ :"__lutaml_raw_#{rule.to}"
958
+ end,
833
959
  )
834
960
  end
835
961
  entry = Struct.new(:version, :records).new(version, records)
@@ -1458,6 +1584,36 @@ effective_register = lutaml_register)
1458
1584
  end
1459
1585
  end
1460
1586
 
1587
+ # map_any_element members: every child element no explicit
1588
+ # element rule claims, in document order; scalar targets take
1589
+ # the captured elements' text — the same normalize step scalar
1590
+ # elements get (the cast pipeline receives strings, not wrappers
1591
+ # whose to_s is their inspect); Serializable targets keep the
1592
+ # wrappers and cast into instances.
1593
+ def any_element_members(doc, mapping, attr, register)
1594
+ claimed = Set.new(
1595
+ mapping.mappings(register)
1596
+ .select { |r| !r.attribute? && r.name }
1597
+ .flat_map { |r| [r.name.to_s] },
1598
+ )
1599
+ unclaimed = doc.element_children.reject do |child|
1600
+ claimed.include?(child.unprefixed_name)
1601
+ end
1602
+ return unclaimed if attr.nil? ||
1603
+ attr_type_is_serializable(attr, register)
1604
+
1605
+ unclaimed.map do |child|
1606
+ child_text = child.nil_element? ? nil : child&.text
1607
+ child_cdata = child&.cdata
1608
+ text = if child_text.is_a?(Array) || child_cdata.is_a?(Array)
1609
+ nil
1610
+ else
1611
+ child_text&.+ child_cdata
1612
+ end
1613
+ text.nil? ? child : text
1614
+ end
1615
+ end
1616
+
1461
1617
  def attr_type_is_serializable(attr, effective_register)
1462
1618
  attr&.serializable_type?(effective_register) || false
1463
1619
  end
@@ -1509,6 +1665,73 @@ effective_register = lutaml_register)
1509
1665
  # @param child_names_set [Set] Set of child namespaced_name and unprefixed_name
1510
1666
  # @param default_namespace [String, nil] the default namespace
1511
1667
  # @return [Boolean] true if a matching child likely exists
1668
+ # #903: for an unmatched named element rule whose record classed
1669
+ # it, replay what the visit would write without the walk: :drop
1670
+ # (the visit ended at the omission gate or was a guarded next)
1671
+ # writes nothing; :write_nil replays the nil the default value
1672
+ # map produces, plus the flag churn. Returns true when handled.
1673
+ # lutaml-model#922: whether the attribute holds anything other
1674
+ # than its initialized marker (uninitialized sentinel / lazy
1675
+ # collection) — i.e. a custom writer populated it mid-parse.
1676
+ def writer_populated?(record, instance)
1677
+ return false unless record&.raw_reader
1678
+
1679
+ !raw_initialized?(record, instance)
1680
+ end
1681
+
1682
+ # True when the attribute still holds the value the compiled init
1683
+ # seeded (uninitialized sentinel / lazy collection) — i.e. nothing
1684
+ # has written it since.
1685
+ def raw_initialized?(record, instance)
1686
+ raw = instance.public_send(record.raw_reader)
1687
+ raw.equal?(::Lutaml::Model::UninitializedClass.instance) ||
1688
+ raw.equal?(::Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
1689
+ end
1690
+
1691
+ def replay_unmatched_rule(child_names_set, record, rule, instance,
1692
+ defaults_used, xml_mapping)
1693
+ return false unless child_names_set && record
1694
+
1695
+ mode = record.unmatched_mode
1696
+ return false unless mode
1697
+ return false if rule.attribute? || rule.content_mapping? ||
1698
+ rule.raw_mapping? || !rule.name ||
1699
+ xml_mapping.any_element_rule.equal?(rule)
1700
+
1701
+ matched = record.unmatched_names.any? do |n|
1702
+ child_names_set.include?(n)
1703
+ end
1704
+ return false if matched
1705
+
1706
+ return true if mode == :drop
1707
+
1708
+ # The visited path picks the default branch only while the
1709
+ # attribute still reads as default-valued; once another rule
1710
+ # or a custom writer set it mid-parse, the omission gate ends
1711
+ # the visit with no write. Mirror that: replay writes only
1712
+ # while unset, and drops otherwise (a custom writer may have
1713
+ # populated the attribute — relaton's ICS Isoics-fallback
1714
+ # pattern).
1715
+ rule_to = rule.to
1716
+ unless instance.using_default?(rule_to)
1717
+ return true
1718
+ end
1719
+
1720
+ # lutaml-model#922: a custom writer may have populated the
1721
+ # attribute mid-parse without going through the tracker (the
1722
+ # compiled setters are direct ivar writes), so the tracked
1723
+ # flag above can lie. The raw state is the truth: still at its
1724
+ # initialized marker (sentinel / lazy collection) means unset
1725
+ # -- the visited path wrote the default there; anything else
1726
+ # is a writer assignment this replay must not clobber.
1727
+ return true unless raw_initialized?(record, instance)
1728
+
1729
+ defaults_used << rule_to
1730
+ instance.public_send(record.replay_writer, nil)
1731
+ instance.value_set_for(rule_to)
1732
+ true
1733
+ end
1734
+
1512
1735
  def child_matches_rule?(rule, child_names_set, default_namespace)
1513
1736
  rule_names = rule.namespaced_names(default_namespace)
1514
1737
  # Array#intersect? requires an Array argument; child_names_set may
@@ -58,7 +58,10 @@ module Lutaml
58
58
  # @param value [String, nil] the namespace URI
59
59
  # @return [String, nil] the namespace URI
60
60
  def uri(value = nil)
61
- @uri_value = value if value
61
+ if value
62
+ @uri_value = value
63
+ @to_key = nil
64
+ end
62
65
  @uri_value
63
66
  end
64
67
 
@@ -130,6 +133,7 @@ module Lutaml
130
133
  def prefix_default(value = nil)
131
134
  if value
132
135
  @prefix_default_value = value.to_s
136
+ @to_key = nil
133
137
  end
134
138
  @prefix_default_value
135
139
  end
@@ -286,13 +290,19 @@ module Lutaml
286
290
  #
287
291
  # @api private
288
292
  def to_key
289
- prefix = prefix_default
290
- namespace_uri = uri
291
-
292
- if prefix && !prefix.empty?
293
- "#{prefix}:#{namespace_uri}"
294
- else
295
- ":#{namespace_uri}"
293
+ # Called per element on the serialize path (90k births per
294
+ # 200-paragraph to_xml); memoized since uri/prefix_default
295
+ # are set at class-definition time. The setters above
296
+ # invalidate on reconfiguration.
297
+ @to_key ||= begin
298
+ prefix = prefix_default
299
+ namespace_uri = uri
300
+
301
+ if prefix && !prefix.empty?
302
+ "#{prefix}:#{namespace_uri}"
303
+ else
304
+ ":#{namespace_uri}"
305
+ end
296
306
  end
297
307
  end
298
308
 
@@ -409,9 +409,25 @@ module Lutaml
409
409
  # Handle collections and arrays
410
410
  if child_instance.is_a?(Array) || child_instance.is_a?(Lutaml::Model::Collection)
411
411
  instances = child_instance.is_a?(Lutaml::Model::Collection) ? child_instance.collection : child_instance
412
- # Collect needs from all instances and merge
412
+ # Collect needs from the FIRST instance of each distinct
413
+ # class and merge once — instances of one class share the
414
+ # mapping graph, so re-collecting every member re-walks
415
+ # identical structure (row-heavy models spent most of
416
+ # their serialize budget here). Value-dependent divergences
417
+ # in later members surface as unspellable forms on the
418
+ # serializer and fall back interpretively, never as wrong
419
+ # output. Merge-once is equivalent to merge-N: every read
420
+ # path of the merged needs is any?/set-based.
413
421
  merged_needs = NamespaceNeeds.new
422
+ seen_classes = nil
414
423
  instances.each do |item|
424
+ item_class = item.class
425
+ if seen_classes
426
+ next if seen_classes[item_class]
427
+ else
428
+ seen_classes = {}
429
+ end
430
+ seen_classes[item_class] = true
415
431
  item_ns_prefix = if item.is_a?(::Lutaml::Model::Serialize)
416
432
  item.xml_namespace_prefix
417
433
  end