From d84d897cacc1bd7f5b7d00f3e8638cd6b1e30818 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 31 Oct 2026 13:05:18 +0100 Subject: [PATCH] PostgreSQL: Escape quotes in COPY option values --- src/ast/mod.rs | 14 +++---- src/ast/value.rs | 18 +++++++++ tests/sqlparser_postgres.rs | 75 +++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 7dfd13d6b1..3d34194460 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -9730,12 +9730,12 @@ impl fmt::Display for CopyOption { Format(name) => write!(f, "FORMAT {name}"), Freeze(true) => write!(f, "FREEZE"), Freeze(false) => write!(f, "FREEZE FALSE"), - Delimiter(char) => write!(f, "DELIMITER '{char}'"), + Delimiter(char) => write!(f, "DELIMITER '{}'", value::escape_single_quote_char(*char)), Null(string) => write!(f, "NULL '{}'", value::escape_single_quote_string(string)), Header(true) => write!(f, "HEADER"), Header(false) => write!(f, "HEADER FALSE"), - Quote(char) => write!(f, "QUOTE '{char}'"), - Escape(char) => write!(f, "ESCAPE '{char}'"), + Quote(char) => write!(f, "QUOTE '{}'", value::escape_single_quote_char(*char)), + Escape(char) => write!(f, "ESCAPE '{}'", value::escape_single_quote_char(*char)), ForceQuote(columns) => write!(f, "FORCE_QUOTE ({})", display_comma_separated(columns)), ForceNotNull(columns) => { write!(f, "FORCE_NOT_NULL ({})", display_comma_separated(columns)) @@ -9888,7 +9888,7 @@ impl fmt::Display for CopyLegacyOption { } Ok(()) } - Delimiter(char) => write!(f, "DELIMITER '{char}'"), + Delimiter(char) => write!(f, "DELIMITER '{}'", value::escape_single_quote_char(*char)), EmptyAsNull => write!(f, "EMPTYASNULL"), Encrypted { auto } => write!(f, "ENCRYPTED{}", if *auto { " AUTO" } else { "" }), Escape => write!(f, "ESCAPE"), @@ -10039,7 +10039,7 @@ impl fmt::Display for IamRoleKind { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { IamRoleKind::Default => write!(f, "DEFAULT"), - IamRoleKind::Arn(arn) => write!(f, "'{arn}'"), + IamRoleKind::Arn(arn) => write!(f, "'{}'", value::escape_single_quote_string(arn)), } } } @@ -10068,8 +10068,8 @@ impl fmt::Display for CopyLegacyCsvOption { use CopyLegacyCsvOption::*; match self { Header => write!(f, "HEADER"), - Quote(char) => write!(f, "QUOTE '{char}'"), - Escape(char) => write!(f, "ESCAPE '{char}'"), + Quote(char) => write!(f, "QUOTE '{}'", value::escape_single_quote_char(*char)), + Escape(char) => write!(f, "ESCAPE '{}'", value::escape_single_quote_char(*char)), ForceQuote(columns) => write!(f, "FORCE QUOTE {}", display_comma_separated(columns)), ForceNotNull(columns) => { write!(f, "FORCE NOT NULL {}", display_comma_separated(columns)) diff --git a/src/ast/value.rs b/src/ast/value.rs index 5b05af694c..2b376fbdb7 100644 --- a/src/ast/value.rs +++ b/src/ast/value.rs @@ -613,6 +613,24 @@ pub fn escape_single_quote_string(s: &str) -> EscapeQuotedString<'_> { escape_quoted_string(s, '\'') } +pub struct EscapeSingleQuoteChar(char); + +impl fmt::Display for EscapeSingleQuoteChar { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.0 == '\'' { + f.write_str("''") + } else { + write!(f, "{}", self.0) + } + } +} + +/// Convenience wrapper for escaping a single character for a single-quoted +/// literal (`'`). +pub fn escape_single_quote_char(c: char) -> EscapeSingleQuoteChar { + EscapeSingleQuoteChar(c) +} + /// Convenience wrapper for escaping strings for double-quoted literals (`").` pub fn escape_double_quote_string(s: &str) -> EscapeQuotedString<'_> { escape_quoted_string(s, '\"') diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 322ce2235b..1411b70f5b 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -12522,3 +12522,78 @@ fn parse_column_generic_options() { ); } } + +#[test] +fn parse_copy_option_quoted_chars_round_trip() { + let stmt = pg().verified_stmt( + "COPY y TO STDOUT (FORMAT CSV, QUOTE '''', ESCAPE '''', DELIMITER '''', NULL 'it''s')", + ); + assert_eq!( + stmt, + Statement::Copy { + source: CopySource::Table { + table_name: ObjectName::from(vec!["y".into()]), + columns: vec![], + }, + to: true, + target: CopyTarget::Stdout, + options: vec![ + CopyOption::Format("CSV".into()), + CopyOption::Quote('\''), + CopyOption::Escape('\''), + CopyOption::Delimiter('\''), + CopyOption::Null("it's".into()), + ], + legacy_options: vec![], + payload: None, + } + ); + + let stmt = pg().verified_stmt("COPY y TO STDOUT DELIMITER '''' CSV QUOTE '''' ESCAPE ''''"); + assert_eq!( + stmt, + Statement::Copy { + source: CopySource::Table { + table_name: ObjectName::from(vec!["y".into()]), + columns: vec![], + }, + to: true, + target: CopyTarget::Stdout, + options: vec![], + legacy_options: vec![ + CopyLegacyOption::Delimiter('\''), + CopyLegacyOption::Csv(vec![ + CopyLegacyCsvOption::Quote('\''), + CopyLegacyCsvOption::Escape('\''), + ]), + ], + payload: None, + } + ); + + pg().one_statement_parses_to( + "COPY y TO stdout WITH CSV QUOTE '''' DELIMITER '|'", + "COPY y TO STDOUT CSV QUOTE '''' DELIMITER '|'", + ); + + let stmt = pg() + .verified_stmt("COPY users TO 'data.csv' IAM_ROLE 'arn:aws:iam::123456789:role/ro''le'"); + assert_eq!( + stmt, + Statement::Copy { + source: CopySource::Table { + table_name: ObjectName::from(vec!["users".into()]), + columns: vec![], + }, + to: true, + target: CopyTarget::File { + filename: "data.csv".into(), + }, + options: vec![], + legacy_options: vec![CopyLegacyOption::IamRole(IamRoleKind::Arn( + "arn:aws:iam::123456789:role/ro'le".into(), + ))], + payload: None, + } + ); +}