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.
- checksums.yaml +4 -4
- data/README.adoc +25 -23
- data/docs/_pages/breaking-changes.adoc +58 -0
- data/docs/_pages/collections.adoc +25 -24
- data/docs/_pages/validation.adoc +48 -1
- data/lib/compat/opal/lutaml_model_boot.rb +1 -0
- data/lib/lutaml/key_value/transform.rb +7 -1
- data/lib/lutaml/model/attribute.rb +67 -24
- data/lib/lutaml/model/collection.rb +5 -2
- data/lib/lutaml/model/collection_handler.rb +45 -2
- data/lib/lutaml/model/serialize/enum_handling.rb +5 -1
- data/lib/lutaml/model/validation.rb +49 -4
- data/lib/lutaml/model/version.rb +1 -1
- data/lib/lutaml/xml/format.rb +2 -0
- data/lib/lutaml/xml/model_transform.rb +33 -9
- data/lib/lutaml/xml/nested_collection_attribute.rb +95 -0
- data/lib/lutaml/xml/schema/xsd_schema.rb +396 -128
- data/lib/lutaml/xml/serialization/instance_methods.rb +2 -2
- data/lib/lutaml/xml/transformation/element_builder.rb +55 -8
- data/spec/lutaml/model/attribute_collection_spec.rb +156 -0
- data/spec/lutaml/model/attribute_spec.rb +1 -1
- data/spec/lutaml/model/cardinality_strict_spec.rb +614 -0
- data/spec/lutaml/model/cdata_spec.rb +1 -1
- data/spec/lutaml/model/collection_register_spec.rb +119 -0
- data/spec/lutaml/model/collection_single_value_coercion_spec.rb +222 -0
- data/spec/lutaml/model/root_mappings_spec.rb +10 -7
- data/spec/lutaml/model/schema/xsd_schema_spec.rb +785 -25
- data/spec/lutaml/model/serializable_validation_spec.rb +293 -0
- data/spec/lutaml/model/value_policy_spec.rb +6 -3
- data/spec/lutaml/model/xsd_form_default_patterns_spec.rb +12 -12
- data/spec/lutaml/model/xsd_patterns_spec.rb +29 -29
- data/spec/lutaml/model/xsd_type_validation_spec.rb +1 -1
- data/spec/lutaml/turtle/transform_spec.rb +2 -2
- metadata +5 -1
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require_relative "../../../lib/lutaml/model"
|
|
3
|
+
|
|
4
|
+
module CardinalityStrictSpec
|
|
5
|
+
# Singular attribute only, mapped for every format.
|
|
6
|
+
class Simple < Lutaml::Model::Serializable
|
|
7
|
+
attribute :name, :string
|
|
8
|
+
|
|
9
|
+
xml do
|
|
10
|
+
root "simple"
|
|
11
|
+
map_element "name", to: :name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# key_value covers JSON, YAML and TOML
|
|
15
|
+
key_value do
|
|
16
|
+
map "name", to: :name
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Bounded collection, mapped for every format.
|
|
21
|
+
class Ranged < Lutaml::Model::Serializable
|
|
22
|
+
attribute :nick, :string, collection: 0..2
|
|
23
|
+
|
|
24
|
+
xml do
|
|
25
|
+
root "ranged"
|
|
26
|
+
map_element "nick", to: :nick
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
key_value do
|
|
30
|
+
map "nick", to: :nick
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Singular attribute deserialized by a custom method. The method receives one
|
|
35
|
+
# node for a single occurrence and the whole array when several arrive — the
|
|
36
|
+
# same shape a custom `to:` method already gets for a collection. It records
|
|
37
|
+
# what it saw so the specs can assert the handover.
|
|
38
|
+
class CustomSingular < Lutaml::Model::Serializable
|
|
39
|
+
attribute :label, :string
|
|
40
|
+
|
|
41
|
+
xml do
|
|
42
|
+
root "custom_singular"
|
|
43
|
+
map_element "label", to: :label,
|
|
44
|
+
with: { from: :label_from, to: :label_to }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.received
|
|
48
|
+
@received ||= []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def label_from(model, node)
|
|
52
|
+
self.class.received << node
|
|
53
|
+
texts = Array(node).map { |n| n.text.upcase }
|
|
54
|
+
model.label = texts.size > 1 ? texts : texts.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def label_to(model, parent, doc); end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A transform on the same rule as a custom deserializer. The transformer runs
|
|
61
|
+
# first and sees whatever arrived, including the over-count array.
|
|
62
|
+
class RecordingTransform < Lutaml::Model::ValueTransformer
|
|
63
|
+
def self.seen
|
|
64
|
+
@seen ||= []
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def from_xml(value)
|
|
68
|
+
self.class.seen << value
|
|
69
|
+
value
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_xml
|
|
73
|
+
value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
class CustomWithTransform < Lutaml::Model::Serializable
|
|
78
|
+
attribute :label, :string
|
|
79
|
+
|
|
80
|
+
xml do
|
|
81
|
+
root "custom_with_transform"
|
|
82
|
+
map_element "label", to: :label,
|
|
83
|
+
with: { from: :label_from, to: :label_to },
|
|
84
|
+
transform: RecordingTransform
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def label_from(model, node)
|
|
88
|
+
model.label = Array(node).map { |n| n.text.upcase }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def label_to(model, parent, doc); end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Singular :hash attribute. Shares the same parse branch as custom methods,
|
|
95
|
+
# without a custom method of its own.
|
|
96
|
+
class HashSingular < Lutaml::Model::Serializable
|
|
97
|
+
attribute :meta, :hash
|
|
98
|
+
|
|
99
|
+
xml do
|
|
100
|
+
root "hash_singular"
|
|
101
|
+
map_element "meta", to: :meta
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A custom method that collapses the array to one value. The over-count is
|
|
106
|
+
# then not reported: the method decided what the attribute holds.
|
|
107
|
+
class CollapsingCustom < Lutaml::Model::Serializable
|
|
108
|
+
attribute :label, :string
|
|
109
|
+
|
|
110
|
+
xml do
|
|
111
|
+
root "collapsing_custom"
|
|
112
|
+
map_element "label", to: :label,
|
|
113
|
+
with: { from: :label_from, to: :label_to }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def label_from(model, node)
|
|
117
|
+
model.label = Array(node).first.text.upcase
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def label_to(model, parent, doc); end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Singular attribute with an enum. The generated reader used to collapse the
|
|
124
|
+
# stored array via .first, which hid an over-count from #validate entirely.
|
|
125
|
+
class EnumSingular < Lutaml::Model::Serializable
|
|
126
|
+
attribute :kind, :string, values: %w[a b]
|
|
127
|
+
|
|
128
|
+
key_value do
|
|
129
|
+
map "kind", to: :kind
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Enum on a bounded collection: the case where both the range and the value
|
|
134
|
+
# set can fail at once.
|
|
135
|
+
class EnumRanged < Lutaml::Model::Serializable
|
|
136
|
+
attribute :kind, :string, values: %w[a b], collection: 0..2
|
|
137
|
+
|
|
138
|
+
xml do
|
|
139
|
+
root "enum_ranged"
|
|
140
|
+
map_element "kind", to: :kind
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Enum and pattern on collections, to pin that both skip nil elements.
|
|
145
|
+
class NilElements < Lutaml::Model::Serializable
|
|
146
|
+
attribute :kind, :string, values: %w[a b], collection: true
|
|
147
|
+
attribute :code, :string, pattern: /\A[a-z]+\z/, collection: true
|
|
148
|
+
|
|
149
|
+
key_value do
|
|
150
|
+
map "kind", to: :kind
|
|
151
|
+
map "code", to: :code
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A value-level validation that measures length. Stands in for the facets
|
|
156
|
+
# PR #719 adds: it must never run against an over-count array, or a
|
|
157
|
+
# `min_length` check would silently measure the element count instead.
|
|
158
|
+
class LengthChecked < Lutaml::Model::Serializable
|
|
159
|
+
attribute :code, :string, validations: proc {
|
|
160
|
+
validate :code_length
|
|
161
|
+
|
|
162
|
+
def code_length(value)
|
|
163
|
+
errors.add(:code, "measured #{value.length}")
|
|
164
|
+
end
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
key_value do
|
|
168
|
+
map "code", to: :code
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# A plain Ruby class mapped with `model`. Instances have no #validate, so a
|
|
173
|
+
# cardinality violation has nowhere to be reported later.
|
|
174
|
+
class PlainTarget
|
|
175
|
+
attr_accessor :name
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
class PlainMapper < Lutaml::Model::Serializable
|
|
179
|
+
model PlainTarget
|
|
180
|
+
|
|
181
|
+
attribute :name, :string
|
|
182
|
+
|
|
183
|
+
xml do
|
|
184
|
+
root "plain"
|
|
185
|
+
map_element "name", to: :name
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
key_value do
|
|
189
|
+
map "name", to: :name
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# #185 asks for cardinality enforcement across XML AND key/value formats.
|
|
195
|
+
# Parsing preserves whatever multiplicity arrived; `.validate` judges it. An
|
|
196
|
+
# over-count on a singular attribute is reported there, not raised at parse.
|
|
197
|
+
# Declared ranges keep their existing parse-time timing.
|
|
198
|
+
RSpec.describe "Issue #185 strict cardinality" do
|
|
199
|
+
describe "non-collection attribute (default 0..1) given more than one value" do
|
|
200
|
+
it "does not raise at parse for XML; reports on validate" do
|
|
201
|
+
obj = nil
|
|
202
|
+
expect do
|
|
203
|
+
obj = CardinalityStrictSpec::Simple.from_xml(
|
|
204
|
+
"<simple><name>A</name><name>B</name></simple>",
|
|
205
|
+
)
|
|
206
|
+
end.not_to raise_error
|
|
207
|
+
|
|
208
|
+
expect(obj.name).to eq(%w[A B])
|
|
209
|
+
expect(obj.validate).to include(
|
|
210
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
211
|
+
)
|
|
212
|
+
expect { obj.validate! }.to raise_error(Lutaml::Model::ValidationError)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
json: ['{"name":["A","B"]}', '{"name":"A"}'],
|
|
217
|
+
yaml: ["name:\n- A\n- B\n", "name: A\n"],
|
|
218
|
+
toml: ['name = ["A", "B"]', 'name = "A"'],
|
|
219
|
+
}.each do |format, (over_count, single)|
|
|
220
|
+
it "does not raise at parse for #{format.upcase}; reports on validate" do
|
|
221
|
+
obj = nil
|
|
222
|
+
expect do
|
|
223
|
+
obj = CardinalityStrictSpec::Simple.public_send(:"from_#{format}",
|
|
224
|
+
over_count)
|
|
225
|
+
end.not_to raise_error
|
|
226
|
+
|
|
227
|
+
expect(obj.name).to eq(%w[A B])
|
|
228
|
+
expect(obj.validate).to include(
|
|
229
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
it "accepts a single #{format.upcase} value" do
|
|
234
|
+
obj = CardinalityStrictSpec::Simple.public_send(:"from_#{format}",
|
|
235
|
+
single)
|
|
236
|
+
expect(obj.name).to eq("A")
|
|
237
|
+
expect(obj.validate).to be_empty
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it "accepts a single XML occurrence as a scalar" do
|
|
242
|
+
obj = CardinalityStrictSpec::Simple.from_xml("<simple><name>A</name></simple>")
|
|
243
|
+
expect(obj.name).to eq("A")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it "accepts zero XML occurrences" do
|
|
247
|
+
obj = CardinalityStrictSpec::Simple.from_xml("<simple/>")
|
|
248
|
+
expect(obj.name).to be_nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
context "when the singular attribute uses a custom deserializer" do
|
|
252
|
+
before { CardinalityStrictSpec::CustomSingular.received.clear }
|
|
253
|
+
|
|
254
|
+
it "hands the whole array to the custom method for multiple elements" do
|
|
255
|
+
obj = nil
|
|
256
|
+
expect do
|
|
257
|
+
obj = CardinalityStrictSpec::CustomSingular.from_xml(
|
|
258
|
+
"<custom_singular><label>a</label><label>b</label>" \
|
|
259
|
+
"</custom_singular>",
|
|
260
|
+
)
|
|
261
|
+
end.not_to raise_error
|
|
262
|
+
|
|
263
|
+
# Invoked once with the whole array, not once per child.
|
|
264
|
+
received = CardinalityStrictSpec::CustomSingular.received
|
|
265
|
+
expect(received.size).to eq(1)
|
|
266
|
+
expect(received.first).to be_a(Array)
|
|
267
|
+
|
|
268
|
+
expect(obj.label).to eq(%w[A B])
|
|
269
|
+
expect(obj.validate).to include(
|
|
270
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
271
|
+
)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it "runs the custom method for a single XML element" do
|
|
275
|
+
obj = CardinalityStrictSpec::CustomSingular.from_xml(
|
|
276
|
+
"<custom_singular><label>hi</label></custom_singular>",
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
received = CardinalityStrictSpec::CustomSingular.received
|
|
280
|
+
expect(received.size).to eq(1)
|
|
281
|
+
expect(received.first).not_to be_a(Array)
|
|
282
|
+
|
|
283
|
+
expect(obj.label).to eq("HI")
|
|
284
|
+
expect(obj.validate).to be_empty
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
context "when a transform shares the rule with a custom deserializer" do
|
|
289
|
+
before { CardinalityStrictSpec::RecordingTransform.seen.clear }
|
|
290
|
+
|
|
291
|
+
it "runs the transform before the custom method, on the whole array" do
|
|
292
|
+
obj = CardinalityStrictSpec::CustomWithTransform.from_xml(
|
|
293
|
+
"<custom_with_transform><label>a</label><label>b</label>" \
|
|
294
|
+
"</custom_with_transform>",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
seen = CardinalityStrictSpec::RecordingTransform.seen
|
|
298
|
+
expect(seen.size).to eq(1)
|
|
299
|
+
expect(seen.first).to be_a(Array)
|
|
300
|
+
|
|
301
|
+
expect(obj.label).to eq(%w[A B])
|
|
302
|
+
expect(obj.validate).to include(
|
|
303
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
context "when the custom method collapses the array" do
|
|
309
|
+
it "reports nothing, because the method decided what is stored" do
|
|
310
|
+
obj = CardinalityStrictSpec::CollapsingCustom.from_xml(
|
|
311
|
+
"<collapsing_custom><label>a</label><label>b</label>" \
|
|
312
|
+
"</collapsing_custom>",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
expect(obj.label).to eq("A")
|
|
316
|
+
expect(obj.validate).to be_empty
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
context "when the model is a plain Ruby class" do
|
|
321
|
+
# A PORO has no #validate, so deferring would discard the violation.
|
|
322
|
+
# These stay eager for that reason, not by oversight.
|
|
323
|
+
it "raises at parse for XML" do
|
|
324
|
+
expect do
|
|
325
|
+
CardinalityStrictSpec::PlainMapper.from_xml(
|
|
326
|
+
"<plain><name>A</name><name>B</name></plain>",
|
|
327
|
+
)
|
|
328
|
+
end.to raise_error(Lutaml::Model::CollectionTrueMissingError, /`name`/)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it "raises at parse for key/value" do
|
|
332
|
+
expect do
|
|
333
|
+
CardinalityStrictSpec::PlainMapper.from_json('{"name":["A","B"]}')
|
|
334
|
+
end.to raise_error(Lutaml::Model::CollectionTrueMissingError, /`name`/)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "still accepts a single value" do
|
|
338
|
+
obj = CardinalityStrictSpec::PlainMapper.from_xml(
|
|
339
|
+
"<plain><name>A</name></plain>",
|
|
340
|
+
)
|
|
341
|
+
expect(obj).to be_a(CardinalityStrictSpec::PlainTarget)
|
|
342
|
+
expect(obj.name).to eq("A")
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
context "when the singular attribute is a :hash" do
|
|
347
|
+
it "keeps a single occurrence a Hash" do
|
|
348
|
+
obj = CardinalityStrictSpec::HashSingular.from_xml(
|
|
349
|
+
"<hash_singular><meta><k>v</k></meta></hash_singular>",
|
|
350
|
+
)
|
|
351
|
+
expect(obj.meta).to eq({ "k" => "v" })
|
|
352
|
+
expect(obj.validate).to be_empty
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it "stores an over-count as an array and reports it on validate" do
|
|
356
|
+
obj = CardinalityStrictSpec::HashSingular.from_xml(
|
|
357
|
+
"<hash_singular><meta><k>v1</k></meta><meta><k>v2</k></meta>" \
|
|
358
|
+
"</hash_singular>",
|
|
359
|
+
)
|
|
360
|
+
expect(obj.meta).to eq([{ "k" => "v1" }, { "k" => "v2" }])
|
|
361
|
+
expect(obj.validate).to include(
|
|
362
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
describe "declared collection range (collection: 0..2)" do
|
|
369
|
+
it "flags an XML over-max on validate (XML validates ranges lazily)" do
|
|
370
|
+
obj = nil
|
|
371
|
+
expect do
|
|
372
|
+
obj = CardinalityStrictSpec::Ranged.from_xml(
|
|
373
|
+
"<ranged><nick>a</nick><nick>b</nick><nick>c</nick></ranged>",
|
|
374
|
+
)
|
|
375
|
+
end.not_to raise_error
|
|
376
|
+
expect(obj.validate).to include(
|
|
377
|
+
an_instance_of(Lutaml::Model::CollectionCountOutOfRangeError),
|
|
378
|
+
)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it "reports a key/value over-max via validate" do
|
|
382
|
+
obj = nil
|
|
383
|
+
expect do
|
|
384
|
+
obj = CardinalityStrictSpec::Ranged.from_json('{"nick":["a","b","c"]}')
|
|
385
|
+
end.not_to raise_error
|
|
386
|
+
expect(obj.validate).to include(
|
|
387
|
+
an_instance_of(Lutaml::Model::CollectionCountOutOfRangeError),
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it "accepts values within range for XML and key/value" do
|
|
392
|
+
xml_obj = CardinalityStrictSpec::Ranged.from_xml(
|
|
393
|
+
"<ranged><nick>a</nick><nick>b</nick></ranged>",
|
|
394
|
+
)
|
|
395
|
+
json_obj = CardinalityStrictSpec::Ranged.from_json('{"nick":["a","b"]}')
|
|
396
|
+
expect(xml_obj.validate).to be_empty
|
|
397
|
+
expect(json_obj.validate).to be_empty
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
describe "absent optional collection (regression: min-side false positive)" do
|
|
402
|
+
# collection: 0..2 with zero occurrences is valid; it must not raise at
|
|
403
|
+
# key/value parse just because an absent value arrives as nil.
|
|
404
|
+
it "does not raise for an absent optional collection in JSON" do
|
|
405
|
+
obj = nil
|
|
406
|
+
expect { obj = CardinalityStrictSpec::Ranged.from_json("{}") }
|
|
407
|
+
.not_to raise_error
|
|
408
|
+
expect(obj.validate).to be_empty
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it "does not raise for an absent optional collection in YAML" do
|
|
412
|
+
expect { CardinalityStrictSpec::Ranged.from_yaml("--- {}\n") }
|
|
413
|
+
.not_to raise_error
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
it "still raises when the range requires a minimum (1..)" do
|
|
417
|
+
klass = Class.new(Lutaml::Model::Serializable) do
|
|
418
|
+
def self.name = "CardinalityStrictSpec::AtLeastOne"
|
|
419
|
+
attribute :nick, :string, collection: (1..)
|
|
420
|
+
key_value { map "nick", to: :nick }
|
|
421
|
+
end
|
|
422
|
+
expect { klass.from_json("{}") }
|
|
423
|
+
.to raise_error(Lutaml::Model::CollectionCountOutOfRangeError)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
describe "map_content is left unchanged" do
|
|
428
|
+
let(:mixed) do
|
|
429
|
+
Class.new(Lutaml::Model::Serializable) do
|
|
430
|
+
def self.name = "CardinalityStrictSpec::Mixed"
|
|
431
|
+
attribute :bold, :string, collection: true
|
|
432
|
+
attribute :content, :string
|
|
433
|
+
xml do
|
|
434
|
+
root "r"
|
|
435
|
+
map_element "bold", to: :bold
|
|
436
|
+
map_content to: :content
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
it "does not raise at parse; flags a singular content target on validate" do
|
|
442
|
+
obj = nil
|
|
443
|
+
expect do
|
|
444
|
+
obj = mixed.from_xml(
|
|
445
|
+
"<r>one <bold>b</bold> two <bold>c</bold> three</r>",
|
|
446
|
+
)
|
|
447
|
+
end.not_to raise_error
|
|
448
|
+
expect(obj.validate).to include(
|
|
449
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
450
|
+
)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
describe "enum on a singular attribute" do
|
|
455
|
+
it "reports an over-count whose elements are outside the value set" do
|
|
456
|
+
obj = CardinalityStrictSpec::EnumSingular.from_json(
|
|
457
|
+
'{"kind":["a","z"]}',
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
expect(obj.kind).to eq(%w[a z])
|
|
461
|
+
expect(obj.validate).to contain_exactly(
|
|
462
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
463
|
+
)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
it "reports an over-count whose elements are all in the value set" do
|
|
467
|
+
obj = CardinalityStrictSpec::EnumSingular.from_json(
|
|
468
|
+
'{"kind":["a","b"]}',
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
expect(obj.kind).to eq(%w[a b])
|
|
472
|
+
expect(obj.validate).to contain_exactly(
|
|
473
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
474
|
+
)
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
it "leaves a single value reading as a scalar" do
|
|
478
|
+
obj = CardinalityStrictSpec::EnumSingular.from_json('{"kind":"a"}')
|
|
479
|
+
|
|
480
|
+
expect(obj.kind).to eq("a")
|
|
481
|
+
expect(obj.validate).to be_empty
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
it "still rejects a single value outside the set" do
|
|
485
|
+
obj = CardinalityStrictSpec::EnumSingular.from_json('{"kind":"z"}')
|
|
486
|
+
|
|
487
|
+
expect(obj.validate).to contain_exactly(
|
|
488
|
+
an_instance_of(Lutaml::Model::InvalidValueError),
|
|
489
|
+
)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# The generated `a?` shorthand reads through the same reader. An attribute
|
|
493
|
+
# holding two values has no honest "yes" to give.
|
|
494
|
+
it "answers the value shorthand false while over-counted" do
|
|
495
|
+
obj = CardinalityStrictSpec::EnumSingular.from_json(
|
|
496
|
+
'{"kind":["a","z"]}',
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
expect(obj.a?).to be(false)
|
|
500
|
+
|
|
501
|
+
obj.kind = "a"
|
|
502
|
+
expect(obj.a?).to be(true)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Known limitation, pinned so it stays visible. An enum setter always
|
|
506
|
+
# stores its value as an array, so a one-element array and a bare scalar
|
|
507
|
+
# are indistinguishable by the time #validate reads the slot. The
|
|
508
|
+
# shape-based rule therefore catches over-counts on an enum but not a
|
|
509
|
+
# one-element array, which a non-enum attribute does flag. Closing this
|
|
510
|
+
# needs the enum setter to preserve the assigned shape.
|
|
511
|
+
it "does not flag a one-element array, unlike a non-enum attribute" do
|
|
512
|
+
enumed = CardinalityStrictSpec::EnumSingular.from_json('{"kind":["a"]}')
|
|
513
|
+
plain = CardinalityStrictSpec::Simple.from_json('{"name":["A"]}')
|
|
514
|
+
|
|
515
|
+
expect(enumed.kind).to eq("a")
|
|
516
|
+
expect(enumed.validate).to be_empty
|
|
517
|
+
|
|
518
|
+
expect(plain.validate).to contain_exactly(
|
|
519
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# An invalid document does not round-trip. The array reaches the writer as
|
|
524
|
+
# the attribute's value and gets stringified, which is what any singular
|
|
525
|
+
# attribute holding an over-count already does — this is not specific to
|
|
526
|
+
# enums. Pinned so the shape is a visible decision. Fixing the violation
|
|
527
|
+
# restores clean output.
|
|
528
|
+
it "does not round-trip an over-count" do
|
|
529
|
+
obj = CardinalityStrictSpec::EnumSingular.from_json(
|
|
530
|
+
'{"kind":["a","b"]}',
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
expect(obj.to_json).to eq('{"kind":"[\"a\", \"b\"]"}')
|
|
534
|
+
|
|
535
|
+
obj.kind = "a"
|
|
536
|
+
expect(obj.to_json).to eq('{"kind":"a"}')
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
describe "enum on a bounded collection" do
|
|
541
|
+
# Shape before contents: the count is the more fundamental violation, so a
|
|
542
|
+
# value failing both its range and its value set reports the range.
|
|
543
|
+
it "reports the range when both the range and the value set fail" do
|
|
544
|
+
obj = CardinalityStrictSpec::EnumRanged.from_xml(
|
|
545
|
+
"<enum_ranged><kind>a</kind><kind>z</kind><kind>q</kind></enum_ranged>",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
expect(obj.validate).to contain_exactly(
|
|
549
|
+
an_instance_of(Lutaml::Model::CollectionCountOutOfRangeError),
|
|
550
|
+
)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
it "reports the value set when only the value set fails" do
|
|
554
|
+
obj = CardinalityStrictSpec::EnumRanged.from_xml(
|
|
555
|
+
"<enum_ranged><kind>a</kind><kind>z</kind></enum_ranged>",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
expect(obj.validate).to contain_exactly(
|
|
559
|
+
an_instance_of(Lutaml::Model::InvalidValueError),
|
|
560
|
+
)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
describe "nil elements in a collection" do
|
|
565
|
+
it "skips them for both enum and pattern checks" do
|
|
566
|
+
obj = CardinalityStrictSpec::NilElements.from_json(
|
|
567
|
+
'{"kind":["a",null],"code":["ab",null]}',
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
expect(obj.validate).to be_empty
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
it "still reports a non-nil element outside the value set" do
|
|
574
|
+
obj = CardinalityStrictSpec::NilElements.from_json(
|
|
575
|
+
'{"kind":["a",null,"z"]}',
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
expect(obj.validate).to contain_exactly(
|
|
579
|
+
an_instance_of(Lutaml::Model::InvalidValueError),
|
|
580
|
+
)
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
describe "a one-element array on a singular attribute" do
|
|
585
|
+
# The rule is about shape, not count: the attribute is declared to hold a
|
|
586
|
+
# value, and an array of one is still an array.
|
|
587
|
+
it "is a violation even though only one value arrived" do
|
|
588
|
+
obj = CardinalityStrictSpec::Simple.from_json('{"name":["A"]}')
|
|
589
|
+
|
|
590
|
+
expect(obj.name).to eq(["A"])
|
|
591
|
+
expect(obj.validate).to contain_exactly(
|
|
592
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
593
|
+
)
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
describe "value-level validations against an over-count (PR #719 guard)" do
|
|
598
|
+
it "does not run them, so nothing measures the element count" do
|
|
599
|
+
obj = CardinalityStrictSpec::LengthChecked.from_json(
|
|
600
|
+
'{"code":["abcd","efgh"]}',
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
expect(obj.validate).to contain_exactly(
|
|
604
|
+
an_instance_of(Lutaml::Model::CollectionTrueMissingError),
|
|
605
|
+
)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
it "runs them normally for a single value" do
|
|
609
|
+
obj = CardinalityStrictSpec::LengthChecked.from_json('{"code":"abcd"}')
|
|
610
|
+
|
|
611
|
+
expect(obj.validate.map(&:message).join).to include("measured 4")
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
end
|