lutaml-model 0.3.24 → 0.3.25

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +35 -16
  3. data/README.adoc +274 -28
  4. data/lib/lutaml/model/attribute.rb +18 -8
  5. data/lib/lutaml/model/error/type_error.rb +9 -0
  6. data/lib/lutaml/model/error/unknown_type_error.rb +9 -0
  7. data/lib/lutaml/model/error/validation_error.rb +0 -1
  8. data/lib/lutaml/model/error.rb +2 -0
  9. data/lib/lutaml/model/serialize.rb +6 -1
  10. data/lib/lutaml/model/type/boolean.rb +38 -0
  11. data/lib/lutaml/model/type/date.rb +35 -0
  12. data/lib/lutaml/model/type/date_time.rb +32 -4
  13. data/lib/lutaml/model/type/decimal.rb +42 -0
  14. data/lib/lutaml/model/type/float.rb +37 -0
  15. data/lib/lutaml/model/type/hash.rb +62 -0
  16. data/lib/lutaml/model/type/integer.rb +41 -0
  17. data/lib/lutaml/model/type/string.rb +49 -0
  18. data/lib/lutaml/model/type/time.rb +49 -0
  19. data/lib/lutaml/model/type/time_without_date.rb +37 -5
  20. data/lib/lutaml/model/type/value.rb +52 -0
  21. data/lib/lutaml/model/type.rb +50 -114
  22. data/lib/lutaml/model/version.rb +1 -1
  23. data/lib/lutaml/model/xml_adapter/builder/nokogiri.rb +5 -2
  24. data/lib/lutaml/model/xml_adapter/ox_adapter.rb +2 -1
  25. data/lib/lutaml/model/xml_adapter/xml_document.rb +0 -2
  26. data/lutaml-model.gemspec +1 -1
  27. data/spec/address_spec.rb +170 -0
  28. data/spec/fixtures/address.rb +33 -0
  29. data/spec/fixtures/person.rb +73 -0
  30. data/spec/fixtures/sample_model.rb +40 -0
  31. data/spec/fixtures/vase.rb +38 -0
  32. data/spec/fixtures/xml/special_char.xml +13 -0
  33. data/spec/lutaml/model/attribute_spec.rb +112 -0
  34. data/spec/lutaml/model/collection_spec.rb +299 -0
  35. data/spec/lutaml/model/comparable_model_spec.rb +106 -0
  36. data/spec/lutaml/model/custom_model_spec.rb +410 -0
  37. data/spec/lutaml/model/custom_serialization_spec.rb +170 -0
  38. data/spec/lutaml/model/defaults_spec.rb +221 -0
  39. data/spec/lutaml/model/delegation_spec.rb +340 -0
  40. data/spec/lutaml/model/inheritance_spec.rb +92 -0
  41. data/spec/lutaml/model/json_adapter_spec.rb +37 -0
  42. data/spec/lutaml/model/key_value_mapping_spec.rb +86 -0
  43. data/spec/lutaml/model/map_content_spec.rb +118 -0
  44. data/spec/lutaml/model/mixed_content_spec.rb +625 -0
  45. data/spec/lutaml/model/namespace_spec.rb +57 -0
  46. data/spec/lutaml/model/ordered_content_spec.rb +83 -0
  47. data/spec/lutaml/model/render_nil_spec.rb +138 -0
  48. data/spec/lutaml/model/schema/json_schema_spec.rb +79 -0
  49. data/spec/lutaml/model/schema/relaxng_schema_spec.rb +60 -0
  50. data/spec/lutaml/model/schema/xsd_schema_spec.rb +55 -0
  51. data/spec/lutaml/model/schema/yaml_schema_spec.rb +47 -0
  52. data/spec/lutaml/model/serializable_spec.rb +297 -0
  53. data/spec/lutaml/model/serializable_validation_spec.rb +85 -0
  54. data/spec/lutaml/model/simple_model_spec.rb +314 -0
  55. data/spec/lutaml/model/toml_adapter_spec.rb +39 -0
  56. data/spec/lutaml/model/type/boolean_spec.rb +54 -0
  57. data/spec/lutaml/model/type/date_spec.rb +118 -0
  58. data/spec/lutaml/model/type/date_time_spec.rb +127 -0
  59. data/spec/lutaml/model/type/decimal_spec.rb +125 -0
  60. data/spec/lutaml/model/type/float_spec.rb +191 -0
  61. data/spec/lutaml/model/type/hash_spec.rb +63 -0
  62. data/spec/lutaml/model/type/integer_spec.rb +145 -0
  63. data/spec/lutaml/model/type/string_spec.rb +150 -0
  64. data/spec/lutaml/model/type/time_spec.rb +142 -0
  65. data/spec/lutaml/model/type/time_without_date_spec.rb +125 -0
  66. data/spec/lutaml/model/type_spec.rb +276 -0
  67. data/spec/lutaml/model/utils_spec.rb +79 -0
  68. data/spec/lutaml/model/validation_spec.rb +83 -0
  69. data/spec/lutaml/model/with_child_mapping_spec.rb +174 -0
  70. data/spec/lutaml/model/xml_adapter/nokogiri_adapter_spec.rb +56 -0
  71. data/spec/lutaml/model/xml_adapter/oga_adapter_spec.rb +56 -0
  72. data/spec/lutaml/model/xml_adapter/ox_adapter_spec.rb +61 -0
  73. data/spec/lutaml/model/xml_adapter/xml_namespace_spec.rb +251 -0
  74. data/spec/lutaml/model/xml_adapter_spec.rb +178 -0
  75. data/spec/lutaml/model/xml_mapping_spec.rb +863 -0
  76. data/spec/lutaml/model/yaml_adapter_spec.rb +30 -0
  77. data/spec/lutaml/model_spec.rb +1 -0
  78. data/spec/person_spec.rb +161 -0
  79. data/spec/spec_helper.rb +33 -0
  80. metadata +66 -2
@@ -0,0 +1,299 @@
1
+ require "spec_helper"
2
+ require "lutaml/model"
3
+
4
+ module CollectionTests
5
+ class Pot < Lutaml::Model::Serializable
6
+ attribute :material, Lutaml::Model::Type::String
7
+
8
+ xml do
9
+ root "pot"
10
+ map_element "material", to: :material
11
+ end
12
+ end
13
+
14
+ class Kiln < Lutaml::Model::Serializable
15
+ attribute :brand, Lutaml::Model::Type::String
16
+ attribute :pots, Pot, collection: 0..2
17
+ attribute :temperatures, Lutaml::Model::Type::Integer, collection: true
18
+ attribute :operators, Lutaml::Model::Type::String, collection: (1..),
19
+ default: -> {
20
+ ["Default Operator"]
21
+ }
22
+ attribute :sensors, Lutaml::Model::Type::String, collection: 1..3,
23
+ default: -> {
24
+ ["Default Sensor"]
25
+ }
26
+
27
+ xml do
28
+ root "kiln"
29
+ map_attribute "brand", to: :brand
30
+ map_element "pot", to: :pots
31
+ map_element "temperature", to: :temperatures
32
+ map_element "operator", to: :operators
33
+ map_element "sensor", to: :sensors
34
+ end
35
+ end
36
+
37
+ class Address < Lutaml::Model::Serializable
38
+ attribute :street, :string
39
+ attribute :city, :string
40
+ attribute :address, Address
41
+
42
+ xml do
43
+ root "address"
44
+ map_element "street", to: :street
45
+ map_element "city", with: { from: :city_from_xml, to: :city_to_xml }
46
+ map_element "address", to: :address
47
+ end
48
+
49
+ def city_from_xml(model, node)
50
+ model.city = node
51
+ end
52
+
53
+ def city_to_xml(model, parent, doc)
54
+ doc.add_element(parent, "<city>#{model.city}</city>")
55
+ end
56
+ end
57
+ end
58
+
59
+ RSpec.describe CollectionTests do
60
+ let(:pots) { [{ material: "clay" }, { material: "ceramic" }] }
61
+ let(:temperatures) { [1200, 1300, 1400] }
62
+ let(:operators) { ["John", "Jane"] }
63
+ let(:sensors) { ["Temp1", "Temp2"] }
64
+ let(:attributes) do
65
+ {
66
+ brand: "Skutt",
67
+ pots: pots,
68
+ temperatures: temperatures,
69
+ operators: operators,
70
+ sensors: sensors,
71
+ }
72
+ end
73
+ let(:model) { CollectionTests::Kiln.new(attributes) }
74
+
75
+ let(:model_xml) do
76
+ <<~XML
77
+ <kiln brand="Skutt">
78
+ <pot>
79
+ <material>clay</material>
80
+ </pot>
81
+ <pot>
82
+ <material>ceramic</material>
83
+ </pot>
84
+ <temperature>1200</temperature>
85
+ <temperature>1300</temperature>
86
+ <temperature>1400</temperature>
87
+ <operator>John</operator>
88
+ <operator>Jane</operator>
89
+ <sensor>Temp1</sensor>
90
+ <sensor>Temp2</sensor>
91
+ </kiln>
92
+ XML
93
+ end
94
+
95
+ it "initializes with default values" do
96
+ default_model = CollectionTests::Kiln.new
97
+ expect(default_model.brand).to be_nil
98
+ expect(default_model.pots).to eq([])
99
+ expect(default_model.temperatures).to eq([])
100
+ expect(default_model.operators).to eq(["Default Operator"])
101
+ expect(default_model.sensors).to eq(["Default Sensor"])
102
+ end
103
+
104
+ it "serializes to XML" do
105
+ expected_xml = model_xml.strip
106
+ expect(model.to_xml.strip).to eq(expected_xml)
107
+ end
108
+
109
+ it "deserializes from XML" do
110
+ sample = CollectionTests::Kiln.from_xml(model_xml)
111
+ expect(sample.brand).to eq("Skutt")
112
+ expect(sample.pots.size).to eq(2)
113
+ expect(sample.pots[0].material).to eq("clay")
114
+ expect(sample.pots[1].material).to eq("ceramic")
115
+ expect(sample.temperatures).to eq([1200, 1300, 1400])
116
+ expect(sample.operators).to eq(["John", "Jane"])
117
+ expect(sample.sensors).to eq(["Temp1", "Temp2"])
118
+ end
119
+
120
+ it "round-trips XML" do
121
+ xml = model.to_xml
122
+ new_model = CollectionTests::Kiln.from_xml(xml)
123
+ expect(new_model.brand).to eq(model.brand)
124
+ expect(new_model.pots.size).to eq(model.pots.size)
125
+ model.pots.each_with_index do |pot, index|
126
+ expect(new_model.pots[index].material).to eq(pot.material)
127
+ end
128
+ expect(new_model.temperatures).to eq(model.temperatures)
129
+ expect(new_model.operators).to eq(model.operators)
130
+ expect(new_model.sensors).to eq(model.sensors)
131
+ end
132
+
133
+ context "when model contains self as attribute" do
134
+ let(:xml) do
135
+ <<~XML
136
+ <address>
137
+ <street>A</street>
138
+ <city>B</city>
139
+ <address>
140
+ <street>C</street>
141
+ <city>D</city>
142
+ </address>
143
+ </address>
144
+ XML
145
+ end
146
+
147
+ it "deserializes from XML" do
148
+ model = CollectionTests::Address.from_xml(xml)
149
+
150
+ expect(model.street).to eq("A")
151
+ expect(model.city).to eq("B")
152
+ expect(model.address.street).to eq("C")
153
+ expect(model.address.city).to eq("D")
154
+ end
155
+
156
+ it "round-trips XML" do
157
+ model = CollectionTests::Address.from_xml(xml)
158
+
159
+ expect(model.to_xml).to be_equivalent_to(xml)
160
+ end
161
+ end
162
+
163
+ context "when collection counts are below given ranges" do
164
+ let(:invalid_attributes) do
165
+ attributes.merge(operators: [], sensors: [])
166
+ end
167
+
168
+ it "raises ValidationError containing CollectionCountOutOfRangeError for operators" do
169
+ kiln = CollectionTests::Kiln.new(invalid_attributes)
170
+ expect do
171
+ kiln.validate!
172
+ end.to raise_error(Lutaml::Model::ValidationError) do |error|
173
+ expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError)
174
+ expect(error.error_messages).to include(a_string_matching(/operators count is 0, must be at least 1/))
175
+ end
176
+ end
177
+
178
+ it "raises ValidationError containing CollectionCountOutOfRangeError for sensors" do
179
+ kiln = CollectionTests::Kiln.new(attributes.merge(sensors: []))
180
+ expect do
181
+ kiln.validate!
182
+ end.to raise_error(Lutaml::Model::ValidationError) do |error|
183
+ expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError)
184
+ expect(error.error_messages).to include(a_string_matching(/sensors count is 0, must be between 1 and 3/))
185
+ end
186
+ end
187
+ end
188
+
189
+ context "when collection counts are below given ranges" do
190
+ let(:invalid_attributes) do
191
+ attributes.merge(operators: [], sensors: [])
192
+ end
193
+
194
+ it "raises CollectionCountOutOfRangeError" do
195
+ kiln = CollectionTests::Kiln.new(invalid_attributes)
196
+ expect do
197
+ kiln.validate!
198
+ end.to raise_error(Lutaml::Model::ValidationError) do |error|
199
+ expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError)
200
+ expect(error.error_messages).to include(a_string_matching(/operators count is 0, must be at least 1/))
201
+ end
202
+ end
203
+ end
204
+
205
+ context "when collection with unbounded maximum exceeds minimum" do
206
+ let(:valid_attributes) do
207
+ attributes.merge(operators: ["John", "Jane", "Jim", "Jessica"])
208
+ end
209
+
210
+ it "creates the model without errors" do
211
+ expect { CollectionTests::Kiln.new(valid_attributes) }.not_to raise_error
212
+ end
213
+ end
214
+
215
+ context "when deserializing XML with invalid collection counts" do
216
+ let(:invalid_xml) do
217
+ <<~XML
218
+ <kiln brand="Skutt">
219
+ <pot>
220
+ <material>clay</material>
221
+ </pot>
222
+ <pot>
223
+ <material>ceramic</material>
224
+ </pot>
225
+ <pot>
226
+ <material>porcelain</material>
227
+ </pot>
228
+ <temperature>1200</temperature>
229
+ <operator>John</operator>
230
+ <sensor>Temp1</sensor>
231
+ <sensor>Temp2</sensor>
232
+ <sensor>Temp3</sensor>
233
+ <sensor>Temp4</sensor>
234
+ </kiln>
235
+ XML
236
+ end
237
+
238
+ it "raises ValidationError containing CollectionCountOutOfRangeError" do
239
+ expect do
240
+ CollectionTests::Kiln.from_xml(invalid_xml).validate!
241
+ end.to raise_error(Lutaml::Model::ValidationError) do |error|
242
+ expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError)
243
+ expect(error.error_messages).to include(a_string_matching(/pots count is 3, must be between 0 and 2/))
244
+ end
245
+ end
246
+ end
247
+
248
+ context "when specifying invalid collection ranges" do
249
+ it "raises an error for a range with only an upper bound" do
250
+ expect do
251
+ Class.new(Lutaml::Model::Serializable) do
252
+ attribute :invalid_range, Lutaml::Model::Type::String, collection: ..3
253
+ end
254
+ end.to raise_error(ArgumentError, /Invalid collection range/)
255
+ end
256
+
257
+ it "raises an error for a range where max is less than min" do
258
+ expect do
259
+ Class.new(Lutaml::Model::Serializable) do
260
+ attribute :invalid_range, Lutaml::Model::Type::String,
261
+ collection: 9..3
262
+ end
263
+ end.to raise_error(ArgumentError, /Invalid collection range/)
264
+ end
265
+
266
+ it "raises an error for a negative range" do
267
+ expect do
268
+ Class.new(Lutaml::Model::Serializable) do
269
+ attribute :invalid_range, Lutaml::Model::Type::String,
270
+ collection: -2..1
271
+ end
272
+ end.to raise_error(ArgumentError, /Invalid collection range/)
273
+ end
274
+
275
+ it "allows a range with only a lower bound" do
276
+ expect do
277
+ Class.new(Lutaml::Model::Serializable) do
278
+ attribute :valid_range, Lutaml::Model::Type::String, collection: 1..
279
+ end
280
+ end.not_to raise_error
281
+ end
282
+
283
+ it "allows a range with both lower and upper bounds" do
284
+ expect do
285
+ Class.new(Lutaml::Model::Serializable) do
286
+ attribute :valid_range, Lutaml::Model::Type::String, collection: 1..3
287
+ end
288
+ end.not_to raise_error
289
+ end
290
+
291
+ it "allows a range with zero as the lower bound" do
292
+ expect do
293
+ Class.new(Lutaml::Model::Serializable) do
294
+ attribute :valid_range, Lutaml::Model::Type::String, collection: 0..3
295
+ end
296
+ end.not_to raise_error
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,106 @@
1
+ require "spec_helper"
2
+
3
+ # Simple model with basic attributes
4
+ class ComparableGlaze < Lutaml::Model::Serializable
5
+ attribute :color, :string
6
+ attribute :temperature, :integer
7
+ attribute :food_safe, :boolean
8
+ end
9
+
10
+ # Model with a nested Serializable object
11
+ class ComparableCeramic < Lutaml::Model::Serializable
12
+ attribute :type, :string
13
+ attribute :glaze, ComparableGlaze
14
+ end
15
+
16
+ # Model with a deeply nested Serializable object
17
+ class ComparableCeramicCollection < Lutaml::Model::Serializable
18
+ attribute :name, :string
19
+ attribute :featured_piece, ComparableCeramic # This creates a two-level nesting
20
+ end
21
+
22
+ RSpec.describe Lutaml::Model::ComparableModel do
23
+ describe "comparisons" do
24
+ context "with simple types (Glaze)" do
25
+ it "compares equal objects with basic attributes" do
26
+ glaze1 = ComparableGlaze.new(color: "Blue", temperature: 1200,
27
+ food_safe: true)
28
+ glaze2 = ComparableGlaze.new(color: "Blue", temperature: 1200,
29
+ food_safe: true)
30
+ expect(glaze1).to eq(glaze2)
31
+ expect(glaze1.hash).to eq(glaze2.hash)
32
+ end
33
+
34
+ it "compares unequal objects with basic attributes" do
35
+ glaze1 = ComparableGlaze.new(color: "Blue", temperature: 1200,
36
+ food_safe: true)
37
+ glaze2 = ComparableGlaze.new(color: "Red", temperature: 1000,
38
+ food_safe: false)
39
+ expect(glaze1).not_to eq(glaze2)
40
+ expect(glaze1.hash).not_to eq(glaze2.hash)
41
+ end
42
+ end
43
+
44
+ context "with nested Serializable objects (Ceramic)" do
45
+ it "compares equal objects with one level of nesting" do
46
+ # Here, we're comparing Ceramic objects that contain Glaze objects
47
+ glaze1 = ComparableGlaze.new(color: "Blue", temperature: 1200,
48
+ food_safe: true)
49
+ glaze2 = ComparableGlaze.new(color: "Blue", temperature: 1200,
50
+ food_safe: true)
51
+ ceramic1 = ComparableCeramic.new(type: "Bowl", glaze: glaze1)
52
+ ceramic2 = ComparableCeramic.new(type: "Bowl", glaze: glaze2)
53
+ expect(ceramic1).to eq(ceramic2)
54
+ expect(ceramic1.hash).to eq(ceramic2.hash)
55
+ end
56
+
57
+ it "compares unequal objects with one level of nesting" do
58
+ # Here, we're comparing Ceramic objects with different Glaze objects
59
+ glaze1 = ComparableGlaze.new(color: "Blue", temperature: 1200,
60
+ food_safe: true)
61
+ glaze2 = ComparableGlaze.new(color: "Red", temperature: 1000,
62
+ food_safe: false)
63
+ ceramic1 = ComparableCeramic.new(type: "Bowl", glaze: glaze1)
64
+ ceramic2 = ComparableCeramic.new(type: "Plate", glaze: glaze2)
65
+ expect(ceramic1).not_to eq(ceramic2)
66
+ expect(ceramic1.hash).not_to eq(ceramic2.hash)
67
+ end
68
+ end
69
+
70
+ context "with deeply nested Serializable objects (CeramicCollection)" do
71
+ it "compares equal objects with two levels of nesting" do
72
+ # This test compares CeramicCollection objects that contain Ceramic objects,
73
+ # 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)
86
+ end
87
+
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)
103
+ end
104
+ end
105
+ end
106
+ end