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