lutaml-model 0.8.130 → 0.8.132

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/docs/key_value_data_model.adoc +9 -9
  3. data/lib/lutaml/json/adapter/multi_json_adapter.rb +1 -7
  4. data/lib/lutaml/json/adapter/oj_adapter.rb +1 -7
  5. data/lib/lutaml/json/adapter/standard_adapter.rb +1 -7
  6. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +1 -7
  7. data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +1 -7
  8. data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +1 -7
  9. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +1 -7
  10. data/lib/lutaml/key_value/adapter/toml/toml_rb_adapter.rb +1 -7
  11. data/lib/lutaml/key_value/adapter/toml/tomlib_adapter.rb +1 -7
  12. data/lib/lutaml/key_value/adapter/yaml/standard_adapter.rb +1 -7
  13. data/lib/lutaml/key_value/data_model/element.rb +31 -32
  14. data/lib/lutaml/key_value/transform.rb +24 -8
  15. data/lib/lutaml/key_value/transformation/value_serializer.rb +14 -2
  16. data/lib/lutaml/key_value/transformation.rb +33 -20
  17. data/lib/lutaml/model/attribute.rb +15 -2
  18. data/lib/lutaml/model/collection.rb +1 -5
  19. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +9 -4
  20. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +15 -8
  21. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +2 -2
  22. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +10 -0
  23. data/lib/lutaml/model/serialize/attribute_definition.rb +57 -4
  24. data/lib/lutaml/model/transform.rb +78 -25
  25. data/lib/lutaml/model/type_context.rb +9 -0
  26. data/lib/lutaml/model/version.rb +1 -1
  27. data/lib/lutaml/toml/adapter/toml_rb_adapter.rb +1 -7
  28. data/lib/lutaml/toml/adapter/tomlib_adapter.rb +1 -7
  29. data/lib/lutaml/xml/builder/base.rb +3 -1
  30. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -2
  31. data/lib/lutaml/yaml/adapter/standard_adapter.rb +1 -7
  32. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +1 -7
  33. data/lib/lutaml/yamlld/adapter.rb +1 -6
  34. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +3 -3
  35. data/spec/lutaml/key_value/transformation_spec.rb +50 -50
  36. data/spec/lutaml/model/collection_spec.rb +43 -0
  37. data/spec/lutaml/model/schema/xml_compiler_runtime_spec.rb +81 -0
  38. metadata +2 -2
@@ -110,8 +110,11 @@ module Lutaml
110
110
  end
111
111
 
112
112
  local = Utils.last_of_split(raw_type)
113
- Definitions::TypeRef.new(kind: :symbol,
114
- value: Utils.snake_case(local))
113
+ primitive = SupportedDataTypes.primitive_symbol(local)
114
+ Definitions::TypeRef.new(
115
+ kind: :symbol,
116
+ value: primitive || Utils.snake_case(local),
117
+ )
115
118
  end
116
119
 
117
120
  private
@@ -169,8 +172,10 @@ module Lutaml
169
172
  element.simple_type)
170
173
  end
171
174
  if element.complex_type
172
- return register_anonymous_complex_type("CT_#{element.name}",
173
- element.complex_type)
175
+ return register_anonymous_complex_type(
176
+ "CT_#{element.name}", element.complex_type,
177
+ root_name: element.name
178
+ )
174
179
  end
175
180
 
176
181
  "string"
@@ -184,8 +189,10 @@ module Lutaml
184
189
  end
185
190
 
186
191
  if item.respond_to?(:complex_type) && item.complex_type
187
- register_anonymous_complex_type("CT_#{item.name}",
188
- item.complex_type)
192
+ register_anonymous_complex_type(
193
+ "CT_#{item.name}", item.complex_type,
194
+ root_name: item.name
195
+ )
189
196
  end
190
197
  end
191
198
 
@@ -196,10 +203,10 @@ module Lutaml
196
203
  anon_name
197
204
  end
198
205
 
199
- def register_anonymous_complex_type(anon_name, anon_node)
206
+ def register_anonymous_complex_type(anon_name, anon_node, root_name: nil)
200
207
  anon_node.name = anon_name
201
208
  @parent.complex_types[anon_name] =
202
- @parent.build_complex_type(anon_node)
209
+ @parent.build_complex_type(anon_node, root_name: root_name)
203
210
  anon_name
204
211
  end
205
212
 
@@ -156,8 +156,8 @@ module Lutaml
156
156
  @simple_types_builder.build(simple_type)
157
157
  end
158
158
 
159
- def build_complex_type(complex_type)
160
- @complex_types_builder.build(complex_type)
159
+ def build_complex_type(complex_type, root_name: nil)
160
+ @complex_types_builder.build(complex_type, root_name: root_name)
161
161
  end
162
162
 
163
163
  private
@@ -83,6 +83,16 @@ module Lutaml
83
83
  def skippable?(value)
84
84
  SKIPPABLE_NAMES.include?(value.to_s)
85
85
  end
86
+
87
+ # Lutaml primitive symbol for a skippable XSD type
88
+ # (`"int"` -> `:integer`, `"dateTime"` -> `:date_time`); nil
89
+ # when the name is not a skippable built-in.
90
+ def primitive_symbol(value)
91
+ info = TABLE[value.to_sym]
92
+ return nil unless info && info[:skippable]
93
+
94
+ Utils.snake_case(info[:class_name].split("::").last)
95
+ end
86
96
  end
87
97
  end
88
98
  end
@@ -142,6 +142,7 @@ module Lutaml
142
142
  marks = {}
143
143
  simple = []
144
144
  complex = []
145
+ lazy = []
145
146
  attrs.each do |name, attr|
146
147
  next if attr.derived?
147
148
 
@@ -154,8 +155,17 @@ module Lutaml
154
155
  # per-attribute code, so its state is identical by
155
156
  # construction.
156
157
  marks[name] = false
157
- if attr.default_kind == :none && attr.options[:values].nil? &&
158
- name.to_s.match?(PLAIN_NAME)
158
+ plain_name = name.to_s.match?(PLAIN_NAME)
159
+ if plain_name && attr.lazy_collection_default?
160
+ # The implicit empty-collection default: the constructor
161
+ # ends at `[]` + a default mark, and the LAZY sentinel
162
+ # answers `[]` from the reader — so seed the sentinel and
163
+ # skip the per-instance lambda (#1010). The false mark is
164
+ # what the XML unmatched-rule contract reads: a seeded
165
+ # collection must not render as a mid-parse default.
166
+ lazy << name
167
+ elsif attr.default_kind == :none && attr.options[:values].nil? &&
168
+ plain_name
159
169
  simple << name
160
170
  else
161
171
  complex << name
@@ -169,6 +179,9 @@ module Lutaml
169
179
  "@using_default = { #{pairs.join(', ')} }"
170
180
  end
171
181
  simple_lines = simple.map { |n| "@#{n} = nil" }
182
+ lazy_lines = lazy.map do |n|
183
+ "@#{n} = Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION"
184
+ end
172
185
  complex_lines = complex.map do |n|
173
186
  "initialize_single_attribute({}, #{n.inspect}, " \
174
187
  "self.class.attributes(@lutaml_register)[#{n.inspect}], " \
@@ -179,6 +192,11 @@ module Lutaml
179
192
  define_method(method_name) do
180
193
  @using_default = marks
181
194
  simple.each { |n| IVAR_SET.bind_call(self, :"@#{n}", nil) }
195
+ lazy.each do |n|
196
+ IVAR_SET.bind_call(
197
+ self, :"@#{n}", Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION
198
+ )
199
+ end
182
200
  complex.each do |n|
183
201
  initialize_single_attribute(
184
202
  {}, n, self.class.attributes(@lutaml_register)[n],
@@ -193,6 +211,7 @@ module Lutaml
193
211
  def #{method_name}
194
212
  #{marks_line}
195
213
  #{simple_lines.join("\n")}
214
+ #{lazy_lines.join("\n")}
196
215
  #{complex_lines.join("\n")}
197
216
  end
198
217
  RUBY
@@ -452,7 +471,7 @@ module Lutaml
452
471
 
453
472
  if !AttributeDefinition.source_compilation?
454
473
  define_block_attribute_setters(name, attr)
455
- elsif attr.collection?
474
+ elsif attr.collection? && !attr.lazy_collection_default?
456
475
  # class_eval interpolates, e.g.:
457
476
  # def items=(value)
458
477
  # value_set_for(:items)
@@ -493,6 +512,25 @@ module Lutaml
493
512
  record_mutation_collection(:#{name}, value)
494
513
  end
495
514
  RUBY
515
+ elsif attr.collection?
516
+ # Lazy-seeded default collections (#1010): a nil write is a
517
+ # real state here (initialize_empty reads nil after an
518
+ # explicit nil value), so it must land over the sentinel
519
+ # instead of preserving it.
520
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
521
+ def #{name}=(value)
522
+ value_set_for(:#{name})
523
+ value = __attribute_definition_#{name}.cast_value(value, lutaml_register)
524
+ @#{name} = value
525
+ record_mutation_collection(:#{name}, value)
526
+ end
527
+
528
+ def __assign_parsed_#{name}=(value)
529
+ value_set_for(:#{name})
530
+ @#{name} = value
531
+ record_mutation_collection(:#{name}, value)
532
+ end
533
+ RUBY
496
534
  else
497
535
  class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
498
536
  def #{name}=(value)
@@ -551,7 +589,7 @@ module Lutaml
551
589
  name = name.to_sym # the compiled form passes :name literals
552
590
  ivar = :"@#{name}"
553
591
  handle = :"__attribute_definition_#{name}"
554
- if attr.collection?
592
+ if attr.collection? && !attr.lazy_collection_default?
555
593
  assign = lambda do |model, value|
556
594
  current = IVAR_GET.bind_call(model, ivar)
557
595
  unless current.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) &&
@@ -568,6 +606,21 @@ module Lutaml
568
606
  value_set_for(name)
569
607
  assign.call(self, value)
570
608
  end
609
+ elsif attr.collection?
610
+ # Lazy-seeded default collections (#1010): nil lands over the
611
+ # sentinel — see the source-compiled branch.
612
+ assign = lambda do |model, value|
613
+ IVAR_SET.bind_call(model, ivar, value)
614
+ SEND.bind_call(model, :record_mutation_collection, name, value)
615
+ end
616
+ define_method(:"#{name}=") do |value|
617
+ value_set_for(name)
618
+ assign.call(self, SEND.bind_call(self, handle).cast_value(value, lutaml_register))
619
+ end
620
+ define_method(:"__assign_parsed_#{name}=") do |value|
621
+ value_set_for(name)
622
+ assign.call(self, value)
623
+ end
571
624
  else
572
625
  define_method(:"#{name}=") do |value|
573
626
  value_set_for(name)
@@ -3,12 +3,81 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class Transform
6
- @transform_cache = {}
7
-
8
6
  # Maximum number of cached Transform instances before eviction.
9
7
  # Covers most OOXML/ISO schemas with ~1000+ classes and multiple registers.
10
8
  MAX_CACHE_SIZE = 2048
11
9
 
10
+ # The (lane, context, register) memo lives in a shared constant,
11
+ # not in class-level ivars: KeyValue::Transform subclasses this
12
+ # class, and inherited class methods run with the subclass as
13
+ # self — ivar state forked per subclass, and clear_cache! on the
14
+ # base class never reached the subclass's copy (stale compiled
15
+ # plans across register resets). The lane is part of the key:
16
+ # each transform class compiles format-specific rules, so a KV
17
+ # transform and the base one must never answer for each other.
18
+ class TransformCache
19
+ attr_reader :size
20
+
21
+ def initialize
22
+ @entries = {}
23
+ @size = 0
24
+ end
25
+
26
+ # Nested memo, not an array key: the hot path is one lookup per
27
+ # parsed node and the key array was that lookup's only
28
+ # allocation (60k arrays on a 6k-node corpus).
29
+ def fetch_or_store(lane, context, register)
30
+ by_context = @entries[lane]
31
+ by_register = by_context&.[](context)
32
+ return by_register[register] if by_register&.key?(register)
33
+
34
+ by_register = ((@entries[lane] ||= {})[context] ||= {})
35
+ transform = yield
36
+ by_register[register] = transform
37
+ @size += 1
38
+ transform
39
+ end
40
+
41
+ def evict_half_if_full
42
+ return unless @size >= MAX_CACHE_SIZE
43
+
44
+ drop = @size / 2
45
+ @entries.each do |lane, by_context|
46
+ by_context.each do |ctx, by_register|
47
+ break if drop <= 0
48
+
49
+ drop -= by_register.size
50
+ by_context.delete(ctx)
51
+ end
52
+ @entries.delete(lane) if by_context.empty?
53
+ break if drop <= 0
54
+ end
55
+ @size = @entries.sum { |_, by_context| by_context.sum { |_, br| br.size } }
56
+ end
57
+
58
+ def invalidate(context, register = nil)
59
+ @entries.each_value do |by_context|
60
+ if register
61
+ by_register = by_context[context]
62
+ next unless by_register&.delete(register)
63
+
64
+ @size -= 1
65
+ by_context.delete(context) if by_register.empty?
66
+ else
67
+ removed = by_context.delete(context)
68
+ @size -= removed.size if removed
69
+ end
70
+ end
71
+ end
72
+
73
+ def clear
74
+ @entries.clear
75
+ @size = 0
76
+ end
77
+ end
78
+
79
+ TRANSFORM_CACHE = TransformCache.new
80
+
12
81
  def self.data_to_model(context, data, format, options = {})
13
82
  register = options[:register] || Lutaml::Model::Config.default_register
14
83
  transform = cached_transform(context, register, options[:mappings])
@@ -28,13 +97,10 @@ module Lutaml
28
97
  def self.cached_transform(context, register, mappings = nil)
29
98
  return override_transform(context, register, mappings) if mappings
30
99
 
31
- @transform_cache ||= {}
32
- cache_key = [context, register]
33
- entry = @transform_cache[cache_key]
34
- return entry if entry
35
-
36
- evict_if_needed if @transform_cache.size >= MAX_CACHE_SIZE
37
- @transform_cache[cache_key] = new(context, register)
100
+ TRANSFORM_CACHE.evict_half_if_full
101
+ TRANSFORM_CACHE.fetch_or_store(self, context, register) do
102
+ new(context, register)
103
+ end
38
104
  end
39
105
 
40
106
  def self.override_transform(context, register, mappings)
@@ -54,30 +120,17 @@ module Lutaml
54
120
  private_class_method :evict_override_if_needed
55
121
 
56
122
  def self.clear_cache!
57
- @transform_cache&.clear
123
+ TRANSFORM_CACHE.clear
58
124
  @override_transform_cache&.clear
59
125
  end
60
126
 
61
127
  def self.cache_size
62
- @transform_cache&.size || 0
128
+ TRANSFORM_CACHE.size
63
129
  end
64
130
 
65
131
  def self.invalidate_for(context, register = nil)
66
- return unless @transform_cache
67
-
68
- if register
69
- @transform_cache.delete([context, register])
70
- else
71
- @transform_cache.reject! { |(ctx, _reg)| ctx == context }
72
- end
73
- end
74
-
75
- def self.evict_if_needed
76
- # Evict oldest half of entries when cache is full
77
- keys_to_remove = @transform_cache.keys.first(@transform_cache.size / 2)
78
- keys_to_remove.each { |k| @transform_cache.delete(k) }
132
+ TRANSFORM_CACHE.invalidate(context, register)
79
133
  end
80
- private_class_method :evict_if_needed
81
134
 
82
135
  attr_reader :context, :lutaml_register
83
136
 
@@ -38,6 +38,8 @@ module Lutaml
38
38
  # # Has :custom, falls back to default for built-in types
39
39
  #
40
40
  class TypeContext
41
+ EMPTY_SUBSTITUTIONS = [].freeze
42
+
41
43
  # @return [Symbol] The context identifier
42
44
  attr_reader :id
43
45
 
@@ -204,6 +206,13 @@ module Lutaml
204
206
  # @param from_type [Class, Symbol, String] The type to find substitutions for
205
207
  # @return [Array<TypeSubstitution>] Matching substitutions
206
208
  def substitution_for(from_type)
209
+ # The no-substitution context is the common case, and this
210
+ # lookup runs per nested model per serialized instance — the
211
+ # select's result array was the allocation (#KV-to profile
212
+ # 2026-10-26). The frozen shared array answers readers only;
213
+ # every consumer here selects/any?/eaches it.
214
+ return EMPTY_SUBSTITUTIONS if substitutions.empty?
215
+
207
216
  from_type_class = from_type.is_a?(Class) ? from_type : nil
208
217
 
209
218
  substitutions.select do |sub|
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.130"
5
+ VERSION = "0.8.132"
6
6
  end
7
7
  end
@@ -19,13 +19,7 @@ module Lutaml
19
19
  def to_toml(*)
20
20
  require "toml-rb"
21
21
  # Handle KeyValueElement input (new symmetric architecture)
22
- attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
23
- # Unwrap __root__ wrapper to get actual content
24
- @attributes.to_hash["__root__"]
25
- else
26
- # Legacy Hash input (backward compatibility)
27
- @attributes
28
- end
22
+ attributes_to_serialize = @attributes
29
23
 
30
24
  TomlRB.dump(attributes_to_serialize)
31
25
  rescue LoadError
@@ -34,13 +34,7 @@ module Lutaml
34
34
  def to_toml(*)
35
35
  require "tomlib"
36
36
  # Handle KeyValueElement input (new symmetric architecture)
37
- attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
38
- # Unwrap __root__ wrapper to get actual content
39
- @attributes.to_hash["__root__"]
40
- else
41
- # Legacy Hash input (backward compatibility)
42
- @attributes
43
- end
37
+ attributes_to_serialize = @attributes
44
38
 
45
39
  Tomlib.dump(attributes_to_serialize)
46
40
  # Tomlib::Generator.new(to_h).toml_str
@@ -116,7 +116,9 @@ module Lutaml
116
116
 
117
117
  def add_xml_fragment(element, content)
118
118
  target = resolve_target(element)
119
- parsed = @context.parse("<__root__>#{content}</__root__>")
119
+ # The wrapper tag only gives the parser a root; its name is
120
+ # discarded — only the parsed children are adopted.
121
+ parsed = @context.parse("<fragment>#{content}</fragment>")
120
122
  parsed.root&.children&.each { |child| target.add_child(child) }
121
123
  rescue Moxml::ParseError
122
124
  target.add_child(@doc.create_text(content.to_s))
@@ -64,8 +64,8 @@ module Lutaml
64
64
  # This ensures root namespace is included in all_namespaces
65
65
  if mapping_dsl.namespace_class
66
66
  rules << ::Lutaml::Model::CompiledRule.new(
67
- attribute_name: :__root_namespace__,
68
- serialized_name: "__root__",
67
+ attribute_name: :root_namespace,
68
+ serialized_name: "root_namespace",
69
69
  namespace_class: mapping_dsl.namespace_class,
70
70
  mapping_type: :root_namespace,
71
71
  )
@@ -25,13 +25,7 @@ module Lutaml
25
25
 
26
26
  def to_yaml(options = {})
27
27
  # Handle KeyValueElement input (new symmetric architecture)
28
- attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
29
- # Unwrap __root__ wrapper to get actual content
30
- @attributes.to_hash["__root__"]
31
- else
32
- # Legacy Hash input (backward compatibility)
33
- @attributes
34
- end
28
+ attributes_to_serialize = @attributes
35
29
 
36
30
  YAML.dump(attributes_to_serialize, options)
37
31
  end
@@ -91,13 +91,7 @@ module Lutaml
91
91
  private_class_method :syntax_error
92
92
 
93
93
  def to_yaml(_options = {})
94
- attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
95
- # Unwrap __root__ wrapper to get actual content
96
- @attributes.to_hash["__root__"]
97
- else
98
- # Legacy Hash input (backward compatibility)
99
- @attributes
100
- end
94
+ attributes_to_serialize = @attributes
101
95
 
102
96
  # Generation must match the standard (Psych) adapter's output
103
97
  # byte-for-byte: document separator, quoting of digit-leading
@@ -12,12 +12,7 @@ module Lutaml
12
12
  end
13
13
 
14
14
  def to_yamlld(options = {})
15
- attributes_to_serialize =
16
- if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
17
- @attributes.to_hash["__root__"]
18
- else
19
- @attributes
20
- end
15
+ attributes_to_serialize = @attributes
21
16
  YAML.dump(attributes_to_serialize, options)
22
17
  end
23
18
  end
@@ -54,12 +54,12 @@ RSpec.describe(TEPTRIS_AVAILABLE ? Lutaml::KeyValue::Adapter::Toml::TeptrisAdapt
54
54
  .to include("name", "[nested]", "a = [1, 2]")
55
55
  end
56
56
 
57
- it "unwraps the __root__ KeyValueElement wrapper" do
58
- element = Lutaml::KeyValue::DataModel::Element.new("__root__")
57
+ it "serializes Element children through their keyed hash shape" do
58
+ element = Lutaml::KeyValue::DataModel::Element.new("doc")
59
59
  name_child = Lutaml::KeyValue::DataModel::Element.new("name")
60
60
  name_child.value = "x"
61
61
  element.add_child(name_child)
62
- expect(described_class.new(element).to_toml).to include("name")
62
+ expect(element.to_hash).to eq("doc" => { "name" => "x" })
63
63
  end
64
64
  end
65
65