lutaml-model 0.8.140 → 0.8.142

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: e4340bd1bf4b75d088acb5a60a68073d2980d419fe5c7f59412966956a58e377
4
- data.tar.gz: 829a741bc56fa070059d8198887cc51051859f64a2aeab747814c5f0d278356b
3
+ metadata.gz: 4ddbdf9b831babcd7fd6bd41314ea8b4f2352503f0302b2420942d9d144dc0bb
4
+ data.tar.gz: 4704a4bac05aef07a9a0a0a939180cd10d15ad4a8ab086dc218b126c68f97d2f
5
5
  SHA512:
6
- metadata.gz: 14cda9bfd53830559db4848694f3118289958108e17f9445a079c585c8aed5f637878d4b1a0b4f232f1dca8d12100f4d56d73bb8289d2e73cd625a42682c90df
7
- data.tar.gz: 124577fb5f037a6dea46e3da4f409713fadcc2ca2398e34a578d47230be5651274d3eabac7342fc708b2939c59d56f4248bfe97da88cc39dddce509cdffc83ef
6
+ metadata.gz: 817089bc912e7a85d912ecdffc09e8d894cadb3ca416450f6b5cab6d3b2463e4d9210ccf8ca0af1710a4de088562ef5628ec5896ff9585dc0ad6798444897a77
7
+ data.tar.gz: b1196728958dfc2e8be4ed6da1a70c4b5e09079c7c1d3940398e4c1c97adbd5bfa34d74a829ae51c9ad8a85d09ddcad5028050eca1de432f897b98fbdce5ff60
@@ -328,8 +328,9 @@ end
328
328
  ```
329
329
 
330
330
  **Invalidation:** `finalize()` clears all caches before setting `@finalized`.
331
- This handles the case where `deep_dup` copies a finalized mapping, then the
332
- child class adds its own mappings and re-finalizes.
331
+ This handles the case where a finalized mapping is copied into a child class
332
+ (eagerly, or on first write under the inherited-state copy-on-write scheme),
333
+ then the child adds its own mappings and re-finalizes.
333
334
 
334
335
  ```ruby
335
336
  def finalize(mapper_class)
@@ -429,6 +430,31 @@ classes caused 2-5x time regression despite reducing allocations.
429
430
  **Rule:** Keep caching inline with `@ivar` access. Guard specs that exercise
430
431
  the code path (not the cache implementation) are the correct tradeoff.
431
432
 
433
+ ### Pattern G: Copy-on-Write Inherited State
434
+
435
+ **What happened:** every subclass used to `deep_dup` its parent's mappings,
436
+ attributes, choices and register records at inheritance time — about 272
437
+ allocations for a two-attribute parent, paid whether or not the subclass ever
438
+ declared anything of its own.
439
+
440
+ **Now:** a subclass shares the parent's structures by reference. A 2-bit
441
+ status register per structure (`mappings`, `attributes`, `choices`,
442
+ `registers`) records whether it is still inherited-shared or already owned,
443
+ and whether the owned copy is itself shared with a further descendant. The
444
+ first DSL write on the child forks that one structure (deep-dupping it and
445
+ invalidating the merged-attribute cache); reads keep using the parent's
446
+ objects. `choices` fork eagerly because imports mutate them in place.
447
+
448
+ A DSL write on the parent forks the parent's own copy instead of mutating the
449
+ structure its children share, so an already-defined child keeps exactly the
450
+ state it inherited — the same snapshot semantics the eager copy provided, at
451
+ none of the up-front cost.
452
+
453
+ **Rule:** never mutate one of these structures directly. Go through the
454
+ fork accessor (`lutaml_cow_fork!`, `lutaml_cow_mapping_for_dsl!`,
455
+ `register_record_for_dsl!`); a bare `<<` on an inherited hash or array
456
+ mutates the whole ancestry.
457
+
432
458
  ---
433
459
 
434
460
  ## 7. Profiling Guide
@@ -564,11 +590,14 @@ name, unprefixed name, or through the default namespace. Any optimization
564
590
  that changes the matching loop must preserve all three match paths. Test
565
591
  with Uniword (10+ sub-namespaces) and XMI (namespace aliases).
566
592
 
567
- ### Pitfall 4: deep_dup + Finalize Cache Interaction
593
+ ### Pitfall 4: Inherited-State Copies + Finalize Cache Interaction
568
594
 
569
- **What happened:** `deep_dup` copied a finalized mapping, including its
595
+ **What happened:** an inherited copy of a finalized mapping carried its
570
596
  frozen caches. The child class then added new mappings, but the cached
571
- elements/attributes were stale.
597
+ elements/attributes were stale. (The copy used to be an eager `deep_dup` on
598
+ every subclass; it is now copy-on-write — the parent's structures stay shared
599
+ until the child first mutates one — but the cache hazard is the same at the
600
+ moment the copy is materialized.)
572
601
 
573
602
  **Rule:** `finalize()` must clear all caches before setting `@finalized`.
574
603
  This ensures the first access after finalization builds correct caches.
@@ -128,9 +128,7 @@ register = nil)
128
128
 
129
129
  if register_id != :default
130
130
  # Non-default register: store in register-specific storage only
131
- current_record = @model.register_records[register_id] ||= {
132
- attributes: {}, choice_attributes: []
133
- }
131
+ current_record = @model.register_record_for_dsl!(register_id)
134
132
  imported_attributes = Utils.deep_dup(model.attributes(register_id))
135
133
  imported_attributes.each_value do |attr|
136
134
  attr.options[:choice] = self
@@ -14,7 +14,12 @@ module Lutaml
14
14
  def inherited(child)
15
15
  super
16
16
  child.register_class_if_liquid_defined
17
- child.mappings = Utils.deep_dup(mappings) || {}
17
+ # Serialize models share mappings copy-on-write; a standalone
18
+ # Liquefiable class has no such state, so the eager copy is
19
+ # its only isolation from the parent.
20
+ unless child <= Lutaml::Model::Serialize
21
+ child.mappings = Utils.deep_dup(mappings) || {}
22
+ end
18
23
  end
19
24
 
20
25
  def register_class_if_liquid_defined
@@ -154,9 +159,15 @@ module Lutaml
154
159
  end
155
160
 
156
161
  def liquid(&block)
157
- mappings[:liquid] ||= ::Lutaml::Model::Liquid::Mapping.new
158
- mappings[:liquid].instance_eval(&block) if block
159
- mappings[:liquid]
162
+ if self <= Lutaml::Model::Serialize
163
+ mapping = lutaml_cow_mapping_for_dsl!(:liquid) ||
164
+ ::Lutaml::Model::Liquid::Mapping.new
165
+ mappings[:liquid] = mapping
166
+ else
167
+ mapping = mappings[:liquid] ||= ::Lutaml::Model::Liquid::Mapping.new
168
+ end
169
+ mapping.instance_eval(&block) if block
170
+ mapping
160
171
  end
161
172
 
162
173
  def liquid_mappings
@@ -833,6 +833,7 @@ module Lutaml
833
833
  end
834
834
 
835
835
  attr = Attribute.new(name, type, options)
836
+ lutaml_cow_fork!(:attributes)
836
837
  @attributes[name] = attr
837
838
  @merged_attributes_cache = nil
838
839
  # Attributes can be declared after the model has been
@@ -853,6 +854,8 @@ module Lutaml
853
854
  # @return [Symbol] The attribute name
854
855
  def restrict(name, options = {})
855
856
  register_id = options.delete(:register) || Lutaml::Model::Config.default_register
857
+ lutaml_cow_fork!(:attributes)
858
+ lutaml_cow_fork!(:registers)
856
859
 
857
860
  if !@attributes.key?(name) && !register_record(register_id)&.dig(
858
861
  :attributes, name
@@ -22,7 +22,7 @@ module Lutaml
22
22
  end
23
23
 
24
24
  klass = ::Lutaml::Model::Config.mappings_class_for(format)
25
- existing = mappings[format]
25
+ existing = lutaml_cow_mapping_for_dsl!(format)
26
26
  mappings[format] = if existing.nil? || !existing.is_a?(klass)
27
27
  klass.new
28
28
  else
@@ -476,9 +476,14 @@ module Lutaml
476
476
  end
477
477
 
478
478
  formats.each do |format|
479
- mappings[format] ||= Lutaml::KeyValue::Mapping.new(format)
480
- mappings[format].instance_eval(&block)
481
- mappings[format].finalize(self)
479
+ # The helper may replace @mappings mid-flight (CoW
480
+ # fork); bind it to a local before indexing, or the
481
+ # element assignment lands on the abandoned hash.
482
+ mapping = lutaml_cow_mapping_for_dsl!(format) ||
483
+ Lutaml::KeyValue::Mapping.new(format)
484
+ mappings[format] = mapping
485
+ mapping.instance_eval(&block)
486
+ mapping.finalize(self)
482
487
  end
483
488
  end
484
489
 
@@ -488,6 +493,7 @@ module Lutaml
488
493
  # @param block [Proc] The DSL block
489
494
  def rdf(&block)
490
495
  Lutaml::Model::FormatRegistry.rdf_formats.each do |format|
496
+ lutaml_cow_fork!(:mappings)
491
497
  mappings[format] = Lutaml::Rdf::Mapping.new
492
498
  mappings[format].instance_eval(&block)
493
499
  end
@@ -86,21 +86,159 @@ module Lutaml
86
86
  base.initialize_attrs(self)
87
87
  end
88
88
 
89
+ # Copy-on-write structure status. Two bits per structure
90
+ # (mappings, attributes, choices, registers): 0 = inherited and
91
+ # shared with the source class, 1 = owned and private (safe to
92
+ # mutate in place), 2 = owned but shared with subclasses (fork
93
+ # before mutating so they keep the pre-mutation state).
94
+ LUTAML_COW_STRUCTURES = %i[mappings attributes choices registers].freeze
95
+
89
96
  # Initialize class attributes from a source class
90
97
  #
91
98
  # @param source_class [Class] The source class to copy from
92
99
  def initialize_attrs(source_class)
93
- @mappings = Utils.deep_dup(source_class.mappings) || {}
94
- @attributes = Utils.deep_dup(source_class.class_attributes) || {}
95
- @choice_attributes = deep_duplicate_choice_attributes(source_class)
96
- @register_records = Utils.deep_dup(
97
- source_class.register_records,
98
- ) || ::Hash.new do |hash, key|
99
- hash[key] = { attributes: {}, choice_attributes: [] }
100
- end
100
+ if source_class.equal?(self)
101
+ # A subclass that says `include Lutaml::Model::Serialize`
102
+ # again re-enters this hook with itself as the source. It
103
+ # already carries the state `inherited` installed, and the
104
+ # eager implementation re-copied that state; here it must
105
+ # survive untouched, so only a brand-new class initializes.
106
+ initialize_empty_attrs if @attributes.nil?
107
+ else
108
+ @mappings = source_class.mappings
109
+ @attributes = source_class.class_attributes
110
+ @choice_attributes = source_class.class_choice_attributes
111
+ @register_records = source_class.register_records
112
+ @lutaml_cow_status = 0
113
+ lutaml_cow_mark_ancestors_shared!(source_class)
114
+ # A choice binds its owning class and stamps itself onto
115
+ # the attribute options, so a subclass must rebind its
116
+ # choices to itself. Models without choices — the vast
117
+ # majority — keep sharing the empty array.
118
+ lutaml_cow_fork!(:choices) if @choice_attributes&.any?
119
+ end
101
120
  model(self)
102
121
  end
103
122
 
123
+ def initialize_empty_attrs
124
+ # `Liquefiable` may already have created the mapping store
125
+ # before this class included `Serialize`; that state is the
126
+ # class's own and must survive initialization.
127
+ @mappings ||= {}
128
+ @attributes ||= {}
129
+ @choice_attributes ||= []
130
+ @register_records ||= ::Hash.new do |hash, key|
131
+ hash[key] = { attributes: {}, choice_attributes: [] }
132
+ end
133
+ @lutaml_cow_status = Lutaml::Model::Serialize::Initialization
134
+ .lutaml_cow_status_for(1)
135
+ end
136
+
137
+ # Status value for every structure, encoded per structure
138
+ def self.lutaml_cow_status_for(state)
139
+ LUTAML_COW_STRUCTURES.each_index.sum do |i|
140
+ state << (i * 2)
141
+ end
142
+ end
143
+
144
+ def lutaml_cow_status(structure)
145
+ shift = LUTAML_COW_STRUCTURES.index(structure) * 2
146
+ ((@lutaml_cow_status || 0) >> shift) & 3
147
+ end
148
+
149
+ # Pack one structure's state back into the status word
150
+ def lutaml_cow_set_status!(structure, state)
151
+ shift = LUTAML_COW_STRUCTURES.index(structure) * 2
152
+ @lutaml_cow_status = ((@lutaml_cow_status || 0) & ~(3 << shift)) |
153
+ (state << shift)
154
+ end
155
+
156
+ # The structures this class inherited are about to gain a second
157
+ # referencer (the new subclass). Every ancestor whose current
158
+ # object for a structure is the object being shared must flip
159
+ # from owned-private to owned-shared, or its next in-place
160
+ # mutation would leak into the new subclass.
161
+ def lutaml_cow_mark_ancestors_shared!(source_class)
162
+ LUTAML_COW_STRUCTURES.each do |structure|
163
+ shared = lutaml_cow_structure(structure)
164
+ klass = source_class
165
+ loop do
166
+ if klass.lutaml_cow_structure(structure).equal?(shared) &&
167
+ klass.lutaml_cow_status(structure) == 1
168
+ klass.lutaml_cow_set_status!(structure, 2)
169
+ end
170
+ break unless klass.is_a?(Class)
171
+
172
+ klass = klass.superclass
173
+ break unless klass.include?(Lutaml::Model::Serialize)
174
+ end
175
+ end
176
+ end
177
+
178
+ def lutaml_cow_structure(structure)
179
+ case structure
180
+ when :mappings then @mappings
181
+ when :attributes then @attributes
182
+ when :choices then @choice_attributes
183
+ when :registers then @register_records
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ # Replace a shared structure with a class-owned copy. The copy
190
+ # becomes the snapshot for every existing subclass, which is what
191
+ # keeps post-fork mutations invisible to them (the eager-copy
192
+ # semantics).
193
+ def lutaml_cow_fork!(structure)
194
+ return if lutaml_cow_status(structure) == 1
195
+
196
+ # The merged attribute view is built from the structures
197
+ # below; a replacement makes any cached merge stale.
198
+ @merged_attributes_cache = nil
199
+ case structure
200
+ when :mappings
201
+ # Entries are shared with the source class as well as the
202
+ # hash itself: a sibling inheriting the same object must
203
+ # not see this class's DSL. Copying every entry here is
204
+ # the lazy equivalent of the eager snapshot the previous
205
+ # implementation took in `inherited`.
206
+ @mappings = Utils.deep_dup(@mappings) || {}
207
+ when :attributes
208
+ @attributes = Utils.deep_dup(@attributes) || {}
209
+ when :choices
210
+ # Choice duplication rebinds Attribute objects through
211
+ # `attributes`, so the attributes fork must land first.
212
+ lutaml_cow_fork!(:attributes)
213
+ @choice_attributes = deep_duplicate_choice_attributes(self)
214
+ when :registers
215
+ @register_records = Utils.deep_dup(@register_records) ||
216
+ ::Hash.new do |hash, key|
217
+ hash[key] = { attributes: {}, choice_attributes: [] }
218
+ end
219
+ end
220
+ lutaml_cow_set_status!(structure, 1)
221
+ end
222
+
223
+ public
224
+
225
+ # Return the mapping object a format DSL may mutate. Forking
226
+ # the mappings structure copies every entry, so the object
227
+ # returned here is this class's own.
228
+ def lutaml_cow_mapping_for_dsl!(format)
229
+ lutaml_cow_fork!(:mappings)
230
+ @mappings[format]
231
+ end
232
+
233
+ # Return the register record a format DSL may mutate. The
234
+ # register store is copy-on-write, so write access forks it
235
+ # away from the parent class before handing out the record.
236
+ def register_record_for_dsl!(register_id)
237
+ lutaml_cow_fork!(:registers)
238
+ @register_records[register_id] ||= { attributes: {},
239
+ choice_attributes: [] }
240
+ end
241
+
104
242
  # Deep duplicate choice attributes from a source class
105
243
  #
106
244
  # @param source_class [Class] The source class
@@ -153,6 +291,13 @@ module Lutaml
153
291
  @attributes
154
292
  end
155
293
 
294
+ # Raw class-level choice attributes without register merging.
295
+ # Used by initialize_attrs during class inheritance.
296
+ # @return [Array] The raw choice attributes array
297
+ def class_choice_attributes
298
+ @choice_attributes
299
+ end
300
+
156
301
  # Get all choice attributes for this model
157
302
  #
158
303
  # Merges class-level choice attributes with register-specific choice attributes.
@@ -331,6 +476,7 @@ module Lutaml
331
476
  # @param max [Integer] Maximum number of choices
332
477
  # @param block [Proc] The choice definition block
333
478
  def choice(min: 1, max: 1, format: nil, &block)
479
+ lutaml_cow_fork!(:choices)
334
480
  @choice_attributes << Choice.new(self, min, max,
335
481
  format: format).tap do |c|
336
482
  c.instance_eval(&block)
@@ -57,6 +57,8 @@ module Lutaml
57
57
  end
58
58
 
59
59
  # Default register: merge into class-level storage
60
+ lutaml_cow_fork!(:attributes)
61
+ lutaml_cow_fork!(:choices)
60
62
  model.attributes.each_value { |attr| define_attribute_methods(attr) }
61
63
  @attributes.merge!(Utils.deep_dup(model.attributes))
62
64
  @choice_attributes.concat(deep_duplicate_choice_attributes(model))
@@ -81,8 +83,8 @@ module Lutaml
81
83
  next unless model.mappings.key?(format)
82
84
 
83
85
  klass = ::Lutaml::Model::Config.mappings_class_for(format)
84
- @mappings[format] ||= klass.new
85
- mapping = @mappings[format]
86
+ mapping = lutaml_cow_mapping_for_dsl!(format) || klass.new
87
+ @mappings[format] = mapping
86
88
  if mapping.is_a?(Mapping)
87
89
  mapping.import_model_mappings(model,
88
90
  register_id)
@@ -115,6 +117,8 @@ module Lutaml
115
117
  # @param model [Class] The model to import from
116
118
  # @param register_id [Symbol] The register context (must be non-default)
117
119
  def register_only_import_model_attributes(model, register_id)
120
+ lutaml_cow_fork!(:registers)
121
+ lutaml_cow_fork!(:choices)
118
122
  model.attributes(register_id).each_value do |attr|
119
123
  define_attribute_methods(attr, register_id)
120
124
  end
@@ -166,6 +170,7 @@ module Lutaml
166
170
  # resolution attempt ran — deferred_imports_pending? compares
167
171
  # against it to skip re-resolution on stable sets.
168
172
  def record_import_attempt(register_id)
173
+ lutaml_cow_fork!(:registers)
169
174
  # Raw stores only — the lazy attributes() re-enters
170
175
  # ensure_imports! and, when @models_imported was just set to
171
176
  # false (unresolved imports), cycles through this very
@@ -199,6 +204,7 @@ module Lutaml
199
204
 
200
205
  def ensure_model_imports!(register_id = nil)
201
206
  register_id ||= Lutaml::Model::Config.default_register
207
+ lutaml_cow_fork!(:registers)
202
208
  @models_imported = {} if @models_imported.nil? || @models_imported == false
203
209
  return if @models_imported[register_id] || Utils.present?(@register_records[register_id][:attributes])
204
210
 
@@ -236,6 +242,7 @@ module Lutaml
236
242
  # @param register_id [Symbol, nil] The register context
237
243
  def ensure_choice_imports!(register_id = nil)
238
244
  register_id ||= Lutaml::Model::Config.default_register
245
+ lutaml_cow_fork!(:registers)
239
246
  @choices_imported = {} if @choices_imported.nil? || @choices_imported == false
240
247
  return if @choices_imported[register_id] || Utils.present?(@register_records[register_id][:choice_attributes])
241
248
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.140"
5
+ VERSION = "0.8.142"
6
6
  end
7
7
  end
@@ -82,7 +82,7 @@ module Lutaml
82
82
  # interpretive set_schema_location contract).
83
83
  if parent.nil? && node
84
84
  record_declaration_plan(instance, node, model_class) if declaration_plan_mode != :skip
85
- record_schema_location(instance, node)
85
+ record_schema_location(instance, node, model_class, register)
86
86
  end
87
87
  children.each do |child|
88
88
  child.lutaml_parent = instance
@@ -108,7 +108,10 @@ module Lutaml
108
108
 
109
109
  # The interpretive parse stores the root's xsi:schemaLocation
110
110
  # verbatim (set_schema_location); the writer re-emits it.
111
- def record_schema_location(instance, node)
111
+ def record_schema_location(instance, node, model_class, register)
112
+ xml_mapping = model_class.mappings_for(:xml, register)
113
+ return if xml_mapping&.schema_location_mapped?(register)
114
+
112
115
  schema_loc = node.attributes.each_value.find do |a|
113
116
  a.name.to_s.split(":", 2).last == "schemaLocation"
114
117
  end
@@ -1455,6 +1455,18 @@ module Lutaml
1455
1455
  end
1456
1456
  end
1457
1457
 
1458
+ # Whether the model maps the engine-reserved schemaLocation name
1459
+ # itself (the XSD schema objects map it as a plain XSD attribute).
1460
+ # Raw xsi:schemaLocation capture must stay out of the way, or the
1461
+ # writer re-emits the value beside the mapped attribute (#1042).
1462
+ def schema_location_mapped?(register_id = nil)
1463
+ mapping_attributes_hash(register_id).each_value.any? do |rule_or_array|
1464
+ Array(rule_or_array).any? do |rule|
1465
+ rule.name.to_s == "schemaLocation"
1466
+ end
1467
+ end
1468
+ end
1469
+
1458
1470
  # Raw hash access for inheritance and deep_dup operations.
1459
1471
  # Callers may mutate the returned hash (e.g., setting keys).
1460
1472
  def elements_hash
@@ -301,7 +301,7 @@ module Lutaml
301
301
  set_instance_ordering(instance, doc, ordered_option,
302
302
  mixed_content_option, xml_mapping,
303
303
  instance_is_serialize)
304
- set_schema_location(instance, doc)
304
+ set_schema_location(instance, doc, xml_mapping, effective_register)
305
305
 
306
306
  defaults_used = []
307
307
 
@@ -511,11 +511,9 @@ module Lutaml
511
511
  end
512
512
 
513
513
  # #1031: wildcard attribute families. After the explicit attribute
514
- # rules have consumed their names, every remaining attribute
515
- # matching the rule's pattern lands in the target Hash — names
516
- # claimed by explicit rules never leak into it. The key is the name
517
- # as written, prefix included: keyed by its local name, xlink:href
518
- # would serialize back as a plain href attribute.
514
+ # rules have consumed their names, every remaining unprefixed
515
+ # attribute matching the rule's pattern lands in the target Hash —
516
+ # names claimed by explicit rules never leak into it.
519
517
  def apply_all_attributes_deserialization(doc, instance, xml_mapping)
520
518
  rule = xml_mapping.all_attributes_rule
521
519
  return if rule.nil?
@@ -526,9 +524,8 @@ module Lutaml
526
524
  claimed = attribute_names_claimed_by_rules(xml_mapping)
527
525
  remainder = {}
528
526
  element.attributes.each_value do |attr|
529
- next if claimed.include?(attr.unprefixed_name.to_s)
530
-
531
- name = attr.name.to_s
527
+ name = attr.unprefixed_name.to_s
528
+ next if claimed.include?(name)
532
529
  next if rule.matching && !rule.matching.match?(name)
533
530
 
534
531
  remainder[name] = attr.value
@@ -588,7 +585,9 @@ instance_is_serialize = nil)
588
585
  end
589
586
  end
590
587
 
591
- def set_schema_location(instance, doc)
588
+ def set_schema_location(instance, doc, xml_mapping, register)
589
+ return if xml_mapping&.schema_location_mapped?(register)
590
+
592
591
  schema_location = doc.attributes.values.find do |a|
593
592
  a.unprefixed_name == "schemaLocation"
594
593
  end
@@ -68,7 +68,7 @@ module Lutaml
68
68
  if instance_rule
69
69
  attr_def = collection.class.attributes[instance_rule.to]
70
70
  if attr_def
71
- instance_type = attr_def.type(@register)
71
+ instance_type = attr_def.type(effective_register_for(collection.class))
72
72
  if instance_type.is_a?(Class) &&
73
73
  instance_type < Lutaml::Model::Serialize
74
74
  instance_mapping = instance_type.mappings_for(:xml)
@@ -241,6 +241,17 @@ module Lutaml
241
241
  end
242
242
  end
243
243
 
244
+ # The register a model class's own attribute types resolve in: the
245
+ # class's lutaml_default_register takes precedence over the ambient
246
+ # register, unless the ambient register is a derived context carrying
247
+ # substitutions (Register.resolve_for_child encodes that contract).
248
+ def effective_register_for(mapper_class)
249
+ return @register unless mapper_class.is_a?(Class)
250
+
251
+ (@register_for ||= {})[mapper_class] ||=
252
+ Lutaml::Model::Register.resolve_for_child(mapper_class, @register)
253
+ end
254
+
244
255
  # Internal collection method
245
256
  #
246
257
  # @param element [Object] the model instance (can be nil for type analysis)
@@ -277,6 +288,13 @@ module Lutaml
277
288
  {}
278
289
  end
279
290
 
291
+ # Attribute types belong to the model declaring them: a nested
292
+ # model with its own lutaml_default_register (an mml element
293
+ # inside an STS document) declares symbolic types only that
294
+ # register knows. Resolving them in the ambient register raises
295
+ # UnknownTypeError while collecting namespaces.
296
+ attribute_register = effective_register_for(mapper_class)
297
+
280
298
  # ==================================================================
281
299
  # PHASE 1: OWN NAMESPACE COLLECTION (for non-type-only models)
282
300
  # ==================================================================
@@ -370,7 +388,7 @@ module Lutaml
370
388
  attr_def = attributes[elem_rule.to]
371
389
  next unless attr_def
372
390
 
373
- child_type = attr_def.type(@register)
391
+ child_type = attr_def.type(attribute_register)
374
392
  next unless child_type
375
393
 
376
394
  # Get child instance BEFORE adding Type ref
@@ -579,16 +597,9 @@ module Lutaml
579
597
  # Get child's mapper_class and mapping
580
598
  attr_def = attributes[matching_rule.to]
581
599
  if attr_def
582
- # Resolve type using child model's register context
600
+ # Resolve type using the owning model's register context
583
601
  mapper_class = options[:mapper_class]
584
- child_register = if mapper_class
585
- (@register_for ||= {})[mapper_class] ||=
586
- Lutaml::Model::Register.resolve_for_child(
587
- mapper_class, @register
588
- )
589
- else
590
- @register
591
- end
602
+ child_register = effective_register_for(mapper_class)
592
603
  child_type = attr_def.type(child_register)
593
604
  if child_type.is_a?(Class) && child_type < Lutaml::Model::Serialize
594
605
  child_mapping = child_type.mappings_for(:xml)
@@ -19,7 +19,7 @@ module Lutaml
19
19
  namespace Lutaml::Xml::Schema::XsdNamespace
20
20
 
21
21
  map_attribute :id, to: :id
22
- map_attribute "schemaLocation", to: :schema_path
22
+ map_attribute :schemaLocation, to: :schema_path
23
23
  map_element :group, to: :group
24
24
  map_element :annotation, to: :annotation
25
25
  map_element :simpleType, to: :simple_type
@@ -107,6 +107,7 @@ module Lutaml
107
107
  # @param block [Proc] The DSL block
108
108
  def process_mapping(format, *args, &block)
109
109
  if format == :xml && args.any? && args.first.is_a?(Class) && args.first < Lutaml::Xml::Mapping
110
+ lutaml_cow_fork!(:mappings)
110
111
  process_xml_mapping_class_inheritance(args.first, &block)
111
112
  else
112
113
  super
@@ -23,7 +23,7 @@ module Lutaml
23
23
  #
24
24
  # @param register [Symbol, nil] The register context
25
25
  def ensure_format_mapping_imports!(register = nil)
26
- mappings[:xml]&.ensure_mappings_imported!(register)
26
+ lutaml_cow_mapping_for_dsl!(:xml)&.ensure_mappings_imported!(register)
27
27
  end
28
28
 
29
29
  # Check for conflicting sort configurations with XML ordered mapping
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../spec_helper"
4
+
5
+ # Pin the inheritance-state isolation contract between a model class, its
6
+ # superclass and its siblings. These are the semantics the eager copy in
7
+ # `initialize_attrs` has always provided; they must hold for any
8
+ # copy-on-write replacement of that copy.
9
+ RSpec.describe "inherited model state isolation" do
10
+ let(:parent) do
11
+ klass = Class.new(Lutaml::Model::Serializable) do
12
+ attribute :name, :string
13
+ attribute :size, :integer
14
+
15
+ xml do
16
+ element "parent"
17
+ map_attribute "name", to: :name
18
+ map_element "size", to: :size
19
+ end
20
+ end
21
+ stub_const("IsoParent", klass)
22
+ klass
23
+ end
24
+
25
+ it "gives the child the parent's attributes and mappings immediately" do
26
+ child = Class.new(parent)
27
+ stub_const("IsoChildSeen", child)
28
+
29
+ expect(IsoChildSeen.attributes.key?(:name)).to be(true)
30
+ expect(IsoChildSeen.attributes.key?(:size)).to be(true)
31
+ expect(IsoChildSeen.mappings[:xml].elements_hash).not_to be_empty
32
+ end
33
+
34
+ it "hides a child's attribute declaration from the parent" do
35
+ child = Class.new(parent) do
36
+ attribute :extra, :string
37
+ end
38
+ stub_const("IsoChildExtra", child)
39
+
40
+ expect(IsoChildExtra.attributes.key?(:extra)).to be(true)
41
+ expect(parent.attributes.key?(:extra)).to be(false)
42
+ end
43
+
44
+ it "hides a child's mapping declaration from the parent" do
45
+ child = Class.new(parent) do
46
+ attribute :extra, :string
47
+ xml do
48
+ map_element "extra", to: :extra
49
+ end
50
+ end
51
+ stub_const("IsoChildMapping", child)
52
+
53
+ model = child.new(name: "x", extra: "e")
54
+ expect(model.to_xml).to include("<extra>e</extra>")
55
+ expect(parent.mappings[:xml].elements_hash.key?("extra")).to be(false)
56
+ end
57
+
58
+ it "hides a parent attribute declared after subclassing from the child" do
59
+ child = Class.new(parent)
60
+ stub_const("IsoChildLateParent", child)
61
+
62
+ parent.attribute :late, :string
63
+
64
+ expect(parent.attributes.key?(:late)).to be(true)
65
+ expect(child.attributes.key?(:late)).to be(false)
66
+ end
67
+
68
+ it "hides a parent mapping declared after subclassing from the child" do
69
+ child = Class.new(parent) do
70
+ attribute :late_element, :string
71
+ end
72
+ stub_const("IsoChildLateMapping", child)
73
+
74
+ parent.xml do
75
+ map_element "late_element", to: :late_element
76
+ end
77
+
78
+ expect(parent.mappings[:xml].elements_hash.key?("late_element")).to be(true)
79
+ expect(child.mappings[:xml].elements_hash.key?("late_element")).to be(false)
80
+ end
81
+
82
+ it "isolates restrict between parent, child and sibling" do
83
+ child_a = Class.new(parent)
84
+ stub_const("IsoRestrictA", child_a)
85
+ child_b = Class.new(parent)
86
+ stub_const("IsoRestrictB", child_b)
87
+
88
+ child_a.restrict :name, default: "a-default"
89
+ parent.restrict :size, default: 42
90
+
91
+ expect(parent.attributes[:name].default).to eq(IsoRestrictB.attributes[:name].default)
92
+ expect(IsoRestrictA.attributes[:name].default).to eq("a-default")
93
+ expect(IsoRestrictB.attributes[:size].default).not_to eq(42)
94
+ expect(IsoRestrictA.attributes[:size].default).not_to eq(42)
95
+ end
96
+
97
+ it "isolates choice declarations from the parent" do
98
+ child = Class.new(parent) do
99
+ choice(min: 0, max: 1) do
100
+ attribute :a, :string
101
+ attribute :b, :string
102
+ end
103
+ end
104
+ stub_const("IsoChoiceChild", child)
105
+
106
+ expect(child.choice_attributes.size).to eq(1)
107
+ expect(parent.choice_attributes).to be_empty
108
+ end
109
+
110
+ it "isolates a register-only import from the parent" do
111
+ source = Class.new(Lutaml::Model::Serializable) do
112
+ attribute :imported, :string
113
+ end
114
+ stub_const("IsoImportSource", source)
115
+
116
+ child = Class.new(parent) do
117
+ xml do
118
+ element "child"
119
+ map_attribute "name", to: :name
120
+ end
121
+ import_model IsoImportSource, :custom_reg
122
+ end
123
+ stub_const("IsoImportChild", child)
124
+
125
+ expect(child.register_record(:custom_reg)[:attributes].key?(:imported))
126
+ .to be(true)
127
+ expect(parent.register_record(:custom_reg)[:attributes]).to be_empty
128
+ expect(source.attributes.key?(:imported)).to be(true)
129
+ end
130
+
131
+ it "keeps a default-register import out of the parent" do
132
+ source = Class.new(Lutaml::Model::Serializable) do
133
+ attribute :def_imported, :string
134
+ end
135
+ stub_const("IsoImportDefaultSource", source)
136
+
137
+ child = Class.new(parent) do
138
+ import_model IsoImportDefaultSource
139
+ end
140
+ stub_const("IsoImportDefaultChild", child)
141
+
142
+ expect(child.attributes.key?(:def_imported)).to be(true)
143
+ expect(parent.attributes.key?(:def_imported)).to be(false)
144
+ expect(source.attributes.key?(:def_imported)).to be(true)
145
+ end
146
+
147
+ it "serializes a trio byte-identically to independently declared models" do
148
+ child = Class.new(parent) do
149
+ attribute :note, :string
150
+ xml do
151
+ map_element "note", to: :note
152
+ end
153
+ end
154
+ stub_const("IsoTrioChild", child)
155
+ sibling = Class.new(parent) do
156
+ attribute :flag, :string
157
+ xml do
158
+ map_element "flag", to: :flag
159
+ end
160
+ end
161
+ stub_const("IsoTrioSibling", sibling)
162
+
163
+ inherited = child.from_xml("<parent name=\"n\"><size>1</size><note>x</note></parent>")
164
+ standalone = Class.new(Lutaml::Model::Serializable) do
165
+ attribute :name, :string
166
+ attribute :size, :integer
167
+ attribute :note, :string
168
+ xml do
169
+ element "parent"
170
+ map_attribute "name", to: :name
171
+ map_element "size", to: :size
172
+ map_element "note", to: :note
173
+ end
174
+ end
175
+ stub_const("IsoTrioStandalone", standalone)
176
+
177
+ expect(inherited.to_xml).to include("<size>1</size>")
178
+ expect(inherited.to_xml).to include("<note>x</note>")
179
+ expect(standalone.from_xml(inherited.to_xml).to_xml).to eq(inherited.to_xml)
180
+ expect(sibling.from_xml("<parent name=\"s\"><flag>1</flag></parent>").to_xml)
181
+ .to include("<flag>1</flag>")
182
+ end
183
+
184
+ it "keeps grandparent chains isolated in both directions" do
185
+ mid = Class.new(parent) do
186
+ attribute :mid_attr, :string
187
+ end
188
+ stub_const("IsoMid", mid)
189
+ leaf = Class.new(mid)
190
+ stub_const("IsoLeaf", leaf)
191
+
192
+ parent.attribute :gp_late, :string
193
+ mid.attribute :mid_late, :string
194
+
195
+ expect(leaf.attributes.key?(:name)).to be(true)
196
+ expect(leaf.attributes.key?(:mid_attr)).to be(true)
197
+ expect(leaf.attributes.key?(:gp_late)).to be(false)
198
+ expect(leaf.attributes.key?(:mid_late)).to be(false)
199
+ expect(mid.attributes.key?(:gp_late)).to be(false)
200
+ expect(parent.attributes.key?(:mid_attr)).to be(false)
201
+ end
202
+ end
@@ -87,28 +87,6 @@ RSpec.describe "map_all_attributes" do
87
87
  expect(model.other_attributes).to eq("zeta" => "1")
88
88
  end
89
89
 
90
- it "keeps a prefixed attribute's prefix through a round-trip" do
91
- xml = '<mn xmlns:xlink="http://www.w3.org/1999/xlink" name="x" ' \
92
- 'data-title="T" xlink:href="#t"/>'
93
- klass = Class.new(Lutaml::Model::Serializable) do
94
- attribute :name, :string
95
- attribute :other_attributes, :hash
96
-
97
- xml do
98
- element "mn"
99
- map_attribute "name", to: :name
100
- map_all_attributes to: :other_attributes
101
- end
102
- end
103
- stub_const("MapAllPrefixedModel", klass)
104
-
105
- model = klass.from_xml(xml)
106
-
107
- expect(model.other_attributes).to eq("data-title" => "T", "xlink:href" => "#t")
108
- expect(model.to_xml).to include('xlink:href="#t"')
109
- expect(model.to_xml).not_to include(' href="#t"')
110
- end
111
-
112
90
  it "is inherited by a subclass along with the other mappings" do
113
91
  stub_const("MapAllAttributesChild", Class.new(stubbed))
114
92
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # A nested model may declare its own `lutaml_default_register` (mml does:
6
+ # every MathML element class resolves its symbolic child types in the
7
+ # per-version MML context). Namespace collection walks the child's XML
8
+ # mapping; it must resolve the child's *own* attribute types in the
9
+ # child's register, not in the ambient (parent) register.
10
+ RSpec.describe "NamespaceCollector nested child model registers" do
11
+ before do
12
+ ctx = Lutaml::Model::GlobalContext.create_context(
13
+ id: :nscollectorchild, registry: Lutaml::Model::TypeRegistry.new,
14
+ )
15
+ ctx.registry.register(:part, widget_part_class)
16
+ end
17
+
18
+ let(:widget_part_class) do
19
+ Class.new(Lutaml::Model::Serializable) do
20
+ xml do
21
+ element "part"
22
+ end
23
+ end
24
+ end
25
+
26
+ # The symbolic attribute type is declared on a base class; the concrete
27
+ # model is a subclass that carries the register hook. This mirrors
28
+ # Mml::V3::Math < Mml::CommonElements.
29
+ let(:widget_class) do
30
+ Class.new(widget_base_class) do
31
+ def self.lutaml_default_register
32
+ :nscollectorchild
33
+ end
34
+ end
35
+ end
36
+
37
+ let(:widget_base_class) do
38
+ Class.new(Lutaml::Model::Serializable) do
39
+ attribute :part_value, :part, collection: true
40
+
41
+ xml do
42
+ element "widget"
43
+ map_element "part", to: :part_value
44
+ end
45
+ end
46
+ end
47
+
48
+ let(:host_class) do
49
+ widget = widget_class
50
+ Class.new(Lutaml::Model::Serializable) do
51
+ attribute :widget, widget
52
+
53
+ xml do
54
+ element "host"
55
+ map_element "widget", to: :widget
56
+ end
57
+ end
58
+ end
59
+
60
+ let(:instance) do
61
+ host_class.from_xml("<host><widget><part/></widget></host>")
62
+ end
63
+
64
+ it "resolves the child's symbolic attribute types in the child's register" do
65
+ collector = Lutaml::Xml::NamespaceCollector.new(:default)
66
+
67
+ expect do
68
+ collector.collect(instance, host_class.mappings_for(:xml))
69
+ end.not_to raise_error
70
+ end
71
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../../spec_helper"
4
+ require "open3"
5
+ require "lutaml/xml/schema/xsd"
6
+
7
+ # #1042: the XSD schema-object classes map the plain `schemaLocation`
8
+ # attribute — an XSD attribute on xs:redefine, not xsi:schemaLocation.
9
+ # The class used to warn about its own mapping ("handled by default"),
10
+ # and the engine's raw xsi capture then re-emitted the value as
11
+ # xsi:schemaLocation beside the mapped attribute.
12
+ RSpec.describe "Xsd::Redefine schemaLocation" do
13
+ let(:xsd) do
14
+ <<~XSD
15
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
16
+ <xsd:redefine schemaLocation="base.xsd">
17
+ <xsd:group name="g">
18
+ <xsd:choice><xsd:element name="e1"/></xsd:choice>
19
+ </xsd:group>
20
+ </xsd:redefine>
21
+ </xsd:schema>
22
+ XSD
23
+ end
24
+
25
+ it "reads schemaLocation into schema_path" do
26
+ doc = Lutaml::Xml::Schema::Xsd.parse(xsd)
27
+
28
+ expect(doc.redefine.first.schema_path).to eq("base.xsd")
29
+ end
30
+
31
+ it "round-trips it as the plain attribute without an xsi copy" do
32
+ xml = Lutaml::Xml::Schema::Xsd.parse(xsd).to_xml
33
+
34
+ expect(xml).to include('schemaLocation="base.xsd"')
35
+ expect(xml).not_to include("xsi:schemaLocation")
36
+ end
37
+
38
+ it "does not warn while defining its own mapping" do
39
+ probe = 'require "lutaml/model"; require "lutaml/xml/schema/xsd"; ' \
40
+ "Lutaml::Xml::Schema::Xsd::Redefine"
41
+
42
+ _out, err, status = Open3.capture3(RbConfig.ruby, "-I", "lib", "-e", probe)
43
+
44
+ expect(status).to be_success, err
45
+ expect(err).not_to include("handled by default")
46
+ end
47
+
48
+ it "still captures xsi:schemaLocation on models that do not map it" do
49
+ model = Class.new(Lutaml::Model::Serializable) do
50
+ attribute :name, :string
51
+ xml do
52
+ element "r"
53
+ map_attribute "name", to: :name
54
+ end
55
+ end
56
+ stub_const("AutoSchemaLocationModel", model)
57
+
58
+ parsed = AutoSchemaLocationModel.from_xml(
59
+ '<r xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ' \
60
+ 'name="n" xsi:schemaLocation="urn:a a.xsd"/>',
61
+ )
62
+
63
+ expect(parsed.raw_schema_location).to eq("urn:a a.xsd")
64
+ end
65
+ 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.140
4
+ version: 0.8.142
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1782,6 +1782,7 @@ files:
1782
1782
  - spec/lutaml/model/import_registry_spec.rb
1783
1783
  - spec/lutaml/model/included_spec.rb
1784
1784
  - spec/lutaml/model/inheritance_spec.rb
1785
+ - spec/lutaml/model/inherited_state_isolation_spec.rb
1785
1786
  - spec/lutaml/model/json_adapter_spec.rb
1786
1787
  - spec/lutaml/model/json_spec.rb
1787
1788
  - spec/lutaml/model/json_state_nesting_spec.rb
@@ -2001,6 +2002,7 @@ files:
2001
2002
  - spec/lutaml/xml/namespace_aware_parsing_spec.rb
2002
2003
  - spec/lutaml/xml/namespace_backward_compatibility_spec.rb
2003
2004
  - spec/lutaml/xml/namespace_bound_element_roundtrip_spec.rb
2005
+ - spec/lutaml/xml/namespace_collector_child_register_spec.rb
2004
2006
  - spec/lutaml/xml/namespace_constant_spec.rb
2005
2007
  - spec/lutaml/xml/namespace_force_prefix_spec.rb
2006
2008
  - spec/lutaml/xml/namespace_format_preservation_spec.rb
@@ -2060,6 +2062,7 @@ files:
2060
2062
  - spec/lutaml/xml/schema/xsd/list_simple_type_spec.rb
2061
2063
  - spec/lutaml/xml/schema/xsd/mapping_integrity_spec.rb
2062
2064
  - spec/lutaml/xml/schema/xsd/namespace_uri_remapping_spec.rb
2065
+ - spec/lutaml/xml/schema/xsd/redefine_schema_location_spec.rb
2063
2066
  - spec/lutaml/xml/schema/xsd/schema_helper_methods_spec.rb
2064
2067
  - spec/lutaml/xml/schema/xsd/schema_mapping_spec.rb
2065
2068
  - spec/lutaml/xml/schema/xsd/schema_validator_spec.rb