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,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Lutaml
|
|
8
|
+
module Store
|
|
9
|
+
module Adapter
|
|
10
|
+
class FileSystem < Base
|
|
11
|
+
DEFAULT_EXTENSION = ".data"
|
|
12
|
+
METADATA_EXTENSION = ".meta"
|
|
13
|
+
|
|
14
|
+
def initialize(config = {})
|
|
15
|
+
super
|
|
16
|
+
@root_path = @config[:path] || raise(ConfigurationError, "FileSystem adapter requires :path config")
|
|
17
|
+
@extension = @config[:extension] || DEFAULT_EXTENSION
|
|
18
|
+
@create_directories = @config.fetch(:create_directories, true)
|
|
19
|
+
@integrity_enabled = @config.fetch(:integrity_checks, true)
|
|
20
|
+
@integrity_algorithm = @config.fetch(:integrity_algorithm, "sha256")
|
|
21
|
+
|
|
22
|
+
setup_directory_structure
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(key)
|
|
26
|
+
file_path = path_for_key(key)
|
|
27
|
+
return nil unless File.exist?(file_path)
|
|
28
|
+
|
|
29
|
+
data = file_safe_read(file_path)
|
|
30
|
+
metadata = read_metadata(key)
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
verify_data_integrity(data, metadata)
|
|
34
|
+
rescue Integrity::IntegrityError => e
|
|
35
|
+
repaired_data = repair_corruption(key)
|
|
36
|
+
return repaired_data if repaired_data
|
|
37
|
+
|
|
38
|
+
raise e
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
data
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set(key, value)
|
|
45
|
+
file_path = path_for_key(key)
|
|
46
|
+
ensure_directory_exists(File.dirname(file_path))
|
|
47
|
+
|
|
48
|
+
full_metadata = wrap_with_integrity(value)
|
|
49
|
+
file_safe_write(file_path, value)
|
|
50
|
+
write_metadata(key, full_metadata) if full_metadata.any?
|
|
51
|
+
value
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete(key)
|
|
55
|
+
file_path = path_for_key(key)
|
|
56
|
+
metadata_path = metadata_path_for_key(key)
|
|
57
|
+
|
|
58
|
+
return nil unless File.exist?(file_path)
|
|
59
|
+
|
|
60
|
+
value = file_safe_read(file_path)
|
|
61
|
+
File.delete(file_path)
|
|
62
|
+
File.delete(metadata_path) if File.exist?(metadata_path)
|
|
63
|
+
|
|
64
|
+
cleanup_empty_directories(File.dirname(file_path))
|
|
65
|
+
|
|
66
|
+
value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def exists?(key)
|
|
70
|
+
File.exist?(path_for_key(key))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def keys
|
|
74
|
+
return [] unless Dir.exist?(@root_path)
|
|
75
|
+
|
|
76
|
+
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).filter_map do |file_path|
|
|
77
|
+
key_from_path(file_path) if File.file?(file_path)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def all
|
|
82
|
+
result = {}
|
|
83
|
+
|
|
84
|
+
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
|
|
85
|
+
next unless File.file?(file_path)
|
|
86
|
+
|
|
87
|
+
key = key_from_path(file_path)
|
|
88
|
+
value = get(key)
|
|
89
|
+
result[key] = value if value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def clear
|
|
96
|
+
return 0 unless Dir.exist?(@root_path)
|
|
97
|
+
|
|
98
|
+
count = 0
|
|
99
|
+
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
|
|
100
|
+
if File.file?(file_path)
|
|
101
|
+
File.delete(file_path)
|
|
102
|
+
count += 1
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
Dir.glob(File.join(@root_path, "**", "*#{METADATA_EXTENSION}")).each do |file_path|
|
|
107
|
+
File.delete(file_path) if File.file?(file_path)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
cleanup_empty_directories(@root_path, preserve_root: true)
|
|
111
|
+
|
|
112
|
+
count
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def size
|
|
116
|
+
return 0 unless Dir.exist?(@root_path)
|
|
117
|
+
|
|
118
|
+
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).count { |path| File.file?(path) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def close; end
|
|
122
|
+
|
|
123
|
+
def verify_integrity
|
|
124
|
+
corrupted_keys = []
|
|
125
|
+
|
|
126
|
+
keys.each do |key|
|
|
127
|
+
get(key)
|
|
128
|
+
rescue Integrity::IntegrityError
|
|
129
|
+
corrupted_keys << key
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
total_keys: keys.size,
|
|
134
|
+
corrupted_keys: corrupted_keys,
|
|
135
|
+
integrity_ok: corrupted_keys.empty?
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def repair_corruption(key, backup_data = nil)
|
|
140
|
+
file_path = path_for_key(key)
|
|
141
|
+
return nil unless File.exist?(file_path)
|
|
142
|
+
|
|
143
|
+
corrupted_data = file_safe_read(file_path)
|
|
144
|
+
repaired_data = Integrity.repair_data(corrupted_data, backup_data)
|
|
145
|
+
|
|
146
|
+
if Integrity.valid_data?(repaired_data)
|
|
147
|
+
set(key, repaired_data)
|
|
148
|
+
return repaired_data
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def stats
|
|
155
|
+
super.merge(
|
|
156
|
+
root_path: @root_path,
|
|
157
|
+
extension: @extension,
|
|
158
|
+
disk_usage: calculate_disk_usage
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def setup_directory_structure
|
|
165
|
+
return unless @create_directories
|
|
166
|
+
|
|
167
|
+
FileUtils.mkdir_p(@root_path) unless Dir.exist?(@root_path)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def path_for_key(key)
|
|
171
|
+
safe_key = sanitize_key(key)
|
|
172
|
+
|
|
173
|
+
if safe_key.length >= 2
|
|
174
|
+
subdir = safe_key[0, 2]
|
|
175
|
+
File.join(@root_path, subdir, "#{safe_key}#{@extension}")
|
|
176
|
+
else
|
|
177
|
+
File.join(@root_path, "#{safe_key}#{@extension}")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def metadata_path_for_key(key)
|
|
182
|
+
data_path = path_for_key(key)
|
|
183
|
+
data_path.sub(@extension, METADATA_EXTENSION)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def key_from_path(file_path)
|
|
187
|
+
relative_path = file_path.sub(@root_path, "").sub(%r{^/}, "")
|
|
188
|
+
File.basename(relative_path, @extension)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def sanitize_key(key)
|
|
192
|
+
key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def create_integrity_metadata(data)
|
|
196
|
+
return {} unless @integrity_enabled
|
|
197
|
+
|
|
198
|
+
Integrity.create_integrity_metadata(data, @integrity_algorithm)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def verify_data_integrity(data, metadata)
|
|
202
|
+
return true unless @integrity_enabled
|
|
203
|
+
return true unless metadata.is_a?(Hash) && metadata[:integrity]
|
|
204
|
+
|
|
205
|
+
Integrity.verify_integrity_metadata(data, metadata[:integrity])
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def wrap_with_integrity(data, user_metadata = {})
|
|
209
|
+
metadata = user_metadata.dup
|
|
210
|
+
metadata[:integrity] = create_integrity_metadata(data) if @integrity_enabled
|
|
211
|
+
metadata
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def write_metadata(key, metadata)
|
|
215
|
+
return unless metadata.any?
|
|
216
|
+
|
|
217
|
+
metadata_path = metadata_path_for_key(key)
|
|
218
|
+
ensure_directory_exists(File.dirname(metadata_path))
|
|
219
|
+
|
|
220
|
+
File.write(metadata_path, JSON.generate(metadata))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def read_metadata(key)
|
|
224
|
+
metadata_path = metadata_path_for_key(key)
|
|
225
|
+
return {} unless File.exist?(metadata_path)
|
|
226
|
+
|
|
227
|
+
begin
|
|
228
|
+
JSON.parse(File.read(metadata_path), symbolize_names: true)
|
|
229
|
+
rescue JSON::ParserError
|
|
230
|
+
{}
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def ensure_directory_exists(dir_path)
|
|
235
|
+
return if Dir.exist?(dir_path)
|
|
236
|
+
|
|
237
|
+
FileUtils.mkdir_p(dir_path)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def cleanup_empty_directories(dir_path, preserve_root: false)
|
|
241
|
+
return if preserve_root && dir_path == @root_path
|
|
242
|
+
return unless Dir.exist?(dir_path)
|
|
243
|
+
return unless Dir.empty?(dir_path)
|
|
244
|
+
|
|
245
|
+
Dir.rmdir(dir_path)
|
|
246
|
+
|
|
247
|
+
parent_dir = File.dirname(dir_path)
|
|
248
|
+
cleanup_empty_directories(parent_dir, preserve_root: preserve_root) if parent_dir != dir_path
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def file_safe_read(file_path)
|
|
252
|
+
File.open(file_path, "rb") do |file|
|
|
253
|
+
file.flock(File::LOCK_SH)
|
|
254
|
+
file.read
|
|
255
|
+
end
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
raise BackendError, "Failed to read file #{file_path}: #{e.message}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def file_safe_write(file_path, content)
|
|
261
|
+
temp_path = "#{file_path}.tmp.#{Process.pid}"
|
|
262
|
+
|
|
263
|
+
File.open(temp_path, "wb") do |file|
|
|
264
|
+
file.flock(File::LOCK_EX)
|
|
265
|
+
file.write(content)
|
|
266
|
+
file.fsync
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
File.rename(temp_path, file_path)
|
|
270
|
+
rescue StandardError => e
|
|
271
|
+
File.delete(temp_path) if File.exist?(temp_path)
|
|
272
|
+
raise BackendError, "Failed to write file #{file_path}: #{e.message}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def calculate_disk_usage
|
|
276
|
+
return 0 unless Dir.exist?(@root_path)
|
|
277
|
+
|
|
278
|
+
total_size = 0
|
|
279
|
+
Dir.glob(File.join(@root_path, "**", "*#{@extension}")).each do |file_path|
|
|
280
|
+
total_size += File.size(file_path) if File.file?(file_path)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
total_size
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Adapter
|
|
6
|
+
class Memory < Base
|
|
7
|
+
def initialize(config = {})
|
|
8
|
+
super
|
|
9
|
+
@data = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
@ttl_enabled = @config.fetch(:ttl_enabled, false)
|
|
12
|
+
@ttl_data = {} if @ttl_enabled
|
|
13
|
+
@default_ttl = @config[:default_ttl] || 3600
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(key)
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
return nil unless @data.key?(key)
|
|
19
|
+
|
|
20
|
+
if @ttl_enabled && expired?(key)
|
|
21
|
+
@data.delete(key)
|
|
22
|
+
@ttl_data.delete(key)
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@data[key]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def set(key, value, metadata = {})
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@data[key] = value
|
|
33
|
+
|
|
34
|
+
if @ttl_enabled
|
|
35
|
+
ttl_value = metadata[:ttl] || @default_ttl
|
|
36
|
+
@ttl_data[key] = Time.now + ttl_value if ttl_value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete(key)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
existed = @data.key?(key)
|
|
46
|
+
@data.delete(key)
|
|
47
|
+
@ttl_data.delete(key) if @ttl_enabled
|
|
48
|
+
existed
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def exists?(key)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
return false unless @data.key?(key)
|
|
55
|
+
|
|
56
|
+
if @ttl_enabled && expired?(key)
|
|
57
|
+
@data.delete(key)
|
|
58
|
+
@ttl_data.delete(key)
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def all
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
cleanup_expired if @ttl_enabled
|
|
69
|
+
@data.dup
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def clear
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
count = @data.size
|
|
76
|
+
@data.clear
|
|
77
|
+
@ttl_data.clear if @ttl_enabled
|
|
78
|
+
count
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def size
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
cleanup_expired if @ttl_enabled
|
|
85
|
+
@data.size
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def keys
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
cleanup_expired if @ttl_enabled
|
|
92
|
+
@data.keys
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def close
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@data.clear
|
|
99
|
+
@ttl_data.clear if @ttl_enabled
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def stats
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
cleanup_expired if @ttl_enabled
|
|
106
|
+
super.merge(
|
|
107
|
+
size: @data.size,
|
|
108
|
+
ttl_enabled: @ttl_enabled,
|
|
109
|
+
expired_keys: @ttl_enabled ? count_expired_keys : 0
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cleanup_expired
|
|
115
|
+
return unless @ttl_enabled
|
|
116
|
+
|
|
117
|
+
@mutex.synchronize do
|
|
118
|
+
expired_keys = []
|
|
119
|
+
@ttl_data.each do |key, expiry_time|
|
|
120
|
+
expired_keys << key if Time.now > expiry_time
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
expired_keys.each do |key|
|
|
124
|
+
@data.delete(key)
|
|
125
|
+
@ttl_data.delete(key)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
expired_keys.size
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def set_ttl(key, ttl)
|
|
133
|
+
return false unless @ttl_enabled
|
|
134
|
+
|
|
135
|
+
@mutex.synchronize do
|
|
136
|
+
return false unless @data.key?(key)
|
|
137
|
+
|
|
138
|
+
if ttl
|
|
139
|
+
@ttl_data[key] = Time.now + ttl
|
|
140
|
+
else
|
|
141
|
+
@ttl_data.delete(key)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get_ttl(key)
|
|
149
|
+
return nil unless @ttl_enabled
|
|
150
|
+
|
|
151
|
+
@mutex.synchronize do
|
|
152
|
+
return nil unless @ttl_data.key?(key)
|
|
153
|
+
|
|
154
|
+
expiry_time = @ttl_data[key]
|
|
155
|
+
remaining = expiry_time - Time.now
|
|
156
|
+
remaining.positive? ? remaining : nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def bulk_set(key_value_pairs, ttl: nil)
|
|
161
|
+
@mutex.synchronize do
|
|
162
|
+
key_value_pairs.each do |key, value|
|
|
163
|
+
@data[key] = value
|
|
164
|
+
|
|
165
|
+
if @ttl_enabled
|
|
166
|
+
ttl_value = ttl || @default_ttl
|
|
167
|
+
@ttl_data[key] = Time.now + ttl_value if ttl_value
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def bulk_get(keys)
|
|
174
|
+
@mutex.synchronize do
|
|
175
|
+
result = {}
|
|
176
|
+
keys.each do |key|
|
|
177
|
+
if @data.key?(key)
|
|
178
|
+
if @ttl_enabled && expired?(key)
|
|
179
|
+
@data.delete(key)
|
|
180
|
+
@ttl_data.delete(key)
|
|
181
|
+
result[key] = nil
|
|
182
|
+
else
|
|
183
|
+
result[key] = @data[key]
|
|
184
|
+
end
|
|
185
|
+
else
|
|
186
|
+
result[key] = nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def bulk_delete(keys)
|
|
194
|
+
@mutex.synchronize do
|
|
195
|
+
result = {}
|
|
196
|
+
keys.each do |key|
|
|
197
|
+
result[key] = @data.delete(key)
|
|
198
|
+
@ttl_data.delete(key) if @ttl_enabled
|
|
199
|
+
end
|
|
200
|
+
result
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def expired?(key)
|
|
207
|
+
return false unless @ttl_enabled
|
|
208
|
+
return false unless @ttl_data.key?(key)
|
|
209
|
+
|
|
210
|
+
Time.now > @ttl_data[key]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def count_expired_keys
|
|
214
|
+
return 0 unless @ttl_enabled
|
|
215
|
+
|
|
216
|
+
count = 0
|
|
217
|
+
@ttl_data.each_value do |expiry_time|
|
|
218
|
+
count += 1 if Time.now > expiry_time
|
|
219
|
+
end
|
|
220
|
+
count
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Store
|
|
7
|
+
module Adapter
|
|
8
|
+
class Sqlite < Base
|
|
9
|
+
DEFAULT_TABLE_NAME = "lutaml_store"
|
|
10
|
+
|
|
11
|
+
def initialize(config = {})
|
|
12
|
+
super
|
|
13
|
+
begin
|
|
14
|
+
require "sqlite3"
|
|
15
|
+
rescue LoadError
|
|
16
|
+
raise ConfigurationError,
|
|
17
|
+
"sqlite3 gem is required for the SQLite adapter. Add it to your Gemfile."
|
|
18
|
+
end
|
|
19
|
+
@db_path = @config[:path] || raise(ConfigurationError, "SQLite adapter requires :path config")
|
|
20
|
+
@table_name = @config[:table_name] || DEFAULT_TABLE_NAME
|
|
21
|
+
@timeout = @config[:timeout] || 30_000
|
|
22
|
+
|
|
23
|
+
setup_database
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get(key)
|
|
27
|
+
result = nil
|
|
28
|
+
execute_query("SELECT value FROM #{@table_name} WHERE key = ?", [key]) do |row|
|
|
29
|
+
value = row[0]
|
|
30
|
+
begin
|
|
31
|
+
result = JSON.parse(value)
|
|
32
|
+
rescue JSON::ParserError
|
|
33
|
+
result = value
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def set(key, value)
|
|
40
|
+
serialized_value = value.is_a?(String) ? value : JSON.generate(value)
|
|
41
|
+
|
|
42
|
+
execute_statement(
|
|
43
|
+
"INSERT OR REPLACE INTO #{@table_name} (key, value, updated_at) VALUES (?, ?, ?)",
|
|
44
|
+
[key, serialized_value, Time.now.to_f]
|
|
45
|
+
)
|
|
46
|
+
value
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete(key)
|
|
50
|
+
value = get(key)
|
|
51
|
+
return nil unless value
|
|
52
|
+
|
|
53
|
+
execute_statement("DELETE FROM #{@table_name} WHERE key = ?", [key])
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def exists?(key)
|
|
58
|
+
execute_query("SELECT 1 FROM #{@table_name} WHERE key = ? LIMIT 1", [key]) do |_row|
|
|
59
|
+
return true
|
|
60
|
+
end
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def all
|
|
65
|
+
result = {}
|
|
66
|
+
execute_query("SELECT key, value FROM #{@table_name}") do |row|
|
|
67
|
+
result[row[0]] = row[1]
|
|
68
|
+
end
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def clear
|
|
73
|
+
count = size
|
|
74
|
+
execute_statement("DELETE FROM #{@table_name}")
|
|
75
|
+
count
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def size
|
|
79
|
+
execute_query("SELECT COUNT(*) FROM #{@table_name}") do |row|
|
|
80
|
+
return row[0]
|
|
81
|
+
end
|
|
82
|
+
0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def keys
|
|
86
|
+
result = []
|
|
87
|
+
execute_query("SELECT key FROM #{@table_name}") do |row|
|
|
88
|
+
result << row[0]
|
|
89
|
+
end
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def close
|
|
94
|
+
@db&.close
|
|
95
|
+
@db = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def stats
|
|
99
|
+
super.merge(
|
|
100
|
+
db_path: @db_path,
|
|
101
|
+
table_name: @table_name,
|
|
102
|
+
database_size: calculate_database_size,
|
|
103
|
+
schema_version: get_schema_version
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def bulk_set(key_value_pairs)
|
|
108
|
+
@db.transaction do
|
|
109
|
+
key_value_pairs.each do |key, value|
|
|
110
|
+
serialized_value = value.is_a?(String) ? value : JSON.generate(value)
|
|
111
|
+
execute_statement(
|
|
112
|
+
"INSERT OR REPLACE INTO #{@table_name} (key, value, updated_at) VALUES (?, ?, ?)",
|
|
113
|
+
[key, serialized_value, Time.now.to_f]
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bulk_delete(keys)
|
|
120
|
+
result = {}
|
|
121
|
+
@db.transaction do
|
|
122
|
+
keys.each do |key|
|
|
123
|
+
value = get(key)
|
|
124
|
+
if value
|
|
125
|
+
execute_statement("DELETE FROM #{@table_name} WHERE key = ?", [key])
|
|
126
|
+
result[key] = value
|
|
127
|
+
else
|
|
128
|
+
result[key] = nil
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def setup_database
|
|
138
|
+
@db = SQLite3::Database.new(@db_path)
|
|
139
|
+
@db.busy_timeout = @timeout
|
|
140
|
+
|
|
141
|
+
@db.execute("PRAGMA journal_mode=WAL")
|
|
142
|
+
@db.execute("PRAGMA synchronous=NORMAL")
|
|
143
|
+
@db.execute("PRAGMA cache_size=10000")
|
|
144
|
+
@db.execute("PRAGMA temp_store=memory")
|
|
145
|
+
|
|
146
|
+
create_table_if_not_exists
|
|
147
|
+
create_indexes
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def create_table_if_not_exists
|
|
151
|
+
@db.execute(<<~SQL)
|
|
152
|
+
CREATE TABLE IF NOT EXISTS #{@table_name} (
|
|
153
|
+
key TEXT PRIMARY KEY,
|
|
154
|
+
value BLOB NOT NULL,
|
|
155
|
+
updated_at REAL NOT NULL DEFAULT (julianday('now'))
|
|
156
|
+
)
|
|
157
|
+
SQL
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def create_indexes
|
|
161
|
+
@db.execute("CREATE INDEX IF NOT EXISTS idx_#{@table_name}_updated_at ON #{@table_name} (updated_at)")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def execute_query(sql, params = [])
|
|
165
|
+
@db.execute(sql, params) do |row|
|
|
166
|
+
yield row if block_given?
|
|
167
|
+
end
|
|
168
|
+
rescue SQLite3::Exception => e
|
|
169
|
+
raise BackendError, "SQLite query failed: #{e.message}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def execute_statement(sql, params = [])
|
|
173
|
+
@db.execute(sql, params)
|
|
174
|
+
rescue SQLite3::Exception => e
|
|
175
|
+
raise BackendError, "SQLite statement failed: #{e.message}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def calculate_database_size
|
|
179
|
+
return 0 unless File.exist?(@db_path)
|
|
180
|
+
|
|
181
|
+
File.size(@db_path)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def get_schema_version
|
|
185
|
+
execute_query("PRAGMA user_version") do |row|
|
|
186
|
+
return row[0]
|
|
187
|
+
end
|
|
188
|
+
0
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
module Adapter
|
|
6
|
+
autoload :Base, "lutaml/store/adapter/base"
|
|
7
|
+
autoload :Memory, "lutaml/store/adapter/memory"
|
|
8
|
+
autoload :FileSystem, "lutaml/store/adapter/filesystem"
|
|
9
|
+
autoload :Sqlite, "lutaml/store/adapter/sqlite"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|