1pub(crate) use dataprof_core::DataProfilerError;
8use dataprof_core::{
9 AnalysisOptions, DataSource, ExecutionMetadata, MetricPack, QualityDimension, QueryEngine,
10};
11use dataprof_metrics::analyze_column_with_analysis_options;
12use dataprof_runtime::{ProfileReport, ReportAssembler, RowCompletenessTracker};
13
14pub mod connection;
15pub mod connectors;
16pub mod query_columns;
17pub mod retry;
18pub mod sampling;
19pub mod security;
20pub mod streaming;
21
22pub use connection::*;
23pub use connectors::*;
24pub use query_columns::QueryColumns;
25pub use retry::*;
26pub use sampling::*;
27pub use security::*;
28
29#[derive(Debug, Clone)]
31pub struct DatabaseConfig {
32 pub connection_string: String,
33 pub batch_size: usize,
34 pub max_connections: Option<u32>,
35 pub connection_timeout: Option<std::time::Duration>,
36 pub retry_config: Option<RetryConfig>,
37 pub sampling_config: Option<SamplingConfig>,
38 pub ssl_config: Option<SslConfig>,
39 pub load_credentials_from_env: bool,
40}
41
42impl Default for DatabaseConfig {
43 fn default() -> Self {
44 Self {
45 connection_string: String::new(),
46 batch_size: 10000,
47 max_connections: Some(10),
48 connection_timeout: Some(std::time::Duration::from_secs(30)),
49 retry_config: Some(RetryConfig::default()),
50 sampling_config: None,
51 ssl_config: Some(SslConfig::default()),
52 load_credentials_from_env: true,
53 }
54 }
55}
56
57#[async_trait::async_trait]
59pub trait DatabaseConnector: Send + Sync {
60 async fn connect(&mut self) -> Result<(), DataProfilerError>;
62
63 async fn disconnect(&mut self) -> Result<(), DataProfilerError>;
65
66 async fn profile_query(&mut self, query: &str) -> Result<QueryColumns, DataProfilerError>;
68
69 async fn profile_query_streaming(
71 &mut self,
72 query: &str,
73 batch_size: usize,
74 ) -> Result<QueryColumns, DataProfilerError>;
75
76 async fn get_table_schema(
78 &mut self,
79 table_name: &str,
80 ) -> Result<Vec<String>, DataProfilerError>;
81
82 async fn count_table_rows(&mut self, table_name: &str) -> Result<u64, DataProfilerError>;
84
85 async fn test_connection(&mut self) -> Result<bool, DataProfilerError>;
87}
88
89pub fn create_connector(
91 mut config: DatabaseConfig,
92) -> Result<Box<dyn DatabaseConnector>, DataProfilerError> {
93 if config.load_credentials_from_env || config.connection_string.is_empty() {
94 config = apply_environment_configuration(config)?;
95 }
96
97 let connection_str = config.connection_string.as_str();
98
99 if connection_str.starts_with("postgresql://") || connection_str.starts_with("postgres://") {
100 Ok(Box::new(connectors::postgres::PostgresConnector::new(
101 config,
102 )?))
103 } else if connection_str.starts_with("mysql://") {
104 Ok(Box::new(connectors::mysql::MySqlConnector::new(config)?))
105 } else if connection_str.starts_with("sqlite://")
106 || connection_str.ends_with(".db")
107 || connection_str.ends_with(".sqlite")
108 || connection_str == ":memory:"
109 {
110 Ok(Box::new(connectors::sqlite::SqliteConnector::new(config)?))
111 } else {
112 Err(DataProfilerError::DatabaseConfigError {
113 message: format!(
114 "Unsupported database connection string: {}. Supported: postgresql://, mysql://, sqlite://",
115 connection_str
116 ),
117 })
118 }
119}
120
121fn apply_environment_configuration(
123 mut config: DatabaseConfig,
124) -> Result<DatabaseConfig, DataProfilerError> {
125 let database_type = if config.connection_string.is_empty() {
126 if std::env::var("POSTGRES_URL").is_ok()
127 || std::env::var("DATABASE_URL")
128 .map(|url| url.starts_with("postgres"))
129 .unwrap_or(false)
130 {
131 "postgresql".to_string()
132 } else if std::env::var("MYSQL_URL").is_ok() {
133 "mysql".to_string()
134 } else {
135 "postgresql".to_string()
136 }
137 } else {
138 let conn_info = ConnectionInfo::parse(&config.connection_string)?;
139 conn_info.database_type().to_string()
140 };
141 let database_type = database_type.as_str();
142
143 if config.connection_string.is_empty() {
144 let (secure_connection_string, ssl_config) = load_secure_database_config(database_type)?;
145 config.connection_string = secure_connection_string;
146 config.ssl_config = Some(ssl_config);
147 } else {
148 if let Some(ssl_config) = &config.ssl_config {
149 config.connection_string = ssl_config
150 .apply_to_connection_string(config.connection_string.clone(), database_type);
151 }
152
153 if config.load_credentials_from_env {
154 let credentials = DatabaseCredentials::from_environment(database_type);
155 config.connection_string =
156 credentials.apply_to_connection_string(&config.connection_string);
157 }
158 }
159
160 Ok(config)
161}
162
163pub async fn analyze_database(
165 config: DatabaseConfig,
166 query: &str,
167 calculate_quality: bool,
168 quality_dimensions: Option<Vec<QualityDimension>>,
169) -> Result<ProfileReport, DataProfilerError> {
170 let mut options = AnalysisOptions::default().with_quality_dimensions(quality_dimensions);
171 if !calculate_quality {
172 options = options.with_metric_packs(Some(
175 MetricPack::all()
176 .into_iter()
177 .filter(|pack| *pack != MetricPack::Quality)
178 .collect(),
179 ));
180 }
181 analyze_database_with_options(config, query, &options).await
182}
183
184pub async fn analyze_database_with_options(
191 config: DatabaseConfig,
192 query: &str,
193 options: &AnalysisOptions,
194) -> Result<ProfileReport, DataProfilerError> {
195 if !options.semantic_hints().is_empty() {
199 return Err(DataProfilerError::UnsupportedDataSource {
200 message: "positive_columns, identifier_columns, and temporal_columns are not \
201 supported for database profiling yet"
202 .to_string(),
203 });
204 }
205 if config.batch_size == 0 {
206 return Err(DataProfilerError::invalid_config(
207 "database batch_size must be greater than zero",
208 "Set batch_size to a positive number of rows.",
209 ));
210 }
211 let mut connector = create_connector(config.clone())?;
212
213 connector.connect().await?;
214
215 let start = std::time::Instant::now();
216
217 let (actual_query, is_table) = if query.trim().to_uppercase().starts_with("SELECT") {
218 let validated_query = security::validate_base_query(query)?;
219 (validated_query, false)
220 } else {
221 security::validate_sql_identifier(query)?;
222 (format!("SELECT * FROM {}", query), true)
223 };
224
225 let total_rows = if is_table {
226 match connector.count_table_rows(query).await {
230 Ok(count) => count,
231 Err(e) => {
232 log::warn!(
233 "count_table_rows failed for '{}': {}; row count unknown, sampling disabled",
234 query,
235 e
236 );
237 0
238 }
239 }
240 } else {
241 0
242 };
243
244 let (final_query, sample_info) = if let Some(sampling_config) = &config.sampling_config {
245 if total_rows > sampling_config.sample_size as u64 {
246 let sampled_query = sampling_config.generate_sample_query(&actual_query, total_rows)?;
247 let info = SampleInfo::new(
248 total_rows,
249 sampling_config.sample_size.min(total_rows as usize) as u64,
250 sampling_config.strategy.clone(),
251 );
252 (sampled_query, Some(info))
253 } else {
254 (actual_query, None)
255 }
256 } else {
257 (actual_query, None)
258 };
259
260 let columns = connector
261 .profile_query_streaming(&final_query, config.batch_size)
262 .await?;
263
264 connector.disconnect().await?;
265
266 let query_engine = detect_query_engine(&config.connection_string);
267
268 if columns.is_empty() {
269 let mut exec = ExecutionMetadata::new(0, 0, start.elapsed().as_millis())
270 .with_engine(query_engine.to_string());
271 if let Some(ref info) = sample_info
272 && info.sampling_ratio < 1.0
273 {
274 exec = exec
275 .with_sampling(info.sampling_ratio)
276 .with_source_exhausted(false);
277 }
278 return Ok(ReportAssembler::new(
279 DataSource::Query {
280 engine: query_engine.clone(),
281 statement: query.to_string(),
282 database: extract_database_name(&config.connection_string),
283 execution_id: None,
284 },
285 exec,
286 )
287 .skip_quality()
288 .build());
289 }
290
291 let actual_rows_processed = columns.row_count();
294
295 let column_profiles: Vec<_> = columns
299 .iter()
300 .map(|(name, data)| analyze_column_with_analysis_options(name, data, options))
301 .collect();
302
303 let mut completeness = RowCompletenessTracker::default();
306 let aligned: Vec<&[String]> = columns.values().map(|data| data.as_slice()).collect();
307 completeness.observe_aligned_columns(&aligned, actual_rows_processed);
308 let row_completeness = completeness.summary();
309
310 let scan_time_ms = start.elapsed().as_millis();
311 let sampling_ratio = sample_info.map(|s| s.sampling_ratio).unwrap_or(1.0);
312 let num_columns = column_profiles.len();
313
314 let mut execution = ExecutionMetadata::new(actual_rows_processed, num_columns, scan_time_ms)
315 .with_engine(query_engine.to_string());
316 if sampling_ratio < 1.0 {
317 execution = execution
318 .with_sampling(sampling_ratio)
319 .with_source_exhausted(false);
320 }
321
322 Ok(ReportAssembler::new(
323 DataSource::Query {
324 engine: query_engine,
325 statement: query.to_string(),
326 database: extract_database_name(&config.connection_string),
327 execution_id: None,
328 },
329 execution,
330 )
331 .columns(column_profiles)
332 .with_quality_data(columns.into_map())
335 .with_row_completeness(row_completeness)
336 .with_analysis_options(options)
337 .build())
338}
339
340fn detect_query_engine(connection_string: &str) -> QueryEngine {
342 let conn = connection_string.to_lowercase();
343 if conn.starts_with("postgres") || conn.starts_with("postgresql") {
344 QueryEngine::Postgres
345 } else if conn.starts_with("mysql") || conn.starts_with("mariadb") {
346 QueryEngine::MySql
347 } else if conn.starts_with("sqlite") {
348 QueryEngine::Sqlite
349 } else {
350 QueryEngine::Custom("unknown".to_string())
351 }
352}
353
354fn extract_database_name(connection_string: &str) -> Option<String> {
356 if let Some(pos) = connection_string.rfind('/') {
357 let db_part = &connection_string[pos + 1..];
358 let db_name = db_part.split('?').next().unwrap_or(db_part);
359 if !db_name.is_empty() {
360 return Some(db_name.to_string());
361 }
362 }
363 None
364}