From bcd3a426da9603f21dfac49eb9f8e4e43ebda79a Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 18 Sep 2026 15:15:24 +0100 Subject: [PATCH 1/2] Treat a reserved word after AS as an alias Some dialects allow reserved words as aliases, as in `SELECT id AS set FROM tbl`. The word is tokenized as a RESERVED_* token and the parser then reads it as the start of a clause, so the formatter breaks the line and reindents. Directly after AS a reserved word can only be an alias name, so convert it to IDENTIFIER there. Only the token types that cannot legitimately follow AS are converted, leaving `CREATE TABLE t AS SELECT ...` and `PREPARE foo AS UPDATE ...` working. The bare alias form, as in `FROM pg_settings set`, needs statement position tracking and is not covered here. Refs #801 --- src/lexer/disambiguateTokens.ts | 45 +++++++++++++++++++++++++++++++++ test/behavesLikeSqlFormatter.ts | 11 ++++++++ test/postgresql.test.ts | 26 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/src/lexer/disambiguateTokens.ts b/src/lexer/disambiguateTokens.ts index f97a72c705..db096c322b 100644 --- a/src/lexer/disambiguateTokens.ts +++ b/src/lexer/disambiguateTokens.ts @@ -12,6 +12,9 @@ import { isReserved, Token, TokenType } from './token.js'; * When IDENTIFIER or RESERVED_DATA_TYPE token is followed by "[" * converts it to ARRAY_IDENTIFIER or ARRAY_KEYWORD accordingly. * + * Converts a reserved word directly after AS to IDENTIFIER, as it can only + * be an alias name there. + * * This is needed to avoid ambiguity in parser which expects function names * to always be followed by open-paren, and to distinguish between * array accessor `foo[1]` and array literal `[1, 2, 3]`. @@ -19,6 +22,7 @@ import { isReserved, Token, TokenType } from './token.js'; export function disambiguateTokens(tokens: Token[]): Token[] { return tokens .map(propertyNameKeywordToIdent) + .map(keywordAliasAfterAs) .map(funcNameToIdent) .map(dataTypeToParameterizedDataType) .map(identToArrayIdent) @@ -39,6 +43,47 @@ const propertyNameKeywordToIdent = (token: Token, i: number, tokens: Token[]): T return token; }; +/** + * Some dialects allow reserved words as aliases, as in `SELECT id AS set FROM tbl`. + * Such a word is tokenized as a RESERVED_* token, which the parser then treats as + * the start of a clause. Directly after AS it can only be an alias name, so we + * convert it to IDENTIFIER. + * + * Only the token types that cannot legitimately follow AS are converted, leaving + * `CREATE TABLE t AS SELECT ...` and `PREPARE foo AS UPDATE ...` working. + */ +const keywordAliasAfterAs = (token: Token, i: number, tokens: Token[]): Token => { + if (canBeAliasAfterAs(token)) { + const prevToken = prevNonCommentToken(tokens, i); + if (prevToken && isAsKeyword(prevToken)) { + return { ...token, type: TokenType.IDENTIFIER, text: token.raw }; + } + } + return token; +}; + +const isAsKeyword = (token: Token): boolean => + (token.type === TokenType.RESERVED_KEYWORD || token.type === TokenType.RESERVED_KEYWORD_PHRASE) && + token.text === 'AS'; + +const canBeAliasAfterAs = (token: Token): boolean => + token.type === TokenType.RESERVED_SET_OPERATION || + token.type === TokenType.RESERVED_JOIN || + token.type === TokenType.LIMIT || + token.type === TokenType.BETWEEN || + token.type === TokenType.CASE || + token.type === TokenType.END || + token.type === TokenType.WHEN || + token.type === TokenType.ELSE || + token.type === TokenType.THEN || + token.type === TokenType.AND || + token.type === TokenType.OR || + token.type === TokenType.XOR || + // SET is the clause keyword used as an alias in #801. The other + // RESERVED_CLAUSE words can follow AS for real (SELECT, VALUES, WITH, + // INSERT, UPDATE, DELETE, EXECUTE, ...), so they stay keywords. + (token.type === TokenType.RESERVED_CLAUSE && token.text === 'SET'); + const funcNameToIdent = (token: Token, i: number, tokens: Token[]): Token => { if (token.type === TokenType.RESERVED_FUNCTION_NAME) { const nextToken = nextNonCommentToken(tokens, i); diff --git a/test/behavesLikeSqlFormatter.ts b/test/behavesLikeSqlFormatter.ts index 1b9e636ba6..cfde304b19 100644 --- a/test/behavesLikeSqlFormatter.ts +++ b/test/behavesLikeSqlFormatter.ts @@ -277,4 +277,15 @@ export default function behavesLikeSqlFormatter(format: FormatFn) { tbl; `); }); + + // Issue #801 + it('supports reserved word as column alias after AS', () => { + const result = format('SELECT id AS set FROM tbl;'); + expect(result).toBe(dedent` + SELECT + id AS set + FROM + tbl; + `); + }); } diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index e8e99adfea..24c3f1def4 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -324,4 +324,30 @@ describe('PostgreSqlFormatter', () => { EXECUTE FUNCTION example_function (); `); }); + + // Issue #801 + it('supports reserved word as table alias after AS', () => { + expect(format(`SELECT set.foo FROM settings AS set;`)).toBe(dedent` + SELECT + set.foo + FROM + settings AS set; + `); + expect(format(`SELECT * FROM pg_settings AS set WHERE set.name = $9;`)).toBe(dedent` + SELECT + * + FROM + pg_settings AS set + WHERE + set.name = $9; + `); + }); + + it('keeps SET as a clause after a reserved-word alias', () => { + expect(format(`UPDATE tbl AS set SET x = 1;`)).toBe(dedent` + UPDATE tbl AS set + SET + x = 1; + `); + }); }); From 55c68bb0984059a5da5bbf02537023af95c132e5 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Tue, 22 Sep 2026 19:42:39 +0100 Subject: [PATCH 2/2] Address review: narrow isAsKeyword, move tests to shared helpers No behaviour change. AS is never tokenized as RESERVED_KEYWORD_PHRASE: no dialect declares a bare "AS" phrase, only phrases containing it (AS MATERIALIZED, NULL AS, GENERATED ... AS IDENTITY), and those tokenize with the whole phrase as the token text. Drop that branch from isAsKeyword. Reword the disambiguateTokens doc comment, which claimed every reserved word after AS becomes an identifier. Only the types that cannot start a clause there are converted. Merge the two PostgreSQL-specific tests into the existing shared one, now covering a column alias and a table alias in one statement and running for every dialect. Drop the WHERE case: set.name disambiguation is handled separately and is not part of #801. Move the SET-as-a-clause regression test to supportsUpdate(), since SparkSQL has no UPDATE statement and it cannot live in behavesLikeSqlFormatter(). Refs #801 --- src/lexer/disambiguateTokens.ts | 7 +++---- test/behavesLikeSqlFormatter.ts | 6 +++--- test/features/update.ts | 10 ++++++++++ test/postgresql.test.ts | 26 -------------------------- 4 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/lexer/disambiguateTokens.ts b/src/lexer/disambiguateTokens.ts index db096c322b..f8c42be3bb 100644 --- a/src/lexer/disambiguateTokens.ts +++ b/src/lexer/disambiguateTokens.ts @@ -12,8 +12,8 @@ import { isReserved, Token, TokenType } from './token.js'; * When IDENTIFIER or RESERVED_DATA_TYPE token is followed by "[" * converts it to ARRAY_IDENTIFIER or ARRAY_KEYWORD accordingly. * - * Converts a reserved word directly after AS to IDENTIFIER, as it can only - * be an alias name there. + * Converts a reserved word after AS to IDENTIFIER when that word cannot start + * a clause there, leaving `CREATE TABLE t AS SELECT ...` alone. * * This is needed to avoid ambiguity in parser which expects function names * to always be followed by open-paren, and to distinguish between @@ -63,8 +63,7 @@ const keywordAliasAfterAs = (token: Token, i: number, tokens: Token[]): Token => }; const isAsKeyword = (token: Token): boolean => - (token.type === TokenType.RESERVED_KEYWORD || token.type === TokenType.RESERVED_KEYWORD_PHRASE) && - token.text === 'AS'; + token.type === TokenType.RESERVED_KEYWORD && token.text === 'AS'; const canBeAliasAfterAs = (token: Token): boolean => token.type === TokenType.RESERVED_SET_OPERATION || diff --git a/test/behavesLikeSqlFormatter.ts b/test/behavesLikeSqlFormatter.ts index cfde304b19..1b444ca1ac 100644 --- a/test/behavesLikeSqlFormatter.ts +++ b/test/behavesLikeSqlFormatter.ts @@ -279,13 +279,13 @@ export default function behavesLikeSqlFormatter(format: FormatFn) { }); // Issue #801 - it('supports reserved word as column alias after AS', () => { - const result = format('SELECT id AS set FROM tbl;'); + it('supports reserved word as alias after AS', () => { + const result = format('SELECT id AS set FROM tbl AS set;'); expect(result).toBe(dedent` SELECT id AS set FROM - tbl; + tbl AS set; `); }); } diff --git a/test/features/update.ts b/test/features/update.ts index a412cba9af..279b4c424a 100644 --- a/test/features/update.ts +++ b/test/features/update.ts @@ -39,6 +39,16 @@ export default function supportsUpdate(format: FormatFn, { whereCurrentOf }: Upd `); }); + // Issue #801 + it('keeps SET as a clause after a reserved word alias', () => { + const result = format('UPDATE tbl AS set SET x = 1;'); + expect(result).toBe(dedent` + UPDATE tbl AS set + SET + x = 1; + `); + }); + if (whereCurrentOf) { it('formats UPDATE statement with cursor position', () => { const result = format("UPDATE Customers SET Name='John' WHERE CURRENT OF my_cursor;"); diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index 24c3f1def4..e8e99adfea 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -324,30 +324,4 @@ describe('PostgreSqlFormatter', () => { EXECUTE FUNCTION example_function (); `); }); - - // Issue #801 - it('supports reserved word as table alias after AS', () => { - expect(format(`SELECT set.foo FROM settings AS set;`)).toBe(dedent` - SELECT - set.foo - FROM - settings AS set; - `); - expect(format(`SELECT * FROM pg_settings AS set WHERE set.name = $9;`)).toBe(dedent` - SELECT - * - FROM - pg_settings AS set - WHERE - set.name = $9; - `); - }); - - it('keeps SET as a clause after a reserved-word alias', () => { - expect(format(`UPDATE tbl AS set SET x = 1;`)).toBe(dedent` - UPDATE tbl AS set - SET - x = 1; - `); - }); });