lutaml-model 0.8.50 → 0.8.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bd76b1c0377a6e86d547f5ca29fe5d164d390bc7f1d88ba7051a5930b119911f
|
|
4
|
+
data.tar.gz: 22e2dea3ad97c502647a49ec748113e939234b9136c02634b99c722ce443df10
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0883bebaf58efeeda222ddc83f74661f396184786944f48f1a9ae2ecb5cbb4c15059cb5dd93df791d101acd734ca597e849b5c0d4488b104c59b7afa01f05e40'
|
|
7
|
+
data.tar.gz: 46e13698b95bff244e8d31627bbd3c1fd2a9fc667b3741a9707bac2a85256be426a9606c3c38ac848722ae668c488da39643154f5387aa6a60ee006dd57056b3
|
|
@@ -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
|
@@ -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
|
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.51
|
|
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
|