From 2b1c5fa71eb24f2611cae69e854cdb3d7164420f Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 15 Sep 2026 15:37:36 +0800 Subject: [PATCH] fix(oceanbase): support manual transaction control in oracle mode --- .../java/com/dbx/agent/AbstractJdbcAgent.java | 31 +++++--- .../java/com/dbx/agent/DatabaseAgent.java | 72 +++++++++++++++++-- .../com/dbx/agent/JdbcSchemaSwitcher.java | 18 +++++ .../java/com/dbx/agent/JsonRpcServer.java | 33 ++++++++- .../com/dbx/agent/AbstractJdbcAgentTest.java | 16 +++++ .../agent/CommonJavaCompatibilityTest.java | 36 ++++++++++ .../dbx/agent/JdbcConnectionPoolingTest.java | 59 +++++++++++++++ .../OceanBaseOracleAgentTest.java | 71 ++++++++++++++++++ .../src/components/layout/EditorGroup.vue | 3 +- .../database/databaseFeatureSupport.spec.ts | 19 ++++- .../lib/database/databaseFeatureSupport.ts | 15 +++- ...queryStore.manualTransactionExpiry.spec.ts | 37 ++++++---- .../queryStore.multiStatementError.spec.ts | 12 ++-- apps/desktop/src/stores/queryStore.ts | 12 ++-- crates/dbx-core/src/query.rs | 9 +-- 15 files changed, 391 insertions(+), 52 deletions(-) diff --git a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java index 0f65d08c6f..8006768c7a 100644 --- a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java @@ -255,13 +255,19 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { @Override public QueryResult executeTransaction(List statements, String schema) { - return TransactionExecutor.executeUpdateStatements( - requireConnected(), - statements, - schema, - this::setSchemaSQL, - this::resetSchemaSQL - ); + Connection conn = requireConnected(); + return unchecked(() -> { + if (!conn.getAutoCommit()) { + throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open"); + } + return TransactionExecutor.executeUpdateStatements( + conn, + statements, + schema, + this::setSchemaSQL, + this::resetSchemaSQL + ); + }); } @Override @@ -377,7 +383,9 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { JdbcExecutor executor, boolean succeeded, boolean requiresSessionAffinity, - boolean evictAfterRequest + boolean evictAfterRequest, + boolean endsSessionAffinity, + boolean preservesSchemaContext ) { if (poolRegistry == null) { return; @@ -389,7 +397,12 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { } if (succeeded && requiresSessionAffinity) { sessionAffinity = true; - JdbcSchemaSwitcher.forget(connection); + if (!preservesSchemaContext) { + JdbcSchemaSwitcher.forget(connection); + } + } + if (succeeded && endsSessionAffinity) { + sessionAffinity = false; } if (pooledLease == null) { connection = null; diff --git a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java index 7f7e79095e..81eae8898e 100644 --- a/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/DatabaseAgent.java @@ -224,13 +224,71 @@ public interface DatabaseAgent { if (conn == null) { throw new IllegalStateException("Not connected"); } - return TransactionExecutor.executeUpdateStatements( - conn, - statements, - schema, - this::setSchemaSQL, - this::resetSchemaSQL - ); + return unchecked(() -> { + if (!conn.getAutoCommit()) { + throw new IllegalStateException("Cannot start a one-shot transaction while a manual transaction is open"); + } + return TransactionExecutor.executeUpdateStatements( + conn, + statements, + schema, + this::setSchemaSQL, + this::resetSchemaSQL + ); + }); + } + + /** Starts an interactive transaction on the current JDBC connection. */ + default Map beginManualTransaction(String schema) { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return unchecked(() -> { + if (!conn.getAutoCommit()) { + throw new IllegalStateException("Manual transaction already open"); + } + if (schema != null && !schema.trim().isEmpty()) { + JdbcSchemaSwitcher.apply(conn, schema, this::setSchemaSQL, this::resetSchemaSQL); + } + conn.setAutoCommit(false); + JdbcSchemaSwitcher.preserve(conn); + return Collections.singletonMap("ok", (Object) true); + }); + } + + /** Commits an interactive transaction on the current JDBC connection. */ + default Map commitManualTransaction() { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return unchecked(() -> { + if (conn.getAutoCommit()) { + throw new IllegalStateException("No manual transaction open"); + } + conn.commit(); + conn.setAutoCommit(true); + JdbcSchemaSwitcher.releasePreserved(conn); + return Collections.singletonMap("ok", (Object) true); + }); + } + + /** Rolls back an interactive transaction on the current JDBC connection. */ + default Map rollbackManualTransaction() { + Connection conn = getConnection(); + if (conn == null) { + throw new IllegalStateException("Not connected"); + } + return unchecked(() -> { + if (conn.getAutoCommit()) { + throw new IllegalStateException("No manual transaction open"); + } + conn.rollback(); + conn.setAutoCommit(true); + JdbcSchemaSwitcher.releasePreserved(conn); + return Collections.singletonMap("ok", (Object) true); + }); } default QueryResult executeBatch(List statements, String schema) { diff --git a/agents/common/src/main/java/com/dbx/agent/JdbcSchemaSwitcher.java b/agents/common/src/main/java/com/dbx/agent/JdbcSchemaSwitcher.java index 9afc686fe2..7d6675e953 100644 --- a/agents/common/src/main/java/com/dbx/agent/JdbcSchemaSwitcher.java +++ b/agents/common/src/main/java/com/dbx/agent/JdbcSchemaSwitcher.java @@ -12,6 +12,8 @@ import java.util.function.Supplier; final class JdbcSchemaSwitcher { private static final Map RESET_STATE_BY_CONNECTION = Collections.synchronizedMap(new WeakHashMap<>()); + private static final Map PRESERVED_BY_CONNECTION = + Collections.synchronizedMap(new WeakHashMap<>()); private JdbcSchemaSwitcher() { } @@ -27,6 +29,9 @@ final class JdbcSchemaSwitcher { Supplier resetSchemaSql ) throws Exception { if (schema == null || schema.trim().isEmpty()) { + if (isPreserved(conn)) { + return; + } if (!restore(conn, true)) { throw new SQLException("Failed to restore the original JDBC schema context"); } @@ -70,7 +75,20 @@ final class JdbcSchemaSwitcher { RESET_STATE_BY_CONNECTION.remove(conn); } + static void preserve(Connection conn) { + PRESERVED_BY_CONNECTION.put(conn, Boolean.TRUE); + } + + static void releasePreserved(Connection conn) { + PRESERVED_BY_CONNECTION.remove(conn); + } + + private static boolean isPreserved(Connection conn) { + return PRESERVED_BY_CONNECTION.containsKey(conn); + } + private static boolean restore(Connection conn, boolean preserveOnFailure) { + PRESERVED_BY_CONNECTION.remove(conn); ResetState resetState = RESET_STATE_BY_CONNECTION.remove(conn); if (resetState == null) { return true; diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java index 7af6257a04..7a267fbe97 100644 --- a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -111,7 +111,9 @@ public final class JsonRpcServer { jdbcExecutor, succeeded, requiresSessionAffinity(method, params), - evictAfterRequest(method) + evictAfterRequest(method), + endsSessionAffinity(method), + preservesSchemaContext() ); } } @@ -315,6 +317,15 @@ public final class JsonRpcServer { List statements = gson.fromJson(params.get("statements"), statementsType); return agent.executeTransaction(statements, stringOrNull(params, "schema")); } + if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) { + return agent.beginManualTransaction(stringOrNull(params, "schema")); + } + if (AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method)) { + return agent.commitManualTransaction(); + } + if (AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method)) { + return agent.rollbackManualTransaction(); + } if (AgentProtocol.METHOD_EXECUTE_BATCH.equals(method)) { Type statementsType = new TypeToken>() {}.getType(); List statements = gson.fromJson(params.get("statements"), statementsType); @@ -422,6 +433,9 @@ public final class JsonRpcServer { } private boolean requiresSessionAffinity(String method, JsonObject params) { + if (AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION.equals(method)) { + return true; + } try { if (AgentProtocol.METHOD_EXECUTE_QUERY.equals(method) || AgentProtocol.METHOD_EXECUTE_QUERY_PAGE.equals(method) @@ -447,6 +461,23 @@ public final class JsonRpcServer { return false; } + private static boolean endsSessionAffinity(String method) { + return AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION.equals(method) + || AgentProtocol.METHOD_ROLLBACK_MANUAL_TRANSACTION.equals(method); + } + + private boolean preservesSchemaContext() { + Connection connection = agent.getConnection(); + if (connection == null) { + return false; + } + try { + return !connection.getAutoCommit(); + } catch (Exception ignored) { + return false; + } + } + private static boolean evictAfterRequest(String method) { return AgentProtocol.METHOD_GET_EXPLAIN_INFO.equals(method); } diff --git a/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java index 54c2708844..09bd975cfd 100644 --- a/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java +++ b/agents/common/src/test/java/com/dbx/agent/AbstractJdbcAgentTest.java @@ -300,6 +300,22 @@ class AbstractJdbcAgentTest { ); } + @Test + void rejectsOneShotTransactionWhenManualTransactionIsOpen() { + TrackingConnection tracking = new TrackingConnection(); + TestAgent agent = new TestAgent(tracking); + agent.connect(new ConnectParams()); + agent.beginManualTransaction(null); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> agent.executeTransaction(Collections.singletonList("UPDATE A SET ID = 1"), null) + ); + + assertEquals("Cannot start a one-shot transaction while a manual transaction is open", error.getMessage()); + assertEquals(Collections.singletonList("setAutoCommit:false"), tracking.calls); + } + @Test void preservesPlSqlBlockTerminatorDuringTransactionExecution() { TrackingConnection tracking = new TrackingConnection(); diff --git a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java index 1af9a28dba..ea9a24058e 100644 --- a/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java +++ b/agents/common/src/test/java/com/dbx/agent/CommonJavaCompatibilityTest.java @@ -150,6 +150,42 @@ class CommonJavaCompatibilityTest { assertEquals("Example JDBC", result.getAsJsonObject("databaseInfo").get("driverName").getAsString()); } + @Test + void jsonRpcServerDispatchesInteractiveTransactionMethods() { + List calls = new ArrayList<>(); + MinimalAgent agent = new MinimalAgent() { + @Override + public Map beginManualTransaction(String schema) { + calls.add("begin:" + schema); + return Collections.singletonMap("ok", (Object) true); + } + + @Override + public Map commitManualTransaction() { + calls.add("commit"); + return Collections.singletonMap("ok", (Object) true); + } + + @Override + public Map rollbackManualTransaction() { + calls.add("rollback"); + return Collections.singletonMap("ok", (Object) true); + } + }; + JsonRpcServer server = new JsonRpcServer(agent); + + assertTrue(JsonParser.parseString(server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"begin_manual_transaction\",\"params\":{\"schema\":\"APP\"}}" + )).getAsJsonObject().has("result")); + assertTrue(JsonParser.parseString(server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"commit_manual_transaction\",\"params\":{}}" + )).getAsJsonObject().has("result")); + assertTrue(JsonParser.parseString(server.handleRequest( + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"rollback_manual_transaction\",\"params\":{}}" + )).getAsJsonObject().has("result")); + assertEquals(List.of("begin:APP", "commit", "rollback"), calls); + } + @Test void multiSessionServerCreatesAndClosesIndependentAgents() throws Exception { java.util.List created = new java.util.ArrayList<>(); diff --git a/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java b/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java index 6a8ccaeeb4..0cd554e285 100644 --- a/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java +++ b/agents/common/src/test/java/com/dbx/agent/JdbcConnectionPoolingTest.java @@ -1869,6 +1869,65 @@ class JdbcConnectionPoolingTest { } } + @Test + void interactiveTransactionPinsConnectionUntilCommit() { + AtomicInteger physicalOpens = new AtomicInteger(); + AtomicInteger requestIds = new AtomicInteger(); + String url = h2Url("manual_transaction"); + try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 2)) { + openSession(server, requestIds, "transaction"); + query(server, requestIds, "transaction", "CREATE TABLE ITEMS (ID INT)", null); + + result(request( + server, + requestIds, + AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION, + sessionParams("transaction") + )); + query(server, requestIds, "transaction", "INSERT INTO ITEMS VALUES (1)", null); + + openSession(server, requestIds, "observer"); + JsonObject beforeCommit = query(server, requestIds, "observer", "SELECT COUNT(*) FROM ITEMS", null); + assertEquals(0, beforeCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt()); + + result(request( + server, + requestIds, + AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION, + sessionParams("transaction") + )); + JsonObject afterCommit = query(server, requestIds, "observer", "SELECT COUNT(*) FROM ITEMS", null); + assertEquals(1, afterCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsInt()); + } + } + + @Test + void interactiveTransactionRestoresSchemaBeforeReturningConnection() { + AtomicInteger physicalOpens = new AtomicInteger(); + AtomicInteger requestIds = new AtomicInteger(); + String url = h2Url("manual_transaction_schema"); + try (MultiSessionJsonRpcServer server = server(url, physicalOpens, 2)) { + openSession(server, requestIds, "transaction-schema"); + query(server, requestIds, "transaction-schema", "CREATE SCHEMA IF NOT EXISTS APP", null); + + JsonObject beginParams = sessionParams("transaction-schema"); + beginParams.addProperty("schema", "APP"); + result(request(server, requestIds, AgentProtocol.METHOD_BEGIN_MANUAL_TRANSACTION, beginParams)); + JsonObject inTransaction = query(server, requestIds, "transaction-schema", "SELECT CURRENT_SCHEMA", null); + assertEquals("APP", inTransaction.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString()); + + result(request( + server, + requestIds, + AgentProtocol.METHOD_COMMIT_MANUAL_TRANSACTION, + sessionParams("transaction-schema") + )); + openSession(server, requestIds, "schema-observer"); + JsonObject afterCommit = query(server, requestIds, "schema-observer", "SELECT CURRENT_SCHEMA", null); + assertEquals("PUBLIC", afterCommit.getAsJsonArray("rows").get(0).getAsJsonArray().get(0).getAsString()); + } + } + @Test void affinityDetectionCoversCommonSessionStateStatements() { assertTrue(JdbcConnectionAffinity.requiresSessionAffinity("SET search_path TO app")); diff --git a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java index 342f57e9c5..d13f0d5b68 100644 --- a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java +++ b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java @@ -150,6 +150,46 @@ class OceanBaseOracleAgentTest { Assertions.assertEquals(List.of(), queryTimeouts); } + @Test + void supportsCommitAndRollbackForInteractiveTransactions() { + List calls = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, transactionConnection(calls)); + + agent.beginManualTransaction(null); + Assertions.assertEquals(List.of("setAutoCommit:false"), calls); + agent.commitManualTransaction(); + Assertions.assertEquals(List.of("setAutoCommit:false", "commit", "setAutoCommit:true"), calls); + + agent.beginManualTransaction(null); + agent.rollbackManualTransaction(); + Assertions.assertEquals( + List.of("setAutoCommit:false", "commit", "setAutoCommit:true", "setAutoCommit:false", "rollback", "setAutoCommit:true"), + calls + ); + } + + @Test + void failedCommitKeepsTransactionOpenSoItCanBeRolledBack() throws SQLException { + List calls = new ArrayList<>(); + boolean[] failCommit = {true}; + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + Connection connection = transactionConnection(calls, failCommit); + TestSupport.setPrivateConnection(agent, connection); + + agent.beginManualTransaction(null); + Assertions.assertThrows(RuntimeException.class, agent::commitManualTransaction); + Assertions.assertFalse(connection.getAutoCommit()); + + failCommit[0] = false; + Assertions.assertDoesNotThrow(agent::rollbackManualTransaction); + Assertions.assertTrue(connection.getAutoCommit()); + Assertions.assertEquals( + List.of("setAutoCommit:false", "commit", "rollback", "setAutoCommit:true"), + calls + ); + } + @Test void synchronizesSessionTimeoutForEveryQueryEntryPoint() { List sql = new ArrayList<>(); @@ -847,6 +887,37 @@ class OceanBaseOracleAgentTest { return executionConnection(sql, new ArrayList<>(), List.of()); } + private static Connection transactionConnection(List calls) { + return transactionConnection(calls, new boolean[] {false}); + } + + private static Connection transactionConnection(List calls, boolean[] failCommit) { + boolean[] autoCommit = {true}; + return proxy(Connection.class, (method, args) -> { + switch (method.getName()) { + case "getAutoCommit": + return autoCommit[0]; + case "setAutoCommit": + autoCommit[0] = (Boolean) args[0]; + calls.add("setAutoCommit:" + autoCommit[0]); + return null; + case "commit": + calls.add("commit"); + if (failCommit[0]) { + throw new SQLException("commit failed"); + } + return null; + case "rollback": + calls.add("rollback"); + return null; + case "isClosed": + return false; + default: + return defaultValue(method.getReturnType()); + } + }); + } + private static Connection executionConnection( List sql, List queryTimeouts, diff --git a/apps/desktop/src/components/layout/EditorGroup.vue b/apps/desktop/src/components/layout/EditorGroup.vue index cf1a166956..f708c36c6a 100644 --- a/apps/desktop/src/components/layout/EditorGroup.vue +++ b/apps/desktop/src/components/layout/EditorGroup.vue @@ -13,6 +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 { GROUP_TAB_BAR_PORTAL } from "./groupTabBarPortal"; import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps, QueryEditorSurfaceHandle, StatementRange } from "./querySurfaces"; import type { QueryTab } from "@/types/database"; @@ -108,7 +109,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(() => effectiveDatabaseTypeForConnection(activeConnection.value) === "oracle" && (activeTab.value?.autoCommit ?? true) === false); +const isGroupOracleManualTransaction = computed(() => usesOracleStickyTransactionState(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, diff --git a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts index c46b79320a..b173b15c6b 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseFeatureSupport.spec.ts @@ -20,6 +20,7 @@ import { supportsTableImport, supportsTableVacuum, supportsTransaction, + usesOracleStickyTransactionState, usesConnectionOnlyQueryTarget, usesTreeSchemaMode, schemaNodeHasLoadableName, @@ -160,10 +161,10 @@ describe("supportsTransaction", () => { expect(supportsTransaction("mysql")).toBe(true); expect(supportsTransaction("oracle")).toBe(true); expect(supportsTransaction("jdbc")).toBe(true); + expect(supportsTransaction("oceanbase-oracle")).toBe(true); }); it("returns false for unsupported database types", () => { - expect(supportsTransaction("oceanbase-oracle")).toBe(false); expect(supportsTransaction("redis")).toBe(false); expect(supportsTransaction("mongodb")).toBe(false); expect(supportsTransaction("duckdb")).toBe(false); @@ -198,6 +199,7 @@ describe("defaultAutoCommitForDbType", () => { expect(defaultAutoCommitForDbType("postgres", "manual")).toBe(false); expect(defaultAutoCommitForDbType("oracle", "manual")).toBe(false); expect(defaultAutoCommitForDbType("jdbc", "manual")).toBe(false); + expect(defaultAutoCommitForDbType("oceanbase-oracle", "manual")).toBe(false); expect(defaultAutoCommitForDbType("mysql", "auto")).toBe(true); expect(defaultAutoCommitForDbType(undefined, "auto")).toBe(true); }); @@ -208,11 +210,24 @@ describe("defaultAutoCommitForDbType", () => { expect(defaultAutoCommitForDbType("sqlite", "manual")).toBe(true); expect(defaultAutoCommitForDbType("dameng", "manual")).toBe(true); expect(defaultAutoCommitForDbType("clickhouse", "manual")).toBe(true); - expect(defaultAutoCommitForDbType("oceanbase-oracle", "manual")).toBe(true); expect(defaultAutoCommitForDbType(undefined, "manual")).toBe(true); }); }); +describe("usesOracleStickyTransactionState", () => { + it("enables sticky manual-transaction state for Oracle family", () => { + expect(usesOracleStickyTransactionState("oracle")).toBe(true); + expect(usesOracleStickyTransactionState("oceanbase-oracle")).toBe(true); + }); + + it("keeps other databases on the generic transaction path", () => { + expect(usesOracleStickyTransactionState("mysql")).toBe(false); + expect(usesOracleStickyTransactionState("postgres")).toBe(false); + expect(usesOracleStickyTransactionState("jdbc")).toBe(false); + expect(usesOracleStickyTransactionState(undefined)).toBe(false); + }); +}); + describe("supportsSqlInListPaste", () => { it("allows generic and SQL-like editors", () => { expect(supportsSqlInListPaste(undefined)).toBe(true); diff --git a/apps/desktop/src/lib/database/databaseFeatureSupport.ts b/apps/desktop/src/lib/database/databaseFeatureSupport.ts index bb6bdb5a0a..589244d67a 100644 --- a/apps/desktop/src/lib/database/databaseFeatureSupport.ts +++ b/apps/desktop/src/lib/database/databaseFeatureSupport.ts @@ -257,7 +257,12 @@ export function usesPostgresLikeStructureCopy(dbType?: DatabaseType): boolean { return !!dbType && PG_LIKE_STRUCTURE_TYPES.has(dbType); } -const TRANSACTION_SUPPORTED_TYPES: readonly string[] = ["postgres", "mysql", "oracle", "jdbc"]; +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. */ +const ORACLE_STICKY_TRANSACTION_TYPES: ReadonlySet = new Set(["oracle", "oceanbase-oracle"]); /** * Returns true if the given database type supports explicit transaction control @@ -311,6 +316,14 @@ export function rejectsAliasReferenceInHaving(dbType?: string): boolean { return !!dbType && HAVING_ALIAS_REJECTED_DATABASE_TYPES.has(dbType); } +/** + * Returns true if the database type uses Oracle's sticky manual-transaction state + * (commit/rollback hidden until an unproven statement dirties the session). + */ +export function usesOracleStickyTransactionState(dbType?: string): boolean { + return !!dbType && ORACLE_STICKY_TRANSACTION_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.manualTransactionExpiry.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts index 438aded0b0..fb53764296 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.manualTransactionExpiry.spec.ts @@ -125,12 +125,12 @@ describe("queryStore manual transaction expiry recovery", () => { expect(tab.result?.affected_rows).toBe(1); }); - it("normalizes a stale manual OceanBase tab before query dispatch", async () => { + it("normalizes a stale manual tab for a non-transactional database before query dispatch", async () => { mocks.getConnectionConfig.mockReturnValue({ - id: "oceanbase-1", - name: "OceanBase Oracle", - db_type: "oceanbase-oracle", - database: "SYS", + id: "sqlite-1", + name: "SQLite", + db_type: "sqlite", + database: "main", query_timeout_secs: 30, }); mocks.beginManualTransaction.mockRejectedValue(new Error("BEGIN manual transaction failed: Agent RPC error (-1): Unknown method: begin_manual_transaction")); @@ -138,7 +138,7 @@ describe("queryStore manual transaction expiry recovery", () => { const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); - const tabId = store.createTab("oceanbase-1", "SYS", "Query", "query", "SYS"); + const tabId = store.createTab("sqlite-1", "main", "Query", "query", "main"); // Simulate a manual-mode value restored from an older saved query tab. store.setAutoCommit(tabId, false); @@ -153,13 +153,20 @@ describe("queryStore manual transaction expiry recovery", () => { expect(tab.result?.execution_error).not.toBe(true); }); - it("does not fall back to auto-commit when a supported manual transaction fails to begin", async () => { + it.each(["oracle", "oceanbase-oracle"] as const)("does not fall back to auto-commit when a supported %s manual transaction fails to begin", async (databaseType) => { mocks.beginManualTransaction.mockRejectedValue(new Error("manual transaction begin failed")); mocks.executeMulti.mockResolvedValue(successfulSelect()); + mocks.getConnectionConfig.mockReturnValue({ + id: `${databaseType}-1`, + name: databaseType, + db_type: databaseType, + database: "ORCL", + query_timeout_secs: 30, + }); const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); - const tabId = store.createTab("oracle-1", "ORCL", "Query", "query", "APP"); + const tabId = store.createTab(`${databaseType}-1`, "ORCL", "Query", "query", "APP"); store.setAutoCommit(tabId, false); await store.executeTabSql(tabId, "SELECT 1 FROM DUAL"); @@ -238,20 +245,20 @@ describe("queryStore manual transaction expiry recovery", () => { expect(tab.result?.rows).toEqual([[1]]); }); - it("surfaces ordinary query failures after normalizing a stale OceanBase tab", async () => { + it("surfaces ordinary query failures after normalizing a stale non-transactional tab", async () => { mocks.getConnectionConfig.mockReturnValue({ - id: "oceanbase-1", - name: "OceanBase Oracle", - db_type: "oceanbase-oracle", - database: "SYS", + id: "sqlite-1", + name: "SQLite", + db_type: "sqlite", + database: "main", query_timeout_secs: 30, }); mocks.beginManualTransaction.mockRejectedValue(new Error("BEGIN manual transaction failed: Agent RPC error (-1): Unknown method: begin_manual_transaction")); - mocks.executeMulti.mockRejectedValue(new Error("OceanBase query failed")); + mocks.executeMulti.mockRejectedValue(new Error("SQLite query failed")); const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); - const tabId = store.createTab("oceanbase-1", "SYS", "Query", "query", "SYS"); + const tabId = store.createTab("sqlite-1", "main", "Query", "query", "main"); store.setAutoCommit(tabId, false); await store.executeTabSql(tabId, "SELECT missing_column FROM DUAL"); diff --git a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts index 18b6bc6a0d..837f0a14cb 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.multiStatementError.spec.ts @@ -558,11 +558,11 @@ describe("queryStore multi-statement errors", () => { expect(tab.results).toHaveLength(4); }); - it("invalidates only the executing Oracle tab after successful CURRENT_SCHEMA changes", async () => { + it.each(["oracle", "oceanbase-oracle"] as const)("invalidates only the executing %s tab after successful CURRENT_SCHEMA changes", async (databaseType) => { mocks.getConnectionConfig.mockReturnValue({ - id: "oracle-1", - name: "Oracle", - db_type: "oracle", + id: `${databaseType}-1`, + name: databaseType, + db_type: databaseType, database: "ORCL", query_timeout_secs: 30, }); @@ -572,8 +572,8 @@ describe("queryStore multi-statement errors", () => { .mockResolvedValueOnce([{ columns: [], rows: [], affected_rows: 0, execution_time_ms: 1 }]); const { useQueryStore } = await import("@/stores/queryStore"); const store = useQueryStore(); - const tabA = store.createTab("oracle-1", "ORCL", "Tab A"); - const tabB = store.createTab("oracle-1", "ORCL", "Tab B"); + const tabA = store.createTab(`${databaseType}-1`, "ORCL", "Tab A"); + const tabB = store.createTab(`${databaseType}-1`, "ORCL", "Tab B"); // Exercise the explicit auto-commit execute-multi path. store.setAutoCommit(tabA, true); store.setAutoCommit(tabB, true); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 1d17d89a88..09e8ac479e 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -38,7 +38,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 } from "@/lib/database/databaseFeatureSupport"; +import { defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState } 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"; @@ -4578,7 +4578,7 @@ 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 (dbType === "oracle") tab.oracleTxnPossiblyDirty = true; + if (usesOracleStickyTransactionState(dbType)) tab.oracleTxnPossiblyDirty = true; } /** Reset only the Oracle sticky-dirty bit. Used when a session continues but @@ -7009,7 +7009,7 @@ export const useQueryStore = defineStore("query", () => { executionDispatched = true; // Only an initial manual execution classifies the user SQL (Oracle-only). // A later cursor-page fetch must neither set nor clear the sticky bit. - const isInitialOracleManualExecution = effectiveDbType === "oracle" && !options?.pagination?.sessionId; + const isInitialOracleManualExecution = usesOracleStickyTransactionState(effectiveDbType) && !options?.pagination?.sessionId; const classificationSql = isInitialOracleManualExecution ? queryBaseSql : undefined; let manualTransactionRecoveryAttempted = false; executionPromise = (async () => { @@ -7059,14 +7059,14 @@ export const useQueryStore = defineStore("query", () => { // must neither set nor clear the bit, and the Core no-op (empty script) // must neither set nor clear it. Otherwise any result that is not proven // read-only dirties the session monotonically. - if (tab.autoCommit === false && effectiveDbType === "oracle" && !options?.pagination?.sessionId && tab.txnSessionId) { + if (tab.autoCommit === false && usesOracleStickyTransactionState(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; } } - const successfulOracleSchemaChanges = effectiveDbType === "oracle" ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0; + const successfulOracleSchemaChanges = usesOracleStickyTransactionState(effectiveDbType) ? results.filter((result) => result.execution_error !== true && isOracleCurrentSchemaStatement(result.sourceStatement)).length : 0; const successfulSapHanaSchemaChanges = effectiveDbType === "saphana" ? results.filter((result) => result.execution_error !== true && isSapHanaSetSchemaStatement(result.sourceStatement)).length : 0; const sqlServerUseDatabase = effectiveDbType === "sqlserver" ? annotatedResults.sqlServerUseDatabase : undefined; if (hiddenPrimaryKeys.length > 0 && results.length === 1) { @@ -7297,7 +7297,7 @@ export const useQueryStore = defineStore("query", () => { clearOracleTxnPossiblyDirty(tab); tab.txnSessionId = undefined; tab.txnAutoRolledBack = false; - } else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && effectiveDatabaseTypeForConnection(useConnectionStore().getConfig(tab.connectionId)) === "oracle") { + } else if (tab.txnSessionId && executionDispatched && !options?.pagination?.sessionId && usesOracleStickyTransactionState(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 diff --git a/crates/dbx-core/src/query.rs b/crates/dbx-core/src/query.rs index 843ffa4f76..00cc80be43 100644 --- a/crates/dbx-core/src/query.rs +++ b/crates/dbx-core/src/query.rs @@ -5500,7 +5500,7 @@ pub async fn execute_in_manual_transaction_with_options( empty_query_result(0), options.table_data_preview, ); - if db_type == Some(DatabaseType::Oracle) { + if matches!(db_type, Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle)) { result = result.with_manual_transaction_no_statement(); } return Ok(vec![result]); @@ -5562,11 +5562,12 @@ pub async fn execute_in_manual_transaction_with_options( // 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 db_type == Some(DatabaseType::Oracle) { + 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, DatabaseType::Oracle); + 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()