fix(oracle): stop ROWID pagination injection on views

This commit is contained in:
zipg authored and GitHub committed 2026-09-09 22:35:51 +08:00
1 parent c8c7a40e46
commit b84d162049
11 files changed
+196 -17

No files matched your search

@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
setTableMeta: vi.fn(),
clearInvalidDataTabSort: vi.fn(),
activeResultExecutionTarget: vi.fn(),
lookupLocalCompletionTables: vi.fn(),
metadataGeneration: 0,
}));
@@ -47,6 +48,7 @@ vi.mock("@/stores/connectionStore", () => ({
getConfig: mocks.getConfig,
ensureConnected: mocks.ensureConnected,
metadataGenerationFor: () => mocks.metadataGeneration,
lookupLocalCompletionTables: mocks.lookupLocalCompletionTables,
}),
}));
@@ -145,6 +147,7 @@ describe("useDataGridActions", () => {
mocks.buildSortedQuerySql.mockResolvedValue({ ok: true, sql: "SELECT sorted" });
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.activeResultExecutionTarget.mockReturnValue(undefined);
mocks.lookupLocalCompletionTables.mockReturnValue([]);
mocks.getColumns.mockResolvedValue([{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, extra: null }]);
mocks.listIndexes.mockResolvedValue([]);
});
@@ -167,6 +170,39 @@ describe("useDataGridActions", () => {
expect(mocks.executeTabSql.mock.calls[0]?.[2]).not.toHaveProperty("preserveTotalRowCountDuringExecution");
});
it("repairs a restored Oracle view type before building paginated SQL", async () => {
mocks.getConfig.mockReturnValue({ id: "oracle-1", db_type: "oracle", default_schema: "REPORTING" });
mocks.lookupLocalCompletionTables.mockReturnValue([{ name: "REPORT_ROWS", schema: "REPORTING", type: "view" }]);
mocks.buildTableSelectSql.mockResolvedValueOnce('SELECT "ID" FROM "REPORTING"."REPORT_ROWS"');
const tab = tableDataTab({
connectionId: "oracle-1",
database: "XEPDB1",
title: "REPORT_ROWS",
tableMeta: {
schema: "REPORTING",
tableName: "REPORT_ROWS",
tableType: "TABLE",
columns: [{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
primaryKeys: ["__DBX_ROWID"],
},
});
mocks.tabs.push(tab);
const actions = useDataGridActions(computed(() => tab));
await actions.onReloadData(tab.id, tab.sql, "", "", "", 100, 100, "refresh");
expect(tab.tableMeta?.tableType).toBe("VIEW");
expect(mocks.buildTableSelectSql).toHaveBeenCalledWith(
expect.objectContaining({
tableName: "REPORT_ROWS",
tableType: "VIEW",
includeRowId: false,
limit: 100,
offset: 100,
}),
);
});
it("executes the WHERE apply SQL on the emitting tab (#8216)", async () => {
// ContentArea 的 executeSql 事件契约是 (tabId, sql),App.vue 按此顺序透传;
// 签名若仍是 sql-first,resolveActionTab 会拿 SQL 文本当 tab id 匹配失败,
@@ -565,6 +565,58 @@ describe("useSidebarDataOpenRuntime", () => {
expect(mocks.loadTableMetadata).not.toHaveBeenCalled();
});
it("repairs a restored Oracle view tab that was persisted as a table", async () => {
mocks.databaseType = "oracle";
const viewNode = { ...tableNode, id: "view-users", type: "view" as const, tableType: undefined };
mocks.tabs.push({
id: "existing-view-tab",
connectionId: "connection-1",
database: "app",
title: "users",
mode: "data",
schema: "public",
sql: "SELECT * FROM users",
isDirty: false,
isExecuting: true,
executionId: "running-query",
isCancelling: false,
isExplaining: false,
tableMeta: {
schema: "public",
tableName: "users",
tableType: "TABLE",
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
primaryKeys: ["__DBX_ROWID"],
},
tableMetaUpdatedAt: Date.now(),
tableMetaGeneration: 0,
} as QueryTab);
mocks.loadTableMetadata.mockImplementationOnce(async (request: { database: string; schema?: string; tableName: string; tableType?: string }) => ({
metadata: {
schema: request.schema,
tableName: request.tableName,
tableType: request.tableType,
database: request.database,
columns: [{ name: "id", data_type: "bigint", is_nullable: false, column_default: null, is_primary_key: false, extra: null }],
indexes: [],
primaryKeys: [],
cachedAt: Date.now(),
},
cacheStatus: "miss",
ageMs: 0,
}));
await useSidebarDataOpenRuntime().openData(viewNode);
expect(mocks.tabs[0]?.tableMeta?.tableType).toBe("VIEW");
expect(mocks.cancelTabExecution).not.toHaveBeenCalled();
expect(mocks.executeTabSql).not.toHaveBeenCalled();
await vi.waitFor(() => {
expect(mocks.loadTableMetadata).toHaveBeenCalledWith(expect.objectContaining({ tableName: "users", tableType: "VIEW" }));
expect(mocks.tabs[0]?.tableMeta?.primaryKeys).toEqual([]);
});
});
it("does not mark row identity pending on a warm metadata cache", async () => {
mocks.cachedMetadata = {
metadata: {
@@ -77,6 +77,26 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
return tab.resultPageLimit ?? tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize);
}
function reconcileOracleTableType(tab: QueryTab): void {
const config = connectionStore.getConfig(tab.connectionId);
const databaseType = effectiveDatabaseTypeForConnection(config);
if (databaseType !== "oracle" && databaseType !== "oceanbase-oracle") return;
const tableMeta = tab.tableMeta;
if (!tableMeta?.tableName) return;
const normalize = (value: string | undefined) => value?.trim().toLowerCase() ?? "";
const resolvedSchema = tableMeta.schema?.trim() || config?.default_schema?.trim();
const matches = connectionStore
.lookupLocalCompletionTables(tab.connectionId, tableMeta.database ?? tab.database, tableMeta.tableName, 20, resolvedSchema, tableMeta.catalog)
.filter((candidate) => normalize(candidate.name) === normalize(tableMeta.tableName) && normalize(candidate.schema) === normalize(resolvedSchema) && normalize(candidate.catalog) === normalize(tableMeta.catalog));
if (matches.length !== 1) return;
const objectType = matches[0]?.type;
const resolvedTableType = objectType === "view" ? "VIEW" : objectType === "materialized_view" ? "MATERIALIZED_VIEW" : objectType === "table" ? "TABLE" : undefined;
if (!resolvedTableType || tableMeta.tableType?.trim().toUpperCase() === resolvedTableType) return;
queryStore.setTableMeta(tab.id, { ...tableMeta, tableType: resolvedTableType });
}
function resultSortPagination(tab: QueryTab): { limit: number; offset: number } | undefined {
const limit = tab.resultPageLimit;
return typeof limit === "number" && limit > 0 ? { limit, offset: 0 } : undefined;
@@ -193,6 +213,7 @@ export function useDataGridActions(activeTab: ComputedRef<QueryTab | undefined>)
const startedAt = performance.now();
const elapsed = () => `${Math.round(performance.now() - startedAt)}ms`;
if (tab.mode === "data" && tableMetaForDataTab(tab)) {
reconcileOracleTableType(tab);
tab.whereInput = whereInput ?? "";
queryStore.clearInvalidDataTabSort(tab.id);
const realColumnNames = tab.tableMeta?.columns.map((column) => column.name) ?? [];
@@ -174,17 +174,25 @@ export function useSidebarDataOpenRuntime() {
if (existingSameTableTab && (existingSameTableTab.isExecuting || canActivateExistingDataTableTab(existingSameTableTab, { activateExecuting: false }))) {
if (node.comment !== undefined) existingSameTableTab.tableComment = node.comment;
const previousTableType = existingSameTableTab.tableMeta?.tableType?.trim().toUpperCase();
const tableTypeChanged = !!existingSameTableTab.tableMeta && previousTableType !== tableType;
if (tableTypeChanged) {
queryStore.setTableMeta(existingSameTableTab.id, {
...existingSameTableTab.tableMeta!,
tableType,
});
}
queryStore.switchTab(existingSameTableTab.id);
logPhase("existing-tab-activated", { table: node.label });
// 代次失配视同冷缓存(即使位于 30s TTL 窗口内也要重建):disconnect /
// 关库 / 死池重连都可能不改变 timestamp 判定而改变连接生命周期
const connectionGeneration = connectionStore.metadataGenerationFor(node.connectionId, node.database);
if (isDataTabMetadataLifecycleStale(existingSameTableTab, connectionGeneration) || dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) {
if (tableTypeChanged || isDataTabMetadataLifecycleStale(existingSameTableTab, connectionGeneration) || dataTabMetadataNeedsRefresh(existingSameTableTab, DATA_TAB_METADATA_TTL_MS)) {
// 真实列缺失时行标识未知:启动刷新的同时必须挂起编辑门控(所有
// "真实列缺失且启动刷新"的入口统一置 pending)
if (!existingSameTableTab.tableMeta?.columns.length) existingSameTableTab.tableMetaPending = true;
void refreshTableMetaInBackground(existingSameTableTab.id, true);
logPhase("metadata-started", { tabId: existingSameTableTab.id, reason: "existing-tab-stale" });
logPhase("metadata-started", { tabId: existingSameTableTab.id, reason: tableTypeChanged ? "object-type-changed" : "existing-tab-stale" });
}
return;
}