Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
62367cf
test: add server-query parity harness
emrberk Nov 10, 2026
f46bcad
feat: parse hex, float-suffix, E'...' and typed literals
emrberk Nov 10, 2026
a964c1c
feat: parse GROUP BY / SAMPLE BY ROLLUP, CUBE and GROUPING SETS
emrberk Nov 10, 2026
ac9b186
feat: parse DEDUP SKIP, the DEDUPLICATE synonym and ALTER DEDUP witho…
emrberk Nov 10, 2026
e59df46
fix: require ON in EXPIRE ROWS KEEP HIGHEST|LOWEST and end WHEN at IN…
emrberk Nov 10, 2026
ac28eef
feat: parse implicit select bodies in mat views, CTAS, views and COPY
emrberk Nov 10, 2026
55c8518
fix: unescape '' in single-quoted statement values so they round-trip
emrberk Nov 10, 2026
52c8d03
fix: end duration literals at a word boundary and add the ns unit
emrberk Nov 10, 2026
65d4abd
feat: parse bind variables $n and :name
emrberk Nov 10, 2026
8624c3c
feat: lex backtick-quoted identifiers and $ inside identifiers
emrberk Nov 10, 2026
82f521b
feat: nest block comments, run unterminated ones to the end, skip emp…
emrberk Nov 10, 2026
b602c4a
feat: type 99m / 1d as decimal / double numbers in expressions, lex 1…
emrberk Nov 10, 2026
3765d8c
feat: parse AT TIME ZONE, timestamp WITH TIME ZONE casts and DOUBLE P…
emrberk Nov 10, 2026
9b08b99
feat: parse IS [NOT] TRUE/FALSE, <> ALL (...) and NOT WITHIN
emrberk Nov 10, 2026
0065ffd
feat: parse GROUPS window frames and EXCLUDE GROUP / EXCLUDE TIES
emrberk Nov 10, 2026
1fe1a17
feat: parse (expr).member, stacked unary / NOT operands and chained c…
emrberk Nov 10, 2026
764aea0
feat: DECLARE AUDITED/OVERRIDABLE markers, trailing commas, implicit …
emrberk Nov 10, 2026
c89a663
feat: SAMPLE BY <expr> [unit], FILL(<expr>, ...), ALIGN TO ... TIME Z…
emrberk Nov 10, 2026
e1ef5c0
feat: accept server clause orders (LATEST before WHERE/SAMPLE BY, PIV…
emrberk Nov 10, 2026
731cf41
feat: PIVOT alias after `)`, FOR <expr> IN, IN values aliased without AS
emrberk Nov 10, 2026
217346a
feat: join clause words after the joined table, HORIZON JOIN optional…
emrberk Nov 10, 2026
8336103
feat: schema-qualified table functions in FROM/JOIN, UNION DISTINCT
emrberk Nov 10, 2026
84c258f
feat: CREATE/ALTER TABLE [NOT] NULL column modifiers, CTAS CAST symbo…
emrberk Nov 10, 2026
a9922f7
feat: CREATE MATERIALIZED VIEW TTL without PARTITION BY, bare/DEFERRE…
emrberk Nov 10, 2026
cd66977
feat: ALTER TABLE ADD without COLUMN, per-column IF NOT EXISTS, RENAM…
emrberk Nov 10, 2026
21c786e
feat: CONVERT PARTITION TO PARQUET|NATIVE LIST .. [WITH (..)]
emrberk Nov 10, 2026
322b503
fix: ALTER MATERIALIZED VIEW SUSPEND WAL WITH <code|'tag'>, '<message>'
emrberk Nov 10, 2026
9f19dad
feat: PG session statements BEGIN/COMMIT/ROLLBACK, DEALLOCATE, SET, D…
emrberk Nov 10, 2026
9ad7294
feat: DROP ALL without TABLES, UPDATE t AS alias, COPY TO quoted code…
emrberk Nov 10, 2026
c0cb207
feat: POSTING_DELTA/POSTING_EF, PARQUET byte_stream_split/default cod…
emrberk Nov 10, 2026
0afa016
feat: functions list — missing table-valued/scalar names, files() tab…
emrberk Nov 10, 2026
590b9b6
feat: join/clause/statement words usable as identifiers (server non-r…
emrberk Nov 10, 2026
e0509dc
feat: Enterprise resource groups (CREATE/ALTER/DROP RESOURCE GROUP, S…
emrberk Nov 10, 2026
0df1fe1
feat: Enterprise CREATE [OR REPLACE] VIEW ... WITH AUDIT [OWNED BY x]
emrberk Nov 10, 2026
23830d3
feat: Enterprise GRANT/REVOKE — all permission names, WITH VERIFICATI…
emrberk Nov 10, 2026
c7bc2b9
feat: Enterprise ADD COLUMN OWNED BY, quoted memory limits, unquoted …
emrberk Nov 10, 2026
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
48 changes: 46 additions & 2 deletions src/autocomplete/budgeted-content-assist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,43 @@ import {
Rule,
Terminal,
} from "chevrotain"
import { parser } from "../parser/parser"
import {
ALIAS_STOP_STATEMENT_TOKENS,
IMPLICIT_ALIAS_STOP_TOKENS,
parser,
} from "../parser/parser"
import { As, Case, IdentifierKeyword, Semicolon, When } from "../parser/lexer"

// The parser's implicit-alias GATEs (a join / clause / statement word is not
// an alias without AS), which content assist would otherwise ignore: the
// alias path would consume e.g. `ASOF` in `FROM t ASOF JOIN u` and prune the
// join path.
function isStoppedIdentifier(
terminalType: TokenType,
ruleStack: string[],
tokenVector: IToken[],
idx: number,
): boolean {
if (terminalType !== IdentifierKeyword) return false
// `CASE WHEN`: never a simple-CASE operand named `when`
const tokenType = tokenVector[idx].tokenType
if (tokenType === When && idx > 0 && tokenVector[idx - 1].tokenType === Case)
return true
// `SHOW `, `COPY `, `GRANT ` ...: a statement keyword, not the table of an
// implicit SELECT (`show WHERE ...`) at statement start
if (
ALIAS_STOP_STATEMENT_TOKENS.has(tokenType) &&
(idx === 0 || tokenVector[idx - 1].tokenType === Semicolon) &&
(ruleStack.includes("implicitSelectStatement") ||
ruleStack.includes("pivotStatement"))
)
return true
const n = ruleStack.length
if (n < 2 || ruleStack[n - 1] !== "identifier") return false
const stops = IMPLICIT_ALIAS_STOP_TOKENS.get(ruleStack[n - 2])
if (!stops || !stops.has(tokenVector[idx].tokenType)) return false
return idx === 0 || tokenVector[idx - 1].tokenType !== As
}

export interface ContentAssistSuggestion {
nextTokenType: TokenType
Expand Down Expand Up @@ -147,7 +183,15 @@ function nextPossibleTokensAfterBudgeted(
if (currIdx < tokenVectorLength - 1) {
const nextIdx = currIdx + 1
const actualToken = tokenVector[nextIdx]
if (tokMatcher(actualToken, prod.terminalType)) {
if (
tokMatcher(actualToken, prod.terminalType) &&
!isStoppedIdentifier(
prod.terminalType,
currRuleStack,
tokenVector,
nextIdx,
)
) {
possiblePaths.push({
idx: nextIdx,
def: currDef.slice(1),
Expand Down
227 changes: 205 additions & 22 deletions src/autocomplete/content-assist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ILexingError, IToken, TokenType } from "chevrotain"
import { parse as parseRaw } from "../parser/parser"
import { visitor } from "../parser/visitor"
import { QuestDBLexer } from "../parser/lexer"
import { CLAUSE_IDENTIFIER_KEYWORD_NAMES } from "../parser/tokens"
import type { Statement } from "../parser/ast"
import {
IDENTIFIER_KEYWORD_TOKENS,
Expand Down Expand Up @@ -36,6 +37,7 @@ const WORD_BOUNDARY_CHARS = new Set([
"%",
"'",
'"',
"`",
"|",
"&",
"^",
Expand Down Expand Up @@ -580,10 +582,18 @@ function extractTables(fullSql: string, tokens: IToken[]): ExtractResult {

let alias: string | undefined
let aliasStart = tableNameResult.nextIndex
if (tokens[aliasStart]?.tokenType.name === "As") {
const explicitAs = tokens[aliasStart]?.tokenType.name === "As"
if (explicitAs) {
aliasStart++
}
if (isIdentifierLike(tokens[aliasStart])) {
if (
isIdentifierLike(tokens[aliasStart]) &&
// `FROM t GROUP BY`, `FROM t LEFT JOIN`: clause words are no alias
(explicitAs ||
!CLAUSE_IDENTIFIER_KEYWORD_NAMES.has(
tokens[aliasStart].tokenType.name,
))
) {
alias = tokenToNamePart(tokens[aliasStart])
}

Expand Down Expand Up @@ -872,6 +882,90 @@ function contextFunctionSuggestions(
return inSubsample ? [...SUBSAMPLE_METHODS] : []
}

// Enterprise permission names (Permission.java namePermissionMap)
export const PERMISSION_NAMES: readonly string[] = [
"ALL",
"SELECT",
"INSERT",
"UPDATE",
"CREATE TABLE",
"DROP TABLE",
"RENAME TABLE",
"TRUNCATE TABLE",
"ADD COLUMN",
"DROP COLUMN",
"RENAME COLUMN",
"ALTER COLUMN TYPE",
"ALTER COLUMN CACHE",
"ALTER SYMBOL CAPACITY",
"ADD INDEX",
"DROP INDEX",
"REINDEX",
"ATTACH PARTITION",
"DETACH PARTITION",
"DROP PARTITION",
"SET TABLE PARAM",
"SET TABLE TYPE",
"SET TABLE FORMAT",
"RESUME WAL",
"CREATE GROUP",
"DROP GROUP",
"CREATE USER",
"DROP USER",
"CREATE SERVICE ACCOUNT",
"DROP SERVICE ACCOUNT",
"ADD USER",
"REMOVE USER",
"CANCEL ANY COPY",
"BACKUP TABLE",
"BACKUP DATABASE",
"SNAPSHOT",
"VACUUM TABLE",
"ENABLE USER",
"DISABLE USER",
"ADD PASSWORD",
"REMOVE PASSWORD",
"ADD EXTERNAL ALIAS",
"REMOVE EXTERNAL ALIAS",
"CREATE JWK",
"DROP JWK",
"CREATE REST TOKEN",
"DROP REST TOKEN",
"LIST USERS",
"USER DETAILS",
"DEDUP ENABLE",
"DEDUP DISABLE",
"HTTP",
"PGWIRE",
"ILP",
"SQL ENGINE ADMIN",
"SYSTEM ADMIN",
"CREATE MATERIALIZED VIEW",
"DROP MATERIALIZED VIEW",
"REFRESH MATERIALIZED VIEW",
"SETTINGS",
"DATABASE ADMIN",
"AUDIT VIEW",
"CREATE VIEW",
"DROP VIEW",
"CREATE LIVE VIEW",
"DROP LIVE VIEW",
"COMPILE VIEW",
"ALTER VIEW",
"SET REFRESH LIMIT",
"SET REFRESH TYPE",
"CONVERT PARTITION TO PARQUET",
"CONVERT PARTITION TO NATIVE",
"SET PARQUET SETTINGS",
"SET STORAGE POLICY",
"REMOVE STORAGE POLICY",
"ENABLE STORAGE POLICY",
"DISABLE STORAGE POLICY",
"SWITCH ROLE",
"SET MEMORY LIMIT",
"RESOURCE GROUP ADMIN",
]

// Category names valid inside SHOW CREATE DATABASE (INCLUDE|EXCLUDE) ( ... ).
// The grammar consumes these via `identifier` (they are non-reserved), so only
// the explicit `ALL` token surfaces on its own; re-inject the rest.
Expand Down Expand Up @@ -912,27 +1006,21 @@ function contextKeywordSuggestions(
idKeywordStacks.some((stack) => stack.includes(r))

const last = tokens[tokens.length - 1]?.tokenType.name
const prev = tokens[tokens.length - 2]?.tokenType.name
const out = new Set<string>()

// GRANT / REVOKE permission phrases whose words route through `identifier`:
// CONVERT PARTITION [TO PARQUET|NATIVE], SET TABLE FORMAT, SET TABLE TYPE,
// SET STORAGE POLICY, REMOVE STORAGE POLICY.
// (SET / TABLE / TO / PARQUET / NATIVE at their spots are explicit CONSUMEs
// and already surface; we only add the identifier-path words.)
// GRANT / REVOKE permission phrases: the grammar takes any 1-3 words (most
// via `identifier`), so offer the next word of every server permission name
// that extends the words typed so far in the current list item.
if (inRule("permissionToken")) {
if (last === "Grant" || last === "Revoke") {
out.add("CONVERT")
out.add("REMOVE")
} else if (last === "Convert") {
out.add("PARTITION")
} else if (last === "Set" || last === "Remove") {
out.add("STORAGE")
} else if (last === "Table" && prev === "Set") {
out.add("FORMAT")
out.add("TYPE")
} else if (last === "Storage" && (prev === "Set" || prev === "Remove")) {
out.add("POLICY")
const words: string[] = []
for (let i = tokens.length - 1; i >= 0; i--) {
const name = tokens[i].tokenType.name
if (name === "Grant" || name === "Revoke" || name === "Comma") break
words.unshift(tokens[i].image.toUpperCase())
}
const prefix = words.length > 0 ? words.join(" ") + " " : ""
for (const perm of PERMISSION_NAMES) {
if (perm.startsWith(prefix)) out.add(perm.split(" ")[words.length])
}
}

Expand All @@ -951,6 +1039,44 @@ function contextKeywordSuggestions(
return [...out]
}

const QUIET_EXPRESSION_ANCHORS = [
"sampleByClause",
"alignToClause",
"fillValue",
]
// Literal / variable / punctuation tokens stay valid there (no keyword noise)
const QUIET_EXPRESSION_KEPT_TOKENS = new Set([
"DurationLiteral",
"NumberLiteral",
"LongLiteral",
"DecimalLiteral",
"FloatLiteral",
"StringLiteral",
"EscapeStringLiteral",
"VariableReference",
"BindVariable",
"Minus",
"LParen",
])

/**
* If the ruleStack is at the top level of the expression directly under
* SAMPLE BY, ALIGN TO (TIME ZONE / WITH OFFSET) or a FILL value, return that
* anchor rule. Nested expressions (function arguments, parentheses) are not
* top level.
*/
function quietExpressionAnchor(ruleStack: string[]): string | undefined {
for (let i = ruleStack.length - 2; i >= 0; i--) {
if (!QUIET_EXPRESSION_ANCHORS.includes(ruleStack[i])) continue
if (ruleStack[i + 1] !== "expression") return undefined
const nested = ruleStack
.slice(i + 2)
.some((r) => r === "expression" || r === "selectStatement")
return nested ? undefined : ruleStack[i]
}
return undefined
}

/**
* Compute content assist suggestions, handling CTE context specially.
*
Expand All @@ -973,7 +1099,59 @@ function computeSuggestions(tokens: IToken[]): ComputeResult {
: "statement"
// Budgeted: aborts after a fixed number of path-exploration steps.
// The budget keeps autocomplete responsive and falls back to empty suggestions when hit.
const { suggestions } = computeContentAssistBudgeted(ruleName, tokens)
const { suggestions: rawSuggestions } = computeContentAssistBudgeted(
ruleName,
tokens,
)
// timestampWithTimeZoneLiteral opens with explicit TIMESTAMP / TIMESTAMP_NS
// tokens. Content assist ignores its GATE, so without this filter those two
// keywords would be offered wherever an expression can start. Only the
// rule's first token is dropped; WITH TIME ZONE after `timestamp` stays.
// columnDefinition only takes DOUBLE PRECISION in CREATE TABLE (a rule
// argument gates it); ALTER TABLE ADD COLUMN shares the rule, so drop
// PRECISION there as well.
// SAMPLE BY / FILL / ALIGN TO ... TIME ZONE / WITH OFFSET take any
// expression on the server, but at the top level of those expressions we
// keep the short lists offered before (nothing for SAMPLE BY and ALIGN TO,
// NULL / PREV / LINEAR / NONE for FILL) instead of every column, function
// and operator.
let fillOperandStart = false
const suggestions = rawSuggestions.filter((s) => {
const quiet = quietExpressionAnchor(s.ruleStack)
if (quiet === "fillValue" && s.nextTokenType.name === "Null") {
fillOperandStart = true
return true
}
return !(
(quiet && !QUIET_EXPRESSION_KEPT_TOKENS.has(s.nextTokenType.name)) ||
(s.ruleStack[s.ruleStack.length - 1] === "timestampWithTimeZoneLiteral" &&
(s.nextTokenType.name === "Timestamp" ||
s.nextTokenType.name === "TimestampNs")) ||
(s.nextTokenType.name === "Precision" &&
(s.ruleStack.includes("alterTableAction") ||
s.ruleStack[s.ruleStack.length - 1] === "columnNullModifier")) ||
// SET <name> ... takes free-form tokens; only offer TO after the name
(s.ruleStack[s.ruleStack.length - 1] === "setStatement" &&
(s.nextTokenType.name !== "To" ||
tokens[tokens.length - 1]?.tokenType.name === "Set")) ||
// ARRAY[...] literals: not offered at every expression start (it was an
// identifier before); ARRAY stays suggested as a type name
(s.nextTokenType.name === "Array" &&
s.ruleStack[s.ruleStack.length - 1] === "arrayLiteral") ||
// IS / ILIKE / DECLARE are accepted as column names, not suggested
(s.ruleStack[s.ruleStack.length - 1] === "columnDefinition" &&
(s.nextTokenType.name === "Is" ||
s.nextTokenType.name === "Ilike" ||
s.nextTokenType.name === "Declare")) ||
// ANY only continues CANCEL in permission names (CANCEL ANY COPY)
(s.nextTokenType.name === "Any" &&
s.ruleStack[s.ruleStack.length - 1] === "permissionToken" &&
tokens[tokens.length - 1]?.tokenType.name !== "Cancel") ||
// INCREMENTAL is an obsolete synonym of IMMEDIATE in REFRESH modes
(s.nextTokenType.name === "Incremental" &&
s.ruleStack[s.ruleStack.length - 1] === "materializedViewRefresh")
)
})
const result = suggestions.map((s) => s.nextTokenType)

// Walk every IdentifierKeyword path and union the category flags valid at
Expand Down Expand Up @@ -1028,6 +1206,7 @@ function computeSuggestions(tokens: IToken[]): ComputeResult {
)

const contextKeywords = contextKeywordSuggestions(tokens, suggestions)
if (fillOperandStart) contextKeywords.push("PREV", "LINEAR", "NONE")
const contextFunctions = contextFunctionSuggestions(tokens, suggestions)

return {
Expand Down Expand Up @@ -1157,7 +1336,11 @@ export function getContentAssist(
// problems than it solves (prefix filtering breaks, ambiguity with string values).
const fullTokens = QuestDBLexer.tokenize(fullSql).tokens
for (const token of fullTokens) {
if (token.tokenType.name !== "StringLiteral") continue
if (
token.tokenType.name !== "StringLiteral" &&
token.tokenType.name !== "EscapeStringLiteral"
)
continue
const start = token.startOffset
const end = token.startOffset + token.image.length
if (cursorOffset > start && cursorOffset < end) {
Expand Down
6 changes: 6 additions & 0 deletions src/autocomplete/token-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,20 @@ export const SKIP_TOKENS = new Set([
"BitOr",
// Variable references (user-defined, can't suggest)
"VariableReference",
// Bind variables ($1, :name)
"BindVariable",
// Literals (don't suggest literal tokens)
"StringLiteral",
"NumberLiteral",
"LongLiteral",
"DecimalLiteral",
"DurationLiteral",
"MalformedDurationLiteral",
"GeohashLiteral",
"GeohashBinaryLiteral",
"HexLiteral",
"FloatLiteral",
"EscapeStringLiteral",
"Nan",
// Whitespace
"WhiteSpace",
Expand Down
Loading
Loading