Skip to main content

query_forge/input/formats/
mod.rs

1use std::path::Path;
2
3use anyhow::Result;
4use chrono::{NaiveDate, NaiveDateTime};
5
6use crate::{
7    ExtractionOptions, HeaderCase, InputNormalizationOptions, QueryValue, TypeInferenceOptions,
8};
9
10pub(crate) mod csv;
11pub(crate) mod html;
12pub(crate) mod json;
13pub(crate) mod jsonl;
14pub(crate) mod markdown;
15pub(crate) mod parquet;
16pub(crate) mod xlsx;
17pub(crate) mod xml;
18
19/// Raw tabular data loaded from a single input sheet or file.
20#[derive(Debug)]
21pub struct SheetData {
22    /// Original name of the sheet, file, or tag as returned by the format loader.
23    pub original_name: String,
24    /// Column names in declaration order.
25    pub columns: Vec<String>,
26    /// Data rows; each row is a parallel `Vec` aligned with `columns`.
27    pub rows: Vec<Vec<QueryValue>>,
28}
29
30pub fn load_input(
31    input_path: &Path,
32    requested_sheet: Option<&str>,
33    inference_options: &TypeInferenceOptions,
34    extraction_options: &ExtractionOptions,
35    has_headers: bool,
36) -> Result<SheetData> {
37    if input_path
38        .extension()
39        .map(|extension| extension.to_string_lossy())
40        .is_some_and(|extension| extension.eq_ignore_ascii_case("parquet"))
41    {
42        return parquet::load_parquet_sheet(input_path, requested_sheet, inference_options);
43    }
44
45    if input_path
46        .extension()
47        .map(|extension| extension.to_string_lossy())
48        .is_some_and(|extension| extension.eq_ignore_ascii_case("xml"))
49    {
50        return xml::load_xml_sheet(
51            input_path,
52            requested_sheet,
53            inference_options,
54            extraction_options,
55        );
56    }
57
58    if input_path
59        .extension()
60        .map(|extension| extension.to_string_lossy())
61        .is_some_and(|extension| extension.eq_ignore_ascii_case("csv"))
62    {
63        return csv::load_csv_sheet(input_path, requested_sheet, inference_options, has_headers);
64    }
65
66    if input_path
67        .extension()
68        .map(|extension| extension.to_string_lossy())
69        .is_some_and(|extension| extension.eq_ignore_ascii_case("jsonl"))
70    {
71        return jsonl::load_jsonl_sheet(input_path, requested_sheet, inference_options);
72    }
73
74    if input_path
75        .extension()
76        .map(|extension| extension.to_string_lossy())
77        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
78    {
79        return json::load_json_sheet(
80            input_path,
81            requested_sheet,
82            inference_options,
83            extraction_options,
84        );
85    }
86
87    if input_path
88        .extension()
89        .map(|extension| extension.to_string_lossy())
90        .is_some_and(|extension| {
91            extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("markdown")
92        })
93    {
94        return markdown::load_markdown_sheet(
95            input_path,
96            requested_sheet,
97            inference_options,
98            has_headers,
99        );
100    }
101
102    if input_path
103        .extension()
104        .map(|extension| extension.to_string_lossy())
105        .is_some_and(|extension| {
106            extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm")
107        })
108    {
109        return html::load_html_sheet(
110            input_path,
111            requested_sheet,
112            inference_options,
113            has_headers,
114        );
115    }
116
117    xlsx::load_xlsx_sheet(input_path, requested_sheet, inference_options, has_headers)
118}
119
120pub(crate) fn apply_input_normalization(sheet: &mut SheetData, options: &InputNormalizationOptions) {
121    if options.trim {
122        for column in &mut sheet.columns {
123            *column = column.trim().to_owned();
124        }
125
126        for row in &mut sheet.rows {
127            for value in row {
128                if let QueryValue::Text(text) = value {
129                    let trimmed = text.trim();
130                    if trimmed.is_empty() {
131                        *value = QueryValue::Null;
132                    } else {
133                        *text = trimmed.to_owned();
134                    }
135                }
136            }
137        }
138    }
139
140    if options.normalize_headers {
141        sheet.columns = sheet
142            .columns
143            .iter()
144            .enumerate()
145            .map(|(index, header)| {
146                normalize_header_value(header, index + 1, options.header_case.as_ref())
147            })
148            .collect();
149    }
150
151    if options.dedupe_headers {
152        dedupe_headers(&mut sheet.columns);
153    }
154
155    if options.skip_empty_rows {
156        sheet.rows.retain(|row| !is_row_empty(row));
157    }
158}
159
160pub(crate) fn normalize_header_value(
161    header: &str,
162    index: usize,
163    header_case: Option<&HeaderCase>,
164) -> String {
165    let normalized = header
166        .trim()
167        .chars()
168        .map(|character| {
169            if character.is_alphanumeric() || character == '_' {
170                character
171            } else {
172                '_'
173            }
174        })
175        .collect::<String>();
176    let collapsed = collapse_underscores(&normalized)
177        .trim_matches('_')
178        .to_owned();
179
180    if collapsed.is_empty() {
181        return format!("column{index}");
182    }
183
184    let mut value = collapsed;
185
186    if matches!(header_case, Some(HeaderCase::Snake)) {
187        value = value.to_ascii_lowercase();
188    } else if matches!(header_case, Some(HeaderCase::ScreamingSnake)) {
189        value = value.to_ascii_uppercase();
190    } else if matches!(header_case, Some(HeaderCase::Camel)) {
191        value = to_camel_case(&value);
192    } else if matches!(header_case, Some(HeaderCase::Pascal)) {
193        value = to_pascal_case(&value);
194    }
195
196    value
197}
198
199/// Split `value` on `_` boundaries, lowercase every word, then join with the
200/// first word unchanged and all subsequent words title-cased.  Produces camelCase.
201fn to_camel_case(value: &str) -> String {
202    let mut result = String::with_capacity(value.len());
203    for (i, word) in value.split('_').enumerate() {
204        if word.is_empty() {
205            continue;
206        }
207        if i == 0 {
208            result.push_str(&word.to_ascii_lowercase());
209        } else {
210            let mut chars = word.chars();
211            if let Some(first) = chars.next() {
212                result.push(first.to_ascii_uppercase());
213                result.push_str(&chars.as_str().to_ascii_lowercase());
214            }
215        }
216    }
217    result
218}
219
220/// Split `value` on `_` boundaries, lowercase every word, then join with all
221/// words title-cased.  Produces PascalCase.
222fn to_pascal_case(value: &str) -> String {
223    let mut result = String::with_capacity(value.len());
224    for word in value.split('_') {
225        if word.is_empty() {
226            continue;
227        }
228        let mut chars = word.chars();
229        if let Some(first) = chars.next() {
230            result.push(first.to_ascii_uppercase());
231            result.push_str(&chars.as_str().to_ascii_lowercase());
232        }
233    }
234    result
235}
236
237fn collapse_underscores(value: &str) -> String {
238    let mut output = String::with_capacity(value.len());
239    let mut previous_was_underscore = false;
240
241    for character in value.chars() {
242        if character == '_' {
243            if !previous_was_underscore {
244                output.push(character);
245            }
246            previous_was_underscore = true;
247        } else {
248            output.push(character);
249            previous_was_underscore = false;
250        }
251    }
252
253    output
254}
255
256pub(crate) fn dedupe_headers(headers: &mut [String]) {
257    let mut seen = std::collections::HashMap::new();
258    let mut assigned = std::collections::HashSet::new();
259
260    for header in headers {
261        let base = header.clone();
262        let count = seen
263            .entry(base.clone())
264            .and_modify(|count| *count += 1)
265            .or_insert(1usize);
266
267        let mut candidate = if *count == 1 {
268            base.clone()
269        } else {
270            format!("{base}_{count}")
271        };
272
273        while assigned.contains(&candidate) {
274            *count += 1;
275            candidate = format!("{base}_{count}");
276        }
277
278        assigned.insert(candidate.clone());
279        *header = candidate;
280    }
281}
282
283fn is_row_empty(row: &[QueryValue]) -> bool {
284    row.iter().all(|value| match value {
285        QueryValue::Null => true,
286        QueryValue::Text(text) => text.trim().is_empty(),
287        QueryValue::Integer(_) | QueryValue::Real(_) => false,
288    })
289}
290
291pub(super) fn normalize_text_headers(headers: &[String]) -> Vec<String> {
292    let mut seen = std::collections::HashMap::new();
293
294    headers
295        .iter()
296        .enumerate()
297        .map(|(index, header)| {
298            let base = if header.trim().is_empty() {
299                format!("column{}", index + 1)
300            } else {
301                header.clone()
302            };
303
304            let count = seen
305                .entry(base.clone())
306                .and_modify(|count| *count += 1)
307                .or_insert(1usize);
308
309            if *count == 1 {
310                base
311            } else {
312                format!("{base}_{count}")
313            }
314        })
315        .collect()
316}
317
318pub(super) fn rows_maps_to_sheet_data(
319    rows_maps: Vec<Vec<(String, QueryValue)>>,
320    original_name: String,
321) -> SheetData {
322    let mut columns = Vec::<String>::new();
323    for row in &rows_maps {
324        for (column, _) in row {
325            if !columns.iter().any(|existing| existing == column) {
326                columns.push(column.clone());
327            }
328        }
329    }
330
331    let rows = rows_maps
332        .into_iter()
333        .map(|row| {
334            columns
335                .iter()
336                .map(|column| {
337                    row.iter()
338                        .find(|(key, _)| key == column)
339                        .map(|(_, value)| value.clone())
340                        .unwrap_or(QueryValue::Null)
341                })
342                .collect::<Vec<_>>()
343        })
344        .collect::<Vec<_>>();
345
346    SheetData {
347        original_name,
348        columns,
349        rows,
350    }
351}
352
353pub(super) fn parse_scalar_value(raw: &str, inference_options: &TypeInferenceOptions) -> QueryValue {
354    let trimmed = raw.trim();
355    if matches_token(trimmed, &inference_options.null_values) {
356        return QueryValue::Null;
357    }
358
359    if !inference_options.infer_types {
360        return QueryValue::Text(raw.to_owned());
361    }
362
363    if matches_token(trimmed, &inference_options.true_values) {
364        return QueryValue::Integer(1);
365    }
366
367    if matches_token(trimmed, &inference_options.false_values) {
368        return QueryValue::Integer(0);
369    }
370
371    if let Ok(value) = trimmed.parse::<i64>() {
372        return QueryValue::Integer(value);
373    }
374
375    if let Some(value) = parse_decimal_value(trimmed, inference_options.decimal_comma) {
376        return QueryValue::Real(value);
377    }
378
379    if let Some(value) = parse_date_value(trimmed, inference_options.date_format.as_deref()) {
380        return QueryValue::Text(value);
381    }
382
383    QueryValue::Text(raw.to_owned())
384}
385
386fn matches_token(raw: &str, candidates: &[String]) -> bool {
387    candidates
388        .iter()
389        .any(|candidate| raw.eq_ignore_ascii_case(candidate.trim()))
390}
391
392fn parse_decimal_value(raw: &str, decimal_comma: bool) -> Option<f64> {
393    if let Ok(value) = raw.parse::<f64>() {
394        return Some(value);
395    }
396
397    if !decimal_comma {
398        return None;
399    }
400
401    let compact = raw.replace(' ', "");
402    if !compact.contains(',') {
403        return None;
404    }
405
406    let normalized = if compact.contains('.') {
407        compact.replace('.', "").replace(',', ".")
408    } else {
409        compact.replace(',', ".")
410    };
411
412    normalized.parse::<f64>().ok()
413}
414
415fn parse_date_value(raw: &str, date_format: Option<&str>) -> Option<String> {
416    let format = date_format?;
417
418    if let Ok(value) = NaiveDate::parse_from_str(raw, format) {
419        return Some(value.format("%Y-%m-%d").to_string());
420    }
421
422    if let Ok(value) = NaiveDateTime::parse_from_str(raw, format) {
423        return Some(value.format("%Y-%m-%d %H:%M:%S").to_string());
424    }
425
426    None
427}
428
429pub(super) fn apply_inference_overrides(
430    value: QueryValue,
431    inference_options: &TypeInferenceOptions,
432) -> QueryValue {
433    if inference_options.infer_types {
434        return value;
435    }
436
437    match value {
438        QueryValue::Null => QueryValue::Null,
439        QueryValue::Integer(number) => QueryValue::Text(number.to_string()),
440        QueryValue::Real(number) => QueryValue::Text(number.to_string()),
441        QueryValue::Text(text) => QueryValue::Text(text),
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn normalizes_special_character_headers_to_column_name() {
451        assert_eq!(
452            normalize_header_value(" !!! ", 3, Some(&HeaderCase::Snake)),
453            "column3"
454        );
455    }
456
457    #[test]
458    fn header_case_snake_lowercases_all() {
459        assert_eq!(
460            normalize_header_value("First_Name", 1, Some(&HeaderCase::Snake)),
461            "first_name"
462        );
463        assert_eq!(
464            normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::Snake)),
465            "product_id"
466        );
467    }
468
469    #[test]
470    fn header_case_camel_produces_camel_case() {
471        assert_eq!(
472            normalize_header_value("first_name", 1, Some(&HeaderCase::Camel)),
473            "firstName"
474        );
475        assert_eq!(
476            normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::Camel)),
477            "productId"
478        );
479        assert_eq!(
480            normalize_header_value("product", 1, Some(&HeaderCase::Camel)),
481            "product"
482        );
483    }
484
485    #[test]
486    fn header_case_pascal_produces_pascal_case() {
487        assert_eq!(
488            normalize_header_value("first_name", 1, Some(&HeaderCase::Pascal)),
489            "FirstName"
490        );
491        assert_eq!(
492            normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::Pascal)),
493            "ProductId"
494        );
495        assert_eq!(
496            normalize_header_value("product", 1, Some(&HeaderCase::Pascal)),
497            "Product"
498        );
499    }
500
501    #[test]
502    fn header_case_screaming_snake_uppercases_all() {
503        assert_eq!(
504            normalize_header_value("first_name", 1, Some(&HeaderCase::ScreamingSnake)),
505            "FIRST_NAME"
506        );
507        assert_eq!(
508            normalize_header_value("PRODUCT ID", 1, Some(&HeaderCase::ScreamingSnake)),
509            "PRODUCT_ID"
510        );
511    }
512
513    #[test]
514    fn header_case_fallback_to_column_index_for_empty() {
515        assert_eq!(
516            normalize_header_value(" !!! ", 3, Some(&HeaderCase::Camel)),
517            "column3"
518        );
519        assert_eq!(
520            normalize_header_value(" !!! ", 5, Some(&HeaderCase::Pascal)),
521            "column5"
522        );
523        assert_eq!(
524            normalize_header_value(" !!! ", 2, Some(&HeaderCase::ScreamingSnake)),
525            "column2"
526        );
527    }
528
529    #[test]
530    fn dedupe_headers_handles_generated_name_collisions() {
531        let mut headers = vec![
532            "name".to_owned(),
533            "name".to_owned(),
534            "name_2".to_owned(),
535            "name".to_owned(),
536        ];
537
538        dedupe_headers(&mut headers);
539
540        assert_eq!(headers, vec!["name", "name_2", "name_2_2", "name_3"]);
541    }
542}