lutaml-model 0.6.7 → 0.7.1

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos-todo.json +7 -0
  3. data/.github/workflows/dependent-repos.json +17 -9
  4. data/.rubocop_todo.yml +18 -33
  5. data/README.adoc +4380 -2557
  6. data/lib/lutaml/model/attribute.rb +94 -15
  7. data/lib/lutaml/model/choice.rb +7 -0
  8. data/lib/lutaml/model/comparable_model.rb +48 -9
  9. data/lib/lutaml/model/error/collection_count_out_of_range_error.rb +1 -1
  10. data/lib/lutaml/model/error/polymorphic_error.rb +9 -0
  11. data/lib/lutaml/model/error.rb +1 -0
  12. data/lib/lutaml/model/mapping/json_mapping.rb +17 -0
  13. data/lib/lutaml/model/{key_value_mapping.rb → mapping/key_value_mapping.rb} +58 -14
  14. data/lib/lutaml/model/{key_value_mapping_rule.rb → mapping/key_value_mapping_rule.rb} +18 -2
  15. data/lib/lutaml/model/mapping/mapping_rule.rb +299 -0
  16. data/lib/lutaml/model/mapping/toml_mapping.rb +25 -0
  17. data/lib/lutaml/model/{xml_mapping.rb → mapping/xml_mapping.rb} +97 -15
  18. data/lib/lutaml/model/{xml_mapping_rule.rb → mapping/xml_mapping_rule.rb} +20 -3
  19. data/lib/lutaml/model/mapping/yaml_mapping.rb +17 -0
  20. data/lib/lutaml/model/mapping.rb +14 -0
  21. data/lib/lutaml/model/schema/xml_compiler.rb +15 -15
  22. data/lib/lutaml/model/sequence.rb +2 -2
  23. data/lib/lutaml/model/serialize.rb +247 -97
  24. data/lib/lutaml/model/type/date.rb +1 -1
  25. data/lib/lutaml/model/type/date_time.rb +2 -2
  26. data/lib/lutaml/model/type/hash.rb +1 -1
  27. data/lib/lutaml/model/type/time.rb +2 -2
  28. data/lib/lutaml/model/type/time_without_date.rb +2 -2
  29. data/lib/lutaml/model/uninitialized_class.rb +64 -0
  30. data/lib/lutaml/model/utils.rb +14 -0
  31. data/lib/lutaml/model/validation.rb +1 -0
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/model/xml_adapter/nokogiri_adapter.rb +1 -1
  34. data/lib/lutaml/model/xml_adapter/oga_adapter.rb +1 -1
  35. data/lib/lutaml/model/xml_adapter/ox_adapter.rb +1 -1
  36. data/lib/lutaml/model/xml_adapter/xml_document.rb +38 -17
  37. data/lib/lutaml/model/xml_adapter/xml_element.rb +17 -7
  38. data/lib/lutaml/model.rb +1 -0
  39. data/spec/benchmarks/xml_parsing_benchmark_spec.rb +3 -3
  40. data/spec/fixtures/person.rb +5 -5
  41. data/spec/lutaml/model/attribute_spec.rb +37 -1
  42. data/spec/lutaml/model/cdata_spec.rb +2 -2
  43. data/spec/lutaml/model/collection_spec.rb +50 -2
  44. data/spec/lutaml/model/comparable_model_spec.rb +92 -27
  45. data/spec/lutaml/model/defaults_spec.rb +1 -1
  46. data/spec/lutaml/model/enum_spec.rb +1 -1
  47. data/spec/lutaml/model/group_spec.rb +316 -14
  48. data/spec/lutaml/model/key_value_mapping_spec.rb +41 -3
  49. data/spec/lutaml/model/polymorphic_spec.rb +348 -0
  50. data/spec/lutaml/model/render_empty_spec.rb +194 -0
  51. data/spec/lutaml/model/render_nil_spec.rb +206 -22
  52. data/spec/lutaml/model/simple_model_spec.rb +9 -9
  53. data/spec/lutaml/model/value_map_spec.rb +240 -0
  54. data/spec/lutaml/model/xml/namespace/nested_with_explicit_namespace_spec.rb +85 -0
  55. data/spec/lutaml/model/xml/xml_element_spec.rb +93 -0
  56. data/spec/lutaml/model/xml_mapping_rule_spec.rb +102 -2
  57. data/spec/lutaml/model/xml_mapping_spec.rb +45 -3
  58. data/spec/sample_model_spec.rb +3 -3
  59. metadata +20 -8
  60. data/lib/lutaml/model/mapping_rule.rb +0 -109
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+
5
+ module Lutaml
6
+ module Model
7
+ class UninitializedClass
8
+ include Singleton
9
+
10
+ def to_s
11
+ self
12
+ end
13
+
14
+ def inspect
15
+ "unititialized"
16
+ end
17
+
18
+ def uninitialized?
19
+ true
20
+ end
21
+
22
+ def match?(_args)
23
+ false
24
+ end
25
+
26
+ def include?(_args)
27
+ false
28
+ end
29
+
30
+ def gsub(_, _)
31
+ self
32
+ end
33
+
34
+ def to_yaml
35
+ nil
36
+ end
37
+
38
+ def to_f
39
+ self
40
+ end
41
+
42
+ def size
43
+ 0
44
+ end
45
+
46
+ def encoding
47
+ # same as default encoding for string
48
+ "".encoding
49
+ end
50
+
51
+ def method_missing(method, *_args, &_block)
52
+ if method.end_with?("?")
53
+ false
54
+ else
55
+ super
56
+ end
57
+ end
58
+
59
+ def respond_to_missing?(method_name, _include_private = false)
60
+ method_name.end_with?("?")
61
+ end
62
+ end
63
+ end
64
+ end
@@ -34,6 +34,16 @@ module Lutaml
34
34
  .downcase
35
35
  end
36
36
 
37
+ def initialized?(value)
38
+ return true unless value.respond_to?(:initialized?)
39
+
40
+ value.initialized?
41
+ end
42
+
43
+ def uninitialized?(value)
44
+ !initialized?(value)
45
+ end
46
+
37
47
  def present?(value)
38
48
  !blank?(value)
39
49
  end
@@ -49,6 +59,10 @@ module Lutaml
49
59
  collection.empty?
50
60
  end
51
61
 
62
+ def empty?(value)
63
+ value.respond_to?(:empty?) ? value.empty? : false
64
+ end
65
+
52
66
  def add_method_if_not_defined(klass, method_name, &block)
53
67
  unless klass.method_defined?(method_name)
54
68
  klass.class_eval do
@@ -14,6 +14,7 @@ module Lutaml
14
14
  rescue Lutaml::Model::InvalidValueError,
15
15
  Lutaml::Model::CollectionCountOutOfRangeError,
16
16
  Lutaml::Model::CollectionTrueMissingError,
17
+ Lutaml::Model::PolymorphicError,
17
18
  PatternNotMatchedError => e
18
19
  errors << e
19
20
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.6.7"
5
+ VERSION = "0.7.1"
6
6
  end
7
7
  end
@@ -53,7 +53,7 @@ module Lutaml
53
53
  end
54
54
 
55
55
  def build_ordered_element(xml, element, options = {})
56
- mapper_class = options[:mapper_class] || element.class
56
+ mapper_class = determine_mapper_class(element, options)
57
57
  xml_mapping = mapper_class.mappings_for(:xml)
58
58
  return xml unless xml_mapping
59
59
 
@@ -113,7 +113,7 @@ module Lutaml
113
113
  private
114
114
 
115
115
  def build_ordered_element(builder, element, options = {})
116
- mapper_class = options[:mapper_class] || element.class
116
+ mapper_class = determine_mapper_class(element, options)
117
117
  xml_mapping = mapper_class.mappings_for(:xml)
118
118
  return xml unless xml_mapping
119
119
 
@@ -46,7 +46,7 @@ module Lutaml
46
46
  private
47
47
 
48
48
  def build_ordered_element(builder, element, options = {})
49
- mapper_class = options[:mapper_class] || element.class
49
+ mapper_class = determine_mapper_class(element, options)
50
50
  xml_mapping = mapper_class.mappings_for(:xml)
51
51
  return xml unless xml_mapping
52
52
 
@@ -158,7 +158,7 @@ module Lutaml
158
158
  value = transform_method.call(value)
159
159
  end
160
160
 
161
- if value.is_a?(Array)
161
+ if value.is_a?(Array) && !Utils.empty_collection?(value)
162
162
  value.each do |item|
163
163
  add_to_xml(xml, element, prefix, item, options)
164
164
  end
@@ -168,12 +168,18 @@ module Lutaml
168
168
 
169
169
  return if !render_element?(rule, element, value)
170
170
 
171
+ value = rule.render_value_for(value)
172
+
171
173
  if value && (attribute&.type&.<= Lutaml::Model::Serialize)
172
174
  handle_nested_elements(
173
175
  xml,
174
176
  value,
175
177
  options.merge({ rule: rule, attribute: attribute }),
176
178
  )
179
+ elsif value.nil?
180
+ xml.create_and_add_element(rule.name, attributes: { "xsi:nil" => true })
181
+ elsif Utils.empty?(value)
182
+ xml.create_and_add_element(rule.name)
177
183
  elsif rule.raw_mapping?
178
184
  xml.add_xml_fragment(xml, value)
179
185
  elsif rule.prefix_set?
@@ -205,19 +211,12 @@ module Lutaml
205
211
  end
206
212
 
207
213
  def build_unordered_element(xml, element, options = {})
208
- mapper_class = options[:mapper_class] || element.class
214
+ mapper_class = determine_mapper_class(element, options)
209
215
  xml_mapping = mapper_class.mappings_for(:xml)
210
216
  return xml unless xml_mapping
211
217
 
212
- attributes = options[:xml_attributes] ||= {}
213
- attributes = build_attributes(element,
214
- xml_mapping, options).merge(attributes)&.compact
215
-
216
- prefix = if options.key?(:namespace_prefix)
217
- options[:namespace_prefix]
218
- elsif xml_mapping.namespace_prefix
219
- xml_mapping.namespace_prefix
220
- end
218
+ attributes = build_element_attributes(element, xml_mapping, options)
219
+ prefix = determine_namespace_prefix(options, xml_mapping)
221
220
 
222
221
  prefixed_xml = xml.add_namespace_prefix(prefix)
223
222
  tag_name = options[:tag_name] || xml_mapping.root_element
@@ -234,6 +233,7 @@ module Lutaml
234
233
  end
235
234
 
236
235
  mappings = xml_mapping.elements + [xml_mapping.raw_mapping].compact
236
+
237
237
  mappings.each do |element_rule|
238
238
  attribute_def = attribute_definition_for(element, element_rule,
239
239
  mapper_class: mapper_class)
@@ -241,7 +241,7 @@ module Lutaml
241
241
  if attribute_def
242
242
  value = attribute_value_for(element, element_rule)
243
243
 
244
- next if value.nil? && !element_rule.render_nil?
244
+ next if !element_rule.render?(value, element)
245
245
 
246
246
  value = [value] if attribute_def.collection? && !value.is_a?(Array)
247
247
  end
@@ -271,6 +271,8 @@ module Lutaml
271
271
  text = content_rule.serialize(element)
272
272
  text = text.join if text.is_a?(Array)
273
273
 
274
+ return unless content_rule.render?(text, element)
275
+
274
276
  xml.add_text(xml, text, cdata: content_rule.cdata)
275
277
  end
276
278
  end
@@ -289,11 +291,7 @@ module Lutaml
289
291
  end
290
292
 
291
293
  def render_element?(rule, element, value)
292
- render_default?(rule, element) && render_value?(rule, value)
293
- end
294
-
295
- def render_value?(rule, value)
296
- rule.attribute? || rule.render_nil? || !value.nil?
294
+ rule.render?(value, element)
297
295
  end
298
296
 
299
297
  def render_default?(rule, element)
@@ -433,6 +431,29 @@ module Lutaml
433
431
  def cdata
434
432
  @root.cdata
435
433
  end
434
+
435
+ private
436
+
437
+ def determine_mapper_class(element, options)
438
+ if options[:mapper_class] && element.is_a?(options[:mapper_class])
439
+ element.class
440
+ else
441
+ options[:mapper_class] || element.class
442
+ end
443
+ end
444
+
445
+ def determine_namespace_prefix(options, mapping)
446
+ return options[:namespace_prefix] if options.key?(:namespace_prefix)
447
+
448
+ mapping.namespace_prefix
449
+ end
450
+
451
+ def build_element_attributes(element, mapping, options)
452
+ xml_attributes = options[:xml_attributes] ||= {}
453
+ attributes = build_attributes(element, mapping, options)
454
+
455
+ attributes.merge(xml_attributes)&.compact
456
+ end
436
457
  end
437
458
  end
438
459
  end
@@ -127,9 +127,17 @@ module Lutaml
127
127
  end
128
128
 
129
129
  def text
130
+ return @text if children.empty?
130
131
  return text_children.map(&:text) if children.count > 1
131
132
 
132
- @text
133
+ text_children.map(&:text).join
134
+ end
135
+
136
+ def cdata
137
+ return @text if children.empty?
138
+ return cdata_children.map(&:text) if children.count > 1
139
+
140
+ cdata_children.map(&:text).join
133
141
  end
134
142
 
135
143
  def cdata_children
@@ -140,6 +148,10 @@ module Lutaml
140
148
  find_children_by_name("text")
141
149
  end
142
150
 
151
+ def [](name)
152
+ find_attribute_value(name) || find_children_by_name(name)
153
+ end
154
+
143
155
  def find_attribute_value(attribute_name)
144
156
  if attribute_name.is_a?(Array)
145
157
  attributes.values.find do |attr|
@@ -164,15 +176,13 @@ module Lutaml
164
176
  find_children_by_name(name).first
165
177
  end
166
178
 
167
- def cdata
168
- return cdata_children.map(&:text) if children.count > 1
169
-
170
- @text
171
- end
172
-
173
179
  def to_h
174
180
  document.to_h
175
181
  end
182
+
183
+ def nil_element?
184
+ find_attribute_value("xsi:nil") == "true"
185
+ end
176
186
  end
177
187
  end
178
188
  end
data/lib/lutaml/model.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "moxml"
4
+ require_relative "model/uninitialized_class"
4
5
  require_relative "model/version"
5
6
  require_relative "model/loggable"
6
7
  require_relative "model/type"
@@ -61,9 +61,9 @@ RSpec.describe "LutaML Model Performance" do
61
61
  end
62
62
 
63
63
  thresholds = {
64
- "Nokogiri Adapter" => 4,
65
- "Ox Adapter" => 10,
66
- "Oga Adapter" => 4,
64
+ "Nokogiri Adapter" => 3,
65
+ "Ox Adapter" => 7,
66
+ "Oga Adapter" => 3,
67
67
  }
68
68
 
69
69
  report.entries.each do |entry|
@@ -17,11 +17,11 @@ class Person < Lutaml::Model::Serializable
17
17
  map_element "FirstName",
18
18
  to: :first_name,
19
19
  namespace: "http://example.com/nsp1",
20
- prefix: "nsp1"
20
+ prefix: "nsp1", render_empty: :omit
21
21
  map_element "LastName",
22
22
  to: :last_name,
23
23
  namespace: "http://example.com/nsp1",
24
- prefix: "nsp1"
24
+ prefix: "nsp1", render_empty: :as_blank
25
25
  map_element "Age", to: :age
26
26
  map_element "Height", to: :height
27
27
  map_element "Birthdate", to: :birthdate
@@ -31,9 +31,9 @@ class Person < Lutaml::Model::Serializable
31
31
  end
32
32
 
33
33
  json do
34
- map "firstName", to: :first_name
35
- map "lastName", to: :last_name
36
- map "age", to: :age
34
+ map "firstName", to: :first_name, render_empty: :omit
35
+ map "lastName", to: :last_name, render_empty: :as_empty
36
+ map "age", to: :age, render_empty: :as_nil
37
37
  map "height", to: :height
38
38
  map "birthdate", to: :birthdate
39
39
  map "lastLogin", to: :last_login
@@ -136,7 +136,9 @@ RSpec.describe Lutaml::Model::Attribute do
136
136
  context "when default is not set" do
137
137
  let(:attribute) { described_class.new("name", :string) }
138
138
 
139
- it { expect(attribute.default).to be_nil }
139
+ it "returns uninitialized" do
140
+ expect(attribute.default).to be(Lutaml::Model::UninitializedClass.instance)
141
+ end
140
142
  end
141
143
 
142
144
  context "when default is set as a proc" do
@@ -165,4 +167,38 @@ RSpec.describe Lutaml::Model::Attribute do
165
167
  end
166
168
  end
167
169
  end
170
+
171
+ describe "#deep_dup" do
172
+ let(:duplicate_attribute) { Lutaml::Model::Utils.deep_dup(attribute) }
173
+
174
+ context "when deep_dup method is not defined and instance is deep_duplicated" do
175
+ let(:attribute) { described_class.new("name", :string) }
176
+
177
+ before do
178
+ described_class.alias_method :orig_deep_dup, :deep_dup
179
+ described_class.undef_method :deep_dup
180
+ end
181
+
182
+ after do
183
+ described_class.alias_method :deep_dup, :orig_deep_dup
184
+ attribute.options.delete(:foo)
185
+ end
186
+
187
+ it "confirms that options values are linked of original and duplicate instances" do
188
+ duplicate_attribute
189
+ attribute.options[:foo] = "bar"
190
+ expect(duplicate_attribute.options).to include(:foo)
191
+ end
192
+ end
193
+
194
+ context "when deep_dup method is defined and instance is deep_duplicated" do
195
+ let(:attribute) { described_class.new("name", :string) }
196
+
197
+ it "confirms that options values are not linked of original and duplicate instances" do
198
+ duplicate_attribute
199
+ attribute.options[:foo] = "bar"
200
+ expect(duplicate_attribute.options).not_to include(:foo)
201
+ end
202
+ end
203
+ end
168
204
  end
@@ -117,8 +117,8 @@ module CDATA
117
117
 
118
118
  def child_from_xml(model, value)
119
119
  model.child_mapper ||= CustomModelChild.new
120
- model.child_mapper.street = value.first.find_child_by_name("street").text
121
- model.child_mapper.city = value.first.find_child_by_name("city").text
120
+ model.child_mapper.street = value.first.find_child_by_name("street").cdata
121
+ model.child_mapper.city = value.first.find_child_by_name("city").cdata
122
122
  end
123
123
  end
124
124
 
@@ -54,6 +54,16 @@ module CollectionTests
54
54
  doc.add_element(parent, "<city>#{model.city}</city>")
55
55
  end
56
56
  end
57
+
58
+ class ReturnNilTest < Lutaml::Model::Serializable
59
+ attribute :default_items, :string, collection: true
60
+ attribute :regular_items, :string, collection: true, initialize_empty: true
61
+
62
+ yaml do
63
+ map "default_items", to: :default_items
64
+ map "regular_items", to: :regular_items, render_default: true, render_empty: true
65
+ end
66
+ end
57
67
  end
58
68
 
59
69
  RSpec.describe CollectionTests do
@@ -95,8 +105,8 @@ RSpec.describe CollectionTests do
95
105
  it "initializes with default values" do
96
106
  default_model = CollectionTests::Kiln.new
97
107
  expect(default_model.brand).to be_nil
98
- expect(default_model.pots).to eq([])
99
- expect(default_model.temperatures).to eq([])
108
+ expect(default_model.pots).to be_nil
109
+ expect(default_model.temperatures).to be_nil
100
110
  expect(default_model.operators).to eq(["Default Operator"])
101
111
  expect(default_model.sensors).to eq(["Default Sensor"])
102
112
  end
@@ -280,4 +290,42 @@ RSpec.describe CollectionTests do
280
290
  end.not_to raise_error
281
291
  end
282
292
  end
293
+
294
+ context "when using initialize_empty option with collections" do
295
+ let(:parsed) { CollectionTests::ReturnNilTest.from_yaml(yaml) }
296
+ let(:model) { CollectionTests::ReturnNilTest.new }
297
+
298
+ let(:yaml) do
299
+ <<~YAML
300
+ ---
301
+ regular_items: ~
302
+ YAML
303
+ end
304
+
305
+ it "sets nil value when reading from YAML with nil value" do
306
+ expect(parsed.default_items).to be_nil
307
+ expect(parsed.regular_items).to be_nil
308
+ end
309
+
310
+ it "initializes with empty array when initialize_empty is true" do
311
+ expect(model.regular_items).to eq([])
312
+ end
313
+
314
+ it "preserves initialize_empty behavior when serializing and deserializing" do
315
+ expected_yaml = <<~YAML
316
+ ---
317
+ regular_items: []
318
+ YAML
319
+
320
+ expect(model.to_yaml).to eq(expected_yaml)
321
+ end
322
+
323
+ it "raises StandardError for initialize_empty without collection" do
324
+ expect do
325
+ Class.new(Lutaml::Model::Serializable) do
326
+ attribute :invalid_range, :string, initialize_empty: true
327
+ end
328
+ end.to raise_error(StandardError, /Invalid option `initialize_empty` given without `collection: true` option/)
329
+ end
330
+ end
283
331
  end
@@ -19,6 +19,11 @@ class ComparableCeramicCollection < Lutaml::Model::Serializable
19
19
  attribute :featured_piece, ComparableCeramic # This creates a two-level nesting
20
20
  end
21
21
 
22
+ class RecursiveNode < Lutaml::Model::Serializable
23
+ attribute :name, :string
24
+ attribute :next_node, RecursiveNode
25
+ end
26
+
22
27
  RSpec.describe Lutaml::Model::ComparableModel do
23
28
  describe "comparisons" do
24
29
  context "with simple types (Glaze)" do
@@ -68,38 +73,98 @@ RSpec.describe Lutaml::Model::ComparableModel do
68
73
  end
69
74
 
70
75
  context "with deeply nested Serializable objects (CeramicCollection)" do
76
+ let(:first_collection) do
77
+ ComparableCeramicCollection.new(
78
+ name: "Blue Collection",
79
+ featured_piece: ComparableCeramic.new(
80
+ type: "Bowl",
81
+ glaze: ComparableGlaze.new(
82
+ color: "Blue",
83
+ temperature: 1200,
84
+ food_safe: true,
85
+ ),
86
+ ),
87
+ )
88
+ end
89
+
90
+ let(:second_collection) do
91
+ ComparableCeramicCollection.new(
92
+ name: "Blue Collection",
93
+ featured_piece: ComparableCeramic.new(
94
+ type: "Bowl",
95
+ glaze: ComparableGlaze.new(
96
+ color: "Blue",
97
+ temperature: 1200,
98
+ food_safe: true,
99
+ ),
100
+ ),
101
+ )
102
+ end
103
+
71
104
  it "compares equal objects with two levels of nesting" do
72
105
  # This test compares CeramicCollection objects that contain Ceramic objects,
73
106
  # which in turn contain Glaze objects - a two-level deep nesting
74
- glaze1 = ComparableGlaze.new(color: "Blue", temperature: 1200,
75
- food_safe: true)
76
- glaze2 = ComparableGlaze.new(color: "Blue", temperature: 1200,
77
- food_safe: true)
78
- ceramic1 = ComparableCeramic.new(type: "Bowl", glaze: glaze1)
79
- ceramic2 = ComparableCeramic.new(type: "Bowl", glaze: glaze2)
80
- collection1 = ComparableCeramicCollection.new(name: "Blue Collection",
81
- featured_piece: ceramic1)
82
- collection2 = ComparableCeramicCollection.new(name: "Blue Collection",
83
- featured_piece: ceramic2)
84
- expect(collection1).to eq(collection2)
85
- expect(collection1.hash).to eq(collection2.hash)
107
+ expect(first_collection).to eq(second_collection)
86
108
  end
87
109
 
88
- it "compares unequal objects with two levels of nesting" do
89
- # This test compares CeramicCollection objects that are different at every level:
90
- # the collection name, the ceramic type, and the glaze properties
91
- glaze1 = ComparableGlaze.new(color: "Blue", temperature: 1200,
92
- food_safe: true)
93
- glaze2 = ComparableGlaze.new(color: "Red", temperature: 1000,
94
- food_safe: false)
95
- ceramic1 = ComparableCeramic.new(type: "Bowl", glaze: glaze1)
96
- ceramic2 = ComparableCeramic.new(type: "Plate", glaze: glaze2)
97
- collection1 = ComparableCeramicCollection.new(name: "Blue Collection",
98
- featured_piece: ceramic1)
99
- collection2 = ComparableCeramicCollection.new(name: "Red Collection",
100
- featured_piece: ceramic2)
101
- expect(collection1).not_to eq(collection2)
102
- expect(collection1.hash).not_to eq(collection2.hash)
110
+ it "generates same hash for objects with two levels of nesting" do
111
+ expect(first_collection.hash).to eq(second_collection.hash)
112
+ end
113
+
114
+ context "with deeply nested objects that are not equal" do
115
+ before do
116
+ second_collection.name = "Red Collection"
117
+ second_collection.featured_piece.type = "Plate"
118
+ second_collection.featured_piece.glaze.color = "Red"
119
+ end
120
+
121
+ it "compares unequal objects with two levels of nesting" do
122
+ # This test compares CeramicCollection objects that are different at every level:
123
+ # the collection name, the ceramic type, and the glaze properties
124
+ expect(first_collection).not_to eq(second_collection)
125
+ end
126
+
127
+ it "generates different hashes for objects with two levels of nesting" do
128
+ expect(first_collection.hash).not_to eq(second_collection.hash)
129
+ end
130
+ end
131
+ end
132
+
133
+ context "with recursive relationships" do
134
+ let(:first_recursive_node) do
135
+ node1 = RecursiveNode.new(name: "A")
136
+ node2 = RecursiveNode.new(name: "B", next_node: node1)
137
+ node1.next_node = node2
138
+ node1
139
+ end
140
+
141
+ let(:second_recursive_node) do
142
+ node1 = RecursiveNode.new(name: "A")
143
+ node2 = RecursiveNode.new(name: "B", next_node: node1)
144
+ node1.next_node = node2
145
+ node1
146
+ end
147
+
148
+ describe ".eql?" do
149
+ it "compares equal objects" do
150
+ expect(first_recursive_node).to eq(second_recursive_node)
151
+ end
152
+
153
+ it "compares unequal objects" do
154
+ second_recursive_node.name = "X"
155
+ expect(first_recursive_node).not_to eq(second_recursive_node)
156
+ end
157
+ end
158
+
159
+ describe ".hash" do
160
+ it "returns the same hash for equal objects" do
161
+ expect(first_recursive_node.hash).to eq(second_recursive_node.hash)
162
+ end
163
+
164
+ it "returns different hashes for unequal objects" do
165
+ second_recursive_node.name = "X"
166
+ expect(first_recursive_node.hash).not_to eq(second_recursive_node.hash)
167
+ end
103
168
  end
104
169
  end
105
170
  end
@@ -143,7 +143,7 @@ RSpec.describe DefaultsSpec::Glaze do
143
143
  expect(default_model.temperature).to eq(1050)
144
144
  expect(default_model.firing_time).to eq(60)
145
145
  expect(default_model.balance).to eq(BigDecimal("0.0"))
146
- expect(default_model.tags).to eq([])
146
+ expect(default_model.tags).to be_nil
147
147
  expect(default_model.properties).to eq({ food_safe: true })
148
148
  expect(default_model.status).to eq("active")
149
149
  expect(default_model.batch_number).to eq(0)
@@ -5,7 +5,7 @@ module EnumSpec
5
5
  class WithEnum < Lutaml::Model::Serializable
6
6
  attribute :without_enum, :string
7
7
  attribute :single_value, :string, values: %w[user admin super_admin]
8
- attribute :multi_value, :string, values: %w[singular dual plural], collection: true
8
+ attribute :multi_value, :string, values: %w[singular dual plural], collection: true, initialize_empty: true
9
9
  end
10
10
  end
11
11