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.
@@ -16,13 +16,18 @@ module Lutaml
16
16
  # echo neither — leptris-ruby#220).
17
17
  module PlanHydrator
18
18
  class << self
19
- # plan: the compiler's entry for model_class
19
+ # plan: the compiled plan entry for model_class
20
20
  # value: the walk root PlanValue (element)
21
21
  # node: the parsed source element (leptris); ordered/mixed
22
22
  # plans rebuild element_order from it and ordered children
23
23
  # hydrate natively against their own nodes
24
+ # declaration_plan_mode: mirrors from_xml's
25
+ # :import_declaration_plan option (:skip records nothing;
26
+ # :lazy is honored as :eager — the lazy variant pins the
27
+ # raw engine DOM, the retention the eager default exists
28
+ # to avoid)
24
29
  def call(model_class, plan, value, parent: nil, node: nil,
25
- register: nil)
30
+ register: nil, declaration_plan_mode: :eager)
26
31
  # The child model's declared lutaml_default_register takes
27
32
  # precedence over the ambient register — the same
28
33
  # resolve_for_child contract the interpretive path applies.
@@ -32,7 +37,9 @@ module Lutaml
32
37
  register = Lutaml::Model::Register.resolve_for_child(
33
38
  model_class, register || Lutaml::Model::Config.default_register
34
39
  )
35
- buckets = node && plan[:needs_nodes] ? element_buckets(node) : nil
40
+ buckets = if node && plan[:needs_nodes]
41
+ element_buckets(node, plan[:contested_names])
42
+ end
36
43
  attr_kwargs = attributes_kwargs(plan, value, node)
37
44
  child_kwargs, children, delegates =
38
45
  children_kwargs(model_class, plan, value, buckets, register)
@@ -48,12 +55,31 @@ module Lutaml
48
55
  register)
49
56
  instance.lutaml_parent = parent if parent
50
57
  instance.lutaml_root ||= parent&.lutaml_root || parent
58
+ # Root prefix fidelity (the interpretive transform records
59
+ # it from the source element): prefixed sources keep their
60
+ # prefixes through the serialize lane's format
61
+ # preservation.
62
+ if parent.nil? && node.respond_to?(:prefix) && node.prefix &&
63
+ !node.prefix.empty?
64
+ instance.xml_namespace_prefix = node.prefix
65
+ end
51
66
  # #909: order summaries are per-instance pinned records —
52
67
  # read-only consumers skip them via the config.
53
68
  if node && plan[:ordered] &&
54
69
  Lutaml::Model::Config.instance.element_order_tracking
55
70
  instance.element_order = PlanOrder.build(node)
56
71
  end
72
+ # Root declaration-plan parity (the interpretive
73
+ # transform's eager mode): consumers and the interpretive
74
+ # serialize fallback read import_declaration_plan state —
75
+ # plan-hydrated roots must carry the same in-scope xmlns
76
+ # declarations or round-trips drop them (#872). The root's
77
+ # xsi:schemaLocation rides the same state (the
78
+ # interpretive set_schema_location contract).
79
+ if parent.nil? && node
80
+ record_declaration_plan(instance, node, model_class) if declaration_plan_mode != :skip
81
+ record_schema_location(instance, node)
82
+ end
57
83
  children.each do |child|
58
84
  child.lutaml_parent = instance
59
85
  child.lutaml_root ||= instance.lutaml_root || instance
@@ -65,6 +91,51 @@ module Lutaml
65
91
 
66
92
  private
67
93
 
94
+ def record_declaration_plan(instance, node, model_class)
95
+ ns_data = collect_native_namespaces(node)
96
+ return if ns_data.empty?
97
+
98
+ plan = Lutaml::Xml::DeclarationPlan.from_input_with_locations(
99
+ ns_data, model_class.mappings_for(:xml)
100
+ )
101
+ instance.import_declaration_plan = plan if plan
102
+ end
103
+
104
+ # The interpretive parse stores the root's xsi:schemaLocation
105
+ # verbatim (set_schema_location); the writer re-emits it.
106
+ def record_schema_location(instance, node)
107
+ schema_loc = node.attributes.each_value.find do |a|
108
+ a.name.to_s.split(":", 2).last == "schemaLocation"
109
+ end
110
+ instance.raw_schema_location = schema_loc.value if schema_loc
111
+ end
112
+
113
+ # Same ns_data shape as ModelTransform.collect_element_namespaces,
114
+ # collected straight off the engine nodes — no wrapper tree.
115
+ # Iterates children with an inline element filter:
116
+ # element_children materializes a filtered array per node,
117
+ # which the niso allocation gate caught at scale.
118
+ def collect_native_namespaces(element, path = [], result = {})
119
+ own = element.namespace_definitions
120
+ unless own.empty?
121
+ namespaces = {}
122
+ own.each do |ns|
123
+ key = ns.prefix || :default
124
+ namespaces[key] = { uri: ns.href, prefix: ns.prefix,
125
+ format: ns.prefix ? :prefix : :default }
126
+ end
127
+ result[path.dup] = namespaces
128
+ end
129
+ element.children.each do |child|
130
+ next unless child.is_a?(::Leptris::XML::Element)
131
+
132
+ path.push(child.name)
133
+ collect_native_namespaces(child, path, result)
134
+ path.pop
135
+ end
136
+ result
137
+ end
138
+
68
139
  def register
69
140
  Lutaml::Model::Config.default_register
70
141
  end
@@ -152,8 +223,8 @@ module Lutaml
152
223
  # hydration allocated two or three strings per attribute row
153
224
  # per instance.
154
225
  def plan_attr_rows(plan)
155
- plan[:attr_row_names] ||= plan[:attr_rows].map do |rule, attr|
156
- [rule, attr, rule.name.to_s]
226
+ plan[:attr_row_names] ||= plan[:attr_rows].map do |rule, attr, row_name|
227
+ [rule, attr, row_name || rule.name.to_s]
157
228
  end
158
229
  end
159
230
 
@@ -173,7 +244,9 @@ module Lutaml
173
244
  # the block-backed Hash is a fresh object per hydrated row.
174
245
  spellings = nil
175
246
  row_tags = plan[:row_tags]
176
- plan[:rows].each_with_index do |(rule, attr, kind, spelling, delegate), idx|
247
+ # rubocop:disable Metrics/BlockLength -- one row-kind
248
+ # dispatch table; splitting it would scatter the contract
249
+ plan[:rows].each_with_index do |(rule, attr, kind, spelling, delegate, row_type), idx|
177
250
  tag = row_tags&.[](idx)
178
251
  case kind
179
252
  when :scalar
@@ -221,9 +294,11 @@ module Lutaml
221
294
  # node's children. Without one (direct PlanHydrator
222
295
  # use), the subtree fragment-parses interpretively.
223
296
  if buckets
224
- child_type = attr.type(register)
297
+ child_type = row_type || attr.type(register)
225
298
  child_plan = PlanCompiler.compile(child_type, register)
226
- items = buckets.fetch(rule.name.to_s, []).map do |n|
299
+ selected, = child_bucket(buckets, rule, child_plan,
300
+ contested_name?(plan, rule))
301
+ items = (selected || []).map do |n|
227
302
  call(child_type, child_plan,
228
303
  child_plan[:descriptor].walk(n), node: n,
229
304
  register: register)
@@ -234,6 +309,26 @@ module Lutaml
234
309
  attr.collection? ? items : items.first)
235
310
  end
236
311
  end
312
+ when :child_deferred
313
+ # Uncompilable child (cycles, opted-out shapes): each
314
+ # source node casts through the attribute — the full
315
+ # interpretive machinery on the bridged island,
316
+ # identical semantics to the interpretive path.
317
+ contested_uri = if plan[:contested_names]&.include?(rule.name.to_s)
318
+ child_type_uri(attr, register)
319
+ end
320
+ elements = deferred_elements(value, rule, buckets,
321
+ contested_uri)
322
+ next if elements.empty?
323
+
324
+ results = elements.map do |element|
325
+ attr.cast(element, :xml, register)
326
+ end
327
+ unless results.empty?
328
+ children.concat(results)
329
+ assign(kwargs, delegates, delegate, rule, attr,
330
+ attr.collection? ? results : results.first)
331
+ end
237
332
  when :collection_cb
238
333
  values = grouped[rule.name.to_s].to_a.map do |item|
239
334
  item&.string_value
@@ -259,16 +354,30 @@ module Lutaml
259
354
  spellings ||= Hash.new { |h, k| h[k] = [] }
260
355
  spellings[[rule, attr]] << grouped[spelling.to_s].to_a
261
356
  when :nested
262
- child_type = attr.type(register)
357
+ child_type = row_type || attr.type(register)
263
358
  child_plan = PlanCompiler.compile(child_type, register)
264
- cursor = if child_plan[:needs_nodes] && buckets
265
- buckets.fetch(rule.name.to_s, [])
266
- end
267
- items = grouped.fetch(rule.name.to_s, []).each_with_index
268
- .map do |v, i|
269
- call(child_type, child_plan, v,
270
- node: cursor && cursor[i], register: register)
359
+ selected, = child_bucket(buckets, rule, child_plan,
360
+ contested_name?(plan, rule))
361
+ if selected && plan[:contested_names]&.include?(rule.name.to_s)
362
+ # Contested name: the walk's grouped values cross-echo
363
+ # every same-name element into every row — dispatch
364
+ # per source node instead (the walk is row-scoped).
365
+ items = selected.map do |n|
366
+ call(child_type, child_plan,
367
+ child_plan[:descriptor].walk(n), node: n,
368
+ register: register)
271
369
  end
370
+ else
371
+ values = grouped.fetch(rule.name.to_s, [])
372
+ cursor = if child_plan[:needs_nodes] && buckets
373
+ selected
374
+ end
375
+ items = values.each_with_index
376
+ .map do |v, i|
377
+ call(child_type, child_plan, v,
378
+ node: cursor && cursor[i], register: register)
379
+ end
380
+ end
272
381
  next if items.empty?
273
382
 
274
383
  children.concat(items)
@@ -276,6 +385,7 @@ module Lutaml
276
385
  attr.collection? ? items : items.first)
277
386
  end
278
387
  end
388
+ # rubocop:enable Metrics/BlockLength
279
389
  unless !spellings || spellings.empty?
280
390
  spellings.each do |(rule, attr), groups|
281
391
  # Interpretive order for shared-attribute groups is
@@ -405,11 +515,30 @@ module Lutaml
405
515
  # construction only, no parse), falling back to a fragment
406
516
  # parse of the captured subtree when no node is available.
407
517
  # Both lists are document-ordered for the row's name.
408
- def deferred_elements(value, rule, buckets)
409
- nodes = buckets && buckets[rule.name.to_s]
410
- return nodes.map { |n| bridge_element(n) } if nodes
518
+ def deferred_elements(value, rule, buckets, contested_uri = nil)
519
+ return raw_strings(value, rule).map { |raw| fragment_element(raw) } unless buckets
411
520
 
412
- raw_strings(value, rule).map { |raw| fragment_element(raw) }
521
+ nodes = buckets[rule.name.to_s]
522
+ return [] unless nodes
523
+
524
+ if contested_uri
525
+ # Contested name: bind only the (URI, local) matches —
526
+ # the type's own mapping namespace is the row identity.
527
+ nodes = buckets[[contested_uri, rule.name.to_s]] || []
528
+ end
529
+ nodes.map { |n| bridge_element(n) }
530
+ end
531
+
532
+ def child_type_uri(attr, register)
533
+ type = attr.type(register)
534
+ return nil unless type.is_a?(Class) &&
535
+ type.include?(::Lutaml::Model::Serialize)
536
+
537
+ type.mappings_for(:xml, register)&.namespace_uri
538
+ end
539
+
540
+ def contested_name?(plan, rule)
541
+ plan[:contested_names]&.include?(rule.name.to_s) || false
413
542
  end
414
543
 
415
544
  def bridge_element(node)
@@ -499,7 +628,7 @@ module Lutaml
499
628
  # Frozen with the plan: recomputing the tally per hydration
500
629
  # showed in the #876-era profile as Enumerable#find/tally churn.
501
630
  plan[:sole_claimants] ||= plan[:attr_rows]
502
- .map { |rule, _attr| rule.name.to_s }.tally
631
+ .map { |rule, _attr, row_name| row_name || rule.name.to_s }.tally
503
632
  .select { |_n, c| c == 1 }.keys
504
633
  end
505
634
 
@@ -544,15 +673,48 @@ module Lutaml
544
673
  # ordered-child hydration. Only built when the plan's subtree
545
674
  # needs source nodes; namespace-qualified models never get
546
675
  # here (the compiler keeps them interpretive).
547
- def element_buckets(node)
676
+ # Contested names need (URI, local) node buckets; every other
677
+ # plan hydrates from the name view alone — the qualified keys
678
+ # are one array + hash write per child and measured as an
679
+ # allocation regression on element-heavy corpora (niso gate).
680
+ def element_buckets(node, contested_names = nil)
548
681
  buckets = {}
682
+ qualified = contested_names && !contested_names.empty?
549
683
  node.children.each do |child|
550
684
  next unless child.is_a?(::Leptris::XML::Element)
551
685
 
552
- (buckets[child.name] ||= []) << child
686
+ name = child.name
687
+ (buckets[name] ||= []) << child
688
+ if qualified
689
+ uri = child.namespace&.href
690
+ (buckets[[uri, name]] ||= []) << child if uri
691
+ end
553
692
  end
554
693
  buckets
555
694
  end
695
+
696
+ # (URI, local) child identity — the interpretive matcher's
697
+ # typed-element dispatch: same-name rows in different
698
+ # namespaces bind by the child type's exact URI (w14:docId vs
699
+ # w15:docId under one model). Only CONTESTED names dispatch
700
+ # exactly — a uniquely-named row binds the whole name bucket
701
+ # leniently (any qualification), and the uri-keyed view is
702
+ # built only for plans that carry contested names. Returns
703
+ # [selected, all] where all is the name bucket (aligned with
704
+ # the walk's grouped values for position filtering).
705
+ def child_bucket(buckets, rule, child_plan, contested)
706
+ name = rule.name.to_s
707
+ all = buckets ? buckets[name] : nil
708
+ return [all, all] unless buckets && all && contested
709
+
710
+ tree_ns = child_plan[:tree][:ns]
711
+ exact_uri = tree_ns.is_a?(Hash) ? tree_ns[:exact] : tree_ns
712
+ if exact_uri
713
+ qualified = buckets[[exact_uri, name]]
714
+ return [qualified || [], all]
715
+ end
716
+ [all, all]
717
+ end
556
718
  end
557
719
  end
558
720
  end