diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 8a9a67a74..69a004c9b 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -3650,8 +3650,11 @@ pub enum Statement { options: Vec, /// WITH options (before PostgreSQL version 9.0) legacy_options: Vec, - /// VALUES a vector of values to be copied - values: Vec>, + /// 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, }, /// ```sql /// COPY INTO | @@ -5325,7 +5328,7 @@ impl fmt::Display for Statement { target, options, legacy_options, - values, + payload, } => { write!(f, "COPY")?; match source { @@ -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(()) } diff --git a/src/ast/spans.rs b/src/ast/spans.rs index a34fe66d9..bea2c06f4 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -347,7 +347,7 @@ impl Spanned for Statement { target: _, options: _, legacy_options: _, - values: _, + payload: _, } => source.span(), Statement::CopyIntoSnowflake { into: _, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b2b3f42bb..a00d5792b 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -12000,12 +12000,12 @@ 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, @@ -12013,7 +12013,7 @@ impl<'a> Parser<'a> { target, options, legacy_options, - values, + payload, }) } @@ -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> { - self.parse_tab_value() - } - - /// Parse a single tab-separated value row used by `COPY` payload parsing. - pub fn parse_tab_value(&mut self) -> Vec> { - 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 { + 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) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index a7128eafd..f219b71aa 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -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] @@ -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, } ); } @@ -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}`" ); } @@ -1517,7 +1520,7 @@ fn test_copy_from() { }, options: vec![], legacy_options: vec![], - values: vec![], + payload: None, } ); @@ -1535,7 +1538,7 @@ fn test_copy_from() { }, options: vec![], legacy_options: vec![CopyLegacyOption::Delimiter(',')], - values: vec![], + payload: None, } ); @@ -1556,7 +1559,7 @@ fn test_copy_from() { CopyLegacyOption::Delimiter(','), CopyLegacyOption::Csv(vec![CopyLegacyCsvOption::Header,]) ], - values: vec![], + payload: None, } ); } @@ -1577,7 +1580,7 @@ fn test_copy_to() { }, options: vec![], legacy_options: vec![], - values: vec![], + payload: None, } ); @@ -1595,7 +1598,7 @@ fn test_copy_to() { }, options: vec![], legacy_options: vec![CopyLegacyOption::Delimiter(',')], - values: vec![], + payload: None, } ); @@ -1616,7 +1619,7 @@ fn test_copy_to() { CopyLegacyOption::Delimiter(','), CopyLegacyOption::Csv(vec![CopyLegacyCsvOption::Header,]) ], - values: vec![], + payload: None, } ) } @@ -1670,7 +1673,7 @@ fn parse_copy_from() { CopyOption::Encoding("utf8".into()), ], legacy_options: vec![], - values: vec![], + payload: None, } ); } @@ -1700,7 +1703,7 @@ fn parse_copy_to() { }, options: vec![], legacy_options: vec![], - values: vec![], + payload: None, } ); @@ -1716,7 +1719,7 @@ fn parse_copy_to() { target: CopyTarget::Stdout, options: vec![CopyOption::Delimiter('|')], legacy_options: vec![], - values: vec![], + payload: None, } ); @@ -1735,7 +1738,7 @@ fn parse_copy_to() { }, options: vec![], legacy_options: vec![], - values: vec![], + payload: None, } ); @@ -1805,7 +1808,7 @@ fn parse_copy_to() { }, options: vec![], legacy_options: vec![], - values: vec![], + payload: None, } ) } @@ -1836,7 +1839,7 @@ fn parse_copy_from_before_v9_0() { CopyLegacyCsvOption::ForceNotNull(vec!["column".into()]), ]), ], - values: vec![], + payload: None, } ); @@ -1862,7 +1865,7 @@ fn parse_copy_from_before_v9_0() { CopyLegacyCsvOption::Escape('\\'), ]), ], - values: vec![], + payload: None, } ); } @@ -1893,7 +1896,7 @@ fn parse_copy_to_before_v9_0() { CopyLegacyCsvOption::ForceQuote(vec!["column".into()]), ]), ], - values: vec![], + payload: None, } ) } @@ -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\\."); +}