Skip to content

Commit 8609aff

Browse files
committed
fix: Fix autocomplete table name issues.
1 parent 42e9e81 commit 8609aff

4 files changed

Lines changed: 177 additions & 17 deletions

File tree

packages/server/src/complete/candidates/createTableCandidates.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ export function createCatalogDatabaseAndTableCandidates(
2727
const qualificationLevel = lastToken.split('.').length - 1
2828

2929
const qualifiedEntities = tables.flatMap((table) => {
30+
const results: Identifier[] = []
31+
32+
// When user types without dots (qualificationLevel === 0), always include
33+
// a table name suggestion. This allows typing "act" to match "actor" even
34+
// if the table has a database (e.g., "squeal.actor").
35+
if (qualificationLevel === 0) {
36+
const tableIdentifier = new Identifier(
37+
lastToken,
38+
table.tableName,
39+
'',
40+
ICONS.TABLE,
41+
onFromClause ? 'FROM' : 'OTHERS'
42+
)
43+
results.push(tableIdentifier)
44+
}
45+
46+
// Also add qualified suggestions (catalog/database) based on qualification level
3047
let qualificationNeeded = 0
3148
if (table.catalog) {
3249
qualificationNeeded++
@@ -37,14 +54,18 @@ export function createCatalogDatabaseAndTableCandidates(
3754
const qualificationLevelNeeded = qualificationNeeded - qualificationLevel
3855
switch (qualificationLevelNeeded) {
3956
case 0: {
40-
const tableIdentifier = new Identifier(
41-
lastToken,
42-
getFullyQualifiedTableName(table),
43-
'',
44-
ICONS.TABLE,
45-
onFromClause ? 'FROM' : 'OTHERS'
46-
)
47-
return [tableIdentifier]
57+
// Only add fully qualified name if we haven't already added just the table name
58+
if (qualificationLevel > 0) {
59+
const tableIdentifier = new Identifier(
60+
lastToken,
61+
getFullyQualifiedTableName(table),
62+
'',
63+
ICONS.TABLE,
64+
onFromClause ? 'FROM' : 'OTHERS'
65+
)
66+
results.push(tableIdentifier)
67+
}
68+
break
4869
}
4970
case 1: {
5071
const qualifiedDatabaseName =
@@ -60,7 +81,7 @@ export function createCatalogDatabaseAndTableCandidates(
6081
ICONS.DATABASE,
6182
onFromClause ? 'FROM' : 'OTHERS'
6283
)
63-
return [databaseIdentifier]
84+
results.push(databaseIdentifier)
6485
}
6586
break
6687
}
@@ -73,11 +94,11 @@ export function createCatalogDatabaseAndTableCandidates(
7394
ICONS.CATALOG,
7495
onFromClause ? 'FROM' : 'OTHERS'
7596
)
76-
return [catalogIdentifier]
97+
results.push(catalogIdentifier)
7798
}
7899
break
79100
}
80-
return []
101+
return results
81102
})
82103

83104
return qualifiedEntities

packages/server/src/complete/complete.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -350,14 +350,39 @@ class Completer {
350350
if (!ast.distinct) {
351351
this.addCandidate(toCompletionItemForKeyword('DISTINCT'))
352352
}
353+
354+
// Check if cursor is inside a FROM clause table reference
355+
// This handles the case where "SELECT * FROM a" parses successfully
356+
// but we still want to suggest tables starting with "a"
357+
const parsedFromClause = getFromNodesFromClause(this.sql)
358+
const fromNodes = parsedFromClause?.from?.tables || []
359+
const subqueryTables = createTablesFromFromNodes(fromNodes)
360+
const schemaAndSubqueries = this.schema.tables.concat(subqueryTables)
361+
362+
for (const tableNode of fromNodes) {
363+
if (tableNode.type === 'table') {
364+
// Check if the lastToken matches the table name (user is typing the table name)
365+
// This means the cursor is ON the table name, not after it (like typing an alias)
366+
const tableNameMatches =
367+
this.lastToken.length > 0 &&
368+
tableNode.table.toLowerCase().startsWith(this.lastToken.toLowerCase())
369+
370+
if (tableNameMatches && isPosInLocation(tableNode.location, this.pos)) {
371+
// Cursor is typing a table name - suggest tables
372+
this.addCandidatesForTables(schemaAndSubqueries, true)
373+
if (logger.isDebugEnabled())
374+
logger.debug(
375+
`parse query returns: ${JSON.stringify(this.candidates)}`
376+
)
377+
return
378+
}
379+
}
380+
}
381+
353382
const columnRef = findColumnAtPosition(ast, this.pos)
354383
if (!columnRef) {
355384
this.addJoinCondidates(ast)
356385
} else {
357-
const parsedFromClause = getFromNodesFromClause(this.sql)
358-
const fromNodes = parsedFromClause?.from?.tables || []
359-
const subqueryTables = createTablesFromFromNodes(fromNodes)
360-
const schemaAndSubqueries = this.schema.tables.concat(subqueryTables)
361386
if (columnRef.table) {
362387
// We know what table/alias this column belongs to
363388
// Find the corresponding table and suggest it's columns

packages/server/test/complete.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -553,13 +553,19 @@ describe('Fully qualified table names', () => {
553553
expect(result.candidates).toEqual(expect.arrayContaining(expected))
554554
})
555555

556-
test('not complete table name when not qualified', () => {
556+
test('complete table name when not qualified', () => {
557+
// After the fix for GitHub issue #24, typing a partial table name should
558+
// match tables even if they require qualification (have database/catalog).
559+
// This allows users to type "tabl" and get "table2" and "table3" suggestions.
557560
const result = complete(
558561
'SELECT * FROM tabl',
559562
{ line: 0, column: 18 },
560563
SIMPLE_NESTED_SCHEMA
561564
)
562-
expect(result.candidates.length).toEqual(0)
565+
// Should match table2 and table3
566+
const labels = result.candidates.map((c) => c.label)
567+
expect(labels).toContain('table2')
568+
expect(labels).toContain('table3')
563569
})
564570
test('complete alias when table', () => {
565571
const result = complete(

packages/server/test/complete/complete_table.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,112 @@ describe('TableName completion', () => {
149149
const TABLE_KIND = 21
150150
expect(result.candidates.every((c) => c.kind === TABLE_KIND)).toBe(true)
151151
})
152+
153+
test('complete table name with database-qualified tables', () => {
154+
const schema = {
155+
tables: [
156+
{
157+
catalog: null,
158+
database: 'squeal',
159+
tableName: 'actor',
160+
columns: [{ columnName: 'actor_id', description: '' }],
161+
},
162+
{
163+
catalog: null,
164+
database: 'squeal',
165+
tableName: 'actor_info',
166+
columns: [{ columnName: 'actor_id', description: '' }],
167+
},
168+
{
169+
catalog: null,
170+
database: 'squeal',
171+
tableName: 'film',
172+
columns: [{ columnName: 'film_id', description: '' }],
173+
},
174+
],
175+
functions: [],
176+
}
177+
178+
// Test: typing 'a' after FROM should match 'actor' and 'actor_info'
179+
const result = complete('SELECT * FROM a', { line: 0, column: 15 }, schema)
180+
181+
const labels = result.candidates.map((c) => c.label)
182+
expect(labels).toContain('actor')
183+
expect(labels).toContain('actor_info')
184+
expect(labels).not.toContain('film')
185+
})
186+
187+
test('complete table name when SQL parses successfully', () => {
188+
// This tests Issue 1 - when the parser treats partial table name as valid
189+
const schema = {
190+
tables: [
191+
{ catalog: null, database: null, tableName: 'actor', columns: [] },
192+
{ catalog: null, database: null, tableName: 'actor_info', columns: [] },
193+
{ catalog: null, database: null, tableName: 'film', columns: [] },
194+
],
195+
functions: [],
196+
}
197+
198+
const result = complete(
199+
'SELECT * FROM act',
200+
{ line: 0, column: 17 },
201+
schema
202+
)
203+
204+
const labels = result.candidates.map((c) => c.label)
205+
expect(labels).toContain('actor')
206+
expect(labels).toContain('actor_info')
207+
expect(labels).not.toContain('film')
208+
})
209+
210+
test('complete table name with partial input after typing more characters', () => {
211+
const schema = {
212+
tables: [
213+
{
214+
catalog: null,
215+
database: 'squeal',
216+
tableName: 'actor',
217+
columns: [],
218+
},
219+
{
220+
catalog: null,
221+
database: 'squeal',
222+
tableName: 'actor_info',
223+
columns: [],
224+
},
225+
{
226+
catalog: null,
227+
database: 'squeal',
228+
tableName: 'film',
229+
columns: [],
230+
},
231+
{
232+
catalog: null,
233+
database: 'squeal',
234+
tableName: 'film_actor',
235+
columns: [],
236+
},
237+
{
238+
catalog: null,
239+
database: 'squeal',
240+
tableName: 'customer',
241+
columns: [],
242+
},
243+
],
244+
functions: [],
245+
}
246+
247+
// Test: typing 'fil' should match 'film' and 'film_actor'
248+
const result = complete(
249+
'SELECT * FROM fil',
250+
{ line: 0, column: 17 },
251+
schema
252+
)
253+
254+
const labels = result.candidates.map((c) => c.label)
255+
expect(labels).toContain('film')
256+
expect(labels).toContain('film_actor')
257+
expect(labels).not.toContain('actor')
258+
expect(labels).not.toContain('customer')
259+
})
152260
})

0 commit comments

Comments
 (0)