lutaml-model 0.8.137 → 0.8.139

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: 6c2afc32a6388d8ebb0a54a71a1e985860511be854729f2776a5075f255ff0eb
4
- data.tar.gz: 6bc50c18c84ab0dc27466b32d9de5566d52d95eb54669c33dcea41fde3ac98f6
3
+ metadata.gz: a027b25146b1db5759062241ef72d4e6cfb7122f4dff800d5683535bf4767c5b
4
+ data.tar.gz: 2b49a35df1f800d47d18b2e79737fe58fccc79385a2f38e5e5c9169acae84063
5
5
  SHA512:
6
- metadata.gz: f2e701ec4c50e37c12c781d14b2fae5296cb95c0845b77a505d3f160c6c7b9ba7b960dadfe2e6d7b580122b8f122b6f1824d6f576f82f39767725ed508676503
7
- data.tar.gz: e31699cf34fb46a2c0b2a0cec58e3fce25681c2a1f91c0eab74b0694a60e0f248cb666d5194bfb6630293095b696486f1c2c8c16f854f8303f3bf9b7932ae083
6
+ metadata.gz: e66ba7f1fff3188bbe477aa108681b53cfbacaec36f7dfd652a54c6305f083a28fc4992e6586312fc22da9bded0864f5336f848538b755a315f8c4fbdde3d427
7
+ data.tar.gz: 8f1039a43171dea42d3bf5f3db295ae4bc44e15d087fc531a9910515d307634410d3a6b102adfff2b5ee30764ff0e19dbefdeb9e1c84618332981bc95f4c67ab
@@ -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
 
@@ -376,7 +376,11 @@ module Lutaml
376
376
  # through the real constructor for them, exactly as the walk
377
377
  # did before the compiled seed existed. The seed stays the
378
378
  # fast path for classes using the standard initializer.
379
- return new if walk_needs_constructor?
379
+ return new(lutaml_register: register) if constructs_through_initialize?
380
+ # did before the compiled seed existed, register included. The
381
+ # seed stays the fast path for classes using the standard
382
+ # initializer.
383
+ return new(lutaml_register: register) if constructs_through_initialize?
380
384
 
381
385
  instance = allocate
382
386
  instance.init_walk_state(register_id)
@@ -388,8 +392,8 @@ module Lutaml
388
392
  # optional setup, and the compiled walk seed would skip it.
389
393
  # (The standard initializer's owner is Serialize::Builder; a
390
394
  # class- or parent-defined initialize makes the owner the class.)
391
- def walk_needs_constructor?
392
- @walk_needs_constructor ||=
395
+ def constructs_through_initialize?
396
+ @constructs_through_initialize ||=
393
397
  instance_method(:initialize).owner != Lutaml::Model::Serialize::Builder
394
398
  end
395
399
 
@@ -407,6 +411,14 @@ module Lutaml
407
411
  # stack limit — models without pending deferred imports skip
408
412
  # it entirely.
409
413
  ensure_imports!(register_id) if finalized? && deferred_imports_pending?(register_id)
414
+ # Custom-initialize classes (#1032's twin): their initialize is
415
+ # constructor contract — internal state the compiled seeding
416
+ # cannot know about — so they construct through the real
417
+ # constructor, then carry the parse's register context.
418
+ if constructs_through_initialize?
419
+ instance = new(lutaml_register: register_id)
420
+ return instance
421
+ end
410
422
  instance = allocate
411
423
  instance.finalize_deserialization(register_id)
412
424
  instance
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.137"
5
+ VERSION = "0.8.139"
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
@@ -122,6 +122,8 @@ module Lutaml
122
122
  # them (compiling here silently dropped pi_settings).
123
123
  return opt_out!(:processing_instruction) unless
124
124
  mapping.processing_instruction_mappings.empty?
125
+ # Wildcard attribute families ride the interpretive path too
126
+ return opt_out!(:all_attributes) if mapping.all_attributes_rule
125
127
 
126
128
  rows = []
127
129
  attr_rows = [] # [[rule, attr, row_name]] hydration metadata
@@ -38,7 +38,7 @@ module Lutaml
38
38
  @xml_instance
39
39
  end
40
40
 
41
- attr_reader :root_element, :namespace_uri, :namespace_prefix, :element_sequence, :mappings_imported, :namespace_class, :namespace_param, :element_name, :documentation_text, :type_name_value, :namespace_scope, :mapper_class, :xml_space, :consolidation_maps, :processing_instruction_mappings, :any_element_rule
41
+ attr_reader :root_element, :namespace_uri, :namespace_prefix, :element_sequence, :mappings_imported, :namespace_class, :namespace_param, :element_name, :documentation_text, :type_name_value, :namespace_scope, :mapper_class, :xml_space, :consolidation_maps, :processing_instruction_mappings, :any_element_rule, :all_attributes_rule
42
42
 
43
43
  def initialize
44
44
  super
@@ -49,6 +49,7 @@ module Lutaml
49
49
  @element_sequence = []
50
50
  @content_mapping = nil
51
51
  @raw_mapping = nil
52
+ @all_attributes_rule = nil
52
53
  @mixed_content = false
53
54
  @xml_space = nil
54
55
  @format = :xml
@@ -875,6 +876,26 @@ module Lutaml
875
876
  )
876
877
  end
877
878
 
879
+ # Capture every attribute not claimed by an explicit map_attribute
880
+ # (and matching an optional pattern) into a Hash attribute, and emit
881
+ # it back as attributes after the mapped ones on serialization —
882
+ # the remainder semantics of map_all, for attribute families
883
+ # (lutaml-model#1031): HTML data-*, ARIA aria-*, on* handlers.
884
+ #
885
+ # @param to [Symbol] the Hash attribute name
886
+ # @param matching [Regexp, nil] optional name filter; nil captures
887
+ # every unclaimed attribute
888
+ #
889
+ # @example
890
+ # xml do
891
+ # element "mn"
892
+ # map_attribute "name", to: :name
893
+ # map_all_attributes to: :other_attributes, matching: /\A(data-|aria-|on)/
894
+ # end
895
+ def map_all_attributes(to:, matching: nil)
896
+ @all_attributes_rule = AllAttributesMapping.new(to, matching)
897
+ end
898
+
878
899
  def sequence(&block)
879
900
  @element_sequence << ::Lutaml::Model::Sequence.new(self,
880
901
  format: :xml).tap do |s|
@@ -1681,5 +1702,21 @@ module Lutaml
1681
1702
  self.class.new(@target.dup, @to)
1682
1703
  end
1683
1704
  end
1705
+
1706
+ # Wildcard attribute-family mapping (#1031): the target Hash collects
1707
+ # every attribute no explicit map_attribute claims and that matches
1708
+ # the optional pattern; serialization emits each pair as an attribute.
1709
+ class AllAttributesMapping
1710
+ attr_reader :to, :matching
1711
+
1712
+ def initialize(to, matching)
1713
+ @to = to
1714
+ @matching = matching
1715
+ end
1716
+
1717
+ def dup
1718
+ self.class.new(@to, @matching)
1719
+ end
1720
+ end
1684
1721
  end
1685
1722
  end
@@ -503,11 +503,50 @@ module Lutaml
503
503
  apply_processing_instruction_deserialization(doc, instance, xml_mapping,
504
504
  effective_register)
505
505
 
506
+ apply_all_attributes_deserialization(doc, instance, xml_mapping)
507
+
506
508
  run_consolidation(instance, effective_register, instance_is_serialize)
507
509
 
508
510
  instance
509
511
  end
510
512
 
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.
517
+ def apply_all_attributes_deserialization(doc, instance, xml_mapping)
518
+ rule = xml_mapping.all_attributes_rule
519
+ return if rule.nil?
520
+
521
+ element = doc.is_a?(::Lutaml::Xml::XmlElement) ? doc : doc.root
522
+ return if element.nil?
523
+
524
+ claimed = attribute_names_claimed_by_rules(xml_mapping)
525
+ remainder = {}
526
+ element.attributes.each_value do |attr|
527
+ name = attr.unprefixed_name.to_s
528
+ next if claimed.include?(name)
529
+ next if rule.matching && !rule.matching.match?(name)
530
+
531
+ remainder[name] = attr.value
532
+ end
533
+ return if remainder.empty?
534
+
535
+ instance.public_send(:"#{rule.to}=", remainder)
536
+ instance.value_set_for(rule.to) if instance.is_a?(::Lutaml::Model::Serialize)
537
+ end
538
+
539
+ # Attribute names claimed by explicit map_attribute rules, plus the
540
+ # engine-handled schemaLocation (which renders through
541
+ # raw_schema_location, not the remainder).
542
+ def attribute_names_claimed_by_rules(xml_mapping)
543
+ names = %w[schemaLocation]
544
+ xml_mapping.mapping_attributes_hash.each_value do |rule_or_array|
545
+ Array(rule_or_array).each { |rule| names << rule.name.to_s }
546
+ end
547
+ names
548
+ end
549
+
511
550
  def validate_document!(doc, options)
512
551
  return unless doc.is_a?(Array)
513
552
 
@@ -101,6 +101,9 @@ module Lutaml
101
101
  apply_standard_rules(root, model_instance, options)
102
102
  end
103
103
 
104
+ # The wildcard attribute family emits after the mapped attributes
105
+ apply_all_attributes_mappings(root, model_instance, mapping)
106
+
104
107
  root
105
108
  end
106
109
 
@@ -242,6 +245,27 @@ module Lutaml
242
245
  end
243
246
 
244
247
  # Check if element order should be used for serialization
248
+
249
+ # #1031: emit the wildcard attribute family after the mapped
250
+ # attributes. Attributes render in insertion order; the remainder
251
+ # follows the explicitly mapped names.
252
+ def apply_all_attributes_mappings(root, model_instance, mapping)
253
+ rule = mapping.all_attributes_rule
254
+ return if rule.nil?
255
+
256
+ value = model_instance.public_send(rule.to)
257
+ return if value.nil? || !value.is_a?(::Hash)
258
+
259
+ value.each do |name, attr_value|
260
+ next if attr_value.nil?
261
+
262
+ root.add_attribute(
263
+ ::Lutaml::Xml::DataModel::XmlAttribute.new(name.to_s,
264
+ attr_value.to_s, nil),
265
+ )
266
+ end
267
+ end
268
+
245
269
  #
246
270
  # @param model_instance [Object] The model instance
247
271
  # @param mapping [Xml::Mapping] The mapping
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # #1031: wildcard attribute families — every attribute no explicit
6
+ # map_attribute claims and that matches the pattern lands in a Hash and
7
+ # is emitted back after the mapped attributes on serialization.
8
+ RSpec.describe "map_all_attributes" do
9
+ let(:model_class) do
10
+ Class.new(Lutaml::Model::Serializable) do
11
+ attribute :name, :string
12
+ attribute :data_attributes, :hash
13
+
14
+ xml do
15
+ element "mn"
16
+ map_attribute "name", to: :name
17
+ map_all_attributes to: :data_attributes, matching: /\A(data-|aria-|on)/
18
+ end
19
+ end
20
+ end
21
+
22
+ let(:stubbed) do
23
+ stub_const("MapAllAttributesModel", model_class)
24
+ MapAllAttributesModel
25
+ end
26
+
27
+ it "captures unclaimed, pattern-matching attributes into the Hash" do
28
+ xml = '<mn name="x" data-title="T" onclick="f()" aria-label="L" unre="nope"/>'
29
+ model = stubbed.from_xml(xml)
30
+
31
+ expect(model.name).to eq("x")
32
+ expect(model.data_attributes).to eq(
33
+ "data-title" => "T", "onclick" => "f()", "aria-label" => "L",
34
+ )
35
+ end
36
+
37
+ it "never lets mapped names leak into the remainder" do
38
+ xml = '<mn name="x" data-name="D"/>'
39
+ model = stubbed.from_xml(xml)
40
+
41
+ expect(model.data_attributes).to eq("data-name" => "D")
42
+ end
43
+
44
+ it "emits the remainder after the mapped attributes on serialization" do
45
+ model = stubbed.from_xml(
46
+ '<mn name="x" data-title="T" onclick="f()"/>',
47
+ )
48
+ out = model.to_xml
49
+
50
+ expect(out).to include('name="x"')
51
+ expect(out).to include('data-title="T"')
52
+ expect(out).to include('onclick="f()"')
53
+ expect(out.index("name=")).to be < out.index("data-title=")
54
+ end
55
+
56
+ it "round-trips losslessly" do
57
+ xml = '<mn name="x" data-title="T" onclick="f()" aria-label="L"/>'
58
+ model = stubbed.from_xml(xml)
59
+ round_tripped = stubbed.from_xml(model.to_xml)
60
+
61
+ expect(round_tripped.data_attributes).to eq(model.data_attributes)
62
+ expect(round_tripped.name).to eq(model.name)
63
+ end
64
+
65
+ it "emits nothing when the remainder is empty" do
66
+ model = stubbed.from_xml('<mn name="x"/>')
67
+ out = model.to_xml
68
+
69
+ expect(out).not_to include("data-")
70
+ end
71
+
72
+ it "captures every unclaimed attribute when no pattern is given" do
73
+ klass = Class.new(Lutaml::Model::Serializable) do
74
+ attribute :name, :string
75
+ attribute :other_attributes, :hash
76
+
77
+ xml do
78
+ element "el"
79
+ map_attribute "name", to: :name
80
+ map_all_attributes to: :other_attributes
81
+ end
82
+ end
83
+ stub_const("MapAllNoPatternModel", klass)
84
+
85
+ model = klass.from_xml('<el name="x" zeta="1"/>')
86
+
87
+ expect(model.other_attributes).to eq("zeta" => "1")
88
+ end
89
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # A class that defines its own initialize is built through it by the
6
+ # from_* walk, and the walk hands it the register exactly as
7
+ # `new(lutaml_register:)` did before the compiled seed existed: a
8
+ # constructor that tells a blank construction from a caller's by that
9
+ # keyword sees the same call either way.
10
+ RSpec.describe "Walk construction of a custom-initialize class" do
11
+ let(:model) do
12
+ Class.new(Lutaml::Model::Serializable) do
13
+ attribute :name, :string
14
+
15
+ json do
16
+ map "name", to: :name
17
+ end
18
+
19
+ class << self
20
+ attr_accessor :constructions
21
+ end
22
+
23
+ def initialize(*args, **kwargs)
24
+ self.class.constructions << [args, kwargs.keys]
25
+ @internal = :ready
26
+ super
27
+ end
28
+
29
+ attr_reader :internal
30
+ end
31
+ end
32
+
33
+ before do
34
+ stub_const("WalkConstructorModel", model)
35
+ model.constructions = []
36
+ end
37
+
38
+ it "runs the class's own initialize" do
39
+ expect(model.from_json('{"name":"a"}').internal).to eq(:ready)
40
+ end
41
+
42
+ it "passes the register, as the walk did through new(lutaml_register:)" do
43
+ parsed = model.from_json('{"name":"a"}')
44
+
45
+ expect(model.constructions).to eq([[[], [:lutaml_register]]])
46
+ expect(parsed.name).to eq("a")
47
+ end
48
+ 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.137
4
+ version: 0.8.139
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
@@ -1796,6 +1796,7 @@ files:
1796
1796
  - spec/lutaml/model/liquid/indexed_access_spec.rb
1797
1797
  - spec/lutaml/model/liquid_compatibility_spec.rb
1798
1798
  - spec/lutaml/model/logger_spec.rb
1799
+ - spec/lutaml/model/map_all_attributes_spec.rb
1799
1800
  - spec/lutaml/model/map_all_spec.rb
1800
1801
  - spec/lutaml/model/map_content_spec.rb
1801
1802
  - spec/lutaml/model/map_unmapped_spec.rb
@@ -1912,6 +1913,7 @@ files:
1912
1913
  - spec/lutaml/model/value_map_spec.rb
1913
1914
  - spec/lutaml/model/value_policy_spec.rb
1914
1915
  - spec/lutaml/model/value_transformation_comprehensive_spec.rb
1916
+ - spec/lutaml/model/walk_constructor_spec.rb
1915
1917
  - spec/lutaml/model/with_child_mapping_spec.rb
1916
1918
  - spec/lutaml/model/writer_assigned_attribute_spec.rb
1917
1919
  - spec/lutaml/model/xml_decoupling_spec.rb