Skip to content

Commit 409b23f

Browse files
authored
grammar-level table/column classification, join-specific suggestions (#2)
1 parent 85afedf commit 409b23f

File tree

7 files changed

+1049
-317
lines changed

7 files changed

+1049
-317
lines changed

src/autocomplete/content-assist.ts

Lines changed: 130 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ export interface ContentAssistResult {
7676
* should resolve this against tablesInScope aliases/names to filter columns.
7777
*/
7878
qualifiedTableRef?: string
79+
/** Whether the grammar context expects column names (expression/columnRef positions) */
80+
suggestColumns: boolean
81+
/** Whether the grammar context expects table names (tableName positions, or expression context) */
82+
suggestTables: boolean
7983
}
8084

8185
// =============================================================================
@@ -239,6 +243,23 @@ function extractTablesFromAst(ast: unknown): ExtractResult {
239243
}
240244
}
241245

246+
// Handle ALTER TABLE / ALTER MATERIALIZED VIEW
247+
if (
248+
(n.type === "alterTable" || n.type === "alterMaterializedView") &&
249+
n.table
250+
) {
251+
const tableName = normalizeTableName(n.table)
252+
if (tableName) {
253+
tables.push({ table: tableName })
254+
}
255+
}
256+
if (n.type === "alterMaterializedView" && n.view) {
257+
const viewName = normalizeTableName(n.view)
258+
if (viewName) {
259+
tables.push({ table: viewName })
260+
}
261+
}
262+
242263
// Recurse into child nodes
243264
for (const key of Object.keys(n)) {
244265
const child = n[key]
@@ -511,28 +532,46 @@ function extractTables(fullSql: string, tokens: IToken[]): ExtractResult {
511532

512533
// Scan for FROM/JOIN table references only in the outer query (after CTEs).
513534
// This avoids leaking tables referenced inside CTE bodies into the outer scope.
535+
// Also detect ALTER TABLE / TRUNCATE TABLE patterns for column scoping.
536+
const DDL_TABLE_PREFIXES = new Set(["Alter", "Truncate", "Drop"])
514537
for (let i = outerQueryStart; i < tokens.length; i++) {
515-
if (!TABLE_PREFIX_TOKENS.has(tokens[i].tokenType.name)) continue
538+
const tokenName = tokens[i].tokenType.name
516539

517-
const tableNameResult = readQualifiedName(i + 1)
518-
if (!tableNameResult) continue
540+
// Standard DML: FROM/JOIN/UPDATE/INTO <table>
541+
if (TABLE_PREFIX_TOKENS.has(tokenName)) {
542+
const tableNameResult = readQualifiedName(i + 1)
543+
if (!tableNameResult) continue
519544

520-
let alias: string | undefined
521-
let aliasStart = tableNameResult.nextIndex
522-
if (tokens[aliasStart]?.tokenType.name === "As") {
523-
aliasStart++
524-
}
525-
if (isIdentifierLike(tokens[aliasStart])) {
526-
alias = tokenToNamePart(tokens[aliasStart])
527-
}
545+
let alias: string | undefined
546+
let aliasStart = tableNameResult.nextIndex
547+
if (tokens[aliasStart]?.tokenType.name === "As") {
548+
aliasStart++
549+
}
550+
if (isIdentifierLike(tokens[aliasStart])) {
551+
alias = tokenToNamePart(tokens[aliasStart])
552+
}
528553

529-
tables.push({
530-
table: tableNameResult.name,
531-
alias,
532-
})
554+
tables.push({
555+
table: tableNameResult.name,
556+
alias,
557+
})
558+
559+
// Continue from where we consumed table/alias to avoid duplicate captures.
560+
i = alias ? aliasStart : tableNameResult.nextIndex - 1
561+
continue
562+
}
533563

534-
// Continue from where we consumed table/alias to avoid duplicate captures.
535-
i = alias ? aliasStart : tableNameResult.nextIndex - 1
564+
// DDL: ALTER TABLE / TRUNCATE TABLE / DROP TABLE <name>
565+
if (
566+
DDL_TABLE_PREFIXES.has(tokenName) &&
567+
tokens[i + 1]?.tokenType.name === "Table"
568+
) {
569+
const tableNameResult = readQualifiedName(i + 2)
570+
if (tableNameResult) {
571+
tables.push({ table: tableNameResult.name })
572+
i = tableNameResult.nextIndex - 1
573+
}
574+
}
536575
}
537576
for (const name of cteNames) {
538577
tables.push({ table: name })
@@ -615,6 +654,32 @@ function collapseTrailingQualifiedRef(tokens: IToken[]): IToken[] | null {
615654
return [...tokens.slice(0, start), lastToken]
616655
}
617656

657+
/**
658+
* Classify an identifier suggestion path based on its ruleStack.
659+
* - "column": identifierExpression or columnRef → suggest columns + tables
660+
* - "table": tableName rule → suggest tables only
661+
* - "newName": everything else (CREATE TABLE name, user names, etc.) → no suggestions
662+
*/
663+
function classifyIdentifierPath(
664+
ruleStack: string[],
665+
): "column" | "table" | "newName" {
666+
if (ruleStack.includes("valuesClause")) return "newName"
667+
if (
668+
ruleStack.includes("identifierExpression") ||
669+
ruleStack.includes("columnRef") ||
670+
ruleStack.includes("qualifiedStar")
671+
)
672+
return "column"
673+
if (ruleStack.includes("tableName")) return "table"
674+
return "newName"
675+
}
676+
677+
interface ComputeResult {
678+
nextTokenTypes: TokenType[]
679+
suggestColumns: boolean
680+
suggestTables: boolean
681+
}
682+
618683
/**
619684
* Compute content assist suggestions, handling CTE context specially.
620685
*
@@ -623,7 +688,7 @@ function collapseTrailingQualifiedRef(tokens: IToken[]): IToken[] | null {
623688
* updateStatement paths. This function detects that case and merges suggestions
624689
* from all WITH-capable statement types.
625690
*/
626-
function computeSuggestions(tokens: IToken[]): TokenType[] {
691+
function computeSuggestions(tokens: IToken[]): ComputeResult {
627692
const ruleName = tokens.some((t) => t.tokenType.name === "Semicolon")
628693
? "statements"
629694
: "statement"
@@ -638,37 +703,58 @@ function computeSuggestions(tokens: IToken[]): TokenType[] {
638703
const specific = suggestions.filter(
639704
(s) => !isImplicitStatementPath(s.ruleStack, IMPLICIT_RULES),
640705
)
641-
const result = (specific.length > 0 ? specific : suggestions).map(
642-
(s) => s.nextTokenType,
643-
)
706+
const effectiveSuggestions = specific.length > 0 ? specific : suggestions
707+
const result = effectiveSuggestions.map((s) => s.nextTokenType)
708+
709+
// Classify each IdentifierKeyword path to determine whether columns/tables
710+
// should be suggested, based on the grammar rule that expects the identifier.
711+
let suggestColumns = false
712+
let suggestTables = false
713+
for (const s of effectiveSuggestions) {
714+
if (s.nextTokenType.name === "IdentifierKeyword") {
715+
const cls = classifyIdentifierPath(s.ruleStack)
716+
if (cls === "column") {
717+
suggestColumns = true
718+
suggestTables = true
719+
} else if (cls === "table") {
720+
suggestTables = true
721+
}
722+
}
723+
}
644724

645725
// qualifiedStar fix: When computeContentAssist finds the qualifiedStar
646726
// path in selectItem (suggesting just Dot), the expression path is missed.
647727
// Detect this by checking if the *specific* (non-catch-all) suggestions are
648728
// all from qualifiedStar, then re-compute with the qualified reference
649729
// collapsed to a single identifier to get expression-path suggestions.
650-
const effectiveSuggestions = specific.length > 0 ? specific : suggestions
651730
if (
652731
effectiveSuggestions.length > 0 &&
653732
effectiveSuggestions.every((s) => s.ruleStack.includes("qualifiedStar"))
654733
) {
655-
// Find and collapse the trailing qualified reference (ident.ident...ident)
656-
// into a single identifier token, then re-compute to get expression-path suggestions.
657734
const collapsed = collapseTrailingQualifiedRef(tokens)
658735
if (collapsed) {
659736
try {
660737
const extra = parser.computeContentAssist(ruleName, collapsed)
661738
const filteredExtra = extra.filter(
662739
(s) => !isImplicitStatementPath(s.ruleStack, IMPLICIT_RULES),
663740
)
664-
const extraResult = (
741+
const extraEffective =
665742
filteredExtra.length > 0 ? filteredExtra : extra
666-
).map((s) => s.nextTokenType)
667743
const seen = new Set(result.map((t) => t.name))
668-
for (const t of extraResult) {
669-
if (!seen.has(t.name)) {
670-
seen.add(t.name)
671-
result.push(t)
744+
for (const s of extraEffective) {
745+
if (!seen.has(s.nextTokenType.name)) {
746+
seen.add(s.nextTokenType.name)
747+
result.push(s.nextTokenType)
748+
}
749+
// Classify extra paths too
750+
if (s.nextTokenType.name === "IdentifierKeyword") {
751+
const cls = classifyIdentifierPath(s.ruleStack)
752+
if (cls === "column") {
753+
suggestColumns = true
754+
suggestTables = true
755+
} else if (cls === "table") {
756+
suggestTables = true
757+
}
672758
}
673759
}
674760
} catch (e) {
@@ -677,7 +763,7 @@ function computeSuggestions(tokens: IToken[]): TokenType[] {
677763
}
678764
}
679765

680-
return result
766+
return { nextTokenTypes: result, suggestColumns, suggestTables }
681767
}
682768

683769
/**
@@ -738,6 +824,8 @@ export function getContentAssist(
738824
tokensBefore: [],
739825
isMidWord: true,
740826
lexErrors: [],
827+
suggestColumns: false,
828+
suggestTables: false,
741829
}
742830
}
743831
}
@@ -764,8 +852,13 @@ export function getContentAssist(
764852

765853
// Get syntactically valid next tokens using Chevrotain's content assist
766854
let nextTokenTypes: TokenType[] = []
855+
let suggestColumns = false
856+
let suggestTables = false
767857
try {
768-
nextTokenTypes = computeSuggestions(tokensForAssist)
858+
const computed = computeSuggestions(tokensForAssist)
859+
nextTokenTypes = computed.nextTokenTypes
860+
suggestColumns = computed.suggestColumns
861+
suggestTables = computed.suggestTables
769862
} catch (e) {
770863
// If content assist fails, return empty suggestions
771864
// This can happen with malformed input
@@ -834,6 +927,8 @@ export function getContentAssist(
834927
isMidWord,
835928
lexErrors: lexResult.errors,
836929
qualifiedTableRef: qualifiedRef?.table,
930+
suggestColumns,
931+
suggestTables,
837932
}
838933
}
839934

@@ -843,7 +938,9 @@ export function getContentAssist(
843938
export function getNextValidTokens(sql: string): string[] {
844939
const lexResult = QuestDBLexer.tokenize(sql)
845940
try {
846-
return computeSuggestions(lexResult.tokens).map((t) => t.name)
941+
return computeSuggestions(lexResult.tokens).nextTokenTypes.map(
942+
(t) => t.name,
943+
)
847944
} catch (e) {
848945
return []
849946
}

0 commit comments

Comments
 (0)