lutaml-model 0.8.54 → 0.8.55

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffb19b7e87ec763167dc806fb0fd0b37a1ca292e7627333ba1238f06c3e5f17e
4
- data.tar.gz: 340c6f96fb82f0d972ca654f548db51bdef12155bc94ae95e3eaf6eacf96af29
3
+ metadata.gz: c16452c0371017c525543d5fcd948d4eaf9680dcff5ec2b193d2e9fabe43f86a
4
+ data.tar.gz: 1853acc8790ddc2e1b53cfc8c1a68b750f695fc01f474d608e5d29cfbe269a57
5
5
  SHA512:
6
- metadata.gz: d79e2b5b7fce4f6ba3115cdb190365b8a1fc1212922f00cabf2e9855fa85c2350808c0844f78c55015de4e8c8cf480cbdf5390e8afaa0a401f01ad6e2a1d93a3
7
- data.tar.gz: c6a29c349be599ddfcbcb53087c17178de7de17aa1afa46fb5e2fc6eb2b3c4b0c0189b68a26aa77d0e2d561966c4001c0bd04abacf2d6e987d8846185b6add29
6
+ metadata.gz: 6ac74cd346e28f14990ae94ef0921bce2f75bc92252e82a6d7a16059683b25a5e2645e4227a3a72479880921c9a831d69faa951cb6eb20600f29a41ba010e9c8
7
+ data.tar.gz: 9f30ab7f585db43c6d0e94425c9e4fe3e9895a02c28c7a7f3f5888ef5dc8a6ea4dee287cfb58c2300edcd23e0d6f92a9a792f602a0696f0a76c35334362add79
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.54"
5
+ VERSION = "0.8.55"
6
6
  end
7
7
  end
@@ -95,6 +95,21 @@ module Lutaml
95
95
  return nil if attr.nil?
96
96
  return nil if attr.derived?
97
97
 
98
+ # Partition rows (TODO 34 step 2) ride native predicates
99
+ # only in the plain-capture shape; any other when_attribute
100
+ # shape stays interpretive (the suite pins nested-target
101
+ # partitions to the interpretive path).
102
+ unless rule.when_attribute.empty?
103
+ t = attr.type(register)
104
+ plain_partition = !rule.delegate &&
105
+ !rule.has_custom_method_for_deserialization? &&
106
+ !rule.polymorphic_mapping? &&
107
+ !attr.polymorphic? && !attr.union? &&
108
+ !(t.is_a?(Class) &&
109
+ t.include?(::Lutaml::Model::Serialize))
110
+ return nil unless plain_partition
111
+ end
112
+
98
113
  # Interpretive hydration materializes every mapped
99
114
  # collection, present or not; the fast path mirrors with
100
115
  # constructor-time empty arrays.
@@ -178,6 +193,10 @@ module Lutaml
178
193
 
179
194
  row = { name: rule.name.to_s }
180
195
  row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
196
+ # lutaml-model#88: same-name rows partitioned by the
197
+ # rule's discriminator ride the engine's exclusive
198
+ # predicate match (leptris 1.9.221+, #1272).
199
+ row[:when] = rule.when_attribute unless rule.when_attribute.empty?
181
200
 
182
201
  if attr.collection?
183
202
  compiled << [rule, attr, :collection_native, nil,
@@ -192,6 +211,8 @@ module Lutaml
192
211
  end
193
212
  end
194
213
 
214
+ row_tags = partition_row_tags!(compiled, rows, tag)
215
+
195
216
  flags = []
196
217
  flags << :cdata if cdata
197
218
  flags << :mixed_content if mixed_content
@@ -212,6 +233,7 @@ module Lutaml
212
233
 
213
234
  { descriptor: descriptor, tree: tree, rows: compiled,
214
235
  attr_rows: attr_rows, mapping: mapping,
236
+ row_tags: row_tags,
215
237
  ordered: mapping.ordered? || mapping.mixed_content?,
216
238
  needs_nodes: needs_nodes,
217
239
  collection_defaults: collection_defaults }
@@ -219,14 +241,45 @@ module Lutaml
219
241
 
220
242
  def compilable_mapping?(mapping)
221
243
  mapping.root_element &&
222
- !(mapping.respond_to?(:root_mappings) && mapping.root_mappings) &&
223
- # lutaml-model#88: when_attribute partitions route same-name
224
- # occurrences by attribute value — name-keyed plan rows would
225
- # hydrate every occurrence into EVERY partition attribute
226
- # (verified double-capture under the flag). The interpretive
227
- # filter is the only correct path until the descriptor ABI
228
- # grows row predicates.
229
- mapping.mappings.none?(&:when_attribute?)
244
+ !(mapping.respond_to?(:root_mappings) && mapping.root_mappings)
245
+ end
246
+
247
+ # Partition bookkeeping for when_attribute rows (#88, TODO
248
+ # 34 step 2): the engine matches same-name rows exclusively —
249
+ # first matching row wins — so predicate rows must PRECEDE any
250
+ # plain sibling on the same name (a plain-first order
251
+ # double-captures: the plain row takes everything and the
252
+ # predicates still claim their matches). Every row of a
253
+ # partitioned name gets a distinct type_tag; plan values echo
254
+ # it back, giving the hydrator row-exact routing with no
255
+ # per-occurrence re-derivation. Returns {compiled_index =>
256
+ # tag} (nil when the model has no partitions). compiled and
257
+ # rows are parallel and stay in lockstep through the reorder.
258
+ def partition_row_tags!(compiled, rows, tag)
259
+ by_name = rows.each_with_index
260
+ .group_by { |(row, _)| row[:name] }
261
+ .select { |_name, pairs| pairs.any? { |(row, _)| row[:when] } }
262
+ return nil if by_name.empty?
263
+
264
+ permutation = by_name.each_value.flat_map do |pairs|
265
+ pairs.sort_by { |(row, i)| [row[:when] ? 0 : 1, i] }
266
+ .map(&:last)
267
+ end
268
+ remaining = (0...rows.length).to_a - permutation
269
+ permutation.concat(remaining)
270
+
271
+ row_tags = {}
272
+ permutation.each_with_index do |old_index, new_index|
273
+ row = rows[old_index]
274
+ next unless by_name.key?(row[:name])
275
+
276
+ tag += 1
277
+ row[:type_tag] = tag
278
+ row_tags[new_index] = tag
279
+ end
280
+ rows.replace(permutation.map { |i| rows[i] })
281
+ compiled.replace(permutation.map { |i| compiled[i] })
282
+ row_tags
230
283
  end
231
284
 
232
285
  # Attribute for a rule — delegate rules resolve against their
@@ -73,19 +73,32 @@ module Lutaml
73
73
  # parent/root links once the parent exists; delegate values
74
74
  # wait for the instance (their target object must exist).
75
75
  def children_kwargs(_model_class, plan, value, buckets = nil)
76
- grouped = group_children(value)
76
+ grouped, tagged = group_children(value, plan[:row_tags])
77
77
  buckets = nil unless buckets && plan[:needs_nodes]
78
78
  kwargs = {}
79
79
  children = []
80
80
  delegates = []
81
81
  spellings = Hash.new { |h, k| h[k] = [] }
82
- plan[:rows].each do |rule, attr, kind, spelling, delegate|
82
+ row_tags = plan[:row_tags]
83
+ plan[:rows].each_with_index do |(rule, attr, kind, spelling, delegate), idx|
84
+ tag = row_tags&.[](idx)
83
85
  case kind
84
86
  when :scalar
85
87
  # Raw passthrough: the model constructor is the single
86
88
  # cast authority — pre-casting here doubled every cast.
87
89
  # Class transforms still apply before assignment.
88
- v = grouped.dig(rule.name.to_s, 0)&.string_value
90
+ if tag && (vals = tagged[tag]) && vals.size > 1
91
+ # Interpretive parity: several captures into a
92
+ # non-collection attribute arrive as the full array.
93
+ v = vals.map(&:string_value)
94
+ else
95
+ first = if tag
96
+ tagged[tag]&.first
97
+ else
98
+ grouped.dig(rule.name.to_s, 0)
99
+ end
100
+ v = first&.string_value
101
+ end
89
102
  unless v.nil?
90
103
  v = rule.transform_value(attr, v, :from, :xml) if rule.transform.is_a?(Class)
91
104
  assign(kwargs, delegates, delegate, rule, attr, v)
@@ -122,7 +135,11 @@ module Lutaml
122
135
  assign(kwargs, delegates, delegate, rule, attr, values)
123
136
  end
124
137
  when :collection_native
125
- values = native_collection(value, rule.name.to_s)
138
+ values = if tag
139
+ tagged[tag].to_a.map(&:string_value)
140
+ else
141
+ native_collection(value, rule.name.to_s)
142
+ end
126
143
  unless values.nil?
127
144
  assign(kwargs, delegates, delegate, rule, attr, values)
128
145
  end
@@ -314,15 +331,20 @@ module Lutaml
314
331
  Lutaml::Xml::Adapter::LeptrisAdapter.parse(raw).root
315
332
  end
316
333
 
317
- def group_children(value)
334
+ def group_children(value, row_tags = nil)
318
335
  grouped = {}
336
+ tagged = {}
337
+ want_tags = !row_tags.nil?
319
338
  value.count.times do |i|
320
339
  child = value.at(i)
321
340
  next if child.name.nil? # content runs, read separately
322
341
 
323
342
  (grouped[child.name] ||= []) << child
343
+ if want_tags && child.type_tag != 0
344
+ (tagged[child.type_tag] ||= []) << child
345
+ end
324
346
  end
325
- grouped
347
+ [grouped, tagged]
326
348
  end
327
349
 
328
350
  # Element children bucketed by local name, document order
@@ -62,12 +62,19 @@ module Lutaml
62
62
 
63
63
  case kind
64
64
  when :scalar
65
- add_leaf(element, row_name(rule, spelling),
66
- attr.serialize(value, :xml, register), doc)
65
+ # Multi-capture parity: an attribute that collected
66
+ # several occurrences serializes one element per item,
67
+ # as the interpretive writer does.
68
+ Array(value).each do |item|
69
+ add_leaf(element, row_name(rule, spelling),
70
+ attr.serialize(item, :xml, register), doc,
71
+ attrs: rule.when_attribute)
72
+ end
67
73
  when :collection_native, :collection_cb
68
74
  Array(value).each do |item|
69
75
  add_leaf(element, rule.name.to_s,
70
- attr.serialize(item, :xml, register), doc)
76
+ attr.serialize(item, :xml, register), doc,
77
+ attrs: rule.when_attribute)
71
78
  end
72
79
  when :nested, :ordered_deferred
73
80
  Array(value).each do |item|
@@ -217,8 +224,12 @@ module Lutaml
217
224
  attr.serialize(value, :xml, register).to_s)
218
225
  end
219
226
 
220
- def add_leaf(element, name, value, doc)
227
+ # Partition rows (#88) re-emit their discriminator: the wire
228
+ # element carries the when_attribute pairs so the round trip
229
+ # re-routes to the same attribute on reparse.
230
+ def add_leaf(element, name, value, doc, attrs: nil)
221
231
  child = element.create_child(name)
232
+ attrs&.each { |k, v| child[k.to_s] = v.to_s }
222
233
  child.add_child(doc.create_text_node(value.to_s)) unless value.nil?
223
234
  child
224
235
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/xml"
5
+
6
+ # The plan fast path rides when_attribute partitions (TODO 34 step 2,
7
+ # leptris 1.9.221+ #1272 predicate rows): same-name rows claim their
8
+ # occurrences exclusively, the plain sibling takes the unclaimed, and
9
+ # serialization re-stamps the discriminators. All lanes must agree —
10
+ # plan and interpretive — on parse AND round trip, including the
11
+ # multi-capture edge (several unclaimed occurrences into a scalar
12
+ # attribute arrive as the full array, interpretive parity).
13
+ RSpec.describe "Plan fast path with when_attribute partitions" do
14
+ before do
15
+ require "leptris/xml"
16
+ rescue LoadError
17
+ skip "leptris not available"
18
+ end
19
+
20
+ let(:model) do
21
+ Class.new(Lutaml::Model::Serializable) do
22
+ attribute :a_val, :string
23
+ attribute :b_val, :string
24
+ attribute :other, :string
25
+
26
+ xml do
27
+ root "part"
28
+ map_element "item", to: :a_val, when_attribute: { "type" => "a" }
29
+ map_element "item", to: :b_val, when_attribute: { "type" => "b" }
30
+ map_element "item", to: :other
31
+ end
32
+ end
33
+ end
34
+
35
+ let(:doc) do
36
+ %(<part><item type="a">A</item><item type="b">B</item><item>plain</item><item type="x">X</item></part>)
37
+ end
38
+
39
+ it "compiles the partitioned model with row tags" do
40
+ plan = Lutaml::Xml::PlanCompiler.compile(
41
+ model, Lutaml::Model::Config.default_register
42
+ )
43
+ expect(plan).not_to be_nil
44
+ expect(plan[:row_tags].length).to eq(3)
45
+ end
46
+
47
+ it "routes occurrences exclusively and matches the interpretive parse" do
48
+ fast = model.from_xml(doc)
49
+ slow = Lutaml::Model::Config.instance.tap { |c| c.xml_plan_fast_path = false }
50
+ .then { model.from_xml(doc) }
51
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
52
+
53
+ expect(fast.a_val).to eq("A")
54
+ expect(fast.b_val).to eq("B")
55
+ expect(fast.other).to eq(["plain", "X"])
56
+ expect(fast.a_val).to eq(slow.a_val)
57
+ expect(fast.b_val).to eq(slow.b_val)
58
+ expect(fast.other).to eq(slow.other)
59
+ end
60
+
61
+ it "round-trips through the serializer with discriminator stamps" do
62
+ round = model.from_xml(model.from_xml(doc).to_xml)
63
+ expect(round.a_val).to eq("A")
64
+ expect(round.b_val).to eq("B")
65
+ expect(round.other).to eq(["plain", "X"])
66
+ end
67
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.54
4
+ version: 0.8.55
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-22 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -1970,6 +1970,7 @@ files:
1970
1970
  - spec/lutaml/xml/pipeline_integration_spec.rb
1971
1971
  - spec/lutaml/xml/plain_model_accessors_spec.rb
1972
1972
  - spec/lutaml/xml/plan_fast_path_spec.rb
1973
+ - spec/lutaml/xml/plan_partition_spec.rb
1973
1974
  - spec/lutaml/xml/plan_walk_spec.rb
1974
1975
  - spec/lutaml/xml/prefix_control_spec.rb
1975
1976
  - spec/lutaml/xml/register_spec.rb