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