feat(oracle): hide commit/rollback for clean manual-transaction state
This commit is contained in:
1 parent
d2326acb81
commit
bd05c0350d
13 files changed
+729
-22
No files matched your search
@@ -341,6 +341,12 @@ const activeConnection = computed(() => {
|
||||
return tab ? connectionStore.getConfig(tab.connectionId) : undefined;
|
||||
});
|
||||
|
||||
// Oracle manual-mode indicator derived from the RESOLVED database type (an
|
||||
// Oracle connection uses the agent runtime but reports db_type "oracle"), so
|
||||
// the toolbar's Commit/Rollback visibility can apply the Oracle dirty-state rule
|
||||
// without inferring Oracle from the raw transport type.
|
||||
const isOracleManualTransaction = computed(() => effectiveDatabaseTypeForConnection(activeConnection.value) === "oracle" && (activeTab.value?.autoCommit ?? true) === false);
|
||||
|
||||
function updateAgentDriverUpdateCount(count: number) {
|
||||
if (!settingsStore.editorSettings.updateNotificationsEnabled) {
|
||||
agentDriverUpdateCount.value = 0;
|
||||
@@ -3039,6 +3045,8 @@ onUnmounted(() => {
|
||||
:auto-commit="activeTab.autoCommit ?? true"
|
||||
:txn-session-id="activeTab?.txnSessionId"
|
||||
:txn-auto-rolled-back="activeTab?.txnAutoRolledBack"
|
||||
:oracle-txn-possibly-dirty="activeTab?.oracleTxnPossiblyDirty"
|
||||
:is-oracle-manual-transaction="isOracleManualTransaction"
|
||||
@update:explain-mode="(m: 'explain' | 'autotrace') => (explainMode = m)"
|
||||
@update:block-dangerous-redis-commands="(v: boolean) => (blockDangerousRedisCommands = v)"
|
||||
@update:auto-commit="
|
||||
|
||||
@@ -34,6 +34,12 @@ const props = defineProps<{
|
||||
autoCommit?: boolean;
|
||||
txnSessionId?: string;
|
||||
txnAutoRolledBack?: boolean;
|
||||
/** Oracle-only: whether the current manual Oracle session executed a statement
|
||||
* DBX cannot prove read-only. Commit/Rollback are hidden while false. */
|
||||
oracleTxnPossiblyDirty?: boolean;
|
||||
/** Oracle manual mode derived from the resolved database type (not raw
|
||||
* db_type, which can be the agent transport). */
|
||||
isOracleManualTransaction?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -153,6 +159,12 @@ function toggleSqlSemanticDiagnostics() {
|
||||
const isTransactionActive = computed(() => !!props.txnSessionId);
|
||||
const isManualTransactionMode = computed(() => props.autoCommit === false || isTransactionActive.value);
|
||||
const transactionModeBadge = computed(() => (isManualTransactionMode.value ? "M" : "A"));
|
||||
// Oracle manual mode hides Commit/Rollback while the session is clean (no
|
||||
// unproven statement executed). Every other database keeps the existing rule.
|
||||
const showTxnActions = computed(() => {
|
||||
if (props.isOracleManualTransaction) return isTransactionActive.value && props.oracleTxnPossiblyDirty === true;
|
||||
return isTransactionActive.value;
|
||||
});
|
||||
const transactionTooltip = computed(() => {
|
||||
const isAgent = (props.activeConnection?.db_type as string) === "agent";
|
||||
const isManual = isManualTransactionMode.value;
|
||||
@@ -430,8 +442,8 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ transactionTooltip }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<!-- Commit button (only when transaction is active) -->
|
||||
<Tooltip v-if="isTransactionActive">
|
||||
<!-- Commit button (only when a transaction action is warranted) -->
|
||||
<Tooltip v-if="showTxnActions">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-green-600 hover:bg-green-500/10 hover:text-green-700 dark:text-green-300 dark:hover:text-green-200" :disabled="activeTab.isExecuting" :aria-label="t('toolbar.commit')" @click="emit('commit')">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
@@ -440,8 +452,8 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
<TooltipContent>{{ t("toolbar.commit") }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<!-- Rollback button (only when transaction is active) -->
|
||||
<Tooltip v-if="isTransactionActive">
|
||||
<!-- Rollback button (only when a transaction action is warranted) -->
|
||||
<Tooltip v-if="showTxnActions">
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-6 text-red-600 hover:bg-red-500/10 hover:text-red-700 dark:text-red-300 dark:hover:text-red-200" :disabled="activeTab.isExecuting" :aria-label="t('toolbar.rollback')" @click="emit('rollback')">
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
|
||||
+9
@@ -31,4 +31,13 @@ describe("EditorToolbar transaction mode control", () => {
|
||||
expect(rollbackIndex).toBeGreaterThan(commitIndex);
|
||||
expect(actionGroupEndIndex).toBeGreaterThan(rollbackIndex);
|
||||
});
|
||||
|
||||
it("hides Commit/Rollback for a clean Oracle manual session only", () => {
|
||||
expect(toolbarSource).toContain("const showTxnActions = computed(() => {");
|
||||
expect(toolbarSource).toContain("if (props.isOracleManualTransaction) return isTransactionActive.value && props.oracleTxnPossiblyDirty === true;");
|
||||
expect(toolbarSource).toContain("return isTransactionActive.value;");
|
||||
// Both buttons are driven by the combined condition.
|
||||
expect(toolbarSource).toContain('<Tooltip v-if="showTxnActions">');
|
||||
expect(toolbarSource.match(/<Tooltip v-if="showTxnActions">/g)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1253,7 +1253,7 @@ export async function beginManualTransaction(_connectionId: string, _database: s
|
||||
throw new Error("Manual transaction management is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function executeInManualTransaction(_txnSessionId: string, _sql: string, _database: string, _schema?: string, _maxRows?: number, _tableDataPreview?: boolean, _pageSize?: number, _resultSessionId?: string): Promise<QueryResult[]> {
|
||||
export async function executeInManualTransaction(_txnSessionId: string, _sql: string, _database: string, _schema?: string, _maxRows?: number, _tableDataPreview?: boolean, _pageSize?: number, _resultSessionId?: string, _classificationSql?: string): Promise<QueryResult[]> {
|
||||
throw new Error("Manual transaction management is only available in the desktop app.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1482,7 +1482,7 @@ export async function beginManualTransaction(connectionId: string, database: str
|
||||
return invoke("begin_manual_transaction", { connectionId, database, schema, catalog });
|
||||
}
|
||||
|
||||
export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number, tableDataPreview?: boolean, pageSize?: number, resultSessionId?: string): Promise<QueryResult[]> {
|
||||
export async function executeInManualTransaction(txnSessionId: string, sql: string, database: string, schema?: string, maxRows?: number, tableDataPreview?: boolean, pageSize?: number, resultSessionId?: string, classificationSql?: string): Promise<QueryResult[]> {
|
||||
return invoke("execute_in_manual_transaction", {
|
||||
txnSessionId,
|
||||
sql,
|
||||
@@ -1492,6 +1492,7 @@ export async function executeInManualTransaction(txnSessionId: string, sql: stri
|
||||
tableDataPreview,
|
||||
pageSize,
|
||||
resultSessionId,
|
||||
classificationSql,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -844,7 +844,7 @@ describe("queryStore hidden primary key editing", () => {
|
||||
await execution;
|
||||
|
||||
expect(beginManualTransaction).toHaveBeenCalledWith("oracle-1", "ORCL", undefined, undefined);
|
||||
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT t.* FROM APP.WIDE_TABLE t", "ORCL", undefined, expect.any(Number), true);
|
||||
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT t.* FROM APP.WIDE_TABLE t", "ORCL", undefined, expect.any(Number), true, undefined, undefined, "SELECT t.* FROM APP.WIDE_TABLE t");
|
||||
expect(store.tabs.find((tab) => tab.id === tabId)?.result?.large_value_cells).toEqual([{ row_index: 0, column_index: 1, original_bytes: 81920 }]);
|
||||
});
|
||||
|
||||
@@ -890,7 +890,7 @@ describe("queryStore hidden primary key editing", () => {
|
||||
await store.executeTabSql(tabId, sql);
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.autoCommit).toBe(false);
|
||||
expect(executeInManualTransaction).toHaveBeenNthCalledWith(1, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, undefined);
|
||||
expect(executeInManualTransaction).toHaveBeenNthCalledWith(1, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, undefined, sql);
|
||||
expect(tab.result?.rows).toHaveLength(100);
|
||||
|
||||
await store.executeTabSql(tabId, sql, {
|
||||
@@ -902,7 +902,7 @@ describe("queryStore hidden primary key editing", () => {
|
||||
replaceActiveResultInGroup: true,
|
||||
});
|
||||
|
||||
expect(executeInManualTransaction).toHaveBeenNthCalledWith(2, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, "oracle-go-1");
|
||||
expect(executeInManualTransaction).toHaveBeenNthCalledWith(2, "txn-1", sql, "ORCL", undefined, expect.any(Number), false, 100, "oracle-go-1", undefined);
|
||||
expect(tab.result?.rows).toHaveLength(200);
|
||||
expect(tab.result?.rows[100]).toEqual([101]);
|
||||
});
|
||||
@@ -959,7 +959,7 @@ 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);
|
||||
expect(executeInManualTransaction).toHaveBeenCalledWith("txn-1", "SELECT name, `id` AS `__DBX_PK_0` FROM users", "app", undefined, expect.any(Number), false, undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it("keeps a keyless Oracle query editable when its WHERE clause reads another table", async () => {
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
analyzeEditableQueryEditability: vi.fn(),
|
||||
beginManualTransaction: vi.fn(),
|
||||
closeClientConnectionSession: vi.fn(),
|
||||
closeQuerySession: vi.fn(),
|
||||
commitManualTransaction: vi.fn(),
|
||||
executeInManualTransaction: vi.fn(),
|
||||
executeMulti: vi.fn(),
|
||||
getConnectionConfig: vi.fn(),
|
||||
prepareQueryPaginationExecutionPlan: vi.fn(),
|
||||
rollbackManualTransaction: vi.fn(),
|
||||
saveOpenTabsState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
analyzeEditableQueryEditability: mocks.analyzeEditableQueryEditability,
|
||||
beginManualTransaction: mocks.beginManualTransaction,
|
||||
closeClientConnectionSession: mocks.closeClientConnectionSession,
|
||||
closeQuerySession: mocks.closeQuerySession,
|
||||
commitManualTransaction: mocks.commitManualTransaction,
|
||||
executeInManualTransaction: mocks.executeInManualTransaction,
|
||||
executeMulti: mocks.executeMulti,
|
||||
prepareQueryPaginationExecutionPlan: mocks.prepareQueryPaginationExecutionPlan,
|
||||
rollbackManualTransaction: mocks.rollbackManualTransaction,
|
||||
saveOpenTabsState: mocks.saveOpenTabsState,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({
|
||||
useConnectionStore: () => ({
|
||||
ensureConnected: vi.fn().mockResolvedValue(undefined),
|
||||
getConfig: mocks.getConnectionConfig,
|
||||
recordConnectionLostError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settingsStore", () => ({
|
||||
useSettingsStore: () => ({
|
||||
editorSettings: {
|
||||
autoCalculateTotalRows: false,
|
||||
continueOnErrorOnBatch: false,
|
||||
pageSize: 100,
|
||||
queryResultMaxRowsEnabled: false,
|
||||
queryResultMaxRows: 1000,
|
||||
openTabsRestoreMode: "all",
|
||||
confirmUnsavedSqlClose: false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
type OracleResult = Record<string, unknown>;
|
||||
|
||||
function cleanSelect(): OracleResult[] {
|
||||
return [{ columns: ["VALUE"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, manual_transaction_proven_read_only: true }];
|
||||
}
|
||||
|
||||
function dirtyUpdate(): OracleResult[] {
|
||||
return [{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 }];
|
||||
}
|
||||
|
||||
function noOpResult(): OracleResult[] {
|
||||
return [{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1, manual_transaction_no_statement: true }];
|
||||
}
|
||||
|
||||
function expiredTransactionError() {
|
||||
return {
|
||||
version: 1 as const,
|
||||
code: "DBX-TXN-1001",
|
||||
messageKey: "backendErrors.transaction.sessionExpired",
|
||||
messageParams: { timeoutSecs: 300 },
|
||||
source: "legacyBackend" as const,
|
||||
operationOutcome: "not_started" as const,
|
||||
origin: { subsystem: "database", adapter: "native" },
|
||||
diagnostics: { category: "transaction", stage: "execute" },
|
||||
};
|
||||
}
|
||||
|
||||
describe("queryStore Oracle manual-transaction sticky state", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
mocks.getConnectionConfig.mockReturnValue({
|
||||
id: "oracle-1",
|
||||
name: "Oracle",
|
||||
db_type: "oracle",
|
||||
database: "ORCL",
|
||||
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);
|
||||
});
|
||||
|
||||
async function setupManualTab(store: { createTab: (c: string, d: string, t: string, m: string, s: string) => string }, _sql = "SELECT * FROM EMP") {
|
||||
mocks.beginManualTransaction.mockResolvedValue("txn-oracle");
|
||||
const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP");
|
||||
store.setAutoCommit(tabId, false);
|
||||
return tabId;
|
||||
}
|
||||
|
||||
it("sends classificationSql only for the initial Oracle manual execution", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
|
||||
expect(mocks.executeInManualTransaction).toHaveBeenCalledOnce();
|
||||
const args = mocks.executeInManualTransaction.mock.calls[0]!;
|
||||
expect(args[0]).toBe("txn-oracle");
|
||||
// classificationSql is the last argument of the non-cursor branch.
|
||||
expect(args.at(-1)).toBe("SELECT * FROM EMP");
|
||||
});
|
||||
|
||||
it("keeps a clean Oracle manual session clean after a proven simple SELECT", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
|
||||
expect(tab.txnSessionId).toBe("txn-oracle");
|
||||
});
|
||||
|
||||
it("dirties an Oracle manual session after an UPDATE", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValue(dirtyUpdate());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.oracleTxnPossiblyDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("never clears the sticky state after a later read (UPDATE -> SELECT)", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockResolvedValueOnce(cleanSelect());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.oracleTxnPossiblyDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("dirties the session for any unproven statement in a mixed script", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValue([
|
||||
{ columns: ["VALUE"], rows: [[1]], affected_rows: 0, execution_time_ms: 1, manual_transaction_proven_read_only: true },
|
||||
{ columns: [], rows: [], affected_rows: 1, execution_time_ms: 1 },
|
||||
]);
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "SELECT * FROM DUAL; DELETE FROM EMP");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.oracleTxnPossiblyDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a Core no-op result as neither dirty nor clean-changing", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValueOnce(noOpResult()).mockResolvedValueOnce(noOpResult());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
// Empty script keeps dirty undefined; then a real clean select still stays clean.
|
||||
await store.executeTabSql(tabId, " -- only a comment\n");
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
|
||||
});
|
||||
|
||||
it("passes no classificationSql and changes no sticky state on a cursor-page fetch", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockResolvedValueOnce(cleanSelect());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
|
||||
|
||||
// Simulate a later page fetch of a clean result.
|
||||
mocks.executeInManualTransaction.mockClear();
|
||||
mocks.executeInManualTransaction.mockResolvedValue(cleanSelect());
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP", {
|
||||
pagination: { sessionId: "cursor-1", clientSessionId: "client-1", limit: 100, offset: 100 },
|
||||
});
|
||||
|
||||
const args = mocks.executeInManualTransaction.mock.calls[0]!;
|
||||
// Page fetch carries no classificationSql (last argument), so it cannot
|
||||
// change the sticky bit.
|
||||
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);
|
||||
});
|
||||
|
||||
it("resets the sticky state on session-expiry recovery and recomputes from the retry", async () => {
|
||||
mocks.beginManualTransaction.mockResolvedValueOnce("txn-old").mockResolvedValueOnce("txn-new");
|
||||
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockRejectedValueOnce(expiredTransactionError()).mockResolvedValueOnce(cleanSelect());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.txnSessionId).toBe("txn-new");
|
||||
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
|
||||
});
|
||||
|
||||
it("clears the sticky state together with the session on DBX rollback", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValue(dirtyUpdate());
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
|
||||
await store.rollbackTransaction(tabId);
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
expect(tab.txnSessionId).toBeUndefined();
|
||||
expect(tab.oracleTxnPossiblyDirty).not.toBe(true);
|
||||
});
|
||||
|
||||
it("clears session and sticky state when a statement failure disposes the manual session", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValueOnce(dirtyUpdate()).mockRejectedValueOnce(new Error("Statement 1 failed: ORA-02292 integrity constraint violated. The manual transaction was rolled back."));
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
|
||||
await store.executeTabSql(tabId, "DELETE FROM EMP WHERE DEPTNO = 99");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
// 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.txnAutoRolledBack).not.toBe(true);
|
||||
});
|
||||
|
||||
it("dirties a clean session when a frontend timeout rejects while the manual session survives", async () => {
|
||||
mocks.executeInManualTransaction.mockRejectedValueOnce(new Error("Query timed out after 30 seconds"));
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "UPDATE EMP SET DEPTNO = 10");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
// 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);
|
||||
});
|
||||
|
||||
it("keeps a clean session clean when a cursor-page fetch times out", async () => {
|
||||
mocks.executeInManualTransaction.mockResolvedValueOnce(cleanSelect()).mockRejectedValueOnce(new Error("Query timed out after 30 seconds"));
|
||||
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = await setupManualTab(store);
|
||||
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP");
|
||||
await store.executeTabSql(tabId, "SELECT * FROM EMP", {
|
||||
pagination: { sessionId: "cursor-1", clientSessionId: "client-1", limit: 100, offset: 100 },
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("leaves non-Oracle manual sessions using the old rule and no marker handling", async () => {
|
||||
mocks.getConnectionConfig.mockReturnValue({
|
||||
id: "pg-1",
|
||||
name: "Postgres",
|
||||
db_type: "postgres",
|
||||
database: "postgres",
|
||||
query_timeout_secs: 30,
|
||||
});
|
||||
mocks.beginManualTransaction.mockResolvedValue("txn-pg");
|
||||
// A Postgres manual execution never returns the Oracle marker.
|
||||
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");
|
||||
store.setAutoCommit(tabId, false);
|
||||
|
||||
await store.executeTabSql(tabId, "SELECT 1");
|
||||
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
// No classificationSql was sent and no Oracle sticky state exists.
|
||||
const args = mocks.executeInManualTransaction.mock.calls[0]!;
|
||||
expect(args.at(-1)).toBeUndefined();
|
||||
expect(tab.oracleTxnPossiblyDirty).toBeUndefined();
|
||||
expect(tab.txnSessionId).toBe("txn-pg");
|
||||
});
|
||||
});
|
||||
@@ -3243,18 +3243,36 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (tab.txnSessionId) {
|
||||
void rollbackTransaction(id);
|
||||
} else {
|
||||
tab.txnAutoRolledBack = false;
|
||||
clearManualTransactionSession(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Centralized manual-session cleanup. Clears every field tied to a manual
|
||||
* transaction session exactly when that session is conclusively ended or
|
||||
* discarded. Callers must not assign these fields individually. */
|
||||
function clearManualTransactionSession(tab: QueryTab) {
|
||||
tab.txnSessionId = undefined;
|
||||
tab.txnAutoRolledBack = false;
|
||||
if (tab.oracleTxnPossiblyDirty !== undefined) tab.oracleTxnPossiblyDirty = false;
|
||||
}
|
||||
|
||||
function rollbackTabTransaction(tab: QueryTab, options?: { resetAutoCommit?: boolean }) {
|
||||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||||
if (options?.resetAutoCommit) {
|
||||
const dbType = useConnectionStore().getConfig(tab.connectionId)?.db_type;
|
||||
tab.autoCommit = defaultAutoCommitForDbType(dbType);
|
||||
}
|
||||
clearOracleTxnPossiblyDirty(tab);
|
||||
tab.txnAutoRolledBack = false;
|
||||
}
|
||||
|
||||
@@ -3264,8 +3282,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
try {
|
||||
await api.commitManualTransaction(tab.txnSessionId);
|
||||
} finally {
|
||||
tab.txnSessionId = undefined;
|
||||
tab.txnAutoRolledBack = false;
|
||||
clearManualTransactionSession(tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3275,8 +3292,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
try {
|
||||
await api.rollbackManualTransaction(tab.txnSessionId);
|
||||
} finally {
|
||||
tab.txnSessionId = undefined;
|
||||
tab.txnAutoRolledBack = false;
|
||||
clearManualTransactionSession(tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5242,19 +5258,26 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
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 = effectiveDbType === "oracle" && !options?.pagination?.sessionId;
|
||||
const classificationSql = isInitialOracleManualExecution ? queryBaseSql : undefined;
|
||||
let manualTransactionRecoveryAttempted = false;
|
||||
executionPromise = (async () => {
|
||||
const txnSessionId = tab.txnSessionId;
|
||||
if (!txnSessionId) throw new Error("Manual transaction session was not initialized");
|
||||
const executeInTransaction = (sessionId: string) =>
|
||||
useAgentResultSession
|
||||
? api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, pageLimit, options?.pagination?.sessionId)
|
||||
: api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview);
|
||||
? api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, pageLimit, options?.pagination?.sessionId, classificationSql)
|
||||
: api.executeInManualTransaction(sessionId, sqlToExecute, executionDatabase, executionSchema, pageLimit ?? agentProtocolQueryResultMaxRows(queryResultMaxRows), useOracleLobPreview, undefined, undefined, classificationSql);
|
||||
try {
|
||||
return await executeInTransaction(txnSessionId);
|
||||
} catch (error) {
|
||||
if (options?.pagination?.sessionId || manualTransactionRecoveryAttempted || !isManualTransactionSessionExpired(error)) throw error;
|
||||
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);
|
||||
tab.txnSessionId = undefined;
|
||||
tab.txnAutoRolledBack = true;
|
||||
queryExecutionLog("info", "manual-txn:expired-recover", { traceId, elapsed: elapsed() });
|
||||
@@ -5325,6 +5348,18 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const annotatedResults = annotateQueryResultSources(markQueryResultsRowsRaw(await withFrontendQueryTimeout(executionPromise, frontendTimeoutSecs, t("editor.queryTimeoutError", { seconds: frontendTimeoutSecs }))), queryBaseSql, sourceLabelDatabase, effectiveDbType, options?.sourceOffset);
|
||||
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 && effectiveDbType === "oracle" && !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;
|
||||
}
|
||||
}
|
||||
const successfulOracleSchemaChanges = effectiveDbType === "oracle" ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0;
|
||||
const successfulSapHanaSchemaChanges = effectiveDbType === "saphana" ? results.filter((result) => result.execution_error !== true && isSapHanaSetSchemaStatement(result.sourceStatement)).length : 0;
|
||||
const sqlServerUseDatabase = effectiveDbType === "sqlserver" ? annotatedResults.sqlServerUseDatabase : undefined;
|
||||
@@ -5532,11 +5567,23 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const errMsg: string = e?.message ?? String(e);
|
||||
const idleTimeout = /5 minutes of inactivity/i.test(errMsg) || errMsg.includes("5 分钟无操作") || errMsg.includes("已自动回滚");
|
||||
if (idleTimeout) {
|
||||
// Backend session was removed and rolled back after idle expiry: clear
|
||||
// the sticky dirty state together with the session.
|
||||
clearOracleTxnPossiblyDirty(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);
|
||||
tab.txnSessionId = undefined;
|
||||
tab.txnAutoRolledBack = false;
|
||||
} else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)) === "oracle") {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
const current = findExecutionTab(id);
|
||||
|
||||
@@ -646,6 +646,13 @@ export interface QueryResult {
|
||||
execution_error?: true;
|
||||
/** Set only for SQL Server informational messages emitted by the backend. */
|
||||
server_message?: true;
|
||||
/** Oracle-only manual-transaction UX marker: set on a manual-transaction result
|
||||
* whose statement DBX proved to be an ordinary top-level read. Absent for
|
||||
* every non-Oracle execution and every unproven Oracle statement. */
|
||||
manual_transaction_proven_read_only?: true;
|
||||
/** Oracle-only manual-transaction UX marker: set on the synthetic successful
|
||||
* result of an empty/whitespace/comments-only manual script. */
|
||||
manual_transaction_no_statement?: true;
|
||||
/** Structured backend error; authoritative when execution_error is true. */
|
||||
error?: BackendError;
|
||||
/** Zero-based index of the submitted statement that produced this result. */
|
||||
@@ -1311,6 +1318,10 @@ export interface QueryTab {
|
||||
txnSessionId?: string;
|
||||
/** Set to true when a manual transaction was auto-rolled back due to inactivity */
|
||||
txnAutoRolledBack?: boolean;
|
||||
/** Oracle-only, non-persisted: whether the current manual Oracle session has
|
||||
* executed at least one statement DBX cannot prove read-only. Commit/Rollback
|
||||
* actions are hidden while a session is clean. Never cleared by a later read. */
|
||||
oracleTxnPossiblyDirty?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedSqlFolder {
|
||||
|
||||
Reference in new issue
Block a user