lutaml-model 0.8.41 → 0.8.42

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: 1cd747883eee7ea6a87aea0c708e6054f1f6cf9f7b06e295b32f1b7ba06a8a75
4
+ data.tar.gz: 639b21ad4dd77104b3bce40f0ba7bad925a5b608806e6e8979b50cb68f57b70e
5
5
  SHA512:
6
- metadata.gz: dc281b89a9c032cff1f2a38de58b465dccc5385421d9daed7a8244adec62a282a79163b392224a2ead89d93281bbf1d6f5772f879f088a930332ae8821c7784e
7
- data.tar.gz: 1d0c2b049cc9c45e7dfe0dd0a6ec6326c98011ab09c6a9f32eff39b36a67cfce1dd97f20341d301258acfc9c9eb282d43718d3a7ab0e0401ae29e6d1a936d724
6
+ metadata.gz: 05174a52193320261f496e92ac656b6ddc5a47167db5b98b3682c0397a4c9fb0f2a58f20e8be689d7f35486bea63dc2228d367574c3834587be6d5ba97306cd9
7
+ data.tar.gz: a6efef58f862390afe6fcf903ce3ff4d829d451079b7a1762990ed0e0bf1c5623c798c712a485d8e81cdc415189a37e9c5f7b1c02dd2d03ad6c17d96b5bac5a0
@@ -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
@@ -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
@@ -122,6 +122,15 @@ module Lutaml
122
122
  @value_map[:from].freeze
123
123
  @value_map[:to].freeze
124
124
  @value_map.freeze
125
+
126
+ # The :from treatment flags are read on every rule application of
127
+ # every parse; they are static once @value_map is final. The three
128
+ # Hash#[] chains per value used to cost 1.4 M value_map calls per
129
+ # ISO-13849 parse.
130
+ from_map = @value_map[:from]
131
+ @treat_nil_flag = from_map[:nil] != :omitted
132
+ @treat_empty_flag = from_map[:empty] != :omitted
133
+ @treat_omitted_flag = from_map[:omitted] != :omitted
125
134
  end
126
135
 
127
136
  def default_value_map(options = {})
@@ -243,14 +252,20 @@ module Lutaml
243
252
  end
244
253
 
245
254
  def treat_nil?(options = {})
255
+ return @treat_nil_flag unless options[:nil] || options[:empty] || options[:omitted]
256
+
246
257
  value_map(:from, options)[:nil] != :omitted
247
258
  end
248
259
 
249
260
  def treat_empty?(options = {})
261
+ return @treat_empty_flag unless options[:nil] || options[:empty] || options[:omitted]
262
+
250
263
  value_map(:from, options)[:empty] != :omitted
251
264
  end
252
265
 
253
266
  def treat_omitted?(options = {})
267
+ return @treat_omitted_flag unless options[:nil] || options[:empty] || options[:omitted]
268
+
254
269
  value_map(:from, options)[:omitted] != :omitted
255
270
  end
256
271
 
@@ -402,6 +417,47 @@ context = nil)
402
417
  # Frozen empty array for the common case of no transformers
403
418
  EMPTY_TRANSFORMERS = [].freeze
404
419
 
420
+ # Verdict for handle_transform_method's dispatch, computed once per
421
+ # (rule, attribute) — both are static after definition, but rules may
422
+ # be frozen, so the memo lives at class level keyed on identity.
423
+ # :assign — no transformer runs on deserialize (fast path);
424
+ # :import — hash/proc transformers go through ImportTransformer.
425
+ TRANSFORM_DISPATCH = {}.compare_by_identity
426
+
427
+ def self.transform_dispatch(rule, attr)
428
+ per_attr = TRANSFORM_DISPATCH[rule]
429
+ if per_attr.nil?
430
+ per_attr = {}.compare_by_identity
431
+ TRANSFORM_DISPATCH[rule] = per_attr
432
+ end
433
+ verdict = per_attr[attr]
434
+ return verdict if verdict
435
+
436
+ rule_transform = rule.transform
437
+ # NB: `::Hash` — inside Lutaml::Model the bare constant `Hash` is
438
+ # the hash-format adapter class, so a plain is_a?(Hash) is always
439
+ # false here (the original dispatch shared that shadow and never
440
+ # took the hash-transform branch).
441
+ rule_has = rule_transform.is_a?(Class) ||
442
+ (rule_transform.is_a?(::Hash) && !rule_transform.empty?)
443
+ attr_transform = attr&.transform
444
+ attr_has = attr_transform &&
445
+ (attr_transform.is_a?(Class) ||
446
+ (attr_transform.is_a?(::Hash) && !attr_transform.empty?))
447
+
448
+ verdict = if !rule_has && !attr_has
449
+ :assign
450
+ elsif rule.get_transformers(attr).any? do |t|
451
+ t.is_a?(Class) && t < Lutaml::Model::ValueTransformer
452
+ end
453
+ :assign
454
+ else
455
+ :import
456
+ end
457
+ per_attr[attr] = verdict
458
+ verdict
459
+ end
460
+
405
461
  def get_transformers(attribute)
406
462
  # Fast path: most rules have no transforms at all
407
463
  rule_transform = transform
@@ -480,28 +536,16 @@ context = nil)
480
536
 
481
537
  def handle_transform_method(model, value, attributes, context = nil)
482
538
  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
539
+ # The transform verdict (none / class-based already applied /
540
+ # hash-proc via ImportTransformer) is static per (rule, attr)
541
+ # it was recomputed with is_a? chains on every rule application
542
+ # (16.8 M Kernel#is_a? per ISO-13849 parse across the walk).
543
+ if self.class.transform_dispatch(self, attr) == :import
502
544
  transformed = ImportTransformer.call(value, self, attr,
503
545
  context: context)
504
546
  assign_value(model, transformed)
547
+ else
548
+ assign_value(model, value)
505
549
  end
506
550
  true
507
551
  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,21 @@ 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] ||=
132
+ cached = (@merged_attributes_cache ||= {})[register_id]
133
+ return cached if cached
134
+
135
+ ensure_imports!(register) if finalized?
136
+ @merged_attributes_cache[register_id] ||=
129
137
  @attributes.merge(@register_records[register_id][:attributes])
130
138
  else
139
+ ensure_imports!(register) if finalized?
131
140
  @attributes
132
141
  end
133
142
  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.42"
6
6
  end
7
7
  end
@@ -315,6 +315,13 @@ module Lutaml
315
315
  compiled_rule_records(instance.class, effective_register,
316
316
  grouped_plain_rules)
317
317
  end
318
+ # Loop invariants: the entity-decode flag is a per-(class, register)
319
+ # fact and the attributes hash is memoized per register — both were
320
+ # re-asked per rule application (942k + 751k calls per ISO-13849
321
+ # parse).
322
+ decode_entities_flag = decode_html_entities_for(instance,
323
+ effective_register)
324
+ deserialize_attributes = attributes
318
325
  mappings.each do |rule|
319
326
  record = records&.[](rule)
320
327
  rule_to = rule.to
@@ -376,6 +383,9 @@ module Lutaml
376
383
  default_namespace)
377
384
  # Pre-match: no child element matches this rule.
378
385
  # Skip expensive value_for_rule, handle defaults inline.
386
+ # NOTE: do not "read the instance" here (to_value_for) —
387
+ # the compiled getters materialize lazy collections on
388
+ # first read, which tripled parse allocations when tried.
379
389
  if instance.using_default?(rule_to) || rule.render_default
380
390
  defaults_used << rule_to
381
391
  attr&.default(effective_register) || rule.to_value_for(instance)
@@ -409,9 +419,7 @@ module Lutaml
409
419
  value = apply_value_map(value, from_map, attr)
410
420
  value = normalize_xml_value(value, rule, attr, new_opts,
411
421
  effective_register)
412
- value = decode_html_entities_value(value, decode_html_entities_for(
413
- instance, effective_register
414
- ))
422
+ value = decode_html_entities_value(value, decode_entities_flag)
415
423
  value = rule.transform_value(attr, value, :from, :xml)
416
424
  # An over-count is normally left for `.validate` to report. A mapped
417
425
  # PORO has no `.validate`, so deferring there would discard the
@@ -420,7 +428,7 @@ module Lutaml
420
428
  !rule.content_mapping?
421
429
  attr.valid_collection!(value, context)
422
430
  end
423
- rule.deserialize(instance, value, attributes, context,
431
+ rule.deserialize(instance, value, deserialize_attributes, context,
424
432
  options[:context])
425
433
 
426
434
  instance.value_set_for(rule_to)
@@ -786,10 +794,27 @@ _effective_register)
786
794
  end
787
795
 
788
796
  def decode_html_entities_for(instance, register)
797
+ # Asked per rule application (942k per ISO-13849 parse) for a
798
+ # per-(class, register) fact; the transform instance itself is
799
+ # cached per (model class, register), so the memo lives here.
800
+ per_class = (@decode_html_entities_cache ||= {}.compare_by_identity)
801
+ per_register = (per_class[instance.class] ||= {})
802
+ return per_register[register] if per_register.key?(register)
803
+
789
804
  klass = instance.class
790
- return false unless klass.is_a?(Class) && klass.include?(Lutaml::Model::Serialize)
805
+ per_register[register] = klass.is_a?(Class) &&
806
+ klass.include?(Lutaml::Model::Serialize) &&
807
+ (klass.mappings_for(:xml, register)&.decode_html_entities? || false)
808
+ end
809
+
810
+ # Serializable type class → its XML namespace class, asked per
811
+ # (rule × element) in value_for_rule / resolve_rule_names_with_type
812
+ # for a per-type static.
813
+ def type_namespace_class_of(type_class)
814
+ cache = (@type_ns_class_cache ||= {}.compare_by_identity)
815
+ return cache[type_class] if cache.key?(type_class)
791
816
 
792
- klass.mappings_for(:xml, register)&.decode_html_entities?
817
+ cache[type_class] = type_class.mappings_for(:xml)&.namespace_class
793
818
  end
794
819
 
795
820
  def value_for_rule(session, rule, options, cached_attr = nil,
@@ -847,7 +872,7 @@ _effective_register)
847
872
  type_ns_all_uris = nil
848
873
  if attr_type_is_serializable
849
874
  type_ns_class = if attr_type_is_class
850
- attr_type.mappings_for(:xml)&.namespace_class
875
+ type_namespace_class_of(attr_type)
851
876
  else
852
877
  attr.type_namespace_class(effective_register)
853
878
  end
@@ -1292,7 +1317,7 @@ effective_register = lutaml_register)
1292
1317
  elsif precomputed_type.is_a?(Class) &&
1293
1318
  precomputed_type.include?(::Lutaml::Model::Serialize)
1294
1319
  # Use pre-computed type directly — avoids redundant attr.type() call
1295
- attr_type_ns_class = precomputed_type.mappings_for(:xml)&.namespace_class
1320
+ attr_type_ns_class = type_namespace_class_of(precomputed_type)
1296
1321
  if attr_type_ns_class
1297
1322
  return ["#{attr_type_ns_class.uri}:#{rule.name}"]
1298
1323
  end
@@ -1309,7 +1334,7 @@ effective_register = lutaml_register)
1309
1334
  if attr_type_class
1310
1335
  attr_type_ns_class = if attr_type_class.is_a?(Class) &&
1311
1336
  attr_type_class.include?(::Lutaml::Model::Serialize)
1312
- attr_type_class.mappings_for(:xml)&.namespace_class
1337
+ type_namespace_class_of(attr_type_class)
1313
1338
  end
1314
1339
  if attr_type_ns_class
1315
1340
  return ["#{attr_type_ns_class.uri}:#{rule.name}"]
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#811: the parse path re-resolved per (element × rule) what is
6
+ # static per (attribute/rule, register). These guards pin the hoisting
7
+ # contracts: register-keyed type caching with replacement invalidation,
8
+ # the lock-free child-register resolution, per-rule treatment flags, and
9
+ # the transform dispatch verdicts.
10
+ RSpec.describe "parse-path dispatch hoisting" do
11
+ describe "Attribute#type register caching" do
12
+ let(:klass) do
13
+ Class.new(Lutaml::Model::Serializable) do
14
+ attribute :value, :string
15
+ end
16
+ end
17
+
18
+ it "returns the same resolved class for a repeated register" do
19
+ attr = klass.attributes[:value]
20
+ expect(attr.type(:default)).to equal(attr.type(:default))
21
+ end
22
+
23
+ it "does not pin an unregistered register to the thread default" do
24
+ attr = klass.attributes[:value]
25
+ expect(attr.type(:nonexistent_register)).to eq(Lutaml::Model::Type::String)
26
+ end
27
+
28
+ it "re-resolves when a context is replaced under the same id" do
29
+ attr = klass.attributes[:value]
30
+
31
+ build_context = lambda do |string_type|
32
+ Lutaml::Model::TypeContext.new(
33
+ id: :hoist_test,
34
+ registry: Lutaml::Model::TypeRegistry.new.tap do |r|
35
+ r.register(:string, string_type)
36
+ end,
37
+ )
38
+ end
39
+
40
+ Lutaml::Model::GlobalContext.register_context(
41
+ build_context.call(Lutaml::Model::Type::String),
42
+ )
43
+ expect(attr.type(:hoist_test)).to equal(Lutaml::Model::Type::String)
44
+ expect(attr.type(:hoist_test)).to equal(Lutaml::Model::Type::String)
45
+
46
+ Lutaml::Model::GlobalContext.register_context(
47
+ build_context.call(Lutaml::Model::Type::Integer),
48
+ )
49
+ expect(attr.type(:hoist_test)).to equal(Lutaml::Model::Type::Integer)
50
+
51
+ Lutaml::Model::GlobalContext.unregister_context(:hoist_test)
52
+ end
53
+ end
54
+
55
+ describe "Register.resolve_for_child" do
56
+ it "caches a nil answer without recomputing" do
57
+ klass = Class.new(Lutaml::Model::Serializable)
58
+ expect(Lutaml::Model::Register.resolve_for_child(klass, nil)).to be_nil
59
+ expect(Lutaml::Model::Register.resolve_for_child(klass, nil)).to be_nil
60
+ end
61
+
62
+ it "resolves the child's own default register over the parent's" do
63
+ klass = Class.new(Lutaml::Model::Serializable) do
64
+ def self.lutaml_default_register
65
+ :child_default
66
+ end
67
+ end
68
+ expect(Lutaml::Model::Register.resolve_for_child(klass, :other)).to eq(:child_default)
69
+ expect(Lutaml::Model::Register.resolve_for_child(klass, nil)).to eq(:child_default)
70
+ end
71
+ end
72
+
73
+ describe "MappingRule treatment flags" do
74
+ def rule_with(treat_nil: :nil, treat_empty: :empty, treat_omitted: :nil)
75
+ Lutaml::Model::MappingRule.new(
76
+ "name", to: :name,
77
+ treat_nil: treat_nil, treat_empty: treat_empty,
78
+ treat_omitted: treat_omitted
79
+ )
80
+ end
81
+
82
+ it "computes the same answers as the value_map walk" do
83
+ rule = rule_with(treat_omitted: :omitted, treat_nil: :omitted)
84
+ expect(rule.treat_omitted?).to be(false)
85
+ expect(rule.treat_nil?).to be(false)
86
+ expect(rule.treat_empty?).to be(true)
87
+
88
+ expect(rule.treat_omitted?({})).to eq(rule.treat_omitted?)
89
+ expect(rule.treat_nil?({})).to eq(rule.treat_nil?)
90
+ end
91
+
92
+ it "honors per-call overrides over the precomputed flags" do
93
+ rule = rule_with
94
+ expect(rule.treat_omitted?).to be(true)
95
+ expect(rule.treat_omitted?({ omitted: :omitted })).to be(false)
96
+ expect(rule.treat_nil?({ nil: :omitted })).to be(false)
97
+ expect(rule.treat_empty?({ empty: :omitted })).to be(false)
98
+ end
99
+ end
100
+
101
+ describe "transform dispatch" do
102
+ let(:base) do
103
+ Class.new(Lutaml::Model::Serializable) do
104
+ attribute :plain, :string
105
+ attribute :mapped, :string
106
+ end
107
+ end
108
+
109
+ it "assigns directly for a rule without transformers" do
110
+ rule = Lutaml::Model::MappingRule.new("mapped", to: :mapped)
111
+ expect(Lutaml::Model::MappingRule.transform_dispatch(
112
+ rule, base.attributes[:mapped]
113
+ )).to be(:assign)
114
+ end
115
+
116
+ it "routes hash transformers through ImportTransformer" do
117
+ rule = Lutaml::Model::MappingRule.new(
118
+ "mapped", to: :mapped,
119
+ transform: { import: ->(v) { v.to_s.upcase } }
120
+ )
121
+ expect(Lutaml::Model::MappingRule.transform_dispatch(
122
+ rule, base.attributes[:mapped]
123
+ )).to be(:import)
124
+ end
125
+
126
+ it "applies an import transform through a real round trip" do
127
+ klass = Class.new(Lutaml::Model::Serializable) do
128
+ attribute :shout, :string
129
+
130
+ xml do
131
+ element "doc"
132
+ map_element "shout", to: :shout,
133
+ transform: { import: ->(v) { v.to_s.upcase } }
134
+ end
135
+ end
136
+
137
+ expect(klass.from_xml("<doc><shout>soft</shout></doc>").shout).to eq("SOFT")
138
+ end
139
+ end
140
+
141
+ describe "uninitialized defaults are cached" do
142
+ let(:klass) do
143
+ Class.new(Lutaml::Model::Serializable) do
144
+ attribute :nothing, :string
145
+ end
146
+ end
147
+
148
+ it "returns the same uninitialized sentinel without re-casting" do
149
+ attr = klass.attributes[:nothing]
150
+ expect(attr.default(:default)).to equal(attr.default(:default))
151
+ expect(attr.default(:default)).to equal(
152
+ Lutaml::Model::UninitializedClass.instance,
153
+ )
154
+ end
155
+ end
156
+
157
+ describe "html-entities decode flag" do
158
+ it "memoizes per class without changing the answer" do
159
+ klass = Class.new(Lutaml::Model::Serializable) do
160
+ attribute :body, :string
161
+
162
+ xml do
163
+ element "doc"
164
+ map_element "body", to: :body
165
+ html_entities
166
+ end
167
+ end
168
+
169
+ expect(klass.from_xml("<doc><body>a &amp; b</body></doc>").body).to eq("a & b")
170
+ expect(klass.from_xml("<doc><body>a &amp; b</body></doc>").body).to eq("a & b")
171
+ end
172
+ end
173
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.41
4
+ version: 0.8.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1765,6 +1765,7 @@ files:
1765
1765
  - spec/lutaml/model/opal_smoke_spec.rb
1766
1766
  - spec/lutaml/model/optimization_spec.rb
1767
1767
  - spec/lutaml/model/ordered_content_spec.rb
1768
+ - spec/lutaml/model/parse_dispatch_hoist_spec.rb
1768
1769
  - spec/lutaml/model/parsed_model_mutation_spec.rb
1769
1770
  - spec/lutaml/model/phantom_value_spec.rb
1770
1771
  - spec/lutaml/model/polymorphic_spec.rb