mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-07 07:03:34 +08:00
fix(query-editor): support substring completion matching
This commit is contained in:
@@ -193,6 +193,7 @@ import {
|
||||
resolveOracleLikeExecutionSchemaName,
|
||||
resolveOracleLikeLookupSchemaCandidates,
|
||||
resolveQueryEditorFormatterLanguage,
|
||||
resolveQueryEditorCompletionFilterText,
|
||||
resolveQueryEditorMonacoLanguage,
|
||||
resolveQueryEditorHoverTarget,
|
||||
resolveQueryEditorNavigationDecorations,
|
||||
@@ -5723,13 +5724,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
dbQualifiedLabel,
|
||||
};
|
||||
};
|
||||
const buildTableSuggestion = (label: string, prefix: string, comment?: string) => ({
|
||||
const buildTableSuggestion = (
|
||||
label: string,
|
||||
detailPrefix: string,
|
||||
comment?: string,
|
||||
filterPrefix = '',
|
||||
filterCandidates: readonly string[] = [label],
|
||||
) => ({
|
||||
label: buildQueryEditorTableSuggestionLabel(
|
||||
label,
|
||||
appendCommentToDetail(prefix, comment),
|
||||
appendCommentToDetail(detailPrefix, comment),
|
||||
useStructuredCompletionLabel,
|
||||
),
|
||||
filterText: normalizeQueryEditorTableSuggestionText(label),
|
||||
filterText: resolveQueryEditorCompletionFilterText(filterPrefix, filterCandidates)
|
||||
|| normalizeQueryEditorTableSuggestionText(label),
|
||||
});
|
||||
const normalizeRoutineType = (routineType: string) => (
|
||||
String(routineType || '').trim().toUpperCase().includes('PROC') ? 'PROCEDURE' : 'FUNCTION'
|
||||
@@ -6061,16 +6069,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates: cols,
|
||||
prefix: colPrefix,
|
||||
getMatchRank: (column, prefix) => rankQueryEditorCompletionCandidate(prefix, [column.name], false),
|
||||
getSelectionKey: (column) => '0' + column.name,
|
||||
getMatchRank: (column, prefix) => rankQueryEditorCompletionCandidate(prefix, [column.name]),
|
||||
getSelectionKey: (column, _prefix, matchRank) => `0${matchRank}${column.name}`,
|
||||
buildSuggestion: (column) => ({
|
||||
label: column.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: quoteCompletionPart(column.name),
|
||||
detail: buildColumnCompletionDetail(column),
|
||||
documentation: buildColumnCompletionDocumentation(column),
|
||||
filterText: resolveQueryEditorCompletionFilterText(colPrefix, [column.name]) || column.name,
|
||||
range,
|
||||
sortText: '0' + column.name,
|
||||
sortText: `0${rankQueryEditorCompletionCandidate(colPrefix, [column.name]) ?? 9}${column.name}`,
|
||||
}),
|
||||
});
|
||||
return { suggestions };
|
||||
@@ -6100,11 +6109,11 @@ 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], false);
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.displayName, table.tableName]);
|
||||
},
|
||||
getSelectionKey: (table) => {
|
||||
getSelectionKey: (table, _prefix, matchRank) => {
|
||||
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || qualifier, table.tableName || '');
|
||||
return '0' + meta.displayName;
|
||||
return `0${matchRank}${meta.displayName}`;
|
||||
},
|
||||
buildSuggestion: (table) => {
|
||||
const meta = buildDbQualifiedTableSuggestionMeta(table.dbName || qualifier, table.tableName || '');
|
||||
@@ -6113,13 +6122,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
meta.displayName,
|
||||
`${translate('query_editor.object_info.table')} (${table.dbName})`,
|
||||
table.comment,
|
||||
prefix,
|
||||
[meta.displayName, table.tableName],
|
||||
),
|
||||
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,
|
||||
sortText: `0${rankQueryEditorCompletionCandidate(prefix, [meta.displayName, table.tableName]) ?? 9}${meta.displayName}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -6133,10 +6144,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
return rankQueryEditorCompletionCandidate(
|
||||
normalizedPrefix,
|
||||
[meta.displayName, meta.objectName, view.viewName],
|
||||
false,
|
||||
);
|
||||
},
|
||||
getSelectionKey: (view) => '05' + buildViewSuggestionMeta(view).displayName,
|
||||
getSelectionKey: (view, _prefix, matchRank) => `05${matchRank}${buildViewSuggestionMeta(view).displayName}`,
|
||||
buildSuggestion: (view) => {
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
return {
|
||||
@@ -6144,8 +6154,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(meta.displayName),
|
||||
detail: `${getViewTypeLabel(materialized)} (${view.dbName})`,
|
||||
filterText: resolveQueryEditorCompletionFilterText(prefix, [meta.displayName, meta.objectName, view.viewName])
|
||||
|| meta.displayName,
|
||||
range,
|
||||
sortText: '05' + meta.displayName,
|
||||
sortText: `05${rankQueryEditorCompletionCandidate(prefix, [meta.displayName, meta.objectName, view.viewName]) ?? 9}${meta.displayName}`,
|
||||
};
|
||||
},
|
||||
})
|
||||
@@ -6210,9 +6222,9 @@ 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], false);
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [parsed.table]);
|
||||
},
|
||||
getSelectionKey: (table) => '0' + splitSchemaAndTable(table.tableName || '').table,
|
||||
getSelectionKey: (table, _prefix, matchRank) => `0${matchRank}${splitSchemaAndTable(table.tableName || '').table}`,
|
||||
buildSuggestion: (table) => {
|
||||
const parsed = splitSchemaAndTable(table.tableName || '');
|
||||
return {
|
||||
@@ -6220,13 +6232,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
parsed.table,
|
||||
`${translate('query_editor.object_info.table')} (${table.dbName}${parsed.schema ? '.' + parsed.schema : ''})`,
|
||||
table.comment,
|
||||
prefix,
|
||||
[parsed.table, table.tableName],
|
||||
),
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
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' + parsed.table,
|
||||
sortText: `0${rankQueryEditorCompletionCandidate(prefix, [parsed.table, table.tableName]) ?? 9}${parsed.table}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -6239,9 +6253,9 @@ 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], false);
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [meta.objectName]);
|
||||
},
|
||||
getSelectionKey: (view) => '05' + buildViewSuggestionMeta(view).objectName,
|
||||
getSelectionKey: (view, _prefix, matchRank) => `05${matchRank}${buildViewSuggestionMeta(view).objectName}`,
|
||||
buildSuggestion: (view) => {
|
||||
const meta = buildViewSuggestionMeta(view);
|
||||
return {
|
||||
@@ -6249,8 +6263,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPart(meta.objectName),
|
||||
detail: `${getViewTypeLabel(materialized)} (${getViewSuggestionScope(view, meta)})`,
|
||||
filterText: resolveQueryEditorCompletionFilterText(prefix, [meta.objectName, view.viewName])
|
||||
|| meta.objectName,
|
||||
range,
|
||||
sortText: '05' + meta.objectName,
|
||||
sortText: `05${rankQueryEditorCompletionCandidate(prefix, [meta.objectName]) ?? 9}${meta.objectName}`,
|
||||
};
|
||||
},
|
||||
})
|
||||
@@ -6319,16 +6335,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const suggestions = buildBoundedQueryEditorCompletionSuggestions({
|
||||
candidates: cols,
|
||||
prefix,
|
||||
getMatchRank: (column, normalizedPrefix) => rankQueryEditorCompletionCandidate(normalizedPrefix, [column.name], false),
|
||||
getSelectionKey: (column) => '0' + column.name,
|
||||
getMatchRank: (column, normalizedPrefix) => rankQueryEditorCompletionCandidate(normalizedPrefix, [column.name]),
|
||||
getSelectionKey: (column, _prefix, matchRank) => `0${matchRank}${column.name}`,
|
||||
buildSuggestion: (column) => ({
|
||||
label: column.name,
|
||||
kind: monaco.languages.CompletionItemKind.Field,
|
||||
insertText: quoteCompletionPart(column.name),
|
||||
detail: buildColumnCompletionDetail(column),
|
||||
documentation: buildColumnCompletionDocumentation(column),
|
||||
filterText: resolveQueryEditorCompletionFilterText(prefix, [column.name]) || column.name,
|
||||
range,
|
||||
sortText: '0' + column.name,
|
||||
sortText: `0${rankQueryEditorCompletionCandidate(prefix, [column.name]) ?? 9}${column.name}`,
|
||||
}),
|
||||
});
|
||||
return { suggestions };
|
||||
@@ -6353,12 +6370,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const wordPrefix = (word.word || '').toLowerCase();
|
||||
const getPrefixMatchRank = (...candidates: string[]) => {
|
||||
if (!wordPrefix) return '0';
|
||||
const normalized = candidates
|
||||
.map((candidate) => String(candidate || '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
if (normalized.some((candidate) => candidate.startsWith(wordPrefix))) return '0';
|
||||
if (normalized.some((candidate) => candidate.includes(wordPrefix))) return '1';
|
||||
return '9';
|
||||
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 expectsRoutineName = /\bCALL\s+[`"]?[\w.]*$/i.test(linePrefix);
|
||||
@@ -6462,12 +6475,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
if (!foundTables.has(fullIdent) && !foundTables.has(shortIdent) && (!pureIdent || !foundTables.has(pureIdent))) {
|
||||
return null;
|
||||
}
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [column.name], false);
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [column.name]);
|
||||
},
|
||||
getSelectionKey: (column) => (
|
||||
isCurrentCompletionDatabase(column.dbName || '')
|
||||
? sortGroups.columnCurrent + column.name
|
||||
: sortGroups.columnOther + column.name
|
||||
getSelectionKey: (column, _prefix, matchRank) => (
|
||||
(isCurrentCompletionDatabase(column.dbName || '')
|
||||
? sortGroups.columnCurrent
|
||||
: sortGroups.columnOther)
|
||||
+ matchRank
|
||||
+ column.name
|
||||
),
|
||||
buildSuggestion: (column) => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(column.dbName || '');
|
||||
@@ -6477,8 +6492,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
insertText: quoteCompletionPart(column.name),
|
||||
detail: buildColumnCompletionDetail(column),
|
||||
documentation: buildColumnCompletionDocumentation(column),
|
||||
filterText: resolveQueryEditorCompletionFilterText(wordPrefix, [column.name]) || column.name,
|
||||
range,
|
||||
sortText: isCurrentDb ? sortGroups.columnCurrent + column.name : sortGroups.columnOther + column.name,
|
||||
sortText: `${isCurrentDb ? sortGroups.columnCurrent : sortGroups.columnOther}${rankQueryEditorCompletionCandidate(wordPrefix, [column.name]) ?? 9}${column.name}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -6506,16 +6522,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
return rankQueryEditorCompletionCandidate(normalizedPrefix, [table.tableName, pureTable]);
|
||||
},
|
||||
getSelectionKey: (table) => {
|
||||
getSelectionKey: (table, _prefix, matchRank) => {
|
||||
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.tableOther + matchRank + label;
|
||||
}
|
||||
return sortGroups.tableCurrent + getPrefixMatchRank(table.tableName || '', pureTable) + pureTable;
|
||||
return sortGroups.tableCurrent + matchRank + pureTable;
|
||||
},
|
||||
buildSuggestion: (table) => {
|
||||
const isCurrentDb = isCurrentCompletionDatabase(table.dbName || '');
|
||||
@@ -6529,6 +6545,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
label,
|
||||
`${translate('query_editor.object_info.table')} (${table.dbName})`,
|
||||
table.comment,
|
||||
wordPrefix,
|
||||
[label, table.tableName || '', pureTable],
|
||||
),
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(label),
|
||||
@@ -6546,6 +6564,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
label,
|
||||
`${translate('query_editor.object_info.table')}${schemaInfo}`,
|
||||
table.comment,
|
||||
wordPrefix,
|
||||
[label, table.tableName || '', pureTable],
|
||||
),
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(hasDuplicate ? table.tableName : pureTable),
|
||||
@@ -6569,13 +6589,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
[meta.dbQualifiedLabel, meta.displayName, meta.objectName, view.viewName],
|
||||
);
|
||||
},
|
||||
getSelectionKey: (view) => {
|
||||
getSelectionKey: (view, _prefix, matchRank) => {
|
||||
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 || '')
|
||||
+ matchRank
|
||||
+ label;
|
||||
},
|
||||
buildSuggestion: (view) => {
|
||||
@@ -6587,6 +6607,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: meta.insertText,
|
||||
detail: `${getViewTypeLabel(materialized)} (${getViewSuggestionScope(view, meta)})`,
|
||||
filterText: resolveQueryEditorCompletionFilterText(wordPrefix, [meta.dbQualifiedLabel, meta.displayName, meta.objectName, view.viewName])
|
||||
|| label,
|
||||
range,
|
||||
sortText: (isCurrentDb ? sortGroups.tableCurrent : sortGroups.tableOther)
|
||||
+ '1'
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
isOracleBaseTableReference,
|
||||
materializeBoundedQueryEditorCompletionBatches,
|
||||
rankQueryEditorCompletionCandidate,
|
||||
resolveQueryEditorCompletionFilterText,
|
||||
resolveOracleLikeDefaultSchemaName,
|
||||
resolveOracleLikeExecutionSchemaName,
|
||||
resolveOracleLikeLookupSchemaCandidates,
|
||||
@@ -194,6 +195,18 @@ describe('QueryEditor completion candidate budget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryEditor completion filter text', () => {
|
||||
it('returns the matching suffix for Monaco substring filtering', () => {
|
||||
expect(resolveQueryEditorCompletionFilterText('title', ['short_title'])).toBe('title');
|
||||
expect(resolveQueryEditorCompletionFilterText('title', ['subtitle'])).toBe('title');
|
||||
});
|
||||
|
||||
it('does not override the filter text for an empty prefix or a non-match', () => {
|
||||
expect(resolveQueryEditorCompletionFilterText('', ['short_title'])).toBeUndefined();
|
||||
expect(resolveQueryEditorCompletionFilterText('name', ['short_title'])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryEditor Monaco SQL grammar', () => {
|
||||
it.each([
|
||||
[{ config: { type: 'mysql' } }, 'mysql'],
|
||||
|
||||
@@ -83,6 +83,28 @@ export const rankQueryEditorCompletionCandidate = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Monaco applies its own fuzzy filter after the provider returns. When a
|
||||
* candidate is matched only by a substring, expose the matching suffix so
|
||||
* Monaco can keep the item visible even when the match does not start at a
|
||||
* word boundary (for example `title` in `subtitle`).
|
||||
*/
|
||||
export const resolveQueryEditorCompletionFilterText = (
|
||||
prefix: string,
|
||||
candidates: readonly string[],
|
||||
): string | undefined => {
|
||||
const normalizedPrefix = String(prefix || '').trim().toLowerCase();
|
||||
if (!normalizedPrefix) return undefined;
|
||||
for (const candidate of candidates) {
|
||||
const value = String(candidate || '').trim();
|
||||
const matchIndex = value.toLowerCase().indexOf(normalizedPrefix);
|
||||
if (matchIndex >= 0) {
|
||||
return value.slice(matchIndex);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type RankedQueryEditorCompletionCandidate<Candidate> = {
|
||||
candidate: Candidate;
|
||||
selectionKey: string;
|
||||
|
||||
Reference in New Issue
Block a user