lutaml-model 0.8.41 → 0.8.43

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: 6fc47a2bee1b20995c17426890ae3fdc3f9db24317a8cb0c83ea3e81f13372be
4
- data.tar.gz: 72b52030485d681b901b077b75195a40d7779c0ec12df982e8bc7f49b3ef9dad
3
+ metadata.gz: fe2357c36c2a199a3788090dffd420874612482e21e7ac678bd5f1e6fdd575ce
4
+ data.tar.gz: a3d601af07558b2915dce9f4df383c00b905505c8fbcd192a89ad88b3e66ea49
5
5
  SHA512:
6
- metadata.gz: dc281b89a9c032cff1f2a38de58b465dccc5385421d9daed7a8244adec62a282a79163b392224a2ead89d93281bbf1d6f5772f879f088a930332ae8821c7784e
7
- data.tar.gz: 1d0c2b049cc9c45e7dfe0dd0a6ec6326c98011ab09c6a9f32eff39b36a67cfce1dd97f20341d301258acfc9c9eb282d43718d3a7ab0e0401ae29e6d1a936d724
6
+ metadata.gz: c82bb3761127b4beb8f2d267a60d107d69eb509cd2fd094763eed5ab5d0814497252db4cd1311709072ab829b156aac46f839852bcc4f20f37e627aa7b5daea7
7
+ data.tar.gz: 32aa679d229c03ccccf0ee8f1359f9559309b92f5493a0207fd5a495e0f8ef1a0cc3255a0e4e30c2e7c5319e8910a8863286116d6a0e4bfe72af1a0b8be2c71f
@@ -562,3 +562,35 @@ end
562
562
  ====
563
563
 
564
564
 
565
+
566
+ === Attribute-value dispatch on a shared element name
567
+
568
+ When several distinct attributes share one wire element, selected by a
569
+ sibling attribute's value, declare the discriminator with `when_attribute:`
570
+
571
+ [source,ruby]
572
+ ----
573
+ class Requirement < Lutaml::Model::Serializable
574
+ attribute :guidance, Component, collection: true
575
+ attribute :purpose, Component, collection: true
576
+
577
+ xml do
578
+ element "requirement"
579
+ map_element "component", to: :guidance,
580
+ when_attribute: { "type" => "guidance" }
581
+ map_element "component", to: :purpose,
582
+ when_attribute: { "type" => "purpose" }
583
+ end
584
+ end
585
+ ----
586
+
587
+ Each rule hydrates only from the `<component>` occurrences whose `type`
588
+ attribute carries the expected value, in document order; uncovered values
589
+ are ignored by all discriminator rules. On serialization the discriminator
590
+ pair is re-emitted on the element (unless the value's own mapping already
591
+ writes that attribute).
592
+
593
+ Co-occurrence semantics: several `when_attribute` rules may share the
594
+ element name; each target attribute fills independently, so declare each
595
+ as `collection: true` when the element repeats. Rules without
596
+ `when_attribute` keep claiming every occurrence of their name as before.
@@ -78,7 +78,8 @@ module Lutaml
78
78
  polymorphic: {},
79
79
  polymorphic_map: {},
80
80
  transform: {},
81
- value_map: {}
81
+ value_map: {},
82
+ serialize: true
82
83
  )
83
84
  mapping_name = name_for_mapping(root_mappings, name)
84
85
  validate!(mapping_name, to, with, render_nil, render_empty)
@@ -100,6 +101,7 @@ module Lutaml
100
101
  polymorphic_map: polymorphic_map,
101
102
  transform: transform,
102
103
  value_map: value_map,
104
+ serialize: serialize,
103
105
  )
104
106
  end
105
107
 
@@ -22,7 +22,8 @@ module Lutaml
22
22
  polymorphic: {},
23
23
  polymorphic_map: {},
24
24
  transform: {},
25
- value_map: {}
25
+ value_map: {},
26
+ serialize: true
26
27
  )
27
28
  super(
28
29
  name,
@@ -41,6 +42,7 @@ module Lutaml
41
42
  polymorphic_map: polymorphic_map,
42
43
  transform: transform,
43
44
  value_map: value_map,
45
+ serialize: serialize,
44
46
  )
45
47
 
46
48
  @child_mappings = child_mappings
@@ -65,6 +67,7 @@ module Lutaml
65
67
  child_mappings: Lutaml::Model::Utils.deep_dup(child_mappings),
66
68
  root_mappings: Lutaml::Model::Utils.deep_dup(root_mappings),
67
69
  value_map: Lutaml::Model::Utils.deep_dup(@value_map),
70
+ serialize: serialize?,
68
71
  )
69
72
  end
70
73
 
@@ -127,6 +127,8 @@ module Lutaml
127
127
  end
128
128
 
129
129
  def valid_mapping?(rule, options)
130
+ return false if rule.respond_to?(:serialize?) && !rule.serialize?
131
+
130
132
  only = options[:only]
131
133
  except = options[:except]
132
134
  name = rule.to
@@ -230,6 +230,7 @@ transformation_factory:)
230
230
  custom_methods: custom_methods,
231
231
  delegate: delegate,
232
232
  root_mappings: mapping_rule.root_mappings,
233
+ serialize: mapping_rule.serialize?,
233
234
  )
234
235
  end
235
236
 
@@ -239,6 +240,8 @@ transformation_factory:)
239
240
  # @param options [Hash] Transformation options (may contain :only, :except)
240
241
  # @return [Boolean] true if the rule should be applied
241
242
  def valid_mapping?(rule, options)
243
+ return false if rule.option(:serialize) == false
244
+
242
245
  only = options[:only]
243
246
  except = options[:except]
244
247
  name = rule.attribute_name
@@ -172,30 +172,56 @@ module Lutaml
172
172
  end
173
173
  end
174
174
 
175
- # Non-default register path: resolve directly without normalize_context
176
- # when context_or_register is already a TypeContext or Symbol (common cases)
177
- context = case context_or_register
178
- when Lutaml::Model::TypeContext
179
- context_or_register
180
- when Symbol
181
- GlobalContext.context(context_or_register) || GlobalContext.default_context
182
- when Lutaml::Model::Register
183
- GlobalContext.context(context_or_register.id) || GlobalContext.default_context
184
- else
185
- GlobalContext.default_context
186
- end
187
-
188
- # Performance: Cache per TypeContext identity.
189
- # When type substitution replaces a TypeContext, the object_id changes,
190
- # so old cache entries are never hit again (safe invalidation).
191
- cache_key = context.object_id
192
- @type_cache ||= {}
193
- cached = @type_cache[cache_key]
194
- return cached if cached
195
-
196
- resolved = resolver.resolve(unresolved_type, context)
197
- @type_cache[cache_key] = resolved
198
- resolved
175
+ # Register/Symbol path: the resolution is a static fact of
176
+ # (attribute, register id), but the old code walked
177
+ # GlobalContext.context — a singleton hop, a delegation, a to_sym
178
+ # normalization and a registry lookup — on every call (2.4 M calls,
179
+ # 3.9 M GlobalContext#context hits per ISO-13849 parse). Cache keyed
180
+ # on the register id itself; a context replaced under the same id
181
+ # bumps GlobalContext.context_generation and flushes the cache.
182
+ register_id = case context_or_register
183
+ when Symbol
184
+ context_or_register
185
+ when Lutaml::Model::Register
186
+ context_or_register.id
187
+ end
188
+
189
+ if register_id
190
+ cache = (@type_cache_by_register ||= {})
191
+ generation = GlobalContext.context_generation
192
+ if @type_cache_generation != generation
193
+ cache.clear
194
+ @type_cache_generation = generation
195
+ end
196
+ cached = cache[register_id]
197
+ return cached if cached
198
+
199
+ context = GlobalContext.context(register_id)
200
+ # An unregistered id falls back to the (thread-local) default
201
+ # context — do not pin that answer in the shared cache.
202
+ return resolver.resolve(unresolved_type, context || GlobalContext.default_context) unless context
203
+
204
+ resolved = resolver.resolve(unresolved_type, context)
205
+ cache[register_id] = resolved
206
+ resolved
207
+ elsif context_or_register.is_a?(Lutaml::Model::TypeContext)
208
+ # Performance: Cache per TypeContext identity.
209
+ # When type substitution replaces a TypeContext, the object_id changes,
210
+ # so old cache entries are never hit again (safe invalidation).
211
+ cache_key = context_or_register.object_id
212
+ @type_cache ||= {}
213
+ cached = @type_cache[cache_key]
214
+ return cached if cached
215
+
216
+ resolved = resolver.resolve(unresolved_type, context_or_register)
217
+ @type_cache[cache_key] = resolved
218
+ resolved
219
+ else
220
+ @cached_type_default ||= begin
221
+ context = normalize_context(context_or_register)
222
+ resolver.resolve(unresolved_type, context)
223
+ end
224
+ end
199
225
  end
200
226
 
201
227
  # @api public
@@ -305,7 +331,10 @@ module Lutaml
305
331
  EMPTY_TRANSFORM_HASH = {}.freeze
306
332
 
307
333
  def transform
308
- @options[:transform] || EMPTY_TRANSFORM_HASH
334
+ # Options are fixed after definition (value_policy memoizes the
335
+ # same way) — the per-call @options[:transform] Hash#[] was hit
336
+ # 2.9 M times on the ISO-13849 parse profile.
337
+ @transform ||= @options[:transform] || EMPTY_TRANSFORM_HASH
309
338
  end
310
339
 
311
340
  def method_name
@@ -438,7 +467,7 @@ module Lutaml
438
467
  def cast_element(value, register)
439
468
  # Resolve first: an undeclared type has to raise UnknownTypeError
440
469
  # even for a nil value (the 0.8.33 behavior).
441
- type(register)
470
+ resolved_type = type(register)
442
471
 
443
472
  return value if Utils.uninitialized?(value)
444
473
 
@@ -448,7 +477,7 @@ module Lutaml
448
477
  # manufacture a typed INSTANCE from nothing: that is the
449
478
  # phantom #793 removed, suppressed by the post-check below.
450
479
  if value.nil?
451
- result = cast_element_present(value, register)
480
+ result = cast_element_present(value, register, resolved_type)
452
481
  return result if result.nil?
453
482
  return nil if result.is_a?(Lutaml::Model::Type::Value) ||
454
483
  result.is_a?(Lutaml::Model::Serialize)
@@ -456,11 +485,11 @@ module Lutaml
456
485
  return result
457
486
  end
458
487
 
459
- cast_element_present(value, register)
488
+ cast_element_present(value, register, resolved_type)
460
489
  end
461
490
 
462
- def cast_element_present(value, register)
463
- resolved_type = type(register)
491
+ def cast_element_present(value, register, resolved_type = nil)
492
+ resolved_type ||= type(register)
464
493
 
465
494
  return cast_union(value, nil, register) if union?
466
495
  return resolved_type.new(value) if value.is_a?(::Hash) && !hash_type?
@@ -474,8 +503,10 @@ module Lutaml
474
503
  # The castability verdict is a class-hierarchy fact, immutable
475
504
  # per resolved type — check once, not per value (TODO.max-perf/15).
476
505
  checked = (@castable_types ||= {}.compare_by_identity)
477
- validate_attr_type!(resolved_type) unless checked[resolved_type]
478
- checked[resolved_type] = true
506
+ unless checked[resolved_type]
507
+ validate_attr_type!(resolved_type)
508
+ checked[resolved_type] = true
509
+ end
479
510
 
480
511
  resolved_type.cast(value)
481
512
  end
@@ -518,6 +549,7 @@ instance_object = nil)
518
549
  def immutable_value?(value)
519
550
  value.nil? || value.is_a?(Numeric) || value.is_a?(String) ||
520
551
  value.is_a?(Symbol) || value == true || value == false ||
552
+ value.equal?(::Lutaml::Model::UninitializedClass.instance) ||
521
553
  value.frozen?
522
554
  end
523
555
 
@@ -925,6 +957,11 @@ instance_object = nil)
925
957
  Lutaml::Model::Type::Union.validate_members!(@options[:union_member_types])
926
958
  Lutaml::Model::Type::Union.validate_combo!(@options)
927
959
  end
960
+ # `restrict` merges new options into the SAME attribute object
961
+ # post-construction — the option-derived memos must reset here or
962
+ # collection?/transform keep answering from the pre-restrict state.
963
+ remove_instance_variable(:@collection_option) if defined?(@collection_option)
964
+ remove_instance_variable(:@transform) if defined?(@transform)
928
965
  @raw = !!@options[:raw]
929
966
  if @raw
930
967
  warn "[DEPRECATED] attribute :#{name}, :string, raw: true is deprecated. " \
@@ -1031,6 +1068,8 @@ instance_object = nil)
1031
1068
  @type_cache&.clear
1032
1069
  @cached_type_default = nil
1033
1070
  @default_type_context = nil
1071
+ @type_cache_by_register&.clear
1072
+ @type_cache_generation = nil
1034
1073
  end
1035
1074
 
1036
1075
  # Public validation contract: mappings check their targets at
@@ -14,7 +14,9 @@ module Lutaml
14
14
  #
15
15
  # @return [Object, nil] The collection option value
16
16
  def collection
17
- @options[:collection]
17
+ @collection_option = @options[:collection] unless defined?(@collection_option)
18
+
19
+ @collection_option
18
20
  end
19
21
 
20
22
  # Check if this attribute is a collection
@@ -59,7 +61,17 @@ module Lutaml
59
61
  # @param register [Symbol, nil] Register for custom-collection type resolution
60
62
  # @return [Object] A new collection instance
61
63
  def build_collection(*args, register: nil)
62
- items = args.flatten
64
+ # args.flatten allocated a fresh Array even for the dominant
65
+ # calls: no args, or one already-flat Array from cast_items
66
+ # (71,949 flatten allocations per ISO-13849 parse).
67
+ items = if args.empty?
68
+ []
69
+ elsif args.length == 1 && (first = args[0]).is_a?(::Array) &&
70
+ first.none?(::Array)
71
+ first
72
+ else
73
+ args.flatten
74
+ end
63
75
  unless register && initialize_accepts_register?
64
76
  return collection_class.new(items)
65
77
  end
@@ -64,6 +64,18 @@ module Lutaml
64
64
  @default_context_id = :default
65
65
  @namespace_register_map = {} # namespace_uri => register_id
66
66
  @mutex = Mutex.new
67
+ @context_generation = 0
68
+ end
69
+
70
+ # Bumped whenever a named context is registered, replaced,
71
+ # unregistered, or the registry is reset. Callers that cache a
72
+ # resolution keyed on a context ID compare this generation to
73
+ # invalidate — a replaced context under the same ID must not keep
74
+ # answering from a stale cache.
75
+ #
76
+ # @return [Integer]
77
+ def context_generation
78
+ @context_generation
67
79
  end
68
80
 
69
81
  # Get the current default context.
@@ -123,6 +135,10 @@ module Lutaml
123
135
  # @return [void]
124
136
  def register_context(context)
125
137
  @registry.register(context)
138
+ # A replacement under an existing id must not keep answering from
139
+ # the resolver's [context.id, name]-keyed cache.
140
+ @resolver.clear_cache(context.id)
141
+ @context_generation += 1
126
142
  end
127
143
 
128
144
  # Create and register a new context.
@@ -133,12 +149,14 @@ module Lutaml
133
149
  # @param substitutions [Array<TypeSubstitution, Hash>] Type substitutions
134
150
  # @return [TypeContext] The created context
135
151
  def create_context(id:, registry: nil, fallback_to: [], substitutions: [])
136
- @registry.create(
152
+ context = @registry.create(
137
153
  id: id,
138
154
  registry: registry,
139
155
  fallback_to: fallback_to,
140
156
  substitutions: substitutions,
141
157
  )
158
+ @context_generation += 1
159
+ context
142
160
  end
143
161
 
144
162
  # Unregister a context and clear its caches.
@@ -148,6 +166,7 @@ module Lutaml
148
166
  def unregister_context(id)
149
167
  @resolver.clear_cache(id)
150
168
  @registry.unregister(id)
169
+ @context_generation += 1
151
170
  end
152
171
 
153
172
  # Execute a block with a specific context as default.
@@ -185,6 +204,7 @@ module Lutaml
185
204
  end
186
205
  @namespace_register_map.clear
187
206
  @default_context_id = :default
207
+ @context_generation += 1
188
208
  end
189
209
  end
190
210
 
@@ -363,6 +383,10 @@ context_id = nil)
363
383
  instance.context(id)
364
384
  end
365
385
 
386
+ def context_generation
387
+ instance.context_generation
388
+ end
389
+
366
390
  def clear_xml_namespace_registry!
367
391
  instance.clear_xml_namespace_registry!
368
392
  end
@@ -70,7 +70,9 @@ module Lutaml
70
70
  polymorphic: {},
71
71
  polymorphic_map: {},
72
72
  transform: {},
73
- value_map: {}
73
+ value_map: {},
74
+ serialize: true,
75
+ when_attribute: {}
74
76
  )
75
77
  @name = name
76
78
  @to = to
@@ -92,6 +94,11 @@ module Lutaml
92
94
  @polymorphic = polymorphic
93
95
  @polymorphic_map = polymorphic_map
94
96
  @transform = transform
97
+ @serialize_mapping = serialize
98
+ # Attribute-value discriminator (lutaml-model#88): select this
99
+ # rule's wire occurrences by a sibling attribute value, e.g.
100
+ # `when_attribute: { "type" => "guidance" }`.
101
+ @when_attribute = (when_attribute || {}).freeze
95
102
 
96
103
  # Cache whether this rule needs the full deserialize chain.
97
104
  # Over 95% of rules are "simple" (no custom method, no delegate).
@@ -122,6 +129,15 @@ module Lutaml
122
129
  @value_map[:from].freeze
123
130
  @value_map[:to].freeze
124
131
  @value_map.freeze
132
+
133
+ # The :from treatment flags are read on every rule application of
134
+ # every parse; they are static once @value_map is final. The three
135
+ # Hash#[] chains per value used to cost 1.4 M value_map calls per
136
+ # ISO-13849 parse.
137
+ from_map = @value_map[:from]
138
+ @treat_nil_flag = from_map[:nil] != :omitted
139
+ @treat_empty_flag = from_map[:empty] != :omitted
140
+ @treat_omitted_flag = from_map[:omitted] != :omitted
125
141
  end
126
142
 
127
143
  def default_value_map(options = {})
@@ -165,6 +181,11 @@ module Lutaml
165
181
 
166
182
  alias from name
167
183
  alias render_default? render_default
184
+
185
+ # Whether this rule participates in serialization output. False
186
+ # marks a hydrate-only mapping: consumed by from_* but never
187
+ # rendered by to_*. (The value callback #serialize is separate.)
188
+ def serialize? = @serialize_mapping
168
189
  alias attribute? attribute
169
190
 
170
191
  def render?(value, instance = nil, options = {})
@@ -243,14 +264,20 @@ module Lutaml
243
264
  end
244
265
 
245
266
  def treat_nil?(options = {})
267
+ return @treat_nil_flag unless options[:nil] || options[:empty] || options[:omitted]
268
+
246
269
  value_map(:from, options)[:nil] != :omitted
247
270
  end
248
271
 
249
272
  def treat_empty?(options = {})
273
+ return @treat_empty_flag unless options[:nil] || options[:empty] || options[:omitted]
274
+
250
275
  value_map(:from, options)[:empty] != :omitted
251
276
  end
252
277
 
253
278
  def treat_omitted?(options = {})
279
+ return @treat_omitted_flag unless options[:nil] || options[:empty] || options[:omitted]
280
+
254
281
  value_map(:from, options)[:omitted] != :omitted
255
282
  end
256
283
 
@@ -313,6 +340,16 @@ context = nil)
313
340
  # each time (240k allocations on the ISO-13849 serialize profile).
314
341
  # Rules may be frozen, so no per-instance memo: one to_s instead
315
342
  # of two is the frozen-safe diet.
343
+ # Discriminator pairs this rule selects (and re-emits) on, empty
344
+ # for an ordinary rule.
345
+ def when_attribute
346
+ @when_attribute ||= {}.freeze
347
+ end
348
+
349
+ def when_attribute?
350
+ !when_attribute.empty?
351
+ end
352
+
316
353
  def custom_method_only?
317
354
  name = attribute_name.to_s
318
355
  (name.start_with?("__") && name.end_with?("__")) ||
@@ -402,6 +439,47 @@ context = nil)
402
439
  # Frozen empty array for the common case of no transformers
403
440
  EMPTY_TRANSFORMERS = [].freeze
404
441
 
442
+ # Verdict for handle_transform_method's dispatch, computed once per
443
+ # (rule, attribute) — both are static after definition, but rules may
444
+ # be frozen, so the memo lives at class level keyed on identity.
445
+ # :assign — no transformer runs on deserialize (fast path);
446
+ # :import — hash/proc transformers go through ImportTransformer.
447
+ TRANSFORM_DISPATCH = {}.compare_by_identity
448
+
449
+ def self.transform_dispatch(rule, attr)
450
+ per_attr = TRANSFORM_DISPATCH[rule]
451
+ if per_attr.nil?
452
+ per_attr = {}.compare_by_identity
453
+ TRANSFORM_DISPATCH[rule] = per_attr
454
+ end
455
+ verdict = per_attr[attr]
456
+ return verdict if verdict
457
+
458
+ rule_transform = rule.transform
459
+ # NB: `::Hash` — inside Lutaml::Model the bare constant `Hash` is
460
+ # the hash-format adapter class, so a plain is_a?(Hash) is always
461
+ # false here (the original dispatch shared that shadow and never
462
+ # took the hash-transform branch).
463
+ rule_has = rule_transform.is_a?(Class) ||
464
+ (rule_transform.is_a?(::Hash) && !rule_transform.empty?)
465
+ attr_transform = attr&.transform
466
+ attr_has = attr_transform &&
467
+ (attr_transform.is_a?(Class) ||
468
+ (attr_transform.is_a?(::Hash) && !attr_transform.empty?))
469
+
470
+ verdict = if !rule_has && !attr_has
471
+ :assign
472
+ elsif rule.get_transformers(attr).any? do |t|
473
+ t.is_a?(Class) && t < Lutaml::Model::ValueTransformer
474
+ end
475
+ :assign
476
+ else
477
+ :import
478
+ end
479
+ per_attr[attr] = verdict
480
+ verdict
481
+ end
482
+
405
483
  def get_transformers(attribute)
406
484
  # Fast path: most rules have no transforms at all
407
485
  rule_transform = transform
@@ -480,28 +558,16 @@ context = nil)
480
558
 
481
559
  def handle_transform_method(model, value, attributes, context = nil)
482
560
  attr = attributes[to]
483
- # Fast path: no transforms at all (covers 95%+ of rules)
484
- # transform defaults to {} which is truthy but semantically empty
485
- rule_has_transform = transform.is_a?(Class) || (transform.is_a?(Hash) && !transform.empty?)
486
- attr_has_transform = attr&.transform && (attr.transform.is_a?(Class) || (attr.transform.is_a?(Hash) && !attr.transform.empty?))
487
- if !rule_has_transform && !attr_has_transform
488
- assign_value(model, value)
489
- return true
490
- end
491
-
492
- # If we have class-based transformers, they were already applied in transform_value
493
- # Only call ImportTransformer for hash/proc-based transformers
494
- transformers = get_transformers(attr)
495
- has_class_transformer = transformers.any? { |t| t.is_a?(Class) && t < Lutaml::Model::ValueTransformer }
496
-
497
- if has_class_transformer
498
- # Class transformers already applied, just assign the value
499
- assign_value(model, value)
500
- else
501
- # Hash/proc transformers need ImportTransformer
561
+ # The transform verdict (none / class-based already applied /
562
+ # hash-proc via ImportTransformer) is static per (rule, attr) —
563
+ # it was recomputed with is_a? chains on every rule application
564
+ # (16.8 M Kernel#is_a? per ISO-13849 parse across the walk).
565
+ if self.class.transform_dispatch(self, attr) == :import
502
566
  transformed = ImportTransformer.call(value, self, attr,
503
567
  context: context)
504
568
  assign_value(model, transformed)
569
+ else
570
+ assign_value(model, value)
505
571
  end
506
572
  true
507
573
  end
@@ -87,12 +87,18 @@ module Lutaml
87
87
  (RESOLVE_CACHE[child_class] ||= {})[parent_register] =
88
88
  _resolve_for_child_uncached(child_class, parent_register)
89
89
  else
90
+ # Read-first: Concurrent::Map#compute_if_absent takes the mutex
91
+ # on EVERY call, hits included — 1.35 M synchronized blocks per
92
+ # ISO-13849 parse for answers that never change. The plain #[]
93
+ # read is lock-free; nil is a valid cached answer, hence key?.
90
94
  # Nested maps: class → register → result. Every hop keys on a
91
95
  # stable object (Class / Symbol), so the hot lookup allocates
92
96
  # nothing — the flat [object_id, register] array key cost one
93
97
  # Array per call, six per parsed element.
94
98
  per_class = RESOLVE_CACHE[child_class] # rubocop:todo Style/IdenticalConditionalBranches
95
- per_class ||= RESOLVE_CACHE.compute_if_absent(child_class) do
99
+ return per_class[parent_register] if per_class&.key?(parent_register)
100
+
101
+ per_class = RESOLVE_CACHE.compute_if_absent(child_class) do
96
102
  Concurrent::Map.new
97
103
  end
98
104
  per_class.compute_if_absent(parent_register) do
@@ -122,12 +122,26 @@ module Lutaml
122
122
  # @param register [Symbol, nil] The register context
123
123
  # @return [Hash] The attributes hash
124
124
  def attributes(register = nil)
125
- ensure_imports!(register) if finalized?
125
+ # Cache hit first: a filled entry can only exist after imports
126
+ # were ensured for that register (the fill path below runs
127
+ # ensure_imports! first), and mutations that invalidate the
128
+ # merge clear @merged_attributes_cache. The lookup was walked
129
+ # per rule application (751k per ISO-13849 parse).
126
130
  if @register_records&.any?
127
131
  register_id = extract_register_id(register)
128
- (@merged_attributes_cache ||= {})[register_id] ||=
129
- @attributes.merge(@register_records[register_id][:attributes])
132
+ cached = (@merged_attributes_cache ||= {})[register_id]
133
+ return cached if cached
134
+
135
+ ensure_imports!(register) if finalized?
136
+ # ensure_imports! may re-entrantly run clear_cache (restrict /
137
+ # import resolution), which nils @merged_attributes_cache —
138
+ # compute the merge first and re-materialize the memo store
139
+ # at write time (#815: standalone to_xml on register-bound
140
+ # models crashed with nil[] here).
141
+ merged = @attributes.merge(@register_records[register_id][:attributes])
142
+ (@merged_attributes_cache ||= {})[register_id] = merged
130
143
  else
144
+ ensure_imports!(register) if finalized?
131
145
  @attributes
132
146
  end
133
147
  end
@@ -241,12 +241,21 @@ module Lutaml
241
241
  end
242
242
 
243
243
  def respond_to_missing?(method_name, include_private = false)
244
- (method_name.to_s.end_with?("=") && attribute_exist?(method_name)) ||
245
- super
244
+ # Setter probes from non-compiled dispatch land here ~24k times per
245
+ # ISO-13849 parse; strip the suffix once instead of re-walking
246
+ # attribute_exist?'s to_s/chomp/to_sym chain.
247
+ name = method_name.to_s
248
+ if name.end_with?("=") &&
249
+ self.class.attributes(lutaml_register)
250
+ .key?(name.delete_suffix("=").to_sym)
251
+ return true
252
+ end
253
+
254
+ super
246
255
  end
247
256
 
248
257
  def attribute_exist?(name)
249
- name = name.to_s.chomp("=").to_sym if name.end_with?("=")
258
+ name = name.to_s.delete_suffix("=").to_sym if name.end_with?("=")
250
259
 
251
260
  self.class.attributes(lutaml_register).key?(name)
252
261
  end
@@ -5,7 +5,7 @@ module Lutaml
5
5
  module Type
6
6
  class String < Value
7
7
  # Performance-optimized cast with short-circuit for already-correct types
8
- def self.cast(value, options = {})
8
+ def self.cast(value, options = EMPTY_OPTIONS)
9
9
  return nil if value.nil?
10
10
  return value if Utils.uninitialized?(value)
11
11
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.41"
5
+ VERSION = "0.8.43"
6
6
  end
7
7
  end