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                        column_data.push(value.unwrap_or_default());
109                    }
110                }
111            }
112
113            let batch_size_actual = rows.len();
114            all_batches.push(batch_result);
115            progress.update(batch_size_actual as u64);
116
117            if let Some(percentage) = progress.percentage() {
118                log::info!(
119                    "{} streaming progress: {:.1}% ({}/{} rows)",
120                    $db_name,
121                    percentage,
122                    progress.processed_rows,
123                    $total_rows
124                );
125            }
126
127            offset += $batch_size;
128            if batch_size_actual < $batch_size {
129                break;
130            }
131        }
132
133        merge_column_batches(all_batches)
134    }};
135}
136
137/// Macro to process rows into column-oriented HashMap.
138#[macro_export]
139macro_rules! process_rows_to_columns {
140    ($rows:expr) => {{
141        use sqlx::{Column, Row};
142
143        if $rows.is_empty() {
144            std::collections::HashMap::new()
145        } else {
146            let columns = $rows[0].columns();
147            let mut result: std::collections::HashMap<String, Vec<String>> =
148                std::collections::HashMap::with_capacity(columns.len());
149
150            for col in columns {
151                result.insert(col.name().to_string(), Vec::with_capacity($rows.len()));
152            }
153
154            for row in &$rows {
155                for (i, col) in columns.iter().enumerate() {
156                    let value: Option<String> = $crate::db_column_to_string!(row, i);
157                    if let Some(column_data) = result.get_mut(col.name()) {
158                        column_data.push(value.unwrap_or_default());
159                    }
160                }
161            }
162
163            result
164        }
165    }};
166}
167
168/// Build a count query for a given table or query
169#[allow(dead_code)]
170pub fn build_count_query(query: &str) -> Result<String, DataProfilerError> {
171    if query.trim().to_uppercase().starts_with("SELECT") {
172        let validated_query = validate_base_query(query)?;
173        Ok(format!(
174            "SELECT COUNT(*) FROM ({}) as count_subquery",
175            validated_query
176        ))
177    } else {
178        validate_sql_identifier(query)?;
179        Ok(format!("SELECT COUNT(*) FROM {}", query))
180    }
181}
182
183/// Build a batch query with LIMIT and OFFSET
184#[allow(dead_code)]
185pub fn build_batch_query(
186    query: &str,
187    batch_size: usize,
188    offset: usize,
189) -> Result<String, DataProfilerError> {
190    let validated_query = if query.trim().to_uppercase().starts_with("SELECT") {
191        validate_base_query(query)?
192    } else {
193        validate_sql_identifier(query)?;
194        format!("SELECT * FROM {}", query)
195    };
196    Ok(format!(
197        "{} LIMIT {} OFFSET {}",
198        validated_query, batch_size, offset
199    ))
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_build_count_query_table() {
208        let result = build_count_query("users").unwrap();
209        assert_eq!(result, "SELECT COUNT(*) FROM users");
210    }
211
212    #[test]
213    fn test_build_count_query_select() {
214        let result = build_count_query("SELECT * FROM users WHERE active = true").unwrap();
215        assert!(result.contains("SELECT COUNT(*) FROM"));
216        assert!(result.contains("count_subquery"));
217    }
218
219    #[test]
220    fn test_build_batch_query() {
221        let result = build_batch_query("users", 100, 0).unwrap();
222        assert_eq!(result, "SELECT * FROM users LIMIT 100 OFFSET 0");
223    }
224}