lutaml-model 0.8.84 → 0.8.85

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.
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Leptris
6
+ # Hydrates model instances from a Descriptor#walk PlanValue tree,
7
+ # keyed by each value's producing row name (never position — rows
8
+ # for missing elements are simply absent). Native rows build
9
+ # constructor kwargs; deferred rows (custom methods, polymorphism,
10
+ # unions) capture their subtree verbatim and interpret it
11
+ # post-walk — the fragment parse runs the existing interpretive
12
+ # machinery on just that island. Delegate rules hydrate
13
+ # post-instance onto their target object. Collection rows route
14
+ # natively when a single one exists, else through callback rows
15
+ # (their values echo name and type_tag; native collection values
16
+ # echo neither — leptris-ruby#220).
17
+ module PlanHydrator
18
+ class << self
19
+ # plan: the compiler's entry for model_class
20
+ # value: the walk root PlanValue (element)
21
+ # node: the parsed source element (leptris); ordered/mixed
22
+ # plans rebuild element_order from it and ordered children
23
+ # hydrate natively against their own nodes
24
+ def call(model_class, plan, value, parent: nil, node: nil,
25
+ register: nil)
26
+ # The child model's declared lutaml_default_register takes
27
+ # precedence over the ambient register — the same
28
+ # resolve_for_child contract the interpretive path applies.
29
+ # Without it, hydration resolves the child's symbol attribute
30
+ # types in the parent context and raises UnknownTypeError
31
+ # for ids registered only in the child's register (#876).
32
+ register = Lutaml::Model::Register.resolve_for_child(
33
+ model_class, register || Lutaml::Model::Config.default_register
34
+ )
35
+ buckets = node && plan[:needs_nodes] ? element_buckets(node) : nil
36
+ attr_kwargs = attributes_kwargs(plan, value, node)
37
+ child_kwargs, children, delegates =
38
+ children_kwargs(model_class, plan, value, buckets, register)
39
+ plan[:collection_defaults].each do |name|
40
+ next if attr_kwargs.key?(name) || child_kwargs.key?(name)
41
+
42
+ child_kwargs[name] = []
43
+ end
44
+ # TODO.max-perf/33: the bulk constructor — allocate +
45
+ # compiled writers, from_hash semantics — measured ~30x on
46
+ # hydration-heavy corpora versus the generic constructor.
47
+ instance = model_class.instantiate(attr_kwargs.merge(child_kwargs),
48
+ register)
49
+ instance.lutaml_parent = parent if parent
50
+ instance.lutaml_root ||= parent&.lutaml_root || parent
51
+ instance.element_order = PlanOrder.build(node) if node && plan[:ordered]
52
+ children.each do |child|
53
+ child.lutaml_parent = instance
54
+ child.lutaml_root ||= instance.lutaml_root || instance
55
+ end
56
+ interpret_deferred(model_class, plan, value, instance, node, buckets)
57
+ route_delegates(delegates, instance)
58
+ instance
59
+ end
60
+
61
+ private
62
+
63
+ def register
64
+ Lutaml::Model::Config.default_register
65
+ end
66
+
67
+ def attributes_kwargs(plan, value, node = nil)
68
+ kwargs = {}
69
+ sole_claimants = sole_claimant_names(plan)
70
+ plan_attr_rows(plan).each do |rule, attr, name|
71
+ v = value.attribute(name)
72
+ if v.nil? && node && sole_claimants.include?(name)
73
+ # #754/#790 parity: a sole-claimant attribute rule binds
74
+ # any qualification (the interpretive matcher's lenient
75
+ # recovery). The walk captures exact (URI, local) matches
76
+ # only, so the qualified spelling is read off the node.
77
+ v = lenient_node_attribute(node, name)
78
+ end
79
+ next if v.nil?
80
+
81
+ v = v.split(rule.delimiter) if rule.delimiter
82
+ if rule.as_list && rule.as_list[:import]
83
+ v = rule.as_list[:import].call(v)
84
+ end
85
+ kwargs[attr.name.to_sym] = v
86
+ end
87
+ kwargs
88
+ end
89
+
90
+ # Wire-name strings are plan-frozen; materializing them per
91
+ # hydration allocated two or three strings per attribute row
92
+ # per instance.
93
+ def plan_attr_rows(plan)
94
+ plan[:attr_row_names] ||= plan[:attr_rows].map do |rule, attr|
95
+ [rule, attr, rule.name.to_s]
96
+ end
97
+ end
98
+
99
+ # Returns [kwargs, hydrated_child_instances, delegate_values]
100
+ # — child instances come back so the caller can decorate
101
+ # parent/root links once the parent exists; delegate values
102
+ # wait for the instance (their target object must exist).
103
+ def children_kwargs(_model_class, plan, value, buckets = nil,
104
+ register = nil)
105
+ register ||= Lutaml::Model::Config.default_register
106
+ grouped, tagged = group_children(value, plan[:row_tags])
107
+ buckets = nil unless buckets && plan[:needs_nodes]
108
+ kwargs = {}
109
+ children = []
110
+ delegates = []
111
+ # Lazy: models without multi-spelling rows never touch it, and
112
+ # the block-backed Hash is a fresh object per hydrated row.
113
+ spellings = nil
114
+ row_tags = plan[:row_tags]
115
+ plan[:rows].each_with_index do |(rule, attr, kind, spelling, delegate), idx|
116
+ tag = row_tags&.[](idx)
117
+ case kind
118
+ when :scalar
119
+ # Raw passthrough: the model constructor is the single
120
+ # cast authority — pre-casting here doubled every cast.
121
+ # Class transforms still apply before assignment.
122
+ if tag && (vals = tagged[tag]) && vals.size > 1
123
+ # Interpretive parity: several captures into a
124
+ # non-collection attribute arrive as the full array.
125
+ v = vals.map(&:string_value)
126
+ else
127
+ first = if tag
128
+ tagged[tag]&.first
129
+ else
130
+ grouped.dig(rule.name.to_s, 0)
131
+ end
132
+ v = first&.string_value
133
+ end
134
+ unless v.nil?
135
+ v = rule.transform_value(attr, v, :from, :xml) if rule.transform.is_a?(Class)
136
+ assign(kwargs, delegates, delegate, rule, attr, v)
137
+ end
138
+ when :raw, :custom_method, :polymorphic, :content_deferred
139
+ # interpreted post-instance (interpret_deferred)
140
+ when :content
141
+ assign(kwargs, delegates, delegate, rule, attr,
142
+ content_runs(value))
143
+ when :ordered_deferred
144
+ # With a source node the child hydrates natively: one
145
+ # walk against its own node + element_order from the
146
+ # node's children. Without one (direct PlanHydrator
147
+ # use), the subtree fragment-parses interpretively.
148
+ if buckets
149
+ child_type = attr.type(register)
150
+ child_plan = PlanCompiler.compile(child_type, register)
151
+ items = buckets.fetch(rule.name.to_s, []).map do |n|
152
+ call(child_type, child_plan,
153
+ child_plan[:descriptor].walk(n), node: n,
154
+ register: register)
155
+ end
156
+ unless items.empty?
157
+ children.concat(items)
158
+ assign(kwargs, delegates, delegate, rule, attr,
159
+ attr.collection? ? items : items.first)
160
+ end
161
+ end
162
+ when :collection_cb
163
+ values = grouped[rule.name.to_s].to_a.map(&:string_value)
164
+ if rule.transform.is_a?(Class)
165
+ values = values.map { |v| rule.transform_value(attr, v, :from, :xml) }
166
+ end
167
+ unless values.empty?
168
+ assign(kwargs, delegates, delegate, rule, attr, values)
169
+ end
170
+ when :collection_native
171
+ values = if tag
172
+ tagged[tag].to_a.map(&:string_value)
173
+ else
174
+ native_collection(value, rule.name.to_s)
175
+ end
176
+ unless values.nil?
177
+ assign(kwargs, delegates, delegate, rule, attr, values)
178
+ end
179
+ when :spelling
180
+ spellings ||= Hash.new { |h, k| h[k] = [] }
181
+ spellings[[rule, attr]] << grouped[spelling.to_s].to_a
182
+ when :nested
183
+ child_type = attr.type(register)
184
+ child_plan = PlanCompiler.compile(child_type, register)
185
+ cursor = if child_plan[:needs_nodes] && buckets
186
+ buckets.fetch(rule.name.to_s, [])
187
+ end
188
+ items = grouped.fetch(rule.name.to_s, []).each_with_index
189
+ .map do |v, i|
190
+ call(child_type, child_plan, v,
191
+ node: cursor && cursor[i], register: register)
192
+ end
193
+ next if items.empty?
194
+
195
+ children.concat(items)
196
+ assign(kwargs, delegates, delegate, rule, attr,
197
+ attr.collection? ? items : items.first)
198
+ end
199
+ end
200
+ unless !spellings || spellings.empty?
201
+ spellings.each do |(rule, attr), groups|
202
+ # Interpretive order for shared-attribute groups is
203
+ # spelling-group order (first spelling's matches, then
204
+ # the next's), not interleaved document order.
205
+ values = groups.compact.flatten.map(&:string_value)
206
+ next if values.empty?
207
+
208
+ delegate = delegate_of(plan, rule)
209
+ assign(kwargs, delegates, delegate, rule, attr,
210
+ attr.collection? ? values : values.first)
211
+ end
212
+ end
213
+ [kwargs, children, delegates]
214
+ end
215
+
216
+ # Deferred islands: raw subtrees become wrapper elements via a
217
+ # fragment parse, then the interpretive machinery runs on just
218
+ # that island — custom method invocation with an
219
+ # element-shaped argument, or the polymorphic/union cast.
220
+ # With the source node available the island wraps the ORIGINAL
221
+ # node (no fragment parse — the reparse dominated deferred
222
+ # hydration). Ordered children only land here without a node.
223
+ def interpret_deferred(model_class, plan, value, instance, node = nil,
224
+ buckets = nil)
225
+ plan[:rows].each do |rule, attr, kind, _spelling, _delegate|
226
+ case kind
227
+ when :content_deferred
228
+ runs = content_runs(value)
229
+ if runs.empty?
230
+ # The interpretive pipeline marks every applied rule's
231
+ # attribute set (model_transform apply). Skipping the
232
+ # mark leaves using_default? true, so the serializer's
233
+ # render gate suppresses a mapped reader that derives
234
+ # content from other attributes (#856).
235
+ instance.value_set_for(attr.name)
236
+ next
237
+ end
238
+
239
+ # Non-collection content attrs hold the joined text
240
+ # (the interpretive path assigns element text, the runs
241
+ # concatenated).
242
+ instance.public_send(:"#{attr.name}=", runs.join)
243
+ when :raw
244
+ raws = raw_strings(value, rule)
245
+ next if raws.empty?
246
+
247
+ instance.public_send(:"#{attr.name}=",
248
+ attr.collection? ? raws : raws.first)
249
+ when :custom_method
250
+ elements = deferred_elements(value, rule, buckets)
251
+ next if elements.empty?
252
+
253
+ args = attr.collection? ? elements : elements.first
254
+ rule.deserialize(instance, args,
255
+ model_class.attributes(register),
256
+ model_class)
257
+ when :ordered_deferred
258
+ next if node # hydrated natively in children_kwargs
259
+
260
+ results = deferred_elements(value, rule, buckets).map do |element|
261
+ attr.cast(element, :xml, register,
262
+ lutaml_parent: instance,
263
+ lutaml_root: instance.lutaml_root || instance)
264
+ end
265
+ next if results.empty?
266
+
267
+ instance.public_send(:"#{attr.name}=",
268
+ attr.collection? ? results : results.first)
269
+ when :polymorphic
270
+ results = deferred_elements(value, rule, buckets).map do |element|
271
+ attr.cast(element, :xml, register,
272
+ polymorphic: rule.polymorphic,
273
+ lutaml_parent: instance,
274
+ lutaml_root: instance.lutaml_root || instance)
275
+ end
276
+ next if results.empty?
277
+
278
+ instance.public_send(:"#{attr.name}=",
279
+ attr.collection? ? results : results.first)
280
+ end
281
+ end
282
+ end
283
+
284
+ # Wrapper elements for deferred rows: the ORIGINAL source
285
+ # nodes bridged through moxml's wrapper cache (wrapper
286
+ # construction only, no parse), falling back to a fragment
287
+ # parse of the captured subtree when no node is available.
288
+ # Both lists are document-ordered for the row's name.
289
+ def deferred_elements(value, rule, buckets)
290
+ nodes = buckets && buckets[rule.name.to_s]
291
+ return nodes.map { |n| bridge_element(n) } if nodes
292
+
293
+ raw_strings(value, rule).map { |raw| fragment_element(raw) }
294
+ end
295
+
296
+ def bridge_element(node)
297
+ Lutaml::Xml::LeptrisElement.new(
298
+ Moxml::Node.wrap(node, bridge_context),
299
+ )
300
+ end
301
+
302
+ def bridge_context
303
+ @bridge_context ||= Moxml::Context.new(:leptris)
304
+ end
305
+
306
+ def route_delegates(delegates, instance)
307
+ delegates.each do |rule, delegate_attr, attr, v|
308
+ target = instance.public_send(rule.delegate)
309
+ unless target
310
+ # the interpretive path instantiates absent delegate
311
+ # targets; mirror it
312
+ target = delegate_attr.type(register).new
313
+ instance.public_send(:"#{rule.delegate}=", target)
314
+ end
315
+ target.public_send(:"#{attr.name}=", v)
316
+ end
317
+ end
318
+
319
+ def delegate_of(plan, rule)
320
+ entry = plan[:rows].find { |r, _, _k, _| r.equal?(rule) }
321
+ entry && entry[4]
322
+ end
323
+
324
+ # Delegate rules hold their values for post-instance routing
325
+ # (the target object must exist first); plain rules land in
326
+ # kwargs directly.
327
+ def assign(kwargs, delegates, delegate, rule, attr, value)
328
+ if delegate
329
+ delegates << [rule, delegate, attr, value]
330
+ else
331
+ kwargs[attr.name.to_sym] = value
332
+ end
333
+ end
334
+
335
+ def content_runs(value)
336
+ out = []
337
+ value.count.times do |i|
338
+ c = value.at(i)
339
+ next unless c.name.nil? && c.kind == :collection
340
+
341
+ out.concat(Array.new(c.count) { |j| c.at(j).string_value })
342
+ end
343
+ out
344
+ end
345
+
346
+ # Native collection rows echo their producing row's name
347
+ # (leptris 1.9.178).
348
+ def native_collection(value, name)
349
+ value.count.times do |i|
350
+ c = value.at(i)
351
+ next unless c.kind == :collection && c.name == name
352
+
353
+ return Array.new(c.count) { |j| c.at(j).string_value }
354
+ end
355
+ nil
356
+ end
357
+
358
+ def raw_strings(value, rule)
359
+ out = []
360
+ value.count.times do |idx|
361
+ child = value.at(idx)
362
+ if child.name == rule.name.to_s && child.kind == :raw
363
+ out << child.string_value
364
+ end
365
+ end
366
+ out
367
+ end
368
+
369
+ # Fragment deferral: parse the captured subtree back into a
370
+ # wrapper element. Ancestor namespace context is absent (the
371
+ # compiler only defers on namespace-free model chains).
372
+ def fragment_element(raw)
373
+ Lutaml::Xml::Adapter::LeptrisAdapter.parse(raw).root
374
+ end
375
+
376
+ # Attribute names claimed by exactly one attr row — the only
377
+ # names eligible for #754/#790 lenient binding (multi-claimant
378
+ # names stay exact; per #841 leniency is sole-claimant-only).
379
+ def sole_claimant_names(plan)
380
+ # Frozen with the plan: recomputing the tally per hydration
381
+ # showed in the #876-era profile as Enumerable#find/tally churn.
382
+ plan[:sole_claimants] ||= plan[:attr_rows]
383
+ .map { |rule, _attr| rule.name.to_s }.tally
384
+ .select { |_n, c| c == 1 }.keys
385
+ end
386
+
387
+ # Local-name attribute lookup on the source node, qualified
388
+ # spellings included (w:val matches rule "val").
389
+ def lenient_node_attribute(node, local_name)
390
+ return nil unless node.respond_to?(:attributes)
391
+
392
+ node.attributes.each_value do |attr|
393
+ # Leptris::XML::Attr carries the raw (possibly prefixed)
394
+ # name; strip the prefix for the local-name comparison.
395
+ name = attr.name.to_s
396
+ name = name.split(":").last if name.include?(":")
397
+ return attr.value if name == local_name
398
+ end
399
+ nil
400
+ end
401
+
402
+ def group_children(value, row_tags = nil)
403
+ # One crossing per subtree: names/tags/children come back in
404
+ # parallel arrays from leptris_plan_value_children_snapshot.
405
+ # The plan-path version gate (PlanCompiler.compile) already
406
+ # guarantees leptris >= 1.9.273.0.
407
+ names, tags, children = value.children_snapshot
408
+ grouped = {}
409
+ want_tags = !row_tags.nil?
410
+ tagged = want_tags ? {} : nil
411
+ names.each_with_index do |name, i|
412
+ next if name.nil? # content runs, read separately
413
+
414
+ child = children[i]
415
+ (grouped[name] ||= []) << child
416
+ if want_tags && tags[i] != 0
417
+ (tagged[tags[i]] ||= []) << child
418
+ end
419
+ end
420
+ [grouped, tagged]
421
+ end
422
+
423
+ # Element children bucketed by local name, document order
424
+ # preserved — the node-side mirror of group_children for
425
+ # ordered-child hydration. Only built when the plan's subtree
426
+ # needs source nodes; namespace-qualified models never get
427
+ # here (the compiler keeps them interpretive).
428
+ def element_buckets(node)
429
+ buckets = {}
430
+ node.children.each do |child|
431
+ next unless child.is_a?(::Leptris::XML::Element)
432
+
433
+ (buckets[child.name] ||= []) << child
434
+ end
435
+ buckets
436
+ end
437
+ end
438
+ end
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Leptris
6
+ # Reconstructs element_order for ordered/mixed models from the
7
+ # leptris node surface in one native pass, mirroring the
8
+ # interpretive AdapterElement#order contract: text runs, CDATA,
9
+ # comments, PIs, and element entries (local name + namespace
10
+ # uri/prefix) in document order, frozen like the interpretive
11
+ # product.
12
+ module PlanOrder
13
+ TEXT_MARKER = "text"
14
+ CDATA_MARKER = "#cdata-section"
15
+ COMMENT_MARKER = "comment"
16
+
17
+ class << self
18
+ def build(node)
19
+ node.children.filter_map { |child| entry_for(child) }
20
+ .each(&:freeze).freeze
21
+ end
22
+
23
+ private
24
+
25
+ def entry_for(child)
26
+ case child
27
+ when ::Leptris::XML::CDATA
28
+ Element.new("Text", CDATA_MARKER,
29
+ text_content: child.content, node_type: :cdata)
30
+ when ::Leptris::XML::Text
31
+ Element.new("Text", TEXT_MARKER,
32
+ text_content: child.text, node_type: :text)
33
+ when ::Leptris::XML::Comment
34
+ Element.new("Comment", COMMENT_MARKER,
35
+ text_content: child.content, node_type: :comment)
36
+ when ::Leptris::XML::ProcessingInstruction
37
+ Element.new("ProcessingInstruction", child.name,
38
+ text_content: child.content.to_s.sub(/\A\s+/, ""),
39
+ node_type: :processing_instruction)
40
+ when ::Leptris::XML::Element
41
+ ns = child.namespace
42
+ # Leptris::XML::Namespace is a single object (href/prefix),
43
+ # not the Nokogiri-style prefix hash — hash accessors blow
44
+ # up ordered+namespaced plan parses.
45
+ Element.new("Element", child.name,
46
+ node_type: :element,
47
+ namespace_uri: ns&.href,
48
+ namespace_prefix: ns&.prefix)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end