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 std::fs;

use anyhow::{Result, anyhow, bail};

use crate::cli::QueryCommand;

pub(crate) fn load_query(cmd: &QueryCommand) -> Result<String> {
    if let Some(sql) = &cmd.sql {
        return Ok(sql.clone());
    }

    if let Some(query_path) = &cmd.sql_file {
        return fs::read_to_string(query_path)
            .map_err(Into::into)
            .map(|content| content.trim().to_owned());
    }

    bail!("provide either --sql or --sql-file")
}

/// Prepends CTE definitions to the SQL when `--with` is used.
/// Each entry in `ctes` should be `name AS (...)` without the leading `WITH`.
pub(crate) fn apply_with_ctes(sql: &str, ctes: &[String]) -> String {
    if ctes.is_empty() {
        return sql.to_owned();
    }
    let cte_block = ctes.join(",\n    ");
    format!("WITH {cte_block}\n{sql}")
}

pub(crate) fn parse_query_params(entries: &[String]) -> Result<Vec<query_forge::QueryParam>> {
    entries
        .iter()
        .map(|entry| {
            let (name, value) = entry
                .split_once('=')
                .ok_or_else(|| anyhow!("invalid --param '{entry}', expected NAME=VALUE"))?;

            if name.trim().is_empty() {
                bail!("invalid --param '{entry}', parameter name cannot be empty");
            }

            Ok(query_forge::QueryParam {
                name: name.trim().to_owned(),
                value: parse_param_value(value.trim()),
            })
        })
        .collect()
}

pub(crate) fn resolve_type_inference_options(
    cmd: &QueryCommand,
) -> 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()
        },
    }
}

pub(crate) fn resolve_input_normalization_options(
    cmd: &QueryCommand,
) -> 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(crate::cli::HeaderCase::Snake) => Some(query_forge::HeaderCase::Snake),
            Some(crate::cli::HeaderCase::Camel) => Some(query_forge::HeaderCase::Camel),
            Some(crate::cli::HeaderCase::Pascal) => Some(query_forge::HeaderCase::Pascal),
            Some(crate::cli::HeaderCase::ScreamingSnake) => {
                Some(query_forge::HeaderCase::ScreamingSnake)
            }
            None => None,
        },
        dedupe_headers: cmd.dedupe_headers,
    }
}

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

fn parse_param_value(raw_value: &str) -> query_forge::QueryValue {
    if raw_value.eq_ignore_ascii_case("null") {
        return query_forge::QueryValue::Null;
    }

    if raw_value.eq_ignore_ascii_case("true") {
        return query_forge::QueryValue::Integer(1);
    }

    if raw_value.eq_ignore_ascii_case("false") {
        return query_forge::QueryValue::Integer(0);
    }

    if let Ok(integer) = raw_value.parse::<i64>() {
        return query_forge::QueryValue::Integer(integer);
    }

    if let Ok(real) = raw_value.parse::<f64>() {
        return query_forge::QueryValue::Real(real);
    }

    query_forge::QueryValue::Text(raw_value.to_owned())
}

#[cfg(test)]
mod tests {
    use clap::Parser;

    use crate::cli::{Cli, Commands};

    use super::{parse_query_params, resolve_input_normalization_options};

    #[test]
    fn parses_query_params() {
        let entries = vec![
            "wbs=W092500011.2.2.4".to_owned(),
            "min=10".to_owned(),
            "enabled=true".to_owned(),
        ];

        let params = parse_query_params(&entries).expect("valid params");

        assert_eq!(params.len(), 3);
        assert_eq!(params[0].name, "wbs");
        assert_eq!(params[1].name, "min");
        assert_eq!(params[2].name, "enabled");
    }

    #[test]
    fn resolves_input_normalization_options_from_cli_flags() {
        let cli = Cli::parse_from([
            "qf",
            "query",
            "--input",
            "data.csv",
            "--sql",
            "SELECT * FROM table",
            "--trim",
            "--skip-empty-rows",
            "--normalize-headers",
            "--header-case",
            "snake",
            "--dedupe-headers",
        ]);

        let Commands::Query(cmd) = cli.command else {
            panic!("expected query command");
        };

        let options = resolve_input_normalization_options(&cmd);
        assert!(options.trim);
        assert!(options.skip_empty_rows);
        assert!(options.normalize_headers);
        assert!(matches!(
            options.header_case,
            Some(query_forge::HeaderCase::Snake)
        ));
        assert!(options.dedupe_headers);
    }

    #[test]
    fn resolves_extraction_options_from_cli_flags() {
        use super::resolve_extraction_options;

        let cli = Cli::parse_from([
            "qf",
            "query",
            "--input",
            "data.json",
            "--sql",
            "SELECT * FROM table",
            "--json-mode",
            "object",
            "--xml-mode",
            "descendants",
        ]);

        let Commands::Query(cmd) = cli.command else {
            panic!("expected query command");
        };

        let opts = resolve_extraction_options(&cmd);
        assert!(matches!(opts.json_mode, query_forge::JsonMode::Object));
        assert!(matches!(opts.xml_mode, query_forge::XmlMode::Descendants));
    }

    #[test]
    fn extraction_options_default_to_array_and_rows_when_not_set() {
        use super::resolve_extraction_options;

        let cli = Cli::parse_from([
            "qf",
            "query",
            "--input",
            "data.json",
            "--sql",
            "SELECT * FROM table",
        ]);

        let Commands::Query(cmd) = cli.command else {
            panic!("expected query command");
        };

        let opts = resolve_extraction_options(&cmd);
        assert!(matches!(opts.json_mode, query_forge::JsonMode::Array));
        assert!(matches!(opts.xml_mode, query_forge::XmlMode::Rows));
    }
}