fix(editor): hide commit/rollback for clean mysql/pg manual transactions

Closes #9018
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-16 20:17:55 +08:00
1 parent 91b8fa0aa8
commit e9be6d0419
15 files changed
+1016 -137

No files matched your search

@@ -1171,7 +1171,11 @@ describe("queryStore hidden primary key editing", () => {
await store.executeTabSql(tabId, "SELECT name FROM users");
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT name, `id` AS `__DBX_PK_0` FROM users", "app", undefined, expect.any(Number), false, undefined, undefined, undefined);
// MySQL is a sticky proven-read-only dialect (#9018): the call now ends
// with the user-facing classification SQL (9th argument), but still opts
// out of table-data preview. Every slot stays asserted — the spec's mocks
// are fixed, so database/schema/row-limit/cursor args must not drift.
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT name, `id` AS `__DBX_PK_0` FROM users", "app", undefined, 100000, false, undefined, undefined, "SELECT name FROM users");
});
it("keeps a keyless Oracle query editable when its WHERE clause reads another table", async () => {
@@ -59,17 +59,17 @@ function installLocalStorage() {
});
}
type OracleResult = Record<string, unknown>;
type StickyResult = Record<string, unknown>;
function cleanSelect(): OracleResult[] {
function cleanSelect(): StickyResult[] {
return [{ columns: ["VALUE"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, manual_transaction_proven_read_only: true }];
}
function dirtyUpdate(): OracleResult[] {
function dirtyUpdate(): StickyResult[] {
return [{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }];
}
function noOpResult(): OracleResult[] {
function noOpResult(): StickyResult[] {
return [{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1, manual_transaction_no_statement: true }];
}
@@ -86,7 +86,7 @@ function expiredTransactionError() {
};
}
describe("queryStore Oracle manual-transaction sticky state", () => {
describe("queryStore Oracle/OceanBase manual-transaction sticky state (behavior frozen)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
@@ -144,7 +144,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
await store.executeTabSql(tabId, "SELECT * FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnPossiblyDirty).not.toBe(true);
expect(tab.txnSessionId).toBe("txn-oracle");
});
@@ -158,7 +158,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).toBe(true);
expect(tab.txnPossiblyDirty).toBe(true);
});
it("marks an Oracle manual session dirty when the result grid dispatches a mutation", async () => {
@@ -172,7 +172,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
store.markManualTransactionDirty(tabId);
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).toBe(true);
expect(tab.txnPossiblyDirty).toBe(true);
});
it("never clears the sticky state after a later read (UPDATE -> SELECT)", async () => {
@@ -186,7 +186,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
await store.executeTabSql(tabId, "SELECT * FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).toBe(true);
expect(tab.txnPossiblyDirty).toBe(true);
});
it("dirties the session for any unproven statement in a mixed script", async () => {
@@ -202,7 +202,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
await store.executeTabSql(tabId, "SELECT * FROM DUAL; DELETE FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).toBe(true);
expect(tab.txnPossiblyDirty).toBe(true);
});
it("treats a Core no-op result as neither dirty nor clean-changing", async () => {
@@ -217,7 +217,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
await store.executeTabSql(tabId, "SELECT * FROM EMP");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnPossiblyDirty).not.toBe(true);
});
it("passes no classificationSql and changes no sticky state on a cursor-page fetch", async () => {
@@ -242,7 +242,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
expect(args.at(-1)).toBeUndefined();
const tab = store.tabs.find((item) => item.id === tabId)!;
// Page fetch must neither set nor clear the dirty bit.
expect(tab.oracleTxnPossiblyDirty).toBe(true);
expect(tab.txnPossiblyDirty).toBe(true);
});
it("resets the sticky state on session-expiry recovery and recomputes from the retry", async () => {
@@ -258,7 +258,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnSessionId).toBe("txn-new");
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnPossiblyDirty).not.toBe(true);
});
it("clears the sticky state together with the session on DBX rollback", async () => {
@@ -273,7 +273,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnSessionId).toBeUndefined();
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnPossiblyDirty).not.toBe(true);
});
it("clears session and sticky state when a statement failure disposes the manual session", async () => {
@@ -290,7 +290,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
// The `rolled back` fragment is the cleanup compatibility contract: the
// backend removed the manual session, so both fields must reset with it.
expect(tab.txnSessionId).toBeUndefined();
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnPossiblyDirty).not.toBe(true);
expect(tab.txnAutoRolledBack).not.toBe(true);
});
@@ -307,7 +307,7 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
// The statement may still have executed server-side while the manual
// session survives, so the sticky state must stay fail-closed dirty.
expect(tab.txnSessionId).toBe("txn-oracle");
expect(tab.oracleTxnPossiblyDirty).toBe(true);
expect(tab.txnPossiblyDirty).toBe(true);
});
it("keeps a clean session clean when a cursor-page fetch times out", async () => {
@@ -325,34 +325,126 @@ describe("queryStore Oracle manual-transaction sticky state", () => {
const tab = store.tabs.find((item) => item.id === tabId)!;
// Page fetches never participate in the sticky state, matching aggregation.
expect(tab.txnSessionId).toBe("txn-oracle");
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
expect(tab.txnPossiblyDirty).not.toBe(true);
});
it("leaves non-Oracle manual sessions using the old rule and no marker handling", async () => {
it("keeps the legacy toolbar rule for dialects without sticky state", async () => {
mocks.getConnectionConfig.mockReturnValue({
id: "pg-1",
name: "Postgres",
db_type: "postgres",
database: "postgres",
id: "jdbc-1",
name: "JDBC",
db_type: "jdbc",
database: "appdb",
query_timeout_secs: 30,
});
mocks.beginManualTransaction.mockResolvedValue("txn-pg");
// A Postgres manual execution never returns the Oracle marker.
mocks.beginManualTransaction.mockResolvedValue("txn-jdbc");
mocks.executeInManualTransaction.mockResolvedValue([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]);
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "postgres", "Query", "query", "public");
const tabId = store.createTab("jdbc-1", "appdb", "Query", "query", "main");
store.setAutoCommit(tabId, false);
await store.executeTabSql(tabId, "SELECT 1");
store.markManualTransactionDirty(tabId);
const tab = store.tabs.find((item) => item.id === tabId)!;
// No classificationSql was sent and no Oracle sticky state exists.
// No classificationSql is sent and no sticky state exists for non-sticky
// dialects: commit/rollback follow the legacy always-visible rule.
const args = mocks.executeInManualTransaction.mock.calls[0]!;
expect(args.at(-1)).toBeUndefined();
expect(tab.oracleTxnPossiblyDirty).toBeUndefined();
expect(tab.txnSessionId).toBe("txn-pg");
expect(tab.txnPossiblyDirty).toBeUndefined();
expect(tab.txnSessionId).toBe("txn-jdbc");
});
});
describe.each([
{ label: "MySQL", configId: "mysql-1", database: "test", dbType: "mysql", txnId: "txn-mysql" },
{ label: "PostgreSQL", configId: "pg-1", database: "postgres", dbType: "postgres", txnId: "txn-pg" },
])("queryStore $label proven-read-only sticky state", ({ configId, database, dbType, txnId }) => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
setActivePinia(createPinia());
mocks.getConnectionConfig.mockReturnValue({
id: configId,
name: dbType,
db_type: dbType,
database,
query_timeout_secs: 30,
});
mocks.prepareQueryPaginationExecutionPlan.mockImplementation(async (options) => ({
sqlToExecute: options.sql,
pageSql: undefined,
pageLimit: undefined,
pageOffset: undefined,
countSql: undefined,
useAgentResultSession: false,
}));
mocks.analyzeEditableQueryEditability.mockResolvedValue({ editable: false, reason: "not-select" });
mocks.saveOpenTabsState.mockResolvedValue(undefined);
mocks.beginManualTransaction.mockResolvedValue(txnId);
});
async function setupStickyManualTab(store: { createTab: (c: string, d: string, t: string, m: string, s: string) => string }) {
const tabId = store.createTab(configId, database, "Query", "query", "APP");
store.setAutoCommit(tabId, false);
return tabId;
}
it("sends classificationSql for the initial manual execution and stays clean on a proven read", async () => {
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupStickyManualTab(store);
await store.executeTabSql(tabId, "SELECT * FROM users");
const args = mocks.executeInManualTransaction.mock.calls[0]!;
expect(args.at(-1)).toBe("SELECT * FROM users");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnPossiblyDirty).not.toBe(true);
expect(tab.txnSessionId).toBe(txnId);
});
it("dirties the session after an unproven statement", async () => {
mocks.executeInManualTransaction.mockResolvedValue(dirtyUpdate());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupStickyManualTab(store);
await store.executeTabSql(tabId, "UPDATE users SET name = 'x'");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnPossiblyDirty).toBe(true);
});
it("keeps a comment-only script from dirtying the session (core no-op)", async () => {
mocks.executeInManualTransaction.mockResolvedValue(noOpResult());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupStickyManualTab(store);
await store.executeTabSql(tabId, " -- only a comment\n");
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnPossiblyDirty).not.toBe(true);
});
it("marks the session dirty when the result grid dispatches a mutation", async () => {
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = await setupStickyManualTab(store);
await store.executeTabSql(tabId, "SELECT * FROM users");
store.markManualTransactionDirty(tabId);
const tab = store.tabs.find((item) => item.id === tabId)!;
expect(tab.txnPossiblyDirty).toBe(true);
});
});
+31 -29
View File
@@ -39,7 +39,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 { defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
import { defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
import { TABLE_DATA_EXPORT_PAGE_SIZE } from "@/lib/table/tableDataExport";
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
@@ -87,7 +87,7 @@ import { appendDebugLog } from "@/lib/backend/debugLog";
import { BackendErrorException, formatError, isManualTransactionSessionExpired, isUnsupportedManualTransactionMethod, normalizeBackendError, type BackendError } from "@/lib/backend/errorUtils";
import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition";
import { isDetachedWindow, resolveWindowContext } from "@/lib/app/windowContext";
import type { DetachedTabHandoff, DetachedTabRuntimeState } from "@/lib/app/detachedTabHandoff";
import { normalizeDetachedTabRuntime, type DetachedTabHandoff, type DetachedTabRuntimeState } from "@/lib/app/detachedTabHandoff";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
@@ -2486,7 +2486,7 @@ export const useQueryStore = defineStore("query", () => {
editorSelection: tab.editorSelection,
txnSessionId: tab.txnSessionId,
txnAutoRolledBack: tab.txnAutoRolledBack,
oracleTxnPossiblyDirty: tab.oracleTxnPossiblyDirty,
txnPossiblyDirty: tab.txnPossiblyDirty,
...runtime,
},
...(snapshot ? { resultCacheKey } : {}),
@@ -2541,7 +2541,7 @@ export const useQueryStore = defineStore("query", () => {
restoredTab.originalSql = file.sql;
}
}
Object.assign(restoredTab, handoff.runtime);
Object.assign(restoredTab, normalizeDetachedTabRuntime(handoff.runtime));
if (handoff.resultCacheKey) {
restoredTab.resultCacheKey = handoff.resultCacheKey;
const restoredResult = restoreCachedResultPayload(restoredTab, await readTabResultSnapshot(handoff.resultCacheKey));
@@ -4614,15 +4614,15 @@ export const useQueryStore = defineStore("query", () => {
const tab = tabs.value.find((item) => item.id === id);
if (!tab?.txnSessionId) return;
const dbType = effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId));
if (usesOracleStickyTransactionState(dbType)) tab.oracleTxnPossiblyDirty = true;
if (usesProvenReadOnlyStickyTransactionState(dbType)) tab.txnPossiblyDirty = true;
}
/** Reset only the Oracle sticky-dirty bit. Used when a session continues but
* the old dirty state must be discarded (e.g. idle-expiry recovery where the
* replacement session starts fresh). Full session cleanup goes through
* `clearManualTransactionSession`. */
function clearOracleTxnPossiblyDirty(tab: { oracleTxnPossiblyDirty?: boolean }) {
if (tab.oracleTxnPossiblyDirty !== undefined) tab.oracleTxnPossiblyDirty = false;
/** Reset only the sticky proven-read-only dirty bit. Used when a session
* continues but the old dirty state must be discarded (e.g. idle-expiry
* recovery where the replacement session starts fresh). Full session cleanup
* goes through `clearManualTransactionSession`. */
function clearTxnPossiblyDirty(tab: { txnPossiblyDirty?: boolean }) {
if (tab.txnPossiblyDirty !== undefined) tab.txnPossiblyDirty = false;
}
/** Centralized manual-session cleanup. Clears every field tied to a manual
@@ -4631,7 +4631,7 @@ export const useQueryStore = defineStore("query", () => {
function clearManualTransactionSession(tab: QueryTab) {
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = false;
if (tab.oracleTxnPossiblyDirty !== undefined) tab.oracleTxnPossiblyDirty = false;
if (tab.txnPossiblyDirty !== undefined) tab.txnPossiblyDirty = false;
}
function rollbackTabTransaction(tab: QueryTab, options?: { resetAutoCommit?: boolean; resetAutoCommitDbType?: string }) {
@@ -4644,7 +4644,7 @@ export const useQueryStore = defineStore("query", () => {
const dbType = options.resetAutoCommitDbType ?? useConnectionStore().getConfig(tab.connectionId)?.db_type;
tab.autoCommit = defaultAutoCommitForDbTypeWithSetting(dbType);
}
clearOracleTxnPossiblyDirty(tab);
clearTxnPossiblyDirty(tab);
tab.txnAutoRolledBack = false;
}
@@ -7059,10 +7059,11 @@ export const useQueryStore = defineStore("query", () => {
} else {
queryExecutionLog("info", "execute-in-txn:invoke", { traceId, txnSessionId: tab.txnSessionId, elapsed: elapsed() });
executionDispatched = true;
// Only an initial manual execution classifies the user SQL (Oracle-only).
// A later cursor-page fetch must neither set nor clear the sticky bit.
const isInitialOracleManualExecution = usesOracleStickyTransactionState(effectiveDbType) && !options?.pagination?.sessionId;
const classificationSql = isInitialOracleManualExecution ? queryBaseSql : undefined;
// Only an initial manual execution classifies the user SQL (sticky
// proven-read-only dialects). A later cursor-page fetch must neither
// set nor clear the sticky bit.
const isInitialStickyClassification = usesProvenReadOnlyStickyTransactionState(effectiveDbType) && !options?.pagination?.sessionId;
const classificationSql = isInitialStickyClassification ? queryBaseSql : undefined;
let manualTransactionRecoveryAttempted = false;
executionPromise = (async () => {
const txnSessionId = tab.txnSessionId;
@@ -7082,7 +7083,7 @@ export const useQueryStore = defineStore("query", () => {
manualTransactionRecoveryAttempted = true;
// The expired session was discarded by the backend; the replacement
// session starts fresh, so the old sticky state resets with it.
clearOracleTxnPossiblyDirty(tab);
clearTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = true;
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
@@ -7107,16 +7108,17 @@ export const useQueryStore = defineStore("query", () => {
);
const results = offsetBatchQueryResultIndexes(annotatedResults.results, batchResume?.startStatementIndex ?? 0);
reconcileBatchSqlResults(tab, executionId, results);
// Oracle-only sticky state aggregation. Only the initial manual execution
// participates: a later cursor-page fetch (pagination.sessionId present)
// must neither set nor clear the bit, and the Core no-op (empty script)
// must neither set nor clear it. Otherwise any result that is not proven
// read-only dirties the session monotonically.
if (tab.autoCommit === false && usesOracleStickyTransactionState(effectiveDbType) && !options?.pagination?.sessionId && tab.txnSessionId) {
// Sticky proven-read-only aggregation (Oracle/OceanBase-Oracle/MySQL/PG).
// Only the initial manual execution participates: a later cursor-page
// fetch (pagination.sessionId present) must neither set nor clear the
// bit, and the Core no-op (empty script) must neither set nor clear it.
// Otherwise any result that is not proven read-only dirties the session
// monotonically.
if (tab.autoCommit === false && usesProvenReadOnlyStickyTransactionState(effectiveDbType) && !options?.pagination?.sessionId && tab.txnSessionId) {
const rawResults = annotatedResults.results;
const isCoreNoOp = rawResults.length > 0 && rawResults.every((result) => result.manual_transaction_no_statement === true);
if (!isCoreNoOp && rawResults.some((result) => result.manual_transaction_proven_read_only !== true)) {
tab.oracleTxnPossiblyDirty = true;
tab.txnPossiblyDirty = true;
}
}
const successfulOracleSchemaChanges = usesOracleStickyTransactionState(effectiveDbType) ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0;
@@ -7341,21 +7343,21 @@ export const useQueryStore = defineStore("query", () => {
if (idleTimeout) {
// Backend session was removed and rolled back after idle expiry: clear
// the sticky dirty state together with the session.
clearOracleTxnPossiblyDirty(tab);
clearTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = true;
} else if (/rolled.?back/i.test(errMsg) || /transaction session not found/i.test(errMsg) || /agent runtime terminated/i.test(errMsg)) {
// Statement failure that disposed the manual session: the `rolled back`
// message fragment is a frontend cleanup compatibility contract.
clearOracleTxnPossiblyDirty(tab);
clearTxnPossiblyDirty(tab);
tab.txnSessionId = undefined;
tab.txnAutoRolledBack = false;
} else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && usesOracleStickyTransactionState(effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)))) {
} else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && usesProvenReadOnlyStickyTransactionState(effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)))) {
// Frontend timeout/cancel or mid-script failure: the statement may still
// have executed server-side while the manual session survives, so keep
// the sticky dirty state fail-closed instead of a clean toolbar on a
// dirty session. Cursor-page fetches stay excluded like the aggregation.
tab.oracleTxnPossiblyDirty = true;
tab.txnPossiblyDirty = true;
}
}
const current = findExecutionTab(id);