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