Skip to main content

scirs2_io/
streaming_csv.rs

1//! Streaming CSV reader with schema inference and typed access.
2//!
3//! Provides memory-efficient, lazy iteration over CSV files along with
4//! schema inference, typed row parsing, and batch (chunk) reading.
5//! The reader never loads the entire file into memory; each row is decoded
6//! on demand.
7//!
8//! # Design overview
9//!
10//! * [`CsvStreamReader`] — core iterator that yields `Result<Vec<String>>`.
11//! * [`ColumnType`] — enum representing an inferred column type.
12//! * [`TypedRow`] — a parsed row where every field has been coerced to
13//!   its inferred type.
14//! * [`TypedValue`] — the per-field variant produced by typed parsing.
15//! * [`infer_schema`] — scans the first `n_rows` data rows and returns a
16//!   `Vec<ColumnType>` describing the file.
17//!
18//! # Examples
19//!
20//! ```rust,no_run
21//! use scirs2_io::streaming_csv::{CsvStreamReader, infer_schema, ColumnType};
22//!
23//! // Lazy iterator — only one row is in memory at a time.
24//! let mut reader = CsvStreamReader::new("data.csv", b',', true).unwrap();
25//! for result in &mut reader {
26//!     let row = result.unwrap();
27//!     println!("{:?}", row);
28//! }
29//!
30//! // Schema inference
31//! let schema = infer_schema("data.csv", b',', 100).unwrap();
32//! for (i, col) in schema.iter().enumerate() {
33//!     println!("column {i}: {col:?}");
34//! }
35//! ```
36
37use std::fs::File;
38use std::io::{BufRead, BufReader};
39use std::path::Path;
40
41use crate::error::{IoError, Result};
42
43// ─────────────────────────────── ColumnType ──────────────────────────────────
44
45/// The inferred type of a CSV column.
46///
47/// Type inference uses the following priority order during schema detection:
48/// - If every sampled value parses as `i64` → `Integer`
49/// - Else if every value parses as `f64` → `Float`
50/// - Else if every value is a recognised boolean literal → `Boolean`
51/// - Otherwise → `Text`
52///
53/// Empty / null cells are skipped when determining the dominant type.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ColumnType {
56    /// 64-bit signed integer column.
57    Integer,
58    /// 64-bit floating-point column.
59    Float,
60    /// Boolean column (`true`/`false`/`yes`/`no`/`1`/`0`).
61    Boolean,
62    /// Free-form text column (fallback).
63    Text,
64}
65
66// ─────────────────────────────── TypedValue ──────────────────────────────────
67
68/// A single parsed cell value produced by [`read_typed_row`].
69#[derive(Debug, Clone, PartialEq)]
70pub enum TypedValue {
71    /// Parsed 64-bit integer.
72    Integer(i64),
73    /// Parsed 64-bit float.
74    Float(f64),
75    /// Parsed boolean.
76    Boolean(bool),
77    /// Raw text that could not (or should not) be parsed further.
78    Text(String),
79    /// Empty or explicit null field.
80    Null,
81}
82
83// ─────────────────────────────── TypedRow ────────────────────────────────────
84
85/// A fully typed row — one [`TypedValue`] per column.
86pub type TypedRow = Vec<TypedValue>;
87
88// ─────────────────────────────── parse helpers ───────────────────────────────
89
90/// Parse a quoted CSV row, respecting `""` escape sequences.
91///
92/// Trailing/leading whitespace is **not** stripped inside quoted fields
93/// (RFC 4180 compliance) but is stripped for bare fields.
94fn parse_csv_row_quoted(line: &str, delimiter: u8) -> Vec<String> {
95    let sep = delimiter as char;
96    let mut fields: Vec<String> = Vec::new();
97    let mut current = String::new();
98    let mut in_quotes = false;
99    let mut chars = line.chars().peekable();
100
101    while let Some(ch) = chars.next() {
102        if ch == '"' {
103            if in_quotes {
104                if chars.peek() == Some(&'"') {
105                    // Escaped double quote
106                    chars.next();
107                    current.push('"');
108                } else {
109                    in_quotes = false;
110                }
111            } else {
112                in_quotes = true;
113            }
114        } else if ch == sep && !in_quotes {
115            fields.push(current.trim().to_string());
116            current.clear();
117        } else {
118            current.push(ch);
119        }
120    }
121    fields.push(current.trim().to_string());
122    fields
123}
124
125/// Infer the [`TypedValue`] for a single string cell given a [`ColumnType`] hint.
126fn coerce_cell(cell: &str, col_type: &ColumnType) -> TypedValue {
127    let trimmed = cell.trim();
128    if trimmed.is_empty()
129        || trimmed.eq_ignore_ascii_case("null")
130        || trimmed.eq_ignore_ascii_case("na")
131        || trimmed.eq_ignore_ascii_case("n/a")
132        || trimmed.eq_ignore_ascii_case("nan")
133    {
134        return TypedValue::Null;
135    }
136    match col_type {
137        ColumnType::Integer => trimmed
138            .parse::<i64>()
139            .map(TypedValue::Integer)
140            .unwrap_or_else(|_| TypedValue::Text(trimmed.to_string())),
141        ColumnType::Float => trimmed
142            .parse::<f64>()
143            .map(TypedValue::Float)
144            .unwrap_or_else(|_| TypedValue::Text(trimmed.to_string())),
145        ColumnType::Boolean => match trimmed.to_lowercase().as_str() {
146            "true" | "yes" | "1" => TypedValue::Boolean(true),
147            "false" | "no" | "0" => TypedValue::Boolean(false),
148            _ => TypedValue::Text(trimmed.to_string()),
149        },
150        ColumnType::Text => TypedValue::Text(trimmed.to_string()),
151    }
152}
153
154// ─────────────────────────────── CsvStreamReader ─────────────────────────────
155
156/// Streaming CSV reader backed by a file on disk.
157///
158/// Implements [`Iterator`]`<Item = Result<Vec<String>>>` for lazy, one-row-at-a-time
159/// processing.  The reader owns a [`BufReader<File>`] so only a small I/O buffer
160/// is kept in memory regardless of file size.
161///
162/// # Behaviour
163///
164/// - If `has_header` is `true` the first non-blank line is consumed during
165///   construction and exposed via `headers`.
166/// - Blank lines inside the data region are silently skipped.
167/// - Quoted fields (`"..."`) with internal commas or escaped `""` double-quotes
168///   are handled correctly.
169pub struct CsvStreamReader {
170    inner: BufReader<File>,
171    delimiter: u8,
172    headers: Option<Vec<String>>,
173    finished: bool,
174    rows_yielded: u64,
175}
176
177impl CsvStreamReader {
178    /// Open `path` as a streaming CSV reader.
179    ///
180    /// # Arguments
181    ///
182    /// * `path`        — path to the CSV file.
183    /// * `delimiter`   — field separator byte (e.g. `b','` or `b'\t'`).
184    /// * `has_header`  — if `true` the first row is treated as a header row.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`IoError::FileNotFound`] if the path does not exist, or
189    /// [`IoError::FileError`] on any I/O failure while reading the header.
190    pub fn new<P: AsRef<Path>>(path: P, delimiter: u8, has_header: bool) -> Result<Self> {
191        let path = path.as_ref();
192        let file = File::open(path)
193            .map_err(|e| IoError::FileNotFound(format!("{}: {e}", path.display())))?;
194        let mut inner = BufReader::new(file);
195
196        let headers = if has_header {
197            let mut line = String::new();
198            loop {
199                line.clear();
200                let n = inner
201                    .read_line(&mut line)
202                    .map_err(|e| IoError::FileError(format!("header read error: {e}")))?;
203                if n == 0 {
204                    // Empty file — no header line found.
205                    break None;
206                }
207                let trimmed = line.trim();
208                if !trimmed.is_empty() {
209                    let hdrs = parse_csv_row_quoted(trimmed, delimiter);
210                    break Some(hdrs);
211                }
212            }
213        } else {
214            None
215        };
216
217        Ok(Self {
218            inner,
219            delimiter,
220            headers,
221            finished: false,
222            rows_yielded: 0,
223        })
224    }
225
226    /// Return the header row if `has_header` was `true`.
227    pub fn headers(&self) -> Option<&[String]> {
228        self.headers.as_deref()
229    }
230
231    /// Total data rows yielded so far (not counting the header).
232    pub fn rows_yielded(&self) -> u64 {
233        self.rows_yielded
234    }
235
236    /// Read up to `n_rows` data rows as a batch.
237    ///
238    /// Returns an empty `Vec` when the file is exhausted.  Any error encountered
239    /// while reading a row causes the entire batch call to fail.
240    pub fn read_chunk(&mut self, n_rows: usize) -> Result<Vec<Vec<String>>> {
241        let mut batch = Vec::with_capacity(n_rows);
242        for _ in 0..n_rows {
243            match self.next_row_inner()? {
244                Some(row) => batch.push(row),
245                None => break,
246            }
247        }
248        Ok(batch)
249    }
250
251    /// Read the next raw string row. Returns `Ok(None)` at EOF.
252    fn next_row_inner(&mut self) -> Result<Option<Vec<String>>> {
253        if self.finished {
254            return Ok(None);
255        }
256        let mut line = String::new();
257        loop {
258            line.clear();
259            let n = self.inner.read_line(&mut line).map_err(|e| {
260                IoError::FileError(format!("read error at row {}: {e}", self.rows_yielded + 1))
261            })?;
262            if n == 0 {
263                self.finished = true;
264                return Ok(None);
265            }
266            let trimmed = line.trim();
267            if trimmed.is_empty() {
268                continue;
269            }
270            self.rows_yielded += 1;
271            return Ok(Some(parse_csv_row_quoted(trimmed, self.delimiter)));
272        }
273    }
274}
275
276impl Iterator for CsvStreamReader {
277    type Item = Result<Vec<String>>;
278
279    fn next(&mut self) -> Option<Self::Item> {
280        match self.next_row_inner() {
281            Ok(Some(row)) => Some(Ok(row)),
282            Ok(None) => None,
283            Err(e) => Some(Err(e)),
284        }
285    }
286}
287
288// ─────────────────────────────── Schema inference ────────────────────────────
289
290/// Infer the column schema by scanning up to `n_rows` data rows of a CSV file.
291///
292/// The function opens the file fresh (separate from any existing reader), reads
293/// up to `n_rows` rows (after the header) and applies the following heuristic
294/// per column:
295///
296/// 1. All non-null sampled values parse as `i64`  → [`ColumnType::Integer`]
297/// 2. All non-null sampled values parse as `f64`  → [`ColumnType::Float`]
298/// 3. All non-null values are recognised booleans → [`ColumnType::Boolean`]
299/// 4. Otherwise                                   → [`ColumnType::Text`]
300///
301/// If no non-null values are seen for a column, it defaults to `Text`.
302///
303/// # Errors
304///
305/// Returns an error if the file cannot be opened or read.
306pub fn infer_schema<P: AsRef<Path>>(
307    path: P,
308    delimiter: u8,
309    n_rows: usize,
310) -> Result<Vec<ColumnType>> {
311    let path = path.as_ref();
312    let mut reader = CsvStreamReader::new(path, delimiter, true)?;
313
314    // Collect n_rows sample rows (or however many exist).
315    let sample = reader.read_chunk(n_rows)?;
316
317    if sample.is_empty() {
318        return Ok(Vec::new());
319    }
320
321    let n_cols = sample.iter().map(|r| r.len()).max().unwrap_or(0);
322    if n_cols == 0 {
323        return Ok(Vec::new());
324    }
325
326    // Per-column tracking flags.
327    // We start optimistic (all types possible) and rule out as we see values.
328    #[derive(Clone)]
329    struct ColFlags {
330        can_int: bool,
331        can_float: bool,
332        can_bool: bool,
333        seen_non_null: bool,
334    }
335
336    let mut flags = vec![
337        ColFlags {
338            can_int: true,
339            can_float: true,
340            can_bool: true,
341            seen_non_null: false,
342        };
343        n_cols
344    ];
345
346    let null_sentinels: &[&str] = &["", "null", "na", "n/a", "nan"];
347
348    for row in &sample {
349        for (col_idx, cell) in row.iter().enumerate() {
350            if col_idx >= n_cols {
351                break;
352            }
353            let trimmed = cell.trim();
354            let is_null = null_sentinels
355                .iter()
356                .any(|s| trimmed.eq_ignore_ascii_case(s));
357            if is_null {
358                continue;
359            }
360
361            let f = &mut flags[col_idx];
362            f.seen_non_null = true;
363
364            // Test integer parsability.
365            if f.can_int && trimmed.parse::<i64>().is_err() {
366                f.can_int = false;
367            }
368            // Test float parsability (integers are valid floats too).
369            if f.can_float && trimmed.parse::<f64>().is_err() {
370                f.can_float = false;
371            }
372            // Test boolean parsability.
373            if f.can_bool {
374                let lower = trimmed.to_lowercase();
375                match lower.as_str() {
376                    "true" | "false" | "yes" | "no" | "1" | "0" => {}
377                    _ => f.can_bool = false,
378                }
379            }
380        }
381    }
382
383    let schema = flags
384        .into_iter()
385        .map(|f| {
386            if !f.seen_non_null {
387                return ColumnType::Text;
388            }
389            if f.can_int {
390                ColumnType::Integer
391            } else if f.can_float {
392                ColumnType::Float
393            } else if f.can_bool {
394                ColumnType::Boolean
395            } else {
396                ColumnType::Text
397            }
398        })
399        .collect();
400
401    Ok(schema)
402}
403
404// ─────────────────────────────── Typed row parsing ───────────────────────────
405
406/// Parse a raw string row into a [`TypedRow`] by applying the given schema.
407///
408/// If `row.len() < schema.len()` the trailing columns receive [`TypedValue::Null`].
409/// Extra columns beyond the schema length are returned as [`TypedValue::Text`].
410///
411/// # Errors
412///
413/// This function is currently infallible (it degrades gracefully) but returns
414/// `Result` to allow future validation hooks.
415pub fn read_typed_row(row: &[String], schema: &[ColumnType]) -> Result<TypedRow> {
416    let len = schema.len().max(row.len());
417    let mut typed = Vec::with_capacity(len);
418    for col_idx in 0..len {
419        let cell = row.get(col_idx).map(String::as_str).unwrap_or("");
420        let col_type = schema.get(col_idx).unwrap_or(&ColumnType::Text);
421        typed.push(coerce_cell(cell, col_type));
422    }
423    Ok(typed)
424}
425
426// ─────────────────────────────── Tests ───────────────────────────────────────
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use std::io::Write;
432
433    fn write_temp_csv(name: &str, content: &str) -> std::path::PathBuf {
434        let dir = std::env::temp_dir().join("scirs2_streaming_csv_tests");
435        std::fs::create_dir_all(&dir).expect("mkdir");
436        let path = dir.join(name);
437        let mut f = File::create(&path).expect("create");
438        f.write_all(content.as_bytes()).expect("write");
439        path
440    }
441
442    // ── Iterator interface ────────────────────────────────────────────────────
443
444    #[test]
445    fn test_iterator_with_header() {
446        let path = write_temp_csv(
447            "iter_header.csv",
448            "name,age,score\nAlice,30,9.5\nBob,25,8.1\n",
449        );
450        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
451        assert_eq!(
452            r.headers(),
453            Some(vec!["name".to_string(), "age".to_string(), "score".to_string()].as_slice())
454        );
455
456        let rows: Vec<_> = r.by_ref().map(|x| x.expect("row ok")).collect();
457        assert_eq!(rows.len(), 2);
458        assert_eq!(rows[0], vec!["Alice", "30", "9.5"]);
459        assert_eq!(rows[1], vec!["Bob", "25", "8.1"]);
460    }
461
462    #[test]
463    fn test_iterator_no_header() {
464        let path = write_temp_csv("iter_no_header.csv", "1,2,3\n4,5,6\n");
465        let mut r = CsvStreamReader::new(&path, b',', false).expect("open");
466        assert!(r.headers().is_none());
467        let rows: Vec<_> = r.by_ref().map(|x| x.expect("ok")).collect();
468        assert_eq!(rows.len(), 2);
469        assert_eq!(rows[0], vec!["1", "2", "3"]);
470    }
471
472    // ── read_chunk ────────────────────────────────────────────────────────────
473
474    #[test]
475    fn test_read_chunk_basic() {
476        let content = "a,b\n1,2\n3,4\n5,6\n7,8\n9,10\n";
477        let path = write_temp_csv("chunk_basic.csv", content);
478        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
479
480        let chunk1 = r.read_chunk(2).expect("chunk1");
481        assert_eq!(chunk1.len(), 2);
482        assert_eq!(chunk1[0], vec!["1", "2"]);
483
484        let chunk2 = r.read_chunk(2).expect("chunk2");
485        assert_eq!(chunk2.len(), 2);
486        assert_eq!(chunk2[0], vec!["5", "6"]);
487
488        let chunk3 = r.read_chunk(10).expect("chunk3"); // fewer than requested
489        assert_eq!(chunk3.len(), 1);
490        assert_eq!(chunk3[0], vec!["9", "10"]);
491
492        let empty = r.read_chunk(5).expect("empty");
493        assert!(empty.is_empty());
494    }
495
496    #[test]
497    fn test_read_chunk_larger_than_file() {
498        let path = write_temp_csv("chunk_large.csv", "x\n1\n2\n");
499        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
500        let all = r.read_chunk(9999).expect("all");
501        assert_eq!(all.len(), 2);
502    }
503
504    // ── rows_yielded counter ──────────────────────────────────────────────────
505
506    #[test]
507    fn test_rows_yielded_tracks_count() {
508        let path = write_temp_csv("rows_yielded.csv", "h\n1\n2\n3\n");
509        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
510        assert_eq!(r.rows_yielded(), 0);
511        r.next().expect("some").expect("ok");
512        assert_eq!(r.rows_yielded(), 1);
513        r.read_chunk(5).expect("rest");
514        assert_eq!(r.rows_yielded(), 3);
515    }
516
517    // ── quoted fields ─────────────────────────────────────────────────────────
518
519    #[test]
520    fn test_quoted_fields_with_commas() {
521        let content = "name,address\nAlice,\"New York, NY\"\nBob,\"Los Angeles, CA\"\n";
522        let path = write_temp_csv("quoted.csv", content);
523        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
524        let row1 = r.next().expect("some").expect("ok");
525        assert_eq!(row1[1], "New York, NY");
526    }
527
528    #[test]
529    fn test_escaped_double_quote_inside_field() {
530        let content = "id,note\n1,\"He said \"\"hello\"\"\"\n";
531        let path = write_temp_csv("escaped_quote.csv", content);
532        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
533        let row = r.next().expect("some").expect("ok");
534        assert_eq!(row[1], "He said \"hello\"");
535    }
536
537    // ── tab delimiter ─────────────────────────────────────────────────────────
538
539    #[test]
540    fn test_tab_delimiter() {
541        let path = write_temp_csv("tab.csv", "a\tb\tc\n10\t20\t30\n");
542        let mut r = CsvStreamReader::new(&path, b'\t', true).expect("open");
543        assert_eq!(
544            r.headers(),
545            Some(vec!["a".to_string(), "b".to_string(), "c".to_string()].as_slice())
546        );
547        let row = r.next().expect("some").expect("ok");
548        assert_eq!(row, vec!["10", "20", "30"]);
549    }
550
551    // ── blank lines are skipped ───────────────────────────────────────────────
552
553    #[test]
554    fn test_blank_lines_skipped() {
555        let path = write_temp_csv("blanks.csv", "x\n1\n\n\n2\n");
556        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
557        let rows: Vec<_> = r.by_ref().map(|x| x.expect("ok")).collect();
558        assert_eq!(rows.len(), 2);
559    }
560
561    // ── schema inference ──────────────────────────────────────────────────────
562
563    #[test]
564    fn test_infer_schema_mixed_types() {
565        let content = "id,value,active,label\n1,3.14,true,hello\n2,2.71,false,world\n";
566        let path = write_temp_csv("schema_mixed.csv", content);
567        let schema = infer_schema(&path, b',', 50).expect("infer");
568        assert_eq!(schema.len(), 4);
569        assert_eq!(schema[0], ColumnType::Integer);
570        assert_eq!(schema[1], ColumnType::Float);
571        assert_eq!(schema[2], ColumnType::Boolean);
572        assert_eq!(schema[3], ColumnType::Text);
573    }
574
575    #[test]
576    fn test_infer_schema_all_integer() {
577        let path = write_temp_csv("schema_int.csv", "n\n1\n2\n3\n");
578        let schema = infer_schema(&path, b',', 10).expect("infer");
579        assert_eq!(schema[0], ColumnType::Integer);
580    }
581
582    #[test]
583    fn test_infer_schema_float_beats_integer_when_mixed() {
584        let path = write_temp_csv("schema_float.csv", "n\n1\n2.5\n3\n");
585        let schema = infer_schema(&path, b',', 10).expect("infer");
586        assert_eq!(schema[0], ColumnType::Float);
587    }
588
589    #[test]
590    fn test_infer_schema_with_nulls() {
591        // Column with only nulls should default to Text.
592        let path = write_temp_csv("schema_null.csv", "a,b\n1,\n2,NA\n");
593        let schema = infer_schema(&path, b',', 10).expect("infer");
594        assert_eq!(schema[0], ColumnType::Integer);
595        assert_eq!(schema[1], ColumnType::Text);
596    }
597
598    // ── typed row parsing ─────────────────────────────────────────────────────
599
600    #[test]
601    fn test_read_typed_row_all_types() {
602        let schema = vec![
603            ColumnType::Integer,
604            ColumnType::Float,
605            ColumnType::Boolean,
606            ColumnType::Text,
607        ];
608        let raw: Vec<String> = vec!["42", "2.5", "true", "hello"]
609            .into_iter()
610            .map(String::from)
611            .collect();
612        let typed = read_typed_row(&raw, &schema).expect("parse");
613        assert!(matches!(typed[0], TypedValue::Integer(42)));
614        assert!(matches!(typed[1], TypedValue::Float(f) if (f - 2.5).abs() < 1e-10));
615        assert!(matches!(typed[2], TypedValue::Boolean(true)));
616        assert!(matches!(typed[3], TypedValue::Text(ref s) if s == "hello"));
617    }
618
619    #[test]
620    fn test_read_typed_row_null_cells() {
621        let schema = vec![ColumnType::Integer, ColumnType::Float];
622        let raw: Vec<String> = vec!["", "NA"].into_iter().map(String::from).collect();
623        let typed = read_typed_row(&raw, &schema).expect("parse");
624        assert!(matches!(typed[0], TypedValue::Null));
625        assert!(matches!(typed[1], TypedValue::Null));
626    }
627
628    #[test]
629    fn test_read_typed_row_short_row_padded_with_null() {
630        let schema = vec![ColumnType::Integer, ColumnType::Float, ColumnType::Boolean];
631        let raw: Vec<String> = vec!["1"].into_iter().map(String::from).collect();
632        let typed = read_typed_row(&raw, &schema).expect("parse");
633        assert_eq!(typed.len(), 3);
634        assert!(matches!(typed[0], TypedValue::Integer(1)));
635        assert!(matches!(typed[1], TypedValue::Null));
636        assert!(matches!(typed[2], TypedValue::Null));
637    }
638
639    #[test]
640    fn test_read_typed_row_extra_columns_text() {
641        let schema = vec![ColumnType::Integer];
642        let raw: Vec<String> = vec!["1", "extra"].into_iter().map(String::from).collect();
643        let typed = read_typed_row(&raw, &schema).expect("parse");
644        assert_eq!(typed.len(), 2);
645        assert!(matches!(typed[1], TypedValue::Text(_)));
646    }
647
648    // ── large file simulation (many rows, streaming) ──────────────────────────
649
650    #[test]
651    fn test_large_file_lazy_iteration() {
652        // 10 000 rows — only one row in memory at a time.
653        let n = 10_000_usize;
654        let mut content = String::from("i,v\n");
655        for i in 0..n {
656            content.push_str(&format!("{},{}\n", i, i as f64 * 1.1));
657        }
658        let path = write_temp_csv("large.csv", &content);
659        let mut r = CsvStreamReader::new(&path, b',', true).expect("open");
660        let mut count = 0usize;
661        for item in &mut r {
662            let row = item.expect("row ok");
663            let _ = row[0].parse::<usize>().expect("int");
664            count += 1;
665        }
666        assert_eq!(count, n);
667    }
668}