lutaml-model 0.8.33 → 0.8.34

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +5 -0
  3. data/Gemfile +4 -0
  4. data/README.adoc +39 -0
  5. data/docs/conversion_caching.adoc +143 -0
  6. data/lib/compat/opal/lutaml_model_boot.rb +15 -0
  7. data/lib/lutaml/model/attribute.rb +89 -0
  8. data/lib/lutaml/model/config.rb +13 -0
  9. data/lib/lutaml/model/configuration.rb +23 -0
  10. data/lib/lutaml/model/error/fraction_digits_error.rb +20 -0
  11. data/lib/lutaml/model/error/length_error.rb +19 -0
  12. data/lib/lutaml/model/error/max_exclusive_error.rb +19 -0
  13. data/lib/lutaml/model/error/max_inclusive_error.rb +20 -0
  14. data/lib/lutaml/model/error/max_length_error.rb +20 -0
  15. data/lib/lutaml/model/error/min_exclusive_error.rb +19 -0
  16. data/lib/lutaml/model/error/min_inclusive_error.rb +20 -0
  17. data/lib/lutaml/model/error/min_length_error.rb +20 -0
  18. data/lib/lutaml/model/error/restriction_error.rb +8 -0
  19. data/lib/lutaml/model/error/total_digits_error.rb +20 -0
  20. data/lib/lutaml/model/error/type/max_exclusive_error.rb +20 -0
  21. data/lib/lutaml/model/error/type/min_exclusive_error.rb +20 -0
  22. data/lib/lutaml/model/error/type.rb +2 -0
  23. data/lib/lutaml/model/restriction_validation.rb +437 -0
  24. data/lib/lutaml/model/schema/definitions/restricted_type.rb +4 -2
  25. data/lib/lutaml/model/schema/renderers/restricted_type.rb +134 -4
  26. data/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +3 -0
  27. data/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +9 -1
  28. data/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +2 -0
  29. data/lib/lutaml/model/schema/templates.rb +2 -0
  30. data/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +39 -7
  31. data/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +3 -0
  32. data/lib/lutaml/model/serialize/attribute_definition.rb +18 -7
  33. data/lib/lutaml/model/serialize/builder.rb +4 -5
  34. data/lib/lutaml/model/serialize/conversion_caching.rb +124 -0
  35. data/lib/lutaml/model/serialize/enum_handling.rb +50 -11
  36. data/lib/lutaml/model/serialize/format_conversion.rb +46 -38
  37. data/lib/lutaml/model/serialize/initialization.rb +4 -2
  38. data/lib/lutaml/model/serialize.rb +61 -0
  39. data/lib/lutaml/model/services/type/validator.rb +23 -0
  40. data/lib/lutaml/model/type/string.rb +28 -3
  41. data/lib/lutaml/model/type/value.rb +231 -0
  42. data/lib/lutaml/model/type.rb +2 -0
  43. data/lib/lutaml/model/validation.rb +2 -1
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/model.rb +16 -0
  46. data/lib/lutaml/xml/model_transform.rb +4 -1
  47. data/lib/lutaml/xml/schema/xsd/max_exclusive.rb +1 -1
  48. data/lib/lutaml/xml/schema/xsd/min_exclusive.rb +1 -1
  49. data/lib/lutaml/xml/schema/xsd_schema.rb +183 -11
  50. data/lib/lutaml/xml/transformation/order_reconciler.rb +267 -0
  51. data/lib/lutaml/xml/transformation/ordered_applier.rb +80 -122
  52. data/lib/lutaml/xml/transformation/rule_compiler.rb +2 -0
  53. data/lib/lutaml/xml/transformation.rb +2 -0
  54. data/lib/lutaml/xml/transformation_support.rb +1 -0
  55. data/spec/fixtures/xml/restriction_facets.xsd +46 -0
  56. data/spec/lutaml/model/attribute_restriction_spec.rb +472 -0
  57. data/spec/lutaml/model/attribute_spec.rb +7 -2
  58. data/spec/lutaml/model/collection_reader_liveness_spec.rb +516 -0
  59. data/spec/lutaml/model/conversion_caching_spec.rb +280 -0
  60. data/spec/lutaml/model/lazy_collection_spec.rb +28 -3
  61. data/spec/lutaml/model/parsed_model_mutation_spec.rb +486 -0
  62. data/spec/lutaml/model/phantom_value_spec.rb +799 -0
  63. data/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +1 -0
  64. data/spec/lutaml/model/schema/xsd_schema_spec.rb +340 -695
  65. data/spec/lutaml/model/serialize_perf_guard_spec.rb +53 -1
  66. data/spec/lutaml/model/type_digit_facet_spec.rb +229 -0
  67. data/spec/lutaml/model/type_facet_restriction_spec.rb +667 -0
  68. data/spec/lutaml/model/type_white_space_facet_spec.rb +132 -0
  69. data/spec/lutaml/xml/schema/compiler_spec.rb +302 -0
  70. data/spec/lutaml/xml/schema/facet_round_trip_spec.rb +153 -0
  71. metadata +27 -1
@@ -0,0 +1,516 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # A collection reader has to hand back a collection the caller can push onto.
6
+ #
7
+ # Four different readers broke that in four different ways, and only one of
8
+ # them made any noise:
9
+ # - the regular reader handed out LAZY_EMPTY_COLLECTION, one frozen Array
10
+ # shared by every instance, so `model.items << x` raised FrozenError;
11
+ # - the register-scoped reader substituted a fresh [] on every call, so the
12
+ # push landed on a throwaway and vanished without a word;
13
+ # - the enum reader returned `i.uniq`, also a fresh Array, same silence;
14
+ # - the reference reader resolved into a fresh Array, same silence again.
15
+ module CollectionReaderLivenessSpec
16
+ class Doc < Lutaml::Model::Serializable
17
+ attribute :name, :string
18
+ attribute :items, :string, collection: true
19
+ attribute :others, :string, collection: true
20
+
21
+ xml do
22
+ root "doc"
23
+ map_element "name", to: :name
24
+ map_element "item", to: :items
25
+ map_element "other", to: :others
26
+ end
27
+ end
28
+
29
+ class Filled < Lutaml::Model::Serializable
30
+ attribute :name, :string
31
+ attribute :items, :string, collection: true, initialize_empty: true
32
+
33
+ xml do
34
+ root "doc"
35
+ map_element "name", to: :name
36
+ map_element "item", to: :items
37
+ end
38
+
39
+ key_value do
40
+ map "name", to: :name
41
+ map "item", to: :items
42
+ end
43
+ end
44
+
45
+ # A model is allowed to define its own writer, and the generated reader has
46
+ # to cope with whatever that writer stored.
47
+ class RolesOwnWriter < Lutaml::Model::Serializable
48
+ attribute :roles, :integer, values: [1, 2, 3], collection: true
49
+
50
+ def roles=(value)
51
+ @roles = Set.new(value)
52
+ end
53
+ end
54
+
55
+ # A model whose own writer keeps the value somewhere else, so the enum ivar
56
+ # is never written and the reader meets a bare nil.
57
+ class RolesLazyWriter < Lutaml::Model::Serializable
58
+ attribute :roles, :string, values: %w[reader writer admin],
59
+ collection: true
60
+
61
+ def roles=(value)
62
+ @stashed = value
63
+ end
64
+ end
65
+
66
+ # A reader the model defines itself can hand back anything at all, including
67
+ # one array shared by every instance. Deliberately not duplicated — sharing
68
+ # is the whole point, and a writer that mutated it would reach every model.
69
+ SHARED_ROLES = [] # rubocop:disable Style/MutableConstant -- shared on purpose
70
+
71
+ class RolesOwnReader < Lutaml::Model::Serializable
72
+ attribute :roles, :string, values: %w[reader writer admin],
73
+ collection: true
74
+
75
+ def roles
76
+ @roles ||= SHARED_ROLES
77
+ end
78
+ end
79
+
80
+ class RolesSetReader < Lutaml::Model::Serializable
81
+ attribute :roles, :string, values: %w[reader writer admin],
82
+ collection: true
83
+
84
+ def roles
85
+ @roles ||= Set.new
86
+ end
87
+ end
88
+
89
+ class RolesFrozenDuplicates < Lutaml::Model::Serializable
90
+ attribute :roles, :string, values: %w[reader writer admin],
91
+ collection: true
92
+
93
+ def roles=(_value)
94
+ @roles = %w[admin admin].freeze
95
+ end
96
+ end
97
+
98
+ class RefAuthor < Lutaml::Model::Serializable
99
+ attribute :id, :string
100
+ attribute :name, :string
101
+
102
+ xml do
103
+ root "author"
104
+ map_element "id", to: :id
105
+ map_element "name", to: :name
106
+ end
107
+ end
108
+
109
+ class RefBook < Lutaml::Model::Serializable
110
+ attribute :id, :string
111
+ attribute :co_authors, { ref: ["CollectionReaderLivenessSpec::RefAuthor", :id] },
112
+ collection: true, default: []
113
+
114
+ xml do
115
+ root "book"
116
+ map_element "id", to: :id
117
+ map_element "coAuthor", to: :co_authors
118
+ end
119
+ end
120
+
121
+ class Roles < Lutaml::Model::Serializable
122
+ attribute :name, :string
123
+ attribute :roles, :string, values: %w[reader writer admin], collection: true
124
+
125
+ xml do
126
+ root "roles"
127
+ map_element "name", to: :name
128
+ map_element "role", to: :roles
129
+ end
130
+ end
131
+ end
132
+
133
+ RSpec.describe "what a collection reader hands back" do
134
+ let(:sentinel) { Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION }
135
+ let(:bare) { "<doc><name>n</name></doc>" }
136
+
137
+ describe "the regular collection reader" do
138
+ it "keeps an item pushed onto a collection the document omitted" do
139
+ model = CollectionReaderLivenessSpec::Doc.from_xml(bare)
140
+
141
+ model.items << "kept"
142
+
143
+ expect(model.items).to eq(["kept"])
144
+ end
145
+
146
+ it "carries that item into the document" do
147
+ model = CollectionReaderLivenessSpec::Doc.from_xml(bare)
148
+ model.items << "kept"
149
+
150
+ expect(model.to_xml.to_s).to include("<item>kept</item>")
151
+ end
152
+
153
+ it "gives each instance its own array" do
154
+ one = CollectionReaderLivenessSpec::Doc.from_xml(bare)
155
+ two = CollectionReaderLivenessSpec::Doc.from_xml(bare)
156
+
157
+ one.items << "mine"
158
+
159
+ expect(two.items).to eq([])
160
+ end
161
+
162
+ it "leaves the collections nobody read on the shared sentinel" do
163
+ model = CollectionReaderLivenessSpec::Doc.from_xml(bare)
164
+
165
+ model.items
166
+
167
+ expect(model.instance_variable_get(:@others)).to be(sentinel)
168
+ end
169
+
170
+ it "keeps the sentinel on a frozen model rather than raising" do
171
+ model = CollectionReaderLivenessSpec::Doc.from_xml(bare).freeze
172
+
173
+ expect(model.items).to be(sentinel)
174
+ end
175
+
176
+ # Reading must not be observable in the output. Every earlier round of
177
+ # this fix was refuted by a change in what got emitted.
178
+ %w[xml json yaml].each do |format|
179
+ it "does not change to_#{format} just because something read it" do
180
+ quiet = CollectionReaderLivenessSpec::Doc.from_xml(bare)
181
+ peeked = CollectionReaderLivenessSpec::Doc.from_xml(bare)
182
+ peeked.items
183
+ peeked.others
184
+
185
+ expect(peeked.public_send(:"to_#{format}").to_s)
186
+ .to eq(quiet.public_send(:"to_#{format}").to_s)
187
+ end
188
+ end
189
+ end
190
+
191
+ describe "the enum collection reader" do
192
+ it "keeps an item pushed onto an empty enum collection" do
193
+ model = CollectionReaderLivenessSpec::Roles.from_xml(
194
+ "<roles><name>n</name></roles>",
195
+ )
196
+
197
+ model.roles << "admin"
198
+
199
+ expect(model.roles).to eq(["admin"])
200
+ end
201
+
202
+ it "keeps an item pushed onto a populated enum collection" do
203
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
204
+ roles: ["reader"])
205
+
206
+ model.roles << "writer"
207
+
208
+ expect(model.roles).to eq(%w[reader writer])
209
+ end
210
+
211
+ it "carries that item into the document" do
212
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
213
+ roles: ["reader"])
214
+ model.roles << "writer"
215
+
216
+ expect(model.to_xml.to_s).to include("<role>writer</role>")
217
+ end
218
+
219
+ it "still hides duplicates the way the reader used to" do
220
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n")
221
+ model.roles = ["reader"]
222
+ model.roles = %w[reader writer]
223
+
224
+ expect(model.roles).to eq(%w[reader writer])
225
+ end
226
+
227
+ it "still answers the shorthand predicate" do
228
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
229
+ roles: ["admin"])
230
+
231
+ expect(model.admin?).to be(true)
232
+ expect(model.reader?).to be(false)
233
+ end
234
+
235
+ # Handing back the stored Array must not cost the guarantees the old
236
+ # `uniq` reader gave.
237
+ it "still reads unique after a duplicate is pushed through the reader" do
238
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
239
+ roles: ["admin"])
240
+
241
+ model.roles << "admin"
242
+
243
+ expect(model.roles).to eq(["admin"])
244
+ end
245
+
246
+ # The reader hands out the stored Array, so a writer must not mutate it.
247
+ # Every write builds a new Array, the way it did before the reader went
248
+ # live, which keeps a shared or frozen backing value safe.
249
+ it "does not write through into an array a caller already holds" do
250
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
251
+ roles: ["admin"])
252
+ held = model.roles
253
+
254
+ model.roles = ["reader"]
255
+
256
+ expect(held).to eq(["admin"])
257
+ expect(model.roles).to eq(%w[admin reader])
258
+ end
259
+
260
+ it "does not duplicate into a held array through the shorthand writer" do
261
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
262
+ roles: ["admin"])
263
+ held = model.roles
264
+
265
+ model.admin = true
266
+
267
+ expect(held).to eq(["admin"])
268
+ end
269
+
270
+ it "hands back a stored array even when nothing ever wrote the ivar" do
271
+ model = CollectionReaderLivenessSpec::RolesLazyWriter.new
272
+
273
+ model.roles << "admin"
274
+
275
+ expect(model.roles).to eq(["admin"])
276
+ end
277
+
278
+ it "does not write through into a reader the model defines itself" do
279
+ CollectionReaderLivenessSpec::SHARED_ROLES.clear
280
+ one = CollectionReaderLivenessSpec::RolesOwnReader.new
281
+ two = CollectionReaderLivenessSpec::RolesOwnReader.new
282
+
283
+ one.roles = ["admin"]
284
+
285
+ expect(one.roles).to eq(["admin"])
286
+ expect(two.roles).to eq([])
287
+ expect(CollectionReaderLivenessSpec::SHARED_ROLES).to eq([])
288
+ end
289
+
290
+ it "assigns through a reader that hands back something other than an array" do
291
+ model = CollectionReaderLivenessSpec::RolesSetReader.new
292
+
293
+ model.roles = ["admin"]
294
+
295
+ expect(model.roles).to eq(Set.new(["admin"]))
296
+ end
297
+
298
+ it "does not remove through into a held array via the shorthand writer" do
299
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
300
+ roles: %w[admin reader])
301
+ held = model.roles
302
+
303
+ model.admin = false
304
+
305
+ expect(held).to eq(%w[admin reader])
306
+ expect(model.roles).to eq(["reader"])
307
+ end
308
+
309
+ it "does not mutate through the shorthand writer on a frozen model" do
310
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
311
+ roles: ["admin"])
312
+ held = model.roles
313
+ model.freeze
314
+
315
+ expect { model.writer = true }.to raise_error(FrozenError)
316
+ expect(held).to eq(["admin"])
317
+ end
318
+
319
+ it "reads a frozen backing array as unique" do
320
+ model = CollectionReaderLivenessSpec::RolesFrozenDuplicates.new
321
+ model.roles = ["admin"]
322
+
323
+ expect(model.roles).to eq(["admin"])
324
+ end
325
+
326
+ it "leaves a backing value its own writer stored alone" do
327
+ model = CollectionReaderLivenessSpec::RolesOwnWriter.new
328
+ model.roles = [1, 2]
329
+
330
+ expect(model.roles).to eq([1, 2])
331
+ expect(model.instance_variable_get(:@roles)).to eq(Set.new([1, 2]))
332
+ end
333
+
334
+ it "does not touch the collection when a frozen model rejects the write" do
335
+ model = CollectionReaderLivenessSpec::Roles.new(name: "n",
336
+ roles: ["admin"])
337
+ model.roles
338
+ model.freeze
339
+
340
+ expect { model.roles = ["reader"] }.to raise_error(FrozenError)
341
+ expect(model.instance_variable_get(:@roles)).to eq(["admin"])
342
+ end
343
+ end
344
+
345
+ describe "the register-scoped collection reader" do
346
+ # initialization.rb defines its own pair of collection methods. Round 5
347
+ # could not reach them through the public API, so drive the generator
348
+ # directly rather than assume the row is unreachable and therefore fine.
349
+ let(:host) do
350
+ Class.new(Lutaml::Model::Serializable) do
351
+ def self.name
352
+ "CollectionReaderLivenessSpec::RegisterScopedHost"
353
+ end
354
+
355
+ attribute :notes, :string, collection: true
356
+ end
357
+ end
358
+
359
+ let(:instance) do
360
+ host.send(:remove_method, :notes)
361
+ host.send(:remove_method, :notes=)
362
+ host.singleton_class.send(:public, :define_collection_register_methods)
363
+ host.define_collection_register_methods(:notes)
364
+
365
+ inst = host.allocate
366
+ inst.send(:finalize_deserialization, nil)
367
+ inst
368
+ end
369
+
370
+ it "is the reader under test" do
371
+ expect(instance.method(:notes).source_location.first)
372
+ .to end_with("serialize/initialization.rb")
373
+ end
374
+
375
+ it "keeps an item pushed through it" do
376
+ instance.notes << "kept"
377
+
378
+ expect(instance.notes).to eq(["kept"])
379
+ end
380
+
381
+ it "keeps an item appended builder-style" do
382
+ instance.notes("kept")
383
+
384
+ expect(instance.notes).to eq(["kept"])
385
+ end
386
+ end
387
+
388
+ describe "the reference collection reader" do
389
+ let(:author) do
390
+ CollectionReaderLivenessSpec::RefAuthor.new(id: "a2", name: "Two")
391
+ end
392
+
393
+ it "keeps an item pushed onto an empty reference collection" do
394
+ book = CollectionReaderLivenessSpec::RefBook.new(id: "b")
395
+
396
+ book.co_authors << author
397
+
398
+ expect(book.co_authors).to eq([author])
399
+ end
400
+
401
+ it "keeps an item pushed onto a populated reference collection" do
402
+ CollectionReaderLivenessSpec::RefAuthor.new(id: "a1", name: "One")
403
+ book = CollectionReaderLivenessSpec::RefBook.new(id: "b",
404
+ co_authors: ["a1"])
405
+
406
+ book.co_authors << author
407
+
408
+ expect(book.co_authors.map(&:id)).to eq(%w[a1 a2])
409
+ end
410
+
411
+ it "carries that item into the document" do
412
+ book = CollectionReaderLivenessSpec::RefBook.new(id: "b")
413
+ book.co_authors << author
414
+
415
+ expect(book.to_xml.to_s).to include("<coAuthor>a2</coAuthor>")
416
+ end
417
+
418
+ it "still reads the keys back once the objects are stored" do
419
+ CollectionReaderLivenessSpec::RefAuthor.new(id: "a1", name: "One")
420
+ book = CollectionReaderLivenessSpec::RefBook.new(id: "b",
421
+ co_authors: ["a1"])
422
+
423
+ book.co_authors # resolves, and stores what it resolved
424
+
425
+ expect(book.co_authors_ids).to eq(["a1"])
426
+ end
427
+
428
+ it "stays live when the collection also holds a raw nil" do
429
+ CollectionReaderLivenessSpec::RefAuthor.new(id: "a1", name: "One")
430
+ book = CollectionReaderLivenessSpec::RefBook.new(
431
+ id: "b", co_authors: ["a1", nil],
432
+ )
433
+
434
+ book.co_authors << author
435
+
436
+ expect(book.co_authors.size).to eq(3)
437
+ expect(book.co_authors[1]).to be_nil
438
+ expect(book.co_authors.compact.map(&:id)).to eq(%w[a1 a2])
439
+ end
440
+
441
+ it "keeps re-resolving while any reference is still dangling" do
442
+ book = CollectionReaderLivenessSpec::RefBook.new(id: "b",
443
+ co_authors: ["late"])
444
+
445
+ expect(book.co_authors).to eq([nil])
446
+
447
+ CollectionReaderLivenessSpec::RefAuthor.new(id: "late", name: "Late")
448
+
449
+ expect(book.co_authors.map(&:id)).to eq(["late"])
450
+ end
451
+ end
452
+
453
+ describe "every construction entry point" do
454
+ # The readers above are reached through from_xml. A collection that the
455
+ # model actually holds has to come back live from all of them, not just
456
+ # that one.
457
+ let(:entry_points) do
458
+ {
459
+ "new" => -> { CollectionReaderLivenessSpec::Filled.new },
460
+ "new with attributes" => lambda {
461
+ CollectionReaderLivenessSpec::Filled.new(name: "n")
462
+ },
463
+ "from_xml" => lambda {
464
+ CollectionReaderLivenessSpec::Filled.from_xml(bare)
465
+ },
466
+ "from_json" => lambda {
467
+ CollectionReaderLivenessSpec::Filled.from_json('{"name":"n"}')
468
+ },
469
+ "from_yaml" => lambda {
470
+ CollectionReaderLivenessSpec::Filled.from_yaml("name: n\n")
471
+ },
472
+ "from_hash" => lambda {
473
+ CollectionReaderLivenessSpec::Filled.from_hash({ "name" => "n" })
474
+ },
475
+ "from_toml" => lambda {
476
+ CollectionReaderLivenessSpec::Filled.from_toml(%(name = "n"\n))
477
+ },
478
+ "dup" => lambda {
479
+ CollectionReaderLivenessSpec::Filled.from_xml(bare).dup
480
+ },
481
+ "clone" => lambda {
482
+ CollectionReaderLivenessSpec::Filled.from_xml(bare).clone
483
+ },
484
+ }
485
+ end
486
+
487
+ it "hands back the same array on every read" do
488
+ results = entry_points.transform_values do |build|
489
+ model = build.call
490
+ model.items.equal?(model.items)
491
+ end
492
+
493
+ expect(results).to all(satisfy { |_name, same| same })
494
+ end
495
+
496
+ it "keeps an item pushed through the reader" do
497
+ results = entry_points.transform_values do |build|
498
+ model = build.call
499
+ model.items << "kept"
500
+ model.items
501
+ end
502
+
503
+ expect(results.values).to all(eq(["kept"]))
504
+ end
505
+
506
+ it "carries that item into the document" do
507
+ results = entry_points.transform_values do |build|
508
+ model = build.call
509
+ model.items << "kept"
510
+ model.to_xml.to_s.include?("<item>kept</item>")
511
+ end
512
+
513
+ expect(results).to all(satisfy { |_name, reached| reached })
514
+ end
515
+ end
516
+ end