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
|
@@ -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,
|
|
@@ -337,6 +337,7 @@ register_id, register, attr_name, custom_methods_value)
|
|
|
337
337
|
value_map: value_map,
|
|
338
338
|
custom_methods: custom_methods_value,
|
|
339
339
|
polymorphic: mapping_rule.polymorphic,
|
|
340
|
+
when_attribute: mapping_rule.when_attribute,
|
|
340
341
|
form: mapping_rule.form,
|
|
341
342
|
delegate_from: delegate_target,
|
|
342
343
|
)
|
|
@@ -417,6 +418,7 @@ register_id, register, custom_methods_value)
|
|
|
417
418
|
value_map: value_map,
|
|
418
419
|
custom_methods: custom_methods_value,
|
|
419
420
|
polymorphic: mapping_rule.polymorphic,
|
|
421
|
+
when_attribute: mapping_rule.when_attribute,
|
|
420
422
|
form: mapping_rule.form,
|
|
421
423
|
alias_names: alias_names,
|
|
422
424
|
)
|
|
@@ -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
|
|
@@ -137,26 +137,36 @@ RSpec.describe Lutaml::KeyValue::Transformation::RuleCompiler do
|
|
|
137
137
|
end
|
|
138
138
|
|
|
139
139
|
describe "#valid_mapping?" do
|
|
140
|
-
|
|
140
|
+
def compiled_rule(serialize: true)
|
|
141
|
+
Lutaml::Model::CompiledRule.new(
|
|
142
|
+
attribute_name: :name,
|
|
143
|
+
serialized_name: "name",
|
|
144
|
+
serialize: serialize,
|
|
145
|
+
)
|
|
146
|
+
end
|
|
141
147
|
|
|
142
148
|
it "returns true when no only/except options" do
|
|
143
|
-
expect(compiler.valid_mapping?(
|
|
149
|
+
expect(compiler.valid_mapping?(compiled_rule, {})).to be true
|
|
144
150
|
end
|
|
145
151
|
|
|
146
152
|
it "returns true when attribute is in only list" do
|
|
147
|
-
expect(compiler.valid_mapping?(
|
|
153
|
+
expect(compiler.valid_mapping?(compiled_rule, { only: [:name] })).to be true
|
|
148
154
|
end
|
|
149
155
|
|
|
150
156
|
it "returns false when attribute is not in only list" do
|
|
151
|
-
expect(compiler.valid_mapping?(
|
|
157
|
+
expect(compiler.valid_mapping?(compiled_rule, { only: [:age] })).to be false
|
|
152
158
|
end
|
|
153
159
|
|
|
154
160
|
it "returns true when attribute is not in except list" do
|
|
155
|
-
expect(compiler.valid_mapping?(
|
|
161
|
+
expect(compiler.valid_mapping?(compiled_rule, { except: [:age] })).to be true
|
|
156
162
|
end
|
|
157
163
|
|
|
158
164
|
it "returns false when attribute is in except list" do
|
|
159
|
-
expect(compiler.valid_mapping?(
|
|
165
|
+
expect(compiler.valid_mapping?(compiled_rule, { except: [:name] })).to be false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "returns false for a hydrate-only rule (serialize: false)" do
|
|
169
|
+
expect(compiler.valid_mapping?(compiled_rule(serialize: false), {})).to be false
|
|
160
170
|
end
|
|
161
171
|
end
|
|
162
172
|
|
|
@@ -283,7 +283,10 @@ RSpec.describe Lutaml::KeyValue::Transformation do
|
|
|
283
283
|
|
|
284
284
|
expect(transformation).to be_frozen
|
|
285
285
|
|
|
286
|
-
rule =
|
|
286
|
+
rule = Lutaml::Model::CompiledRule.new(
|
|
287
|
+
attribute_name: :name,
|
|
288
|
+
serialized_name: "name",
|
|
289
|
+
)
|
|
287
290
|
expect do
|
|
288
291
|
transformation.send(:valid_mapping?, rule, {})
|
|
289
292
|
end.not_to raise_error
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# lutaml-model#815: standalone `SomeModel.new.to_xml` crashed on 0.8.42 when
|
|
6
|
+
# the model's attributes are merged through register records whose imports
|
|
7
|
+
# clear @merged_attributes_cache re-entrantly from inside ensure_imports! —
|
|
8
|
+
# the memo store was materialized before ensure_imports! and the write after
|
|
9
|
+
# it hit nil[].
|
|
10
|
+
RSpec.describe "attributes memo across import-triggered cache clears" do
|
|
11
|
+
let(:namespace_class) do
|
|
12
|
+
Class.new(Lutaml::Xml::W3c::XmlNamespace) do
|
|
13
|
+
uri "https://example.com/ns815"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
let(:register) do
|
|
18
|
+
Lutaml::Model::Register.new(:attrs815, fallback: [:default]).tap do |reg|
|
|
19
|
+
reg.bind_namespace(namespace_class)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
let(:base_model) do
|
|
24
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
25
|
+
attribute :shared, :string
|
|
26
|
+
|
|
27
|
+
xml do
|
|
28
|
+
element "Base815"
|
|
29
|
+
namespace namespace_class
|
|
30
|
+
map_element "shared", to: :shared
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
let(:consumer_model) do
|
|
36
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
37
|
+
attribute :own, :string
|
|
38
|
+
|
|
39
|
+
xml do
|
|
40
|
+
element "Consumer815"
|
|
41
|
+
namespace namespace_class
|
|
42
|
+
map_element "own", to: :own
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
before do
|
|
48
|
+
stub_const("Attrs815::NamespaceClass", namespace_class)
|
|
49
|
+
Lutaml::Model::GlobalContext.reset!
|
|
50
|
+
Lutaml::Model::GlobalRegister.instance.register(register)
|
|
51
|
+
# Populate @register_records through the import the parse path uses.
|
|
52
|
+
base_model.import_model_attributes(consumer_model, register.id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
after do
|
|
56
|
+
Lutaml::Model::GlobalContext.reset!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "serializes a register-bound model as its own document root" do
|
|
60
|
+
instance = base_model.new(shared: "x")
|
|
61
|
+
expect(instance.to_xml).to include("<shared>x</shared>")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "merges register attributes after an import-triggered cache clear" do
|
|
65
|
+
instance = consumer_model.new(own: "y")
|
|
66
|
+
expect(instance.to_xml).to include("<own>y</own>")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "keeps answering attributes() from the memo after imports" do
|
|
70
|
+
first = consumer_model.attributes(:attrs815)
|
|
71
|
+
expect(consumer_model.attributes(:attrs815)).to equal(first)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "survives a re-entrant cache clear from inside ensure_imports!" do
|
|
75
|
+
# uniword's register-bound models (#815): choice/restrict resolution
|
|
76
|
+
# runs clear_cache while ensure_imports! is still on the stack, which
|
|
77
|
+
# nils @merged_attributes_cache after the reader materialized it.
|
|
78
|
+
reentrant = Class.new(base_model) do
|
|
79
|
+
def self.finalized? = true
|
|
80
|
+
|
|
81
|
+
def self.ensure_format_mapping_imports!(_register = nil)
|
|
82
|
+
clear_cache
|
|
83
|
+
super
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
expect { reentrant.attributes(:attrs815) }.not_to raise_error
|
|
88
|
+
expect(reentrant.attributes(:attrs815)[:shared].name).to eq(:shared)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require_relative "../../../lib/lutaml/model"
|
|
5
|
+
|
|
6
|
+
module HydrateOnlyMapping
|
|
7
|
+
class Message < Lutaml::Model::Serializable
|
|
8
|
+
attribute :body, :string
|
|
9
|
+
attribute :source_offset, :integer
|
|
10
|
+
|
|
11
|
+
key_value do
|
|
12
|
+
map "body", to: :body
|
|
13
|
+
map "source_offset", to: :source_offset, serialize: false
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RSpec.describe "hydrate-only mappings (serialize: false)" do
|
|
19
|
+
let(:input) { { "body" => "hello", "source_offset" => 42 } }
|
|
20
|
+
|
|
21
|
+
it "hydrates the attribute from input" do
|
|
22
|
+
message = HydrateOnlyMapping::Message.from_hash(input)
|
|
23
|
+
|
|
24
|
+
expect(message.body).to eq("hello")
|
|
25
|
+
expect(message.source_offset).to eq(42)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "omits the attribute from serialized output" do
|
|
29
|
+
message = HydrateOnlyMapping::Message.from_hash(input)
|
|
30
|
+
|
|
31
|
+
expect(message.to_hash).to eq("body" => "hello")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "round-trips through JSON without the hidden key" do
|
|
35
|
+
message = HydrateOnlyMapping::Message.from_json(input.to_json)
|
|
36
|
+
|
|
37
|
+
expect(JSON.parse(message.to_json)).to eq("body" => "hello")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "keeps the mapping through deep-copied mappings" do
|
|
41
|
+
mapping = HydrateOnlyMapping::Message.mappings_for(:hash)
|
|
42
|
+
rule = mapping.mappings.find { |r| r.to == :source_offset }
|
|
43
|
+
|
|
44
|
+
expect(mapping.deep_dup.mappings.find { |r| r.to == :source_offset }
|
|
45
|
+
.serialize?).to eq(rule.serialize?)
|
|
46
|
+
end
|
|
47
|
+
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
|