lutaml-model 0.8.102 → 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.
@@ -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)
@@ -55,11 +73,25 @@ module Lutaml
55
73
  )
56
74
  doc.root = root
57
75
  ns_ctx.declare_on(root)
58
- if plan[:ordered]
59
- build_ordered(root, instance, plan, doc, register)
60
- else
61
- 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)
62
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
+
63
95
  doc.to_xml(indent: 2, no_decl: true)
64
96
  end
65
97
 
@@ -115,6 +147,38 @@ module Lutaml
115
147
  @hoisted[uri] = prefix
116
148
  @forms[uri] = { prefix: prefix, uri: uri }
117
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
118
182
  collect(plan[:tree])
119
183
  end
120
184
 
@@ -165,14 +229,18 @@ module Lutaml
165
229
  ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
166
230
  end
167
231
 
232
+ # Declaration order matches the interpretive writer: the
233
+ # prefixed namespaces sorted by prefix, the default
234
+ # namespace last.
168
235
  def declare_on(root, _attr_form_prefix = nil)
169
- @hoisted.each do |uri, prefix|
170
- key = uri
171
- 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]
172
240
 
173
- @declared[key] = true
174
- root.add_namespace_definition(prefix, uri)
175
- end
241
+ @declared[key] = true
242
+ root.add_namespace_definition(prefix, uri)
243
+ end
176
244
  end
177
245
  end
178
246
 
@@ -181,7 +249,7 @@ module Lutaml
181
249
  # plain models through plan rows.
182
250
  def serializable?(plan)
183
251
  plan[:rows].all? do |_rule, _attr, kind, _sp, _del|
184
- SERIALIZABLE_KINDS.include?(kind)
252
+ PLAN_SERIALIZABLE_KINDS.include?(kind)
185
253
  end
186
254
  end
187
255
 
@@ -231,23 +299,33 @@ module Lutaml
231
299
  end
232
300
  when :nested, :ordered_deferred
233
301
  Array(value).each do |item|
234
- child_plan = nested_plans[item.class] ||
235
- (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))
236
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.
237
309
  child = element.create_child(
238
- 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),
239
311
  )
240
312
  ns_ctx&.collect(child_plan[:tree])
241
313
  if child_plan[:ordered]
242
- next unless build_ordered(child, item, child_plan,
243
- doc, reg, ns_ctx, child_ns)
244
-
314
+ build_ordered(child, item, child_plan,
315
+ doc, reg, ns_ctx, child_ns) || return
245
316
  else
246
- build(child, item, child_plan, doc, reg, ns_ctx, child_ns)
317
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
247
318
  end
248
319
  end
249
320
  when :raw
250
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?
251
329
  when :content, :content_deferred
252
330
  Array(value).each { |run| append_text(element, run.to_s, doc) }
253
331
  end
@@ -273,30 +351,96 @@ module Lutaml
273
351
  plan[:nested_plans] ||= {}
274
352
  end
275
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
+
276
392
  # Element_order-driven emission (the interpretive order
277
393
  # applier's contract): text runs and comments verbatim, each
278
394
  # element entry consuming the next item of its collection
279
395
  # (document order). Text reads prefer the content attribute
280
396
  # when it lines up with the text-node count, so mutations to
281
397
  # the model are reflected; element_order text is the fallback.
282
- # PIs drop (interpretive parity). Returns nil when a nested
283
- # 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.
284
401
  def build_ordered(element, instance, plan, doc, reg = nil,
285
- 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
+
286
408
  reg ||= register
287
409
  ns_ctx&.collect(plan[:tree])
288
410
  write_attributes(element, instance, plan, reg, ns_ctx)
289
- rows_by_name = {}
290
- plan[:rows].each do |rule, attr, kind, spelling, delegate|
291
- next unless rule.name
292
-
293
- rows_by_name[rule.name.to_s] ||= [rule, attr, kind, spelling,
294
- delegate]
295
- 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)
296
421
  content_entry = plan[:rows].find do |rule, _attr, kind, _sp, _del|
297
422
  CONTENT_KINDS.include?(kind) && rule.name.nil?
298
423
  end
299
- 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
300
444
  element_indices = ::Hash.new(0)
301
445
  content_attr = content_entry && content_entry[1]
302
446
  content_value = content_attr &&
@@ -330,50 +474,132 @@ module Lutaml
330
474
  when "Comment"
331
475
  element.add_child(doc.create_comment(object.text_content))
332
476
  when "Element"
333
- 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
334
495
  next unless entry
335
496
 
336
- _, attr, kind, _spelling, delegate = entry
497
+ _, attr, kind, _spelling, delegate, row_ns = entry
337
498
  value = value_of(instance, attr, delegate)
338
499
  case kind
339
500
  when :collection_native, :collection_cb
340
- index = element_indices[object.name]
501
+ index = element_indices[dispatch_key]
341
502
  items = Array(value)
342
503
  next unless index < items.length
343
504
 
344
- element_indices[object.name] += 1
345
- 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,
346
508
  attr.serialize(items[index], :xml, reg), doc)
347
509
  when :nested, :ordered_deferred
348
510
  item = if attr.collection?
349
- index = element_indices[object.name]
350
- element_indices[object.name] += 1
511
+ index = element_indices[dispatch_key]
512
+ element_indices[dispatch_key] += 1
351
513
  Array(value)[index]
352
514
  else
353
515
  value
354
516
  end
355
517
  next unless item
356
518
 
357
- child_plan = nested_plans_for(plan)[item.class] ||
358
- (nested_plans_for(plan)[item.class] = PlanCompiler.compile(item.class, reg))
359
- 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
+ )
360
525
  if child_plan[:ordered]
361
526
  return nil unless build_ordered(child, item, child_plan,
362
- doc, reg)
527
+ doc, reg, ns_ctx, child_ns)
363
528
  else
364
- build(child, item, child_plan, doc, reg)
529
+ build(child, item, child_plan, doc, reg, ns_ctx, child_ns) || return
365
530
  end
366
531
  when :raw
367
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?
368
537
  when :scalar
369
- add_leaf(element, object.name,
538
+ add_leaf(element,
539
+ ns_ctx ? ns_ctx.spell(object.name, row_ns) : object.name,
370
540
  attr.serialize(value, :xml, reg), doc)
371
541
  end
372
542
  end
373
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
+
548
+ true
549
+ end
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
374
596
  true
375
597
  end
376
598
 
599
+ def compiled_rules_for(klass, reg)
600
+ klass.transformation_for(:xml, reg)&.compiled_rules
601
+ end
602
+
377
603
  # Attributes in document order when the instance recorded it
378
604
  # (parsed models), else plan row order.
379
605
  def write_attributes(element, instance, plan, reg = nil,
@@ -420,9 +646,10 @@ module Lutaml
420
646
  prefix = nil)
421
647
  return unless entry
422
648
 
423
- _rule, attr, delegate = entry
649
+ rule, attr, delegate = entry
424
650
  value = value_of(instance, attr, delegate)
425
651
  return if value.nil?
652
+ return unless rule.render?(value, instance)
426
653
 
427
654
  spelled = prefix ? "#{prefix}:#{name}" : name
428
655
  element.set_attribute(spelled,
@@ -12,7 +12,7 @@ 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
@@ -23,7 +23,11 @@ module Lutaml
23
23
 
24
24
  PlanHydrator.call(model_class, plan,
25
25
  plan[:descriptor].walk(root),
26
- node: root)
26
+ node: root,
27
+ declaration_plan_mode:
28
+ options.fetch(
29
+ :import_declaration_plan, :eager
30
+ ))
27
31
  end
28
32
  end
29
33
  end
@@ -33,15 +33,18 @@ RSpec.describe "XML plan fast path" do
33
33
  around do |example|
34
34
  old = Lutaml::Model::Config.instance.xml_plan_fast_path
35
35
  Lutaml::Model::Config.instance.xml_plan_fast_path = true
36
+ Lutaml::Model::Config.instance.xml_plan_ordered_path = true
36
37
  example.run
37
38
  ensure
38
39
  Lutaml::Model::Config.instance.xml_plan_fast_path = old
40
+ Lutaml::Model::Config.instance.xml_plan_ordered_path = false
39
41
  end
40
42
 
41
43
  # Real-corpus crash (niso-jats): sec-in-sec nesting is a
42
- # self-referential model — compile must resolve it to nil instead of
43
- # recursing to SystemStackError.
44
- it "opts self-referential models out instead of recursing" do
44
+ # self-referential model — compile must not recurse to
45
+ # SystemStackError. The cycle point defers the child ROW (per-node
46
+ # interpretive islands) instead of opting the model out.
47
+ it "defers the self-referential child row instead of recursing" do
45
48
  node = Class.new(Lutaml::Model::Serializable) do
46
49
  attribute :text, :string
47
50
  xml do
@@ -57,7 +60,9 @@ RSpec.describe "XML plan fast path" do
57
60
  end
58
61
  stub_const("PlanFastPath::SelfRef", node)
59
62
 
60
- expect(Lutaml::Xml::Leptris::PlanCompiler.compile(node, :default)).to be_nil
63
+ plan = Lutaml::Xml::Leptris::PlanCompiler.compile(node, :default)
64
+ expect(plan).not_to be_nil
65
+ expect(plan[:rows].map { |r| r[2] }).to include(:child_deferred)
61
66
 
62
67
  xml = "<n>#{'<n>t</n>' * 200}</n>"
63
68
  parsed = node.from_xml(xml)