From 189cd11cb90ecc49521bfce9583c40ce1cb179fa Mon Sep 17 00:00:00 2001 From: kunghim Date: Mon, 3 Aug 2026 16:45:33 +0800 Subject: [PATCH] fix(query-editor): support substring completion matching --- frontend/src/components/QueryEditor.tsx | 100 +++++++++++------- .../queryEditor/QueryEditorHelpers.test.ts | 13 +++ .../queryEditor/QueryEditorHelpers.ts | 22 ++++ 3 files changed, 96 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index e59e6aba..a26c1bc4 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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' diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts index df8ec371..4404c511 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts @@ -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'], diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index aa64529e..4dbf8473 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -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; selectionKey: string;