Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions src/ast/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3650,8 +3650,11 @@ pub enum Statement {
options: Vec<CopyOption>,
/// WITH options (before PostgreSQL version 9.0)
legacy_options: Vec<CopyLegacyOption>,
/// VALUES a vector of values to be copied
values: Vec<Option<String>>,
/// The inline payload of `COPY ... FROM STDIN`, as written, without the
/// `\.` terminator. Interpreting it (field separators, escapes and the
/// `\N` null marker) is left to the caller. `None` when the statement
/// carries no inline payload.
payload: Option<String>,
},
/// ```sql
/// COPY INTO <table> | <location>
Expand Down Expand Up @@ -5325,7 +5328,7 @@ impl fmt::Display for Statement {
target,
options,
legacy_options,
values,
payload,
} => {
write!(f, "COPY")?;
match source {
Expand All @@ -5347,19 +5350,10 @@ impl fmt::Display for Statement {
if !legacy_options.is_empty() {
write!(f, " {}", display_separated(legacy_options, " "))?;
}
if !values.is_empty() {
writeln!(f, ";")?;
let mut delim = "";
for v in values {
write!(f, "{delim}")?;
delim = "\t";
if let Some(v) = v {
write!(f, "{v}")?;
} else {
write!(f, "\\N")?;
}
}
write!(f, "\n\\.")?;
if let Some(payload) = payload {
// The payload starts on the line after the command and ends
// at the `\.` terminator.
write!(f, ";\n{payload}\\.")?;
}
Ok(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/ast/spans.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ impl Spanned for Statement {
target: _,
options: _,
legacy_options: _,
values: _,
payload: _,
} => source.span(),
Statement::CopyIntoSnowflake {
into: _,
Expand Down
74 changes: 42 additions & 32 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12000,20 +12000,20 @@ impl<'a> Parser<'a> {
while let Some(opt) = self.maybe_parse(|parser| parser.parse_copy_legacy_option())? {
legacy_options.push(opt);
}
let values =
let payload =
if matches!(target, CopyTarget::Stdin) && self.peek_token_ref().token != Token::EOF {
self.expect_token(&Token::SemiColon)?;
self.parse_tsv()
self.parse_copy_payload()
} else {
vec![]
None
};
Ok(Statement::Copy {
source,
to,
target,
options,
legacy_options,
values,
payload,
})
}

Expand Down Expand Up @@ -12354,40 +12354,50 @@ impl<'a> Parser<'a> {
Ok(s.chars().next().unwrap())
}

/// Parse a tab separated values in
/// COPY payload
pub fn parse_tsv(&mut self) -> Vec<Option<String>> {
self.parse_tab_value()
}

/// Parse a single tab-separated value row used by `COPY` payload parsing.
pub fn parse_tab_value(&mut self) -> Vec<Option<String>> {
let mut values = vec![];
let mut content = String::new();
while let Some(t) = self.next_token_no_skip().map(|t| &t.token) {
match t {
Token::Whitespace(Whitespace::Tab) => {
values.push(Some(core::mem::take(&mut content)));
}
Token::Whitespace(Whitespace::Newline) => {
values.push(Some(core::mem::take(&mut content)));
}
/// Parse the inline payload of `COPY ... FROM STDIN` as written.
///
/// The payload is data rather than SQL, so it is captured without being
/// interpreted: the data starts on the line following the command and runs
/// up to the `\.` terminator, which is consumed but not returned. Field
/// separators, escapes and the `\N` null marker are left to the caller.
///
/// Returns `None` when nothing but the statement delimiter follows, so a
/// bare `COPY t FROM STDIN;` is not given a payload it never carried.
fn parse_copy_payload(&mut self) -> Option<String> {
use core::fmt::Write;

let mut payload = String::new();
// The newline ending the command line is a delimiter, not data.
if self.peek_nth_token_no_skip_ref(0).token == Token::Whitespace(Whitespace::Newline) {
let _ = self.next_token_no_skip();
}
while let Some(token) = self.next_token_no_skip().map(|t| &t.token) {
match token {
Token::Backslash => {
if self.consume_token(&Token::Period) {
return values;
}
if let Token::Word(w) = self.next_token().token {
if w.value == "N" {
values.push(None);
}
// `\.` terminates the payload, every other backslash is data.
if self.peek_nth_token_no_skip_ref(0).token == Token::Period {
let _ = self.next_token_no_skip();
return Some(payload);
}
payload.push('\\');
}
_ => {
content.push_str(&t.to_string());
// `Display` for a single line comment appends a newline that the
// tokenizer left in the stream as a separate token, so render it
// here instead to avoid duplicating it.
Token::Whitespace(Whitespace::SingleLineComment { prefix, comment }) => {
payload.push_str(prefix);
payload.push_str(comment);
}
// Rendering into the buffer avoids a temporary `String` per
// token, and a payload can hold an entire table.
token => {
let _ = write!(payload, "{token}");
}
}
}
values
// An unterminated payload keeps whatever data it holds, but an empty one
// means the statement had no inline data at all.
(!payload.is_empty()).then_some(payload)
}

/// Parse a literal value (numbers, strings, date/time, booleans)
Expand Down
104 changes: 86 additions & 18 deletions tests/sqlparser_postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1442,7 +1442,10 @@ Kwara & Kogi
PHP ₱ USD $
\N Some other value
\\."#;
pg_and_generic().one_statement_parses_to(sql, "");
// The payload survives printing unchanged, so the only difference from the
// input is the normalized `STDIN` keyword.
let canonical = sql.replacen("FROM stdin;", "FROM STDIN;", 1);
pg_and_generic().one_statement_parses_to(sql, &canonical);
}

#[test]
Expand All @@ -1459,7 +1462,7 @@ fn parse_copy_from_stdin_without_semicolon() {
target: CopyTarget::Stdin,
options: vec![],
legacy_options: vec![CopyLegacyOption::Null("null".into())],
values: vec![],
payload: None,
}
);
}
Expand Down Expand Up @@ -1488,11 +1491,11 @@ fn parse_copy_from_stdin_without_semicolon_variants() {
Statement::Copy {
to: false,
target: CopyTarget::Stdin,
values,
payload,
..
} => {
assert!(
values.is_empty(),
payload.is_none(),
"expected no inline COPY payload for `{sql}`"
);
}
Expand All @@ -1517,7 +1520,7 @@ fn test_copy_from() {
},
options: vec![],
legacy_options: vec![],
values: vec![],
payload: None,
}
);

Expand All @@ -1535,7 +1538,7 @@ fn test_copy_from() {
},
options: vec![],
legacy_options: vec![CopyLegacyOption::Delimiter(',')],
values: vec![],
payload: None,
}
);

Expand All @@ -1556,7 +1559,7 @@ fn test_copy_from() {
CopyLegacyOption::Delimiter(','),
CopyLegacyOption::Csv(vec![CopyLegacyCsvOption::Header,])
],
values: vec![],
payload: None,
}
);
}
Expand All @@ -1577,7 +1580,7 @@ fn test_copy_to() {
},
options: vec![],
legacy_options: vec![],
values: vec![],
payload: None,
}
);

Expand All @@ -1595,7 +1598,7 @@ fn test_copy_to() {
},
options: vec![],
legacy_options: vec![CopyLegacyOption::Delimiter(',')],
values: vec![],
payload: None,
}
);

Expand All @@ -1616,7 +1619,7 @@ fn test_copy_to() {
CopyLegacyOption::Delimiter(','),
CopyLegacyOption::Csv(vec![CopyLegacyCsvOption::Header,])
],
values: vec![],
payload: None,
}
)
}
Expand Down Expand Up @@ -1670,7 +1673,7 @@ fn parse_copy_from() {
CopyOption::Encoding("utf8".into()),
],
legacy_options: vec![],
values: vec![],
payload: None,
}
);
}
Expand Down Expand Up @@ -1700,7 +1703,7 @@ fn parse_copy_to() {
},
options: vec![],
legacy_options: vec![],
values: vec![],
payload: None,
}
);

Expand All @@ -1716,7 +1719,7 @@ fn parse_copy_to() {
target: CopyTarget::Stdout,
options: vec![CopyOption::Delimiter('|')],
legacy_options: vec![],
values: vec![],
payload: None,
}
);

Expand All @@ -1735,7 +1738,7 @@ fn parse_copy_to() {
},
options: vec![],
legacy_options: vec![],
values: vec![],
payload: None,
}
);

Expand Down Expand Up @@ -1805,7 +1808,7 @@ fn parse_copy_to() {
},
options: vec![],
legacy_options: vec![],
values: vec![],
payload: None,
}
)
}
Expand Down Expand Up @@ -1836,7 +1839,7 @@ fn parse_copy_from_before_v9_0() {
CopyLegacyCsvOption::ForceNotNull(vec!["column".into()]),
]),
],
values: vec![],
payload: None,
}
);

Expand All @@ -1862,7 +1865,7 @@ fn parse_copy_from_before_v9_0() {
CopyLegacyCsvOption::Escape('\\'),
]),
],
values: vec![],
payload: None,
}
);
}
Expand Down Expand Up @@ -1893,7 +1896,7 @@ fn parse_copy_to_before_v9_0() {
CopyLegacyCsvOption::ForceQuote(vec!["column".into()]),
]),
],
values: vec![],
payload: None,
}
)
}
Expand Down Expand Up @@ -9663,3 +9666,68 @@ fn parse_right_deep_join_chain() {
// NATURAL JOIN followed by a constrained join must stay left-associative.
pg().verified_stmt("SELECT * FROM t0 NATURAL JOIN t1 INNER JOIN t2 ON true");
}

#[test]
fn parse_copy_from_stdin_payload_is_captured_as_written() {
// Reported symptoms: the payload arrived as a flat list of fields with no
// row separator, so two rows of two columns came back as six values, and
// printing a parsed statement never reproduced its input.
let check = |sql: &str, printed: &str, expected: Option<&str>| match pg_and_generic()
.one_statement_parses_to(sql, printed)
{
Statement::Copy { payload, .. } => {
assert_eq!(payload.as_deref(), expected, "for {sql:?}")
}
other => panic!("expected COPY, got {other:?}"),
};

// Tabs, newlines and the `\N` null marker are all left uninterpreted.
let rows = "COPY t (a, b) FROM STDIN;\n1\t\\N\n2\ty\n\\.";
check(rows, rows, Some("1\t\\N\n2\ty\n"));

// Only the keyword casing is normalized.
check(
"COPY t (a, b) FROM stdin;\n1\t\\N\n2\ty\n\\.",
rows,
Some("1\t\\N\n2\ty\n"),
);

// An empty payload is still a payload, distinct from a `COPY` that carries
// no inline data at all.
let empty = "COPY t (a, b) FROM STDIN;\n\\.";
check(empty, empty, Some(""));

// A statement delimiter with nothing behind it is not a payload, so
// printing must not invent data and a `\.` terminator.
check(
"COPY t (a, b) FROM STDIN;",
"COPY t (a, b) FROM STDIN",
None,
);
check(
"COPY t (a, b) FROM STDIN;\n",
"COPY t (a, b) FROM STDIN",
None,
);

// A payload cut short keeps the rows it does have, and printing restores
// the terminator the input was missing.
check(
"COPY t (a, b) FROM STDIN;\n1\t2\n",
"COPY t (a, b) FROM STDIN;\n1\t2\n\\.",
Some("1\t2\n"),
);

// The declared format does not change how the payload is captured.
let csv = "COPY t (a, b) FROM STDIN (FORMAT CSV);\na,b\n1,\"x y\"\n\\.";
check(csv, csv, Some("a,b\n1,\"x y\"\n"));
}

#[test]
fn parse_copy_from_stdin_payload_with_comment_marker() {
// A `--` in the payload is data, but the tokenizer still reports it as a
// comment, and printing that token appends a newline the tokenizer already
// emitted separately.
pg_and_generic().verified_stmt("COPY t (a, b) FROM STDIN;\n1\t-- not a comment\n2\ty\n\\.");
pg_and_generic().verified_stmt("COPY t (a, b) FROM STDIN;\n1\t/* nor is this */\n\\.");
}
Loading