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: f2f88962ec380d731bd7a31ce5ff62f9aec9dcef4b7783aebfb128b6b5c86bbb
4
- data.tar.gz: b282edc8ccf91c2381315e58a34d54a15642153e673ba31bbbd46c084c7634c4
3
+ metadata.gz: bd76b1c0377a6e86d547f5ca29fe5d164d390bc7f1d88ba7051a5930b119911f
4
+ data.tar.gz: 22e2dea3ad97c502647a49ec748113e939234b9136c02634b99c722ce443df10
5
5
  SHA512:
6
- metadata.gz: f92f932e37bed4d355b3a41c1d2599bf23a5f0907f2ba18967f27cb5a572a4be11d080d54d0a73787055b6e9143414928e87086f2269f42acfb77665e9cccda6
7
- data.tar.gz: a5801daa0947b0a2308fc720412d0add3cd5956f6a902cf20aa62b0ece6d883ec801dc4c4112319ab161a5d68934d54aac0253d601515823d04440df52011400
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]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.50"
5
+ VERSION = "0.8.51"
6
6
  end
7
7
  end
@@ -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.50
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