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
@@ -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
- compiled_rules.find do |r|
135
- r.is_a?(::Lutaml::Model::CompiledRule) &&
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
- let(:rule) { double("CompiledRule", attribute_name: :name) }
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?(rule, {})).to be true
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?(rule, { only: [:name] })).to be true
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?(rule, { only: [:age] })).to be false
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?(rule, { except: [:age] })).to be true
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?(rule, { except: [:name] })).to be false
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 = instance_double(Lutaml::Model::CompiledRule, attribute_name: :name)
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