1103 lines
42 KiB
Rust
1103 lines
42 KiB
Rust
use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
|
|
use deadpool_postgres::{ManagerConfig, Pool, RecyclingMethod, Runtime};
|
|
use futures::{SinkExt, StreamExt};
|
|
use percent_encoding::percent_decode_str;
|
|
use rust_decimal::Decimal;
|
|
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
|
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
|
|
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
use std::time::Instant;
|
|
use tokio_postgres::config::SslMode;
|
|
use tokio_postgres::types::{FromSql, Type};
|
|
use tokio_postgres::{Row, SimpleQueryMessage};
|
|
|
|
use super::file_validator::validate_file_path;
|
|
use crate::sql::starts_with_executable_sql_keyword;
|
|
use crate::types::{
|
|
ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, ObjectInfo, QueryResult, TableInfo, TriggerInfo,
|
|
};
|
|
|
|
fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
|
|
if let Ok(v) = row.try_get::<_, DateTime<Utc>>(idx) {
|
|
return Some(serde_json::Value::String(v.to_rfc3339()));
|
|
}
|
|
if let Ok(v) = row.try_get::<_, NaiveDateTime>(idx) {
|
|
return Some(serde_json::Value::String(v.to_string()));
|
|
}
|
|
if let Ok(v) = row.try_get::<_, NaiveDate>(idx) {
|
|
return Some(serde_json::Value::String(v.to_string()));
|
|
}
|
|
if let Ok(v) = row.try_get::<_, NaiveTime>(idx) {
|
|
return Some(serde_json::Value::String(v.to_string()));
|
|
}
|
|
None
|
|
}
|
|
|
|
struct PgSystemU32(u32);
|
|
|
|
impl<'a> FromSql<'a> for PgSystemU32 {
|
|
fn from_sql(_: &Type, raw: &'a [u8]) -> Result<Self, Box<dyn std::error::Error + Sync + Send>> {
|
|
let bytes: [u8; 4] = raw.try_into().map_err(|_| "expected 4 bytes for PostgreSQL system u32")?;
|
|
Ok(Self(u32::from_be_bytes(bytes)))
|
|
}
|
|
|
|
fn accepts(ty: &Type) -> bool {
|
|
matches!(*ty, Type::XID | Type::CID)
|
|
}
|
|
}
|
|
|
|
fn pg_u32_number(v: u32) -> serde_json::Value {
|
|
serde_json::Value::Number(serde_json::Number::from(v))
|
|
}
|
|
|
|
fn pg_system_u32_to_json(row: &Row, idx: usize) -> Option<serde_json::Value> {
|
|
if let Ok(v) = row.try_get::<_, u32>(idx) {
|
|
return Some(pg_u32_number(v));
|
|
}
|
|
row.try_get::<_, PgSystemU32>(idx).ok().map(|v| pg_u32_number(v.0))
|
|
}
|
|
|
|
fn pg_value_to_json(row: &Row, idx: usize, type_name: &str) -> serde_json::Value {
|
|
let upper = type_name.to_uppercase();
|
|
|
|
if upper == "JSON" || upper == "JSONB" {
|
|
if let Ok(v) = row.try_get::<_, serde_json::Value>(idx) {
|
|
return serde_json::Value::String(v.to_string());
|
|
}
|
|
if let Ok(v) = row.try_get::<_, String>(idx) {
|
|
return serde_json::Value::String(v);
|
|
}
|
|
return serde_json::Value::Null;
|
|
}
|
|
|
|
if upper == "BOOL" {
|
|
return row.try_get::<_, bool>(idx).map(serde_json::Value::Bool).unwrap_or(serde_json::Value::Null);
|
|
}
|
|
|
|
if upper.contains("TIMESTAMP")
|
|
|| upper == "DATE"
|
|
|| upper == "TIME"
|
|
|| upper == "TIMETZ"
|
|
|| upper.contains("INTERVAL")
|
|
{
|
|
if let Some(v) = pg_temporal_to_json_value(row, idx) {
|
|
return v;
|
|
}
|
|
}
|
|
|
|
if upper == "NUMERIC" || upper == "DECIMAL" || upper == "MONEY" {
|
|
return row
|
|
.try_get::<_, Decimal>(idx)
|
|
.map(|v: Decimal| serde_json::Value::String(v.to_string()))
|
|
.unwrap_or(serde_json::Value::Null);
|
|
}
|
|
|
|
if upper == "UUID" {
|
|
return row
|
|
.try_get::<_, uuid::Uuid>(idx)
|
|
.map(|v| serde_json::Value::String(v.to_string()))
|
|
.unwrap_or(serde_json::Value::Null);
|
|
}
|
|
|
|
if matches!(upper.as_str(), "OID" | "XID" | "CID") {
|
|
return pg_system_u32_to_json(row, idx).unwrap_or(serde_json::Value::Null);
|
|
}
|
|
|
|
row.try_get::<_, String>(idx)
|
|
.map(serde_json::Value::String)
|
|
.or_else(|e| pg_system_u32_to_json(row, idx).ok_or(e))
|
|
.or_else(|_| row.try_get::<_, i64>(idx).map(super::safe_i64_to_json))
|
|
.or_else(|_| row.try_get::<_, i32>(idx).map(|v| serde_json::Value::Number(v.into())))
|
|
.or_else(|_| row.try_get::<_, i16>(idx).map(|v| serde_json::Value::Number(v.into())))
|
|
.or_else(|_| row.try_get::<_, i8>(idx).map(|v| serde_json::Value::Number(v.into())))
|
|
.or_else(|_| {
|
|
row.try_get::<_, Vec<u32>>(idx)
|
|
.map(|v| serde_json::Value::Array(v.into_iter().map(pg_u32_number).collect()))
|
|
})
|
|
.or_else(|_| {
|
|
row.try_get::<_, Vec<i8>>(idx)
|
|
.map(|v| serde_json::Value::Array(v.into_iter().map(|v| serde_json::Value::Number(v.into())).collect()))
|
|
})
|
|
.or_else(|_| {
|
|
row.try_get::<_, Vec<i16>>(idx)
|
|
.map(|v| serde_json::Value::Array(v.into_iter().map(|v| serde_json::Value::Number(v.into())).collect()))
|
|
})
|
|
.or_else(|_| {
|
|
row.try_get::<_, Vec<i32>>(idx)
|
|
.map(|v| serde_json::Value::Array(v.into_iter().map(|v| serde_json::Value::Number(v.into())).collect()))
|
|
})
|
|
.or_else(|_| {
|
|
row.try_get::<_, Vec<i64>>(idx)
|
|
.map(|v| serde_json::Value::Array(v.into_iter().map(|v| serde_json::Value::Number(v.into())).collect()))
|
|
})
|
|
.or_else(|_| {
|
|
row.try_get::<_, f64>(idx).map(|v| {
|
|
serde_json::Number::from_f64(v).map(serde_json::Value::Number).unwrap_or(serde_json::Value::Null)
|
|
})
|
|
})
|
|
.or_else(|_| {
|
|
row.try_get::<_, f32>(idx).map(|v| {
|
|
serde_json::Number::from_f64((v as f64 * 1_000_000.0).round() / 1_000_000.0)
|
|
.map(serde_json::Value::Number)
|
|
.unwrap_or(serde_json::Value::Null)
|
|
})
|
|
})
|
|
.or_else(|_| row.try_get::<_, bool>(idx).map(serde_json::Value::Bool))
|
|
.or_else(|_| row.try_get::<_, uuid::Uuid>(idx).map(|v| serde_json::Value::String(v.to_string())))
|
|
.or_else(|e| pg_temporal_to_json_value(row, idx).ok_or(e))
|
|
.or_else(|_| {
|
|
row.try_get::<_, Vec<u8>>(idx).map(|bytes| match std::str::from_utf8(&bytes) {
|
|
Ok(s) => serde_json::Value::String(s.to_string()),
|
|
Err(_) => {
|
|
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
|
serde_json::Value::String(hex)
|
|
}
|
|
})
|
|
})
|
|
.unwrap_or(serde_json::Value::Null)
|
|
}
|
|
|
|
fn pg_error_to_string(err: tokio_postgres::Error) -> String {
|
|
err.as_db_error().map(ToString::to_string).unwrap_or_else(|| err.to_string())
|
|
}
|
|
|
|
fn should_retry_postgres_text_query(err: &tokio_postgres::Error) -> bool {
|
|
let message = err.as_db_error().map(ToString::to_string).unwrap_or_else(|| err.to_string()).to_ascii_lowercase();
|
|
message.contains("no binary output function")
|
|
|| message.contains("no binary send function")
|
|
|| message.contains("cannot display a value of type")
|
|
}
|
|
|
|
async fn execute_select_prepared(
|
|
client: &deadpool_postgres::Client,
|
|
sql: &str,
|
|
start: Instant,
|
|
row_limit: usize,
|
|
) -> Result<QueryResult, tokio_postgres::Error> {
|
|
let stmt = client.prepare_cached(sql).await?;
|
|
let columns: Vec<String> = stmt.columns().iter().map(|c| c.name().to_string()).collect();
|
|
let column_types: Vec<String> = stmt.columns().iter().map(|c| c.type_().name().to_string()).collect();
|
|
|
|
let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
|
|
let stream = client.query_raw(&stmt, params).await?;
|
|
tokio::pin!(stream);
|
|
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
|
let mut truncated = false;
|
|
|
|
while let Some(row_result) = stream.next().await {
|
|
if result_rows.len() >= row_limit {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
let row = row_result?;
|
|
result_rows.push(
|
|
(0..row.columns().len())
|
|
.map(|i| pg_value_to_json(&row, i, column_types.get(i).map(String::as_str).unwrap_or("")))
|
|
.collect(),
|
|
);
|
|
}
|
|
|
|
Ok(QueryResult {
|
|
columns,
|
|
rows: result_rows,
|
|
affected_rows: 0,
|
|
execution_time_ms: start.elapsed().as_millis(),
|
|
truncated,
|
|
session_id: None,
|
|
has_more: false,
|
|
})
|
|
}
|
|
|
|
async fn execute_select_text(
|
|
client: &deadpool_postgres::Client,
|
|
sql: &str,
|
|
start: Instant,
|
|
row_limit: usize,
|
|
) -> Result<QueryResult, String> {
|
|
let messages = client.simple_query(sql).await.map_err(pg_error_to_string)?;
|
|
let mut columns: Vec<String> = Vec::new();
|
|
let mut result_rows: Vec<Vec<serde_json::Value>> = Vec::new();
|
|
let mut truncated = false;
|
|
|
|
for message in messages {
|
|
match message {
|
|
SimpleQueryMessage::RowDescription(cols) => {
|
|
columns = cols.iter().map(|c| c.name().to_string()).collect();
|
|
}
|
|
SimpleQueryMessage::Row(row) => {
|
|
if columns.is_empty() {
|
|
columns = row.columns().iter().map(|c| c.name().to_string()).collect();
|
|
}
|
|
if result_rows.len() >= row_limit {
|
|
truncated = true;
|
|
continue;
|
|
}
|
|
let mut values = Vec::with_capacity(row.len());
|
|
for i in 0..row.len() {
|
|
values.push(match row.try_get(i).map_err(pg_error_to_string)? {
|
|
Some(value) => serde_json::Value::String(value.to_string()),
|
|
None => serde_json::Value::Null,
|
|
});
|
|
}
|
|
result_rows.push(values);
|
|
}
|
|
SimpleQueryMessage::CommandComplete(_) => {}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(QueryResult {
|
|
columns,
|
|
rows: result_rows,
|
|
affected_rows: 0,
|
|
execution_time_ms: start.elapsed().as_millis(),
|
|
truncated,
|
|
session_id: None,
|
|
has_more: false,
|
|
})
|
|
}
|
|
|
|
async fn execute_select_query(
|
|
client: &deadpool_postgres::Client,
|
|
sql: &str,
|
|
start: Instant,
|
|
row_limit: usize,
|
|
) -> Result<QueryResult, String> {
|
|
match execute_select_prepared(client, sql, start, row_limit).await {
|
|
Ok(result) => Ok(result),
|
|
Err(err) if should_retry_postgres_text_query(&err) => execute_select_text(client, sql, start, row_limit).await,
|
|
Err(err) => Err(pg_error_to_string(err)),
|
|
}
|
|
}
|
|
|
|
pub async fn connect(url: &str) -> Result<Pool, String> {
|
|
validate_postgres_ssl_paths(url)?;
|
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
|
|
|
let tz = iana_time_zone::get_timezone().unwrap_or_else(|_| "UTC".to_string());
|
|
|
|
super::with_connection_timeout("PostgreSQL", super::connection_timeout(), async {
|
|
let pg_config =
|
|
tokio_postgres::Config::from_str(url).map_err(|e| format!("Invalid PostgreSQL connection URL: {e}"))?;
|
|
|
|
let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Fast };
|
|
let tls_config = postgres_tls_config(&pg_config);
|
|
let mgr = deadpool_postgres::Manager::from_config(
|
|
pg_config.clone(),
|
|
tokio_postgres_rustls::MakeRustlsConnect::new(tls_config),
|
|
mgr_config,
|
|
);
|
|
let pool = Pool::builder(mgr)
|
|
.max_size(1)
|
|
.runtime(Runtime::Tokio1)
|
|
.wait_timeout(Some(super::connection_timeout()))
|
|
.build()
|
|
.map_err(|e| format!("Failed to create PostgreSQL pool: {e}"))?;
|
|
|
|
// Verify connectivity and set timezone. Only set timezone if the user
|
|
// hasn't already specified one via connection parameters (e.g. options=-c timezone=...)
|
|
let client = pool.get().await.map_err(|e| format!("PostgreSQL connection failed: {e}"))?;
|
|
if !pg_url_has_timezone_setting(url) {
|
|
client
|
|
.execute(&format!("SET timezone = '{}'", tz.replace('\'', "''")), &[])
|
|
.await
|
|
.map_err(|e| format!("PostgreSQL SET timezone failed: {e}"))?;
|
|
}
|
|
|
|
Ok(pool)
|
|
})
|
|
.await
|
|
}
|
|
|
|
fn postgres_tls_config(pg_config: &tokio_postgres::Config) -> rustls::ClientConfig {
|
|
if postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()) {
|
|
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
|
|
return rustls::ClientConfig::builder()
|
|
.dangerous()
|
|
.with_custom_certificate_verifier(Arc::new(NoPostgresCertVerification { provider }))
|
|
.with_no_client_auth();
|
|
}
|
|
|
|
let mut root_store = rustls::RootCertStore::empty();
|
|
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
|
rustls::ClientConfig::builder().with_root_certificates(root_store).with_no_client_auth()
|
|
}
|
|
|
|
fn postgres_sslmode_accepts_invalid_certs(ssl_mode: SslMode) -> bool {
|
|
matches!(ssl_mode, SslMode::Prefer | SslMode::Require)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct NoPostgresCertVerification {
|
|
provider: Arc<CryptoProvider>,
|
|
}
|
|
|
|
impl ServerCertVerifier for NoPostgresCertVerification {
|
|
fn verify_server_cert(
|
|
&self,
|
|
_end_entity: &CertificateDer<'_>,
|
|
_intermediates: &[CertificateDer<'_>],
|
|
_server_name: &ServerName<'_>,
|
|
_ocsp_response: &[u8],
|
|
_now: UnixTime,
|
|
) -> Result<ServerCertVerified, rustls::Error> {
|
|
Ok(ServerCertVerified::assertion())
|
|
}
|
|
|
|
fn verify_tls12_signature(
|
|
&self,
|
|
message: &[u8],
|
|
cert: &CertificateDer<'_>,
|
|
dss: &rustls::DigitallySignedStruct,
|
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
|
verify_tls12_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
|
|
}
|
|
|
|
fn verify_tls13_signature(
|
|
&self,
|
|
message: &[u8],
|
|
cert: &CertificateDer<'_>,
|
|
dss: &rustls::DigitallySignedStruct,
|
|
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
|
verify_tls13_signature(message, cert, dss, &self.provider.signature_verification_algorithms)
|
|
}
|
|
|
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
|
self.provider.signature_verification_algorithms.supported_schemes()
|
|
}
|
|
}
|
|
|
|
/// Check whether the user's connection URL already specifies a timezone via
|
|
/// the `options` parameter so we don't overwrite it with the local timezone.
|
|
fn pg_url_has_timezone_setting(url: &str) -> bool {
|
|
let lower = url.to_lowercase();
|
|
// Match "timezone=" anywhere after the query string, covering:
|
|
// ?options=-c timezone=Asia/Shanghai
|
|
// ?options=--timezone=UTC
|
|
// Also handles URL-encoded forms like timezone%3D
|
|
if let Some(query) = lower.split('?').nth(1) {
|
|
if query.contains("timezone=") || query.contains("timezone%3d") {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn validate_postgres_ssl_paths(url: &str) -> Result<(), String> {
|
|
if let Some(query_start) = url.find('?') {
|
|
let query_string = &url[query_start + 1..];
|
|
|
|
for param in query_string.split('&') {
|
|
if let Some((key, value)) = param.split_once('=') {
|
|
match key {
|
|
"sslcert" | "sslkey" | "sslrootcert" => {
|
|
let decoded = percent_decode_str(value)
|
|
.decode_utf8()
|
|
.map_err(|_| format!("Invalid URL encoding in {key}"))?;
|
|
|
|
validate_file_path(&decoded, |_| false).map_err(|e| format!("{key}: {e}"))?;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_databases(pool: &Pool) -> Result<Vec<DatabaseInfo>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client
|
|
.prepare_cached(
|
|
"SELECT datname FROM pg_database \
|
|
WHERE datistemplate = false AND datallowconn = true \
|
|
ORDER BY datname",
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(rows.iter().map(|row| DatabaseInfo { name: row.get::<_, String>(0) }).collect())
|
|
}
|
|
|
|
pub async fn list_tables(pool: &Pool, schema: &str) -> Result<Vec<TableInfo>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client.prepare_cached(postgres_tables_sql()).await.map_err(|e| e.to_string())?;
|
|
let rows = client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| TableInfo {
|
|
name: row.get::<_, String>(0),
|
|
table_type: row.get::<_, String>(1),
|
|
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
fn postgres_tables_sql() -> &'static str {
|
|
"SELECT c.relname AS table_name, \
|
|
CASE c.relkind WHEN 'r' THEN 'BASE TABLE' WHEN 'v' THEN 'VIEW' \
|
|
WHEN 'm' THEN 'MATERIALIZED VIEW' WHEN 'f' THEN 'FOREIGN TABLE' \
|
|
WHEN 'p' THEN 'BASE TABLE' END AS table_type, \
|
|
obj_description(c.oid) AS table_comment \
|
|
FROM pg_catalog.pg_class c \
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
|
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
|
|
ORDER BY c.relname"
|
|
}
|
|
|
|
fn list_objects_sql(include_timestamps: bool) -> &'static str {
|
|
if include_timestamps {
|
|
return "SELECT c.relname AS object_name, \
|
|
CASE c.relkind \
|
|
WHEN 'v' THEN 'VIEW' \
|
|
WHEN 'm' THEN 'VIEW' \
|
|
ELSE 'TABLE' \
|
|
END AS object_type, \
|
|
obj_description(c.oid) AS object_comment, \
|
|
stat.creation::text AS created_at, \
|
|
COALESCE( \
|
|
CASE WHEN current_setting('track_commit_timestamp', true) = 'on' \
|
|
THEN pg_xact_commit_timestamp(c.xmin)::text END, \
|
|
stat.modification::text \
|
|
) AS updated_at, \
|
|
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \
|
|
FROM pg_catalog.pg_class c \
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
|
LEFT JOIN LATERAL pg_stat_file( \
|
|
CASE WHEN c.relkind IN ('r','m','f','p') THEN pg_relation_filepath(c.oid) END, true \
|
|
) stat ON true \
|
|
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
|
|
UNION ALL \
|
|
SELECT p.proname AS object_name, \
|
|
CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
|
|
obj_description(p.oid) AS object_comment, \
|
|
NULL::text AS created_at, \
|
|
CASE WHEN current_setting('track_commit_timestamp', true) = 'on' \
|
|
THEN pg_xact_commit_timestamp(p.xmin)::text END AS updated_at, \
|
|
CASE p.prokind WHEN 'p' THEN 2 ELSE 3 END AS sort_order \
|
|
FROM pg_catalog.pg_proc p \
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \
|
|
WHERE n.nspname = $1 AND p.prokind IN ('p','f') \
|
|
ORDER BY sort_order, object_name";
|
|
}
|
|
|
|
"SELECT c.relname AS object_name, \
|
|
CASE c.relkind \
|
|
WHEN 'v' THEN 'VIEW' \
|
|
WHEN 'm' THEN 'VIEW' \
|
|
ELSE 'TABLE' \
|
|
END AS object_type, \
|
|
obj_description(c.oid) AS object_comment, \
|
|
NULL::text AS created_at, \
|
|
NULL::text AS updated_at, \
|
|
CASE c.relkind WHEN 'v' THEN 1 WHEN 'm' THEN 1 ELSE 0 END AS sort_order \
|
|
FROM pg_catalog.pg_class c \
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
|
|
WHERE n.nspname = $1 AND c.relkind IN ('r','v','m','f','p') \
|
|
UNION ALL \
|
|
SELECT p.proname AS object_name, \
|
|
CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END AS object_type, \
|
|
obj_description(p.oid) AS object_comment, \
|
|
NULL::text AS created_at, \
|
|
NULL::text AS updated_at, \
|
|
CASE p.prokind WHEN 'p' THEN 2 ELSE 3 END AS sort_order \
|
|
FROM pg_catalog.pg_proc p \
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace \
|
|
WHERE n.nspname = $1 AND p.prokind IN ('p','f') \
|
|
ORDER BY sort_order, object_name"
|
|
}
|
|
|
|
pub async fn list_objects(pool: &Pool, schema: &str) -> Result<Vec<ObjectInfo>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client.prepare_cached(list_objects_sql(true)).await.map_err(|e| e.to_string())?;
|
|
let rows = match client.query(&stmt, &[&schema]).await {
|
|
Ok(rows) => rows,
|
|
Err(_) => {
|
|
let stmt = client.prepare_cached(list_objects_sql(false)).await.map_err(|e| e.to_string())?;
|
|
client.query(&stmt, &[&schema]).await.map_err(|e| e.to_string())?
|
|
}
|
|
};
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| ObjectInfo {
|
|
name: row.get::<_, String>(0),
|
|
object_type: row.get::<_, String>(1),
|
|
schema: Some(schema.to_string()),
|
|
comment: row.try_get::<_, Option<String>>(2).ok().flatten().filter(|s| !s.is_empty()),
|
|
created_at: row.try_get::<_, Option<String>>(3).ok().flatten().filter(|s| !s.is_empty()),
|
|
updated_at: row.try_get::<_, Option<String>>(4).ok().flatten().filter(|s| !s.is_empty()),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn list_schemas(pool: &Pool) -> Result<Vec<String>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client
|
|
.prepare_cached(
|
|
"SELECT n.nspname AS schema_name FROM pg_catalog.pg_namespace n \
|
|
WHERE n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast') \
|
|
AND n.nspname NOT LIKE 'pg_toast_temp_%' \
|
|
AND n.nspname NOT LIKE 'pg_temp_%' \
|
|
ORDER BY n.nspname",
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = client.query(&stmt, &[]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(rows.iter().map(|row| row.get::<_, String>(0)).collect())
|
|
}
|
|
|
|
pub async fn get_columns(pool: &Pool, schema: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client
|
|
.prepare_cached(
|
|
"SELECT a.attname AS column_name, \
|
|
format_type(a.atttypid, a.atttypmod) AS full_type, \
|
|
NOT a.attnotnull AS is_nullable, \
|
|
pg_get_expr(ad.adbin, ad.adrelid) AS column_default, \
|
|
EXISTS ( \
|
|
SELECT 1 FROM pg_constraint co \
|
|
JOIN pg_index i ON i.indrelid = co.conrelid AND co.conindid = i.indexrelid \
|
|
WHERE co.conrelid = a.attrelid AND co.contype = 'p' \
|
|
AND a.attnum = ANY(i.indkey) \
|
|
) AS is_pk, \
|
|
col_description(a.attrelid, a.attnum) AS column_comment, \
|
|
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 \
|
|
THEN ((a.atttypmod - 4) >> 16) & 65535 ELSE NULL END AS numeric_precision, \
|
|
CASE WHEN t.typname = 'numeric' AND a.atttypmod > 0 \
|
|
THEN (a.atttypmod - 4) & 65535 ELSE NULL END AS numeric_scale, \
|
|
CASE WHEN t.typname IN ('varchar', 'bpchar') AND a.atttypmod > 0 \
|
|
THEN a.atttypmod - 4 ELSE NULL END AS character_maximum_length \
|
|
FROM pg_attribute a \
|
|
JOIN pg_type t ON t.oid = a.atttypid \
|
|
LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum \
|
|
WHERE a.attrelid = (quote_ident($1) || '.' || quote_ident($2))::regclass \
|
|
AND a.attnum > 0 AND NOT a.attisdropped \
|
|
ORDER BY a.attnum",
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = client.query(&stmt, &[&schema, &table]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| {
|
|
let full_type = row.try_get::<_, Option<String>>(1).ok().flatten().unwrap_or_default();
|
|
ColumnInfo {
|
|
name: row.get::<_, String>(0),
|
|
data_type: full_type,
|
|
is_nullable: row.get::<_, bool>(2),
|
|
column_default: row.try_get::<_, Option<String>>(3).ok().flatten(),
|
|
is_primary_key: row.get::<_, bool>(4),
|
|
extra: None,
|
|
comment: row.try_get::<_, Option<String>>(5).ok().flatten(),
|
|
numeric_precision: row.try_get::<_, Option<i32>>(6).ok().flatten(),
|
|
numeric_scale: row.try_get::<_, Option<i32>>(7).ok().flatten(),
|
|
character_maximum_length: row.try_get::<_, Option<i32>>(8).ok().flatten(),
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub(crate) fn pg_quote_ident(ident: &str) -> String {
|
|
format!("\"{}\"", ident.replace('"', "\"\""))
|
|
}
|
|
|
|
fn query_result_row_limit(max_rows: Option<usize>) -> usize {
|
|
max_rows.unwrap_or(crate::query::MAX_ROWS).max(1)
|
|
}
|
|
|
|
pub async fn execute_query(pool: &Pool, sql: &str) -> Result<QueryResult, String> {
|
|
execute_query_with_max_rows(pool, sql, None).await
|
|
}
|
|
|
|
pub async fn execute_query_with_max_rows(
|
|
pool: &Pool,
|
|
sql: &str,
|
|
max_rows: Option<usize>,
|
|
) -> Result<QueryResult, String> {
|
|
let start = Instant::now();
|
|
let row_limit = query_result_row_limit(max_rows);
|
|
|
|
if starts_with_executable_sql_keyword(sql, &["SELECT", "SHOW", "EXPLAIN", "WITH", "TABLE"]) {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
execute_select_query(&client, sql, start, row_limit).await
|
|
} else {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let affected = client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(QueryResult {
|
|
columns: vec![],
|
|
rows: vec![],
|
|
affected_rows: affected,
|
|
execution_time_ms: start.elapsed().as_millis(),
|
|
truncated: false,
|
|
session_id: None,
|
|
has_more: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub async fn execute_query_with_schema(pool: &Pool, schema: &str, sql: &str) -> Result<QueryResult, String> {
|
|
execute_query_with_schema_and_max_rows(pool, schema, sql, None).await
|
|
}
|
|
|
|
pub async fn execute_query_with_schema_and_max_rows(
|
|
pool: &Pool,
|
|
schema: &str,
|
|
sql: &str,
|
|
max_rows: Option<usize>,
|
|
) -> Result<QueryResult, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
client
|
|
.execute(&format!("SET search_path TO {}, public", pg_quote_ident(schema)), &[])
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let result = execute_query_with_max_rows_inner(&client, sql, max_rows).await;
|
|
|
|
// Always reset search_path so the connection is clean when returned to the pool
|
|
let _ = client.execute("RESET search_path", &[]).await;
|
|
|
|
result
|
|
}
|
|
|
|
async fn execute_query_with_max_rows_inner(
|
|
client: &deadpool_postgres::Client,
|
|
sql: &str,
|
|
max_rows: Option<usize>,
|
|
) -> Result<QueryResult, String> {
|
|
let start = Instant::now();
|
|
let row_limit = query_result_row_limit(max_rows);
|
|
|
|
if starts_with_executable_sql_keyword(sql, &["SELECT", "SHOW", "EXPLAIN", "WITH", "TABLE"]) {
|
|
execute_select_query(client, sql, start, row_limit).await
|
|
} else {
|
|
let affected = client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(QueryResult {
|
|
columns: vec![],
|
|
rows: vec![],
|
|
affected_rows: affected,
|
|
execution_time_ms: start.elapsed().as_millis(),
|
|
truncated: false,
|
|
session_id: None,
|
|
has_more: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub async fn list_indexes(pool: &Pool, schema: &str, table: &str) -> Result<Vec<IndexInfo>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client
|
|
.prepare_cached(
|
|
"SELECT i.relname AS index_name, \
|
|
array_agg(COALESCE(a.attname, pg_get_indexdef(ix.indexrelid, k.n::int, true)) ORDER BY k.n) AS columns, \
|
|
ix.indisunique AS is_unique, \
|
|
ix.indisprimary AS is_primary, \
|
|
pg_get_expr(ix.indpred, ix.indrelid) AS filter_expr, \
|
|
am.amname AS index_type, \
|
|
ix.indnkeyatts AS nkeyatts, \
|
|
ix.indkey AS indkey, \
|
|
obj_description(i.oid, 'pg_class') AS index_comment \
|
|
FROM pg_index ix \
|
|
JOIN pg_class t ON t.oid = ix.indrelid \
|
|
JOIN pg_class i ON i.oid = ix.indexrelid \
|
|
JOIN pg_namespace n ON n.oid = t.relnamespace \
|
|
JOIN pg_am am ON am.oid = i.relam \
|
|
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, n) ON true \
|
|
LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND k.attnum > 0 \
|
|
WHERE n.nspname = $1 AND t.relname = $2 \
|
|
GROUP BY i.relname, i.oid, ix.indisunique, ix.indisprimary, ix.indpred, ix.indrelid, am.amname, ix.indnkeyatts, ix.indkey \
|
|
ORDER BY i.relname",
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = client.query(&stmt, &[&schema, &table]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| {
|
|
let all_cols: Vec<String> = row.get::<_, Vec<String>>(1);
|
|
let nkeyatts = row.try_get::<_, Option<i16>>(6).ok().flatten().unwrap_or(all_cols.len() as i16) as usize;
|
|
let split_at = nkeyatts.min(all_cols.len());
|
|
let key_cols = all_cols[..split_at].to_vec();
|
|
let included = if split_at < all_cols.len() { all_cols[split_at..].to_vec() } else { vec![] };
|
|
IndexInfo {
|
|
name: row.get::<_, String>(0),
|
|
columns: key_cols,
|
|
is_unique: row.get::<_, bool>(2),
|
|
is_primary: row.get::<_, bool>(3),
|
|
filter: row.try_get::<_, Option<String>>(4).ok().flatten(),
|
|
index_type: row.try_get::<_, Option<String>>(5).ok().flatten(),
|
|
included_columns: if included.is_empty() { None } else { Some(included) },
|
|
comment: row.try_get::<_, Option<String>>(8).ok().flatten(),
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn list_foreign_keys(pool: &Pool, schema: &str, table: &str) -> Result<Vec<ForeignKeyInfo>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client
|
|
.prepare_cached(
|
|
"SELECT kcu.constraint_name, kcu.column_name, \
|
|
ccu.table_name AS ref_table, ccu.column_name AS ref_column \
|
|
FROM information_schema.key_column_usage kcu \
|
|
JOIN information_schema.referential_constraints rc \
|
|
ON kcu.constraint_name = rc.constraint_name \
|
|
AND kcu.constraint_schema = rc.constraint_schema \
|
|
JOIN information_schema.constraint_column_usage ccu \
|
|
ON rc.unique_constraint_name = ccu.constraint_name \
|
|
AND rc.unique_constraint_schema = ccu.constraint_schema \
|
|
WHERE kcu.table_schema = $1 AND kcu.table_name = $2 \
|
|
ORDER BY kcu.constraint_name",
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = client.query(&stmt, &[&schema, &table]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| ForeignKeyInfo {
|
|
name: row.get::<_, String>(0),
|
|
column: row.get::<_, String>(1),
|
|
ref_table: row.get::<_, String>(2),
|
|
ref_column: row.get::<_, String>(3),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn list_triggers(pool: &Pool, schema: &str, table: &str) -> Result<Vec<TriggerInfo>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stmt = client
|
|
.prepare_cached(
|
|
"SELECT trigger_name, event_manipulation, action_timing \
|
|
FROM information_schema.triggers \
|
|
WHERE trigger_schema = $1 AND event_object_table = $2 \
|
|
ORDER BY trigger_name",
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let rows = client.query(&stmt, &[&schema, &table]).await.map_err(|e| e.to_string())?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| TriggerInfo {
|
|
name: row.get::<_, String>(0),
|
|
event: row.get::<_, String>(1),
|
|
timing: row.get::<_, String>(2),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Execute multiple SQL statements in a single round-trip using batch_execute.
|
|
/// Best for DDL scripts where per-statement affected-row counts are not needed.
|
|
pub async fn execute_batch(pool: &Pool, statements: &[String]) -> Result<(), String> {
|
|
let combined = statements.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(";\n");
|
|
if combined.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
client.batch_execute(&combined).await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Export data via COPY TO STDOUT. `sql` must be a complete COPY statement, e.g.
|
|
/// `COPY table (col1, col2) TO STDOUT (FORMAT CSV, HEADER)`.
|
|
/// Returns the raw COPY output bytes.
|
|
pub async fn copy_out(pool: &Pool, sql: &str) -> Result<Vec<u8>, String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let stream = client.copy_out(sql).await.map_err(|e| e.to_string())?;
|
|
tokio::pin!(stream);
|
|
let mut result = Vec::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
result.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
/// Import data via COPY FROM STDIN. `sql` must be a complete COPY statement, e.g.
|
|
/// `COPY table (col1, col2) FROM STDIN (FORMAT CSV)`.
|
|
/// `data` is the raw input in the format specified by the COPY command.
|
|
pub async fn copy_in(pool: &Pool, sql: &str, data: &[u8]) -> Result<(), String> {
|
|
let client = pool.get().await.map_err(|e| e.to_string())?;
|
|
let sink = client.copy_in::<str, bytes::Bytes>(sql).await.map_err(|e: tokio_postgres::Error| e.to_string())?;
|
|
let mut sink = Box::pin(sink);
|
|
sink.as_mut().send(bytes::Bytes::copy_from_slice(data)).await.map_err(|e| e.to_string())?;
|
|
sink.as_mut().close().await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio_postgres::types::FromSql;
|
|
|
|
// --- pg_quote_ident ---
|
|
|
|
#[test]
|
|
fn pg_system_u32_decodes_catalog_integer_types() {
|
|
let raw = 42_u32.to_be_bytes();
|
|
|
|
assert_eq!(u32::from_sql(&Type::OID, &raw).unwrap(), 42);
|
|
assert_eq!(PgSystemU32::from_sql(&Type::XID, &raw).unwrap().0, 42);
|
|
assert_eq!(PgSystemU32::from_sql(&Type::CID, &raw).unwrap().0, 42);
|
|
assert!(u32::accepts(&Type::OID));
|
|
assert!(PgSystemU32::accepts(&Type::XID));
|
|
assert!(PgSystemU32::accepts(&Type::CID));
|
|
assert!(!PgSystemU32::accepts(&Type::OID));
|
|
assert!(!PgSystemU32::accepts(&Type::INT4));
|
|
}
|
|
|
|
#[test]
|
|
fn pg_quote_ident_plain_identifier() {
|
|
assert_eq!(pg_quote_ident("public"), "\"public\"");
|
|
}
|
|
|
|
#[test]
|
|
fn pg_quote_ident_escapes_double_quotes() {
|
|
assert_eq!(pg_quote_ident("my\"schema"), "\"my\"\"schema\"");
|
|
}
|
|
|
|
#[test]
|
|
fn pg_quote_ident_empty_string() {
|
|
assert_eq!(pg_quote_ident(""), "\"\"");
|
|
}
|
|
|
|
#[test]
|
|
fn pg_quote_ident_special_chars() {
|
|
// PostgreSQL allows many special chars in quoted identifiers
|
|
let ident = "my schema with spaces";
|
|
assert_eq!(pg_quote_ident(ident), "\"my schema with spaces\"");
|
|
}
|
|
|
|
#[test]
|
|
fn pg_quote_ident_injection_attempt() {
|
|
// A malicious schema name that tries to break out of quotes
|
|
let malicious = r#"public"; DROP TABLE users; --"#;
|
|
let escaped = pg_quote_ident(malicious);
|
|
// Double quotes should be doubled, not breaking out
|
|
assert_eq!(escaped, r#""public""; DROP TABLE users; --""#);
|
|
assert!(escaped.matches('"').count() % 2 == 0, "quote count should be even");
|
|
}
|
|
|
|
// --- query_result_row_limit ---
|
|
|
|
#[test]
|
|
fn row_limit_uses_max_rows_when_present() {
|
|
assert_eq!(query_result_row_limit(Some(50)), 50);
|
|
}
|
|
|
|
#[test]
|
|
fn row_limit_falls_back_to_default() {
|
|
let default = crate::query::MAX_ROWS;
|
|
assert_eq!(query_result_row_limit(None), default);
|
|
}
|
|
|
|
#[test]
|
|
fn row_limit_clamps_zero_to_one() {
|
|
assert_eq!(query_result_row_limit(Some(0)), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn row_limit_allows_max_rows_override() {
|
|
assert_eq!(query_result_row_limit(Some(5)), 5);
|
|
}
|
|
|
|
// --- validate_postgres_ssl_paths ---
|
|
|
|
#[test]
|
|
fn ssl_validation_passes_for_clean_url() {
|
|
assert!(validate_postgres_ssl_paths("postgres://localhost/db").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_passes_for_url_without_query() {
|
|
assert!(validate_postgres_ssl_paths("host=localhost dbname=test").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_passes_for_irrelevant_params() {
|
|
assert!(validate_postgres_ssl_paths("postgres://localhost/db?sslmode=require&connect_timeout=10").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_rejects_nonexistent_sslcert_path() {
|
|
let result = validate_postgres_ssl_paths("postgres://localhost/db?sslcert=/nonexistent/path/cert.pem");
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("sslcert"), "error should mention sslcert");
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_rejects_nonexistent_sslkey_path() {
|
|
let result = validate_postgres_ssl_paths("postgres://localhost/db?sslkey=/nonexistent/path/key.pem");
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("sslkey"), "error should mention sslkey");
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_rejects_nonexistent_sslrootcert_path() {
|
|
let result = validate_postgres_ssl_paths("postgres://localhost/db?sslrootcert=/nonexistent/path/root.crt");
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("sslrootcert"), "error should mention sslrootcert");
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_rejects_path_traversal_in_sslcert() {
|
|
let result = validate_postgres_ssl_paths("postgres://localhost/db?sslcert=../../../etc/passwd");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_handles_url_encoded_ssl_param() {
|
|
// %2F = '/', so sslcert=%2Ftmp%2Fcert.pem means sslcert=/tmp/cert.pem
|
|
let result = validate_postgres_ssl_paths("postgres://localhost/db?sslcert=%2Fnonexistent%2Fcert.pem");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn ssl_validation_handles_multiple_params() {
|
|
let result =
|
|
validate_postgres_ssl_paths("postgres://localhost/db?sslmode=require&sslcert=/nonexistent/cert.pem");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn postgres_tls_accepts_invalid_certs_for_require_sslmode() {
|
|
let pg_config = tokio_postgres::Config::from_str("postgres://localhost/db?sslmode=require").unwrap();
|
|
|
|
assert!(postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()));
|
|
}
|
|
|
|
#[test]
|
|
fn postgres_tls_accepts_invalid_certs_for_default_prefer_sslmode() {
|
|
let pg_config = tokio_postgres::Config::from_str("postgres://localhost/db").unwrap();
|
|
|
|
assert!(postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()));
|
|
}
|
|
|
|
#[test]
|
|
fn postgres_tls_keeps_verification_off_only_when_ssl_is_disabled() {
|
|
let pg_config = tokio_postgres::Config::from_str("postgres://localhost/db?sslmode=disable").unwrap();
|
|
|
|
assert!(!postgres_sslmode_accepts_invalid_certs(pg_config.get_ssl_mode()));
|
|
}
|
|
|
|
// --- SQL generation ---
|
|
|
|
#[test]
|
|
fn postgres_tables_sql_contains_expected_columns() {
|
|
let sql = postgres_tables_sql();
|
|
assert!(sql.contains("table_name"));
|
|
assert!(sql.contains("table_type"));
|
|
assert!(sql.contains("table_comment"));
|
|
assert!(sql.contains("$1"));
|
|
assert!(sql.contains("BASE TABLE"));
|
|
assert!(sql.contains("VIEW"));
|
|
assert!(sql.contains("MATERIALIZED VIEW"));
|
|
assert!(sql.contains("FOREIGN TABLE"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_objects_sql_includes_routines() {
|
|
let sql = list_objects_sql(true);
|
|
assert!(sql.contains("pg_catalog.pg_class"));
|
|
assert!(sql.contains("pg_catalog.pg_proc"));
|
|
assert!(sql.contains("pg_stat_file"));
|
|
assert!(sql.contains("pg_xact_commit_timestamp"));
|
|
assert!(sql.contains("'PROCEDURE'"));
|
|
assert!(sql.contains("'FUNCTION'"));
|
|
}
|
|
|
|
#[test]
|
|
fn list_objects_sql_without_timestamps_omits_stat_file() {
|
|
let sql = list_objects_sql(false);
|
|
assert!(!sql.contains("pg_stat_file"));
|
|
assert!(sql.contains("NULL::text AS created_at"));
|
|
assert!(sql.contains("NULL::text AS updated_at"));
|
|
}
|
|
|
|
#[test]
|
|
fn both_list_objects_sql_variants_use_parameter() {
|
|
assert!(list_objects_sql(true).contains("$1"));
|
|
assert!(list_objects_sql(false).contains("$1"));
|
|
}
|
|
|
|
#[test]
|
|
fn both_list_objects_sql_variants_include_pg_proc() {
|
|
assert!(list_objects_sql(true).contains("pg_catalog.pg_proc"));
|
|
assert!(list_objects_sql(false).contains("pg_catalog.pg_proc"));
|
|
}
|
|
|
|
// --- execute_batch ---
|
|
|
|
#[tokio::test]
|
|
async fn execute_batch_empty_statements_returns_ok() {
|
|
// Empty input should not error or try to connect
|
|
// We can't test with a real pool, but we can verify the empty-early-return logic
|
|
// by testing that an empty Vec doesn't need a pool reference
|
|
let statements: Vec<String> = vec![];
|
|
// This test validates the early return logic at code review level
|
|
// Actual execution requires a pool; we just verify the empty path exists
|
|
assert!(statements.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn execute_batch_whitespace_only_is_filtered() {
|
|
let statements = vec![" ".to_string(), "\t\n".to_string(), "".to_string()];
|
|
let combined = statements.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(";\n");
|
|
assert!(combined.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn execute_batch_joins_with_semicolons() {
|
|
let statements = vec!["SELECT 1".to_string(), "SELECT 2".to_string()];
|
|
let combined = statements.iter().map(|s| s.trim()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(";\n");
|
|
assert_eq!(combined, "SELECT 1;\nSELECT 2");
|
|
}
|
|
|
|
// --- SET timezone escaping ---
|
|
|
|
#[test]
|
|
fn timezone_single_quotes_are_doubled() {
|
|
let tz = "UTC";
|
|
let escaped = tz.replace('\'', "''");
|
|
assert_eq!(escaped, "UTC");
|
|
}
|
|
|
|
#[test]
|
|
fn timezone_with_quote_is_escaped() {
|
|
let tz = "Some'Zone";
|
|
let escaped = tz.replace('\'', "''");
|
|
assert_eq!(escaped, "Some''Zone");
|
|
}
|
|
|
|
// --- pg_url_has_timezone_setting ---
|
|
|
|
#[test]
|
|
fn url_without_timezone_returns_false() {
|
|
assert!(!pg_url_has_timezone_setting("postgres://localhost/db"));
|
|
assert!(!pg_url_has_timezone_setting("postgres://localhost/db?sslmode=require"));
|
|
}
|
|
|
|
#[test]
|
|
fn url_with_options_timezone_returns_true() {
|
|
assert!(pg_url_has_timezone_setting("postgres://localhost/db?options=-c timezone=Asia/Shanghai"));
|
|
}
|
|
|
|
#[test]
|
|
fn url_with_url_encoded_timezone_returns_true() {
|
|
assert!(pg_url_has_timezone_setting("postgres://localhost/db?options=-c%20timezone%3DUTC"));
|
|
}
|
|
|
|
#[test]
|
|
fn url_with_uppercase_timezone_returns_true() {
|
|
assert!(pg_url_has_timezone_setting("postgres://localhost/db?options=--TimeZone=UTC"));
|
|
}
|
|
}
|