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,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe "whiteSpace facet (cast-time normalization)" do
6
+ describe ":collapse mode" do
7
+ before do
8
+ stub_const("CollapsedToken", Class.new(Lutaml::Model::Type::String) do
9
+ white_space :collapse
10
+ end)
11
+ stub_const("Doc", Class.new(Lutaml::Model::Serializable) do
12
+ attribute :token, CollapsedToken
13
+ end)
14
+ end
15
+
16
+ it "collapses internal runs and strips edges at cast time" do
17
+ expect(CollapsedToken.cast(" a\t b\n\n c ")).to eq("a b c")
18
+ end
19
+
20
+ it "normalizes the value stored on the model instance" do
21
+ expect(Doc.new(token: " hello world ").token).to eq("hello world")
22
+ end
23
+
24
+ it "normalizes values loaded from a serialized document" do
25
+ expect(Doc.from_yaml("token: \" hello world \"\n").token)
26
+ .to eq("hello world")
27
+ end
28
+ end
29
+
30
+ describe ":replace mode" do
31
+ before do
32
+ stub_const("ReplacedToken", Class.new(Lutaml::Model::Type::String) do
33
+ white_space :replace
34
+ end)
35
+ end
36
+
37
+ it "replaces tab/newline/carriage-return with a single space" do
38
+ expect(ReplacedToken.cast("a\tb\nc\rd")).to eq("a b c d")
39
+ end
40
+
41
+ it "keeps existing spaces and edges (no collapse, no strip)" do
42
+ expect(ReplacedToken.cast(" a\tb ")).to eq(" a b ")
43
+ end
44
+ end
45
+
46
+ describe ":preserve mode" do
47
+ before do
48
+ stub_const("PreservedToken", Class.new(Lutaml::Model::Type::String) do
49
+ white_space :preserve
50
+ end)
51
+ end
52
+
53
+ it "leaves the value untouched" do
54
+ expect(PreservedToken.cast(" a\t b ")).to eq(" a\t b ")
55
+ end
56
+ end
57
+
58
+ describe "declaration guards" do
59
+ it "rejects an unknown mode" do
60
+ expect do
61
+ Class.new(Lutaml::Model::Type::String) { white_space :squash }
62
+ end.to raise_error(ArgumentError, /must be one of/)
63
+ end
64
+
65
+ it "rejects white_space on a non-string type at declaration" do
66
+ expect do
67
+ Class.new(Lutaml::Model::Type::Integer) { white_space :collapse }
68
+ end.to raise_error(ArgumentError, /string-derived/)
69
+ end
70
+ end
71
+
72
+ describe "inheritance (stricter wins)" do
73
+ before do
74
+ stub_const("ReplaceBase", Class.new(Lutaml::Model::Type::String) do
75
+ white_space :replace
76
+ end)
77
+ end
78
+
79
+ it "lets a child tighten to a stricter mode" do
80
+ child = Class.new(ReplaceBase) { white_space :collapse }
81
+
82
+ expect(child.facets[:white_space]).to eq(:collapse)
83
+ end
84
+
85
+ it "raises when a child loosens the inherited mode" do
86
+ child = Class.new(ReplaceBase) { white_space :preserve }
87
+
88
+ expect { child.facets }.to raise_error(ArgumentError, /cannot widen/)
89
+ end
90
+
91
+ it "computes each subclass's effective mode independently" do
92
+ strict = Class.new(ReplaceBase) { white_space :collapse }
93
+ plain = Class.new(ReplaceBase)
94
+
95
+ expect(strict.white_space_mode).to eq(:collapse)
96
+ expect(plain.white_space_mode).to eq(:replace)
97
+ end
98
+ end
99
+
100
+ describe "memo invalidation when a facet is declared after a cast" do
101
+ it "recomputes the effective mode for a cast-before-declare sequence" do
102
+ cls = Class.new(Lutaml::Model::Type::String)
103
+ expect(cls.cast("a\tb")).to eq("a\tb")
104
+
105
+ cls.white_space :collapse
106
+
107
+ expect(cls.white_space_mode).to eq(:collapse)
108
+ expect(cls.cast(" a\t b ")).to eq("a b")
109
+ end
110
+ end
111
+
112
+ describe "plain :string is unaffected (fast path intact)" do
113
+ before do
114
+ stub_const("Plain", Class.new(Lutaml::Model::Serializable) do
115
+ attribute :s, :string
116
+ end)
117
+ end
118
+
119
+ it "does not normalize a plain string attribute" do
120
+ expect(Plain.new(s: " a b ").s).to eq(" a b ")
121
+ end
122
+
123
+ it "returns the identical object via the EMPTY_OPTIONS fast path" do
124
+ raw = " a b "
125
+ cast = Lutaml::Model::Type::String.cast(
126
+ raw, Lutaml::Model::Type::Value::EMPTY_OPTIONS
127
+ )
128
+
129
+ expect(cast).to equal(raw)
130
+ end
131
+ end
132
+ end
@@ -146,6 +146,308 @@ RSpec.describe Lutaml::Model::Schema::XmlCompiler do
146
146
  end
147
147
  end
148
148
 
149
+ context "when compiling constraining facets (issue #191 from_xsd)" do
150
+ let!(:facets_dir) { Dir.mktmpdir }
151
+ let(:item) { RestrictionFacetsSpec::CTItem }
152
+
153
+ before do
154
+ described_class.to_models(
155
+ File.read("spec/fixtures/xml/restriction_facets.xsd"),
156
+ output_dir: facets_dir,
157
+ create_files: true,
158
+ module_namespace: "RestrictionFacetsSpec",
159
+ )
160
+ require File.join(facets_dir, "restrictionfacetsspec_registry.rb")
161
+ RestrictionFacetsSpec.register_all
162
+ end
163
+
164
+ after do
165
+ FileUtils.rm_rf(facets_dir)
166
+ if defined?(RestrictionFacetsSpec)
167
+ Object.send(:remove_const, :RestrictionFacetsSpec)
168
+ end
169
+ end
170
+
171
+ it "validates a fully in-range item with no errors" do
172
+ model = item.new(code: "ab", ratio: 50, price: "123.45",
173
+ moment: "2020-06-15T12:00:00")
174
+ expect(model.validate).to be_empty
175
+ end
176
+
177
+ it "collects a MinLengthError for a too-short string (minLength)" do
178
+ expect(item.new(code: "a").validate.first)
179
+ .to be_a(Lutaml::Model::MinLengthError)
180
+ end
181
+
182
+ it "collects a MaxLengthError for a too-long string (maxLength)" do
183
+ expect(item.new(code: "toolongvalue").validate.first)
184
+ .to be_a(Lutaml::Model::MaxLengthError)
185
+ end
186
+
187
+ it "collects a MaxExclusiveError at the exclusive upper bound" do
188
+ expect(item.new(ratio: 100).validate.first)
189
+ .to be_a(Lutaml::Model::MaxExclusiveError)
190
+ end
191
+
192
+ it "collects a MinExclusiveError at the exclusive lower bound" do
193
+ expect(item.new(ratio: 0).validate.first)
194
+ .to be_a(Lutaml::Model::MinExclusiveError)
195
+ end
196
+
197
+ it "collects a TotalDigitsError beyond the total-digit cap" do
198
+ expect(item.new(price: "1234.56").validate.first)
199
+ .to be_a(Lutaml::Model::TotalDigitsError)
200
+ end
201
+
202
+ it "collects a FractionDigitsError beyond the fraction-digit cap" do
203
+ expect(item.new(price: "1.234").validate.first)
204
+ .to be_a(Lutaml::Model::FractionDigitsError)
205
+ end
206
+
207
+ it "collects a MinInclusiveError before the temporal lower bound" do
208
+ expect(item.new(moment: "2019-12-31T00:00:00").validate.first)
209
+ .to be_a(Lutaml::Model::MinInclusiveError)
210
+ end
211
+
212
+ it "accepts a boolean value allowed by a cast enumeration" do
213
+ expect(item.new(flag: true).validate).to be_empty
214
+ end
215
+
216
+ it "collects an InvalidValueError for a disallowed boolean enum value" do
217
+ expect(item.new(flag: false).validate.first)
218
+ .to be_a(Lutaml::Model::InvalidValueError)
219
+ end
220
+
221
+ it "normalizes whitespace at cast time (whiteSpace collapse)" do
222
+ expect(item.new(label: " a b ").label).to eq("a b")
223
+ end
224
+
225
+ it "carries the error attribute name" do
226
+ error = item.new(code: "a").validate.first
227
+ expect(error.to_s).to include("code")
228
+ end
229
+ end
230
+
231
+ context "when compiling a built-in interpolated pattern (issue #191 anyURI)" do
232
+ let!(:uri_dir) { Dir.mktmpdir }
233
+ let(:link) { AnyUriPatternSpec::CTLink }
234
+ let(:uri_schema) do
235
+ <<~XSD
236
+ <?xml version="1.0" encoding="utf-8"?>
237
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
238
+ <xsd:complexType name="CT_Link">
239
+ <xsd:attribute name="href" type="xsd:anyURI" />
240
+ </xsd:complexType>
241
+ <xsd:element name="Link" type="CT_Link" />
242
+ </xsd:schema>
243
+ XSD
244
+ end
245
+
246
+ before do
247
+ described_class.to_models(
248
+ uri_schema,
249
+ output_dir: uri_dir,
250
+ create_files: true,
251
+ module_namespace: "AnyUriPatternSpec",
252
+ )
253
+ require File.join(uri_dir, "anyuripatternspec_registry.rb")
254
+ AnyUriPatternSpec.register_all
255
+ end
256
+
257
+ after do
258
+ FileUtils.rm_rf(uri_dir)
259
+ if defined?(AnyUriPatternSpec)
260
+ Object.send(:remove_const, :AnyUriPatternSpec)
261
+ end
262
+ end
263
+
264
+ # Regression: the lazy pattern facet must keep the built-in anyURI
265
+ # pattern's live `#{...}` interpolation, else every valid URI is
266
+ # falsely rejected at validate time.
267
+ it "accepts a valid URI through the lazy interpolated pattern facet" do
268
+ expect(link.new(href: "http://example.com/a?b=1").validate)
269
+ .to be_empty
270
+ end
271
+
272
+ it "rejects an invalid URI at cast time" do
273
+ expect { link.new(href: "has spaces here") }
274
+ .to raise_error(Lutaml::Model::Type::PatternNotMatchedError)
275
+ end
276
+ end
277
+
278
+ context "when a restriction repeats a bound (issue #191 tightest-wins)" do
279
+ let(:tight_schema) do
280
+ <<~XSD
281
+ <?xml version="1.0" encoding="utf-8"?>
282
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
283
+ <xsd:simpleType name="Tight">
284
+ <xsd:restriction base="xsd:integer">
285
+ <xsd:minExclusive value="5" />
286
+ <xsd:minExclusive value="10" />
287
+ <xsd:maxExclusive value="99" />
288
+ <xsd:maxExclusive value="90" />
289
+ </xsd:restriction>
290
+ </xsd:simpleType>
291
+ </xsd:schema>
292
+ XSD
293
+ end
294
+
295
+ # Exclusive bounds must tighten like the inclusive ones: the largest
296
+ # lower bound and smallest upper bound win, so no out-of-range value
297
+ # slips through the generated facets.
298
+ it "carries the tightest exclusive bounds" do
299
+ code = described_class.to_models(tight_schema).fetch("Tight")
300
+ expect(code).to include("exclusive min: 10, max: 90")
301
+ end
302
+
303
+ it "orders repeated decimal bounds numerically, not lexically" do
304
+ schema = <<~XSD
305
+ <?xml version="1.0" encoding="utf-8"?>
306
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
307
+ <xsd:simpleType name="D">
308
+ <xsd:restriction base="xsd:decimal">
309
+ <xsd:minInclusive value="9.5" />
310
+ <xsd:minInclusive value="10.5" />
311
+ </xsd:restriction>
312
+ </xsd:simpleType>
313
+ </xsd:schema>
314
+ XSD
315
+ code = described_class.to_models(schema).fetch("D")
316
+ expect(code).to include('inclusive min: BigDecimal("10.5")')
317
+ end
318
+ end
319
+
320
+ context "when a restriction derives from a user-defined atomic type " \
321
+ "(issue #191 chained base)" do
322
+ # Deriving a tighter type from a named numeric/decimal/temporal type is
323
+ # valid, common XSD. The further restriction's base is the user type
324
+ # (not a built-in), so its bound/enumeration must render through the
325
+ # parent's `cast` — a bare string would crash `<=>`/never equal the
326
+ # cast value at validation.
327
+ let(:chained_schema) do
328
+ <<~XSD
329
+ <?xml version="1.0" encoding="utf-8"?>
330
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
331
+ <xsd:simpleType name="Amount">
332
+ <xsd:restriction base="xsd:integer">
333
+ <xsd:minInclusive value="0" />
334
+ </xsd:restriction>
335
+ </xsd:simpleType>
336
+ <xsd:simpleType name="SmallAmount">
337
+ <xsd:restriction base="Amount">
338
+ <xsd:maxInclusive value="100" />
339
+ </xsd:restriction>
340
+ </xsd:simpleType>
341
+ </xsd:schema>
342
+ XSD
343
+ end
344
+
345
+ it "renders the chained bound through the parent type's cast" do
346
+ code = described_class.to_models(chained_schema).fetch("SmallAmount")
347
+ expect(code).to include('inclusive max: Amount.cast("100")')
348
+ end
349
+
350
+ it "renders a chained enumeration through the parent type's cast" do
351
+ schema = <<~XSD
352
+ <?xml version="1.0" encoding="utf-8"?>
353
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
354
+ <xsd:simpleType name="Amount">
355
+ <xsd:restriction base="xsd:integer">
356
+ <xsd:minInclusive value="0" />
357
+ </xsd:restriction>
358
+ </xsd:simpleType>
359
+ <xsd:simpleType name="Choice">
360
+ <xsd:restriction base="Amount">
361
+ <xsd:enumeration value="5" />
362
+ <xsd:enumeration value="10" />
363
+ </xsd:restriction>
364
+ </xsd:simpleType>
365
+ </xsd:schema>
366
+ XSD
367
+ code = described_class.to_models(schema).fetch("Choice")
368
+ expect(code).to include("enumeration(Amount.cast(\"5\"), Amount.cast(\"10\"))")
369
+ end
370
+ end
371
+
372
+ context "when compiling non-integer atomic bases (issue #191)" do
373
+ let!(:atomic_dir) { Dir.mktmpdir }
374
+ let(:atomic_schema) do
375
+ <<~XSD
376
+ <?xml version="1.0" encoding="utf-8"?>
377
+ <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema">
378
+ <xsd:simpleType name="ST_Money">
379
+ <xsd:restriction base="xsd:decimal">
380
+ <xsd:minExclusive value="1.5" />
381
+ <xsd:maxExclusive value="9.5" />
382
+ </xsd:restriction>
383
+ </xsd:simpleType>
384
+ <xsd:simpleType name="ST_Rate">
385
+ <xsd:restriction base="xsd:float">
386
+ <xsd:minInclusive value="1.25" />
387
+ <xsd:maxInclusive value="9.75" />
388
+ </xsd:restriction>
389
+ </xsd:simpleType>
390
+ <xsd:simpleType name="ST_Day">
391
+ <xsd:restriction base="xsd:date">
392
+ <xsd:minInclusive value="2020-01-01" />
393
+ </xsd:restriction>
394
+ </xsd:simpleType>
395
+ <xsd:complexType name="CT_Rec">
396
+ <xsd:attribute name="money" type="ST_Money" />
397
+ <xsd:attribute name="rate" type="ST_Rate" />
398
+ <xsd:attribute name="day" type="ST_Day" />
399
+ </xsd:complexType>
400
+ <xsd:element name="Rec" type="CT_Rec" />
401
+ </xsd:schema>
402
+ XSD
403
+ end
404
+ let(:rec) { AtomicBasesSpec::CTRec }
405
+
406
+ before do
407
+ described_class.to_models(
408
+ atomic_schema,
409
+ output_dir: atomic_dir,
410
+ create_files: true,
411
+ module_namespace: "AtomicBasesSpec",
412
+ )
413
+ require File.join(atomic_dir, "atomicbasesspec_registry.rb")
414
+ AtomicBasesSpec.register_all
415
+ end
416
+
417
+ after do
418
+ FileUtils.rm_rf(atomic_dir)
419
+ if defined?(AtomicBasesSpec)
420
+ Object.send(:remove_const, :AtomicBasesSpec)
421
+ end
422
+ end
423
+
424
+ # Regression: decimal facet values must keep their exact lexical form
425
+ # (the exclusive facets used to parse `value` as an integer, truncating
426
+ # 1.5 to 1). At the exact bound 1.5 the eager inclusive cast passes and
427
+ # the lazy exclusive facet rejects — proving the bound is 1.5, not 1.
428
+ it "preserves decimal precision on exclusive bounds" do
429
+ expect(rec.new(money: "5.0").validate).to be_empty
430
+ expect(rec.new(money: "1.5").validate.first)
431
+ .to be_a(Lutaml::Model::MinExclusiveError)
432
+ end
433
+
434
+ # Regression: xsd:float/xsd:date were absent from the supported-type map,
435
+ # so a faceted restriction generated a class inheriting Ruby-core
436
+ # Float/Date and failed to load. They now map to the Lutaml types. A
437
+ # below-range float is rejected by the eager numeric cast.
438
+ it "compiles a float restriction that loads and enforces bounds" do
439
+ expect(rec.new(rate: 5.0).validate).to be_empty
440
+ expect { rec.new(rate: 0.5) }
441
+ .to raise_error(Lutaml::Model::Type::MinBoundError)
442
+ end
443
+
444
+ it "compiles a date restriction that loads and enforces bounds" do
445
+ expect(rec.new(day: "2020-06-01").validate).to be_empty
446
+ expect(rec.new(day: "2019-06-01").validate.first)
447
+ .to be_a(Lutaml::Model::MinInclusiveError)
448
+ end
449
+ end
450
+
149
451
  context "when processing example from lutaml-model#260" do
150
452
  let!(:address_dir) { Dir.mktmpdir }
151
453
  let(:address) do
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/model/schema"
5
+ require "bigdecimal"
6
+ require "tmpdir"
7
+ require "fileutils"
8
+
9
+ # End-to-end round-trip for issue #191 constraining facets: a constrained type
10
+ # survives model -> XSD -> model (its facet macro is regenerated) and an XSD's
11
+ # facets survive XSD -> model -> XSD (each facet element reappears).
12
+ RSpec.describe "Issue #191 facet round-trip" do
13
+ # Generate XSD from a one-attribute model of the constrained type, compile it
14
+ # back, and return the regenerated source of the facet-bearing ST_* type.
15
+ def round_trip_source(constrained_type)
16
+ model = Class.new(Lutaml::Model::Serializable) do
17
+ attribute :value, constrained_type
18
+ xml do
19
+ root "R"
20
+ map_element "value", to: :value
21
+ end
22
+ end
23
+ xsd = Lutaml::Xml::Schema::XsdSchema.generate(model, pretty: true)
24
+ compiled = Lutaml::Model::Schema::XmlCompiler.to_models(xsd)
25
+ compiled.fetch(compiled.keys.grep(/\AST_/).first)
26
+ end
27
+
28
+ describe "model -> XSD -> model regenerates each facet macro" do
29
+ {
30
+ "integer inclusive bounds" => [
31
+ -> { Class.new(Lutaml::Model::Type::Integer) { inclusive min: 0, max: 100 } },
32
+ "inclusive min: 0, max: 100",
33
+ ],
34
+ "integer exclusive bounds" => [
35
+ -> { Class.new(Lutaml::Model::Type::Integer) { exclusive min: 0, max: 100 } },
36
+ "exclusive min: 0, max: 100",
37
+ ],
38
+ "exact length" => [
39
+ -> { Class.new(Lutaml::Model::Type::String) { length 5 } },
40
+ "length 5",
41
+ ],
42
+ "length range" => [
43
+ -> { Class.new(Lutaml::Model::Type::String) { length min: 2, max: 8 } },
44
+ "length min: 2, max: 8",
45
+ ],
46
+ "pattern" => [
47
+ -> { Class.new(Lutaml::Model::Type::String) { pattern(/[A-Z]+/) } },
48
+ "pattern(%r{[A-Z]+})",
49
+ ],
50
+ "enumeration" => [
51
+ -> { Class.new(Lutaml::Model::Type::String) { enumeration("AB", "CD") } },
52
+ 'enumeration("AB", "CD")',
53
+ ],
54
+ "whiteSpace" => [
55
+ -> { Class.new(Lutaml::Model::Type::String) { white_space :collapse } },
56
+ "white_space :collapse",
57
+ ],
58
+ "total and fraction digits" => [
59
+ lambda {
60
+ Class.new(Lutaml::Model::Type::Decimal) do
61
+ total_digits 5
62
+ fraction_digits 2
63
+ end
64
+ },
65
+ ["total_digits 5", "fraction_digits 2"],
66
+ ],
67
+ "decimal inclusive bounds" => [
68
+ -> { Class.new(Lutaml::Model::Type::Decimal) { inclusive min: BigDecimal("1.5") } },
69
+ 'inclusive min: BigDecimal("1.5")',
70
+ ],
71
+ "float inclusive bounds" => [
72
+ -> { Class.new(Lutaml::Model::Type::Float) { inclusive min: 1.25, max: 9.75 } },
73
+ 'inclusive min: Lutaml::Model::Type::Float.cast("1.25")',
74
+ ],
75
+ "date inclusive bound" => [
76
+ lambda {
77
+ Class.new(Lutaml::Model::Type::Date) do
78
+ inclusive min: Lutaml::Model::Type::Date.cast("2020-01-01")
79
+ end
80
+ },
81
+ 'inclusive min: Lutaml::Model::Type::Date.cast("2020-01-01")',
82
+ ],
83
+ "boolean enumeration" => [
84
+ -> { Class.new(Lutaml::Model::Type::Boolean) { enumeration(true) } },
85
+ 'enumeration(Lutaml::Model::Type::Boolean.cast("true"))',
86
+ ],
87
+ }.each do |facet, (build, macros)|
88
+ it "regenerates #{facet}" do
89
+ source = round_trip_source(build.call)
90
+ Array(macros).each { |macro| expect(source).to include(macro) }
91
+ end
92
+ end
93
+
94
+ # Layer-1 attribute facets (issue #191 F3) must also survive the round-trip:
95
+ # `min`/`max` on the attribute emit an xs:restriction that recompiles to the
96
+ # canonical facet macro.
97
+ it "regenerates Layer-1 attribute min/max bounds" do
98
+ model = Class.new(Lutaml::Model::Serializable) do
99
+ attribute :value, :integer, min: 0, max: 100
100
+ xml do
101
+ root "R"
102
+ map_element "value", to: :value
103
+ end
104
+ end
105
+ xsd = Lutaml::Xml::Schema::XsdSchema.generate(model, pretty: true)
106
+ compiled = Lutaml::Model::Schema::XmlCompiler.to_models(xsd)
107
+ source = compiled.fetch(compiled.keys.grep(/\AST_/).first)
108
+
109
+ expect(source).to include("inclusive min: 0, max: 100")
110
+ end
111
+ end
112
+
113
+ describe "XSD -> model -> XSD regenerates each facet element" do
114
+ let!(:dir) { Dir.mktmpdir }
115
+
116
+ let(:regenerated) do
117
+ Lutaml::Model::Schema::XmlCompiler.to_models(
118
+ File.read("spec/fixtures/xml/restriction_facets.xsd"),
119
+ output_dir: dir, create_files: true, module_namespace: "FacetRtSpec",
120
+ )
121
+ require File.join(dir, "facetrtspec_registry.rb")
122
+ FacetRtSpec.register_all
123
+ Lutaml::Xml::Schema::XsdSchema.generate(FacetRtSpec::CTItem, pretty: true)
124
+ end
125
+
126
+ after do
127
+ FileUtils.rm_rf(dir)
128
+ # The compiled fixture must load as a real module for register_all; drop it
129
+ # so repeated runs do not accumulate constants.
130
+ # rubocop:disable-next RSpec/RemoveConst
131
+ Object.send(:remove_const, :FacetRtSpec) if defined?(FacetRtSpec)
132
+ end
133
+
134
+ {
135
+ "minLength/maxLength" =>
136
+ ['<xs:minLength value="2"/>', '<xs:maxLength value="8"/>'],
137
+ "min/maxExclusive" =>
138
+ ['<xs:minExclusive value="0"/>', '<xs:maxExclusive value="100"/>'],
139
+ "total/fractionDigits" =>
140
+ ['<xs:totalDigits value="5"/>', '<xs:fractionDigits value="2"/>'],
141
+ "whiteSpace" => ['<xs:whiteSpace value="collapse"/>'],
142
+ "min/maxInclusive" => [
143
+ '<xs:minInclusive value="2020-01-01T00:00:00+00:00"/>',
144
+ '<xs:maxInclusive value="2020-12-31T23:59:59+00:00"/>',
145
+ ],
146
+ "enumeration" => ['<xs:enumeration value="true"/>'],
147
+ }.each do |facet, fragments|
148
+ it "regenerates #{facet}" do
149
+ fragments.each { |fragment| expect(regenerated).to include(fragment) }
150
+ end
151
+ end
152
+ end
153
+ end