lutaml-model 0.8.101 → 0.8.103

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.
@@ -53,12 +53,16 @@ module Lutaml
53
53
  # name and wildcard rows carry their own :any.
54
54
  ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
55
55
  raw].freeze
56
+ # Row kinds whose hydration can dispatch per source node —
57
+ # the only safe claimants of a contested (multi-row) name.
58
+ PER_NODE_ROW_KINDS = %i[nested ordered_deferred
59
+ child_deferred].freeze
56
60
 
57
61
  class << self
58
62
  def compile(model_class, register)
59
- # Version gate, not a capability probe: the plan path needs
60
- # leptris >= 1.9.273.0 (the child-handles snapshot). Older
61
- # gems take the interpretive path via the nil opt-out.
63
+ # Version gate: below MIN_LEPTRIS_VERSION (the single
64
+ # capability floor) gems take the interpretive path via
65
+ # the nil opt-out.
62
66
  return nil unless Leptris.plan_path_compatible?
63
67
 
64
68
  # A child model's declared lutaml_default_register takes
@@ -96,58 +100,31 @@ module Lutaml
96
100
  end
97
101
  end
98
102
 
99
- # Two engine walk defects gate walk-side consumers here; both
100
- # bail models to the interpretive matcher, whose exact-first-
101
- # then-any-qualification precedence binds what the walk drops.
102
- # The serialize side (PlanSerializer) reads the Ruby tree and
103
- # stays on the fast path. leptris 1.9.320.0 (#1587) fixes
104
- # both defects — the scan is skipped there and every model
105
- # rides the walk again.
106
- #
107
- # leptris#1585: exact-URI CHILD-row ns_uri strings are not
108
- # retained engine-side — once the build anchors are GC'd (a
109
- # warm walk plus one GC suffices), those rows stop matching
110
- # and hydration silently answers nil.
111
- #
112
- # leptris#1586: plain attribute rows do not leniently match
113
- # namespace-qualified wire attributes (uniword fontTable:
114
- # every w:name/w:val against a plain attr row hydrates nil).
115
- # A plan level carrying plain attr rows under a namespaced
116
- # chain therefore cannot be trusted on the walk; attribute
117
- # rows with an exact URI (or :any) and plans without any
118
- # namespace form are unaffected.
119
- def engine_walk_safe?(plan)
120
- return true if Leptris.walk_ns_safety_compatible?
121
-
122
- tree_safe?(plan[:tree], false)
123
- end
124
-
125
103
  private
126
104
 
127
- def tree_safe?(tree, ancestor_ns)
128
- namespaced = ancestor_ns || tree[:ns].is_a?(Hash)
129
- attrs = tree[:attributes] || []
130
- return false if namespaced &&
131
- attrs.any? { |a| !a[:ns].is_a?(Hash) && a[:ns] != :any }
132
-
133
- (tree[:children] || []).all? do |row|
134
- return false if row[:ns].is_a?(Hash)
135
-
136
- next true unless row[:kind] == :nested && row[:plan]
137
-
138
- tree_safe?(row[:plan], namespaced)
139
- end
140
- end
141
-
142
105
  def build(model_class, register)
143
106
  return nil unless model_class.is_a?(Class) &&
144
107
  model_class.include?(::Lutaml::Model::Serialize)
145
108
 
146
109
  mapping = model_class.mappings_for(:xml, register)
147
110
  return nil unless compilable_mapping?(mapping)
111
+ # Ordered/mixed models ride the plan path only when opted
112
+ # in: the per-node ordered hydration beats the interpretive
113
+ # lane on time but allocates above the downstream budget
114
+ # gates (niso pnas ratio 1.25) — it ships dark until the
115
+ # allocation diet lands.
116
+ if (mapping.ordered? || mapping.mixed_content?) &&
117
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
118
+ return opt_out!(:ordered_opt_in)
119
+ end
120
+ # Processing-instruction mappings (rfcxml's <?rfc toc?>)
121
+ # have no plan-row spelling — the interpretive path owns
122
+ # them (compiling here silently dropped pi_settings).
123
+ return opt_out!(:processing_instruction) unless
124
+ mapping.processing_instruction_mappings.empty?
148
125
 
149
126
  rows = []
150
- attr_rows = [] # [[rule, attr]] hydration metadata
127
+ attr_rows = [] # [[rule, attr, row_name]] hydration metadata
151
128
  plan_attrs = [] # [{name:, kind:}] rows for the engine plan
152
129
  compiled = [] # [rule, attr, kind, spelling, delegate_target]
153
130
  ns_attr_names = [] # exact-URI attribute rows (ambiguity guard)
@@ -243,28 +220,26 @@ module Lutaml
243
220
  type = attr.type(register)
244
221
  if serializable_type?(type)
245
222
  child = compile(type, register)
246
- return opt_out!(:child_uncompilable) unless child
247
-
248
- if child[:ordered]
249
- # Ordered/mixed children need element_order on their
250
- # instances; the walk hands back plan values, not
251
- # source nodes, so the subtree defers interpretively
252
- # (the fragment parse runs the full machinery,
253
- # order included).
254
- return opt_out!(:ordered_child_with_ns) if model_ns
255
-
256
- needs_nodes = true
257
- compiled << [rule, attr, :ordered_deferred, nil,
258
- delegate_target]
259
- rows << { name: rule.name.to_s, kind: :raw }
260
- else
261
- compiled << [rule, attr, :nested, nil, delegate_target]
262
- needs_nodes ||= child[:needs_nodes]
263
- nested_row = { name: rule.name.to_s, kind: :nested,
264
- plan: child[:tree] }
265
- nested_row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
266
- rows << nested_row
223
+ if child.nil? &&
224
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
225
+ child_mapping = type.mappings_for(:xml, register)
226
+ if child_mapping&.ordered? ||
227
+ child_mapping&.mixed_content?
228
+ # A child refused ONLY by the ordered opt-in gate
229
+ # cascades: the bridged-island deferral drops its
230
+ # mixed children (customxml schemaRefs), so the
231
+ # parent compiles to nil — main's whole-model
232
+ # interpretive shape.
233
+ return opt_out!(:ordered_opt_in_cascade)
234
+ end
267
235
  end
236
+ needs_nodes = true if child.nil? || child[:ordered]
237
+ compiled_entry, row = child_row_for(rule, attr, type, child,
238
+ delegate_target,
239
+ model_ns)
240
+ compiled << compiled_entry
241
+ needs_nodes ||= child && child[:needs_nodes]
242
+ rows << row
268
243
  elsif rule.multiple_mappings?
269
244
  rule.name.each do |spelling|
270
245
  compiled << [rule, attr, :spelling, spelling.to_s,
@@ -312,6 +287,19 @@ module Lutaml
312
287
 
313
288
  row_tags = partition_row_tags!(compiled, rows, tag)
314
289
 
290
+ # Same-name rows cross-echo in the engine walk: every source
291
+ # element of a name lands in every row of that name (two
292
+ # w14:/w15: docId rows each capture both elements). Names
293
+ # claimed by more than one element row are contested — they
294
+ # hydrate per source node (the ordered_deferred pattern)
295
+ # when every claimant is per-node-dispatchable, and opt the
296
+ # model out otherwise (the interpretive matcher owns the
297
+ # (URI, local) dispatch there).
298
+ contested = contested_element_names(rows, compiled)
299
+ return opt_out!(:contested_names) if contested == :unsafe
300
+
301
+ needs_nodes = true unless contested.empty?
302
+
315
303
  # The plan serializer does not emit namespace declarations —
316
304
  # namespaced models serialize through the interpretive
317
305
  # writer (lutaml-model#847: standalone to_xml under leptris
@@ -335,13 +323,13 @@ module Lutaml
335
323
  flags << :ns_lenient
336
324
 
337
325
  # Namespace-less model child rows bind the strict unwritten
338
- # spelling (bare, or inherited default xmlns) on engines
339
- # with the leptris#1560 :unqualified form — the interpretive
340
- # matcher's exact no-namespace semantic, refusing prefixed
341
- # spellings where the ns_lenient superset bound them.
342
- # Rows with an explicit form (rule ns, type ns, :none, :any)
343
- # are untouched; name-matched element row kinds only.
344
- if model_ns.nil? && Leptris.ns_unqualified_rows_compatible?
326
+ # spelling (bare, or inherited default xmlns) — the
327
+ # interpretive matcher's exact no-namespace semantic,
328
+ # refusing prefixed spellings where the ns_lenient
329
+ # superset bound them. Rows with an explicit form (rule ns,
330
+ # type ns, :none, :any) are untouched; name-matched element
331
+ # row kinds only.
332
+ if model_ns.nil?
345
333
  rows.each do |r|
346
334
  next if r[:ns]
347
335
 
@@ -373,14 +361,6 @@ module Lutaml
373
361
  # must receive one through the buckets chain.
374
362
  needs_nodes = true unless ns_attr_names.empty?
375
363
 
376
- # A model combining exact-URI attribute rows with nested
377
- # child rows needs the walk's nested attribute capture
378
- # (leptris#1563, fixed 1.9.313.0): older engines drop the
379
- # children's attribute capture under such plans, so they
380
- # stay interpretive. Leaf models are unaffected.
381
- return opt_out!(:attr_type_ns_nested) if plan_attrs.any? { |a| a[:ns] } &&
382
- rows.any? && !Leptris.nested_attr_capture_compatible?
383
-
384
364
  # The catch-all row: kind :wildcard with an explicit :any ns
385
365
  # form (pad0) — the two-pass walk routes every child no
386
366
  # named row claimed here, tagged for hydrator routing.
@@ -403,6 +383,7 @@ module Lutaml
403
383
  ordered: mapping.ordered? || mapping.mixed_content?,
404
384
  needs_nodes: needs_nodes,
405
385
  collection_defaults: collection_defaults,
386
+ contested_names: contested.empty? ? nil : contested,
406
387
  ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names,
407
388
  wildcard_tag: (wildcard_tag if defined?(wildcard_tag)) }
408
389
  end
@@ -424,9 +405,19 @@ module Lutaml
424
405
  attr_local_claims)
425
406
  return :non_scalar unless scalar_type?(attr, register)
426
407
 
427
- attr_rows << [rule, attr]
408
+ raw_name = rule.name.to_s
409
+ # W3C-reserved xml:* attributes bind by raw name — the xml
410
+ # prefix is implicit (never a model namespace), so form
411
+ # defaults must not qualify them under the model's URI
412
+ # (w:t's xml:space under a :qualified WML mapping).
413
+ if raw_name.start_with?("xml:")
414
+ attr_local_claims[raw_name][:plain] += 1
415
+ plan_attrs << { name: raw_name }
416
+ attr_rows << [rule, attr, raw_name]
417
+ return nil
418
+ end
428
419
  type_ns = attr.type_namespace_class(register)
429
- claim = attr_local_claims[rule.name.to_s]
420
+ raw_name
430
421
  if type_ns.nil?
431
422
  # attribute_form_default :qualified on the model's
432
423
  # namespace qualifies locally-declared attributes under
@@ -438,39 +429,108 @@ module Lutaml
438
429
  ns_class = mapping.namespace_class
439
430
  if rule.namespace_param != :blank && ns_class &&
440
431
  ns_class.attribute_form_default == :qualified
441
- claim[:ns] += 1
442
- plan_attrs << { name: rule.name.to_s,
432
+ row_name = local_attr_name(raw_name)
433
+ attr_local_claims[row_name][:ns] += 1
434
+ plan_attrs << { name: row_name,
443
435
  ns: { exact: ns_class.uri.to_s,
444
436
  prefix: ns_class.prefix_default&.to_s } }
445
- ns_attr_names << rule.name.to_s
437
+ ns_attr_names << row_name
438
+ attr_rows << [rule, attr, row_name]
446
439
  return nil
447
440
  end
448
441
 
449
- claim[:plain] += 1
450
- plan_attrs << { name: rule.name.to_s }
442
+ attr_local_claims[raw_name][:plain] += 1
443
+ plan_attrs << { name: raw_name }
444
+ attr_rows << [rule, attr, raw_name]
451
445
  return nil
452
446
  end
453
447
  return :attr_type_ns_symbol if type_ns.is_a?(Symbol)
454
- return :attr_type_ns_aliases unless Leptris.attr_ns_rows_compatible? &&
455
- type_ns.all_uris.size == 1
456
-
457
- claim[:ns] += 1
458
- plan_attrs << { name: rule.name.to_s,
448
+ return :attr_type_ns_aliases unless type_ns.all_uris.size == 1
449
+
450
+ # Exact-URI rows match the engine's (URI, local) capture by
451
+ # the LOCAL name — a prefixed rule spelling (xlink:href)
452
+ # binds its local part under the row's URI. Plain rows keep
453
+ # the raw name (matches-any-qualification capture). The
454
+ # claims tally keys by the effective match key so an exact
455
+ # row and a plain row claiming the same local name still
456
+ # opt the model out (#758).
457
+ row_name = local_attr_name(raw_name)
458
+ attr_local_claims[row_name][:ns] += 1
459
+ plan_attrs << { name: row_name,
459
460
  ns: { exact: type_ns.uri.to_s,
460
461
  prefix: type_ns.prefix_default&.to_s } }
461
- ns_attr_names << rule.name.to_s
462
+ ns_attr_names << row_name
463
+ attr_rows << [rule, attr, row_name]
462
464
  nil
463
465
  end
464
466
 
467
+ def local_attr_name(name)
468
+ name.include?(":") ? name.split(":", 2).last : name
469
+ end
470
+
471
+ # Names claimed by more than one element row (engine-row
472
+ # view; content and wildcard pseudo-rows are exempt). Returns
473
+ # the contested name list, or :unsafe when any claimant row
474
+ # cannot dispatch per node — the model then compiles to nil
475
+ # and the interpretive matcher owns the dispatch.
476
+ def contested_element_names(rows, compiled)
477
+ contested = []
478
+ pseudo_row_kinds = %i[content wildcard].freeze
479
+ rows.group_by { |r| r[:name] }.each do |name, group|
480
+ next if group.length < 2 || name.nil?
481
+ next if group.any? { |r| pseudo_row_kinds.include?(r[:kind]) }
482
+ # when_attribute partitions route exclusively by engine
483
+ # predicate (leptris 1.9.221+ #1272) — same-name partition
484
+ # groups are engine-managed, not cross-echoed.
485
+ next if group.any? { |r| r[:when] }
486
+
487
+ kinds = compiled.filter_map do |rule, _attr, kind, _spelling, _del|
488
+ next if rule.nil?
489
+
490
+ names = rule.name.is_a?(Array) ? rule.name.map(&:to_s) : [rule.name.to_s]
491
+ kind if names.include?(name)
492
+ end
493
+ return :unsafe unless kinds.all? do |k|
494
+ PER_NODE_ROW_KINDS.include?(k)
495
+ end
496
+
497
+ contested << name
498
+ end
499
+ contested
500
+ end
501
+
502
+ # Row entries for a serializable child. Three shapes: # - nil child (a cyclic graph, or a shape that opted out):
503
+ # :child_deferred — the ROW defers per-node instead of
504
+ # opting the whole model out; the buckets chain delivers
505
+ # source nodes and the interpretive cast runs the full
506
+ # machinery on each island. (The whole-model bail here
507
+ # cascaded child_uncompilable up entire trees — uniword's
508
+ # Run → pict cycle nilled DocumentRoot.)
509
+ # - ordered child: :ordered_deferred — the per-node plan
510
+ # walk + element_order rebuild (PlanOrder) carry the
511
+ # namespace context in the child plan's own exact-URI
512
+ # rows; nothing is lost under a namespaced parent.
513
+ # - otherwise: :nested with the child's tree.
514
+ # Returns [[compiled tuple], tree row].
515
+ def child_row_for(rule, attr, type, child, delegate_target,
516
+ model_ns)
517
+ if child.nil?
518
+ [[rule, attr, :child_deferred, nil, delegate_target, type],
519
+ { name: rule.name.to_s, kind: :raw }]
520
+ elsif child[:ordered]
521
+ [[rule, attr, :ordered_deferred, nil, delegate_target, type],
522
+ { name: rule.name.to_s, kind: :raw }]
523
+ else
524
+ row = { name: rule.name.to_s, kind: :nested,
525
+ plan: child[:tree] }
526
+ row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
527
+ [[rule, attr, :nested, nil, delegate_target, type], row]
528
+ end
529
+ end
530
+
465
531
  def compilable_mapping?(mapping)
466
532
  # Mapping's uniform interface: root_mappings defaults to
467
533
  # false on the base (KeyValue overrides with its own).
468
- # The map_any_element catch-all rides the engine's wildcard
469
- # row (leptris#1552, named rows take precedence); engines
470
- # without the kind keep the interpretive refusal.
471
- return false if mapping.any_element_rule &&
472
- !Leptris.wildcard_rows_compatible?
473
-
474
534
  mapping.root_element && !mapping.root_mappings?
475
535
  end
476
536
 
@@ -548,8 +608,7 @@ module Lutaml
548
608
  type_ns = attr.type_namespace_class(register)
549
609
  return nil if type_ns.nil? || type_ns == :inherit
550
610
  return :bail_symbol if type_ns.is_a?(Symbol)
551
- return :bail_aliases unless Leptris.attr_ns_rows_compatible? &&
552
- type_ns.all_uris.size == 1
611
+ return :bail_aliases unless type_ns.all_uris.size == 1
553
612
 
554
613
  form = { exact: type_ns.uri.to_s,
555
614
  prefix: type_ns.prefix_default&.to_s }