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
@@ -35,9 +35,61 @@ RSpec.describe "Lazy nil deserialization state guard specs" do
35
35
  instance.send(:init_deserialization_state, nil)
36
36
  # Collections are initialized with LAZY_EMPTY_COLLECTION (frozen shared [])
37
37
  # instead of per-instance Array.new — avoids allocation overhead
38
+ expect(instance.instance_variable_get(:@tags))
39
+ .to be(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
40
+ end
41
+
42
+ it "allocates a per-instance array only once the collection is read" do
43
+ instance = model_class.allocate
44
+ instance.send(:init_deserialization_state, nil)
45
+
38
46
  tags = instance.tags
47
+
39
48
  expect(tags).to eq([])
40
- expect(tags).to be(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
49
+ expect(tags).not_to be(Lutaml::Model::Serialize::LAZY_EMPTY_COLLECTION)
50
+ expect(instance.instance_variable_get(:@tags)).to be(tags)
51
+ end
52
+ end
53
+
54
+ describe "enum collection assignment cost" do
55
+ # Duplicates are the collection reader's job, so this writer stays a plain
56
+ # `+`. The tempting alternative — deduping here with an Array#include? per
57
+ # appended item — makes assignment quadratic, and an enum collection has no
58
+ # size limit. Measured where this guard was written: the rescan form takes
59
+ # 6.35s at this size, against well under a millisecond for the `+`. The
60
+ # budget is deliberately coarse; it exists to catch a quadratic writer, not
61
+ # to police small regressions.
62
+ let(:enum_values) { (1..64_000).to_a }
63
+
64
+ let(:enum_model_class) do
65
+ values = enum_values
66
+ Class.new(Lutaml::Model::Serializable) do
67
+ attribute :codes, :integer, values: values, collection: true
68
+
69
+ def self.name
70
+ "LargeEnumCollectionModel"
71
+ end
72
+ end
73
+ end
74
+
75
+ it "assigns a large collection without rescanning what it already holds" do
76
+ model = enum_model_class.new
77
+
78
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
+ model.codes = enum_values
80
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
81
+
82
+ expect(model.codes.size).to eq(64_000)
83
+ expect(elapsed).to be < 0.75
84
+ end
85
+
86
+ it "still reads back without duplicates after repeated assignment" do
87
+ model = enum_model_class.new
88
+
89
+ model.codes = [1, 2, 2, 3]
90
+ model.codes = [3, 4]
91
+
92
+ expect(model.codes).to eq([1, 2, 3, 4])
41
93
  end
42
94
  end
43
95
 
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "bigdecimal"
5
+
6
+ RSpec.describe "totalDigits / fractionDigits facets" do
7
+ def build_model(base, total: nil, fraction: nil)
8
+ type = Class.new(base)
9
+ type.total_digits(total) unless total.nil?
10
+ type.fraction_digits(fraction) unless fraction.nil?
11
+ Class.new(Lutaml::Model::Serializable) do
12
+ attribute :n, type
13
+ end
14
+ end
15
+
16
+ def errors_for(base, value, total: nil, fraction: nil)
17
+ build_model(base, total: total, fraction: fraction).new(n: value).validate
18
+ end
19
+
20
+ let(:decimal) { Lutaml::Model::Type::Decimal }
21
+ let(:integer) { Lutaml::Model::Type::Integer }
22
+
23
+ describe "digit counting convention" do
24
+ # base, lexical value, expected total digits, expected fraction digits
25
+ [
26
+ [Lutaml::Model::Type::Decimal, "123.45", 5, 2],
27
+ [Lutaml::Model::Type::Decimal, "100", 3, 0],
28
+ [Lutaml::Model::Type::Decimal, "0.1", 1, 1],
29
+ [Lutaml::Model::Type::Decimal, "0.100", 1, 1],
30
+ [Lutaml::Model::Type::Decimal, "100.00", 3, 0],
31
+ [Lutaml::Model::Type::Decimal, "-123.45", 5, 2],
32
+ [Lutaml::Model::Type::Decimal, "12.340", 4, 2],
33
+ [Lutaml::Model::Type::Decimal, "0.05", 1, 2],
34
+ [Lutaml::Model::Type::Decimal, "0.001", 1, 3],
35
+ [Lutaml::Model::Type::Decimal, "0.5", 1, 1],
36
+ [Lutaml::Model::Type::Integer, 12345, 5, 0],
37
+ ].each do |base, value, total, fraction|
38
+ it "counts #{value.inspect} as #{total} total / #{fraction} fraction" do
39
+ expect(errors_for(base, value, total: total)).to be_empty
40
+ expect(errors_for(base, value, fraction: fraction)).to be_empty
41
+
42
+ if total > 1
43
+ expect(errors_for(base, value, total: total - 1).first)
44
+ .to be_a(Lutaml::Model::TotalDigitsError)
45
+ end
46
+ if fraction.positive?
47
+ expect(errors_for(base, value, fraction: fraction - 1).first)
48
+ .to be_a(Lutaml::Model::FractionDigitsError)
49
+ end
50
+ end
51
+ end
52
+
53
+ it "counts only significant digits toward the total (XSD)" do
54
+ # `0.05` has one significant digit (`5`), so a `total_digits 1` type
55
+ # accepts it: the leading zero before the point is insignificant. This
56
+ # is required for XSD round-trip conformance -- a model generated from
57
+ # `<xs:totalDigits value="1"/>` must accept 0.05 and reject 12.
58
+ model = build_model(decimal, total: 1)
59
+
60
+ expect(model.new(n: "0.05").validate).to be_empty
61
+ expect(model.new(n: "12").validate.first)
62
+ .to be_a(Lutaml::Model::TotalDigitsError)
63
+ end
64
+
65
+ it "treats integer zero as zero significant digits" do
66
+ expect(errors_for(integer, 0, total: 1)).to be_empty
67
+ expect(errors_for(integer, 0, fraction: 0)).to be_empty
68
+ end
69
+
70
+ it "treats decimal zero as zero significant digits" do
71
+ expect(errors_for(decimal, "0", total: 1)).to be_empty
72
+ expect(errors_for(decimal, "0.0", fraction: 0)).to be_empty
73
+ end
74
+ end
75
+
76
+ describe "total_digits enforcement" do
77
+ it "passes a Decimal within the digit budget" do
78
+ expect(errors_for(decimal, "123.45", total: 8)).to be_empty
79
+ end
80
+
81
+ it "collects a TotalDigitsError when a Decimal exceeds the budget" do
82
+ expect(errors_for(decimal, "123.456", total: 5).first)
83
+ .to be_a(Lutaml::Model::TotalDigitsError)
84
+ end
85
+
86
+ it "passes an Integer within the digit budget" do
87
+ expect(errors_for(integer, 12345, total: 5)).to be_empty
88
+ end
89
+
90
+ it "collects a TotalDigitsError when an Integer exceeds the budget" do
91
+ expect(errors_for(integer, 123456, total: 5).first)
92
+ .to be_a(Lutaml::Model::TotalDigitsError)
93
+ end
94
+ end
95
+
96
+ describe "fraction_digits enforcement" do
97
+ it "passes a Decimal within the fraction budget" do
98
+ expect(errors_for(decimal, "1.23", fraction: 2)).to be_empty
99
+ end
100
+
101
+ it "collects a FractionDigitsError when the fraction is too long" do
102
+ expect(errors_for(decimal, "1.234", fraction: 2).first)
103
+ .to be_a(Lutaml::Model::FractionDigitsError)
104
+ end
105
+
106
+ it "enforces both facets together (Money-style)" do
107
+ model = build_model(decimal, total: 8, fraction: 2)
108
+
109
+ expect(model.new(n: "123456.78").validate).to be_empty
110
+ expect(model.new(n: "1234567.89").validate.first)
111
+ .to be_a(Lutaml::Model::TotalDigitsError)
112
+ expect(model.new(n: "1.234").validate.first)
113
+ .to be_a(Lutaml::Model::FractionDigitsError)
114
+ end
115
+ end
116
+
117
+ describe "laziness" do
118
+ it "does not raise until validate!" do
119
+ model = build_model(decimal, total: 3)
120
+
121
+ expect { model.new(n: "123456") }.not_to raise_error
122
+ expect(model.new(n: "123456").validate.first)
123
+ .to be_a(Lutaml::Model::TotalDigitsError)
124
+ end
125
+ end
126
+
127
+ describe "declaration guards" do
128
+ it "rejects a negative total_digits" do
129
+ expect { Class.new(decimal) { total_digits(-1) } }
130
+ .to raise_error(ArgumentError)
131
+ end
132
+
133
+ it "rejects total_digits 0" do
134
+ expect { Class.new(decimal) { total_digits 0 } }
135
+ .to raise_error(ArgumentError)
136
+ end
137
+
138
+ it "rejects a non-integer total_digits" do
139
+ expect { Class.new(decimal) { total_digits 2.5 } }
140
+ .to raise_error(ArgumentError)
141
+ end
142
+
143
+ it "rejects a negative fraction_digits" do
144
+ expect { Class.new(decimal) { fraction_digits(-1) } }
145
+ .to raise_error(ArgumentError)
146
+ end
147
+
148
+ it "allows fraction_digits 0" do
149
+ expect { Class.new(decimal) { fraction_digits 0 } }.not_to raise_error
150
+ end
151
+ end
152
+
153
+ describe "applicability guard" do
154
+ it "rejects digit facets on a Float subclass" do
155
+ expect { errors_for(Lutaml::Model::Type::Float, 1.5, total: 5) }
156
+ .to raise_error(ArgumentError, /:integer and :decimal/)
157
+ end
158
+
159
+ it "rejects digit facets on a String subclass" do
160
+ expect { errors_for(Lutaml::Model::Type::String, "12", total: 5) }
161
+ .to raise_error(ArgumentError, /:integer and :decimal/)
162
+ end
163
+
164
+ it "rejects digit facets on a temporal subclass" do
165
+ expect do
166
+ errors_for(Lutaml::Model::Type::Date, "2020-01-01", total: 5)
167
+ end.to raise_error(ArgumentError, /:integer and :decimal/)
168
+ end
169
+ end
170
+
171
+ describe "consistency guard" do
172
+ it "rejects fraction_digits exceeding total_digits" do
173
+ expect { errors_for(decimal, "1.5", total: 3, fraction: 5) }
174
+ .to raise_error(ArgumentError, /fraction_digits.*exceeds.*total_digits/)
175
+ end
176
+
177
+ it "accepts fraction_digits equal to total_digits" do
178
+ expect(errors_for(decimal, "0.12", total: 2, fraction: 2)).to be_empty
179
+ end
180
+ end
181
+
182
+ describe "MAX-like merge and inheritance" do
183
+ it "keeps the smaller total_digits when re-declared" do
184
+ type = Class.new(decimal) do
185
+ total_digits 8
186
+ total_digits 5
187
+ end
188
+
189
+ expect(type.facets).to eq(total_digits: 5)
190
+ end
191
+
192
+ it "lets a child tighten total_digits" do
193
+ base = Class.new(decimal) { total_digits 8 }
194
+ child = Class.new(base) { total_digits 5 }
195
+
196
+ expect(child.facets).to eq(total_digits: 5)
197
+ end
198
+
199
+ it "raises when a child widens total_digits" do
200
+ base = Class.new(decimal) { total_digits 5 }
201
+ child = Class.new(base) { total_digits 8 }
202
+
203
+ expect { child.facets }.to raise_error(ArgumentError, /cannot widen/)
204
+ end
205
+
206
+ it "lets a child tighten fraction_digits" do
207
+ base = Class.new(decimal) { fraction_digits 4 }
208
+ child = Class.new(base) { fraction_digits 2 }
209
+
210
+ expect(child.facets).to eq(fraction_digits: 2)
211
+ end
212
+
213
+ it "raises when a child widens fraction_digits" do
214
+ base = Class.new(decimal) { fraction_digits 2 }
215
+ child = Class.new(base) { fraction_digits 4 }
216
+
217
+ expect { child.facets }.to raise_error(ArgumentError, /cannot widen/)
218
+ end
219
+
220
+ it "enforces the tightest inherited total_digits" do
221
+ base = Class.new(decimal) { total_digits 8 }
222
+ child = Class.new(base) { total_digits 6 }
223
+
224
+ expect(errors_for(child, "12345.6")).to be_empty
225
+ expect(errors_for(child, "1234567").first)
226
+ .to be_a(Lutaml::Model::TotalDigitsError)
227
+ end
228
+ end
229
+ end