fix(editor): hide commit/rollback for clean mysql/pg manual transactions
Closes #9018
This commit is contained in:
1 parent
91b8fa0aa8
commit
e9be6d0419
15 files changed
+1016
-137
No files matched your search
@@ -13,7 +13,7 @@ import { createContentSurfaceEventForwarders } from "@/lib/tabs/contentSurfaceEv
|
||||
import { isPreviewTab } from "@/lib/tabs/tabPresentation";
|
||||
import { resolveExecutableSql } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||||
import { usesOracleStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
|
||||
import { usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
|
||||
import { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal";
|
||||
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces";
|
||||
import type { QueryTab } from "@/types/database";
|
||||
@@ -110,7 +110,7 @@ const groupTabs = computed(() => {
|
||||
const activeTab = computed(() => groupTabs.value.find((tab) => tab.id === props.activeTabId) ?? groupTabs.value[0] ?? null);
|
||||
const activeConnection = computed(() => (activeTab.value ? connectionStore.getConfig(activeTab.value.connectionId) : undefined));
|
||||
const showGroupToolbar = computed(() => activeTab.value?.mode === "query" && !isPreviewTab(activeTab.value));
|
||||
const isGroupOracleManualTransaction = computed(() => usesOracleStickyTransactionState(effectiveDatabaseTypeForConnection(activeConnection.value)) && (activeTab.value?.autoCommit ?? true) === false);
|
||||
const isGroupStickyManualTransaction = computed(() => usesProvenReadOnlyStickyTransactionState(effectiveDatabaseTypeForConnection(activeConnection.value)) && (activeTab.value?.autoCommit ?? true) === false);
|
||||
// Each group previews the executable SQL of its own active tab (selection
|
||||
// stored on the tab), not the focused tab's global selection.
|
||||
// tabPlacement drives each pane's own bar position: the strip sits above,
|
||||
@@ -185,8 +185,8 @@ const groupExecutableSql = computed(() => {
|
||||
: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="isGroupOracleManualTransaction"
|
||||
:txn-possibly-dirty="activeTab.txnPossiblyDirty"
|
||||
:sticky-proven-read-only-state="isGroupStickyManualTransaction"
|
||||
@update:explain-mode="(m: 'explain' | 'autotrace') => (toolbar.explainMode.value = m)"
|
||||
@update:block-dangerous-redis-commands="(v: boolean) => (toolbar.blockDangerousRedisCommands.value = v)"
|
||||
@update:auto-commit="
|
||||
|
||||
@@ -43,10 +43,10 @@ const props = defineProps<{
|
||||
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;
|
||||
txnPossiblyDirty?: boolean;
|
||||
/** Oracle manual mode derived from the resolved database type (not raw
|
||||
* db_type, which can be the agent transport). */
|
||||
isOracleManualTransaction?: boolean;
|
||||
stickyProvenReadOnlyState?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -272,10 +272,11 @@ function toggleInsertValueHints() {
|
||||
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.
|
||||
// Sticky proven-read-only dialects (Oracle/OceanBase-Oracle/MySQL/PostgreSQL)
|
||||
// hide 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;
|
||||
if (props.stickyProvenReadOnlyState) return isTransactionActive.value && props.txnPossiblyDirty === true;
|
||||
return isTransactionActive.value;
|
||||
});
|
||||
const transactionTooltip = computed(() => {
|
||||
|
||||
@@ -122,8 +122,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
onToolbarExecute,
|
||||
});
|
||||
app.use(pinia);
|
||||
@@ -195,8 +195,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
onExecutePointerDown,
|
||||
onToolbarExecute,
|
||||
});
|
||||
@@ -274,8 +274,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
onExecutePointerDown: () => {
|
||||
pendingSnapshot = editorRef.value?.captureExecutionSnapshot?.();
|
||||
},
|
||||
@@ -341,8 +341,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
@@ -396,8 +396,8 @@ describe("EditorToolbar mount contract", () => {
|
||||
autoCommit: true,
|
||||
txnSessionId: undefined,
|
||||
txnAutoRolledBack: false,
|
||||
oracleTxnPossiblyDirty: false,
|
||||
isOracleManualTransaction: false,
|
||||
txnPossiblyDirty: false,
|
||||
stickyProvenReadOnlyState: false,
|
||||
});
|
||||
app.config.errorHandler = (err) => {
|
||||
errors.push(err);
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, nextTick } from "vue";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: {
|
||||
name: "ButtonStub",
|
||||
template: `<button><slot /></button>`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/searchable-select", () => ({
|
||||
SearchableSelect: {
|
||||
name: "SearchableSelectStub",
|
||||
template: `<div />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: {
|
||||
name: "TooltipStub",
|
||||
template: `<span><slot /></span>`,
|
||||
},
|
||||
TooltipTrigger: {
|
||||
name: "TooltipTriggerStub",
|
||||
template: `<span><slot /></span>`,
|
||||
},
|
||||
TooltipContent: {
|
||||
name: "TooltipContentStub",
|
||||
template: `<span><slot /></span>`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/TruncatedTextTooltip.vue", () => ({
|
||||
default: {
|
||||
name: "TruncatedTextTooltipStub",
|
||||
template: `<span />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
|
||||
default: {
|
||||
name: "DatabaseIconStub",
|
||||
template: `<span />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/connection/ConnectionTreeSelect.vue", () => ({
|
||||
default: {
|
||||
name: "ConnectionTreeSelectStub",
|
||||
template: `<div />`,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/common/ProductionContextBadge.vue", () => ({
|
||||
default: {
|
||||
name: "ProductionContextBadgeStub",
|
||||
template: `<span />`,
|
||||
},
|
||||
}));
|
||||
|
||||
import EditorToolbar from "../EditorToolbar.vue";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
function createHost(): HTMLDivElement {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
describe("EditorToolbar commit/rollback visibility", () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let i18n: ReturnType<typeof createI18n>;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: { en: {} },
|
||||
});
|
||||
});
|
||||
|
||||
async function mountToolbar(props: { dbType: string; stickyProvenReadOnlyState: boolean; txnPossiblyDirty?: boolean; txnSessionId?: string }) {
|
||||
const connectionStore = useConnectionStore();
|
||||
connectionStore.connections = [
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "conn",
|
||||
db_type: props.dbType,
|
||||
color: "",
|
||||
} as never,
|
||||
];
|
||||
|
||||
const host = createHost();
|
||||
const app = createApp(EditorToolbar, {
|
||||
activeTab: {
|
||||
id: "tab-1",
|
||||
title: "SQL",
|
||||
connectionId: "conn-1",
|
||||
database: "db",
|
||||
sql: "SELECT 1",
|
||||
mode: "query",
|
||||
isExecuting: false,
|
||||
isCancelling: false,
|
||||
isExplaining: false,
|
||||
},
|
||||
activeConnection: connectionStore.getConfig("conn-1"),
|
||||
executableSql: "SELECT 1",
|
||||
explainMode: "explain",
|
||||
blockDangerousRedisCommands: false,
|
||||
sqlKeywordCase: "preserve",
|
||||
databaseRequiredSignal: 0,
|
||||
autoCommit: false,
|
||||
txnSessionId: props.txnSessionId,
|
||||
txnAutoRolledBack: false,
|
||||
txnPossiblyDirty: props.txnPossiblyDirty,
|
||||
stickyProvenReadOnlyState: props.stickyProvenReadOnlyState,
|
||||
});
|
||||
app.use(pinia);
|
||||
app.use(i18n);
|
||||
app.mount(host);
|
||||
await nextTick();
|
||||
return host;
|
||||
}
|
||||
|
||||
const commitSelector = `button[aria-label="toolbar.commit"]`;
|
||||
const rollbackSelector = `button[aria-label="toolbar.rollback"]`;
|
||||
|
||||
it("hides Commit/Rollback for a clean MySQL sticky manual session", async () => {
|
||||
const host = await mountToolbar({ dbType: "mysql", stickyProvenReadOnlyState: true, txnPossiblyDirty: false, txnSessionId: "txn-1" });
|
||||
|
||||
expect(host.querySelector(commitSelector)).toBeNull();
|
||||
expect(host.querySelector(rollbackSelector)).toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("shows Commit/Rollback once the MySQL sticky session is dirty", async () => {
|
||||
const host = await mountToolbar({ dbType: "mysql", stickyProvenReadOnlyState: true, txnPossiblyDirty: true, txnSessionId: "txn-1" });
|
||||
|
||||
expect(host.querySelector(commitSelector)).not.toBeNull();
|
||||
expect(host.querySelector(rollbackSelector)).not.toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it("keeps the legacy always-visible rule for non-sticky dialects with a session", async () => {
|
||||
const host = await mountToolbar({ dbType: "jdbc", stickyProvenReadOnlyState: false, txnPossiblyDirty: false, txnSessionId: "txn-1" });
|
||||
|
||||
expect(host.querySelector(commitSelector)).not.toBeNull();
|
||||
expect(host.querySelector(rollbackSelector)).not.toBeNull();
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
supportsTableVacuum,
|
||||
supportsTransaction,
|
||||
usesOracleStickyTransactionState,
|
||||
usesProvenReadOnlyStickyTransactionState,
|
||||
usesConnectionOnlyQueryTarget,
|
||||
usesTreeSchemaMode,
|
||||
schemaNodeHasLoadableName,
|
||||
@@ -215,7 +216,7 @@ describe("defaultAutoCommitForDbType", () => {
|
||||
});
|
||||
|
||||
describe("usesOracleStickyTransactionState", () => {
|
||||
it("enables sticky manual-transaction state for Oracle family", () => {
|
||||
it("marks only the Oracle family for schema-change compensation", () => {
|
||||
expect(usesOracleStickyTransactionState("oracle")).toBe(true);
|
||||
expect(usesOracleStickyTransactionState("oceanbase-oracle")).toBe(true);
|
||||
});
|
||||
@@ -228,6 +229,31 @@ describe("usesOracleStickyTransactionState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("usesProvenReadOnlyStickyTransactionState", () => {
|
||||
it("enables the sticky toolbar for the Oracle family, MySQL and PostgreSQL", () => {
|
||||
expect(usesProvenReadOnlyStickyTransactionState("oracle")).toBe(true);
|
||||
expect(usesProvenReadOnlyStickyTransactionState("oceanbase-oracle")).toBe(true);
|
||||
expect(usesProvenReadOnlyStickyTransactionState("mysql")).toBe(true);
|
||||
expect(usesProvenReadOnlyStickyTransactionState("postgres")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps databases without manual-transaction support on the legacy toolbar", () => {
|
||||
expect(usesProvenReadOnlyStickyTransactionState("doris")).toBe(false);
|
||||
expect(usesProvenReadOnlyStickyTransactionState("kingbase")).toBe(false);
|
||||
expect(usesProvenReadOnlyStickyTransactionState("jdbc")).toBe(false);
|
||||
expect(usesProvenReadOnlyStickyTransactionState("redis")).toBe(false);
|
||||
expect(usesProvenReadOnlyStickyTransactionState(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("only enables sticky state where manual mode is reachable", () => {
|
||||
// The sticky UX gates commit/rollback in manual mode; a member without
|
||||
// transaction support could never reach it. Guards against drift.
|
||||
for (const dbType of ["oracle", "oceanbase-oracle", "mysql", "postgres"]) {
|
||||
expect(supportsTransaction(dbType)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsSqlInListPaste", () => {
|
||||
it("allows generic and SQL-like editors", () => {
|
||||
expect(supportsSqlInListPaste(undefined)).toBe(true);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeDetachedTabRuntime, type DetachedTabRuntimeState } from "@/lib/app/detachedTabHandoff";
|
||||
|
||||
describe("normalizeDetachedTabRuntime", () => {
|
||||
it("migrates a legacy-only oracleTxnPossiblyDirty key to txnPossiblyDirty", () => {
|
||||
// Detached handoffs persist to disk, so payloads written by pre-#9018
|
||||
// builds can still carry the old key; the sticky dirty bit must survive.
|
||||
const runtime: DetachedTabRuntimeState = { oracleTxnPossiblyDirty: true };
|
||||
expect(normalizeDetachedTabRuntime(runtime)).toEqual({ txnPossiblyDirty: true });
|
||||
});
|
||||
|
||||
it("prefers the renamed key when both are present", () => {
|
||||
const runtime: DetachedTabRuntimeState = { txnPossiblyDirty: false, oracleTxnPossiblyDirty: true };
|
||||
expect(normalizeDetachedTabRuntime(runtime)).toEqual({ txnPossiblyDirty: false });
|
||||
});
|
||||
|
||||
it("keeps a legacy clean bit as clean", () => {
|
||||
const runtime: DetachedTabRuntimeState = { txnSessionId: "txn-1", oracleTxnPossiblyDirty: false };
|
||||
expect(normalizeDetachedTabRuntime(runtime)).toEqual({ txnSessionId: "txn-1", txnPossiblyDirty: false });
|
||||
});
|
||||
|
||||
it("leaves runtimes without either key untouched", () => {
|
||||
expect(normalizeDetachedTabRuntime({ txnSessionId: "txn-1" })).toEqual({ txnSessionId: "txn-1" });
|
||||
});
|
||||
});
|
||||
@@ -8,10 +8,24 @@ export interface DetachedTabRuntimeState {
|
||||
editorSelection?: QueryTab["editorSelection"];
|
||||
txnSessionId?: string;
|
||||
txnAutoRolledBack?: boolean;
|
||||
txnPossiblyDirty?: boolean;
|
||||
/** Legacy key from before the #9018 rename. Handoffs persist to disk
|
||||
* (DETACHED_TABS_STATE_KEY), so payloads written by older builds may still
|
||||
* carry it; run restored runtimes through normalizeDetachedTabRuntime. */
|
||||
oracleTxnPossiblyDirty?: boolean;
|
||||
activeOutputView?: DetachedOutputView;
|
||||
}
|
||||
|
||||
/** Migrates a restored handoff runtime: prefer the renamed key, fall back to
|
||||
* the pre-#9018 key, and drop the legacy field so it never leaks onto a tab. */
|
||||
export function normalizeDetachedTabRuntime(runtime: DetachedTabRuntimeState): DetachedTabRuntimeState {
|
||||
const { oracleTxnPossiblyDirty, ...rest } = runtime;
|
||||
if (rest.txnPossiblyDirty === undefined && oracleTxnPossiblyDirty !== undefined) {
|
||||
rest.txnPossiblyDirty = oracleTxnPossiblyDirty;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
|
||||
export interface DetachedTabHandoff {
|
||||
schemaVersion: 1;
|
||||
tabId: string;
|
||||
|
||||
@@ -259,11 +259,21 @@ export function usesPostgresLikeStructureCopy(dbType?: DatabaseType): boolean {
|
||||
|
||||
const TRANSACTION_SUPPORTED_TYPES: readonly string[] = ["postgres", "mysql", "oracle", "jdbc", "oceanbase-oracle"];
|
||||
|
||||
/** Oracle-family databases that use the sticky manual-transaction UX. OceanBase
|
||||
* Oracle mode runs the same transaction model as Oracle, so it shares the
|
||||
* commit/rollback dirty-state behavior rather than the generic transaction path. */
|
||||
/** Oracle-family databases, kept ONLY for the Oracle-specific ALTER SESSION SET
|
||||
* CURRENT_SCHEMA compensation in queryStore. Do not use for toolbar/dirty-bit
|
||||
* gating — that is {@link usesProvenReadOnlyStickyTransactionState} so MySQL and
|
||||
* PostgreSQL participate without dragging Oracle schema-change compensation in. */
|
||||
const ORACLE_STICKY_TRANSACTION_TYPES: ReadonlySet<string> = new Set(["oracle", "oceanbase-oracle"]);
|
||||
|
||||
/** Databases whose manual-transaction toolbar hides Commit/Rollback until an
|
||||
* unproven statement dirties the session. Mirrors the Rust proof gate
|
||||
* (crates/dbx-core/src/query.rs + sql_risk.rs `prove_read_only_for_database`).
|
||||
* Every member must also be in TRANSACTION_SUPPORTED_TYPES above: a database
|
||||
* cannot reach manual mode (and this UX) without explicit transaction control
|
||||
* (#9018). Family members like doris/kingbase join only when their transaction
|
||||
* support lands. */
|
||||
const PROVEN_READ_ONLY_STICKY_TYPES: ReadonlySet<string> = new Set(["oracle", "oceanbase-oracle", "mysql", "postgres"]);
|
||||
|
||||
/**
|
||||
* Returns true if the given database type supports explicit transaction control
|
||||
* (i.e. toggling between auto-commit and manual transaction mode via BEGIN/COMMIT).
|
||||
@@ -317,13 +327,22 @@ export function rejectsAliasReferenceInHaving(dbType?: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the database type uses Oracle's sticky manual-transaction state
|
||||
* (commit/rollback hidden until an unproven statement dirties the session).
|
||||
* Returns true if the database type participates in Oracle's schema-change
|
||||
* compensation under manual transactions. Toolbar/dirty-bit gating must use
|
||||
* `usesProvenReadOnlyStickyTransactionState` instead.
|
||||
*/
|
||||
export function usesOracleStickyTransactionState(dbType?: string): boolean {
|
||||
return !!dbType && ORACLE_STICKY_TRANSACTION_TYPES.has(dbType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the database type uses the sticky manual-transaction UX:
|
||||
* commit/rollback hidden while the session is clean (no unproven statement).
|
||||
*/
|
||||
export function usesProvenReadOnlyStickyTransactionState(dbType?: string): boolean {
|
||||
return !!dbType && PROVEN_READ_ONLY_STICKY_TYPES.has(dbType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default auto-commit mode when opening a query tab for the given database type.
|
||||
* Query tabs default to auto-commit; users can explicitly switch to manual transactions.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+120
-28
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -1029,12 +1029,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 UX marker for sticky proven-read-only dialects (Oracle,
|
||||
* OceanBase-Oracle, MySQL, PostgreSQL): set on a manual-transaction result
|
||||
* whose statement DBX proved to be an ordinary read by that dialect's strict
|
||||
* heuristic. Absent for unproven statements and non-participating dialects. */
|
||||
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 UX marker for the same dialects: 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;
|
||||
@@ -1826,10 +1827,11 @@ 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;
|
||||
/** Sticky proven-read-only dialects (Oracle/OceanBase-Oracle/MySQL/PostgreSQL),
|
||||
* not persisted: whether the current manual 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. */
|
||||
txnPossiblyDirty?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedSqlFolder {
|
||||
|
||||
Reference in new issue
Block a user