diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 7e12284f..1f2371d2 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -2573,7 +2573,7 @@ describe('QueryEditor external SQL save', () => { }); }); - it('suggests Oracle private synonyms directly and resolves their columns through the synonym owner', async () => { + it('keeps same-name Oracle synonyms scoped by owner and resolves qualified columns', async () => { let renderer!: ReactTestRenderer; autoFetchState.visible = true; storeState.connections[0].config.type = 'oracle'; @@ -2582,20 +2582,37 @@ describe('QueryEditor external SQL save', () => { backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'A' }] }); backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] }); backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] }); - backendApp.DBGetColumns.mockImplementation(async (_config: any, dbName: string, tableName: string) => ({ - success: true, - data: dbName === 'B' && tableName === 'PERSON' - ? [ - { name: 'ID', type: 'NUMBER' }, - { name: 'NAME', type: 'VARCHAR2' }, - ] - : [], - })); + backendApp.DBGetColumns.mockImplementation(async (_config: any, dbName: string, tableName: string) => { + if (dbName === 'B' && tableName === 'PERSON') { + return { + success: true, + data: [ + { name: 'ID', type: 'NUMBER' }, + { name: 'NAME', type: 'VARCHAR2' }, + ], + }; + } + if (dbName === 'IMP_BASICINFO' && tableName === 'PERSON') { + return { + success: true, + data: [ + { name: 'AC01', type: 'VARCHAR2' }, + { name: 'AC02', type: 'VARCHAR2' }, + ], + }; + } + return { success: true, data: [] }; + }); backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { if (/ALL_SYNONYMS/i.test(sql)) { return { success: true, - data: [{ synonym_owner: 'B', synonym_name: 'PERSON', target_schema_name: 'A', target_name: 'PERSON' }], + data: [ + { synonym_owner: 'IMP_BASICINFO', synonym_name: 'PERSON', target_schema_name: 'IMP_DATA', target_name: 'PERSON' }, + { synonym_owner: 'IMP_BASICINFO', synonym_name: 'AC02', target_schema_name: 'IMP_DATA', target_name: 'AC02' }, + { synonym_owner: 'PUBLIC', synonym_name: 'PERSON', target_schema_name: 'PUBLIC_DATA', target_name: 'PERSON' }, + { synonym_owner: 'B', synonym_name: 'PERSON', target_schema_name: 'A', target_name: 'PERSON' }, + ], }; } return { success: true, data: [] }; @@ -2626,6 +2643,8 @@ describe('QueryEditor external SQL save', () => { detail: '同义词 (A.PERSON)', }), ])); + expect(synonymItems.suggestions.filter((item: any) => item.label === 'PERSON')).toHaveLength(1); + expect(synonymItems.suggestions.some((item: any) => item.label === 'AC02')).toBe(false); expect(backendApp.DBQuery).toHaveBeenCalledWith(expect.anything(), 'A', expect.stringMatching(/ALL_SYNONYMS/i)); editorState.value = 'SELECT p. FROM PERSON p'; @@ -2637,6 +2656,32 @@ describe('QueryEditor external SQL save', () => { expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'B', 'PERSON'); expect(columnItems.suggestions.map((item: any) => item.label)).toEqual(expect.arrayContaining(['ID', 'NAME'])); + editorState.value = 'SELECT * FROM IMP_BASICINFO.'; + editorState.latestOnChange?.(editorState.value); + const ownerItems = await sqlProvider.provideCompletionItems( + editorState.editor.getModel(), + { lineNumber: 1, column: editorState.value.length + 1 }, + ); + expect(ownerItems.suggestions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + label: 'PERSON', + detail: '同义词 (IMP_DATA.PERSON)', + }), + expect.objectContaining({ + label: 'AC02', + detail: '同义词 (IMP_DATA.AC02)', + }), + ])); + + editorState.value = 'SELECT p. FROM IMP_BASICINFO.PERSON p'; + editorState.latestOnChange?.(editorState.value); + const ownerColumnItems = await sqlProvider.provideCompletionItems( + editorState.editor.getModel(), + { lineNumber: 1, column: 'SELECT p.'.length + 1 }, + ); + expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'IMP_BASICINFO', 'PERSON'); + expect(ownerColumnItems.suggestions.map((item: any) => item.label)).toEqual(expect.arrayContaining(['AC01', 'AC02'])); + await act(async () => { renderer.unmount(); }); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 52e4e071..06853f36 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -163,6 +163,7 @@ import { resolveQueryEditorNavigationTarget, resolveQueryLocatorPlan, rewriteLeadingSelectTableReference, + selectUnqualifiedCompletionSynonyms, splitCompletionSchemaAndTable, splitQueryIdentifierPathSegments, stripCompletionIdentifierQuotes, @@ -2944,7 +2945,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc if (!targetName) return; const targetSchemaName = String(getCaseInsensitiveValue(row, ['target_schema_name', 'table_owner', 'target_owner']) || targetParts.schemaName || '').trim(); - const uniqueKey = synonymName.toLowerCase(); + const uniqueKey = [ownerName.toLowerCase(), synonymName.toLowerCase()].join('\u0000'); if (seenSynonyms.has(uniqueKey)) return; seenSynonyms.add(uniqueKey); allSynonyms.push({ @@ -4887,6 +4888,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc String(activeConnection?.config?.driver || ''), { oceanBaseProtocol: activeConnection?.config?.oceanBaseProtocol }, ); + const oracleLoginOwner = isOracleLikeDialect(activeDialect) + ? resolveOracleLikeDefaultSchemaName(activeConnection?.config) + : ''; const shouldQuoteCompletionIdentifiers = isPostgresSchemaDialect(activeDialect); const quoteCompletionPart = (ident: string) => { const raw = String(ident || '').trim(); @@ -5023,7 +5027,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc sortText, }; }; - const buildConnConfig = () => { const connId = sharedCurrentConnectionId; const conn = sharedConnections.find(c => c.id === connId); @@ -5138,7 +5141,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }; - const findCompletionSynonym = (dbName: string, tableIdent: string): CompletionSynonymMeta | undefined => { + const findCompletionSynonym = ( + tableIdent: string, + explicitOwnerName = '', + ): CompletionSynonymMeta | undefined => { const parsed = splitSchemaAndTable(tableIdent); const synonymName = String(parsed.table || tableIdent).trim().toLowerCase(); if (!synonymName) return undefined; @@ -5147,23 +5153,27 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc )); if (matches.length === 0) return undefined; - const explicitOwner = String(parsed.schema || '').trim().toLowerCase(); + const explicitOwner = String(explicitOwnerName || parsed.schema || '').trim().toLowerCase(); if (explicitOwner) { return matches.find((synonym) => String(synonym.ownerName || '').trim().toLowerCase() === explicitOwner); } - const currentOwner = String(dbName || '').trim().toLowerCase(); - return matches.find((synonym) => String(synonym.ownerName || '').trim().toLowerCase() === currentOwner) - || matches[0]; + const loginOwner = oracleLoginOwner.trim().toLowerCase(); + return matches.find((synonym) => String(synonym.ownerName || '').trim().toLowerCase() === loginOwner) + || matches.find((synonym) => String(synonym.ownerName || '').trim().toLowerCase() === 'public'); }; - const getCompletionColumnsByTable = async (dbName: string, tableIdent: string) => { + const getCompletionColumnsByTable = async ( + dbName: string, + tableIdent: string, + explicitOwnerName = '', + ) => { const connId = sharedCurrentConnectionId; const targetDb = String(dbName || '').trim(); const targetTable = String(tableIdent || '').trim(); if (!connId || !targetDb || !targetTable) return [] as CompletionColumnMeta[]; - const synonym = findCompletionSynonym(targetDb, targetTable); + const synonym = findCompletionSynonym(targetTable, explicitOwnerName); const lookupDbName = String(synonym?.ownerName || targetDb).trim(); const lookupTableName = String(synonym?.synonymName || targetTable).trim(); const preloaded = synonym ? [] : findPreloadedColumns(targetDb, targetTable); @@ -5216,7 +5226,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const tablePart = stripQuotes(threePartMatch[2]); const colPrefix = (threePartMatch[3] || '').toLowerCase(); - const cols = await getCompletionColumnsByTable(dbPart, tablePart); + const cols = await getCompletionColumnsByTable(dbPart, tablePart, dbPart); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } @@ -5396,7 +5406,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const tableInfo = aliasMap[qualifier.toLowerCase()]; if (tableInfo) { - const cols = await getCompletionColumnsByTable(tableInfo.dbName, tableInfo.tableName); + const cols = await getCompletionColumnsByTable( + tableInfo.dbName, + tableInfo.tableName, + tableInfo.explicitOwnerName, + ); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } @@ -5517,7 +5531,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc seenReferencedTables.add(key); const preloaded = findPreloadedColumns(tableInfo.dbName, tableInfo.tableName); if (preloaded.length > 0) continue; - const cols = await getCompletionColumnsByTable(tableInfo.dbName, tableInfo.tableName); + const cols = await getCompletionColumnsByTable( + tableInfo.dbName, + tableInfo.tableName, + tableInfo.explicitOwnerName, + ); if (isSqlCompletionRequestCancelled(token)) { return createEmptySqlCompletionResult(); } @@ -5653,7 +5671,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }); - const synonymSuggestions = sharedSynonymsData + const synonymSuggestions = selectUnqualifiedCompletionSynonyms(sharedSynonymsData, oracleLoginOwner) .filter((synonym) => includesWordPrefix(synonym.synonymName || '')) .map((synonym) => buildSynonymSuggestion( synonym, diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts index 4f11b7d6..234fac21 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + buildQueryEditorAliasMap, collectQueryEditorReferencedDatabaseNames, isOracleBaseTableReference, resolveOracleLikeDefaultSchemaName, resolveOracleLikeExecutionSchemaName, resolveOracleLikeLookupSchemaCandidates, resolveQueryEditorNavigationTarget, + selectUnqualifiedCompletionSynonyms, shouldHandleQueryEditorRunShortcutFallback, } from './QueryEditorHelpers'; @@ -70,9 +72,34 @@ describe('QueryEditorHelpers Oracle-like execution schema', () => { expect(isOracleBaseTableReference('SELECT * FROM person', 'B', baseTables)).toBe(false); expect(isOracleBaseTableReference('SELECT * FROM person_view', 'B', baseTables)).toBe(false); }); + + it('prefers login-owner synonyms, falls back to PUBLIC, and excludes other owners', () => { + const otherOwner = { ownerName: 'IMP_BASICINFO', synonymName: 'PERSON', targetName: 'OTHER_PERSON' }; + const publicOwner = { ownerName: 'PUBLIC', synonymName: 'PERSON', targetName: 'PUBLIC_PERSON' }; + const loginOwner = { ownerName: 'B', synonymName: 'PERSON', targetName: 'LOGIN_PERSON' }; + const otherOnly = { ownerName: 'IMP_BASICINFO', synonymName: 'AC02', targetName: 'AC02' }; + + expect(selectUnqualifiedCompletionSynonyms( + [otherOwner, publicOwner, otherOnly, loginOwner], + 'B', + )).toEqual([loginOwner]); + expect(selectUnqualifiedCompletionSynonyms([otherOwner, publicOwner, otherOnly], 'B')).toEqual([publicOwner]); + }); }); describe('QueryEditorHelpers qualified navigation (MySQL db.table + PG schema.table)', () => { + it('tracks an explicit two-part owner separately from the current database', () => { + const qualified = buildQueryEditorAliasMap('SELECT p.* FROM IMP_BASICINFO.PERSON p', 'A'); + expect(qualified.p).toEqual({ + dbName: 'IMP_BASICINFO', + tableName: 'PERSON', + explicitOwnerName: 'IMP_BASICINFO', + }); + + const unqualified = buildQueryEditorAliasMap('SELECT p.* FROM PERSON p', 'A'); + expect(unqualified.p).toEqual({ dbName: 'A', tableName: 'PERSON' }); + }); + it('collects cross-db names from SQL without requiring an empty visible list', () => { const sql = ` SELECT * FROM uk_back_corp; diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index ee0bc6e9..7a2635a6 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -28,6 +28,26 @@ export type CompletionRoutineMeta = {dbName: string, routineName: string, routin export type CompletionSequenceMeta = {dbName: string, sequenceName: string, schemaName?: string}; export type CompletionPackageMeta = {dbName: string, packageName: string, schemaName?: string}; +export const selectUnqualifiedCompletionSynonyms = ( + synonyms: CompletionSynonymMeta[], + loginOwnerName: string, +): CompletionSynonymMeta[] => { + const loginOwner = String(loginOwnerName || '').trim().toLowerCase(); + const preferred = new Map(); + synonyms.forEach((synonym) => { + const key = String(synonym.synonymName || '').trim().toLowerCase(); + const owner = String(synonym.ownerName || '').trim().toLowerCase(); + if (!key) return; + const rank = loginOwner && owner === loginOwner ? 0 : owner === 'public' ? 1 : 2; + if (rank > 1) return; + const current = preferred.get(key); + if (!current || rank < current.rank) { + preferred.set(key, { synonym, rank }); + } + }); + return Array.from(preferred.values(), ({ synonym }) => synonym); +}; + export const QUERY_LOCATOR_ALIAS_PREFIX = '__gonavi_locator_'; const QUERY_LOCATOR_METADATA_TIMEOUT_MS = 1500; const SQLSERVER_MESSAGE_PREFIX_RE = /^\s*mssql:/i; @@ -1612,8 +1632,8 @@ export const buildQueryEditorHoverMarkdown = (target: QueryEditorHoverTarget): s export const buildQueryEditorAliasMap = ( fullText: string, currentDb: string, -): Record => { - const aliasMap: Record = {}; +): Record => { + const aliasMap: Record = {}; const reserved = new Set([ 'where', 'on', 'group', 'order', 'limit', 'having', 'left', 'right', 'inner', 'outer', 'full', 'cross', 'join', @@ -1628,21 +1648,26 @@ export const buildQueryEditorAliasMap = ( const parts = tableIdent.split('.'); let dbName = currentDb || ''; let tableName = tableIdent; + let explicitOwnerName = ''; if (parts.length === 2) { dbName = parts[0]; tableName = parts[1]; + explicitOwnerName = parts[0]; } else if (parts.length >= 3) { dbName = parts[0]; tableName = parts.slice(1).join('.'); } const shortTable = getCompletionQualifiedNameLastPart(tableIdent); - if (shortTable) aliasMap[shortTable.toLowerCase()] = { dbName, tableName }; + const aliasTarget = explicitOwnerName + ? { dbName, tableName, explicitOwnerName } + : { dbName, tableName }; + if (shortTable) aliasMap[shortTable.toLowerCase()] = aliasTarget; const alias = stripCompletionIdentifierQuotes(match[2] || '').trim(); if (!alias) continue; const loweredAlias = alias.toLowerCase(); if (reserved.has(loweredAlias)) continue; - aliasMap[loweredAlias] = { dbName, tableName }; + aliasMap[loweredAlias] = aliasTarget; } return aliasMap; }; diff --git a/internal/db/oracle_get_tables_test.go b/internal/db/oracle_get_tables_test.go index c3fef0a7..acedbab3 100644 --- a/internal/db/oracle_get_tables_test.go +++ b/internal/db/oracle_get_tables_test.go @@ -32,6 +32,10 @@ func TestOracleGetTablesPrefixesOwnerForAllTablesQuery(t *testing.T) { if !reflect.DeepEqual(tables, want) { t.Fatalf("期望返回带 OWNER 前缀的表名 %v,实际 %v", want, tables) } + queries := state.snapshotQueries() + if len(queries) != 1 || strings.Contains(strings.ToLower(queries[0]), "all_synonyms") { + t.Fatalf("GetTables 应保持只返回物理表,实际查询: %v", queries) + } } func TestOracleGetTablesPrefixesCurrentUserForUserTablesQuery(t *testing.T) {