lutaml-model 0.8.49 → 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: 2dab1dcbc2ea77cfd1c61d9ffba4717521b9e97e1f3f6c8dd6f9ce6c7014222f
4
- data.tar.gz: ed16efadd94ce85b9e52a5c7de822f9c7de7e3c581203280120630e8cdb389ff
3
+ metadata.gz: bd76b1c0377a6e86d547f5ca29fe5d164d390bc7f1d88ba7051a5930b119911f
4
+ data.tar.gz: 22e2dea3ad97c502647a49ec748113e939234b9136c02634b99c722ce443df10
5
5
  SHA512:
6
- metadata.gz: cd6bbdebb2c812eb849774ac168fe2c194bc21113521be6b4ca8b389ab5f6ebb2743b951478077332a18a2c214f458962c0a89d54e1a26447b3b103b7c2bb153
7
- data.tar.gz: fddad44bad334a640facc5f6869b91c2a12134da6c79e58b7bed1787e14c03702d5ffd0ffcece27e435fe2f9387a7d13040e45794d178424cd7e56acbf9bb36c
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
- PARSED_ASSIGN_WRITERS = {}.compare_by_identity
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]
@@ -350,6 +350,59 @@ module Lutaml
350
350
  instance
351
351
  end
352
352
 
353
+ # Fast bulk constructor for deserialization-heavy callers
354
+ # (native extensions building object trees bottom-up).
355
+ #
356
+ # Allocates without running #initialize and applies each present
357
+ # attribute through its compiled writer, which casts the value
358
+ # and marks it as explicitly set — so defaults, to_hash output,
359
+ # and using_default? behave exactly as if the instance had been
360
+ # produced by from_hash. Values may be primitives or already
361
+ # built instances (instances pass through casting unchanged);
362
+ # absent keys keep their defaults. Unknown keys raise.
363
+ #
364
+ # @param attrs [Hash] attribute names (String or Symbol) to
365
+ # pre-cast values
366
+ # @param register [Symbol, nil] The register context
367
+ # @return [Object] The hydrated instance
368
+ def instantiate(attrs = {}, register = nil)
369
+ instance = allocate_for_deserialization(register)
370
+ register_id = instance.lutaml_register
371
+ given = {}
372
+ attrs.each do |key, value|
373
+ name = key.to_sym
374
+ next if name == :lutaml_register
375
+
376
+ unless attributes(register_id).key?(name)
377
+ raise Error, "unknown attribute '#{name}' for #{self}"
378
+ end
379
+
380
+ given[name] = value
381
+ end
382
+ attributes(register_id).each do |name, attr|
383
+ next if attr.derived?
384
+
385
+ if given.key?(name)
386
+ instance.public_send(:"#{name}=", given[name])
387
+ else
388
+ # Absent keys seed their default explicitly, mirroring
389
+ # initialize_attributes: every mapped attribute ends up
390
+ # set (nil when no default), never left as the
391
+ # uninitialized sentinel — readers and formatters touch
392
+ # absent attributes freely.
393
+ default = attr.default_value(register_id, instance)
394
+ value = if Lutaml::Model::Utils.uninitialized?(default)
395
+ nil
396
+ else
397
+ attr.cast_value(default, register_id)
398
+ end
399
+ instance.public_send(:"#{name}=", value)
400
+ instance.using_default_for(name)
401
+ end
402
+ end
403
+ instance
404
+ end
405
+
353
406
  # Define register-specific attribute methods on the class itself.
354
407
  #
355
408
  # Called once per (class, register) combination. Replaces per-instance
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.49"
5
+ VERSION = "0.8.51"
6
6
  end
7
7
  end
@@ -30,7 +30,11 @@ module Lutaml
30
30
 
31
31
  child_kwargs[name] = []
32
32
  end
33
- instance = model_class.new(**attr_kwargs, **child_kwargs)
33
+ # TODO.max-perf/33: the bulk constructor — allocate +
34
+ # compiled writers, from_hash semantics — measured ~30x on
35
+ # hydration-heavy corpora versus the generic constructor.
36
+ instance = model_class.instantiate(attr_kwargs.merge(child_kwargs),
37
+ register)
34
38
  instance.lutaml_parent = parent if parent
35
39
  instance.lutaml_root ||= parent&.lutaml_root || parent
36
40
  instance.element_order = PlanOrder.build(node) if node && plan[:ordered]
@@ -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
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../../../lib/lutaml/model"
5
+
6
+ module InstantiateMapping
7
+ class Part < Lutaml::Model::Serializable
8
+ attribute :label, :string
9
+ attribute :count, :integer
10
+ end
11
+
12
+ class Widget < Lutaml::Model::Serializable
13
+ attribute :name, :string
14
+ attribute :flag, :boolean
15
+ attribute :part, Part
16
+ attribute :tags, :string, collection: true
17
+
18
+ key_value do
19
+ map "name", to: :name
20
+ map "flag", to: :flag
21
+ map "part", to: :part
22
+ map "tags", to: :tags
23
+ end
24
+ end
25
+ end
26
+
27
+ RSpec.describe "Serializable.instantiate" do
28
+ it "hydrates attributes from primitives" do
29
+ widget = InstantiateMapping::Widget.instantiate("name" => "w", "flag" => true,
30
+ "tags" => %w[a b])
31
+ expect(widget.name).to eq("w")
32
+ expect(widget.flag).to be(true)
33
+ expect(widget.tags).to eq(%w[a b])
34
+ end
35
+
36
+ it "accepts pre-built instances for typed attributes" do
37
+ part = InstantiateMapping::Part.instantiate(label: "p", count: 2)
38
+ widget = InstantiateMapping::Widget.instantiate(name: "w", part: part)
39
+
40
+ expect(widget.part).to equal(part)
41
+ end
42
+
43
+ it "keeps defaults for absent keys and reports them as defaults" do
44
+ widget = InstantiateMapping::Widget.instantiate
45
+ # Absent scalars read as the uninitialized sentinel, exactly like the
46
+ # XML fast path; serialization omits them either way.
47
+ expect(Lutaml::Model::Utils.uninitialized?(widget.name) ||
48
+ widget.name.nil?).to be(true)
49
+ expect(widget).to be_using_default(:name)
50
+ end
51
+
52
+ it "marks set attributes as explicitly set" do
53
+ widget = InstantiateMapping::Widget.instantiate(name: "w")
54
+ expect(widget).not_to be_using_default(:name)
55
+ end
56
+
57
+ it "serializes identically to from_hash" do
58
+ input = { "name" => "w", "flag" => false,
59
+ "part" => { "label" => "p", "count" => 3 },
60
+ "tags" => ["x"] }
61
+ from_hash = InstantiateMapping::Widget.from_hash(input)
62
+
63
+ part = InstantiateMapping::Part.instantiate(input["part"])
64
+ fast = InstantiateMapping::Widget.instantiate(
65
+ name: "w", flag: false, part: part, tags: ["x"],
66
+ )
67
+
68
+ expect(fast.to_hash).to eq(from_hash.to_hash)
69
+ end
70
+
71
+ it "raises on unknown attribute names" do
72
+ expect { InstantiateMapping::Widget.instantiate(bogus: 1) }
73
+ .to raise_error(Lutaml::Model::Error, /unknown attribute/)
74
+ end
75
+ 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.49
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-20 00:00:00.000000000 Z
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
@@ -1797,6 +1798,7 @@ files:
1797
1798
  - spec/lutaml/model/schema/yaml_schema_spec.rb
1798
1799
  - spec/lutaml/model/sequence_choice_compositor_spec.rb
1799
1800
  - spec/lutaml/model/sequence_spec.rb
1801
+ - spec/lutaml/model/serializable_instantiate_spec.rb
1800
1802
  - spec/lutaml/model/serializable_spec.rb
1801
1803
  - spec/lutaml/model/serializable_validation_spec.rb
1802
1804
  - spec/lutaml/model/serialize/builder_spec.rb