lutaml-model 0.8.94 → 0.8.96

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.
@@ -99,6 +99,14 @@ module Lutaml
99
99
  attr_rows = [] # [[rule, attr]] hydration metadata
100
100
  plan_attrs = [] # [{name:, kind:}] rows for the engine plan
101
101
  compiled = [] # [rule, attr, kind, spelling, delegate_target]
102
+ ns_attr_names = [] # exact-URI attribute rows (ambiguity guard)
103
+ # Local-name claim tallies for attribute rows: a name claimed
104
+ # by both an exact-URI row and a plain (any-namespace) row
105
+ # cannot be dispatched by the walk — the ns form :none
106
+ # matches any qualification, so the plain row captures the
107
+ # qualified spelling too (#758). The interpretive matcher
108
+ # owns shared names.
109
+ attr_local_claims = Hash.new { |h, k| h[k] = { ns: 0, plain: 0 } }
102
110
  cdata = false
103
111
  mixed_content = false
104
112
  needs_nodes = false
@@ -154,15 +162,10 @@ module Lutaml
154
162
  return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
155
163
 
156
164
  if rule.attribute?
157
- return opt_out!(:non_scalar) unless scalar_type?(attr, register)
158
- # Attribute plan rows are local-name keyed; a type-level
159
- # namespace makes the attribute (URI, local)-identified
160
- # (lutaml-model#744) — the interpretive matcher owns it
161
- # until plan rows carry namespace identity.
162
- return opt_out!(:attr_type_ns) if attr.type_namespace_class(register)
163
-
164
- attr_rows << [rule, attr]
165
- plan_attrs << { name: rule.name.to_s }
165
+ opt = compile_attribute_row(rule, attr, register,
166
+ attr_rows, plan_attrs,
167
+ ns_attr_names, attr_local_claims)
168
+ return opt_out!(opt) if opt
166
169
  elsif rule.content_mapping?
167
170
  return opt_out!(:multi_content) if content_rows(rows) >= 1
168
171
 
@@ -247,12 +250,23 @@ module Lutaml
247
250
  # namespaced models serialize through the interpretive
248
251
  # writer (lutaml-model#847: standalone to_xml under leptris
249
252
  # dropped the element xmlns entirely).
250
- namespaced = !model_ns.nil? || rows.any? { |r| r[:ns] }
253
+ # Exact-URI attribute rows include their namespaced spelling
254
+ # here: the plan serializer writes attributes by local name
255
+ # (no prefixed wire spelling, no declaration emission —
256
+ # leptris#1551), so these models serialize interpretively.
257
+ namespaced = !model_ns.nil? || rows.any? { |r| r[:ns] } ||
258
+ plan_attrs.any? { |a| a[:ns] }
251
259
 
252
260
  flags = []
253
261
  flags << :cdata if cdata
254
262
  flags << :mixed_content if mixed_content
255
- flags << :ns_lenient if model_ns
263
+ # Unconditional: a namespace-less model's children match by
264
+ # local name even when the document declares a default or
265
+ # prefixed namespace (#932) — the interpretive matcher's
266
+ # unprefixed-any-URI behavior; elements (unlike attributes)
267
+ # inherit the default xmlns, so the strict no-URI default
268
+ # dropped every child of a namespaced document.
269
+ flags << :ns_lenient
256
270
  tree = { name: mapping.root_element.to_s,
257
271
  attributes: plan_attrs, children: rows }
258
272
  tree[:ns] = model_ns if model_ns
@@ -267,23 +281,95 @@ module Lutaml
267
281
  return nil
268
282
  end
269
283
 
284
+ return opt_out!(:attr_ns_claim_conflict) if attr_local_claims
285
+ .any? { |_, c| c[:ns].positive? && c[:plain].positive? }
286
+
287
+ # Exact-URI attribute rows rely on the sole-claimant lenient
288
+ # recovery for out-of-namespace spellings (the interpretive
289
+ # exact-first-then-any-qualification precedence), and that
290
+ # recovery reads the source node — models carrying such rows
291
+ # must receive one through the buckets chain.
292
+ needs_nodes = true unless ns_attr_names.empty?
293
+
294
+ # A model combining exact-URI attribute rows with nested
295
+ # child rows needs the walk's nested attribute capture
296
+ # (leptris#1563, fixed 1.9.313.0): older engines drop the
297
+ # children's attribute capture under such plans, so they
298
+ # stay interpretive. Leaf models are unaffected.
299
+ return opt_out!(:attr_type_ns_nested) if plan_attrs.any? { |a| a[:ns] } &&
300
+ rows.any? && !Leptris.nested_attr_capture_compatible?
301
+
302
+ # The catch-all row: kind :wildcard with an explicit :any ns
303
+ # form (pad0) — the two-pass walk routes every child no
304
+ # named row claimed here, tagged for hydrator routing.
305
+ # Document order recovery and bridging happen in the
306
+ # hydrator against the source node.
307
+ if (any_rule = mapping.any_element_rule)
308
+ any_attr = model_class.attributes(register)[any_rule.to]
309
+ return opt_out!(:attr_nil) if any_attr.nil?
310
+
311
+ needs_nodes = true
312
+ wildcard_tag = tag += 1
313
+ compiled << [any_rule, any_attr, :wildcard_any, nil, nil]
314
+ rows << { name: "__lutaml_any__", kind: :wildcard, ns: :any,
315
+ type_tag: wildcard_tag }
316
+ end
317
+
270
318
  { descriptor: descriptor, tree: tree, rows: compiled,
271
319
  attr_rows: attr_rows, mapping: mapping,
272
320
  row_tags: row_tags, namespaced: namespaced,
273
321
  ordered: mapping.ordered? || mapping.mixed_content?,
274
322
  needs_nodes: needs_nodes,
275
- collection_defaults: collection_defaults }
323
+ collection_defaults: collection_defaults,
324
+ ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names,
325
+ wildcard_tag: (wildcard_tag if defined?(wildcard_tag)) }
326
+ end
327
+
328
+ # Attribute rows: identity is (URI, local) (lutaml-model#744).
329
+ # A type-level namespace compiles into an exact-URI AttrPlan ns
330
+ # row (libleptris 1.9.289): the walk captures the (URI, local)
331
+ # match and the sole-claimant lenient recovery in PlanHydrator
332
+ # keeps the interpretive exact-first-then-any-qualification
333
+ # precedence for out-of-namespace spellings. Returns the
334
+ # opt-out clause when the row must stay interpretive, nil
335
+ # otherwise. Types declaring alias URI families stay
336
+ # interpretive (a plan row holds a single exact URI and
337
+ # non-sole-claimant alias families have no plan-side
338
+ # resolution order); :blank / :inherit arrive as Symbols —
339
+ # no URI to express in a row.
340
+ def compile_attribute_row(rule, attr, register, attr_rows,
341
+ plan_attrs, ns_attr_names,
342
+ attr_local_claims)
343
+ return :non_scalar unless scalar_type?(attr, register)
344
+
345
+ attr_rows << [rule, attr]
346
+ type_ns = attr.type_namespace_class(register)
347
+ claim = attr_local_claims[rule.name.to_s]
348
+ if type_ns.nil?
349
+ claim[:plain] += 1
350
+ plan_attrs << { name: rule.name.to_s }
351
+ return nil
352
+ end
353
+ return :attr_type_ns_symbol if type_ns.is_a?(Symbol)
354
+ return :attr_type_ns_aliases unless Leptris.attr_ns_rows_compatible? &&
355
+ type_ns.all_uris.size == 1
356
+
357
+ claim[:ns] += 1
358
+ plan_attrs << { name: rule.name.to_s,
359
+ ns: { exact: type_ns.uri.to_s,
360
+ prefix: type_ns.prefix_default&.to_s } }
361
+ ns_attr_names << rule.name.to_s
362
+ nil
276
363
  end
277
364
 
278
365
  def compilable_mapping?(mapping)
279
366
  # Mapping's uniform interface: root_mappings defaults to
280
367
  # false on the base (KeyValue overrides with its own).
281
- # A map_any_element catch-all is not plan-shaped yet — the
282
- # compiled plan has no row for it, so plan-path models
283
- # would silently drop every catch-all child (0.8.86's
284
- # smoke catch). The interpretive path owns them until the
285
- # plan learns the catch-all row.
286
- return false if mapping.any_element_rule
368
+ # The map_any_element catch-all rides the engine's wildcard
369
+ # row (leptris#1552, named rows take precedence); engines
370
+ # without the kind keep the interpretive refusal.
371
+ return false if mapping.any_element_rule &&
372
+ !Leptris.wildcard_rows_compatible?
287
373
 
288
374
  mapping.root_element && !mapping.root_mappings?
289
375
  end
@@ -344,9 +430,9 @@ module Lutaml
344
430
  # prefix. Blank-namespace rules (xmlns="") match :none.
345
431
  def child_ns(rule, _model_ns)
346
432
  uri = rule.namespace
347
- return { exact: uri.to_s } if uri && !uri.to_s.empty?
433
+ return :none if uri.nil? || uri.to_s.empty?
348
434
 
349
- :none
435
+ { exact: uri.to_s, prefix: rule.prefix&.to_s }
350
436
  end
351
437
 
352
438
  def content_rows(rows)
@@ -360,7 +446,10 @@ module Lutaml
360
446
  # Mapping's uniform interface: namespace_class defaults
361
447
  # to nil on the base (Xml overrides).
362
448
  ns_class = mapping.namespace_class
363
- ns_class&.uri ? { exact: ns_class.uri.to_s } : nil
449
+ return nil unless ns_class&.uri
450
+
451
+ { exact: ns_class.uri.to_s,
452
+ prefix: ns_class.prefix_default&.to_s }
364
453
  end
365
454
 
366
455
  def scalar_type?(attr, register)
@@ -72,6 +72,7 @@ module Lutaml
72
72
  def attributes_kwargs(plan, value, node = nil)
73
73
  kwargs = {}
74
74
  sole_claimants = sole_claimant_names(plan)
75
+ ns_attr_names = plan[:ns_attr_names]
75
76
  plan_attr_rows(plan).each do |rule, attr, name|
76
77
  v = value.attribute(name)
77
78
  if v.nil? && node && sole_claimants.include?(name)
@@ -83,6 +84,14 @@ module Lutaml
83
84
  end
84
85
  next if v.nil?
85
86
 
87
+ # (URI, local) rows mirror the interpretive matcher's
88
+ # ambiguity refusal: a second spelling of the same local
89
+ # name binds nothing (lutaml-model#744).
90
+ if ns_attr_names&.include?(name) &&
91
+ ambiguous_local_spellings?(node, name)
92
+ next
93
+ end
94
+
86
95
  v = v.split(rule.delimiter) if rule.delimiter
87
96
  if rule.as_list && rule.as_list[:import]
88
97
  v = rule.as_list[:import].call(v)
@@ -92,6 +101,53 @@ module Lutaml
92
101
  kwargs
93
102
  end
94
103
 
104
+ # True when the node carries more than one spelling of the
105
+ # local name (any qualification). Only consulted for exact-URI
106
+ # attribute rows.
107
+ def ambiguous_local_spellings?(node, local_name)
108
+ return false unless node.respond_to?(:attributes)
109
+
110
+ seen = false
111
+ node.attributes.each_value do |attr|
112
+ name = attr.name.to_s
113
+ name = name.split(":").last if name.include?(":")
114
+ next unless name == local_name
115
+
116
+ return true if seen
117
+
118
+ seen = true
119
+ end
120
+ false
121
+ end
122
+
123
+ # A captured-but-empty child marshals as a nil string value.
124
+ # The interpretive pipeline classifies the raw "" through the
125
+ # rule's value map (:empty key) — the :empty directive (the
126
+ # plain-rule default) keeps it as "", :nil binds nil, and any
127
+ # other directive means uninitialized, i.e. no binding.
128
+ def mapped_empty_value(rule)
129
+ case rule.value_map(:from)[:empty]
130
+ when :nil then nil
131
+ when :empty then ""
132
+ else :skip
133
+ end
134
+ end
135
+
136
+ def mapped_capture_values(values, rule)
137
+ return values unless values.include?(nil)
138
+
139
+ mapped = mapped_empty_value(rule)
140
+ out = []
141
+ values.each do |item|
142
+ if item.nil?
143
+ out << mapped unless mapped == :skip
144
+ else
145
+ out << item
146
+ end
147
+ end
148
+ out
149
+ end
150
+
95
151
  # Wire-name strings are plan-frozen; materializing them per
96
152
  # hydration allocated two or three strings per attribute row
97
153
  # per instance.
@@ -128,6 +184,10 @@ module Lutaml
128
184
  # Interpretive parity: several captures into a
129
185
  # non-collection attribute arrive as the full array.
130
186
  v = vals.map(&:string_value)
187
+ if v.include?(nil)
188
+ v = v.map { |item| item.nil? ? mapped_empty_value(rule) : item }
189
+ v.reject! { |item| item == :skip }
190
+ end
131
191
  else
132
192
  first = if tag
133
193
  tagged[tag]&.first
@@ -135,16 +195,26 @@ module Lutaml
135
195
  grouped.dig(rule.name.to_s, 0)
136
196
  end
137
197
  v = first&.string_value
198
+ v = mapped_empty_value(rule) if v.nil? && first
138
199
  end
139
- unless v.nil?
200
+ unless v.nil? || v == :skip
140
201
  v = rule.transform_value(attr, v, :from, :xml) if rule.transform.is_a?(Class)
141
202
  assign(kwargs, delegates, delegate, rule, attr, v)
142
203
  end
143
- when :raw, :custom_method, :polymorphic, :content_deferred
204
+ when :wildcard_any
144
205
  # interpreted post-instance (interpret_deferred)
145
206
  when :content
146
- assign(kwargs, delegates, delegate, rule, attr,
147
- content_runs(value))
207
+ runs = content_runs(value)
208
+ if runs.empty? && !attr.collection?
209
+ # An empty element's content binds through the rule's
210
+ # value map for scalar content attributes: "" for the
211
+ # plain :empty directive, nil for :nil, skipped for
212
+ # anything else (uninitialized).
213
+ bound = mapped_empty_value(rule)
214
+ assign(kwargs, delegates, delegate, rule, attr, bound) unless bound == :skip
215
+ else
216
+ assign(kwargs, delegates, delegate, rule, attr, runs)
217
+ end
148
218
  when :ordered_deferred
149
219
  # With a source node the child hydrates natively: one
150
220
  # walk against its own node + element_order from the
@@ -165,7 +235,10 @@ module Lutaml
165
235
  end
166
236
  end
167
237
  when :collection_cb
168
- values = grouped[rule.name.to_s].to_a.map(&:string_value)
238
+ values = grouped[rule.name.to_s].to_a.map do |item|
239
+ item&.string_value
240
+ end
241
+ values = mapped_capture_values(values, rule)
169
242
  if rule.transform.is_a?(Class)
170
243
  values = values.map { |v| rule.transform_value(attr, v, :from, :xml) }
171
244
  end
@@ -178,6 +251,7 @@ module Lutaml
178
251
  else
179
252
  native_collection(value, rule.name.to_s)
180
253
  end
254
+ values = mapped_capture_values(values, rule) unless values.nil?
181
255
  unless values.nil?
182
256
  assign(kwargs, delegates, delegate, rule, attr, values)
183
257
  end
@@ -232,12 +306,19 @@ module Lutaml
232
306
  when :content_deferred
233
307
  runs = content_runs(value)
234
308
  if runs.empty?
235
- # The interpretive pipeline marks every applied rule's
236
- # attribute set (model_transform apply). Skipping the
237
- # mark leaves using_default? true, so the serializer's
238
- # render gate suppresses a mapped reader that derives
239
- # content from other attributes (#856).
309
+ # The interpretive pipeline classifies the raw ""
310
+ # through the rule's value map: the :empty directive
311
+ # (the plain-rule default) binds "" — the attribute is
312
+ # still marked set, which is what the #856 mark is
313
+ # about; :nil binds nil; other directives leave the
314
+ # binding unset.
240
315
  instance.value_set_for(attr.name)
316
+ case rule.value_map(:from)[:empty]
317
+ when :nil
318
+ instance.public_send(:"#{attr.name}=", nil)
319
+ when :empty
320
+ instance.public_send(:"#{attr.name}=", "")
321
+ end
241
322
  next
242
323
  end
243
324
 
@@ -271,6 +352,39 @@ module Lutaml
271
352
 
272
353
  instance.public_send(:"#{attr.name}=",
273
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
+ )
274
388
  when :polymorphic
275
389
  results = deferred_elements(value, rule, buckets).map do |element|
276
390
  attr.cast(element, :xml, register,
@@ -38,16 +38,84 @@ 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(plan)
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
+ class Namespaces
65
+ attr_reader :unspellable
66
+
67
+ def initialize(plan)
68
+ @forms = {}
69
+ @declared = {}
70
+ @unspellable = false
71
+ collect(plan[:tree])
72
+ end
73
+
74
+ def unspellable?
75
+ @unspellable
76
+ end
77
+
78
+ def collect(tree)
79
+ register(tree[:ns])
80
+ (tree[:attributes] || []).each { |attr| register(attr[:ns]) }
81
+ (tree[:children] || []).each do |row|
82
+ register(row[:ns]) if row[:ns].is_a?(Hash)
83
+ collect(row[:plan]) if row[:kind] == :nested && row[:plan]
84
+ end
85
+ end
86
+
87
+ def register(ns_form)
88
+ return if ns_form.nil? || ns_form == :none || ns_form == :any
89
+
90
+ form = { prefix: ns_form[:prefix], uri: ns_form[:exact] }
91
+ @forms[ns_form[:exact]] ||= form
92
+ @unspellable = true if ns_form[:exact] && !ns_form.key?(:prefix)
93
+ end
94
+
95
+ # The wire spelling for a local name under this form.
96
+ def spell(local, ns_form = nil)
97
+ form = ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
98
+ prefix = form && form[:prefix]
99
+ prefix ? "#{prefix}:#{local}" : local
100
+ end
101
+
102
+ def form_for(ns_form)
103
+ return nil if ns_form.nil? || ns_form == :none
104
+
105
+ ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
106
+ end
107
+
108
+ def declare_on(root)
109
+ @forms.each_value do |form|
110
+ key = form[:uri]
111
+ next if key.nil? || @declared[key]
112
+
113
+ @declared[key] = true
114
+ root.add_namespace_definition(form[:prefix], form[:uri])
115
+ end
116
+ end
117
+ end
118
+
51
119
  # Whether the compiled plan is serialize-shaped. Ordered/mixed
52
120
  # models serialize through build_ordered from element_order;
53
121
  # plain models through plan rows.
@@ -63,9 +131,10 @@ module Lutaml
63
131
  Lutaml::Model::Config.default_register
64
132
  end
65
133
 
66
- def build(element, instance, plan, doc, reg = nil)
134
+ def build(element, instance, plan, doc, reg = nil, ns_ctx = nil,
135
+ inherited_ns = nil)
67
136
  reg ||= register
68
- write_attributes(element, instance, plan, reg)
137
+ write_attributes(element, instance, plan, reg, ns_ctx)
69
138
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
70
139
  value = value_of(instance, attr, delegate)
71
140
  next unless rule.render?(value, instance)
@@ -76,33 +145,46 @@ module Lutaml
76
145
  # several occurrences serializes one element per item,
77
146
  # as the interpretive writer does. A plain scalar skips
78
147
  # the Array() wrap — the common case.
148
+ row_ns = plan[:tree][:children]&.find do |r|
149
+ r[:name] == rule.name.to_s
150
+ end&.[](:ns) || inherited_ns
79
151
  if value.is_a?(::Array)
80
152
  value.each do |item|
81
- add_leaf(element, row_name(rule, spelling),
153
+ add_leaf(element,
154
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
82
155
  attr.serialize(item, :xml, reg), doc,
83
156
  attrs: rule.when_attribute)
84
157
  end
85
158
  else
86
- add_leaf(element, row_name(rule, spelling),
159
+ add_leaf(element,
160
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), row_ns) : row_name(rule, spelling),
87
161
  attr.serialize(value, :xml, reg), doc,
88
162
  attrs: rule.when_attribute)
89
163
  end
90
164
  when :collection_native, :collection_cb
165
+ row_ns = plan[:tree][:children]&.find do |r|
166
+ r[:name] == rule.name.to_s
167
+ end&.[](:ns) || inherited_ns
91
168
  Array(value).each do |item|
92
- add_leaf(element, rule.name.to_s,
169
+ add_leaf(element,
170
+ ns_ctx ? ns_ctx.spell(rule.name.to_s, row_ns) : rule.name.to_s,
93
171
  attr.serialize(item, :xml, reg), doc,
94
172
  attrs: rule.when_attribute)
95
173
  end
96
174
  when :nested, :ordered_deferred
97
175
  Array(value).each do |item|
98
176
  child_plan = PlanCompiler.compile(item.class, reg)
99
- child = element.create_child(child_plan[:tree][:name])
177
+ child_ns = child_plan[:tree][:ns] || inherited_ns
178
+ child = element.create_child(
179
+ ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
180
+ )
181
+ ns_ctx&.collect(child_plan[:tree])
100
182
  if child_plan[:ordered]
101
- return nil unless build_ordered(child, item, child_plan,
102
- doc, reg)
183
+ next unless build_ordered(child, item, child_plan,
184
+ doc, reg, ns_ctx, child_ns)
103
185
 
104
186
  else
105
- build(child, item, child_plan, doc, reg)
187
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns)
106
188
  end
107
189
  end
108
190
  when :raw
@@ -121,9 +203,11 @@ module Lutaml
121
203
  # the model are reflected; element_order text is the fallback.
122
204
  # PIs drop (interpretive parity). Returns nil when a nested
123
205
  # ordered child lacks element_order (caller falls back).
124
- def build_ordered(element, instance, plan, doc, reg = nil)
206
+ def build_ordered(element, instance, plan, doc, reg = nil,
207
+ ns_ctx = nil, _inherited_ns = nil)
125
208
  reg ||= register
126
- write_attributes(element, instance, plan, reg)
209
+ ns_ctx&.collect(plan[:tree])
210
+ write_attributes(element, instance, plan, reg, ns_ctx)
127
211
  rows_by_name = {}
128
212
  plan[:rows].each do |rule, attr, kind, spelling, delegate|
129
213
  next unless rule.name
@@ -213,8 +297,16 @@ module Lutaml
213
297
 
214
298
  # Attributes in document order when the instance recorded it
215
299
  # (parsed models), else plan row order.
216
- def write_attributes(element, instance, plan, _reg = nil)
300
+ def write_attributes(element, instance, plan, reg = nil,
301
+ ns_ctx = nil)
217
302
  recorded = instance.attribute_order
303
+ attr_prefixes = {}
304
+ if ns_ctx
305
+ (plan[:tree][:attributes] || []).each do |a|
306
+ form = a[:ns].is_a?(Hash) ? a[:ns] : nil
307
+ attr_prefixes[a[:name]] = form && form[:prefix]
308
+ end
309
+ end
218
310
  if recorded && !recorded.empty?
219
311
  by_name = {}
220
312
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
@@ -223,24 +315,28 @@ module Lutaml
223
315
  names = recorded |
224
316
  plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
225
317
  names.each do |name|
226
- write_attribute(element, instance, name, by_name[name])
318
+ write_attribute(element, instance, name, by_name[name],
319
+ reg, attr_prefixes[name])
227
320
  end
228
321
  else
229
322
  plan[:attr_rows].each do |rule, attr, _sp, delegate|
230
323
  write_attribute(element, instance, rule.name.to_s,
231
- [rule, attr, delegate])
324
+ [rule, attr, delegate], reg,
325
+ attr_prefixes[rule.name.to_s])
232
326
  end
233
327
  end
234
328
  end
235
329
 
236
- def write_attribute(element, instance, name, entry, reg = nil)
330
+ def write_attribute(element, instance, name, entry, reg = nil,
331
+ prefix = nil)
237
332
  return unless entry
238
333
 
239
334
  _rule, attr, delegate = entry
240
335
  value = value_of(instance, attr, delegate)
241
336
  return if value.nil?
242
337
 
243
- element.set_attribute(name,
338
+ spelled = prefix ? "#{prefix}:#{name}" : name
339
+ element.set_attribute(spelled,
244
340
  attr.serialize(value, :xml, reg || register).to_s)
245
341
  end
246
342
 
@@ -16,6 +16,23 @@ module Lutaml
16
16
  # existing opt-out protocol.
17
17
  MIN_LEPTRIS_VERSION = "1.9.273.0"
18
18
 
19
+ # AttrPlan rows carrying an ns form (exact-URI attribute matching,
20
+ # leptris#1486) shipped in libleptris 1.9.289.0. Older gems keep
21
+ # type-namespaced attributes on the interpretive path.
22
+ MIN_LEPTRIS_ATTR_NS_VERSION = "1.9.289.0"
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
+
19
36
  def self.plan_path_compatible?
20
37
  return false unless defined?(Gem)
21
38
 
@@ -23,6 +40,27 @@ module Lutaml
23
40
  spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
24
41
  end
25
42
 
43
+ def self.attr_ns_rows_compatible?
44
+ return false unless defined?(Gem)
45
+
46
+ spec = Gem.loaded_specs["leptris"]
47
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_ATTR_NS_VERSION)
48
+ end
49
+
50
+ def self.nested_attr_capture_compatible?
51
+ return false unless defined?(Gem)
52
+
53
+ spec = Gem.loaded_specs["leptris"]
54
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NESTED_ATTR_VERSION)
55
+ end
56
+
57
+ def self.wildcard_rows_compatible?
58
+ return false unless defined?(Gem)
59
+
60
+ spec = Gem.loaded_specs["leptris"]
61
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WILDCARD_VERSION)
62
+ end
63
+
26
64
  autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
27
65
  autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
28
66
  autoload :PlanOrder, "lutaml/xml/leptris/plan_order"