lutaml-model 0.8.94 → 0.8.95

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: '082f3275306fcd89a94f69f68b53d38b0c4c644de743dff261b6bc5166b12ec7'
4
- data.tar.gz: 968f9d94e179035695be49649bd9c0b0c2e7e83192661ba3f6d95bbd5803a813
3
+ metadata.gz: c5933a3971f4f39e30541f504636641cd059e7a97751ba67a710203dbfdcc45a
4
+ data.tar.gz: c23b70227e80d99427b9d4125a09e33d9751112bc72a5e6abaca73d6a3897c60
5
5
  SHA512:
6
- metadata.gz: 4f06573be9b9da0d12a561522a63d2d14b771a3ff7357fdb92c8d06213fa997efe19470f0d6b6ab39d9c672038715cc6efa1d607cb77dce5487e2134b56335a1
7
- data.tar.gz: 275f2d2c74931c84024278dbc5bddcfe18cc267990c0516080605ce1fd2170c5513efcdd141ffbb72b9967cb91fd7ed74b338936a0e1a793aeba2411f8405d09
6
+ metadata.gz: 473b9970246c24172d6040a9c5062fad06d0fea413a5e04956ac98c0990324f2543668436c9259ad2a30b8647555d1e00102f6346afd3018814d68c2841ca8e5
7
+ data.tar.gz: f48b568bdc715a7649a262ca5757474f56ecc52d775121be99f18f067570d48d5401ff29cd7141ff768e9ea0a3e0aef5996d32129b8907c386cfd3c72e9f5370
@@ -116,9 +116,16 @@ jobs:
116
116
  File.write("Gemfile", out.join)
117
117
  ' /tmp/main-checkout
118
118
  bundle install --jobs=4
119
- bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
120
- { echo "::warning::baseline gate failed — re-running once under less contention (#834)"; \
121
- sleep 30; bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb; }
119
+ # lutaml-model#834: min-of-N across attempts — contention
120
+ # inflates single-shot timings, so run three attempts and let
121
+ # the merge keep the least-interfered measurement per fixture.
122
+ for i in 1 2 3; do
123
+ bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
124
+ echo "::warning::baseline attempt $i errored (#834) — continuing with remaining attempts"
125
+ cp "$BENCH_JSON" "$BENCH_JSON.attempt$i" 2>/dev/null || true
126
+ done
127
+ ruby ${{ github.workspace }}/bench/bench_merge_attempts.rb \
128
+ "$BENCH_JSON" "$BENCH_JSON.attempt1" "$BENCH_JSON.attempt2" "$BENCH_JSON.attempt3"
122
129
 
123
130
  - name: Run current benchmark (PR branch)
124
131
  env:
@@ -146,9 +153,13 @@ jobs:
146
153
  File.write("Gemfile", out.join)
147
154
  ' ${{ github.workspace }}
148
155
  bundle install --jobs=4
149
- bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
150
- { echo "::warning::gate failed — re-running once under less contention (#834)"; \
151
- sleep 30; bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb; }
156
+ for i in 1 2 3; do
157
+ bundle exec ruby ${{ github.workspace }}/bench/bench_${{ matrix.downstream }}.rb || \
158
+ echo "::warning::current attempt $i errored (#834) — continuing with remaining attempts"
159
+ cp "$BENCH_JSON" "$BENCH_JSON.attempt$i" 2>/dev/null || true
160
+ done
161
+ ruby ${{ github.workspace }}/bench/bench_merge_attempts.rb \
162
+ "$BENCH_JSON" "$BENCH_JSON.attempt1" "$BENCH_JSON.attempt2" "$BENCH_JSON.attempt3"
152
163
 
153
164
  - name: Compare baseline vs current
154
165
  run: |
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lutaml-model#834: merge N benchmark-attempt JSONs into one result set,
4
+ # taking the per-fixture minimum of every metric. Contention inflates
5
+ # timings; the minimum across attempts is the least-interfered
6
+ # measurement, so gates evaluated on merged output misfire only when
7
+ # EVERY attempt was slow.
8
+ #
9
+ # Usage: ruby bench_merge_attempts.rb <merged.json> <attempt1.json> [attempt2.json ...]
10
+ #
11
+ # Attempt files may be missing (crashed attempt): missing files are
12
+ # skipped; if none exist, nothing is written and the caller's compare
13
+ # step fails on the missing merged file.
14
+ if ARGV.length < 3
15
+ warn "Usage: #{$PROGRAM_NAME} <merged.json> <attempt1.json> [attempt2.json ...]"
16
+ exit 1
17
+ end
18
+
19
+ merged_path = ARGV[0]
20
+ attempt_paths = ARGV[1..]
21
+
22
+ require "json"
23
+
24
+ best = {}
25
+ attempt_paths.each do |path|
26
+ next unless File.exist?(path)
27
+
28
+ results = JSON.parse(File.read(path))
29
+ results.each do |label, metrics|
30
+ cur = best[label]
31
+ if cur
32
+ cur["avg_time"] = [cur["avg_time"], metrics["avg_time"]].min
33
+ cur["min_time"] = [cur["min_time"], metrics["min_time"]].min
34
+ cur["max_time"] = [cur["max_time"], metrics["max_time"]].min
35
+ cur["allocations"] = [cur["allocations"], metrics["allocations"]].min
36
+ cur["ips"] = [cur["ips"], metrics["ips"]].max
37
+ else
38
+ best[label] = metrics.dup
39
+ end
40
+ end
41
+ end
42
+
43
+ if best.nil? || best.empty?
44
+ warn "No attempt JSONs found among: #{attempt_paths.join(', ')}"
45
+ exit 1
46
+ end
47
+
48
+ File.write(merged_path, JSON.pretty_generate(best))
49
+ puts "Merged #{attempt_paths.size} attempt file(s) into #{merged_path} (#{best.size} fixtures)"
@@ -63,18 +63,14 @@ module Lutaml
63
63
  end
64
64
  RUBY
65
65
 
66
- # lutaml-model#922: raw readers for the deserializer's
67
- # unmatched-rule handling. A custom writer that assigns a
68
- # *different* attribute mid-parse (relaton's ICS Isoics
69
- # fallback; any derived-value pattern) is invisible to the
70
- # value_set tracker -- public setters don't mark it -- so the
71
- # tracked "still default" state lies. The deserializer needs
72
- # the raw stored state to know whether an attribute was
73
- # writer-populated: an attribute still holding its
74
- # initialized marker (sentinel / lazy collection) is unset;
75
- # anything else was written since init and must never be
76
- # overwritten by an unmatched rule's default assignment.
77
- raw_lines = attrs.map do |name, _attr|
66
+ # Raw readers compiled alongside the seeding so every
67
+ # attribute the register can see — including late-merged
68
+ # imports that bypass define_attribute_methods — carries
69
+ # one (#933). Idempotent with the declaration-time
70
+ # definition.
71
+ raw_lines = attrs.filter_map do |name, _attr|
72
+ next unless name.to_s.match?(PLAIN_NAME)
73
+
78
74
  <<~RUBY
79
75
  def __lutaml_raw_#{name}
80
76
  @#{name}
@@ -126,6 +122,9 @@ module Lutaml
126
122
  end
127
123
  remove_method(compiled) if defined_now
128
124
  end
125
+ # The names cache must go with the methods: the memoized
126
+ # lookup answers stale names for removed methods otherwise.
127
+ @state_defaults_names.clear
129
128
  end
130
129
 
131
130
  def define_attribute_methods(attr, register = nil)
@@ -147,7 +146,11 @@ module Lutaml
147
146
  # too -- XMI's extension loader declares attributes after
148
147
  # first parse.
149
148
  if name.to_s.match?(PLAIN_NAME)
150
- model.class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
149
+ # class_eval on self: a bare `model` receiver resolves
150
+ # through the host class's own namespace (omml's Base
151
+ # answers a Store model) and defined the reader on the
152
+ # wrong object (#933).
153
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
151
154
  def __lutaml_raw_#{name}
152
155
  @#{name}
153
156
  end
@@ -345,8 +345,20 @@ module Lutaml
345
345
  # @param register [Symbol, nil] The register context
346
346
  # @return [Object] The allocated instance
347
347
  def allocate_for_deserialization(register = nil)
348
- instance = allocate
349
348
  register_id = extract_register_id(register)
349
+ # Deferred imports (symbol-form import_model_attributes) must
350
+ # resolve before state defaults compile: the compiled seeding
351
+ # and the raw readers cover the post-import attribute set, and
352
+ # the transform's records already carry the mapping's rules
353
+ # for imported names — CTR's <m:br> parsed before resolution
354
+ # had no reader (#933). The constructor path ensures the same
355
+ # way (initialize_attributes). Guarded: deep object graphs
356
+ # (XSD schema trees) allocate recursively, and the ensure
357
+ # chain's frames per level pushed that recursion past the
358
+ # stack limit — models without pending deferred imports skip
359
+ # it entirely.
360
+ ensure_imports!(register_id) if finalized? && deferred_imports_pending?
361
+ instance = allocate
350
362
  instance.finalize_deserialization(register_id)
351
363
  instance
352
364
  end
@@ -60,6 +60,10 @@ module Lutaml
60
60
  model.attributes.each_value { |attr| define_attribute_methods(attr) }
61
61
  @attributes.merge!(Utils.deep_dup(model.attributes))
62
62
  @choice_attributes.concat(deep_duplicate_choice_attributes(model))
63
+ # Imported attributes grow the state-defaults surface (the
64
+ # raw readers live there) — a first use may already have
65
+ # compiled it for the pre-import attribute set (#933).
66
+ invalidate_state_defaults!
63
67
  # Ensure @models_imported is a hash; migrate from legacy nil/false state
64
68
  @models_imported ||= {}
65
69
  @models_imported[reg] = true
@@ -122,6 +126,9 @@ module Lutaml
122
126
  deep_duplicate_choice_attributes(model, register_id),
123
127
  )
124
128
  @merged_attributes_cache = nil
129
+ # See import_model_attributes: late-registered attributes
130
+ # must recompile the register's state defaults (#933).
131
+ invalidate_state_defaults!
125
132
  end
126
133
  private :register_only_import_model_attributes
127
134
 
@@ -153,6 +160,15 @@ module Lutaml
153
160
  # Ensure all model imports are resolved for a specific register
154
161
  #
155
162
  # @param register_id [Symbol, nil] The register context
163
+ # Whether deferred (symbol-form) imports are still pending —
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? }
169
+ end || restrict_attributes.any?
170
+ end
171
+
156
172
  def ensure_model_imports!(register_id = nil)
157
173
  register_id ||= Lutaml::Model::Config.default_register
158
174
  @models_imported = {} if @models_imported.nil? || @models_imported == false
@@ -197,6 +213,7 @@ module Lutaml
197
213
  @choices_imported[register_id] = true
198
214
  all_resolved = true
199
215
 
216
+ resolved_any = false
200
217
  importable_choices.each do |choice, choice_imports|
201
218
  choice_imports.each do |method, models|
202
219
  models.uniq.each do |model|
@@ -216,11 +233,19 @@ module Lutaml
216
233
  end
217
234
 
218
235
  choice.public_send(method, model_class, register_id)
236
+ resolved_any = true
219
237
  end
220
238
  end
221
239
  end
222
240
 
223
241
  @choices_imported[register_id] = true if all_resolved
242
+ # Choice imports can add attributes without passing through
243
+ # define_attribute_methods — recompile state defaults so the
244
+ # seeding and raw readers cover them (#933). Only when an
245
+ # import actually resolved: ensure_choice_imports! runs on
246
+ # every ensure_imports! (allocation-time), and a recompile
247
+ # per allocation would churn.
248
+ invalidate_state_defaults! if resolved_any
224
249
  end
225
250
 
226
251
  # Ensure all restrict attributes are applied
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.94"
5
+ VERSION = "0.8.95"
6
6
  end
7
7
  end
@@ -99,6 +99,14 @@ module Lutaml
99
99
  attr_rows = [] # [[rule, attr]] hydration metadata
100
100
  plan_attrs = [] # [{name:, kind:}] rows for the engine plan
101
101
  compiled = [] # [rule, attr, kind, spelling, delegate_target]
102
+ ns_attr_names = [] # exact-URI attribute rows (ambiguity guard)
103
+ # Local-name claim tallies for attribute rows: a name claimed
104
+ # by both an exact-URI row and a plain (any-namespace) row
105
+ # cannot be dispatched by the walk — the ns form :none
106
+ # matches any qualification, so the plain row captures the
107
+ # qualified spelling too (#758). The interpretive matcher
108
+ # owns shared names.
109
+ attr_local_claims = Hash.new { |h, k| h[k] = { ns: 0, plain: 0 } }
102
110
  cdata = false
103
111
  mixed_content = false
104
112
  needs_nodes = false
@@ -154,15 +162,10 @@ module Lutaml
154
162
  return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
155
163
 
156
164
  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 }
165
+ opt = compile_attribute_row(rule, attr, register,
166
+ attr_rows, plan_attrs,
167
+ ns_attr_names, attr_local_claims)
168
+ return opt_out!(opt) if opt
166
169
  elsif rule.content_mapping?
167
170
  return opt_out!(:multi_content) if content_rows(rows) >= 1
168
171
 
@@ -247,12 +250,23 @@ module Lutaml
247
250
  # namespaced models serialize through the interpretive
248
251
  # writer (lutaml-model#847: standalone to_xml under leptris
249
252
  # dropped the element xmlns entirely).
250
- namespaced = !model_ns.nil? || rows.any? { |r| r[:ns] }
253
+ # Exact-URI attribute rows include their namespaced spelling
254
+ # here: the plan serializer writes attributes by local name
255
+ # (no prefixed wire spelling, no declaration emission —
256
+ # leptris#1551), so these models serialize interpretively.
257
+ namespaced = !model_ns.nil? || rows.any? { |r| r[:ns] } ||
258
+ plan_attrs.any? { |a| a[:ns] }
251
259
 
252
260
  flags = []
253
261
  flags << :cdata if cdata
254
262
  flags << :mixed_content if mixed_content
255
- flags << :ns_lenient if model_ns
263
+ # Unconditional: a namespace-less model's children match by
264
+ # local name even when the document declares a default or
265
+ # prefixed namespace (#932) — the interpretive matcher's
266
+ # unprefixed-any-URI behavior; elements (unlike attributes)
267
+ # inherit the default xmlns, so the strict no-URI default
268
+ # dropped every child of a namespaced document.
269
+ flags << :ns_lenient
256
270
  tree = { name: mapping.root_element.to_s,
257
271
  attributes: plan_attrs, children: rows }
258
272
  tree[:ns] = model_ns if model_ns
@@ -267,12 +281,69 @@ module Lutaml
267
281
  return nil
268
282
  end
269
283
 
284
+ return opt_out!(:attr_ns_claim_conflict) if attr_local_claims
285
+ .any? { |_, c| c[:ns].positive? && c[:plain].positive? }
286
+
287
+ # Exact-URI attribute rows rely on the sole-claimant lenient
288
+ # recovery for out-of-namespace spellings (the interpretive
289
+ # exact-first-then-any-qualification precedence), and that
290
+ # recovery reads the source node — models carrying such rows
291
+ # must receive one through the buckets chain.
292
+ needs_nodes = true unless ns_attr_names.empty?
293
+
294
+ # A model combining exact-URI attribute rows with nested
295
+ # child rows stays interpretive: the engine's walk drops
296
+ # nested-children attribute capture under such plans
297
+ # (FontTable's w:name under an mc:Ignorable ns row —
298
+ # observed 1.9.311.3; filed upstream). Leaf models keep the
299
+ # lift — that is the measured win (uniword's deep property
300
+ # models are leaves).
301
+ return opt_out!(:attr_type_ns_nested) if plan_attrs.any? { |a| a[:ns] } &&
302
+ rows.any?
303
+
270
304
  { descriptor: descriptor, tree: tree, rows: compiled,
271
305
  attr_rows: attr_rows, mapping: mapping,
272
306
  row_tags: row_tags, namespaced: namespaced,
273
307
  ordered: mapping.ordered? || mapping.mixed_content?,
274
308
  needs_nodes: needs_nodes,
275
- collection_defaults: collection_defaults }
309
+ collection_defaults: collection_defaults,
310
+ ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names }
311
+ end
312
+
313
+ # Attribute rows: identity is (URI, local) (lutaml-model#744).
314
+ # A type-level namespace compiles into an exact-URI AttrPlan ns
315
+ # row (libleptris 1.9.289): the walk captures the (URI, local)
316
+ # match and the sole-claimant lenient recovery in PlanHydrator
317
+ # keeps the interpretive exact-first-then-any-qualification
318
+ # precedence for out-of-namespace spellings. Returns the
319
+ # opt-out clause when the row must stay interpretive, nil
320
+ # otherwise. Types declaring alias URI families stay
321
+ # interpretive (a plan row holds a single exact URI and
322
+ # non-sole-claimant alias families have no plan-side
323
+ # resolution order); :blank / :inherit arrive as Symbols —
324
+ # no URI to express in a row.
325
+ def compile_attribute_row(rule, attr, register, attr_rows,
326
+ plan_attrs, ns_attr_names,
327
+ attr_local_claims)
328
+ return :non_scalar unless scalar_type?(attr, register)
329
+
330
+ attr_rows << [rule, attr]
331
+ type_ns = attr.type_namespace_class(register)
332
+ claim = attr_local_claims[rule.name.to_s]
333
+ if type_ns.nil?
334
+ claim[:plain] += 1
335
+ plan_attrs << { name: rule.name.to_s }
336
+ return nil
337
+ end
338
+ return :attr_type_ns_symbol if type_ns.is_a?(Symbol)
339
+ return :attr_type_ns_aliases unless Leptris.attr_ns_rows_compatible? &&
340
+ type_ns.all_uris.size == 1
341
+
342
+ claim[:ns] += 1
343
+ plan_attrs << { name: rule.name.to_s,
344
+ ns: { exact: type_ns.uri.to_s } }
345
+ ns_attr_names << rule.name.to_s
346
+ nil
276
347
  end
277
348
 
278
349
  def compilable_mapping?(mapping)
@@ -72,6 +72,7 @@ module Lutaml
72
72
  def attributes_kwargs(plan, value, node = nil)
73
73
  kwargs = {}
74
74
  sole_claimants = sole_claimant_names(plan)
75
+ ns_attr_names = plan[:ns_attr_names]
75
76
  plan_attr_rows(plan).each do |rule, attr, name|
76
77
  v = value.attribute(name)
77
78
  if v.nil? && node && sole_claimants.include?(name)
@@ -83,6 +84,14 @@ module Lutaml
83
84
  end
84
85
  next if v.nil?
85
86
 
87
+ # (URI, local) rows mirror the interpretive matcher's
88
+ # ambiguity refusal: a second spelling of the same local
89
+ # name binds nothing (lutaml-model#744).
90
+ if ns_attr_names&.include?(name) &&
91
+ ambiguous_local_spellings?(node, name)
92
+ next
93
+ end
94
+
86
95
  v = v.split(rule.delimiter) if rule.delimiter
87
96
  if rule.as_list && rule.as_list[:import]
88
97
  v = rule.as_list[:import].call(v)
@@ -92,6 +101,53 @@ module Lutaml
92
101
  kwargs
93
102
  end
94
103
 
104
+ # True when the node carries more than one spelling of the
105
+ # local name (any qualification). Only consulted for exact-URI
106
+ # attribute rows.
107
+ def ambiguous_local_spellings?(node, local_name)
108
+ return false unless node.respond_to?(:attributes)
109
+
110
+ seen = false
111
+ node.attributes.each_value do |attr|
112
+ name = attr.name.to_s
113
+ name = name.split(":").last if name.include?(":")
114
+ next unless name == local_name
115
+
116
+ return true if seen
117
+
118
+ seen = true
119
+ end
120
+ false
121
+ end
122
+
123
+ # A captured-but-empty child marshals as a nil string value.
124
+ # The interpretive pipeline classifies the raw "" through the
125
+ # rule's value map (:empty key) — the :empty directive (the
126
+ # plain-rule default) keeps it as "", :nil binds nil, and any
127
+ # other directive means uninitialized, i.e. no binding.
128
+ def mapped_empty_value(rule)
129
+ case rule.value_map(:from)[:empty]
130
+ when :nil then nil
131
+ when :empty then ""
132
+ else :skip
133
+ end
134
+ end
135
+
136
+ def mapped_capture_values(values, rule)
137
+ return values unless values.include?(nil)
138
+
139
+ mapped = mapped_empty_value(rule)
140
+ out = []
141
+ values.each do |item|
142
+ if item.nil?
143
+ out << mapped unless mapped == :skip
144
+ else
145
+ out << item
146
+ end
147
+ end
148
+ out
149
+ end
150
+
95
151
  # Wire-name strings are plan-frozen; materializing them per
96
152
  # hydration allocated two or three strings per attribute row
97
153
  # per instance.
@@ -128,6 +184,10 @@ module Lutaml
128
184
  # Interpretive parity: several captures into a
129
185
  # non-collection attribute arrive as the full array.
130
186
  v = vals.map(&:string_value)
187
+ if v.include?(nil)
188
+ v = v.map { |item| item.nil? ? mapped_empty_value(rule) : item }
189
+ v.reject! { |item| item == :skip }
190
+ end
131
191
  else
132
192
  first = if tag
133
193
  tagged[tag]&.first
@@ -135,16 +195,26 @@ module Lutaml
135
195
  grouped.dig(rule.name.to_s, 0)
136
196
  end
137
197
  v = first&.string_value
198
+ v = mapped_empty_value(rule) if v.nil? && first
138
199
  end
139
- unless v.nil?
200
+ unless v.nil? || v == :skip
140
201
  v = rule.transform_value(attr, v, :from, :xml) if rule.transform.is_a?(Class)
141
202
  assign(kwargs, delegates, delegate, rule, attr, v)
142
203
  end
143
204
  when :raw, :custom_method, :polymorphic, :content_deferred
144
205
  # interpreted post-instance (interpret_deferred)
145
206
  when :content
146
- assign(kwargs, delegates, delegate, rule, attr,
147
- content_runs(value))
207
+ runs = content_runs(value)
208
+ if runs.empty? && !attr.collection?
209
+ # An empty element's content binds through the rule's
210
+ # value map for scalar content attributes: "" for the
211
+ # plain :empty directive, nil for :nil, skipped for
212
+ # anything else (uninitialized).
213
+ bound = mapped_empty_value(rule)
214
+ assign(kwargs, delegates, delegate, rule, attr, bound) unless bound == :skip
215
+ else
216
+ assign(kwargs, delegates, delegate, rule, attr, runs)
217
+ end
148
218
  when :ordered_deferred
149
219
  # With a source node the child hydrates natively: one
150
220
  # walk against its own node + element_order from the
@@ -165,7 +235,10 @@ module Lutaml
165
235
  end
166
236
  end
167
237
  when :collection_cb
168
- values = grouped[rule.name.to_s].to_a.map(&:string_value)
238
+ values = grouped[rule.name.to_s].to_a.map do |item|
239
+ item&.string_value
240
+ end
241
+ values = mapped_capture_values(values, rule)
169
242
  if rule.transform.is_a?(Class)
170
243
  values = values.map { |v| rule.transform_value(attr, v, :from, :xml) }
171
244
  end
@@ -178,6 +251,7 @@ module Lutaml
178
251
  else
179
252
  native_collection(value, rule.name.to_s)
180
253
  end
254
+ values = mapped_capture_values(values, rule) unless values.nil?
181
255
  unless values.nil?
182
256
  assign(kwargs, delegates, delegate, rule, attr, values)
183
257
  end
@@ -232,12 +306,19 @@ module Lutaml
232
306
  when :content_deferred
233
307
  runs = content_runs(value)
234
308
  if runs.empty?
235
- # The interpretive pipeline marks every applied rule's
236
- # attribute set (model_transform apply). Skipping the
237
- # mark leaves using_default? true, so the serializer's
238
- # render gate suppresses a mapped reader that derives
239
- # content from other attributes (#856).
309
+ # The interpretive pipeline classifies the raw ""
310
+ # through the rule's value map: the :empty directive
311
+ # (the plain-rule default) binds "" — the attribute is
312
+ # still marked set, which is what the #856 mark is
313
+ # about; :nil binds nil; other directives leave the
314
+ # binding unset.
240
315
  instance.value_set_for(attr.name)
316
+ case rule.value_map(:from)[:empty]
317
+ when :nil
318
+ instance.public_send(:"#{attr.name}=", nil)
319
+ when :empty
320
+ instance.public_send(:"#{attr.name}=", "")
321
+ end
241
322
  next
242
323
  end
243
324
 
@@ -16,6 +16,11 @@ module Lutaml
16
16
  # existing opt-out protocol.
17
17
  MIN_LEPTRIS_VERSION = "1.9.273.0"
18
18
 
19
+ # AttrPlan rows carrying an ns form (exact-URI attribute matching,
20
+ # leptris#1486) shipped in libleptris 1.9.289.0. Older gems keep
21
+ # type-namespaced attributes on the interpretive path.
22
+ MIN_LEPTRIS_ATTR_NS_VERSION = "1.9.289.0"
23
+
19
24
  def self.plan_path_compatible?
20
25
  return false unless defined?(Gem)
21
26
 
@@ -23,6 +28,13 @@ module Lutaml
23
28
  spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
24
29
  end
25
30
 
31
+ def self.attr_ns_rows_compatible?
32
+ return false unless defined?(Gem)
33
+
34
+ spec = Gem.loaded_specs["leptris"]
35
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_ATTR_NS_VERSION)
36
+ end
37
+
26
38
  autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
27
39
  autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
28
40
  autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
@@ -43,9 +43,9 @@ end
43
43
  # unmatched-rule replay must never overwrite a value a writer assigned.
44
44
  RSpec.describe "attributes assigned inside another attribute's writer" do
45
45
  %i[Plain Defaulted].each do |klass_name|
46
- klass = WriterAssigned.const_get(klass_name)
47
-
48
46
  describe "with #{klass_name.to_s.downcase} text attribute" do
47
+ let(:klass) { WriterAssigned.const_get(klass_name) }
48
+
49
49
  it "keeps the derived value through from_xml" do
50
50
  instance = klass.from_xml("<probe><code>67.060</code></probe>")
51
51
 
@@ -64,10 +64,12 @@ RSpec.describe "XML plan fast path" do
64
64
  expect(parsed).to be_a(node)
65
65
  end
66
66
 
67
- # lutaml-model#744: attribute plan rows are local-name keyed — a
68
- # type-namespaced attribute (xmi:type vs type) must take the
69
- # interpretive matcher, or the namespaced value is silently lost.
70
- it "opts models with type-namespaced attributes out to the interpretive path" do
67
+ # lutaml-model#744/#758: a local name claimed by BOTH a
68
+ # type-namespaced attribute row and a plain row stays interpretive —
69
+ # the walk's ns form :none matches any qualification, so the plain
70
+ # row would capture the qualified spelling too. The interpretive
71
+ # matcher dispatches the spellings correctly.
72
+ it "opts shared-local-name attribute claims out to the interpretive path" do
71
73
  xmi_ns = Class.new(Lutaml::Xml::Namespace) do
72
74
  uri "http://www.omg.org/spec/XMI/20131001"
73
75
  prefix_default "xmi"
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "XML plan fast path: namespace-less models on namespaced documents" do
6
+ # lutaml-model#932: elements (unlike attributes) inherit the default
7
+ # xmlns, so the walk's strict no-URI default dropped every child of a
8
+ # namespaced document for models that declare no namespace. The
9
+ # ns_lenient flag restores the interpretive matcher's behavior:
10
+ # children match by local name under any document qualification.
11
+
12
+ let(:child_class) do
13
+ klass = Class.new(Lutaml::Model::Serializable) do
14
+ attribute :fileref, :string
15
+
16
+ xml do
17
+ element "imagedata"
18
+ map_attribute "fileref", to: :fileref
19
+ end
20
+ end
21
+ stub_const("NsLessDoc::Imagedata", klass)
22
+ klass
23
+ end
24
+
25
+ def define_parent
26
+ child_class
27
+ Class.new(Lutaml::Model::Serializable) do
28
+ attribute :imagedata, NsLessDoc::Imagedata
29
+ attribute :content, :string, collection: true
30
+
31
+ xml do
32
+ element "imageobject"
33
+ map_element "imagedata", to: :imagedata
34
+ map_content to: :content
35
+ end
36
+ end
37
+
38
+ end
39
+
40
+ around do |example|
41
+ old = Lutaml::Model::Config.instance.xml_plan_fast_path
42
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
43
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
44
+ ensure
45
+ Lutaml::Model::Config.instance.xml_plan_fast_path = old
46
+ end
47
+
48
+ it "binds nested children under a default namespace" do
49
+ parent = define_parent
50
+ doc = '<imageobject xmlns="http://docbook.org/ns/docbook">' \
51
+ '<imagedata fileref="x.png"/></imageobject>'
52
+
53
+ expect(parent.from_xml(doc).imagedata.fileref).to eq("x.png")
54
+ end
55
+
56
+ it "binds nested children under a prefixed namespace" do
57
+ parent = define_parent
58
+ doc = '<db:imageobject xmlns:db="http://docbook.org/ns/docbook">' \
59
+ '<db:imagedata fileref="x.png"/></db:imageobject>'
60
+
61
+ expect(parent.from_xml(doc).imagedata.fileref).to eq("x.png")
62
+ end
63
+
64
+ it "keeps binding on namespace-less documents" do
65
+ parent = define_parent
66
+ doc = %(<imageobject><imagedata fileref="x.png"/></imageobject>)
67
+
68
+ expect(parent.from_xml(doc).imagedata.fileref).to eq("x.png")
69
+ end
70
+
71
+ it "binds nested children of mixed-content models under a default namespace" do
72
+ parent = define_parent
73
+ parent.xml do
74
+ element "imageobject"
75
+ mixed_content
76
+ map_content to: :content
77
+ map_element "imagedata", to: :imagedata
78
+ end
79
+ doc = '<imageobject xmlns="http://docbook.org/ns/docbook">' \
80
+ 'text<imagedata fileref="x.png"/>tail</imageobject>'
81
+
82
+ parsed = parent.from_xml(doc)
83
+ expect(parsed.imagedata.fileref).to eq("x.png")
84
+ expect(parsed.content).to include("text")
85
+ end
86
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "XML plan fast path: type-namespaced attribute rows" do
6
+ # Attribute identity is (URI, local) (lutaml-model#744). A map_attribute
7
+ # whose TYPE carries a namespace class used to opt the whole model out
8
+ # of the plan path; it now compiles into an exact-URI AttrPlan ns row
9
+ # (libleptris 1.9.289) with the sole-claimant lenient recovery keeping
10
+ # the interpretive exact-first-then-any-qualification precedence.
11
+
12
+ let(:wml_ns) do
13
+ Class.new(Lutaml::Xml::Namespace) do
14
+ uri "http://example.com/type-ns/wml"
15
+ prefix_default "w"
16
+ end
17
+ end
18
+
19
+ let(:val_type) do
20
+ stub_const("PlanTypeNsAttr::WmlNs", wml_ns)
21
+ type = Class.new(Lutaml::Model::Type::String) do
22
+ include Lutaml::Xml::Type::Configurable
23
+
24
+ xml do
25
+ namespace PlanTypeNsAttr::WmlNs
26
+ end
27
+ end
28
+ stub_const("PlanTypeNsAttr::ValType", type)
29
+ type
30
+ end
31
+
32
+ let(:klass) do
33
+ val_type
34
+ model = Class.new(Lutaml::Model::Serializable) do
35
+ attribute :val, PlanTypeNsAttr::ValType
36
+
37
+ xml do
38
+ element "keepNext"
39
+ namespace PlanTypeNsAttr::WmlNs
40
+ map_attribute "val", to: :val
41
+ end
42
+ end
43
+ stub_const("PlanTypeNsAttr::KeepNext", model)
44
+ model
45
+ end
46
+
47
+ around do |example|
48
+ old = Lutaml::Model::Config.instance.xml_plan_fast_path
49
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
50
+ Lutaml::Model::Config.with_adapter(xml: :leptris) { example.run }
51
+ ensure
52
+ Lutaml::Model::Config.instance.xml_plan_fast_path = old
53
+ end
54
+
55
+ it "compiles a model whose attribute type carries a namespace" do
56
+ plan = Lutaml::Xml::Leptris::PlanCompiler.compile(
57
+ klass, Lutaml::Model::Config.default_register
58
+ )
59
+
60
+ expect(plan).not_to be_nil
61
+ ns_row = plan[:tree][:attributes].find { |a| a[:name] == "val" }
62
+ expect(ns_row[:ns]).to eq(exact: "http://example.com/type-ns/wml")
63
+ end
64
+
65
+ it "binds the attribute from the type's namespace" do
66
+ doc = %(<w:keepNext xmlns:w="http://example.com/type-ns/wml" w:val="yes"/>)
67
+
68
+ expect(klass.from_xml(doc).val).to eq("yes")
69
+ end
70
+
71
+ # The interpretive matcher refuses an ambiguous local name outright:
72
+ # a foreign spelling alongside the canonical one binds nothing. The
73
+ # exact-URI row mirrors that refusal (lutaml-model#744).
74
+ it "refuses to bind when a foreign spelling accompanies the canonical one" do
75
+ doc = '<w:keepNext xmlns:w="http://example.com/type-ns/wml" ' \
76
+ 'xmlns:f="http://example.com/foreign" val="foreign" w:val="canonical"/>'
77
+
78
+ expect(klass.from_xml(doc).val).to be_nil
79
+ end
80
+
81
+ # Interpretive parity: the plan path resolves exactly as the
82
+ # interpretive matcher (nokogiri) does on every spelling shape.
83
+ it "matches the interpretive path's resolution on all spelling shapes" do
84
+ canonical = %(<w:keepNext xmlns:w="http://example.com/type-ns/wml" w:val="yes"/>)
85
+ ambiguous = '<w:keepNext xmlns:w="http://example.com/type-ns/wml" ' \
86
+ 'xmlns:f="http://example.com/foreign" val="foreign" w:val="canonical"/>'
87
+ foreign_only = '<w:keepNext xmlns:f="http://example.com/foreign" ' \
88
+ 'xmlns:w="http://example.com/type-ns/wml" val="foreign"/>'
89
+
90
+ [canonical, ambiguous, foreign_only].each do |doc|
91
+ interpretive = Lutaml::Model::Config.with_adapter(xml: :nokogiri) do
92
+ klass.from_xml(doc).val
93
+ end
94
+
95
+ expect(klass.from_xml(doc).val).to eq(interpretive)
96
+ end
97
+ end
98
+
99
+ # A type declaring alias URI families stays interpretive: the plan row
100
+ # holds one exact URI and non-sole-claimant alias families have no
101
+ # plan-side resolution order.
102
+ it "keeps alias-declaring types on the interpretive path" do
103
+ aliased_ns = Class.new(Lutaml::Xml::Namespace) do
104
+ uri "http://example.com/type-ns/canonical"
105
+ uri_aliases "http://example.com/type-ns/alias"
106
+ end
107
+ stub_const("PlanTypeNsAttr::AliasedNs", aliased_ns)
108
+ type = Class.new(Lutaml::Model::Type::String) do
109
+ include Lutaml::Xml::Type::Configurable
110
+
111
+ xml do
112
+ namespace PlanTypeNsAttr::AliasedNs
113
+ end
114
+ end
115
+ stub_const("PlanTypeNsAttr::AliasedValType", type)
116
+ model = Class.new(Lutaml::Model::Serializable) do
117
+ attribute :val, PlanTypeNsAttr::AliasedValType
118
+
119
+ xml do
120
+ element "keepNext"
121
+ map_attribute "val", to: :val
122
+ end
123
+ end
124
+ stub_const("PlanTypeNsAttr::AliasedKeepNext", model)
125
+
126
+ expect(Lutaml::Xml::Leptris::PlanCompiler.compile(
127
+ model, Lutaml::Model::Config.default_register
128
+ )).to be_nil
129
+ end
130
+
131
+ # Engines older than the AttrPlan ns form (libleptris 1.9.289) keep
132
+ # the opt-out — the existing version-gate protocol.
133
+ it "opts out when the engine predates attribute ns rows" do
134
+ allow(Lutaml::Xml::Leptris).to receive(:attr_ns_rows_compatible?)
135
+ .and_return(false)
136
+
137
+ expect(Lutaml::Xml::Leptris::PlanCompiler.compile(
138
+ klass, Lutaml::Model::Config.default_register
139
+ )).to be_nil
140
+ end
141
+ end
data/spec/person_spec.rb CHANGED
@@ -115,12 +115,13 @@ RSpec.describe Person do
115
115
  expect(model.to_yaml).to eq(attributes_yaml.to_yaml)
116
116
  end
117
117
 
118
- # yeptris misparses time-only scalars ("07:00:00" anchors to a fixed
119
- # date at midnight) — pending the engine fix, this example cannot
120
- # pass under the in-suite-detected engine.
121
- # rubocop:disable RSpec/PendingWithoutReason -- yeptris misparses time-only
122
- # scalars (fixed-date midnight anchor); engine fix pending upstream.
123
- xit "deserializes from YAML" do
118
+ # yeptris 0.6.29.x regressed the time-only scalar anchor: "07:00:00"
119
+ # comes back as a fixed 2025-07-19 00:00 instead of today-07:00
120
+ # (psych answers exactly; verified on the psych path). Re-pended
121
+ # until the yeptris typed-materialize ships the fix — evidence on
122
+ # moxml#336. 873a67bd un-pended these on the premise that the
123
+ # 0.6.28.x behavior held; it did not survive the 0.6.29 cut.
124
+ xit "deserializes from YAML" do # rubocop:disable RSpec/PendingWithoutReason -- yeptris time-only misparse; engine fix pending.
124
125
  yaml = attributes_yaml.to_yaml
125
126
  person = described_class.from_yaml(yaml)
126
127
  expect(person.first_name).to eq("John")
@@ -133,7 +134,7 @@ RSpec.describe Person do
133
134
  end
134
135
 
135
136
  # Same yeptris time-only scalar misparse as above.
136
- xit "deserializes from YAML array" do
137
+ xit "deserializes from YAML array" do # rubocop:disable RSpec/PendingWithoutReason -- yeptris time-only misparse; engine fix pending.
137
138
  yaml = [attributes_yaml.dup, attributes_yaml.dup].to_yaml
138
139
 
139
140
  persons = described_class.from_yaml(yaml)
@@ -154,5 +155,4 @@ RSpec.describe Person do
154
155
  expect(persons[1].wakeup_time).to eq(Time.parse("07:00:00"))
155
156
  expect(persons[1].active).to be true
156
157
  end
157
- # rubocop:enable RSpec/PendingWithoutReason
158
158
  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.94
4
+ version: 0.8.95
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-05 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -184,6 +184,7 @@ files:
184
184
  - bench/bench_alloc_audit.rb
185
185
  - bench/bench_common.rb
186
186
  - bench/bench_compare.rb
187
+ - bench/bench_merge_attempts.rb
187
188
  - bench/bench_mml.rb
188
189
  - bench/bench_niso.rb
189
190
  - bench/bench_sts.rb
@@ -1985,7 +1986,9 @@ files:
1985
1986
  - spec/lutaml/xml/pipeline_integration_spec.rb
1986
1987
  - spec/lutaml/xml/plain_model_accessors_spec.rb
1987
1988
  - spec/lutaml/xml/plan_fast_path_spec.rb
1989
+ - spec/lutaml/xml/plan_ns_less_document_spec.rb
1988
1990
  - spec/lutaml/xml/plan_partition_spec.rb
1991
+ - spec/lutaml/xml/plan_type_ns_attribute_spec.rb
1989
1992
  - spec/lutaml/xml/plan_walk_spec.rb
1990
1993
  - spec/lutaml/xml/prefix_control_spec.rb
1991
1994
  - spec/lutaml/xml/register_spec.rb