1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
use redis::{Client, Commands, Connection};
use sha2::{Digest, Sha256};
use std::time::Duration;
use tracing::{debug, info};
pub struct RedisCache {
connection: Option<Connection>,
enabled: bool,
}
impl RedisCache {
/// Try to create a new Redis cache connection
pub fn new() -> Self {
// Cache is OPT-IN - must explicitly enable via SQL_CLI_CACHE=true
// This ensures sql-cli works exactly as before for users without Redis
match std::env::var("SQL_CLI_CACHE") {
Ok(val) => {
// Only proceed if explicitly enabled
if !val.eq_ignore_ascii_case("true")
&& !val.eq_ignore_ascii_case("yes")
&& val != "1"
{
debug!("Cache not enabled (SQL_CLI_CACHE != true)");
return Self {
connection: None,
enabled: false,
};
}
}
Err(_) => {
// No SQL_CLI_CACHE variable = cache disabled (default)
debug!("Cache disabled by default (set SQL_CLI_CACHE=true to enable)");
return Self {
connection: None,
enabled: false,
};
}
}
debug!("Cache explicitly enabled via SQL_CLI_CACHE=true");
// Get Redis URL from environment or use default
let redis_url = std::env::var("SQL_CLI_REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string());
// Try to connect with a short timeout
match Client::open(redis_url.as_str()) {
Ok(client) => {
// Try to get connection with timeout
match client.get_connection_with_timeout(Duration::from_secs(1)) {
Ok(mut conn) => {
// Test the connection
match redis::cmd("PING").query::<String>(&mut conn) {
Ok(_) => {
debug!("Redis cache connected successfully");
Self {
connection: Some(conn),
enabled: true,
}
}
Err(e) => {
debug!("Redis ping failed: {}", e);
Self {
connection: None,
enabled: false,
}
}
}
}
Err(e) => {
debug!("Redis connection failed: {}", e);
Self {
connection: None,
enabled: false,
}
}
}
}
Err(e) => {
debug!("Redis client creation failed: {}", e);
Self {
connection: None,
enabled: false,
}
}
}
}
/// Generate a cache key from Web CTE components (legacy - kept for compatibility)
pub fn generate_key(
table_name: &str, // CTE name to prevent collisions
url: &str,
method: Option<&str>,
headers: &[(String, String)],
body: Option<&str>,
) -> String {
// Call the new method with empty context for backward compatibility
Self::generate_key_with_context(table_name, url, method, headers, body, "")
}
/// Generate a cache key from Web CTE components with query context
pub fn generate_key_with_context(
table_name: &str, // CTE name
url: &str,
method: Option<&str>,
headers: &[(String, String)],
body: Option<&str>,
query_context: &str, // Hash or unique identifier of the full query
) -> String {
Self::generate_key_full(
table_name,
url,
method,
headers,
body,
query_context,
None, // json_path
&[], // form_files
&[], // form_fields
)
}
/// Generate a complete cache key from all Web CTE components
pub fn generate_key_full(
table_name: &str,
url: &str,
method: Option<&str>,
headers: &[(String, String)],
body: Option<&str>,
_query_context: &str, // Kept for API compatibility but not used
json_path: Option<&str>,
form_files: &[(String, String)],
form_fields: &[(String, String)],
) -> String {
let mut hasher = Sha256::new();
// NOTE: We do NOT include query_context - each WEB CTE should be
// independent and cache based only on its own properties
// Hash the CTE name first
hasher.update(table_name.as_bytes());
hasher.update(b":::"); // Separator
// Hash URL
hasher.update(url.as_bytes());
hasher.update(b":::");
// Hash method
if let Some(method) = method {
hasher.update(method.as_bytes());
hasher.update(b":::");
}
// Hash headers (sorted for consistency)
let mut sorted_headers = headers.to_vec();
sorted_headers.sort_by(|a, b| a.0.cmp(&b.0));
for (key, value) in sorted_headers {
hasher.update(key.as_bytes());
hasher.update(b":");
hasher.update(value.as_bytes());
hasher.update(b";");
}
// Hash body
if let Some(body) = body {
hasher.update(b"body:");
hasher.update(body.as_bytes());
hasher.update(b":::");
}
// Hash json_path
if let Some(path) = json_path {
hasher.update(b"json_path:");
hasher.update(path.as_bytes());
hasher.update(b":::");
}
// Hash form_files (sorted for consistency)
if !form_files.is_empty() {
let mut sorted_files = form_files.to_vec();
sorted_files.sort_by(|a, b| a.0.cmp(&b.0));
for (field, path) in sorted_files {
hasher.update(b"file:");
hasher.update(field.as_bytes());
hasher.update(b"=");
hasher.update(path.as_bytes());
hasher.update(b";");
}
}
// Hash form_fields (sorted for consistency)
if !form_fields.is_empty() {
let mut sorted_fields = form_fields.to_vec();
sorted_fields.sort_by(|a, b| a.0.cmp(&b.0));
for (field, value) in sorted_fields {
hasher.update(b"field:");
hasher.update(field.as_bytes());
hasher.update(b"=");
hasher.update(value.as_bytes());
hasher.update(b";");
}
}
format!("sql-cli:web:{}:{:x}", table_name, hasher.finalize())
}
/// Check if cache is enabled
pub fn is_enabled(&self) -> bool {
self.enabled
}
/// Get data from cache
pub fn get(&mut self, key: &str) -> Option<Vec<u8>> {
if !self.enabled {
return None;
}
if let Some(ref mut conn) = self.connection {
match conn.get::<_, Vec<u8>>(key) {
Ok(data) => {
debug!("Cache HIT: {}", &key[0..32.min(key.len())]);
Some(data)
}
Err(_) => {
debug!("Cache MISS: {}", &key[0..32.min(key.len())]);
None
}
}
} else {
None
}
}
/// Store data in cache with TTL
pub fn set(&mut self, key: &str, data: &[u8], ttl_seconds: u64) -> Result<(), String> {
if !self.enabled {
return Ok(());
}
if let Some(ref mut conn) = self.connection {
match conn.set_ex::<_, _, String>(key, data, ttl_seconds as usize) {
Ok(_) => {
info!("Cached {} bytes with TTL {}s", data.len(), ttl_seconds);
Ok(())
}
Err(e) => {
debug!("Failed to cache: {}", e);
// Don't fail the query just because caching failed
Ok(())
}
}
} else {
Ok(())
}
}
/// Get TTL of a key (for debugging)
pub fn ttl(&mut self, key: &str) -> Option<i64> {
if !self.enabled {
return None;
}
if let Some(ref mut conn) = self.connection {
conn.ttl(key).ok()
} else {
None
}
}
/// Check cache statistics
/// Purge all cache entries (delete all keys matching sql-cli:* pattern)
pub fn purge_all(&mut self) -> Result<usize, String> {
if !self.enabled {
return Err("Cache not enabled".to_string());
}
let conn = self.connection.as_mut().ok_or("No connection")?;
// Scan for all keys matching our prefix
let pattern = "sql-cli:*";
let keys: Vec<String> = redis::cmd("KEYS")
.arg(pattern)
.query(conn)
.map_err(|e| format!("Failed to scan keys: {}", e))?;
if keys.is_empty() {
return Ok(0);
}
let count = keys.len();
// Delete all keys in one command
redis::cmd("DEL")
.arg(&keys)
.query::<()>(conn)
.map_err(|e| format!("Failed to delete keys: {}", e))?;
info!("Purged {} cache entries", count);
Ok(count)
}
pub fn stats(&mut self) -> Option<String> {
if !self.enabled {
return None;
}
if let Some(ref mut conn) = self.connection {
// Get all sql-cli keys
if let Ok(keys) = redis::cmd("KEYS")
.arg("sql-cli:*")
.query::<Vec<String>>(conn)
{
let count = keys.len();
let mut total_size = 0;
let mut expiring_soon = 0;
for key in &keys {
// Get memory usage
if let Ok(size) = redis::cmd("MEMORY")
.arg("USAGE")
.arg(key)
.query::<Option<usize>>(conn)
{
total_size += size.unwrap_or(0);
}
// Check TTL
if let Ok(ttl) = conn.ttl::<_, i64>(key) {
if ttl > 0 && ttl < 300 {
expiring_soon += 1;
}
}
}
return Some(format!(
"Cache stats: {} entries, {:.2} MB, {} expiring soon",
count,
total_size as f64 / 1_048_576.0,
expiring_soon
));
}
}
None
}
}
impl Default for RedisCache {
fn default() -> Self {
Self::new()
}
}