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}`
);