lutaml-model 0.8.36 → 0.8.38

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/bench/bench_sts.rb +9 -3
  3. data/bench/gate_config.rb +5 -4
  4. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  5. data/lib/lutaml/key_value/transform.rb +52 -5
  6. data/lib/lutaml/key_value/transformation.rb +10 -2
  7. data/lib/lutaml/model/adapter_resolver.rb +14 -2
  8. data/lib/lutaml/model/attribute.rb +15 -9
  9. data/lib/lutaml/model/choice.rb +6 -5
  10. data/lib/lutaml/model/format_registry.rb +3 -1
  11. data/lib/lutaml/model/liquefiable.rb +23 -2
  12. data/lib/lutaml/model/mapping/mapping_rule.rb +46 -11
  13. data/lib/lutaml/model/sequence.rb +47 -2
  14. data/lib/lutaml/model/serialize/attribute_definition.rb +176 -47
  15. data/lib/lutaml/model/serialize/deserialization_context.rb +1 -0
  16. data/lib/lutaml/model/serialize.rb +54 -10
  17. data/lib/lutaml/model/services/transformer.rb +30 -11
  18. data/lib/lutaml/model/store.rb +70 -32
  19. data/lib/lutaml/model/version.rb +1 -1
  20. data/lib/lutaml/xml/format.rb +1 -0
  21. data/lib/lutaml/xml/html_entities.rb +290 -0
  22. data/lib/lutaml/xml/mapping.rb +72 -0
  23. data/lib/lutaml/xml/model_transform.rb +210 -72
  24. data/lib/lutaml/xml/parse_session.rb +38 -0
  25. data/lib/lutaml/yaml/format.rb +1 -0
  26. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +38 -0
  27. data/spec/lutaml/model/custom_method_context_spec.rb +62 -0
  28. data/spec/lutaml/model/liquefiable_lazy_spec.rb +36 -0
  29. data/spec/lutaml/model/mapping_type_validation_spec.rb +61 -0
  30. data/spec/lutaml/model/sequence_choice_compositor_spec.rb +66 -0
  31. data/spec/lutaml/model/store_spec.rb +13 -11
  32. data/spec/lutaml/model/yaml_stream_spec.rb +70 -0
  33. data/spec/lutaml/xml/html_entities_spec.rb +45 -0
  34. metadata +9 -1
@@ -12,6 +12,100 @@ module Lutaml
12
12
  #
13
13
  # @param attr [Attribute] The attribute to define methods for
14
14
  # @param register [Symbol, nil] The register for type resolution
15
+ # Compile (per class, cached) the method that seeds every attribute
16
+ # with its "no data arrived" sentinel: collections share the frozen
17
+ # LAZY_EMPTY_COLLECTION, scalars share the UninitializedClass
18
+ # singleton. Compiled once instead of walked per instance — the
19
+ # grammars-compile-don't-interpret rule applied to object state.
20
+ # Compile (per class and register, on demand) the method that
21
+ # seeds every attribute with its "no data arrived" sentinel:
22
+ # collections share the frozen LAZY_EMPTY_COLLECTION, scalars
23
+ # share the UninitializedClass singleton. Compiled once instead
24
+ # of walked per instance — grammars compile, they don't
25
+ # interpret.
26
+ def compiled_state_defaults_name!(register_id)
27
+ @state_defaults_names ||= {}
28
+ name = @state_defaults_names[register_id]
29
+ return name if name
30
+
31
+ method_name = :"__init_state_defaults_#{register_id}"
32
+ compile_state_defaults!(method_name, register_id)
33
+ @state_defaults_names[register_id] = method_name
34
+ method_name
35
+ end
36
+
37
+ def compile_state_defaults!(method_name, register_id = nil)
38
+ attrs = attributes(register_id)
39
+
40
+ if attrs.empty?
41
+ define_method(method_name) do
42
+ # no attributes to seed
43
+ end
44
+ return
45
+ end
46
+
47
+ lines = attrs.map do |name, attr|
48
+ if attr.collection?
49
+ "@#{name} = Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION"
50
+ else
51
+ "@#{name} = Lutaml::Model::UninitializedClass.instance"
52
+ end
53
+ end.join("\n")
54
+
55
+ # class_eval interpolates per-attribute `@name = <sentinel>` lines:
56
+ # def __init_state_defaults_default
57
+ # @id = Lutaml::Model::UninitializedClass.instance
58
+ # @items = Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION
59
+ # end
60
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
61
+ def #{method_name}
62
+ #{lines}
63
+ end
64
+ RUBY
65
+ end
66
+
67
+ # Historical getter shape for punctuation-named attributes and
68
+ # any name the compiled form cannot express.
69
+ def define_reflective_attribute_methods(name, attr)
70
+ if attr.collection?
71
+ define_method(name) do |*args|
72
+ if args.empty?
73
+ materialize_lazy_collection(name)
74
+ else
75
+ # Builder-style: g.member(item) appends to collection
76
+ value = args.first
77
+ current = instance_variable_get(:"@#{name}") || []
78
+ new_value = current.is_a?(Array) ? current + [value] : value
79
+ instance_variable_set(:"@#{name}", new_value)
80
+ record_mutation(name, value)
81
+ value
82
+ end
83
+ end
84
+ else
85
+ define_method(name) do |*args|
86
+ if args.empty?
87
+ instance_variable_get(:"@#{name}")
88
+ else
89
+ public_send(:"#{name}=", args.first)
90
+ args.first
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ def invalidate_state_defaults!
97
+ # Opal's method_defined? takes no inherit flag (see the
98
+ # setter_defined check in define_regular_attribute_methods).
99
+ (@state_defaults_names ||= {}).each_key do |compiled|
100
+ defined_now = if Lutaml::Model.opal?
101
+ method_defined?(compiled)
102
+ else
103
+ method_defined?(compiled, false)
104
+ end
105
+ remove_method(compiled) if defined_now
106
+ end
107
+ end
108
+
15
109
  def define_attribute_methods(attr, register = nil)
16
110
  name = attr.name
17
111
  register_id = extract_register_id(register)
@@ -106,35 +200,54 @@ module Lutaml
106
200
  #
107
201
  # @param name [Symbol] The attribute name
108
202
  # @param attr [Attribute] The attribute definition
203
+ # Plain identifier names compile to `@name` reads; punctuation
204
+ # names (`mixed?`) keep the reflective path (@name is not valid
205
+ # Ruby for them).
206
+ PLAIN_NAME = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
207
+
109
208
  def define_regular_attribute_methods(name, attr)
110
- # For collection attributes, the getter accepts an optional argument
111
- # for builder-style syntax: g.member(item) appends to the collection
209
+ unless name.to_s.match?(PLAIN_NAME)
210
+ return define_reflective_attribute_methods(name, attr)
211
+ end
212
+
213
+ # Getters compile with a sentinel default argument instead of a
214
+ # `*args` splat: the splat allocated an (almost always empty)
215
+ # Array on EVERY read — the largest per-call allocation source
216
+ # on instance-heavy parses (TODO.max-perf/06). The optional-arg
217
+ # form allocates nothing, and the read is a direct @ivar.
112
218
  if attr.collection?
113
- define_method(name) do |*args|
114
- if args.empty?
115
- materialize_lazy_collection(name)
116
- else
117
- # Builder-style: g.member(item) appends to collection
118
- value = args.first
119
- current = instance_variable_get(:"@#{name}") || []
120
- new_value = current.is_a?(Array) ? current + [value] : value
121
- instance_variable_set(:"@#{name}", new_value)
122
- record_mutation(name, value)
123
- value
219
+ # class_eval interpolates, e.g.:
220
+ # def items(arg = Lutaml::Model::Serialize::NO_ARG)
221
+ # if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
222
+ # materialize_lazy_collection(:items)
223
+ # else
224
+ # ... builder append ...
225
+ # end
226
+ # end
227
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
228
+ def #{name}(arg = Lutaml::Model::Serialize::NO_ARG)
229
+ if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
230
+ materialize_lazy_collection(:#{name})
231
+ else
232
+ current = @#{name} || []
233
+ new_value = current.is_a?(Array) ? current + [arg] : arg
234
+ @#{name} = new_value
235
+ record_mutation(:#{name}, arg)
236
+ arg
237
+ end
124
238
  end
125
- end
239
+ RUBY
126
240
  else
127
- # For non-collection attributes, getter accepts optional argument
128
- # for builder-style syntax: g.description(value) sets the value.
129
- # Tracking happens inside the setter, so no duplicate call here.
130
- define_method(name) do |*args|
131
- if args.empty?
132
- instance_variable_get(:"@#{name}")
133
- else
134
- public_send(:"#{name}=", args.first)
135
- args.first
241
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
242
+ def #{name}(arg = Lutaml::Model::Serialize::NO_ARG)
243
+ if arg.equal?(Lutaml::Model::Serialize::NO_ARG)
244
+ @#{name}
245
+ else
246
+ public_send(:"#{name}=", arg)
247
+ arg
248
+ end
136
249
  end
137
- end
250
+ RUBY
138
251
  end
139
252
 
140
253
  enum_shorthand_names = instance_variable_get(:@__enum_shorthand_names__) || Set.new
@@ -146,35 +259,50 @@ module Lutaml
146
259
  else
147
260
  method_defined?(:"#{name}=", false)
148
261
  end
149
- unless setter_defined && !enum_shorthand_names.include?(name.to_s)
150
- if attr.collection?
151
- define_method(:"#{name}=") do |value|
152
- value_set_for(name)
153
- value = attr.cast_value(value, lutaml_register)
154
- # Preserve the frozen sentinel when the deserialization pipeline
155
- # would overwrite it with nil/UninitializedClass (meaning "no data
156
- # found for this collection"). This maintains the zero-allocation
157
- # guarantee for unused collections. The sentinel is replaced with
158
- # a real Array only when actual data is set.
159
- current = instance_variable_get(:"@#{name}")
160
- if current.equal?(LAZY_EMPTY_COLLECTION) &&
262
+ return if setter_defined && !enum_shorthand_names.include?(name.to_s)
263
+
264
+ # class_eval'd bodies cannot close over locals; a hidden
265
+ # define_method accessor holds the Attribute handle.
266
+ attr_reader_method = :"__attribute_definition_#{name}"
267
+ unless method_defined?(attr_reader_method, false)
268
+ define_method(attr_reader_method) { attr }
269
+ end
270
+
271
+ if attr.collection?
272
+ # class_eval interpolates, e.g.:
273
+ # def items=(value)
274
+ # value_set_for(:items)
275
+ # value = ATTR.cast_value(value, lutaml_register)
276
+ # current = @items
277
+ # ... sentinel preservation ...
278
+ # record_mutation_collection(:items, value)
279
+ # end
280
+ # The compiled body writes @items directly: the define_method
281
+ # form interpolated "@items" name strings per call — one per
282
+ # setter invocation on instance-heavy parses (TODO.max-perf/06).
283
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
284
+ def #{name}=(value)
285
+ value_set_for(:#{name})
286
+ value = __attribute_definition_#{name}.cast_value(value, lutaml_register)
287
+ current = @#{name}
288
+ if current.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) &&
161
289
  (value.nil? || Lutaml::Model::Utils.uninitialized?(value))
162
290
  # Sentinel stays — no allocation for truly empty collections
163
291
  else
164
- instance_variable_set(:"@#{name}", value)
292
+ @#{name} = value
165
293
  end
166
- # Track one entry per item so element_order reflects the
167
- # number of <name> elements that will be emitted.
168
- record_mutation_collection(name, value)
294
+ record_mutation_collection(:#{name}, value)
169
295
  end
170
- else
171
- define_method(:"#{name}=") do |value|
172
- value_set_for(name)
173
- value = attr.cast_value(value, lutaml_register)
174
- instance_variable_set(:"@#{name}", value)
175
- record_mutation(name, value)
296
+ RUBY
297
+ else
298
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
299
+ def #{name}=(value)
300
+ value_set_for(:#{name})
301
+ value = __attribute_definition_#{name}.cast_value(value, lutaml_register)
302
+ @#{name} = value
303
+ record_mutation(:#{name}, value)
176
304
  end
177
- end
305
+ RUBY
178
306
  end
179
307
  end
180
308
 
@@ -200,6 +328,7 @@ module Lutaml
200
328
  attr = Attribute.new(name, type, options)
201
329
  @attributes[name] = attr
202
330
  @merged_attributes_cache = nil
331
+ invalidate_state_defaults!
203
332
  define_attribute_methods(attr)
204
333
 
205
334
  attr
@@ -29,6 +29,7 @@ module Lutaml
29
29
  import_declaration_plan
30
30
  polymorphic
31
31
  collection
32
+ __lutaml_child_memo
32
33
  render_empty
33
34
  render_nil
34
35
  cdata
@@ -33,6 +33,11 @@ module Lutaml
33
33
  # The getter materializes a real Array on first access.
34
34
  LAZY_EMPTY_COLLECTION = [].freeze
35
35
 
36
+ # Sentinel distinguishing "getter called with no argument" from
37
+ # builder-syntax `g.attr(value)` where value may be nil. Compiled
38
+ # getters default to it instead of a `*args` splat.
39
+ NO_ARG = Object.new.freeze
40
+
36
41
  INTERNAL_ATTRIBUTES = %i[@using_default @lutaml_register @lutaml_parent @lutaml_root
37
42
  @register_records].freeze
38
43
 
@@ -73,6 +78,49 @@ module Lutaml
73
78
  end
74
79
  end
75
80
 
81
+ # lutaml-model#436: multi-document streams. `from_<f>_stream`
82
+ # parses each document separately; `discriminator:` receives each
83
+ # parsed document hash and returns the model class to build with,
84
+ # enabling polymorphic streams. `to_<f>_stream` joins instances
85
+ # into one stream document.
86
+ def self.register_stream_methods(format)
87
+ ClassMethods.define_method(:"from_#{format}_stream") do |data, options = {}|
88
+ docs = Lutaml::Model::Serialize.stream_documents(data, format, options)
89
+ discriminator = options[:discriminator]
90
+ docs.map do |doc|
91
+ target = discriminator ? discriminator.call(doc) : self
92
+ target.send(:"of_#{format}", doc, options)
93
+ end
94
+ end
95
+
96
+ ClassMethods.define_method(:"to_#{format}_stream") do |instances, options = {}|
97
+ instances.map do |instance|
98
+ instance.send(:"to_#{format}", options)
99
+ end.join("---\n")
100
+ end
101
+ end
102
+
103
+ def self.stream_documents(data, format, options)
104
+ require "yaml"
105
+ if YAML.respond_to?(:load_stream, true)
106
+ begin
107
+ docs = YAML.load_stream(data,
108
+ aliases: options.fetch(:aliases, true))
109
+ rescue ArgumentError
110
+ # Older Psych builds without the aliases keyword: aliases are
111
+ # on by default there.
112
+ docs = YAML.load_stream(data)
113
+ end
114
+ docs.is_a?(Array) ? docs.compact : [docs].compact
115
+ else
116
+ data.split(/^---\s*$/).filter_map do |chunk|
117
+ chunk.strip.empty? ? nil : YAML.safe_load(chunk)
118
+ end
119
+ end
120
+ rescue StandardError => e
121
+ raise Lutaml::Model::InvalidFormatError.new(format, e.message)
122
+ end
123
+
76
124
  def self.register_to_format_method(format)
77
125
  ClassMethods.define_method(:"to_#{format}") do |instance, options = {}|
78
126
  to(format, instance, options)
@@ -108,16 +156,12 @@ module Lutaml
108
156
  @using_default = nil
109
157
  @lutaml_register = register
110
158
 
111
- # Initialize all attributes to their "empty" state.
112
- # Collections use a shared frozen sentinel (zero allocation per instance).
113
- # Non-collections use UninitializedClass.instance (singleton, no allocation).
114
- # This ensures consistent initial state for the deserialization pipeline:
115
- # attributes that don't match any rule keep their UninitializedClass value,
116
- # avoiding the need for the setter to be called as a no-op.
117
- self.class.attributes(register).each do |name, attr|
118
- instance_variable_set(:"@#{name}",
119
- attr.collection? ? LAZY_EMPTY_COLLECTION : Lutaml::Model::UninitializedClass.instance)
120
- end
159
+ # Seed every attribute through the per-(class, register) compiled
160
+ # method: plain `@x = sentinel` writes, no per-call "@#{name}"
161
+ # string interpolation, no instance_variable_set. The previous
162
+ # generic walk interpolated a name string per attribute per
163
+ # instance — 581k allocations on the ISO-13849 profile.
164
+ public_send(self.class.compiled_state_defaults_name!(self.class.extract_register_id(register)))
121
165
  end
122
166
 
123
167
  # Complete deserialization initialization after allocation.
@@ -2,8 +2,8 @@ module Lutaml
2
2
  module Model
3
3
  class Transformer
4
4
  class << self
5
- def call(value, rule, attribute, format: nil)
6
- new(rule, attribute, format).call(value)
5
+ def call(value, rule, attribute, format: nil, context: nil)
6
+ new(rule, attribute, format, context).call(value)
7
7
  end
8
8
 
9
9
  private
@@ -15,7 +15,7 @@ module Lutaml
15
15
  transform.is_a?(::Hash) ? transform[direction] : transform
16
16
  end
17
17
 
18
- def apply_static(value, sources, direction, format)
18
+ def apply_static(value, sources, direction, format, context = nil)
19
19
  methods = sources.filter_map do |obj|
20
20
  get_transform_static(obj, direction)
21
21
  end
@@ -34,16 +34,23 @@ module Lutaml
34
34
  tc.public_send(apply_direction, v, format)
35
35
  end
36
36
 
37
- methods.reduce(result) { |tv, m| m.call(tv) }
37
+ methods.reduce(result) do |tv, m|
38
+ if m.arity == 2
39
+ m.call(tv, context)
40
+ else
41
+ m.call(tv)
42
+ end
43
+ end
38
44
  end
39
45
  end
40
46
 
41
- attr_reader :rule, :attribute, :format
47
+ attr_reader :rule, :attribute, :format, :context
42
48
 
43
- def initialize(rule, attribute, format = nil)
49
+ def initialize(rule, attribute, format = nil, context = nil)
44
50
  @rule = rule
45
51
  @attribute = attribute
46
52
  @format = format
53
+ @context = context
47
54
  end
48
55
 
49
56
  def call(value)
@@ -61,7 +68,19 @@ module Lutaml
61
68
  end
62
69
 
63
70
  methods.reduce(result) do |transformed_value, method|
64
- method.call(transformed_value)
71
+ invoke_transform(method, transformed_value)
72
+ end
73
+ end
74
+
75
+ # lutaml-model#550: `with:` custom methods opt into the options
76
+ # passed to `from_*` / `to_*` by declaring a second parameter
77
+ # (exact arity 2); one-parameter methods keep the historical
78
+ # single-argument call.
79
+ def invoke_transform(method, value)
80
+ if method.arity == 2
81
+ method.call(value, context)
82
+ else
83
+ method.call(value)
65
84
  end
66
85
  end
67
86
 
@@ -87,8 +106,8 @@ module Lutaml
87
106
 
88
107
  class ImportTransformer < Transformer
89
108
  class << self
90
- def call(value, rule, attribute, format: nil)
91
- apply_static(value, [rule, attribute], :import, format)
109
+ def call(value, rule, attribute, format: nil, context: nil)
110
+ apply_static(value, [rule, attribute], :import, format, context)
92
111
  end
93
112
  end
94
113
 
@@ -103,8 +122,8 @@ module Lutaml
103
122
 
104
123
  class ExportTransformer < Transformer
105
124
  class << self
106
- def call(value, rule, attribute, format: nil)
107
- apply_static(value, [attribute, rule], :export, format)
125
+ def call(value, rule, attribute, format: nil, context: nil)
126
+ apply_static(value, [attribute, rule], :export, format, context)
108
127
  end
109
128
  end
110
129
 
@@ -6,15 +6,28 @@ require "weakref" unless Lutaml::Model.opal?
6
6
  module Lutaml
7
7
  module Model
8
8
  class Store
9
- # Compact dead WeakRef shells once a class bucket grows past this size.
9
+ # Compact dead index entries once a class bucket grows past this size.
10
10
  COMPACTION_THRESHOLD = 1000
11
11
 
12
12
  # Once the threshold is exceeded, only compact every Nth subsequent
13
- # register call. Amortises the O(N) reject! over N inserts so
13
+ # register call. Amortises the O(N) prune over N inserts so
14
14
  # register stays O(1) per call rather than O(N) per call (O(N^2)
15
15
  # cumulatively for the class).
16
16
  COMPACTION_INTERVAL = 1000
17
17
 
18
+ WeakBucket = if Lutaml::Model.opal?
19
+ # Opal has neither WeakRef nor ObjectSpace::WeakMap; documents in
20
+ # the browser are small, so strong references are the fallback.
21
+ ::Hash
22
+ else
23
+ # CRuby: WeakMap holds keys weakly with zero Ruby-level
24
+ # allocation per insert and no per-object finalizer — the WeakRef
25
+ # approach allocated one finalizer-registering object per
26
+ # instance (lutaml-model#695) and dominated GC cycles on
27
+ # instance-heavy parses.
28
+ ::ObjectSpace::WeakMap
29
+ end
30
+
18
31
  class << self
19
32
  def instance
20
33
  @instance ||= new
@@ -42,8 +55,10 @@ module Lutaml
42
55
  end
43
56
 
44
57
  def initialize
45
- @store = ::Hash.new { |hash, key| hash[key] = [] }
46
- # Nested index: { model_key => { reference_key => { value => WeakRef(object) } } }
58
+ # WeakMap-based buckets: value presence marks liveness, no
59
+ # per-instance allocation (lutaml-model#695).
60
+ @store = ::Hash.new { |hash, key| hash[key] = WeakBucket.new }
61
+ # Nested index: { model_key => { reference_key => { value => object } } }
47
62
  # Grouped by model_key so register only iterates this class's own indices.
48
63
  @index = {}
49
64
  @inserts_since_compaction = ::Hash.new(0)
@@ -52,11 +67,10 @@ module Lutaml
52
67
 
53
68
  def register(object)
54
69
  model_key = object.class.to_s
55
- refs = @store[model_key]
56
- refs << WeakRef.new(object)
70
+ @store[model_key][object] = true
57
71
  @inserts_since_compaction[model_key] += 1
58
72
 
59
- compact_if_needed(refs, model_key)
73
+ compact_if_needed(model_key)
60
74
 
61
75
  update_existing_indices(object, model_key)
62
76
  end
@@ -78,25 +92,26 @@ module Lutaml
78
92
  obj
79
93
  end
80
94
 
95
+ def live_objects(model_key)
96
+ bucket = @store[model_key]
97
+ return [] unless bucket
98
+
99
+ live_objects_for(bucket)
100
+ end
101
+
81
102
  def clear
82
- @store = ::Hash.new { |hash, key| hash[key] = [] }
103
+ @store = ::Hash.new { |hash, key| hash[key] = WeakBucket.new }
83
104
  @index = {}
84
105
  @inserts_since_compaction = ::Hash.new(0)
85
106
  @compaction_count = 0
86
107
  end
87
108
 
88
109
  def store
89
- @store.transform_values do |refs|
90
- refs.each_with_object([]) do |ref, alive|
91
- alive << ref.__getobj__ if ref.weakref_alive?
92
- rescue WeakRef::RefError
93
- nil
94
- end
95
- end
110
+ @store.transform_values { |refs| live_objects_for(refs) }
96
111
  end
97
112
 
98
113
  def refs_for(model_key)
99
- @store[model_key]
114
+ LiveView.new(self, model_key)
100
115
  end
101
116
 
102
117
  def inserts_since_compaction
@@ -117,15 +132,15 @@ module Lutaml
117
132
  @index[model_key] ||= {}
118
133
  end
119
134
 
120
- # Build index for a (model_class, reference_key) pair by scanning existing instances.
135
+ # Build index for a (model_class, reference_key) pair by scanning
136
+ # live instances. Index values hold WeakRefs: the index must not
137
+ # pin objects (only the rare classes with resolved reference keys
138
+ # pay this; the per-instance bucket stays allocation-free WeakMap).
121
139
  def build_index(model_indices, model_key, reference_key)
122
140
  entries = model_indices[reference_key] = {}
123
- @store[model_key]&.each do |ref|
124
- obj = ref.__getobj__
141
+ each_live(model_key) do |obj|
125
142
  value = obj.public_send(reference_key)
126
143
  entries[value] = WeakRef.new(obj) if value
127
- rescue WeakRef::RefError
128
- next
129
144
  end
130
145
  end
131
146
 
@@ -136,11 +151,9 @@ module Lutaml
136
151
  model_indices = @index[model_key]
137
152
  return unless model_indices
138
153
 
139
- model_indices.each do |reference_key, entries|
154
+ model_indices.each_key do |reference_key|
140
155
  value = object.public_send(reference_key)
141
- entries[value] = WeakRef.new(object) if value
142
- rescue WeakRef::RefError
143
- next
156
+ model_indices[reference_key][value] = WeakRef.new(object) if value
144
157
  end
145
158
  end
146
159
 
@@ -150,17 +163,11 @@ module Lutaml
150
163
  nil
151
164
  end
152
165
 
153
- def compact_if_needed(refs, model_key)
154
- return unless refs.size > COMPACTION_THRESHOLD
166
+ def compact_if_needed(model_key)
155
167
  return unless @inserts_since_compaction[model_key] >= COMPACTION_INTERVAL
156
168
 
157
169
  @inserts_since_compaction[model_key] = 0
158
170
  @compaction_count += 1
159
- refs.reject! do |ref|
160
- !ref.weakref_alive?
161
- rescue WeakRef::RefError
162
- true
163
- end
164
171
  prune_index(model_key)
165
172
  end
166
173
 
@@ -177,6 +184,37 @@ module Lutaml
177
184
  entries.empty?
178
185
  end
179
186
  end
187
+
188
+ def each_live(model_key, &block)
189
+ @store[model_key]&.each_key(&block)
190
+ end
191
+
192
+ def live_objects_for(bucket)
193
+ # WeakMap#each_key requires a block on CRuby; WeakMap#each
194
+ # yields key/value pairs, and Hash buckets enumerate the same
195
+ # way (value is `true` for WeakMap, the object for the Opal
196
+ # fallback keyed by object identity).
197
+ bucket.map { |key, _value| key }
198
+ end
199
+
200
+ # Array-shaped live view over a class's WeakMap bucket, for
201
+ # specs/debugging (refs_for used to expose the raw array).
202
+ class LiveView
203
+ include Enumerable
204
+
205
+ def initialize(store, model_key)
206
+ @store = store
207
+ @model_key = model_key
208
+ end
209
+
210
+ def each(&block)
211
+ @store.live_objects(@model_key).each(&block)
212
+ end
213
+
214
+ def size
215
+ @store.live_objects(@model_key).size
216
+ end
217
+ end
180
218
  end
181
219
  end
182
220
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.36"
5
+ VERSION = "0.8.38"
6
6
  end
7
7
  end
@@ -21,6 +21,7 @@ end
21
21
 
22
22
  module Lutaml
23
23
  module Xml
24
+ autoload :HtmlEntities, "#{__dir__}/html_entities"
24
25
  autoload :PlanCompiler, "#{__dir__}/plan_compiler"
25
26
  autoload :PlanHydrator, "#{__dir__}/plan_hydrator"
26
27
  autoload :PlanOrder, "#{__dir__}/plan_order"