🐛 fix(query-editor): 修复补全标识符大小写

This commit is contained in:
kunghim
2026-08-04 16:04:13 +08:00
parent 0202bf0203
commit 52c1cdfb89
4 changed files with 519 additions and 50 deletions

View File

@@ -1964,9 +1964,18 @@ describe('QueryEditor external SQL save', () => {
}
});
it('accepts the AI inline ghost with the default Tab shortcut and consumes the keydown', async () => {
it('accepts a metadata-normalized inline ghost with the default Tab shortcut and preserves trailing SQL', async () => {
vi.useFakeTimers();
try {
storeState.sqlLogs = [{
id: 'sql-log-inline-case',
timestamp: Date.now(),
sql: 'SELECT * FROM a_cninfo_announcement WHERE id = 1;',
status: 'success',
duration: 12,
dbName: 'main',
} as any];
const inlineAiService = {
AIGetProviders: vi.fn(async () => [{
id: 'openai-main',
@@ -1991,8 +2000,7 @@ describe('QueryEditor external SQL save', () => {
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [
{ TABLE_NAME: 'videos' },
{ TABLE_NAME: 'visits' },
{ TABLE_NAME: 'a_cninfo_announcement' },
],
});
@@ -2020,19 +2028,19 @@ describe('QueryEditor external SQL save', () => {
});
await act(async () => {
create(<QueryEditor tab={createTab({ query: 'SELECT', dbName: 'main' })} />);
create(<QueryEditor tab={createTab({ query: 'SELECT * FROM A_C', dbName: 'main' })} />);
});
editorState.value = 'SELECT';
editorState.position = { lineNumber: 1, column: 'SELECT'.length + 1 };
editorState.value = 'SELECT * FROM A_C';
editorState.position = { lineNumber: 1, column: 'SELECT * FROM A_C'.length + 1 };
editorState.editor.executeEdits.mockClear();
editorState.editor.trigger.mockClear();
editorState.domNode.appendChild.mockClear();
await act(async () => {
editorState.latestOnChange?.('SELECT');
editorState.latestOnChange?.('SELECT * FROM A_C');
editorState.modelContentListeners.forEach((listener) => listener({
changes: [{ text: 'T' }],
changes: [{ text: 'C' }],
}));
vi.advanceTimersByTime(220);
for (let i = 0; i < 8; i += 1) {
@@ -2040,6 +2048,11 @@ describe('QueryEditor external SQL save', () => {
}
});
const ghostOverlay = editorState.domNode.appendChild.mock.calls[
editorState.domNode.appendChild.mock.calls.length - 1
]?.[0];
expect(ghostOverlay?.textContent).toBe('ninfo_announcement WHERE id = 1;');
const shortcutEvent = {
type: 'keydown',
key: 'Tab',
@@ -2069,8 +2082,16 @@ describe('QueryEditor external SQL save', () => {
expect(editorState.editor.executeEdits).toHaveBeenCalledWith(
'gonavi-ai-inline-sql-completion',
[expect.objectContaining({ text: expect.any(String) })],
[expect.objectContaining({
text: 'a_cninfo_announcement WHERE id = 1;',
range: expect.objectContaining({
startColumn: 15,
endColumn: 18,
}),
})],
);
expect(editorState.value).toBe('SELECT * FROM a_cninfo_announcement WHERE id = 1;');
expect(inlineAiService.AIChatSend).not.toHaveBeenCalled();
expect(monacoShortcutEvent.preventDefault).toHaveBeenCalled();
expect(monacoShortcutEvent.stopPropagation).toHaveBeenCalled();
expect(shortcutEvent.preventDefault).toHaveBeenCalled();
@@ -3447,6 +3468,7 @@ describe('QueryEditor external SQL save', () => {
success: true,
data: [
{ Tables_in_main: 'users' },
{ Tables_in_main: 'a_cninfo_announcement' },
{ Tables_in_main: 'hrmresource' },
{ Tables_in_main: 'hrm_resource_export_template' },
{ Tables_in_main: 'archive_hrmresource' },
@@ -3457,6 +3479,7 @@ describe('QueryEditor external SQL save', () => {
data: [
{ tableName: 'hrmresource', name: 'hrmresult', type: 'varchar(32)' },
{ tableName: 'users', name: 'hrmresult_from_users', type: 'varchar(32)' },
{ tableName: 'users', name: 'SHORT_TITLE', type: 'varchar(255)' },
],
});
@@ -3494,6 +3517,24 @@ describe('QueryEditor external SQL save', () => {
expect(commaLabels).not.toContain('hrmresult_from_users');
expect(backendApp.DBGetColumns.mock.calls.map((call: any[]) => call[2])).not.toContain('hrmres');
editorState.value = 'SELECT * FROM A_C';
editorState.latestOnChange?.(editorState.value);
const uppercaseTableResult = await sqlProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 1, column: editorState.value.length + 1 },
);
const uppercaseTable = uppercaseTableResult.suggestions.find((item: any) => item.label === 'a_cninfo_announcement');
expect(uppercaseTable?.insertText).toBe('a_cninfo_announcement');
editorState.value = 'SELECT * FROM users WHERE sh';
editorState.latestOnChange?.(editorState.value);
const lowercaseColumnResult = await sqlProvider.provideCompletionItems(
editorState.editor.getModel(),
{ lineNumber: 1, column: editorState.value.length + 1 },
);
const lowercaseColumn = lowercaseColumnResult.suggestions.find((item: any) => item.label === 'SHORT_TITLE');
expect(lowercaseColumn?.insertText).toBe('short_title');
await act(async () => {
renderer.unmount();
});

View File

@@ -213,6 +213,7 @@ import {
shouldHandleQueryEditorRunShortcutFallback,
} from './queryEditor/QueryEditorHelpers';
import {
applyQueryEditorCompletionFragmentCase,
buildQueryEditorAiInlineSuggestOptions,
getQueryEditorAiService,
requestQueryEditorInlineCompletion,
@@ -221,6 +222,7 @@ import {
resolveInlineSqlGhostPreviewText,
resolveQueryEditorInlineMemoryInsertText,
resolveQueryEditorInlineCompletionIntentDetails,
resolveQueryEditorInlineCompletionEdit,
resolveQueryEditorInlineLocalCompletion,
resolveQueryEditorInlineRuntimeReadiness,
shouldTriggerQueryEditorInlineObjectSuggestFallback,
@@ -228,6 +230,7 @@ import {
type QueryEditorAiApplyMode,
type QueryEditorAiContext,
type QueryEditorAiEditorSnapshot,
type QueryEditorInlineCompletionEdit,
} from './queryEditor/QueryEditorAiAssist';
export {
collectQueryEditorObjectDecorationCandidates,
@@ -1559,6 +1562,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const triggerSqlAiCompletionFallbackApplyingRef = useRef(false);
const aiInlineGhostRef = useRef<{
insertText: string;
editText: string;
replacePrefixLength: number;
modelUri: string;
position: { lineNumber: number; column: number };
snapshot: QueryEditorAiEditorSnapshot;
@@ -4418,7 +4423,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return false;
}
const changes = Array.isArray(event?.changes) ? event.changes : [];
return changes.some((change: any) => String(change?.text ?? '') === ghost.insertText);
return changes.some((change: any) => {
const changedText = String(change?.text ?? '');
return changedText === ghost.insertText || changedText === ghost.editText;
});
};
const buildInlineGhostEditorSnapshot = (model: any, position: { lineNumber: number; column: number }): QueryEditorAiEditorSnapshot => {
@@ -4527,8 +4535,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
position: { lineNumber: number; column: number },
insertText: string,
snapshot: QueryEditorAiEditorSnapshot,
edit?: QueryEditorInlineCompletionEdit,
) => {
const previewText = resolveInlineSqlGhostPreviewText(insertText);
const resolvedEdit = edit || {
previewText: insertText,
editText: insertText,
replacePrefixLength: 0,
};
const previewText = resolveInlineSqlGhostPreviewText(resolvedEdit.previewText);
if (!previewText) {
clearAiInlineGhost(false);
return;
@@ -4536,7 +4550,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const modelUri = String(model?.uri?.toString?.() || '');
aiInlineGhostRef.current = {
insertText,
insertText: resolvedEdit.previewText,
editText: resolvedEdit.editText,
replacePrefixLength: resolvedEdit.replacePrefixLength,
modelUri,
position,
snapshot,
@@ -4592,23 +4608,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
aiInlineGhostAcceptingRef.current = true;
try {
editor.pushUndoStop?.();
const replacePrefixLength = Math.max(
0,
Math.min(ghost.replacePrefixLength, Math.max(0, position.column - 1)),
);
const editStartPosition = {
lineNumber: position.lineNumber,
column: position.column - replacePrefixLength,
};
const startOffset = typeof model.getOffsetAt === 'function'
? Number(model.getOffsetAt(position))
? Number(model.getOffsetAt(editStartPosition))
: Number.NaN;
editor.executeEdits?.('gonavi-ai-inline-sql-completion', [{
range: new monaco.Range(
position.lineNumber,
position.column,
editStartPosition.lineNumber,
editStartPosition.column,
position.lineNumber,
position.column,
),
text: ghost.insertText,
text: ghost.editText,
forceMoveMarkers: true,
}]);
editor.pushUndoStop?.();
syncQueryDraft(String(editor.getValue?.() ?? model.getValue?.() ?? ''));
if (Number.isFinite(startOffset) && typeof model.getPositionAt === 'function') {
const nextPosition = normalizeEditorPosition(model.getPositionAt(startOffset + ghost.insertText.length));
const nextPosition = normalizeEditorPosition(model.getPositionAt(startOffset + ghost.editText.length));
if (nextPosition) {
editor.setPosition?.(nextPosition);
}
@@ -4652,20 +4676,26 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
const intent = resolveQueryEditorInlineCompletionIntentDetails(editorSnapshot);
const shouldUseInlineMemory = manualTrigger || intent.intent !== 'general_sql';
let memoryInsertText = '';
if (shouldUseInlineMemory) {
const memoryInsertText = resolveQueryEditorInlineMemoryInsertText({
const initialAiContext = buildQueryEditorAiContext();
memoryInsertText = resolveQueryEditorInlineMemoryInsertText({
editorSnapshot,
memoryEntries: inlineSqlMemoryEntries,
sourceType: initialAiContext.sourceType,
});
if (memoryInsertText.trim()) {
renderAiInlineGhost(model, position, memoryInsertText, editorSnapshot);
// Empty fragments do not need metadata-based case correction and retain
// the previous immediate memory-completion behavior.
if (memoryInsertText.trim() && !intent.fragment) {
const memoryEdit = resolveQueryEditorInlineCompletionEdit({
aiContext: initialAiContext,
editorSnapshot,
insertText: memoryInsertText,
});
renderAiInlineGhost(model, position, memoryEdit.previewText, editorSnapshot, memoryEdit);
return;
}
}
if (!shouldRequestQueryEditorInlineCompletion(editorSnapshot)) {
return;
}
const requestId = ++aiInlineGhostRequestSeqRef.current;
const runRequest = () => {
if (aiInlineGhostTimerRef.current !== null) {
@@ -4679,6 +4709,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
try {
if (shouldUseInlineMemory) {
if (!memoryInsertText.trim()) {
const initialAiContext = buildQueryEditorAiContext();
memoryInsertText = resolveQueryEditorInlineMemoryInsertText({
editorSnapshot,
memoryEntries: inlineSqlMemoryEntries,
sourceType: initialAiContext.sourceType,
});
}
if (memoryInsertText.trim()) {
if (
(intent.intent === 'table_name' || intent.intent === 'column_name')
&& intent.fragment
) {
await ensureQueryEditorAiContextMetadata(editorSnapshot);
if (
requestId !== aiInlineGhostRequestSeqRef.current
|| editorRef.current !== editor
) {
return;
}
}
const aiContext = buildQueryEditorAiContext();
const memoryEdit = resolveQueryEditorInlineCompletionEdit({
aiContext,
editorSnapshot,
insertText: memoryInsertText,
});
renderAiInlineGhost(model, position, memoryEdit.previewText, editorSnapshot, memoryEdit);
return;
}
}
if (!shouldRequestQueryEditorInlineCompletion(editorSnapshot)) {
return;
}
const aiContext = buildQueryEditorAiContext();
const localCompletion = resolveQueryEditorInlineLocalCompletion({
aiContext,
@@ -4687,7 +4752,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
if (localCompletion.handled) {
if (localCompletion.insertText.trim()) {
renderAiInlineGhost(model, position, localCompletion.insertText, editorSnapshot);
const localEdit = resolveQueryEditorInlineCompletionEdit({
aiContext,
editorSnapshot,
insertText: localCompletion.insertText,
});
renderAiInlineGhost(model, position, localEdit.previewText, editorSnapshot, localEdit);
}
return;
}
@@ -4735,7 +4805,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
return;
}
renderAiInlineGhost(model, position, insertText, editorSnapshot);
const inlineEdit = resolveQueryEditorInlineCompletionEdit({
aiContext: buildQueryEditorAiContext(),
editorSnapshot,
insertText,
});
renderAiInlineGhost(model, position, inlineEdit.previewText, editorSnapshot, inlineEdit);
} catch (error) {
console.warn('GoNavi AI inline SQL ghost failed', error);
}
@@ -4803,7 +4878,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
clearAiInlineGhost();
return;
}
renderAiInlineGhost(model, ghost.position, ghost.insertText, ghost.snapshot);
renderAiInlineGhost(model, ghost.position, ghost.insertText, ghost.snapshot, {
previewText: ghost.insertText,
editText: ghost.editText,
replacePrefixLength: ghost.replacePrefixLength,
});
};
const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => {
@@ -5710,6 +5789,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (!raw) return raw;
return shouldQuoteCompletionIdentifiers ? quoteQualifiedIdent(activeDialect, raw) : raw;
};
const applyCompletionFragmentCase = (ident: string, fragment: string) => (
shouldQuoteCompletionIdentifiers
? ident
: applyQueryEditorCompletionFragmentCase(ident, fragment)
);
const getActiveCompletionDbName = () => String(sharedCurrentDb || currentDbRef.current || currentDb || tab.dbName || '').trim();
const dialectKeywords = resolveSqlKeywords(activeDialect);
const dialectFunctions = resolveSqlFunctions(activeDialect);
@@ -5725,14 +5809,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
&& !!parsed.table
&& parsed.schema.toLowerCase() === rawDbName.toLowerCase();
const displayName = schemaMatchesDb ? parsed.table : rawTableName;
const insertName = schemaMatchesDb ? parsed.table : rawTableName;
const insertText = schemaMatchesDb
? quoteCompletionPart(parsed.table)
: quoteCompletionPath(rawTableName);
? quoteCompletionPart(insertName)
: quoteCompletionPath(insertName);
const dbQualifiedLabel = rawDbName
? `${rawDbName}.${displayName || rawTableName}`
: (displayName || rawTableName);
return {
displayName: displayName || rawTableName,
insertName,
insertText,
dbQualifiedLabel,
};
@@ -6072,7 +6158,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (threePartMatch) {
const dbPart = stripQuotes(threePartMatch[1]);
const tablePart = stripQuotes(threePartMatch[2]);
const colPrefix = (threePartMatch[3] || '').toLowerCase();
const rawColPrefix = String(threePartMatch[3] || '');
const colPrefix = rawColPrefix.toLowerCase();
const cols = await getCompletionColumnsByTable(dbPart, tablePart, dbPart);
if (isSqlCompletionRequestCancelled(token)) {
@@ -6087,7 +6174,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
buildSuggestion: (column) => ({
label: column.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: quoteCompletionPart(column.name),
insertText: quoteCompletionPart(applyCompletionFragmentCase(column.name, rawColPrefix)),
detail: buildColumnCompletionDetail(column),
documentation: buildColumnCompletionDocumentation(column),
filterText: resolveQueryEditorCompletionFilterText(colPrefix, [column.name]) || column.name,
@@ -6102,7 +6189,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const qualifierMatch = linePrefix.match(QUERY_EDITOR_SQL_QUALIFIER_COMPLETION_REGEX);
if (qualifierMatch) {
const qualifier = stripQuotes(qualifierMatch[1]);
const prefix = (qualifierMatch[2] || '').toLowerCase();
const rawPrefix = String(qualifierMatch[2] || '');
const prefix = rawPrefix.toLowerCase();
const qualifierLower = qualifier.toLowerCase();
// 首先检查 qualifier 是否是数据库名(跨库表提示)
@@ -6139,7 +6227,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
[meta.displayName, table.tableName],
),
kind: monaco.languages.CompletionItemKind.Class,
insertText: meta.insertText,
insertText: quoteCompletionPath(applyCompletionFragmentCase(meta.insertName, rawPrefix)),
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${table.dbName})`, table.comment),
documentation: buildCompletionDocumentation(table.comment),
range,
@@ -6251,7 +6339,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
[parsed.table, table.tableName],
),
kind: monaco.languages.CompletionItemKind.Class,
insertText: quoteCompletionPart(parsed.table),
insertText: quoteCompletionPart(applyCompletionFragmentCase(parsed.table, rawPrefix)),
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${table.dbName}${parsed.schema ? '.' + parsed.schema : ''})`, table.comment),
documentation: buildCompletionDocumentation(table.comment),
range,
@@ -6355,7 +6443,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
buildSuggestion: (column) => ({
label: column.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: quoteCompletionPart(column.name),
insertText: quoteCompletionPart(applyCompletionFragmentCase(column.name, rawPrefix)),
detail: buildColumnCompletionDetail(column),
documentation: buildColumnCompletionDocumentation(column),
filterText: resolveQueryEditorCompletionFilterText(prefix, [column.name]) || column.name,
@@ -6379,7 +6467,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const currentDatabase = getActiveCompletionDbName();
const isCurrentCompletionDatabase = (dbName: string) =>
String(dbName || '').toLowerCase() === currentDatabase.toLowerCase();
const wordPrefix = (word.word || '').toLowerCase();
const rawWordPrefix = String(word.word || '');
const wordPrefix = rawWordPrefix.toLowerCase();
const getPrefixMatchRank = (...candidates: string[]) => {
if (!wordPrefix) return '0';
const matchRank = rankQueryEditorCompletionCandidate(wordPrefix, candidates);
@@ -6502,7 +6591,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return {
label: column.name,
kind: monaco.languages.CompletionItemKind.Field,
insertText: quoteCompletionPart(column.name),
insertText: quoteCompletionPart(applyCompletionFragmentCase(column.name, rawWordPrefix)),
detail: buildColumnCompletionDetail(column),
documentation: buildColumnCompletionDocumentation(column),
filterText: resolveQueryEditorCompletionFilterText(wordPrefix, [column.name]) || column.name,
@@ -6563,7 +6652,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
[label, table.tableName || '', pureTable],
),
kind: monaco.languages.CompletionItemKind.Class,
insertText: quoteCompletionPath(label),
insertText: quoteCompletionPath(applyCompletionFragmentCase(label, rawWordPrefix)),
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${table.dbName})`, table.comment),
documentation: buildCompletionDocumentation(table.comment),
range,
@@ -6582,7 +6671,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
[label, table.tableName || '', pureTable],
),
kind: monaco.languages.CompletionItemKind.Class,
insertText: quoteCompletionPath(hasDuplicate ? table.tableName : pureTable),
insertText: quoteCompletionPath(applyCompletionFragmentCase(
hasDuplicate ? table.tableName : pureTable,
rawWordPrefix,
)),
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')}${schemaInfo}`, table.comment),
documentation: buildCompletionDocumentation(table.comment),
range,

View File

@@ -12,6 +12,7 @@ import {
resolveInlineSqlInsertText,
resolveQueryEditorAiRuntimeReadiness,
resolveQueryEditorInlineMemoryInsertText,
resolveQueryEditorInlineCompletionEdit,
resolveQueryEditorInlineCompletionModel,
resolveQueryEditorInlineCompletionIntentDetails,
sanitizeSqlAssistantResponse,
@@ -173,6 +174,117 @@ describe('QueryEditorAiAssist', () => {
})).toBe(' videos SET status = 1 WHERE id = ?;');
});
it('inherits the typed table fragment case without changing the remaining remembered SQL', () => {
expect(resolveQueryEditorInlineMemoryInsertText({
editorSnapshot: {
prefix: 'SELECT * FROM A_C',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM A_C',
currentLineAfterCursor: '',
},
memoryEntries: [
{ sql: "SELECT * FROM a_cninfo_announcement where short_title like 'about%'" },
],
})).toBe("ninfo_announcement where short_title like 'about%'");
expect(resolveQueryEditorInlineMemoryInsertText({
editorSnapshot: {
prefix: 'SELECT * FROM a_c',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM a_c',
currentLineAfterCursor: '',
},
memoryEntries: [
{ sql: 'SELECT * FROM A_CNINFO_ANNOUNCEMENT WHERE SHORT_TITLE IS NOT NULL' },
],
})).toBe('ninfo_announcement WHERE SHORT_TITLE IS NOT NULL');
});
it('uses the metadata identifier when accepting case-mismatched table completion', () => {
const aiContext = {
sourceType: 'mysql',
currentDb: 'main',
tables: [{ dbName: 'main', tableName: 'a_cninfo_announcement' }],
columns: [],
};
expect(resolveQueryEditorInlineCompletionEdit({
aiContext,
editorSnapshot: {
prefix: 'SELECT * FROM A_C',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM A_C',
currentLineAfterCursor: '',
},
insertText: "ninfo_announcement where short_title like 'about%'",
})).toEqual({
previewText: "ninfo_announcement where short_title like 'about%'",
editText: "a_cninfo_announcement where short_title like 'about%'",
replacePrefixLength: 3,
});
expect(resolveQueryEditorInlineCompletionEdit({
aiContext: {
...aiContext,
tables: [{ dbName: 'main', tableName: 'TABLE' }],
},
editorSnapshot: {
prefix: 'SELECT * FROM ta',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM ta',
currentLineAfterCursor: '',
},
insertText: 'ble where id = 1',
})).toEqual({
previewText: 'ble where id = 1',
editText: 'table where id = 1',
replacePrefixLength: 2,
});
expect(resolveQueryEditorInlineCompletionEdit({
aiContext: {
...aiContext,
currentDb: 'main',
tables: [{ dbName: 'analytics', tableName: 'a_cninfo_announcement' }],
},
editorSnapshot: {
prefix: 'SELECT * FROM analytics.A_C',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM analytics.A_C',
currentLineAfterCursor: '',
},
insertText: 'ninfo_announcement',
})).toEqual({
previewText: 'ninfo_announcement',
editText: 'analytics.a_cninfo_announcement',
replacePrefixLength: 'analytics.A_C'.length,
});
});
it('preserves exact-case inline identifiers for PostgreSQL-family dialects', () => {
const postgresContext = {
sourceType: 'postgres',
currentDb: 'main',
tables: [{ dbName: 'main', tableName: 'TABLE' }],
columns: [],
};
expect(resolveQueryEditorInlineCompletionEdit({
aiContext: postgresContext,
editorSnapshot: {
prefix: 'SELECT * FROM ta',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM ta',
currentLineAfterCursor: '',
},
insertText: 'ble',
})).toEqual({
previewText: 'BLE',
editText: 'TABLE',
replacePrefixLength: 2,
});
});
it('sanitizes fenced SQL and removes duplicated typed prefixes', () => {
expect(sanitizeSqlAssistantResponse('```sql\nselect * from users;\n```')).toBe('select * from users;');
expect(sanitizeSqlAssistantResponse('SQL: select count(*) from orders;')).toBe('select count(*) from orders;');
@@ -613,6 +725,54 @@ describe('QueryEditorAiAssist', () => {
expect(service.AIGetActiveProvider).not.toHaveBeenCalled();
});
it('inherits the typed fragment case for deterministic table-name completion', async () => {
const service = readyService('TABLE');
const buildRequest = (fragment: string) => ({
service,
aiContext: {
connectionName: 'Local Dameng',
sourceType: 'dameng',
currentDb: 'APP',
tables: [{ dbName: 'APP', tableName: 'TABLE' }],
columns: [],
},
editorSnapshot: {
prefix: `SELECT * FROM ${fragment}`,
suffix: '',
currentLineBeforeCursor: `SELECT * FROM ${fragment}`,
currentLineAfterCursor: '',
},
});
await expect(requestQueryEditorInlineCompletion(buildRequest('ta'))).resolves.toBe('ble');
await expect(requestQueryEditorInlineCompletion(buildRequest('TA'))).resolves.toBe('BLE');
expect(service.AIChatSend).not.toHaveBeenCalled();
});
it('inherits the typed fragment case for deterministic column-name completion', async () => {
const service = readyService('SHORT_TITLE');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local Dameng',
sourceType: 'dameng',
currentDb: 'APP',
tables: [{ dbName: 'APP', tableName: 'VIDEOS' }],
columns: [{ dbName: 'APP', tableName: 'VIDEOS', name: 'SHORT_TITLE', type: 'varchar' }],
},
editorSnapshot: {
prefix: 'SELECT v.sh FROM VIDEOS v WHERE v.sh',
suffix: '',
currentLineBeforeCursor: 'SELECT v.sh FROM VIDEOS v WHERE v.sh',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('ort_title');
expect(service.AIChatSend).not.toHaveBeenCalled();
});
it('uses deterministic schema metadata for alter-table inline completion and skips AI', async () => {
const service = readyService('ALTER TABLE orders ADD COLUMN status INT;');
@@ -667,6 +827,33 @@ describe('QueryEditorAiAssist', () => {
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
});
it('inherits the typed fragment case for grounded AI table-name completion', async () => {
const service = readyService('TABLE');
const insertText = await requestQueryEditorInlineCompletion({
service,
aiContext: {
connectionName: 'Local Dameng',
sourceType: 'dameng',
currentDb: 'APP',
tables: [
{ dbName: 'APP', tableName: 'TABLE' },
{ dbName: 'APP', tableName: 'TARGET' },
],
columns: [],
},
editorSnapshot: {
prefix: 'SELECT * FROM ta',
suffix: '',
currentLineBeforeCursor: 'SELECT * FROM ta',
currentLineAfterCursor: '',
},
});
expect(insertText).toBe('ble');
expect(service.AIChatSend).toHaveBeenCalledTimes(1);
});
it('rejects ungrounded AI table-name inline completion when the suggestion is outside schema metadata', async () => {
const service = readyService('Japgolly');

View File

@@ -9,6 +9,7 @@ import type {
import {
buildQueryEditorAliasMap,
} from './QueryEditorHelpers';
import { isPostgresSchemaDialect } from '../../utils/connectionDriverType';
export type QueryEditorAiApplyMode = 'insert' | 'replaceSelection' | 'replaceAll';
@@ -279,12 +280,53 @@ const normalizeInlineMemoryMatchText = (sql: string): string => (
.toLowerCase()
);
export const applyQueryEditorCompletionFragmentCase = (
candidate: string,
fragment: string,
preserveCandidateCase = false,
): string => {
if (preserveCandidateCase) {
return candidate;
}
const activeFragment = String(fragment || '').trim().split('.').pop() || '';
const fragmentCharacters = activeFragment.replace(/[^A-Za-z]/g, '');
const candidateParts = String(candidate || '').split('.');
const candidateLastPart = candidateParts.pop() || '';
const candidateCharacters = candidateLastPart.replace(/[^A-Za-z]/g, '');
if (!fragmentCharacters || !candidateCharacters) {
return candidate;
}
// Lowercase input may intentionally target an uppercase metadata name. Do not
// uppercase a lowercase metadata name: case-sensitive MySQL deployments would fail.
if (
fragmentCharacters === fragmentCharacters.toLowerCase()
&& candidateCharacters === candidateCharacters.toUpperCase()
) {
return [...candidateParts, candidateLastPart.toLowerCase()].join('.');
}
return candidate;
};
const applyInlineMemoryObjectCase = (
insertText: string,
fragment: string,
preserveCandidateCase = false,
): string => {
const identifierSuffix = String(insertText || '').match(/^[A-Za-z0-9_$]+/)?.[0] || '';
if (!identifierSuffix) {
return insertText;
}
return `${applyQueryEditorCompletionFragmentCase(identifierSuffix, fragment, preserveCandidateCase)}${insertText.slice(identifierSuffix.length)}`;
};
export const resolveQueryEditorInlineMemoryInsertText = ({
editorSnapshot,
memoryEntries,
sourceType,
}: {
editorSnapshot: QueryEditorAiEditorSnapshot;
memoryEntries: QueryEditorInlineMemoryEntry[];
sourceType?: string;
}): string => {
if (!shouldAllowQueryEditorInlineMemoryCompletion(editorSnapshot)) {
return '';
@@ -300,7 +342,13 @@ export const resolveQueryEditorInlineMemoryInsertText = ({
if (normalizedStatementPrefix && !normalizeInlineMemoryMatchText(candidateSql).startsWith(normalizedStatementPrefix)) {
continue;
}
return limitInlineInsertText(resolveInlineSqlInsertText(candidateSql, editorSnapshot.prefix));
const insertText = resolveInlineSqlInsertText(candidateSql, editorSnapshot.prefix);
const intent = resolveQueryEditorInlineCompletionIntentDetails(editorSnapshot);
return limitInlineInsertText(
intent.intent === 'table_name' || intent.intent === 'column_name'
? applyInlineMemoryObjectCase(insertText, intent.fragment, isPostgresSchemaDialect(sourceType || ''))
: insertText,
);
}
return '';
};
@@ -1201,15 +1249,22 @@ const filterInlineTableMatches = (
): CompletionTableMeta[] => {
const normalizedFragment = normalizeInlineIdentifierPath(fragment).toLowerCase();
const useQualifiedName = normalizedFragment.includes('.');
const matched = collectCurrentDatabaseTables(tables, currentDb).filter((table) => {
const sourceTables = useQualifiedName ? tables : collectCurrentDatabaseTables(tables, currentDb);
const matched = sourceTables.filter((table) => {
if (!normalizedFragment) {
return true;
}
const normalizedTableName = normalizeInlineIdentifierPath(table.tableName || '');
const candidate = useQualifiedName
? normalizedTableName
: getInlineIdentifierLastPart(normalizedTableName);
return candidate.toLowerCase().startsWith(normalizedFragment);
const normalizedDbName = normalizeInlineIdentifierPath(table.dbName || '');
const candidatePaths = useQualifiedName
? [
normalizedTableName,
normalizedDbName && !normalizedTableName.toLowerCase().startsWith(`${normalizedDbName.toLowerCase()}.`)
? `${normalizedDbName}.${normalizedTableName}`
: '',
].filter(Boolean)
: [getInlineIdentifierLastPart(normalizedTableName)];
return candidatePaths.some((candidate) => candidate.toLowerCase().startsWith(normalizedFragment));
});
return matched.slice(0, MAX_INLINE_SCHEMA_TABLES);
};
@@ -1258,6 +1313,7 @@ const resolveInlineColumnOwnerReference = (
const resolveUniqueCompletionCandidateInsertText = (
candidates: string[],
fragment: string,
preserveCandidateCase = false,
): string => {
const dedupedCandidates = Array.from(new Map(
candidates
@@ -1283,7 +1339,8 @@ const resolveUniqueCompletionCandidateInsertText = (
if (prefixMatches.length !== 1) {
return '';
}
return prefixMatches[0].slice(normalizedFragment.length);
return applyQueryEditorCompletionFragmentCase(prefixMatches[0], normalizedFragment, preserveCandidateCase)
.slice(normalizedFragment.length);
};
const collectInlineTableCandidateLabels = (
@@ -1295,7 +1352,19 @@ const collectInlineTableCandidateLabels = (
return filterInlineTableMatches(context.tables || [], currentDb, fragment)
.map((table) => {
const normalizedTableName = normalizeInlineIdentifierPath(table.tableName || '');
return useQualifiedName ? normalizedTableName : getInlineIdentifierLastPart(normalizedTableName);
if (!useQualifiedName) {
return getInlineIdentifierLastPart(normalizedTableName);
}
const normalizedDbName = normalizeInlineIdentifierPath(table.dbName || '');
const candidatePaths = [
normalizedTableName,
normalizedDbName && !normalizedTableName.toLowerCase().startsWith(`${normalizedDbName.toLowerCase()}.`)
? `${normalizedDbName}.${normalizedTableName}`
: '',
].filter(Boolean);
return candidatePaths.find((candidate) => candidate.toLowerCase().startsWith(normalizeInlineIdentifierPath(fragment).toLowerCase()))
|| candidatePaths[0]
|| '';
})
.filter(Boolean);
};
@@ -1322,6 +1391,7 @@ const resolveValidatedInlineObjectCandidateInsertText = ({
prefix,
normalizer,
safePattern,
preserveCandidateCase = false,
}: {
candidateLabels: string[];
fragment: string;
@@ -1329,6 +1399,7 @@ const resolveValidatedInlineObjectCandidateInsertText = ({
prefix: string;
normalizer: (value: string) => string;
safePattern: RegExp;
preserveCandidateCase?: boolean;
}): string => {
const trimmedInsertText = String(insertText || '').trim();
if (!trimmedInsertText || !safePattern.test(trimmedInsertText)) {
@@ -1354,7 +1425,10 @@ const resolveValidatedInlineObjectCandidateInsertText = ({
return '';
}
return resolveInlineSqlInsertText(matchedCandidate, prefix);
return resolveInlineSqlInsertText(
applyQueryEditorCompletionFragmentCase(matchedCandidate, fragment, preserveCandidateCase),
prefix,
);
};
const shouldAllowInlineObjectAiFallback = (
@@ -1389,7 +1463,11 @@ const resolveDeterministicInlineTableInsertText = (
fragment: string,
): string => {
const candidateLabels = collectInlineTableCandidateLabels(context, fragment);
return resolveUniqueCompletionCandidateInsertText(candidateLabels, normalizeInlineIdentifierPath(fragment));
return resolveUniqueCompletionCandidateInsertText(
candidateLabels,
normalizeInlineIdentifierPath(fragment),
isPostgresSchemaDialect(context.sourceType || ''),
);
};
const resolveDeterministicInlineColumnInsertText = (
@@ -1399,7 +1477,11 @@ const resolveDeterministicInlineColumnInsertText = (
fragment: string,
): string => {
const candidateLabels = collectInlineColumnCandidateLabels(context, editorSnapshot, qualifier);
return resolveUniqueCompletionCandidateInsertText(candidateLabels, stripInlineIdentifierQuotes(fragment || '').trim());
return resolveUniqueCompletionCandidateInsertText(
candidateLabels,
stripInlineIdentifierQuotes(fragment || '').trim(),
isPostgresSchemaDialect(context.sourceType || ''),
);
};
const resolveValidatedInlineTableAiInsertText = (
@@ -1414,6 +1496,7 @@ const resolveValidatedInlineTableAiInsertText = (
prefix: editorSnapshot.prefix,
normalizer: normalizeInlineIdentifierPath,
safePattern: INLINE_TABLE_FRAGMENT_SAFE_RE,
preserveCandidateCase: isPostgresSchemaDialect(context.sourceType || ''),
});
const resolveValidatedInlineColumnAiInsertText = (
@@ -1429,6 +1512,7 @@ const resolveValidatedInlineColumnAiInsertText = (
prefix: editorSnapshot.prefix,
normalizer: stripInlineIdentifierQuotes,
safePattern: INLINE_COLUMN_FRAGMENT_SAFE_RE,
preserveCandidateCase: isPostgresSchemaDialect(context.sourceType || ''),
});
const shouldAllowInlineTableAiFallback = (
@@ -1480,6 +1564,71 @@ const resolveDeterministicInlineSchemaCompletion = (
};
};
export interface QueryEditorInlineCompletionEdit {
previewText: string;
editText: string;
replacePrefixLength: number;
}
export const resolveQueryEditorInlineCompletionEdit = ({
aiContext,
editorSnapshot,
insertText,
}: {
aiContext: QueryEditorAiContext;
editorSnapshot: QueryEditorAiEditorSnapshot;
insertText: string;
}): QueryEditorInlineCompletionEdit => {
const fallback: QueryEditorInlineCompletionEdit = {
previewText: insertText,
editText: insertText,
replacePrefixLength: 0,
};
const intent = resolveQueryEditorInlineCompletionIntentDetails(editorSnapshot);
if ((intent.intent !== 'table_name' && intent.intent !== 'column_name') || !intent.fragment) {
return fallback;
}
const candidateLabels = intent.intent === 'table_name'
? collectInlineTableCandidateLabels(aiContext, intent.fragment)
: collectInlineColumnCandidateLabels(aiContext, editorSnapshot, intent.qualifier);
const normalizer = intent.intent === 'table_name'
? normalizeInlineIdentifierPath
: stripInlineIdentifierQuotes;
const token = String(insertText || '').match(/^[A-Za-z0-9_$]+/)?.[0] || '';
if (!token) {
return fallback;
}
const normalizedDirectSuggestion = normalizer(token).toLowerCase();
const normalizedCombinedSuggestion = normalizer(`${intent.fragment}${token}`).toLowerCase();
const matchedCandidate = Array.from(new Map(
candidateLabels
.map((candidate) => String(candidate || '').trim())
.filter(Boolean)
.map((candidate) => [normalizer(candidate).toLowerCase(), candidate] as const),
).values()).find((candidate) => {
const normalizedCandidate = normalizer(candidate).toLowerCase();
return normalizedCandidate === normalizedDirectSuggestion
|| normalizedCandidate === normalizedCombinedSuggestion;
});
if (!matchedCandidate) {
return fallback;
}
const canonicalIdentifier = applyQueryEditorCompletionFragmentCase(
matchedCandidate,
intent.fragment,
isPostgresSchemaDialect(aiContext.sourceType || ''),
);
const remainder = String(insertText).slice(token.length);
const editText = `${canonicalIdentifier}${remainder}`;
return {
previewText: editText.slice(intent.fragment.length),
editText,
replacePrefixLength: intent.fragment.length,
};
};
const buildKeywordSuffixInsertText = (statementPrefix: string, suffix: string): string => (
/\s$/.test(statementPrefix) ? suffix : ` ${suffix}`
);