use std::sync::Arc;
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",
feature = "duckdb"
))]
use dbnexus::ConnectionPool;
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
use dbnexus::database::pool::DbPool;
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
use dbnexus::foundation::config::DbConfig;
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
use crate::domain::config::database::DatabaseDriver;
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
pub struct DbNexusAdapter {
pool: Arc<dyn ConnectionPool + Send + Sync>,
table_name: String,
admin_role: String,
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
impl DbNexusAdapter {
pub async fn new(url: &str, pool_size: u32) -> Result<Self, InklogError> {
Self::with_table_name(url, pool_size, crate::support::io::sink::entity::TABLE_NAME).await
}
pub async fn with_table_name(
url: &str,
pool_size: u32,
table_name: &str,
) -> Result<Self, InklogError> {
Self::with_full_config(url, pool_size, table_name, None, "admin").await
}
pub async fn with_full_config(
url: &str,
pool_size: u32,
table_name: &str,
permissions_path: Option<String>,
admin_role: &str,
) -> Result<Self, InklogError> {
validate_table_name(table_name)?;
let config = DbConfig {
url: url.to_string(),
pool_config: dbnexus::foundation::config::PoolConfig {
max_connections: pool_size,
min_connections: 1,
idle_timeout: 300,
acquire_timeout: 5000,
},
permissions_path,
migrations_dir: None,
auto_migrate: false,
migration_timeout: 60,
admin_role: admin_role.to_string(),
warmup_timeout: 30,
warmup_retries: 3,
cache_config: dbnexus::foundation::config::CacheConfig::default(),
retry_policy: Some(dbnexus::reliability::retry::RetryPolicy::default()),
};
let pool = DbPool::with_config(config).await.map_err(|e| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
InklogError::DatabaseError {
message: crate::i18n::tr_args("db-pool_create_failed", args),
source: Some(Box::new(e)),
}
})?;
let adapter = Self {
pool: Arc::new(pool),
table_name: table_name.to_string(),
admin_role: admin_role.to_string(),
};
adapter.ensure_table_exists(url).await?;
Ok(adapter)
}
pub fn from_pool(pool: DbPool, table_name: &str) -> Result<Self, InklogError> {
validate_table_name(table_name)?;
Ok(Self {
pool: Arc::new(pool),
table_name: table_name.to_string(),
admin_role: "admin".to_string(),
})
}
pub fn from_connection_pool(
pool: Arc<dyn ConnectionPool + Send + Sync>,
table_name: &str,
) -> Result<Self, InklogError> {
validate_table_name(table_name)?;
Ok(Self {
pool,
table_name: table_name.to_string(),
admin_role: "admin".to_string(),
})
}
pub fn pool(&self) -> &dyn ConnectionPool {
self.pool.as_ref()
}
pub fn pool_arc(&self) -> Arc<dyn ConnectionPool + Send + Sync> {
Arc::clone(&self.pool)
}
pub fn table_name(&self) -> &str {
&self.table_name
}
async fn ensure_table_exists(&self, url: &str) -> Result<(), InklogError> {
let driver = detect_driver_from_url(url);
let ddl = generate_create_table_sql(&self.table_name, &driver);
let session = self.pool.get_session(&self.admin_role).await.map_err(|e| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
InklogError::DatabaseError {
message: crate::i18n::tr_args("db-session_failed", args),
source: Some(Box::new(e)),
}
})?;
session.execute_raw_ddl(&ddl).await.map_err(|e| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
InklogError::DatabaseError {
message: crate::i18n::tr_args("db-ensure_table_failed", args),
source: Some(Box::new(e)),
}
})?;
Ok(())
}
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
fn validate_table_name(name: &str) -> Result<(), InklogError> {
if name.is_empty() {
return Err(InklogError::ConfigError(crate::i18n::tr("db-table_empty")));
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
let mut args = fluent_bundle::FluentArgs::new();
args.set("name", name.to_string());
return Err(InklogError::ConfigError(crate::i18n::tr_args(
"db-table_invalid_start",
args,
)));
}
for c in chars {
if !c.is_ascii_alphanumeric() && c != '_' {
let mut args = fluent_bundle::FluentArgs::new();
args.set("name", name.to_string());
args.set("char", c.to_string());
return Err(InklogError::ConfigError(crate::i18n::tr_args(
"db-table_invalid_char",
args,
)));
}
}
Ok(())
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[inline]
fn escape_sql_string(s: &str) -> String {
s.replace('\'', "''")
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
fn generate_create_table_sql(table_name: &str, driver: &DatabaseDriver) -> String {
match driver {
DatabaseDriver::SQLite => format!(
"CREATE TABLE IF NOT EXISTS {} (\
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\
)",
table_name
),
DatabaseDriver::PostgreSQL => format!(
"CREATE TABLE IF NOT EXISTS {} (\
id BIGSERIAL PRIMARY KEY, \
timestamp TIMESTAMPTZ 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\
)",
table_name
),
DatabaseDriver::MySQL => format!(
"CREATE TABLE IF NOT EXISTS {} (\
id BIGINT AUTO_INCREMENT PRIMARY KEY, \
timestamp TIMESTAMP 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\
)",
table_name
),
DatabaseDriver::DuckDB => format!(
"CREATE TABLE IF NOT EXISTS {} (\
id BIGINT AUTOINCREMENT PRIMARY KEY, \
timestamp TIMESTAMP 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\
)",
table_name
),
}
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
fn detect_driver_from_url(url: &str) -> DatabaseDriver {
if url.starts_with("sqlite:") || url.starts_with("sqlite3:") {
DatabaseDriver::SQLite
} else if url.starts_with("postgres:") || url.starts_with("postgresql:") {
DatabaseDriver::PostgreSQL
} else if url.starts_with("mysql:") {
DatabaseDriver::MySQL
} else if url.starts_with("duckdb:") || url.starts_with("duckdb://") {
DatabaseDriver::DuckDB
} else {
DatabaseDriver::PostgreSQL
}
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[async_trait]
impl Database for DbNexusAdapter {
async fn insert_batch(&self, records: &[LogRecord]) -> Result<usize, InklogError> {
if records.is_empty() {
return Ok(0);
}
let start = std::time::Instant::now();
let session = self.pool.get_session(&self.admin_role).await.map_err(|e| {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
InklogError::DatabaseError {
message: crate::i18n::tr_args("db-session_failed", args),
source: Some(Box::new(e)),
}
})?;
let sqls: Vec<String> = records
.iter()
.map(|record| {
let timestamp = record.timestamp.to_rfc3339();
let level = escape_sql_string(&record.level);
let target = escape_sql_string(&record.target);
let message = escape_sql_string(&record.message);
let fields_json =
serde_json::to_string(&record.fields).unwrap_or_else(|_| "{}".to_string());
let fields_escaped = escape_sql_string(&fields_json);
let file = record
.file
.as_ref()
.map(|f| format!("'{}'", escape_sql_string(f)))
.unwrap_or_else(|| "NULL".to_string());
let line = record
.line
.map(|l| l.to_string())
.unwrap_or_else(|| "NULL".to_string());
let thread_id = escape_sql_string(&record.thread_id);
format!(
"INSERT INTO {} (timestamp, level, target, message, fields, file, line, thread_id) \
VALUES ('{}', '{}', '{}', '{}', '{}', {}, {}, '{}')",
self.table_name,
timestamp,
level,
target,
message,
fields_escaped,
file,
line,
thread_id
)
})
.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| {
let elapsed_ms = start.elapsed().as_millis();
tracing::warn!(
table = %self.table_name,
error = %e,
elapsed_ms = elapsed_ms,
"Database batch insert failed"
);
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
let msg = crate::i18n::tr_args("db-batch_insert_failed", args);
InklogError::DatabaseError {
message: msg,
source: Some(Box::new(e)),
}
})?;
let elapsed_ms = start.elapsed().as_millis();
tracing::debug!(
table = %self.table_name,
count = records.len(),
elapsed_ms = elapsed_ms,
"Database batch insert succeeded"
);
Ok(records.len())
}
async fn is_healthy(&self) -> bool {
match self.pool.get_session(&self.admin_role).await {
Ok(_) => true,
Err(e) => {
let mut args = fluent_bundle::FluentArgs::new();
args.set("err", e.to_string());
tracing::warn!(
"{}",
crate::i18n::tr_args("warn-db_health_check_failed", args)
);
false
}
}
}
}
#[cfg(not(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
)))]
pub struct DbNexusAdapter {
_phantom: (),
}
#[cfg(not(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
)))]
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 {
message: "DbNexusAdapter requires 'dbnexus' feature to be enabled".to_string(),
source: None,
})
}
}
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, Ordering};
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(feature = "sqlite")]
#[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,
pool_config: dbnexus::foundation::config::PoolConfig {
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(),
retry_policy: Some(dbnexus::reliability::retry::RetryPolicy::default()),
};
let pool = DbPool::with_config(config)
.await
.expect("Failed to create pool");
let db = DbNexusAdapter::from_pool(pool, "logs").expect("from_pool should succeed");
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(feature = "sqlite")]
#[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,
pool_config: dbnexus::foundation::config::PoolConfig {
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(),
retry_policy: Some(dbnexus::reliability::retry::RetryPolicy::default()),
};
let pool = DbPool::with_config(config)
.await
.expect("Failed to create pool");
let db = DbNexusAdapter::from_pool(pool, "logs").expect("from_pool should succeed");
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",
feature = "duckdb"
)))]
#[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(feature = "sqlite")]
#[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(feature = "sqlite")]
#[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(feature = "sqlite")]
#[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);
}
#[test]
fn test_level_field_single_quote_escaping() {
let malicious_level = "INFO'OR'1'='1";
let escaped = malicious_level.replace('\'', "''");
assert_eq!(escaped, "INFO''OR''1''=''1");
assert_eq!(
escaped.matches('\'').count(),
8,
"all 4 original quotes should be doubled"
);
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_table_name_accepts_valid_names() {
assert!(validate_table_name("logs").is_ok());
assert!(validate_table_name("my_logs").is_ok());
assert!(validate_table_name("_private").is_ok());
assert!(validate_table_name("Logs123").is_ok());
assert!(validate_table_name("a").is_ok());
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_table_name_rejects_sql_injection() {
assert!(validate_table_name("logs; DROP TABLE users").is_err());
assert!(validate_table_name("logs' OR '1'='1").is_err());
assert!(validate_table_name("logs--comment").is_err());
assert!(validate_table_name("logs\";").is_err());
assert!(validate_table_name("").is_err());
assert!(validate_table_name("123logs").is_err());
assert!(validate_table_name("public.logs").is_err());
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_escape_sql_string_empty() {
assert_eq!(escape_sql_string(""), "");
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_escape_sql_string_no_special_chars() {
assert_eq!(escape_sql_string("hello world"), "hello world");
assert_eq!(escape_sql_string("abc123"), "abc123");
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_escape_sql_string_single_quote() {
assert_eq!(escape_sql_string("it's"), "it''s");
assert_eq!(escape_sql_string("'"), "''");
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_escape_sql_string_multiple_quotes() {
assert_eq!(escape_sql_string("a'b'c'd"), "a''b''c''d");
assert_eq!(escape_sql_string("''''"), "''''''''");
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_escape_sql_string_unicode() {
assert_eq!(escape_sql_string("こんにちは"), "こんにちは");
assert_eq!(escape_sql_string("世界'it's"), "世界''it''s");
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_generate_create_table_sql_sqlite() {
let ddl = generate_create_table_sql("logs", &DatabaseDriver::SQLite);
assert!(ddl.contains("CREATE TABLE IF NOT EXISTS logs"));
assert!(ddl.contains("INTEGER PRIMARY KEY AUTOINCREMENT"));
assert!(ddl.contains("TEXT NOT NULL"));
assert!(ddl.contains("line INTEGER"));
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_generate_create_table_sql_postgres() {
let ddl = generate_create_table_sql("logs", &DatabaseDriver::PostgreSQL);
assert!(ddl.contains("CREATE TABLE IF NOT EXISTS logs"));
assert!(ddl.contains("BIGSERIAL PRIMARY KEY"));
assert!(ddl.contains("TIMESTAMPTZ NOT NULL"));
assert!(ddl.contains("TEXT NOT NULL"));
}
#[cfg(any(
feature = "sqlite",
feature = "postgres",
feature = "mysql",
feature = "duckdb"
))]
#[test]
fn test_generate_create_table_sql_mysql() {
let ddl = generate_create_table_sql("logs", &DatabaseDriver::MySQL);
assert!(ddl.contains("CREATE TABLE IF NOT EXISTS logs"));
assert!(ddl.contains("BIGINT AUTO_INCREMENT PRIMARY KEY"));
assert!(ddl.contains("TIMESTAMP NOT NULL"));
assert!(ddl.contains("TEXT NOT NULL"));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn test_dbnexus_adapter_custom_admin_role() {
let temp_dir = std::env::temp_dir();
let perm_path = temp_dir.join("inklog_custom_role_perm.yaml");
let perm_content = r#"roles:
superadmin:
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_custom_role.db");
let db_url = format!("sqlite:{}?mode=rwc", db_path.to_string_lossy());
let adapter = DbNexusAdapter::with_full_config(
&db_url,
1,
"logs",
Some(perm_path.to_string_lossy().to_string()),
"superadmin",
)
.await
.expect("with_full_config should succeed");
assert_eq!(adapter.admin_role, "superadmin");
assert_eq!(adapter.table_name(), "logs");
let _ = std::fs::remove_file(&perm_path);
let _ = std::fs::remove_file(&db_path);
}
}