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,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module IntegrationTestModels
|
|
7
|
+
class TestDocument < Lutaml::Model::Serializable
|
|
8
|
+
attribute :id, :string
|
|
9
|
+
attribute :title, :string
|
|
10
|
+
attribute :content, :string
|
|
11
|
+
attribute :created_at, :string
|
|
12
|
+
|
|
13
|
+
key_value do
|
|
14
|
+
map :id, to: :id
|
|
15
|
+
map :title, to: :title
|
|
16
|
+
map :content, to: :content
|
|
17
|
+
map :created_at, to: :created_at
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class TestAuthor < Lutaml::Model::Serializable
|
|
22
|
+
attribute :name, :string
|
|
23
|
+
attribute :email, :string
|
|
24
|
+
attribute :bio, :string
|
|
25
|
+
|
|
26
|
+
key_value do
|
|
27
|
+
map :name, to: :name
|
|
28
|
+
map :email, to: :email
|
|
29
|
+
map :bio, to: :bio
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class TestBook < Lutaml::Model::Serializable
|
|
34
|
+
attribute :isbn, :string
|
|
35
|
+
attribute :title, :string
|
|
36
|
+
attribute :author, :string
|
|
37
|
+
attribute :published_year, :integer
|
|
38
|
+
attribute :genre, :string
|
|
39
|
+
|
|
40
|
+
key_value do
|
|
41
|
+
map :isbn, to: :isbn
|
|
42
|
+
map :title, to: :title
|
|
43
|
+
map :author, to: :author
|
|
44
|
+
map :published_year, to: :published_year
|
|
45
|
+
map :genre, to: :genre
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
RSpec.describe "Lutaml::Store with Lutaml::Model::Serializable integration" do
|
|
51
|
+
let(:doc_model) { { model: IntegrationTestModels::TestDocument, key: :id, dir: "documents" } }
|
|
52
|
+
let(:author_model) { { model: IntegrationTestModels::TestAuthor, key: :name, dir: "authors" } }
|
|
53
|
+
let(:book_model) { { model: IntegrationTestModels::TestBook, key: :isbn, dir: "books" } }
|
|
54
|
+
|
|
55
|
+
describe "single model CRUD" do
|
|
56
|
+
let(:store) { Lutaml::Store.new(adapter: :memory, models: [doc_model]) }
|
|
57
|
+
let(:document) do
|
|
58
|
+
IntegrationTestModels::TestDocument.new(
|
|
59
|
+
id: "doc1", title: "Test Document",
|
|
60
|
+
content: "This is a test", created_at: "2024-01-01"
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "saves and fetches a Lutaml::Model::Serializable instance" do
|
|
65
|
+
store.save(document)
|
|
66
|
+
|
|
67
|
+
retrieved = store.fetch(model: IntegrationTestModels::TestDocument, id: "doc1")
|
|
68
|
+
expect(retrieved).to be_a(IntegrationTestModels::TestDocument)
|
|
69
|
+
expect(retrieved.id).to eq("doc1")
|
|
70
|
+
expect(retrieved.title).to eq("Test Document")
|
|
71
|
+
expect(retrieved.content).to eq("This is a test")
|
|
72
|
+
expect(retrieved.created_at).to eq("2024-01-01")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "returns nil for non-existent key" do
|
|
76
|
+
expect(store.fetch(model: IntegrationTestModels::TestDocument, id: "missing")).to be_nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "updates a model with hash attributes" do
|
|
80
|
+
store.save(document)
|
|
81
|
+
|
|
82
|
+
updated = store.update(
|
|
83
|
+
model: IntegrationTestModels::TestDocument,
|
|
84
|
+
id: "doc1",
|
|
85
|
+
attributes: { title: "Updated Title" }
|
|
86
|
+
)
|
|
87
|
+
expect(updated.title).to eq("Updated Title")
|
|
88
|
+
expect(updated.content).to eq("This is a test")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "updates a model with block" do
|
|
92
|
+
store.save(document)
|
|
93
|
+
|
|
94
|
+
updated = store.update(model: IntegrationTestModels::TestDocument, id: "doc1") do |model|
|
|
95
|
+
model.content = "New content"
|
|
96
|
+
model
|
|
97
|
+
end
|
|
98
|
+
expect(updated.content).to eq("New content")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "deletes a model" do
|
|
102
|
+
store.save(document)
|
|
103
|
+
expect(store.exists?(model: IntegrationTestModels::TestDocument, id: "doc1")).to be true
|
|
104
|
+
|
|
105
|
+
result = store.destroy(model: IntegrationTestModels::TestDocument, id: "doc1")
|
|
106
|
+
expect(result).to be true
|
|
107
|
+
expect(store.fetch(model: IntegrationTestModels::TestDocument, id: "doc1")).to be_nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "reports count and exists" do
|
|
111
|
+
expect(store.count(model: IntegrationTestModels::TestDocument)).to eq(0)
|
|
112
|
+
|
|
113
|
+
store.save(document)
|
|
114
|
+
expect(store.count(model: IntegrationTestModels::TestDocument)).to eq(1)
|
|
115
|
+
expect(store.exists?(model: IntegrationTestModels::TestDocument, id: "doc1")).to be true
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe "model type validation" do
|
|
120
|
+
let(:store) { Lutaml::Store.new(adapter: :memory, models: [doc_model]) }
|
|
121
|
+
|
|
122
|
+
it "raises when saving an unregistered model" do
|
|
123
|
+
author = IntegrationTestModels::TestAuthor.new(name: "John Doe", email: "john@example.com")
|
|
124
|
+
|
|
125
|
+
expect { store.save(author) }.to raise_error(Lutaml::Store::ModelNotRegisteredError)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
describe "collection operations" do
|
|
130
|
+
let(:store) { Lutaml::Store.new(adapter: :memory, models: [author_model]) }
|
|
131
|
+
let(:authors) do
|
|
132
|
+
[
|
|
133
|
+
IntegrationTestModels::TestAuthor.new(name: "Alice Johnson", email: "alice@example.com", bio: "Fiction writer"),
|
|
134
|
+
IntegrationTestModels::TestAuthor.new(name: "Bob Wilson", email: "bob@example.com", bio: "Technical writer"),
|
|
135
|
+
IntegrationTestModels::TestAuthor.new(name: "Carol Davis", email: "carol@example.com", bio: "Science writer")
|
|
136
|
+
]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "stores multiple models and retrieves all" do
|
|
140
|
+
authors.each { |a| store.save(a) }
|
|
141
|
+
|
|
142
|
+
all_authors = store.all(model: IntegrationTestModels::TestAuthor)
|
|
143
|
+
expect(all_authors.size).to eq(3)
|
|
144
|
+
expect(all_authors.map(&:name).sort).to eq(["Alice Johnson", "Bob Wilson", "Carol Davis"])
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it "queries with where" do
|
|
148
|
+
authors.each { |a| store.save(a) }
|
|
149
|
+
|
|
150
|
+
fiction = store.where(model: IntegrationTestModels::TestAuthor, bio: "Fiction writer")
|
|
151
|
+
expect(fiction.size).to eq(1)
|
|
152
|
+
expect(fiction.first.name).to eq("Alice Johnson")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it "updates a model and persists the change" do
|
|
156
|
+
authors.each { |a| store.save(a) }
|
|
157
|
+
|
|
158
|
+
store.update(model: IntegrationTestModels::TestAuthor, name: "Alice Johnson") do |model|
|
|
159
|
+
model.bio = "Updated bio: Fiction and mystery writer"
|
|
160
|
+
model
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
retrieved = store.fetch(model: IntegrationTestModels::TestAuthor, name: "Alice Johnson")
|
|
164
|
+
expect(retrieved.bio).to eq("Updated bio: Fiction and mystery writer")
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe "multi-model store" do
|
|
169
|
+
let(:store) { Lutaml::Store.new(adapter: :memory, models: [doc_model, author_model, book_model]) }
|
|
170
|
+
|
|
171
|
+
it "stores and retrieves different model types independently" do
|
|
172
|
+
document = IntegrationTestModels::TestDocument.new(id: "doc1", title: "Test Doc")
|
|
173
|
+
author = IntegrationTestModels::TestAuthor.new(name: "John Doe", email: "john@example.com")
|
|
174
|
+
book = IntegrationTestModels::TestBook.new(isbn: "978-123", title: "Test Book", author: "John Doe",
|
|
175
|
+
published_year: 2024, genre: "Fiction")
|
|
176
|
+
|
|
177
|
+
store.save(document)
|
|
178
|
+
store.save(author)
|
|
179
|
+
store.save(book)
|
|
180
|
+
|
|
181
|
+
expect(store.count(model: IntegrationTestModels::TestDocument)).to eq(1)
|
|
182
|
+
expect(store.count(model: IntegrationTestModels::TestAuthor)).to eq(1)
|
|
183
|
+
expect(store.count(model: IntegrationTestModels::TestBook)).to eq(1)
|
|
184
|
+
|
|
185
|
+
fetched_doc = store.fetch(model: IntegrationTestModels::TestDocument, id: "doc1")
|
|
186
|
+
fetched_author = store.fetch(model: IntegrationTestModels::TestAuthor, name: "John Doe")
|
|
187
|
+
fetched_book = store.fetch(model: IntegrationTestModels::TestBook, isbn: "978-123")
|
|
188
|
+
|
|
189
|
+
expect(fetched_doc).to be_a(IntegrationTestModels::TestDocument)
|
|
190
|
+
expect(fetched_author).to be_a(IntegrationTestModels::TestAuthor)
|
|
191
|
+
expect(fetched_book).to be_a(IntegrationTestModels::TestBook)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "isolates where queries by model type" do
|
|
195
|
+
store.save(IntegrationTestModels::TestDocument.new(id: "d1", title: "Ruby Guide"))
|
|
196
|
+
store.save(IntegrationTestModels::TestBook.new(isbn: "b1", title: "Ruby Guide", author: "Author"))
|
|
197
|
+
|
|
198
|
+
docs = store.where(model: IntegrationTestModels::TestDocument, title: "Ruby Guide")
|
|
199
|
+
books = store.where(model: IntegrationTestModels::TestBook, title: "Ruby Guide")
|
|
200
|
+
|
|
201
|
+
expect(docs.size).to eq(1)
|
|
202
|
+
expect(docs.first).to be_a(IntegrationTestModels::TestDocument)
|
|
203
|
+
expect(books.size).to eq(1)
|
|
204
|
+
expect(books.first).to be_a(IntegrationTestModels::TestBook)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
describe "file I/O round-trip" do
|
|
209
|
+
let(:tmpdir) { Dir.mktmpdir }
|
|
210
|
+
after { FileUtils.rm_rf(tmpdir) }
|
|
211
|
+
|
|
212
|
+
let(:store) { Lutaml::Store.new(adapter: :memory, models: [book_model]) }
|
|
213
|
+
let(:books) do
|
|
214
|
+
[
|
|
215
|
+
IntegrationTestModels::TestBook.new(isbn: "978-0123456789", title: "Ruby Programming", author: "Jane Smith",
|
|
216
|
+
published_year: 2023, genre: "Programming"),
|
|
217
|
+
IntegrationTestModels::TestBook.new(isbn: "978-9876543210", title: "Go Programming", author: "Bob Jones",
|
|
218
|
+
published_year: 2024, genre: "Programming")
|
|
219
|
+
]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it "round-trips through YAML separate layout" do
|
|
223
|
+
store.save_all(books, path: tmpdir, format: :yaml, layout: :separate)
|
|
224
|
+
|
|
225
|
+
fresh_store = Lutaml::Store.new(adapter: :memory, models: [book_model])
|
|
226
|
+
loaded = fresh_store.load_all(IntegrationTestModels::TestBook, path: tmpdir, format: :yaml, layout: :separate)
|
|
227
|
+
|
|
228
|
+
expect(loaded.size).to eq(2)
|
|
229
|
+
expect(loaded.map(&:title).sort).to eq(["Go Programming", "Ruby Programming"])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it "round-trips through JSON separate layout" do
|
|
233
|
+
store.save_all(books, path: tmpdir, format: :json, layout: :separate)
|
|
234
|
+
|
|
235
|
+
fresh_store = Lutaml::Store.new(adapter: :memory, models: [book_model])
|
|
236
|
+
loaded = fresh_store.load_all(IntegrationTestModels::TestBook, path: tmpdir, format: :json, layout: :separate)
|
|
237
|
+
|
|
238
|
+
expect(loaded.size).to eq(2)
|
|
239
|
+
expect(loaded.map(&:author).sort).to eq(["Bob Jones", "Jane Smith"])
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
describe "import_all workflow" do
|
|
244
|
+
let(:tmpdir) { Dir.mktmpdir }
|
|
245
|
+
after { FileUtils.rm_rf(tmpdir) }
|
|
246
|
+
|
|
247
|
+
let(:store) { Lutaml::Store.new(adapter: :memory, models: [doc_model]) }
|
|
248
|
+
let(:documents) do
|
|
249
|
+
[
|
|
250
|
+
IntegrationTestModels::TestDocument.new(id: "doc1", title: "First Document", content: "Content 1"),
|
|
251
|
+
IntegrationTestModels::TestDocument.new(id: "doc2", title: "Second Document", content: "Content 2")
|
|
252
|
+
]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it "loads from directory and makes models queryable" do
|
|
256
|
+
store.save_all(documents, path: tmpdir, format: :yaml, layout: :separate)
|
|
257
|
+
|
|
258
|
+
fresh_store = Lutaml::Store.new(adapter: :memory, models: [doc_model])
|
|
259
|
+
loaded = fresh_store.import_all(IntegrationTestModels::TestDocument, path: tmpdir, format: :yaml,
|
|
260
|
+
layout: :separate)
|
|
261
|
+
|
|
262
|
+
expect(loaded.size).to eq(2)
|
|
263
|
+
|
|
264
|
+
# Queryable via fetch
|
|
265
|
+
doc1 = fresh_store.fetch(model: IntegrationTestModels::TestDocument, id: "doc1")
|
|
266
|
+
expect(doc1).not_to be_nil
|
|
267
|
+
expect(doc1.title).to eq("First Document")
|
|
268
|
+
|
|
269
|
+
# Queryable via where
|
|
270
|
+
second = fresh_store.where(model: IntegrationTestModels::TestDocument, title: "Second Document")
|
|
271
|
+
expect(second.size).to eq(1)
|
|
272
|
+
expect(second.first.content).to eq("Content 2")
|
|
273
|
+
|
|
274
|
+
# Queryable via count
|
|
275
|
+
expect(fresh_store.count(model: IntegrationTestModels::TestDocument)).to eq(2)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
describe "statistics" do
|
|
280
|
+
let(:store) { Lutaml::Store.new(adapter: :memory, models: [doc_model]) }
|
|
281
|
+
|
|
282
|
+
it "reports registered models and counts" do
|
|
283
|
+
store.save(IntegrationTestModels::TestDocument.new(id: "doc1", title: "Test"))
|
|
284
|
+
|
|
285
|
+
stats = store.stats
|
|
286
|
+
expect(stats[:models_registered]).to eq(1)
|
|
287
|
+
expect(stats[:total_models]).to eq(1)
|
|
288
|
+
expect(stats[:registered_models]).to include("IntegrationTestModels::TestDocument")
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
module ModelSerializerTestModels
|
|
6
|
+
class SerialBook < Lutaml::Model::Serializable
|
|
7
|
+
attribute :isbn, :string
|
|
8
|
+
attribute :title, :string
|
|
9
|
+
attribute :author, :string
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class SerialEbook < SerialBook
|
|
13
|
+
attribute :format, :string
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
RSpec.describe Lutaml::Store::ModelSerializer do
|
|
18
|
+
subject(:serializer) { described_class.new }
|
|
19
|
+
|
|
20
|
+
describe "#serialize" do
|
|
21
|
+
it "serializes a model to a hash with class metadata" do
|
|
22
|
+
book = ModelSerializerTestModels::SerialBook.new(isbn: "978-123", title: "Test Book", author: "Author")
|
|
23
|
+
result = serializer.serialize(book)
|
|
24
|
+
|
|
25
|
+
expect(result).to be_a(Hash)
|
|
26
|
+
expect(result["_class"]).to eq("ModelSerializerTestModels::SerialBook")
|
|
27
|
+
expect(result["isbn"]).to eq("978-123")
|
|
28
|
+
expect(result["title"]).to eq("Test Book")
|
|
29
|
+
expect(result["author"]).to eq("Author")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "serializes a subclass with correct class name" do
|
|
33
|
+
ebook = ModelSerializerTestModels::SerialEbook.new(isbn: "978-456", title: "Digital Book", format: "epub")
|
|
34
|
+
result = serializer.serialize(ebook)
|
|
35
|
+
|
|
36
|
+
expect(result["_class"]).to eq("ModelSerializerTestModels::SerialEbook")
|
|
37
|
+
expect(result["format"]).to eq("epub")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe "#deserialize" do
|
|
42
|
+
it "deserializes a hash to the correct model class" do
|
|
43
|
+
data = {
|
|
44
|
+
"_class" => "ModelSerializerTestModels::SerialBook",
|
|
45
|
+
"isbn" => "978-123",
|
|
46
|
+
"title" => "Test Book",
|
|
47
|
+
"author" => "Author"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
result = serializer.deserialize(data, ModelSerializerTestModels::SerialBook)
|
|
51
|
+
|
|
52
|
+
expect(result).to be_a(ModelSerializerTestModels::SerialBook)
|
|
53
|
+
expect(result.isbn).to eq("978-123")
|
|
54
|
+
expect(result.title).to eq("Test Book")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "deserializes to a subclass when class metadata indicates it" do
|
|
58
|
+
data = {
|
|
59
|
+
"_class" => "ModelSerializerTestModels::SerialEbook",
|
|
60
|
+
"isbn" => "978-456",
|
|
61
|
+
"title" => "Digital",
|
|
62
|
+
"format" => "epub"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
result = serializer.deserialize(data, ModelSerializerTestModels::SerialBook)
|
|
66
|
+
|
|
67
|
+
expect(result).to be_a(ModelSerializerTestModels::SerialEbook)
|
|
68
|
+
expect(result.format).to eq("epub")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "rejects incompatible polymorphic types" do
|
|
72
|
+
data = {
|
|
73
|
+
"_class" => "ModelSerializerTestModels::SerialBook",
|
|
74
|
+
"isbn" => "978-123"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
expect do
|
|
78
|
+
serializer.deserialize(data, ModelSerializerTestModels::SerialEbook)
|
|
79
|
+
end.to raise_error(Lutaml::Store::PolymorphicUpdateError, /not compatible/)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "raises on invalid data (missing _class)" do
|
|
83
|
+
data = { "isbn" => "978-123" }
|
|
84
|
+
|
|
85
|
+
expect do
|
|
86
|
+
serializer.deserialize(data, ModelSerializerTestModels::SerialBook)
|
|
87
|
+
end.to raise_error(Lutaml::Store::CompositeModelError, /Invalid serialized data/)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "raises on invalid data (not a Hash)" do
|
|
91
|
+
expect do
|
|
92
|
+
serializer.deserialize("not a hash", ModelSerializerTestModels::SerialBook)
|
|
93
|
+
end.to raise_error(Lutaml::Store::CompositeModelError, /Invalid serialized data/)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it "raises on unresolvable class name" do
|
|
97
|
+
data = { "_class" => "NonExistentClass", "isbn" => "123" }
|
|
98
|
+
|
|
99
|
+
expect do
|
|
100
|
+
serializer.deserialize(data, ModelSerializerTestModels::SerialBook)
|
|
101
|
+
end.to raise_error(Lutaml::Store::CompositeModelError, /Cannot resolve class/)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "strips internal metadata keys from model data" do
|
|
105
|
+
data = {
|
|
106
|
+
"_class" => "ModelSerializerTestModels::SerialBook",
|
|
107
|
+
"_composite_models" => { "studio" => { "storage_key" => "key1" } },
|
|
108
|
+
"isbn" => "978-123",
|
|
109
|
+
"title" => "Test"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
result = serializer.deserialize(data, ModelSerializerTestModels::SerialBook)
|
|
113
|
+
|
|
114
|
+
expect(result.isbn).to eq("978-123")
|
|
115
|
+
expect(result.title).to eq("Test")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe "round-trip" do
|
|
120
|
+
it "serializes and deserializes preserving all attributes" do
|
|
121
|
+
book = ModelSerializerTestModels::SerialBook.new(isbn: "978-789", title: "Round Trip", author: "Traveler")
|
|
122
|
+
serialized = serializer.serialize(book)
|
|
123
|
+
deserialized = serializer.deserialize(serialized, ModelSerializerTestModels::SerialBook)
|
|
124
|
+
|
|
125
|
+
expect(deserialized.isbn).to eq("978-789")
|
|
126
|
+
expect(deserialized.title).to eq("Round Trip")
|
|
127
|
+
expect(deserialized.author).to eq("Traveler")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "preserves subclass identity through round-trip" do
|
|
131
|
+
ebook = ModelSerializerTestModels::SerialEbook.new(isbn: "978-000", title: "E-Book", author: "Digital",
|
|
132
|
+
format: "mobi")
|
|
133
|
+
serialized = serializer.serialize(ebook)
|
|
134
|
+
deserialized = serializer.deserialize(serialized, ModelSerializerTestModels::SerialBook)
|
|
135
|
+
|
|
136
|
+
expect(deserialized).to be_a(ModelSerializerTestModels::SerialEbook)
|
|
137
|
+
expect(deserialized.format).to eq("mobi")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Lutaml::Store::BasicStore do
|
|
6
|
+
let(:store) { described_class.new }
|
|
7
|
+
|
|
8
|
+
describe "initialization" do
|
|
9
|
+
it "creates a store with default memory adapter" do
|
|
10
|
+
expect(store.adapter).to be_a(Lutaml::Store::Adapter::Memory)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "creates a store with configuration hash" do
|
|
14
|
+
config = {
|
|
15
|
+
"adapter" => { "type" => "memory" },
|
|
16
|
+
"cache" => { "enabled" => false }
|
|
17
|
+
}
|
|
18
|
+
store = described_class.new(config)
|
|
19
|
+
expect(store.cache).to be_nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "creates a store with direct adapter instance" do
|
|
23
|
+
adapter = Lutaml::Store::Adapter::Memory.new
|
|
24
|
+
store = described_class.new(adapter)
|
|
25
|
+
expect(store.adapter).to eq(adapter)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
describe "basic operations" do
|
|
30
|
+
it "stores and retrieves values" do
|
|
31
|
+
store.set("key1", "value1")
|
|
32
|
+
expect(store.get("key1")).to eq("value1")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "returns nil for non-existent keys" do
|
|
36
|
+
expect(store.get("nonexistent")).to be_nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "deletes values" do
|
|
40
|
+
store.set("key1", "value1")
|
|
41
|
+
expect(store.delete("key1")).to be true
|
|
42
|
+
expect(store.get("key1")).to be_nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "checks key existence" do
|
|
46
|
+
store.set("key1", "value1")
|
|
47
|
+
expect(store.exists?("key1")).to be true
|
|
48
|
+
expect(store.exists?("nonexistent")).to be false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "returns all data" do
|
|
52
|
+
store.set("key1", "value1")
|
|
53
|
+
store.set("key2", "value2")
|
|
54
|
+
expect(store.all).to eq("key1" => "value1", "key2" => "value2")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "clears all data" do
|
|
58
|
+
store.set("key1", "value1")
|
|
59
|
+
store.clear
|
|
60
|
+
expect(store.size).to eq(0)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "returns correct size" do
|
|
64
|
+
expect(store.size).to eq(0)
|
|
65
|
+
store.set("key1", "value1")
|
|
66
|
+
expect(store.size).to eq(1)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe "caching" do
|
|
71
|
+
let(:config) do
|
|
72
|
+
{
|
|
73
|
+
"adapter" => { "type" => "memory" },
|
|
74
|
+
"cache" => { "enabled" => true, "max_size" => 10 }
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
let(:store) { described_class.new(config) }
|
|
78
|
+
|
|
79
|
+
it "caches retrieved values" do
|
|
80
|
+
store.set("key1", "value1")
|
|
81
|
+
|
|
82
|
+
# First get should hit backend
|
|
83
|
+
value1 = store.get("key1")
|
|
84
|
+
expect(value1).to eq("value1")
|
|
85
|
+
|
|
86
|
+
# Second get should hit cache
|
|
87
|
+
value2 = store.get("key1")
|
|
88
|
+
expect(value2).to eq("value1")
|
|
89
|
+
|
|
90
|
+
# Cache should contain the value
|
|
91
|
+
expect(store.cache_stats[:size]).to eq(1)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "invalidates cache on delete" do
|
|
95
|
+
store.set("key1", "value1")
|
|
96
|
+
store.get("key1") # Cache the value
|
|
97
|
+
|
|
98
|
+
store.delete("key1")
|
|
99
|
+
expect(store.cache_stats[:size]).to eq(0)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe "events" do
|
|
104
|
+
let(:events) { [] }
|
|
105
|
+
|
|
106
|
+
before do
|
|
107
|
+
store.on(:set) { |data| events << data }
|
|
108
|
+
store.on(:get) { |data| events << data }
|
|
109
|
+
store.on(:delete) { |data| events << data }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "emits events for operations" do
|
|
113
|
+
store.set("key1", "value1")
|
|
114
|
+
store.get("key1")
|
|
115
|
+
store.delete("key1")
|
|
116
|
+
|
|
117
|
+
expect(events.size).to eq(3)
|
|
118
|
+
expect(events[0][:event]).to eq(:set)
|
|
119
|
+
expect(events[1][:event]).to eq(:get)
|
|
120
|
+
expect(events[2][:event]).to eq(:delete)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
describe "monitoring" do
|
|
125
|
+
let(:config) do
|
|
126
|
+
{
|
|
127
|
+
"adapter" => { "type" => "memory" },
|
|
128
|
+
"monitoring" => { "enabled" => true }
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
let(:store) { described_class.new(config) }
|
|
132
|
+
|
|
133
|
+
it "tracks operation statistics" do
|
|
134
|
+
store.set("key1", "value1")
|
|
135
|
+
store.get("key1")
|
|
136
|
+
|
|
137
|
+
stats = store.stats
|
|
138
|
+
monitor_stats = stats[:monitor_stats]
|
|
139
|
+
expect(monitor_stats[:operations][:set]).to eq(1)
|
|
140
|
+
expect(monitor_stats[:operations][:get]).to eq(1)
|
|
141
|
+
# NOTE: total_operations includes the size call from stats method
|
|
142
|
+
expect(monitor_stats[:total_operations]).to be >= 2
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe "configuration from file" do
|
|
147
|
+
let(:config_file) { "test_config.yml" }
|
|
148
|
+
let(:config_content) do
|
|
149
|
+
<<~YAML
|
|
150
|
+
lutaml_store:
|
|
151
|
+
adapter:
|
|
152
|
+
type: memory
|
|
153
|
+
cache:
|
|
154
|
+
enabled: true
|
|
155
|
+
max_size: 500
|
|
156
|
+
monitoring:
|
|
157
|
+
enabled: true
|
|
158
|
+
YAML
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
before do
|
|
162
|
+
File.write(config_file, config_content)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
after do
|
|
166
|
+
File.delete(config_file) if File.exist?(config_file)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "loads configuration from YAML file" do
|
|
170
|
+
store = described_class.from_file(config_file)
|
|
171
|
+
expect(store.adapter).to be_a(Lutaml::Store::Adapter::Memory)
|
|
172
|
+
expect(store.cache).not_to be_nil
|
|
173
|
+
expect(store.monitor).not_to be_nil
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
describe "#close" do
|
|
178
|
+
it "closes resources properly" do
|
|
179
|
+
expect { store.close }.not_to raise_error
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Lutaml::Store do
|
|
4
|
+
it "has a version number" do
|
|
5
|
+
expect(Lutaml::Store::VERSION).not_to be nil
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it "provides access to Store and DatabaseStore classes" do
|
|
9
|
+
expect(Lutaml::Store::BasicStore).to be_a(Class)
|
|
10
|
+
expect(Lutaml::Store::DatabaseStore).to be_a(Class)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it "returns a DatabaseStore from .new" do
|
|
14
|
+
test_model = Struct.new(:id, :name)
|
|
15
|
+
store = Lutaml::Store.new(
|
|
16
|
+
adapter: :memory,
|
|
17
|
+
models: [{ model: test_model, key: :id }]
|
|
18
|
+
)
|
|
19
|
+
expect(store).to be_a(Lutaml::Store::DatabaseStore)
|
|
20
|
+
end
|
|
21
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
require "lutaml/store"
|
|
5
|
+
|
|
6
|
+
RSpec.configure do |config|
|
|
7
|
+
# Enable flags like --only-failures and --next-failure
|
|
8
|
+
config.example_status_persistence_file_path = ".rspec_status"
|
|
9
|
+
|
|
10
|
+
# Disable RSpec exposing methods globally on `Module` and `main`
|
|
11
|
+
config.disable_monkey_patching!
|
|
12
|
+
|
|
13
|
+
config.expect_with :rspec do |c|
|
|
14
|
+
c.syntax = :expect
|
|
15
|
+
end
|
|
16
|
+
end
|