lutaml-model 0.8.93 → 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 +4 -4
- data/.github/workflows/downstream-performance.yml +17 -6
- data/bench/bench_merge_attempts.rb +49 -0
- data/lib/lutaml/model/serialize/attribute_definition.rb +52 -0
- data/lib/lutaml/model/serialize/initialization.rb +29 -1
- data/lib/lutaml/model/serialize/model_import.rb +25 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/leptris/plan_compiler.rb +83 -12
- data/lib/lutaml/xml/leptris/plan_hydrator.rb +90 -9
- data/lib/lutaml/xml/leptris.rb +12 -0
- data/lib/lutaml/xml/model_transform.rb +51 -8
- data/spec/lutaml/model/late_attribute_declaration_spec.rb +35 -0
- data/spec/lutaml/model/writer_assigned_attribute_spec.rb +62 -0
- data/spec/lutaml/xml/plan_fast_path_spec.rb +6 -4
- data/spec/lutaml/xml/plan_ns_less_document_spec.rb +86 -0
- data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +141 -0
- data/spec/person_spec.rb +8 -8
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5933a3971f4f39e30541f504636641cd059e7a97751ba67a710203dbfdcc45a
|
|
4
|
+
data.tar.gz: c23b70227e80d99427b9d4125a09e33d9751112bc72a5e6abaca73d6a3897c60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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)"
|
|
@@ -62,6 +62,24 @@ module Lutaml
|
|
|
62
62
|
#{lines}
|
|
63
63
|
end
|
|
64
64
|
RUBY
|
|
65
|
+
|
|
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
|
+
|
|
74
|
+
<<~RUBY
|
|
75
|
+
def __lutaml_raw_#{name}
|
|
76
|
+
@#{name}
|
|
77
|
+
end
|
|
78
|
+
RUBY
|
|
79
|
+
end.join
|
|
80
|
+
class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
|
|
81
|
+
#{raw_lines}
|
|
82
|
+
RUBY
|
|
65
83
|
end
|
|
66
84
|
|
|
67
85
|
# Historical getter shape for punctuation-named attributes and
|
|
@@ -104,12 +122,41 @@ module Lutaml
|
|
|
104
122
|
end
|
|
105
123
|
remove_method(compiled) if defined_now
|
|
106
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
|
|
107
128
|
end
|
|
108
129
|
|
|
109
130
|
def define_attribute_methods(attr, register = nil)
|
|
110
131
|
name = attr.name
|
|
111
132
|
register_id = extract_register_id(register)
|
|
112
133
|
|
|
134
|
+
# lutaml-model#922: raw-state reader for the deserializer's
|
|
135
|
+
# unmatched-rule handling. A custom writer that assigns a
|
|
136
|
+
# *different* attribute mid-parse (relaton's ICS Isoics
|
|
137
|
+
# fallback; any derived-value pattern) is invisible to the
|
|
138
|
+
# value_set tracker -- public setters don't mark it -- so the
|
|
139
|
+
# tracked "still default" state lies. The deserializer needs
|
|
140
|
+
# the raw stored state to know whether an attribute was
|
|
141
|
+
# writer-populated: still holding the initialized marker
|
|
142
|
+
# (sentinel / lazy collection) means unset; anything else was
|
|
143
|
+
# written since init and must never be overwritten by an
|
|
144
|
+
# unmatched rule's default assignment. Declared here (not in
|
|
145
|
+
# the state-defaults compilation) so late declarations get it
|
|
146
|
+
# too -- XMI's extension loader declares attributes after
|
|
147
|
+
# first parse.
|
|
148
|
+
if name.to_s.match?(PLAIN_NAME)
|
|
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
|
|
154
|
+
def __lutaml_raw_#{name}
|
|
155
|
+
@#{name}
|
|
156
|
+
end
|
|
157
|
+
RUBY
|
|
158
|
+
end
|
|
159
|
+
|
|
113
160
|
if attr.enum?
|
|
114
161
|
add_enum_methods_to_model(
|
|
115
162
|
model,
|
|
@@ -354,6 +401,11 @@ module Lutaml
|
|
|
354
401
|
attr = Attribute.new(name, type, options)
|
|
355
402
|
@attributes[name] = attr
|
|
356
403
|
@merged_attributes_cache = nil
|
|
404
|
+
# Attributes can be declared after the model has been
|
|
405
|
+
# instantiated (forward references, post-hoc declarations);
|
|
406
|
+
# the interned writer table from the first instantiation must
|
|
407
|
+
# not outlive the attribute set it was built from.
|
|
408
|
+
@instantiate_writers = nil
|
|
357
409
|
invalidate_state_defaults!
|
|
358
410
|
define_attribute_methods(attr)
|
|
359
411
|
|
|
@@ -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
|
|
@@ -392,6 +404,22 @@ module Lutaml
|
|
|
392
404
|
# set (nil when no default), never left as the
|
|
393
405
|
# uninitialized sentinel — readers and formatters touch
|
|
394
406
|
# absent attributes freely.
|
|
407
|
+
#
|
|
408
|
+
# lutaml-model#922: unless a custom writer already
|
|
409
|
+
# populated it. An earlier kwarg's writer may have
|
|
410
|
+
# assigned this attribute as a side effect (relaton's ICS
|
|
411
|
+
# populates <text> from the Isoics dataset inside code=);
|
|
412
|
+
# the raw state is the truth — still at its initialized
|
|
413
|
+
# marker (sentinel / lazy collection) means unset, and
|
|
414
|
+
# only then is the default seeded. The writer's own
|
|
415
|
+
# value_set_for (inside the compiled writer) already
|
|
416
|
+
# marked it set, so it renders.
|
|
417
|
+
raw = instance.public_send(:"__lutaml_raw_#{name}")
|
|
418
|
+
unset = raw.nil? ||
|
|
419
|
+
raw.equal?(Lutaml::Model::UninitializedClass.instance) ||
|
|
420
|
+
raw.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
|
|
421
|
+
next unless unset
|
|
422
|
+
|
|
395
423
|
default = attr.default_value(register_id, instance)
|
|
396
424
|
value = if Lutaml::Model::Utils.uninitialized?(default)
|
|
397
425
|
nil
|
|
@@ -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
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
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
|
|
data/lib/lutaml/xml/leptris.rb
CHANGED
|
@@ -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"
|
|
@@ -345,12 +345,11 @@ module Lutaml
|
|
|
345
345
|
mappings.each do |rule|
|
|
346
346
|
record = records&.[](rule)
|
|
347
347
|
|
|
348
|
-
# #903: replay-or-drop unmatched
|
|
349
|
-
#
|
|
350
|
-
if replay_unmatched_rule(child_names_set, record, rule,
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
end
|
|
348
|
+
# #903/#922: replay-or-drop unmatched rules; never clobber
|
|
349
|
+
# a mid-parse custom-writer assignment.
|
|
350
|
+
next if replay_unmatched_rule(child_names_set, record, rule,
|
|
351
|
+
instance, defaults_used,
|
|
352
|
+
xml_mapping)
|
|
354
353
|
|
|
355
354
|
rule_to = rule.to
|
|
356
355
|
|
|
@@ -436,11 +435,19 @@ module Lutaml
|
|
|
436
435
|
# NOTE: do not "read the instance" here (to_value_for) —
|
|
437
436
|
# the compiled getters materialize lazy collections on
|
|
438
437
|
# first read, which tripled parse allocations when tried.
|
|
438
|
+
if writer_populated?(record, instance)
|
|
439
|
+
instance.value_set_for(rule_to)
|
|
440
|
+
next
|
|
441
|
+
end
|
|
439
442
|
if instance.using_default?(rule_to) || rule.render_default
|
|
440
443
|
defaults_used << rule_to
|
|
441
444
|
attr&.default(effective_register) || rule.to_value_for(instance)
|
|
442
445
|
else
|
|
443
|
-
|
|
446
|
+
# The attribute already holds an explicit value --
|
|
447
|
+
# assigned by a matched rule or by a custom writer
|
|
448
|
+
# mid-parse (lutaml-model#922). An unmatched rule
|
|
449
|
+
# must not overwrite it with nil.
|
|
450
|
+
next
|
|
444
451
|
end
|
|
445
452
|
else
|
|
446
453
|
raise "ELSE-BODY" if ENV["CATCHDBG"] && rule.name.nil?
|
|
@@ -857,7 +864,7 @@ _effective_register)
|
|
|
857
864
|
|
|
858
865
|
Record = Struct.new(:attr, :derived, :group_skip, :valid,
|
|
859
866
|
:unmatched_mode, :replay_writer,
|
|
860
|
-
:unmatched_names)
|
|
867
|
+
:unmatched_names, :raw_reader)
|
|
861
868
|
|
|
862
869
|
def compiled_rule_records(model_class, register, grouped_plain_rules)
|
|
863
870
|
return {}.compare_by_identity unless model_class.include?(Lutaml::Model::Serialize)
|
|
@@ -946,6 +953,15 @@ _effective_register)
|
|
|
946
953
|
unmatched_mode,
|
|
947
954
|
replay_writer,
|
|
948
955
|
unmatched_names,
|
|
956
|
+
# lutaml-model#922: raw-state reader for the attribute,
|
|
957
|
+
# when the compiled init seeds it (plain names -- the same
|
|
958
|
+
# set compile_state_defaults! writes). Delegated rules name
|
|
959
|
+
# an attribute on the delegate object, not on this
|
|
960
|
+
# instance, so they get no raw reader.
|
|
961
|
+
if attr && rule.delegate.nil? &&
|
|
962
|
+
rule.to.to_s.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
963
|
+
:"__lutaml_raw_#{rule.to}"
|
|
964
|
+
end,
|
|
949
965
|
)
|
|
950
966
|
end
|
|
951
967
|
entry = Struct.new(:version, :records).new(version, records)
|
|
@@ -1630,6 +1646,24 @@ effective_register = lutaml_register)
|
|
|
1630
1646
|
# (the visit ended at the omission gate or was a guarded next)
|
|
1631
1647
|
# writes nothing; :write_nil replays the nil the default value
|
|
1632
1648
|
# map produces, plus the flag churn. Returns true when handled.
|
|
1649
|
+
# lutaml-model#922: whether the attribute holds anything other
|
|
1650
|
+
# than its initialized marker (uninitialized sentinel / lazy
|
|
1651
|
+
# collection) — i.e. a custom writer populated it mid-parse.
|
|
1652
|
+
def writer_populated?(record, instance)
|
|
1653
|
+
return false unless record&.raw_reader
|
|
1654
|
+
|
|
1655
|
+
!raw_initialized?(record, instance)
|
|
1656
|
+
end
|
|
1657
|
+
|
|
1658
|
+
# True when the attribute still holds the value the compiled init
|
|
1659
|
+
# seeded (uninitialized sentinel / lazy collection) — i.e. nothing
|
|
1660
|
+
# has written it since.
|
|
1661
|
+
def raw_initialized?(record, instance)
|
|
1662
|
+
raw = instance.public_send(record.raw_reader)
|
|
1663
|
+
raw.equal?(::Lutaml::Model::UninitializedClass.instance) ||
|
|
1664
|
+
raw.equal?(::Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
|
|
1665
|
+
end
|
|
1666
|
+
|
|
1633
1667
|
def replay_unmatched_rule(child_names_set, record, rule, instance,
|
|
1634
1668
|
defaults_used, xml_mapping)
|
|
1635
1669
|
return false unless child_names_set && record
|
|
@@ -1659,6 +1693,15 @@ effective_register = lutaml_register)
|
|
|
1659
1693
|
return true
|
|
1660
1694
|
end
|
|
1661
1695
|
|
|
1696
|
+
# lutaml-model#922: a custom writer may have populated the
|
|
1697
|
+
# attribute mid-parse without going through the tracker (the
|
|
1698
|
+
# compiled setters are direct ivar writes), so the tracked
|
|
1699
|
+
# flag above can lie. The raw state is the truth: still at its
|
|
1700
|
+
# initialized marker (sentinel / lazy collection) means unset
|
|
1701
|
+
# -- the visited path wrote the default there; anything else
|
|
1702
|
+
# is a writer assignment this replay must not clobber.
|
|
1703
|
+
return true unless raw_initialized?(record, instance)
|
|
1704
|
+
|
|
1662
1705
|
defaults_used << rule_to
|
|
1663
1706
|
instance.public_send(record.replay_writer, nil)
|
|
1664
1707
|
instance.value_set_for(rule_to)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# Attributes can be declared after the model has been instantiated
|
|
6
|
+
# (forward references, post-hoc declarations). The interned writer
|
|
7
|
+
# table built during the first instantiation must not outlive the
|
|
8
|
+
# attribute set it was built from.
|
|
9
|
+
RSpec.describe "attributes declared after first instantiation" do
|
|
10
|
+
it "initializes attributes added after an earlier instantiation" do
|
|
11
|
+
klass = Class.new(Lutaml::Model::Serializable) do
|
|
12
|
+
attribute :name, :string
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
klass.new(name: "first")
|
|
16
|
+
|
|
17
|
+
klass.attribute :size, :string
|
|
18
|
+
instance = klass.new(name: "second", size: "L")
|
|
19
|
+
|
|
20
|
+
expect(instance.name).to eq("second")
|
|
21
|
+
expect(instance.size).to eq("L")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "keeps earlier instances unaffected" do
|
|
25
|
+
klass = Class.new(Lutaml::Model::Serializable) do
|
|
26
|
+
attribute :name, :string
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
first = klass.new(name: "first")
|
|
30
|
+
klass.attribute :size, :string
|
|
31
|
+
|
|
32
|
+
expect(first.name).to eq("first")
|
|
33
|
+
expect(first.respond_to?(:size)).to be(true)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module WriterAssigned
|
|
6
|
+
class Plain < Lutaml::Model::Serializable
|
|
7
|
+
attribute :code, :string
|
|
8
|
+
attribute :text, :string
|
|
9
|
+
|
|
10
|
+
xml do
|
|
11
|
+
element "probe"
|
|
12
|
+
map_element "code", to: :code
|
|
13
|
+
map_element "text", to: :text
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def code=(val)
|
|
17
|
+
super
|
|
18
|
+
self.text = "derived-#{val}" if val
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class Defaulted < Lutaml::Model::Serializable
|
|
23
|
+
attribute :code, :string
|
|
24
|
+
attribute :text, :string, default: -> { "" }
|
|
25
|
+
|
|
26
|
+
xml do
|
|
27
|
+
element "probe"
|
|
28
|
+
map_element "code", to: :code
|
|
29
|
+
map_element "text", to: :text
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def code=(val)
|
|
33
|
+
super
|
|
34
|
+
self.text = "derived-#{val}" if val
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# lutaml-model#922: a custom writer that assigns a *different* attribute
|
|
40
|
+
# mid-parse (relaton's Relaton::Bib::ICS populates <text> from the Isoics
|
|
41
|
+
# dataset inside code=) must survive the deserializer's unmatched-rule
|
|
42
|
+
# handling: the default seeding in Initialization#instantiate and the
|
|
43
|
+
# unmatched-rule replay must never overwrite a value a writer assigned.
|
|
44
|
+
RSpec.describe "attributes assigned inside another attribute's writer" do
|
|
45
|
+
%i[Plain Defaulted].each do |klass_name|
|
|
46
|
+
describe "with #{klass_name.to_s.downcase} text attribute" do
|
|
47
|
+
let(:klass) { WriterAssigned.const_get(klass_name) }
|
|
48
|
+
|
|
49
|
+
it "keeps the derived value through from_xml" do
|
|
50
|
+
instance = klass.from_xml("<probe><code>67.060</code></probe>")
|
|
51
|
+
|
|
52
|
+
expect(instance.text).to eq("derived-67.060")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "renders the derived value on to_xml" do
|
|
56
|
+
instance = klass.from_xml("<probe><code>67.060</code></probe>")
|
|
57
|
+
|
|
58
|
+
expect(instance.to_xml).to include("<text>derived-67.060</text>")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -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:
|
|
68
|
-
# type-namespaced attribute
|
|
69
|
-
#
|
|
70
|
-
|
|
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
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
|
|
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.
|
|
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-
|
|
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
|
|
@@ -1759,6 +1760,7 @@ files:
|
|
|
1759
1760
|
- spec/lutaml/model/jsonl_spec.rb
|
|
1760
1761
|
- spec/lutaml/model/key_value_data_model/key_value_element_spec.rb
|
|
1761
1762
|
- spec/lutaml/model/key_value_mapping_spec.rb
|
|
1763
|
+
- spec/lutaml/model/late_attribute_declaration_spec.rb
|
|
1762
1764
|
- spec/lutaml/model/lazy_collection_spec.rb
|
|
1763
1765
|
- spec/lutaml/model/liquefiable_lazy_spec.rb
|
|
1764
1766
|
- spec/lutaml/model/liquefiable_spec.rb
|
|
@@ -1872,6 +1874,7 @@ files:
|
|
|
1872
1874
|
- spec/lutaml/model/value_policy_spec.rb
|
|
1873
1875
|
- spec/lutaml/model/value_transformation_comprehensive_spec.rb
|
|
1874
1876
|
- spec/lutaml/model/with_child_mapping_spec.rb
|
|
1877
|
+
- spec/lutaml/model/writer_assigned_attribute_spec.rb
|
|
1875
1878
|
- spec/lutaml/model/xml_decoupling_spec.rb
|
|
1876
1879
|
- spec/lutaml/model/xsd_form_default_patterns_spec.rb
|
|
1877
1880
|
- spec/lutaml/model/xsd_patterns_spec.rb
|
|
@@ -1983,7 +1986,9 @@ files:
|
|
|
1983
1986
|
- spec/lutaml/xml/pipeline_integration_spec.rb
|
|
1984
1987
|
- spec/lutaml/xml/plain_model_accessors_spec.rb
|
|
1985
1988
|
- spec/lutaml/xml/plan_fast_path_spec.rb
|
|
1989
|
+
- spec/lutaml/xml/plan_ns_less_document_spec.rb
|
|
1986
1990
|
- spec/lutaml/xml/plan_partition_spec.rb
|
|
1991
|
+
- spec/lutaml/xml/plan_type_ns_attribute_spec.rb
|
|
1987
1992
|
- spec/lutaml/xml/plan_walk_spec.rb
|
|
1988
1993
|
- spec/lutaml/xml/prefix_control_spec.rb
|
|
1989
1994
|
- spec/lutaml/xml/register_spec.rb
|