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 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 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 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 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 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 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}