diff --git a/src/hooks/useSqlAutocompleteRegistration.ts b/src/hooks/useSqlAutocompleteRegistration.ts index 7ce48315..040838fb 100644 --- a/src/hooks/useSqlAutocompleteRegistration.ts +++ b/src/hooks/useSqlAutocompleteRegistration.ts @@ -24,6 +24,7 @@ export function useSqlAutocompleteRegistration( tables, activeDriver, activeSchema, + activeDatabaseName, activeCapabilities, schemaDataMap, databaseDataMap, @@ -31,6 +32,8 @@ export function useSqlAutocompleteRegistration( } = useDatabase(); const schema = options?.schema ?? activeSchema; + const defaultNamespace = + schema ?? (activeCapabilities?.schemas === true ? null : activeDatabaseName); const isMultiDb = usesMultiDatabaseLayout(activeCapabilities, selectedDatabases); const enabled = options?.enabled ?? true; @@ -43,12 +46,21 @@ export function useSqlAutocompleteRegistration( const register = (monaco: Monaco) => { if (cancelled) return; - let effectiveTables = tables; + let effectiveTables = tables.map((table) => + defaultNamespace && !table.schema + ? { ...table, schema: defaultNamespace } + : table, + ); if (activeCapabilities?.schemas && schema) { - effectiveTables = schemaDataMap[schema]?.tables ?? tables; + effectiveTables = (schemaDataMap[schema]?.tables ?? tables).map( + (table) => ({ ...table, schema: table.schema ?? schema }), + ); } else if (isMultiDb) { - effectiveTables = selectedDatabases.flatMap( - (db) => databaseDataMap[db]?.tables ?? [], + effectiveTables = selectedDatabases.flatMap((db) => + (databaseDataMap[db]?.tables ?? []).map((table) => ({ + ...table, + schema: table.schema ?? db, + })), ); } @@ -56,7 +68,7 @@ export function useSqlAutocompleteRegistration( monaco, connectionId, effectiveTables, - schema, + defaultNamespace, activeCapabilities ?? activeDriver ?? null, ); }; @@ -78,6 +90,7 @@ export function useSqlAutocompleteRegistration( enabled, options?.monaco, schema, + defaultNamespace, tables, activeDriver, activeCapabilities, diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index c3e87c52..b6fa021b 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -1063,7 +1063,10 @@ export const Editor = ({ commandScopeId }: EditorProps) => { currentTab?.type === "table" ? currentTab.activeTable : undefined; if (!tableName && textToRun) { - const extracted = extractTableName(textToRun); + const extracted = extractTableName( + textToRun, + schema ?? activeDatabaseName, + ); // Reject views and materialized views — they are not row-editable // (materialized views only accept REFRESH, not INSERT/UPDATE/DELETE). if ( @@ -1257,7 +1260,8 @@ export const Editor = ({ commandScopeId }: EditorProps) => { return; } const res = item?.result ?? null; - const tableName = extractTableName(entry.query) ?? null; + const tableName = + extractTableName(entry.query, schema ?? activeDatabaseName) ?? null; if (shouldRecordHistory) { addHistoryEntry( entry.query, diff --git a/src/utils/autocomplete.ts b/src/utils/autocomplete.ts index 223f442e..2bf53f46 100644 --- a/src/utils/autocomplete.ts +++ b/src/utils/autocomplete.ts @@ -229,6 +229,35 @@ export const registerSqlAutocomplete = ( const simpleDotMatch = qualifiedDotMatch ? null : textUntilPosition.match(/(?:["'`])?([a-zA-Z0-9_]+)(?:["'`])?\.([a-zA-Z0-9_]*)$/); if (qualifiedDotMatch || simpleDotMatch) { + // In a table operand, `namespace.partial` refers to a table rather than + // a column. Resolve this before the regular table/alias dot path so + // `FROM Ops.Add` does not request columns for a table named `Ops`. + if (simpleDotMatch && suggestionKinds.tables) { + const namespace = simpleDotMatch[1].toLowerCase(); + const partialTable = simpleDotMatch[2]; + const namespaceTables = tables.filter( + (table) => table.schema?.toLowerCase() === namespace, + ); + + if (namespaceTables.length > 0) { + const tableRange = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: position.column - partialTable.length, + endColumn: position.column, + }; + return { + suggestions: namespaceTables.map((table) => ({ + label: table.name, + kind: monaco.languages.CompletionItemKind.Class, + detail: `Table · ${table.schema}`, + ...buildIdentifierInsert(table.name, tableRange), + sortText: `1_${table.name}`, + })), + }; + } + } + let dotTables: TableInfo[] = []; let partialColumn: string; diff --git a/src/utils/sql.ts b/src/utils/sql.ts index d45854d9..a5379c2a 100644 --- a/src/utils/sql.ts +++ b/src/utils/sql.ts @@ -243,10 +243,15 @@ export function statementLabel(query: string): string { /** * Extracts the table name from a SELECT query. * Handles quotes: `table`, "table", 'table', and unquoted table names. + * Schema-qualified names are accepted only when the qualifier matches + * `expectedSchema`, preventing edits from targeting the active schema by mistake. * Returns null if no table is found or if it's not a SELECT query. * Returns null for aggregate queries (COUNT, SUM, etc.) since they don't return table rows. */ -export function extractTableName(sql: string): string | null { +export function extractTableName( + sql: string, + expectedSchema?: string | null, +): string | null { // Remove comments and normalize whitespace const cleaned = sql .replace(/--[^\n]*/g, '') // Remove line comments @@ -285,13 +290,18 @@ export function extractTableName(sql: string): string | null { return null; } - // Match FROM clause with optional quotes - // Matches: FROM table, FROM `table`, FROM "table", FROM 'table' - const fromMatch = cleaned.match(/\bFROM\s+([`"']?)(\w+)\1/i); + // Match an optionally schema-qualified identifier. Each identifier can be + // quoted independently, for example `Ops`.`Addresses` or Ops.Addresses. + const fromMatch = cleaned.match( + /\bFROM\s+(?:(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([\w$]+))\s*\.\s*)?(?:`([^`]+)`|"([^"]+)"|'([^']+)'|([\w$]+))/i, + ); + if (!fromMatch) return null; + + const schema = fromMatch[1] ?? fromMatch[2] ?? fromMatch[3] ?? fromMatch[4]; + const table = fromMatch[5] ?? fromMatch[6] ?? fromMatch[7] ?? fromMatch[8]; + if (!table) return null; - if (fromMatch && fromMatch[2]) { - return fromMatch[2]; - } + if (schema && schema !== expectedSchema) return null; - return null; + return table; } diff --git a/tests/hooks/useSqlAutocompleteRegistration.test.ts b/tests/hooks/useSqlAutocompleteRegistration.test.ts new file mode 100644 index 00000000..d16f0b09 --- /dev/null +++ b/tests/hooks/useSqlAutocompleteRegistration.test.ts @@ -0,0 +1,86 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Monaco } from '@monaco-editor/react'; +import { useSqlAutocompleteRegistration } from '../../src/hooks/useSqlAutocompleteRegistration'; +import { registerSqlAutocomplete } from '../../src/utils/autocomplete'; +import { useDatabase } from '../../src/hooks/useDatabase'; + +vi.mock('../../src/hooks/useDatabase'); +vi.mock('../../src/utils/autocomplete', () => ({ + registerSqlAutocomplete: vi.fn(), + disposeSqlAutocomplete: vi.fn(), +})); + +const capabilities = { + schemas: false, + file_based: false, + folder_based: false, + single_database: false, + no_connection_required: false, +}; + +const monaco = {} as Monaco; +const mockUseDatabase = vi.mocked(useDatabase); +const mockRegisterSqlAutocomplete = vi.mocked(registerSqlAutocomplete); + +describe('useSqlAutocompleteRegistration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('associates flat tables with the active database', () => { + mockUseDatabase.mockReturnValue({ + tables: [{ name: 'Addresses' }], + activeDriver: 'mysql', + activeSchema: null, + activeDatabaseName: 'Ops', + activeCapabilities: capabilities, + schemaDataMap: {}, + databaseDataMap: {}, + selectedDatabases: [], + } as ReturnType); + + renderHook(() => + useSqlAutocompleteRegistration('conn1', { monaco }), + ); + + expect(mockRegisterSqlAutocomplete).toHaveBeenCalledWith( + monaco, + 'conn1', + [{ name: 'Addresses', schema: 'Ops' }], + 'Ops', + capabilities, + ); + }); + + it('preserves each table database in multi-database mode', () => { + mockUseDatabase.mockReturnValue({ + tables: [], + activeDriver: 'mysql', + activeSchema: null, + activeDatabaseName: 'Ops', + activeCapabilities: capabilities, + schemaDataMap: {}, + databaseDataMap: { + Ops: { tables: [{ name: 'Addresses' }] }, + Archive: { tables: [{ name: 'Addresses' }] }, + }, + selectedDatabases: ['Ops', 'Archive'], + } as ReturnType); + + renderHook(() => + useSqlAutocompleteRegistration('conn1', { monaco }), + ); + + expect(mockRegisterSqlAutocomplete).toHaveBeenCalledWith( + monaco, + 'conn1', + [ + { name: 'Addresses', schema: 'Ops' }, + { name: 'Addresses', schema: 'Archive' }, + ], + 'Ops', + capabilities, + ); + }); +}); diff --git a/tests/utils/autocomplete.test.ts b/tests/utils/autocomplete.test.ts index 76c7141e..3c3b4fbc 100644 --- a/tests/utils/autocomplete.test.ts +++ b/tests/utils/autocomplete.test.ts @@ -455,6 +455,59 @@ describe('autocomplete', () => { }); describe('dot trigger (table.column)', () => { + it('should suggest tables after an active database qualifier', async () => { + const monaco = createMockMonaco(); + const tables: TableInfo[] = [ + { name: 'Addresses', schema: 'Ops' }, + { name: 'AuditLog', schema: 'Ops' }, + { name: 'Addresses', schema: 'Archive' }, + ]; + + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + tables, + 'Ops', + 'mysql', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const value = 'SELECT * FROM Ops.'; + const result = await provider.provideCompletionItems( + createMockModel(value), + { lineNumber: 1, column: value.length + 1 }, + ); + + expect(result.suggestions.map((suggestion: { label: string }) => suggestion.label)) + .toEqual(['Addresses', 'AuditLog']); + expect(result.suggestions[0]?.insertText).toBe('Addresses'); + expect(result.suggestions[0]?.detail).toBe('Table · Ops'); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('should replace only the partial table name after a database qualifier', async () => { + const monaco = createMockMonaco(); + registerSqlAutocomplete( + monaco as unknown as Parameters[0], + 'conn1', + [{ name: 'Addresses', schema: 'Ops' }], + 'Ops', + 'mysql', + ); + + const provider = monaco.languages.registerCompletionItemProvider.mock.calls[0][1]; + const value = 'SELECT * FROM Ops.Add'; + const result = await provider.provideCompletionItems( + createMockModel(value, 'Add'), + { lineNumber: 1, column: value.length + 1 }, + ); + + expect(result.suggestions[0]?.label).toBe('Addresses'); + expect(result.suggestions[0]?.range.startColumn).toBe(value.length - 2); + expect(result.suggestions[0]?.range.endColumn).toBe(value.length + 1); + expect(invoke).not.toHaveBeenCalled(); + }); + it('should provide column suggestions after typing table name with dot', async () => { const mockInvoke = invoke as unknown as ReturnType; mockInvoke.mockResolvedValue([ diff --git a/tests/utils/sql.test.ts b/tests/utils/sql.test.ts index 825f72af..486fa59f 100644 --- a/tests/utils/sql.test.ts +++ b/tests/utils/sql.test.ts @@ -158,6 +158,18 @@ describe('sql utils', () => { expect(extractTableName("SELECT * FROM `my_table` WHERE id = 1")).toBe('my_table'); }); + it('should extract a table qualified by the active schema', () => { + expect(extractTableName('SELECT * FROM Ops.Addresses', 'Ops')).toBe('Addresses'); + expect(extractTableName('SELECT * FROM `Ops`.`Addresses`', 'Ops')).toBe('Addresses'); + expect(extractTableName('SELECT * FROM "Ops"."Addresses"', 'Ops')).toBe('Addresses'); + expect(extractTableName("SELECT * FROM 'Ops'.'Addresses'", 'Ops')).toBe('Addresses'); + }); + + it('should reject a qualified table outside the active schema', () => { + expect(extractTableName('SELECT * FROM Ops.Addresses')).toBeNull(); + expect(extractTableName('SELECT * FROM Other.Addresses', 'Ops')).toBeNull(); + }); + it('should return null for non-SELECT queries', () => { expect(extractTableName('UPDATE users SET name="test"')).toBeNull(); expect(extractTableName('DELETE FROM users')).toBeNull();