Skip to main content

servers/postgres/copy_in/
csv.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The `csv` format — currently the only implemented COPY FROM STDIN
16//! format.
17//!
18//! Parsing and typed conversion both reuse arrow-csv's incremental
19//! [`Decoder`](arrow::csv::reader::Decoder) — the same engine backing
20//! GreptimeDB's file-based `COPY ... FROM 'file.csv'` (see
21//! `common-datasource`'s `csv` file format) — so both COPY routes accept
22//! identical data. The decoder splits records (quoting, escaped quotes,
23//! custom delimiters, `\n`/`\r\n` separators, optional headers) and
24//! converts fields against the targeted columns' schema in one pass.
25//!
26//! Consequences of sharing the engine: JSON and Binary columns are
27//! rejected, timestamps must be date/time strings (offset-free ones
28//! parse in the session timezone), and an empty field — quoted or not —
29//! is NULL, exactly like file-based COPY.
30
31use std::sync::Arc;
32
33use api::helper::vectors_to_rows;
34use arrow::csv::ReaderBuilder;
35use arrow::csv::reader::Decoder as CsvDecoder;
36use arrow_schema::{ArrowError, DataType as ArrowDataType, Field, Schema as ArrowSchema};
37use datatypes::data_type::DataType;
38use datatypes::prelude::ConcreteDataType;
39use datatypes::schema::{ColumnSchema, Schema as GreptimeSchema};
40use datatypes::vectors::{BooleanVector, Helper, StringVector, Vector};
41use pgwire::error::{PgWireError, PgWireResult};
42
43use crate::postgres::copy_in::codec::{CopyFormat, ParsedRow, RecordParser};
44use crate::postgres::copy_in::options::CopyOptionSet;
45use crate::postgres::copy_in::{COPY_IN_FLUSH_ROWS, bad_copy_data, invalid_text_repr, unsupported};
46
47/// The validated `csv` format configuration.
48#[derive(Debug, Clone)]
49pub(super) struct CsvCodec {
50    delimiter: u8,
51    quote: u8,
52    escape: u8,
53    header: bool,
54}
55
56/// Builds the CSV codec from the parsed options.
57pub(super) fn build_csv_codec(mut options: CopyOptionSet) -> PgWireResult<CopyFormat> {
58    let delimiter = options.take_single_byte("DELIMITER")?.unwrap_or(b',');
59    let quote = options.take_single_byte("QUOTE")?.unwrap_or(b'"');
60    // In PostgreSQL the default escape character for CSV is the quote
61    // character itself (doubled quotes inside quoted fields).
62    let escape = options.take_single_byte("ESCAPE")?.unwrap_or(quote);
63    // An empty string is the default NULL marker (an empty field is NULL);
64    // a custom NULL string is not supported because arrow-csv's null
65    // matching does not distinguish quoted from unquoted fields.
66    if let Some(null) = options.take_string("NULL")?
67        && !null.is_empty()
68    {
69        return Err(unsupported(
70            "custom NULL string is not supported by format csv; an empty field is NULL",
71        ));
72    }
73    let header = options.take_flag("HEADER")?.unwrap_or(false);
74    options.reject_leftovers("csv")?;
75    if delimiter == quote {
76        return Err(bad_copy_data("DELIMITER must not be equal to QUOTE"));
77    }
78    Ok(CopyFormat::Csv(CsvCodec {
79        delimiter,
80        quote,
81        escape,
82        header,
83    }))
84}
85
86impl CsvCodec {
87    /// The column types arrow-csv can decode against.
88    pub(super) fn check_column_type(&self, data_type: &ConcreteDataType) -> PgWireResult<()> {
89        let supported = matches!(
90            data_type,
91            ConcreteDataType::Boolean(_)
92                | ConcreteDataType::Int8(_)
93                | ConcreteDataType::Int16(_)
94                | ConcreteDataType::Int32(_)
95                | ConcreteDataType::Int64(_)
96                | ConcreteDataType::UInt8(_)
97                | ConcreteDataType::UInt16(_)
98                | ConcreteDataType::UInt32(_)
99                | ConcreteDataType::UInt64(_)
100                | ConcreteDataType::Float32(_)
101                | ConcreteDataType::Float64(_)
102                | ConcreteDataType::String(_)
103                | ConcreteDataType::Date(_)
104                | ConcreteDataType::Timestamp(_)
105                | ConcreteDataType::Decimal128(_)
106        );
107        if !supported {
108            return Err(unsupported(format!(
109                "column type \"{}\" is not supported by format csv",
110                data_type.name()
111            )));
112        }
113        Ok(())
114    }
115
116    pub(super) fn create_parser(
117        &self,
118        columns: &[ColumnSchema],
119        timezone: &str,
120    ) -> PgWireResult<Box<dyn RecordParser + Send>> {
121        // Decode schema: the greptime schema's arrow projection, except
122        // Boolean columns decode as Utf8 — arrow-csv only accepts
123        // `true`/`false`, while PostgreSQL COPY IN accepts its full set
124        // of boolean spellings (`t`, `f`, `yes`, `on`, ...), which COPY
125        // OUT emits — and Timestamp fields carry the SESSION timezone so
126        // offset-free strings parse like SQL INSERT does instead of as
127        // UTC. The substitutions are applied in `flush_batch` and by the
128        // decoder's tz-aware parsing respectively; explicit offsets in
129        // the input always win.
130        let boolean_columns: Vec<usize> = columns
131            .iter()
132            .enumerate()
133            .filter(|(_, c)| matches!(c.data_type, ConcreteDataType::Boolean(_)))
134            .map(|(i, _)| i)
135            .collect();
136        let arrow_schema = GreptimeSchema::try_new(columns.to_vec())
137            .map_err(|e| unsupported(format!("invalid columns for format csv: {e}")))?
138            .arrow_schema()
139            .clone();
140        let timezone = timezone.to_string();
141        let fields = arrow_schema
142            .fields()
143            .iter()
144            .zip(columns)
145            .map(|(field, column)| match &column.data_type {
146                ConcreteDataType::Boolean(_) => {
147                    Field::new(field.name(), ArrowDataType::Utf8, field.is_nullable())
148                }
149                ConcreteDataType::Timestamp(_) => match field.data_type() {
150                    ArrowDataType::Timestamp(unit, None) => Field::new(
151                        field.name(),
152                        ArrowDataType::Timestamp(*unit, Some(Arc::from(timezone.as_str()))),
153                        field.is_nullable(),
154                    ),
155                    _ => field.as_ref().clone(),
156                },
157                _ => field.as_ref().clone(),
158            })
159            .collect::<Vec<_>>();
160        let mut builder = ReaderBuilder::new(Arc::new(ArrowSchema::new(fields)))
161            .with_header(self.header)
162            .with_delimiter(self.delimiter)
163            .with_quote(self.quote)
164            .with_batch_size(COPY_IN_FLUSH_ROWS);
165        // An escape character equal to the quote is the doubled-quote
166        // default; csv-core handles that through quoting alone.
167        if self.escape != self.quote {
168            builder = builder.with_escape(self.escape);
169        }
170        Ok(Box::new(CsvRecordParser {
171            decoder: builder.build_decoder(),
172            boolean_columns,
173        }))
174    }
175}
176
177/// Incremental parser backed by arrow-csv's decoder.
178struct CsvRecordParser {
179    decoder: CsvDecoder,
180    /// Indices of Boolean columns, decoded as Utf8 and converted by
181    /// [`flush_batch`] to accept PostgreSQL's boolean spellings.
182    boolean_columns: Vec<usize>,
183}
184
185impl RecordParser for CsvRecordParser {
186    fn feed(&mut self, data: &[u8]) -> PgWireResult<Vec<ParsedRow>> {
187        let mut buf = data;
188        let mut rows = Vec::new();
189        while !buf.is_empty() {
190            let consumed = self.decoder.decode(buf).map_err(decode_error)?;
191            if consumed == 0 {
192                while let Some(batch) = self.decoder.flush().map_err(decode_error)? {
193                    rows.extend(self.flush_batch(&batch)?);
194                }
195            } else {
196                buf = &buf[consumed..];
197            }
198        }
199        Ok(rows)
200    }
201
202    fn finish(&mut self) -> PgWireResult<Vec<ParsedRow>> {
203        // An empty decode signals EOF, finalizing a trailing record that
204        // lacks its record separator.
205        self.decoder.decode(&[]).map_err(decode_error)?;
206        let mut rows = Vec::new();
207        while let Some(batch) = self.decoder.flush().map_err(decode_error)? {
208            rows.extend(self.flush_batch(&batch)?);
209        }
210        Ok(rows)
211    }
212}
213
214impl CsvRecordParser {
215    fn flush_batch(
216        &mut self,
217        batch: &arrow::record_batch::RecordBatch,
218    ) -> PgWireResult<Vec<ParsedRow>> {
219        let mut vectors = Helper::try_into_vectors(batch.columns())
220            .map_err(|e| bad_copy_data(format!("failed to convert decoded CSV batch: {e}")))?;
221        for &index in &self.boolean_columns {
222            let Some(vector) = vectors[index].as_any().downcast_ref::<StringVector>() else {
223                return Err(bad_copy_data(
224                    "expected boolean column to decode as a string vector",
225                ));
226            };
227            let mut values = Vec::with_capacity(batch.num_rows());
228            for row_index in 0..batch.num_rows() {
229                values.push(match vector.get(row_index) {
230                    datatypes::value::Value::Null => None,
231                    datatypes::value::Value::String(s) => Some(parse_pg_bool(s.as_utf8())?),
232                    other => {
233                        return Err(bad_copy_data(format!(
234                            "expected string values in a boolean column, got {other:?}"
235                        )));
236                    }
237                });
238            }
239            vectors[index] = Arc::new(BooleanVector::from(values));
240        }
241        Ok(vectors_to_rows(vectors.iter(), batch.num_rows()))
242    }
243}
244
245/// Parses PostgreSQL's boolean spellings: `true`/`false`, `t`/`f`,
246/// `yes`/`no`, `on`/`off` and `1`/`0`, case-insensitive with surrounding
247/// whitespace allowed (see the BOOLEAN type's input grammar). COPY OUT
248/// emits `t`/`f`, so accepting them keeps both COPY directions
249/// round-trippable.
250fn parse_pg_bool(input: &str) -> PgWireResult<bool> {
251    match input.trim().to_ascii_lowercase().as_str() {
252        "true" | "t" | "yes" | "y" | "on" | "1" => Ok(true),
253        "false" | "f" | "no" | "n" | "off" | "0" => Ok(false),
254        _ => Err(invalid_text_repr(format!(
255            "invalid input syntax for type boolean: \"{input}\""
256        ))),
257    }
258}
259
260fn decode_error(error: ArrowError) -> PgWireError {
261    match &error {
262        ArrowError::CsvError(message) => {
263            bad_copy_data(format!("failed to parse CSV data: {message}"))
264        }
265        ArrowError::ParseError(message) | ArrowError::CastError(message) => {
266            invalid_text_repr(format!("failed to parse CSV data: {message}"))
267        }
268        _ => bad_copy_data(format!("failed to parse CSV data: {error}")),
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use datatypes::schema::ColumnSchema;
275
276    use super::*;
277    use crate::postgres::copy_in::options::CopyOptionValue;
278
279    fn test_columns() -> Vec<ColumnSchema> {
280        vec![
281            ColumnSchema::new("host", ConcreteDataType::string_datatype(), true),
282            ColumnSchema::new("val", ConcreteDataType::float64_datatype(), true),
283            ColumnSchema::new(
284                "ts",
285                ConcreteDataType::timestamp_millisecond_datatype(),
286                false,
287            ),
288        ]
289    }
290
291    fn build_codec(set: CopyOptionSet) -> CsvCodec {
292        match build_csv_codec(set).unwrap() {
293            CopyFormat::Csv(codec) => codec,
294            other => panic!("expected csv format, got {other:?}"),
295        }
296    }
297
298    fn parser() -> Box<dyn RecordParser + Send> {
299        build_codec(CopyOptionSet::default())
300            .create_parser(&test_columns(), "UTC")
301            .unwrap()
302    }
303
304    fn string_at(row: &ParsedRow, index: usize) -> Option<String> {
305        row.values[index].value_data.as_ref().map(|v| match v {
306            api::v1::value::ValueData::StringValue(s) => s.clone(),
307            other => panic!("expected string, got {other:?}"),
308        })
309    }
310
311    fn rows_with(mut set: CopyOptionSet) -> Box<dyn RecordParser + Send> {
312        set.take_format().unwrap();
313        build_codec(set)
314            .create_parser(&test_columns(), "UTC")
315            .unwrap()
316    }
317
318    /// Feeds chunks and then the EOF signal, returning all rows: with the
319    /// strict decoder protocol, feeds only emit full
320    /// `COPY_IN_FLUSH_ROWS` batches; a trailing partial batch surfaces at
321    /// `finish`.
322    fn feed_all(parser: &mut Box<dyn RecordParser + Send>, chunks: &[&[u8]]) -> Vec<ParsedRow> {
323        let mut rows = Vec::new();
324        for chunk in chunks {
325            rows.extend(parser.feed(chunk).unwrap());
326        }
327        rows.extend(parser.finish().unwrap());
328        rows
329    }
330
331    #[test]
332    fn test_build_defaults() {
333        let codec = build_codec(CopyOptionSet::default());
334        assert!(matches!(codec, CsvCodec { .. }));
335    }
336
337    #[test]
338    fn test_build_rejects_bad_options() {
339        let mut set = CopyOptionSet::default();
340        set.insert("delimiter".into(), CopyOptionValue::String("\"".into()))
341            .unwrap();
342        let err = build_csv_codec(set).unwrap_err();
343        assert!(
344            err.to_string()
345                .contains("DELIMITER must not be equal to QUOTE")
346        );
347
348        let mut set = CopyOptionSet::default();
349        set.insert("null".into(), CopyOptionValue::String("NULL".into()))
350            .unwrap();
351        assert!(
352            build_csv_codec(set)
353                .unwrap_err()
354                .to_string()
355                .contains("custom NULL string")
356        );
357
358        let mut set = CopyOptionSet::default();
359        set.take_format().unwrap();
360        set.insert("precision".into(), CopyOptionValue::Ident("ns".into()))
361            .unwrap();
362        assert!(
363            build_csv_codec(set)
364                .unwrap_err()
365                .to_string()
366                .contains("not supported by format csv")
367        );
368    }
369
370    #[test]
371    fn test_check_column_type() {
372        let codec = build_codec(CopyOptionSet::default());
373        assert!(
374            codec
375                .check_column_type(&ConcreteDataType::json_datatype())
376                .is_err()
377        );
378        assert!(
379            codec
380                .check_column_type(&ConcreteDataType::binary_datatype())
381                .is_err()
382        );
383        assert!(
384            codec
385                .check_column_type(&ConcreteDataType::string_datatype())
386                .is_ok()
387        );
388    }
389
390    #[test]
391    fn test_csv_parse_records() {
392        let mut parser = parser();
393        let rows = feed_all(
394            &mut parser,
395            &[b"host1,1.5,2023-11-14 22:13:20\n\
396                ,2.5,2023-11-14 22:13:21\n\
397                \"quoted, comma\",3.5,2023-11-14 22:13:22\n"],
398        );
399        assert_eq!(rows.len(), 3);
400        assert_eq!(string_at(&rows[0], 0), Some("host1".into()));
401        // An empty field is NULL.
402        assert_eq!(string_at(&rows[1], 0), None);
403        assert_eq!(string_at(&rows[2], 0), Some("quoted, comma".into()));
404    }
405
406    #[test]
407    fn test_csv_escaped_quotes() {
408        let mut parser = parser();
409        let rows = feed_all(
410            &mut parser,
411            &[b"\"say \"\"hi\"\"\",1.0,2023-11-14 22:13:20\n"],
412        );
413        assert_eq!(string_at(&rows[0], 0), Some("say \"hi\"".into()));
414    }
415
416    #[test]
417    fn test_csv_custom_escape() {
418        let mut set = CopyOptionSet::default();
419        set.insert("escape".into(), CopyOptionValue::String("\\".into()))
420            .unwrap();
421        let mut parser = rows_with(set);
422        let rows = feed_all(&mut parser, &[b"\"a\\\"b\",1.0,2023-11-14 22:13:20\n"]);
423        assert_eq!(string_at(&rows[0], 0), Some("a\"b".into()));
424    }
425
426    #[test]
427    fn test_csv_quoted_newline_and_chunk_boundaries() {
428        let mut parser = parser();
429        // Split inside a quoted field containing a newline, and a CRLF
430        // record separator split across chunks. No batch is full, so all
431        // feeds return nothing and both rows surface at the EOF signal.
432        assert!(parser.feed(b"\"multi").unwrap().is_empty());
433        assert!(
434            parser
435                .feed(b"line\nfield\",1.0,2023-11-14 22:13:20")
436                .unwrap()
437                .is_empty()
438        );
439        assert!(
440            parser
441                .feed(b"\r\nhost2,2.0,2023-11-14 22:13:21\n")
442                .unwrap()
443                .is_empty()
444        );
445        let rows = parser.finish().unwrap();
446        assert_eq!(rows.len(), 2);
447        assert_eq!(string_at(&rows[0], 0), Some("multiline\nfield".into()));
448        assert_eq!(string_at(&rows[1], 0), Some("host2".into()));
449    }
450
451    #[test]
452    fn test_csv_header() {
453        let mut set = CopyOptionSet::default();
454        set.insert("header".into(), CopyOptionValue::Flag).unwrap();
455        let mut parser = rows_with(set);
456        let rows = feed_all(
457            &mut parser,
458            &[b"host,val,ts\nhost1,1.0,2023-11-14 22:13:20\n"],
459        );
460        assert_eq!(rows.len(), 1);
461        assert_eq!(string_at(&rows[0], 0), Some("host1".into()));
462    }
463
464    #[test]
465    fn test_csv_trailing_record_without_newline() {
466        let mut parser = parser();
467        let fed = parser
468            .feed(b"host1,1.0,2023-11-14 22:13:20\nhost2,2.0,2023-11-14 22:13:21")
469            .unwrap();
470        // While the trailing record is pending, flush cannot emit the
471        // completed one either; both surface at the EOF signal.
472        assert!(fed.is_empty());
473        let rows = parser.finish().unwrap();
474        assert_eq!(rows.len(), 2);
475        assert_eq!(string_at(&rows[0], 0), Some("host1".into()));
476        assert_eq!(string_at(&rows[1], 0), Some("host2".into()));
477    }
478
479    #[test]
480    fn test_csv_finish_at_record_boundary() {
481        let mut parser = parser();
482        // The complete record stays buffered (no full batch); the feed
483        // emits nothing and `finish` surfaces it.
484        assert!(
485            parser
486                .feed(b"host1,1.0,2023-11-14 22:13:20\n")
487                .unwrap()
488                .is_empty()
489        );
490        let rows = parser.finish().unwrap();
491        assert_eq!(rows.len(), 1);
492        assert_eq!(string_at(&rows[0], 0), Some("host1".into()));
493    }
494
495    /// Regression test for chunk-boundary corruption: CopyData chunking is
496    /// arbitrary (records may split mid-field), and an earlier feed loop
497    /// flushed partial batches at record-boundary chunk ends, which
498    /// arrow-csv does not permit mid-stream — fields then parse
499    /// ("00001h,ost-001u,..."). Feed the same payload at many chunk sizes
500    /// and verify EVERY cell.
501    #[test]
502    fn test_csv_chunked_feeds_are_alignment_safe() {
503        // (int32 id, timestamp) rows of near-equal length.
504        let sweep_columns = vec![
505            ColumnSchema::new("i", ConcreteDataType::int32_datatype(), true),
506            ColumnSchema::new(
507                "ts",
508                ConcreteDataType::timestamp_millisecond_datatype(),
509                false,
510            ),
511        ];
512        // A payload wider than one decoder batch so batch-full flushes
513        // interleave with chunk boundaries too.
514        let total = 2 * COPY_IN_FLUSH_ROWS + 137;
515        let payload: Vec<u8> = (0..total)
516            .map(|i| {
517                format!(
518                    "{:06},2024-01-01 00:00:{:02}.{:03}\n",
519                    i,
520                    (i / 1000) % 60,
521                    i % 1000
522                )
523            })
524            .collect::<String>()
525            .into_bytes();
526
527        let check = |chunk: usize| {
528            let mut parser = build_codec(CopyOptionSet::default())
529                .create_parser(&sweep_columns, "UTC")
530                .unwrap();
531            let mut ids: Vec<i32> = Vec::new();
532            for c in payload.chunks(chunk) {
533                for row in parser.feed(c).unwrap() {
534                    ids.push(match row.values[0].value_data.as_ref().unwrap() {
535                        api::v1::value::ValueData::I32Value(v) => *v,
536                        other => panic!("expected i32, got {other:?}"),
537                    });
538                }
539            }
540            for row in parser.finish().unwrap() {
541                ids.push(match row.values[0].value_data.as_ref().unwrap() {
542                    api::v1::value::ValueData::I32Value(v) => *v,
543                    other => panic!("expected i32, got {other:?}"),
544                });
545            }
546            assert_eq!(ids.len(), total, "chunk={chunk}");
547            for (i, v) in ids.iter().enumerate() {
548                assert_eq!(*v, i as i32, "chunk={chunk} row {i} corrupted");
549            }
550        };
551
552        // Small chunk sizes hit every possible boundary alignment,
553        // including multiple decode calls per record.
554        for chunk in [1usize, 2, 3, 7, 8, 13, 31, 64, 97, 128, 1024] {
555            check(chunk);
556        }
557        // Client-natural sizes with a shifted start to vary where
558        // boundaries land relative to records.
559        for chunk in [4096usize, 8192, 65531] {
560            check(chunk);
561            let mut shifted = payload.clone();
562            shifted.drain(..37);
563            let mut parser = build_codec(CopyOptionSet::default())
564                .create_parser(&sweep_columns, "UTC")
565                .unwrap();
566            let mut count = 0usize;
567            for c in shifted.chunks(chunk) {
568                count += parser.feed(c).unwrap().len();
569            }
570            count += parser.finish().unwrap().len();
571            assert_eq!(count, total - 1, "chunk={chunk} shifted");
572        }
573    }
574
575    #[test]
576    fn test_csv_boolean_spellings() {
577        // PostgreSQL COPY OUT emits `t`/`f`; COPY IN accepts the full
578        // boolean input grammar.
579        let columns = vec![
580            ColumnSchema::new("flag", ConcreteDataType::boolean_datatype(), true),
581            ColumnSchema::new(
582                "ts",
583                ConcreteDataType::timestamp_millisecond_datatype(),
584                false,
585            ),
586        ];
587        let mut parser = build_codec(CopyOptionSet::default())
588            .create_parser(&columns, "UTC")
589            .unwrap();
590        let rows = feed_all(
591            &mut parser,
592            &[b"t,2024-01-01 00:00:00\n\
593                 f,2024-01-01 00:00:01\n\
594                 TRUE,2024-01-01 00:00:02\n\
595                 no,2024-01-01 00:00:03\n\
596                 on,2024-01-01 00:00:04\n\
597                 0,2024-01-01 00:00:05\n\
598                 ,2024-01-01 00:00:06\n"],
599        );
600        let expected = [
601            Some(true),
602            Some(false),
603            Some(true),
604            Some(false),
605            Some(true),
606            Some(false),
607            None,
608        ];
609        for (row, want) in rows.iter().zip(expected) {
610            assert_eq!(
611                row.values[0].value_data,
612                want.map(api::v1::value::ValueData::BoolValue),
613                "{:?}",
614                row.values[0].value_data
615            );
616        }
617    }
618
619    #[test]
620    fn test_csv_boolean_rejects_garbage() {
621        let columns = vec![
622            ColumnSchema::new("flag", ConcreteDataType::boolean_datatype(), true),
623            ColumnSchema::new(
624                "ts",
625                ConcreteDataType::timestamp_millisecond_datatype(),
626                false,
627            ),
628        ];
629        let mut parser = build_codec(CopyOptionSet::default())
630            .create_parser(&columns, "UTC")
631            .unwrap();
632        parser.feed(b"maybe,2024-01-01 00:00:00\n").unwrap();
633        let err = match parser.finish() {
634            Ok(_) => panic!("expected parse error"),
635            Err(e) => e,
636        };
637        assert!(
638            err.to_string()
639                .contains("invalid input syntax for type boolean"),
640            "{err}"
641        );
642    }
643
644    #[test]
645    fn test_csv_timestamp_session_timezone() {
646        // Offset-free timestamp strings must parse in the session
647        // timezone (parity with SQL INSERT); explicit offsets win.
648        let columns = vec![
649            ColumnSchema::new("host", ConcreteDataType::string_datatype(), true),
650            ColumnSchema::new(
651                "ts",
652                ConcreteDataType::timestamp_millisecond_datatype(),
653                false,
654            ),
655        ];
656        let mut parser = build_codec(CopyOptionSet::default())
657            .create_parser(&columns, "Asia/Shanghai")
658            .unwrap();
659        let rows = feed_all(
660            &mut parser,
661            &[b"host1,2024-01-01 00:00:00\n\
662                host2,2024-01-01T00:00:00+08:00\n\
663                host3,2024-01-01T00:00:00Z\n"],
664        );
665        let millis = |row: &ParsedRow| match row.values[1].value_data.as_ref().unwrap() {
666            api::v1::value::ValueData::TimestampMillisecondValue(v) => *v,
667            other => panic!("expected ts, got {other:?}"),
668        };
669        // 2024-01-01T00:00:00+08:00 == 2023-12-31T16:00:00Z
670        assert_eq!(millis(&rows[0]), 1704038400000);
671        // Explicit +08:00 offset: same instant as the naive parse.
672        assert_eq!(millis(&rows[1]), 1704038400000);
673        // Explicit Z: parsed as UTC regardless of the session zone.
674        assert_eq!(millis(&rows[2]), 1704067200000);
675    }
676
677    #[test]
678    fn test_csv_errors() {
679        let mut p = parser();
680        let err = match p.feed(b"host1,1.0\n") {
681            Ok(_) => panic!("expected parse error"),
682            Err(e) => e,
683        };
684        assert!(
685            err.to_string().contains("incorrect number of fields"),
686            "{err}"
687        );
688
689        // Value conversion runs at flush time, so the bad double
690        // surfaces at the EOF signal, not at feed time.
691        let mut p = parser();
692        p.feed(b"host1,not_a_double,2023-11-14 22:13:20\n").unwrap();
693        let err = match p.finish() {
694            Ok(_) => panic!("expected parse error"),
695            Err(e) => e,
696        };
697        assert!(err.to_string().contains("failed to parse"), "{err}");
698    }
699}