#![allow(clippy::doc_markdown)]
use async_trait::async_trait;
use thiserror::Error;
pub mod translate;
pub use translate::{translate_placeholders, TranslatedSql};
#[cfg(feature = "sqlite")]
pub mod sqlite;
#[cfg(feature = "sqlite")]
pub use sqlite::SqliteConnector;
#[async_trait]
pub trait SqlConnector: Send + Sync + 'static {
fn dialect(&self) -> Dialect;
async fn execute(
&self,
sql: &str,
params: &[(String, serde_json::Value)],
) -> Result<Vec<serde_json::Value>, ConnectorError>;
async fn schema_text(&self) -> Result<String, ConnectorError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Dialect {
Postgres,
MySql,
Athena,
Sqlite,
}
impl Dialect {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Postgres => "PostgreSQL",
Self::MySql => "MySQL",
Self::Athena => "Amazon Athena (Presto/Trino)",
Self::Sqlite => "SQLite",
}
}
#[must_use]
pub const fn placeholder_guidance(self) -> &'static str {
match self {
Self::Postgres => "Use $1, $2, $3, ... for positional parameters.",
Self::MySql => "Use ? for positional parameters in argument order.",
Self::Athena => "Use ? for positional parameters in argument order.",
Self::Sqlite => "Use :name for named parameters or ? for positional.",
}
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConnectorError {
#[error("connector I/O error: {0}")]
Io(String),
#[error("schema fetch failed: {0}")]
Schema(String),
#[error("dialect mismatch: query used {used:?} but connector is {actual:?}")]
DialectMismatch {
used: Dialect,
actual: Dialect,
},
#[error("driver error: {0}")]
Driver(String),
#[error("query error: {0}")]
Query(String),
#[error("parameter bind failed for '{name}': {reason}")]
ParameterBind {
name: String,
reason: String,
},
#[error("connection error: {0}")]
Connection(String),
}
#[cfg(any(test, feature = "sqlite"))]
#[allow(dead_code)]
pub(crate) struct MockSqlConnector {
pub dialect: Dialect,
pub schema: String,
}
#[cfg(any(test, feature = "sqlite"))]
#[async_trait]
impl SqlConnector for MockSqlConnector {
fn dialect(&self) -> Dialect {
self.dialect
}
async fn execute(
&self,
_sql: &str,
_params: &[(String, serde_json::Value)],
) -> Result<Vec<serde_json::Value>, ConnectorError> {
Err(ConnectorError::Driver(
"MockSqlConnector::execute is fixture-only; use SqliteConnector for real execution"
.into(),
))
}
async fn schema_text(&self) -> Result<String, ConnectorError> {
Ok(self.schema.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn dialect_name_stable_for_all_variants() {
for d in [
Dialect::Postgres,
Dialect::MySql,
Dialect::Athena,
Dialect::Sqlite,
] {
assert!(
!d.name().is_empty(),
"Dialect::name must be non-empty for {d:?}"
);
}
}
#[test]
fn dialect_placeholder_guidance_stable_for_all_variants() {
for d in [
Dialect::Postgres,
Dialect::MySql,
Dialect::Athena,
Dialect::Sqlite,
] {
assert!(
!d.placeholder_guidance().is_empty(),
"guidance must be non-empty for {d:?}"
);
}
}
proptest! {
#[test]
fn every_dialect_has_guidance(idx in 0usize..4) {
let d = match idx {
0 => Dialect::Postgres,
1 => Dialect::MySql,
2 => Dialect::Athena,
_ => Dialect::Sqlite,
};
prop_assert!(!d.placeholder_guidance().is_empty());
prop_assert!(!d.name().is_empty());
}
}
}
#[cfg(test)]
mod execute_signature_tests {
use super::SqlConnector;
fn assert_send_sync<T: Send + Sync + 'static>() {}
#[test]
fn connector_trait_object_is_send_sync_static() {
assert_send_sync::<Box<dyn SqlConnector>>();
}
}
#[cfg(test)]
mod connector_error_tests {
use super::ConnectorError;
#[test]
fn test_display_format_driver() {
assert_eq!(
format!("{}", ConnectorError::Driver("oops".into())),
"driver error: oops"
);
}
#[test]
fn test_display_format_parameter_bind() {
assert_eq!(
format!(
"{}",
ConnectorError::ParameterBind {
name: "id".into(),
reason: "expected int, got string".into(),
}
),
"parameter bind failed for 'id': expected int, got string"
);
}
#[test]
fn test_connection_display_does_not_echo_password() {
let err = ConnectorError::Connection("connection refused".into());
let rendered = format!("{err}");
for forbidden in ["password", "AWS_SECRET_ACCESS_KEY", "DATABASE_URL"] {
assert!(
!rendered.contains(forbidden),
"Connection Display must not synthesize the credential token {forbidden:?}; got {rendered:?}"
);
}
}
}