lutaml-store 0.2.1 → 0.2.4
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 +4 -4
- data/.rubocop_todo.yml +22 -13
- data/CLAUDE.md +22 -9
- data/README.adoc +129 -15
- data/lib/lutaml/store/adapter/base.rb +28 -0
- data/lib/lutaml/store/adapter/memory.rb +213 -121
- data/lib/lutaml/store/adapter/sqlite.rb +181 -7
- data/lib/lutaml/store/adapter.rb +17 -0
- data/lib/lutaml/store/basic_store.rb +1 -10
- data/lib/lutaml/store/cache_store.rb +2 -10
- data/lib/lutaml/store/database_store.rb +321 -58
- data/lib/lutaml/store/format/base.rb +4 -0
- data/lib/lutaml/store/format/marshal_format.rb +4 -0
- data/lib/lutaml/store/format/xml.rb +37 -0
- data/lib/lutaml/store/format/yamls.rb +1 -1
- data/lib/lutaml/store/format.rb +3 -1
- data/lib/lutaml/store/format_serializer.rb +44 -0
- data/lib/lutaml/store/package_transport/base.rb +12 -0
- data/lib/lutaml/store/package_transport/directory_transport.rb +8 -8
- data/lib/lutaml/store/package_transport/zip_transport.rb +11 -6
- data/lib/lutaml/store/predicate.rb +248 -0
- data/lib/lutaml/store/query.rb +259 -0
- data/lib/lutaml/store/storage_key.rb +1 -1
- data/lib/lutaml/store/version.rb +1 -1
- data/lib/lutaml/store.rb +3 -0
- metadata +14 -4
|
@@ -3,136 +3,145 @@
|
|
|
3
3
|
module Lutaml
|
|
4
4
|
module Store
|
|
5
5
|
module Adapter
|
|
6
|
+
# In-memory key-value adapter optimized for read-heavy workloads.
|
|
7
|
+
# Writes are synchronized; reads are lock-free using snapshot copies.
|
|
6
8
|
class Memory < Base
|
|
7
9
|
def initialize(config = {})
|
|
8
10
|
super
|
|
9
11
|
@data = {}
|
|
10
|
-
@
|
|
12
|
+
@write_mutex = Mutex.new
|
|
13
|
+
@read_snapshot = {}.freeze
|
|
14
|
+
@snapshot_stale = true
|
|
11
15
|
@ttl_enabled = @config.fetch(:ttl_enabled, false)
|
|
12
|
-
@ttl_data = {}
|
|
16
|
+
@ttl_data = @ttl_enabled ? {} : nil
|
|
13
17
|
@default_ttl = @config[:default_ttl] || 3600
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
# ── Read operations (lock-free via snapshot) ──
|
|
21
|
+
|
|
16
22
|
def get(key)
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
cleanup_expired_if_needed
|
|
24
|
+
snapshot[key]
|
|
25
|
+
end
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
def exists?(key)
|
|
28
|
+
cleanup_expired_if_needed
|
|
29
|
+
snapshot.key?(key)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all
|
|
33
|
+
cleanup_expired_if_needed
|
|
34
|
+
snapshot.dup
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def size
|
|
38
|
+
cleanup_expired_if_needed
|
|
39
|
+
snapshot.size
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def keys
|
|
43
|
+
cleanup_expired_if_needed
|
|
44
|
+
snapshot.keys
|
|
45
|
+
end
|
|
25
46
|
|
|
26
|
-
|
|
47
|
+
def each_key(&block)
|
|
48
|
+
cleanup_expired_if_needed
|
|
49
|
+
snapshot.each_key(&block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def bulk_get(keys)
|
|
53
|
+
cleanup_expired_if_needed
|
|
54
|
+
snap = snapshot
|
|
55
|
+
keys.each_with_object({}) do |k, h|
|
|
56
|
+
h[k] = snap[k]
|
|
27
57
|
end
|
|
28
58
|
end
|
|
29
59
|
|
|
60
|
+
# ── Write operations (synchronized) ──
|
|
61
|
+
|
|
30
62
|
def set(key, value, metadata = {})
|
|
31
|
-
@
|
|
63
|
+
@write_mutex.synchronize do
|
|
32
64
|
@data[key] = value
|
|
65
|
+
invalidate_snapshot
|
|
33
66
|
|
|
34
67
|
if @ttl_enabled
|
|
35
68
|
ttl_value = metadata[:ttl] || @default_ttl
|
|
36
69
|
@ttl_data[key] = Time.now + ttl_value if ttl_value
|
|
37
70
|
end
|
|
38
|
-
|
|
39
|
-
value
|
|
40
71
|
end
|
|
72
|
+
value
|
|
41
73
|
end
|
|
42
74
|
|
|
43
75
|
def delete(key)
|
|
44
|
-
@
|
|
76
|
+
@write_mutex.synchronize do
|
|
45
77
|
existed = @data.key?(key)
|
|
46
78
|
@data.delete(key)
|
|
47
|
-
@ttl_data
|
|
79
|
+
@ttl_data&.delete(key)
|
|
80
|
+
invalidate_snapshot
|
|
48
81
|
existed
|
|
49
82
|
end
|
|
50
83
|
end
|
|
51
84
|
|
|
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
85
|
def clear
|
|
74
|
-
@
|
|
86
|
+
@write_mutex.synchronize do
|
|
75
87
|
count = @data.size
|
|
76
88
|
@data.clear
|
|
77
|
-
@ttl_data
|
|
89
|
+
@ttl_data&.clear
|
|
90
|
+
invalidate_snapshot
|
|
78
91
|
count
|
|
79
92
|
end
|
|
80
93
|
end
|
|
81
94
|
|
|
82
|
-
def
|
|
83
|
-
@
|
|
84
|
-
|
|
85
|
-
@
|
|
95
|
+
def close
|
|
96
|
+
@write_mutex.synchronize do
|
|
97
|
+
@data.clear
|
|
98
|
+
@ttl_data&.clear
|
|
99
|
+
invalidate_snapshot
|
|
86
100
|
end
|
|
87
101
|
end
|
|
88
102
|
|
|
89
|
-
def
|
|
90
|
-
@
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
end
|
|
94
|
-
end
|
|
103
|
+
def bulk_set(key_value_pairs, ttl: nil)
|
|
104
|
+
@write_mutex.synchronize do
|
|
105
|
+
key_value_pairs.each do |key, value|
|
|
106
|
+
@data[key] = value
|
|
95
107
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
if @ttl_enabled
|
|
109
|
+
ttl_value = ttl || @default_ttl
|
|
110
|
+
@ttl_data[key] = Time.now + ttl_value if ttl_value
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
invalidate_snapshot
|
|
100
114
|
end
|
|
101
|
-
current_keys.each(&block)
|
|
102
115
|
end
|
|
103
116
|
|
|
104
|
-
def
|
|
105
|
-
@
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
def bulk_delete(keys)
|
|
118
|
+
@write_mutex.synchronize do
|
|
119
|
+
result = {}
|
|
120
|
+
keys.each do |key|
|
|
121
|
+
result[key] = @data.delete(key)
|
|
122
|
+
@ttl_data&.delete(key)
|
|
123
|
+
end
|
|
124
|
+
invalidate_snapshot
|
|
125
|
+
result
|
|
108
126
|
end
|
|
109
127
|
end
|
|
110
128
|
|
|
111
|
-
|
|
112
|
-
@mutex.synchronize do
|
|
113
|
-
cleanup_expired if @ttl_enabled
|
|
114
|
-
super.merge(
|
|
115
|
-
size: @data.size,
|
|
116
|
-
ttl_enabled: @ttl_enabled,
|
|
117
|
-
expired_keys: @ttl_enabled ? count_expired_keys : 0
|
|
118
|
-
)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
129
|
+
# ── TTL operations ──
|
|
121
130
|
|
|
122
131
|
def cleanup_expired
|
|
123
132
|
return unless @ttl_enabled
|
|
124
133
|
|
|
125
|
-
@
|
|
126
|
-
|
|
127
|
-
@ttl_data.
|
|
128
|
-
|
|
134
|
+
@write_mutex.synchronize do
|
|
135
|
+
now = Time.now
|
|
136
|
+
expired_keys = @ttl_data.each_with_object([]) do |(key, expiry_time), arr|
|
|
137
|
+
arr << key if now > expiry_time
|
|
129
138
|
end
|
|
130
139
|
|
|
131
140
|
expired_keys.each do |key|
|
|
132
141
|
@data.delete(key)
|
|
133
142
|
@ttl_data.delete(key)
|
|
134
143
|
end
|
|
135
|
-
|
|
144
|
+
invalidate_snapshot unless expired_keys.empty?
|
|
136
145
|
expired_keys.size
|
|
137
146
|
end
|
|
138
147
|
end
|
|
@@ -140,7 +149,7 @@ module Lutaml
|
|
|
140
149
|
def set_ttl(key, ttl)
|
|
141
150
|
return false unless @ttl_enabled
|
|
142
151
|
|
|
143
|
-
@
|
|
152
|
+
@write_mutex.synchronize do
|
|
144
153
|
return false unless @data.key?(key)
|
|
145
154
|
|
|
146
155
|
if ttl
|
|
@@ -148,7 +157,7 @@ module Lutaml
|
|
|
148
157
|
else
|
|
149
158
|
@ttl_data.delete(key)
|
|
150
159
|
end
|
|
151
|
-
|
|
160
|
+
invalidate_snapshot
|
|
152
161
|
true
|
|
153
162
|
end
|
|
154
163
|
end
|
|
@@ -156,76 +165,159 @@ module Lutaml
|
|
|
156
165
|
def get_ttl(key)
|
|
157
166
|
return nil unless @ttl_enabled
|
|
158
167
|
|
|
159
|
-
|
|
160
|
-
|
|
168
|
+
snap = snapshot
|
|
169
|
+
return nil unless snap.key?(key)
|
|
170
|
+
return nil unless @ttl_data.key?(key)
|
|
171
|
+
|
|
172
|
+
remaining = @ttl_data[key] - Time.now
|
|
173
|
+
remaining.positive? ? remaining : nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ── Query operations (lock-free via snapshot) ──
|
|
177
|
+
|
|
178
|
+
def execute_query(query)
|
|
179
|
+
cleanup_expired_if_needed
|
|
180
|
+
snap = snapshot
|
|
181
|
+
model_name = query.model_class.name
|
|
182
|
+
preds = query.predicates
|
|
183
|
+
|
|
184
|
+
results = []
|
|
185
|
+
snap.each do |key, value|
|
|
186
|
+
parsed = StorageKey.parse(key.to_s)
|
|
187
|
+
next unless parsed.class_name == model_name
|
|
188
|
+
next unless preds_all_match?(preds, value)
|
|
161
189
|
|
|
162
|
-
|
|
163
|
-
remaining = expiry_time - Time.now
|
|
164
|
-
remaining.positive? ? remaining : nil
|
|
190
|
+
results << [key, value]
|
|
165
191
|
end
|
|
192
|
+
|
|
193
|
+
results = apply_sort(results, query.orders)
|
|
194
|
+
results = apply_pagination(results, query.limit_value, query.offset_value)
|
|
166
195
|
end
|
|
167
196
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
197
|
+
def count_query(query)
|
|
198
|
+
cleanup_expired_if_needed
|
|
199
|
+
snap = snapshot
|
|
200
|
+
model_name = query.model_class.name
|
|
201
|
+
preds = query.predicates
|
|
172
202
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
203
|
+
count = 0
|
|
204
|
+
snap.each do |key, value|
|
|
205
|
+
parsed = StorageKey.parse(key.to_s)
|
|
206
|
+
next unless parsed.class_name == model_name
|
|
207
|
+
next unless preds_all_match?(preds, value)
|
|
208
|
+
|
|
209
|
+
count += 1
|
|
178
210
|
end
|
|
211
|
+
count
|
|
179
212
|
end
|
|
180
213
|
|
|
181
|
-
def
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
214
|
+
def batch_query(query, after: nil, limit: 1000)
|
|
215
|
+
cleanup_expired_if_needed
|
|
216
|
+
snap = snapshot
|
|
217
|
+
model_name = query.model_class.name
|
|
218
|
+
preds = query.predicates
|
|
219
|
+
|
|
220
|
+
sorted_keys = snap.keys.sort_by(&:to_s)
|
|
221
|
+
sorted_keys = sorted_keys.select { |k| k.to_s > after } if after
|
|
222
|
+
|
|
223
|
+
results = []
|
|
224
|
+
sorted_keys.each do |key|
|
|
225
|
+
parsed = StorageKey.parse(key.to_s)
|
|
226
|
+
next unless parsed.class_name == model_name
|
|
227
|
+
|
|
228
|
+
value = snap[key]
|
|
229
|
+
next unless value
|
|
230
|
+
next unless preds_all_match?(preds, value)
|
|
231
|
+
|
|
232
|
+
results << [key.to_s, value]
|
|
233
|
+
break if results.size >= limit
|
|
198
234
|
end
|
|
235
|
+
results
|
|
199
236
|
end
|
|
200
237
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
238
|
+
def stats
|
|
239
|
+
cleanup_expired_if_needed
|
|
240
|
+
snap = snapshot
|
|
241
|
+
super.merge(
|
|
242
|
+
size: snap.size,
|
|
243
|
+
ttl_enabled: @ttl_enabled,
|
|
244
|
+
expired_keys: @ttl_enabled ? count_expired_keys : 0
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
# Frozen snapshot — lock-free reads. Rebuilt lazily after writes.
|
|
251
|
+
def snapshot
|
|
252
|
+
if @snapshot_stale
|
|
253
|
+
@write_mutex.synchronize do
|
|
254
|
+
if @snapshot_stale
|
|
255
|
+
@read_snapshot = @data.dup.freeze
|
|
256
|
+
@snapshot_stale = false
|
|
257
|
+
end
|
|
207
258
|
end
|
|
208
|
-
result
|
|
209
259
|
end
|
|
260
|
+
@read_snapshot
|
|
210
261
|
end
|
|
211
262
|
|
|
212
|
-
|
|
263
|
+
def invalidate_snapshot
|
|
264
|
+
@snapshot_stale = true
|
|
265
|
+
end
|
|
213
266
|
|
|
214
|
-
def
|
|
215
|
-
return
|
|
216
|
-
|
|
267
|
+
def cleanup_expired_if_needed
|
|
268
|
+
return unless @ttl_enabled
|
|
269
|
+
|
|
270
|
+
now = Time.now
|
|
271
|
+
return unless @ttl_data.any? { |_k, expiry| now > expiry }
|
|
217
272
|
|
|
218
|
-
|
|
273
|
+
cleanup_expired
|
|
219
274
|
end
|
|
220
275
|
|
|
221
276
|
def count_expired_keys
|
|
222
277
|
return 0 unless @ttl_enabled
|
|
223
278
|
|
|
224
|
-
|
|
225
|
-
@ttl_data.
|
|
226
|
-
|
|
279
|
+
now = Time.now
|
|
280
|
+
@ttl_data.count { |_k, expiry| now > expiry }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Inline predicate evaluation — avoids method dispatch overhead in tight loops
|
|
284
|
+
def preds_all_match?(predicates, value)
|
|
285
|
+
predicates.all? { |p| p.match?(value) }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def apply_sort(results, orders)
|
|
289
|
+
return results if orders.empty?
|
|
290
|
+
|
|
291
|
+
results.sort do |a, b|
|
|
292
|
+
orders.reduce(0) do |cmp, o|
|
|
293
|
+
break cmp unless cmp.zero?
|
|
294
|
+
|
|
295
|
+
va = a.last.is_a?(Hash) ? a.last[o.field.to_s] : nil
|
|
296
|
+
vb = b.last.is_a?(Hash) ? b.last[o.field.to_s] : nil
|
|
297
|
+
|
|
298
|
+
cmp_val = compare_values(va, vb)
|
|
299
|
+
o.direction == :desc ? -cmp_val : cmp_val
|
|
300
|
+
end
|
|
227
301
|
end
|
|
228
|
-
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def compare_values(val_a, val_b)
|
|
305
|
+
if val_a.nil? && val_b.nil?
|
|
306
|
+
0
|
|
307
|
+
elsif val_a.nil?
|
|
308
|
+
1
|
|
309
|
+
elsif val_b.nil?
|
|
310
|
+
-1
|
|
311
|
+
else
|
|
312
|
+
val_a <=> val_b || 0
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def apply_pagination(results, limit, offset)
|
|
317
|
+
start = offset || 0
|
|
318
|
+
results = results[start..] || []
|
|
319
|
+
results = results.first(limit) if limit
|
|
320
|
+
results
|
|
229
321
|
end
|
|
230
322
|
end
|
|
231
323
|
end
|
|
@@ -23,9 +23,11 @@ module Lutaml
|
|
|
23
23
|
setup_database
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# ── Key-value operations ──
|
|
27
|
+
|
|
26
28
|
def get(key)
|
|
27
29
|
result = nil
|
|
28
|
-
|
|
30
|
+
execute_query_raw("SELECT value FROM #{@table_name} WHERE key = ?", [key]) do |row|
|
|
29
31
|
value = row[0]
|
|
30
32
|
begin
|
|
31
33
|
result = JSON.parse(value)
|
|
@@ -55,7 +57,7 @@ module Lutaml
|
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
def exists?(key)
|
|
58
|
-
|
|
60
|
+
execute_query_raw("SELECT 1 FROM #{@table_name} WHERE key = ? LIMIT 1", [key]) do |_row|
|
|
59
61
|
return true
|
|
60
62
|
end
|
|
61
63
|
false
|
|
@@ -63,7 +65,7 @@ module Lutaml
|
|
|
63
65
|
|
|
64
66
|
def all
|
|
65
67
|
result = {}
|
|
66
|
-
|
|
68
|
+
execute_query_raw("SELECT key, value FROM #{@table_name}") do |row|
|
|
67
69
|
result[row[0]] = row[1]
|
|
68
70
|
end
|
|
69
71
|
result
|
|
@@ -76,7 +78,7 @@ module Lutaml
|
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
def size
|
|
79
|
-
|
|
81
|
+
execute_query_raw("SELECT COUNT(*) FROM #{@table_name}") do |row|
|
|
80
82
|
return row[0]
|
|
81
83
|
end
|
|
82
84
|
0
|
|
@@ -84,7 +86,7 @@ module Lutaml
|
|
|
84
86
|
|
|
85
87
|
def keys
|
|
86
88
|
result = []
|
|
87
|
-
|
|
89
|
+
execute_query_raw("SELECT key FROM #{@table_name}") do |row|
|
|
88
90
|
result << row[0]
|
|
89
91
|
end
|
|
90
92
|
result
|
|
@@ -132,6 +134,56 @@ module Lutaml
|
|
|
132
134
|
result
|
|
133
135
|
end
|
|
134
136
|
|
|
137
|
+
# ── Query operations ──
|
|
138
|
+
|
|
139
|
+
def execute_query(query)
|
|
140
|
+
sql, params = build_select_sql(query)
|
|
141
|
+
results = []
|
|
142
|
+
execute_query_raw(sql, params) do |row|
|
|
143
|
+
key = row[0]
|
|
144
|
+
value = parse_json_value(row[1])
|
|
145
|
+
results << [key, value]
|
|
146
|
+
end
|
|
147
|
+
results
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def count_query(query)
|
|
151
|
+
sql, params = build_count_sql(query)
|
|
152
|
+
execute_query_raw(sql, params) do |row|
|
|
153
|
+
return row[0]
|
|
154
|
+
end
|
|
155
|
+
0
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def batch_query(query, after: nil, limit: 1000)
|
|
159
|
+
conditions, params = build_conditions(query)
|
|
160
|
+
|
|
161
|
+
if after
|
|
162
|
+
conditions << "key > ?"
|
|
163
|
+
params << after
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
sql = "SELECT key, value FROM #{@table_name}"
|
|
167
|
+
sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
|
|
168
|
+
sql += " ORDER BY key ASC"
|
|
169
|
+
sql += " LIMIT ?"
|
|
170
|
+
params << limit
|
|
171
|
+
|
|
172
|
+
results = []
|
|
173
|
+
execute_query_raw(sql, params) do |row|
|
|
174
|
+
key = row[0]
|
|
175
|
+
value = parse_json_value(row[1])
|
|
176
|
+
results << [key, value]
|
|
177
|
+
end
|
|
178
|
+
results
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def transaction(&block)
|
|
182
|
+
@db.transaction(&block)
|
|
183
|
+
rescue SQLite3::Exception => e
|
|
184
|
+
raise BackendError, "Transaction failed: #{e.message}"
|
|
185
|
+
end
|
|
186
|
+
|
|
135
187
|
private
|
|
136
188
|
|
|
137
189
|
def setup_database
|
|
@@ -161,7 +213,129 @@ module Lutaml
|
|
|
161
213
|
@db.execute("CREATE INDEX IF NOT EXISTS idx_#{@table_name}_updated_at ON #{@table_name} (updated_at)")
|
|
162
214
|
end
|
|
163
215
|
|
|
164
|
-
|
|
216
|
+
# ── SQL generation ──
|
|
217
|
+
|
|
218
|
+
def build_select_sql(query)
|
|
219
|
+
conditions, params = build_conditions(query)
|
|
220
|
+
|
|
221
|
+
sql = "SELECT key, value FROM #{@table_name}"
|
|
222
|
+
sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
|
|
223
|
+
sql += build_order_clause(query.orders)
|
|
224
|
+
sql += build_limit_offset(query.limit_value, query.offset_value, params)
|
|
225
|
+
|
|
226
|
+
[sql, params]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def build_count_sql(query)
|
|
230
|
+
conditions, params = build_conditions(query)
|
|
231
|
+
|
|
232
|
+
sql = "SELECT COUNT(*) FROM #{@table_name}"
|
|
233
|
+
sql += " WHERE #{conditions.join(" AND ")}" unless conditions.empty?
|
|
234
|
+
|
|
235
|
+
[sql, params]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def build_conditions(query)
|
|
239
|
+
conditions = ["key LIKE ?"]
|
|
240
|
+
params = ["#{query.model_class.name}:%"]
|
|
241
|
+
|
|
242
|
+
query.predicates.each do |pred|
|
|
243
|
+
clause, bind = translate_predicate(pred)
|
|
244
|
+
next unless clause
|
|
245
|
+
|
|
246
|
+
conditions << clause
|
|
247
|
+
params.concat(bind)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
[conditions, params]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
SIMPLE_PREDICATES = {
|
|
254
|
+
Predicate::Equal => "=",
|
|
255
|
+
Predicate::NotEqual => "!=",
|
|
256
|
+
Predicate::GreaterThan => ">",
|
|
257
|
+
Predicate::LessThan => "<",
|
|
258
|
+
Predicate::GreaterThanOrEqual => ">=",
|
|
259
|
+
Predicate::LessThanOrEqual => "<="
|
|
260
|
+
}.freeze
|
|
261
|
+
|
|
262
|
+
def translate_predicate(pred)
|
|
263
|
+
field_json = "json_extract(value, '$.#{pred.field}')"
|
|
264
|
+
|
|
265
|
+
op = SIMPLE_PREDICATES[pred.class]
|
|
266
|
+
return ["#{field_json} #{op} ?", [pred.value]] if op
|
|
267
|
+
|
|
268
|
+
case pred
|
|
269
|
+
when Predicate::Between
|
|
270
|
+
["#{field_json} BETWEEN ? AND ?", [pred.value.first, pred.value.last]]
|
|
271
|
+
when Predicate::NotBetween
|
|
272
|
+
["#{field_json} NOT BETWEEN ? AND ?", [pred.value.first, pred.value.last]]
|
|
273
|
+
when Predicate::In
|
|
274
|
+
["#{field_json} IN (#{in_placeholders(pred)})", pred.value]
|
|
275
|
+
when Predicate::NotIn
|
|
276
|
+
["#{field_json} NOT IN (#{in_placeholders(pred)})", pred.value]
|
|
277
|
+
when Predicate::Matches
|
|
278
|
+
translate_matches(field_json, pred, "LIKE")
|
|
279
|
+
when Predicate::NotMatches
|
|
280
|
+
translate_matches(field_json, pred, "NOT LIKE")
|
|
281
|
+
when Predicate::Nil
|
|
282
|
+
["#{field_json} IS NULL", []]
|
|
283
|
+
when Predicate::NotNil
|
|
284
|
+
["#{field_json} IS NOT NULL", []]
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def in_placeholders(pred)
|
|
289
|
+
pred.value.map { "?" }.join(", ")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def translate_matches(field_json, pred, op)
|
|
293
|
+
pattern = pred.value.is_a?(Regexp) ? regex_to_like(pred.value) : "%#{pred.value}%"
|
|
294
|
+
["#{field_json} #{op} ?", [pattern]]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def pred_value(pred) # :nodoc:
|
|
298
|
+
pred.value
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def build_order_clause(orders)
|
|
302
|
+
return "" if orders.empty?
|
|
303
|
+
|
|
304
|
+
clauses = orders.map do |o|
|
|
305
|
+
dir = o.direction == :desc ? "DESC" : "ASC"
|
|
306
|
+
"json_extract(value, '$.#{o.field}') #{dir} NULLS LAST"
|
|
307
|
+
end
|
|
308
|
+
" ORDER BY #{clauses.join(", ")}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def build_limit_offset(limit, offset, params)
|
|
312
|
+
sql = ""
|
|
313
|
+
if limit
|
|
314
|
+
sql += " LIMIT ?"
|
|
315
|
+
params << limit.to_i
|
|
316
|
+
end
|
|
317
|
+
if offset
|
|
318
|
+
sql += " OFFSET ?"
|
|
319
|
+
params << offset.to_i
|
|
320
|
+
end
|
|
321
|
+
sql
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def regex_to_like(regex)
|
|
325
|
+
source = regex.source
|
|
326
|
+
pattern = source.gsub(".+", "%").gsub(".*", "%").gsub(".", "_").gsub("^", "").gsub("$", "")
|
|
327
|
+
"%#{pattern}%"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def parse_json_value(raw)
|
|
331
|
+
JSON.parse(raw)
|
|
332
|
+
rescue JSON::ParserError
|
|
333
|
+
raw
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# ── Raw query helpers ──
|
|
337
|
+
|
|
338
|
+
def execute_query_raw(sql, params = [])
|
|
165
339
|
@db.execute(sql, params) do |row|
|
|
166
340
|
yield row if block_given?
|
|
167
341
|
end
|
|
@@ -182,7 +356,7 @@ module Lutaml
|
|
|
182
356
|
end
|
|
183
357
|
|
|
184
358
|
def get_schema_version
|
|
185
|
-
|
|
359
|
+
execute_query_raw("PRAGMA user_version") do |row|
|
|
186
360
|
return row[0]
|
|
187
361
|
end
|
|
188
362
|
0
|