lutaml-model 0.8.129 → 0.8.131

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 (40) 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/mapping_rule.rb +4 -4
  15. data/lib/lutaml/key_value/transform.rb +6 -5
  16. data/lib/lutaml/key_value/transformation.rb +21 -18
  17. data/lib/lutaml/model/attribute.rb +23 -1
  18. data/lib/lutaml/model/collection.rb +1 -5
  19. data/lib/lutaml/model/mapping/mapping_rule.rb +21 -2
  20. data/lib/lutaml/model/schema/xml_compiler/spec_builder/complex_types.rb +9 -4
  21. data/lib/lutaml/model/schema/xml_compiler/spec_builder/members.rb +15 -8
  22. data/lib/lutaml/model/schema/xml_compiler/spec_builder.rb +2 -2
  23. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +10 -0
  24. data/lib/lutaml/model/serialize/attribute_definition.rb +133 -2
  25. data/lib/lutaml/model/serialize/initialization.rb +12 -0
  26. data/lib/lutaml/model/serialize.rb +30 -10
  27. data/lib/lutaml/model/version.rb +1 -1
  28. data/lib/lutaml/toml/adapter/toml_rb_adapter.rb +1 -7
  29. data/lib/lutaml/toml/adapter/tomlib_adapter.rb +1 -7
  30. data/lib/lutaml/xml/builder/base.rb +3 -1
  31. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -2
  32. data/lib/lutaml/yaml/adapter/standard_adapter.rb +1 -7
  33. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +1 -7
  34. data/lib/lutaml/yamlld/adapter.rb +1 -6
  35. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +3 -3
  36. data/spec/lutaml/key_value/group_instantiate_spec.rb +8 -0
  37. data/spec/lutaml/key_value/transformation_spec.rb +50 -50
  38. data/spec/lutaml/model/collection_spec.rb +43 -0
  39. data/spec/lutaml/model/schema/xml_compiler_runtime_spec.rb +81 -0
  40. metadata +2 -2
@@ -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
@@ -120,6 +120,103 @@ module Lutaml
120
120
  RUBY
121
121
  end
122
122
 
123
+ # The walk-side seed (#1003): constructor-equivalent state in one
124
+ # compiled pass. Unlike the deserialization seed (sentinels, lazy
125
+ # collections — readers answer uninitialized), the programmatic
126
+ # constructor resolves defaults and leaves non-defaulted
127
+ # attributes nil with explicit using-default marks; the KV walk's
128
+ # absent-attribute rendering contract depends on exactly that.
129
+ def walk_state_defaults_name!(register_id)
130
+ @walk_state_defaults_names ||= {}
131
+ name = @walk_state_defaults_names[register_id]
132
+ return name if name
133
+
134
+ method_name = :"__init_walk_state_defaults_#{register_id}"
135
+ compile_walk_state_defaults!(method_name, register_id)
136
+ @walk_state_defaults_names[register_id] = method_name
137
+ method_name
138
+ end
139
+
140
+ def compile_walk_state_defaults!(method_name, register_id = nil)
141
+ attrs = attributes(register_id)
142
+ marks = {}
143
+ simple = []
144
+ complex = []
145
+ lazy = []
146
+ attrs.each do |name, attr|
147
+ next if attr.derived?
148
+
149
+ # Inline only the provably-simple shape: no default, no
150
+ # value set (enum attributes materialize through their
151
+ # writer), plain ivar-safe name. The constructor's end
152
+ # state for that shape is exactly `nil` + a false mark.
153
+ # Everything else — defaults, enums, delegated and exotic
154
+ # attributes — routes through the constructor's own
155
+ # per-attribute code, so its state is identical by
156
+ # construction.
157
+ marks[name] = false
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
169
+ simple << name
170
+ else
171
+ complex << name
172
+ end
173
+ end
174
+
175
+ marks_line = if marks.empty?
176
+ "@using_default = {}"
177
+ else
178
+ pairs = marks.map { |k, v| "#{k.inspect} => #{v}" }
179
+ "@using_default = { #{pairs.join(', ')} }"
180
+ end
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
185
+ complex_lines = complex.map do |n|
186
+ "initialize_single_attribute({}, #{n.inspect}, " \
187
+ "self.class.attributes(@lutaml_register)[#{n.inspect}], " \
188
+ "Serialize::DEFAULT_VALUE_MAP)"
189
+ end
190
+
191
+ unless AttributeDefinition.source_compilation?
192
+ define_method(method_name) do
193
+ @using_default = marks
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
200
+ complex.each do |n|
201
+ initialize_single_attribute(
202
+ {}, n, self.class.attributes(@lutaml_register)[n],
203
+ Serialize::DEFAULT_VALUE_MAP
204
+ )
205
+ end
206
+ end
207
+ return
208
+ end
209
+
210
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
211
+ def #{method_name}
212
+ #{marks_line}
213
+ #{simple_lines.join("\n")}
214
+ #{lazy_lines.join("\n")}
215
+ #{complex_lines.join("\n")}
216
+ end
217
+ RUBY
218
+ end
219
+
123
220
  # Block-form raw-state reader (no String eval), defined on self.
124
221
  def define_raw_state_reader(name)
125
222
  ivar = :"@#{name}"
@@ -374,7 +471,7 @@ module Lutaml
374
471
 
375
472
  if !AttributeDefinition.source_compilation?
376
473
  define_block_attribute_setters(name, attr)
377
- elsif attr.collection?
474
+ elsif attr.collection? && !attr.lazy_collection_default?
378
475
  # class_eval interpolates, e.g.:
379
476
  # def items=(value)
380
477
  # value_set_for(:items)
@@ -415,6 +512,25 @@ module Lutaml
415
512
  record_mutation_collection(:#{name}, value)
416
513
  end
417
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
418
534
  else
419
535
  class_eval(<<~RUBY, __FILE__, __LINE__ + 1) # rubocop:disable Style/DocumentDynamicEvalDefinition
420
536
  def #{name}=(value)
@@ -473,7 +589,7 @@ module Lutaml
473
589
  name = name.to_sym # the compiled form passes :name literals
474
590
  ivar = :"@#{name}"
475
591
  handle = :"__attribute_definition_#{name}"
476
- if attr.collection?
592
+ if attr.collection? && !attr.lazy_collection_default?
477
593
  assign = lambda do |model, value|
478
594
  current = IVAR_GET.bind_call(model, ivar)
479
595
  unless current.equal?(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION) &&
@@ -490,6 +606,21 @@ module Lutaml
490
606
  value_set_for(name)
491
607
  assign.call(self, value)
492
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
493
624
  else
494
625
  define_method(:"#{name}=") do |value|
495
626
  value_set_for(name)
@@ -363,6 +363,18 @@ module Lutaml
363
363
  #
364
364
  # @param register [Symbol, nil] The register context
365
365
  # @return [Object] The allocated instance
366
+ # Walk-side allocation (#1003): the KV walk's instantiation —
367
+ # constructor-equivalent state via the compiled walk seed
368
+ # (defaults resolved, non-defaulted attributes nil) without the
369
+ # per-attribute determine_value walk.
370
+ def allocate_for_walk(register = nil)
371
+ register_id = extract_register_id(register)
372
+ ensure_imports!(register_id) if finalized? && deferred_imports_pending?(register_id)
373
+ instance = allocate
374
+ instance.init_walk_state(register_id)
375
+ instance
376
+ end
377
+
366
378
  def allocate_for_deserialization(register = nil)
367
379
  register_id = extract_register_id(register)
368
380
  # Deferred imports (symbol-form import_model_attributes) must
@@ -190,6 +190,18 @@ module Lutaml
190
190
  register_in_reference_store
191
191
  end
192
192
 
193
+ # Walk-side construction (#1003): constructor-equivalent state
194
+ # (defaults resolved, non-defaulted attributes nil, explicit
195
+ # using-default marks) seeded in one compiled pass, replacing the
196
+ # per-attribute determine_value walk on every parsed instance.
197
+ def init_walk_state(register)
198
+ register_id = self.class.extract_register_id(register)
199
+ @lutaml_register = register_id
200
+ public_send(self.class.walk_state_defaults_name!(register_id))
201
+ define_singleton_attribute_methods
202
+ register_in_reference_store
203
+ end
204
+
193
205
  def extract_register_id(attrs, options)
194
206
  register = attrs&.dig(:lutaml_register) || options&.dig(:register)
195
207
  self.class.extract_register_id(register)
@@ -378,19 +390,27 @@ module Lutaml
378
390
  self.class.attributes(lutaml_register).each do |name, attr|
379
391
  next if attr.derived?
380
392
 
381
- value = determine_value(attrs, name, attr)
382
- default = using_default?(name)
383
- value = self.class.apply_value_map(value, vmap, attr)
384
- # Performance: Only call ensure_utf8 for string values
385
- value = self.class.ensure_utf8(value) if value.is_a?(::String)
386
- # Interpolating the writer symbol allocated a String per
387
- # attribute per instance; the per-register writer table is
388
- # interned once per class.
389
- public_send(self.class.instantiate_writers(lutaml_register)[name], value)
390
- using_default_for(name) if default
393
+ initialize_single_attribute(attrs, name, attr, vmap)
391
394
  end
392
395
  end
393
396
 
397
+ # The constructor's per-attribute body, callable one attribute at
398
+ # a time — the compiled walk seed (#1003) routes every attribute
399
+ # it does not inline through this exact code, so its state is
400
+ # identical by construction.
401
+ def initialize_single_attribute(attrs, name, attr, vmap)
402
+ value = determine_value(attrs, name, attr)
403
+ default = using_default?(name)
404
+ value = self.class.apply_value_map(value, vmap, attr)
405
+ # Performance: Only call ensure_utf8 for string values
406
+ value = self.class.ensure_utf8(value) if value.is_a?(::String)
407
+ # Interpolating the writer symbol allocated a String per
408
+ # attribute per instance; the per-register writer table is
409
+ # interned once per class.
410
+ public_send(self.class.instantiate_writers(lutaml_register)[name], value)
411
+ using_default_for(name) if default
412
+ end
413
+
394
414
  def determine_value(attrs, name, attr)
395
415
  # One hash pass: a sentinel fetch distinguishes absent keys
396
416
  # from present-nil values, replacing two key probes plus the
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.129"
5
+ VERSION = "0.8.131"
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
 
@@ -104,6 +104,14 @@ RSpec.describe "KV group-then-bulk hydration" do
104
104
  @built += 1
105
105
  super
106
106
  end
107
+
108
+ # The walk and group lanes construct through the compiled walk
109
+ # seed (#1003) rather than new; count that entry too so the
110
+ # wiring proof tracks the real construction path.
111
+ def allocate_for_walk(register = nil)
112
+ @built += 1
113
+ super
114
+ end
107
115
  end
108
116
  end
109
117
  holder = Class.new(Lutaml::Model::Serializable) do
@@ -69,14 +69,11 @@ RSpec.describe Lutaml::KeyValue::Transformation do
69
69
  transformation = described_class.new(KVSimpleModel, mapping, :json, nil)
70
70
 
71
71
  result = transformation.transform(model)
72
- hash = { "__root__" => result }
73
-
74
- # Remove __root__ wrapper
75
- expect(hash["__root__"]).to eq({
76
- "name" => "John",
77
- "age" => 30,
78
- "active" => true,
79
- })
72
+ expect(result).to eq({
73
+ "name" => "John",
74
+ "age" => 30,
75
+ "active" => true,
76
+ })
80
77
  end
81
78
 
82
79
  it "handles nil values based on render_nil" do
@@ -85,10 +82,8 @@ RSpec.describe Lutaml::KeyValue::Transformation do
85
82
  transformation = described_class.new(KVSimpleModel, mapping, :json, nil)
86
83
 
87
84
  result = transformation.transform(model)
88
- hash = { "__root__" => result }
89
-
90
85
  # nil values should be rendered by default
91
- expect(hash["__root__"]["age"]).to be_nil
86
+ expect(result["age"]).to be_nil
92
87
  end
93
88
  end
94
89
 
@@ -101,16 +96,14 @@ RSpec.describe Lutaml::KeyValue::Transformation do
101
96
  transformation = described_class.new(KVPerson, mapping, :json, nil)
102
97
 
103
98
  result = transformation.transform(person)
104
- hash = { "__root__" => result }
105
-
106
- expect(hash["__root__"]).to eq({
107
- "name" => "John",
108
- "address" => {
109
- "street" => "123 Main St",
110
- "city" => "NYC",
111
- "zip" => "10001",
112
- },
113
- })
99
+ expect(result).to eq({
100
+ "name" => "John",
101
+ "address" => {
102
+ "street" => "123 Main St",
103
+ "city" => "NYC",
104
+ "zip" => "10001",
105
+ },
106
+ })
114
107
  end
115
108
 
116
109
  it "handles nil nested model" do
@@ -119,10 +112,8 @@ RSpec.describe Lutaml::KeyValue::Transformation do
119
112
  transformation = described_class.new(KVPerson, mapping, :json, nil)
120
113
 
121
114
  result = transformation.transform(person)
122
- hash = { "__root__" => result }
123
-
124
115
  # nil nested model should be in output (based on default render_nil)
125
- expect(hash["__root__"]["address"]).to be_nil
116
+ expect(result["address"]).to be_nil
126
117
  end
127
118
  end
128
119
 
@@ -135,13 +126,11 @@ RSpec.describe Lutaml::KeyValue::Transformation do
135
126
  transformation = described_class.new(KVTeam, mapping, :json, nil)
136
127
 
137
128
  result = transformation.transform(team)
138
- hash = { "__root__" => result }
139
-
140
- expect(hash["__root__"]).to eq({
141
- "name" => "Dev Team",
142
- "members" => ["Alice", "Bob",
143
- "Charlie"],
144
- })
129
+ expect(result).to eq({
130
+ "name" => "Dev Team",
131
+ "members" => ["Alice", "Bob",
132
+ "Charlie"],
133
+ })
145
134
  end
146
135
 
147
136
  it "handles empty collection" do
@@ -150,10 +139,8 @@ RSpec.describe Lutaml::KeyValue::Transformation do
150
139
  transformation = described_class.new(KVTeam, mapping, :json, nil)
151
140
 
152
141
  result = transformation.transform(team)
153
- hash = { "__root__" => result }
154
-
155
142
  # Empty collections are not rendered by default (render_empty defaults to false)
156
- expect(hash["__root__"]["members"]).to be_nil
143
+ expect(result["members"]).to be_nil
157
144
  end
158
145
 
159
146
  it "handles nil collection" do
@@ -162,10 +149,8 @@ RSpec.describe Lutaml::KeyValue::Transformation do
162
149
  transformation = described_class.new(KVTeam, mapping, :json, nil)
163
150
 
164
151
  result = transformation.transform(team)
165
- hash = { "__root__" => result }
166
-
167
152
  # nil collection should be nil by default
168
- expect(hash["__root__"]["members"]).to be_nil
153
+ expect(result["members"]).to be_nil
169
154
  end
170
155
  end
171
156
 
@@ -188,9 +173,7 @@ RSpec.describe Lutaml::KeyValue::Transformation do
188
173
  nil)
189
174
 
190
175
  result = transformation.transform(model)
191
- hash = { "__root__" => result }
192
-
193
- expect(hash["__root__"]["name"]).to eq("JOHN")
176
+ expect(result["name"]).to eq("JOHN")
194
177
  end
195
178
  end
196
179
 
@@ -211,10 +194,8 @@ RSpec.describe Lutaml::KeyValue::Transformation do
211
194
  transformation = described_class.new(KVRenderModel, mapping, :json, nil)
212
195
 
213
196
  result = transformation.transform(model)
214
- hash = { "__root__" => result }
215
-
216
197
  # optional should be omitted
217
- expect(hash["__root__"].keys).not_to include("optional")
198
+ expect(result.keys).not_to include("optional")
218
199
  end
219
200
 
220
201
  it "omits default values when render_default: false" do
@@ -223,10 +204,8 @@ RSpec.describe Lutaml::KeyValue::Transformation do
223
204
  transformation = described_class.new(KVRenderModel, mapping, :json, nil)
224
205
 
225
206
  result = transformation.transform(model)
226
- hash = { "__root__" => result }
227
-
228
207
  # defaulted should be omitted
229
- expect(hash["__root__"].keys).not_to include("defaulted")
208
+ expect(result.keys).not_to include("defaulted")
230
209
  end
231
210
  end
232
211
  end
@@ -239,9 +218,6 @@ RSpec.describe Lutaml::KeyValue::Transformation do
239
218
 
240
219
  result = transformation.transform(model)
241
220
 
242
- # Hash-direct emission: the __root__ Element tree (write-only in
243
- # production, immediately flattened by every consumer) is gone;
244
- # transform produces the output hash itself.
245
221
  expect(result).to be_a(Hash)
246
222
  expect(result).to eq("name" => "John", "age" => 30, "active" => true)
247
223
  end
@@ -257,7 +233,6 @@ RSpec.describe Lutaml::KeyValue::Transformation do
257
233
  expect(result).to be_a(Lutaml::KeyValue::DataModel::Element)
258
234
 
259
235
  # Presentation happens via to_hash, not during transformation
260
- hash = { "__root__" => result }
261
236
  expect(hash).to be_a(Hash)
262
237
  end
263
238
  end
@@ -350,6 +325,31 @@ RSpec.describe Lutaml::KeyValue::Transformation do
350
325
  expect(model.to_hash).to eq("code" => "X")
351
326
  end
352
327
 
328
+ it "builds nested structures through chained Element writes" do
329
+ # glossarist's Citation#locality_to_yaml pattern: assign an empty
330
+ # hash child, then write into it through the hash-like reader —
331
+ # the reader must return the child's own value object.
332
+ klass = Class.new(Lutaml::Model::Serializable) do
333
+ attribute :type, :string
334
+ attribute :reference_from, :string
335
+
336
+ key_value do
337
+ map "locality", to: :type, with: { to: :locality_to_yaml }
338
+ end
339
+
340
+ def locality_to_yaml(model, doc)
341
+ doc["locality"] = {}
342
+ doc["locality"]["type"] = model.type
343
+ doc["locality"]["reference_from"] = model.reference_from
344
+ end
345
+ end
346
+
347
+ model = klass.new(type: "clause", reference_from: "§3")
348
+ expect(model.to_hash).to eq(
349
+ "locality" => { "type" => "clause", "reference_from" => "§3" },
350
+ )
351
+ end
352
+
353
353
  it "ignores a custom method's return value (mutation-only contract)" do
354
354
  # A method returning a hash without touching the doc serializes
355
355
  # as nothing — honoring returns merged child content at the
@@ -416,6 +416,49 @@ RSpec.describe Lutaml::Model::Collection do
416
416
  expect(model.to_yaml).to eq(expected_yaml)
417
417
  end
418
418
 
419
+ # #1010: implicit-default collections are walk-seeded as the lazy
420
+ # sentinel instead of running the default lambda per instance. The
421
+ # sentinel answers [] on first read, and an explicit nil value must
422
+ # land over it — including through the deserialization fast path,
423
+ # whose writers otherwise preserve the sentinel.
424
+ it "reads nil when a parsed nil lands on the deserialization seed" do
425
+ klass = Class.new(Lutaml::Model::Serializable) do
426
+ attribute :items, :string, collection: true, initialize_empty: true
427
+ yaml { map "items", to: :items }
428
+ end
429
+
430
+ instance = klass.allocate_for_deserialization
431
+ instance.__assign_parsed_items = nil
432
+
433
+ expect(instance.items).to be_nil
434
+ end
435
+
436
+ it "materializes [] for untouched lazy-seeded collections" do
437
+ klass = Class.new(Lutaml::Model::Serializable) do
438
+ attribute :items, :string, collection: true, initialize_empty: true
439
+ yaml { map "items", to: :items }
440
+ end
441
+
442
+ parsed = klass.from_yaml("---\n{}\n")
443
+
444
+ expect(parsed.items).to eq([])
445
+ first = parsed.items
446
+ expect(parsed.items).to equal(first)
447
+ end
448
+
449
+ it "keeps an explicit default over the implicit empty collection" do
450
+ klass = Class.new(Lutaml::Model::Serializable) do
451
+ attribute :items, :integer,
452
+ collection: true, initialize_empty: true,
453
+ default: -> { [1, 2] }
454
+ yaml { map "items", to: :items }
455
+ end
456
+
457
+ parsed = klass.from_yaml("---\n{}\n")
458
+
459
+ expect(parsed.items).to eq([1, 2])
460
+ end
461
+
419
462
  it "raises StandardError for initialize_empty without collection" do
420
463
  expect do
421
464
  Class.new(Lutaml::Model::Serializable) do