Skip to content

Snowflake Reserved SQL Keywords as Implicit Table Alias #1934

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 11, 2025
Merged
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
10 changes: 8 additions & 2 deletions src/dialect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,11 +992,17 @@ pub trait Dialect: Debug + Any {
explicit || self.is_column_alias(kw, parser)
}

/// Returns true if the specified keyword should be parsed as a table identifier.
/// See [keywords::RESERVED_FOR_TABLE_ALIAS]
fn is_table_alias(&self, kw: &Keyword, _parser: &mut Parser) -> bool {
!keywords::RESERVED_FOR_TABLE_ALIAS.contains(kw)
}

/// Returns true if the specified keyword should be parsed as a table factor alias.
/// When explicit is true, the keyword is preceded by an `AS` word. Parser is provided
/// to enable looking ahead if needed.
fn is_table_factor_alias(&self, explicit: bool, kw: &Keyword, _parser: &mut Parser) -> bool {
explicit || !keywords::RESERVED_FOR_TABLE_ALIAS.contains(kw)
fn is_table_factor_alias(&self, explicit: bool, kw: &Keyword, parser: &mut Parser) -> bool {
explicit || self.is_table_alias(kw, parser)
}

/// Returns true if this dialect supports querying historical table data
Expand Down
88 changes: 85 additions & 3 deletions src/dialect/snowflake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,11 @@ impl Dialect for SnowflakeDialect {
}

// `FETCH` can be considered an alias as long as it's not followed by `FIRST`` or `NEXT`
// which would give it a different meanings, for example: `SELECT 1 FETCH FIRST 10 ROWS` - not an alias
Keyword::FETCH
if parser.peek_keyword(Keyword::FIRST) || parser.peek_keyword(Keyword::NEXT) =>
// which would give it a different meanings, for example:
// `SELECT 1 FETCH FIRST 10 ROWS` - not an alias
// `SELECT 1 FETCH 10` - not an alias
Keyword::FETCH if parser.peek_one_of_keywords(&[Keyword::FIRST, Keyword::NEXT]).is_some()
|| matches!(parser.peek_token().token, Token::Number(_, _)) =>
{
false
}
Expand All @@ -345,6 +347,86 @@ impl Dialect for SnowflakeDialect {
}
}

fn is_table_alias(&self, kw: &Keyword, parser: &mut Parser) -> bool {
match kw {
// The following keywords can be considered an alias as long as
// they are not followed by other tokens that may change their meaning
Keyword::LIMIT
| Keyword::RETURNING
| Keyword::INNER
| Keyword::USING
| Keyword::PIVOT
| Keyword::UNPIVOT
| Keyword::EXCEPT
| Keyword::MATCH_RECOGNIZE
| Keyword::OFFSET
if !matches!(parser.peek_token_ref().token, Token::SemiColon | Token::EOF) =>
{
false
}

// `FETCH` can be considered an alias as long as it's not followed by `FIRST`` or `NEXT`
// which would give it a different meanings, for example:
// `SELECT * FROM tbl FETCH FIRST 10 ROWS` - not an alias
// `SELECT * FROM tbl FETCH 10` - not an alias
Keyword::FETCH
if parser
.peek_one_of_keywords(&[Keyword::FIRST, Keyword::NEXT])
.is_some()
|| matches!(parser.peek_token().token, Token::Number(_, _)) =>
{
false
}

// All sorts of join-related keywords can be considered aliases unless additional
// keywords change their meaning.
Keyword::RIGHT | Keyword::LEFT | Keyword::SEMI | Keyword::ANTI
if parser
.peek_one_of_keywords(&[Keyword::JOIN, Keyword::OUTER])
.is_some() =>
{
false
}
Keyword::GLOBAL if parser.peek_keyword(Keyword::FULL) => false,

// Reserved keywords by the Snowflake dialect, which seem to be less strictive
// than what is listed in `keywords::RESERVED_FOR_TABLE_ALIAS`. The following
// keywords were tested with the this statement: `SELECT <KW>.* FROM tbl <KW>`.
Keyword::WITH
| Keyword::ORDER
| Keyword::SELECT
| Keyword::WHERE
| Keyword::GROUP
| Keyword::HAVING
| Keyword::LATERAL
| Keyword::UNION
| Keyword::INTERSECT
| Keyword::MINUS
| Keyword::ON
| Keyword::JOIN
| Keyword::INNER
| Keyword::CROSS
| Keyword::FULL
| Keyword::LEFT
| Keyword::RIGHT
| Keyword::NATURAL
| Keyword::USING
| Keyword::ASOF
| Keyword::MATCH_CONDITION
| Keyword::SET
| Keyword::QUALIFY
| Keyword::FOR
| Keyword::START
| Keyword::CONNECT
| Keyword::SAMPLE
| Keyword::TABLESAMPLE
| Keyword::FROM => false,

// Any other word is considered an alias
_ => true,
}
}

/// See: <https://docs.snowflake.com/en/sql-reference/constructs/at-before>
fn supports_timestamp_versioning(&self) -> bool {
true
Expand Down
26 changes: 19 additions & 7 deletions tests/sqlparser_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5548,7 +5548,8 @@ fn parse_named_window_functions() {
WINDOW w AS (PARTITION BY x), win AS (ORDER BY y)";
supported_dialects.verified_stmt(sql);

let select = verified_only_select(sql);
let select = all_dialects_except(|d| d.is_table_alias(&Keyword::WINDOW, &mut Parser::new(d)))
.verified_only_select(sql);

const EXPECTED_PROJ_QTY: usize = 2;
assert_eq!(EXPECTED_PROJ_QTY, select.projection.len());
Expand Down Expand Up @@ -5578,6 +5579,7 @@ fn parse_named_window_functions() {

#[test]
fn parse_window_clause() {
let dialects = all_dialects_except(|d| d.is_table_alias(&Keyword::WINDOW, &mut Parser::new(d)));
let sql = "SELECT * \
FROM mytable \
WINDOW \
Expand All @@ -5590,10 +5592,14 @@ fn parse_window_clause() {
window7 AS (window1 ROWS UNBOUNDED PRECEDING), \
window8 AS (window1 PARTITION BY a ORDER BY b ROWS UNBOUNDED PRECEDING) \
ORDER BY C3";
verified_only_select(sql);
dialects.verified_only_select(sql);

let sql = "SELECT * from mytable WINDOW window1 AS window2";
let dialects = all_dialects_except(|d| d.is::<BigQueryDialect>() || d.is::<GenericDialect>());
let dialects = all_dialects_except(|d| {
d.is::<BigQueryDialect>()
|| d.is::<GenericDialect>()
|| d.is_table_alias(&Keyword::WINDOW, &mut Parser::new(d))
});
let res = dialects.parse_sql_statements(sql);
assert_eq!(
ParserError::ParserError("Expected: (, found: window2".to_string()),
Expand All @@ -5603,14 +5609,15 @@ fn parse_window_clause() {

#[test]
fn test_parse_named_window() {
let dialects = all_dialects_except(|d| d.is_table_alias(&Keyword::WINDOW, &mut Parser::new(d)));
let sql = "SELECT \
MIN(c12) OVER window1 AS min1, \
MAX(c12) OVER window2 AS max1 \
FROM aggregate_test_100 \
WINDOW window1 AS (ORDER BY C12), \
window2 AS (PARTITION BY C11) \
ORDER BY C3";
let actual_select_only = verified_only_select(sql);
let actual_select_only = dialects.verified_only_select(sql);
let expected = Select {
select_token: AttachedToken::empty(),
distinct: None,
Expand Down Expand Up @@ -5759,14 +5766,18 @@ fn test_parse_named_window() {

#[test]
fn parse_window_and_qualify_clause() {
let dialects = all_dialects_except(|d| {
d.is_table_alias(&Keyword::WINDOW, &mut Parser::new(d))
|| d.is_table_alias(&Keyword::QUALIFY, &mut Parser::new(d))
});
let sql = "SELECT \
MIN(c12) OVER window1 AS min1 \
FROM aggregate_test_100 \
QUALIFY ROW_NUMBER() OVER my_window \
WINDOW window1 AS (ORDER BY C12), \
window2 AS (PARTITION BY C11) \
ORDER BY C3";
verified_only_select(sql);
dialects.verified_only_select(sql);

let sql = "SELECT \
MIN(c12) OVER window1 AS min1 \
Expand All @@ -5775,7 +5786,7 @@ fn parse_window_and_qualify_clause() {
window2 AS (PARTITION BY C11) \
QUALIFY ROW_NUMBER() OVER my_window \
ORDER BY C3";
verified_only_select(sql);
dialects.verified_only_select(sql);
}

#[test]
Expand Down Expand Up @@ -7443,7 +7454,8 @@ fn parse_join_syntax_variants() {
"SELECT c1 FROM t1 FULL JOIN t2 USING(c1)",
);

let res = parse_sql_statements("SELECT * FROM a OUTER JOIN b ON 1");
let dialects = all_dialects_except(|d| d.is_table_alias(&Keyword::OUTER, &mut Parser::new(d)));
let res = dialects.parse_sql_statements("SELECT * FROM a OUTER JOIN b ON 1");
assert_eq!(
ParserError::ParserError("Expected: APPLY, found: JOIN".to_string()),
res.unwrap_err()
Expand Down
51 changes: 51 additions & 0 deletions tests/sqlparser_snowflake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3492,6 +3492,57 @@ fn test_sql_keywords_as_select_item_aliases() {
}
}

#[test]
fn test_sql_keywords_as_table_aliases() {
// Some keywords that should be parsed as an alias implicitly
let unreserved_kws = vec![
"VIEW",
"EXPLAIN",
"ANALYZE",
"SORT",
"PIVOT",
"UNPIVOT",
"TOP",
"LIMIT",
"OFFSET",
"FETCH",
"EXCEPT",
"CLUSTER",
"DISTRIBUTE",
"GLOBAL",
"ANTI",
"SEMI",
"RETURNING",
"OUTER",
"WINDOW",
"END",
"PARTITION",
"PREWHERE",
"SETTINGS",
"FORMAT",
"MATCH_RECOGNIZE",
"OPEN",
];

for kw in unreserved_kws {
snowflake().verified_stmt(&format!("SELECT * FROM tbl AS {kw}"));
snowflake().one_statement_parses_to(
&format!("SELECT * FROM tbl {kw}"),
&format!("SELECT * FROM tbl AS {kw}"),
);
}

// Some keywords that should not be parsed as an alias implicitly
let reserved_kws = vec![
"FROM", "GROUP", "HAVING", "ORDER", "SELECT", "UNION", "WHERE", "WITH",
];
for kw in reserved_kws {
assert!(snowflake()
.parse_sql_statements(&format!("SELECT * FROM tbl {kw}"))
.is_err());
}
}

#[test]
fn test_timetravel_at_before() {
snowflake().verified_only_select("SELECT * FROM tbl AT(TIMESTAMP => '2024-12-15 00:00:00')");
Expand Down