mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-14 02:24:19 +08:00
🐛 fix(query-editor): 修复 Oracle 同名同义词补全
- 合并 PR #654,并按 OWNER 与名称保留同义词元数据 - 无限定名优先登录用户,缺失时回退 PUBLIC,显式 schema 精确匹配 - 保持 GetTables 仅返回物理表,避免影响导出、ER 与 ROWID 判断 - 补充 owner 解析、PUBLIC 回退及物理表查询回归测试
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, { synonym: CompletionSynonymMeta; rank: number }>();
|
||||
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<string, { dbName: string; tableName: string }> => {
|
||||
const aliasMap: Record<string, { dbName: string; tableName: string }> = {};
|
||||
): Record<string, { dbName: string; tableName: string; explicitOwnerName?: string }> => {
|
||||
const aliasMap: Record<string, { dbName: string; tableName: string; explicitOwnerName?: string }> = {};
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user