Skip to main content

scirs2_io/
query.rs

1//! SQL-like query interface for data files.
2//!
3//! Provides a builder-pattern query engine that can operate over in-memory rows
4//! or CSV-backed data sources without any external SQL engine dependency.
5//!
6//! # Example
7//!
8//! ```rust
9//! use scirs2_io::query::{DataQuery, DataSource, ColumnValue};
10//!
11//! // Build rows manually
12//! let rows = vec![
13//!     vec![("name".to_string(), ColumnValue::Text("Alice".to_string())),
14//!          ("age".to_string(),  ColumnValue::Integer(30))],
15//!     vec![("name".to_string(), ColumnValue::Text("Bob".to_string())),
16//!          ("age".to_string(),  ColumnValue::Integer(25))],
17//! ];
18//! let source = DataSource::InMemoryRows(rows);
19//! let result = DataQuery::from(source)
20//!     .select(&["name", "age"])
21//!     .order_by("age", true)
22//!     .execute()
23//!     .expect("query failed");
24//!
25//! assert_eq!(result.n_rows, 2);
26//! ```
27
28use std::collections::HashMap;
29use std::io::{BufRead, BufReader};
30use std::path::Path;
31
32use crate::error::{IoError, Result};
33
34// ──────────────────────────────────────────────────────────────────────────────
35// Column value type
36// ──────────────────────────────────────────────────────────────────────────────
37
38/// A single typed cell value within a [`Row`].
39#[derive(Debug, Clone, PartialEq)]
40pub enum ColumnValue {
41    /// Null / missing value.
42    Null,
43    /// 64-bit signed integer.
44    Integer(i64),
45    /// 64-bit floating-point number.
46    Float(f64),
47    /// Boolean.
48    Boolean(bool),
49    /// UTF-8 text string.
50    Text(String),
51}
52
53impl ColumnValue {
54    /// Return a best-effort `f64` representation (for aggregations).
55    pub fn as_f64(&self) -> Option<f64> {
56        match self {
57            ColumnValue::Integer(i) => Some(*i as f64),
58            ColumnValue::Float(f) => Some(*f),
59            ColumnValue::Boolean(b) => Some(if *b { 1.0 } else { 0.0 }),
60            _ => None,
61        }
62    }
63
64    /// Return a sortable string key representation.
65    fn sort_key(&self) -> String {
66        match self {
67            ColumnValue::Null => "\x00".to_string(),
68            ColumnValue::Integer(i) => format!("{:020}", i),
69            ColumnValue::Float(f) => format!("{:030.15}", f),
70            ColumnValue::Boolean(b) => if *b { "1" } else { "0" }.to_string(),
71            ColumnValue::Text(s) => s.clone(),
72        }
73    }
74}
75
76impl std::fmt::Display for ColumnValue {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            ColumnValue::Null => write!(f, "NULL"),
80            ColumnValue::Integer(i) => write!(f, "{}", i),
81            ColumnValue::Float(v) => write!(f, "{}", v),
82            ColumnValue::Boolean(b) => write!(f, "{}", b),
83            ColumnValue::Text(s) => write!(f, "{}", s),
84        }
85    }
86}
87
88// ──────────────────────────────────────────────────────────────────────────────
89// Row
90// ──────────────────────────────────────────────────────────────────────────────
91
92/// A single data row with named columns.
93///
94/// Columns are stored in insertion order; lookup by name performs a linear scan
95/// which is fast for the typical column counts in tabular data.
96#[derive(Debug, Clone)]
97pub struct Row {
98    /// Column names in order.
99    pub columns: Vec<String>,
100    /// Values aligned with `columns`.
101    pub values: Vec<ColumnValue>,
102}
103
104impl Row {
105    /// Construct a row from a `Vec<(name, value)>` list.
106    pub fn from_pairs(pairs: Vec<(String, ColumnValue)>) -> Self {
107        let mut columns = Vec::with_capacity(pairs.len());
108        let mut values = Vec::with_capacity(pairs.len());
109        for (k, v) in pairs {
110            columns.push(k);
111            values.push(v);
112        }
113        Row { columns, values }
114    }
115
116    /// Look up a value by column name. Returns `None` when the column is absent.
117    pub fn get(&self, column: &str) -> Option<&ColumnValue> {
118        self.columns
119            .iter()
120            .position(|c| c == column)
121            .map(|idx| &self.values[idx])
122    }
123
124    /// Returns `true` when the row contains no columns.
125    pub fn is_empty(&self) -> bool {
126        self.columns.is_empty()
127    }
128
129    /// Number of columns in this row.
130    pub fn len(&self) -> usize {
131        self.columns.len()
132    }
133
134    /// Return an iterator over `(column_name, value)` pairs.
135    pub fn iter(&self) -> impl Iterator<Item = (&str, &ColumnValue)> {
136        self.columns
137            .iter()
138            .map(|s| s.as_str())
139            .zip(self.values.iter())
140    }
141
142    /// Project to a subset of columns (in the given order).  Missing columns
143    /// produce `ColumnValue::Null`.
144    fn project(&self, cols: &[String]) -> Row {
145        let values = cols
146            .iter()
147            .map(|c| self.get(c).cloned().unwrap_or(ColumnValue::Null))
148            .collect();
149        Row {
150            columns: cols.to_vec(),
151            values,
152        }
153    }
154}
155
156// ──────────────────────────────────────────────────────────────────────────────
157// Data source
158// ──────────────────────────────────────────────────────────────────────────────
159
160/// Backing data for a [`DataQuery`].
161pub enum DataSource {
162    /// A CSV file path.  The first line is treated as the header row.
163    CsvFile(String),
164    /// Rows already loaded into memory as `(column_name, value)` pairs.
165    InMemoryRows(Vec<Vec<(String, ColumnValue)>>),
166    /// An already-constructed `Vec<Row>`.
167    Rows(Vec<Row>),
168}
169
170// ──────────────────────────────────────────────────────────────────────────────
171// Query result
172// ──────────────────────────────────────────────────────────────────────────────
173
174/// The result of executing a [`DataQuery`].
175#[derive(Debug, Clone)]
176pub struct QueryResult {
177    /// All matching rows after projection, filtering, ordering and limiting.
178    pub rows: Vec<Row>,
179    /// Column names present in the result rows (in projection order).
180    pub columns: Vec<String>,
181    /// Total number of result rows (equals `rows.len()`).
182    pub n_rows: usize,
183}
184
185impl QueryResult {
186    /// Iterate over the rows.
187    pub fn iter(&self) -> impl Iterator<Item = &Row> {
188        self.rows.iter()
189    }
190}
191
192// ──────────────────────────────────────────────────────────────────────────────
193// Group-by result
194// ──────────────────────────────────────────────────────────────────────────────
195
196/// A set of rows grouped by the value of one column.
197#[derive(Debug, Clone)]
198pub struct GroupedResult {
199    /// Group key → list of rows in that group.
200    pub groups: HashMap<String, Vec<Row>>,
201    /// The column that was used as the grouping key.
202    pub group_column: String,
203}
204
205impl GroupedResult {
206    /// Count the number of rows in each group.
207    pub fn count(&self) -> HashMap<String, usize> {
208        self.groups
209            .iter()
210            .map(|(k, v)| (k.clone(), v.len()))
211            .collect()
212    }
213
214    /// Sum of a numeric column for each group.
215    pub fn sum(&self, column: &str) -> HashMap<String, f64> {
216        self.groups
217            .iter()
218            .map(|(k, rows)| {
219                let s = rows
220                    .iter()
221                    .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
222                    .sum();
223                (k.clone(), s)
224            })
225            .collect()
226    }
227
228    /// Arithmetic mean of a numeric column for each group.
229    pub fn mean(&self, column: &str) -> HashMap<String, f64> {
230        self.groups
231            .iter()
232            .map(|(k, rows)| {
233                let vals: Vec<f64> = rows
234                    .iter()
235                    .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
236                    .collect();
237                let mean = if vals.is_empty() {
238                    0.0
239                } else {
240                    vals.iter().sum::<f64>() / vals.len() as f64
241                };
242                (k.clone(), mean)
243            })
244            .collect()
245    }
246
247    /// Minimum value of a numeric column for each group.
248    pub fn min(&self, column: &str) -> HashMap<String, f64> {
249        self.groups
250            .iter()
251            .map(|(k, rows)| {
252                let min = rows
253                    .iter()
254                    .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
255                    .fold(f64::INFINITY, f64::min);
256                (k.clone(), min)
257            })
258            .collect()
259    }
260
261    /// Maximum value of a numeric column for each group.
262    pub fn max(&self, column: &str) -> HashMap<String, f64> {
263        self.groups
264            .iter()
265            .map(|(k, rows)| {
266                let max = rows
267                    .iter()
268                    .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
269                    .fold(f64::NEG_INFINITY, f64::max);
270                (k.clone(), max)
271            })
272            .collect()
273    }
274}
275
276// ──────────────────────────────────────────────────────────────────────────────
277// Aggregate helper (global, not grouped)
278// ──────────────────────────────────────────────────────────────────────────────
279
280/// Aggregation functions over all rows in a [`QueryResult`].
281pub struct Aggregations<'a> {
282    result: &'a QueryResult,
283}
284
285impl<'a> Aggregations<'a> {
286    /// Number of rows.
287    pub fn count(&self) -> usize {
288        self.result.n_rows
289    }
290
291    /// Sum of a numeric column across all rows.
292    pub fn sum(&self, column: &str) -> f64 {
293        self.result
294            .rows
295            .iter()
296            .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
297            .sum()
298    }
299
300    /// Arithmetic mean of a numeric column across all rows.
301    pub fn mean(&self, column: &str) -> f64 {
302        let vals: Vec<f64> = self
303            .result
304            .rows
305            .iter()
306            .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
307            .collect();
308        if vals.is_empty() {
309            0.0
310        } else {
311            vals.iter().sum::<f64>() / vals.len() as f64
312        }
313    }
314
315    /// Minimum of a numeric column across all rows.
316    pub fn min(&self, column: &str) -> f64 {
317        self.result
318            .rows
319            .iter()
320            .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
321            .fold(f64::INFINITY, f64::min)
322    }
323
324    /// Maximum of a numeric column across all rows.
325    pub fn max(&self, column: &str) -> f64 {
326        self.result
327            .rows
328            .iter()
329            .filter_map(|r| r.get(column).and_then(|v| v.as_f64()))
330            .fold(f64::NEG_INFINITY, f64::max)
331    }
332}
333
334impl QueryResult {
335    /// Return an [`Aggregations`] helper bound to this result.
336    pub fn agg(&self) -> Aggregations<'_> {
337        Aggregations { result: self }
338    }
339
340    /// Group rows by the string representation of the given column's values.
341    pub fn group_by(self, column: &str) -> GroupedResult {
342        let mut groups: HashMap<String, Vec<Row>> = HashMap::new();
343        let col_owned = column.to_string();
344        for row in self.rows {
345            let key = row
346                .get(column)
347                .map(|v| v.to_string())
348                .unwrap_or_else(|| "NULL".to_string());
349            groups.entry(key).or_default().push(row);
350        }
351        GroupedResult {
352            groups,
353            group_column: col_owned,
354        }
355    }
356}
357
358// ──────────────────────────────────────────────────────────────────────────────
359// DataQuery builder
360// ──────────────────────────────────────────────────────────────────────────────
361
362/// Order-by specification.
363struct OrderSpec {
364    column: String,
365    ascending: bool,
366}
367
368/// A lazy query builder.
369///
370/// Build the query using the fluent API, then call [`execute`] to materialise
371/// the results.
372///
373/// [`execute`]: DataQuery::execute
374pub struct DataQuery {
375    source: DataSource,
376    select_cols: Option<Vec<String>>,
377    predicates: Vec<Box<dyn Fn(&Row) -> bool + Send + Sync>>,
378    order: Option<OrderSpec>,
379    limit: Option<usize>,
380}
381
382impl DataQuery {
383    /// Create a query from a [`DataSource`].
384    pub fn from(source: DataSource) -> Self {
385        DataQuery {
386            source,
387            select_cols: None,
388            predicates: Vec::new(),
389            order: None,
390            limit: None,
391        }
392    }
393
394    /// Restrict the projected columns.  Pass an empty slice to select all.
395    pub fn select(mut self, columns: &[&str]) -> Self {
396        if !columns.is_empty() {
397            self.select_cols = Some(columns.iter().map(|s| s.to_string()).collect());
398        }
399        self
400    }
401
402    /// Add a row-level filter predicate.  Multiple calls are ANDed together.
403    pub fn filter(mut self, predicate: impl Fn(&Row) -> bool + Send + Sync + 'static) -> Self {
404        self.predicates.push(Box::new(predicate));
405        self
406    }
407
408    /// Limit the number of result rows.
409    pub fn limit(mut self, n: usize) -> Self {
410        self.limit = Some(n);
411        self
412    }
413
414    /// Sort the result by a column.  `ascending = true` → smallest first.
415    pub fn order_by(mut self, column: &str, ascending: bool) -> Self {
416        self.order = Some(OrderSpec {
417            column: column.to_string(),
418            ascending,
419        });
420        self
421    }
422
423    /// Execute the query and return the materialised result.
424    pub fn execute(self) -> Result<QueryResult> {
425        let DataQuery {
426            source,
427            select_cols,
428            predicates,
429            order,
430            limit,
431        } = self;
432
433        // 1. Load rows from the source
434        let mut rows = load_rows(source)?;
435
436        // 2. Apply predicates
437        rows.retain(|row| predicates.iter().all(|p| p(row)));
438
439        // 3. Sort
440        if let Some(ord) = order {
441            let col = ord.column.clone();
442            let asc = ord.ascending;
443            rows.sort_by(|a, b| {
444                let ka = a.get(&col).map(|v| v.sort_key()).unwrap_or_default();
445                let kb = b.get(&col).map(|v| v.sort_key()).unwrap_or_default();
446                if asc {
447                    ka.cmp(&kb)
448                } else {
449                    kb.cmp(&ka)
450                }
451            });
452        }
453
454        // 4. Limit
455        if let Some(n) = limit {
456            rows.truncate(n);
457        }
458
459        // 5. Projection
460        let columns: Vec<String> = match &select_cols {
461            Some(cols) => cols.clone(),
462            None => {
463                // Collect union of all column names preserving first-seen order
464                let mut seen = std::collections::LinkedList::new();
465                let mut seen_set = std::collections::HashSet::new();
466                for row in &rows {
467                    for col in &row.columns {
468                        if seen_set.insert(col.clone()) {
469                            seen.push_back(col.clone());
470                        }
471                    }
472                }
473                seen.into_iter().collect()
474            }
475        };
476
477        let projected: Vec<Row> = rows.iter().map(|r| r.project(&columns)).collect();
478        let n_rows = projected.len();
479
480        Ok(QueryResult {
481            rows: projected,
482            columns,
483            n_rows,
484        })
485    }
486}
487
488// ──────────────────────────────────────────────────────────────────────────────
489// Internal helpers
490// ──────────────────────────────────────────────────────────────────────────────
491
492/// Parse a CSV cell string into a [`ColumnValue`].
493fn parse_cell(s: &str) -> ColumnValue {
494    let trimmed = s.trim();
495    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") || trimmed == "NA" {
496        return ColumnValue::Null;
497    }
498    if let Ok(i) = trimmed.parse::<i64>() {
499        return ColumnValue::Integer(i);
500    }
501    if let Ok(f) = trimmed.parse::<f64>() {
502        return ColumnValue::Float(f);
503    }
504    if trimmed.eq_ignore_ascii_case("true") {
505        return ColumnValue::Boolean(true);
506    }
507    if trimmed.eq_ignore_ascii_case("false") {
508        return ColumnValue::Boolean(false);
509    }
510    ColumnValue::Text(trimmed.to_string())
511}
512
513/// Split a CSV line respecting double-quoted fields.
514fn split_csv_line(line: &str) -> Vec<String> {
515    let mut fields = Vec::new();
516    let mut cur = String::new();
517    let mut in_quotes = false;
518    let mut chars = line.chars().peekable();
519
520    while let Some(ch) = chars.next() {
521        match ch {
522            '"' => {
523                if in_quotes {
524                    // Check for escaped quote ("")
525                    if chars.peek() == Some(&'"') {
526                        chars.next();
527                        cur.push('"');
528                    } else {
529                        in_quotes = false;
530                    }
531                } else {
532                    in_quotes = true;
533                }
534            }
535            ',' if !in_quotes => {
536                fields.push(cur.clone());
537                cur.clear();
538            }
539            _ => cur.push(ch),
540        }
541    }
542    fields.push(cur);
543    fields
544}
545
546/// Load all rows from the given [`DataSource`].
547fn load_rows(source: DataSource) -> Result<Vec<Row>> {
548    match source {
549        DataSource::Rows(rows) => Ok(rows),
550
551        DataSource::InMemoryRows(pairs_list) => {
552            Ok(pairs_list.into_iter().map(Row::from_pairs).collect())
553        }
554
555        DataSource::CsvFile(path) => {
556            let file = std::fs::File::open(Path::new(&path))
557                .map_err(|e| IoError::FileError(format!("cannot open '{}': {}", path, e)))?;
558            let reader = BufReader::new(file);
559            let mut lines = reader.lines();
560
561            // First line → headers
562            let header_line = lines
563                .next()
564                .ok_or_else(|| IoError::FormatError("CSV file is empty".to_string()))?
565                .map_err(|e| IoError::Io(e))?;
566            let headers: Vec<String> = split_csv_line(&header_line)
567                .into_iter()
568                .map(|s| s.trim().to_string())
569                .collect();
570
571            let mut rows = Vec::new();
572            for line_result in lines {
573                let line = line_result.map_err(|e| IoError::Io(e))?;
574                if line.trim().is_empty() {
575                    continue;
576                }
577                let cells = split_csv_line(&line);
578                let pairs: Vec<(String, ColumnValue)> = headers
579                    .iter()
580                    .enumerate()
581                    .map(|(i, h)| {
582                        let val = cells
583                            .get(i)
584                            .map(|s| parse_cell(s))
585                            .unwrap_or(ColumnValue::Null);
586                        (h.clone(), val)
587                    })
588                    .collect();
589                rows.push(Row::from_pairs(pairs));
590            }
591            Ok(rows)
592        }
593    }
594}
595
596// ──────────────────────────────────────────────────────────────────────────────
597// execute() free function
598// ──────────────────────────────────────────────────────────────────────────────
599
600/// Execute a [`DataQuery`] and return the [`QueryResult`].
601///
602/// This is a convenience wrapper identical to calling [`DataQuery::execute`].
603pub fn execute(query: DataQuery) -> Result<QueryResult> {
604    query.execute()
605}
606
607// ──────────────────────────────────────────────────────────────────────────────
608// Tests
609// ──────────────────────────────────────────────────────────────────────────────
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn sample_rows() -> Vec<Row> {
616        vec![
617            Row::from_pairs(vec![
618                ("name".to_string(), ColumnValue::Text("Alice".to_string())),
619                ("age".to_string(), ColumnValue::Integer(30)),
620                ("score".to_string(), ColumnValue::Float(95.5)),
621                ("dept".to_string(), ColumnValue::Text("eng".to_string())),
622            ]),
623            Row::from_pairs(vec![
624                ("name".to_string(), ColumnValue::Text("Bob".to_string())),
625                ("age".to_string(), ColumnValue::Integer(25)),
626                ("score".to_string(), ColumnValue::Float(80.0)),
627                ("dept".to_string(), ColumnValue::Text("hr".to_string())),
628            ]),
629            Row::from_pairs(vec![
630                ("name".to_string(), ColumnValue::Text("Carol".to_string())),
631                ("age".to_string(), ColumnValue::Integer(35)),
632                ("score".to_string(), ColumnValue::Float(88.0)),
633                ("dept".to_string(), ColumnValue::Text("eng".to_string())),
634            ]),
635        ]
636    }
637
638    #[test]
639    fn test_select_and_count() {
640        let result = DataQuery::from(DataSource::Rows(sample_rows()))
641            .select(&["name", "age"])
642            .execute()
643            .expect("execute");
644        assert_eq!(result.n_rows, 3);
645        assert_eq!(result.columns, vec!["name", "age"]);
646        // score should be absent
647        assert!(result.rows[0]
648            .get("score")
649            .map(|v| matches!(v, ColumnValue::Null))
650            .unwrap_or(true));
651    }
652
653    #[test]
654    fn test_filter() {
655        let result = DataQuery::from(DataSource::Rows(sample_rows()))
656            .filter(|row| matches!(row.get("age"), Some(ColumnValue::Integer(a)) if *a > 28))
657            .execute()
658            .expect("execute");
659        assert_eq!(result.n_rows, 2); // Alice (30) and Carol (35)
660    }
661
662    #[test]
663    fn test_order_by_ascending() {
664        let result = DataQuery::from(DataSource::Rows(sample_rows()))
665            .order_by("age", true)
666            .execute()
667            .expect("execute");
668        let ages: Vec<i64> = result
669            .rows
670            .iter()
671            .filter_map(|r| {
672                if let Some(ColumnValue::Integer(a)) = r.get("age") {
673                    Some(*a)
674                } else {
675                    None
676                }
677            })
678            .collect();
679        assert_eq!(ages, vec![25, 30, 35]);
680    }
681
682    #[test]
683    fn test_order_by_descending() {
684        let result = DataQuery::from(DataSource::Rows(sample_rows()))
685            .order_by("age", false)
686            .execute()
687            .expect("execute");
688        let ages: Vec<i64> = result
689            .rows
690            .iter()
691            .filter_map(|r| {
692                if let Some(ColumnValue::Integer(a)) = r.get("age") {
693                    Some(*a)
694                } else {
695                    None
696                }
697            })
698            .collect();
699        assert_eq!(ages, vec![35, 30, 25]);
700    }
701
702    #[test]
703    fn test_limit() {
704        let result = DataQuery::from(DataSource::Rows(sample_rows()))
705            .limit(2)
706            .execute()
707            .expect("execute");
708        assert_eq!(result.n_rows, 2);
709    }
710
711    #[test]
712    fn test_aggregations() {
713        let result = DataQuery::from(DataSource::Rows(sample_rows()))
714            .execute()
715            .expect("execute");
716        let agg = result.agg();
717        assert_eq!(agg.count(), 3);
718        assert!((agg.sum("age") - 90.0).abs() < 1e-9);
719        assert!((agg.mean("age") - 30.0).abs() < 1e-9);
720        assert!((agg.min("age") - 25.0).abs() < 1e-9);
721        assert!((agg.max("age") - 35.0).abs() < 1e-9);
722    }
723
724    #[test]
725    fn test_group_by() {
726        let result = DataQuery::from(DataSource::Rows(sample_rows()))
727            .execute()
728            .expect("execute");
729        let grouped = result.group_by("dept");
730        let counts = grouped.count();
731        assert_eq!(*counts.get("eng").unwrap_or(&0), 2);
732        assert_eq!(*counts.get("hr").unwrap_or(&0), 1);
733    }
734
735    #[test]
736    fn test_group_by_sum() {
737        let result = DataQuery::from(DataSource::Rows(sample_rows()))
738            .execute()
739            .expect("execute");
740        let grouped = result.group_by("dept");
741        let sums = grouped.sum("age");
742        // eng: Alice(30) + Carol(35) = 65
743        assert!((sums.get("eng").copied().unwrap_or(0.0) - 65.0).abs() < 1e-9);
744    }
745
746    #[test]
747    fn test_csv_round_trip() {
748        use std::io::Write;
749
750        let dir = std::env::temp_dir();
751        let path = dir.join("test_query_csv.csv");
752        {
753            let mut f = std::fs::File::create(&path).expect("create csv");
754            writeln!(f, "id,val").expect("write header");
755            writeln!(f, "1,10.5").expect("write row 1");
756            writeln!(f, "2,20.0").expect("write row 2");
757        }
758
759        let result = DataQuery::from(DataSource::CsvFile(
760            path.to_str().expect("path").to_string(),
761        ))
762        .execute()
763        .expect("execute");
764
765        assert_eq!(result.n_rows, 2);
766        assert!((result.agg().sum("val") - 30.5).abs() < 1e-9);
767
768        // cleanup
769        let _ = std::fs::remove_file(&path);
770    }
771
772    #[test]
773    fn test_in_memory_rows_source() {
774        let pairs = vec![
775            vec![
776                ("x".to_string(), ColumnValue::Integer(1)),
777                ("y".to_string(), ColumnValue::Float(1.1)),
778            ],
779            vec![
780                ("x".to_string(), ColumnValue::Integer(2)),
781                ("y".to_string(), ColumnValue::Float(2.2)),
782            ],
783        ];
784        let result = DataQuery::from(DataSource::InMemoryRows(pairs))
785            .execute()
786            .expect("execute");
787        assert_eq!(result.n_rows, 2);
788    }
789
790    #[test]
791    fn test_combined_filter_order_limit() {
792        let result = DataQuery::from(DataSource::Rows(sample_rows()))
793            .filter(|row| matches!(row.get("dept"), Some(ColumnValue::Text(d)) if d == "eng"))
794            .order_by("age", true)
795            .limit(1)
796            .execute()
797            .expect("execute");
798        // eng: Alice(30), Carol(35) → sorted asc → Alice → limit 1
799        assert_eq!(result.n_rows, 1);
800        assert_eq!(
801            result.rows[0].get("name"),
802            Some(&ColumnValue::Text("Alice".to_string()))
803        );
804    }
805}