diff --git a/src/grammar/keywords.ts b/src/grammar/keywords.ts index 699a836..229965e 100644 --- a/src/grammar/keywords.ts +++ b/src/grammar/keywords.ts @@ -31,6 +31,7 @@ export const keywords: string[] = [ "cast", "checkpoint", "cleanup", + "cold", "column", "columns", "compile", @@ -75,6 +76,7 @@ export const keywords: string[] = [ "flush", "following", "for", + "force", "foreign", "format", "from", @@ -112,6 +114,7 @@ export const keywords: string[] = [ "live", "lock", "lt", + "manager", "maps", "materialized", "maxUncommittedRows", @@ -155,6 +158,7 @@ export const keywords: string[] = [ "rebase", "references", "refresh", + "refresher", "release", "reindex", "remove", diff --git a/src/parser/ast.ts b/src/parser/ast.ts index 1002371..375e7e1 100644 --- a/src/parser/ast.ts +++ b/src/parser/ast.ts @@ -958,11 +958,12 @@ export interface BackupStatement extends AstNode { table?: QualifiedName } -// Enterprise: SWITCH ROLE TO {PRIMARY|REPLICA} [TIMEOUT ] | SWITCH STATUS +// Enterprise role and cold-storage role switching. export interface SwitchStatement extends AstNode { type: "switch" - action: "role" | "status" - role?: "PRIMARY" | "REPLICA" + action: "role" | "status" | "coldStorageRole" | "coldStorageStatus" + role?: "PRIMARY" | "REPLICA" | "MANAGER" | "REFRESHER" + force?: boolean timeout?: number } diff --git a/src/parser/cst-types.d.ts b/src/parser/cst-types.d.ts index c4bb340..2b1e5a3 100644 --- a/src/parser/cst-types.d.ts +++ b/src/parser/cst-types.d.ts @@ -1997,13 +1997,18 @@ export interface SwitchStatementCstNode extends CstNode { export type SwitchStatementCstChildren = { Switch: IToken[]; - Role?: IToken[]; - To?: IToken[]; + Role?: (IToken)[]; + To?: (IToken)[]; Primary?: IToken[]; Replica?: IToken[]; - Timeout?: IToken[]; - NumberLiteral?: IToken[]; - Status?: IToken[]; + Timeout?: (IToken)[]; + NumberLiteral?: (IToken)[]; + Status?: (IToken)[]; + Cold?: IToken[]; + Storage?: IToken[]; + Manager?: IToken[]; + Force?: IToken[]; + Refresher?: IToken[]; }; export interface CompileViewStatementCstNode extends CstNode { diff --git a/src/parser/lexer.ts b/src/parser/lexer.ts index 73753dc..50ddb39 100644 --- a/src/parser/lexer.ts +++ b/src/parser/lexer.ts @@ -312,6 +312,10 @@ import { Timeout, Expire, Cleanup, + Cold, + Force, + Manager, + Refresher, Highest, Lowest, Live, @@ -638,6 +642,10 @@ export { Timeout, Expire, Cleanup, + Cold, + Force, + Manager, + Refresher, Highest, Lowest, Live, diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 23afab5..d20cf53 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -217,6 +217,10 @@ import { Timeout, Expire, Cleanup, + Cold, + Force, + Manager, + Refresher, Highest, Lowest, Live, @@ -3754,7 +3758,10 @@ class QuestDBParser extends CstParser { ]) }) - // SWITCH ROLE TO { PRIMARY | REPLICA } [ TIMEOUT ] | SWITCH STATUS + // SWITCH ROLE TO { PRIMARY | REPLICA } [ TIMEOUT ] + // SWITCH STATUS + // SWITCH COLD STORAGE ROLE TO { MANAGER [ FORCE ] | REFRESHER } [ TIMEOUT ] + // SWITCH COLD STORAGE STATUS private switchStatement = this.RULE("switchStatement", () => { this.CONSUME(Switch) this.OR([ @@ -3773,6 +3780,42 @@ class QuestDBParser extends CstParser { }, }, { ALT: () => this.CONSUME(Status) }, + { + ALT: () => { + this.CONSUME(Cold) + this.CONSUME(Storage) + this.OR2([ + { ALT: () => this.CONSUME1(Status) }, + { + ALT: () => { + this.CONSUME1(Role) + this.CONSUME1(To) + this.OR3([ + { + ALT: () => { + this.CONSUME(Manager) + this.OPTION1(() => this.CONSUME(Force)) + this.OPTION2(() => { + this.CONSUME1(Timeout) + this.CONSUME1(NumberLiteral) + }) + }, + }, + { + ALT: () => { + this.CONSUME(Refresher) + this.OPTION3(() => { + this.CONSUME2(Timeout) + this.CONSUME2(NumberLiteral) + }) + }, + }, + ]) + }, + }, + ]) + }, + }, ]) }) diff --git a/src/parser/toSql.ts b/src/parser/toSql.ts index 32f245a..e0fda05 100644 --- a/src/parser/toSql.ts +++ b/src/parser/toSql.ts @@ -1439,7 +1439,12 @@ function backupToSql(stmt: AST.BackupStatement): string { function switchToSql(stmt: AST.SwitchStatement): string { if (stmt.action === "status") return "SWITCH STATUS" - let s = `SWITCH ROLE TO ${stmt.role}` + if (stmt.action === "coldStorageStatus") return "SWITCH COLD STORAGE STATUS" + const coldStorage = stmt.action === "coldStorageRole" + let s = coldStorage + ? `SWITCH COLD STORAGE ROLE TO ${stmt.role}` + : `SWITCH ROLE TO ${stmt.role}` + if (coldStorage && stmt.force) s += " FORCE" if (stmt.timeout !== undefined) s += ` TIMEOUT ${stmt.timeout}` return s } diff --git a/src/parser/tokens.ts b/src/parser/tokens.ts index ae4be8c..55ad1f6 100644 --- a/src/parser/tokens.ts +++ b/src/parser/tokens.ts @@ -337,6 +337,10 @@ export const IDENTIFIER_KEYWORD_NAMES = new globalThis.Set([ "Only", "Align", "Latest", + "Cold", + "Force", + "Manager", + "Refresher", // New constants that can be used as identifiers "Ilp", "Native", @@ -765,6 +769,10 @@ export const Replica = getToken("Replica") export const Timeout = getToken("Timeout") export const Expire = getToken("Expire") export const Cleanup = getToken("Cleanup") +export const Cold = getToken("Cold") +export const Force = getToken("Force") +export const Manager = getToken("Manager") +export const Refresher = getToken("Refresher") export const Highest = getToken("Highest") export const Lowest = getToken("Lowest") export const Live = getToken("Live") diff --git a/src/parser/visitor.ts b/src/parser/visitor.ts index a625b65..e5500eb 100644 --- a/src/parser/visitor.ts +++ b/src/parser/visitor.ts @@ -3187,6 +3187,23 @@ class QuestDBVisitor extends BaseVisitor { } switchStatement(ctx: SwitchStatementCstChildren): AST.SwitchStatement { + if (ctx.Cold) { + if (ctx.Status) { + return { type: "switch", action: "coldStorageStatus" } + } + const result: AST.SwitchStatement = { + type: "switch", + action: "coldStorageRole", + role: ctx.Refresher ? "REFRESHER" : "MANAGER", + } + if (ctx.Force) { + result.force = true + } + if (ctx.Timeout && ctx.NumberLiteral) { + result.timeout = tokenInt(ctx.NumberLiteral[0].image) + } + return result + } if (ctx.Status) { return { type: "switch", action: "status" } } diff --git a/tests/autocomplete.test.ts b/tests/autocomplete.test.ts index 61ecee8..d6f74e7 100644 --- a/tests/autocomplete.test.ts +++ b/tests/autocomplete.test.ts @@ -150,6 +150,35 @@ describe("createAutocompleteProvider", () => { const labels = getLabelsAt(provider, "SELECT * FROM trades ORDER ") expect(labels).toContain("BY") }) + + it("walks through SWITCH COLD STORAGE role and status commands", () => { + assertSuggestionsWalkthrough(provider, [ + { typed: "SWITCH ", expects: ["COLD", "ROLE", "STATUS"] }, + { typed: "SWITCH COLD ", expects: ["STORAGE"] }, + { + typed: "SWITCH COLD STORAGE ", + expects: ["ROLE", "STATUS"], + }, + { typed: "SWITCH COLD STORAGE ROLE ", expects: ["TO"] }, + { + typed: "SWITCH COLD STORAGE ROLE TO ", + expects: ["MANAGER", "REFRESHER"], + }, + { + typed: "SWITCH COLD STORAGE ROLE TO MANAGER ", + expects: ["FORCE", "TIMEOUT"], + }, + { + typed: "SWITCH COLD STORAGE ROLE TO MANAGER FORCE ", + expects: ["TIMEOUT"], + }, + { + typed: "SWITCH COLD STORAGE ROLE TO REFRESHER ", + expects: ["TIMEOUT"], + rejects: ["FORCE"], + }, + ]) + }) }) describe("column suggestions", () => { diff --git a/tests/lexer.test.ts b/tests/lexer.test.ts index 4302e6d..cc7fe7e 100644 --- a/tests/lexer.test.ts +++ b/tests/lexer.test.ts @@ -76,6 +76,25 @@ describe("QuestDB Lexer", () => { expect(result3.tokens[0].tokenType.name).toBe("Select") }) + it("should tokenize cold storage switch keywords", () => { + const result = tokenize( + "SWITCH COLD STORAGE ROLE TO MANAGER FORCE TIMEOUT 10000", + ) + + expect(result.errors).toHaveLength(0) + expect(result.tokens.map((token) => token.tokenType.name)).toEqual([ + "Switch", + "Cold", + "Storage", + "Role", + "To", + "Manager", + "Force", + "Timeout", + "NumberLiteral", + ]) + }) + it("should skip whitespace and comments", () => { const result = tokenize(` -- This is a comment diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 7f61c84..1fb158a 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -8064,6 +8064,57 @@ orders PIVOT (sum(amount) FOR status IN ('open'))` }) }) + describe("SWITCH COLD STORAGE — parse & AST", () => { + it("parses a forced manager switch with a timeout", () => { + const result = parseToAst( + "SWITCH COLD STORAGE ROLE TO MANAGER FORCE TIMEOUT 10000", + ) + expect(result.errors).toHaveLength(0) + expect(result.ast).toHaveLength(1) + + const statement = result.ast[0] as AST.SwitchStatement + expect(statement.type).toBe("switch") + expect(statement.action).toBe("coldStorageRole") + expect(statement.role).toBe("MANAGER") + expect(statement.force).toBe(true) + expect(statement.timeout).toBe(10000) + }) + + it("parses a refresher switch without FORCE", () => { + const statement = parseToAst( + "SWITCH COLD STORAGE ROLE TO REFRESHER TIMEOUT 30000", + ).ast[0] as AST.SwitchStatement + + expect(statement.action).toBe("coldStorageRole") + expect(statement.role).toBe("REFRESHER") + expect(statement.force).toBeUndefined() + expect(statement.timeout).toBe(30000) + }) + + it("parses cold storage status", () => { + const statement = parseToAst("SWITCH COLD STORAGE STATUS") + .ast[0] as AST.SwitchStatement + + expect(statement.action).toBe("coldStorageStatus") + expect(statement.role).toBeUndefined() + }) + + it("is case-insensitive", () => { + expect( + parseToAst("sWiTcH cOlD sToRaGe rOlE tO mAnAgEr fOrCe tImEoUt 10000") + .errors, + ).toHaveLength(0) + }) + + it("keeps the new non-reserved keywords usable as identifiers", () => { + expect( + parseToAst( + "SELECT cold, force, manager, refresher FROM cold AS manager", + ).errors, + ).toHaveLength(0) + }) + }) + describe("GRANT/REVOKE column wildcard + EXCLUDE — parse & AST", () => { it("wildcard", () => { const r = parseToAst("GRANT SELECT ON tab(*) TO alice") @@ -8334,6 +8385,29 @@ orders PIVOT (sum(amount) FOR status IN ('open'))` } }) + describe("SWITCH COLD STORAGE (Enterprise #989) — round-trip", () => { + const queries = [ + "SWITCH COLD STORAGE STATUS", + "SWITCH COLD STORAGE ROLE TO MANAGER", + "SWITCH COLD STORAGE ROLE TO MANAGER FORCE", + "SWITCH COLD STORAGE ROLE TO MANAGER TIMEOUT 10000", + "SWITCH COLD STORAGE ROLE TO MANAGER FORCE TIMEOUT 10000", + "SWITCH COLD STORAGE ROLE TO REFRESHER", + "SWITCH COLD STORAGE ROLE TO REFRESHER TIMEOUT 30000", + ] + + for (const query of queries) { + it(`round-trips: ${query}`, () => { + const parsed = parseToAst(query) + expect(parsed.errors, JSON.stringify(parsed.errors)).toHaveLength(0) + + const reparsed = parseToAst(toSql(parsed.ast[0])) + expect(reparsed.errors).toHaveLength(0) + expect(reparsed.ast[0]).toEqual(parsed.ast[0]) + }) + } + }) + describe("GRANT/REVOKE column wildcard + EXCLUDE (Enterprise #1033) — round-trip", () => { const queries = [ "grant select on t1(*) to ddd",