lutaml-model 0.8.96 → 0.8.97
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 +4 -4
- data/lib/lutaml/model/serialize/format_conversion.rb +7 -1
- data/lib/lutaml/model/serialize/initialization.rb +25 -7
- data/lib/lutaml/model/serialize/model_import.rb +34 -4
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/leptris/plan_compiler.rb +101 -4
- data/lib/lutaml/xml/leptris/plan_serializer.rb +78 -18
- data/lib/lutaml/xml/leptris/plan_walk.rb +1 -0
- data/lib/lutaml/xml/leptris.rb +21 -0
- data/lib/lutaml/xml/model_transform.rb +38 -14
- data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2dcefe56e003b241812971d37fc9d76d6b6945cb5a138a9fa15f9ce2aa4fbd7d
|
|
4
|
+
data.tar.gz: 835dfecf768169c9fbc6ab772c2e11dbe593f3f5e9deb7016b09c8088706117c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7efe4b0bc24f20b9d48e37c33b5e23cf23af7397900a7d788912275a4b396318d916c721b21bc6373de53e99f3f4f0dc29e459c94bba2e2baaf8c4da67232c14
|
|
7
|
+
data.tar.gz: 59d5412393469b1904c4c6e29bc0ff58b0c6333b5e3710e531d9e7a8ae465e7762993288f04e6147a12a204a8fb581059a24491d9dc90d7f309e78b1bb39b97a
|
|
@@ -95,7 +95,12 @@ module Lutaml
|
|
|
95
95
|
options.key?(:mappings) || options.key?(:adapter) ||
|
|
96
96
|
options.key?(:_adapter_override) ||
|
|
97
97
|
options.key?(:indent) || options.key?(:xml_declaration) ||
|
|
98
|
-
options.key?(:declaration) || options.key?(:doctype)
|
|
98
|
+
options.key?(:declaration) || options.key?(:doctype) ||
|
|
99
|
+
# prefix requests ride the interpretive writer's use_prefix
|
|
100
|
+
# option (planner Tier-1) — the plan emission has no
|
|
101
|
+
# option-aware spelling. The instance-level option prep
|
|
102
|
+
# rewrites :prefix to :use_prefix before this gate.
|
|
103
|
+
options.key?(:prefix) || options.key?(:use_prefix)
|
|
99
104
|
|
|
100
105
|
adapter_name = Lutaml::Model::Config.adapter_for(:xml)
|
|
101
106
|
adapter_name &&= adapter_name.name
|
|
@@ -133,6 +138,7 @@ module Lutaml
|
|
|
133
138
|
register = Lutaml::Model::Config.default_register
|
|
134
139
|
plan = Lutaml::Xml::Leptris::PlanCompiler.compile(self, register)
|
|
135
140
|
return nil unless plan
|
|
141
|
+
return nil unless Lutaml::Xml::Leptris::PlanCompiler.engine_walk_safe?(plan)
|
|
136
142
|
|
|
137
143
|
root = ::Leptris::XML.parse(data.to_s).root
|
|
138
144
|
return nil if root.nil?
|
|
@@ -172,12 +172,30 @@ module Lutaml
|
|
|
172
172
|
#
|
|
173
173
|
# @param register [Symbol, nil] The register context
|
|
174
174
|
def ensure_imports!(register = nil)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
175
|
+
# Bounded re-entry for the resolution chain. Each leg's
|
|
176
|
+
# record_import_attempt tail calls attributes(), which calls
|
|
177
|
+
# back into ensure_imports!; nested passes converge imports
|
|
178
|
+
# whose targets only become resolvable after an earlier leg
|
|
179
|
+
# (so they must not be dropped), but with a permanently
|
|
180
|
+
# pending import the tail re-entered unboundedly and crashed
|
|
181
|
+
# with SystemStackError (#938). Two levels allow one nested
|
|
182
|
+
# convergence pass; deeper re-entry is suppressed per
|
|
183
|
+
# register and the counter unwinds on error.
|
|
184
|
+
register_id = extract_register_id(register)
|
|
185
|
+
(@ensure_imports_depth ||= {})[register_id] ||= 0
|
|
186
|
+
return if @ensure_imports_depth[register_id] >= 2
|
|
187
|
+
|
|
188
|
+
@ensure_imports_depth[register_id] += 1
|
|
189
|
+
begin
|
|
190
|
+
ensure_model_imports!(register)
|
|
191
|
+
ensure_choice_imports!(register)
|
|
192
|
+
ensure_restrict_attributes!(register)
|
|
193
|
+
# Hook for format-specific mapping import resolution.
|
|
194
|
+
# XML overrides this to call mappings[:xml]&.ensure_mappings_imported!(register)
|
|
195
|
+
ensure_format_mapping_imports!(register)
|
|
196
|
+
ensure
|
|
197
|
+
@ensure_imports_depth[register_id] -= 1
|
|
198
|
+
end
|
|
181
199
|
end
|
|
182
200
|
|
|
183
201
|
# Hook for format-specific mapping import resolution.
|
|
@@ -357,7 +375,7 @@ module Lutaml
|
|
|
357
375
|
# chain's frames per level pushed that recursion past the
|
|
358
376
|
# stack limit — models without pending deferred imports skip
|
|
359
377
|
# it entirely.
|
|
360
|
-
ensure_imports!(register_id) if finalized? && deferred_imports_pending?
|
|
378
|
+
ensure_imports!(register_id) if finalized? && deferred_imports_pending?(register_id)
|
|
361
379
|
instance = allocate
|
|
362
380
|
instance.finalize_deserialization(register_id)
|
|
363
381
|
instance
|
|
@@ -162,11 +162,39 @@ module Lutaml
|
|
|
162
162
|
# @param register_id [Symbol, nil] The register context
|
|
163
163
|
# Whether deferred (symbol-form) imports are still pending —
|
|
164
164
|
# gates the allocation-time ensure chain (#933).
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
# The attribute-set fingerprint at the moment an import
|
|
166
|
+
# resolution attempt ran — deferred_imports_pending? compares
|
|
167
|
+
# against it to skip re-resolution on stable sets.
|
|
168
|
+
def record_import_attempt(register_id)
|
|
169
|
+
# Raw stores only — the lazy attributes() re-enters
|
|
170
|
+
# ensure_imports! and, when @models_imported was just set to
|
|
171
|
+
# false (unresolved imports), cycles through this very
|
|
172
|
+
# method forever (the uniword register_in SystemStackError).
|
|
173
|
+
register_attrs = (@register_records ||= {})[register_id]
|
|
174
|
+
(@import_attempt_fingerprints ||= {})[register_id] =
|
|
175
|
+
@attributes.size + @choice_attributes.to_a.size +
|
|
176
|
+
(register_attrs ? register_attrs[:attributes].to_a.size : 0)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def deferred_imports_pending?(register_id = nil)
|
|
180
|
+
register_id ||= Lutaml::Model::Config.default_register
|
|
181
|
+
has_importables = importable_models.any? { |_m, models| models.any? } ||
|
|
182
|
+
importable_choices.any? do |_c, imports|
|
|
183
|
+
imports.any? { |_m, models| models.any? }
|
|
169
184
|
end || restrict_attributes.any?
|
|
185
|
+
return false unless has_importables
|
|
186
|
+
|
|
187
|
+
# Unresolvable imports stay pending forever; re-running the
|
|
188
|
+
# ensure chain (and its state-defaults invalidation) on every
|
|
189
|
+
# allocation thrashed import-heavy models quadratically.
|
|
190
|
+
# Resolution re-arms only when the attribute set CHANGED
|
|
191
|
+
# since the last attempt — a late declaration after a failed
|
|
192
|
+
# resolution re-attempts; a stable set skips.
|
|
193
|
+
attempted = (@import_attempt_fingerprints ||= {})[register_id]
|
|
194
|
+
return true if attempted.nil?
|
|
195
|
+
|
|
196
|
+
current = attributes(register_id).size + @choice_attributes.to_a.size
|
|
197
|
+
current != attempted
|
|
170
198
|
end
|
|
171
199
|
|
|
172
200
|
def ensure_model_imports!(register_id = nil)
|
|
@@ -200,6 +228,7 @@ module Lutaml
|
|
|
200
228
|
end
|
|
201
229
|
|
|
202
230
|
@models_imported[register_id] = all_resolved
|
|
231
|
+
record_import_attempt(register_id)
|
|
203
232
|
end
|
|
204
233
|
|
|
205
234
|
# Ensure all choice imports are resolved for a specific register
|
|
@@ -239,6 +268,7 @@ module Lutaml
|
|
|
239
268
|
end
|
|
240
269
|
|
|
241
270
|
@choices_imported[register_id] = true if all_resolved
|
|
271
|
+
record_import_attempt(register_id)
|
|
242
272
|
# Choice imports can add attributes without passing through
|
|
243
273
|
# define_attribute_methods — recompile state defaults so the
|
|
244
274
|
# seeding and raw readers cover them (#933). Only when an
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -86,8 +86,49 @@ module Lutaml
|
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# Two engine walk defects gate walk-side consumers here; both
|
|
90
|
+
# bail models to the interpretive matcher, whose exact-first-
|
|
91
|
+
# then-any-qualification precedence binds what the walk drops.
|
|
92
|
+
# The serialize side (PlanSerializer) reads the Ruby tree and
|
|
93
|
+
# stays on the fast path. leptris 1.9.320.0 (#1587) fixes
|
|
94
|
+
# both defects — the scan is skipped there and every model
|
|
95
|
+
# rides the walk again.
|
|
96
|
+
#
|
|
97
|
+
# leptris#1585: exact-URI CHILD-row ns_uri strings are not
|
|
98
|
+
# retained engine-side — once the build anchors are GC'd (a
|
|
99
|
+
# warm walk plus one GC suffices), those rows stop matching
|
|
100
|
+
# and hydration silently answers nil.
|
|
101
|
+
#
|
|
102
|
+
# leptris#1586: plain attribute rows do not leniently match
|
|
103
|
+
# namespace-qualified wire attributes (uniword fontTable:
|
|
104
|
+
# every w:name/w:val against a plain attr row hydrates nil).
|
|
105
|
+
# A plan level carrying plain attr rows under a namespaced
|
|
106
|
+
# chain therefore cannot be trusted on the walk; attribute
|
|
107
|
+
# rows with an exact URI (or :any) and plans without any
|
|
108
|
+
# namespace form are unaffected.
|
|
109
|
+
def engine_walk_safe?(plan)
|
|
110
|
+
return true if Leptris.walk_ns_safety_compatible?
|
|
111
|
+
|
|
112
|
+
tree_safe?(plan[:tree], false)
|
|
113
|
+
end
|
|
114
|
+
|
|
89
115
|
private
|
|
90
116
|
|
|
117
|
+
def tree_safe?(tree, ancestor_ns)
|
|
118
|
+
namespaced = ancestor_ns || tree[:ns].is_a?(Hash)
|
|
119
|
+
attrs = tree[:attributes] || []
|
|
120
|
+
return false if namespaced &&
|
|
121
|
+
attrs.any? { |a| !a[:ns].is_a?(Hash) && a[:ns] != :any }
|
|
122
|
+
|
|
123
|
+
(tree[:children] || []).all? do |row|
|
|
124
|
+
return false if row[:ns].is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
next true unless row[:kind] == :nested && row[:plan]
|
|
127
|
+
|
|
128
|
+
tree_safe?(row[:plan], namespaced)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
91
132
|
def build(model_class, register)
|
|
92
133
|
return nil unless model_class.is_a?(Class) &&
|
|
93
134
|
model_class.include?(::Lutaml::Model::Serialize)
|
|
@@ -162,7 +203,7 @@ module Lutaml
|
|
|
162
203
|
return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
|
|
163
204
|
|
|
164
205
|
if rule.attribute?
|
|
165
|
-
opt = compile_attribute_row(rule, attr, register,
|
|
206
|
+
opt = compile_attribute_row(rule, attr, register, mapping,
|
|
166
207
|
attr_rows, plan_attrs,
|
|
167
208
|
ns_attr_names, attr_local_claims)
|
|
168
209
|
return opt_out!(opt) if opt
|
|
@@ -225,7 +266,22 @@ module Lutaml
|
|
|
225
266
|
return opt_out!(:non_scalar) unless scalar_type?(attr, register)
|
|
226
267
|
|
|
227
268
|
row = { name: rule.name.to_s }
|
|
228
|
-
|
|
269
|
+
if rule.namespace_set?
|
|
270
|
+
row[:ns] = child_ns(rule, model_ns)
|
|
271
|
+
else
|
|
272
|
+
# The interpretive writer qualifies an element row
|
|
273
|
+
# by its attribute's type namespace when the rule
|
|
274
|
+
# declares none (resolve step 4). Rows without the
|
|
275
|
+
# type form inherit the tree namespace and land
|
|
276
|
+
# under the wrong default xmlns (uniword
|
|
277
|
+
# CoreProperties: <dc:title> spelled <title>).
|
|
278
|
+
type_ns_form = element_type_ns_form(rule, attr,
|
|
279
|
+
register, model_ns)
|
|
280
|
+
return opt_out!(:elem_type_ns_symbol) if type_ns_form == :bail_symbol
|
|
281
|
+
return opt_out!(:elem_type_ns_aliases) if type_ns_form == :bail_aliases
|
|
282
|
+
|
|
283
|
+
row[:ns] = type_ns_form if type_ns_form
|
|
284
|
+
end
|
|
229
285
|
# lutaml-model#88: same-name rows partitioned by the
|
|
230
286
|
# rule's discriminator ride the engine's exclusive
|
|
231
287
|
# predicate match (leptris 1.9.221+, #1272).
|
|
@@ -337,8 +393,8 @@ module Lutaml
|
|
|
337
393
|
# non-sole-claimant alias families have no plan-side
|
|
338
394
|
# resolution order); :blank / :inherit arrive as Symbols —
|
|
339
395
|
# no URI to express in a row.
|
|
340
|
-
def compile_attribute_row(rule, attr, register,
|
|
341
|
-
plan_attrs, ns_attr_names,
|
|
396
|
+
def compile_attribute_row(rule, attr, register, mapping,
|
|
397
|
+
attr_rows, plan_attrs, ns_attr_names,
|
|
342
398
|
attr_local_claims)
|
|
343
399
|
return :non_scalar unless scalar_type?(attr, register)
|
|
344
400
|
|
|
@@ -346,6 +402,24 @@ module Lutaml
|
|
|
346
402
|
type_ns = attr.type_namespace_class(register)
|
|
347
403
|
claim = attr_local_claims[rule.name.to_s]
|
|
348
404
|
if type_ns.nil?
|
|
405
|
+
# attribute_form_default :qualified on the model's
|
|
406
|
+
# namespace qualifies locally-declared attributes under
|
|
407
|
+
# the parent URI — the interpretive
|
|
408
|
+
# resolve_attribute_namespace contract (WML: updateFields
|
|
409
|
+
# w:val under xmlns="..."). The W3C default is
|
|
410
|
+
# :unqualified, so == :qualified means explicitly set.
|
|
411
|
+
# Rules with an explicit :blank stay unprefixed.
|
|
412
|
+
ns_class = mapping.namespace_class
|
|
413
|
+
if rule.namespace_param != :blank && ns_class &&
|
|
414
|
+
ns_class.attribute_form_default == :qualified
|
|
415
|
+
claim[:ns] += 1
|
|
416
|
+
plan_attrs << { name: rule.name.to_s,
|
|
417
|
+
ns: { exact: ns_class.uri.to_s,
|
|
418
|
+
prefix: ns_class.prefix_default&.to_s } }
|
|
419
|
+
ns_attr_names << rule.name.to_s
|
|
420
|
+
return nil
|
|
421
|
+
end
|
|
422
|
+
|
|
349
423
|
claim[:plain] += 1
|
|
350
424
|
plan_attrs << { name: rule.name.to_s }
|
|
351
425
|
return nil
|
|
@@ -435,6 +509,29 @@ module Lutaml
|
|
|
435
509
|
{ exact: uri.to_s, prefix: rule.prefix&.to_s }
|
|
436
510
|
end
|
|
437
511
|
|
|
512
|
+
# Type-namespace ns form for a scalar element row, mirroring
|
|
513
|
+
# the interpretive resolve_element_namespace step 4: the
|
|
514
|
+
# attribute's type namespace qualifies the row when the rule
|
|
515
|
+
# declares none. Rows inheriting the tree namespace (type ns
|
|
516
|
+
# nil, :inherit, or equal to the model's own URI) get no form.
|
|
517
|
+
# :blank and alias families have no row-expressible form —
|
|
518
|
+
# the model stays interpretive.
|
|
519
|
+
def element_type_ns_form(rule, attr, register, model_ns)
|
|
520
|
+
return nil if rule.namespace_set?
|
|
521
|
+
|
|
522
|
+
type_ns = attr.type_namespace_class(register)
|
|
523
|
+
return nil if type_ns.nil? || type_ns == :inherit
|
|
524
|
+
return :bail_symbol if type_ns.is_a?(Symbol)
|
|
525
|
+
return :bail_aliases unless Leptris.attr_ns_rows_compatible? &&
|
|
526
|
+
type_ns.all_uris.size == 1
|
|
527
|
+
|
|
528
|
+
form = { exact: type_ns.uri.to_s,
|
|
529
|
+
prefix: type_ns.prefix_default&.to_s }
|
|
530
|
+
return nil if form[:exact] == model_ns&.[](:exact)
|
|
531
|
+
|
|
532
|
+
form
|
|
533
|
+
end
|
|
534
|
+
|
|
438
535
|
def content_rows(rows)
|
|
439
536
|
rows.count { |r| r[:kind] == :content }
|
|
440
537
|
end
|
|
@@ -38,7 +38,7 @@ module Lutaml
|
|
|
38
38
|
return nil if plan[:ordered] && instance.element_order.nil?
|
|
39
39
|
|
|
40
40
|
doc = ::Leptris::XML::Document.create
|
|
41
|
-
ns_ctx = Namespaces.new(plan)
|
|
41
|
+
ns_ctx = Namespaces.new(instance, plan, register)
|
|
42
42
|
return nil if ns_ctx.unspellable?
|
|
43
43
|
|
|
44
44
|
root = doc.create_element(
|
|
@@ -61,13 +61,51 @@ module Lutaml
|
|
|
61
61
|
# first-encounter order — the #1558 contract on the DOM
|
|
62
62
|
# path. A nested plan's own tree ns overrides the inherited
|
|
63
63
|
# one; rows without an ns form inherit.
|
|
64
|
+
# Namespace spelling sourced from the interpretive writer's
|
|
65
|
+
# own machinery: NamespaceCollector + DeclarationPlanner
|
|
66
|
+
# decide prefix-vs-default per namespace exactly as the
|
|
67
|
+
# interpretive path does — no heuristic divergence (the
|
|
68
|
+
# uniword FontScheme/Image regressions). Option-bearing
|
|
69
|
+
# calls (prefix: true) never reach here; the serializer's
|
|
70
|
+
# plain spelling is the interpretive writer's plain
|
|
71
|
+
# spelling.
|
|
64
72
|
class Namespaces
|
|
65
73
|
attr_reader :unspellable
|
|
66
74
|
|
|
67
|
-
def initialize(plan)
|
|
75
|
+
def initialize(instance, plan, register)
|
|
68
76
|
@forms = {}
|
|
77
|
+
@hoisted = {}
|
|
69
78
|
@declared = {}
|
|
70
79
|
@unspellable = false
|
|
80
|
+
mapping = plan[:mapping]
|
|
81
|
+
# The interpretive writer plans from the BUILT element
|
|
82
|
+
# tree: absent attributes (nil values) and absent
|
|
83
|
+
# children contribute no namespace needs, so an element
|
|
84
|
+
# whose own namespace would otherwise be prefix-hoisted
|
|
85
|
+
# (W3C: prefixed attributes need a prefix declaration)
|
|
86
|
+
# falls back to default-ns format. Collecting from the
|
|
87
|
+
# instance reproduces that — collecting from nil (type
|
|
88
|
+
# analysis) hoists every declared need and diverges
|
|
89
|
+
# (`<w:b xmlns:w>` vs the interpretive `<b xmlns>` when
|
|
90
|
+
# val=true omits the only prefixed attribute).
|
|
91
|
+
ns_prefix = if instance.is_a?(::Lutaml::Model::Serialize)
|
|
92
|
+
instance.xml_namespace_prefix
|
|
93
|
+
end
|
|
94
|
+
collect_options = { mapper_class: instance.class }
|
|
95
|
+
if ns_prefix && !ns_prefix.empty?
|
|
96
|
+
collect_options[:__xml_namespace_prefix] = ns_prefix
|
|
97
|
+
end
|
|
98
|
+
needs = Lutaml::Xml::NamespaceCollector.new(register).collect(
|
|
99
|
+
instance, mapping, **collect_options
|
|
100
|
+
)
|
|
101
|
+
planner = Lutaml::Xml::DeclarationPlanner.new(register)
|
|
102
|
+
declared_plan = planner.plan(
|
|
103
|
+
instance.class, mapping, needs, options: {}
|
|
104
|
+
)
|
|
105
|
+
declared_plan.root_node&.hoisted_declarations&.each do |prefix, uri|
|
|
106
|
+
@hoisted[uri] = prefix
|
|
107
|
+
@forms[uri] = { prefix: prefix, uri: uri }
|
|
108
|
+
end
|
|
71
109
|
collect(plan[:tree])
|
|
72
110
|
end
|
|
73
111
|
|
|
@@ -76,42 +114,55 @@ module Lutaml
|
|
|
76
114
|
end
|
|
77
115
|
|
|
78
116
|
def collect(tree)
|
|
79
|
-
|
|
80
|
-
(tree[:attributes] || []).each { |
|
|
117
|
+
check(tree[:ns])
|
|
118
|
+
(tree[:attributes] || []).each { |a| check(a[:ns]) }
|
|
81
119
|
(tree[:children] || []).each do |row|
|
|
82
|
-
|
|
120
|
+
check(row[:ns]) if row[:ns].is_a?(Hash)
|
|
83
121
|
collect(row[:plan]) if row[:kind] == :nested && row[:plan]
|
|
84
122
|
end
|
|
85
123
|
end
|
|
86
124
|
|
|
87
|
-
|
|
125
|
+
# An exact form the planner did not declare cannot be
|
|
126
|
+
# spelled — the serializer falls back interpretively.
|
|
127
|
+
def check(ns_form)
|
|
88
128
|
return if ns_form.nil? || ns_form == :none || ns_form == :any
|
|
89
129
|
|
|
90
|
-
|
|
91
|
-
@
|
|
92
|
-
@unspellable = true if ns_form[:exact] && !ns_form.key?(:prefix)
|
|
130
|
+
uri = ns_form[:exact]
|
|
131
|
+
@unspellable = true if uri && !@forms.key?(uri)
|
|
93
132
|
end
|
|
94
133
|
|
|
95
|
-
# The
|
|
134
|
+
# The planner's hoisted prefix for a URI (nil when hoisted
|
|
135
|
+
# as the default namespace).
|
|
136
|
+
def prefix_for(uri)
|
|
137
|
+
@hoisted[uri]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The wire spelling for a local name under this form: the
|
|
141
|
+
# planner's hoisted prefix for the URI, or unprefixed when
|
|
142
|
+
# hoisted as the default namespace.
|
|
96
143
|
def spell(local, ns_form = nil)
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
uri = if ns_form.is_a?(Hash)
|
|
145
|
+
ns_form[:exact]
|
|
146
|
+
else
|
|
147
|
+
@forms.key?(ns_form) ? @forms[ns_form][:uri] : nil
|
|
148
|
+
end
|
|
149
|
+
prefix = uri ? @hoisted[uri] : nil
|
|
99
150
|
prefix ? "#{prefix}:#{local}" : local
|
|
100
151
|
end
|
|
101
152
|
|
|
102
153
|
def form_for(ns_form)
|
|
103
154
|
return nil if ns_form.nil? || ns_form == :none
|
|
104
155
|
|
|
105
|
-
ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
|
|
156
|
+
ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
|
|
106
157
|
end
|
|
107
158
|
|
|
108
|
-
def declare_on(root)
|
|
109
|
-
@
|
|
110
|
-
key =
|
|
159
|
+
def declare_on(root, _attr_form_prefix = nil)
|
|
160
|
+
@hoisted.each do |uri, prefix|
|
|
161
|
+
key = uri
|
|
111
162
|
next if key.nil? || @declared[key]
|
|
112
163
|
|
|
113
164
|
@declared[key] = true
|
|
114
|
-
root.add_namespace_definition(
|
|
165
|
+
root.add_namespace_definition(prefix, uri)
|
|
115
166
|
end
|
|
116
167
|
end
|
|
117
168
|
end
|
|
@@ -304,7 +355,16 @@ module Lutaml
|
|
|
304
355
|
if ns_ctx
|
|
305
356
|
(plan[:tree][:attributes] || []).each do |a|
|
|
306
357
|
form = a[:ns].is_a?(Hash) ? a[:ns] : nil
|
|
307
|
-
|
|
358
|
+
# Namespaced attributes always spell prefixed (W3C:
|
|
359
|
+
# attributes cannot use the default namespace) — the
|
|
360
|
+
# interpretive writer emits the type namespace's own
|
|
361
|
+
# prefix even when the element hoists the same URI as
|
|
362
|
+
# the default namespace (updateFields w:val under
|
|
363
|
+
# xmlns="...").
|
|
364
|
+
attr_prefixes[a[:name]] =
|
|
365
|
+
if form
|
|
366
|
+
form[:prefix] || ns_ctx.prefix_for(form[:exact])
|
|
367
|
+
end
|
|
308
368
|
end
|
|
309
369
|
end
|
|
310
370
|
if recorded && !recorded.empty?
|
|
@@ -16,6 +16,7 @@ module Lutaml
|
|
|
16
16
|
register = Lutaml::Model::Config.default_register
|
|
17
17
|
plan = PlanCompiler.compile(model_class, register)
|
|
18
18
|
return nil unless plan
|
|
19
|
+
return nil unless PlanCompiler.engine_walk_safe?(plan)
|
|
19
20
|
|
|
20
21
|
document = ::Leptris::XML.parse(xml)
|
|
21
22
|
root = document.root
|
data/lib/lutaml/xml/leptris.rb
CHANGED
|
@@ -33,6 +33,15 @@ module Lutaml
|
|
|
33
33
|
# map_any_element catch-all. Ships with the 1.9.313 binding.
|
|
34
34
|
MIN_LEPTRIS_WILDCARD_VERSION = "1.9.313.0"
|
|
35
35
|
|
|
36
|
+
# Walk-side namespace safety (leptris#1587, closing #1585 and
|
|
37
|
+
# #1586): child-row exact-URI ns_uri is retained engine-side
|
|
38
|
+
# (deep-copied at build, freed with the plan) and plain attribute
|
|
39
|
+
# rows leniently match namespace-qualified wire attributes
|
|
40
|
+
# (exact-first-then-any, top level and nested). Both shipped in
|
|
41
|
+
# 1.9.320.0; older engines keep the affected models interpretive
|
|
42
|
+
# through PlanCompiler.engine_walk_safe?.
|
|
43
|
+
MIN_LEPTRIS_WALK_NS_SAFETY_VERSION = "1.9.320.0"
|
|
44
|
+
|
|
36
45
|
def self.plan_path_compatible?
|
|
37
46
|
return false unless defined?(Gem)
|
|
38
47
|
|
|
@@ -61,6 +70,18 @@ module Lutaml
|
|
|
61
70
|
spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WILDCARD_VERSION)
|
|
62
71
|
end
|
|
63
72
|
|
|
73
|
+
# Memoized: consulted per parse on the walk gate hot path, and
|
|
74
|
+
# the loaded leptris spec cannot change within a process.
|
|
75
|
+
@walk_ns_safety = nil
|
|
76
|
+
def self.walk_ns_safety_compatible?
|
|
77
|
+
return @walk_ns_safety unless @walk_ns_safety.nil?
|
|
78
|
+
return (@walk_ns_safety = false) unless defined?(Gem)
|
|
79
|
+
|
|
80
|
+
spec = Gem.loaded_specs["leptris"]
|
|
81
|
+
@walk_ns_safety =
|
|
82
|
+
!!(spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WALK_NS_SAFETY_VERSION))
|
|
83
|
+
end
|
|
84
|
+
|
|
64
85
|
autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
|
|
65
86
|
autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
|
|
66
87
|
autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
|
|
@@ -384,21 +384,15 @@ module Lutaml
|
|
|
384
384
|
value = if !rule.attribute? && rule.name.nil? &&
|
|
385
385
|
xml_mapping.any_element_rule.equal?(rule)
|
|
386
386
|
# map_any_element: every child element no explicit
|
|
387
|
-
# element rule claims, in document order
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
# re-finds by name, losing
|
|
392
|
-
#
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
xml_mapping.mappings(effective_register)
|
|
396
|
-
.select { |r| !r.attribute? && r.name }
|
|
397
|
-
.flat_map { |r| [r.name.to_s] },
|
|
387
|
+
# element rule claims, in document order (claimed
|
|
388
|
+
# set + scalar text extraction live in
|
|
389
|
+
# any_element_members). A name-less MappingRule is
|
|
390
|
+
# otherwise indistinguishable from a content rule;
|
|
391
|
+
# name-derived lookup re-finds by name, losing
|
|
392
|
+
# order and double-picking.
|
|
393
|
+
any_element_members(
|
|
394
|
+
doc, xml_mapping, attr, effective_register
|
|
398
395
|
)
|
|
399
|
-
doc.element_children.reject do |child|
|
|
400
|
-
claimed.include?(child.unprefixed_name)
|
|
401
|
-
end
|
|
402
396
|
elsif rule.raw_mapping?
|
|
403
397
|
scoped_raw_inner_xml(doc, xml_mapping)
|
|
404
398
|
elsif rule.content_mapping?
|
|
@@ -1590,6 +1584,36 @@ effective_register = lutaml_register)
|
|
|
1590
1584
|
end
|
|
1591
1585
|
end
|
|
1592
1586
|
|
|
1587
|
+
# map_any_element members: every child element no explicit
|
|
1588
|
+
# element rule claims, in document order; scalar targets take
|
|
1589
|
+
# the captured elements' text — the same normalize step scalar
|
|
1590
|
+
# elements get (the cast pipeline receives strings, not wrappers
|
|
1591
|
+
# whose to_s is their inspect); Serializable targets keep the
|
|
1592
|
+
# wrappers and cast into instances.
|
|
1593
|
+
def any_element_members(doc, mapping, attr, register)
|
|
1594
|
+
claimed = Set.new(
|
|
1595
|
+
mapping.mappings(register)
|
|
1596
|
+
.select { |r| !r.attribute? && r.name }
|
|
1597
|
+
.flat_map { |r| [r.name.to_s] },
|
|
1598
|
+
)
|
|
1599
|
+
unclaimed = doc.element_children.reject do |child|
|
|
1600
|
+
claimed.include?(child.unprefixed_name)
|
|
1601
|
+
end
|
|
1602
|
+
return unclaimed if attr.nil? ||
|
|
1603
|
+
attr_type_is_serializable(attr, register)
|
|
1604
|
+
|
|
1605
|
+
unclaimed.map do |child|
|
|
1606
|
+
child_text = child.nil_element? ? nil : child&.text
|
|
1607
|
+
child_cdata = child&.cdata
|
|
1608
|
+
text = if child_text.is_a?(Array) || child_cdata.is_a?(Array)
|
|
1609
|
+
nil
|
|
1610
|
+
else
|
|
1611
|
+
child_text&.+ child_cdata
|
|
1612
|
+
end
|
|
1613
|
+
text.nil? ? child : text
|
|
1614
|
+
end
|
|
1615
|
+
end
|
|
1616
|
+
|
|
1593
1617
|
def attr_type_is_serializable(attr, effective_register)
|
|
1594
1618
|
attr&.serializable_type?(effective_register) || false
|
|
1595
1619
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Issue #938: a deferred (symbol-form) import that cannot resolve leaves
|
|
6
|
+
# the import pass with all_resolved = false. record_import_attempt then
|
|
7
|
+
# calls attributes(), which re-enters ensure_imports!; the completion
|
|
8
|
+
# marker stored false before that call, so the re-entrant pass ran the
|
|
9
|
+
# import loop again and recursed to SystemStackError. Consumers with
|
|
10
|
+
# large mutually-referencing type graphs (uniword OOXML sets) hit this
|
|
11
|
+
# on load.
|
|
12
|
+
module ModelImportReentry938
|
|
13
|
+
class OrphanSource < Lutaml::Model::Serializable
|
|
14
|
+
attribute :label, :string
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class PendingImporter < Lutaml::Model::Serializable
|
|
18
|
+
attribute :name, :string
|
|
19
|
+
import_model_attributes :NoSuchModelAnywhere938
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ResolvableImporter < Lutaml::Model::Serializable
|
|
23
|
+
attribute :name, :string
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Lutaml::Model::GlobalRegister
|
|
27
|
+
.lookup(Lutaml::Model::Config.default_register)
|
|
28
|
+
.register_model(OrphanSource, id: :ModelImportReentry938OrphanSource)
|
|
29
|
+
ResolvableImporter
|
|
30
|
+
.import_model_attributes(:ModelImportReentry938OrphanSource)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
RSpec.describe "deferred model import re-entry" do
|
|
34
|
+
it "does not recurse when a deferred import cannot resolve" do
|
|
35
|
+
expect { ModelImportReentry938::PendingImporter.attributes(:default) }
|
|
36
|
+
.not_to raise_error
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "resolves a deferred import without recursing on re-access" do
|
|
40
|
+
importer = ModelImportReentry938::ResolvableImporter
|
|
41
|
+
3.times { importer.attributes(:default) }
|
|
42
|
+
|
|
43
|
+
expect(importer.attributes(:default)).to have_key(:name)
|
|
44
|
+
expect(importer.attributes(:default)).to have_key(:label)
|
|
45
|
+
end
|
|
46
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lutaml-model
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.97
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -1773,6 +1773,7 @@ files:
|
|
|
1773
1773
|
- spec/lutaml/model/mapping_rule_perf_guard_spec.rb
|
|
1774
1774
|
- spec/lutaml/model/mapping_type_validation_spec.rb
|
|
1775
1775
|
- spec/lutaml/model/mixed_content_spec.rb
|
|
1776
|
+
- spec/lutaml/model/model_import_reentry_spec.rb
|
|
1776
1777
|
- spec/lutaml/model/model_transformer_spec.rb
|
|
1777
1778
|
- spec/lutaml/model/multiple_mapping_spec.rb
|
|
1778
1779
|
- spec/lutaml/model/namespace_versioning_spec.rb
|