Skip to main content

query_forge/
summary.rs

1use std::collections::{HashMap, HashSet};
2
3use anyhow::Result;
4
5use crate::input;
6use crate::value::display_value;
7use crate::{
8    ColumnInfo, ColumnStats, ExtractionOptions, InferredType, QueryValue, TableSummary,
9    TypeInferenceOptions, WorkbookInput,
10};
11
12/// Load metadata for every input without running a SQL query.
13///
14/// Returns one [`TableSummary`] per input in declaration order.
15pub fn load_table_summaries(
16    workbook_inputs: &[WorkbookInput<'_>],
17    sample: usize,
18    include_stats: bool,
19) -> Result<Vec<TableSummary>> {
20    let mut summaries = Vec::with_capacity(workbook_inputs.len());
21    let inference_options = TypeInferenceOptions::default();
22
23    for (index, workbook_input) in workbook_inputs.iter().enumerate() {
24        let sheet = input::load_input(
25            workbook_input.path,
26            workbook_input.sheet_name,
27            &inference_options,
28            &ExtractionOptions::default(),
29            true,
30        )?;
31
32        let table_name = workbook_input
33            .table_name
34            .map(str::to_owned)
35            .unwrap_or_else(|| {
36                if index == 0 {
37                    "table".to_owned()
38                } else {
39                    format!("table{}", index + 1)
40                }
41            });
42
43        let columns = infer_column_infos(&table_name, &sheet);
44        let mut warnings = collect_warnings(&table_name, &sheet, &columns);
45
46        let sample_rows = sheet.rows.iter().take(sample).cloned().collect();
47
48        let column_stats = if include_stats {
49            Some(compute_column_stats(&sheet))
50        } else {
51            None
52        };
53
54        // Warn about empty tables (non-fatal for inspection).
55        if sheet.rows.is_empty() {
56            warnings.push(format!(
57                "table '{}' loaded from '{}' contains no data rows",
58                table_name,
59                workbook_input.path.display()
60            ));
61        }
62
63        summaries.push(TableSummary {
64            table_name,
65            source_path: workbook_input.path.display().to_string(),
66            source_selector: workbook_input.sheet_name.map(str::to_owned),
67            row_count: sheet.rows.len(),
68            columns,
69            sample_rows,
70            warnings,
71            column_stats,
72        });
73    }
74
75    Ok(summaries)
76}
77
78/// Infer the SQL-compatible type and nullability for each column in `sheet`.
79fn infer_column_infos(table_name: &str, sheet: &input::SheetData) -> Vec<ColumnInfo> {
80    sheet
81        .columns
82        .iter()
83        .enumerate()
84        .map(|(col_idx, col_name)| {
85            let mut has_integer = false;
86            let mut has_real = false;
87            let mut has_text = false;
88            let mut has_null = false;
89
90            for row in &sheet.rows {
91                match row.get(col_idx).unwrap_or(&QueryValue::Null) {
92                    QueryValue::Null => has_null = true,
93                    QueryValue::Integer(_) => has_integer = true,
94                    QueryValue::Real(_) => has_real = true,
95                    QueryValue::Text(_) => has_text = true,
96                }
97            }
98
99            let inferred_type = match (has_integer, has_real, has_text) {
100                (true, false, false) => InferredType::Integer,
101                (false, false, true) => InferredType::Text,
102                (false, false, false) => InferredType::Text,
103                // Integer+Real and Real-only both map to REAL: integers are
104                // promotable to real without loss, so REAL is the wider type.
105                (_, true, false) => InferredType::Real,
106                _ => InferredType::Mixed,
107            };
108
109            ColumnInfo {
110                table_name: table_name.to_owned(),
111                column_name: col_name.clone(),
112                inferred_type,
113                nullable: has_null,
114            }
115        })
116        .collect()
117}
118
119/// Collect non-fatal warnings for a loaded table.
120fn collect_warnings(table_name: &str, sheet: &input::SheetData, columns: &[ColumnInfo]) -> Vec<String> {
121    let mut warnings = Vec::new();
122
123    for col_info in columns {
124        if col_info.inferred_type == InferredType::Mixed {
125            warnings.push(format!(
126                "column '{}' in table '{}' has mixed types (INTEGER/REAL and TEXT values)",
127                col_info.column_name, table_name
128            ));
129        }
130    }
131
132    // Detect duplicate original column names that were de-duplicated by normalisation.
133    let mut seen_names: HashMap<String, usize> = HashMap::new();
134    for col in &sheet.columns {
135        *seen_names.entry(col.clone()).or_insert(0) += 1;
136    }
137    for (name, count) in &seen_names {
138        if *count > 1 {
139            warnings.push(format!(
140                "column name '{}' appears {} times in table '{}'; duplicates were renamed",
141                name, count, table_name
142            ));
143        }
144    }
145
146    warnings
147}
148
149/// Compute per-column distinct counts and min/max values.
150fn compute_column_stats(sheet: &input::SheetData) -> Vec<ColumnStats> {
151    sheet
152        .columns
153        .iter()
154        .enumerate()
155        .map(|(col_idx, _)| {
156            let mut distinct: HashSet<String> = HashSet::new();
157            // Track min/max as f64 to handle both Integer and Real columns
158            // uniformly without reparsing strings.
159            let mut min_num: Option<f64> = None;
160            let mut max_num: Option<f64> = None;
161            // Remember whether the extremes came from an integer so we can
162            // render them without a spurious decimal point.
163            let mut min_as_int: Option<i64> = None;
164            let mut max_as_int: Option<i64> = None;
165
166            for row in &sheet.rows {
167                let value = row.get(col_idx).unwrap_or(&QueryValue::Null);
168                if matches!(value, QueryValue::Null) {
169                    continue;
170                }
171                distinct.insert(display_value(value));
172
173                let (as_f64, as_int) = match value {
174                    QueryValue::Integer(n) => (*n as f64, Some(*n)),
175                    QueryValue::Real(n) => (*n, None),
176                    _ => continue,
177                };
178
179                if min_num.map_or(true, |m| as_f64 < m) {
180                    min_num = Some(as_f64);
181                    min_as_int = as_int;
182                }
183                if max_num.map_or(true, |m| as_f64 > m) {
184                    max_num = Some(as_f64);
185                    max_as_int = as_int;
186                }
187            }
188
189            let min_value =
190                min_num.map(|v| min_as_int.map_or_else(|| v.to_string(), |i| i.to_string()));
191            let max_value =
192                max_num.map(|v| max_as_int.map_or_else(|| v.to_string(), |i| i.to_string()));
193
194            ColumnStats {
195                distinct_count: distinct.len(),
196                min_value,
197                max_value,
198            }
199        })
200        .collect()
201}