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