lutaml-model 0.8.34 → 0.8.36

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: 65361e31ef80bfd3600c0bc3ea4241c2b9a9e9d227b1772b9bd23f3324e92ad7
4
- data.tar.gz: da258d5127e14511cd77bd1ca44e6ad1647aaaf445cc14a92102096f180a82a3
3
+ metadata.gz: 13023a5add888c4affa15d1e7c025548e329b0aad8112582550be7cff794524a
4
+ data.tar.gz: 7393786a16da4f06d7962f7395edab78b5985ab1820c98da8b95f66f669be99d
5
5
  SHA512:
6
- metadata.gz: 2d5ece44cac496100e30c2d4f3c5ca3934f656bd81352ee9215e57c2a4b8563f0a7a8ae3c155909eaf132efcce992e1954731b89cd79808fcf8c948555b06abd
7
- data.tar.gz: 9db5fc0ac630ad33336250d06a953357d8a2d60bb0f0cae037d5a2d9d6cc12da2fca2937aa5b73c5b490852381995937fad226049e51fd167906e900084fa837
6
+ metadata.gz: 03be6773d23935e26c6593d2c966f5063af4840142f988c6db534517b26b7889adc15c11c5d5280b181b9b19e35a971bbbc889ce673d63a750385e5b392d60b7
7
+ data.tar.gz: 656f73aab6fe55e9dbe8337a8c6ec76fae5c2cadfade74bdd30500d3ea16105cdf3de1ebce9d837b4ddd3c6851e84ac7b5b7a7f40f2729d79827014e9d0a0b9e
data/.rspec CHANGED
@@ -1,3 +1,4 @@
1
1
  --format documentation
2
2
  --color
3
3
  --require spec_helper
4
+ --tag ~benchmark
@@ -52,11 +52,13 @@ module Lutaml
52
52
  # Public reader for compiled rule plans: does this type class carry a
53
53
  # custom from_xml/from_json (answer static, probed once)?
54
54
  def custom_from_probe?(type)
55
- TypeProbeCache.cache[type] ||= begin
56
- base = Lutaml::Model::Type::Value.singleton_class
55
+ cache = TypeProbeCache.cache
56
+ return cache[type] if cache.key?(type)
57
+
58
+ base = Lutaml::Model::Type::Value.singleton_class
59
+ cache[type] =
57
60
  type.method(:from_xml).owner != base ||
58
- type.method(:from_json).owner != base
59
- end
61
+ type.method(:from_json).owner != base
60
62
  end
61
63
  end
62
64
 
@@ -434,18 +436,31 @@ module Lutaml
434
436
  end
435
437
 
436
438
  def cast_element(value, register)
437
- # Resolve first: an undeclared type has to raise UnknownTypeError even
438
- # when the value carries nothing, the way it did before the guard
439
- # below existed.
440
- resolved_type = type(register)
439
+ # Resolve first: an undeclared type has to raise UnknownTypeError
440
+ # even for a nil value (the 0.8.33 behavior).
441
+ type(register)
441
442
 
442
- # Casting is for data. Every built-in type already hands nil and the
443
- # uninitialized sentinel straight back, so this changes nothing for
444
- # them. A type with its own `self.cast` returns a real instance
445
- # instead, and that instance becomes a value nobody wrote — a phantom
446
- # element in the document, or a one-item collection the source never
447
- # contained. `cast` below guards the format entry point the same way.
448
- return value if no_data?(value)
443
+ return value if Utils.uninitialized?(value)
444
+
445
+ # nil reaches the type's cast — the long-shipped contract
446
+ # ST_OnOff-style types depend on (absent attribute -> cast(nil)
447
+ # -> false; lutaml-model#795). What nil must NOT do is
448
+ # manufacture a typed INSTANCE from nothing: that is the
449
+ # phantom #793 removed, suppressed by the post-check below.
450
+ if value.nil?
451
+ result = cast_element_present(value, register)
452
+ return result if result.nil?
453
+ return nil if result.is_a?(Lutaml::Model::Type::Value) ||
454
+ result.is_a?(Lutaml::Model::Serialize)
455
+
456
+ return result
457
+ end
458
+
459
+ cast_element_present(value, register)
460
+ end
461
+
462
+ def cast_element_present(value, register)
463
+ resolved_type = type(register)
449
464
 
450
465
  return cast_union(value, nil, register) if union?
451
466
  return resolved_type.new(value) if value.is_a?(::Hash) && !hash_type?
@@ -783,14 +798,25 @@ instance_object = nil)
783
798
  end
784
799
 
785
800
  def cast(value, format, register, options = {})
786
- # Same rule as cast_element, for the format entry point. from_json /
787
- # from_yaml / from_toml / from_hash reach a missing key as nil, and a
788
- # type with its own `self.cast` turns that nil into an instance the
789
- # document never carried. Unlike cast_element this returns before
790
- # resolving the type: resolution here depends on the format options
791
- # below, and an undeclared type still raises through cast_element.
792
- return value if no_data?(value)
801
+ # Sentinel: never a cast input. nil flows on — absent values
802
+ # reach the type's cast (ST_OnOff booleans, #795); a cast that
803
+ # manufactures a typed instance from nothing is the phantom
804
+ # #793 removed, suppressed here as in cast_element.
805
+ return value if Utils.uninitialized?(value)
806
+
807
+ if value.nil?
808
+ result = cast_present(value, format, register, options)
809
+ return result if result.nil?
810
+ return nil if result.is_a?(Lutaml::Model::Type::Value) ||
811
+ result.is_a?(Lutaml::Model::Serialize)
812
+
813
+ return result
814
+ end
815
+
816
+ cast_present(value, format, register, options)
817
+ end
793
818
 
819
+ def cast_present(value, format, register, options = {})
794
820
  # Namespace-aware type resolution: use type_with_namespace if namespace_uri provided
795
821
  namespace_uri = options[:namespace_uri]
796
822
  resolved_type = if options[:resolved_type]
@@ -80,15 +80,22 @@ module Lutaml
80
80
  # @param parent_register [Symbol, String, nil] The parent's register/context ID
81
81
  # @return [Symbol, nil] The register ID to use for the child
82
82
  def self.resolve_for_child(child_class, parent_register)
83
- cache_key = [child_class.object_id, parent_register]
84
-
85
83
  if Lutaml::Model.opal?
86
- return RESOLVE_CACHE[cache_key] if RESOLVE_CACHE.key?(cache_key)
84
+ per_class = RESOLVE_CACHE[child_class] # rubocop:todo Style/IdenticalConditionalBranches
85
+ return per_class[parent_register] if per_class&.key?(parent_register)
87
86
 
88
- RESOLVE_CACHE[cache_key] =
87
+ (RESOLVE_CACHE[child_class] ||= {})[parent_register] =
89
88
  _resolve_for_child_uncached(child_class, parent_register)
90
89
  else
91
- RESOLVE_CACHE.compute_if_absent(cache_key) do
90
+ # Nested maps: class → register → result. Every hop keys on a
91
+ # stable object (Class / Symbol), so the hot lookup allocates
92
+ # nothing — the flat [object_id, register] array key cost one
93
+ # Array per call, six per parsed element.
94
+ per_class = RESOLVE_CACHE[child_class] # rubocop:todo Style/IdenticalConditionalBranches
95
+ per_class ||= RESOLVE_CACHE.compute_if_absent(child_class) do
96
+ Concurrent::Map.new
97
+ end
98
+ per_class.compute_if_absent(parent_register) do
92
99
  _resolve_for_child_uncached(child_class, parent_register)
93
100
  end
94
101
  end
@@ -174,25 +174,31 @@ module Lutaml
174
174
  # @param register [Symbol, Register, nil] The register
175
175
  # @return [Mapping] The resolved mapping
176
176
  def get_or_build_mapping(model_class, format, register)
177
- # Performance: Use fast array key instead of symbol construction
177
+ # Performance: nested class → format → register maps keyed on
178
+ # stable objects; the flat array key allocated per call.
178
179
  register_id = extract_register_id(register)
179
- key = [model_class.object_id, format, register_id]
180
180
 
181
- # Fast path: native Ruby uses Concurrent::Map, Opal uses Hash.
182
- cached = @mappings[key]
181
+ per_class = @mappings[model_class]
182
+ per_format = per_class&.[](format)
183
+ cached = per_format&.[](register_id)
183
184
  return cached if cached
184
185
 
185
- # Build mapping OUTSIDE any lock to avoid deadlock
186
- # (ensure_mappings_imported! may recursively call get_or_build_mapping)
187
186
  mapping = model_class.mappings[format]
188
187
  mapping = mapping || model_class.default_mappings(format)
189
188
 
190
189
  mapping.ensure_mappings_imported!(register_id)
191
190
 
192
- @mappings[key] = mapping
191
+ per_class = (@mappings[model_class] ||= new_inner_map)
192
+ (per_class[format] ||= new_inner_map)[register_id] = mapping
193
193
  mapping
194
194
  end
195
195
 
196
+ # Inner cache level container matching the outer map's engine
197
+ # (Concurrent::Map under GVL-threaded MRI, plain Hash under Opal).
198
+ def new_inner_map
199
+ Lutaml::Model.opal? ? {} : ::Concurrent::Map.new
200
+ end
201
+
196
202
  # Clear all cached data (transformations and mappings).
197
203
  #
198
204
  # This is useful for testing or when configuration changes dynamically.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.34"
5
+ VERSION = "0.8.36"
6
6
  end
7
7
  end
@@ -64,8 +64,8 @@ module Lutaml
64
64
  name = adapter_class.name_of(node)
65
65
  super(
66
66
  node,
67
- Hash(attributes),
68
- Array(children),
67
+ attributes || NO_ATTRIBUTES,
68
+ children || NO_CHILDREN,
69
69
  text,
70
70
  name: name,
71
71
  parent_document: parent,
@@ -146,8 +146,19 @@ module Lutaml
146
146
  end
147
147
  end
148
148
 
149
+ # Shared frozen empties: the parse path hands these straight into
150
+ # XmlElement (which never mutates the containers it is given), so
151
+ # attribute-less elements — the majority in real documents — build
152
+ # nothing here.
153
+ NO_ATTRIBUTES = {}.freeze # rubocop:todo Lint/UselessConstantScoping
154
+ NO_CHILDREN = [].freeze # rubocop:todo Lint/UselessConstantScoping
155
+ EMPTY_ATTRIBUTES = [{}, nil].freeze # rubocop:todo Lint/UselessConstantScoping
156
+
149
157
  def node_attributes_with_order(node)
150
- return [{}, nil] unless node.is_a?(Moxml::Element)
158
+ return EMPTY_ATTRIBUTES unless node.is_a?(Moxml::Element)
159
+
160
+ attrs = node.attributes
161
+ return EMPTY_ATTRIBUTES if attrs.respond_to?(:empty?) && attrs.empty?
151
162
 
152
163
  order = []
153
164
  hash = node.attributes.each_with_object({}) do |attr, h|
@@ -174,7 +185,7 @@ module Lutaml
174
185
  end
175
186
 
176
187
  def parse_children(node, default_namespace: nil)
177
- return [] unless node.children
188
+ return NO_CHILDREN unless node.children
178
189
 
179
190
  # Non-element children (text/cdata/comment/PI) stay RAW moxml
180
191
  # nodes in the list — the hot scalar parse path reads text off
@@ -197,6 +208,8 @@ module Lutaml
197
208
  public
198
209
 
199
210
  def children
211
+ return @children if @children.empty?
212
+
200
213
  unless @non_element_children_wrapped
201
214
  @children.map! do |child|
202
215
  if child.is_a?(Moxml::Node) && !child.is_a?(Moxml::Element)
@@ -402,7 +402,7 @@ instance_is_serialize = nil)
402
402
  # dup: XmlElement#order hands back a frozen cache shared with the
403
403
  # DOM. The model's copy has to stay mutable so callers can maintain
404
404
  # element_order themselves.
405
- instance.element_order = doc.root.order.dup
405
+ instance.element_order = doc.root.order
406
406
  if instance_is_serialize && doc.root.is_a?(::Lutaml::Xml::XmlElement)
407
407
  instance.attribute_order = doc.root.attribute_order
408
408
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "benchmark/ips"
5
+
6
+ # Adapter-comparison benchmark, runnable on demand:
7
+ # bundle exec rspec --tag benchmark spec/benchmarks/
8
+ # Tagged out of the default suite (.rsex --tag ~benchmark); asserts
9
+ # nothing about absolute speed — machines differ — but prints i/s and
10
+ # allocations/op so regressions are visible when run deliberately.
11
+ RSpec.describe "XML adapter benchmark", :benchmark do
12
+ Item = Class.new(Lutaml::Model::Serializable) do # rubocop:todo Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
13
+ attribute :id, :integer
14
+ attribute :name, :string
15
+ attribute :tags, :string, collection: true
16
+
17
+ xml do
18
+ element "item"
19
+ map_attribute "id", to: :id
20
+ map_element "name", to: :name
21
+ map_element "tag", to: :tags
22
+ end
23
+ end
24
+
25
+ Root = Class.new(Lutaml::Model::Serializable) do # rubocop:todo Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
26
+ attribute :item, Item, collection: true
27
+
28
+ xml do
29
+ element "root"
30
+ map_element "item", to: :item
31
+ end
32
+ end
33
+
34
+ XML = +"<root>" # rubocop:todo Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
35
+ 1_000.times { |i| XML << %(<item id="#{i}"><name>Item #{i}</name><tag>a</tag><tag>b</tag></item>) }
36
+ XML << "</root>"
37
+
38
+ def allocations_of
39
+ GC.start
40
+ before = GC.stat(:total_allocated_objects)
41
+ yield
42
+ GC.stat(:total_allocated_objects) - before
43
+ end
44
+
45
+ %i[nokogiri ox oga rexml leptris].each do |adapter|
46
+ it "#{adapter}: from_xml/to_xml i/s and allocations" do
47
+ model = nil
48
+ Lutaml::Model::Config.with_adapter(xml: adapter) do
49
+ begin
50
+ Lutaml::Model::Config.adapter_for(:xml)
51
+ rescue Lutaml::Model::UnknownAdapterTypeError
52
+ skip "#{adapter} adapter unavailable on this platform"
53
+ end
54
+ report = Benchmark.ips(time: 2, warmup: 1, quiet: true) do |x|
55
+ x.report("parse") { model = Root.from_xml(XML) }
56
+ x.report("serialize") { model.to_xml }
57
+ end
58
+ parse_ips = report.entries.first.ips
59
+ serialize_ips = report.entries.last.ips
60
+
61
+ allocs = allocations_of { Root.from_xml(XML) }
62
+ puts format( # rubocop:todo RSpec/Output
63
+ "%<a>-9s parse %<p>7.1f i/s serialize %<s>7.1f i/s %<al>8d allocs/op",
64
+ a: adapter, p: parse_ips, s: serialize_ips, al: allocs,
65
+ )
66
+ expect(model.item.size).to eq(1_000)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # The 0.8.33 parse contracts restored after #795: a custom type's cast
6
+ # sees an absent attribute's nil (ST_OnOff booleans — an absent flag is
7
+ # semantically false), and the parsed element_order array is frozen
8
+ # (consumers build thaw-on-demand helpers on that).
9
+ RSpec.describe "absent-value and order contracts" do
10
+ before do
11
+ stub_const("OnOff", Class.new(Lutaml::Model::Type::Boolean) do
12
+ def self.cast(value, _options = {})
13
+ return false if value.nil?
14
+
15
+ super
16
+ end
17
+ end)
18
+
19
+ stub_const("Doc", Class.new(Lutaml::Model::Serializable) do
20
+ attribute :flag, OnOff
21
+ xml do
22
+ element "doc"
23
+ map_attribute "flag", to: :flag
24
+ end
25
+ end)
26
+ end
27
+
28
+ it "casts an absent attribute through the type (nil -> false)" do
29
+ expect(Doc.from_xml("<doc/>").flag).to be(false)
30
+ end
31
+
32
+ it "still casts a present attribute" do
33
+ expect(Doc.from_xml('<doc flag="0"/>').flag).to be(false)
34
+ expect(Doc.from_xml('<doc flag="1"/>').flag).to be(true)
35
+ end
36
+
37
+ it "hands the parsed element_order back frozen" do
38
+ expect(Doc.from_xml('<doc flag="1"/>').element_order).to be_frozen
39
+ end
40
+
41
+ it "does not manufacture typed instances from nothing" do
42
+ stub_const("PhantomType", Class.new(Lutaml::Model::Type::String) do
43
+ # Value#initialize casts what it is given, so a cast that
44
+ # constructs has to bypass it (see phantom_value_spec's Eager).
45
+ def initialize(value) # rubocop:disable Lint/MissingSuper
46
+ @value = value
47
+ end
48
+
49
+ def self.cast(value, _options = {})
50
+ return value if value.is_a?(PhantomType)
51
+
52
+ new("phantom(#{value.inspect})")
53
+ end
54
+ end)
55
+ stub_const("PhantomDoc", Class.new(Lutaml::Model::Serializable) do
56
+ attribute :phantom, PhantomType
57
+ xml do
58
+ element "k"
59
+ map_attribute "p", to: :phantom
60
+ end
61
+ end)
62
+
63
+ expect(PhantomDoc.from_xml("<k/>").phantom).to be_nil
64
+ end
65
+ end
@@ -294,12 +294,18 @@ RSpec.describe "parsed model mutation" do
294
294
  end
295
295
  end
296
296
 
297
- # Defect 3: element_order was frozen on parsed models.
297
+ # element_order on a parsed model: the parsed order array is frozen
298
+ # (the 0.8.33 contract consumers build thaw-on-demand helpers on,
299
+ # lutaml-model#795). Mutation goes through reassignment — the
300
+ # accessor is a public writer.
298
301
  describe "element_order on a parsed model" do
299
- it "is mutable" do
302
+ it "is reassignable" do
300
303
  model = ParsedModelMutationSpec::Mixed.from_xml('<p><a v="1"/></p>')
301
304
 
302
- expect { model.element_order << "x" }.not_to raise_error
305
+ expect(model.element_order).to be_frozen
306
+ expect do
307
+ model.element_order = model.element_order.dup << "x"
308
+ end.not_to raise_error
303
309
  end
304
310
  end
305
311
 
@@ -467,7 +473,7 @@ RSpec.describe "parsed model mutation" do
467
473
  it "serializes without raising when element_order holds an Integer" do
468
474
  model = ParsedModelMutationSpec::NilColl
469
475
  .from_xml("<s><lead>L</lead><item>a</item></s>")
470
- model.element_order << 42
476
+ model.element_order = model.element_order.dup << 42
471
477
 
472
478
  expect { model.to_xml }.not_to raise_error
473
479
  end
@@ -475,7 +481,7 @@ RSpec.describe "parsed model mutation" do
475
481
  it "still reconciles the well-formed entries around a foreign one" do
476
482
  model = ParsedModelMutationSpec::NilColl
477
483
  .from_xml("<s><lead>L</lead><item>a</item></s>")
478
- model.element_order.insert(1, 42)
484
+ model.element_order = model.element_order.dup.insert(1, 42)
479
485
  model.items << "b"
480
486
 
481
487
  xml = model.to_xml
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.34
4
+ version: 0.8.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-17 00:00:00.000000000 Z
11
+ date: 2026-09-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -971,6 +971,7 @@ files:
971
971
  - sig/lutaml/model.rbs
972
972
  - spec/address_person_spec.rb
973
973
  - spec/address_spec.rb
974
+ - spec/benchmarks/xml_parsing_benchmark_spec.rb
974
975
  - spec/ceramic_spec.rb
975
976
  - spec/fixtures/address.rb
976
977
  - spec/fixtures/ceramic.rb
@@ -1684,6 +1685,7 @@ files:
1684
1685
  - spec/lutaml/key_value/transformation/rule_compiler_spec.rb
1685
1686
  - spec/lutaml/key_value/transformation/value_serializer_spec.rb
1686
1687
  - spec/lutaml/key_value/transformation_spec.rb
1688
+ - spec/lutaml/model/absent_value_contracts_spec.rb
1687
1689
  - spec/lutaml/model/attribute_apply_value_map_spec.rb
1688
1690
  - spec/lutaml/model/attribute_collection_spec.rb
1689
1691
  - spec/lutaml/model/attribute_default_cache_spec.rb