oracle-rs 0.1.7

Pure Rust driver for Oracle databases - no OCI/ODPI-C required
Documentation
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
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Statement caching for improved performance
//!
//! This module provides client-side statement caching to avoid repeated
//! parsing of SQL statements on the Oracle server. When a statement is
//! executed, its cursor ID and metadata are cached. Subsequent executions
//! of the same SQL text can reuse the cached cursor, skipping the parse phase.
//!
//! # Known limitation: server-side cursor cleanup
//!
//! When a cursor completes or a cached statement is evicted, we reset the
//! cursor_id to 0 locally but do not send a cursor-close message to the
//! server. Python-oracledb piggybacks close-cursor requests on subsequent
//! messages to free server resources. For long-running connections with many
//! distinct SQL statements, this could lead to server-side cursor accumulation.
//! Oracle will eventually reclaim these, but explicit cleanup would be better.

use indexmap::IndexMap;
use std::time::Instant;

use crate::statement::Statement;

/// Wrapper for a cached statement with usage tracking
#[derive(Debug)]
struct CachedStatement {
    /// The cached statement with cursor_id and metadata
    statement: Statement,
    /// Whether the statement is currently in use
    in_use: bool,
    /// When this statement was last used
    last_used: Instant,
}

impl CachedStatement {
    fn new(statement: Statement) -> Self {
        Self {
            statement,
            in_use: false,
            last_used: Instant::now(),
        }
    }

    fn touch(&mut self) {
        self.last_used = Instant::now();
    }
}

/// Client-side statement cache using LRU eviction
///
/// The cache stores prepared statements keyed by their SQL text.
/// When a statement is retrieved from cache, its cursor ID is preserved,
/// allowing Oracle to skip parsing and use the cached server cursor.
///
/// # Example
///
/// ```ignore
/// // Statement caching is automatic when enabled via config
/// let mut config = Config::new("localhost", 1521, "FREEPDB1", "user", "pass");
/// config.set_stmtcachesize(20);  // Enable with 20 statement cache
///
/// let conn = Connection::connect_with_config(config).await?;
///
/// // First call: parses SQL, gets cursor_id from Oracle
/// conn.query("SELECT * FROM users WHERE id = :1", &[Value::Integer(1)]).await?;
///
/// // Second call: reuses cached cursor, no re-parsing!
/// conn.query("SELECT * FROM users WHERE id = :1", &[Value::Integer(2)]).await?;
/// ```
#[derive(Debug)]
pub struct StatementCache {
    /// The cache using IndexMap for O(1) lookup + LRU ordering
    cache: IndexMap<String, CachedStatement>,
    /// Maximum number of statements to cache
    max_size: usize,
}

impl StatementCache {
    /// Create a new statement cache with the given maximum size
    ///
    /// A size of 0 effectively disables caching.
    pub fn new(max_size: usize) -> Self {
        Self {
            cache: IndexMap::with_capacity(max_size),
            max_size,
        }
    }

    /// Get a statement from the cache, if available
    ///
    /// Returns a clone of the cached statement with preserved cursor_id and metadata.
    /// If the cached statement is already in use, returns a fresh statement.
    /// Updates LRU ordering on hit.
    pub fn get(&mut self, sql: &str) -> Option<Statement> {
        if self.max_size == 0 {
            return None;
        }

        // Check if we have this SQL cached
        if let Some(cached) = self.cache.get_mut(sql) {
            cached.touch();

            if cached.in_use {
                // Statement is in use - return a fresh statement
                // The caller will get a new cursor from Oracle
                tracing::trace!(sql = sql, "Statement cache hit but in use, returning fresh");
                return None;
            }

            // Mark as in use and return a clone for reuse
            cached.in_use = true;
            tracing::trace!(
                sql = sql,
                cursor_id = cached.statement.cursor_id(),
                "Statement cache hit"
            );
            return Some(cached.statement.clone_for_reuse());
        }

        tracing::trace!(sql = sql, "Statement cache miss");
        None
    }

    /// Store a statement in the cache
    ///
    /// DDL statements are never cached. If the cache is full, the least
    /// recently used statement is evicted and its cursor ID is queued for closing.
    pub fn put(&mut self, sql: String, statement: Statement) {
        if self.max_size == 0 {
            return;
        }

        // Never cache DDL statements (CREATE, ALTER, DROP, etc.)
        if statement.is_ddl() {
            tracing::trace!(sql = sql, "Not caching DDL statement");
            return;
        }

        // Don't cache statements without a cursor_id (not yet executed)
        if statement.cursor_id() == 0 {
            tracing::trace!(sql = sql, "Not caching statement without cursor_id");
            return;
        }

        // Check if already cached (update it)
        if let Some(cached) = self.cache.get_mut(&sql) {
            cached.statement = statement;
            cached.in_use = false;
            cached.touch();
            tracing::trace!(sql = sql, "Updated existing cache entry");
            return;
        }

        // Evict LRU entry if cache is full
        if self.cache.len() >= self.max_size {
            self.evict_lru();
        }

        tracing::trace!(
            sql = sql,
            cursor_id = statement.cursor_id(),
            "Adding statement to cache"
        );
        self.cache.insert(sql, CachedStatement::new(statement));
    }

    /// Return a statement to the cache after use
    ///
    /// This marks the statement as no longer in use so it can be reused.
    pub fn return_statement(&mut self, sql: &str) {
        if let Some(cached) = self.cache.get_mut(sql) {
            cached.in_use = false;
            tracing::trace!(sql = sql, "Statement returned to cache");
        }
    }

    /// Mark a cursor as closed in the cache
    ///
    /// Resets cursor_id to 0 so the next execution gets a fresh cursor from
    /// Oracle. This prevents data corruption from reusing stale cursor IDs.
    ///
    /// Following python-oracledb's clear_cursor design pattern.
    pub fn mark_cursor_closed(&mut self, sql: &str) {
        if let Some(cached) = self.cache.get_mut(sql) {
            if cached.statement.cursor_id() != 0 {
                cached.statement.set_cursor_id(0);
                cached.statement.set_executed(false);
                tracing::trace!(sql = sql, "Cursor closed, reset cursor_id to 0");
            }
        }
    }

    /// Clear all cached statements
    ///
    /// This should be called when the session changes (e.g., DRCP session switch).
    pub fn clear(&mut self) {
        self.cache.clear();
        tracing::debug!("Statement cache cleared");
    }

    /// Get the current number of cached statements
    pub fn len(&self) -> usize {
        self.cache.len()
    }

    /// Check if the cache is empty
    pub fn is_empty(&self) -> bool {
        self.cache.is_empty()
    }

    /// Get the maximum cache size
    pub fn max_size(&self) -> usize {
        self.max_size
    }

    /// Evict the least recently used entry
    fn evict_lru(&mut self) {
        // Find the LRU entry (first entry that's not in use)
        let lru_key = self
            .cache
            .iter()
            .filter(|(_, cached)| !cached.in_use)
            .min_by_key(|(_, cached)| cached.last_used)
            .map(|(key, _)| key.clone());

        if let Some(key) = lru_key {
            if let Some(cached) = self.cache.swap_remove(&key) {
                tracing::trace!(
                    sql = key,
                    cursor_id = cached.statement.cursor_id(),
                    "Evicted LRU statement from cache"
                );
            }
        } else {
            // All statements are in use - this is rare but possible
            tracing::warn!("Statement cache full and all statements in use");
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_test_statement(sql: &str, cursor_id: u16) -> Statement {
        let mut stmt = Statement::new(sql);
        stmt.set_cursor_id(cursor_id);
        stmt.set_executed(true);
        stmt
    }

    #[test]
    fn test_cache_basic() {
        let mut cache = StatementCache::new(5);

        // Add a statement
        let stmt = make_test_statement("SELECT 1 FROM DUAL", 100);
        cache.put("SELECT 1 FROM DUAL".to_string(), stmt);

        assert_eq!(cache.len(), 1);

        // Retrieve it
        let cached = cache.get("SELECT 1 FROM DUAL").expect("Should be cached");
        assert_eq!(cached.cursor_id(), 100);

        // Return it
        cache.return_statement("SELECT 1 FROM DUAL");
    }

    #[test]
    fn test_cache_miss() {
        let mut cache = StatementCache::new(5);
        assert!(cache.get("SELECT 1 FROM DUAL").is_none());
    }

    #[test]
    fn test_cache_disabled() {
        let mut cache = StatementCache::new(0);

        let stmt = make_test_statement("SELECT 1 FROM DUAL", 100);
        cache.put("SELECT 1 FROM DUAL".to_string(), stmt);

        assert_eq!(cache.len(), 0);
        assert!(cache.get("SELECT 1 FROM DUAL").is_none());
    }

    #[test]
    fn test_ddl_not_cached() {
        let mut cache = StatementCache::new(5);

        let mut stmt = Statement::new("CREATE TABLE test (id NUMBER)");
        stmt.set_cursor_id(100);
        cache.put("CREATE TABLE test (id NUMBER)".to_string(), stmt);

        assert_eq!(cache.len(), 0);
    }

    #[test]
    fn test_no_cursor_not_cached() {
        let mut cache = StatementCache::new(5);

        // Statement without cursor_id should not be cached
        let stmt = Statement::new("SELECT 1 FROM DUAL");
        cache.put("SELECT 1 FROM DUAL".to_string(), stmt);

        assert_eq!(cache.len(), 0);
    }

    #[test]
    fn test_lru_eviction() {
        let mut cache = StatementCache::new(3);

        // Add 3 statements
        cache.put(
            "SELECT 1 FROM DUAL".to_string(),
            make_test_statement("SELECT 1 FROM DUAL", 1),
        );
        cache.put(
            "SELECT 2 FROM DUAL".to_string(),
            make_test_statement("SELECT 2 FROM DUAL", 2),
        );
        cache.put(
            "SELECT 3 FROM DUAL".to_string(),
            make_test_statement("SELECT 3 FROM DUAL", 3),
        );

        assert_eq!(cache.len(), 3);

        // Access the first one to make it recently used
        cache.get("SELECT 1 FROM DUAL");
        cache.return_statement("SELECT 1 FROM DUAL");

        // Add a 4th - should evict "SELECT 2" (LRU)
        cache.put(
            "SELECT 4 FROM DUAL".to_string(),
            make_test_statement("SELECT 4 FROM DUAL", 4),
        );

        assert_eq!(cache.len(), 3);
        assert!(cache.get("SELECT 2 FROM DUAL").is_none()); // Evicted
        assert!(cache.get("SELECT 1 FROM DUAL").is_some()); // Still there
    }

    #[test]
    fn test_in_use_not_returned() {
        let mut cache = StatementCache::new(5);

        cache.put(
            "SELECT 1 FROM DUAL".to_string(),
            make_test_statement("SELECT 1 FROM DUAL", 100),
        );

        // Get the statement (marks it in use)
        let _ = cache.get("SELECT 1 FROM DUAL");

        // Try to get it again - should return None because it's in use
        assert!(cache.get("SELECT 1 FROM DUAL").is_none());

        // Return it
        cache.return_statement("SELECT 1 FROM DUAL");

        // Now we can get it again
        assert!(cache.get("SELECT 1 FROM DUAL").is_some());
    }

    #[test]
    fn test_clear() {
        let mut cache = StatementCache::new(5);

        cache.put(
            "SELECT 1 FROM DUAL".to_string(),
            make_test_statement("SELECT 1 FROM DUAL", 1),
        );
        cache.put(
            "SELECT 2 FROM DUAL".to_string(),
            make_test_statement("SELECT 2 FROM DUAL", 2),
        );

        assert_eq!(cache.len(), 2);

        cache.clear();

        assert_eq!(cache.len(), 0);
    }

    #[test]
    fn test_update_existing() {
        let mut cache = StatementCache::new(5);

        cache.put(
            "SELECT 1 FROM DUAL".to_string(),
            make_test_statement("SELECT 1 FROM DUAL", 100),
        );

        // Update with new cursor_id
        cache.put(
            "SELECT 1 FROM DUAL".to_string(),
            make_test_statement("SELECT 1 FROM DUAL", 200),
        );

        assert_eq!(cache.len(), 1);

        let cached = cache.get("SELECT 1 FROM DUAL").unwrap();
        assert_eq!(cached.cursor_id(), 200);
    }
}