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

@@ -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);
@@ -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();
});
});