Skip to main content

redis_oxide/
script.rs

1//! Lua scripting support for Redis
2//!
3//! This module provides functionality for executing Lua scripts on Redis servers
4//! using EVAL and EVALSHA commands. Scripts are automatically cached and managed
5//! for optimal performance.
6//!
7//! # Examples
8//!
9//! ## Basic Script Execution
10//!
11//! ```no_run
12//! use redis_oxide::{Client, ConnectionConfig};
13//!
14//! # #[tokio::main]
15//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! let config = ConnectionConfig::new("redis://localhost:6379");
17//! let client = Client::connect(config).await?;
18//!
19//! // Execute a simple Lua script
20//! let script = "return redis.call('GET', KEYS[1])";
21//! let result: Option<String> = client.eval(script, vec!["mykey".to_string()], vec![]).await?;
22//! println!("Result: {:?}", result);
23//! # Ok(())
24//! # }
25//! ```
26//!
27//! ## Script with Arguments
28//!
29//! ```no_run
30//! use redis_oxide::{Client, ConnectionConfig};
31//!
32//! # #[tokio::main]
33//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
34//! let config = ConnectionConfig::new("redis://localhost:6379");
35//! let client = Client::connect(config).await?;
36//!
37//! // Script that increments a counter by a given amount
38//! let script = r#"
39//!     local current = redis.call('GET', KEYS[1]) or 0
40//!     local increment = tonumber(ARGV[1])
41//!     local new_value = tonumber(current) + increment
42//!     redis.call('SET', KEYS[1], new_value)
43//!     return new_value
44//! "#;
45//!
46//! let result: i64 = client.eval(
47//!     script,
48//!     vec!["counter".to_string()],
49//!     vec!["5".to_string()]
50//! ).await?;
51//! println!("New counter value: {}", result);
52//! # Ok(())
53//! # }
54//! ```
55//!
56//! ## Using Script Manager for Caching
57//!
58//! ```no_run
59//! use redis_oxide::{Client, ConnectionConfig, Script};
60//!
61//! # #[tokio::main]
62//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
63//! let config = ConnectionConfig::new("redis://localhost:6379");
64//! let client = Client::connect(config).await?;
65//!
66//! // Create a reusable script
67//! let script = Script::new(r#"
68//!     local key = KEYS[1]
69//!     local value = ARGV[1]
70//!     redis.call('SET', key, value)
71//!     return redis.call('GET', key)
72//! "#);
73//!
74//! // Execute the script (automatically uses EVALSHA if cached)
75//! let result: String = script.execute(
76//!     &client,
77//!     vec!["mykey".to_string()],
78//!     vec!["myvalue".to_string()]
79//! ).await?;
80//! println!("Result: {}", result);
81//! # Ok(())
82//! # }
83//! ```
84
85use crate::core::{
86    error::{RedisError, RedisResult},
87    value::RespValue,
88};
89use sha1::{Digest, Sha1};
90use std::collections::HashMap;
91use std::convert::TryFrom;
92use std::sync::Arc;
93use tokio::sync::RwLock;
94
95/// A Lua script that can be executed on Redis
96#[derive(Debug, Clone)]
97pub struct Script {
98    /// The Lua script source code
99    source: String,
100    /// SHA1 hash of the script (for EVALSHA)
101    sha: String,
102}
103
104impl Script {
105    /// Create a new script from Lua source code
106    ///
107    /// The script is automatically hashed for use with EVALSHA.
108    ///
109    /// # Examples
110    ///
111    /// ```
112    /// use redis_oxide::Script;
113    ///
114    /// let script = Script::new("return redis.call('GET', KEYS[1])");
115    /// println!("Script SHA: {}", script.sha());
116    /// ```
117    pub fn new(source: impl Into<String>) -> Self {
118        let source = source.into();
119        let sha = calculate_sha1(&source);
120
121        Self { source, sha }
122    }
123
124    /// Get the SHA1 hash of the script
125    #[must_use]
126    pub fn sha(&self) -> &str {
127        &self.sha
128    }
129
130    /// Get the source code of the script
131    #[must_use]
132    pub fn source(&self) -> &str {
133        &self.source
134    }
135
136    /// Execute the script on the given client
137    ///
138    /// This method will first try to use EVALSHA (if the script is cached on the server),
139    /// and fall back to EVAL if the script is not cached.
140    ///
141    /// # Arguments
142    ///
143    /// * `client` - The Redis client to execute the script on
144    /// * `keys` - List of Redis keys that the script will access (KEYS array in Lua)
145    /// * `args` - List of arguments to pass to the script (ARGV array in Lua)
146    ///
147    /// # Examples
148    ///
149    /// ```no_run
150    /// # use redis_oxide::{Client, ConnectionConfig, Script};
151    /// # #[tokio::main]
152    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
153    /// # let config = ConnectionConfig::new("redis://localhost:6379");
154    /// # let client = Client::connect(config).await?;
155    /// let script = Script::new("return KEYS[1] .. ':' .. ARGV[1]");
156    ///
157    /// let result: String = script.execute(
158    ///     &client,
159    ///     vec!["user".to_string()],
160    ///     vec!["123".to_string()]
161    /// ).await?;
162    ///
163    /// assert_eq!(result, "user:123");
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub async fn execute<T>(
168        &self,
169        client: &crate::Client,
170        keys: Vec<String>,
171        args: Vec<String>,
172    ) -> RedisResult<T>
173    where
174        T: TryFrom<RespValue>,
175        T::Error: Into<RedisError>,
176    {
177        // First try EVALSHA
178        match client.evalsha(&self.sha, keys.clone(), args.clone()).await {
179            Ok(result) => Ok(result),
180            Err(RedisError::Protocol(msg) | RedisError::Server(msg))
181                if msg.contains("NOSCRIPT") =>
182            {
183                // Script not cached, use EVAL
184                client.eval(&self.source, keys, args).await
185            }
186            Err(e) => Err(e),
187        }
188    }
189
190    /// Load the script into Redis cache
191    ///
192    /// This sends the script to Redis using SCRIPT LOAD, which caches it
193    /// for future EVALSHA calls.
194    ///
195    /// # Examples
196    ///
197    /// ```no_run
198    /// # use redis_oxide::{Client, ConnectionConfig, Script};
199    /// # #[tokio::main]
200    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
201    /// # let config = ConnectionConfig::new("redis://localhost:6379");
202    /// # let client = Client::connect(config).await?;
203    /// let script = Script::new("return 'Hello, World!'");
204    ///
205    /// // Preload the script
206    /// let sha = script.load(&client).await?;
207    /// println!("Script loaded with SHA: {}", sha);
208    /// # Ok(())
209    /// # }
210    /// ```
211    pub async fn load(&self, client: &crate::Client) -> RedisResult<String> {
212        client.script_load(&self.source).await
213    }
214}
215
216/// Script manager for caching and managing multiple scripts
217#[derive(Debug)]
218pub struct ScriptManager {
219    scripts: Arc<RwLock<HashMap<String, Script>>>,
220}
221
222impl ScriptManager {
223    /// Create a new script manager
224    #[must_use]
225    pub fn new() -> Self {
226        Self {
227            scripts: Arc::new(RwLock::new(HashMap::new())),
228        }
229    }
230
231    /// Register a script with the manager
232    ///
233    /// # Examples
234    ///
235    /// ```no_run
236    /// use redis_oxide::{Script, ScriptManager};
237    ///
238    /// # #[tokio::main]
239    /// # async fn main() {
240    /// let mut manager = ScriptManager::new();
241    /// let script = Script::new("return 'Hello'");
242    ///
243    /// manager.register("greeting", script).await;
244    /// # }
245    /// ```
246    pub async fn register(&self, name: impl Into<String>, script: Script) {
247        let mut scripts = self.scripts.write().await;
248        scripts.insert(name.into(), script);
249    }
250
251    /// Get a script by name
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// # use redis_oxide::{Script, ScriptManager};
257    /// # #[tokio::main]
258    /// # async fn main() {
259    /// let manager = ScriptManager::new();
260    /// let script = Script::new("return 'Hello'");
261    /// manager.register("greeting", script).await;
262    ///
263    /// if let Some(script) = manager.get("greeting").await {
264    ///     println!("Found script with SHA: {}", script.sha());
265    /// }
266    /// # }
267    /// ```
268    pub async fn get(&self, name: &str) -> Option<Script> {
269        let scripts = self.scripts.read().await;
270        scripts.get(name).cloned()
271    }
272
273    /// Execute a script by name
274    ///
275    /// # Examples
276    ///
277    /// ```no_run
278    /// # use redis_oxide::{Client, ConnectionConfig, Script, ScriptManager};
279    /// # #[tokio::main]
280    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
281    /// # let config = ConnectionConfig::new("redis://localhost:6379");
282    /// # let client = Client::connect(config).await?;
283    /// let manager = ScriptManager::new();
284    /// let script = Script::new("return KEYS[1]");
285    /// manager.register("get_key", script).await;
286    ///
287    /// let result: String = manager.execute(
288    ///     "get_key",
289    ///     &client,
290    ///     vec!["mykey".to_string()],
291    ///     vec![]
292    /// ).await?;
293    /// # Ok(())
294    /// # }
295    /// ```
296    pub async fn execute<T>(
297        &self,
298        name: &str,
299        client: &crate::Client,
300        keys: Vec<String>,
301        args: Vec<String>,
302    ) -> RedisResult<T>
303    where
304        T: TryFrom<RespValue>,
305        T::Error: Into<RedisError>,
306    {
307        let script = self
308            .get(name)
309            .await
310            .ok_or_else(|| RedisError::Protocol(format!("Script '{}' not found", name)))?;
311
312        script.execute(client, keys, args).await
313    }
314
315    /// Load all registered scripts into Redis cache
316    ///
317    /// # Examples
318    ///
319    /// ```no_run
320    /// # use redis_oxide::{Client, ConnectionConfig, Script, ScriptManager};
321    /// # #[tokio::main]
322    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
323    /// # let config = ConnectionConfig::new("redis://localhost:6379");
324    /// # let client = Client::connect(config).await?;
325    /// let manager = ScriptManager::new();
326    ///
327    /// // Register some scripts
328    /// manager.register("script1", Script::new("return 1")).await;
329    /// manager.register("script2", Script::new("return 2")).await;
330    ///
331    /// // Load all scripts at once
332    /// let results = manager.load_all(&client).await?;
333    /// println!("Loaded {} scripts", results.len());
334    /// # Ok(())
335    /// # }
336    /// ```
337    pub async fn load_all(&self, client: &crate::Client) -> RedisResult<HashMap<String, String>> {
338        let scripts = self.scripts.read().await;
339        let mut results = HashMap::new();
340
341        for (name, script) in scripts.iter() {
342            let sha = script.load(client).await?;
343            results.insert(name.clone(), sha);
344        }
345
346        Ok(results)
347    }
348
349    /// Get the number of registered scripts
350    #[must_use]
351    pub async fn len(&self) -> usize {
352        let scripts = self.scripts.read().await;
353        scripts.len()
354    }
355
356    /// Check if the manager has any scripts
357    #[must_use]
358    pub async fn is_empty(&self) -> bool {
359        let scripts = self.scripts.read().await;
360        scripts.is_empty()
361    }
362
363    /// List all registered script names
364    pub async fn list_scripts(&self) -> Vec<String> {
365        let scripts = self.scripts.read().await;
366        scripts.keys().cloned().collect()
367    }
368
369    /// Remove a script from the manager
370    pub async fn remove(&self, name: &str) -> Option<Script> {
371        let mut scripts = self.scripts.write().await;
372        scripts.remove(name)
373    }
374
375    /// Clear all scripts from the manager
376    pub async fn clear(&self) {
377        let mut scripts = self.scripts.write().await;
378        scripts.clear();
379    }
380}
381
382impl Default for ScriptManager {
383    fn default() -> Self {
384        Self::new()
385    }
386}
387
388/// Calculate SHA1 hash of a string
389fn calculate_sha1(input: &str) -> String {
390    let mut hasher = Sha1::new();
391    hasher.update(input.as_bytes());
392    let result = hasher.finalize();
393    hex::encode(result)
394}
395
396/// Common Lua script patterns
397pub mod patterns {
398    use super::Script;
399
400    /// Atomic increment with expiration
401    ///
402    /// # Arguments
403    /// - KEYS[1]: The key to increment
404    /// - ARGV\[1\]: Increment amount
405    /// - ARGV\[2\]: Expiration time in seconds
406    pub fn atomic_increment_with_expiration() -> Script {
407        Script::new(
408            r"
409            local key = KEYS[1]
410            local increment = tonumber(ARGV[1])
411            local expiration = tonumber(ARGV[2])
412            
413            local current = redis.call('GET', key)
414            local new_value
415            
416            if current == false then
417                new_value = increment
418            else
419                new_value = tonumber(current) + increment
420            end
421            
422            redis.call('SET', key, new_value)
423            redis.call('EXPIRE', key, expiration)
424            
425            return new_value
426        ",
427        )
428    }
429
430    /// Conditional set (SET if value matches)
431    ///
432    /// # Arguments
433    /// - KEYS\[1\]: The key to set
434    /// - ARGV\[1\]: Expected current value
435    /// - ARGV\[2\]: New value to set
436    pub fn conditional_set() -> Script {
437        Script::new(
438            r"
439            local key = KEYS[1]
440            local expected = ARGV[1]
441            local new_value = ARGV[2]
442            
443            local current = redis.call('GET', key)
444            
445            if current == expected then
446                redis.call('SET', key, new_value)
447                return 1
448            else
449                return 0
450            end
451        ",
452        )
453    }
454
455    /// Rate limiting with sliding window
456    ///
457    /// # Arguments
458    /// - KEYS\[1\]: The rate limit key
459    /// - ARGV\[1\]: Window size in seconds
460    /// - ARGV\[2\]: Maximum requests per window
461    pub fn sliding_window_rate_limit() -> Script {
462        Script::new(
463            r#"
464            local key = KEYS[1]
465            local window = tonumber(ARGV[1])
466            local limit = tonumber(ARGV[2])
467            local now = redis.call('TIME')[1]
468            
469            -- Remove old entries
470            redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
471            
472            -- Count current entries
473            local current = redis.call('ZCARD', key)
474            
475            if current < limit then
476                -- Add current request
477                redis.call('ZADD', key, now, now)
478                redis.call('EXPIRE', key, window)
479                return { 1, limit - current - 1 }
480            else
481                return { 0, 0 }
482            end
483        "#,
484        )
485    }
486
487    /// Distributed lock with expiration
488    ///
489    /// # Arguments
490    /// - KEYS\[1\]: The lock key
491    /// - ARGV\[1\]: Lock identifier (unique per client)
492    /// - ARGV\[2\]: Lock expiration in seconds
493    pub fn distributed_lock() -> Script {
494        Script::new(
495            r#"
496            local key = KEYS[1]
497            local identifier = ARGV[1]
498            local expiration = tonumber(ARGV[2])
499            
500            if redis.call('SET', key, identifier, 'NX', 'EX', expiration) then
501                return 1
502            else
503                return 0
504            end
505        "#,
506        )
507    }
508
509    /// Release distributed lock
510    ///
511    /// # Arguments
512    /// - KEYS\[1\]: The lock key
513    /// - ARGV\[1\]: Lock identifier (must match)
514    pub fn release_lock() -> Script {
515        Script::new(
516            r#"
517            local key = KEYS[1]
518            local identifier = ARGV[1]
519            
520            if redis.call('GET', key) == identifier then
521                return redis.call('DEL', key)
522            else
523                return 0
524            end
525        "#,
526        )
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[test]
535    fn test_script_creation() {
536        let script = Script::new("return 'hello'");
537        assert_eq!(script.source(), "return 'hello'");
538        assert!(!script.sha().is_empty());
539        assert_eq!(script.sha().len(), 40); // SHA1 is 40 hex characters
540    }
541
542    #[test]
543    fn test_sha1_calculation() {
544        let sha = calculate_sha1("hello world");
545        assert_eq!(sha, "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
546    }
547
548    #[test]
549    fn test_script_sha_consistency() {
550        let script1 = Script::new("return 1");
551        let script2 = Script::new("return 1");
552        assert_eq!(script1.sha(), script2.sha());
553    }
554
555    #[test]
556    fn test_script_sha_uniqueness() {
557        let script1 = Script::new("return 1");
558        let script2 = Script::new("return 2");
559        assert_ne!(script1.sha(), script2.sha());
560    }
561
562    #[tokio::test]
563    async fn test_script_manager_creation() {
564        let manager = ScriptManager::new();
565        assert!(manager.is_empty().await);
566        assert_eq!(manager.len().await, 0);
567    }
568
569    #[tokio::test]
570    async fn test_script_manager_register_and_get() {
571        let manager = ScriptManager::new();
572        let script = Script::new("return 'test'");
573        let sha = script.sha().to_string();
574
575        manager.register("test_script", script).await;
576
577        assert!(!manager.is_empty().await);
578        assert_eq!(manager.len().await, 1);
579
580        let retrieved = manager.get("test_script").await.unwrap();
581        assert_eq!(retrieved.sha(), sha);
582        assert_eq!(retrieved.source(), "return 'test'");
583    }
584
585    #[tokio::test]
586    async fn test_script_manager_remove() {
587        let manager = ScriptManager::new();
588        let script = Script::new("return 'test'");
589
590        manager.register("test_script", script).await;
591        assert_eq!(manager.len().await, 1);
592
593        let removed = manager.remove("test_script").await;
594        assert!(removed.is_some());
595        assert_eq!(manager.len().await, 0);
596
597        let not_found = manager.remove("nonexistent").await;
598        assert!(not_found.is_none());
599    }
600
601    #[tokio::test]
602    async fn test_script_manager_clear() {
603        let manager = ScriptManager::new();
604
605        manager.register("script1", Script::new("return 1")).await;
606        manager.register("script2", Script::new("return 2")).await;
607        assert_eq!(manager.len().await, 2);
608
609        manager.clear().await;
610        assert_eq!(manager.len().await, 0);
611        assert!(manager.is_empty().await);
612    }
613
614    #[tokio::test]
615    async fn test_script_manager_list_scripts() {
616        let manager = ScriptManager::new();
617
618        manager
619            .register("script_a", Script::new("return 'a'"))
620            .await;
621        manager
622            .register("script_b", Script::new("return 'b'"))
623            .await;
624
625        let mut scripts = manager.list_scripts().await;
626        scripts.sort();
627
628        assert_eq!(scripts, vec!["script_a", "script_b"]);
629    }
630
631    #[test]
632    fn test_pattern_scripts() {
633        // Test that pattern scripts can be created without panicking
634        let _increment = patterns::atomic_increment_with_expiration();
635        let _conditional = patterns::conditional_set();
636        let _rate_limit = patterns::sliding_window_rate_limit();
637        let _lock = patterns::distributed_lock();
638        let _unlock = patterns::release_lock();
639    }
640}