Skip to main content

dataprof_db/
lib.rs

1//! Database connectivity module for dataprof.
2//!
3//! This crate owns the database profiling surface, including connection
4//! handling, secure configuration helpers, sampling, streaming, and the
5//! feature-gated sqlx-based connectors.
6
7pub(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/// Database configuration for connection strings and settings
30#[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/// Trait that all database connectors must implement
58#[async_trait::async_trait]
59pub trait DatabaseConnector: Send + Sync {
60    /// Connect to the database
61    async fn connect(&mut self) -> Result<(), DataProfilerError>;
62
63    /// Disconnect from the database
64    async fn disconnect(&mut self) -> Result<(), DataProfilerError>;
65
66    /// Execute a query and get column data for profiling
67    async fn profile_query(&mut self, query: &str) -> Result<QueryColumns, DataProfilerError>;
68
69    /// Execute a query with streaming for large result sets
70    async fn profile_query_streaming(
71        &mut self,
72        query: &str,
73        batch_size: usize,
74    ) -> Result<QueryColumns, DataProfilerError>;
75
76    /// Get table schema information
77    async fn get_table_schema(
78        &mut self,
79        table_name: &str,
80    ) -> Result<Vec<String>, DataProfilerError>;
81
82    /// Count total rows in table (for progress tracking)
83    async fn count_table_rows(&mut self, table_name: &str) -> Result<u64, DataProfilerError>;
84
85    /// Test connection
86    async fn test_connection(&mut self) -> Result<bool, DataProfilerError>;
87}
88
89/// Factory function to create appropriate database connector
90pub 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
121/// Apply environment configuration to database config
122fn 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
163/// High-level function to analyze a database table or query.
164pub 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        // Everything but quality, spelled as packs so the selection travels as
173        // one value from here on.
174        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
184/// Analyze a database table or query, honouring the caller's full analysis
185/// selection.
186///
187/// This is the entry point that carries metric packs and locale as well as
188/// quality dimensions, so a query profile reports exactly the analysis the
189/// caller asked for — the same selection every file path applies.
190pub async fn analyze_database_with_options(
191    config: DatabaseConfig,
192    query: &str,
193    options: &AnalysisOptions,
194) -> Result<ProfileReport, DataProfilerError> {
195    // Rejected here rather than in the caller: this is the boundary that lacks
196    // the capability, and a hint that reached the connectors would be silently
197    // ignored. Checked before connecting so nothing is scanned first.
198    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        // decode-audit: unknown — a failed COUNT means "row count unknown", not
227        // "zero rows". 0 disables sampling below, so log the failure instead of
228        // silently pretending the table is empty.
229        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    // decode-audit: no-data — the empty-columns case returned early above, so
292    // this default is only a guard; every column vec has the same length.
293    let actual_rows_processed = columns.row_count();
294
295    // `columns` keeps the query's column order, so the report reports columns in
296    // the order they were selected — the same source-order contract CSV, Parquet
297    // and JSON honour.
298    let column_profiles: Vec<_> = columns
299        .iter()
300        .map(|(name, data)| analyze_column_with_analysis_options(name, data, options))
301        .collect();
302
303    // The query result is row-aligned and holds every value, so complete
304    // records can be counted directly rather than bounded from null totals.
305    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    // Quality metrics look every column up by name and never iterate for
333    // presentation, so dropping the order here costs nothing.
334    .with_quality_data(columns.into_map())
335    .with_row_completeness(row_completeness)
336    .with_analysis_options(options)
337    .build())
338}
339
340/// Detect query engine from connection string
341fn 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
354/// Extract database name from connection string
355fn 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}