lutaml-model 0.8.42 → 0.8.44

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/docs/_guides/advanced-mapping.adoc +66 -0
  3. data/docs/_tutorials/polymorphic-models-tutorial.adoc +2 -0
  4. data/lib/lutaml/key_value/mapping.rb +3 -1
  5. data/lib/lutaml/key_value/mapping_rule.rb +4 -1
  6. data/lib/lutaml/key_value/transform.rb +2 -0
  7. data/lib/lutaml/key_value/transformation/rule_compiler.rb +3 -0
  8. data/lib/lutaml/model/collection_handler.rb +11 -1
  9. data/lib/lutaml/model/mapping/mapping_rule.rb +23 -1
  10. data/lib/lutaml/model/serialize/initialization.rb +7 -2
  11. data/lib/lutaml/model/serialize.rb +12 -3
  12. data/lib/lutaml/model/version.rb +1 -1
  13. data/lib/lutaml/xml/adapter_element.rb +2 -1
  14. data/lib/lutaml/xml/element.rb +8 -2
  15. data/lib/lutaml/xml/mapping.rb +20 -1
  16. data/lib/lutaml/xml/mapping_rule.rb +13 -1
  17. data/lib/lutaml/xml/model_transform.rb +69 -5
  18. data/lib/lutaml/xml/parse_session.rb +33 -2
  19. data/lib/lutaml/xml/transformation/element_builder.rb +25 -6
  20. data/lib/lutaml/xml/transformation/order_reconciler.rb +16 -1
  21. data/lib/lutaml/xml/transformation/ordered_applier.rb +73 -2
  22. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
  23. data/lib/lutaml/xml/xml_element.rb +20 -1
  24. data/spec/lutaml/key_value/transformation/rule_compiler_spec.rb +16 -6
  25. data/spec/lutaml/key_value/transformation_spec.rb +4 -1
  26. data/spec/lutaml/model/attributes_memo_clear_race_spec.rb +90 -0
  27. data/spec/lutaml/model/hydrate_only_mapping_spec.rb +47 -0
  28. data/spec/lutaml/xml/when_attribute_ordered_spec.rb +148 -0
  29. data/spec/lutaml/xml/when_attribute_spec.rb +225 -0
  30. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cd747883eee7ea6a87aea0c708e6054f1f6cf9f7b06e295b32f1b7ba06a8a75
4
- data.tar.gz: 639b21ad4dd77104b3bce40f0ba7bad925a5b608806e6e8979b50cb68f57b70e
3
+ metadata.gz: 57902ec64a7b7915f601d0525d747ec742c78a18bd7e78469c0af41aa35d2101
4
+ data.tar.gz: a27ee76ad1f66aaacb324c5ba79f75dca698c3f729c2e61706ed4cbe85c0b255
5
5
  SHA512:
6
- metadata.gz: 05174a52193320261f496e92ac656b6ddc5a47167db5b98b3682c0397a4c9fb0f2a58f20e8be689d7f35486bea63dc2228d367574c3834587be6d5ba97306cd9
7
- data.tar.gz: a6efef58f862390afe6fcf903ce3ff4d829d451079b7a1762990ed0e0bf1c5623c798c712a485d8e81cdc415189a37e9c5f7b1c02dd2d03ad6c17d96b5bac5a0
6
+ metadata.gz: fd6d918fcd42d655682bc769f9c047860a3bba698258137f638cafd789f7b8ee0e176c89a12c84f09310245aaa227bc33d3c344c255591b14527a691acca6088
7
+ data.tar.gz: 36bf72d194bc8ed3e5ddb8837c55f5dd3170006ecf37cfc26004f46133fc6684e267d56123577e06b9ca9fe2d3dfcab838a21b6bd3b884409613ea364276329e
@@ -562,3 +562,69 @@ end
562
562
  ====
563
563
 
564
564
 
565
+
566
+ === Attribute-value dispatch on a shared element name
567
+
568
+ When several distinct attributes share one wire element, selected by a
569
+ sibling attribute's value, declare the discriminator with `when_attribute:`
570
+
571
+ [source,ruby]
572
+ ----
573
+ class Requirement < Lutaml::Model::Serializable
574
+ attribute :guidance, Component, collection: true
575
+ attribute :purpose, Component, collection: true
576
+
577
+ xml do
578
+ element "requirement"
579
+ map_element "component", to: :guidance,
580
+ when_attribute: { "type" => "guidance" }
581
+ map_element "component", to: :purpose,
582
+ when_attribute: { "type" => "purpose" }
583
+ end
584
+ end
585
+ ----
586
+
587
+ Each rule hydrates only from the `<component>` occurrences whose `type`
588
+ attribute carries the expected value, in document order; uncovered values
589
+ are ignored by all discriminator rules. On serialization the discriminator
590
+ pair is re-emitted on the element (unless the value's own mapping already
591
+ writes that attribute).
592
+
593
+ Co-occurrence semantics: several `when_attribute` rules may share the
594
+ element name; each target attribute fills independently, so declare each
595
+ as `collection: true` when the element repeats. A rule without
596
+ `when_attribute` that shares the name only claims occurrences no
597
+ discriminator rule claimed, so every occurrence is captured exactly once.
598
+
599
+ `when_attribute` and `polymorphic:` are two different dispatch axes over
600
+ the same discriminator idea — pick by what the discriminator selects:
601
+
602
+ * `polymorphic: { attribute:, class_map: }` — the value selects the
603
+ **class** each occurrence hydrates into; ONE attribute holds every
604
+ occurrence. See the polymorphic models tutorial.
605
+ * `when_attribute:` — the value selects **which attribute** claims the
606
+ occurrence; the declared type stays fixed.
607
+
608
+ They compose: a rule may partition with `when_attribute` while its
609
+ attribute dispatches classes with `polymorphic`.
610
+
611
+ Under an `ordered` mapping, serialization preserves the original
612
+ interleaving: `element_order` records each occurrence's attributes, and
613
+ the ordered walk routes every occurrence back to the rule whose
614
+ discriminator it satisfies.
615
+
616
+ [source,ruby]
617
+ ----
618
+ xml do
619
+ element "requirement"
620
+ ordered
621
+ map_element "component", to: :guidance,
622
+ when_attribute: { "type" => "guidance" }
623
+ map_element "component", to: :purpose,
624
+ when_attribute: { "type" => "purpose" }
625
+ end
626
+ ----
627
+
628
+ Given `<component type="guidance">`, `<component type="purpose">`,
629
+ `<component type="guidance">` in the source, `to_xml` re-emits exactly
630
+ that guidance, purpose, guidance sequence.
@@ -9,6 +9,8 @@ nav_order: 8
9
9
 
10
10
  Polymorphic attributes allow an attribute to accept multiple types of values. This is useful when attributes share common characteristics but have type-specific behaviors.
11
11
 
12
+ NOTE: Polymorphism dispatches the *class* of each hydrated item into one attribute. If the discriminator instead needs to route occurrences of a shared element name into *different attributes* (e.g. `component[@type='guidance']` → `guidance`, `component[@type='purpose']` → `purpose`), that is `when_attribute:` in the advanced mapping guide — the two compose on one rule.
13
+
12
14
  == Understanding polymorphism
13
15
 
14
16
  A polymorphic attribute can accept instances of different classes that share a common base class.
@@ -78,7 +78,8 @@ module Lutaml
78
78
  polymorphic: {},
79
79
  polymorphic_map: {},
80
80
  transform: {},
81
- value_map: {}
81
+ value_map: {},
82
+ serialize: true
82
83
  )
83
84
  mapping_name = name_for_mapping(root_mappings, name)
84
85
  validate!(mapping_name, to, with, render_nil, render_empty)
@@ -100,6 +101,7 @@ module Lutaml
100
101
  polymorphic_map: polymorphic_map,
101
102
  transform: transform,
102
103
  value_map: value_map,
104
+ serialize: serialize,
103
105
  )
104
106
  end
105
107
 
@@ -22,7 +22,8 @@ module Lutaml
22
22
  polymorphic: {},
23
23
  polymorphic_map: {},
24
24
  transform: {},
25
- value_map: {}
25
+ value_map: {},
26
+ serialize: true
26
27
  )
27
28
  super(
28
29
  name,
@@ -41,6 +42,7 @@ module Lutaml
41
42
  polymorphic_map: polymorphic_map,
42
43
  transform: transform,
43
44
  value_map: value_map,
45
+ serialize: serialize,
44
46
  )
45
47
 
46
48
  @child_mappings = child_mappings
@@ -65,6 +67,7 @@ module Lutaml
65
67
  child_mappings: Lutaml::Model::Utils.deep_dup(child_mappings),
66
68
  root_mappings: Lutaml::Model::Utils.deep_dup(root_mappings),
67
69
  value_map: Lutaml::Model::Utils.deep_dup(@value_map),
70
+ serialize: serialize?,
68
71
  )
69
72
  end
70
73
 
@@ -127,6 +127,8 @@ module Lutaml
127
127
  end
128
128
 
129
129
  def valid_mapping?(rule, options)
130
+ return false if rule.respond_to?(:serialize?) && !rule.serialize?
131
+
130
132
  only = options[:only]
131
133
  except = options[:except]
132
134
  name = rule.to
@@ -230,6 +230,7 @@ transformation_factory:)
230
230
  custom_methods: custom_methods,
231
231
  delegate: delegate,
232
232
  root_mappings: mapping_rule.root_mappings,
233
+ serialize: mapping_rule.serialize?,
233
234
  )
234
235
  end
235
236
 
@@ -239,6 +240,8 @@ transformation_factory:)
239
240
  # @param options [Hash] Transformation options (may contain :only, :except)
240
241
  # @return [Boolean] true if the rule should be applied
241
242
  def valid_mapping?(rule, options)
243
+ return false if rule.option(:serialize) == false
244
+
242
245
  only = options[:only]
243
246
  except = options[:except]
244
247
  name = rule.attribute_name
@@ -61,7 +61,17 @@ module Lutaml
61
61
  # @param register [Symbol, nil] Register for custom-collection type resolution
62
62
  # @return [Object] A new collection instance
63
63
  def build_collection(*args, register: nil)
64
- items = args.flatten
64
+ # args.flatten allocated a fresh Array even for the dominant
65
+ # calls: no args, or one already-flat Array from cast_items
66
+ # (71,949 flatten allocations per ISO-13849 parse).
67
+ items = if args.empty?
68
+ []
69
+ elsif args.length == 1 && (first = args[0]).is_a?(::Array) &&
70
+ first.none?(::Array)
71
+ first
72
+ else
73
+ args.flatten
74
+ end
65
75
  unless register && initialize_accepts_register?
66
76
  return collection_class.new(items)
67
77
  end
@@ -70,7 +70,9 @@ module Lutaml
70
70
  polymorphic: {},
71
71
  polymorphic_map: {},
72
72
  transform: {},
73
- value_map: {}
73
+ value_map: {},
74
+ serialize: true,
75
+ when_attribute: {}
74
76
  )
75
77
  @name = name
76
78
  @to = to
@@ -92,6 +94,11 @@ module Lutaml
92
94
  @polymorphic = polymorphic
93
95
  @polymorphic_map = polymorphic_map
94
96
  @transform = transform
97
+ @serialize_mapping = serialize
98
+ # Attribute-value discriminator (lutaml-model#88): select this
99
+ # rule's wire occurrences by a sibling attribute value, e.g.
100
+ # `when_attribute: { "type" => "guidance" }`.
101
+ @when_attribute = (when_attribute || {}).freeze
95
102
 
96
103
  # Cache whether this rule needs the full deserialize chain.
97
104
  # Over 95% of rules are "simple" (no custom method, no delegate).
@@ -174,6 +181,11 @@ module Lutaml
174
181
 
175
182
  alias from name
176
183
  alias render_default? render_default
184
+
185
+ # Whether this rule participates in serialization output. False
186
+ # marks a hydrate-only mapping: consumed by from_* but never
187
+ # rendered by to_*. (The value callback #serialize is separate.)
188
+ def serialize? = @serialize_mapping
177
189
  alias attribute? attribute
178
190
 
179
191
  def render?(value, instance = nil, options = {})
@@ -328,6 +340,16 @@ context = nil)
328
340
  # each time (240k allocations on the ISO-13849 serialize profile).
329
341
  # Rules may be frozen, so no per-instance memo: one to_s instead
330
342
  # of two is the frozen-safe diet.
343
+ # Discriminator pairs this rule selects (and re-emits) on, empty
344
+ # for an ordinary rule.
345
+ def when_attribute
346
+ @when_attribute ||= {}.freeze
347
+ end
348
+
349
+ def when_attribute?
350
+ !when_attribute.empty?
351
+ end
352
+
331
353
  def custom_method_only?
332
354
  name = attribute_name.to_s
333
355
  (name.start_with?("__") && name.end_with?("__")) ||
@@ -133,8 +133,13 @@ module Lutaml
133
133
  return cached if cached
134
134
 
135
135
  ensure_imports!(register) if finalized?
136
- @merged_attributes_cache[register_id] ||=
137
- @attributes.merge(@register_records[register_id][:attributes])
136
+ # ensure_imports! may re-entrantly run clear_cache (restrict /
137
+ # import resolution), which nils @merged_attributes_cache —
138
+ # compute the merge first and re-materialize the memo store
139
+ # at write time (#815: standalone to_xml on register-bound
140
+ # models crashed with nil[] here).
141
+ merged = @attributes.merge(@register_records[register_id][:attributes])
142
+ (@merged_attributes_cache ||= {})[register_id] = merged
138
143
  else
139
144
  ensure_imports!(register) if finalized?
140
145
  @attributes
@@ -241,12 +241,21 @@ module Lutaml
241
241
  end
242
242
 
243
243
  def respond_to_missing?(method_name, include_private = false)
244
- (method_name.to_s.end_with?("=") && attribute_exist?(method_name)) ||
245
- super
244
+ # Setter probes from non-compiled dispatch land here ~24k times per
245
+ # ISO-13849 parse; strip the suffix once instead of re-walking
246
+ # attribute_exist?'s to_s/chomp/to_sym chain.
247
+ name = method_name.to_s
248
+ if name.end_with?("=") &&
249
+ self.class.attributes(lutaml_register)
250
+ .key?(name.delete_suffix("=").to_sym)
251
+ return true
252
+ end
253
+
254
+ super
246
255
  end
247
256
 
248
257
  def attribute_exist?(name)
249
- name = name.to_s.chomp("=").to_sym if name.end_with?("=")
258
+ name = name.to_s.delete_suffix("=").to_sym if name.end_with?("=")
250
259
 
251
260
  self.class.attributes(lutaml_register).key?(name)
252
261
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.42"
5
+ VERSION = "0.8.44"
6
6
  end
7
7
  end
@@ -302,7 +302,8 @@ module Lutaml
302
302
  Lutaml::Xml::Element.new("Element", child.unprefixed_name,
303
303
  node_type: :element,
304
304
  namespace_uri: child.namespace_uri,
305
- namespace_prefix: child.namespace_prefix)
305
+ namespace_prefix: child.namespace_prefix,
306
+ attributes: order_entry_attributes(child))
306
307
  end
307
308
  end
308
309
  end.each(&:freeze).freeze
@@ -4,7 +4,7 @@ module Lutaml
4
4
  include Lutaml::Model::Liquefiable
5
5
 
6
6
  attr_reader :type, :name, :text_content, :node_type, :namespace_uri,
7
- :namespace_prefix
7
+ :namespace_prefix, :attributes
8
8
 
9
9
  # Create a new Element for order tracking
10
10
  #
@@ -14,8 +14,13 @@ module Lutaml
14
14
  # @param node_type [Symbol, nil] The node type (:text, :cdata, :element, :comment, :processing_instruction)
15
15
  # @param namespace_uri [String, nil] The namespace URI of this element
16
16
  # @param namespace_prefix [String, nil] The namespace prefix of this element
17
+ # @param attributes [::Hash{String=>String}, nil] The element's
18
+ # attributes keyed by namespaced name. Only element entries parsed
19
+ # from a document carry them, so `when_attribute` rules can tell
20
+ # same-name entries apart on ordered serialization.
17
21
  def initialize(type, name, text_content: nil, node_type: nil,
18
- namespace_uri: nil, namespace_prefix: nil)
22
+ namespace_uri: nil, namespace_prefix: nil,
23
+ attributes: nil)
19
24
  @type = type # "Text" or "Element" - deprecated, kept for backward compatibility
20
25
  @name = name
21
26
  # Infer node_type from type for backward compatibility if not provided
@@ -26,6 +31,7 @@ module Lutaml
26
31
  @text_content ||= name if text? || cdata?
27
32
  @namespace_uri = namespace_uri
28
33
  @namespace_prefix = namespace_prefix
34
+ @attributes = attributes&.freeze
29
35
  end
30
36
 
31
37
  # Check if this is a text content node (not CDATA)
@@ -527,6 +527,7 @@ module Lutaml
527
527
  nil),
528
528
  transform: {},
529
529
  value_map: {},
530
+ when_attribute: {},
530
531
  form: nil,
531
532
  documentation: nil,
532
533
  xsd_type: (xsd_type_provided = false
@@ -537,6 +538,8 @@ module Lutaml
537
538
  name, to, with, render_nil, render_empty, type: TYPES[:element]
538
539
  )
539
540
 
541
+ validate_when_attribute!(when_attribute) unless when_attribute.empty?
542
+
540
543
  # Warn if prefix parameter is provided
541
544
  if prefix_provided != false
542
545
  warn "[DEPRECATED] The prefix parameter on map_element is deprecated. " \
@@ -584,6 +587,7 @@ module Lutaml
584
587
  form: form,
585
588
  documentation: documentation,
586
589
  raw: raw,
590
+ when_attribute: when_attribute,
587
591
  )
588
592
  # Store rules with the same element name in an array to support
589
593
  # multiple mapping rules for the same element name with different target types
@@ -607,6 +611,20 @@ module Lutaml
607
611
  end
608
612
  end
609
613
 
614
+ # lutaml-model#88: attribute-value discriminator. Keys are wire
615
+ # attribute names, values the expected string value.
616
+ def validate_when_attribute!(when_attribute)
617
+ when_attribute.each do |k, v|
618
+ next if (k.is_a?(::String) || k.is_a?(::Symbol)) &&
619
+ (v.is_a?(::String) || v.is_a?(::Symbol))
620
+
621
+ raise Lutaml::Model::IncorrectMappingArgumentsError,
622
+ "when_attribute expects string/symbol attribute names " \
623
+ "mapped to string/symbol values, got " \
624
+ "#{k.inspect} => #{v.inspect}"
625
+ end
626
+ end
627
+
610
628
  def map_attribute(
611
629
  name,
612
630
  to: nil,
@@ -1150,7 +1168,8 @@ module Lutaml
1150
1168
  grouped = {}
1151
1169
  mappings(reg_key).each do |r|
1152
1170
  next if r.attribute? || r.raw_mapping? || r.content_mapping? ||
1153
- r.cdata || r.has_custom_method_for_deserialization? ||
1171
+ r.cdata || r.when_attribute? ||
1172
+ r.has_custom_method_for_deserialization? ||
1154
1173
  (r.transform.is_a?(Hash) && !r.transform.empty?) ||
1155
1174
  r.transform.is_a?(Class)
1156
1175
 
@@ -16,7 +16,7 @@ module Lutaml
16
16
  # Writers for deep_dup (preserves exact object references)
17
17
  attr_accessor :namespace, :prefix, :namespace_class
18
18
 
19
- def initialize(
19
+ def initialize( # rubocop:disable Metrics/ParameterLists -- mirrors the DSL surface
20
20
  name,
21
21
  to:,
22
22
  render_nil: false,
@@ -37,6 +37,7 @@ module Lutaml
37
37
  polymorphic_map: {},
38
38
  transform: {},
39
39
  value_map: {},
40
+ when_attribute: {},
40
41
  as_list: nil,
41
42
  delimiter: nil,
42
43
  form: nil,
@@ -59,6 +60,7 @@ module Lutaml
59
60
  polymorphic_map: polymorphic_map,
60
61
  transform: transform,
61
62
  value_map: value_map,
63
+ when_attribute: when_attribute,
62
64
  )
63
65
 
64
66
  # Store original namespace parameter to preserve :inherit symbol
@@ -225,6 +227,15 @@ module Lutaml
225
227
  end
226
228
  end
227
229
 
230
+ # Whether a parsed element satisfies this rule's when_attribute
231
+ # discriminator: every expected attribute value matches.
232
+ def matches_when_attribute?(element)
233
+ when_attribute.all? do |name, expected|
234
+ actual = element.find_attribute_value(name.to_s)
235
+ !actual.nil? && actual.to_s == expected.to_s
236
+ end
237
+ end
238
+
228
239
  def deep_dup
229
240
  # Preserve @namespace_param exactly as it was (string, Class, :inherit, or nil)
230
241
  # This ensures the duplicate has the same internal state as the original
@@ -261,6 +272,7 @@ module Lutaml
261
272
  form: @form,
262
273
  documentation: @documentation,
263
274
  raw: @raw,
275
+ when_attribute: when_attribute,
264
276
  ).tap do |dup_rule|
265
277
  # Manually preserve the exact @namespace_class object to avoid
266
278
  # recreating anonymous classes (which would have different object_ids)
@@ -40,6 +40,10 @@ module Lutaml
40
40
  # Performance: Frozen empty hash to reduce allocations
41
41
  EMPTY_HASH = {}.freeze
42
42
 
43
+ # ":".getbyte(0) — getbyte comparisons avoid the per-candidate
44
+ # string slice in namespace-prefix checks on the lenient paths.
45
+ COLON_BYTE = 58
46
+
43
47
  def data_to_model(data, _format, options = {})
44
48
  # Use child's own default register if it has one
45
49
  # This ensures versioned schemas (e.g., MML v2 with lutaml_default_register = :mml_v2)
@@ -600,6 +604,21 @@ _effective_register)
600
604
  def convert_rule_name_to_attribute_name(doc, rule_name, session: nil)
601
605
  return nil unless rule_name.include?(":")
602
606
 
607
+ # Pure function of (element attributes, spelling) — the same
608
+ # conversions repeat per rule application (~450k find/slice
609
+ # allocations per ISO-13849 parse before this memo).
610
+ conversions = session&.rule_name_resolution(doc.root)
611
+ if conversions
612
+ return conversions[rule_name] if conversions.key?(rule_name)
613
+
614
+ converted = convert_rule_name_uncached(doc, rule_name, session)
615
+ conversions[rule_name] = converted
616
+ else
617
+ convert_rule_name_uncached(doc, rule_name, session)
618
+ end
619
+ end
620
+
621
+ def convert_rule_name_uncached(doc, rule_name, session)
603
622
  # URI-vs-prefix detection works on the whole rule name: the
604
623
  # namespace part is a prefix of rule_name, so "://" anywhere in
605
624
  # the name means a URI-form namespace, and a "urn:" namespace
@@ -620,10 +639,13 @@ _effective_register)
620
639
  candidates = session.element_local_attribute_index(doc.root)[local_name]
621
640
  # No candidate carries this local name: nothing in the
622
641
  # element can match, the scan is skipped entirely.
642
+ # getbyte, not a [i] slice — the slice allocated one
643
+ # string per probed candidate (~380k per ISO-13849 parse).
623
644
  matched = candidates&.find do |attr|
624
645
  ns = attr.namespace
625
646
  next false if ns.nil? || ns.length > last_colon_index
626
- next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
647
+ next false unless rule_name.start_with?(ns) &&
648
+ rule_name.getbyte(ns.length) == COLON_BYTE
627
649
 
628
650
  attr.unprefixed_name == local_name
629
651
  end
@@ -631,7 +653,8 @@ _effective_register)
631
653
  matched = doc.root.attributes.each_value.find do |attr|
632
654
  ns = attr.namespace
633
655
  next false if ns.nil? || ns.length > last_colon_index
634
- next false unless rule_name.start_with?(ns) && rule_name[ns.length] == ":"
656
+ next false unless rule_name.start_with?(ns) &&
657
+ rule_name.getbyte(ns.length) == COLON_BYTE
635
658
 
636
659
  attr.unprefixed_name == local_name
637
660
  end
@@ -661,13 +684,27 @@ _effective_register)
661
684
  def find_attribute_by_local_name(doc, rule_names,
662
685
  flexible_local: false, session: nil)
663
686
  root_index = session&.element_local_attribute_index(doc.root)
687
+ # Matched VALUE per (element, spelling, flexible flag) — pure
688
+ # while the element is parse-frozen.
689
+ memo = if session
690
+ session.lenient_local_matches(doc.root,
691
+ flexible_local)
692
+ else
693
+ nil
694
+ end
664
695
 
665
696
  rule_names.each do |rule_name|
666
697
  next unless rule_name.include?(":")
667
698
 
668
- resolved = match_attribute_by_local_name(
669
- doc, rule_name, root_index, flexible_local
670
- )
699
+ resolved = if memo&.key?(rule_name)
700
+ memo[rule_name]
701
+ else
702
+ match = match_attribute_by_local_name(
703
+ doc, rule_name, root_index, flexible_local
704
+ )
705
+ memo[rule_name] = match if memo
706
+ match
707
+ end
671
708
  return resolved if resolved
672
709
  end
673
710
  nil
@@ -686,6 +723,11 @@ _effective_register)
686
723
  local_name, rule_uri = parts
687
724
 
688
725
  candidates = root_index&.[](local_name)
726
+ # The index carries every local form of every attribute on the
727
+ # element — a miss means no attribute can match, so the full
728
+ # each_value scan below is dead whenever the index exists.
729
+ return nil if root_index && !candidates
730
+
689
731
  matched_attr = if candidates
690
732
  candidates.find do |attr|
691
733
  local_name_match?(attr, local_name, rule_uri,
@@ -1009,6 +1051,28 @@ _effective_register)
1009
1051
  end
1010
1052
  end
1011
1053
 
1054
+ # lutaml-model#88: attribute-value discriminator — among the
1055
+ # name-matched occurrences, keep only those whose sibling
1056
+ # attributes carry the expected values. A plain rule sharing the
1057
+ # name with discriminator rules gets the complement — occurrences
1058
+ # no discriminator claimed — so each occurrence is captured
1059
+ # exactly once, mirroring ordered-serialization routing.
1060
+ if rule.when_attribute?
1061
+ children = children.select do |child|
1062
+ rule.matches_when_attribute?(child)
1063
+ end
1064
+ else
1065
+ siblings = session.when_attribute_siblings_by_name
1066
+ unless siblings.empty?
1067
+ claimed = rule_names.filter_map { |n| siblings[n] }.flatten
1068
+ unless claimed.empty?
1069
+ children = children.reject do |child|
1070
+ claimed.any? { |sibling| sibling.matches_when_attribute?(child) }
1071
+ end
1072
+ end
1073
+ end
1074
+ end
1075
+
1012
1076
  # Performance: Cache rule method check before loop to avoid repeated method calls
1013
1077
  rule_has_custom_method = rule.has_custom_method_for_deserialization?
1014
1078
  if rule_has_custom_method || attr_type == ::Lutaml::Model::Type::Hash
@@ -29,8 +29,17 @@ module Lutaml
29
29
  # functions of the element and the name; ISO-13849-shaped
30
30
  # documents repeat the same resolutions per rule application, so
31
31
  # the computed answer is memoized per element for the parse.
32
- def rule_name_resolution
33
- @rule_name_resolution ||= {}
32
+ def rule_name_resolution(element)
33
+ @rule_name_resolution ||= {}.compare_by_identity
34
+ @rule_name_resolution[element] ||= {}
35
+ end
36
+
37
+ # Lenient local-name fallback matches, per (element, flexible_local):
38
+ # spelling -> matched attribute VALUE. Same purity argument as
39
+ # rule_name_resolution — elements are parse-frozen.
40
+ def lenient_local_matches(element, flexible_local)
41
+ @lenient_local_matches ||= {}.compare_by_identity
42
+ (@lenient_local_matches[element] ||= {})[flexible_local] ||= {}
34
43
  end
35
44
 
36
45
  # Local-name -> [attributes] index over one element's attributes,
@@ -56,6 +65,28 @@ module Lutaml
56
65
  end
57
66
  end
58
67
 
68
+ # lutaml-model#88: element wire names partitioned by `when_attribute`
69
+ # rules, as name -> discriminator rules. A plain rule on a
70
+ # partitioned name captures only occurrences no discriminator
71
+ # claimed — the mirror of ordered-serialization routing, so each
72
+ # occurrence is captured exactly once instead of double-captured by
73
+ # the plain rule and its discriminator sibling. Empty unless the
74
+ # mapping uses when_attribute at all; non-Serialize custom models
75
+ # have no mappings to partition.
76
+ def when_attribute_siblings_by_name
77
+ @when_attribute_siblings_by_name ||=
78
+ if instance_is_serialize && (mapping = xml_mapping)
79
+ mapping.mappings.each_with_object({}) do |rule, index|
80
+ pairs = rule.when_attribute
81
+ next if pairs.nil? || pairs.empty?
82
+
83
+ (index[rule.name.to_s] ||= []) << rule
84
+ end
85
+ else
86
+ {}
87
+ end
88
+ end
89
+
59
90
  def model_class
60
91
  @model_class ||= instance.class
61
92
  end
@@ -42,12 +42,31 @@ register_id, register)
42
42
  rule.attribute_type < Lutaml::Model::Serialize
43
43
  end
44
44
 
45
- if is_nested_model
46
- create_nested_model_element(rule, value, options, register,
47
- union: union)
48
- else
49
- create_simple_value_element(rule, value, options, model_class,
50
- register_id)
45
+ element = if is_nested_model
46
+ create_nested_model_element(rule, value, options,
47
+ register, union: union)
48
+ else
49
+ create_simple_value_element(rule, value, options,
50
+ model_class, register_id)
51
+ end
52
+ apply_when_attribute(element, rule)
53
+ element
54
+ end
55
+
56
+ # lutaml-model#88: re-emit the discriminator attributes on the
57
+ # element created for this rule, unless the value's own mapping
58
+ # already wrote them.
59
+ def apply_when_attribute(element, rule)
60
+ pairs = rule.option(:when_attribute)
61
+ return if pairs.nil? || pairs.empty?
62
+ return if element.nil?
63
+
64
+ pairs.each do |name, expected|
65
+ next if element.attributes.any? { |a| a.name == name.to_s }
66
+
67
+ element.attributes << ::Lutaml::Xml::DataModel::XmlAttribute.new(
68
+ name.to_s, expected.to_s
69
+ )
51
70
  end
52
71
  end
53
72