mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
⚡️ perf(query-completion): 限制大元数据候选构建
This commit is contained in:
@@ -3252,6 +3252,230 @@ describe('QueryEditor external SQL save', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps large table and referenced-column completion within a bounded candidate budget', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
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')}`,
|
||||
}));
|
||||
const noisyColumnRows = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
tableName: 'users',
|
||||
name: `column_${String(index).padStart(4, '0')}`,
|
||||
type: 'varchar(64)',
|
||||
}));
|
||||
backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] });
|
||||
backendApp.DBGetTables.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
{ Tables_in_main: 'users' },
|
||||
...noisyTableRows,
|
||||
{ Tables_in_main: 'entity_primary' },
|
||||
{ Tables_in_main: 'entity' },
|
||||
],
|
||||
});
|
||||
backendApp.DBGetAllColumns.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [
|
||||
...noisyColumnRows,
|
||||
{ tableName: 'users', name: 'column_primary', type: 'varchar(64)' },
|
||||
{ tableName: 'users', name: 'column', type: 'varchar(64)' },
|
||||
],
|
||||
});
|
||||
|
||||
editorState.value = 'SELECT * FROM entity';
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const sqlProvider = findSqlCompletionProvider();
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
|
||||
const tableItems = await sqlProvider.provideCompletionItems(
|
||||
editorState.editor.getModel(),
|
||||
{ lineNumber: 1, column: editorState.value.length + 1 },
|
||||
);
|
||||
const tableLabels = tableItems.suggestions.map((item: any) => item.label);
|
||||
expect(tableLabels).toHaveLength(200);
|
||||
expect(tableLabels.slice(0, 2)).toEqual(['entity', 'entity_primary']);
|
||||
|
||||
editorState.value = 'SELECT * FROM users WHERE column';
|
||||
const columnItems = await sqlProvider.provideCompletionItems(
|
||||
editorState.editor.getModel(),
|
||||
{ lineNumber: 1, column: editorState.value.length + 1 },
|
||||
);
|
||||
const columnLabels = columnItems.suggestions.map((item: any) => item.label);
|
||||
expect(columnLabels).toHaveLength(200);
|
||||
expect(columnLabels).toContain('COLUMN');
|
||||
expect(columnLabels).toContain('column');
|
||||
expect(columnLabels.indexOf('column')).toBeLessThan(columnLabels.indexOf('column_0000'));
|
||||
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a late current-database column when other-database candidates fill the budget first', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetDatabases.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ Database: 'main' }, { Database: 'otherdb' }],
|
||||
});
|
||||
backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => ({
|
||||
success: true,
|
||||
data: dbName === 'main'
|
||||
? [{ Tables_in_main: 'local_table' }]
|
||||
: [{ Tables_in_otherdb: 'remote_table' }],
|
||||
}));
|
||||
backendApp.DBGetAllColumns.mockImplementation(async (_config: any, dbName: string) => ({
|
||||
success: true,
|
||||
data: dbName === 'otherdb'
|
||||
? Array.from({ length: 200 }, (_, index) => ({
|
||||
tableName: 'remote_table',
|
||||
name: `col_other_${String(index).padStart(3, '0')}`,
|
||||
type: 'varchar(64)',
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
backendApp.DBGetColumns.mockImplementation(async (_config: any, dbName: string, tableName: string) => ({
|
||||
success: true,
|
||||
data: dbName === 'main' && tableName === 'local_table'
|
||||
? [{ name: 'col_current_late', type: 'varchar(64)' }]
|
||||
: [],
|
||||
}));
|
||||
|
||||
editorState.value = [
|
||||
'SELECT r.col_other_000',
|
||||
'FROM otherdb.remote_table r',
|
||||
'JOIN main.local_table l ON r.id = l.id',
|
||||
'WHERE col',
|
||||
].join('\n');
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const sqlProvider = findSqlCompletionProvider();
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
|
||||
const completionItems = await sqlProvider.provideCompletionItems(
|
||||
editorState.editor.getModel(),
|
||||
{ lineNumber: 4, column: 'WHERE col'.length + 1 },
|
||||
);
|
||||
const labels = completionItems.suggestions.map((item: any) => item.label);
|
||||
|
||||
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'main', 'local_table');
|
||||
expect(labels).toHaveLength(200);
|
||||
expect(labels).toContain('col_current_late');
|
||||
expect(labels.indexOf('col_current_late')).toBeLessThan(labels.indexOf('col_other_000'));
|
||||
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps final sortText ordering when an other-database exact match follows 200 current-database prefixes', async () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
autoFetchState.visible = true;
|
||||
storeState.connections[0].config.type = 'mysql';
|
||||
storeState.connections[0].config.database = 'main';
|
||||
backendApp.DBGetDatabases.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ Database: 'main' }, { Database: 'otherdb' }],
|
||||
});
|
||||
backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => ({
|
||||
success: true,
|
||||
data: dbName === 'main'
|
||||
? Array.from({ length: 200 }, (_, index) => ({
|
||||
Tables_in_main: `tar_current_${String(index).padStart(3, '0')}`,
|
||||
}))
|
||||
: [{ Tables_in_otherdb: 'seed' }, { Tables_in_otherdb: 'tar' }],
|
||||
}));
|
||||
backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] });
|
||||
|
||||
editorState.value = 'SELECT * FROM otherdb.seed;\nSELECT tar';
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const sqlProvider = findSqlCompletionProvider();
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
|
||||
const completionItems = await sqlProvider.provideCompletionItems(
|
||||
editorState.editor.getModel(),
|
||||
{ lineNumber: 2, column: 'SELECT tar'.length + 1 },
|
||||
);
|
||||
const labels = completionItems.suggestions.map((item: any) => item.label);
|
||||
|
||||
expect(labels).toHaveLength(200);
|
||||
expect(labels).not.toContain('otherdb.tar');
|
||||
expect(labels[0]).toBe('tar_current_000');
|
||||
expect(labels[199]).toBe('tar_current_199');
|
||||
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no completion for an unmatched known schema qualifier instead of leaking global objects', 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: 'dbo.users' },
|
||||
{ Table: 'sales.zzz_candidate' },
|
||||
{ Table: 'zzz_global' },
|
||||
],
|
||||
});
|
||||
backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] });
|
||||
|
||||
editorState.value = 'SELECT * FROM dbo.zzz';
|
||||
await act(async () => {
|
||||
renderer = create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'main' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
const sqlProvider = findSqlCompletionProvider();
|
||||
expect(sqlProvider).toBeTruthy();
|
||||
|
||||
const completionItems = await sqlProvider.provideCompletionItems(
|
||||
editorState.editor.getModel(),
|
||||
{ lineNumber: 1, column: editorState.value.length + 1 },
|
||||
);
|
||||
|
||||
expect(completionItems.suggestions).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
renderer.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves database and table targets for ctrl/cmd navigation', () => {
|
||||
const tables = [
|
||||
{ dbName: 'main', tableName: 'users' },
|
||||
|
||||
@@ -110,6 +110,7 @@ import {
|
||||
type QueryEditorNavigationTarget,
|
||||
type QueryStatementPlan,
|
||||
QUERY_EDITOR_HOVER_DELAY_MS,
|
||||
QUERY_EDITOR_COMPLETION_SUGGESTION_LIMIT,
|
||||
QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH,
|
||||
QUERY_EDITOR_OBJECT_DECORATION_MAX_TEXT_LENGTH,
|
||||
QUERY_EDITOR_PERSISTED_DRAFT_MAX_TEXT_LENGTH,
|
||||
@@ -118,6 +119,8 @@ import {
|
||||
QUERY_EDITOR_SQL_THREE_PART_COMPLETION_REGEX,
|
||||
appendCommentToDetail,
|
||||
areSqlStatementListsEqual,
|
||||
buildBoundedQueryEditorCompletionSuggestions,
|
||||
createBoundedQueryEditorCompletionCandidateBatch,
|
||||
buildCompletionDocumentation,
|
||||
buildColumnCompletionDetail,
|
||||
buildColumnCompletionDocumentation,
|
||||
@@ -136,7 +139,9 @@ import {
|
||||
clearQueryEditorObjectDecorations,
|
||||
collectQueryEditorObjectDecorationCandidates,
|
||||
collectQueryEditorReferencedDatabaseNames,
|
||||
findCompletionTablesByDatabase,
|
||||
getCaseInsensitiveValue,
|
||||
getCompletionTableSchemaCounts,
|
||||
getFirstRowValue,
|
||||
getNormalizedPositionAtOffset,
|
||||
hasQueryEditorCtrlMetaModifier,
|
||||
@@ -158,6 +163,7 @@ import {
|
||||
queryCompletionMetadataRowsBySpecs,
|
||||
readSidebarSqlDropText,
|
||||
matchLeadingSelectTableReference,
|
||||
materializeBoundedQueryEditorCompletionBatches,
|
||||
resolveNewQueryDefaultTemplate,
|
||||
resolveEventTargetNode,
|
||||
resolveNextResultSetIndex,
|
||||
@@ -170,6 +176,7 @@ import {
|
||||
resolveQueryEditorHoverTarget,
|
||||
resolveQueryEditorNavigationDecorations,
|
||||
resolveQueryEditorNavigationTarget,
|
||||
rankQueryEditorCompletionCandidate,
|
||||
resolveQueryLocatorPlan,
|
||||
rewriteLeadingSelectTableReference,
|
||||
selectUnqualifiedCompletionSynonyms,
|
||||
@@ -5290,19 +5297,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return createEmptySqlCompletionResult();
|
||||
}
|
||||
|
||||
const filtered = colPrefix
|
||||
? cols.filter(c => (c.name || '').toLowerCase().startsWith(colPrefix))
|
||||
: cols;
|
||||
|
||||
const suggestions = filtered.map(c => ({
|
||||
label: c.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: quoteCompletionPart(c.name),
|
||||
detail: buildColumnCompletionDetail(c),
|
||||
documentation: buildColumnCompletionDocumentation(c),
|
||||
range,
|
||||
sortText: '0' + c.name
|
||||
}));
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates: cols,
|
||||
prefix: colPrefix,
|
||||
getMatchRank: (column, prefix) => rankQueryEditorCompletionCandidate(prefix, [column.name], false),
|
||||
getSelectionKey: (column) => '0' + column.name,
|
||||
buildSuggestion: (column) => ({
|
||||
label: column.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: quoteCompletionPart(column.name),
|
||||
detail: buildColumnCompletionDetail(column),
|
||||
documentation: buildColumnCompletionDocumentation(column),
|
||||
range,
|
||||
sortText: '0' + column.name,
|
||||
}),
|
||||
});
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
@@ -5317,138 +5326,192 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const visibleDbs = sharedVisibleDbs;
|
||||
if (visibleDbs.some(db => db.toLowerCase() === qualifierLower)) {
|
||||
// qualifier 是数据库名,提示该库的表
|
||||
let tables = sharedTablesData.filter(t =>
|
||||
(t.dbName || '').toLowerCase() === qualifierLower
|
||||
);
|
||||
let tables = findCompletionTablesByDatabase(sharedTablesData, qualifier);
|
||||
if (tables.length === 0) {
|
||||
tables = await getLazyTablesByDB(qualifier);
|
||||
if (isSqlCompletionRequestCancelled(token)) {
|
||||
return createEmptySqlCompletionResult();
|
||||
}
|
||||
}
|
||||
const filtered = prefix
|
||||
? tables.filter(t => {
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || '');
|
||||
return String(suggestionMeta.displayName || '').toLowerCase().startsWith(prefix)
|
||||
|| String(t.tableName || '').toLowerCase().startsWith(prefix);
|
||||
})
|
||||
: tables;
|
||||
|
||||
const suggestions = filtered.map(t => {
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || '');
|
||||
return {
|
||||
label: suggestionMeta.displayName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: suggestionMeta.insertText,
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
range,
|
||||
sortText: '0' + suggestionMeta.displayName
|
||||
};
|
||||
const tableBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: tables,
|
||||
prefix,
|
||||
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], false);
|
||||
},
|
||||
getSelectionKey: (table) => {
|
||||
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || qualifier, table.tableName || '');
|
||||
return '0' + meta.displayName;
|
||||
},
|
||||
buildSuggestion: (table) => {
|
||||
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || qualifier, table.tableName || '');
|
||||
return {
|
||||
label: meta.displayName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: meta.insertText,
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${table.dbName})`, table.comment),
|
||||
documentation: buildCompletionDocumentation(table.comment),
|
||||
range,
|
||||
sortText: '0' + meta.displayName,
|
||||
};
|
||||
},
|
||||
});
|
||||
const viewSuggestions = [
|
||||
...sharedViewsData.map((view) => ({ view, materialized: false })),
|
||||
...sharedMaterializedViewsData.map((view) => ({ view, materialized: true })),
|
||||
]
|
||||
.filter(({ view }) => String(view.dbName || '').toLowerCase() === qualifierLower)
|
||||
.map(({ view, materialized }) => ({ view, materialized, meta: buildViewSuggestionMeta(view) }))
|
||||
.filter(({ view, meta }) => (
|
||||
!prefix
|
||||
|| meta.displayName.toLowerCase().startsWith(prefix)
|
||||
|| meta.objectName.toLowerCase().startsWith(prefix)
|
||||
|| String(view.viewName || '').toLowerCase().startsWith(prefix)
|
||||
))
|
||||
.map(({ view, materialized, meta }) => ({
|
||||
label: meta.displayName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(meta.displayName),
|
||||
detail: `${getViewTypeLabel(materialized)} (${view.dbName})`,
|
||||
range,
|
||||
sortText: '05' + meta.displayName,
|
||||
}));
|
||||
const synonymSuggestions = sharedSynonymsData
|
||||
.filter((synonym) => String(synonym.ownerName || '').trim().toLowerCase() === qualifierLower)
|
||||
.filter((synonym) => !prefix || String(synonym.synonymName || '').toLowerCase().startsWith(prefix))
|
||||
.map((synonym) => buildSynonymSuggestion(synonym, '06' + synonym.synonymName));
|
||||
const routineSuggestions = sharedRoutinesData
|
||||
.filter((routine) => String(routine.dbName || '').toLowerCase() === qualifierLower)
|
||||
.map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) }))
|
||||
.filter(({ routine, meta }) => (
|
||||
!prefix
|
||||
|| meta.displayName.toLowerCase().startsWith(prefix)
|
||||
|| meta.objectName.toLowerCase().startsWith(prefix)
|
||||
|| String(routine.routineName || '').toLowerCase().startsWith(prefix)
|
||||
))
|
||||
.map(({ routine, meta }) => ({
|
||||
label: meta.displayName,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: meta.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName})`,
|
||||
range,
|
||||
sortText: '1' + meta.displayName,
|
||||
}));
|
||||
return { suggestions: [...suggestions, ...viewSuggestions, ...synonymSuggestions, ...routineSuggestions] };
|
||||
const buildQualifiedViewBatch = (views: CompletionViewMeta[], materialized: boolean) => (
|
||||
createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: views,
|
||||
prefix,
|
||||
getMatchRank: (view, normalizedPrefix) => {
|
||||
if (String(view.dbName || '').toLowerCase() !== qualifierLower) return null;
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
return rankQueryEditorCompletionCandidate(
|
||||
normalizedPrefix,
|
||||
[meta.displayName, meta.objectName, view.viewName],
|
||||
false,
|
||||
);
|
||||
},
|
||||
getSelectionKey: (view) => '05' + buildViewSuggestionMeta(view).displayName,
|
||||
buildSuggestion: (view) => {
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
return {
|
||||
label: meta.displayName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(meta.displayName),
|
||||
detail: `${getViewTypeLabel(materialized)} (${view.dbName})`,
|
||||
range,
|
||||
sortText: '05' + meta.displayName,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
const viewBatch = buildQualifiedViewBatch(sharedViewsData, false);
|
||||
const materializedViewBatch = buildQualifiedViewBatch(sharedMaterializedViewsData, true);
|
||||
const synonymBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: sharedSynonymsData,
|
||||
prefix,
|
||||
getMatchRank: (synonym, normalizedPrefix) => (
|
||||
String(synonym.ownerName || '').trim().toLowerCase() === qualifierLower
|
||||
? rankQueryEditorCompletionCandidate(normalizedPrefix, [synonym.synonymName], false)
|
||||
: null
|
||||
),
|
||||
getSelectionKey: (synonym) => '06' + synonym.synonymName,
|
||||
buildSuggestion: (synonym) => buildSynonymSuggestion(synonym, '06' + synonym.synonymName),
|
||||
});
|
||||
const routineBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: sharedRoutinesData,
|
||||
prefix,
|
||||
getMatchRank: (routine, normalizedPrefix) => {
|
||||
if (String(routine.dbName || '').toLowerCase() !== qualifierLower) return null;
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
return rankQueryEditorCompletionCandidate(
|
||||
normalizedPrefix,
|
||||
[meta.displayName, meta.objectName, routine.routineName],
|
||||
false,
|
||||
);
|
||||
},
|
||||
getSelectionKey: (routine) => '1' + buildRoutineSuggestionMeta(routine).displayName,
|
||||
buildSuggestion: (routine) => {
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
return {
|
||||
label: meta.displayName,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: meta.insertText,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName})`,
|
||||
range,
|
||||
sortText: '1' + meta.displayName,
|
||||
};
|
||||
},
|
||||
});
|
||||
return {
|
||||
suggestions: materializeBoundedQueryEditorCompletionBatches([
|
||||
tableBatch,
|
||||
viewBatch,
|
||||
materializedViewBatch,
|
||||
synonymBatch,
|
||||
routineBatch,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
// qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table
|
||||
const schemaTables = sharedTablesData
|
||||
.map(t => {
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
let hasKnownSchemaQualifier = false;
|
||||
const schemaTableBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: sharedTablesData,
|
||||
prefix,
|
||||
getMatchRank: (table, normalizedPrefix) => {
|
||||
const parsed = splitSchemaAndTable(table.tableName || '');
|
||||
if (parsed.schema.toLowerCase() !== qualifierLower) return null;
|
||||
hasKnownSchemaQualifier = true;
|
||||
if (!parsed.table) return null;
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [parsed.table], false);
|
||||
},
|
||||
getSelectionKey: (table) => '0' + splitSchemaAndTable(table.tableName || '').table,
|
||||
buildSuggestion: (table) => {
|
||||
const parsed = splitSchemaAndTable(table.tableName || '');
|
||||
return {
|
||||
dbName: t.dbName || '',
|
||||
schema: parsed.schema,
|
||||
table: parsed.table,
|
||||
comment: t.comment,
|
||||
};
|
||||
})
|
||||
.filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table);
|
||||
const schemaViews = [
|
||||
...sharedViewsData.map((view) => ({ view, materialized: false })),
|
||||
...sharedMaterializedViewsData.map((view) => ({ view, materialized: true })),
|
||||
]
|
||||
.map(({ view, materialized }) => ({ view, materialized, meta: buildViewSuggestionMeta(view) }))
|
||||
.filter(({ meta }) => meta.schemaName.toLowerCase() === qualifierLower && !!meta.objectName);
|
||||
const schemaSynonyms = sharedSynonymsData
|
||||
.filter((synonym) => String(synonym.ownerName || '').trim().toLowerCase() === qualifierLower);
|
||||
|
||||
if (schemaTables.length > 0 || schemaViews.length > 0 || schemaSynonyms.length > 0) {
|
||||
const filtered = prefix
|
||||
? schemaTables.filter(t => t.table.toLowerCase().startsWith(prefix))
|
||||
: schemaTables;
|
||||
|
||||
const suggestions = [
|
||||
...filtered.map(t => ({
|
||||
label: t.table,
|
||||
label: parsed.table,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPart(t.table),
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
insertText: quoteCompletionPart(parsed.table),
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${table.dbName}${parsed.schema ? '.' + parsed.schema : ''})`, table.comment),
|
||||
documentation: buildCompletionDocumentation(table.comment),
|
||||
range,
|
||||
sortText: '0' + t.table
|
||||
})),
|
||||
...schemaViews
|
||||
.filter(({ meta }) => !prefix || meta.objectName.toLowerCase().startsWith(prefix))
|
||||
.map(({ view, materialized, meta }) => ({
|
||||
label: meta.objectName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPart(meta.objectName),
|
||||
sortText: '0' + parsed.table,
|
||||
};
|
||||
},
|
||||
});
|
||||
const buildSchemaViewBatch = (views: CompletionViewMeta[], materialized: boolean) => (
|
||||
createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: views,
|
||||
prefix,
|
||||
getMatchRank: (view, normalizedPrefix) => {
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
if (meta.schemaName.toLowerCase() !== qualifierLower) return null;
|
||||
hasKnownSchemaQualifier = true;
|
||||
if (!meta.objectName) return null;
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.objectName], false);
|
||||
},
|
||||
getSelectionKey: (view) => '05' + buildViewSuggestionMeta(view).objectName,
|
||||
buildSuggestion: (view) => {
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
return {
|
||||
label: meta.objectName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPart(meta.objectName),
|
||||
detail: `${getViewTypeLabel(materialized)} (${getViewSuggestionScope(view, meta)})`,
|
||||
range,
|
||||
sortText: '05' + meta.objectName,
|
||||
})),
|
||||
...schemaSynonyms
|
||||
.filter((synonym) => !prefix || String(synonym.synonymName || '').toLowerCase().startsWith(prefix))
|
||||
.map((synonym) => buildSynonymSuggestion(synonym, '06' + synonym.synonymName)),
|
||||
];
|
||||
const routineSuggestions = sharedRoutinesData
|
||||
.filter((routine) => {
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
return meta.schemaName.toLowerCase() === qualifierLower;
|
||||
})
|
||||
.map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) }))
|
||||
.filter(({ meta }) => !prefix || meta.objectName.toLowerCase().startsWith(prefix))
|
||||
.map(({ routine, meta }) => ({
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
const schemaViewBatch = buildSchemaViewBatch(sharedViewsData, false);
|
||||
const schemaMaterializedViewBatch = buildSchemaViewBatch(sharedMaterializedViewsData, true);
|
||||
const schemaSynonymBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: sharedSynonymsData,
|
||||
prefix,
|
||||
getMatchRank: (synonym, normalizedPrefix) => {
|
||||
if (String(synonym.ownerName || '').trim().toLowerCase() !== qualifierLower) return null;
|
||||
hasKnownSchemaQualifier = true;
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [synonym.synonymName], false);
|
||||
},
|
||||
getSelectionKey: (synonym) => '06' + synonym.synonymName,
|
||||
buildSuggestion: (synonym) => buildSynonymSuggestion(synonym, '06' + synonym.synonymName),
|
||||
});
|
||||
const schemaRoutineBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: sharedRoutinesData,
|
||||
prefix,
|
||||
getMatchRank: (routine, normalizedPrefix) => {
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
if (meta.schemaName.toLowerCase() !== qualifierLower) return null;
|
||||
hasKnownSchemaQualifier = true;
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.objectName], false);
|
||||
},
|
||||
getSelectionKey: (routine) => '1' + buildRoutineSuggestionMeta(routine).objectName,
|
||||
buildSuggestion: (routine) => {
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
return {
|
||||
label: meta.objectName,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: `${quoteCompletionPart(meta.objectName)}($0)`,
|
||||
@@ -5456,8 +5519,18 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
detail: `${getRoutineTypeLabel(routine.routineType)} (${routine.dbName}${meta.schemaName ? '.' + meta.schemaName : ''})`,
|
||||
range,
|
||||
sortText: '1' + meta.objectName,
|
||||
}));
|
||||
return { suggestions: [...suggestions, ...routineSuggestions] };
|
||||
};
|
||||
},
|
||||
});
|
||||
const schemaSuggestions = materializeBoundedQueryEditorCompletionBatches([
|
||||
schemaTableBatch,
|
||||
schemaViewBatch,
|
||||
schemaMaterializedViewBatch,
|
||||
schemaSynonymBatch,
|
||||
schemaRoutineBatch,
|
||||
]);
|
||||
if (hasKnownSchemaQualifier) {
|
||||
return { suggestions: schemaSuggestions };
|
||||
}
|
||||
|
||||
// 否则检查是否是表别名或表名,提示列
|
||||
@@ -5474,19 +5547,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return createEmptySqlCompletionResult();
|
||||
}
|
||||
|
||||
const filtered = prefix
|
||||
? cols.filter(c => (c.name || '').toLowerCase().startsWith(prefix))
|
||||
: cols;
|
||||
|
||||
const suggestions = filtered.map(c => ({
|
||||
label: c.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: quoteCompletionPart(c.name),
|
||||
detail: buildColumnCompletionDetail(c),
|
||||
documentation: buildColumnCompletionDocumentation(c),
|
||||
range,
|
||||
sortText: '0' + c.name
|
||||
}));
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates: cols,
|
||||
prefix,
|
||||
getMatchRank: (column, normalizedPrefix) => rankQueryEditorCompletionCandidate(normalizedPrefix, [column.name], false),
|
||||
getSelectionKey: (column) => '0' + column.name,
|
||||
buildSuggestion: (column) => ({
|
||||
label: column.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: quoteCompletionPart(column.name),
|
||||
detail: buildColumnCompletionDetail(column),
|
||||
documentation: buildColumnCompletionDocumentation(column),
|
||||
range,
|
||||
sortText: '0' + column.name,
|
||||
}),
|
||||
});
|
||||
return { suggestions };
|
||||
}
|
||||
}
|
||||
@@ -5507,8 +5582,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const isCurrentCompletionDatabase = (dbName: string) =>
|
||||
String(dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||
const wordPrefix = (word.word || '').toLowerCase();
|
||||
const startsWithPrefix = (candidate: string) => !wordPrefix || candidate.toLowerCase().startsWith(wordPrefix);
|
||||
const includesWordPrefix = (candidate: string) => !wordPrefix || String(candidate || '').toLowerCase().includes(wordPrefix);
|
||||
const getPrefixMatchRank = (...candidates: string[]) => {
|
||||
if (!wordPrefix) return '0';
|
||||
const normalized = candidates
|
||||
@@ -5543,30 +5616,27 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
? { keyword: '20', func: '25', routineCurrent: '26', routineOther: '27', columnCurrent: '10', columnOther: '11', tableCurrent: '00', tableOther: '01', db: '30' }
|
||||
: { keyword: '30', func: '25', routineCurrent: '26', routineOther: '27', columnCurrent: '00', columnOther: '01', tableCurrent: '10', tableOther: '11', db: '20' };
|
||||
let completionTables = sharedTablesData;
|
||||
const currentSharedTables = expectsTableName && currentDatabase
|
||||
? findCompletionTablesByDatabase(sharedTablesData, currentDatabase)
|
||||
: [];
|
||||
if (
|
||||
expectsTableName
|
||||
&& currentDatabase
|
||||
&& !sharedTablesData.some((t) => isCurrentCompletionDatabase(t.dbName || ''))
|
||||
&& currentSharedTables.length === 0
|
||||
) {
|
||||
const lazyTables = await getLazyTablesByDB(currentDatabase);
|
||||
if (isSqlCompletionRequestCancelled(token)) {
|
||||
return createEmptySqlCompletionResult();
|
||||
}
|
||||
if (lazyTables.length > 0) {
|
||||
const seenTableKeys = new Set<string>();
|
||||
completionTables = [...sharedTablesData, ...lazyTables].filter((table) => {
|
||||
const key = `${String(table.dbName || '').toLowerCase()}.${String(table.tableName || '').toLowerCase()}`;
|
||||
if (seenTableKeys.has(key)) return false;
|
||||
seenTableKeys.add(key);
|
||||
return true;
|
||||
});
|
||||
completionTables = lazyTables;
|
||||
}
|
||||
}
|
||||
if (
|
||||
expectsTableName
|
||||
&& currentDatabase
|
||||
&& sharedTablesData.some((table) => isCurrentCompletionDatabase(table.dbName || ''))
|
||||
&& sharedTablesData.some((table) => isCurrentCompletionDatabase(table.dbName || '') && !normalizeCommentText(table.comment))
|
||||
&& currentSharedTables.length > 0
|
||||
&& currentSharedTables.some((table) => !normalizeCommentText(table.comment))
|
||||
) {
|
||||
const enrichedTables = await getLazyTablesByDB(currentDatabase);
|
||||
if (isSqlCompletionRequestCancelled(token)) {
|
||||
@@ -5577,7 +5647,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
if (expectsTableName && currentDatabase) {
|
||||
completionTables = completionTables.filter((table) => isCurrentCompletionDatabase(table.dbName || ''));
|
||||
completionTables = findCompletionTablesByDatabase(completionTables, currentDatabase);
|
||||
}
|
||||
|
||||
const referencedColumns: CompletionColumnMeta[] = [];
|
||||
@@ -5604,149 +5674,189 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
// 相关列提示:匹配 SQL 中引用的表(FROM/JOIN 等)
|
||||
// 权重最高,输入 WHERE 条件时优先显示
|
||||
// 先用索引把候选收敛到被引用表的列,避免整库列全量扫描。
|
||||
const relevantColumnCandidates = expectsTableName || foundTables.size === 0
|
||||
const preloadedRelevantColumns = expectsTableName || foundTables.size === 0
|
||||
? []
|
||||
: [
|
||||
...collectSharedColumnsForTableIdents(sharedAllColumnsData, foundTables),
|
||||
...referencedColumns,
|
||||
];
|
||||
const relevantColumns = relevantColumnCandidates
|
||||
.filter(c => {
|
||||
const fullIdent = `${c.dbName}.${c.tableName}`.toLowerCase();
|
||||
const shortIdent = (c.tableName || '').toLowerCase();
|
||||
// 对 schema.table 格式,也用纯表名部分匹配(如 public.users → users)
|
||||
const parsed = splitSchemaAndTable(c.tableName || '');
|
||||
: collectSharedColumnsForTableIdents(sharedAllColumnsData, foundTables);
|
||||
const relevantColumnCandidates = preloadedRelevantColumns.length === 0
|
||||
? referencedColumns
|
||||
: referencedColumns.length === 0
|
||||
? preloadedRelevantColumns
|
||||
: [...preloadedRelevantColumns, ...referencedColumns];
|
||||
const relevantColumnBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: relevantColumnCandidates,
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (column, normalizedPrefix) => {
|
||||
const fullIdent = `${column.dbName}.${column.tableName}`.toLowerCase();
|
||||
const shortIdent = (column.tableName || '').toLowerCase();
|
||||
const parsed = splitSchemaAndTable(column.tableName || '');
|
||||
const pureIdent = (parsed.table || '').toLowerCase();
|
||||
return (foundTables.has(fullIdent) || foundTables.has(shortIdent) || (pureIdent && foundTables.has(pureIdent))) && startsWithPrefix(c.name || '');
|
||||
})
|
||||
.map(c => {
|
||||
// 当前库的表字段优先级更高
|
||||
const isCurrentDb = isCurrentCompletionDatabase(c.dbName || '');
|
||||
if (!foundTables.has(fullIdent) && !foundTables.has(shortIdent) && (!pureIdent || !foundTables.has(pureIdent))) {
|
||||
return null;
|
||||
}
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [column.name], false);
|
||||
},
|
||||
getSelectionKey: (column) => (
|
||||
isCurrentCompletionDatabase(column.dbName || '')
|
||||
? sortGroups.columnCurrent + column.name
|
||||
: sortGroups.columnOther + column.name
|
||||
),
|
||||
buildSuggestion: (column) => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(column.dbName || '');
|
||||
return {
|
||||
label: c.name,
|
||||
label: column.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: quoteCompletionPart(c.name),
|
||||
detail: buildColumnCompletionDetail(c),
|
||||
documentation: buildColumnCompletionDocumentation(c),
|
||||
insertText: quoteCompletionPart(column.name),
|
||||
detail: buildColumnCompletionDetail(column),
|
||||
documentation: buildColumnCompletionDocumentation(column),
|
||||
range,
|
||||
sortText: isCurrentDb ? sortGroups.columnCurrent + c.name : sortGroups.columnOther + c.name,
|
||||
sortText: isCurrentDb ? sortGroups.columnCurrent + column.name : sortGroups.columnOther + column.name,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 表提示:当前库智能处理 schema.table 格式
|
||||
// 1. 构建纯表名到 schema 列表的映射,检测同名表
|
||||
const currentDbTables = completionTables.filter(t =>
|
||||
isCurrentCompletionDatabase(t.dbName || '')
|
||||
);
|
||||
const tableNameToSchemas = new Map<string, string[]>();
|
||||
for (const t of currentDbTables) {
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = (parsed.table || t.tableName || '').toLowerCase();
|
||||
const schemas = tableNameToSchemas.get(pureTable) || [];
|
||||
schemas.push(parsed.schema || '');
|
||||
tableNameToSchemas.set(pureTable, schemas);
|
||||
}
|
||||
const currentDatabaseTables = currentDatabase
|
||||
? findCompletionTablesByDatabase(completionTables, currentDatabase)
|
||||
: [];
|
||||
const tableNameToSchemaCount = getCompletionTableSchemaCounts(currentDatabaseTables);
|
||||
|
||||
const tableSuggestions = completionTables
|
||||
.filter(t => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(t.dbName || '');
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = parsed.table || t.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || '');
|
||||
const label = suggestionMeta.dbQualifiedLabel;
|
||||
// 跨库:用 db.table 格式匹配
|
||||
return includesWordPrefix(label)
|
||||
|| includesWordPrefix(t.tableName || '')
|
||||
|| includesWordPrefix(pureTable);
|
||||
}
|
||||
// 当前库:同时用完整名和纯表名匹配
|
||||
return includesWordPrefix(t.tableName || '') || includesWordPrefix(pureTable);
|
||||
})
|
||||
.map(t => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(t.dbName || '');
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = parsed.table || t.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || '');
|
||||
const label = suggestionMeta.dbQualifiedLabel;
|
||||
const tableBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: completionTables,
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (table, normalizedPrefix) => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(table.dbName || '');
|
||||
const parsed = splitSchemaAndTable(table.tableName || '');
|
||||
const pureTable = parsed.table || table.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || '', table.tableName || '');
|
||||
return rankQueryEditorCompletionCandidate(
|
||||
normalizedPrefix,
|
||||
[meta.dbQualifiedLabel, table.tableName, pureTable],
|
||||
);
|
||||
}
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [table.tableName, pureTable]);
|
||||
},
|
||||
getSelectionKey: (table) => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(table.dbName || '');
|
||||
const parsed = splitSchemaAndTable(table.tableName || '');
|
||||
const pureTable = parsed.table || table.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || '', table.tableName || '');
|
||||
const label = meta.dbQualifiedLabel;
|
||||
return sortGroups.tableOther + getPrefixMatchRank(label, table.tableName || '', pureTable) + label;
|
||||
}
|
||||
return sortGroups.tableCurrent + getPrefixMatchRank(table.tableName || '', pureTable) + pureTable;
|
||||
},
|
||||
buildSuggestion: (table) => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(table.dbName || '');
|
||||
const parsed = splitSchemaAndTable(table.tableName || '');
|
||||
const pureTable = parsed.table || table.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || '', table.tableName || '');
|
||||
const label = meta.dbQualifiedLabel;
|
||||
return {
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(label),
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${table.dbName})`, table.comment),
|
||||
documentation: buildCompletionDocumentation(table.comment),
|
||||
range,
|
||||
sortText: sortGroups.tableOther + getPrefixMatchRank(label, table.tableName || '', pureTable) + label,
|
||||
};
|
||||
}
|
||||
const hasDuplicate = (tableNameToSchemaCount.get(pureTable.toLowerCase()) || 0) > 1;
|
||||
const label = hasDuplicate ? table.tableName : pureTable;
|
||||
const schemaInfo = parsed.schema ? ` (${parsed.schema})` : '';
|
||||
return {
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(label),
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
insertText: quoteCompletionPath(hasDuplicate ? table.tableName : pureTable),
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')}${schemaInfo}`, table.comment),
|
||||
documentation: buildCompletionDocumentation(table.comment),
|
||||
range,
|
||||
sortText: sortGroups.tableOther + getPrefixMatchRank(label, t.tableName || '', pureTable) + label,
|
||||
sortText: sortGroups.tableCurrent + getPrefixMatchRank(table.tableName || '', pureTable) + pureTable,
|
||||
};
|
||||
}
|
||||
// 当前库:检查是否有跨 schema 同名表
|
||||
const schemas = tableNameToSchemas.get(pureTable.toLowerCase()) || [];
|
||||
const hasDuplicate = schemas.length > 1;
|
||||
// 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名
|
||||
const label = hasDuplicate ? t.tableName : pureTable;
|
||||
const insertText = quoteCompletionPath(hasDuplicate ? t.tableName : pureTable);
|
||||
const schemaInfo = parsed.schema ? ` (${parsed.schema})` : '';
|
||||
return {
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText,
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')}${schemaInfo}`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
range,
|
||||
sortText: sortGroups.tableCurrent + getPrefixMatchRank(t.tableName || '', pureTable) + pureTable,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const completionViews = [
|
||||
...sharedViewsData.map((view) => ({ view, materialized: false })),
|
||||
...sharedMaterializedViewsData.map((view) => ({ view, materialized: true })),
|
||||
].filter(({ view }) => (
|
||||
!expectsTableName
|
||||
|| !currentDatabase
|
||||
|| isCurrentCompletionDatabase(view.dbName || '')
|
||||
));
|
||||
const viewSuggestions = completionViews
|
||||
.map(({ view, materialized }) => ({ view, materialized, meta: buildViewSuggestionMeta(view) }))
|
||||
.filter(({ view, meta }) => (
|
||||
includesWordPrefix(meta.dbQualifiedLabel)
|
||||
|| includesWordPrefix(meta.displayName)
|
||||
|| includesWordPrefix(meta.objectName)
|
||||
|| includesWordPrefix(view.viewName || '')
|
||||
))
|
||||
.map(({ view, materialized, meta }) => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(view.dbName || '');
|
||||
const label = isCurrentDb ? meta.displayName : meta.dbQualifiedLabel;
|
||||
return {
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: meta.insertText,
|
||||
detail: `${getViewTypeLabel(materialized)} (${getViewSuggestionScope(view, meta)})`,
|
||||
range,
|
||||
sortText: (isCurrentDb ? sortGroups.tableCurrent : sortGroups.tableOther)
|
||||
const buildGlobalViewBatch = (views: CompletionViewMeta[], materialized: boolean) => (
|
||||
createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: views,
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (view, normalizedPrefix) => {
|
||||
if (expectsTableName && currentDatabase && !isCurrentCompletionDatabase(view.dbName || '')) return null;
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
return rankQueryEditorCompletionCandidate(
|
||||
normalizedPrefix,
|
||||
[meta.dbQualifiedLabel, meta.displayName, meta.objectName, view.viewName],
|
||||
);
|
||||
},
|
||||
getSelectionKey: (view) => {
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
const isCurrentDb = isCurrentCompletionDatabase(view.dbName || '');
|
||||
const label = isCurrentDb ? meta.displayName : meta.dbQualifiedLabel;
|
||||
return (isCurrentDb ? sortGroups.tableCurrent : sortGroups.tableOther)
|
||||
+ '1'
|
||||
+ getPrefixMatchRank(label, meta.displayName, meta.objectName, view.viewName || '')
|
||||
+ label,
|
||||
};
|
||||
});
|
||||
+ label;
|
||||
},
|
||||
buildSuggestion: (view) => {
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
const isCurrentDb = isCurrentCompletionDatabase(view.dbName || '');
|
||||
const label = isCurrentDb ? meta.displayName : meta.dbQualifiedLabel;
|
||||
return {
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: meta.insertText,
|
||||
detail: `${getViewTypeLabel(materialized)} (${getViewSuggestionScope(view, meta)})`,
|
||||
range,
|
||||
sortText: (isCurrentDb ? sortGroups.tableCurrent : sortGroups.tableOther)
|
||||
+ '1'
|
||||
+ getPrefixMatchRank(label, meta.displayName, meta.objectName, view.viewName || '')
|
||||
+ label,
|
||||
};
|
||||
},
|
||||
})
|
||||
);
|
||||
const viewBatch = buildGlobalViewBatch(sharedViewsData, false);
|
||||
const materializedViewBatch = buildGlobalViewBatch(sharedMaterializedViewsData, true);
|
||||
|
||||
const synonymSuggestions = selectUnqualifiedCompletionSynonyms(sharedSynonymsData, oracleLoginOwner)
|
||||
.filter((synonym) => includesWordPrefix(synonym.synonymName || ''))
|
||||
.map((synonym) => buildSynonymSuggestion(
|
||||
const synonymBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: selectUnqualifiedCompletionSynonyms(sharedSynonymsData, oracleLoginOwner),
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (synonym, normalizedPrefix) => (
|
||||
rankQueryEditorCompletionCandidate(normalizedPrefix, [synonym.synonymName])
|
||||
),
|
||||
getSelectionKey: (synonym) => (
|
||||
sortGroups.tableCurrent + '05' + getPrefixMatchRank(synonym.synonymName || '') + synonym.synonymName
|
||||
),
|
||||
buildSuggestion: (synonym) => buildSynonymSuggestion(
|
||||
synonym,
|
||||
sortGroups.tableCurrent + '05' + getPrefixMatchRank(synonym.synonymName || '') + synonym.synonymName,
|
||||
));
|
||||
),
|
||||
});
|
||||
|
||||
const routineSuggestions = sharedRoutinesData
|
||||
.map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) }))
|
||||
.filter(({ routine, meta }) => {
|
||||
if (expectsRoutineName && meta.routineType !== 'PROCEDURE') return false;
|
||||
return includesWordPrefix(meta.dbQualifiedLabel)
|
||||
|| includesWordPrefix(meta.displayName)
|
||||
|| includesWordPrefix(meta.objectName)
|
||||
|| includesWordPrefix(routine.routineName || '');
|
||||
})
|
||||
.map(({ routine, meta }) => {
|
||||
const routineBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: sharedRoutinesData,
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (routine, normalizedPrefix) => {
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
if (expectsRoutineName && meta.routineType !== 'PROCEDURE') return null;
|
||||
return rankQueryEditorCompletionCandidate(
|
||||
normalizedPrefix,
|
||||
[meta.dbQualifiedLabel, meta.displayName, meta.objectName, routine.routineName],
|
||||
);
|
||||
},
|
||||
getSelectionKey: (routine) => {
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
const isCurrentDb = isCurrentCompletionDatabase(routine.dbName || '');
|
||||
return (isCurrentDb ? sortGroups.routineCurrent : sortGroups.routineOther)
|
||||
+ getPrefixMatchRank(meta.dbQualifiedLabel, meta.displayName, meta.objectName, routine.routineName || '')
|
||||
+ meta.dbQualifiedLabel;
|
||||
},
|
||||
buildSuggestion: (routine) => {
|
||||
const meta = buildRoutineSuggestionMeta(routine);
|
||||
const isCurrentDb = isCurrentCompletionDatabase(routine.dbName || '');
|
||||
const schemaInfo = meta.schemaName && meta.schemaName.toLowerCase() !== String(routine.dbName || '').toLowerCase()
|
||||
? `.${meta.schemaName}`
|
||||
@@ -5762,65 +5872,68 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
+ getPrefixMatchRank(meta.dbQualifiedLabel, meta.displayName, meta.objectName, routine.routineName || '')
|
||||
+ meta.dbQualifiedLabel,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 数据库提示
|
||||
const dbSuggestions = sharedVisibleDbs
|
||||
.filter((db) => startsWithPrefix(db))
|
||||
.map(db => ({
|
||||
const dbBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: sharedVisibleDbs,
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (db, normalizedPrefix) => rankQueryEditorCompletionCandidate(normalizedPrefix, [db], false),
|
||||
getSelectionKey: (db) => sortGroups.db + db,
|
||||
buildSuggestion: (db) => ({
|
||||
label: db,
|
||||
kind: monaco.languages.CompletionItemKind.Module,
|
||||
insertText: db,
|
||||
detail: translate('query_editor.object_info.database'),
|
||||
range,
|
||||
sortText: sortGroups.db + db,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
|
||||
// 关键字提示
|
||||
const keywordSuggestions = dialectKeywords
|
||||
.filter((k) => startsWithPrefix(k))
|
||||
.map(k => ({
|
||||
label: k,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: k,
|
||||
range,
|
||||
sortText: sortGroups.keyword + k,
|
||||
}));
|
||||
const keywordBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: dialectKeywords,
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (keyword, normalizedPrefix) => rankQueryEditorCompletionCandidate(normalizedPrefix, [keyword], false),
|
||||
getSelectionKey: (keyword) => sortGroups.keyword + keyword,
|
||||
buildSuggestion: (keyword) => ({
|
||||
label: keyword,
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: keyword,
|
||||
range,
|
||||
sortText: sortGroups.keyword + keyword,
|
||||
}),
|
||||
});
|
||||
|
||||
// 内置函数提示
|
||||
const funcSuggestions = dialectFunctions
|
||||
.filter((f) => startsWithPrefix(f.name))
|
||||
.map(f => ({
|
||||
label: f.name,
|
||||
const funcBatch = createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: dialectFunctions,
|
||||
prefix: wordPrefix,
|
||||
getMatchRank: (func, normalizedPrefix) => rankQueryEditorCompletionCandidate(normalizedPrefix, [func.name], false),
|
||||
getSelectionKey: (func) => sortGroups.func + func.name,
|
||||
buildSuggestion: (func) => ({
|
||||
label: func.name,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: f.name + '($0)',
|
||||
insertText: func.name + '($0)',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
detail: f.detail,
|
||||
detail: func.detail,
|
||||
range,
|
||||
sortText: sortGroups.func + f.name,
|
||||
}));
|
||||
sortText: sortGroups.func + func.name,
|
||||
}),
|
||||
});
|
||||
|
||||
const suggestions = isNewStatementKeywordContext
|
||||
? [
|
||||
...keywordSuggestions,
|
||||
...funcSuggestions,
|
||||
...tableSuggestions,
|
||||
...viewSuggestions,
|
||||
...synonymSuggestions,
|
||||
...dbSuggestions,
|
||||
...routineSuggestions,
|
||||
...relevantColumns,
|
||||
]
|
||||
: [
|
||||
...relevantColumns, // FROM 表的列最优先
|
||||
...tableSuggestions, // 表次之
|
||||
...viewSuggestions, // 视图和表同属可查询对象
|
||||
...synonymSuggestions, // 同义词也可直接作为查询对象
|
||||
...dbSuggestions, // 数据库
|
||||
...routineSuggestions, // 存储过程/函数
|
||||
...funcSuggestions, // 内置函数
|
||||
...keywordSuggestions // 关键字最后
|
||||
];
|
||||
const suggestions = materializeBoundedQueryEditorCompletionBatches([
|
||||
relevantColumnBatch,
|
||||
tableBatch,
|
||||
viewBatch,
|
||||
materializedViewBatch,
|
||||
synonymBatch,
|
||||
dbBatch,
|
||||
routineBatch,
|
||||
funcBatch,
|
||||
keywordBatch,
|
||||
], QUERY_EDITOR_COMPLETION_SUGGESTION_LIMIT);
|
||||
return { suggestions };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBoundedQueryEditorCompletionSuggestions,
|
||||
buildQueryEditorAliasMap,
|
||||
collectQueryEditorReferencedDatabaseNames,
|
||||
createBoundedQueryEditorCompletionCandidateBatch,
|
||||
findCompletionTablesByDatabase,
|
||||
getCompletionTableSchemaCounts,
|
||||
isOracleBaseTableReference,
|
||||
materializeBoundedQueryEditorCompletionBatches,
|
||||
rankQueryEditorCompletionCandidate,
|
||||
resolveOracleLikeDefaultSchemaName,
|
||||
resolveOracleLikeExecutionSchemaName,
|
||||
resolveOracleLikeLookupSchemaCandidates,
|
||||
@@ -13,6 +19,161 @@ import {
|
||||
shouldHandleQueryEditorRunShortcutFallback,
|
||||
} from './QueryEditorHelpers';
|
||||
|
||||
describe('QueryEditor completion candidate budget', () => {
|
||||
it('builds at most the budget after ranking exact, prefix, and substring matches', () => {
|
||||
const candidates = [
|
||||
...Array.from({ length: 5_000 }, (_, index) => `archive_entity_${String(index).padStart(4, '0')}`),
|
||||
'entity_primary',
|
||||
'entity',
|
||||
];
|
||||
let materialized = 0;
|
||||
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates,
|
||||
prefix: 'entity',
|
||||
getMatchRank: (candidate, prefix) => rankQueryEditorCompletionCandidate(prefix, [candidate]),
|
||||
getSelectionKey: (candidate, _prefix, rank) => `${rank}${candidate}`,
|
||||
buildSuggestion: (candidate) => {
|
||||
materialized += 1;
|
||||
const rank = rankQueryEditorCompletionCandidate('entity', [candidate]);
|
||||
return { label: candidate, sortText: `${rank}${candidate}` };
|
||||
},
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveLength(200);
|
||||
expect(materialized).toBe(200);
|
||||
expect(suggestions.slice(0, 2).map((item) => item.label)).toEqual(['entity', 'entity_primary']);
|
||||
});
|
||||
|
||||
it('stops scanning an empty-prefix source once the budget is full', () => {
|
||||
let inspected = 0;
|
||||
const candidates = Array.from({ length: 10_000 }, (_, index) => `table_${String(index).padStart(5, '0')}`);
|
||||
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates,
|
||||
prefix: '',
|
||||
getMatchRank: () => {
|
||||
inspected += 1;
|
||||
return 0;
|
||||
},
|
||||
getSelectionKey: (candidate) => candidate,
|
||||
buildSuggestion: (candidate) => ({ label: candidate }),
|
||||
sourceAlreadySortedBySelection: true,
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveLength(200);
|
||||
expect(inspected).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a late same-rank candidate when its final sort key is better', () => {
|
||||
const candidates = [
|
||||
...Array.from({ length: 200 }, (_, index) => ({
|
||||
label: `other_${index}`,
|
||||
sortText: `10${String(index).padStart(3, '0')}`,
|
||||
})),
|
||||
{ label: 'current_late', sortText: '00current_late' },
|
||||
];
|
||||
let materialized = 0;
|
||||
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates,
|
||||
prefix: '',
|
||||
getMatchRank: () => 0,
|
||||
getSelectionKey: (candidate) => candidate.sortText,
|
||||
buildSuggestion: (candidate) => {
|
||||
materialized += 1;
|
||||
return candidate;
|
||||
},
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveLength(200);
|
||||
expect(materialized).toBe(200);
|
||||
expect(suggestions.map((item) => item.label)).toContain('current_late');
|
||||
});
|
||||
|
||||
it('uses final sortText semantics when a late exact match ranks after current-database prefixes', () => {
|
||||
const candidates = [
|
||||
...Array.from({ length: 200 }, (_, index) => ({
|
||||
label: `current_prefix_${String(index).padStart(3, '0')}`,
|
||||
matchRank: 1 as const,
|
||||
sortText: `00current_${String(index).padStart(3, '0')}`,
|
||||
})),
|
||||
{ label: 'other_exact', matchRank: 0 as const, sortText: '01other_exact' },
|
||||
];
|
||||
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates,
|
||||
prefix: 'target',
|
||||
getMatchRank: (candidate) => candidate.matchRank,
|
||||
getSelectionKey: (candidate) => candidate.sortText,
|
||||
buildSuggestion: (candidate) => candidate,
|
||||
});
|
||||
|
||||
expect(suggestions).toHaveLength(200);
|
||||
expect(suggestions.map((item) => item.label)).not.toContain('other_exact');
|
||||
expect(suggestions[199]?.label).toBe('current_prefix_199');
|
||||
});
|
||||
|
||||
it('materializes at most one global budget across nine completion categories', () => {
|
||||
let materialized = 0;
|
||||
const batches = Array.from({ length: 9 }, (_, groupIndex) => (
|
||||
createBoundedQueryEditorCompletionCandidateBatch({
|
||||
candidates: Array.from({ length: 500 }, (_, candidateIndex) => ({
|
||||
label: `group_${groupIndex}_${candidateIndex}`,
|
||||
sortText: `${String(groupIndex).padStart(2, '0')}${String(candidateIndex).padStart(3, '0')}`,
|
||||
})),
|
||||
prefix: '',
|
||||
getMatchRank: () => 0,
|
||||
getSelectionKey: (candidate) => candidate.sortText,
|
||||
buildSuggestion: (candidate) => {
|
||||
materialized += 1;
|
||||
return candidate;
|
||||
},
|
||||
})
|
||||
));
|
||||
|
||||
expect(materialized).toBe(0);
|
||||
const suggestions = materializeBoundedQueryEditorCompletionBatches(batches);
|
||||
|
||||
expect(suggestions).toHaveLength(200);
|
||||
expect(materialized).toBe(200);
|
||||
expect(suggestions[0]?.label).toBe('group_0_0');
|
||||
expect(suggestions[199]?.label).toBe('group_0_199');
|
||||
});
|
||||
|
||||
it('caches schema counts on the current-database partition without reading other table names', () => {
|
||||
let currentTableNameReads = 0;
|
||||
let otherTableNameReads = 0;
|
||||
const currentTables = Array.from({ length: 3 }, (_, index) => ({
|
||||
dbName: 'main',
|
||||
get tableName() {
|
||||
currentTableNameReads += 1;
|
||||
return index < 2 ? `schema_${index}.users` : 'orders';
|
||||
},
|
||||
}));
|
||||
const otherTables = Array.from({ length: 5_000 }, (_, index) => ({
|
||||
dbName: 'archive',
|
||||
get tableName() {
|
||||
otherTableNameReads += 1;
|
||||
return `archive_${index}`;
|
||||
},
|
||||
}));
|
||||
const allTables = [...otherTables, ...currentTables];
|
||||
|
||||
const firstPartition = findCompletionTablesByDatabase(allTables, 'main');
|
||||
const firstCounts = getCompletionTableSchemaCounts(firstPartition);
|
||||
const secondPartition = findCompletionTablesByDatabase(allTables, 'main');
|
||||
const secondCounts = getCompletionTableSchemaCounts(secondPartition);
|
||||
|
||||
expect(firstPartition).toBe(secondPartition);
|
||||
expect(firstCounts).toBe(secondCounts);
|
||||
expect(firstCounts.get('users')).toBe(2);
|
||||
expect(firstCounts.get('orders')).toBe(1);
|
||||
expect(currentTableNameReads).toBe(3);
|
||||
expect(otherTableNameReads).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryEditor Monaco SQL grammar', () => {
|
||||
it.each([
|
||||
[{ config: { type: 'mysql' } }, 'mysql'],
|
||||
|
||||
@@ -28,6 +28,271 @@ export type CompletionRoutineMeta = {dbName: string, routineName: string, routin
|
||||
export type CompletionSequenceMeta = {dbName: string, sequenceName: string, schemaName?: string};
|
||||
export type CompletionPackageMeta = {dbName: string, packageName: string, schemaName?: string};
|
||||
|
||||
// Metadata refreshes replace the source array, so identity-keyed partitions stay correct and let
|
||||
// repeated completion requests avoid allocating/scanning another full-database filter result.
|
||||
const completionTablesByDatabaseCache = new WeakMap<CompletionTableMeta[], Map<string, CompletionTableMeta[]>>();
|
||||
|
||||
export const findCompletionTablesByDatabase = (
|
||||
tables: CompletionTableMeta[],
|
||||
dbName: string,
|
||||
): CompletionTableMeta[] => {
|
||||
let index = completionTablesByDatabaseCache.get(tables);
|
||||
if (!index) {
|
||||
index = new Map<string, CompletionTableMeta[]>();
|
||||
tables.forEach((table) => {
|
||||
const key = String(table.dbName || '').trim().toLowerCase();
|
||||
const matches = index!.get(key);
|
||||
if (matches) {
|
||||
matches.push(table);
|
||||
} else {
|
||||
index!.set(key, [table]);
|
||||
}
|
||||
});
|
||||
completionTablesByDatabaseCache.set(tables, index);
|
||||
}
|
||||
return index.get(String(dbName || '').trim().toLowerCase()) || [];
|
||||
};
|
||||
|
||||
export const QUERY_EDITOR_COMPLETION_SUGGESTION_LIMIT = 200;
|
||||
|
||||
export type QueryEditorCompletionMatchRank = 0 | 1 | 2 | null;
|
||||
|
||||
export const rankQueryEditorCompletionCandidate = (
|
||||
prefix: string,
|
||||
candidates: readonly string[],
|
||||
includeSubstring = true,
|
||||
): QueryEditorCompletionMatchRank => {
|
||||
const normalizedPrefix = String(prefix || '').trim().toLowerCase();
|
||||
if (!normalizedPrefix) return 0;
|
||||
|
||||
let hasPrefixMatch = false;
|
||||
let hasSubstringMatch = false;
|
||||
for (const candidate of candidates) {
|
||||
const normalizedCandidate = String(candidate || '').trim().toLowerCase();
|
||||
if (!normalizedCandidate) continue;
|
||||
if (normalizedCandidate === normalizedPrefix) return 0;
|
||||
if (normalizedCandidate.startsWith(normalizedPrefix)) {
|
||||
hasPrefixMatch = true;
|
||||
} else if (includeSubstring && normalizedCandidate.includes(normalizedPrefix)) {
|
||||
hasSubstringMatch = true;
|
||||
}
|
||||
}
|
||||
if (hasPrefixMatch) return 1;
|
||||
if (hasSubstringMatch) return 2;
|
||||
return null;
|
||||
};
|
||||
|
||||
type RankedQueryEditorCompletionCandidate<Candidate> = {
|
||||
candidate: Candidate;
|
||||
selectionKey: string;
|
||||
sourceIndex: number;
|
||||
};
|
||||
|
||||
export type QueryEditorCompletionCandidateBatch<Candidate, Suggestion> = {
|
||||
rankedCandidates: RankedQueryEditorCompletionCandidate<Candidate>[];
|
||||
buildSuggestion: (candidate: Candidate) => Suggestion;
|
||||
};
|
||||
|
||||
const compareRankedQueryEditorCompletionCandidates = <Candidate,>(
|
||||
left: RankedQueryEditorCompletionCandidate<Candidate>,
|
||||
right: RankedQueryEditorCompletionCandidate<Candidate>,
|
||||
): number => {
|
||||
if (left.selectionKey < right.selectionKey) return -1;
|
||||
if (left.selectionKey > right.selectionKey) return 1;
|
||||
return left.sourceIndex - right.sourceIndex;
|
||||
};
|
||||
|
||||
export const createBoundedQueryEditorCompletionCandidateBatch = <Candidate, Suggestion>({
|
||||
candidates,
|
||||
prefix,
|
||||
getMatchRank,
|
||||
getSelectionKey,
|
||||
buildSuggestion,
|
||||
limit = QUERY_EDITOR_COMPLETION_SUGGESTION_LIMIT,
|
||||
sourceAlreadySortedBySelection = false,
|
||||
}: {
|
||||
candidates: readonly Candidate[];
|
||||
prefix: string;
|
||||
getMatchRank: (candidate: Candidate, normalizedPrefix: string) => QueryEditorCompletionMatchRank;
|
||||
getSelectionKey: (
|
||||
candidate: Candidate,
|
||||
normalizedPrefix: string,
|
||||
matchRank: Exclude<QueryEditorCompletionMatchRank, null>,
|
||||
) => string;
|
||||
buildSuggestion: (candidate: Candidate) => Suggestion;
|
||||
limit?: number;
|
||||
sourceAlreadySortedBySelection?: boolean;
|
||||
}): QueryEditorCompletionCandidateBatch<Candidate, Suggestion> => {
|
||||
const normalizedLimit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (normalizedLimit === 0 || candidates.length === 0) {
|
||||
return { rankedCandidates: [], buildSuggestion };
|
||||
}
|
||||
|
||||
const normalizedPrefix = String(prefix || '').trim().toLowerCase();
|
||||
const compareCandidateToRanked = (
|
||||
selectionKey: string,
|
||||
sourceIndex: number,
|
||||
right: RankedQueryEditorCompletionCandidate<Candidate>,
|
||||
): number => {
|
||||
if (selectionKey < right.selectionKey) return -1;
|
||||
if (selectionKey > right.selectionKey) return 1;
|
||||
return sourceIndex - right.sourceIndex;
|
||||
};
|
||||
// Max-heap: the worst retained candidate stays at index 0 and can be replaced in O(log limit).
|
||||
const selectedHeap: RankedQueryEditorCompletionCandidate<Candidate>[] = [];
|
||||
const siftUpWorst = (startIndex: number) => {
|
||||
let childIndex = startIndex;
|
||||
while (childIndex > 0) {
|
||||
const parentIndex = Math.floor((childIndex - 1) / 2);
|
||||
if (compareRankedQueryEditorCompletionCandidates(selectedHeap[parentIndex], selectedHeap[childIndex]) >= 0) break;
|
||||
[selectedHeap[parentIndex], selectedHeap[childIndex]] = [selectedHeap[childIndex], selectedHeap[parentIndex]];
|
||||
childIndex = parentIndex;
|
||||
}
|
||||
};
|
||||
const siftDownWorst = (startIndex: number) => {
|
||||
let parentIndex = startIndex;
|
||||
while (true) {
|
||||
const leftIndex = parentIndex * 2 + 1;
|
||||
if (leftIndex >= selectedHeap.length) break;
|
||||
const rightIndex = leftIndex + 1;
|
||||
let worseChildIndex = leftIndex;
|
||||
if (
|
||||
rightIndex < selectedHeap.length
|
||||
&& compareRankedQueryEditorCompletionCandidates(selectedHeap[rightIndex], selectedHeap[leftIndex]) > 0
|
||||
) {
|
||||
worseChildIndex = rightIndex;
|
||||
}
|
||||
if (compareRankedQueryEditorCompletionCandidates(selectedHeap[parentIndex], selectedHeap[worseChildIndex]) >= 0) break;
|
||||
[selectedHeap[parentIndex], selectedHeap[worseChildIndex]] = [selectedHeap[worseChildIndex], selectedHeap[parentIndex]];
|
||||
parentIndex = worseChildIndex;
|
||||
}
|
||||
};
|
||||
|
||||
for (let sourceIndex = 0; sourceIndex < candidates.length; sourceIndex += 1) {
|
||||
const candidate = candidates[sourceIndex];
|
||||
const rank = getMatchRank(candidate, normalizedPrefix);
|
||||
if (rank === null) continue;
|
||||
const selectionKey = String(getSelectionKey(candidate, normalizedPrefix, rank) || '');
|
||||
if (selectedHeap.length < normalizedLimit) {
|
||||
selectedHeap.push({ candidate, selectionKey, sourceIndex });
|
||||
siftUpWorst(selectedHeap.length - 1);
|
||||
} else {
|
||||
const worst = selectedHeap[0];
|
||||
if (compareCandidateToRanked(selectionKey, sourceIndex, worst) < 0) {
|
||||
// Reuse the root entry so descending input cannot allocate one retained wrapper per source row.
|
||||
worst.candidate = candidate;
|
||||
worst.selectionKey = selectionKey;
|
||||
worst.sourceIndex = sourceIndex;
|
||||
siftDownWorst(0);
|
||||
}
|
||||
}
|
||||
// Early-stop is safe only when the caller guarantees the source already follows the same
|
||||
// final selection-key + stable-input-order tuple used by this bounded top-k.
|
||||
if (sourceAlreadySortedBySelection && selectedHeap.length >= normalizedLimit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectedHeap.sort(compareRankedQueryEditorCompletionCandidates);
|
||||
return { rankedCandidates: selectedHeap, buildSuggestion };
|
||||
};
|
||||
|
||||
export const materializeBoundedQueryEditorCompletionBatches = <Suggestion,>(
|
||||
batches: readonly QueryEditorCompletionCandidateBatch<any, Suggestion>[],
|
||||
limit = QUERY_EDITOR_COMPLETION_SUGGESTION_LIMIT,
|
||||
): Suggestion[] => {
|
||||
const normalizedLimit = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (normalizedLimit === 0 || batches.length === 0) return [];
|
||||
|
||||
type GlobalRankedCandidate = {
|
||||
batchIndex: number;
|
||||
candidateIndex: number;
|
||||
selectionKey: string;
|
||||
sourceIndex: number;
|
||||
};
|
||||
const compareGlobalCandidates = (left: GlobalRankedCandidate, right: GlobalRankedCandidate): number => {
|
||||
if (left.selectionKey < right.selectionKey) return -1;
|
||||
if (left.selectionKey > right.selectionKey) return 1;
|
||||
if (left.batchIndex !== right.batchIndex) return left.batchIndex - right.batchIndex;
|
||||
return left.sourceIndex - right.sourceIndex;
|
||||
};
|
||||
const compareCandidateToGlobal = (
|
||||
batchIndex: number,
|
||||
selectionKey: string,
|
||||
sourceIndex: number,
|
||||
right: GlobalRankedCandidate,
|
||||
): number => {
|
||||
if (selectionKey < right.selectionKey) return -1;
|
||||
if (selectionKey > right.selectionKey) return 1;
|
||||
if (batchIndex !== right.batchIndex) return batchIndex - right.batchIndex;
|
||||
return sourceIndex - right.sourceIndex;
|
||||
};
|
||||
const selectedHeap: GlobalRankedCandidate[] = [];
|
||||
const siftUpWorst = (startIndex: number) => {
|
||||
let childIndex = startIndex;
|
||||
while (childIndex > 0) {
|
||||
const parentIndex = Math.floor((childIndex - 1) / 2);
|
||||
if (compareGlobalCandidates(selectedHeap[parentIndex], selectedHeap[childIndex]) >= 0) break;
|
||||
[selectedHeap[parentIndex], selectedHeap[childIndex]] = [selectedHeap[childIndex], selectedHeap[parentIndex]];
|
||||
childIndex = parentIndex;
|
||||
}
|
||||
};
|
||||
const siftDownWorst = (startIndex: number) => {
|
||||
let parentIndex = startIndex;
|
||||
while (true) {
|
||||
const leftIndex = parentIndex * 2 + 1;
|
||||
if (leftIndex >= selectedHeap.length) break;
|
||||
const rightIndex = leftIndex + 1;
|
||||
let worseChildIndex = leftIndex;
|
||||
if (
|
||||
rightIndex < selectedHeap.length
|
||||
&& compareGlobalCandidates(selectedHeap[rightIndex], selectedHeap[leftIndex]) > 0
|
||||
) {
|
||||
worseChildIndex = rightIndex;
|
||||
}
|
||||
if (compareGlobalCandidates(selectedHeap[parentIndex], selectedHeap[worseChildIndex]) >= 0) break;
|
||||
[selectedHeap[parentIndex], selectedHeap[worseChildIndex]] = [selectedHeap[worseChildIndex], selectedHeap[parentIndex]];
|
||||
parentIndex = worseChildIndex;
|
||||
}
|
||||
};
|
||||
|
||||
batches.forEach((batch, batchIndex) => {
|
||||
batch.rankedCandidates.forEach((candidate, candidateIndex) => {
|
||||
if (selectedHeap.length < normalizedLimit) {
|
||||
selectedHeap.push({
|
||||
batchIndex,
|
||||
candidateIndex,
|
||||
selectionKey: candidate.selectionKey,
|
||||
sourceIndex: candidate.sourceIndex,
|
||||
});
|
||||
siftUpWorst(selectedHeap.length - 1);
|
||||
return;
|
||||
}
|
||||
const worst = selectedHeap[0];
|
||||
if (compareCandidateToGlobal(batchIndex, candidate.selectionKey, candidate.sourceIndex, worst) < 0) {
|
||||
worst.batchIndex = batchIndex;
|
||||
worst.candidateIndex = candidateIndex;
|
||||
worst.selectionKey = candidate.selectionKey;
|
||||
worst.sourceIndex = candidate.sourceIndex;
|
||||
siftDownWorst(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
selectedHeap.sort(compareGlobalCandidates);
|
||||
return selectedHeap.map(({ batchIndex, candidateIndex }) => {
|
||||
const batch = batches[batchIndex];
|
||||
return batch.buildSuggestion(batch.rankedCandidates[candidateIndex].candidate);
|
||||
});
|
||||
};
|
||||
|
||||
export const buildBoundedQueryEditorCompletionSuggestions = <Candidate, Suggestion>(
|
||||
options: Parameters<typeof createBoundedQueryEditorCompletionCandidateBatch<Candidate, Suggestion>>[0],
|
||||
): Suggestion[] => {
|
||||
const batch = createBoundedQueryEditorCompletionCandidateBatch(options);
|
||||
return materializeBoundedQueryEditorCompletionBatches([batch], options.limit);
|
||||
};
|
||||
|
||||
export const selectUnqualifiedCompletionSynonyms = (
|
||||
synonyms: CompletionSynonymMeta[],
|
||||
loginOwnerName: string,
|
||||
@@ -743,6 +1008,27 @@ export const splitCompletionSchemaAndTable = (qualified: string): { schema: stri
|
||||
return { schema: '', table: parts[0] || '' };
|
||||
};
|
||||
|
||||
// The caller passes the cached current-database partition. Schema duplicate detection therefore
|
||||
// reads only that partition once instead of walking every visible database on each keystroke.
|
||||
const completionTableSchemaCountCache = new WeakMap<CompletionTableMeta[], Map<string, number>>();
|
||||
|
||||
export const getCompletionTableSchemaCounts = (
|
||||
currentDatabaseTables: CompletionTableMeta[],
|
||||
): Map<string, number> => {
|
||||
const cached = completionTableSchemaCountCache.get(currentDatabaseTables);
|
||||
if (cached) return cached;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
currentDatabaseTables.forEach((table) => {
|
||||
const parsed = splitCompletionSchemaAndTable(table.tableName || '');
|
||||
const pureTable = String(parsed.table || table.tableName || '').toLowerCase();
|
||||
if (!pureTable) return;
|
||||
counts.set(pureTable, (counts.get(pureTable) || 0) + 1);
|
||||
});
|
||||
completionTableSchemaCountCache.set(currentDatabaseTables, counts);
|
||||
return counts;
|
||||
};
|
||||
|
||||
export const DEFAULT_QUERY_TEMPLATE = 'SELECT * FROM ';
|
||||
|
||||
export const resolveNewQueryDefaultTemplate = (
|
||||
|
||||
Reference in New Issue
Block a user