lutaml-model 0.8.32 → 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 +47 -6
  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,486 @@
1
+ require "spec_helper"
2
+ require_relative "../../../lib/lutaml/model"
3
+
4
+ # Regression coverage for the parsed-vs-built divergence: a model parsed from
5
+ # a document carries element_order, and mutations made after parsing used to
6
+ # be dropped during serialization.
7
+ #
8
+ # Every example here PARSES input, mutates, then serializes, and asserts on
9
+ # the emitted document. Asserting on attribute reads is what hid these bugs —
10
+ # the value always reads back correctly.
11
+ module ParsedModelMutationSpec
12
+ class Child < Lutaml::Model::Serializable
13
+ attribute :v, :string
14
+
15
+ xml do
16
+ root "child"
17
+ map_attribute "v", to: :v
18
+ end
19
+ end
20
+
21
+ class Mixed < Lutaml::Model::Serializable
22
+ attribute :a, Child
23
+ attribute :b, Child
24
+
25
+ xml do
26
+ root "p"
27
+ mixed_content
28
+ map_element "a", to: :a
29
+ map_element "b", to: :b
30
+ end
31
+ end
32
+
33
+ class MixColl < Lutaml::Model::Serializable
34
+ attribute :a, Child, collection: true
35
+ attribute :b, Child
36
+
37
+ xml do
38
+ root "p"
39
+ mixed_content
40
+ map_element "a", to: :a
41
+ map_element "b", to: :b
42
+ end
43
+ end
44
+
45
+ class EmptyColl < Lutaml::Model::Serializable
46
+ attribute :lead, :string
47
+ attribute :blank_topic, :string, collection: true
48
+ attribute :nil_topic, :string, collection: true
49
+ attribute :plain_topic, :string, collection: true
50
+
51
+ xml do
52
+ root "s"
53
+ ordered
54
+ map_element "lead", to: :lead
55
+ map_element "blank", to: :blank_topic, render_empty: :as_blank
56
+ map_element "nil", to: :nil_topic, render_empty: :as_nil
57
+ map_element "plain", to: :plain_topic
58
+ end
59
+ end
60
+
61
+ class NilColl < Lutaml::Model::Serializable
62
+ attribute :lead, :string
63
+ attribute :items, :string, collection: true
64
+
65
+ xml do
66
+ root "s"
67
+ ordered
68
+ map_element "lead", to: :lead
69
+ map_element "item", to: :items, render_nil: :as_nil
70
+ end
71
+ end
72
+
73
+ class DefaultColl < Lutaml::Model::Serializable
74
+ attribute :lead, :string
75
+ attribute :items, :string, collection: true, default: -> { [] }
76
+
77
+ xml do
78
+ root "s"
79
+ ordered
80
+ map_element "lead", to: :lead
81
+ map_element "item", to: :items
82
+ end
83
+ end
84
+
85
+ class DerivedOrdered < Lutaml::Model::Serializable
86
+ attribute :lead, :string
87
+ attribute :calc, :string, method: :calc_value
88
+
89
+ xml do
90
+ root "s"
91
+ ordered
92
+ map_element "lead", to: :lead
93
+ map_element "calc", to: :calc
94
+ end
95
+
96
+ def calc_value
97
+ "D"
98
+ end
99
+ end
100
+
101
+ class Aliased < Lutaml::Model::Serializable
102
+ attribute :items, :string, collection: true
103
+
104
+ xml do
105
+ root "p"
106
+ ordered
107
+ map_element %w[item old-item], to: :items
108
+ end
109
+ end
110
+
111
+ class Glaze < Lutaml::Model::Serializable
112
+ attribute :color, :string
113
+ end
114
+
115
+ class Delegating < Lutaml::Model::Serializable
116
+ attribute :glaze, Glaze
117
+ attribute :other, :string
118
+
119
+ xml do
120
+ root "d"
121
+ ordered
122
+ map_element "color", to: :color, delegate: :glaze
123
+ map_element "other", to: :other
124
+ end
125
+ end
126
+
127
+ class CustomColl < Lutaml::Model::Serializable
128
+ attribute :items, :string, collection: true
129
+
130
+ xml do
131
+ root "p"
132
+ ordered
133
+ map_element "item", to: :items, with: { to: :items_to, from: :items_from }
134
+ end
135
+
136
+ def items_to(model, parent, doc)
137
+ Array(model.items).each do |v|
138
+ el = doc.create_element("item")
139
+ doc.add_text(el, v)
140
+ parent.add_child(el)
141
+ end
142
+ end
143
+
144
+ def items_from(model, values)
145
+ model.items = Array(values).map(&:to_s)
146
+ end
147
+ end
148
+
149
+ class Inner < Lutaml::Model::Serializable
150
+ attribute :x, :string
151
+ end
152
+
153
+ class DelegatingAlias < Lutaml::Model::Serializable
154
+ attribute :inner, Inner
155
+ attribute :other, :string
156
+
157
+ xml do
158
+ root "d"
159
+ ordered
160
+ map_element %w[x old-x], to: :x, delegate: :inner
161
+ map_element "other", to: :other
162
+ end
163
+ end
164
+
165
+ class SameNameA < Lutaml::Model::Serializable
166
+ attribute :v, :string
167
+
168
+ xml do
169
+ root "sa"
170
+ map_attribute "v", to: :v
171
+ end
172
+ end
173
+
174
+ class SameNameB < Lutaml::Model::Serializable
175
+ attribute :w, :string
176
+
177
+ xml do
178
+ root "sb"
179
+ map_attribute "w", to: :w
180
+ end
181
+ end
182
+
183
+ class AmbiguousNames < Lutaml::Model::Serializable
184
+ attribute :a, SameNameA
185
+ attribute :b, SameNameB
186
+
187
+ xml do
188
+ root "p"
189
+ ordered
190
+ map_element "same", to: :a
191
+ map_element "same", to: :b
192
+ end
193
+ end
194
+
195
+ class CustomMethod < Lutaml::Model::Serializable
196
+ attribute :name, :string
197
+
198
+ xml do
199
+ root "c"
200
+ ordered
201
+ map_element "label", with: { to: :label_to_xml, from: :label_from_xml }
202
+ map_element "name", to: :name
203
+ end
204
+
205
+ def label_to_xml(model, parent, doc)
206
+ el = doc.create_element("label")
207
+ doc.add_text(el, "L:#{model.name}")
208
+ parent.add_child(el)
209
+ end
210
+
211
+ def label_from_xml(model, value)
212
+ model.name = Array(value).first.to_s.sub(/^L:/, "")
213
+ end
214
+ end
215
+ end
216
+
217
+ RSpec.describe "parsed model mutation" do
218
+ # Defect 1: mixed_content dropped an attribute set after parse.
219
+ describe "singular element set after parse" do
220
+ it "emits the element that was set" do
221
+ model = ParsedModelMutationSpec::Mixed.from_xml('<p><a v="1"/></p>')
222
+ model.b = ParsedModelMutationSpec::Child.new(v: "2")
223
+
224
+ expect(model.to_xml).to include('<b v="2"/>').or include('<b v="2"></b>')
225
+ end
226
+
227
+ it "emits it in mapping-declaration order" do
228
+ model = ParsedModelMutationSpec::Mixed.from_xml('<p><b v="2"/></p>')
229
+ model.a = ParsedModelMutationSpec::Child.new(v: "1")
230
+
231
+ expect(model.to_xml.index("<a ")).to be < model.to_xml.index("<b ")
232
+ end
233
+
234
+ it "leaves an untouched round-trip unchanged" do
235
+ xml = '<p><a v="1"/></p>'
236
+ model = ParsedModelMutationSpec::Mixed.from_xml(xml)
237
+
238
+ expect(model.to_xml).not_to include("<b")
239
+ end
240
+
241
+ it "is idempotent across repeated serialization" do
242
+ model = ParsedModelMutationSpec::Mixed.from_xml('<p><a v="1"/></p>')
243
+ model.b = ParsedModelMutationSpec::Child.new(v: "2")
244
+
245
+ first = model.to_xml
246
+ second = model.to_xml
247
+
248
+ expect(second).to eq(first)
249
+ expect(second.scan("<b").size).to eq(1)
250
+ end
251
+ end
252
+
253
+ # Defect 2: mixed_content ignored collection growth.
254
+ describe "collection grown after parse" do
255
+ it "emits every item" do
256
+ model = ParsedModelMutationSpec::MixColl.from_xml('<p><a v="1"/></p>')
257
+ model.a = [
258
+ ParsedModelMutationSpec::Child.new(v: "1"),
259
+ ParsedModelMutationSpec::Child.new(v: "2"),
260
+ ]
261
+
262
+ expect(model.to_xml.scan("<a ").size).to eq(2)
263
+ end
264
+
265
+ it "still emits the right count when the collection shrinks" do
266
+ model = ParsedModelMutationSpec::MixColl
267
+ .from_xml('<p><a v="1"/><a v="2"/></p>')
268
+ model.a = [ParsedModelMutationSpec::Child.new(v: "9")]
269
+
270
+ expect(model.to_xml.scan("<a ").size).to eq(1)
271
+ end
272
+
273
+ it "appends new items after existing ones, not before an interleaved element" do
274
+ model = ParsedModelMutationSpec::MixColl
275
+ .from_xml('<p><a v="1"/><b v="B"/><a v="2"/></p>')
276
+ model.a = [
277
+ ParsedModelMutationSpec::Child.new(v: "1"),
278
+ ParsedModelMutationSpec::Child.new(v: "2"),
279
+ ParsedModelMutationSpec::Child.new(v: "3"),
280
+ ]
281
+
282
+ xml = model.to_xml
283
+ expect(xml.index('v="3"')).to be > xml.index('v="2"')
284
+ end
285
+
286
+ it "shares one counter across canonical and alias element names" do
287
+ model = ParsedModelMutationSpec::Aliased
288
+ .from_xml("<p><old-item>x</old-item></p>")
289
+ model.items = %w[x y]
290
+
291
+ xml = model.to_xml
292
+ expect(xml).to include("x")
293
+ expect(xml).to include("y")
294
+ end
295
+ end
296
+
297
+ # Defect 3: element_order was frozen on parsed models.
298
+ describe "element_order on a parsed model" do
299
+ it "is mutable" do
300
+ model = ParsedModelMutationSpec::Mixed.from_xml('<p><a v="1"/></p>')
301
+
302
+ expect { model.element_order << "x" }.not_to raise_error
303
+ end
304
+ end
305
+
306
+ describe "empty collection assigned after parse" do
307
+ it "emits a blank element under render_empty: :as_blank" do
308
+ model = ParsedModelMutationSpec::EmptyColl.from_xml("<s><lead>L</lead></s>")
309
+ model.blank_topic = []
310
+
311
+ expect(model.to_xml).to include("<blank")
312
+ end
313
+
314
+ it "emits a nil element under render_empty: :as_nil" do
315
+ model = ParsedModelMutationSpec::EmptyColl.from_xml("<s><lead>L</lead></s>")
316
+ model.nil_topic = []
317
+
318
+ expect(model.to_xml).to include("<nil")
319
+ end
320
+
321
+ it "emits nothing without render_empty" do
322
+ model = ParsedModelMutationSpec::EmptyColl.from_xml("<s><lead>L</lead></s>")
323
+ model.plain_topic = []
324
+
325
+ expect(model.to_xml).not_to include("<plain")
326
+ end
327
+ end
328
+
329
+ # A collection set to nil is never iterated, so it must be counted as one
330
+ # element rather than measured for length.
331
+ describe "collection set to nil after parse" do
332
+ it "renders it as a nil element under render_nil" do
333
+ model = ParsedModelMutationSpec::NilColl
334
+ .from_xml("<s><lead>L</lead><item>x</item></s>")
335
+ model.items = nil
336
+
337
+ expect(model.to_xml).to include("nil=\"true\"")
338
+ end
339
+
340
+ it "emits nothing when the element was absent" do
341
+ model = ParsedModelMutationSpec::NilColl.from_xml("<s><lead>L</lead></s>")
342
+ model.items = nil
343
+
344
+ expect(model.to_xml).not_to include("<item")
345
+ end
346
+ end
347
+
348
+ # Pushing onto a default collection never reaches the setter, so the
349
+ # attribute still reads as "using default" while holding real data.
350
+ describe "default collection mutated in place" do
351
+ it "emits the pushed item on a built model" do
352
+ model = ParsedModelMutationSpec::DefaultColl.new { |m| m.lead = "L" }
353
+ model.items << "x"
354
+
355
+ expect(model.to_xml).to include("<item>x</item>")
356
+ end
357
+
358
+ it "emits the pushed item on a parsed model" do
359
+ model = ParsedModelMutationSpec::DefaultColl
360
+ .from_xml("<s><lead>L</lead></s>")
361
+ model.items << "y"
362
+
363
+ expect(model.to_xml).to include("<item>y</item>")
364
+ end
365
+
366
+ it "emits nothing when the default collection stays empty" do
367
+ model = ParsedModelMutationSpec::DefaultColl
368
+ .from_xml("<s><lead>L</lead></s>")
369
+
370
+ expect(model.to_xml).not_to include("<item")
371
+ end
372
+ end
373
+
374
+ # A derived attribute always reads as "using default", so the default
375
+ # guard has to exempt it the way RenderPolicy does.
376
+ describe "derived attribute on an ordered mapping" do
377
+ it "is emitted whether or not element_order is populated" do
378
+ empty_order = ParsedModelMutationSpec::DerivedOrdered.from_xml("<s/>")
379
+ with_order = ParsedModelMutationSpec::DerivedOrdered
380
+ .from_xml("<s><lead>L</lead></s>")
381
+
382
+ expect(empty_order.to_xml).to include("<calc>D</calc>")
383
+ expect(with_order.to_xml).to include("<calc>D</calc>")
384
+ end
385
+ end
386
+
387
+ describe "delegated element set after parse" do
388
+ it "emits the delegated value" do
389
+ model = ParsedModelMutationSpec::Delegating.from_xml("<d><other>o</other></d>")
390
+ model.glaze = ParsedModelMutationSpec::Glaze.new(color: "red")
391
+
392
+ expect(model.to_xml).to include("red")
393
+ end
394
+ end
395
+
396
+ describe "custom-method element rule on an ordered mapping" do
397
+ it "serializes without raising" do
398
+ model = ParsedModelMutationSpec::CustomMethod
399
+ .from_xml("<c><label>L:x</label><name>x</name></c>")
400
+
401
+ expect { model.to_xml }.not_to raise_error
402
+ end
403
+
404
+ # A custom `to:` emits the whole collection itself and is invoked once
405
+ # per matching entry, so one entry must mean one invocation.
406
+ it "invokes a custom collection transform once, not once per item" do
407
+ model = ParsedModelMutationSpec::CustomColl.from_xml("<p><item>a</item></p>")
408
+ model.items = %w[a b]
409
+
410
+ expect(model.to_xml.scan("<item>").size).to eq(2)
411
+ end
412
+ end
413
+
414
+ # Two rules mapping one element name cannot be told apart by the ordered
415
+ # dispatcher, which resolves an entry to the first matching rule. That is
416
+ # a pre-existing limitation, so these specs pin the boundaries of what
417
+ # reconciliation may do around it rather than the dispatcher's output.
418
+ describe "two element rules sharing one serialized name" do
419
+ it "emits both values when built" do
420
+ model = ParsedModelMutationSpec::AmbiguousNames.new do |m|
421
+ m.a = ParsedModelMutationSpec::SameNameA.new(v: "1")
422
+ m.b = ParsedModelMutationSpec::SameNameB.new(w: "2")
423
+ end
424
+
425
+ expect(model.to_xml).to include('v="1"')
426
+ expect(model.to_xml).to include('w="2"')
427
+ end
428
+
429
+ # A parsed entry stands for every rule that shares its name, so
430
+ # reconciliation must not emit the ones the dispatcher skipped —
431
+ # doing so duplicates the element on a plain round-trip.
432
+ it "does not duplicate the element on a round-trip" do
433
+ xml = '<p><same v="1"/></p>'
434
+ model = ParsedModelMutationSpec::AmbiguousNames.from_xml(xml)
435
+
436
+ expect(model.to_xml.scan("<same").size).to eq(1)
437
+ end
438
+ end
439
+
440
+ describe "delegated element with name aliases" do
441
+ it "keeps the parsed document order" do
442
+ model = ParsedModelMutationSpec::DelegatingAlias
443
+ .from_xml("<d><other>o</other><old-x>V</old-x></d>")
444
+
445
+ xml = model.to_xml
446
+ expect(xml).to include("V")
447
+ expect(xml.index("other")).to be < xml.index("V")
448
+ end
449
+ end
450
+
451
+ describe "builder-block construction" do
452
+ it "emits attributes supplied by hash in declaration order" do
453
+ model = ParsedModelMutationSpec::EmptyColl.new(lead: "L") do |m|
454
+ m.plain_topic = ["t"]
455
+ end
456
+
457
+ xml = model.to_xml
458
+ expect(xml.index("<lead")).to be < xml.index("<plain")
459
+ end
460
+ end
461
+
462
+ describe "foreign entries in element_order" do
463
+ # element_order is a plain public accessor, so arrays holding objects
464
+ # that are not order entries (no #type) exist in the wild. Regression:
465
+ # reconciliation used to call #type on every entry and crash with
466
+ # NoMethodError for an instance of Integer.
467
+ it "serializes without raising when element_order holds an Integer" do
468
+ model = ParsedModelMutationSpec::NilColl
469
+ .from_xml("<s><lead>L</lead><item>a</item></s>")
470
+ model.element_order << 42
471
+
472
+ expect { model.to_xml }.not_to raise_error
473
+ end
474
+
475
+ it "still reconciles the well-formed entries around a foreign one" do
476
+ model = ParsedModelMutationSpec::NilColl
477
+ .from_xml("<s><lead>L</lead><item>a</item></s>")
478
+ model.element_order.insert(1, 42)
479
+ model.items << "b"
480
+
481
+ xml = model.to_xml
482
+ expect(xml.scan(%r{<lead>L</lead>|<item>a</item>|<item>b</item>}))
483
+ .to eq(["<lead>L</lead>", "<item>a</item>", "<item>b</item>"])
484
+ end
485
+ end
486
+ end