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