fix(oracle): stop ROWID pagination injection on views
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user