From f57064fe1d9d7cd26538c6c680d0f76e67f205de Mon Sep 17 00:00:00 2001 From: Ben Herzberg Date: Thu, 10 Sep 2026 15:54:19 +0300 Subject: [PATCH] Postgres: allow reserved keywords as bare column alias PostgreSQL allows almost any keyword (SELECT, ANALYZE, LATERAL, AND, OR, COLLATE, ...) to be used as a bare (AS-less) column alias unless it is in a small set of keywords that require a leading AS. See https://www.postgresql.org/docs/current/sql-keywords-appendix.html Previously: SELECT (SELECT c FROM tbl_name LIMIT 1) select; failed to parse. --- src/dialect/postgresql.rs | 19 +++++++++++++++++-- src/parser/mod.rs | 34 +++++++++++++++++++++++++++++----- tests/sqlparser_common.rs | 36 +++++++++++++++++++++++++----------- tests/sqlparser_postgres.rs | 19 +++++++++++++++++++ 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index 3bec6ceba3..39f52044d4 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -29,11 +29,11 @@ use log::debug; use crate::dialect::{Dialect, Precedence}; -use crate::keywords::Keyword; +use crate::keywords::{self, Keyword}; use crate::parser::{Parser, ParserError}; use crate::tokenizer::Token; -use super::keywords::{self, RESERVED_FOR_IDENTIFIER}; +use super::keywords::RESERVED_FOR_IDENTIFIER; /// Keywords in [`keywords::RESERVED_FOR_TABLE_ALIAS`] because of other dialects, yet are safe for aliasing in PostgreSQL. /// See . @@ -54,6 +54,13 @@ const RESERVED_EXCLUSIONS_FOR_TABLE_ALIAS: &[Keyword] = &[ #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PostgreSqlDialect {} +/// Keywords that PostgreSQL additionally allows (on top of +/// [keywords::RESERVED_FOR_COLUMN_ALIAS]) to be used as a bare (`AS`-less) +/// column alias. +/// See +const ADDITIONALLY_ALLOWED_BARE_COLUMN_ALIASES: &[Keyword] = + &[Keyword::SELECT, Keyword::ANALYZE, Keyword::LATERAL]; + const PERIOD_PREC: u8 = 200; const DOUBLE_COLON_PREC: u8 = 140; const BRACKET_PREC: u8 = 130; @@ -368,4 +375,12 @@ impl Dialect for PostgreSqlDialect { fn supports_comment_optimizer_hint(&self) -> bool { true } + + /// Even reserved keywords can be used as a bare (`AS`-less) column alias in + /// PostgreSQL, unless they are in a small set of keywords that require `AS`. + /// See + fn is_column_alias(&self, kw: &Keyword, _parser: &mut Parser) -> bool { + ADDITIONALLY_ALLOWED_BARE_COLUMN_ALIASES.contains(kw) + || !keywords::RESERVED_FOR_COLUMN_ALIAS.contains(kw) + } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 2171548f91..6ace155abf 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1422,11 +1422,19 @@ impl<'a> Parser<'a> { // Parse an optional collation cast operator following `expr`. // // For example (MSSQL): t1.a COLLATE Latin1_General_CI_AS - if !self.in_column_definition_state() && self.parse_keyword(Keyword::COLLATE) { - expr = Expr::Collate { - expr: Box::new(expr), - collation: self.parse_object_name(false)?, - }; + // + // `COLLATE` with no collation name following it is not a cast operator; it's a bare + // (`AS`-less) column alias, e.g. Postgres' `SELECT 1 collate`. + if !self.in_column_definition_state() && self.peek_keyword(Keyword::COLLATE) { + if let Some(collation) = self.maybe_parse(|parser| { + parser.expect_keyword(Keyword::COLLATE)?; + parser.parse_object_name(false) + })? { + expr = Expr::Collate { + expr: Box::new(expr), + collation, + }; + } } debug!("prefix: {expr:?}"); @@ -1444,6 +1452,22 @@ impl<'a> Parser<'a> { break; } + // `AND`/`OR`/`COLLATE` with no right-hand expression following it is not a + // binary operator or collation cast; it's a bare (`AS`-less) column alias, + // e.g. Postgres' `SELECT 1 and` or `SELECT 1 collate`. + if let Token::Word(w) = &self.peek_token_ref().token { + let kw = w.keyword; + if matches!(kw, Keyword::AND | Keyword::OR | Keyword::COLLATE) + && matches!( + self.peek_nth_token_ref(1).token, + Token::EOF | Token::Comma | Token::RParen | Token::SemiColon + ) + && self.dialect.is_column_alias(&kw, self) + { + break; + } + } + expr = self.parse_infix(expr, next_precedence)?; } Ok(expr) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 29b060a82d..6ea3584cc5 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -8354,38 +8354,52 @@ fn parse_values() { #[test] fn parse_multiple_statements() { - fn test_with(sql1: &str, sql2_kw: &str, sql2_rest: &str) { + fn test_with(dialects: &TestedDialects, sql1: &str, sql2_kw: &str, sql2_rest: &str) { // Check that a string consisting of two statements delimited by a semicolon // parses the same as both statements individually: - let res = parse_sql_statements(&(sql1.to_owned() + ";" + sql2_kw + sql2_rest)); + let res = dialects.parse_sql_statements(&(sql1.to_owned() + ";" + sql2_kw + sql2_rest)); assert_eq!( vec![ - one_statement_parses_to(sql1, ""), - one_statement_parses_to(&(sql2_kw.to_owned() + sql2_rest), ""), + dialects.one_statement_parses_to(sql1, ""), + dialects.one_statement_parses_to(&(sql2_kw.to_owned() + sql2_rest), ""), ], res.unwrap() ); // Check that extra semicolon at the end is stripped by normalization: - one_statement_parses_to(&(sql1.to_owned() + ";"), sql1); + dialects.one_statement_parses_to(&(sql1.to_owned() + ";"), sql1); // Check that forgetting the semicolon results in an error: - let res = parse_sql_statements(&(sql1.to_owned() + " " + sql2_kw + sql2_rest)); + let res = dialects.parse_sql_statements(&(sql1.to_owned() + " " + sql2_kw + sql2_rest)); assert_eq!( ParserError::ParserError("Expected: end of statement, found: ".to_string() + sql2_kw), res.unwrap_err() ); } - test_with("SELECT foo", "SELECT", " bar"); + // PostgreSQL allows a bare `SELECT` to be used as a column alias, so unlike + // the other dialects, omitting the semicolon here does not result in an + // error there. + test_with(&all_dialects_but_pg(), "SELECT foo", "SELECT", " bar"); // ensure that SELECT/WITH is not parsed as a table or column alias if ';' // separating the statements is omitted: - test_with("SELECT foo FROM baz", "SELECT", " bar"); - test_with("SELECT foo", "WITH", " cte AS (SELECT 1 AS s) SELECT bar"); + test_with(&all_dialects(), "SELECT foo FROM baz", "SELECT", " bar"); test_with( + &all_dialects(), + "SELECT foo", + "WITH", + " cte AS (SELECT 1 AS s) SELECT bar", + ); + test_with( + &all_dialects(), "SELECT foo FROM baz", "WITH", " cte AS (SELECT 1 AS s) SELECT bar", ); - test_with("DELETE FROM foo", "SELECT", " bar"); - test_with("INSERT INTO foo VALUES (1)", "SELECT", " bar"); + test_with(&all_dialects(), "DELETE FROM foo", "SELECT", " bar"); + test_with( + &all_dialects(), + "INSERT INTO foo VALUES (1)", + "SELECT", + " bar", + ); // Since MySQL supports the `CREATE TABLE SELECT` syntax, this needs to be handled separately let res = parse_sql_statements("CREATE TABLE foo (baz INT); SELECT bar"); assert_eq!( diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index dfd883eb4d..bd7ef37be9 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -9918,3 +9918,22 @@ fn parse_non_reserved_keywords_as_table_alias() { )); } } + +#[test] +fn parse_reserved_keyword_as_bare_column_alias() { + // PostgreSQL allows (almost) any keyword, reserved or not, to be used as a bare + // (`AS`-less) column alias; only a small set of keywords require a leading `AS`. + // See + pg().verified_stmt("SELECT 1 AS select"); + for kw in ["select", "analyze", "lateral", "and", "or", "collate"] { + pg().one_statement_parses_to(&format!("SELECT 1 {kw}"), &format!("SELECT 1 AS {kw}")); + } + + // `AND`/`OR`/`COLLATE` are still parsed as operators when followed by an operand. + pg().verified_stmt("SELECT 1 AND 2"); + pg().verified_stmt("SELECT 1 OR 2"); + pg().verified_stmt(r#"SELECT 1 COLLATE "de_DE""#); + + // Keywords that require `AS` still cannot be used as a bare column alias. + assert!(pg().parse_sql_statements("SELECT 1 where").is_err()); +}