Skip to main content

scirs2_io/table_provider/
mod.rs

1//! DataFusion-compatible table provider interface.
2//!
3//! This module defines a standalone, zero-dependency table abstraction that mirrors
4//! the Apache DataFusion `TableProvider` trait without pulling in the `datafusion`
5//! or `arrow` crates.
6//!
7//! # Overview
8//!
9//! - `Schema` / `Field` / `DataType` — schema definitions
10//! - `ColumnValues` / `ColumnBatch` / `RecordBatch` — in-memory columnar batches
11//! - `TableProvider` trait — uniform scan interface
12//! - `InMemoryTable` — simple in-memory implementation
13//! - `CsvTableProvider` — reads a CSV file and exposes it as a table
14//! - `filter_batch` / `project_batch` — predicate pushdown and column projection helpers
15
16use std::fs;
17
18use crate::error::IoError;
19
20// ─── Schema types ─────────────────────────────────────────────────────────────
21
22/// Scalar or nested data type for a table column.
23#[non_exhaustive]
24#[derive(Debug, Clone, PartialEq)]
25pub enum DataType {
26    /// 32-bit signed integer.
27    Int32,
28    /// 64-bit signed integer.
29    Int64,
30    /// 32-bit IEEE 754 floating-point number.
31    Float32,
32    /// 64-bit IEEE 754 floating-point number.
33    Float64,
34    /// UTF-8 encoded string.
35    Utf8,
36    /// Boolean (true / false).
37    Boolean,
38    /// Opaque binary data.
39    Binary,
40    /// Variable-length list of elements of the given type.
41    List(Box<DataType>),
42    /// Struct composed of named fields.
43    Struct(Vec<Field>),
44}
45
46/// A named, typed column descriptor.
47#[derive(Debug, Clone, PartialEq)]
48pub struct Field {
49    /// Column name.
50    pub name: String,
51    /// Column data type.
52    pub data_type: DataType,
53    /// Whether the column may contain `NULL` values.
54    pub nullable: bool,
55}
56
57impl Field {
58    /// Create a new field with the given name, type, and nullability.
59    pub fn new(name: impl Into<String>, data_type: DataType, nullable: bool) -> Self {
60        Self {
61            name: name.into(),
62            data_type,
63            nullable,
64        }
65    }
66}
67
68/// An ordered collection of `Field` descriptors forming a table schema.
69#[derive(Debug, Clone, PartialEq)]
70pub struct Schema {
71    /// Column descriptors, in order.
72    pub fields: Vec<Field>,
73}
74
75impl Schema {
76    /// Create a new schema from a list of fields.
77    pub fn new(fields: Vec<Field>) -> Self {
78        Self { fields }
79    }
80
81    /// Find a field by name (case-sensitive linear scan).
82    pub fn find_field(&self, name: &str) -> Option<&Field> {
83        self.fields.iter().find(|f| f.name == name)
84    }
85
86    /// Return the number of columns in this schema.
87    pub fn n_fields(&self) -> usize {
88        self.fields.len()
89    }
90}
91
92// ─── In-memory column data ────────────────────────────────────────────────────
93
94/// Typed, nullable column values for one column in a `RecordBatch`.
95#[derive(Debug, Clone)]
96pub enum ColumnValues {
97    /// 32-bit signed integers.
98    Int32s(Vec<Option<i32>>),
99    /// 64-bit signed integers.
100    Int64s(Vec<Option<i64>>),
101    /// 32-bit floats.
102    Float32s(Vec<Option<f32>>),
103    /// 64-bit floats.
104    Float64s(Vec<Option<f64>>),
105    /// UTF-8 strings.
106    Utf8s(Vec<Option<String>>),
107    /// Booleans.
108    Booleans(Vec<Option<bool>>),
109}
110
111impl ColumnValues {
112    /// Return the number of rows represented by this column.
113    pub fn len(&self) -> usize {
114        match self {
115            ColumnValues::Int32s(v) => v.len(),
116            ColumnValues::Int64s(v) => v.len(),
117            ColumnValues::Float32s(v) => v.len(),
118            ColumnValues::Float64s(v) => v.len(),
119            ColumnValues::Utf8s(v) => v.len(),
120            ColumnValues::Booleans(v) => v.len(),
121        }
122    }
123
124    /// Return true if this column has no rows.
125    pub fn is_empty(&self) -> bool {
126        self.len() == 0
127    }
128
129    /// Slice row range `[offset, offset + length)`.
130    pub fn slice(&self, offset: usize, length: usize) -> ColumnValues {
131        let end = (offset + length).min(self.len());
132        match self {
133            ColumnValues::Int32s(v) => ColumnValues::Int32s(v[offset..end].to_vec()),
134            ColumnValues::Int64s(v) => ColumnValues::Int64s(v[offset..end].to_vec()),
135            ColumnValues::Float32s(v) => ColumnValues::Float32s(v[offset..end].to_vec()),
136            ColumnValues::Float64s(v) => ColumnValues::Float64s(v[offset..end].to_vec()),
137            ColumnValues::Utf8s(v) => ColumnValues::Utf8s(v[offset..end].to_vec()),
138            ColumnValues::Booleans(v) => ColumnValues::Booleans(v[offset..end].to_vec()),
139        }
140    }
141
142    /// Filter rows by a boolean mask.
143    pub fn filter_by_mask(&self, mask: &[bool]) -> ColumnValues {
144        match self {
145            ColumnValues::Int32s(v) => ColumnValues::Int32s(
146                v.iter()
147                    .zip(mask)
148                    .filter_map(|(val, &m)| if m { Some(val.clone()) } else { None })
149                    .collect(),
150            ),
151            ColumnValues::Int64s(v) => ColumnValues::Int64s(
152                v.iter()
153                    .zip(mask)
154                    .filter_map(|(val, &m)| if m { Some(val.clone()) } else { None })
155                    .collect(),
156            ),
157            ColumnValues::Float32s(v) => ColumnValues::Float32s(
158                v.iter()
159                    .zip(mask)
160                    .filter_map(|(val, &m)| if m { Some(*val) } else { None })
161                    .collect(),
162            ),
163            ColumnValues::Float64s(v) => ColumnValues::Float64s(
164                v.iter()
165                    .zip(mask)
166                    .filter_map(|(val, &m)| if m { Some(*val) } else { None })
167                    .collect(),
168            ),
169            ColumnValues::Utf8s(v) => ColumnValues::Utf8s(
170                v.iter()
171                    .zip(mask)
172                    .filter_map(|(val, &m)| if m { Some(val.clone()) } else { None })
173                    .collect(),
174            ),
175            ColumnValues::Booleans(v) => ColumnValues::Booleans(
176                v.iter()
177                    .zip(mask)
178                    .filter_map(|(val, &m)| if m { Some(*val) } else { None })
179                    .collect(),
180            ),
181        }
182    }
183}
184
185/// A named column batch: column name + typed values.
186#[derive(Debug, Clone)]
187pub struct ColumnBatch {
188    /// Column name matching the schema field.
189    pub name: String,
190    /// Declared data type.
191    pub data_type: DataType,
192    /// The actual values.
193    pub values: ColumnValues,
194}
195
196/// An in-memory columnar batch: a schema plus one `ColumnBatch` per field.
197#[derive(Debug, Clone)]
198pub struct RecordBatch {
199    /// Schema of this batch.
200    pub schema: Schema,
201    /// Column data, one per schema field, in schema order.
202    pub columns: Vec<ColumnBatch>,
203    /// Number of rows.
204    pub n_rows: usize,
205}
206
207impl RecordBatch {
208    /// Create a new `RecordBatch`, validating that all columns have consistent row counts.
209    pub fn new(schema: Schema, columns: Vec<ColumnBatch>) -> Result<Self, IoError> {
210        // Validate column count.
211        if columns.len() != schema.n_fields() {
212            return Err(IoError::ValidationError(format!(
213                "schema has {} fields but {} columns were provided",
214                schema.n_fields(),
215                columns.len()
216            )));
217        }
218
219        // Validate row count consistency.
220        let n_rows = if columns.is_empty() {
221            0
222        } else {
223            columns[0].values.len()
224        };
225        for col in &columns {
226            if col.values.len() != n_rows {
227                return Err(IoError::ValidationError(format!(
228                    "column '{}' has {} rows but expected {n_rows}",
229                    col.name,
230                    col.values.len()
231                )));
232            }
233        }
234
235        Ok(Self {
236            schema,
237            columns,
238            n_rows,
239        })
240    }
241
242    /// Look up a column by name.
243    pub fn column(&self, name: &str) -> Option<&ColumnBatch> {
244        self.columns.iter().find(|c| c.name == name)
245    }
246
247    /// Return a sub-batch with rows `[offset, offset + length)`.
248    pub fn slice(&self, offset: usize, length: usize) -> Result<RecordBatch, IoError> {
249        if offset > self.n_rows {
250            return Err(IoError::ValidationError(format!(
251                "slice offset {offset} exceeds n_rows {}",
252                self.n_rows
253            )));
254        }
255        let actual_len = length.min(self.n_rows.saturating_sub(offset));
256        let new_columns: Vec<ColumnBatch> = self
257            .columns
258            .iter()
259            .map(|c| ColumnBatch {
260                name: c.name.clone(),
261                data_type: c.data_type.clone(),
262                values: c.values.slice(offset, actual_len),
263            })
264            .collect();
265
266        Ok(RecordBatch {
267            schema: self.schema.clone(),
268            columns: new_columns,
269            n_rows: actual_len,
270        })
271    }
272}
273
274// ─── TableProvider trait ──────────────────────────────────────────────────────
275
276/// A uniform table scan interface compatible with Apache DataFusion's `TableProvider`.
277///
278/// Implementors may expose any data source (in-memory, CSV, Parquet, Delta Lake…)
279/// through this interface.
280pub trait TableProvider: Send + Sync {
281    /// Return the table's schema.
282    fn schema(&self) -> &Schema;
283
284    /// Produce an iterator of `RecordBatch`es.
285    ///
286    /// `projection` is an optional slice of **column indices** to include.
287    /// `limit` is an optional upper bound on the total number of rows to return.
288    fn scan(
289        &self,
290        projection: Option<&[usize]>,
291        limit: Option<usize>,
292    ) -> Result<Box<dyn Iterator<Item = Result<RecordBatch, IoError>>>, IoError>;
293
294    /// Optional row-count estimate for query planning.
295    fn n_rows_estimate(&self) -> Option<usize> {
296        None
297    }
298}
299
300// ─── InMemoryTable ────────────────────────────────────────────────────────────
301
302/// A simple `TableProvider` backed by an in-memory list of `RecordBatch`es.
303pub struct InMemoryTable {
304    schema: Schema,
305    batches: Vec<RecordBatch>,
306}
307
308impl InMemoryTable {
309    /// Create a new in-memory table with the given schema and batches.
310    pub fn new(schema: Schema, batches: Vec<RecordBatch>) -> Self {
311        Self { schema, batches }
312    }
313
314    /// Return the total number of rows across all batches.
315    pub fn total_rows(&self) -> usize {
316        self.batches.iter().map(|b| b.n_rows).sum()
317    }
318}
319
320impl TableProvider for InMemoryTable {
321    fn schema(&self) -> &Schema {
322        &self.schema
323    }
324
325    fn scan(
326        &self,
327        projection: Option<&[usize]>,
328        limit: Option<usize>,
329    ) -> Result<Box<dyn Iterator<Item = Result<RecordBatch, IoError>>>, IoError> {
330        let proj_indices: Option<Vec<usize>> = projection.map(|p| p.to_vec());
331        let mut batches: Vec<Result<RecordBatch, IoError>> = Vec::new();
332        let mut rows_remaining = limit;
333
334        for batch in &self.batches {
335            // Apply limit.
336            let batch = if let Some(rem) = rows_remaining {
337                if rem == 0 {
338                    break;
339                }
340                let sliced = batch.slice(0, rem)?;
341                rows_remaining = Some(rem.saturating_sub(sliced.n_rows));
342                sliced
343            } else {
344                batch.clone()
345            };
346
347            // Apply projection.
348            let batch = if let Some(ref indices) = proj_indices {
349                project_batch_by_indices(&batch, indices)?
350            } else {
351                batch
352            };
353
354            batches.push(Ok(batch));
355        }
356
357        Ok(Box::new(batches.into_iter()))
358    }
359
360    fn n_rows_estimate(&self) -> Option<usize> {
361        Some(self.total_rows())
362    }
363}
364
365// ─── CsvTableProvider ─────────────────────────────────────────────────────────
366
367/// A `TableProvider` that reads a CSV file and serves it as a single `RecordBatch`.
368///
369/// Schema inference rule: all columns are inferred as `Utf8`; column names come
370/// from the first row when `has_header = true`, otherwise `col_0`, `col_1`, …
371pub struct CsvTableProvider {
372    schema: Schema,
373    batch: RecordBatch,
374}
375
376impl CsvTableProvider {
377    /// Open a CSV file, parse it, and infer the schema.
378    pub fn open(path: &str, has_header: bool) -> Result<Self, IoError> {
379        let content = fs::read_to_string(path)
380            .map_err(|e| IoError::FileNotFound(format!("cannot read CSV file {path}: {e}")))?;
381
382        let mut lines = content.lines();
383        let first_line = match lines.next() {
384            Some(l) => l,
385            None => {
386                // Empty file — return empty schema/batch.
387                let schema = Schema::new(vec![]);
388                let batch = RecordBatch {
389                    schema: schema.clone(),
390                    columns: vec![],
391                    n_rows: 0,
392                };
393                return Ok(Self { schema, batch });
394            }
395        };
396
397        let header_fields: Vec<String> = split_csv_line(first_line);
398
399        // Build schema: all columns are Utf8 (inference can be extended later).
400        let column_names: Vec<String> = if has_header {
401            header_fields.clone()
402        } else {
403            (0..header_fields.len())
404                .map(|i| format!("col_{i}"))
405                .collect()
406        };
407
408        let fields: Vec<Field> = column_names
409            .iter()
410            .map(|name| Field::new(name.clone(), DataType::Utf8, true))
411            .collect();
412        let schema = Schema::new(fields);
413
414        // Parse rows.
415        let mut row_data: Vec<Vec<Option<String>>> = Vec::new();
416
417        // If no header, the first line is data.
418        if !has_header {
419            let row: Vec<Option<String>> = header_fields
420                .into_iter()
421                .map(|v| if v.is_empty() { None } else { Some(v) })
422                .collect();
423            row_data.push(row);
424        }
425
426        for line in lines {
427            if line.trim().is_empty() {
428                continue;
429            }
430            let cols = split_csv_line(line);
431            let row: Vec<Option<String>> = cols
432                .into_iter()
433                .map(|v| if v.is_empty() { None } else { Some(v) })
434                .collect();
435            row_data.push(row);
436        }
437
438        let n_cols = schema.n_fields();
439        let n_rows = row_data.len();
440
441        // Build per-column vectors.
442        let mut col_vecs: Vec<Vec<Option<String>>> = vec![Vec::with_capacity(n_rows); n_cols];
443        for row in &row_data {
444            for (ci, cell) in row.iter().enumerate() {
445                if ci < n_cols {
446                    col_vecs[ci].push(cell.clone());
447                }
448            }
449            // Pad missing columns.
450            for ci in row.len()..n_cols {
451                col_vecs[ci].push(None);
452            }
453        }
454
455        let columns: Vec<ColumnBatch> = schema
456            .fields
457            .iter()
458            .zip(col_vecs)
459            .map(|(field, vals)| ColumnBatch {
460                name: field.name.clone(),
461                data_type: DataType::Utf8,
462                values: ColumnValues::Utf8s(vals),
463            })
464            .collect();
465
466        let batch = RecordBatch::new(schema.clone(), columns)?;
467        Ok(Self { schema, batch })
468    }
469}
470
471impl TableProvider for CsvTableProvider {
472    fn schema(&self) -> &Schema {
473        &self.schema
474    }
475
476    fn scan(
477        &self,
478        projection: Option<&[usize]>,
479        limit: Option<usize>,
480    ) -> Result<Box<dyn Iterator<Item = Result<RecordBatch, IoError>>>, IoError> {
481        let batch = if let Some(lim) = limit {
482            self.batch.slice(0, lim)?
483        } else {
484            self.batch.clone()
485        };
486
487        let batch = if let Some(proj) = projection {
488            project_batch_by_indices(&batch, proj)?
489        } else {
490            batch
491        };
492
493        Ok(Box::new(std::iter::once(Ok(batch))))
494    }
495
496    fn n_rows_estimate(&self) -> Option<usize> {
497        Some(self.batch.n_rows)
498    }
499}
500
501// ─── Batch helpers ────────────────────────────────────────────────────────────
502
503/// Apply a row-level predicate to a `RecordBatch`, returning only matching rows.
504///
505/// `predicate(values, row_index)` should return `true` to keep the row.
506pub fn filter_batch(
507    batch: &RecordBatch,
508    column: &str,
509    predicate: impl Fn(&ColumnValues, usize) -> bool,
510) -> Result<RecordBatch, IoError> {
511    let col = batch
512        .column(column)
513        .ok_or_else(|| IoError::NotFound(format!("column '{column}' not found in batch")))?;
514
515    let mask: Vec<bool> = (0..batch.n_rows)
516        .map(|i| predicate(&col.values, i))
517        .collect();
518
519    let n_rows = mask.iter().filter(|&&m| m).count();
520
521    let new_columns: Vec<ColumnBatch> = batch
522        .columns
523        .iter()
524        .map(|c| ColumnBatch {
525            name: c.name.clone(),
526            data_type: c.data_type.clone(),
527            values: c.values.filter_by_mask(&mask),
528        })
529        .collect();
530
531    Ok(RecordBatch {
532        schema: batch.schema.clone(),
533        columns: new_columns,
534        n_rows,
535    })
536}
537
538/// Project a `RecordBatch` to the named columns, in the order specified.
539pub fn project_batch(batch: &RecordBatch, columns: &[&str]) -> Result<RecordBatch, IoError> {
540    let mut new_fields = Vec::new();
541    let mut new_columns = Vec::new();
542
543    for &name in columns {
544        let field = batch
545            .schema
546            .find_field(name)
547            .ok_or_else(|| IoError::NotFound(format!("projection: column '{name}' not found")))?;
548        let col = batch.column(name).ok_or_else(|| {
549            IoError::NotFound(format!("projection: column data '{name}' not found"))
550        })?;
551        new_fields.push(field.clone());
552        new_columns.push(col.clone());
553    }
554
555    let new_schema = Schema::new(new_fields);
556    Ok(RecordBatch {
557        schema: new_schema,
558        columns: new_columns,
559        n_rows: batch.n_rows,
560    })
561}
562
563/// Project a `RecordBatch` to the columns at the given **indices** (0-based).
564pub fn project_batch_by_indices(
565    batch: &RecordBatch,
566    indices: &[usize],
567) -> Result<RecordBatch, IoError> {
568    let mut new_fields = Vec::new();
569    let mut new_columns = Vec::new();
570
571    for &idx in indices {
572        let field = batch.schema.fields.get(idx).ok_or_else(|| {
573            IoError::ValidationError(format!(
574                "projection index {idx} out of range (schema has {} fields)",
575                batch.schema.n_fields()
576            ))
577        })?;
578        let col = batch
579            .columns
580            .get(idx)
581            .ok_or_else(|| IoError::ValidationError(format!("column index {idx} out of range")))?;
582        new_fields.push(field.clone());
583        new_columns.push(col.clone());
584    }
585
586    let new_schema = Schema::new(new_fields);
587    Ok(RecordBatch {
588        schema: new_schema,
589        columns: new_columns,
590        n_rows: batch.n_rows,
591    })
592}
593
594// ─── CSV parsing helper ───────────────────────────────────────────────────────
595
596/// Split a CSV line on commas, stripping optional double-quote wrapping.
597fn split_csv_line(line: &str) -> Vec<String> {
598    let mut fields = Vec::new();
599    let mut current = String::new();
600    let mut in_quotes = false;
601    let mut chars = line.chars().peekable();
602
603    while let Some(ch) = chars.next() {
604        match ch {
605            '"' if !in_quotes => {
606                in_quotes = true;
607            }
608            '"' if in_quotes => {
609                // Escaped double quote?
610                if chars.peek() == Some(&'"') {
611                    current.push('"');
612                    chars.next();
613                } else {
614                    in_quotes = false;
615                }
616            }
617            ',' if !in_quotes => {
618                fields.push(current.trim().to_string());
619                current = String::new();
620            }
621            other => current.push(other),
622        }
623    }
624    fields.push(current.trim().to_string());
625    fields
626}
627
628// ─── Tests ───────────────────────────────────────────────────────────────────
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use std::io::Write;
634
635    fn make_schema() -> Schema {
636        Schema::new(vec![
637            Field::new("id", DataType::Int32, false),
638            Field::new("name", DataType::Utf8, true),
639            Field::new("score", DataType::Float64, true),
640        ])
641    }
642
643    fn make_batch(schema: &Schema, n: usize) -> RecordBatch {
644        let ids: Vec<Option<i32>> = (0..n as i32).map(Some).collect();
645        let names: Vec<Option<String>> = (0..n).map(|i| Some(format!("user_{i}"))).collect();
646        let scores: Vec<Option<f64>> = (0..n).map(|i| Some(i as f64 * 1.5)).collect();
647
648        RecordBatch::new(
649            schema.clone(),
650            vec![
651                ColumnBatch {
652                    name: "id".to_string(),
653                    data_type: DataType::Int32,
654                    values: ColumnValues::Int32s(ids),
655                },
656                ColumnBatch {
657                    name: "name".to_string(),
658                    data_type: DataType::Utf8,
659                    values: ColumnValues::Utf8s(names),
660                },
661                ColumnBatch {
662                    name: "score".to_string(),
663                    data_type: DataType::Float64,
664                    values: ColumnValues::Float64s(scores),
665                },
666            ],
667        )
668        .expect("make_batch")
669    }
670
671    // ── Schema ────────────────────────────────────────────────────────────────
672
673    #[test]
674    fn test_schema_find_field() {
675        let schema = make_schema();
676        assert!(schema.find_field("id").is_some());
677        assert!(schema.find_field("name").is_some());
678        assert!(schema.find_field("missing").is_none());
679        assert_eq!(schema.n_fields(), 3);
680    }
681
682    // ── RecordBatch ───────────────────────────────────────────────────────────
683
684    #[test]
685    fn test_record_batch_column_count() {
686        let schema = make_schema();
687        let batch = make_batch(&schema, 10);
688        assert_eq!(batch.n_rows, 10);
689        assert!(batch.column("id").is_some());
690        assert!(batch.column("score").is_some());
691        assert!(batch.column("missing").is_none());
692    }
693
694    #[test]
695    fn test_record_batch_slice() {
696        let schema = make_schema();
697        let batch = make_batch(&schema, 10);
698        let sliced = batch.slice(2, 3).expect("slice");
699        assert_eq!(sliced.n_rows, 3);
700        if let ColumnValues::Int32s(ref ids) = sliced.column("id").unwrap().values {
701            assert_eq!(ids[0], Some(2));
702            assert_eq!(ids[2], Some(4));
703        } else {
704            panic!("expected Int32s");
705        }
706    }
707
708    #[test]
709    fn test_record_batch_slice_beyond_end() {
710        let schema = make_schema();
711        let batch = make_batch(&schema, 5);
712        let sliced = batch.slice(3, 100).expect("slice beyond end");
713        assert_eq!(sliced.n_rows, 2);
714    }
715
716    #[test]
717    fn test_record_batch_new_column_count_mismatch() {
718        let schema = make_schema();
719        // Provide only 2 columns for a 3-field schema.
720        let result = RecordBatch::new(
721            schema,
722            vec![
723                ColumnBatch {
724                    name: "id".to_string(),
725                    data_type: DataType::Int32,
726                    values: ColumnValues::Int32s(vec![Some(1)]),
727                },
728                ColumnBatch {
729                    name: "name".to_string(),
730                    data_type: DataType::Utf8,
731                    values: ColumnValues::Utf8s(vec![Some("a".to_string())]),
732                },
733            ],
734        );
735        assert!(result.is_err());
736    }
737
738    // ── InMemoryTable ─────────────────────────────────────────────────────────
739
740    #[test]
741    fn test_inmemory_table_scan_all() {
742        let schema = make_schema();
743        let batch1 = make_batch(&schema, 5);
744        let batch2 = make_batch(&schema, 3);
745        let table = InMemoryTable::new(schema, vec![batch1, batch2]);
746
747        let batches: Vec<RecordBatch> = table
748            .scan(None, None)
749            .expect("scan")
750            .map(|r| r.expect("batch"))
751            .collect();
752        assert_eq!(batches.len(), 2);
753        assert_eq!(batches[0].n_rows, 5);
754        assert_eq!(batches[1].n_rows, 3);
755        assert_eq!(table.n_rows_estimate(), Some(8));
756    }
757
758    #[test]
759    fn test_inmemory_table_scan_limit() {
760        let schema = make_schema();
761        let batch = make_batch(&schema, 10);
762        let table = InMemoryTable::new(schema, vec![batch]);
763
764        let batches: Vec<RecordBatch> = table
765            .scan(None, Some(4))
766            .expect("scan")
767            .map(|r| r.expect("batch"))
768            .collect();
769        let total: usize = batches.iter().map(|b| b.n_rows).sum();
770        assert_eq!(total, 4);
771    }
772
773    #[test]
774    fn test_inmemory_table_projection() {
775        let schema = make_schema();
776        let batch = make_batch(&schema, 5);
777        let table = InMemoryTable::new(schema, vec![batch]);
778
779        // Project only columns 0 and 2 (id and score).
780        let batches: Vec<RecordBatch> = table
781            .scan(Some(&[0, 2]), None)
782            .expect("scan")
783            .map(|r| r.expect("batch"))
784            .collect();
785        assert_eq!(batches[0].schema.n_fields(), 2);
786        assert!(batches[0].column("id").is_some());
787        assert!(batches[0].column("score").is_some());
788        assert!(batches[0].column("name").is_none());
789    }
790
791    // ── project_batch ─────────────────────────────────────────────────────────
792
793    #[test]
794    fn test_project_batch_by_name() {
795        let schema = make_schema();
796        let batch = make_batch(&schema, 4);
797        let projected = project_batch(&batch, &["id", "score"]).expect("project");
798        assert_eq!(projected.schema.n_fields(), 2);
799        assert_eq!(projected.n_rows, 4);
800    }
801
802    #[test]
803    fn test_project_batch_missing_column() {
804        let schema = make_schema();
805        let batch = make_batch(&schema, 4);
806        let result = project_batch(&batch, &["nonexistent"]);
807        assert!(result.is_err());
808    }
809
810    // ── filter_batch ──────────────────────────────────────────────────────────
811
812    #[test]
813    fn test_filter_batch_int32() {
814        let schema = make_schema();
815        let batch = make_batch(&schema, 10);
816
817        // Keep rows where id >= 5.
818        let filtered = filter_batch(&batch, "id", |vals, row| {
819            if let ColumnValues::Int32s(ref v) = vals {
820                v[row].map_or(false, |id| id >= 5)
821            } else {
822                false
823            }
824        })
825        .expect("filter");
826
827        assert_eq!(filtered.n_rows, 5);
828    }
829
830    #[test]
831    fn test_filter_batch_missing_column() {
832        let schema = make_schema();
833        let batch = make_batch(&schema, 5);
834        let result = filter_batch(&batch, "nonexistent", |_, _| true);
835        assert!(result.is_err());
836    }
837
838    // ── CsvTableProvider ──────────────────────────────────────────────────────
839
840    fn write_temp_csv(content: &str) -> tempfile::NamedTempFile {
841        let mut f = tempfile::NamedTempFile::new().expect("tempfile");
842        f.write_all(content.as_bytes()).expect("write csv");
843        f
844    }
845
846    #[test]
847    fn test_csv_provider_with_header() {
848        let csv = "id,name,value\n1,alice,10.5\n2,bob,20.0\n3,carol,30.3\n";
849        let tmp = write_temp_csv(csv);
850        let provider =
851            CsvTableProvider::open(tmp.path().to_str().unwrap(), true).expect("open csv");
852
853        assert_eq!(provider.schema().n_fields(), 3);
854        assert!(provider.schema().find_field("id").is_some());
855        assert!(provider.schema().find_field("name").is_some());
856        assert!(provider.schema().find_field("value").is_some());
857
858        let batches: Vec<RecordBatch> = provider
859            .scan(None, None)
860            .expect("scan")
861            .map(|r| r.expect("batch"))
862            .collect();
863        assert_eq!(batches[0].n_rows, 3);
864    }
865
866    #[test]
867    fn test_csv_provider_without_header() {
868        let csv = "1,alice\n2,bob\n";
869        let tmp = write_temp_csv(csv);
870        let provider = CsvTableProvider::open(tmp.path().to_str().unwrap(), false)
871            .expect("open csv no-header");
872
873        assert_eq!(provider.schema().n_fields(), 2);
874        assert!(provider.schema().find_field("col_0").is_some());
875        assert!(provider.schema().find_field("col_1").is_some());
876
877        let batches: Vec<RecordBatch> = provider
878            .scan(None, None)
879            .expect("scan")
880            .map(|r| r.expect("batch"))
881            .collect();
882        assert_eq!(batches[0].n_rows, 2);
883    }
884
885    #[test]
886    fn test_csv_provider_limit() {
887        let csv = "a,b\n1,2\n3,4\n5,6\n7,8\n";
888        let tmp = write_temp_csv(csv);
889        let provider =
890            CsvTableProvider::open(tmp.path().to_str().unwrap(), true).expect("open csv");
891
892        let batches: Vec<RecordBatch> = provider
893            .scan(None, Some(2))
894            .expect("scan")
895            .map(|r| r.expect("batch"))
896            .collect();
897        let total: usize = batches.iter().map(|b| b.n_rows).sum();
898        assert_eq!(total, 2);
899    }
900
901    #[test]
902    fn test_csv_provider_projection() {
903        let csv = "x,y,z\n1,2,3\n4,5,6\n";
904        let tmp = write_temp_csv(csv);
905        let provider =
906            CsvTableProvider::open(tmp.path().to_str().unwrap(), true).expect("open csv");
907
908        // Project only column 0 (x) and column 2 (z).
909        let batches: Vec<RecordBatch> = provider
910            .scan(Some(&[0, 2]), None)
911            .expect("scan")
912            .map(|r| r.expect("batch"))
913            .collect();
914        assert_eq!(batches[0].schema.n_fields(), 2);
915        assert!(batches[0].column("x").is_some());
916        assert!(batches[0].column("z").is_some());
917        assert!(batches[0].column("y").is_none());
918    }
919
920    // ── split_csv_line ────────────────────────────────────────────────────────
921
922    #[test]
923    fn test_split_csv_quoted() {
924        let line = r#"hello,"world, with comma",42"#;
925        let fields = split_csv_line(line);
926        assert_eq!(fields, vec!["hello", "world, with comma", "42"]);
927    }
928
929    #[test]
930    fn test_split_csv_simple() {
931        let fields = split_csv_line("a,b,c");
932        assert_eq!(fields, vec!["a", "b", "c"]);
933    }
934}