fix(oracle): enable deleting keyless query rows

This commit is contained in:
t8y2 committed 2026-07-14 01:10:12 +08:00
1 parent 8497e34b31
commit e74af3e85f
4 files changed
+142 -17

No files matched your search

@@ -6,6 +6,7 @@ const executeQuery = vi.fn();
const analyzeEditableQueryEditability = vi.fn();
const getColumns = vi.fn();
const listIndexes = vi.fn();
const listObjects = vi.fn();
const getConnectionConfig = vi.fn();
const buildSortedQuerySql = vi.fn();
const buildDataGridCountSql = vi.fn();
@@ -32,6 +33,7 @@ vi.mock("@/lib/backend/api", () => ({
executeQuery,
getColumns,
listIndexes,
listObjects,
prepareQueryPaginationExecutionPlan,
saveOpenTabsState: vi.fn().mockResolvedValue(undefined),
}));
@@ -75,6 +77,7 @@ describe("queryStore hidden primary key editing", () => {
{ name: "name", data_type: "varchar", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
]);
listIndexes.mockResolvedValue([]);
listObjects.mockResolvedValue([]);
analyzeEditableQueryEditability.mockImplementation(async (sql: string) => queryAnalysis(sql));
buildSortedQuerySql.mockImplementation(async (options) => ({ ok: true, sql: `${options.originalSql} ORDER BY ${options.column} ${options.direction.toUpperCase()}` }));
buildDataGridCountSql.mockResolvedValue("SELECT COUNT(*) FROM `users`");
@@ -120,6 +123,93 @@ describe("queryStore hidden primary key editing", () => {
expect(tab.queryEditabilityReason).toBeUndefined();
});
it("uses a hidden Oracle ROWID to keep keyless base-table query results editable", async () => {
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 });
getColumns.mockResolvedValue([
{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "PLATFORM", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
]);
listObjects.mockResolvedValue([{ name: "TT_PLATFORM_CARS", object_type: "TABLE", schema: "SH_SMCVDMS_OVERSEAS_DRSSITB" }]);
analyzeEditableQueryEditability.mockImplementation(async (sql: string) => {
const hidden = sql.includes("__DBX_PK_0");
return {
editable: true,
analysis: {
schema: "SH_SMCVDMS_OVERSEAS_DRSSITB",
tableName: "TT_PLATFORM_CARS",
tableAlias: "t",
selectStar: !hidden,
columns: hidden
? [
{ star: true, sourceQualifier: "t", sourceKey: "t:0", resultName: "*", expression: "t.*" },
{ resultName: "__DBX_PK_0", expression: "ROWIDTOCHAR(ROWID)" },
]
: [],
},
};
});
executeMulti.mockResolvedValue([
{
columns: ["ID", "PLATFORM", "__DBX_PK_0"],
rows: [[72, "轻卡", "AAAPr9AAEAAAACXAAA"]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query");
await store.executeTabSql(tabId, "SELECT t.* FROM SH_SMCVDMS_OVERSEAS_DRSSITB.TT_PLATFORM_CARS t WHERE t.PLATFORM = '轻卡'");
expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT t.*, ROWIDTOCHAR(ROWID) AS \"__DBX_PK_0\" FROM SH_SMCVDMS_OVERSEAS_DRSSITB.TT_PLATFORM_CARS t WHERE t.PLATFORM = '轻卡'", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 }));
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.result?.hidden_column_indexes).toEqual([2]);
await vi.waitFor(() => expect(tab.querySourceColumns).toEqual(["ID", "PLATFORM", "__DBX_ROWID"]));
expect(tab.tableMeta?.primaryKeys).toEqual(["__DBX_ROWID"]);
expect(tab.queryAnalysis).toBeDefined();
expect(tab.queryAnalysis?.allowInsert).toBe(false);
expect(tab.queryEditabilityReason).toBeUndefined();
});
it("does not inject Oracle ROWID into keyless view queries", async () => {
getConnectionConfig.mockReturnValue({ id: "oracle-1", name: "Oracle", db_type: "oracle", database: "ORCL", query_timeout_secs: 30 });
getColumns.mockResolvedValue([
{ name: "ID", data_type: "NUMBER", is_nullable: false, column_default: null, is_primary_key: false, extra: null },
{ name: "PLATFORM", data_type: "VARCHAR2(100)", is_nullable: true, column_default: null, is_primary_key: false, extra: null },
]);
listIndexes.mockResolvedValue([]);
listObjects.mockResolvedValue([{ name: "PLATFORM_VIEW", object_type: "VIEW", schema: "APP" }]);
analyzeEditableQueryEditability.mockResolvedValue({
editable: true,
analysis: {
schema: "APP",
tableName: "PLATFORM_VIEW",
selectStar: true,
columns: [],
},
});
executeMulti.mockResolvedValue([
{
columns: ["ID", "PLATFORM"],
rows: [[72, "轻卡"]],
affected_rows: 0,
execution_time_ms: 1,
},
]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("oracle-1", "ORCL", "Query");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.tableMeta = { schema: "APP", tableName: "PLATFORM_VIEW", tableType: "VIEW", columns: [], primaryKeys: [] };
await store.executeTabSql(tabId, "SELECT * FROM APP.PLATFORM_VIEW");
expect(executeMulti).toHaveBeenCalledWith("oracle-1", "ORCL", "SELECT * FROM APP.PLATFORM_VIEW", undefined, expect.any(String), expect.objectContaining({ timeoutSecs: 30 }));
expect(tab.result?.hidden_column_indexes).toBeUndefined();
});
it("keeps hidden primary keys and editability after database sorting", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
+35 -15
View File
@@ -28,7 +28,7 @@ import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest";
import { canUseKeylessRowPredicate, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase";
@@ -2209,6 +2209,14 @@ export const useQueryStore = defineStore("query", () => {
return primaryKeys.filter((primaryKey) => !selectedColumns.has(primaryKey.toLowerCase()));
}
async function oracleRowIdIsSafeForQuery(tab: QueryTab, loaded: LoadedEditableSource): Promise<boolean> {
const knownType = loaded.tableMeta.tableType?.trim().toUpperCase();
if (knownType) return knownType === "TABLE";
const objects = await api.listObjects(tab.connectionId!, tab.database, loaded.tableMeta.schema ?? "", ["TABLE", "VIEW", "MATERIALIZED_VIEW"], loaded.tableMeta.tableName, 20, 0, loaded.tableMeta.catalog);
const matching = objects.find((object) => object.name.toLowerCase() === loaded.tableMeta.tableName.toLowerCase());
return matching?.object_type.trim().toUpperCase() === "TABLE";
}
async function prepareEditableQueryExecution(tab: QueryTab, sql: string, conn: ConnectionConfig | undefined, databaseType: DatabaseType | undefined, traceId: string, elapsed: () => string): Promise<EditableQueryExecutionPreparation> {
const unchanged = { sql, metadataSql: sql, hiddenPrimaryKeys: [] };
if (!databaseType || !HIDDEN_QUERY_KEY_DATABASE_TYPES.has(databaseType) || !tab.connectionId || !tab.database) return unchanged;
@@ -2218,27 +2226,32 @@ export const useQueryStore = defineStore("query", () => {
if (!editability.editable || !editability.analysis) return unchanged;
const analysis = editability.analysis;
const sources = editableQuerySources(analysis);
if (sources.length !== 1 || analysis.distinct || analysis.selectStar) return unchanged;
if (sources.length !== 1 || analysis.distinct) return unchanged;
const loaded = await loadEditableQuerySource(tab, analysis, sources[0]!, conn, databaseType, traceId, elapsed);
if (loaded.tableMeta.tableType?.toUpperCase().includes("VIEW")) return unchanged;
const metadataAnalysis = expandStarProjectionColumnsForSource(bindUnqualifiedColumnsForSource(loaded.analysis, loaded.source, loaded.tableMeta.columns), loaded.source, loaded.tableMeta.columns);
// Hidden row identifiers are limited to declared primary keys. Unique
// indexes, views, and synthetic ROWID fallbacks keep the prior behavior.
const primaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (primaryKeys.length === 0) return unchanged;
const declaredPrimaryKeys = loaded.tableMeta.columns.filter((column) => column.is_primary_key).map((column) => column.name);
// Oracle base tables without declared keys use the same ROWID identity as
// table-data tabs. Confirm the object is a base table because selecting
// ROWID from a view can fail with ORA-01445.
if (databaseType === "oracle" && declaredPrimaryKeys.length === 0 && !(await oracleRowIdIsSafeForQuery(tab, loaded))) return unchanged;
const primaryKeys = editablePrimaryKeys(databaseType, loaded.tableMeta.columns, loaded.tableMeta.tableType);
const missingPrimaryKeys = missingPrimaryKeysForSource(primaryKeys, metadataAnalysis, loaded.source.key);
const missingPrimaryKeys = declaredPrimaryKeys.length === 0 ? primaryKeys : missingPrimaryKeysForSource(primaryKeys, metadataAnalysis, loaded.source.key);
if (missingPrimaryKeys.length === 0) return unchanged;
const primaryKeySet = new Set(primaryKeys.map((primaryKey) => primaryKey.toLowerCase()));
const hasWritableProjection = metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName.toLowerCase()));
const hasWritableProjection = metadataAnalysis.selectStar
? loaded.tableMeta.columns.some((column) => !primaryKeySet.has(column.name.toLowerCase()))
: metadataAnalysis.columns.some((column) => column.sourceName && column.sourceKey === loaded.source.key && !primaryKeySet.has(column.sourceName.toLowerCase()));
if (!hasWritableProjection) return unchanged;
const rewritten = buildQueryWithHiddenPrimaryKeys({
sql,
databaseType,
primaryKeys: missingPrimaryKeys,
existingResultNames: metadataAnalysis.columns.map((column) => column.resultName),
existingResultNames: metadataAnalysis.selectStar ? loaded.tableMeta.columns.map((column) => column.name) : metadataAnalysis.columns.map((column) => column.resultName),
sourceExpressions: databaseType === "oracle" && missingPrimaryKeys.includes(DBX_ROWID_COLUMN) ? { [DBX_ROWID_COLUMN]: "ROWIDTOCHAR(ROWID)" } : undefined,
});
if (!rewritten) return unchanged;
console.info("[DBX][executeTabSql:hidden-primary-keys]", {
@@ -2256,7 +2269,7 @@ export const useQueryStore = defineStore("query", () => {
}
}
async function buildQueryMetadataPatch(tab: QueryTab, sql: string, traceId?: string, elapsed?: () => string): Promise<QueryMetadataPatch | undefined> {
async function buildQueryMetadataPatch(tab: QueryTab, sql: string, traceId?: string, elapsed?: () => string, hiddenPrimaryKeys: HiddenPrimaryKeyProjection[] = []): Promise<QueryMetadataPatch | undefined> {
if (tab.mode !== "query") return;
if (!tab.result || !tab.result.columns.length) {
return {
@@ -2330,7 +2343,13 @@ export const useQueryStore = defineStore("query", () => {
if (loadedSources.length === 1) {
const loaded = loadedSources[0]!;
const metadataAnalysis = expandStarProjectionColumnsForSource(bindUnqualifiedColumnsForSource(loaded.analysis, loaded.source, loaded.tableMeta.columns, allSourceColumns), loaded.source, loaded.tableMeta.columns);
const primaryKeys = loaded.tableMeta.primaryKeys;
const syntheticRowIdProjection = hiddenPrimaryKeys.find((projection) => projection.sourceName.toUpperCase() === DBX_ROWID_COLUMN);
const primaryKeys = loaded.tableMeta.primaryKeys.length === 0 && syntheticRowIdProjection ? [DBX_ROWID_COLUMN] : loaded.tableMeta.primaryKeys;
const sourceColumns = sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key);
if (sourceColumns && syntheticRowIdProjection) {
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)) {
return {
queryAnalysis: undefined,
@@ -2340,7 +2359,8 @@ export const useQueryStore = defineStore("query", () => {
};
}
if (!allPrimaryKeysPresent(primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key)) {
const primaryKeysPresent = syntheticRowIdProjection ? sourceColumns?.some((column) => column?.toUpperCase() === DBX_ROWID_COLUMN) === true : allPrimaryKeysPresent(primaryKeys, tab.result.columns, metadataAnalysis, loaded.source.key);
if (!primaryKeysPresent) {
return {
queryAnalysis: undefined,
querySourceColumns: undefined,
@@ -2360,9 +2380,9 @@ export const useQueryStore = defineStore("query", () => {
return {
queryAnalysis: metadataAnalysis,
querySourceColumns: sourceColumnsForResult(metadataAnalysis, tab.result.columns, loaded.source.key),
querySourceColumns: sourceColumns,
queryEditabilityReason: undefined,
tableMeta: loaded.tableMeta,
tableMeta: primaryKeys === loaded.tableMeta.primaryKeys ? loaded.tableMeta : { ...loaded.tableMeta, primaryKeys },
};
}
@@ -2412,7 +2432,7 @@ export const useQueryStore = defineStore("query", () => {
const tab = tabs.value.find((t) => t.id === tabId);
if (!tab || tab.result !== result) return;
console.info("[DBX][executeTabSql:metadata:start]", { traceId, elapsed: elapsed() });
const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed);
const patch = await buildQueryMetadataPatch(tab, sql, traceId, elapsed, hiddenPrimaryKeys);
if (patch?.queryAnalysis && hiddenPrimaryKeys.length > 0) {
patch.queryAnalysis = { ...patch.queryAnalysis, allowInsert: false };
}