Skip to main content

sql_cli/data/
stream_loader.rs

1// Stream-based data loader that works with any Read source
2// This allows the same code to handle files, HTTP responses, or any other data stream
3
4use anyhow::{Context, Result};
5use csv::ReaderBuilder;
6use serde_json::Value as JsonValue;
7use std::borrow::Cow;
8use std::collections::{HashMap, HashSet};
9use std::io::{BufRead, BufReader, Read};
10use tracing::{debug, info};
11
12use crate::data::advanced_csv_loader::StringInterner;
13use crate::data::datatable::{DataColumn, DataRow, DataTable, DataType, DataValue};
14
15/// Options controlling how a CSV stream is parsed.
16///
17/// Default is RFC-4180 style: comma delimiter, header row required. Surfaces
18/// (CLI flag, `READ_CSV(path, '|')`, WEB CTE `DELIMITER`) populate this struct
19/// at the edge; internal layers just pass it through.
20#[derive(Debug, Clone)]
21pub struct CsvReadOptions {
22    pub delimiter: u8,
23    pub has_headers: bool,
24}
25
26impl Default for CsvReadOptions {
27    fn default() -> Self {
28        Self {
29            delimiter: b',',
30            has_headers: true,
31        }
32    }
33}
34
35/// Pick a default delimiter from a path's extension.
36///
37/// `.tsv` → tab, `.psv` → pipe; everything else (including stdin `-`) → comma.
38/// Case-insensitive. This is only the *auto-detect* layer — explicit overrides
39/// from the CLI flag, `READ_CSV` 2nd arg, or WEB CTE `DELIMITER` win over this.
40pub fn detect_delimiter_from_path(path: &str) -> u8 {
41    let lower = path.to_ascii_lowercase();
42    if lower.ends_with(".tsv") {
43        b'\t'
44    } else if lower.ends_with(".psv") {
45        b'|'
46    } else {
47        b','
48    }
49}
50
51/// Parse a user-supplied delimiter string into a single byte.
52///
53/// Accepts:
54///   - a single ASCII character (e.g. `","`, `"|"`, `";"`)
55///   - the two-character escapes `"\t"`, `"\n"`, `"\r"` (typing literal tabs
56///     in SQL strings or shell args is awkward, so this is the canonical form)
57///
58/// Rejects multi-character strings, non-ASCII, and empty strings with a clear
59/// error. Caller is expected to wrap the error with context if needed.
60pub fn parse_delimiter_arg(s: &str) -> anyhow::Result<u8> {
61    match s {
62        "\\t" | "\t" => return Ok(b'\t'),
63        "\\n" => return Ok(b'\n'),
64        "\\r" => return Ok(b'\r'),
65        _ => {}
66    }
67    let bytes = s.as_bytes();
68    if bytes.len() == 1 && bytes[0].is_ascii() {
69        return Ok(bytes[0]);
70    }
71    Err(anyhow::anyhow!(
72        "delimiter must be a single ASCII character (or '\\t', '\\n', '\\r'); got {:?}",
73        s
74    ))
75}
76
77/// Resolve which delimiter to use for a given path.
78///
79/// Precedence (highest first):
80///   1. `explicit` override (typically from a CLI flag or 2nd arg)
81///   2. extension auto-detect (`.tsv` → tab, `.psv` → pipe)
82///   3. comma
83pub fn resolve_delimiter(path: &str, explicit: Option<u8>) -> u8 {
84    explicit.unwrap_or_else(|| detect_delimiter_from_path(path))
85}
86
87/// Delete the whitespace padding that "pretty printed" CSVs put around *quoted*
88/// fields, so the parser can see they were quoted at all.
89///
90/// RFC 4180 only recognises a quote that is the *first* byte of a field, so a
91/// human-aligned file like
92///
93/// ```text
94/// "Index", "Name", "Day"
95///  1, "George Washington",  22
96/// ```
97///
98/// parses the second column's header as the literal text ` "Name"` — padding,
99/// quotes and all — which is why `SELECT Name` then fails with "column not
100/// found". Worse, a comma *inside* a padded field splits it in two, because the
101/// parser never saw the field as quoted.
102///
103/// Nobody writing a file like that intends the quotes to end up in their column
104/// names, so we delete the run of spaces/tabs in front of an opening quote (and
105/// the mirror run after the closing quote). That turns the field back into a
106/// properly quoted one and the parser handles it from there — embedded
107/// delimiters included.
108///
109/// Deliberately narrow: **unquoted fields are left exactly as they are.** In a
110/// file written by a machine, ` 22` and `22` mean the same thing, but there is
111/// no way to tell that apart from a value whose spaces are real — an unquoted
112/// `  David  ` is the only way some producers can express a padded string, and
113/// `data/test_simple_strings.csv` relies on it surviving the load. Whitespace
114/// next to a quote carries no such ambiguity: the quotes already delimit the
115/// value, so anything outside them is alignment. Numeric-looking unquoted fields
116/// get their padding handled at type-inference time instead, where trimming
117/// can't destroy a string.
118///
119/// Bytes inside a quoted field are copied verbatim, so `"  padded  "` keeps its
120/// spaces — if you quoted the whitespace, you meant it. The delimiter itself is
121/// never treated as padding, which keeps `.tsv` files intact.
122///
123/// Returns [`Cow::Borrowed`] when there was nothing to strip, so the common
124/// RFC-4180 case costs one scan and no allocation.
125pub fn strip_field_padding(input: &[u8], delimiter: u8) -> Cow<'_, [u8]> {
126    let is_pad = |b: u8| (b == b' ' || b == b'\t') && b != delimiter;
127    let ends_field =
128        |b: Option<u8>| matches!(b, None | Some(b'\n') | Some(b'\r')) || b == Some(delimiter);
129
130    let mut out: Option<Vec<u8>> = None;
131    let mut i = 0;
132    let mut at_field_start = true;
133    let mut in_quotes = false;
134    // Set when a quoted field has just closed, so the padding that follows it is
135    // known to be alignment rather than part of an unquoted value.
136    let mut after_quoted_field = false;
137
138    while i < input.len() {
139        let b = input[i];
140
141        if in_quotes {
142            // `""` is an escaped quote and stays inside the field.
143            if b == b'"' {
144                if input.get(i + 1) == Some(&b'"') {
145                    if let Some(o) = out.as_mut() {
146                        o.extend_from_slice(&input[i..i + 2]);
147                    }
148                    i += 2;
149                    continue;
150                }
151                in_quotes = false;
152                after_quoted_field = true;
153            }
154            if let Some(o) = out.as_mut() {
155                o.push(b);
156            }
157            i += 1;
158            continue;
159        }
160
161        if is_pad(b) {
162            let mut j = i;
163            while j < input.len() && is_pad(input[j]) {
164                j += 1;
165            }
166            let next = input.get(j).copied();
167            // Padding is only ours to remove when a quote sits on one side of it:
168            // before an opening quote, or after a closing one.
169            let before_open_quote = at_field_start && next == Some(b'"');
170            let after_close_quote = after_quoted_field && ends_field(next);
171            if before_open_quote || after_close_quote {
172                out.get_or_insert_with(|| input[..i].to_vec());
173                i = j;
174                continue;
175            }
176            // Anything else belongs to an unquoted value — leave it alone.
177            if let Some(o) = out.as_mut() {
178                o.extend_from_slice(&input[i..j]);
179            }
180            i = j;
181            at_field_start = false;
182            continue;
183        }
184
185        if b == b'"' && at_field_start {
186            in_quotes = true;
187        }
188        at_field_start = b == delimiter || b == b'\n' || b == b'\r';
189        if at_field_start {
190            after_quoted_field = false;
191        }
192        if let Some(o) = out.as_mut() {
193            o.push(b);
194        }
195        i += 1;
196    }
197
198    out.map_or(Cow::Borrowed(input), Cow::Owned)
199}
200
201/// Human-readable form of a delimiter byte for diagnostic metadata.
202fn delimiter_label(d: u8) -> String {
203    match d {
204        b'\t' => "\\t".to_string(),
205        b'\n' => "\\n".to_string(),
206        b'\r' => "\\r".to_string(),
207        b => (b as char).to_string(),
208    }
209}
210
211/// Column analysis results for determining interning strategy
212#[derive(Debug)]
213struct ColumnAnalysis {
214    index: usize,
215    _name: String,
216    _cardinality: usize,
217    _sample_size: usize,
218    _unique_ratio: f64,
219    is_categorical: bool,
220    _avg_string_length: usize,
221}
222
223/// Advanced stream-based CSV loader with string interning
224pub struct StreamCsvLoader {
225    sample_size: usize,
226    cardinality_threshold: f64,
227    interners: HashMap<usize, StringInterner>,
228}
229
230impl StreamCsvLoader {
231    pub fn new() -> Self {
232        Self {
233            sample_size: 1000,
234            cardinality_threshold: 0.3,
235            interners: HashMap::new(),
236        }
237    }
238
239    /// Analyze columns to determine which should use string interning
240    fn analyze_columns(
241        &self,
242        rows: &[Vec<String>],
243        headers: &csv::StringRecord,
244    ) -> Vec<ColumnAnalysis> {
245        let mut analyses = Vec::new();
246
247        for (col_idx, header) in headers.iter().enumerate() {
248            let mut unique_values = HashSet::new();
249            let mut total_length = 0;
250            let mut non_empty_count = 0;
251
252            // Sample rows to analyze cardinality
253            for row in rows.iter().take(self.sample_size) {
254                if let Some(value) = row.get(col_idx) {
255                    if !value.is_empty() {
256                        unique_values.insert(value.clone());
257                        total_length += value.len();
258                        non_empty_count += 1;
259                    }
260                }
261            }
262
263            let cardinality = unique_values.len();
264            let sample_size = rows.len().min(self.sample_size);
265            let unique_ratio = if sample_size > 0 {
266                cardinality as f64 / sample_size as f64
267            } else {
268                1.0
269            };
270
271            let avg_string_length = if non_empty_count > 0 {
272                total_length / non_empty_count
273            } else {
274                0
275            };
276
277            // Consider categorical if low cardinality ratio or short strings with repetition
278            let is_categorical = unique_ratio < self.cardinality_threshold
279                || (avg_string_length < 20 && cardinality < sample_size / 2);
280
281            analyses.push(ColumnAnalysis {
282                index: col_idx,
283                _name: header.to_string(),
284                _cardinality: cardinality,
285                _sample_size: sample_size,
286                _unique_ratio: unique_ratio,
287                is_categorical,
288                _avg_string_length: avg_string_length,
289            });
290        }
291
292        analyses
293    }
294
295    /// Load CSV data with string interning from any Read source, using default
296    /// comma-delimited options. Thin wrapper over [`load_csv_from_reader_with_opts`]
297    /// kept so existing callers don't need to touch options.
298    pub fn load_csv_from_reader<R: Read>(
299        &mut self,
300        reader: R,
301        table_name: &str,
302        source_type: &str,
303        source_path: &str,
304    ) -> Result<DataTable> {
305        self.load_csv_from_reader_with_opts(
306            reader,
307            table_name,
308            source_type,
309            source_path,
310            &CsvReadOptions::default(),
311        )
312    }
313
314    /// Load CSV data with string interning, honouring caller-supplied options
315    /// (delimiter, headers).
316    pub fn load_csv_from_reader_with_opts<R: Read>(
317        &mut self,
318        mut reader: R,
319        table_name: &str,
320        source_type: &str,
321        source_path: &str,
322        opts: &CsvReadOptions,
323    ) -> Result<DataTable> {
324        info!(
325            "Stream CSV load: Loading {} with optimizations (delimiter={})",
326            source_path,
327            delimiter_label(opts.delimiter)
328        );
329
330        // Read all data into memory
331        let mut raw_buffer = Vec::new();
332        reader.read_to_end(&mut raw_buffer)?;
333
334        // Strip alignment padding up front so both passes below agree on where
335        // fields start and end (the NULL pass indexes into these same bytes).
336        let buffer = strip_field_padding(&raw_buffer, opts.delimiter);
337
338        // First pass: Parse CSV with headers
339        let mut csv_reader = ReaderBuilder::new()
340            .has_headers(opts.has_headers)
341            .delimiter(opts.delimiter)
342            .from_reader(&buffer[..]);
343
344        let headers = csv_reader.headers()?.clone();
345        let mut table = DataTable::new(table_name);
346
347        // Add metadata about the source
348        table
349            .metadata
350            .insert("source_type".to_string(), source_type.to_string());
351        table
352            .metadata
353            .insert("source_path".to_string(), source_path.to_string());
354        table
355            .metadata
356            .insert("delimiter".to_string(), delimiter_label(opts.delimiter));
357
358        // Create columns from headers
359        for header in &headers {
360            table.add_column(DataColumn::new(header));
361        }
362
363        // Collect all rows as strings
364        let mut string_rows = Vec::new();
365        for result in csv_reader.records() {
366            let record = result?;
367            let row: Vec<String> = record.iter().map(|s| s.to_string()).collect();
368            string_rows.push(row);
369        }
370
371        // Analyze columns for string interning
372        let analyses = self.analyze_columns(&string_rows, &headers);
373        let categorical_columns: HashSet<usize> = analyses
374            .iter()
375            .filter(|a| a.is_categorical)
376            .map(|a| a.index)
377            .collect();
378
379        info!(
380            "Column analysis: {} of {} columns will use string interning",
381            categorical_columns.len(),
382            analyses.len()
383        );
384
385        // Initialize interners for categorical columns
386        for col_idx in &categorical_columns {
387            self.interners.insert(*col_idx, StringInterner::new());
388        }
389
390        // Second pass: Read raw lines for NULL detection
391        let mut line_reader = BufReader::new(&buffer[..]);
392        let mut raw_lines = Vec::new();
393        let mut raw_line = String::new();
394
395        // Skip header
396        line_reader.read_line(&mut raw_line)?;
397        raw_line.clear();
398
399        // Read all raw lines
400        for _ in 0..string_rows.len() {
401            line_reader.read_line(&mut raw_line)?;
402            raw_lines.push(raw_line.clone());
403            raw_line.clear();
404        }
405
406        // Infer column types by sampling
407        let mut column_types = vec![DataType::Null; headers.len()];
408        let sample_size = string_rows.len().min(100);
409
410        for row in string_rows.iter().take(sample_size) {
411            for (col_idx, value) in row.iter().enumerate() {
412                if !value.is_empty() {
413                    let inferred = DataType::infer_from_string(value);
414                    column_types[col_idx] = column_types[col_idx].merge(&inferred);
415                }
416            }
417        }
418
419        // Update column types
420        for (col_idx, column) in table.columns.iter_mut().enumerate() {
421            column.data_type = column_types[col_idx].clone();
422        }
423
424        // Convert strings to typed values and add rows
425        for (row_idx, string_row) in string_rows.iter().enumerate() {
426            let mut values = Vec::new();
427            let raw_line = &raw_lines[row_idx];
428
429            for (col_idx, value) in string_row.iter().enumerate() {
430                let data_value = if value.is_empty() {
431                    // Check if this is NULL (,,) vs empty string ("")
432                    if is_null_field(raw_line, col_idx, opts.delimiter as char) {
433                        DataValue::Null
434                    } else if categorical_columns.contains(&col_idx) {
435                        // Use interned string for empty categorical values
436                        if let Some(interner) = self.interners.get_mut(&col_idx) {
437                            DataValue::InternedString(interner.intern(""))
438                        } else {
439                            DataValue::String(String::new())
440                        }
441                    } else {
442                        DataValue::String(String::new())
443                    }
444                } else if categorical_columns.contains(&col_idx)
445                    && column_types[col_idx] == DataType::String
446                {
447                    // Use string interning for categorical columns
448                    if let Some(interner) = self.interners.get_mut(&col_idx) {
449                        DataValue::InternedString(interner.intern(value))
450                    } else {
451                        DataValue::from_string(value, &column_types[col_idx])
452                    }
453                } else {
454                    DataValue::from_string(value, &column_types[col_idx])
455                };
456                values.push(data_value);
457            }
458            table
459                .add_row(DataRow::new(values))
460                .map_err(|e| anyhow::anyhow!(e))?;
461        }
462
463        // Print interner statistics
464        for (col_idx, interner) in &self.interners {
465            let stats = interner.stats();
466            if stats.memory_saved_bytes > 0 {
467                debug!(
468                    "Column {} interning: {} unique strings, {} references, {} bytes saved",
469                    headers.get(*col_idx).unwrap_or(&String::new()),
470                    stats.unique_strings,
471                    stats.total_references,
472                    stats.memory_saved_bytes
473                );
474            }
475        }
476
477        // Update column statistics
478        table.infer_column_types();
479
480        Ok(table)
481    }
482}
483
484/// Simple wrapper for loading CSV without advanced features. Defaults to
485/// comma delimiter; for other delimiters use [`load_csv_from_reader_with_opts`].
486pub fn load_csv_from_reader<R: Read>(
487    reader: R,
488    table_name: &str,
489    source_type: &str,
490    source_path: &str,
491) -> Result<DataTable> {
492    let mut loader = StreamCsvLoader::new();
493    loader.load_csv_from_reader(reader, table_name, source_type, source_path)
494}
495
496/// As [`load_csv_from_reader`], but honouring caller-supplied [`CsvReadOptions`]
497/// (delimiter, headers).
498pub fn load_csv_from_reader_with_opts<R: Read>(
499    reader: R,
500    table_name: &str,
501    source_type: &str,
502    source_path: &str,
503    opts: &CsvReadOptions,
504) -> Result<DataTable> {
505    let mut loader = StreamCsvLoader::new();
506    loader.load_csv_from_reader_with_opts(reader, table_name, source_type, source_path, opts)
507}
508
509/// Parse JSON content as either a JSON array of objects or JSONL
510/// (newline-delimited JSON, one object per line). The format is detected by
511/// peeking at the first non-whitespace byte: `[` starts an array, anything
512/// else is parsed line-by-line.
513///
514/// Empty and whitespace-only lines are skipped in JSONL mode. Parse errors
515/// in JSONL mode include the source line number.
516pub fn parse_json_records(content: &str) -> Result<Vec<JsonValue>> {
517    let trimmed = content.trim_start();
518    if trimmed.starts_with('[') {
519        return serde_json::from_str(content).with_context(|| "Failed to parse JSON array");
520    }
521
522    let mut out = Vec::new();
523    for (idx, raw_line) in content.lines().enumerate() {
524        let line = raw_line.trim();
525        if line.is_empty() {
526            continue;
527        }
528        let value: JsonValue = serde_json::from_str(line)
529            .with_context(|| format!("Failed to parse JSONL at line {}", idx + 1))?;
530        out.push(value);
531    }
532    Ok(out)
533}
534
535/// Navigate to a sub-value of a JSON document using a dotted path.
536///
537/// This is the shared "find the rows" step used by both the WEB CTE
538/// `JSON_PATH` clause and `READ_JSON(path, json_path)`. It only *locates* a
539/// value; it deliberately does not filter, transform, or pluck scalars — that
540/// is SQL's job (use a WHERE clause / column list once the rows are loaded).
541/// Anything beyond locating the row set is out of scope; pipe through `jq`.
542///
543/// Supports two forms per dotted segment:
544///   - `name`     — descend into an object key
545///   - `name[]`   — descend into `name` (must be an array), then map the
546///                  remainder of the path across every element. A bare `[]`
547///                  projects over the current value when it is already an array.
548///
549/// Example for an Elasticsearch response:
550///   `hits.hits[]._source`
551/// returns an array of `_source` objects, one per hit — i.e. the `_source`
552/// fields become the top-level row shape consumed by the loader.
553///
554/// An empty path (or one that is only dots) returns the value unchanged.
555pub fn navigate_json_path(value: &JsonValue, path: &str) -> Result<JsonValue> {
556    let parts: Vec<&str> = path.split('.').filter(|p| !p.is_empty()).collect();
557    walk_json_path(value, &parts)
558}
559
560fn walk_json_path(value: &JsonValue, parts: &[&str]) -> Result<JsonValue> {
561    let Some((head, tail)) = parts.split_first() else {
562        return Ok(value.clone());
563    };
564
565    // Array projection: `name[]` (or bare `[]`) maps the rest of the path
566    // across each element of an array.
567    if let Some(name) = head.strip_suffix("[]") {
568        let array_val = if name.is_empty() {
569            value
570        } else {
571            value
572                .get(name)
573                .ok_or_else(|| anyhow::anyhow!("Path '{}' not found in JSON", name))?
574        };
575        let arr = array_val.as_array().ok_or_else(|| {
576            anyhow::anyhow!(
577                "Expected array at '{}' for [] projection, got {}",
578                if name.is_empty() { "<root>" } else { name },
579                json_kind(array_val)
580            )
581        })?;
582        let mut projected = Vec::with_capacity(arr.len());
583        for el in arr {
584            projected.push(walk_json_path(el, tail)?);
585        }
586        return Ok(JsonValue::Array(projected));
587    }
588
589    let next = value
590        .get(head)
591        .ok_or_else(|| anyhow::anyhow!("Path '{}' not found in JSON", head))?;
592    walk_json_path(next, tail)
593}
594
595/// Human-readable JSON type name, for error messages.
596fn json_kind(value: &JsonValue) -> &'static str {
597    match value {
598        JsonValue::Null => "null",
599        JsonValue::Bool(_) => "bool",
600        JsonValue::Number(_) => "number",
601        JsonValue::String(_) => "string",
602        JsonValue::Array(_) => "array",
603        JsonValue::Object(_) => "object",
604    }
605}
606
607/// Compute the ordered union of object keys across the first `sample_size`
608/// records. Order of first occurrence is preserved so the column layout is
609/// stable. Non-object records are skipped.
610pub fn collect_column_names(records: &[JsonValue], sample_size: usize) -> Vec<String> {
611    let mut seen: HashSet<String> = HashSet::new();
612    let mut names: Vec<String> = Vec::new();
613    for record in records.iter().take(sample_size) {
614        if let Some(obj) = record.as_object() {
615            for key in obj.keys() {
616                if seen.insert(key.clone()) {
617                    names.push(key.clone());
618                }
619            }
620        }
621    }
622    names
623}
624
625/// Load JSON data from any Read source into a DataTable.
626///
627/// Accepts either a JSON array of objects (`[{...}, {...}]`) or JSONL
628/// (one JSON object per line). Format is auto-detected.
629pub fn load_json_from_reader<R: Read>(
630    mut reader: R,
631    table_name: &str,
632    source_type: &str,
633    source_path: &str,
634) -> Result<DataTable> {
635    let mut json_str = String::new();
636    reader.read_to_string(&mut json_str)?;
637
638    let json_data: Vec<JsonValue> = parse_json_records(&json_str)?;
639
640    if json_data.is_empty() {
641        return Ok(DataTable::new(table_name));
642    }
643
644    // Schema is the union of keys across the first 100 records so heterogeneous
645    // JSONL streams (where later records may carry fields the first one did
646    // not) don't silently drop columns.
647    let column_names = collect_column_names(&json_data, 100);
648    if column_names.is_empty() {
649        return Err(anyhow::anyhow!(
650            "JSON data must contain objects (got non-object records)"
651        ));
652    }
653
654    let mut table = DataTable::new(table_name);
655
656    // Add metadata
657    table
658        .metadata
659        .insert("source_type".to_string(), source_type.to_string());
660    table
661        .metadata
662        .insert("source_path".to_string(), source_path.to_string());
663
664    for name in &column_names {
665        table.add_column(DataColumn::new(name));
666    }
667
668    // Collect values for type inference
669    let mut string_rows = Vec::new();
670    for json_obj in &json_data {
671        if let Some(obj) = json_obj.as_object() {
672            let mut row = Vec::new();
673            for col_name in &column_names {
674                let value = obj
675                    .get(col_name)
676                    .map(|v| json_value_to_string(v))
677                    .unwrap_or_default();
678                row.push(value);
679            }
680            string_rows.push(row);
681        }
682    }
683
684    // Infer column types
685    let mut column_types = vec![DataType::Null; column_names.len()];
686    let sample_size = string_rows.len().min(100);
687
688    for row in string_rows.iter().take(sample_size) {
689        for (col_idx, value) in row.iter().enumerate() {
690            if !value.is_empty() && value != "null" {
691                let inferred = DataType::infer_from_string(value);
692                column_types[col_idx] = column_types[col_idx].merge(&inferred);
693            }
694        }
695    }
696
697    // Update column types
698    for (col_idx, column) in table.columns.iter_mut().enumerate() {
699        column.data_type = column_types[col_idx].clone();
700    }
701
702    // Convert to typed values and add rows
703    for string_row in &string_rows {
704        let mut values = Vec::new();
705        for (col_idx, value) in string_row.iter().enumerate() {
706            let data_value = if value.is_empty() || value == "null" {
707                DataValue::Null
708            } else {
709                DataValue::from_string(value, &column_types[col_idx])
710            };
711            values.push(data_value);
712        }
713        table
714            .add_row(DataRow::new(values))
715            .map_err(|e| anyhow::anyhow!(e))?;
716    }
717
718    // Update statistics
719    table.infer_column_types();
720
721    Ok(table)
722}
723
724/// Helper to convert JSON value to string for type inference
725fn json_value_to_string(value: &JsonValue) -> String {
726    match value {
727        JsonValue::Null => String::new(),
728        JsonValue::Bool(b) => b.to_string(),
729        JsonValue::Number(n) => n.to_string(),
730        JsonValue::String(s) => s.clone(),
731        JsonValue::Array(arr) => format!("{:?}", arr),
732        JsonValue::Object(obj) => format!("{:?}", obj),
733    }
734}
735
736/// Helper to detect NULL fields in raw CSV lines. `delimiter` is the field
737/// separator character used in the source (`,` for plain CSV, `\t` for TSV, etc.).
738fn is_null_field(raw_line: &str, field_index: usize, delimiter: char) -> bool {
739    let mut delim_count = 0;
740    let mut in_quotes = false;
741    let mut field_start = 0;
742    let mut prev_char = ' ';
743
744    for (i, ch) in raw_line.char_indices() {
745        if ch == '"' && prev_char != '\\' {
746            in_quotes = !in_quotes;
747        } else if ch == delimiter && !in_quotes {
748            if delim_count == field_index {
749                // Found the field - check if it's empty
750                return i == field_start
751                    || (i == field_start + 1
752                        && raw_line.chars().nth(field_start) == Some(delimiter));
753            }
754            delim_count += 1;
755            field_start = i + 1;
756        }
757        prev_char = ch;
758    }
759
760    // Check last field
761    if delim_count == field_index {
762        let remaining = raw_line[field_start..].trim_end();
763        return remaining.is_empty() || remaining.chars().next() == Some(delimiter);
764    }
765
766    false
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772    use std::io::Cursor;
773
774    // ---- strip_field_padding tests ----
775
776    fn stripped(input: &str, delimiter: u8) -> String {
777        String::from_utf8(strip_field_padding(input.as_bytes(), delimiter).into_owned()).unwrap()
778    }
779
780    #[test]
781    fn test_strip_padding_leaves_rfc4180_untouched() {
782        let input = "Index,Name,Day\n1,\"George Washington\",22\n";
783        // Nothing to strip => no allocation, bytes handed straight through.
784        assert!(matches!(
785            strip_field_padding(input.as_bytes(), b','),
786            Cow::Borrowed(_)
787        ));
788        assert_eq!(stripped(input, b','), input);
789    }
790
791    #[test]
792    fn test_strip_padding_unwraps_quotes_padded_after_delimiter() {
793        // The president_birthdays.csv shape: quotes preceded by a space are
794        // invisible to the parser until the padding is gone.
795        let input = "\"Index\", \"Name\", \"Day\"\n 1, \"George Washington\",\t22\n";
796        // Only the padding hugging a quote goes; ` 1` and `\t22` are unquoted and
797        // stay put (type inference sees through their padding instead).
798        assert_eq!(
799            stripped(input, b','),
800            "\"Index\",\"Name\",\"Day\"\n 1,\"George Washington\",\t22\n"
801        );
802    }
803
804    #[test]
805    fn test_strip_padding_preserves_whitespace_inside_quotes() {
806        // Quoted padding is data, not alignment.
807        let input = "a, \"  keep  \" ,b\n";
808        assert_eq!(stripped(input, b','), "a,\"  keep  \",b\n");
809    }
810
811    #[test]
812    fn test_strip_padding_leaves_unquoted_fields_alone() {
813        // `data/test_simple_strings.csv` stores `  David  ` unquoted and tests
814        // assert on those spaces, so an unquoted field is never touched here.
815        let input = "4,  David  ,x\n";
816        assert_eq!(stripped(input, b','), input);
817    }
818
819    #[test]
820    fn test_strip_padding_preserves_escaped_quotes() {
821        let input = " \"he said \"\"hi\"\"\" ,1\n";
822        assert_eq!(stripped(input, b','), "\"he said \"\"hi\"\"\",1\n");
823    }
824
825    #[test]
826    fn test_strip_padding_never_eats_a_tab_delimiter() {
827        // In a .tsv the tab is the delimiter, so only spaces can be padding.
828        let input = "a\t \"b\" \tc\n";
829        assert_eq!(stripped(input, b'\t'), "a\t\"b\"\tc\n");
830    }
831
832    #[test]
833    fn test_strip_padding_recovers_delimiter_inside_padded_quotes() {
834        // Padding hid the quoting, so this used to split into three fields.
835        let input = "1, \"Adams, John\", 2\n";
836        let out = stripped(input, b',');
837        assert_eq!(out, "1,\"Adams, John\", 2\n");
838
839        let mut rdr = ReaderBuilder::new()
840            .has_headers(false)
841            .from_reader(out.as_bytes());
842        let rec = rdr.records().next().unwrap().unwrap();
843        assert_eq!(rec.len(), 3);
844        assert_eq!(&rec[1], "Adams, John");
845    }
846
847    #[test]
848    fn test_strip_padding_handles_crlf_and_end_of_line_pad() {
849        let input = " \"a\" , \"b\" \r\n \"c\" , \"d\" \r\n";
850        assert_eq!(stripped(input, b','), "\"a\",\"b\"\r\n\"c\",\"d\"\r\n");
851    }
852
853    #[test]
854    fn test_padded_csv_loads_with_clean_headers_and_numeric_types() {
855        let csv = "\"Index\", \"Name\", \"Year\"\n 1, \"George Washington\", 1732\n 2, \"John Adams\", 1735\n";
856        let table = StreamCsvLoader::new()
857            .load_csv_from_reader(Cursor::new(csv), "pres", "test", "<test>")
858            .unwrap();
859
860        let names: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
861        assert_eq!(names, vec!["Index", "Name", "Year"]);
862        // Padding used to make every column a string; Year should now be numeric.
863        assert_eq!(table.columns[2].data_type, DataType::Integer);
864        assert_eq!(
865            table.rows[0].values[1],
866            DataValue::String("George Washington".to_string())
867        );
868    }
869
870    // ---- navigate_json_path tests ----
871
872    #[test]
873    fn test_navigate_json_path_descends_object_key() {
874        // The TeamCity-style case: object with a nested array one level down.
875        let doc = serde_json::json!({
876            "count": 2,
877            "project": [{"id": "a"}, {"id": "b"}]
878        });
879        let extracted = navigate_json_path(&doc, "project").unwrap();
880        assert!(extracted.is_array());
881        assert_eq!(extracted.as_array().unwrap().len(), 2);
882    }
883
884    #[test]
885    fn test_navigate_json_path_nested_descent() {
886        // TeamCity actually wraps as { projects: { project: [...] } }.
887        let doc = serde_json::json!({
888            "projects": {"project": [{"id": "a"}, {"id": "b"}, {"id": "c"}]}
889        });
890        let extracted = navigate_json_path(&doc, "projects.project").unwrap();
891        assert_eq!(extracted.as_array().unwrap().len(), 3);
892    }
893
894    #[test]
895    fn test_navigate_json_path_array_projection() {
896        // Elasticsearch-style: project _source out of each hit.
897        let doc = serde_json::json!({
898            "hits": {"hits": [
899                {"_source": {"id": 1}},
900                {"_source": {"id": 2}}
901            ]}
902        });
903        let extracted = navigate_json_path(&doc, "hits.hits[]._source").unwrap();
904        let arr = extracted.as_array().unwrap();
905        assert_eq!(arr.len(), 2);
906        assert_eq!(arr[1]["id"], serde_json::json!(2));
907    }
908
909    #[test]
910    fn test_navigate_json_path_bare_projection_over_root_array() {
911        let doc = serde_json::json!([{"v": {"x": 1}}, {"v": {"x": 2}}]);
912        let extracted = navigate_json_path(&doc, "[].v").unwrap();
913        let arr = extracted.as_array().unwrap();
914        assert_eq!(arr[0]["x"], serde_json::json!(1));
915    }
916
917    #[test]
918    fn test_navigate_json_path_empty_path_is_identity() {
919        let doc = serde_json::json!({"a": 1});
920        let extracted = navigate_json_path(&doc, "").unwrap();
921        assert_eq!(extracted, doc);
922    }
923
924    #[test]
925    fn test_navigate_json_path_missing_key_errors() {
926        let doc = serde_json::json!({"a": 1});
927        let err = navigate_json_path(&doc, "b").unwrap_err();
928        assert!(err.to_string().contains("not found"), "{}", err);
929    }
930
931    #[test]
932    fn test_navigate_json_path_projection_on_non_array_errors() {
933        let doc = serde_json::json!({"a": {"not": "an array"}});
934        let err = navigate_json_path(&doc, "a[]").unwrap_err();
935        let msg = err.to_string();
936        assert!(msg.contains("Expected array"), "{}", msg);
937        assert!(msg.contains("object"), "{}", msg);
938    }
939
940    #[test]
941    fn test_csv_from_reader() {
942        let csv_data = "id,name,value\n1,Alice,100\n2,Bob,200\n3,,300";
943        let reader = Cursor::new(csv_data);
944
945        let table =
946            load_csv_from_reader(reader, "test", "stream", "memory").expect("Failed to load CSV");
947
948        assert_eq!(table.name, "test");
949        assert_eq!(table.column_count(), 3);
950        assert_eq!(table.row_count(), 3);
951
952        // Check that empty field is NULL
953        let value = table.get_value(2, 1).unwrap();
954        assert!(matches!(value, DataValue::Null));
955    }
956
957    #[test]
958    fn test_json_from_reader() {
959        let json_data = r#"[
960            {"id": 1, "name": "Alice", "value": 100},
961            {"id": 2, "name": "Bob", "value": 200},
962            {"id": 3, "name": null, "value": 300}
963        ]"#;
964        let reader = Cursor::new(json_data);
965
966        let table =
967            load_json_from_reader(reader, "test", "stream", "memory").expect("Failed to load JSON");
968
969        assert_eq!(table.name, "test");
970        assert_eq!(table.column_count(), 3);
971        assert_eq!(table.row_count(), 3);
972
973        // Check that null is handled
974        let value = table.get_value(2, 1).unwrap();
975        assert!(matches!(value, DataValue::Null));
976    }
977
978    #[test]
979    fn test_jsonl_from_reader() {
980        let jsonl_data = "{\"id\":1,\"name\":\"Alice\"}\n{\"id\":2,\"name\":\"Bob\"}\n";
981        let reader = Cursor::new(jsonl_data);
982
983        let table = load_json_from_reader(reader, "test", "stream", "memory")
984            .expect("Failed to load JSONL");
985
986        assert_eq!(table.column_count(), 2);
987        assert_eq!(table.row_count(), 2);
988    }
989
990    #[test]
991    fn test_jsonl_heterogeneous_schema_unioned() {
992        // Second record adds an "extra" field; loader should pick it up via the
993        // union, and row 0 should have Null for it.
994        let jsonl_data = "{\"id\":1}\n{\"id\":2,\"extra\":\"hi\"}\n";
995        let reader = Cursor::new(jsonl_data);
996        let table = load_json_from_reader(reader, "test", "stream", "memory").expect("load");
997        assert_eq!(table.column_count(), 2);
998        assert_eq!(table.row_count(), 2);
999    }
1000
1001    #[test]
1002    fn test_jsonl_skips_blank_lines() {
1003        let jsonl_data = "{\"id\":1}\n\n\n{\"id\":2}\n";
1004        let reader = Cursor::new(jsonl_data);
1005        let table = load_json_from_reader(reader, "test", "stream", "memory").expect("load");
1006        assert_eq!(table.row_count(), 2);
1007    }
1008
1009    #[test]
1010    fn test_parse_json_records_array_form() {
1011        let recs = parse_json_records(r#"[{"a":1},{"a":2}]"#).unwrap();
1012        assert_eq!(recs.len(), 2);
1013    }
1014
1015    #[test]
1016    fn test_parse_json_records_jsonl_form() {
1017        let recs = parse_json_records("{\"a\":1}\n{\"a\":2}\n").unwrap();
1018        assert_eq!(recs.len(), 2);
1019    }
1020
1021    #[test]
1022    fn test_parse_json_records_jsonl_error_cites_line() {
1023        let err = parse_json_records("{\"a\":1}\nnot json\n").unwrap_err();
1024        assert!(err.to_string().contains("line 2"));
1025    }
1026
1027    // ---- CsvReadOptions / delimiter detection ----
1028
1029    #[test]
1030    fn test_csv_options_default_is_comma() {
1031        let opts = CsvReadOptions::default();
1032        assert_eq!(opts.delimiter, b',');
1033        assert!(opts.has_headers);
1034    }
1035
1036    #[test]
1037    fn test_detect_delimiter_from_path() {
1038        assert_eq!(detect_delimiter_from_path("data.tsv"), b'\t');
1039        assert_eq!(detect_delimiter_from_path("data.TSV"), b'\t');
1040        assert_eq!(detect_delimiter_from_path("/tmp/foo.psv"), b'|');
1041        assert_eq!(detect_delimiter_from_path("data.PSV"), b'|');
1042        assert_eq!(detect_delimiter_from_path("data.csv"), b',');
1043        assert_eq!(detect_delimiter_from_path("noext"), b',');
1044        assert_eq!(detect_delimiter_from_path("-"), b',');
1045    }
1046
1047    #[test]
1048    fn test_load_csv_with_pipe_delimiter() {
1049        let data = "id|name|score\n1|alice|10\n2|bob|20\n";
1050        let reader = Cursor::new(data);
1051        let opts = CsvReadOptions {
1052            delimiter: b'|',
1053            has_headers: true,
1054        };
1055        let table = load_csv_from_reader_with_opts(reader, "psv", "test", "memory", &opts)
1056            .expect("load failed");
1057        assert_eq!(table.column_count(), 3);
1058        assert_eq!(table.row_count(), 2);
1059        assert_eq!(table.get_value(0, 0).unwrap(), &DataValue::Integer(1));
1060        assert_eq!(
1061            table.get_value(1, 1).unwrap(),
1062            &DataValue::String("bob".to_string())
1063        );
1064    }
1065
1066    #[test]
1067    fn test_load_csv_with_tab_delimiter() {
1068        let data = "id\tname\tscore\n1\talice\t10\n2\tbob\t20\n";
1069        let reader = Cursor::new(data);
1070        let opts = CsvReadOptions {
1071            delimiter: b'\t',
1072            has_headers: true,
1073        };
1074        let table = load_csv_from_reader_with_opts(reader, "tsv", "test", "memory", &opts)
1075            .expect("load failed");
1076        assert_eq!(table.column_count(), 3);
1077        assert_eq!(table.row_count(), 2);
1078        assert_eq!(table.get_value(0, 0).unwrap(), &DataValue::Integer(1));
1079    }
1080
1081    #[test]
1082    fn test_metadata_records_delimiter() {
1083        // Comma -> stored as ","
1084        let table = load_csv_from_reader(Cursor::new("a,b\n1,2\n"), "t", "test", "memory").unwrap();
1085        assert_eq!(
1086            table.metadata.get("delimiter").map(String::as_str),
1087            Some(",")
1088        );
1089
1090        // Tab -> stored as "\t"
1091        let opts = CsvReadOptions {
1092            delimiter: b'\t',
1093            has_headers: true,
1094        };
1095        let table = load_csv_from_reader_with_opts(
1096            Cursor::new("a\tb\n1\t2\n"),
1097            "t",
1098            "test",
1099            "memory",
1100            &opts,
1101        )
1102        .unwrap();
1103        assert_eq!(
1104            table.metadata.get("delimiter").map(String::as_str),
1105            Some("\\t")
1106        );
1107    }
1108
1109    #[test]
1110    fn test_parse_delimiter_arg_accepts_single_char() {
1111        assert_eq!(parse_delimiter_arg(",").unwrap(), b',');
1112        assert_eq!(parse_delimiter_arg("|").unwrap(), b'|');
1113        assert_eq!(parse_delimiter_arg(";").unwrap(), b';');
1114    }
1115
1116    #[test]
1117    fn test_parse_delimiter_arg_accepts_backslash_escapes() {
1118        assert_eq!(parse_delimiter_arg("\\t").unwrap(), b'\t');
1119        assert_eq!(parse_delimiter_arg("\t").unwrap(), b'\t');
1120        assert_eq!(parse_delimiter_arg("\\n").unwrap(), b'\n');
1121        assert_eq!(parse_delimiter_arg("\\r").unwrap(), b'\r');
1122    }
1123
1124    #[test]
1125    fn test_parse_delimiter_arg_rejects_multi_char() {
1126        let err = parse_delimiter_arg("||").unwrap_err();
1127        assert!(err.to_string().contains("single ASCII character"));
1128    }
1129
1130    #[test]
1131    fn test_parse_delimiter_arg_rejects_non_ascii() {
1132        let err = parse_delimiter_arg("ö").unwrap_err();
1133        assert!(err.to_string().contains("single ASCII character"));
1134    }
1135
1136    #[test]
1137    fn test_resolve_delimiter_explicit_wins() {
1138        assert_eq!(resolve_delimiter("data.psv", Some(b',')), b',');
1139        assert_eq!(resolve_delimiter("data.tsv", Some(b';')), b';');
1140        assert_eq!(resolve_delimiter("data.csv", Some(b'|')), b'|');
1141    }
1142
1143    #[test]
1144    fn test_resolve_delimiter_falls_back_to_extension() {
1145        assert_eq!(resolve_delimiter("data.psv", None), b'|');
1146        assert_eq!(resolve_delimiter("data.tsv", None), b'\t');
1147        assert_eq!(resolve_delimiter("data.csv", None), b',');
1148        assert_eq!(resolve_delimiter("data.dat", None), b',');
1149    }
1150
1151    #[test]
1152    fn test_null_detection_works_with_pipe_delimiter() {
1153        // Middle column is unquoted-empty -> NULL, not empty string.
1154        let data = "id|name|score\n1||10\n";
1155        let opts = CsvReadOptions {
1156            delimiter: b'|',
1157            has_headers: true,
1158        };
1159        let table =
1160            load_csv_from_reader_with_opts(Cursor::new(data), "psv", "test", "memory", &opts)
1161                .expect("load failed");
1162        assert!(matches!(table.get_value(0, 1).unwrap(), DataValue::Null));
1163    }
1164}