🐛 fix(query-editor): 修复外部 SQL 标签状态与 OceanBase 查询改写

- 修复外部 SQL 文件删除后标签残留及关闭异常
- 修复 OceanBase Oracle 查询注入隐藏 ROWID 时的表名改写
- 修复小写表名执行时的精确引用并保留日志中的原始 SQL
- 补充查询编辑器相关回归测试
This commit is contained in:
Syngnat
2026-06-17 09:49:39 +08:00
parent 0632c5242c
commit 7ff3e00759
3 changed files with 345 additions and 6 deletions

View File

@@ -3258,6 +3258,100 @@ describe('QueryEditor external SQL save', () => {
renderer?.unmount();
});
it('rewrites OceanBase Oracle SELECT * queries before injecting hidden ROWID locator columns', async () => {
storeState.connections[0].config.type = 'oceanbase';
(storeState.connections[0].config as any).oceanBaseProtocol = 'oracle';
storeState.connections[0].config.user = 'dev';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['WAFER_ID', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ WAFER_ID: 'R015Z10F08', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'WAFER_ID', key: '' }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: 'SELECT * FROM EDC_LOG' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'DEV', 'EDC_LOG');
expect(executedSql).toContain('FROM EDC_LOG gonavi_query_source');
expect(executedSql).toMatch(/SELECT\s+gonavi_query_source\.\*\s*,\s+gonavi_query_source\.ROWID\s+AS\s+"__gonavi_oracle_rowid__"/i);
expect(dataGridState.latestProps?.tableName).toBe('DEV.EDC_LOG');
expect(dataGridState.latestProps?.editLocator).toMatchObject({
strategy: 'oracle-rowid',
columns: ['ROWID'],
valueColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
hiddenColumns: [ORACLE_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
expect(dataGridState.latestProps?.readOnly).toBe(false);
expect(dataGridState.latestProps?.showRowNumberColumn).toBe(true);
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: 'SELECT * FROM EDC_LOG',
status: 'success',
}));
expect(messageApi.warning).not.toHaveBeenCalled();
renderer?.unmount();
});
it('quotes exact-case OceanBase Oracle lowercase tables for execution while keeping sql logs unchanged', async () => {
storeState.connections[0].config.type = 'oceanbase';
(storeState.connections[0].config as any).oceanBaseProtocol = 'oracle';
storeState.connections[0].config.user = 'SYS@oracle_tenant#cluster';
storeState.connections[0].config.database = 'ORCLPDB1';
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [{ Table: 'SYS.test' }],
});
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['NAME', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ NAME: 'demo', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }],
});
backendApp.DBGetColumns.mockResolvedValueOnce({
success: true,
data: [{ name: 'NAME', key: '' }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: 'select * from test' })} />);
});
await act(async () => {
await findButton(renderer!, '运行').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.anything(), 'SYS');
expect(backendApp.DBGetColumns).toHaveBeenCalledWith(expect.anything(), 'SYS', 'test');
expect(executedSql).toMatch(/from\s+"test"\s+gonavi_query_source/i);
expect(executedSql).toMatch(/SELECT\s+gonavi_query_source\.\*\s*,\s+gonavi_query_source\.ROWID\s+AS\s+"__gonavi_oracle_rowid__"/i);
expect(dataGridState.latestProps?.tableName).toBe('SYS.test');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: 'select * from test',
status: 'success',
}));
expect(messageApi.warning).not.toHaveBeenCalled();
renderer?.unmount();
});
it('keeps Oracle anonymous PL/SQL blocks intact when running from the editor', async () => {
storeState.connections[0].config.type = 'oracle';
storeState.connections[0].config.database = 'ORCLPDB1';

View File

@@ -7,7 +7,7 @@ import { TabData, ColumnDefinition, IndexDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiTransactional, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, DBGetIndexes, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App';
import { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { getDataSourceCapabilities, shouldShowOceanBaseRowNumberColumn } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts";
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
@@ -963,6 +963,172 @@ const getQueryEditorModelValueLength = (model: any): number | null => {
}
};
type QueryIdentifierPathSegment = {
raw: string;
value: string;
quoted: boolean;
};
const isQuotedQueryIdentifierPart = (part: string): boolean => {
const text = String(part || '').trim();
if (!text) return false;
return (text.startsWith('`') && text.endsWith('`'))
|| (text.startsWith('"') && text.endsWith('"'))
|| (text.startsWith('[') && text.endsWith(']'));
};
const splitQueryIdentifierPathSegments = (qualifiedName: string): QueryIdentifierPathSegment[] => {
const text = String(qualifiedName || '').trim();
if (!text) return [];
const segments: QueryIdentifierPathSegment[] = [];
let current = '';
let inDouble = false;
let inBacktick = false;
let inBracket = false;
const flush = () => {
const raw = current.trim();
current = '';
if (!raw) return;
segments.push({
raw,
value: stripQueryIdentifierQuotes(raw),
quoted: isQuotedQueryIdentifierPart(raw),
});
};
for (let index = 0; index < text.length; index += 1) {
const ch = text[index];
const next = index + 1 < text.length ? text[index + 1] : '';
if (inDouble) {
current += ch;
if (ch === '"' && next === '"') {
current += next;
index += 1;
continue;
}
if (ch === '"') inDouble = false;
continue;
}
if (inBacktick) {
current += ch;
if (ch === '`' && next === '`') {
current += next;
index += 1;
continue;
}
if (ch === '`') inBacktick = false;
continue;
}
if (inBracket) {
current += ch;
if (ch === ']' && next === ']') {
current += next;
index += 1;
continue;
}
if (ch === ']') inBracket = false;
continue;
}
if (ch === '"') {
inDouble = true;
current += ch;
continue;
}
if (ch === '`') {
inBacktick = true;
current += ch;
continue;
}
if (ch === '[') {
inBracket = true;
current += ch;
continue;
}
if (ch === '.') {
flush();
continue;
}
current += ch;
}
flush();
return segments;
};
const matchLeadingSelectTableReference = (sql: string): { prefix: string; tableText: string; suffix: string } | null => {
const match = String(sql || '').match(new RegExp(`^(\\s*SELECT\\s+[\\s\\S]+?\\s+FROM\\s+)(${QUERY_EDITOR_SQL_IDENTIFIER_PATH_PATTERN})([\\s\\S]*)$`, 'i'));
if (!match) return null;
return {
prefix: match[1],
tableText: match[2],
suffix: match[3] || '',
};
};
const rewriteLeadingSelectTableReference = (sql: string, replacement: string): string | undefined => {
const match = matchLeadingSelectTableReference(sql);
if (!match || !replacement) return undefined;
return `${match.prefix}${replacement}${match.suffix}`;
};
const resolveOracleExactCaseTableReference = (
statement: string,
currentDb: string,
tables: CompletionTableMeta[],
): string | undefined => {
const leadingTable = matchLeadingSelectTableReference(statement);
if (!leadingTable) return undefined;
const segments = splitQueryIdentifierPathSegments(leadingTable.tableText);
if (segments.length === 0 || segments.length > 2 || segments.some((segment) => segment.quoted)) {
return undefined;
}
if (!segments.some((segment) => /[a-z]/.test(segment.value))) {
return undefined;
}
const rawSchemaName = segments.length === 2 ? String(segments[0]?.value || '').trim() : '';
const rawObjectName = String(segments[segments.length - 1]?.value || '').trim();
const targetDbName = String(rawSchemaName || currentDb || '').trim();
if (!rawObjectName || !targetDbName) return undefined;
const normalizedTargetDbName = targetDbName.toLowerCase();
const matched = tables.find((table) => {
if (String(table.dbName || '').trim().toLowerCase() !== normalizedTargetDbName) return false;
const parsed = splitSidebarQualifiedName(String(table.tableName || ''));
const objectName = String(parsed.objectName || table.tableName || '').trim();
const schemaName = String(parsed.schemaName || table.dbName || '').trim();
if (objectName !== rawObjectName) return false;
if (!rawSchemaName) return true;
return schemaName.toLowerCase() === rawSchemaName.toLowerCase();
});
if (!matched) return undefined;
const matchedParsed = splitSidebarQualifiedName(String(matched.tableName || ''));
const exactObjectName = String(matchedParsed.objectName || matched.tableName || '').trim();
const exactSchemaName = String(matchedParsed.schemaName || matched.dbName || rawSchemaName).trim();
const quotedParts = rawSchemaName
? [exactSchemaName, exactObjectName]
: [exactObjectName];
if (quotedParts.some((part) => !String(part || '').trim())) {
return undefined;
}
return quotedParts.map((part) => quoteIdentPart('oracle', part)).join('.');
};
const resolveOracleLikeDefaultSchemaName = (config: any): string => {
const rawUser = String(config?.user || '').trim();
if (!rawUser) return '';
const userPart = rawUser.split('@')[0] || rawUser;
return String(userPart || '').trim();
};
const getQueryEditorModelTextIfWithinLimit = (model: any, maxTextLength: number): string | null => {
const modelLength = getQueryEditorModelValueLength(model);
if (modelLength !== null && modelLength > maxTextLength) {
@@ -1776,25 +1942,27 @@ const clearQueryEditorObjectDecorations = (
const resolveQueryLocatorPlan = async ({
statement,
originalStatement,
dbType,
currentDb,
config,
forceReadOnly,
}: {
statement: string;
originalStatement?: string;
dbType: string;
currentDb: string;
config: any;
forceReadOnly: boolean;
}): Promise<QueryStatementPlan> => {
const plan: QueryStatementPlan = {
originalSql: statement,
originalSql: originalStatement || statement,
executedSql: statement,
pkColumns: [],
};
if (forceReadOnly) return plan;
const defaultSchema = isOracleLikeDialect(dbType) ? String(config?.user || '').trim() : '';
const defaultSchema = isOracleLikeDialect(dbType) ? resolveOracleLikeDefaultSchemaName(config) : '';
let tableRef = extractQueryResultTableRef(statement, dbType, currentDb, defaultSchema);
if (!tableRef) return plan;
plan.tableRef = tableRef;
@@ -4652,10 +4820,84 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
.length || sourceStatements.length;
const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult;
const statementPlans: QueryStatementPlan[] = [];
const showRowNumberColumn = shouldShowOceanBaseRowNumberColumn(config);
const defaultOracleSchema = isOracleLikeDialect(normalizedDbType)
? resolveOracleLikeDefaultSchemaName(config)
: '';
const oracleTableCache = new Map<string, CompletionTableMeta[]>();
const getOracleTablesForDb = async (dbName: string): Promise<CompletionTableMeta[]> => {
const normalizedDbName = String(dbName || '').trim();
if (!normalizedDbName) return [];
const cacheKey = normalizedDbName.toLowerCase();
const cached = oracleTableCache.get(cacheKey);
if (cached) return cached;
const existing = tablesRef.current.filter((table) => String(table.dbName || '').trim().toLowerCase() === cacheKey);
if (existing.length > 0) {
oracleTableCache.set(cacheKey, existing);
return existing;
}
try {
const resTables = await DBGetTables(buildRpcConnectionConfig(config) as any, normalizedDbName);
if (!resTables?.success || !Array.isArray(resTables.data)) {
oracleTableCache.set(cacheKey, []);
return [];
}
const fetchedTables = resTables.data
.map((row: any) => {
const tableName = String(Object.values(row || {})[0] || '').trim();
if (!tableName) return null;
return {
dbName: normalizedDbName,
tableName,
} as CompletionTableMeta;
})
.filter(Boolean) as CompletionTableMeta[];
if (fetchedTables.length > 0) {
const knownKeys = new Set(tablesRef.current.map((table) => `${String(table.dbName || '').trim().toLowerCase()}\u0000${String(table.tableName || '').trim()}`));
const missing = fetchedTables.filter((table) => !knownKeys.has(`${String(table.dbName || '').trim().toLowerCase()}\u0000${String(table.tableName || '').trim()}`));
if (missing.length > 0) {
tablesRef.current = [...tablesRef.current, ...missing];
if (isActive) {
sharedTablesData = tablesRef.current;
}
}
}
oracleTableCache.set(cacheKey, fetchedTables);
return fetchedTables;
} catch {
oracleTableCache.set(cacheKey, []);
return [];
}
};
const executedSourceStatements: string[] = [];
for (const statement of sourceStatements) {
let executableStatement = statement;
if (isOracleLikeDialect(normalizedDbType)) {
const leadingTable = matchLeadingSelectTableReference(statement);
if (leadingTable) {
const leadingSegments = splitQueryIdentifierPathSegments(leadingTable.tableText);
const oracleLookupDbName = String(
(leadingSegments.length >= 2 ? leadingSegments[0]?.value : '')
|| defaultOracleSchema
|| currentDb
|| '',
).trim();
const oracleTables = oracleLookupDbName ? await getOracleTablesForDb(oracleLookupDbName) : [];
const exactQualifiedTable = resolveOracleExactCaseTableReference(statement, oracleLookupDbName, oracleTables);
if (exactQualifiedTable) {
executableStatement = rewriteLeadingSelectTableReference(statement, exactQualifiedTable) || statement;
}
}
}
executedSourceStatements.push(executableStatement);
}
const statementPlans: QueryStatementPlan[] = [];
for (let index = 0; index < sourceStatements.length; index += 1) {
statementPlans.push(await resolveQueryLocatorPlan({
statement,
statement: executedSourceStatements[index] || sourceStatements[index],
originalStatement: sourceStatements[index],
dbType: normalizedDbType,
currentDb,
config,
@@ -4691,7 +4933,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
addSqlLog({
id: `log-${Date.now()}-query-multi`,
timestamp: Date.now(),
sql: fullSQL,
sql: sourceStatements.join(';\n'),
status: res.success ? 'success' : 'error',
duration,
message: res.success ? '' : res.message,
@@ -4832,6 +5074,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
pkColumns: plan?.pkColumns || [],
editLocator,
readOnly: forceReadOnlyResult || !editLocator || editLocator.readOnly,
showRowNumberColumn,
truncated,
page,
});

View File

@@ -20,6 +20,7 @@ export type QueryEditorResultSet = {
pkColumns: string[];
editLocator?: EditRowLocator;
readOnly: boolean;
showRowNumberColumn?: boolean;
truncated?: boolean;
pkLoading?: boolean;
page?: QueryResultPaginationState & { loading?: boolean };
@@ -463,6 +464,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
showRowNumberColumn={rs.showRowNumberColumn}
onReload={() => {
if (rs.page) {
onResultPageChange(rs.key, rs.page.current, rs.page.pageSize);