lutaml-model 0.8.131 → 0.8.132

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: 411fee08073d8c3578b1071024486fa23ed6f1c3b93e918f7c47e175b9a95f90
4
- data.tar.gz: 80e702fd74cda828374ffc7b5367311a2b49ce5ba2d5580e8038e13b6b9269ca
3
+ metadata.gz: 83ece4646bc4f2ecd052f6ba5edf994cb512b4be3a08e8a1778c5b357daab424
4
+ data.tar.gz: 9bac20d16559163de764a7148974724c199cacbce0d88d945b932c7d36ccaa19
5
5
  SHA512:
6
- metadata.gz: d48928bc46206f2067989a4637ace150fcf71db4df6c18e1df93de5c621c088db0490bf85d860ec6861ad08d208218553a5d2ae7806ea4b5e88067b0032d51ed
7
- data.tar.gz: 8d0127e8139146f8d1df26a488904d7e48202e4c5b75a49f67a895f87d372507f9217253590496c44513677653bd8516f009d59f7889a41832c2e12333987585
6
+ metadata.gz: 27120683de1ff8d9c95049e683dffdf302aa43edf9a4e5e6debda189df3903fdf98f4722200eb4106285e86dfabb170b77fcaea3313c6e1a8e8e548ec2db0eab
7
+ data.tar.gz: 26a84cc1b288f7b829c1bdb44360adc7211843e506d1d51c1f416c411636fa6099dee79fd26d4d41a7e22b5eaf3ef1510d9580dbe49318ac8c2b9e02b80974bc
@@ -346,15 +346,21 @@ child_mappings)
346
346
  # path. Returns nil (caller falls back) on any shape the plan
347
347
  # does not cover, e.g. a non-Hash item where a model was expected.
348
348
  def kv_group_build(rows, doc, format, register, options)
349
- setters = []
349
+ # Positional values, not per-row [name, value] pairs: rows are a
350
+ # fixed per-class list, so the writer to apply lives at the row's
351
+ # index and the pair arrays were the lane's biggest allocation
352
+ # (~6 arrays per node on hydrate-heavy corpora).
353
+ values = Array.new(rows.size, MISSING)
350
354
  children = []
351
355
  # Lazy: the present-key hot path never pushes, and this runs
352
356
  # once per hydrated model instance.
353
357
  absent = nil
354
358
  unmapped = nil
359
+ i = 0
355
360
  rows.each do |rule, attr, kind, type, child_rows, child_register|
356
361
  if kind == :unmapped
357
362
  unmapped = [type, child_rows]
363
+ i += 1
358
364
  next
359
365
  end
360
366
 
@@ -364,6 +370,7 @@ child_mappings)
364
370
  v = Lutaml::Model::Utils.fetch_str_or_sym(doc, rule.name, MISSING)
365
371
  if v.equal?(MISSING)
366
372
  (absent ||= []) << rule
373
+ i += 1
367
374
  next
368
375
  end
369
376
 
@@ -389,13 +396,15 @@ child_mappings)
389
396
  built << self.class.data_to_model(type.attr_type,
390
397
  child_hash, format, { mappings: type.child_rules })
391
398
  end
392
- setters << [rule.kv_setter, built]
399
+ values[i] = built
393
400
  children.concat(built)
401
+ i += 1
394
402
  next
395
403
  end
396
404
 
397
405
  if kind == :scalar
398
- setters << [rule.kv_setter, v]
406
+ values[i] = v
407
+ i += 1
399
408
  next
400
409
  end
401
410
 
@@ -417,7 +426,7 @@ child_hash, format, { mappings: type.child_rules })
417
426
 
418
427
  built << child
419
428
  end
420
- setters << [rule.kv_setter, built]
429
+ values[i] = built
421
430
  children.concat(built)
422
431
  else
423
432
  return nil unless v.is_a?(::Hash)
@@ -427,12 +436,20 @@ child_hash, format, { mappings: type.child_rules })
427
436
  options)
428
437
  return nil if child.nil?
429
438
 
430
- setters << [rule.kv_setter, child]
439
+ values[i] = child
431
440
  children << child
432
441
  end
442
+ i += 1
433
443
  end
434
444
  instance = model_class.allocate_for_walk(register)
435
- setters.each { |name, value| instance.public_send(name, value) }
445
+ i = 0
446
+ rows.each do |rule, _attr, kind, *_rest|
447
+ v = values[i]
448
+ i += 1
449
+ next if v.equal?(MISSING) || kind == :unmapped
450
+
451
+ instance.public_send(rule.kv_setter, v)
452
+ end
436
453
  absent&.each do |rule|
437
454
  process_mapping_rule(doc, instance, format, rule, options, nil)
438
455
  end
@@ -43,6 +43,9 @@ model_class: nil)
43
43
  @register_id = register_id
44
44
  @transformation_factory = transformation_factory
45
45
  @model_class = model_class
46
+ # Pre-declared: the serializer may be frozen after setup, and
47
+ # the type-transformation memo mutates this hash in place.
48
+ @lutaml_type_transformations = {}
46
49
  end
47
50
 
48
51
  # Serialize a value for an item (handles nested models and primitives).
@@ -117,10 +120,10 @@ model_class: nil)
117
120
 
118
121
  # Get or create child transformation
119
122
  child_transformation = if uses_polymorphism
120
- create_transformation(actual_type)
123
+ transformation_for_type(actual_type)
121
124
  else
122
125
  rule.child_transformation ||
123
- create_transformation(rule.attribute_type)
126
+ transformation_for_type(rule.attribute_type)
124
127
  end
125
128
 
126
129
  if child_transformation
@@ -296,6 +299,15 @@ model_class: nil)
296
299
  transformation_factory.call(type_class)
297
300
  end
298
301
 
302
+ # Memoized on this object: the factory binds this object's
303
+ # mapping/format/register, so the memo is register-safe, and
304
+ # building one per serialized node is a Transformation per
305
+ # nested model per instance.
306
+ def transformation_for_type(type_class)
307
+ (@lutaml_type_transformations ||= {})[type_class] ||=
308
+ create_transformation(type_class)
309
+ end
310
+
299
311
  # Transform a nested model using the child transformation.
300
312
  #
301
313
  # @param value [Object] The model instance
@@ -63,6 +63,9 @@ module Lutaml
63
63
 
64
64
  # Set up serializers before calling super
65
65
  # The super will call compile_rules and freeze
66
+ # Pre-declared: the transformation is frozen after compile, and
67
+ # the type-transformation memo mutates this hash in place.
68
+ @lutaml_type_transformations = {}
66
69
  @rule_compiler = RuleCompiler.new(
67
70
  model_class: model_class,
68
71
  register_id: register_id,
@@ -1034,6 +1037,15 @@ child_mappings, options)
1034
1037
  # @param value [Object] The value
1035
1038
  # @param options [Hash] Options
1036
1039
  # @return [Object] The value (could be Hash, primitive, or KeyValueElement)
1040
+ # Child transformations memoized on this transformation instance:
1041
+ # the factory binds this instance's mapping/format/register, so
1042
+ # the memo is register-safe, and building one per serialized node
1043
+ # is a Transformation per nested model per instance.
1044
+ def transformation_for_type(type_class)
1045
+ (@lutaml_type_transformations ||= {})[type_class] ||=
1046
+ build_child_transformation(type_class)
1047
+ end
1048
+
1037
1049
  def create_value_for_item(rule, value, options)
1038
1050
  return nil if value.nil?
1039
1051
 
@@ -1098,9 +1110,7 @@ child_mappings, options)
1098
1110
  if !child_transformation || uses_polymorphism
1099
1111
  # For polymorphic types, use the actual runtime type's mappings
1100
1112
  type_for_mapping = uses_polymorphism ? actual_type : rule.attribute_type
1101
- mapping = type_for_mapping.mappings_for(format, register_id)
1102
- child_transformation = self.class.new(type_for_mapping, mapping,
1103
- format, register)
1113
+ child_transformation = transformation_for_type(type_for_mapping)
1104
1114
  end
1105
1115
 
1106
1116
  if child_transformation
@@ -3,12 +3,81 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class Transform
6
- @transform_cache = {}
7
-
8
6
  # Maximum number of cached Transform instances before eviction.
9
7
  # Covers most OOXML/ISO schemas with ~1000+ classes and multiple registers.
10
8
  MAX_CACHE_SIZE = 2048
11
9
 
10
+ # The (lane, context, register) memo lives in a shared constant,
11
+ # not in class-level ivars: KeyValue::Transform subclasses this
12
+ # class, and inherited class methods run with the subclass as
13
+ # self — ivar state forked per subclass, and clear_cache! on the
14
+ # base class never reached the subclass's copy (stale compiled
15
+ # plans across register resets). The lane is part of the key:
16
+ # each transform class compiles format-specific rules, so a KV
17
+ # transform and the base one must never answer for each other.
18
+ class TransformCache
19
+ attr_reader :size
20
+
21
+ def initialize
22
+ @entries = {}
23
+ @size = 0
24
+ end
25
+
26
+ # Nested memo, not an array key: the hot path is one lookup per
27
+ # parsed node and the key array was that lookup's only
28
+ # allocation (60k arrays on a 6k-node corpus).
29
+ def fetch_or_store(lane, context, register)
30
+ by_context = @entries[lane]
31
+ by_register = by_context&.[](context)
32
+ return by_register[register] if by_register&.key?(register)
33
+
34
+ by_register = ((@entries[lane] ||= {})[context] ||= {})
35
+ transform = yield
36
+ by_register[register] = transform
37
+ @size += 1
38
+ transform
39
+ end
40
+
41
+ def evict_half_if_full
42
+ return unless @size >= MAX_CACHE_SIZE
43
+
44
+ drop = @size / 2
45
+ @entries.each do |lane, by_context|
46
+ by_context.each do |ctx, by_register|
47
+ break if drop <= 0
48
+
49
+ drop -= by_register.size
50
+ by_context.delete(ctx)
51
+ end
52
+ @entries.delete(lane) if by_context.empty?
53
+ break if drop <= 0
54
+ end
55
+ @size = @entries.sum { |_, by_context| by_context.sum { |_, br| br.size } }
56
+ end
57
+
58
+ def invalidate(context, register = nil)
59
+ @entries.each_value do |by_context|
60
+ if register
61
+ by_register = by_context[context]
62
+ next unless by_register&.delete(register)
63
+
64
+ @size -= 1
65
+ by_context.delete(context) if by_register.empty?
66
+ else
67
+ removed = by_context.delete(context)
68
+ @size -= removed.size if removed
69
+ end
70
+ end
71
+ end
72
+
73
+ def clear
74
+ @entries.clear
75
+ @size = 0
76
+ end
77
+ end
78
+
79
+ TRANSFORM_CACHE = TransformCache.new
80
+
12
81
  def self.data_to_model(context, data, format, options = {})
13
82
  register = options[:register] || Lutaml::Model::Config.default_register
14
83
  transform = cached_transform(context, register, options[:mappings])
@@ -28,13 +97,10 @@ module Lutaml
28
97
  def self.cached_transform(context, register, mappings = nil)
29
98
  return override_transform(context, register, mappings) if mappings
30
99
 
31
- @transform_cache ||= {}
32
- cache_key = [context, register]
33
- entry = @transform_cache[cache_key]
34
- return entry if entry
35
-
36
- evict_if_needed if @transform_cache.size >= MAX_CACHE_SIZE
37
- @transform_cache[cache_key] = new(context, register)
100
+ TRANSFORM_CACHE.evict_half_if_full
101
+ TRANSFORM_CACHE.fetch_or_store(self, context, register) do
102
+ new(context, register)
103
+ end
38
104
  end
39
105
 
40
106
  def self.override_transform(context, register, mappings)
@@ -54,30 +120,17 @@ module Lutaml
54
120
  private_class_method :evict_override_if_needed
55
121
 
56
122
  def self.clear_cache!
57
- @transform_cache&.clear
123
+ TRANSFORM_CACHE.clear
58
124
  @override_transform_cache&.clear
59
125
  end
60
126
 
61
127
  def self.cache_size
62
- @transform_cache&.size || 0
128
+ TRANSFORM_CACHE.size
63
129
  end
64
130
 
65
131
  def self.invalidate_for(context, register = nil)
66
- return unless @transform_cache
67
-
68
- if register
69
- @transform_cache.delete([context, register])
70
- else
71
- @transform_cache.reject! { |(ctx, _reg)| ctx == context }
72
- end
73
- end
74
-
75
- def self.evict_if_needed
76
- # Evict oldest half of entries when cache is full
77
- keys_to_remove = @transform_cache.keys.first(@transform_cache.size / 2)
78
- keys_to_remove.each { |k| @transform_cache.delete(k) }
132
+ TRANSFORM_CACHE.invalidate(context, register)
79
133
  end
80
- private_class_method :evict_if_needed
81
134
 
82
135
  attr_reader :context, :lutaml_register
83
136
 
@@ -38,6 +38,8 @@ module Lutaml
38
38
  # # Has :custom, falls back to default for built-in types
39
39
  #
40
40
  class TypeContext
41
+ EMPTY_SUBSTITUTIONS = [].freeze
42
+
41
43
  # @return [Symbol] The context identifier
42
44
  attr_reader :id
43
45
 
@@ -204,6 +206,13 @@ module Lutaml
204
206
  # @param from_type [Class, Symbol, String] The type to find substitutions for
205
207
  # @return [Array<TypeSubstitution>] Matching substitutions
206
208
  def substitution_for(from_type)
209
+ # The no-substitution context is the common case, and this
210
+ # lookup runs per nested model per serialized instance — the
211
+ # select's result array was the allocation (#KV-to profile
212
+ # 2026-10-26). The frozen shared array answers readers only;
213
+ # every consumer here selects/any?/eaches it.
214
+ return EMPTY_SUBSTITUTIONS if substitutions.empty?
215
+
207
216
  from_type_class = from_type.is_a?(Class) ? from_type : nil
208
217
 
209
218
  substitutions.select do |sub|
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.131"
5
+ VERSION = "0.8.132"
6
6
  end
7
7
  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.131
4
+ version: 0.8.132
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-25 00:00:00.000000000 Z
11
+ date: 2026-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64