lutaml-model 0.8.36 → 0.8.37

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: 13023a5add888c4affa15d1e7c025548e329b0aad8112582550be7cff794524a
4
- data.tar.gz: 7393786a16da4f06d7962f7395edab78b5985ab1820c98da8b95f66f669be99d
3
+ metadata.gz: ec31a25915a5fd6ceeb31c2e61456c90ae223e565eab14051f8ddb40e373fe9e
4
+ data.tar.gz: a2e6027e8684bfcc63e58650f979d880e43836ea5521f0ee0ba5e30205bd89cf
5
5
  SHA512:
6
- metadata.gz: 03be6773d23935e26c6593d2c966f5063af4840142f988c6db534517b26b7889adc15c11c5d5280b181b9b19e35a971bbbc889ce673d63a750385e5b392d60b7
7
- data.tar.gz: 656f73aab6fe55e9dbe8337a8c6ec76fae5c2cadfade74bdd30500d3ea16105cdf3de1ebce9d837b4ddd3c6851e84ac7b5b7a7f40f2729d79827014e9d0a0b9e
6
+ metadata.gz: a101732ad5730070d1c7629b9bf0e72e6573d01a716cf165846b8474f5b7dca7e2415d80ed7a1f8d526fea61363ecbba414e9ecb819f8f2b42b9f09636e81d03
7
+ data.tar.gz: 42e60f78f63d5d8033edba244858479c95dc1147cf9cc2698ab410d259bd8485a2d2df9825482dc3f895d673177212c1e686a143b5afdde2850d78a706241170
data/bench/bench_sts.rb CHANGED
@@ -9,6 +9,7 @@
9
9
  # BENCH_JSON=/tmp/results.json bundle exec ruby tmp/bench/bench_sts.rb
10
10
 
11
11
  require_relative "bench_common"
12
+ require_relative "gate_config"
12
13
  include BenchCommon
13
14
 
14
15
  print_header("STS Ruby Benchmark — ISO/NISO STS document parsing")
@@ -39,11 +40,16 @@ sts_files.each do |label, path|
39
40
  end
40
41
  end
41
42
 
43
+ # The informative print reads the configured gate (bench_compare is the
44
+ # enforcing layer). The old hardcoded 2.0s literal predated the
45
+ # gate_config split and mislabeled every CI run (#313, TODO.max-perf/09).
42
46
  puts "\n Gate checks:"
43
47
  if results["iso-13849-1MB"]
44
- status = results["iso-13849-1MB"][:avg_time] < 2.0 ? "PASS" : "FAIL"
45
- printf " ISO-13849-1MB < 2.0s: %s (%.3fs)\n", status,
46
- results["iso-13849-1MB"][:avg_time]
48
+ gate = GateConfig::GATES.dig(:sts, "iso-13849-1MB") || {}
49
+ limit = gate[:absolute_max]
50
+ avg = results["iso-13849-1MB"][:avg_time]
51
+ status = avg < limit ? "PASS" : "FAIL"
52
+ printf " ISO-13849-1MB < %.1fs: %s (%.3fs)\n", limit, status, avg
47
53
  end
48
54
 
49
55
  write_results_json(json_output_path, results) if json_output_path
@@ -2,6 +2,10 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
+ # Marker stored by set_adapter_class: the resolved slot holds a
6
+ # pre-resolved class (or nil, meaning "unset — keep detecting").
7
+ FIXED_ADAPTER_TYPE = :__fixed__
8
+
5
9
  # Single authority for adapter resolution: (format, type_name) → adapter class.
6
10
  #
7
11
  # Consolidates adapter metadata, loading, validation, and auto-detection
@@ -38,11 +42,19 @@ module Lutaml
38
42
  return resolved_adapter_class(format, scope_override)
39
43
  end
40
44
 
41
- # 2. Explicitly configured type
45
+ # 2. Explicitly configured type — a named type is an explicit pin
46
+ # and must never fall through to auto-detection: detection loads
47
+ # optional engines (e.g. yeptris) into the process even when the
48
+ # user pinned a standard adapter, which crashes platforms where
49
+ # the engine's build is broken. FIXED_ADAPTER_TYPE with no
50
+ # resolved class means "unset" (set_adapter_class(format, nil))
51
+ # and keeps the old detect-through behavior.
42
52
  configured = configured_type(format)
43
53
  if configured
44
54
  adapter = resolved[format]
45
55
  return adapter if adapter
56
+
57
+ return load_and_cache(format, configured) if configured != FIXED_ADAPTER_TYPE
46
58
  end
47
59
 
48
60
  # 3. Lazy auto-detection (cached)
@@ -91,7 +103,7 @@ module Lutaml
91
103
  # @return [void]
92
104
  def set_adapter_class(format, adapter_class)
93
105
  resolved[format] = adapter_class
94
- configured_types[format] = :__fixed__
106
+ configured_types[format] = FIXED_ADAPTER_TYPE
95
107
  end
96
108
 
97
109
  # Load and resolve an adapter class by type name.
@@ -1029,6 +1029,16 @@ instance_object = nil)
1029
1029
  @default_type_context = nil
1030
1030
  end
1031
1031
 
1032
+ # Public validation contract: mappings check their targets at
1033
+ # finalize (lutaml-model#296), the cast path checks per value.
1034
+ def validate_attr_type!(resolved_type)
1035
+ return true if resolved_type <= Serializable || resolved_type <= Type::Value
1036
+ return true if resolved_type.include?(Serialize)
1037
+
1038
+ raise Lutaml::Model::InvalidAttributeTypeError.new(name,
1039
+ resolved_type.name)
1040
+ end
1041
+
1032
1042
  private
1033
1043
 
1034
1044
  attr_writer :raw, :validations
@@ -1040,14 +1050,6 @@ instance_object = nil)
1040
1050
  match&.last
1041
1051
  end
1042
1052
 
1043
- def validate_attr_type!(resolved_type)
1044
- return true if resolved_type <= Serializable || resolved_type <= Type::Value
1045
- return true if resolved_type.include?(Serialize)
1046
-
1047
- raise Lutaml::Model::InvalidAttributeTypeError.new(name,
1048
- resolved_type.name)
1049
- end
1050
-
1051
1053
  # validated_range_object is provided by CollectionHandler module
1052
1054
 
1053
1055
  def validate_name!(name, reserved_methods:)
@@ -3,11 +3,12 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class Choice
6
- attr_reader :attributes,
7
- :model,
8
- :min,
9
- :max
10
- attr_accessor :format
6
+ attr_reader :attributes, :min, :max
7
+ attr_accessor :model, :format
8
+
9
+ # Rebound at mapping finalize: sequence-nested choices are built
10
+ # against the mapping object, validation resolves through the
11
+ # mapper class (lutaml-model#687).
11
12
 
12
13
  INTERNAL_ATTRIBUTES = %i[@flat_attributes].freeze
13
14
 
@@ -25,6 +25,27 @@ module Lutaml
25
25
  register_liquid_drop_class
26
26
  end
27
27
 
28
+ # lutaml-model#800: models defined before `require "liquid"` keep
29
+ # their `liquid do ... end` mappings; the drop class materializes
30
+ # on first use. Consumers that require Liquid first keep the
31
+ # eager path unchanged.
32
+ def ensure_liquid_registered!
33
+ if !Object.const_defined?(:Liquid) && !liquid_loadable?
34
+ raise Lutaml::Model::LiquidNotEnabledError
35
+ end
36
+
37
+ register_class_if_liquid_defined
38
+ end
39
+
40
+ # Load the Liquid gem on first use; a missing gem surfaces as
41
+ # LiquidNotEnabledError at the point a drop is actually needed.
42
+ def liquid_loadable?
43
+ require "liquid"
44
+ true
45
+ rescue LoadError
46
+ false
47
+ end
48
+
28
49
  def liquid_class(class_name)
29
50
  @custom_liquid_class_name = class_name
30
51
  end
@@ -94,6 +115,7 @@ module Lutaml
94
115
  end
95
116
 
96
117
  def to_liquid_class
118
+ ensure_liquid_registered!
97
119
  register_methods unless @methods_generated
98
120
 
99
121
  base_drop_class
@@ -132,8 +154,6 @@ module Lutaml
132
154
  end
133
155
 
134
156
  def liquid(&block)
135
- return unless Object.const_defined?(:Liquid)
136
-
137
157
  mappings[:liquid] ||= ::Lutaml::Model::Liquid::Mapping.new
138
158
  mappings[:liquid].instance_eval(&block) if block
139
159
  mappings[:liquid]
@@ -155,6 +175,7 @@ module Lutaml
155
175
  end
156
176
 
157
177
  def to_liquid
178
+ self.class.ensure_liquid_registered!
158
179
  self.class.validate_liquid!
159
180
 
160
181
  self.class.register_methods
@@ -45,6 +45,13 @@ module Lutaml
45
45
  end
46
46
  end
47
47
 
48
+ # Normalized name spellings for matching, computed once. The name
49
+ # is fixed after construction (attr_reader only), so the memo is
50
+ # safe. Hot loops used to redo Array(name).map(&:to_s) per element.
51
+ def name_strings
52
+ @name_strings ||= Array(name).map(&:to_s).freeze
53
+ end
54
+
48
55
  def initialize(
49
56
  name,
50
57
  to:,
@@ -5,11 +5,12 @@ module Lutaml
5
5
  class Sequence
6
6
  include DeepDupable
7
7
 
8
- attr_accessor :model, :attributes
8
+ attr_accessor :model, :attributes, :compositors
9
9
  attr_reader :format
10
10
 
11
11
  def initialize(model, format: nil)
12
12
  @attributes = []
13
+ @compositors = []
13
14
  @model = model
14
15
  @format = format
15
16
  end
@@ -19,6 +20,7 @@ module Lutaml
19
20
  def deep_dup(new_model = nil)
20
21
  dup_seq = Sequence.new(new_model || @model, format: @format)
21
22
  dup_seq.attributes = Utils.deep_dup(@attributes)
23
+ dup_seq.compositors = Utils.deep_dup(@compositors)
22
24
  dup_seq
23
25
  end
24
26
 
@@ -32,7 +34,50 @@ module Lutaml
32
34
  end
33
35
 
34
36
  def map_element(name, **options)
35
- @attributes << @model.map_element(name, **options)
37
+ rule = @model.map_element(name, **options)
38
+ @attributes << rule
39
+ if (nested = @choice_in_progress)
40
+ nested.attributes << rule
41
+ end
42
+ rule
43
+ end
44
+
45
+ # An xs:choice compositor nested inside an xs:sequence
46
+ # (lutaml-model#687). Element rules mapped in the block take
47
+ # their positional place in this sequence AND carry the choice
48
+ # on their model attributes, so validation applies order and
49
+ # exclusivity exactly as a model-level choice does. The choice
50
+ # itself is remembered in #compositors — the compositor shape
51
+ # stays queryable next to the flat rule order.
52
+ def choice(min: 1, max: 1, &block)
53
+ nested = Choice.new(@model, min, max, format: @format)
54
+ previous = @choice_in_progress
55
+ @choice_in_progress = nested
56
+ begin
57
+ instance_eval(&block)
58
+ ensure
59
+ @choice_in_progress = previous
60
+ end
61
+ @compositors << nested
62
+ nested
63
+ end
64
+
65
+ # Bind nested choice compositors to the mapper's model attributes
66
+ # (options[:choice]), so validation applies order and exclusivity
67
+ # the same way a model-level choice does. Runs at mapping finalize,
68
+ # when the mapper class is known — the DSL block runs earlier.
69
+ def bind_choice_compositors!(mapper_class)
70
+ @compositors.each do |choice|
71
+ choice.model = mapper_class
72
+ # The choice's contents become the model attributes its rules
73
+ # target — the same shape a model-level choice carries — so
74
+ # validation machinery works unchanged.
75
+ bound = choice.attributes.filter_map do |rule|
76
+ mapper_class.attributes[rule.to]
77
+ end.uniq
78
+ choice.attributes.replace(bound)
79
+ bound.each { |target| target.options[:choice] = choice }
80
+ end
36
81
  end
37
82
 
38
83
  def import_model_mappings(model, register = nil)
@@ -12,6 +12,59 @@ 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, on demand) the method that seeds every
21
+ # attribute with its "no data arrived" sentinel: collections share
22
+ # the frozen LAZY_EMPTY_COLLECTION, scalars share the
23
+ # UninitializedClass singleton. Compiled once instead of walked
24
+ # per instance — grammars compile, they don't interpret.
25
+ def compile_state_defaults!(_register = nil)
26
+ attrs = attributes
27
+
28
+ if attrs.empty?
29
+ define_method(:__init_deserialized_state_defaults) do
30
+ # no attributes to seed
31
+ end
32
+ return
33
+ end
34
+
35
+ lines = attrs.map do |name, attr|
36
+ if attr.collection?
37
+ "@#{name} = Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION"
38
+ else
39
+ "@#{name} = Lutaml::Model::UninitializedClass.instance"
40
+ end
41
+ end.join("\n")
42
+
43
+ # class_eval interpolates per-attribute `@name = <sentinel>` lines:
44
+ # def __init_deserialized_state_defaults
45
+ # @id = Lutaml::Model::UninitializedClass.instance
46
+ # @items = Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION
47
+ # end
48
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
49
+ def __init_deserialized_state_defaults
50
+ #{lines}
51
+ end
52
+ RUBY
53
+ end
54
+
55
+ def invalidate_state_defaults!
56
+ # Opal's method_defined? takes no inherit flag (see the
57
+ # setter_defined check in define_regular_attribute_methods).
58
+ defined_now = if Lutaml::Model.opal?
59
+ method_defined?(:__init_deserialized_state_defaults)
60
+ else
61
+ method_defined?(:__init_deserialized_state_defaults, false)
62
+ end
63
+ return unless defined_now
64
+
65
+ remove_method(:__init_deserialized_state_defaults)
66
+ end
67
+
15
68
  def define_attribute_methods(attr, register = nil)
16
69
  name = attr.name
17
70
  register_id = extract_register_id(register)
@@ -200,6 +253,7 @@ module Lutaml
200
253
  attr = Attribute.new(name, type, options)
201
254
  @attributes[name] = attr
202
255
  @merged_attributes_cache = nil
256
+ invalidate_state_defaults!
203
257
  define_attribute_methods(attr)
204
258
 
205
259
  attr
@@ -108,15 +108,20 @@ module Lutaml
108
108
  @using_default = nil
109
109
  @lutaml_register = register
110
110
 
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)
111
+ # Default register: seed through the per-class compiled method
112
+ # (plain `@x = sentinel` writes). The previous per-instance
113
+ # instance_variable_set loop was a measured hot spot — one send
114
+ # and one symbol allocation per attribute per instance. Register-
115
+ # specific attribute sets are rare and keep the generic walk.
116
+ if register.nil? || register == :default
117
+ klass = self.class
118
+ klass.compile_state_defaults! unless klass.method_defined?(:__init_deserialized_state_defaults)
119
+ __init_deserialized_state_defaults
120
+ else
121
+ self.class.attributes(register).each do |name, attr|
122
+ instance_variable_set(:"@#{name}",
123
+ attr.collection? ? LAZY_EMPTY_COLLECTION : Lutaml::Model::UninitializedClass.instance)
124
+ end
120
125
  end
121
126
  end
122
127
 
@@ -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.37"
6
6
  end
7
7
  end
@@ -84,6 +84,7 @@ module Lutaml
84
84
  @cached_elements = {}
85
85
  @cached_attributes = {}
86
86
  @cached_mappings = {}
87
+ @cached_attribute_name_counts = {}
87
88
  @finalized = false
88
89
  end
89
90
 
@@ -98,6 +99,17 @@ module Lutaml
98
99
  # Resolve any deferred mapping imports before finalizing
99
100
  ensure_mappings_imported!
100
101
 
102
+ # lutaml-model#687: nested sequence choices tag their target
103
+ # model attributes now that the mapper class is known.
104
+ @element_sequence.each do |seq|
105
+ seq.bind_choice_compositors!(mapper_class)
106
+ end
107
+
108
+ # lutaml-model#296: a mapped rule whose attribute type can never
109
+ # accept the serialization shape fails here, with the attribute
110
+ # and type named, instead of surfacing mid-parse as a cast oddity.
111
+ validate_mapped_attribute_types!(mapper_class)
112
+
101
113
  # Validate mixed content requires collection attribute for content mapping
102
114
  validate_mixed_content_collection!(mapper_class)
103
115
 
@@ -108,6 +120,7 @@ module Lutaml
108
120
  @cached_elements.clear
109
121
  @cached_attributes.clear
110
122
  @cached_mappings.clear
123
+ @cached_attribute_name_counts.clear
111
124
  @finalized = true
112
125
  end
113
126
 
@@ -1028,6 +1041,26 @@ module Lutaml
1028
1041
  end
1029
1042
  end
1030
1043
 
1044
+ # Fire InvalidAttributeTypeError at definition time for mapped
1045
+ # rules whose target attribute type is neither a Type::Value nor
1046
+ # Serializable (lutaml-model#296). Undeclared types are left to
1047
+ # deferred-import resolution.
1048
+ def validate_mapped_attribute_types!(mapper_class)
1049
+ (elements + attributes).each do |rule|
1050
+ target = mapper_class.attributes[rule.to]
1051
+ next unless target
1052
+
1053
+ begin
1054
+ resolved = target.type(Lutaml::Model::Config.default_register)
1055
+ rescue Lutaml::Model::UnknownTypeError
1056
+ next
1057
+ end
1058
+ next unless resolved.is_a?(Class)
1059
+
1060
+ target.validate_attr_type!(resolved)
1061
+ end
1062
+ end
1063
+
1031
1064
  def elements(register_id = nil)
1032
1065
  reg_key = register_id || :default
1033
1066
  cached = @cached_elements[reg_key]
@@ -1060,6 +1093,26 @@ module Lutaml
1060
1093
  @raw_mapping
1061
1094
  end
1062
1095
 
1096
+ # lutaml-model#790/#791: whether `rule` is the only attribute rule
1097
+ # claiming its exact name spelling. The answer is a property of the
1098
+ # mapping, not the document, so it is counted once per register
1099
+ # (rules sharing a spelling make each other non-sole).
1100
+ def sole_local_attribute_claimant?(rule, register_id = nil)
1101
+ reg_key = register_id || :default
1102
+ counts = @cached_attribute_name_counts[reg_key]
1103
+ unless counts
1104
+ counts = {}
1105
+ attributes(register_id).each do |r|
1106
+ key = r.name_strings
1107
+ counts[key] = (counts[key] || 0) + 1
1108
+ end
1109
+ counts.freeze if @finalized
1110
+ @cached_attribute_name_counts[reg_key] = counts
1111
+ end
1112
+
1113
+ (counts[rule.name_strings] || 1) == 1
1114
+ end
1115
+
1063
1116
  def mappings(register_id = nil)
1064
1117
  reg_key = register_id || :default
1065
1118
  cached = @cached_mappings[reg_key]
@@ -12,6 +12,15 @@ module Lutaml
12
12
  # - Used by model's serialization pipeline via Transform.for(:xml)
13
13
  #
14
14
  class ModelTransform < ::Lutaml::Model::Transform
15
+ # Namespaced rule name -> [local_name, rule_uri]. Pure string
16
+ # splitting, deterministic per spelling, shared across parses.
17
+ # Concurrent::Map under threaded MRI, plain Hash under Opal.
18
+ NAMESPACED_NAME_PARTS = if Lutaml::Model.opal?
19
+ {}
20
+ else
21
+ Lutaml::Model::RuntimeCompatibility.require_native("concurrent")
22
+ Concurrent::Map.new
23
+ end
15
24
  include NestedCollectionAttribute
16
25
 
17
26
  # Performance: Frozen empty hash to reduce allocations
@@ -487,16 +496,11 @@ _effective_register)
487
496
  mapping = klass.mappings_for(:xml, effective_register)
488
497
  return true unless mapping
489
498
 
490
- expected = Array(rule.name).map(&:to_s)
491
- mapping.mappings.none? do |sib|
492
- next false unless sib.attribute? && !sib.equal?(rule)
493
-
494
- Array(sib.name).map(&:to_s) == expected
495
- end
499
+ mapping.sole_local_attribute_claimant?(rule, effective_register)
496
500
  end
497
501
 
498
502
  def value_for_xml_attribute(doc, rule, rule_names,
499
- flexible_local: false)
503
+ flexible_local: false, session: nil)
500
504
  # For attributes, rule_names may contain namespaced names, but find_attribute_value
501
505
  # expects prefix:name or just name, so we need to convert namespaced names to prefix format.
502
506
  #
@@ -524,7 +528,8 @@ _effective_register)
524
528
  # (e.g., v:ext without xmlns:v="...").
525
529
  if value.nil? && rule_names.any? { |rn| rn.include?(":") }
526
530
  value = find_attribute_by_local_name(doc, rule_names,
527
- flexible_local: flexible_local)
531
+ flexible_local: flexible_local,
532
+ session: session)
528
533
  end
529
534
 
530
535
  value = value&.split(rule.delimiter) if rule.delimiter
@@ -588,49 +593,61 @@ _effective_register)
588
593
  # @param rule_names [Array<String>] the rule names to match
589
594
  # @return [String, nil] the attribute value or nil
590
595
  def find_attribute_by_local_name(doc, rule_names,
591
- flexible_local: false)
596
+ flexible_local: false, session: nil)
597
+ root_index = session&.root_local_attribute_index
598
+
592
599
  rule_names.each do |rn|
593
600
  next unless rn.include?(":")
594
601
 
595
- last_colon_index = rn.rindex(":")
596
- local_name = rn[(last_colon_index + 1)..]
597
- rule_uri = rn[0...last_colon_index]
598
-
599
- matched_attr = doc.root.attributes.each_value.find do |attr|
600
- # Local-name fallback serves two lenient cases only:
601
- # - an attribute with an undeclared prefix (raw name keeps the
602
- # colon, namespace unresolvable), the historical purpose;
603
- # - an attribute whose resolved namespace IS one of the rule's
604
- # namespace URIs (prefix rebinding, lutaml-model#744).
605
- # Everything else — including a definitively namespace-less
606
- # attribute and an attribute from a foreign namespace — must
607
- # not satisfy the fallback, or a plain rule steals a
608
- # namespaced sibling attribute (lutaml-model#758). An
609
- # XmlAttribute with a resolved namespace has namespaced_name
610
- # "URI:local", so this comparison is allocation-free.
611
- if flexible_local
612
- # Sole claimant: any qualification of the local name binds
613
- # (element namespace, foreign extension namespace, none).
614
- attr.unprefixed_name == local_name ||
615
- ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
616
- elsif attr.namespace.nil?
617
- next false if attr.namespace_prefix.nil? &&
618
- !attr.name.include?(":")
619
-
620
- attr.unprefixed_name == local_name ||
621
- ((colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name) == local_name
622
- else
623
- # Prefixed attributes carry namespaced_name "prefix:local",
624
- # which a URI-form rule name can never equal — match by the
625
- # attribute's resolved namespace instead.
626
- attr.namespace == rule_uri && attr.unprefixed_name == local_name
627
- end
602
+ # Splitting a namespaced rule name is pure string work repeated
603
+ # per element; memoized per spelling (bounded by the model's
604
+ # distinct rule names).
605
+ parts = NAMESPACED_NAME_PARTS[rn]
606
+ unless parts
607
+ last_colon_index = rn.rindex(":")
608
+ parts = [rn[(last_colon_index + 1)..], rn[0...last_colon_index]]
609
+ NAMESPACED_NAME_PARTS[rn] = parts
628
610
  end
611
+ local_name, rule_uri = parts
612
+
613
+ candidates = root_index&.[](local_name)
614
+ matched_attr = if candidates
615
+ candidates.find do |attr|
616
+ local_name_match?(attr, local_name, rule_uri,
617
+ flexible_local)
618
+ end
619
+ else
620
+ doc.root.attributes.each_value.find do |attr|
621
+ local_name_match?(attr, local_name, rule_uri,
622
+ flexible_local)
623
+ end
624
+ end
629
625
  return matched_attr&.value if matched_attr
630
626
  end
631
627
  nil
632
628
  end
633
629
 
630
+ # Namespace discipline of the lenient local-name fallback
631
+ # (#744/#758/#790): sole claimants bind any qualification; shared
632
+ # names must resolve to the rule's namespace.
633
+ def local_name_match?(attr, local_name, rule_uri, flexible_local)
634
+ if flexible_local
635
+ attr.unprefixed_name == local_name ||
636
+ attr_name_local(attr) == local_name
637
+ elsif attr.namespace.nil?
638
+ return false if attr.namespace_prefix.nil? && !attr.name.include?(":")
639
+
640
+ attr.unprefixed_name == local_name ||
641
+ attr_name_local(attr) == local_name
642
+ else
643
+ attr.namespace == rule_uri && attr.unprefixed_name == local_name
644
+ end
645
+ end
646
+
647
+ def attr_name_local(attr)
648
+ (colon = attr.name.rindex(":")) ? attr.name[(colon + 1)..] : attr.name
649
+ end
650
+
634
651
  def value_for_rule(session, rule, options, cached_attr = nil,
635
652
  extra_rule_names = nil)
636
653
  doc = session.doc
@@ -24,6 +24,25 @@ module Lutaml
24
24
  @instance_is_serialize ||= instance.is_a?(::Lutaml::Model::Serialize)
25
25
  end
26
26
 
27
+ # Local-name -> [attributes] index over the root element's
28
+ # attributes, built on first lenient lookup (TODO.max-perf/08).
29
+ # The fallback used to rescan every attribute per missed rule;
30
+ # elements are parse-frozen, so the index builds at most once per
31
+ # session. Each attribute is filed under every local-name spelling
32
+ # the matcher compares (unprefixed name and colon-split local).
33
+ def root_local_attribute_index
34
+ @root_local_attribute_index ||= begin
35
+ index = ::Hash.new { |h, k| h[k] = [] }
36
+ doc.root.attributes.each_value do |attr|
37
+ local_names(attr).each do |key|
38
+ entries = index[key]
39
+ entries << attr unless entries.include?(attr)
40
+ end
41
+ end
42
+ index
43
+ end
44
+ end
45
+
27
46
  def model_class
28
47
  @model_class ||= instance.class
29
48
  end
@@ -57,3 +76,9 @@ module Lutaml
57
76
  end
58
77
  end
59
78
  end
79
+
80
+ def local_names(attr)
81
+ colon = attr.name.rindex(":")
82
+ split_local = colon ? attr.name[(colon + 1)..] : attr.name
83
+ [attr.unprefixed_name, split_local].compact.uniq
84
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Lutaml::Model::AdapterResolver, ".adapter_for with a configured type" do
6
+ after do
7
+ Lutaml::Model::Config.configure do |config|
8
+ config.xml_adapter_type = :nokogiri
9
+ config.yaml_adapter_type = :standard
10
+ config.json_adapter_type = :standard
11
+ end
12
+ end
13
+
14
+ it "resolves the configured adapter without running auto-detection" do
15
+ Lutaml::Model::Config.configure do |config|
16
+ config.json_adapter_type = :standard
17
+ end
18
+
19
+ adapter = described_class.adapter_for(:json)
20
+
21
+ expect(described_class.configured_type(:json)).to eq(:standard)
22
+ expect(adapter.name).to include("Json::Standard")
23
+ end
24
+
25
+ it "does not load the optional yeptris engine when a standard adapter is configured" do
26
+ skip "yeptris already loaded by an earlier spec" if Object.const_defined?(:Yeptris)
27
+
28
+ Lutaml::Model::Config.configure do |config|
29
+ config.yaml_adapter_type = :standard
30
+ config.json_adapter_type = :standard
31
+ end
32
+
33
+ described_class.adapter_for(:yaml)
34
+ described_class.adapter_for(:json)
35
+
36
+ expect(Object.const_defined?(:Yeptris)).to be(false)
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#800: `liquid do ... end` blocks survive a boot order in
6
+ # which Liquid is required after lutaml-model; the drop class
7
+ # materializes on first use instead of requiring Liquid at definition.
8
+ RSpec.describe "Liquefiable lazy registration" do
9
+ it "materializes the drop class on first to_liquid" do
10
+ klass = Class.new(Lutaml::Model::Serializable) do
11
+ attribute :name, :string
12
+
13
+ liquid do
14
+ map "full_name", to: :name
15
+ end
16
+ end
17
+
18
+ # Mapping stored at definition; drop registration is lazy
19
+ expect(klass.liquid_mappings.mappings).not_to be_empty
20
+
21
+ drop = klass.new(name: "Apollo").to_liquid
22
+ expect(drop).to be_a(Liquid::Drop)
23
+ expect(drop.full_name).to eq("Apollo")
24
+ end
25
+
26
+ it "keeps ensure_liquid_registered! idempotent" do
27
+ klass = Class.new(Lutaml::Model::Serializable) do
28
+ attribute :name, :string
29
+ end
30
+
31
+ klass.ensure_liquid_registered!
32
+ first = klass.base_drop_class
33
+ klass.ensure_liquid_registered!
34
+ expect(klass.base_drop_class).to equal(first)
35
+ end
36
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#296: a mapped rule whose attribute type can never accept
6
+ # the serialization shape fails at mapping definition, naming the
7
+ # attribute and the type, instead of surfacing mid-parse.
8
+ RSpec.describe "Mapping-time attribute type validation" do
9
+ it "raises InvalidAttributeTypeError at definition for a non-castable type" do
10
+ expect do
11
+ Class.new(Lutaml::Model::Serializable) do
12
+ attribute :ends, Object
13
+
14
+ xml do
15
+ element "doc"
16
+ map_element "ends", to: :ends
17
+ end
18
+ end
19
+ end.to raise_error(Lutaml::Model::InvalidAttributeTypeError) do |err|
20
+ expect(err.message).to include("attribute `ends`")
21
+ expect(err.message).to include("Object")
22
+ end
23
+ end
24
+
25
+ it "accepts Value, Serializable, and collection types" do
26
+ item = Class.new(Lutaml::Model::Serializable) do
27
+ attribute :name, :string
28
+ xml do
29
+ element "item"
30
+ map_element "name", to: :name
31
+ end
32
+ end
33
+
34
+ expect do
35
+ Class.new(Lutaml::Model::Serializable) do
36
+ attribute :count, :integer
37
+ attribute :name, :string
38
+ attribute :items, item, collection: true
39
+
40
+ xml do
41
+ element "doc"
42
+ map_element "count", to: :count
43
+ map_element "items", to: :items
44
+ end
45
+ end
46
+ end.not_to raise_error
47
+ end
48
+
49
+ it "leaves undeclared (deferred) types to import resolution" do
50
+ expect do
51
+ Class.new(Lutaml::Model::Serializable) do
52
+ attribute :later, :missing_type_name
53
+
54
+ xml do
55
+ element "doc"
56
+ map_element "later", to: :later
57
+ end
58
+ end
59
+ end.not_to raise_error
60
+ end
61
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # lutaml-model#687: an xs:sequence remembers a nested xs:choice
6
+ # compositor. Element rules inside `choice` take their position in the
7
+ # sequence and carry the choice on their model attributes, so validation
8
+ # applies order and exclusivity.
9
+ RSpec.describe "Sequence with nested choice" do
10
+ let(:model) do
11
+ Class.new(Lutaml::Model::Serializable) do
12
+ attribute :title, :string
13
+ attribute :hardcover, :string
14
+ attribute :paperback, :string
15
+ attribute :author, :string, collection: true
16
+
17
+ xml do
18
+ element "book"
19
+ sequence do
20
+ map_element "title", to: :title
21
+ choice do
22
+ map_element "hardcover", to: :hardcover
23
+ map_element "papercover", to: :paperback
24
+ map_element "paperback", to: :paperback
25
+ end
26
+ map_element "author", to: :author
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ it "records the choice as a compositor alongside the flat order" do
33
+ sequence = model.mappings_for(:xml).element_sequence.first
34
+ expect(sequence.compositors).to contain_exactly(
35
+ an_instance_of(Lutaml::Model::Choice),
36
+ )
37
+ choice = sequence.compositors.first
38
+ # After finalize the choice carries the model attributes its rules
39
+ # target — the shape validation machinery expects.
40
+ expect(choice.attributes.map(&:name)).to eq(%i[hardcover paperback])
41
+ end
42
+
43
+ it "keeps the model attributes tagged with the nested choice" do
44
+ choice = model.mappings_for(:xml).element_sequence.first.compositors.first
45
+ expect(model.attributes[:hardcover].options[:choice]).to equal(choice)
46
+ expect(model.attributes[:paperback].options[:choice]).to equal(choice)
47
+ end
48
+
49
+ it "validates order and exclusivity" do
50
+ valid = model.from_xml(
51
+ "<book><title>T</title><paperback>PB</paperback><author>A</author></book>",
52
+ )
53
+ expect(valid.validate).to be_empty
54
+
55
+ out_of_order = model.from_xml(
56
+ "<book><author>A</author><title>T</title><hardcover>HB</hardcover></book>",
57
+ )
58
+ expect(out_of_order.validate).not_to be_empty
59
+
60
+ both_branches = model.from_xml(
61
+ "<book><title>T</title><hardcover>HB</hardcover>" \
62
+ "<paperback>PB</paperback><author>A</author></book>",
63
+ )
64
+ expect(both_branches.validate).not_to be_empty
65
+ end
66
+ end
@@ -202,12 +202,10 @@ RSpec.describe Lutaml::Model::Store do
202
202
  # After compaction, the refs array should be smaller than before
203
203
  # (some dead refs removed). Exact count depends on GC timing,
204
204
  # but the live refs must still be present.
205
- live = instance.refs_for(model_class.to_s)
206
- alive_count = live.count do |ref|
207
- ref.weakref_alive?
208
- rescue WeakRef::RefError
209
- false
210
- end
205
+ # refs_for yields live objects directly under the WeakMap buckets
206
+ # (dead keys never surface), so the live count is bounded by the
207
+ # post-GC population regardless of compaction timing.
208
+ alive_count = instance.refs_for(model_class.to_s).count
211
209
  expect(alive_count).to be < (threshold + 1 + interval)
212
210
  end
213
211
 
@@ -225,14 +223,18 @@ RSpec.describe Lutaml::Model::Store do
225
223
  expect(counters[other_class.to_s]).to eq(3)
226
224
  end
227
225
 
228
- it "does not compact when exactly at threshold" do
229
- threshold = Lutaml::Model::Store::COMPACTION_THRESHOLD
226
+ it "compacts only on interval multiples" do
227
+ interval = Lutaml::Model::Store::COMPACTION_INTERVAL
230
228
  instance = described_class.instance
231
229
 
232
- _objects = Array.new(threshold) { |i| model_class.new(id: "edge-#{i}") }
233
-
234
- # refs.size == threshold, which does not satisfy size > threshold
230
+ # WeakMap buckets shed dead keys without help, so compaction is
231
+ # purely index maintenance on a fixed cadence: the counter fires
232
+ # exactly at each interval boundary, never between.
233
+ (interval - 1).times { |i| model_class.new(id: "edge-#{i}") }
235
234
  expect(instance.compaction_count).to eq(0)
235
+
236
+ model_class.new(id: "edge-trigger")
237
+ expect(instance.compaction_count).to eq(1)
236
238
  end
237
239
  end
238
240
 
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.36
4
+ version: 0.8.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1686,6 +1686,7 @@ files:
1686
1686
  - spec/lutaml/key_value/transformation/value_serializer_spec.rb
1687
1687
  - spec/lutaml/key_value/transformation_spec.rb
1688
1688
  - spec/lutaml/model/absent_value_contracts_spec.rb
1689
+ - spec/lutaml/model/adapter_resolver_configured_spec.rb
1689
1690
  - spec/lutaml/model/attribute_apply_value_map_spec.rb
1690
1691
  - spec/lutaml/model/attribute_collection_spec.rb
1691
1692
  - spec/lutaml/model/attribute_default_cache_spec.rb
@@ -1743,6 +1744,7 @@ files:
1743
1744
  - spec/lutaml/model/key_value_data_model/key_value_element_spec.rb
1744
1745
  - spec/lutaml/model/key_value_mapping_spec.rb
1745
1746
  - spec/lutaml/model/lazy_collection_spec.rb
1747
+ - spec/lutaml/model/liquefiable_lazy_spec.rb
1746
1748
  - spec/lutaml/model/liquefiable_spec.rb
1747
1749
  - spec/lutaml/model/liquid/indexed_access_spec.rb
1748
1750
  - spec/lutaml/model/liquid_compatibility_spec.rb
@@ -1750,6 +1752,7 @@ files:
1750
1752
  - spec/lutaml/model/map_all_spec.rb
1751
1753
  - spec/lutaml/model/map_content_spec.rb
1752
1754
  - spec/lutaml/model/mapping_rule_perf_guard_spec.rb
1755
+ - spec/lutaml/model/mapping_type_validation_spec.rb
1753
1756
  - spec/lutaml/model/mixed_content_spec.rb
1754
1757
  - spec/lutaml/model/model_transformer_spec.rb
1755
1758
  - spec/lutaml/model/multiple_mapping_spec.rb
@@ -1783,6 +1786,7 @@ files:
1783
1786
  - spec/lutaml/model/schema/renderers/union_spec.rb
1784
1787
  - spec/lutaml/model/schema/xsd_schema_spec.rb
1785
1788
  - spec/lutaml/model/schema/yaml_schema_spec.rb
1789
+ - spec/lutaml/model/sequence_choice_compositor_spec.rb
1786
1790
  - spec/lutaml/model/sequence_spec.rb
1787
1791
  - spec/lutaml/model/serializable_spec.rb
1788
1792
  - spec/lutaml/model/serializable_validation_spec.rb