use crate::InklogError;
use crate::LogRecord;
use async_trait::async_trait;
#[async_trait]
pub trait Database: Send + Sync {
async fn insert_batch(&self, records: &[LogRecord]) -> Result<usize, InklogError>;
async fn is_healthy(&self) -> bool;
}
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
use dbnexus::database::pool::DbPool;
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
use dbnexus::foundation::config::DbConfig;
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
pub struct DbNexusAdapter {
pool: DbPool,
table_name: String,
}
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
impl DbNexusAdapter {
pub async fn new(url: &str, pool_size: u32) -> Result<Self, InklogError> {
Self::with_table_name(url, pool_size, "logs").await
}
pub async fn with_table_name(
url: &str,
pool_size: u32,
table_name: &str,
) -> Result<Self, InklogError> {
let config = DbConfig {
url: url.to_string(),
max_connections: pool_size,
min_connections: 1,
idle_timeout: 300,
acquire_timeout: 5000,
permissions_path: None,
migrations_dir: None,
auto_migrate: false,
migration_timeout: 60,
admin_role: "admin".to_string(),
warmup_timeout: 30,
warmup_retries: 3,
cache_config: dbnexus::foundation::config::CacheConfig::default(),
};
let pool = DbPool::with_config(config).await.map_err(|e| {
InklogError::DatabaseError(format!("Failed to create connection pool: {}", e))
})?;
Ok(Self {
pool,
table_name: table_name.to_string(),
})
}
pub fn from_pool(pool: DbPool, table_name: &str) -> Self {
Self {
pool,
table_name: table_name.to_string(),
}
}
pub fn pool(&self) -> &DbPool {
&self.pool
}
pub fn table_name(&self) -> &str {
&self.table_name
}
}
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
#[async_trait]
impl Database for DbNexusAdapter {
async fn insert_batch(&self, records: &[LogRecord]) -> Result<usize, InklogError> {
if records.is_empty() {
return Ok(0);
}
let session = self
.pool
.get_session("admin")
.await
.map_err(|e| InklogError::DatabaseError(format!("Failed to get session: {}", e)))?;
let sqls: Vec<String> = records
.iter()
.map(|record| {
let timestamp = record.timestamp.to_rfc3339();
let level = &record.level;
let target = &record.target;
let message = record.message.replace('\'', "''");
let fields_json =
serde_json::to_string(&record.fields).unwrap_or_else(|_| "{}".to_string());
let fields_escaped = fields_json.replace('\'', "''");
let file = record
.file
.as_ref()
.map(|f| format!("'{}'", f.replace('\'', "''")))
.unwrap_or_else(|| "NULL".to_string());
let line = record
.line
.map(|l| l.to_string())
.unwrap_or_else(|| "NULL".to_string());
let thread_id = &record.thread_id;
format!(
"INSERT INTO {} (timestamp, level, target, message, fields, file, line, thread_id) \
VALUES ('{}', '{}', '{}', '{}', '{}', {}, {}, '{}')",
self.table_name,
timestamp,
level,
target.replace('\'', "''"),
message,
fields_escaped,
file,
line,
thread_id.replace('\'', "''")
)
})
.collect();
let sql_refs: Vec<&str> = sqls.iter().map(|s| s.as_str()).collect();
session
.batch_execute_in_transaction(sql_refs)
.await
.map_err(|e| {
tracing::error!("Batch insert failed, transaction rolled back: {}", e);
InklogError::DatabaseError(format!("Batch insert failed: {}", e))
})?;
Ok(records.len())
}
async fn is_healthy(&self) -> bool {
match self.pool.get_session("admin").await {
Ok(_) => true,
Err(e) => {
tracing::warn!("Database health check failed: {}", e);
false
}
}
}
}
#[cfg(not(any(feature = "sqlite", feature = "postgres", feature = "mysql")))]
pub struct DbNexusAdapter {
_phantom: (),
}
#[cfg(not(any(feature = "sqlite", feature = "postgres", feature = "mysql")))]
impl DbNexusAdapter {
#[deprecated(note = "Enable 'dbnexus' feature to use DbNexusAdapter")]
pub async fn new(_url: &str, _pool_size: u32) -> Result<Self, InklogError> {
Err(InklogError::DatabaseError(
"DbNexusAdapter requires 'dbnexus' feature to be enabled".to_string(),
))
}
}
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
pub struct MockDatabaseAdapter {
records: RwLock<Vec<LogRecord>>,
healthy: Arc<AtomicBool>,
}
impl MockDatabaseAdapter {
pub fn new() -> Self {
Self {
records: RwLock::new(Vec::new()),
healthy: Arc::new(AtomicBool::new(true)),
}
}
pub fn set_healthy(&self, healthy: bool) {
self.healthy.store(healthy, Ordering::SeqCst);
}
pub fn record_count(&self) -> usize {
self.records.read().unwrap().len()
}
pub fn get_records(&self) -> Vec<LogRecord> {
self.records.read().unwrap().clone()
}
pub fn clear(&self) {
self.records.write().unwrap().clear();
}
}
impl Default for MockDatabaseAdapter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
impl MockDatabaseAdapter {
pub fn stored_count(&self) -> usize {
self.records.read().unwrap().len()
}
}
#[async_trait]
impl Database for MockDatabaseAdapter {
async fn insert_batch(&self, records: &[LogRecord]) -> Result<usize, InklogError> {
if records.is_empty() {
return Ok(0);
}
let mut stored = self.records.write().unwrap();
stored.extend_from_slice(records);
Ok(records.len())
}
async fn is_healthy(&self) -> bool {
self.healthy.load(Ordering::SeqCst)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tracing::Level;
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
#[tokio::test]
async fn test_dbnexus_adapter_health_check() {
let temp_dir = std::env::temp_dir();
let perm_path = temp_dir.join("inklog_health_perm.yaml");
let perm_content = r#"roles:
admin:
tables:
- name: "*"
operations: ["select", "insert", "update", "delete"]
"#;
std::fs::write(&perm_path, perm_content).expect("Failed to write permissions file");
let db_path = temp_dir.join("inklog_health.db");
let db_url = format!("sqlite:{}?mode=rwc", db_path.to_string_lossy());
let config = DbConfig {
url: db_url,
max_connections: 1,
min_connections: 1,
idle_timeout: 300,
acquire_timeout: 30000,
permissions_path: Some(perm_path.to_string_lossy().to_string()),
migrations_dir: None,
auto_migrate: false,
migration_timeout: 60,
admin_role: "admin".to_string(),
warmup_timeout: 60,
warmup_retries: 5,
cache_config: dbnexus::foundation::config::CacheConfig::default(),
};
let pool = DbPool::with_config(config)
.await
.expect("Failed to create pool");
let db = DbNexusAdapter::from_pool(pool, "logs");
let session = db
.pool
.get_session("admin")
.await
.expect("Failed to get session");
session
.execute_raw_ddl(
"CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT NOT NULL,
fields TEXT,
file TEXT,
line INTEGER,
thread_id TEXT NOT NULL
)",
)
.await
.expect("Failed to create table");
drop(session);
let session = db
.pool
.get_session("admin")
.await
.expect("Failed to get session");
let result = session.execute_raw("SELECT COUNT(*) FROM logs").await;
assert!(
result.is_ok(),
"Health check query failed: {:?}",
result.err()
);
drop(session);
drop(db);
let _ = std::fs::remove_file(&perm_path);
let _ = std::fs::remove_file(&db_path);
}
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
#[tokio::test]
async fn test_dbnexus_adapter_insert_batch() {
let temp_dir = std::env::temp_dir();
let perm_path = temp_dir.join("inklog_batch_perm.yaml");
let perm_content = r#"roles:
admin:
tables:
- name: "*"
operations: ["select", "insert", "update", "delete"]
"#;
std::fs::write(&perm_path, perm_content).expect("Failed to write permissions file");
let db_path = temp_dir.join("inklog_batch.db");
let db_url = format!("sqlite:{}?mode=rwc", db_path.to_string_lossy());
let config = DbConfig {
url: db_url,
max_connections: 2,
min_connections: 1,
idle_timeout: 300,
acquire_timeout: 30000,
permissions_path: Some(perm_path.to_string_lossy().to_string()),
migrations_dir: None,
auto_migrate: false,
migration_timeout: 60,
admin_role: "admin".to_string(),
warmup_timeout: 60,
warmup_retries: 5,
cache_config: dbnexus::foundation::config::CacheConfig::default(),
};
let pool = DbPool::with_config(config)
.await
.expect("Failed to create pool");
let db = DbNexusAdapter::from_pool(pool, "logs");
let session = db
.pool
.get_session("admin")
.await
.expect("Failed to get session");
let create_result = session
.execute_raw_ddl(
"CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT NOT NULL,
fields TEXT,
file TEXT,
line INTEGER,
thread_id TEXT NOT NULL
)",
)
.await;
assert!(
create_result.is_ok(),
"Failed to create table: {:?}",
create_result.err()
);
drop(session);
let records = vec![LogRecord::new(
tracing::Level::INFO,
"test::module".to_string(),
"Test message".to_string(),
)];
let count = db.insert_batch(&records).await.expect("Failed to insert");
assert_eq!(count, 1);
drop(db);
let _ = std::fs::remove_file(&perm_path);
let _ = std::fs::remove_file(&db_path);
}
#[cfg(not(any(feature = "sqlite", feature = "postgres", feature = "mysql")))]
#[allow(deprecated)]
#[tokio::test]
async fn test_dbnexus_adapter_not_available_without_feature() {
let result = DbNexusAdapter::new("test", 1).await;
assert!(result.is_err());
if let Err(InklogError::DatabaseError(_)) = result {
} else {
panic!("Expected DatabaseError");
}
}
#[tokio::test]
async fn test_mock_database_insert_batch() {
let db = MockDatabaseAdapter::new();
let records = vec![
LogRecord::new(Level::INFO, "module1".to_string(), "message1".to_string()),
LogRecord::new(Level::WARN, "module2".to_string(), "message2".to_string()),
];
let count = db.insert_batch(&records).await.unwrap();
assert_eq!(count, 2);
assert_eq!(db.record_count(), 2);
}
#[tokio::test]
async fn test_mock_database_insert_empty_batch() {
let db = MockDatabaseAdapter::new();
let records: Vec<LogRecord> = vec![];
let count = db.insert_batch(&records).await.unwrap();
assert_eq!(count, 0);
assert_eq!(db.record_count(), 0);
}
#[tokio::test]
async fn test_mock_database_is_healthy() {
let db = MockDatabaseAdapter::new();
assert!(db.is_healthy().await);
db.set_healthy(false);
assert!(!db.is_healthy().await);
db.set_healthy(true);
assert!(db.is_healthy().await);
}
#[tokio::test]
async fn test_mock_database_get_records() {
let db = MockDatabaseAdapter::new();
let records = vec![
LogRecord::new(Level::INFO, "module".to_string(), "message1".to_string()),
LogRecord::new(Level::ERROR, "module".to_string(), "message2".to_string()),
];
db.insert_batch(&records).await.unwrap();
let stored = db.get_records();
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].message, "message1");
assert_eq!(stored[1].message, "message2");
}
#[tokio::test]
async fn test_mock_database_clear() {
let db = MockDatabaseAdapter::new();
let records = vec![LogRecord::new(
Level::INFO,
"module".to_string(),
"message".to_string(),
)];
db.insert_batch(&records).await.unwrap();
assert_eq!(db.record_count(), 1);
db.clear();
assert_eq!(db.record_count(), 0);
}
#[tokio::test]
async fn test_mock_database_default() {
let db = MockDatabaseAdapter::default();
assert!(db.is_healthy().await);
assert_eq!(db.record_count(), 0);
}
#[tokio::test]
async fn test_mock_database_multiple_inserts() {
let db = MockDatabaseAdapter::new();
let records1 = vec![LogRecord::new(
Level::INFO,
"module1".to_string(),
"message1".to_string(),
)];
db.insert_batch(&records1).await.unwrap();
assert_eq!(db.record_count(), 1);
let records2 = vec![LogRecord::new(
Level::WARN,
"module2".to_string(),
"message2".to_string(),
)];
db.insert_batch(&records2).await.unwrap();
assert_eq!(db.record_count(), 2);
let stored = db.get_records();
assert_eq!(stored[0].message, "message1");
assert_eq!(stored[1].message, "message2");
}
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
#[tokio::test]
async fn test_dbnexus_adapter_with_table_name_creates_instance() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join("inklog_with_table_name.db");
let db_url = format!("sqlite:{}?mode=rwc", db_path.to_string_lossy());
let adapter = DbNexusAdapter::with_table_name(&db_url, 1, "custom_logs")
.await
.expect("with_table_name should succeed");
assert_eq!(adapter.table_name(), "custom_logs");
let _ = std::fs::remove_file(&db_path);
}
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
#[tokio::test]
async fn test_dbnexus_adapter_pool_getter_returns_underlying_pool() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join("inklog_pool_getter.db");
let db_url = format!("sqlite:{}?mode=rwc", db_path.to_string_lossy());
let adapter = DbNexusAdapter::new(&db_url, 1)
.await
.expect("new should succeed");
let _session = adapter
.pool()
.get_session("admin")
.await
.expect("should get session from underlying pool");
let _ = std::fs::remove_file(&db_path);
}
#[cfg(any(feature = "sqlite", feature = "postgres", feature = "mysql"))]
#[tokio::test]
async fn test_dbnexus_adapter_insert_empty_batch_returns_zero() {
let temp_dir = std::env::temp_dir();
let db_path = temp_dir.join("inklog_empty_batch.db");
let db_url = format!("sqlite:{}?mode=rwc", db_path.to_string_lossy());
let adapter = DbNexusAdapter::new(&db_url, 1)
.await
.expect("new should succeed");
let empty: Vec<LogRecord> = vec![];
let count = adapter
.insert_batch(&empty)
.await
.expect("empty batch should succeed");
assert_eq!(count, 0, "empty batch must return 0");
let _ = std::fs::remove_file(&db_path);
}
}