From 5310ec7c445bfbf21039ca742f17e35d79071cf6 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Sun, 14 Jun 2026 15:54:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E4=B8=BA=20P?= =?UTF-8?q?ostgres=20=E5=85=BC=E5=AE=B9=E6=96=B9=E8=A8=80=E8=A1=A5?= =?UTF-8?q?=E5=85=A8=E5=A2=9E=E5=8A=A0=E6=A0=87=E8=AF=86=E7=AC=A6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SQL 编辑器补全中识别需要保留大小写的对象名 - 自动为大写表名和字段名插入双引号标识符 - 保持 MySQL 等其它方言现有补全行为不变 - 补充 QueryEditor 相关测试覆盖 Close #562 --- .../QueryEditor.external-sql-save.test.tsx | 102 ++++++++++++++++++ frontend/src/components/QueryEditor.tsx | 28 +++-- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 0834005..fc5b2b1 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -994,6 +994,108 @@ describe('QueryEditor external SQL save', () => { }); }); + it('quotes uppercase postgres table names in FROM completion insert text', async () => { + let renderer!: ReactTestRenderer; + autoFetchState.visible = true; + storeState.connections[0].config.type = 'postgres'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Table: 'public.MyTable' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + renderer = create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.')); + expect(sqlProvider).toBeTruthy(); + + editorState.value = 'SELECT * FROM My'; + editorState.latestOnChange?.(editorState.value); + const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 }); + const match = result.suggestions.find((item: any) => item.label === 'MyTable'); + + expect(match?.insertText).toBe('"MyTable"'); + + await act(async () => { + renderer.unmount(); + }); + }); + + it('quotes uppercase postgres table names after schema qualifiers in completion insert text', async () => { + let renderer!: ReactTestRenderer; + autoFetchState.visible = true; + storeState.connections[0].config.type = 'postgres'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Table: 'public.MyTable' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + + await act(async () => { + renderer = create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.')); + expect(sqlProvider).toBeTruthy(); + + editorState.value = 'SELECT * FROM public.'; + editorState.latestOnChange?.(editorState.value); + const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: editorState.value.length + 1 }); + const match = result.suggestions.find((item: any) => item.label === 'MyTable'); + + expect(match?.insertText).toBe('"MyTable"'); + + await act(async () => { + renderer.unmount(); + }); + }); + + it('quotes uppercase postgres column names in completion insert text', async () => { + let renderer!: ReactTestRenderer; + autoFetchState.visible = true; + storeState.connections[0].config.type = 'postgres'; + storeState.connections[0].config.database = 'main'; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'main' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [{ Table: 'public.MyTable' }] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ + success: true, + data: [{ tableName: 'public.MyTable', name: 'DisplayName', type: 'text' }], + }); + + await act(async () => { + renderer = create(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + const sqlProvider = editorState.providers.find((provider) => Array.isArray(provider.triggerCharacters) && provider.triggerCharacters.includes('.')); + expect(sqlProvider).toBeTruthy(); + + editorState.value = 'SELECT Dis FROM public."MyTable"'; + editorState.latestOnChange?.(editorState.value); + const result = await sqlProvider.provideCompletionItems(editorState.editor.getModel(), { lineNumber: 1, column: 'SELECT Dis'.length + 1 }); + const match = result.suggestions.find((item: any) => item.label === 'DisplayName'); + + expect(match?.insertText).toBe('"DisplayName"'); + + await act(async () => { + renderer.unmount(); + }); + }); + it('resolves database and table targets for ctrl/cmd navigation', () => { const tables = [ { dbName: 'main', tableName: 'users' }, diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 9bc6d1c..7bbd090 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -12,6 +12,7 @@ import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../uti import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts"; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { isPostgresSchemaDialect } from '../utils/connectionDriverType'; import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { @@ -20,7 +21,7 @@ import { resolveQueryResultPaginationTotal, } from '../utils/queryResultPagination'; import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable'; -import { quoteIdentPart } from '../utils/sql'; +import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; import { formatSqlExecutionError } from '../utils/sqlErrorSemantics'; import { shouldUseSqlEditorManagedTransaction } from '../utils/sqlEditorTransaction'; import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; @@ -3102,6 +3103,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc String(activeConnection?.config?.driver || ''), { oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol }, ); + const shouldQuoteCompletionIdentifiers = isPostgresSchemaDialect(activeDialect); + const quoteCompletionPart = (ident: string) => { + const raw = String(ident || '').trim(); + if (!raw) return raw; + return shouldQuoteCompletionIdentifiers ? quoteIdentPart(activeDialect, raw) : raw; + }; + const quoteCompletionPath = (ident: string) => { + const raw = String(ident || '').trim(); + if (!raw) return raw; + return shouldQuoteCompletionIdentifiers ? quoteQualifiedIdent(activeDialect, raw) : raw; + }; const dialectKeywords = resolveSqlKeywords(activeDialect); const dialectFunctions = resolveSqlFunctions(activeDialect); @@ -3208,7 +3220,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const suggestions = filtered.map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field, - insertText: c.name, + insertText: quoteCompletionPart(c.name), detail: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment), documentation: buildCompletionDocumentation(c.comment), range, @@ -3238,7 +3250,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const suggestions = filtered.map(t => ({ label: t.tableName, kind: monaco.languages.CompletionItemKind.Class, - insertText: t.tableName, + insertText: quoteCompletionPath(t.tableName), detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, @@ -3268,7 +3280,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const suggestions = filtered.map(t => ({ label: t.table, kind: monaco.languages.CompletionItemKind.Class, - insertText: t.table, + insertText: quoteCompletionPart(t.table), detail: appendCommentToDetail(`Table (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, @@ -3343,7 +3355,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const suggestions = filtered.map(c => ({ label: c.name, kind: monaco.languages.CompletionItemKind.Field, - insertText: c.name, + insertText: quoteCompletionPart(c.name), detail: appendCommentToDetail( c.type ? `${c.type} (${c.dbName ? c.dbName + '.' : ''}${c.tableName})` : (c.tableName ? `(${c.tableName})` : ''), c.comment, @@ -3427,7 +3439,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return { label: c.name, kind: monaco.languages.CompletionItemKind.Field, - insertText: c.name, + insertText: quoteCompletionPart(c.name), detail: appendCommentToDetail(`${c.type} (${c.dbName}.${c.tableName})`, c.comment), documentation: buildCompletionDocumentation(c.comment), range, @@ -3472,7 +3484,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return { label, kind: monaco.languages.CompletionItemKind.Class, - insertText: label, + insertText: quoteCompletionPath(label), detail: appendCommentToDetail(`Table (${t.dbName})`, t.comment), documentation: buildCompletionDocumentation(t.comment), range, @@ -3484,7 +3496,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const hasDuplicate = schemas.length > 1; // 同名表存在于多个 schema → 显示 schema.table;否则只显示纯表名 const label = hasDuplicate ? t.tableName : pureTable; - const insertText = hasDuplicate ? t.tableName : pureTable; + const insertText = quoteCompletionPath(hasDuplicate ? t.tableName : pureTable); const schemaInfo = parsed.schema ? ` (${parsed.schema})` : ''; return { label,