From 3fa4f8e9004a856bb02a624210dddb0659a58ead Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 14 Jul 2026 17:11:07 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(query-editor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20OceanBase=20=E8=A7=86=E5=9B=BE=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E4=B8=8E=E8=BE=93=E5=85=A5=E4=B8=A2=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 仅对元数据确认的 OceanBase Oracle 基表注入 ROWID - 视图和同义词查询改为只读,避免触发 OBE-01445 - 在 SQL 补全中加入普通视图和物化视图候选 - 修复 beforeinput 已拦截时首个可打印字符被跳过的问题 - 补充视图、同义词和输入回归测试 --- .../DataViewer.primary-key.test.tsx | 27 +++ frontend/src/components/DataViewer.tsx | 8 +- .../MonacoEditor.input-fallback.test.tsx | 27 ++- frontend/src/components/MonacoEditor.tsx | 3 +- .../QueryEditor.external-sql-save.test.tsx | 92 ++++++++++ frontend/src/components/QueryEditor.tsx | 161 ++++++++++++++++-- .../queryEditor/QueryEditorHelpers.test.ts | 12 ++ .../queryEditor/QueryEditorHelpers.ts | 31 +++- 8 files changed, 342 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index 58eb4bba..5759daa0 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -444,6 +444,33 @@ describe('DataViewer safe editing locator', () => { renderer.unmount(); }); + it('queries Oracle views without injecting ROWID and keeps the result read-only', async () => { + backendApp.DBGetColumns.mockResolvedValue({ + success: true, + data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }], + }); + backendApp.DBQuery.mockResolvedValue({ + success: true, + fields: ['ID', 'NAME'], + data: [{ ID: 7, NAME: 'view-row' }], + }); + + const renderer = await renderAndReload(createTab({ + id: 'tab-oracle-view-rowid', + tableName: 'PERSON_VIEW', + title: 'PERSON_VIEW', + objectType: 'view', + })); + + const viewQueries = backendApp.DBQuery.mock.calls + .map((call: any[]) => String(call[2] || '')) + .filter((sql: string) => sql.includes('PERSON_VIEW')); + expect(viewQueries.length).toBeGreaterThan(0); + expect(viewQueries.every((sql: string) => !/\bROWID\b/i.test(sql))).toBe(true); + expect(dataGridState.latestProps?.readOnly).toBe(true); + renderer.unmount(); + }); + it('does not add fallback ORDER BY for DuckDB table preview when a primary key is available', async () => { storeState.connections[0].config.type = 'duckdb'; storeState.connections[0].config.database = 'main'; diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index c4912aa8..e03555c3 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -411,7 +411,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const duckdbSafeSelectCacheRef = useRef>({}); const currentConnConfig = connections.find(c => c.id === tab.connectionId)?.config; const currentConnCaps = getDataSourceCapabilities(currentConnConfig); - const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult; + // Ordinary views can reject physical ROWID (for example Oracle join views), so + // browse them without attempting the editable-table locator optimization. + const forceReadOnly = currentConnCaps.forceReadOnlyQueryResult || tab.objectType === 'view'; const preferManualTotalCount = currentConnCaps.preferManualTotalCount; const supportsApproximateTableCount = currentConnCaps.supportsApproximateTableCount; const supportsApproximateTotalPages = currentConnCaps.supportsApproximateTotalPages; @@ -491,7 +493,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ totalCountLoading: false, totalCountCancelled: false, })); - }, [tab.id, tab.connectionId, tab.dbName, tab.tableName]); + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, tab.objectType]); const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => { scrollSnapshotRef.current = snapshot; @@ -1226,7 +1228,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ return; } fetchData(1, pagination.pageSize); - }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter + }, [tab.id, tab.connectionId, tab.dbName, tab.tableName, tab.objectType, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter return (
diff --git a/frontend/src/components/MonacoEditor.input-fallback.test.tsx b/frontend/src/components/MonacoEditor.input-fallback.test.tsx index 339f976d..4d689288 100644 --- a/frontend/src/components/MonacoEditor.input-fallback.test.tsx +++ b/frontend/src/components/MonacoEditor.input-fallback.test.tsx @@ -23,12 +23,12 @@ class FakeTextAreaElement extends FakeHTMLElement { this.listeners.get(type)?.delete(listener); } - dispatchPrintableBeforeInput(text: string): void { + dispatchPrintableBeforeInput(text: string, defaultPrevented = false): void { const event = { data: text, inputType: 'insertText', isComposing: false, - defaultPrevented: false, + defaultPrevented, } as InputEvent; this.listeners.get('beforeinput')?.forEach((listener) => listener(event)); } @@ -169,6 +169,29 @@ describe('MonacoEditor printable input fallback', () => { expect(position).toEqual({ lineNumber: 1, column: 3 }); }); + it('recovers a default-prevented leading character when later input reaches the model', () => { + installPrintableInputFallback(editor, { + editor: { EditorOption: { readOnly: 1 } }, + }); + + value = 'SELECT * FROM person'; + position = { lineNumber: 1, column: value.length + 1 }; + input.dispatchPrintableBeforeInput('s', true); + input.dispatchPrintableBeforeInput('f'); + value = 'SELECT * FROM personf'; + position = { lineNumber: 1, column: value.length + 1 }; + modelContentListener?.(); + + vi.advanceTimersByTime(80); + + expect(editor.executeEdits).toHaveBeenCalledWith( + 'gonavi-printable-input-fallback', + [expect.objectContaining({ text: 'sf' })], + ); + expect(value).toBe('SELECT * FROM personsf'); + expect(position).toEqual({ lineNumber: 1, column: value.length + 1 }); + }); + it('settles the previous missing input before buffering text after a cursor move', () => { installPrintableInputFallback(editor, { editor: { EditorOption: { readOnly: 1 } }, diff --git a/frontend/src/components/MonacoEditor.tsx b/frontend/src/components/MonacoEditor.tsx index abf60a3b..4f9fa090 100644 --- a/frontend/src/components/MonacoEditor.tsx +++ b/frontend/src/components/MonacoEditor.tsx @@ -420,8 +420,7 @@ export const installPrintableInputFallback = (editor: any, monaco: any) => { const handleBeforeInput = (event: InputEvent) => { const text = String(event.data || ''); if ( - event.defaultPrevented - || event.isComposing + event.isComposing || event.inputType !== 'insertText' || !text || text.length > 8 diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 599bce53..bba390da 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -2461,6 +2461,52 @@ describe('QueryEditor external SQL save', () => { }); }); + it('suggests Oracle views after their metadata has loaded', async () => { + let renderer!: ReactTestRenderer; + autoFetchState.visible = true; + storeState.connections[0].config.type = 'oracle'; + storeState.connections[0].config.database = 'APP'; + backendApp.DBGetDatabases.mockResolvedValueOnce({ success: true, data: [{ Database: 'APP' }] }); + backendApp.DBGetTables.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBGetAllColumns.mockResolvedValueOnce({ success: true, data: [] }); + backendApp.DBQuery.mockImplementation(async (_config: any, _dbName: string, sql: string) => { + if (String(sql || '').includes('USER_VIEWS')) { + return { success: true, data: [{ view_name: 'PERSON_VIEW' }] }; + } + return { success: true, data: [] }; + }); + + await act(async () => { + renderer = create(); + }); + await act(async () => { + for (let i = 0; i < 12; i += 1) { + await Promise.resolve(); + } + }); + + const sqlProvider = findSqlCompletionProvider(); + expect(sqlProvider).toBeTruthy(); + + editorState.value = 'SELECT * FROM person'; + editorState.latestOnChange?.(editorState.value); + const result = await sqlProvider.provideCompletionItems( + editorState.editor.getModel(), + { lineNumber: 1, column: editorState.value.length + 1 }, + ); + + expect(result.suggestions).toEqual(expect.arrayContaining([ + expect.objectContaining({ + label: 'PERSON_VIEW', + insertText: 'PERSON_VIEW', + detail: '视图 (APP)', + }), + ])); + await act(async () => { + renderer.unmount(); + }); + }); + it('fuzzy matches table names in FROM completion before column candidates', async () => { let renderer!: ReactTestRenderer; autoFetchState.visible = true; @@ -8612,6 +8658,10 @@ describe('QueryEditor external SQL save', () => { (storeState.connections[0].config as any).oceanBaseProtocol = 'oracle'; storeState.connections[0].config.user = 'dev'; storeState.connections[0].config.database = 'ORCLPDB1'; + backendApp.DBGetTables.mockResolvedValueOnce({ + success: true, + data: [{ Table: 'DEV.EDC_LOG' }], + }); backendApp.DBQueryMulti.mockResolvedValueOnce({ success: true, data: [{ columns: ['WAFER_ID', ORACLE_ROWID_LOCATOR_COLUMN], rows: [{ WAFER_ID: 'R015Z10F08', [ORACLE_ROWID_LOCATOR_COLUMN]: 'AAAA' }] }], @@ -8657,6 +8707,48 @@ describe('QueryEditor external SQL save', () => { renderer?.unmount(); }); + it('does not inject ROWID for an OceanBase Oracle synonym that is not a base table', async () => { + storeState.connections[0].config.type = 'oceanbase'; + (storeState.connections[0].config as any).oceanBaseProtocol = 'oracle'; + storeState.connections[0].config.user = 'B'; + storeState.connections[0].config.database = 'ORCLPDB1'; + backendApp.DBGetTables.mockResolvedValue({ + success: true, + data: [{ Table: 'B.OTHER_TABLE' }], + }); + backendApp.DBGetColumns.mockResolvedValueOnce({ + success: true, + data: [{ name: 'ID', key: '' }, { name: 'NAME', key: '' }], + }); + backendApp.DBQueryMulti.mockResolvedValueOnce({ + success: true, + data: [{ columns: ['ID', 'NAME'], rows: [{ ID: 7, NAME: 'synonym-row' }] }], + }); + + 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(executedSql).toMatch(/FROM\s+person/i); + expect(executedSql).not.toMatch(/\bROWID\b/i); + expect(dataGridState.latestProps?.editLocator).toMatchObject({ + strategy: 'all-columns', + columns: ['ID', 'NAME'], + readOnly: false, + }); + renderer?.unmount(); + }); + it('keeps OceanBase Oracle sequence queries out of the ROWNUM auto-limit wrapper', async () => { const sql = 'SELECT IMP_BASICINFO.SEQ_HIS_AZA7.nextval FROM dual'; storeState.connections[0].config.type = 'oceanbase'; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index ac76e13d..e31482f1 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -17,6 +17,7 @@ import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifie import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isPostgresSchemaDialect } from '../utils/connectionDriverType'; +import { resolveOceanBaseProtocolFromConfig } from '../utils/oceanBaseProtocol'; import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect'; import { applyQueryAutoLimit } from '../utils/queryAutoLimit'; import { @@ -135,6 +136,7 @@ import { getQueryEditorDecorationModelTextIfLightweight, getQueryEditorObjectResolveText, getTabQueryValue, + isOracleBaseTableReference, isDocumentLevelShortcutTarget, isQueryEditorPrimaryMouseButton, normalizeCommentText, @@ -199,6 +201,17 @@ const QUERY_EDITOR_AI_INLINE_DEBOUNCE_MS = 220; const QUERY_EDITOR_AI_INLINE_CONTEXT_KEY = 'gonaviAiInlineSuggestionVisible'; const QUERY_EDITOR_IME_FALLBACK_DELAY_MS = 80; +const isOceanBaseOracleConnection = (config: any): boolean => { + const type = String(config?.type || '').trim().toLowerCase(); + const driver = String(config?.driver || '').trim().toLowerCase(); + if (type !== 'oceanbase' && driver !== 'oceanbase') return false; + try { + return resolveOceanBaseProtocolFromConfig(config || {}) === 'oracle'; + } catch { + return false; + } +}; + const normalizeQueryEditorInlineMemorySqlKey = (sql: string): string => ( String(sql || '') .replace(/\r\n?/g, '\n') @@ -4895,6 +4908,49 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc routineType: normalizeRoutineType(routine.routineType), }; }; + const getViewTypeLabel = (materialized: boolean) => ( + materialized + ? translate('query_editor.object_info.materialized_view') + : translate('sidebar.object.view') + ); + const buildViewSuggestionMeta = (view: CompletionViewMeta) => { + const rawDbName = String(view.dbName || '').trim(); + const rawViewName = String(view.viewName || '').trim(); + const parsed = splitSchemaAndTable(rawViewName); + const schemaName = String(view.schemaName || parsed.schema || '').trim(); + const objectName = String(parsed.table || rawViewName).trim(); + const schemaMatchesDb = !!schemaName + && !!rawDbName + && schemaName.toLowerCase() === rawDbName.toLowerCase(); + const isCurrentDb = rawDbName.toLowerCase() === getActiveCompletionDbName().toLowerCase(); + const schemaQualifiedName = parsed.schema + ? rawViewName + : (schemaName && !schemaMatchesDb ? `${schemaName}.${objectName}` : objectName); + const displayName = isCurrentDb && schemaMatchesDb + ? objectName + : schemaQualifiedName; + const dbQualifiedLabel = rawDbName && !isCurrentDb + ? `${rawDbName}.${displayName}` + : displayName; + const insertName = rawDbName && !isCurrentDb + ? dbQualifiedLabel + : displayName; + return { + displayName, + dbQualifiedLabel, + insertText: quoteCompletionPath(insertName), + objectName, + schemaName, + }; + }; + const getViewSuggestionScope = (view: CompletionViewMeta, meta: ReturnType) => { + const dbName = String(view.dbName || '').trim(); + const schemaName = String(meta.schemaName || '').trim(); + if (!schemaName || schemaName.toLowerCase() === dbName.toLowerCase()) { + return dbName; + } + return dbName ? `${dbName}.${schemaName}` : schemaName; + }; const buildConnConfig = () => { const connId = sharedCurrentConnectionId; @@ -5127,6 +5183,26 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc sortText: '0' + suggestionMeta.displayName }; }); + const viewSuggestions = [ + ...sharedViewsData.map((view) => ({ view, materialized: false })), + ...sharedMaterializedViewsData.map((view) => ({ view, materialized: true })), + ] + .filter(({ view }) => String(view.dbName || '').toLowerCase() === qualifierLower) + .map(({ view, materialized }) => ({ view, materialized, meta: buildViewSuggestionMeta(view) })) + .filter(({ view, meta }) => ( + !prefix + || meta.displayName.toLowerCase().startsWith(prefix) + || meta.objectName.toLowerCase().startsWith(prefix) + || String(view.viewName || '').toLowerCase().startsWith(prefix) + )) + .map(({ view, materialized, meta }) => ({ + label: meta.displayName, + kind: monaco.languages.CompletionItemKind.Class, + insertText: quoteCompletionPath(meta.displayName), + detail: `${getViewTypeLabel(materialized)} (${view.dbName})`, + range, + sortText: '05' + meta.displayName, + })); const routineSuggestions = sharedRoutinesData .filter((routine) => String(routine.dbName || '').toLowerCase() === qualifierLower) .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) @@ -5145,7 +5221,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc range, sortText: '1' + meta.displayName, })); - return { suggestions: [...suggestions, ...routineSuggestions] }; + return { suggestions: [...suggestions, ...viewSuggestions, ...routineSuggestions] }; } // qualifier 是 schema(如 dbo/public)时,仅补全表名,避免输入 dbo. 后再补成 dbo.dbo.table @@ -5160,21 +5236,39 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }) .filter(t => t.schema.toLowerCase() === qualifierLower && !!t.table); + const schemaViews = [ + ...sharedViewsData.map((view) => ({ view, materialized: false })), + ...sharedMaterializedViewsData.map((view) => ({ view, materialized: true })), + ] + .map(({ view, materialized }) => ({ view, materialized, meta: buildViewSuggestionMeta(view) })) + .filter(({ meta }) => meta.schemaName.toLowerCase() === qualifierLower && !!meta.objectName); - if (schemaTables.length > 0) { + if (schemaTables.length > 0 || schemaViews.length > 0) { const filtered = prefix ? schemaTables.filter(t => t.table.toLowerCase().startsWith(prefix)) : schemaTables; - const suggestions = filtered.map(t => ({ - label: t.table, - kind: monaco.languages.CompletionItemKind.Class, - insertText: quoteCompletionPart(t.table), - detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment), - documentation: buildCompletionDocumentation(t.comment), - range, - sortText: '0' + t.table - })); + const suggestions = [ + ...filtered.map(t => ({ + label: t.table, + kind: monaco.languages.CompletionItemKind.Class, + insertText: quoteCompletionPart(t.table), + detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName}${t.schema ? '.' + t.schema : ''})`, t.comment), + documentation: buildCompletionDocumentation(t.comment), + range, + sortText: '0' + t.table + })), + ...schemaViews + .filter(({ meta }) => !prefix || meta.objectName.toLowerCase().startsWith(prefix)) + .map(({ view, materialized, meta }) => ({ + label: meta.objectName, + kind: monaco.languages.CompletionItemKind.Class, + insertText: quoteCompletionPart(meta.objectName), + detail: `${getViewTypeLabel(materialized)} (${getViewSuggestionScope(view, meta)})`, + range, + sortText: '05' + meta.objectName, + })), + ]; const routineSuggestions = sharedRoutinesData .filter((routine) => { const meta = buildRoutineSuggestionMeta(routine); @@ -5424,6 +5518,38 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }); + const completionViews = [ + ...sharedViewsData.map((view) => ({ view, materialized: false })), + ...sharedMaterializedViewsData.map((view) => ({ view, materialized: true })), + ].filter(({ view }) => ( + !expectsTableName + || !currentDatabase + || isCurrentCompletionDatabase(view.dbName || '') + )); + const viewSuggestions = completionViews + .map(({ view, materialized }) => ({ view, materialized, meta: buildViewSuggestionMeta(view) })) + .filter(({ view, meta }) => ( + includesWordPrefix(meta.dbQualifiedLabel) + || includesWordPrefix(meta.displayName) + || includesWordPrefix(meta.objectName) + || includesWordPrefix(view.viewName || '') + )) + .map(({ view, materialized, meta }) => { + const isCurrentDb = isCurrentCompletionDatabase(view.dbName || ''); + const label = isCurrentDb ? meta.displayName : meta.dbQualifiedLabel; + return { + label, + kind: monaco.languages.CompletionItemKind.Class, + insertText: meta.insertText, + detail: `${getViewTypeLabel(materialized)} (${getViewSuggestionScope(view, meta)})`, + range, + sortText: (isCurrentDb ? sortGroups.tableCurrent : sortGroups.tableOther) + + '1' + + getPrefixMatchRank(label, meta.displayName, meta.objectName, view.viewName || '') + + label, + }; + }); + const routineSuggestions = sharedRoutinesData .map((routine) => ({ routine, meta: buildRoutineSuggestionMeta(routine) })) .filter(({ routine, meta }) => { @@ -5492,6 +5618,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc ...keywordSuggestions, ...funcSuggestions, ...tableSuggestions, + ...viewSuggestions, ...dbSuggestions, ...routineSuggestions, ...relevantColumns, @@ -5499,6 +5626,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc : [ ...relevantColumns, // FROM 表的列最优先 ...tableSuggestions, // 表次之 + ...viewSuggestions, // 视图和表同属可查询对象 ...dbSuggestions, // 数据库 ...routineSuggestions, // 存储过程/函数 ...funcSuggestions, // 内置函数 @@ -6643,6 +6771,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc .length || sourceStatements.length; const forceReadOnlyResult = connCaps.forceReadOnlyQueryResult; + const oceanBaseOracleConnection = isOceanBaseOracleConnection(config); const defaultOracleSchema = isOracleLikeDialect(normalizedDbType) ? resolveOracleLikeDefaultSchemaName(config) : ''; @@ -6694,8 +6823,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }; const executedSourceStatements: string[] = []; + const allowOracleRowIDByStatement: boolean[] = []; for (const statement of sourceStatements) { let executableStatement = statement; + let allowOracleRowID = !oceanBaseOracleConnection; if (isOracleLikeDialect(normalizedDbType)) { const leadingTable = matchLeadingSelectTableReference(statement); if (leadingTable) { @@ -6706,6 +6837,12 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc let exactQualifiedTable: string | undefined; for (const oracleLookupDbName of oracleLookupDbCandidates) { const oracleTables = oracleLookupDbName ? await getOracleTablesForDb(oracleLookupDbName) : []; + if ( + oceanBaseOracleConnection + && isOracleBaseTableReference(statement, oracleLookupDbName, oracleTables) + ) { + allowOracleRowID = true; + } exactQualifiedTable = resolveOracleExactCaseTableReference(statement, oracleLookupDbName, oracleTables, { qualifyUnqualified: Boolean( leadingSegments.length === 1 @@ -6723,6 +6860,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } executedSourceStatements.push(executableStatement); + allowOracleRowIDByStatement.push(allowOracleRowID); } const statementPlans: QueryStatementPlan[] = []; for (let index = 0; index < sourceStatements.length; index += 1) { @@ -6735,6 +6873,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc currentDb, config, forceReadOnly: forceReadOnlyResult, + allowOracleRowID: allowOracleRowIDByStatement[index], })); } catch (planError) { // 行定位计划失败绝不能阻断查询执行,兜底裸计划保证结果页始终呈现。 diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts index 502bf044..4f11b7d6 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { collectQueryEditorReferencedDatabaseNames, + isOracleBaseTableReference, resolveOracleLikeDefaultSchemaName, resolveOracleLikeExecutionSchemaName, resolveOracleLikeLookupSchemaCandidates, @@ -58,6 +59,17 @@ describe('QueryEditorHelpers Oracle-like execution schema', () => { expect(resolveOracleLikeExecutionSchemaName(config, 'APP_OWNER')).toBe('APP_OWNER'); expect(resolveOracleLikeLookupSchemaCandidates(config, 'APP_OWNER')).toEqual(['APP_OWNER']); }); + + it('recognizes base tables but not synonyms when deciding whether ROWID is safe', () => { + const baseTables = [ + { dbName: 'A', tableName: 'A.PERSON' }, + { dbName: 'B', tableName: 'B.ORDERS' }, + ]; + + expect(isOracleBaseTableReference('SELECT * FROM A.person', 'A', baseTables)).toBe(true); + expect(isOracleBaseTableReference('SELECT * FROM person', 'B', baseTables)).toBe(false); + expect(isOracleBaseTableReference('SELECT * FROM person_view', 'B', baseTables)).toBe(false); + }); }); describe('QueryEditorHelpers qualified navigation (MySQL db.table + PG schema.table)', () => { diff --git a/frontend/src/components/queryEditor/QueryEditorHelpers.ts b/frontend/src/components/queryEditor/QueryEditorHelpers.ts index 2796db66..51cff908 100644 --- a/frontend/src/components/queryEditor/QueryEditorHelpers.ts +++ b/frontend/src/components/queryEditor/QueryEditorHelpers.ts @@ -1240,6 +1240,33 @@ export const rewriteLeadingSelectTableReference = (sql: string, replacement: str return `${match.prefix}${replacement}${match.suffix}`; }; +export const isOracleBaseTableReference = ( + statement: string, + currentDb: string, + tables: CompletionTableMeta[], +): boolean => { + const leadingTable = matchLeadingSelectTableReference(statement); + if (!leadingTable) return false; + + const segments = splitQueryIdentifierPathSegments(leadingTable.tableText); + if (segments.length === 0 || segments.length > 2) return false; + + const explicitSchemaName = segments.length === 2 ? String(segments[0]?.value || '').trim() : ''; + const objectName = String(segments[segments.length - 1]?.value || '').trim(); + const targetSchemaName = explicitSchemaName || String(currentDb || '').trim(); + if (!objectName || !targetSchemaName) return false; + + const normalizedSchemaName = targetSchemaName.toLowerCase(); + return tables.some((table) => { + if (String(table.dbName || '').trim().toLowerCase() !== normalizedSchemaName) return false; + const parsed = splitSidebarQualifiedName(String(table.tableName || '')); + const tableObjectName = String(parsed.objectName || table.tableName || '').trim(); + const tableSchemaName = String(parsed.schemaName || table.dbName || '').trim(); + if (tableObjectName.toLowerCase() !== objectName.toLowerCase()) return false; + return !explicitSchemaName || tableSchemaName.toLowerCase() === normalizedSchemaName; + }); +}; + export const resolveOracleExactCaseTableReference = ( statement: string, currentDb: string, @@ -2330,6 +2357,7 @@ export const resolveQueryLocatorPlan = async ({ currentDb, config, forceReadOnly, + allowOracleRowID = true, }: { statement: string; originalStatement?: string; @@ -2337,6 +2365,7 @@ export const resolveQueryLocatorPlan = async ({ currentDb: string; config: any; forceReadOnly: boolean; + allowOracleRowID?: boolean; }): Promise => { const plan: QueryStatementPlan = { originalSql: originalStatement || statement, @@ -2452,7 +2481,7 @@ export const resolveQueryLocatorPlan = async ({ const uniqueKeyGroup = uniqueKeyGroups.find((group) => group.length > 0); if (uniqueKeyGroup) { plan.editLocator = buildColumnLocator('unique-key', uniqueKeyGroup); - } else if (isOracleLikeDialect(dbType)) { + } else if (allowOracleRowID && isOracleLikeDialect(dbType)) { needsOracleRowIDExpression = true; plan.editLocator = { strategy: 'oracle-rowid',