lutaml-model 0.8.95 → 0.8.97

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.
@@ -86,8 +86,49 @@ module Lutaml
86
86
  end
87
87
  end
88
88
 
89
+ # Two engine walk defects gate walk-side consumers here; both
90
+ # bail models to the interpretive matcher, whose exact-first-
91
+ # then-any-qualification precedence binds what the walk drops.
92
+ # The serialize side (PlanSerializer) reads the Ruby tree and
93
+ # stays on the fast path. leptris 1.9.320.0 (#1587) fixes
94
+ # both defects — the scan is skipped there and every model
95
+ # rides the walk again.
96
+ #
97
+ # leptris#1585: exact-URI CHILD-row ns_uri strings are not
98
+ # retained engine-side — once the build anchors are GC'd (a
99
+ # warm walk plus one GC suffices), those rows stop matching
100
+ # and hydration silently answers nil.
101
+ #
102
+ # leptris#1586: plain attribute rows do not leniently match
103
+ # namespace-qualified wire attributes (uniword fontTable:
104
+ # every w:name/w:val against a plain attr row hydrates nil).
105
+ # A plan level carrying plain attr rows under a namespaced
106
+ # chain therefore cannot be trusted on the walk; attribute
107
+ # rows with an exact URI (or :any) and plans without any
108
+ # namespace form are unaffected.
109
+ def engine_walk_safe?(plan)
110
+ return true if Leptris.walk_ns_safety_compatible?
111
+
112
+ tree_safe?(plan[:tree], false)
113
+ end
114
+
89
115
  private
90
116
 
117
+ def tree_safe?(tree, ancestor_ns)
118
+ namespaced = ancestor_ns || tree[:ns].is_a?(Hash)
119
+ attrs = tree[:attributes] || []
120
+ return false if namespaced &&
121
+ attrs.any? { |a| !a[:ns].is_a?(Hash) && a[:ns] != :any }
122
+
123
+ (tree[:children] || []).all? do |row|
124
+ return false if row[:ns].is_a?(Hash)
125
+
126
+ next true unless row[:kind] == :nested && row[:plan]
127
+
128
+ tree_safe?(row[:plan], namespaced)
129
+ end
130
+ end
131
+
91
132
  def build(model_class, register)
92
133
  return nil unless model_class.is_a?(Class) &&
93
134
  model_class.include?(::Lutaml::Model::Serialize)
@@ -162,7 +203,7 @@ module Lutaml
162
203
  return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
163
204
 
164
205
  if rule.attribute?
165
- opt = compile_attribute_row(rule, attr, register,
206
+ opt = compile_attribute_row(rule, attr, register, mapping,
166
207
  attr_rows, plan_attrs,
167
208
  ns_attr_names, attr_local_claims)
168
209
  return opt_out!(opt) if opt
@@ -225,7 +266,22 @@ module Lutaml
225
266
  return opt_out!(:non_scalar) unless scalar_type?(attr, register)
226
267
 
227
268
  row = { name: rule.name.to_s }
228
- row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
269
+ if rule.namespace_set?
270
+ row[:ns] = child_ns(rule, model_ns)
271
+ else
272
+ # The interpretive writer qualifies an element row
273
+ # by its attribute's type namespace when the rule
274
+ # declares none (resolve step 4). Rows without the
275
+ # type form inherit the tree namespace and land
276
+ # under the wrong default xmlns (uniword
277
+ # CoreProperties: <dc:title> spelled <title>).
278
+ type_ns_form = element_type_ns_form(rule, attr,
279
+ register, model_ns)
280
+ return opt_out!(:elem_type_ns_symbol) if type_ns_form == :bail_symbol
281
+ return opt_out!(:elem_type_ns_aliases) if type_ns_form == :bail_aliases
282
+
283
+ row[:ns] = type_ns_form if type_ns_form
284
+ end
229
285
  # lutaml-model#88: same-name rows partitioned by the
230
286
  # rule's discriminator ride the engine's exclusive
231
287
  # predicate match (leptris 1.9.221+, #1272).
@@ -292,14 +348,28 @@ module Lutaml
292
348
  needs_nodes = true unless ns_attr_names.empty?
293
349
 
294
350
  # A model combining exact-URI attribute rows with nested
295
- # child rows stays interpretive: the engine's walk drops
296
- # nested-children attribute capture under such plans
297
- # (FontTable's w:name under an mc:Ignorable ns row —
298
- # observed 1.9.311.3; filed upstream). Leaf models keep the
299
- # lift — that is the measured win (uniword's deep property
300
- # models are leaves).
351
+ # child rows needs the walk's nested attribute capture
352
+ # (leptris#1563, fixed 1.9.313.0): older engines drop the
353
+ # children's attribute capture under such plans, so they
354
+ # stay interpretive. Leaf models are unaffected.
301
355
  return opt_out!(:attr_type_ns_nested) if plan_attrs.any? { |a| a[:ns] } &&
302
- rows.any?
356
+ rows.any? && !Leptris.nested_attr_capture_compatible?
357
+
358
+ # The catch-all row: kind :wildcard with an explicit :any ns
359
+ # form (pad0) — the two-pass walk routes every child no
360
+ # named row claimed here, tagged for hydrator routing.
361
+ # Document order recovery and bridging happen in the
362
+ # hydrator against the source node.
363
+ if (any_rule = mapping.any_element_rule)
364
+ any_attr = model_class.attributes(register)[any_rule.to]
365
+ return opt_out!(:attr_nil) if any_attr.nil?
366
+
367
+ needs_nodes = true
368
+ wildcard_tag = tag += 1
369
+ compiled << [any_rule, any_attr, :wildcard_any, nil, nil]
370
+ rows << { name: "__lutaml_any__", kind: :wildcard, ns: :any,
371
+ type_tag: wildcard_tag }
372
+ end
303
373
 
304
374
  { descriptor: descriptor, tree: tree, rows: compiled,
305
375
  attr_rows: attr_rows, mapping: mapping,
@@ -307,7 +377,8 @@ module Lutaml
307
377
  ordered: mapping.ordered? || mapping.mixed_content?,
308
378
  needs_nodes: needs_nodes,
309
379
  collection_defaults: collection_defaults,
310
- ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names }
380
+ ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names,
381
+ wildcard_tag: (wildcard_tag if defined?(wildcard_tag)) }
311
382
  end
312
383
 
313
384
  # Attribute rows: identity is (URI, local) (lutaml-model#744).
@@ -322,8 +393,8 @@ module Lutaml
322
393
  # non-sole-claimant alias families have no plan-side
323
394
  # resolution order); :blank / :inherit arrive as Symbols —
324
395
  # no URI to express in a row.
325
- def compile_attribute_row(rule, attr, register, attr_rows,
326
- plan_attrs, ns_attr_names,
396
+ def compile_attribute_row(rule, attr, register, mapping,
397
+ attr_rows, plan_attrs, ns_attr_names,
327
398
  attr_local_claims)
328
399
  return :non_scalar unless scalar_type?(attr, register)
329
400
 
@@ -331,6 +402,24 @@ module Lutaml
331
402
  type_ns = attr.type_namespace_class(register)
332
403
  claim = attr_local_claims[rule.name.to_s]
333
404
  if type_ns.nil?
405
+ # attribute_form_default :qualified on the model's
406
+ # namespace qualifies locally-declared attributes under
407
+ # the parent URI — the interpretive
408
+ # resolve_attribute_namespace contract (WML: updateFields
409
+ # w:val under xmlns="..."). The W3C default is
410
+ # :unqualified, so == :qualified means explicitly set.
411
+ # Rules with an explicit :blank stay unprefixed.
412
+ ns_class = mapping.namespace_class
413
+ if rule.namespace_param != :blank && ns_class &&
414
+ ns_class.attribute_form_default == :qualified
415
+ claim[:ns] += 1
416
+ plan_attrs << { name: rule.name.to_s,
417
+ ns: { exact: ns_class.uri.to_s,
418
+ prefix: ns_class.prefix_default&.to_s } }
419
+ ns_attr_names << rule.name.to_s
420
+ return nil
421
+ end
422
+
334
423
  claim[:plain] += 1
335
424
  plan_attrs << { name: rule.name.to_s }
336
425
  return nil
@@ -341,7 +430,8 @@ module Lutaml
341
430
 
342
431
  claim[:ns] += 1
343
432
  plan_attrs << { name: rule.name.to_s,
344
- ns: { exact: type_ns.uri.to_s } }
433
+ ns: { exact: type_ns.uri.to_s,
434
+ prefix: type_ns.prefix_default&.to_s } }
345
435
  ns_attr_names << rule.name.to_s
346
436
  nil
347
437
  end
@@ -349,12 +439,11 @@ module Lutaml
349
439
  def compilable_mapping?(mapping)
350
440
  # Mapping's uniform interface: root_mappings defaults to
351
441
  # false on the base (KeyValue overrides with its own).
352
- # A map_any_element catch-all is not plan-shaped yet — the
353
- # compiled plan has no row for it, so plan-path models
354
- # would silently drop every catch-all child (0.8.86's
355
- # smoke catch). The interpretive path owns them until the
356
- # plan learns the catch-all row.
357
- return false if mapping.any_element_rule
442
+ # The map_any_element catch-all rides the engine's wildcard
443
+ # row (leptris#1552, named rows take precedence); engines
444
+ # without the kind keep the interpretive refusal.
445
+ return false if mapping.any_element_rule &&
446
+ !Leptris.wildcard_rows_compatible?
358
447
 
359
448
  mapping.root_element && !mapping.root_mappings?
360
449
  end
@@ -415,9 +504,32 @@ module Lutaml
415
504
  # prefix. Blank-namespace rules (xmlns="") match :none.
416
505
  def child_ns(rule, _model_ns)
417
506
  uri = rule.namespace
418
- return { exact: uri.to_s } if uri && !uri.to_s.empty?
507
+ return :none if uri.nil? || uri.to_s.empty?
508
+
509
+ { exact: uri.to_s, prefix: rule.prefix&.to_s }
510
+ end
511
+
512
+ # Type-namespace ns form for a scalar element row, mirroring
513
+ # the interpretive resolve_element_namespace step 4: the
514
+ # attribute's type namespace qualifies the row when the rule
515
+ # declares none. Rows inheriting the tree namespace (type ns
516
+ # nil, :inherit, or equal to the model's own URI) get no form.
517
+ # :blank and alias families have no row-expressible form —
518
+ # the model stays interpretive.
519
+ def element_type_ns_form(rule, attr, register, model_ns)
520
+ return nil if rule.namespace_set?
419
521
 
420
- :none
522
+ type_ns = attr.type_namespace_class(register)
523
+ return nil if type_ns.nil? || type_ns == :inherit
524
+ return :bail_symbol if type_ns.is_a?(Symbol)
525
+ return :bail_aliases unless Leptris.attr_ns_rows_compatible? &&
526
+ type_ns.all_uris.size == 1
527
+
528
+ form = { exact: type_ns.uri.to_s,
529
+ prefix: type_ns.prefix_default&.to_s }
530
+ return nil if form[:exact] == model_ns&.[](:exact)
531
+
532
+ form
421
533
  end
422
534
 
423
535
  def content_rows(rows)
@@ -431,7 +543,10 @@ module Lutaml
431
543
  # Mapping's uniform interface: namespace_class defaults
432
544
  # to nil on the base (Xml overrides).
433
545
  ns_class = mapping.namespace_class
434
- ns_class&.uri ? { exact: ns_class.uri.to_s } : nil
546
+ return nil unless ns_class&.uri
547
+
548
+ { exact: ns_class.uri.to_s,
549
+ prefix: ns_class.prefix_default&.to_s }
435
550
  end
436
551
 
437
552
  def scalar_type?(attr, register)
@@ -201,7 +201,7 @@ module Lutaml
201
201
  v = rule.transform_value(attr, v, :from, :xml) if rule.transform.is_a?(Class)
202
202
  assign(kwargs, delegates, delegate, rule, attr, v)
203
203
  end
204
- when :raw, :custom_method, :polymorphic, :content_deferred
204
+ when :wildcard_any
205
205
  # interpreted post-instance (interpret_deferred)
206
206
  when :content
207
207
  runs = content_runs(value)
@@ -352,6 +352,39 @@ module Lutaml
352
352
 
353
353
  instance.public_send(:"#{attr.name}=",
354
354
  attr.collection? ? results : results.first)
355
+ when :wildcard_any
356
+ # map_any_element: every child element no named row
357
+ # claims, in document order, bridged as wrappers — the
358
+ # interpretive pass's contract. The wildcard row's
359
+ # type_tag marks its captures in the walk.
360
+ next unless node
361
+
362
+ claimed = plan[:rows].filter_map do |wr, _wa, _wk, spelling, _wd|
363
+ next if wr.equal?(rule) || wr.name.nil?
364
+
365
+ spelling ? spelling.to_s : wr.name.to_s
366
+ end.uniq
367
+ elements = []
368
+ node.children.each do |child|
369
+ next unless child.is_a?(::Leptris::XML::Element)
370
+ next if claimed.include?(child.name)
371
+
372
+ elements << bridge_element(child)
373
+ end
374
+ next if elements.empty?
375
+
376
+ # The interpretive pass casts each collected element
377
+ # through the attribute: String keeps the wrapper,
378
+ # Serializable types build model instances.
379
+ results = elements.map do |element|
380
+ attr.cast(element, :xml, register,
381
+ lutaml_parent: instance,
382
+ lutaml_root: instance.lutaml_root || instance)
383
+ end
384
+ instance.public_send(
385
+ :"#{attr.name}=",
386
+ attr.collection? ? results : results.first,
387
+ )
355
388
  when :polymorphic
356
389
  results = deferred_elements(value, rule, buckets).map do |element|
357
390
  attr.cast(element, :xml, register,
@@ -38,16 +38,135 @@ module Lutaml
38
38
  return nil if plan[:ordered] && instance.element_order.nil?
39
39
 
40
40
  doc = ::Leptris::XML::Document.create
41
- root = doc.create_element(plan[:tree][:name])
41
+ ns_ctx = Namespaces.new(instance, plan, register)
42
+ return nil if ns_ctx.unspellable?
43
+
44
+ root = doc.create_element(
45
+ ns_ctx.spell(plan[:tree][:name], plan[:tree][:ns]),
46
+ )
42
47
  doc.root = root
48
+ ns_ctx.declare_on(root)
43
49
  if plan[:ordered]
44
50
  build_ordered(root, instance, plan, doc, register)
45
51
  else
46
- build(root, instance, plan, doc, register)
52
+ build(root, instance, plan, doc, register, ns_ctx)
47
53
  end
48
54
  doc.to_xml(indent: 2, no_decl: true)
49
55
  end
50
56
 
57
+ # Namespace spelling for namespaced plans (#847): every ns
58
+ # form resolves to a wire spelling (prefix:local, or local
59
+ # under a default xmlns), and every distinct (prefix, uri)
60
+ # pair is declared exactly once on the output root in
61
+ # first-encounter order — the #1558 contract on the DOM
62
+ # path. A nested plan's own tree ns overrides the inherited
63
+ # one; rows without an ns form inherit.
64
+ # Namespace spelling sourced from the interpretive writer's
65
+ # own machinery: NamespaceCollector + DeclarationPlanner
66
+ # decide prefix-vs-default per namespace exactly as the
67
+ # interpretive path does — no heuristic divergence (the
68
+ # uniword FontScheme/Image regressions). Option-bearing
69
+ # calls (prefix: true) never reach here; the serializer's
70
+ # plain spelling is the interpretive writer's plain
71
+ # spelling.
72
+ class Namespaces
73
+ attr_reader :unspellable
74
+
75
+ def initialize(instance, plan, register)
76
+ @forms = {}
77
+ @hoisted = {}
78
+ @declared = {}
79
+ @unspellable = false
80
+ mapping = plan[:mapping]
81
+ # The interpretive writer plans from the BUILT element
82
+ # tree: absent attributes (nil values) and absent
83
+ # children contribute no namespace needs, so an element
84
+ # whose own namespace would otherwise be prefix-hoisted
85
+ # (W3C: prefixed attributes need a prefix declaration)
86
+ # falls back to default-ns format. Collecting from the
87
+ # instance reproduces that — collecting from nil (type
88
+ # analysis) hoists every declared need and diverges
89
+ # (`<w:b xmlns:w>` vs the interpretive `<b xmlns>` when
90
+ # val=true omits the only prefixed attribute).
91
+ ns_prefix = if instance.is_a?(::Lutaml::Model::Serialize)
92
+ instance.xml_namespace_prefix
93
+ end
94
+ collect_options = { mapper_class: instance.class }
95
+ if ns_prefix && !ns_prefix.empty?
96
+ collect_options[:__xml_namespace_prefix] = ns_prefix
97
+ end
98
+ needs = Lutaml::Xml::NamespaceCollector.new(register).collect(
99
+ instance, mapping, **collect_options
100
+ )
101
+ planner = Lutaml::Xml::DeclarationPlanner.new(register)
102
+ declared_plan = planner.plan(
103
+ instance.class, mapping, needs, options: {}
104
+ )
105
+ declared_plan.root_node&.hoisted_declarations&.each do |prefix, uri|
106
+ @hoisted[uri] = prefix
107
+ @forms[uri] = { prefix: prefix, uri: uri }
108
+ end
109
+ collect(plan[:tree])
110
+ end
111
+
112
+ def unspellable?
113
+ @unspellable
114
+ end
115
+
116
+ def collect(tree)
117
+ check(tree[:ns])
118
+ (tree[:attributes] || []).each { |a| check(a[:ns]) }
119
+ (tree[:children] || []).each do |row|
120
+ check(row[:ns]) if row[:ns].is_a?(Hash)
121
+ collect(row[:plan]) if row[:kind] == :nested && row[:plan]
122
+ end
123
+ end
124
+
125
+ # An exact form the planner did not declare cannot be
126
+ # spelled — the serializer falls back interpretively.
127
+ def check(ns_form)
128
+ return if ns_form.nil? || ns_form == :none || ns_form == :any
129
+
130
+ uri = ns_form[:exact]
131
+ @unspellable = true if uri && !@forms.key?(uri)
132
+ end
133
+
134
+ # The planner's hoisted prefix for a URI (nil when hoisted
135
+ # as the default namespace).
136
+ def prefix_for(uri)
137
+ @hoisted[uri]
138
+ end
139
+
140
+ # The wire spelling for a local name under this form: the
141
+ # planner's hoisted prefix for the URI, or unprefixed when
142
+ # hoisted as the default namespace.
143
+ def spell(local, ns_form = nil)
144
+ uri = if ns_form.is_a?(Hash)
145
+ ns_form[:exact]
146
+ else
147
+ @forms.key?(ns_form) ? @forms[ns_form][:uri] : nil
148
+ end
149
+ prefix = uri ? @hoisted[uri] : nil
150
+ prefix ? "#{prefix}:#{local}" : local
151
+ end
152
+
153
+ def form_for(ns_form)
154
+ return nil if ns_form.nil? || ns_form == :none
155
+
156
+ ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
157
+ end
158
+
159
+ def declare_on(root, _attr_form_prefix = nil)
160
+ @hoisted.each do |uri, prefix|
161
+ key = uri
162
+ next if key.nil? || @declared[key]
163
+
164
+ @declared[key] = true
165
+ root.add_namespace_definition(prefix, uri)
166
+ end
167
+ end
168
+ end
169
+
51
170
  # Whether the compiled plan is serialize-shaped. Ordered/mixed
52
171
  # models serialize through build_ordered from element_order;
53
172
  # plain models through plan rows.
@@ -63,9 +182,10 @@ module Lutaml
63
182
  Lutaml::Model::Config.default_register
64
183
  end
65
184
 
66
- def build(element, instance, plan, doc, reg = nil)
185
+ def build(element, instance, plan, doc, reg = nil, ns_ctx = nil,
186
+ inherited_ns = nil)
67
187
  reg ||= register
68
- write_attributes(element, instance, plan, reg)
188
+ write_attributes(element, instance, plan, reg, ns_ctx)
69
189
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
70
190
  value = value_of(instance, attr, delegate)
71
191
  next unless rule.render?(value, instance)
@@ -76,33 +196,46 @@ module Lutaml
76
196
  # several occurrences serializes one element per item,
77
197
  # as the interpretive writer does. A plain scalar skips
78
198
  # the Array() wrap — the common case.
199
+ row_ns = plan[:tree][:children]&.find do |r|
200
+ r[:name] == rule.name.to_s
201
+ end&.[](:ns) || inherited_ns
79
202
  if value.is_a?(::Array)
80
203
  value.each do |item|
81
- add_leaf(element, row_name(rule, spelling),
204
+ add_leaf(element,
205
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
82
206
  attr.serialize(item, :xml, reg), doc,
83
207
  attrs: rule.when_attribute)
84
208
  end
85
209
  else
86
- add_leaf(element, row_name(rule, spelling),
210
+ add_leaf(element,
211
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
87
212
  attr.serialize(value, :xml, reg), doc,
88
213
  attrs: rule.when_attribute)
89
214
  end
90
215
  when :collection_native, :collection_cb
216
+ row_ns = plan[:tree][:children]&.find do |r|
217
+ r[:name] == rule.name.to_s
218
+ end&.[](:ns) || inherited_ns
91
219
  Array(value).each do |item|
92
- add_leaf(element, rule.name.to_s,
220
+ add_leaf(element,
221
+ ns_ctx ? ns_ctx.spell(rule.name.to_s, row_ns) : rule.name.to_s,
93
222
  attr.serialize(item, :xml, reg), doc,
94
223
  attrs: rule.when_attribute)
95
224
  end
96
225
  when :nested, :ordered_deferred
97
226
  Array(value).each do |item|
98
227
  child_plan = PlanCompiler.compile(item.class, reg)
99
- child = element.create_child(child_plan[:tree][:name])
228
+ child_ns = child_plan[:tree][:ns] || inherited_ns
229
+ child = element.create_child(
230
+ ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
231
+ )
232
+ ns_ctx&.collect(child_plan[:tree])
100
233
  if child_plan[:ordered]
101
- return nil unless build_ordered(child, item, child_plan,
102
- doc, reg)
234
+ next unless build_ordered(child, item, child_plan,
235
+ doc, reg, ns_ctx, child_ns)
103
236
 
104
237
  else
105
- build(child, item, child_plan, doc, reg)
238
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns)
106
239
  end
107
240
  end
108
241
  when :raw
@@ -121,9 +254,11 @@ module Lutaml
121
254
  # the model are reflected; element_order text is the fallback.
122
255
  # PIs drop (interpretive parity). Returns nil when a nested
123
256
  # ordered child lacks element_order (caller falls back).
124
- def build_ordered(element, instance, plan, doc, reg = nil)
257
+ def build_ordered(element, instance, plan, doc, reg = nil,
258
+ ns_ctx = nil, _inherited_ns = nil)
125
259
  reg ||= register
126
- write_attributes(element, instance, plan, reg)
260
+ ns_ctx&.collect(plan[:tree])
261
+ write_attributes(element, instance, plan, reg, ns_ctx)
127
262
  rows_by_name = {}
128
263
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
129
264
  next unless rule.name
@@ -213,8 +348,25 @@ module Lutaml
213
348
 
214
349
  # Attributes in document order when the instance recorded it
215
350
  # (parsed models), else plan row order.
216
- def write_attributes(element, instance, plan, _reg = nil)
351
+ def write_attributes(element, instance, plan, reg = nil,
352
+ ns_ctx = nil)
217
353
  recorded = instance.attribute_order
354
+ attr_prefixes = {}
355
+ if ns_ctx
356
+ (plan[:tree][:attributes] || []).each do |a|
357
+ form = a[:ns].is_a?(Hash) ? a[:ns] : nil
358
+ # Namespaced attributes always spell prefixed (W3C:
359
+ # attributes cannot use the default namespace) — the
360
+ # interpretive writer emits the type namespace's own
361
+ # prefix even when the element hoists the same URI as
362
+ # the default namespace (updateFields w:val under
363
+ # xmlns="...").
364
+ attr_prefixes[a[:name]] =
365
+ if form
366
+ form[:prefix] || ns_ctx.prefix_for(form[:exact])
367
+ end
368
+ end
369
+ end
218
370
  if recorded && !recorded.empty?
219
371
  by_name = {}
220
372
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
@@ -223,24 +375,28 @@ module Lutaml
223
375
  names = recorded |
224
376
  plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
225
377
  names.each do |name|
226
- write_attribute(element, instance, name, by_name[name])
378
+ write_attribute(element, instance, name, by_name[name],
379
+ reg, attr_prefixes[name])
227
380
  end
228
381
  else
229
382
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
230
383
  write_attribute(element, instance, rule.name.to_s,
231
- [rule, attr, delegate])
384
+ [rule, attr, delegate], reg,
385
+ attr_prefixes[rule.name.to_s])
232
386
  end
233
387
  end
234
388
  end
235
389
 
236
- def write_attribute(element, instance, name, entry, reg = nil)
390
+ def write_attribute(element, instance, name, entry, reg = nil,
391
+ prefix = nil)
237
392
  return unless entry
238
393
 
239
394
  _rule, attr, delegate = entry
240
395
  value = value_of(instance, attr, delegate)
241
396
  return if value.nil?
242
397
 
243
- element.set_attribute(name,
398
+ spelled = prefix ? "#{prefix}:#{name}" : name
399
+ element.set_attribute(spelled,
244
400
  attr.serialize(value, :xml, reg || register).to_s)
245
401
  end
246
402
 
@@ -16,6 +16,7 @@ module Lutaml
16
16
  register = Lutaml::Model::Config.default_register
17
17
  plan = PlanCompiler.compile(model_class, register)
18
18
  return nil unless plan
19
+ return nil unless PlanCompiler.engine_walk_safe?(plan)
19
20
 
20
21
  document = ::Leptris::XML.parse(xml)
21
22
  root = document.root
@@ -21,6 +21,27 @@ module Lutaml
21
21
  # type-namespaced attributes on the interpretive path.
22
22
  MIN_LEPTRIS_ATTR_NS_VERSION = "1.9.289.0"
23
23
 
24
+ # Nested child plans capture their declared attribute rows
25
+ # correctly again as of 1.9.313.0 (leptris#1563: the walk dropped
26
+ # children's attribute capture when the parent plan carried an
27
+ # exact-URI attribute row). Older gems keep those models
28
+ # interpretive.
29
+ MIN_LEPTRIS_NESTED_ATTR_VERSION = "1.9.313.0"
30
+
31
+ # Wildcard child rows (leptris#1552: named-rows-take-precedence
32
+ # two-pass walk, ns-form aware, type_tag echo) — the plan path's
33
+ # map_any_element catch-all. Ships with the 1.9.313 binding.
34
+ MIN_LEPTRIS_WILDCARD_VERSION = "1.9.313.0"
35
+
36
+ # Walk-side namespace safety (leptris#1587, closing #1585 and
37
+ # #1586): child-row exact-URI ns_uri is retained engine-side
38
+ # (deep-copied at build, freed with the plan) and plain attribute
39
+ # rows leniently match namespace-qualified wire attributes
40
+ # (exact-first-then-any, top level and nested). Both shipped in
41
+ # 1.9.320.0; older engines keep the affected models interpretive
42
+ # through PlanCompiler.engine_walk_safe?.
43
+ MIN_LEPTRIS_WALK_NS_SAFETY_VERSION = "1.9.320.0"
44
+
24
45
  def self.plan_path_compatible?
25
46
  return false unless defined?(Gem)
26
47
 
@@ -35,6 +56,32 @@ module Lutaml
35
56
  spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_ATTR_NS_VERSION)
36
57
  end
37
58
 
59
+ def self.nested_attr_capture_compatible?
60
+ return false unless defined?(Gem)
61
+
62
+ spec = Gem.loaded_specs["leptris"]
63
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NESTED_ATTR_VERSION)
64
+ end
65
+
66
+ def self.wildcard_rows_compatible?
67
+ return false unless defined?(Gem)
68
+
69
+ spec = Gem.loaded_specs["leptris"]
70
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WILDCARD_VERSION)
71
+ end
72
+
73
+ # Memoized: consulted per parse on the walk gate hot path, and
74
+ # the loaded leptris spec cannot change within a process.
75
+ @walk_ns_safety = nil
76
+ def self.walk_ns_safety_compatible?
77
+ return @walk_ns_safety unless @walk_ns_safety.nil?
78
+ return (@walk_ns_safety = false) unless defined?(Gem)
79
+
80
+ spec = Gem.loaded_specs["leptris"]
81
+ @walk_ns_safety =
82
+ !!(spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WALK_NS_SAFETY_VERSION))
83
+ end
84
+
38
85
  autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
39
86
  autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
40
87
  autoload :PlanOrder, "lutaml/xml/leptris/plan_order"