lutaml-model 0.8.6 → 0.8.8

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.
@@ -6,7 +6,8 @@ module Lutaml
6
6
  @transform_cache = {}
7
7
 
8
8
  # Maximum number of cached Transform instances before eviction.
9
- MAX_CACHE_SIZE = 256
9
+ # Covers most OOXML/ISO schemas with ~1000+ classes and multiple registers.
10
+ MAX_CACHE_SIZE = 2048
10
11
 
11
12
  def self.data_to_model(context, data, format, options = {})
12
13
  register = options[:register] || Lutaml::Model::Config.default_register
@@ -15,7 +16,7 @@ module Lutaml
15
16
  end
16
17
 
17
18
  def self.model_to_data(context, model, format, options = {})
18
- register = model.lutaml_register if model.respond_to?(:lutaml_register)
19
+ register = model.lutaml_register if model.is_a?(Lutaml::Model::Serialize)
19
20
  register ||= Lutaml::Model::Config.default_register
20
21
  transform = cached_transform(context, register)
21
22
  transform.model_to_data(model, format, options)
@@ -23,7 +24,7 @@ module Lutaml
23
24
 
24
25
  def self.cached_transform(context, register)
25
26
  @transform_cache ||= {}
26
- cache_key = [context.object_id, register]
27
+ cache_key = [context, register]
27
28
  entry = @transform_cache[cache_key]
28
29
  return entry if entry
29
30
 
@@ -39,6 +40,16 @@ module Lutaml
39
40
  @transform_cache&.size || 0
40
41
  end
41
42
 
43
+ def self.invalidate_for(context, register = nil)
44
+ return unless @transform_cache
45
+
46
+ if register
47
+ @transform_cache.delete([context, register])
48
+ else
49
+ @transform_cache.reject! { |(ctx, _reg)| ctx == context }
50
+ end
51
+ end
52
+
42
53
  def self.evict_if_needed
43
54
  # Evict oldest half of entries when cache is full
44
55
  keys_to_remove = @transform_cache.keys.first(@transform_cache.size / 2)
@@ -8,10 +8,10 @@ module Lutaml
8
8
  return super if Utils.uninitialized?(value)
9
9
  return nil if value.nil?
10
10
 
11
- hash = if value.respond_to?(:to_h)
12
- value.to_h
13
- else
14
- Hash(value)
11
+ hash = case value
12
+ when ::Hash then value
13
+ when ::Array then value.to_h
14
+ else value.to_h
15
15
  end
16
16
 
17
17
  normalize_hash(hash)
@@ -40,7 +40,11 @@ module Lutaml
40
40
  return nil if value.nil?
41
41
  return value if value.is_a?(::Hash)
42
42
 
43
- value.respond_to?(:to_h) ? value.to_h : Hash(value)
43
+ case value
44
+ when ::Hash then value
45
+ when ::Array then value.to_h
46
+ else value.to_h
47
+ end
44
48
  end
45
49
 
46
50
  # XSD type for Hash
@@ -7,7 +7,7 @@ module Lutaml
7
7
  def self.cast(value, options = {})
8
8
  return nil if value.nil?
9
9
  return value if Utils.uninitialized?(value)
10
- return nil if value.respond_to?(:empty?) && value.empty?
10
+ return nil if value.is_a?(::String) && value.empty?
11
11
 
12
12
  # Convert to string for validation and unwrapping
13
13
  str_value = if value.is_a?(::Symbol)
@@ -115,18 +115,25 @@ module Lutaml
115
115
  end
116
116
 
117
117
  def blank?(value)
118
- value.respond_to?(:empty?) ? value.empty? : value.nil?
118
+ case value
119
+ when ::String, ::Array, ::Hash then value.empty?
120
+ when ::NilClass then true
121
+ else false
122
+ end
119
123
  end
120
124
 
121
125
  def empty_collection?(collection)
122
126
  return false if collection.nil?
123
- return false unless [Array, Hash].include?(collection.class)
127
+ return false unless [::Array, ::Hash].include?(collection.class)
124
128
 
125
129
  collection.empty?
126
130
  end
127
131
 
128
132
  def empty?(value)
129
- value.respond_to?(:empty?) ? value.empty? : false
133
+ case value
134
+ when ::String, ::Array, ::Hash then value.empty?
135
+ else false
136
+ end
130
137
  end
131
138
 
132
139
  def add_if_present(hash, key, value)
@@ -273,7 +280,11 @@ module Lutaml
273
280
  end
274
281
 
275
282
  def deep_dup_object(object)
276
- object.respond_to?(:deep_dup) ? object.deep_dup : object.dup
283
+ if object.is_a?(DeepDupable)
284
+ object.deep_dup
285
+ else
286
+ object.dup
287
+ end
277
288
  end
278
289
 
279
290
  def camelize_part(part)
@@ -10,7 +10,7 @@ module Lutaml
10
10
  value = public_send(:"#{name}")
11
11
 
12
12
  begin
13
- if value.respond_to?(:validate)
13
+ if value.is_a?(Lutaml::Model::Serialize)
14
14
  sub_errors = value.validate
15
15
  errors.concat(sub_errors) if sub_errors.is_a?(Array)
16
16
  else
@@ -74,8 +74,14 @@ module Lutaml
74
74
  nil
75
75
  end
76
76
 
77
+ # Default: no element order. XML overrides via InstanceMethods prepend
78
+ # with attr_accessor :element_order.
79
+ def element_order
80
+ nil
81
+ end
82
+
77
83
  def order_names
78
- return [] unless respond_to?(:element_order) && element_order
84
+ return [] unless element_order
79
85
 
80
86
  element_order.each_with_object([]) do |element, arr|
81
87
  next if element.text?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.6"
5
+ VERSION = "0.8.8"
6
6
  end
7
7
  end
data/lib/lutaml/model.rb CHANGED
@@ -33,6 +33,7 @@ module Lutaml
33
33
  autoload :AdapterResolver, "#{__dir__}/model/adapter_resolver"
34
34
  autoload :AdapterScope, "#{__dir__}/model/adapter_scope"
35
35
  autoload :Utils, "#{__dir__}/model/utils"
36
+ autoload :DeepDupable, "#{__dir__}/model/deep_dupable"
36
37
  autoload :Serializable, "#{__dir__}/model/serializable"
37
38
  autoload :Error, "#{__dir__}/model/error"
38
39
  autoload :Constants, "#{__dir__}/model/constants"
@@ -145,13 +145,10 @@ module Lutaml
145
145
  attribute_group.sort_by { |item| item.name.to_s }
146
146
  end
147
147
 
148
- # Capture both the target namespace URI and the matching prefix
149
- # discovered during XML deserialization.
150
- def target_namespace_from(model, value, custom_args = {})
148
+ def target_namespace_from(model, value)
151
149
  model.target_namespace = value
152
- namespaces = custom_args[:namespaces] || {}
153
150
  model.target_namespace_prefix =
154
- namespaces.find { |_, namespace| namespace.uri == value }&.first
151
+ namespace_prefix_for(model.pending_plan_root_element, value)
155
152
  end
156
153
 
157
154
  # Find a type definition by local name
@@ -246,6 +243,12 @@ module Lutaml
246
243
 
247
244
  private
248
245
 
246
+ def namespace_prefix_for(element, namespace_uri)
247
+ element&.own_namespaces&.find do |_, namespace|
248
+ namespace.uri == namespace_uri
249
+ end&.first
250
+ end
251
+
249
252
  def all_namespaces
250
253
  # Aggregate the schema target namespace with imported namespaces.
251
254
  namespaces = [target_namespace].compact
@@ -34,6 +34,8 @@ module Lutaml
34
34
  def clear_xml_parse_state!
35
35
  @import_declaration_plan = nil
36
36
  @pending_plan_root_element = nil
37
+ @element_order = nil
38
+ @attribute_order = nil
37
39
  self
38
40
  end
39
41
 
@@ -282,7 +282,7 @@ module Lutaml
282
282
  namespace_uri: child.namespace_uri,
283
283
  namespace_prefix: child.namespace_prefix)
284
284
  end
285
- end
285
+ end.each(&:freeze).freeze
286
286
  end
287
287
 
288
288
  def root
@@ -288,30 +288,19 @@ RSpec.describe Lutaml::Model::Attribute do
288
288
  describe "#deep_dup" do
289
289
  let(:duplicate_attribute) { Lutaml::Model::Utils.deep_dup(attribute) }
290
290
 
291
- context "when deep_dup method is not defined and instance is deep_duplicated" do
292
- let(:attribute) { described_class.new("name", :string) }
293
-
294
- before do
295
- described_class.alias_method :orig_deep_dup, :deep_dup
296
- described_class.undef_method :deep_dup
297
- end
298
-
299
- after do
300
- described_class.alias_method :deep_dup, :orig_deep_dup
301
- attribute.options.delete(:foo)
302
- end
303
-
304
- it "confirms that options values are linked of original and duplicate instances" do
305
- duplicate_attribute
306
- attribute.options[:foo] = "bar"
307
- expect(duplicate_attribute.options).to include(:foo)
291
+ context "when object does not include DeepDupable" do
292
+ it "falls back to dup for plain objects" do
293
+ plain = Struct.new(:value).new("hello")
294
+ result = Lutaml::Model::Utils.deep_dup(plain)
295
+ expect(result.value).to eq("hello")
296
+ expect(result).not_to equal(plain)
308
297
  end
309
298
  end
310
299
 
311
- context "when deep_dup method is defined and instance is deep_duplicated" do
300
+ context "when Attribute is deep_duplicated" do
312
301
  let(:attribute) { described_class.new("name", :string) }
313
302
 
314
- it "confirms that options values are not linked of original and duplicate instances" do
303
+ it "creates independent copies of options" do
315
304
  duplicate_attribute
316
305
  attribute.options[:foo] = "bar"
317
306
  expect(duplicate_attribute.options).not_to include(:foo)
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "Register-specific attribute methods" do
6
+ let(:register) do
7
+ Lutaml::Model::Register.new(:register_methods_test,
8
+ fallback: [:default])
9
+ end
10
+
11
+ let(:base_model) do
12
+ Class.new(Lutaml::Model::Serializable) do
13
+ attribute :name, :string
14
+
15
+ xml do
16
+ root "Base"
17
+ map_element "Name", to: :name
18
+ end
19
+ end
20
+ end
21
+
22
+ let(:extension_model) do
23
+ Class.new(Lutaml::Model::Serializable) do
24
+ attribute :version, :string
25
+ attribute :priority, :integer
26
+
27
+ xml do
28
+ root "Extension"
29
+ map_element "Version", to: :version
30
+ map_element "Priority", to: :priority
31
+ end
32
+ end
33
+ end
34
+
35
+ before do
36
+ Lutaml::Model::GlobalContext.reset!
37
+ Lutaml::Model::GlobalRegister.register(register)
38
+ register.register_model(extension_model, id: :extension_model)
39
+ base_model.import_model_attributes(extension_model, register.id)
40
+ end
41
+
42
+ describe "class-level method definition" do
43
+ it "does not create singleton class methods for register-specific attributes" do
44
+ instance = base_model.new({ name: "test",
45
+ version: "1.0" },
46
+ register: register.id)
47
+
48
+ expect(instance.version).to eq("1.0")
49
+
50
+ # Method should be on the class, NOT directly on the singleton class
51
+ expect(instance.singleton_class.instance_methods(false)).not_to include(:version)
52
+ expect(instance.singleton_class.instance_methods(false)).not_to include(:version=)
53
+ end
54
+
55
+ it "defines methods on the class accessible to all instances" do
56
+ instance1 = base_model.new({ name: "a" }, register: register.id)
57
+ instance2 = base_model.new({ name: "b" }, register: register.id)
58
+
59
+ instance1.version = "1.0"
60
+ instance2.version = "2.0"
61
+
62
+ expect(instance1.version).to eq("1.0")
63
+ expect(instance2.version).to eq("2.0")
64
+ end
65
+
66
+ it "guards against re-definition with @_register_methods_defined" do
67
+ base_model.new({ name: "a" }, register: register.id)
68
+
69
+ guard = base_model.instance_variable_get(:@_register_methods_defined)
70
+ expect(guard).to include(register.id => true)
71
+
72
+ # Second instance creation should not re-trigger method definition
73
+ base_model.new({ name: "b" }, register: register.id)
74
+ expect(guard[register.id]).to be(true)
75
+ end
76
+ end
77
+
78
+ describe "type casting in setter" do
79
+ it "casts integer values correctly" do
80
+ instance = base_model.new({ name: "test" }, register: register.id)
81
+
82
+ instance.priority = "5"
83
+ expect(instance.priority).to eq(5)
84
+ end
85
+
86
+ it "casts string values correctly" do
87
+ instance = base_model.new({ name: "test" }, register: register.id)
88
+
89
+ instance.version = 42
90
+ expect(instance.version).to eq("42")
91
+ end
92
+ end
93
+
94
+ describe "value_set_for tracking" do
95
+ it "marks register-specific attributes as explicitly set" do
96
+ instance = base_model.new({ name: "test" }, register: register.id)
97
+
98
+ # After initialization, version was set via public_send in initialize_attributes
99
+ expect(instance.using_default?(:version)).to be(false)
100
+ end
101
+
102
+ it "uses default when register-specific attribute is not provided" do
103
+ instance = base_model.allocate_for_deserialization(register.id)
104
+
105
+ # Not yet set — using default
106
+ expect(instance.using_default?(:version)).to be(true)
107
+
108
+ instance.version = "1.0"
109
+ expect(instance.using_default?(:version)).to be(false)
110
+ end
111
+ end
112
+
113
+ describe "default register instances" do
114
+ it "returns early without defining methods for :default register" do
115
+ base_model.ensure_register_methods_defined(:default)
116
+
117
+ guard = base_model.instance_variable_get(:@_register_methods_defined)
118
+ expect(guard).to be_nil
119
+ end
120
+
121
+ it "default-register instances still work for class-level attributes" do
122
+ instance = base_model.new(name: "test")
123
+
124
+ expect(instance.name).to eq("test")
125
+ instance.name = "changed"
126
+ expect(instance.name).to eq("changed")
127
+ end
128
+ end
129
+
130
+ describe "deserialization path" do
131
+ it "defines methods via finalize_deserialization" do
132
+ instance = base_model.allocate_for_deserialization(register.id)
133
+ instance.version = "3.0"
134
+
135
+ expect(instance.version).to eq("3.0")
136
+ expect(instance.singleton_class.instance_methods(false)).not_to include(:version)
137
+ end
138
+ end
139
+
140
+ describe "clear_cache resets the guard" do
141
+ it "clears @_register_methods_defined on clear_cache" do
142
+ base_model.new({ name: "a" }, register: register.id)
143
+ expect(base_model.instance_variable_get(:@_register_methods_defined)).not_to be_nil
144
+
145
+ base_model.clear_cache
146
+ expect(base_model.instance_variable_get(:@_register_methods_defined)).to be_nil
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Lutaml::Model::Store do
6
+ let(:model_class) do
7
+ Class.new(Lutaml::Model::Serializable) do
8
+ attribute :id, :string
9
+ attribute :name, :string
10
+ end
11
+ end
12
+
13
+ before { described_class.clear }
14
+ after { described_class.clear }
15
+
16
+ describe "#register and #resolve" do
17
+ it "resolves a registered object by reference key" do
18
+ obj = model_class.new(id: "abc", name: "test")
19
+ result = described_class.resolve(model_class, :id, "abc")
20
+ expect(result).to eq(obj)
21
+ end
22
+
23
+ it "returns nil for non-existent reference" do
24
+ model_class.new(id: "abc")
25
+ result = described_class.resolve(model_class, :id, "nonexistent")
26
+ expect(result).to be_nil
27
+ end
28
+
29
+ it "returns nil for non-existent class" do
30
+ other_class = Class.new(Lutaml::Model::Serializable) do
31
+ attribute :id, :string
32
+ end
33
+ result = described_class.resolve(other_class, :id, "anything")
34
+ expect(result).to be_nil
35
+ end
36
+
37
+ it "resolves by name attribute" do
38
+ obj = model_class.new(id: "abc", name: "myobj")
39
+ result = described_class.resolve(model_class, :name, "myobj")
40
+ expect(result).to eq(obj)
41
+ end
42
+ end
43
+
44
+ describe "indexed lookup" do
45
+ it "builds index lazily on first resolve" do
46
+ 5.times { |i| model_class.new(id: "obj-#{i}") }
47
+
48
+ result = described_class.resolve(model_class, :id, "obj-3")
49
+ expect(result.id).to eq("obj-3")
50
+ end
51
+
52
+ it "updates index when new objects are registered after first resolve" do
53
+ model_class.new(id: "first")
54
+ described_class.resolve(model_class, :id, "first") # triggers index build
55
+
56
+ model_class.new(id: "second")
57
+ result = described_class.resolve(model_class, :id, "second")
58
+ expect(result.id).to eq("second")
59
+ end
60
+
61
+ it "reuses index across multiple resolves for same key" do
62
+ 10.times { |i| model_class.new(id: "obj-#{i}") }
63
+
64
+ # First resolve builds the index
65
+ described_class.resolve(model_class, :id, "obj-5")
66
+ # Second resolve should use the same index
67
+ result = described_class.resolve(model_class, :id, "obj-7")
68
+ expect(result.id).to eq("obj-7")
69
+ end
70
+ end
71
+
72
+ describe "WeakRef behavior" do
73
+ it "uses WeakRef for storage (objects can be collected when unreferenced)" do
74
+ obj = model_class.new(id: "alive")
75
+ result = described_class.resolve(model_class, :id, "alive")
76
+ expect(result).to eq(obj)
77
+ end
78
+
79
+ it "resolves objects with strong references held externally" do
80
+ obj = model_class.new(id: "alive")
81
+ GC.start
82
+ result = described_class.resolve(model_class, :id, "alive")
83
+ expect(result).to eq(obj)
84
+ end
85
+ end
86
+
87
+ describe "#store" do
88
+ it "returns only live objects" do
89
+ obj1 = model_class.new(id: "keep")
90
+ _obj2 = model_class.new(id: "drop")
91
+ _obj2 = nil
92
+ GC.start
93
+
94
+ entries = described_class.store[model_class.to_s]
95
+ # obj1 should be alive; obj2 may or may not be GC'd depending on timing
96
+ expect(entries).to include(obj1)
97
+ end
98
+ end
99
+
100
+ describe "#clear" do
101
+ it "removes all registered objects" do
102
+ model_class.new(id: "a")
103
+ model_class.new(id: "b")
104
+ described_class.clear
105
+
106
+ expect(described_class.resolve(model_class, :id, "a")).to be_nil
107
+ expect(described_class.resolve(model_class, :id, "b")).to be_nil
108
+ end
109
+
110
+ it "clears the index" do
111
+ model_class.new(id: "indexed")
112
+ described_class.resolve(model_class, :id, "indexed") # build index
113
+ described_class.clear
114
+
115
+ model_class.new(id: "new-one")
116
+ result = described_class.resolve(model_class, :id, "new-one")
117
+ expect(result.id).to eq("new-one")
118
+ end
119
+ end
120
+ end
@@ -59,4 +59,46 @@ RSpec.describe "Transform caching" do
59
59
  expect(Lutaml::Model::Transform.cache_size).to be <= 4
60
60
  end
61
61
  end
62
+
63
+ describe ".invalidate_for" do
64
+ it "invalidates cache for a specific context" do
65
+ klass = Class.new(Lutaml::Model::Serializable) do
66
+ attribute :name, :string
67
+ end
68
+
69
+ t1 = Lutaml::Model::Transform.cached_transform(klass, :default)
70
+ Lutaml::Model::Transform.invalidate_for(klass)
71
+ t2 = Lutaml::Model::Transform.cached_transform(klass, :default)
72
+ expect(t1).not_to equal(t2)
73
+ end
74
+
75
+ it "invalidates cache for a specific context and register" do
76
+ klass = Class.new(Lutaml::Model::Serializable) do
77
+ attribute :name, :string
78
+ end
79
+
80
+ t1_default = Lutaml::Model::Transform.cached_transform(klass, :default)
81
+ t1_other = Lutaml::Model::Transform.cached_transform(klass, :other)
82
+
83
+ Lutaml::Model::Transform.invalidate_for(klass, :default)
84
+
85
+ t2_default = Lutaml::Model::Transform.cached_transform(klass, :default)
86
+ t2_other = Lutaml::Model::Transform.cached_transform(klass, :other)
87
+
88
+ expect(t1_default).not_to equal(t2_default)
89
+ expect(t1_other).to equal(t2_other)
90
+ end
91
+
92
+ it "uses class identity as key, not object_id" do
93
+ klass = Class.new(Lutaml::Model::Serializable) do
94
+ attribute :name, :string
95
+ end
96
+
97
+ t1 = Lutaml::Model::Transform.cached_transform(klass, :default)
98
+ # Force GC — object_id reuse should not cause stale hits
99
+ GC.start
100
+ t2 = Lutaml::Model::Transform.cached_transform(klass, :default)
101
+ expect(t1).to equal(t2)
102
+ end
103
+ end
62
104
  end
@@ -75,11 +75,18 @@ RSpec.describe "#clear_xml_parse_state!" do
75
75
  end
76
76
 
77
77
  describe "user-facing attributes" do
78
- it "does not clear element_order" do
78
+ it "clears element_order to release parse buffers" do
79
79
  model = model_class.from_xml(xml_with_ns)
80
- original_order = model.element_order
80
+ expect(model.element_order).not_to be_nil
81
81
  model.clear_xml_parse_state!
82
- expect(model.element_order).to eq(original_order)
82
+ expect(model.element_order).to be_nil
83
+ end
84
+
85
+ it "clears attribute_order to release parse buffers" do
86
+ model = model_class.from_xml(xml_with_ns)
87
+ expect(model.attribute_order).not_to be_nil
88
+ model.clear_xml_parse_state!
89
+ expect(model.attribute_order).to be_nil
83
90
  end
84
91
 
85
92
  it "does not clear encoding" do
@@ -14,6 +14,41 @@ RSpec.describe "Schema mapping integration" do
14
14
  Lutaml::Xml::Schema::Xsd::Glob.schema_mappings = nil
15
15
  end
16
16
 
17
+ describe "target namespace prefix parsing" do
18
+ let(:xsd_content) do
19
+ <<~XSD
20
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
21
+ xmlns:t="http://example.com/test"
22
+ targetNamespace="http://example.com/test">
23
+ <xs:element name="Root" type="xs:string"/>
24
+ </xs:schema>
25
+ XSD
26
+ end
27
+
28
+ it "captures the root prefix declared for the target namespace" do
29
+ parsed = Lutaml::Xml::Schema::Xsd.parse(xsd_content)
30
+
31
+ expect(parsed.target_namespace).to eq("http://example.com/test")
32
+ expect(parsed.target_namespace_prefix).to eq("t")
33
+ expect(parsed.element.first.target_prefix).to eq("t")
34
+ end
35
+
36
+ it "captures nil prefix when target namespace is the default (unprefixed)" do
37
+ xsd_default_ns = <<~XSD
38
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
39
+ xmlns="http://example.com/default"
40
+ targetNamespace="http://example.com/default">
41
+ <xs:element name="Root" type="xs:string"/>
42
+ </xs:schema>
43
+ XSD
44
+
45
+ parsed = Lutaml::Xml::Schema::Xsd.parse(xsd_default_ns)
46
+
47
+ expect(parsed.target_namespace).to eq("http://example.com/default")
48
+ expect(parsed.target_namespace_prefix).to be_nil
49
+ end
50
+ end
51
+
17
52
  describe "parsing with exact string mappings" do
18
53
  let(:xsd_content) do
19
54
  <<~XSD
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "lutaml/model"
3
4
  require "lutaml/xml/schema/xsd"
4
5
 
5
6
  module XsdSpecHelper