From e3f77fb7312903ec4cfc27c3428301dc4261ea23 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 23 Sep 2026 12:38:06 +0200 Subject: [PATCH 1/3] SQLite: Accept fallback keywords as implicit column aliases --- src/dialect/mod.rs | 9 ++++++ src/dialect/sqlite.rs | 24 +++++++++++++++ src/parser/mod.rs | 21 +++++++++++++- tests/sqlparser_common.rs | 28 +++++++++++++----- tests/sqlparser_sqlite.rs | 61 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 3ce71eb0be..762394b3b1 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1526,6 +1526,15 @@ pub trait Dialect: Debug + Any { explicit || self.is_column_alias(kw, parser) } + /// Returns true if `OVER` without a following `(` or plain-identifier window name + /// may be used as an implicit column alias rather than starting a window clause. + /// + /// SQLite reads `OVER` as a keyword only when `(` or a name follows, see + /// `analyzeOverKeyword` in [tokenize.c](https://sqlite.org/src/file?name=src/tokenize.c) + fn supports_over_keyword_as_column_alias(&self) -> bool { + false + } + /// Returns true if the specified keyword should be parsed as a table factor identifier. /// See [keywords::RESERVED_FOR_TABLE_FACTOR] fn is_table_factor(&self, kw: &Keyword, _parser: &mut Parser) -> bool { diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index 41b0e8be16..3deffafb7d 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -160,4 +160,28 @@ impl Dialect for SQLiteDialect { fn supports_unterminated_block_comments(&self) -> bool { true } + + fn is_column_alias(&self, kw: &Keyword, _parser: &mut Parser) -> bool { + // https://www.sqlite.org/lang_keywords.html + !matches!( + kw, + Keyword::SELECT + | Keyword::WHERE + | Keyword::GROUP + | Keyword::HAVING + | Keyword::ORDER + | Keyword::LIMIT + | Keyword::UNION + | Keyword::EXCEPT + | Keyword::INTERSECT + | Keyword::RETURNING + | Keyword::VALUES + | Keyword::FROM + | Keyword::INTO + ) + } + + fn supports_over_keyword_as_column_alias(&self) -> bool { + true + } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 276b252ed2..8a00d044fa 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2676,7 +2676,26 @@ impl<'a> Parser<'a> { let window_spec = self.parse_window_spec()?; Some(WindowType::WindowSpec(window_spec)) } else { - Some(WindowType::NamedWindow(self.parse_identifier()?)) + let next_word_kw = match &self.peek_token_ref().token { + Token::Word(w) => Some(w.keyword), + _ => None, + }; + let next_is_window_name = match next_word_kw { + Some(kw) => { + let dialect = self.dialect; + dialect.is_column_alias(&kw, self) + } + None => matches!( + &self.peek_token_ref().token, + Token::SingleQuotedString(_) | Token::DoubleQuotedString(_) + ), + }; + if self.dialect.supports_over_keyword_as_column_alias() && !next_is_window_name { + self.prev_token(); + None + } else { + Some(WindowType::NamedWindow(self.parse_identifier()?)) + } } } else { None diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 87fb61dc17..223757e787 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -8387,12 +8387,25 @@ fn parse_multiple_statements() { // 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( - "SELECT foo FROM baz", - "WITH", - " cte AS (SELECT 1 AS s) SELECT bar", - ); + // WITH is a valid alias in SQLite so missing-semicolon detection skips it + for sql1 in ["SELECT foo", "SELECT foo FROM baz"] { + let cte_rest = " cte AS (SELECT 1 AS s) SELECT bar"; + let res = parse_sql_statements(&(sql1.to_owned() + ";WITH" + cte_rest)); + assert_eq!( + vec![ + one_statement_parses_to(sql1, ""), + one_statement_parses_to(&("WITH".to_owned() + cte_rest), ""), + ], + res.unwrap() + ); + one_statement_parses_to(&(sql1.to_owned() + ";"), sql1); + let res = all_dialects_except(|d| d.is::()) + .parse_sql_statements(&(sql1.to_owned() + " WITH" + cte_rest)); + assert_eq!( + ParserError::ParserError("Expected: end of statement, found: WITH".to_string()), + res.unwrap_err() + ); + } test_with("DELETE FROM foo", "SELECT", " bar"); test_with("INSERT INTO foo VALUES (1)", "SELECT", " bar"); // Since MySQL supports the `CREATE TABLE SELECT` syntax, this needs to be handled separately @@ -9238,7 +9251,8 @@ fn parse_fetch() { }); let ast = verified_query("SELECT foo FROM bar FETCH FIRST 2 ROWS ONLY"); assert_eq!(ast.fetch, fetch_first_two_rows_only); - let ast = verified_query("SELECT 'foo' FETCH FIRST 2 ROWS ONLY"); + let ast = all_dialects_except(|d| d.is::()) + .verified_query("SELECT 'foo' FETCH FIRST 2 ROWS ONLY"); assert_eq!(ast.fetch, fetch_first_two_rows_only); let ast = verified_query("SELECT foo FROM bar FETCH FIRST ROWS ONLY"); assert_eq!( diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 1752e976cb..7b4103fbcc 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1058,6 +1058,49 @@ fn test_cast_empty_type() { ); } +#[test] +fn parse_keyword_column_aliases() { + for kw in [ + "analyze", + "cluster", + "distribute", + "end", + "exclude", + "explain", + "fetch", + "lateral", + "minus", + "offset", + "sort", + "top", + "view", + "with", + ] { + sqlite().one_statement_parses_to( + &format!("SELECT a {kw} FROM t"), + &format!("SELECT a AS {kw} FROM t"), + ); + sqlite().one_statement_parses_to(&format!("SELECT 1 {kw}"), &format!("SELECT 1 AS {kw}")); + } + for (sql, error) in [ + ("SELECT 1 WHERE", "Expected: an expression, found: EOF"), + ("SELECT 1 GROUP", "Expected: end of statement, found: GROUP"), + ] { + assert_eq!( + sqlite().parse_sql_statements(sql), + Err(ParserError::ParserError(error.to_string())), + "{sql}" + ); + } + assert_eq!( + TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("SELECT a view FROM t"), + Err(ParserError::ParserError( + "Expected: end of statement, found: view".to_string() + )) + ); +} + #[test] fn parse_n_prefix_not_national_string() { // In SQLite, `n'...'` is the identifier `n` followed by a string literal. @@ -1161,6 +1204,24 @@ fn parse_unterminated_block_comment() { ); } +#[test] +fn parse_over_as_column_alias_after_function() { + // In SQLite, OVER is a fallback identifier so `f() OVER` aliases the result as `over`. + sqlite().one_statement_parses_to("SELECT f() OVER", "SELECT f() AS OVER"); + // A keyword window name is not mistaken for an alias candidate. + sqlite().verified_stmt("SELECT f() OVER partition"); + // Window function syntax still works when OVER is followed by `(`. + sqlite().verified_stmt("SELECT f() OVER ()"); + // Other dialects keep the greedy OVER parse and reject the bare form. + let generic = TestedDialects::new(vec![Box::new(GenericDialect {})]); + assert_eq!( + generic.parse_sql_statements("SELECT f() OVER"), + Err(ParserError::ParserError( + "Expected: identifier, found: EOF".to_string() + )) + ); +} + fn sqlite() -> TestedDialects { TestedDialects::new(vec![Box::new(SQLiteDialect {})]) } From 875ce0c51603eeb59e06abb814325cd34eb3d7ec Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Mon, 19 Oct 2026 20:10:23 +0200 Subject: [PATCH 2/3] Run the OVER alias lookahead only in dialects that allow it --- src/parser/mod.rs | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8a00d044fa..4dfd3d51bc 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -2676,21 +2676,17 @@ impl<'a> Parser<'a> { let window_spec = self.parse_window_spec()?; Some(WindowType::WindowSpec(window_spec)) } else { - let next_word_kw = match &self.peek_token_ref().token { - Token::Word(w) => Some(w.keyword), - _ => None, - }; - let next_is_window_name = match next_word_kw { - Some(kw) => { - let dialect = self.dialect; - dialect.is_column_alias(&kw, self) - } - None => matches!( - &self.peek_token_ref().token, - Token::SingleQuotedString(_) | Token::DoubleQuotedString(_) - ), - }; - if self.dialect.supports_over_keyword_as_column_alias() && !next_is_window_name { + let over_is_alias = self.dialect.supports_over_keyword_as_column_alias() + && match &self.peek_token_ref().token { + Token::Word(w) => { + let kw = w.keyword; + let dialect = self.dialect; + !dialect.is_column_alias(&kw, self) + } + Token::SingleQuotedString(_) | Token::DoubleQuotedString(_) => false, + _ => true, + }; + if over_is_alias { self.prev_token(); None } else { From fb7ed0afe46036a1d5031ca1fffe17af7eafbf6b Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 12:51:17 +0200 Subject: [PATCH 3/3] Cover quoted window names after `OVER` --- tests/sqlparser_sqlite.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 7b4103fbcc..eaadea263e 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1212,6 +1212,9 @@ fn parse_over_as_column_alias_after_function() { sqlite().verified_stmt("SELECT f() OVER partition"); // Window function syntax still works when OVER is followed by `(`. sqlite().verified_stmt("SELECT f() OVER ()"); + // A quoted string names the window, so OVER cannot be an alias for it + sqlite().verified_stmt("SELECT f() OVER 'partition'"); + sqlite().verified_stmt(r#"SELECT f() OVER "partition""#); // Other dialects keep the greedy OVER parse and reject the bare form. let generic = TestedDialects::new(vec![Box::new(GenericDialect {})]); assert_eq!(