lutaml-store 0.1.1
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.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +27 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +10 -0
- data/.rubocop_todo.yml +450 -0
- data/CLAUDE.md +57 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
- data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +220 -0
- data/README.adoc +1430 -0
- data/Rakefile +12 -0
- data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
- data/TODO.impl/1-lutaml-hal-migration.md +60 -0
- data/TODO.impl/2-glossarist-migration.md +359 -0
- data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/demo/Gemfile +15 -0
- data/demo/Gemfile.lock +61 -0
- data/demo/README.adoc +301 -0
- data/demo/data/vcards/co/contact_10_thompson.data +1 -0
- data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
- data/demo/data/vcards/co/contact_1_doe.data +1 -0
- data/demo/data/vcards/co/contact_1_doe.meta +1 -0
- data/demo/data/vcards/co/contact_2_smith.data +1 -0
- data/demo/data/vcards/co/contact_2_smith.meta +1 -0
- data/demo/data/vcards/co/contact_3_johnson.data +1 -0
- data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
- data/demo/data/vcards/co/contact_4_garcia.data +1 -0
- data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
- data/demo/data/vcards/co/contact_5_wilson.data +1 -0
- data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
- data/demo/data/vcards/co/contact_6_brown.data +1 -0
- data/demo/data/vcards/co/contact_6_brown.meta +1 -0
- data/demo/data/vcards/co/contact_7_davis.data +1 -0
- data/demo/data/vcards/co/contact_7_davis.meta +1 -0
- data/demo/data/vcards/co/contact_8_anderson.data +1 -0
- data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
- data/demo/data/vcards/co/contact_9_taylor.data +1 -0
- data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
- data/demo/data/vcards.db +0 -0
- data/demo/pottery_class_demo.rb +164 -0
- data/demo/vcard_models.rb +140 -0
- data/demo/vcard_store_demo.rb +526 -0
- data/lib/lutaml/store/adapter/base.rb +65 -0
- data/lib/lutaml/store/adapter/filesystem.rb +288 -0
- data/lib/lutaml/store/adapter/memory.rb +225 -0
- data/lib/lutaml/store/adapter/sqlite.rb +193 -0
- data/lib/lutaml/store/adapter.rb +12 -0
- data/lib/lutaml/store/attribute_updater.rb +198 -0
- data/lib/lutaml/store/basic_store.rb +190 -0
- data/lib/lutaml/store/cache.rb +108 -0
- data/lib/lutaml/store/cache_store.rb +282 -0
- data/lib/lutaml/store/composite_model_handler.rb +169 -0
- data/lib/lutaml/store/compression.rb +137 -0
- data/lib/lutaml/store/config.rb +178 -0
- data/lib/lutaml/store/database_store.rb +425 -0
- data/lib/lutaml/store/events.rb +92 -0
- data/lib/lutaml/store/format/base.rb +33 -0
- data/lib/lutaml/store/format/json.rb +25 -0
- data/lib/lutaml/store/format/jsonl.rb +37 -0
- data/lib/lutaml/store/format/marshal_format.rb +37 -0
- data/lib/lutaml/store/format/yaml.rb +29 -0
- data/lib/lutaml/store/format/yamls.rb +35 -0
- data/lib/lutaml/store/format.rb +33 -0
- data/lib/lutaml/store/http_cache.rb +279 -0
- data/lib/lutaml/store/http_cache_config.rb +53 -0
- data/lib/lutaml/store/http_cache_entry.rb +69 -0
- data/lib/lutaml/store/http_header_processor.rb +175 -0
- data/lib/lutaml/store/integrity.rb +102 -0
- data/lib/lutaml/store/model_registration.rb +75 -0
- data/lib/lutaml/store/model_registry.rb +123 -0
- data/lib/lutaml/store/model_serializer.rb +69 -0
- data/lib/lutaml/store/monitor.rb +192 -0
- data/lib/lutaml/store/storage_key.rb +40 -0
- data/lib/lutaml/store/version.rb +7 -0
- data/lib/lutaml/store.rb +41 -0
- data/lutaml-store.gemspec +35 -0
- data/plan.adoc +606 -0
- data/sig/lutaml/store.rbs +6 -0
- data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
- data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
- data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
- data/spec/lutaml/store/autoload_spec.rb +34 -0
- data/spec/lutaml/store/cache_store_spec.rb +271 -0
- data/spec/lutaml/store/compression_spec.rb +78 -0
- data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
- data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
- data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
- data/spec/lutaml/store/database_store_spec.rb +279 -0
- data/spec/lutaml/store/file_io_spec.rb +219 -0
- data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
- data/spec/lutaml/store/format_spec.rb +70 -0
- data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
- data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
- data/spec/lutaml/store/http_cache_spec.rb +422 -0
- data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
- data/spec/lutaml/store/import_spec.rb +90 -0
- data/spec/lutaml/store/integrity_spec.rb +157 -0
- data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
- data/spec/lutaml/store/load_save_spec.rb +107 -0
- data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
- data/spec/lutaml/store/model_serializer_spec.rb +140 -0
- data/spec/lutaml/store/store_spec.rb +182 -0
- data/spec/lutaml/store_spec.rb +21 -0
- data/spec/spec_helper.rb +16 -0
- metadata +166 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module DatabaseStoreTestModels
|
|
6
|
+
class TestStudio < Lutaml::Model::Serializable
|
|
7
|
+
attribute :studio_key, :string
|
|
8
|
+
attribute :name, :string
|
|
9
|
+
attribute :location, :string
|
|
10
|
+
attribute :_class, :string, default: -> { "TestStudio" }, polymorphic_class: true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class TestCeramicStudio < TestStudio
|
|
14
|
+
attribute :clay_type, :string
|
|
15
|
+
attribute :_class, :string, default: -> { "TestCeramicStudio" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class TestPotteryClass < Lutaml::Model::Serializable
|
|
19
|
+
attribute :class_id, :string
|
|
20
|
+
attribute :description, :string
|
|
21
|
+
attribute :studio, TestStudio, polymorphic: true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
RSpec.describe Lutaml::Store::DatabaseStore do
|
|
26
|
+
let(:config) { { adapter_type: :memory } }
|
|
27
|
+
|
|
28
|
+
let(:models) do
|
|
29
|
+
[
|
|
30
|
+
{ model: DatabaseStoreTestModels::TestPotteryClass, key: :class_id },
|
|
31
|
+
{ model: DatabaseStoreTestModels::TestStudio, key: :studio_key, polymorphic_class_key: :_class },
|
|
32
|
+
{ model: DatabaseStoreTestModels::TestCeramicStudio, key: :studio_key, polymorphic_class_key: :_class }
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
let(:store) { described_class.new(adapter: :memory, models: models) }
|
|
37
|
+
|
|
38
|
+
describe "#initialize" do
|
|
39
|
+
it "creates a database store with models" do
|
|
40
|
+
expect(store).to be_a(described_class)
|
|
41
|
+
expect(store.registry.count).to eq(3)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "raises error when no models provided" do
|
|
45
|
+
expect do
|
|
46
|
+
described_class.new(adapter: :memory, models: [])
|
|
47
|
+
end.to raise_error(Lutaml::Store::ConfigurationError, /No models registered/)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe "#save and #fetch" do
|
|
52
|
+
let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
|
|
53
|
+
let(:pottery_class) do
|
|
54
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
55
|
+
class_id: "pottery_101",
|
|
56
|
+
description: "Basic pottery",
|
|
57
|
+
studio: studio
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "saves and fetches a model with composite relationships" do
|
|
62
|
+
store.save(pottery_class)
|
|
63
|
+
|
|
64
|
+
fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
|
|
65
|
+
expect(fetched).to be_a(DatabaseStoreTestModels::TestPotteryClass)
|
|
66
|
+
expect(fetched.class_id).to eq("pottery_101")
|
|
67
|
+
expect(fetched.description).to eq("Basic pottery")
|
|
68
|
+
expect(fetched.studio).to be_a(DatabaseStoreTestModels::TestStudio)
|
|
69
|
+
expect(fetched.studio.studio_key).to eq("test_studio")
|
|
70
|
+
expect(fetched.studio.name).to eq("Test Studio")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "handles polymorphic models correctly" do
|
|
74
|
+
ceramic_studio = DatabaseStoreTestModels::TestCeramicStudio.new(
|
|
75
|
+
studio_key: "ceramic_studio",
|
|
76
|
+
name: "Ceramic Studio",
|
|
77
|
+
clay_type: "Porcelain"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
store.save(ceramic_studio)
|
|
81
|
+
|
|
82
|
+
fetched = store.fetch(model: DatabaseStoreTestModels::TestStudio, studio_key: "ceramic_studio")
|
|
83
|
+
expect(fetched).to be_a(DatabaseStoreTestModels::TestCeramicStudio)
|
|
84
|
+
expect(fetched.clay_type).to eq("Porcelain")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "returns nil for non-existent models" do
|
|
88
|
+
result = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "nonexistent")
|
|
89
|
+
expect(result).to be_nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe "#update" do
|
|
94
|
+
let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
|
|
95
|
+
let(:pottery_class) do
|
|
96
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
97
|
+
class_id: "pottery_101",
|
|
98
|
+
description: "Basic pottery",
|
|
99
|
+
studio: studio
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
before { store.save(pottery_class) }
|
|
104
|
+
|
|
105
|
+
it "updates with block" do
|
|
106
|
+
updated = store.update(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101") do |model|
|
|
107
|
+
model.description = "Advanced pottery"
|
|
108
|
+
model
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
expect(updated.description).to eq("Advanced pottery")
|
|
112
|
+
|
|
113
|
+
fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
|
|
114
|
+
expect(fetched.description).to eq("Advanced pottery")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "updates with hash including dot notation" do
|
|
118
|
+
updated = store.update(
|
|
119
|
+
model: DatabaseStoreTestModels::TestPotteryClass,
|
|
120
|
+
class_id: "pottery_101",
|
|
121
|
+
attributes: { "studio.location" => "Downtown" }
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
expect(updated.studio.location).to eq("Downtown")
|
|
125
|
+
|
|
126
|
+
fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
|
|
127
|
+
expect(fetched.studio.location).to eq("Downtown")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe "#destroy" do
|
|
132
|
+
let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
|
|
133
|
+
let(:pottery_class) do
|
|
134
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
135
|
+
class_id: "pottery_101",
|
|
136
|
+
description: "Basic pottery",
|
|
137
|
+
studio: studio
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
before { store.save(pottery_class) }
|
|
142
|
+
|
|
143
|
+
it "destroys a model and its composite relationships" do
|
|
144
|
+
result = store.destroy(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
|
|
145
|
+
expect(result).to be true
|
|
146
|
+
|
|
147
|
+
fetched = store.fetch(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")
|
|
148
|
+
expect(fetched).to be_nil
|
|
149
|
+
|
|
150
|
+
studio_fetched = store.fetch(model: DatabaseStoreTestModels::TestStudio, studio_key: "test_studio")
|
|
151
|
+
expect(studio_fetched).to be_nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "returns false for non-existent models" do
|
|
155
|
+
result = store.destroy(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "nonexistent")
|
|
156
|
+
expect(result).to be false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
describe "#where" do
|
|
161
|
+
let(:studio1) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio1", name: "Studio One") }
|
|
162
|
+
let(:studio2) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio2", name: "Studio Two") }
|
|
163
|
+
let(:pottery1) do
|
|
164
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
165
|
+
class_id: "pottery_101",
|
|
166
|
+
description: "Basic pottery",
|
|
167
|
+
studio: studio1
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
let(:pottery2) do
|
|
171
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
172
|
+
class_id: "pottery_201",
|
|
173
|
+
description: "Advanced pottery",
|
|
174
|
+
studio: studio2
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
before do
|
|
179
|
+
store.save([pottery1, pottery2])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "finds models by criteria" do
|
|
183
|
+
results = store.where(model: DatabaseStoreTestModels::TestPotteryClass, description: "Basic pottery")
|
|
184
|
+
expect(results.size).to eq(1)
|
|
185
|
+
expect(results.first.class_id).to eq("pottery_101")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
describe "#all" do
|
|
190
|
+
let(:studio1) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio1", name: "Studio One") }
|
|
191
|
+
let(:studio2) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio2", name: "Studio Two") }
|
|
192
|
+
let(:pottery1) do
|
|
193
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
194
|
+
class_id: "pottery_101",
|
|
195
|
+
description: "Basic pottery",
|
|
196
|
+
studio: studio1
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
let(:pottery2) do
|
|
200
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
201
|
+
class_id: "pottery_201",
|
|
202
|
+
description: "Advanced pottery",
|
|
203
|
+
studio: studio2
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
before do
|
|
208
|
+
store.save([pottery1, pottery2])
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "returns all models of a specific type" do
|
|
212
|
+
results = store.all(model: DatabaseStoreTestModels::TestPotteryClass)
|
|
213
|
+
expect(results.size).to eq(2)
|
|
214
|
+
expect(results.map(&:class_id)).to contain_exactly("pottery_101", "pottery_201")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
describe "#exists?" do
|
|
219
|
+
let(:studio) { DatabaseStoreTestModels::TestStudio.new(studio_key: "test_studio", name: "Test Studio") }
|
|
220
|
+
let(:pottery_class) do
|
|
221
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
222
|
+
class_id: "pottery_101",
|
|
223
|
+
description: "Basic pottery",
|
|
224
|
+
studio: studio
|
|
225
|
+
)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
before { store.save(pottery_class) }
|
|
229
|
+
|
|
230
|
+
it "returns true for existing models" do
|
|
231
|
+
expect(store.exists?(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "pottery_101")).to be true
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "returns false for non-existent models" do
|
|
235
|
+
expect(store.exists?(model: DatabaseStoreTestModels::TestPotteryClass, class_id: "nonexistent")).to be false
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
describe "#count" do
|
|
240
|
+
let(:studio1) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio1", name: "Studio One") }
|
|
241
|
+
let(:studio2) { DatabaseStoreTestModels::TestStudio.new(studio_key: "studio2", name: "Studio Two") }
|
|
242
|
+
let(:pottery1) do
|
|
243
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
244
|
+
class_id: "pottery_101",
|
|
245
|
+
description: "Basic pottery",
|
|
246
|
+
studio: studio1
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
let(:pottery2) do
|
|
250
|
+
DatabaseStoreTestModels::TestPotteryClass.new(
|
|
251
|
+
class_id: "pottery_201",
|
|
252
|
+
description: "Advanced pottery",
|
|
253
|
+
studio: studio2
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
before do
|
|
258
|
+
store.save([pottery1, pottery2])
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it "returns count of models" do
|
|
262
|
+
expect(store.count(model: DatabaseStoreTestModels::TestPotteryClass)).to eq(2)
|
|
263
|
+
expect(store.count(model: DatabaseStoreTestModels::TestStudio)).to eq(2)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
describe "#stats" do
|
|
268
|
+
it "provides statistics" do
|
|
269
|
+
stats = store.stats
|
|
270
|
+
expect(stats).to include(:models_registered, :registered_models, :total_models)
|
|
271
|
+
expect(stats[:models_registered]).to eq(3)
|
|
272
|
+
expect(stats[:registered_models]).to include(
|
|
273
|
+
"DatabaseStoreTestModels::TestPotteryClass",
|
|
274
|
+
"DatabaseStoreTestModels::TestStudio",
|
|
275
|
+
"DatabaseStoreTestModels::TestCeramicStudio"
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
class FileTestItem < Lutaml::Model::Serializable
|
|
8
|
+
attribute :name, :string
|
|
9
|
+
attribute :value, :string
|
|
10
|
+
|
|
11
|
+
key_value do
|
|
12
|
+
map :name, to: :name
|
|
13
|
+
map :value, to: :value
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RSpec.describe "Lutaml::Store file I/O" do
|
|
18
|
+
let(:tmpdir) { Dir.mktmpdir }
|
|
19
|
+
after { FileUtils.rm_rf(tmpdir) }
|
|
20
|
+
|
|
21
|
+
let(:store) do
|
|
22
|
+
Lutaml::Store.new(
|
|
23
|
+
adapter: :memory,
|
|
24
|
+
models: [{ model: FileTestItem, key: :name, dir: "items" }]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
let(:items) do
|
|
29
|
+
[
|
|
30
|
+
FileTestItem.new(name: "alpha", value: "first"),
|
|
31
|
+
FileTestItem.new(name: "beta", value: "second"),
|
|
32
|
+
FileTestItem.new(name: "gamma", value: "third")
|
|
33
|
+
]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def items_dir
|
|
37
|
+
File.join(tmpdir, "items")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# ── YAML format ──
|
|
41
|
+
|
|
42
|
+
describe "YAML separate layout" do
|
|
43
|
+
it "round-trips models" do
|
|
44
|
+
store.save_all(items, path: tmpdir, format: :yaml, layout: :separate)
|
|
45
|
+
|
|
46
|
+
expect(Dir.glob(File.join(items_dir, "*.{yaml,yml}")).size).to eq(3)
|
|
47
|
+
|
|
48
|
+
loaded = store.load_all(FileTestItem, path: tmpdir, format: :yaml, layout: :separate)
|
|
49
|
+
expect(loaded.size).to eq(3)
|
|
50
|
+
expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
|
|
51
|
+
expect(loaded.map(&:value).sort).to eq(%w[first second third])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "writes one file per model named by key" do
|
|
55
|
+
store.save_all(items, path: tmpdir, format: :yaml, layout: :separate)
|
|
56
|
+
|
|
57
|
+
expect(File.exist?(File.join(items_dir, "alpha.yaml"))).to be true
|
|
58
|
+
expect(File.exist?(File.join(items_dir, "beta.yaml"))).to be true
|
|
59
|
+
expect(File.exist?(File.join(items_dir, "gamma.yaml"))).to be true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe "YAML grouped layout" do
|
|
64
|
+
it "writes one file per key" do
|
|
65
|
+
store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
|
|
66
|
+
|
|
67
|
+
files = Dir.glob(File.join(items_dir, "*.yaml")).sort
|
|
68
|
+
expect(files.size).to eq(3)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "round-trips models" do
|
|
72
|
+
store.save_all(items, path: tmpdir, format: :yaml, layout: :grouped)
|
|
73
|
+
|
|
74
|
+
loaded = store.load_all(FileTestItem, path: tmpdir, format: :yaml, layout: :grouped)
|
|
75
|
+
expect(loaded.size).to eq(3)
|
|
76
|
+
expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# ── JSON format ──
|
|
81
|
+
|
|
82
|
+
describe "JSON separate layout" do
|
|
83
|
+
it "round-trips models" do
|
|
84
|
+
store.save_all(items, path: tmpdir, format: :json, layout: :separate)
|
|
85
|
+
|
|
86
|
+
expect(Dir.glob(File.join(items_dir, "*.json")).size).to eq(3)
|
|
87
|
+
|
|
88
|
+
loaded = store.load_all(FileTestItem, path: tmpdir, format: :json, layout: :separate)
|
|
89
|
+
expect(loaded.size).to eq(3)
|
|
90
|
+
expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "writes valid JSON files" do
|
|
94
|
+
store.save_all(items, path: tmpdir, format: :json, layout: :separate)
|
|
95
|
+
|
|
96
|
+
Dir.glob(File.join(items_dir, "*.json")).each do |path|
|
|
97
|
+
expect { JSON.parse(File.read(path, encoding: "utf-8")) }.not_to raise_error
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ── JSONL format ──
|
|
103
|
+
|
|
104
|
+
describe "JSONL export/import" do
|
|
105
|
+
it "exports models to JSONL file" do
|
|
106
|
+
export_path = File.join(tmpdir, "data.jsonl")
|
|
107
|
+
store.export(items, path: export_path, format: :jsonl)
|
|
108
|
+
|
|
109
|
+
expect(File.exist?(export_path)).to be true
|
|
110
|
+
lines = File.read(export_path, encoding: "utf-8").lines.reject { |l| l.strip.empty? }
|
|
111
|
+
expect(lines.size).to eq(3)
|
|
112
|
+
lines.each { |line| expect { JSON.parse(line) }.not_to raise_error }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "round-trips through JSONL file" do
|
|
116
|
+
export_path = File.join(tmpdir, "data.jsonl")
|
|
117
|
+
store.export(items, path: export_path, format: :jsonl)
|
|
118
|
+
|
|
119
|
+
fmt = Lutaml::Store::Format.resolve(:jsonl)
|
|
120
|
+
data = File.read(export_path, encoding: "utf-8")
|
|
121
|
+
loaded = fmt.deserialize_many(data, FileTestItem)
|
|
122
|
+
|
|
123
|
+
expect(loaded.size).to eq(3)
|
|
124
|
+
expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Marshal format ──
|
|
129
|
+
|
|
130
|
+
describe "Marshal separate layout" do
|
|
131
|
+
it "round-trips models" do
|
|
132
|
+
store.save_all(items, path: tmpdir, format: :marshal, layout: :separate)
|
|
133
|
+
|
|
134
|
+
expect(Dir.glob(File.join(items_dir, "*.bin")).size).to eq(3)
|
|
135
|
+
|
|
136
|
+
loaded = store.load_all(FileTestItem, path: tmpdir, format: :marshal, layout: :separate)
|
|
137
|
+
expect(loaded.size).to eq(3)
|
|
138
|
+
expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ── YAMLS format ──
|
|
143
|
+
|
|
144
|
+
describe "YAMLS grouped layout" do
|
|
145
|
+
it "writes multi-document YAML files" do
|
|
146
|
+
# Grouped YAMLS: each group file contains multiple docs
|
|
147
|
+
store.save_all(items, path: tmpdir, format: :yamls, layout: :grouped)
|
|
148
|
+
|
|
149
|
+
files = Dir.glob(File.join(items_dir, "*.{yaml,yml}")).sort
|
|
150
|
+
expect(files.size).to eq(3)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "round-trips models" do
|
|
154
|
+
store.save_all(items, path: tmpdir, format: :yamls, layout: :grouped)
|
|
155
|
+
|
|
156
|
+
loaded = store.load_all(FileTestItem, path: tmpdir, format: :yamls, layout: :grouped)
|
|
157
|
+
expect(loaded.size).to eq(3)
|
|
158
|
+
expect(loaded.map(&:name).sort).to eq(%w[alpha beta gamma])
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ── Edge cases ──
|
|
163
|
+
|
|
164
|
+
describe "empty directory" do
|
|
165
|
+
it "returns empty array from load_all" do
|
|
166
|
+
FileUtils.mkdir_p(items_dir)
|
|
167
|
+
|
|
168
|
+
loaded = store.load_all(FileTestItem, path: tmpdir, format: :yaml, layout: :separate)
|
|
169
|
+
expect(loaded).to eq([])
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
describe "empty model list" do
|
|
174
|
+
it "returns empty array from save_all" do
|
|
175
|
+
result = store.save_all([], path: tmpdir, format: :yaml, layout: :separate)
|
|
176
|
+
expect(result).to eq([])
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe "missing directory" do
|
|
181
|
+
it "raises BackendError for load_all" do
|
|
182
|
+
expect do
|
|
183
|
+
store.load_all(FileTestItem, path: "/nonexistent/path", format: :yaml, layout: :separate)
|
|
184
|
+
end.to raise_error(Lutaml::Store::BackendError, /Directory not found/)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe "no path provided" do
|
|
189
|
+
it "raises BackendError for save_all without path" do
|
|
190
|
+
items_with_no_dir = [
|
|
191
|
+
FileTestItem.new(name: "x", value: "y")
|
|
192
|
+
]
|
|
193
|
+
# No dir in registration and no path
|
|
194
|
+
bare_store = Lutaml::Store.new(
|
|
195
|
+
adapter: :memory,
|
|
196
|
+
models: [{ model: FileTestItem, key: :name }]
|
|
197
|
+
)
|
|
198
|
+
expect do
|
|
199
|
+
bare_store.save_all(items_with_no_dir, format: :yaml, layout: :separate)
|
|
200
|
+
end.to raise_error(Lutaml::Store::BackendError, /No directory specified/)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
describe "unknown layout" do
|
|
205
|
+
it "raises ConfigurationError" do
|
|
206
|
+
expect do
|
|
207
|
+
store.save_all(items, path: tmpdir, format: :yaml, layout: :unknown)
|
|
208
|
+
end.to raise_error(Lutaml::Store::ConfigurationError, /Unknown layout/)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
describe "unknown format" do
|
|
213
|
+
it "raises UnsupportedFormatError" do
|
|
214
|
+
expect do
|
|
215
|
+
store.save_all(items, path: tmpdir, format: :csv, layout: :separate)
|
|
216
|
+
end.to raise_error(Lutaml::Store::Format::UnsupportedFormatError)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
class FormatTestItem < Lutaml::Model::Serializable
|
|
7
|
+
attribute :name, :string
|
|
8
|
+
attribute :value, :string
|
|
9
|
+
attribute :count, :integer
|
|
10
|
+
|
|
11
|
+
key_value do
|
|
12
|
+
map :name, to: :name
|
|
13
|
+
map :value, to: :value
|
|
14
|
+
map :count, to: :count
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
RSpec.describe "Format handler round-trips" do
|
|
19
|
+
let(:item) { FormatTestItem.new(name: "alpha", value: "first", count: 42) }
|
|
20
|
+
let(:items) do
|
|
21
|
+
[
|
|
22
|
+
FormatTestItem.new(name: "alpha", value: "first", count: 1),
|
|
23
|
+
FormatTestItem.new(name: "beta", value: "second", count: 2)
|
|
24
|
+
]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
shared_examples "single-model round-trip" do |format_sym|
|
|
28
|
+
it "round-trips a single model through #{format_sym}" do
|
|
29
|
+
fmt = Lutaml::Store::Format.resolve(format_sym)
|
|
30
|
+
serialized = fmt.serialize(item)
|
|
31
|
+
restored = fmt.deserialize(serialized, FormatTestItem)
|
|
32
|
+
|
|
33
|
+
expect(restored.name).to eq("alpha")
|
|
34
|
+
expect(restored.value).to eq("first")
|
|
35
|
+
expect(restored.count).to eq(42)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
shared_examples "multi-model round-trip" do |format_sym|
|
|
40
|
+
it "round-trips multiple models through #{format_sym}" do
|
|
41
|
+
fmt = Lutaml::Store::Format.resolve(format_sym)
|
|
42
|
+
serialized = fmt.serialize_many(items)
|
|
43
|
+
restored = fmt.deserialize_many(serialized, FormatTestItem)
|
|
44
|
+
|
|
45
|
+
expect(restored.size).to eq(2)
|
|
46
|
+
expect(restored.map(&:name).sort).to eq(%w[alpha beta])
|
|
47
|
+
expect(restored.map(&:count).sort).to eq([1, 2])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe Lutaml::Store::Format::Yaml do
|
|
52
|
+
it_behaves_like "single-model round-trip", :yaml
|
|
53
|
+
it_behaves_like "multi-model round-trip", :yaml
|
|
54
|
+
|
|
55
|
+
it "produces valid YAML" do
|
|
56
|
+
fmt = described_class.new
|
|
57
|
+
output = fmt.serialize(item)
|
|
58
|
+
expect(output).to include("name:")
|
|
59
|
+
expect(output).to include("value:")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe Lutaml::Store::Format::Yamls do
|
|
64
|
+
it_behaves_like "single-model round-trip", :yamls
|
|
65
|
+
it_behaves_like "multi-model round-trip", :yamls
|
|
66
|
+
|
|
67
|
+
it "produces multi-document YAML stream" do
|
|
68
|
+
fmt = described_class.new
|
|
69
|
+
output = fmt.serialize_many(items)
|
|
70
|
+
expect(output.scan(/^---/).size).to be >= 2
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe Lutaml::Store::Format::Json do
|
|
75
|
+
it_behaves_like "single-model round-trip", :json
|
|
76
|
+
|
|
77
|
+
it "produces valid JSON" do
|
|
78
|
+
fmt = described_class.new
|
|
79
|
+
output = fmt.serialize(item)
|
|
80
|
+
parsed = JSON.parse(output)
|
|
81
|
+
expect(parsed["name"]).to eq("alpha")
|
|
82
|
+
expect(parsed["count"]).to eq(42)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe Lutaml::Store::Format::Jsonl do
|
|
87
|
+
it_behaves_like "single-model round-trip", :jsonl
|
|
88
|
+
it_behaves_like "multi-model round-trip", :jsonl
|
|
89
|
+
|
|
90
|
+
it "produces line-delimited JSON" do
|
|
91
|
+
fmt = described_class.new
|
|
92
|
+
output = fmt.serialize_many(items)
|
|
93
|
+
lines = output.lines.reject { |l| l.strip.empty? }
|
|
94
|
+
expect(lines.size).to eq(2)
|
|
95
|
+
lines.each { |line| expect { JSON.parse(line) }.not_to raise_error }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe Lutaml::Store::Format::MarshalFormat do
|
|
100
|
+
it_behaves_like "single-model round-trip", :marshal
|
|
101
|
+
it_behaves_like "multi-model round-trip", :marshal
|
|
102
|
+
|
|
103
|
+
it "produces binary data" do
|
|
104
|
+
fmt = described_class.new
|
|
105
|
+
output = fmt.serialize(item)
|
|
106
|
+
expect(output).to be_a(String)
|
|
107
|
+
expect(output.encoding).to eq(Encoding::ASCII_8BIT)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
RSpec.describe Lutaml::Store::Format do
|
|
7
|
+
describe ".resolve" do
|
|
8
|
+
it "resolves :yaml format" do
|
|
9
|
+
fmt = described_class.resolve(:yaml)
|
|
10
|
+
expect(fmt).to be_a(Lutaml::Store::Format::Yaml)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "resolves :yamls format" do
|
|
14
|
+
fmt = described_class.resolve(:yamls)
|
|
15
|
+
expect(fmt).to be_a(Lutaml::Store::Format::Yamls)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "resolves :json format" do
|
|
19
|
+
fmt = described_class.resolve(:json)
|
|
20
|
+
expect(fmt).to be_a(Lutaml::Store::Format::Json)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "resolves :jsonl format" do
|
|
24
|
+
fmt = described_class.resolve(:jsonl)
|
|
25
|
+
expect(fmt).to be_a(Lutaml::Store::Format::Jsonl)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "resolves :marshal format" do
|
|
29
|
+
fmt = described_class.resolve(:marshal)
|
|
30
|
+
expect(fmt).to be_a(Lutaml::Store::Format::MarshalFormat)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "raises for unknown format" do
|
|
34
|
+
expect { described_class.resolve(:unknown) }
|
|
35
|
+
.to raise_error(Lutaml::Store::Format::UnsupportedFormatError)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe Lutaml::Store::Format::Yaml do
|
|
40
|
+
let(:fmt) { described_class.new }
|
|
41
|
+
|
|
42
|
+
it "returns .yaml extension" do
|
|
43
|
+
expect(fmt.extension).to eq(".yaml")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "returns glob pattern" do
|
|
47
|
+
expect(fmt.glob_pattern).to eq("*.{yaml,yml}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe Lutaml::Store::Format::Json do
|
|
52
|
+
let(:fmt) { described_class.new }
|
|
53
|
+
|
|
54
|
+
it "returns .json extension" do
|
|
55
|
+
expect(fmt.extension).to eq(".json")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "returns glob pattern" do
|
|
59
|
+
expect(fmt.glob_pattern).to eq("*.json")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe Lutaml::Store::Format::MarshalFormat do
|
|
64
|
+
let(:fmt) { described_class.new }
|
|
65
|
+
|
|
66
|
+
it "returns .bin extension" do
|
|
67
|
+
expect(fmt.extension).to eq(".bin")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|