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;
}
@@ -42,6 +42,7 @@ function index(columns: string[], isUnique = true, filter: string | null = null)
describe("tableEditing", () => {
it("synthesizes ROWID only for Oracle-compatible base tables", () => {
expect(editablePrimaryKeys("oracle", [column("ID"), column("NAME")])).toEqual([]);
expect(editablePrimaryKeys("oracle", [column("ID"), column("NAME")], "VIEW")).toEqual([]);
expect(editablePrimaryKeys("oracle", [column("ID"), column("NAME")], "TABLE")).toEqual([DBX_ROWID_COLUMN]);
expect(editablePrimaryKeys("oceanbase-oracle", [column("ID"), column("NAME")], "TABLE")).toEqual([DBX_ROWID_COLUMN]);
@@ -82,6 +83,9 @@ describe("tableEditing", () => {
});
it("does not include Oracle ROWID for view data tabs", () => {
expect(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN])).toBe(true);
expect(shouldIncludeSyntheticRowId("oracle", [DBX_ROWID_COLUMN])).toBe(false);
expect(shouldIncludeSyntheticRowId("oracle", [DBX_ROWID_COLUMN], "TABLE")).toBe(true);
expect(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN], "VIEW")).toBe(false);
expect(usesSyntheticRowIdKey("oracle", [DBX_ROWID_COLUMN], "MATERIALIZED_VIEW")).toBe(false);
expect(usesSyntheticRowIdKey("oceanbase-oracle", [DBX_ROWID_COLUMN], "TABLE")).toBe(true);
+10 -2
View File
@@ -9,6 +9,10 @@ function isViewTableType(tableType?: string): boolean {
return tableType?.toUpperCase().includes("VIEW") === true;
}
function isKnownOracleBaseTableType(tableType?: string): boolean {
return tableType?.trim().toUpperCase() === "TABLE";
}
export function isTdengineStableTableType(tableType?: string): boolean {
const normalized = tableType?.trim().toUpperCase();
return normalized === "STABLE" || normalized === "SUPER TABLE" || normalized === "SUPERTABLE";
@@ -19,7 +23,8 @@ export function editablePrimaryKeys(databaseType: DatabaseType | undefined, colu
if (isViewTableType(tableType)) return primaryKeys;
if (databaseType === "tdengine" && primaryKeys.length > 0 && isTdengineStableTableType(tableType)) return [DBX_TDENGINE_TBNAME_COLUMN, ...primaryKeys];
const syntheticKey = getDatabaseCapability(databaseType).syntheticKey;
if ((syntheticKey === "oracle-rowid" || syntheticKey === "xugu-rowid") && primaryKeys.length === 0) return [DBX_ROWID_COLUMN];
if (syntheticKey === "oracle-rowid" && primaryKeys.length === 0 && isKnownOracleBaseTableType(tableType)) return [DBX_ROWID_COLUMN];
if (syntheticKey === "xugu-rowid" && primaryKeys.length === 0) return [DBX_ROWID_COLUMN];
if (syntheticKey === "neo4j-element-id" && primaryKeys.length === 0) return [DBX_NEO4J_ELEMENT_ID_COLUMN];
return primaryKeys;
}
@@ -93,7 +98,9 @@ export function hiveTablePropertiesIndicateTransactional(result: { rows: readonl
export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean {
if (isViewTableType(tableType)) return false;
const syntheticKey = getDatabaseCapability(databaseType).syntheticKey;
return primaryKeys.length === 1 && (((syntheticKey === "oracle-rowid" || syntheticKey === "xugu-rowid") && primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN) || (syntheticKey === "neo4j-element-id" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN));
if (primaryKeys.length !== 1) return false;
if (syntheticKey === "oracle-rowid" || syntheticKey === "xugu-rowid") return primaryKeys[0].toUpperCase() === DBX_ROWID_COLUMN;
return syntheticKey === "neo4j-element-id" && primaryKeys[0] === DBX_NEO4J_ELEMENT_ID_COLUMN;
}
/**
@@ -103,6 +110,7 @@ export function usesSyntheticRowIdKey(databaseType: DatabaseType | undefined, pr
*/
export function shouldIncludeSyntheticRowId(databaseType: DatabaseType | undefined, primaryKeys: string[], tableType?: string): boolean {
if (isViewTableType(tableType)) return false;
if (getDatabaseCapability(databaseType).syntheticKey === "oracle-rowid" && !isKnownOracleBaseTableType(tableType)) return false;
if (usesSyntheticRowIdKey(databaseType, primaryKeys, tableType)) return true;
return databaseType === "xugu" && primaryKeys.length === 0;
}
@@ -773,7 +773,7 @@ describe("queryStore hidden primary key editing", () => {
expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT * FROM CUSTOMER_VIEW", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 }));
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.result?.hidden_column_indexes).toBeUndefined();
await vi.waitFor(() => expect(tab.queryEditabilityReason).toBe("primary-key-not-returned"));
await vi.waitFor(() => expect(tab.queryEditabilityReason).toBe("no-primary-key"));
expect(tab.queryAnalysis).toBeUndefined();
});
+28 -3
View File
@@ -4676,6 +4676,30 @@ export const useQueryStore = defineStore("query", () => {
oracleLobPreview: boolean;
}
function oracleCompletionTableType(tab: QueryTab, metadataDbType: string, database: string, schema: string, tableName: string, catalog?: string): string | undefined {
if (metadataDbType !== "oracle" && metadataDbType !== "oceanbase-oracle") return undefined;
const resolvedSchema = schema.trim();
if (!resolvedSchema) return undefined;
const normalizeIdentifier = (value: string | undefined) => value?.trim().toLowerCase() ?? "";
const targetName = normalizeIdentifier(tableName);
const targetSchema = normalizeIdentifier(resolvedSchema);
const targetCatalog = catalog?.trim() ? normalizeIdentifier(catalog) : undefined;
const matches = useConnectionStore()
.lookupLocalCompletionTables(tab.connectionId!, database, tableName, 20, resolvedSchema, catalog)
.filter((table) => normalizeIdentifier(table.name) === targetName && normalizeIdentifier(table.schema) === targetSchema && (!targetCatalog || normalizeIdentifier(table.catalog) === targetCatalog));
if (matches.length !== 1) return undefined;
const match = matches[0]!;
return match.tableType?.trim() || match.type?.toUpperCase();
}
function canUseQueryKeylessRowPredicate(databaseType: DatabaseType, loaded: LoadedEditableSource): boolean {
if (!canUseKeylessRowPredicate(databaseType, loaded.tableMeta.primaryKeys)) return false;
// An unknown Oracle object may be a view whose query shape rejects ROWID
// and whose rows cannot be mapped safely for writes. Keep the result
// read-only until the object tree or tab metadata confirms its type.
return databaseType !== "oracle" || !!loaded.tableMeta.tableType?.trim();
}
function applyQueryMetadataPatch(tab: QueryTab, patch: QueryMetadataPatch) {
tab.queryAnalysis = patch.queryAnalysis;
tab.querySourceColumns = patch.querySourceColumns;
@@ -4730,7 +4754,8 @@ export const useQueryStore = defineStore("query", () => {
// Keep SQL Server writes unqualified unless the SELECT source explicitly
// named a schema, so SELECT and UPDATE resolve the same object.
const writeSchema = dbType === "sqlserver" && !source.schema ? undefined : metadataSchema || undefined;
const knownTableType = tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined;
const localTableType = oracleCompletionTableType(tab, metadataDbType, metadataDatabase, metadataSchema || conn?.default_schema || "", metadataTableName, metadataCatalog);
const knownTableType = localTableType ?? (tab.tableMeta?.tableName.toLowerCase() === metadataTableName.toLowerCase() && normalizeOptionalSchema(tab.tableMeta.schema) === normalizeOptionalSchema(metadataSchema) ? tab.tableMeta.tableType : undefined);
return {
source: metadataSource,
analysis: normalizeUppercaseFoldedQueryAnalysis(metadataDbType, cloneAnalysisForSource(analysis, metadataSource), metadataSchema || undefined, metadataTableName),
@@ -5129,7 +5154,7 @@ export const useQueryStore = defineStore("query", () => {
const primaryKeys = loaded.tableMeta.primaryKeys;
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result!.columns, loaded.source.key, dbType as DatabaseType, primaryKeys);
const primaryKeysPresent = primaryKeysPresentForSource(dbType, primaryKeys, tab.result!.columns, metadataAnalysis, loaded.source.key, loaded.tableMeta.columns);
const keylessAllowed = sources.length === 1 && canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys);
const keylessAllowed = sources.length === 1 && canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded);
const primaryKeySet = new Set(primaryKeys);
const editableSourceColumnCount = (sourceColumns ?? []).filter((column) => column && !primaryKeySet.has(column)).length;
return {
@@ -5154,7 +5179,7 @@ export const useQueryStore = defineStore("query", () => {
const resultIndex = tab.result.columns.findIndex((column) => column.toLowerCase() === syntheticRowIdProjection.alias.toLowerCase());
if (resultIndex >= 0) sourceColumns[resultIndex] = DBX_ROWID_COLUMN;
}
if (primaryKeys.length === 0 && !canUseKeylessRowPredicate(dbType as DatabaseType, primaryKeys)) {
if (primaryKeys.length === 0 && !canUseQueryKeylessRowPredicate(dbType as DatabaseType, loaded)) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,