Skip to main content

dataprof_db/connectors/
common.rs

1//! Common utilities and shared logic for database connectors
2//!
3//! This module provides reusable functions to reduce code duplication across
4//! PostgreSQL, MySQL, and SQLite connectors.
5
6use crate::DataProfilerError;
7use crate::security::{validate_base_query, validate_sql_identifier};
8
9/// Generate "not connected to database" error
10#[allow(dead_code)]
11pub fn not_connected_error() -> DataProfilerError {
12    DataProfilerError::database_connection("Not connected to database")
13}
14
15/// Generate feature-not-enabled error for a specific database
16#[allow(dead_code)]
17pub fn feature_not_enabled_error(db_name: &str, feature: &str) -> DataProfilerError {
18    DataProfilerError::database_feature_disabled(db_name, feature)
19}
20
21/// Render one column of one row as the string the profiler ingests.
22///
23/// The profiler consumes columns as `Vec<String>` and re-infers types from the
24/// textual form, so every SQL type has to be turned into a faithful string.
25/// sqlx offers no backend-agnostic "decode as whatever this is" call, so we try
26/// concrete types in order and take the first that decodes.
27///
28/// Order is load-bearing:
29///
30/// * `Option<String>` first. sqlx skips the type-compatibility check when the
31///   value is NULL, so a NULL of *any* SQL type decodes here as `Ok(None)`.
32///   That makes this arm the NULL detector as well as the text arm; everything
33///   below it is known to be a non-null value.
34/// * Integers before `bool`. SQLite stores booleans as INTEGER and will happily
35///   decode `42` as `true`, so trying `bool` early would render integers as
36///   "true"/"false".
37/// * Widest integer first, so an INT8/BIGINT is not truncated by a narrower arm.
38///
39/// A value whose type matches none of these arms (NUMERIC/DECIMAL, dates and
40/// times without the corresponding sqlx feature, BLOB) yields `None` and is
41/// recorded as a null, which is the pre-existing behaviour for those types.
42#[macro_export]
43macro_rules! db_column_to_string {
44    ($row:expr, $index:expr) => {{
45        let row = $row;
46        let index = $index;
47
48        if let Ok(v) = row.try_get::<Option<String>, _>(index) {
49            v
50        } else if let Ok(v) = row.try_get::<Option<i64>, _>(index) {
51            v.map(|x| x.to_string())
52        } else if let Ok(v) = row.try_get::<Option<i32>, _>(index) {
53            v.map(|x| x.to_string())
54        } else if let Ok(v) = row.try_get::<Option<i16>, _>(index) {
55            v.map(|x| x.to_string())
56        } else if let Ok(v) = row.try_get::<Option<f64>, _>(index) {
57            // `{:?}` keeps the decimal point on integral floats ("100.0", not
58            // "100"), so a REAL column of whole numbers is still inferred as a
59            // float downstream. It also stays compact at the extremes ("1e300").
60            v.map(|x| format!("{:?}", x))
61        } else if let Ok(v) = row.try_get::<Option<f32>, _>(index) {
62            v.map(|x| format!("{:?}", x))
63        } else if let Ok(v) = row.try_get::<Option<bool>, _>(index) {
64            v.map(|x| x.to_string())
65        } else {
66            None
67        }
68    }};
69}
70
71/// Macro to generate the streaming batch loop for profiling queries.
72#[macro_export]
73macro_rules! streaming_profile_loop {
74    ($pool:expr, $query:expr, $batch_size:expr, $total_rows:expr, $db_name:literal) => {{
75        use sqlx::{Column, Row};
76        use $crate::connectors::common::build_batch_query;
77        use $crate::streaming::{StreamingProgress, merge_column_batches};
78
79        let mut progress = StreamingProgress::new(Some($total_rows as u64));
80        let mut all_batches: Vec<std::collections::HashMap<String, Vec<String>>> = Vec::new();
81        let mut offset = 0usize;
82
83        loop {
84            let batch_query = build_batch_query($query, $batch_size, offset)?;
85            let rows = sqlx::query(&batch_query)
86                .fetch_all($pool)
87                .await
88                .map_err(|e| $crate::DataProfilerError::DatabaseQueryError {
89                    message: format!("Batch query execution failed: {}", e),
90                })?;
91
92            if rows.is_empty() {
93                break;
94            }
95
96            let columns = rows[0].columns();
97            let mut batch_result: std::collections::HashMap<String, Vec<String>> =
98                std::collections::HashMap::with_capacity(columns.len());
99
100            for col in columns {
101                batch_result.insert(col.name().to_string(), Vec::with_capacity(rows.len()));
102            }
103
104            for row in &rows {
105                for (i, col) in columns.iter().enumerate() {
106                    let value: Option<String> = $crate::db_column_to_string!(row, i);
107                    if let Some(column_data) = batch_result.get_mut(col.name()) {
108                        // decode-audit: no-data — None is SQL NULL (or a type
109                        // db_column_to_string documents as unsupported); "" is
110                        // the profiler's textual null.
111                        column_data.push(value.unwrap_or_default());
112                    }
113                }
114            }
115
116            let batch_size_actual = rows.len();
117            all_batches.push(batch_result);
118            progress.update(batch_size_actual as u64);
119
120            if let Some(percentage) = progress.percentage() {
121                log::info!(
122                    "{} streaming progress: {:.1}% ({}/{} rows)",
123                    $db_name,
124                    percentage,
125                    progress.processed_rows,
126                    $total_rows
127                );
128            }
129
130            offset += $batch_size;
131            if batch_size_actual < $batch_size {
132                break;
133            }
134        }
135
136        merge_column_batches(all_batches)
137    }};
138}
139
140/// Macro to process rows into column-oriented HashMap.
141#[macro_export]
142macro_rules! process_rows_to_columns {
143    ($rows:expr) => {{
144        use sqlx::{Column, Row};
145
146        if $rows.is_empty() {
147            std::collections::HashMap::new()
148        } else {
149            let columns = $rows[0].columns();
150            let mut result: std::collections::HashMap<String, Vec<String>> =
151                std::collections::HashMap::with_capacity(columns.len());
152
153            for col in columns {
154                result.insert(col.name().to_string(), Vec::with_capacity($rows.len()));
155            }
156
157            for row in &$rows {
158                for (i, col) in columns.iter().enumerate() {
159                    let value: Option<String> = $crate::db_column_to_string!(row, i);
160                    if let Some(column_data) = result.get_mut(col.name()) {
161                        // decode-audit: no-data — None is SQL NULL (or a type
162                        // db_column_to_string documents as unsupported); "" is
163                        // the profiler's textual null.
164                        column_data.push(value.unwrap_or_default());
165                    }
166                }
167            }
168
169            result
170        }
171    }};
172}
173
174/// Build a count query for a given table or query
175#[allow(dead_code)]
176pub fn build_count_query(query: &str) -> Result<String, DataProfilerError> {
177    if query.trim().to_uppercase().starts_with("SELECT") {
178        let validated_query = validate_base_query(query)?;
179        Ok(format!(
180            "SELECT COUNT(*) FROM ({}) as count_subquery",
181            validated_query
182        ))
183    } else {
184        validate_sql_identifier(query)?;
185        Ok(format!("SELECT COUNT(*) FROM {}", query))
186    }
187}
188
189/// Build a batch query with LIMIT and OFFSET
190#[allow(dead_code)]
191pub fn build_batch_query(
192    query: &str,
193    batch_size: usize,
194    offset: usize,
195) -> Result<String, DataProfilerError> {
196    let validated_query = if query.trim().to_uppercase().starts_with("SELECT") {
197        validate_base_query(query)?
198    } else {
199        validate_sql_identifier(query)?;
200        format!("SELECT * FROM {}", query)
201    };
202    Ok(format!(
203        "{} LIMIT {} OFFSET {}",
204        validated_query, batch_size, offset
205    ))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_build_count_query_table() {
214        let result = build_count_query("users").unwrap();
215        assert_eq!(result, "SELECT COUNT(*) FROM users");
216    }
217
218    #[test]
219    fn test_build_count_query_select() {
220        let result = build_count_query("SELECT * FROM users WHERE active = true").unwrap();
221        assert!(result.contains("SELECT COUNT(*) FROM"));
222        assert!(result.contains("count_subquery"));
223    }
224
225    #[test]
226    fn test_build_batch_query() {
227        let result = build_batch_query("users", 100, 0).unwrap();
228        assert_eq!(result, "SELECT * FROM users LIMIT 100 OFFSET 0");
229    }
230}