mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 00:33:28 +08:00
🐛 fix(query-editor): 修复 OceanBase 视图查询与输入丢字
- 仅对元数据确认的 OceanBase Oracle 基表注入 ROWID - 视图和同义词查询改为只读,避免触发 OBE-01445 - 在 SQL 补全中加入普通视图和物化视图候选 - 修复 beforeinput 已拦截时首个可打印字符被跳过的问题 - 补充视图、同义词和输入回归测试
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -411,7 +411,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({
|
||||
const duckdbSafeSelectCacheRef = useRef<Record<string, string>>({});
|
||||
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 (
|
||||
<div className={isV2Ui ? 'gn-v2-data-viewer' : undefined} style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(<QueryEditor tab={createTab({ query: '', dbName: 'APP' })} />);
|
||||
});
|
||||
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(<QueryEditor tab={createTab({ dbName: 'ORCLPDB1', query: 'SELECT * FROM person' })} />);
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
@@ -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<typeof buildViewSuggestionMeta>) => {
|
||||
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) {
|
||||
// 行定位计划失败绝不能阻断查询执行,兜底裸计划保证结果页始终呈现。
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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<QueryStatementPlan> => {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user