lutaml-model 0.8.96 → 0.8.98
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/docs/_guides/native-engines.adoc +16 -5
- data/lib/lutaml/model/attribute.rb +10 -1
- data/lib/lutaml/model/schema/base_schema.rb +15 -0
- data/lib/lutaml/model/schema/generator/definitions_collection.rb +10 -0
- data/lib/lutaml/model/serialize/format_conversion.rb +7 -1
- data/lib/lutaml/model/serialize/initialization.rb +25 -7
- data/lib/lutaml/model/serialize/model_import.rb +34 -4
- data/lib/lutaml/model/type/time.rb +6 -0
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/leptris/plan_compiler.rb +129 -6
- data/lib/lutaml/xml/leptris/plan_serializer.rb +110 -30
- data/lib/lutaml/xml/leptris/plan_walk.rb +1 -0
- data/lib/lutaml/xml/leptris.rb +36 -0
- data/lib/lutaml/xml/model_transform.rb +38 -14
- data/lib/lutaml/xml/namespace_collector.rb +17 -1
- data/lib/lutaml/xml/type/serializers.rb +8 -1
- data/spec/lutaml/model/model_import_reentry_spec.rb +46 -0
- data/spec/lutaml/model/schema/generate_many_spec.rb +61 -0
- data/spec/person_spec.rb +10 -9
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9f7cb26a9f87fbfe0feb461b25dd3e8b50e32d64bbbd32b23f848e96355965b
|
|
4
|
+
data.tar.gz: 1961e58a64424ca1d69181f3e873d107e8f1d8762f1d1155c1de953c883ceaa1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8624d39f5b4d556f6c72e7f76fa6ab262490a1583d5776756373dd4ea220bb1ba89e3f57f7774efa8f5f8c16f631f312ac149ed93ed651c15223068265bea629
|
|
7
|
+
data.tar.gz: 1249cac19b6f80880f5eb0c29cb0b2df17ff5813021573f6c2b63c923f6b2d63d2f579b9d14ebf18b8dae774df208787cef61cbfc507848fd99afffbc28b1404
|
|
@@ -67,11 +67,22 @@ When the XML adapter resolves to leptris, XML (de)serialization
|
|
|
67
67
|
compiles the model's mapping into a leptris descriptor plan and runs
|
|
68
68
|
the whole document through one native pass — measured 6.7x parse and
|
|
69
69
|
4x serialize with -76% / -80% allocations on the plan-path corpus.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
Namespace-qualified models ride the path too: serialization spells
|
|
71
|
+
prefixed element and attribute names through the same namespace
|
|
72
|
+
planning the interpretive writer uses, and every `(prefix, uri)`
|
|
73
|
+
pair is declared once on the output root. Models the plan cannot
|
|
74
|
+
express exactly (same-name discriminator partitions, deferred-rule
|
|
75
|
+
shapes) and shapes with custom semantics (custom methods,
|
|
76
|
+
polymorphism, unions, ordered children) hydrate through the
|
|
77
|
+
interpretive pipeline or a per-subtree fragment of it, unchanged.
|
|
78
|
+
|
|
79
|
+
Version floor note: leptris 1.9.320+ retains child-row namespace
|
|
80
|
+
URIs engine-side and matches plain attribute rows against
|
|
81
|
+
namespace-qualified wire spellings. On older engines, models whose
|
|
82
|
+
plans carry exact-URI child rows or plain attribute rows under a
|
|
83
|
+
namespaced chain parse interpretively for correctness — a per-model
|
|
84
|
+
fallback, not an error. Bundles resolving leptris 1.9.320+ get the
|
|
85
|
+
full fast path.
|
|
75
86
|
|
|
76
87
|
The path is on by default and is a no-op without leptris (standard
|
|
77
88
|
adapters keep the classic pipeline). Opt out for the whole process:
|
|
@@ -1240,7 +1240,16 @@ instance_object = nil)
|
|
|
1240
1240
|
# instead of three per serialized scalar. Reference and
|
|
1241
1241
|
# custom-serializer rows keep the wrapped path.
|
|
1242
1242
|
if resolved_type <= Type::Reference ||
|
|
1243
|
-
Type::Value.format_type_serializer_for(format, resolved_type)&.dig(:to)
|
|
1243
|
+
(registered_to = Type::Value.format_type_serializer_for(format, resolved_type)&.dig(:to))
|
|
1244
|
+
# String→String under the default :xml serializer is the
|
|
1245
|
+
# identity — the wrapper mint per string value on the XML
|
|
1246
|
+
# serialize path is pure waste. A consumer override of the
|
|
1247
|
+
# serializer disables the fast path via the proc comparison.
|
|
1248
|
+
if resolved_type == Type::String && value.is_a?(::String) &&
|
|
1249
|
+
registered_to == Lutaml::Xml::Type::Serializers::STRING_XML_TO
|
|
1250
|
+
return value
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1244
1253
|
value = wrap_in_type_if_needed(value, resolved_type)
|
|
1245
1254
|
return value.public_send(TO_FORMAT_METHODS[format] || :"to_#{format}")
|
|
1246
1255
|
end
|
|
@@ -16,6 +16,21 @@ module Lutaml
|
|
|
16
16
|
|
|
17
17
|
format_schema(schema, options)
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
# Generate one schema document covering several model classes.
|
|
21
|
+
# The roots share a single $defs map and no top-level $ref is
|
|
22
|
+
# emitted; each root stays addressable under $defs by its class
|
|
23
|
+
# name. Use generate for a single root with a top-level $ref.
|
|
24
|
+
def generate_many(klasses, options = {})
|
|
25
|
+
schema = {
|
|
26
|
+
"$schema" => options[:schema],
|
|
27
|
+
"$id" => options[:id],
|
|
28
|
+
"description" => options[:description],
|
|
29
|
+
"$defs" => Generator::DefinitionsCollection.from_classes(klasses).to_schema,
|
|
30
|
+
}.compact
|
|
31
|
+
|
|
32
|
+
format_schema(schema, options)
|
|
33
|
+
end
|
|
19
34
|
end
|
|
20
35
|
|
|
21
36
|
attr_reader :schema, :klass
|
|
@@ -14,6 +14,16 @@ module Lutaml
|
|
|
14
14
|
collection
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Build one collection covering several roots. The seen-set
|
|
18
|
+
# is shared across roots so a class referenced by more than
|
|
19
|
+
# one of them registers once.
|
|
20
|
+
def from_classes(klasses)
|
|
21
|
+
collection = new
|
|
22
|
+
seen = {}
|
|
23
|
+
klasses.each { |klass| register_definition(collection, klass, seen) }
|
|
24
|
+
collection
|
|
25
|
+
end
|
|
26
|
+
|
|
17
27
|
def process_attributes(collection, klass, seen = {})
|
|
18
28
|
register = extract_register_from(klass)
|
|
19
29
|
klass.attributes.each_value do |attribute|
|
|
@@ -95,7 +95,12 @@ module Lutaml
|
|
|
95
95
|
options.key?(:mappings) || options.key?(:adapter) ||
|
|
96
96
|
options.key?(:_adapter_override) ||
|
|
97
97
|
options.key?(:indent) || options.key?(:xml_declaration) ||
|
|
98
|
-
options.key?(:declaration) || options.key?(:doctype)
|
|
98
|
+
options.key?(:declaration) || options.key?(:doctype) ||
|
|
99
|
+
# prefix requests ride the interpretive writer's use_prefix
|
|
100
|
+
# option (planner Tier-1) — the plan emission has no
|
|
101
|
+
# option-aware spelling. The instance-level option prep
|
|
102
|
+
# rewrites :prefix to :use_prefix before this gate.
|
|
103
|
+
options.key?(:prefix) || options.key?(:use_prefix)
|
|
99
104
|
|
|
100
105
|
adapter_name = Lutaml::Model::Config.adapter_for(:xml)
|
|
101
106
|
adapter_name &&= adapter_name.name
|
|
@@ -133,6 +138,7 @@ module Lutaml
|
|
|
133
138
|
register = Lutaml::Model::Config.default_register
|
|
134
139
|
plan = Lutaml::Xml::Leptris::PlanCompiler.compile(self, register)
|
|
135
140
|
return nil unless plan
|
|
141
|
+
return nil unless Lutaml::Xml::Leptris::PlanCompiler.engine_walk_safe?(plan)
|
|
136
142
|
|
|
137
143
|
root = ::Leptris::XML.parse(data.to_s).root
|
|
138
144
|
return nil if root.nil?
|
|
@@ -172,12 +172,30 @@ module Lutaml
|
|
|
172
172
|
#
|
|
173
173
|
# @param register [Symbol, nil] The register context
|
|
174
174
|
def ensure_imports!(register = nil)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
|
|
175
|
+
# Bounded re-entry for the resolution chain. Each leg's
|
|
176
|
+
# record_import_attempt tail calls attributes(), which calls
|
|
177
|
+
# back into ensure_imports!; nested passes converge imports
|
|
178
|
+
# whose targets only become resolvable after an earlier leg
|
|
179
|
+
# (so they must not be dropped), but with a permanently
|
|
180
|
+
# pending import the tail re-entered unboundedly and crashed
|
|
181
|
+
# with SystemStackError (#938). Two levels allow one nested
|
|
182
|
+
# convergence pass; deeper re-entry is suppressed per
|
|
183
|
+
# register and the counter unwinds on error.
|
|
184
|
+
register_id = extract_register_id(register)
|
|
185
|
+
(@ensure_imports_depth ||= {})[register_id] ||= 0
|
|
186
|
+
return if @ensure_imports_depth[register_id] >= 2
|
|
187
|
+
|
|
188
|
+
@ensure_imports_depth[register_id] += 1
|
|
189
|
+
begin
|
|
190
|
+
ensure_model_imports!(register)
|
|
191
|
+
ensure_choice_imports!(register)
|
|
192
|
+
ensure_restrict_attributes!(register)
|
|
193
|
+
# Hook for format-specific mapping import resolution.
|
|
194
|
+
# XML overrides this to call mappings[:xml]&.ensure_mappings_imported!(register)
|
|
195
|
+
ensure_format_mapping_imports!(register)
|
|
196
|
+
ensure
|
|
197
|
+
@ensure_imports_depth[register_id] -= 1
|
|
198
|
+
end
|
|
181
199
|
end
|
|
182
200
|
|
|
183
201
|
# Hook for format-specific mapping import resolution.
|
|
@@ -357,7 +375,7 @@ module Lutaml
|
|
|
357
375
|
# chain's frames per level pushed that recursion past the
|
|
358
376
|
# stack limit — models without pending deferred imports skip
|
|
359
377
|
# it entirely.
|
|
360
|
-
ensure_imports!(register_id) if finalized? && deferred_imports_pending?
|
|
378
|
+
ensure_imports!(register_id) if finalized? && deferred_imports_pending?(register_id)
|
|
361
379
|
instance = allocate
|
|
362
380
|
instance.finalize_deserialization(register_id)
|
|
363
381
|
instance
|
|
@@ -162,11 +162,39 @@ module Lutaml
|
|
|
162
162
|
# @param register_id [Symbol, nil] The register context
|
|
163
163
|
# Whether deferred (symbol-form) imports are still pending —
|
|
164
164
|
# gates the allocation-time ensure chain (#933).
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
# The attribute-set fingerprint at the moment an import
|
|
166
|
+
# resolution attempt ran — deferred_imports_pending? compares
|
|
167
|
+
# against it to skip re-resolution on stable sets.
|
|
168
|
+
def record_import_attempt(register_id)
|
|
169
|
+
# Raw stores only — the lazy attributes() re-enters
|
|
170
|
+
# ensure_imports! and, when @models_imported was just set to
|
|
171
|
+
# false (unresolved imports), cycles through this very
|
|
172
|
+
# method forever (the uniword register_in SystemStackError).
|
|
173
|
+
register_attrs = (@register_records ||= {})[register_id]
|
|
174
|
+
(@import_attempt_fingerprints ||= {})[register_id] =
|
|
175
|
+
@attributes.size + @choice_attributes.to_a.size +
|
|
176
|
+
(register_attrs ? register_attrs[:attributes].to_a.size : 0)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def deferred_imports_pending?(register_id = nil)
|
|
180
|
+
register_id ||= Lutaml::Model::Config.default_register
|
|
181
|
+
has_importables = importable_models.any? { |_m, models| models.any? } ||
|
|
182
|
+
importable_choices.any? do |_c, imports|
|
|
183
|
+
imports.any? { |_m, models| models.any? }
|
|
169
184
|
end || restrict_attributes.any?
|
|
185
|
+
return false unless has_importables
|
|
186
|
+
|
|
187
|
+
# Unresolvable imports stay pending forever; re-running the
|
|
188
|
+
# ensure chain (and its state-defaults invalidation) on every
|
|
189
|
+
# allocation thrashed import-heavy models quadratically.
|
|
190
|
+
# Resolution re-arms only when the attribute set CHANGED
|
|
191
|
+
# since the last attempt — a late declaration after a failed
|
|
192
|
+
# resolution re-attempts; a stable set skips.
|
|
193
|
+
attempted = (@import_attempt_fingerprints ||= {})[register_id]
|
|
194
|
+
return true if attempted.nil?
|
|
195
|
+
|
|
196
|
+
current = attributes(register_id).size + @choice_attributes.to_a.size
|
|
197
|
+
current != attempted
|
|
170
198
|
end
|
|
171
199
|
|
|
172
200
|
def ensure_model_imports!(register_id = nil)
|
|
@@ -200,6 +228,7 @@ module Lutaml
|
|
|
200
228
|
end
|
|
201
229
|
|
|
202
230
|
@models_imported[register_id] = all_resolved
|
|
231
|
+
record_import_attempt(register_id)
|
|
203
232
|
end
|
|
204
233
|
|
|
205
234
|
# Ensure all choice imports are resolved for a specific register
|
|
@@ -239,6 +268,7 @@ module Lutaml
|
|
|
239
268
|
end
|
|
240
269
|
|
|
241
270
|
@choices_imported[register_id] = true if all_resolved
|
|
271
|
+
record_import_attempt(register_id)
|
|
242
272
|
# Choice imports can add attributes without passing through
|
|
243
273
|
# define_attribute_methods — recompile state defaults so the
|
|
244
274
|
# seeding and raw readers cover them (#933). Only when an
|
|
@@ -13,6 +13,12 @@ module Lutaml
|
|
|
13
13
|
case value
|
|
14
14
|
when ::Time then value
|
|
15
15
|
when ::DateTime then value.to_time
|
|
16
|
+
when ::Integer
|
|
17
|
+
# YAML 1.1 sexagesimal: psych resolves an unquoted hh:mm:ss
|
|
18
|
+
# scalar to seconds since midnight (25200 for "07:00:00").
|
|
19
|
+
# Feeding that integer through Time.parse yields a garbage
|
|
20
|
+
# date — reconstruct the wall-clock time of the current day.
|
|
21
|
+
::Date.today.to_time + value
|
|
16
22
|
else ::Time.parse(value.to_s)
|
|
17
23
|
end
|
|
18
24
|
rescue ArgumentError
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -48,6 +48,12 @@ module Lutaml
|
|
|
48
48
|
end
|
|
49
49
|
end.cache
|
|
50
50
|
|
|
51
|
+
# Name-matched element row kinds eligible for the :unqualified
|
|
52
|
+
# ns form (leptris#1560). Text rows (content) match no element
|
|
53
|
+
# name and wildcard rows carry their own :any.
|
|
54
|
+
ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
|
|
55
|
+
raw].freeze
|
|
56
|
+
|
|
51
57
|
class << self
|
|
52
58
|
def compile(model_class, register)
|
|
53
59
|
# Version gate, not a capability probe: the plan path needs
|
|
@@ -74,9 +80,13 @@ module Lutaml
|
|
|
74
80
|
# is THREAD-LOCAL (a shared in-progress set races: a concurrent
|
|
75
81
|
# same-key compile would cache false permanently — the #828
|
|
76
82
|
# lesson); the nil at the cycle point is NOT cached — the
|
|
77
|
-
# outermost build completes and caches the real verdict.
|
|
83
|
+
# outermost build completes and caches the real verdict. The
|
|
84
|
+
# :cycle stat makes the guard visible in plan_stats — without
|
|
85
|
+
# it a cyclic graph (uniword pict↔shape, sdt→sdt) reads as
|
|
86
|
+
# "nil with an empty histogram", indistinguishable from a
|
|
87
|
+
# silent build failure.
|
|
78
88
|
stack = (Thread.current[:plan_compiler_stack] ||= [])
|
|
79
|
-
return
|
|
89
|
+
return opt_out!(:cycle) if stack.include?(key)
|
|
80
90
|
|
|
81
91
|
stack.push(key)
|
|
82
92
|
begin
|
|
@@ -86,8 +96,49 @@ module Lutaml
|
|
|
86
96
|
end
|
|
87
97
|
end
|
|
88
98
|
|
|
99
|
+
# Two engine walk defects gate walk-side consumers here; both
|
|
100
|
+
# bail models to the interpretive matcher, whose exact-first-
|
|
101
|
+
# then-any-qualification precedence binds what the walk drops.
|
|
102
|
+
# The serialize side (PlanSerializer) reads the Ruby tree and
|
|
103
|
+
# stays on the fast path. leptris 1.9.320.0 (#1587) fixes
|
|
104
|
+
# both defects — the scan is skipped there and every model
|
|
105
|
+
# rides the walk again.
|
|
106
|
+
#
|
|
107
|
+
# leptris#1585: exact-URI CHILD-row ns_uri strings are not
|
|
108
|
+
# retained engine-side — once the build anchors are GC'd (a
|
|
109
|
+
# warm walk plus one GC suffices), those rows stop matching
|
|
110
|
+
# and hydration silently answers nil.
|
|
111
|
+
#
|
|
112
|
+
# leptris#1586: plain attribute rows do not leniently match
|
|
113
|
+
# namespace-qualified wire attributes (uniword fontTable:
|
|
114
|
+
# every w:name/w:val against a plain attr row hydrates nil).
|
|
115
|
+
# A plan level carrying plain attr rows under a namespaced
|
|
116
|
+
# chain therefore cannot be trusted on the walk; attribute
|
|
117
|
+
# rows with an exact URI (or :any) and plans without any
|
|
118
|
+
# namespace form are unaffected.
|
|
119
|
+
def engine_walk_safe?(plan)
|
|
120
|
+
return true if Leptris.walk_ns_safety_compatible?
|
|
121
|
+
|
|
122
|
+
tree_safe?(plan[:tree], false)
|
|
123
|
+
end
|
|
124
|
+
|
|
89
125
|
private
|
|
90
126
|
|
|
127
|
+
def tree_safe?(tree, ancestor_ns)
|
|
128
|
+
namespaced = ancestor_ns || tree[:ns].is_a?(Hash)
|
|
129
|
+
attrs = tree[:attributes] || []
|
|
130
|
+
return false if namespaced &&
|
|
131
|
+
attrs.any? { |a| !a[:ns].is_a?(Hash) && a[:ns] != :any }
|
|
132
|
+
|
|
133
|
+
(tree[:children] || []).all? do |row|
|
|
134
|
+
return false if row[:ns].is_a?(Hash)
|
|
135
|
+
|
|
136
|
+
next true unless row[:kind] == :nested && row[:plan]
|
|
137
|
+
|
|
138
|
+
tree_safe?(row[:plan], namespaced)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
91
142
|
def build(model_class, register)
|
|
92
143
|
return nil unless model_class.is_a?(Class) &&
|
|
93
144
|
model_class.include?(::Lutaml::Model::Serialize)
|
|
@@ -162,7 +213,7 @@ module Lutaml
|
|
|
162
213
|
return opt_out!(:fragment_with_ns) if fragment_needed && model_ns
|
|
163
214
|
|
|
164
215
|
if rule.attribute?
|
|
165
|
-
opt = compile_attribute_row(rule, attr, register,
|
|
216
|
+
opt = compile_attribute_row(rule, attr, register, mapping,
|
|
166
217
|
attr_rows, plan_attrs,
|
|
167
218
|
ns_attr_names, attr_local_claims)
|
|
168
219
|
return opt_out!(opt) if opt
|
|
@@ -225,7 +276,22 @@ module Lutaml
|
|
|
225
276
|
return opt_out!(:non_scalar) unless scalar_type?(attr, register)
|
|
226
277
|
|
|
227
278
|
row = { name: rule.name.to_s }
|
|
228
|
-
|
|
279
|
+
if rule.namespace_set?
|
|
280
|
+
row[:ns] = child_ns(rule, model_ns)
|
|
281
|
+
else
|
|
282
|
+
# The interpretive writer qualifies an element row
|
|
283
|
+
# by its attribute's type namespace when the rule
|
|
284
|
+
# declares none (resolve step 4). Rows without the
|
|
285
|
+
# type form inherit the tree namespace and land
|
|
286
|
+
# under the wrong default xmlns (uniword
|
|
287
|
+
# CoreProperties: <dc:title> spelled <title>).
|
|
288
|
+
type_ns_form = element_type_ns_form(rule, attr,
|
|
289
|
+
register, model_ns)
|
|
290
|
+
return opt_out!(:elem_type_ns_symbol) if type_ns_form == :bail_symbol
|
|
291
|
+
return opt_out!(:elem_type_ns_aliases) if type_ns_form == :bail_aliases
|
|
292
|
+
|
|
293
|
+
row[:ns] = type_ns_form if type_ns_form
|
|
294
|
+
end
|
|
229
295
|
# lutaml-model#88: same-name rows partitioned by the
|
|
230
296
|
# rule's discriminator ride the engine's exclusive
|
|
231
297
|
# predicate match (leptris 1.9.221+, #1272).
|
|
@@ -267,6 +333,22 @@ module Lutaml
|
|
|
267
333
|
# inherit the default xmlns, so the strict no-URI default
|
|
268
334
|
# dropped every child of a namespaced document.
|
|
269
335
|
flags << :ns_lenient
|
|
336
|
+
|
|
337
|
+
# Namespace-less model child rows bind the strict unwritten
|
|
338
|
+
# spelling (bare, or inherited default xmlns) on engines
|
|
339
|
+
# with the leptris#1560 :unqualified form — the interpretive
|
|
340
|
+
# matcher's exact no-namespace semantic, refusing prefixed
|
|
341
|
+
# spellings where the ns_lenient superset bound them.
|
|
342
|
+
# Rows with an explicit form (rule ns, type ns, :none, :any)
|
|
343
|
+
# are untouched; name-matched element row kinds only.
|
|
344
|
+
if model_ns.nil? && Leptris.ns_unqualified_rows_compatible?
|
|
345
|
+
rows.each do |r|
|
|
346
|
+
next if r[:ns]
|
|
347
|
+
|
|
348
|
+
r[:ns] = :unqualified if ROW_KINDS_NS_UNQUALIFIED.include?(r[:kind])
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
270
352
|
tree = { name: mapping.root_element.to_s,
|
|
271
353
|
attributes: plan_attrs, children: rows }
|
|
272
354
|
tree[:ns] = model_ns if model_ns
|
|
@@ -337,8 +419,8 @@ module Lutaml
|
|
|
337
419
|
# non-sole-claimant alias families have no plan-side
|
|
338
420
|
# resolution order); :blank / :inherit arrive as Symbols —
|
|
339
421
|
# no URI to express in a row.
|
|
340
|
-
def compile_attribute_row(rule, attr, register,
|
|
341
|
-
plan_attrs, ns_attr_names,
|
|
422
|
+
def compile_attribute_row(rule, attr, register, mapping,
|
|
423
|
+
attr_rows, plan_attrs, ns_attr_names,
|
|
342
424
|
attr_local_claims)
|
|
343
425
|
return :non_scalar unless scalar_type?(attr, register)
|
|
344
426
|
|
|
@@ -346,6 +428,24 @@ module Lutaml
|
|
|
346
428
|
type_ns = attr.type_namespace_class(register)
|
|
347
429
|
claim = attr_local_claims[rule.name.to_s]
|
|
348
430
|
if type_ns.nil?
|
|
431
|
+
# attribute_form_default :qualified on the model's
|
|
432
|
+
# namespace qualifies locally-declared attributes under
|
|
433
|
+
# the parent URI — the interpretive
|
|
434
|
+
# resolve_attribute_namespace contract (WML: updateFields
|
|
435
|
+
# w:val under xmlns="..."). The W3C default is
|
|
436
|
+
# :unqualified, so == :qualified means explicitly set.
|
|
437
|
+
# Rules with an explicit :blank stay unprefixed.
|
|
438
|
+
ns_class = mapping.namespace_class
|
|
439
|
+
if rule.namespace_param != :blank && ns_class &&
|
|
440
|
+
ns_class.attribute_form_default == :qualified
|
|
441
|
+
claim[:ns] += 1
|
|
442
|
+
plan_attrs << { name: rule.name.to_s,
|
|
443
|
+
ns: { exact: ns_class.uri.to_s,
|
|
444
|
+
prefix: ns_class.prefix_default&.to_s } }
|
|
445
|
+
ns_attr_names << rule.name.to_s
|
|
446
|
+
return nil
|
|
447
|
+
end
|
|
448
|
+
|
|
349
449
|
claim[:plain] += 1
|
|
350
450
|
plan_attrs << { name: rule.name.to_s }
|
|
351
451
|
return nil
|
|
@@ -435,6 +535,29 @@ module Lutaml
|
|
|
435
535
|
{ exact: uri.to_s, prefix: rule.prefix&.to_s }
|
|
436
536
|
end
|
|
437
537
|
|
|
538
|
+
# Type-namespace ns form for a scalar element row, mirroring
|
|
539
|
+
# the interpretive resolve_element_namespace step 4: the
|
|
540
|
+
# attribute's type namespace qualifies the row when the rule
|
|
541
|
+
# declares none. Rows inheriting the tree namespace (type ns
|
|
542
|
+
# nil, :inherit, or equal to the model's own URI) get no form.
|
|
543
|
+
# :blank and alias families have no row-expressible form —
|
|
544
|
+
# the model stays interpretive.
|
|
545
|
+
def element_type_ns_form(rule, attr, register, model_ns)
|
|
546
|
+
return nil if rule.namespace_set?
|
|
547
|
+
|
|
548
|
+
type_ns = attr.type_namespace_class(register)
|
|
549
|
+
return nil if type_ns.nil? || type_ns == :inherit
|
|
550
|
+
return :bail_symbol if type_ns.is_a?(Symbol)
|
|
551
|
+
return :bail_aliases unless Leptris.attr_ns_rows_compatible? &&
|
|
552
|
+
type_ns.all_uris.size == 1
|
|
553
|
+
|
|
554
|
+
form = { exact: type_ns.uri.to_s,
|
|
555
|
+
prefix: type_ns.prefix_default&.to_s }
|
|
556
|
+
return nil if form[:exact] == model_ns&.[](:exact)
|
|
557
|
+
|
|
558
|
+
form
|
|
559
|
+
end
|
|
560
|
+
|
|
438
561
|
def content_rows(rows)
|
|
439
562
|
rows.count { |r| r[:kind] == :content }
|
|
440
563
|
end
|
|
@@ -38,7 +38,7 @@ module Lutaml
|
|
|
38
38
|
return nil if plan[:ordered] && instance.element_order.nil?
|
|
39
39
|
|
|
40
40
|
doc = ::Leptris::XML::Document.create
|
|
41
|
-
ns_ctx = Namespaces.new(plan)
|
|
41
|
+
ns_ctx = Namespaces.new(instance, plan, register)
|
|
42
42
|
return nil if ns_ctx.unspellable?
|
|
43
43
|
|
|
44
44
|
root = doc.create_element(
|
|
@@ -61,13 +61,51 @@ module Lutaml
|
|
|
61
61
|
# first-encounter order — the #1558 contract on the DOM
|
|
62
62
|
# path. A nested plan's own tree ns overrides the inherited
|
|
63
63
|
# one; rows without an ns form inherit.
|
|
64
|
+
# Namespace spelling sourced from the interpretive writer's
|
|
65
|
+
# own machinery: NamespaceCollector + DeclarationPlanner
|
|
66
|
+
# decide prefix-vs-default per namespace exactly as the
|
|
67
|
+
# interpretive path does — no heuristic divergence (the
|
|
68
|
+
# uniword FontScheme/Image regressions). Option-bearing
|
|
69
|
+
# calls (prefix: true) never reach here; the serializer's
|
|
70
|
+
# plain spelling is the interpretive writer's plain
|
|
71
|
+
# spelling.
|
|
64
72
|
class Namespaces
|
|
65
73
|
attr_reader :unspellable
|
|
66
74
|
|
|
67
|
-
def initialize(plan)
|
|
75
|
+
def initialize(instance, plan, register)
|
|
68
76
|
@forms = {}
|
|
77
|
+
@hoisted = {}
|
|
69
78
|
@declared = {}
|
|
70
79
|
@unspellable = false
|
|
80
|
+
mapping = plan[:mapping]
|
|
81
|
+
# The interpretive writer plans from the BUILT element
|
|
82
|
+
# tree: absent attributes (nil values) and absent
|
|
83
|
+
# children contribute no namespace needs, so an element
|
|
84
|
+
# whose own namespace would otherwise be prefix-hoisted
|
|
85
|
+
# (W3C: prefixed attributes need a prefix declaration)
|
|
86
|
+
# falls back to default-ns format. Collecting from the
|
|
87
|
+
# instance reproduces that — collecting from nil (type
|
|
88
|
+
# analysis) hoists every declared need and diverges
|
|
89
|
+
# (`<w:b xmlns:w>` vs the interpretive `<b xmlns>` when
|
|
90
|
+
# val=true omits the only prefixed attribute).
|
|
91
|
+
ns_prefix = if instance.is_a?(::Lutaml::Model::Serialize)
|
|
92
|
+
instance.xml_namespace_prefix
|
|
93
|
+
end
|
|
94
|
+
collect_options = { mapper_class: instance.class }
|
|
95
|
+
if ns_prefix && !ns_prefix.empty?
|
|
96
|
+
collect_options[:__xml_namespace_prefix] = ns_prefix
|
|
97
|
+
end
|
|
98
|
+
needs = Lutaml::Xml::NamespaceCollector.new(register).collect(
|
|
99
|
+
instance, mapping, **collect_options
|
|
100
|
+
)
|
|
101
|
+
planner = Lutaml::Xml::DeclarationPlanner.new(register)
|
|
102
|
+
declared_plan = planner.plan(
|
|
103
|
+
instance.class, mapping, needs, options: {}
|
|
104
|
+
)
|
|
105
|
+
declared_plan.root_node&.hoisted_declarations&.each do |prefix, uri|
|
|
106
|
+
@hoisted[uri] = prefix
|
|
107
|
+
@forms[uri] = { prefix: prefix, uri: uri }
|
|
108
|
+
end
|
|
71
109
|
collect(plan[:tree])
|
|
72
110
|
end
|
|
73
111
|
|
|
@@ -76,42 +114,55 @@ module Lutaml
|
|
|
76
114
|
end
|
|
77
115
|
|
|
78
116
|
def collect(tree)
|
|
79
|
-
|
|
80
|
-
(tree[:attributes] || []).each { |
|
|
117
|
+
check(tree[:ns])
|
|
118
|
+
(tree[:attributes] || []).each { |a| check(a[:ns]) }
|
|
81
119
|
(tree[:children] || []).each do |row|
|
|
82
|
-
|
|
120
|
+
check(row[:ns]) if row[:ns].is_a?(Hash)
|
|
83
121
|
collect(row[:plan]) if row[:kind] == :nested && row[:plan]
|
|
84
122
|
end
|
|
85
123
|
end
|
|
86
124
|
|
|
87
|
-
|
|
125
|
+
# An exact form the planner did not declare cannot be
|
|
126
|
+
# spelled — the serializer falls back interpretively.
|
|
127
|
+
def check(ns_form)
|
|
88
128
|
return if ns_form.nil? || ns_form == :none || ns_form == :any
|
|
89
129
|
|
|
90
|
-
|
|
91
|
-
@
|
|
92
|
-
|
|
130
|
+
uri = ns_form[:exact]
|
|
131
|
+
@unspellable = true if uri && !@forms.key?(uri)
|
|
132
|
+
end
|
|
133
|
+
|
|
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]
|
|
93
138
|
end
|
|
94
139
|
|
|
95
|
-
# The wire spelling for a local name under this form
|
|
140
|
+
# The wire spelling for a local name under this form: the
|
|
141
|
+
# planner's hoisted prefix for the URI, or unprefixed when
|
|
142
|
+
# hoisted as the default namespace.
|
|
96
143
|
def spell(local, ns_form = nil)
|
|
97
|
-
|
|
98
|
-
|
|
144
|
+
uri = if ns_form.is_a?(Hash)
|
|
145
|
+
ns_form[:exact]
|
|
146
|
+
else
|
|
147
|
+
@forms.key?(ns_form) ? @forms[ns_form][:uri] : nil
|
|
148
|
+
end
|
|
149
|
+
prefix = uri ? @hoisted[uri] : nil
|
|
99
150
|
prefix ? "#{prefix}:#{local}" : local
|
|
100
151
|
end
|
|
101
152
|
|
|
102
153
|
def form_for(ns_form)
|
|
103
154
|
return nil if ns_form.nil? || ns_form == :none
|
|
104
155
|
|
|
105
|
-
ns_form.is_a?(Hash) ? ns_form : @forms[ns_form]
|
|
156
|
+
ns_form.is_a?(Hash) ? { prefix: @hoisted[ns_form[:exact]], uri: ns_form[:exact] } : @forms[ns_form]
|
|
106
157
|
end
|
|
107
158
|
|
|
108
|
-
def declare_on(root)
|
|
109
|
-
@
|
|
110
|
-
key =
|
|
159
|
+
def declare_on(root, _attr_form_prefix = nil)
|
|
160
|
+
@hoisted.each do |uri, prefix|
|
|
161
|
+
key = uri
|
|
111
162
|
next if key.nil? || @declared[key]
|
|
112
163
|
|
|
113
164
|
@declared[key] = true
|
|
114
|
-
root.add_namespace_definition(
|
|
165
|
+
root.add_namespace_definition(prefix, uri)
|
|
115
166
|
end
|
|
116
167
|
end
|
|
117
168
|
end
|
|
@@ -135,6 +186,8 @@ module Lutaml
|
|
|
135
186
|
inherited_ns = nil)
|
|
136
187
|
reg ||= register
|
|
137
188
|
write_attributes(element, instance, plan, reg, ns_ctx)
|
|
189
|
+
ns_index = ns_index_for(plan)
|
|
190
|
+
nested_plans = nested_plans_for(plan)
|
|
138
191
|
plan[:rows].each do |rule, attr, kind, spelling, delegate|
|
|
139
192
|
value = value_of(instance, attr, delegate)
|
|
140
193
|
next unless rule.render?(value, instance)
|
|
@@ -145,9 +198,7 @@ module Lutaml
|
|
|
145
198
|
# several occurrences serializes one element per item,
|
|
146
199
|
# as the interpretive writer does. A plain scalar skips
|
|
147
200
|
# the Array() wrap — the common case.
|
|
148
|
-
row_ns =
|
|
149
|
-
r[:name] == rule.name.to_s
|
|
150
|
-
end&.[](:ns) || inherited_ns
|
|
201
|
+
row_ns = ns_index[row_name(rule, spelling)] || inherited_ns
|
|
151
202
|
if value.is_a?(::Array)
|
|
152
203
|
value.each do |item|
|
|
153
204
|
add_leaf(element,
|
|
@@ -162,9 +213,7 @@ module Lutaml
|
|
|
162
213
|
attrs: rule.when_attribute)
|
|
163
214
|
end
|
|
164
215
|
when :collection_native, :collection_cb
|
|
165
|
-
row_ns =
|
|
166
|
-
r[:name] == rule.name.to_s
|
|
167
|
-
end&.[](:ns) || inherited_ns
|
|
216
|
+
row_ns = ns_index[rule.name.to_s] || inherited_ns
|
|
168
217
|
Array(value).each do |item|
|
|
169
218
|
add_leaf(element,
|
|
170
219
|
ns_ctx ? ns_ctx.spell(rule.name.to_s, row_ns) : rule.name.to_s,
|
|
@@ -173,7 +222,8 @@ module Lutaml
|
|
|
173
222
|
end
|
|
174
223
|
when :nested, :ordered_deferred
|
|
175
224
|
Array(value).each do |item|
|
|
176
|
-
child_plan =
|
|
225
|
+
child_plan = nested_plans[item.class] ||
|
|
226
|
+
(nested_plans[item.class] = PlanCompiler.compile(item.class, reg))
|
|
177
227
|
child_ns = child_plan[:tree][:ns] || inherited_ns
|
|
178
228
|
child = element.create_child(
|
|
179
229
|
ns_ctx ? ns_ctx.spell(child_plan[:tree][:name], child_ns) : child_plan[:tree][:name],
|
|
@@ -195,6 +245,25 @@ module Lutaml
|
|
|
195
245
|
end
|
|
196
246
|
end
|
|
197
247
|
|
|
248
|
+
# Per-plan lazy indexes: the name→ns lookup and the
|
|
249
|
+
# per-class child-plan resolution are per-instance calls on
|
|
250
|
+
# the serialize hot path — memoized once per compiled plan
|
|
251
|
+
# they cost nothing per row (the old per-row children#find
|
|
252
|
+
# plus PlanCompiler key arrays were ~4 allocs/row).
|
|
253
|
+
def ns_index_for(plan)
|
|
254
|
+
plan[:ns_index] ||= begin
|
|
255
|
+
h = {}
|
|
256
|
+
(plan[:tree][:children] || []).each do |r|
|
|
257
|
+
h[r[:name]] = r[:ns] unless h.key?(r[:name])
|
|
258
|
+
end
|
|
259
|
+
h
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def nested_plans_for(plan)
|
|
264
|
+
plan[:nested_plans] ||= {}
|
|
265
|
+
end
|
|
266
|
+
|
|
198
267
|
# Element_order-driven emission (the interpretive order
|
|
199
268
|
# applier's contract): text runs and comments verbatim, each
|
|
200
269
|
# element entry consuming the next item of its collection
|
|
@@ -276,7 +345,8 @@ module Lutaml
|
|
|
276
345
|
end
|
|
277
346
|
next unless item
|
|
278
347
|
|
|
279
|
-
child_plan =
|
|
348
|
+
child_plan = nested_plans_for(plan)[item.class] ||
|
|
349
|
+
(nested_plans_for(plan)[item.class] = PlanCompiler.compile(item.class, reg))
|
|
280
350
|
child = element.create_child(child_plan[:tree][:name])
|
|
281
351
|
if child_plan[:ordered]
|
|
282
352
|
return nil unless build_ordered(child, item, child_plan,
|
|
@@ -300,11 +370,21 @@ module Lutaml
|
|
|
300
370
|
def write_attributes(element, instance, plan, reg = nil,
|
|
301
371
|
ns_ctx = nil)
|
|
302
372
|
recorded = instance.attribute_order
|
|
303
|
-
attr_prefixes =
|
|
304
|
-
if ns_ctx
|
|
373
|
+
attr_prefixes = nil
|
|
374
|
+
if ns_ctx && !(plan[:tree][:attributes] || []).empty?
|
|
375
|
+
attr_prefixes = {}
|
|
305
376
|
(plan[:tree][:attributes] || []).each do |a|
|
|
306
377
|
form = a[:ns].is_a?(Hash) ? a[:ns] : nil
|
|
307
|
-
|
|
378
|
+
# Namespaced attributes always spell prefixed (W3C:
|
|
379
|
+
# attributes cannot use the default namespace) — the
|
|
380
|
+
# interpretive writer emits the type namespace's own
|
|
381
|
+
# prefix even when the element hoists the same URI as
|
|
382
|
+
# the default namespace (updateFields w:val under
|
|
383
|
+
# xmlns="...").
|
|
384
|
+
attr_prefixes[a[:name]] =
|
|
385
|
+
if form
|
|
386
|
+
form[:prefix] || ns_ctx.prefix_for(form[:exact])
|
|
387
|
+
end
|
|
308
388
|
end
|
|
309
389
|
end
|
|
310
390
|
if recorded && !recorded.empty?
|
|
@@ -316,13 +396,13 @@ module Lutaml
|
|
|
316
396
|
plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
|
|
317
397
|
names.each do |name|
|
|
318
398
|
write_attribute(element, instance, name, by_name[name],
|
|
319
|
-
reg, attr_prefixes[name])
|
|
399
|
+
reg, attr_prefixes && attr_prefixes[name])
|
|
320
400
|
end
|
|
321
401
|
else
|
|
322
402
|
plan[:attr_rows].each do |rule, attr, _sp, delegate|
|
|
323
403
|
write_attribute(element, instance, rule.name.to_s,
|
|
324
404
|
[rule, attr, delegate], reg,
|
|
325
|
-
attr_prefixes[rule.name.to_s])
|
|
405
|
+
attr_prefixes && attr_prefixes[rule.name.to_s])
|
|
326
406
|
end
|
|
327
407
|
end
|
|
328
408
|
end
|
|
@@ -16,6 +16,7 @@ module Lutaml
|
|
|
16
16
|
register = Lutaml::Model::Config.default_register
|
|
17
17
|
plan = PlanCompiler.compile(model_class, register)
|
|
18
18
|
return nil unless plan
|
|
19
|
+
return nil unless PlanCompiler.engine_walk_safe?(plan)
|
|
19
20
|
|
|
20
21
|
document = ::Leptris::XML.parse(xml)
|
|
21
22
|
root = document.root
|
data/lib/lutaml/xml/leptris.rb
CHANGED
|
@@ -33,6 +33,23 @@ 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
|
+
|
|
45
|
+
# The :unqualified child-row ns form (leptris#1560: matches only
|
|
46
|
+
# the UNWRITTEN spelling — bare or inherited-default-ns — and
|
|
47
|
+
# refuses prefixed spellings, the interpretive matcher's exact
|
|
48
|
+
# no-namespace semantic) ships in binding 1.9.323.0. Older
|
|
49
|
+
# engines keep namespace-less model rows on the ns_lenient
|
|
50
|
+
# superset (local-name matching under any qualification).
|
|
51
|
+
MIN_LEPTRIS_NS_UNQUALIFIED_VERSION = "1.9.323.0"
|
|
52
|
+
|
|
36
53
|
def self.plan_path_compatible?
|
|
37
54
|
return false unless defined?(Gem)
|
|
38
55
|
|
|
@@ -61,6 +78,25 @@ module Lutaml
|
|
|
61
78
|
spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WILDCARD_VERSION)
|
|
62
79
|
end
|
|
63
80
|
|
|
81
|
+
# Memoized: consulted per parse on the walk gate hot path, and
|
|
82
|
+
# the loaded leptris spec cannot change within a process.
|
|
83
|
+
@walk_ns_safety = nil
|
|
84
|
+
def self.walk_ns_safety_compatible?
|
|
85
|
+
return @walk_ns_safety unless @walk_ns_safety.nil?
|
|
86
|
+
return (@walk_ns_safety = false) unless defined?(Gem)
|
|
87
|
+
|
|
88
|
+
spec = Gem.loaded_specs["leptris"]
|
|
89
|
+
@walk_ns_safety =
|
|
90
|
+
!!(spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_WALK_NS_SAFETY_VERSION))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.ns_unqualified_rows_compatible?
|
|
94
|
+
return false unless defined?(Gem)
|
|
95
|
+
|
|
96
|
+
spec = Gem.loaded_specs["leptris"]
|
|
97
|
+
spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_NS_UNQUALIFIED_VERSION)
|
|
98
|
+
end
|
|
99
|
+
|
|
64
100
|
autoload :PlanCompiler, "lutaml/xml/leptris/plan_compiler"
|
|
65
101
|
autoload :PlanHydrator, "lutaml/xml/leptris/plan_hydrator"
|
|
66
102
|
autoload :PlanOrder, "lutaml/xml/leptris/plan_order"
|
|
@@ -384,21 +384,15 @@ module Lutaml
|
|
|
384
384
|
value = if !rule.attribute? && rule.name.nil? &&
|
|
385
385
|
xml_mapping.any_element_rule.equal?(rule)
|
|
386
386
|
# map_any_element: every child element no explicit
|
|
387
|
-
# element rule claims, in document order
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
# re-finds by name, losing
|
|
392
|
-
#
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
xml_mapping.mappings(effective_register)
|
|
396
|
-
.select { |r| !r.attribute? && r.name }
|
|
397
|
-
.flat_map { |r| [r.name.to_s] },
|
|
387
|
+
# element rule claims, in document order (claimed
|
|
388
|
+
# set + scalar text extraction live in
|
|
389
|
+
# any_element_members). A name-less MappingRule is
|
|
390
|
+
# otherwise indistinguishable from a content rule;
|
|
391
|
+
# name-derived lookup re-finds by name, losing
|
|
392
|
+
# order and double-picking.
|
|
393
|
+
any_element_members(
|
|
394
|
+
doc, xml_mapping, attr, effective_register
|
|
398
395
|
)
|
|
399
|
-
doc.element_children.reject do |child|
|
|
400
|
-
claimed.include?(child.unprefixed_name)
|
|
401
|
-
end
|
|
402
396
|
elsif rule.raw_mapping?
|
|
403
397
|
scoped_raw_inner_xml(doc, xml_mapping)
|
|
404
398
|
elsif rule.content_mapping?
|
|
@@ -1590,6 +1584,36 @@ effective_register = lutaml_register)
|
|
|
1590
1584
|
end
|
|
1591
1585
|
end
|
|
1592
1586
|
|
|
1587
|
+
# map_any_element members: every child element no explicit
|
|
1588
|
+
# element rule claims, in document order; scalar targets take
|
|
1589
|
+
# the captured elements' text — the same normalize step scalar
|
|
1590
|
+
# elements get (the cast pipeline receives strings, not wrappers
|
|
1591
|
+
# whose to_s is their inspect); Serializable targets keep the
|
|
1592
|
+
# wrappers and cast into instances.
|
|
1593
|
+
def any_element_members(doc, mapping, attr, register)
|
|
1594
|
+
claimed = Set.new(
|
|
1595
|
+
mapping.mappings(register)
|
|
1596
|
+
.select { |r| !r.attribute? && r.name }
|
|
1597
|
+
.flat_map { |r| [r.name.to_s] },
|
|
1598
|
+
)
|
|
1599
|
+
unclaimed = doc.element_children.reject do |child|
|
|
1600
|
+
claimed.include?(child.unprefixed_name)
|
|
1601
|
+
end
|
|
1602
|
+
return unclaimed if attr.nil? ||
|
|
1603
|
+
attr_type_is_serializable(attr, register)
|
|
1604
|
+
|
|
1605
|
+
unclaimed.map do |child|
|
|
1606
|
+
child_text = child.nil_element? ? nil : child&.text
|
|
1607
|
+
child_cdata = child&.cdata
|
|
1608
|
+
text = if child_text.is_a?(Array) || child_cdata.is_a?(Array)
|
|
1609
|
+
nil
|
|
1610
|
+
else
|
|
1611
|
+
child_text&.+ child_cdata
|
|
1612
|
+
end
|
|
1613
|
+
text.nil? ? child : text
|
|
1614
|
+
end
|
|
1615
|
+
end
|
|
1616
|
+
|
|
1593
1617
|
def attr_type_is_serializable(attr, effective_register)
|
|
1594
1618
|
attr&.serializable_type?(effective_register) || false
|
|
1595
1619
|
end
|
|
@@ -409,9 +409,25 @@ module Lutaml
|
|
|
409
409
|
# Handle collections and arrays
|
|
410
410
|
if child_instance.is_a?(Array) || child_instance.is_a?(Lutaml::Model::Collection)
|
|
411
411
|
instances = child_instance.is_a?(Lutaml::Model::Collection) ? child_instance.collection : child_instance
|
|
412
|
-
# Collect needs from
|
|
412
|
+
# Collect needs from the FIRST instance of each distinct
|
|
413
|
+
# class and merge once — instances of one class share the
|
|
414
|
+
# mapping graph, so re-collecting every member re-walks
|
|
415
|
+
# identical structure (row-heavy models spent most of
|
|
416
|
+
# their serialize budget here). Value-dependent divergences
|
|
417
|
+
# in later members surface as unspellable forms on the
|
|
418
|
+
# serializer and fall back interpretively, never as wrong
|
|
419
|
+
# output. Merge-once is equivalent to merge-N: every read
|
|
420
|
+
# path of the merged needs is any?/set-based.
|
|
413
421
|
merged_needs = NamespaceNeeds.new
|
|
422
|
+
seen_classes = nil
|
|
414
423
|
instances.each do |item|
|
|
424
|
+
item_class = item.class
|
|
425
|
+
if seen_classes
|
|
426
|
+
next if seen_classes[item_class]
|
|
427
|
+
else
|
|
428
|
+
seen_classes = {}
|
|
429
|
+
end
|
|
430
|
+
seen_classes[item_class] = true
|
|
415
431
|
item_ns_prefix = if item.is_a?(::Lutaml::Model::Serialize)
|
|
416
432
|
item.xml_namespace_prefix
|
|
417
433
|
end
|
|
@@ -8,13 +8,20 @@ module Lutaml
|
|
|
8
8
|
module Serializers
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
|
+
# The default :xml String serializer is the identity for String
|
|
12
|
+
# values. Attribute#serialize_value fast-paths String→String
|
|
13
|
+
# through an identity comparison with this exact proc, so a
|
|
14
|
+
# consumer override of the serializer disables that fast path
|
|
15
|
+
# automatically.
|
|
16
|
+
STRING_XML_TO = ->(inst) { inst.value&.to_s }.freeze
|
|
17
|
+
|
|
11
18
|
def register_all!
|
|
12
19
|
v = Lutaml::Model::Type::Value
|
|
13
20
|
|
|
14
21
|
# String — value&.to_s
|
|
15
22
|
v.register_format_type_serializer(
|
|
16
23
|
:xml, Lutaml::Model::Type::String,
|
|
17
|
-
to:
|
|
24
|
+
to: STRING_XML_TO,
|
|
18
25
|
from: ->(val) { Lutaml::Model::Type::String.cast(val) }
|
|
19
26
|
)
|
|
20
27
|
|
|
@@ -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
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module GenerateManySpec
|
|
6
|
+
class Source < Lutaml::Model::Serializable
|
|
7
|
+
attribute :name, :string
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class Other < Lutaml::Model::Serializable
|
|
11
|
+
attribute :label, :string
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Consumer < Lutaml::Model::Serializable
|
|
15
|
+
attribute :inner, Source
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
RSpec.describe Lutaml::Model::Schema::BaseSchema do
|
|
20
|
+
describe ".generate_many" do
|
|
21
|
+
it "covers every root in one document without a top-level $ref" do
|
|
22
|
+
parsed = JSON.parse(
|
|
23
|
+
Lutaml::Model::Schema::JsonSchema.generate_many(
|
|
24
|
+
[GenerateManySpec::Source, GenerateManySpec::Other],
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
expect(parsed).not_to have_key("$ref")
|
|
29
|
+
expect(parsed["$defs"]).to have_key("GenerateManySpec_Source")
|
|
30
|
+
expect(parsed["$defs"]).to have_key("GenerateManySpec_Other")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "registers a class shared by several roots once" do
|
|
34
|
+
parsed = JSON.parse(
|
|
35
|
+
Lutaml::Model::Schema::JsonSchema.generate_many(
|
|
36
|
+
[GenerateManySpec::Source, GenerateManySpec::Consumer],
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(parsed["$defs"].keys.size).to eq(2)
|
|
41
|
+
expect(parsed["$defs"]).to have_key("GenerateManySpec_Consumer")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "formats YAML output" do
|
|
45
|
+
yaml = Lutaml::Model::Schema::YamlSchema.generate_many(
|
|
46
|
+
[GenerateManySpec::Source, GenerateManySpec::Other],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
expect(yaml).to start_with("%YAML")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe "Generator::DefinitionsCollection.from_classes" do
|
|
54
|
+
it "builds one collection covering all roots" do
|
|
55
|
+
collection = Lutaml::Model::Schema::Generator::DefinitionsCollection
|
|
56
|
+
.from_classes([GenerateManySpec::Source, GenerateManySpec::Consumer])
|
|
57
|
+
|
|
58
|
+
expect(collection.to_schema.keys.size).to eq(2)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/spec/person_spec.rb
CHANGED
|
@@ -115,13 +115,14 @@ RSpec.describe Person do
|
|
|
115
115
|
expect(model.to_yaml).to eq(attributes_yaml.to_yaml)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
|
|
118
|
+
# Root cause of the long-pended time-only misparse: psych resolves an
|
|
119
|
+
# unquoted hh:mm:ss scalar as a YAML 1.1 sexagesimal integer (25200
|
|
120
|
+
# for "07:00:00"), and the :time cast fed that integer through
|
|
121
|
+
# Time.parse, which reads "25200" as a date (the fixed 2025-07-19
|
|
122
|
+
# garbage). The cast now reads sexagesimal integers as seconds since
|
|
123
|
+
# midnight — both the psych and yeptris paths answer today-07:00.
|
|
124
|
+
# Evidence chain: moxml#336, yeptris-ruby#265.
|
|
125
|
+
it "deserializes from YAML" do
|
|
125
126
|
yaml = attributes_yaml.to_yaml
|
|
126
127
|
person = described_class.from_yaml(yaml)
|
|
127
128
|
expect(person.first_name).to eq("John")
|
|
@@ -133,8 +134,8 @@ RSpec.describe Person do
|
|
|
133
134
|
expect(person.active).to be true
|
|
134
135
|
end
|
|
135
136
|
|
|
136
|
-
# Same
|
|
137
|
-
|
|
137
|
+
# Same sexagesimal time-only scalar as above.
|
|
138
|
+
it "deserializes from YAML array" do
|
|
138
139
|
yaml = [attributes_yaml.dup, attributes_yaml.dup].to_yaml
|
|
139
140
|
|
|
140
141
|
persons = described_class.from_yaml(yaml)
|
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.98
|
|
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-10 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
|
|
@@ -1795,6 +1796,7 @@ files:
|
|
|
1795
1796
|
- spec/lutaml/model/root_mappings_spec.rb
|
|
1796
1797
|
- spec/lutaml/model/rule_value_extractor_spec.rb
|
|
1797
1798
|
- spec/lutaml/model/runtime_compatibility_spec.rb
|
|
1799
|
+
- spec/lutaml/model/schema/generate_many_spec.rb
|
|
1798
1800
|
- spec/lutaml/model/schema/generation_regressions_spec.rb
|
|
1799
1801
|
- spec/lutaml/model/schema/generator/definitions_collection_spec.rb
|
|
1800
1802
|
- spec/lutaml/model/schema/json_schema_spec.rb
|