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.
@@ -1,362 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Lutaml
4
- module Xml
5
- # Phase 5 slice: compile a model's XML mapping into a
6
- # Leptris::XML::Descriptor plan and materialize whole documents in
7
- # one native pass (leptris_plan_walk) — no moxml wrapper tree, no
8
- # per-element Ruby dispatch. Measured on the 200-item probe:
9
- # walk+hydrate 8.7x faster, 82% fewer allocations than the
10
- # interpretive path, hydration-equal output.
11
- #
12
- # A model compiles when EVERY rule is plan-shaped; richer rows
13
- # defer their subtree verbatim and interpret post-walk rather
14
- # than opting the whole model out:
15
- # - map_attribute / map_element / content / raw rows
16
- # - Value-scalar types or nested Serializables
17
- # - custom methods, polymorphism, unions, and ordered/mixed
18
- # children capture :raw and interpret post-walk
19
- # - ordered/mixed root mappings compile; the entry points
20
- # reconstruct element_order from the node surface
21
- # - attributes not derived/union/polymorphic
22
- #
23
- # The fast path is opt-in (Config.xml_plan_fast_path) while the
24
- # full semantics audit (parent links, consolidation, ordering
25
- # metadata) completes.
26
- module PlanCompiler
27
- # TODO.perf/15: env-gated histogram of why models opt out of the
28
- # plan path. Set PLAN_COMPILE_STATS=1 and read PlanCompiler.stats
29
- # after a parse. Zero cost when disabled.
30
- def self.stats
31
- @stats ||= Hash.new(0)
32
- end
33
-
34
- def self.opt_out!(clause)
35
- @plan_stats ||= Hash.new(0)
36
- @plan_stats[clause] += 1 if ENV["PLAN_COMPILE_STATS"]
37
- nil
38
- end
39
-
40
- # Isolated holder: suites freeze model classes; a cache on a
41
- # frozen constant would be immutable (TypeProbeCache precedent).
42
- PLAN_CACHE = ::Class.new do
43
- class << self
44
- def cache
45
- @cache ||= {}
46
- end
47
- end
48
- end.cache
49
-
50
- class << self
51
- def compile(model_class, register)
52
- # A child model's declared lutaml_default_register takes
53
- # precedence over the ambient (parent) register — the same
54
- # contract the interpretive path applies through
55
- # Register.resolve_for_child. Without this, plan compilation
56
- # resolves the child's symbol attribute types in the parent
57
- # context and raises UnknownTypeError for ids registered only
58
- # in the child's own register (#876).
59
- register = Lutaml::Model::Register.resolve_for_child(
60
- model_class, register
61
- )
62
-
63
- key = [model_class, register]
64
- return PLAN_CACHE[key] if PLAN_CACHE.key?(key)
65
-
66
- # Cycle guard: a self-referential model (JATS sec-in-sec) must
67
- # resolve to nil — the interpretive pipeline owns it. The guard
68
- # is THREAD-LOCAL (a shared in-progress set races: a concurrent
69
- # same-key compile would cache false permanently — the #828
70
- # lesson); the nil at the cycle point is NOT cached — the
71
- # outermost build completes and caches the real verdict.
72
- stack = (Thread.current[:plan_compiler_stack] ||= [])
73
- return nil if stack.include?(key)
74
-
75
- stack.push(key)
76
- begin
77
- PLAN_CACHE[key] = build(model_class, register)
78
- ensure
79
- stack.pop
80
- end
81
- end
82
-
83
- private
84
-
85
- def build(model_class, register)
86
- return nil unless model_class.is_a?(Class) &&
87
- model_class.include?(::Lutaml::Model::Serialize)
88
-
89
- mapping = model_class.mappings_for(:xml, register)
90
- return nil unless compilable_mapping?(mapping)
91
-
92
- rows = []
93
- attr_rows = [] # [[rule, attr]] hydration metadata
94
- plan_attrs = [] # [{name:, kind:}] rows for the engine plan
95
- compiled = [] # [rule, attr, kind, spelling, delegate_target]
96
- cdata = false
97
- mixed_content = false
98
- needs_nodes = false
99
- collection_defaults = [] # collection attrs with element rows
100
- tag = 100 # type_tag echo space for callback-routed rows
101
- model_ns = plan_namespace(model_class, mapping, register)
102
-
103
- # Collection rows are NATIVE since leptris 1.9.178 —
104
- # collection values echo name and type_tag (the #220 gap is
105
- # closed), so any number routes without callbacks.
106
- true
107
-
108
- mapping.mappings(register).each do |rule|
109
- delegate_target = nil
110
- attr = if rule.delegate
111
- delegate_target = model_class.attributes(register)[rule.delegate]
112
- t = delegate_target&.type(register)
113
- if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
114
- t.attributes(register)[rule.to]
115
- end
116
- else
117
- model_class.attributes(register)[rule.to]
118
- end
119
- return opt_out!(:attr_nil) if attr.nil?
120
- return opt_out!(:derived) if attr.derived?
121
-
122
- # Partition rows (TODO 34 step 2) ride native predicates
123
- # only in the plain-capture shape; any other when_attribute
124
- # shape stays interpretive (the suite pins nested-target
125
- # partitions to the interpretive path).
126
- unless rule.when_attribute.empty?
127
- t = attr.type(register)
128
- plain_partition = !rule.delegate &&
129
- !rule.has_custom_method_for_deserialization? &&
130
- !rule.polymorphic_mapping? &&
131
- !attr.polymorphic? && !attr.union? &&
132
- !(t.is_a?(Class) &&
133
- t.include?(::Lutaml::Model::Serialize))
134
- return opt_out!(:non_plain_partition) unless plain_partition
135
- end
136
-
137
- # Interpretive hydration materializes every mapped
138
- # collection, present or not; the fast path mirrors with
139
- # constructor-time empty arrays.
140
- unless rule.attribute? || !attr.collection?
141
- collection_defaults << attr.name.to_sym
142
- end
143
-
144
- # Fragment deferrals lose ancestor namespace context —
145
- # ns-qualified models keep those rules interpretive.
146
- fragment_needed = rule.has_custom_method_for_deserialization? ||
147
- rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
148
- return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
149
-
150
- if rule.attribute?
151
- return opt_out!(:non_scalar) unless scalar_type?(attr, register)
152
- # Attribute plan rows are local-name keyed; a type-level
153
- # namespace makes the attribute (URI, local)-identified
154
- # (lutaml-model#744) — the interpretive matcher owns it
155
- # until plan rows carry namespace identity.
156
- return opt_out!(:attr_type_ns) if attr.type_namespace_class(register)
157
-
158
- attr_rows << [rule, attr]
159
- plan_attrs << { name: rule.name.to_s }
160
- elsif rule.content_mapping?
161
- return opt_out!(:multi_content) if content_rows(rows) >= 1
162
-
163
- mixed_content = true
164
- compiled << [rule, attr,
165
- attr.collection? ? :content : :content_deferred,
166
- nil, delegate_target]
167
- rows << { name: "__content__#{compiled.size}", kind: :content }
168
- elsif rule.raw_mapping? || rule.raw == :element
169
- compiled << [rule, attr, :raw, nil, delegate_target]
170
- rows << { name: rule.name.to_s, kind: :raw }
171
- elsif rule.has_custom_method_for_deserialization?
172
- needs_nodes = true
173
- compiled << [rule, attr, :custom_method, nil, delegate_target]
174
- rows << { name: rule.name.to_s, kind: :raw }
175
- elsif rule.polymorphic_mapping? || attr.polymorphic? || attr.union?
176
- type = attr.type(register)
177
- return opt_out!(:non_serializable_poly) unless serializable_type?(type) || attr.union?
178
-
179
- needs_nodes = true
180
- compiled << [rule, attr, :polymorphic, nil, delegate_target]
181
- rows << { name: rule.name.to_s, kind: :raw }
182
- else
183
- type = attr.type(register)
184
- if serializable_type?(type)
185
- child = compile(type, register)
186
- return opt_out!(:child_uncompilable) unless child
187
-
188
- if child[:ordered]
189
- # Ordered/mixed children need element_order on their
190
- # instances; the walk hands back plan values, not
191
- # source nodes, so the subtree defers interpretively
192
- # (the fragment parse runs the full machinery,
193
- # order included).
194
- return opt_out!(:ordered_child_with_ns) if model_ns
195
-
196
- needs_nodes = true
197
- compiled << [rule, attr, :ordered_deferred, nil,
198
- delegate_target]
199
- rows << { name: rule.name.to_s, kind: :raw }
200
- else
201
- compiled << [rule, attr, :nested, nil, delegate_target]
202
- needs_nodes ||= child[:needs_nodes]
203
- nested_row = { name: rule.name.to_s, kind: :nested,
204
- plan: child[:tree] }
205
- nested_row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
206
- rows << nested_row
207
- end
208
- elsif rule.multiple_mappings?
209
- rule.name.each do |spelling|
210
- compiled << [rule, attr, :spelling, spelling.to_s,
211
- delegate_target]
212
- rows << { name: spelling.to_s, kind: :callback,
213
- type_tag: (tag += 1) }
214
- end
215
- else
216
- return opt_out!(:non_scalar) unless scalar_type?(attr, register)
217
-
218
- row = { name: rule.name.to_s }
219
- row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
220
- # lutaml-model#88: same-name rows partitioned by the
221
- # rule's discriminator ride the engine's exclusive
222
- # predicate match (leptris 1.9.221+, #1272).
223
- row[:when] = rule.when_attribute unless rule.when_attribute.empty?
224
-
225
- if attr.collection?
226
- compiled << [rule, attr, :collection_native, nil,
227
- delegate_target]
228
- rows << row.merge(kind: :collection)
229
- else
230
- cdata ||= rule.cdata
231
- compiled << [rule, attr, :scalar, nil, delegate_target]
232
- rows << row.merge(kind: :scalar)
233
- end
234
- end
235
- end
236
- end
237
-
238
- row_tags = partition_row_tags!(compiled, rows, tag)
239
-
240
- # The plan serializer does not emit namespace declarations —
241
- # namespaced models serialize through the interpretive
242
- # writer (lutaml-model#847: standalone to_xml under leptris
243
- # dropped the element xmlns entirely).
244
- namespaced = !model_ns.nil? || rows.any? { |r| r[:ns] }
245
-
246
- flags = []
247
- flags << :cdata if cdata
248
- flags << :mixed_content if mixed_content
249
- flags << :ns_lenient if model_ns
250
- tree = { name: mapping.root_element.to_s,
251
- attributes: plan_attrs, children: rows }
252
- tree[:ns] = model_ns if model_ns
253
- tree[:flags] = flags unless flags.empty?
254
- begin
255
- # Lazy: the Opal boot loads this file, and leptris is a
256
- # native gem there — the require only belongs on the
257
- # engines-enabled path.
258
- require "leptris/xml/descriptor"
259
- descriptor = ::Leptris::XML::Descriptor.build(**tree)
260
- rescue StandardError
261
- return nil
262
- end
263
-
264
- { descriptor: descriptor, tree: tree, rows: compiled,
265
- attr_rows: attr_rows, mapping: mapping,
266
- row_tags: row_tags, namespaced: namespaced,
267
- ordered: mapping.ordered? || mapping.mixed_content?,
268
- needs_nodes: needs_nodes,
269
- collection_defaults: collection_defaults }
270
- end
271
-
272
- def compilable_mapping?(mapping)
273
- mapping.root_element &&
274
- !(mapping.respond_to?(:root_mappings) && mapping.root_mappings)
275
- end
276
-
277
- # Partition bookkeeping for when_attribute rows (#88, TODO
278
- # 34 step 2): the engine matches same-name rows exclusively —
279
- # first matching row wins — so predicate rows must PRECEDE any
280
- # plain sibling on the same name (a plain-first order
281
- # double-captures: the plain row takes everything and the
282
- # predicates still claim their matches). Every row of a
283
- # partitioned name gets a distinct type_tag; plan values echo
284
- # it back, giving the hydrator row-exact routing with no
285
- # per-occurrence re-derivation. Returns {compiled_index =>
286
- # tag} (nil when the model has no partitions). compiled and
287
- # rows are parallel and stay in lockstep through the reorder.
288
- def partition_row_tags!(compiled, rows, tag)
289
- by_name = rows.each_with_index
290
- .group_by { |(row, _)| row[:name] }
291
- .select { |_name, pairs| pairs.any? { |(row, _)| row[:when] } }
292
- return nil if by_name.empty?
293
-
294
- permutation = by_name.each_value.flat_map do |pairs|
295
- pairs.sort_by { |(row, i)| [row[:when] ? 0 : 1, i] }
296
- .map(&:last)
297
- end
298
- remaining = (0...rows.length).to_a - permutation
299
- permutation.concat(remaining)
300
-
301
- row_tags = {}
302
- permutation.each_with_index do |old_index, new_index|
303
- row = rows[old_index]
304
- next unless by_name.key?(row[:name])
305
-
306
- tag += 1
307
- row[:type_tag] = tag
308
- row_tags[new_index] = tag
309
- end
310
- rows.replace(permutation.map { |i| rows[i] })
311
- compiled.replace(permutation.map { |i| compiled[i] })
312
- row_tags
313
- end
314
-
315
- # Attribute for a rule — delegate rules resolve against their
316
- # target model's attributes.
317
- def attr_of(model_class, rule, register)
318
- attr = model_class.attributes(register)[rule.to]
319
- return attr if attr || !rule.delegate
320
-
321
- target = model_class.attributes(register)[rule.delegate]
322
- t = target&.type(register)
323
- if t.is_a?(Class) && t.include?(::Lutaml::Model::Serialize)
324
- t.attributes(register)[rule.to]
325
- end
326
- end
327
-
328
- # Rule-level namespace → ChildPlan ns form (leptris 1.9.178):
329
- # the child binds by local name under the rule's URI with any
330
- # prefix. Blank-namespace rules (xmlns="") match :none.
331
- def child_ns(rule, _model_ns)
332
- uri = rule.namespace
333
- return { exact: uri.to_s } if uri && !uri.to_s.empty?
334
-
335
- :none
336
- end
337
-
338
- def content_rows(rows)
339
- rows.count { |r| r[:kind] == :content }
340
- end
341
-
342
- # Model-level namespace: exact URI match with lenient prefixes
343
- # — children bind by local name under any prefix the document
344
- # bound to the URI (#754 adoption semantics on the engine).
345
- def plan_namespace(_model_class, mapping, _register)
346
- ns_class = mapping.namespace_class if mapping.respond_to?(:namespace_class)
347
- ns_class&.uri ? { exact: ns_class.uri.to_s } : nil
348
- end
349
-
350
- def scalar_type?(attr, register)
351
- type = attr.type(register)
352
- type.is_a?(Class) && type < ::Lutaml::Model::Type::Value &&
353
- !attr.custom_collection?
354
- end
355
-
356
- def serializable_type?(type)
357
- type.is_a?(Class) && type.include?(::Lutaml::Model::Serialize)
358
- end
359
- end
360
- end
361
- end
362
- end