diff --git a/apps/desktop/src/components/layout/EditorGroup.vue b/apps/desktop/src/components/layout/EditorGroup.vue
index bea2976b69..e79e78fdfd 100644
--- a/apps/desktop/src/components/layout/EditorGroup.vue
+++ b/apps/desktop/src/components/layout/EditorGroup.vue
@@ -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="
diff --git a/apps/desktop/src/components/layout/EditorToolbar.vue b/apps/desktop/src/components/layout/EditorToolbar.vue
index 6e73feda78..55a42b858d 100644
--- a/apps/desktop/src/components/layout/EditorToolbar.vue
+++ b/apps/desktop/src/components/layout/EditorToolbar.vue
@@ -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(() => {
diff --git a/apps/desktop/src/components/layout/__tests__/EditorToolbar.mount.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorToolbar.mount.spec.ts
index d03590825a..aee88c47ef 100644
--- a/apps/desktop/src/components/layout/__tests__/EditorToolbar.mount.spec.ts
+++ b/apps/desktop/src/components/layout/__tests__/EditorToolbar.mount.spec.ts
@@ -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);
diff --git a/apps/desktop/src/components/layout/__tests__/EditorToolbar.transactionMode.spec.ts b/apps/desktop/src/components/layout/__tests__/EditorToolbar.transactionMode.spec.ts
new file mode 100644
index 0000000000..7f73ba8f7d
--- /dev/null
+++ b/apps/desktop/src/components/layout/__tests__/EditorToolbar.transactionMode.spec.ts
@@ -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: ``,
+ },
+}));
+
+vi.mock("@/components/ui/searchable-select", () => ({
+ SearchableSelect: {
+ name: "SearchableSelectStub",
+ template: `
`,
+ },
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+ Tooltip: {
+ name: "TooltipStub",
+ template: ``,
+ },
+ TooltipTrigger: {
+ name: "TooltipTriggerStub",
+ template: ``,
+ },
+ TooltipContent: {
+ name: "TooltipContentStub",
+ template: ``,
+ },
+}));
+
+vi.mock("@/components/ui/TruncatedTextTooltip.vue", () => ({
+ default: {
+ name: "TruncatedTextTooltipStub",
+ template: ``,
+ },
+}));
+
+vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
+ default: {
+ name: "DatabaseIconStub",
+ template: ``,
+ },
+}));
+
+vi.mock("@/components/connection/ConnectionTreeSelect.vue", () => ({
+ default: {
+ name: "ConnectionTreeSelectStub",
+ template: ``,
+ },
+}));
+
+vi.mock("@/components/common/ProductionContextBadge.vue", () => ({
+ default: {
+ name: "ProductionContextBadgeStub",
+ template: ``,
+ },
+}));
+
+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;
+ let i18n: ReturnType;
+
+ 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();
+ });
+});
diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
index b173b15c6b..f0f130251a 100644
--- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
+++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts
@@ -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);
diff --git a/apps/desktop/src/lib/app/__tests__/detachedTabHandoff.spec.ts b/apps/desktop/src/lib/app/__tests__/detachedTabHandoff.spec.ts
new file mode 100644
index 0000000000..3788843633
--- /dev/null
+++ b/apps/desktop/src/lib/app/__tests__/detachedTabHandoff.spec.ts
@@ -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" });
+ });
+});
diff --git a/apps/desktop/src/lib/app/detachedTabHandoff.ts b/apps/desktop/src/lib/app/detachedTabHandoff.ts
index 62e91f3069..11bef1e4aa 100644
--- a/apps/desktop/src/lib/app/detachedTabHandoff.ts
+++ b/apps/desktop/src/lib/app/detachedTabHandoff.ts
@@ -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;
diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts
index 589244d67a..9d1cecd934 100644
--- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts
+++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts
@@ -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 = 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 = 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.
diff --git a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts
index d343ba59b6..2e9b7af55f 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.hiddenPrimaryKey.spec.ts
@@ -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 () => {
diff --git a/apps/desktop/src/stores/__tests__/queryStore.oracleTxnStickyState.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts
similarity index 72%
rename from apps/desktop/src/stores/__tests__/queryStore.oracleTxnStickyState.spec.ts
rename to apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts
index ba29ab07ef..b0a8457600 100644
--- a/apps/desktop/src/stores/__tests__/queryStore.oracleTxnStickyState.spec.ts
+++ b/apps/desktop/src/stores/__tests__/queryStore.provenReadOnlyStickyState.spec.ts
@@ -59,17 +59,17 @@ function installLocalStorage() {
});
}
-type OracleResult = Record;
+type StickyResult = Record;
-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);
});
});
diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts
index ec0bd18bd2..4e5aafe196 100644
--- a/apps/desktop/src/stores/queryStore.ts
+++ b/apps/desktop/src/stores/queryStore.ts
@@ -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);
diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts
index cd3185e738..a8d687750e 100644
--- a/apps/desktop/src/types/database.ts
+++ b/apps/desktop/src/types/database.ts
@@ -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 {
diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs
index a4d015bd6f..60c3eecc96 100644
--- a/crates/dbx-core/src/query.rs
+++ b/crates/dbx-core/src/query.rs
@@ -232,14 +232,15 @@ pub struct ExecuteMultiResult {
pub error: Option,
#[serde(skip_serializing_if = "is_false")]
pub server_message: bool,
- /// Oracle-only manual-transaction UX metadata: true only for a statement
- /// proven to be an ordinary top-level read. Absent/false for every other
- /// Oracle statement and every non-Oracle execution. Not part of the
- /// reusable database-result model (`db::QueryResult`).
+ /// Manual-transaction UX metadata for sticky proven-read-only dialects
+ /// (Oracle, OceanBase-Oracle, MySQL, PostgreSQL): true only for a statement
+ /// proven to be an ordinary read by that dialect's strict heuristic.
+ /// Absent/false for unproven statements and non-participating dialects.
+ /// Not part of the reusable database-result model (`db::QueryResult`).
#[serde(skip_serializing_if = "is_false")]
pub manual_transaction_proven_read_only: bool,
- /// Oracle-only manual-transaction UX metadata: true on the synthetic
- /// successful result when the manual-execution splitter found zero
+ /// Manual-transaction UX metadata for the same dialects: true on the
+ /// synthetic successful result when the manual-execution splitter found zero
/// statements (empty/whitespace/comments-only script). Lets the frontend
/// treat it as a no-op rather than an unproven statement.
#[serde(skip_serializing_if = "is_false")]
@@ -5378,6 +5379,45 @@ fn mysql_error_is_syntax_error(error: &mysql_async::Error) -> bool {
}
}
+/// Compute per-execution-statement proven-read-only markers for the sticky
+/// manual-transaction UX (#7122 Oracle, #9018 MySQL/PostgreSQL). The user-facing
+/// classification SQL is split with the same dialect-aware splitter as the
+/// execution SQL and paired by count/position; any mismatch is fail-closed (no
+/// markers). Oracle/OceanBase-Oracle keep the lexical classifier, MySQL and
+/// PostgreSQL use the strict `sql_risk` proof; every other dialect is unproven.
+fn classify_manual_transaction_statements(
+ database_type: Option,
+ execution_statement_count: usize,
+ classification_sql: Option<&str>,
+) -> Vec {
+ let Some(database_type) = database_type.filter(|database_type| {
+ matches!(
+ database_type,
+ DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Mysql | DatabaseType::Postgres
+ )
+ }) else {
+ return Vec::new();
+ };
+ let Some(classification_sql) = classification_sql else {
+ return Vec::new();
+ };
+ let user_statements = crate::sql::split_sql_statements_for_database(classification_sql, database_type);
+ let paired = user_statements.len() == execution_statement_count && !user_statements.is_empty();
+ if !paired {
+ return Vec::new();
+ }
+ user_statements
+ .iter()
+ .map(|statement| match database_type {
+ DatabaseType::Mysql | DatabaseType::Postgres => {
+ crate::sql_risk::prove_read_only_for_database(statement, database_type)
+ == crate::sql_risk::ReadProof::ProvenReadOnly
+ }
+ _ => is_oracle_proven_read_only_statement(statement),
+ })
+ .collect()
+}
+
async fn begin_transaction_session(
state: &AppState,
connection_id: &str,
@@ -5688,7 +5728,7 @@ pub async fn execute_in_manual_transaction_with_options(
},
);
if statements.is_empty() {
- // Oracle-only UX marker: the no-op is Core's decision that the script
+ // Sticky-dialect UX marker: the no-op is Core's decision that the script
// (empty/whitespace/comments-only) has no statements, so the frontend
// must not treat it as an unproven statement. Every other database
// receives the plain empty result.
@@ -5696,7 +5736,10 @@ pub async fn execute_in_manual_transaction_with_options(
empty_query_result(0),
options.table_data_preview,
);
- if matches!(db_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) {
+ if matches!(
+ db_type,
+ Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle | DatabaseType::Mysql | DatabaseType::Postgres)
+ ) {
result = result.with_manual_transaction_no_statement();
}
return Ok(vec![result]);
@@ -5752,30 +5795,14 @@ pub async fn execute_in_manual_transaction_with_options(
let row_limit = options.max_rows.unwrap_or(MAX_ROWS).max(1);
let mut results = Vec::with_capacity(statements.len());
- // Oracle-only classification pairing. The core splits both the execution
+ // Sticky-dialect classification pairing. The core splits both the execution
// SQL and, when present, the user-facing classification SQL with the same
- // Oracle-aware splitter. A marker is emitted only when both lists have the
+ // dialect-aware splitter. A marker is emitted only when both lists have the
// same non-zero count and every paired user statement is proven read-only;
// any mismatch is fail-closed (no marker). This is deliberately a
// trust-boundary count/position pairing, not a SQL-equivalence parser.
- let classification: Vec = if let Some(dialect) =
- db_type.filter(|db_type| matches!(db_type, DatabaseType::Oracle | DatabaseType::OceanbaseOracle))
- {
- match options.classification_sql.as_deref() {
- Some(classification_sql) => {
- let user_statements = crate::sql::split_sql_statements_for_database(classification_sql, dialect);
- let paired = user_statements.len() == statements.len() && !user_statements.is_empty();
- if paired {
- user_statements.iter().map(|statement| is_oracle_proven_read_only_statement(statement)).collect()
- } else {
- Vec::new()
- }
- }
- None => Vec::new(),
- }
- } else {
- Vec::new()
- };
+ let classification: Vec =
+ classify_manual_transaction_statements(db_type, statements.len(), options.classification_sql.as_deref());
let mut conn = connection.lock().await;
for (i, statement) in statements.iter().enumerate() {
@@ -10492,6 +10519,44 @@ for line in sys.stdin:
assert!(second_params.get("sql").is_none());
}
+ /// Spawns a fake Python agent and registers a manual transaction session in
+ /// the app state so `execute_in_manual_transaction_with_options` can run
+ /// end to end without a live database.
+ #[test]
+ fn manual_transaction_classification_pairs_statements_and_fails_closed() {
+ // MySQL/PostgreSQL route through the strict sql_risk proof.
+ assert_eq!(classify_manual_transaction_statements(Some(DatabaseType::Mysql), 1, Some("SELECT 1")), vec![true]);
+ assert_eq!(
+ classify_manual_transaction_statements(Some(DatabaseType::Postgres), 1, Some("SELECT * FROM users")),
+ vec![true]
+ );
+ // Mixed script: every statement is classified individually.
+ assert_eq!(
+ classify_manual_transaction_statements(Some(DatabaseType::Mysql), 2, Some("SELECT 1; DELETE FROM t")),
+ vec![true, false]
+ );
+ // Session-state writes fail the proof (SELECT ... INTO @var).
+ assert_eq!(
+ classify_manual_transaction_statements(Some(DatabaseType::Mysql), 1, Some("SELECT 1 INTO @x")),
+ vec![false]
+ );
+ // Count mismatch, missing classification SQL, non-participating dialects
+ // and unknown connections are all fail-closed (no markers).
+ assert!(classify_manual_transaction_statements(Some(DatabaseType::Mysql), 3, Some("SELECT 1")).is_empty());
+ assert!(classify_manual_transaction_statements(Some(DatabaseType::Mysql), 1, None).is_empty());
+ assert!(classify_manual_transaction_statements(Some(DatabaseType::Doris), 1, Some("SELECT 1")).is_empty());
+ assert!(classify_manual_transaction_statements(None, 1, Some("SELECT 1")).is_empty());
+ // Oracle keeps its lexical classifier and its pairing behavior.
+ assert_eq!(
+ classify_manual_transaction_statements(Some(DatabaseType::Oracle), 1, Some("SELECT * FROM EMP")),
+ vec![true]
+ );
+ assert_eq!(
+ classify_manual_transaction_statements(Some(DatabaseType::OceanbaseOracle), 1, Some("DELETE FROM EMP")),
+ vec![false]
+ );
+ }
+
/// Spawns a fake Python agent and registers a manual transaction session in
/// the app state so `execute_in_manual_transaction_with_options` can run
/// end to end without a live database.
diff --git a/crates/dbx-core/src/query_execution_sql.rs b/crates/dbx-core/src/query_execution_sql.rs
index c7e998cb5d..8e1f2f71dc 100644
--- a/crates/dbx-core/src/query_execution_sql.rs
+++ b/crates/dbx-core/src/query_execution_sql.rs
@@ -444,7 +444,7 @@ fn contains_keyword_sequence(sql: &str, first: &str, second: &str) -> bool {
false
}
-fn contains_unquoted_keyword(sql: &str, dialect: &dyn sqlparser::dialect::Dialect, keyword: &str) -> bool {
+pub(crate) fn contains_unquoted_keyword(sql: &str, dialect: &dyn sqlparser::dialect::Dialect, keyword: &str) -> bool {
Tokenizer::new(dialect, sql).tokenize().is_ok_and(|tokens| {
tokens.into_iter().any(|token| {
matches!(token, Token::Word(word) if word.quote_style.is_none() && word.value.eq_ignore_ascii_case(keyword))
diff --git a/crates/dbx-core/src/sql_risk.rs b/crates/dbx-core/src/sql_risk.rs
index 5bd7fa5be6..f23ec65b0e 100644
--- a/crates/dbx-core/src/sql_risk.rs
+++ b/crates/dbx-core/src/sql_risk.rs
@@ -3,7 +3,7 @@ use std::ops::ControlFlow;
use serde::{Deserialize, Serialize};
use sqlparser::ast::{
visit_expressions, BinaryOperator, Expr, FromTable, OnConflictAction, OnInsert, Query, SetExpr, SqliteOnConflict,
- Statement, UnaryOperator, Value,
+ Statement, TableFactor, UnaryOperator, Value, Visit, Visitor,
};
use sqlparser::dialect::{
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect,
@@ -86,21 +86,7 @@ fn classify_statement(stmt: &Statement, detect_select_into: bool) -> SqlRisk {
Statement::ExplainTable { .. } => SqlRisk::ReadOnly,
// Show/Describe variants
- Statement::ShowTables { .. }
- | Statement::ShowColumns { .. }
- | Statement::ShowCatalogs { .. }
- | Statement::ShowDatabases { .. }
- | Statement::ShowSchemas { .. }
- | Statement::ShowViews { .. }
- | Statement::ShowFunctions { .. }
- | Statement::ShowCreate { .. }
- | Statement::ShowVariable { .. }
- | Statement::ShowVariables { .. }
- | Statement::ShowStatus { .. }
- | Statement::ShowProcessList { .. }
- | Statement::ShowCharset(_)
- | Statement::ShowObjects(_)
- | Statement::ShowCollation { .. } => SqlRisk::ReadOnly,
+ stmt if is_show_read_only_statement(stmt) => SqlRisk::ReadOnly,
// Write operations
Statement::Insert { .. } | Statement::Update { .. } | Statement::Delete { .. } | Statement::Merge { .. } => {
@@ -141,6 +127,31 @@ fn classify_statement(stmt: &Statement, detect_select_into: bool) -> SqlRisk {
}
}
+/// SHOW/DESCRIBE-style statement variants sqlparser models as reads. Shared by
+/// the risk classifier and the manual-transaction proof (#9018) so the two
+/// cannot drift; unparseable SHOW variants fail closed upstream in the proof
+/// (parse error) and to the keyword fallback in the risk classifier.
+fn is_show_read_only_statement(statement: &Statement) -> bool {
+ matches!(
+ statement,
+ Statement::ShowTables { .. }
+ | Statement::ShowColumns { .. }
+ | Statement::ShowCatalogs { .. }
+ | Statement::ShowDatabases { .. }
+ | Statement::ShowSchemas { .. }
+ | Statement::ShowViews { .. }
+ | Statement::ShowFunctions { .. }
+ | Statement::ShowCreate { .. }
+ | Statement::ShowVariable { .. }
+ | Statement::ShowVariables { .. }
+ | Statement::ShowStatus { .. }
+ | Statement::ShowProcessList { .. }
+ | Statement::ShowCharset(_)
+ | Statement::ShowObjects(_)
+ | Statement::ShowCollation { .. }
+ )
+}
+
fn statement_is_dangerous(stmt: &Statement, detect_select_into: bool) -> bool {
match stmt {
// `USE` only changes connection-local state. MCP separately requires
@@ -876,10 +887,471 @@ fn sql_contains_locking_clause(sql: &str, dialect: &dyn sqlparser::dialect::Dial
false
}
+// ---------------------------------------------------------------------------
+// Strict manual-transaction read-only proof (#9018)
+//
+// Unlike `classify_sql_risk*` (keyword fallback on parse failure may answer
+// ReadOnly), the proof below is fail-closed end to end: the parse must succeed
+// and yield exactly one statement, no write-capable construct may appear, and
+// every called function must be an allowlisted pure builtin. The outcome only
+// drives commit/rollback button visibility in manual transaction mode — it is
+// a heuristic, never a security boundary (PG custom operators/casts and MySQL
+// UDFs can hide arbitrary side effects static syntax cannot see).
+// ---------------------------------------------------------------------------
+
+/// Strict manual-transaction UX proof outcome. UI-only.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ReadProof {
+ /// DBX's strict heuristic is satisfied for this statement.
+ ProvenReadOnly,
+ /// Anything unprovable. Buttons stay visible; execution is unaffected.
+ Unproven,
+}
+
+/// Pure built-in functions accepted by the MySQL proof. Fail-closed: a call to
+/// anything not listed (UDFs, session-state setters like `LAST_INSERT_ID(expr)`,
+/// `SLEEP`/`GET_LOCK`, ...) is Unproven. Only ever grows by review.
+const MYSQL_PROOF_SAFE_FUNCTIONS: &[&str] = &[
+ "abs",
+ "ascii",
+ "avg",
+ "bin",
+ "ceiling",
+ "char_length",
+ "character_length",
+ "coalesce",
+ "concat",
+ "concat_ws",
+ "conv",
+ "count",
+ "crc32",
+ "curdate",
+ "curtime",
+ "current_date",
+ "current_time",
+ "current_timestamp",
+ "date_format",
+ "datediff",
+ "dayname",
+ "dayofmonth",
+ "dayofweek",
+ "dayofyear",
+ "exp",
+ "floor",
+ "format",
+ "greatest",
+ "hex",
+ "hour",
+ "if",
+ "ifnull",
+ "inet_aton",
+ "inet_ntoa",
+ "instr",
+ "isnull",
+ "json_extract",
+ "json_length",
+ "json_unquote",
+ "json_valid",
+ "last_day",
+ "lcase",
+ "least",
+ "left",
+ "length",
+ "ln",
+ "locate",
+ "log",
+ "log10",
+ "log2",
+ "lower",
+ "lpad",
+ "ltrim",
+ "max",
+ "md5",
+ "microsecond",
+ "min",
+ "minute",
+ "mod",
+ "month",
+ "monthname",
+ "now",
+ "nullif",
+ "oct",
+ "ord",
+ "position",
+ "pow",
+ "power",
+ "quarter",
+ "rand",
+ "repeat",
+ "replace",
+ "reverse",
+ "right",
+ "round",
+ "rpad",
+ "rtrim",
+ "second",
+ "sha",
+ "sha1",
+ "sha2",
+ "sign",
+ "space",
+ "sqrt",
+ "str_to_date",
+ "substring",
+ "substr",
+ "time_format",
+ "timediff",
+ "timestampadd",
+ "timestampdiff",
+ "truncate",
+ "unhex",
+ "unix_timestamp",
+ "upper",
+ "ucase",
+ "utc_date",
+ "utc_time",
+ "utc_timestamp",
+ "uuid",
+ "week",
+ "weekday",
+ "year",
+];
+
+/// Pure built-in functions accepted by the PostgreSQL proof. Same contract as
+/// `MYSQL_PROOF_SAFE_FUNCTIONS`; sequence mutators/readers (`nextval`/`setval`),
+/// `pg_sleep`, advisory-lock and large-object functions are deliberately absent.
+const POSTGRES_PROOF_SAFE_FUNCTIONS: &[&str] = &[
+ "abs",
+ "age",
+ "array_agg",
+ "array_length",
+ "ascii",
+ "avg",
+ "btrim",
+ "cardinality",
+ "ceil",
+ "ceiling",
+ "char_length",
+ "character_length",
+ "chr",
+ "coalesce",
+ "concat",
+ "concat_ws",
+ "count",
+ "current_catalog",
+ "current_date",
+ "current_schema",
+ "current_setting",
+ "current_time",
+ "current_timestamp",
+ "current_user",
+ "date_part",
+ "date_trunc",
+ "decode",
+ "div",
+ "encode",
+ "every",
+ "exp",
+ "floor",
+ "format",
+ "gen_random_uuid",
+ "greatest",
+ "left",
+ "length",
+ "localtime",
+ "localtimestamp",
+ "log",
+ "lower",
+ "lpad",
+ "ltrim",
+ "max",
+ "md5",
+ "min",
+ "mod",
+ "now",
+ "nullif",
+ "position",
+ "power",
+ "repeat",
+ "replace",
+ "reverse",
+ "right",
+ "round",
+ "rpad",
+ "rtrim",
+ "sha224",
+ "sha256",
+ "sha384",
+ "sha512",
+ "sign",
+ "split_part",
+ "sqrt",
+ "starts_with",
+ "strpos",
+ "string_agg",
+ "substr",
+ "substring",
+ "to_char",
+ "to_date",
+ "to_hex",
+ "to_number",
+ "to_timestamp",
+ "trunc",
+ "unnest",
+ "upper",
+ "version",
+];
+
+/// Prove that a single SQL statement is, by DBX's strict heuristic, an
+/// ordinary read. Only MySQL/PostgreSQL take part in this proof; every other
+/// database type is Unproven so its manual-transaction toolbar keeps the
+/// legacy behavior. Oracle keeps its own lexical classifier
+/// (`is_oracle_proven_read_only_statement`) — do not reroute it here.
+pub fn prove_read_only_for_database(sql: &str, database_type: DatabaseType) -> ReadProof {
+ match database_type {
+ DatabaseType::Mysql => prove_read_only_statement(sql, "mysql", MYSQL_PROOF_SAFE_FUNCTIONS),
+ DatabaseType::Postgres => prove_read_only_statement(sql, "postgres", POSTGRES_PROOF_SAFE_FUNCTIONS),
+ _ => ReadProof::Unproven,
+ }
+}
+
+fn prove_read_only_statement(sql: &str, dialect: &str, allowed_functions: &[&str]) -> ReadProof {
+ let database_type = if dialect == "mysql" { DatabaseType::Mysql } else { DatabaseType::Postgres };
+ // Lexical rejections that must not depend on parser support: dialect-specific
+ // write syntax (executable comments, INTO OUTFILE/DUMPFILE, PostgreSQL
+ // SELECT INTO) and MySQL session writes the parser models inconsistently
+ // (SELECT @a := 1, SELECT ... INTO @var — INTO may sit in several positions).
+ // `supports_select_into_table_creation` is deliberately NOT reused here: it
+ // answers "SELECT INTO table creation" risk and excludes MySQL on purpose,
+ // while the proof must reject any INTO target as session state.
+ let cleaned = crate::query_execution_sql::strip_sql_comments_and_literals(sql);
+ if cleaned.trim().is_empty() || crate::query_execution_sql::has_dialect_specific_write(sql, database_type) {
+ return ReadProof::Unproven;
+ }
+ let parser_dialect = resolve_dialect(dialect);
+ if dialect == "mysql"
+ && (cleaned.contains(":=")
+ || crate::query_execution_sql::contains_unquoted_keyword(&cleaned, parser_dialect.as_ref(), "INTO"))
+ {
+ return ReadProof::Unproven;
+ }
+ // The strict proof has NO keyword fallback: parse failure is Unproven, and
+ // the API stays fail-closed even for callers that skipped pre-splitting.
+ let Ok(statements) = Parser::parse_sql(parser_dialect.as_ref(), sql) else {
+ return ReadProof::Unproven;
+ };
+ let [statement] = statements.as_slice() else {
+ return ReadProof::Unproven;
+ };
+ prove_statement(statement, allowed_functions)
+}
+
+fn prove_statement(statement: &Statement, allowed_functions: &[&str]) -> ReadProof {
+ match statement {
+ Statement::Query(query) => {
+ // Reuses the risk engine's structural walk: writable CTEs, DML set
+ // branches, locking clauses, SELECT INTO and denylisted side-effect
+ // functions. detect_select_into is always true — a proof is about a
+ // clean session, not table-creation risk.
+ if query_is_write_capable(query, true) {
+ return ReadProof::Unproven;
+ }
+ let mut visitor = ProofFunctionVisitor { allowed_functions, rejected: false };
+ let _ = query.visit(&mut visitor);
+ if visitor.rejected {
+ ReadProof::Unproven
+ } else {
+ ReadProof::ProvenReadOnly
+ }
+ }
+ // Plain EXPLAIN never executes the statement; EXPLAIN ANALYZE does.
+ Statement::Explain { analyze: false, statement, .. } => prove_statement(statement, allowed_functions),
+ Statement::Explain { .. } => ReadProof::Unproven,
+ // MySQL `DESC t` / `DESCRIBE t`.
+ Statement::ExplainTable { .. } => ReadProof::ProvenReadOnly,
+ statement if is_show_read_only_statement(statement) => ReadProof::ProvenReadOnly,
+ _ => ReadProof::Unproven,
+ }
+}
+
+/// Full-statement allowlist scan. Deliberately uses the `visitor`-feature walk
+/// (covers CTE bodies, FROM subqueries, LATERAL derived tables and ORDER BY
+/// expressions) instead of `visit_expressions`, which skips FROM subqueries.
+/// Window functions and table functions (`FROM generate_series(...)`) are
+/// rejected outright in v1 — fail-closed, relaxed only by review.
+struct ProofFunctionVisitor<'a> {
+ allowed_functions: &'a [&'a str],
+ rejected: bool,
+}
+
+impl Visitor for ProofFunctionVisitor<'_> {
+ type Break = ();
+
+ fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
+ if let Expr::Function(function) = expr {
+ if function.over.is_some() {
+ self.rejected = true;
+ return ControlFlow::Break(());
+ }
+ let parts = &function.name.0;
+ let allowed = parts.len() == 1
+ && parts.last().and_then(|part| part.as_ident()).is_some_and(|ident| {
+ self.allowed_functions.iter().any(|candidate| ident.value.eq_ignore_ascii_case(candidate))
+ });
+ if !allowed {
+ self.rejected = true;
+ return ControlFlow::Break(());
+ }
+ }
+ ControlFlow::Continue(())
+ }
+
+ fn pre_visit_table_factor(&mut self, table_factor: &TableFactor) -> ControlFlow<()> {
+ if let TableFactor::Table { args: Some(_), .. } = table_factor {
+ self.rejected = true;
+ return ControlFlow::Break(());
+ }
+ ControlFlow::Continue(())
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn mysql_proof_accepts_plain_reads_and_allowlisted_functions() {
+ for sql in [
+ "SELECT * FROM users",
+ "SELECT COUNT(*) FROM orders",
+ "SELECT id, name FROM users WHERE id IN (1, 2, 3)",
+ "SELECT id, name FROM users WHERE id IN (SELECT user_id FROM roles) ORDER BY created_at LIMIT 10",
+ "SELECT 'update' FROM t",
+ "SELECT a FROM t UNION ALL SELECT b FROM u",
+ "WITH totals AS (SELECT COUNT(*) AS n FROM orders) SELECT n FROM totals",
+ "SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM users",
+ "SELECT id FROM users ORDER BY rand() LIMIT 1",
+ "SHOW TABLES",
+ "SHOW CREATE TABLE users",
+ "EXPLAIN SELECT * FROM users",
+ "DESC users",
+ "DESCRIBE users",
+ "-- only a comment header\nSELECT id FROM users",
+ "SELECT 1",
+ ] {
+ assert_eq!(
+ prove_read_only_for_database(sql, DatabaseType::Mysql),
+ ReadProof::ProvenReadOnly,
+ "expected proven: {sql}"
+ );
+ }
+ }
+
+ #[test]
+ fn mysql_proof_rejects_session_writes_locking_and_unknown_functions() {
+ for sql in [
+ "SELECT @a := 1",
+ "SELECT 1 INTO @current_id",
+ "SELECT id FROM users INTO @current_id LIMIT 1",
+ "SELECT * FROM users INTO OUTFILE '/tmp/x'",
+ "SELECT * FROM users FOR UPDATE",
+ "SELECT * FROM users FOR SHARE",
+ "SELECT * FROM users LOCK IN SHARE MODE",
+ "SELECT SLEEP(10)",
+ "SELECT GET_LOCK('x', 1)",
+ "SELECT LAST_INSERT_ID()",
+ "SELECT my_custom_udf(1)",
+ "SELECT secret_schema.fn(1) FROM t",
+ "SELECT id FROM users WHERE EXISTS (SELECT SLEEP(1))",
+ "WITH x AS (SELECT SLEEP(1)) SELECT 1",
+ "SELECT ROW_NUMBER() OVER (ORDER BY id) FROM users",
+ "SELECT id FROM users; SELECT 1",
+ "EXPLAIN ANALYZE SELECT * FROM users",
+ "SELEC * FORM users",
+ "SET autocommit = 1",
+ ] {
+ assert_eq!(
+ prove_read_only_for_database(sql, DatabaseType::Mysql),
+ ReadProof::Unproven,
+ "expected unproven: {sql}"
+ );
+ }
+ }
+
+ #[test]
+ fn postgres_proof_accepts_plain_reads_and_rejects_side_effects() {
+ for sql in [
+ "SELECT * FROM users",
+ "WITH x AS (SELECT 1) SELECT * FROM x",
+ "SELECT * FROM t WHERE id IN (SELECT id FROM u)",
+ "SELECT coalesce(a, b) FROM t",
+ "SELECT split_part(email, '@', 2) FROM users",
+ "EXPLAIN SELECT 1",
+ "SELECT 1",
+ ] {
+ assert_eq!(
+ prove_read_only_for_database(sql, DatabaseType::Postgres),
+ ReadProof::ProvenReadOnly,
+ "expected proven: {sql}"
+ );
+ }
+ for sql in [
+ "WITH w AS (INSERT INTO t VALUES (1) RETURNING *) SELECT * FROM w",
+ "SELECT * INTO new_t FROM t",
+ "SELECT * FROM t FOR UPDATE",
+ // FOR SHARE parses into query.locks like FOR UPDATE; the other two
+ // PG lock strengths (FOR NO KEY UPDATE / FOR KEY SHARE) fail the
+ // parse itself in sqlparser 0.62 and stay Unproven fail-closed.
+ "SELECT * FROM t FOR SHARE",
+ "SELECT * FROM t FOR NO KEY UPDATE",
+ "SELECT * FROM t FOR KEY SHARE",
+ "SELECT nextval('seq')",
+ "SELECT setval('seq', 1)",
+ "SELECT pg_sleep(1)",
+ "SELECT lo_import('/etc/passwd')",
+ "EXPLAIN ANALYZE SELECT 1",
+ "SELECT my_udf()",
+ "SELECT id, row_number() OVER () FROM t",
+ "SELECT 1; SELECT 2",
+ ] {
+ assert_eq!(
+ prove_read_only_for_database(sql, DatabaseType::Postgres),
+ ReadProof::Unproven,
+ "expected unproven: {sql}"
+ );
+ }
+ }
+
+ #[test]
+ fn proof_visitor_covers_from_subqueries_ctes_lateral_and_order_by() {
+ // visit_expressions skips FROM subqueries; the proof's visitor must not.
+ for sql in [
+ "SELECT * FROM (SELECT SLEEP(1)) AS x",
+ "SELECT * FROM t ORDER BY SLEEP(1)",
+ "SELECT a FROM t, LATERAL (SELECT my_udf() FROM u) d",
+ "SELECT * FROM generate_series(1, 10)",
+ ] {
+ assert_eq!(
+ prove_read_only_for_database(sql, DatabaseType::Postgres),
+ ReadProof::Unproven,
+ "expected unproven: {sql}"
+ );
+ }
+ assert_eq!(
+ prove_read_only_for_database("SELECT * FROM (SELECT 1) AS x ORDER BY md5(id)", DatabaseType::Postgres),
+ ReadProof::ProvenReadOnly
+ );
+ }
+
+ #[test]
+ fn proof_only_applies_to_enabled_dialects() {
+ // Oracle keeps its own lexical classifier; the gate in query.rs must
+ // never route it through the generic proof.
+ assert_eq!(prove_read_only_for_database("SELECT 1", DatabaseType::Oracle), ReadProof::Unproven);
+ // Family members without manual-transaction UI stay unproven in v1.
+ assert_eq!(prove_read_only_for_database("SELECT 1", DatabaseType::Doris), ReadProof::Unproven);
+ }
+
#[test]
fn classify_select_statements() {
assert_eq!(classify_sql_risk("SELECT * FROM users", "postgres").unwrap(), SqlRisk::ReadOnly);