1use akar_catalog::CatalogColumn;
10use akar_common::types::{Date, Interval, LogicalTypeID, Timestamp, Value};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone)]
15pub struct CsvReaderConfig {
16 pub delimiter: u8,
18 pub has_header: bool,
20 pub quote: u8,
22 pub escape: u8,
24 pub null_str: String,
26}
27
28impl Default for CsvReaderConfig {
29 fn default() -> Self {
30 Self {
31 delimiter: b',',
32 has_header: true,
33 quote: b'"',
34 escape: b'\\',
35 null_str: String::new(),
36 }
37 }
38}
39
40impl CsvReaderConfig {
41 pub fn from_options(options: &HashMap<String, String>) -> Self {
45 let mut config = Self::default();
46
47 if let Some(d) = options.get("HEADER").or_else(|| options.get("header")) {
48 config.has_header = d.eq_ignore_ascii_case("true");
49 }
50
51 if let Some(d) = options
52 .get("DELIM")
53 .or_else(|| options.get("delim"))
54 .or_else(|| options.get("DELIMITER"))
55 && let Some(c) = d.chars().next()
56 {
57 config.delimiter = c as u8;
58 }
59
60 if let Some(q) = options.get("QUOTE").or_else(|| options.get("quote"))
61 && let Some(c) = q.chars().next()
62 {
63 config.quote = c as u8;
64 }
65
66 if let Some(e) = options.get("ESCAPE").or_else(|| options.get("escape"))
67 && let Some(c) = e.chars().next()
68 {
69 config.escape = c as u8;
70 }
71
72 if let Some(n) = options.get("NULL").or_else(|| options.get("null")) {
73 config.null_str = n.clone();
74 }
75
76 config
77 }
78}
79
80#[derive(Debug)]
82pub enum CsvReaderError {
83 IoError(std::io::Error),
85 CsvError(String),
87 TypeCoercion {
89 line: usize,
90 column: usize,
91 column_name: String,
92 value: String,
93 expected_type: String,
94 message: String,
95 },
96 ColumnCountMismatch {
98 line: usize,
99 expected: usize,
100 actual: usize,
101 },
102}
103
104impl std::fmt::Display for CsvReaderError {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 match self {
107 CsvReaderError::IoError(e) => write!(f, "IO error: {e}"),
108 CsvReaderError::CsvError(e) => write!(f, "CSV error: {e}"),
109 CsvReaderError::TypeCoercion {
110 line,
111 column,
112 column_name,
113 value,
114 expected_type,
115 message,
116 } => write!(
117 f,
118 "Type coercion error at line {line}, column {column} ('{column_name}'): \
119 cannot coerce '{value}' to {expected_type}: {message}"
120 ),
121 CsvReaderError::ColumnCountMismatch { line, expected, actual } => write!(
122 f,
123 "Column count mismatch at line {line}: expected {expected} columns, got {actual}"
124 ),
125 }
126 }
127}
128
129impl std::error::Error for CsvReaderError {}
130
131pub type CsvResult<T> = Result<T, CsvReaderError>;
133
134pub fn read_csv(
152 path: &str,
153 vfs: &akar_common::file_system::VirtualFileSystemRegistry,
154 columns: &[CatalogColumn],
155 config: &CsvReaderConfig,
156) -> CsvResult<Vec<Vec<Value>>> {
157 let file = vfs.open_read(path).map_err(CsvReaderError::IoError)?;
158 let mut reader = std::io::BufReader::new(file);
159
160 let mut raw_reader = csv::ReaderBuilder::new()
161 .delimiter(config.delimiter)
162 .has_headers(config.has_header)
163 .quote(config.quote)
164 .escape(Some(config.escape))
165 .flexible(true)
166 .from_reader(&mut reader);
167
168 let headers: Vec<String> = if config.has_header {
170 raw_reader
171 .headers()
172 .map_err(|e| CsvReaderError::CsvError(e.to_string()))?
173 .iter()
174 .map(|h| h.to_string())
175 .collect()
176 } else {
177 columns.iter().map(|c| c.name.clone()).collect()
178 };
179
180 if headers.len() != columns.len() {
182 return Err(CsvReaderError::ColumnCountMismatch {
183 line: 1,
184 expected: columns.len(),
185 actual: headers.len(),
186 });
187 }
188
189 let mut results = Vec::new();
190 let start_line = if config.has_header { 2 } else { 1 };
191
192 for (line_number, result) in (start_line..).zip(raw_reader.records()) {
193 let record = result.map_err(|e| CsvReaderError::CsvError(format!("Line {line_number}: {e}")))?;
194
195 if record.len() != columns.len() {
196 return Err(CsvReaderError::ColumnCountMismatch {
197 line: line_number,
198 expected: columns.len(),
199 actual: record.len(),
200 });
201 }
202
203 let mut row = Vec::with_capacity(columns.len());
204 for (col_idx, field) in record.iter().enumerate() {
205 let col = &columns[col_idx];
206 let value = coerce_string_to_value(
207 field,
208 col.logical_type,
209 line_number,
210 col_idx,
211 &col.name,
212 &config.null_str,
213 )?;
214 row.push(value);
215 }
216
217 results.push(row);
218 }
219
220 Ok(results)
221}
222
223fn coerce_string_to_value(
227 field: &str,
228 target_type: LogicalTypeID,
229 line: usize,
230 column: usize,
231 column_name: &str,
232 null_str: &str,
233) -> CsvResult<Value> {
234 let trimmed = field.trim();
235
236 if trimmed == null_str || trimmed.eq_ignore_ascii_case("null") {
238 return Ok(Value::Null);
239 }
240
241 match target_type {
242 LogicalTypeID::Bool => coerce_bool(trimmed, line, column, column_name),
243 LogicalTypeID::Int64 | LogicalTypeID::Serial => {
244 coerce_parse(trimmed, line, column, column_name, "INT64").map(Value::Int64)
245 }
246 LogicalTypeID::Int32 => coerce_parse(trimmed, line, column, column_name, "INT32").map(Value::Int32),
247 LogicalTypeID::Int16 => coerce_parse(trimmed, line, column, column_name, "INT16").map(Value::Int16),
248 LogicalTypeID::Int8 => coerce_parse(trimmed, line, column, column_name, "INT8").map(Value::Int8),
249 LogicalTypeID::UInt64 => coerce_parse(trimmed, line, column, column_name, "UINT64").map(Value::UInt64),
250 LogicalTypeID::UInt32 => coerce_parse(trimmed, line, column, column_name, "UINT32").map(Value::UInt32),
251 LogicalTypeID::UInt16 => coerce_parse(trimmed, line, column, column_name, "UINT16").map(Value::UInt16),
252 LogicalTypeID::UInt8 => coerce_parse(trimmed, line, column, column_name, "UINT8").map(Value::UInt8),
253 LogicalTypeID::Double => coerce_parse::<f64>(trimmed, line, column, column_name, "DOUBLE").map(Value::Double),
254 LogicalTypeID::Float => coerce_parse::<f32>(trimmed, line, column, column_name, "FLOAT").map(Value::Float),
255 LogicalTypeID::String => Ok(Value::String(trimmed.to_string())),
256 LogicalTypeID::Date => coerce_date(trimmed, line, column, column_name),
257 LogicalTypeID::Timestamp | LogicalTypeID::TimestampMs => coerce_timestamp(trimmed, line, column, column_name),
258 LogicalTypeID::TimestampSec => coerce_timestamp_sec(trimmed, line, column, column_name),
259 LogicalTypeID::TimestampNs => coerce_timestamp_ns(trimmed, line, column, column_name),
260 LogicalTypeID::TimestampTz => coerce_timestamp_tz(trimmed, line, column, column_name),
261 LogicalTypeID::Interval => coerce_interval(trimmed, line, column, column_name),
262 LogicalTypeID::Blob => Ok(Value::Blob(parse_blob(trimmed))),
263 LogicalTypeID::List => Ok(Value::List(parse_list(trimmed, null_str))),
264 LogicalTypeID::Map => Ok(Value::Map(parse_map(trimmed, null_str))),
265 LogicalTypeID::Struct | LogicalTypeID::Node | LogicalTypeID::Rel => {
266 Ok(Value::Struct(parse_struct(trimmed, null_str)))
267 }
268 _ => Ok(Value::String(trimmed.to_string())),
270 }
271}
272
273fn coerce_bool(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
275 match s.to_lowercase().as_str() {
276 "true" | "1" | "yes" | "t" => Ok(Value::Bool(true)),
277 "false" | "0" | "no" | "f" => Ok(Value::Bool(false)),
278 other => Err(CsvReaderError::TypeCoercion {
279 line,
280 column,
281 column_name: column_name.to_string(),
282 value: other.to_string(),
283 expected_type: "BOOL".into(),
284 message: "expected true/false, 1/0, yes/no, or t/f".into(),
285 }),
286 }
287}
288
289fn coerce_parse<T: std::str::FromStr>(
291 s: &str,
292 line: usize,
293 column: usize,
294 column_name: &str,
295 type_name: &str,
296) -> CsvResult<T> {
297 s.parse::<T>().map_err(|_| CsvReaderError::TypeCoercion {
298 line,
299 column,
300 column_name: column_name.to_string(),
301 value: s.to_string(),
302 expected_type: type_name.to_string(),
303 message: format!("cannot parse '{s}' as {type_name}"),
304 })
305}
306
307fn coerce_date(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
309 let parts: Vec<&str> = s.split('-').collect();
311 if parts.len() != 3 {
312 return Err(coercion_err(
313 s,
314 line,
315 column,
316 column_name,
317 "DATE",
318 "expected YYYY-MM-DD format",
319 ));
320 }
321 let year: i32 = parts[0]
322 .parse()
323 .map_err(|_| coercion_err(s, line, column, column_name, "DATE", "invalid year"))?;
324 let month: u32 = parts[1]
325 .parse()
326 .map_err(|_| coercion_err(s, line, column, column_name, "DATE", "invalid month"))?;
327 let day: u32 = parts[2]
328 .parse()
329 .map_err(|_| coercion_err(s, line, column, column_name, "DATE", "invalid day"))?;
330
331 let days = naive_date_to_epoch_days(year, month, day)
333 .ok_or_else(|| coercion_err(s, line, column, column_name, "DATE", "invalid calendar date"))?;
334
335 Ok(Value::Date(Date::from_days_since_epoch(days)))
336}
337
338fn coerce_timestamp(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
340 let ts = parse_timestamp_micros(s).ok_or_else(|| {
341 coercion_err(
342 s,
343 line,
344 column,
345 column_name,
346 "TIMESTAMP",
347 "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
348 )
349 })?;
350 Ok(Value::Timestamp(Timestamp::from_micros_since_epoch(ts)))
351}
352
353fn coerce_timestamp_sec(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
355 let micros = parse_timestamp_micros(s).ok_or_else(|| {
356 coercion_err(
357 s,
358 line,
359 column,
360 column_name,
361 "TIMESTAMP_SEC",
362 "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
363 )
364 })?;
365 Ok(Value::TimestampSec(Timestamp(micros / 1_000_000)))
366}
367
368fn coerce_timestamp_ns(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
370 let micros = parse_timestamp_micros(s).ok_or_else(|| {
371 coercion_err(
372 s,
373 line,
374 column,
375 column_name,
376 "TIMESTAMP_NS",
377 "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
378 )
379 })?;
380 Ok(Value::TimestampNs(Timestamp(micros * 1000)))
382}
383
384fn coerce_timestamp_tz(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
386 let micros = parse_timestamp_micros(s).ok_or_else(|| {
387 coercion_err(
388 s,
389 line,
390 column,
391 column_name,
392 "TIMESTAMP_TZ",
393 "expected YYYY-MM-DD HH:MM:SS[.ffffff] format",
394 )
395 })?;
396 Ok(Value::TimestampTz(akar_common::types::TimestampTZ(micros)))
397}
398
399fn coerce_interval(s: &str, line: usize, column: usize, column_name: &str) -> CsvResult<Value> {
401 match parse_interval_str(s) {
402 Some(iv) => Ok(Value::Interval(iv)),
403 None => Err(coercion_err(
404 s,
405 line,
406 column,
407 column_name,
408 "INTERVAL",
409 "expected duration format (e.g. '1 year 2 months 3 days 4 hours 5 minutes 6 seconds')",
410 )),
411 }
412}
413
414fn coercion_err(
417 value: &str,
418 line: usize,
419 column: usize,
420 column_name: &str,
421 expected_type: &str,
422 message: &str,
423) -> CsvReaderError {
424 CsvReaderError::TypeCoercion {
425 line,
426 column,
427 column_name: column_name.to_string(),
428 value: value.to_string(),
429 expected_type: expected_type.to_string(),
430 message: message.to_string(),
431 }
432}
433
434fn naive_date_to_epoch_days(year: i32, month: u32, day: u32) -> Option<i32> {
439 if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
440 return None;
441 }
442
443 let (y, m) = if month <= 2 {
446 (year as i64 - 1, month as i64 + 12)
447 } else {
448 (year as i64, month as i64)
449 };
450 let era = if y >= 0 { y } else { y - 399 } / 400;
451 let yoe = y - era * 400;
452 let doy = (153 * (m - 3) + 2) / 5 + day as i64;
453 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
454
455 const EPOCH_OFFSET: i64 = 719469;
458
459 let days = era * 146097 + doe - EPOCH_OFFSET;
460 Some(days as i32)
461}
462
463fn parse_timestamp_micros(s: &str) -> Option<i64> {
467 let s = s.trim();
468 let (date_part, time_part) = if let Some(space_idx) = s.find(' ') {
470 (&s[..space_idx], &s[space_idx + 1..])
471 } else {
472 (s, "00:00:00")
474 };
475
476 let date_parts: Vec<&str> = date_part.split('-').collect();
478 if date_parts.len() != 3 {
479 return None;
480 }
481 let year: i32 = date_parts[0].parse().ok()?;
482 let month: u32 = date_parts[1].parse().ok()?;
483 let day: u32 = date_parts[2].parse().ok()?;
484 let epoch_days = naive_date_to_epoch_days(year, month, day)?;
485
486 let time_parts: Vec<&str> = time_part.split(':').collect();
488 if time_parts.len() < 2 || time_parts.len() > 3 {
489 return None;
490 }
491 let hour: u32 = time_parts[0].parse().ok()?;
492 let minute: u32 = time_parts[1].parse().ok()?;
493
494 let (second, micros) = if time_parts.len() == 3 {
495 let sec_str = time_parts[2];
496 if let Some(dot_idx) = sec_str.find('.') {
497 let sec: u32 = sec_str[..dot_idx].parse().ok()?;
498 let frac_str = &sec_str[dot_idx + 1..];
499 let mut frac = [0u8; 6];
501 for (i, ch) in frac_str.chars().enumerate() {
502 if i >= 6 {
503 break;
504 }
505 if ch.is_ascii_digit() {
506 frac[i] = ch as u8 - b'0';
507 } else {
508 return None;
509 }
510 }
511 let micros = frac.iter().fold(0u64, |acc, &d| acc * 10 + d as u64);
512 (sec, micros)
513 } else {
514 (sec_str.parse().ok()?, 0)
515 }
516 } else {
517 (0, 0)
518 };
519
520 let total_micros = epoch_days as i64 * 86_400_000_000i64
521 + hour as i64 * 3_600_000_000i64
522 + minute as i64 * 60_000_000i64
523 + second as i64 * 1_000_000i64
524 + micros as i64;
525
526 Some(total_micros)
527}
528
529fn parse_interval_str(s: &str) -> Option<Interval> {
537 let mut months: i32 = 0;
538 let mut days: i32 = 0;
539 let mut micros: i64 = 0;
540
541 let s = s.trim().to_lowercase();
542 let tokens: Vec<&str> = s.split_whitespace().collect();
544 let mut i = 0;
545 while i + 1 < tokens.len() {
546 let value: i64 = tokens[i].parse().ok()?;
547 let unit = tokens[i + 1];
548 match unit {
549 u if u.starts_with("year") => months += value as i32 * 12,
550 u if u.starts_with("month") => months += value as i32,
551 u if u.starts_with("day") => days += value as i32,
552 u if u.starts_with("hour") => micros += value * 3_600_000_000,
553 u if u.starts_with("minute") => micros += value * 60_000_000,
554 u if u.starts_with("second") && !unit.contains("milli") && !unit.contains("micro") => {
555 micros += value * 1_000_000;
556 }
557 u if u.starts_with("millisecond") => micros += value * 1_000,
558 u if u.starts_with("microsecond") || u == "us" => micros += value,
559 _ => {
560 }
562 }
563 i += 2;
564 }
565
566 Some(Interval::new(months, days, micros))
567}
568
569fn parse_blob(s: &str) -> Vec<u8> {
576 let s = s.trim();
577 if s.is_empty() {
578 return Vec::new();
579 }
580
581 let mut result = Vec::new();
583 let mut chars = s.chars().peekable();
584
585 while let Some(ch) = chars.next() {
586 if ch == '\\' && chars.peek() == Some(&'x') {
587 chars.next(); let hex_str: String = chars.by_ref().take(2).collect();
590 if hex_str.len() == 2 {
591 if let Ok(byte) = u8::from_str_radix(&hex_str, 16) {
592 result.push(byte);
593 } else {
594 result.push(b'\\');
596 result.push(b'x');
597 result.extend_from_slice(hex_str.as_bytes());
598 }
599 } else {
600 result.push(b'\\');
601 result.push(b'x');
602 result.extend_from_slice(hex_str.as_bytes());
603 }
604 } else {
605 let mut buf = [0u8; 4];
607 let encoded = ch.encode_utf8(&mut buf);
608 result.extend_from_slice(encoded.as_bytes());
609 }
610 }
611
612 result
613}
614
615fn parse_list(s: &str, null_str: &str) -> Vec<Value> {
621 let s = s.trim();
622 if s.is_empty() || s == "[]" {
623 return Vec::new();
624 }
625
626 let inner = if s.starts_with('[') && s.ends_with(']') {
628 &s[1..s.len() - 1]
629 } else {
630 s
631 };
632
633 if inner.trim().is_empty() {
634 return Vec::new();
635 }
636
637 split_csv_respecting_quotes(inner, ',')
639 .into_iter()
640 .map(|item| {
641 let trimmed = item.trim();
642 if trimmed == null_str || trimmed.eq_ignore_ascii_case("null") {
643 Value::Null
644 } else {
645 Value::String(trimmed.to_string())
646 }
647 })
648 .collect()
649}
650
651fn parse_struct(s: &str, null_str: &str) -> Vec<(String, Value)> {
657 let s = s.trim();
658 if s.is_empty() || s == "{}" {
659 return Vec::new();
660 }
661
662 let inner = if s.starts_with('{') && s.ends_with('}') {
664 &s[1..s.len() - 1]
665 } else {
666 s
667 };
668
669 if inner.trim().is_empty() {
670 return Vec::new();
671 }
672
673 split_top_level(inner, ',')
675 .into_iter()
676 .filter_map(|field| {
677 let trimmed = field.trim();
678 if trimmed.is_empty() {
679 return None;
680 }
681 if let Some(colon_idx) = trimmed.find(':') {
683 let key = trimmed[..colon_idx].trim().to_string();
684 let val_str = trimmed[colon_idx + 1..].trim();
685 let value = if val_str == null_str || val_str.eq_ignore_ascii_case("null") {
686 Value::Null
687 } else {
688 Value::String(val_str.to_string())
689 };
690 Some((key, value))
691 } else {
692 Some((trimmed.to_string(), Value::Null))
693 }
694 })
695 .collect()
696}
697
698fn parse_map(s: &str, null_str: &str) -> Vec<(Value, Value)> {
704 let s = s.trim();
705 if s.is_empty() || s == "{}" {
706 return Vec::new();
707 }
708
709 let inner = if s.starts_with('{') && s.ends_with('}') {
711 &s[1..s.len() - 1]
712 } else {
713 s
714 };
715
716 if inner.trim().is_empty() {
717 return Vec::new();
718 }
719
720 split_top_level(inner, ',')
721 .into_iter()
722 .filter_map(|field| {
723 let trimmed = field.trim();
724 if trimmed.is_empty() {
725 return None;
726 }
727 if let Some(eq_idx) = trimmed.find('=') {
729 let key_str = trimmed[..eq_idx].trim();
730 let val_str = trimmed[eq_idx + 1..].trim();
731 let key = if key_str == null_str || key_str.eq_ignore_ascii_case("null") {
732 Value::Null
733 } else {
734 Value::String(key_str.to_string())
735 };
736 let value = if val_str == null_str || val_str.eq_ignore_ascii_case("null") {
737 Value::Null
738 } else {
739 Value::String(val_str.to_string())
740 };
741 Some((key, value))
742 } else {
743 Some((Value::String(trimmed.to_string()), Value::Null))
744 }
745 })
746 .collect()
747}
748
749fn split_csv_respecting_quotes(s: &str, delimiter: char) -> Vec<String> {
753 let mut parts = Vec::new();
754 let mut current = String::new();
755 let mut in_quotes = false;
756
757 for ch in s.chars() {
758 match ch {
759 '"' => in_quotes = !in_quotes,
760 c if c == delimiter && !in_quotes => {
761 parts.push(current.trim().to_string());
762 current = String::new();
763 }
764 c => current.push(c),
765 }
766 }
767 parts.push(current.trim().to_string());
768 parts
769}
770
771fn split_top_level(s: &str, delimiter: char) -> Vec<String> {
774 let mut parts = Vec::new();
775 let mut current = String::new();
776 let mut depth_brace = 0i32;
777 let mut depth_bracket = 0i32;
778 let mut depth_paren = 0i32;
779 let mut in_quotes = false;
780
781 for ch in s.chars() {
782 match ch {
783 '"' => {
784 in_quotes = !in_quotes;
785 current.push(ch);
786 }
787 '{' if !in_quotes => {
788 depth_brace += 1;
789 current.push(ch);
790 }
791 '}' if !in_quotes => {
792 depth_brace -= 1;
793 current.push(ch);
794 }
795 '[' if !in_quotes => {
796 depth_bracket += 1;
797 current.push(ch);
798 }
799 ']' if !in_quotes => {
800 depth_bracket -= 1;
801 current.push(ch);
802 }
803 '(' if !in_quotes => {
804 depth_paren += 1;
805 current.push(ch);
806 }
807 ')' if !in_quotes => {
808 depth_paren -= 1;
809 current.push(ch);
810 }
811 c if c == delimiter && !in_quotes && depth_brace == 0 && depth_bracket == 0 && depth_paren == 0 => {
812 parts.push(current.trim().to_string());
813 current = String::new();
814 }
815 c => current.push(c),
816 }
817 }
818 parts.push(current.trim().to_string());
819 parts
820}
821
822#[cfg(test)]
825mod tests {
826 use super::*;
827
828 fn test_schema() -> Vec<CatalogColumn> {
829 vec![
830 CatalogColumn {
831 compression: akar_common::enums::CompressionType::Uncompressed,
832 name: "name".into(),
833 logical_type: LogicalTypeID::String,
834 is_primary_key: true,
835 default_value: None,
836 },
837 CatalogColumn {
838 compression: akar_common::enums::CompressionType::Uncompressed,
839 name: "age".into(),
840 logical_type: LogicalTypeID::Int64,
841 is_primary_key: false,
842 default_value: None,
843 },
844 CatalogColumn {
845 compression: akar_common::enums::CompressionType::Uncompressed,
846 name: "score".into(),
847 logical_type: LogicalTypeID::Double,
848 is_primary_key: false,
849 default_value: None,
850 },
851 CatalogColumn {
852 compression: akar_common::enums::CompressionType::Uncompressed,
853 name: "active".into(),
854 logical_type: LogicalTypeID::Bool,
855 is_primary_key: false,
856 default_value: None,
857 },
858 ]
859 }
860
861 #[test]
862 fn test_read_csv_basic() {
863 let dir = tempfile::tempdir().unwrap();
864 let csv_path = dir.path().join("test.csv");
865 std::fs::write(
866 &csv_path,
867 "name,age,score,active\nAlice,30,95.5,true\nBob,25,87.3,false\n",
868 )
869 .unwrap();
870
871 let config = CsvReaderConfig::default();
872 let rows = read_csv(
873 csv_path.to_str().unwrap(),
874 &akar_common::file_system::VirtualFileSystemRegistry::new(),
875 &test_schema(),
876 &config,
877 )
878 .unwrap();
879 assert_eq!(rows.len(), 2);
880 assert_eq!(rows[0][0], Value::String("Alice".into()));
881 assert_eq!(rows[0][1], Value::Int64(30));
882 assert_eq!(rows[0][2], Value::Double(95.5));
883 assert_eq!(rows[0][3], Value::Bool(true));
884 }
885
886 #[test]
887 fn test_read_csv_no_header() {
888 let dir = tempfile::tempdir().unwrap();
889 let csv_path = dir.path().join("noheader.csv");
890 std::fs::write(&csv_path, "Charlie,40,91.2,true\nDiana,22,88.1,false\n").unwrap();
891
892 let config = CsvReaderConfig {
893 has_header: false,
894 ..Default::default()
895 };
896 let rows = read_csv(
897 csv_path.to_str().unwrap(),
898 &akar_common::file_system::VirtualFileSystemRegistry::new(),
899 &test_schema(),
900 &config,
901 )
902 .unwrap();
903 assert_eq!(rows.len(), 2);
904 assert_eq!(rows[1][0], Value::String("Diana".into()));
905 assert_eq!(rows[1][1], Value::Int64(22));
906 }
907
908 #[test]
909 fn test_read_csv_custom_delimiter() {
910 let dir = tempfile::tempdir().unwrap();
911 let csv_path = dir.path().join("pipes.csv");
912 std::fs::write(&csv_path, "name|age|score|active\nEve|35|77.5|true\n").unwrap();
913
914 let config = CsvReaderConfig {
915 delimiter: b'|',
916 ..Default::default()
917 };
918 let rows = read_csv(
919 csv_path.to_str().unwrap(),
920 &akar_common::file_system::VirtualFileSystemRegistry::new(),
921 &test_schema(),
922 &config,
923 )
924 .unwrap();
925 assert_eq!(rows.len(), 1);
926 assert_eq!(rows[0][0], Value::String("Eve".into()));
927 assert_eq!(rows[0][1], Value::Int64(35));
928 }
929
930 #[test]
931 fn test_read_csv_quoted_fields() {
932 let dir = tempfile::tempdir().unwrap();
933 let csv_path = dir.path().join("quoted.csv");
934 std::fs::write(&csv_path, "name,age,score,active\n\"Frank, Jr.\",28,99.9,true\n").unwrap();
935
936 let config = CsvReaderConfig::default();
937 let rows = read_csv(
938 csv_path.to_str().unwrap(),
939 &akar_common::file_system::VirtualFileSystemRegistry::new(),
940 &test_schema(),
941 &config,
942 )
943 .unwrap();
944 assert_eq!(rows.len(), 1);
945 assert_eq!(rows[0][0], Value::String("Frank, Jr.".into()));
946 }
947
948 #[test]
949 fn test_read_csv_null_values() {
950 let dir = tempfile::tempdir().unwrap();
951 let csv_path = dir.path().join("nulls.csv");
952 std::fs::write(&csv_path, "name,age,score,active\nGrace,,,\n").unwrap();
953
954 let config = CsvReaderConfig::default();
955 let rows = read_csv(
956 csv_path.to_str().unwrap(),
957 &akar_common::file_system::VirtualFileSystemRegistry::new(),
958 &test_schema(),
959 &config,
960 )
961 .unwrap();
962 assert_eq!(rows.len(), 1);
963 assert_eq!(rows[0][0], Value::String("Grace".into()));
964 assert_eq!(rows[0][1], Value::Null);
965 assert_eq!(rows[0][2], Value::Null);
966 assert_eq!(rows[0][3], Value::Null);
967 }
968
969 #[test]
970 fn test_read_csv_custom_null_str() {
971 let dir = tempfile::tempdir().unwrap();
972 let csv_path = dir.path().join("nulls.csv");
973 std::fs::write(&csv_path, "name,age\nNULL,5\n\"\",7\n").unwrap();
974
975 let config = CsvReaderConfig {
976 null_str: "NULL".into(),
977 ..Default::default()
978 };
979
980 let rows = read_csv(
981 csv_path.to_str().unwrap(),
982 &akar_common::file_system::VirtualFileSystemRegistry::new(),
983 &test_schema()[..2],
984 &config,
985 )
986 .unwrap();
987 assert_eq!(rows.len(), 2);
988 assert_eq!(rows[0][0], Value::Null);
989 assert_eq!(rows[0][1], Value::Int64(5));
990 assert_eq!(rows[1][0], Value::String(String::new()));
991 assert_eq!(rows[1][1], Value::Int64(7));
992 }
993
994 #[test]
995 fn test_read_csv_custom_null_str_nested() {
996 let dir = tempfile::tempdir().unwrap();
997 let csv_path = dir.path().join("nested.csv");
998 std::fs::write(&csv_path, "tags,props\n\"[NULL, NA, x]\",\"{a=NA, b=x}\"\n").unwrap();
999
1000 let config = CsvReaderConfig {
1001 null_str: "NA".into(),
1002 ..Default::default()
1003 };
1004
1005 let schema = vec![
1006 CatalogColumn {
1007 compression: akar_common::enums::CompressionType::Uncompressed,
1008 name: "tags".into(),
1009 logical_type: LogicalTypeID::List,
1010 is_primary_key: false,
1011 default_value: None,
1012 },
1013 CatalogColumn {
1014 compression: akar_common::enums::CompressionType::Uncompressed,
1015 name: "props".into(),
1016 logical_type: LogicalTypeID::Map,
1017 is_primary_key: false,
1018 default_value: None,
1019 },
1020 ];
1021
1022 let rows = read_csv(
1023 csv_path.to_str().unwrap(),
1024 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1025 &schema,
1026 &config,
1027 )
1028 .unwrap();
1029 assert_eq!(rows.len(), 1);
1030 assert_eq!(
1031 rows[0][0],
1032 Value::List(vec![Value::Null, Value::Null, Value::String("x".into())])
1033 );
1034 assert_eq!(
1035 rows[0][1],
1036 Value::Map(vec![
1037 (Value::String("a".into()), Value::Null),
1038 (Value::String("b".into()), Value::String("x".into())),
1039 ])
1040 );
1041 }
1042
1043 #[test]
1044 fn test_read_csv_column_count_mismatch() {
1045 let dir = tempfile::tempdir().unwrap();
1046 let csv_path = dir.path().join("bad_cols.csv");
1047 std::fs::write(&csv_path, "name,age,score,active\nAlice,30\n").unwrap();
1048
1049 let config = CsvReaderConfig::default();
1050 let result = read_csv(
1051 csv_path.to_str().unwrap(),
1052 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1053 &test_schema(),
1054 &config,
1055 );
1056 assert!(result.is_err());
1057 match result.unwrap_err() {
1058 CsvReaderError::ColumnCountMismatch { line, expected, actual } => {
1059 assert_eq!(line, 2);
1060 assert_eq!(expected, 4);
1061 assert_eq!(actual, 2);
1062 }
1063 _ => panic!("Expected ColumnCountMismatch"),
1064 }
1065 }
1066
1067 #[test]
1068 fn test_read_csv_type_coercion_error() {
1069 let dir = tempfile::tempdir().unwrap();
1070 let csv_path = dir.path().join("bad_type.csv");
1071 std::fs::write(&csv_path, "name,age,score,active\nAlice,not_a_number,95.5,true\n").unwrap();
1072
1073 let config = CsvReaderConfig::default();
1074 let result = read_csv(
1075 csv_path.to_str().unwrap(),
1076 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1077 &test_schema(),
1078 &config,
1079 );
1080 assert!(result.is_err());
1081 match result.unwrap_err() {
1082 CsvReaderError::TypeCoercion { line, column, .. } => {
1083 assert_eq!(line, 2);
1084 assert_eq!(column, 1);
1085 }
1086 _ => panic!("Expected TypeCoercion"),
1087 }
1088 }
1089
1090 #[test]
1091 fn test_read_csv_file_not_found() {
1092 let config = CsvReaderConfig::default();
1093 let result = read_csv(
1094 "nonexistent.csv",
1095 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1096 &test_schema(),
1097 &config,
1098 );
1099 assert!(result.is_err());
1100 match result.unwrap_err() {
1101 CsvReaderError::IoError(_) => {} _ => panic!("Expected IoError"),
1103 }
1104 }
1105
1106 #[test]
1107 fn test_read_csv_dates_and_timestamps() {
1108 let dir = tempfile::tempdir().unwrap();
1109 let csv_path = dir.path().join("dates.csv");
1110 std::fs::write(
1111 &csv_path,
1112 "name,birth,updated\nAlice,1990-05-15,2024-01-20 14:30:00.123456\n",
1113 )
1114 .unwrap();
1115
1116 let schema = vec![
1117 CatalogColumn {
1118 compression: akar_common::enums::CompressionType::Uncompressed,
1119 name: "name".into(),
1120 logical_type: LogicalTypeID::String,
1121 is_primary_key: false,
1122 default_value: None,
1123 },
1124 CatalogColumn {
1125 compression: akar_common::enums::CompressionType::Uncompressed,
1126 name: "birth".into(),
1127 logical_type: LogicalTypeID::Date,
1128 is_primary_key: false,
1129 default_value: None,
1130 },
1131 CatalogColumn {
1132 compression: akar_common::enums::CompressionType::Uncompressed,
1133 name: "updated".into(),
1134 logical_type: LogicalTypeID::Timestamp,
1135 is_primary_key: false,
1136 default_value: None,
1137 },
1138 ];
1139
1140 let config = CsvReaderConfig::default();
1141 let rows = read_csv(
1142 csv_path.to_str().unwrap(),
1143 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1144 &schema,
1145 &config,
1146 )
1147 .unwrap();
1148 assert_eq!(rows.len(), 1);
1149 assert_eq!(rows[0][0], Value::String("Alice".into()));
1150 if let Value::Date(d) = &rows[0][1] {
1152 assert_eq!(d.days_since_epoch(), 7439); } else {
1154 panic!("Expected Date");
1155 }
1156 if let Value::Timestamp(ts) = &rows[0][2] {
1157 assert!(ts.micros_since_epoch() > 0);
1159 } else {
1160 panic!("Expected Timestamp");
1161 }
1162 }
1163
1164 #[test]
1165 fn test_read_csv_interval() {
1166 let dir = tempfile::tempdir().unwrap();
1167 let csv_path = dir.path().join("intervals.csv");
1168 std::fs::write(
1169 &csv_path,
1170 "name,duration\nTask1,1 year 2 months 3 days 4 hours 5 minutes 6 seconds\n",
1171 )
1172 .unwrap();
1173
1174 let schema = vec![
1175 CatalogColumn {
1176 compression: akar_common::enums::CompressionType::Uncompressed,
1177 name: "name".into(),
1178 logical_type: LogicalTypeID::String,
1179 is_primary_key: false,
1180 default_value: None,
1181 },
1182 CatalogColumn {
1183 compression: akar_common::enums::CompressionType::Uncompressed,
1184 name: "duration".into(),
1185 logical_type: LogicalTypeID::Interval,
1186 is_primary_key: false,
1187 default_value: None,
1188 },
1189 ];
1190
1191 let config = CsvReaderConfig::default();
1192 let rows = read_csv(
1193 csv_path.to_str().unwrap(),
1194 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1195 &schema,
1196 &config,
1197 )
1198 .unwrap();
1199 assert_eq!(rows.len(), 1);
1200 if let Value::Interval(iv) = &rows[0][1] {
1201 assert_eq!(iv.months, 14);
1203 assert_eq!(iv.days, 3);
1204 assert_eq!(iv.micros, 14_706_000_000);
1206 } else {
1207 panic!("Expected Interval");
1208 }
1209 }
1210
1211 #[test]
1212 fn test_read_csv_list_and_struct() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let csv_path = dir.path().join("complex.csv");
1215 std::fs::write(
1216 &csv_path,
1217 "name,tags,metadata\nItem1,\"[a,b,c]\",\"{key1: val1, key2: val2}\"\n",
1218 )
1219 .unwrap();
1220
1221 let schema = vec![
1222 CatalogColumn {
1223 compression: akar_common::enums::CompressionType::Uncompressed,
1224 name: "name".into(),
1225 logical_type: LogicalTypeID::String,
1226 is_primary_key: false,
1227 default_value: None,
1228 },
1229 CatalogColumn {
1230 compression: akar_common::enums::CompressionType::Uncompressed,
1231 name: "tags".into(),
1232 logical_type: LogicalTypeID::List,
1233 is_primary_key: false,
1234 default_value: None,
1235 },
1236 CatalogColumn {
1237 compression: akar_common::enums::CompressionType::Uncompressed,
1238 name: "metadata".into(),
1239 logical_type: LogicalTypeID::Struct,
1240 is_primary_key: false,
1241 default_value: None,
1242 },
1243 ];
1244
1245 let config = CsvReaderConfig::default();
1246 let rows = read_csv(
1247 csv_path.to_str().unwrap(),
1248 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1249 &schema,
1250 &config,
1251 )
1252 .unwrap();
1253 assert_eq!(rows.len(), 1);
1254 assert_eq!(rows[0][0], Value::String("Item1".into()));
1255 if let Value::List(items) = &rows[0][1] {
1256 assert_eq!(items.len(), 3);
1257 } else {
1258 panic!("Expected List");
1259 }
1260 if let Value::Struct(fields) = &rows[0][2] {
1261 assert_eq!(fields.len(), 2);
1262 assert_eq!(fields[0].0, "key1");
1263 } else {
1264 panic!("Expected Struct");
1265 }
1266 }
1267
1268 #[test]
1269 fn test_read_csv_blob() {
1270 let dir = tempfile::tempdir().unwrap();
1271 let csv_path = dir.path().join("blobs.csv");
1272 std::fs::write(&csv_path, "name,data\nBlob1,\\xAA\\xBB\\xCC\\xDD\n").unwrap();
1273
1274 let schema = vec![
1275 CatalogColumn {
1276 compression: akar_common::enums::CompressionType::Uncompressed,
1277 name: "name".into(),
1278 logical_type: LogicalTypeID::String,
1279 is_primary_key: false,
1280 default_value: None,
1281 },
1282 CatalogColumn {
1283 compression: akar_common::enums::CompressionType::Uncompressed,
1284 name: "data".into(),
1285 logical_type: LogicalTypeID::Blob,
1286 is_primary_key: false,
1287 default_value: None,
1288 },
1289 ];
1290
1291 let config = CsvReaderConfig::default();
1292 let rows = read_csv(
1293 csv_path.to_str().unwrap(),
1294 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1295 &schema,
1296 &config,
1297 )
1298 .unwrap();
1299 assert_eq!(rows.len(), 1);
1300 if let Value::Blob(bytes) = &rows[0][1] {
1301 assert_eq!(bytes, &[0xAA, 0xBB, 0xCC, 0xDD]);
1302 } else {
1303 panic!("Expected Blob");
1304 }
1305 }
1306
1307 #[test]
1308 fn test_read_csv_uint_types() {
1309 let dir = tempfile::tempdir().unwrap();
1310 let csv_path = dir.path().join("uints.csv");
1311 std::fs::write(&csv_path, "small,medium,large\n100,1000,100000\n").unwrap();
1312
1313 let schema = vec![
1314 CatalogColumn {
1315 compression: akar_common::enums::CompressionType::Uncompressed,
1316 name: "small".into(),
1317 logical_type: LogicalTypeID::UInt8,
1318 is_primary_key: false,
1319 default_value: None,
1320 },
1321 CatalogColumn {
1322 compression: akar_common::enums::CompressionType::Uncompressed,
1323 name: "medium".into(),
1324 logical_type: LogicalTypeID::UInt32,
1325 is_primary_key: false,
1326 default_value: None,
1327 },
1328 CatalogColumn {
1329 compression: akar_common::enums::CompressionType::Uncompressed,
1330 name: "large".into(),
1331 logical_type: LogicalTypeID::UInt64,
1332 is_primary_key: false,
1333 default_value: None,
1334 },
1335 ];
1336
1337 let config = CsvReaderConfig::default();
1338 let rows = read_csv(
1339 csv_path.to_str().unwrap(),
1340 &akar_common::file_system::VirtualFileSystemRegistry::new(),
1341 &schema,
1342 &config,
1343 )
1344 .unwrap();
1345 assert_eq!(rows[0][0], Value::UInt8(100));
1346 assert_eq!(rows[0][1], Value::UInt32(1000));
1347 assert_eq!(rows[0][2], Value::UInt64(100000));
1348 }
1349
1350 #[test]
1351 fn test_config_from_options() {
1352 let mut opts = HashMap::new();
1353 opts.insert("HEADER".into(), "false".into());
1354 opts.insert("DELIM".into(), "|".into());
1355 opts.insert("QUOTE".into(), "'".into());
1356 opts.insert("ESCAPE".into(), "`".into());
1357 opts.insert("NULL".into(), "NA".into());
1358
1359 let config = CsvReaderConfig::from_options(&opts);
1360 assert!(!config.has_header);
1361 assert_eq!(config.delimiter, b'|');
1362 assert_eq!(config.quote, b'\'');
1363 assert_eq!(config.escape, b'`');
1364 assert_eq!(config.null_str, "NA");
1365 }
1366
1367 #[test]
1368 fn test_parse_blob_mixed() {
1369 let blob = parse_blob("Hello\\x20World");
1370 assert_eq!(blob, b"Hello World");
1371 }
1372
1373 #[test]
1374 fn test_split_top_level_nested() {
1375 let result = split_top_level("{a: 1, b: {c: 2}}, {d: 3}", ',');
1376 assert_eq!(result.len(), 2);
1377 assert_eq!(result[0], "{a: 1, b: {c: 2}}");
1378 assert_eq!(result[1], "{d: 3}");
1379 }
1380
1381 #[test]
1382 fn test_naive_date_to_epoch_days_known() {
1383 assert_eq!(naive_date_to_epoch_days(1970, 1, 1), Some(0));
1385 assert_eq!(naive_date_to_epoch_days(2024, 1, 1), Some(19723));
1387 assert_eq!(naive_date_to_epoch_days(1990, 5, 15), Some(7439));
1389 }
1390}