diff --git a/packages/server/src/complete/candidates/createTableCandidates.ts b/packages/server/src/complete/candidates/createTableCandidates.ts index 58d396d2..db47cf4f 100644 --- a/packages/server/src/complete/candidates/createTableCandidates.ts +++ b/packages/server/src/complete/candidates/createTableCandidates.ts @@ -27,6 +27,23 @@ export function createCatalogDatabaseAndTableCandidates( const qualificationLevel = lastToken.split('.').length - 1 const qualifiedEntities = tables.flatMap((table) => { + const results: Identifier[] = [] + + // When user types without dots (qualificationLevel === 0), always include + // a table name suggestion. This allows typing "act" to match "actor" even + // if the table has a database (e.g., "squeal.actor"). + if (qualificationLevel === 0) { + const tableIdentifier = new Identifier( + lastToken, + table.tableName, + '', + ICONS.TABLE, + onFromClause ? 'FROM' : 'OTHERS' + ) + results.push(tableIdentifier) + } + + // Also add qualified suggestions (catalog/database) based on qualification level let qualificationNeeded = 0 if (table.catalog) { qualificationNeeded++ @@ -37,14 +54,18 @@ export function createCatalogDatabaseAndTableCandidates( const qualificationLevelNeeded = qualificationNeeded - qualificationLevel switch (qualificationLevelNeeded) { case 0: { - const tableIdentifier = new Identifier( - lastToken, - getFullyQualifiedTableName(table), - '', - ICONS.TABLE, - onFromClause ? 'FROM' : 'OTHERS' - ) - return [tableIdentifier] + // Only add fully qualified name if we haven't already added just the table name + if (qualificationLevel > 0) { + const tableIdentifier = new Identifier( + lastToken, + getFullyQualifiedTableName(table), + '', + ICONS.TABLE, + onFromClause ? 'FROM' : 'OTHERS' + ) + results.push(tableIdentifier) + } + break } case 1: { const qualifiedDatabaseName = @@ -60,7 +81,7 @@ export function createCatalogDatabaseAndTableCandidates( ICONS.DATABASE, onFromClause ? 'FROM' : 'OTHERS' ) - return [databaseIdentifier] + results.push(databaseIdentifier) } break } @@ -73,11 +94,11 @@ export function createCatalogDatabaseAndTableCandidates( ICONS.CATALOG, onFromClause ? 'FROM' : 'OTHERS' ) - return [catalogIdentifier] + results.push(catalogIdentifier) } break } - return [] + return results }) return qualifiedEntities diff --git a/packages/server/src/complete/complete.ts b/packages/server/src/complete/complete.ts index 20de6338..e2b67694 100644 --- a/packages/server/src/complete/complete.ts +++ b/packages/server/src/complete/complete.ts @@ -350,14 +350,41 @@ class Completer { if (!ast.distinct) { this.addCandidate(toCompletionItemForKeyword('DISTINCT')) } + + // Check if cursor is inside a FROM clause table reference + // This handles the case where "SELECT * FROM a" parses successfully + // but we still want to suggest tables starting with "a" + const parsedFromClause = getFromNodesFromClause(this.sql) + const fromNodes = getAllNestedFromNodes( + parsedFromClause?.from?.tables || [] + ) + const subqueryTables = createTablesFromFromNodes(fromNodes) + const schemaAndSubqueries = this.schema.tables.concat(subqueryTables) + + for (const tableNode of fromNodes) { + if (tableNode.type === 'table') { + // Check if the lastToken matches the table name (user is typing the table name) + // This means the cursor is ON the table name, not after it (like typing an alias) + const tableNameMatches = + this.lastToken.length > 0 && + tableNode.table.toLowerCase().startsWith(this.lastToken.toLowerCase()) + + if (tableNameMatches && isPosInLocation(tableNode.location, this.pos)) { + // Cursor is typing a table name - suggest tables + this.addCandidatesForTables(schemaAndSubqueries, true) + if (logger.isDebugEnabled()) + logger.debug( + `parse query returns: ${JSON.stringify(this.candidates)}` + ) + return + } + } + } + const columnRef = findColumnAtPosition(ast, this.pos) if (!columnRef) { this.addJoinCondidates(ast) } else { - const parsedFromClause = getFromNodesFromClause(this.sql) - const fromNodes = parsedFromClause?.from?.tables || [] - const subqueryTables = createTablesFromFromNodes(fromNodes) - const schemaAndSubqueries = this.schema.tables.concat(subqueryTables) if (columnRef.table) { // We know what table/alias this column belongs to // Find the corresponding table and suggest it's columns diff --git a/packages/server/test/complete.test.ts b/packages/server/test/complete.test.ts index b00d1434..9167e972 100644 --- a/packages/server/test/complete.test.ts +++ b/packages/server/test/complete.test.ts @@ -553,13 +553,19 @@ describe('Fully qualified table names', () => { expect(result.candidates).toEqual(expect.arrayContaining(expected)) }) - test('not complete table name when not qualified', () => { + test('complete table name when not qualified', () => { + // After the fix for GitHub issue #24, typing a partial table name should + // match tables even if they require qualification (have database/catalog). + // This allows users to type "tabl" and get "table2" and "table3" suggestions. const result = complete( 'SELECT * FROM tabl', { line: 0, column: 18 }, SIMPLE_NESTED_SCHEMA ) - expect(result.candidates.length).toEqual(0) + // Should match table2 and table3 + const labels = result.candidates.map((c) => c.label) + expect(labels).toContain('table2') + expect(labels).toContain('table3') }) test('complete alias when table', () => { const result = complete( diff --git a/packages/server/test/complete/complete_table.test.ts b/packages/server/test/complete/complete_table.test.ts index dd2a05b1..2b592275 100644 --- a/packages/server/test/complete/complete_table.test.ts +++ b/packages/server/test/complete/complete_table.test.ts @@ -1,6 +1,6 @@ import { complete } from '../../src/complete' -const SIMPLE_SCHEMA = { +const simpleSchema = { tables: [ { catalog: null, @@ -24,24 +24,102 @@ const SIMPLE_SCHEMA = { ], } +interface MockTableData { + catalog: string | null + columns: { columnName: string; description: string }[] + database: string | null + tableName: string +} + +function mockSchema(...tableDefinitions: MockTableData[]) { + return { + functions: [], + tables: tableDefinitions, + } +} + +function table(name: string): MockTableData { + const demoTables: Record = { + actor: ['actor_id', 'first_name', 'last_name', 'last_update'], + actor_info: ['actor_id', 'first_name', 'last_name', 'film_info'], + address: [ + 'address_id', + 'address', + 'address2', + 'district', + 'city_id', + 'postal_code', + 'phone', + 'last_update', + ], + customer: [ + 'customer_id', + 'store_id', + 'first_name', + 'last_name', + 'email', + 'address_id', + 'activebool', + 'create_date', + 'last_update', + 'active', + ], + film: [ + 'film_id', + 'title', + 'description', + 'release_year', + 'language_id', + 'original_language_id', + 'rental_duration', + 'rental_rate', + 'length', + 'replacement_cost', + 'rating', + 'last_update', + 'special_features', + 'fulltext', + ], + notes: ['id', 'note', 'last_modified'], + staff: [ + 'staff_id', + 'first_name', + 'last_name', + 'address_id', + 'email', + 'store_id', + 'active', + 'username', + 'password', + 'last_update', + 'picture', + ], + users: ['id', 'name', 'email', 'password'], + } + + return { + catalog: null, + columns: (demoTables[name] || []).map((columnName) => ({ + columnName, + description: '', + })), + database: 'demo', + tableName: name, + } +} + describe('TableName completion', () => { test('complete function keyword', () => { - const result = complete( - 'SELECT arr', - { line: 0, column: 10 }, - SIMPLE_SCHEMA - ) + const result = complete('SELECT arr', { line: 0, column: 10 }, simpleSchema) + expect(result.candidates.length).toEqual(2) expect(result.candidates[0].label).toEqual('array_concat()') expect(result.candidates[1].label).toEqual('array_contains()') }) test('complete function keyword', () => { - const result = complete( - 'SELECT ARR', - { line: 0, column: 10 }, - SIMPLE_SCHEMA - ) + const result = complete('SELECT ARR', { line: 0, column: 10 }, simpleSchema) + expect(result.candidates.length).toEqual(2) expect(result.candidates[0].label).toEqual('ARRAY_CONCAT()') expect(result.candidates[1].label).toEqual('ARRAY_CONTAINS()') @@ -51,8 +129,9 @@ describe('TableName completion', () => { const result = complete( 'SELECT T FROM TABLE1', { line: 0, column: 8 }, - SIMPLE_SCHEMA + simpleSchema ) + expect(result.candidates.length).toEqual(1) expect(result.candidates[0].label).toEqual('TABLE1') }) @@ -61,8 +140,9 @@ describe('TableName completion', () => { const result = complete( 'SELECT ta FROM TABLE1 as tab', { line: 0, column: 9 }, - SIMPLE_SCHEMA + simpleSchema ) + expect(result.candidates.length).toEqual(3) expect(result.candidates).toEqual( expect.arrayContaining([expect.objectContaining({ label: 'tab' })]) @@ -72,8 +152,9 @@ describe('TableName completion', () => { const result = complete( 'SELECT FROM TABLE1', { line: 0, column: 6 }, - SIMPLE_SCHEMA + simpleSchema ) + expect(result.candidates.length).toEqual(2) expect(result.candidates[0].label).toEqual('Select all columns from TABLE1') expect(result.candidates[0].insertText).toEqual( @@ -88,8 +169,9 @@ describe('TableName completion', () => { const result = complete( 'SELEC FROM TABLE1', { line: 0, column: 5 }, - SIMPLE_SCHEMA + simpleSchema ) + expect(result.candidates.length).toEqual(2) expect(result.candidates[0].label).toEqual('SELECT') expect(result.candidates[1].label).toEqual('Select all columns from TABLE1') @@ -102,7 +184,7 @@ describe('TableName completion', () => { const result = complete( 'SELECT FROM TABLE1', { line: 0, column: 7 }, - SIMPLE_SCHEMA + simpleSchema ) const expected = [ expect.objectContaining({ @@ -114,23 +196,7 @@ describe('TableName completion', () => { }) test('complete table name after FROM keyword with partial input', () => { - const schema = { - tables: [ - { - catalog: null, - database: null, - tableName: 'notes', - columns: [{ columnName: 'id', description: '' }], - }, - { - catalog: null, - database: null, - tableName: 'users', - columns: [{ columnName: 'name', description: '' }], - }, - ], - functions: [], - } + const schema = mockSchema(table('notes'), table('users')) const result = complete( 'SELECT * FROM not', @@ -138,15 +204,68 @@ describe('TableName completion', () => { schema ) - // Should suggest table names const labels = result.candidates.map((c) => c.label) + expect(labels).toContain('notes') - // Should NOT include any column suggestions (even if qualified) expect(labels).not.toEqual(expect.arrayContaining(['id', 'name'])) - // All candidates should be tables (CompletionItemKind.Constant = 21) - const TABLE_KIND = 21 - expect(result.candidates.every((c) => c.kind === TABLE_KIND)).toBe(true) + expect(result.candidates.every((c) => c.kind === 21)).toBe(true) + }) + + test('complete table name with database-qualified tables', () => { + const schema = mockSchema( + table('actor'), + table('actor_info'), + table('film') + ) + + // Test: typing 'a' after FROM should match 'actor' and 'actor_info' + const result = complete('SELECT * FROM a', { line: 0, column: 15 }, schema) + + const labels = result.candidates.map((c) => c.label) + + expect(labels).toEqual(['actor', 'actor_info']) + }) + + test('complete table name when SQL parses successfully', () => { + // This tests the case when the parser treats partial table name as valid + // See https://github.com/deepnote/sql-language-server/issues/24 + const schema = mockSchema( + table('actor'), + table('actor_info'), + table('film') + ) + + const result = complete( + 'SELECT * FROM act', + { line: 0, column: 17 }, + schema + ) + + const labels = result.candidates.map((c) => c.label) + + expect(labels).toEqual(['actor', 'actor_info']) + }) + + test('complete table name with partial input after typing more characters', () => { + const schema = mockSchema( + table('actor'), + table('actor_info'), + table('customer'), + table('film'), + table('film_actor') + ) + + // Test: typing 'fil' should match 'film' and 'film_actor' + const result = complete( + 'SELECT * FROM fil', + { line: 0, column: 17 }, + schema + ) + + const labels = result.candidates.map((c) => c.label) + + expect(labels).toEqual(['film', 'film_actor']) }) })