From ac716c15988d087a84c3ec23df3eef42b3cfcbb0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 20 Sep 2026 09:57:17 +0700 Subject: [PATCH] fix(plugin-oracle): commit Oracle statements the way every other engine does --- CHANGELOG.md | 2 + .../OracleCoreConnection.swift | 86 +++++- .../TableProOracleCore/OracleCoreError.swift | 3 + .../TableProOracleCore/OraclePLSQLUnit.swift | 2 +- .../OracleSessionTransaction.swift | 81 ++++++ .../OracleTransactionRole.swift | 52 ++++ .../OracleSessionTransactionTests.swift | 141 ++++++++++ .../OracleTransactionRoleTests.swift | 86 ++++++ .../Execution/AutocommitOnlyStatement.swift | 2 +- .../Execution/BatchTransactionPolicy.swift | 1 + .../Execution/TransactionEngineFamily.swift | 15 +- TablePro/Resources/Localizable.xcstrings | 3 + .../TableProMobile/Drivers/OracleDriver.swift | 8 +- .../TableProMobile/Localizable.xcstrings | 3 + .../Drivers/OracleDriverTests.swift | 46 ++++ .../OracleDriverTransactionStateTests.swift | 35 +++ .../AutocommitOnlyStatementTests.swift | 12 + .../BatchTransactionPolicyTests.swift | 29 ++ .../TransactionEngineFamilyTests.swift | 20 +- docs/databases/oracle.mdx | 16 ++ docs/features/sql-editor.mdx | 8 +- docs/ios/index.mdx | 2 +- scripts/check-oracle-autocommit.sh | 254 ++++++++++++++++++ 23 files changed, 887 insertions(+), 20 deletions(-) create mode 100644 Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift create mode 100644 Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift create mode 100644 Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift create mode 100644 Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift create mode 100644 TableProMobile/TableProMobileTests/Drivers/OracleDriverTransactionStateTests.swift create mode 100755 scripts/check-oracle-autocommit.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 674a0591f8..e4549198cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Acknowledgements and a privacy policy link under **Settings > About** on iPhone and iPad. - Privacy manifest for the iOS app. - Oracle `DBMS_OUTPUT` lines shown with the result of the statement that printed them, and in a new **Output** result view. +- Oracle transactions opened with `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE`, held until `COMMIT` or `ROLLBACK`. ### Changed @@ -44,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL*Plus `/` lines, `q'[…]'` literals and backslashes in strings misread in Oracle scripts. - `:NEW` and `:OLD` in an Oracle trigger body opening the parameter panel. - 1 row affected reported for every Oracle PL/SQL block. +- Oracle statements run outside a transaction never committed, on Mac and on iPhone and iPad. - MySQL procedures with a `CASE` statement swallowing the statements after them in the editor. - Icon-only buttons announced as nothing by VoiceOver across the data grid, row inspector, editor find bar, filter bar, structure, dashboard and settings. - Status icons that carried a result only as a symbol and a colour, silent to VoiceOver, in the AWS and app import steps and the plugin lists. diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index 7dae05a0b8..52771f76b2 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -54,10 +54,15 @@ public final class OracleCoreConnection: @unchecked Sendable { private let unsupportedWarner = UnsupportedTypeWarner() private let nioLogger = Logging.Logger(label: "com.TablePro.oracle-nio") + /// Restoring a replaced session's settings writes nothing, so it neither commits nor joins a transaction. + private static let sessionSetupOptions = StatementOptions(autoCommit: false) + private struct LockedState: Sendable { var isConnected = false var hasEverConnected = false var nioConnection: OracleNIO.OracleConnection? + var sessionID = 0 + var transaction = OracleSessionTransaction() var queryTimeoutSeconds = 0 var sessionSchema: String? var capturesServerOutput = false @@ -130,6 +135,7 @@ public final class OracleCoreConnection: @unchecked Sendable { state.withLock { current in current.nioConnection = connection + current.sessionID = connectionId current.isConnected = true current.hasEverConnected = true } @@ -322,17 +328,75 @@ public final class OracleCoreConnection: @unchecked Sendable { let connection = try requireConnection() if let schema = state.withLock({ $0.sessionSchema }) { _ = try await withQueryDeadline { [self] in - try await collectRows(OracleSchemaQueries.setCurrentSchema(schema), on: connection) + try await collectRows( + OracleSchemaQueries.setCurrentSchema(schema), + options: Self.sessionSetupOptions, + on: connection + ) } } if state.withLock({ $0.capturesServerOutput }) { _ = try await withQueryDeadline { [self] in - try await collectRows(OracleServerOutput.enableStatement, on: connection) + try await collectRows( + OracleServerOutput.enableStatement, + options: Self.sessionSetupOptions, + on: connection + ) } } return connection } + // MARK: - Transactions + + /// Whether a transaction is open on this session, from ``beginTransaction()`` or from a statement that opens one, + /// until a `COMMIT` or `ROLLBACK` ends it. + public var holdsTransaction: Bool { + state.withLock { $0.transaction.isOpen } + } + + /// Holds every statement that follows in one transaction, until a `COMMIT` or `ROLLBACK` runs on the session. + /// + /// Oracle has no statement that opens a transaction the way `BEGIN` does elsewhere: the first write opens one. So + /// this sends nothing, and the statements after it simply stop committing as they run. + public func beginTransaction() { + state.withLock { $0.transaction.open() } + } + + /// The options a statement in `role` runs with, and the connection it runs on. Read under the query gate and after + /// any reconnect, so the connection a transaction is bound to is the one the statement runs on. + private func admit(_ role: OracleTransactionRole) throws -> (options: StatementOptions, session: Int) { + try state.withLock { current in + let autoCommit = try current.transaction.admit(role, on: current.sessionID) + return (StatementOptions(autoCommit: autoCommit), current.sessionID) + } + } + + private func recordSuccess(of role: OracleTransactionRole, on session: Int) { + state.withLock { $0.transaction.statementSucceeded(role, on: session) } + } + + /// Called under the query gate, so the answer about the transaction comes from the connection the refused + /// `COMMIT` or `ROLLBACK` ran on. + private func recordFailure(of role: OracleTransactionRole) async { + guard role == .endsTransaction, holdsTransaction else { return } + let serverHoldsTransaction = await serverHoldsTransaction() + state.withLock { $0.transaction.statementFailed(role, serverHoldsTransaction: serverHoldsTransaction) } + } + + private func serverHoldsTransaction() async -> Bool? { + guard let connection = state.withLock({ $0.isConnected ? $0.nioConnection : nil }) else { return nil } + let answer = try? await withQueryDeadline { [self] in + try await collectRows( + OracleSessionTransaction.serverTransactionQuery, + options: Self.sessionSetupOptions, + on: connection + ) + } + guard let answer else { return nil } + return answer.rows.first?.first.map { $0 != .null } ?? false + } + // MARK: - Server Output /// Turns `DBMS_OUTPUT` on for this session, and for every session a reconnect replaces it with, since the setting @@ -442,13 +506,16 @@ public final class OracleCoreConnection: @unchecked Sendable { } public func executeQuery(_ query: String) async throws -> OracleRawResult { + let role = OracleTransactionRole(of: query) await queryGate.acquire() do { let connection = try await reconnectedConnection() + let admitted = try admit(role) let result = try await withQueryDeadline { [self] in - try await collectRows(query, on: connection) + try await collectRows(query, options: admitted.options, on: connection) } + recordSuccess(of: role, on: admitted.session) await queryGate.release() return result } catch { @@ -456,6 +523,7 @@ public final class OracleCoreConnection: @unchecked Sendable { /// can redial and install a new connection. Marking the failure dead after that would tear /// down the connection the next query is already running on. let mapped = mapExecutionError(error) + await recordFailure(of: role) await queryGate.release() throw mapped } @@ -463,10 +531,11 @@ public final class OracleCoreConnection: @unchecked Sendable { private func collectRows( _ query: String, + options: StatementOptions, on connection: OracleNIO.OracleConnection ) async throws -> OracleRawResult { let statement = OracleStatement(stringLiteral: query) - let stream = try await connection.execute(statement, logger: nioLogger) + let stream = try await connection.execute(statement, options: options, logger: nioLogger) let columnNames = stream.columns.map(\.name) var columnTypeNames: [String] = [] @@ -519,17 +588,21 @@ public final class OracleCoreConnection: @unchecked Sendable { _ query: String, continuation: AsyncThrowingStream.Continuation ) async throws { + let role = OracleTransactionRole(of: query) await queryGate.acquire() do { let connection = try await reconnectedConnection() + let admitted = try admit(role) try await withQueryDeadline { [self] in - try await streamRows(query, on: connection, continuation: continuation) + try await streamRows(query, options: admitted.options, on: connection, continuation: continuation) } + recordSuccess(of: role, on: admitted.session) await queryGate.release() continuation.finish() } catch { let mapped = mapExecutionError(error) + await recordFailure(of: role) await queryGate.release() throw mapped } @@ -537,11 +610,12 @@ public final class OracleCoreConnection: @unchecked Sendable { private func streamRows( _ query: String, + options: StatementOptions, on connection: OracleNIO.OracleConnection, continuation: AsyncThrowingStream.Continuation ) async throws { let statement = OracleStatement(stringLiteral: query) - let stream = try await connection.execute(statement, logger: nioLogger) + let stream = try await connection.execute(statement, options: options, logger: nioLogger) let columnNames = stream.columns.map(\.name) var columnTypeNames: [String] = [] diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreError.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreError.swift index 147b362a3b..715cfbb619 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreError.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreError.swift @@ -21,6 +21,7 @@ public enum OracleCoreError: LocalizedError, Sendable, Equatable { case protocolError case loginTimedOut case queryTimedOut + case transactionLost case authVerifierUnsupported(flag: String) case authVersionNotSupported case authConnectionDropped(phase: String?) @@ -46,6 +47,8 @@ public enum OracleCoreError: LocalizedError, Sendable, Equatable { return String(localized: "Timed out during the Oracle login handshake. The server accepted the network connection but did not finish logging in.") case .queryTimedOut: return String(localized: "The query did not finish within the configured timeout, so the connection was reset. Run the query again.") + case .transactionLost: + return String(localized: "The connection was lost while a transaction was open. Check which of its changes were saved before running them again.") case .authVerifierUnsupported: return String(localized: "This account uses a password verifier the database driver does not support.") case .authVersionNotSupported: diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLUnit.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLUnit.swift index e8bfd0f7ca..0d0164c8ad 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLUnit.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OraclePLSQLUnit.swift @@ -115,7 +115,7 @@ public struct OracleCompilationError: Sendable, Equatable { } /// Reads the words and identifiers at the head of a statement, past comments. -private struct HeaderReader { +struct HeaderReader { private let scalars: [Unicode.Scalar] private var index = 0 diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift new file mode 100644 index 0000000000..9c5bb23cff --- /dev/null +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Whether the session has a transaction open, and which connection it lives on. +/// +/// Oracle never commits on its own: every write joins a transaction that stays open until something commits it, and +/// oracle-nio sends no commit unless a statement asks for one. TablePro presents every engine the same way, with each +/// statement committed as it runs unless a transaction is open, so a statement run with none open carries the +/// driver's commit flag and the server commits it in the same round trip. +/// +/// A transaction opens with ``open()``, which is what the app's own writes call, or with a statement that opens one +/// (``OracleTransactionRole/opensTransaction``). It ends with a `COMMIT` or `ROLLBACK`, whoever sends it. +/// +/// The transaction belongs to the connection its first statement ran on. A connection that closes takes the +/// transaction with it, so a statement that finds the transaction's connection replaced fails with +/// ``OracleCoreError/transactionLost`` rather than carrying on in a new session that holds none of the earlier work. +struct OracleSessionTransaction: Equatable, Sendable { + private(set) var isOpen = false + private var session: Int? + + mutating func open() { + isOpen = true + } + + /// Whether a statement in `role` runs with the commit flag on the connection `session`. + /// + /// Inside a transaction nothing commits on its own, and the transaction is bound to `session` if no statement has + /// bound it yet. A query takes no part: it writes nothing, and it may run on any connection. + mutating func admit(_ role: OracleTransactionRole, on session: Int) throws -> Bool { + switch role { + case .query: + return false + case .opensTransaction, .endsTransaction: + guard isOpen else { return false } + case .other: + guard isOpen else { return true } + } + try bind(to: session) + return false + } + + /// A statement that opens a transaction opens it here only once Oracle has accepted it, and a `COMMIT` or + /// `ROLLBACK` ends it. + mutating func statementSucceeded(_ role: OracleTransactionRole, on session: Int) { + switch role { + case .opensTransaction: + isOpen = true + self.session = session + case .endsTransaction: + close() + case .query, .other: + break + } + } + + /// A `COMMIT` or `ROLLBACK` the server refused ends the transaction only when the server no longer holds one: a + /// malformed one leaves it open, and a commit a deferred constraint rolled back (ORA-02091) ends it. + /// + /// `serverHoldsTransaction` is nil when there was no live connection to ask. The transaction is then left as it + /// is, and the next write finds out whether its connection survived. + mutating func statementFailed(_ role: OracleTransactionRole, serverHoldsTransaction: Bool?) { + guard role == .endsTransaction, serverHoldsTransaction == false else { return } + close() + } + + /// What the server answers for the session's own transaction: its id while one is open, NULL otherwise. Named + /// with its owner, because a bare `DBMS_TRANSACTION` resolves to an object of that name in the current schema first. + static let serverTransactionQuery = "SELECT SYS.DBMS_TRANSACTION.LOCAL_TRANSACTION_ID FROM SYS.DUAL" + + private mutating func bind(to session: Int) throws { + if let bound = self.session, bound != session { + close() + throw OracleCoreError.transactionLost + } + self.session = session + } + + private mutating func close() { + isOpen = false + session = nil + } +} diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift new file mode 100644 index 0000000000..b0f322bffc --- /dev/null +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift @@ -0,0 +1,52 @@ +import Foundation + +/// What a statement does to the session's transaction, read from its first words. +enum OracleTransactionRole: Equatable, Sendable { + /// `SELECT` or `WITH`. A query writes nothing, so it never needs a commit, and a commit sent with a + /// `SELECT ... FOR UPDATE` ends the transaction its cursor belongs to: measured on Oracle 23ai, fetching past the + /// first round trip then fails with ORA-01002. + case query + + /// `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE`. Each one only means something inside a transaction, and Oracle + /// opens one for it (measured: `DBMS_TRANSACTION.LOCAL_TRANSACTION_ID` is set after a `SAVEPOINT` alone), so it + /// opens one here rather than being committed away the moment it runs. + case opensTransaction + + /// `COMMIT`, or a `ROLLBACK` that is not to a savepoint. + case endsTransaction + + /// Everything else: DML, DDL, PL/SQL, `CALL`, `ROLLBACK TO`, and the `FORCE` forms of `COMMIT` and `ROLLBACK`, + /// which settle an in-doubt distributed transaction rather than the session's own. + case other + + /// How much of a statement is read for its first words. A dump can hold a statement millions of characters long, + /// and every statement the driver runs is read here. + private static let headerScanLimit = 4_096 + + init(of sql: String) { + var reader = HeaderReader(String(String.UnicodeScalarView(sql.unicodeScalars.prefix(Self.headerScanLimit)))) + switch reader.nextWord() { + case "SELECT", "WITH": + self = .query + case "SAVEPOINT": + self = .opensTransaction + case "SET": + self = reader.nextWord() == "TRANSACTION" ? .opensTransaction : .other + case "LOCK": + self = reader.nextWord() == "TABLE" ? .opensTransaction : .other + case "COMMIT": + self = Self.skippingWork(&reader) == "FORCE" ? .other : .endsTransaction + case "ROLLBACK": + let word = Self.skippingWork(&reader) + self = word == "TO" || word == "FORCE" ? .other : .endsTransaction + default: + self = .other + } + } + + private static func skippingWork(_ reader: inout HeaderReader) -> String? { + let word = reader.nextWord() + guard word == "WORK" else { return word } + return reader.nextWord() + } +} diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift new file mode 100644 index 0000000000..d12aa1aee1 --- /dev/null +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift @@ -0,0 +1,141 @@ +@testable import TableProOracleCore +import XCTest + +/// Oracle commits nothing on its own, so a write the driver runs without the commit flag stays invisible to every +/// other session, and holds its locks, until something commits it. These pin when the flag is sent. +final class OracleSessionTransactionTests: XCTestCase { + func testAWriteOutsideATransactionCommitsAsItRuns() throws { + var transaction = OracleSessionTransaction() + XCTAssertTrue(try transaction.admit(.other, on: 1)) + XCTAssertFalse(transaction.isOpen) + } + + func testAQueryNeverCarriesACommit() throws { + var transaction = OracleSessionTransaction() + XCTAssertFalse(try transaction.admit(.query, on: 1)) + transaction.open() + XCTAssertFalse(try transaction.admit(.query, on: 1)) + } + + func testNothingCommitsInsideATransactionUntilItEnds() throws { + var transaction = OracleSessionTransaction() + transaction.open() + XCTAssertFalse(try transaction.admit(.other, on: 1)) + XCTAssertFalse(try transaction.admit(.other, on: 1)) + + XCTAssertFalse(try transaction.admit(.endsTransaction, on: 1)) + transaction.statementSucceeded(.endsTransaction, on: 1) + XCTAssertFalse(transaction.isOpen) + XCTAssertTrue(try transaction.admit(.other, on: 1)) + } + + func testACommitTheServerRolledBackEndsTheTransaction() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.endsTransaction, on: 1) + transaction.statementFailed(.endsTransaction, serverHoldsTransaction: false) + XCTAssertFalse(transaction.isOpen) + } + + func testAMalformedCommitLeavesTheTransactionOpen() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.endsTransaction, on: 1) + transaction.statementFailed(.endsTransaction, serverHoldsTransaction: true) + XCTAssertTrue(transaction.isOpen) + XCTAssertFalse(try transaction.admit(.other, on: 1)) + } + + func testACommitWithNoConnectionToAskLeavesTheTransactionToTheNextWrite() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.endsTransaction, on: 1) + transaction.statementFailed(.endsTransaction, serverHoldsTransaction: nil) + XCTAssertTrue(transaction.isOpen) + XCTAssertThrowsError(try transaction.admit(.other, on: 2)) { error in + XCTAssertEqual(error as? OracleCoreError, .transactionLost) + } + } + + func testAFailedWriteLeavesTheTransactionOpen() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + transaction.statementFailed(.other, serverHoldsTransaction: false) + XCTAssertTrue(transaction.isOpen) + } + + func testACommitWithNoTransactionOpenIsSentAsIs() throws { + var transaction = OracleSessionTransaction() + XCTAssertFalse(try transaction.admit(.endsTransaction, on: 1)) + transaction.statementSucceeded(.endsTransaction, on: 1) + XCTAssertFalse(transaction.isOpen) + } + + func testASavepointOpensATransactionOnceOracleAcceptsIt() throws { + var transaction = OracleSessionTransaction() + XCTAssertFalse(try transaction.admit(.opensTransaction, on: 1)) + XCTAssertFalse(transaction.isOpen) + transaction.statementSucceeded(.opensTransaction, on: 1) + XCTAssertTrue(transaction.isOpen) + XCTAssertFalse(try transaction.admit(.other, on: 1)) + } + + func testARefusedSavepointOpensNothing() throws { + var transaction = OracleSessionTransaction() + _ = try transaction.admit(.opensTransaction, on: 1) + transaction.statementFailed(.opensTransaction, serverHoldsTransaction: nil) + XCTAssertFalse(transaction.isOpen) + XCTAssertTrue(try transaction.admit(.other, on: 1)) + } + + func testAWriteOnAReplacedConnectionReportsTheTransactionLost() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + + XCTAssertThrowsError(try transaction.admit(.other, on: 2)) { error in + XCTAssertEqual(error as? OracleCoreError, .transactionLost) + } + XCTAssertFalse(transaction.isOpen) + XCTAssertTrue(try transaction.admit(.other, on: 2)) + } + + func testACommitOnAReplacedConnectionReportsTheTransactionLost() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + + XCTAssertThrowsError(try transaction.admit(.endsTransaction, on: 2)) { error in + XCTAssertEqual(error as? OracleCoreError, .transactionLost) + } + XCTAssertFalse(transaction.isOpen) + } + + func testATransactionBindsToTheConnectionOfItsFirstWriteNotItsFirstRead() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.query, on: 1) + XCTAssertFalse(try transaction.admit(.other, on: 2)) + XCTAssertThrowsError(try transaction.admit(.other, on: 3)) + } + + func testAQueryOnAReplacedConnectionStillRuns() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + XCTAssertFalse(try transaction.admit(.query, on: 2)) + XCTAssertTrue(transaction.isOpen) + } + + func testOpeningAnOpenTransactionKeepsItsConnection() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + transaction.open() + XCTAssertThrowsError(try transaction.admit(.other, on: 2)) + } +} diff --git a/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift new file mode 100644 index 0000000000..b79c3b2fdc --- /dev/null +++ b/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift @@ -0,0 +1,86 @@ +@testable import TableProOracleCore +import XCTest + +/// Whether a statement commits as it runs is decided from its first words, so a misread one either leaves a write +/// uncommitted or commits away a savepoint or a lock the user meant to hold. +final class OracleTransactionRoleTests: XCTestCase { + func testQueriesAreReadsWhateverLeadsThem() { + for sql in [ + "SELECT * FROM emp", + "select 1 from dual", + "WITH t AS (SELECT 1 a FROM dual) SELECT a FROM t", + "SELECT * FROM emp FOR UPDATE", + "-- latest hires\nSELECT * FROM emp", + "/* report */ SELECT 1 FROM dual", + ] { + XCTAssertEqual(OracleTransactionRole(of: sql), .query, sql) + } + } + + func testStatementsThatOnlyMeanSomethingInsideATransactionOpenOne() { + for sql in [ + "SAVEPOINT before_raise", + "savepoint a", + "SET TRANSACTION READ ONLY", + "set transaction isolation level serializable", + "SET TRANSACTION NAME 'nightly'", + "LOCK TABLE emp IN EXCLUSIVE MODE", + "-- hold it\nLOCK TABLE emp IN SHARE MODE NOWAIT", + ] { + XCTAssertEqual(OracleTransactionRole(of: sql), .opensTransaction, sql) + } + } + + func testCommitAndAFullRollbackEndTheTransaction() { + for sql in [ + "COMMIT", + "commit work", + "COMMIT COMMENT 'nightly load'", + "COMMIT WRITE BATCH NOWAIT", + "ROLLBACK", + "rollback work", + "/* undo */ ROLLBACK", + ] { + XCTAssertEqual(OracleTransactionRole(of: sql), .endsTransaction, sql) + } + } + + func testARollbackToASavepointKeepsTheTransactionOpen() { + for sql in ["ROLLBACK TO before_raise", "ROLLBACK TO SAVEPOINT a", "rollback work to savepoint a"] { + XCTAssertEqual(OracleTransactionRole(of: sql), .other, sql) + } + } + + func testSettlingAnInDoubtDistributedTransactionLeavesTheSessionsOwnAlone() { + for sql in ["COMMIT FORCE '1.2.3'", "commit work force '1.2.3', 42", "ROLLBACK FORCE '1.2.3'", "ROLLBACK WORK FORCE '1.2.3'"] { + XCTAssertEqual(OracleTransactionRole(of: sql), .other, sql) + } + } + + func testAVeryLongStatementIsReadFromItsHead() { + let values = Array(repeating: "(1)", count: 500_000).joined(separator: ", ") + XCTAssertEqual(OracleTransactionRole(of: "INSERT INTO t VALUES \(values)"), .other) + XCTAssertEqual(OracleTransactionRole(of: "SELECT \(values) FROM dual"), .query) + } + + func testEverythingElseIsOrdinaryWork() { + for sql in [ + "INSERT INTO emp VALUES (1)", + "UPDATE emp SET sal = sal * 2", + "DELETE FROM emp", + "MERGE INTO emp USING dual ON (1 = 1) WHEN MATCHED THEN UPDATE SET sal = 1", + "CREATE TABLE t (a NUMBER)", + "BEGIN NULL; END;", + "DECLARE v NUMBER; BEGIN v := 1; END;", + "CALL p()", + "ALTER SESSION SET CURRENT_SCHEMA = hr", + "SET ROLE ALL", + "LOCK", + "EXPLAIN PLAN FOR SELECT 1 FROM dual", + "", + " ", + ] { + XCTAssertEqual(OracleTransactionRole(of: sql), .other, sql) + } + } +} diff --git a/TablePro/Core/Services/Execution/AutocommitOnlyStatement.swift b/TablePro/Core/Services/Execution/AutocommitOnlyStatement.swift index e715f8df40..3a201c8059 100644 --- a/TablePro/Core/Services/Execution/AutocommitOnlyStatement.swift +++ b/TablePro/Core/Services/Execution/AutocommitOnlyStatement.swift @@ -44,7 +44,7 @@ internal enum AutocommitOnlyStatement { return matchesDuckDB(statement, rules: rules) case .sqlServer: return matchesSQLServer(statement, rules: rules) - case .redis, .other: + case .oracle, .redis, .other: return false } } diff --git a/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift b/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift index 470a792910..8df446c532 100644 --- a/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift +++ b/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift @@ -81,6 +81,7 @@ internal enum BatchTransactionPolicy { let following = cursor.next()?.word return following == "START" || following == "BEGIN" case "SET": + guard !family.setTransactionOpensTransaction else { return cursor.next()?.word == "TRANSACTION" } return setsCommitMode(&cursor, family: family) default: return false diff --git a/TablePro/Core/Services/Execution/TransactionEngineFamily.swift b/TablePro/Core/Services/Execution/TransactionEngineFamily.swift index 04de0c19ee..e0594f8a7b 100644 --- a/TablePro/Core/Services/Execution/TransactionEngineFamily.swift +++ b/TablePro/Core/Services/Execution/TransactionEngineFamily.swift @@ -25,6 +25,7 @@ internal enum TransactionEngineFamily: String, Hashable, Sendable, CaseIterable case sqlite case duckdb case sqlServer + case oracle case redis case other @@ -48,10 +49,19 @@ internal enum TransactionEngineFamily: String, Hashable, Sendable, CaseIterable /// Whether `SAVEPOINT` opens a transaction of its own, which makes a batch holding one a script /// that manages its own transaction rather than one running in autocommit. Measured on SQLite /// 3.54.0: `SAVEPOINT a; INSERT ...; BEGIN` answers "cannot start a transaction within a - /// transaction". DuckDB has no `SAVEPOINT` at all, and PostgreSQL rejects one outside a + /// transaction", and on Oracle 23ai `DBMS_TRANSACTION.LOCAL_TRANSACTION_ID` is set after a + /// `SAVEPOINT` alone. DuckDB has no `SAVEPOINT` at all, and PostgreSQL rejects one outside a /// transaction block instead of opening one. internal var savepointOpensTransaction: Bool { - self == .sqlite + self == .sqlite || self == .oracle + } + + /// Whether `SET TRANSACTION` opens a transaction, which makes a batch holding one a script that + /// manages its own. Measured on Oracle 23ai: `DBMS_TRANSACTION.LOCAL_TRANSACTION_ID` is set + /// right after it, and the transaction lasts until a `COMMIT` or `ROLLBACK`. Oracle has no + /// `BEGIN` for a transaction, so this is the statement a script opens one with. + internal var setTransactionOpensTransaction: Bool { + self == .oracle } private static let familiesByTypeId: [String: TransactionEngineFamily] = [ @@ -71,6 +81,7 @@ internal enum TransactionEngineFamily: String, Hashable, Sendable, CaseIterable "Turso": .sqlite, "DuckDB": .duckdb, "SQL Server": .sqlServer, + "Oracle": .oracle, "Redis": .redis ] } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index aba11b3591..48ee6d091f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -182127,6 +182127,9 @@ }, "Only the first %lld lines are shown." : { + }, + "The connection was lost while a transaction was open. Check which of its changes were saved before running them again." : { + } }, "version" : "1.1" diff --git a/TableProMobile/TableProMobile/Drivers/OracleDriver.swift b/TableProMobile/TableProMobile/Drivers/OracleDriver.swift index 89e564e7e6..144b5d11b7 100644 --- a/TableProMobile/TableProMobile/Drivers/OracleDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/OracleDriver.swift @@ -180,7 +180,9 @@ nonisolated final class OracleDriver: DatabaseDriver, @unchecked Sendable { // MARK: - Transactions - func beginTransaction() async throws {} + func beginTransaction() async throws { + core.beginTransaction() + } func commitTransaction() async throws { _ = try await runQuery(OracleSchemaQueries.commitTransaction) @@ -190,6 +192,10 @@ nonisolated final class OracleDriver: DatabaseDriver, @unchecked Sendable { _ = try await runQuery(OracleSchemaQueries.rollbackTransaction) } + func sessionTransactionState() async -> DriverTransactionState { + core.holdsTransaction ? .explicitTransaction : .idle + } + // MARK: - Database & Schema Navigation /// Oracle has no database concept separate from the schema, so the sidebar's diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 8b4bc5546b..2536ebff7c 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -16380,6 +16380,9 @@ } } } + }, + "The connection was lost while a transaction was open. Check which of its changes were saved before running them again." : { + }, "The data could not be read: %@" : { "localizations" : { diff --git a/TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift index 7f5f143066..37da952817 100644 --- a/TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/OracleDriverTests.swift @@ -184,6 +184,43 @@ final class OracleDriverTests: XCTestCase { XCTAssertEqual(result.rows.first?.first, "0") } + func testAQueryEditorWriteIsVisibleToAnotherSession() async throws { + let driver = try XCTUnwrap(driver) + try await recreateTable("TP_MOBILE_AUTOCOMMIT", body: "v NUMBER(10)") + for try await _ in driver.executeStreaming(query: "INSERT INTO TP_MOBILE_AUTOCOMMIT VALUES (1)", options: .default) {} + + let seen = try await countSeenByAnotherSession("TP_MOBILE_AUTOCOMMIT") + XCTAssertEqual(seen, "1") + } + + func testARowEditIsVisibleToAnotherSession() async throws { + let driver = try XCTUnwrap(driver) + try await recreateTable("TP_MOBILE_ROW_EDIT", body: "v NUMBER(10)") + try await driver.executeWrite(["INSERT INTO TP_MOBILE_ROW_EDIT VALUES (1)"]) + + let seen = try await countSeenByAnotherSession("TP_MOBILE_ROW_EDIT") + XCTAssertEqual(seen, "1") + let state = await driver.sessionTransactionState() + XCTAssertEqual(state, .idle) + } + + func testAnOpenTransactionHoldsItsWritesUntilRollback() async throws { + let driver = try XCTUnwrap(driver) + try await recreateTable("TP_MOBILE_HELD", body: "v NUMBER(10)") + try await driver.beginTransaction() + _ = try await driver.execute(query: "INSERT INTO TP_MOBILE_HELD VALUES (1)") + let stateInside = await driver.sessionTransactionState() + XCTAssertEqual(stateInside, .explicitTransaction) + let seenInside = try await countSeenByAnotherSession("TP_MOBILE_HELD") + XCTAssertEqual(seenInside, "0") + + try await driver.rollbackTransaction() + let stateAfter = await driver.sessionTransactionState() + XCTAssertEqual(stateAfter, .idle) + let result = try await driver.execute(query: "SELECT COUNT(*) FROM TP_MOBILE_HELD") + XCTAssertEqual(result.rows.first?.first, "0") + } + func testStreamingYieldsColumnsThenRows() async throws { let driver = try XCTUnwrap(driver) try await recreateTable("TP_MOBILE_STREAM", body: "id NUMBER(10) PRIMARY KEY") @@ -232,6 +269,15 @@ final class OracleDriverTests: XCTestCase { } } + private func countSeenByAnotherSession(_ table: String) async throws -> String? { + let config = try XCTUnwrap(Self.loadTestConfig()) + let other = OracleDriver(connection: Self.makeConnection(from: config), password: config["ORACLE_TEST_PASSWORD"] ?? "") + try await other.connect() + let result = try await other.execute(query: "SELECT COUNT(*) FROM \(schema).\(table)") + try await other.disconnect() + return result.rows.first?.first ?? nil + } + private func recreateTable(_ name: String, body: String) async throws { let driver = try XCTUnwrap(driver) _ = try? await driver.execute(query: "DROP TABLE \(name) CASCADE CONSTRAINTS") diff --git a/TableProMobile/TableProMobileTests/Drivers/OracleDriverTransactionStateTests.swift b/TableProMobile/TableProMobileTests/Drivers/OracleDriverTransactionStateTests.swift new file mode 100644 index 0000000000..0b79e949df --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/OracleDriverTransactionStateTests.swift @@ -0,0 +1,35 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import Testing + +/// Oracle commits nothing on its own, so a row edit only reaches the server for good when the driver either sends it +/// with the commit flag or wraps it in a transaction it then commits. These pin what the driver reports to the write +/// path, which is what decides between the two. +@Suite("Oracle transaction state on iOS") +struct OracleDriverTransactionStateTests { + private func makeDriver() -> OracleDriver { + OracleDriver( + connection: DatabaseConnection(type: .oracle, host: "127.0.0.1", port: 1_521, username: "app"), + password: nil + ) + } + + @Test("A session with nothing open is idle, so a row edit gets a transaction the app commits") + func idleSessionOpensItsOwnTransaction() async { + let driver = makeDriver() + let state = await driver.sessionTransactionState() + #expect(state == .idle) + #expect(WriteTransactionPolicy.opensTransaction(supportsTransactions: true, state: state, statementCount: 1)) + } + + @Test("An opened transaction is reported, so a row edit joins it and leaves the commit to its owner") + func openTransactionIsJoined() async throws { + let driver = makeDriver() + try await driver.beginTransaction() + let state = await driver.sessionTransactionState() + #expect(state == .explicitTransaction) + #expect(!WriteTransactionPolicy.opensTransaction(supportsTransactions: true, state: state, statementCount: 1)) + } +} diff --git a/TableProTests/Core/Services/Execution/AutocommitOnlyStatementTests.swift b/TableProTests/Core/Services/Execution/AutocommitOnlyStatementTests.swift index cb353c8d56..4fb6eac62a 100644 --- a/TableProTests/Core/Services/Execution/AutocommitOnlyStatementTests.swift +++ b/TableProTests/Core/Services/Execution/AutocommitOnlyStatementTests.swift @@ -32,6 +32,8 @@ private enum AutocommitOnlyFixture { return sqlite case .sqlServer: return sqlServer + case .oracle: + return SQLLexicalRules(dialect: .oracle) case .redis, .other: return SQLLexicalRules(dialect: .generic) } @@ -390,4 +392,14 @@ struct AutocommitOnlyStatementSQLServerTests { func unknownEnginesKeepTheWrap(statement: String) { #expect(!AutocommitOnlyFixture.matches(statement, .other)) } + + /// Oracle has no statement it refuses inside a transaction: DDL commits the open one on its own + /// instead of failing, so nothing forces an Oracle batch out of the wrap. + @Test( + "Oracle keeps the wrap for every statement", + arguments: ["CREATE TABLE t (a NUMBER)", "ALTER SESSION SET CURRENT_SCHEMA = hr", "SET TRANSACTION READ ONLY"] + ) + func oracleKeepsTheWrap(statement: String) { + #expect(!AutocommitOnlyFixture.matches(statement, .oracle)) + } } diff --git a/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift b/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift index d380f928e3..cd7192f8d0 100644 --- a/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift +++ b/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift @@ -70,6 +70,35 @@ struct BatchTransactionPolicyTests { #expect(Self.plan(["INSERT INTO t VALUES (1)", statement], .postgresql) == .appTransaction) } + /// Oracle has no `BEGIN` for a transaction: `BEGIN` opens a PL/SQL block. `SET TRANSACTION` is + /// the statement that opens one, so a script holding it manages its own. + @Test( + "An Oracle script that opens its own transaction runs as written", + arguments: [ + "SET TRANSACTION READ ONLY", + "set transaction isolation level serializable", + "SET TRANSACTION NAME 'nightly'", + "-- hold the rows\nSET TRANSACTION READ WRITE", + ] + ) + func oracleSetTransactionIsNotWrapped(statement: String) { + #expect(Self.plan([statement, "UPDATE t SET a = 1"], .oracle) == .scriptTransaction) + } + + @Test( + "An Oracle script without SET TRANSACTION is wrapped", + arguments: [ + ["INSERT INTO t VALUES (1)", "UPDATE t SET a = 2"], + ["SAVEPOINT a", "INSERT INTO t VALUES (1)", "ROLLBACK TO a"], + ["LOCK TABLE t IN EXCLUSIVE MODE", "UPDATE t SET a = 1"], + ["SET ROLE ALL", "UPDATE t SET a = 1"], + ["BEGIN NULL; END;", "COMMIT"], + ] + ) + func oracleBatchIsWrapped(statements: [String]) { + #expect(Self.plan(statements, .oracle) == .appTransaction) + } + @Test( "MySQL refuses SET TRANSACTION inside a transaction, so a script that sets one runs as written", arguments: ["SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", "set transaction read only"] diff --git a/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift b/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift index 3100b1aac4..91e1eb32af 100644 --- a/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift +++ b/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift @@ -50,16 +50,28 @@ struct TransactionEngineFamilyTests { @Test( "An engine with no curated rules falls back to keeping the wrap", - arguments: ["Databend", "Cloudflare D1", "Oracle", "ClickHouse", "MongoDB", "FutureDB"] + arguments: ["Databend", "Cloudflare D1", "Snowflake", "ClickHouse", "MongoDB", "FutureDB"] ) func unknownEnginesFallBack(typeId: String) { #expect(TransactionEngineFamily.of(DatabaseType(rawValue: typeId)) == .other) } - @Test("Only SQLite opens a transaction with a savepoint") - func savepointOpensATransactionOnSQLiteAlone() { + @Test("Oracle reads its own rules") + func oracleIsItsOwnFamily() { + #expect(TransactionEngineFamily.of(.oracle) == .oracle) + } + + @Test("Only SQLite and Oracle open a transaction with a savepoint") + func savepointOpensATransactionOnSQLiteAndOracle() { + for family in TransactionEngineFamily.allCases { + #expect(family.savepointOpensTransaction == (family == .sqlite || family == .oracle)) + } + } + + @Test("Only Oracle opens a transaction with SET TRANSACTION") + func setTransactionOpensATransactionOnOracleAlone() { for family in TransactionEngineFamily.allCases { - #expect(family.savepointOpensTransaction == (family == .sqlite)) + #expect(family.setTransactionOpensTransaction == (family == .oracle)) } } diff --git a/docs/databases/oracle.mdx b/docs/databases/oracle.mdx index 67e69e5c3e..93416bf30c 100644 --- a/docs/databases/oracle.mdx +++ b/docs/databases/oracle.mdx @@ -102,6 +102,22 @@ In a trigger body, `:NEW` and `:OLD` are left as written. In an anonymous block, An anonymous block, or a query whose `WITH` clause declares a function, runs code on the server. One that drops or truncates, including through `EXECUTE IMMEDIATE '…'`, raises the [dangerous query warning](/features/safe-mode) before it runs, and a Read-Only connection refuses it like any write. [MCP clients](/external-api/mcp-tools) and the AI assistant cannot send either. +## Transactions + +A statement commits the moment it succeeds, and another session sees the change at once. A `SELECT ... FOR UPDATE` holds its row locks until the next statement you run that is not a query. To hold changes until you decide, start with `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE`. Everything after it waits in one transaction until a `COMMIT` or `ROLLBACK`, and `ROLLBACK TO` a savepoint keeps the rest of it open. + +```sql +SAVEPOINT before_raise; +UPDATE emp SET sal = sal * 1.1 WHERE deptno = 20; +SELECT ename, sal FROM emp WHERE deptno = 20; +ROLLBACK TO before_raise; +COMMIT; +``` + +Run the lines one at a time with `Cmd+Enter`, or all of them with `Cmd+Shift+Enter`. While the transaction is open, a grid save and a batch run inside it and leave the commit to you; see [which transaction a batch runs in](/features/sql-editor#which-transaction-the-batch-runs-in). A `CREATE`, `ALTER`, `DROP` or `TRUNCATE` commits the open transaction before it runs, so a `ROLLBACK` after one takes back only what came later. + +If the connection drops while a transaction is open, the next write fails with "The connection was lost while a transaction was open" and runs nothing. Check which of the transaction's changes were saved before running them again. + ## Column type support | Oracle type | Display | diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index fdd9acf8d4..15db809109 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -42,15 +42,15 @@ A batch runs top to bottom and stops at the first statement that fails. The erro | The batch | Runs in | A failure or Stop | |---|---|---| | Ordinary statements | A transaction TablePro opens and commits | Everything rolls back | -| One that manages its own: `BEGIN`, `START TRANSACTION`, `XA START`, `SET autocommit`, `SET IMPLICIT_TRANSACTIONS ON` on SQL Server, `SAVEPOINT` on SQLite | The script's own transaction | The transaction it left open is rolled back | +| One that manages its own: `BEGIN`, `START TRANSACTION`, `XA START`, `SET autocommit`, `SET IMPLICIT_TRANSACTIONS ON` on SQL Server, `SAVEPOINT` on SQLite, `SET TRANSACTION` on Oracle | The script's own transaction | The transaction it left open is rolled back | | One holding a [statement a transaction cannot hold](#statements-a-transaction-cannot-hold) | No transaction | Each statement that ran stays applied, and the banner counts them | | Anything, on a connection that already has a transaction open | That transaction | The transaction stays open and the message says so | -The last row wins over the other three. The open transaction comes from a `BEGIN` you ran with `Cmd+Enter`, a `SET autocommit = 0`, `LOCK TABLES` on MySQL or MariaDB, or an [MCP client's](/external-api/mcp-tools) `begin`, and a batch that joins it sends no `BEGIN`, `COMMIT` or `ROLLBACK` at all: the script's own text decides, so a script ending in `COMMIT` commits. Where a failed statement has left the transaction unable to commit, the message says to roll it back rather than offering the choice. A `BEGIN` left running here reaches the rest of the window too, so a grid save and a **Users & Roles** apply land inside it. +The last row wins over the other three. The open transaction comes from a `BEGIN` you ran with `Cmd+Enter`, a `SET autocommit = 0`, `LOCK TABLES` on MySQL or MariaDB, a `SET TRANSACTION`, `SAVEPOINT` or `LOCK TABLE` on Oracle, or an [MCP client's](/external-api/mcp-tools) `begin`, and a batch that joins it sends no `BEGIN`, `COMMIT` or `ROLLBACK` at all: the script's own text decides, so a script ending in `COMMIT` commits. Where a failed statement has left the transaction unable to commit, the message says to roll it back rather than offering the choice. A `BEGIN` left running here reaches the rest of the window too, so a grid save and a **Users & Roles** apply land inside it. -PostgreSQL, Redshift, CockroachDB, MySQL, MariaDB, TiDB, SQLite, DuckDB and SQL Server report what their session holds. Anywhere else there is no answer to be had, so a plain batch is wrapped as it always was and a self-managed script is left alone after a failure, since the transaction its text opened may well predate the run. +PostgreSQL, Redshift, CockroachDB, MySQL, MariaDB, TiDB, SQLite, DuckDB, SQL Server and Oracle report what their session holds. Anywhere else there is no answer to be had, so a plain batch is wrapped as it always was and a self-managed script is left alone after a failure, since the transaction its text opened may well predate the run. -Two things survive a rollback in any of those four cases. MySQL and MariaDB commit a `CREATE`, `ALTER` or `DROP` the moment it runs, along with everything before it, and CockroachDB commits the open transaction before it processes any DDL at all. +Two things survive a rollback in any of those four cases. MySQL, MariaDB and Oracle commit a `CREATE`, `ALTER` or `DROP` the moment it runs, along with everything before it, and CockroachDB commits the open transaction before it processes any DDL at all. ### Statements a transaction cannot hold diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index c22577bfc6..d0f1a2064b 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -108,7 +108,7 @@ Tap a row to open it full screen, page between rows, edit values, toggle one to A new row starts with every column on **DEFAULT**, which leaves that column out of the `INSERT` so the database fills it in. The badge beside a field switches it between **DEFAULT**, **NULL** and a typed value, and **NULL** is offered on nullable columns only. Generated columns are never written, and an auto-increment key stays on **DEFAULT** until you type one. -A save, insert, delete, truncate or drop runs inside a read-write transaction. MySQL, MariaDB, PostgreSQL and Redshift report what their session is already holding, and a write there runs inside that rather than under a transaction of its own, so the rows wait for whoever opened it. A server that starts its sessions read-only is a different problem: see [Server read-only is not Safe Mode](/features/safe-mode#server-read-only-is-not-safe-mode). SQL typed into the Query section is yours and goes out as written. +A save, insert, delete, truncate or drop runs inside a read-write transaction. MySQL, MariaDB, PostgreSQL, Redshift and Oracle report what their session is already holding, and a write there runs inside that rather than under a transaction of its own, so the rows wait for whoever opened it. A server that starts its sessions read-only is a different problem: see [Server read-only is not Safe Mode](/features/safe-mode#server-read-only-is-not-safe-mode). SQL typed into the Query section is yours and goes out as written. ### Querying diff --git a/scripts/check-oracle-autocommit.sh b/scripts/check-oracle-autocommit.sh new file mode 100755 index 0000000000..83675af931 --- /dev/null +++ b/scripts/check-oracle-autocommit.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# +# Check that OracleCoreConnection commits the way TablePro presents every engine, against a real Oracle server. +# +# Oracle never commits on its own and oracle-nio sends no commit unless a statement asks for one, so a write the +# driver runs without the commit flag stays invisible to every other session, and keeps its row locks, until something +# commits it. OracleSessionTransaction decides per statement whether the flag is sent. This runs each rule through the +# same OracleCoreConnection the plugin and the iOS driver use, and reads the result from a second session. +# +# Usage: +# scripts/check-oracle-autocommit.sh [host] [port] [service] [user] [password] +# +# Defaults suit a throwaway Oracle Free container: +# docker run -d --name oracle-probe -p 1521:1521 -e ORACLE_PASSWORD=probe_pw \ +# -e APP_USER=probe -e APP_USER_PASSWORD=probe_pw gvenzl/oracle-free:23-slim-faststart +# +# The user needs CREATE TABLE. It opens two sessions, creates tables prefixed TP_AC_ and drops them again. Exits +# non-zero on a disagreement, 3 when a prerequisite is missing. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-1521}" +SERVICE="${3:-FREEPDB1}" +USER_NAME="${4:-probe}" +PASSWORD="${5:-probe_pw}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [ -z "${DEVELOPER_DIR:-}" ]; then + for candidate in /Applications/Xcode-beta.app /Applications/Xcode.app; do + if [ -d "$candidate/Contents/Developer" ]; then + export DEVELOPER_DIR="$candidate/Contents/Developer" + break + fi + done +fi +command -v swift > /dev/null || { + echo "swift not found" >&2 + exit 3 +} + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/Sources/Probe" + +cat > "$WORK/Package.swift" < "$WORK/Sources/Probe/main.swift" <<'EOF' +import Foundation +import TableProOracleCore + +let arguments = CommandLine.arguments + +func session() -> OracleCoreConnection { + OracleCoreConnection(options: OracleConnectionOptions( + host: arguments[1], port: Int(arguments[2]) ?? 1_521, user: arguments[4], password: arguments[5], + identifierMode: .service, serviceName: arguments[3] + )) +} + +let writer = session() +let reader = session() +do { + try await writer.connect() + try await reader.connect() +} catch { + print("cannot connect: \(error)") + exit(3) +} + +@discardableResult +func run(_ sql: String, on connection: OracleCoreConnection = writer) async -> Error? { + do { + _ = try await connection.executeQuery(sql) + return nil + } catch { + return error + } +} + +func stream(_ sql: String) async -> Error? { + let rows = AsyncThrowingStream { continuation in + Task { + do { + try await writer.streamQuery(sql, continuation: continuation) + } catch { + continuation.finish(throwing: error) + } + } + } + do { + for try await _ in rows {} + return nil + } catch { + return error + } +} + +func rows(_ sql: String, on connection: OracleCoreConnection) async -> [String] { + let result = try? await connection.executeQuery(sql) + return result?.rows.compactMap { $0.first?.stringValue } ?? [] +} + +func readerSees(_ value: Int) async -> Bool { + await rows("SELECT TO_CHAR(COUNT(*)) FROM TP_AC_T WHERE X = \(value)", on: reader) == ["1"] +} + +var disagreements = 0 +@MainActor func check(_ holds: Bool, _ rule: String) { + print("\(holds ? "ok " : "FAIL") \(rule)") + if !holds { disagreements += 1 } +} + +await run("BEGIN EXECUTE IMMEDIATE 'DROP TABLE TP_AC_T PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;") +await run("BEGIN EXECUTE IMMEDIATE 'DROP TABLE TP_AC_D PURGE'; EXCEPTION WHEN OTHERS THEN NULL; END;") +if let failure = await run("CREATE TABLE TP_AC_T (X NUMBER PRIMARY KEY)") { + print("cannot create TP_AC_T: \(failure)") + exit(3) +} +await run(""" + CREATE TABLE TP_AC_D (X NUMBER CONSTRAINT TP_AC_D_POSITIVE CHECK (X > 0) DEFERRABLE INITIALLY DEFERRED) + """) + +await run("INSERT INTO TP_AC_T VALUES (1)") +check(await readerSees(1), "a write run on its own is visible to another session at once") + +_ = await stream("INSERT INTO TP_AC_T VALUES (2)") +check(await readerSees(2), "a streamed write run on its own is visible to another session at once") + +await run("UPDATE TP_AC_T SET X = X WHERE X = 1") +let rowFree = await rows("SELECT TO_CHAR(X) FROM TP_AC_T WHERE X = 1 FOR UPDATE NOWAIT", on: reader) == ["1"] +await run("ROLLBACK", on: reader) +check(rowFree, "a write run on its own holds no row lock afterwards") + +await run("INSERT INTO TP_AC_T SELECT LEVEL + 100 FROM DUAL CONNECT BY LEVEL <= 50") +let locked = (try? await writer.executeQuery("SELECT X FROM TP_AC_T WHERE X > 100 FOR UPDATE").rows.count) ?? -1 +check(locked == 50, "SELECT ... FOR UPDATE reads every row, not only the first fetch (read \(locked))") +let rowsHeld = await run("SELECT X FROM TP_AC_T WHERE X = 101 FOR UPDATE NOWAIT", on: reader) != nil +await run("INSERT INTO TP_AC_T VALUES (3)") +let rowsReleased = await run("SELECT X FROM TP_AC_T WHERE X = 101 FOR UPDATE NOWAIT", on: reader) == nil +await run("ROLLBACK", on: reader) +check(rowsHeld && rowsReleased, "SELECT ... FOR UPDATE holds its locks until the next statement that is not a query") + +writer.beginTransaction() +await run("INSERT INTO TP_AC_T VALUES (10)") +await run("INSERT INTO TP_AC_T VALUES (11)") +let pendingBeforeCommit = !(await readerSees(10)) +await run("COMMIT") +let committed10 = await readerSees(10) +let committed11 = await readerSees(11) +check(pendingBeforeCommit && committed10 && committed11 && !writer.holdsTransaction, + "writes inside an opened transaction stay pending until COMMIT, which ends it") +await run("INSERT INTO TP_AC_T VALUES (12)") +check(await readerSees(12), "a write after COMMIT commits as it runs again") + +writer.beginTransaction() +await run("INSERT INTO TP_AC_T VALUES (20)") +let duplicate = await run("INSERT INTO TP_AC_T VALUES (20)") +await run("ROLLBACK") +let writerKept20 = await rows("SELECT TO_CHAR(COUNT(*)) FROM TP_AC_T WHERE X = 20", on: writer) != ["0"] +let readerSees20 = await readerSees(20) +check(duplicate != nil && !writerKept20 && !readerSees20 && !writer.holdsTransaction, + "a failed statement inside an opened transaction rolls back with the rest of it") + +await run("SAVEPOINT TP_AC_S") +let savepointOpened = writer.holdsTransaction +await run("INSERT INTO TP_AC_T VALUES (30)") +await run("ROLLBACK TO TP_AC_S") +await run("INSERT INTO TP_AC_T VALUES (31)") +let pendingAfterSavepoint = !(await readerSees(31)) +let openAfterRollbackTo = writer.holdsTransaction +await run("COMMIT") +let keptBeforeSavepoint = await readerSees(30) +let keptAfterSavepoint = await readerSees(31) +check(savepointOpened && pendingAfterSavepoint && openAfterRollbackTo && !keptBeforeSavepoint && keptAfterSavepoint, + "SAVEPOINT opens a transaction that ROLLBACK TO keeps open") + +await run("SET TRANSACTION NAME 'tp_ac'") +await run("INSERT INTO TP_AC_T VALUES (40)") +let pendingAfterSetTransaction = !(await readerSees(40)) +await run("ROLLBACK") +let discarded = !(await readerSees(40)) +check(pendingAfterSetTransaction && discarded && !writer.holdsTransaction, + "SET TRANSACTION opens a transaction and ROLLBACK discards it") + +await run("LOCK TABLE TP_AC_T IN EXCLUSIVE MODE") +let lockHeld = await run("LOCK TABLE TP_AC_T IN EXCLUSIVE MODE NOWAIT", on: reader) != nil +await run("COMMIT") +let lockReleased = await run("LOCK TABLE TP_AC_T IN EXCLUSIVE MODE NOWAIT", on: reader) == nil +await run("ROLLBACK", on: reader) +check(lockHeld && lockReleased, "LOCK TABLE holds its lock until COMMIT") + +await run("BEGIN INSERT INTO TP_AC_T VALUES (50); END;") +check(await readerSees(50), "a PL/SQL block that writes commits as it runs") + +writer.beginTransaction() +await run("INSERT INTO TP_AC_T VALUES (55)") +let malformed = await run("COMMIT BOGUS") +let heldAfterMalformed = writer.holdsTransaction +let pendingAfterMalformed = !(await readerSees(55)) +await run("ROLLBACK") +let rolledBackAfterMalformed = !(await readerSees(55)) +check(malformed != nil && heldAfterMalformed && pendingAfterMalformed && rolledBackAfterMalformed, + "a COMMIT the server cannot parse leaves the transaction open for the ROLLBACK after it") + +writer.beginTransaction() +await run("INSERT INTO TP_AC_D VALUES (-1)") +let refused = await run("COMMIT") +let deferredGone = await rows("SELECT TO_CHAR(COUNT(*)) FROM TP_AC_D", on: writer) == ["0"] +check(refused != nil && deferredGone && !writer.holdsTransaction, "a COMMIT the server refuses ends the transaction") + +writer.beginTransaction() +await run("INSERT INTO TP_AC_T VALUES (60)") +writer.disconnect() +let lost = await run("INSERT INTO TP_AC_T VALUES (61)") +let ranAfterLoss = await readerSees(61) +check((lost as? OracleCoreError) == .transactionLost && !writer.holdsTransaction && !ranAfterLoss, + "a write after the transaction's connection closed reports the transaction lost and runs nothing") +await run("INSERT INTO TP_AC_T VALUES (62)") +check(await readerSees(62), "the next write commits as it runs on the new connection") +let closeCommitted = await readerSees(60) +print("info a graceful close with a write pending \(closeCommitted ? "committed" : "rolled back") it") + +await run("DROP TABLE TP_AC_T PURGE") +await run("DROP TABLE TP_AC_D PURGE") +writer.disconnect() +reader.disconnect() +try? await Task.sleep(nanoseconds: 500_000_000) + +print(disagreements == 0 ? "Oracle commits as TablePro presents it on this server." : "\(disagreements) disagreement(s).") +exit(disagreements == 0 ? 0 : 1) +EOF + +echo "Building the probe against Packages/TableProOracle (the first build takes a minute)" +(cd "$WORK" && swift build -c debug > "$WORK/build.log" 2>&1) || { + grep -E "error:" "$WORK/build.log" | head -20 >&2 + exit 3 +} +"$WORK/.build/debug/Probe" "$HOST" "$PORT" "$SERVICE" "$USER_NAME" "$PASSWORD" 2> /dev/null