lutaml-model 0.8.100 → 0.8.114

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +6 -5
  3. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  4. data/lib/lutaml/json/adapter/yeptris_adapter.rb +11 -0
  5. data/lib/lutaml/key_value/transform.rb +4 -0
  6. data/lib/lutaml/key_value/transformation.rb +10 -0
  7. data/lib/lutaml/model/attribute.rb +43 -3
  8. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  9. data/lib/lutaml/model/configuration.rb +13 -0
  10. data/lib/lutaml/model/serialize/builder.rb +22 -0
  11. data/lib/lutaml/model/serialize/format_conversion.rb +21 -3
  12. data/lib/lutaml/model/serialize/initialization.rb +14 -1
  13. data/lib/lutaml/model/type_resolver.rb +7 -5
  14. data/lib/lutaml/model/version.rb +1 -1
  15. data/lib/lutaml/xml/adapter/leptris_adapter.rb +1 -0
  16. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -45
  17. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  18. data/lib/lutaml/xml/leptris/plan_compiler.rb +161 -102
  19. data/lib/lutaml/xml/leptris/plan_hydrator.rb +276 -58
  20. data/lib/lutaml/xml/leptris/plan_serializer.rb +298 -45
  21. data/lib/lutaml/xml/leptris/plan_walk.rb +6 -3
  22. data/lib/lutaml/xml/leptris.rb +25 -84
  23. data/lib/lutaml/xml/model_transform.rb +5 -0
  24. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +15 -6
  25. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +11 -0
  26. data/lutaml-model.gemspec +3 -3
  27. data/spec/lutaml/json/yeptris_adapter_spec.rb +3 -10
  28. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +32 -8
  29. data/spec/lutaml/model/collection_strict_assignment_spec.rb +55 -0
  30. data/spec/lutaml/model/singular_tolerance_spec.rb +159 -0
  31. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  32. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  33. data/spec/lutaml/xml/plan_fast_path_spec.rb +12 -6
  34. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +0 -11
  35. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  36. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +0 -9
  37. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +3 -10
  38. metadata +16 -10
@@ -18,12 +18,30 @@ module Lutaml
18
18
  # order applier — including its content-attr-first text reads
19
19
  # (mutations to the model show up) and CDATA flattening. Models
20
20
  # without element_order fall back interpretively (return nil).
21
+ # Writer-added values the order snapshot does not cover ride the
22
+ # OrderReconciler: the walk consumes the reconciled order and
23
+ # unplaceable rules emit appended (the interpretive
24
+ # apply_remaining_rules contract).
21
25
  module PlanSerializer
22
26
  SERIALIZABLE_KINDS = %i[scalar collection_native collection_cb
23
27
  nested ordered_deferred raw content
24
28
  content_deferred].freeze
29
+ PLAN_SERIALIZABLE_KINDS = SERIALIZABLE_KINDS
25
30
  CONTENT_KINDS = %i[content content_deferred].freeze
26
31
 
32
+ # Reconciler host: the interpretive support modules are written
33
+ # against the Transformation's combined surface (the reconciler
34
+ # consumes applier helpers such as find_rule_for_element and
35
+ # rule_discriminator), so the plan lane drives them through a
36
+ # dedicated host holding both. The helpers are pure functions
37
+ # of their arguments — the frozen host carries no state.
38
+ class ReconcilerHost
39
+ include(TransformationSupport::OrderedApplier)
40
+ include(TransformationSupport::OrderReconciler)
41
+ include(TransformationSupport::SkipLogic)
42
+ end
43
+ RECONCILER = ReconcilerHost.new.freeze
44
+
27
45
  class << self
28
46
  # plan: the compiler's entry for instance's class
29
47
  def call(instance, plan, register = nil)
@@ -37,6 +55,15 @@ module Lutaml
37
55
  )
38
56
  return nil if plan[:ordered] && instance.element_order.nil?
39
57
 
58
+ # The native lane first: purely namespace-less trees emit
59
+ # through the engine's result builder and writers, all-C
60
+ # after the ops list — 2.4x the DOM-mint loop on the
61
+ # audit corpus. Everything else keeps the DOM lane below
62
+ # (namespaced spellings are instance-dependent; ordered,
63
+ # wildcard, raw, and callback shapes are not
64
+ # ops-expressible).
65
+ return NativeEmission.emit(instance, plan, register) if NativeEmission.compatible?(plan)
66
+
40
67
  doc = ::Leptris::XML::Document.create
41
68
  ns_ctx = Namespaces.new(instance, plan, register)
42
69
  return nil if ns_ctx.unspellable?
@@ -46,11 +73,25 @@ module Lutaml
46
73
  )
47
74
  doc.root = root
48
75
  ns_ctx.declare_on(root)
49
- if plan[:ordered]
50
- build_ordered(root, instance, plan, doc, register)
51
- else
52
- build(root, instance, plan, doc, register, ns_ctx)
76
+ # The interpretive writer's raw schemaLocation re-emission
77
+ # (handle_schema_location): the parse-captured string rides
78
+ # back as xsi:schemaLocation with its declaration.
79
+ if instance.is_a?(::Lutaml::Model::Serialize) &&
80
+ instance.raw_schema_location &&
81
+ !instance.raw_schema_location.empty?
82
+ root.set_attribute(
83
+ "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"
84
+ )
85
+ root.set_attribute("xsi:schemaLocation",
86
+ instance.raw_schema_location)
53
87
  end
88
+ built = if plan[:ordered]
89
+ build_ordered(root, instance, plan, doc, register, ns_ctx)
90
+ else
91
+ build(root, instance, plan, doc, register, ns_ctx)
92
+ end
93
+ return nil unless built
94
+
54
95
  doc.to_xml(indent: 2, no_decl: true)
55
96
  end
56
97
 
@@ -106,6 +147,38 @@ module Lutaml
106
147
  @hoisted[uri] = prefix
107
148
  @forms[uri] = { prefix: prefix, uri: uri }
108
149
  end
150
+ # Format preservation (the interpretive writer's
151
+ # rule): an instance parsed from a PREFIXED source keeps
152
+ # its prefixes — the model's own namespace hoists with
153
+ # the instance's recorded prefix even when the fresh
154
+ # planner preferred the default-ns format.
155
+ model_uri = plan[:tree][:ns] && plan[:tree][:ns][:exact]
156
+ if ns_prefix && !ns_prefix.empty? && model_uri &&
157
+ @hoisted.key?(model_uri)
158
+ @hoisted[model_uri] = ns_prefix
159
+ @forms[model_uri] = { prefix: ns_prefix, uri: model_uri }
160
+ end
161
+ # Source declaration preservation (the interpretive
162
+ # writer's stored-plan merge): prefixes the parsed source
163
+ # declared are kept for URIs the document emits. The
164
+ # planner's class-analysis path has no stored-plan
165
+ # access, so the merge reads the instance's recorded plan
166
+ # directly.
167
+ stored = if instance.is_a?(::Lutaml::Model::Serialize)
168
+ instance.import_declaration_plan
169
+ end
170
+ if stored.is_a?(Lutaml::Xml::DeclarationPlan) &&
171
+ stored.root_node.is_a?(
172
+ Lutaml::Xml::DeclarationPlan::ElementNode,
173
+ ) && (ns_prefix.nil? || ns_prefix.empty?)
174
+ original_uris = stored.original_namespace_uris || {}
175
+ stored.root_node.hoisted_declarations.each do |prefix, uri|
176
+ effective_uri = original_uris[uri] || uri
177
+ already = @hoisted.value?(effective_uri) ||
178
+ @hoisted.value?(uri)
179
+ @hoisted[prefix] = uri if already && !@hoisted.key?(prefix)
180
+ end
181
+ end
109
182
  collect(plan[:tree])
110
183
  end
111
184
 
@@ -156,14 +229,18 @@ module Lutaml
156
229
  ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
157
230
  end
158
231
 
232
+ # Declaration order matches the interpretive writer: the
233
+ # prefixed namespaces sorted by prefix, the default
234
+ # namespace last.
159
235
  def declare_on(root, _attr_form_prefix = nil)
160
- @hoisted.each do |uri, prefix|
161
- key = uri
162
- next if key.nil? || @declared[key]
236
+ @hoisted.sort_by { |_uri, prefix| [prefix ? 0 : 1, prefix.to_s] }
237
+ .each do |uri, prefix|
238
+ key = uri
239
+ next if key.nil? || @declared[key]
163
240
 
164
- @declared[key] = true
165
- root.add_namespace_definition(prefix, uri)
166
- end
241
+ @declared[key] = true
242
+ root.add_namespace_definition(prefix, uri)
243
+ end
167
244
  end
168
245
  end
169
246
 
@@ -172,7 +249,7 @@ module Lutaml
172
249
  # plain models through plan rows.
173
250
  def serializable?(plan)
174
251
  plan[:rows].all? do |_rule, _attr, kind, _sp, _del|
175
- SERIALIZABLE_KINDS.include?(kind)
252
+ PLAN_SERIALIZABLE_KINDS.include?(kind)
176
253
  end
177
254
  end
178
255
 
@@ -222,25 +299,35 @@ module Lutaml
222
299
  end
223
300
  when :nested, :ordered_deferred
224
301
  Array(value).each do |item|
225
- child_plan = nested_plans[item.class] ||
226
- (nested_plans[item.class] = PlanCompiler.compile(item.class, reg))
302
+ # Keyed by [class, row name]: distinct rows (sz, szCs)
303
+ # can share one class with different root names.
304
+ child_plan = nested_plans[[item.class, rule.name.to_s]] ||
305
+ (nested_plans[[item.class, rule.name.to_s]] = PlanCompiler.compile(item.class, reg))
227
306
  child_ns = child_plan[:tree][:ns] || inherited_ns
307
+ # The ROW's wire name wins over the child plan's
308
+ # root — distinct rows (sz, szCs) share one class.
228
309
  child = element.create_child(
229
- ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
310
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), child_ns) : row_name(rule, spelling),
230
311
  )
231
312
  ns_ctx&.collect(child_plan[:tree])
232
313
  if child_plan[:ordered]
233
- next unless build_ordered(child, item, child_plan,
234
- doc, reg, ns_ctx, child_ns)
235
-
314
+ build_ordered(child, item, child_plan,
315
+ doc, reg, ns_ctx, child_ns) || return
236
316
  else
237
- build(child, item, child_plan, doc, reg, ns_ctx, child_ns)
317
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
238
318
  end
239
319
  end
240
320
  when :raw
241
321
  Array(value).each { |raw| element.add_child(raw.to_s) }
322
+ when :child_deferred
323
+ # A plan-less child carrying a value (a cycle point
324
+ # with data, e.g. bibliography's Author → author):
325
+ # the interpretive serializer owns the model — the
326
+ # plan lane has no faithful spelling for it. Empty
327
+ # deferred rows ride the fast lane untouched.
328
+ return nil unless Array(value).empty?
242
329
  when :content, :content_deferred
243
- Array(value).each { |run| element.add_child(doc.create_text_node(run.to_s)) }
330
+ Array(value).each { |run| append_text(element, run.to_s, doc) }
244
331
  end
245
332
  end
246
333
  end
@@ -264,30 +351,96 @@ module Lutaml
264
351
  plan[:nested_plans] ||= {}
265
352
  end
266
353
 
354
+ # The row's exact element URI — the child type's namespace,
355
+ # the same source the interpretive matcher dispatches by for
356
+ # (URI, local) element identity. Nil for scalar/raw/content
357
+ # rows (any-qualification binding). Memoized per plan (rules
358
+ # are stable per compiled plan).
359
+ def row_exact_uri(rule, attr, kind, type, reg, plan)
360
+ return nil unless %i[nested ordered_deferred].include?(kind)
361
+
362
+ cache = plan[:row_exact_uris] ||= {}.compare_by_identity
363
+ return cache[rule] if cache.key?(rule)
364
+
365
+ child_type = type || attr.type(reg)
366
+ child_plan = child_type && PlanCompiler.compile(child_type, reg)
367
+ tree_ns = child_plan && child_plan[:tree][:ns]
368
+ cache[rule] = tree_ns.is_a?(Hash) ? tree_ns[:exact] : tree_ns
369
+ end
370
+
371
+ def ordered_layout_for(plan, inherited_ns, ns_index, reg)
372
+ cache = plan[:ordered_layouts] ||= {}.compare_by_identity
373
+ return cache[inherited_ns] if cache.key?(inherited_ns)
374
+
375
+ rows_by_name = {}
376
+ name_keys = ::Hash.new { |h, k| h[k] = [] }
377
+ plan[:rows].each do |rule, attr, kind, spelling, delegate, type|
378
+ next unless rule.name
379
+
380
+ exact = row_exact_uri(rule, attr, kind, type, reg, plan)
381
+ name = rule.name.to_s
382
+ key = exact ? [exact, name] : [nil, name]
383
+ rows_by_name[key] ||= [rule, attr, kind, spelling,
384
+ delegate,
385
+ ns_index ? ns_index[name] || inherited_ns : inherited_ns]
386
+ name_keys[name] << key
387
+ end
388
+ name_keys.default = nil
389
+ cache[inherited_ns] = [rows_by_name, name_keys]
390
+ end
391
+
267
392
  # Element_order-driven emission (the interpretive order
268
393
  # applier's contract): text runs and comments verbatim, each
269
394
  # element entry consuming the next item of its collection
270
395
  # (document order). Text reads prefer the content attribute
271
396
  # when it lines up with the text-node count, so mutations to
272
397
  # the model are reflected; element_order text is the fallback.
273
- # PIs drop (interpretive parity). Returns nil when a nested
274
- # ordered child lacks element_order (caller falls back).
398
+ # PIs drop (interpretive parity). A nested ordered instance
399
+ # without element_order (built by Ruby, not parsed) falls
400
+ # back to declaration order — the interpretive behavior.
275
401
  def build_ordered(element, instance, plan, doc, reg = nil,
276
- ns_ctx = nil, _inherited_ns = nil)
402
+ ns_ctx = nil, inherited_ns = nil)
403
+ if instance.element_order.nil?
404
+ return build(element, instance, plan, doc, reg, ns_ctx,
405
+ inherited_ns)
406
+ end
407
+
277
408
  reg ||= register
278
409
  ns_ctx&.collect(plan[:tree])
279
410
  write_attributes(element, instance, plan, reg, ns_ctx)
280
- rows_by_name = {}
281
- plan[:rows].each do |rule, attr, kind, spelling, delegate|
282
- next unless rule.name
283
-
284
- rows_by_name[rule.name.to_s] ||= [rule, attr, kind, spelling,
285
- delegate]
286
- end
411
+ ns_index = ns_ctx ? ns_index_for(plan) : nil
412
+ # (URI, local) row keys — the interpretive matcher's
413
+ # typed-element identity: same-name rows in different
414
+ # namespaces (w14:docId vs w15:docId) must not share one
415
+ # dispatch slot. Name-keyed rows (no exact URI) bind any
416
+ # qualification. The layout is static per (plan, inherited
417
+ # ns) — the reconciliation corpus re-serializes thousands
418
+ # of same-shaped paragraphs, so it is memoized.
419
+ rows_by_name, name_keys =
420
+ ordered_layout_for(plan, inherited_ns, ns_index, reg)
287
421
  content_entry = plan[:rows].find do |rule, _attr, kind, _sp, _del|
288
422
  CONTENT_KINDS.include?(kind) && rule.name.nil?
289
423
  end
290
- order = instance.element_order
424
+ # Writer-added values sit outside the order snapshot — the
425
+ # reconciler inserts their entries at the interpretive
426
+ # anchors and returns the rules it could not place for
427
+ # appended emission (silent drops are data loss). The
428
+ # reconciler speaks CompiledRule — the interpretive
429
+ # transformation's memoized set; the plan's MappingRule
430
+ # rows cannot drive it. Pristine parsed instances skip the
431
+ # scan entirely: the parse-built order covers every set
432
+ # value, and the writer-set flag is the only flow that can
433
+ # produce deficits.
434
+ rules = if instance.mutated_after_parse?
435
+ compiled_rules_for(instance.class, reg)
436
+ end
437
+ if rules
438
+ order, fallback_rules =
439
+ RECONCILER.reconciled_element_order(instance, rules, {})
440
+ else
441
+ order = instance.element_order
442
+ fallback_rules = []
443
+ end
291
444
  element_indices = ::Hash.new(0)
292
445
  content_attr = content_entry && content_entry[1]
293
446
  content_value = content_attr &&
@@ -321,50 +474,132 @@ module Lutaml
321
474
  when "Comment"
322
475
  element.add_child(doc.create_comment(object.text_content))
323
476
  when "Element"
324
- entry = rows_by_name[object.name]
477
+ # A uniquely-named row absorbs the entry at any
478
+ # qualification (the interpretive matcher's lenient
479
+ # binding — order entries written by consumers carry no
480
+ # namespace). Contested names dispatch strictly by
481
+ # (URI, local), falling back to URI-less rows.
482
+ name = object.name
483
+ keys = name_keys[name]
484
+ if keys.length == 1
485
+ dispatch_key = keys.first
486
+ entry = rows_by_name[dispatch_key]
487
+ else
488
+ dispatch_key = [object.namespace_uri, name]
489
+ entry = rows_by_name[dispatch_key]
490
+ unless entry
491
+ dispatch_key = [nil, name]
492
+ entry = rows_by_name[dispatch_key]
493
+ end
494
+ end
325
495
  next unless entry
326
496
 
327
- _, attr, kind, _spelling, delegate = entry
497
+ _, attr, kind, _spelling, delegate, row_ns = entry
328
498
  value = value_of(instance, attr, delegate)
329
499
  case kind
330
500
  when :collection_native, :collection_cb
331
- index = element_indices[object.name]
501
+ index = element_indices[dispatch_key]
332
502
  items = Array(value)
333
503
  next unless index < items.length
334
504
 
335
- element_indices[object.name] += 1
336
- add_leaf(element, object.name,
505
+ element_indices[dispatch_key] += 1
506
+ add_leaf(element,
507
+ ns_ctx ? ns_ctx.spell(object.name, row_ns) : object.name,
337
508
  attr.serialize(items[index], :xml, reg), doc)
338
509
  when :nested, :ordered_deferred
339
510
  item = if attr.collection?
340
- index = element_indices[object.name]
341
- element_indices[object.name] += 1
511
+ index = element_indices[dispatch_key]
512
+ element_indices[dispatch_key] += 1
342
513
  Array(value)[index]
343
514
  else
344
515
  value
345
516
  end
346
517
  next unless item
347
518
 
348
- child_plan = nested_plans_for(plan)[item.class] ||
349
- (nested_plans_for(plan)[item.class] = PlanCompiler.compile(item.class, reg))
350
- child = element.create_child(child_plan[:tree][:name])
519
+ child_plan = nested_plans_for(plan)[[item.class, object.name]] ||
520
+ (nested_plans_for(plan)[[item.class, object.name]] = PlanCompiler.compile(item.class, reg))
521
+ child_ns = child_plan[:tree][:ns] || row_ns || inherited_ns
522
+ child = element.create_child(
523
+ ns_ctx ? ns_ctx.spell(object.name, child_ns) : object.name,
524
+ )
351
525
  if child_plan[:ordered]
352
526
  return nil unless build_ordered(child, item, child_plan,
353
- doc, reg)
527
+ doc, reg, ns_ctx, child_ns)
354
528
  else
355
- build(child, item, child_plan, doc, reg)
529
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
356
530
  end
357
531
  when :raw
358
532
  element.add_child(value.to_s) unless value.nil?
533
+ when :child_deferred
534
+ # Valued cycle-point children: interpretive lane (see
535
+ # build).
536
+ return nil unless Array(value).empty?
359
537
  when :scalar
360
- add_leaf(element, object.name,
538
+ add_leaf(element,
539
+ ns_ctx ? ns_ctx.spell(object.name, row_ns) : object.name,
361
540
  attr.serialize(value, :xml, reg), doc)
362
541
  end
363
542
  end
364
543
  end
544
+ return nil if !fallback_rules.empty? &&
545
+ !emit_uncovered_values(element, instance, plan, fallback_rules,
546
+ doc, reg, ns_ctx, inherited_ns)
547
+
365
548
  true
366
549
  end
367
550
 
551
+ # OrderReconciler fallback: element rules the reconciled order
552
+ # could not place but that still hold a value — emit them
553
+ # appended, the ordinary way (the interpretive
554
+ # apply_remaining_rules contract for the plan row model).
555
+ def emit_uncovered_values(element, instance, plan, fallback, doc,
556
+ reg, ns_ctx, inherited_ns)
557
+ by_attr = {}
558
+ plan[:rows].each { |row| by_attr[row[1].name] ||= row if row[1] }
559
+ fallback.each do |rule|
560
+ entry = by_attr[rule.attribute_name]
561
+ next unless entry
562
+
563
+ _rule, attr, kind, _spelling, delegate, row_ns = entry
564
+ value = value_of(instance, attr, delegate)
565
+ name = rule.name.to_s
566
+ Array(value).each do |item|
567
+ case kind
568
+ when :collection_native, :collection_cb, :scalar
569
+ add_leaf(element,
570
+ ns_ctx ? ns_ctx.spell(name, row_ns) : name,
571
+ attr.serialize(item, :xml, reg), doc)
572
+ when :nested, :ordered_deferred
573
+ next if item.nil?
574
+
575
+ child_plan =
576
+ nested_plans_for(plan)[[item.class, name]] ||
577
+ (nested_plans_for(plan)[[item.class, name]] =
578
+ PlanCompiler.compile(item.class, reg))
579
+ child_ns = child_plan[:tree][:ns] || row_ns || inherited_ns
580
+ child = element.create_child(
581
+ ns_ctx ? ns_ctx.spell(name, child_ns) : name,
582
+ )
583
+ if child_plan[:ordered]
584
+ return nil unless build_ordered(child, item, child_plan,
585
+ doc, reg, ns_ctx, child_ns)
586
+ else
587
+ built = build(child, item, child_plan, doc, reg, ns_ctx,
588
+ child_ns)
589
+ return nil unless built
590
+ end
591
+ end
592
+ end
593
+ # Valued cycle-point rows keep the walk's contract.
594
+ return nil if kind == :child_deferred && !Array(value).empty?
595
+ end
596
+ true
597
+ end
598
+
599
+ def compiled_rules_for(klass, reg)
600
+ klass.transformation_for(:xml, reg)&.compiled_rules
601
+ end
602
+
368
603
  # Attributes in document order when the instance recorded it
369
604
  # (parsed models), else plan row order.
370
605
  def write_attributes(element, instance, plan, reg = nil,
@@ -411,9 +646,10 @@ module Lutaml
411
646
  prefix = nil)
412
647
  return unless entry
413
648
 
414
- _rule, attr, delegate = entry
649
+ rule, attr, delegate = entry
415
650
  value = value_of(instance, attr, delegate)
416
651
  return if value.nil?
652
+ return unless rule.render?(value, instance)
417
653
 
418
654
  spelled = prefix ? "#{prefix}:#{name}" : name
419
655
  element.set_attribute(spelled,
@@ -426,10 +662,27 @@ module Lutaml
426
662
  def add_leaf(element, name, value, doc, attrs: nil)
427
663
  child = element.create_child(name)
428
664
  attrs&.each { |k, v| child[k.to_s] = v.to_s }
429
- child.add_child(doc.create_text_node(value.to_s)) unless value.nil?
665
+ append_text(child, value.to_s, doc) unless value.nil?
430
666
  child
431
667
  end
432
668
 
669
+ # Text attach: the address face (leptris#408 adoption)
670
+ # passes raw node addresses — the pointer face mints an
671
+ # FFI::Pointer wrapper per fresh node, the last per-row
672
+ # Ruby-side allocation on the DOM build.
673
+ def append_text(parent, text, doc)
674
+ node = doc.create_text_node(text)
675
+ status = ::Leptris::XML::FFI.leptris_element_add_child_addr(
676
+ parent.c_address, node.c_address
677
+ )
678
+ unless status.zero?
679
+ raise ::Leptris::XML::Error,
680
+ "text attach failed (status=#{status})"
681
+ end
682
+
683
+ node
684
+ end
685
+
433
686
  def value_of(instance, attr, delegate)
434
687
  if delegate
435
688
  target = instance.public_send(delegate)
@@ -12,11 +12,10 @@ module Lutaml
12
12
  class << self
13
13
  # Build the plan, walk the document, and hydrate the mapped
14
14
  # root. Returns nil when the model is not plan-compilable.
15
- def call(model_class, xml, _options = {})
15
+ def call(model_class, xml, options = {})
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)
20
19
 
21
20
  document = ::Leptris::XML.parse(xml)
22
21
  root = document.root
@@ -24,7 +23,11 @@ module Lutaml
24
23
 
25
24
  PlanHydrator.call(model_class, plan,
26
25
  plan[:descriptor].walk(root),
27
- node: root)
26
+ node: root,
27
+ declaration_plan_mode:
28
+ options.fetch(
29
+ :import_declaration_plan, :eager
30
+ ))
28
31
  end
29
32
  end
30
33
  end
@@ -8,95 +8,36 @@ module Lutaml
8
8
  # reference leptris types directly (the moxml adapters and the
9
9
  # interpretive path never enter here).
10
10
  module Leptris
11
- # The plan path requires the one-crossing child snapshot
12
- # (leptris_plan_value_children_snapshot with child handles,
13
- # PlanValue#children_snapshot), which shipped in leptris
14
- # 1.9.273.0. Bundles resolving an older gem keep the
11
+ # Single capability floor (leptris 1.9.351.1): the child-handles
12
+ # snapshot (1.9.273), exact-URI attribute rows (#1486, 1.9.289),
13
+ # nested-attribute capture (#1563, 1.9.313), wildcard rows
14
+ # (#1552, 1.9.313), child-row ns_uri retention and qualified
15
+ # attribute leniency (#1585/#1586, 1.9.320), the :unqualified ns
16
+ # form (#1560, 1.9.323), build_result with the address-based
17
+ # text attach (#408, 1.9.334/1.9.336), and the plan-value
18
+ # attributes batch face (PlanValue#attributes_snapshot,
19
+ # leptris-ruby#447). Bundles resolving an older gem keep the
15
20
  # interpretive path — PlanCompiler.compile answers nil — the
16
- # existing opt-out protocol.
17
- MIN_LEPTRIS_VERSION = "1.9.273.0"
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
-
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
-
45
- # The :unqualified child-row ns form (leptris#1560: matches only
46
- # the UNWRITTEN spelling — bare or inherited-default-ns — and
47
- # refuses prefixed spellings, the interpretive matcher's exact
48
- # no-namespace semantic) ships in binding 1.9.323.0. Older
49
- # engines keep namespace-less model rows on the ns_lenient
50
- # superset (local-name matching under any qualification).
51
- MIN_LEPTRIS_NS_UNQUALIFIED_VERSION = "1.9.323.0"
21
+ # opt-out protocol.
22
+ MIN_LEPTRIS_VERSION = "1.9.366.2"
52
23
 
24
+ # Memoized: the compiler consults this on every cache hit (per
25
+ # child row per node), and the comparison minted a Gem::Version
26
+ # per call — visible in the niso ordered-lane allocation profile.
27
+ # The loaded gem cannot change mid-process.
53
28
  def self.plan_path_compatible?
54
- return false unless defined?(Gem)
55
-
56
- spec = Gem.loaded_specs["leptris"]
57
- spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
58
- end
59
-
60
- def self.attr_ns_rows_compatible?
61
- return false unless defined?(Gem)
62
-
63
- spec = Gem.loaded_specs["leptris"]
64
- spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_ATTR_NS_VERSION)
65
- end
66
-
67
- def self.nested_attr_capture_compatible?
68
- return false unless defined?(Gem)
69
-
70
- spec = Gem.loaded_specs["leptris"]
71
- spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NESTED_ATTR_VERSION)
72
- end
73
-
74
- def self.wildcard_rows_compatible?
75
- return false unless defined?(Gem)
76
-
77
- spec = Gem.loaded_specs["leptris"]
78
- spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WILDCARD_VERSION)
79
- end
80
-
81
- # Memoized: consulted per parse on the walk gate hot path, and
82
- # the loaded leptris spec cannot change within a process.
83
- @walk_ns_safety = nil
84
- def self.walk_ns_safety_compatible?
85
- return @walk_ns_safety unless @walk_ns_safety.nil?
86
- return (@walk_ns_safety = false) unless defined?(Gem)
87
-
88
- spec = Gem.loaded_specs["leptris"]
89
- @walk_ns_safety =
90
- !!(spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WALK_NS_SAFETY_VERSION))
91
- end
92
-
93
- def self.ns_unqualified_rows_compatible?
94
- return false unless defined?(Gem)
95
-
96
- spec = Gem.loaded_specs["leptris"]
97
- spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NS_UNQUALIFIED_VERSION)
29
+ return @plan_path_compatible if defined?(@plan_path_compatible)
30
+
31
+ @plan_path_compatible =
32
+ if defined?(Gem)
33
+ spec = Gem.loaded_specs["leptris"]
34
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
35
+ else
36
+ false
37
+ end
98
38
  end
99
39
 
40
+ autoload :NativeEmission, "lutaml/xml/leptris/native_emission"
100
41
  autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
101
42
  autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
102
43
  autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
@@ -479,6 +479,11 @@ module Lutaml
479
479
  # violation entirely — check it while there is still someone to tell.
480
480
  if !instance_is_serialize && attr && !attr.collection? &&
481
481
  !rule.content_mapping?
482
+ # singular_tolerance: :first collapses an over-ingested
483
+ # collection to its leading value before the writer sees it
484
+ # (#971) — the reduced scalar then flows through
485
+ # deserialize and serializes as a single element.
486
+ value = value.first if attr.tolerates_singular_overflow?(value)
482
487
  attr.valid_collection!(value, context)
483
488
  end
484
489
  rule.deserialize(instance, value, deserialize_attributes, context,