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,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/store"
5
+
6
+ module ConversionCachingSpec
7
+ class CachedModel < Lutaml::Model::Serializable
8
+ attribute :name, :string
9
+ attribute :value, :string
10
+
11
+ cache_conversions
12
+
13
+ xml do
14
+ root "cached-model"
15
+ map_element "name", to: :name
16
+ map_element "value", to: :value
17
+ end
18
+ end
19
+
20
+ # Same root and shape as CachedModel: proves two cached classes
21
+ # parsing identical input do not collide in the store.
22
+ class TwinCachedModel < Lutaml::Model::Serializable
23
+ attribute :name, :string
24
+ attribute :value, :string
25
+
26
+ cache_conversions
27
+
28
+ xml do
29
+ root "cached-model"
30
+ map_element "name", to: :name
31
+ map_element "value", to: :value
32
+ end
33
+ end
34
+
35
+ class UncachedModel < Lutaml::Model::Serializable
36
+ attribute :name, :string
37
+
38
+ xml do
39
+ root "uncached-model"
40
+ map_element "name", to: :name
41
+ end
42
+ end
43
+
44
+ class InheritedModel < CachedModel
45
+ end
46
+ end
47
+
48
+ RSpec.describe "Conversion caching" do
49
+ let(:store) { Lutaml::Store::BasicStore.new(adapter: { type: :memory }) }
50
+
51
+ after do
52
+ Lutaml::Model::Config.conversion_cache = nil
53
+ end
54
+
55
+ describe "Config.conversion_cache" do
56
+ it "defaults to a memory-backed BasicStore when lutaml-store is loaded" do
57
+ expect(Lutaml::Model::Config.conversion_cache)
58
+ .to be_a(Lutaml::Store::BasicStore)
59
+ end
60
+
61
+ it "is settable via the configure block" do
62
+ Lutaml::Model::Config.configure do |config|
63
+ config.conversion_cache = store
64
+ end
65
+
66
+ expect(Lutaml::Model::Config.conversion_cache).to be(store)
67
+ end
68
+
69
+ it "is disabled by assigning false" do
70
+ Lutaml::Model::Config.conversion_cache = false
71
+
72
+ expect(Lutaml::Model::Config.conversion_cache).to be_nil
73
+ end
74
+ end
75
+
76
+ describe ".cache_conversions" do
77
+ it "is disabled by default" do
78
+ expect(ConversionCachingSpec::UncachedModel.conversion_caching_enabled?)
79
+ .to be(false)
80
+ end
81
+
82
+ it "is enabled by the macro" do
83
+ expect(ConversionCachingSpec::CachedModel.conversion_caching_enabled?)
84
+ .to be(true)
85
+ end
86
+
87
+ it "is inherited by subclasses" do
88
+ expect(ConversionCachingSpec::InheritedModel.conversion_caching_enabled?)
89
+ .to be(true)
90
+ end
91
+ end
92
+
93
+ describe "deserialization caching" do
94
+ let(:xml) { "<cached-model><name>a</name><value>1</value></cached-model>" }
95
+
96
+ before do
97
+ Lutaml::Model::Config.conversion_cache = store
98
+ end
99
+
100
+ it "returns the cached instance for repeated identical input" do
101
+ first = ConversionCachingSpec::CachedModel.from_xml(xml)
102
+ second = ConversionCachingSpec::CachedModel.from_xml(xml)
103
+
104
+ expect(second).to be(first)
105
+ end
106
+
107
+ it "uses the auto-detected default store when none is assigned" do
108
+ Lutaml::Model::Config.conversion_cache = nil
109
+
110
+ first = ConversionCachingSpec::CachedModel.from_xml(xml)
111
+ second = ConversionCachingSpec::CachedModel.from_xml(xml)
112
+
113
+ expect(second).to be(first)
114
+ expect(Lutaml::Model::Config.conversion_cache.size).to eq(1)
115
+ end
116
+
117
+ it "does not cache when disabled with false" do
118
+ Lutaml::Model::Config.conversion_cache = false
119
+
120
+ first = ConversionCachingSpec::CachedModel.from_xml(xml)
121
+ second = ConversionCachingSpec::CachedModel.from_xml(xml)
122
+
123
+ expect(second).not_to be(first)
124
+ end
125
+
126
+ it "parses different input separately" do
127
+ other = "<cached-model><name>b</name><value>2</value></cached-model>"
128
+
129
+ first = ConversionCachingSpec::CachedModel.from_xml(xml)
130
+ second = ConversionCachingSpec::CachedModel.from_xml(other)
131
+
132
+ expect(second.name).to eq("b")
133
+ expect(second).not_to be(first)
134
+ end
135
+
136
+ it "keeps same-named input separate per class" do
137
+ cached = ConversionCachingSpec::CachedModel.from_xml(xml)
138
+ twin = ConversionCachingSpec::TwinCachedModel.from_xml(xml)
139
+
140
+ expect(twin).to be_a(ConversionCachingSpec::TwinCachedModel)
141
+ expect(cached).to be_a(ConversionCachingSpec::CachedModel)
142
+ expect(store.size).to eq(2)
143
+ end
144
+
145
+ it "caches calls that pass only the register option" do
146
+ first = ConversionCachingSpec::CachedModel.from_xml(xml,
147
+ register: :default)
148
+ second = ConversionCachingSpec::CachedModel.from_xml(xml,
149
+ register: :default)
150
+
151
+ expect(second).to be(first)
152
+ end
153
+
154
+ it "caches calls with extra options under distinct keys" do
155
+ plain = ConversionCachingSpec::CachedModel.from_xml(xml)
156
+ opted = ConversionCachingSpec::CachedModel.from_xml(xml,
157
+ encoding: "UTF-8")
158
+ again = ConversionCachingSpec::CachedModel.from_xml(xml,
159
+ encoding: "UTF-8")
160
+
161
+ expect(store.size).to eq(2)
162
+ expect(again).to be(opted)
163
+ expect(opted).not_to be(plain)
164
+ end
165
+
166
+ it "treats a wrong-typed stored value as a miss" do
167
+ ConversionCachingSpec::CachedModel.from_xml(xml)
168
+ store.each_key { |key| store.set(key, "corrupted") }
169
+
170
+ result = ConversionCachingSpec::CachedModel.from_xml(xml)
171
+
172
+ expect(result).to be_a(ConversionCachingSpec::CachedModel)
173
+ expect(result.name).to eq("a")
174
+ end
175
+
176
+ it "does not cache classes that did not opt in" do
177
+ ConversionCachingSpec::UncachedModel.from_xml(
178
+ "<uncached-model><name>a</name></uncached-model>",
179
+ )
180
+
181
+ expect(store.size).to eq(0)
182
+ end
183
+
184
+ it "lets store errors propagate instead of mislabeling them" do
185
+ allow(store).to receive(:get).and_raise(TypeError, "store broke")
186
+
187
+ expect do
188
+ ConversionCachingSpec::CachedModel.from_xml(xml)
189
+ end.to raise_error(TypeError, "store broke")
190
+ end
191
+
192
+ it "still raises InvalidFormatError for malformed input" do
193
+ expect do
194
+ ConversionCachingSpec::CachedModel.from_json("{not json")
195
+ end.to raise_error(Lutaml::Model::InvalidFormatError)
196
+ end
197
+ end
198
+
199
+ describe "serialization caching" do
200
+ let(:instance) do
201
+ ConversionCachingSpec::CachedModel.new(name: "a", value: "1")
202
+ end
203
+
204
+ before do
205
+ Lutaml::Model::Config.conversion_cache = store
206
+ end
207
+
208
+ it "serves repeated serialization from the store" do
209
+ instance.to_xml
210
+ store.each_key { |key| store.set(key, "<sentinel/>") }
211
+
212
+ expect(instance.to_xml).to eq("<sentinel/>")
213
+ end
214
+
215
+ it "re-serializes after the instance is mutated" do
216
+ instance.to_xml
217
+ instance.name = "b"
218
+
219
+ expect(instance.to_xml).to include("b")
220
+ expect(store.size).to eq(2)
221
+ end
222
+
223
+ it "caches each option set under its own key" do
224
+ instance.to_xml
225
+ instance.to_xml(pretty: true)
226
+
227
+ expect(store.size).to eq(2)
228
+ end
229
+
230
+ it "bypasses for instances that cannot be marshaled" do
231
+ instance.instance_variable_set(:@unmarshalable, proc {})
232
+
233
+ expect(instance.to_xml).to include("<name>a</name>")
234
+ expect(store.size).to eq(0)
235
+ end
236
+
237
+ it "attempts marshal only once for an unmarshalable instance" do
238
+ instance.instance_variable_set(:@unmarshalable, proc {})
239
+
240
+ allow(Marshal).to receive(:dump).and_call_original
241
+
242
+ 2.times { instance.to_xml }
243
+
244
+ expect(Marshal).to have_received(:dump).once
245
+ end
246
+
247
+ it "does not cache classes that did not opt in" do
248
+ ConversionCachingSpec::UncachedModel.new(name: "a").to_xml
249
+
250
+ expect(store.size).to eq(0)
251
+ end
252
+ end
253
+
254
+ describe "with Lutaml::Store::CacheStore" do
255
+ let(:store) { Lutaml::Store::CacheStore.new(adapter: { type: :memory }) }
256
+ let(:xml) { "<cached-model><name>a</name><value>1</value></cached-model>" }
257
+
258
+ before do
259
+ Lutaml::Model::Config.conversion_cache = store
260
+ end
261
+
262
+ it "serves to_* output strings through the JSON-marshaling store" do
263
+ instance = ConversionCachingSpec::CachedModel.new(name: "a", value: "1")
264
+ instance.to_xml
265
+ # CacheStore has no #each_key; snapshot keys first.
266
+ keys = store.keys
267
+ keys.each { |key| store.set(key, "<sentinel/>") }
268
+
269
+ expect(instance.to_xml).to eq("<sentinel/>")
270
+ end
271
+
272
+ it "recomputes from_* results that cannot round-trip through JSON" do
273
+ first = ConversionCachingSpec::CachedModel.from_xml(xml)
274
+ second = ConversionCachingSpec::CachedModel.from_xml(xml)
275
+
276
+ expect(second).not_to be(first)
277
+ expect(second.name).to eq("a")
278
+ end
279
+ end
280
+ end
@@ -78,14 +78,39 @@ RSpec.describe "Lazy collection initialization", type: :model do
78
78
  expect(ivar_c).to be(sentinel)
79
79
  end
80
80
 
81
- it "returns sentinel for uninitialized collections (behaves like [])" do
81
+ it "hands out a real empty array for uninitialized collections" do
82
82
  xml = "<multi><name>test</name></multi>"
83
83
  instance = LazyCollectionTests::MultiCollection.from_xml(xml)
84
84
 
85
85
  items_a = instance.items_a
86
86
  expect(items_a).to eq([])
87
- expect(items_a).to be_frozen
88
- expect(items_a).to be(sentinel)
87
+ # The sentinel is frozen and shared by every instance of every class, so
88
+ # a reader cannot hand it out: `instance.items_a << x` has to work, and
89
+ # has to stay on this instance.
90
+ expect(items_a).not_to be_frozen
91
+ expect(items_a).not_to be(sentinel)
92
+ expect(instance.instance_variable_get(:@items_a)).to be(items_a)
93
+ end
94
+
95
+ it "keeps a pushed item and keeps it on this instance" do
96
+ xml = "<multi><name>test</name></multi>"
97
+ one = LazyCollectionTests::MultiCollection.from_xml(xml)
98
+ two = LazyCollectionTests::MultiCollection.from_xml(xml)
99
+
100
+ one.items_a << "hello"
101
+
102
+ expect(one.items_a).to eq(["hello"])
103
+ expect(two.items_a).to eq([])
104
+ end
105
+
106
+ it "leaves the collections nobody read on the sentinel" do
107
+ xml = "<multi><name>test</name></multi>"
108
+ instance = LazyCollectionTests::MultiCollection.from_xml(xml)
109
+
110
+ instance.items_a
111
+
112
+ expect(instance.instance_variable_get(:@items_b)).to be(sentinel)
113
+ expect(instance.instance_variable_get(:@items_c)).to be(sentinel)
89
114
  end
90
115
 
91
116
  it "supports builder-style append on sentinel collections" do