mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-07 15:13:51 +08:00
🐛 fix(query-editor): 修复表名及别名字段补全异常
- 表名候选改为严格前缀匹配,并在截断时触发补全重查 - 识别逗号分隔表、别名及跨库引用,补充词法边界回归测试 Fixes #810 Fixes #827
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user