lutaml-model 0.8.43 → 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 +36 -2
- data/docs/_tutorials/polymorphic-models-tutorial.adoc +2 -0
- 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/model_transform.rb +14 -1
- data/lib/lutaml/xml/parse_session.rb +22 -0
- data/lib/lutaml/xml/transformation/order_reconciler.rb +16 -1
- data/lib/lutaml/xml/transformation/ordered_applier.rb +73 -2
- data/lib/lutaml/xml/xml_element.rb +20 -1
- data/spec/lutaml/xml/when_attribute_ordered_spec.rb +148 -0
- data/spec/lutaml/xml/when_attribute_spec.rb +92 -0
- metadata +2 -1
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
|
|
@@ -592,5 +592,39 @@ writes that attribute).
|
|
|
592
592
|
|
|
593
593
|
Co-occurrence semantics: several `when_attribute` rules may share the
|
|
594
594
|
element name; each target attribute fills independently, so declare each
|
|
595
|
-
as `collection: true` when the element repeats.
|
|
596
|
-
`when_attribute`
|
|
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.
|
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)
|
|
@@ -1053,11 +1053,24 @@ _effective_register)
|
|
|
1053
1053
|
|
|
1054
1054
|
# lutaml-model#88: attribute-value discriminator — among the
|
|
1055
1055
|
# name-matched occurrences, keep only those whose sibling
|
|
1056
|
-
# attributes carry the expected values.
|
|
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.
|
|
1057
1060
|
if rule.when_attribute?
|
|
1058
1061
|
children = children.select do |child|
|
|
1059
1062
|
rule.matches_when_attribute?(child)
|
|
1060
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
|
|
1061
1074
|
end
|
|
1062
1075
|
|
|
1063
1076
|
# Performance: Cache rule method check before loop to avoid repeated method calls
|
|
@@ -65,6 +65,28 @@ module Lutaml
|
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
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
|
+
|
|
68
90
|
def model_class
|
|
69
91
|
@model_class ||= instance.class
|
|
70
92
|
end
|
|
@@ -220,25 +220,40 @@ module Lutaml
|
|
|
220
220
|
# accepts a nil rule namespace, namespace aliases and name aliases.
|
|
221
221
|
# When more than one rule would claim the entry, reconciling would
|
|
222
222
|
# feed the value to the wrong rule, so leave the rule alone.
|
|
223
|
+
# Rules distinguished by `when_attribute` are separate rules: their
|
|
224
|
+
# inserted entries carry the discriminator attributes, so they
|
|
225
|
+
# resolve back correctly.
|
|
223
226
|
def unambiguous?(rule, element_rules)
|
|
227
|
+
discriminator = rule_discriminator(rule)
|
|
224
228
|
matches = element_rules.select do |candidate|
|
|
225
229
|
matches_element_rule?(candidate, rule.serialized_name,
|
|
226
|
-
rule.namespace_class&.uri)
|
|
230
|
+
rule.namespace_class&.uri) &&
|
|
231
|
+
rule_discriminator(candidate) == discriminator
|
|
227
232
|
end
|
|
228
233
|
|
|
229
234
|
matches == [rule]
|
|
230
235
|
end
|
|
231
236
|
|
|
232
237
|
def new_order_entry(rule)
|
|
238
|
+
discriminator = rule_discriminator(rule)
|
|
233
239
|
::Lutaml::Xml::Element.new(
|
|
234
240
|
"Element",
|
|
235
241
|
rule.serialized_name,
|
|
236
242
|
node_type: :element,
|
|
237
243
|
namespace_uri: rule.namespace_class&.uri,
|
|
238
244
|
namespace_prefix: nil,
|
|
245
|
+
attributes: discriminator && discriminator_attributes(discriminator),
|
|
239
246
|
)
|
|
240
247
|
end
|
|
241
248
|
|
|
249
|
+
# The discriminator pairs as order-entry attributes, so an inserted
|
|
250
|
+
# entry routes back to its own rule at emit time and re-parses.
|
|
251
|
+
def discriminator_attributes(pairs)
|
|
252
|
+
pairs.each_with_object({}) do |(name, value), attrs|
|
|
253
|
+
attrs[name.to_s] = value.to_s
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
242
257
|
# Where a rule's new entries go:
|
|
243
258
|
# - after the last entry that already resolves to it, so extra
|
|
244
259
|
# collection items follow the existing ones and any interleaved
|
|
@@ -122,6 +122,14 @@ model_class, register_id)
|
|
|
122
122
|
|
|
123
123
|
# Find the mapping rule for an element from element_order
|
|
124
124
|
#
|
|
125
|
+
# Same-name entries under different `when_attribute` discriminators
|
|
126
|
+
# (all `<component>`s, distinguished by `type="guidance"` vs
|
|
127
|
+
# `type="purpose"`) route to the rule whose discriminator pairs the
|
|
128
|
+
# entry's recorded attributes satisfy. Entries carrying no
|
|
129
|
+
# attributes — built before the entry format carried them, or
|
|
130
|
+
# inserted by hand — fall back to the first plain rule, which is
|
|
131
|
+
# the pre-discriminator behavior.
|
|
132
|
+
#
|
|
125
133
|
# @param object [Xml::Element] Element from element_order
|
|
126
134
|
# @param compiled_rules [Array<CompiledRule>] The compiled rules
|
|
127
135
|
# @return [CompiledRule, nil] The matching rule or nil
|
|
@@ -131,11 +139,23 @@ model_class, register_id)
|
|
|
131
139
|
|
|
132
140
|
object_ns_uri = object.namespace_uri # nil if old element_order (backward compat)
|
|
133
141
|
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
first = nil
|
|
143
|
+
shared_name = false
|
|
144
|
+
compiled_rules.each do |r|
|
|
145
|
+
next unless r.is_a?(::Lutaml::Model::CompiledRule) &&
|
|
136
146
|
r.option(:mapping_type) == :element &&
|
|
137
147
|
matches_element_rule?(r, object.name, object_ns_uri)
|
|
148
|
+
|
|
149
|
+
if first.nil?
|
|
150
|
+
first = r
|
|
151
|
+
else
|
|
152
|
+
shared_name = true
|
|
153
|
+
break
|
|
154
|
+
end
|
|
138
155
|
end
|
|
156
|
+
return first unless shared_name
|
|
157
|
+
|
|
158
|
+
resolve_shared_name_rule(object, compiled_rules, object_ns_uri)
|
|
139
159
|
end
|
|
140
160
|
|
|
141
161
|
private
|
|
@@ -175,6 +195,57 @@ model_class, register_id)
|
|
|
175
195
|
rule.matches_name?(name) && rule_ns_matches
|
|
176
196
|
end
|
|
177
197
|
|
|
198
|
+
# Pick among rules sharing a serialized name when the entry's
|
|
199
|
+
# recorded attributes are the only thing that tells them apart.
|
|
200
|
+
#
|
|
201
|
+
# A discriminator rule whose pairs the entry satisfies wins over
|
|
202
|
+
# any plain rule regardless of declaration order, because a plain
|
|
203
|
+
# rule would claim EVERY same-name entry including discriminated
|
|
204
|
+
# ones. Entries matching no discriminator pair — an unknown value,
|
|
205
|
+
# or an entry without attributes — fall back to the first plain
|
|
206
|
+
# rule.
|
|
207
|
+
def resolve_shared_name_rule(object, compiled_rules, object_ns_uri)
|
|
208
|
+
plain = nil
|
|
209
|
+
compiled_rules.each do |r|
|
|
210
|
+
next unless r.is_a?(::Lutaml::Model::CompiledRule) &&
|
|
211
|
+
r.option(:mapping_type) == :element &&
|
|
212
|
+
matches_element_rule?(r, object.name, object_ns_uri)
|
|
213
|
+
|
|
214
|
+
pairs = rule_discriminator(r)
|
|
215
|
+
return r if pairs && entry_matches_when_attribute?(object, pairs)
|
|
216
|
+
|
|
217
|
+
plain ||= r if pairs.nil?
|
|
218
|
+
end
|
|
219
|
+
plain
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The rule's `when_attribute` discriminator pairs, or nil for a
|
|
223
|
+
# plain rule. Shared by OrderedApplier and OrderReconciler since
|
|
224
|
+
# both run on the same transform host.
|
|
225
|
+
#
|
|
226
|
+
# @param rule [CompiledRule]
|
|
227
|
+
# @return [::Hash, nil]
|
|
228
|
+
def rule_discriminator(rule)
|
|
229
|
+
pairs = rule.option(:when_attribute)
|
|
230
|
+
pairs && !pairs.empty? ? pairs : nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Whether an order entry's recorded attributes satisfy a rule's
|
|
234
|
+
# discriminator pairs. Mirrors Xml::MappingRule#matches_when_attribute?
|
|
235
|
+
# — non-nil and string-equal — against the namespaced-name keying
|
|
236
|
+
# the entries record.
|
|
237
|
+
def entry_matches_when_attribute?(object, pairs)
|
|
238
|
+
return false unless object.is_a?(::Lutaml::Xml::Element)
|
|
239
|
+
|
|
240
|
+
attrs = object.attributes
|
|
241
|
+
return false if attrs.nil?
|
|
242
|
+
|
|
243
|
+
pairs.all? do |name, expected|
|
|
244
|
+
actual = attrs[name.to_s]
|
|
245
|
+
!actual.nil? && actual.to_s == expected.to_s
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
178
249
|
def process_element_order_item(object, root, model_instance, options,
|
|
179
250
|
compiled_rules, _mapping, element_indices,
|
|
180
251
|
content_rule = nil, text_node_index = 0,
|
|
@@ -284,11 +284,30 @@ module Lutaml
|
|
|
284
284
|
Lutaml::Xml::Element.new("Element", child.unprefixed_name,
|
|
285
285
|
node_type: :element,
|
|
286
286
|
namespace_uri: child.namespace_uri,
|
|
287
|
-
namespace_prefix: child.namespace_prefix
|
|
287
|
+
namespace_prefix: child.namespace_prefix,
|
|
288
|
+
attributes: order_entry_attributes(child))
|
|
288
289
|
end
|
|
289
290
|
end.each(&:freeze).freeze
|
|
290
291
|
end
|
|
291
292
|
|
|
293
|
+
# The attributes an element-order entry records, keyed by namespaced
|
|
294
|
+
# name — the same key find_attribute_value indexes. Same-name entries
|
|
295
|
+
# under different `when_attribute` discriminators need them to route
|
|
296
|
+
# back to the right rule on ordered serialization. nil for attribute-
|
|
297
|
+
# less children keeps the common entry allocation-free.
|
|
298
|
+
#
|
|
299
|
+
# @param child [XmlElement] An element child of this node
|
|
300
|
+
# @return [::Hash{String=>String}, nil]
|
|
301
|
+
def order_entry_attributes(child)
|
|
302
|
+
return if child.attributes.empty?
|
|
303
|
+
|
|
304
|
+
attrs = {}
|
|
305
|
+
child.attributes.each_value do |attr|
|
|
306
|
+
attrs[attr.namespaced_name] = attr.value
|
|
307
|
+
end
|
|
308
|
+
attrs.freeze
|
|
309
|
+
end
|
|
310
|
+
|
|
292
311
|
def root
|
|
293
312
|
self
|
|
294
313
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# lutaml-model#88: `when_attribute` partitioning under `ordered` mappings.
|
|
6
|
+
# The ordered walk routes each element_order entry by its recorded
|
|
7
|
+
# attributes, so `<component type="guidance">` and `<component
|
|
8
|
+
# type="purpose">` interleave on to_xml exactly as the source document did.
|
|
9
|
+
class WhenOrderedComponent < Lutaml::Model::Serializable
|
|
10
|
+
attribute :text, :string
|
|
11
|
+
|
|
12
|
+
xml do
|
|
13
|
+
element "component"
|
|
14
|
+
map_element "text", to: :text
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class WhenOrderedRequirement < Lutaml::Model::Serializable
|
|
19
|
+
attribute :guidance, WhenOrderedComponent, collection: true
|
|
20
|
+
attribute :purpose, WhenOrderedComponent, collection: true
|
|
21
|
+
attribute :test_method, WhenOrderedComponent, collection: true
|
|
22
|
+
|
|
23
|
+
xml do
|
|
24
|
+
element "requirement"
|
|
25
|
+
ordered
|
|
26
|
+
map_element "component", to: :guidance,
|
|
27
|
+
when_attribute: { "type" => "guidance" }
|
|
28
|
+
map_element "component", to: :purpose,
|
|
29
|
+
when_attribute: { "type" => "purpose" }
|
|
30
|
+
map_element "component", to: :test_method,
|
|
31
|
+
when_attribute: { type: "test-method" }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
RSpec.describe "when_attribute under ordered mappings" do
|
|
36
|
+
before do
|
|
37
|
+
stub_const("WhenOrdered::Component", WhenOrderedComponent)
|
|
38
|
+
stub_const("WhenOrdered::Requirement", WhenOrderedRequirement)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
let(:xml) do
|
|
42
|
+
<<~XML
|
|
43
|
+
<requirement>
|
|
44
|
+
<component type="guidance"><text>g1</text></component>
|
|
45
|
+
<component type="purpose"><text>p1</text></component>
|
|
46
|
+
<component type="guidance"><text>g2</text></component>
|
|
47
|
+
<component type="test-method"><text>t1</text></component>
|
|
48
|
+
</requirement>
|
|
49
|
+
XML
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def serialized_types(doc)
|
|
53
|
+
doc.scan(/<component type="([^"]+)">/).flatten
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "parses partitioned values" do
|
|
57
|
+
req = WhenOrderedRequirement.from_xml(xml)
|
|
58
|
+
|
|
59
|
+
expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
|
|
60
|
+
expect(req.purpose.map(&:text)).to eq(["p1"])
|
|
61
|
+
expect(req.test_method.map(&:text)).to eq(["t1"])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "records the discriminator attributes on element_order entries" do
|
|
65
|
+
req = WhenOrderedRequirement.from_xml(xml)
|
|
66
|
+
entries = req.element_order.select { |e| e.type == "Element" }
|
|
67
|
+
|
|
68
|
+
expect(entries.map(&:attributes)).to eq([
|
|
69
|
+
{ "type" => "guidance" },
|
|
70
|
+
{ "type" => "purpose" },
|
|
71
|
+
{ "type" => "guidance" },
|
|
72
|
+
{ "type" => "test-method" },
|
|
73
|
+
])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "round-trips the original interleaving" do
|
|
77
|
+
round = WhenOrderedRequirement.from_xml(xml).to_xml
|
|
78
|
+
|
|
79
|
+
expect(serialized_types(round)).to eq(
|
|
80
|
+
%w[guidance purpose guidance test-method],
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "survives a re-parse of the ordered output" do
|
|
85
|
+
once = WhenOrderedRequirement.from_xml(xml)
|
|
86
|
+
twice = WhenOrderedRequirement.from_xml(once.to_xml)
|
|
87
|
+
|
|
88
|
+
expect(twice.guidance.map(&:text)).to eq(%w[g1 g2])
|
|
89
|
+
expect(twice.purpose.map(&:text)).to eq(["p1"])
|
|
90
|
+
expect(twice.test_method.map(&:text)).to eq(["t1"])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "routes appended items to the right attribute on ordered output" do
|
|
94
|
+
req = WhenOrderedRequirement.from_xml(xml)
|
|
95
|
+
req.guidance << WhenOrderedComponent.new(text: "g3")
|
|
96
|
+
|
|
97
|
+
out = req.to_xml
|
|
98
|
+
# Reconciliation places new entries after the rule's last existing
|
|
99
|
+
# occurrence (the OrderReconciler contract), not at document end.
|
|
100
|
+
expect(serialized_types(out)).to eq(
|
|
101
|
+
%w[guidance purpose guidance guidance test-method],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
reparsed = WhenOrderedRequirement.from_xml(out)
|
|
105
|
+
expect(reparsed.guidance.map(&:text)).to eq(%w[g1 g2 g3])
|
|
106
|
+
expect(reparsed.purpose.map(&:text)).to eq(["p1"])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "drops occurrences with an unknown discriminator from ordered output" do
|
|
110
|
+
with_unknown = WhenOrderedRequirement.from_xml(<<~XML)
|
|
111
|
+
<requirement>
|
|
112
|
+
<component type="guidance"><text>g1</text></component>
|
|
113
|
+
<component type="unknown"><text>x</text></component>
|
|
114
|
+
<component type="purpose"><text>p1</text></component>
|
|
115
|
+
</requirement>
|
|
116
|
+
XML
|
|
117
|
+
|
|
118
|
+
expect(with_unknown.guidance.map(&:text)).to eq(["g1"])
|
|
119
|
+
expect(with_unknown.purpose.map(&:text)).to eq(["p1"])
|
|
120
|
+
expect(serialized_types(with_unknown.to_xml)).to eq(%w[guidance purpose])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "falls back to the plain rule for legacy attribute-less entries" do
|
|
124
|
+
both = Class.new(Lutaml::Model::Serializable) do
|
|
125
|
+
attribute :plain, :string
|
|
126
|
+
attribute :special, :string
|
|
127
|
+
|
|
128
|
+
xml do
|
|
129
|
+
element "holder"
|
|
130
|
+
ordered
|
|
131
|
+
map_element "item", to: :plain
|
|
132
|
+
map_element "item", to: :special, when_attribute: { "kind" => "special" }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
stub_const("WhenOrdered::Holder", both)
|
|
136
|
+
|
|
137
|
+
doc = both.from_xml(
|
|
138
|
+
"<holder><item>a</item><item kind=\"special\">b</item></holder>",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
expect(doc.plain).to eq("a")
|
|
142
|
+
expect(doc.special).to eq("b")
|
|
143
|
+
|
|
144
|
+
out = doc.to_xml
|
|
145
|
+
expect(out.scan(%r{<item[^>]*>([^<]*)</item>})).to eq([["a"], ["b"]])
|
|
146
|
+
expect(out).to include('kind="special"')
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -130,4 +130,96 @@ RSpec.describe "when_attribute discriminator mappings" do
|
|
|
130
130
|
end
|
|
131
131
|
end.to raise_error(ArgumentError)
|
|
132
132
|
end
|
|
133
|
+
|
|
134
|
+
# Orthogonality guard: `when_attribute` partitions occurrences across
|
|
135
|
+
# attributes; `polymorphic` dispatches the class of each hydrated item.
|
|
136
|
+
# Different axes over the same discriminator concept — they compose on
|
|
137
|
+
# one rule rather than substitute for each other.
|
|
138
|
+
describe "vs polymorphic dispatch" do
|
|
139
|
+
before do
|
|
140
|
+
stub_const("AxisComp", Class.new(Lutaml::Model::Serializable) do
|
|
141
|
+
attribute :text, :string
|
|
142
|
+
end)
|
|
143
|
+
stub_const("AxisGuidanceComp", Class.new(AxisComp))
|
|
144
|
+
stub_const("AxisPurposeComp", Class.new(AxisComp))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
let(:axes_xml) do
|
|
148
|
+
<<~XML
|
|
149
|
+
<req>
|
|
150
|
+
<component type="guidance"><text>g1</text></component>
|
|
151
|
+
<component type="purpose"><text>p1</text></component>
|
|
152
|
+
<component type="guidance"><text>g2</text></component>
|
|
153
|
+
</req>
|
|
154
|
+
XML
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it "polymorphic keeps one attribute and dispatches classes" do
|
|
158
|
+
poly = Class.new(Lutaml::Model::Serializable) do
|
|
159
|
+
attribute :components, AxisComp, collection: true
|
|
160
|
+
|
|
161
|
+
xml do
|
|
162
|
+
element "req"
|
|
163
|
+
map_element "component", to: :components, polymorphic: {
|
|
164
|
+
attribute: "type",
|
|
165
|
+
class_map: {
|
|
166
|
+
"guidance" => "AxisGuidanceComp",
|
|
167
|
+
"purpose" => "AxisPurposeComp",
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
parsed = poly.from_xml(axes_xml)
|
|
174
|
+
expect(parsed.components.map(&:class))
|
|
175
|
+
.to eq([AxisGuidanceComp, AxisPurposeComp, AxisGuidanceComp])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "when_attribute keeps one class and partitions attributes" do
|
|
179
|
+
partitioned = Class.new(Lutaml::Model::Serializable) do
|
|
180
|
+
attribute :guidance, AxisComp, collection: true
|
|
181
|
+
attribute :purpose, AxisComp, collection: true
|
|
182
|
+
|
|
183
|
+
xml do
|
|
184
|
+
element "req"
|
|
185
|
+
map_element "component", to: :guidance,
|
|
186
|
+
when_attribute: { "type" => "guidance" }
|
|
187
|
+
map_element "component", to: :purpose,
|
|
188
|
+
when_attribute: { "type" => "purpose" }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
parsed = partitioned.from_xml(axes_xml)
|
|
193
|
+
expect(parsed.guidance.map(&:text)).to eq(%w[g1 g2])
|
|
194
|
+
expect(parsed.purpose.map(&:text)).to eq(["p1"])
|
|
195
|
+
expect(parsed.guidance.first.class).to eq(AxisComp)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "composes: partition and class dispatch on the same rule" do
|
|
199
|
+
both = Class.new(Lutaml::Model::Serializable) do
|
|
200
|
+
attribute :guidance, AxisComp, collection: true
|
|
201
|
+
attribute :purpose, AxisComp, collection: true
|
|
202
|
+
|
|
203
|
+
xml do
|
|
204
|
+
element "req"
|
|
205
|
+
map_element "component", to: :guidance,
|
|
206
|
+
when_attribute: { "type" => "guidance" },
|
|
207
|
+
polymorphic: {
|
|
208
|
+
attribute: "type",
|
|
209
|
+
class_map: { "guidance" => "AxisGuidanceComp" },
|
|
210
|
+
}
|
|
211
|
+
map_element "component", to: :purpose,
|
|
212
|
+
when_attribute: { "type" => "purpose" },
|
|
213
|
+
polymorphic: {
|
|
214
|
+
attribute: "type",
|
|
215
|
+
class_map: { "purpose" => "AxisPurposeComp" },
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
parsed = both.from_xml(axes_xml)
|
|
221
|
+
expect(parsed.guidance.map(&:class)).to eq([AxisGuidanceComp, AxisGuidanceComp])
|
|
222
|
+
expect(parsed.purpose.map(&:class)).to eq([AxisPurposeComp])
|
|
223
|
+
end
|
|
224
|
+
end
|
|
133
225
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lutaml-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.44
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -2006,6 +2006,7 @@ files:
|
|
|
2006
2006
|
- spec/lutaml/xml/validate_xml_with_constructs_spec.rb
|
|
2007
2007
|
- spec/lutaml/xml/validate_xml_with_spec.rb
|
|
2008
2008
|
- spec/lutaml/xml/w3c_types_spec.rb
|
|
2009
|
+
- spec/lutaml/xml/when_attribute_ordered_spec.rb
|
|
2009
2010
|
- spec/lutaml/xml/when_attribute_spec.rb
|
|
2010
2011
|
- spec/lutaml/xml/xml_adapter_spec.rb
|
|
2011
2012
|
- spec/lutaml/xml/xml_declaration_spec.rb
|