🐛 fix(query-editor): 修复 OceanBase 执行误用登录Schema

- 保持 OceanBase Oracle 默认登录 schema 解析不变
- 未限定表名改写时增加登录 schema 到所选 schema 的回退候选
- 补充 SBDEVREAD/SBDEV 与 ORCLPDB1 场景回归测试

Fixes #615
This commit is contained in:
Syngnat
2026-07-06 10:30:00 +08:00
parent 93dc696e90
commit 258fccd56f
4 changed files with 118 additions and 18 deletions

View File

@@ -8138,6 +8138,10 @@ describe('QueryEditor external SQL save', () => {
storeState.connections[0].config.user = 'SBDEVREAD';
storeState.connections[0].config.database = 'ORCLPDB1';
(storeState.connections[0].config as any).readOnly = true;
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [],
});
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [{ Table: 'SBDEV.PERSON_INFO' }],
@@ -8161,7 +8165,8 @@ describe('QueryEditor external SQL save', () => {
});
const executedSql = String(backendApp.DBQueryMulti.mock.calls[0][2]);
expect(backendApp.DBGetTables).toHaveBeenCalledWith(expect.anything(), 'SBDEV');
expect(backendApp.DBGetTables).toHaveBeenNthCalledWith(1, expect.anything(), 'SBDEVREAD');
expect(backendApp.DBGetTables).toHaveBeenNthCalledWith(2, expect.anything(), 'SBDEV');
expect(backendApp.DBGetColumns).not.toHaveBeenCalled();
expect(executedSql).toMatch(/from\s+"SBDEV"\."PERSON_INFO"\s+where\s+zjjhm=''/i);
expect(executedSql).not.toContain('SBDEVREAD.PERSON_INFO');
@@ -8173,6 +8178,46 @@ describe('QueryEditor external SQL save', () => {
renderer?.unmount();
});
it('keeps qualifying OceanBase Oracle read-only queries when config.database already equals the selected schema', async () => {
storeState.connections[0].config.type = 'oceanbase';
(storeState.connections[0].config as any).oceanBaseProtocol = 'oracle';
storeState.connections[0].config.user = 'SBDEVREAD';
storeState.connections[0].config.database = 'SBDEV';
(storeState.connections[0].config as any).readOnly = true;
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [],
});
backendApp.DBGetTables.mockResolvedValueOnce({
success: true,
data: [{ Table: 'SBDEV.SYSM_USER' }],
});
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['USER_ID'], rows: [{ USER_ID: '0001477884' }] }],
});
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab({ dbName: 'SBDEV', query: "select * from sysm_user where user_id='0001477884'" })} />);
});
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).toHaveBeenNthCalledWith(1, expect.anything(), 'SBDEVREAD');
expect(backendApp.DBGetTables).toHaveBeenNthCalledWith(2, expect.anything(), 'SBDEV');
expect(executedSql).toMatch(/from\s+"SBDEV"\."SYSM_USER"\s+where\s+user_id='0001477884'/i);
expect(executedSql).not.toContain('SBDEVREAD.SYSM_USER');
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

@@ -132,6 +132,7 @@ import {
resolveOracleExactCaseTableReference,
resolveOracleLikeDefaultSchemaName,
resolveOracleLikeExecutionSchemaName,
resolveOracleLikeLookupSchemaCandidates,
resolveQueryEditorFormatterLanguage,
resolveQueryEditorHoverTarget,
resolveQueryEditorNavigationDecorations,
@@ -6012,13 +6013,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const defaultOracleSchema = isOracleLikeDialect(normalizedDbType)
? resolveOracleLikeDefaultSchemaName(config)
: '';
const oracleExecutionSchema = isOracleLikeDialect(normalizedDbType)
? resolveOracleLikeExecutionSchemaName(config, currentDb)
: '';
const shouldQualifyOracleUnqualifiedTables = Boolean(
oracleExecutionSchema
&& oracleExecutionSchema.toLowerCase() !== String(defaultOracleSchema || '').trim().toLowerCase(),
);
const oracleTableCache = new Map<string, CompletionTableMeta[]>();
const getOracleTablesForDb = async (dbName: string): Promise<CompletionTableMeta[]> => {
const normalizedDbName = String(dbName || '').trim();
@@ -6073,16 +6067,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const leadingTable = matchLeadingSelectTableReference(statement);
if (leadingTable) {
const leadingSegments = splitQueryIdentifierPathSegments(leadingTable.tableText);
const oracleLookupDbName = String(
(leadingSegments.length >= 2 ? leadingSegments[0]?.value : '')
|| oracleExecutionSchema
|| currentDb
|| '',
).trim();
const oracleTables = oracleLookupDbName ? await getOracleTablesForDb(oracleLookupDbName) : [];
const exactQualifiedTable = resolveOracleExactCaseTableReference(statement, oracleLookupDbName, oracleTables, {
qualifyUnqualified: shouldQualifyOracleUnqualifiedTables,
});
const oracleLookupDbCandidates = leadingSegments.length >= 2
? [String(leadingSegments[0]?.value || '').trim()].filter(Boolean)
: resolveOracleLikeLookupSchemaCandidates(config, currentDb);
let exactQualifiedTable: string | undefined;
for (const oracleLookupDbName of oracleLookupDbCandidates) {
const oracleTables = oracleLookupDbName ? await getOracleTablesForDb(oracleLookupDbName) : [];
exactQualifiedTable = resolveOracleExactCaseTableReference(statement, oracleLookupDbName, oracleTables, {
qualifyUnqualified: Boolean(
leadingSegments.length === 1
&& oracleLookupDbName
&& oracleLookupDbName.toLowerCase() !== String(defaultOracleSchema || '').trim().toLowerCase(),
),
});
if (exactQualifiedTable) {
break;
}
}
if (exactQualifiedTable) {
executableStatement = rewriteLeadingSelectTableReference(statement, exactQualifiedTable) || statement;
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
resolveOracleLikeDefaultSchemaName,
resolveOracleLikeExecutionSchemaName,
resolveOracleLikeLookupSchemaCandidates,
} from './QueryEditorHelpers';
describe('QueryEditorHelpers Oracle-like execution schema', () => {
it('uses the selected schema when it differs from the login user', () => {
const config = {
type: 'oceanbase',
oceanBaseProtocol: 'oracle',
user: 'SBDEVREAD',
database: 'SBDEV',
};
expect(resolveOracleLikeDefaultSchemaName(config)).toBe('SBDEVREAD');
expect(resolveOracleLikeExecutionSchemaName(config, 'SBDEV')).toBe('SBDEVREAD');
expect(resolveOracleLikeLookupSchemaCandidates(config, 'SBDEV')).toEqual(['SBDEVREAD', 'SBDEV']);
});
it('keeps the login user schema when the selected schema is the same owner', () => {
const config = {
type: 'oracle',
user: 'APP_OWNER',
database: 'ORCLPDB1',
};
expect(resolveOracleLikeExecutionSchemaName(config, 'APP_OWNER')).toBe('APP_OWNER');
expect(resolveOracleLikeLookupSchemaCandidates(config, 'APP_OWNER')).toEqual(['APP_OWNER']);
});
});

View File

@@ -1305,6 +1305,27 @@ export const resolveOracleLikeExecutionSchemaName = (config: any, currentDb: str
return resolveOracleLikeDefaultSchemaName(config) || selectedDb;
};
export const resolveOracleLikeLookupSchemaCandidates = (config: any, currentDb: string): string[] => {
const candidates: string[] = [];
const seen = new Set<string>();
const push = (value: string) => {
const normalized = String(value || '').trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
candidates.push(normalized);
};
const defaultSchema = resolveOracleLikeDefaultSchemaName(config);
const selectedDb = String(currentDb || '').trim();
push(defaultSchema);
if (selectedDb && selectedDb.toLowerCase() !== String(defaultSchema || '').trim().toLowerCase()) {
push(selectedDb);
}
return candidates;
};
export const getQueryEditorModelTextIfWithinLimit = (model: any, maxTextLength: number): string | null => {
const modelLength = getQueryEditorModelValueLength(model);
if (modelLength !== null && modelLength > maxTextLength) {