use crate::reader::Reader;
use crate::{GgsqlError, Result};
pub fn split_cache_uri(uri: &str) -> Option<(String, String)> {
let (scheme, rest) = uri.split_once("://")?;
let (cache, primary) = scheme.split_once('+')?;
if cache.is_empty() || primary.is_empty() || primary.contains('+') {
return None;
}
Some((format!("{}://{}", primary, rest), cache.to_string()))
}
#[cfg(any(feature = "duckdb", feature = "sqlite"))]
const KNOWN_CACHE_PARAMS: &[&str] = &["cache_ttl", "cache_max_bytes", "cache_disabled"];
#[cfg(any(feature = "duckdb", feature = "sqlite"))]
fn strip_cache_params(uri: &str) -> (String, crate::reader::cache::CacheConfigOverride) {
use crate::reader::cache::{parse_human_bytes, CacheConfigOverride};
let mut over = CacheConfigOverride::default();
let Some((body, query)) = uri.split_once('?') else {
return (uri.to_string(), over);
};
let mut kept: Vec<&str> = Vec::new();
for segment in query.split('&') {
match segment.split_once('=') {
Some((key, value)) if KNOWN_CACHE_PARAMS.contains(&key) => match key {
"cache_ttl" => over.ttl_secs = value.trim().parse::<u64>().ok(),
"cache_max_bytes" => over.max_bytes = parse_human_bytes(value),
"cache_disabled" => {
let v = value.trim().to_ascii_lowercase();
over.enabled = Some(!matches!(v.as_str(), "1" | "true" | "yes"));
}
_ => unreachable!("validated against KNOWN_CACHE_PARAMS"),
},
_ => kept.push(segment),
}
}
if kept.is_empty() {
(body.to_string(), over)
} else {
(format!("{}?{}", body, kept.join("&")), over)
}
}
#[cfg(any(feature = "duckdb", feature = "sqlite"))]
fn cache_uri(scheme: &str) -> Result<&'static str> {
match scheme {
"duckdb" => Ok("duckdb://memory"),
"sqlite" => Ok("sqlite://memory"),
_ => Err(GgsqlError::ReaderError(format!(
"Unsupported cache backend '{}'. Supported: duckdb, sqlite",
scheme
))),
}
}
pub fn build_reader(uri: &str) -> Result<Box<dyn Reader + Send>> {
if uri.starts_with("duckdb://") {
#[cfg(feature = "duckdb")]
{
return Ok(Box::new(
crate::reader::DuckDBReader::from_connection_string(uri)?,
));
}
#[cfg(not(feature = "duckdb"))]
{
return Err(GgsqlError::ReaderError(
"DuckDB reader not compiled in. Rebuild with --features duckdb".to_string(),
));
}
}
if uri.starts_with("sqlite://") {
#[cfg(feature = "sqlite")]
{
return Ok(Box::new(
crate::reader::SqliteReader::from_connection_string(uri)?,
));
}
#[cfg(not(feature = "sqlite"))]
{
return Err(GgsqlError::ReaderError(
"SQLite reader not compiled in. Rebuild with --features sqlite".to_string(),
));
}
}
if uri.starts_with("odbc://") {
#[cfg(feature = "odbc")]
{
return Ok(Box::new(crate::reader::OdbcReader::from_connection_string(
uri,
)?));
}
#[cfg(not(feature = "odbc"))]
{
return Err(GgsqlError::ReaderError(
"ODBC reader not compiled in. Rebuild with --features odbc".to_string(),
));
}
}
if uri.starts_with("postgres://") || uri.starts_with("postgresql://") {
return Err(GgsqlError::ReaderError(
"PostgreSQL reader is not yet implemented".to_string(),
));
}
Err(GgsqlError::ReaderError(format!(
"Unsupported connection string: {}. Supported: duckdb://, sqlite://, odbc://",
uri
)))
}
pub fn reader_from_uri(uri: &str) -> Result<Box<dyn Reader + Send>> {
if let Some((primary_uri, cache_scheme)) = split_cache_uri(uri) {
#[cfg(any(feature = "duckdb", feature = "sqlite"))]
{
use crate::reader::cache::CacheConfig;
let (primary_uri, over) = strip_cache_params(&primary_uri);
let config = CacheConfig::from_env().merge(over);
let primary = build_reader(&primary_uri)?;
let cache = build_reader(cache_uri(&cache_scheme)?)?;
return Ok(Box::new(crate::reader::CachingReader::with_config(
primary,
cache,
primary_uri,
cache_scheme,
config,
)));
}
#[cfg(not(any(feature = "duckdb", feature = "sqlite")))]
{
let _ = (&primary_uri, &cache_scheme);
return Err(GgsqlError::ReaderError(
"Caching layer requires the duckdb or sqlite feature".to_string(),
));
}
}
build_reader(uri)
}
pub fn extract_odbc_value(conn_str: &str, key: &str) -> Option<String> {
let lower = conn_str.to_lowercase();
let prefix = format!("{}=", key);
let start = lower.find(&prefix)?;
let rest = &conn_str[start + prefix.len()..];
let value = rest.split(';').next().unwrap_or("");
let value = value.trim().trim_matches(|c| c == '{' || c == '}');
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_reader_unsupported_scheme() {
let err = build_reader("mysql://localhost/db")
.err()
.unwrap()
.to_string();
assert!(err.contains("Unsupported connection string"), "got: {err}");
}
#[test]
fn test_build_reader_postgres_not_implemented() {
let err = build_reader("postgres://user@localhost/db")
.err()
.unwrap()
.to_string();
assert!(err.contains("not yet implemented"), "got: {err}");
}
#[cfg(feature = "duckdb")]
#[test]
fn test_build_reader_duckdb_memory_and_empty() {
assert!(build_reader("duckdb://memory").is_ok());
assert!(build_reader("duckdb://").is_err());
}
#[cfg(feature = "sqlite")]
#[test]
fn test_build_reader_sqlite_memory() {
assert!(build_reader("sqlite://memory").is_ok());
assert!(build_reader("sqlite://:memory:").is_ok());
}
#[cfg(all(feature = "duckdb", feature = "sqlite"))]
#[test]
fn test_reader_from_uri_composite_builds() {
assert!(reader_from_uri("duckdb+sqlite://memory").is_ok());
assert!(reader_from_uri("sqlite+duckdb://memory").is_ok());
}
#[test]
fn test_split_cache_uri_duckdb_cache_over_odbc() {
assert_eq!(
split_cache_uri("duckdb+odbc://Driver=Snowflake;Server=x"),
Some((
"odbc://Driver=Snowflake;Server=x".to_string(),
"duckdb".to_string()
))
);
}
#[test]
fn test_split_cache_uri_duckdb_cache_over_sqlite_memory() {
assert_eq!(
split_cache_uri("duckdb+sqlite://memory"),
Some(("sqlite://memory".to_string(), "duckdb".to_string()))
);
}
#[test]
fn test_split_cache_uri_plain_is_none() {
assert_eq!(split_cache_uri("duckdb://memory"), None);
assert_eq!(split_cache_uri("odbc://DSN=x"), None);
}
#[test]
fn test_split_cache_uri_rejects_multiple_plus() {
assert_eq!(split_cache_uri("a+b+c://x"), None);
assert_eq!(split_cache_uri("duckdb+a+b://x"), None);
}
#[test]
fn test_split_cache_uri_rejects_empty_parts() {
assert_eq!(split_cache_uri("+duckdb://x"), None);
assert_eq!(split_cache_uri("odbc+://x"), None);
}
#[cfg(any(feature = "duckdb", feature = "sqlite"))]
#[test]
fn test_strip_cache_params_parses_known_keys() {
let (uri, over) = strip_cache_params("duckdb://memory?cache_ttl=600");
assert_eq!(uri, "duckdb://memory");
assert_eq!(over.ttl_secs, Some(600));
assert_eq!(over.max_bytes, None);
assert_eq!(over.enabled, None);
let (uri, over) =
strip_cache_params("duckdb://memory?cache_max_bytes=256mb&cache_disabled=true");
assert_eq!(uri, "duckdb://memory");
assert_eq!(over.max_bytes, Some(256 * 1024 * 1024));
assert_eq!(over.enabled, Some(false));
}
#[cfg(any(feature = "duckdb", feature = "sqlite"))]
#[test]
fn test_strip_cache_params_keeps_non_cache_segments() {
let (uri, over) = strip_cache_params("odbc://DSN=foo?warehouse=PROD");
assert_eq!(uri, "odbc://DSN=foo?warehouse=PROD");
assert_eq!(over.ttl_secs, None);
let (uri, over) = strip_cache_params("odbc://Driver=Snowflake;Server=x");
assert_eq!(uri, "odbc://Driver=Snowflake;Server=x");
assert_eq!(over.enabled, None);
let (uri, over) =
strip_cache_params("odbc://DSN=foo?ttl=99&cache_ttl=10&cache_max_bytes=8mb");
assert_eq!(uri, "odbc://DSN=foo?ttl=99");
assert_eq!(over.ttl_secs, Some(10));
assert_eq!(over.max_bytes, Some(8 * 1024 * 1024));
let (uri, over) = strip_cache_params("duckdb://memory?cache_ttl=10&cache_disabled=1");
assert_eq!(uri, "duckdb://memory");
assert_eq!(over.ttl_secs, Some(10));
assert_eq!(over.enabled, Some(false));
let (uri, over) = strip_cache_params("duckdb://memory");
assert_eq!(uri, "duckdb://memory");
assert_eq!(over.ttl_secs, None);
}
#[cfg(all(feature = "duckdb", feature = "sqlite"))]
#[test]
fn test_reader_from_uri_applies_uri_cache_params() {
assert!(
reader_from_uri("duckdb+sqlite://memory?cache_ttl=600&cache_max_bytes=64mb").is_ok()
);
}
}