Skip to main content

servers/postgres/copy_in/
mod.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//! PostgreSQL `COPY ... FROM STDIN` (copy-in) support.
16//!
17//! The wire handshake is driven by pgwire: returning [`Response::CopyIn`]
18//! from a query handler makes the server send a `CopyInResponse` and enter
19//! the copy-in sub-protocol; subsequent `CopyData` messages from the client
20//! are dispatched to [`CopyHandler`], whose implementation below parses
21//! the data stream incrementally and flushes it into the query handler as
22//! [`RowInsertRequests`] batches.
23//!
24//! # Format framework
25//!
26//! Each COPY data format is implemented as a [`codec::CopyInCodec`]: a
27//! stateless strategy that validates its options at statement-parse time,
28//! describes the column types it can ingest, and creates the stateful
29//! record parser for one stream. Formats register a builder under every
30//! accepted `FORMAT` name in [`REGISTERED_FORMATS`] — `csv` is the only
31//! implemented format today; PostgreSQL's `text`/`binary` and
32//! GreptimeDB-specific formats alike can be added the same way. See the
33//! `codec` module docs.
34
35mod codec;
36mod csv;
37mod options;
38mod parse;
39
40use std::fmt::Debug;
41use std::sync::Arc;
42
43use api::helper::ColumnDataTypeWrapper;
44use api::v1::column_def::options_from_column_schema;
45use api::v1::{
46    Row as GrpcRow, RowInsertRequest, RowInsertRequests, Rows as GrpcRows, SemanticType,
47};
48use async_trait::async_trait;
49use codec::{CopyInCodec, RecordParser};
50use common_query::OutputData;
51use common_telemetry::{info, warn};
52use datafusion::sql::sqlparser::ast::{CopyTarget, Statement as SqlParserStatement};
53use datatypes::data_type::DataType;
54use datatypes::schema::{ColumnSchema, SchemaRef};
55use futures::{Sink, SinkExt, stream};
56use pgwire::api::copy::CopyHandler;
57use pgwire::api::results::{CopyResponse, Response, Tag};
58use pgwire::api::{ClientInfo, PgWireConnectionState};
59use pgwire::error::{ErrorInfo, PgWireError, PgWireResult};
60use pgwire::messages::PgWireBackendMessage;
61use pgwire::messages::copy::{CopyData, CopyDone, CopyFail};
62use session::context::QueryContextRef;
63use table::metadata::TableInfo;
64
65use crate::metrics::METRIC_POSTGRES_COPY_IN_ROWS;
66use crate::postgres::PostgresServerHandlerInner;
67use crate::postgres::types::PgErrorCode;
68use crate::postgres::utils::convert_err;
69
70/// Number of parsed rows buffered before flushing a batch to the handler.
71///
72/// Each flush runs the full insert pipeline (catalog lookup, on-demand
73/// schema check, partition split, region write) and, without the
74/// pending-rows batcher, waits for it — so larger batches mean fewer
75/// round trips at the cost of linearly more memory per connection
76/// (decoder batch + rows buffer). The constant doubles as the arrow
77/// decoder's batch size. High-rate COPY users should enable the
78/// pending-rows batcher (`global-table-batch` / `BatcherOptions.protocols`
79/// containing `Postgres`), which turns flushes into enqueues and makes
80/// the per-batch fixed costs negligible.
81pub(crate) const COPY_IN_FLUSH_ROWS: usize = 32768;
82
83/// Builds a codec from the format-neutral option set.
84type CodecBuilder = fn(options::CopyOptionSet) -> PgWireResult<Arc<dyn CopyInCodec>>;
85
86/// The registered COPY FROM STDIN formats: accepted `FORMAT` name to
87/// builder. `csv` is the only implemented format; the others are
88/// registered so they report a clear "not supported yet" instead of an
89/// unknown-format error.
90const REGISTERED_FORMATS: &[(&str, CodecBuilder)] = &[
91    ("csv", csv::build_csv_codec),
92    ("text", build_unimplemented_text_codec),
93    ("txt", build_unimplemented_text_codec),
94    ("binary", build_unimplemented_binary_codec),
95];
96
97fn build_unimplemented_text_codec(
98    _options: options::CopyOptionSet,
99) -> PgWireResult<Arc<dyn CopyInCodec>> {
100    Err(unsupported(
101        "the text format is not supported yet; use WITH (FORMAT csv)",
102    ))
103}
104
105fn build_unimplemented_binary_codec(
106    _options: options::CopyOptionSet,
107) -> PgWireResult<Arc<dyn CopyInCodec>> {
108    Err(unsupported(
109        "COPY FROM STDIN WITH (FORMAT binary) is not supported yet",
110    ))
111}
112
113/// Resolves the format codec from the parsed options.
114fn resolve_codec(options: &mut options::CopyOptionSet) -> PgWireResult<Arc<dyn CopyInCodec>> {
115    let name = options.take_format()?.ok_or_else(|| {
116        unsupported(
117            "COPY FROM STDIN requires an explicit format in this version; use WITH (FORMAT csv)",
118        )
119    })?;
120    let builder = REGISTERED_FORMATS
121        .iter()
122        .find(|(registered, _)| *registered == name)
123        .map(|(_, builder)| *builder)
124        .ok_or_else(|| bad_copy_data(format!("unknown COPY format \"{}\"", name)))?;
125    builder(std::mem::take(options))
126}
127
128/// The parsed `COPY tbl [(columns)] FROM STDIN [(options)]` statement.
129#[derive(Debug, Clone)]
130pub(crate) struct CopyFromStdin {
131    /// Table name parts as written (`t`, `schema.t`, `catalog.schema.t`);
132    /// quoted names keep their case.
133    table: Vec<String>,
134    /// Requested column list; empty means all columns in table order.
135    columns: Vec<String>,
136    /// The resolved format codec, validated and ready to run.
137    codec: Arc<dyn CopyInCodec>,
138}
139
140fn copy_in_error(code: PgErrorCode, message: impl Into<String>) -> PgWireError {
141    PgWireError::UserError(Box::new(code.to_err_info(message.into())))
142}
143
144fn unsupported(message: impl Into<String>) -> PgWireError {
145    copy_in_error(PgErrorCode::Ec0A000, message)
146}
147
148fn bad_copy_data(message: impl Into<String>) -> PgWireError {
149    copy_in_error(PgErrorCode::Ec22P04, message)
150}
151
152fn invalid_text_repr(message: impl Into<String>) -> PgWireError {
153    copy_in_error(PgErrorCode::Ec22P02, message)
154}
155
156pub(crate) fn copy_in_must_run_alone_error() -> PgWireError {
157    PgWireError::UserError(Box::new(ErrorInfo::new(
158        "ERROR".to_string(),
159        PgErrorCode::Ec0A000.code(),
160        "COPY FROM STDIN must be executed alone".to_string(),
161    )))
162}
163
164/// Parses a leading `COPY ... FROM STDIN` statement from SQL text.
165///
166/// The statement is parsed textually — not through sqlparser — because the
167/// `WITH (...)` option list is open: formats may accept options beyond
168/// sqlparser's COPY grammar, and unknown options must reach the format
169/// codec instead of failing statement parsing. Returns `Ok(None)` when
170/// the string does not lead with a copy-in statement.
171pub(crate) fn copy_from_stdin_from_sql(sql: &str) -> PgWireResult<Option<CopyFromStdin>> {
172    let raw = match parse::scan_copy_from_stdin(sql)? {
173        parse::CopyInScan::NotCopyIn => return Ok(None),
174        parse::CopyInScan::CopyIn(raw) => raw,
175    };
176    let mut option_set = raw.options;
177    let codec = resolve_codec(&mut option_set)?;
178    Ok(Some(CopyFromStdin {
179        table: raw.table,
180        columns: raw.columns,
181        codec,
182    }))
183}
184
185/// Whether an already-parsed statement is a `COPY ... FROM STDIN`; used to
186/// reject copy-in statements trailing other statements, where the textual
187/// scanner (which only sees statement-leading COPYs) does not see them.
188pub(crate) fn is_copy_from_stdin(statement: &SqlParserStatement) -> bool {
189    matches!(
190        statement,
191        SqlParserStatement::Copy {
192            to: false,
193            target: CopyTarget::Stdin,
194            ..
195        }
196    )
197}
198
199/// Resolves table name parts against the query context defaults.
200fn resolve_table_name(
201    query_ctx: &QueryContextRef,
202    parts: &[String],
203) -> PgWireResult<(String, String, String)> {
204    match parts {
205        [table] => Ok((
206            query_ctx.current_catalog().to_string(),
207            query_ctx.current_schema(),
208            table.clone(),
209        )),
210        [schema, table] => Ok((
211            query_ctx.current_catalog().to_string(),
212            schema.clone(),
213            table.clone(),
214        )),
215        [catalog, schema, table] => Ok((catalog.clone(), schema.clone(), table.clone())),
216        _ => Err(unsupported(format!(
217            "invalid table name for COPY: {}",
218            parts.join(".")
219        ))),
220    }
221}
222
223/// Per-connection state of an in-progress COPY FROM STDIN.
224pub(crate) struct CopyInState {
225    query_ctx: QueryContextRef,
226    /// The gRPC column schemas of the targeted columns. Cloned into each
227    /// flushed batch: `Rows.schema` is an owned `Vec` in the proto
228    /// request, so one copy per request is inherent.
229    grpc_schema: Vec<api::v1::ColumnSchema>,
230    table_name: String,
231    parser: Box<dyn RecordParser + Send>,
232    /// The resolved codec's name, for logging.
233    codec_name: &'static str,
234    /// The wire format code of the resolved codec.
235    codec_format_code: i8,
236    /// Parsed rows not yet flushed to the handler.
237    rows: Vec<GrpcRow>,
238    rows_written: u64,
239}
240
241impl CopyInState {
242    fn new(
243        codec: Arc<dyn CopyInCodec>,
244        columns: Vec<String>,
245        table_info: Arc<TableInfo>,
246        query_ctx: QueryContextRef,
247        table_name: String,
248    ) -> PgWireResult<Self> {
249        let schema = table_info.meta.schema.clone();
250        let timestamp_index = schema.timestamp_index();
251
252        let columns = resolve_columns(codec.as_ref(), columns, &schema, &table_info)?;
253        if let Some(ts_index) = timestamp_index {
254            let ts_column = &schema.column_schemas()[ts_index];
255            if !columns.iter().any(|column| column.name == ts_column.name) {
256                return Err(unsupported(format!(
257                    "COPY FROM STDIN requires the time index column \"{}\" in the column list",
258                    ts_column.name
259                )));
260            }
261        }
262        let parser = codec.create_parser(&columns, query_ctx.timezone().to_string().as_str())?;
263
264        let grpc_schema = columns
265            .iter()
266            .map(|column| build_grpc_column_schema(column, &table_info))
267            .collect::<PgWireResult<Vec<_>>>()?;
268
269        Ok(Self {
270            query_ctx,
271            grpc_schema,
272            table_name,
273            parser,
274            codec_name: codec.name(),
275            codec_format_code: codec.format_code(),
276            rows: Vec::with_capacity(COPY_IN_FLUSH_ROWS),
277            rows_written: 0,
278        })
279    }
280
281    /// The wire format code advertised to the client.
282    pub(crate) fn format_code(&self) -> i8 {
283        self.codec_format_code
284    }
285
286    /// Feeds a `CopyData` chunk into the parser and buffers parsed rows.
287    fn feed(&mut self, data: &[u8]) -> PgWireResult<()> {
288        self.rows.extend(self.parser.feed(data)?);
289        Ok(())
290    }
291
292    /// Parses the buffered tail at `CopyDone`; a final record without a
293    /// trailing newline is accepted.
294    fn finish(&mut self) -> PgWireResult<()> {
295        self.rows.extend(self.parser.finish()?);
296        Ok(())
297    }
298
299    fn should_flush(&self) -> bool {
300        self.rows.len() >= COPY_IN_FLUSH_ROWS
301    }
302
303    /// Takes the buffered rows as an insert request, if any.
304    fn take_requests(&mut self) -> Option<RowInsertRequests> {
305        if self.rows.is_empty() {
306            return None;
307        }
308        let rows = GrpcRows {
309            schema: self.grpc_schema.clone(),
310            rows: std::mem::take(&mut self.rows),
311        };
312        Some(RowInsertRequests {
313            inserts: vec![RowInsertRequest {
314                table_name: self.table_name.clone(),
315                rows: Some(rows),
316            }],
317        })
318    }
319}
320
321fn resolve_columns(
322    codec: &dyn CopyInCodec,
323    columns: Vec<String>,
324    schema: &SchemaRef,
325    table_info: &Arc<TableInfo>,
326) -> PgWireResult<Vec<ColumnSchema>> {
327    let table_name = &table_info.name;
328    let selected: Vec<ColumnSchema> = if columns.is_empty() {
329        schema.column_schemas().to_vec()
330    } else {
331        let mut selected = Vec::with_capacity(columns.len());
332        for name in &columns {
333            let column = schema
334                .column_schema_by_name(name)
335                .ok_or_else(|| {
336                    copy_in_error(
337                        PgErrorCode::Ec42703,
338                        format!(
339                            "column \"{}\" of relation \"{}\" does not exist",
340                            name, table_name
341                        ),
342                    )
343                })?
344                .clone();
345            if selected.iter().any(|c: &ColumnSchema| &c.name == name) {
346                return Err(bad_copy_data(format!(
347                    "column \"{}\" specified more than once",
348                    name
349                )));
350            }
351            selected.push(column);
352        }
353        selected
354    };
355
356    for column in &selected {
357        codec.check_column_type(&column.data_type)?;
358    }
359
360    Ok(selected)
361}
362
363/// Derives a column's semantic type the same way the flow adapter does
364/// (`flow::adapter::util::column_schemas_to_proto`): the time-index flag
365/// for timestamps, primary-key membership for tags.
366fn semantic_type_of(table_info: &TableInfo, column: &ColumnSchema) -> PgWireResult<SemanticType> {
367    let meta = &table_info.meta;
368    if column.is_time_index() {
369        Ok(SemanticType::Timestamp)
370    } else if meta
371        .primary_key_indices
372        .iter()
373        .any(|&index| meta.schema.column_schemas()[index].name == column.name)
374    {
375        Ok(SemanticType::Tag)
376    } else {
377        Ok(SemanticType::Field)
378    }
379}
380
381fn build_grpc_column_schema(
382    column: &ColumnSchema,
383    table_info: &Arc<TableInfo>,
384) -> PgWireResult<api::v1::ColumnSchema> {
385    let (datatype, datatype_extension) = ColumnDataTypeWrapper::try_from(column.data_type.clone())
386        .map_err(|_| {
387            unsupported(format!(
388                "column type \"{}\" is not supported for COPY FROM STDIN yet",
389                column.data_type.name()
390            ))
391        })?
392        .to_parts();
393    Ok(api::v1::ColumnSchema {
394        column_name: column.name.clone(),
395        datatype: datatype as i32,
396        semantic_type: semantic_type_of(table_info, column)? as i32,
397        datatype_extension,
398        options: options_from_column_schema(column),
399    })
400}
401
402// ---------------------------------------------------------------------------
403// pgwire handlers
404// ---------------------------------------------------------------------------
405
406impl PostgresServerHandlerInner {
407    /// Prepares a COPY FROM STDIN: resolves the target table, validates
408    /// columns and stashes the per-connection copy state.
409    pub(crate) async fn begin_copy_in(&self, stmt: CopyFromStdin) -> PgWireResult<Response> {
410        // The batching-aware context routes copy-in flushes through the
411        // pending-rows batcher like SQL INSERT when it is enabled for
412        // this connection.
413        let query_ctx = self.new_query_context();
414        let (catalog, schema, table) = resolve_table_name(&query_ctx, &stmt.table)?;
415
416        let Some(table_info) = self
417            .copy_in_handler
418            .copy_in_table(&catalog, &schema, &table, query_ctx.clone())
419            .await
420            .map_err(convert_err)?
421        else {
422            return Err(copy_in_error(
423                PgErrorCode::Ec42P01,
424                format!("relation \"{}\" does not exist", stmt.table.join(".")),
425            ));
426        };
427
428        // The insert path resolves the bare table name against the query
429        // context, so point the context at the resolved catalog and schema
430        // when the COPY target is qualified.
431        let query_ctx = adjust_query_context(query_ctx, &catalog, &schema);
432
433        let state = CopyInState::new(stmt.codec, stmt.columns, table_info, query_ctx, table)?;
434        let num_columns = state.grpc_schema.len();
435        let format_code = state.format_code();
436
437        *self.copy_in_state.lock().await = Some(state);
438
439        Ok(Response::CopyIn(CopyResponse::new(
440            format_code,
441            num_columns,
442            stream::empty(),
443        )))
444    }
445
446    /// Flushes buffered rows of an in-progress copy. Returns the number of
447    /// rows written by the flushed batch.
448    async fn flush_copy_in(&self, state: &mut CopyInState) -> PgWireResult<u64> {
449        let Some(requests) = state.take_requests() else {
450            return Ok(0);
451        };
452        let query_ctx = state.query_ctx.clone();
453        let output = self
454            .copy_in_handler
455            .copy_in_insert(requests, query_ctx)
456            .await
457            .map_err(convert_err)?;
458        match output.data {
459            OutputData::AffectedRows(rows) => Ok(rows as u64),
460            OutputData::RecordBatches(_) | OutputData::Stream(_) => {
461                warn!("unexpected output from COPY FROM STDIN insert");
462                Ok(0)
463            }
464        }
465    }
466}
467
468fn adjust_query_context(
469    query_ctx: QueryContextRef,
470    catalog: &str,
471    schema: &str,
472) -> QueryContextRef {
473    if query_ctx.current_catalog() == catalog && query_ctx.current_schema() == schema {
474        return query_ctx;
475    }
476    let mut forked = query_ctx.fork();
477    forked.set_current_catalog(catalog);
478    forked.set_current_schema(schema);
479    Arc::new(forked)
480}
481
482#[async_trait]
483impl CopyHandler for PostgresServerHandlerInner {
484    async fn on_copy_data<C>(&self, _client: &mut C, copy_data: CopyData) -> PgWireResult<()>
485    where
486        C: ClientInfo + Sink<PgWireBackendMessage> + Unpin + Send + Sync,
487        C::Error: Debug,
488        PgWireError: From<<C as Sink<PgWireBackendMessage>>::Error>,
489    {
490        let mut guard = self.copy_in_state.lock().await;
491        let Some(mut state) = guard.take() else {
492            return Err(copy_in_error(
493                PgErrorCode::Ec08P01,
494                "CopyData message received outside COPY FROM STDIN",
495            ));
496        };
497        // The state is owned here: any parse, conversion or insert error
498        // below aborts the whole copy and drops the buffered data —
499        // otherwise a pooled connection would retain failed-import
500        // buffers until its next COPY. On success it is put back.
501        state.feed(&copy_data.data)?;
502        if state.should_flush() {
503            let rows = self.flush_copy_in(&mut state).await?;
504            state.rows_written += rows;
505            METRIC_POSTGRES_COPY_IN_ROWS.inc_by(rows);
506        }
507        *guard = Some(state);
508        Ok(())
509    }
510
511    async fn on_copy_done<C>(&self, client: &mut C, _done: CopyDone) -> PgWireResult<()>
512    where
513        C: ClientInfo + Sink<PgWireBackendMessage> + Unpin + Send + Sync,
514        C::Error: Debug,
515        PgWireError: From<<C as Sink<PgWireBackendMessage>>::Error>,
516    {
517        let mut guard = self.copy_in_state.lock().await;
518        // Take ownership up front: even finalization failures (parse
519        // errors in the trailing record, insert errors) drop the state
520        // instead of retaining it on a long-lived connection.
521        let Some(mut state) = guard.take() else {
522            return Err(copy_in_error(
523                PgErrorCode::Ec08P01,
524                "CopyDone message received outside COPY FROM STDIN",
525            ));
526        };
527        state.finish()?;
528        let rows = self.flush_copy_in(&mut state).await?;
529        state.rows_written += rows;
530        METRIC_POSTGRES_COPY_IN_ROWS.inc_by(rows);
531
532        let rows_written = state.rows_written;
533        let codec_name = state.codec_name;
534        info!(
535            "PostgreSQL COPY FROM STDIN (format {}) finished: {} rows written",
536            codec_name, rows_written
537        );
538        client
539            .send(PgWireBackendMessage::CommandComplete(
540                Tag::new("COPY").with_rows(rows_written as usize).into(),
541            ))
542            .await?;
543
544        // After a successful extended-protocol copy-in, pgwire leaves the
545        // connection in `CopyInProgress(true)` and swallows the client's
546        // terminating `Sync` message, so `ReadyForQuery` would never be
547        // sent and the connection would deadlock. Route the state to
548        // `AwaitingSync` so the `Sync` is dispatched to `on_sync`, which
549        // completes the round trip.
550        if let PgWireConnectionState::CopyInProgress(true) = client.state() {
551            client.set_state(PgWireConnectionState::AwaitingSync);
552        }
553        Ok(())
554    }
555
556    async fn on_copy_fail<C>(&self, _client: &mut C, fail: CopyFail) -> PgWireError
557    where
558        C: ClientInfo + Sink<PgWireBackendMessage> + Unpin + Send + Sync,
559        C::Error: Debug,
560        PgWireError: From<<C as Sink<PgWireBackendMessage>>::Error>,
561    {
562        // Drop the buffered state; partial data of a failed copy is not
563        // written.
564        self.copy_in_state.lock().await.take();
565        copy_in_error(
566            PgErrorCode::Ec57014,
567            format!("COPY FROM STDIN mode terminated by user: {}", fail.message),
568        )
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn test_parse_copy_from_stdin_requires_format() {
578        let err = copy_from_stdin_from_sql("COPY t FROM STDIN").unwrap_err();
579        assert!(
580            err.to_string()
581                .contains("requires an explicit format in this version"),
582            "{err}"
583        );
584
585        let copy = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv)")
586            .unwrap()
587            .unwrap();
588        assert_eq!(copy.table, vec!["t"]);
589        assert!(copy.columns.is_empty());
590        assert_eq!(copy.codec.name(), "csv");
591        assert_eq!(copy.codec.format_code(), 0);
592    }
593
594    #[test]
595    fn test_parse_copy_from_stdin_csv() {
596        let copy = copy_from_stdin_from_sql(
597            "COPY t (a, b) FROM STDIN WITH (FORMAT csv, HEADER, DELIMITER ';')",
598        )
599        .unwrap()
600        .unwrap();
601        assert_eq!(copy.columns, vec!["a", "b"]);
602        assert_eq!(copy.codec.name(), "csv");
603    }
604
605    #[test]
606    fn test_parse_copy_from_stdin_text_unsupported() {
607        for sql in [
608            "COPY t FROM STDIN WITH (FORMAT text)",
609            "COPY t FROM STDIN WITH (FORMAT txt)",
610        ] {
611            let err = copy_from_stdin_from_sql(sql).unwrap_err();
612            assert!(
613                err.to_string().contains("text format is not supported yet"),
614                "{err}"
615            );
616        }
617    }
618
619    #[test]
620    fn test_parse_copy_from_stdin_binary_unsupported() {
621        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT binary)").unwrap_err();
622        assert!(err.to_string().contains("not supported"));
623    }
624
625    #[test]
626    fn test_parse_copy_from_stdin_unknown_format() {
627        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT parquet)").unwrap_err();
628        assert!(err.to_string().contains("unknown COPY format"));
629    }
630
631    #[test]
632    fn test_parse_copy_from_stdin_not_copy_in() {
633        assert!(
634            copy_from_stdin_from_sql("COPY (SELECT 1) TO STDOUT")
635                .unwrap()
636                .is_none()
637        );
638        assert!(
639            copy_from_stdin_from_sql("COPY t TO '/tmp/x.csv'")
640                .unwrap()
641                .is_none()
642        );
643        // GreptimeDB's own file-based COPY FROM.
644        assert!(
645            copy_from_stdin_from_sql("COPY t FROM '/tmp/x.csv' WITH (FORMAT csv)")
646                .unwrap()
647                .is_none()
648        );
649    }
650
651    #[test]
652    fn test_redundant_options() {
653        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv, FORMAT text)")
654            .unwrap_err();
655        assert!(err.to_string().contains("redundant FORMAT"));
656
657        let err = copy_from_stdin_from_sql("COPY t FROM STDIN WITH (DELIMITER ',', DELIMITER ';')")
658            .unwrap_err();
659        assert!(err.to_string().contains("redundant DELIMITER"));
660    }
661
662    #[test]
663    fn test_format_specific_options_rejected() {
664        // The open WITH section parses anything, but the format rejects
665        // the options it does not understand.
666        let err =
667            copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv, PRECISION 'ns', FREEZE)")
668                .unwrap_err();
669        assert!(
670            err.to_string().contains("not supported by format csv"),
671            "{err}"
672        );
673    }
674
675    #[test]
676    fn test_multi_statement_rejected() {
677        let err = copy_from_stdin_from_sql("COPY t FROM STDIN; SELECT 1").unwrap_err();
678        assert!(err.to_string().contains("alone"));
679        // Trailing separators and quoted semicolons are fine.
680        assert!(
681            copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv);")
682                .unwrap()
683                .is_some()
684        );
685        assert!(
686            copy_from_stdin_from_sql("COPY t FROM STDIN WITH (FORMAT csv, DELIMITER ';')")
687                .unwrap()
688                .is_some()
689        );
690    }
691}