lutaml-model 0.8.138 → 0.8.140

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: 9b04af29b0ab95619a185f91ca138ff213b771112707395df0aea9d093978aa5
4
- data.tar.gz: c3830c610420cceb888b370417cf2f83a044aa00e10cb3bca7c71c3833587dc4
3
+ metadata.gz: e4340bd1bf4b75d088acb5a60a68073d2980d419fe5c7f59412966956a58e377
4
+ data.tar.gz: 829a741bc56fa070059d8198887cc51051859f64a2aeab747814c5f0d278356b
5
5
  SHA512:
6
- metadata.gz: 205e61d1960bfff5771363582c8db6600f6f703a255b69e7684d40abc04434dc9ef786ec260518930afb4584db219acade52068975fea7ac39604cab82891d28
7
- data.tar.gz: 1a19fc45f5880487573f2849203247015edd843f9c7000a6de992c70aaa439dbcc36e4741f1027b0ca441ee8358ec3ce1559e3f877f91476b80a3b661a3fecfd
6
+ metadata.gz: 14cda9bfd53830559db4848694f3118289958108e17f9445a079c585c8aed5f637878d4b1a0b4f232f1dca8d12100f4d56d73bb8289d2e73cd625a42682c90df
7
+ data.tar.gz: 124577fb5f037a6dea46e3da4f409713fadcc2ca2398e34a578d47230be5651274d3eabac7342fc708b2939c59d56f4248bfe97da88cc39dddce509cdffc83ef
@@ -379,6 +379,13 @@ module Lutaml
379
379
  # Performance: Frozen empty hash to reduce allocations
380
380
  EMPTY_TRANSFORM_HASH = {}.freeze
381
381
 
382
+ # Default-argument options for the serialize lane. The per-call
383
+ # `options = {}` literal allocated one Hash per serialize call
384
+ # (two per mapped XML row); every consumer of the argument only
385
+ # reads it or derives a new hash (merge), so a shared frozen
386
+ # empty is safe.
387
+ EMPTY_OPTIONS = {}.freeze
388
+
382
389
  def transform
383
390
  # Options are fixed after definition (value_policy memoizes the
384
391
  # same way) — the per-call @options[:transform] Hash#[] was hit
@@ -918,7 +925,7 @@ instance_object = nil)
918
925
  true
919
926
  end
920
927
 
921
- def serialize(value, format, register, options = {})
928
+ def serialize(value, format, register, options = EMPTY_OPTIONS)
922
929
  value ||= build_collection if collection? && initialize_empty?
923
930
  return value if value.nil? || Utils.uninitialized?(value)
924
931
 
@@ -374,9 +374,6 @@ module Lutaml
374
374
  # compiled seed cannot know about (SchemaRepository's
375
375
  # @parsed_schemas/@namespace_registry/@type_index): construct
376
376
  # through the real constructor for them, exactly as the walk
377
- # did before the compiled seed existed. The seed stays the
378
- # fast path for classes using the standard initializer.
379
- return new(lutaml_register: register) if constructs_through_initialize?
380
377
  # did before the compiled seed existed, register included. The
381
378
  # seed stays the fast path for classes using the standard
382
379
  # initializer.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.138"
5
+ VERSION = "0.8.140"
6
6
  end
7
7
  end
@@ -31,6 +31,17 @@ module Lutaml
31
31
  ROOT_ROW = 0xFFFF_FFFF
32
32
  EMITTABLE_KINDS = %i[scalar collection nested].freeze
33
33
 
34
+ # Fiber-local buffer of reusable op Hash slots. The engine's
35
+ # build_result copies every op into FFI memory before it
36
+ # returns, so the Ruby hashes are dead weight past the call;
37
+ # recycling them positionally removes one Hash allocation per
38
+ # emitted op (four per mapped row on the emission lane) at no
39
+ # semantic cost. Slots are cleared before every reuse, so
40
+ # stale keys from a prior kind cannot leak into an op.
41
+ OP_BUFFER_KEY = :lutaml_model_leptris_native_emission_op_buffer
42
+ OP_BUFFER_BASE_KEY =
43
+ :lutaml_model_leptris_native_emission_op_buffer_base
44
+
34
45
  class << self
35
46
  def compatible?(plan)
36
47
  !plan[:ordered] && tree_emittable?(plan[:tree])
@@ -49,28 +60,59 @@ module Lutaml
49
60
  end
50
61
  end
51
62
 
63
+ # len is the logical length of the buffer: slots [base, len) hold
64
+ # the ops of the emission in progress. base is nonzero when a
65
+ # user hook re-enters to_xml mid-emission — the inner
66
+ # emission appends after the outer's written slots instead of
67
+ # overwriting them. The tail is trimmed after the engine
68
+ # consumed the result so a pathological document cannot grow
69
+ # the buffer without bound.
52
70
  def emit(instance, plan, register)
53
- ops = [{ kind: :element, row_index: ROOT_ROW }]
54
- emit_element(ops, instance, plan, register, 0)
55
- ops << { kind: :end }
56
- compact = plan[:descriptor].serialize(
57
- plan[:descriptor].build_result(ops),
58
- )
59
- ::Leptris::XML.parse(compact).to_xml(indent: 2, no_decl: true)
71
+ buf = (Thread.current[OP_BUFFER_KEY] ||= [])
72
+ base = (Thread.current[OP_BUFFER_BASE_KEY] ||= 0)
73
+ len = base
74
+ begin
75
+ op_slot(buf, len)
76
+ buf[len][:kind] = :element
77
+ buf[len][:row_index] = ROOT_ROW
78
+ len = emit_element(buf, len + 1, instance, plan, register, 0)
79
+ op_slot(buf, len)
80
+ buf[len][:kind] = :end
81
+ len += 1
82
+ compact = plan[:descriptor].serialize(
83
+ plan[:descriptor].build_result(buf[base, len - base]),
84
+ )
85
+ ::Leptris::XML.parse(compact).to_xml(indent: 2, no_decl: true)
86
+ ensure
87
+ buf.slice!(len..) if buf.size > len
88
+ Thread.current[OP_BUFFER_BASE_KEY] = base
89
+ end
60
90
  end
61
91
 
62
92
  private
63
93
 
94
+ # Cleared slot len of the buffer, reusing the Hash object a
95
+ # previous emission left there when present. Also publishes
96
+ # this emission's exclusive end as the fiber-local base: the
97
+ # walk evaluates user code (model getters, default procs,
98
+ # registered serializers) between slots, and a re-entrant
99
+ # to_xml from that code must append after every written op,
100
+ # never overlap this emission's slots.
101
+ def op_slot(buf, len)
102
+ Thread.current[OP_BUFFER_BASE_KEY] = len + 1
103
+ (buf[len] ||= {}).clear
104
+ end
105
+
64
106
  # plan_index: this plan's flattened index. The engine's
65
107
  # flatten_plan pushes each nested row's child plan exactly
66
108
  # once — collection members share that slot — so child flat
67
109
  # indices come from a static per-plan layout (relative to
68
110
  # this plan's index), never from a runtime counter.
69
- def emit_element(ops, instance, plan, register, plan_index)
111
+ def emit_element(buf, len, instance, plan, register, plan_index)
70
112
  my_index = plan_index
71
113
  layout = flat_layout_for(plan)
72
114
 
73
- emit_attributes(ops, instance, plan, my_index, register)
115
+ len = emit_attributes(buf, len, instance, plan, my_index, register)
74
116
 
75
117
  plan[:rows].each_with_index do |(rule, attr, kind, _spelling, delegate), row_index|
76
118
  value = value_of(instance, attr, delegate)
@@ -83,31 +125,41 @@ module Lutaml
83
125
  # as the DOM lane does.
84
126
  if value.is_a?(::Array)
85
127
  value.each do |item|
86
- ops << scalar_op(my_index, row_index, attr, item, register)
128
+ len = scalar_op(buf, len, my_index, row_index, attr, item,
129
+ register)
87
130
  end
88
131
  else
89
- ops << scalar_op(my_index, row_index, attr, value, register)
132
+ len = scalar_op(buf, len, my_index, row_index, attr, value,
133
+ register)
90
134
  end
91
135
  when :collection_native, :collection_cb
92
136
  Array(value).each do |item|
93
- ops << scalar_op(my_index, row_index, attr, item, register)
137
+ len = scalar_op(buf, len, my_index, row_index, attr, item,
138
+ register)
94
139
  end
95
140
  when :nested
96
141
  _tree, child_rel = layout[row_index]
97
142
  next unless child_rel
98
143
 
144
+ child_plans = child_plans_for(plan, row_index)
99
145
  Array(value).each do |item|
100
- child_plan = nested_plans_for(plan)[[item.class, rule.name.to_s]] ||
101
- (nested_plans_for(plan)[[item.class, rule.name.to_s]] =
146
+ child_plan = child_plans[item.class] ||
147
+ (child_plans[item.class] =
102
148
  PlanCompiler.compile(item.class, register))
103
- ops << { kind: :element, plan_index: my_index,
104
- row_index: row_index }
105
- emit_element(ops, item, child_plan,
106
- register, my_index + child_rel)
107
- ops << { kind: :end }
149
+ op_slot(buf, len)
150
+ buf[len][:kind] = :element
151
+ buf[len][:plan_index] = my_index
152
+ buf[len][:row_index] = row_index
153
+ len += 1
154
+ len = emit_element(buf, len, item, child_plan,
155
+ register, my_index + child_rel)
156
+ op_slot(buf, len)
157
+ buf[len][:kind] = :end
158
+ len += 1
108
159
  end
109
160
  end
110
161
  end
162
+ len
111
163
  end
112
164
 
113
165
  # {nested row_index => [child tree, relative flat index]} —
@@ -133,19 +185,50 @@ module Lutaml
133
185
  end
134
186
  end
135
187
 
136
- def emit_attributes(ops, instance, plan, plan_index, register)
188
+ # The name bookkeeping is static per plan — attr_rows and the
189
+ # tree's attribute rows never vary per instance — so the
190
+ # name->tree row_index map is memoized on the plan. Only
191
+ # instances carrying a recorded attribute order (parsed
192
+ # documents whose XML attribute sequence must be replayed)
193
+ # take the per-instance path.
194
+ def emit_attributes(buf, len, instance, plan, plan_index, register)
137
195
  recorded = instance.attribute_order
138
- attr_rows = plan[:attr_rows]
139
196
  if recorded && !recorded.empty?
140
- by_name = {}
141
- attr_rows.each { |rule, attr, _sp, delegate| by_name[rule.name.to_s] = [rule, attr, delegate] }
142
- names = recorded | attr_rows.map { |rule, _a, _s, _d| rule.name.to_s }
143
- else
144
- names = attr_rows.map { |rule, _a, _s, _d| rule.name.to_s }
145
- by_name = {}
146
- attr_rows.each { |rule, attr, _sp, delegate| by_name[rule.name.to_s] = [rule, attr, delegate] }
197
+ emit_attributes_recorded(buf, len, instance, plan, plan_index,
198
+ register, recorded)
199
+ return len
200
+ end
201
+
202
+ tree_attrs = plan[:tree][:attributes] || []
203
+ row_indices = attr_row_indices_for(plan, tree_attrs)
204
+ attr_rows = plan[:attr_rows]
205
+ attr_rows.each do |rule, attr, _sp, delegate|
206
+ value = value_of(instance, attr, delegate)
207
+ next if value.nil?
208
+
209
+ name = rule.name.to_s
210
+ row_index = row_indices[name]
211
+ next unless row_index
212
+
213
+ op_slot(buf, len)
214
+ buf[len][:kind] = :attr
215
+ buf[len][:plan_index] = plan_index
216
+ buf[len][:row_index] = row_index
217
+ buf[len][:name] = name
218
+ buf[len][:value] = attr.serialize(value, :xml, register).to_s
219
+ len += 1
147
220
  end
221
+ len
222
+ end
223
+
224
+ def emit_attributes_recorded(buf, len, instance, plan, plan_index,
225
+ register, recorded)
226
+ attr_rows = plan[:attr_rows]
227
+ by_name = {}
228
+ attr_rows.each { |rule, attr, _sp, delegate| by_name[rule.name.to_s] = [rule, attr, delegate] }
229
+ names = recorded | attr_rows.map { |rule, _a, _s, _d| rule.name.to_s }
148
230
  tree_attrs = plan[:tree][:attributes] || []
231
+ row_indices = attr_row_indices_for(plan, tree_attrs)
149
232
  names.each do |name|
150
233
  entry = by_name[name]
151
234
  next unless entry
@@ -154,18 +237,40 @@ module Lutaml
154
237
  value = value_of(instance, attr, delegate)
155
238
  next if value.nil?
156
239
 
157
- row_index = tree_attrs.index { |a| a[:name] == name }
240
+ row_index = row_indices[name]
158
241
  next unless row_index
159
242
 
160
- ops << { kind: :attr, plan_index: plan_index,
161
- row_index: row_index, name: name,
162
- value: attr.serialize(value, :xml, register).to_s }
243
+ op_slot(buf, len)
244
+ buf[len][:kind] = :attr
245
+ buf[len][:plan_index] = plan_index
246
+ buf[len][:row_index] = row_index
247
+ buf[len][:name] = name
248
+ buf[len][:value] = attr.serialize(value, :xml, register).to_s
249
+ len += 1
250
+ end
251
+ len
252
+ end
253
+
254
+ # name -> tree_attrs index, first occurrence wins (same match
255
+ # semantics as the Array#index scan it replaced).
256
+ def attr_row_indices_for(plan, tree_attrs)
257
+ return plan[:attr_row_indices] if plan[:attr_row_indices]
258
+
259
+ indices = {}
260
+ tree_attrs.each_with_index do |tree_attr, i|
261
+ name = tree_attr[:name]
262
+ indices[name] = i unless indices.key?(name)
163
263
  end
264
+ plan[:attr_row_indices] = indices
164
265
  end
165
266
 
166
- def scalar_op(plan_index, row_index, attr, value, register)
167
- { kind: :scalar, plan_index: plan_index, row_index: row_index,
168
- value: attr.serialize(value, :xml, register).to_s }
267
+ def scalar_op(buf, len, plan_index, row_index, attr, value, register)
268
+ op_slot(buf, len)
269
+ buf[len][:kind] = :scalar
270
+ buf[len][:plan_index] = plan_index
271
+ buf[len][:row_index] = row_index
272
+ buf[len][:value] = attr.serialize(value, :xml, register).to_s
273
+ len + 1
169
274
  end
170
275
 
171
276
  def value_of(instance, attr, delegate)
@@ -177,8 +282,11 @@ module Lutaml
177
282
  end
178
283
  end
179
284
 
180
- def nested_plans_for(plan)
181
- plan[:nested_plans] ||= {}
285
+ # Compiled child plans per nested row, keyed by row index and
286
+ # then by member class. Keying through Integers and classes
287
+ # avoids allocating a [class, name] pair per emitted child.
288
+ def child_plans_for(plan, row_index)
289
+ (plan[:nested_plans_by_row] ||= {})[row_index] ||= {}
182
290
  end
183
291
  end
184
292
  end
@@ -1556,6 +1556,7 @@ module Lutaml
1556
1556
  @attributes
1557
1557
  @elements
1558
1558
  @processing_instruction_mappings
1559
+ @all_attributes_rule
1559
1560
  @register_elements
1560
1561
  @register_attributes
1561
1562
  @register_element_sequences
@@ -511,9 +511,11 @@ module Lutaml
511
511
  end
512
512
 
513
513
  # #1031: wildcard attribute families. After the explicit attribute
514
- # rules have consumed their names, every remaining unprefixed
515
- # attribute matching the rule's pattern lands in the target Hash —
516
- # names claimed by explicit rules never leak into it.
514
+ # rules have consumed their names, every remaining attribute
515
+ # matching the rule's pattern lands in the target Hash — names
516
+ # claimed by explicit rules never leak into it. The key is the name
517
+ # as written, prefix included: keyed by its local name, xlink:href
518
+ # would serialize back as a plain href attribute.
517
519
  def apply_all_attributes_deserialization(doc, instance, xml_mapping)
518
520
  rule = xml_mapping.all_attributes_rule
519
521
  return if rule.nil?
@@ -524,8 +526,9 @@ module Lutaml
524
526
  claimed = attribute_names_claimed_by_rules(xml_mapping)
525
527
  remainder = {}
526
528
  element.attributes.each_value do |attr|
527
- name = attr.unprefixed_name.to_s
528
- next if claimed.include?(name)
529
+ next if claimed.include?(attr.unprefixed_name.to_s)
530
+
531
+ name = attr.name.to_s
529
532
  next if rule.matching && !rule.matching.match?(name)
530
533
 
531
534
  remainder[name] = attr.value
@@ -86,4 +86,35 @@ RSpec.describe "map_all_attributes" do
86
86
 
87
87
  expect(model.other_attributes).to eq("zeta" => "1")
88
88
  end
89
+
90
+ it "keeps a prefixed attribute's prefix through a round-trip" do
91
+ xml = '<mn xmlns:xlink="http://www.w3.org/1999/xlink" name="x" ' \
92
+ 'data-title="T" xlink:href="#t"/>'
93
+ klass = Class.new(Lutaml::Model::Serializable) do
94
+ attribute :name, :string
95
+ attribute :other_attributes, :hash
96
+
97
+ xml do
98
+ element "mn"
99
+ map_attribute "name", to: :name
100
+ map_all_attributes to: :other_attributes
101
+ end
102
+ end
103
+ stub_const("MapAllPrefixedModel", klass)
104
+
105
+ model = klass.from_xml(xml)
106
+
107
+ expect(model.other_attributes).to eq("data-title" => "T", "xlink:href" => "#t")
108
+ expect(model.to_xml).to include('xlink:href="#t"')
109
+ expect(model.to_xml).not_to include(' href="#t"')
110
+ end
111
+
112
+ it "is inherited by a subclass along with the other mappings" do
113
+ stub_const("MapAllAttributesChild", Class.new(stubbed))
114
+
115
+ model = MapAllAttributesChild.from_xml('<mn name="x" data-title="T"/>')
116
+
117
+ expect(model.data_attributes).to eq("data-title" => "T")
118
+ expect(model.to_xml).to include('data-title="T"')
119
+ end
89
120
  end
@@ -819,4 +819,65 @@ RSpec.describe "XML plan fast path" do
819
819
  )
820
820
  end
821
821
  end
822
+
823
+ describe "native emission op buffer" do
824
+ around do |example|
825
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
826
+ end
827
+
828
+ it "keeps a re-entrant to_xml from a getter from corrupting the outer emission" do
829
+ inner = Class.new(Lutaml::Model::Serializable) do
830
+ attribute :v, :string
831
+
832
+ xml do
833
+ element "inner"
834
+ map_attribute "v", to: :v
835
+ end
836
+ end
837
+ outer = Class.new(Lutaml::Model::Serializable) do
838
+ attribute :v, :string
839
+ attribute :items, inner, collection: true
840
+
841
+ xml do
842
+ element "outer"
843
+ map_attribute "v", to: :v
844
+ map_element "inner", to: :items
845
+ end
846
+
847
+ define_method(:items) do
848
+ inner.new(v: "reentrant").to_xml
849
+ @items
850
+ end
851
+ end
852
+
853
+ model = outer.new(v: "x", items: [inner.new(v: "1"), inner.new(v: "2")])
854
+ expected = "<outer v=\"x\">\n <inner v=\"1\"/>\n <inner v=\"2\"/>\n</outer>"
855
+ expect(model.to_xml).to eq(expected)
856
+ expect(model.to_xml).to eq(expected)
857
+ end
858
+
859
+ it "keeps interleaved emissions of distinct models stable" do
860
+ alpha = Class.new(Lutaml::Model::Serializable) do
861
+ attribute :a, :string
862
+
863
+ xml do
864
+ element "alpha"
865
+ map_attribute "a", to: :a
866
+ end
867
+ end
868
+ beta = Class.new(Lutaml::Model::Serializable) do
869
+ attribute :b, :integer
870
+
871
+ xml do
872
+ element "beta"
873
+ map_attribute "b", to: :b
874
+ end
875
+ end
876
+
877
+ 5.times do
878
+ expect(alpha.new(a: "x").to_xml).to eq("<alpha a=\"x\"/>")
879
+ expect(beta.new(b: 7).to_xml).to eq("<beta b=\"7\"/>")
880
+ end
881
+ end
882
+ end
822
883
  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.138
4
+ version: 0.8.140
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-10-27 00:00:00.000000000 Z
11
+ date: 2026-10-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64