query-forge 0.9.0

Run SQL queries and dataset diffs on XLSX/XML/CSV/JSON/JSONL/Markdown/HTML/Feather/Parquet inputs and export results as text, CSV, JSONL, Markdown, XML, HTML, XLSX, Feather, or Parquet
Documentation
use anyhow::Result;

use crate::cli::{ConvCommand, HeaderCase, JsonMode, XmlMode};
use crate::input_spec::parse_input_specs;
use crate::output::{parse_output_path_spec, write_query_result};

pub(crate) fn run_conv_command(cmd: ConvCommand) -> Result<()> {
    let result = execute_conv_command(&cmd)?;
    let (output_path, output_format_hint) = parse_output_path_spec(cmd.output.as_deref());
    write_query_result(
        &result,
        output_path.as_deref(),
        cmd.format,
        output_format_hint.as_deref(),
    )?;

    Ok(())
}

pub(crate) fn execute_conv_command(cmd: &ConvCommand) -> Result<query_forge::QueryResult> {
    let type_inference_options = resolve_type_inference_options(&cmd);
    let input_normalization_options = resolve_input_normalization_options(&cmd);
    let extraction_options = resolve_extraction_options(&cmd);

    let input_specs = parse_input_specs(&cmd.input, None)?;
    let workbook_inputs = input_specs
        .iter()
        .map(|input| query_forge::WorkbookInput {
            path: input.path.as_path(),
            sheet_name: input.sheet_name.as_deref(),
            table_name: input.table_name.as_deref(),
            explicit_format: input.explicit_format.as_deref(),
        })
        .collect::<Vec<_>>();

    let sql = build_select_all_sql(&workbook_inputs);

    query_forge::run_query_with_params_multi_inputs_and_options_and_normalization(
        &workbook_inputs,
        &sql,
        &[],
        &type_inference_options,
        &input_normalization_options,
        &extraction_options,
        !cmd.no_headers,
    )
}

/// Builds a `SELECT * FROM table [UNION ALL SELECT * FROM table2 ...]` query
/// that reads all rows from each input in order.
fn build_select_all_sql(inputs: &[query_forge::WorkbookInput<'_>]) -> String {
    let table_names: Vec<String> = inputs
        .iter()
        .enumerate()
        .map(|(i, input)| {
            if let Some(name) = input.table_name {
                name.to_owned()
            } else if i == 0 {
                "table".to_owned()
            } else {
                format!("table{}", i + 1)
            }
        })
        .collect();

    if table_names.len() == 1 {
        format!("SELECT * FROM \"{}\"", table_names[0])
    } else {
        table_names
            .iter()
            .map(|name| format!("SELECT * FROM \"{name}\""))
            .collect::<Vec<_>>()
            .join(" UNION ALL ")
    }
}

fn resolve_type_inference_options(cmd: &ConvCommand) -> query_forge::TypeInferenceOptions {
    let defaults = query_forge::TypeInferenceOptions::default();

    query_forge::TypeInferenceOptions {
        infer_types: !cmd.all_text || cmd.infer_types,
        decimal_comma: cmd.decimal_comma,
        date_format: cmd.date_format.clone(),
        null_values: if cmd.null_values.is_empty() {
            defaults.null_values
        } else {
            cmd.null_values
                .iter()
                .map(|value| value.trim().to_owned())
                .collect()
        },
        true_values: if cmd.true_values.is_empty() {
            defaults.true_values
        } else {
            cmd.true_values
                .iter()
                .map(|value| value.trim().to_owned())
                .collect()
        },
        false_values: if cmd.false_values.is_empty() {
            defaults.false_values
        } else {
            cmd.false_values
                .iter()
                .map(|value| value.trim().to_owned())
                .collect()
        },
    }
}

fn resolve_input_normalization_options(
    cmd: &ConvCommand,
) -> query_forge::InputNormalizationOptions {
    query_forge::InputNormalizationOptions {
        trim: cmd.trim,
        skip_empty_rows: cmd.skip_empty_rows,
        normalize_headers: cmd.normalize_headers,
        header_case: match cmd.header_case {
            Some(HeaderCase::Snake) => Some(query_forge::HeaderCase::Snake),
            Some(HeaderCase::Camel) => Some(query_forge::HeaderCase::Camel),
            Some(HeaderCase::Pascal) => Some(query_forge::HeaderCase::Pascal),
            Some(HeaderCase::ScreamingSnake) => Some(query_forge::HeaderCase::ScreamingSnake),
            None => None,
        },
        dedupe_headers: cmd.dedupe_headers,
    }
}

fn resolve_extraction_options(cmd: &ConvCommand) -> query_forge::ExtractionOptions {
    query_forge::ExtractionOptions {
        json_mode: match cmd.json_mode {
            Some(JsonMode::Array) | None => query_forge::JsonMode::Array,
            Some(JsonMode::Object) => query_forge::JsonMode::Object,
            Some(JsonMode::Flatten) => query_forge::JsonMode::Flatten,
        },
        xml_mode: match cmd.xml_mode {
            Some(XmlMode::Rows) | None => query_forge::XmlMode::Rows,
            Some(XmlMode::Descendants) => query_forge::XmlMode::Descendants,
            Some(XmlMode::Attributes) => query_forge::XmlMode::Attributes,
        },
    }
}

#[cfg(test)]
mod tests {
    use super::build_select_all_sql;

    fn make_input<'a>(
        table_name: Option<&'a str>,
    ) -> query_forge::WorkbookInput<'a> {
        query_forge::WorkbookInput {
            path: std::path::Path::new("dummy.csv"),
            sheet_name: None,
            table_name,
            explicit_format: None,
        }
    }

    #[test]
    fn single_input_no_name_becomes_table() {
        let inputs = vec![make_input(None)];
        assert_eq!(build_select_all_sql(&inputs), "SELECT * FROM \"table\"");
    }

    #[test]
    fn single_input_with_explicit_name() {
        let inputs = vec![make_input(Some("sales"))];
        assert_eq!(build_select_all_sql(&inputs), "SELECT * FROM \"sales\"");
    }

    #[test]
    fn two_inputs_unnamed_produce_union_all() {
        let inputs = vec![make_input(None), make_input(None)];
        assert_eq!(
            build_select_all_sql(&inputs),
            "SELECT * FROM \"table\" UNION ALL SELECT * FROM \"table2\""
        );
    }

    #[test]
    fn three_inputs_unnamed() {
        let inputs = vec![make_input(None), make_input(None), make_input(None)];
        assert_eq!(
            build_select_all_sql(&inputs),
            "SELECT * FROM \"table\" UNION ALL SELECT * FROM \"table2\" UNION ALL SELECT * FROM \"table3\""
        );
    }

    #[test]
    fn two_inputs_with_explicit_names() {
        let inputs = vec![make_input(Some("a")), make_input(Some("b"))];
        assert_eq!(
            build_select_all_sql(&inputs),
            "SELECT * FROM \"a\" UNION ALL SELECT * FROM \"b\""
        );
    }
}