lutaml-model 0.8.100 → 0.8.119

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 (42) 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 +28 -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/adapter_resolver.rb +13 -5
  8. data/lib/lutaml/model/attribute.rb +77 -3
  9. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  10. data/lib/lutaml/model/configuration.rb +13 -0
  11. data/lib/lutaml/model/serialize/builder.rb +22 -0
  12. data/lib/lutaml/model/serialize/format_conversion.rb +21 -3
  13. data/lib/lutaml/model/serialize/initialization.rb +14 -1
  14. data/lib/lutaml/model/type_resolver.rb +7 -5
  15. data/lib/lutaml/model/version.rb +1 -1
  16. data/lib/lutaml/xml/adapter/leptris_adapter.rb +1 -0
  17. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -45
  18. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  19. data/lib/lutaml/xml/leptris/plan_compiler.rb +161 -102
  20. data/lib/lutaml/xml/leptris/plan_hydrator.rb +276 -58
  21. data/lib/lutaml/xml/leptris/plan_serializer.rb +309 -47
  22. data/lib/lutaml/xml/leptris/plan_walk.rb +6 -3
  23. data/lib/lutaml/xml/leptris.rb +25 -84
  24. data/lib/lutaml/xml/model_transform.rb +5 -0
  25. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +15 -6
  26. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +29 -0
  27. data/lutaml-model.gemspec +3 -3
  28. data/spec/lutaml/json/yeptris_adapter_spec.rb +3 -10
  29. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +32 -8
  30. data/spec/lutaml/model/cast_element_form_spec.rb +54 -0
  31. data/spec/lutaml/model/collection_strict_assignment_spec.rb +55 -0
  32. data/spec/lutaml/model/from_json_scalar_cast_spec.rb +70 -0
  33. data/spec/lutaml/model/singular_tolerance_spec.rb +159 -0
  34. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  35. data/spec/lutaml/model/yeptris_native_detection_spec.rb +83 -0
  36. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  37. data/spec/lutaml/xml/plan_fast_path_spec.rb +34 -6
  38. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +0 -11
  39. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  40. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +0 -9
  41. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +3 -10
  42. metadata +19 -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,20 @@ 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
+ # element_order is the order contract: a model carrying it —
66
+ # parse-captured or programmatically tracked (#977) — must
67
+ # serialize through the ordered walk, not the flat
68
+ # schema-order native ops layout.
69
+ return NativeEmission.emit(instance, plan, register) if
70
+ NativeEmission.compatible?(plan) && instance.element_order.nil?
71
+
40
72
  doc = ::Leptris::XML::Document.create
41
73
  ns_ctx = Namespaces.new(instance, plan, register)
42
74
  return nil if ns_ctx.unspellable?
@@ -46,11 +78,25 @@ module Lutaml
46
78
  )
47
79
  doc.root = root
48
80
  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)
81
+ # The interpretive writer's raw schemaLocation re-emission
82
+ # (handle_schema_location): the parse-captured string rides
83
+ # back as xsi:schemaLocation with its declaration.
84
+ if instance.is_a?(::Lutaml::Model::Serialize) &&
85
+ instance.raw_schema_location &&
86
+ !instance.raw_schema_location.empty?
87
+ root.set_attribute(
88
+ "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance"
89
+ )
90
+ root.set_attribute("xsi:schemaLocation",
91
+ instance.raw_schema_location)
53
92
  end
93
+ built = if plan[:ordered] || !instance.element_order.nil?
94
+ build_ordered(root, instance, plan, doc, register, ns_ctx)
95
+ else
96
+ build(root, instance, plan, doc, register, ns_ctx)
97
+ end
98
+ return nil unless built
99
+
54
100
  doc.to_xml(indent: 2, no_decl: true)
55
101
  end
56
102
 
@@ -106,6 +152,38 @@ module Lutaml
106
152
  @hoisted[uri] = prefix
107
153
  @forms[uri] = { prefix: prefix, uri: uri }
108
154
  end
155
+ # Format preservation (the interpretive writer's
156
+ # rule): an instance parsed from a PREFIXED source keeps
157
+ # its prefixes — the model's own namespace hoists with
158
+ # the instance's recorded prefix even when the fresh
159
+ # planner preferred the default-ns format.
160
+ model_uri = plan[:tree][:ns] && plan[:tree][:ns][:exact]
161
+ if ns_prefix && !ns_prefix.empty? && model_uri &&
162
+ @hoisted.key?(model_uri)
163
+ @hoisted[model_uri] = ns_prefix
164
+ @forms[model_uri] = { prefix: ns_prefix, uri: model_uri }
165
+ end
166
+ # Source declaration preservation (the interpretive
167
+ # writer's stored-plan merge): prefixes the parsed source
168
+ # declared are kept for URIs the document emits. The
169
+ # planner's class-analysis path has no stored-plan
170
+ # access, so the merge reads the instance's recorded plan
171
+ # directly.
172
+ stored = if instance.is_a?(::Lutaml::Model::Serialize)
173
+ instance.import_declaration_plan
174
+ end
175
+ if stored.is_a?(Lutaml::Xml::DeclarationPlan) &&
176
+ stored.root_node.is_a?(
177
+ Lutaml::Xml::DeclarationPlan::ElementNode,
178
+ ) && (ns_prefix.nil? || ns_prefix.empty?)
179
+ original_uris = stored.original_namespace_uris || {}
180
+ stored.root_node.hoisted_declarations.each do |prefix, uri|
181
+ effective_uri = original_uris[uri] || uri
182
+ already = @hoisted.value?(effective_uri) ||
183
+ @hoisted.value?(uri)
184
+ @hoisted[prefix] = uri if already && !@hoisted.key?(prefix)
185
+ end
186
+ end
109
187
  collect(plan[:tree])
110
188
  end
111
189
 
@@ -156,14 +234,18 @@ module Lutaml
156
234
  ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
157
235
  end
158
236
 
237
+ # Declaration order matches the interpretive writer: the
238
+ # prefixed namespaces sorted by prefix, the default
239
+ # namespace last.
159
240
  def declare_on(root, _attr_form_prefix = nil)
160
- @hoisted.each do |uri, prefix|
161
- key = uri
162
- next if key.nil? || @declared[key]
241
+ @hoisted.sort_by { |_uri, prefix| [prefix ? 0 : 1, prefix.to_s] }
242
+ .each do |uri, prefix|
243
+ key = uri
244
+ next if key.nil? || @declared[key]
163
245
 
164
- @declared[key] = true
165
- root.add_namespace_definition(prefix, uri)
166
- end
246
+ @declared[key] = true
247
+ root.add_namespace_definition(prefix, uri)
248
+ end
167
249
  end
168
250
  end
169
251
 
@@ -172,7 +254,7 @@ module Lutaml
172
254
  # plain models through plan rows.
173
255
  def serializable?(plan)
174
256
  plan[:rows].all? do |_rule, _attr, kind, _sp, _del|
175
- SERIALIZABLE_KINDS.include?(kind)
257
+ PLAN_SERIALIZABLE_KINDS.include?(kind)
176
258
  end
177
259
  end
178
260
 
@@ -222,25 +304,35 @@ module Lutaml
222
304
  end
223
305
  when :nested, :ordered_deferred
224
306
  Array(value).each do |item|
225
- child_plan = nested_plans[item.class] ||
226
- (nested_plans[item.class] = PlanCompiler.compile(item.class, reg))
307
+ # Keyed by [class, row name]: distinct rows (sz, szCs)
308
+ # can share one class with different root names.
309
+ child_plan = nested_plans[[item.class, rule.name.to_s]] ||
310
+ (nested_plans[[item.class, rule.name.to_s]] = PlanCompiler.compile(item.class, reg))
227
311
  child_ns = child_plan[:tree][:ns] || inherited_ns
312
+ # The ROW's wire name wins over the child plan's
313
+ # root — distinct rows (sz, szCs) share one class.
228
314
  child = element.create_child(
229
- ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
315
+ ns_ctx ? ns_ctx.spell(row_name(rule, spelling), child_ns) : row_name(rule, spelling),
230
316
  )
231
317
  ns_ctx&.collect(child_plan[:tree])
232
- if child_plan[:ordered]
233
- next unless build_ordered(child, item, child_plan,
234
- doc, reg, ns_ctx, child_ns)
235
-
318
+ if child_plan[:ordered] || !item.element_order.nil?
319
+ build_ordered(child, item, child_plan,
320
+ doc, reg, ns_ctx, child_ns) || return
236
321
  else
237
- build(child, item, child_plan, doc, reg, ns_ctx, child_ns)
322
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
238
323
  end
239
324
  end
240
325
  when :raw
241
326
  Array(value).each { |raw| element.add_child(raw.to_s) }
327
+ when :child_deferred
328
+ # A plan-less child carrying a value (a cycle point
329
+ # with data, e.g. bibliography's Author → author):
330
+ # the interpretive serializer owns the model — the
331
+ # plan lane has no faithful spelling for it. Empty
332
+ # deferred rows ride the fast lane untouched.
333
+ return nil unless Array(value).empty?
242
334
  when :content, :content_deferred
243
- Array(value).each { |run| element.add_child(doc.create_text_node(run.to_s)) }
335
+ Array(value).each { |run| append_text(element, run.to_s, doc) }
244
336
  end
245
337
  end
246
338
  end
@@ -264,30 +356,96 @@ module Lutaml
264
356
  plan[:nested_plans] ||= {}
265
357
  end
266
358
 
359
+ # The row's exact element URI — the child type's namespace,
360
+ # the same source the interpretive matcher dispatches by for
361
+ # (URI, local) element identity. Nil for scalar/raw/content
362
+ # rows (any-qualification binding). Memoized per plan (rules
363
+ # are stable per compiled plan).
364
+ def row_exact_uri(rule, attr, kind, type, reg, plan)
365
+ return nil unless %i[nested ordered_deferred].include?(kind)
366
+
367
+ cache = plan[:row_exact_uris] ||= {}.compare_by_identity
368
+ return cache[rule] if cache.key?(rule)
369
+
370
+ child_type = type || attr.type(reg)
371
+ child_plan = child_type && PlanCompiler.compile(child_type, reg)
372
+ tree_ns = child_plan && child_plan[:tree][:ns]
373
+ cache[rule] = tree_ns.is_a?(Hash) ? tree_ns[:exact] : tree_ns
374
+ end
375
+
376
+ def ordered_layout_for(plan, inherited_ns, ns_index, reg)
377
+ cache = plan[:ordered_layouts] ||= {}.compare_by_identity
378
+ return cache[inherited_ns] if cache.key?(inherited_ns)
379
+
380
+ rows_by_name = {}
381
+ name_keys = ::Hash.new { |h, k| h[k] = [] }
382
+ plan[:rows].each do |rule, attr, kind, spelling, delegate, type|
383
+ next unless rule.name
384
+
385
+ exact = row_exact_uri(rule, attr, kind, type, reg, plan)
386
+ name = rule.name.to_s
387
+ key = exact ? [exact, name] : [nil, name]
388
+ rows_by_name[key] ||= [rule, attr, kind, spelling,
389
+ delegate,
390
+ ns_index ? ns_index[name] || inherited_ns : inherited_ns]
391
+ name_keys[name] << key
392
+ end
393
+ name_keys.default = nil
394
+ cache[inherited_ns] = [rows_by_name, name_keys]
395
+ end
396
+
267
397
  # Element_order-driven emission (the interpretive order
268
398
  # applier's contract): text runs and comments verbatim, each
269
399
  # element entry consuming the next item of its collection
270
400
  # (document order). Text reads prefer the content attribute
271
401
  # when it lines up with the text-node count, so mutations to
272
402
  # 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).
403
+ # PIs drop (interpretive parity). A nested ordered instance
404
+ # without element_order (built by Ruby, not parsed) falls
405
+ # back to declaration order — the interpretive behavior.
275
406
  def build_ordered(element, instance, plan, doc, reg = nil,
276
- ns_ctx = nil, _inherited_ns = nil)
407
+ ns_ctx = nil, inherited_ns = nil)
408
+ if instance.element_order.nil?
409
+ return build(element, instance, plan, doc, reg, ns_ctx,
410
+ inherited_ns)
411
+ end
412
+
277
413
  reg ||= register
278
414
  ns_ctx&.collect(plan[:tree])
279
415
  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
416
+ ns_index = ns_ctx ? ns_index_for(plan) : nil
417
+ # (URI, local) row keys — the interpretive matcher's
418
+ # typed-element identity: same-name rows in different
419
+ # namespaces (w14:docId vs w15:docId) must not share one
420
+ # dispatch slot. Name-keyed rows (no exact URI) bind any
421
+ # qualification. The layout is static per (plan, inherited
422
+ # ns) — the reconciliation corpus re-serializes thousands
423
+ # of same-shaped paragraphs, so it is memoized.
424
+ rows_by_name, name_keys =
425
+ ordered_layout_for(plan, inherited_ns, ns_index, reg)
287
426
  content_entry = plan[:rows].find do |rule, _attr, kind, _sp, _del|
288
427
  CONTENT_KINDS.include?(kind) && rule.name.nil?
289
428
  end
290
- order = instance.element_order
429
+ # Writer-added values sit outside the order snapshot — the
430
+ # reconciler inserts their entries at the interpretive
431
+ # anchors and returns the rules it could not place for
432
+ # appended emission (silent drops are data loss). The
433
+ # reconciler speaks CompiledRule — the interpretive
434
+ # transformation's memoized set; the plan's MappingRule
435
+ # rows cannot drive it. Pristine parsed instances skip the
436
+ # scan entirely: the parse-built order covers every set
437
+ # value, and the writer-set flag is the only flow that can
438
+ # produce deficits.
439
+ rules = if instance.mutated_after_parse?
440
+ compiled_rules_for(instance.class, reg)
441
+ end
442
+ if rules
443
+ order, fallback_rules =
444
+ RECONCILER.reconciled_element_order(instance, rules, {})
445
+ else
446
+ order = instance.element_order
447
+ fallback_rules = []
448
+ end
291
449
  element_indices = ::Hash.new(0)
292
450
  content_attr = content_entry && content_entry[1]
293
451
  content_value = content_attr &&
@@ -321,50 +479,136 @@ module Lutaml
321
479
  when "Comment"
322
480
  element.add_child(doc.create_comment(object.text_content))
323
481
  when "Element"
324
- entry = rows_by_name[object.name]
482
+ # A uniquely-named row absorbs the entry at any
483
+ # qualification (the interpretive matcher's lenient
484
+ # binding — order entries written by consumers carry no
485
+ # namespace). Contested names dispatch strictly by
486
+ # (URI, local), falling back to URI-less rows.
487
+ name = object.name
488
+ # Order entries may carry names with no mapped row (the
489
+ # parse records every child in document order); the
490
+ # layout cache kills its default proc to stay
491
+ # mutation-free, so a miss reads nil and drops below.
492
+ keys = name_keys[name]
493
+ if keys&.length == 1
494
+ dispatch_key = keys.first
495
+ entry = rows_by_name[dispatch_key]
496
+ else
497
+ dispatch_key = [object.namespace_uri, name]
498
+ entry = rows_by_name[dispatch_key]
499
+ unless entry
500
+ dispatch_key = [nil, name]
501
+ entry = rows_by_name[dispatch_key]
502
+ end
503
+ end
325
504
  next unless entry
326
505
 
327
- _, attr, kind, _spelling, delegate = entry
506
+ _, attr, kind, _spelling, delegate, row_ns = entry
328
507
  value = value_of(instance, attr, delegate)
329
508
  case kind
330
509
  when :collection_native, :collection_cb
331
- index = element_indices[object.name]
510
+ index = element_indices[dispatch_key]
332
511
  items = Array(value)
333
512
  next unless index < items.length
334
513
 
335
- element_indices[object.name] += 1
336
- add_leaf(element, object.name,
514
+ element_indices[dispatch_key] += 1
515
+ add_leaf(element,
516
+ ns_ctx ? ns_ctx.spell(object.name, row_ns) : object.name,
337
517
  attr.serialize(items[index], :xml, reg), doc)
338
518
  when :nested, :ordered_deferred
339
519
  item = if attr.collection?
340
- index = element_indices[object.name]
341
- element_indices[object.name] += 1
520
+ index = element_indices[dispatch_key]
521
+ element_indices[dispatch_key] += 1
342
522
  Array(value)[index]
343
523
  else
344
524
  value
345
525
  end
346
526
  next unless item
347
527
 
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])
351
- if child_plan[:ordered]
528
+ child_plan = nested_plans_for(plan)[[item.class, object.name]] ||
529
+ (nested_plans_for(plan)[[item.class, object.name]] = PlanCompiler.compile(item.class, reg))
530
+ child_ns = child_plan[:tree][:ns] || row_ns || inherited_ns
531
+ child = element.create_child(
532
+ ns_ctx ? ns_ctx.spell(object.name, child_ns) : object.name,
533
+ )
534
+ if child_plan[:ordered] || !item.element_order.nil?
352
535
  return nil unless build_ordered(child, item, child_plan,
353
- doc, reg)
536
+ doc, reg, ns_ctx, child_ns)
354
537
  else
355
- build(child, item, child_plan, doc, reg)
538
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
356
539
  end
357
540
  when :raw
358
541
  element.add_child(value.to_s) unless value.nil?
542
+ when :child_deferred
543
+ # Valued cycle-point children: interpretive lane (see
544
+ # build).
545
+ return nil unless Array(value).empty?
359
546
  when :scalar
360
- add_leaf(element, object.name,
547
+ add_leaf(element,
548
+ ns_ctx ? ns_ctx.spell(object.name, row_ns) : object.name,
361
549
  attr.serialize(value, :xml, reg), doc)
362
550
  end
363
551
  end
364
552
  end
553
+ return nil if !fallback_rules.empty? &&
554
+ !emit_uncovered_values(element, instance, plan, fallback_rules,
555
+ doc, reg, ns_ctx, inherited_ns)
556
+
557
+ true
558
+ end
559
+
560
+ # OrderReconciler fallback: element rules the reconciled order
561
+ # could not place but that still hold a value — emit them
562
+ # appended, the ordinary way (the interpretive
563
+ # apply_remaining_rules contract for the plan row model).
564
+ def emit_uncovered_values(element, instance, plan, fallback, doc,
565
+ reg, ns_ctx, inherited_ns)
566
+ by_attr = {}
567
+ plan[:rows].each { |row| by_attr[row[1].name] ||= row if row[1] }
568
+ fallback.each do |rule|
569
+ entry = by_attr[rule.attribute_name]
570
+ next unless entry
571
+
572
+ _rule, attr, kind, _spelling, delegate, row_ns = entry
573
+ value = value_of(instance, attr, delegate)
574
+ name = rule.name.to_s
575
+ Array(value).each do |item|
576
+ case kind
577
+ when :collection_native, :collection_cb, :scalar
578
+ add_leaf(element,
579
+ ns_ctx ? ns_ctx.spell(name, row_ns) : name,
580
+ attr.serialize(item, :xml, reg), doc)
581
+ when :nested, :ordered_deferred
582
+ next if item.nil?
583
+
584
+ child_plan =
585
+ nested_plans_for(plan)[[item.class, name]] ||
586
+ (nested_plans_for(plan)[[item.class, name]] =
587
+ PlanCompiler.compile(item.class, reg))
588
+ child_ns = child_plan[:tree][:ns] || row_ns || inherited_ns
589
+ child = element.create_child(
590
+ ns_ctx ? ns_ctx.spell(name, child_ns) : name,
591
+ )
592
+ if child_plan[:ordered] || !item.element_order.nil?
593
+ return nil unless build_ordered(child, item, child_plan,
594
+ doc, reg, ns_ctx, child_ns)
595
+ else
596
+ built = build(child, item, child_plan, doc, reg, ns_ctx,
597
+ child_ns)
598
+ return nil unless built
599
+ end
600
+ end
601
+ end
602
+ # Valued cycle-point rows keep the walk's contract.
603
+ return nil if kind == :child_deferred && !Array(value).empty?
604
+ end
365
605
  true
366
606
  end
367
607
 
608
+ def compiled_rules_for(klass, reg)
609
+ klass.transformation_for(:xml, reg)&.compiled_rules
610
+ end
611
+
368
612
  # Attributes in document order when the instance recorded it
369
613
  # (parsed models), else plan row order.
370
614
  def write_attributes(element, instance, plan, reg = nil,
@@ -411,9 +655,10 @@ module Lutaml
411
655
  prefix = nil)
412
656
  return unless entry
413
657
 
414
- _rule, attr, delegate = entry
658
+ rule, attr, delegate = entry
415
659
  value = value_of(instance, attr, delegate)
416
660
  return if value.nil?
661
+ return unless rule.render?(value, instance)
417
662
 
418
663
  spelled = prefix ? "#{prefix}:#{name}" : name
419
664
  element.set_attribute(spelled,
@@ -426,10 +671,27 @@ module Lutaml
426
671
  def add_leaf(element, name, value, doc, attrs: nil)
427
672
  child = element.create_child(name)
428
673
  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?
674
+ append_text(child, value.to_s, doc) unless value.nil?
430
675
  child
431
676
  end
432
677
 
678
+ # Text attach: the address face (leptris#408 adoption)
679
+ # passes raw node addresses — the pointer face mints an
680
+ # FFI::Pointer wrapper per fresh node, the last per-row
681
+ # Ruby-side allocation on the DOM build.
682
+ def append_text(parent, text, doc)
683
+ node = doc.create_text_node(text)
684
+ status = ::Leptris::XML::FFI.leptris_element_add_child_addr(
685
+ parent.c_address, node.c_address
686
+ )
687
+ unless status.zero?
688
+ raise ::Leptris::XML::Error,
689
+ "text attach failed (status=#{status})"
690
+ end
691
+
692
+ node
693
+ end
694
+
433
695
  def value_of(instance, attr, delegate)
434
696
  if delegate
435
697
  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,