dataprof_db/connectors/
postgres.rs1#[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
18pub 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 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 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}