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.
- checksums.yaml +4 -4
- data/docs/_guides/advanced-mapping.adoc +66 -0
- data/docs/_tutorials/polymorphic-models-tutorial.adoc +2 -0
- data/lib/lutaml/key_value/mapping.rb +3 -1
- data/lib/lutaml/key_value/mapping_rule.rb +4 -1
- data/lib/lutaml/key_value/transform.rb +2 -0
- data/lib/lutaml/key_value/transformation/rule_compiler.rb +3 -0
- data/lib/lutaml/model/collection_handler.rb +11 -1
- data/lib/lutaml/model/mapping/mapping_rule.rb +23 -1
- data/lib/lutaml/model/serialize/initialization.rb +7 -2
- data/lib/lutaml/model/serialize.rb +12 -3
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/adapter_element.rb +2 -1
- data/lib/lutaml/xml/element.rb +8 -2
- data/lib/lutaml/xml/mapping.rb +20 -1
- data/lib/lutaml/xml/mapping_rule.rb +13 -1
- data/lib/lutaml/xml/model_transform.rb +69 -5
- data/lib/lutaml/xml/parse_session.rb +33 -2
- data/lib/lutaml/xml/transformation/element_builder.rb +25 -6
- data/lib/lutaml/xml/transformation/order_reconciler.rb +16 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +73 -2
- data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
- data/lib/lutaml/xml/xml_element.rb +20 -1
- data/spec/lutaml/key_value/transformation/rule_compiler_spec.rb +16 -6
- data/spec/lutaml/key_value/transformation_spec.rb +4 -1
- data/spec/lutaml/model/attributes_memo_clear_race_spec.rb +90 -0
- data/spec/lutaml/model/hydrate_only_mapping_spec.rb +47 -0
- data/spec/lutaml/xml/when_attribute_ordered_spec.rb +148 -0
- data/spec/lutaml/xml/when_attribute_spec.rb +225 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 57902ec64a7b7915f601d0525d747ec742c78a18bd7e78469c0af41aa35d2101
|
|
4
|
+
data.tar.gz: a27ee76ad1f66aaacb324c5ba79f75dca698c3f729c2e61706ed4cbe85c0b255
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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.
|
|
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
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -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
|
data/lib/lutaml/xml/element.rb
CHANGED
|
@@ -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)
|
data/lib/lutaml/xml/mapping.rb
CHANGED
|
@@ -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.
|
|
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) &&
|
|
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) &&
|
|
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 =
|
|
669
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|