Skip to main content

query_forge/
lib.rs

1mod engine;
2mod input;
3mod output;
4mod summary;
5mod types;
6mod value;
7mod writers;
8
9pub use engine::{
10    run_query,
11    run_query_with_params,
12    run_query_with_params_multi,
13    run_query_with_params_multi_inputs,
14    run_query_with_params_multi_inputs_and_options,
15    run_query_with_params_multi_inputs_and_options_and_normalization,
16};
17pub use input::{SheetData, load_input};
18pub use output::{render_csv, render_html, render_json, render_jsonl, render_markdown, render_text, render_xml};
19pub use summary::load_table_summaries;
20pub use types::{
21    ColumnInfo,
22    ColumnStats,
23    ExtractionOptions,
24    HeaderCase,
25    InferredType,
26    InputNormalizationOptions,
27    JsonMode,
28    QueryParam,
29    QueryResult,
30    QueryValue,
31    TableSummary,
32    TypeInferenceOptions,
33    WorkbookInput,
34    XmlMode,
35};
36pub use writers::{write_parquet, write_xlsx};
37
38#[cfg(test)]
39use anyhow::Result;
40#[cfg(test)]
41use std::path::Path;
42#[cfg(test)]
43use rust_xlsxwriter::Workbook;
44#[cfg(test)]
45mod tests {
46    use std::{
47        fs,
48        path::PathBuf,
49        time::{SystemTime, UNIX_EPOCH},
50    };
51
52    use super::*;
53
54    fn temp_path(name: &str) -> PathBuf {
55        let unique = SystemTime::now()
56            .duration_since(UNIX_EPOCH)
57            .expect("time should move forward")
58            .as_nanos();
59        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.xlsx"))
60    }
61
62    fn temp_xml_path(name: &str) -> PathBuf {
63        let unique = SystemTime::now()
64            .duration_since(UNIX_EPOCH)
65            .expect("time should move forward")
66            .as_nanos();
67        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.xml"))
68    }
69
70    fn temp_csv_path(name: &str) -> PathBuf {
71        let unique = SystemTime::now()
72            .duration_since(UNIX_EPOCH)
73            .expect("time should move forward")
74            .as_nanos();
75        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.csv"))
76    }
77
78    fn temp_jsonl_path(name: &str) -> PathBuf {
79        let unique = SystemTime::now()
80            .duration_since(UNIX_EPOCH)
81            .expect("time should move forward")
82            .as_nanos();
83        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.jsonl"))
84    }
85
86    fn temp_json_path(name: &str) -> PathBuf {
87        let unique = SystemTime::now()
88            .duration_since(UNIX_EPOCH)
89            .expect("time should move forward")
90            .as_nanos();
91        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.json"))
92    }
93
94    fn temp_markdown_path(name: &str) -> PathBuf {
95        let unique = SystemTime::now()
96            .duration_since(UNIX_EPOCH)
97            .expect("time should move forward")
98            .as_nanos();
99        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.md"))
100    }
101
102    fn temp_parquet_path(name: &str) -> PathBuf {
103        let unique = SystemTime::now()
104            .duration_since(UNIX_EPOCH)
105            .expect("time should move forward")
106            .as_nanos();
107        std::env::temp_dir().join(format!("query-forge-{name}-{unique}.parquet"))
108    }
109
110    fn write_test_workbook(path: &Path) -> Result<()> {
111        let mut workbook = Workbook::new();
112        let worksheet = workbook.add_worksheet();
113
114        worksheet.write_string(0, 0, "name")?;
115        worksheet.write_string(0, 1, "price")?;
116        worksheet.write_string(0, 2, "active")?;
117        worksheet.write_string(1, 0, "Keyboard")?;
118        worksheet.write_number(1, 1, 12.5)?;
119        worksheet.write_boolean(1, 2, true)?;
120        worksheet.write_string(2, 0, "Cable")?;
121        worksheet.write_number(2, 1, 5.0)?;
122        worksheet.write_boolean(2, 2, false)?;
123
124        workbook.save(path)?;
125        Ok(())
126    }
127
128    fn write_test_workbook_on_sheet(path: &Path, sheet_name: &str) -> Result<()> {
129        let mut workbook = Workbook::new();
130        let worksheet = workbook.add_worksheet();
131        worksheet.set_name(sheet_name)?;
132
133        worksheet.write_string(0, 0, "name")?;
134        worksheet.write_string(0, 1, "price")?;
135        worksheet.write_string(1, 0, "Keyboard")?;
136        worksheet.write_number(1, 1, 12.5)?;
137
138        workbook.save(path)?;
139        Ok(())
140    }
141
142    fn write_test_xml(path: &Path) -> Result<()> {
143        fs::write(
144            path,
145            r#"<?xml version="1.0" encoding="UTF-8"?>
146<data>
147    <row>
148        <name>Keyboard</name>
149        <price>12.5</price>
150        <active>true</active>
151    </row>
152    <row>
153        <name>Cable</name>
154        <price>5</price>
155        <active>false</active>
156    </row>
157</data>
158"#,
159        )?;
160
161        Ok(())
162    }
163
164    fn write_test_xml_with_sections(path: &Path) -> Result<()> {
165        fs::write(
166            path,
167            r#"<?xml version="1.0" encoding="UTF-8"?>
168<root>
169    <Inventory>
170        <row>
171            <name>Keyboard</name>
172            <price>12.5</price>
173        </row>
174    </Inventory>
175    <Archive>
176        <row>
177            <name>Legacy Cable</name>
178            <price>3.5</price>
179        </row>
180    </Archive>
181</root>
182"#,
183        )?;
184
185        Ok(())
186    }
187
188    fn write_test_csv(path: &Path) -> Result<()> {
189        fs::write(
190            path,
191            "name,price,active\nKeyboard,12.5,true\nCable,5,false\n",
192        )?;
193
194        Ok(())
195    }
196
197    fn write_test_csv_with_custom_inference_values(path: &Path) -> Result<()> {
198        fs::write(
199            path,
200            "name,amount,flag,date,note\nKeyboard,\"1,5\",YES,31/12/2025,N/A\nCable,\"2,0\",NO,01/01/2026,ok\n",
201        )?;
202
203        Ok(())
204    }
205
206    fn write_test_csv_no_headers(path: &Path) -> Result<()> {
207        fs::write(path, "Keyboard,12.5,true\nCable,5,false\n")?;
208
209        Ok(())
210    }
211
212    fn write_test_csv_for_normalization(path: &Path) -> Result<()> {
213        fs::write(
214            path,
215            "First Name,First-Name, Notes \n Alice ,Alice, hello \n  ,  ,   \n",
216        )?;
217
218        Ok(())
219    }
220
221    fn write_test_jsonl(path: &Path) -> Result<()> {
222        fs::write(
223            path,
224            "{\"name\":\"Keyboard\",\"price\":12.5,\"active\":true}\n{\"name\":\"Cable\",\"price\":5,\"active\":false}\n",
225        )?;
226
227        Ok(())
228    }
229
230    fn write_test_jsonl_with_sparse_columns(path: &Path) -> Result<()> {
231        fs::write(
232            path,
233            "{\"name\":\"Keyboard\",\"price\":12.5}\n\n{\"name\":\"Cable\",\"active\":false}\n",
234        )?;
235
236        Ok(())
237    }
238
239    fn write_test_json(path: &Path) -> Result<()> {
240        fs::write(
241            path,
242            "[{\"name\":\"Keyboard\",\"price\":12.5,\"active\":true},{\"name\":\"Cable\",\"price\":5,\"active\":false}]",
243        )?;
244
245        Ok(())
246    }
247
248    fn write_test_json_with_sections(path: &Path) -> Result<()> {
249        fs::write(
250            path,
251            "{\"Inventory\":[{\"name\":\"Keyboard\",\"price\":12.5}],\"Archive\":[{\"name\":\"Legacy Cable\",\"price\":3.5}]}",
252        )?;
253
254        Ok(())
255    }
256
257    fn write_test_markdown_with_tables(path: &Path) -> Result<()> {
258        fs::write(
259            path,
260            r#"# Inventory Report
261
262| name | price | active |
263| --- | ---: | :---: |
264| Keyboard | 12.5 | true |
265| Cable | 5 | false |
266
267## Archive
268
269| name | price |
270| --- | --- |
271| Legacy Cable | 3.5 |
272"#,
273        )?;
274
275        Ok(())
276    }
277
278    fn write_test_markdown_with_headers_only(path: &Path) -> Result<()> {
279        fs::write(
280            path,
281            r#"| name | price |
282| --- | --- |
283"#,
284        )?;
285
286        Ok(())
287    }
288
289    #[test]
290    fn applies_input_normalization_options_before_query() -> Result<()> {
291        let csv_path = temp_csv_path("csv-normalization");
292        write_test_csv_for_normalization(&csv_path)?;
293        let options = InputNormalizationOptions {
294            trim: true,
295            skip_empty_rows: true,
296            normalize_headers: true,
297            header_case: Some(HeaderCase::Snake),
298            dedupe_headers: true,
299        };
300
301        let inputs = [WorkbookInput {
302            path: &csv_path,
303            sheet_name: None,
304            table_name: None,
305        }];
306        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
307            &inputs,
308            "SELECT first_name, first_name_2, notes FROM table",
309            &[],
310            &TypeInferenceOptions::default(),
311            &options,
312            &ExtractionOptions::default(),
313            true,
314        )?;
315
316        assert_eq!(result.columns, vec!["first_name", "first_name_2", "notes"]);
317        assert_eq!(
318            result.rows,
319            vec![vec![
320                QueryValue::Text("Alice".into()),
321                QueryValue::Text("Alice".into()),
322                QueryValue::Text("hello".into())
323            ]]
324        );
325
326        fs::remove_file(csv_path)?;
327        Ok(())
328    }
329
330    #[test]
331    fn executes_sql_query_against_sheet() -> Result<()> {
332        let workbook_path = temp_path("query");
333        write_test_workbook(&workbook_path)?;
334
335        let result = run_query(
336            &workbook_path,
337            Some("Sheet1"),
338            "SELECT name, price FROM table WHERE price > 10 ORDER BY price DESC",
339            true,
340        )?;
341
342        assert_eq!(result.columns, vec!["name", "price"]);
343        assert_eq!(
344            result.rows,
345            vec![vec![
346                QueryValue::Text("Keyboard".into()),
347                QueryValue::Real(12.5)
348            ]]
349        );
350
351        fs::remove_file(workbook_path)?;
352        Ok(())
353    }
354
355    #[test]
356    fn executes_sql_query_against_sheet1_alias() -> Result<()> {
357        let workbook_path = temp_path("query-sheet1-alias");
358        write_test_workbook(&workbook_path)?;
359
360        let result = run_query(
361            &workbook_path,
362            Some("Sheet1"),
363            "SELECT name, price FROM table1 WHERE price > 10 ORDER BY price DESC",
364            true,
365        )?;
366
367        assert_eq!(result.columns, vec!["name", "price"]);
368        assert_eq!(
369            result.rows,
370            vec![vec![
371                QueryValue::Text("Keyboard".into()),
372                QueryValue::Real(12.5)
373            ]]
374        );
375
376        fs::remove_file(workbook_path)?;
377        Ok(())
378    }
379
380    #[test]
381    fn executes_sql_query_with_named_parameter() -> Result<()> {
382        let workbook_path = temp_path("query-with-param");
383        write_test_workbook(&workbook_path)?;
384
385        let result = run_query_with_params(
386            &workbook_path,
387            Some("Sheet1"),
388            "SELECT name, price FROM table WHERE price > :min_price ORDER BY price DESC",
389            &[QueryParam {
390                name: "min_price".to_owned(),
391                value: QueryValue::Real(10.0),
392            }],
393            true,
394        )?;
395
396        assert_eq!(
397            result,
398            QueryResult {
399                columns: vec!["name".to_owned(), "price".to_owned()],
400                rows: vec![vec![
401                    QueryValue::Text("Keyboard".to_owned()),
402                    QueryValue::Real(12.5),
403                ]],
404            }
405        );
406
407        fs::remove_file(&workbook_path)?;
408        Ok(())
409    }
410
411    #[test]
412    fn executes_query_against_multiple_workbooks() -> Result<()> {
413        let workbook_path_1 = temp_path("multi-1");
414        let workbook_path_2 = temp_path("multi-2");
415        write_test_workbook(&workbook_path_1)?;
416        write_test_workbook(&workbook_path_2)?;
417
418        let result = run_query_with_params_multi(
419            &[workbook_path_1.as_path(), workbook_path_2.as_path()],
420            Some("Sheet1"),
421            "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
422            &[],
423            true,
424        )?;
425
426        assert_eq!(result.columns, vec!["total_rows"]);
427        assert_eq!(
428            result.rows,
429            vec![vec![QueryValue::Integer(2)], vec![QueryValue::Integer(2)]]
430        );
431
432        fs::remove_file(&workbook_path_1)?;
433        fs::remove_file(&workbook_path_2)?;
434        Ok(())
435    }
436
437    #[test]
438    fn executes_query_against_multiple_workbooks_with_distinct_sheet_names() -> Result<()> {
439        let workbook_path_1 = temp_path("multi-sheet-1");
440        let workbook_path_2 = temp_path("multi-sheet-2");
441        write_test_workbook_on_sheet(&workbook_path_1, "Consuntivo")?;
442        write_test_workbook_on_sheet(&workbook_path_2, "WKL")?;
443
444        let result = run_query_with_params_multi_inputs(
445            &[
446                WorkbookInput {
447                    path: workbook_path_1.as_path(),
448                    sheet_name: Some("Consuntivo"),
449                    table_name: None,
450                },
451                WorkbookInput {
452                    path: workbook_path_2.as_path(),
453                    sheet_name: Some("WKL"),
454                    table_name: None,
455                },
456            ],
457            "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
458            &[],
459            true,
460        )?;
461
462        assert_eq!(result.columns, vec!["total_rows"]);
463        assert_eq!(
464            result.rows,
465            vec![vec![QueryValue::Integer(1)], vec![QueryValue::Integer(1)]]
466        );
467
468        fs::remove_file(&workbook_path_1)?;
469        fs::remove_file(&workbook_path_2)?;
470        Ok(())
471    }
472
473    #[test]
474    fn executes_query_with_explicit_table_names() -> Result<()> {
475        let sales_path = temp_path("explicit-name-sales");
476        let costs_path = temp_path("explicit-name-costs");
477        write_test_workbook_on_sheet(&sales_path, "Sheet1")?;
478        write_test_workbook_on_sheet(&costs_path, "Sheet1")?;
479
480        let result = run_query_with_params_multi_inputs(
481            &[
482                WorkbookInput {
483                    path: sales_path.as_path(),
484                    sheet_name: Some("Sheet1"),
485                    table_name: Some("sales"),
486                },
487                WorkbookInput {
488                    path: costs_path.as_path(),
489                    sheet_name: Some("Sheet1"),
490                    table_name: Some("costs"),
491                },
492            ],
493            "SELECT COUNT(*) AS total_rows FROM sales UNION ALL SELECT COUNT(*) AS total_rows FROM costs",
494            &[],
495            true,
496        )?;
497
498        assert_eq!(result.columns, vec!["total_rows"]);
499        assert_eq!(
500            result.rows,
501            vec![vec![QueryValue::Integer(1)], vec![QueryValue::Integer(1)]]
502        );
503
504        fs::remove_file(&sales_path)?;
505        fs::remove_file(&costs_path)?;
506        Ok(())
507    }
508
509    #[test]
510    fn executes_sql_query_against_whole_xml_file() -> Result<()> {
511        let xml_path = temp_xml_path("xml-whole");
512        write_test_xml(&xml_path)?;
513
514        let result = run_query(
515            &xml_path,
516            None,
517            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
518            true,
519        )?;
520
521        assert_eq!(result.columns, vec!["name", "price"]);
522        assert_eq!(
523            result.rows,
524            vec![vec![
525                QueryValue::Text("Keyboard".into()),
526                QueryValue::Real(12.5)
527            ]]
528        );
529
530        fs::remove_file(xml_path)?;
531        Ok(())
532    }
533
534    #[test]
535    fn executes_sql_query_against_xml_sheet_tag() -> Result<()> {
536        let xml_path = temp_xml_path("xml-sheet-tag");
537        write_test_xml_with_sections(&xml_path)?;
538
539        let result = run_query(
540            &xml_path,
541            Some("Archive"),
542            "SELECT name, price FROM table",
543            true,
544        )?;
545
546        assert_eq!(result.columns, vec!["name", "price"]);
547        assert_eq!(
548            result.rows,
549            vec![vec![
550                QueryValue::Text("Legacy Cable".into()),
551                QueryValue::Real(3.5)
552            ]]
553        );
554
555        fs::remove_file(xml_path)?;
556        Ok(())
557    }
558
559    #[test]
560    fn executes_query_against_heterogeneous_xlsx_and_xml_inputs() -> Result<()> {
561        let workbook_path = temp_path("mixed-xlsx");
562        let xml_path = temp_xml_path("mixed-xml");
563        write_test_workbook(&workbook_path)?;
564        write_test_xml(&xml_path)?;
565
566        let result = run_query_with_params_multi_inputs(
567            &[
568                WorkbookInput {
569                    path: workbook_path.as_path(),
570                    sheet_name: Some("Sheet1"),
571                    table_name: None,
572                },
573                WorkbookInput {
574                    path: xml_path.as_path(),
575                    sheet_name: None,
576                    table_name: None,
577                },
578            ],
579            "SELECT COUNT(*) AS total_rows FROM table UNION ALL SELECT COUNT(*) AS total_rows FROM table2",
580            &[],
581            true,
582        )?;
583
584        assert_eq!(result.columns, vec!["total_rows"]);
585        assert_eq!(
586            result.rows,
587            vec![vec![QueryValue::Integer(2)], vec![QueryValue::Integer(2)]]
588        );
589
590        fs::remove_file(&workbook_path)?;
591        fs::remove_file(&xml_path)?;
592        Ok(())
593    }
594
595    #[test]
596    fn executes_sql_query_against_csv_file() -> Result<()> {
597        let csv_path = temp_csv_path("csv");
598        write_test_csv(&csv_path)?;
599
600        let result = run_query(
601            &csv_path,
602            None,
603            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
604            true,
605        )?;
606
607        assert_eq!(result.columns, vec!["name", "price"]);
608        assert_eq!(
609            result.rows,
610            vec![vec![
611                QueryValue::Text("Keyboard".into()),
612                QueryValue::Real(12.5)
613            ]]
614        );
615
616        fs::remove_file(csv_path)?;
617        Ok(())
618    }
619
620    #[test]
621    fn supports_configurable_type_inference_options() -> Result<()> {
622        let csv_path = temp_csv_path("csv-custom-inference");
623        write_test_csv_with_custom_inference_values(&csv_path)?;
624
625        let options = TypeInferenceOptions {
626            infer_types: true,
627            decimal_comma: true,
628            date_format: Some("%d/%m/%Y".to_owned()),
629            null_values: vec!["N/A".to_owned()],
630            true_values: vec!["YES".to_owned()],
631            false_values: vec!["NO".to_owned()],
632        };
633
634        let workbook_inputs = [WorkbookInput {
635            path: csv_path.as_path(),
636            sheet_name: None,
637            table_name: None,
638        }];
639        let result = run_query_with_params_multi_inputs_and_options(
640            &workbook_inputs,
641            "SELECT amount, flag, date, note FROM table ORDER BY amount",
642            &[],
643            &options,
644            true,
645        )?;
646
647        assert_eq!(result.columns, vec!["amount", "flag", "date", "note"]);
648        assert_eq!(
649            result.rows,
650            vec![
651                vec![
652                    QueryValue::Real(1.5),
653                    QueryValue::Integer(1),
654                    QueryValue::Text("2025-12-31".into()),
655                    QueryValue::Null
656                ],
657                vec![
658                    QueryValue::Real(2.0),
659                    QueryValue::Integer(0),
660                    QueryValue::Text("2026-01-01".into()),
661                    QueryValue::Text("ok".into())
662                ]
663            ]
664        );
665
666        fs::remove_file(csv_path)?;
667        Ok(())
668    }
669
670    #[test]
671    fn supports_all_text_mode() -> Result<()> {
672        let csv_path = temp_csv_path("csv-all-text");
673        write_test_csv(&csv_path)?;
674
675        let options = TypeInferenceOptions {
676            infer_types: false,
677            ..TypeInferenceOptions::default()
678        };
679        let workbook_inputs = [WorkbookInput {
680            path: csv_path.as_path(),
681            sheet_name: None,
682            table_name: None,
683        }];
684        let result = run_query_with_params_multi_inputs_and_options(
685            &workbook_inputs,
686            "SELECT name, price, active FROM table ORDER BY name DESC",
687            &[],
688            &options,
689            true,
690        )?;
691
692        assert_eq!(
693            result.rows,
694            vec![
695                vec![
696                    QueryValue::Text("Keyboard".into()),
697                    QueryValue::Text("12.5".into()),
698                    QueryValue::Text("true".into())
699                ],
700                vec![
701                    QueryValue::Text("Cable".into()),
702                    QueryValue::Text("5".into()),
703                    QueryValue::Text("false".into())
704                ]
705            ]
706        );
707
708        fs::remove_file(csv_path)?;
709        Ok(())
710    }
711
712    #[test]
713    fn executes_sql_query_against_csv_without_headers() -> Result<()> {
714        let csv_path = temp_csv_path("csv-no-headers");
715        write_test_csv_no_headers(&csv_path)?;
716
717        let result = run_query(
718            &csv_path,
719            None,
720            "SELECT column1, column2 FROM table WHERE column3 = 1 ORDER BY column2 DESC",
721            false,
722        )?;
723
724        assert_eq!(result.columns, vec!["column1", "column2"]);
725        assert_eq!(
726            result.rows,
727            vec![vec![
728                QueryValue::Text("Keyboard".into()),
729                QueryValue::Real(12.5)
730            ]]
731        );
732
733        fs::remove_file(csv_path)?;
734        Ok(())
735    }
736
737    #[test]
738    fn executes_sql_query_against_jsonl_file() -> Result<()> {
739        let jsonl_path = temp_jsonl_path("jsonl");
740        write_test_jsonl(&jsonl_path)?;
741
742        let result = run_query(
743            &jsonl_path,
744            None,
745            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
746            true,
747        )?;
748
749        assert_eq!(result.columns, vec!["name", "price"]);
750        assert_eq!(
751            result.rows,
752            vec![vec![
753                QueryValue::Text("Keyboard".into()),
754                QueryValue::Real(12.5)
755            ]]
756        );
757
758        fs::remove_file(jsonl_path)?;
759        Ok(())
760    }
761
762    #[test]
763    fn executes_sql_query_against_jsonl_file_with_sparse_columns() -> Result<()> {
764        let jsonl_path = temp_jsonl_path("jsonl-sparse");
765        write_test_jsonl_with_sparse_columns(&jsonl_path)?;
766
767        let result = run_query(
768            &jsonl_path,
769            None,
770            "SELECT name, price, active FROM table ORDER BY name DESC",
771            true,
772        )?;
773
774        assert_eq!(result.columns, vec!["name", "price", "active"]);
775        assert_eq!(
776            result.rows,
777            vec![
778                vec![
779                    QueryValue::Text("Keyboard".into()),
780                    QueryValue::Real(12.5),
781                    QueryValue::Null
782                ],
783                vec![
784                    QueryValue::Text("Cable".into()),
785                    QueryValue::Null,
786                    QueryValue::Integer(0)
787                ]
788            ]
789        );
790
791        fs::remove_file(jsonl_path)?;
792        Ok(())
793    }
794
795    #[test]
796    fn executes_sql_query_against_json_file() -> Result<()> {
797        let json_path = temp_json_path("json");
798        write_test_json(&json_path)?;
799
800        let result = run_query(
801            &json_path,
802            None,
803            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
804            true,
805        )?;
806
807        assert_eq!(result.columns, vec!["name", "price"]);
808        assert_eq!(
809            result.rows,
810            vec![vec![
811                QueryValue::Text("Keyboard".into()),
812                QueryValue::Real(12.5)
813            ]]
814        );
815
816        fs::remove_file(json_path)?;
817        Ok(())
818    }
819
820    #[test]
821    fn executes_sql_query_against_json_sheet_key() -> Result<()> {
822        let json_path = temp_json_path("json-sheet-key");
823        write_test_json_with_sections(&json_path)?;
824
825        let result = run_query(
826            &json_path,
827            Some("Archive"),
828            "SELECT name, price FROM table",
829            true,
830        )?;
831
832        assert_eq!(result.columns, vec!["name", "price"]);
833        assert_eq!(
834            result.rows,
835            vec![vec![
836                QueryValue::Text("Legacy Cable".into()),
837                QueryValue::Real(3.5)
838            ]]
839        );
840
841        fs::remove_file(json_path)?;
842        Ok(())
843    }
844
845    #[test]
846    fn executes_sql_query_against_first_markdown_table_by_default() -> Result<()> {
847        let markdown_path = temp_markdown_path("markdown-default");
848        write_test_markdown_with_tables(&markdown_path)?;
849
850        let result = run_query(
851            &markdown_path,
852            None,
853            "SELECT name, price FROM table WHERE active = 1",
854            true,
855        )?;
856
857        assert_eq!(result.columns, vec!["name", "price"]);
858        assert_eq!(
859            result.rows,
860            vec![vec![
861                QueryValue::Text("Keyboard".into()),
862                QueryValue::Real(12.5)
863            ]]
864        );
865
866        fs::remove_file(markdown_path)?;
867        Ok(())
868    }
869
870    #[test]
871    fn reports_actionable_error_for_csv_selector() -> Result<()> {
872        let csv_path = temp_csv_path("csv-selector-error");
873        write_test_csv(&csv_path)?;
874
875        let error = run_query(&csv_path, Some("Sheet1"), "SELECT name FROM table", true)
876            .expect_err("CSV selector should fail");
877        let message = error.to_string();
878        assert!(message.contains("does not support selector 'Sheet1'"));
879        assert!(message.contains("Remove ':Sheet1'"));
880
881        fs::remove_file(csv_path)?;
882        Ok(())
883    }
884
885    #[test]
886    fn reports_actionable_error_for_jsonl_selector() -> Result<()> {
887        let jsonl_path = temp_jsonl_path("jsonl-selector-error");
888        write_test_jsonl(&jsonl_path)?;
889
890        let error = run_query(&jsonl_path, Some("Records"), "SELECT name FROM table", true)
891            .expect_err("JSONL selector should fail");
892        let message = error.to_string();
893        assert!(message.contains("does not support selector 'Records'"));
894        assert!(message.contains("Remove ':Records'"));
895
896        fs::remove_file(jsonl_path)?;
897        Ok(())
898    }
899
900    #[test]
901    fn reports_json_key_error_with_available_keys() -> Result<()> {
902        let json_path = temp_json_path("json-missing-key");
903        write_test_json_with_sections(&json_path)?;
904
905        let error = run_query(&json_path, Some("Missing"), "SELECT name FROM table", true)
906            .expect_err("missing JSON key should fail");
907        let message = error.to_string();
908        assert!(message.contains("JSON key 'Missing' not found"));
909        assert!(message.contains("Available keys: Archive, Inventory"));
910
911        fs::remove_file(json_path)?;
912        Ok(())
913    }
914
915    #[test]
916    fn reports_invalid_markdown_selector_with_guidance() -> Result<()> {
917        let markdown_path = temp_markdown_path("markdown-invalid-selector");
918        write_test_markdown_with_tables(&markdown_path)?;
919
920        let error = run_query(&markdown_path, Some("abc"), "SELECT name FROM table", true)
921            .expect_err("non-numeric markdown selector should fail");
922        let message = error.to_string();
923        assert!(message.contains("invalid Markdown table selector 'abc'"));
924        assert!(message.contains("':1'"));
925
926        fs::remove_file(markdown_path)?;
927        Ok(())
928    }
929
930    #[test]
931    fn reports_empty_markdown_table_as_actionable_error() -> Result<()> {
932        let markdown_path = temp_markdown_path("markdown-empty-table");
933        write_test_markdown_with_headers_only(&markdown_path)?;
934
935        let error = run_query(&markdown_path, None, "SELECT name FROM table", true)
936            .expect_err("empty markdown table should fail");
937        let message = error.to_string();
938        assert!(message.contains("is empty (no data rows)"));
939
940        fs::remove_file(markdown_path)?;
941        Ok(())
942    }
943
944    #[test]
945    fn reports_unknown_table_with_inspection_hint() -> Result<()> {
946        let workbook_path = temp_path("unknown-table-query");
947        write_test_workbook(&workbook_path)?;
948
949        let error = run_query(
950            &workbook_path,
951            Some("Sheet1"),
952            "SELECT * FROM missing_table",
953            true,
954        )
955        .expect_err("unknown table should fail");
956        let message = error.to_string();
957        assert!(message.contains("unknown table 'missing_table'"));
958        assert!(message.contains("qf tables --input"));
959
960        fs::remove_file(workbook_path)?;
961        Ok(())
962    }
963
964    #[test]
965    fn reports_unknown_column_with_schema_hint() -> Result<()> {
966        let workbook_path = temp_path("unknown-column-query");
967        write_test_workbook(&workbook_path)?;
968
969        let error = run_query(
970            &workbook_path,
971            Some("Sheet1"),
972            "SELECT missing_column FROM table",
973            true,
974        )
975        .expect_err("unknown column should fail");
976        let message = error.to_string();
977        assert!(message.contains("unknown column 'missing_column'"));
978        assert!(message.contains("qf schema --input"));
979
980        fs::remove_file(workbook_path)?;
981        Ok(())
982    }
983
984    #[test]
985    fn executes_sql_query_against_markdown_table_by_numeric_key() -> Result<()> {
986        let markdown_path = temp_markdown_path("markdown-key");
987        write_test_markdown_with_tables(&markdown_path)?;
988
989        let result = run_query(
990            &markdown_path,
991            Some("2"),
992            "SELECT name, price FROM table",
993            true,
994        )?;
995
996        assert_eq!(result.columns, vec!["name", "price"]);
997        assert_eq!(
998            result.rows,
999            vec![vec![
1000                QueryValue::Text("Legacy Cable".into()),
1001                QueryValue::Real(3.5)
1002            ]]
1003        );
1004
1005        fs::remove_file(markdown_path)?;
1006        Ok(())
1007    }
1008
1009    #[test]
1010    fn executes_query_against_heterogeneous_xlsx_xml_csv_jsonl_json_markdown_inputs() -> Result<()>
1011    {
1012        let workbook_path = temp_path("mixed4-xlsx");
1013        let xml_path = temp_xml_path("mixed4-xml");
1014        let csv_path = temp_csv_path("mixed4-csv");
1015        let jsonl_path = temp_jsonl_path("mixed4-jsonl");
1016        let json_path = temp_json_path("mixed4-json");
1017        let markdown_path = temp_markdown_path("mixed4-markdown");
1018        write_test_workbook(&workbook_path)?;
1019        write_test_xml(&xml_path)?;
1020        write_test_csv(&csv_path)?;
1021        write_test_jsonl(&jsonl_path)?;
1022        write_test_json(&json_path)?;
1023        write_test_markdown_with_tables(&markdown_path)?;
1024
1025        let result = run_query_with_params_multi_inputs(
1026            &[
1027                WorkbookInput {
1028                    path: workbook_path.as_path(),
1029                    sheet_name: Some("Sheet1"),
1030                    table_name: None,
1031                },
1032                WorkbookInput {
1033                    path: xml_path.as_path(),
1034                    sheet_name: None,
1035                    table_name: None,
1036                },
1037                WorkbookInput {
1038                    path: csv_path.as_path(),
1039                    sheet_name: None,
1040                    table_name: None,
1041                },
1042                WorkbookInput {
1043                    path: jsonl_path.as_path(),
1044                    sheet_name: None,
1045                    table_name: None,
1046                },
1047                WorkbookInput {
1048                    path: json_path.as_path(),
1049                    sheet_name: None,
1050                    table_name: None,
1051                },
1052                WorkbookInput {
1053                    path: markdown_path.as_path(),
1054                    sheet_name: None,
1055                    table_name: None,
1056                },
1057            ],
1058            "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",
1059            &[],
1060            true,
1061        )?;
1062
1063        assert_eq!(result.columns, vec!["total_rows"]);
1064        assert_eq!(
1065            result.rows,
1066            vec![
1067                vec![QueryValue::Integer(2)],
1068                vec![QueryValue::Integer(2)],
1069                vec![QueryValue::Integer(2)],
1070                vec![QueryValue::Integer(2)],
1071                vec![QueryValue::Integer(2)],
1072                vec![QueryValue::Integer(2)]
1073            ]
1074        );
1075
1076        fs::remove_file(&workbook_path)?;
1077        fs::remove_file(&xml_path)?;
1078        fs::remove_file(&csv_path)?;
1079        fs::remove_file(&jsonl_path)?;
1080        fs::remove_file(&json_path)?;
1081        fs::remove_file(&markdown_path)?;
1082        Ok(())
1083    }
1084
1085    #[test]
1086    fn writes_query_result_to_xlsx() -> Result<()> {
1087        let output_path = temp_path("output");
1088        let result = QueryResult {
1089            columns: vec!["item".into(), "total".into()],
1090            rows: vec![vec![
1091                QueryValue::Text("Mouse".into()),
1092                QueryValue::Integer(3),
1093            ]],
1094        };
1095
1096        write_xlsx(&result, &output_path)?;
1097
1098        let written = run_query(
1099            &output_path,
1100            Some("Sheet1"),
1101            "SELECT item, total FROM table",
1102            true,
1103        )?;
1104
1105        assert_eq!(written.columns, vec!["item", "total"]);
1106        assert_eq!(
1107            written.rows,
1108            vec![vec![
1109                QueryValue::Text("Mouse".into()),
1110                QueryValue::Real(3.0)
1111            ]]
1112        );
1113
1114        fs::remove_file(output_path)?;
1115        Ok(())
1116    }
1117
1118    #[test]
1119    fn writes_query_result_to_parquet() -> Result<()> {
1120        let output_path = temp_parquet_path("output");
1121        let result = QueryResult {
1122            columns: vec!["item".into(), "qty".into(), "price".into()],
1123            rows: vec![
1124                vec![
1125                    QueryValue::Text("Mouse".into()),
1126                    QueryValue::Integer(3),
1127                    QueryValue::Real(9.99),
1128                ],
1129                vec![
1130                    QueryValue::Text("Keyboard".into()),
1131                    QueryValue::Integer(1),
1132                    QueryValue::Null,
1133                ],
1134            ],
1135        };
1136
1137        write_parquet(&result, &output_path)?;
1138
1139        // Verify the file exists, is non-empty, and has the Parquet magic bytes (PAR1).
1140        let bytes = fs::read(&output_path)?;
1141        assert!(bytes.len() > 8, "parquet file should have content");
1142        assert_eq!(&bytes[..4], b"PAR1", "parquet file should start with PAR1");
1143        assert_eq!(
1144            &bytes[bytes.len() - 4..],
1145            b"PAR1",
1146            "parquet file should end with PAR1"
1147        );
1148
1149        fs::remove_file(output_path)?;
1150        Ok(())
1151    }
1152
1153    #[test]
1154    fn executes_sql_query_against_parquet_file() -> Result<()> {
1155        let parquet_path = temp_parquet_path("input");
1156        let result = QueryResult {
1157            columns: vec!["name".into(), "price".into(), "active".into()],
1158            rows: vec![
1159                vec![
1160                    QueryValue::Text("Keyboard".into()),
1161                    QueryValue::Real(12.5),
1162                    QueryValue::Integer(1),
1163                ],
1164                vec![
1165                    QueryValue::Text("Cable".into()),
1166                    QueryValue::Real(5.0),
1167                    QueryValue::Integer(0),
1168                ],
1169            ],
1170        };
1171        write_parquet(&result, &parquet_path)?;
1172
1173        let queried = run_query(
1174            &parquet_path,
1175            None,
1176            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
1177            true,
1178        )?;
1179
1180        assert_eq!(queried.columns, vec!["name", "price"]);
1181        assert_eq!(
1182            queried.rows,
1183            vec![vec![
1184                QueryValue::Text("Keyboard".into()),
1185                QueryValue::Real(12.5)
1186            ]]
1187        );
1188
1189        fs::remove_file(parquet_path)?;
1190        Ok(())
1191    }
1192
1193    #[test]
1194    fn reports_actionable_error_for_parquet_selector() -> Result<()> {
1195        let parquet_path = temp_parquet_path("parquet-selector-error");
1196        let result = QueryResult {
1197            columns: vec!["name".into()],
1198            rows: vec![vec![QueryValue::Text("Keyboard".into())]],
1199        };
1200        write_parquet(&result, &parquet_path)?;
1201
1202        let error = run_query(
1203            &parquet_path,
1204            Some("Sheet1"),
1205            "SELECT name FROM table",
1206            true,
1207        )
1208        .expect_err("Parquet selector should fail");
1209        let message = error.to_string();
1210        assert!(message.contains("does not support selector 'Sheet1'"));
1211        assert!(message.contains("Remove ':Sheet1'"));
1212
1213        fs::remove_file(parquet_path)?;
1214        Ok(())
1215    }
1216
1217    #[test]
1218    fn renders_csv_with_escaping() {
1219        let result = QueryResult {
1220            columns: vec!["name".into(), "notes".into()],
1221            rows: vec![vec![
1222                QueryValue::Text("Mouse".into()),
1223                QueryValue::Text("line1,line2".into()),
1224            ]],
1225        };
1226
1227        assert_eq!(render_csv(&result), "name,notes\nMouse,\"line1,line2\"");
1228    }
1229
1230    #[test]
1231    fn renders_text_with_aligned_columns() {
1232        let result = QueryResult {
1233            columns: vec!["mese".into(), "totale_ore".into()],
1234            rows: vec![
1235                vec![
1236                    QueryValue::Text("2026-01-01".into()),
1237                    QueryValue::Real(10.5),
1238                ],
1239                vec![
1240                    QueryValue::Text("2026-12-01".into()),
1241                    QueryValue::Integer(2),
1242                ],
1243            ],
1244        };
1245
1246        assert_eq!(
1247            render_text(&result),
1248            "mese       | totale_ore\n-----------+-----------\n2026-01-01 | 10.5      \n2026-12-01 | 2         "
1249        );
1250    }
1251
1252    #[test]
1253    fn renders_jsonl() {
1254        let result = QueryResult {
1255            columns: vec!["item".into(), "stock".into(), "note".into()],
1256            rows: vec![vec![
1257                QueryValue::Text("Desk".into()),
1258                QueryValue::Integer(8),
1259                QueryValue::Null,
1260            ]],
1261        };
1262
1263        assert_eq!(
1264            render_jsonl(&result),
1265            "{\"item\":\"Desk\",\"stock\":8,\"note\":null}"
1266        );
1267    }
1268
1269    #[test]
1270    fn renders_json() {
1271        let result = QueryResult {
1272            columns: vec!["item".into(), "stock".into(), "note".into()],
1273            rows: vec![vec![
1274                QueryValue::Text("Desk".into()),
1275                QueryValue::Integer(8),
1276                QueryValue::Null,
1277            ]],
1278        };
1279
1280        assert_eq!(
1281            render_json(&result),
1282            "[{\"item\":\"Desk\",\"stock\":8,\"note\":null}]"
1283        );
1284    }
1285
1286    #[test]
1287    fn renders_markdown() {
1288        let result = QueryResult {
1289            columns: vec!["category".into(), "total".into()],
1290            rows: vec![vec![
1291                QueryValue::Text("electronics".into()),
1292                QueryValue::Integer(47),
1293            ]],
1294        };
1295
1296        assert_eq!(
1297            render_markdown(&result),
1298            "| category | total |\n| --- | --- |\n| electronics | 47 |"
1299        );
1300    }
1301
1302    // ── JSON extraction modes ─────────────────────────────────────────────────
1303
1304    fn write_test_json_object(path: &Path) -> Result<()> {
1305        fs::write(
1306            path,
1307            r#"{"name":"Alice","age":30,"city":"Rome"}"#,
1308        )?;
1309        Ok(())
1310    }
1311
1312    fn write_test_json_nested(path: &Path) -> Result<()> {
1313        fs::write(
1314            path,
1315            r#"[{"user":{"name":"Alice","address":{"city":"Rome"}},"score":10},{"user":{"name":"Bob","address":{"city":"Paris"}},"score":20}]"#,
1316        )?;
1317        Ok(())
1318    }
1319
1320    #[test]
1321    fn json_object_mode_turns_keys_into_rows() -> Result<()> {
1322        let json_path = temp_json_path("json-object-mode");
1323        write_test_json_object(&json_path)?;
1324
1325        let opts = ExtractionOptions {
1326            json_mode: JsonMode::Object,
1327            ..ExtractionOptions::default()
1328        };
1329        let inputs = [WorkbookInput {
1330            path: &json_path,
1331            sheet_name: None,
1332            table_name: None,
1333        }];
1334        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
1335            &inputs,
1336            "SELECT key, value FROM table ORDER BY key",
1337            &[],
1338            &TypeInferenceOptions::default(),
1339            &InputNormalizationOptions::default(),
1340            &opts,
1341            true,
1342        )?;
1343
1344        assert_eq!(result.columns, vec!["key", "value"]);
1345        // Three key-value rows, sorted by key.
1346        assert_eq!(result.rows.len(), 3);
1347        assert_eq!(result.rows[0][0], QueryValue::Text("age".into()));
1348        assert_eq!(result.rows[0][1], QueryValue::Integer(30));
1349        assert_eq!(result.rows[1][0], QueryValue::Text("city".into()));
1350        assert_eq!(result.rows[1][1], QueryValue::Text("Rome".into()));
1351        assert_eq!(result.rows[2][0], QueryValue::Text("name".into()));
1352        assert_eq!(result.rows[2][1], QueryValue::Text("Alice".into()));
1353
1354        fs::remove_file(json_path)?;
1355        Ok(())
1356    }
1357
1358    #[test]
1359    fn json_flatten_mode_expands_nested_objects() -> Result<()> {
1360        let json_path = temp_json_path("json-flatten-mode");
1361        write_test_json_nested(&json_path)?;
1362
1363        let opts = ExtractionOptions {
1364            json_mode: JsonMode::Flatten,
1365            ..ExtractionOptions::default()
1366        };
1367        let inputs = [WorkbookInput {
1368            path: &json_path,
1369            sheet_name: None,
1370            table_name: None,
1371        }];
1372        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
1373            &inputs,
1374            "SELECT \"user.name\", \"user.address.city\", score FROM table ORDER BY score",
1375            &[],
1376            &TypeInferenceOptions::default(),
1377            &InputNormalizationOptions::default(),
1378            &opts,
1379            true,
1380        )?;
1381
1382        assert!(result.columns.contains(&"user.name".to_owned()));
1383        assert!(result.columns.contains(&"user.address.city".to_owned()));
1384        assert_eq!(result.rows.len(), 2);
1385        // First row: Alice / Rome / 10
1386        let alice_col = result.columns.iter().position(|c| c == "user.name").unwrap();
1387        let city_col = result.columns.iter().position(|c| c == "user.address.city").unwrap();
1388        let score_col = result.columns.iter().position(|c| c == "score").unwrap();
1389        assert_eq!(result.rows[0][alice_col], QueryValue::Text("Alice".into()));
1390        assert_eq!(result.rows[0][city_col], QueryValue::Text("Rome".into()));
1391        assert_eq!(result.rows[0][score_col], QueryValue::Integer(10));
1392
1393        fs::remove_file(json_path)?;
1394        Ok(())
1395    }
1396
1397    #[test]
1398    fn json_array_mode_is_default_behavior() -> Result<()> {
1399        let json_path = temp_json_path("json-array-mode-default");
1400        write_test_json(&json_path)?;
1401
1402        let opts = ExtractionOptions {
1403            json_mode: JsonMode::Array,
1404            ..ExtractionOptions::default()
1405        };
1406        let inputs = [WorkbookInput {
1407            path: &json_path,
1408            sheet_name: None,
1409            table_name: None,
1410        }];
1411        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
1412            &inputs,
1413            "SELECT name, price FROM table WHERE active = 1",
1414            &[],
1415            &TypeInferenceOptions::default(),
1416            &InputNormalizationOptions::default(),
1417            &opts,
1418            true,
1419        )?;
1420
1421        assert_eq!(result.columns, vec!["name", "price"]);
1422        assert_eq!(
1423            result.rows,
1424            vec![vec![
1425                QueryValue::Text("Keyboard".into()),
1426                QueryValue::Real(12.5)
1427            ]]
1428        );
1429
1430        fs::remove_file(json_path)?;
1431        Ok(())
1432    }
1433
1434    // ── XML extraction modes ──────────────────────────────────────────────────
1435
1436    fn write_test_xml_with_attributes(path: &Path) -> Result<()> {
1437        fs::write(
1438            path,
1439            r#"<?xml version="1.0" encoding="UTF-8"?>
1440<products>
1441    <product id="1" name="Keyboard" price="12.5" active="true"/>
1442    <product id="2" name="Cable" price="5" active="false"/>
1443</products>
1444"#,
1445        )?;
1446        Ok(())
1447    }
1448
1449    fn write_test_xml_leaf_elements(path: &Path) -> Result<()> {
1450        fs::write(
1451            path,
1452            r#"<?xml version="1.0" encoding="UTF-8"?>
1453<config>
1454    <host>localhost</host>
1455    <port>5432</port>
1456    <database>mydb</database>
1457</config>
1458"#,
1459        )?;
1460        Ok(())
1461    }
1462
1463    #[test]
1464    fn xml_attributes_mode_extracts_attributes_as_columns() -> Result<()> {
1465        let xml_path = temp_xml_path("xml-attributes-mode");
1466        write_test_xml_with_attributes(&xml_path)?;
1467
1468        let opts = ExtractionOptions {
1469            xml_mode: XmlMode::Attributes,
1470            ..ExtractionOptions::default()
1471        };
1472        let inputs = [WorkbookInput {
1473            path: &xml_path,
1474            sheet_name: None,
1475            table_name: None,
1476        }];
1477        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
1478            &inputs,
1479            "SELECT id, name, price FROM table ORDER BY id",
1480            &[],
1481            &TypeInferenceOptions::default(),
1482            &InputNormalizationOptions::default(),
1483            &opts,
1484            true,
1485        )?;
1486
1487        assert_eq!(result.rows.len(), 2);
1488        let id_col = result.columns.iter().position(|c| c == "id").unwrap();
1489        let name_col = result.columns.iter().position(|c| c == "name").unwrap();
1490        assert_eq!(result.rows[0][id_col], QueryValue::Integer(1));
1491        assert_eq!(result.rows[0][name_col], QueryValue::Text("Keyboard".into()));
1492        assert_eq!(result.rows[1][id_col], QueryValue::Integer(2));
1493        assert_eq!(result.rows[1][name_col], QueryValue::Text("Cable".into()));
1494
1495        fs::remove_file(xml_path)?;
1496        Ok(())
1497    }
1498
1499    #[test]
1500    fn xml_descendants_mode_collects_leaf_elements_as_tag_value_rows() -> Result<()> {
1501        let xml_path = temp_xml_path("xml-descendants-mode");
1502        write_test_xml_leaf_elements(&xml_path)?;
1503
1504        let opts = ExtractionOptions {
1505            xml_mode: XmlMode::Descendants,
1506            ..ExtractionOptions::default()
1507        };
1508        let inputs = [WorkbookInput {
1509            path: &xml_path,
1510            sheet_name: None,
1511            table_name: None,
1512        }];
1513        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
1514            &inputs,
1515            "SELECT tag, value FROM table ORDER BY tag",
1516            &[],
1517            &TypeInferenceOptions::default(),
1518            &InputNormalizationOptions::default(),
1519            &opts,
1520            true,
1521        )?;
1522
1523        assert_eq!(result.columns, vec!["tag", "value"]);
1524        assert_eq!(result.rows.len(), 3);
1525        // Sorted alphabetically: database, host, port
1526        assert_eq!(result.rows[0][0], QueryValue::Text("database".into()));
1527        assert_eq!(result.rows[0][1], QueryValue::Text("mydb".into()));
1528        assert_eq!(result.rows[1][0], QueryValue::Text("host".into()));
1529        assert_eq!(result.rows[1][1], QueryValue::Text("localhost".into()));
1530        assert_eq!(result.rows[2][0], QueryValue::Text("port".into()));
1531        assert_eq!(result.rows[2][1], QueryValue::Integer(5432));
1532
1533        fs::remove_file(xml_path)?;
1534        Ok(())
1535    }
1536
1537    #[test]
1538    fn xml_rows_mode_is_default_behavior() -> Result<()> {
1539        let xml_path = temp_xml_path("xml-rows-mode-default");
1540        write_test_xml(&xml_path)?;
1541
1542        let opts = ExtractionOptions {
1543            xml_mode: XmlMode::Rows,
1544            ..ExtractionOptions::default()
1545        };
1546        let inputs = [WorkbookInput {
1547            path: &xml_path,
1548            sheet_name: None,
1549            table_name: None,
1550        }];
1551        let result = run_query_with_params_multi_inputs_and_options_and_normalization(
1552            &inputs,
1553            "SELECT name, price FROM table WHERE active = 1 ORDER BY price DESC",
1554            &[],
1555            &TypeInferenceOptions::default(),
1556            &InputNormalizationOptions::default(),
1557            &opts,
1558            true,
1559        )?;
1560
1561        assert_eq!(result.columns, vec!["name", "price"]);
1562        assert_eq!(
1563            result.rows,
1564            vec![vec![
1565                QueryValue::Text("Keyboard".into()),
1566                QueryValue::Real(12.5)
1567            ]]
1568        );
1569
1570        fs::remove_file(xml_path)?;
1571        Ok(())
1572    }
1573}