feat: Add PostgreSQL and Redis client support
Some checks failed
Rhai Tests / Run Rhai Tests (push) Waiting to run
Rhai Tests / Run Rhai Tests (pull_request) Has been cancelled

- Add PostgreSQL client functionality for database interactions.
- Add Redis client functionality for cache and data store operations.
- Extend Rhai scripting with PostgreSQL and Redis client modules.
- Add documentation and test cases for both clients.
This commit is contained in:
Mahmoud Emad
2025-05-09 09:45:50 +03:00
parent d3c645e8e6
commit f002445c9e
29 changed files with 2787 additions and 455 deletions

View File

@@ -6,10 +6,13 @@ A robust Redis client wrapper for Rust applications that provides connection man
- **Singleton Pattern**: Maintains a global Redis client instance, so we don't re-int all the time.
- **Connection Management**: Automatically handles connection creation and reconnection
- **Flexible Connectivity**:
- **Flexible Connectivity**:
- Tries Unix socket connection first (`$HOME/hero/var/myredis.sock`)
- Falls back to TCP connection (localhost) if socket connection fails
- **Database Selection**: Uses the `REDISDB` environment variable to select the Redis database (defaults to 0)
- **Authentication Support**: Supports username/password authentication
- **Builder Pattern**: Flexible configuration with a builder pattern
- **TLS Support**: Optional TLS encryption for secure connections
- **Error Handling**: Comprehensive error handling with detailed error messages
- **Thread Safety**: Safe to use in multi-threaded applications
@@ -52,9 +55,51 @@ let result: redis::RedisResult<()> = client.execute(&mut cmd);
reset()?;
```
### Builder Pattern
The module provides a builder pattern for flexible configuration:
```rust
use crate::redisclient::{RedisConfigBuilder, with_config};
// Create a configuration builder
let config = RedisConfigBuilder::new()
.host("redis.example.com")
.port(6379)
.db(1)
.username("user")
.password("secret")
.use_tls(true)
.connection_timeout(30);
// Connect with the configuration
let client = with_config(config)?;
```
### Unix Socket Connection
You can explicitly configure a Unix socket connection:
```rust
use crate::redisclient::{RedisConfigBuilder, with_config};
// Create a configuration builder for Unix socket
let config = RedisConfigBuilder::new()
.use_unix_socket(true)
.socket_path("/path/to/redis.sock")
.db(1);
// Connect with the configuration
let client = with_config(config)?;
```
## Environment Variables
- `REDISDB`: Specifies the Redis database number to use (default: 0)
- `REDIS_HOST`: Specifies the Redis host (default: 127.0.0.1)
- `REDIS_PORT`: Specifies the Redis port (default: 6379)
- `REDIS_USERNAME`: Specifies the Redis username for authentication
- `REDIS_PASSWORD`: Specifies the Redis password for authentication
- `HOME`: Used to determine the path to the Redis Unix socket
## Connection Strategy
@@ -77,6 +122,25 @@ The module includes both unit tests and integration tests:
- Integration tests that require a real Redis server
- Tests automatically skip if Redis is not available
### Unit Tests
- Tests for the builder pattern and configuration
- Tests for connection URL building
- Tests for environment variable handling
### Integration Tests
- Tests for basic Redis operations (SET, GET, EXPIRE)
- Tests for hash operations (HSET, HGET, HGETALL, HDEL)
- Tests for list operations (RPUSH, LLEN, LRANGE, LPOP)
- Tests for error handling (invalid commands, wrong data types)
Run the tests with:
```bash
cargo test --lib redisclient::tests
```
## Thread Safety
The Redis client is wrapped in an `Arc<Mutex<>>` to ensure thread safety when accessing the global instance.

View File

@@ -1,9 +1,149 @@
use redis::{Client, Connection, RedisError, RedisResult, Cmd};
use lazy_static::lazy_static;
use redis::{Client, Cmd, Connection, RedisError, RedisResult};
use std::env;
use std::path::Path;
use std::sync::{Arc, Mutex, Once};
use std::sync::atomic::{AtomicBool, Ordering};
use lazy_static::lazy_static;
use std::sync::{Arc, Mutex, Once};
/// Redis connection configuration builder
///
/// This struct is used to build a Redis connection configuration.
/// It follows the builder pattern to allow for flexible configuration.
#[derive(Clone)]
pub struct RedisConfigBuilder {
pub host: String,
pub port: u16,
pub db: i64,
pub username: Option<String>,
pub password: Option<String>,
pub use_tls: bool,
pub use_unix_socket: bool,
pub socket_path: Option<String>,
pub connection_timeout: Option<u64>,
}
impl Default for RedisConfigBuilder {
fn default() -> Self {
Self {
host: "127.0.0.1".to_string(),
port: 6379,
db: 0,
username: None,
password: None,
use_tls: false,
use_unix_socket: false,
socket_path: None,
connection_timeout: None,
}
}
}
impl RedisConfigBuilder {
/// Create a new Redis connection configuration builder with default values
pub fn new() -> Self {
Self::default()
}
/// Set the host for the Redis connection
pub fn host(mut self, host: &str) -> Self {
self.host = host.to_string();
self
}
/// Set the port for the Redis connection
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
/// Set the database for the Redis connection
pub fn db(mut self, db: i64) -> Self {
self.db = db;
self
}
/// Set the username for the Redis connection (Redis 6.0+)
pub fn username(mut self, username: &str) -> Self {
self.username = Some(username.to_string());
self
}
/// Set the password for the Redis connection
pub fn password(mut self, password: &str) -> Self {
self.password = Some(password.to_string());
self
}
/// Enable TLS for the Redis connection
pub fn use_tls(mut self, use_tls: bool) -> Self {
self.use_tls = use_tls;
self
}
/// Use Unix socket for the Redis connection
pub fn use_unix_socket(mut self, use_unix_socket: bool) -> Self {
self.use_unix_socket = use_unix_socket;
self
}
/// Set the Unix socket path for the Redis connection
pub fn socket_path(mut self, socket_path: &str) -> Self {
self.socket_path = Some(socket_path.to_string());
self.use_unix_socket = true;
self
}
/// Set the connection timeout in seconds
pub fn connection_timeout(mut self, seconds: u64) -> Self {
self.connection_timeout = Some(seconds);
self
}
/// Build the connection URL from the configuration
pub fn build_connection_url(&self) -> String {
if self.use_unix_socket {
if let Some(ref socket_path) = self.socket_path {
return format!("unix://{}", socket_path);
} else {
// Default socket path
let home_dir = env::var("HOME").unwrap_or_else(|_| String::from("/root"));
return format!("unix://{}/hero/var/myredis.sock", home_dir);
}
}
let mut url = if self.use_tls {
format!("rediss://{}:{}", self.host, self.port)
} else {
format!("redis://{}:{}", self.host, self.port)
};
// Add authentication if provided
if let Some(ref username) = self.username {
if let Some(ref password) = self.password {
url = format!(
"redis://{}:{}@{}:{}",
username, password, self.host, self.port
);
} else {
url = format!("redis://{}@{}:{}", username, self.host, self.port);
}
} else if let Some(ref password) = self.password {
url = format!("redis://:{}@{}:{}", password, self.host, self.port);
}
// Add database
url = format!("{}/{}", url, self.db);
url
}
/// Build a Redis client from the configuration
pub fn build(&self) -> RedisResult<(Client, i64)> {
let url = self.build_connection_url();
let client = Client::open(url)?;
Ok((client, self.db))
}
}
// Global Redis client instance using lazy_static
lazy_static! {
@@ -33,7 +173,7 @@ impl RedisClientWrapper {
// Execute a command on the Redis connection
pub fn execute<T: redis::FromRedisValue>(&self, cmd: &mut Cmd) -> RedisResult<T> {
let mut conn_guard = self.connection.lock().unwrap();
// If we don't have a connection or it's not working, create a new one
if conn_guard.is_none() || {
if let Some(ref mut conn) = *conn_guard {
@@ -55,22 +195,25 @@ impl RedisClientWrapper {
}
let mut conn = self.client.get_connection()?;
// Ping Redis to ensure it works
let ping_result: String = redis::cmd("PING").query(&mut conn)?;
if ping_result != "PONG" {
return Err(RedisError::from((redis::ErrorKind::ResponseError, "Failed to ping Redis server")));
return Err(RedisError::from((
redis::ErrorKind::ResponseError,
"Failed to ping Redis server",
)));
}
// Select the database
redis::cmd("SELECT").arg(self.db).execute(&mut conn);
self.initialized.store(true, Ordering::Relaxed);
// Store the connection
let mut conn_guard = self.connection.lock().unwrap();
*conn_guard = Some(conn);
Ok(())
}
}
@@ -84,65 +227,91 @@ pub fn get_redis_client() -> RedisResult<Arc<RedisClientWrapper>> {
return Ok(Arc::clone(client));
}
}
// Create a new client
let client = create_redis_client()?;
// Store the client globally
{
let mut guard = REDIS_CLIENT.lock().unwrap();
*guard = Some(Arc::clone(&client));
}
Ok(client)
}
// Create a new Redis client
fn create_redis_client() -> RedisResult<Arc<RedisClientWrapper>> {
// First try: Connect via Unix socket
// Get Redis configuration from environment variables
let db = get_redis_db();
let password = env::var("REDIS_PASSWORD").ok();
let username = env::var("REDIS_USERNAME").ok();
let host = env::var("REDIS_HOST").unwrap_or_else(|_| String::from("127.0.0.1"));
let port = env::var("REDIS_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(6379);
// Create a builder with environment variables
let mut builder = RedisConfigBuilder::new().host(&host).port(port).db(db);
if let Some(user) = username {
builder = builder.username(&user);
}
if let Some(pass) = password {
builder = builder.password(&pass);
}
// First try: Connect via Unix socket if it exists
let home_dir = env::var("HOME").unwrap_or_else(|_| String::from("/root"));
let socket_path = format!("{}/hero/var/myredis.sock", home_dir);
if Path::new(&socket_path).exists() {
// Try to connect via Unix socket
let socket_url = format!("unix://{}", socket_path);
match Client::open(socket_url) {
Ok(client) => {
let db = get_redis_db();
let socket_builder = builder.clone().socket_path(&socket_path);
match socket_builder.build() {
Ok((client, db)) => {
let wrapper = Arc::new(RedisClientWrapper::new(client, db));
// Initialize the client
if let Err(err) = wrapper.initialize() {
eprintln!("Socket exists at {} but connection failed: {}", socket_path, err);
eprintln!(
"Socket exists at {} but connection failed: {}",
socket_path, err
);
} else {
return Ok(wrapper);
}
},
}
Err(err) => {
eprintln!("Socket exists at {} but connection failed: {}", socket_path, err);
eprintln!(
"Socket exists at {} but connection failed: {}",
socket_path, err
);
}
}
}
// Second try: Connect via TCP to localhost
let tcp_url = "redis://127.0.0.1/";
match Client::open(tcp_url) {
Ok(client) => {
let db = get_redis_db();
// Second try: Connect via TCP
match builder.clone().build() {
Ok((client, db)) => {
let wrapper = Arc::new(RedisClientWrapper::new(client, db));
// Initialize the client
wrapper.initialize()?;
Ok(wrapper)
},
Err(err) => {
Err(RedisError::from((
redis::ErrorKind::IoError,
"Failed to connect to Redis",
format!("Could not connect via socket at {} or via TCP to localhost: {}", socket_path, err)
)))
}
Err(err) => Err(RedisError::from((
redis::ErrorKind::IoError,
"Failed to connect to Redis",
format!(
"Could not connect via socket at {} or via TCP to {}:{}: {}",
socket_path, host, port, err
),
))),
}
}
@@ -161,7 +330,7 @@ pub fn reset() -> RedisResult<()> {
let mut client_guard = REDIS_CLIENT.lock().unwrap();
*client_guard = None;
}
// Create a new client, only return error if it fails
// We don't need to return the client itself
get_redis_client()?;
@@ -175,4 +344,18 @@ where
{
let client = get_redis_client()?;
client.execute(cmd)
}
}
/// Create a new Redis client with custom configuration
///
/// # Arguments
///
/// * `config` - The Redis connection configuration builder
///
/// # Returns
///
/// * `RedisResult<Client>` - The Redis client if successful, error otherwise
pub fn with_config(config: RedisConfigBuilder) -> RedisResult<Client> {
let (client, _) = config.build()?;
Ok(client)
}

View File

@@ -1,25 +1,25 @@
use super::*;
use std::env;
use redis::RedisResult;
use std::env;
#[cfg(test)]
mod redis_client_tests {
use super::*;
#[test]
fn test_env_vars() {
// Save original REDISDB value to restore later
let original_redisdb = env::var("REDISDB").ok();
// Set test environment variables
env::set_var("REDISDB", "5");
// Test with invalid value
env::set_var("REDISDB", "invalid");
// Test with unset value
env::remove_var("REDISDB");
// Restore original REDISDB value
if let Some(redisdb) = original_redisdb {
env::set_var("REDISDB", redisdb);
@@ -27,21 +27,21 @@ mod redis_client_tests {
env::remove_var("REDISDB");
}
}
#[test]
fn test_redis_client_creation_mock() {
// This is a simplified test that doesn't require an actual Redis server
// It just verifies that the function handles environment variables correctly
// Save original HOME value to restore later
let original_home = env::var("HOME").ok();
// Set HOME to a test value
env::set_var("HOME", "/tmp");
// The actual client creation would be tested in integration tests
// with a real Redis server or a mock
// Restore original HOME value
if let Some(home) = original_home {
env::set_var("HOME", home);
@@ -49,12 +49,12 @@ mod redis_client_tests {
env::remove_var("HOME");
}
}
#[test]
fn test_reset_mock() {
// This is a simplified test that doesn't require an actual Redis server
// In a real test, we would need to mock the Redis client
// Just verify that the reset function doesn't panic
// This is a minimal test - in a real scenario, we would use mocking
// to verify that the client is properly reset
@@ -63,6 +63,77 @@ mod redis_client_tests {
// So we don't assert anything here
}
}
#[test]
fn test_redis_config_builder() {
// Test the Redis configuration builder
// Test default values
let config = RedisConfigBuilder::new();
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 6379);
assert_eq!(config.db, 0);
assert_eq!(config.username, None);
assert_eq!(config.password, None);
assert_eq!(config.use_tls, false);
assert_eq!(config.use_unix_socket, false);
assert_eq!(config.socket_path, None);
assert_eq!(config.connection_timeout, None);
// Test setting values
let config = RedisConfigBuilder::new()
.host("redis.example.com")
.port(6380)
.db(1)
.username("user")
.password("pass")
.use_tls(true)
.connection_timeout(30);
assert_eq!(config.host, "redis.example.com");
assert_eq!(config.port, 6380);
assert_eq!(config.db, 1);
assert_eq!(config.username, Some("user".to_string()));
assert_eq!(config.password, Some("pass".to_string()));
assert_eq!(config.use_tls, true);
assert_eq!(config.connection_timeout, Some(30));
// Test socket path setting
let config = RedisConfigBuilder::new().socket_path("/tmp/redis.sock");
assert_eq!(config.use_unix_socket, true);
assert_eq!(config.socket_path, Some("/tmp/redis.sock".to_string()));
}
#[test]
fn test_connection_url_building() {
// Test building connection URLs
// Test default URL
let config = RedisConfigBuilder::new();
let url = config.build_connection_url();
assert_eq!(url, "redis://127.0.0.1:6379/0");
// Test with authentication
let config = RedisConfigBuilder::new().username("user").password("pass");
let url = config.build_connection_url();
assert_eq!(url, "redis://user:pass@127.0.0.1:6379/0");
// Test with password only
let config = RedisConfigBuilder::new().password("pass");
let url = config.build_connection_url();
assert_eq!(url, "redis://:pass@127.0.0.1:6379/0");
// Test with TLS
let config = RedisConfigBuilder::new().use_tls(true);
let url = config.build_connection_url();
assert_eq!(url, "rediss://127.0.0.1:6379/0");
// Test with Unix socket
let config = RedisConfigBuilder::new().socket_path("/tmp/redis.sock");
let url = config.build_connection_url();
assert_eq!(url, "unix:///tmp/redis.sock");
}
}
// Integration tests that require a real Redis server
@@ -70,7 +141,7 @@ mod redis_client_tests {
#[cfg(test)]
mod redis_integration_tests {
use super::*;
// Helper function to check if Redis is available
fn is_redis_available() -> bool {
match get_redis_client() {
@@ -78,49 +149,200 @@ mod redis_integration_tests {
Err(_) => false,
}
}
#[test]
fn test_redis_client_integration() {
if !is_redis_available() {
println!("Skipping Redis integration tests - Redis server not available");
return;
}
println!("Running Redis integration tests...");
// Test basic operations
test_basic_redis_operations();
// Test more complex operations
test_hash_operations();
test_list_operations();
// Test error handling
test_error_handling();
}
fn test_basic_redis_operations() {
if !is_redis_available() {
return;
}
// Test setting and getting values
let client_result = get_redis_client();
if client_result.is_err() {
// Skip the test if we can't connect to Redis
return;
}
// Create SET command
let mut set_cmd = redis::cmd("SET");
set_cmd.arg("test_key").arg("test_value");
// Execute SET command
let set_result: RedisResult<()> = execute(&mut set_cmd);
assert!(set_result.is_ok());
// Create GET command
let mut get_cmd = redis::cmd("GET");
get_cmd.arg("test_key");
// Execute GET command and check the result
if let Ok(value) = execute::<String>(&mut get_cmd) {
assert_eq!(value, "test_value");
}
// Test expiration
let mut expire_cmd = redis::cmd("EXPIRE");
expire_cmd.arg("test_key").arg(1); // Expire in 1 second
let expire_result: RedisResult<i32> = execute(&mut expire_cmd);
assert!(expire_result.is_ok());
assert_eq!(expire_result.unwrap(), 1);
// Sleep for 2 seconds to let the key expire
std::thread::sleep(std::time::Duration::from_secs(2));
// Check that the key has expired
let mut exists_cmd = redis::cmd("EXISTS");
exists_cmd.arg("test_key");
let exists_result: RedisResult<i32> = execute(&mut exists_cmd);
assert!(exists_result.is_ok());
assert_eq!(exists_result.unwrap(), 0);
// Clean up
let _: RedisResult<()> = execute(&mut redis::cmd("DEL").arg("test_key"));
}
}
fn test_hash_operations() {
if !is_redis_available() {
return;
}
// Test hash operations
let hash_key = "test_hash";
// Set hash fields
let mut hset_cmd = redis::cmd("HSET");
hset_cmd
.arg(hash_key)
.arg("field1")
.arg("value1")
.arg("field2")
.arg("value2");
let hset_result: RedisResult<i32> = execute(&mut hset_cmd);
assert!(hset_result.is_ok());
assert_eq!(hset_result.unwrap(), 2);
// Get hash field
let mut hget_cmd = redis::cmd("HGET");
hget_cmd.arg(hash_key).arg("field1");
let hget_result: RedisResult<String> = execute(&mut hget_cmd);
assert!(hget_result.is_ok());
assert_eq!(hget_result.unwrap(), "value1");
// Get all hash fields
let mut hgetall_cmd = redis::cmd("HGETALL");
hgetall_cmd.arg(hash_key);
let hgetall_result: RedisResult<Vec<String>> = execute(&mut hgetall_cmd);
assert!(hgetall_result.is_ok());
let hgetall_values = hgetall_result.unwrap();
assert_eq!(hgetall_values.len(), 4); // field1, value1, field2, value2
// Delete hash field
let mut hdel_cmd = redis::cmd("HDEL");
hdel_cmd.arg(hash_key).arg("field1");
let hdel_result: RedisResult<i32> = execute(&mut hdel_cmd);
assert!(hdel_result.is_ok());
assert_eq!(hdel_result.unwrap(), 1);
// Clean up
let _: RedisResult<()> = execute(&mut redis::cmd("DEL").arg(hash_key));
}
fn test_list_operations() {
if !is_redis_available() {
return;
}
// Test list operations
let list_key = "test_list";
// Push items to list
let mut rpush_cmd = redis::cmd("RPUSH");
rpush_cmd
.arg(list_key)
.arg("item1")
.arg("item2")
.arg("item3");
let rpush_result: RedisResult<i32> = execute(&mut rpush_cmd);
assert!(rpush_result.is_ok());
assert_eq!(rpush_result.unwrap(), 3);
// Get list length
let mut llen_cmd = redis::cmd("LLEN");
llen_cmd.arg(list_key);
let llen_result: RedisResult<i32> = execute(&mut llen_cmd);
assert!(llen_result.is_ok());
assert_eq!(llen_result.unwrap(), 3);
// Get list range
let mut lrange_cmd = redis::cmd("LRANGE");
lrange_cmd.arg(list_key).arg(0).arg(-1);
let lrange_result: RedisResult<Vec<String>> = execute(&mut lrange_cmd);
assert!(lrange_result.is_ok());
let lrange_values = lrange_result.unwrap();
assert_eq!(lrange_values.len(), 3);
assert_eq!(lrange_values[0], "item1");
assert_eq!(lrange_values[1], "item2");
assert_eq!(lrange_values[2], "item3");
// Pop item from list
let mut lpop_cmd = redis::cmd("LPOP");
lpop_cmd.arg(list_key);
let lpop_result: RedisResult<String> = execute(&mut lpop_cmd);
assert!(lpop_result.is_ok());
assert_eq!(lpop_result.unwrap(), "item1");
// Clean up
let _: RedisResult<()> = execute(&mut redis::cmd("DEL").arg(list_key));
}
fn test_error_handling() {
if !is_redis_available() {
return;
}
// Test error handling
// Test invalid command
let mut invalid_cmd = redis::cmd("INVALID_COMMAND");
let invalid_result: RedisResult<()> = execute(&mut invalid_cmd);
assert!(invalid_result.is_err());
// Test wrong data type
let key = "test_wrong_type";
// Set a string value
let mut set_cmd = redis::cmd("SET");
set_cmd.arg(key).arg("string_value");
let set_result: RedisResult<()> = execute(&mut set_cmd);
assert!(set_result.is_ok());
// Try to use a hash command on a string
let mut hget_cmd = redis::cmd("HGET");
hget_cmd.arg(key).arg("field");
let hget_result: RedisResult<String> = execute(&mut hget_cmd);
assert!(hget_result.is_err());
// Clean up
let _: RedisResult<()> = execute(&mut redis::cmd("DEL").arg(key));
}
}