Skip to main content

dataprof_db/connectors/
postgres.rs

1//! PostgreSQL database connector with connection pooling
2
3#[cfg(not(feature = "postgres"))]
4use super::common::feature_not_enabled_error;
5#[cfg(feature = "postgres")]
6use super::common::{build_count_query, not_connected_error};
7use crate::connection::ConnectionInfo;
8#[cfg(feature = "postgres")]
9use crate::security::validate_sql_identifier;
10use crate::{DataProfilerError, DatabaseConfig, DatabaseConnector, QueryColumns};
11#[cfg(feature = "postgres")]
12use crate::{process_rows_to_columns, streaming_profile_loop};
13use async_trait::async_trait;
14
15#[cfg(feature = "postgres")]
16use {sqlx::postgres::PgPool, sqlx::postgres::PgPoolOptions};
17
18/// PostgreSQL connector with connection pooling support
19pub struct PostgresConnector {
20    #[allow(dead_code)]
21    config: DatabaseConfig,
22    #[allow(dead_code)]
23    connection_info: ConnectionInfo,
24    #[cfg(feature = "postgres")]
25    pool: Option<PgPool>,
26    #[cfg(not(feature = "postgres"))]
27    #[allow(dead_code)]
28    pool: Option<()>,
29}
30
31impl PostgresConnector {
32    /// Create a new PostgreSQL connector
33    pub fn new(config: DatabaseConfig) -> Result<Self, DataProfilerError> {
34        let connection_info = ConnectionInfo::parse(&config.connection_string)?;
35
36        if connection_info.database_type() != "postgresql" {
37            return Err(DataProfilerError::DatabaseConfigError {
38                message: format!(
39                    "Invalid connection string for PostgreSQL: expected a postgresql:// URL, got a {} URL",
40                    connection_info.database_type()
41                ),
42            });
43        }
44
45        Ok(Self {
46            config,
47            connection_info,
48            pool: None,
49        })
50    }
51}
52
53#[async_trait]
54impl DatabaseConnector for PostgresConnector {
55    async fn connect(&mut self) -> Result<(), DataProfilerError> {
56        #[cfg(feature = "postgres")]
57        {
58            let connection_string = self.connection_info.to_connection_string("sqlx");
59
60            let pool = PgPoolOptions::new()
61                .max_connections(self.config.max_connections.unwrap_or(10))
62                .acquire_timeout(
63                    self.config
64                        .connection_timeout
65                        .unwrap_or(std::time::Duration::from_secs(30)),
66                )
67                .connect(&connection_string)
68                .await
69                .map_err(|e| {
70                    DataProfilerError::database_connection(&format!(
71                        "Failed to connect to PostgreSQL: {}",
72                        e
73                    ))
74                })?;
75
76            self.pool = Some(pool);
77            Ok(())
78        }
79
80        #[cfg(not(feature = "postgres"))]
81        {
82            Err(DataProfilerError::database_feature_disabled(
83                "PostgreSQL",
84                "postgres",
85            ))
86        }
87    }
88
89    async fn disconnect(&mut self) -> Result<(), DataProfilerError> {
90        #[cfg(feature = "postgres")]
91        {
92            if let Some(pool) = &self.pool {
93                pool.close().await;
94                self.pool = None;
95            }
96        }
97        Ok(())
98    }
99
100    #[allow(unused_variables)]
101    async fn profile_query(&mut self, query: &str) -> Result<QueryColumns, DataProfilerError> {
102        #[cfg(feature = "postgres")]
103        {
104            let pool = self.pool.as_ref().ok_or_else(not_connected_error)?;
105
106            let rows = sqlx::query(query).fetch_all(pool).await.map_err(|e| {
107                DataProfilerError::database_query(&format!("Query execution failed: {}", e))
108            })?;
109
110            process_rows_to_columns!(rows)
111        }
112
113        #[cfg(not(feature = "postgres"))]
114        Err(feature_not_enabled_error("PostgreSQL", "postgres"))
115    }
116
117    #[allow(unused_variables)]
118    async fn profile_query_streaming(
119        &mut self,
120        query: &str,
121        batch_size: usize,
122    ) -> Result<QueryColumns, DataProfilerError> {
123        #[cfg(feature = "postgres")]
124        {
125            let pool = self.pool.as_ref().ok_or_else(not_connected_error)?;
126
127            let count_query = build_count_query(query)?;
128            let total_rows: i64 = sqlx::query_scalar(&count_query)
129                .fetch_one(pool)
130                .await
131                .map_err(|e| {
132                    DataProfilerError::database_query(&format!("Failed to count rows: {}", e))
133                })?;
134
135            streaming_profile_loop!(pool, query, batch_size, total_rows, "PostgreSQL")
136        }
137
138        #[cfg(not(feature = "postgres"))]
139        Err(feature_not_enabled_error("PostgreSQL", "postgres"))
140    }
141
142    #[allow(unused_variables)]
143    async fn get_table_schema(
144        &mut self,
145        table_name: &str,
146    ) -> Result<Vec<String>, DataProfilerError> {
147        #[cfg(feature = "postgres")]
148        {
149            use sqlx::Row;
150
151            let pool = self.pool.as_ref().ok_or_else(not_connected_error)?;
152
153            let query = r#"
154                SELECT column_name
155                FROM information_schema.columns
156                WHERE table_name = $1
157                ORDER BY ordinal_position
158            "#;
159
160            let rows = sqlx::query(query)
161                .bind(table_name)
162                .fetch_all(pool)
163                .await
164                .map_err(|e| {
165                    DataProfilerError::database_query(&format!("Failed to get table schema: {}", e))
166                })?;
167
168            let mut columns = Vec::new();
169            for row in rows {
170                let column_name: String = row.try_get(0).map_err(|e| {
171                    DataProfilerError::database_query(&format!("Failed to read column name: {}", e))
172                })?;
173                columns.push(column_name);
174            }
175
176            Ok(columns)
177        }
178
179        #[cfg(not(feature = "postgres"))]
180        Err(feature_not_enabled_error("PostgreSQL", "postgres"))
181    }
182
183    #[allow(unused_variables)]
184    async fn count_table_rows(&mut self, table_name: &str) -> Result<u64, DataProfilerError> {
185        #[cfg(feature = "postgres")]
186        {
187            let pool = self.pool.as_ref().ok_or_else(not_connected_error)?;
188
189            validate_sql_identifier(table_name)?;
190            let query = format!("SELECT COUNT(*) FROM {}", table_name);
191            let count: i64 = sqlx::query_scalar(&query)
192                .fetch_one(pool)
193                .await
194                .map_err(|e| {
195                    DataProfilerError::database_query(&format!("Failed to count rows: {}", e))
196                })?;
197
198            Ok(count as u64)
199        }
200
201        #[cfg(not(feature = "postgres"))]
202        Err(feature_not_enabled_error("PostgreSQL", "postgres"))
203    }
204
205    async fn test_connection(&mut self) -> Result<bool, DataProfilerError> {
206        #[cfg(feature = "postgres")]
207        {
208            let pool = self.pool.as_ref().ok_or_else(not_connected_error)?;
209
210            let result: i32 = sqlx::query_scalar("SELECT 1")
211                .fetch_one(pool)
212                .await
213                .map_err(|e| {
214                    DataProfilerError::database_query(&format!("Connection test failed: {}", e))
215                })?;
216
217            Ok(result == 1)
218        }
219
220        #[cfg(not(feature = "postgres"))]
221        Err(feature_not_enabled_error("PostgreSQL", "postgres"))
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn scheme_mismatch_error_does_not_leak_the_connection_string() {
231        // A mysql:// URL handed to the Postgres connector is a config error, but
232        // it must report the detected scheme — never echo the raw string, which
233        // carries the password.
234        let config = DatabaseConfig {
235            connection_string: "mysql://admin:s3cret@db.internal/app".to_string(),
236            ..Default::default()
237        };
238        let msg = match PostgresConnector::new(config) {
239            Ok(_) => panic!("scheme mismatch must fail"),
240            Err(err) => err.to_string(),
241        };
242        assert!(!msg.contains("s3cret"), "password must not leak: {msg}");
243        assert!(!msg.contains("admin"), "username must not leak: {msg}");
244        assert!(
245            msg.contains("mysql"),
246            "should name the detected scheme: {msg}"
247        );
248    }
249}