Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions src/hooks/useSqlAutocompleteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ export function useSqlAutocompleteRegistration(
tables,
activeDriver,
activeSchema,
activeDatabaseName,
activeCapabilities,
schemaDataMap,
databaseDataMap,
selectedDatabases,
} = useDatabase();

const schema = options?.schema ?? activeSchema;
const defaultNamespace =
schema ?? (activeCapabilities?.schemas === true ? null : activeDatabaseName);
const isMultiDb = usesMultiDatabaseLayout(activeCapabilities, selectedDatabases);

const enabled = options?.enabled ?? true;
Expand All @@ -43,20 +46,29 @@ 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,
})),
);
}

registerSqlAutocomplete(
monaco,
connectionId,
effectiveTables,
schema,
defaultNamespace,
activeCapabilities ?? activeDriver ?? null,
);
};
Expand All @@ -78,6 +90,7 @@ export function useSqlAutocompleteRegistration(
enabled,
options?.monaco,
schema,
defaultNamespace,
tables,
activeDriver,
activeCapabilities,
Expand Down
8 changes: 6 additions & 2 deletions src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions src/utils/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
26 changes: 18 additions & 8 deletions src/utils/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
86 changes: 86 additions & 0 deletions tests/hooks/useSqlAutocompleteRegistration.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useDatabase>);

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<typeof useDatabase>);

renderHook(() =>
useSqlAutocompleteRegistration('conn1', { monaco }),
);

expect(mockRegisterSqlAutocomplete).toHaveBeenCalledWith(
monaco,
'conn1',
[
{ name: 'Addresses', schema: 'Ops' },
{ name: 'Addresses', schema: 'Archive' },
],
'Ops',
capabilities,
);
});
});
53 changes: 53 additions & 0 deletions tests/utils/autocomplete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof registerSqlAutocomplete>[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<typeof registerSqlAutocomplete>[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<typeof vi.fn>;
mockInvoke.mockResolvedValue([
Expand Down
12 changes: 12 additions & 0 deletions tests/utils/sql.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down