Skip to content

Commit e224c37

Browse files
emrberkclaude
andcommitted
fix autocomplete: qualified columns, self-join aliases, phantom suggestions, star context
- Filter column suggestions by qualifier in dot-references (t1. only shows t1 columns) - Emit alias-qualified columns in self-joins (t1.amount, t2.amount) to avoid ambiguity - Filter phantom suggestions from computeContentAssist - Distinguish wildcard * (SELECT *) from multiplication * (price *) for expression scope - Suppress function flooding in post-expression position (SELECT * FR -> only keywords) - Suppress suggestions inside string literals and quoted identifiers (parser guard) - Add comprehensive tests for all above scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 32c873c commit e224c37

File tree

5 files changed

+1479
-28
lines changed

5 files changed

+1479
-28
lines changed

src/autocomplete/content-assist.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export interface ContentAssistResult {
7070
isMidWord: boolean
7171
/** Any lexer errors */
7272
lexErrors: ILexingError[]
73+
/**
74+
* When the cursor is after a qualified reference (e.g., "t1." or "trades."),
75+
* this contains the qualifier name (e.g., "t1" or "trades"). The provider
76+
* should resolve this against tablesInScope aliases/names to filter columns.
77+
*/
78+
qualifiedTableRef?: string
7379
}
7480

7581
// =============================================================================
@@ -813,9 +819,11 @@ export function getContentAssist(
813819
}
814820
}
815821

816-
if (tablesInScope.length === 0) {
817-
const inferred = inferTableFromQualifiedRef(tokens, isMidWord)
818-
if (inferred) tablesInScope.push(inferred)
822+
// Detect qualified reference context (e.g., "t1." or "trades.sym")
823+
// and extract the qualifier name so the provider can filter columns.
824+
const qualifiedRef = inferTableFromQualifiedRef(tokens, isMidWord)
825+
if (tablesInScope.length === 0 && qualifiedRef) {
826+
tablesInScope.push(qualifiedRef)
819827
}
820828

821829
return {
@@ -825,6 +833,7 @@ export function getContentAssist(
825833
tokensBefore: tokens,
826834
isMidWord,
827835
lexErrors: lexResult.errors,
836+
qualifiedTableRef: qualifiedRef?.table,
828837
}
829838
}
830839

src/autocomplete/provider.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ function getIdentifierSuggestionScope(
8080
lastTokenName?: string,
8181
prevTokenName?: string,
8282
rawLastTokenName?: string,
83+
rawPrevTokenName?: string,
8384
): {
8485
includeColumns: boolean
8586
includeTables: boolean
@@ -90,6 +91,20 @@ function getIdentifierSuggestionScope(
9091
return { includeColumns: false, includeTables: false }
9192
}
9293

94+
// Star (*) is context-dependent: it's a wildcard after SELECT/comma/LParen,
95+
// but multiplication after an expression (identifier, number, rparen).
96+
// "SELECT * |" → wildcard, suppress columns (alias/keyword position)
97+
// "SELECT price * |" → multiplication, suggest columns for RHS
98+
if (rawLastTokenName === "Star") {
99+
if (rawPrevTokenName && isExpressionEnd(rawPrevTokenName)) {
100+
// Multiplication: previous token is an expression-end, so * is an operator.
101+
// The user needs columns/functions for the right-hand side.
102+
return { includeColumns: true, includeTables: true }
103+
}
104+
// Wildcard: no expression before *, e.g., SELECT *, t.*, or start of expression
105+
return { includeColumns: false, includeTables: false }
106+
}
107+
93108
// After AS keyword: either subquery start (WITH name AS (|) or alias (SELECT x AS |).
94109
if (lastTokenName === "As") {
95110
// "WITH name AS (|" → LParen is raw last → subquery start, suggest tables
@@ -156,6 +171,7 @@ export function createAutocompleteProvider(
156171
cteColumns,
157172
tokensBefore,
158173
isMidWord,
174+
qualifiedTableRef,
159175
} = getContentAssist(query, cursorOffset)
160176

161177
// Merge CTE columns into the schema so getColumnsInScope() can find them
@@ -167,6 +183,43 @@ export function createAutocompleteProvider(
167183
}
168184
: normalizedSchema
169185

186+
// When mid-word, the last token in tokensBefore is a partial word
187+
// the user is still typing. It may have been captured as a table name
188+
// by extractTables (e.g., "FROM te" → {table: "te"}). Filter it out
189+
// to prevent suggesting the incomplete text back to the user.
190+
let effectiveTablesInScope = tablesInScope
191+
if (isMidWord && tokensBefore.length > 0) {
192+
const partialLower =
193+
tokensBefore[tokensBefore.length - 1].image.toLowerCase()
194+
const cteNameSet = new Set(Object.keys(cteColumns))
195+
const schemaLower = new Set(
196+
normalizedSchema.tables.map((t) => t.name.toLowerCase()),
197+
)
198+
effectiveTablesInScope = tablesInScope.filter((t) => {
199+
const lower = t.table.toLowerCase()
200+
return (
201+
lower !== partialLower ||
202+
cteNameSet.has(lower) ||
203+
schemaLower.has(lower)
204+
)
205+
})
206+
}
207+
208+
// When the cursor is in a qualified reference (e.g., "t1." or "trades."),
209+
// resolve the qualifier against tablesInScope aliases/names and filter
210+
// so only that table's columns are suggested.
211+
if (qualifiedTableRef && effectiveTablesInScope.length > 1) {
212+
const qualifierLower = qualifiedTableRef.toLowerCase()
213+
const matched = effectiveTablesInScope.filter(
214+
(t) =>
215+
t.alias?.toLowerCase() === qualifierLower ||
216+
t.table.toLowerCase() === qualifierLower,
217+
)
218+
if (matched.length > 0) {
219+
effectiveTablesInScope = matched
220+
}
221+
}
222+
170223
// If parser returned valid next tokens, use them
171224
if (nextTokenTypes.length > 0) {
172225
// When mid-word, the last token in tokensBefore is the partial word being typed.
@@ -181,15 +234,20 @@ export function createAutocompleteProvider(
181234
tokensForScope.length > 0
182235
? tokensForScope[tokensForScope.length - 1]?.tokenType?.name
183236
: undefined
237+
const rawPrevTokenName =
238+
tokensForScope.length > 1
239+
? tokensForScope[tokensForScope.length - 2]?.tokenType?.name
240+
: undefined
184241
const scope = getIdentifierSuggestionScope(
185242
lastTokenName,
186243
prevTokenName,
187244
rawLastTokenName,
245+
rawPrevTokenName,
188246
)
189247
return buildSuggestions(
190248
nextTokenTypes,
191249
effectiveSchema,
192-
tablesInScope,
250+
effectiveTablesInScope,
193251
{ ...scope, isMidWord },
194252
)
195253
}

src/autocomplete/suggestion-builder.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type { TableRef } from "./content-assist"
2828
*/
2929
interface ColumnWithTable extends ColumnInfo {
3030
tableName: string
31+
tableAlias?: string
3132
}
3233

3334
/**
@@ -48,6 +49,7 @@ function getColumnsInScope(
4849
columns.push({
4950
...col,
5051
tableName: tableRef.table,
52+
tableAlias: tableRef.alias,
5153
})
5254
}
5355
}
@@ -157,36 +159,86 @@ export function buildSuggestions(
157159
: []
158160

159161
// Add columns with HIGH priority (they should appear first).
160-
// Deduplicate by column name, collecting all table names per column.
162+
// When a column name appears from multiple distinct aliased table refs
163+
// (e.g., self-join: trades t1 JOIN trades t2), emit alias-qualified
164+
// suggestions (e.g., "t1.amount", "t2.amount") to avoid ambiguity.
165+
// Otherwise, emit a single bare column name.
161166
if (includeColumns) {
162-
const columnMap = new Map<string, { type: string; tables: string[] }>()
167+
// Group columns by name, collecting each source (alias/table + type).
168+
const columnMap = new Map<
169+
string,
170+
{
171+
sources: {
172+
qualifier: string
173+
type: string
174+
hasAlias: boolean
175+
}[]
176+
}
177+
>()
163178
for (const col of columnsInScope) {
179+
const qualifier = col.tableAlias ?? col.tableName
164180
const existing = columnMap.get(col.name)
165181
if (existing) {
166-
if (!existing.tables.includes(col.tableName)) {
167-
existing.tables.push(col.tableName)
182+
// Only add if this qualifier is new (avoid duplicates from same alias)
183+
if (!existing.sources.some((s) => s.qualifier === qualifier)) {
184+
existing.sources.push({
185+
qualifier,
186+
type: col.type,
187+
hasAlias: !!col.tableAlias,
188+
})
168189
}
169190
} else {
170-
columnMap.set(col.name, { type: col.type, tables: [col.tableName] })
191+
columnMap.set(col.name, {
192+
sources: [
193+
{ qualifier, type: col.type, hasAlias: !!col.tableAlias },
194+
],
195+
})
171196
}
172197
}
173198
for (const [colName, info] of columnMap) {
174-
suggestions.unshift({
175-
label: colName,
176-
kind: SuggestionKind.Column,
177-
insertText: colName,
178-
detail: ` (${info.tables.sort().join(", ")})`,
179-
description: info.tables.length > 1 ? "" : info.type,
180-
priority: SuggestionPriority.High,
181-
})
199+
// Only qualify when there are multiple sources and at least one has
200+
// an explicit alias. This covers self-joins (trades t1 JOIN trades t2)
201+
// without qualifying columns from unrelated CTEs that happen to share
202+
// column names.
203+
const needsQualification =
204+
info.sources.length > 1 && info.sources.some((s) => s.hasAlias)
205+
if (needsQualification) {
206+
// Ambiguous: emit one qualified suggestion per source
207+
for (const source of info.sources) {
208+
const qualified = `${source.qualifier}.${colName}`
209+
suggestions.unshift({
210+
label: qualified,
211+
kind: SuggestionKind.Column,
212+
insertText: qualified,
213+
detail: ` (${source.qualifier})`,
214+
description: source.type,
215+
filterText: colName,
216+
priority: SuggestionPriority.High,
217+
})
218+
}
219+
} else {
220+
// Unambiguous: emit bare column name
221+
const source = info.sources[0]
222+
suggestions.unshift({
223+
label: colName,
224+
kind: SuggestionKind.Column,
225+
insertText: colName,
226+
detail: ` (${source.qualifier})`,
227+
description: source.type,
228+
priority: SuggestionPriority.High,
229+
})
230+
}
182231
}
183232
}
184233

185234
// Add functions when the user is mid-word (typing a prefix).
186235
// This avoids flooding the list with ~300 functions when the user
187236
// just typed "SELECT " with no prefix. Functions are valid in both
188237
// expression context (SELECT md5(...)) and table context (FROM long_sequence(...)).
189-
if (isMidWord) {
238+
// Skip functions in post-expression position (includeColumns=false,
239+
// includeTables=false) — e.g., after "SELECT *" the user is typing a
240+
// keyword (FROM) or alias, not a function call.
241+
if (isMidWord && (includeColumns || includeTables)) {
190242
for (const fn of functions) {
191243
if (seenKeywords.has(fn.toUpperCase())) continue
192244
suggestions.push({

0 commit comments

Comments
 (0)