diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 0f6df697..90a468f3 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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(); + create(); }); - 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(); }); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index a594f5ee..856997b3 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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, diff --git a/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts b/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts index 4cd9019b..2bfaed64 100644 --- a/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts +++ b/frontend/src/components/queryEditor/QueryEditorAiAssist.test.ts @@ -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'); diff --git a/frontend/src/components/queryEditor/QueryEditorAiAssist.ts b/frontend/src/components/queryEditor/QueryEditorAiAssist.ts index 1c422603..a597ab3f 100644 --- a/frontend/src/components/queryEditor/QueryEditorAiAssist.ts +++ b/frontend/src/components/queryEditor/QueryEditorAiAssist.ts @@ -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}` );