Skip to main content

query_forge/
lib.rs

1use std::path::Path;
2use std::{collections::HashSet, fmt::Write as _, fs, sync::OnceLock};
3
4use anyhow::{Context, Result, anyhow, bail};
5use calamine::{Data, Reader, open_workbook_auto};
6use chrono::{NaiveDate, NaiveDateTime};
7use parquet::{
8    file::reader::{FileReader, SerializedFileReader},
9    record::Field,
10};
11use regex::Regex;
12use roxmltree::{Document, Node};
13use rusqlite::{
14    Connection, params_from_iter,
15    types::{Value, ValueRef},
16};
17use rust_xlsxwriter::Workbook;
18use serde_json::Value as JsonValue;
19
20#[derive(Debug, Clone, PartialEq)]
21pub enum QueryValue {
22    Null,
23    Integer(i64),
24    Real(f64),
25    Text(String),
26}
27
28#[derive(Debug, Clone, PartialEq)]
29pub struct QueryResult {
30    pub columns: Vec<String>,
31    pub rows: Vec<Vec<QueryValue>>,
32}
33
34#[derive(Debug, Clone, PartialEq)]
35pub struct QueryParam {
36    pub name: String,
37    pub value: QueryValue,
38}
39
40#[derive(Debug, Clone, Copy)]
41pub struct WorkbookInput<'a> {
42    pub path: &'a Path,
43    pub sheet_name: Option<&'a str>,
44    pub table_name: Option<&'a str>,
45}
46
47#[derive(Debug, Clone)]
48pub struct TypeInferenceOptions {
49    pub infer_types: bool,
50    pub decimal_comma: bool,
51    pub date_format: Option<String>,
52    pub null_values: Vec<String>,
53    pub true_values: Vec<String>,
54    pub false_values: Vec<String>,
55}
56
57impl Default for TypeInferenceOptions {
58    fn default() -> Self {
59        Self {
60            infer_types: true,
61            decimal_comma: false,
62            date_format: None,
63            null_values: vec![String::new()],
64            true_values: vec!["true".to_owned()],
65            false_values: vec!["false".to_owned()],
66        }
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum HeaderCase {
72    Snake,
73}
74
75#[derive(Debug, Clone, Default)]
76pub struct InputNormalizationOptions {
77    pub trim: bool,
78    pub skip_empty_rows: bool,
79    pub normalize_headers: bool,
80    pub header_case: Option<HeaderCase>,
81    pub dedupe_headers: bool,
82}
83
84/// Controls how JSON input files are parsed into rows.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
86pub enum JsonMode {
87    /// Each element of a top-level JSON array becomes a row (default).
88    ///
89    /// If the root is an object the object itself becomes a single row.
90    #[default]
91    Array,
92    /// Each key-value pair of a JSON object becomes a row with "key" and "value" columns.
93    ///
94    /// Useful for flat configuration or metadata objects.
95    Object,
96    /// Recursively flatten nested JSON objects and arrays into a single row per top-level
97    /// element, using dotted key paths (e.g. `address.city`) for nested fields.
98    Flatten,
99}
100
101/// Controls how XML input files are parsed into rows.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
103pub enum XmlMode {
104    /// Detect and extract tabular rows from the XML structure (default).
105    ///
106    /// Looks first for direct `<row>` children, then for any element whose direct
107    /// children are all leaf elements (no deeper nesting).
108    #[default]
109    Rows,
110    /// Collect every leaf text element in the document as a separate row.
111    ///
112    /// Each row has two columns: `tag` (element name) and `value` (text content).
113    Descendants,
114    /// Extract XML element attributes as columns.
115    ///
116    /// Every element that carries at least one XML attribute becomes a row; attribute
117    /// names map to column names. Text content of the element is also included as a
118    /// `value` column when present.
119    Attributes,
120}
121
122/// Options that control how JSON and XML inputs are extracted into rows.
123#[derive(Debug, Clone, Default)]
124pub struct ExtractionOptions {
125    pub json_mode: JsonMode,
126    pub xml_mode: XmlMode,
127}
128
129/// The SQL-compatible type inferred for a column.
130#[derive(Debug, Clone, PartialEq)]
131pub enum InferredType {
132    Integer,
133    Real,
134    Text,
135    /// Column contains a mix of numeric and text values.
136    Mixed,
137}
138
139impl InferredType {
140    pub fn as_str(&self) -> &'static str {
141        match self {
142            InferredType::Integer => "INTEGER",
143            InferredType::Real => "REAL",
144            InferredType::Text => "TEXT",
145            InferredType::Mixed => "MIXED",
146        }
147    }
148}
149
150/// Per-column information returned by inspection commands.
151#[derive(Debug, Clone)]
152pub struct ColumnInfo {
153    pub table_name: String,
154    pub column_name: String,
155    pub inferred_type: InferredType,
156    pub nullable: bool,
157}
158
159/// Optional per-column statistics (enabled by `--stats`).
160#[derive(Debug, Clone)]
161pub struct ColumnStats {
162    pub distinct_count: usize,
163    pub min_value: Option<String>,
164    pub max_value: Option<String>,
165}
166
167/// Summary of a single loaded table, returned by `load_table_summaries`.
168#[derive(Debug, Clone)]
169pub struct TableSummary {
170    /// The logical SQL table name (explicit or auto-generated).
171    pub table_name: String,
172    /// The original file path as provided on the command line.
173    pub source_path: String,
174    /// The sheet/tag/key/index selector, if any.
175    pub source_selector: Option<String>,
176    pub row_count: usize,
177    pub columns: Vec<ColumnInfo>,
178    /// Up to `sample` rows of actual data (column values in column order).
179    pub sample_rows: Vec<Vec<QueryValue>>,
180    /// Non-fatal warnings (e.g. mixed-type columns, duplicate header normalisations).
181    pub warnings: Vec<String>,
182    /// Per-column statistics; `None` when `--stats` is not requested.
183    pub column_stats: Option<Vec<ColumnStats>>,
184}
185
186/// Load metadata for every input without running a SQL query.
187///
188/// Returns one [`TableSummary`] per input in declaration order.
189pub fn load_table_summaries(
190    workbook_inputs: &[WorkbookInput<'_>],
191    sample: usize,
192    include_stats: bool,
193) -> Result<Vec<TableSummary>> {
194    let mut summaries = Vec::with_capacity(workbook_inputs.len());
195    let inference_options = TypeInferenceOptions::default();
196
197    for (index, workbook_input) in workbook_inputs.iter().enumerate() {
198        let sheet = load_input(
199            workbook_input.path,
200            workbook_input.sheet_name,
201            &inference_options,
202            &ExtractionOptions::default(),
203            true,
204        )?;
205
206        let table_name = workbook_input
207            .table_name
208            .map(str::to_owned)
209            .unwrap_or_else(|| {
210                if index == 0 {
211                    "table".to_owned()
212                } else {
213                    format!("table{}", index + 1)
214                }
215            });
216
217        let columns = infer_column_infos(&table_name, &sheet);
218        let mut warnings = collect_warnings(&table_name, &sheet, &columns);
219
220        let sample_rows = sheet.rows.iter().take(sample).cloned().collect();
221
222        let column_stats = if include_stats {
223            Some(compute_column_stats(&sheet))
224        } else {
225            None
226        };
227
228        // Warn about empty tables (non-fatal for inspection).
229        if sheet.rows.is_empty() {
230            warnings.push(format!(
231                "table '{}' loaded from '{}' contains no data rows",
232                table_name,
233                workbook_input.path.display()
234            ));
235        }
236
237        summaries.push(TableSummary {
238            table_name,
239            source_path: workbook_input.path.display().to_string(),
240            source_selector: workbook_input.sheet_name.map(str::to_owned),
241            row_count: sheet.rows.len(),
242            columns,
243            sample_rows,
244            warnings,
245            column_stats,
246        });
247    }
248
249    Ok(summaries)
250}
251
252/// Infer the SQL-compatible type and nullability for each column in `sheet`.
253fn infer_column_infos(table_name: &str, sheet: &SheetData) -> Vec<ColumnInfo> {
254    sheet
255        .columns
256        .iter()
257        .enumerate()
258        .map(|(col_idx, col_name)| {
259            let mut has_integer = false;
260            let mut has_real = false;
261            let mut has_text = false;
262            let mut has_null = false;
263
264            for row in &sheet.rows {
265                match row.get(col_idx).unwrap_or(&QueryValue::Null) {
266                    QueryValue::Null => has_null = true,
267                    QueryValue::Integer(_) => has_integer = true,
268                    QueryValue::Real(_) => has_real = true,
269                    QueryValue::Text(_) => has_text = true,
270                }
271            }
272
273            let inferred_type = match (has_integer, has_real, has_text) {
274                (true, false, false) => InferredType::Integer,
275                (false, false, true) => InferredType::Text,
276                (false, false, false) => InferredType::Text,
277                // Integer+Real and Real-only both map to REAL: integers are
278                // promotable to real without loss, so REAL is the wider type.
279                (_, true, false) => InferredType::Real,
280                _ => InferredType::Mixed,
281            };
282
283            ColumnInfo {
284                table_name: table_name.to_owned(),
285                column_name: col_name.clone(),
286                inferred_type,
287                nullable: has_null,
288            }
289        })
290        .collect()
291}
292
293/// Collect non-fatal warnings for a loaded table.
294fn collect_warnings(table_name: &str, sheet: &SheetData, columns: &[ColumnInfo]) -> Vec<String> {
295    let mut warnings = Vec::new();
296
297    for col_info in columns {
298        if col_info.inferred_type == InferredType::Mixed {
299            warnings.push(format!(
300                "column '{}' in table '{}' has mixed types (INTEGER/REAL and TEXT values)",
301                col_info.column_name, table_name
302            ));
303        }
304    }
305
306    // Detect duplicate original column names that were de-duplicated by normalisation.
307    let mut seen_names: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
308    for col in &sheet.columns {
309        *seen_names.entry(col.clone()).or_insert(0) += 1;
310    }
311    for (name, count) in &seen_names {
312        if *count > 1 {
313            warnings.push(format!(
314                "column name '{}' appears {} times in table '{}'; duplicates were renamed",
315                name, count, table_name
316            ));
317        }
318    }
319
320    warnings
321}
322
323/// Compute per-column distinct counts and min/max values.
324fn compute_column_stats(sheet: &SheetData) -> Vec<ColumnStats> {
325    sheet
326        .columns
327        .iter()
328        .enumerate()
329        .map(|(col_idx, _)| {
330            let mut distinct: std::collections::HashSet<String> = std::collections::HashSet::new();
331            // Track min/max as f64 to handle both Integer and Real columns
332            // uniformly without reparsing strings.
333            let mut min_num: Option<f64> = None;
334            let mut max_num: Option<f64> = None;
335            // Remember whether the extremes came from an integer so we can
336            // render them without a spurious decimal point.
337            let mut min_as_int: Option<i64> = None;
338            let mut max_as_int: Option<i64> = None;
339
340            for row in &sheet.rows {
341                let value = row.get(col_idx).unwrap_or(&QueryValue::Null);
342                if matches!(value, QueryValue::Null) {
343                    continue;
344                }
345                distinct.insert(display_value(value));
346
347                let (as_f64, as_int) = match value {
348                    QueryValue::Integer(n) => (*n as f64, Some(*n)),
349                    QueryValue::Real(n) => (*n, None),
350                    _ => continue,
351                };
352
353                if min_num.map_or(true, |m| as_f64 < m) {
354                    min_num = Some(as_f64);
355                    min_as_int = as_int;
356                }
357                if max_num.map_or(true, |m| as_f64 > m) {
358                    max_num = Some(as_f64);
359                    max_as_int = as_int;
360                }
361            }
362
363            let min_value =
364                min_num.map(|v| min_as_int.map_or_else(|| v.to_string(), |i| i.to_string()));
365            let max_value =
366                max_num.map(|v| max_as_int.map_or_else(|| v.to_string(), |i| i.to_string()));
367
368            ColumnStats {
369                distinct_count: distinct.len(),
370                min_value,
371                max_value,
372            }
373        })
374        .collect()
375}
376
377pub fn run_query(
378    workbook_path: &Path,
379    sheet_name: Option<&str>,
380    query: &str,
381    has_headers: bool,
382) -> Result<QueryResult> {
383    run_query_with_params(workbook_path, sheet_name, query, &[], has_headers)
384}
385
386pub fn run_query_with_params(
387    workbook_path: &Path,
388    sheet_name: Option<&str>,
389    query: &str,
390    params: &[QueryParam],
391    has_headers: bool,
392) -> Result<QueryResult> {
393    run_query_with_params_multi(&[workbook_path], sheet_name, query, params, has_headers)
394}
395
396pub fn run_query_with_params_multi(
397    workbook_paths: &[&Path],
398    sheet_name: Option<&str>,
399    query: &str,
400    params: &[QueryParam],
401    has_headers: bool,
402) -> Result<QueryResult> {
403    let workbook_inputs = workbook_paths
404        .iter()
405        .map(|path| WorkbookInput {
406            path,
407            sheet_name,
408            table_name: None,
409        })
410        .collect::<Vec<_>>();
411
412    run_query_with_params_multi_inputs(&workbook_inputs, query, params, has_headers)
413}
414
415pub fn run_query_with_params_multi_inputs(
416    workbook_inputs: &[WorkbookInput<'_>],
417    query: &str,
418    params: &[QueryParam],
419    has_headers: bool,
420) -> Result<QueryResult> {
421    run_query_with_params_multi_inputs_and_options(
422        workbook_inputs,
423        query,
424        params,
425        &TypeInferenceOptions::default(),
426        has_headers,
427    )
428}
429
430pub fn run_query_with_params_multi_inputs_and_options(
431    workbook_inputs: &[WorkbookInput<'_>],
432    query: &str,
433    params: &[QueryParam],
434    inference_options: &TypeInferenceOptions,
435    has_headers: bool,
436) -> Result<QueryResult> {
437    run_query_with_params_multi_inputs_and_options_and_normalization(
438        workbook_inputs,
439        query,
440        params,
441        inference_options,
442        &InputNormalizationOptions::default(),
443        &ExtractionOptions::default(),
444        has_headers,
445    )
446}
447
448pub fn run_query_with_params_multi_inputs_and_options_and_normalization(
449    workbook_inputs: &[WorkbookInput<'_>],
450    query: &str,
451    params: &[QueryParam],
452    inference_options: &TypeInferenceOptions,
453    normalization_options: &InputNormalizationOptions,
454    extraction_options: &ExtractionOptions,
455    has_headers: bool,
456) -> Result<QueryResult> {
457    if workbook_inputs.is_empty() {
458        bail!("at least one workbook input is required");
459    }
460
461    let connection =
462        Connection::open_in_memory().context("failed to create in-memory SQLite database")?;
463    let mut registered_sheet_views = HashSet::new();
464
465    for (index, workbook_input) in workbook_inputs.iter().enumerate() {
466        let mut sheet = load_input(
467            workbook_input.path,
468            workbook_input.sheet_name,
469            inference_options,
470            extraction_options,
471            has_headers,
472        )?;
473        apply_input_normalization(&mut sheet, normalization_options);
474        let table_name = workbook_input
475            .table_name
476            .map(str::to_owned)
477            .unwrap_or_else(|| {
478                if index == 0 {
479                    "table".to_owned()
480                } else {
481                    format!("table{}", index + 1)
482                }
483            });
484
485        register_sheet(
486            &connection,
487            &sheet,
488            &table_name,
489            &mut registered_sheet_views,
490        )?;
491    }
492
493    execute_query(&connection, query, params)
494}
495
496pub fn render_text(result: &QueryResult) -> String {
497    if result.columns.is_empty() {
498        return String::new();
499    }
500
501    let mut column_widths = result
502        .columns
503        .iter()
504        .map(|column| column.len())
505        .collect::<Vec<_>>();
506    let mut rendered_rows = Vec::with_capacity(result.rows.len());
507
508    for row in &result.rows {
509        let mut rendered_row = Vec::with_capacity(result.columns.len());
510        for index in 0..result.columns.len() {
511            let rendered_value = row.get(index).map(display_value).unwrap_or_default();
512            column_widths[index] = column_widths[index].max(rendered_value.len());
513            rendered_row.push(rendered_value);
514        }
515        rendered_rows.push(rendered_row);
516    }
517
518    let mut output = String::new();
519    write_aligned_row(&mut output, &result.columns, &column_widths);
520    output.push('\n');
521    output.push_str(
522        &column_widths
523            .iter()
524            .map(|width| "-".repeat(*width))
525            .collect::<Vec<_>>()
526            .join("-+-"),
527    );
528
529    for row in rendered_rows {
530        output.push('\n');
531        write_aligned_row(&mut output, &row, &column_widths);
532    }
533
534    output
535}
536
537fn write_aligned_row(output: &mut String, values: &[String], column_widths: &[usize]) {
538    for (index, value) in values.iter().enumerate() {
539        if index > 0 {
540            output.push_str(" | ");
541        }
542
543        let _ = write!(output, "{value:<width$}", width = column_widths[index]);
544    }
545}
546
547pub fn render_csv(result: &QueryResult) -> String {
548    let mut output = String::new();
549    output.push_str(
550        &result
551            .columns
552            .iter()
553            .map(|column| escape_csv_field(column))
554            .collect::<Vec<_>>()
555            .join(","),
556    );
557
558    for row in &result.rows {
559        output.push('\n');
560        output.push_str(
561            &row.iter()
562                .map(display_value)
563                .map(|value| escape_csv_field(&value))
564                .collect::<Vec<_>>()
565                .join(","),
566        );
567    }
568
569    output
570}
571
572pub fn render_jsonl(result: &QueryResult) -> String {
573    let mut output = String::new();
574
575    for (row_index, row) in result.rows.iter().enumerate() {
576        if row_index > 0 {
577            output.push('\n');
578        }
579        output.push('{');
580        for (column_index, column) in result.columns.iter().enumerate() {
581            if column_index > 0 {
582                output.push(',');
583            }
584            output.push_str(&escape_json_string(column));
585            output.push(':');
586            output.push_str(&to_json_value(
587                row.get(column_index).unwrap_or(&QueryValue::Null),
588            ));
589        }
590        output.push('}');
591    }
592
593    output
594}
595
596pub fn render_json(result: &QueryResult) -> String {
597    let mut output = String::from("[");
598
599    for (row_index, row) in result.rows.iter().enumerate() {
600        if row_index > 0 {
601            output.push(',');
602        }
603        output.push('{');
604        for (column_index, column) in result.columns.iter().enumerate() {
605            if column_index > 0 {
606                output.push(',');
607            }
608            output.push_str(&escape_json_string(column));
609            output.push(':');
610            output.push_str(&to_json_value(
611                row.get(column_index).unwrap_or(&QueryValue::Null),
612            ));
613        }
614        output.push('}');
615    }
616
617    output.push(']');
618    output
619}
620
621pub fn render_markdown(result: &QueryResult) -> String {
622    let mut output = String::new();
623
624    output.push('|');
625    for column in &result.columns {
626        output.push(' ');
627        output.push_str(&escape_markdown_cell(column));
628        output.push(' ');
629        output.push('|');
630    }
631    output.push('\n');
632
633    output.push('|');
634    for _ in &result.columns {
635        output.push_str(" --- |");
636    }
637
638    for row in &result.rows {
639        output.push('\n');
640        output.push('|');
641        for column_index in 0..result.columns.len() {
642            let value = row.get(column_index).unwrap_or(&QueryValue::Null);
643            output.push(' ');
644            output.push_str(&escape_markdown_cell(&display_value(value)));
645            output.push(' ');
646            output.push('|');
647        }
648    }
649
650    output
651}
652
653pub fn render_xml(result: &QueryResult) -> String {
654    let mut output = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data>\n");
655
656    for row in &result.rows {
657        output.push_str("  <row>\n");
658        for (column_index, column) in result.columns.iter().enumerate() {
659            let value = row.get(column_index).unwrap_or(&QueryValue::Null);
660            let xml_column = escape_xml_tag(column);
661            let xml_value = escape_xml_text(&display_value(value));
662            output.push_str(&format!(
663                "    <{}>{}</{}>
664",
665                xml_column, xml_value, xml_column
666            ));
667        }
668        output.push_str("  </row>\n");
669    }
670
671    output.push_str("</data>");
672    output
673}
674
675pub fn write_xlsx(result: &QueryResult, output_path: &Path) -> Result<()> {
676    let mut workbook = Workbook::new();
677    let worksheet = workbook.add_worksheet();
678
679    for (column, header) in result.columns.iter().enumerate() {
680        worksheet.write_string(0, column as u16, header)?;
681    }
682
683    for (row_index, row) in result.rows.iter().enumerate() {
684        for (column_index, value) in row.iter().enumerate() {
685            let excel_row = (row_index + 1) as u32;
686            let excel_column = column_index as u16;
687
688            match value {
689                QueryValue::Null => {}
690                QueryValue::Integer(number) => {
691                    worksheet.write_number(excel_row, excel_column, *number as f64)?;
692                }
693                QueryValue::Real(number) => {
694                    worksheet.write_number(excel_row, excel_column, *number)?;
695                }
696                QueryValue::Text(text) => {
697                    worksheet.write_string(excel_row, excel_column, text)?;
698                }
699            }
700        }
701    }
702
703    workbook
704        .save(output_path)
705        .with_context(|| format!("failed to write {}", output_path.display()))
706}
707pub fn write_parquet(result: &QueryResult, output_path: &Path) -> Result<()> {
708    use std::sync::Arc;
709
710    use parquet::basic::{ConvertedType, Repetition, Type as PhysicalType};
711    use parquet::column::writer::ColumnWriter;
712    use parquet::data_type::ByteArray;
713    use parquet::file::properties::WriterProperties;
714    use parquet::file::writer::SerializedFileWriter;
715    use parquet::schema::types::Type as ParquetType;
716
717    #[derive(Clone, Copy)]
718    enum ColKind {
719        Int64,
720        Double,
721        Bytes,
722    }
723
724    // Infer the physical type for each column from its values.
725    let col_kinds: Vec<ColKind> = (0..result.columns.len())
726        .map(|ci| {
727            let mut has_real = false;
728            let mut has_text = false;
729            for row in &result.rows {
730                match row.get(ci).unwrap_or(&QueryValue::Null) {
731                    QueryValue::Real(_) => has_real = true,
732                    QueryValue::Text(_) => has_text = true,
733                    _ => {}
734                }
735            }
736            if has_text {
737                ColKind::Bytes
738            } else if has_real {
739                ColKind::Double
740            } else {
741                ColKind::Int64
742            }
743        })
744        .collect();
745
746    // Build the Parquet schema.
747    let fields: Vec<Arc<ParquetType>> = result
748        .columns
749        .iter()
750        .zip(&col_kinds)
751        .map(|(name, kind)| {
752            let physical = match kind {
753                ColKind::Int64 => PhysicalType::INT64,
754                ColKind::Double => PhysicalType::DOUBLE,
755                ColKind::Bytes => PhysicalType::BYTE_ARRAY,
756            };
757            let mut builder = ParquetType::primitive_type_builder(name, physical)
758                .with_repetition(Repetition::OPTIONAL);
759            if matches!(kind, ColKind::Bytes) {
760                builder = builder.with_converted_type(ConvertedType::UTF8);
761            }
762            Arc::new(
763                builder
764                    .build()
765                    .with_context(|| format!("failed to build Parquet field '{name}'"))
766                    .expect("valid field"),
767            )
768        })
769        .collect();
770
771    let schema = Arc::new(
772        ParquetType::group_type_builder("schema")
773            .with_fields(fields)
774            .build()
775            .context("failed to build Parquet schema")?,
776    );
777
778    let props = Arc::new(WriterProperties::builder().build());
779    let file = fs::File::create(output_path)
780        .with_context(|| format!("failed to create {}", output_path.display()))?;
781    let mut file_writer = SerializedFileWriter::new(file, schema, props)
782        .context("failed to initialize Parquet writer")?;
783
784    let mut rg = file_writer
785        .next_row_group()
786        .context("failed to start Parquet row group")?;
787
788    for (col_idx, kind) in col_kinds.iter().enumerate() {
789        let def_levels: Vec<i16> = result
790            .rows
791            .iter()
792            .map(|row| match row.get(col_idx).unwrap_or(&QueryValue::Null) {
793                QueryValue::Null => 0,
794                _ => 1,
795            })
796            .collect();
797
798        let Some(mut col_writer) = rg
799            .next_column()
800            .context("failed to open Parquet column writer")?
801        else {
802            break;
803        };
804
805        match (kind, col_writer.untyped()) {
806            (ColKind::Int64, ColumnWriter::Int64ColumnWriter(w)) => {
807                let values: Vec<i64> = result
808                    .rows
809                    .iter()
810                    .filter_map(|row| match row.get(col_idx).unwrap_or(&QueryValue::Null) {
811                        QueryValue::Integer(v) => Some(*v),
812                        _ => None,
813                    })
814                    .collect();
815                w.write_batch(&values, Some(&def_levels), None)?;
816            }
817            (ColKind::Double, ColumnWriter::DoubleColumnWriter(w)) => {
818                let values: Vec<f64> = result
819                    .rows
820                    .iter()
821                    .filter_map(|row| match row.get(col_idx).unwrap_or(&QueryValue::Null) {
822                        QueryValue::Real(v) => Some(*v),
823                        QueryValue::Integer(v) => Some(*v as f64),
824                        _ => None,
825                    })
826                    .collect();
827                w.write_batch(&values, Some(&def_levels), None)?;
828            }
829            (ColKind::Bytes, ColumnWriter::ByteArrayColumnWriter(w)) => {
830                let values: Vec<ByteArray> = result
831                    .rows
832                    .iter()
833                    .filter_map(|row| match row.get(col_idx).unwrap_or(&QueryValue::Null) {
834                        QueryValue::Text(s) => Some(ByteArray::from(s.as_bytes().to_vec())),
835                        QueryValue::Integer(v) => Some(ByteArray::from(v.to_string().into_bytes())),
836                        QueryValue::Real(v) => Some(ByteArray::from(v.to_string().into_bytes())),
837                        _ => None,
838                    })
839                    .collect();
840                w.write_batch(&values, Some(&def_levels), None)?;
841            }
842            _ => unreachable!("column kind and writer type must agree"),
843        }
844
845        col_writer.close()?;
846    }
847
848    rg.close()?;
849    file_writer.close()?;
850
851    Ok(())
852}
853
854#[derive(Debug)]
855struct SheetData {
856    original_name: String,
857    columns: Vec<String>,
858    rows: Vec<Vec<QueryValue>>,
859}
860
861fn load_input(
862    input_path: &Path,
863    requested_sheet: Option<&str>,
864    inference_options: &TypeInferenceOptions,
865    extraction_options: &ExtractionOptions,
866    has_headers: bool,
867) -> Result<SheetData> {
868    if input_path
869        .extension()
870        .map(|extension| extension.to_string_lossy())
871        .is_some_and(|extension| extension.eq_ignore_ascii_case("parquet"))
872    {
873        return load_parquet_sheet(input_path, requested_sheet, inference_options);
874    }
875
876    if input_path
877        .extension()
878        .map(|extension| extension.to_string_lossy())
879        .is_some_and(|extension| extension.eq_ignore_ascii_case("xml"))
880    {
881        return load_xml_sheet(input_path, requested_sheet, inference_options, extraction_options);
882    }
883
884    if input_path
885        .extension()
886        .map(|extension| extension.to_string_lossy())
887        .is_some_and(|extension| extension.eq_ignore_ascii_case("csv"))
888    {
889        return load_csv_sheet(input_path, requested_sheet, inference_options, has_headers);
890    }
891
892    if input_path
893        .extension()
894        .map(|extension| extension.to_string_lossy())
895        .is_some_and(|extension| extension.eq_ignore_ascii_case("jsonl"))
896    {
897        return load_jsonl_sheet(input_path, requested_sheet, inference_options);
898    }
899
900    if input_path
901        .extension()
902        .map(|extension| extension.to_string_lossy())
903        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
904    {
905        return load_json_sheet(input_path, requested_sheet, inference_options, extraction_options);
906    }
907
908    if input_path
909        .extension()
910        .map(|extension| extension.to_string_lossy())
911        .is_some_and(|extension| {
912            extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
913        })
914    {
915        return load_markdown_sheet(input_path, requested_sheet, inference_options, has_headers);
916    }
917
918    load_xlsx_sheet(input_path, requested_sheet, inference_options, has_headers)
919}
920
921fn load_parquet_sheet(
922    parquet_path: &Path,
923    requested_sheet: Option<&str>,
924    inference_options: &TypeInferenceOptions,
925) -> Result<SheetData> {
926    if let Some(selector) = requested_sheet {
927        bail!(
928            "Parquet input {} does not support selector '{selector}'. Remove ':{selector}' from --input for Parquet files.",
929            parquet_path.display()
930        );
931    }
932
933    let reader = SerializedFileReader::try_from(parquet_path)
934        .with_context(|| format!("failed to open {}", parquet_path.display()))?;
935    let mut row_iter = reader
936        .get_row_iter(None)
937        .with_context(|| format!("failed to read rows from {}", parquet_path.display()))?;
938
939    let first_row = row_iter
940        .next()
941        .transpose()
942        .with_context(|| format!("failed to read first row from {}", parquet_path.display()))?;
943
944    let Some(first_row) = first_row else {
945        bail!("Parquet input {} is empty", parquet_path.display());
946    };
947
948    let columns = normalize_text_headers(
949        &first_row
950            .get_column_iter()
951            .map(|(name, _)| name.to_owned())
952            .collect::<Vec<_>>(),
953    );
954
955    let mut rows = vec![parquet_row_to_values(first_row, inference_options)];
956    for row in row_iter {
957        let row =
958            row.with_context(|| format!("failed to read row from {}", parquet_path.display()))?;
959        rows.push(parquet_row_to_values(row, inference_options));
960    }
961
962    Ok(SheetData {
963        original_name: "parquet".to_owned(),
964        columns,
965        rows,
966    })
967}
968
969fn load_csv_sheet(
970    csv_path: &Path,
971    requested_sheet: Option<&str>,
972    inference_options: &TypeInferenceOptions,
973    has_headers: bool,
974) -> Result<SheetData> {
975    if let Some(selector) = requested_sheet {
976        bail!(
977            "CSV input {} does not support selector '{selector}'. Remove ':{selector}' from --input for CSV files.",
978            csv_path.display()
979        );
980    }
981
982    let mut reader = csv::ReaderBuilder::new()
983        .has_headers(has_headers)
984        .from_path(csv_path)
985        .with_context(|| format!("failed to open {}", csv_path.display()))?;
986
987    let mut records = Vec::new();
988    for record in reader.records() {
989        let record = record
990            .with_context(|| format!("failed to read CSV record from {}", csv_path.display()))?;
991        records.push(record.iter().map(str::to_owned).collect::<Vec<_>>());
992    }
993
994    let columns = if has_headers {
995        let headers = reader
996            .headers()
997            .with_context(|| format!("failed to read headers from {}", csv_path.display()))?
998            .iter()
999            .map(str::to_owned)
1000            .collect::<Vec<_>>();
1001        normalize_text_headers(&headers)
1002    } else {
1003        let width = records.iter().map(Vec::len).max().unwrap_or(0);
1004        (1..=width).map(|index| format!("column{index}")).collect()
1005    };
1006
1007    if columns.is_empty() {
1008        bail!("CSV input {} is empty", csv_path.display());
1009    }
1010
1011    let rows = records
1012        .into_iter()
1013        .map(|record| {
1014            (0..columns.len())
1015                .map(|index| {
1016                    let value = record.get(index).map(String::as_str).unwrap_or("");
1017                    parse_scalar_value(value, inference_options)
1018                })
1019                .collect::<Vec<_>>()
1020        })
1021        .filter(|row| row.iter().any(|value| !matches!(value, QueryValue::Null)))
1022        .collect::<Vec<_>>();
1023
1024    Ok(SheetData {
1025        original_name: "csv".to_owned(),
1026        columns,
1027        rows,
1028    })
1029}
1030
1031fn load_jsonl_sheet(
1032    jsonl_path: &Path,
1033    requested_sheet: Option<&str>,
1034    inference_options: &TypeInferenceOptions,
1035) -> Result<SheetData> {
1036    if let Some(selector) = requested_sheet {
1037        bail!(
1038            "JSONL input {} does not support selector '{selector}'. Remove ':{selector}' from --input for JSONL files.",
1039            jsonl_path.display()
1040        );
1041    }
1042
1043    let content = fs::read_to_string(jsonl_path)
1044        .with_context(|| format!("failed to read {}", jsonl_path.display()))?;
1045
1046    let mut rows_maps = Vec::<Vec<(String, QueryValue)>>::new();
1047    for (line_index, raw_line) in content.lines().enumerate() {
1048        let line = raw_line.trim();
1049        if line.is_empty() {
1050            continue;
1051        }
1052
1053        let json_value = serde_json::from_str::<JsonValue>(line).with_context(|| {
1054            format!(
1055                "failed to parse JSONL line {} in {}",
1056                line_index + 1,
1057                jsonl_path.display()
1058            )
1059        })?;
1060
1061        let JsonValue::Object(object) = json_value else {
1062            bail!(
1063                "JSONL line {} in {} is not an object",
1064                line_index + 1,
1065                jsonl_path.display()
1066            );
1067        };
1068
1069        rows_maps.push(
1070            object
1071                .into_iter()
1072                .map(|(key, value)| (key, json_to_query_value(value, inference_options)))
1073                .collect(),
1074        );
1075    }
1076
1077    if rows_maps.is_empty() {
1078        bail!("JSONL input {} is empty", jsonl_path.display());
1079    }
1080
1081    Ok(rows_maps_to_sheet_data(rows_maps, "jsonl".to_owned()))
1082}
1083
1084fn load_json_sheet(
1085    json_path: &Path,
1086    requested_sheet: Option<&str>,
1087    inference_options: &TypeInferenceOptions,
1088    extraction_options: &ExtractionOptions,
1089) -> Result<SheetData> {
1090    let content = fs::read_to_string(json_path)
1091        .with_context(|| format!("failed to read {}", json_path.display()))?;
1092    let root = serde_json::from_str::<JsonValue>(&content)
1093        .with_context(|| format!("failed to parse JSON {}", json_path.display()))?;
1094
1095    let scope = if let Some(sheet_key) = requested_sheet {
1096        let JsonValue::Object(mut object) = root else {
1097            bail!(
1098                "JSON input {} uses selector '{sheet_key}', but key selection requires a top-level object. Remove the selector or provide a JSON object at the root.",
1099                json_path.display()
1100            );
1101        };
1102
1103        let mut available_keys = object.keys().cloned().collect::<Vec<_>>();
1104        available_keys.sort();
1105
1106        object.remove(sheet_key).ok_or_else(|| {
1107            let available_suffix = if available_keys.is_empty() {
1108                " The top-level object has no keys.".to_owned()
1109            } else {
1110                format!(" Available keys: {}.", available_keys.join(", "))
1111            };
1112            anyhow!(
1113                "JSON key '{sheet_key}' not found in {}.{available_suffix}",
1114                json_path.display()
1115            )
1116        })?
1117    } else {
1118        root
1119    };
1120
1121    let rows_maps = match extraction_options.json_mode {
1122        JsonMode::Array => json_scope_to_rows(scope, inference_options),
1123        JsonMode::Object => json_scope_to_rows_object(scope, inference_options),
1124        JsonMode::Flatten => json_scope_to_rows_flatten(scope, inference_options),
1125    };
1126    if rows_maps.is_empty() {
1127        if let Some(sheet_key) = requested_sheet {
1128            bail!(
1129                "JSON selector '{sheet_key}' in {} resolved to an empty table (no rows)",
1130                json_path.display()
1131            );
1132        }
1133        bail!("JSON input {} is empty", json_path.display());
1134    }
1135
1136    Ok(rows_maps_to_sheet_data(
1137        rows_maps,
1138        requested_sheet.unwrap_or("json").to_owned(),
1139    ))
1140}
1141
1142fn load_markdown_sheet(
1143    markdown_path: &Path,
1144    requested_sheet: Option<&str>,
1145    inference_options: &TypeInferenceOptions,
1146    has_headers: bool,
1147) -> Result<SheetData> {
1148    let content = fs::read_to_string(markdown_path)
1149        .with_context(|| format!("failed to read {}", markdown_path.display()))?;
1150    let tables = parse_markdown_tables(&content);
1151    if tables.is_empty() {
1152        bail!(
1153            "Markdown input {} does not contain any table",
1154            markdown_path.display()
1155        );
1156    }
1157
1158    let table_index = if let Some(key) = requested_sheet {
1159        let index = key
1160            .trim()
1161            .parse::<usize>()
1162            .map_err(|_| {
1163                anyhow!(
1164                    "invalid Markdown table selector '{key}' for {}. Use a 1-based numeric index such as ':1' or ':2'.",
1165                    markdown_path.display()
1166                )
1167            })?;
1168        if index == 0 {
1169            bail!(
1170                "invalid Markdown table selector '{key}' for {}. Table indexes are 1-based (:1, :2, ...).",
1171                markdown_path.display()
1172            );
1173        }
1174        index
1175    } else {
1176        1
1177    };
1178
1179    let table_count = tables.len();
1180    let Some(table) = tables.into_iter().nth(table_index - 1) else {
1181        bail!(
1182            "Markdown table {} not found in {}. Found {} table(s); choose an index between 1 and {}.",
1183            table_index,
1184            markdown_path.display(),
1185            table_count,
1186            table_count
1187        );
1188    };
1189
1190    let mut rows = table.rows;
1191    let columns = if has_headers {
1192        normalize_text_headers(&table.headers)
1193    } else {
1194        rows.insert(0, table.headers);
1195        let width = rows.iter().map(Vec::len).max().unwrap_or(0);
1196        (1..=width).map(|index| format!("column{index}")).collect()
1197    };
1198
1199    if columns.is_empty() {
1200        bail!("Markdown table {} is empty", table_index);
1201    }
1202
1203    let data_rows = rows
1204        .into_iter()
1205        .map(|row| {
1206            (0..columns.len())
1207                .map(|index| {
1208                    let value = row.get(index).map(String::as_str).unwrap_or("");
1209                    parse_scalar_value(value, inference_options)
1210                })
1211                .collect::<Vec<_>>()
1212        })
1213        .filter(|row| row.iter().any(|value| !matches!(value, QueryValue::Null)))
1214        .collect::<Vec<_>>();
1215
1216    if data_rows.is_empty() {
1217        bail!(
1218            "Markdown table {} in {} is empty (no data rows)",
1219            table_index,
1220            markdown_path.display()
1221        );
1222    }
1223
1224    Ok(SheetData {
1225        original_name: format!("table{table_index}"),
1226        columns,
1227        rows: data_rows,
1228    })
1229}
1230
1231fn load_xlsx_sheet(
1232    workbook_path: &Path,
1233    requested_sheet: Option<&str>,
1234    inference_options: &TypeInferenceOptions,
1235    has_headers: bool,
1236) -> Result<SheetData> {
1237    let mut workbook = open_workbook_auto(workbook_path)
1238        .with_context(|| format!("failed to open {}", workbook_path.display()))?;
1239
1240    let sheet_name = match requested_sheet {
1241        Some(name) => name.to_owned(),
1242        None => workbook
1243            .sheet_names()
1244            .first()
1245            .cloned()
1246            .ok_or_else(|| anyhow!("workbook does not contain any sheets"))?,
1247    };
1248
1249    let range = workbook
1250        .worksheet_range(&sheet_name)
1251        .with_context(|| format!("failed to read sheet {sheet_name}"))?;
1252
1253    if range.width() == 0 {
1254        bail!("sheet {sheet_name} is empty");
1255    }
1256
1257    let mut rows = range.rows();
1258    let columns = if has_headers {
1259        let header_row = rows
1260            .next()
1261            .ok_or_else(|| anyhow!("sheet {sheet_name} is empty"))?;
1262        normalize_headers(header_row, range.width())
1263    } else {
1264        (1..=range.width())
1265            .map(|index| format!("column{index}"))
1266            .collect()
1267    };
1268
1269    let data_rows = rows
1270        .map(|row| {
1271            (0..columns.len())
1272                .map(|index| convert_cell(row.get(index).unwrap_or(&Data::Empty)))
1273                .map(|value| apply_inference_overrides(value, inference_options))
1274                .collect::<Vec<_>>()
1275        })
1276        .filter(|row| row.iter().any(|value| !matches!(value, QueryValue::Null)))
1277        .collect();
1278
1279    Ok(SheetData {
1280        original_name: sheet_name,
1281        columns,
1282        rows: data_rows,
1283    })
1284}
1285
1286fn load_xml_sheet(
1287    xml_path: &Path,
1288    requested_sheet: Option<&str>,
1289    inference_options: &TypeInferenceOptions,
1290    extraction_options: &ExtractionOptions,
1291) -> Result<SheetData> {
1292    let xml_content = fs::read_to_string(xml_path)
1293        .with_context(|| format!("failed to read {}", xml_path.display()))?;
1294    let document = Document::parse(&xml_content)
1295        .with_context(|| format!("failed to parse XML {}", xml_path.display()))?;
1296
1297    let root = document.root_element();
1298    let scope = if let Some(sheet_tag) = requested_sheet {
1299        root.descendants()
1300            .find(|node| node.is_element() && node.tag_name().name() == sheet_tag)
1301            .ok_or_else(|| anyhow!("XML tag '{sheet_tag}' not found"))?
1302    } else {
1303        root
1304    };
1305
1306    let records = match extraction_options.xml_mode {
1307        XmlMode::Rows => {
1308            let mut records = collect_xml_records(scope, inference_options);
1309            if records.is_empty() {
1310                let fallback = xml_row_from_children(scope, inference_options);
1311                if fallback.is_empty() {
1312                    let scope_name = requested_sheet.unwrap_or(scope.tag_name().name());
1313                    bail!("XML scope '{scope_name}' does not contain tabular data");
1314                }
1315                records.push(fallback);
1316            }
1317            records
1318        }
1319        XmlMode::Descendants => {
1320            let records = collect_xml_descendants(scope, inference_options);
1321            if records.is_empty() {
1322                let scope_name = requested_sheet.unwrap_or(scope.tag_name().name());
1323                bail!(
1324                    "XML scope '{scope_name}' does not contain any leaf text elements (--xml-mode descendants)"
1325                );
1326            }
1327            records
1328        }
1329        XmlMode::Attributes => {
1330            let records = collect_xml_attributes_records(scope, inference_options);
1331            if records.is_empty() {
1332                let scope_name = requested_sheet.unwrap_or(scope.tag_name().name());
1333                bail!(
1334                    "XML scope '{scope_name}' does not contain any elements with attributes (--xml-mode attributes)"
1335                );
1336            }
1337            records
1338        }
1339    };
1340
1341    let mut columns = Vec::new();
1342    for row in &records {
1343        for (column, _) in row {
1344            if !columns.iter().any(|existing| existing == column) {
1345                columns.push(column.clone());
1346            }
1347        }
1348    }
1349
1350    let rows = records
1351        .into_iter()
1352        .map(|row| {
1353            columns
1354                .iter()
1355                .map(|column| {
1356                    row.iter()
1357                        .find(|(key, _)| key == column)
1358                        .map(|(_, value)| value.clone())
1359                        .unwrap_or(QueryValue::Null)
1360                })
1361                .collect::<Vec<_>>()
1362        })
1363        .collect::<Vec<_>>();
1364
1365    let original_name = requested_sheet
1366        .map(str::to_owned)
1367        .unwrap_or_else(|| scope.tag_name().name().to_owned());
1368
1369    Ok(SheetData {
1370        original_name,
1371        columns,
1372        rows,
1373    })
1374}
1375
1376fn collect_xml_records(
1377    scope: Node<'_, '_>,
1378    inference_options: &TypeInferenceOptions,
1379) -> Vec<Vec<(String, QueryValue)>> {
1380    let direct_row_nodes = scope
1381        .children()
1382        .filter(|node| node.is_element() && node.tag_name().name().eq_ignore_ascii_case("row"))
1383        .collect::<Vec<_>>();
1384    if !direct_row_nodes.is_empty() {
1385        return direct_row_nodes
1386            .into_iter()
1387            .map(|row| xml_row_from_children(row, inference_options))
1388            .filter(|row| !row.is_empty())
1389            .collect();
1390    }
1391
1392    scope
1393        .descendants()
1394        .filter(|node| node.is_element() && *node != scope)
1395        .filter(|node| is_xml_record_candidate(*node))
1396        .map(|row| xml_row_from_children(row, inference_options))
1397        .filter(|row| !row.is_empty())
1398        .collect()
1399}
1400
1401fn is_xml_record_candidate(node: Node<'_, '_>) -> bool {
1402    let children = node
1403        .children()
1404        .filter(|child| child.is_element())
1405        .collect::<Vec<_>>();
1406
1407    !children.is_empty()
1408        && children
1409            .iter()
1410            .all(|child| !child.children().any(|inner| inner.is_element()))
1411}
1412
1413fn xml_row_from_children(
1414    node: Node<'_, '_>,
1415    inference_options: &TypeInferenceOptions,
1416) -> Vec<(String, QueryValue)> {
1417    node.children()
1418        .filter(|child| child.is_element())
1419        .map(|child| {
1420            let column = child.tag_name().name().to_owned();
1421            let value = xml_text_to_query_value(&xml_text_content(child), inference_options);
1422            (column, value)
1423        })
1424        .collect()
1425}
1426
1427fn xml_text_content(node: Node<'_, '_>) -> String {
1428    node.text().unwrap_or_default().trim().to_owned()
1429}
1430
1431fn xml_text_to_query_value(raw: &str, inference_options: &TypeInferenceOptions) -> QueryValue {
1432    parse_scalar_value(raw, inference_options)
1433}
1434
1435/// `descendants` mode: collect every leaf text element (element with no child elements)
1436/// as a separate row.  Each row has two columns: `tag` (element name) and `value`
1437/// (text content).
1438fn collect_xml_descendants(
1439    scope: Node<'_, '_>,
1440    inference_options: &TypeInferenceOptions,
1441) -> Vec<Vec<(String, QueryValue)>> {
1442    scope
1443        .descendants()
1444        .filter(|node| node.is_element() && *node != scope)
1445        .filter(|node| !node.children().any(|child| child.is_element()))
1446        .map(|node| {
1447            let tag = node.tag_name().name().to_owned();
1448            let value = xml_text_to_query_value(&xml_text_content(node), inference_options);
1449            vec![
1450                ("tag".to_owned(), QueryValue::Text(tag)),
1451                ("value".to_owned(), value),
1452            ]
1453        })
1454        .collect()
1455}
1456
1457/// `attributes` mode: every element that carries at least one XML attribute becomes a
1458/// row.  Attribute names map to column names; text content is included as a `value`
1459/// column when non-empty.
1460fn collect_xml_attributes_records(
1461    scope: Node<'_, '_>,
1462    inference_options: &TypeInferenceOptions,
1463) -> Vec<Vec<(String, QueryValue)>> {
1464    scope
1465        .descendants()
1466        .filter(|node| node.is_element() && *node != scope)
1467        .filter(|node| node.attributes().count() > 0)
1468        .map(|node| {
1469            let mut row: Vec<(String, QueryValue)> = node
1470                .attributes()
1471                .map(|attr| {
1472                    let column = attr.name().to_owned();
1473                    let value = xml_text_to_query_value(attr.value(), inference_options);
1474                    (column, value)
1475                })
1476                .collect();
1477            let text = xml_text_content(node);
1478            if !text.is_empty() {
1479                row.push((
1480                    "value".to_owned(),
1481                    xml_text_to_query_value(&text, inference_options),
1482                ));
1483            }
1484            row
1485        })
1486        .filter(|row| !row.is_empty())
1487        .collect()
1488}
1489
1490fn parse_scalar_value(raw: &str, inference_options: &TypeInferenceOptions) -> QueryValue {
1491    let trimmed = raw.trim();
1492    if matches_token(trimmed, &inference_options.null_values) {
1493        return QueryValue::Null;
1494    }
1495
1496    if !inference_options.infer_types {
1497        return QueryValue::Text(raw.to_owned());
1498    }
1499
1500    if matches_token(trimmed, &inference_options.true_values) {
1501        return QueryValue::Integer(1);
1502    }
1503
1504    if matches_token(trimmed, &inference_options.false_values) {
1505        return QueryValue::Integer(0);
1506    }
1507
1508    if let Ok(value) = trimmed.parse::<i64>() {
1509        return QueryValue::Integer(value);
1510    }
1511
1512    if let Some(value) = parse_decimal_value(trimmed, inference_options.decimal_comma) {
1513        return QueryValue::Real(value);
1514    }
1515
1516    if let Some(value) = parse_date_value(trimmed, inference_options.date_format.as_deref()) {
1517        return QueryValue::Text(value);
1518    }
1519
1520    QueryValue::Text(raw.to_owned())
1521}
1522
1523fn matches_token(raw: &str, candidates: &[String]) -> bool {
1524    candidates
1525        .iter()
1526        .any(|candidate| raw.eq_ignore_ascii_case(candidate.trim()))
1527}
1528
1529fn parse_decimal_value(raw: &str, decimal_comma: bool) -> Option<f64> {
1530    if let Ok(value) = raw.parse::<f64>() {
1531        return Some(value);
1532    }
1533
1534    if !decimal_comma {
1535        return None;
1536    }
1537
1538    let compact = raw.replace(' ', "");
1539    if !compact.contains(',') {
1540        return None;
1541    }
1542
1543    let normalized = if compact.contains('.') {
1544        compact.replace('.', "").replace(',', ".")
1545    } else {
1546        compact.replace(',', ".")
1547    };
1548
1549    normalized.parse::<f64>().ok()
1550}
1551
1552fn parse_date_value(raw: &str, date_format: Option<&str>) -> Option<String> {
1553    let format = date_format?;
1554
1555    if let Ok(value) = NaiveDate::parse_from_str(raw, format) {
1556        return Some(value.format("%Y-%m-%d").to_string());
1557    }
1558
1559    if let Ok(value) = NaiveDateTime::parse_from_str(raw, format) {
1560        return Some(value.format("%Y-%m-%d %H:%M:%S").to_string());
1561    }
1562
1563    None
1564}
1565
1566fn apply_inference_overrides(
1567    value: QueryValue,
1568    inference_options: &TypeInferenceOptions,
1569) -> QueryValue {
1570    if inference_options.infer_types {
1571        return value;
1572    }
1573
1574    match value {
1575        QueryValue::Null => QueryValue::Null,
1576        QueryValue::Integer(number) => QueryValue::Text(number.to_string()),
1577        QueryValue::Real(number) => QueryValue::Text(number.to_string()),
1578        QueryValue::Text(text) => QueryValue::Text(text),
1579    }
1580}
1581
1582fn parquet_row_to_values(
1583    row: parquet::record::Row,
1584    inference_options: &TypeInferenceOptions,
1585) -> Vec<QueryValue> {
1586    row.into_columns()
1587        .into_iter()
1588        .map(|(_, field)| parquet_field_to_query_value(field, inference_options))
1589        .collect()
1590}
1591
1592fn parquet_field_to_query_value(
1593    field: Field,
1594    inference_options: &TypeInferenceOptions,
1595) -> QueryValue {
1596    apply_inference_overrides(
1597        match field {
1598            Field::Null => QueryValue::Null,
1599            Field::Bool(value) => QueryValue::Integer(i64::from(value)),
1600            Field::Byte(value) => QueryValue::Integer(i64::from(value)),
1601            Field::Short(value) => QueryValue::Integer(i64::from(value)),
1602            Field::Int(value) => QueryValue::Integer(i64::from(value)),
1603            Field::Long(value) => QueryValue::Integer(value),
1604            Field::UByte(value) => QueryValue::Integer(i64::from(value)),
1605            Field::UShort(value) => QueryValue::Integer(i64::from(value)),
1606            Field::UInt(value) => QueryValue::Integer(i64::from(value)),
1607            Field::ULong(value) => i64::try_from(value)
1608                .map(QueryValue::Integer)
1609                .unwrap_or_else(|_| QueryValue::Text(value.to_string())),
1610            Field::Float16(value) => QueryValue::Real(f64::from(value)),
1611            Field::Float(value) => QueryValue::Real(f64::from(value)),
1612            Field::Double(value) => QueryValue::Real(value),
1613            Field::Decimal(value) => QueryValue::Text(format!("{value:?}")),
1614            Field::Str(value) => QueryValue::Text(value),
1615            Field::Bytes(value) => QueryValue::Text(String::from_utf8_lossy(value.data()).into()),
1616            Field::Date(value) => QueryValue::Integer(i64::from(value)),
1617            Field::TimestampMillis(value) => QueryValue::Integer(value),
1618            Field::TimestampMicros(value) => QueryValue::Integer(value),
1619            Field::Group(value) => QueryValue::Text(value.to_string()),
1620            Field::ListInternal(value) => QueryValue::Text(format!("{value:?}")),
1621            Field::MapInternal(value) => QueryValue::Text(format!("{value:?}")),
1622        },
1623        inference_options,
1624    )
1625}
1626
1627fn apply_input_normalization(sheet: &mut SheetData, options: &InputNormalizationOptions) {
1628    if options.trim {
1629        for column in &mut sheet.columns {
1630            *column = column.trim().to_owned();
1631        }
1632
1633        for row in &mut sheet.rows {
1634            for value in row {
1635                if let QueryValue::Text(text) = value {
1636                    let trimmed = text.trim();
1637                    if trimmed.is_empty() {
1638                        *value = QueryValue::Null;
1639                    } else {
1640                        *text = trimmed.to_owned();
1641                    }
1642                }
1643            }
1644        }
1645    }
1646
1647    if options.normalize_headers {
1648        sheet.columns = sheet
1649            .columns
1650            .iter()
1651            .enumerate()
1652            .map(|(index, header)| {
1653                normalize_header_value(header, index + 1, options.header_case.as_ref())
1654            })
1655            .collect();
1656    }
1657
1658    if options.dedupe_headers {
1659        dedupe_headers(&mut sheet.columns);
1660    }
1661
1662    if options.skip_empty_rows {
1663        sheet.rows.retain(|row| !is_row_empty(row));
1664    }
1665}
1666
1667fn normalize_header_value(header: &str, index: usize, header_case: Option<&HeaderCase>) -> String {
1668    let normalized = header
1669        .trim()
1670        .chars()
1671        .map(|character| {
1672            if character.is_alphanumeric() || character == '_' {
1673                character
1674            } else {
1675                '_'
1676            }
1677        })
1678        .collect::<String>();
1679    let collapsed = collapse_underscores(&normalized)
1680        .trim_matches('_')
1681        .to_owned();
1682
1683    let mut value = if collapsed.is_empty() {
1684        format!("column{index}")
1685    } else {
1686        collapsed
1687    };
1688
1689    if matches!(header_case, Some(HeaderCase::Snake)) {
1690        value = value.to_ascii_lowercase();
1691    }
1692
1693    value
1694}
1695
1696fn collapse_underscores(value: &str) -> String {
1697    let mut output = String::with_capacity(value.len());
1698    let mut previous_was_underscore = false;
1699
1700    for character in value.chars() {
1701        if character == '_' {
1702            if !previous_was_underscore {
1703                output.push(character);
1704            }
1705            previous_was_underscore = true;
1706        } else {
1707            output.push(character);
1708            previous_was_underscore = false;
1709        }
1710    }
1711
1712    output
1713}
1714
1715fn dedupe_headers(headers: &mut [String]) {
1716    let mut seen = std::collections::HashMap::new();
1717    let mut assigned = HashSet::new();
1718
1719    for header in headers {
1720        let base = header.clone();
1721        let count = seen
1722            .entry(base.clone())
1723            .and_modify(|count| *count += 1)
1724            .or_insert(1usize);
1725
1726        let mut candidate = if *count == 1 {
1727            base.clone()
1728        } else {
1729            format!("{base}_{count}")
1730        };
1731
1732        while assigned.contains(&candidate) {
1733            *count += 1;
1734            candidate = format!("{base}_{count}");
1735        }
1736
1737        assigned.insert(candidate.clone());
1738        *header = candidate;
1739    }
1740}
1741
1742fn is_row_empty(row: &[QueryValue]) -> bool {
1743    row.iter().all(|value| match value {
1744        QueryValue::Null => true,
1745        QueryValue::Text(text) => text.trim().is_empty(),
1746        QueryValue::Integer(_) | QueryValue::Real(_) => false,
1747    })
1748}
1749
1750fn normalize_text_headers(headers: &[String]) -> Vec<String> {
1751    let mut seen = std::collections::HashMap::new();
1752
1753    headers
1754        .iter()
1755        .enumerate()
1756        .map(|(index, header)| {
1757            let base = if header.trim().is_empty() {
1758                format!("column{}", index + 1)
1759            } else {
1760                header.clone()
1761            };
1762
1763            let count = seen
1764                .entry(base.clone())
1765                .and_modify(|count| *count += 1)
1766                .or_insert(1usize);
1767
1768            if *count == 1 {
1769                base
1770            } else {
1771                format!("{base}_{count}")
1772            }
1773        })
1774        .collect()
1775}
1776
1777fn rows_maps_to_sheet_data(
1778    rows_maps: Vec<Vec<(String, QueryValue)>>,
1779    original_name: String,
1780) -> SheetData {
1781    let mut columns = Vec::<String>::new();
1782    for row in &rows_maps {
1783        for (column, _) in row {
1784            if !columns.iter().any(|existing| existing == column) {
1785                columns.push(column.clone());
1786            }
1787        }
1788    }
1789
1790    let rows = rows_maps
1791        .into_iter()
1792        .map(|row| {
1793            columns
1794                .iter()
1795                .map(|column| {
1796                    row.iter()
1797                        .find(|(key, _)| key == column)
1798                        .map(|(_, value)| value.clone())
1799                        .unwrap_or(QueryValue::Null)
1800                })
1801                .collect::<Vec<_>>()
1802        })
1803        .collect::<Vec<_>>();
1804
1805    SheetData {
1806        original_name,
1807        columns,
1808        rows,
1809    }
1810}
1811
1812#[derive(Debug)]
1813struct MarkdownTable {
1814    headers: Vec<String>,
1815    rows: Vec<Vec<String>>,
1816}
1817
1818fn parse_markdown_tables(content: &str) -> Vec<MarkdownTable> {
1819    let lines = content.lines().collect::<Vec<_>>();
1820    let mut tables = Vec::new();
1821    let mut index = 0usize;
1822
1823    while index + 1 < lines.len() {
1824        if !looks_like_markdown_row(lines[index]) || !is_markdown_separator(lines[index + 1]) {
1825            index += 1;
1826            continue;
1827        }
1828
1829        let headers = parse_markdown_row(lines[index]);
1830        if headers.is_empty() {
1831            index += 1;
1832            continue;
1833        }
1834
1835        let mut rows = Vec::new();
1836        index += 2;
1837        while index < lines.len() && looks_like_markdown_row(lines[index]) {
1838            if is_markdown_separator(lines[index]) {
1839                break;
1840            }
1841            let row = parse_markdown_row(lines[index]);
1842            if row.iter().any(|value| !value.trim().is_empty()) {
1843                rows.push(row);
1844            }
1845            index += 1;
1846        }
1847
1848        tables.push(MarkdownTable { headers, rows });
1849    }
1850
1851    tables
1852}
1853
1854fn looks_like_markdown_row(line: &str) -> bool {
1855    line.contains('|')
1856}
1857
1858fn is_markdown_separator(line: &str) -> bool {
1859    let parts = split_markdown_cells(line);
1860    if parts.is_empty() {
1861        return false;
1862    }
1863
1864    parts.iter().all(|part| {
1865        let cell = part.trim();
1866        if cell.is_empty() {
1867            return false;
1868        }
1869
1870        let inner = cell.trim_matches(':');
1871        !inner.is_empty() && inner.chars().all(|character| character == '-')
1872    })
1873}
1874
1875fn parse_markdown_row(line: &str) -> Vec<String> {
1876    split_markdown_cells(line)
1877        .into_iter()
1878        .map(|cell| cell.replace("\\|", "|").trim().to_owned())
1879        .collect()
1880}
1881
1882fn split_markdown_cells(line: &str) -> Vec<String> {
1883    let trimmed = line.trim();
1884    if trimmed.is_empty() {
1885        return Vec::new();
1886    }
1887
1888    let without_prefix = trimmed.strip_prefix('|').unwrap_or(trimmed);
1889    let content = without_prefix.strip_suffix('|').unwrap_or(without_prefix);
1890
1891    content.split('|').map(str::to_owned).collect()
1892}
1893
1894fn json_scope_to_rows(
1895    scope: JsonValue,
1896    inference_options: &TypeInferenceOptions,
1897) -> Vec<Vec<(String, QueryValue)>> {
1898    match scope {
1899        JsonValue::Array(items) => items
1900            .into_iter()
1901            .map(|item| json_item_to_row(item, inference_options))
1902            .collect::<Vec<_>>(),
1903        JsonValue::Object(object) => vec![
1904            object
1905                .into_iter()
1906                .map(|(key, value)| (key, json_to_query_value(value, inference_options)))
1907                .collect(),
1908        ],
1909        scalar => vec![vec![(
1910            "value".to_owned(),
1911            json_to_query_value(scalar, inference_options),
1912        )]],
1913    }
1914}
1915
1916fn json_item_to_row(
1917    item: JsonValue,
1918    inference_options: &TypeInferenceOptions,
1919) -> Vec<(String, QueryValue)> {
1920    match item {
1921        JsonValue::Object(object) => object
1922            .into_iter()
1923            .map(|(key, value)| (key, json_to_query_value(value, inference_options)))
1924            .collect(),
1925        scalar => vec![(
1926            "value".to_owned(),
1927            json_to_query_value(scalar, inference_options),
1928        )],
1929    }
1930}
1931
1932fn json_to_query_value(value: JsonValue, inference_options: &TypeInferenceOptions) -> QueryValue {
1933    match value {
1934        JsonValue::Null => QueryValue::Null,
1935        JsonValue::Bool(flag) => {
1936            apply_inference_overrides(QueryValue::Integer(i64::from(flag)), inference_options)
1937        }
1938        JsonValue::Number(number) => {
1939            if let Some(integer) = number.as_i64() {
1940                apply_inference_overrides(QueryValue::Integer(integer), inference_options)
1941            } else if let Some(real) = number.as_f64() {
1942                apply_inference_overrides(QueryValue::Real(real), inference_options)
1943            } else {
1944                QueryValue::Text(number.to_string())
1945            }
1946        }
1947        JsonValue::String(text) => parse_scalar_value(&text, inference_options),
1948        JsonValue::Array(_) | JsonValue::Object(_) => QueryValue::Text(value.to_string()),
1949    }
1950}
1951
1952/// `object` mode: each key-value pair of a JSON object becomes a separate row with
1953/// columns `key` and `value`.  When the scope is an array each element is processed
1954/// independently.  Non-object, non-array scalars produce a single row.
1955fn json_scope_to_rows_object(
1956    scope: JsonValue,
1957    inference_options: &TypeInferenceOptions,
1958) -> Vec<Vec<(String, QueryValue)>> {
1959    match scope {
1960        JsonValue::Object(object) => object
1961            .into_iter()
1962            .map(|(key, value)| {
1963                vec![
1964                    ("key".to_owned(), QueryValue::Text(key)),
1965                    (
1966                        "value".to_owned(),
1967                        json_to_query_value(value, inference_options),
1968                    ),
1969                ]
1970            })
1971            .collect(),
1972        JsonValue::Array(items) => items
1973            .into_iter()
1974            .flat_map(|item| json_scope_to_rows_object(item, inference_options))
1975            .collect(),
1976        scalar => vec![vec![
1977            ("key".to_owned(), QueryValue::Text("value".to_owned())),
1978            (
1979                "value".to_owned(),
1980                json_to_query_value(scalar, inference_options),
1981            ),
1982        ]],
1983    }
1984}
1985
1986/// `flatten` mode: recursively expand nested JSON objects and arrays into flat key-value
1987/// pairs using dotted paths (`address.city`, `tags.0`, …).  Arrays produce one row per
1988/// top-level element; a non-array root produces a single row.
1989fn json_scope_to_rows_flatten(
1990    scope: JsonValue,
1991    inference_options: &TypeInferenceOptions,
1992) -> Vec<Vec<(String, QueryValue)>> {
1993    match scope {
1994        JsonValue::Array(items) => items
1995            .into_iter()
1996            .map(|item| {
1997                let mut row = Vec::new();
1998                flatten_json_value("", item, &mut row, inference_options);
1999                row
2000            })
2001            .filter(|row| !row.is_empty())
2002            .collect(),
2003        _ => {
2004            let mut row = Vec::new();
2005            flatten_json_value("", scope, &mut row, inference_options);
2006            if row.is_empty() { vec![] } else { vec![row] }
2007        }
2008    }
2009}
2010
2011/// Recursively walks `value`, emitting one `(key, scalar)` pair per leaf into
2012/// `result`.  Nested object keys are joined with `.`; array indices are appended
2013/// with `.N`.
2014fn flatten_json_value(
2015    prefix: &str,
2016    value: JsonValue,
2017    result: &mut Vec<(String, QueryValue)>,
2018    inference_options: &TypeInferenceOptions,
2019) {
2020    match value {
2021        JsonValue::Object(obj) => {
2022            for (k, v) in obj {
2023                let key = if prefix.is_empty() {
2024                    k
2025                } else {
2026                    format!("{prefix}.{k}")
2027                };
2028                flatten_json_value(&key, v, result, inference_options);
2029            }
2030        }
2031        JsonValue::Array(arr) => {
2032            for (i, v) in arr.into_iter().enumerate() {
2033                let key = if prefix.is_empty() {
2034                    i.to_string()
2035                } else {
2036                    format!("{prefix}.{i}")
2037                };
2038                flatten_json_value(&key, v, result, inference_options);
2039            }
2040        }
2041        _ => {
2042            let key = if prefix.is_empty() {
2043                "value".to_owned()
2044            } else {
2045                prefix.to_owned()
2046            };
2047            result.push((key, json_to_query_value(value, inference_options)));
2048        }
2049    }
2050}
2051
2052fn normalize_headers(header_row: &[Data], width: usize) -> Vec<String> {
2053    let mut seen = std::collections::HashMap::new();
2054
2055    (0..width)
2056        .map(|index| {
2057            let base = header_row
2058                .get(index)
2059                .map(cell_to_string)
2060                .filter(|value| !value.trim().is_empty())
2061                .unwrap_or_else(|| format!("column{}", index + 1));
2062
2063            let count = seen
2064                .entry(base.clone())
2065                .and_modify(|count| *count += 1)
2066                .or_insert(1usize);
2067
2068            if *count == 1 {
2069                base
2070            } else {
2071                format!("{base}_{count}")
2072            }
2073        })
2074        .collect()
2075}
2076
2077fn convert_cell(cell: &Data) -> QueryValue {
2078    match cell {
2079        Data::Empty => QueryValue::Null,
2080        Data::Int(value) => QueryValue::Integer(*value),
2081        Data::Float(value) => QueryValue::Real(*value),
2082        Data::String(value) => QueryValue::Text(value.clone()),
2083        Data::Bool(value) => QueryValue::Integer(i64::from(*value)),
2084        Data::DateTime(value) => QueryValue::Text(value.to_string()),
2085        Data::DateTimeIso(value) => QueryValue::Text(value.clone()),
2086        Data::DurationIso(value) => QueryValue::Text(value.clone()),
2087        Data::Error(value) => QueryValue::Text(value.to_string()),
2088    }
2089}
2090
2091fn cell_to_string(cell: &Data) -> String {
2092    match convert_cell(cell) {
2093        QueryValue::Null => String::new(),
2094        QueryValue::Integer(value) => value.to_string(),
2095        QueryValue::Real(value) => value.to_string(),
2096        QueryValue::Text(value) => value,
2097    }
2098}
2099
2100fn register_sheet(
2101    connection: &Connection,
2102    sheet: &SheetData,
2103    table_name: &str,
2104    registered_views: &mut HashSet<String>,
2105) -> Result<()> {
2106    let columns = sheet
2107        .columns
2108        .iter()
2109        .map(|column| quote_identifier(column))
2110        .collect::<Vec<_>>()
2111        .join(", ");
2112
2113    connection
2114        .execute(
2115            &format!("CREATE TABLE {} ({columns})", quote_identifier(table_name)),
2116            [],
2117        )
2118        .context("failed to create sheet table")?;
2119
2120    let table1_key = normalize_view_key("table1");
2121    if table_name == "table" && !registered_views.contains(&table1_key) {
2122        connection
2123            .execute(
2124                &format!(
2125                    "CREATE VIEW {} AS SELECT * FROM {}",
2126                    quote_identifier("table1"),
2127                    quote_identifier("table")
2128                ),
2129                [],
2130            )
2131            .context("failed to register alias view table1 for first input")?;
2132        registered_views.insert(table1_key);
2133    }
2134
2135    let sanitized_sheet_name = sanitize_table_name(&sheet.original_name);
2136    let sanitized_sheet_key = normalize_view_key(&sanitized_sheet_name);
2137    if sanitized_sheet_name != table_name && !registered_views.contains(&sanitized_sheet_key) {
2138        connection
2139            .execute(
2140                &format!(
2141                    "CREATE VIEW {} AS SELECT * FROM {}",
2142                    quote_identifier(&sanitized_sheet_name),
2143                    quote_identifier(table_name)
2144                ),
2145                [],
2146            )
2147            .with_context(|| {
2148                format!("failed to register view for sheet {}", sheet.original_name)
2149            })?;
2150        registered_views.insert(sanitized_sheet_key);
2151    }
2152
2153    if sheet.rows.is_empty() {
2154        return Ok(());
2155    }
2156
2157    let placeholders = vec!["?"; sheet.columns.len()].join(", ");
2158    let insert_sql = format!(
2159        "INSERT INTO {} VALUES ({placeholders})",
2160        quote_identifier(table_name)
2161    );
2162    let mut statement = connection
2163        .prepare(&insert_sql)
2164        .context("failed to prepare insert statement")?;
2165
2166    for row in &sheet.rows {
2167        let values = row.iter().map(to_sql_value).collect::<Vec<_>>();
2168        statement
2169            .execute(params_from_iter(values))
2170            .context("failed to insert sheet row")?;
2171    }
2172
2173    Ok(())
2174}
2175
2176fn execute_query(
2177    connection: &Connection,
2178    query: &str,
2179    params: &[QueryParam],
2180) -> Result<QueryResult> {
2181    let normalized_query = normalize_query_for_reserved_identifiers(query);
2182    let mut statement = connection
2183        .prepare(&normalized_query)
2184        .map_err(|error| enrich_query_prepare_error(connection, query, error))?;
2185
2186    if statement.column_count() == 0 {
2187        bail!("query must return rows");
2188    }
2189
2190    let columns = statement
2191        .column_names()
2192        .into_iter()
2193        .map(str::to_owned)
2194        .collect::<Vec<_>>();
2195
2196    bind_query_params(&mut statement, params)?;
2197
2198    let column_count = statement.column_count();
2199    let mut rows = statement.raw_query();
2200    let mut result_rows = Vec::new();
2201
2202    while let Some(row) = rows.next().context("failed to fetch query row")? {
2203        let mut values = Vec::with_capacity(column_count);
2204
2205        for index in 0..column_count {
2206            values.push(match row.get_ref(index)? {
2207                ValueRef::Null => QueryValue::Null,
2208                ValueRef::Integer(value) => QueryValue::Integer(value),
2209                ValueRef::Real(value) => QueryValue::Real(value),
2210                ValueRef::Text(value) => {
2211                    QueryValue::Text(String::from_utf8_lossy(value).into_owned())
2212                }
2213                ValueRef::Blob(value) => QueryValue::Text(format_blob(value)),
2214            });
2215        }
2216
2217        result_rows.push(values);
2218    }
2219
2220    Ok(QueryResult {
2221        columns,
2222        rows: result_rows,
2223    })
2224}
2225
2226fn enrich_query_prepare_error(
2227    connection: &Connection,
2228    query: &str,
2229    error: rusqlite::Error,
2230) -> anyhow::Error {
2231    let raw_error = error.to_string();
2232
2233    if let Some(table) = raw_error.strip_prefix("no such table: ").map(str::trim) {
2234        let table = simplify_sqlite_missing_identifier(table);
2235        let available_tables = list_loaded_table_names(connection);
2236        let available_suffix = if available_tables.is_empty() {
2237            String::new()
2238        } else {
2239            format!(" Available tables/views: {}.", available_tables.join(", "))
2240        };
2241
2242        return anyhow!(
2243            "query references unknown table '{table}'.{available_suffix} Check your table names or run `qf tables --input ...` to inspect available tables.\nQuery: {query}"
2244        );
2245    }
2246
2247    if let Some(column) = raw_error.strip_prefix("no such column: ").map(str::trim) {
2248        let column = simplify_sqlite_missing_identifier(column);
2249        return anyhow!(
2250            "query references unknown column '{column}'. Check your column names or run `qf schema --input ...` to inspect available columns.\nQuery: {query}"
2251        );
2252    }
2253
2254    anyhow!(error).context(format!("failed to prepare query: {query}"))
2255}
2256
2257fn simplify_sqlite_missing_identifier(identifier: &str) -> &str {
2258    identifier
2259        .split_once(" in ")
2260        .map(|(name, _)| name)
2261        .unwrap_or(identifier)
2262        .trim()
2263}
2264
2265fn list_loaded_table_names(connection: &Connection) -> Vec<String> {
2266    let mut statement = match connection.prepare(
2267        "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
2268    ) {
2269        Ok(statement) => statement,
2270        Err(_) => return Vec::new(),
2271    };
2272
2273    let names = match statement.query_map([], |row| row.get::<_, String>(0)) {
2274        Ok(names) => names,
2275        Err(_) => return Vec::new(),
2276    };
2277
2278    names.filter_map(|name| name.ok()).collect()
2279}
2280
2281fn normalize_query_for_reserved_identifiers(query: &str) -> String {
2282    static RESERVED_TABLE_RE: OnceLock<Regex> = OnceLock::new();
2283
2284    let re = RESERVED_TABLE_RE.get_or_init(|| {
2285        Regex::new(r"(?i)\b(from|join|update|into)\s+table\b")
2286            .expect("reserved table regex should compile")
2287    });
2288
2289    re.replace_all(query, |captures: &regex::Captures<'_>| {
2290        format!("{} \"table\"", &captures[1])
2291    })
2292    .into_owned()
2293}
2294
2295fn bind_query_params(statement: &mut rusqlite::Statement<'_>, params: &[QueryParam]) -> Result<()> {
2296    for param in params {
2297        let mut bound = false;
2298
2299        for prefix in [":", "@", "$"] {
2300            let parameter_name = format!("{prefix}{}", param.name);
2301            if let Some(index) = statement
2302                .parameter_index(&parameter_name)
2303                .with_context(|| format!("failed to inspect parameter {parameter_name}"))?
2304            {
2305                statement
2306                    .raw_bind_parameter(index, to_sql_value(&param.value))
2307                    .with_context(|| format!("failed to bind parameter {parameter_name}"))?;
2308                bound = true;
2309                break;
2310            }
2311        }
2312
2313        if !bound {
2314            bail!("query does not contain parameter :{}", param.name);
2315        }
2316    }
2317
2318    Ok(())
2319}
2320
2321fn to_sql_value(value: &QueryValue) -> Value {
2322    match value {
2323        QueryValue::Null => Value::Null,
2324        QueryValue::Integer(value) => Value::Integer(*value),
2325        QueryValue::Real(value) => Value::Real(*value),
2326        QueryValue::Text(value) => Value::Text(value.clone()),
2327    }
2328}
2329
2330fn display_value(value: &QueryValue) -> String {
2331    match value {
2332        QueryValue::Null => String::new(),
2333        QueryValue::Integer(value) => value.to_string(),
2334        QueryValue::Real(value) => value.to_string(),
2335        QueryValue::Text(value) => value.clone(),
2336    }
2337}
2338
2339fn escape_csv_field(value: &str) -> String {
2340    if value.contains([',', '"', '\n', '\r']) {
2341        format!("\"{}\"", value.replace('"', "\"\""))
2342    } else {
2343        value.to_owned()
2344    }
2345}
2346
2347fn escape_json_string(value: &str) -> String {
2348    let mut escaped = String::from("\"");
2349    for character in value.chars() {
2350        match character {
2351            '"' => escaped.push_str("\\\""),
2352            '\\' => escaped.push_str("\\\\"),
2353            '\n' => escaped.push_str("\\n"),
2354            '\r' => escaped.push_str("\\r"),
2355            '\t' => escaped.push_str("\\t"),
2356            '\u{08}' => escaped.push_str("\\b"),
2357            '\u{0C}' => escaped.push_str("\\f"),
2358            c if c <= '\u{1F}' => {
2359                let _ = write!(&mut escaped, "\\u{:04x}", c as u32);
2360            }
2361            c => escaped.push(c),
2362        }
2363    }
2364    escaped.push('"');
2365    escaped
2366}
2367
2368fn to_json_value(value: &QueryValue) -> String {
2369    match value {
2370        QueryValue::Null => "null".to_owned(),
2371        QueryValue::Integer(number) => number.to_string(),
2372        QueryValue::Real(number) if number.is_finite() => number.to_string(),
2373        QueryValue::Real(_) => "null".to_owned(),
2374        QueryValue::Text(text) => escape_json_string(text),
2375    }
2376}
2377
2378fn escape_markdown_cell(value: &str) -> String {
2379    value.replace('|', "\\|").replace('\n', "<br>")
2380}
2381
2382fn escape_xml_text(value: &str) -> String {
2383    value
2384        .replace('&', "&amp;")
2385        .replace('<', "&lt;")
2386        .replace('>', "&gt;")
2387        .replace('"', "&quot;")
2388        .replace('\'', "&apos;")
2389}
2390
2391fn escape_xml_tag(value: &str) -> String {
2392    let sanitized = value
2393        .chars()
2394        .map(|character| {
2395            if character.is_alphanumeric() || character == '_' || character == '-' {
2396                character
2397            } else {
2398                '_'
2399            }
2400        })
2401        .collect::<String>();
2402
2403    if sanitized.is_empty() {
2404        "element".to_owned()
2405    } else if sanitized.chars().next().unwrap_or('x').is_numeric() {
2406        format!("_{sanitized}")
2407    } else {
2408        sanitized
2409    }
2410}
2411
2412fn format_blob(value: &[u8]) -> String {
2413    let mut formatted = String::from("0x");
2414    for byte in value {
2415        let _ = write!(&mut formatted, "{byte:02x}");
2416    }
2417
2418    formatted
2419}
2420
2421fn quote_identifier(identifier: &str) -> String {
2422    format!("\"{}\"", identifier.replace('"', "\"\""))
2423}
2424
2425fn sanitize_table_name(name: &str) -> String {
2426    let sanitized = name
2427        .chars()
2428        .map(|character| {
2429            if character.is_alphanumeric() || character == '_' {
2430                character
2431            } else {
2432                '_'
2433            }
2434        })
2435        .collect::<String>()
2436        .trim_matches('_')
2437        .to_string();
2438
2439    if sanitized.is_empty() {
2440        "table_view".to_owned()
2441    } else {
2442        sanitized
2443    }
2444}
2445
2446fn normalize_view_key(name: &str) -> String {
2447    name.to_ascii_lowercase()
2448}
2449
2450#[cfg(test)]
2451mod tests {
2452    use std::{
2453        fs,
2454        path::PathBuf,
2455        time::{SystemTime, UNIX_EPOCH},
2456    };
2457
2458    use super::*;
2459
2460    fn temp_path(name: &str) -> PathBuf {
2461        let unique = SystemTime::now()
2462            .duration_since(UNIX_EPOCH)
2463            .expect("time should move forward")
2464            .as_nanos();
2465        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.xlsx"))
2466    }
2467
2468    fn temp_xml_path(name: &str) -> PathBuf {
2469        let unique = SystemTime::now()
2470            .duration_since(UNIX_EPOCH)
2471            .expect("time should move forward")
2472            .as_nanos();
2473        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.xml"))
2474    }
2475
2476    fn temp_csv_path(name: &str) -> PathBuf {
2477        let unique = SystemTime::now()
2478            .duration_since(UNIX_EPOCH)
2479            .expect("time should move forward")
2480            .as_nanos();
2481        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.csv"))
2482    }
2483
2484    fn temp_jsonl_path(name: &str) -> PathBuf {
2485        let unique = SystemTime::now()
2486            .duration_since(UNIX_EPOCH)
2487            .expect("time should move forward")
2488            .as_nanos();
2489        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.jsonl"))
2490    }
2491
2492    fn temp_json_path(name: &str) -> PathBuf {
2493        let unique = SystemTime::now()
2494            .duration_since(UNIX_EPOCH)
2495            .expect("time should move forward")
2496            .as_nanos();
2497        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.json"))
2498    }
2499
2500    fn temp_markdown_path(name: &str) -> PathBuf {
2501        let unique = SystemTime::now()
2502            .duration_since(UNIX_EPOCH)
2503            .expect("time should move forward")
2504            .as_nanos();
2505        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.md"))
2506    }
2507
2508    fn temp_parquet_path(name: &str) -> PathBuf {
2509        let unique = SystemTime::now()
2510            .duration_since(UNIX_EPOCH)
2511            .expect("time should move forward")
2512            .as_nanos();
2513        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.parquet"))
2514    }
2515
2516    fn write_test_workbook(path: &Path) -> Result<()> {
2517        let mut workbook = Workbook::new();
2518        let worksheet = workbook.add_worksheet();
2519
2520        worksheet.write_string(0, 0, "name")?;
2521        worksheet.write_string(0, 1, "price")?;
2522        worksheet.write_string(0, 2, "active")?;
2523        worksheet.write_string(1, 0, "Keyboard")?;
2524        worksheet.write_number(1, 1, 12.5)?;
2525        worksheet.write_boolean(1, 2, true)?;
2526        worksheet.write_string(2, 0, "Cable")?;
2527        worksheet.write_number(2, 1, 5.0)?;
2528        worksheet.write_boolean(2, 2, false)?;
2529
2530        workbook.save(path)?;
2531        Ok(())
2532    }
2533
2534    fn write_test_workbook_on_sheet(path: &Path, sheet_name: &str) -> Result<()> {
2535        let mut workbook = Workbook::new();
2536        let worksheet = workbook.add_worksheet();
2537        worksheet.set_name(sheet_name)?;
2538
2539        worksheet.write_string(0, 0, "name")?;
2540        worksheet.write_string(0, 1, "price")?;
2541        worksheet.write_string(1, 0, "Keyboard")?;
2542        worksheet.write_number(1, 1, 12.5)?;
2543
2544        workbook.save(path)?;
2545        Ok(())
2546    }
2547
2548    fn write_test_xml(path: &Path) -> Result<()> {
2549        fs::write(
2550            path,
2551            r#"<?xml version="1.0" encoding="UTF-8"?>
2552<data>
2553    <row>
2554        <name>Keyboard</name>
2555        <price>12.5</price>
2556        <active>true</active>
2557    </row>
2558    <row>
2559        <name>Cable</name>
2560        <price>5</price>
2561        <active>false</active>
2562    </row>
2563</data>
2564"#,
2565        )?;
2566
2567        Ok(())
2568    }
2569
2570    fn write_test_xml_with_sections(path: &Path) -> Result<()> {
2571        fs::write(
2572            path,
2573            r#"<?xml version="1.0" encoding="UTF-8"?>
2574<root>
2575    <Inventory>
2576        <row>
2577            <name>Keyboard</name>
2578            <price>12.5</price>
2579        </row>
2580    </Inventory>
2581    <Archive>
2582        <row>
2583            <name>Legacy Cable</name>
2584            <price>3.5</price>
2585        </row>
2586    </Archive>
2587</root>
2588"#,
2589        )?;
2590
2591        Ok(())
2592    }
2593
2594    fn write_test_csv(path: &Path) -> Result<()> {
2595        fs::write(
2596            path,
2597            "name,price,active\nKeyboard,12.5,true\nCable,5,false\n",
2598        )?;
2599
2600        Ok(())
2601    }
2602
2603    fn write_test_csv_with_custom_inference_values(path: &Path) -> Result<()> {
2604        fs::write(
2605            path,
2606            "name,amount,flag,date,note\nKeyboard,\"1,5\",YES,31/12/2025,N/A\nCable,\"2,0\",NO,01/01/2026,ok\n",
2607        )?;
2608
2609        Ok(())
2610    }
2611
2612    fn write_test_csv_no_headers(path: &Path) -> Result<()> {
2613        fs::write(path, "Keyboard,12.5,true\nCable,5,false\n")?;
2614
2615        Ok(())
2616    }
2617
2618    fn write_test_csv_for_normalization(path: &Path) -> Result<()> {
2619        fs::write(
2620            path,
2621            "First Name,First-Name, Notes \n Alice ,Alice, hello \n  ,  ,   \n",
2622        )?;
2623
2624        Ok(())
2625    }
2626
2627    fn write_test_jsonl(path: &Path) -> Result<()> {
2628        fs::write(
2629            path,
2630            "{\"name\":\"Keyboard\",\"price\":12.5,\"active\":true}\n{\"name\":\"Cable\",\"price\":5,\"active\":false}\n",
2631        )?;
2632
2633        Ok(())
2634    }
2635
2636    fn write_test_json(path: &Path) -> Result<()> {
2637        fs::write(
2638            path,
2639            "[{\"name\":\"Keyboard\",\"price\":12.5,\"active\":true},{\"name\":\"Cable\",\"price\":5,\"active\":false}]",
2640        )?;
2641
2642        Ok(())
2643    }
2644
2645    fn write_test_json_with_sections(path: &Path) -> Result<()> {
2646        fs::write(
2647            path,
2648            "{\"Inventory\":[{\"name\":\"Keyboard\",\"price\":12.5}],\"Archive\":[{\"name\":\"Legacy Cable\",\"price\":3.5}]}",
2649        )?;
2650
2651        Ok(())
2652    }
2653
2654    fn write_test_markdown_with_tables(path: &Path) -> Result<()> {
2655        fs::write(
2656            path,
2657            r#"# Inventory Report
2658
2659| name | price | active |
2660| --- | ---: | :---: |
2661| Keyboard | 12.5 | true |
2662| Cable | 5 | false |
2663
2664## Archive
2665
2666| name | price |
2667| --- | --- |
2668| Legacy Cable | 3.5 |
2669"#,
2670        )?;
2671
2672        Ok(())
2673    }
2674
2675    fn write_test_markdown_with_headers_only(path: &Path) -> Result<()> {
2676        fs::write(
2677            path,
2678            r#"| name | price |
2679| --- | --- |
2680"#,
2681        )?;
2682
2683        Ok(())
2684    }
2685
2686    #[test]
2687    fn normalizes_duplicate_and_blank_headers() {
2688        let headers = vec![
2689            Data::String("name".into()),
2690            Data::String(String::new()),
2691            Data::String("name".into()),
2692        ];
2693
2694        assert_eq!(
2695            normalize_headers(&headers, 3),
2696            vec!["name", "column2", "name_2"]
2697        );
2698    }
2699
2700    #[test]
2701    fn applies_input_normalization_options_before_query() -> Result<()> {
2702        let csv_path = temp_csv_path("csv-normalization");
2703        write_test_csv_for_normalization(&csv_path)?;
2704        let options = InputNormalizationOptions {
2705            trim: true,
2706            skip_empty_rows: true,
2707            normalize_headers: true,
2708            header_case: Some(HeaderCase::Snake),
2709            dedupe_headers: true,
2710        };
2711
2712        let inputs = [WorkbookInput {
2713            path: &csv_path,
2714            sheet_name: None,
2715            table_name: None,
2716        }];
2717        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
2718            &inputs,
2719            "SELECT first_name, first_name_2, notes FROM table",
2720            &[],
2721            &TypeInferenceOptions::default(),
2722            &options,
2723            &ExtractionOptions::default(),
2724            true,
2725        )?;
2726
2727        assert_eq!(result.columns, vec!["first_name", "first_name_2", "notes"]);
2728        assert_eq!(
2729            result.rows,
2730            vec![vec![
2731                QueryValue::Text("Alice".into()),
2732                QueryValue::Text("Alice".into()),
2733                QueryValue::Text("hello".into())
2734            ]]
2735        );
2736
2737        fs::remove_file(csv_path)?;
2738        Ok(())
2739    }
2740
2741    #[test]
2742    fn normalizes_special_character_headers_to_column_name() {
2743        assert_eq!(
2744            normalize_header_value(" !!! ", 3, Some(&HeaderCase::Snake)),
2745            "column3"
2746        );
2747    }
2748
2749    #[test]
2750    fn dedupe_headers_handles_generated_name_collisions() {
2751        let mut headers = vec![
2752            "name".to_owned(),
2753            "name".to_owned(),
2754            "name_2".to_owned(),
2755            "name".to_owned(),
2756        ];
2757
2758        dedupe_headers(&mut headers);
2759
2760        assert_eq!(headers, vec!["name", "name_2", "name_2_2", "name_3"]);
2761    }
2762
2763    #[test]
2764    fn executes_sql_query_against_sheet() -> Result<()> {
2765        let workbook_path = temp_path("query");
2766        write_test_workbook(&workbook_path)?;
2767
2768        let result = run_query(
2769            &workbook_path,
2770            Some("Sheet1"),
2771            "SELECT name, price FROM table WHERE price > 10 ORDER BY price DESC",
2772            true,
2773        )?;
2774
2775        assert_eq!(result.columns, vec!["name", "price"]);
2776        assert_eq!(
2777            result.rows,
2778            vec![vec![
2779                QueryValue::Text("Keyboard".into()),
2780                QueryValue::Real(12.5)
2781            ]]
2782        );
2783
2784        fs::remove_file(workbook_path)?;
2785        Ok(())
2786    }
2787
2788    #[test]
2789    fn executes_sql_query_against_sheet1_alias() -> Result<()> {
2790        let workbook_path = temp_path("query-sheet1-alias");
2791        write_test_workbook(&workbook_path)?;
2792
2793        let result = run_query(
2794            &workbook_path,
2795            Some("Sheet1"),
2796            "SELECT name, price FROM table1 WHERE price > 10 ORDER BY price DESC",
2797            true,
2798        )?;
2799
2800        assert_eq!(result.columns, vec!["name", "price"]);
2801        assert_eq!(
2802            result.rows,
2803            vec![vec![
2804                QueryValue::Text("Keyboard".into()),
2805                QueryValue::Real(12.5)
2806            ]]
2807        );
2808
2809        fs::remove_file(workbook_path)?;
2810        Ok(())
2811    }
2812
2813    #[test]
2814    fn executes_sql_query_with_named_parameter() -> Result<()> {
2815        let workbook_path = temp_path("query-with-param");
2816        write_test_workbook(&workbook_path)?;
2817
2818        let result = run_query_with_params(
2819            &workbook_path,
2820            Some("Sheet1"),
2821            "SELECT name, price FROM table WHERE price > :min_price ORDER BY price DESC",
2822            &[QueryParam {
2823                name: "min_price".to_owned(),
2824                value: QueryValue::Real(10.0),
2825            }],
2826            true,
2827        )?;
2828
2829        assert_eq!(
2830            result,
2831            QueryResult {
2832                columns: vec!["name".to_owned(), "price".to_owned()],
2833                rows: vec![vec![
2834                    QueryValue::Text("Keyboard".to_owned()),
2835                    QueryValue::Real(12.5),
2836                ]],
2837            }
2838        );
2839
2840        fs::remove_file(&workbook_path)?;
2841        Ok(())
2842    }
2843
2844    #[test]
2845    fn executes_query_against_multiple_workbooks() -> Result<()> {
2846        let workbook_path_1 = temp_path("multi-1");
2847        let workbook_path_2 = temp_path("multi-2");
2848        write_test_workbook(&workbook_path_1)?;
2849        write_test_workbook(&workbook_path_2)?;
2850
2851        let result = run_query_with_params_multi(
2852            &[workbook_path_1.as_path(), workbook_path_2.as_path()],
2853            Some("Sheet1"),
2854            "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
2855            &[],
2856            true,
2857        )?;
2858
2859        assert_eq!(result.columns, vec!["total_rows"]);
2860        assert_eq!(
2861            result.rows,
2862            vec![vec![QueryValue::Integer(2)], vec![QueryValue::Integer(2)]]
2863        );
2864
2865        fs::remove_file(&workbook_path_1)?;
2866        fs::remove_file(&workbook_path_2)?;
2867        Ok(())
2868    }
2869
2870    #[test]
2871    fn executes_query_against_multiple_workbooks_with_distinct_sheet_names() -> Result<()> {
2872        let workbook_path_1 = temp_path("multi-sheet-1");
2873        let workbook_path_2 = temp_path("multi-sheet-2");
2874        write_test_workbook_on_sheet(&workbook_path_1, "Consuntivo")?;
2875        write_test_workbook_on_sheet(&workbook_path_2, "WKL")?;
2876
2877        let result = run_query_with_params_multi_inputs(
2878            &[
2879                WorkbookInput {
2880                    path: workbook_path_1.as_path(),
2881                    sheet_name: Some("Consuntivo"),
2882                    table_name: None,
2883                },
2884                WorkbookInput {
2885                    path: workbook_path_2.as_path(),
2886                    sheet_name: Some("WKL"),
2887                    table_name: None,
2888                },
2889            ],
2890            "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
2891            &[],
2892            true,
2893        )?;
2894
2895        assert_eq!(result.columns, vec!["total_rows"]);
2896        assert_eq!(
2897            result.rows,
2898            vec![vec![QueryValue::Integer(1)], vec![QueryValue::Integer(1)]]
2899        );
2900
2901        fs::remove_file(&workbook_path_1)?;
2902        fs::remove_file(&workbook_path_2)?;
2903        Ok(())
2904    }
2905
2906    #[test]
2907    fn executes_query_with_explicit_table_names() -> Result<()> {
2908        let sales_path = temp_path("explicit-name-sales");
2909        let costs_path = temp_path("explicit-name-costs");
2910        write_test_workbook_on_sheet(&sales_path, "Sheet1")?;
2911        write_test_workbook_on_sheet(&costs_path, "Sheet1")?;
2912
2913        let result = run_query_with_params_multi_inputs(
2914            &[
2915                WorkbookInput {
2916                    path: sales_path.as_path(),
2917                    sheet_name: Some("Sheet1"),
2918                    table_name: Some("sales"),
2919                },
2920                WorkbookInput {
2921                    path: costs_path.as_path(),
2922                    sheet_name: Some("Sheet1"),
2923                    table_name: Some("costs"),
2924                },
2925            ],
2926            "SELECT COUNT(*) AS total_rows FROM sales UNION ALL SELECT COUNT(*) AS total_rows FROM costs",
2927            &[],
2928            true,
2929        )?;
2930
2931        assert_eq!(result.columns, vec!["total_rows"]);
2932        assert_eq!(
2933            result.rows,
2934            vec![vec![QueryValue::Integer(1)], vec![QueryValue::Integer(1)]]
2935        );
2936
2937        fs::remove_file(&sales_path)?;
2938        fs::remove_file(&costs_path)?;
2939        Ok(())
2940    }
2941
2942    #[test]
2943    fn executes_sql_query_against_whole_xml_file() -> Result<()> {
2944        let xml_path = temp_xml_path("xml-whole");
2945        write_test_xml(&xml_path)?;
2946
2947        let result = run_query(
2948            &xml_path,
2949            None,
2950            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
2951            true,
2952        )?;
2953
2954        assert_eq!(result.columns, vec!["name", "price"]);
2955        assert_eq!(
2956            result.rows,
2957            vec![vec![
2958                QueryValue::Text("Keyboard".into()),
2959                QueryValue::Real(12.5)
2960            ]]
2961        );
2962
2963        fs::remove_file(xml_path)?;
2964        Ok(())
2965    }
2966
2967    #[test]
2968    fn executes_sql_query_against_xml_sheet_tag() -> Result<()> {
2969        let xml_path = temp_xml_path("xml-sheet-tag");
2970        write_test_xml_with_sections(&xml_path)?;
2971
2972        let result = run_query(
2973            &xml_path,
2974            Some("Archive"),
2975            "SELECT name, price FROM table",
2976            true,
2977        )?;
2978
2979        assert_eq!(result.columns, vec!["name", "price"]);
2980        assert_eq!(
2981            result.rows,
2982            vec![vec![
2983                QueryValue::Text("Legacy Cable".into()),
2984                QueryValue::Real(3.5)
2985            ]]
2986        );
2987
2988        fs::remove_file(xml_path)?;
2989        Ok(())
2990    }
2991
2992    #[test]
2993    fn executes_query_against_heterogeneous_xlsx_and_xml_inputs() -> Result<()> {
2994        let workbook_path = temp_path("mixed-xlsx");
2995        let xml_path = temp_xml_path("mixed-xml");
2996        write_test_workbook(&workbook_path)?;
2997        write_test_xml(&xml_path)?;
2998
2999        let result = run_query_with_params_multi_inputs(
3000            &[
3001                WorkbookInput {
3002                    path: workbook_path.as_path(),
3003                    sheet_name: Some("Sheet1"),
3004                    table_name: None,
3005                },
3006                WorkbookInput {
3007                    path: xml_path.as_path(),
3008                    sheet_name: None,
3009                    table_name: None,
3010                },
3011            ],
3012            "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
3013            &[],
3014            true,
3015        )?;
3016
3017        assert_eq!(result.columns, vec!["total_rows"]);
3018        assert_eq!(
3019            result.rows,
3020            vec![vec![QueryValue::Integer(2)], vec![QueryValue::Integer(2)]]
3021        );
3022
3023        fs::remove_file(&workbook_path)?;
3024        fs::remove_file(&xml_path)?;
3025        Ok(())
3026    }
3027
3028    #[test]
3029    fn executes_sql_query_against_csv_file() -> Result<()> {
3030        let csv_path = temp_csv_path("csv");
3031        write_test_csv(&csv_path)?;
3032
3033        let result = run_query(
3034            &csv_path,
3035            None,
3036            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
3037            true,
3038        )?;
3039
3040        assert_eq!(result.columns, vec!["name", "price"]);
3041        assert_eq!(
3042            result.rows,
3043            vec![vec![
3044                QueryValue::Text("Keyboard".into()),
3045                QueryValue::Real(12.5)
3046            ]]
3047        );
3048
3049        fs::remove_file(csv_path)?;
3050        Ok(())
3051    }
3052
3053    #[test]
3054    fn supports_configurable_type_inference_options() -> Result<()> {
3055        let csv_path = temp_csv_path("csv-custom-inference");
3056        write_test_csv_with_custom_inference_values(&csv_path)?;
3057
3058        let options = TypeInferenceOptions {
3059            infer_types: true,
3060            decimal_comma: true,
3061            date_format: Some("%d/%m/%Y".to_owned()),
3062            null_values: vec!["N/A".to_owned()],
3063            true_values: vec!["YES".to_owned()],
3064            false_values: vec!["NO".to_owned()],
3065        };
3066
3067        let workbook_inputs = [WorkbookInput {
3068            path: csv_path.as_path(),
3069            sheet_name: None,
3070            table_name: None,
3071        }];
3072        let result = run_query_with_params_multi_inputs_and_options(
3073            &workbook_inputs,
3074            "SELECT amount, flag, date, note FROM table ORDER BY amount",
3075            &[],
3076            &options,
3077            true,
3078        )?;
3079
3080        assert_eq!(result.columns, vec!["amount", "flag", "date", "note"]);
3081        assert_eq!(
3082            result.rows,
3083            vec![
3084                vec![
3085                    QueryValue::Real(1.5),
3086                    QueryValue::Integer(1),
3087                    QueryValue::Text("2025-12-31".into()),
3088                    QueryValue::Null
3089                ],
3090                vec![
3091                    QueryValue::Real(2.0),
3092                    QueryValue::Integer(0),
3093                    QueryValue::Text("2026-01-01".into()),
3094                    QueryValue::Text("ok".into())
3095                ]
3096            ]
3097        );
3098
3099        fs::remove_file(csv_path)?;
3100        Ok(())
3101    }
3102
3103    #[test]
3104    fn supports_all_text_mode() -> Result<()> {
3105        let csv_path = temp_csv_path("csv-all-text");
3106        write_test_csv(&csv_path)?;
3107
3108        let options = TypeInferenceOptions {
3109            infer_types: false,
3110            ..TypeInferenceOptions::default()
3111        };
3112        let workbook_inputs = [WorkbookInput {
3113            path: csv_path.as_path(),
3114            sheet_name: None,
3115            table_name: None,
3116        }];
3117        let result = run_query_with_params_multi_inputs_and_options(
3118            &workbook_inputs,
3119            "SELECT name, price, active FROM table ORDER BY name DESC",
3120            &[],
3121            &options,
3122            true,
3123        )?;
3124
3125        assert_eq!(
3126            result.rows,
3127            vec![
3128                vec![
3129                    QueryValue::Text("Keyboard".into()),
3130                    QueryValue::Text("12.5".into()),
3131                    QueryValue::Text("true".into())
3132                ],
3133                vec![
3134                    QueryValue::Text("Cable".into()),
3135                    QueryValue::Text("5".into()),
3136                    QueryValue::Text("false".into())
3137                ]
3138            ]
3139        );
3140
3141        fs::remove_file(csv_path)?;
3142        Ok(())
3143    }
3144
3145    #[test]
3146    fn executes_sql_query_against_csv_without_headers() -> Result<()> {
3147        let csv_path = temp_csv_path("csv-no-headers");
3148        write_test_csv_no_headers(&csv_path)?;
3149
3150        let result = run_query(
3151            &csv_path,
3152            None,
3153            "SELECT column1, column2 FROM table WHERE column3 = 1 ORDER BY column2 DESC",
3154            false,
3155        )?;
3156
3157        assert_eq!(result.columns, vec!["column1", "column2"]);
3158        assert_eq!(
3159            result.rows,
3160            vec![vec![
3161                QueryValue::Text("Keyboard".into()),
3162                QueryValue::Real(12.5)
3163            ]]
3164        );
3165
3166        fs::remove_file(csv_path)?;
3167        Ok(())
3168    }
3169
3170    #[test]
3171    fn executes_sql_query_against_jsonl_file() -> Result<()> {
3172        let jsonl_path = temp_jsonl_path("jsonl");
3173        write_test_jsonl(&jsonl_path)?;
3174
3175        let result = run_query(
3176            &jsonl_path,
3177            None,
3178            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
3179            true,
3180        )?;
3181
3182        assert_eq!(result.columns, vec!["name", "price"]);
3183        assert_eq!(
3184            result.rows,
3185            vec![vec![
3186                QueryValue::Text("Keyboard".into()),
3187                QueryValue::Real(12.5)
3188            ]]
3189        );
3190
3191        fs::remove_file(jsonl_path)?;
3192        Ok(())
3193    }
3194
3195    #[test]
3196    fn executes_sql_query_against_json_file() -> Result<()> {
3197        let json_path = temp_json_path("json");
3198        write_test_json(&json_path)?;
3199
3200        let result = run_query(
3201            &json_path,
3202            None,
3203            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
3204            true,
3205        )?;
3206
3207        assert_eq!(result.columns, vec!["name", "price"]);
3208        assert_eq!(
3209            result.rows,
3210            vec![vec![
3211                QueryValue::Text("Keyboard".into()),
3212                QueryValue::Real(12.5)
3213            ]]
3214        );
3215
3216        fs::remove_file(json_path)?;
3217        Ok(())
3218    }
3219
3220    #[test]
3221    fn executes_sql_query_against_json_sheet_key() -> Result<()> {
3222        let json_path = temp_json_path("json-sheet-key");
3223        write_test_json_with_sections(&json_path)?;
3224
3225        let result = run_query(
3226            &json_path,
3227            Some("Archive"),
3228            "SELECT name, price FROM table",
3229            true,
3230        )?;
3231
3232        assert_eq!(result.columns, vec!["name", "price"]);
3233        assert_eq!(
3234            result.rows,
3235            vec![vec![
3236                QueryValue::Text("Legacy Cable".into()),
3237                QueryValue::Real(3.5)
3238            ]]
3239        );
3240
3241        fs::remove_file(json_path)?;
3242        Ok(())
3243    }
3244
3245    #[test]
3246    fn executes_sql_query_against_first_markdown_table_by_default() -> Result<()> {
3247        let markdown_path = temp_markdown_path("markdown-default");
3248        write_test_markdown_with_tables(&markdown_path)?;
3249
3250        let result = run_query(
3251            &markdown_path,
3252            None,
3253            "SELECT name, price FROM table WHERE active = 1",
3254            true,
3255        )?;
3256
3257        assert_eq!(result.columns, vec!["name", "price"]);
3258        assert_eq!(
3259            result.rows,
3260            vec![vec![
3261                QueryValue::Text("Keyboard".into()),
3262                QueryValue::Real(12.5)
3263            ]]
3264        );
3265
3266        fs::remove_file(markdown_path)?;
3267        Ok(())
3268    }
3269
3270    #[test]
3271    fn reports_actionable_error_for_csv_selector() -> Result<()> {
3272        let csv_path = temp_csv_path("csv-selector-error");
3273        write_test_csv(&csv_path)?;
3274
3275        let error = run_query(&csv_path, Some("Sheet1"), "SELECT name FROM table", true)
3276            .expect_err("CSV selector should fail");
3277        let message = error.to_string();
3278        assert!(message.contains("does not support selector 'Sheet1'"));
3279        assert!(message.contains("Remove ':Sheet1'"));
3280
3281        fs::remove_file(csv_path)?;
3282        Ok(())
3283    }
3284
3285    #[test]
3286    fn reports_actionable_error_for_jsonl_selector() -> Result<()> {
3287        let jsonl_path = temp_jsonl_path("jsonl-selector-error");
3288        write_test_jsonl(&jsonl_path)?;
3289
3290        let error = run_query(&jsonl_path, Some("Records"), "SELECT name FROM table", true)
3291            .expect_err("JSONL selector should fail");
3292        let message = error.to_string();
3293        assert!(message.contains("does not support selector 'Records'"));
3294        assert!(message.contains("Remove ':Records'"));
3295
3296        fs::remove_file(jsonl_path)?;
3297        Ok(())
3298    }
3299
3300    #[test]
3301    fn reports_json_key_error_with_available_keys() -> Result<()> {
3302        let json_path = temp_json_path("json-missing-key");
3303        write_test_json_with_sections(&json_path)?;
3304
3305        let error = run_query(&json_path, Some("Missing"), "SELECT name FROM table", true)
3306            .expect_err("missing JSON key should fail");
3307        let message = error.to_string();
3308        assert!(message.contains("JSON key 'Missing' not found"));
3309        assert!(message.contains("Available keys: Archive, Inventory"));
3310
3311        fs::remove_file(json_path)?;
3312        Ok(())
3313    }
3314
3315    #[test]
3316    fn reports_invalid_markdown_selector_with_guidance() -> Result<()> {
3317        let markdown_path = temp_markdown_path("markdown-invalid-selector");
3318        write_test_markdown_with_tables(&markdown_path)?;
3319
3320        let error = run_query(&markdown_path, Some("abc"), "SELECT name FROM table", true)
3321            .expect_err("non-numeric markdown selector should fail");
3322        let message = error.to_string();
3323        assert!(message.contains("invalid Markdown table selector 'abc'"));
3324        assert!(message.contains("':1'"));
3325
3326        fs::remove_file(markdown_path)?;
3327        Ok(())
3328    }
3329
3330    #[test]
3331    fn reports_empty_markdown_table_as_actionable_error() -> Result<()> {
3332        let markdown_path = temp_markdown_path("markdown-empty-table");
3333        write_test_markdown_with_headers_only(&markdown_path)?;
3334
3335        let error = run_query(&markdown_path, None, "SELECT name FROM table", true)
3336            .expect_err("empty markdown table should fail");
3337        let message = error.to_string();
3338        assert!(message.contains("is empty (no data rows)"));
3339
3340        fs::remove_file(markdown_path)?;
3341        Ok(())
3342    }
3343
3344    #[test]
3345    fn reports_unknown_table_with_inspection_hint() -> Result<()> {
3346        let workbook_path = temp_path("unknown-table-query");
3347        write_test_workbook(&workbook_path)?;
3348
3349        let error = run_query(
3350            &workbook_path,
3351            Some("Sheet1"),
3352            "SELECT * FROM missing_table",
3353            true,
3354        )
3355        .expect_err("unknown table should fail");
3356        let message = error.to_string();
3357        assert!(message.contains("unknown table 'missing_table'"));
3358        assert!(message.contains("qf tables --input"));
3359
3360        fs::remove_file(workbook_path)?;
3361        Ok(())
3362    }
3363
3364    #[test]
3365    fn reports_unknown_column_with_schema_hint() -> Result<()> {
3366        let workbook_path = temp_path("unknown-column-query");
3367        write_test_workbook(&workbook_path)?;
3368
3369        let error = run_query(
3370            &workbook_path,
3371            Some("Sheet1"),
3372            "SELECT missing_column FROM table",
3373            true,
3374        )
3375        .expect_err("unknown column should fail");
3376        let message = error.to_string();
3377        assert!(message.contains("unknown column 'missing_column'"));
3378        assert!(message.contains("qf schema --input"));
3379
3380        fs::remove_file(workbook_path)?;
3381        Ok(())
3382    }
3383
3384    #[test]
3385    fn executes_sql_query_against_markdown_table_by_numeric_key() -> Result<()> {
3386        let markdown_path = temp_markdown_path("markdown-key");
3387        write_test_markdown_with_tables(&markdown_path)?;
3388
3389        let result = run_query(
3390            &markdown_path,
3391            Some("2"),
3392            "SELECT name, price FROM table",
3393            true,
3394        )?;
3395
3396        assert_eq!(result.columns, vec!["name", "price"]);
3397        assert_eq!(
3398            result.rows,
3399            vec![vec![
3400                QueryValue::Text("Legacy Cable".into()),
3401                QueryValue::Real(3.5)
3402            ]]
3403        );
3404
3405        fs::remove_file(markdown_path)?;
3406        Ok(())
3407    }
3408
3409    #[test]
3410    fn executes_query_against_heterogeneous_xlsx_xml_csv_jsonl_json_markdown_inputs() -> Result<()>
3411    {
3412        let workbook_path = temp_path("mixed4-xlsx");
3413        let xml_path = temp_xml_path("mixed4-xml");
3414        let csv_path = temp_csv_path("mixed4-csv");
3415        let jsonl_path = temp_jsonl_path("mixed4-jsonl");
3416        let json_path = temp_json_path("mixed4-json");
3417        let markdown_path = temp_markdown_path("mixed4-markdown");
3418        write_test_workbook(&workbook_path)?;
3419        write_test_xml(&xml_path)?;
3420        write_test_csv(&csv_path)?;
3421        write_test_jsonl(&jsonl_path)?;
3422        write_test_json(&json_path)?;
3423        write_test_markdown_with_tables(&markdown_path)?;
3424
3425        let result = run_query_with_params_multi_inputs(
3426            &[
3427                WorkbookInput {
3428                    path: workbook_path.as_path(),
3429                    sheet_name: Some("Sheet1"),
3430                    table_name: None,
3431                },
3432                WorkbookInput {
3433                    path: xml_path.as_path(),
3434                    sheet_name: None,
3435                    table_name: None,
3436                },
3437                WorkbookInput {
3438                    path: csv_path.as_path(),
3439                    sheet_name: None,
3440                    table_name: None,
3441                },
3442                WorkbookInput {
3443                    path: jsonl_path.as_path(),
3444                    sheet_name: None,
3445                    table_name: None,
3446                },
3447                WorkbookInput {
3448                    path: json_path.as_path(),
3449                    sheet_name: None,
3450                    table_name: None,
3451                },
3452                WorkbookInput {
3453                    path: markdown_path.as_path(),
3454                    sheet_name: None,
3455                    table_name: None,
3456                },
3457            ],
3458            "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2 UNION ALL SELECT COUNT(*) AS total_rows FROM table3 UNION ALL SELECT COUNT(*) AS total_rows FROM table4 UNION ALL SELECT COUNT(*) AS total_rows FROM table5 UNION ALL SELECT COUNT(*) AS total_rows FROM table6",
3459            &[],
3460            true,
3461        )?;
3462
3463        assert_eq!(result.columns, vec!["total_rows"]);
3464        assert_eq!(
3465            result.rows,
3466            vec![
3467                vec![QueryValue::Integer(2)],
3468                vec![QueryValue::Integer(2)],
3469                vec![QueryValue::Integer(2)],
3470                vec![QueryValue::Integer(2)],
3471                vec![QueryValue::Integer(2)],
3472                vec![QueryValue::Integer(2)]
3473            ]
3474        );
3475
3476        fs::remove_file(&workbook_path)?;
3477        fs::remove_file(&xml_path)?;
3478        fs::remove_file(&csv_path)?;
3479        fs::remove_file(&jsonl_path)?;
3480        fs::remove_file(&json_path)?;
3481        fs::remove_file(&markdown_path)?;
3482        Ok(())
3483    }
3484
3485    #[test]
3486    fn writes_query_result_to_xlsx() -> Result<()> {
3487        let output_path = temp_path("output");
3488        let result = QueryResult {
3489            columns: vec!["item".into(), "total".into()],
3490            rows: vec![vec![
3491                QueryValue::Text("Mouse".into()),
3492                QueryValue::Integer(3),
3493            ]],
3494        };
3495
3496        write_xlsx(&result, &output_path)?;
3497
3498        let written = run_query(
3499            &output_path,
3500            Some("Sheet1"),
3501            "SELECT item, total FROM table",
3502            true,
3503        )?;
3504
3505        assert_eq!(written.columns, vec!["item", "total"]);
3506        assert_eq!(
3507            written.rows,
3508            vec![vec![
3509                QueryValue::Text("Mouse".into()),
3510                QueryValue::Real(3.0)
3511            ]]
3512        );
3513
3514        fs::remove_file(output_path)?;
3515        Ok(())
3516    }
3517
3518    #[test]
3519    fn writes_query_result_to_parquet() -> Result<()> {
3520        let output_path = temp_parquet_path("output");
3521        let result = QueryResult {
3522            columns: vec!["item".into(), "qty".into(), "price".into()],
3523            rows: vec![
3524                vec![
3525                    QueryValue::Text("Mouse".into()),
3526                    QueryValue::Integer(3),
3527                    QueryValue::Real(9.99),
3528                ],
3529                vec![
3530                    QueryValue::Text("Keyboard".into()),
3531                    QueryValue::Integer(1),
3532                    QueryValue::Null,
3533                ],
3534            ],
3535        };
3536
3537        write_parquet(&result, &output_path)?;
3538
3539        // Verify the file exists, is non-empty, and has the Parquet magic bytes (PAR1).
3540        let bytes = fs::read(&output_path)?;
3541        assert!(bytes.len() > 8, "parquet file should have content");
3542        assert_eq!(&bytes[..4], b"PAR1", "parquet file should start with PAR1");
3543        assert_eq!(
3544            &bytes[bytes.len() - 4..],
3545            b"PAR1",
3546            "parquet file should end with PAR1"
3547        );
3548
3549        fs::remove_file(output_path)?;
3550        Ok(())
3551    }
3552
3553    #[test]
3554    fn executes_sql_query_against_parquet_file() -> Result<()> {
3555        let parquet_path = temp_parquet_path("input");
3556        let result = QueryResult {
3557            columns: vec!["name".into(), "price".into(), "active".into()],
3558            rows: vec![
3559                vec![
3560                    QueryValue::Text("Keyboard".into()),
3561                    QueryValue::Real(12.5),
3562                    QueryValue::Integer(1),
3563                ],
3564                vec![
3565                    QueryValue::Text("Cable".into()),
3566                    QueryValue::Real(5.0),
3567                    QueryValue::Integer(0),
3568                ],
3569            ],
3570        };
3571        write_parquet(&result, &parquet_path)?;
3572
3573        let queried = run_query(
3574            &parquet_path,
3575            None,
3576            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
3577            true,
3578        )?;
3579
3580        assert_eq!(queried.columns, vec!["name", "price"]);
3581        assert_eq!(
3582            queried.rows,
3583            vec![vec![
3584                QueryValue::Text("Keyboard".into()),
3585                QueryValue::Real(12.5)
3586            ]]
3587        );
3588
3589        fs::remove_file(parquet_path)?;
3590        Ok(())
3591    }
3592
3593    #[test]
3594    fn reports_actionable_error_for_parquet_selector() -> Result<()> {
3595        let parquet_path = temp_parquet_path("parquet-selector-error");
3596        let result = QueryResult {
3597            columns: vec!["name".into()],
3598            rows: vec![vec![QueryValue::Text("Keyboard".into())]],
3599        };
3600        write_parquet(&result, &parquet_path)?;
3601
3602        let error = run_query(
3603            &parquet_path,
3604            Some("Sheet1"),
3605            "SELECT name FROM table",
3606            true,
3607        )
3608        .expect_err("Parquet selector should fail");
3609        let message = error.to_string();
3610        assert!(message.contains("does not support selector 'Sheet1'"));
3611        assert!(message.contains("Remove ':Sheet1'"));
3612
3613        fs::remove_file(parquet_path)?;
3614        Ok(())
3615    }
3616
3617    #[test]
3618    fn renders_csv_with_escaping() {
3619        let result = QueryResult {
3620            columns: vec!["name".into(), "notes".into()],
3621            rows: vec![vec![
3622                QueryValue::Text("Mouse".into()),
3623                QueryValue::Text("line1,line2".into()),
3624            ]],
3625        };
3626
3627        assert_eq!(render_csv(&result), "name,notes\nMouse,\"line1,line2\"");
3628    }
3629
3630    #[test]
3631    fn renders_text_with_aligned_columns() {
3632        let result = QueryResult {
3633            columns: vec!["mese".into(), "totale_ore".into()],
3634            rows: vec![
3635                vec![
3636                    QueryValue::Text("2026-01-01".into()),
3637                    QueryValue::Real(10.5),
3638                ],
3639                vec![
3640                    QueryValue::Text("2026-12-01".into()),
3641                    QueryValue::Integer(2),
3642                ],
3643            ],
3644        };
3645
3646        assert_eq!(
3647            render_text(&result),
3648            "mese       | totale_ore\n-----------+-----------\n2026-01-01 | 10.5      \n2026-12-01 | 2         "
3649        );
3650    }
3651
3652    #[test]
3653    fn renders_jsonl() {
3654        let result = QueryResult {
3655            columns: vec!["item".into(), "stock".into(), "note".into()],
3656            rows: vec![vec![
3657                QueryValue::Text("Desk".into()),
3658                QueryValue::Integer(8),
3659                QueryValue::Null,
3660            ]],
3661        };
3662
3663        assert_eq!(
3664            render_jsonl(&result),
3665            "{\"item\":\"Desk\",\"stock\":8,\"note\":null}"
3666        );
3667    }
3668
3669    #[test]
3670    fn renders_json() {
3671        let result = QueryResult {
3672            columns: vec!["item".into(), "stock".into(), "note".into()],
3673            rows: vec![vec![
3674                QueryValue::Text("Desk".into()),
3675                QueryValue::Integer(8),
3676                QueryValue::Null,
3677            ]],
3678        };
3679
3680        assert_eq!(
3681            render_json(&result),
3682            "[{\"item\":\"Desk\",\"stock\":8,\"note\":null}]"
3683        );
3684    }
3685
3686    #[test]
3687    fn renders_markdown() {
3688        let result = QueryResult {
3689            columns: vec!["category".into(), "total".into()],
3690            rows: vec![vec![
3691                QueryValue::Text("electronics".into()),
3692                QueryValue::Integer(47),
3693            ]],
3694        };
3695
3696        assert_eq!(
3697            render_markdown(&result),
3698            "| category | total |\n| --- | --- |\n| electronics | 47 |"
3699        );
3700    }
3701
3702    // ── JSON extraction modes ─────────────────────────────────────────────────
3703
3704    fn write_test_json_object(path: &Path) -> Result<()> {
3705        fs::write(
3706            path,
3707            r#"{"name":"Alice","age":30,"city":"Rome"}"#,
3708        )?;
3709        Ok(())
3710    }
3711
3712    fn write_test_json_nested(path: &Path) -> Result<()> {
3713        fs::write(
3714            path,
3715            r#"[{"user":{"name":"Alice","address":{"city":"Rome"}},"score":10},{"user":{"name":"Bob","address":{"city":"Paris"}},"score":20}]"#,
3716        )?;
3717        Ok(())
3718    }
3719
3720    #[test]
3721    fn json_object_mode_turns_keys_into_rows() -> Result<()> {
3722        let json_path = temp_json_path("json-object-mode");
3723        write_test_json_object(&json_path)?;
3724
3725        let opts = ExtractionOptions {
3726            json_mode: JsonMode::Object,
3727            ..ExtractionOptions::default()
3728        };
3729        let inputs = [WorkbookInput {
3730            path: &json_path,
3731            sheet_name: None,
3732            table_name: None,
3733        }];
3734        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
3735            &inputs,
3736            "SELECT key, value FROM table ORDER BY key",
3737            &[],
3738            &TypeInferenceOptions::default(),
3739            &InputNormalizationOptions::default(),
3740            &opts,
3741            true,
3742        )?;
3743
3744        assert_eq!(result.columns, vec!["key", "value"]);
3745        // Three key-value rows, sorted by key.
3746        assert_eq!(result.rows.len(), 3);
3747        assert_eq!(result.rows[0][0], QueryValue::Text("age".into()));
3748        assert_eq!(result.rows[0][1], QueryValue::Integer(30));
3749        assert_eq!(result.rows[1][0], QueryValue::Text("city".into()));
3750        assert_eq!(result.rows[1][1], QueryValue::Text("Rome".into()));
3751        assert_eq!(result.rows[2][0], QueryValue::Text("name".into()));
3752        assert_eq!(result.rows[2][1], QueryValue::Text("Alice".into()));
3753
3754        fs::remove_file(json_path)?;
3755        Ok(())
3756    }
3757
3758    #[test]
3759    fn json_flatten_mode_expands_nested_objects() -> Result<()> {
3760        let json_path = temp_json_path("json-flatten-mode");
3761        write_test_json_nested(&json_path)?;
3762
3763        let opts = ExtractionOptions {
3764            json_mode: JsonMode::Flatten,
3765            ..ExtractionOptions::default()
3766        };
3767        let inputs = [WorkbookInput {
3768            path: &json_path,
3769            sheet_name: None,
3770            table_name: None,
3771        }];
3772        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
3773            &inputs,
3774            "SELECT \"user.name\", \"user.address.city\", score FROM table ORDER BY score",
3775            &[],
3776            &TypeInferenceOptions::default(),
3777            &InputNormalizationOptions::default(),
3778            &opts,
3779            true,
3780        )?;
3781
3782        assert!(result.columns.contains(&"user.name".to_owned()));
3783        assert!(result.columns.contains(&"user.address.city".to_owned()));
3784        assert_eq!(result.rows.len(), 2);
3785        // First row: Alice / Rome / 10
3786        let alice_col = result.columns.iter().position(|c| c == "user.name").unwrap();
3787        let city_col = result.columns.iter().position(|c| c == "user.address.city").unwrap();
3788        let score_col = result.columns.iter().position(|c| c == "score").unwrap();
3789        assert_eq!(result.rows[0][alice_col], QueryValue::Text("Alice".into()));
3790        assert_eq!(result.rows[0][city_col], QueryValue::Text("Rome".into()));
3791        assert_eq!(result.rows[0][score_col], QueryValue::Integer(10));
3792
3793        fs::remove_file(json_path)?;
3794        Ok(())
3795    }
3796
3797    #[test]
3798    fn json_array_mode_is_default_behavior() -> Result<()> {
3799        let json_path = temp_json_path("json-array-mode-default");
3800        write_test_json(&json_path)?;
3801
3802        let opts = ExtractionOptions {
3803            json_mode: JsonMode::Array,
3804            ..ExtractionOptions::default()
3805        };
3806        let inputs = [WorkbookInput {
3807            path: &json_path,
3808            sheet_name: None,
3809            table_name: None,
3810        }];
3811        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
3812            &inputs,
3813            "SELECT name, price FROM table WHERE active = 1",
3814            &[],
3815            &TypeInferenceOptions::default(),
3816            &InputNormalizationOptions::default(),
3817            &opts,
3818            true,
3819        )?;
3820
3821        assert_eq!(result.columns, vec!["name", "price"]);
3822        assert_eq!(
3823            result.rows,
3824            vec![vec![
3825                QueryValue::Text("Keyboard".into()),
3826                QueryValue::Real(12.5)
3827            ]]
3828        );
3829
3830        fs::remove_file(json_path)?;
3831        Ok(())
3832    }
3833
3834    // ── XML extraction modes ──────────────────────────────────────────────────
3835
3836    fn write_test_xml_with_attributes(path: &Path) -> Result<()> {
3837        fs::write(
3838            path,
3839            r#"<?xml version="1.0" encoding="UTF-8"?>
3840<products>
3841    <product id="1" name="Keyboard" price="12.5" active="true"/>
3842    <product id="2" name="Cable" price="5" active="false"/>
3843</products>
3844"#,
3845        )?;
3846        Ok(())
3847    }
3848
3849    fn write_test_xml_leaf_elements(path: &Path) -> Result<()> {
3850        fs::write(
3851            path,
3852            r#"<?xml version="1.0" encoding="UTF-8"?>
3853<config>
3854    <host>localhost</host>
3855    <port>5432</port>
3856    <database>mydb</database>
3857</config>
3858"#,
3859        )?;
3860        Ok(())
3861    }
3862
3863    #[test]
3864    fn xml_attributes_mode_extracts_attributes_as_columns() -> Result<()> {
3865        let xml_path = temp_xml_path("xml-attributes-mode");
3866        write_test_xml_with_attributes(&xml_path)?;
3867
3868        let opts = ExtractionOptions {
3869            xml_mode: XmlMode::Attributes,
3870            ..ExtractionOptions::default()
3871        };
3872        let inputs = [WorkbookInput {
3873            path: &xml_path,
3874            sheet_name: None,
3875            table_name: None,
3876        }];
3877        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
3878            &inputs,
3879            "SELECT id, name, price FROM table ORDER BY id",
3880            &[],
3881            &TypeInferenceOptions::default(),
3882            &InputNormalizationOptions::default(),
3883            &opts,
3884            true,
3885        )?;
3886
3887        assert_eq!(result.rows.len(), 2);
3888        let id_col = result.columns.iter().position(|c| c == "id").unwrap();
3889        let name_col = result.columns.iter().position(|c| c == "name").unwrap();
3890        assert_eq!(result.rows[0][id_col], QueryValue::Integer(1));
3891        assert_eq!(result.rows[0][name_col], QueryValue::Text("Keyboard".into()));
3892        assert_eq!(result.rows[1][id_col], QueryValue::Integer(2));
3893        assert_eq!(result.rows[1][name_col], QueryValue::Text("Cable".into()));
3894
3895        fs::remove_file(xml_path)?;
3896        Ok(())
3897    }
3898
3899    #[test]
3900    fn xml_descendants_mode_collects_leaf_elements_as_tag_value_rows() -> Result<()> {
3901        let xml_path = temp_xml_path("xml-descendants-mode");
3902        write_test_xml_leaf_elements(&xml_path)?;
3903
3904        let opts = ExtractionOptions {
3905            xml_mode: XmlMode::Descendants,
3906            ..ExtractionOptions::default()
3907        };
3908        let inputs = [WorkbookInput {
3909            path: &xml_path,
3910            sheet_name: None,
3911            table_name: None,
3912        }];
3913        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
3914            &inputs,
3915            "SELECT tag, value FROM table ORDER BY tag",
3916            &[],
3917            &TypeInferenceOptions::default(),
3918            &InputNormalizationOptions::default(),
3919            &opts,
3920            true,
3921        )?;
3922
3923        assert_eq!(result.columns, vec!["tag", "value"]);
3924        assert_eq!(result.rows.len(), 3);
3925        // Sorted alphabetically: database, host, port
3926        assert_eq!(result.rows[0][0], QueryValue::Text("database".into()));
3927        assert_eq!(result.rows[0][1], QueryValue::Text("mydb".into()));
3928        assert_eq!(result.rows[1][0], QueryValue::Text("host".into()));
3929        assert_eq!(result.rows[1][1], QueryValue::Text("localhost".into()));
3930        assert_eq!(result.rows[2][0], QueryValue::Text("port".into()));
3931        assert_eq!(result.rows[2][1], QueryValue::Integer(5432));
3932
3933        fs::remove_file(xml_path)?;
3934        Ok(())
3935    }
3936
3937    #[test]
3938    fn xml_rows_mode_is_default_behavior() -> Result<()> {
3939        let xml_path = temp_xml_path("xml-rows-mode-default");
3940        write_test_xml(&xml_path)?;
3941
3942        let opts = ExtractionOptions {
3943            xml_mode: XmlMode::Rows,
3944            ..ExtractionOptions::default()
3945        };
3946        let inputs = [WorkbookInput {
3947            path: &xml_path,
3948            sheet_name: None,
3949            table_name: None,
3950        }];
3951        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
3952            &inputs,
3953            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
3954            &[],
3955            &TypeInferenceOptions::default(),
3956            &InputNormalizationOptions::default(),
3957            &opts,
3958            true,
3959        )?;
3960
3961        assert_eq!(result.columns, vec!["name", "price"]);
3962        assert_eq!(
3963            result.rows,
3964            vec![vec![
3965                QueryValue::Text("Keyboard".into()),
3966                QueryValue::Real(12.5)
3967            ]]
3968        );
3969
3970        fs::remove_file(xml_path)?;
3971        Ok(())
3972    }
3973}