query-forge 0.5.0

Run SQL queries on XLSX/XML/CSV/JSON/JSONL/Markdown/HTML/Parquet inputs and export results as text, CSV, JSONL, Markdown, XML, HTML, XLSX, or Parquet
Documentation
use std::collections::{HashMap, HashSet};

use anyhow::Result;

use crate::input;
use crate::value::display_value;
use crate::{
    ColumnInfo, ColumnStats, ExtractionOptions, InferredType, QueryValue, TableSummary,
    TypeInferenceOptions, WorkbookInput,
};

/// Load metadata for every input without running a SQL query.
///
/// Returns one [`TableSummary`] per input in declaration order.
pub fn load_table_summaries(
    workbook_inputs: &[WorkbookInput<'_>],
    sample: usize,
    include_stats: bool,
) -> Result<Vec<TableSummary>> {
    let mut summaries = Vec::with_capacity(workbook_inputs.len());
    let inference_options = TypeInferenceOptions::default();

    for (index, workbook_input) in workbook_inputs.iter().enumerate() {
        let sheet = input::load_input(
            workbook_input.path,
            workbook_input.sheet_name,
            &inference_options,
            &ExtractionOptions::default(),
            true,
        )?;

        let table_name = workbook_input
            .table_name
            .map(str::to_owned)
            .unwrap_or_else(|| {
                if index == 0 {
                    "table".to_owned()
                } else {
                    format!("table{}", index + 1)
                }
            });

        let columns = infer_column_infos(&table_name, &sheet);
        let mut warnings = collect_warnings(&table_name, &sheet, &columns);

        let sample_rows = sheet.rows.iter().take(sample).cloned().collect();

        let column_stats = if include_stats {
            Some(compute_column_stats(&sheet))
        } else {
            None
        };

        // Warn about empty tables (non-fatal for inspection).
        if sheet.rows.is_empty() {
            warnings.push(format!(
                "table '{}' loaded from '{}' contains no data rows",
                table_name,
                workbook_input.path.display()
            ));
        }

        summaries.push(TableSummary {
            table_name,
            source_path: workbook_input.path.display().to_string(),
            source_selector: workbook_input.sheet_name.map(str::to_owned),
            row_count: sheet.rows.len(),
            columns,
            sample_rows,
            warnings,
            column_stats,
        });
    }

    Ok(summaries)
}

/// Infer the SQL-compatible type and nullability for each column in `sheet`.
fn infer_column_infos(table_name: &str, sheet: &input::SheetData) -> Vec<ColumnInfo> {
    sheet
        .columns
        .iter()
        .enumerate()
        .map(|(col_idx, col_name)| {
            let mut has_integer = false;
            let mut has_real = false;
            let mut has_text = false;
            let mut has_null = false;

            for row in &sheet.rows {
                match row.get(col_idx).unwrap_or(&QueryValue::Null) {
                    QueryValue::Null => has_null = true,
                    QueryValue::Integer(_) => has_integer = true,
                    QueryValue::Real(_) => has_real = true,
                    QueryValue::Text(_) => has_text = true,
                }
            }

            let inferred_type = match (has_integer, has_real, has_text) {
                (true, false, false) => InferredType::Integer,
                (false, false, true) => InferredType::Text,
                (false, false, false) => InferredType::Text,
                // Integer+Real and Real-only both map to REAL: integers are
                // promotable to real without loss, so REAL is the wider type.
                (_, true, false) => InferredType::Real,
                _ => InferredType::Mixed,
            };

            ColumnInfo {
                table_name: table_name.to_owned(),
                column_name: col_name.clone(),
                inferred_type,
                nullable: has_null,
            }
        })
        .collect()
}

/// Collect non-fatal warnings for a loaded table.
fn collect_warnings(table_name: &str, sheet: &input::SheetData, columns: &[ColumnInfo]) -> Vec<String> {
    let mut warnings = Vec::new();

    for col_info in columns {
        if col_info.inferred_type == InferredType::Mixed {
            warnings.push(format!(
                "column '{}' in table '{}' has mixed types (INTEGER/REAL and TEXT values)",
                col_info.column_name, table_name
            ));
        }
    }

    // Detect duplicate original column names that were de-duplicated by normalisation.
    let mut seen_names: HashMap<String, usize> = HashMap::new();
    for col in &sheet.columns {
        *seen_names.entry(col.clone()).or_insert(0) += 1;
    }
    for (name, count) in &seen_names {
        if *count > 1 {
            warnings.push(format!(
                "column name '{}' appears {} times in table '{}'; duplicates were renamed",
                name, count, table_name
            ));
        }
    }

    warnings
}

/// Compute per-column distinct counts and min/max values.
fn compute_column_stats(sheet: &input::SheetData) -> Vec<ColumnStats> {
    sheet
        .columns
        .iter()
        .enumerate()
        .map(|(col_idx, _)| {
            let mut distinct: HashSet<String> = HashSet::new();
            // Track min/max as f64 to handle both Integer and Real columns
            // uniformly without reparsing strings.
            let mut min_num: Option<f64> = None;
            let mut max_num: Option<f64> = None;
            // Remember whether the extremes came from an integer so we can
            // render them without a spurious decimal point.
            let mut min_as_int: Option<i64> = None;
            let mut max_as_int: Option<i64> = None;

            for row in &sheet.rows {
                let value = row.get(col_idx).unwrap_or(&QueryValue::Null);
                if matches!(value, QueryValue::Null) {
                    continue;
                }
                distinct.insert(display_value(value));

                let (as_f64, as_int) = match value {
                    QueryValue::Integer(n) => (*n as f64, Some(*n)),
                    QueryValue::Real(n) => (*n, None),
                    _ => continue,
                };

                if min_num.map_or(true, |m| as_f64 < m) {
                    min_num = Some(as_f64);
                    min_as_int = as_int;
                }
                if max_num.map_or(true, |m| as_f64 > m) {
                    max_num = Some(as_f64);
                    max_as_int = as_int;
                }
            }

            let min_value =
                min_num.map(|v| min_as_int.map_or_else(|| v.to_string(), |i| i.to_string()));
            let max_value =
                max_num.map(|v| max_as_int.map_or_else(|| v.to_string(), |i| i.to_string()));

            ColumnStats {
                distinct_count: distinct.len(),
                min_value,
                max_value,
            }
        })
        .collect()
}