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);