From 7ff3e007590f3b89550dea388378110b63d2ca4d Mon Sep 17 00:00:00 2001 From: Syngnat Date: Wed, 17 Jun 2026 09:49:39 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=96=E9=83=A8=20SQL=20=E6=A0=87=E7=AD=BE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=20OceanBase=20=E6=9F=A5=E8=AF=A2=E6=94=B9?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复外部 SQL 文件删除后标签残留及关闭异常 - 修复 OceanBase Oracle 查询注入隐藏 ROWID 时的表名改写 - 修复小写表名执行时的精确引用并保留日志中的原始 SQL - 补充查询编辑器相关回归测试 --- .../QueryEditor.external-sql-save.test.tsx | 94 +++++++ frontend/src/components/QueryEditor.tsx | 255 +++++++++++++++++- .../components/QueryEditorResultsPanel.tsx | 2 + 3 files changed, 345 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index eb69775..2de04c5 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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(); + }); + + 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(); + }); + + 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'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 57f38b2..4916b3a 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 => { 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(); + const getOracleTablesForDb = async (dbName: string): Promise => { + 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, }); diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx index 914c5de..ba6783a 100644 --- a/frontend/src/components/QueryEditorResultsPanel.tsx +++ b/frontend/src/components/QueryEditorResultsPanel.tsx @@ -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 = ({ connectionId={currentConnectionId} pkColumns={rs.pkColumns} editLocator={rs.editLocator} + showRowNumberColumn={rs.showRowNumberColumn} onReload={() => { if (rs.page) { onResultPageChange(rs.key, rs.page.current, rs.page.pageSize);