lutaml-model 0.8.31 → 0.8.32

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +25 -23
  3. data/docs/_pages/breaking-changes.adoc +58 -0
  4. data/docs/_pages/collections.adoc +25 -24
  5. data/docs/_pages/validation.adoc +48 -1
  6. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  7. data/lib/lutaml/key_value/transform.rb +7 -1
  8. data/lib/lutaml/model/attribute.rb +67 -24
  9. data/lib/lutaml/model/collection.rb +5 -2
  10. data/lib/lutaml/model/collection_handler.rb +45 -2
  11. data/lib/lutaml/model/serialize/enum_handling.rb +5 -1
  12. data/lib/lutaml/model/validation.rb +49 -4
  13. data/lib/lutaml/model/version.rb +1 -1
  14. data/lib/lutaml/xml/format.rb +2 -0
  15. data/lib/lutaml/xml/model_transform.rb +33 -9
  16. data/lib/lutaml/xml/nested_collection_attribute.rb +95 -0
  17. data/lib/lutaml/xml/schema/xsd_schema.rb +396 -128
  18. data/lib/lutaml/xml/serialization/instance_methods.rb +2 -2
  19. data/lib/lutaml/xml/transformation/element_builder.rb +55 -8
  20. data/spec/lutaml/model/attribute_collection_spec.rb +156 -0
  21. data/spec/lutaml/model/attribute_spec.rb +1 -1
  22. data/spec/lutaml/model/cardinality_strict_spec.rb +614 -0
  23. data/spec/lutaml/model/cdata_spec.rb +1 -1
  24. data/spec/lutaml/model/collection_register_spec.rb +119 -0
  25. data/spec/lutaml/model/collection_single_value_coercion_spec.rb +222 -0
  26. data/spec/lutaml/model/root_mappings_spec.rb +10 -7
  27. data/spec/lutaml/model/schema/xsd_schema_spec.rb +785 -25
  28. data/spec/lutaml/model/serializable_validation_spec.rb +293 -0
  29. data/spec/lutaml/model/value_policy_spec.rb +6 -3
  30. data/spec/lutaml/model/xsd_form_default_patterns_spec.rb +12 -12
  31. data/spec/lutaml/model/xsd_patterns_spec.rb +29 -29
  32. data/spec/lutaml/model/xsd_type_validation_spec.rb +1 -1
  33. data/spec/lutaml/turtle/transform_spec.rb +2 -2
  34. metadata +5 -1
@@ -0,0 +1,119 @@
1
+ require "spec_helper"
2
+ require_relative "../../../lib/lutaml/model"
3
+
4
+ module CollectionRegisterSpec
5
+ # Inherits Collection#initialize, so it declares the register keyword.
6
+ class Standard < Lutaml::Model::Collection
7
+ instances :items, :string
8
+ end
9
+
10
+ # Overrides #initialize with the positional signature that predates the
11
+ # register. Passing the keyword to this raises ArgumentError.
12
+ class Positional < Lutaml::Model::Collection
13
+ instances :items, :string
14
+
15
+ def initialize(items = [])
16
+ super
17
+ end
18
+ end
19
+
20
+ # Accepts and forwards arbitrary keywords, so the register still arrives.
21
+ class Forwarding < Lutaml::Model::Collection
22
+ instances :items, :string
23
+
24
+ def initialize(items = [], **)
25
+ super
26
+ end
27
+ end
28
+
29
+ class UsesStandard < Lutaml::Model::Serializable
30
+ attribute :items, :string, collection: Standard
31
+ key_value { map "items", to: :items }
32
+ end
33
+
34
+ class UsesPositional < Lutaml::Model::Serializable
35
+ attribute :items, :string, collection: Positional
36
+ key_value { map "items", to: :items }
37
+ end
38
+
39
+ class UsesForwarding < Lutaml::Model::Serializable
40
+ attribute :items, :string, collection: Forwarding
41
+ key_value { map "items", to: :items }
42
+ end
43
+
44
+ class UsesPlainArray < Lutaml::Model::Serializable
45
+ attribute :items, :string, collection: true
46
+ key_value { map "items", to: :items }
47
+ end
48
+ end
49
+
50
+ RSpec.describe "collection construction and the register" do
51
+ let(:register) { Lutaml::Model::Register.new(:collection_register_spec) }
52
+
53
+ before { Lutaml::Model::GlobalRegister.register(register) }
54
+
55
+ describe "a custom collection whose constructor takes the register" do
56
+ it "receives the register used for the parse" do
57
+ obj = CollectionRegisterSpec::UsesStandard.from_json(
58
+ '{"items":["x","y"]}',
59
+ register: :collection_register_spec,
60
+ )
61
+
62
+ expect(obj.items.lutaml_register).to eq(:collection_register_spec)
63
+ end
64
+
65
+ it "receives it through a forwarding constructor too" do
66
+ obj = CollectionRegisterSpec::UsesForwarding.from_json(
67
+ '{"items":["x","y"]}',
68
+ register: :collection_register_spec,
69
+ )
70
+
71
+ expect(obj.items.lutaml_register).to eq(:collection_register_spec)
72
+ end
73
+ end
74
+
75
+ describe "a custom collection with a positional-only constructor" do
76
+ it "parses without raising ArgumentError" do
77
+ obj = nil
78
+
79
+ expect do
80
+ obj = CollectionRegisterSpec::UsesPositional.from_json(
81
+ '{"items":["x","y"]}',
82
+ )
83
+ end.not_to raise_error
84
+
85
+ expect(obj.items.to_a).to eq(%w[x y])
86
+ end
87
+
88
+ it "accepts assignment without raising ArgumentError" do
89
+ obj = CollectionRegisterSpec::UsesPositional.new
90
+
91
+ expect { obj.items = %w[x y] }.not_to raise_error
92
+ expect(obj.items.to_a).to eq(%w[x y])
93
+ end
94
+
95
+ # There is no way to hand a register to a constructor that does not take
96
+ # one, so such a collection resolves against the default register.
97
+ it "falls back to the default register under a non-default parse" do
98
+ obj = CollectionRegisterSpec::UsesPositional.from_json(
99
+ '{"items":["x"]}',
100
+ register: :collection_register_spec,
101
+ )
102
+
103
+ expect(obj.items.lutaml_register)
104
+ .to eq(Lutaml::Model::Config.default_register)
105
+ end
106
+ end
107
+
108
+ describe "a plain collection: true attribute" do
109
+ it "still builds a bare Array when a register is in play" do
110
+ obj = CollectionRegisterSpec::UsesPlainArray.from_json(
111
+ '{"items":["x","y"]}',
112
+ register: :collection_register_spec,
113
+ )
114
+
115
+ expect(obj.items).to be_an(Array)
116
+ expect(obj.items).to eq(%w[x y])
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,222 @@
1
+ require "spec_helper"
2
+ require_relative "../../../lib/lutaml/model"
3
+
4
+ module CollectionSingleValueCoercion
5
+ class Item < Lutaml::Model::Serializable
6
+ attribute :name, :string
7
+
8
+ xml do
9
+ element "item"
10
+ map_element "name", to: :name
11
+ end
12
+ end
13
+
14
+ class Basket < Lutaml::Model::Serializable
15
+ attribute :items, Item, collection: true
16
+ attribute :labels, :string, collection: true
17
+ attribute :sizes, :string, collection: 1..2
18
+
19
+ xml do
20
+ element "basket"
21
+ map_element "item", to: :items
22
+ map_element "label", to: :labels
23
+ map_element "size", to: :sizes
24
+ end
25
+ end
26
+
27
+ class NameParts < Lutaml::Model::Collection
28
+ instances :parts, :string
29
+ end
30
+
31
+ class MetadataEntries < Lutaml::Model::Collection
32
+ instances :entries, :hash
33
+ end
34
+
35
+ class Person < Lutaml::Model::Serializable
36
+ attribute :name_parts, :string, collection: NameParts
37
+ attribute :metadata_entries, :hash, collection: MetadataEntries
38
+
39
+ xml do
40
+ element "person"
41
+ map_element "part", to: :name_parts
42
+ end
43
+ end
44
+
45
+ class Coded < Lutaml::Model::Serializable
46
+ attribute :codes, :string, collection: true, pattern: /\A[a-z]+\z/
47
+
48
+ xml do
49
+ element "coded"
50
+ map_element "code", to: :codes
51
+ end
52
+ end
53
+
54
+ # Pattern applied to an attribute backed by a custom Collection class. The
55
+ # value is a Collection instance (not a plain Array), so pattern validation
56
+ # must flatten it per element rather than treat it as a single non-string.
57
+ class CodedCustom < Lutaml::Model::Serializable
58
+ attribute :codes, :string, collection: NameParts, pattern: /\A[a-z]+\z/
59
+
60
+ xml do
61
+ element "coded_custom"
62
+ map_element "code", to: :codes
63
+ end
64
+ end
65
+ end
66
+
67
+ RSpec.describe "single value coercion on collection attributes" do
68
+ describe "setter assignment" do
69
+ it "wraps a single model instance into a one-element array" do
70
+ basket = CollectionSingleValueCoercion::Basket.new
71
+ basket.items = CollectionSingleValueCoercion::Item.new(name: "one")
72
+
73
+ expect(basket.items).to be_a(Array)
74
+ expect(basket.items.map(&:name)).to eq(["one"])
75
+ end
76
+
77
+ it "wraps a single primitive into a one-element array" do
78
+ basket = CollectionSingleValueCoercion::Basket.new
79
+ basket.labels = "fragile"
80
+
81
+ expect(basket.labels).to eq(["fragile"])
82
+ end
83
+
84
+ it "leaves nil untouched" do
85
+ basket = CollectionSingleValueCoercion::Basket.new
86
+ basket.labels = nil
87
+
88
+ expect(basket.labels).to be_nil
89
+ end
90
+
91
+ it "leaves array assignment unchanged" do
92
+ basket = CollectionSingleValueCoercion::Basket.new
93
+ basket.labels = %w[a b]
94
+
95
+ expect(basket.labels).to eq(%w[a b])
96
+ end
97
+ end
98
+
99
+ describe "constructor assignment" do
100
+ it "wraps a single value passed to the constructor" do
101
+ basket = CollectionSingleValueCoercion::Basket.new(labels: "solo")
102
+
103
+ expect(basket.labels).to eq(["solo"])
104
+ end
105
+
106
+ it "produces the same shape as parsing one element" do
107
+ parsed = CollectionSingleValueCoercion::Basket.from_xml(
108
+ "<basket><label>solo</label></basket>",
109
+ )
110
+ built = CollectionSingleValueCoercion::Basket.new(labels: "solo")
111
+
112
+ expect(built.labels).to eq(parsed.labels)
113
+ end
114
+ end
115
+
116
+ describe "bounded collections" do
117
+ it "wraps a single value and satisfies the range validation" do
118
+ basket = CollectionSingleValueCoercion::Basket.new(sizes: "small")
119
+
120
+ expect(basket.sizes).to eq(["small"])
121
+ expect { basket.validate! }.not_to raise_error
122
+ end
123
+ end
124
+
125
+ describe "custom collection classes" do
126
+ it "wraps a single value into the custom collection" do
127
+ person = CollectionSingleValueCoercion::Person.new
128
+ person.name_parts = "Ada"
129
+
130
+ expect(person.name_parts)
131
+ .to be_a(CollectionSingleValueCoercion::NameParts)
132
+ expect(person.name_parts.to_a.size).to eq(1)
133
+ end
134
+
135
+ it "routes a plain Array through the collection branch element-wise" do
136
+ person = CollectionSingleValueCoercion::Person.new
137
+ person.name_parts = %w[Ada Lovelace]
138
+
139
+ expect(person.name_parts)
140
+ .to be_a(CollectionSingleValueCoercion::NameParts)
141
+ expect(person.name_parts.to_a.size).to eq(2)
142
+ end
143
+
144
+ it "does not double-cast arrays for hash-valued custom collections" do
145
+ person = CollectionSingleValueCoercion::Person.new
146
+ person.metadata_entries = [{ "text" => "x" }]
147
+
148
+ expect(person.metadata_entries)
149
+ .to be_a(CollectionSingleValueCoercion::MetadataEntries)
150
+ expect(person.metadata_entries.to_a).to eq(["x"])
151
+ end
152
+
153
+ it "does not double-cast single values for hash-valued custom collections" do
154
+ person = CollectionSingleValueCoercion::Person.new
155
+ person.metadata_entries = { "text" => "x" }
156
+
157
+ expect(person.metadata_entries)
158
+ .to be_a(CollectionSingleValueCoercion::MetadataEntries)
159
+ expect(person.metadata_entries.to_a).to eq(["x"])
160
+ end
161
+
162
+ it "rebuilds a fresh collection instead of sharing the assigned one" do
163
+ shared = CollectionSingleValueCoercion::NameParts.new(%w[Ada])
164
+ a = CollectionSingleValueCoercion::Person.new
165
+ b = CollectionSingleValueCoercion::Person.new
166
+
167
+ a.name_parts = shared
168
+ b.name_parts = shared
169
+
170
+ expect(a.name_parts).not_to be(shared)
171
+ expect(a.name_parts).not_to be(b.name_parts)
172
+ expect(a.name_parts.to_a).to eq(%w[Ada])
173
+ end
174
+ end
175
+
176
+ describe "pattern validation on collection attributes" do
177
+ it "validates a coerced single value per element" do
178
+ coded = CollectionSingleValueCoercion::Coded.new(codes: "abc")
179
+
180
+ expect(coded.codes).to eq(["abc"])
181
+ expect { coded.validate! }.not_to raise_error
182
+ end
183
+
184
+ it "validates each element of an assigned array" do
185
+ coded = CollectionSingleValueCoercion::Coded.new(codes: %w[abc def])
186
+
187
+ expect { coded.validate! }.not_to raise_error
188
+ end
189
+
190
+ it "rejects an element that violates the pattern" do
191
+ coded = CollectionSingleValueCoercion::Coded.new(codes: %w[abc DEF])
192
+
193
+ expect { coded.validate! }
194
+ .to raise_error(Lutaml::Model::ValidationError, /DEF/)
195
+ end
196
+
197
+ it "validates each element of a custom Collection per element" do
198
+ coded = CollectionSingleValueCoercion::CodedCustom.new(codes: %w[abc def])
199
+
200
+ expect { coded.validate! }.not_to raise_error
201
+ end
202
+
203
+ it "rejects a bad element inside a custom Collection" do
204
+ coded = CollectionSingleValueCoercion::CodedCustom.new(codes: %w[abc DEF])
205
+
206
+ expect { coded.validate! }
207
+ .to raise_error(Lutaml::Model::ValidationError, /DEF/)
208
+ end
209
+ end
210
+
211
+ describe "serialization round-trip" do
212
+ it "serializes a coerced single value as a repeated element would" do
213
+ basket = CollectionSingleValueCoercion::Basket.new
214
+ basket.items = CollectionSingleValueCoercion::Item.new(name: "one")
215
+
216
+ round_tripped = CollectionSingleValueCoercion::Basket.from_xml(
217
+ basket.to_xml,
218
+ )
219
+ expect(round_tripped.items.map(&:name)).to eq(["one"])
220
+ end
221
+ end
222
+ end
@@ -280,14 +280,17 @@ RSpec.describe "RootMapping" do
280
280
  }
281
281
  end
282
282
 
283
- it "raises error" do
283
+ it "parses, then reports the missing `collection: true` on validate" do
284
+ obj = nil
284
285
  expect do
285
- RootMappingSpec::CeramicCollectionWithoutCollectionTrue.public_send(
286
- :"from_#{format}", input
287
- )
288
- end.to raise_error(
289
- Lutaml::Model::CollectionTrueMissingError,
290
- "May be `collection: true` is missing for `ceramics` in RootMappingSpec::CeramicCollectionWithoutCollectionTrue",
286
+ obj = RootMappingSpec::CeramicCollectionWithoutCollectionTrue
287
+ .public_send(:"from_#{format}", input)
288
+ end.not_to raise_error
289
+
290
+ expect(obj.validate).to include(
291
+ an_object_having_attributes(
292
+ message: "May be `collection: true` is missing for `ceramics` in RootMappingSpec::CeramicCollectionWithoutCollectionTrue",
293
+ ),
291
294
  )
292
295
  end
293
296
  end