🐛 fix(query-editor): 修复表名及别名字段补全异常

- 表名候选改为严格前缀匹配,并在截断时触发补全重查

- 识别逗号分隔表、别名及跨库引用,补充词法边界回归测试

Fixes #810

Fixes #827
This commit is contained in:
Syngnat
2026-08-03 21:51:41 +08:00
parent b57561f1b0
commit 1403393178
4 changed files with 588 additions and 54 deletions

View File

@@ -3438,15 +3438,26 @@ describe('QueryEditor external SQL save', () => {
});
});
it('fuzzy matches table names in FROM completion before column candidates', async () => {
it('matches table names from the beginning in FROM completion', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.database = '';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'information_schema' }, { Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Tables_in_main: 'fs_org_auth_application' }] });
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [
{ Tables_in_main: 'users' },
{ Tables_in_main: 'hrmresource' },
{ Tables_in_main: 'hrm_resource_export_template' },
{ Tables_in_main: 'archive_hrmresource' },
],
});
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'fs_org_auth_application', name: 'orgi', type: 'varchar(32)' }],
data: [
{ tableName: 'hrmresource', name: 'hrmresult', type: 'varchar(32)' },
{ tableName: 'users', name: 'hrmresult_from_users', type: 'varchar(32)' },
],
});
await act(async () => {
@@ -3461,13 +3472,193 @@ describe('QueryEditor external SQL save', () => {
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
expect(sqlProvider).toBeTruthy();
editorState.value = 'SELECT * FROM org';
editorState.value = 'SELECT * FROM hrmres';
editorState.latestOnChange?.(editorState.value);
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
const labels = result.suggestions.map((item: any) => item.label);
expect(labels).toContain('fs_org_auth_application');
expect(labels).not.toContain('orgi');
expect(labels).toContain('hrmresource');
expect(labels).not.toContain('hrm_resource_export_template');
expect(labels).not.toContain('archive_hrmresource');
expect(labels).not.toContain('hrmresult');
editorState.value = 'SELECT * FROM users u, hrmres';
editorState.latestOnChange?.(editorState.value);
const commaResult = await sqlProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 1, column: editorState.value.length + 1 },
);
const commaLabels = commaResult.suggestions.map((item: any) => item.label);
expect(commaLabels).toContain('hrmresource');
expect(commaLabels).not.toContain('archive_hrmresource');
expect(commaLabels).not.toContain('hrmresult_from_users');
expect(backendApp.DBGetColumns.mock.calls.map((call: any[]) => call[2])).not.toContain('hrmres');
await act(async () => {
renderer.unmount();
});
});
it('marks bounded FROM completion as incomplete so Monaco retriggers with the final prefix', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.database = 'main';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [
...Array.from({ length: 201 }, (_, index) => ({
Tables_in_main: `hrm_resource_${String(index).padStart(3, '0')}`,
})),
{ Tables_in_main: 'hrmresource' },
],
});
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: '', dbName: 'main' })} />);
});
await act(async () => {
for (let index = 0; index < 8; index += 1) {
await Promise.resolve();
}
});
const sqlProvider = findSqlCompletionProvider();
expect(sqlProvider).toBeTruthy();
editorState.value = 'SELECT * FROM h';
editorState.latestOnChange?.(editorState.value);
const initialResult = await sqlProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 1, column: editorState.value.length + 1 },
);
expect(initialResult.suggestions).toHaveLength(200);
expect(initialResult.suggestions.map((item: any) => item.label)).not.toContain('hrmresource');
expect(initialResult.incomplete).toBe(true);
editorState.value = 'SELECT * FROM hrmres';
editorState.latestOnChange?.(editorState.value);
const retriggeredResult = await sqlProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 1, column: editorState.value.length + 1 },
{ triggerKind: 2 },
);
expect(retriggeredResult.suggestions.map((item: any) => item.label)).toEqual(['hrmresource']);
expect(retriggeredResult.incomplete).toBe(true);
await act(async () => {
renderer.unmount();
});
});
it('resolves columns from comma-separated Dameng table references and aliases', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.type = 'dameng';
storeState.connections[0].config.database = 'DEV';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'DEV' }] });
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [
{ Table: 'VULNERABILITY_INFO_T' },
{ Table: 'VULNERABILITY_DETAIL_T' },
],
});
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
backendApp.DBGetColumns.mockImplementation(async (_config: any, _dbName: string, tableName: string) => ({
success: true,
data: tableName === 'VULNERABILITY_DETAIL_T'
? [
{ name: 'DETAIL_ID', type: 'VARCHAR' },
{ name: 'VULNERABILITY_ID', type: 'VARCHAR' },
]
: [
{ name: 'CODE', type: 'VARCHAR' },
{ name: 'CONTENT', type: 'VARCHAR' },
{ name: 'ID', type: 'VARCHAR' },
],
}));
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: '', dbName: 'DEV' })} />);
});
await act(async () => {
for (let index = 0; index < 16; index += 1) {
await Promise.resolve();
}
});
const sqlProvider = findSqlCompletionProvider();
expect(sqlProvider).toBeTruthy();
const sqlPrefix = 'SELECT * FROM VULNERABILITY_INFO_T a, VULNERABILITY_DETAIL_T b '
+ 'WHERE VULNERABILITY_INFO_T.CODE = ';
for (const qualifier of ['VULNERABILITY_DETAIL_T', 'b']) {
editorState.value = `${sqlPrefix}${qualifier}.`;
editorState.latestOnChange?.(editorState.value);
const result = await sqlProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 1, column: editorState.value.length + 1 },
);
const labels = result.suggestions.map((item: any) => item.label);
expect(result.suggestions).toEqual(expect.arrayContaining([
expect.objectContaining({
label: 'DETAIL_ID',
detail: expect.stringContaining('VULNERABILITY_DETAIL_T'),
}),
]));
expect(labels).toEqual(expect.arrayContaining(['DETAIL_ID', 'VULNERABILITY_ID']));
expect(labels).not.toEqual(expect.arrayContaining(['CODE', 'CONTENT', 'ID']));
}
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'DEV', 'VULNERABILITY_DETAIL_T');
expect(backendApp.DBGetColumns).not.toHaveBeenCalledWith(expect.anything(), 'DEV', 'VULNERABILITY_INFO_T');
await act(async () => {
renderer.unmount();
});
});
it('keeps FROM inside an unfinished EXTRACT expression in column completion context', async () => {
let renderer!: ReactTestRenderer;
autoFetchState.visible = true;
storeState.connections[0].config.type = 'postgres';
storeState.connections[0].config.database = 'main';
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [
{ Table: 'users' },
{ Table: 'archive_created_at' },
],
});
backendApp.DBGetAllColumns.mockResolvedValueOnce({
success: true,
data: [{ tableName: 'users', name: 'created_at', type: 'timestamp' }],
});
const sql = 'SELECT EXTRACT(YEAR FROM creat) FROM users';
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ query: sql, dbName: 'main' })} />);
});
await act(async () => {
for (let index = 0; index < 8; index += 1) {
await Promise.resolve();
}
});
const sqlProvider = findSqlCompletionProvider();
expect(sqlProvider).toBeTruthy();
const cursorPrefix = 'SELECT EXTRACT(YEAR FROM creat';
const result = await sqlProvider.provideCompletionItems(
createSqlCompletionModel(sql, 'creat'),
{ lineNumber: 1, column: cursorPrefix.length + 1 },
);
expect(result.suggestions.map((item: any) => item.label)).toContain('created_at');
expect(backendApp.DBGetColumns.mock.calls.map((call: any[]) => call[2])).not.toContain('created_at');
await act(async () => {
renderer.unmount();
});
@@ -3542,7 +3733,7 @@ describe('QueryEditor external SQL save', () => {
const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.'));
expect(sqlProvider).toBeTruthy();
editorState.value = 'SELECT * FROM or';
editorState.value = 'SELECT * FROM fs_org';
editorState.latestOnChange?.(editorState.value);
const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 });
const labels = result.suggestions.map((item: any) => item.label);
@@ -4016,7 +4207,7 @@ describe('QueryEditor external SQL save', () => {
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
const noisyTableRows = Array.from({ length: 2_000 }, (_, index) => ({
Tables_in_main: `archive_entity_${String(index).padStart(4, '0')}`,
Tables_in_main: `entity_z_archive_${String(index).padStart(4, '0')}`,
}));
const noisyColumnRows = Array.from({ length: 2_000 }, (_, index) => ({
tableName: 'users',

View File

@@ -139,7 +139,6 @@ import {
QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH,
QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH,
QUERY_EDITOR_SQL_QUALIFIER_COMPLETION_REGEX,
QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX,
QUERY_EDITOR_SQL_THREE_PART_COMPLETION_REGEX,
appendCommentToDetail,
areSqlStatementListsEqual,
@@ -164,6 +163,7 @@ import {
clearQueryEditorObjectDecorations,
collectQueryEditorObjectDecorationCandidates,
collectQueryEditorReferencedDatabaseNames,
collectQueryEditorTableReferences,
findCompletionTablesByDatabase,
getCaseInsensitiveValue,
getCompletionTableSchemaCounts,
@@ -177,6 +177,7 @@ import {
getQueryEditorObjectResolveText,
getTabQueryValue,
isOracleBaseTableReference,
isQueryEditorTableSourceCompletionContext,
isDocumentLevelShortcutTarget,
isQueryEditorPrimaryMouseButton,
normalizeCommentText,
@@ -983,7 +984,7 @@ const resolveQueryEditorAiConnectionHost = (connection: any): string => {
// HMR 重载时释放旧注册避免补全和 hover 内容重复
const _g = globalThis as any;
const SQL_COMPLETION_PROVIDER_VERSION = '20260718-mysql-language-v1';
const SQL_COMPLETION_PROVIDER_VERSION = '20260803-prefix-retrigger-v2';
const QUERY_EDITOR_MONACO_LANGUAGE_IDS = ['sql', 'mysql'] as const;
if (!_g.__gonaviSqlCompletionState) {
_g.__gonaviSqlCompletionState = { registered: false, version: '', disposables: [] as any[] };
@@ -1119,7 +1120,15 @@ let sharedColumnsCacheData: Record<string, any[]> = {};
let sharedActiveEditorModelUri = '';
const sharedLazyTablesCache: Record<string, CompletionTableMeta[] | undefined> = {};
const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> | undefined> = {};
const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] });
const createSqlCompletionResult = (suggestions: any[], retriggerOnContinue = false) => ({
suggestions,
// Monaco otherwise keeps filtering a cached list locally. Re-run strict
// object-name contexts as the prefix grows, and re-run any full 200-item
// window so omitted candidates can enter the next result.
incomplete: suggestions.length > 0
&& (retriggerOnContinue || suggestions.length >= QUERY_EDITOR_COMPLETION_SUGGESTION_LIMIT),
});
const createEmptySqlCompletionResult = () => createSqlCompletionResult([]);
const isSqlCompletionRequestCancelled = (token?: { isCancellationRequested?: boolean } | null) =>
Boolean(token?.isCancellationRequested);
const clearRecord = (record: Record<string, unknown>) => {
@@ -6086,7 +6095,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
sortText: `0${rankQueryEditorCompletionCandidate(colPrefix, [column.name]) ?? 9}${column.name}`,
}),
});
return { suggestions };
return createSqlCompletionResult(suggestions);
}
// 1) 两段式 qualifier.xxx 格式
@@ -6113,7 +6122,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
getMatchRank: (table, normalizedPrefix) => {
if (String(table.dbName || '').toLowerCase() !== qualifierLower) return null;
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || qualifier, table.tableName || '');
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.displayName, table.tableName]);
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.displayName, table.tableName], false);
},
getSelectionKey: (table, _prefix, matchRank) => {
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || qualifier, table.tableName || '');
@@ -6148,6 +6157,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return rankQueryEditorCompletionCandidate(
normalizedPrefix,
[meta.displayName, meta.objectName, view.viewName],
false,
);
},
getSelectionKey: (view, _prefix, matchRank) => `05${matchRank}${buildViewSuggestionMeta(view).displayName}`,
@@ -6205,15 +6215,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
},
});
return {
suggestions: materializeBoundedQueryEditorCompletionBatches([
return createSqlCompletionResult(
materializeBoundedQueryEditorCompletionBatches([
tableBatch,
viewBatch,
materializedViewBatch,
synonymBatch,
routineBatch,
]),
};
true,
);
}
// qualifier 是 schema如 dbo/public仅补全表名避免输入 dbo. 后再补成 dbo.dbo.table
@@ -6226,7 +6237,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (parsed.schema.toLowerCase() !== qualifierLower) return null;
hasKnownSchemaQualifier = true;
if (!parsed.table) return null;
return rankQueryEditorCompletionCandidate(normalizedPrefix, [parsed.table]);
return rankQueryEditorCompletionCandidate(normalizedPrefix, [parsed.table], false);
},
getSelectionKey: (table, _prefix, matchRank) => `0${matchRank}${splitSchemaAndTable(table.tableName || '').table}`,
buildSuggestion: (table) => {
@@ -6257,7 +6268,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (meta.schemaName.toLowerCase() !== qualifierLower) return null;
hasKnownSchemaQualifier = true;
if (!meta.objectName) return null;
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.objectName]);
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.objectName], false);
},
getSelectionKey: (view, _prefix, matchRank) => `05${matchRank}${buildViewSuggestionMeta(view).objectName}`,
buildSuggestion: (view) => {
@@ -6319,7 +6330,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
schemaRoutineBatch,
]);
if (hasKnownSchemaQualifier) {
return { suggestions: schemaSuggestions };
return createSqlCompletionResult(schemaSuggestions, true);
}
// 否则检查是否是表别名或表名,提示列
@@ -6352,17 +6363,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
sortText: `0${rankQueryEditorCompletionCandidate(prefix, [column.name]) ?? 9}${column.name}`,
}),
});
return { suggestions };
return createSqlCompletionResult(suggestions);
}
}
// 2) global/table/column completion
const tableRegex = QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX;
tableRegex.lastIndex = 0;
const foundTables = new Set<string>();
let match;
while ((match = tableRegex.exec(completionReferenceText)) !== null) {
const t = normalizeQualifiedName(match[1] || '');
for (const reference of collectQueryEditorTableReferences(completionReferenceText)) {
const t = normalizeQualifiedName(reference.tableIdent);
if (!t) continue;
// 存储完整标识 db.table 或 table
foundTables.add(t.toLowerCase());
@@ -6377,7 +6385,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const matchRank = rankQueryEditorCompletionCandidate(wordPrefix, candidates);
return matchRank === null ? '9' : String(matchRank);
};
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix);
const expectsTableName = isQueryEditorTableSourceCompletionContext(completionScopeText)
|| /\b(?:TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix);
const expectsRoutineName = /\bCALL\s+[`"]?[\w.]*$/i.test(linePrefix);
const matchesKeywordPrefix = wordPrefix.length > 0
&& dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
@@ -6522,9 +6531,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return rankQueryEditorCompletionCandidate(
normalizedPrefix,
[meta.dbQualifiedLabel, table.tableName, pureTable],
!expectsTableName,
);
}
return rankQueryEditorCompletionCandidate(normalizedPrefix, [table.tableName, pureTable]);
return rankQueryEditorCompletionCandidate(normalizedPrefix, [table.tableName, pureTable], !expectsTableName);
},
getSelectionKey: (table, _prefix, matchRank) => {
const isCurrentDb = isCurrentCompletionDatabase(table.dbName || '');
@@ -6591,6 +6601,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return rankQueryEditorCompletionCandidate(
normalizedPrefix,
[meta.dbQualifiedLabel, meta.displayName, meta.objectName, view.viewName],
!expectsTableName,
);
},
getSelectionKey: (view, _prefix, matchRank) => {
@@ -6629,7 +6640,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
candidates: selectUnqualifiedCompletionSynonyms(sharedSynonymsData, oracleLoginOwner),
prefix: wordPrefix,
getMatchRank: (synonym, normalizedPrefix) => (
rankQueryEditorCompletionCandidate(normalizedPrefix, [synonym.synonymName])
rankQueryEditorCompletionCandidate(normalizedPrefix, [synonym.synonymName], !expectsTableName)
),
getSelectionKey: (synonym) => (
sortGroups.tableCurrent + '05' + getPrefixMatchRank(synonym.synonymName || '') + synonym.synonymName
@@ -6649,6 +6660,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return rankQueryEditorCompletionCandidate(
normalizedPrefix,
[meta.dbQualifiedLabel, meta.displayName, meta.objectName, routine.routineName],
!expectsTableName && !expectsRoutineName,
);
},
getSelectionKey: (routine) => {
@@ -6737,7 +6749,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
funcBatch,
keywordBatch,
], QUERY_EDITOR_COMPLETION_SUGGESTION_LIMIT);
return { suggestions };
return createSqlCompletionResult(suggestions, expectsTableName || expectsRoutineName);
}
});
registerQueryEditorCompletionProvider({

View File

@@ -5,10 +5,12 @@ import {
buildQueryEditorAliasMap,
buildQueryEditorResultSetMergeKey,
collectQueryEditorReferencedDatabaseNames,
collectQueryEditorTableReferences,
createBoundedQueryEditorCompletionCandidateBatch,
findCompletionTablesByDatabase,
getCompletionTableSchemaCounts,
isOracleBaseTableReference,
isQueryEditorTableSourceCompletionContext,
materializeBoundedQueryEditorCompletionBatches,
rankQueryEditorCompletionCandidate,
resolveQueryEditorCompletionFilterText,
@@ -309,9 +311,102 @@ describe('QueryEditorHelpers qualified navigation (MySQL db.table + PG schema.ta
expect(unqualified.p).toEqual({ dbName: 'A', tableName: 'PERSON' });
});
it('collects table names and aliases from comma-separated FROM sources', () => {
const aliases = buildQueryEditorAliasMap(
'SELECT * FROM VULNERABILITY_INFO_T a, VULNERABILITY_DETAIL_T b '
+ 'WHERE VULNERABILITY_INFO_T.CODE = VULNERABILITY_DETAIL_T.',
'DEV',
);
expect(aliases.vulnerability_info_t).toEqual({ dbName: 'DEV', tableName: 'VULNERABILITY_INFO_T' });
expect(aliases.a).toEqual({ dbName: 'DEV', tableName: 'VULNERABILITY_INFO_T' });
expect(aliases.vulnerability_detail_t).toEqual({ dbName: 'DEV', tableName: 'VULNERABILITY_DETAIL_T' });
expect(aliases.b).toEqual({ dbName: 'DEV', tableName: 'VULNERABILITY_DETAIL_T' });
});
it('ignores expression commas, literals, comments, and table-valued functions', () => {
const references = collectQueryEditorTableReferences(`
SELECT concat(a.code, 'FROM fake_one f, fake_two g'), a.name
FROM (
SELECT * FROM inner_a ia, inner_b ib
) nested
JOIN generate_series(1, 2) series ON true
JOIN outer_table ot ON ot.id = nested.id
WHERE fn(ot.code, ot.name) = '-- JOIN fake_three z'
/* FROM fake_four q, fake_five w */
ORDER BY ot.code, ot.name
`);
expect(references.map((reference) => reference.tableIdent)).toEqual([
'inner_a',
'inner_b',
'outer_table',
]);
expect(references.map((reference) => reference.alias)).toEqual(['ia', 'ib', 'ot']);
});
it('preserves quoted identifier parts and allows quoted reserved aliases', () => {
const references = collectQueryEditorTableReferences(
'SELECT * FROM "Odd.Schema"."Table.Name" AS "where", [dbo].[Other Table] b',
);
expect(references).toEqual([
{
tableIdent: 'Odd.Schema.Table.Name',
parts: ['Odd.Schema', 'Table.Name'],
alias: 'where',
},
{
tableIdent: 'dbo.Other Table',
parts: ['dbo', 'Other Table'],
alias: 'b',
},
]);
});
it('keeps INSERT, UPDATE, and DELETE targets while skipping FROM table-valued functions', () => {
const references = collectQueryEditorTableReferences(`
INSERT INTO audit_log (id, name) VALUES (1, 'created');
UPDATE users SET name = 'updated' WHERE id = 1;
DELETE FROM expired_sessions WHERE expires_at < CURRENT_TIMESTAMP;
SELECT * FROM generate_series(1, 2) series JOIN active_users au ON true;
`);
expect(references.map((reference) => reference.tableIdent)).toEqual([
'audit_log',
'users',
'expired_sessions',
'active_users',
]);
});
it('keeps PostgreSQL JSONB operators and ignores FROM inside SQL expressions', () => {
const references = collectQueryEditorTableReferences(`
SELECT payload #>> '{id}',
EXTRACT(YEAR FROM created_at),
TRIM(BOTH ' ' FROM display_name)
FROM events e
WHERE e.id > 0
`);
expect(references).toEqual([{
tableIdent: 'events',
parts: ['events'],
alias: 'e',
}]);
});
it('recognizes table completion after a comma but not after an alias or WHERE clause', () => {
expect(isQueryEditorTableSourceCompletionContext('SELECT * FROM users u, hrmres')).toBe(true);
expect(isQueryEditorTableSourceCompletionContext('SELECT * FROM users u,')).toBe(true);
expect(isQueryEditorTableSourceCompletionContext('SELECT * FROM users u')).toBe(false);
expect(isQueryEditorTableSourceCompletionContext('SELECT * FROM users WHERE id = 1')).toBe(false);
expect(isQueryEditorTableSourceCompletionContext('SELECT EXTRACT(YEAR FROM created_at)')).toBe(false);
});
it('collects cross-db names from SQL without requiring an empty visible list', () => {
const sql = `
SELECT * FROM uk_back_corp;
SELECT * FROM uk_back_corp u, reporting.audit_log a;
SELECT * FROM front_end_sys_new.fs_mkefu_regist_record WHERE mobile = '1';
DELETE FROM front_end_sys_new.fs_mkefu_regist_record WHERE mobile = '1';
SELECT * FROM public.users;
@@ -320,12 +415,13 @@ SELECT * FROM analytics.public.events;
const names = collectQueryEditorReferencedDatabaseNames(
sql,
'mkefu_test_new',
['mkefu_test_new', 'front_end_sys_new', 'analytics'],
['mkefu_test_new', 'front_end_sys_new', 'analytics', 'reporting'],
);
expect(names).toEqual(expect.arrayContaining([
'mkefu_test_new',
'front_end_sys_new',
'analytics',
'reporting',
]));
// public 是常见 schema两段时不应当成库去拉取
expect(names.map((name) => name.toLowerCase())).not.toContain('public');

View File

@@ -1836,7 +1836,9 @@ export const maskQueryEditorSqlLiteralsAndComments = (source: string): string =>
continue;
}
if (ch === '#') {
// MySQL-style # comments must not consume PostgreSQL JSONB operators
// such as #>, #>>, and #-.
if (ch === '#' && next !== '>' && next !== '-') {
maskAt(i);
inLineComment = true;
continue;
@@ -1966,23 +1968,261 @@ export const buildQueryEditorHoverMarkdown = (target: QueryEditorHoverTarget): s
}
};
export type QueryEditorTableReference = {
tableIdent: string;
parts: string[];
alias?: string;
};
type QueryEditorSqlReferenceToken = {
raw: string;
quoted: boolean;
};
type QueryEditorSqlReferenceDepthState = {
fromListActive: boolean;
queryStatementActive: boolean;
sourceContextActive: boolean;
expectsSource?: 'from' | 'join' | 'comma' | 'update' | 'into';
};
const QUERY_EDITOR_SQL_REFERENCE_PUNCTUATION = new Set(['(', ')', '.', ',', ';']);
const QUERY_EDITOR_SQL_FROM_LIST_END_WORDS = new Set([
'where', 'group', 'order', 'having', 'limit', 'fetch', 'offset', 'qualify', 'window',
'union', 'except', 'intersect', 'minus', 'returning', 'set', 'values',
'connect', 'start', 'model', 'match_recognize', 'for',
]);
const QUERY_EDITOR_SQL_TABLE_ALIAS_RESERVED_WORDS = new Set([
...QUERY_EDITOR_SQL_FROM_LIST_END_WORDS,
'select', 'from', 'join', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'natural',
'straight_join', 'apply', 'on', 'using', 'as', 'update', 'into', 'delete',
'only', 'lateral', 'partition', 'sample', 'tablesample', 'with',
'use', 'force', 'ignore', 'index', 'indexed', 'pivot', 'unpivot',
]);
const QUERY_EDITOR_SQL_TABLE_SOURCE_MODIFIERS = new Set(['only', 'lateral']);
const tokenizeQueryEditorSqlReferences = (source: string): QueryEditorSqlReferenceToken[] => {
const masked = maskQueryEditorSqlLiteralsAndComments(source);
const tokenRegex = new RegExp(`${QUERY_EDITOR_SQL_IDENTIFIER_PATTERN}|[().,;]`, 'g');
const tokens: QueryEditorSqlReferenceToken[] = [];
let match: RegExpExecArray | null;
while ((match = tokenRegex.exec(masked)) !== null) {
const raw = match[0] || '';
tokens.push({
raw,
quoted: !QUERY_EDITOR_SQL_REFERENCE_PUNCTUATION.has(raw) && isQuotedQueryIdentifierPart(raw),
});
}
return tokens;
};
const isQueryEditorSqlIdentifierToken = (token: QueryEditorSqlReferenceToken | undefined): token is QueryEditorSqlReferenceToken => (
!!token && !QUERY_EDITOR_SQL_REFERENCE_PUNCTUATION.has(token.raw)
);
/**
* Collect physical table-like references with a small depth-aware scanner.
* Commas only introduce another source while the same parenthesis level is in
* a FROM list, so SELECT expressions and function arguments are not mistaken
* for tables.
*/
const analyzeQueryEditorTableReferences = (source: string): {
references: QueryEditorTableReference[];
expectsTableSource: boolean;
} => {
const tokens = tokenizeQueryEditorSqlReferences(String(source || ''));
const references: QueryEditorTableReference[] = [];
const states: QueryEditorSqlReferenceDepthState[] = [{
fromListActive: false,
queryStatementActive: false,
sourceContextActive: false,
}];
let depth = 0;
const getState = () => {
if (!states[depth]) {
states[depth] = {
fromListActive: false,
queryStatementActive: false,
sourceContextActive: false,
};
}
return states[depth];
};
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const state = getState();
if (token.raw === '(') {
// A parenthesized source is a derived table or table-valued
// expression. Its inner SELECT is scanned independently.
state.expectsSource = undefined;
state.sourceContextActive = false;
depth += 1;
states[depth] = {
fromListActive: false,
queryStatementActive: false,
sourceContextActive: false,
};
continue;
}
if (token.raw === ')') {
states.splice(depth, 1);
depth = Math.max(0, depth - 1);
continue;
}
if (token.raw === ';') {
state.fromListActive = false;
state.queryStatementActive = false;
state.sourceContextActive = false;
state.expectsSource = undefined;
continue;
}
if (token.raw === ',') {
if (state.fromListActive) {
state.expectsSource = 'comma';
state.sourceContextActive = true;
}
continue;
}
if (token.raw === '.') {
continue;
}
if (!isQueryEditorSqlIdentifierToken(token)) {
continue;
}
const keyword = token.quoted ? '' : stripCompletionIdentifierQuotes(token.raw).toLowerCase();
if (state.expectsSource) {
if (QUERY_EDITOR_SQL_TABLE_SOURCE_MODIFIERS.has(keyword)) {
continue;
}
const pathTokens = [token.raw];
let pathEnd = index;
while (
pathTokens.length < 3
&& tokens[pathEnd + 1]?.raw === '.'
&& isQueryEditorSqlIdentifierToken(tokens[pathEnd + 2])
) {
pathTokens.push(tokens[pathEnd + 2].raw);
pathEnd += 2;
}
const sourceKind = state.expectsSource;
state.expectsSource = undefined;
// FROM generate_series(...) and similar expressions are not
// physical table metadata targets.
if (
tokens[pathEnd + 1]?.raw === '('
&& (sourceKind === 'from' || sourceKind === 'join' || sourceKind === 'comma')
) {
state.sourceContextActive = false;
index = pathEnd;
continue;
}
const tableText = pathTokens.join('.');
const parts = splitQueryIdentifierPathSegments(tableText)
.map((part) => part.value.trim())
.filter(Boolean);
if (parts.length === 0) {
index = pathEnd;
continue;
}
let alias: string | undefined;
let consumedEnd = pathEnd;
const nextToken = tokens[pathEnd + 1];
const nextKeyword = isQueryEditorSqlIdentifierToken(nextToken) && !nextToken.quoted
? stripCompletionIdentifierQuotes(nextToken.raw).toLowerCase()
: '';
if (nextKeyword === 'as') {
const aliasToken = tokens[pathEnd + 2];
if (isQueryEditorSqlIdentifierToken(aliasToken)) {
const normalizedAlias = stripCompletionIdentifierQuotes(aliasToken.raw).trim();
const aliasKeyword = aliasToken.quoted ? '' : normalizedAlias.toLowerCase();
if (normalizedAlias && (aliasToken.quoted || !QUERY_EDITOR_SQL_TABLE_ALIAS_RESERVED_WORDS.has(aliasKeyword))) {
alias = normalizedAlias;
consumedEnd = pathEnd + 2;
}
}
} else if (isQueryEditorSqlIdentifierToken(nextToken)) {
const normalizedAlias = stripCompletionIdentifierQuotes(nextToken.raw).trim();
if (normalizedAlias && (nextToken.quoted || !QUERY_EDITOR_SQL_TABLE_ALIAS_RESERVED_WORDS.has(nextKeyword))) {
alias = normalizedAlias;
consumedEnd = pathEnd + 1;
}
}
references.push({
tableIdent: parts.join('.'),
parts,
...(alias ? { alias } : {}),
});
state.sourceContextActive = !alias;
index = consumedEnd;
continue;
}
if (keyword === 'select' || keyword === 'delete') {
state.queryStatementActive = true;
state.sourceContextActive = false;
continue;
}
if (keyword === 'from' && state.queryStatementActive) {
state.fromListActive = true;
state.expectsSource = 'from';
state.sourceContextActive = true;
continue;
}
if (keyword === 'join' || keyword === 'straight_join' || keyword === 'apply') {
state.fromListActive = true;
state.expectsSource = 'join';
state.sourceContextActive = true;
continue;
}
if (keyword === 'update' || keyword === 'into') {
state.queryStatementActive = true;
state.expectsSource = keyword;
state.sourceContextActive = true;
continue;
}
if (QUERY_EDITOR_SQL_FROM_LIST_END_WORDS.has(keyword)) {
state.fromListActive = false;
state.sourceContextActive = false;
state.expectsSource = undefined;
continue;
}
if (QUERY_EDITOR_SQL_TABLE_ALIAS_RESERVED_WORDS.has(keyword)) {
state.sourceContextActive = false;
}
}
return {
references,
expectsTableSource: getState().sourceContextActive,
};
};
export const collectQueryEditorTableReferences = (source: string): QueryEditorTableReference[] => (
analyzeQueryEditorTableReferences(source).references
);
export const isQueryEditorTableSourceCompletionContext = (source: string): boolean => (
analyzeQueryEditorTableReferences(source).expectsTableSource
);
export const buildQueryEditorAliasMap = (
fullText: string,
currentDb: string,
): Record<string, { dbName: string; tableName: string; explicitOwnerName?: string }> => {
const aliasMap: Record<string, { dbName: string; tableName: string; explicitOwnerName?: string }> = {};
const reserved = new Set([
'where', 'on', 'group', 'order', 'limit', 'having',
'left', 'right', 'inner', 'outer', 'full', 'cross', 'join',
'union', 'except', 'intersect', 'as', 'set', 'values', 'returning',
]);
const aliasRegex = QUERY_EDITOR_SQL_ALIAS_REFERENCE_REGEX;
aliasRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = aliasRegex.exec(fullText)) !== null) {
const tableIdent = normalizeCompletionQualifiedName(match[1] || '');
for (const reference of collectQueryEditorTableReferences(fullText)) {
const tableIdent = reference.tableIdent;
if (!tableIdent) continue;
const parts = tableIdent.split('.');
const parts = reference.parts;
let dbName = currentDb || '';
let tableName = tableIdent;
let explicitOwnerName = '';
@@ -1994,17 +2234,15 @@ export const buildQueryEditorAliasMap = (
dbName = parts[0];
tableName = parts.slice(1).join('.');
}
const shortTable = getCompletionQualifiedNameLastPart(tableIdent);
const shortTable = parts[parts.length - 1] || '';
const aliasTarget = explicitOwnerName
? { dbName, tableName, explicitOwnerName }
: { dbName, tableName };
if (shortTable) aliasMap[shortTable.toLowerCase()] = aliasTarget;
const alias = stripCompletionIdentifierQuotes(match[2] || '').trim();
const alias = reference.alias || '';
if (!alias) continue;
const loweredAlias = alias.toLowerCase();
if (reserved.has(loweredAlias)) continue;
aliasMap[loweredAlias] = aliasTarget;
aliasMap[alias.toLowerCase()] = aliasTarget;
}
return aliasMap;
};
@@ -2051,13 +2289,10 @@ export const collectQueryEditorReferencedDatabaseNames = (
.map((db) => [db.toLowerCase(), db] as const),
);
const currentDbKey = String(currentDb || '').trim().toLowerCase();
const tableRegex = QUERY_EDITOR_SQL_TABLE_REFERENCE_REGEX;
tableRegex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = tableRegex.exec(String(fullText || ''))) !== null) {
const tableIdent = normalizeCompletionQualifiedName(match[1] || '');
for (const reference of collectQueryEditorTableReferences(fullText)) {
const tableIdent = reference.tableIdent;
if (!tableIdent) continue;
const parts = tableIdent.split('.').map((part) => String(part || '').trim()).filter(Boolean);
const parts = reference.parts.map((part) => String(part || '').trim()).filter(Boolean);
if (parts.length < 2) continue;
const firstPart = parts[0];