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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7b4bd8b10dc92c3a53f615bb3e13c907d1cc4976eb547a5a2da41aa2837bef17
4
- data.tar.gz: e1e5eacb6d80dc8108d65a5264d5e9d872d4259103672f6a88e0d912b437002f
3
+ metadata.gz: 2dcefe56e003b241812971d37fc9d76d6b6945cb5a138a9fa15f9ce2aa4fbd7d
4
+ data.tar.gz: 835dfecf768169c9fbc6ab772c2e11dbe593f3f5e9deb7016b09c8088706117c
5
5
  SHA512:
6
- metadata.gz: efa1a2368c6f2f0352dd928eb33c4c45c9580a431ca3847fe709fcb1026f29deaf2f55e7ec52eafaebdac537ef0c5d55588b22d37d6f6bb27d332165ad9a795a
7
- data.tar.gz: 5380981def9666c4ec1fa0f4cb7f977dcba141a5278003a96486d814e7c812d2083339baa3d9d8079c3029d7aef0e84226be40eb188315bd5270c974baed1dda
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
- ensure_model_imports!(register)
176
- ensure_choice_imports!(register)
177
- ensure_restrict_attributes!(register)
178
- # Hook for format-specific mapping import resolution.
179
- # XML overrides this to call mappings[:xml]&.ensure_mappings_imported!(register)
180
- ensure_format_mapping_imports!(register)
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
- def deferred_imports_pending?
166
- importable_models.any? { |_method, models| models.any? } ||
167
- importable_choices.any? do |_choice, imports|
168
- imports.any? { |_method, models| models.any? }
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.96"
5
+ VERSION = "0.8.97"
6
6
  end
7
7
  end
@@ -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
- row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
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, attr_rows,
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
- register(tree[:ns])
80
- (tree[:attributes] || []).each { |attr| register(attr[:ns]) }
117
+ check(tree[:ns])
118
+ (tree[:attributes] || []).each { |a| check(a[:ns]) }
81
119
  (tree[:children] || []).each do |row|
82
- register(row[:ns]) if row[:ns].is_a?(Hash)
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
- def register(ns_form)
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
- form = { prefix: ns_form[:prefix], uri: ns_form[:exact] }
91
- @forms[ns_form[:exact]] ||= form
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 wire spelling for a local name under this form.
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
- form = ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
98
- prefix = form && form[:prefix]
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
- @forms.each_value do |form|
110
- key = form[:uri]
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(form[:prefix], form[:uri])
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
- attr_prefixes[a[:name]] = form && form[:prefix]
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
@@ -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. A name-less
388
- # MappingRule is otherwise indistinguishable from a
389
- # content rule (the dispatch consumed it as doc.text);
390
- # name-derived lookup also cannot express this — it
391
- # re-finds by name, losing order and double-picking.
392
- # The pass walks the document children once; the
393
- # normalize/cast pipeline below handles the rest.
394
- claimed = Set.new(
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.96
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-07 00:00:00.000000000 Z
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