lutaml-model 0.8.50 → 0.8.52
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 +25 -3
- data/lib/lutaml/key_value/transform.rb +197 -0
- data/lib/lutaml/model/configuration.rb +1 -1
- data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/plan_compiler.rb +15 -1
- data/spec/lutaml/key_value/group_instantiate_spec.rb +302 -0
- data/spec/lutaml/xml/plan_fast_path_spec.rb +36 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 92e0193fa58094112522b5a7717aed2f568d77096eb0ae3bc1a246be28c05bf0
|
|
4
|
+
data.tar.gz: 20dcd124c22c695cc0a8fca65d04422ac84f468515ef153d909e5a6dcf5eac67
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cc3e71dceb6ae87db00453efbd905de28dc10d528cd38426b29c2c0da1a67d64d41a59095e45a9da1daa97888a4d001c8211b0ad64a3a011ef46949e71206a6
|
|
7
|
+
data.tar.gz: c444800a986c0e34bc1502fd671f53d7ff4776377d89b62225b857c923f4b97058042094145caf0d0a54db37576bedd546a733e114a444118ff2e13241092d83
|
|
@@ -57,9 +57,31 @@ RUBY_YJIT_ENABLE=1 ruby app.rb
|
|
|
57
57
|
RubyVM::YJIT.enable
|
|
58
58
|
----
|
|
59
59
|
|
|
60
|
-
The
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
The XML plan fast path moves work out of Ruby and into the engine,
|
|
61
|
+
which shrinks the YJIT win — enabling both is still net faster than
|
|
62
|
+
either alone.
|
|
63
|
+
|
|
64
|
+
== The XML plan fast path (default on)
|
|
65
|
+
|
|
66
|
+
When the XML adapter resolves to leptris, XML (de)serialization
|
|
67
|
+
compiles the model's mapping into a leptris descriptor plan and runs
|
|
68
|
+
the whole document through one native pass — measured 6.7x parse and
|
|
69
|
+
4x serialize with -76% / -80% allocations on the plan-path corpus.
|
|
70
|
+
Models the plan cannot express exactly (same-name discriminator
|
|
71
|
+
partitions, namespace-qualified models with deferred-rule shapes) and
|
|
72
|
+
shapes with custom semantics (custom methods, polymorphism, unions,
|
|
73
|
+
ordered children) hydrate through the interpretive pipeline or a
|
|
74
|
+
per-subtree fragment of it, unchanged.
|
|
75
|
+
|
|
76
|
+
The path is on by default and is a no-op without leptris (standard
|
|
77
|
+
adapters keep the classic pipeline). Opt out for the whole process:
|
|
78
|
+
|
|
79
|
+
[source,ruby]
|
|
80
|
+
----
|
|
81
|
+
Lutaml::Model::Config.configure do |config|
|
|
82
|
+
config.xml_plan_fast_path = false
|
|
83
|
+
end
|
|
84
|
+
----
|
|
63
85
|
|
|
64
86
|
== Selecting adapters explicitly
|
|
65
87
|
|
|
@@ -12,6 +12,20 @@ module Lutaml
|
|
|
12
12
|
model_class, lutaml_register
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
+
# TODO.max-perf/37: eligible models hydrate in one pass —
|
|
16
|
+
# collect raw values per rule, recurse into eligible child
|
|
17
|
+
# models, build every instance through the bulk constructor.
|
|
18
|
+
# Falls back to the per-rule walk for anything ineligible.
|
|
19
|
+
if !options.key?(:mappings) && data.is_a?(::Hash) &&
|
|
20
|
+
%i[json yaml toml hash].include?(format) &&
|
|
21
|
+
(group = self.class.kv_group_plan(model_class, format,
|
|
22
|
+
lutaml_register)) &&
|
|
23
|
+
(instance = kv_group_build(group, data, format, child_register,
|
|
24
|
+
options))
|
|
25
|
+
root_and_parent_assignment(instance, options)
|
|
26
|
+
return instance
|
|
27
|
+
end
|
|
28
|
+
|
|
15
29
|
if model_class.include?(Lutaml::Model::Serialize)
|
|
16
30
|
instance = model_class.new(lutaml_register: child_register)
|
|
17
31
|
else
|
|
@@ -76,6 +90,189 @@ module Lutaml
|
|
|
76
90
|
hash.keys == [""] ? hash[""] : hash
|
|
77
91
|
end
|
|
78
92
|
|
|
93
|
+
# ---- TODO.max-perf/37: group-then-instantiate fast path ----
|
|
94
|
+
|
|
95
|
+
# [model class, format, register] -> rows or false (ineligible).
|
|
96
|
+
# Rows: [[rule, attr, kind, child_rows]] with kind :scalar or
|
|
97
|
+
# :model; child_rows is the child model's own row set. Cycle-safe:
|
|
98
|
+
# an in-progress model resolves false, so self-referential models
|
|
99
|
+
# take the interpretive walk.
|
|
100
|
+
# Concurrent::Map under threaded MRI, plain Hash under Opal
|
|
101
|
+
# (Concurrent is unavailable there) — the RULE_RECORDS pattern.
|
|
102
|
+
# Writes are idempotent (the same deterministic plan is computed),
|
|
103
|
+
# so a lost race costs a duplicate build, never a wrong value.
|
|
104
|
+
KV_GROUP_PLANS = if Lutaml::Model.opal?
|
|
105
|
+
{}
|
|
106
|
+
else
|
|
107
|
+
Lutaml::Model::RuntimeCompatibility
|
|
108
|
+
.require_native("concurrent")
|
|
109
|
+
Concurrent::Map.new
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.kv_group_plan(model_class, format, register)
|
|
113
|
+
# Context generation: specs (and apps) reset registers between
|
|
114
|
+
# parses; a plan cached across a reset references dead attribute
|
|
115
|
+
# objects. The generation key retires stale plans for free.
|
|
116
|
+
key = [model_class, format, register,
|
|
117
|
+
Lutaml::Model::GlobalContext.context_generation]
|
|
118
|
+
plan = KV_GROUP_PLANS[key]
|
|
119
|
+
return plan unless plan.nil?
|
|
120
|
+
|
|
121
|
+
# Cycle detection rides a THREAD-LOCAL recursion stack: a shared
|
|
122
|
+
# in-progress set races (a concurrent same-key build would cache
|
|
123
|
+
# false permanently), and the stack is per-build by definition.
|
|
124
|
+
# The false at the cycle point is NOT cached — the outermost
|
|
125
|
+
# build completes and caches the model's real verdict.
|
|
126
|
+
stack = (Thread.current[:kv_group_plan_stack] ||= [])
|
|
127
|
+
return false if stack.include?(key)
|
|
128
|
+
|
|
129
|
+
stack.push(key)
|
|
130
|
+
begin
|
|
131
|
+
KV_GROUP_PLANS[key] = build_kv_group_plan(model_class, format,
|
|
132
|
+
register)
|
|
133
|
+
ensure
|
|
134
|
+
stack.pop
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def self.build_kv_group_plan(model_class, format, register)
|
|
139
|
+
return false unless model_class.is_a?(Class) &&
|
|
140
|
+
model_class.include?(Lutaml::Model::Serialize)
|
|
141
|
+
|
|
142
|
+
mapping = model_class.mappings_for(format, register)
|
|
143
|
+
return false if mapping.nil?
|
|
144
|
+
|
|
145
|
+
attrs = model_class.attributes(register)
|
|
146
|
+
rows = nil
|
|
147
|
+
mapping.mappings(register).each do |rule|
|
|
148
|
+
eligible = !rule.name.nil? && !rule.multiple_mappings? &&
|
|
149
|
+
rule.delegate.nil? &&
|
|
150
|
+
!rule.has_custom_method_for_deserialization? &&
|
|
151
|
+
!rule.raw_mapping? && !rule.root_mapping? &&
|
|
152
|
+
!rule.hash_mappings && rule.child_mappings.nil? &&
|
|
153
|
+
rule.when_attribute.empty? &&
|
|
154
|
+
!(if rule.polymorphic.is_a?(::Hash)
|
|
155
|
+
!rule.polymorphic.empty?
|
|
156
|
+
else
|
|
157
|
+
!!rule.polymorphic
|
|
158
|
+
end) &&
|
|
159
|
+
rule.transform.is_a?(::Hash) && rule.transform.empty? &&
|
|
160
|
+
rule.value_map(:from) ==
|
|
161
|
+
Lutaml::Model::Serialize::DEFAULT_VALUE_MAP
|
|
162
|
+
return false unless eligible
|
|
163
|
+
|
|
164
|
+
attr = attrs[rule.to]
|
|
165
|
+
return false if attr.nil? || attr.derived?
|
|
166
|
+
# Declared ranges keep their eager validation on the
|
|
167
|
+
# interpretive walk; polymorphic/union/custom-collection
|
|
168
|
+
# dispatch and registered type substitutions own their cast
|
|
169
|
+
# (the per-rule walk threads them through cast options).
|
|
170
|
+
return false if attr.collection? && attr.collection.is_a?(Range)
|
|
171
|
+
return false if attr.polymorphic? || attr.union? ||
|
|
172
|
+
attr.custom_collection?
|
|
173
|
+
|
|
174
|
+
type = attr.type(register)
|
|
175
|
+
return false if Lutaml::Model::GlobalContext.context(register)
|
|
176
|
+
.substitution_for(type).any?
|
|
177
|
+
|
|
178
|
+
if type.is_a?(Class) && type.include?(Lutaml::Model::Serialize)
|
|
179
|
+
child_rows = kv_group_plan(type, format,
|
|
180
|
+
Lutaml::Model::Register
|
|
181
|
+
.resolve_for_child(type, register))
|
|
182
|
+
return false unless child_rows
|
|
183
|
+
|
|
184
|
+
rows ||= []
|
|
185
|
+
rows << [rule, attr, :model, type, child_rows]
|
|
186
|
+
elsif type.is_a?(Class) && type < Lutaml::Model::Type::Value &&
|
|
187
|
+
!attr.value_policy.whole_value?(type) &&
|
|
188
|
+
!Lutaml::Model::Attribute.custom_from_probe?(type)
|
|
189
|
+
rows ||= []
|
|
190
|
+
rows << [rule, attr, :scalar, nil, nil]
|
|
191
|
+
else
|
|
192
|
+
return false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
# No eligible rows at all (empty mapping) — nothing to gain.
|
|
196
|
+
rows
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Build values bottom-up, then ONE instance per model: present
|
|
200
|
+
# keys through the casting setters, ABSENT rules through the real
|
|
201
|
+
# per-rule walk (their extractor/defaults/sentinel semantics are
|
|
202
|
+
# the walk's own — reproducing them here would fork them). The
|
|
203
|
+
# per-rule walk is eliminated exactly for the present-key hot
|
|
204
|
+
# path. Returns nil (caller falls back) on any shape the plan
|
|
205
|
+
# does not cover, e.g. a non-Hash item where a model was expected.
|
|
206
|
+
def kv_group_build(rows, doc, format, register, options)
|
|
207
|
+
setters = []
|
|
208
|
+
children = []
|
|
209
|
+
absent = []
|
|
210
|
+
rows.each do |rule, attr, kind, type, child_rows|
|
|
211
|
+
unless Lutaml::Model::Utils.string_or_symbol_key?(doc, rule.name)
|
|
212
|
+
absent << rule
|
|
213
|
+
next
|
|
214
|
+
end
|
|
215
|
+
v = Lutaml::Model::Utils.fetch_str_or_sym(doc, rule.name)
|
|
216
|
+
|
|
217
|
+
if kind == :scalar
|
|
218
|
+
setters << [:"#{rule.to}=", v]
|
|
219
|
+
next
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
child_register = Lutaml::Model::Register.resolve_for_child(type,
|
|
223
|
+
register)
|
|
224
|
+
if attr.collection?
|
|
225
|
+
# A present-but-nil collection reaches the per-rule walk,
|
|
226
|
+
# which owns the sentinel interplay for that edge
|
|
227
|
+
# (render_nil :as_empty semantics).
|
|
228
|
+
return nil if v.nil?
|
|
229
|
+
|
|
230
|
+
items = v.is_a?(::Array) ? v : [v]
|
|
231
|
+
built = []
|
|
232
|
+
items.each do |item|
|
|
233
|
+
return nil unless item.is_a?(::Hash)
|
|
234
|
+
|
|
235
|
+
child = self.class.kv_group_instance(type, child_rows, item,
|
|
236
|
+
format, child_register,
|
|
237
|
+
options)
|
|
238
|
+
return nil if child.nil?
|
|
239
|
+
|
|
240
|
+
built << child
|
|
241
|
+
end
|
|
242
|
+
setters << [:"#{rule.to}=", built]
|
|
243
|
+
children.concat(built)
|
|
244
|
+
else
|
|
245
|
+
return nil unless v.is_a?(::Hash)
|
|
246
|
+
|
|
247
|
+
child = self.class.kv_group_instance(type, child_rows, v,
|
|
248
|
+
format, child_register,
|
|
249
|
+
options)
|
|
250
|
+
return nil if child.nil?
|
|
251
|
+
|
|
252
|
+
setters << [:"#{rule.to}=", child]
|
|
253
|
+
children << child
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
instance = model_class.new(lutaml_register: register)
|
|
257
|
+
setters.each { |name, value| instance.public_send(name, value) }
|
|
258
|
+
absent.each do |rule|
|
|
259
|
+
process_mapping_rule(doc, instance, format, rule, options, nil)
|
|
260
|
+
end
|
|
261
|
+
children.each do |child|
|
|
262
|
+
child.lutaml_parent = instance
|
|
263
|
+
child.lutaml_root ||= instance.lutaml_root || instance
|
|
264
|
+
end
|
|
265
|
+
instance
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def self.kv_group_instance(model_class, rows, doc, format, register,
|
|
269
|
+
options)
|
|
270
|
+
# cached_transform is per (class, register); format rides the
|
|
271
|
+
# call, not the cache.
|
|
272
|
+
cached_transform(model_class, register)
|
|
273
|
+
.kv_group_build(rows, doc, format, register, options)
|
|
274
|
+
end
|
|
275
|
+
|
|
79
276
|
private
|
|
80
277
|
|
|
81
278
|
def process_rule!(instance, rule, hash, format, _mappings, options)
|
|
@@ -463,8 +463,16 @@ context = nil, pre_cast: false)
|
|
|
463
463
|
# that class's attribute, or nil when only a casting writer
|
|
464
464
|
# exists (custom writers, reflective names, enum shorthands).
|
|
465
465
|
# Class-level because mapping rules may be frozen (see the
|
|
466
|
-
# name-string note above TRANSFORM_DISPATCH).
|
|
467
|
-
|
|
466
|
+
# name-string note above TRANSFORM_DISPATCH). Concurrent::Map
|
|
467
|
+
# under threaded MRI, plain identity Hash under Opal — writes are
|
|
468
|
+
# idempotent (the same verdict is recomputed).
|
|
469
|
+
PARSED_ASSIGN_WRITERS = if Lutaml::Model.opal?
|
|
470
|
+
{}.compare_by_identity
|
|
471
|
+
else
|
|
472
|
+
Lutaml::Model::RuntimeCompatibility
|
|
473
|
+
.require_native("concurrent")
|
|
474
|
+
Concurrent::Map.new
|
|
475
|
+
end
|
|
468
476
|
|
|
469
477
|
def self.transform_dispatch(rule, attr)
|
|
470
478
|
per_attr = TRANSFORM_DISPATCH[rule]
|
data/lib/lutaml/model/version.rb
CHANGED
|
@@ -39,7 +39,21 @@ module Lutaml
|
|
|
39
39
|
key = [model_class, register]
|
|
40
40
|
return PLAN_CACHE[key] if PLAN_CACHE.key?(key)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
# Cycle guard: a self-referential model (JATS sec-in-sec) must
|
|
43
|
+
# resolve to nil — the interpretive pipeline owns it. The guard
|
|
44
|
+
# is THREAD-LOCAL (a shared in-progress set races: a concurrent
|
|
45
|
+
# same-key compile would cache false permanently — the #828
|
|
46
|
+
# lesson); the nil at the cycle point is NOT cached — the
|
|
47
|
+
# outermost build completes and caches the real verdict.
|
|
48
|
+
stack = (Thread.current[:plan_compiler_stack] ||= [])
|
|
49
|
+
return nil if stack.include?(key)
|
|
50
|
+
|
|
51
|
+
stack.push(key)
|
|
52
|
+
begin
|
|
53
|
+
PLAN_CACHE[key] = build(model_class, register)
|
|
54
|
+
ensure
|
|
55
|
+
stack.pop
|
|
56
|
+
end
|
|
43
57
|
end
|
|
44
58
|
|
|
45
59
|
private
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
# TODO.max-perf/37: eligible models hydrate in ONE pass — present keys
|
|
6
|
+
# collected per rule, child models recursed, one instance per model.
|
|
7
|
+
# Absent rules keep the real per-rule walk; anything ineligible keeps
|
|
8
|
+
# the whole walk.
|
|
9
|
+
class KvGroupPart < Lutaml::Model::Serializable
|
|
10
|
+
attribute :label, :string
|
|
11
|
+
attribute :count, :integer
|
|
12
|
+
|
|
13
|
+
json do
|
|
14
|
+
map "label", to: :label
|
|
15
|
+
map "count", to: :count
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
hsh do
|
|
19
|
+
map "label", to: :label
|
|
20
|
+
map "count", to: :count
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class KvGroupWidget < Lutaml::Model::Serializable
|
|
25
|
+
attribute :name, :string
|
|
26
|
+
attribute :flag, :boolean
|
|
27
|
+
attribute :part, KvGroupPart
|
|
28
|
+
attribute :parts, KvGroupPart, collection: true
|
|
29
|
+
attribute :tags, :string, collection: true
|
|
30
|
+
|
|
31
|
+
json do
|
|
32
|
+
map "name", to: :name
|
|
33
|
+
map "flag", to: :flag
|
|
34
|
+
map "part", to: :part
|
|
35
|
+
map "parts", to: :parts
|
|
36
|
+
map "tags", to: :tags
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
hsh do
|
|
40
|
+
map "name", to: :name
|
|
41
|
+
map "flag", to: :flag
|
|
42
|
+
map "part", to: :part
|
|
43
|
+
map "parts", to: :parts
|
|
44
|
+
map "tags", to: :tags
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
RSpec.describe "KV group-then-bulk hydration" do
|
|
49
|
+
before do
|
|
50
|
+
stub_const("KvGroup::Part", KvGroupPart)
|
|
51
|
+
stub_const("KvGroup::Widget", KvGroupWidget)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
let(:doc) do
|
|
55
|
+
{
|
|
56
|
+
"name" => "w",
|
|
57
|
+
"flag" => true,
|
|
58
|
+
"part" => { "label" => "p", "count" => 2 },
|
|
59
|
+
"parts" => [{ "label" => "a" }, { "label" => "b", "count" => 5 }],
|
|
60
|
+
"tags" => %w[x y],
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "hydrates nested models through the group path" do
|
|
65
|
+
widget = KvGroupWidget.from_hash(doc)
|
|
66
|
+
|
|
67
|
+
expect(widget.name).to eq("w")
|
|
68
|
+
expect(widget.flag).to be(true)
|
|
69
|
+
expect(widget.part.label).to eq("p")
|
|
70
|
+
expect(widget.part.count).to eq(2)
|
|
71
|
+
expect(widget.parts.map(&:label)).to eq(%w[a b])
|
|
72
|
+
expect(widget.parts.map(&:count)).to eq([nil, 5])
|
|
73
|
+
expect(widget.tags).to eq(%w[x y])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "threads parent and root links like the per-rule walk" do
|
|
77
|
+
widget = KvGroupWidget.from_hash(doc)
|
|
78
|
+
|
|
79
|
+
expect(widget.part.lutaml_parent).to equal(widget)
|
|
80
|
+
expect(widget.parts.first.lutaml_parent).to equal(widget)
|
|
81
|
+
expect(widget.parts.first.lutaml_root).to equal(widget)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "leaves absent attributes exactly as the per-rule walk does" do
|
|
85
|
+
empty = KvGroupWidget.from_hash({})
|
|
86
|
+
# The walk's own contract for absent keys: values seeded by the
|
|
87
|
+
# constructor's defaults, marked set-by-parse (not default) — the
|
|
88
|
+
# group path runs the same absent-rule walk, so it matches.
|
|
89
|
+
expect(empty.name).to be_nil
|
|
90
|
+
expect(empty.part).to be_nil
|
|
91
|
+
|
|
92
|
+
named = KvGroupWidget.from_hash("name" => "q")
|
|
93
|
+
expect(named.name).to eq("q")
|
|
94
|
+
expect(named.part).to be_nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "engages the group path (wiring proves engagement)" do
|
|
98
|
+
counted = Class.new(KvGroupPart) do
|
|
99
|
+
@built = 0
|
|
100
|
+
class << self
|
|
101
|
+
attr_reader :built
|
|
102
|
+
|
|
103
|
+
def new(*args)
|
|
104
|
+
@built += 1
|
|
105
|
+
super
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
holder = Class.new(Lutaml::Model::Serializable) do
|
|
110
|
+
attribute :parts, counted, collection: true
|
|
111
|
+
|
|
112
|
+
json { map "parts", to: :parts }
|
|
113
|
+
hsh { map "parts", to: :parts }
|
|
114
|
+
end
|
|
115
|
+
stub_const("KvGroup::Counted", counted)
|
|
116
|
+
stub_const("KvGroup::Holder", holder)
|
|
117
|
+
|
|
118
|
+
holder.from_hash("parts" => [{ "label" => "a" }, { "label" => "b" }])
|
|
119
|
+
# 2 parts = 2 constructions, one per instance, no per-rule walk
|
|
120
|
+
expect(counted.built).to eq(2)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it "works through json and yaml" do
|
|
124
|
+
json = KvGroupWidget.from_json(doc.to_json)
|
|
125
|
+
yaml = KvGroupWidget.from_yaml(doc.to_yaml)
|
|
126
|
+
|
|
127
|
+
expect(json.to_hash).to eq(yaml.to_hash)
|
|
128
|
+
expect(json.parts.map(&:label)).to eq(%w[a b])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "wraps a single object into a model collection" do
|
|
132
|
+
widget = KvGroupWidget.from_hash("parts" => { "label" => "solo" })
|
|
133
|
+
|
|
134
|
+
expect(widget.parts.map(&:label)).to eq(["solo"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "falls back and matches the per-rule walk for junk items" do
|
|
138
|
+
# The interpretive walk rejects a non-object item in a model row
|
|
139
|
+
# with InvalidFormatError; the group path must behave identically
|
|
140
|
+
# after falling back — same data in, same outcome, either path.
|
|
141
|
+
expect do
|
|
142
|
+
KvGroupWidget.from_hash("parts" => [{ "label" => "a" }, "junk"])
|
|
143
|
+
end.to raise_error(Lutaml::Model::InvalidFormatError)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe "eligibility" do
|
|
147
|
+
it "rejects custom methods and still parses" do
|
|
148
|
+
custom = Class.new(KvGroupWidget) do
|
|
149
|
+
json do
|
|
150
|
+
map "name", to: :name
|
|
151
|
+
map "flag", to: :flag
|
|
152
|
+
map "part", to: :part
|
|
153
|
+
map "parts", to: :parts
|
|
154
|
+
map "tags", to: :tags
|
|
155
|
+
map "name", to: :name, with: { to: :n_to, from: :n_from }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def n_from(model, _value)
|
|
159
|
+
model.name = "from-custom"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def n_to(_model, _doc); end
|
|
163
|
+
end
|
|
164
|
+
stub_const("KvGroup::Custom", custom)
|
|
165
|
+
|
|
166
|
+
expect(Lutaml::KeyValue::Transform.kv_group_plan(custom, :json,
|
|
167
|
+
:default)).to be(false)
|
|
168
|
+
# Falls back to the per-rule walk; whatever it produces for the
|
|
169
|
+
# custom rule is that path's contract, unchanged by this feature.
|
|
170
|
+
expect(custom.from_hash(doc)).to be_a(custom)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "rejects when_attribute partitions" do
|
|
174
|
+
partitioned = Class.new(KvGroupWidget) do
|
|
175
|
+
attribute :guidance, KvGroupPart, collection: true
|
|
176
|
+
|
|
177
|
+
json do
|
|
178
|
+
map "guidance", to: :guidance,
|
|
179
|
+
when_attribute: { "type" => "guidance" }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
stub_const("KvGroup::Partitioned", partitioned)
|
|
183
|
+
|
|
184
|
+
expect(Lutaml::KeyValue::Transform.kv_group_plan(partitioned, :json,
|
|
185
|
+
:default)).to be(false)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "rejects rule-level polymorphic dispatch" do
|
|
189
|
+
animal = Class.new(Lutaml::Model::Serializable) do
|
|
190
|
+
attribute :name, :string
|
|
191
|
+
json { map "name", to: :name }
|
|
192
|
+
end
|
|
193
|
+
zoo = Class.new(Lutaml::Model::Serializable) do
|
|
194
|
+
attribute :animals, animal, collection: true
|
|
195
|
+
json do
|
|
196
|
+
map "animals", to: :animals, polymorphic: { attribute: "type" }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
stub_const("KvGroup::Zoo", zoo)
|
|
200
|
+
|
|
201
|
+
expect(Lutaml::KeyValue::Transform.kv_group_plan(zoo, :json,
|
|
202
|
+
:default)).to be(false)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it "rejects range collections" do
|
|
206
|
+
ranged = Class.new(Lutaml::Model::Serializable) do
|
|
207
|
+
attribute :tags, :string, collection: 1..3
|
|
208
|
+
|
|
209
|
+
json { map "tags", to: :tags }
|
|
210
|
+
end
|
|
211
|
+
stub_const("KvGroup::Ranged", ranged)
|
|
212
|
+
|
|
213
|
+
expect(Lutaml::KeyValue::Transform.kv_group_plan(ranged, :json,
|
|
214
|
+
:default)).to be(false)
|
|
215
|
+
expect(ranged.from_hash("tags" => %w[a b]).tags).to eq(%w[a b])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "rejects self-referential models (cycle)" do
|
|
219
|
+
node = Class.new(Lutaml::Model::Serializable) do
|
|
220
|
+
attribute :name, :string
|
|
221
|
+
json { map "name", to: :name }
|
|
222
|
+
end
|
|
223
|
+
node.attribute :child, node
|
|
224
|
+
node.json do
|
|
225
|
+
map "name", to: :name
|
|
226
|
+
map "child", to: :child
|
|
227
|
+
end
|
|
228
|
+
stub_const("KvGroup::Node", node)
|
|
229
|
+
|
|
230
|
+
expect(Lutaml::KeyValue::Transform.kv_group_plan(node, :json,
|
|
231
|
+
:default)).to be(false)
|
|
232
|
+
parsed = node.from_hash("name" => "n", "child" => { "name" => "c" })
|
|
233
|
+
expect(parsed.child.name).to eq("c")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it "rejects delegates" do
|
|
237
|
+
holder = Class.new(Lutaml::Model::Serializable) do
|
|
238
|
+
attribute :label, :string
|
|
239
|
+
json { map "label", to: :label }
|
|
240
|
+
end
|
|
241
|
+
delegating = Class.new(Lutaml::Model::Serializable) do
|
|
242
|
+
attribute :part, holder
|
|
243
|
+
json { map "label", to: :label, delegate: :part }
|
|
244
|
+
end
|
|
245
|
+
stub_const("KvGroup::Delegating", delegating)
|
|
246
|
+
|
|
247
|
+
expect(Lutaml::KeyValue::Transform.kv_group_plan(delegating, :json,
|
|
248
|
+
:default)).to be(false)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
describe "cache thread safety" do
|
|
254
|
+
it "survives concurrent first-builds with correct plans (no false caching)" do
|
|
255
|
+
part = Class.new(Lutaml::Model::Serializable) do
|
|
256
|
+
attribute :label, :string
|
|
257
|
+
json { map "label", to: :label }
|
|
258
|
+
end
|
|
259
|
+
widget = Class.new(Lutaml::Model::Serializable) do
|
|
260
|
+
attribute :part, part
|
|
261
|
+
json { map "part", to: :part }
|
|
262
|
+
end
|
|
263
|
+
stub_const("KvGroup::ThreadPart", part)
|
|
264
|
+
stub_const("KvGroup::ThreadWidget", widget)
|
|
265
|
+
|
|
266
|
+
# Clear any warm cache so every thread races the first build.
|
|
267
|
+
Lutaml::KeyValue::Transform::KV_GROUP_PLANS.clear
|
|
268
|
+
|
|
269
|
+
verdicts = Array.new(8) do
|
|
270
|
+
Thread.new do
|
|
271
|
+
Lutaml::KeyValue::Transform.kv_group_plan(widget, :json, :default)
|
|
272
|
+
end
|
|
273
|
+
end.map(&:value)
|
|
274
|
+
|
|
275
|
+
expect(verdicts).to all(be_truthy)
|
|
276
|
+
expect(verdicts.uniq.length).to eq(1)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it "keeps the cycle verdict per-build across threads" do
|
|
280
|
+
node = Class.new(Lutaml::Model::Serializable) do
|
|
281
|
+
attribute :name, :string
|
|
282
|
+
json { map "name", to: :name }
|
|
283
|
+
end
|
|
284
|
+
node.attribute :child, node
|
|
285
|
+
node.json do
|
|
286
|
+
map "name", to: :name
|
|
287
|
+
map "child", to: :child
|
|
288
|
+
end
|
|
289
|
+
stub_const("KvGroup::ThreadNode", node)
|
|
290
|
+
|
|
291
|
+
Lutaml::KeyValue::Transform::KV_GROUP_PLANS.clear
|
|
292
|
+
verdicts = Array.new(4) do
|
|
293
|
+
Thread.new do
|
|
294
|
+
Lutaml::KeyValue::Transform.kv_group_plan(node, :json, :default)
|
|
295
|
+
end
|
|
296
|
+
end.map(&:value)
|
|
297
|
+
|
|
298
|
+
expect(verdicts).to all(be(false))
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
end
|
|
@@ -38,8 +38,42 @@ RSpec.describe "XML plan fast path" do
|
|
|
38
38
|
Lutaml::Model::Config.instance.xml_plan_fast_path = old
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
# Real-corpus crash (niso-jats): sec-in-sec nesting is a
|
|
42
|
+
# self-referential model — compile must resolve it to nil instead of
|
|
43
|
+
# recursing to SystemStackError.
|
|
44
|
+
it "opts self-referential models out instead of recursing" do
|
|
45
|
+
node = Class.new(Lutaml::Model::Serializable) do
|
|
46
|
+
attribute :text, :string
|
|
47
|
+
xml do
|
|
48
|
+
element "n"
|
|
49
|
+
map_content to: :text
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
node.attribute :child, node
|
|
53
|
+
node.xml do
|
|
54
|
+
element "n"
|
|
55
|
+
map_content to: :text
|
|
56
|
+
map_element "child", to: :child
|
|
57
|
+
end
|
|
58
|
+
stub_const("PlanFastPath::SelfRef", node)
|
|
59
|
+
|
|
60
|
+
expect(Lutaml::Xml::PlanCompiler.compile(node, :default)).to be_nil
|
|
61
|
+
|
|
62
|
+
xml = "<n>#{'<n>t</n>' * 200}</n>"
|
|
63
|
+
parsed = node.from_xml(xml)
|
|
64
|
+
expect(parsed).to be_a(node)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "is on by default and can be disabled" do
|
|
68
|
+
expect(Lutaml::Model::Configuration.new.xml_plan_fast_path).to be(true)
|
|
69
|
+
|
|
70
|
+
# Opt-out restores the interpretive pipeline for the whole process.
|
|
71
|
+
Lutaml::Model::Config.instance.xml_plan_fast_path = false
|
|
72
|
+
item = item_class.new(id: 1, name: "a", tags: %w[x])
|
|
73
|
+
parsed = root_class.from_xml(root_class.new(item: [item]).to_xml)
|
|
74
|
+
expect(parsed.item.first.id).to eq(1)
|
|
75
|
+
ensure
|
|
76
|
+
Lutaml::Model::Config.instance.xml_plan_fast_path = true
|
|
43
77
|
end
|
|
44
78
|
|
|
45
79
|
it "hydrates equal to the interpretive path" do
|
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.52
|
|
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-09-
|
|
11
|
+
date: 2026-09-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -1684,6 +1684,7 @@ files:
|
|
|
1684
1684
|
- spec/lutaml/json/yeptris_adapter_spec.rb
|
|
1685
1685
|
- spec/lutaml/jsonld/adapter_spec.rb
|
|
1686
1686
|
- spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb
|
|
1687
|
+
- spec/lutaml/key_value/group_instantiate_spec.rb
|
|
1687
1688
|
- spec/lutaml/key_value/transformation/collection_serializer_spec.rb
|
|
1688
1689
|
- spec/lutaml/key_value/transformation/rule_compiler_spec.rb
|
|
1689
1690
|
- spec/lutaml/key_value/transformation/value_serializer_spec.rb
|