diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf43f655b..240e128ab0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Inspector toolbar button was permanently dimmed on macOS 13. - A connection's status on the welcome window stopped updating once the window was open. - The Compare & Sync licence notice froze the app instead of opening as a sheet. +- `VACUUM`, `CREATE INDEX CONCURRENTLY`, `SET sql_log_bin` and `PRAGMA foreign_keys` failing or ignored when running several statements. +- A batch committing, discarding or aborting a transaction already open on the connection. +- Grid saves, structure changes and a Users & Roles apply committing a transaction already open on the connection. +- Stopping a multi-statement run committing the batch anyway, or leaving its transaction open on the connection. +- A lost connection during a commit reported as a clean rollback. +- Query in one tab cancelled and rolled back when another tab or window on the connection starts or stops a query. +- Stop on MySQL 5.5, 5.6 or MariaDB 5.5 interrupting the next statement on the connection. +- `QUEUED` results and hidden command errors when running several Redis commands or saving Redis grid edits. +- Query timeout ignored on MySQL before 5.7.8 and MariaDB before 10.1.1. +- MySQL query run a second time, and left running on the server, after the connection timed out. +- Empty error when a parameterized MySQL query timed out. +- Check constraints reported as added on MySQL before 8.0.16 and MariaDB before 10.2.1, which discard them. +- `Unknown table 'CHECK_CONSTRAINTS'` opening a table's structure on MariaDB 10.2 before 10.2.22 and 10.3 before 10.3.10. +- Syntax error removing a check constraint on MySQL 8.0.16 to 8.0.18. +- Syntax error setting a password or connection limit in Users & Roles on MySQL before 5.7.6 and MariaDB before 10.2. +- Passwordless account created when a new user's connection limit was set before saving. +- Connection held as "session settings changed" after a `SET PASSWORD`. +- Rows saved, added or deleted on iPhone and iPad refused by a MySQL, MariaDB, PostgreSQL or Redshift server that starts sessions read-only. +- Copying objects into a connection that already has a transaction open committing it. +- Replace-copy into a remote libSQL target failing at `BEGIN`. ### Security diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 9fefaca534..f021837f1b 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -68,7 +68,7 @@ let package = Package( ), .target( name: "TableProDatabase", - dependencies: ["TableProModels", "TableProCoreTypes"], + dependencies: ["TableProModels", "TableProCoreTypes", "TableProPluginKit"], path: "Sources/TableProDatabase" ), .target( @@ -163,7 +163,7 @@ let package = Package( ), .testTarget( name: "TableProDatabaseTests", - dependencies: ["TableProDatabase", "TableProModels"], + dependencies: ["TableProDatabase", "TableProModels", "TableProPluginKit"], path: "Tests/TableProDatabaseTests" ), .testTarget( diff --git a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver+Write.swift b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver+Write.swift new file mode 100644 index 0000000000..10b71f41a4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver+Write.swift @@ -0,0 +1,37 @@ +import Foundation +import TableProPluginKit + +public extension DatabaseDriver { + @discardableResult + func executeWrite(_ statements: [String]) async throws -> Int { + guard !statements.isEmpty else { return 0 } + + let opensTransaction = await WriteTransactionPolicy.opensTransaction( + supportsTransactions: supportsTransactions, + state: sessionTransactionState(), + statementCount: statements.count + ) + guard opensTransaction else { return try await runWriteStatements(statements) } + + try await beginTransaction(mode: .readWrite) + do { + let affected = try await runWriteStatements(statements) + try await commitTransaction() + return affected + } catch { + try? await rollbackTransaction() + throw error + } + } +} + +private extension DatabaseDriver { + func runWriteStatements(_ statements: [String]) async throws -> Int { + var affected = 0 + for statement in statements { + let result = try await execute(query: statement) + affected += max(result.rowsAffected, 0) + } + return affected + } +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift index 244996062e..5501b414dc 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift @@ -1,5 +1,6 @@ import Foundation import TableProModels +import TableProPluginKit public protocol DatabaseDriver: AnyObject, Sendable { func connect() async throws @@ -25,8 +26,10 @@ public protocol DatabaseDriver: AnyObject, Sendable { var supportsTransactions: Bool { get } func beginTransaction() async throws + func beginTransaction(mode: PluginTransactionAccessMode) async throws func commitTransaction() async throws func rollbackTransaction() async throws + func sessionTransactionState() async -> DriverTransactionState var serverVersion: String? { get } @@ -36,6 +39,12 @@ public protocol DatabaseDriver: AnyObject, Sendable { public extension DatabaseDriver { var holdsSuspensionBlockingResource: Bool { false } + func beginTransaction(mode: PluginTransactionAccessMode) async throws { + try await beginTransaction() + } + + func sessionTransactionState() async -> DriverTransactionState { .unknown } + func escapeStringLiteral(_ value: String) -> String { SQLEscaping.ansiStringLiteral(value) } diff --git a/Packages/TableProCore/Sources/TableProDatabase/DriverTransactionState.swift b/Packages/TableProCore/Sources/TableProDatabase/DriverTransactionState.swift new file mode 100644 index 0000000000..3128df9d5f --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/DriverTransactionState.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum DriverTransactionState: Sendable, Equatable { + case idle + case explicitTransaction + case implicitTransaction + case unknown +} diff --git a/Packages/TableProCore/Sources/TableProDatabase/WriteTransactionPolicy.swift b/Packages/TableProCore/Sources/TableProDatabase/WriteTransactionPolicy.swift new file mode 100644 index 0000000000..d7a53e025e --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/WriteTransactionPolicy.swift @@ -0,0 +1,19 @@ +import Foundation + +public enum WriteTransactionPolicy { + public static func opensTransaction( + supportsTransactions: Bool, + state: DriverTransactionState, + statementCount: Int + ) -> Bool { + guard supportsTransactions else { return false } + switch state { + case .idle, .implicitTransaction: + return true + case .explicitTransaction: + return false + case .unknown: + return statementCount > 1 + } + } +} diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/WriteTransactionTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/WriteTransactionTests.swift new file mode 100644 index 0000000000..0b36c5efd4 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/WriteTransactionTests.swift @@ -0,0 +1,297 @@ +import Foundation +import TableProDatabase +import TableProModels +import TableProPluginKit +import Testing + +private enum WriteEvent: Equatable { + case begin(PluginTransactionAccessMode) + case beginWithoutMode + case execute(String) + case commit + case rollback +} + +private struct WriteFailure: Error, Equatable {} + +private final class RecordingWriteDriver: DatabaseDriver, @unchecked Sendable { + var supportsTransactions = true + var scriptedState: DriverTransactionState = .idle + var failingStatement: String? + var failsBegin = false + var rowsAffected: [String: Int] = [:] + + private(set) var events: [WriteEvent] = [] + + var supportsSchemas: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() async throws {} + func ping() async throws -> Bool { true } + func cancelCurrentQuery() async throws {} + + func execute(query: String) async throws -> QueryResult { + events.append(.execute(query)) + if query == failingStatement { throw WriteFailure() } + return QueryResult(columns: [], rows: [], rowsAffected: rowsAffected[query] ?? 1, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { [] } + func fetchDatabases() async throws -> [String] { [] } + func switchDatabase(to name: String) async throws {} + func switchSchema(to name: String) async throws {} + func fetchSchemas() async throws -> [String] { [] } + + func beginTransaction() async throws { + events.append(.beginWithoutMode) + } + + func beginTransaction(mode: PluginTransactionAccessMode) async throws { + if failsBegin { throw WriteFailure() } + events.append(.begin(mode)) + } + + func commitTransaction() async throws { + events.append(.commit) + } + + func rollbackTransaction() async throws { + events.append(.rollback) + } + + func sessionTransactionState() async -> DriverTransactionState { scriptedState } +} + +private final class ProtocolDefaultDriver: DatabaseDriver, @unchecked Sendable { + private(set) var events: [WriteEvent] = [] + + var supportsSchemas: Bool { false } + var currentSchema: String? { nil } + var supportsTransactions: Bool { true } + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() async throws {} + func ping() async throws -> Bool { true } + func cancelCurrentQuery() async throws {} + + func execute(query: String) async throws -> QueryResult { + events.append(.execute(query)) + return QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { [] } + func fetchDatabases() async throws -> [String] { [] } + func switchDatabase(to name: String) async throws {} + func switchSchema(to name: String) async throws {} + func fetchSchemas() async throws -> [String] { [] } + + func beginTransaction() async throws { + events.append(.beginWithoutMode) + } + + func commitTransaction() async throws { + events.append(.commit) + } + + func rollbackTransaction() async throws { + events.append(.rollback) + } +} + +@Suite("Write transaction policy") +struct WriteTransactionPolicyTests { + @Test("A driver without transactions never opens one") + func withoutTransactions() { + for state in [DriverTransactionState.idle, .explicitTransaction, .implicitTransaction, .unknown] { + for count in 1...2 { + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: false, state: state, statementCount: count + ) == false + ) + } + } + } + + @Test("An idle session wraps whatever it is given") + func idleWraps() { + #expect(WriteTransactionPolicy.opensTransaction(supportsTransactions: true, state: .idle, statementCount: 1)) + #expect(WriteTransactionPolicy.opensTransaction(supportsTransactions: true, state: .idle, statementCount: 2)) + } + + @Test("A transaction the server opened because autocommit is off still wraps and commits") + func implicitTransactionWraps() { + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: true, state: .implicitTransaction, statementCount: 1 + ) + ) + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: true, state: .implicitTransaction, statementCount: 2 + ) + ) + } + + @Test("A transaction the user opened is joined, never wrapped") + func explicitTransactionRunsBare() { + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: true, state: .explicitTransaction, statementCount: 1 + ) == false + ) + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: true, state: .explicitTransaction, statementCount: 2 + ) == false + ) + } + + @Test("An unknown state wraps a batch for atomicity and leaves a single statement bare") + func unknownFollowsStatementCount() { + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: true, state: .unknown, statementCount: 1 + ) == false + ) + #expect( + WriteTransactionPolicy.opensTransaction(supportsTransactions: true, state: .unknown, statementCount: 2) + ) + } +} + +@Suite("Driver write execution") +struct DatabaseDriverWriteTests { + @Test("An idle session wraps a single statement in a read-write transaction") + func idleSingleStatementWraps() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .idle + + let affected = try await driver.executeWrite(["UPDATE t SET v = 1"]) + + #expect(affected == 1) + #expect(driver.events == [.begin(.readWrite), .execute("UPDATE t SET v = 1"), .commit]) + } + + @Test("A session inside the user's transaction runs the statement bare") + func explicitTransactionRunsBare() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .explicitTransaction + + _ = try await driver.executeWrite(["UPDATE t SET v = 1"]) + + #expect(driver.events == [.execute("UPDATE t SET v = 1")]) + } + + @Test("A session with autocommit off is wrapped and committed") + func implicitTransactionWraps() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .implicitTransaction + + _ = try await driver.executeWrite(["UPDATE t SET v = 1"]) + + #expect(driver.events == [.begin(.readWrite), .execute("UPDATE t SET v = 1"), .commit]) + } + + @Test("An unknown state leaves a single statement bare and wraps a batch") + func unknownStateFollowsStatementCount() async throws { + let single = RecordingWriteDriver() + single.scriptedState = .unknown + _ = try await single.executeWrite(["UPDATE t SET v = 1"]) + #expect(single.events == [.execute("UPDATE t SET v = 1")]) + + let batch = RecordingWriteDriver() + batch.scriptedState = .unknown + _ = try await batch.executeWrite(["INSERT a", "INSERT b"]) + #expect(batch.events == [.begin(.readWrite), .execute("INSERT a"), .execute("INSERT b"), .commit]) + } + + @Test("A driver without transactions runs a batch bare") + func withoutTransactionsRunsBare() async throws { + let driver = RecordingWriteDriver() + driver.supportsTransactions = false + driver.scriptedState = .idle + + _ = try await driver.executeWrite(["INSERT a", "INSERT b"]) + + #expect(driver.events == [.execute("INSERT a"), .execute("INSERT b")]) + } + + @Test("A failed statement rolls back, rethrows and runs nothing after it") + func failureRollsBack() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .idle + driver.failingStatement = "INSERT b" + + await #expect(throws: WriteFailure.self) { + _ = try await driver.executeWrite(["INSERT a", "INSERT b", "INSERT c"]) + } + + #expect(driver.events == [.begin(.readWrite), .execute("INSERT a"), .execute("INSERT b"), .rollback]) + } + + @Test("A begin that fails runs no statement and no rollback") + func failedBeginStopsEverything() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .idle + driver.failsBegin = true + + await #expect(throws: WriteFailure.self) { + _ = try await driver.executeWrite(["INSERT a"]) + } + + #expect(driver.events.isEmpty) + } + + @Test("The affected-row sum ignores a driver that reports a negative count") + func negativeRowCountsAreIgnored() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .idle + driver.rowsAffected = ["INSERT a": -1, "INSERT b": 3] + + let affected = try await driver.executeWrite(["INSERT a", "INSERT b"]) + + #expect(affected == 3) + } + + @Test("No statements means no transaction and no work") + func emptyStatementsDoNothing() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .idle + + let affected = try await driver.executeWrite([]) + + #expect(affected == 0) + #expect(driver.events.isEmpty) + } + + @Test("A driver that implements neither requirement reports unknown and opens a plain transaction") + func protocolDefaultsLeaveBehaviorUnchanged() async throws { + let driver = ProtocolDefaultDriver() + + #expect(await driver.sessionTransactionState() == .unknown) + + _ = try await driver.executeWrite(["UPDATE t SET v = 1"]) + #expect(driver.events == [.execute("UPDATE t SET v = 1")]) + + _ = try await driver.executeWrite(["INSERT a", "INSERT b"]) + #expect( + driver.events == [ + .execute("UPDATE t SET v = 1"), + .beginWithoutMode, + .execute("INSERT a"), + .execute("INSERT b"), + .commit + ] + ) + } +} diff --git a/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift b/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift index 6862b98388..76404692fd 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBConnection.swift @@ -288,6 +288,45 @@ actor DuckDBConnectionActor { } } + /// What the session has open, for a caller deciding whether it may open a transaction of its + /// own on it. + /// + /// A released handle answers `.idle`: a release only happens over a session holding nothing, and + /// reopening to ask would undo it. The probe itself never reopens for the same reason, and it + /// runs outside `noteActivity`, so asking the question does not reset the idle clock the + /// question is about. + func sessionTransactionState() -> PluginSessionTransactionState { + guard !isReleased else { return .idle } + guard connection != nil else { return .unknown } + if let decided = DuckDBTransactionProbe.state( + catalogType: probeReading(DuckDBTransactionProbe.catalogTypeQuery), + tracksOpenTransaction: hasOpenTransaction + ) { + return decided + } + return DuckDBTransactionProbe.state( + first: probeReading(DuckDBTransactionProbe.transactionIdQuery), + second: probeReading(DuckDBTransactionProbe.transactionIdQuery) + ) + } + + /// Runs one probe statement through `duckdb_query`, which is the only path that hands back the + /// error *type*: a refusal because the transaction is aborted is the answer rather than a + /// failure, and the message text is not a contract. + private func probeReading(_ sql: String) -> DuckDBTransactionProbe.Reading { + guard let conn = connection else { return .unreadable } + var result = duckdb_result() + defer { duckdb_destroy_result(&result) } + guard duckdb_query(conn, sql, &result) != DuckDBError else { + return duckdb_result_error_type(&result) == DUCKDB_ERROR_TRANSACTION ? .abortedTransaction : .unreadable + } + guard duckdb_row_count(&result) > 0, let cell = duckdb_value_varchar(&result, 0, 0) else { + return .unreadable + } + defer { duckdb_free(cell) } + return .value(String(cString: cell)) + } + /// Nil when nothing stands in the way of a release. private func heldSessionState() -> DuckDBReleaseOutcome? { if hasOpenTransaction { return .holdsOpenTransaction } diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 25c8496e17..ceeb87e8f8 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -216,6 +216,10 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { true } var parameterStyle: ParameterStyle { .dollar } + func sessionTransactionState() async -> PluginSessionTransactionState { + await connectionActor.sessionTransactionState() + } + var capabilities: PluginCapabilities { [ .parameterizedQueries, diff --git a/Plugins/DuckDBDriverPlugin/DuckDBTransactionProbe.swift b/Plugins/DuckDBDriverPlugin/DuckDBTransactionProbe.swift new file mode 100644 index 0000000000..40cb33ab24 --- /dev/null +++ b/Plugins/DuckDBDriverPlugin/DuckDBTransactionProbe.swift @@ -0,0 +1,72 @@ +// +// DuckDBTransactionProbe.swift +// DuckDBDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// How the driver asks DuckDB whether the session has a transaction open, and how the answers are +/// read. No CDuckDB import, so TableProTests can exercise the decision without the plugin bundle. +/// +/// DuckDB's C API has no call for this, and the obvious probe is destructive: measured on the +/// shipped v1.5.2, a `BEGIN TRANSACTION` issued inside a transaction does not merely fail, it aborts +/// the transaction it was testing for. `txid_current()` is the one reading that costs nothing. +/// Measured on the same library: outside a transaction two calls answer 6 then 10, inside one they +/// both answer 12, and running it inside an open transaction left the transaction and its rows +/// untouched (`COMMIT` afterwards kept both). +/// +/// The catalog gate in front of it is not optional. `txid_current()` is DuckDB's own transaction +/// manager, and the plugin itself moves the session off it: Quack remote mode runs `ATTACH` plus +/// `USE ` at connect, and known-extension autoloading makes `ATTACH ... (TYPE sqlite)` +/// reachable too. Measured against v1.5.2 with a SQLite catalog in front: inside the user's open +/// transaction, `txid_current()` failed with `INTERNAL Error: DuckTransaction::Get called on +/// non-DuckDB transaction` and **took the transaction with it**, so the row the user had inserted +/// was gone after their own `COMMIT` reported success. `duckdb_databases()` is safe in the same +/// place, measured: it answered `sqlite`, and the transaction went on to commit both rows. +enum DuckDBTransactionProbe { + /// One answer read back from the connection. + enum Reading: Equatable { + case value(String) + /// The statement was refused because the transaction is aborted, which is itself the + /// answer: `DUCKDB_ERROR_TRANSACTION`, "Current transaction is aborted (please ROLLBACK)". + case abortedTransaction + case unreadable + } + + /// Which transaction manager owns the session's default catalog. + static let catalogTypeQuery = + "SELECT type::VARCHAR FROM duckdb_databases() WHERE database_name = current_database()" + + /// Asked twice. A statement outside a transaction gets a transaction of its own, so the two + /// answers differ; inside one they are the same transaction. + static let transactionIdQuery = "SELECT txid_current()::VARCHAR" + + /// What the catalog answer settles on its own, or nil when the transaction id has to be probed. + /// + /// A catalog DuckDB does not own falls back to what the driver saw go past in the statement + /// text, which over-reports toward joining: the cost of that is a batch that opens no + /// transaction of its own, and the cost of the opposite is the user's transaction destroyed. + static func state( + catalogType: Reading, + tracksOpenTransaction: Bool + ) -> PluginSessionTransactionState? { + switch catalogType { + case .abortedTransaction: + return .abortedTransaction + case .unreadable: + return .unknown + case .value(let type): + guard type.lowercased() != nativeCatalogType else { return nil } + return tracksOpenTransaction ? .inTransaction : .idle + } + } + + static func state(first: Reading, second: Reading) -> PluginSessionTransactionState { + if first == .abortedTransaction || second == .abortedTransaction { return .abortedTransaction } + guard case .value(let firstId) = first, case .value(let secondId) = second else { return .unknown } + return firstId == secondId ? .inTransaction : .idle + } + + private static let nativeCatalogType = "duckdb" +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index cb33aa0b7d..e8da2e6c32 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -414,6 +414,18 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "BEGIN TRANSACTION") } + /// One round trip, and only when a caller is about to own a transaction on this session. It + /// goes through `executeInternal` rather than `execute` so the app's query cancellation and + /// history never see it. + func sessionTransactionState() async -> PluginSessionTransactionState { + guard let result = try? await executeInternal(MSSQLSessionTransaction.probe) else { return .unknown } + let row = result.rows.first + return MSSQLSessionTransaction.state( + tranCount: row?.first?.asText, + transactionState: row?.dropFirst().first?.asText + ) + } + // MARK: - Query Execution func execute(query: String) async throws -> PluginQueryResult { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift b/Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift new file mode 100644 index 0000000000..4dd90b4af6 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift @@ -0,0 +1,35 @@ +// +// MSSQLSessionTransaction.swift +// MSSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// How the driver asks SQL Server what the session has open, and how the answer is read. No CFreeTDS +/// import, so TableProTests can exercise the decision without the plugin bundle. +/// +/// `@@TRANCOUNT` is the count of open transactions, explicit and implicit alike. `XACT_STATE()` +/// answers -1 for one that can no longer be committed, which is the state a batch-aborting error +/// leaves behind, and telling the user to commit that one silently discards their work. +/// +/// `SET IMPLICIT_TRANSACTIONS ON` alone is deliberately not read as a transaction. Measured on Azure +/// SQL Edge: with the option on and nothing run since, `SELECT @@TRANCOUNT, @@OPTIONS & 2` answered +/// `0 2` and asking again still answered 0, so nothing is pending and a caller's own transaction +/// commits only its own statements. The first statement after that makes `@@TRANCOUNT` 1, which is +/// what this reports. +enum MSSQLSessionTransaction { + static let probe = "SELECT @@TRANCOUNT, XACT_STATE()" + + static func state(tranCount: String?, transactionState: String?) -> PluginSessionTransactionState { + guard let count = tranCount.flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) else { + return .unknown + } + if transactionState.flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) == uncommittable { + return .abortedTransaction + } + return count > 0 ? .inTransaction : .idle + } + + private static let uncommittable = -1 +} diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection+StatementDeadline.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection+StatementDeadline.swift new file mode 100644 index 0000000000..1962ce81e6 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection+StatementDeadline.swift @@ -0,0 +1,70 @@ +// +// MariaDBPluginConnection+StatementDeadline.swift +// MySQLDriverPlugin +// +// Stopping a statement on a server that has no statement timeout of its own. +// + +import CMariaDB +import Foundation +import OSLog + +private let deadlineLogger = Logger(subsystem: "com.TablePro", category: "MariaDBStatementDeadline") + +internal extension MariaDBPluginConnection { + /// Wraps one statement on the primary handle. Runs on `queue`, which is serial, so the kill the + /// deadline sends and the statement it names cannot outlive each other. + /// + /// Every statement this connection sends goes through here, the buffered read, the prepared + /// statement and the export stream alike, which is why the latched-kill check belongs here and + /// not beside one of them. Hooking only the two buffered paths left an export right after a Stop + /// collecting the kill instead. + func runStatement(_ sql: String, _ body: () throws -> T) throws -> T { + absorbLatchedKillIfNeeded() + return try deadlineRunner(threadId: currentThreadId).run(sql, body: body) + } + + private func deadlineRunner(threadId: UInt) -> MySQLStatementDeadlineRunner { + MySQLStatementDeadlineRunner( + deadline: statementDeadline, + flavor: flavor, + socketTimeoutSeconds: socketTimeoutSeconds, + watch: statementWatch, + now: { ContinuousClock.now }, + schedule: { [deadlineQueue] duration, action in + let item = DispatchWorkItem(block: action) + let milliseconds = max(Int(duration / .milliseconds(1)), 0) + deadlineQueue.asyncAfter(deadline: .now() + .milliseconds(milliseconds), execute: item) + return { item.cancel() } + }, + expire: { [weak self] token in self?.expireStatement(token: token, threadId: threadId) }, + flushInterrupt: { [weak self] in self?.consumePendingInterrupt() }, + killOrphan: { [weak self] in self?.killOrphanedStatement(threadId: threadId) }, + failureDetail: { error in + guard let failure = error as? MariaDBPluginError else { return nil } + return MySQLStatementFailure(code: failure.code, message: failure.message) + }, + deadlineExceeded: { MariaDBPluginError.queryTimeoutExceeded(seconds: $0) }, + markOutlasted: { error in + guard var failure = error as? MariaDBPluginError else { return error } + failure.outlastedSocketTimeout = true + return failure + } + ) + } + + /// The kill connection is opened before the watch's lock is taken, because taking it is what + /// holds the statement's own completion, and opening a connection across the internet costs + /// 800-1900ms. Inside the lock the watch re-checks that the statement is still running, so a + /// statement that finished while the connection was opening is never killed. + private func expireStatement(token: UInt64, threadId: UInt) { + guard statementWatch.isRunning(token) else { return } + guard let statement = killTarget.statement(threadId: threadId) else { return } + guard let killConn = openKillConnection() else { + deadlineLogger.warning("Query timeout could not open a connection to stop the statement") + return + } + defer { mysql_close(killConn) } + statementWatch.expire(token) { sendKill(statement, on: killConn) } + } +} diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 8b689bc540..3f6af39040 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -21,6 +21,24 @@ struct MariaDBPluginError: Error { let message: String let sqlState: String? + /// Set when the statement waited out the client's own socket timeout rather than the server + /// dropping the connection. The two arrive as the same `2013`, and only this one leaves a copy + /// of the statement running on the server, so it is never replayed. + var outlastedSocketTimeout = false + + /// `1317 Query execution was interrupted` is what the server answers a `KILL QUERY`, so the + /// deadline reports its own stop under the code and SQLSTATE a native statement timeout uses. + static func queryTimeoutExceeded(seconds: Int) -> MariaDBPluginError { + MariaDBPluginError( + code: 1_317, + message: String( + format: String(localized: "Query stopped after running past the %d second query timeout"), + seconds + ), + sqlState: "70100" + ) + } + static let notConnected = MariaDBPluginError( code: 0, message: String(localized: "Not connected to database"), sqlState: nil) static let connectionFailed = MariaDBPluginError( @@ -56,6 +74,12 @@ final class MariaDBPluginConnection: @unchecked Sendable { /// user pressing Stop repeatedly opens one connection at a time rather than one per press. private let cancelQueue = DispatchQueue(label: "com.TablePro.mariadb.plugin.cancel", qos: .userInitiated) + /// The deadline's own queue, so a statement stopped by the query timeout never waits behind a + /// Stop the user pressed, or the other way round. + internal let deadlineQueue = DispatchQueue(label: "com.TablePro.mariadb.plugin.deadline", qos: .userInitiated) + + internal let statementWatch = MySQLStatementWatch() + private let host: String private let port: UInt32 private let user: String @@ -66,6 +90,10 @@ final class MariaDBPluginConnection: @unchecked Sendable { private let queryTimeoutSeconds: Int private let connectionEncoding: MySQLConnectionEncoding + /// How long a read may go silent before libmariadb reports the connection lost, which is the + /// only thing separating its own timeout from a server-side drop. + internal let socketTimeoutSeconds: UInt32 + private let stateLock = NSLock() private let cancellationGate = PluginQueryCancellationGate() private var _isConnected: Bool = false @@ -91,18 +119,66 @@ final class MariaDBPluginConnection: @unchecked Sendable { private var _flavor: MySQLServerFlavor = .mysql private var _killTarget: MySQLKillTarget = .threadId - private var flavor: MySQLServerFlavor { + /// Set when the server has no statement timeout of its own, so the driver stops a statement + /// that runs past the query timeout with `KILL QUERY` from a second connection. + private var _statementDeadline: MySQLStatementDeadline? + + internal var statementDeadline: MySQLStatementDeadline? { + stateLock.withLock { _statementDeadline } + } + + internal func adopt(statementDeadline: MySQLStatementDeadline?) { + stateLock.withLock { _statementDeadline = statementDeadline } + } + + internal var flavor: MySQLServerFlavor { stateLock.lock() defer { stateLock.unlock() } return _flavor } - private var killTarget: MySQLKillTarget { + internal var killTarget: MySQLKillTarget { stateLock.lock() defer { stateLock.unlock() } return _killTarget } + /// Whether a `KILL QUERY` this connection sent is still sitting on the server waiting for the + /// next statement to collect. Written from the cancel queue and the statement queue, so it lives + /// under the same lock the flavor does. + private var _killLatch = MySQLKillLatch() + + private func recordKillDelivered(generation: Int) { + stateLock.withLock { _killLatch.recordDelivered(generation: generation) } + } + + private func recordKillInterrupted(generation: Int) { + stateLock.withLock { _killLatch.recordInterrupted(generation: generation) } + } + + private func takeKillAbsorption() -> Bool { + stateLock.withLock { _killLatch.takeAbsorption() } + } + + /// Notes the server's own interruption code on the way past, so a kill this statement collected + /// is not absorbed a second time by the next one. It never changes the error it is handed. + private func noting(_ error: MariaDBPluginError, generation: Int) -> MariaDBPluginError { + guard flavor.isInterruptedByKill(errno: error.code, message: error.message) else { return error } + recordKillInterrupted(generation: generation) + return error + } + + /// Runs before every statement this connection sends, on the statement queue. + /// + /// Draining the cancel queue first is what makes the latch mean anything: a kill dispatched for + /// the statement that just ended may not have reached the server yet, and reading the latch + /// before it went out would let it arrive during the statement below instead. + internal func absorbLatchedKillIfNeeded() { + cancelQueue.sync {} + guard takeKillAbsorption(), MySQLKillLatch.absorbsLatchedKill(flavor: flavor) else { return } + consumePendingInterrupt() + } + func adopt(flavor: MySQLServerFlavor, killTarget: MySQLKillTarget) { stateLock.lock() _flavor = flavor @@ -172,6 +248,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { self.enableCleartextPlugin = enableCleartextPlugin self.queryTimeoutSeconds = queryTimeoutSeconds self.connectionEncoding = connectionEncoding + self.socketTimeoutSeconds = mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeoutSeconds) } deinit { @@ -237,10 +314,10 @@ final class MariaDBPluginConnection: @unchecked Sendable { var timeout: UInt32 = 10 mysql_options(mysql, MYSQL_OPT_CONNECT_TIMEOUT, &timeout) - var readTimeout = mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeoutSeconds) + var readTimeout = socketTimeoutSeconds mysql_options(mysql, MYSQL_OPT_READ_TIMEOUT, &readTimeout) - var writeTimeout = mysqlSocketTimeoutSeconds(forQueryTimeout: queryTimeoutSeconds) + var writeTimeout = socketTimeoutSeconds mysql_options(mysql, MYSQL_OPT_WRITE_TIMEOUT, &writeTimeout) var protocol_tcp = UInt32(MYSQL_PROTOCOL_TCP.rawValue) @@ -357,18 +434,25 @@ final class MariaDBPluginConnection: @unchecked Sendable { /// in-flight read give up. The kill is server-side cleanup and carries only a thread id, no /// handle, so it is safe to finish on its own queue. func cancelCurrentQuery() { - guard cancellationGate.cancel() != nil else { return } + guard let generation = cancellationGate.cancel() else { return } guard let mysql = mysql, let statement = killStatement(for: mysql) else { return } cancelQueue.async { [self] in - killQueryOnServer(statement: statement) + killQueryOnServer(statement: statement, generation: generation) } } - private func killStatement(for mysql: UnsafeMutablePointer) -> String? { + internal func killStatement(for mysql: UnsafeMutablePointer) -> String? { killTarget.statement(threadId: mysql_thread_id(mysql)) } + /// The server thread this connection is on, read before a statement goes out so a kill still + /// has somewhere to go once the handle itself is unusable. + internal var currentThreadId: UInt { + guard let mysql = self.mysql else { return 0 } + return mysql_thread_id(mysql) + } + /// The kill has to reach the server the query is running on, which means repeating the transport /// the primary connection chose. Without `MYSQL_OPT_PROTOCOL` a host spelled `localhost` resolves /// to the default unix socket and the `port` argument is ignored, so `KILL QUERY` lands on a @@ -380,9 +464,21 @@ final class MariaDBPluginConnection: @unchecked Sendable { /// a server without TLS failed with 2026 and Stop did nothing. Reading the configured mode /// instead would break `.preferred`, the default, the same way: the primary succeeds through its /// plaintext fallback and every kill after it repeats the attempt that already failed. - private func killQueryOnServer(statement killQuery: String) { + private func killQueryOnServer(statement killQuery: String, generation: Int) { + guard let killConn = openKillConnection() else { + logger.warning("\(killQuery, privacy: .public) could not open a connection") + return + } + defer { mysql_close(killConn) } + guard sendKill(killQuery, on: killConn) else { return } + recordKillDelivered(generation: generation) + } + + /// Opened outside any lock the statement's own completion waits on: against a server across the + /// internet this is 800-1900ms of TCP, TLS and auth. + internal func openKillConnection() -> UnsafeMutablePointer? { let killConn = mysql_init(nil) - guard let killConn = killConn else { return } + guard let killConn = killConn else { return nil } var killTimeout: UInt32 = 5 mysql_options(killConn, MYSQL_OPT_CONNECT_TIMEOUT, &killTimeout) @@ -428,18 +524,56 @@ final class MariaDBPluginConnection: @unchecked Sendable { } } - if killResult != nil { - let killStatus = killQuery.withCString { queryPtr in - mysql_real_query(killConn, queryPtr, UInt(killQuery.utf8.count)) - } - if killStatus != 0 { - logger.warning("\(killQuery, privacy: .public) rejected: \(self.errorMessage(from: killConn))") - } - } else { - logger.warning("\(killQuery, privacy: .public) could not connect: \(self.errorMessage(from: killConn))") + guard killResult != nil else { + logger.warning("KILL QUERY could not connect: \(self.errorMessage(from: killConn))") + mysql_close(killConn) + return nil + } + return killConn + } + + /// Whether the kill went out. The caller records an interrupt only on `true`, so a refused or + /// unreachable kill never leaves a statement reported as stopped when it is still running. + @discardableResult + internal func sendKill(_ killQuery: String, on killConn: UnsafeMutablePointer) -> Bool { + let killStatus = killQuery.withCString { queryPtr in + mysql_real_query(killConn, queryPtr, UInt(killQuery.utf8.count)) + } + guard killStatus == 0 else { + logger.warning("\(killQuery, privacy: .public) rejected: \(self.errorMessage(from: killConn))") + return false + } + return true + } + + /// Runs a statement whose only job is to test and clear the server's `KILL QUERY` flag, for a + /// kill that arrived after the statement it was meant for had already finished. Without it the + /// flag reaches the next statement: measured on MySQL 5.5.62, 5.6.51 and MariaDB 5.5.64, the + /// statement after an idle kill failed with `ERROR 1317` and an `INSERT ... SELECT` inserted + /// nothing. It costs the session its `ROW_COUNT()` and `FOUND_ROWS()`, which installing the row + /// cap already costs. + internal func consumePendingInterrupt() { + guard let mysql = self.mysql else { return } + let probe = "SELECT 1" + _ = probe.withCString { probePtr in + mysql_real_query(mysql, probePtr, UInt(probe.utf8.count)) + } + if let discarded = mysql_store_result(mysql) { + while mysql_fetch_row(discarded) != nil {} + mysql_free_result(discarded) } + } - mysql_close(killConn) + /// Stops a statement the client gave up on while the server kept running it. The primary handle + /// is already unusable, so the thread id captured before the statement went out is the only way + /// back to it. + internal func killOrphanedStatement(threadId: UInt) { + guard let statement = killTarget.statement(threadId: threadId) else { return } + cancelQueue.async { [self] in + guard let killConn = openKillConnection() else { return } + defer { mysql_close(killConn) } + sendKill(statement, on: killConn) + } } private func errorMessage(from mysql: UnsafeMutablePointer) -> String { @@ -545,6 +679,10 @@ final class MariaDBPluginConnection: @unchecked Sendable { } private func executeQuerySync(_ query: String, rowCap: Int? = nil) throws -> MariaDBPluginQueryResult { + try runStatement(query) { try self.runTextStatement(query, rowCap: rowCap) } + } + + private func runTextStatement(_ query: String, rowCap: Int?) throws -> MariaDBPluginQueryResult { guard !isShuttingDown, let mysql = self.mysql else { throw MariaDBPluginError.notConnected } @@ -565,7 +703,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { } if queryStatus != 0 { - throw self.getError() + throw noting(self.getError(), generation: generation) } let resultPtr = mysql_use_result(mysql) @@ -637,7 +775,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { } if outcome.serverIgnoredLimit { if !sessionFlavor.dropsIdleSessionOnKillQuery, let statement = killStatement(for: mysql) { - killQueryOnServer(statement: statement) + killQueryOnServer(statement: statement, generation: generation) } while mysql_fetch_row(resultPtr) != nil {} } @@ -649,7 +787,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { let fetchErrno = mysql_errno(mysql) if fetchErrno != 0 { - let error = getError() + let error = noting(getError(), generation: generation) if !isExpectedInterruption( errno: fetchErrno, message: error.message, wasTruncated: outcome.serverIgnoredLimit ) { @@ -870,16 +1008,25 @@ final class MariaDBPluginConnection: @unchecked Sendable { _ query: String, parameters: [PluginCellValue], rowCap: Int? = nil + ) throws -> MariaDBPluginQueryResult { + guard flavor.preparesOnServer else { + return try executeQuerySync(DatabendLiteral.inline(query, parameters: parameters), rowCap: rowCap) + } + return try runStatement(query) { + try self.runPreparedStatement(query, parameters: parameters, rowCap: rowCap) + } + } + + private func runPreparedStatement( + _ query: String, + parameters: [PluginCellValue], + rowCap: Int? ) throws -> MariaDBPluginQueryResult { guard !isShuttingDown, let mysql = self.mysql else { throw MariaDBPluginError.notConnected } defer { recordTransactionState(on: mysql) } - guard flavor.preparesOnServer else { - return try executeQuerySync(DatabendLiteral.inline(query, parameters: parameters), rowCap: rowCap) - } - let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } @@ -918,11 +1065,11 @@ final class MariaDBPluginConnection: @unchecked Sendable { defer { bindings.cleanup() } if mysql_stmt_execute(stmt) != 0 { - throw getStmtError(stmt) + throw noting(getStmtError(stmt), generation: generation) } } else { if mysql_stmt_execute(stmt) != 0 { - throw getStmtError(stmt) + throw noting(getStmtError(stmt), generation: generation) } } let executedAt = Date().timeIntervalSince(sentAt) @@ -979,98 +1126,95 @@ final class MariaDBPluginConnection: @unchecked Sendable { /// which is why the abort is a polled flag instead. return PluginRowStream.make { continuation, abort in self.queue.async { [self] in - guard !isShuttingDown, let mysql = self.mysql else { - continuation.finish(throwing: MariaDBPluginError.notConnected) - return - } - defer { recordTransactionState(on: mysql) } - - let generation = cancellationGate.beginQuery() - defer { cancellationGate.endQuery(generation) } - - guard !abort.isAborted else { - continuation.finish() - return - } - do { - try reconcileSelectLimit(rowCap: nil, statement: queryToRun, on: mysql) + try runStatement(queryToRun) { + try self.streamStatement(queryToRun, continuation: continuation, abort: abort) + } + continuation.finish() } catch { continuation.finish(throwing: error) - return } + } + } + } - let queryStatus = queryToRun.withCString { queryPtr in - mysql_real_query(mysql, queryPtr, UInt(queryToRun.utf8.count)) - } + private func streamStatement( + _ queryToRun: String, + continuation: AsyncThrowingStream.Continuation, + abort: PluginStreamAbort + ) throws { + guard !isShuttingDown, let mysql = self.mysql else { + throw MariaDBPluginError.notConnected + } + defer { recordTransactionState(on: mysql) } - if queryStatus != 0 { - continuation.finish(throwing: self.getError()) - return - } + let generation = cancellationGate.beginQuery() + defer { cancellationGate.endQuery(generation) } - let resultPtr = mysql_use_result(mysql) + guard !abort.isAborted else { return } - if resultPtr == nil { - let fieldCount = mysql_field_count(mysql) - if fieldCount == 0 { - continuation.finish() - } else { - continuation.finish(throwing: self.getError()) - } - return - } + try reconcileSelectLimit(rowCap: nil, statement: queryToRun, on: mysql) - let columns = MariaDBCharacterSet.describeColumns( - of: mysql_fetch_fields(resultPtr), - count: Int(mysql_num_fields(resultPtr)), - encoding: connectionEncoding, - flavor: flavor - ) + let queryStatus = queryToRun.withCString { queryPtr in + mysql_real_query(mysql, queryPtr, UInt(queryToRun.utf8.count)) + } - continuation.yield(.header(PluginStreamHeader( - columns: columns.names, - columnTypeNames: columns.typeNames, - estimatedRowCount: nil - ))) - - let batchSize = 5_000 - var batch: [PluginRow] = [] - batch.reserveCapacity(batchSize) - while let rowPtr = mysql_fetch_row(resultPtr) { - if abort.isAborted || cancellationGate.isCancelled(generation) { - /// Same shape as the capped buffered read: stop the server first, then - /// drain what is already in flight so the connection stays usable. - if let statement = killStatement(for: mysql) { - killQueryOnServer(statement: statement) - } - while mysql_fetch_row(resultPtr) != nil {} - mysql_free_result(resultPtr) - continuation.finish(throwing: CancellationError()) - return - } + if queryStatus != 0 { + throw noting(getError(), generation: generation) + } - batch.append(textProtocolRow(rowPtr, lengths: mysql_fetch_lengths(resultPtr), columns: columns)) - if batch.count >= batchSize { - continuation.yield(.rows(batch)) - batch.removeAll(keepingCapacity: true) - } - } - if !batch.isEmpty { - continuation.yield(.rows(batch)) - } + let resultPtr = mysql_use_result(mysql) - if mysql_errno(mysql) != 0 { - let error = self.getError() - mysql_free_result(resultPtr) - continuation.finish(throwing: error) - return - } + if resultPtr == nil { + guard mysql_field_count(mysql) == 0 else { throw noting(getError(), generation: generation) } + return + } + + let columns = MariaDBCharacterSet.describeColumns( + of: mysql_fetch_fields(resultPtr), + count: Int(mysql_num_fields(resultPtr)), + encoding: connectionEncoding, + flavor: flavor + ) + + continuation.yield(.header(PluginStreamHeader( + columns: columns.names, + columnTypeNames: columns.typeNames, + estimatedRowCount: nil + ))) + let batchSize = 5_000 + var batch: [PluginRow] = [] + batch.reserveCapacity(batchSize) + while let rowPtr = mysql_fetch_row(resultPtr) { + if abort.isAborted || cancellationGate.isCancelled(generation) { + /// Same shape as the capped buffered read: stop the server first, then + /// drain what is already in flight so the connection stays usable. + if let statement = killStatement(for: mysql) { + killQueryOnServer(statement: statement, generation: generation) + } + while mysql_fetch_row(resultPtr) != nil {} mysql_free_result(resultPtr) - continuation.finish() + throw CancellationError() + } + + batch.append(textProtocolRow(rowPtr, lengths: mysql_fetch_lengths(resultPtr), columns: columns)) + if batch.count >= batchSize { + continuation.yield(.rows(batch)) + batch.removeAll(keepingCapacity: true) } } + if !batch.isEmpty { + continuation.yield(.rows(batch)) + } + + if mysql_errno(mysql) != 0 { + let error = noting(getError(), generation: generation) + mysql_free_result(resultPtr) + throw error + } + + mysql_free_result(resultPtr) } // MARK: - Server Information @@ -1088,12 +1232,20 @@ final class MariaDBPluginConnection: @unchecked Sendable { return readError(from: mysql) } + /// A killed or server-timed-out prepared fetch leaves `mysql_stmt_errno` at 0 and reports the + /// reason on the connection handle instead: measured on MySQL 5.7.44, `fetch rc=1 stmt errno 0 + /// '' conn errno 3024`. Reading only the statement threw code 0 with an empty message, so the + /// user was shown nothing at all. private func getStmtError(_ stmt: UnsafeMutablePointer) -> MariaDBPluginError { - MariaDBPluginError( - code: mysql_stmt_errno(stmt), - message: mysql_stmt_error(stmt).map(decodedMessage) ?? "Unknown statement error", - sqlState: sqlState(mysql_stmt_sqlstate(stmt)) - ) + let code = mysql_stmt_errno(stmt) + guard code == 0, let mysql = self.mysql, mysql_errno(mysql) != 0 else { + return MariaDBPluginError( + code: code, + message: mysql_stmt_error(stmt).map(decodedMessage) ?? "Unknown statement error", + sqlState: sqlState(mysql_stmt_sqlstate(stmt)) + ) + } + return readError(from: mysql) } private func decodedMessage(_ message: UnsafePointer) -> String { diff --git a/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift b/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift new file mode 100644 index 0000000000..bf52455509 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift @@ -0,0 +1,89 @@ +// +// MySQLAccountStatements.swift +// MySQLDriverPlugin +// +// The account grammar one server takes. Pure, so TableProTests compiles it. +// + +import Foundation +import TableProPluginKit + +internal enum MySQLAccountSyntax: Equatable, Sendable { + case alterUser + case grantUsage +} + +/// `CREATE USER ... WITH MAX_USER_CONNECTIONS`, `ALTER USER ... WITH MAX_USER_CONNECTIONS` and +/// `ALTER USER ... IDENTIFIED BY` all arrived in MySQL 5.7.6 and MariaDB 10.2.0, and MySQL 8 +/// removed every form that works below them, so no single spelling reaches both. +/// +/// The legacy password goes in as `GRANT USAGE ON *.* TO acct IDENTIFIED BY 'p'` rather than +/// `SET PASSWORD FOR acct = PASSWORD('p')`, which fails silently for an account on an auth plugin: +/// measured on MariaDB 10.1.48 against a `unix_socket` account, `SET PASSWORD` answered `Query OK, +/// 1 warning` with `Note 1699 SET PASSWORD has no significance for users authenticating via +/// plugins`, left the plugin in place, and the new password then got `ERROR 1698`. The `GRANT` +/// cleared the plugin and the login worked, which is what 10.2's `ALTER USER` does too. It also +/// avoids the `ERROR 1827` a MySQL 5.6 `sha256_password` account gives `SET PASSWORD`. The cost is +/// that it needs `GRANT OPTION` as well as `UPDATE` on `mysql.*`, and that it re-creates an account +/// another admin dropped between the load and the apply. +internal struct MySQLAccountStatements { + internal let syntax: MySQLAccountSyntax + internal let account: (PluginPrincipalRef) -> String + internal let literal: (String) -> String + + internal init( + syntax: MySQLAccountSyntax, + account: @escaping (PluginPrincipalRef) -> String, + literal: @escaping (String) -> String + ) { + self.syntax = syntax + self.account = account + self.literal = literal + } + + internal func create(_ definition: PluginPrincipalDefinition) -> [String] { + let name = account(definition.ref) + var statement = "CREATE USER \(name)" + if let password = definition.password, !password.isEmpty { + statement += " IDENTIFIED BY '\(literal(password))'" + } + guard let limit = definition.connectionLimit else { return [statement] } + guard syntax == .alterUser else { + /// `CREATE USER` first, because it is the statement that fails on a duplicate account: + /// measured on MySQL 5.5.62, 5.6.51 and MariaDB 5.5.64 and 10.0.38, the `GRANT` on its + /// own creates a passwordless account instead. + return [statement, connectionLimit(limit, for: definition.ref)] + } + return [statement + " WITH MAX_USER_CONNECTIONS \(limit)"] + } + + internal func alter( + old: PluginPrincipalDefinition, + new: PluginPrincipalDefinition + ) -> [String] { + var statements: [String] = [] + if old.connectionLimit != new.connectionLimit { + statements.append(connectionLimit(new.connectionLimit ?? 0, for: old.ref)) + } + if old.ref != new.ref { + statements.append("RENAME USER \(account(old.ref)) TO \(account(new.ref))") + } + return statements + } + + internal func setPassword(_ password: String, for principal: PluginPrincipalRef) -> [String] { + let name = account(principal) + guard syntax == .alterUser else { + return ["GRANT USAGE ON *.* TO \(name) IDENTIFIED BY '\(literal(password))'"] + } + return ["ALTER USER \(name) IDENTIFIED BY '\(literal(password))'"] + } + + private func connectionLimit(_ limit: Int, for principal: PluginPrincipalRef) -> String { + let name = account(principal) + guard syntax == .alterUser else { + return "GRANT USAGE ON *.* TO \(name) WITH MAX_USER_CONNECTIONS \(limit)" + } + return "ALTER USER \(name) WITH MAX_USER_CONNECTIONS \(limit)" + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift b/Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift new file mode 100644 index 0000000000..0e3305d3c4 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift @@ -0,0 +1,130 @@ +// +// MySQLCheckConstraints.swift +// MySQLDriverPlugin +// +// Where a server's check constraints are read from, and whether it has any to read. +// + +import Foundation +import TableProPluginKit + +internal enum MySQLCheckConstraintSource: Equatable, Sendable { + case unavailable + case informationSchema + case createTableStatement + case databendCatalog +} + +/// One decision for both the read and the edit, because "enforces CHECK" and "has a +/// `CHECK_CONSTRAINTS` table" are two different facts and treating them as one produced two bugs. +/// +/// MySQL before 8.0.16 and MariaDB before 10.2.1 parse `ADD CONSTRAINT ... CHECK` and throw the +/// clause away: measured on 5.5.62, 5.6.51, 5.7.44, MariaDB 10.0.38 and 10.1.48, the statement +/// answers `Query OK` with no warning and the violating insert then succeeds. MariaDB 10.2.1 to +/// 10.2.21 and 10.3.0 to 10.3.9 enforce the constraint but have no catalog for it, so reading one +/// fails with `ERROR 1109 Unknown table 'CHECK_CONSTRAINTS'` and takes the whole Structure tab with +/// it; `SHOW CREATE TABLE` is what answers there. +internal enum MySQLCheckConstraints { + static func source(banner: String?, flavor: MySQLServerFlavor) -> MySQLCheckConstraintSource { + switch flavor { + case .mysql: + return MySQLServerVersion.isAtLeast((8, 0, 16), banner: banner) ? .informationSchema : .unavailable + case .mariadb: + guard MySQLServerVersion.isAtLeast((10, 2, 1), banner: banner) else { return .unavailable } + return mariadbListsCheckConstraints(banner: banner) ? .informationSchema : .createTableStatement + case .tidb(let version): + guard let version, version >= MySQLEngineVersion(major: 7, minor: 2, patch: 0) else { return .unavailable } + return .createTableStatement + case .oceanbase(let version): + guard let version, version >= MySQLEngineVersion(major: 4, minor: 0, patch: 0) else { return .unavailable } + return .informationSchema + case .databend: + return .databendCatalog + } + } + + /// Whether a check constraint written to this server would survive. False while the version is + /// unknown, so the statement is withheld rather than sent to a server that may discard it. + static func supportsEditing(banner: String?, flavor: MySQLServerFlavor) -> Bool { + knowsVersion(banner: banner, flavor: flavor) && source(banner: banner, flavor: flavor) != .unavailable + } + + /// Why this connected server has no check constraints to list or edit, or nil when it has. + /// + /// A server whose version is unknown refuses nothing. Every disconnect clears the banner and + /// resets the flavor to `.mysql`, and the app keeps that handle installed across a reconnect, + /// so reading "no version" as "too old" hides the Constraints tab on MariaDB 11 and words the + /// reason as MySQL 8.0.16. + static func refusal(banner: String?, flavor: MySQLServerFlavor) -> String? { + guard knowsVersion(banner: banner, flavor: flavor), + source(banner: banner, flavor: flavor) == .unavailable, + let floor = versionFloorName(for: flavor) + else { return nil } + return String(format: String(localized: "Check constraints need %@ or later."), floor) + } + + /// MySQL 8.0.16 to 8.0.18 takes `DROP CHECK` and answers `ERROR 1064` to `DROP CONSTRAINT`; + /// 8.0.19 takes both. MariaDB never takes `DROP CHECK`. + static func dropStatement( + quotedTable: String, + quotedName: String, + banner: String?, + flavor: MySQLServerFlavor + ) -> String { + let keyword = usesDropCheckKeyword(banner: banner, flavor: flavor) ? "DROP CHECK" : "DROP CONSTRAINT" + return "ALTER TABLE \(quotedTable) \(keyword) \(quotedName)" + } + + static func parse(createTable sql: String) -> [PluginCheckConstraintInfo] { + guard let body = MySQLCreateTableScanner.firstGroup(in: Substring(sql)) else { return [] } + return MySQLCreateTableScanner.topLevelElements(of: body).compactMap(checkConstraint(in:)) + } + + /// The catalog arrived mid-series: measured, 10.2.21 answers `ERROR 1109` and 10.2.22 lists the + /// constraint, and the 10.3 series repeats that at 10.3.9 and 10.3.10. + private static func mariadbListsCheckConstraints(banner: String?) -> Bool { + guard let banner, let version = MySQLServerVersion.components(from: banner) else { return false } + guard version.major == 10, version.minor <= 3 else { + return MySQLServerVersion.isAtLeast((10, 4, 0), banner: banner) + } + return version.minor == 2 ? version.patch >= 22 : version.patch >= 10 + } + + private static func knowsVersion(banner: String?, flavor: MySQLServerFlavor) -> Bool { + switch flavor { + case .mysql, .mariadb: + guard let banner else { return false } + return MySQLServerVersion.components(from: banner) != nil + case .tidb(let version), .oceanbase(let version): + return version != nil + case .databend: + return true + } + } + + private static func versionFloorName(for flavor: MySQLServerFlavor) -> String? { + switch flavor { + case .mysql: return "MySQL 8.0.16" + case .mariadb: return "MariaDB 10.2.1" + case .tidb: return "TiDB 7.2" + case .oceanbase: return "OceanBase 4.0" + case .databend: return nil + } + } + + private static func usesDropCheckKeyword(banner: String?, flavor: MySQLServerFlavor) -> Bool { + guard flavor == .mysql else { return false } + return !MySQLServerVersion.isAtLeast((8, 0, 19), banner: banner) + } + + private static func checkConstraint(in element: Substring) -> PluginCheckConstraintInfo? { + var rest = element + guard MySQLCreateTableScanner.consume("CONSTRAINT", from: &rest), + let name = MySQLCreateTableScanner.consumeBacktickName(from: &rest), + MySQLCreateTableScanner.consume("CHECK", from: &rest) + else { return nil } + rest = rest.drop(while: \.isWhitespace) + guard rest.first == "(", let expression = MySQLCreateTableScanner.firstGroup(in: rest) else { return nil } + return PluginCheckConstraintInfo(name: name, expression: expression.trimmingCharacters(in: .whitespaces)) + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLKillLatch.swift b/Plugins/MySQLDriverPlugin/MySQLKillLatch.swift new file mode 100644 index 0000000000..6e85876b6d --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLKillLatch.swift @@ -0,0 +1,65 @@ +// +// MySQLKillLatch.swift +// MySQLDriverPlugin +// +// Whether a `KILL QUERY` this connection sent is still waiting on the server for the next +// statement to collect. +// + +import Foundation + +/// A `KILL QUERY` that reaches the server after the statement it names has finished is not always +/// dropped. Measured with the app's own libmariadb against an idle session: MySQL 5.5.62, 5.6.51 and +/// MariaDB 5.5.64 hold the flag and hand it to whatever the session runs next, which fails with +/// `ERROR 1317 Query execution was interrupted`; MySQL 5.7.44 and 8.4.11 and MariaDB 10.0.38, +/// 10.1.48 and 10.6.28 drop it. +/// +/// Stop is exactly when that happens. The kill goes out on its own connection while the statement it +/// names is already unwinding, so the next statement on the session pays for it: on a batch that is +/// the following statement, and on an export it is the stream. +/// +/// Pure, so the ordering is testable without a server. The connection records what it did; the +/// decision to spend a round trip absorbing the flag is this one call. +nonisolated internal struct MySQLKillLatch { + private var deliveredGeneration: Int? + private var interruptedGeneration: Int? + + internal init() {} + + /// A kill this connection actually put on the wire, for the statement generation it named. + internal mutating func recordDelivered(generation: Int) { + deliveredGeneration = generation + } + + /// The server answering that generation's statement with its interruption code, which is the + /// kill being spent rather than held. + internal mutating func recordInterrupted(generation: Int) { + interruptedGeneration = generation + } + + /// Whether the next statement has to absorb a flag the server is still holding, and clears the + /// latch either way. + /// + /// Order-independent on purpose: the kill is delivered from the cancel queue while the statement + /// it names reads its error on the statement queue, so either can be recorded first. + internal mutating func takeAbsorption() -> Bool { + defer { + deliveredGeneration = nil + interruptedGeneration = nil + } + guard let delivered = deliveredGeneration else { return false } + return interruptedGeneration != delivered + } + + /// Only the two engines measured to hold an idle kill pay the absorbing round trip. TiDB drops + /// the session on `KILL QUERY` instead of flagging it, and Databend and OceanBase are unmeasured, + /// so neither gets a statement inserted into its session on a guess. + internal static func absorbsLatchedKill(flavor: MySQLServerFlavor) -> Bool { + switch flavor { + case .mysql, .mariadb: + return true + case .tidb, .databend, .oceanbase: + return false + } + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift index 3bb2c5ae45..4ba82f602c 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+CatalogFallback.swift @@ -99,7 +99,11 @@ internal extension MySQLPluginDriver { /// Base tables only: `SHOW CREATE TABLE` answers for a view with its `SELECT` and for a MariaDB /// sequence with the sequence's own table, neither of which can carry a constraint. func showForeignKeysByTable(database: String) async throws -> [String: [PluginForeignKeyInfo]] { - let omittedAction = MySQLServerVersion.omittedForeignKeyAction(banner: _serverVersion, flavor: flavor) + let identity = serverIdentity + let omittedAction = MySQLServerVersion.omittedForeignKeyAction( + banner: identity.banner, + flavor: identity.flavor + ) return try await degradedRead(database: database, baseTablesOnly: true) { table in try await self.ddlForeignKeys(table: table.name, database: database, omittedAction: omittedAction) } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift index cdc8233d88..60a2bf97fb 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Flavor.swift @@ -86,10 +86,12 @@ extension MySQLPluginDriver { return flavor.killTarget(connectionIdentifier: identifier) } - func tidbCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] { + /// The read for a server that enforces check constraints without cataloguing them: TiDB from + /// 7.2, and MariaDB 10.2.1 to 10.2.21 and 10.3.0 to 10.3.9. + func createTableCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] { let result = try await execute(query: "SHOW CREATE TABLE \(qualifiedName(table, schema: schema))") guard let createTable = result.rows.first?[safe: 1]?.asText else { return [] } - return TiDBCheckConstraints.parse(createTable: createTable) + return MySQLCheckConstraints.parse(createTable: createTable) } private func probeSucceeds(_ statement: String, on connection: MariaDBPluginConnection) async -> Bool { @@ -106,7 +108,7 @@ extension MySQLPluginDriver { let result = try await connection.executeQuery(statement) return result.rows.first?.first?.asText } catch { - Self.logger.debug("Flavor probe failed: \(error.localizedDescription, privacy: .public)") + Self.logger.debug("Flavor probe failed: \(error.localizedDescription, privacy: .private)") return nil } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift index c5634bd1a1..cb04e3a32a 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ForeignKeys.swift @@ -17,7 +17,11 @@ extension MySQLPluginDriver { func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { guard !flavor.isDatabend else { return [] } let database = effectiveSchema(schema) - let omittedAction = MySQLServerVersion.omittedForeignKeyAction(banner: _serverVersion, flavor: flavor) + let identity = serverIdentity + let omittedAction = MySQLServerVersion.omittedForeignKeyAction( + banner: identity.banner, + flavor: identity.flavor + ) let byTable = try await catalogOrShow( database: database, catalog: { try await self.catalogForeignKeys(database: database, table: table) }, @@ -85,7 +89,10 @@ extension MySQLPluginDriver { return MySQLForeignKeyCatalog.group( columnRows: columnRows, actionRows: actionRows.filter { !$0.onDelete.isEmpty && !$0.onUpdate.isEmpty }, - defaultAction: MySQLServerVersion.omittedForeignKeyAction(banner: _serverVersion, flavor: flavor) + defaultAction: MySQLServerVersion.omittedForeignKeyAction( + banner: serverIdentity.banner, + flavor: serverIdentity.flavor + ) ) } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift index 39424912a1..19ade08db1 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+PrincipalSQL.swift @@ -8,38 +8,18 @@ import TableProPluginKit extension MySQLPluginDriver { func generateCreatePrincipalSQL(definition: PluginPrincipalDefinition) -> [String]? { - let account = grantAccount(definition.ref) - var statement = "CREATE USER \(account)" - - if let password = definition.password, !password.isEmpty { - statement += " IDENTIFIED BY '\(escapeStringLiteral(password))'" - } - if let limit = definition.connectionLimit { - statement += " WITH MAX_USER_CONNECTIONS \(limit)" - } - return [statement] + accountStatements.create(definition) } func generateAlterPrincipalSQL( old: PluginPrincipalDefinition, new: PluginPrincipalDefinition ) -> [String]? { - var statements: [String] = [] - let account = grantAccount(old.ref) - - if old.connectionLimit != new.connectionLimit { - statements.append( - "ALTER USER \(account) WITH MAX_USER_CONNECTIONS \(new.connectionLimit ?? 0)" - ) - } - if old.ref != new.ref { - statements.append("RENAME USER \(account) TO \(grantAccount(new.ref))") - } - return statements + accountStatements.alter(old: old, new: new) } func generateSetPasswordSQL(principal: PluginPrincipalRef, password: String) -> [String]? { - ["ALTER USER \(grantAccount(principal)) IDENTIFIED BY '\(escapeStringLiteral(password))'"] + accountStatements.setPassword(password, for: principal) } func generateDropPrincipalSQL( @@ -57,6 +37,15 @@ extension MySQLPluginDriver { grantBuilder(for: changeSet.principal).revokeStatements(changeSet.grantsToRemove) } + private var accountStatements: MySQLAccountStatements { + let identity = serverIdentity + return MySQLAccountStatements( + syntax: MySQLServerVersion.accountSyntax(banner: identity.banner, flavor: identity.flavor), + account: { self.grantAccount($0) }, + literal: { self.escapeStringLiteral($0) } + ) + } + private func grantBuilder(for principal: PluginPrincipalRef) -> PluginGrantSQLBuilder { PluginGrantSQLBuilder( grantee: grantAccount(principal), diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift index 5e861d9e7b..0a20b7f9e0 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Schema.swift @@ -109,7 +109,8 @@ internal extension MySQLPluginDriver { /// it. It degrades to no expressions instead, which is what the blind path returns anyway. private func fetchGenerationExpressions(table: String, schema: String?) async throws -> [String: String] { guard catalogVisibility.visibility(of: effectiveSchema(schema)) != .blind else { return [:] } - guard MySQLServerVersion.hasGenerationExpression(banner: _serverVersion, flavor: flavor) else { + let identity = serverIdentity + guard MySQLServerVersion.hasGenerationExpression(banner: identity.banner, flavor: identity.flavor) else { return [:] } let query = """ @@ -142,14 +143,18 @@ internal extension MySQLPluginDriver { /// directly. Neither exposes the columns a check touches, so `columns` stays empty rather than /// being guessed from the expression. func fetchCheckConstraints(table: String, schema: String?) async throws -> [PluginCheckConstraintInfo] { - let flavor = self.flavor - guard !flavor.isDatabend else { - return try await databendCheckConstraints(table: table, schema: schema) - } - guard MySQLServerVersion.hasCheckConstraints(banner: _serverVersion, flavor: flavor) else { + let identity = serverIdentity + let flavor = identity.flavor + switch MySQLCheckConstraints.source(banner: identity.banner, flavor: flavor) { + case .unavailable: return [] + case .databendCatalog: + return try await databendCheckConstraints(table: table, schema: schema) + case .createTableStatement: + return try await createTableCheckConstraints(table: table, schema: schema) + case .informationSchema: + break } - guard !flavor.isTiDB else { return try await tidbCheckConstraints(table: table, schema: schema) } let database = effectiveSchemaLiteral(schema) let safeTable = mysqlEscapeStringLiteral(table) let query: String @@ -201,8 +206,9 @@ internal extension MySQLPluginDriver { ) async throws -> [String: [PluginColumnInfo]] { let escapedDb = effectiveSchemaLiteral(schema) let tableFilter = table.map { " AND TABLE_NAME = '\(mysqlEscapeStringLiteral($0))'" } ?? "" + let identity = serverIdentity let hasGenerationExpression = MySQLServerVersion.hasGenerationExpression( - banner: _serverVersion, flavor: flavor + banner: identity.banner, flavor: identity.flavor ) let generationProjection = hasGenerationExpression ? "GENERATION_EXPRESSION" : "NULL" let query = """ diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ServerSupport.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ServerSupport.swift new file mode 100644 index 0000000000..b1eca21e04 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+ServerSupport.swift @@ -0,0 +1,16 @@ +// +// MySQLPluginDriver+ServerSupport.swift +// MySQLDriverPlugin +// +// What the connected server refuses, as opposed to what the engine can do at its newest. +// + +import Foundation +import TableProPluginKit + +internal extension MySQLPluginDriver { + var checkConstraintRefusal: String? { + let identity = serverIdentity + return MySQLCheckConstraints.refusal(banner: identity.banner, flavor: identity.flavor) + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 5e2162a105..960f9e7ef0 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -12,7 +12,7 @@ import TableProPluginKit final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private let config: DriverConnectionConfig private var mariadbConnection: MariaDBPluginConnection? - internal var _serverVersion: String? + private var _serverVersion: String? private var _activeDatabase: String /// The database a metadata read is scoped to. MySQL has no schema level, so this is what a @@ -73,10 +73,18 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { internal static let logger = Logger(subsystem: "com.TablePro", category: "MySQLPluginDriver") var currentSchema: String? { nil } - var serverVersion: String? { _serverVersion } + var serverVersion: String? { sessionLock.withLock { _serverVersion } } + + /// The banner and the flavor together, taken under one lock. `connect()` writes both in the + /// same block, so reading them separately can pair a new banner with the old flavor: a + /// `10.1.48-MariaDB` banner under `.mysql` clears an 8.0.16 floor, because 10 is above 8. + internal var serverIdentity: (banner: String?, flavor: MySQLServerFlavor) { + sessionLock.withLock { (_serverVersion, _flavor) } + } internal var catalogQuotesDefaults: Bool { - MySQLServerVersion.quotesColumnDefault(banner: _serverVersion, flavor: flavor) + let identity = serverIdentity + return MySQLServerVersion.quotesColumnDefault(banner: identity.banner, flavor: identity.flavor) } var supportsSchemas: Bool { false } var supportsTransactions: Bool { true } @@ -146,8 +154,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } conn.adopt(flavor: resolvedFlavor, killTarget: await killTarget(for: resolvedFlavor, on: conn)) mariadbConnection = conn - _serverVersion = conn.serverVersion() + let banner = conn.serverVersion() sessionLock.withLock { + _serverVersion = banner _flavor = resolvedFlavor isReleased = false isDisconnected = false @@ -161,10 +170,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { Task { await timer.stop() } mariadbConnection?.disconnect() mariadbConnection = nil - _serverVersion = nil catalogVisibility.clear() let initialFlavor = Self.initialFlavor(for: config) let inFlight = sessionLock.withLock { () -> Task? in + _serverVersion = nil _flavor = initialFlavor isReleased = false isDisconnected = true @@ -212,6 +221,23 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: flavor.beginTransactionStatement(mode: mode)) } + /// No round trip: the transaction is the flag the server put in the reply to the last statement, + /// and the table lock is what the footprint saw go past. The connection's flags are read before + /// the lock is taken, which is the order `endOperation` writes them in. + /// + /// A released connection answers `.idle` rather than `.unknown`, because a release only happens + /// over a footprint that is holding nothing at all. A flavour whose replies may not carry the + /// status flags answers `.unknown`, which leaves the caller deciding as if it had not asked. + func sessionTransactionState() async -> PluginSessionTransactionState { + guard flavor.reportsSessionStatusFlags else { return .unknown } + let state = sessionLock.withLock { (released: isReleased, disconnected: isDisconnected) } + guard !state.disconnected else { return .unknown } + guard !state.released else { return .idle } + guard let conn = mariadbConnection else { return .unknown } + let isInTransaction = conn.isInTransaction + return sessionLock.withLock { footprint.transactionState(isInTransaction: isInTransaction) } + } + // MARK: - Query Execution func execute(query: String) async throws -> PluginQueryResult { @@ -227,7 +253,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { defer { endOperation(on: conn) } noteActivity(query) let startTime = Date() - let result = try await conn.executeParameterizedQuery(query, parameters: parameters, rowCap: cap) + let result: MariaDBPluginQueryResult + do { + result = try await conn.executeParameterizedQuery(query, parameters: parameters, rowCap: cap) + } catch { + noteFailure(query) + throw error + } return PluginQueryResult( columns: result.columns, columnTypeNames: result.columnTypeNames, @@ -254,7 +286,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { noteActivity(query) let startTime = Date() - let result = try await conn.executeParameterizedQuery(query, parameters: parameters) + let result: MariaDBPluginQueryResult + do { + result = try await conn.executeParameterizedQuery(query, parameters: parameters) + } catch { + noteFailure(query) + throw error + } return PluginQueryResult( columns: result.columns, @@ -307,8 +345,9 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { isTruncated: result.isTruncated, columnMeta: result.columnMeta ) - } catch let error as MariaDBPluginError - where !isRetry && isConnectionLostError(error) && mayReplay(query) { + } catch let error as MariaDBPluginError where !isRetry + && mysqlConnectionLossMayReplay(code: error.code, outlastedSocketTimeout: error.outlastedSocketTimeout) + && mayReplay(query) { try await reconnect() return try await executeWithReconnect( query: query, @@ -316,13 +355,14 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { rowCap: rowCap, countsAsActivity: countsAsActivity ) + } catch { + if countsAsActivity { + noteFailure(query) + } + throw error } } - private func isConnectionLostError(_ error: MariaDBPluginError) -> Bool { - [2_006, 2_013, 2_055].contains(Int(error.code)) - } - // MARK: - Idle connection release private func noteActivity(_ sql: String) { @@ -332,6 +372,14 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + /// The footprint reads the statement before it runs, so a statement the server refused has to + /// be taken back. Only what a failure provably did not leave behind is cleared, which today is + /// the table lock: a `LOCK TABLES` that errors holds nothing, and releases what the session held + /// before it. + private func noteFailure(_ sql: String) { + sessionLock.withLock { footprint.observeFailure(of: sql) } + } + private func mayReplay(_ query: String) -> Bool { sessionLock.withLock { mysqlMayReplay(query, on: footprint) } } @@ -836,18 +884,51 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { /// timeout on every connect: the footprint would be dirty before the user ran anything and no /// connection would ever be released. It is the driver's own setting and the reconnect puts it /// back, so it is not the session's to lose. + /// + /// Which enforcement the session gets is the server's own answer rather than a reading of its + /// banner: the `SET SESSION` runs, and only `ERROR 1193 Unknown system variable` switches the + /// session to a client-side deadline. MySQL gained `max_execution_time` in 5.7.8 and MariaDB + /// `max_statement_time` in 10.1.1, but a proxy or a fork misreports its version in both + /// directions, and ProxySQL defaults its banner to 5.5.30 in front of a modern server. func applyQueryTimeout(_ seconds: Int) async throws { sessionLock.withLock { appliedQueryTimeoutSeconds = seconds } + let sessionFlavor = flavor + let deadline = await installServerQueryTimeout(seconds: seconds, flavor: sessionFlavor) + adopt(clientDeadline: deadline) + } + + /// Sends the flavor's statements in order and answers with the client-side deadline the session + /// is left needing, which is `nil` whenever the server is enforcing one of its own. + private func installServerQueryTimeout( + seconds: Int, + flavor: MySQLServerFlavor + ) async -> MySQLStatementDeadline? { + var installation = MySQLQueryTimeoutInstallation(seconds: seconds, flavor: flavor) for statement in flavor.queryTimeoutStatements(seconds: seconds) { + let step: MySQLQueryTimeoutInstallation.Step do { _ = try await executeWithReconnect(query: statement, isRetry: false, countsAsActivity: false) + step = installation.accepted() + } catch let error as MariaDBPluginError where mysqlRejectsStatementTimeout(code: error.code) { + step = installation.refusedAsUnknownVariable() } catch { Self.logger.warning( "Failed to set query timeout with \(statement, privacy: .public): \(error.localizedDescription)" ) - return + step = installation.failed() } + guard case .adopt(let deadline) = step else { continue } + return deadline } + return nil + } + + private func adopt(clientDeadline: MySQLStatementDeadline?) { + mariadbConnection?.adopt(statementDeadline: clientDeadline) + guard let clientDeadline else { return } + Self.logger.info( + "Server has no statement timeout; a statement past \(clientDeadline.seconds, privacy: .public)s is stopped with KILL QUERY" + ) } // MARK: - EXPLAIN @@ -945,6 +1026,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func generateAddCheckConstraintSQL(table: String, constraint: PluginCheckConstraintDefinition) -> String? { + let identity = serverIdentity + guard MySQLCheckConstraints.supportsEditing(banner: identity.banner, flavor: identity.flavor) else { + return nil + } let expression = constraint.expression.trimmingCharacters(in: .whitespacesAndNewlines) guard !expression.isEmpty, !constraint.name.isEmpty else { return nil } return "ALTER TABLE \(quoteIdentifier(table)) ADD CONSTRAINT " @@ -952,8 +1037,16 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func generateDropCheckConstraintSQL(table: String, constraintName: String) -> String? { - guard !constraintName.isEmpty else { return nil } - return "ALTER TABLE \(quoteIdentifier(table)) DROP CONSTRAINT \(quoteIdentifier(constraintName))" + let identity = serverIdentity + guard MySQLCheckConstraints.supportsEditing(banner: identity.banner, flavor: identity.flavor), + !constraintName.isEmpty + else { return nil } + return MySQLCheckConstraints.dropStatement( + quotedTable: quoteIdentifier(table), + quotedName: quoteIdentifier(constraintName), + banner: identity.banner, + flavor: identity.flavor + ) } func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { diff --git a/Plugins/MySQLDriverPlugin/MySQLQueryTimeout.swift b/Plugins/MySQLDriverPlugin/MySQLQueryTimeout.swift new file mode 100644 index 0000000000..7984a65842 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLQueryTimeout.swift @@ -0,0 +1,134 @@ +// +// MySQLQueryTimeout.swift +// MySQLDriverPlugin +// +// What enforces the query timeout on this server, and how a statement that ran past it is read. +// Pure, so TableProTests compiles it. +// + +import Foundation + +/// Which statements a client-side deadline covers, matching the scope each engine's own statement +/// timeout has. Measured on MySQL 5.7.44: `max_execution_time` stops a `SELECT` and leaves +/// `SHOW TABLES ... WHERE SLEEP(5) = 0`, `CALL p()`, `DO SLEEP(5)` and `INSERT ... SELECT` alone. +/// Measured on MariaDB 10.1.48: `max_statement_time` stops all of them. +internal enum MySQLStatementDeadlineScope: Equatable, Sendable { + case selectStatements + case everyStatement +} + +internal struct MySQLStatementDeadline: Equatable, Sendable { + internal let seconds: Int + internal let scope: MySQLStatementDeadlineScope + + internal func applies(to sql: String) -> Bool { + guard seconds > 0 else { return false } + guard scope == .selectStatements else { return true } + return mysqlStrippedStatementBody(sql).range( + of: #"^[\s(]*SELECT\b"#, options: [.regularExpression, .caseInsensitive] + ) != nil + } +} + +internal enum MySQLQueryTimeoutEnforcement: Equatable { + case serverStatements([String]) + case clientDeadline(MySQLStatementDeadline) +} + +/// What the version floors say this server should do with the timeout. The driver runs the flavor's +/// statements first and falls back to the deadline on the server's own refusal, so this is the +/// predicate the tests and `scripts/check-mysql-query-timeout.sh` compare a live server against +/// rather than the runtime gate: a proxy or a fork gets the banner wrong in both directions. +internal func mysqlQueryTimeoutEnforcement( + seconds: Int, + flavor: MySQLServerFlavor, + banner: String? +) -> MySQLQueryTimeoutEnforcement { + guard MySQLServerVersion.hasStatementTimeout(banner: banner, flavor: flavor) else { + return .clientDeadline(mysqlClientDeadline(seconds: seconds, flavor: flavor)) + } + return .serverStatements(flavor.queryTimeoutStatements(seconds: seconds)) +} + +internal func mysqlClientDeadline(seconds: Int, flavor: MySQLServerFlavor) -> MySQLStatementDeadline { + MySQLStatementDeadline(seconds: seconds, scope: flavor.isMariaDB ? .everyStatement : .selectStatements) +} + +/// What the session ends up enforcing as the flavor's timeout statements are sent one at a time, +/// and the single writer of the connection's client-side deadline. +/// +/// A flavor can send more than one: OceanBase sends `ob_query_timeout` and then +/// `max_execution_time = 0`. A server that takes the first and answers `ERROR 1193` to the second +/// already has a working server-side timeout, so a client-side deadline on top of it stops the +/// statement twice, and the second `KILL QUERY` lands on whatever the session runs next +/// (`MySQLKillLatch.absorbsLatchedKill` is false for OceanBase, so nothing absorbs it and the next +/// statement fails with `ERROR 1317`). +/// +/// A statement the server refused for any other reason stops the run with no deadline at all, and +/// the caller adopts that `nil` rather than returning: the deadline belongs to the timeout this call +/// installed and never to the one a previous call did, and a bare return left a deadline built from +/// an earlier `seconds` in force. +internal struct MySQLQueryTimeoutInstallation { + internal enum Step: Equatable { + case sendNextStatement + case adopt(MySQLStatementDeadline?) + } + + private let seconds: Int + private let flavor: MySQLServerFlavor + private var serverTookAStatement = false + + internal init(seconds: Int, flavor: MySQLServerFlavor) { + self.seconds = seconds + self.flavor = flavor + } + + internal mutating func accepted() -> Step { + serverTookAStatement = true + return .sendNextStatement + } + + internal mutating func refusedAsUnknownVariable() -> Step { + guard !serverTookAStatement else { return .sendNextStatement } + return .adopt(mysqlClientDeadline(seconds: seconds, flavor: flavor)) + } + + internal func failed() -> Step { + .adopt(nil) + } +} + +/// `ERROR 1193 Unknown system variable`, which is how every server without a statement timeout +/// answers the `SET SESSION` that would install one. Any other failure is a server problem the +/// driver reports rather than a missing feature it works around. +internal let mysqlUnknownSystemVariableCode: UInt32 = 1_193 + +internal func mysqlRejectsStatementTimeout(code: UInt32) -> Bool { + code == mysqlUnknownSystemVariableCode +} + +internal enum MySQLStatementFailureCause: Equatable { + case deadlineExceeded + case outlastedSocketTimeout + case server +} + +/// Why a statement failed, which decides what the user is told and whether the statement may be run +/// again. `libmariadb` reports its own read timeout as `2013 Lost connection to server during +/// query`, the same code and text a server-side drop gets, so only the time waited separates them. +internal func mysqlStatementFailureCause( + errno: UInt32, + message: String, + flavor: MySQLServerFlavor, + deadlineExpired: Bool, + waited: Duration, + socketTimeoutSeconds: UInt32 +) -> MySQLStatementFailureCause { + if deadlineExpired, flavor.isInterruptedByKill(errno: errno, message: message) { + return .deadlineExceeded + } + guard mysqlConnectionLossCodes.contains(errno), + mysqlWaitCouldOutlastSocketTimeout(waited, socketTimeoutSeconds: socketTimeoutSeconds) + else { return .server } + return .outlastedSocketTimeout +} diff --git a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift index f462d2da89..bd06c66b4a 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift @@ -113,6 +113,22 @@ nonisolated internal enum MySQLServerFlavor: Equatable, Sendable { } } + /// Whether the server's replies carry the session status flags `SERVER_STATUS_IN_TRANS` is read + /// from, so a caller can be told what the session has open. + /// + /// Measured with the app's own libmariadb 3.4.4 against MySQL 5.5.62 and 8.4.11, MariaDB 5.5.64 + /// and 11.4.13 and TiDB v8.5.1: all five report the flag, including the transaction that + /// `SET autocommit = 0` plus a write opens. Databend and OceanBase are unmeasured, so they + /// report nothing rather than reporting "no transaction" from a flag that may never be set. + var reportsSessionStatusFlags: Bool { + switch self { + case .mysql, .mariadb, .tidb: + return true + case .databend, .oceanbase: + return false + } + } + var listsSequencesAsTables: Bool { !isTiDB } var dropsIdleSessionOnKillQuery: Bool { isTiDB } diff --git a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift index e5f4144c7e..9951722f59 100644 --- a/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift +++ b/Plugins/MySQLDriverPlugin/MySQLServerVersion.swift @@ -27,22 +27,46 @@ enum MySQLServerVersion { return version.patch >= target.2 } - /// MySQL parsed and ignored CHECK before 8.0.16; MariaDB enforces it from 10.2.1. - /// `INFORMATION_SCHEMA.CHECK_CONSTRAINTS` appears with that support on both. - static func hasCheckConstraints(banner: String?, flavor: MySQLServerFlavor) -> Bool { + /// True only when the banner parses and names a version below `target`. An unreadable banner is + /// not an old server, so a gate that picks legacy syntax asks this rather than `!isAtLeast`. + static func isKnownBelow(_ target: (Int, Int, Int), banner: String?) -> Bool { + guard let banner, components(from: banner) != nil else { return false } + return !isAtLeast(target, banner: banner) + } + + /// Whether the server has a statement timeout at all. MySQL gained `max_execution_time` in + /// 5.7.8 and MariaDB `max_statement_time` in 10.1.1; measured, everything below answers + /// `ERROR 1193 Unknown system variable` to both spellings. + /// + /// This is the floor the tests and `scripts/check-mysql-query-timeout.sh` assert, not the + /// runtime gate: `applyQueryTimeout` runs the statement and reads the server's own answer, + /// which is right for a fork, a proxy or a release no image exists for. + static func hasStatementTimeout(banner: String?, flavor: MySQLServerFlavor) -> Bool { switch flavor { case .mysql: - return isAtLeast((8, 0, 16), banner: banner) + return isAtLeast((5, 7, 8), banner: banner) case .mariadb: - return isAtLeast((10, 2, 1), banner: banner) - case .tidb(let version): - guard let version else { return false } - return version >= MySQLEngineVersion(major: 7, minor: 2, patch: 0) - case .oceanbase(let version): - guard let version else { return false } - return version >= MySQLEngineVersion(major: 4, minor: 0, patch: 0) - case .databend: - return false + return isAtLeast((10, 1, 1), banner: banner) + case .tidb, .oceanbase, .databend: + return true + } + } + + /// Which account grammar this server takes. `CREATE USER ... WITH MAX_USER_CONNECTIONS`, + /// `ALTER USER ... WITH MAX_USER_CONNECTIONS` and `ALTER USER ... IDENTIFIED BY` all arrived in + /// MySQL 5.7.6 and MariaDB 10.2.0; measured, MySQL 5.5.62 and 5.6.51 and MariaDB 5.5.64, + /// 10.0.38 and 10.1.48 answer `ERROR 1064` to all three. + /// + /// TiDB and OceanBase ignore the banner, which lies about them: OceanBase handshakes as 5.7.25, + /// or 5.6.25 through OBProxy. + static func accountSyntax(banner: String?, flavor: MySQLServerFlavor) -> MySQLAccountSyntax { + switch flavor { + case .mysql: + return isKnownBelow((5, 7, 6), banner: banner) ? .grantUsage : .alterUser + case .mariadb: + return isKnownBelow((10, 2, 0), banner: banner) ? .grantUsage : .alterUser + case .tidb, .oceanbase, .databend: + return .alterUser } } diff --git a/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift b/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift index 340773b1af..66773be43e 100644 --- a/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift +++ b/Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift @@ -106,6 +106,39 @@ struct MySQLSessionFootprint: Equatable { hasOpenTransaction = isOpen } + /// A statement the server refused held nothing, and `LOCK TABLES` releases what the session + /// held before it acquires anything. Measured on MySQL 8.4 with `lock_wait_timeout = 1`: after + /// `LOCK TABLES t WRITE` a second session's `INSERT` failed with error 1205, and after a + /// following `LOCK TABLES nonexistent WRITE` (error 1146) the same `INSERT` went through. + /// + /// Only the lock flag is taken back. The rest of the footprint stays set, because a statement + /// that failed can still have created a temporary table, opened a transaction or moved a + /// session setting on its way to failing. + /// + /// The failure names the whole text that was sent, not the statement inside it that the server + /// refused, so only a single statement is provably the one that failed. A server runs a batch + /// in order and stops at the first refusal, so `LOCK TABLES t WRITE; INSERT INTO t VALUES (bad)` + /// holds the lock and fails on the `INSERT`: clearing the flag for any `LOCK TABLES` anywhere in + /// the text released a lock the session was still holding, and the idle release then handed the + /// connection back. + mutating func observeFailure(of sql: String) { + let statements = SQLStatementSplitting.statements(in: sql) + guard statements.count == 1, let failed = statements.first else { return } + let head = Self.collapsedHead(of: Self.executableBody(of: failed).uppercased()) + guard head.hasPrefix("LOCK TABLE") else { return } + hasLockedTables = false + } + + /// What the session holds, for a caller deciding whether it may open a transaction of its own. + /// + /// The open transaction is the server's own answer, from `observeServerTransaction`. The lock + /// is not, and it is never read as a transaction: a `START TRANSACTION` would release it, so + /// the batch must not send one, but there is nothing open for the user to commit. + func transactionState(isInTransaction: Bool) -> PluginSessionTransactionState { + if isInTransaction { return .inTransaction } + return hasLockedTables ? .holdsSessionLocks : .idle + } + private mutating func observeTransaction(_ statement: String) { switch SQLTransactionTracking.effect(of: statement) { case .opens: hasOpenTransaction = true @@ -153,6 +186,13 @@ struct MySQLSessionFootprint: Equatable { if head.hasPrefix("UNLOCK TABLES") { hasLockedTables = false } + /// Beginning a transaction releases every table a `LOCK TABLES` held. Measured on MySQL + /// 8.4 with `lock_wait_timeout = 1`: a second session's `INSERT` failed with error 1205 + /// while the lock was held, and went through after the holder ran `BEGIN`. A `COMMIT` does + /// not release it, which is why only the opening spellings are listed. + if Self.releasesTableLocks(head) { + hasLockedTables = false + } /// Set, not cleared, for the same reason a dropped temporary table is: a `HANDLER ... CLOSE` /// names one cursor. if head.hasPrefix("HANDLER ") { @@ -222,6 +262,16 @@ struct MySQLSessionFootprint: Equatable { private static let headLength = 64 + /// The two spellings that begin a transaction, read exactly rather than through + /// `SQLTransactionTracking`, whose `.opens` also matches `START REPLICA` and `START SLAVE`. + /// Those hold no transaction and release no lock, and a flag cleared by one of them would leave + /// the session holding a lock nothing knows about. + private static func releasesTableLocks(_ head: String) -> Bool { + if head.hasPrefix("START TRANSACTION") { return true } + guard head.hasPrefix("BEGIN") else { return false } + return head == "BEGIN" || head.hasPrefix("BEGIN WORK") + } + /// A `FLUSH` names its tables before the clause that matters, and a list of them runs past /// the head, so this one reads the whole statement. No `FLUSH` is long enough for that to /// cost anything. @@ -237,6 +287,10 @@ struct MySQLSessionFootprint: Equatable { private mutating func observeSet(_ normalized: String) { let body = normalized.dropFirst("SET ".count).trimmingCharacters(in: .whitespaces) guard !body.hasPrefix("@@GLOBAL."), !body.hasPrefix("GLOBAL ") else { return } + /// `SET PASSWORD` writes the grant tables, not the session, so it survives a reconnect. It + /// read as a session setting, which held the connection with the wrong reason and turned + /// off replay until the next reconnect. + guard !Self.assignsAccountPassword(body) else { return } /// `@@` is a system variable under another spelling, not a user variable: reporting /// `SET @@SESSION.sql_mode` as "session variables set" blocks the release for the right /// reason and tells the user the wrong one. @@ -246,4 +300,13 @@ struct MySQLSessionFootprint: Equatable { hasSessionSettings = true } } + + /// `SET PASSWORD` and `SET PASSWORD FOR ...`, and not `SET password_history = 3`, which is an + /// ordinary session setting whose name starts the same way. + private static func assignsAccountPassword(_ body: String) -> Bool { + guard body.hasPrefix("PASSWORD") else { return false } + let rest = body.dropFirst("PASSWORD".count) + guard let next = rest.first else { return false } + return next.isWhitespace || next == "=" + } } diff --git a/Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift b/Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift index 640065f368..866f759b42 100644 --- a/Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift +++ b/Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift @@ -3,6 +3,8 @@ // MySQLDriverPlugin // +import Foundation + internal let mysqlSocketTimeoutGraceSeconds = 30 internal func mysqlSocketTimeoutSeconds(forQueryTimeout queryTimeoutSeconds: Int) -> UInt32 { @@ -11,3 +13,14 @@ internal func mysqlSocketTimeoutSeconds(forQueryTimeout queryTimeoutSeconds: Int let clamped = min(queryTimeoutSeconds, ceiling) return UInt32(clamped + mysqlSocketTimeoutGraceSeconds) } + +/// Whether a failure this long into a statement could be the client's own read timeout rather than +/// the server dropping the connection. `MYSQL_OPT_READ_TIMEOUT` needs that much silence before it +/// fires, so anything earlier is never ours. +internal func mysqlWaitCouldOutlastSocketTimeout( + _ waited: Duration, + socketTimeoutSeconds: UInt32 +) -> Bool { + guard socketTimeoutSeconds > 0 else { return false } + return waited >= .seconds(Int64(socketTimeoutSeconds)) +} diff --git a/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift b/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift index ccf55c9acc..5bc95623d2 100644 --- a/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift +++ b/Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift @@ -55,6 +55,21 @@ internal func mysqlMayReplay(_ query: String, on footprint: MySQLSessionFootprin footprint.isClean && mysqlStatementIsSafeToReplay(query) } +/// The codes libmariadb reports when the connection under a statement is gone. +internal let mysqlConnectionLossCodes: Set = [2_006, 2_013, 2_055] + +/// Whether a lost connection may be retaken and the statement run again on the session that +/// replaces it. +/// +/// `ma_net_safe_read` reports the client's own read timeout with `CR_SERVER_LOST` (2013), the same +/// code and the same text as a server-side drop, so a statement that waited out the socket timeout +/// is indistinguishable from one the server hung up on. It is not gone: measured on MySQL 5.5.62 +/// with a one second query timeout, the server was still running two copies of the statement after +/// the driver reported the connection lost. Replaying adds a third. +internal func mysqlConnectionLossMayReplay(code: UInt32, outlastedSocketTimeout: Bool) -> Bool { + !outlastedSocketTimeout && mysqlConnectionLossCodes.contains(code) +} + internal func mysqlStatementIsSafeToReplay(_ query: String) -> Bool { guard mysqlStatementIsReadOnly(query) else { return false } let collapsed = query diff --git a/Plugins/MySQLDriverPlugin/MySQLStatementDeadlineRunner.swift b/Plugins/MySQLDriverPlugin/MySQLStatementDeadlineRunner.swift new file mode 100644 index 0000000000..471a1a3580 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLStatementDeadlineRunner.swift @@ -0,0 +1,94 @@ +// +// MySQLStatementDeadlineRunner.swift +// MySQLDriverPlugin +// +// The order a timed statement runs in. Every C call is injected, so TableProTests compiles it. +// + +import Foundation + +internal struct MySQLStatementFailure: Equatable { + internal let code: UInt32 + internal let message: String +} + +internal typealias MySQLDeadlineCancel = () -> Void + +/// Runs one statement under a client-side deadline and classifies how it ended. +/// +/// The connection supplies the parts that touch libmariadb: `expire` opens the second connection +/// and sends `KILL QUERY`, `flushInterrupt` runs a throwaway statement on the primary handle to +/// consume a kill the statement did not, and `killOrphan` stops a statement still running on the +/// server after the socket timeout gave up on it. Everything about the order they happen in lives +/// here, where a test can drive it. +internal struct MySQLStatementDeadlineRunner { + internal let deadline: MySQLStatementDeadline? + internal let flavor: MySQLServerFlavor + internal let socketTimeoutSeconds: UInt32 + internal let watch: MySQLStatementWatch + internal let now: () -> ContinuousClock.Instant + internal let schedule: (Duration, @escaping () -> Void) -> MySQLDeadlineCancel + internal let expire: (UInt64) -> Void + internal let flushInterrupt: () -> Void + internal let killOrphan: () -> Void + internal let failureDetail: (any Error) -> MySQLStatementFailure? + internal let deadlineExceeded: (Int) -> any Error + internal let markOutlasted: (any Error) -> any Error + + internal func run(_ sql: String, body: () throws -> T) throws -> T { + guard let deadline, deadline.applies(to: sql) else { return try runUntimed(body) } + + let startedAt = now() + let token = watch.begin() + let cancelSchedule = schedule(.seconds(deadline.seconds)) { expire(token) } + let outcome = Result(catching: body) + cancelSchedule() + let killWasSent = watch.end(token) + + switch outcome { + case .success(let value): + if killWasSent { flushInterrupt() } + return value + case .failure(let error): + let cause = cause(of: error, waited: now() - startedAt, deadlineExpired: killWasSent) + if cause == .deadlineExceeded { throw deadlineExceeded(deadline.seconds) } + if killWasSent { flushInterrupt() } + throw reported(error, cause: cause) + } + } + + /// A statement with no deadline still outlives the socket timeout, and on MySQL the engine's + /// own timeout never covered it: measured on 5.7.44 with `max_execution_time = 2000`, + /// `INSERT ... SELECT` ran for 170 seconds. + private func runUntimed(_ body: () throws -> T) throws -> T { + let startedAt = now() + do { + return try body() + } catch { + let cause = cause(of: error, waited: now() - startedAt, deadlineExpired: false) + throw reported(error, cause: cause) + } + } + + private func cause( + of error: any Error, + waited: Duration, + deadlineExpired: Bool + ) -> MySQLStatementFailureCause { + guard let detail = failureDetail(error) else { return .server } + return mysqlStatementFailureCause( + errno: detail.code, + message: detail.message, + flavor: flavor, + deadlineExpired: deadlineExpired, + waited: waited, + socketTimeoutSeconds: socketTimeoutSeconds + ) + } + + private func reported(_ error: any Error, cause: MySQLStatementFailureCause) -> any Error { + guard cause == .outlastedSocketTimeout else { return error } + killOrphan() + return markOutlasted(error) + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLStatementWatch.swift b/Plugins/MySQLDriverPlugin/MySQLStatementWatch.swift new file mode 100644 index 0000000000..2516dc71d9 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLStatementWatch.swift @@ -0,0 +1,59 @@ +// +// MySQLStatementWatch.swift +// MySQLDriverPlugin +// +// Which statement the client-side deadline is allowed to interrupt, and whether it did. +// + +import Foundation + +/// `KILL QUERY` sets a flag on the server's thread that the *next* statement to test it consumes, +/// so a kill that lands after the statement it was meant for has already finished interrupts an +/// innocent one instead. Measured on MySQL 5.5.62, 5.6.51 and MariaDB 5.5.64: after an idle +/// `KILL QUERY`, the following `SELECT COUNT(*) FROM information_schema.COLLATIONS a, b` and +/// `INSERT ... SELECT` both failed with `ERROR 1317` and the row was not inserted. +/// +/// Two things close that window. The interrupt runs under this lock and only while the token it +/// names is still the running statement, and `end` takes the same lock, so the statement cannot +/// finish, and therefore the next one on the serial queue cannot start, while a kill is in flight. +/// And `end` reports whether a kill was sent, so a caller whose statement did not end in an +/// interruption knows to consume the flag itself before releasing the queue. +internal final class MySQLStatementWatch: @unchecked Sendable { + private let lock = NSLock() + private var lastToken: UInt64 = 0 + private var runningToken: UInt64? + private var interruptedToken: UInt64? + + internal init() {} + + internal func begin() -> UInt64 { + lock.withLock { + lastToken += 1 + runningToken = lastToken + return lastToken + } + } + + /// Whether a kill was sent for this statement. Blocks while an interrupt for it is in flight. + internal func end(_ token: UInt64) -> Bool { + lock.withLock { + if runningToken == token { runningToken = nil } + guard interruptedToken == token else { return false } + interruptedToken = nil + return true + } + } + + internal func isRunning(_ token: UInt64) -> Bool { + lock.withLock { runningToken == token } + } + + /// Runs `interrupt` only while `token` is still the running statement, and records that a kill + /// was sent only when it reports one went out. + internal func expire(_ token: UInt64, interrupt: () -> Bool) { + lock.withLock { + guard runningToken == token, interrupt() else { return } + interruptedToken = token + } + } +} diff --git a/Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift b/Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift deleted file mode 100644 index 7c580e5409..0000000000 --- a/Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// TiDBCheckConstraints.swift -// MySQLDriverPlugin -// - -import Foundation -import TableProPluginKit - -internal enum TiDBCheckConstraints { - static func parse(createTable sql: String) -> [PluginCheckConstraintInfo] { - guard let body = MySQLCreateTableScanner.firstGroup(in: Substring(sql)) else { return [] } - return MySQLCreateTableScanner.topLevelElements(of: body).compactMap(checkConstraint(in:)) - } - - private static func checkConstraint(in element: Substring) -> PluginCheckConstraintInfo? { - var rest = element - guard MySQLCreateTableScanner.consume("CONSTRAINT", from: &rest), - let name = MySQLCreateTableScanner.consumeBacktickName(from: &rest), - MySQLCreateTableScanner.consume("CHECK", from: &rest) - else { return nil } - rest = rest.drop(while: \.isWhitespace) - guard rest.first == "(", let expression = MySQLCreateTableScanner.firstGroup(in: rest) else { return nil } - return PluginCheckConstraintInfo(name: name, expression: expression.trimmingCharacters(in: .whitespaces)) - } -} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift b/Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift index 2d655bf50e..dc6963e639 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQConnectionLoss.swift @@ -16,6 +16,26 @@ enum LibPQTransactionState: Sendable, Equatable { var mayHoldTransaction: Bool { self != .idle } + + /// What the app is told about the session, so nothing it owns opens, commits or rolls back a + /// transaction over one the user already has. + /// + /// `PQTRANS_INERROR` is its own answer rather than another open transaction: measured on + /// PostgreSQL 17.11, a `COMMIT` in that state answers with the command tag `ROLLBACK` and no + /// error, so telling the user to commit loses the work it was meant to keep. `PQTRANS_ACTIVE` + /// is a statement still in flight, which says nothing about the transaction around it. + var sessionTransactionState: PluginSessionTransactionState { + switch self { + case .idle: + return .idle + case .inTransaction: + return .inTransaction + case .inError: + return .abortedTransaction + case .active, .unknown: + return .unknown + } + } } enum LibPQServerMessage { diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index c849d30128..950717a1ff 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -219,6 +219,14 @@ final class LibPQDriverCore: @unchecked Sendable { _ = try await execute(query: "SET statement_timeout = \(ms)") } + /// A connection that has gone away answers `.unknown` rather than `.idle`: a caller that reads + /// "nothing open" opens a transaction of its own, and this is the one answer that must never be + /// a guess. + func sessionTransactionState() async -> PluginSessionTransactionState { + guard let pqConn = libpqConnection else { return .unknown } + return await pqConn.transactionState().sessionTransactionState + } + private func connection() throws -> LibPQPluginConnection { guard let pqConn = libpqConnection else { throw LibPQPluginError.notConnected @@ -308,6 +316,10 @@ extension LibPQBackedDriver { try await core.applyQueryTimeout(seconds) } + func sessionTransactionState() async -> PluginSessionTransactionState { + await core.sessionTransactionState() + } + func switchSchema(to schema: String) async throws { try await core.applySchema(schema) } diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift index 565e9f27dd..b9e9a695ec 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQPluginConnection.swift @@ -443,9 +443,29 @@ final class LibPQPluginConnection: @unchecked Sendable { /// aborted, so a statement sent now joins it. Called on the connection's queue only: one `PGconn` /// may not be used from two threads at once, and the lock guards the pointer rather than the call. private func isInsideTransactionBlockOnQueue() -> Bool { - guard let conn = connectionHandle else { return false } - let status = PQtransactionStatus(conn) - return status == PQTRANS_INTRANS || status == PQTRANS_INERROR + let state = transactionStateOnQueue() + return state == .inTransaction || state == .inError + } + + /// What the session has open, from the `ReadyForQuery` status libpq keeps from the last reply. + /// + /// No round trip and no server work: measured against PostgreSQL 17.11, a million calls took + /// 1.5ms, and the answer is local enough to survive a backend another session terminated + /// (`PQtransactionStatus` still reported `INTRANS` with `PQstatus` OK). + func transactionState() async -> LibPQTransactionState { + do { + return try await pluginDispatchAsync(on: queue) { [self] in + guard !isShuttingDown else { return LibPQTransactionState.unknown } + return transactionStateOnQueue() + } + } catch { + return .unknown + } + } + + private func transactionStateOnQueue() -> LibPQTransactionState { + guard let conn = connectionHandle, PQstatus(conn) == CONNECTION_OK else { return .unknown } + return Self.transactionState(PQtransactionStatus(conn)) } func boundedQuery(_ query: String, rowCap: Int) async throws -> LibPQPluginQueryResult { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift index b2ecc47f39..ddf51f6bc7 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift @@ -1,6 +1,6 @@ import Foundation import TableProPluginKit -internal func postgresBeginTransactionStatement(mode: PluginTransactionAccessMode) -> String { +nonisolated internal func postgresBeginTransactionStatement(mode: PluginTransactionAccessMode) -> String { mode == .readWrite ? "BEGIN READ WRITE" : "BEGIN" } diff --git a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift index 8fae3c975f..231d0164ef 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift @@ -193,7 +193,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { if let type { args += ["TYPE", type] } let connection = try await connection(to: node.address) - let reply = try await connection.executeCommand(args).throwIfError() + let reply = try await connection.executeCommand(args).throwIfError().throwIfQueued("SCAN") let page = RedisScanReply.parse(reply) let next = RedisClusterCursor.advance( after: node.id, diff --git a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift index e800fc17e2..09870ba8c8 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift @@ -64,20 +64,24 @@ extension RedisCommandChannel { func verifyStillPrimary() async throws {} - /// Runs a command and turns a server error reply into a thrown error. + /// Runs a command and turns anything that is not its own answer into a thrown error. /// /// hiredis hands `-READONLY`, `-WRONGTYPE`, `-NOPERM` and the rest back as ordinary replies, - /// so a caller that ignores the reply reports success for a command the server refused. Every - /// command site goes through here rather than reading the reply straight. + /// so a caller that ignores the reply reports success for a command the server refused. A + /// `+QUEUED` is the second such reply: it acknowledges an open `MULTI` block rather than + /// answering, and reading a value out of it gave the sidebar a key count of zero and the grid + /// "QUEUED" as a stored value. Every command site goes through here rather than reading the + /// reply straight. @discardableResult func run(_ args: [String]) async throws -> RedisReply { - try await executeCommand(args).throwIfError(args.first ?? "") + let name = args.first ?? "" + return try await executeCommand(args).throwIfError(name).throwIfQueued(name) } @discardableResult func run(_ args: [Data]) async throws -> RedisReply { let name = args.first.flatMap { String(data: $0, encoding: .utf8) } ?? "" - return try await executeCommand(args).throwIfError(name) + return try await executeCommand(args).throwIfError(name).throwIfQueued(name) } /// The single-node walk. A cluster channel replaces this with one that visits every master. @@ -87,7 +91,7 @@ extension RedisCommandChannel { args += ["COUNT", String(count)] if let type { args += ["TYPE", type] } - let reply = try await executeCommand(args).throwIfError() + let reply = try await executeCommand(args).throwIfError().throwIfQueued("SCAN") let page = RedisScanReply.parse(reply) return RedisKeyspacePage(cursor: page.cursor, keys: page.keys, isIncomplete: false) } diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index c04d9ac115..03a339d653 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -68,6 +68,7 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { private var _isShuttingDown: Bool = false private var _cachedServerVersion: String? private var _currentDatabase: Int + private var _queuedDatabase = RedisQueuedDatabase() var isConnected: Bool { stateLock.lock() @@ -167,6 +168,7 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { _isConnected = false _cachedServerVersion = nil _currentDatabase = database + _queuedDatabase.clear() stateLock.unlock() #if canImport(CRedis) @@ -267,6 +269,10 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { // MARK: - Database Selection + /// A `SELECT` the server queued into an open `MULTI` block has not moved the session, so the + /// index is held aside until the block resolves rather than recorded now. Recording it now is + /// right only if `EXEC` follows: after a `DISCARD` the session is still on the old database, + /// and a `FLUSHDB` staged against the row the app believed it was on would empty that one. func selectDatabase(_ index: Int) async throws { #if canImport(CRedis) try await pluginDispatchAsync(on: queue) { [self] in @@ -286,7 +292,12 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { throw RedisPluginError(code: 2, message: "SELECT \(index) failed: \(msg)") } stateLock.lock() - _currentDatabase = index + if reply.isQueued { + _queuedDatabase.queue(index) + } else { + _queuedDatabase.clear() + _currentDatabase = index + } stateLock.unlock() } #else @@ -453,6 +464,7 @@ private extension RedisPluginConnection { context = nil sslContext = nil _isConnected = false + _queuedDatabase.clear() stateLock.unlock() if let handle { redisFree(handle) } if let ssl { redisFreeSSLContext(ssl) } @@ -485,6 +497,12 @@ private extension RedisPluginConnection { /// which side it happened on. An incomplete RESP command is never executed, so a failed write /// is always replayable; once the command is on the wire only a read-only command is. func executeCommandSyncRetrying(_ args: [Data]) throws -> RedisReply { + let reply = try sendAllowingReplay(args) + resolveQueuedDatabase(command: args.first, reply: reply) + return reply + } + + private func sendAllowingReplay(_ args: [Data]) throws -> RedisReply { do { return try executeCommandSync(args) } catch let failure as RedisTransportFailure where !isShuttingDown && canReplay(args, after: failure) { @@ -493,6 +511,18 @@ private extension RedisPluginConnection { } } + /// The block a queued `SELECT` was held in has resolved, so the session's database follows the + /// server's own answer. `reconnectSync` frees the context, which drops any pending index, so a + /// replay never promotes one the lost session had queued. + private func resolveQueuedDatabase(command: Data?, reply: RedisReply) { + let name = command.flatMap { String(data: $0, encoding: .utf8) } + stateLock.lock() + if let selected = _queuedDatabase.resolve(command: name, reply: reply) { + _currentDatabase = selected + } + stateLock.unlock() + } + /// A pipeline puts several commands in one buffer, so a read failure part-way through cannot /// say which of them ran. Replaying is only safe when none of them writes. func executePipelineSyncRetrying(_ commands: [[Data]]) throws -> [RedisReply] { @@ -786,6 +816,5 @@ private extension RedisPluginConnection { } return nil } - } #endif diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 5802dbaf41..ef5d65c38e 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -8,10 +8,31 @@ import OSLog import TableProPluginKit extension RedisPluginDriver { + /// The one door into an operation, so the paged read and the streamed read cannot disagree about + /// a command the server queued. + /// + /// The translation lives here rather than at the call sites because it was missing from one of + /// them: a command routed through `executeBoundedQuery` threw the queued error instead of + /// answering `QUEUED`, and nothing recorded it, so `EXEC`'s replies paired with the commands one + /// position out. func executeOperation( _ operation: RedisOperation, connection conn: any RedisCommandChannel, startTime: Date + ) async throws -> PluginQueryResult { + do { + return try await runOperation(operation, connection: conn, startTime: startTime) + } catch let queued as RedisQueuedCommand { + guard operation.queuedCommandAnswer == .reportQueued else { throw queued } + recordQueued(queued.command) + return buildStatusResult(Self.queuedStatus, startTime: startTime) + } + } + + private func runOperation( + _ operation: RedisOperation, + connection conn: any RedisCommandChannel, + startTime: Date ) async throws -> PluginQueryResult { switch operation { case .get, .set, .del, .keys, .scan, .type, .ttl, .pttl, .expire, .persist, .rename, .exists: diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift index e4de7af900..bfee5a2b52 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift @@ -255,9 +255,13 @@ extension RedisPluginDriver { } } + /// An error element is marked the way `redis-cli` marks one, because `EXEC` answers with the + /// failures of the block inline among its values: an unmarked `WRONGTYPE Operation against a + /// key holding the wrong kind of value` in a result row reads as a stored string. func redisReplyToString(_ reply: RedisReply) -> String { switch reply { - case .string(let s), .status(let s), .error(let s): return s + case .string(let s), .status(let s): return s + case .error(let message): return "(error) \(message)" case .integer(let i): return String(i) case .data(let d): return String(data: d, encoding: .utf8) ?? d.base64EncodedString() case .array(let items): return "[\(items.map { redisReplyToString($0) }.joined(separator: ", "))]" diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index d57cb43bdc..9a21020572 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -36,6 +36,16 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { static let maxKeyBrowseScan = 10_000 + /// The commands the app queued into the block it opened, in the order it queued them, so + /// ``commitTransaction`` can name the ones `EXEC` reports as failed. A user is free to type + /// their own `MULTI` on the same session, so the list is a best effort that + /// ``RedisTransactionOutcome`` falls back from rather than a promise, and it is bounded because + /// nothing but a user's own typing decides how long a block gets. + private static let maxRecordedQueuedCommands = 10_000 + + private let queuedCommandsLock = NSLock() + private var queuedCommands: [String] = [] + var serverVersion: String? { redisConnection?.serverVersion() } @@ -191,6 +201,30 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return try await executeOperation(operation, connection: conn, startTime: startTime) } + /// `+QUEUED` is the honest answer for a command the user sent into an open block, so it is the + /// result rather than an error: the block is theirs to end, and `EXEC` will report every reply. + static let queuedStatus = "QUEUED" + + func recordQueued(_ command: String) { + queuedCommandsLock.lock() + if queuedCommands.count < Self.maxRecordedQueuedCommands { queuedCommands.append(command) } + queuedCommandsLock.unlock() + } + + private func takeQueuedCommands() -> [String] { + queuedCommandsLock.lock() + defer { queuedCommandsLock.unlock() } + let recorded = queuedCommands + queuedCommands = [] + return recorded + } + + private func clearQueuedCommands() { + queuedCommandsLock.lock() + queuedCommands = [] + queuedCommandsLock.unlock() + } + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { try await execute(query: query) } @@ -371,18 +405,34 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { redisConnection?.supportsTransactions ?? true } + /// `MULTI` does not open a transaction so much as start queueing: every command after it + /// answers `+QUEUED` in place of its own reply and nothing runs until `EXEC`. So the app's + /// statements are recorded as they are queued, and `EXEC`'s own reply is what says whether each + /// of them ran. + /// + /// The block is still worth opening for a write the app generated, because a command the server + /// refuses at queue time aborts the whole block instead of leaving half of it applied. Measured + /// on Redis 8.10.1: an ACL user without `+expire` running `MULTI; SET b 1; EXPIRE b 10; EXEC` + /// leaves `EXISTS b` at 0, where the same two commands sent unwrapped leave the `SET` applied. func beginTransaction() async throws { guard let conn = redisConnection else { throw RedisPluginError.notConnected } + clearQueuedCommands() try await conn.run(["MULTI"]) } func commitTransaction() async throws { guard let conn = redisConnection else { throw RedisPluginError.notConnected } - try await conn.run(["EXEC"]) + let queued = takeQueuedCommands() + let reply = try await conn.run(["EXEC"]) + let failed = RedisTransactionOutcome.failures(inExecReply: reply, queuedCommands: queued) + guard failed.isEmpty else { throw RedisTransactionError(failed: failed) } } + /// `DISCARD` drops a block nothing has applied yet, which is the whole of what Redis can take + /// back. A block `EXEC` already ran is gone, and the failure `commitTransaction` raises says so. func rollbackTransaction() async throws { guard let conn = redisConnection else { throw RedisPluginError.notConnected } + clearQueuedCommands() try await conn.run(["DISCARD"]) } diff --git a/Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift b/Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift new file mode 100644 index 0000000000..eb465696d2 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift @@ -0,0 +1,34 @@ +// +// RedisQueuedCommandPolicy.swift +// RedisDriverPlugin +// +// What an operation answers when the server queued it into an open MULTI block instead of running +// it. Pure, so TableProTests can exercise it without loading the plugin bundle. +// + +import Foundation + +/// What the driver hands back for a command the server answered `+QUEUED` to. +enum RedisQueuedCommandAnswer: Equatable { + /// The acknowledgement is the honest answer: the block is the user's to end, and `EXEC` will + /// report every reply. The command is recorded so `EXEC`'s reply can be paired with it by + /// position. + case reportQueued + + /// A keyspace walk the app built for a grid. Its reply has to say the keyspace could not be + /// read, because a one-row `QUEUED` status in the data grid reads as an empty table. + case refuse +} + +extension RedisOperation { + /// `KEYBROWSE` and `KEYTREE` are the operations the app pages through `execute(query:)` and + /// streams through `streamRows(query:)`, so answering them differently on the two routes would + /// make browsing disagree with itself. Every other command is the user's own and gets the + /// acknowledgement the server gave it. + var queuedCommandAnswer: RedisQueuedCommandAnswer { + switch self { + case .keyBrowse, .keyTree: return .refuse + default: return .reportQueued + } + } +} diff --git a/Plugins/RedisDriverPlugin/RedisQueuedDatabase.swift b/Plugins/RedisDriverPlugin/RedisQueuedDatabase.swift new file mode 100644 index 0000000000..ebf0bf3e9b --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisQueuedDatabase.swift @@ -0,0 +1,57 @@ +// +// RedisQueuedDatabase.swift +// RedisDriverPlugin +// +// Which database the session is on after a SELECT that a MULTI block queued. +// +// A `SELECT` inside a block answers `+QUEUED` and takes effect only when `EXEC` runs. Measured on +// Redis 8.10.1: `MULTI; SELECT 2; EXEC` then `CLIENT INFO` reports `db=2`, while `MULTI; SELECT 2; +// DISCARD` and `MULTI; SELECT 3; RESET` both leave the session on `db=0`. So recording the index +// at queue time is right after `EXEC` and wrong after either of the other two, and recording +// nothing is wrong after `EXEC`. +// +// The index has to follow the server, because it is what the `FLUSHDB` guard compares a row +// against and what a reconnect re-selects. The queued index is therefore held aside until the +// block resolves. +// + +import Foundation + +struct RedisQueuedDatabase: Equatable, Sendable { + private(set) var pending: Int? + + mutating func queue(_ index: Int) { + pending = index + } + + mutating func clear() { + pending = nil + } + + /// The index the session moved to, when the block carrying a queued `SELECT` applied. + /// + /// `EXEC` answers an array for a block it ran, an error for one it refused, and a nil reply for + /// one whose `WATCH` was broken. `DISCARD` and `RESET` end the block without running it. A + /// nested `MULTI` is refused and leaves the block open, so only one that succeeded clears. + mutating func resolve(command: String?, reply: RedisReply) -> Int? { + guard let name = command?.uppercased() else { return nil } + switch name { + case "EXEC": + guard case .array = reply, let index = pending else { + pending = nil + return nil + } + pending = nil + return index + case "DISCARD", "RESET": + pending = nil + return nil + case "MULTI": + guard !reply.isError else { return nil } + pending = nil + return nil + default: + return nil + } + } +} diff --git a/Plugins/RedisDriverPlugin/RedisReply.swift b/Plugins/RedisDriverPlugin/RedisReply.swift index 97ceb482ac..46ab19ec4d 100644 --- a/Plugins/RedisDriverPlugin/RedisReply.swift +++ b/Plugins/RedisDriverPlugin/RedisReply.swift @@ -57,6 +57,29 @@ enum RedisReply { return message } + /// A `+QUEUED` simple string, which is what Redis answers for every command it holds in an open + /// `MULTI` block instead of that command's own reply. + /// + /// The reply *shape* is the signal, not the text: measured over raw RESP on Redis 8.10.1, a + /// queued command answers `+QUEUED\r\n` while a `GET` of a key holding the word arrives as the + /// bulk string `$6\r\nQUEUED`. A command can also answer `+QUEUED` outside any block (`EVAL + /// "return redis.status_reply('QUEUED')" 0`, measured, byte for byte the same), which nothing + /// in the driver sends. + var isQueued: Bool { + guard case .status(let value) = self else { return false } + return value == "QUEUED" + } + + /// A queued reply is the block's acknowledgement, never the command's answer, so every caller + /// that reads a value out of one reads the acknowledgement instead: `GET` returned "QUEUED" as + /// the stored value, `DEL` counted zero deletions, `LPUSH` reported length zero and `DBSIZE` + /// reported an empty keyspace. + @discardableResult + func throwIfQueued(_ command: @autoclosure () -> String) throws -> RedisReply { + guard isQueued else { return self } + throw RedisQueuedCommand(command: command()) + } + /// hiredis hands a server error back as an ordinary reply with `ctx->err == 0`, so nothing /// throws unless a caller looks. Every path that acts on a reply has to call this or it will /// report success for a command the server refused. @@ -99,6 +122,24 @@ extension RedisPluginError: PluginDriverError { var pluginErrorDetail: String? { detail } } +/// A command the server queued instead of running, because a `MULTI` block is open on the session. +struct RedisQueuedCommand: Error, Equatable { + let command: String +} + +extension RedisQueuedCommand: PluginDriverError { + var pluginErrorMessage: String { + String( + format: String(localized: "Redis queued %@ instead of running it."), + command.isEmpty ? String(localized: "the command") : command + ) + } + + var pluginErrorDetail: String? { + String(localized: "A MULTI block is open on this connection. Run EXEC to apply it, or DISCARD to drop it.") + } +} + /// A connection-level failure that records which side of the exchange it happened on. /// /// hiredis reports a read timeout with the same REDIS_ERR_IO it uses for a failed write, so the diff --git a/Plugins/RedisDriverPlugin/RedisTransactionOutcome.swift b/Plugins/RedisDriverPlugin/RedisTransactionOutcome.swift new file mode 100644 index 0000000000..cc065325d8 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisTransactionOutcome.swift @@ -0,0 +1,78 @@ +// +// RedisTransactionOutcome.swift +// RedisDriverPlugin +// +// What EXEC's reply says about the block it just applied. +// +// EXEC answers one element per queued command and puts a command's failure in that element, so a +// caller that reads only the top level reports success for a block half of which the server +// refused. Measured on Redis 8.10.1: `MULTI; GET s; LPUSH s x; SET t 1; DEL nokey; INCR s; EXEC` +// answers an array of five holding `-WRONGTYPE` and `-ERR value is not an integer`, `GET t` then +// answers 1, and nothing was rolled back. The grid-save shape is the same: a `RENAME` of a missing +// key followed by a `SET` answered `+OK` at the top level while the `SET` was applied. +// +// A queue-time refusal is a different reply and needs no pairing: it arrives as a top-level +// `-EXECABORT` and applies nothing, which `throwIfError` already raises. Measured for an ACL user +// without `+expire` (`-NOPERM` then `-EXECABORT`, `EXISTS b` 0) and under `maxmemory 1` (`-OOM` +// then `-EXECABORT`, the renamed key untouched). +// + +import Foundation +import TableProPluginKit + +/// One command the block ran and the server refused, labelled with the command the app queued at +/// that position. +struct RedisFailedCommand: Equatable, Sendable { + let label: String + let message: String +} + +struct RedisTransactionError: Error, Equatable { + let failed: [RedisFailedCommand] +} + +extension RedisTransactionError: PluginDriverError { + var pluginErrorMessage: String { + if failed.count == 1, let only = failed.first { + return String( + format: String(localized: "%1$@ failed inside the Redis transaction: %2$@"), + only.label, only.message + ) + } + return String( + format: String(localized: "%1$lld commands failed inside the Redis transaction: %2$@"), + failed.count, + failed.map { "\($0.label): \($0.message)" }.joined(separator: ", ") + ) + } + + var pluginErrorDetail: String? { + String(localized: "EXEC ran the other commands in the block, and Redis cannot roll them back.") + } +} + +enum RedisTransactionOutcome { + /// Pairs EXEC's reply array with the commands queued into the block, in order. + /// + /// A reply that is not an array is a block that never ran: `-EXECABORT` for a queue-time + /// refusal, `-ERR EXEC without MULTI` for one `RESET` already ended, and a nil reply for one + /// whose `WATCH` was broken. None of them applied anything, so none of them names a failure + /// here. + static func failures(inExecReply reply: RedisReply, queuedCommands: [String]) -> [RedisFailedCommand] { + guard case .array(let elements) = reply else { return [] } + return elements.enumerated().compactMap { index, element in + guard let message = element.errorMessage else { return nil } + return RedisFailedCommand(label: label(at: index, in: queuedCommands), message: message) + } + } + + /// The block can hold commands the driver never saw queued, because a user is free to type + /// their own `MULTI` on the same session, so a position with no recorded command is named by + /// its position rather than dropped. + private static func label(at index: Int, in queuedCommands: [String]) -> String { + guard index < queuedCommands.count, !queuedCommands[index].isEmpty else { + return String(format: String(localized: "Command %lld"), index + 1) + } + return queuedCommands[index] + } +} diff --git a/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift b/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift index 5a72f3e7e3..a0b321d743 100644 --- a/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift +++ b/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift @@ -33,6 +33,11 @@ protocol SQLiteExecutionBackend: Actor { nonisolated func abortConnect() func applyBusyTimeout(_ milliseconds: Int32) async + + /// What the session has open, so nothing the app owns opens a transaction over the user's. + /// A backend that cannot ask keeps the `.unknown` default. + func sessionTransactionState() async -> PluginSessionTransactionState + func executeQuery(_ query: String) async throws -> SQLiteRawResult func executeParameterizedQuery(_ query: String, parameters: [PluginCellValue]) async throws -> SQLiteRawResult func streamQuery( @@ -50,6 +55,8 @@ protocol SQLiteCanceller: Sendable { extension SQLiteExecutionBackend { nonisolated func abortConnect() {} + + func sessionTransactionState() async -> PluginSessionTransactionState { .unknown } } struct SQLiteRawResult: Sendable { @@ -226,6 +233,15 @@ actor SQLiteLocalBackend: SQLiteExecutionBackend { busyState.setTimeout(milliseconds: milliseconds) } + /// `sqlite3_get_autocommit` is SQLite's own answer and costs no statement. Measured against + /// SQLite 3.54.0: it reports 0 from a `BEGIN` until the matching `COMMIT` or `ROLLBACK`, and + /// from a bare `SAVEPOINT`, which opens a transaction too. SQLite has no aborted state, since + /// a failed statement leaves the transaction usable. + func sessionTransactionState() -> PluginSessionTransactionState { + guard let db else { return .unknown } + return sqlite3_get_autocommit(db) == 0 ? .inTransaction : .idle + } + private func installBusyHandler() { guard let db else { return } sqlite3_busy_handler(db, sqliteBusyHandler, Unmanaged.passUnretained(busyState).toOpaque()) diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 2611836473..d43b26e839 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -127,6 +127,10 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsSchemas: Bool { false } var supportsTransactions: Bool { true } + func sessionTransactionState() async -> PluginSessionTransactionState { + await backend.sessionTransactionState() + } + var capabilities: PluginCapabilities { [ .parameterizedQueries, diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index be60feb545..82d57da4a6 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -173,6 +173,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func commitTransaction() async throws func rollbackTransaction() async throws + /// What the session is holding, so a caller does not open, commit or roll back a transaction + /// over one the user already has open. A driver that cannot ask keeps the `.unknown` default. + func sessionTransactionState() async -> PluginSessionTransactionState + func cancelQuery() throws func applyQueryTimeout(_ seconds: Int) async throws @@ -341,6 +345,15 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { var unsupportedIndexTypes: Set { get } func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? + /// Why the connected server has no check constraints to list or edit, or nil when it has. + /// + /// The engine's capability flags describe its newest release; this describes the server in + /// front of the user. MySQL before 8.0.16 and MariaDB before 10.2.1 accept + /// `ADD CONSTRAINT ... CHECK` and discard the clause, so a driver that can tell says so here + /// and the Constraints tab is not offered. A driver that cannot tell, or one connected to a + /// server whose version it has not read, returns nil. + var checkConstraintRefusal: String? { get } + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? // Definition SQL for clipboard copy (optional — return nil if not supported) @@ -624,6 +637,8 @@ public extension PluginDatabaseDriver { _ = try await execute(query: "ROLLBACK") } + func sessionTransactionState() async -> PluginSessionTransactionState { .unknown } + func cancelQuery() throws {} func applyQueryTimeout(_ seconds: Int) async throws {} @@ -891,6 +906,7 @@ public extension PluginDatabaseDriver { var unsupportedStructureColumnFields: Set { [] } var unsupportedIndexTypes: Set { [] } func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { nil } + var checkConstraintRefusal: String? { nil } func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { nil } diff --git a/Plugins/TableProPluginKit/PluginSessionTransactionState.swift b/Plugins/TableProPluginKit/PluginSessionTransactionState.swift new file mode 100644 index 0000000000..6d7b9792eb --- /dev/null +++ b/Plugins/TableProPluginKit/PluginSessionTransactionState.swift @@ -0,0 +1,43 @@ +// +// PluginSessionTransactionState.swift +// TableProPluginKit +// + +import Foundation + +/// What the session a driver is holding has open right now, for a caller deciding whether it may +/// open, commit or roll back a transaction of its own on it. +/// +/// The app runs the editor's statements on the connection's shared session, so a `BEGIN` typed into +/// the editor, a `SET autocommit = 0`, a `LOCK TABLES` or an MCP client's `begin` is still in force +/// when the next multi-statement run arrives. Measured across eight engines, an app-owned +/// `BEGIN`/`COMMIT`/`ROLLBACK` sent over one of those either commits the user's work +/// (MySQL, MariaDB, TiDB, PostgreSQL), discards it (PostgreSQL, SQL Server) or aborts the whole +/// transaction (DuckDB, CockroachDB). This is the question that stops it. +/// +/// Every ambiguity resolves toward `.unknown`, which means "decide as if you had not asked": a +/// driver that cannot read its session's state must not report `.idle`, because that is the one +/// answer that lets a caller open a transaction over the user's. +public enum PluginSessionTransactionState: Sendable, Equatable { + /// No transaction is open and the session holds nothing that opening one would disturb. The + /// session's commit mode may still be manual; nothing is pending either way, so a caller's own + /// transaction commits only its own statements. + case idle + + /// A transaction is open. Committing or rolling it back belongs to whoever opened it. + case inTransaction + + /// A transaction is open and the engine will accept nothing but a rollback: PostgreSQL's + /// `PQTRANS_INERROR`, DuckDB's `DUCKDB_ERROR_TRANSACTION`, SQL Server's `XACT_STATE() = -1`. + /// Measured on PostgreSQL 17.11, a `COMMIT` here answers with the command tag `ROLLBACK` and no + /// error, so a caller that tells the user to commit loses their work while reporting success. + case abortedTransaction + + /// No transaction, but the session holds a lock that opening one would release. Measured on + /// MySQL 5.5.62, 8.4.11, MariaDB 5.5.64 and 11.4.13: a `START TRANSACTION` releases the tables + /// a `LOCK TABLES` held, and the status flags never show the lock. + case holdsSessionLocks + + /// The driver cannot tell. Either it has no way to ask, or the read failed. + case unknown +} diff --git a/TablePro/Core/Concurrency/TaskCancellationShield.swift b/TablePro/Core/Concurrency/TaskCancellationShield.swift new file mode 100644 index 0000000000..31379315bb --- /dev/null +++ b/TablePro/Core/Concurrency/TaskCancellationShield.swift @@ -0,0 +1,27 @@ +// +// TaskCancellationShield.swift +// TablePro +// + +import Foundation + +/// Runs work that has to finish whatever happens to the task awaiting it. +/// +/// `Task.cancel()` reaches every child of a structured task, and a driver that installs a +/// `withTaskCancellationHandler` acts on it at once: measured in a swiftc probe, a `ROLLBACK` +/// issued inside an already-cancelled task fired its cancel handler before it was ever sent +/// (`["cancel request for ROLLBACK", "ROLLBACK sent", "ROLLBACK done"]` against the shielded +/// `["ROLLBACK sent", "ROLLBACK done"]`). An unstructured `Task` is not a child, so cancellation +/// stops at this boundary while priority and task-locals still carry through. +/// +/// The standard library's `withTaskCancellationShield(operation:)` is macOS 27 and the deployment +/// target is 13, measured: "'withTaskCancellationShield(operation:)' is only available in macOS +/// 27.0 or newer". +/// +/// Only a single `COMMIT` or `ROLLBACK` goes inside one of these. A statement loop in here would be +/// unstoppable. +internal enum TaskCancellationShield { + internal static func run(_ work: @escaping @Sendable () async throws -> T) async throws -> T { + try await Task { try await work() }.value + } +} diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 1cd6f3e3f6..dac34baee2 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -150,19 +150,11 @@ final class PaginationCoordinator: ObservableObject { // MARK: - Cancel Current Query + /// Stop and `Cmd+.` act on the tab the user is looking at. A window-wide stop is what let one + /// tab's Stop roll back the batch another tab was running. func cancelCurrentQuery() { - parent.cancelInFlightQueryTask() - parent.cancelAllRowCountTasks() - parent.releaseAllExactCounts() - parent.reportEndedExecutions(parent.tabExecution.invalidateAll(reason: .cancelledByUser)) - for idx in parent.tabManager.tabs.indices where parent.tabManager.tabs[idx].pagination.isBusy { - parent.tabManager.mutate(at: idx) { tab in - tab.pagination.isLoadingMore = false - tab.pagination.isCountingExact = false - tab.pagination.isCountPending = false - tab.pagination.isLoading = false - } - } + guard let tabId = parent.tabManager.selectedTabId else { return } + parent.stopExecution(for: tabId) } // MARK: - Exact Row Count @@ -288,7 +280,7 @@ final class PaginationCoordinator: ObservableObject { /// The rows belong to the result the fetch was started on. A result switch leaves the content /// epoch alone, so the fetch is fenced on the result set as well, or the full row set lands on /// whichever result is showing when it arrives, normalized to that result's column count. - private func performFetchAll(tabId: UUID, baseQuery: String, scope: DatabaseScope) { + internal func performFetchAll(tabId: UUID, baseQuery: String, scope: DatabaseScope) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } guard !parent.tabManager.tabs[idx].pagination.isLoadingMore else { return } @@ -303,17 +295,29 @@ final class PaginationCoordinator: ObservableObject { /// would discard its own rows. It registers as unclaimed work instead, which is what keeps /// the titlebar reporting it, and releases that on every exit including cancellation. let workToken = parent.tabExecution.beginUnclaimedWork(for: tabId) + let owner = TabQueryTaskOwner.unclaimedWork(tabId: tabId, token: workToken) + let lease = DriverLeaseOwner() let isTableTab = parent.tabManager.tabs[idx].tabType == .table let startedAt = ContinuousClock.Instant.now + /// Both releases belong to the whole task rather than to its exits: the cancelled path used + /// to clear the loading flag and bare return, leaving a finished fetch installed under the + /// tab's id, and the next `installQueryTask` read it as a live displaced entry and ended it. let fetchAllTask = Task { [weak self, parent] in - defer { parent.tabExecution.endUnclaimedWork(workToken, for: tabId) } + defer { + parent.tabExecution.endUnclaimedWork(workToken, for: tabId) + parent.retireQueryTask(owner) + } guard let self, !parent.isTearingDown else { return } do { let start = CFAbsoluteTimeGetCurrent() progressLog.info("[fetchAll] executing full query: \(baseQuery.prefix(100), privacy: .private)") - let result = try await parent.withExecutionDriver(scope: scope, isTableTab: isTableTab) { driver in + let result = try await parent.withExecutionDriver( + scope: scope, + isTableTab: isTableTab, + lease: lease + ) { driver in try await driver.executeUserQuery( query: baseQuery, rowCap: nil, @@ -344,13 +348,9 @@ final class PaginationCoordinator: ObservableObject { .contains { $0.id == tabId && $0.display.activeResultSetId == resultSetId } guard parent.tabExecution.isSameContent(contentEpoch, for: tabId), stillSameResult else { parent.tabManager.mutate(tabId: tabId) { $0.pagination.isLoadingMore = false } - parent.retireQueryTask(for: nil) - return - } - guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { - parent.retireQueryTask(for: nil) return } + guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } let replaceDelta = parent.mutateActiveTableRows(for: tabId) { rows in rows.replace(rows: result.rows) @@ -362,7 +362,6 @@ final class PaginationCoordinator: ObservableObject { tab.display.activeResultSet?.isTruncated = false } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(replaceDelta) - parent.retireQueryTask(for: nil) parent.toolbarState.recordQueryTiming(result.resolvedTiming, for: tabId) let totalTime = CFAbsoluteTimeGetCurrent() - start @@ -385,7 +384,6 @@ final class PaginationCoordinator: ObservableObject { guard !isStale, !isCancelled else { return } tab.execution.errorMessage = DatabaseWriteRejectionDiagnosis.formatted(error) } - parent.retireQueryTask(for: nil) MainContentCoordinator.logger.error("Fetch all failed: \(error.publicLogShape, privacy: .public)") guard !isStale, !isCancelled else { return } parent.reportOperation( @@ -398,6 +396,6 @@ final class PaginationCoordinator: ObservableObject { } } } - parent.installQueryTask(fetchAllTask, for: nil) + parent.installQueryTask(fetchAllTask, owner: owner, lease: lease) } } diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index be0aa3e3ca..a5fd2a1b64 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -85,12 +85,16 @@ extension QueryExecutionCoordinator { ) } + /// `unresolvedOutcome` is what a statement whose commit went unanswered carries. The statement + /// itself succeeded, so its rows and its timing are real, but whether the server kept it is not + /// something anything here can find out, and a plain success badge would say it did. func recordStatementHistory( sql: String, result: QueryResult, connection: DatabaseConnection, databaseName: String, - parameterValues: [QueryParameter]? = nil + parameterValues: [QueryParameter]? = nil, + unresolvedOutcome: String? = nil ) { let historySQL = sql.hasSuffix(";") ? sql : sql + ";" recordHistory( @@ -102,34 +106,29 @@ extension QueryExecutionCoordinator { source: .editor, executionTime: result.executionTime, rowCount: result.rows.count, - wasSuccessful: true, + wasSuccessful: unresolvedOutcome == nil, + errorMessage: unresolvedOutcome, timing: result.resolvedTiming ) ) } - func applyMultiStatementResults( + /// The settle gate, the task retirement, the history and the outcome notification belong to the + /// caller: a stopped run has already settled its claim and reports a cancellation rather than a + /// success, and still shows the results of the statements its plan could not take back. + /// + /// `sessionNotice` is the one thing a successful run may still have to say: a batch that joined + /// a transaction the user already had open committed nothing, and nothing else in the window + /// reports an open transaction. + func presentMultiStatementResults( tabId: UUID, - claim: TabExecutionClaim, timing: PluginQueryTiming, totalRowsAffected: Int, - newResultSets: [ResultSet] + newResultSets: [ResultSet], + sessionNotice: String? ) { let cumulativeTime = timing.total - guard parent.tabExecution.settle(claim) else { return } - parent.retireQueryTask(for: claim) - parent.toolbarState.recordQueryTiming(timing, for: claim.tabId) - - /// Once for the batch, never once per statement, and below the settle gate rather than at - /// the call site: a superseded batch has its results dropped here, and a notification - /// raised outside this guard would announce a result the user will never be shown. - reportOperation( - kind: .queryBatch, - claim: claim, - outcome: .succeeded( - OperationSummary(rowsAffected: totalRowsAffected, statementCount: newResultSets.count) - ) - ) + parent.toolbarState.recordQueryTiming(timing, for: tabId) guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return @@ -157,6 +156,7 @@ extension QueryExecutionCoordinator { tab.execution.rowsAffected = totalRowsAffected tab.execution.lastExecutedAt = Date() tab.execution.errorMessage = nil + tab.execution.statusMessage = sessionNotice tab.display.replaceUnpinnedResults(with: newResultSets) if tab.display.isResultsCollapsed { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index 171a67a1d4..e326f663ee 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -17,6 +17,17 @@ private struct BoundParameterValues: @unchecked Sendable { let values: [Any?] } +/// What one multi-statement run left behind, and the plan it actually ran under. +/// +/// The plan is decided twice: from the statement text before any driver is leased, and again inside +/// the lease, where the driver can say what the session is already holding. Only the second answer +/// ran, so the failure banner and the status line are written from it. +private struct MultiStatementRun { + let outcome: BatchStatementOutcome + let plan: BatchTransactionPlan + let sessionState: PluginSessionTransactionState +} + private struct PreparedStatement: @unchecked Sendable { let originalSQL: String let executableSQL: String @@ -27,14 +38,22 @@ private struct PreparedStatement: @unchecked Sendable { let parameterValues: [Any?]? let rowCap: Int? let anchor: StatementAnchor? + /// Whether this is the script's own `COMMIT`, read from the text before the lease is taken so + /// the run never lexes inside it. + let isCommitPoint: Bool } -/// What a multi-statement transaction left behind. The results travel out of the lease -/// so the tab, the history and the error sheet are updated after the driver is released. -private enum MultiStatementOutcome { - case completed(results: [QueryResult]) - case failed(results: [QueryResult], failure: MultiStatementFailure, errorDescription: String) - case cancelled +/// What a run has to write into query history, held together so the recording can happen below the +/// settle gate rather than beside the result sets. +/// +/// A batch that was stopped, or superseded by a navigation, has its results dropped there. History +/// used to be written above the gate, so a stopped run recorded every statement as successful while +/// the tab reported it as stopped, which is not what the single-statement path does. +private struct ExecutedStatementHistory { + let prepared: [PreparedStatement] + let results: [QueryResult] + let parameters: [QueryParameter] + let connection: DatabaseConnection } extension QueryExecutionCoordinator { @@ -104,16 +123,6 @@ extension QueryExecutionCoordinator { return } - if parent.currentQueryTask != nil { - parent.currentQueryTask?.cancel() - do { - try DatabaseManager.shared.cancelRunningQuery(for: parent.connectionId) - } catch { - paramLog.warning("cancelQuery failed: \(error.publicLogShape, privacy: .public)") - } - parent.currentQueryTask = nil - } - parent.tabManager.mutate(at: index) { tab in tab.execution.executionTime = nil tab.execution.errorMessage = nil @@ -122,7 +131,7 @@ extension QueryExecutionCoordinator { let conn = parent.connection let tabId = parent.tabManager.tabs[index].id - let claim = parent.tabExecution.claim(tabId) + let (claim, lease) = parent.beginTabExecution(for: tabId) let statement = resolveStatement(sql: sql, tabType: tab.tabType, bypassLimit: bypassRowLimit) let rowCap = statement.rowCap @@ -155,7 +164,7 @@ extension QueryExecutionCoordinator { let fetchResult = try await DatabaseManager.shared.withScopedDriver( scope: scope, route: DatabaseManager.shared.executionRoute(for: scope), - cancellation: .cancellableRead + cancellation: .cancellableRead(lease) ) { [queryExecutor = parent.queryExecutor, boundValues] driver in try await queryExecutor.executeQuery( driver: driver, @@ -224,7 +233,7 @@ extension QueryExecutionCoordinator { parent.tabManager.mutate(tabId: tabId) { tab in tab.pagination.isLoadingMore = false } - parent.retireQueryTask(for: claim) + parent.retireQueryTask(.claim(claim)) if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { parent.reportEndedExecutions([ EndedExecution(tabId: claim.tabId, startedAt: claim.startedAt, reason: .cancelledByUser) @@ -236,7 +245,7 @@ extension QueryExecutionCoordinator { } } } - parent.installQueryTask(parameterizedTask, for: claim) + parent.installQueryTask(parameterizedTask, owner: .claim(claim), lease: lease) } /// Every statement of the run shares one lease on the tab's database, so the @@ -274,8 +283,6 @@ extension QueryExecutionCoordinator { for: parent.connection.type )?.parameterStyle ?? .questionMark - parent.currentQueryTask?.cancel() - parent.tabManager.mutate(at: index) { tab in tab.execution.executionTime = nil tab.execution.errorMessage = nil @@ -283,36 +290,41 @@ extension QueryExecutionCoordinator { let conn = parent.connection let tabId = parent.tabManager.tabs[index].id - let claim = parent.tabExecution.claim(tabId) + let (claim, lease) = parent.beginTabExecution(for: tabId) let totalCount = statements.count let tabType = parent.tabManager.tabs[index].tabType let statementTexts = statements.map(\.sql) let transactionKind = OperationKind.worst(of: statementTexts, databaseType: conn.type) - let wrapsInTransaction = BatchTransactionPolicy.wrapsInTransaction( - statementTexts, - dialect: SqlDialect.from(databaseTypeId: conn.type.rawValue) + let rules = SQLLexicalRules( + databaseType: conn.type, + descriptor: PluginManager.shared.sqlDialect(for: conn.type) ) + let plan = BatchTransactionPolicy.plan(for: statementTexts, databaseType: conn.type, rules: rules) let prepared = statements.map { statement in prepareStatement( statement: statement, parameters: parameters, style: style, tabType: tabType, - bypassRowLimit: bypassRowLimit + bypassRowLimit: bypassRowLimit, + rules: rules ) } let multiStatementTask = Task { [weak self, parent] in guard let self else { return } - let outcome = await runMultiStatementTransaction( + let run = await runMultiStatementTransaction( prepared: prepared, scope: scope, mode: transactionKind.transactionAccessMode, - wrapsInTransaction: wrapsInTransaction, - claim: claim + plan: plan, + claim: claim, + lease: lease ) + let outcome = run.outcome + let sessionNotice = run.plan == .sessionTransaction ? run.sessionState.openTransactionNotice : nil let ranStatements: [String] switch outcome { @@ -329,50 +341,49 @@ extension QueryExecutionCoordinator { ) switch outcome { - case .cancelled: + case .cancelled(let results): guard parent.tabExecution.settle(claim) else { return } - parent.retireQueryTask(for: claim) + parent.retireQueryTask(.claim(claim)) + keepStoppedStatements( + history: ExecutedStatementHistory( + prepared: prepared, results: results, parameters: parameters, connection: conn + ), + tabId: tabId, + sessionNotice: sessionNotice + ) parent.reportEndedExecutions([ EndedExecution(tabId: claim.tabId, startedAt: claim.startedAt, reason: .cancelledByUser) ]) case .completed(let results): - let resultSets = applyExecutedStatements( - prepared: prepared, - results: results, - parameters: parameters, - connection: conn, - tabId: tabId - ) - applyMultiStatementResults( + applyCompletedStatements( + history: ExecutedStatementHistory( + prepared: prepared, results: results, parameters: parameters, connection: conn + ), tabId: tabId, claim: claim, - timing: PluginQueryTiming.batch(of: results), - totalRowsAffected: results.reduce(0) { $0 + $1.rowsAffected }, - newResultSets: resultSets + sessionNotice: sessionNotice ) case .failed(let results, let failure, let errorDescription): - var resultSets = applyExecutedStatements( - prepared: prepared, - results: results, - parameters: parameters, - connection: conn, - tabId: tabId - ) - await handleMultiStatementError( - errorDescription: errorDescription, - connection: conn, + handleMultiStatementError( + MultiStatementFailureContext( + failure: failure, + errorDescription: errorDescription, + executedCount: results.count, + totalCount: totalCount, + plan: run.plan, + sessionState: run.sessionState + ), + history: ExecutedStatementHistory( + prepared: prepared, results: results, parameters: parameters, connection: conn + ), tabId: tabId, claim: claim, statements: statements, - executedCount: results.count, - totalCount: totalCount, - timing: PluginQueryTiming.batch(of: results), - failure: failure, - resultSets: &resultSets + timing: PluginQueryTiming.batch(of: results) ) } } - parent.installQueryTask(multiStatementTask, for: claim) + parent.installQueryTask(multiStatementTask, owner: .claim(claim), lease: lease) } private func prepareStatement( @@ -380,7 +391,8 @@ extension QueryExecutionCoordinator { parameters: [QueryParameter], style: ParameterStyle, tabType: TabType, - bypassRowLimit: Bool + bypassRowLimit: Bool, + rules: SQLLexicalRules ) -> PreparedStatement { let sql = statement.sql let parameterNames = parameters.isEmpty ? [] : SQLParameterExtractor.extractParameters(from: sql) @@ -395,113 +407,131 @@ extension QueryExecutionCoordinator { sentSQL: bounded.sql, parameterValues: conversion?.values, rowCap: bounded.rowCap, - anchor: StatementAnchor(statement) + anchor: StatementAnchor(statement), + isCommitPoint: BatchCommitStatement.matches(sql, rules: rules) ) } + /// The session's own state is read inside the one lease that runs the batch, so nothing can + /// open a transaction between the answer and the first statement, and read again afterwards + /// when the run joined one: a failure moves an engine like PostgreSQL from an open transaction + /// to an aborted one, and the two are told apart in the banner. private func runMultiStatementTransaction( prepared: [PreparedStatement], scope: DatabaseScope, mode: PluginTransactionAccessMode, - wrapsInTransaction: Bool, - claim: TabExecutionClaim - ) async -> MultiStatementOutcome { + plan: BatchTransactionPlan, + claim: TabExecutionClaim, + lease: DriverLeaseOwner + ) async -> MultiStatementRun { do { return try await DatabaseManager.shared.withScopedDriver( scope: scope, route: DatabaseManager.shared.executionRoute(for: scope), - cancellation: .cancellableRead + cancellation: .cancellableRead(lease) ) { driver in - await self.runPreparedStatements( + let sessionPlan = plan.joining(await driver.heldSessionTransactionState()) + let outcome = await BatchStatementRun.run( prepared, + plan: sessionPlan, mode: mode, - wrapsInTransaction: wrapsInTransaction, - claim: claim, - driver: driver + driver: driver, + connectionId: scope.connectionId, + gate: self.claimGate(for: claim), + failureSQL: \.executableSQL, + isCommitPoint: \.isCommitPoint + ) { statement in + try await self.executeStatement( + rowCap: statement.rowCap, + originalSQL: statement.sentSQL, + driver: driver, + parameters: statement.parameterValues + ) + } + guard sessionPlan == .sessionTransaction else { + return MultiStatementRun(outcome: outcome, plan: sessionPlan, sessionState: .idle) + } + return MultiStatementRun( + outcome: outcome, + plan: sessionPlan, + sessionState: await driver.heldSessionTransactionState() ) } } catch { if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { - return .cancelled + return MultiStatementRun(outcome: .cancelled(results: []), plan: plan, sessionState: .unknown) } - return .failed(results: [], failure: .connection, errorDescription: error.localizedDescription) + return MultiStatementRun( + outcome: .failed(results: [], failure: .connection, errorDescription: error.localizedDescription), + plan: plan, + sessionState: .unknown + ) } } - private func runPreparedStatements( - _ prepared: [PreparedStatement], - mode: PluginTransactionAccessMode, - wrapsInTransaction: Bool, - claim: TabExecutionClaim, - driver: DatabaseDriver - ) async -> MultiStatementOutcome { - let useTransaction = wrapsInTransaction && driver.supportsTransactions - if useTransaction { - do { - try await driver.beginTransaction(mode: mode) - } catch { - return .failed(results: [], failure: .transactionStart, errorDescription: error.localizedDescription) - } - } - - var results: [QueryResult] = [] - for statement in prepared { - guard !Task.isCancelled, parent.tabExecution.isCurrent(claim) else { - await rollbackAfterStop(driver: driver, appOpenedTransaction: useTransaction) - return .cancelled - } - do { - results.append(try await executeStatement( - rowCap: statement.rowCap, - originalSQL: statement.sentSQL, - driver: driver, - parameters: statement.parameterValues - )) - } catch { - await rollbackAfterStop(driver: driver, appOpenedTransaction: useTransaction) - return .failed( - results: results, - failure: .statement(sql: statement.executableSQL), - errorDescription: error.localizedDescription - ) - } - } + /// The claim questions the run asks, in the one place that holds both the claim and the + /// registry. Marking and unmarking go through here so a future commit point cannot invent its + /// own ordering. + private func claimGate(for claim: TabExecutionClaim) -> BatchClaimGate { + BatchClaimGate( + isCurrent: { self.parent.tabExecution.isCurrent(claim) }, + enterCommitPhase: { self.parent.tabExecution.enterUninterruptiblePhase(claim) }, + leaveCommitPhase: { self.parent.tabExecution.leaveUninterruptiblePhase(claim) } + ) + } - if useTransaction { - do { - try await driver.commitTransaction() - } catch { - await rollbackAfterStop(driver: driver, appOpenedTransaction: useTransaction) - return .failed(results: results, failure: .commit, errorDescription: error.localizedDescription) - } - } - return .completed(results: results) + /// A Stop cannot take back what a plan without a transaction already committed, so the results + /// and the history of the statements that ran stay rather than being dropped with the run. + private func keepStoppedStatements( + history: ExecutedStatementHistory, + tabId: UUID, + sessionNotice: String? + ) { + guard !history.results.isEmpty else { return } + recordExecutedStatements(history, tabId: tabId, commitOutcomeIsUnknown: false) + presentMultiStatementResults( + tabId: tabId, + timing: PluginQueryTiming.batch(of: history.results), + totalRowsAffected: history.results.reduce(0) { $0 + $1.rowsAffected }, + newResultSets: statementResultSets(history, tabId: tabId), + sessionNotice: sessionNotice + ) } - private func rollbackAfterStop(driver: DatabaseDriver, appOpenedTransaction: Bool) async { - guard driver.supportsTransactions else { return } - do { - try await driver.rollbackTransaction() - } catch { - guard appOpenedTransaction else { - paramLog.debug("No open script transaction to roll back: \(error.publicLogShape, privacy: .public)") - return - } - paramLog.error("Rollback failed: \(error.publicLogShape, privacy: .public)") - } + /// Settles first, then writes. Everything below the gate belongs to a batch that still owns its + /// tab: its history rows, its outcome notification and its result sets. A superseded batch + /// writes none of them, which is what the single-statement path has always done. + private func applyCompletedStatements( + history: ExecutedStatementHistory, + tabId: UUID, + claim: TabExecutionClaim, + sessionNotice: String? + ) { + guard parent.tabExecution.settle(claim) else { return } + parent.retireQueryTask(.claim(claim)) + + let totalRowsAffected = history.results.reduce(0) { $0 + $1.rowsAffected } + reportOperation( + kind: .queryBatch, + claim: claim, + outcome: .succeeded( + OperationSummary(rowsAffected: totalRowsAffected, statementCount: history.results.count) + ) + ) + recordExecutedStatements(history, tabId: tabId, commitOutcomeIsUnknown: false) + presentMultiStatementResults( + tabId: tabId, + timing: PluginQueryTiming.batch(of: history.results), + totalRowsAffected: totalRowsAffected, + newResultSets: statementResultSets(history, tabId: tabId), + sessionNotice: sessionNotice + ) } - private func applyExecutedStatements( - prepared: [PreparedStatement], - results: [QueryResult], - parameters: [QueryParameter], - connection: DatabaseConnection, - tabId: UUID - ) -> [ResultSet] { - var resultSets: [ResultSet] = [] - for (index, pair) in zip(prepared, results).enumerated() { + private func statementResultSets(_ history: ExecutedStatementHistory, tabId: UUID) -> [ResultSet] { + zip(history.prepared, history.results).enumerated().map { index, pair in let (statement, result) = pair - resultSets.append(makeStatementResultSet( + return makeStatementResultSet( result: result, sql: statement.originalSQL, index: index, @@ -509,16 +539,31 @@ extension QueryExecutionCoordinator { baseQueryParameterValues: statement.parameterValues?.map { $0 as? String }, tabId: tabId, anchor: statement.anchor - )) + ) + } + } + + /// A commit whose connection died before the answer leaves every statement of the batch in a + /// state nothing can report as done, so history says so rather than claiming a success it + /// cannot prove. + private func recordExecutedStatements( + _ history: ExecutedStatementHistory, + tabId: UUID, + commitOutcomeIsUnknown: Bool + ) { + let unresolvedOutcome = commitOutcomeIsUnknown + ? String(localized: "The connection was lost while committing, so this may not be saved.") + : nil + for (statement, result) in zip(history.prepared, history.results) { recordStatementHistory( sql: statement.originalSQL, result: result, - connection: connection, + connection: history.connection, databaseName: historyDatabaseName(tabId: tabId), - parameterValues: statement.parameterValues == nil ? nil : parameters + parameterValues: statement.parameterValues == nil ? nil : history.parameters, + unresolvedOutcome: unresolvedOutcome ) } - return resultSets } func applyParameterizedResult( @@ -538,7 +583,7 @@ extension QueryExecutionCoordinator { await MainActor.run { [weak self] in guard let self else { return } guard parent.tabExecution.settle(claim) else { return } - parent.retireQueryTask(for: claim) + parent.retireQueryTask(.claim(claim)) guard !Task.isCancelled else { parent.reportEndedExecutions([ EndedExecution(tabId: claim.tabId, startedAt: claim.startedAt, reason: .cancelledByUser) @@ -588,24 +633,21 @@ extension QueryExecutionCoordinator { /// The transaction was already rolled back inside the lease that ran it, so this /// only reports the failure: resolving a driver here would reach a released handle. - func handleMultiStatementError( - errorDescription: String, - connection: DatabaseConnection, + /// + /// Every write is below the settle gate, history included. A batch whose claim is gone was + /// stopped or superseded, and recording its statements there would put work the tab is not + /// showing into the user's history as if it had been kept. + private func handleMultiStatementError( + _ context: MultiStatementFailureContext, + history: ExecutedStatementHistory, tabId: UUID, claim: TabExecutionClaim, statements: [SQLStatementScanner.ExecutableStatement], - executedCount: Int, - totalCount: Int, - timing: PluginQueryTiming, - failure: MultiStatementFailure, - resultSets: inout [ResultSet] - ) async { + timing: PluginQueryTiming + ) { let cumulativeTime = timing.total - let report = failure.report( - executedCount: executedCount, - totalCount: totalCount, - errorDescription: errorDescription - ) + let errorDescription = context.errorDescription + let report = context.report() let contextMsg = report.message let errorRS = ResultSet(label: report.resultLabel) @@ -613,55 +655,55 @@ extension QueryExecutionCoordinator { errorRS.statementAnchor = report.failedStatementIndex .flatMap { statements.indices.contains($0) ? statements[$0] : nil } .map(StatementAnchor.init) - resultSets.append(errorRS) let failedStatementSQL = report.failedSQL - let capturedResultSets = resultSets - await MainActor.run { [weak self] in - guard let self else { return } - guard parent.tabExecution.settle(claim) else { return } - parent.retireQueryTask(for: claim) - - /// Below the settle gate for the same reason the success arm is: a superseded batch - /// has its error dropped here, so announcing it would report on work the user has - /// already navigated away from. - reportOperation(kind: .queryBatch, claim: claim, outcome: .failed(reason: errorDescription)) - - parent.flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) - parent.tabManager.mutate(tabId: tabId) { tab in - tab.execution.errorMessage = contextMsg - tab.execution.errorQuery = failedStatementSQL - tab.execution.executionTime = cumulativeTime - tab.execution.lastExecutedAt = Date() - - tab.display.replaceUnpinnedResults(with: capturedResultSets) - if tab.display.isResultsCollapsed { - tab.display.isResultsCollapsed = false - } - } - parent.seedBufferFromActiveResult(tabId: tabId) - if parent.tabManager.selectedTabId == tabId { - parent.toolbarState.isResultsCollapsed = false - parent.toolbarState.recordQueryTiming(timing, for: tabId) - parent.announceQueryError(contextMsg) + guard parent.tabExecution.settle(claim) else { return } + parent.retireQueryTask(.claim(claim)) + + /// Below the settle gate for the same reason the success arm is: a superseded batch + /// has its error dropped here, so announcing it would report on work the user has + /// already navigated away from. + reportOperation(kind: .queryBatch, claim: claim, outcome: .failed(reason: errorDescription)) + recordExecutedStatements( + history, + tabId: tabId, + commitOutcomeIsUnknown: context.failure == .commitOutcomeUnknown + ) + + parent.flushBufferToActiveResult(tabId: tabId, pinnedOnly: true) + parent.tabManager.mutate(tabId: tabId) { tab in + tab.execution.errorMessage = contextMsg + tab.execution.errorQuery = failedStatementSQL + tab.execution.executionTime = cumulativeTime + tab.execution.lastExecutedAt = Date() + + tab.display.replaceUnpinnedResults(with: statementResultSets(history, tabId: tabId) + [errorRS]) + if tab.display.isResultsCollapsed { + tab.display.isResultsCollapsed = false } + } + parent.seedBufferFromActiveResult(tabId: tabId) + if parent.tabManager.selectedTabId == tabId { + parent.toolbarState.isResultsCollapsed = false + parent.toolbarState.recordQueryTiming(timing, for: tabId) + parent.announceQueryError(contextMsg) + } - guard let rawSQL = failedStatementSQL else { return } - let recordSQL = rawSQL.hasSuffix(";") ? rawSQL : rawSQL + ";" - recordHistory( - QueryHistoryRecordRequest( - query: recordSQL, - connectionId: connection.id, - databaseName: historyDatabaseName(tabId: tabId), - databaseType: connection.type, - schemaName: historySchemaName(tabId: tabId), - source: .editor, - executionTime: cumulativeTime, - rowCount: -1, - wasSuccessful: false, - errorMessage: errorDescription - ) + guard let rawSQL = failedStatementSQL else { return } + let recordSQL = rawSQL.hasSuffix(";") ? rawSQL : rawSQL + ";" + recordHistory( + QueryHistoryRecordRequest( + query: recordSQL, + connectionId: history.connection.id, + databaseName: historyDatabaseName(tabId: tabId), + databaseType: history.connection.type, + schemaName: historySchemaName(tabId: tabId), + source: .editor, + executionTime: cumulativeTime, + rowCount: -1, + wasSuccessful: false, + errorMessage: errorDescription ) - } + ) } } diff --git a/TablePro/Core/DataWrite/DataWriteError.swift b/TablePro/Core/DataWrite/DataWriteError.swift index 5d911c4d22..03c5d3b1e7 100644 --- a/TablePro/Core/DataWrite/DataWriteError.swift +++ b/TablePro/Core/DataWrite/DataWriteError.swift @@ -13,6 +13,7 @@ enum DataWriteError: LocalizedError, Equatable { case identityNotPreservable(String) case tooManyRowsAffected(table: String, expected: Int, actual: Int) case tooManyRowsAffectedUnrecoverable(table: String, expected: Int, actual: Int) + case tooManyRowsAffectedInSessionTransaction(table: String, expected: Int, actual: Int) case rowsNoLongerMatch(table: String, expected: Int, actual: Int) var errorDescription: String? { @@ -69,7 +70,8 @@ enum DataWriteError: LocalizedError, Equatable { ), table, actual, expected ) - case .tooManyRowsAffectedUnrecoverable(let table, let expected, let actual): + case .tooManyRowsAffectedUnrecoverable(let table, let expected, let actual), + .tooManyRowsAffectedInSessionTransaction(let table, let expected, let actual): return String( format: String( localized: "A statement on '%1$@' matched %2$d rows instead of %3$d." @@ -95,6 +97,17 @@ enum DataWriteError: LocalizedError, Equatable { ), table ) + case .tooManyRowsAffectedInSessionTransaction(let table, _, _): + return String( + format: String( + localized: """ + The extra rows are pending in the transaction already open on this connection. \ + Roll it back to discard them. '%@' has no primary key, so identical rows cannot \ + be told apart. + """ + ), + table + ) case .rowsNoLongerMatch(let table, _, _): return String( format: String( diff --git a/TablePro/Core/DataWrite/DataWriteExecutor.swift b/TablePro/Core/DataWrite/DataWriteExecutor.swift index 26393a28eb..0dfcf145fc 100644 --- a/TablePro/Core/DataWrite/DataWriteExecutor.swift +++ b/TablePro/Core/DataWrite/DataWriteExecutor.swift @@ -39,15 +39,25 @@ struct DataWriteRun: Sendable { let sideStatements: [String] } -/// Some of the batch is on the server and cannot be taken back. +/// Some of the batch reached the server and the app cannot take it back. /// /// Distinct from `DataWriteError` on purpose: that one is an `Equatable` enum whose cases are /// compared in tests, and results do not belong in it. Modelled on `PrincipalApplyError`, which /// reports the same shape for principals. struct DataWritePartialCommitError: LocalizedError { + /// What became of the statements that ran, which decides what the user is told to do next. + enum Disposition: Equatable { + /// Committed as they ran, either by an engine without transactions or because the rollback + /// itself failed. Running the save again would write them a second time. + case written + /// Pending inside a transaction the user already had open, which the app must not end. + case pendingInSessionTransaction + } + let committed: [DataWriteStepResult] let totalStatements: Int let engine: String + let disposition: Disposition let underlying: any Error var errorDescription: String? { @@ -55,16 +65,33 @@ struct DataWritePartialCommitError: LocalizedError { } var partialCommitMessage: String { - String( - format: String( - localized: "%1$lld of %2$lld statements were already written, and %3$@ cannot roll them back." - ), - committed.count, totalStatements, engine - ) + switch disposition { + case .written: + return String( + format: String( + localized: "%1$lld of %2$lld statements were already written, and %3$@ cannot roll them back." + ), + committed.count, totalStatements, engine + ) + case .pendingInSessionTransaction: + return String( + format: String( + localized: "%1$lld of %2$lld statements ran inside the transaction already open on this connection." + ), + committed.count, totalStatements + ) + } } var recoverySuggestion: String? { - String(localized: "Refresh the table to see what was written. Saving again would write those rows a second time.") + switch disposition { + case .written: + return String( + localized: "Refresh the table to see what was written. Saving again would write those rows a second time." + ) + case .pendingInSessionTransaction: + return String(localized: "Roll the transaction back to discard them, or commit it to keep them.") + } } } @@ -97,7 +124,12 @@ enum DataWriteExecutor { } } - let useTransaction = driver.supportsTransactions + /// Asked inside the same lease that runs the statements, so nothing can open a transaction + /// between the answer and the first write. + let owner = WriteTransactionOwner.resolve( + supportsTransactions: driver.supportsTransactions, + sessionState: await driver.heldSessionTransactionState() + ) var results: [DataWriteStepResult] = [] func drainEpilogue() async { @@ -114,7 +146,7 @@ enum DataWriteExecutor { } do { - if useTransaction { + if owner.opensTransaction { try await driver.beginTransaction(mode: mode) } @@ -133,7 +165,7 @@ enum DataWriteExecutor { try verify( step, rowsAffected: result.rowsAffected, - canRollBack: useTransaction, + owner: owner, countsAreMeaningful: DataWriteRowCounts.areMeaningful(for: plan.databaseType) ) results.append( @@ -145,12 +177,12 @@ enum DataWriteExecutor { ) } - if useTransaction { + if owner.opensTransaction { try await driver.commitTransaction() } } catch { - var rollbackSucceeded = useTransaction - if useTransaction { + var rollbackSucceeded = owner.canRollBack + if owner.opensTransaction { do { try await driver.rollbackTransaction() } catch { @@ -162,12 +194,15 @@ enum DataWriteExecutor { /// Without a transaction the statements that already ran are on the server for good, /// and so they are when the rollback itself failed. Reporting that as a plain failure - /// tells the user to try again, and trying again writes them a second time. + /// tells the user to try again, and trying again writes them a second time. A run that + /// joined the user's own transaction is the third case: the statements are pending in + /// it, and only the user can commit or roll it back. if !rollbackSucceeded, !results.isEmpty { throw DataWritePartialCommitError( committed: results, totalStatements: steps.count, engine: plan.databaseType.rawValue, + disposition: owner == .session ? .pendingInSessionTransaction : .written, underlying: error ) } @@ -201,7 +236,7 @@ enum DataWriteExecutor { private static func verify( _ step: DataWriteStep, rowsAffected: Int, - canRollBack: Bool, + owner: WriteTransactionOwner, countsAreMeaningful: Bool ) throws { guard let expected = step.expectedRowCount else { return } @@ -211,11 +246,7 @@ enum DataWriteExecutor { logger.error( "Statement on '\(table, privacy: .private(mask: .hash))' affected \(rowsAffected, privacy: .public) rows, expected at most \(expected, privacy: .public)" ) - throw canRollBack - ? DataWriteError.tooManyRowsAffected(table: table, expected: expected, actual: rowsAffected) - : DataWriteError.tooManyRowsAffectedUnrecoverable( - table: table, expected: expected, actual: rowsAffected - ) + throw tooManyRowsError(owner: owner, table: table, expected: expected, actual: rowsAffected) } guard step.matchesRowsWithoutKey, countsAreMeaningful, rowsAffected < expected else { return } @@ -224,4 +255,22 @@ enum DataWriteExecutor { ) throw DataWriteError.rowsNoLongerMatch(table: table, expected: expected, actual: rowsAffected) } + + /// Three different things to tell the user, one per owner: the app took the statement back, the + /// engine cannot, or it is pending in a transaction only the user can end. + private static func tooManyRowsError( + owner: WriteTransactionOwner, + table: String, + expected: Int, + actual: Int + ) -> DataWriteError { + switch owner { + case .app: + return .tooManyRowsAffected(table: table, expected: expected, actual: actual) + case .session: + return .tooManyRowsAffectedInSessionTransaction(table: table, expected: expected, actual: actual) + case .none: + return .tooManyRowsAffectedUnrecoverable(table: table, expected: expected, actual: actual) + } + } } diff --git a/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift b/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift index 31e51c1e09..6886c0cfdc 100644 --- a/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift +++ b/TablePro/Core/DataWrite/Rewind/RewindExecutor.swift @@ -40,10 +40,12 @@ struct RewindExecutor { ) let queries = planner.readQueries() let route = DatabaseManager.shared.executionRoute(for: scope) + /// Untracked: nothing offers a Stop for a rewind plan, so registering it only exposed the + /// read to whatever else on the connection was being cancelled. let currentRows = try await DatabaseManager.shared.withScopedDriver( scope: scope, route: route, - cancellation: .cancellableRead + cancellation: .untracked ) { driver in var rows: [[PluginCellValue]] = [] for query in queries { diff --git a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift index 738517e04b..ded832023e 100644 --- a/TablePro/Core/Database/Access/DatabaseAccessBridge.swift +++ b/TablePro/Core/Database/Access/DatabaseAccessBridge.swift @@ -194,12 +194,19 @@ internal actor DatabaseAccessBridge { statement = LeadingRowsStatement(sql: normalizedQuery, rowCap: nil) } let connectionId = scope.connectionId - let policy: DriverCancellationPolicy = classification.tier == .safe ? .cancellableRead : .protectedWrite + /// One owner per statement, so a cancel or a timeout reaches this statement's lease and not + /// whatever a query tab or another client has running on the same connection. + let owner = DriverLeaseOwner() + let policy: DriverCancellationPolicy = classification.tier == .safe + ? .cancellableRead(owner) + : .protectedWrite if let cancellation { await cancellation.onCancelRequested { await MainActor.run { - try? DatabaseManager.shared.cancelRunningQuery(for: connectionId, reach: .userStop) + try? DatabaseManager.shared.cancelRunningQuery( + owner: owner, on: connectionId, delivery: .immediate + ) } } } @@ -219,6 +226,7 @@ internal actor DatabaseAccessBridge { scope: scope, route: route, policy: policy, + owner: owner, statement: statement, shouldCap: shouldCap, maxRows: maxRows, @@ -240,6 +248,7 @@ internal actor DatabaseAccessBridge { scope: DatabaseScope, route: ScopedDriverRoute, policy: DriverCancellationPolicy, + owner: DriverLeaseOwner, statement: LeadingRowsStatement, shouldCap: Bool, maxRows: Int, @@ -267,7 +276,9 @@ internal actor DatabaseAccessBridge { group.addTask { try await Task.sleep(for: .seconds(timeoutSeconds)) await MainActor.run { - try? DatabaseManager.shared.cancelRunningQuery(for: connectionId, reach: .userStop) + try? DatabaseManager.shared.cancelRunningQuery( + owner: owner, on: connectionId, delivery: .immediate + ) } throw DatabaseAccessError.timeout( String( diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 4ed1edd1dd..e3cebd9268 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -149,6 +149,9 @@ protocol DatabaseDriver: AnyObject, Sendable { var unsupportedStructureColumnFields: Set { get } var unsupportedIndexTypes: Set { get } + /// Why the connected server has no check constraints to list or edit, or nil when it has. + var checkConstraintRefusal: String? { get } + /// Fetch foreign keys for all tables in the current database/schema in bulk. /// Default implementation falls back to per-table fetchForeignKeys. func fetchAllForeignKeys() async throws -> [String: [ForeignKeyInfo]] @@ -325,6 +328,10 @@ protocol DatabaseDriver: AnyObject, Sendable { /// Rollback the current transaction func rollbackTransaction() async throws + /// What the session is holding, so nothing the app owns opens, commits or rolls back a + /// transaction over one the user already has open on the same session. + func sessionTransactionState() async -> PluginSessionTransactionState + /// Access to the underlying plugin driver for query building dispatch var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { get } @@ -404,6 +411,8 @@ extension DatabaseDriver { try await beginTransaction() } + func sessionTransactionState() async -> PluginSessionTransactionState { .unknown } + func quoteIdentifier(_ name: String) -> String { SQLEscaping.quoteIdentifier(name) } @@ -469,6 +478,7 @@ extension DatabaseDriver { var unsupportedStructureColumnFields: Set { [] } var unsupportedIndexTypes: Set { [] } + var checkConstraintRefusal: String? { nil } func ping() async throws { _ = try await execute(query: "SELECT 1") diff --git a/TablePro/Core/Database/DatabaseManager+Principals.swift b/TablePro/Core/Database/DatabaseManager+Principals.swift index 5a1ffff0f5..73dc1705ae 100644 --- a/TablePro/Core/Database/DatabaseManager+Principals.swift +++ b/TablePro/Core/Database/DatabaseManager+Principals.swift @@ -77,7 +77,15 @@ extension DatabaseManager { rollsBack: Bool, connectionId: UUID ) async throws { - let useTransaction = driver.supportsTransactions && rollsBack + /// No transaction is opened over one the session already holds: on this shared session an + /// app-owned `COMMIT` commits the user's pending work, and MySQL's `START TRANSACTION` + /// commits it implicitly. Joining it instead leaves the statements pending, which is what + /// the failure then reports. + let owner = WriteTransactionOwner.resolve( + supportsTransactions: driver.supportsTransactions, + sessionState: await driver.heldSessionTransactionState() + ) + let useTransaction = owner.opensTransaction && rollsBack if useTransaction { try await driver.beginTransaction(mode: .readWrite) } @@ -92,11 +100,13 @@ extension DatabaseManager { try await driver.commitTransaction() } } catch { - var rolledBack = false + var disposition: PrincipalApplyError.Disposition = owner == .session + ? .pendingInSessionTransaction + : .applied if useTransaction { do { try await driver.rollbackTransaction() - rolledBack = true + disposition = .rolledBack } catch { Self.logger.error( "Rollback failed after principal change error: \(error.localizedDescription)" @@ -107,7 +117,7 @@ extension DatabaseManager { failedStatement: statements[min(appliedCount, statements.count - 1)], appliedCount: appliedCount, totalCount: statements.count, - rolledBack: rolledBack, + disposition: disposition, underlying: error ) } diff --git a/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift index 0d5aae20fc..43134a00ec 100644 --- a/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift +++ b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift @@ -140,6 +140,10 @@ extension DatabaseManager { /// Registers the driver a tracked lease runs on for the length of its body, whichever route it /// took, so Stop reaches the handle the work is actually on. + /// + /// The cancellation check sits after the registration rather than before it, which is what + /// closes the window a cancel issued for this owner a moment earlier would otherwise fall + /// through: it reached an empty map, and the lease then ran the statement anyway. private func trackedLease( for connectionId: UUID, cancellation: DriverCancellationPolicy, @@ -148,65 +152,116 @@ extension DatabaseManager { guard cancellation.isTracked else { return body } let token = UUID() let entry = RunningDriver(driver: nil, policy: cancellation) + let isCancellable = cancellation != .protectedWrite return { driver in await MainActor.run { DatabaseManager.shared.runningDrivers[connectionId, default: [:]][token] = entry.adopting(driver) } do { + if isCancellable { try Task.checkCancellation() } let value = try await body(driver) - await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) } + await DatabaseManager.settleRunningDriver(token, for: connectionId) return value } catch { - await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) } + await DatabaseManager.settleRunningDriver(token, for: connectionId) throw error } } } - internal func releaseRunningDriver(_ token: UUID, for connectionId: UUID) { - runningDrivers[connectionId]?.removeValue(forKey: token) + /// Releases the lease and waits out any background cancel already sent for its handle, still + /// inside the session gate's turn or the pooled lease. A cancel that outlives its own lease + /// lands on whatever the connection runs next, which on MariaDB is a `KILL QUERY` arriving at + /// the following statement and on PostgreSQL a `PQcancel` at the following backend command. + private static func settleRunningDriver(_ token: UUID, for connectionId: UUID) async { + let pending = await MainActor.run { + DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) + } + await pending?.value + } + + /// Registers a driver as running work no cancel may reach, for the length of one commit or + /// rollback the app has to see through. + /// + /// Synchronous, and so is the Stop that reads it, which is the whole point: the registration, + /// the Stop check and the claim's mark happen in one stretch of main-actor work, so a Stop can + /// only land wholly before it or wholly after it. The driver is the handle the statement is + /// actually on, which is not always the session driver now that a cross-database tab runs on a + /// pooled connection. + internal func beginProtectedWrite(on driver: DatabaseDriver, for connectionId: UUID) -> UUID { + let token = UUID() + runningDrivers[connectionId, default: [:]][token] = RunningDriver(driver: driver, policy: .protectedWrite) + return token + } + + internal func endProtectedWrite(_ token: UUID, for connectionId: UUID) { + releaseRunningDriver(token, for: connectionId) + } + + @discardableResult + internal func releaseRunningDriver(_ token: UUID, for connectionId: UUID) -> Task? { + let released = runningDrivers[connectionId]?.removeValue(forKey: token) if runningDrivers[connectionId]?.isEmpty == true { runningDrivers.removeValue(forKey: connectionId) } + return released?.pendingCancel } /// Stop has to reach the handle the query is actually running on, which is no longer /// always the session driver now that a cross-database tab runs on a pooled connection. /// - /// A `.protectedWrite` lease is never reachable: a commit, a rollback or a DDL statement that is - /// half applied is data loss, and both Stop and a superseding navigation would otherwise abort - /// one. The empty-map fallback is deliberately only taken for an explicit user Stop, because it - /// cancels whatever the session driver happens to be running without knowing what that is. - /// Stop stays synchronous because the user is waiting on it. A navigation supersede does not: - /// a PostgreSQL cancel opens a second connection to deliver the request, which through an SSH - /// tunnel costs 70-160ms, and paying that on the main thread turns fast browsing into a stutter. - /// Correctness never depended on the cancel landing first, because the tab's execution claim - /// already discards whatever the superseded query returns; the cancel is only there to stop the - /// server doing work nobody wants. - func cancelRunningQuery(for connectionId: UUID, reach: DriverCancellationReach = .userStop) throws { - let targets = cancellationTargets(for: connectionId, reach: reach) + /// It reaches that owner's leases and nothing else. There is no session-driver fallback: an + /// owner with nothing registered has nothing running, and aborting whatever the shared driver + /// happened to be doing is how one tab's Run stopped another tab's batch. A `.protectedWrite` + /// lease is never reachable either, because a commit, a rollback or a DDL statement that is half + /// applied is data loss. + func cancelRunningQuery( + owner: DriverLeaseOwner, + on connectionId: UUID, + delivery: DriverCancellationDelivery + ) throws { + let targets = cancellationTargets(for: connectionId, owner: owner) guard !targets.isEmpty else { return } - guard reach == .userStop else { - DispatchQueue.global(qos: .utility).async { - for driver in targets { try? driver.cancelQuery() } - } + guard delivery == .background else { + for target in targets { try target.driver.cancelQuery() } return } - for driver in targets { - try driver.cancelQuery() + for target in targets { + runningDrivers[connectionId]?[target.token]?.pendingCancel = Self.backgroundCancel(target.driver) + } + } + + /// Off the main thread because a PostgreSQL cancel opens a second connection to deliver the + /// request, which through an SSH tunnel costs 70-160ms per click of fast browsing (#2061). The + /// Task is handed back to the lease, which awaits it before releasing the handle. + private static func backgroundCancel(_ driver: DatabaseDriver) -> Task { + Task { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + try? driver.cancelQuery() + continuation.resume() + } + } } } + /// A driver held by a protected write is dropped from the targets even when a cancellable lease + /// names the same handle, which is the ordinary shape of a batch: its statements run under one + /// `.cancellableRead` lease and its commit registers the same driver again as a + /// `.protectedWrite`. Without the identity check the cancel would reach the commit through the + /// lease that is still open around it. private func cancellationTargets( for connectionId: UUID, - reach: DriverCancellationReach - ) -> [DatabaseDriver] { + owner: DriverLeaseOwner + ) -> [(token: UUID, driver: DatabaseDriver)] { let running = runningDrivers[connectionId] ?? [:] - let cancellable = running.values.filter { $0.policy == .cancellableRead }.compactMap(\.driver) - guard cancellable.isEmpty else { return cancellable } - guard running.isEmpty, reach == .userStop else { return [] } - return [driver(for: connectionId)].compactMap { $0 } + let protected = running.values.filter { $0.policy == .protectedWrite }.compactMap(\.driver) + return running.compactMap { token, entry in + guard entry.policy == .cancellableRead(owner), let driver = entry.driver else { return nil } + guard !protected.contains(where: { $0 === driver }) else { return nil } + return (token, driver) + } } /// Pooling assumes a second connection to the same definition reaches the same database. diff --git a/TablePro/Core/Database/DriverCancellationPolicy.swift b/TablePro/Core/Database/DriverCancellationPolicy.swift index 8c57a3ae63..c2224cf39c 100644 --- a/TablePro/Core/Database/DriverCancellationPolicy.swift +++ b/TablePro/Core/Database/DriverCancellationPolicy.swift @@ -5,6 +5,18 @@ import Foundation +/// Who a leased driver belongs to, so a cancel reaches that lease and nothing else. +/// +/// One connection runs one tab's work at a time, but several tabs and several windows queue on the +/// same session driver, and an MCP client leases it too. Without an owner a cancel was keyed by +/// connection alone, so starting a query in one tab aborted the batch another tab had running and +/// rolled it back under a "cancelled by user" the user never asked for. +internal struct DriverLeaseOwner: Hashable, Sendable { + private let id = UUID() + + internal init() {} +} + /// Whether a leased driver may be aborted, and by whom. /// /// Cancellation used to be a bool, and `cancelRunningQuery` aborted every tracked handle. That was @@ -16,33 +28,44 @@ internal enum DriverCancellationPolicy: Equatable, Sendable { /// aborted nor clear the handle a real query registered. case untracked - /// User SQL and table loads. Safe to abort: the worst case is a result nobody wanted. - case cancellableRead + /// User SQL and table loads, aborted only by whoever owns the lease. Safe to abort: the worst + /// case is a result nobody wanted. + case cancellableRead(DriverLeaseOwner) /// Commits, rollbacks, row writes and DDL. Registered so the connection is known to be busy, but /// never abortable, because a half-applied write cannot be undone by retrying. case protectedWrite - var isTracked: Bool { + internal var isTracked: Bool { self != .untracked } } -/// How far a cancellation request is allowed to reach when nothing is registered. -internal enum DriverCancellationReach: Equatable, Sendable { - /// The user pressed Stop. May fall back to the session driver, since the intent is explicit. - case userStop +/// When a cancellation request is paid for. +internal enum DriverCancellationDelivery: Equatable, Sendable { + /// The user pressed Stop and is waiting on it, so the request goes out inline. + case immediate - /// A navigation superseded a tab. Only ever aborts a lease it can see, because guessing at the - /// session driver here would abort work belonging to a different tab. - case supersededNavigation + /// A navigation superseded a tab, or a tab was closed. A PostgreSQL cancel opens a second + /// connection to deliver the request, which through an SSH tunnel costs 70-160ms, so it goes to + /// a background queue and the lease awaits it on the way out instead of the user waiting for it. + case background } internal struct RunningDriver { - let driver: DatabaseDriver? - let policy: DriverCancellationPolicy + internal let driver: DatabaseDriver? + internal let policy: DriverCancellationPolicy + + /// A background cancel already sent for this handle. The lease awaits it before releasing, so a + /// request that lands late cannot reach the statement the next owner runs on the same driver. + internal var pendingCancel: Task? + + internal init(driver: DatabaseDriver?, policy: DriverCancellationPolicy) { + self.driver = driver + self.policy = policy + } - func adopting(_ driver: DatabaseDriver) -> RunningDriver { + internal func adopting(_ driver: DatabaseDriver) -> RunningDriver { RunningDriver(driver: driver, policy: policy) } } diff --git a/TablePro/Core/Execution/TabExecutionRegistry.swift b/TablePro/Core/Execution/TabExecutionRegistry.swift index fe97f00daf..fe6baaa48a 100644 --- a/TablePro/Core/Execution/TabExecutionRegistry.swift +++ b/TablePro/Core/Execution/TabExecutionRegistry.swift @@ -59,6 +59,10 @@ internal struct TabExecutionRegistry { private struct Entry { let epoch: Int let startedAt: ContinuousClock.Instant + /// Whether the work this claim owns has passed its point of no return. Set while a batch's + /// `COMMIT` is on the wire, and read by `stop` and `isStoppable` alone: a tab close, a + /// retarget, a supersede and a lost session all end the claim regardless. + var isUninterruptible = false } private var entries: [UUID: Entry] = [:] @@ -128,6 +132,61 @@ internal struct TabExecutionRegistry { return ended } + /// Marks the claim as past the point where Stop can still take its work back, and answers + /// whether it still owned the tab. + /// + /// The answer and the mark are one call for the same reason `settle` is: asking them separately + /// is order-dependent, and a Stop between the two questions would be seen by neither. Both this + /// and `stopAll` run on the main actor, so a Stop lands wholly before this call or wholly after + /// it. It is deliberately not `@discardableResult`: a caller that marks without reading the + /// answer commits over a tab it no longer owns. + internal mutating func enterUninterruptiblePhase(_ claim: TabExecutionClaim) -> Bool { + guard isCurrent(claim) else { return false } + entries[claim.tabId]?.isUninterruptible = true + return true + } + + /// Puts the claim back within reach of Stop, for a batch that has more statements to run after + /// a commit its script wrote itself. + internal mutating func leaveUninterruptiblePhase(_ claim: TabExecutionClaim) { + guard isCurrent(claim) else { return } + entries[claim.tabId]?.isUninterruptible = false + } + + /// What a Stop did, so the caller can act on the one case where the claim outlives it. + /// + /// The answer and the release are one call for the same reason `settle` and + /// `enterUninterruptiblePhase` are: asking whether the claim is uninterruptible and then + /// stopping it are two reads of a value that decides what the caller does next, and an empty + /// `ended` cannot tell "nothing was running" from "the claim was kept". + internal struct StopOutcome: Equatable { + internal let ended: [EndedExecution] + /// True when the claim was past its point of no return, so the work it owns is still running + /// and everything the caller holds for it stays installed. + internal let keptUninterruptibleClaim: Bool + } + + /// What the user's Stop does, as against `invalidate(_:reason:)`, which every other end of an + /// execution still uses. + /// + /// Keyed by tab, because Stop acts on the tab the user is looking at. A claim in its + /// uninterruptible phase is kept, with its content epoch untouched, so the commit that is + /// already on the wire still settles and still applies its results. Everything else ends exactly + /// as it did: `invalidate` and `invalidateAll` ignore the mark, so closing the tab, a retarget or + /// a lost session release it whatever it is doing. + internal mutating func stop(_ tabId: UUID) -> StopOutcome { + unclaimedWork.removeValue(forKey: tabId) + guard entries[tabId]?.isUninterruptible != true else { + return StopOutcome(ended: [], keptUninterruptibleClaim: true) + } + let ended = entries.removeValue(forKey: tabId).map { + [EndedExecution(tabId: tabId, startedAt: $0.startedAt, reason: .cancelledByUser)] + } ?? [] + lastEpoch += 1 + contentEpochs[tabId] = lastEpoch + return StopOutcome(ended: ended, keptUninterruptibleClaim: false) + } + /// Work that runs against a tab without owning its result. /// /// Fetch All is why this exists. `claim` mints a new content epoch, which is the very value the @@ -193,4 +252,14 @@ internal struct TabExecutionRegistry { internal var isAnyExecuting: Bool { !entries.isEmpty || !unclaimedWork.isEmpty } + + /// Whether Stop still has something to act on here. The HIG asks not to offer a cancel that + /// cannot act, and a batch whose `COMMIT` is on the wire cannot be taken back by anything: a + /// kill on a commit already waiting on the server is honoured on one engine and ignored on the + /// next, and closing the tab or disconnecting does not reach it either. + internal func isStoppable(_ tabId: UUID) -> Bool { + if unclaimedWork[tabId] != nil { return true } + guard let entry = entries[tabId] else { return false } + return !entry.isUninterruptible + } } diff --git a/TablePro/Core/Execution/TabQueryTasks.swift b/TablePro/Core/Execution/TabQueryTasks.swift new file mode 100644 index 0000000000..20195179cb --- /dev/null +++ b/TablePro/Core/Execution/TabQueryTasks.swift @@ -0,0 +1,83 @@ +// +// TabQueryTasks.swift +// TablePro +// + +import Foundation + +/// Which execution installed a tab's query task. +/// +/// Fetch All is why this is not simply a claim. It extends the result already on screen, so it +/// registers unclaimed work rather than minting a content epoch that would discard its own rows, +/// and it still owns the handle for as long as it runs. +internal enum TabQueryTaskOwner: Hashable, Sendable { + case claim(TabExecutionClaim) + case unclaimedWork(tabId: UUID, token: UUID) + + internal var tabId: UUID { + switch self { + case .claim(let claim): return claim.tabId + case .unclaimedWork(let tabId, _): return tabId + } + } +} + +/// One tab's in-flight query: the cooperative handle, and the driver lease a Stop has to reach. +/// +/// Both are needed, and neither substitutes for the other. `Task.cancel()` is cooperative, so it +/// stops a batch at its next statement boundary and does nothing at all to a single statement +/// blocked in a C call; the lease is what carries the engine's own abort to the handle the +/// statement is running on. +internal struct TabQueryTask { + internal let owner: TabQueryTaskOwner + internal let lease: DriverLeaseOwner + internal let task: Task +} + +/// One query task per tab, shaped after `TabExecutionRegistry` one layer up: the registry owns which +/// navigation owns a tab's result, this owns which one owns the tab's cancellation. +/// +/// It replaces a single handle per window. That handle was what made cancellation window-wide: +/// every start path cancelled whatever it held, whichever tab owned it, so running a query in one +/// tab or opening a table from the sidebar aborted the batch another tab had running and rolled it +/// back, reporting "cancelled by user" over a Stop nobody pressed. +internal struct TabQueryTasks { + private var entries: [UUID: TabQueryTask] = [:] + + internal init() {} + + /// Hands back whatever the tab already held, which the caller has to end: a tab reaching a + /// second execution without its first having retired means the first is still running. + internal mutating func install(_ entry: TabQueryTask) -> TabQueryTask? { + let displaced = entries.updateValue(entry, forKey: entry.owner.tabId) + return displaced?.owner == entry.owner ? nil : displaced + } + + /// Retires by exact owner, so a completion that owns its own tab cannot take down the handle a + /// successor installed on it. Answers whether it was still the owner. + internal mutating func retire(_ owner: TabQueryTaskOwner) -> Bool { + guard entries[owner.tabId]?.owner == owner else { return false } + entries.removeValue(forKey: owner.tabId) + return true + } + + /// Takes the tab's entry whoever installed it, for a Stop, a supersede or a tab close, all of + /// which end that tab's work regardless of which execution started it. + internal mutating func remove(tabId: UUID) -> TabQueryTask? { + entries.removeValue(forKey: tabId) + } + + internal mutating func removeAll() -> [TabQueryTask] { + let all = Array(entries.values) + entries.removeAll() + return all + } + + internal func task(for tabId: UUID) -> Task? { + entries[tabId]?.task + } + + internal func hasTask(for tabId: UUID) -> Bool { + entries[tabId] != nil + } +} diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index 8d60bdef94..9b4458933b 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -211,24 +211,15 @@ extension MCPConnectionBridge { scope: DatabaseScope, table: String, columns: [String], - rows: [[JsonValue]], - cancellation: MCPCancellationToken? + rows: [[JsonValue]] ) async throws -> JsonValue { let databaseType = try await ensureConnected(scope.connectionId) let style = await MainActor.run { PluginMetadataRegistry.shared.snapshot(for: databaseType)?.parameterStyle ?? ParameterStyle.questionMark } - let connectionId = scope.connectionId - - if let cancellation { - await cancellation.onCancel { _ in - await MainActor.run { - try? DatabaseManager.shared.cancelRunningQuery(for: connectionId, reach: .userStop) - } - } - } - + /// No cancel handler: the insert runs under a `.protectedWrite` lease, which nothing may + /// abort, so a request here could only ever have reached another owner's read. let route = await MainActor.run { DatabaseManager.shared.executionRoute(for: scope) } let schema = scope.schema let inserted = try await DatabaseManager.shared.withScopedDriver( diff --git a/TablePro/Core/MCP/Protocol/Tools/DataTools.swift b/TablePro/Core/MCP/Protocol/Tools/DataTools.swift index 1bc6b6be72..ad9711df13 100644 --- a/TablePro/Core/MCP/Protocol/Tools/DataTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/DataTools.swift @@ -378,8 +378,7 @@ public struct InsertRowsTool: MCPToolImplementation { scope: scope, table: table, columns: columns, - rows: rows, - cancellation: context.cancellation + rows: rows ) return .structured(payload) } catch { diff --git a/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift index 45ac558546..f650e5d176 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift @@ -33,6 +33,26 @@ internal struct ObjectCopyRunResult: Sendable { internal let cancelled: Bool internal let createdDatabase: String? + /// The copy ran on a connection whose session already held a transaction, so what it wrote is + /// pending inside it and only the user can commit or roll it back. It reaches the target through + /// `withMetadataDriver`, which is a pooled connection of its own on most engines and the + /// connection's own session driver on the engines that opt out of pooling. + internal let pendingInSessionTransaction: Bool + + internal init( + outcomes: [ObjectCopyObjectOutcome], + rowsCopied: Int, + cancelled: Bool, + createdDatabase: String?, + pendingInSessionTransaction: Bool = false + ) { + self.outcomes = outcomes + self.rowsCopied = rowsCopied + self.cancelled = cancelled + self.createdDatabase = createdDatabase + self.pendingInSessionTransaction = pendingInSessionTransaction + } + /// Counted by object rather than by outcome. A table copied with its structure and its rows /// produces one outcome for each phase, and reporting "2 objects" for one table is how a /// summary comes to overstate what the run did. @@ -70,14 +90,34 @@ internal struct ObjectCopyFailure: Identifiable, Sendable { internal struct ObjectCopyRunner { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCopyRunner") - private let manager: DatabaseManager + private let manager: any ScopedMetadataProviding private let gate: ExecutionGate - internal init(manager: DatabaseManager = .shared, gate: ExecutionGate = ExecutionGateProvider.shared) { + internal init( + manager: any ScopedMetadataProviding = DatabaseManager.shared, + gate: ExecutionGate = ExecutionGateProvider.shared + ) { self.manager = manager self.gate = gate } + /// Who owns the transaction this phase's statements run inside, asked of the target driver + /// inside the same lease that runs them so nothing can open one between the answer and the + /// first statement. + /// + /// The copy reaches its target through `withMetadataDriver`, which is a pooled connection of its + /// own on most engines and the connection's own session driver on the engines that opt out of + /// pooling (DuckDB, PGlite). On those the session can already hold the user's transaction, and a + /// `BEGIN` of the copy's own aborts it on DuckDB while a `COMMIT` of its own commits their + /// pending work. `DataWriteExecutor`, `DatabaseManager+Principals` and + /// `StructureRebuildPlanRunner` ask the same question for the same reason. + nonisolated private static func transactionOwner(of driver: DatabaseDriver) async -> WriteTransactionOwner { + WriteTransactionOwner.resolve( + supportsTransactions: driver.supportsTransactions, + sessionState: await driver.heldSessionTransactionState() + ) + } + internal func run(_ plan: ObjectCopyPlan, progress: ObjectCopyProgress) async throws -> ObjectCopyRunResult { let request = plan.request progress.setTotalRows(plan.estimatedRowTotal) @@ -109,13 +149,15 @@ internal struct ObjectCopyRunner { var outcomes: [ObjectCopyObjectOutcome] = [] var cancelled = false var rowsCopied = 0 + var pendingInSessionTransaction = false func finished() -> ObjectCopyRunResult { ObjectCopyRunResult( outcomes: outcomes, rowsCopied: rowsCopied, cancelled: cancelled, - createdDatabase: createdDatabase + createdDatabase: createdDatabase, + pendingInSessionTransaction: pendingInSessionTransaction ) } @@ -131,6 +173,7 @@ internal struct ObjectCopyRunner { let result = try await runStructure(plan, request: request, progress: progress) outcomes += result.outcomes cancelled = cancelled || result.cancelled + pendingInSessionTransaction = pendingInSessionTransaction || result.joinedSessionTransaction if result.stopped { return finished() } } @@ -146,6 +189,7 @@ internal struct ObjectCopyRunner { let result = try await runDDL(plan.clearGroups, request: request, progress: progress) outcomes += result.outcomes.filter { $0.error != nil } cancelled = cancelled || result.cancelled + pendingInSessionTransaction = pendingInSessionTransaction || result.joinedSessionTransaction if result.stopped { return finished() } } @@ -154,6 +198,7 @@ internal struct ObjectCopyRunner { outcomes += dataOutcomes.outcomes cancelled = cancelled || dataOutcomes.cancelled rowsCopied = dataOutcomes.rowsCopied + pendingInSessionTransaction = pendingInSessionTransaction || dataOutcomes.joinedSessionTransaction if dataOutcomes.stopped { return finished() } } @@ -164,6 +209,7 @@ internal struct ObjectCopyRunner { let result = try await runDDL(plan.afterDataGroups, request: request, progress: progress) outcomes += result.outcomes cancelled = cancelled || result.cancelled + pendingInSessionTransaction = pendingInSessionTransaction || result.joinedSessionTransaction } return finished() @@ -183,6 +229,8 @@ internal struct ObjectCopyRunner { var cancelled = false /// True when the run must not go on to the rows, because the tables they need are missing. var stopped = false + /// True when the phase ran inside a transaction the session already held. + var joinedSessionTransaction = false } /// Every drop and every create, in one scoped call, wrapped where the engine allows it. @@ -206,7 +254,8 @@ internal struct ObjectCopyRunner { guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { throw ObjectCopyError.refused(Self.noTargetDriver) } - let usesTransaction = hasCleanup && plugin.supportsTransactionalDDL + let owner = await Self.transactionOwner(of: driver) + let usesTransaction = hasCleanup && plugin.supportsTransactionalDDL && owner.opensTransaction let relaxesForeignKeys = hasCleanup && !usesTransaction if usesTransaction { try await plugin.beginTransaction(mode: .readWrite) } if relaxesForeignKeys { @@ -222,9 +271,10 @@ internal struct ObjectCopyRunner { _ = try await plugin.execute(query: statement.sql) } - let result = await Self.execute( + var result = await Self.execute( groups, on: plugin, errorHandling: errorHandling, progress: progress ) + result.joinedSessionTransaction = owner == .session if relaxesForeignKeys { for statement in plugin.foreignKeyEnableStatements() ?? [] { @@ -302,9 +352,12 @@ internal struct ObjectCopyRunner { guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { throw ObjectCopyError.refused(Self.noTargetDriver) } - return await Self.execute( + let owner = await Self.transactionOwner(of: driver) + var result = await Self.execute( runnable, on: plugin, errorHandling: errorHandling, progress: progress ) + result.joinedSessionTransaction = owner == .session + return result } } @@ -314,6 +367,8 @@ internal struct ObjectCopyRunner { var outcomes: [ObjectCopyObjectOutcome] = [] var cancelled = false var stopped = false + /// True when the rows went into a transaction the session already held. + var joinedSessionTransaction = false /// Only what was committed. A cancelled table rolls its rows back, so counting what the /// copier inserted reported rows the target never kept. var rowsCopied = 0 @@ -361,10 +416,12 @@ internal struct ObjectCopyRunner { guard let targetPlugin = CompareMetadataService.pluginDriver(from: targetDriver) else { throw ObjectCopyError.refused(Self.noTargetDriver) } - let usesTransaction = targetPlugin.supportsTransactions + let owner = await Self.transactionOwner(of: targetDriver) + let usesTransaction = owner.opensTransaction if usesTransaction { try await targetPlugin.beginTransaction(mode: .readWrite) } var result = DataResult() + result.joinedSessionTransaction = owner == .session let cleared = await Self.execute( clearGroups, on: targetPlugin, errorHandling: errorHandling, progress: progress ) @@ -416,7 +473,7 @@ internal struct ObjectCopyRunner { _ steps: [ObjectCopyTableStep], from sourceScope: DatabaseScope, into targetPlugin: any PluginDatabaseDriver, - manager: DatabaseManager, + manager: any ScopedMetadataProviding, targetType: DatabaseType, errorHandling: ImportErrorHandling, progress: ObjectCopyProgress @@ -495,7 +552,7 @@ internal struct ObjectCopyRunner { && request.errorHandling != .skipAndContinue do { - let outcome = try await copyRows( + let copied = try await copyRows( step, request: request, targetType: targetType, @@ -503,6 +560,9 @@ internal struct ObjectCopyRunner { completedBefore: result.rowsCopied, progress: progress ) + let outcome = copied.outcome + result.joinedSessionTransaction = + result.joinedSessionTransaction || copied.joinedSessionTransaction /// A cancelled table that rolled back neither counts as copied nor reads as an /// object that succeeded. On a target without transactions nothing rolled back, so /// the batches already flushed are in the target and saying otherwise hides them @@ -546,6 +606,12 @@ internal struct ObjectCopyRunner { return result } + /// One table's rows, and whether they went into a transaction the session already held. + private struct CopiedRows: Sendable { + let outcome: ObjectCopyRowCopier.Outcome + let joinedSessionTransaction: Bool + } + private func copyRows( _ step: ObjectCopyTableStep, request: ObjectCopyRequest, @@ -553,7 +619,7 @@ internal struct ObjectCopyRunner { wrapsInTransaction: Bool, completedBefore: Int, progress: ObjectCopyProgress - ) async throws -> ObjectCopyRowCopier.Outcome { + ) async throws -> CopiedRows { let sourceScope = request.source.scope let targetScope = request.target.scope let copier = ObjectCopyRowCopier(step: step, targetDatabaseType: targetType) @@ -572,7 +638,8 @@ internal struct ObjectCopyRunner { guard let targetPlugin = CompareMetadataService.pluginDriver(from: targetDriver) else { throw ObjectCopyError.refused(Self.noTargetDriver) } - let usesTransaction = wrapsInTransaction && targetPlugin.supportsTransactions + let owner = await Self.transactionOwner(of: targetDriver) + let usesTransaction = wrapsInTransaction && owner.opensTransaction if usesTransaction { try await targetPlugin.beginTransaction(mode: .readWrite) } @@ -584,12 +651,16 @@ internal struct ObjectCopyRunner { progress.setRowsForCurrentObject(rows, completedBefore: completedBefore) } guard usesTransaction else { - /// Nothing to roll back, so every batch already flushed is in the target + /// Nothing of the copy's own to roll back, so every batch already flushed is + /// in the target, or pending in the transaction the session already held, /// whether the user stopped or not. - return ObjectCopyRowCopier.Outcome( - inserted: outcome.inserted, - cancelled: outcome.cancelled, - committed: outcome.inserted + return CopiedRows( + outcome: ObjectCopyRowCopier.Outcome( + inserted: outcome.inserted, + cancelled: outcome.cancelled, + committed: outcome.inserted + ), + joinedSessionTransaction: owner == .session ) } if outcome.cancelled { @@ -597,7 +668,7 @@ internal struct ObjectCopyRunner { } else { try await targetPlugin.commitTransaction() } - return outcome + return CopiedRows(outcome: outcome, joinedSessionTransaction: false) } catch { if usesTransaction { if errorHandling == .stopAndCommit { diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 348a39594e..39b28b8bf1 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -356,6 +356,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor var unsupportedIndexTypes: Set { pluginDriver.unsupportedIndexTypes } + var checkConstraintRefusal: String? { pluginDriver.checkConstraintRefusal } + func fetchApproximateRowCount(table: String) async throws -> Int? { try await fetchApproximateRowCount(table: table, schema: nil) } @@ -630,6 +632,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.rollbackTransaction() } + func sessionTransactionState() async -> PluginSessionTransactionState { + await pluginDriver.sessionTransactionState() + } + // MARK: - Schema Switching func switchSchema(to schema: String) async throws { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 5cb65b23d3..25cf521337 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -77,6 +77,12 @@ final class PluginManager: ObservableObject { /// by where its declared spelling names no kind: a PostgreSQL enum, a domain and a PostGIS /// geometry all classify as text otherwise, which takes the value picker off an enum and the /// spatial rendering off a geometry. + /// + /// 33 also adds `checkConstraintRefusal`, which reports why the connected server has no check + /// constraints even though the engine does, and `sessionTransactionState()`, which reports what + /// the session already has open so nothing the app owns wraps a transaction the user opened. + /// Both have defaults (nil and `.unknown`), so an already-built plugin keeps loading and + /// answers them; the minimum stays where it is and no bulk re-release is needed. nonisolated static let currentPluginKitVersion = 33 /// Still 19, so every plugin already published for the previous release keeps loading. diff --git a/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift b/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift index c7bd17f96f..578a8123bf 100644 --- a/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift +++ b/TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift @@ -20,10 +20,13 @@ internal enum SchemaOperationRefusal { case .addIndex(let index), .modifyIndex(_, let index): return driver.schemaOperationRefusal(.addIndex(index.toPlugin())) case .modifyCheckConstraint(let old, let new): + if let refusal = driver.checkConstraintRefusal { return refusal } guard old.expression == new.expression, old.name != new.name else { return nil } return driver.schemaOperationRefusal(.renameCheckConstraint(from: old.name, to: new.name)) + case .addCheckConstraint, .deleteCheckConstraint: + return driver.checkConstraintRefusal case .modifyColumn, .deleteColumn, .deleteIndex, .addForeignKey, .modifyForeignKey, - .deleteForeignKey, .modifyPrimaryKey, .addCheckConstraint, .deleteCheckConstraint: + .deleteForeignKey, .modifyPrimaryKey: return nil } } diff --git a/TablePro/Core/Services/Execution/AutocommitOnlyStatement+MySQL.swift b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+MySQL.swift new file mode 100644 index 0000000000..89822237b7 --- /dev/null +++ b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+MySQL.swift @@ -0,0 +1,61 @@ +// +// AutocommitOnlyStatement+MySQL.swift +// TablePro +// + +import Foundation + +internal extension AutocommitOnlyStatement { + /// Measured on MySQL 8.4.11 with `gtid_mode = ON` and MariaDB 11.4.13 with the binary log on, + /// each statement run after `START TRANSACTION` and an `INSERT`. The errors are 1694, 1679, + /// 1685, 1766, 1953, 1929, 1179, 1192 and 1568 depending on the variable; what they share is + /// that the statement works on its own and fails inside the wrap. + static func matchesMySQLFamily(_ statement: NSString, rules: SQLLexicalRules) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + guard let keyword = cursor.next()?.word else { return false } + switch keyword { + case "SET": + return setsSomethingAutocommitOnly(&cursor) + case "STOP": + return replicationTargets.contains(cursor.next()?.word ?? "") + default: + return false + } + } +} + +private extension AutocommitOnlyStatement { + static let replicationTargets: Set = ["SLAVE", "REPLICA", "ALL"] + + /// The characteristics of the *next* transaction, which is why MySQL answers `ERROR 1568` + /// inside one. Only the bare `@@name` spelling means that: `SET SESSION transaction_isolation` + /// and `SET @@SESSION.transaction_isolation` set the session variable and are allowed. + static let nextTransactionCharacteristics: Set = [ + "TRANSACTION_ISOLATION", "TRANSACTION_READ_ONLY", "TX_ISOLATION", "TX_READ_ONLY" + ] + + static func setsSomethingAutocommitOnly(_ cursor: inout SQLTokenCursor) -> Bool { + if cursor.peek()?.word == "TRANSACTION" { return true } + return SQLSetAssignments.assignments(from: &cursor, readsList: true).contains(where: isAutocommitOnly) + } + + static func isAutocommitOnly(_ assignment: SQLSetAssignment) -> Bool { + if assignment.spelledWithAtAt, assignment.scope == .unspecified, + nextTransactionCharacteristics.contains(assignment.name) { + return true + } + guard let scope = variableScope(of: assignment.scope) else { return false } + return MySQLAutocommitOnlyVariables.refuses(assignment.name, scope: scope) + } + + static func variableScope(of scope: SQLSetAssignment.Scope) -> MySQLVariableScope? { + switch scope { + case .unspecified, .session, .local: + return .session + case .global, .persist: + return .global + case .persistOnly: + return nil + } + } +} diff --git a/TablePro/Core/Services/Execution/AutocommitOnlyStatement+PostgreSQL.swift b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+PostgreSQL.swift new file mode 100644 index 0000000000..c278383791 --- /dev/null +++ b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+PostgreSQL.swift @@ -0,0 +1,256 @@ +// +// AutocommitOnlyStatement+PostgreSQL.swift +// TablePro +// + +import Foundation + +internal extension AutocommitOnlyStatement { + /// PostgreSQL 17.11, each statement run after `BEGIN`. Redshift and CockroachDB take the + /// PostgreSQL rules plus their own: Redshift's from the `SVL_MULTI_STATEMENT_VIOLATIONS` page, + /// CockroachDB's measured on v25.2.23. `GRANT` and `COPY` are deliberately left out of the + /// Redshift set: both are routinely run inside transactions and the page does not say which + /// forms it means. + static func matchesPostgresFamily( + _ statement: NSString, + family: TransactionEngineFamily, + rules: SQLLexicalRules + ) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + guard let keyword = cursor.next()?.word else { return false } + switch keyword { + case "VACUUM": + return true + case "CLUSTER": + return clustersEveryRelation(&cursor) + case "REINDEX": + return reindexesOutsideATransaction(&cursor) + case "DISCARD": + return cursor.next()?.word == "ALL" + case "COMMIT", "ROLLBACK": + return cursor.next()?.word == "PREPARED" + case "CREATE": + return creates(&cursor, family: family) + case "DROP": + return drops(&cursor, family: family) + case "ALTER": + return alters(&cursor, family: family) + case "SET": + return family == .cockroach && cursor.next()?.word == "CLUSTER" && cursor.next()?.word == "SETTING" + case "BACKUP", "RESTORE", "IMPORT": + return family == .cockroach && !mentions("DETACHED", in: &cursor) + default: + return false + } + } +} + +private extension AutocommitOnlyStatement { + struct StatementOption { + let name: String + let value: String? + + var isOn: Bool { + guard let value else { return true } + return !offValues.contains(value) + } + } + + static let offValues: Set = ["FALSE", "OFF", "0"] + + static func creates(_ cursor: inout SQLTokenCursor, family: TransactionEngineFamily) -> Bool { + guard let object = cursor.next()?.word else { return false } + switch object { + case "DATABASE", "TABLESPACE": + return true + case "SUBSCRIPTION": + return !subscriptionSkipsTheServer(&cursor) + case "UNIQUE": + return cursor.next()?.word == "INDEX" && cursor.next()?.word == "CONCURRENTLY" + case "INDEX": + return cursor.next()?.word == "CONCURRENTLY" + case "EXTERNAL": + return family == .redshift && cursor.next()?.word == "TABLE" + case "LIBRARY": + return family == .redshift + case "OR": + return family == .redshift && cursor.next()?.word == "REPLACE" && cursor.next()?.word == "LIBRARY" + default: + return false + } + } + + static func drops(_ cursor: inout SQLTokenCursor, family: TransactionEngineFamily) -> Bool { + guard let object = cursor.next()?.word else { return false } + switch object { + case "DATABASE", "TABLESPACE", "SUBSCRIPTION": + return true + case "INDEX": + return cursor.next()?.word == "CONCURRENTLY" + case "EXTERNAL": + return family == .redshift && cursor.next()?.word == "TABLE" + case "LIBRARY": + return family == .redshift + default: + return false + } + } + + static func alters(_ cursor: inout SQLTokenCursor, family: TransactionEngineFamily) -> Bool { + guard let object = cursor.next()?.word else { return false } + switch object { + case "SYSTEM": + return true + case "DATABASE": + return cursor.next()?.identifier != nil + && cursor.next()?.word == "SET" + && cursor.next()?.word == "TABLESPACE" + case "SUBSCRIPTION": + return altersSubscriptionOutsideATransaction(&cursor) + case "TYPE": + return mentionsSequence(["ADD", "VALUE"], in: &cursor) + case "TABLE": + return detachesAPartitionConcurrently(&cursor, family: family) + case "EXTERNAL": + return family == .redshift && cursor.next()?.word == "TABLE" + default: + return false + } + } + + /// `ALTER TABLE p DETACH PARTITION p1 CONCURRENTLY` answers "ALTER TABLE ... DETACH + /// CONCURRENTLY cannot run inside a transaction block"; the same statement without + /// `CONCURRENTLY` is fine. Redshift's `ALTER TABLE t APPEND FROM s` is restricted whole. + static func detachesAPartitionConcurrently( + _ cursor: inout SQLTokenCursor, + family: TransactionEngineFamily + ) -> Bool { + while let token = cursor.next() { + guard cursor.parenDepth == 0, let word = token.word else { continue } + if family == .redshift, word == "APPEND" { return true } + guard word == "DETACH", cursor.next()?.word == "PARTITION" else { continue } + guard cursor.next()?.identifier != nil else { return false } + return cursor.next()?.word == "CONCURRENTLY" + } + return false + } + + /// `CREATE SUBSCRIPTION` connects to the publisher and creates a replication slot unless it is + /// told not to, and either of those is what it cannot do inside a transaction block. + static func subscriptionSkipsTheServer(_ cursor: inout SQLTokenCursor) -> Bool { + let options = withOptions(in: &cursor) + return options.contains { ($0.name == "CONNECT" || $0.name == "CREATE_SLOT") && !$0.isOn } + } + + static func altersSubscriptionOutsideATransaction(_ cursor: inout SQLTokenCursor) -> Bool { + guard cursor.next()?.identifier != nil, let action = cursor.next()?.word else { return false } + switch action { + case "REFRESH": + return true + case "ADD", "DROP": + return cursor.next()?.word == "PUBLICATION" && refreshesThePublisher(&cursor) + case "SET": + return setsPublicationOrFailover(&cursor) + default: + return false + } + } + + static func setsPublicationOrFailover(_ cursor: inout SQLTokenCursor) -> Bool { + guard let token = cursor.next() else { return false } + if token.isSymbol(SQLTokenCursor.openParen) { + return readOptions(in: &cursor).contains { $0.name == "FAILOVER" } + } + return token.word == "PUBLICATION" && refreshesThePublisher(&cursor) + } + + static func refreshesThePublisher(_ cursor: inout SQLTokenCursor) -> Bool { + !withOptions(in: &cursor).contains { $0.name == "REFRESH" && !$0.isOn } + } + + /// `REINDEX SCHEMA`, `DATABASE` and `SYSTEM` are refused whole; `INDEX` and `TABLE` only with + /// `CONCURRENTLY`, spelled either as the keyword or as an option that is not turned off. + static func reindexesOutsideATransaction(_ cursor: inout SQLTokenCursor) -> Bool { + var token = cursor.next() + if token?.isSymbol(SQLTokenCursor.openParen) == true { + if readOptions(in: &cursor).contains(where: { $0.name == "CONCURRENTLY" && $0.isOn }) { + return true + } + token = cursor.next() + } + guard let object = token?.word else { return false } + if ["SCHEMA", "DATABASE", "SYSTEM"].contains(object) { return true } + guard object == "INDEX" || object == "TABLE" else { return false } + return cursor.next()?.word == "CONCURRENTLY" + } + + /// A bare `CLUSTER`, with or without `VERBOSE`, clusters every table that has ever been + /// clustered. Naming one keeps the wrap, and whether the server then refuses it depends on the + /// catalog rather than on the text. + static func clustersEveryRelation(_ cursor: inout SQLTokenCursor) -> Bool { + while let token = cursor.next() { + if token.isSymbol(SQLTokenCursor.openParen) { + skipToEndOfOptions(&cursor) + continue + } + guard token.word == "VERBOSE" else { return false } + } + return true + } + + static func withOptions(in cursor: inout SQLTokenCursor) -> [StatementOption] { + while let token = cursor.next() { + guard cursor.parenDepth == 0, token.word == "WITH" else { continue } + guard cursor.next()?.isSymbol(SQLTokenCursor.openParen) == true else { continue } + return readOptions(in: &cursor) + } + return [] + } + + /// Reads a parenthesised option list whose opening parenthesis the caller has consumed. An + /// option is a name, optionally followed by a value with or without `=`. + static func readOptions(in cursor: inout SQLTokenCursor) -> [StatementOption] { + var options: [StatementOption] = [] + var name: String? + var value: String? + while let token = cursor.next() { + if cursor.parenDepth == 0 { break } + if token.isSymbol(SQLTokenCursor.comma) { + if let name { options.append(StatementOption(name: name, value: value)) } + name = nil + value = nil + continue + } + if token.isSymbol(SQLTokenCursor.equals) { continue } + guard let word = token.identifier else { continue } + if name == nil { + name = word + } else if value == nil { + value = word + } + } + if let name { options.append(StatementOption(name: name, value: value)) } + return options + } + + static func skipToEndOfOptions(_ cursor: inout SQLTokenCursor) { + while cursor.parenDepth > 0, cursor.next() != nil {} + } + + static func mentions(_ word: String, in cursor: inout SQLTokenCursor) -> Bool { + while let token = cursor.next() { + if token.word == word { return true } + } + return false + } + + static func mentionsSequence(_ words: [String], in cursor: inout SQLTokenCursor) -> Bool { + guard let first = words.first else { return false } + while let token = cursor.next() { + guard cursor.parenDepth == 0, token.word == first else { continue } + guard words.dropFirst().allSatisfy({ cursor.next()?.word == $0 }) else { continue } + return true + } + return false + } +} diff --git a/TablePro/Core/Services/Execution/AutocommitOnlyStatement+SQLServer.swift b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+SQLServer.swift new file mode 100644 index 0000000000..4afc1b42c4 --- /dev/null +++ b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+SQLServer.swift @@ -0,0 +1,53 @@ +// +// AutocommitOnlyStatement+SQLServer.swift +// TablePro +// + +import Foundation + +internal extension AutocommitOnlyStatement { + /// From the T-SQL transaction locking and row versioning guide, which lists the statements an + /// explicit transaction cannot hold: `CREATE`, `ALTER` and `DROP DATABASE`, the full-text + /// catalog and index statements, `BACKUP`, `RESTORE` and `RECONFIGURE`. Unmeasured: there is no + /// SQL Server here. The statements that begin `CREATE DATABASE` without being one, such as + /// `CREATE DATABASE SCOPED CREDENTIAL`, keep the wrap. + static func matchesSQLServer(_ statement: NSString, rules: SQLLexicalRules) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + guard let keyword = cursor.next()?.word else { return false } + switch keyword { + case "RECONFIGURE": + return true + case "BACKUP": + return backupTargets.contains(cursor.next()?.word ?? "") + case "RESTORE": + return restoreTargets.contains(cursor.next()?.word ?? "") + case "CREATE", "ALTER", "DROP": + return definesADatabaseOrFullTextObject(&cursor) + default: + return false + } + } +} + +private extension AutocommitOnlyStatement { + static let backupTargets: Set = ["DATABASE", "LOG"] + + static let restoreTargets: Set = [ + "DATABASE", "LOG", "HEADERONLY", "FILELISTONLY", "LABELONLY", "VERIFYONLY", "REWINDONLY" + ] + + static let fullTextTargets: Set = ["CATALOG", "INDEX"] + + /// The objects whose statements only start with `DATABASE`: a database-scoped configuration or + /// credential, a database audit specification and a database encryption key are all ordinary + /// statements an explicit transaction can hold. + static let databaseScopedObjects: Set = ["SCOPED", "AUDIT", "ENCRYPTION"] + + static func definesADatabaseOrFullTextObject(_ cursor: inout SQLTokenCursor) -> Bool { + guard let object = cursor.next()?.word else { return false } + if object == "FULLTEXT" { return fullTextTargets.contains(cursor.next()?.word ?? "") } + guard object == "DATABASE" else { return false } + guard let following = cursor.next()?.word else { return true } + return !databaseScopedObjects.contains(following) + } +} diff --git a/TablePro/Core/Services/Execution/AutocommitOnlyStatement+SQLite.swift b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+SQLite.swift new file mode 100644 index 0000000000..fea4d48f59 --- /dev/null +++ b/TablePro/Core/Services/Execution/AutocommitOnlyStatement+SQLite.swift @@ -0,0 +1,63 @@ +// +// AutocommitOnlyStatement+SQLite.swift +// TablePro +// + +import Foundation + +internal extension AutocommitOnlyStatement { + /// SQLite 3.54.0, each statement run after `BEGIN`. `VACUUM` and a journal-mode or safety-level + /// change are refused outright; `PRAGMA foreign_keys` is the quiet one, applying no change and + /// reading back `0` after the `COMMIT` with no error at any point. `wal_checkpoint` is refused + /// once the transaction has run anything, in every form. + static func matchesSQLite(_ statement: NSString, rules: SQLLexicalRules) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + guard let keyword = cursor.next()?.word else { return false } + switch keyword { + case "VACUUM", "DETACH": + return true + case "PRAGMA": + return pragmaIsAutocommitOnly(&cursor) + default: + return false + } + } + + /// DuckDB v1.5.4. It takes `VACUUM`, `ATTACH`, `SET` and every `PRAGMA` inside a transaction, + /// so it shares none of SQLite's rules despite sharing its lexing dialect. What it refuses is a + /// checkpoint and a detach of a database the transaction has touched. + static func matchesDuckDB(_ statement: NSString, rules: SQLLexicalRules) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + guard let keyword = cursor.next()?.word else { return false } + switch keyword { + case "DETACH", "CHECKPOINT": + return true + case "FORCE": + return cursor.next()?.word == "CHECKPOINT" + case "CALL": + return checkpointRoutines.contains(cursor.next()?.word ?? "") + && cursor.next()?.isSymbol(SQLTokenCursor.openParen) == true + default: + return false + } + } +} + +private extension AutocommitOnlyStatement { + static let transactionScopedPragmas: Set = ["JOURNAL_MODE", "FOREIGN_KEYS", "SYNCHRONOUS"] + + static let checkpointRoutines: Set = ["CHECKPOINT", "FORCE_CHECKPOINT"] + + static func pragmaIsAutocommitOnly(_ cursor: inout SQLTokenCursor) -> Bool { + guard var name = cursor.next()?.identifier else { return false } + var following = cursor.next() + if following?.isSymbol(SQLTokenCursor.period) == true { + guard let qualified = cursor.next()?.identifier else { return false } + name = qualified + following = cursor.next() + } + if name == "WAL_CHECKPOINT" { return true } + guard transactionScopedPragmas.contains(name), let following else { return false } + return following.isSymbol(SQLTokenCursor.equals) || following.isSymbol(SQLTokenCursor.openParen) + } +} diff --git a/TablePro/Core/Services/Execution/AutocommitOnlyStatement.swift b/TablePro/Core/Services/Execution/AutocommitOnlyStatement.swift new file mode 100644 index 0000000000..e715f8df40 --- /dev/null +++ b/TablePro/Core/Services/Execution/AutocommitOnlyStatement.swift @@ -0,0 +1,51 @@ +// +// AutocommitOnlyStatement.swift +// TablePro +// + +import Foundation + +/// Whether the engine refuses this statement, or silently ignores it, inside a transaction block. +/// +/// Measured rather than guessed: PostgreSQL answers "VACUUM cannot run inside a transaction block", +/// SQLite "cannot VACUUM from within a transaction", MySQL `ERROR 1694` on the `SET +/// @@SESSION.SQL_LOG_BIN = 0` a GTID mysqldump writes on line 18, DuckDB "Cannot CHECKPOINT", and +/// SQLite applies `PRAGMA foreign_keys = ON` inside one and reads back `0` afterwards with no error +/// at all. Run on its own each of them works, because a single statement is never wrapped. +/// +/// A batch holding one runs in autocommit. The rules are per engine family and not per dialect, +/// because the lexing dialect files DuckDB with SQLite and their answers differ. +/// +/// A family the app cannot open a transaction on at all answers `false` for every statement, +/// because "refused inside a transaction block" has no meaning where there is never a block. +/// ``BatchTransactionPolicy`` decides those families before it reads a statement. +internal enum AutocommitOnlyStatement { + internal static func matches( + _ statement: String, + family: TransactionEngineFamily, + rules: SQLLexicalRules + ) -> Bool { + matches(statement as NSString, family: family, rules: rules) + } + + internal static func matches( + _ statement: NSString, + family: TransactionEngineFamily, + rules: SQLLexicalRules + ) -> Bool { + switch family { + case .postgres, .redshift, .cockroach: + return matchesPostgresFamily(statement, family: family, rules: rules) + case .mysql: + return matchesMySQLFamily(statement, rules: rules) + case .sqlite: + return matchesSQLite(statement, rules: rules) + case .duckdb: + return matchesDuckDB(statement, rules: rules) + case .sqlServer: + return matchesSQLServer(statement, rules: rules) + case .redis, .other: + return false + } + } +} diff --git a/TablePro/Core/Services/Execution/BatchCommitPoint.swift b/TablePro/Core/Services/Execution/BatchCommitPoint.swift new file mode 100644 index 0000000000..c02597a303 --- /dev/null +++ b/TablePro/Core/Services/Execution/BatchCommitPoint.swift @@ -0,0 +1,92 @@ +// +// BatchCommitPoint.swift +// TablePro +// + +import Foundation + +/// The claim questions a batch asks while it runs, handed in so the order they are asked in is +/// testable without a window, a tab or a server. +/// +/// `enterCommitPhase` answers the same thing `isCurrent` does and marks the claim in the same +/// synchronous step, which is what makes the Stop check atomic. `leaveCommitPhase` puts the batch +/// back within reach of Stop. +@MainActor +internal struct BatchClaimGate { + internal let isCurrent: @MainActor () -> Bool + internal let enterCommitPhase: @MainActor () -> Bool + internal let leaveCommitPhase: @MainActor () -> Void +} + +/// What the run does with the mark once the server has answered. +internal enum BatchCommitPhaseExit: Equatable { + /// The batch has more statements to run, so it becomes stoppable again. + case resumes + /// The batch is over and the mark stands until the claim settles. A Stop between the server's + /// answer and the settle would otherwise drop the results of work already committed, which is + /// the whole defect. + case holdsUntilSettled +} + +/// Why a commit did not report a clean success. +internal struct BatchCommitFailure { + internal let errorDescription: String + /// Whether the connection died before the server could answer. Nothing here knows whether the + /// transaction took, so nothing here may claim it was rolled back. + internal let outcomeIsUnknown: Bool +} + +internal enum BatchCommitOutcome { + case stopped + case committed(Value) + case failed(BatchCommitFailure) +} + +/// The batch's point of no return, and the only place a statement the app must see through is sent. +/// +/// Three things happen in one synchronous stretch, in this order: the driver is registered as a +/// protected write so `cancelRunningQuery` cannot reach its handle, Stop is asked once, and the +/// claim is marked so a Stop arriving later keeps it. Both types are `@MainActor` and Stop runs on +/// main, so a Stop lands either wholly before that stretch or wholly after it. Before, the claim is +/// gone and the caller rolls back. After, the cancel skips the handle, the mark keeps the claim, +/// and the shield keeps task cancellation off the statement itself. +/// +/// What this cannot do is stop a commit the server is already working on. Measured on MySQL 8.4.11: +/// a `KILL QUERY` on a commit blocked by `FLUSH TABLES WITH READ LOCK` rolled it back with error +/// 1317, but the same kill on a commit waiting inside `binlog_group_commit_sync_delay` was ignored +/// and the transaction committed, and on PostgreSQL 17.11 cancelling a commit waiting on a missing +/// synchronous standby returned "the transaction has already committed locally". A commit waiting +/// on the server therefore ends when the server says so, and the tab reports that answer. +@MainActor +internal enum BatchCommitPoint { + internal static func run( + driver: DatabaseDriver, + connectionId: UUID, + gate: BatchClaimGate, + exit: BatchCommitPhaseExit, + commit: @escaping @Sendable () async throws -> Value + ) async -> BatchCommitOutcome { + let token = DatabaseManager.shared.beginProtectedWrite(on: driver, for: connectionId) + defer { DatabaseManager.shared.endProtectedWrite(token, for: connectionId) } + + guard !Task.isCancelled, gate.enterCommitPhase() else { return .stopped } + do { + let value = try await TaskCancellationShield.run(commit) + leaveIfResuming(exit, gate: gate) + return .committed(value) + } catch { + leaveIfResuming(exit, gate: gate) + return .failed( + BatchCommitFailure( + errorDescription: error.localizedDescription, + outcomeIsUnknown: CommitOutcomeDiagnosis.isConnectionLoss(error) || driver.hasLostConnection + ) + ) + } + } + + private static func leaveIfResuming(_ exit: BatchCommitPhaseExit, gate: BatchClaimGate) { + guard exit == .resumes else { return } + gate.leaveCommitPhase() + } +} diff --git a/TablePro/Core/Services/Execution/BatchCommitStatement.swift b/TablePro/Core/Services/Execution/BatchCommitStatement.swift new file mode 100644 index 0000000000..31f2cfe45c --- /dev/null +++ b/TablePro/Core/Services/Execution/BatchCommitStatement.swift @@ -0,0 +1,44 @@ +// +// BatchCommitStatement.swift +// TablePro +// + +import Foundation + +/// Whether one statement of a batch is the script's own point of no return. +/// +/// A script that manages its own transaction has the same defect the app's wrap had: its `COMMIT` +/// used to run under the ordinary cancellable lease, so a Stop that landed on it either killed the +/// commit or arrived too late and reported the batch as stopped over work the server had kept. +/// ``BatchStatementRun`` routes a statement this matches through ``BatchCommitPoint`` instead, and +/// leaves the phase again afterwards so the statements after it stay stoppable. +/// +/// Read from the statement's first two words rather than from the plan, because the plan cannot +/// see them: `BatchTransactionPolicy` answers `.scriptTransaction` for the whole batch as soon as +/// one statement opens a transaction, and says nothing about which statement ends it. +/// +/// The ambiguity is resolved toward protecting. `END` closes a control-flow block on MySQL and +/// SQL Server and commits on PostgreSQL and SQLite, and protecting one that was not a commit costs +/// a single statement of Stop being unavailable, while missing one that was is the defect itself. +/// `END IF`, `END LOOP` and the rest are excluded because they are never a transaction's end. +internal enum BatchCommitStatement { + private static let transactionNouns: Set = ["TRANSACTION", "WORK"] + + internal static func matches(_ statement: NSString, rules: SQLLexicalRules) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + guard let keyword = cursor.next()?.word else { return false } + switch keyword { + case "COMMIT": + return true + case "END": + guard let follower = cursor.next()?.word else { return true } + return transactionNouns.contains(follower) + default: + return false + } + } + + internal static func matches(_ statement: String, rules: SQLLexicalRules) -> Bool { + matches(statement as NSString, rules: rules) + } +} diff --git a/TablePro/Core/Services/Execution/BatchStatementRun.swift b/TablePro/Core/Services/Execution/BatchStatementRun.swift new file mode 100644 index 0000000000..7f291d05d3 --- /dev/null +++ b/TablePro/Core/Services/Execution/BatchStatementRun.swift @@ -0,0 +1,185 @@ +// +// BatchStatementRun.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +private let batchLog = Logger(subsystem: "com.TablePro", category: "BatchStatementRun") + +/// What a multi-statement run left behind. The results travel out of the lease so the tab, the +/// history and the error sheet are updated after the driver is released. +/// +/// A stopped run carries the results of the statements that already ran when the plan cannot take +/// them back, so their rows and their history are kept rather than dropped. +internal enum BatchStatementOutcome { + case completed(results: [QueryResult]) + case failed(results: [QueryResult], failure: MultiStatementFailure, errorDescription: String) + case cancelled(results: [QueryResult]) +} + +/// The driver calls one multi-statement run makes, in order, for one ``BatchTransactionPlan``. +/// +/// Extracted from the coordinator so the order is testable without a window, a tab or a server: +/// which plan begins a transaction, which commits, and which rolls back after a failure or a Stop +/// are the whole of the behaviour, and every one of them used to be reachable only through the UI. +/// +/// Every commit and every rollback goes through ``BatchCommitPoint``, which registers the handle as +/// a protected write and shields the statement from task cancellation. The statements themselves +/// stay cancellable: a Stop between two of them is the one that can still take work back. +@MainActor +internal enum BatchStatementRun { + internal static func run( + _ statements: [Statement], + plan: BatchTransactionPlan, + mode: PluginTransactionAccessMode, + driver: DatabaseDriver, + connectionId: UUID, + gate: BatchClaimGate, + failureSQL: (Statement) -> String, + isCommitPoint: (Statement) -> Bool, + execute: @escaping @MainActor @Sendable (Statement) async throws -> QueryResult + ) async -> BatchStatementOutcome { + let opensTransaction = plan.opensTransaction && driver.supportsTransactions + if opensTransaction { + do { + try await driver.beginTransaction(mode: mode) + } catch { + return .failed(results: [], failure: .transactionStart, errorDescription: error.localizedDescription) + } + } + + var results: [QueryResult] = [] + for statement in statements { + guard !Task.isCancelled, gate.isCurrent() else { + await rollback(driver: driver, connectionId: connectionId, plan: plan, opensTransaction: opensTransaction) + return .cancelled(results: plan.keepsExecutedStatements ? results : []) + } + let ran = await runStatement( + statement, + isCommitPoint: isCommitPoint(statement), + driver: driver, + connectionId: connectionId, + gate: gate, + execute: execute + ) + switch ran { + case .stopped: + await rollback(driver: driver, connectionId: connectionId, plan: plan, opensTransaction: opensTransaction) + return .cancelled(results: plan.keepsExecutedStatements ? results : []) + case .committed(let result): + results.append(result) + case .failed(let failure) where failure.outcomeIsUnknown: + return .failed( + results: results, + failure: .commitOutcomeUnknown, + errorDescription: failure.errorDescription + ) + case .failed(let failure): + await rollback(driver: driver, connectionId: connectionId, plan: plan, opensTransaction: opensTransaction) + return .failed( + results: results, + failure: .statement(sql: failureSQL(statement)), + errorDescription: failure.errorDescription + ) + } + } + + guard opensTransaction else { return .completed(results: results) } + return await commit(results: results, driver: driver, connectionId: connectionId, gate: gate, plan: plan) + } + + /// A statement the script wrote itself is ordinary work unless it is the script's own commit, + /// which is as final as the app's own and goes through the same protection. The batch leaves + /// the phase again afterwards, so everything after it stays stoppable. + private static func runStatement( + _ statement: Statement, + isCommitPoint: Bool, + driver: DatabaseDriver, + connectionId: UUID, + gate: BatchClaimGate, + execute: @escaping @MainActor @Sendable (Statement) async throws -> QueryResult + ) async -> BatchCommitOutcome { + guard isCommitPoint else { + do { + return .committed(try await execute(statement)) + } catch { + return .failed( + BatchCommitFailure( + errorDescription: error.localizedDescription, + outcomeIsUnknown: false + ) + ) + } + } + return await BatchCommitPoint.run( + driver: driver, + connectionId: connectionId, + gate: gate, + exit: .resumes + ) { @Sendable in try await execute(statement) } + } + + /// The app's own commit, and the last thing the run does. The mark it takes is held until the + /// claim settles rather than released here: between the server's answer and the settle there is + /// no statement left to stop, and a Stop landing in that gap would drop the results of work the + /// server has already kept. + private static func commit( + results: [QueryResult], + driver: DatabaseDriver, + connectionId: UUID, + gate: BatchClaimGate, + plan: BatchTransactionPlan + ) async -> BatchStatementOutcome { + let committed = await BatchCommitPoint.run( + driver: driver, + connectionId: connectionId, + gate: gate, + exit: .holdsUntilSettled + ) { @Sendable in try await driver.commitTransaction() } + + switch committed { + case .stopped: + await rollback(driver: driver, connectionId: connectionId, plan: plan, opensTransaction: true) + return .cancelled(results: plan.keepsExecutedStatements ? results : []) + case .committed: + return .completed(results: results) + case .failed(let failure) where failure.outcomeIsUnknown: + return .failed(results: results, failure: .commitOutcomeUnknown, errorDescription: failure.errorDescription) + case .failed(let failure): + await rollback(driver: driver, connectionId: connectionId, plan: plan, opensTransaction: true) + return .failed(results: results, failure: .commit, errorDescription: failure.errorDescription) + } + } + + /// A run in autocommit opened nothing, so it has nothing of its own to take back and a rollback + /// here could only reach a transaction the user opened before the run started. + /// + /// Protected and shielded like a commit, because a Stop is normally what asks for it: measured + /// in a swiftc probe, a rollback issued inside an already-cancelled task fires the driver's own + /// cancel handler before the statement is sent, and Dameng never sends it at all. + /// + /// A commit whose outcome is unknown never gets here. Nothing on the other end can be asked, so + /// a rollback there would be a claim rather than an action. + private static func rollback( + driver: DatabaseDriver, + connectionId: UUID, + plan: BatchTransactionPlan, + opensTransaction: Bool + ) async { + guard plan.rollsBackAfterStop, driver.supportsTransactions else { return } + let token = DatabaseManager.shared.beginProtectedWrite(on: driver, for: connectionId) + defer { DatabaseManager.shared.endProtectedWrite(token, for: connectionId) } + do { + try await TaskCancellationShield.run { @Sendable in try await driver.rollbackTransaction() } + } catch { + guard opensTransaction else { + batchLog.debug("No open script transaction to roll back: \(error.publicLogShape, privacy: .public)") + return + } + batchLog.error("Rollback failed: \(error.publicLogShape, privacy: .public)") + } + } +} diff --git a/TablePro/Core/Services/Execution/BatchTransactionPlan.swift b/TablePro/Core/Services/Execution/BatchTransactionPlan.swift new file mode 100644 index 0000000000..cb9d4df71f --- /dev/null +++ b/TablePro/Core/Services/Execution/BatchTransactionPlan.swift @@ -0,0 +1,73 @@ +// +// BatchTransactionPlan.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// How a multi-statement run treats the transaction around it. +/// +/// `.autocommit` is the answer for a batch holding a statement the engine refuses, or silently +/// ignores, inside a transaction block: `VACUUM`, `CREATE INDEX CONCURRENTLY`, `SET sql_log_bin`, +/// `PRAGMA foreign_keys`. Such a batch opens nothing, commits nothing, and rolls nothing back, +/// because there is no transaction of its own to roll back to. +/// +/// `.sessionTransaction` is the answer the text cannot reach: it comes from the driver, once the +/// lease is held, through ``joining(_:)``. +/// +/// The switches over this are exhaustive on purpose. No case may inherit `.appTransaction`'s begin +/// or `.scriptTransaction`'s rollback by falling into a `default:` arm. +internal enum BatchTransactionPlan: Equatable, Sendable { + /// The app opens the transaction, commits it, and rolls it back on a failure or a Stop. + case appTransaction + /// The script opens its own transaction. The app opens none and still rolls back what a failed + /// script left open. + case scriptTransaction + /// Every statement commits as it runs. + case autocommit + /// The transaction, or the table lock, is the session's own, so the run touches neither: no + /// `BEGIN`, no `COMMIT`, no `ROLLBACK`. The user's text decides instead, and a joined script + /// ending in `COMMIT` commits. + case sessionTransaction + + internal var opensTransaction: Bool { + switch self { + case .appTransaction: + return true + case .scriptTransaction, .autocommit, .sessionTransaction: + return false + } + } + + internal var rollsBackAfterStop: Bool { + switch self { + case .appTransaction, .scriptTransaction: + return true + case .autocommit, .sessionTransaction: + return false + } + } + + /// Whether the statements that ran before a failure or a Stop stay in place. The failure banner + /// says so, and a stopped run keeps their results and their history rather than dropping them. + internal var keepsExecutedStatements: Bool { + !rollsBackAfterStop + } + + /// The plan the run actually uses, once the driver has been leased and asked what its session + /// is holding. + /// + /// The text alone cannot see a `BEGIN` the user ran with Cmd+Enter, a `SET autocommit = 0`, a + /// `LOCK TABLES` or an MCP client's `begin`, and every engine measured answers an app-owned + /// `BEGIN` over one of those by committing, discarding or aborting the user's work. + /// + /// A session that answers `.unknown` keeps the wrap for a plain batch, but a self-managed + /// script stops being rolled back: the transaction its text left open may predate the run, and + /// rolling that back discards work the run never did. + internal func joining(_ session: PluginSessionTransactionState) -> BatchTransactionPlan { + guard session.permitsAppTransaction else { return .sessionTransaction } + guard session == .unknown, self == .scriptTransaction else { return self } + return .sessionTransaction + } +} diff --git a/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift b/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift index 64d1d1a86e..e19516bda0 100644 --- a/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift +++ b/TablePro/Core/Services/Execution/BatchTransactionPolicy.swift @@ -4,69 +4,111 @@ // import Foundation -import TableProPluginKit +/// How the app treats the transaction around a multi-statement run, decided from the text alone +/// before any driver is leased. +/// +/// Three answers, in precedence order. A script that opens a transaction of its own or turns the +/// commit mode off manages its own, so the app opens none. A script holding a statement the engine +/// refuses inside a transaction block runs in autocommit. Everything else is wrapped, which is what +/// gives a failed batch its rollback. +/// +/// An engine the app cannot open a transaction on at all +/// (``TransactionEngineFamily/wrapsBatchInTransaction``) never reaches the SQL rules, because they +/// are not its vocabulary: on Redis `SET autocommit 1` is a key write, not commit-mode control. internal enum BatchTransactionPolicy { - private static let sessionScopes: Set = ["SESSION", "LOCAL", "@@SESSION", "@@LOCAL"] - private static let commitModeVariables: Set = ["AUTOCOMMIT", "IMPLICIT_TRANSACTIONS"] - private static let wordsNeeded = 3 + private static let commitModeScopes: Set = [.unspecified, .session, .local] - static func wrapsInTransaction(_ statements: [String], dialect: SqlDialect) -> Bool { - !statements.contains { takesTransactionControl($0, dialect: dialect) } + /// T-SQL writes no `=`: `SET IMPLICIT_TRANSACTIONS ON` and `SET ANSI_NULLS, + /// IMPLICIT_TRANSACTIONS ON` are a comma list of option names followed by `ON` or `OFF`. + /// `ANSI_DEFAULTS` turns `IMPLICIT_TRANSACTIONS` on with it. + private static let implicitTransactionOptions: Set = ["IMPLICIT_TRANSACTIONS", "ANSI_DEFAULTS"] + + internal static func plan( + for statements: [String], + databaseType: DatabaseType, + rules: SQLLexicalRules + ) -> BatchTransactionPlan { + let family = TransactionEngineFamily.of(databaseType) + guard family.wrapsBatchInTransaction else { return queuedBlockPlan(for: statements, rules: rules) } + var runsInAutocommit = false + var holdsASavepoint = false + for statement in statements { + let text = statement as NSString + if takesTransactionControl(text, family: family, rules: rules) { return .scriptTransaction } + if !runsInAutocommit, AutocommitOnlyStatement.matches(text, family: family, rules: rules) { + runsInAutocommit = true + } + if family.savepointOpensTransaction, !holdsASavepoint, startsWithSavepoint(text, rules: rules) { + holdsASavepoint = true + } + } + guard runsInAutocommit else { return .appTransaction } + return holdsASavepoint ? .scriptTransaction : .autocommit } - private static func takesTransactionControl(_ statement: String, dialect: SqlDialect) -> Bool { - let words = leadingWords(of: statement) - guard let first = words.first else { return false } - let following = words.dropFirst().first - switch first { + /// The app opens nothing here, so the only question left is whether the script opens a block of + /// its own. A `MULTI` does, and a batch that fails or is stopped inside one has to end it: + /// nothing in the block has run, and the next command on that session would be queued into it + /// rather than answered. The arm is unconditional because no Redis command reads `MULTI` as + /// anything else. + private static func queuedBlockPlan( + for statements: [String], + rules: SQLLexicalRules + ) -> BatchTransactionPlan { + let opensABlock = statements.contains { statement in + var cursor = SQLTokenCursor(statement as NSString, rules: rules) + return cursor.next()?.word == "MULTI" + } + return opensABlock ? .scriptTransaction : .autocommit + } + + private static func takesTransactionControl( + _ statement: NSString, + family: TransactionEngineFamily, + rules: SQLLexicalRules + ) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + guard let keyword = cursor.next()?.word else { return false } + switch keyword { case "BEGIN": - return SqlBlockStructure.beginStartsTransaction(followedBy: following) + return SqlBlockStructure.beginStartsTransaction(followedBy: cursor.next()?.word) case "START": - return following == "TRANSACTION" + return cursor.next()?.word == "TRANSACTION" case "XA": + let following = cursor.next()?.word return following == "START" || following == "BEGIN" case "SET": - return setsTransactionControl(Array(words.dropFirst()), dialect: dialect) + return setsCommitMode(&cursor, family: family) default: return false } } - private static func setsTransactionControl(_ words: [String], dialect: SqlDialect) -> Bool { - guard let target = words.first else { return false } - if target == "TRANSACTION" { return dialect == .mysql } - guard let variable = sessionScopes.contains(target) ? words.dropFirst().first : target else { return false } - let name = variable.hasPrefix("@@") ? String(variable.dropFirst(2)) : variable - return commitModeVariables.contains(name) + /// `mysqlbinlog` writes `@@session.autocommit=1` as the fourth element of a `SET` list, so the + /// MySQL family reads every element. PostgreSQL's `SET search_path TO a, b` is a list of values + /// rather than of assignments, so everything else reads the first element only. + private static func setsCommitMode(_ cursor: inout SQLTokenCursor, family: TransactionEngineFamily) -> Bool { + guard family != .sqlServer else { return turnsOnImplicitTransactions(&cursor) } + let assignments = SQLSetAssignments.assignments(from: &cursor, readsList: family == .mysql) + return assignments.contains { $0.name == "AUTOCOMMIT" && commitModeScopes.contains($0.scope) } } - private static func leadingWords(of statement: String) -> [String] { - var words: [String] = [] - var current = String.UnicodeScalarView() - for scalar in executableText(of: statement).unicodeScalars { - if scalar == ";" { break } - if isWordScalar(scalar) { - current.append(scalar) - continue - } - guard !current.isEmpty else { continue } - words.append(String(current).uppercased()) - current.removeAll() - if words.count == wordsNeeded { return words } - } - if !current.isEmpty { - words.append(String(current).uppercased()) + private static func turnsOnImplicitTransactions(_ cursor: inout SQLTokenCursor) -> Bool { + var namesTheCommitMode = false + while let token = cursor.next() { + if token.isSymbol(SQLTokenCursor.comma) { continue } + guard let word = token.word else { return false } + if word == "ON" { return namesTheCommitMode } + if word == "OFF" { return false } + guard implicitTransactionOptions.contains(word) else { continue } + namesTheCommitMode = true } - return words - } - - private static func executableText(of statement: String) -> Substring { - let text = QueryClassifier.strippingLeadingComments(statement)[...] - return QueryClassifier.conditionalCommentBody(of: text) ?? text + return false } - private static func isWordScalar(_ scalar: Unicode.Scalar) -> Bool { - scalar == "_" || scalar == "@" || CharacterSet.alphanumerics.contains(scalar) + private static func startsWithSavepoint(_ statement: NSString, rules: SQLLexicalRules) -> Bool { + var cursor = SQLTokenCursor(statement, rules: rules) + return cursor.next()?.word == "SAVEPOINT" } } diff --git a/TablePro/Core/Services/Execution/CommitOutcomeDiagnosis.swift b/TablePro/Core/Services/Execution/CommitOutcomeDiagnosis.swift new file mode 100644 index 0000000000..ea198f23e4 --- /dev/null +++ b/TablePro/Core/Services/Execution/CommitOutcomeDiagnosis.swift @@ -0,0 +1,69 @@ +// +// CommitOutcomeDiagnosis.swift +// TablePro +// + +import Foundation + +/// Whether a failed `COMMIT` still leaves the app able to say what became of the transaction. +/// +/// A commit the server refused is an answer: the transaction is gone and the rollback that follows +/// is honest. A commit whose connection died is not an answer at all. Measured on MySQL 8.4.11: a +/// commit sitting in "Waiting for commit lock" under `FLUSH TABLES WITH READ LOCK` survived +/// `kill -9` of the client, still held its place in the process list, and committed its row once +/// the lock was released. The app's default query timeout is 60 seconds and the MySQL driver's +/// socket read timeout fires 30 seconds after that, so this is the ordinary way a blocked commit +/// ends, not a corner case. +/// +/// Pure, and a text match rather than a driver question, because the error is all that crosses the +/// plugin boundary: `DatabaseError.queryFailed` carries the engine's own sentence and nothing else. +/// ``BatchCommitPoint`` asks the driver's `hasLostConnection` alongside this, so a driver that +/// knows better is believed too. +internal enum CommitOutcomeDiagnosis { + /// Lower-cased fragments of what the engines say when the socket went before the answer did. + /// MySQL 2006 and 2013, libpq's own four, FreeTDS, and the POSIX text Foundation produces. + private static let connectionLossMarkers = [ + "gone away", + "lost connection", + "connection to the server was lost", + "server closed the connection", + "no connection to the server", + "connection not open", + "connection is closed", + "connection was closed", + "connection reset by peer", + "broken pipe", + "not connected", + "ssl connection has been closed", + "ssl syscall error", + "software caused connection abort", + "terminating connection", + "socket is not connected", + "network is down", + "network is unreachable", + "operation timed out", + "read timed out", + ] + + private static let posixConnectionLossCodes: Set = [ + Int(EPIPE), Int(ECONNRESET), Int(ECONNABORTED), Int(ENOTCONN), + Int(ETIMEDOUT), Int(EHOSTUNREACH), Int(ENETDOWN), Int(ENETUNREACH), Int(ENETRESET), + ] + + private static let urlConnectionLossCodes: Set = [ + NSURLErrorTimedOut, + NSURLErrorCannotConnectToHost, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, + NSURLErrorSecureConnectionFailed, + ] + + internal static func isConnectionLoss(_ error: Error) -> Bool { + if case DatabaseError.notConnected = error { return true } + let nsError = error as NSError + if nsError.domain == NSPOSIXErrorDomain, posixConnectionLossCodes.contains(nsError.code) { return true } + if nsError.domain == NSURLErrorDomain, urlConnectionLossCodes.contains(nsError.code) { return true } + let message = error.localizedDescription.lowercased() + return connectionLossMarkers.contains { message.contains($0) } + } +} diff --git a/TablePro/Core/Services/Execution/MultiStatementFailure.swift b/TablePro/Core/Services/Execution/MultiStatementFailure.swift index 219a41ac84..dceaa09aab 100644 --- a/TablePro/Core/Services/Execution/MultiStatementFailure.swift +++ b/TablePro/Core/Services/Execution/MultiStatementFailure.swift @@ -4,12 +4,18 @@ // import Foundation +import TableProPluginKit internal enum MultiStatementFailure: Equatable, Sendable { case connection case transactionStart case statement(sql: String) case commit + /// The commit went out and the connection died before the answer came back. Measured on MySQL + /// 8.4.11: a commit blocked by `FLUSH TABLES WITH READ LOCK` survived `kill -9` of the client + /// and committed its row once the lock was released. Nothing here can say whether the + /// transaction took, so nothing here may report a rollback. + case commitOutcomeUnknown func ranStatementCount(executedCount: Int, totalCount: Int) -> Int { switch self { @@ -17,13 +23,32 @@ internal enum MultiStatementFailure: Equatable, Sendable { return 0 case .statement: return min(executedCount + 1, totalCount) - case .commit: + case .commit, .commitOutcomeUnknown: return executedCount } } +} - func report(executedCount: Int, totalCount: Int, errorDescription: String) -> MultiStatementFailureReport { - switch self { +/// Everything the failure banner is written from. +/// +/// The plan is part of it because the same banner has three meanings. Under the app's own +/// transaction the statements before the failure are rolled back; under a plan that opens none they +/// stay applied; and under a run that joined the user's own transaction they are pending in it and +/// the transaction is still theirs to end. The user cannot tell which happened from the error alone. +/// +/// `sessionState` is read again after the failure rather than carried from the start of the run, +/// because a failure moves it: measured on PostgreSQL 17.11, the statement that failed leaves the +/// block aborted, where a `COMMIT` answers with the command tag `ROLLBACK` and no error. +internal struct MultiStatementFailureContext: Equatable, Sendable { + let failure: MultiStatementFailure + let errorDescription: String + let executedCount: Int + let totalCount: Int + let plan: BatchTransactionPlan + let sessionState: PluginSessionTransactionState + + func report() -> MultiStatementFailureReport { + switch failure { case .connection: return MultiStatementFailureReport( message: errorDescription, @@ -39,16 +64,7 @@ internal enum MultiStatementFailure: Equatable, Sendable { failedSQL: nil ) case .statement(let sql): - let position = min(executedCount + 1, totalCount) - return MultiStatementFailureReport( - message: String( - format: String(localized: "Statement %1$d/%2$d failed: %3$@"), - position, totalCount, errorDescription - ), - resultLabel: String(format: String(localized: "Error %d"), position), - failedStatementIndex: executedCount < totalCount ? executedCount : nil, - failedSQL: sql - ) + return statementReport(sql: sql) case .commit: return MultiStatementFailureReport( message: String(format: String(localized: "The transaction could not be committed: %@"), errorDescription), @@ -56,8 +72,72 @@ internal enum MultiStatementFailure: Equatable, Sendable { failedStatementIndex: nil, failedSQL: nil ) + case .commitOutcomeUnknown: + return unknownCommitReport() } } + + /// Worded without a rollback in it. The app sent the commit, the connection went before the + /// answer, and the server decides on its own: the same kill that rolled a commit back under a + /// read lock was ignored by a commit waiting on `binlog_group_commit_sync_delay`, which then + /// committed. + private func unknownCommitReport() -> MultiStatementFailureReport { + let lost = String( + format: String(localized: "The connection was lost while committing: %@"), + errorDescription + ) + let unknown = String(localized: "The statements may or may not be saved. Check the table before running them again.") + return MultiStatementFailureReport( + message: "\(lost) \(unknown)", + resultLabel: String(localized: "Error"), + failedStatementIndex: nil, + failedSQL: nil + ) + } + + private func statementReport(sql: String) -> MultiStatementFailureReport { + let position = min(executedCount + 1, totalCount) + let failed = String( + format: String(localized: "Statement %1$d/%2$d failed: %3$@"), + position, totalCount, errorDescription + ) + return MultiStatementFailureReport( + message: standingWorkNote().map { "\(failed) \($0)" } ?? failed, + resultLabel: String(format: String(localized: "Error %d"), position), + failedStatementIndex: executedCount < totalCount ? executedCount : nil, + failedSQL: sql + ) + } + + /// What became of the statements that ran before the failure. + private func standingWorkNote() -> String? { + switch plan { + case .appTransaction, .scriptTransaction: + return nil + case .autocommit: + return appliedStatementsNote() + case .sessionTransaction: + return sessionWorkNote() + } + } + + /// A session holding a lock rather than a transaction committed each statement as it ran, + /// exactly as autocommit does, so it reads the same way. + /// + /// Anything else says nothing. A transaction that ended during the run either committed the + /// statements before the failure or took them back, and nothing the driver can be asked + /// afterwards says which. + private func sessionWorkNote() -> String? { + if let notice = sessionState.openTransactionNotice { return notice } + guard sessionState == .holdsSessionLocks else { return nil } + return appliedStatementsNote() + } + + private func appliedStatementsNote() -> String? { + guard executedCount > 0 else { return nil } + guard executedCount > 1 else { return String(localized: "The statement before it stays applied.") } + return String(format: String(localized: "The %d statements before it stay applied."), executedCount) + } } internal struct MultiStatementFailureReport: Equatable, Sendable { diff --git a/TablePro/Core/Services/Execution/MySQLAutocommitOnlyVariables.swift b/TablePro/Core/Services/Execution/MySQLAutocommitOnlyVariables.swift new file mode 100644 index 0000000000..1ce91ef1f2 --- /dev/null +++ b/TablePro/Core/Services/Execution/MySQLAutocommitOnlyVariables.swift @@ -0,0 +1,56 @@ +// +// MySQLAutocommitOnlyVariables.swift +// TablePro +// + +import Foundation + +internal enum MySQLVariableScope: String, Hashable, Sendable, CaseIterable { + case session + case global +} + +/// The system variables MySQL and MariaDB refuse to set while a transaction is open, keyed by the +/// scope that refuses. `SET SESSION binlog_format` answers `ERROR 1679` and `SET GLOBAL +/// binlog_format` is allowed, so the scope is part of the key rather than a flag beside it. +/// +/// `PERSIST` writes the running global value as well as the file, so it resolves to `.global`. +/// `PERSIST_ONLY` writes only the file and is allowed inside a transaction, so it matches nothing. +/// +/// This is a hand-written list that has to agree with two servers and that nothing at runtime +/// checks, which is the shape that let seven MySQL 8.4 variables go missing. +/// `scripts/check-mysql-autocommit-only-variables.sh` tries every variable the server has against a +/// live server and reports both directions, and it reads this table out of this file, so the +/// literal below stays one entry per line. +internal enum MySQLAutocommitOnlyVariables { + internal static func refuses(_ name: String, scope: MySQLVariableScope) -> Bool { + curated[name]?.contains(scope) ?? false + } + + internal static let curated: [String: Set] = [ + "BINLOG_CHECKSUM": [.global], + "BINLOG_DIRECT_NON_TRANSACTIONAL_UPDATES": [.session], + "BINLOG_FORMAT": [.session], + "BINLOG_ROW_VALUE_OPTIONS": [.session, .global], + "BINLOG_TRANSACTION_COMPRESSION": [.session], + "BINLOG_TRANSACTION_COMPRESSION_LEVEL_ZSTD": [.session], + "ENFORCE_GTID_CONSISTENCY": [.global], + "EXPLICIT_DEFAULTS_FOR_TIMESTAMP": [.session], + "GROUP_REPLICATION_CONSISTENCY": [.session], + "GTID_BINLOG_STATE": [.global], + "GTID_DOMAIN_ID": [.session], + "GTID_MODE": [.global], + "GTID_NEXT": [.session], + "GTID_PURGED": [.global], + "GTID_SEQ_NO": [.session], + "GTID_SLAVE_POS": [.global], + "PSEUDO_REPLICA_MODE": [.session], + "PSEUDO_SLAVE_MODE": [.session], + "READ_ONLY": [.global], + "SESSION_TRACK_GTIDS": [.session], + "SKIP_REPLICATION": [.session], + "SQL_LOG_BIN": [.session], + "WSREP_ON": [.session], + "XA_DETACH_ON_PREPARE": [.session] + ] +} diff --git a/TablePro/Core/Services/Execution/PluginSessionTransactionState+Execution.swift b/TablePro/Core/Services/Execution/PluginSessionTransactionState+Execution.swift new file mode 100644 index 0000000000..cbc1b84be1 --- /dev/null +++ b/TablePro/Core/Services/Execution/PluginSessionTransactionState+Execution.swift @@ -0,0 +1,58 @@ +// +// PluginSessionTransactionState+Execution.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal extension DatabaseDriver { + /// What the session is holding, asked only of an engine that has transactions at all: one + /// without them has nothing to report, and nothing the app owns opens a transaction on it. + func heldSessionTransactionState() async -> PluginSessionTransactionState { + guard supportsTransactions else { return .unknown } + return await sessionTransactionState() + } +} + +/// The one reading of a session's state that every app-owned writer on the session route shares. +/// +/// A multi-statement run, a grid Save and a grid Discard all send their own `BEGIN`, `COMMIT` and +/// `ROLLBACK` down the connection's shared session. They must agree about when that is allowed, or +/// the editor joins the user's transaction while a cell edit still commits it. +internal extension PluginSessionTransactionState { + /// Whether the app may open a transaction of its own on this session, and therefore commit and + /// roll it back. + /// + /// `.unknown` answers true, which is what the app did before it could ask: every batch's + /// atomicity outweighs a transaction that may not be there on an engine that cannot say. + var permitsAppTransaction: Bool { + switch self { + case .idle, .unknown: + return true + case .inTransaction, .abortedTransaction, .holdsSessionLocks: + return false + @unknown default: + return true + } + } + + /// What the user has to be told about the transaction this session is holding, or nil when it + /// is holding none. + /// + /// The aborted case says nothing about committing on purpose. Measured on PostgreSQL 17.11, a + /// `COMMIT` in an aborted block answers with the command tag `ROLLBACK` and no error at all, so + /// "commit or roll it back" is advice that silently discards the work it was meant to keep. + var openTransactionNotice: String? { + switch self { + case .inTransaction: + return String(localized: "The transaction on this connection is still open. Commit or roll it back.") + case .abortedTransaction: + return String(localized: "The transaction on this connection can no longer be committed. Roll it back.") + case .idle, .holdsSessionLocks, .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/TablePro/Core/Services/Execution/TransactionEngineFamily.swift b/TablePro/Core/Services/Execution/TransactionEngineFamily.swift new file mode 100644 index 0000000000..04de0c19ee --- /dev/null +++ b/TablePro/Core/Services/Execution/TransactionEngineFamily.swift @@ -0,0 +1,76 @@ +// +// TransactionEngineFamily.swift +// TablePro +// +// Which engines refuse the same statements inside a transaction block. +// +// Not the same grouping as `SqlDialect`, which exists to lex a script: that one files DuckDB +// under `.sqlite` because both take its string literals, and SQL Server under `.generic`. Their +// transaction rules are unrelated. SQLite refuses `VACUUM` and a `PRAGMA journal_mode` inside a +// transaction and DuckDB allows both, so reusing the lexing grouping would unwrap every DuckDB +// batch that vacuums and leave every SQL Server batch that backs up wrapped. +// +// Curated by name, the `SQLTypeFamily` pattern, because what an engine refuses is a fact about +// that engine and no capability the plugin registry publishes implies it. `DatabaseType` is open, +// so anything not named here is `.other` and keeps the wrap it has today. +// + +import Foundation + +internal enum TransactionEngineFamily: String, Hashable, Sendable, CaseIterable { + case postgres + case redshift + case cockroach + case mysql + case sqlite + case duckdb + case sqlServer + case redis + case other + + internal static func of(_ type: DatabaseType) -> TransactionEngineFamily { + familiesByTypeId[type.rawValue] ?? .other + } + + /// Whether the app may open a transaction of its own around a batch. + /// + /// Redis `MULTI` does not open one so much as start queueing: every command after it answers + /// `+QUEUED` in place of its own reply and nothing runs until `EXEC`, which then applies the + /// whole block and puts each command's failure in its own element of one reply array. So a + /// wrapped batch reports `QUEUED` for every statement, hides every error, and cannot be rolled + /// back once `EXEC` has run. Measured on Redis 8.10.1: `MULTI; GET s; LPUSH s x; SET t 1; DEL + /// nokey; INCR s; EXEC` answers five `+QUEUED` and then an array holding two errors, with + /// `SET t 1` applied. + internal var wrapsBatchInTransaction: Bool { + self != .redis + } + + /// 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 block instead of opening one. + internal var savepointOpensTransaction: Bool { + self == .sqlite + } + + private static let familiesByTypeId: [String: TransactionEngineFamily] = [ + "PostgreSQL": .postgres, + "PGlite": .postgres, + "AlloyDB": .postgres, + "Citus": .postgres, + "Greenplum": .postgres, + "Redshift": .redshift, + "CockroachDB": .cockroach, + "MySQL": .mysql, + "MariaDB": .mysql, + "TiDB": .mysql, + "OceanBase": .mysql, + "SQLite": .sqlite, + "libSQL": .sqlite, + "Turso": .sqlite, + "DuckDB": .duckdb, + "SQL Server": .sqlServer, + "Redis": .redis + ] +} diff --git a/TablePro/Core/Services/Execution/WriteTransactionOwner.swift b/TablePro/Core/Services/Execution/WriteTransactionOwner.swift new file mode 100644 index 0000000000..74cd43e9b4 --- /dev/null +++ b/TablePro/Core/Services/Execution/WriteTransactionOwner.swift @@ -0,0 +1,44 @@ +// +// WriteTransactionOwner.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Who owns the transaction an app-owned write runs inside, and therefore who may commit it. +/// +/// A grid Save, Discard or Rewind, a structure rebuild and a batch from the editor all run on the +/// connection's shared session, so a `BEGIN` the user typed into the editor, a `SET autocommit = 0`, +/// a `LOCK TABLES` or an MCP client's `begin` is still in force when the write arrives. Opening a +/// transaction of its own then commits their pending work on PostgreSQL, implicitly commits it on +/// MySQL and aborts it on DuckDB, with nothing raised and the write reporting success. +/// +/// This is the one place that decision is made. ``BatchTransactionPlan/joining(_:)`` is the same +/// decision for a multi-statement run, where the statement text has a say as well. +internal enum WriteTransactionOwner: Equatable { + /// The app opens the transaction, verifies inside it, and commits or rolls it back. + case app + /// The session already holds one. The write joins it and sends no `BEGIN`, `COMMIT` or + /// `ROLLBACK`: ending it belongs to whoever opened it. + case session + /// The engine has no transactions, so every statement is on the server as it runs. + case none + + internal static func resolve( + supportsTransactions: Bool, + sessionState: PluginSessionTransactionState + ) -> WriteTransactionOwner { + guard supportsTransactions else { return .none } + return sessionState.permitsAppTransaction ? .app : .session + } + + internal var opensTransaction: Bool { + self == .app + } + + /// Whether the statements that already ran can still be taken back by the app. + internal var canRollBack: Bool { + self == .app + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index 5ee15c88e4..05fd9651ad 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -40,6 +40,9 @@ struct MenuValidationContext: Equatable { var isCurrentTabSchemaResolved = false var canRestorePreviousValues = false var isQueryExecuting = false + /// Whether Stop still has something to act on. A batch whose `COMMIT` is on the wire is + /// executing and unstoppable at the same time, and `Cmd+.` must dim rather than fire into it. + var isQueryStoppable = false var hasQueryText = false var canClearQuery = false var canClearResults = false @@ -214,7 +217,7 @@ extension MainSplitViewController: NSMenuItemValidation { case #selector(runStatementAndAdvance(_:)): return context.isQueryTab && context.isConnected && context.hasQueryText && !context.isQueryExecuting case #selector(cancelQuery(_:)): - return context.isQueryExecuting + return context.isQueryExecuting && context.isQueryStoppable case #selector(clearQuery(_:)): return context.canClearQuery case #selector(clearResults(_:)): @@ -415,6 +418,7 @@ extension MainSplitViewController: NSMenuItemValidation { isCurrentTabSchemaResolved: actions.isCurrentTabSchemaResolved, canRestorePreviousValues: actions.canRestorePreviousValues, isQueryExecuting: actions.isQueryExecuting, + isQueryStoppable: actions.isQueryStoppable, hasQueryText: actions.hasQueryText, canClearQuery: actions.canClearQuery, canClearResults: actions.canClearResults, diff --git a/TablePro/Core/UsersRoles/PrincipalApplyError.swift b/TablePro/Core/UsersRoles/PrincipalApplyError.swift index c1f6da2c6c..0acfc02092 100644 --- a/TablePro/Core/UsersRoles/PrincipalApplyError.swift +++ b/TablePro/Core/UsersRoles/PrincipalApplyError.swift @@ -1,10 +1,20 @@ import Foundation struct PrincipalApplyError: LocalizedError { + /// What became of the statements that ran before the failure. + enum Disposition: Equatable { + /// The app rolled its own transaction back, so nothing stands. + case rolledBack + /// Applied as they ran, on a connection that does not roll principal statements back. + case applied + /// Pending inside a transaction the session already held, which the app must not end. + case pendingInSessionTransaction + } + let failedStatement: SchemaStatement let appliedCount: Int let totalCount: Int - let rolledBack: Bool + let disposition: Disposition let underlying: Error var errorDescription: String? { @@ -12,16 +22,32 @@ struct PrincipalApplyError: LocalizedError { } var partialApplicationMessage: String? { - guard !rolledBack, appliedCount > 0 else { return nil } - return String( - format: String( - localized: """ - %1$lld of %2$lld statements were applied. \ - This connection does not roll back user and role changes. - """ - ), - appliedCount, - totalCount - ) + guard appliedCount > 0 else { return nil } + switch disposition { + case .rolledBack: + return nil + case .applied: + return String( + format: String( + localized: """ + %1$lld of %2$lld statements were applied. \ + This connection does not roll back user and role changes. + """ + ), + appliedCount, + totalCount + ) + case .pendingInSessionTransaction: + return String( + format: String( + localized: """ + %1$lld of %2$lld statements ran inside the transaction already open on this \ + connection. Commit it to keep them, or roll it back to discard them. + """ + ), + appliedCount, + totalCount + ) + } } } diff --git a/TablePro/Core/UsersRoles/PrincipalChangeManager.swift b/TablePro/Core/UsersRoles/PrincipalChangeManager.swift index a9ff287037..5b01d67982 100644 --- a/TablePro/Core/UsersRoles/PrincipalChangeManager.swift +++ b/TablePro/Core/UsersRoles/PrincipalChangeManager.swift @@ -366,9 +366,10 @@ final class PrincipalChangeManager: ObservableObject { // the CREATE instead, or it would be counted as a change and then silently dropped. if let index = pendingCreates.firstIndex(where: { $0.ref == ref }) { let previous = pendingCreates[index] - guard previous != definition else { return } + let folded = Self.folding(definition, into: previous) + guard previous != folded else { return } - pendingCreates[index] = definition + pendingCreates[index] = folded recomputeChangeCount() registerUndo(actionName: String(localized: "Change Attributes")) { manager in @@ -397,6 +398,26 @@ final class PrincipalChangeManager: ObservableObject { } } + /// The attribute forms carry no password field, so every edit they stage arrives with none. A + /// fold that took it wholesale replaced the staged `CREATE USER ... IDENTIFIED BY` with a + /// passwordless one, and the only way to give a new account a connection limit is through this + /// fold, so the account the user thought they had given a password had none. + private static func folding( + _ definition: PluginPrincipalDefinition, + into staged: PluginPrincipalDefinition + ) -> PluginPrincipalDefinition { + guard (definition.password ?? "").isEmpty else { return definition } + return PluginPrincipalDefinition( + ref: definition.ref, + password: staged.password, + canLogin: definition.canLogin, + attributes: definition.attributes, + memberOf: definition.memberOf, + connectionLimit: definition.connectionLimit, + comment: definition.comment + ) + } + func unstageAlter(_ ref: PluginPrincipalRef) { guard let previous = pendingAlters.removeValue(forKey: ref) else { return } recomputeChangeCount() diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index 643efb7218..76d634cd3b 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -116,11 +116,6 @@ enum QueryClassifier { return remaining.prefix { $0.isLetter || $0.isNumber || $0 == "_" }.uppercased() } - static func conditionalCommentBody(of sql: Substring) -> Substring? { - guard let opener = conditionalCommentOpeners.first(where: { sql.hasPrefix($0) }) else { return nil } - return sql.dropFirst(opener.count).drop { $0.isNumber } - } - static func strippingLeadingComments(_ sql: String) -> String { var remaining = sql[...] while true { diff --git a/TablePro/Core/Utilities/SQL/SQLSetAssignments.swift b/TablePro/Core/Utilities/SQL/SQLSetAssignments.swift new file mode 100644 index 0000000000..29f0ad4f34 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/SQLSetAssignments.swift @@ -0,0 +1,130 @@ +// +// SQLSetAssignments.swift +// TablePro +// + +import Foundation + +/// One `name = value` element of a `SET` statement, as it was written. +/// +/// `spelledWithAtAt` is load-bearing rather than cosmetic: MySQL reads `SET @@transaction_isolation` +/// as the next transaction's characteristics and refuses it inside one, while `SET SESSION +/// transaction_isolation` sets the session variable and is allowed. +internal struct SQLSetAssignment: Equatable, Sendable { + internal enum Scope: Equatable, Sendable { + case unspecified + case session + case local + case global + case persist + case persistOnly + } + + internal let scope: Scope + internal let name: String + internal let spelledWithAtAt: Bool +} + +/// Reads the targets of a `SET`, with the cursor positioned just past the `SET` keyword. +/// +/// Values are skipped to the next depth-0 comma rather than parsed, so a comma inside parentheses +/// or inside a string never splits an element. An element that is not an assignment (`SET NAMES +/// utf8mb4`, `SET CHARACTER SET x`) and a user variable (`SET @x = 1`) yield nothing while the rest +/// of the list is still read: `mysqlbinlog` writes the assignment that matters fourth. +/// +/// MySQL applies the most recent `GLOBAL`/`SESSION` keyword to every following element that carries +/// no modifier of its own, which is why the scope is carried across the list. +internal enum SQLSetAssignments { + private static let scopeKeywords: [String: SQLSetAssignment.Scope] = [ + "SESSION": .session, + "LOCAL": .local, + "GLOBAL": .global, + "PERSIST": .persist, + "PERSIST_ONLY": .persistOnly + ] + + private static let atAtScopes: [String: SQLSetAssignment.Scope] = [ + "SESSION": .session, + "LOCAL": .local, + "GLOBAL": .global + ] + + internal static func assignments(from cursor: inout SQLTokenCursor, readsList: Bool) -> [SQLSetAssignment] { + var head = cursor.next() + var stopsAtFor = false + if head?.word == "STATEMENT" { + stopsAtFor = true + head = cursor.next() + } + + var assignments: [SQLSetAssignment] = [] + var listScope = SQLSetAssignment.Scope.unspecified + while let token = head { + if let assignment = element(startingAt: token, cursor: &cursor, listScope: &listScope) { + assignments.append(assignment) + } + guard readsList else { break } + head = nextElementHead(cursor: &cursor, stopsAtFor: stopsAtFor) + } + return assignments + } + + private static func element( + startingAt token: SQLTokenCursor.Token, + cursor: inout SQLTokenCursor, + listScope: inout SQLSetAssignment.Scope + ) -> SQLSetAssignment? { + var current = token + while let word = current.word, let scope = scopeKeywords[word] { + listScope = scope + guard let next = cursor.next() else { return nil } + current = next + } + guard let assignment = target(current, cursor: &cursor, listScope: listScope) else { return nil } + guard cursor.next()?.isSymbol(SQLTokenCursor.equals) == true else { return nil } + return assignment + } + + private static func target( + _ token: SQLTokenCursor.Token, + cursor: inout SQLTokenCursor, + listScope: SQLSetAssignment.Scope + ) -> SQLSetAssignment? { + guard let word = token.word else { + guard let name = token.identifier else { return nil } + return SQLSetAssignment(scope: listScope, name: name, spelledWithAtAt: false) + } + guard word.hasPrefix("@") else { + return SQLSetAssignment(scope: listScope, name: word, spelledWithAtAt: false) + } + guard word.hasPrefix("@@") else { return nil } + return systemVariable(named: String(word.dropFirst(2)), cursor: &cursor) + } + + private static func systemVariable(named name: String, cursor: inout SQLTokenCursor) -> SQLSetAssignment? { + guard let scope = atAtScopes[name] else { + return SQLSetAssignment(scope: .unspecified, name: name, spelledWithAtAt: true) + } + var lookahead = cursor + guard lookahead.next()?.isSymbol(SQLTokenCursor.period) == true, + let qualified = lookahead.next()?.identifier + else { + return SQLSetAssignment(scope: .unspecified, name: name, spelledWithAtAt: true) + } + cursor = lookahead + return SQLSetAssignment(scope: scope, name: qualified, spelledWithAtAt: true) + } + + private static func nextElementHead( + cursor: inout SQLTokenCursor, + stopsAtFor: Bool + ) -> SQLTokenCursor.Token? { + while let token = cursor.next() { + guard cursor.parenDepth == 0 else { continue } + if stopsAtFor, token.word == "FOR" { return nil } + guard token.isSymbol(SQLTokenCursor.comma) else { continue } + return cursor.next() + } + return nil + } +} diff --git a/TablePro/Core/Utilities/SQL/SQLTokenCursor.swift b/TablePro/Core/Utilities/SQL/SQLTokenCursor.swift new file mode 100644 index 0000000000..675b3d4d79 --- /dev/null +++ b/TablePro/Core/Utilities/SQL/SQLTokenCursor.swift @@ -0,0 +1,229 @@ +// +// SQLTokenCursor.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// A lazy reader over the head of one statement, in the vocabulary a rule about that statement +/// needs: words, quoted identifiers, literals and single symbols. +/// +/// It never materializes the statement. A rule pulls the two or three tokens it cares about and +/// stops, which is what keeps a 100,000-statement dump affordable, and it shares ``SQLNonCodeSpan`` +/// with every other scanner so a comment, a string and a dollar-quoted body end in the same place. +/// +/// The cursor ends at a depth-0 `;`, so a statement carrying a trailing fragment cannot be read as +/// two. A MySQL conditional comment is code, not a comment, so its `/*!NNNNN` and `/*M!NNNNN` +/// openers and the `*/` that closes them are stepped over and the body is read. +/// +/// It is a struct with value semantics, so a rule that needs to look ahead copies it, reads, and +/// either adopts the copy or drops it. +internal struct SQLTokenCursor { + internal enum Token: Equatable { + case word(String) + case quotedIdentifier(String) + case literal + case symbol(UInt16) + } + + internal static let equals = UInt16(UnicodeScalar("=").value) + internal static let comma = UInt16(UnicodeScalar(",").value) + internal static let period = UInt16(UnicodeScalar(".").value) + internal static let openParen = SqlLexer.openParen + internal static let closeParen = SqlLexer.closeParen + + private static let at = UInt16(UnicodeScalar("@").value) + private static let colon = UInt16(UnicodeScalar(":").value) + private static let openBracket = UInt16(UnicodeScalar("[").value) + private static let capitalE = UInt16(UnicodeScalar("E").value) + private static let smallE = UInt16(UnicodeScalar("e").value) + private static let capitalM = UInt16(UnicodeScalar("M").value) + private static let smallM = UInt16(UnicodeScalar("m").value) + private static let digitZero = UInt16(UnicodeScalar("0").value) + private static let digitNine = UInt16(UnicodeScalar("9").value) + + private let text: NSString + private let rules: SQLLexicalRules + private let length: Int + private var index: Int + private var conditionalDepth = 0 + + internal private(set) var parenDepth = 0 + + internal init(_ text: NSString, rules: SQLLexicalRules) { + self.text = text + self.rules = rules + length = text.length + index = 0 + } + + internal init(_ text: String, rules: SQLLexicalRules) { + self.init(text as NSString, rules: rules) + } + + internal mutating func next() -> Token? { + while index < length { + let character = text.character(at: index) + if SqlLexer.isWhitespace(character) { + index += 1 + continue + } + if skipsNonCode(character) { continue } + if character == SqlLexer.semicolon, parenDepth == 0 { return nil } + return token(startingWith: character) + } + return nil + } + + internal func peek() -> Token? { + var lookahead = self + return lookahead.next() + } + + private mutating func skipsNonCode(_ character: UInt16) -> Bool { + if rules.dialect == .mysql { + if let opener = conditionalCommentOpener(at: index) { + index += opener + conditionalDepth += 1 + return true + } + if conditionalDepth > 0, character == SqlLexer.star, + index + 1 < length, text.character(at: index + 1) == SqlLexer.slash { + index += 2 + conditionalDepth -= 1 + return true + } + } + guard startsComment(character) else { return false } + index = SQLNonCodeSpan.end(at: index, in: text, rules: rules) ?? length + return true + } + + private func startsComment(_ character: UInt16) -> Bool { + if rules.dialect.supportsHashLineComments, character == SqlLexer.hash { return true } + return SqlLexer.startsLineComment(text, at: index, length: length) + || SqlLexer.startsBlockComment(text, at: index, length: length) + } + + private func conditionalCommentOpener(at offset: Int) -> Int? { + guard SqlLexer.startsBlockComment(text, at: offset, length: length) else { return nil } + var cursor = offset + 2 + if cursor < length, text.character(at: cursor) == Self.capitalM || text.character(at: cursor) == Self.smallM { + cursor += 1 + } + guard cursor < length, text.character(at: cursor) == SqlLexer.exclamationMark else { return nil } + cursor += 1 + while cursor < length, isDigit(text.character(at: cursor)) { + cursor += 1 + } + return cursor - offset + } + + private mutating func token(startingWith character: UInt16) -> Token? { + if character == Self.openParen { + index += 1 + parenDepth += 1 + return .symbol(character) + } + if character == Self.closeParen { + index += 1 + parenDepth = max(0, parenDepth - 1) + return .symbol(character) + } + if SqlLexer.isQuote(character) || (rules.bracketsDelimitIdentifiers && character == Self.openBracket) { + let start = index + index = SQLNonCodeSpan.end(at: index, in: text, rules: rules) ?? length + guard character != SqlLexer.singleQuote else { return .literal } + return .quotedIdentifier(quotedBody(from: start, to: index)) + } + if startsLiteralSpan(at: index) { + index = SQLNonCodeSpan.end(at: index, in: text, rules: rules) ?? length + return .literal + } + if isWordUnit(character) { return .word(readWord()) } + if character == Self.colon, index + 1 < length, text.character(at: index + 1) == Self.equals { + index += 2 + return .symbol(Self.equals) + } + index += 1 + return .symbol(character) + } + + private func startsLiteralSpan(at offset: Int) -> Bool { + let character = text.character(at: offset) + if rules.dialect.supportsEscapeStringPrefix, + character == Self.capitalE || character == Self.smallE, + offset + 1 < length, text.character(at: offset + 1) == SqlLexer.singleQuote, + offset == 0 || !SQLNonCodeSpan.isWordUnit(text.character(at: offset - 1)) { + return true + } + guard rules.dialect.supportsDollarQuotes, character == SqlDollarQuote.dollar, + case .opener = SqlDollarQuote.scanOpener(at: offset, in: text, bufLen: length) + else { + return false + } + return true + } + + private mutating func readWord() -> String { + let start = index + while index < length, isWordUnit(text.character(at: index)) { + index += 1 + } + return text.substring(with: NSRange(location: start, length: index - start)).uppercased() + } + + private func quotedBody(from start: Int, to end: Int) -> String { + guard end - start >= 2 else { return "" } + let body = text.substring(with: NSRange(location: start + 1, length: end - start - 2)) + guard let closer = closingDelimiter(for: text.character(at: start)) else { return body } + return body.replacingOccurrences(of: "\(closer)\(closer)", with: String(closer)) + } + + private func closingDelimiter(for opener: UInt16) -> Character? { + switch opener { + case SqlLexer.doubleQuote: + return "\"" + case SqlLexer.backtick: + return "`" + case Self.openBracket: + return "]" + default: + return nil + } + } + + private func isWordUnit(_ character: UInt16) -> Bool { + SQLNonCodeSpan.isWordUnit(character) || character == Self.at || character == SqlDollarQuote.dollar + } + + private func isDigit(_ character: UInt16) -> Bool { + character >= Self.digitZero && character <= Self.digitNine + } +} + +internal extension SQLTokenCursor.Token { + var word: String? { + guard case .word(let word) = self else { return nil } + return word + } + + /// The name this token spells, whichever way it was written. A quoted identifier is uppercased + /// so `` `sql_log_bin` `` and `SQL_LOG_BIN` answer the same rule. + var identifier: String? { + switch self { + case .word(let word): + return word + case .quotedIdentifier(let name): + return name.uppercased() + case .literal, .symbol: + return nil + } + } + + func isSymbol(_ expected: UInt16) -> Bool { + guard case .symbol(let symbol) = self else { return false } + return symbol == expected + } +} diff --git a/TablePro/Models/Query/QueryCommandAvailability.swift b/TablePro/Models/Query/QueryCommandAvailability.swift index e3deb8d4ad..d995bf54fb 100644 --- a/TablePro/Models/Query/QueryCommandAvailability.swift +++ b/TablePro/Models/Query/QueryCommandAvailability.swift @@ -34,17 +34,21 @@ struct QueryCommandAvailability { let formatHint: String let favoriteHint: String + /// `isStoppable` is separate from `isExecuting` because a batch whose `COMMIT` is on the wire is + /// still running and can no longer be stopped by anything: the HIG asks not to offer a cancel + /// that cannot act. init( isConnected: Bool, hasQueryText: Bool, isExecuting: Bool, + isStoppable: Bool, hasResults: Bool, explainVariants: [ExplainVariant], shortcutHint: (String, ShortcutAction) -> String ) { self.explainVariants = explainVariants canRun = isConnected && hasQueryText && !isExecuting - canStop = isExecuting + canStop = isExecuting && isStoppable canExplain = isConnected && hasQueryText && !isExecuting && !explainVariants.isEmpty /// Formatting rewrites text the reader already has, so it does not wait for a server. canFormat = hasQueryText @@ -57,7 +61,12 @@ struct QueryCommandAvailability { base: shortcutHint(String(localized: "Run"), .executeQuery), reason: Self.blockedReason(isConnected: isConnected, hasQueryText: hasQueryText, isExecuting: isExecuting) ) - stopHint = shortcutHint(String(localized: "Stop"), .cancelQuery) + stopHint = Self.hint( + base: shortcutHint(String(localized: "Stop"), .cancelQuery), + reason: isExecuting && !isStoppable + ? String(localized: "The batch is committing and cannot be stopped.") + : nil + ) explainHint = Self.hint( base: shortcutHint(String(localized: "Explain"), .explainQuery), reason: explainVariants.isEmpty diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index c8917a984e..f8fe83518f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -28890,6 +28890,9 @@ }, "Check constraint must have a name and an expression" : { + }, + "Check constraints need %@ or later." : { + }, "Check for Updates…" : { "localizations" : { @@ -119545,6 +119548,9 @@ } } } + }, + "Query stopped after running past the %d second query timeout" : { + }, "Query timed out after %d seconds" : { "localizations" : { @@ -156800,6 +156806,9 @@ } } } + }, + "The %d statements before it stay applied." : { + }, "The Access application policy must use a Service Auth rule, or Cloudflare still prompts for browser sign-in." : { "localizations" : { @@ -157629,6 +157638,12 @@ } } } + }, + "The connection was lost while committing, so this may not be saved." : { + + }, + "The connection was lost while committing: %@" : { + }, "The connection was closed." : { "localizations" : { @@ -161092,6 +161107,15 @@ } } } + }, + "The batch is committing and cannot be stopped." : { + + }, + "The statement before it stays applied." : { + + }, + "The statements may or may not be saved. Check the table before running them again." : { + }, "The statement that was sent, with its prefix" : { "localizations" : { diff --git a/TablePro/Views/Editor/QueryEditorBar.swift b/TablePro/Views/Editor/QueryEditorBar.swift index 265e57f781..6adb4c1e7d 100644 --- a/TablePro/Views/Editor/QueryEditorBar.swift +++ b/TablePro/Views/Editor/QueryEditorBar.swift @@ -143,6 +143,7 @@ struct QueryEditorBar: View { .buttonStyle(.bordered) .controlSize(.small) .labelStyle(.titleAndIcon) + .disabled(!commands.canStop) .help(commands.stopHint) .accessibilityIdentifier("query-stop") } else { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index ff8856d59d..a1de52725c 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -498,7 +498,7 @@ struct MainEditorContentView: View { onRun: { coordinator.runQuery(viewport: .firstRow) }, onRunAllStatements: { coordinator.runAllStatements() }, onRunWithoutLimit: { coordinator.runQuery(viewport: .firstRow, bypassRowLimit: true) }, - onStop: { coordinator.cancelCurrentQuery() }, + onStop: { coordinator.stopExecution(for: tab.id) }, onExplain: { variant in coordinator.runExplain(variant: variant) }, onFormat: { EditorEventRouter.shared.performFormatSQLForKeyWindow() }, onSaveAsFavoriteCommand: { coordinator.saveCurrentQueryAsFavorite() }, @@ -697,7 +697,8 @@ struct MainEditorContentView: View { databaseName: scope?.database ?? "", schemaName: scope?.schema, tableName: tableName, - objectKind: tab.tableContext.resolvedObjectKind() + objectKind: tab.tableContext.resolvedObjectKind(), + serverSupport: StructureServerSupport.forConnection(connection.id) ) } } @@ -1117,7 +1118,7 @@ struct MainEditorContentView: View { tabId: tab.id, execution: coordinator.tabExecution, lastTiming: coordinator.toolbarState.queryTiming(forTab: tab.id), - onCancel: { coordinator.cancelCurrentQuery() } + onCancel: { coordinator.stopExecution(for: tab.id) } ), isRefreshingSchema: schemaService.isRefreshing(connectionId: connectionId), viewMode: resultsViewModeBinding(for: tab), @@ -1183,6 +1184,7 @@ struct MainEditorContentView: View { isConnected: MainWindowToolbar.hasLiveSession(coordinator.toolbarState.connectionState), hasQueryText: tab.hasQueryText, isExecuting: coordinator.tabExecution.isExecuting(tab.id), + isStoppable: coordinator.tabExecution.isStoppable(tab.id), hasResults: coordinator.canClearActiveQueryResults, explainVariants: coordinator.connection.type.explainVariants, shortcutHint: { label, action in diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift index f6e2d8f9eb..74181db4f0 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Explain.swift @@ -131,8 +131,7 @@ extension MainContentCoordinator { return } - supersedeExecution(for: tab.id) - let claim = tabExecution.claim(tab.id) + let (claim, lease) = beginTabExecution(for: tab.id) let tabId = tab.id let conn = connection @@ -144,7 +143,7 @@ extension MainContentCoordinator { let fetchResult = try await services.databaseManager.withScopedDriver( scope: scope, route: services.databaseManager.executionRoute(for: scope), - cancellation: .cancellableRead + cancellation: .cancellableRead(lease) ) { [queryExecutor] driver in try await queryExecutor.executeQuery( driver: driver, sql: request.sql, parameters: nil, rowCap: nil @@ -160,7 +159,7 @@ extension MainContentCoordinator { // that cleared the spinner or nilled the task handle would be reporting on a // query that is still running, so the gate comes before all of them. guard tabExecution.settle(claim) else { return } - retireQueryTask(for: claim) + retireQueryTask(.claim(claim)) guard !Task.isCancelled else { reportEndedExecutions([ EndedExecution(tabId: claim.tabId, startedAt: claim.startedAt, reason: .cancelledByUser) @@ -238,7 +237,7 @@ extension MainContentCoordinator { await MainActor.run { [weak self] in guard let self else { return } guard tabExecution.settle(claim) else { return } - retireQueryTask(for: claim) + retireQueryTask(.claim(claim)) // A cancelled EXPLAIN is not a failure the user needs told about, and it does // not belong in history either. @@ -285,6 +284,6 @@ extension MainContentCoordinator { } } } - installQueryTask(explainTask, for: claim) + installQueryTask(explainTask, owner: .claim(claim), lease: lease) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadTracing.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadTracing.swift index 7c0a47b943..85925e1ca1 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadTracing.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+LoadTracing.swift @@ -35,7 +35,7 @@ extension MainContentCoordinator { tracer.anomaly( .blockedByInFlightExecution, token: token, - detail: "site=\(site) hasInFlightQuery=\(currentQueryTask != nil)" + detail: "site=\(site) hasInFlightQuery=\(queryTasks.hasTask(for: tabId))" ) guard !tracer.hasStartedExecution(token) else { return } tracer.finish(token: token, outcome: .blocked) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f337195a9c..45ceeb8870 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -321,7 +321,7 @@ extension MainContentCoordinator { token: started, detail: """ path=reuseActiveTab from=\(previousTableName ?? "none") \ - wasExecuting=\(wasExecuting) hasInFlightQuery=\(currentQueryTask != nil) + wasExecuting=\(wasExecuting) hasInFlightQuery=\(queryTasks.hasTask(for: tabId)) """ ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index f1d7673192..8a7803f64b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -32,19 +32,20 @@ extension MainContentCoordinator { func withExecutionDriver( scope: DatabaseScope, isTableTab: Bool, + lease: DriverLeaseOwner, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { guard isTableTab else { return try await services.databaseManager.withScopedDriver( scope: scope, route: services.databaseManager.executionRoute(for: scope), - cancellation: .cancellableRead, + cancellation: .cancellableRead(lease), body ) } return try await services.databaseManager.withTableReadDriver( scope: scope, - cancellation: .cancellableRead, + cancellation: .cancellableRead(lease), body ) } @@ -70,7 +71,7 @@ extension MainContentCoordinator { tab.pagination.isLoadingMore = false tab.pagination.isLoading = false } - retireQueryTask(for: claim) + retireQueryTask(.claim(claim)) traceExecutionFailed(traceToken, error: error) if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { reportEndedExecutions([ diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift new file mode 100644 index 0000000000..05abb1c0dc --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryTasks.swift @@ -0,0 +1,142 @@ +// +// MainContentCoordinator+QueryTasks.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +extension MainContentCoordinator { + /// Opens a tab's execution: it ends what that tab was doing, claims it, and mints the driver + /// lease every statement of the new execution runs under. + /// + /// The three are one call because they have to happen in that order and only for this tab. + /// Every start path used to end whatever the window held instead, so a Run in one tab, a table + /// opened from the sidebar, a Refresh or an Explain each killed the batch another tab was + /// running and rolled it back. + internal func beginTabExecution(for tabId: UUID) -> (claim: TabExecutionClaim, lease: DriverLeaseOwner) { + supersedeExecution(for: tabId) + return (tabExecution.claim(tabId), DriverLeaseOwner()) + } + + /// A tab reaching a second execution while the first still holds the handle means the first is + /// still running, so the displaced entry is ended rather than dropped. + internal func installQueryTask( + _ task: Task, + owner: TabQueryTaskOwner, + lease: DriverLeaseOwner + ) { + guard let displaced = queryTasks.install( + TabQueryTask(owner: owner, lease: lease, task: task) + ) else { return } + end(displaced, delivery: .background) + } + + /// Retires the tab's Stop handle, but only for the execution that installed it. A completion + /// that owns its own tab can still be a stranger to the execution the tab is running now, and + /// taking that one's handle down would leave a live query with nothing to cancel it. + /// + /// It reports nothing: what the titlebar shows is derived from `tabExecution`, so a completion + /// that cannot retire the handle can no longer leave the window claiming to be busy. + internal func retireQueryTask(_ owner: TabQueryTaskOwner) { + _ = queryTasks.retire(owner) + } + + internal func cancelQueryTask(for tabId: UUID, delivery: DriverCancellationDelivery) { + guard let entry = queryTasks.remove(tabId: tabId) else { return } + end(entry, delivery: delivery) + } + + /// Ends whatever the tab was doing so a new navigation owns it outright. Invalidating before the + /// new claim is minted is what makes "the user navigated away and no successor ever ran" still + /// discard the old result, which a counter that only moved on a successful start could not do. + /// + /// Removing the entry is also what puts the titlebar back to idle, because the indicator reads + /// the registry. A retarget need not be followed by a successor, and nothing else would have + /// lowered a stored flag. + internal func supersedeExecution(for tabId: UUID) { + reportEndedExecutions(tabExecution.invalidate(tabId, reason: .supersededNavigation).map { [$0] } ?? []) + cancelTableLoad(for: tabId) + cancelRowCountTask(for: tabId) + cancelQueryTask(for: tabId, delivery: .background) + } + + /// What Stop and `Cmd+.` do, on the tab the user is looking at and on nothing else. + /// + /// The driver cancel goes out inline, because the user is waiting on it, and the claim ends in + /// the same stretch of main-actor work, so a batch checking `isCurrent` before its commit sees + /// both or neither. A claim whose commit is already on the wire is spared: `stop` keeps it, and + /// `isStoppable` is what keeps the button from being offered over it in the first place. + /// + /// The task goes with the claim. A script-managed batch whose commit point leaves the phase and + /// runs on had its entry removed here while `stop` kept the claim, so the statements still to + /// come had nothing left to cancel them: Stop did nothing for the rest of the run, and the + /// execution ended as a `preparationAbandoned` anomaly. + internal func stopExecution(for tabId: UUID) { + let outcome = tabExecution.stop(tabId) + if !outcome.keptUninterruptibleClaim { + cancelQueryTask(for: tabId, delivery: .immediate) + } + cancelRowCountTask(for: tabId) + releaseExactCount(for: tabId) + reportEndedExecutions(outcome.ended) + tabManager.mutate(tabId: tabId) { tab in + tab.pagination.isLoadingMore = false + tab.pagination.isCountingExact = false + tab.pagination.isCountPending = false + tab.pagination.isLoading = false + } + } + + /// A window closing ends every tab it hosts, and asks the driver to stop each one's work rather + /// than only cancelling the Swift task: `Task.cancel()` is cooperative, so a single long + /// statement runs to completion inside the session gate and every other tab and window on that + /// connection queues behind it. + internal func cancelAllQueryTasks() { + for entry in queryTasks.removeAll() { + end(entry, delivery: .background) + } + } + + /// Reset execution state when a query is cancelled, releasing the tab only if this claim still + /// owns it. Settling is that gate and it comes first, exactly as `finishFailedQuery` does for + /// the other way an execution ends early. + /// + /// This used to invalidate by tab id, which releases whatever the tab is running now rather + /// than what this claim started. A cancelled execution unwinding after its successor had + /// claimed the tab therefore deleted the successor's entry, and the successor's own `settle` + /// then refused to apply the rows it had just fetched (#2342). + @MainActor + internal func resetExecutionState(claim: TabExecutionClaim, executionTime: TimeInterval) { + guard tabExecution.settle(claim) else { return } + reportEndedExecutions([ + EndedExecution(tabId: claim.tabId, startedAt: claim.startedAt, reason: .cancelledByUser) + ]) + retireQueryTask(.claim(claim)) + toolbarState.recordQueryTiming(PluginQueryTiming(total: executionTime), for: claim.tabId) + } + + /// What the window's Run, Stop and `Cmd+.` read. The window can be busy on a tab the user is + /// not looking at, and offering Stop for that one would act on the selected tab instead. + internal var isSelectedTabBusy: Bool { + guard let tabId = tabManager.selectedTabId else { return false } + return tabExecution.isBusy(tabId) + } + + internal var isSelectedTabStoppable: Bool { + guard let tabId = tabManager.selectedTabId else { return false } + return tabExecution.isStoppable(tabId) + } + + private func end(_ entry: TabQueryTask, delivery: DriverCancellationDelivery) { + entry.task.cancel() + do { + try services.databaseManager.cancelRunningQuery( + owner: entry.lease, on: connectionId, delivery: delivery + ) + } catch { + Self.logger.warning("cancelQuery failed: \(error.localizedDescription, privacy: .private)") + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift index 746b7073b6..09faf0c415 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Refresh.swift @@ -75,7 +75,7 @@ extension MainContentCoordinator { } private func reloadTableTab(at tabIndex: Int) { - cancelCurrentQuery() + stopExecution(for: tabManager.tabs[tabIndex].id) /// A refresh asks for the table as it is now, so the exact count the user requested earlier /// describes a table that may have moved on. Retiring it here is what lets the automatic /// count re-derive a total, which it otherwise refuses to do rather than downgrade an exact diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowCountTasks.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowCountTasks.swift index 4ec4ba2da0..50578cac58 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowCountTasks.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowCountTasks.swift @@ -31,8 +31,10 @@ extension MainContentCoordinator { return true } - internal func releaseAllExactCounts() { - exactCountOwners.removeAll() + /// Drops the tab's claim whoever holds it, for a Stop, which ends that tab's count without + /// knowing which one it was. + internal func releaseExactCount(for tabId: UUID) { + exactCountOwners.removeValue(forKey: tabId) } /// Drops a finished task's handle, and only its own, reporting whether it was still the owner. diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift index c952aef4d2..be5c788c36 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabClosing.swift @@ -116,13 +116,12 @@ extension MainContentCoordinator { /// the restored tab the id it had before, which is enough for the orphan to be mistaken for the /// reopened tab's own load and for that load to be refused as a duplicate. /// - /// The window's query handle is only retired when it belongs to this tab: one handle serves - /// every tab, so cancelling it blindly would take another tab's query down. + /// The query handle is this tab's own, so it goes down with the tab. No ownership check is + /// needed any more: a handle keyed by tab cannot belong to another one. internal func releaseExecution(of tab: QueryTab) { reportEndedExecutions(tabExecution.invalidate(tab.id, reason: .abandoned).map { [$0] } ?? []) cancelTableLoad(for: tab.id) cancelRowCountTask(for: tab.id) - guard currentQueryTaskOwner?.tabId == tab.id else { return } - cancelInFlightQueryTask(reach: .supersededNavigation) + cancelQueryTask(for: tab.id, delivery: .background) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift index 9d039e5aef..0746cf18da 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+WindowLifecycle.swift @@ -189,7 +189,7 @@ extension MainContentCoordinator { clearAbandonedExecutingFlagIfNeeded(for: tab) /// The task slot above stops answering the moment the load hands off to an execution: - /// `executeQueryInternal` supersedes, and `supersedeExecution` nils the very slot held by + /// `executeQueryInternal` supersedes, and `supersedeExecution` clears the very slot held by /// the task it is running inside. Every later trigger for the same navigation then found an /// empty slot and scheduled a second identical load, whose predecessor took the successor's /// claim down with it on the way out (#2342). The registry owns the other half of the same @@ -219,7 +219,7 @@ extension MainContentCoordinator { } } await self.openTableTabQuery(tabId: tabId, trigger: trigger) - if let queryTask = self.currentQueryTask { + if let queryTask = self.queryTasks.task(for: tabId) { await queryTask.value } } @@ -255,7 +255,7 @@ extension MainContentCoordinator { } private func clearAbandonedExecutingFlagIfNeeded(for tab: QueryTab) { - guard tabExecution.isExecuting(tab.id), currentQueryTask == nil else { return } + guard tabExecution.isExecuting(tab.id), !queryTasks.hasTask(for: tab.id) else { return } TableLoadTracer.shared.anomaly( .preparationAbandoned, tabId: tab.id, diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 7949ef70e6..2a899ba348 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -280,7 +280,10 @@ final class MainContentCommandActions: ObservableObject { /// existing proves nothing: it is kept alive across a lost session so a reconnect can restore /// the user's tabs. var isConnected: Bool { coordinator?.splitViewController?.isConnected ?? false } - var isQueryExecuting: Bool { coordinator?.tabExecution.isAnyExecuting ?? false } + var isQueryExecuting: Bool { coordinator?.isSelectedTabBusy ?? false } + /// Separate from `isQueryExecuting` because `Cmd+.` has to dim while a batch commits, which is + /// running work nothing can interrupt. + var isQueryStoppable: Bool { coordinator?.isSelectedTabStoppable ?? false } var safeModeLevel: SafeModeLevel { coordinator?.toolbarState.safeModeLevel ?? connection.safeModeLevel } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 5661ec24c2..ee7cc27645 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -332,13 +332,12 @@ final class MainContentCoordinator: ObservableObject { /// change. It is a value type, so every claim, settle and invalidate is a write to this /// property and invalidates its readers. @Published internal var tabExecution = TabExecutionRegistry() - internal var currentQueryTask: Task? - /// Which claim installed `currentQueryTask`. The handle is one per window while claims are one - /// per tab, so owning your own tab is not the same as owning the query the window is running: - /// superseding tab B cancels tab A's task, and A's completion would otherwise nil out B's - /// handle and leave B's query with no spinner and no way to stop it. - internal var currentQueryTaskOwner: TabExecutionClaim? + /// One in-flight query task per tab, beside the claim registry that owns each tab's result. + /// Cancellation is keyed by tab for the same reason ownership is: the window hosts several tabs + /// on one connection, and a handle shared between them made every start path a Stop for whoever + /// held it. + internal var queryTasks = TabQueryTasks() internal var rowCountTasks: [UUID: (token: UUID, task: Task)] = [:] /// Which user-requested exact count currently owns each tab's counting indicator. @@ -927,8 +926,7 @@ final class MainContentCoordinator: ObservableObject { schemaSwitchCancellable = nil fileWatcher?.stopWatching(connectionId: connectionId) fileWatcher = nil - currentQueryTask?.cancel() - currentQueryTask = nil + cancelAllQueryTasks() /// A cancelled task is not a finished one. `Task.cancel()` is cooperative, so the driver /// call may still be running and will throw on the way out; without this the resulting /// error reaches the ordinary failure path and reports a query that "failed" when what @@ -1288,8 +1286,7 @@ final class MainContentCoordinator: ObservableObject { ) { guard let (selectedTab, index) = tabManager.selectedTabAndIndex else { return } - supersedeExecution(for: selectedTab.id) - let claim = tabExecution.claim(selectedTab.id) + let (claim, lease) = beginTabExecution(for: selectedTab.id) tabManager.mutate(at: index) { tab in tab.execution.executionTime = nil @@ -1343,7 +1340,7 @@ final class MainContentCoordinator: ObservableObject { guard let self else { return } traceConnectUnavailable(traceToken) guard tabExecution.settle(claim) else { return } - retireQueryTask(for: claim) + retireQueryTask(.claim(claim)) pendingLoadTrigger = trigger } return @@ -1361,7 +1358,8 @@ final class MainContentCoordinator: ObservableObject { do { let fetchResult = try await withExecutionDriver( scope: scope, - isTableTab: isTableTab + isTableTab: isTableTab, + lease: lease ) { [queryExecutor] driver in try await queryExecutor.executeQuery( driver: driver, @@ -1398,7 +1396,7 @@ final class MainContentCoordinator: ObservableObject { traceStaleResultDropped(traceToken) return } - retireQueryTask(for: claim) + retireQueryTask(.claim(claim)) guard !Task.isCancelled else { traceStaleResultDropped(traceToken) return @@ -1473,71 +1471,7 @@ final class MainContentCoordinator: ObservableObject { ) } } - installQueryTask(queryTask, for: claim) - } - - /// A nil claim means work that runs against a tab without claiming it, which is Fetch All. It - /// still owns the handle for as long as it runs; it just cannot be retired by any claim. - internal func installQueryTask(_ task: Task, for claim: TabExecutionClaim?) { - currentQueryTask = task - currentQueryTaskOwner = claim - } - - /// Retires the window's Stop handle, but only for the execution that installed it. A completion - /// that owns its own tab can still be a stranger to the query the window is running, and taking - /// that one's handle down would leave a live query with nothing to cancel it. - /// - /// It no longer reports anything: what the titlebar shows is derived from `tabExecution`, so a - /// completion that cannot retire the handle can no longer leave the window claiming to be busy. - internal func retireQueryTask(for claim: TabExecutionClaim?) { - guard currentQueryTaskOwner == claim else { return } - currentQueryTask = nil - currentQueryTaskOwner = nil - } - - internal func cancelInFlightQueryTask(reach: DriverCancellationReach = .userStop) { - guard currentQueryTask != nil else { return } - currentQueryTask?.cancel() - do { - try services.databaseManager.cancelRunningQuery(for: connectionId, reach: reach) - } catch { - Self.logger.warning("cancelQuery failed: \(error.publicLogShape, privacy: .public)") - } - currentQueryTask = nil - currentQueryTaskOwner = nil - } - - /// Ends whatever the tab was doing so a new navigation owns it outright. Invalidating before the - /// new claim is minted is what makes "the user navigated away and no successor ever ran" still - /// discard the old result, which a counter that only moved on a successful start could not do. - /// - /// Removing the entry is also what puts the titlebar back to idle, because the indicator reads - /// the registry. A retarget need not be followed by a successor, and nothing else would have - /// lowered a stored flag. - internal func supersedeExecution(for tabId: UUID) { - reportEndedExecutions(tabExecution.invalidate(tabId, reason: .supersededNavigation).map { [$0] } ?? []) - cancelTableLoad(for: tabId) - cancelRowCountTask(for: tabId) - cancelInFlightQueryTask(reach: .supersededNavigation) - } - - /// Reset execution state when a query is cancelled, releasing the tab only if this claim still - /// owns it. Settling is that gate and it comes first, exactly as `finishFailedQuery` does for - /// the other way an execution ends early. - /// - /// This used to invalidate by tab id, which releases whatever the tab is running now rather - /// than what this claim started. A cancelled execution unwinding after its successor had - /// claimed the tab therefore deleted the successor's entry, and the successor's own `settle` - /// then refused to apply the rows it had just fetched (#2342). - @MainActor - internal func resetExecutionState(claim: TabExecutionClaim, executionTime: TimeInterval) { - guard tabExecution.settle(claim) else { return } - reportEndedExecutions([ - EndedExecution(tabId: claim.tabId, startedAt: claim.startedAt, reason: .cancelledByUser) - ]) - guard currentQueryTaskOwner == claim else { return } - retireQueryTask(for: claim) - toolbarState.recordQueryTiming(PluginQueryTiming(total: executionTime), for: claim.tabId) + installQueryTask(queryTask, owner: .claim(claim), lease: lease) } internal func resolveTableEditability(tab: QueryTab, sql: String) -> (tableName: String?, isEditable: Bool) { diff --git a/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift index b537032853..3bd997547b 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift @@ -27,6 +27,20 @@ internal struct CopyObjectsResultView: View { .font(.callout) .foregroundStyle(.secondary) } + if result.pendingInSessionTransaction { + Label( + String( + localized: """ + This copy ran inside the transaction already open on this \ + connection. Commit it to keep the copy, or roll it back to \ + discard it. + """ + ), + systemImage: "clock.arrow.circlepath" + ) + .font(.callout) + .foregroundStyle(.secondary) + } outcomes(result) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/TablePro/Views/Results/ExecutionIndicatorView.swift b/TablePro/Views/Results/ExecutionIndicatorView.swift index 84970bcbc6..f156209fd4 100644 --- a/TablePro/Views/Results/ExecutionIndicatorView.swift +++ b/TablePro/Views/Results/ExecutionIndicatorView.swift @@ -15,6 +15,10 @@ struct ExecutionIndicatorView: View { @ObservedObject private var settingsManager = AppSettingsManager.shared let isExecuting: Bool let lastTiming: PluginQueryTiming? + /// Defaulted so a preview and a caller with nothing to protect read the same as before. A batch + /// whose commit is on the wire passes false: the spinner stays and the button dims, rather than + /// offering a cancel that cannot reach the server. + var canStop = true var onCancel: (() -> Void)? /// Held back rather than the spinner inside it, so a query too fast to report leaves the @@ -63,9 +67,10 @@ struct ExecutionIndicatorView: View { } .buttonStyle(.plain) .controlSize(.small) + .disabled(!canStop) .accessibilityIdentifier("execution-stop") .accessibilityLabel(String(localized: "Cancel Query")) - .help(cancelHint) + .help(canStop ? cancelHint : String(localized: "The batch is committing and cannot be stopped.")) } else if let timing = lastTiming { durationReadout(timing) } diff --git a/TablePro/Views/Results/ExecutionReadout.swift b/TablePro/Views/Results/ExecutionReadout.swift index 3e7ed1200c..762a0bf5a3 100644 --- a/TablePro/Views/Results/ExecutionReadout.swift +++ b/TablePro/Views/Results/ExecutionReadout.swift @@ -32,6 +32,12 @@ struct ExecutionReadout: Equatable { execution.isBusy(tabId) } + /// Whether the Stop beside the spinner can still act. A batch whose `COMMIT` is on the wire + /// keeps the spinner and loses the button, because nothing can take that commit back. + var canStop: Bool { + execution.isStoppable(tabId) + } + /// Nothing to draw when no query has run and none is running. The toolbar used to hold an /// em-dash placeholder there, which spent width to say nothing. var isActive: Bool { @@ -39,6 +45,6 @@ struct ExecutionReadout: Equatable { } static func == (lhs: ExecutionReadout, rhs: ExecutionReadout) -> Bool { - lhs.isExecuting == rhs.isExecuting && lhs.lastTiming == rhs.lastTiming + lhs.isExecuting == rhs.isExecuting && lhs.canStop == rhs.canStop && lhs.lastTiming == rhs.lastTiming } } diff --git a/TablePro/Views/Results/ResultStatusBar.swift b/TablePro/Views/Results/ResultStatusBar.swift index e05b00085d..9a3c3d0f31 100644 --- a/TablePro/Views/Results/ResultStatusBar.swift +++ b/TablePro/Views/Results/ResultStatusBar.swift @@ -206,6 +206,7 @@ struct ResultStatusBar: View { ExecutionIndicatorView( isExecuting: execution.isExecuting, lastTiming: execution.lastTiming, + canStop: execution.canStop, onCancel: execution.onCancel ) } diff --git a/TablePro/Views/Structure/StructureEditingSession.swift b/TablePro/Views/Structure/StructureEditingSession.swift index e002e3529a..5b66a96990 100644 --- a/TablePro/Views/Structure/StructureEditingSession.swift +++ b/TablePro/Views/Structure/StructureEditingSession.swift @@ -76,7 +76,19 @@ internal final class StructureEditingSession: ObservableObject { @Published internal var sortState = SortState() @Published internal var sortDescriptor: StructureSortDescriptor? @Published internal var columnLayouts: [StructureTab: ColumnLayoutState] = [:] - @Published internal var serverSupport = StructureServerSupport.unrestricted + + /// A tab the server withdraws cannot stay selected, or the editor shows a grid for something + /// the server has none of and no segment matches the selection. + @Published internal var serverSupport = StructureServerSupport.unrestricted { + didSet { + guard !availableTabs.contains(selectedTab) else { return } + selectedTab = .columns + } + } + + internal var availableTabs: [StructureTab] { + StructureTabAvailability.tabs(for: connection.type, serverSupport: serverSupport) + } /// What the bottom bar offers while this tab is showing its structure. /// @@ -109,7 +121,8 @@ internal final class StructureEditingSession: ObservableObject { databaseName: String, schemaName: String?, tableName: String, - objectKind: TableInfo.TableType = .table + objectKind: TableInfo.TableType = .table, + serverSupport: StructureServerSupport = .unrestricted ) { self.identity = identity self.connection = connection @@ -117,6 +130,7 @@ internal final class StructureEditingSession: ObservableObject { self.schemaName = schemaName self.tableName = tableName self.objectKind = objectKind + self.serverSupport = serverSupport gridDelegate = StructureGridDelegate( structureChangeManager: changeManager, selectedTab: .columns, diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index d26844fee0..dcf59c68a2 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -248,7 +248,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } } case .checkConstraints: - guard editGate.allows(.addCheckConstraint) else { return } + guard editGate.allows(.dropCheckConstraint) else { return } structureChangeManager.performAsOneUndoStep { for row in translated.sorted(by: >) { guard row < structureChangeManager.workingCheckConstraints.count else { continue } diff --git a/TablePro/Views/Structure/StructureRebuildPlanRunner.swift b/TablePro/Views/Structure/StructureRebuildPlanRunner.swift index c5d562b146..2e461f7c46 100644 --- a/TablePro/Views/Structure/StructureRebuildPlanRunner.swift +++ b/TablePro/Views/Structure/StructureRebuildPlanRunner.swift @@ -115,10 +115,16 @@ enum StructureRebuildPlanRunner { _ = try? await driver.execute(query: sql) } - /// Only the transaction this plan opened is ever rolled back. Rolling back - /// unconditionally would discard a transaction the user had already opened on the same - /// session and never committed. - let usesTransaction = plan.isTransactional && driver.supportsTransactions + /// Only the transaction this plan opened is ever rolled back, and none is opened over a + /// transaction the session already holds: on the same shared session an app-owned + /// `COMMIT` commits the user's pending work, and MySQL's `START TRANSACTION` commits it + /// implicitly. A plan that cannot open one falls to its own compensation statements, + /// which is what an engine whose DDL commits as it runs already relies on. + let owner = WriteTransactionOwner.resolve( + supportsTransactions: driver.supportsTransactions, + sessionState: await driver.heldSessionTransactionState() + ) + let usesTransaction = plan.isTransactional && owner.opensTransaction if usesTransaction { try await driver.beginTransaction(mode: .readWrite) } diff --git a/TablePro/Views/Structure/StructureServerSupport.swift b/TablePro/Views/Structure/StructureServerSupport.swift index 7df85651f1..46eecc1195 100644 --- a/TablePro/Views/Structure/StructureServerSupport.swift +++ b/TablePro/Views/Structure/StructureServerSupport.swift @@ -10,11 +10,21 @@ struct StructureServerSupport: Equatable, Sendable { let unsupportedColumnFields: Set let unsupportedIndexTypes: Set + /// Why this server has no check constraints, even though the engine does. The engine's + /// capability flags describe its newest release, and MySQL before 8.0.16 and MariaDB before + /// 10.2.1 accept a `CHECK` clause and discard it. + let checkConstraintRefusal: String? + static let unrestricted = StructureServerSupport(unsupportedColumnFields: [], unsupportedIndexTypes: []) - init(unsupportedColumnFields: Set, unsupportedIndexTypes: Set) { + init( + unsupportedColumnFields: Set, + unsupportedIndexTypes: Set, + checkConstraintRefusal: String? = nil + ) { self.unsupportedColumnFields = unsupportedColumnFields self.unsupportedIndexTypes = Set(unsupportedIndexTypes.map { $0.uppercased() }) + self.checkConstraintRefusal = checkConstraintRefusal } init(driver: (any DatabaseDriver)?) { @@ -24,7 +34,8 @@ struct StructureServerSupport: Equatable, Sendable { } self.init( unsupportedColumnFields: driver.unsupportedStructureColumnFields, - unsupportedIndexTypes: driver.unsupportedIndexTypes + unsupportedIndexTypes: driver.unsupportedIndexTypes, + checkConstraintRefusal: driver.checkConstraintRefusal ) } @@ -37,6 +48,16 @@ struct StructureServerSupport: Equatable, Sendable { !unsupportedColumnFields.contains(field) } + /// Exhaustive on purpose: a new tab has to state whether this server can refuse it. + func offers(_ tab: StructureTab) -> Bool { + switch tab { + case .checkConstraints: + return checkConstraintRefusal == nil + case .columns, .indexes, .foreignKeys, .triggers, .ddl, .parts: + return true + } + } + func offeredIndexTypes( from types: [EditableIndexDefinition.IndexType] ) -> [EditableIndexDefinition.IndexType] { diff --git a/TablePro/Views/Structure/StructureTabAvailability.swift b/TablePro/Views/Structure/StructureTabAvailability.swift new file mode 100644 index 0000000000..6e113c755e --- /dev/null +++ b/TablePro/Views/Structure/StructureTabAvailability.swift @@ -0,0 +1,36 @@ +// +// StructureTabAvailability.swift +// TablePro +// + +import Foundation + +/// Which tabs the Structure editor offers, from what the engine can do and what the connected +/// server answers for. +/// +/// Both halves matter. `supportsCheckConstraints` is the engine at its newest release; the server +/// in front of the user may be older than the release that gained them, and MySQL before 8.0.16 +/// and MariaDB before 10.2.1 answer `Query OK` to a `CHECK` clause and throw it away. Offering the +/// tab there is offering an edit that reports success and changes nothing. +enum StructureTabAvailability { + static func tabs(for type: DatabaseType, serverSupport: StructureServerSupport) -> [StructureTab] { + StructureTab.allCases.filter { tab in + engineOffers(tab, on: type) && serverSupport.offers(tab) + } + } + + private static func engineOffers(_ tab: StructureTab, on type: DatabaseType) -> Bool { + switch tab { + case .foreignKeys: + return type.supportsForeignKeys + case .parts: + return type == .clickhouse + case .triggers: + return type.supportsTriggers + case .checkConstraints: + return type.supportsCheckConstraints + case .columns, .indexes, .ddl: + return true + } + } +} diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index 16b017a94b..368dea7d14 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -31,7 +31,9 @@ extension TableStructureView { await loadColumns() await loadTabDataIfNeeded(.indexes) await loadTabDataIfNeeded(.foreignKeys) - await loadTabDataIfNeeded(.checkConstraints) + if session.availableTabs.contains(.checkConstraints) { + await loadTabDataIfNeeded(.checkConstraints) + } loadSchemaForEditing() session.hasLoaded = true isInitialLoading = false diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 6b45165f6f..a6d4c0ccc8 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -302,20 +302,7 @@ struct TableStructureView: View { } private var availableTabs: [StructureTab] { - var tabs = StructureTab.allCases - if !connection.type.supportsForeignKeys { - tabs = tabs.filter { $0 != .foreignKeys } - } - if connection.type != .clickhouse { - tabs = tabs.filter { $0 != .parts } - } - if !connection.type.supportsTriggers { - tabs = tabs.filter { $0 != .triggers } - } - if !connection.type.supportsCheckConstraints { - tabs = tabs.filter { $0 != .checkConstraints } - } - return tabs + session.availableTabs } private var toolbar: some View { diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 02e9239116..44c6dfc6f7 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -57,6 +57,17 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { return MySQLServerFlavor.oceanbase(version: nil).queryTimeoutStatements(seconds: 0) } + static func serverFlavor(for databaseType: DatabaseType, banner: String?) -> MySQLServerFlavor { + switch databaseType { + case .tidb: + return .tidb(version: banner.flatMap(MySQLServerFlavor.tidbVersion(fromBanner:))) + case .oceanbase: + return .oceanbase(version: banner.flatMap(MySQLServerFlavor.oceanbaseVersion(fromServerVersion:))) + default: + return MySQLServerFlavor.fromBanner(banner) + } + } + func connect() async throws { try await LocalNetworkPermission.shared.ensureAccess(for: host) try await actor.connect( @@ -69,7 +80,7 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { _ = try await actor.execute(statement) } catch { Self.logger.warning( - "Session setup failed with \(statement, privacy: .public): \(error.localizedDescription, privacy: .public)" + "Session setup failed with \(statement, privacy: .public): \(error.localizedDescription, privacy: .private)" ) break } @@ -277,7 +288,12 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { func fetchSchemas() async throws -> [String] { [] } func beginTransaction() async throws { - _ = try await actor.execute("START TRANSACTION") + try await beginTransaction(mode: .serverDefault) + } + + func beginTransaction(mode: PluginTransactionAccessMode) async throws { + let flavor = Self.serverFlavor(for: databaseType, banner: serverVersion) + _ = try await actor.execute(flavor.beginTransactionStatement(mode: mode)) } func commitTransaction() async throws { @@ -287,6 +303,18 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { func rollbackTransaction() async throws { _ = try await actor.execute("ROLLBACK") } + + func sessionTransactionState() async -> DriverTransactionState { + await actor.transactionState() + } +} + +nonisolated enum MySQLSessionTransaction { + static func state(infoResult: my_bool, serverStatus: UInt32) -> DriverTransactionState { + guard infoResult == 0 else { return .unknown } + guard serverStatus & UInt32(SERVER_STATUS_IN_TRANS) != 0 else { return .idle } + return serverStatus & UInt32(SERVER_STATUS_AUTOCOMMIT) != 0 ? .explicitTransaction : .implicitTransaction + } } // MARK: - MySQL Actor (thread-safe C API access) @@ -424,6 +452,13 @@ private actor MySQLActor { return String(cString: mysql_get_server_info(mysql)) } + func transactionState() -> DriverTransactionState { + guard let mysql else { return .unknown } + var serverStatus: UInt32 = 0 + let infoResult = mariadb_get_info(mysql, MARIADB_CONNECTION_SERVER_STATUS, &serverStatus) + return MySQLSessionTransaction.state(infoResult: infoResult, serverStatus: serverStatus) + } + func execute(_ query: String) throws -> RawMySQLResult { guard let mysql else { throw MySQLError.notConnected } diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index e11f97681f..e78c79e8f5 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -343,7 +343,11 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { } func beginTransaction() async throws { - _ = try await actor.execute("BEGIN") + try await beginTransaction(mode: .serverDefault) + } + + func beginTransaction(mode: PluginTransactionAccessMode) async throws { + _ = try await actor.execute(postgresBeginTransactionStatement(mode: mode)) } func commitTransaction() async throws { @@ -353,6 +357,23 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { func rollbackTransaction() async throws { _ = try await actor.execute("ROLLBACK") } + + func sessionTransactionState() async -> DriverTransactionState { + await actor.transactionState() + } +} + +nonisolated enum PostgreSQLSessionTransaction { + static func state(from status: PGTransactionStatusType) -> DriverTransactionState { + switch status { + case PQTRANS_IDLE: + return .idle + case PQTRANS_INTRANS, PQTRANS_INERROR, PQTRANS_ACTIVE: + return .explicitTransaction + default: + return .unknown + } + } } // MARK: - PostgreSQL Actor (thread-safe C API access) @@ -463,6 +484,11 @@ private actor PostgreSQLActor { return PQserverVersion(conn) } + func transactionState() -> DriverTransactionState { + guard let conn else { return .unknown } + return PostgreSQLSessionTransaction.state(from: PQtransactionStatus(conn)) + } + func serverVersion() -> String? { guard let conn else { return nil } let version = PQserverVersion(conn) diff --git a/TableProMobile/TableProMobile/Helpers/RowInserter.swift b/TableProMobile/TableProMobile/Helpers/RowInserter.swift index 2e36c7c0cd..a78fc2b866 100644 --- a/TableProMobile/TableProMobile/Helpers/RowInserter.swift +++ b/TableProMobile/TableProMobile/Helpers/RowInserter.swift @@ -68,30 +68,6 @@ nonisolated enum RowInserter { ) guard !statements.isEmpty else { throw IntentDataError.noInsertableValues(table) } - if driver.supportsTransactions, statements.count > 1 { - return try await executeInTransaction(driver: driver, statements: statements) - } - return try await executeAll(driver: driver, statements: statements) - } - - private static func executeInTransaction(driver: any DatabaseDriver, statements: [String]) async throws -> Int { - try await driver.beginTransaction() - do { - let affected = try await executeAll(driver: driver, statements: statements) - try await driver.commitTransaction() - return affected - } catch { - try? await driver.rollbackTransaction() - throw error - } - } - - private static func executeAll(driver: any DatabaseDriver, statements: [String]) async throws -> Int { - var affected = 0 - for statement in statements { - let result = try await driver.execute(query: statement) - affected += max(result.rowsAffected, 0) - } - return affected + return try await driver.executeWrite(statements) } } diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 444d451ba2..608635c4bb 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -144,7 +144,7 @@ final class DataBrowserViewModel { do { foreignKeys = try await session.driver.fetchForeignKeys(table: table.name, schema: nil) } catch { - Self.logger.warning("Failed to fetch foreign keys: \(error.localizedDescription, privacy: .public)") + Self.logger.warning("Failed to fetch foreign keys: \(error.localizedDescription, privacy: .private)") } } @@ -237,7 +237,7 @@ final class DataBrowserViewModel { pagination.totalRows = Int(firstCol ?? "0") } } catch { - Self.logger.warning("Failed to fetch row count: \(error.localizedDescription, privacy: .public)") + Self.logger.warning("Failed to fetch row count: \(error.localizedDescription, privacy: .private)") } } @@ -331,15 +331,15 @@ final class DataBrowserViewModel { func deleteRow(pkValues: [(column: String, value: String)]) async -> Bool { guard let session, let table, !pkValues.isEmpty else { return false } do { - _ = try await session.driver.execute( - query: SQLBuilder.buildDelete( + try await session.driver.executeWrite([ + SQLBuilder.buildDelete( table: table.name, schema: schema, type: databaseType, driver: session.driver, primaryKeys: pkValues ) - ) + ]) await load() return true } catch { diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index 96fca1a6da..fbbecbf6bf 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -224,7 +224,7 @@ final class RowDetailViewModel { defer { isSaving = false } do { - _ = try await session.driver.execute(query: sql) + try await session.driver.executeWrite([sql]) guard currentIndex >= 0, currentIndex < rows.count else { return false } let newCells = editedValues.map { value -> Cell in value.map { Cell.text($0) } ?? .null diff --git a/TableProMobile/TableProMobile/Views/InsertRowView.swift b/TableProMobile/TableProMobile/Views/InsertRowView.swift index 0560e66b31..6e75b5b74b 100644 --- a/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -285,7 +285,7 @@ struct InsertRowView: View { defer { isSaving = false } do { - _ = try await session.driver.execute(query: sql) + try await session.driver.executeWrite([sql]) hapticSuccess.toggle() onInserted?() dismiss() diff --git a/TableProMobile/TableProMobile/Views/TableListView.swift b/TableProMobile/TableProMobile/Views/TableListView.swift index 6b990e9f7f..e4a873aea7 100644 --- a/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/TableProMobile/TableProMobile/Views/TableListView.swift @@ -158,11 +158,12 @@ struct TableListView: View { Button(String(localized: "Truncate"), role: .destructive) { if let table = tableToTruncate { Task { + guard let driver = session?.driver else { return } do { let quoted = SQLBuilder.qualifiedIdentifier( table: table.name, schema: activeSchema, for: connection.type ) - _ = try await session?.driver.execute(query: "TRUNCATE TABLE \(quoted)") + try await driver.executeWrite(["TRUNCATE TABLE \(quoted)"]) await coordinator.refreshTables() } catch { errorMessage = error.localizedDescription @@ -184,11 +185,12 @@ struct TableListView: View { Button(String(localized: "Drop"), role: .destructive) { if let table = tableToDrop { Task { + guard let driver = session?.driver else { return } do { let quoted = SQLBuilder.qualifiedIdentifier( table: table.name, schema: activeSchema, for: connection.type ) - _ = try await session?.driver.execute(query: "DROP TABLE \(quoted)") + try await driver.executeWrite(["DROP TABLE \(quoted)"]) await coordinator.refreshTables() } catch { errorMessage = error.localizedDescription diff --git a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index 30693718af..3f30e1e293 100644 --- a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -1,14 +1,14 @@ import Foundation -import Testing import TableProDatabase +@testable import TableProMobile import TableProModels +import TableProPluginKit import TableProQuery -@testable import TableProMobile +import Testing @MainActor @Suite("DataBrowserViewModel") struct DataBrowserViewModelTests { - private func makeSession(driver: MockDatabaseDriver) -> ConnectionSession { ConnectionSession( connectionId: UUID(), @@ -190,6 +190,77 @@ struct DataBrowserViewModelTests { #expect(vm.operationError != nil) } + @Test("deleteRow on an idle session opens a read-write transaction and commits it") + func deleteWrapsIdleSession() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + driver.scriptedTransactionState = .idle + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)) + ] + + let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")]) + #expect(success == true) + #expect(driver.beganTransactionModes == [.readWrite]) + #expect(driver.didCommitTransaction) + } + + @Test("a failed delete rolls the transaction back") + func deleteFailureRollsBack() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + driver.scriptedTransactionState = .idle + driver.scriptedExecuteResults = [.failure(MockDatabaseDriver.MockError.scripted)] + + let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")]) + #expect(success == false) + #expect(driver.didRollbackTransaction) + #expect(!driver.didCommitTransaction) + } + + @Test("deleteRow joins a transaction the session already holds") + func deleteJoinsOpenTransaction() async { + let driver = MockDatabaseDriver() + driver.scriptedColumns = makeColumns() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: makeColumns(), rows: [["1", "Alice"]], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["1"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "users"), databaseType: .mysql, host: "localhost") + await vm.load(isInitial: true) + + driver.scriptedTransactionState = .explicitTransaction + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + .success(QueryResult(columns: makeColumns(), rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [["0"]], rowsAffected: 0, executionTime: 0)) + ] + + let success = await vm.deleteRow(pkValues: [(column: "id", value: "1")]) + #expect(success == true) + #expect(!driver.didBeginTransaction) + #expect(!driver.didCommitTransaction) + } + @Test("changePageSize resets currentPage and totalRows") func changePageSizeResets() async { let driver = MockDatabaseDriver() diff --git a/TableProMobile/TableProMobileTests/Drivers/MySQLDriverTransactionTests.swift b/TableProMobile/TableProMobileTests/Drivers/MySQLDriverTransactionTests.swift new file mode 100644 index 0000000000..2493d2a2da --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/MySQLDriverTransactionTests.swift @@ -0,0 +1,112 @@ +import CMariaDB +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing + +@Suite("MySQL transaction access mode on iOS") +struct MySQLDriverTransactionTests { + private func readWriteStatement(type: DatabaseType, banner: String?) -> String { + MySQLDriver.serverFlavor(for: type, banner: banner).beginTransactionStatement(mode: .readWrite) + } + + @Test("MySQL and MariaDB get the version-gated read-write clause") + func versionGatedClause() { + #expect(readWriteStatement(type: .mysql, banner: "8.4.11") == "START TRANSACTION /*!50605 READ WRITE */") + #expect(readWriteStatement(type: .mysql, banner: "5.7.44") == "START TRANSACTION /*!50605 READ WRITE */") + #expect( + readWriteStatement(type: .mariadb, banner: "10.6.28-MariaDB-ubu2204") + == "START TRANSACTION /*!50605 READ WRITE */" + ) + } + + @Test("TiDB and OceanBase take the plain read-write clause even without a banner") + func plainClauseForForks() { + #expect(readWriteStatement(type: .tidb, banner: nil) == "START TRANSACTION READ WRITE") + #expect(readWriteStatement(type: .oceanbase, banner: nil) == "START TRANSACTION READ WRITE") + } + + @Test("A TiDB banner on a MySQL connection still resolves to TiDB") + func tidbBannerOnMySQLType() { + #expect(readWriteStatement(type: .mysql, banner: "8.0.11-TiDB-v8.5.0") == "START TRANSACTION READ WRITE") + #expect(MySQLDriver.serverFlavor(for: .mysql, banner: "8.0.11-TiDB-v8.5.0").tidbVersion?.major == 8) + } + + @Test("Databend opens a plain BEGIN in either mode") + func databendBegin() { + let flavor = MySQLDriver.serverFlavor(for: .mysql, banner: "8.0.17-v1.2.615-nightly") + #expect(flavor.beginTransactionStatement(mode: .readWrite) == "BEGIN") + #expect(flavor.beginTransactionStatement(mode: .serverDefault) == "BEGIN") + } + + @Test("The server default mode sends no access mode at all") + func serverDefaultMode() { + for type in [DatabaseType.mysql, .mariadb, .tidb, .oceanbase] { + let flavor = MySQLDriver.serverFlavor(for: type, banner: nil) + #expect(flavor.beginTransactionStatement(mode: .serverDefault) == "START TRANSACTION") + } + } + + @Test("The banner supplies the fork version the flavor carries") + func forkVersionsComeFromTheBanner() { + #expect( + MySQLDriver.serverFlavor(for: .tidb, banner: "8.0.11-TiDB-v8.5.0") + == .tidb(version: MySQLEngineVersion(major: 8, minor: 5, patch: 0)) + ) + #expect( + MySQLDriver.serverFlavor(for: .oceanbase, banner: "5.7.25-OceanBase_CE-v4.2.1") + == .oceanbase(version: MySQLEngineVersion(major: 4, minor: 2, patch: 1)) + ) + #expect(MySQLDriver.serverFlavor(for: .oceanbase, banner: "5.7.25") == .oceanbase(version: nil)) + } +} + +@Suite("MySQL session transaction state") +struct MySQLSessionTransactionTests { + private let inTransaction = UInt32(SERVER_STATUS_IN_TRANS) + private let autocommit = UInt32(SERVER_STATUS_AUTOCOMMIT) + + @Test("A reply the client could not read leaves the state unknown") + func unreadableReply() { + #expect(MySQLSessionTransaction.state(infoResult: 1, serverStatus: autocommit) == .unknown) + #expect(MySQLSessionTransaction.state(infoResult: 1, serverStatus: inTransaction | autocommit) == .unknown) + } + + @Test("A fresh autocommit session is idle") + func autocommitSessionIsIdle() { + #expect(MySQLSessionTransaction.state(infoResult: 0, serverStatus: autocommit) == .idle) + } + + @Test("A transaction the user opened under autocommit is explicit") + func userTransactionIsExplicit() { + #expect( + MySQLSessionTransaction.state(infoResult: 0, serverStatus: inTransaction | autocommit) + == .explicitTransaction + ) + } + + @Test("A transaction the server opened because autocommit is off is implicit") + func autocommitOffTransactionIsImplicit() { + #expect(MySQLSessionTransaction.state(infoResult: 0, serverStatus: inTransaction) == .implicitTransaction) + } + + @Test("Autocommit off with nothing started yet is idle") + func autocommitOffBeforeAnyStatementIsIdle() { + #expect(MySQLSessionTransaction.state(infoResult: 0, serverStatus: 0) == .idle) + } + + @Test("Unrelated status bits do not change the answer") + func unrelatedBitsAreIgnored() { + let noBackslashEscapes = UInt32(SERVER_STATUS_NO_BACKSLASH_ESCAPES) + #expect( + MySQLSessionTransaction.state(infoResult: 0, serverStatus: autocommit | noBackslashEscapes) == .idle + ) + #expect( + MySQLSessionTransaction.state( + infoResult: 0, serverStatus: inTransaction | autocommit | noBackslashEscapes + ) == .explicitTransaction + ) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLTransactionStatementTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLTransactionStatementTests.swift new file mode 100644 index 0000000000..dc3a19533a --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLTransactionStatementTests.swift @@ -0,0 +1,39 @@ +import CLibPQ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProPluginKit +import Testing + +@Suite("PostgreSQL transaction access mode on iOS") +struct PostgreSQLTransactionStatementTests { + @Test("A read-write transaction opens with the access mode in the statement") + func readWriteBegin() { + #expect(postgresBeginTransactionStatement(mode: .readWrite) == "BEGIN READ WRITE") + } + + @Test("The server default mode opens a plain BEGIN") + func serverDefaultBegin() { + #expect(postgresBeginTransactionStatement(mode: .serverDefault) == "BEGIN") + } +} + +@Suite("PostgreSQL session transaction state") +struct PostgreSQLSessionTransactionTests { + @Test("An idle connection reports an idle session") + func idleConnection() { + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_IDLE) == .idle) + } + + @Test("Every transaction block reports the user's own transaction") + func openTransactionBlocks() { + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_INTRANS) == .explicitTransaction) + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_INERROR) == .explicitTransaction) + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE) == .explicitTransaction) + } + + @Test("A connection libpq cannot read reports an unknown state") + func unknownStatus() { + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_UNKNOWN) == .unknown) + } +} diff --git a/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift b/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift index 9e4cbf10dc..91bb1b3866 100644 --- a/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift +++ b/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift @@ -1,8 +1,9 @@ import Foundation -import Testing import TableProDatabase -import TableProModels @testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing @Suite("RowInserter") struct RowInserterTests { @@ -55,7 +56,64 @@ struct RowInserterTests { #expect(!driver.didCommitTransaction) } - @Test("does not open a transaction for a single row") + @Test("opens the transaction read-write so a read-only session default cannot refuse the batch") + func multiRowOpensReadWrite() async throws { + let driver = makeDriver(results: [ok(), ok()]) + let rows = [ + PayloadRow(values: ["name": .text("Ada")]), + PayloadRow(values: ["name": .text("Grace")]) + ] + _ = try await RowInserter.insert( + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows + ) + #expect(driver.beganTransactionModes == [.readWrite]) + } + + @Test("wraps a single row when the session reports it is idle") + func idleSingleRowWraps() async throws { + let driver = makeDriver(results: [ok()]) + driver.scriptedTransactionState = .idle + let rows = [PayloadRow(values: ["name": .text("Ada")])] + let affected = try await RowInserter.insert( + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows + ) + #expect(affected == 1) + #expect(driver.beganTransactionModes == [.readWrite]) + #expect(driver.didCommitTransaction) + } + + @Test("joins a transaction the session already holds instead of opening one") + func explicitTransactionIsJoined() async throws { + let driver = makeDriver(results: [ok(), ok()]) + driver.scriptedTransactionState = .explicitTransaction + let rows = [ + PayloadRow(values: ["name": .text("Ada")]), + PayloadRow(values: ["name": .text("Grace")]) + ] + let affected = try await RowInserter.insert( + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows + ) + #expect(affected == 2) + #expect(!driver.didBeginTransaction) + #expect(!driver.didCommitTransaction) + } + + @Test("wraps and commits when autocommit is off, so the rows persist") + func implicitTransactionWraps() async throws { + let driver = makeDriver(results: [ok(), ok()]) + driver.scriptedTransactionState = .implicitTransaction + let rows = [ + PayloadRow(values: ["name": .text("Ada")]), + PayloadRow(values: ["name": .text("Grace")]) + ] + _ = try await RowInserter.insert( + driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows + ) + #expect(driver.beganTransactionModes == [.readWrite]) + #expect(driver.didCommitTransaction) + } + + @Test("does not open a transaction for a single row on a driver that cannot report its session") func singleRowNoTransaction() async throws { let driver = makeDriver(results: [ok()]) let rows = [PayloadRow(values: ["name": .text("Ada")])] diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 54921a5ce5..a26dcef849 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -1,6 +1,7 @@ import Foundation import TableProDatabase import TableProModels +import TableProPluginKit final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { enum MockError: Error { case scripted } @@ -11,16 +12,18 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var scriptedTables: [TableInfo] = [] var scriptedDatabases: [String] = [] var scriptedSchemas: [String] = [] + var scriptedTransactionState: DriverTransactionState = .unknown private(set) var executedQueries: [String] = [] private(set) var fetchColumnsCalls: Int = 0 private(set) var fetchForeignKeysCalls: Int = 0 + private(set) var beganTransactionModes: [PluginTransactionAccessMode] = [] private(set) var didBeginTransaction = false private(set) var didCommitTransaction = false private(set) var didRollbackTransaction = false var supportsSchemas: Bool = false - var currentSchema: String? = nil + var currentSchema: String? var supportsTransactions: Bool = true var serverVersion: String? = "Mock 1.0" var holdsSuspensionBlockingResource: Bool = false @@ -73,8 +76,15 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func switchDatabase(to name: String) async throws {} func switchSchema(to name: String) async throws {} func beginTransaction() async throws { didBeginTransaction = true } + + func beginTransaction(mode: PluginTransactionAccessMode) async throws { + beganTransactionModes.append(mode) + didBeginTransaction = true + } + func commitTransaction() async throws { didCommitTransaction = true } func rollbackTransaction() async throws { didRollbackTransaction = true } + func sessionTransactionState() async -> DriverTransactionState { scriptedTransactionState } } final class MockSecureStore: SecureStore, @unchecked Sendable { diff --git a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift index 15924b4bc7..683ebcb5e5 100644 --- a/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/RowDetailViewModelTests.swift @@ -1,13 +1,13 @@ import Foundation -import Testing import TableProDatabase -import TableProModels @testable import TableProMobile +import TableProModels +import TableProPluginKit +import Testing @MainActor @Suite("RowDetailViewModel") struct RowDetailViewModelTests { - private func makeColumns() -> [ColumnInfo] { [ ColumnInfo(name: "id", typeName: "INT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), @@ -124,6 +124,70 @@ struct RowDetailViewModelTests { #expect(query.contains("WHERE")) } + @Test("saveChanges on an idle session opens a read-write transaction and commits it") + func saveWrapsIdleSession() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionState = .idle + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)) + ] + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns() + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + let success = await vm.saveChanges() + #expect(success == true) + #expect(driver.beganTransactionModes == [.readWrite]) + #expect(driver.didCommitTransaction) + #expect(driver.executedQueries.count == 1) + } + + @Test("a failed save rolls the transaction back and reports the error") + func failedSaveRollsBack() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionState = .idle + driver.scriptedExecuteResults = [.failure(MockDatabaseDriver.MockError.scripted)] + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns() + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + let success = await vm.saveChanges() + #expect(success == false) + #expect(driver.didRollbackTransaction) + #expect(!driver.didCommitTransaction) + #expect(vm.operationError != nil) + } + + @Test("saveChanges joins a transaction the session already holds") + func saveJoinsOpenTransaction() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionState = .explicitTransaction + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)) + ] + let vm = RowDetailViewModel( + columns: makeColumns(), rows: makeRows(), initialIndex: 0, + table: TableInfo(name: "users"), session: makeSession(driver: driver), + columnDetails: makeColumns() + ) + vm.startEditing() + vm.setEditedValue("Charlie", at: 1) + + let success = await vm.saveChanges() + #expect(success == true) + #expect(!driver.didBeginTransaction) + #expect(!driver.didCommitTransaction) + #expect(driver.executedQueries.count == 1) + } + @Test("saveChanges under confirmWrites defers execution and requests confirmation") func saveConfirmWritesDefers() async { let driver = MockDatabaseDriver() diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index 0301d130cf..b13702fc0a 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -72,6 +72,8 @@ targets: # libpq COPY handling the iOS PostgreSQL driver shares with the macOS plugin. - ../Plugins/PostgreSQLDriverPlugin/LibPQPendingResultDrain.swift - ../Plugins/PostgreSQLDriverPlugin/LibPQCopyState.swift + # The read-write BEGIN both platforms open a write transaction with. + - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift configFiles: Debug: ../Configs/Version-iOS.xcconfig Release: ../Configs/Version-iOS.xcconfig diff --git a/TableProTests/Core/Concurrency/TaskCancellationShieldTests.swift b/TableProTests/Core/Concurrency/TaskCancellationShieldTests.swift new file mode 100644 index 0000000000..bec9f8465f --- /dev/null +++ b/TableProTests/Core/Concurrency/TaskCancellationShieldTests.swift @@ -0,0 +1,100 @@ +// +// TaskCancellationShieldTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Task cancellation shield") +struct TaskCancellationShieldTests { + /// What the shield exists for. A driver reads `Task.isCancelled` or installs a + /// `withTaskCancellationHandler`, and a COMMIT sent from an already-cancelled task would be + /// aborted before it reached the socket. + @Test("Work inside the shield never sees the cancellation of the task awaiting it") + func shieldedWorkNeverSeesCancellation() async { + let observed = Observation() + let task = Task { + try await TaskCancellationShield.run { + await withTaskCancellationHandler { + await observed.record(cancelled: Task.isCancelled) + try? await Task.sleep(for: .milliseconds(30)) + await observed.record(cancelledAtEnd: Task.isCancelled) + } onCancel: { + Task { await observed.recordHandlerFired() } + } + } + } + try? await Task.sleep(for: .milliseconds(5)) + task.cancel() + try? await task.value + + #expect(await observed.sawCancellationAtStart == false) + #expect(await observed.sawCancellationAtEnd == false) + #expect(await observed.handlerFired == false) + } + + /// The same work without the shield, so the test proves the shield is what makes the + /// difference rather than the probe being unable to see a cancellation at all. + @Test("The same work run structurally does see it") + func structuredWorkSeesCancellation() async { + let observed = Observation() + let task = Task { + await withTaskCancellationHandler { + try? await Task.sleep(for: .milliseconds(30)) + await observed.record(cancelledAtEnd: Task.isCancelled) + } onCancel: { + Task { await observed.recordHandlerFired() } + } + } + try? await Task.sleep(for: .milliseconds(5)) + task.cancel() + await task.value + try? await Task.sleep(for: .milliseconds(20)) + + #expect(await observed.sawCancellationAtEnd) + #expect(await observed.handlerFired) + } + + @Test("A cancelled caller still gets the value the shielded work produced") + func shieldedWorkStillReturnsItsValue() async throws { + let task = Task { () -> Int in + try await TaskCancellationShield.run { + try? await Task.sleep(for: .milliseconds(20)) + return 42 + } + } + task.cancel() + #expect(try await task.value == 42) + } + + @Test("An error the shielded work throws reaches the caller unchanged") + func shieldedWorkPropagatesItsError() async { + await #expect(throws: ProbeError.self) { + try await TaskCancellationShield.run { throw ProbeError.refused } + } + } +} + +private enum ProbeError: Error { + case refused +} + +private actor Observation { + private(set) var sawCancellationAtStart = false + private(set) var sawCancellationAtEnd = false + private(set) var handlerFired = false + + func record(cancelled: Bool) { + sawCancellationAtStart = cancelled + } + + func record(cancelledAtEnd: Bool) { + sawCancellationAtEnd = cancelledAtEnd + } + + func recordHandlerFired() { + handlerFired = true + } +} diff --git a/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift b/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift index fc8433956e..ee8f4c83fd 100644 --- a/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift +++ b/TableProTests/Core/DataWrite/DataWriteExecutorTests.swift @@ -4,8 +4,8 @@ // import Foundation -import TableProPluginKit @testable import TablePro +import TableProPluginKit import Testing /// One test double for every case in this file: it records the order of everything it was asked to @@ -19,6 +19,8 @@ private final class CountingDriver: PluginDatabaseDriver, @unchecked Sendable { /// One-based index of the statement that should throw, counting only the ones the plan runs. let failOnStatement: Int? let rollbackFails: Bool + /// What the session answers when the executor asks what it is already holding. + let sessionState: PluginSessionTransactionState /// Every call in the order it arrived: statement text, plus "BEGIN", "COMMIT" and "ROLLBACK". private(set) var trace: [String] = [] @@ -32,12 +34,14 @@ private final class CountingDriver: PluginDatabaseDriver, @unchecked Sendable { affectedRows: Int, transactional: Bool = true, failOnStatement: Int? = nil, - rollbackFails: Bool = false + rollbackFails: Bool = false, + sessionState: PluginSessionTransactionState = .idle ) { self.affectedRows = affectedRows self.transactional = transactional self.failOnStatement = failOnStatement self.rollbackFails = rollbackFails + self.sessionState = sessionState } var supportsSchemas: Bool { false } @@ -58,6 +62,8 @@ private final class CountingDriver: PluginDatabaseDriver, @unchecked Sendable { ) } + func sessionTransactionState() async -> PluginSessionTransactionState { sessionState } + func beginTransaction(mode: PluginTransactionAccessMode) async throws { trace.append("BEGIN") } func commitTransaction() async throws { trace.append("COMMIT") } func rollbackTransaction() async throws { @@ -294,4 +300,90 @@ struct DataWriteExecutorTests { #expect(results.first?.wasVerified == false) #expect(counting.didCommit) } + + @Test( + "A save joins a transaction the session already holds instead of committing it", + arguments: [PluginSessionTransactionState.inTransaction, .abortedTransaction, .holdsSessionLocks] + ) + func saveJoinsTheSessionTransaction(state: PluginSessionTransactionState) async throws { + let counting = CountingDriver(affectedRows: 1, sessionState: state) + let results = try await DataWriteExecutor.run(plan(expectedRowCount: 1), on: driver(counting)).results + + #expect(results.first?.rowsAffected == 1) + #expect(counting.trace == ["UPDATE \"t\" SET \"b\" = 0"]) + } + + @Test("A session that reports nothing open is still wrapped, and so is one that cannot say") + func saveWrapsWhenNothingIsOpen() async throws { + for state in [PluginSessionTransactionState.idle, .unknown] { + let counting = CountingDriver(affectedRows: 1, sessionState: state) + _ = try await DataWriteExecutor.run(plan(expectedRowCount: 1), on: driver(counting)) + #expect(counting.trace.first == "BEGIN") + #expect(counting.didCommit) + } + } + + @Test("A failure inside the user's transaction reports the statements as pending, not written") + func failureInsideTheSessionTransactionIsPending() async throws { + let counting = CountingDriver(affectedRows: 1, failOnStatement: 2, sessionState: .inTransaction) + + do { + _ = try await DataWriteExecutor.run( + plan(expectedRowCount: 1, statementCount: 3), on: driver(counting) + ) + Issue.record("expected the run to throw") + } catch let error as DataWritePartialCommitError { + #expect(error.disposition == .pendingInSessionTransaction) + #expect(error.committed.count == 1) + #expect(counting.didRollBack == false) + #expect(error.partialCommitMessage.contains("already open on this connection")) + } + } + + @Test("Too many rows inside the user's transaction blames neither a rollback nor the engine") + func tooManyRowsInsideTheSessionTransaction() async throws { + let counting = CountingDriver(affectedRows: 2, sessionState: .inTransaction) + await #expect( + throws: DataWriteError.tooManyRowsAffectedInSessionTransaction(table: "t", expected: 1, actual: 2) + ) { + try await DataWriteExecutor.run(plan(expectedRowCount: 1), on: driver(counting)) + } + #expect(counting.didRollBack == false) + } +} + +@Suite("Data write transaction ownership") +struct WriteTransactionOwnerTests { + @Test("An engine without transactions is nobody's to wrap") + func withoutTransactionsNobodyOwnsOne() { + for state in [ + PluginSessionTransactionState.idle, .inTransaction, .abortedTransaction, .holdsSessionLocks, .unknown, + ] { + let owner = WriteTransactionOwner.resolve(supportsTransactions: false, sessionState: state) + #expect(owner == WriteTransactionOwner.none) + #expect(owner.opensTransaction == false) + #expect(owner.canRollBack == false) + } + } + + @Test( + "A session holding a transaction or a lock owns it", + arguments: [PluginSessionTransactionState.inTransaction, .abortedTransaction, .holdsSessionLocks] + ) + func sessionOwnsWhatItHolds(state: PluginSessionTransactionState) { + let owner = WriteTransactionOwner.resolve(supportsTransactions: true, sessionState: state) + #expect(owner == .session) + #expect(owner.opensTransaction == false) + #expect(owner.canRollBack == false) + } + + @Test("The app owns the transaction when nothing is open, and when the driver cannot say") + func appOwnsTheRest() { + for state in [PluginSessionTransactionState.idle, .unknown] { + let owner = WriteTransactionOwner.resolve(supportsTransactions: true, sessionState: state) + #expect(owner == .app) + #expect(owner.opensTransaction) + #expect(owner.canRollBack) + } + } } diff --git a/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift b/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift index 87485b5202..16f283625e 100644 --- a/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerDisconnectTests.swift @@ -130,7 +130,7 @@ struct DatabaseManagerDisconnectTests { try await DatabaseManager.shared.withScopedDriver( scope: scope, route: .sessionDriver, - cancellation: .cancellableRead + cancellation: .cancellableRead(DriverLeaseOwner()) ) { driver in driver.connection.database } diff --git a/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift b/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift index 9d3e85ccc3..97ed175e0a 100644 --- a/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift +++ b/TableProTests/Core/Database/DatabaseSwitchLeaseOrderingTests.swift @@ -166,7 +166,7 @@ struct DatabaseSwitchLeaseOrderingTests { try await DatabaseManager.shared.withScopedDriver( scope: orders, route: .sessionDriver, - cancellation: .cancellableRead + cancellation: .cancellableRead(DriverLeaseOwner()) ) { driver in driver.connection.database } @@ -222,7 +222,7 @@ struct DatabaseSwitchLeaseOrderingTests { try await DatabaseManager.shared.withScopedDriver( scope: app, route: .sessionDriver, - cancellation: .cancellableRead + cancellation: .cancellableRead(DriverLeaseOwner()) ) { driver in driver.connection.database } @@ -321,7 +321,7 @@ struct DatabaseSwitchLeaseOrderingTests { try await DatabaseManager.shared.withScopedDriver( scope: app, route: .sessionDriver, - cancellation: .cancellableRead + cancellation: .cancellableRead(DriverLeaseOwner()) ) { _ in await MainActor.run { ran.didRun = true } } @@ -358,7 +358,7 @@ struct DatabaseSwitchLeaseOrderingTests { let holder = await holdDriver(connection.id, until: release) let read = Task { @MainActor in - try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead) { driver in + try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead(DriverLeaseOwner())) { driver in driver === pooled } } @@ -387,7 +387,7 @@ struct DatabaseSwitchLeaseOrderingTests { try await DatabaseManager.shared.withScopedDriver( scope: app, route: DatabaseManager.shared.executionRoute(for: app), - cancellation: .cancellableRead + cancellation: .cancellableRead(DriverLeaseOwner()) ) { _ in await MainActor.run { ran.didRun = true } } @@ -419,7 +419,7 @@ struct DatabaseSwitchLeaseOrderingTests { let running = LeaseRecord() let finish = Latch() let read = Task { @MainActor in - try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead) { driver in + try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead(DriverLeaseOwner())) { driver in await MainActor.run { running.didRun = true } await finish.wait() return driver === pooled @@ -459,7 +459,7 @@ struct DatabaseSwitchLeaseOrderingTests { let ran = LeaseRecord() let read = Task { @MainActor in - try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead) { _ in + try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead(DriverLeaseOwner())) { _ in await MainActor.run { ran.didRun = true } } } @@ -496,7 +496,7 @@ struct DatabaseSwitchLeaseOrderingTests { let ran = LeaseRecord() let read = Task { @MainActor in - try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead) { _ in + try await DatabaseManager.shared.withTableReadDriver(scope: app, cancellation: .cancellableRead(DriverLeaseOwner())) { _ in await MainActor.run { ran.didRun = true } } } diff --git a/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift b/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift index cb1cd0932f..2143911316 100644 --- a/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift +++ b/TableProTests/Core/Database/ProtectedWritePingSuppressionTests.swift @@ -39,7 +39,7 @@ struct ProtectedWritePingSuppressionTests { let connectionId = UUID() defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connectionId) } - seed(.cancellableRead, for: connectionId) + seed(.cancellableRead(DriverLeaseOwner()), for: connectionId) #expect(!DatabaseManager.shared.holdsProtectedWrite(connectionId)) } @@ -56,7 +56,7 @@ struct ProtectedWritePingSuppressionTests { let connection = TestFixtures.makeConnection(type: .postgresql) DatabaseManager.shared.runningDrivers[connectionId] = [ - UUID(): RunningDriver(driver: MockDatabaseDriver(connection: connection), policy: .cancellableRead), + UUID(): RunningDriver(driver: MockDatabaseDriver(connection: connection), policy: .cancellableRead(DriverLeaseOwner())), UUID(): RunningDriver(driver: MockDatabaseDriver(connection: connection), policy: .protectedWrite), ] diff --git a/TableProTests/Core/Database/ScopedDriverCancellationTests.swift b/TableProTests/Core/Database/ScopedDriverCancellationTests.swift index 3571b6f718..ab47b6a120 100644 --- a/TableProTests/Core/Database/ScopedDriverCancellationTests.swift +++ b/TableProTests/Core/Database/ScopedDriverCancellationTests.swift @@ -2,9 +2,10 @@ // ScopedDriverCancellationTests.swift // TableProTests // -// Who a cancel reaches, and which thread pays for it. A superseded navigation cancels off the -// main thread because a PostgreSQL cancel opens a second connection to deliver the request, and -// through an SSH tunnel that round trip cost 68-157ms of main-thread stall per click (#2061). +// Who a cancel reaches, and which thread pays for it. A cancel names the lease owner whose work it +// is ending: keyed by connection alone it reached every tab and every window on that connection, so +// starting a query in one tab rolled back the batch another tab was running (#2061 for the thread, +// and the tab-cancel defect for the owner). // import Foundation @@ -15,83 +16,211 @@ import Testing @Suite("Scoped driver cancellation", .serialized) @MainActor struct ScopedDriverCancellationTests { - @Test("A superseded navigation never cancels on the main thread") - func supersededNavigationCancelsOffTheMainThread() async throws { + @Test("A background delivery never cancels on the main thread") + func backgroundDeliveryCancelsOffTheMainThread() async throws { let connection = TestFixtures.makeConnection(type: .postgresql) let driver = CancelRecordingDriver(connection: connection) - Self.seed(driver, policy: .cancellableRead, for: connection.id) + let owner = DriverLeaseOwner() + Self.seed(driver, policy: .cancellableRead(owner), for: connection.id) defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } - try DatabaseManager.shared.cancelRunningQuery(for: connection.id, reach: .supersededNavigation) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) #expect(await Self.awaitCancel(driver)) #expect(driver.cancelledOnMainThread == false) + #expect(driver.cancelCount == 1) } - /// The cancel is fire-and-forget now, so the half that matters is that it still arrives. - /// Correctness never depended on it landing first, but the server keeps working until it does. - @Test("A superseded navigation still delivers the cancel") - func supersededNavigationStillCancels() async throws { + /// Stop is the opposite trade: the user is waiting on it, so it stays synchronous and is + /// already done by the time the call returns. Nothing here awaits before asserting. + @Test("Stop cancels inline on the caller's thread") + func immediateDeliveryCancelsSynchronously() throws { let connection = TestFixtures.makeConnection(type: .postgresql) let driver = CancelRecordingDriver(connection: connection) - Self.seed(driver, policy: .cancellableRead, for: connection.id) + let owner = DriverLeaseOwner() + Self.seed(driver, policy: .cancellableRead(owner), for: connection.id) defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } - try DatabaseManager.shared.cancelRunningQuery(for: connection.id, reach: .supersededNavigation) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) - #expect(await Self.awaitCancel(driver)) #expect(driver.cancelCount == 1) + #expect(driver.cancelledOnMainThread == true) } - /// Stop is the opposite trade: the user is waiting on it, so it stays synchronous and is - /// already done by the time the call returns. Nothing here awaits before asserting. - @Test("Stop cancels inline on the caller's thread") - func userStopCancelsSynchronously() throws { + /// The whole point of the owner. Two tabs queue on one connection, and stopping one of them must + /// leave the other's handle alone. + @Test("A cancel reaches only the lease that owns it") + func cancelReachesOneOwnerOnly() async throws { let connection = TestFixtures.makeConnection(type: .postgresql) - let driver = CancelRecordingDriver(connection: connection) - Self.seed(driver, policy: .cancellableRead, for: connection.id) + let mine = CancelRecordingDriver(connection: connection) + let theirs = CancelRecordingDriver(connection: connection) + let myOwner = DriverLeaseOwner() + let theirOwner = DriverLeaseOwner() + DatabaseManager.shared.runningDrivers[connection.id] = [ + UUID(): RunningDriver(driver: mine, policy: .cancellableRead(myOwner)), + UUID(): RunningDriver(driver: theirs, policy: .cancellableRead(theirOwner)), + ] defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } - try DatabaseManager.shared.cancelRunningQuery(for: connection.id, reach: .userStop) + try DatabaseManager.shared.cancelRunningQuery(owner: myOwner, on: connection.id, delivery: .immediate) - #expect(driver.cancelCount == 1) - #expect(driver.cancelledOnMainThread == true) + #expect(mine.cancelCount == 1) + #expect(await Self.awaitCancel(theirs) == false) + #expect(theirs.cancelCount == 0) } /// A commit or a DDL statement that is half applied cannot be undone by retrying, so neither - /// reach may abort one. + /// delivery may abort one. @Test("A protected write is never aborted, by Stop or by a supersede") func protectedWriteIsNeverCancelled() async throws { let connection = TestFixtures.makeConnection(type: .postgresql) let driver = CancelRecordingDriver(connection: connection) + let owner = DriverLeaseOwner() Self.seed(driver, policy: .protectedWrite, for: connection.id) defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } - try DatabaseManager.shared.cancelRunningQuery(for: connection.id, reach: .supersededNavigation) - try DatabaseManager.shared.cancelRunningQuery(for: connection.id, reach: .userStop) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) #expect(await Self.awaitCancel(driver) == false) #expect(driver.cancelCount == 0) } - /// The session-driver fallback aborts whatever the connection happens to be running without - /// knowing what it is, so only an explicit Stop may take it. - @Test("A supersede with nothing registered cancels nothing") - func supersededNavigationDoesNotFallBackToTheSessionDriver() async throws { + /// There is no session-driver fallback any more. An owner with nothing registered has nothing + /// running, and aborting whatever the shared driver happened to be doing is how one tab's Run + /// stopped another tab's batch. + @Test("An owner with nothing registered cancels nothing, at either delivery") + func nothingRegisteredCancelsNothing() async throws { let connection = TestFixtures.makeConnection(type: .postgresql) let driver = CancelRecordingDriver(connection: connection) + let owner = DriverLeaseOwner() DatabaseManager.shared.injectSession( ConnectionSession(connection: connection, driver: driver), for: connection.id ) defer { DatabaseManager.shared.removeSession(for: connection.id) } - try DatabaseManager.shared.cancelRunningQuery(for: connection.id, reach: .supersededNavigation) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) #expect(await Self.awaitCancel(driver) == false) #expect(driver.cancelCount == 0) } + /// The shape a committing batch actually has: its statements ran under one `.cancellableRead` + /// lease that is still open, and the commit registered the same handle again as a protected + /// write. Without the identity check the cancel would reach the commit through the lease. + @Test("A cancellable lease over the same handle as a protected write is not cancelled") + func protectedHandleIsExcludedFromItsOwnLease() async throws { + let connection = TestFixtures.makeConnection(type: .postgresql) + let driver = CancelRecordingDriver(connection: connection) + let owner = DriverLeaseOwner() + Self.seed(driver, policy: .cancellableRead(owner), for: connection.id) + let token = DatabaseManager.shared.beginProtectedWrite(on: driver, for: connection.id) + defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } + + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) + + #expect(await Self.awaitCancel(driver) == false) + #expect(driver.cancelCount == 0) + + DatabaseManager.shared.endProtectedWrite(token, for: connection.id) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) + #expect(driver.cancelCount == 1) + } + + /// The exclusion is by handle, not by connection. The owner's second lease on its own pooled + /// driver is still ordinary cancellable work while its first handle commits. + @Test("A different handle beside a protected write is still cancelled") + func otherHandlesStayCancellable() throws { + let connection = TestFixtures.makeConnection(type: .postgresql) + let committing = CancelRecordingDriver(connection: connection) + let reading = CancelRecordingDriver(connection: connection) + let owner = DriverLeaseOwner() + DatabaseManager.shared.runningDrivers[connection.id] = [ + UUID(): RunningDriver(driver: committing, policy: .cancellableRead(owner)), + UUID(): RunningDriver(driver: reading, policy: .cancellableRead(owner)), + ] + _ = DatabaseManager.shared.beginProtectedWrite(on: committing, for: connection.id) + defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } + + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) + + #expect(committing.cancelCount == 0) + #expect(reading.cancelCount == 1) + } + + /// A background cancel outlives the call that issued it, so the lease has to wait it out before + /// releasing the handle. Left unawaited it lands on whatever the connection runs next, which on + /// MariaDB is a `KILL QUERY` arriving at the following statement. + @Test("Releasing a lease hands back the pending background cancel") + func releaseHandsBackThePendingCancel() async throws { + let connection = TestFixtures.makeConnection(type: .postgresql) + let driver = CancelRecordingDriver(connection: connection) + let owner = DriverLeaseOwner() + let token = UUID() + DatabaseManager.shared.runningDrivers[connection.id] = [ + token: RunningDriver(driver: driver, policy: .cancellableRead(owner)) + ] + defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } + + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .background) + let pending = try #require(DatabaseManager.shared.releaseRunningDriver(token, for: connection.id)) + await pending.value + + #expect(driver.cancelCount == 1) + #expect(DatabaseManager.shared.runningDrivers[connection.id] == nil) + } + + /// The other half: once the lease is gone, a cancel for its owner reaches nothing at all. + @Test("A cancel issued after the lease was released reaches nothing") + func cancelAfterReleaseReachesNothing() async throws { + let connection = TestFixtures.makeConnection(type: .postgresql) + let driver = CancelRecordingDriver(connection: connection) + let owner = DriverLeaseOwner() + let token = UUID() + DatabaseManager.shared.runningDrivers[connection.id] = [ + token: RunningDriver(driver: driver, policy: .cancellableRead(owner)) + ] + defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) } + + _ = DatabaseManager.shared.releaseRunningDriver(token, for: connection.id) + try DatabaseManager.shared.cancelRunningQuery(owner: owner, on: connection.id, delivery: .immediate) + + #expect(await Self.awaitCancel(driver) == false) + #expect(driver.cancelCount == 0) + } + + /// A lease whose task was cancelled before its turn came never runs its body, so nothing lands + /// on the driver for a cancel to have to chase. `trackedLease` re-asks after registering too, so + /// the pooled route answers the same as the session route does here. + @Test("A lease cancelled before its turn never runs its body") + func cancelledLeaseNeverRunsTheBody() async throws { + let connection = TestFixtures.makeConnection(type: .postgresql) + let driver = CancelRecordingDriver(connection: connection) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let scope = DatabaseScope(connectionId: connection.id, database: connection.database, schema: nil) + let ran = LockedFlag() + let task = Task { @MainActor in + try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: .sessionDriver, + cancellation: .cancellableRead(DriverLeaseOwner()) + ) { _ in + ran.raise() + } + } + task.cancel() + + await #expect(throws: CancellationError.self) { try await task.value } + #expect(ran.isRaised == false) + } + private static func seed( _ driver: DatabaseDriver, policy: DriverCancellationPolicy, @@ -210,3 +339,21 @@ private final class CancelRecordingDriver: DatabaseDriver, @unchecked Sendable { QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) } } + +/// Raised from inside a `@Sendable` lease body and read from the test, so it needs its own lock. +private final class LockedFlag: @unchecked Sendable { + private let lock = NSLock() + private var raised = false + + func raise() { + lock.lock() + raised = true + lock.unlock() + } + + var isRaised: Bool { + lock.lock() + defer { lock.unlock() } + return raised + } +} diff --git a/TableProTests/Core/Database/ScopedDriverPinningTests.swift b/TableProTests/Core/Database/ScopedDriverPinningTests.swift index 1d23e0403e..74fd93fe51 100644 --- a/TableProTests/Core/Database/ScopedDriverPinningTests.swift +++ b/TableProTests/Core/Database/ScopedDriverPinningTests.swift @@ -78,7 +78,7 @@ struct ScopedDriverPinningTests { try await DatabaseManager.shared.withScopedDriver( scope: foreign, route: DatabaseManager.shared.executionRoute(for: foreign), - cancellation: .cancellableRead + cancellation: .cancellableRead(DriverLeaseOwner()) ) { driver in _ = try await driver.execute(query: "SELECT 1") } diff --git a/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift b/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift index b8eb7006b6..3ae5bd3963 100644 --- a/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift +++ b/TableProTests/Core/Execution/CancelledExecutionOwnershipTests.swift @@ -103,16 +103,50 @@ struct CancelledExecutionOwnershipTests { let live = coordinator.tabExecution.claim(tabId) let handle = neverEndingTask() defer { handle.cancel() } - coordinator.currentQueryTask = handle - coordinator.currentQueryTaskOwner = live + coordinator.installQueryTask(handle, owner: .claim(live), lease: DriverLeaseOwner()) coordinator.resetExecutionState(claim: superseded, executionTime: 12) - #expect(coordinator.currentQueryTask != nil) - #expect(coordinator.currentQueryTaskOwner == live) + #expect(coordinator.queryTasks.hasTask(for: tabId)) #expect(coordinator.toolbarState.queryTimings.isEmpty) } + /// Stop keeps a claim whose commit is already on the wire, and a script-managed batch leaves the + /// phase and goes on running its remaining statements. Taking the handle down anyway left those + /// statements with nothing to cancel them: Stop did nothing for the rest of the run, and the + /// execution ended reporting a `preparationAbandoned` anomaly. + @Test("Stop leaves the query handle of a claim it could not end") + func stopKeepsTheHandleOfACommittingClaim() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + let claim = coordinator.tabExecution.claim(tabId) + let handle = neverEndingTask() + defer { handle.cancel() } + coordinator.installQueryTask(handle, owner: .claim(claim), lease: DriverLeaseOwner()) + let entered = coordinator.tabExecution.enterUninterruptiblePhase(claim) + #expect(entered) + + coordinator.stopExecution(for: tabId) + + #expect(coordinator.queryTasks.hasTask(for: tabId)) + #expect(coordinator.tabExecution.isCurrent(claim)) + } + + @Test("Stop takes down the query handle of a claim it ended") + func stopEndsTheHandleOfAnOrdinaryClaim() { + let (coordinator, tabManager) = makeCoordinator() + let tabId = addTableTab(to: tabManager) + let claim = coordinator.tabExecution.claim(tabId) + let handle = neverEndingTask() + defer { handle.cancel() } + coordinator.installQueryTask(handle, owner: .claim(claim), lease: DriverLeaseOwner()) + + coordinator.stopExecution(for: tabId) + + #expect(coordinator.queryTasks.hasTask(for: tabId) == false) + #expect(coordinator.tabExecution.isCurrent(claim) == false) + } + @Test("Closing a tab releases the execution it was running") func closingATabReleasesItsExecution() { let (coordinator, tabManager) = makeCoordinator() @@ -130,8 +164,8 @@ struct CancelledExecutionOwnershipTests { #expect(coordinator.tabExecution.isAnyExecuting == false) } - /// One query handle serves every tab in the window, so closing a tab may only take the handle - /// down when the handle is that tab's. + /// Each tab owns its own handle, so closing one takes its handle down and leaves every other + /// tab's where it is. @Test("Closing a tab leaves another tab's query handle alone") func closingATabLeavesAnotherTabsHandleAlone() { let (coordinator, tabManager) = makeCoordinator() @@ -141,16 +175,21 @@ struct CancelledExecutionOwnershipTests { Issue.record("expected the closing tab to exist") return } - _ = coordinator.tabExecution.claim(closing) + let closingClaim = coordinator.tabExecution.claim(closing) let otherClaim = coordinator.tabExecution.claim(other) - let handle = neverEndingTask() - defer { handle.cancel() } - coordinator.currentQueryTask = handle - coordinator.currentQueryTaskOwner = otherClaim + let closingHandle = neverEndingTask() + let otherHandle = neverEndingTask() + defer { + closingHandle.cancel() + otherHandle.cancel() + } + coordinator.installQueryTask(closingHandle, owner: .claim(closingClaim), lease: DriverLeaseOwner()) + coordinator.installQueryTask(otherHandle, owner: .claim(otherClaim), lease: DriverLeaseOwner()) coordinator.releaseExecution(of: closingTab) - #expect(coordinator.currentQueryTask != nil) + #expect(coordinator.queryTasks.hasTask(for: closing) == false) + #expect(coordinator.queryTasks.hasTask(for: other)) #expect(coordinator.tabExecution.isCurrent(otherClaim)) } diff --git a/TableProTests/Core/Execution/TabExecutionRegistryTests.swift b/TableProTests/Core/Execution/TabExecutionRegistryTests.swift index 7ddaf8c732..fec966fc4b 100644 --- a/TableProTests/Core/Execution/TabExecutionRegistryTests.swift +++ b/TableProTests/Core/Execution/TabExecutionRegistryTests.swift @@ -129,8 +129,6 @@ struct TabExecutionRegistryTests { #expect(registry.ownsContent(reclaimed) == false) } - - @Test("An unknown tab is idle") func unknownTabIsIdle() { let registry = TabExecutionRegistry() @@ -242,4 +240,164 @@ struct TabExecutionRegistryTests { let claimB = registry.claim(UUID()) #expect(claimA.epoch != claimB.epoch) } + + // MARK: - The uninterruptible phase + + @Test("A stale claim cannot enter the uninterruptible phase") + func staleClaimCannotMarkTheTab() { + var registry = TabExecutionRegistry() + let tabId = UUID() + let first = registry.claim(tabId) + _ = registry.claim(tabId) + + let markedStale = registry.enterUninterruptiblePhase(first) + #expect(markedStale == false) + #expect(registry.isStoppable(tabId)) + } + + /// The whole point. Stop lands while the commit is on the wire, the claim survives it, and the + /// settle that follows still answers yes, so the batch's results reach the tab. + @Test("Stop keeps a claim that is committing, and it still settles afterwards") + func stopKeepsACommittingClaim() { + var registry = TabExecutionRegistry() + let tabId = UUID() + let claim = registry.claim(tabId) + let contentEpoch = registry.contentEpoch(for: tabId) + let marked = registry.enterUninterruptiblePhase(claim) + #expect(marked) + + let outcome = registry.stop(tabId) + + #expect(outcome.ended.isEmpty) + #expect(outcome.keptUninterruptibleClaim) + #expect(registry.isExecuting(tabId)) + #expect(registry.isCurrent(claim)) + #expect(registry.contentEpoch(for: tabId) == contentEpoch) + #expect(registry.isStoppable(tabId) == false) + + let settled = registry.settle(claim) + #expect(settled) + #expect(registry.isAnyExecuting == false) + } + + @Test("Stop ends the tab's own claim, bumps its content epoch, and leaves every other tab") + func stopEndsOnlyTheNamedTab() { + var registry = TabExecutionRegistry() + let other = UUID() + let stopped = UUID() + let otherClaim = registry.claim(other) + let stoppedClaim = registry.claim(stopped) + let stoppedEpoch = registry.contentEpoch(for: stopped) + let otherEpoch = registry.contentEpoch(for: other) + + let outcome = registry.stop(stopped) + + #expect(outcome.ended.map(\.tabId) == [stopped]) + #expect(outcome.ended.first?.reason == .cancelledByUser) + #expect(registry.isCurrent(stoppedClaim) == false) + #expect(registry.contentEpoch(for: stopped) != stoppedEpoch) + #expect(registry.isCurrent(otherClaim)) + #expect(registry.contentEpoch(for: other) == otherEpoch) + } + + @Test("Stop keeps a committing claim on its own tab") + func stopKeepsTheMarkedClaim() { + var registry = TabExecutionRegistry() + let committing = UUID() + let committingClaim = registry.claim(committing) + let marked = registry.enterUninterruptiblePhase(committingClaim) + #expect(marked) + + let outcome = registry.stop(committing) + + #expect(outcome.ended.isEmpty) + #expect(outcome.keptUninterruptibleClaim) + #expect(registry.isCurrent(committingClaim)) + } + + @Test("Stop ends unclaimed work even on a tab that is committing") + func stopEndsUnclaimedWork() { + var registry = TabExecutionRegistry() + let tabId = UUID() + let claim = registry.claim(tabId) + _ = registry.beginUnclaimedWork(for: tabId) + let marked = registry.enterUninterruptiblePhase(claim) + #expect(marked) + + _ = registry.stop(tabId) + + #expect(registry.isBusy(tabId)) + #expect(registry.isStoppable(tabId) == false) + } + + /// Only Stop reads the mark. Closing the tab, a retarget and a lost session all end the claim + /// whatever it is doing, because the window it belongs to is going away regardless. + @Test( + "Everything other than Stop ends a committing claim", + arguments: [ExecutionEndReason.abandoned, .sessionEnded, .supersededNavigation, .cancelledByUser] + ) + func invalidationIgnoresTheMark(reason: ExecutionEndReason) { + var registry = TabExecutionRegistry() + let tabId = UUID() + let claim = registry.claim(tabId) + let marked = registry.enterUninterruptiblePhase(claim) + #expect(marked) + + let ended = registry.invalidate(tabId, reason: reason) + + #expect(ended?.reason == reason) + #expect(registry.isExecuting(tabId) == false) + + var all = TabExecutionRegistry() + let allClaim = all.claim(UUID()) + let markedAll = all.enterUninterruptiblePhase(allClaim) + #expect(markedAll) + let endedAll = all.invalidateAll(reason: reason) + #expect(endedAll.count == 1) + #expect(all.isAnyExecuting == false) + } + + /// A script that commits half way through goes on running, so Stop has to come back. + @Test("Leaving the phase makes the claim stoppable again") + func leavingThePhaseRestoresStop() { + var registry = TabExecutionRegistry() + let tabId = UUID() + let claim = registry.claim(tabId) + let marked = registry.enterUninterruptiblePhase(claim) + #expect(marked) + registry.leaveUninterruptiblePhase(claim) + + #expect(registry.isStoppable(tabId)) + let outcome = registry.stop(tabId) + #expect(outcome.ended.map(\.tabId) == [tabId]) + #expect(!outcome.keptUninterruptibleClaim) + #expect(registry.isExecuting(tabId) == false) + } + + @Test("A stale claim cannot unmark the tab it no longer owns") + func staleClaimCannotLeaveThePhase() { + var registry = TabExecutionRegistry() + let tabId = UUID() + let stale = registry.claim(tabId) + let current = registry.claim(tabId) + let marked = registry.enterUninterruptiblePhase(current) + #expect(marked) + + registry.leaveUninterruptiblePhase(stale) + + #expect(registry.isStoppable(tabId) == false) + } + + @Test("An idle tab is not stoppable and unclaimed work is") + func stoppabilityFollowsWhatIsRunning() { + var registry = TabExecutionRegistry() + let tabId = UUID() + #expect(registry.isStoppable(tabId) == false) + + let token = registry.beginUnclaimedWork(for: tabId) + #expect(registry.isStoppable(tabId)) + + registry.endUnclaimedWork(token, for: tabId) + #expect(registry.isStoppable(tabId) == false) + } } diff --git a/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift b/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift index 4cc25949fc..5df9790306 100644 --- a/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift +++ b/TableProTests/Core/Execution/TabExecutionSettleGuardTests.swift @@ -71,6 +71,23 @@ struct TabExecutionSettleGuardTests { ) } + /// Stop is the one ending that has to spare a claim whose `COMMIT` is already on the wire, and + /// `stop(_:)` is the only call that does. `invalidateAll(reason: .cancelledByUser)` ends every + /// claim regardless, so a Stop path written that way drops the results of a batch the server + /// has already committed, and it ends the claims of every other tab besides. + @Test("No Stop path ends every execution through invalidateAll") + func stopGoesThroughTheTabsOwnStop() throws { + let offenders = try Self.sourceLines(containing: "invalidateAll(reason: .cancelledByUser)") + .filter { $0.file != "TabExecutionRegistry.swift" } + #expect( + offenders.isEmpty, + """ + The user's Stop reports `tabExecution.stop(tabId)`, which acts on one tab and keeps a \ + claim whose commit is already on the wire: \(offenders.map(\.description).sorted()) + """ + ) + } + private struct CallSite { let file: String let line: Int diff --git a/TableProTests/Core/Execution/TabQueryTaskGuardTests.swift b/TableProTests/Core/Execution/TabQueryTaskGuardTests.swift new file mode 100644 index 0000000000..dd835da778 --- /dev/null +++ b/TableProTests/Core/Execution/TabQueryTaskGuardTests.swift @@ -0,0 +1,103 @@ +// +// TabQueryTaskGuardTests.swift +// TableProTests +// +// Cancellation is per tab, and there is exactly one route to the driver's own abort. The defect +// this keeps out is not a wrong call, it is a second route: one window-wide handle, or one more +// place that asks the connection to cancel without saying whose work it is. +// + +import Foundation +import Testing + +@Suite("Per-tab cancellation guard") +struct TabQueryTaskGuardTests { + /// The window's single handle is gone. A reintroduced one is the bug: every start path cancels + /// whatever it holds, so tab B's Run kills tab A's batch and rolls it back. + @Test("No window-wide query handle survives") + func noWindowWideQueryHandle() throws { + for name in ["currentQueryTask", "currentQueryTaskOwner", "cancelInFlightQueryTask"] { + let offenders = try Self.sourceLines(containing: name) + #expect( + offenders.isEmpty, + """ + One query handle per window is what made every start path a Stop. Install and retire \ + through `queryTasks`, keyed by tab: \(offenders.map(\.description).sorted()) + """ + ) + } + } + + /// `cancelRunningQuery` names a lease owner now, and the coordinator reaches it from one place. + /// A second call site is a second owner-scoping decision, which is how the connection-wide + /// version spread in the first place. + @Test("Only the owner-scoped routes ask the driver to cancel") + func cancelRunningQueryHasOneRoutePerSubsystem() throws { + let allowed: Set = [ + "DatabaseManager+ScopedDriver.swift", + "MainContentCoordinator+QueryTasks.swift", + "DatabaseAccessBridge.swift", + ] + let offenders = try Self.sourceLines(containing: "cancelRunningQuery(") + .filter { !allowed.contains($0.file) } + #expect( + offenders.isEmpty, + """ + A cancel has to name the lease that owns the work. Route a new one through \ + `MainContentCoordinator.cancelQueryTask(for:delivery:)`: \ + \(offenders.map(\.description).sorted()) + """ + ) + } + + /// Cancelling every tab at once is a teardown, not a Stop. A Stop that reached for it would be + /// the window-wide behaviour again under a new name. + @Test("Every tab's task is only ever taken down by teardown") + func removeAllIsTeardownOnly() throws { + let allowed: Set = ["MainContentCoordinator+QueryTasks.swift"] + let offenders = try Self.sourceLines(containing: "queryTasks.removeAll") + .filter { !allowed.contains($0.file) } + #expect( + offenders.isEmpty, + """ + `cancelAllQueryTasks()` is the teardown path and the only caller: \ + \(offenders.map(\.description).sorted()) + """ + ) + } + + private struct SourceLine { + let file: String + let line: Int + var description: String { "\(file):\(line)" } + } + + private static func sourceLines(containing needle: String) throws -> [SourceLine] { + let sourceRoot = try repoRoot().appendingPathComponent("TablePro") + guard let enumerator = FileManager.default.enumerator( + at: sourceRoot, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { return [] } + + var sites: [SourceLine] = [] + for case let url as URL in enumerator where url.pathExtension == "swift" { + let text = try String(contentsOf: url, encoding: .utf8) + for (offset, line) in text.components(separatedBy: .newlines).enumerated() + where line.contains(needle) { + sites.append(SourceLine(file: url.lastPathComponent, line: offset + 1)) + } + } + return sites + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw CocoaError(.fileNoSuchFile) + } +} diff --git a/TableProTests/Core/Execution/TabQueryTasksTests.swift b/TableProTests/Core/Execution/TabQueryTasksTests.swift new file mode 100644 index 0000000000..322a7299b9 --- /dev/null +++ b/TableProTests/Core/Execution/TabQueryTasksTests.swift @@ -0,0 +1,160 @@ +// +// TabQueryTasksTests.swift +// TableProTests +// +// One query task per tab. The handle used to be one per window, which made every start path a Stop +// for whichever tab happened to hold it. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Tab query tasks") +struct TabQueryTasksTests { + @Test("Installing on an idle tab displaces nothing") + func installOnIdleTabDisplacesNothing() { + var tasks = TabQueryTasks() + let tabId = UUID() + let entry = Self.entry(for: tabId) + + #expect(tasks.install(entry) == nil) + #expect(tasks.hasTask(for: tabId)) + entry.task.cancel() + } + + /// A tab reaching a second execution while the first still holds the handle means the first is + /// still running, so the caller has to be handed it rather than losing it. + @Test("Installing over a live entry hands the old one back") + func installOverLiveEntryReturnsIt() { + var tasks = TabQueryTasks() + let tabId = UUID() + let first = Self.entry(for: tabId) + let second = Self.entry(for: tabId) + + _ = tasks.install(first) + let displaced = tasks.install(second) + + #expect(displaced?.owner == first.owner) + #expect(tasks.hasTask(for: tabId)) + first.task.cancel() + second.task.cancel() + } + + /// The whole point of the type: tab B's execution never reaches tab A's handle. + @Test("Installing on one tab leaves another tab's entry alone") + func installOnOneTabLeavesTheOtherAlone() { + var tasks = TabQueryTasks() + let tabA = UUID() + let tabB = UUID() + let entryA = Self.entry(for: tabA) + let entryB = Self.entry(for: tabB) + + _ = tasks.install(entryA) + #expect(tasks.install(entryB) == nil) + + #expect(tasks.hasTask(for: tabA)) + #expect(tasks.hasTask(for: tabB)) + entryA.task.cancel() + entryB.task.cancel() + } + + @Test("Retiring works only for the owner that installed the entry") + func retireRequiresTheExactOwner() { + var tasks = TabQueryTasks() + let tabId = UUID() + let entry = Self.entry(for: tabId) + let stranger = Self.entry(for: tabId) + + _ = tasks.install(entry) + + #expect(tasks.retire(stranger.owner) == false) + #expect(tasks.hasTask(for: tabId)) + let retired = tasks.retire(entry.owner) + #expect(retired) + #expect(tasks.hasTask(for: tabId) == false) + entry.task.cancel() + stranger.task.cancel() + } + + /// Fetch All has no claim of its own, so its owner is a token. Two of them on one tab are still + /// two owners, and the first finishing must not retire the second. + @Test("A second Fetch All token on the same tab is a different owner") + func unclaimedWorkTokensAreDistinctOwners() { + var tasks = TabQueryTasks() + let tabId = UUID() + let first = Self.entry(for: .unclaimedWork(tabId: tabId, token: UUID())) + let second = Self.entry(for: .unclaimedWork(tabId: tabId, token: UUID())) + + _ = tasks.install(first) + _ = tasks.install(second) + + #expect(tasks.retire(first.owner) == false) + let retired = tasks.retire(second.owner) + #expect(retired) + first.task.cancel() + second.task.cancel() + } + + /// A Stop, a supersede and a tab close all end that tab's work whoever started it. + @Test("Removing by tab takes the entry whoever installed it") + func removeByTabIgnoresTheOwner() { + var tasks = TabQueryTasks() + let tabId = UUID() + let entry = Self.entry(for: tabId) + + _ = tasks.install(entry) + + #expect(tasks.remove(tabId: tabId)?.owner == entry.owner) + #expect(tasks.remove(tabId: tabId) == nil) + entry.task.cancel() + } + + @Test("Removing everything hands back every entry so each lease can be cancelled") + func removeAllReturnsEveryEntry() { + var tasks = TabQueryTasks() + let entryA = Self.entry(for: UUID()) + let entryB = Self.entry(for: UUID()) + _ = tasks.install(entryA) + _ = tasks.install(entryB) + + let removed = tasks.removeAll() + + #expect(Set(removed.map(\.owner)) == Set([entryA.owner, entryB.owner])) + #expect(tasks.hasTask(for: entryA.owner.tabId) == false) + #expect(tasks.hasTask(for: entryB.owner.tabId) == false) + entryA.task.cancel() + entryB.task.cancel() + } + + @Test("The awaited handle is the one installed for that tab") + func taskLookupIsPerTab() async { + var tasks = TabQueryTasks() + let tabA = UUID() + let tabB = UUID() + let entryA = Self.entry(for: tabA) + _ = tasks.install(entryA) + + #expect(tasks.task(for: tabA) != nil) + #expect(tasks.task(for: tabB) == nil) + await tasks.task(for: tabA)?.value + } + + /// A cancel names one execution's lease, so two executions must never share one. + @Test("Each execution gets a lease of its own") + func leasesAreDistinct() { + let mine = DriverLeaseOwner() + let theirs = DriverLeaseOwner() + let copy = mine + #expect(mine != theirs) + #expect(mine == copy) + } + + private static func entry(for tabId: UUID) -> TabQueryTask { + entry(for: .claim(TabExecutionClaim(tabId: tabId, epoch: Int.random(in: 1 ... 1_000_000), startedAt: .now))) + } + + private static func entry(for owner: TabQueryTaskOwner) -> TabQueryTask { + TabQueryTask(owner: owner, lease: DriverLeaseOwner(), task: Task {}) + } +} diff --git a/TableProTests/Core/Execution/TabRetargetInvalidationTests.swift b/TableProTests/Core/Execution/TabRetargetInvalidationTests.swift index be7b267840..b8f93e8fd4 100644 --- a/TableProTests/Core/Execution/TabRetargetInvalidationTests.swift +++ b/TableProTests/Core/Execution/TabRetargetInvalidationTests.swift @@ -117,7 +117,6 @@ struct TabRetargetInvalidationTests { #expect(registry.isSameContent(capturedB, for: tabB) == false) #expect(registry.isAnyExecuting == false) } - } @Suite("DriverCancellationPolicy") @@ -125,7 +124,7 @@ struct DriverCancellationPolicyTests { @Test("Only untracked leases stay invisible to cancellation") func trackingReflectsPolicy() { #expect(DriverCancellationPolicy.untracked.isTracked == false) - #expect(DriverCancellationPolicy.cancellableRead.isTracked) + #expect(DriverCancellationPolicy.cancellableRead(DriverLeaseOwner()).isTracked) #expect(DriverCancellationPolicy.protectedWrite.isTracked) } @@ -135,6 +134,16 @@ struct DriverCancellationPolicyTests { func protectedWriteIsTrackedButNotCancellable() { let policy = DriverCancellationPolicy.protectedWrite #expect(policy.isTracked) - #expect(policy != .cancellableRead) + #expect(policy != .cancellableRead(DriverLeaseOwner())) + } + + /// The owner is the whole point: two tabs leasing the same connection are two policies, so a + /// cancel naming one cannot match the other. + @Test("Two leases on one connection are never the same policy") + func leasesDoNotMatchEachOther() { + let mine = DriverLeaseOwner() + let theirs = DriverLeaseOwner() + #expect(DriverCancellationPolicy.cancellableRead(mine) == .cancellableRead(mine)) + #expect(DriverCancellationPolicy.cancellableRead(mine) != .cancellableRead(theirs)) } } diff --git a/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift b/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift index 9b0ccd97e7..4e1dbb8c75 100644 --- a/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift +++ b/TableProTests/Core/Execution/WindowBusyStateGuardTests.swift @@ -82,6 +82,29 @@ struct WindowBusyStateGuardTests { #expect(readout(tab).isExecuting == false) } + /// The Stop button in the status bar is a second question about the same registry: whether the + /// work can still be stopped, which a batch whose `COMMIT` is on the wire cannot. It is part of + /// `==` so the bar redraws when the commit phase starts. + @Test("The readout dims its Stop while a batch is committing, and stays busy") + func executionReadoutDimsStopWhileCommitting() { + var registry = TabExecutionRegistry() + let tab = UUID() + func readout() -> ExecutionReadout { + ExecutionReadout(tabId: tab, execution: registry, lastTiming: nil, onCancel: {}) + } + + let claim = registry.claim(tab) + #expect(readout().isExecuting) + #expect(readout().canStop) + let before = readout() + + let marked = registry.enterUninterruptiblePhase(claim) + #expect(marked) + #expect(readout().isExecuting) + #expect(readout().canStop == false) + #expect(before != readout()) + } + private struct SourceLine { let file: String let line: Int diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index 42147ffb84..0201164bac 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -413,9 +413,20 @@ struct MainMenuValidationTests { var context = MenuValidationContext() #expect(!enabled(#selector(MainSplitViewController.cancelQuery(_:)), context)) context.isQueryExecuting = true + context.isQueryStoppable = true #expect(enabled(#selector(MainSplitViewController.cancelQuery(_:)), context)) } + /// A batch whose `COMMIT` is on the wire is executing and unstoppable at the same time, and + /// `Cmd+.` has to dim rather than fire into work nothing can interrupt. + @Test("Cancel Query dims while a batch is committing") + func cancelDimsWhileCommitting() { + var context = MenuValidationContext() + context.isQueryExecuting = true + context.isQueryStoppable = false + #expect(!enabled(#selector(MainSplitViewController.cancelQuery(_:)), context)) + } + @Test("Filter bar needs an active table result grid") func filterBarNeedsTableResultGrid() { var context = MenuValidationContext() diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift new file mode 100644 index 0000000000..edd97ed5d2 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyTransactionTests.swift @@ -0,0 +1,174 @@ +// +// ObjectCopyTransactionTests.swift +// TableProTests +// +// The copy runs against a driver `withMetadataDriver` hands it, which is the connection's own +// session driver on every engine that opts out of pooling (DuckDB, PGlite). A `BEGIN` of its own +// over a transaction the user already had open aborts it on DuckDB and commits their pending work +// on the engines that commit implicitly, so the copy joins the session's transaction instead, the +// way `DataWriteExecutor`, `DatabaseManager+Principals` and `StructureRebuildPlanRunner` do. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +private final class StubMetadataProvider: ScopedMetadataProviding { + private let driver: DatabaseDriver + + init(driver: DatabaseDriver) { + self.driver = driver + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { nil } +} + +private final class RecordingCopyDriver: PluginDatabaseDriver, @unchecked Sendable { + private let lock = NSLock() + private var statements: [String] = [] + private var transactions: [String] = [] + private var state: PluginSessionTransactionState + + init(sessionState: PluginSessionTransactionState) { + state = sessionState + } + + var executed: [String] { lock.withLock { statements } } + var transactionEvents: [String] { lock.withLock { transactions } } + + var capabilities: PluginCapabilities { [] } + var supportsTransactions: Bool { true } + var supportsTransactionalDDL: Bool { true } + + func sessionTransactionState() async -> PluginSessionTransactionState { lock.withLock { state } } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + lock.withLock { statements.append(query) } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func beginTransaction() async throws { lock.withLock { transactions.append("begin") } } + func commitTransaction() async throws { lock.withLock { transactions.append("commit") } } + func rollbackTransaction() async throws { lock.withLock { transactions.append("rollback") } } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@Suite("Object copy transactions") +@MainActor +struct ObjectCopyTransactionTests { + private func endpoint(_ database: String) -> DatabaseEndpoint { + DatabaseEndpoint( + scope: DatabaseScope(connectionId: UUID(), database: database, schema: nil), + connectionName: "server", + databaseType: .duckdb, + safeModeLevel: .silent, + color: .blue + ) + } + + /// A replacement: the drop and the create are one unit, which is what makes the structure phase + /// want a transaction of its own. + private func replacementPlan() -> ObjectCopyPlan { + let selection = ObjectCopySelection(kind: .table, name: "orders", schema: nil) + let step = ObjectCopyTableStep( + selection: selection, + dropStatements: [SyncStatement(sql: "DROP TABLE orders;", objectName: "orders", summary: "drop")], + sequenceStatements: [], + createStatements: [ + SyncStatement(sql: "CREATE TABLE orders (id INTEGER);", objectName: "orders", summary: "create"), + ], + truncateStatements: [], + columns: [], + primaryKeyColumns: ["id"], + sourceQuery: "SELECT \"id\" FROM \"orders\"", + targetTable: "orders", + targetSchema: nil, + estimatedRows: nil, + copiesData: false, + copiesIdentityColumn: false, + note: nil + ) + let request = ObjectCopyRequest( + source: endpoint("app"), + destination: .existing(endpoint("staging")), + objects: [selection], + content: .structure, + existingPolicy: .replace, + errorHandling: .stopAndRollback, + wrapEachTableInTransaction: true + ) + return ObjectCopyPlan( + request: request, + createsDatabase: false, + tableSteps: [step], + definitionSteps: [], + schemaStatements: [] + ) + } + + private func run(sessionState: PluginSessionTransactionState) async throws -> (ObjectCopyRunResult, RecordingCopyDriver) { + let plugin = RecordingCopyDriver(sessionState: sessionState) + let connection = TestFixtures.makeConnection(type: .duckdb) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: plugin) + let runner = ObjectCopyRunner(manager: StubMetadataProvider(driver: adapter), gate: AlwaysAllowGate()) + let result = try await runner.run(replacementPlan(), progress: ObjectCopyProgress(progress: Progress())) + return (result, plugin) + } + + @Test("An idle session lets the copy open its own transaction") + func idleSessionKeepsTheCopysOwnTransaction() async throws { + let (result, driver) = try await run(sessionState: .idle) + + #expect(driver.transactionEvents == ["begin", "commit"]) + #expect(driver.executed == ["DROP TABLE orders;", "CREATE TABLE orders (id INTEGER);"]) + #expect(result.pendingInSessionTransaction == false) + } + + /// The statements still run: they join the transaction the user opened, and only the user can + /// end it. Opening one over it is what aborted it on DuckDB. + @Test("A session holding a transaction is joined rather than wrapped") + func openSessionTransactionIsJoined() async throws { + let (result, driver) = try await run(sessionState: .inTransaction) + + #expect(driver.transactionEvents.isEmpty) + #expect(driver.executed == ["DROP TABLE orders;", "CREATE TABLE orders (id INTEGER);"]) + #expect(result.pendingInSessionTransaction) + } + + /// A `LOCK TABLES` holds no transaction to commit, but a `START TRANSACTION` would release it, + /// so the copy sends none. + @Test("A session holding locks is joined too") + func sessionLocksAreJoined() async throws { + let (result, driver) = try await run(sessionState: .holdsSessionLocks) + + #expect(driver.transactionEvents.isEmpty) + #expect(result.pendingInSessionTransaction) + } +} diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterSessionTransactionTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterSessionTransactionTests.swift new file mode 100644 index 0000000000..e0d55585d3 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginDriverAdapterSessionTransactionTests.swift @@ -0,0 +1,83 @@ +// +// PluginDriverAdapterSessionTransactionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private class BaseSessionDriver: @unchecked Sendable { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { true } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +/// A plugin built before the requirement existed, which reaches the new method through the +/// protocol's own default. +private final class DefaultSessionDriver: BaseSessionDriver, PluginDatabaseDriver {} + +private final class ReportingSessionDriver: BaseSessionDriver, PluginDatabaseDriver, @unchecked Sendable { + let state: PluginSessionTransactionState + + init(state: PluginSessionTransactionState) { + self.state = state + } + + func sessionTransactionState() async -> PluginSessionTransactionState { state } +} + +@Suite("PluginDriverAdapter session transaction state") +struct PluginDriverAdapterSessionTransactionTests { + private func makeAdapter(driver: any PluginDatabaseDriver) -> PluginDriverAdapter { + PluginDriverAdapter( + connection: DatabaseConnection(name: "Test", type: .redis), + pluginDriver: driver + ) + } + + @Test("A plugin that cannot answer reports unknown through the adapter") + func defaultIsUnknown() async { + let adapter = makeAdapter(driver: DefaultSessionDriver()) + #expect(await adapter.sessionTransactionState() == .unknown) + } + + @Test( + "A plugin's own answer is forwarded unchanged", + arguments: [ + PluginSessionTransactionState.idle, + .inTransaction, + .abortedTransaction, + .holdsSessionLocks, + .unknown, + ] + ) + func answerIsForwarded(state: PluginSessionTransactionState) async { + let adapter = makeAdapter(driver: ReportingSessionDriver(state: state)) + #expect(await adapter.sessionTransactionState() == state) + } +} diff --git a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift index 52758ae322..9e4fdccaee 100644 --- a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift +++ b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift @@ -40,6 +40,7 @@ struct PluginKitABIResilienceTests { #expect(driver.unsupportedStructureColumnFields.isEmpty) #expect(driver.unsupportedIndexTypes.isEmpty) #expect(driver.schemaOperationRefusal(.renameCheckConstraint(from: "a", to: "b")) == nil) + #expect(driver.checkConstraintRefusal == nil) #expect(driver.createSchemaStatement(name: "app") == nil) #expect(driver.createSchemaStatements(PluginSchemaDefinition(name: "app")) == nil) #expect(driver.renameSchemaStatements(name: "app", to: "archive") == nil) diff --git a/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift b/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift index cb37f0861c..c79342ba1b 100644 --- a/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift +++ b/TableProTests/Core/SchemaTracking/SchemaOperationRefusalTests.swift @@ -35,6 +35,9 @@ private final class RefusingDDLDriver: PluginDatabaseDriver, @unchecked Sendable func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? { refuse(operation) } + var checkRefusal: String? + var checkConstraintRefusal: String? { checkRefusal } + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { "CREATE TABLE \(definition.tableName) (...)" } @@ -235,4 +238,31 @@ struct SchemaOperationRefusalTests { #expect(error.localizedDescription == Self.generatedReason) } } + + /// The server in front of the user, not the engine: MySQL before 8.0.16 answers `Query OK` to + /// an `ADD CONSTRAINT ... CHECK` and throws the clause away. + @Test("A server with no check constraints refuses every check change at save time") + func serverWithoutChecksRefusesCheckChanges() { + let driver = legacyDriver() + driver.checkRefusal = "Check constraints need MySQL 8.0.16 or later." + let added = constraint("c", "x > 0") + #expect(refusal(of: .addCheckConstraint(added), driver: driver) == driver.checkRefusal) + #expect(refusal(of: .deleteCheckConstraint(added), driver: driver) == driver.checkRefusal) + let renamed = constraint("d", "x > 0") + #expect(refusal(of: .modifyCheckConstraint(old: added, new: renamed), driver: driver) == driver.checkRefusal) + let rewritten = constraint("c", "x > 1") + #expect(refusal(of: .modifyCheckConstraint(old: added, new: rewritten), driver: driver) == driver.checkRefusal) + #expect(refusal(of: .addColumn(column("qty", generated: false)), driver: driver) == nil) + } + + @Test("A server that keeps check constraints refuses none of them") + func serverWithChecksRefusesNothing() { + let driver = legacyDriver() + let added = constraint("c", "x > 0") + #expect(refusal(of: .addCheckConstraint(added), driver: driver) == nil) + #expect(refusal(of: .deleteCheckConstraint(added), driver: driver) == nil) + #expect(refusal( + of: .modifyCheckConstraint(old: added, new: constraint("c", "x > 1")), driver: driver + ) == nil) + } } diff --git a/TableProTests/Core/Services/Execution/AutocommitOnlyStatementTests.swift b/TableProTests/Core/Services/Execution/AutocommitOnlyStatementTests.swift new file mode 100644 index 0000000000..cb353c8d56 --- /dev/null +++ b/TableProTests/Core/Services/Execution/AutocommitOnlyStatementTests.swift @@ -0,0 +1,393 @@ +// +// AutocommitOnlyStatementTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private enum AutocommitOnlyFixture { + static let postgres = SQLLexicalRules(dialect: .postgres) + static let mysql = SQLLexicalRules(dialect: .mysql) + static let sqlite = SQLLexicalRules(dialect: .sqlite) + static let sqlServer = SQLLexicalRules( + dialect: .generic, + backslashEscapes: false, + bracketsDelimitIdentifiers: true + ) + + static func matches(_ statement: String, _ family: TransactionEngineFamily) -> Bool { + AutocommitOnlyStatement.matches(statement, family: family, rules: rules(for: family)) + } + + static func rules(for family: TransactionEngineFamily) -> SQLLexicalRules { + switch family { + case .postgres, .redshift, .cockroach: + return postgres + case .mysql: + return mysql + case .sqlite, .duckdb: + return sqlite + case .sqlServer: + return sqlServer + case .redis, .other: + return SQLLexicalRules(dialect: .generic) + } + } +} + +@Suite("Autocommit-only statements, PostgreSQL") +struct AutocommitOnlyStatementPostgreSQLTests { + @Test( + "PostgreSQL 17 refuses these inside a transaction block", + arguments: [ + "VACUUM", + "VACUUM FULL t", + "VACUUM (VERBOSE, ANALYZE) t", + "/* maintenance */ VACUUM t", + "-- nightly\nVACUUM t", + "CREATE DATABASE app", + "DROP DATABASE IF EXISTS app", + "CREATE TABLESPACE fast LOCATION '/mnt/fast'", + "DROP TABLESPACE fast", + "ALTER DATABASE postgres SET TABLESPACE pg_default", + "ALTER SYSTEM SET work_mem = '8MB'", + "ALTER SYSTEM RESET ALL", + "CREATE INDEX CONCURRENTLY t_v ON t(v)", + "CREATE UNIQUE INDEX CONCURRENTLY t_v ON t(v)", + "DROP INDEX CONCURRENTLY t_v", + "REINDEX INDEX CONCURRENTLY t_v", + "REINDEX TABLE CONCURRENTLY t", + "REINDEX (CONCURRENTLY) TABLE t", + "REINDEX (CONCURRENTLY true) TABLE t", + "REINDEX (CONCURRENTLY 1) TABLE t", + "REINDEX (VERBOSE, CONCURRENTLY) TABLE t", + "REINDEX SCHEMA public", + "REINDEX DATABASE app", + "REINDEX SYSTEM app", + "CLUSTER", + "CLUSTER VERBOSE", + "CLUSTER (VERBOSE)", + "CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p", + "CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p WITH (connect)", + "CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p WITH (enabled = false)", + "DROP SUBSCRIPTION s", + "ALTER SUBSCRIPTION s REFRESH PUBLICATION", + "ALTER SUBSCRIPTION s SET PUBLICATION p", + "ALTER SUBSCRIPTION s ADD PUBLICATION p", + "ALTER SUBSCRIPTION s DROP PUBLICATION p", + "ALTER SUBSCRIPTION s SET (failover = true)", + "COMMIT PREPARED 'gx'", + "ROLLBACK PREPARED 'gx'", + "DISCARD ALL", + "ALTER TABLE p DETACH PARTITION p1 CONCURRENTLY", + "ALTER TYPE mood ADD VALUE 'c'" + ] + ) + func postgresRefusals(statement: String) { + #expect(AutocommitOnlyFixture.matches(statement, .postgres)) + } + + @Test( + "PostgreSQL 17 takes these inside a transaction block", + arguments: [ + "ALTER DATABASE postgres SET work_mem = '8MB'", + "ANALYZE t", + "CHECKPOINT", + "LISTEN channel", + "LOAD 'auto_explain'", + "REINDEX TABLE t", + "REINDEX (CONCURRENTLY false) TABLE t", + "REINDEX (CONCURRENTLY off) TABLE t", + "REINDEX (CONCURRENTLY 0) TABLE t", + "REINDEX (TABLESPACE fast) TABLE t", + "CREATE INDEX \"concurrently\" ON t(v)", + "CLUSTER c USING c_id", + "CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p WITH (connect = false)", + "CREATE SUBSCRIPTION s CONNECTION 'host=x' PUBLICATION p WITH (create_slot = off)", + "ALTER SUBSCRIPTION s SET PUBLICATION p WITH (refresh = false)", + "ALTER SUBSCRIPTION s SET (streaming = on)", + "ALTER SUBSCRIPTION s ENABLE", + "DISCARD PLANS", + "DISCARD TEMP", + "DISCARD SEQUENCES", + "ALTER TABLE p DETACH PARTITION p1", + "ALTER TYPE stock ADD ATTRIBUTE weight integer", + "SELECT 'VACUUM'", + "COMMENT ON TABLE t IS 'VACUUM daily'", + "DO $$ BEGIN PERFORM 1; END $$", + "COMMIT", + "ROLLBACK", + "SET CLUSTER SETTING sql.defaults.x = 1", + "BACKUP INTO 'gs://bucket'" + ] + ) + func postgresAcceptances(statement: String) { + #expect(!AutocommitOnlyFixture.matches(statement, .postgres)) + } +} + +@Suite("Autocommit-only statements, Redshift and CockroachDB") +struct AutocommitOnlyStatementWarehouseTests { + @Test( + "Redshift restricts its own statements as well as PostgreSQL's", + arguments: [ + "CREATE EXTERNAL TABLE spectrum.sales (id int)", + "DROP EXTERNAL TABLE spectrum.sales", + "ALTER EXTERNAL TABLE spectrum.sales SET LOCATION 's3://bucket'", + "ALTER TABLE target APPEND FROM staging", + "CREATE LIBRARY f LANGUAGE plpythonu FROM 's3://bucket'", + "CREATE OR REPLACE LIBRARY f LANGUAGE plpythonu FROM 's3://bucket'", + "DROP LIBRARY f" + ] + ) + func redshiftRefusals(statement: String) { + #expect(AutocommitOnlyFixture.matches(statement, .redshift)) + #expect(!AutocommitOnlyFixture.matches(statement, .postgres)) + } + + @Test( + "CockroachDB refuses a cluster setting and an undetached bulk job", + arguments: [ + "SET CLUSTER SETTING sql.defaults.distsql = 1", + "BACKUP INTO 'gs://bucket'", + "RESTORE FROM LATEST IN 'gs://bucket'", + "IMPORT INTO t CSV DATA ('gs://bucket/t.csv')" + ] + ) + func cockroachRefusals(statement: String) { + #expect(AutocommitOnlyFixture.matches(statement, .cockroach)) + #expect(!AutocommitOnlyFixture.matches(statement, .postgres)) + } + + @Test("A detached CockroachDB job runs inside the transaction") + func detachedJobsKeepTheWrap() { + #expect(!AutocommitOnlyFixture.matches("BACKUP INTO 'gs://bucket' WITH detached", .cockroach)) + #expect(!AutocommitOnlyFixture.matches("RESTORE FROM LATEST IN 'gs://b' WITH (detached)", .cockroach)) + } +} + +@Suite("Autocommit-only statements, MySQL") +struct AutocommitOnlyStatementMySQLTests { + @Test( + "MySQL 8.4 and MariaDB 11.4 refuse these inside a transaction", + arguments: [ + "SET sql_log_bin = 0", + "SET SESSION sql_log_bin = 0", + "SET LOCAL sql_log_bin = 0", + "SET @@SESSION.SQL_LOG_BIN= 0", + "SET @@sql_log_bin = 0", + "SET @@local.sql_log_bin = 0", + "SET `sql_log_bin` = 0", + "SET sql_log_bin := 0", + "SET @@SESSION . sql_log_bin = 0", + "/*!40101 SET @@SESSION.SQL_LOG_BIN= 0 */", + "SET NAMES utf8mb4, sql_log_bin = 0", + "SET @x = 1, @@session.sql_log_bin = 0", + "SET binlog_format = ROW", + "SET SESSION binlog_direct_non_transactional_updates = 0", + "SET gtid_next = 'AUTOMATIC'", + "SET @@SESSION.binlog_row_value_options = ''", + "SET GLOBAL binlog_row_value_options = ''", + "/*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=1*/", + "SET @@GLOBAL.GTID_PURGED='ca7aa847-b2b6-11f1-88c3-d63f97f21d50:1-8'", + "SET GLOBAL gtid_mode = ON", + "SET PERSIST gtid_mode = ON", + "SET GLOBAL enforce_gtid_consistency = ON", + "SET GLOBAL read_only = 1", + "SET @@GLOBAL.read_only = 1", + "SET PERSIST read_only = 1", + "SET GLOBAL gtid_slave_pos = '0-1-3'", + "SET GLOBAL gtid_binlog_state = '0-1-3'", + "SET gtid_domain_id = 3", + "SET gtid_seq_no = 5", + "SET skip_replication = 1", + "SET STATEMENT gtid_domain_id = 3 FOR INSERT INTO t VALUES (1)", + "SET STATEMENT sql_log_bin = 0 FOR INSERT INTO t VALUES (1)", + "SET @@transaction_isolation = 'SERIALIZABLE'", + "SET @@tx_isolation = 'SERIALIZABLE'", + "SET @@transaction_read_only = 1", + "SET @@tx_read_only = 1", + "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", + "set transaction read only", + "STOP SLAVE", + "STOP REPLICA", + "STOP ALL SLAVES", + "SET binlog_transaction_compression = ON", + "SET binlog_transaction_compression_level_zstd = 3", + "SET session_track_gtids = ALL_GTIDS", + "SET pseudo_replica_mode = 1", + "SET explicit_defaults_for_timestamp = 1", + "SET xa_detach_on_prepare = OFF", + "SET group_replication_consistency = 'EVENTUAL'", + "SET GLOBAL binlog_checksum = NONE", + "SET wsrep_on = 0" + ] + ) + func mysqlRefusals(statement: String) { + #expect(AutocommitOnlyFixture.matches(statement, .mysql)) + } + + @Test( + "MySQL takes these inside a transaction, and the scope is what tells them apart", + arguments: [ + "SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN", + "SET GLOBAL binlog_format = ROW", + "SET PERSIST_ONLY read_only = 1", + "SET SESSION binlog_checksum = NONE", + "SET transaction_isolation = 'SERIALIZABLE'", + "SET SESSION transaction_isolation = 'SERIALIZABLE'", + "SET @@SESSION.transaction_isolation = 'SERIALIZABLE'", + "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED", + "SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED", + "SET @s = 'SET sql_log_bin = 0'", + "SET NAMES utf8mb4", + "SET autocommit = 0", + "SET binlog_row_image = FULL", + "SET GLOBAL super_read_only = 1", + "SET GLOBAL offline_mode = 1", + "SET TIMESTAMP = 1700000000", + "STOP GROUP_REPLICATION", + "RESET SLAVE", + "CHANGE MASTER TO MASTER_HOST = 'x'", + "SELECT 'SET sql_log_bin = 0'", + "INSERT INTO t VALUES (1)" + ] + ) + func mysqlAcceptances(statement: String) { + #expect(!AutocommitOnlyFixture.matches(statement, .mysql)) + } +} + +@Suite("Autocommit-only statements, SQLite and DuckDB") +struct AutocommitOnlyStatementEmbeddedTests { + @Test( + "SQLite refuses or silently ignores these inside a transaction", + arguments: [ + "VACUUM", + "vacuum main", + "VACUUM INTO 'copy.db'", + "DETACH other", + "DETACH DATABASE other", + "PRAGMA journal_mode = WAL", + "PRAGMA main.journal_mode=wal", + "PRAGMA journal_mode(WAL)", + "PRAGMA synchronous = OFF", + "PRAGMA main.synchronous = 1", + "PRAGMA synchronous(0)", + "PRAGMA foreign_keys = ON", + "PRAGMA foreign_keys=1", + "PRAGMA foreign_keys(ON)", + "PRAGMA main.foreign_keys = ON", + "PRAGMA wal_checkpoint", + "PRAGMA wal_checkpoint(TRUNCATE)", + "PRAGMA main.wal_checkpoint(FULL)" + ] + ) + func sqliteRefusals(statement: String) { + #expect(AutocommitOnlyFixture.matches(statement, .sqlite)) + } + + @Test( + "SQLite takes these inside a transaction", + arguments: [ + "PRAGMA foreign_keys", + "PRAGMA main.foreign_keys", + "PRAGMA foreign_key_list(t)", + "PRAGMA table_info(t)", + "PRAGMA temp_store = MEMORY", + "PRAGMA page_size = 4096", + "PRAGMA optimize", + "ATTACH 'other.db' AS other", + "SELECT 'VACUUM'", + "INSERT INTO t VALUES (1)" + ] + ) + func sqliteAcceptances(statement: String) { + #expect(!AutocommitOnlyFixture.matches(statement, .sqlite)) + } + + @Test( + "DuckDB refuses a checkpoint and a detach", + arguments: [ + "DETACH other", + "CHECKPOINT", + "CHECKPOINT other", + "FORCE CHECKPOINT", + "CALL checkpoint()", + "CALL force_checkpoint()" + ] + ) + func duckdbRefusals(statement: String) { + #expect(AutocommitOnlyFixture.matches(statement, .duckdb)) + } + + @Test( + "DuckDB takes what SQLite refuses", + arguments: [ + "VACUUM", + "ATTACH 'other.db' AS other", + "SET threads = 4", + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + "PRAGMA force_checkpoint", + "CALL pragma_version()" + ] + ) + func duckdbAcceptances(statement: String) { + #expect(!AutocommitOnlyFixture.matches(statement, .duckdb)) + } +} + +@Suite("Autocommit-only statements, SQL Server and unknown engines") +struct AutocommitOnlyStatementSQLServerTests { + @Test( + "T-SQL cannot hold these in an explicit transaction", + arguments: [ + "CREATE DATABASE Sales", + "CREATE DATABASE [Sales Db]", + "ALTER DATABASE CURRENT SET RECOVERY SIMPLE", + "DROP DATABASE Sales", + "CREATE FULLTEXT CATALOG ftCatalog", + "ALTER FULLTEXT CATALOG ftCatalog REBUILD", + "CREATE FULLTEXT INDEX ON t(c) KEY INDEX pk", + "DROP FULLTEXT INDEX ON t", + "BACKUP DATABASE Sales TO DISK = 'x.bak'", + "BACKUP LOG Sales TO DISK = 'x.trn'", + "RESTORE DATABASE Sales FROM DISK = 'x.bak'", + "RESTORE HEADERONLY FROM DISK = 'x.bak'", + "RECONFIGURE", + "RECONFIGURE WITH OVERRIDE" + ] + ) + func sqlServerRefusals(statement: String) { + #expect(AutocommitOnlyFixture.matches(statement, .sqlServer)) + } + + @Test( + "A statement that only starts with DATABASE keeps the wrap", + arguments: [ + "ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 1", + "CREATE DATABASE SCOPED CREDENTIAL c WITH IDENTITY = 'x'", + "CREATE DATABASE AUDIT SPECIFICATION a FOR SERVER AUDIT s", + "CREATE DATABASE ENCRYPTION KEY WITH ALGORITHM = AES_256", + "BACKUP CERTIFICATE c TO FILE = 'x.cer'", + "RESTORE MASTER KEY FROM FILE = 'x.key' DECRYPTION BY PASSWORD = 'p'", + "EXEC sp_configure 'show advanced options', 1", + "SELECT 1" + ] + ) + func sqlServerAcceptances(statement: String) { + #expect(!AutocommitOnlyFixture.matches(statement, .sqlServer)) + } + + @Test( + "An engine with no curated rules keeps the wrap it has today", + arguments: ["VACUUM", "CHECKPOINT", "SET sql_log_bin = 0", "CREATE DATABASE app", "RECONFIGURE"] + ) + func unknownEnginesKeepTheWrap(statement: String) { + #expect(!AutocommitOnlyFixture.matches(statement, .other)) + } +} diff --git a/TableProTests/Core/Services/Execution/BatchCommitStatementTests.swift b/TableProTests/Core/Services/Execution/BatchCommitStatementTests.swift new file mode 100644 index 0000000000..6d9626d67e --- /dev/null +++ b/TableProTests/Core/Services/Execution/BatchCommitStatementTests.swift @@ -0,0 +1,86 @@ +// +// BatchCommitStatementTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Batch commit statement") +struct BatchCommitStatementTests { + private static func matches(_ sql: String, type: DatabaseType = .postgresql) -> Bool { + BatchCommitStatement.matches(sql, rules: SQLLexicalRules(databaseType: type, descriptor: nil)) + } + + @Test( + "Every spelling of a commit is a commit point", + arguments: [ + "COMMIT", + "commit", + "COMMIT;", + "COMMIT WORK", + "COMMIT TRANSACTION", + "COMMIT TRAN", + "COMMIT AND CHAIN", + "COMMIT PREPARED 'tx1'", + " \n COMMIT ", + "-- save it\nCOMMIT", + "/* save it */ COMMIT", + ] + ) + func commitSpellingsAreCommitPoints(sql: String) { + #expect(Self.matches(sql)) + } + + /// `END` commits on PostgreSQL and SQLite, so it counts. + @Test("END on its own ends the transaction", arguments: ["END", "END;", "END WORK", "END TRANSACTION"]) + func endIsACommitPoint(sql: String) { + #expect(Self.matches(sql)) + } + + /// A block terminator is never a transaction's end, whichever engine wrote it. + @Test( + "END that closes a block is not a commit point", + arguments: ["END IF", "END LOOP", "END CASE", "END WHILE", "END REPEAT"] + ) + func endOfABlockIsNotACommitPoint(sql: String) { + #expect(Self.matches(sql) == false) + } + + @Test( + "Ordinary statements are not commit points", + arguments: [ + "INSERT INTO t VALUES (1)", + "SELECT 1", + "ROLLBACK", + "ROLLBACK TO SAVEPOINT s", + "BEGIN", + "START TRANSACTION", + "RELEASE SAVEPOINT s", + "", + ] + ) + func ordinaryStatementsAreNotCommitPoints(sql: String) { + #expect(Self.matches(sql) == false) + } + + /// The word has to open the statement. A commit named inside one is data, not control. + @Test( + "A commit named inside another statement is not a commit point", + arguments: [ + "SELECT 'COMMIT'", + "INSERT INTO log (action) VALUES ('COMMIT')", + "SELECT commit_ts FROM t", + ] + ) + func committedTextIsNotACommitPoint(sql: String) { + #expect(Self.matches(sql) == false) + } + + /// MySQL runs the body of a conditional comment, so a commit written in one is a real commit. + @Test("A commit inside a MySQL conditional comment is a commit point") + func conditionalCommentCommitIsACommitPoint() { + #expect(Self.matches("/*!40101 COMMIT */", type: .mysql)) + } +} diff --git a/TableProTests/Core/Services/Execution/BatchStatementRunTests.swift b/TableProTests/Core/Services/Execution/BatchStatementRunTests.swift new file mode 100644 index 0000000000..37d12825ad --- /dev/null +++ b/TableProTests/Core/Services/Execution/BatchStatementRunTests.swift @@ -0,0 +1,678 @@ +// +// BatchStatementRunTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Batch statement run") +@MainActor +struct BatchStatementRunTests { + private static let statements = ["INSERT INTO t VALUES (1)", "VACUUM", "SELECT 1"] + + private static func run( + plan: BatchTransactionPlan, + driver: TransactionRecordingDriver, + probe: ClaimProbe? = nil, + statements: [String] = Self.statements, + commitPoints: Set = [], + failing: String? = nil, + stopsAfter: Int = .max + ) async -> BatchStatementOutcome { + let claims = probe ?? ClaimProbe() + return await BatchStatementRun.run( + statements, + plan: plan, + mode: .readWrite, + driver: driver, + connectionId: claims.connectionId, + gate: claims.gate, + failureSQL: { $0 }, + isCommitPoint: { commitPoints.contains($0) } + ) { statement in + if claims.recordExecution() == stopsAfter { claims.stop() } + if statement == failing { throw TestError.refused } + return try await driver.execute(query: statement) + } + } + + @Test("A batch running in autocommit opens nothing and takes nothing back") + func autocommitTouchesNoTransaction() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .autocommit, driver: driver) + #expect(driver.events.isEmpty) + guard case .completed(let results) = outcome else { + Issue.record("expected a completed run, got \(outcome)") + return + } + #expect(results.count == 3) + } + + @Test("A failure in autocommit rolls nothing back and keeps what already ran") + func autocommitFailureKeepsItsResults() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .autocommit, driver: driver, failing: "VACUUM") + #expect(driver.events.isEmpty) + guard case .failed(let results, let failure, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(results.count == 1) + #expect(failure == .statement(sql: "VACUUM")) + } + + @Test("A Stop in autocommit keeps the results of the statements that already committed") + func autocommitStopKeepsItsResults() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .autocommit, driver: driver, stopsAfter: 2) + #expect(driver.events.isEmpty) + guard case .cancelled(let results) = outcome else { + Issue.record("expected a cancelled run, got \(outcome)") + return + } + #expect(results.count == 2) + } + + @Test("The app's own transaction is opened with the mode it was given and committed") + func appTransactionBeginsAndCommits() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .appTransaction, driver: driver) + #expect(driver.events == [.begin(mode: .readWrite), .commit]) + guard case .completed = outcome else { + Issue.record("expected a completed run, got \(outcome)") + return + } + } + + @Test("The app's own transaction is rolled back on a failure, and its results are dropped") + func appTransactionRollsBackAFailure() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .appTransaction, driver: driver, failing: "VACUUM") + #expect(driver.events == [.begin(mode: .readWrite), .rollback]) + guard case .failed(let results, _, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(results.count == 1) + } + + @Test("A Stop rolls the app's own transaction back and keeps no results") + func appTransactionRollsBackAStop() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .appTransaction, driver: driver, stopsAfter: 1) + #expect(driver.events == [.begin(mode: .readWrite), .rollback]) + guard case .cancelled(let results) = outcome else { + Issue.record("expected a cancelled run, got \(outcome)") + return + } + #expect(results.isEmpty) + } + + @Test("A transaction that will not start blames the start and runs no statement") + func failedStartRunsNothing() async { + let driver = TransactionRecordingDriver(failsBegin: true) + let outcome = await Self.run(plan: .appTransaction, driver: driver) + #expect(driver.events == [.begin(mode: .readWrite)]) + guard case .failed(let results, let failure, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(results.isEmpty) + #expect(failure == .transactionStart) + } + + @Test("A commit the server refused is rolled back") + func failedCommitRollsBack() async { + let driver = TransactionRecordingDriver(failsCommit: true) + let outcome = await Self.run(plan: .appTransaction, driver: driver) + #expect(driver.events == [.begin(mode: .readWrite), .commit, .rollback]) + guard case .failed(_, let failure, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(failure == .commit) + } + + @Test("A script that manages its own transaction is neither begun nor committed by the app") + func scriptTransactionIsLeftAlone() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .scriptTransaction, driver: driver) + #expect(driver.events.isEmpty) + guard case .completed = outcome else { + Issue.record("expected a completed run, got \(outcome)") + return + } + } + + @Test("A failed script still has whatever it left open rolled back") + func scriptTransactionRollsBackAFailure() async { + let driver = TransactionRecordingDriver() + _ = await Self.run(plan: .scriptTransaction, driver: driver, failing: "VACUUM") + #expect(driver.events == [.rollback]) + } + + @Test("A stopped script has whatever it left open rolled back") + func scriptTransactionRollsBackAStop() async { + let driver = TransactionRecordingDriver() + _ = await Self.run(plan: .scriptTransaction, driver: driver, stopsAfter: 1) + #expect(driver.events == [.rollback]) + } + + @Test("A run that joined the session's transaction sends no BEGIN, COMMIT or ROLLBACK") + func sessionTransactionIsNeverTouched() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .sessionTransaction, driver: driver) + #expect(driver.events.isEmpty) + guard case .completed = outcome else { + Issue.record("expected a completed run, got \(outcome)") + return + } + } + + @Test("A failure inside the session's transaction leaves it open and keeps what ran") + func sessionTransactionFailureLeavesItOpen() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .sessionTransaction, driver: driver, failing: "VACUUM") + #expect(driver.events.isEmpty) + guard case .failed(let results, _, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(results.count == 1) + } + + @Test("A Stop inside the session's transaction rolls nothing back and keeps what ran") + func sessionTransactionStopLeavesItOpen() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .sessionTransaction, driver: driver, stopsAfter: 2) + #expect(driver.events.isEmpty) + guard case .cancelled(let results) = outcome else { + Issue.record("expected a cancelled run, got \(outcome)") + return + } + #expect(results.count == 2) + } + + @Test( + "A driver without transactions is never asked to begin, commit or roll back", + arguments: [BatchTransactionPlan.appTransaction, .scriptTransaction, .autocommit, .sessionTransaction] + ) + func driversWithoutTransactionsAreLeftAlone(plan: BatchTransactionPlan) async { + let driver = TransactionRecordingDriver(supportsTransactions: false) + _ = await Self.run(plan: plan, driver: driver, failing: "VACUUM") + #expect(driver.events.isEmpty) + } + + // MARK: - The commit point + + /// The defect. Stop lands while the commit is on the wire, the commit still goes through, and + /// the batch reports what the server answered instead of pretending it was taken back. + @Test("A Stop during the commit neither kills it nor drops its results") + func stopDuringTheCommitIsTooLate() async { + let probe = ClaimProbe() + let driver = TransactionRecordingDriver() + driver.whileCommitting = { probe.stop() } + + let outcome = await Self.run(plan: .appTransaction, driver: driver, probe: probe) + + #expect(driver.events == [.begin(mode: .readWrite), .commit]) + guard case .completed(let results) = outcome else { + Issue.record("expected a completed run, got \(outcome)") + return + } + #expect(results.count == 3) + #expect(probe.isCurrent) + #expect(probe.settle()) + } + + /// The other side of the same instant. Stop landed before the commit was sent, so the batch + /// rolls back and reports itself stopped. + @Test("A Stop before the commit rolls back and never sends it") + func stopBeforeTheCommitRollsBack() async { + let driver = TransactionRecordingDriver() + let outcome = await Self.run(plan: .appTransaction, driver: driver, stopsAfter: 3) + #expect(driver.events == [.begin(mode: .readWrite), .rollback]) + guard case .cancelled = outcome else { + Issue.record("expected a cancelled run, got \(outcome)") + return + } + } + + /// The mark is held past the commit deliberately: between the server's answer and the settle + /// there is no statement left to stop, and a Stop in that gap would drop results the server has + /// already kept. + @Test("The claim stays marked after the app's own commit, until it settles") + func theAppsCommitHoldsTheMarkUntilSettle() async { + let probe = ClaimProbe() + _ = await Self.run(plan: .appTransaction, driver: TransactionRecordingDriver(), probe: probe) + + #expect(probe.isStoppable == false) + #expect(probe.commitPhaseExits == 0) + #expect(probe.settle()) + } + + /// A commit whose connection died is not a rollback, and must not be reported as one: measured + /// on MySQL 8.4.11, a commit blocked under a read lock survived `kill -9` of the client and + /// committed once the lock was released. + @Test("A commit that lost the connection reports an unknown outcome and sends no rollback") + func lostConnectionDuringCommitIsUnknown() async { + let driver = TransactionRecordingDriver(failsCommit: true) + driver.commitError = DatabaseError.queryFailed("Lost connection to MySQL server during query") + + let outcome = await Self.run(plan: .appTransaction, driver: driver) + + #expect(driver.events == [.begin(mode: .readWrite), .commit]) + guard case .failed(let results, let failure, let description) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(failure == .commitOutcomeUnknown) + #expect(results.count == 3) + #expect(description.contains("Lost connection")) + } + + /// The driver's own verdict counts too, for an engine whose message says nothing useful. + @Test("A driver that reports a lost connection makes the commit outcome unknown") + func driverReportedLossIsUnknown() async { + let driver = TransactionRecordingDriver(failsCommit: true) + driver.hasLostConnection = true + + let outcome = await Self.run(plan: .appTransaction, driver: driver) + + #expect(driver.events == [.begin(mode: .readWrite), .commit]) + guard case .failed(_, let failure, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(failure == .commitOutcomeUnknown) + } + + // MARK: - A script's own commit + + @Test("A script's own COMMIT goes through the commit point, and the batch is stoppable again") + func scriptCommitIsProtectedAndThenReleased() async { + let probe = ClaimProbe() + let driver = TransactionRecordingDriver() + + let outcome = await Self.run( + plan: .scriptTransaction, + driver: driver, + probe: probe, + statements: ["INSERT INTO t VALUES (1)", "COMMIT", "INSERT INTO t VALUES (2)"], + commitPoints: ["COMMIT"] + ) + + #expect(driver.events.isEmpty) + #expect(probe.commitPhaseEntries == 1) + #expect(probe.commitPhaseExits == 1) + #expect(probe.isStoppable) + guard case .completed(let results) = outcome else { + Issue.record("expected a completed run, got \(outcome)") + return + } + #expect(results.count == 3) + } + + @Test("A Stop during a script's own COMMIT keeps the claim, so the run can still report itself") + func stopDuringAScriptCommitKeepsTheClaim() async { + let probe = ClaimProbe() + let driver = TransactionRecordingDriver() + probe.whileEnteringCommitPhase = { probe.stop() } + + _ = await Self.run( + plan: .scriptTransaction, + driver: driver, + probe: probe, + statements: ["INSERT INTO t VALUES (1)", "COMMIT"], + commitPoints: ["COMMIT"] + ) + + #expect(probe.isCurrent) + #expect(probe.settle()) + } + + @Test("A script's COMMIT that lost the connection reports an unknown outcome, not a statement failure") + func scriptCommitLostConnectionIsUnknown() async { + let driver = TransactionRecordingDriver() + driver.failingStatements = ["COMMIT": DatabaseError.queryFailed("MySQL server has gone away")] + + let outcome = await Self.run( + plan: .scriptTransaction, + driver: driver, + statements: ["INSERT INTO t VALUES (1)", "COMMIT"], + commitPoints: ["COMMIT"] + ) + + #expect(driver.events.isEmpty) + guard case .failed(_, let failure, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(failure == .commitOutcomeUnknown) + } + + @Test("A script's COMMIT the server refused is an ordinary statement failure, and rolls back") + func scriptCommitRefusalIsAStatementFailure() async { + let driver = TransactionRecordingDriver() + driver.failingStatements = ["COMMIT": DatabaseError.queryFailed("cannot commit - no transaction is active")] + + let outcome = await Self.run( + plan: .scriptTransaction, + driver: driver, + statements: ["INSERT INTO t VALUES (1)", "COMMIT"], + commitPoints: ["COMMIT"] + ) + + #expect(driver.events == [.rollback]) + guard case .failed(_, let failure, _) = outcome else { + Issue.record("expected a failed run, got \(outcome)") + return + } + #expect(failure == .statement(sql: "COMMIT")) + } + + // MARK: - Protection and the shield + + /// Stop reaches the driver through `cancelRunningQuery`, which reads the registered handles. + /// While the commit is in flight the batch's own handle is registered as a protected write, so + /// the cancel skips it even though the statements' cancellable lease is still open around it. + @Test("The handle is unreachable by Stop while the commit is in flight") + func theCommitHandleIsProtected() async { + let probe = ClaimProbe() + let driver = TransactionRecordingDriver() + let lease = DriverLeaseOwner() + DatabaseManager.shared.runningDrivers[probe.connectionId] = [ + UUID(): RunningDriver(driver: driver, policy: .cancellableRead(lease)) + ] + defer { DatabaseManager.shared.runningDrivers.removeValue(forKey: probe.connectionId) } + + driver.whileCommitting = { + try? DatabaseManager.shared.cancelRunningQuery( + owner: lease, on: probe.connectionId, delivery: .immediate + ) + } + + _ = await Self.run(plan: .appTransaction, driver: driver, probe: probe) + + #expect(driver.cancelCount == 0) + #expect(driver.events == [.begin(mode: .readWrite), .commit]) + } + + /// The same handle is reachable again the moment the commit is over, so nothing is left + /// permanently uncancellable. + @Test("The protection is released when the commit returns") + func protectionIsReleasedAfterTheCommit() async { + let probe = ClaimProbe() + let driver = TransactionRecordingDriver() + _ = await Self.run(plan: .appTransaction, driver: driver, probe: probe) + + #expect(DatabaseManager.shared.runningDrivers[probe.connectionId] == nil) + #expect(DatabaseManager.shared.holdsProtectedWrite(probe.connectionId) == false) + } + + /// `Task.cancel()` reaches every child of the cancelled task, and a driver that reads it aborts + /// before the statement is sent. The commit is not a child, so it never sees it. + @Test("A commit already on the wire does not see the cancellation of the task awaiting it") + func theCommitIsShieldedFromTaskCancellation() async { + let driver = TransactionRecordingDriver() + let holder = TaskHolder() + let start = TestLatch() + driver.whileCommitting = { holder.cancel() } + + let task = Task { @MainActor in + await start.wait() + return await Self.run(plan: .appTransaction, driver: driver) + } + holder.task = task + start.open() + let outcome = await task.value + + #expect(driver.commitSawCancellation == false) + #expect(driver.events == [.begin(mode: .readWrite), .commit]) + guard case .completed = outcome else { + Issue.record("expected a completed run, got \(outcome)") + return + } + } + + /// The rollback after a Stop is the other statement that has to survive the cancel that asked + /// for it. Dameng never sends one issued inside a cancelled task at all. + @Test("The rollback a Stop asks for does not see the cancellation either") + func theRollbackIsShieldedFromTaskCancellation() async { + let driver = TransactionRecordingDriver() + let holder = TaskHolder() + let start = TestLatch() + driver.whileExecuting = { holder.cancel() } + + let task = Task { @MainActor in + await start.wait() + return await Self.run(plan: .appTransaction, driver: driver) + } + holder.task = task + start.open() + let outcome = await task.value + + #expect(driver.events == [.begin(mode: .readWrite), .rollback]) + #expect(driver.rollbackSawCancellation == false) + guard case .cancelled = outcome else { + Issue.record("expected a cancelled run, got \(outcome)") + return + } + } +} + +private enum TestError: Error { + case refused +} + +/// A real registry behind the gate, so the order Stop and the commit mark land in is the order the +/// app's is, rather than a pair of closures a test wrote to agree with itself. +@MainActor +private final class ClaimProbe { + let connectionId = UUID() + private var registry: TabExecutionRegistry + private let claim: TabExecutionClaim + private let tabId: UUID + private var executions = 0 + + private(set) var commitPhaseEntries = 0 + private(set) var commitPhaseExits = 0 + + /// Fires after the claim has been marked, which is where a Stop that lands during the commit + /// has to be delivered for the test to mean anything. + var whileEnteringCommitPhase: (() -> Void)? + + init() { + var registry = TabExecutionRegistry() + let tabId = UUID() + claim = registry.claim(tabId) + self.registry = registry + self.tabId = tabId + } + + var gate: BatchClaimGate { + BatchClaimGate( + isCurrent: { self.registry.isCurrent(self.claim) }, + enterCommitPhase: { self.enterCommitPhase() }, + leaveCommitPhase: { self.leaveCommitPhase() } + ) + } + + var isCurrent: Bool { registry.isCurrent(claim) } + var isStoppable: Bool { registry.isStoppable(tabId) } + + func recordExecution() -> Int { + executions += 1 + return executions + } + + func stop() { + _ = registry.stop(tabId) + } + + func settle() -> Bool { + registry.settle(claim) + } + + private func enterCommitPhase() -> Bool { + commitPhaseEntries += 1 + let entered = registry.enterUninterruptiblePhase(claim) + whileEnteringCommitPhase?() + return entered + } + + private func leaveCommitPhase() { + commitPhaseExits += 1 + registry.leaveUninterruptiblePhase(claim) + } +} + +@MainActor +private final class TaskHolder { + var task: Task? + + func cancel() { + task?.cancel() + } +} + +@MainActor +private final class TestLatch { + private var waiters: [CheckedContinuation] = [] + private var isOpen = false + + func open() { + guard !isOpen else { return } + isOpen = true + let pending = waiters + waiters = [] + for waiter in pending { waiter.resume() } + } + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } +} + +private enum TransactionEvent: Equatable { + case begin(mode: PluginTransactionAccessMode) + case commit + case rollback +} + +/// Records the transaction calls one run makes, in order. Everything else answers empty: the run +/// under test executes statements through the closure it is given, not through the driver. +private final class TransactionRecordingDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + let supportsTransactions: Bool + var hasLostConnection = false + + private(set) var events: [TransactionEvent] = [] + private(set) var cancelCount = 0 + private(set) var commitSawCancellation = false + private(set) var rollbackSawCancellation = false + + /// What each statement the run executes through this driver should throw, by its text. Only the + /// commit-point cases use it: everything else fails through the run's own closure. + var failingStatements: [String: Error] = [:] + var commitError: Error = TestError.refused + /// Run on the main actor from inside the commit, which is the only window a Stop has left. + var whileCommitting: (@MainActor @Sendable () -> Void)? + /// Run on the main actor from inside the first statement. + var whileExecuting: (@MainActor @Sendable () -> Void)? + + private let failsBegin: Bool + private let failsCommit: Bool + private var executedStatements = 0 + + init(supportsTransactions: Bool = true, failsBegin: Bool = false, failsCommit: Bool = false) { + connection = TestFixtures.makeConnection(type: .sqlite) + self.supportsTransactions = supportsTransactions + self.failsBegin = failsBegin + self.failsCommit = failsCommit + } + + func beginTransaction() async throws { + try await beginTransaction(mode: .readWrite) + } + + func beginTransaction(mode: PluginTransactionAccessMode) async throws { + events.append(.begin(mode: mode)) + if failsBegin { throw TestError.refused } + } + + func commitTransaction() async throws { + events.append(.commit) + commitSawCancellation = Task.isCancelled + if let hook = whileCommitting { await MainActor.run { hook() } } + commitSawCancellation = commitSawCancellation || Task.isCancelled + if failsCommit { throw commitError } + } + + func rollbackTransaction() async throws { + events.append(.rollback) + rollbackSawCancellation = Task.isCancelled + } + + func cancelQuery() throws { + cancelCount += 1 + } + + func execute(query: String) async throws -> QueryResult { + executedStatements += 1 + if executedStatements == 1, let hook = whileExecuting { await MainActor.run { hook() } } + if let error = failingStatements[query] { throw error } + return .empty + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { .empty } + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { .empty } + func fetchTables() async throws -> [TableInfo] { [] } + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { [:] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchDatabases() async throws -> [String] { [] } + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, + name: database, + tableCount: nil, + sizeBytes: nil, + lastAccessed: nil, + isSystemDatabase: false, + icon: "cylinder" + ) + } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: nil, + comment: nil, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } +} diff --git a/TableProTests/Core/Services/Execution/BatchTransactionPlanTests.swift b/TableProTests/Core/Services/Execution/BatchTransactionPlanTests.swift new file mode 100644 index 0000000000..3588fa3319 --- /dev/null +++ b/TableProTests/Core/Services/Execution/BatchTransactionPlanTests.swift @@ -0,0 +1,97 @@ +// +// BatchTransactionPlanTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Batch transaction plan") +struct BatchTransactionPlanTests { + private static let textPlans: [BatchTransactionPlan] = [.appTransaction, .scriptTransaction, .autocommit] + + @Test("Only the app's own plan opens a transaction") + func onlyTheAppPlanOpensOne() { + #expect(BatchTransactionPlan.appTransaction.opensTransaction) + #expect(BatchTransactionPlan.scriptTransaction.opensTransaction == false) + #expect(BatchTransactionPlan.autocommit.opensTransaction == false) + #expect(BatchTransactionPlan.sessionTransaction.opensTransaction == false) + } + + @Test("A joined run rolls nothing back and keeps what ran, exactly as autocommit does") + func joinedRunTakesNothingBack() { + #expect(BatchTransactionPlan.sessionTransaction.rollsBackAfterStop == false) + #expect(BatchTransactionPlan.sessionTransaction.keepsExecutedStatements) + } + + @Test( + "A session holding a transaction or a lock takes the plan over, whatever the text said", + arguments: [PluginSessionTransactionState.inTransaction, .abortedTransaction, .holdsSessionLocks] + ) + func heldSessionTakesOver(state: PluginSessionTransactionState) { + for plan in Self.textPlans { + #expect(plan.joining(state) == .sessionTransaction) + } + } + + @Test("A session with nothing open leaves the plan the text decided") + func idleSessionChangesNothing() { + for plan in Self.textPlans { + #expect(plan.joining(.idle) == plan) + } + } + + /// The wrap stays for a plain batch, because its atomicity is worth more than a transaction that + /// may not be there. A self-managed script stops being rolled back, because the transaction its + /// text left open may predate the run. + @Test("A session that cannot say keeps the wrap but stops the rollback of a self-managed script") + func unknownSessionKeepsTheWrapButNotTheRollback() { + #expect(BatchTransactionPlan.appTransaction.joining(.unknown) == .appTransaction) + #expect(BatchTransactionPlan.autocommit.joining(.unknown) == .autocommit) + #expect(BatchTransactionPlan.scriptTransaction.joining(.unknown) == .sessionTransaction) + } + + @Test("Joining is settled once: a joined plan stays joined") + func joinedPlanStaysJoined() { + for state in [ + PluginSessionTransactionState.idle, .inTransaction, .abortedTransaction, .holdsSessionLocks, .unknown, + ] { + #expect(BatchTransactionPlan.sessionTransaction.joining(state) == .sessionTransaction) + } + } +} + +@Suite("Session transaction state, as the app reads it") +struct SessionTransactionStateTests { + @Test( + "Nothing the app owns opens a transaction over one the session is holding", + arguments: [PluginSessionTransactionState.inTransaction, .abortedTransaction, .holdsSessionLocks] + ) + func heldSessionPermitsNothing(state: PluginSessionTransactionState) { + #expect(state.permitsAppTransaction == false) + } + + @Test("An idle session, and one that cannot say, both permit the app's own transaction") + func idleAndUnknownPermitOne() { + #expect(PluginSessionTransactionState.idle.permitsAppTransaction) + #expect(PluginSessionTransactionState.unknown.permitsAppTransaction) + } + + @Test("An aborted transaction is never told to commit, because a commit there discards the work") + func abortedTransactionSaysRollBackOnly() { + let aborted = PluginSessionTransactionState.abortedTransaction.openTransactionNotice + #expect(aborted == "The transaction on this connection can no longer be committed. Roll it back.") + + let open = PluginSessionTransactionState.inTransaction.openTransactionNotice + #expect(open == "The transaction on this connection is still open. Commit or roll it back.") + } + + @Test("A session holding no transaction says nothing about one") + func nothingIsSaidWithoutATransaction() { + #expect(PluginSessionTransactionState.idle.openTransactionNotice == nil) + #expect(PluginSessionTransactionState.holdsSessionLocks.openTransactionNotice == nil) + #expect(PluginSessionTransactionState.unknown.openTransactionNotice == nil) + } +} diff --git a/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift b/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift index 7221c33450..d380f928e3 100644 --- a/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift +++ b/TableProTests/Core/Services/Execution/BatchTransactionPolicyTests.swift @@ -9,18 +9,26 @@ import Testing @Suite("Batch transaction policy") struct BatchTransactionPolicyTests { + private static func plan(_ statements: [String], _ type: DatabaseType) -> BatchTransactionPlan { + BatchTransactionPolicy.plan( + for: statements, + databaseType: type, + rules: SQLLexicalRules(databaseType: type, descriptor: nil) + ) + } + @Test("A batch with no transaction statements runs inside one transaction") func plainBatchIsWrapped() { - #expect(BatchTransactionPolicy.wrapsInTransaction([ + #expect(Self.plan([ "INSERT INTO t VALUES (1)", "UPDATE t SET a = 2", "SELECT * FROM t", - ], dialect: .postgres)) + ], .postgresql) == .appTransaction) } @Test("An empty batch is wrapped") func emptyBatchIsWrapped() { - #expect(BatchTransactionPolicy.wrapsInTransaction([], dialect: .generic)) + #expect(Self.plan([], .mysql) == .appTransaction) } @Test( @@ -47,12 +55,19 @@ struct BatchTransactionPolicyTests { " \n\tBEGIN", "-- open the transfer\nBEGIN", "/* migration 42 */ START TRANSACTION", - "/*!40101 BEGIN */", - "/*M!100100 START TRANSACTION */", ] ) func transactionOpenerIsNotWrapped(statement: String) { - #expect(!BatchTransactionPolicy.wrapsInTransaction(["INSERT INTO t VALUES (1)", statement], dialect: .generic)) + #expect(Self.plan(["INSERT INTO t VALUES (1)", statement], .mysql) == .scriptTransaction) + } + + @Test( + "A conditional comment carries transaction control on MySQL alone", + arguments: ["/*!40101 BEGIN */", "/*M!100100 START TRANSACTION */"] + ) + func conditionalCommentOpenerIsRead(statement: String) { + #expect(Self.plan(["INSERT INTO t VALUES (1)", statement], .mysql) == .scriptTransaction) + #expect(Self.plan(["INSERT INTO t VALUES (1)", statement], .postgresql) == .appTransaction) } @Test( @@ -60,18 +75,18 @@ struct BatchTransactionPolicyTests { arguments: ["SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", "set transaction read only"] ) func mysqlTransactionCharacteristicsAreNotWrapped(statement: String) { - #expect(!BatchTransactionPolicy.wrapsInTransaction([statement, "UPDATE t SET a = 1"], dialect: .mysql)) + #expect(Self.plan([statement, "UPDATE t SET a = 1"], .mysql) == .autocommit) } @Test( "PostgreSQL, SQL Server and SQLite apply SET TRANSACTION inside the wrap, so it keeps the wrap", - arguments: [SqlDialect.postgres, .sqlite, .generic] + arguments: [DatabaseType.postgresql, .sqlite, .mssql] ) - func transactionCharacteristicsKeepTheWrapElsewhere(dialect: SqlDialect) { - #expect(BatchTransactionPolicy.wrapsInTransaction( + func transactionCharacteristicsKeepTheWrapElsewhere(type: DatabaseType) { + #expect(Self.plan( ["SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", "UPDATE t SET a = 1"], - dialect: dialect - )) + type + ) == .appTransaction) } @Test( @@ -83,12 +98,39 @@ struct BatchTransactionPolicyTests { "SET @@SESSION.autocommit = 0", "SET SESSION autocommit = 0", "SET LOCAL autocommit = 0", - "SET IMPLICIT_TRANSACTIONS ON", "/*!40101 SET autocommit = 0 */", + "SET NAMES utf8mb4, @@session.autocommit=1", ] ) func commitModeSettingIsNotWrapped(statement: String) { - #expect(!BatchTransactionPolicy.wrapsInTransaction([statement, "UPDATE t SET a = 1"], dialect: .mysql)) + #expect(Self.plan([statement, "UPDATE t SET a = 1"], .mysql) == .scriptTransaction) + } + + @Test( + "T-SQL turns the commit mode on with no equals sign at all", + arguments: [ + "SET IMPLICIT_TRANSACTIONS ON", + "SET ANSI_NULLS, IMPLICIT_TRANSACTIONS ON", + "SET ANSI_DEFAULTS ON", + "set implicit_transactions on", + ] + ) + func implicitTransactionsIsNotWrapped(statement: String) { + #expect(Self.plan([statement, "UPDATE t SET a = 1"], .mssql) == .scriptTransaction) + } + + @Test( + "T-SQL options that leave the commit mode alone keep the wrap", + arguments: [ + "SET IMPLICIT_TRANSACTIONS OFF", + "SET ANSI_DEFAULTS OFF", + "SET NOCOUNT ON", + "SET ANSI_NULLS, ANSI_PADDING ON", + "SET @total = 1", + ] + ) + func otherSessionOptionsKeepTheWrap(statement: String) { + #expect(Self.plan([statement, "UPDATE t SET a = 1"], .mssql) == .appTransaction) } @Test( @@ -120,7 +162,7 @@ struct BatchTransactionPolicyTests { ] ) func lookalikeKeepsTheWrap(statement: String) { - #expect(BatchTransactionPolicy.wrapsInTransaction(["INSERT INTO t VALUES (1)", statement], dialect: .mysql)) + #expect(Self.plan(["INSERT INTO t VALUES (1)", statement], .mysql) == .appTransaction) } @Test("A routine whose body manages a transaction is not the script managing one") @@ -136,7 +178,7 @@ struct BatchTransactionPolicyTests { """ let statements = SQLStatementScanner.executableStatements(in: script).map(\.sql) #expect(statements.count == 2) - #expect(BatchTransactionPolicy.wrapsInTransaction(statements, dialect: .mysql)) + #expect(Self.plan(statements, .mysql) == .appTransaction) } @Test("A SQLite dump that opens its own transaction runs as written") @@ -150,6 +192,122 @@ struct BatchTransactionPolicyTests { """ let statements = SQLStatementScanner.executableStatements(in: script).map(\.sql) #expect(statements.count == 5) - #expect(!BatchTransactionPolicy.wrapsInTransaction(statements, dialect: .sqlite)) + #expect(Self.plan(statements, .sqlite) == .scriptTransaction) + } + + @Test("A statement the engine refuses inside a transaction makes the batch run in autocommit") + func autocommitOnlyStatementUnwrapsTheBatch() { + #expect(Self.plan(["CREATE TABLE t (id int)", "VACUUM"], .postgresql) == .autocommit) + #expect(Self.plan(["INSERT INTO t VALUES (1)", "PRAGMA foreign_keys = ON"], .sqlite) == .autocommit) + #expect(Self.plan(["INSERT INTO t VALUES (1)", "CHECKPOINT"], .duckdb) == .autocommit) + } + + @Test("The same statement can be perfectly safe on another engine") + func theEngineDecides() { + #expect(Self.plan(["INSERT INTO t VALUES (1)", "VACUUM"], .duckdb) == .appTransaction) + #expect(Self.plan(["CREATE DATABASE app", "USE app"], .mysql) == .appTransaction) + #expect(Self.plan(["INSERT INTO t VALUES (1)", "CHECKPOINT"], .postgresql) == .appTransaction) + } + + @Test("A SQLite savepoint opens a transaction, so the script owns it after all") + func savepointAfterAnUnwrappingStatement() { + #expect(Self.plan(["VACUUM", "SAVEPOINT a", "INSERT INTO t VALUES (1)"], .sqlite) == .scriptTransaction) + #expect(Self.plan(["VACUUM", "SAVEPOINT a", "INSERT INTO t VALUES (1)"], .postgresql) == .autocommit) + } + + @Test("The preamble of a GTID mysqldump runs in autocommit") + func gtidDumpPreambleIsNotWrapped() { + let statements = [ + "SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN", + "SET @@SESSION.SQL_LOG_BIN= 0", + "SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ 'ca7aa847-b2b6-11f1-88c3-d63f97f21d50:1-8'", + "INSERT INTO t VALUES (1)", + "SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN" + ] + #expect(Self.plan(statements, .mysql) == .autocommit) + } + + @Test("A mysqlbinlog dump manages its own commit mode, which wins over the autocommit rule") + func binlogDumpManagesItsOwnTransaction() { + let statements = [ + "/*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=1*/", + "/*!50003 SET @OLD_COMPLETION_TYPE=@@COMPLETION_TYPE, COMPLETION_TYPE=0*/", + "SET @@session.foreign_key_checks=1, @@session.sql_mode='', @@session.unique_checks=1, @@session.autocommit=1", + "INSERT INTO t VALUES (1)" + ] + #expect(Self.plan(statements, .mysql) == .scriptTransaction) + } + + /// PostgreSQL's transaction-only statements are left alone on purpose. Unwrapping a batch that + /// holds one makes it behave the way `psql` does: `LOCK TABLE`, `SAVEPOINT` and `DECLARE + /// CURSOR` error, and `SET LOCAL`, `SET CONSTRAINTS` and `SET TRANSACTION` warn and do nothing. + /// None of them open a transaction, so none of them can make the script the owner of one, and + /// letting one force the wrap back on would put `VACUUM` back inside it. + @Test( + "A PostgreSQL statement that only works inside a transaction does not change the plan", + arguments: [ + "LOCK TABLE t IN ACCESS EXCLUSIVE MODE", + "SAVEPOINT a", + "DECLARE c CURSOR FOR SELECT 1", + "SET LOCAL statement_timeout = '1s'", + "SET CONSTRAINTS ALL DEFERRED", + "SET TRANSACTION ISOLATION LEVEL SERIALIZABLE" + ] + ) + func postgresTransactionOnlyStatements(statement: String) { + #expect(Self.plan([statement, "INSERT INTO t VALUES (1)"], .postgresql) == .appTransaction) + #expect(Self.plan([statement, "VACUUM"], .postgresql) == .autocommit) + } + + /// Redis `MULTI` queues every command after it and answers `+QUEUED` in place of each reply, so + /// a wrapped batch reports `QUEUED` for every command it ran and hides every error `EXEC` + /// returns. The app opens nothing and each command answers as sent. + @Test( + "A Redis batch runs as sent", + arguments: [ + ["GET s"], + ["GET s", "DEL nokey", "LPUSH l x"], + ["SET k v", "EXPIRE k 10"], + [] + ] + ) + func redisBatchIsNotWrapped(statements: [String]) { + #expect(Self.plan(statements, .redis) == .autocommit) + } + + /// `SET` on Redis writes a key. The SQL commit-mode rules never reach it, so a script writing a + /// key called `autocommit` is a plain batch rather than a script taking transaction control. + @Test( + "A Redis key write is not read as SQL transaction control", + arguments: [ + "SET autocommit 1", + "SET SESSION autocommit", + "SET @@session.autocommit 0", + "GET begin", + "DEL start transaction", + "SET IMPLICIT_TRANSACTIONS ON" + ] + ) + func redisKeyWritesAreNotTransactionControl(statement: String) { + #expect(Self.plan([statement, "GET s"], .redis) == .autocommit) + } + + /// A batch that opens its own block has to end it when it fails or is stopped: nothing in the + /// block has run, and the next command on that session would be queued into it rather than + /// answered. `.scriptTransaction` is what sends the `DISCARD`. + @Test( + "A Redis batch that opens a MULTI block owns the transaction", + arguments: ["MULTI", "multi", " \n\tMULTI", "Multi"] + ) + func redisMultiTakesTransactionControl(statement: String) { + #expect(Self.plan([statement, "SET a 1", "EXEC"], .redis) == .scriptTransaction) + } + + @Test( + "Ending or watching a block does not open one", + arguments: ["EXEC", "DISCARD", "WATCH k", "UNWATCH", "GET multi", "SET multi 1", "RESET"] + ) + func redisBlockEndersDoNotOpenOne(statement: String) { + #expect(Self.plan([statement], .redis) == .autocommit) } } diff --git a/TableProTests/Core/Services/Execution/CommitOutcomeDiagnosisTests.swift b/TableProTests/Core/Services/Execution/CommitOutcomeDiagnosisTests.swift new file mode 100644 index 0000000000..131bfd2cdf --- /dev/null +++ b/TableProTests/Core/Services/Execution/CommitOutcomeDiagnosisTests.swift @@ -0,0 +1,86 @@ +// +// CommitOutcomeDiagnosisTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Commit outcome diagnosis") +struct CommitOutcomeDiagnosisTests { + /// The sentences the engines actually produce when the socket went before the answer did. + @Test( + "A commit whose connection died reports an unknown outcome", + arguments: [ + "MySQL server has gone away", + "Lost connection to MySQL server during query", + "Lost connection to server at 'reading initial communication packet'", + "server closed the connection unexpectedly", + "no connection to the server", + "SSL SYSCALL error: EOF detected", + "SSL connection has been closed unexpectedly", + "connection not open", + "Connection reset by peer", + "Broken pipe", + "terminating connection due to administrator command", + "Operation timed out", + ] + ) + func connectionLossIsRecognised(message: String) { + #expect(CommitOutcomeDiagnosis.isConnectionLoss(DatabaseError.queryFailed(message))) + } + + /// A commit the server refused is an answer, and the rollback that follows one is honest. These + /// must not be reported as unknown, or every deferred-constraint failure would tell the user to + /// go and check the table. + @Test( + "A commit the server answered is not an unknown outcome", + arguments: [ + "could not serialize access due to concurrent update", + "deadlock detected", + "insert or update on table \"orders\" violates foreign key constraint", + "cannot commit - no transaction is active", + "Query execution was interrupted", + "Duplicate entry '1' for key 'PRIMARY'", + ] + ) + func serverAnswersAreNotConnectionLoss(message: String) { + #expect(CommitOutcomeDiagnosis.isConnectionLoss(DatabaseError.queryFailed(message)) == false) + } + + @Test("A driver that threw notConnected reports an unknown outcome") + func notConnectedIsConnectionLoss() { + #expect(CommitOutcomeDiagnosis.isConnectionLoss(DatabaseError.notConnected)) + } + + @Test( + "A POSIX socket failure reports an unknown outcome", + arguments: [EPIPE, ECONNRESET, ETIMEDOUT, ENOTCONN] + ) + func posixSocketFailuresAreConnectionLoss(code: Int32) { + let error = NSError(domain: NSPOSIXErrorDomain, code: Int(code)) + #expect(CommitOutcomeDiagnosis.isConnectionLoss(error)) + } + + @Test("A POSIX failure that is not the socket going is not connection loss") + func unrelatedPosixFailureIsNotConnectionLoss() { + let error = NSError(domain: NSPOSIXErrorDomain, code: Int(ENOENT)) + #expect(CommitOutcomeDiagnosis.isConnectionLoss(error) == false) + } + + @Test("An HTTP driver's lost connection reports an unknown outcome") + func urlConnectionLossIsRecognised() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost) + #expect(CommitOutcomeDiagnosis.isConnectionLoss(error)) + } + + @Test("An error carrying no message at all is not read as a connection loss") + func opaqueErrorIsNotConnectionLoss() { + #expect(CommitOutcomeDiagnosis.isConnectionLoss(OpaqueError.refused) == false) + } +} + +private enum OpaqueError: Error { + case refused +} diff --git a/TableProTests/Core/Services/Execution/MultiStatementFailureTests.swift b/TableProTests/Core/Services/Execution/MultiStatementFailureTests.swift index c4d45a2993..8b9fee0d90 100644 --- a/TableProTests/Core/Services/Execution/MultiStatementFailureTests.swift +++ b/TableProTests/Core/Services/Execution/MultiStatementFailureTests.swift @@ -5,19 +5,34 @@ import Foundation @testable import TablePro +import TableProPluginKit import Testing @Suite("Multi-statement failure report") struct MultiStatementFailureTests { private static let syntaxError = "You have an error in your SQL syntax near 'READ WRITE'" + private static func report( + _ failure: MultiStatementFailure, + executed: Int, + total: Int, + error: String, + plan: BatchTransactionPlan = .appTransaction, + sessionState: PluginSessionTransactionState = .idle + ) -> MultiStatementFailureReport { + MultiStatementFailureContext( + failure: failure, + errorDescription: error, + executedCount: executed, + totalCount: total, + plan: plan, + sessionState: sessionState + ).report() + } + @Test("A transaction that failed to start blames no statement and names the start") func transactionStartBlamesNoStatement() { - let report = MultiStatementFailure.transactionStart.report( - executedCount: 0, - totalCount: 3, - errorDescription: Self.syntaxError - ) + let report = Self.report(.transactionStart, executed: 0, total: 3, error: Self.syntaxError) #expect(report.message == "The transaction could not be started: \(Self.syntaxError)") #expect(!report.message.localizedCaseInsensitiveContains("commit")) #expect(report.failedStatementIndex == nil) @@ -27,11 +42,7 @@ struct MultiStatementFailureTests { @Test("A connection that could not be leased reports its own error and blames no statement") func connectionBlamesNoStatement() { - let report = MultiStatementFailure.connection.report( - executedCount: 0, - totalCount: 2, - errorDescription: "Not connected to database" - ) + let report = Self.report(.connection, executed: 0, total: 2, error: "Not connected to database") #expect(report.message == "Not connected to database") #expect(report.failedStatementIndex == nil) #expect(report.failedSQL == nil) @@ -39,10 +50,11 @@ struct MultiStatementFailureTests { @Test("A failed statement is numbered from one and carries its SQL") func statementFailureIsNumbered() { - let report = MultiStatementFailure.statement(sql: "INSERT INTO missing VALUES (1)").report( - executedCount: 2, - totalCount: 4, - errorDescription: "no such table: missing" + let report = Self.report( + .statement(sql: "INSERT INTO missing VALUES (1)"), + executed: 2, + total: 4, + error: "no such table: missing" ) #expect(report.message == "Statement 3/4 failed: no such table: missing") #expect(report.resultLabel == "Error 3") @@ -52,16 +64,122 @@ struct MultiStatementFailureTests { @Test("A commit failure blames no statement after every statement ran") func commitBlamesNoStatement() { - let report = MultiStatementFailure.commit.report( - executedCount: 3, - totalCount: 3, - errorDescription: "deadlock" - ) + let report = Self.report(.commit, executed: 3, total: 3, error: "deadlock") #expect(report.message == "The transaction could not be committed: deadlock") #expect(report.failedStatementIndex == nil) #expect(report.failedSQL == nil) } + @Test("A failure under a plan that rolled nothing back says the earlier statements stay applied") + func autocommitFailureNamesWhatStaysApplied() { + let report = Self.report( + .statement(sql: "VACUUM"), + executed: 2, + total: 4, + error: "database is locked", + plan: .autocommit + ) + #expect(report.message == "Statement 3/4 failed: database is locked The 2 statements before it stay applied.") + #expect(report.failedStatementIndex == 2) + } + + @Test("One statement before the failure is named in the singular") + func oneAppliedStatementReadsAsOne() { + let report = Self.report( + .statement(sql: "VACUUM"), + executed: 1, + total: 3, + error: "database is locked", + plan: .autocommit + ) + #expect(report.message == "Statement 2/3 failed: database is locked The statement before it stays applied.") + } + + @Test("Nothing is said about earlier statements when there are none, or when they were rolled back") + func nothingIsSaidWhenThereIsNothingToSay() { + let firstStatement = Self.report( + .statement(sql: "VACUUM"), + executed: 0, + total: 3, + error: "database is locked", + plan: .autocommit + ) + #expect(firstStatement.message == "Statement 1/3 failed: database is locked") + + for plan in [BatchTransactionPlan.appTransaction, .scriptTransaction] { + let rolledBack = Self.report( + .statement(sql: "VACUUM"), + executed: 2, + total: 3, + error: "database is locked", + plan: plan + ) + #expect(rolledBack.message == "Statement 3/3 failed: database is locked") + } + } + + @Test("A failure inside the user's own transaction says it is still open") + func joinedFailureNamesTheOpenTransaction() { + let report = Self.report( + .statement(sql: "INSERT INTO missing VALUES (1)"), + executed: 1, + total: 2, + error: "no such table: missing", + plan: .sessionTransaction, + sessionState: .inTransaction + ) + #expect(report.message == """ + Statement 2/2 failed: no such table: missing \ + The transaction on this connection is still open. Commit or roll it back. + """) + } + + @Test("A failure that aborted the user's transaction says to roll it back, never to commit") + func abortedTransactionIsNotToldToCommit() { + let report = Self.report( + .statement(sql: "INSERT INTO t VALUES (1)"), + executed: 1, + total: 2, + error: "duplicate key value violates unique constraint", + plan: .sessionTransaction, + sessionState: .abortedTransaction + ) + #expect(report.message.contains("can no longer be committed. Roll it back.")) + #expect(!report.message.localizedCaseInsensitiveContains("Commit or roll")) + } + + @Test("A session holding only table locks committed each statement as it ran") + func lockedSessionReadsAsApplied() { + let report = Self.report( + .statement(sql: "INSERT INTO t VALUES (1)"), + executed: 2, + total: 3, + error: "Lock wait timeout exceeded", + plan: .sessionTransaction, + sessionState: .holdsSessionLocks + ) + #expect(report.message == """ + Statement 3/3 failed: Lock wait timeout exceeded The 2 statements before it stay applied. + """) + } + + /// A transaction that ended during the run either committed the earlier statements or took them + /// back, and nothing the driver can be asked afterwards says which. + @Test("A joined run says nothing when the session no longer holds what it held") + func joinedRunStaysSilentWhenTheStateMoved() { + for state in [PluginSessionTransactionState.idle, .unknown] { + let report = Self.report( + .statement(sql: "INSERT INTO t VALUES (1)"), + executed: 2, + total: 3, + error: "database is locked", + plan: .sessionTransaction, + sessionState: state + ) + #expect(report.message == "Statement 3/3 failed: database is locked") + } + } + @Test("Nothing ran when the connection or the transaction start failed") func nothingRanBeforeTheFirstStatement() { #expect(MultiStatementFailure.connection.ranStatementCount(executedCount: 0, totalCount: 3) == 0) @@ -73,5 +191,52 @@ struct MultiStatementFailureTests { #expect(MultiStatementFailure.statement(sql: "x").ranStatementCount(executedCount: 1, totalCount: 3) == 2) #expect(MultiStatementFailure.statement(sql: "x").ranStatementCount(executedCount: 2, totalCount: 3) == 3) #expect(MultiStatementFailure.commit.ranStatementCount(executedCount: 3, totalCount: 3) == 3) + #expect(MultiStatementFailure.commitOutcomeUnknown.ranStatementCount(executedCount: 3, totalCount: 3) == 3) + } + + /// A commit whose connection died is not a rollback and must never read as one. Measured on + /// MySQL 8.4.11, the same commit committed its row once the lock blocking it was released. + @Test("A commit whose connection died says the outcome is unknown, and never claims a rollback") + func unknownCommitOutcomeClaimsNothing() { + let report = Self.report( + .commitOutcomeUnknown, + executed: 3, + total: 3, + error: "Lost connection to MySQL server during query" + ) + #expect(report.message.contains("The connection was lost while committing")) + #expect(report.message.contains("Lost connection to MySQL server during query")) + #expect(report.message.contains("may or may not be saved")) + #expect(report.message.localizedCaseInsensitiveContains("rolled back") == false) + #expect(report.message.localizedCaseInsensitiveContains("could not be committed") == false) + #expect(report.failedStatementIndex == nil) + #expect(report.failedSQL == nil) + #expect(report.resultLabel == "Error") + } + + /// A commit the server refused is an answer, and the plan's rollback followed it, so that one + /// still reads as a failed commit rather than as an open question. + @Test("A commit the server refused still reports a failed commit") + func refusedCommitStillReportsAFailedCommit() { + let report = Self.report(.commit, executed: 3, total: 3, error: "deadlock detected") + #expect(report.message == "The transaction could not be committed: deadlock detected") + #expect(report.message.contains("may or may not") == false) + } + + /// The wording is the same whichever plan produced it: the app's own commit under + /// `.appTransaction` and a script's own commit under `.scriptTransaction` are both unanswered. + @Test( + "The unknown-outcome wording does not change with the plan", + arguments: [BatchTransactionPlan.appTransaction, .scriptTransaction, .sessionTransaction, .autocommit] + ) + func unknownCommitOutcomeReadsTheSameUnderEveryPlan(plan: BatchTransactionPlan) { + let report = Self.report( + .commitOutcomeUnknown, + executed: 2, + total: 2, + error: "MySQL server has gone away", + plan: plan + ) + #expect(report.message.contains("may or may not be saved")) } } diff --git a/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift b/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift new file mode 100644 index 0000000000..3100b1aac4 --- /dev/null +++ b/TableProTests/Core/Services/Execution/TransactionEngineFamilyTests.swift @@ -0,0 +1,80 @@ +// +// TransactionEngineFamilyTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Transaction engine family") +struct TransactionEngineFamilyTests { + @Test( + "Every PostgreSQL-compatible engine reads the PostgreSQL rules", + arguments: ["PostgreSQL", "PGlite", "AlloyDB", "Citus", "Greenplum"] + ) + func postgresCompatibles(typeId: String) { + #expect(TransactionEngineFamily.of(DatabaseType(rawValue: typeId)) == .postgres) + } + + @Test("Redshift and CockroachDB add rules of their own, so they are not PostgreSQL") + func warehousesAreTheirOwnFamilies() { + #expect(TransactionEngineFamily.of(.redshift) == .redshift) + #expect(TransactionEngineFamily.of(.cockroachdb) == .cockroach) + } + + @Test( + "Every MySQL-protocol engine reads the MySQL rules", + arguments: ["MySQL", "MariaDB", "TiDB", "OceanBase"] + ) + func mysqlCompatibles(typeId: String) { + #expect(TransactionEngineFamily.of(DatabaseType(rawValue: typeId)) == .mysql) + } + + @Test( + "Every SQLite-compatible engine reads the SQLite rules", + arguments: ["SQLite", "libSQL", "Turso"] + ) + func sqliteCompatibles(typeId: String) { + #expect(TransactionEngineFamily.of(DatabaseType(rawValue: typeId)) == .sqlite) + } + + /// The lexing dialect files DuckDB under SQLite and SQL Server under generic. Both would be + /// wrong here: DuckDB takes `VACUUM` and every `PRAGMA` inside a transaction, and SQL Server has + /// a restricted list of its own. + @Test("DuckDB and SQL Server are not SQLite and not generic") + func lexingDialectIsNotTheAnswer() { + #expect(TransactionEngineFamily.of(.duckdb) == .duckdb) + #expect(TransactionEngineFamily.of(.mssql) == .sqlServer) + } + + @Test( + "An engine with no curated rules falls back to keeping the wrap", + arguments: ["Databend", "Cloudflare D1", "Oracle", "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() { + for family in TransactionEngineFamily.allCases { + #expect(family.savepointOpensTransaction == (family == .sqlite)) + } + } + + @Test("Redis has rules of its own, so it is not the fallback") + func redisIsItsOwnFamily() { + #expect(TransactionEngineFamily.of(.redis) == .redis) + } + + /// Redis is the only engine the app cannot open a transaction on: `MULTI` queues commands until + /// `EXEC` and answers `+QUEUED` in place of every reply, and `DISCARD` can only drop a queue + /// nothing has applied. + @Test("Only Redis refuses the wrap outright") + func onlyRedisRefusesTheWrap() { + for family in TransactionEngineFamily.allCases { + #expect(family.wrapsBatchInTransaction == (family != .redis)) + } + } +} diff --git a/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift b/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift index 57ddb86669..5ddb0a5903 100644 --- a/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift +++ b/TableProTests/Core/UsersRoles/PrincipalChangeManagerTests.swift @@ -168,6 +168,45 @@ struct PrincipalChangeManagerTests { #expect(definition.canLogin == false) } + /// The attribute forms carry no password field, so every edit they stage arrives with none. + /// Taking it wholesale left a `CREATE USER` with no `IDENTIFIED BY`, and the fold is the only + /// way a new account gets a connection limit at all. + @Test("An attribute edit folded into a staged create keeps the create's password") + func foldKeepsTheStagedPassword() { + let carol = PluginPrincipalRef(name: "carol") + let manager = makeManager() + manager.stageCreate(PluginPrincipalDefinition(ref: carol, password: "secret", canLogin: true)) + + manager.stageAlter( + PluginPrincipalDefinition(ref: carol, canLogin: true, connectionLimit: 4), + for: carol + ) + + let changes = manager.pendingChanges() + #expect(changes.count == 1) + guard case let .create(definition) = changes[0] else { + Issue.record("expected a single create carrying the edit") + return + } + #expect(definition.password == "secret") + #expect(definition.connectionLimit == 4) + } + + @Test("A password the edit does carry replaces the staged one") + func foldTakesAnIncomingPassword() { + let carol = PluginPrincipalRef(name: "carol") + let manager = makeManager() + manager.stageCreate(PluginPrincipalDefinition(ref: carol, password: "secret")) + + manager.stageAlter(PluginPrincipalDefinition(ref: carol, password: "newer"), for: carol) + + guard case let .create(definition) = manager.pendingChanges().first else { + Issue.record("expected a single create carrying the edit") + return + } + #expect(definition.password == "newer") + } + @Test("Removing a staged create takes its password and grants with it") func unstageCreateClearsEverything() { let carol = PluginPrincipalRef(name: "carol") diff --git a/TableProTests/Core/Utilities/SQL/SQLSetAssignmentsTests.swift b/TableProTests/Core/Utilities/SQL/SQLSetAssignmentsTests.swift new file mode 100644 index 0000000000..5fb1c7510c --- /dev/null +++ b/TableProTests/Core/Utilities/SQL/SQLSetAssignmentsTests.swift @@ -0,0 +1,106 @@ +// +// SQLSetAssignmentsTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL SET assignments") +struct SQLSetAssignmentsTests { + private static let mysql = SQLLexicalRules(dialect: .mysql) + + private static func assignments(_ sql: String, readsList: Bool = true) -> [SQLSetAssignment] { + var cursor = SQLTokenCursor(sql, rules: mysql) + #expect(cursor.next()?.word == "SET") + return SQLSetAssignments.assignments(from: &cursor, readsList: readsList) + } + + @Test( + "Every spelling of the session scope reaches the same variable", + arguments: [ + "SET sql_log_bin = 0", + "SET SESSION sql_log_bin = 0", + "SET LOCAL sql_log_bin = 0", + "SET @@sql_log_bin = 0", + "SET @@SESSION.SQL_LOG_BIN= 0", + "SET @@local.sql_log_bin = 0", + "SET `sql_log_bin` = 0", + "SET sql_log_bin := 0", + "SET @@SESSION . sql_log_bin = 0" + ] + ) + func sessionScopeSpellings(statement: String) { + let read = Self.assignments(statement) + #expect(read.count == 1) + #expect(read.first?.name == "SQL_LOG_BIN") + #expect(read.first?.scope != .global) + } + + @Test("A scope keyword is recorded as written") + func scopeKeywordsAreRecorded() { + #expect(Self.assignments("SET GLOBAL read_only = 1").first?.scope == .global) + #expect(Self.assignments("SET PERSIST read_only = 1").first?.scope == .persist) + #expect(Self.assignments("SET PERSIST_ONLY read_only = 1").first?.scope == .persistOnly) + #expect(Self.assignments("SET @@GLOBAL.gtid_purged = 'x'").first?.scope == .global) + } + + @Test("A bare double-at variable keeps no scope of its own") + func bareDoubleAtIsUnspecified() { + let read = Self.assignments("SET @@transaction_isolation = 'SERIALIZABLE'") + #expect(read.first?.scope == .unspecified) + #expect(read.first?.spelledWithAtAt == true) + #expect(Self.assignments("SET @@SESSION.transaction_isolation = 'X'").first?.spelledWithAtAt == true) + #expect(Self.assignments("SET transaction_isolation = 'X'").first?.spelledWithAtAt == false) + } + + @Test("An element that is not an assignment yields nothing and does not stop the list") + func nonAssignmentElementsAreSkipped() { + let read = Self.assignments("SET NAMES utf8mb4, sql_log_bin = 0") + #expect(read.map(\.name) == ["SQL_LOG_BIN"]) + #expect(Self.assignments("SET CHARACTER SET utf8mb4").isEmpty) + } + + @Test("A user variable is not a system variable") + func userVariablesYieldNothing() { + #expect(Self.assignments("SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN").isEmpty) + #expect(Self.assignments("SET @x = 1, @@session.sql_log_bin = 0").map(\.name) == ["SQL_LOG_BIN"]) + } + + @Test("A comma inside parentheses or a string does not separate two elements") + func valuesKeepTheirCommas() { + #expect(Self.assignments("SET a = f(1, 2), sql_log_bin = 0").map(\.name) == ["A", "SQL_LOG_BIN"]) + #expect(Self.assignments("SET a = 'x,y', sql_log_bin = 0").map(\.name) == ["A", "SQL_LOG_BIN"]) + } + + @Test("The GTID line a mysqldump writes reads through its conditional comment") + func gtidPurgedLineIsRead() { + let read = Self.assignments("SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ 'ca7aa847:1-8'") + #expect(read.count == 1) + #expect(read.first?.name == "GTID_PURGED") + #expect(read.first?.scope == .global) + } + + @Test("MariaDB's SET STATEMENT stops at FOR") + func setStatementStopsAtFor() { + #expect(Self.assignments("SET STATEMENT gtid_domain_id = 3 FOR INSERT INTO t VALUES (1)") + .map(\.name) == ["GTID_DOMAIN_ID"]) + #expect(Self.assignments("SET STATEMENT a = 1, b = 2 FOR INSERT INTO t VALUES (1)") + .map(\.name) == ["A", "B"]) + } + + @Test("A reader that does not read the list stops after the first element") + func firstElementOnly() { + #expect(Self.assignments("SET NAMES utf8mb4, sql_log_bin = 0", readsList: false).isEmpty) + #expect(Self.assignments("SET autocommit = 0, sql_log_bin = 0", readsList: false).map(\.name) == ["AUTOCOMMIT"]) + } + + @Test("MySQL carries the last scope keyword across the elements that follow it") + func scopeCarriesAcrossTheList() { + let read = Self.assignments("SET GLOBAL read_only = 1, gtid_mode = ON") + #expect(read.map(\.name) == ["READ_ONLY", "GTID_MODE"]) + #expect(read.allSatisfy { $0.scope == .global }) + } +} diff --git a/TableProTests/Core/Utilities/SQL/SQLTokenCursorTests.swift b/TableProTests/Core/Utilities/SQL/SQLTokenCursorTests.swift new file mode 100644 index 0000000000..2760eff419 --- /dev/null +++ b/TableProTests/Core/Utilities/SQL/SQLTokenCursorTests.swift @@ -0,0 +1,133 @@ +// +// SQLTokenCursorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL token cursor") +struct SQLTokenCursorTests { + private static func tokens(_ sql: String, rules: SQLLexicalRules) -> [SQLTokenCursor.Token] { + var cursor = SQLTokenCursor(sql, rules: rules) + var read: [SQLTokenCursor.Token] = [] + while let token = cursor.next() { + read.append(token) + } + return read + } + + private static func words(_ sql: String, rules: SQLLexicalRules) -> [String] { + tokens(sql, rules: rules).compactMap(\.word) + } + + private static let mysql = SQLLexicalRules(dialect: .mysql) + private static let postgres = SQLLexicalRules(dialect: .postgres) + private static let sqlite = SQLLexicalRules(dialect: .sqlite) + + @Test("A line comment is skipped") + func lineCommentIsSkipped() { + #expect(Self.words("-- nightly\nVACUUM t", rules: Self.postgres) == ["VACUUM", "T"]) + } + + @Test("A hash line comment is skipped on MySQL and read as a symbol elsewhere") + func hashCommentIsMySQLOnly() { + #expect(Self.words("# note\nSET sql_log_bin = 0", rules: Self.mysql) == ["SET", "SQL_LOG_BIN", "0"]) + #expect(Self.words("# note\nSET sql_log_bin = 0", rules: Self.postgres) == ["NOTE", "SET", "SQL_LOG_BIN", "0"]) + } + + @Test("A nested block comment is skipped whole on PostgreSQL") + func nestedBlockCommentIsSkipped() { + #expect(Self.words("/* outer /* inner */ still */ VACUUM", rules: Self.postgres) == ["VACUUM"]) + } + + @Test("A MySQL conditional comment carries code, so its body is read") + func conditionalCommentBodyIsRead() { + #expect( + Self.words("/*!40101 SET @@SESSION.SQL_LOG_BIN= 0 */", rules: Self.mysql) + == ["SET", "@@SESSION", "SQL_LOG_BIN", "0"] + ) + #expect( + Self.words("/*M!100100 START TRANSACTION */", rules: Self.mysql) + == ["START", "TRANSACTION"] + ) + } + + @Test("A conditional comment is an ordinary comment on every other dialect") + func conditionalCommentIsSkippedElsewhere() { + #expect(Self.words("/*!40101 SET sql_log_bin = 0 */ VACUUM", rules: Self.postgres) == ["VACUUM"]) + } + + @Test("Reading stops at a semicolon that is not inside parentheses") + func semicolonEndsTheStatement() { + #expect(Self.words("VACUUM; DROP TABLE t", rules: Self.postgres) == ["VACUUM"]) + } + + @Test("A string is a literal, and an identifier keeps the text it quotes") + func quotingIsClassified() { + #expect(Self.tokens("'VACUUM'", rules: Self.postgres) == [.literal]) + #expect(Self.tokens("$$VACUUM$$", rules: Self.postgres) == [.literal]) + #expect(Self.tokens("E'a\\'b'", rules: Self.postgres) == [.literal]) + #expect(Self.tokens("\"x\"", rules: Self.postgres) == [.quotedIdentifier("x")]) + #expect(Self.tokens("`x`", rules: Self.mysql) == [.quotedIdentifier("x")]) + } + + @Test("Brackets quote an identifier only where the rules say they do") + func bracketsFollowTheRules() { + let bracketed = SQLLexicalRules(dialect: .generic, backslashEscapes: false, bracketsDelimitIdentifiers: true) + #expect(Self.tokens("[Sales Db]", rules: bracketed) == [.quotedIdentifier("Sales Db")]) + #expect(Self.words("[Sales Db]", rules: Self.postgres) == ["SALES", "DB"]) + } + + @Test("A doubled delimiter inside a quoted identifier is one character") + func doubledDelimiterIsUnescaped() { + #expect(Self.tokens("\"a\"\"b\"", rules: Self.postgres) == [.quotedIdentifier("a\"b")]) + } + + @Test("MySQL's assignment operator reads as an equals sign") + func colonEqualsReadsAsEquals() { + var cursor = SQLTokenCursor("sql_log_bin := 0", rules: Self.mysql) + #expect(cursor.next()?.word == "SQL_LOG_BIN") + #expect(cursor.next()?.isSymbol(SQLTokenCursor.equals) == true) + } + + @Test("A scope prefix, its dot and its variable are three tokens") + func scopePrefixIsSeparateFromItsVariable() { + #expect( + Self.tokens("@@SESSION . sql_log_bin", rules: Self.mysql) == [ + .word("@@SESSION"), + .symbol(SQLTokenCursor.period), + .word("SQL_LOG_BIN") + ] + ) + } + + @Test("Parenthesis depth counts up and down") + func parenthesisDepthIsTracked() { + var cursor = SQLTokenCursor("PRAGMA journal_mode(WAL)", rules: Self.sqlite) + #expect(cursor.next()?.word == "PRAGMA") + #expect(cursor.parenDepth == 0) + #expect(cursor.next()?.word == "JOURNAL_MODE") + #expect(cursor.next()?.isSymbol(SQLTokenCursor.openParen) == true) + #expect(cursor.parenDepth == 1) + #expect(cursor.next()?.word == "WAL") + #expect(cursor.next()?.isSymbol(SQLTokenCursor.closeParen) == true) + #expect(cursor.parenDepth == 0) + } + + @Test("A semicolon inside parentheses does not end the statement") + func semicolonInsideParenthesesIsRead() { + #expect(Self.words("CALL p('a;b') FROM t", rules: Self.mysql) == ["CALL", "P", "FROM", "T"]) + } + + @Test("Looking ahead leaves the cursor where it was") + func peekDoesNotAdvance() { + var cursor = SQLTokenCursor("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE", rules: Self.mysql) + #expect(cursor.next()?.word == "SET") + #expect(cursor.peek()?.word == "TRANSACTION") + #expect(cursor.peek()?.word == "TRANSACTION") + #expect(cursor.next()?.word == "TRANSACTION") + } +} diff --git a/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift b/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift index f37af9d513..6b8b76f0ee 100644 --- a/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift +++ b/TableProTests/Models/Query/QueryCommandAvailabilityTests.swift @@ -106,10 +106,40 @@ struct QueryCommandAvailabilityTests { #expect(Self.make(hasQueryText: false, hasResults: true).canClearResults) } + /// A batch whose `COMMIT` is on the wire is running and cannot be stopped by anything. The HIG + /// asks not to offer a cancel that cannot act, so Stop dims and says why. + @Test("A batch that is committing offers no Stop, and the hint says why") + func committingBatchOffersNoStop() { + let commands = Self.make(isExecuting: true, isStoppable: false) + + #expect(commands.canStop == false) + #expect(commands.canRun == false) + #expect(commands.stopHint.contains("The batch is committing and cannot be stopped.")) + } + + @Test("An ordinary running query offers Stop with no reason attached") + func runningQueryOffersStop() { + let commands = Self.make(isExecuting: true) + + #expect(commands.canStop) + #expect(commands.stopHint.contains("committing") == false) + } + + /// Nothing is running, so there is nothing to explain and nothing to dim: the hint must not + /// carry the committing reason around an idle bar. + @Test("An idle bar carries no stop reason") + func idleBarCarriesNoStopReason() { + let commands = Self.make(isExecuting: false, isStoppable: false) + + #expect(commands.canStop == false) + #expect(commands.stopHint.contains("committing") == false) + } + private static func make( isConnected: Bool = true, hasQueryText: Bool = true, isExecuting: Bool = false, + isStoppable: Bool = true, hasResults: Bool = true, explainVariants: [ExplainVariant] = [ExplainVariant(id: "plain", label: "Explain", sqlPrefix: "EXPLAIN")] ) -> QueryCommandAvailability { @@ -117,6 +147,7 @@ struct QueryCommandAvailabilityTests { isConnected: isConnected, hasQueryText: hasQueryText, isExecuting: isExecuting, + isStoppable: isStoppable, hasResults: hasResults, explainVariants: explainVariants, shortcutHint: { label, _ in label } diff --git a/TableProTests/Plugins/CheckConstraintParsingTests.swift b/TableProTests/Plugins/CheckConstraintParsingTests.swift index d788b2f48b..3a9b7668f7 100644 --- a/TableProTests/Plugins/CheckConstraintParsingTests.swift +++ b/TableProTests/Plugins/CheckConstraintParsingTests.swift @@ -119,21 +119,6 @@ struct SQLiteCheckConstraintParserTests { @Suite("MySQL server version floors") struct MySQLServerVersionTests { - @Test("MySQL gains CHECK_CONSTRAINTS at 8.0.16, not before") - func mysqlCheckFloor() { - #expect(!MySQLServerVersion.hasCheckConstraints(banner: "5.7.44", flavor: .mysql)) - #expect(!MySQLServerVersion.hasCheckConstraints(banner: "8.0.15", flavor: .mysql)) - #expect(MySQLServerVersion.hasCheckConstraints(banner: "8.0.16", flavor: .mysql)) - #expect(MySQLServerVersion.hasCheckConstraints(banner: "8.4.0", flavor: .mysql)) - } - - @Test("MariaDB gains them at 10.2.1 and reports its own banner") - func mariadbCheckFloor() { - #expect(!MySQLServerVersion.hasCheckConstraints(banner: "10.1.48-MariaDB", flavor: .mariadb)) - #expect(MySQLServerVersion.hasCheckConstraints(banner: "10.2.1-MariaDB", flavor: .mariadb)) - #expect(MySQLServerVersion.hasCheckConstraints(banner: "12.3.2-MariaDB", flavor: .mariadb)) - } - @Test("MariaDB 10.1 has generated columns but no GENERATION_EXPRESSION column") func generationExpressionFloor() { #expect(!MySQLServerVersion.hasGenerationExpression(banner: "10.1.48-MariaDB", flavor: .mariadb)) @@ -144,8 +129,18 @@ struct MySQLServerVersionTests { @Test("an unreadable banner is treated as unsupported rather than assumed modern") func unknownBannerIsUnsupported() { - #expect(!MySQLServerVersion.hasCheckConstraints(banner: nil, flavor: .mysql)) - #expect(!MySQLServerVersion.hasCheckConstraints(banner: "unknown", flavor: .mysql)) + #expect(!MySQLServerVersion.hasGenerationExpression(banner: nil, flavor: .mysql)) + #expect(!MySQLServerVersion.hasGenerationExpression(banner: "unknown", flavor: .mysql)) + } + + /// The other direction: a gate that picks legacy syntax has to hear a version before it does, + /// because the legacy statements are a 1064 on MySQL 8. + @Test("isKnownBelow answers false for a banner it cannot read") + func isKnownBelowNeedsAVersion() { + #expect(MySQLServerVersion.isKnownBelow((5, 7, 6), banner: "5.6.51")) + #expect(!MySQLServerVersion.isKnownBelow((5, 7, 6), banner: "8.4.11")) + #expect(!MySQLServerVersion.isKnownBelow((5, 7, 6), banner: nil)) + #expect(!MySQLServerVersion.isKnownBelow((5, 7, 6), banner: "unknown")) } } diff --git a/TableProTests/Plugins/DuckDBTransactionProbeTests.swift b/TableProTests/Plugins/DuckDBTransactionProbeTests.swift new file mode 100644 index 0000000000..8ba7bbff7d --- /dev/null +++ b/TableProTests/Plugins/DuckDBTransactionProbeTests.swift @@ -0,0 +1,67 @@ +// +// DuckDBTransactionProbeTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DuckDB transaction probe") +struct DuckDBTransactionProbeTests { + /// Measured against the shipped libduckdb v1.5.2: outside a transaction two calls answered 6 + /// then 10, and inside one they both answered 12. + @Test("The same transaction id twice is one open transaction, two different ones is none") + func repeatedIdMeansAnOpenTransaction() { + #expect(DuckDBTransactionProbe.state(first: .value("12"), second: .value("12")) == .inTransaction) + #expect(DuckDBTransactionProbe.state(first: .value("6"), second: .value("10")) == .idle) + } + + @Test("A refusal because the transaction is aborted is the answer, not a failure") + func abortedTransactionIsReported() { + #expect(DuckDBTransactionProbe.state(first: .abortedTransaction, second: .value("1")) == .abortedTransaction) + #expect(DuckDBTransactionProbe.state(first: .value("1"), second: .abortedTransaction) == .abortedTransaction) + } + + @Test("A reading that could not be taken says so rather than guessing") + func unreadableIsUnknown() { + #expect(DuckDBTransactionProbe.state(first: .unreadable, second: .value("1")) == .unknown) + #expect(DuckDBTransactionProbe.state(first: .value("1"), second: .unreadable) == .unknown) + #expect(DuckDBTransactionProbe.state(first: .unreadable, second: .unreadable) == .unknown) + } + + @Test("A DuckDB catalog settles nothing on its own: the transaction id has to be probed") + func nativeCatalogNeedsTheProbe() { + #expect(DuckDBTransactionProbe.state(catalogType: .value("duckdb"), tracksOpenTransaction: false) == nil) + #expect(DuckDBTransactionProbe.state(catalogType: .value("DuckDB"), tracksOpenTransaction: true) == nil) + } + + /// The probe is destructive outside DuckDB's own transaction manager. Measured against v1.5.2 + /// with a SQLite catalog in front: `txid_current()` failed with `DuckTransaction::Get called on + /// non-DuckDB transaction` and aborted the user's transaction, losing the row they had inserted. + @Test("A catalog DuckDB does not own is answered from the statements the driver saw") + func foreignCatalogUsesTheTrackedAnswer() { + #expect(DuckDBTransactionProbe.state(catalogType: .value("sqlite"), tracksOpenTransaction: true) == .inTransaction) + #expect(DuckDBTransactionProbe.state(catalogType: .value("sqlite"), tracksOpenTransaction: false) == .idle) + #expect(DuckDBTransactionProbe.state(catalogType: .value("postgres"), tracksOpenTransaction: true) == .inTransaction) + } + + @Test("A catalog read that was refused for being in an aborted transaction says so") + func abortedCatalogReadIsReported() { + let state = DuckDBTransactionProbe.state(catalogType: .abortedTransaction, tracksOpenTransaction: false) + #expect(state == .abortedTransaction) + } + + @Test("A catalog nobody could read leaves the caller deciding as if it had not asked") + func unreadableCatalogIsUnknown() { + #expect(DuckDBTransactionProbe.state(catalogType: .unreadable, tracksOpenTransaction: true) == .unknown) + } + + @Test("The probe asks for text, because the deprecated value API faults on other types") + func probeQueriesCastToText() { + #expect(DuckDBTransactionProbe.transactionIdQuery == "SELECT txid_current()::VARCHAR") + #expect(DuckDBTransactionProbe.catalogTypeQuery.contains("duckdb_databases()")) + #expect(DuckDBTransactionProbe.catalogTypeQuery.contains("current_database()")) + } +} diff --git a/TableProTests/Plugins/LibPQConnectionLossTests.swift b/TableProTests/Plugins/LibPQConnectionLossTests.swift index b7ab0cb124..a242b1a6d8 100644 --- a/TableProTests/Plugins/LibPQConnectionLossTests.swift +++ b/TableProTests/Plugins/LibPQConnectionLossTests.swift @@ -29,6 +29,18 @@ struct LibPQConnectionLossTests { #expect(LibPQTransactionState.inError.mayHoldTransaction) } + /// `PQTRANS_INERROR` is its own answer rather than another open transaction: a `COMMIT` there + /// answers with the command tag `ROLLBACK` and no error, so the user has to be told to roll + /// back rather than offered the choice. + @Test("The ReadyForQuery status is reported to the app as what the session has open") + func statusMapsToTheSessionState() { + #expect(LibPQTransactionState.idle.sessionTransactionState == .idle) + #expect(LibPQTransactionState.inTransaction.sessionTransactionState == .inTransaction) + #expect(LibPQTransactionState.inError.sessionTransactionState == .abortedTransaction) + #expect(LibPQTransactionState.active.sessionTransactionState == .unknown) + #expect(LibPQTransactionState.unknown.sessionTransactionState == .unknown) + } + @Test("A statement never sent is reported as not run, behind the server's own message") func notSentIsNotRun() { let error = Self.error(.beforeSending(transactionMayBeOpen: false)) diff --git a/TableProTests/Plugins/MSSQLSessionTransactionTests.swift b/TableProTests/Plugins/MSSQLSessionTransactionTests.swift new file mode 100644 index 0000000000..cd2c1ead0a --- /dev/null +++ b/TableProTests/Plugins/MSSQLSessionTransactionTests.swift @@ -0,0 +1,53 @@ +// +// MSSQLSessionTransactionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL Server session transaction") +struct MSSQLSessionTransactionTests { + @Test("A transaction count above zero is an open transaction") + func openTransactionIsCounted() { + #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: "1") == .inTransaction) + #expect(MSSQLSessionTransaction.state(tranCount: "2", transactionState: "1") == .inTransaction) + } + + @Test("Nothing open reads as idle") + func noTransactionIsIdle() { + #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: "0") == .idle) + } + + /// `XACT_STATE()` answers -1 for a transaction that can no longer be committed, which is what a + /// batch-aborting error leaves behind. Telling the user to commit that one discards their work. + @Test("An uncommittable transaction is its own answer") + func uncommittableTransactionIsAborted() { + #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: "-1") == .abortedTransaction) + #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: "-1") == .abortedTransaction) + } + + @Test("A count that is missing or not a number says nothing") + func unreadableCountIsUnknown() { + #expect(MSSQLSessionTransaction.state(tranCount: nil, transactionState: "0") == .unknown) + #expect(MSSQLSessionTransaction.state(tranCount: "", transactionState: "0") == .unknown) + #expect(MSSQLSessionTransaction.state(tranCount: "none", transactionState: "0") == .unknown) + } + + @Test("Padding around the numbers a driver returns is read through") + func paddedNumbersParse() { + #expect(MSSQLSessionTransaction.state(tranCount: " 1 ", transactionState: " 1 ") == .inTransaction) + #expect(MSSQLSessionTransaction.state(tranCount: " 0 ", transactionState: " -1 ") == .abortedTransaction) + } + + /// Measured on Azure SQL Edge: with `SET IMPLICIT_TRANSACTIONS ON` and nothing run since, + /// `@@TRANCOUNT` is 0 and stays 0, so nothing is pending and a caller's own transaction commits + /// only its own statements. The first statement after that makes it 1. + @Test("Implicit transactions on their own are not a transaction") + func implicitTransactionsAloneAreIdle() { + #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: "0") == .idle) + #expect(MSSQLSessionTransaction.probe == "SELECT @@TRANCOUNT, XACT_STATE()") + } +} diff --git a/TableProTests/Plugins/MySQLAccountStatementsTests.swift b/TableProTests/Plugins/MySQLAccountStatementsTests.swift new file mode 100644 index 0000000000..9ac0242615 --- /dev/null +++ b/TableProTests/Plugins/MySQLAccountStatementsTests.swift @@ -0,0 +1,151 @@ +// +// MySQLAccountStatementsTests.swift +// TableProTests +// +// Banners measured through libmariadb's mysql_get_server_info against ten Docker servers. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("MySQL account statements") +struct MySQLAccountStatementsTests { + private static let user = PluginPrincipalRef(name: "u", host: "%") + + private func statements(_ syntax: MySQLAccountSyntax) -> MySQLAccountStatements { + MySQLAccountStatements( + syntax: syntax, + account: { "`\($0.name)`@`\($0.host ?? "%")`" }, + literal: { mysqlEscapeStringLiteral($0) } + ) + } + + private func definition(password: String? = nil, limit: Int? = nil) -> PluginPrincipalDefinition { + PluginPrincipalDefinition(ref: Self.user, password: password, connectionLimit: limit) + } + + @Test("ALTER USER arrived in MySQL 5.7.6 and MariaDB 10.2.0") + func syntaxFloors() { + let legacyMySQL = ["5.5.62", "5.6.51", "5.7.5"] + for banner in legacyMySQL { + #expect(MySQLServerVersion.accountSyntax(banner: banner, flavor: .mysql) == .grantUsage) + } + for banner in ["5.7.6", "5.7.44", "8.4.11"] { + #expect(MySQLServerVersion.accountSyntax(banner: banner, flavor: .mysql) == .alterUser) + } + let legacyMariaDB = ["5.5.64-MariaDB-1~trusty", "10.0.38-MariaDB-1~xenial", "10.1.48-MariaDB-1~bionic"] + for banner in legacyMariaDB { + #expect(MySQLServerVersion.accountSyntax(banner: banner, flavor: .mariadb) == .grantUsage) + } + let modernMariaDB = [ + "10.2.0-MariaDB", + "10.2.44-MariaDB-1:10.2.44+maria~bionic", + "10.6.28-MariaDB-ubu2204", + "11.4.13-MariaDB-ubu2404" + ] + for banner in modernMariaDB { + #expect(MySQLServerVersion.accountSyntax(banner: banner, flavor: .mariadb) == .alterUser) + } + } + + @Test("A banner that lies, or that cannot be read, keeps ALTER USER") + func bannerIsIgnoredWhereItLies() { + #expect(MySQLServerVersion.accountSyntax( + banner: "5.6.25", flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 4, patch: 2)) + ) == .alterUser) + #expect(MySQLServerVersion.accountSyntax( + banner: "5.7.25-TiDB-v6.5.0", flavor: .tidb(version: MySQLEngineVersion(major: 6, minor: 5, patch: 0)) + ) == .alterUser) + #expect(MySQLServerVersion.accountSyntax(banner: "8.0.90-v1.2.3-nightly", flavor: .databend) == .alterUser) + #expect(MySQLServerVersion.accountSyntax(banner: nil, flavor: .mysql) == .alterUser) + #expect(MySQLServerVersion.accountSyntax(banner: "unknown", flavor: .mariadb) == .alterUser) + } + + @Test("A legacy create puts the account in first, then the limit") + func legacyCreate() { + #expect(statements(.grantUsage).create(definition(password: "pw", limit: 4)) == [ + "CREATE USER `u`@`%` IDENTIFIED BY 'pw'", + "GRANT USAGE ON *.* TO `u`@`%` WITH MAX_USER_CONNECTIONS 4" + ]) + #expect(statements(.grantUsage).create(definition(password: "pw")) == [ + "CREATE USER `u`@`%` IDENTIFIED BY 'pw'" + ]) + } + + @Test("A modern create is one statement") + func modernCreate() { + #expect(statements(.alterUser).create(definition(password: "pw", limit: 4)) == [ + "CREATE USER `u`@`%` IDENTIFIED BY 'pw' WITH MAX_USER_CONNECTIONS 4" + ]) + #expect(statements(.alterUser).create(definition(limit: 2)) == [ + "CREATE USER `u`@`%` WITH MAX_USER_CONNECTIONS 2" + ]) + #expect(statements(.alterUser).create(definition()) == ["CREATE USER `u`@`%`"]) + } + + /// `SET PASSWORD FOR acct = PASSWORD('p')` is the other legacy spelling and is not used: + /// measured on MariaDB 10.1.48 against a `unix_socket` account it answered `Query OK, 1 + /// warning` and left the old password working, and on a MySQL 5.6 `sha256_password` account it + /// gave `ERROR 1827`. `GRANT USAGE ... IDENTIFIED BY` cleared the plugin in both. + @Test("A password goes in as the form the server takes, escaped") + func setPassword() { + #expect(statements(.grantUsage).setPassword("it's \\x", for: Self.user) == [ + "GRANT USAGE ON *.* TO `u`@`%` IDENTIFIED BY 'it''s \\\\x'" + ]) + #expect(statements(.alterUser).setPassword("it's \\x", for: Self.user) == [ + "ALTER USER `u`@`%` IDENTIFIED BY 'it''s \\\\x'" + ]) + } + + @Test("A cleared limit goes in as zero, and an unchanged one emits nothing") + func alterLimit() { + let four = definition(limit: 4) + let none = definition() + let six = definition(limit: 6) + #expect(statements(.alterUser).alter(old: four, new: none) + == ["ALTER USER `u`@`%` WITH MAX_USER_CONNECTIONS 0"]) + #expect(statements(.grantUsage).alter(old: four, new: none) + == ["GRANT USAGE ON *.* TO `u`@`%` WITH MAX_USER_CONNECTIONS 0"]) + #expect(statements(.alterUser).alter(old: none, new: six) + == ["ALTER USER `u`@`%` WITH MAX_USER_CONNECTIONS 6"]) + #expect(statements(.grantUsage).alter(old: four, new: four).isEmpty) + } + + @Test("A rename follows the limit change, in both grammars") + func alterRename() { + let old = definition(limit: 4) + let renamed = PluginPrincipalDefinition( + ref: PluginPrincipalRef(name: "v", host: "%"), connectionLimit: 6 + ) + #expect(statements(.alterUser).alter(old: old, new: renamed) == [ + "ALTER USER `u`@`%` WITH MAX_USER_CONNECTIONS 6", + "RENAME USER `u`@`%` TO `v`@`%`" + ]) + #expect(statements(.grantUsage).alter(old: old, new: renamed) == [ + "GRANT USAGE ON *.* TO `u`@`%` WITH MAX_USER_CONNECTIONS 6", + "RENAME USER `u`@`%` TO `v`@`%`" + ]) + } + + @Test("Neither grammar borrows a statement the other server removed") + func grammarsDoNotMix() { + let modern = statements(.alterUser) + let legacy = statements(.grantUsage) + let modernOutput = modern.create(definition(password: "p", limit: 1)) + + modern.setPassword("p", for: Self.user) + + modern.alter(old: definition(limit: 1), new: definition(limit: 2)) + for statement in modernOutput { + #expect(!statement.contains("GRANT USAGE")) + #expect(!statement.contains("PASSWORD(")) + } + let legacyOutput = legacy.create(definition(password: "p", limit: 1)) + + legacy.setPassword("p", for: Self.user) + + legacy.alter(old: definition(limit: 1), new: definition(limit: 2)) + for statement in legacyOutput { + #expect(!statement.contains("ALTER USER")) + #expect(!statement.contains("SET PASSWORD")) + #expect(!statement.contains("CREATE USER `u`@`%` IDENTIFIED BY 'p' WITH")) + } + } +} diff --git a/TableProTests/Plugins/MySQLCheckConstraintsTests.swift b/TableProTests/Plugins/MySQLCheckConstraintsTests.swift new file mode 100644 index 0000000000..457311ad23 --- /dev/null +++ b/TableProTests/Plugins/MySQLCheckConstraintsTests.swift @@ -0,0 +1,160 @@ +// +// MySQLCheckConstraintsTests.swift +// TableProTests +// +// Each statement is SHOW CREATE TABLE output measured on TiDB 7.5.1, 8.5.1 and MariaDB 10.2.21. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("MySQL check constraints") +struct MySQLCheckConstraintsTests { + @Test("7.5 prints constraints unindented, and each expression matches CHECK_CLAUSE") + func tidb75() { + let sql = """ + CREATE TABLE `t` ( + `id` int(11) NOT NULL, + `n` int(11) DEFAULT NULL, + `m` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) /*T![clustered_index] CLUSTERED */, + CONSTRAINT `ck_n` CHECK ((`n` > 0)), + CONSTRAINT `t_chk_1` CHECK ((`m` < 10)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin + """ + let checks = MySQLCheckConstraints.parse(createTable: sql) + #expect(checks.map(\.name) == ["ck_n", "t_chk_1"]) + #expect(checks.map(\.expression) == ["(`n` > 0)", "(`m` < 10)"]) + } + + @Test("Quotes, commas and parentheses inside a name or literal do not split the constraint") + func tidb85QuotedText() { + let sql = """ + CREATE TABLE `w(x` ( + `s` varchar(10) DEFAULT NULL, + CONSTRAINT `c,1` CHECK ((`s` != _utf8mb4'a,(b'' \\\\ )')) /*!80016 NOT ENFORCED */, + CONSTRAINT `q``t` CHECK ((length(`s`) > 1)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin + """ + let checks = MySQLCheckConstraints.parse(createTable: sql) + #expect(checks.map(\.name) == ["c,1", "q`t"]) + #expect(checks.map(\.expression) == ["(`s` != _utf8mb4'a,(b'' \\\\ )')", "(length(`s`) > 1)"]) + } + + @Test("A table without checks has none, and a column named like the keyword is not one") + func noChecks() { + let sql = """ + CREATE TABLE `t` ( + `constraint` int DEFAULT NULL, + `check` varchar(3) DEFAULT '(,)' + ) ENGINE=InnoDB + """ + #expect(MySQLCheckConstraints.parse(createTable: sql).isEmpty) + } + + @Test("MariaDB 10.2.21 enforces CHECK and prints it, so SHOW CREATE TABLE is the read") + func mariadbCreateTableParse() { + let sql = """ + CREATE TABLE `t` ( + `x` int(11) DEFAULT NULL CHECK (`x` <> 5), + CONSTRAINT `c` CHECK (`x` > 0) + ) ENGINE=InnoDB DEFAULT CHARSET=latin1 + """ + let checks = MySQLCheckConstraints.parse(createTable: sql) + #expect(checks.map(\.name) == ["c"]) + #expect(checks.map(\.expression) == ["`x` > 0"]) + } + + @Test("MySQL reads the catalog from 8.0.16 and has nothing to read below it") + func mysqlSource() { + let unavailable = ["5.5.62", "5.6.51", "5.7.44", "8.0.15"] + for banner in unavailable { + #expect(MySQLCheckConstraints.source(banner: banner, flavor: .mysql) == .unavailable) + } + for banner in ["8.0.16", "8.0.19", "8.4.11"] { + #expect(MySQLCheckConstraints.source(banner: banner, flavor: .mysql) == .informationSchema) + } + } + + @Test("MariaDB enforces from 10.2.1 but catalogues only from 10.2.22 and 10.3.10") + func mariadbSource() { + for banner in ["10.0.38-MariaDB", "10.1.48-MariaDB-1~bionic"] { + #expect(MySQLCheckConstraints.source(banner: banner, flavor: .mariadb) == .unavailable) + } + for banner in ["10.2.6-MariaDB", "10.2.21-MariaDB", "10.3.0-MariaDB", "10.3.9-MariaDB"] { + #expect(MySQLCheckConstraints.source(banner: banner, flavor: .mariadb) == .createTableStatement) + } + for banner in ["10.2.22-MariaDB", "10.3.10-MariaDB", "10.6.28-MariaDB-ubu2204", "11.4.13-MariaDB"] { + #expect(MySQLCheckConstraints.source(banner: banner, flavor: .mariadb) == .informationSchema) + } + } + + @Test("TiDB, OceanBase and Databend answer from their own version, not the banner") + func variantSources() { + let banner = "8.0.11-TiDB-v7.5.1" + #expect(MySQLCheckConstraints.source( + banner: banner, flavor: .tidb(version: MySQLEngineVersion(major: 7, minor: 1, patch: 5)) + ) == .unavailable) + #expect(MySQLCheckConstraints.source( + banner: banner, flavor: .tidb(version: MySQLEngineVersion(major: 7, minor: 5, patch: 1)) + ) == .createTableStatement) + #expect(MySQLCheckConstraints.source(banner: banner, flavor: .tidb(version: nil)) == .unavailable) + #expect(MySQLCheckConstraints.source( + banner: "5.7.25", flavor: .oceanbase(version: MySQLEngineVersion(major: 3, minor: 1, patch: 4)) + ) == .unavailable) + #expect(MySQLCheckConstraints.source( + banner: "5.7.25", flavor: .oceanbase(version: MySQLEngineVersion(major: 4, minor: 0, patch: 0)) + ) == .informationSchema) + #expect(MySQLCheckConstraints.source(banner: "8.0.90-v1.2.3-nightly", flavor: .databend) == .databendCatalog) + } + + @Test("A server too old to keep a CHECK says so, and one whose version is unknown says nothing") + func refusal() { + #expect(MySQLCheckConstraints.refusal(banner: "5.7.44", flavor: .mysql) + == "Check constraints need MySQL 8.0.16 or later.") + #expect(MySQLCheckConstraints.refusal(banner: "10.1.48-MariaDB", flavor: .mariadb) + == "Check constraints need MariaDB 10.2.1 or later.") + #expect(MySQLCheckConstraints.refusal(banner: "8.0.16", flavor: .mysql) == nil) + #expect(MySQLCheckConstraints.refusal(banner: "10.2.21-MariaDB", flavor: .mariadb) == nil) + #expect(MySQLCheckConstraints.refusal( + banner: "8.0.11-TiDB-v7.1.5", flavor: .tidb(version: MySQLEngineVersion(major: 7, minor: 1, patch: 5)) + ) == "Check constraints need TiDB 7.2 or later.") + } + + /// A disconnect clears the banner and resets the flavor to `.mysql`, and the app keeps that + /// handle installed across the reconnect. Reading that as "too old" hid the tab on MariaDB 11. + @Test("An unread banner refuses nothing and edits nothing") + func unknownBanner() { + #expect(MySQLCheckConstraints.refusal(banner: nil, flavor: .mysql) == nil) + #expect(MySQLCheckConstraints.refusal(banner: "unknown", flavor: .mysql) == nil) + #expect(MySQLCheckConstraints.refusal(banner: nil, flavor: .mariadb) == nil) + #expect(!MySQLCheckConstraints.supportsEditing(banner: nil, flavor: .mysql)) + #expect(!MySQLCheckConstraints.supportsEditing(banner: "unknown", flavor: .mysql)) + #expect(MySQLCheckConstraints.source(banner: nil, flavor: .mysql) == .unavailable) + } + + @Test("Editing is offered exactly where the server keeps the clause") + func supportsEditing() { + #expect(!MySQLCheckConstraints.supportsEditing(banner: "5.7.44", flavor: .mysql)) + #expect(MySQLCheckConstraints.supportsEditing(banner: "8.0.16", flavor: .mysql)) + #expect(!MySQLCheckConstraints.supportsEditing(banner: "10.1.48-MariaDB", flavor: .mariadb)) + #expect(MySQLCheckConstraints.supportsEditing(banner: "10.2.6-MariaDB", flavor: .mariadb)) + } + + @Test("MySQL 8.0.16 to 8.0.18 takes DROP CHECK, and MariaDB never does") + func dropKeyword() { + #expect(MySQLCheckConstraints.dropStatement( + quotedTable: "`t`", quotedName: "`c`", banner: "8.0.16", flavor: .mysql + ) == "ALTER TABLE `t` DROP CHECK `c`") + #expect(MySQLCheckConstraints.dropStatement( + quotedTable: "`t`", quotedName: "`c`", banner: "8.0.19", flavor: .mysql + ) == "ALTER TABLE `t` DROP CONSTRAINT `c`") + #expect(MySQLCheckConstraints.dropStatement( + quotedTable: "`t`", quotedName: "`c`", banner: "8.4.11", flavor: .mysql + ) == "ALTER TABLE `t` DROP CONSTRAINT `c`") + #expect(MySQLCheckConstraints.dropStatement( + quotedTable: "`t`", quotedName: "`c`", banner: "10.6.28-MariaDB", flavor: .mariadb + ) == "ALTER TABLE `t` DROP CONSTRAINT `c`") + } +} diff --git a/TableProTests/Plugins/MySQLKillLatchTests.swift b/TableProTests/Plugins/MySQLKillLatchTests.swift new file mode 100644 index 0000000000..bdf562ea43 --- /dev/null +++ b/TableProTests/Plugins/MySQLKillLatchTests.swift @@ -0,0 +1,126 @@ +// +// MySQLKillLatchTests.swift +// TableProTests +// +// `MariaDBPluginConnection` is not compiled into the test target, so the ordering is pinned here on +// the pure latch the connection asks. The live behaviour it encodes was measured with the app's own +// libmariadb against seven servers; see MySQLKillLatch.swift. +// + +import Foundation +import Testing + +@Suite("MySQL kill latch") +struct MySQLKillLatchTests { + @Test("Nothing to absorb before a kill has gone out") + func idleLatchAbsorbsNothing() { + var latch = MySQLKillLatch() + #expect(latch.takeAbsorption() == false) + } + + /// The common case after Stop. A killed `SELECT` is caught by the cancellation gate in the fetch + /// loop, which throws `CancellationError` without ever reading the server's errno, so the kill + /// is delivered and nothing reports it as collected. + @Test("A delivered kill nobody reported is absorbed") + func deliveredKillIsAbsorbed() { + var latch = MySQLKillLatch() + latch.recordDelivered(generation: 7) + let absorbed = latch.takeAbsorption() + #expect(absorbed) + } + + @Test("A kill the statement itself collected is not absorbed again") + func interruptedKillIsNotAbsorbed() { + var latch = MySQLKillLatch() + latch.recordDelivered(generation: 7) + latch.recordInterrupted(generation: 7) + #expect(latch.takeAbsorption() == false) + } + + /// The kill goes out on the cancel queue while the statement reads its error on the statement + /// queue, so neither ordering is guaranteed and both have to answer the same. + @Test("The interruption may be recorded before the delivery") + func orderDoesNotMatter() { + var latch = MySQLKillLatch() + latch.recordInterrupted(generation: 7) + latch.recordDelivered(generation: 7) + #expect(latch.takeAbsorption() == false) + } + + /// An interruption belonging to an earlier statement says nothing about the kill just sent. + @Test("An interruption from another generation does not clear the latch") + func staleInterruptionDoesNotClear() { + var latch = MySQLKillLatch() + latch.recordInterrupted(generation: 6) + latch.recordDelivered(generation: 7) + let absorbed = latch.takeAbsorption() + #expect(absorbed) + } + + @Test("Taking the answer clears it, so one kill is absorbed once") + func takingClearsTheLatch() { + var latch = MySQLKillLatch() + latch.recordDelivered(generation: 7) + let first = latch.takeAbsorption() + #expect(first) + #expect(latch.takeAbsorption() == false) + } + + /// Only the two engines measured to hold an idle kill pay the round trip. + @Test("MySQL and MariaDB absorb a latched kill, the other flavours do not") + func onlyMeasuredFlavoursAbsorb() { + #expect(MySQLKillLatch.absorbsLatchedKill(flavor: .mysql)) + #expect(MySQLKillLatch.absorbsLatchedKill(flavor: .mariadb)) + #expect(MySQLKillLatch.absorbsLatchedKill(flavor: .tidb(version: nil)) == false) + #expect(MySQLKillLatch.absorbsLatchedKill(flavor: .databend) == false) + #expect(MySQLKillLatch.absorbsLatchedKill(flavor: .oceanbase(version: nil)) == false) + } +} + +/// The absorb step has to sit where every statement passes, not beside one of them. `streamQuery` +/// was the gap the design left: an export right after a Stop collected the kill instead. +@Suite("MySQL statement entry points") +struct MySQLStatementEntryPointGuardTests { + @Test("Every statement entry point goes through the wrapper that absorbs a latched kill") + func everyStatementEntryPointUsesTheWrapper() throws { + let source = try Self.connectionSource() + let wrapped = Self.lines(of: source).filter { $0.contains("try runStatement(") } + #expect( + wrapped.count == 3, + """ + executeQuerySync, executeParameterizedQuerySync and streamQuery each wrap their body in \ + runStatement, which is the one place absorbLatchedKillIfNeeded runs. A fourth statement \ + path needs the same wrapper: \(wrapped) + """ + ) + #expect(source.contains("absorbLatchedKillIfNeeded()")) + } + + /// The absorb is useless if the kill has not gone out yet, so the drain is part of it. + @Test("Absorbing drains the cancel queue before reading the latch") + func absorbDrainsTheCancelQueueFirst() throws { + let source = try Self.connectionSource() + let body = try #require(source.range(of: "func absorbLatchedKillIfNeeded() {")) + let tail = source[body.upperBound...].prefix(400) + let drain = try #require(tail.range(of: "cancelQueue.sync {}")) + let read = try #require(tail.range(of: "takeKillAbsorption()")) + #expect(drain.lowerBound < read.lowerBound) + } + + private static func lines(of source: String) -> [String] { + source.components(separatedBy: .newlines) + } + + private static func connectionSource() throws -> String { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + let candidate = directory + .appendingPathComponent("Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift") + if FileManager.default.fileExists(atPath: candidate.path) { + return try String(contentsOf: candidate, encoding: .utf8) + } + directory = directory.deletingLastPathComponent() + } + throw CocoaError(.fileNoSuchFile) + } +} diff --git a/TableProTests/Plugins/MySQLQueryTimeoutTests.swift b/TableProTests/Plugins/MySQLQueryTimeoutTests.swift new file mode 100644 index 0000000000..bde138d32d --- /dev/null +++ b/TableProTests/Plugins/MySQLQueryTimeoutTests.swift @@ -0,0 +1,162 @@ +// +// MySQLQueryTimeoutTests.swift +// TableProTests +// +// Banners and behaviour measured with libmariadb 3.4.4 against MySQL 5.5.62, 5.6.51, 5.7.44 and +// 8.4.11 and MariaDB 5.5.64, 10.0.38, 10.1.48 and 10.6.28. +// + +import Foundation +import Testing + +@Suite("MySQL query timeout enforcement") +struct MySQLQueryTimeoutTests { + @Test("A server timeout starts at MySQL 5.7.8") + func mysqlFloor() { + for banner in ["5.5.62", "5.6.51", "5.7.7"] { + #expect(mysqlQueryTimeoutEnforcement(seconds: 30, flavor: .mysql, banner: banner) + == .clientDeadline(MySQLStatementDeadline(seconds: 30, scope: .selectStatements))) + } + for banner in ["5.7.8", "5.7.44", "8.4.11"] { + #expect(mysqlQueryTimeoutEnforcement(seconds: 30, flavor: .mysql, banner: banner) + == .serverStatements(["SET SESSION max_execution_time = 30000"])) + } + } + + @Test("A server timeout starts at MariaDB 10.1.1, and covers every statement below it") + func mariadbFloor() { + for banner in ["5.5.64-MariaDB-1~trusty", "10.0.38-MariaDB-1~xenial", "10.1.0-MariaDB"] { + #expect(mysqlQueryTimeoutEnforcement(seconds: 30, flavor: .mariadb, banner: banner) + == .clientDeadline(MySQLStatementDeadline(seconds: 30, scope: .everyStatement))) + } + for banner in ["10.1.1-MariaDB", "10.1.48-MariaDB-1~bionic"] { + #expect(mysqlQueryTimeoutEnforcement(seconds: 30, flavor: .mariadb, banner: banner) + == .serverStatements(["SET SESSION max_statement_time = 30"])) + } + } + + @Test("An unreadable banner takes the client deadline rather than assuming a modern server") + func unknownBanner() { + #expect(mysqlQueryTimeoutEnforcement(seconds: 30, flavor: .mysql, banner: nil) + == .clientDeadline(MySQLStatementDeadline(seconds: 30, scope: .selectStatements))) + #expect(!MySQLServerVersion.hasStatementTimeout(banner: "unknown", flavor: .mysql)) + } + + @Test("TiDB, OceanBase and Databend keep their own statements whatever the banner says") + func variantsKeepTheirStatements() { + let flavors: [MySQLServerFlavor] = [ + .tidb(version: nil), + .oceanbase(version: MySQLEngineVersion(major: 4, minor: 4, patch: 2)), + .databend + ] + for flavor in flavors { + #expect(mysqlQueryTimeoutEnforcement(seconds: 30, flavor: flavor, banner: "5.6.25") + == .serverStatements(flavor.queryTimeoutStatements(seconds: 30))) + } + } + + @Test("A MySQL client deadline times a SELECT and nothing else") + func mysqlDeadlineScope() { + let deadline = mysqlClientDeadline(seconds: 5, flavor: .mysql) + let selects = ["SELECT 1", " select 1", "(SELECT SLEEP(5))", "/* lead */ SELECT 1", "-- c\nSELECT 1"] + for sql in selects { + #expect(deadline.applies(to: sql)) + } + let others = ["SHOW TABLES", "CALL p()", "DO SLEEP(5)", "INSERT INTO t SELECT 1", "UPDATE t SET a = 1"] + for sql in others { + #expect(!deadline.applies(to: sql)) + } + } + + @Test("A MariaDB client deadline times every statement") + func mariadbDeadlineScope() { + let deadline = mysqlClientDeadline(seconds: 5, flavor: .mariadb) + let statements = [ + "SELECT 1", "SHOW TABLES", "CALL p()", "DO SLEEP(5)", + "INSERT INTO t SELECT 1", "UPDATE t SET a = 1" + ] + for sql in statements { + #expect(deadline.applies(to: sql)) + } + } + + @Test("No timeout applies to nothing") + func zeroSecondsAppliesToNothing() { + #expect(!MySQLStatementDeadline(seconds: 0, scope: .everyStatement).applies(to: "SELECT 1")) + #expect(!MySQLStatementDeadline(seconds: 0, scope: .selectStatements).applies(to: "SELECT 1")) + } + + @Test("Only ERROR 1193 means the server has no statement timeout") + func rejectionCode() { + #expect(mysqlRejectsStatementTimeout(code: 1_193)) + #expect(!mysqlRejectsStatementTimeout(code: 1_064)) + #expect(!mysqlRejectsStatementTimeout(code: 1_227)) + } + + @Test("A statement stopped by the deadline is told apart from one the server refused") + func failureCause() { + #expect(cause(errno: 1_317, deadlineExpired: true) == .deadlineExceeded) + #expect(cause(errno: 1_317, deadlineExpired: false) == .server) + #expect(cause(errno: 3_024, deadlineExpired: true) == .server) + #expect(mysqlStatementFailureCause( + errno: 1_105, message: "AbortedQuery: killed", flavor: .databend, + deadlineExpired: true, waited: .seconds(1), socketTimeoutSeconds: 31 + ) == .deadlineExceeded) + } + + @Test("A lost connection past the socket timeout is the client's own, not the server's") + func socketTimeoutCause() { + #expect(cause(errno: 2_013, waited: .seconds(31)) == .outlastedSocketTimeout) + #expect(cause(errno: 2_013, waited: .milliseconds(30_999)) == .server) + #expect(cause(errno: 2_013, waited: .seconds(31), socketTimeoutSeconds: 0) == .server) + #expect(cause(errno: 1_064, waited: .seconds(31)) == .server) + } + + /// OceanBase sends two, and a server that takes `ob_query_timeout` and answers `ERROR 1193` to + /// `max_execution_time` is already enforcing a timeout of its own. A client-side deadline on top + /// of it stops the statement twice, and OceanBase absorbs no latched kill, so the second + /// `KILL QUERY` fails whatever the session runs next with `ERROR 1317`. + @Test("A refusal after the server has taken a statement leaves the server's timeout alone") + func refusalAfterAnAcceptedStatementKeepsTheServerTimeout() { + let flavor = MySQLServerFlavor.oceanbase(version: MySQLEngineVersion(major: 4, minor: 4, patch: 2)) + #expect(flavor.queryTimeoutStatements(seconds: 30).count == 2) + + var installation = MySQLQueryTimeoutInstallation(seconds: 30, flavor: flavor) + #expect(installation.accepted() == .sendNextStatement) + #expect(installation.refusedAsUnknownVariable() == .sendNextStatement) + } + + @Test("A server that refuses the first statement is timed by the client instead") + func refusalOfTheFirstStatementAdoptsTheClientDeadline() { + var installation = MySQLQueryTimeoutInstallation(seconds: 30, flavor: .mysql) + #expect(installation.refusedAsUnknownVariable() + == .adopt(mysqlClientDeadline(seconds: 30, flavor: .mysql))) + } + + /// Any other failure says nothing about whether the server has a timeout, so no client deadline + /// is installed. Returning without adopting left the deadline a previous call had built from + /// another `seconds` value stopping the user's statements. + @Test("A failure that is not ERROR 1193 clears the deadline rather than keeping an older one") + func otherFailuresAdoptNoDeadline() { + var installation = MySQLQueryTimeoutInstallation(seconds: 30, flavor: .mariadb) + #expect(installation.failed() == .adopt(nil)) + #expect(installation.accepted() == .sendNextStatement) + #expect(installation.failed() == .adopt(nil)) + } + + private func cause( + errno: UInt32, + deadlineExpired: Bool = false, + waited: Duration = .seconds(1), + socketTimeoutSeconds: UInt32 = 31 + ) -> MySQLStatementFailureCause { + mysqlStatementFailureCause( + errno: errno, + message: "Query execution was interrupted", + flavor: .mysql, + deadlineExpired: deadlineExpired, + waited: waited, + socketTimeoutSeconds: socketTimeoutSeconds + ) + } +} diff --git a/TableProTests/Plugins/MySQLServerFlavorTests.swift b/TableProTests/Plugins/MySQLServerFlavorTests.swift index 9db52b79cb..a2eeeb01a7 100644 --- a/TableProTests/Plugins/MySQLServerFlavorTests.swift +++ b/TableProTests/Plugins/MySQLServerFlavorTests.swift @@ -28,6 +28,18 @@ struct MySQLServerFlavorTests { #expect(MySQLServerFlavor.fromBanner(nil) == .mysql) } + /// Measured with the app's own libmariadb against MySQL 5.5.62 and 8.4.11, MariaDB 5.5.64 and + /// 11.4.13 and TiDB v8.5.1. Databend and OceanBase are unmeasured, and an engine that may never + /// set the flag must not be read as reporting no transaction. + @Test("Only the flavours measured to carry the session status flags report them") + func statusFlagReportingIsPerFlavor() { + #expect(MySQLServerFlavor.mysql.reportsSessionStatusFlags) + #expect(MySQLServerFlavor.mariadb.reportsSessionStatusFlags) + #expect(MySQLServerFlavor.tidb(version: nil).reportsSessionStatusFlags) + #expect(MySQLServerFlavor.databend.reportsSessionStatusFlags == false) + #expect(MySQLServerFlavor.oceanbase(version: nil).reportsSessionStatusFlags == false) + } + @Test("TiDB's release information carries the real version when the banner was overridden") func tidbReleaseInformation() { let info = "Release Version: v7.5.1\nEdition: Community\nGit Commit Hash: 7d16cc79" @@ -267,14 +279,16 @@ struct MySQLServerFlavorTests { @Test("CHECK constraints are read on TiDB from 7.2, whatever the 8.0.11 banner says") func tidbCheckConstraints() { let banner = "8.0.11-TiDB-v7.5.1" - #expect(MySQLServerVersion.hasCheckConstraints( + #expect(MySQLCheckConstraints.source( banner: banner, flavor: .tidb(version: MySQLEngineVersion(major: 7, minor: 5, patch: 1)) - )) - #expect(!MySQLServerVersion.hasCheckConstraints( + ) == .createTableStatement) + #expect(MySQLCheckConstraints.source( banner: banner, flavor: .tidb(version: MySQLEngineVersion(major: 7, minor: 1, patch: 5)) - )) - #expect(!MySQLServerVersion.hasCheckConstraints(banner: banner, flavor: .tidb(version: nil))) - #expect(!MySQLServerVersion.hasCheckConstraints(banner: Self.databendBanner, flavor: .databend)) + ) == .unavailable) + #expect(MySQLCheckConstraints.source(banner: banner, flavor: .tidb(version: nil)) == .unavailable) + #expect(MySQLCheckConstraints.source( + banner: Self.databendBanner, flavor: .databend + ) == .databendCatalog) } @Test("Databend's 8.0.90 banner does not unlock MySQL catalog columns it lacks") @@ -291,7 +305,8 @@ struct MySQLServerFlavorTests { ]) func oceanbaseCatalogGates(version: MySQLEngineVersion?, readsCheckConstraints: Bool) { let flavor = MySQLServerFlavor.oceanbase(version: version) - #expect(MySQLServerVersion.hasCheckConstraints(banner: "5.7.25", flavor: flavor) == readsCheckConstraints) + let source = MySQLCheckConstraints.source(banner: "5.7.25", flavor: flavor) + #expect((source == .informationSchema) == readsCheckConstraints) #expect(MySQLServerVersion.hasGenerationExpression(banner: "5.7.25", flavor: flavor)) #expect(!MySQLServerVersion.quotesColumnDefault(banner: "5.7.25", flavor: flavor)) } diff --git a/TableProTests/Plugins/MySQLSessionFootprintTests.swift b/TableProTests/Plugins/MySQLSessionFootprintTests.swift index 3c44cc3d83..bf061f0bd7 100644 --- a/TableProTests/Plugins/MySQLSessionFootprintTests.swift +++ b/TableProTests/Plugins/MySQLSessionFootprintTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("MySQL session footprint") @@ -59,6 +60,79 @@ struct MySQLSessionFootprintTests { #expect(footprint(after: "SELECT GET_LOCK('job', 10)", "SELECT RELEASE_ALL_LOCKS()").isClean) } + /// Measured on MySQL 8.4 with `lock_wait_timeout = 1`: a second session's `INSERT` failed with + /// error 1205 while `LOCK TABLES t WRITE` was held, and went through after the holder ran + /// `BEGIN`. A `COMMIT` does not release it, and the same `INSERT` failed again afterwards. + @Test("Beginning a transaction releases the table locks, and committing does not") + func beginningATransactionReleasesTheLocks() { + #expect(footprint(after: "LOCK TABLES users WRITE", "BEGIN").hasLockedTables == false) + #expect(footprint(after: "LOCK TABLES users WRITE", "begin work").hasLockedTables == false) + #expect(footprint(after: "LOCK TABLES users WRITE", "START TRANSACTION").hasLockedTables == false) + #expect(footprint(after: "LOCK TABLES users WRITE", "COMMIT").hasLockedTables) + } + + /// `SQLTransactionTracking` reads any leading `START` as opening a transaction, which is the + /// safe direction for the release gate and the wrong one here: `START REPLICA` holds no + /// transaction and releases no lock. + @Test("Starting replication is not beginning a transaction, so the locks stay") + func startingReplicationKeepsTheLocks() { + #expect(footprint(after: "LOCK TABLES users WRITE", "START REPLICA").hasLockedTables) + #expect(footprint(after: "LOCK TABLES users WRITE", "BEGIN NOT ATOMIC SELECT 1; END").hasLockedTables) + } + + /// Measured on the same server: after `LOCK TABLES t WRITE` then + /// `LOCK TABLES nonexistent WRITE` (error 1146), the other session's `INSERT` went through, so + /// the failed statement released what the session held and acquired nothing. + @Test("A LOCK TABLES the server refused holds nothing") + func aFailedLockHoldsNothing() { + var result = footprint(after: "LOCK TABLES users WRITE") + result.observeFailure(of: "LOCK TABLES missing WRITE") + #expect(result.hasLockedTables == false) + #expect(result.isClean) + } + + /// The driver reports the text it sent, not the statement inside it the server refused, and a + /// server runs a batch in order and stops at the first refusal. So the lock here was taken and + /// the `INSERT` is what failed. Clearing the flag for any `LOCK TABLES` in the text reported a + /// session holding nothing, and the idle release then handed the connection back and let the + /// next batch's `START TRANSACTION` release the user's lock. + @Test("A batch whose later statement failed keeps the lock its first statement took") + func aFailedBatchKeepsTheLockItAlreadyTook() { + let batch = "LOCK TABLES users WRITE; INSERT INTO users VALUES (bad)" + var result = footprint(after: batch) + result.observeFailure(of: batch) + #expect(result.hasLockedTables) + #expect(result.transactionState(isInTransaction: false) == .holdsSessionLocks) + } + + @Test("A failure takes back nothing but the lock, because a statement can fail after changing the session") + func aFailureTakesBackOnlyTheLock() { + var result = footprint(after: "CREATE TEMPORARY TABLE staging (a INT)", "LOCK TABLES users WRITE") + result.observeFailure(of: "CREATE TEMPORARY TABLE staging (a INT)") + #expect(result.hasTemporaryTables) + #expect(result.hasLockedTables) + } + + @Test("The open transaction is the server's answer, and the lock is never read as one") + func transactionStateSeparatesLocksFromTransactions() { + let locked = footprint(after: "LOCK TABLES users WRITE") + #expect(locked.transactionState(isInTransaction: false) == .holdsSessionLocks) + #expect(locked.transactionState(isInTransaction: true) == .inTransaction) + + let clean = footprint(after: "SELECT 1") + #expect(clean.transactionState(isInTransaction: false) == .idle) + #expect(clean.transactionState(isInTransaction: true) == .inTransaction) + } + + /// `SET autocommit = 0` plus a write opens a transaction that appears nowhere in the text, and + /// the server reports it in the status flags the driver passes in here. + @Test("A transaction only the server can see is still reported") + func serverOnlyTransactionIsReported() { + var result = footprint(after: "SET autocommit = 0", "INSERT INTO t VALUES (1)") + result.observeServerTransaction(isOpen: true) + #expect(result.transactionState(isInTransaction: true) == .inTransaction) + } + /// A global setting outlives the connection, so it is not the session's to lose and must not /// keep the connection alive forever. @Test("A global setting does not block, but a bare SET does") @@ -249,6 +323,32 @@ struct MySQLSessionFootprintTests { result.reset() #expect(result.isClean) } + + /// `SET PASSWORD` writes the grant tables, not the session, so a reconnect loses nothing. It + /// read as a session setting, which held the connection with the wrong reason and turned off + /// replay until the next reconnect. + @Test("SET PASSWORD is an account change, not a session setting") + func setPasswordIsNotASessionSetting() { + let statements = [ + "SET PASSWORD FOR `acc`@`%` = PASSWORD('x')", + "set password = password('x')", + "SET\n PASSWORD\tFOR `acc`@`%` = PASSWORD('x')", + "/*!40101 SET PASSWORD FOR `acc`@`%` = PASSWORD('x') */" + ] + for statement in statements { + let result = footprint(after: statement) + #expect(result.isClean, "\(statement)") + #expect(result.blockingReason == nil, "\(statement)") + #expect(mysqlMayReplay("SELECT 1", on: result), "\(statement)") + } + } + + @Test("A setting whose name starts the same way is still a session setting") + func passwordPrefixedSettingsStillCount() { + #expect(footprint(after: "SET password_history = 3").hasSessionSettings) + #expect(footprint(after: "SET SESSION sql_mode = 'ANSI'").hasSessionSettings) + #expect(footprint(after: "SET sql_mode = 'ANSI'").hasSessionSettings) + } } @Suite("MySQL idle release policy") diff --git a/TableProTests/Plugins/MySQLSocketTimeoutTests.swift b/TableProTests/Plugins/MySQLSocketTimeoutTests.swift index f715500c2f..6494ef3ca9 100644 --- a/TableProTests/Plugins/MySQLSocketTimeoutTests.swift +++ b/TableProTests/Plugins/MySQLSocketTimeoutTests.swift @@ -32,4 +32,17 @@ struct MySQLSocketTimeoutTests { func largeValueClamps() { #expect(mysqlSocketTimeoutSeconds(forQueryTimeout: Int.max) == UInt32.max) } + + @Test("An infinite socket timeout can never be what a failure waited for") + func infiniteTimeoutNeverOutlasted() { + #expect(!mysqlWaitCouldOutlastSocketTimeout(.seconds(0), socketTimeoutSeconds: 0)) + #expect(!mysqlWaitCouldOutlastSocketTimeout(.seconds(3_600), socketTimeoutSeconds: 0)) + } + + @Test("A wait reaches the socket timeout exactly on it, and not a millisecond before") + func boundary() { + #expect(mysqlWaitCouldOutlastSocketTimeout(.seconds(90), socketTimeoutSeconds: 90)) + #expect(mysqlWaitCouldOutlastSocketTimeout(.seconds(91), socketTimeoutSeconds: 90)) + #expect(!mysqlWaitCouldOutlastSocketTimeout(.milliseconds(89_999), socketTimeoutSeconds: 90)) + } } diff --git a/TableProTests/Plugins/MySQLStatementClassificationTests.swift b/TableProTests/Plugins/MySQLStatementClassificationTests.swift index 8b083afefe..2f07261f47 100644 --- a/TableProTests/Plugins/MySQLStatementClassificationTests.swift +++ b/TableProTests/Plugins/MySQLStatementClassificationTests.swift @@ -171,4 +171,23 @@ struct MySQLReplaySafetyTests { #expect(!mysqlMayReplay("UPDATE users SET name = 'a'", on: MySQLSessionFootprint())) #expect(!mysqlMayReplay("SELECT GET_LOCK('job', 10)", on: MySQLSessionFootprint())) } + + /// libmariadb reports its own read timeout as `2013 Lost connection to server during query`, + /// which is what a server-side drop reports too. Measured on MySQL 5.5.62, the server was still + /// running two copies of the statement after the driver reported the connection lost, so the + /// replay would have added a third. + @Test("A connection lost under the socket timeout is retaken, and one lost by it is not") + func connectionLossReplay() { + for code in [UInt32(2_006), 2_013, 2_055] { + #expect(mysqlConnectionLossMayReplay(code: code, outlastedSocketTimeout: false), "\(code)") + #expect(!mysqlConnectionLossMayReplay(code: code, outlastedSocketTimeout: true), "\(code)") + } + } + + @Test("A failure that is not a lost connection is never a reconnect") + func otherCodesAreNotConnectionLoss() { + for code in [UInt32(1_317), 2_026, 3_024] { + #expect(!mysqlConnectionLossMayReplay(code: code, outlastedSocketTimeout: false), "\(code)") + } + } } diff --git a/TableProTests/Plugins/MySQLStatementDeadlineRunnerTests.swift b/TableProTests/Plugins/MySQLStatementDeadlineRunnerTests.swift new file mode 100644 index 0000000000..b8be44752d --- /dev/null +++ b/TableProTests/Plugins/MySQLStatementDeadlineRunnerTests.swift @@ -0,0 +1,151 @@ +// +// MySQLStatementDeadlineRunnerTests.swift +// TableProTests +// + +import Foundation +import Testing + +private struct StubFailure: Error, Equatable { + let code: UInt32 + var message = "stub" + var outlasted = false +} + +private final class RunnerHarness: @unchecked Sendable { + let watch = MySQLStatementWatch() + var scheduledDuration: Duration? + var cancelledSchedule = false + var interrupts = 0 + var flushes = 0 + var orphanKills = 0 + var firesImmediately = false + + func runner( + deadline: MySQLStatementDeadline?, + flavor: MySQLServerFlavor = .mysql, + socketTimeoutSeconds: UInt32 = 31, + elapsed: Duration = .seconds(1) + ) -> MySQLStatementDeadlineRunner { + var reads = 0 + let start = ContinuousClock.now + return MySQLStatementDeadlineRunner( + deadline: deadline, + flavor: flavor, + socketTimeoutSeconds: socketTimeoutSeconds, + watch: watch, + now: { + reads += 1 + return reads == 1 ? start : start.advanced(by: elapsed) + }, + schedule: { duration, action in + self.scheduledDuration = duration + if self.firesImmediately { action() } + return { self.cancelledSchedule = true } + }, + expire: { token in + self.watch.expire(token) { + self.interrupts += 1 + return true + } + }, + flushInterrupt: { self.flushes += 1 }, + killOrphan: { self.orphanKills += 1 }, + failureDetail: { error in + guard let failure = error as? StubFailure else { return nil } + return MySQLStatementFailure(code: failure.code, message: failure.message) + }, + deadlineExceeded: { StubFailure(code: 1_317, message: "stopped after \($0)s") }, + markOutlasted: { error in + guard var failure = error as? StubFailure else { return error } + failure.outlasted = true + return failure + } + ) + } +} + +@Suite("MySQL statement deadline runner") +struct MySQLStatementDeadlineRunnerTests { + private let deadline = MySQLStatementDeadline(seconds: 5, scope: .selectStatements) + + @Test("A statement that finishes first cancels the schedule and is never interrupted") + func fastBodyCancelsSchedule() throws { + let harness = RunnerHarness() + let value = try harness.runner(deadline: deadline).run("SELECT 1") { 42 } + #expect(value == 42) + #expect(harness.scheduledDuration == .seconds(5)) + #expect(harness.cancelledSchedule) + #expect(harness.interrupts == 0) + #expect(harness.flushes == 0) + } + + @Test("A statement past the deadline is interrupted once and reported as the timeout") + func slowBodyBecomesTimeout() { + let harness = RunnerHarness() + harness.firesImmediately = true + #expect(throws: StubFailure(code: 1_317, message: "stopped after 5s")) { + try harness.runner(deadline: deadline).run("SELECT SLEEP(9)") { + throw StubFailure(code: 1_317, message: "Query execution was interrupted") + } + } + #expect(harness.interrupts == 1) + #expect(harness.flushes == 0) + } + + /// The kill connection takes up to 1900ms to open, so the statement can finish while it is + /// being built. The flag then waits for the next statement: measured on MySQL 5.5.62, the one + /// after an idle kill failed with 1317 and an `INSERT ... SELECT` inserted nothing. + @Test("A kill that landed too late is consumed before the queue is released") + func killWithoutInterruptionIsFlushed() throws { + let harness = RunnerHarness() + harness.firesImmediately = true + let value = try harness.runner(deadline: deadline).run("SELECT 1") { 7 } + #expect(value == 7) + #expect(harness.interrupts == 1) + #expect(harness.flushes == 1) + } + + @Test("A kill that landed on a statement the server refused for another reason is flushed too") + func killWithUnrelatedFailureIsFlushed() { + let harness = RunnerHarness() + harness.firesImmediately = true + #expect(throws: StubFailure(code: 1_146, message: "Table 't' doesn't exist")) { + try harness.runner(deadline: deadline).run("SELECT 1") { + throw StubFailure(code: 1_146, message: "Table 't' doesn't exist") + } + } + #expect(harness.flushes == 1) + } + + @Test("A lost connection past the socket timeout is flagged and the orphan is killed") + func socketTimeoutKillsTheOrphan() { + let harness = RunnerHarness() + #expect(throws: StubFailure(code: 2_013, message: "Lost connection", outlasted: true)) { + try harness.runner(deadline: nil, elapsed: .seconds(31)).run("SHOW TABLES") { + throw StubFailure(code: 2_013, message: "Lost connection") + } + } + #expect(harness.orphanKills == 1) + } + + @Test("A lost connection before the socket timeout is left replayable") + func earlyConnectionLossIsNotFlagged() { + let harness = RunnerHarness() + #expect(throws: StubFailure(code: 2_013, message: "Lost connection")) { + try harness.runner(deadline: nil, elapsed: .seconds(2)).run("SHOW TABLES") { + throw StubFailure(code: 2_013, message: "Lost connection") + } + } + #expect(harness.orphanKills == 0) + } + + @Test("A statement outside the deadline's scope schedules nothing") + func outOfScopeStatementIsNotWatched() throws { + let harness = RunnerHarness() + let value = try harness.runner(deadline: deadline).run("SHOW TABLES") { 1 } + #expect(value == 1) + #expect(harness.scheduledDuration == nil) + #expect(harness.interrupts == 0) + } +} diff --git a/TableProTests/Plugins/MySQLStatementWatchTests.swift b/TableProTests/Plugins/MySQLStatementWatchTests.swift new file mode 100644 index 0000000000..144104d80d --- /dev/null +++ b/TableProTests/Plugins/MySQLStatementWatchTests.swift @@ -0,0 +1,100 @@ +// +// MySQLStatementWatchTests.swift +// TableProTests +// + +import Dispatch +import Foundation +import Testing + +@Suite("MySQL statement watch") +struct MySQLStatementWatchTests { + @Test("An expiry while the statement runs interrupts once, and end reports it") + func expiryWhileRunning() { + let watch = MySQLStatementWatch() + let token = watch.begin() + var interrupts = 0 + watch.expire(token) { + interrupts += 1 + return true + } + #expect(interrupts == 1) + #expect(watch.end(token)) + } + + @Test("An expiry after the statement ended never interrupts") + func expiryAfterEnd() { + let watch = MySQLStatementWatch() + let token = watch.begin() + #expect(!watch.end(token)) + var interrupts = 0 + watch.expire(token) { + interrupts += 1 + return true + } + #expect(interrupts == 0) + #expect(!watch.end(token)) + } + + @Test("An expiry for a token a later statement replaced never interrupts") + func staleToken() { + let watch = MySQLStatementWatch() + let first = watch.begin() + _ = watch.end(first) + let second = watch.begin() + var interrupts = 0 + watch.expire(first) { + interrupts += 1 + return true + } + #expect(interrupts == 0) + #expect(!watch.end(second)) + #expect(!watch.isRunning(second)) + } + + @Test("An interrupt that could not be sent leaves end reporting nothing to consume") + func failedInterrupt() { + let watch = MySQLStatementWatch() + let token = watch.begin() + watch.expire(token) { false } + #expect(!watch.end(token)) + } + + @Test("A kill is reported once, so only the statement that saw it consumes the flag") + func interruptConsumedOnce() { + let watch = MySQLStatementWatch() + let token = watch.begin() + watch.expire(token) { true } + #expect(watch.end(token)) + #expect(!watch.end(token)) + } + + /// The whole point of holding the lock across the interrupt: the statement cannot finish, and + /// therefore the next statement on the serial queue cannot start, while a kill is in flight. + @Test("end waits for an interrupt that is still in flight") + func endWaitsForInterrupt() { + let watch = MySQLStatementWatch() + let token = watch.begin() + let ended = DispatchSemaphore(value: 0) + let interruptStarted = DispatchSemaphore(value: 0) + let endResult = MySQLStatementWatchTestBox() + + watch.expire(token) { + DispatchQueue.global().async { + interruptStarted.signal() + endResult.value = watch.end(token) + ended.signal() + } + interruptStarted.wait() + #expect(ended.wait(timeout: .now() + .milliseconds(50)) == .timedOut) + return true + } + + #expect(ended.wait(timeout: .now() + .seconds(5)) == .success) + #expect(endResult.value) + } +} + +private final class MySQLStatementWatchTestBox: @unchecked Sendable { + var value = false +} diff --git a/TableProTests/Plugins/RedisQueuedReplyTests.swift b/TableProTests/Plugins/RedisQueuedReplyTests.swift new file mode 100644 index 0000000000..f900e79705 --- /dev/null +++ b/TableProTests/Plugins/RedisQueuedReplyTests.swift @@ -0,0 +1,230 @@ +// +// RedisQueuedReplyTests.swift +// TableProTests +// +// A command sent while a MULTI block is open answers `+QUEUED` rather than its own reply, and +// every caller that read a value out of one read the acknowledgement instead: GET returned +// "QUEUED" as the stored value, DEL counted zero deletions, LPUSH reported length zero and the +// sidebar's DBSIZE reported an empty keyspace. +// + +import Foundation +import TableProPluginKit +import Testing + +/// Driven by one task at a time, so the replies are handed out in order with no synchronisation. +private final class StubRedisChannel: RedisCommandChannel, @unchecked Sendable { + private var queuedReplies: [RedisReply] + private(set) var sentCommands: [[String]] = [] + + init(_ replies: [RedisReply]) { + queuedReplies = replies + } + + var isConnected: Bool { true } + + func connect(reportingStage report: @escaping ConnectionStageReporter) async throws {} + func disconnect() {} + func cancelCurrentQuery() {} + func serverVersion() -> String? { "8.10.1" } + func currentDatabase() -> Int { 0 } + func selectDatabase(_ index: Int) async throws {} + + func executeCommand(_ args: [Data]) async throws -> RedisReply { + sentCommands.append(args.map { String(data: $0, encoding: .utf8) ?? "" }) + guard !queuedReplies.isEmpty else { return .null } + return queuedReplies.removeFirst() + } + + func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] { + var replies: [RedisReply] = [] + for command in commands { + replies.append(try await executeCommand(command)) + } + return replies + } +} + +@Suite("Redis reply - a queued acknowledgement is not an answer") +struct RedisQueuedReplyShapeTests { + @Test("A +QUEUED simple string is the acknowledgement") + func statusIsQueued() { + #expect(RedisReply.status("QUEUED").isQueued) + } + + /// The shape carries the signal, not the text. Measured over raw RESP on Redis 8.10.1: a + /// queued command answers `+QUEUED\r\n`, while a GET of a key holding the word answers the + /// bulk string `$6\r\nQUEUED`. + static let notQueued: [RedisReply] = [ + .string("QUEUED"), + .data(Data("QUEUED".utf8)), + .error("QUEUED"), + .status("queued"), + .status("QUEUED "), + .status("OK"), + .status(""), + .integer(1), + .array([.status("QUEUED")]), + .null, + ] + + @Test("Every other reply carrying the same word is a value", arguments: notQueued) + func otherShapesAreValues(reply: RedisReply) { + #expect(!reply.isQueued) + } + + @Test("throwIfQueued names the command that was queued") + func throwIfQueuedNamesTheCommand() throws { + do { + try RedisReply.status("QUEUED").throwIfQueued("DBSIZE") + Issue.record("expected a throw") + } catch let queued as RedisQueuedCommand { + #expect(queued == RedisQueuedCommand(command: "DBSIZE")) + #expect(queued.pluginErrorMessage.contains("DBSIZE")) + #expect(queued.pluginErrorDetail?.isEmpty == false) + } + } + + @Test("A real reply passes straight through") + func passesThroughValues() throws { + #expect(try RedisReply.string("QUEUED").throwIfQueued("GET").stringValue == "QUEUED") + #expect(try RedisReply.integer(3).throwIfQueued("DEL").intValue == 3) + } + + @Test("A command with no name still reports something readable") + func unnamedCommand() { + #expect(RedisQueuedCommand(command: "").pluginErrorMessage.isEmpty == false) + } +} + +@Suite("Redis command channel - the run choke point") +struct RedisCommandChannelRunTests { + @Test("run(_: [String]) refuses a queued acknowledgement") + func stringOverloadRefusesQueued() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + await #expect(throws: RedisQueuedCommand(command: "GET")) { + try await channel.run(["GET", "k"]) + } + } + + @Test("run(_: [Data]) refuses a queued acknowledgement and decodes the command name") + func dataOverloadRefusesQueued() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + await #expect(throws: RedisQueuedCommand(command: "DEL")) { + try await channel.run([Data("DEL".utf8), Data("k".utf8)]) + } + } + + /// A server error is still the first thing checked, so a refusal inside a block is reported as + /// the refusal it is rather than as the queueing that never happened. + @Test("An error reply throws the driver error, not the queued one") + func errorBeatsQueued() async throws { + let channel = StubRedisChannel([.error("NOPERM User lim has no permissions to run the 'expire' command")]) + do { + try await channel.run(["EXPIRE", "k", "10"]) + Issue.record("expected a throw") + } catch let error as RedisPluginError { + #expect(error.message.contains("NOPERM")) + } + } + + @Test("A real reply is returned unchanged by both overloads") + func realRepliesPassThrough() async throws { + let strings = StubRedisChannel([.integer(2)]) + #expect(try await strings.run(["DEL", "a", "b"]).intValue == 2) + + let datas = StubRedisChannel([.string("hello")]) + #expect(try await datas.run([Data("GET".utf8), Data("s".utf8)]).stringValue == "hello") + } +} + +@Suite("Redis queued command policy") +struct RedisQueuedCommandPolicyTests { + /// A one-row `QUEUED` status in the data grid reads as an empty table, so the two walks the app + /// builds for itself say the keyspace could not be read instead. + @Test("The app's own keyspace walks refuse a queued reply") + func appKeyspaceWalksRefuse() { + #expect(RedisOperation.keyBrowse(pattern: nil, typeScope: nil, limit: 100, offset: 0) + .queuedCommandAnswer == .refuse) + #expect(RedisOperation.keyTree(pattern: nil, limit: 100).queuedCommandAnswer == .refuse) + } + + @Test("A command the user typed reports the acknowledgement the server gave it") + func userCommandsReportQueued() { + let operations: [RedisOperation] = [ + .get(key: "k"), + .set(key: "k", value: Data("v".utf8), options: nil), + .del(keys: ["k"]), + .dbsize, + .exists(keys: ["k"]), + ] + for operation in operations { + #expect(operation.queuedCommandAnswer == .reportQueued) + } + } +} + +/// The paged read and the streamed read both run an operation, and the streamed one used to run it +/// without translating the queued reply: a command sent into the user's open block threw instead of +/// answering `QUEUED`, and nothing recorded it, so `EXEC`'s replies paired with the recorded commands +/// one position out. The translation therefore belongs to the one function that dispatches an +/// operation, not to a route. The plugin imports CRedis, which this target cannot, so the guard is a +/// source scan. +@Suite("Redis queued translation source scan") +struct RedisQueuedTranslationSourceScanTests { + private static let pluginDirectory: URL = { + var directory = URL(fileURLWithPath: #filePath) + for _ in 0 ..< 3 { directory.deleteLastPathComponent() } + return directory + .appendingPathComponent("Plugins") + .appendingPathComponent("RedisDriverPlugin") + }() + + private static func source(_ name: String) throws -> String { + try String(contentsOf: pluginDirectory.appendingPathComponent(name), encoding: .utf8) + } + + private static let queuedCatch = "catch let queued as RedisQueuedCommand" + + @Test("The operation dispatcher is what translates a queued reply") + func theDispatcherTranslates() throws { + #expect(try Self.source("RedisPluginDriver+Operations.swift").contains(Self.queuedCatch)) + } + + @Test("No route translates a queued reply for itself") + func noRouteTranslatesOnItsOwn() throws { + #expect(!(try Self.source("RedisPluginDriver.swift").contains(Self.queuedCatch))) + } +} + +@Suite("Redis command channel - the default keyspace walk") +struct RedisCommandChannelScanTests { + @Test("A queued SCAN is refused rather than read as an empty keyspace") + func queuedScanIsRefused() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + await #expect(throws: RedisQueuedCommand(command: "SCAN")) { + try await channel.scanKeyspace(cursor: "0", pattern: nil, type: nil, count: 200) + } + } + + @Test("A refused SCAN throws the server's error") + func erroredScanThrows() async throws { + let channel = StubRedisChannel([.error("NOPERM no permissions to run the 'scan' command")]) + await #expect(throws: RedisPluginError.self) { + try await channel.scanKeyspace(cursor: "0", pattern: nil, type: nil, count: 200) + } + } + + @Test("A real SCAN answer is parsed into a page") + func realScanIsParsed() async throws { + let channel = StubRedisChannel([ + .array([.string("17"), .array([.string("a"), .string("b")])]), + ]) + let page = try await channel.scanKeyspace(cursor: "0", pattern: "*", type: "string", count: 200) + #expect(page.cursor == "17") + #expect(page.keys == ["a", "b"]) + #expect(!page.isIncomplete) + #expect(!page.isFinished) + #expect(channel.sentCommands == [["SCAN", "0", "MATCH", "*", "COUNT", "200", "TYPE", "string"]]) + } +} diff --git a/TableProTests/Plugins/RedisTransactionOutcomeTests.swift b/TableProTests/Plugins/RedisTransactionOutcomeTests.swift new file mode 100644 index 0000000000..0c3a04e90c --- /dev/null +++ b/TableProTests/Plugins/RedisTransactionOutcomeTests.swift @@ -0,0 +1,174 @@ +// +// RedisTransactionOutcomeTests.swift +// TableProTests +// +// EXEC puts a command's failure in its own element of the reply array and applies every other +// command in the block anyway, so a caller reading only the top level reported success for a save +// the server half refused. A grid save whose RENAME named a missing key answered +OK and wrote the +// SET that followed it. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("Redis transaction outcome") +struct RedisTransactionOutcomeTests { + /// The shape measured on Redis 8.10.1 for `MULTI; GET s; LPUSH s x; SET t 1; DEL nokey; INCR s; + /// EXEC`, which then left `GET t` answering 1. + static let mixedReply = RedisReply.array([ + .string("hello"), + .error("WRONGTYPE Operation against a key holding the wrong kind of value"), + .status("OK"), + .integer(0), + .error("ERR value is not an integer or out of range"), + ]) + + @Test("Each failed element is named by the command queued at its position") + func namesEachFailure() { + let failures = RedisTransactionOutcome.failures( + inExecReply: Self.mixedReply, + queuedCommands: ["GET", "LPUSH", "SET", "DEL", "INCR"] + ) + #expect(failures == [ + RedisFailedCommand( + label: "LPUSH", + message: "WRONGTYPE Operation against a key holding the wrong kind of value" + ), + RedisFailedCommand(label: "INCR", message: "ERR value is not an integer or out of range"), + ]) + } + + @Test("A block the server applied without complaint names nothing") + func cleanBlockHasNoFailures() { + let reply = RedisReply.array([.status("OK"), .integer(1)]) + #expect(RedisTransactionOutcome.failures(inExecReply: reply, queuedCommands: ["SET", "DEL"]).isEmpty) + #expect(RedisTransactionOutcome.failures(inExecReply: .array([]), queuedCommands: []).isEmpty) + } + + /// `-EXECABORT` for a queue-time refusal, `-ERR EXEC without MULTI` after a `RESET`, and a nil + /// reply for a broken `WATCH` all mean nothing ran, and `throwIfError` already raises the first + /// two. + @Test( + "A reply that is not an array is a block that never ran", + arguments: [ + RedisReply.error("EXECABORT Transaction discarded because of previous errors."), + RedisReply.error("ERR EXEC without MULTI"), + RedisReply.null, + RedisReply.status("OK"), + RedisReply.integer(0), + ] + ) + func nonArrayRepliesNameNothing(reply: RedisReply) { + #expect(RedisTransactionOutcome.failures(inExecReply: reply, queuedCommands: ["SET"]).isEmpty) + } + + /// A user is free to type their own `MULTI` on the same session, so the block can hold commands + /// the driver never recorded. + @Test("A position with no recorded command is named by its position") + func fallsBackToPositions() { + let reply = RedisReply.array([.status("OK"), .error("ERR no such key"), .error("ERR nope")]) + let failures = RedisTransactionOutcome.failures(inExecReply: reply, queuedCommands: ["SET", ""]) + #expect(failures.map(\.label) == ["Command 2", "Command 3"]) + } + + @Test("One failure reads as one command, several read as a list") + func presentsFailuresToTheApp() { + let one = RedisTransactionError(failed: [RedisFailedCommand(label: "RENAME", message: "ERR no such key")]) + #expect(one.pluginErrorMessage.contains("RENAME")) + #expect(one.pluginErrorMessage.contains("ERR no such key")) + + let two = RedisTransactionError(failed: [ + RedisFailedCommand(label: "RENAME", message: "ERR no such key"), + RedisFailedCommand(label: "INCR", message: "ERR value is not an integer or out of range"), + ]) + #expect(two.pluginErrorMessage.contains("RENAME")) + #expect(two.pluginErrorMessage.contains("INCR")) + #expect(two.pluginErrorMessage.contains("2")) + #expect(two.pluginErrorDetail?.isEmpty == false) + } +} + +@Suite("Redis queued database") +struct RedisQueuedDatabaseTests { + @Test("A block that applied moves the session to the queued index") + func execAdoptsThePendingIndex() { + var queued = RedisQueuedDatabase() + queued.queue(2) + #expect(queued.resolve(command: "EXEC", reply: .array([.status("OK")])) == 2) + #expect(queued.pending == nil) + } + + struct EndedBlock: Sendable { + let command: String + let reply: RedisReply + } + + /// Measured: `MULTI; SELECT 2; DISCARD` and `MULTI; SELECT 3; RESET` both leave `CLIENT INFO` + /// reporting `db=0`. + static let endedBlocks: [EndedBlock] = [ + EndedBlock(command: "DISCARD", reply: .status("OK")), + EndedBlock(command: "RESET", reply: .status("RESET")), + EndedBlock(command: "EXEC", reply: .error("EXECABORT Transaction discarded because of previous errors.")), + EndedBlock(command: "EXEC", reply: .error("ERR EXEC without MULTI")), + EndedBlock(command: "EXEC", reply: .null), + ] + + @Test("A block that never ran leaves the session where it was", arguments: endedBlocks) + func endedBlockDropsThePendingIndex(block: EndedBlock) { + var queued = RedisQueuedDatabase() + queued.queue(2) + #expect(queued.resolve(command: block.command, reply: block.reply) == nil) + #expect(queued.pending == nil) + } + + @Test("A command name is read case-insensitively, the way redis-cli accepts one") + func commandNameIsCaseInsensitive() { + var queued = RedisQueuedDatabase() + queued.queue(4) + #expect(queued.resolve(command: "exec", reply: .array([.status("OK")])) == 4) + } + + @Test("Any other command inside the block leaves the queued index waiting") + func otherCommandsKeepThePendingIndex() { + var queued = RedisQueuedDatabase() + queued.queue(2) + #expect(queued.resolve(command: "SET", reply: .status("QUEUED")) == nil) + #expect(queued.resolve(command: "GET", reply: .status("QUEUED")) == nil) + #expect(queued.pending == 2) + } + + /// `MULTI` inside an open block is refused and leaves the block open, so it cannot be read as + /// the block ending. Measured: `ERR MULTI calls can not be nested`. + @Test("A refused nested MULTI keeps the queued index") + func nestedMultiKeepsThePendingIndex() { + var queued = RedisQueuedDatabase() + queued.queue(2) + #expect(queued.resolve(command: "MULTI", reply: .error("ERR MULTI calls can not be nested")) == nil) + #expect(queued.pending == 2) + } + + @Test("A MULTI the server accepted starts a block with nothing pending in it") + func acceptedMultiClearsThePendingIndex() { + var queued = RedisQueuedDatabase() + queued.queue(2) + #expect(queued.resolve(command: "MULTI", reply: .status("OK")) == nil) + #expect(queued.pending == nil) + } + + @Test("Nothing is adopted when no SELECT was queued") + func execWithoutAPendingIndexAdoptsNothing() { + var queued = RedisQueuedDatabase() + #expect(queued.resolve(command: "EXEC", reply: .array([.status("OK")])) == nil) + #expect(queued.resolve(command: nil, reply: .array([.status("OK")])) == nil) + } + + @Test("Clearing it drops the queued index, which is what a lost connection does") + func clearingDropsThePendingIndex() { + var queued = RedisQueuedDatabase() + queued.queue(7) + queued.clear() + #expect(queued.pending == nil) + #expect(queued.resolve(command: "EXEC", reply: .array([.status("OK")])) == nil) + } +} diff --git a/TableProTests/Plugins/TiDBCheckConstraintsTests.swift b/TableProTests/Plugins/TiDBCheckConstraintsTests.swift deleted file mode 100644 index 121fbab3db..0000000000 --- a/TableProTests/Plugins/TiDBCheckConstraintsTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// TiDBCheckConstraintsTests.swift -// TableProTests -// -// Each statement is SHOW CREATE TABLE output measured on TiDB 7.5.1 and 8.5.1. -// - -import Foundation -import TableProPluginKit -import Testing - -@Suite("TiDB check constraints") -struct TiDBCheckConstraintsTests { - @Test("7.5 prints constraints unindented, and each expression matches CHECK_CLAUSE") - func tidb75() { - let sql = """ - CREATE TABLE `t` ( - `id` int(11) NOT NULL, - `n` int(11) DEFAULT NULL, - `m` int(11) DEFAULT NULL, - PRIMARY KEY (`id`) /*T![clustered_index] CLUSTERED */, - CONSTRAINT `ck_n` CHECK ((`n` > 0)), - CONSTRAINT `t_chk_1` CHECK ((`m` < 10)) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin - """ - let checks = TiDBCheckConstraints.parse(createTable: sql) - #expect(checks.map(\.name) == ["ck_n", "t_chk_1"]) - #expect(checks.map(\.expression) == ["(`n` > 0)", "(`m` < 10)"]) - } - - @Test("Quotes, commas and parentheses inside a name or literal do not split the constraint") - func tidb85QuotedText() { - let sql = """ - CREATE TABLE `w(x` ( - `s` varchar(10) DEFAULT NULL, - CONSTRAINT `c,1` CHECK ((`s` != _utf8mb4'a,(b'' \\\\ )')) /*!80016 NOT ENFORCED */, - CONSTRAINT `q``t` CHECK ((length(`s`) > 1)) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin - """ - let checks = TiDBCheckConstraints.parse(createTable: sql) - #expect(checks.map(\.name) == ["c,1", "q`t"]) - #expect(checks.map(\.expression) == ["(`s` != _utf8mb4'a,(b'' \\\\ )')", "(length(`s`) > 1)"]) - } - - @Test("A table without checks has none, and a column named like the keyword is not one") - func noChecks() { - let sql = """ - CREATE TABLE `t` ( - `constraint` int DEFAULT NULL, - `check` varchar(3) DEFAULT '(,)' - ) ENGINE=InnoDB - """ - #expect(TiDBCheckConstraints.parse(createTable: sql).isEmpty) - } -} diff --git a/TableProTests/Views/Main/FetchAllQueryTaskTests.swift b/TableProTests/Views/Main/FetchAllQueryTaskTests.swift new file mode 100644 index 0000000000..10f9feb4cb --- /dev/null +++ b/TableProTests/Views/Main/FetchAllQueryTaskTests.swift @@ -0,0 +1,149 @@ +// +// FetchAllQueryTaskTests.swift +// TableProTests +// +// Fetch All installs a query handle under the tab's id and has to retire it on every exit. The +// cancelled exit did not: it cleared the loading flag and returned, so a finished fetch stayed +// installed and the next execution on that tab read it as a live displaced entry and cancelled it. +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Fetch All query handle", .serialized) +@MainActor +struct FetchAllQueryTaskTests { + /// The driver runs to completion whatever the task does, which is what every driver whose + /// `cancelQuery()` is the PluginKit no-op default does, and is why this exit exists at all. + @Test("A fetch all cancelled while it ran still retires its query handle") + func cancelledFetchAllRetiresItsHandle() async { + let connection = TestFixtures.makeConnection() + let driver = GatedQueryDriver(connection: connection) + var session = ConnectionSession(connection: connection, driver: driver) + session.status = .connected + session.browseDatabase = connection.database + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + let tab = QueryTab(title: "Query", query: "SELECT 1", tabType: .query) + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + + coordinator.paginationCoordinator.performFetchAll( + tabId: tab.id, + baseQuery: "SELECT 1", + scope: DatabaseScope(connectionId: connection.id, database: connection.database, schema: nil) + ) + + await driver.waitUntilRunning() + guard let handle = coordinator.queryTasks.task(for: tab.id) else { + Issue.record("the fetch installed no query handle") + return + } + handle.cancel() + driver.release() + await handle.value + + #expect(coordinator.queryTasks.hasTask(for: tab.id) == false) + #expect(coordinator.tabExecution.isBusy(tab.id) == false) + #expect(tabManager.tabs.first { $0.id == tab.id }?.pagination.isLoadingMore == false) + } +} + +/// Answers `executeUserQuery` only once the test lets it, and never by raising: a cancel has to find +/// the query already on its way back. +private final class GatedQueryDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + private let lock = NSLock() + private var startedQuery = false + private var released = false + + init(connection: DatabaseConnection) { + self.connection = connection + } + + func waitUntilRunning() async { + for _ in 0 ..< 500 { + if lock.withLock({ startedQuery }) { return } + try? await Task.sleep(for: .milliseconds(10)) + } + } + + func release() { + lock.withLock { released = true } + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + lock.withLock { startedQuery = true } + while !lock.withLock({ released }) { + await Task.yield() + } + return Self.emptyResult + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func ping() async throws {} + func cancelQuery() throws {} + func applyQueryTimeout(_ seconds: Int) async throws {} + func execute(query: String) async throws -> QueryResult { Self.emptyResult } + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { Self.emptyResult } + + func fetchTables() async throws -> [TableInfo] { [] } + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { [:] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, + name: database, + tableCount: nil, + sizeBytes: nil, + lastAccessed: nil, + isSystemDatabase: false, + icon: "cylinder" + ) + } + + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: nil, + comment: nil, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } + + func fetchViewDefinition(view: String) async throws -> String { "" } + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + private static var emptyResult: QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } +} diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index 3aef26c4f0..858c55cbb3 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -153,7 +153,7 @@ struct MainContentCoordinatorLazyLoadTests { } /// The task slot stops answering once the load hands off to an execution: `executeQueryInternal` - /// supersedes, and `supersedeExecution` nils the slot held by the task it is running inside. + /// supersedes, and `supersedeExecution` clears the slot held by the task it is running inside. /// Every later trigger for the same navigation then found an empty slot and started a second /// identical query, and the pair collided (#2342). The registry owns the other half. @Test("Returns early when the tab already has an execution in flight") @@ -163,7 +163,7 @@ struct MainContentCoordinatorLazyLoadTests { let claim = coordinator.tabExecution.claim(tabId) let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } defer { inFlight.cancel() } - coordinator.currentQueryTask = inFlight + coordinator.installQueryTask(inFlight, owner: .claim(claim), lease: DriverLeaseOwner()) coordinator.lazyLoadCurrentTabIfNeeded() @@ -178,10 +178,12 @@ struct MainContentCoordinatorLazyLoadTests { func skipsWhenUnclaimedWorkIsRunning() { let (coordinator, tabManager) = makeCoordinator() let tabId = addTableTab(to: tabManager) - _ = coordinator.tabExecution.beginUnclaimedWork(for: tabId) + let token = coordinator.tabExecution.beginUnclaimedWork(for: tabId) let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } defer { inFlight.cancel() } - coordinator.currentQueryTask = inFlight + coordinator.installQueryTask( + inFlight, owner: .unclaimedWork(tabId: tabId, token: token), lease: DriverLeaseOwner() + ) coordinator.lazyLoadCurrentTabIfNeeded() @@ -283,7 +285,7 @@ struct MainContentCoordinatorLazyLoadTests { return } _ = coordinator.tabExecution.claim(tabId) - coordinator.currentQueryTask = nil + #expect(coordinator.queryTasks.hasTask(for: tabId) == false) coordinator.lazyLoadCurrentTabIfNeeded() @@ -331,7 +333,7 @@ struct MainContentCoordinatorLazyLoadTests { let (coordinator, tabManager) = makeCoordinator() let tabId = addTableTab(to: tabManager) let claim = coordinator.tabExecution.claim(tabId) - #expect(coordinator.currentQueryTask == nil) + #expect(coordinator.queryTasks.hasTask(for: tabId) == false) #expect(coordinator.tabExecution.isAnyExecuting) coordinator.lazyLoadCurrentTabIfNeeded() diff --git a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift index 778d212ac5..6ebe8c8dd4 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorRefreshTests.swift @@ -74,18 +74,32 @@ struct MainContentCoordinatorRefreshTests { return tab.id } + @discardableResult private func simulateInFlightQuery( _ coordinator: MainContentCoordinator, _ tabManager: QueryTabManager, - at index: Int + at index: Int, + lease: DriverLeaseOwner = DriverLeaseOwner() ) -> Task { let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } - coordinator.currentQueryTask = inFlight - _ = coordinator.tabExecution.claim(tabManager.tabs[index].id) + let tabId = tabManager.tabs[index].id + let claim = coordinator.tabExecution.claim(tabId) + coordinator.installQueryTask(inFlight, owner: .claim(claim), lease: lease) tabManager.tabs[index].execution.lastExecutedAt = Date() return inFlight } + /// A tab whose lease is registered with the injected driver, which is what makes a driver cancel + /// observable at all: without it `cancelRunningQuery` finds nothing for that owner. + private func seedLease( + _ driver: DatabaseDriver, + lease: DriverLeaseOwner, + for connectionId: UUID + ) { + DatabaseManager.shared.runningDrivers[connectionId, default: [:]][UUID()] = + RunningDriver(driver: driver, policy: .cancellableRead(lease)) + } + @Test("Refresh while a query is in flight cancels it and starts a new execution") func refreshWithInFlightQueryStartsNewExecution() { let (coordinator, tabManager) = makeCoordinator() @@ -98,11 +112,11 @@ struct MainContentCoordinatorRefreshTests { let initialEpoch = coordinator.tabExecution.contentEpoch(for: tabId) coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) - defer { coordinator.currentQueryTask?.cancel() } + defer { coordinator.cancelAllQueryTasks() } #expect(staleTask.isCancelled == true) #expect(coordinator.tabExecution.contentEpoch(for: tabId) != initialEpoch) - #expect(coordinator.currentQueryTask != nil) + #expect(coordinator.queryTasks.hasTask(for: tabId)) #expect(coordinator.tabExecution.isExecuting(tabId) == true) } @@ -118,10 +132,10 @@ struct MainContentCoordinatorRefreshTests { let initialEpoch = coordinator.tabExecution.contentEpoch(for: tabId) coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) - defer { coordinator.currentQueryTask?.cancel() } + defer { coordinator.cancelAllQueryTasks() } #expect(coordinator.tabExecution.contentEpoch(for: tabId) != initialEpoch) - #expect(coordinator.currentQueryTask != nil) + #expect(coordinator.queryTasks.hasTask(for: tabId)) #expect(coordinator.tabExecution.isExecuting(tabId) == true) } @@ -136,7 +150,7 @@ struct MainContentCoordinatorRefreshTests { tabManager.tabs[idx].execution.lastExecutedAt = Date() coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) - defer { coordinator.currentQueryTask?.cancel() } + defer { coordinator.cancelAllQueryTasks() } #expect(tabManager.tabs[idx].content.query != "SELECT outdated FROM users") #expect(tabManager.tabs[idx].content.query.contains("users")) @@ -150,10 +164,8 @@ struct MainContentCoordinatorRefreshTests { Issue.record("expected tab to exist") return } - let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } + let inFlight = simulateInFlightQuery(coordinator, tabManager, at: idx) defer { inFlight.cancel() } - coordinator.currentQueryTask = inFlight - _ = coordinator.tabExecution.claim(tabId) let initialEpoch = coordinator.tabExecution.contentEpoch(for: tabId) coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) @@ -222,25 +234,57 @@ struct MainContentCoordinatorRefreshTests { for _ in 0..<4 { coordinator.setRowCountTask(Task {}, token: UUID(), for: tabId) coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) - coordinator.currentQueryTask?.cancel() - coordinator.currentQueryTask = nil + coordinator.cancelAllQueryTasks() } #expect(driver.cancelQueryCallCount == 0) } } - @Test("cancelCurrentQuery cancels the driver when a query is in flight") + @Test("cancelCurrentQuery cancels the driver when the selected tab has a query in flight") func cancelWithInFlightCancelsDriver() { withInjectedDriver { connection, driver in - let (coordinator, _) = makeCoordinator(connection: connection) - let inFlight = Task { _ = try? await Task.sleep(for: .seconds(60)) } + let (coordinator, tabManager) = makeCoordinator(connection: connection) + let tabId = addTableTab(to: tabManager) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { + Issue.record("expected tab to exist") + return + } + let lease = DriverLeaseOwner() + seedLease(driver, lease: lease, for: connection.id) + let inFlight = simulateInFlightQuery(coordinator, tabManager, at: idx, lease: lease) defer { inFlight.cancel() } - coordinator.currentQueryTask = inFlight coordinator.cancelCurrentQuery() #expect(driver.cancelQueryCallCount == 1) + #expect(inFlight.isCancelled) + } + } + + /// Stop acts on the selected tab, so a background tab's query is not its business. Before the + /// per-tab change this cancelled the driver of whatever the window happened to hold. + @Test("cancelCurrentQuery leaves a background tab's query running") + func cancelLeavesABackgroundTabAlone() { + withInjectedDriver { connection, driver in + let (coordinator, tabManager) = makeCoordinator(connection: connection) + let background = addTableTab(to: tabManager, tableName: "orders") + let selected = addQueryTab(to: tabManager) + guard let idx = tabManager.tabs.firstIndex(where: { $0.id == background }) else { + Issue.record("expected tab to exist") + return + } + let lease = DriverLeaseOwner() + seedLease(driver, lease: lease, for: connection.id) + let inFlight = simulateInFlightQuery(coordinator, tabManager, at: idx, lease: lease) + defer { inFlight.cancel() } + tabManager.selectedTabId = selected + + coordinator.cancelCurrentQuery() + + #expect(driver.cancelQueryCallCount == 0) + #expect(inFlight.isCancelled == false) + #expect(coordinator.tabExecution.isExecuting(background)) } } @@ -256,7 +300,7 @@ struct MainContentCoordinatorRefreshTests { tabManager.tabs[idx].execution.lastExecutedAt = Date() coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) - defer { coordinator.currentQueryTask?.cancel() } + defer { coordinator.cancelAllQueryTasks() } #expect(driver.cancelQueryCallCount == 0) } @@ -282,7 +326,7 @@ struct MainContentCoordinatorRefreshTests { #expect(refreshCalled == true) #expect(coordinator.tabExecution.contentEpoch(for: tabId) == initialEpoch) - #expect(coordinator.currentQueryTask == nil) + #expect(coordinator.queryTasks.hasTask(for: tabId) == false) } @Test("requestRefresh fires immediately and coalesces a rapid second call") @@ -296,7 +340,7 @@ struct MainContentCoordinatorRefreshTests { tabManager.tabs[idx].execution.lastExecutedAt = Date() defer { coordinator.refreshCoalesceTask?.cancel() - coordinator.currentQueryTask?.cancel() + coordinator.cancelAllQueryTasks() } let initialEpoch = coordinator.tabExecution.contentEpoch(for: tabId) @@ -320,7 +364,7 @@ struct MainContentCoordinatorRefreshTests { return } tabManager.tabs[idx].execution.lastExecutedAt = Date() - defer { coordinator.currentQueryTask?.cancel() } + defer { coordinator.cancelAllQueryTasks() } coordinator.requestRefresh(hasPendingTableOps: false, onDiscard: {}) let epochAfterLeading = coordinator.tabExecution.contentEpoch(for: tabId) diff --git a/TableProTests/Views/Main/PaginationCoordinatorTests.swift b/TableProTests/Views/Main/PaginationCoordinatorTests.swift index a97fd685b1..0fb225b3a7 100644 --- a/TableProTests/Views/Main/PaginationCoordinatorTests.swift +++ b/TableProTests/Views/Main/PaginationCoordinatorTests.swift @@ -70,7 +70,7 @@ struct PaginationCoordinatorTests { ) let first = UUID() coordinator.claimExactCount(for: tabId, token: first) - coordinator.releaseAllExactCounts() + coordinator.releaseExactCount(for: tabId) let second = UUID() coordinator.claimExactCount(for: tabId, token: second) diff --git a/TableProTests/Views/Main/QueryFailureReportingTests.swift b/TableProTests/Views/Main/QueryFailureReportingTests.swift index 5dfcf26ab9..4088aa1f7a 100644 --- a/TableProTests/Views/Main/QueryFailureReportingTests.swift +++ b/TableProTests/Views/Main/QueryFailureReportingTests.swift @@ -102,31 +102,27 @@ struct QueryFailureReportingTests { #expect(coordinator.tabExecution.isExecuting(tabId)) } - /// The window's task handle is one per window while claims are one per tab, so owning your own - /// tab is not owning the query the window is running. Retiring the handle says nothing about - /// whether the window is still busy: the executions do. + /// A superseded execution still reaches its own completion path, so retiring has to check the + /// owner: taking the handle a successor installed leaves a live query with nothing to stop it. @Test("Retiring the task handle only works for the execution that installed it") func onlyTheInstallerRetiresTheTaskHandle() { let (coordinator, tabManager) = Self.makeCoordinator() let tabId = Self.addQueryTab(to: tabManager) - let otherTabId = Self.addQueryTab(to: tabManager, title: "Query 2") - let running = coordinator.tabExecution.claim(otherTabId) let stranger = coordinator.tabExecution.claim(tabId) + let running = coordinator.tabExecution.claim(tabId) let task = Task {} - coordinator.installQueryTask(task, for: running) + coordinator.installQueryTask(task, owner: .claim(running), lease: DriverLeaseOwner()) - coordinator.retireQueryTask(for: stranger) - #expect(coordinator.currentQueryTask != nil) + coordinator.retireQueryTask(.claim(stranger)) + #expect(coordinator.queryTasks.hasTask(for: tabId)) - coordinator.retireQueryTask(for: running) - #expect(coordinator.currentQueryTask == nil) + coordinator.retireQueryTask(.claim(running)) + #expect(coordinator.queryTasks.hasTask(for: tabId) == false) #expect(coordinator.tabExecution.isAnyExecuting) let runningSettled = coordinator.tabExecution.settle(running) - let strangerSettled = coordinator.tabExecution.settle(stranger) #expect(runningSettled) - #expect(strangerSettled) #expect(coordinator.tabExecution.isAnyExecuting == false) task.cancel() } @@ -143,11 +139,11 @@ struct QueryFailureReportingTests { let successor = coordinator.tabExecution.claim(otherTabId) let task = Task {} - coordinator.installQueryTask(task, for: successor) + coordinator.installQueryTask(task, owner: .claim(successor), lease: DriverLeaseOwner()) coordinator.resetExecutionState(claim: cancelled, executionTime: 0.5) - #expect(coordinator.currentQueryTask != nil) + #expect(coordinator.queryTasks.hasTask(for: otherTabId)) #expect(coordinator.tabExecution.isAnyExecuting) #expect(coordinator.tabExecution.isExecuting(tabId) == false) #expect(coordinator.tabExecution.isCurrent(successor)) @@ -186,7 +182,7 @@ struct QueryFailureReportingTests { coordinator.supersedeExecution(for: tabId) #expect(coordinator.tabExecution.isAnyExecuting == false) - #expect(coordinator.currentQueryTask == nil) + #expect(coordinator.queryTasks.hasTask(for: tabId) == false) } /// The change manager is one per window and holds whichever tab is selected. Clearing it from a diff --git a/TableProTests/Views/Main/RowCountTaskLifecycleTests.swift b/TableProTests/Views/Main/RowCountTaskLifecycleTests.swift index 3eb8e08218..595542083d 100644 --- a/TableProTests/Views/Main/RowCountTaskLifecycleTests.swift +++ b/TableProTests/Views/Main/RowCountTaskLifecycleTests.swift @@ -88,21 +88,27 @@ struct RowCountTaskLifecycleTests { #expect(coordinator.rowCountTasks.isEmpty) } - @Test("Stop cancels every tab's row count") - func stopCancelsEveryRowCount() { + /// A deliberate spec change: Stop is per tab now, so it ends the selected tab's count and leaves + /// every other tab's running. A window-wide Stop is what let one tab's Stop roll back the batch + /// another tab had running, and the row count follows the same rule. + @Test("Stop cancels the selected tab's row count only") + func stopCancelsTheSelectedTabsRowCount() { let (coordinator, tabManager) = Self.makeCoordinator() let tabA = Self.addTableTab(to: tabManager, tableName: "orders") let tabB = Self.addTableTab(to: tabManager, tableName: "customers") let countA = Self.neverEndingTask() let countB = Self.neverEndingTask() + defer { countA.cancel() } coordinator.setRowCountTask(countA, token: UUID(), for: tabA) coordinator.setRowCountTask(countB, token: UUID(), for: tabB) + tabManager.selectedTabId = tabB coordinator.cancelCurrentQuery() - #expect(countA.isCancelled) + #expect(countA.isCancelled == false) #expect(countB.isCancelled) - #expect(coordinator.rowCountTasks.isEmpty) + #expect(coordinator.rowCountTasks[tabA] != nil) + #expect(coordinator.rowCountTasks[tabB] == nil) } /// A task that finished on its own drops its handle without cancelling a successor that may diff --git a/TableProTests/Views/Main/TabQueryIsolationTests.swift b/TableProTests/Views/Main/TabQueryIsolationTests.swift new file mode 100644 index 0000000000..ed12f8ecfd --- /dev/null +++ b/TableProTests/Views/Main/TabQueryIsolationTests.swift @@ -0,0 +1,372 @@ +// +// TabQueryIsolationTests.swift +// TableProTests +// +// Starting work in one tab must not end work in another. Cancellation used to be keyed by window +// and connection and never by the tab that owned the work, so a Run, a table opened from the +// sidebar, a Refresh, an Explain or a retarget each cancelled whatever the window held: a batch +// running in another tab stopped at its next statement, rolled back, and reported "cancelled by +// user" over a Stop nobody pressed. +// +// Every case drives a real entry point rather than the ownership API underneath it, because the +// defect was that five separate start paths each cancelled on their own terms. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Tab query isolation", .serialized) +@MainActor +struct TabQueryIsolationTests { + /// Tab A holding a live execution: a claim, a never-ending task, and a driver registered under + /// that execution's lease, which is the only thing a cancel can reach. + private struct RunningTab { + let tabId: UUID + let claim: TabExecutionClaim + let task: Task + let driver: CancelCountingDriver + } + + // MARK: - Every start path in tab B + + @Test("Running a query in another tab leaves the first tab's batch alone") + func runQueryLeavesTheOtherTabAlone() async { + await withTwoTabs { coordinator, _, _ in + coordinator.executeQueryInternal("SELECT 1") + } + } + + @Test("Running a parameterized query in another tab leaves the first tab's batch alone") + func parameterizedQueryLeavesTheOtherTabAlone() async { + await withTwoTabs { coordinator, _, _ in + coordinator.queryExecutionCoordinator.executeQueryInternalParameterized( + "SELECT ?", + parameters: ["1"], + originalParameters: [] + ) + } + } + + @Test("Running several statements in another tab leaves the first tab's batch alone") + func multiStatementRunLeavesTheOtherTabAlone() async { + await withTwoTabs { coordinator, _, _ in + coordinator.queryExecutionCoordinator.executeMultipleStatementsWithParameters( + SQLStatementScanner.executableStatements(in: "SELECT 1; SELECT 2"), + parameters: [] + ) + } + } + + /// The variant is passed explicitly so the case cannot go quiet: without one, `runExplain` + /// returns before it starts anything on a build where no driver plugin declared a variant. + @Test("Explaining in another tab leaves the first tab's batch alone") + func explainLeavesTheOtherTabAlone() async { + await withTwoTabs { coordinator, _, _ in + coordinator.runExplain( + variant: ExplainVariant(id: "plain", label: "Explain", sqlPrefix: "EXPLAIN") + ) + } + } + + /// Refresh used to call the window-wide `cancelCurrentQuery`, which is how a table tab's Refresh + /// rolled back an editor tab's batch. + @Test("Refreshing a table tab leaves another tab's batch alone") + func refreshLeavesTheOtherTabAlone() async { + await withTwoTabs(selectedIsTable: true) { coordinator, _, _ in + coordinator.handleRefresh(hasPendingTableOps: false, onDiscard: {}) + } + } + + /// Opening a table into the selected tab from the sidebar retargets it, and the retarget hook + /// supersedes. That supersede reached the window's one handle, not the tab's. + @Test("Retargeting a tab leaves another tab's batch alone") + func retargetLeavesTheOtherTabAlone() async { + await withTwoTabs(selectedIsTable: true, startsExecution: false) { coordinator, _, _ in + _ = try? coordinator.tabManager.replaceTabContent(tableName: "orders", databaseName: "app") + } + } + + /// Closing tab B takes B's own handle down and nothing else. Before the change the close path + /// had to check whether the window's handle happened to belong to the closing tab. + @Test("Closing a tab leaves another tab's batch alone") + func closingATabLeavesTheOtherTabAlone() async { + await withTwoTabs(startsExecution: false) { coordinator, _, selected in + guard let tab = coordinator.tabManager.tabs.first(where: { $0.id == selected }) else { return } + coordinator.releaseExecution(of: tab) + } + } + + // MARK: - Stop + + @Test("Stop with another tab selected cancels that tab only") + func stopActsOnTheSelectedTabOnly() { + let harness = makeHarness() + defer { harness.tearDown() } + let running = harness.running + let selectedLease = DriverLeaseOwner() + let selectedDriver = CancelCountingDriver(connection: harness.connection) + let selectedClaim = harness.coordinator.tabExecution.claim(harness.selectedTabId) + let selectedTask = Self.neverEndingTask() + harness.coordinator.installQueryTask( + selectedTask, owner: .claim(selectedClaim), lease: selectedLease + ) + harness.seed(selectedDriver, lease: selectedLease) + + harness.coordinator.paginationCoordinator.cancelCurrentQuery() + + #expect(selectedTask.isCancelled) + #expect(selectedDriver.cancelCount == 1) + #expect(harness.coordinator.tabExecution.isExecuting(harness.selectedTabId) == false) + + #expect(running.task.isCancelled == false) + #expect(running.driver.cancelCount == 0) + #expect(harness.coordinator.tabExecution.isCurrent(running.claim)) + running.task.cancel() + selectedTask.cancel() + } + + /// Stop spares a claim whose commit is already on the wire, exactly as the window-wide stop did. + @Test("Stop on a tab whose commit is in flight keeps its claim") + func stopSparesACommittingClaim() { + let harness = makeHarness() + defer { harness.tearDown() } + let claim = harness.coordinator.tabExecution.claim(harness.selectedTabId) + let entered = harness.coordinator.tabExecution.enterUninterruptiblePhase(claim) + #expect(entered) + + harness.coordinator.paginationCoordinator.cancelCurrentQuery() + + #expect(harness.coordinator.tabExecution.isCurrent(claim)) + let settled = harness.coordinator.tabExecution.settle(claim) + #expect(settled) + harness.running.task.cancel() + } + + // MARK: - Window chrome follows the selected tab + + @Test("A background tab's work does not make the selected tab look busy") + func busyStateFollowsTheSelectedTab() { + let harness = makeHarness() + defer { harness.tearDown() } + + #expect(harness.coordinator.tabExecution.isAnyExecuting) + #expect(harness.coordinator.isSelectedTabBusy == false) + #expect(harness.coordinator.isSelectedTabStoppable == false) + + harness.coordinator.tabManager.selectedTabId = harness.running.tabId + #expect(harness.coordinator.isSelectedTabBusy) + #expect(harness.coordinator.isSelectedTabStoppable) + harness.running.task.cancel() + } + + /// A completion retires its own tab's handle. The window-wide handle made this a live question: + /// tab A's completion nilled whatever tab B had installed. + @Test("A completion on one tab leaves another tab's handle installed") + func completionRetiresOnlyItsOwnTab() { + let harness = makeHarness() + defer { harness.tearDown() } + let selectedClaim = harness.coordinator.tabExecution.claim(harness.selectedTabId) + let selectedTask = Self.neverEndingTask() + harness.coordinator.installQueryTask( + selectedTask, owner: .claim(selectedClaim), lease: DriverLeaseOwner() + ) + + let settled = harness.coordinator.tabExecution.settle(harness.running.claim) + #expect(settled) + harness.coordinator.retireQueryTask(.claim(harness.running.claim)) + + #expect(harness.coordinator.queryTasks.hasTask(for: harness.running.tabId) == false) + #expect(harness.coordinator.queryTasks.hasTask(for: harness.selectedTabId)) + harness.running.task.cancel() + selectedTask.cancel() + } + + // MARK: - Harness + + @MainActor + private struct Harness { + let coordinator: MainContentCoordinator + let connection: DatabaseConnection + let running: RunningTab + let selectedTabId: UUID + + func seed(_ driver: CancelCountingDriver, lease: DriverLeaseOwner) { + DatabaseManager.shared.runningDrivers[connection.id, default: [:]][UUID()] = + RunningDriver(driver: driver, policy: .cancellableRead(lease)) + } + + func tearDown() { + running.task.cancel() + DatabaseManager.shared.runningDrivers.removeValue(forKey: connection.id) + DatabaseManager.shared.removeSession(for: connection.id) + } + } + + /// Drives one start path in the selected tab and checks that the other tab's execution, task and + /// driver are all untouched by it. + private func withTwoTabs( + selectedIsTable: Bool = false, + startsExecution: Bool = true, + _ start: (MainContentCoordinator, RunningTab, UUID) -> Void + ) async { + let harness = makeHarness(selectedIsTable: selectedIsTable) + defer { harness.tearDown() } + let running = harness.running + + start(harness.coordinator, running, harness.selectedTabId) + + /// Without this the case could pass by doing nothing at all, which is what a start path that + /// silently returns early looks like from the other tab. + #expect(harness.coordinator.tabExecution.isBusy(harness.selectedTabId) == startsExecution) + + #expect(running.task.isCancelled == false) + #expect(harness.coordinator.tabExecution.isCurrent(running.claim)) + #expect(harness.coordinator.queryTasks.hasTask(for: running.tabId)) + /// A background cancel lands on a global queue, so the count is only meaningful once one + /// could have arrived. Without the wait this arm would pass on a delivery that was merely + /// slow rather than absent. + try? await Task.sleep(for: .milliseconds(50)) + #expect(running.driver.cancelCount == 0) + harness.coordinator.cancelAllQueryTasks() + } + + private func makeHarness(selectedIsTable: Bool = false) -> Harness { + let connection = TestFixtures.makeConnection() + var session = ConnectionSession( + connection: connection, + driver: MockDatabaseDriver(connection: connection) + ) + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + + let runningTabId = Self.addTab(to: tabManager, title: "Batch", isTable: false) + let selectedTabId = Self.addTab(to: tabManager, title: "Other", isTable: selectedIsTable) + tabManager.selectedTabId = selectedTabId + + let claim = coordinator.tabExecution.claim(runningTabId) + let lease = DriverLeaseOwner() + let driver = CancelCountingDriver(connection: connection) + let task = Self.neverEndingTask() + coordinator.installQueryTask(task, owner: .claim(claim), lease: lease) + DatabaseManager.shared.runningDrivers[connection.id, default: [:]][UUID()] = + RunningDriver(driver: driver, policy: .cancellableRead(lease)) + + return Harness( + coordinator: coordinator, + connection: connection, + running: RunningTab(tabId: runningTabId, claim: claim, task: task, driver: driver), + selectedTabId: selectedTabId + ) + } + + private static func addTab(to tabManager: QueryTabManager, title: String, isTable: Bool) -> UUID { + var tab = QueryTab( + title: title, + query: isTable ? "SELECT * FROM users" : "SELECT 1", + tabType: isTable ? .table : .query, + tableName: isTable ? "users" : nil + ) + tab.tableContext.isEditable = isTable + tab.execution.lastExecutedAt = isTable ? Date() : nil + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return tab.id + } + + private static func neverEndingTask() -> Task { + Task { _ = try? await Task.sleep(for: .seconds(60)) } + } +} + +/// Counts `cancelQuery()` under a lock, because a background delivery runs off the main actor. +private final class CancelCountingDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + + private let lock = NSLock() + private var calls = 0 + + init(connection: DatabaseConnection) { + self.connection = connection + } + + var cancelCount: Int { + lock.lock() + defer { lock.unlock() } + return calls + } + + func cancelQuery() throws { + lock.lock() + calls += 1 + lock.unlock() + } + + var serverVersion: String? { nil } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + func execute(query: String) async throws -> QueryResult { Self.emptyResult } + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { Self.emptyResult } + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + Self.emptyResult + } + + func fetchTables() async throws -> [TableInfo] { [] } + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchAllColumns() async throws -> [String: [ColumnInfo]] { [:] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, + name: database, + tableCount: nil, + sizeBytes: nil, + lastAccessed: nil, + isSystemDatabase: false, + icon: "cylinder" + ) + } + + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, + dataSize: nil, + indexSize: nil, + totalSize: nil, + avgRowLength: nil, + rowCount: nil, + comment: nil, + engine: nil, + collation: nil, + createTime: nil, + updateTime: nil + ) + } + + func fetchViewDefinition(view: String) async throws -> String { "" } + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + private static var emptyResult: QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } +} diff --git a/TableProTests/Views/Structure/StructureEditingSessionTests.swift b/TableProTests/Views/Structure/StructureEditingSessionTests.swift index 8ab87838c3..db992ea301 100644 --- a/TableProTests/Views/Structure/StructureEditingSessionTests.swift +++ b/TableProTests/Views/Structure/StructureEditingSessionTests.swift @@ -204,4 +204,57 @@ struct StructureEditingSessionTests { MetadataConnectionPool.shared.injectEntry(adapter, scope: scope) return driver } + + /// A tab the server withdraws cannot stay selected, or the editor shows a grid for something + /// the server has none of and no segment matches the selection. + @Test("A session on Constraints moves to Columns when the server withdraws the tab") + func withdrawnTabMovesSelection() { + let connection = DatabaseConnection(name: "MySQL", type: .mysql) + let session = Self.makeSession(connection: connection) + session.selectedTab = .checkConstraints + #expect(session.availableTabs.contains(.checkConstraints)) + + session.serverSupport = StructureServerSupport( + unsupportedColumnFields: [], + unsupportedIndexTypes: [], + checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." + ) + + #expect(!session.availableTabs.contains(.checkConstraints)) + #expect(session.selectedTab == .columns) + } + + @Test("A server change that withdraws nothing leaves the selection alone") + func unchangedSupportKeepsSelection() { + let connection = DatabaseConnection(name: "MySQL", type: .mysql) + let session = Self.makeSession(connection: connection) + session.selectedTab = .indexes + + session.serverSupport = StructureServerSupport( + unsupportedColumnFields: [.generated], + unsupportedIndexTypes: ["BRIN"] + ) + + #expect(session.selectedTab == .indexes) + #expect(session.availableTabs.contains(.checkConstraints)) + } + + @Test("A session built for a server with no check constraints never offers the tab") + func sessionSeededWithServerSupport() { + let connection = DatabaseConnection(name: "MySQL", type: .mysql) + let session = StructureEditingSession( + identity: "testdb.users", + connection: connection, + databaseName: "testdb", + schemaName: nil, + tableName: "users", + serverSupport: StructureServerSupport( + unsupportedColumnFields: [], + unsupportedIndexTypes: [], + checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." + ) + ) + #expect(!session.availableTabs.contains(.checkConstraints)) + #expect(session.selectedTab == .columns) + } } diff --git a/TableProTests/Views/Structure/StructureServerSupportTests.swift b/TableProTests/Views/Structure/StructureServerSupportTests.swift index cb4b2950f9..74a1a0ed24 100644 --- a/TableProTests/Views/Structure/StructureServerSupportTests.swift +++ b/TableProTests/Views/Structure/StructureServerSupportTests.swift @@ -16,9 +16,11 @@ private final class StructureSupportStubDriver: PluginDatabaseDriver, @unchecked var hiddenFields: Set = [] var hiddenIndexTypes: Set = [] + var checkRefusal: String? var unsupportedStructureColumnFields: Set { hiddenFields } var unsupportedIndexTypes: Set { hiddenIndexTypes } + var checkConstraintRefusal: String? { checkRefusal } func connect() async throws {} func disconnect() {} @@ -175,6 +177,23 @@ struct StructureServerSupportTests { #expect(support.unsupportedIndexTypes == ["BRIN"]) } + @Test("A server with no check constraints withdraws the Constraints tab and nothing else") + func checkConstraintRefusalHidesOneTab() { + let driver = StructureSupportStubDriver() + driver.checkRefusal = "Check constraints need MySQL 8.0.16 or later." + let adapter = PluginDriverAdapter( + connection: DatabaseConnection(name: "Test", type: .mysql), + pluginDriver: driver + ) + let support = StructureServerSupport(driver: adapter) + #expect(support.checkConstraintRefusal == "Check constraints need MySQL 8.0.16 or later.") + #expect(!support.offers(.checkConstraints)) + for tab in StructureTab.allCases where tab != .checkConstraints { + #expect(support.offers(tab), "\(tab)") + } + #expect(StructureServerSupport.unrestricted.offers(.checkConstraints)) + } + @Test("A driver built before the hook existed restricts nothing") func defaultDriverRestrictsNothing() { let adapter = PluginDriverAdapter( diff --git a/TableProTests/Views/Structure/StructureTabAvailabilityTests.swift b/TableProTests/Views/Structure/StructureTabAvailabilityTests.swift new file mode 100644 index 0000000000..36abc1bd38 --- /dev/null +++ b/TableProTests/Views/Structure/StructureTabAvailabilityTests.swift @@ -0,0 +1,50 @@ +// +// StructureTabAvailabilityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Structure tab availability") +struct StructureTabAvailabilityTests { + private static let legacyMySQL = StructureServerSupport( + unsupportedColumnFields: [], + unsupportedIndexTypes: [], + checkConstraintRefusal: "Check constraints need MySQL 8.0.16 or later." + ) + + @Test("A MySQL server that discards a CHECK clause is not offered the tab") + func legacyServerHidesConstraints() { + let tabs = StructureTabAvailability.tabs(for: .mysql, serverSupport: Self.legacyMySQL) + #expect(!tabs.contains(.checkConstraints)) + #expect(tabs.contains(.columns)) + #expect(tabs.contains(.indexes)) + #expect(tabs.contains(.foreignKeys)) + #expect(tabs.contains(.ddl)) + } + + @Test("A server that keeps them is offered the tab, on every engine that has them") + func modernServerShowsConstraints() { + #expect(StructureTabAvailability.tabs(for: .mysql, serverSupport: .unrestricted) + .contains(.checkConstraints)) + #expect(StructureTabAvailability.tabs(for: .postgresql, serverSupport: .unrestricted) + .contains(.checkConstraints)) + } + + @Test("An engine with no check constraints at all stays hidden either way") + func engineWithoutChecksStaysHidden() { + for support in [StructureServerSupport.unrestricted, Self.legacyMySQL] { + let tabs = StructureTabAvailability.tabs(for: .redis, serverSupport: support) + #expect(!tabs.contains(.checkConstraints)) + } + } + + @Test("Parts is ClickHouse only, and the server refusal does not reach it") + func partsIsClickHouseOnly() { + #expect(StructureTabAvailability.tabs(for: .clickhouse, serverSupport: .unrestricted).contains(.parts)) + #expect(!StructureTabAvailability.tabs(for: .mysql, serverSupport: .unrestricted).contains(.parts)) + #expect(StructureTabAvailability.tabs(for: .clickhouse, serverSupport: Self.legacyMySQL).contains(.parts)) + } +} diff --git a/TableProUITests/QueryRunUITests.swift b/TableProUITests/QueryRunUITests.swift index 1e1923e895..ac811d3a8d 100644 --- a/TableProUITests/QueryRunUITests.swift +++ b/TableProUITests/QueryRunUITests.swift @@ -118,6 +118,158 @@ final class QueryRunUITests: UITestCase { ) } + /// SQLite applies `PRAGMA foreign_keys` only outside a transaction, and applies it silently: + /// inside one it changes nothing, raises nothing, and reads back `0` after the commit. So the + /// proof that the script ran without a wrap is the constraint the fourth statement then breaks. + func testRunAllAppliesAForeignKeyPragmaInTheSameScript() throws { + let app = try launchWithSampleDatabase() + + app.typeKey("t", modifierFlags: .command) + typeQuery( + "PRAGMA foreign_keys = ON; " + + "CREATE TABLE run_all_fk_parent (id INTEGER PRIMARY KEY); " + + "CREATE TABLE run_all_fk_child (parent INTEGER REFERENCES run_all_fk_parent(id)); " + + "INSERT INTO run_all_fk_child VALUES (42);", + in: app + ) + openRunMenu(in: app).menuItems["Run All Statements"].click() + + let banner = app.windows.firstMatch.staticTexts["query-error-message"].firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 30) { bannerText(banner).contains("Statement 4/4 failed") }, + "The pragma must reach the session, so the insert breaks the constraint: got \(bannerText(banner))" + ) + XCTAssertTrue( + bannerText(banner).contains("FOREIGN KEY constraint failed"), + "The server's own reason must survive into the banner: got \(bannerText(banner))" + ) + } + + /// `VACUUM` inside a transaction answers "cannot VACUUM from within a transaction", so a script + /// holding one must run without the app's wrap and report all three results. + func testRunAllRunsVacuumOutsideATransaction() throws { + let app = try launchWithSampleDatabase() + + app.typeKey("t", modifierFlags: .command) + typeQuery("CREATE TABLE run_all_vacuum_probe (id INTEGER); VACUUM; SELECT 1 AS answer;", in: app) + openRunMenu(in: app).menuItems["Run All Statements"].click() + + let window = app.windows.firstMatch + let chooser = window.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch + let banner = window.staticTexts["query-error-message"].firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 30) { chooser.exists || banner.exists }, + "Running the script must end in results or an error" + ) + XCTAssertFalse( + banner.exists, + "VACUUM must not be wrapped in a transaction: got \(bannerText(banner))" + ) + XCTAssertTrue( + waitForPredicate(timeout: 10) { chooser.title.contains("3") }, + "All three statements must run and report a result: got \(chooser.title)" + ) + } + + /// A batch runs on the connection's shared session, so a `BEGIN` the user ran a moment earlier + /// is still in force. The app must send no transaction of its own over it: SQLite refuses the + /// nested `BEGIN` outright, and the engines that accept one commit or discard the user's work. + func testRunAllJoinsTheTransactionTheUserOpened() throws { + let app = try launchWithSampleDatabase() + let window = app.windows.firstMatch + + app.typeKey("t", modifierFlags: .command) + typeQuery("BEGIN;", in: app) + app.typeKey(.return, modifierFlags: .command) + XCTAssertTrue( + window.staticTexts["Query executed successfully"].waitToExist(timeout: 30), + "The user's own BEGIN must run before the batch does" + ) + + typeQuery( + "CREATE TABLE run_all_session_probe (id INTEGER); INSERT INTO run_all_session_probe VALUES (1);", + in: app + ) + openRunMenu(in: app).menuItems["Run All Statements"].click() + + let chooser = window.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch + let banner = window.staticTexts["query-error-message"].firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 30) { chooser.exists || banner.exists }, + "Running the batch must end in results or an error" + ) + XCTAssertFalse( + banner.exists, + "The batch must join the open transaction rather than open one: got \(bannerText(banner))" + ) + XCTAssertTrue( + waitForPredicate(timeout: 10) { chooser.title.contains("2") }, + "Both statements must run inside the user's transaction: got \(chooser.title)" + ) + + typeQuery("ROLLBACK; SELECT * FROM run_all_session_probe;", in: app) + openRunMenu(in: app).menuItems["Run All Statements"].click() + XCTAssertTrue( + waitForPredicate(timeout: 30) { + bannerText(banner).contains("no such table: run_all_session_probe") + }, + "Nothing the batch ran may be committed: the user's rollback must take it: got \(bannerText(banner))" + ) + } + + /// A failure inside the user's transaction leaves it open, and says so. Rolling it back here + /// would discard whatever they had already done inside it, which is not the batch's to take. + func testRunAllLeavesTheUserTransactionOpenAfterAFailure() throws { + let app = try launchWithSampleDatabase() + let window = app.windows.firstMatch + + app.typeKey("t", modifierFlags: .command) + typeQuery("BEGIN;", in: app) + app.typeKey(.return, modifierFlags: .command) + XCTAssertTrue( + window.staticTexts["Query executed successfully"].waitToExist(timeout: 30), + "The user's own BEGIN must run before the batch does" + ) + + typeQuery( + "CREATE TABLE run_all_kept_probe (id INTEGER); " + + "INSERT INTO run_all_kept_probe VALUES (1); " + + "SELECT * FROM run_all_missing;", + in: app + ) + openRunMenu(in: app).menuItems["Run All Statements"].click() + + let banner = window.staticTexts["query-error-message"].firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 30) { bannerText(banner).contains("Statement 3/3 failed") }, + "The batch must stop at the statement that failed: got \(bannerText(banner))" + ) + XCTAssertTrue( + bannerText(banner).contains("still open"), + "The banner must say the user's transaction is still theirs to end: got \(bannerText(banner))" + ) + + typeQuery("COMMIT; SELECT * FROM run_all_kept_probe;", in: app) + openRunMenu(in: app).menuItems["Run All Statements"].click() + + let chooser = window.descendants(matching: .any) + .matching(identifier: "result-set-menu") + .firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 30) { chooser.title.contains("2") || banner.exists }, + "The commit and the read must both run: got \(chooser.title) \(bannerText(banner))" + ) + XCTAssertFalse( + banner.exists, + "The transaction must still be open to commit, and its table must have survived the failure: " + + "got \(bannerText(banner))" + ) + } + private func bannerText(_ banner: XCUIElement) -> String { guard banner.exists else { return "" } return (banner.value as? String) ?? banner.label diff --git a/docs/customization/general-settings.mdx b/docs/customization/general-settings.mdx index 844bbd9d0c..223b2e09ce 100644 --- a/docs/customization/general-settings.mdx +++ b/docs/customization/general-settings.mdx @@ -40,6 +40,8 @@ How many seconds a query runs before it is cancelled. The default is 60; the pic Where the engine can enforce it, the timeout becomes a server-side setting: `statement_timeout` on PostgreSQL, `max_execution_time` on MySQL and ClickHouse, `max_statement_time` on MariaDB, `ob_query_timeout` on OceanBase. On SQLite it bounds how long a statement waits for a locked database instead. Drivers that talk HTTP (BigQuery, Spanner, Cloudflare D1, LibSQL, Etcd, DynamoDB, Elasticsearch, Typesense, and ClickHouse) bound the request at the timeout plus 30 seconds, and Oracle enforces it in the client: the connection is closed to unblock the call, and the next query reconnects and restores your current schema. +Older servers have neither: MySQL before 5.7.8, MariaDB before 10.1.1. A statement that passes the timeout there is stopped with `KILL QUERY` from a second connection, which reaches a `SELECT` on MySQL and any statement at all on MariaDB. A server behind a proxy that reports a version it does not have still gets the right one of the two. + **No limit** still caps an HTTP request at one hour, because the transport needs a ceiling. ## Command line tool diff --git a/docs/databases/mariadb.mdx b/docs/databases/mariadb.mdx index 1235c372d2..46b337ea67 100644 --- a/docs/databases/mariadb.mdx +++ b/docs/databases/mariadb.mdx @@ -21,6 +21,7 @@ See [Connection URL Reference](/connections/urls). - MariaDB stores JSON in a LONGTEXT column. The driver reads MariaDB's extended field attributes and types those columns as JSON, so their values open in the JSON editor instead of as plain text. - Virtual and persistent columns are detected on every version, including the bare `VIRTUAL` and `PERSISTENT` spelling that 10.1 and older report, and are left out of generated INSERT and UPDATE statements. - A query timeout goes in as `SET SESSION max_statement_time`, counted in seconds. MySQL takes `max_execution_time` in milliseconds. +- 10.2.1 to 10.2.21 and 10.3.0 to 10.3.9 enforce check constraints without publishing an `information_schema.CHECK_CONSTRAINTS` to read them from. The [Constraints tab](/features/table-structure#constraints-tab) takes them out of `SHOW CREATE TABLE` on those releases and out of the catalog everywhere else. - Sequences appear in the sidebar's Tables section as their own kind, read-only, with **Drop Sequence** on the menu. See [Sequences and system-versioned tables](/features/table-operations#sequences-and-system-versioned-tables). - A table declared `WITH SYSTEM VERSIONING` is listed as an ordinary table and loses **Truncate** alone, which the server refuses with error 4137. - A temporary table shadowing a base table of the same name is left out of the listing, so the name appears once. diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 8f845ee9a9..52aaf37b50 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -86,6 +86,8 @@ A read that fails because the server closed the connection is run once more on a A connection with startup commands that run `SET` is holding a changed session setting from the moment it opens, so it reports the error rather than retrying. **Database > Reconnect** takes a new connection and puts back the database and the startup commands. +Waiting has its own limit, and reaching it is a different case. The socket gives the server the [query timeout](/customization/general-settings#query-timeout) plus 30 seconds, and a statement that outlasts that is never sent again: the first copy is almost certainly still running there. It is stopped with `KILL QUERY` from a second connection, and the error comes back after that. + ## Garbled non-Latin text A comment or value that reads `メール` where `メール` belongs was written by a client that sent UTF-8 while telling the server it was sending Latin 1. A `mysql` command-line client without a UTF-8 locale does that, and so does a MySQL 5.7 container loading its `docker-entrypoint-initdb.d` scripts, and so does any client on a server whose `init_connect` runs `SET NAMES latin1`. The server stored the garbled form, so every UTF-8 client shows the same thing. diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index 54b7c1fe50..9bb17e5f81 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -126,6 +126,12 @@ HGETALL myhash; LRANGE mylist 0 -1 SCAN 0 MATCH user:* COUNT 100 ``` +### MULTI blocks + +A command sent after `MULTI` answers `QUEUED` instead of its own reply, and nothing runs until `EXEC`. `EXEC` then returns every reply in order, errors included; an error element reads `(error) WRONGTYPE …`, the way `redis-cli` prints one. + +The rest of the app reads the same session, so while the block is open the sidebar, the key browser and the structure reads report that their command was queued rather than an empty keyspace. Run `EXEC` or `DISCARD` to get them back. + ## SSL/TLS Set this in the **SSL/TLS** pane. Upstash, Redis Cloud and the like require TLS; `rediss://` turns it on when importing a URL. @@ -142,7 +148,9 @@ New connections default to **Disabled**. SNI is sent in every TLS mode. ## Limitations -- No transactions in Cluster mode. Grid saves run their statements one at a time. Group keys under one hash tag if they must move together. +- A command that fails while `EXEC` is running cannot be taken back. Standalone and Sentinel wrap a grid save in `MULTI`/`EXEC`, so the rest of the block stays applied and the error names the one that failed; check the keys it touched. A command refused before it runs, for a missing ACL permission, a full `maxmemory` or wrong arity, aborts the block and writes nothing. +- No transactions in Cluster mode. Grid saves run their commands one at a time, so a failure leaves the earlier ones applied. Group keys under one hash tag if they must move together. +- A database switch made inside a `MULTI` block waits for `EXEC`, and never happens at all after a `DISCARD`. Close the block before browsing the database you picked. - Cluster mode serves database 0 only. The Database Index field is hidden and the sidebar shows a single `db0`. - A command whose keys span hash slots, such as `RENAME`, `SMOVE`, or the `*STORE` commands, is refused in Cluster mode before it is sent. Give the keys a shared hash tag, like `{user}:1` and `{user}:2`. - A key that is not valid UTF-8 never appears in the grid or the tree. Reach it from the CLI; values have no such limit. diff --git a/docs/development/plugin-development.mdx b/docs/development/plugin-development.mdx index 18160d1b0e..4e0ce10f84 100644 --- a/docs/development/plugin-development.mdx +++ b/docs/development/plugin-development.mdx @@ -57,9 +57,10 @@ Twelve requirements have no default. Everything else on the protocol does. | Schema | `fetchTables(schema:)`, `fetchColumns(table:schema:)`, `fetchIndexes(table:schema:)`, `fetchForeignKeys(table:schema:)`, `fetchTableDDL(table:schema:)`, `fetchViewDefinition(view:schema:)`, `fetchTableMetadata(table:schema:)` | | Databases | `fetchDatabases()`, `fetchDatabaseMetadata(_:)` | -Four defaults are worth a second look before you accept them: +Five defaults are worth a second look before you accept them: - `ping()` runs `SELECT 1`, and the transaction methods run `BEGIN` / `COMMIT` / `ROLLBACK` through `execute(query:)`. An engine without those keywords overrides all four. +- `sessionTransactionState()` answers `.unknown`, and a driver that cannot read its session leaves it there. The app treats `.idle` as permission to open a transaction of its own, so a guess hands a multi-statement batch or a grid save the power to commit work the user has not finished. Answer `.inTransaction`, `.abortedTransaction` or `.holdsSessionLocks` only from something the server said. - `fetchAllColumns(schema:)` and `fetchAllForeignKeys(schema:)` loop one round-trip per table. Any SQL driver should replace them with a single catalog query. - `quoteIdentifier`, `escapeStringLiteral`, `executeParameterized`, and `streamRows` assume generic SQL. - A non-SQL database implements `buildBrowseQuery`, `buildFilteredQuery`, and `generateStatements` instead, which is what makes browsing and editing work without SQL. Implement the `schema:`-aware overloads if your database has schemas; the schema-less defaults throw the schema away. diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 003eaff7e2..8aec606bd4 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -133,7 +133,9 @@ Formats are `csv`, `json` and `sql`; pass exactly one of `query` or `tables[]`. `confirm_destructive_operation` runs one `DROP`, `TRUNCATE` or `ALTER … DROP`. Anything that is not destructive is rejected; use `execute_query` for those. The user approves it first, through your elicitation prompt if your client supports elicitation, otherwise through TablePro's own dialog on their Mac. -`transaction_control` takes `action` as `begin`, `commit` or `rollback`. The transaction stays open across calls until it is committed or rolled back, and it runs on the same session as the user's own tabs. Leave nothing open. +`transaction_control` takes `action` as `begin`, `commit` or `rollback`. The transaction stays open across calls until it is committed or rolled back, and it runs on the same session as the user's own tabs, so their editor runs and their grid saves land inside it while it is open. Leave nothing open. + +On Redis the three actions are `MULTI`, `EXEC` and `DISCARD`. Every command between `begin` and `commit` answers `QUEUED` and does nothing until `commit` runs them, and `commit` fails naming each command the server then refused. `rollback` drops the block whole. ## Databases and objects diff --git a/docs/features/copy-objects.mdx b/docs/features/copy-objects.mdx index 3016351258..74de8584a5 100644 --- a/docs/features/copy-objects.mdx +++ b/docs/features/copy-objects.mdx @@ -221,6 +221,8 @@ every row back. **Skip and continue** promises no rollback, so there the clearin A stopped copy reports only what was committed. The table it was in the middle of counts as neither copied nor failed. +When the target connection already holds a transaction, the copy runs inside it and opens none of its own. Nothing it wrote is committed until you commit that transaction, and the result says so. + ## Duplicating a database **Duplicate Database…** creates the new database with the character set and collation the engine diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 984e17258c..679638d638 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -41,7 +41,7 @@ The grid meanings come back the moment you click into the grid, and `Delete` on | Run statement and advance | `Ctrl+Cmd+Enter` | | Previous statement | `Ctrl+Cmd+Left` | | Next statement | `Ctrl+Cmd+Right` | -| Cancel query | `Cmd+.` | +| Cancel the selected tab's query | `Cmd+.` | | Explain query | `Cmd+Option+E` | | Explain with AI | `Cmd+L` | | Optimize with AI | `Cmd+Option+L` | diff --git a/docs/features/safe-mode.mdx b/docs/features/safe-mode.mdx index 148d5096f9..9e8440cbf3 100644 --- a/docs/features/safe-mode.mdx +++ b/docs/features/safe-mode.mdx @@ -71,7 +71,9 @@ Safe Mode runs inside TablePro. It cannot make a server accept a write the serve - The server runs with `read_only` or `super_read_only` turned on. - The server or the session opens new transactions read-only. -TablePro opens its write transactions as read-write, so a server that only defaults new transactions to read-only accepts the save anyway. On MySQL and MariaDB this narrows down the rest: +TablePro opens its write transactions as read-write, on the Mac and on iPhone and iPad alike, so a server that only defaults new transactions to read-only accepts the save anyway. Truncate and Drop are the exception on MySQL and MariaDB. Both commit whatever is open before they run, so they land outside that read-write transaction and under the session's own default, and the server refuses them. Run those two on a connection whose sessions start read-write. + +On MySQL and MariaDB this narrows down the rest: ```sql SHOW SESSION VARIABLES WHERE Variable_name IN diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 6cd226df12..2e6a83e3a4 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -37,10 +37,50 @@ To run the whole tab, press `Cmd+Shift+Enter`, choose **Run All Statements** fro A batch runs top to bottom and stops at the first statement that fails. The error names its place in the run: "Statement 3/5 failed: …". Each statement gets its own result, and each is recorded separately in [query history](/features/query-history). -On engines with transactions, the batch runs inside one, and a failure rolls back the statements before it. Engines without transactions run each statement as-is, with nothing to roll back. Two cases leave earlier statements in place: +### Which transaction the batch runs in -- MySQL and MariaDB commit a `CREATE`, `ALTER` or `DROP` the moment it runs, along with everything before it. A later failure cannot undo any of that. -- A script containing `BEGIN`, `START TRANSACTION`, `XA START` or `SET autocommit` runs exactly as written, with no transaction around it. On MySQL, MariaDB, TiDB and OceanBase, so does one containing `SET TRANSACTION`. What it commits stays committed. When it fails or you stop it, the transaction it still has open is rolled back. +| 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 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. + +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. + +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. + +### Statements a transaction cannot hold + +One of these anywhere in the batch leaves the whole of it unwrapped. The engine either refuses the statement inside a block, as PostgreSQL refuses `VACUUM`, or applies it and quietly throws it away, as SQLite does with `PRAGMA foreign_keys`. + +| Engine | Statements | +|---|---| +| PostgreSQL, Redshift, CockroachDB | `VACUUM`, `CREATE INDEX CONCURRENTLY`, `REINDEX SCHEMA`, `ALTER SYSTEM`, `CREATE DATABASE`, `DISCARD ALL`, `ALTER TYPE … ADD VALUE` | +| MySQL, MariaDB, TiDB, OceanBase | `SET TRANSACTION`, `SET sql_log_bin`, `SET binlog_format`, `SET GLOBAL gtid_mode`, `STOP REPLICA` | +| SQLite, libSQL | `VACUUM`, `DETACH`, `PRAGMA journal_mode`, `PRAGMA foreign_keys`, `PRAGMA wal_checkpoint` | +| DuckDB | `CHECKPOINT`, `FORCE CHECKPOINT`, `DETACH` | +| SQL Server | `CREATE`, `ALTER` and `DROP DATABASE`, `BACKUP`, `RESTORE`, `RECONFIGURE` | + +The MySQL row covers the preamble a `mysqldump` or `mariadb-dump` file carries, so a dump pasted into the editor runs the way `mysql` runs it. CockroachDB adds `SET CLUSTER SETTING`, `BACKUP`, `RESTORE` and `IMPORT`, and Redshift adds `CREATE EXTERNAL TABLE`, `CREATE LIBRARY` and `ALTER TABLE … APPEND`. + +A `CLUSTER` or `REINDEX` naming one table, and a `CALL` of a procedure that commits inside itself, depend on the object rather than on the text, so the batch around them keeps its transaction. Run those on their own. + +Redis has no transaction TablePro can open, so every command runs and answers as sent. A `MULTI` in the script opens a block of its own: each command after it answers `QUEUED` until `EXEC`, and a batch that fails or is stopped inside the block discards it. See [Redis](/databases/redis). + +### Stopping a batch + +Stop (`Cmd+.`) acts between statements. The run halts before the next one goes out, and a transaction TablePro or the script opened is rolled back. A batch running outside a transaction keeps what it already wrote and shows no result for it, so refresh the table to see where it stands. + +The commit is the point of no return. While it is on the wire Stop is dimmed and says why, and nothing else reaches it either: closing the tab releases the tab and nothing more, and disconnecting queues behind the commit, so the server finishes it either way. A script's own `COMMIT` counts the same, and the statements after it become stoppable again. + +If the connection dies while the commit is in flight, the result says the statements may or may not be saved and query history records them the same way. There is nothing left to ask, so no rollback is sent. Check the table before running them again. + +### Each tab runs its own queries + +Stop and `Cmd+.` act on the tab you are looking at. A query started in another tab, or on the same connection in another window, waits for the running one rather than stopping it: the waiting tab shows the ordinary **Executing…** spinner and a live Stop for as long as the other one runs, and a table opened in a new tab shows an empty grid with a spinner. Stopping the waiting tab leaves the running batch alone. ## Statement markers diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index a361b29566..35d6ed02db 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -153,6 +153,8 @@ Renaming a constraint runs a single `RENAME CONSTRAINT` where the engine has one The tab is hidden on engines with no check constraints. SQL Server lists and edits them, but has computed columns rather than generated ones, so it gets this tab and not the two column fields. +It is hidden on MySQL before 8.0.16, MariaDB before 10.2.1, TiDB before 7.2 and OceanBase before 4.0 as well. Those servers accept a `CHECK` clause, answer `Query OK`, and throw the rule away, so there is nothing to list and nothing a save could keep. Upgrade, or enforce the rule with a trigger. + SQLite is the one engine where listing and editing part company. The tab appears on every version, but **+** and **-** need SQLite 3.53.0 or later, the release that added `ADD CONSTRAINT` and `DROP CONSTRAINT` to `ALTER TABLE`. The driver links the system SQLite, so the version is the one macOS ships. On an older one the constraints still list, read-only. ## Saving changes diff --git a/docs/features/users-roles.mdx b/docs/features/users-roles.mdx index 4918c8861e..fe151fbdfc 100644 --- a/docs/features/users-roles.mdx +++ b/docs/features/users-roles.mdx @@ -48,7 +48,9 @@ Toggling a checkbox stages a change, marked on the account, the object, and the The **Privileges / Attributes** switch opens a form for the account itself: whether it can log in, role attributes such as `SUPERUSER` or `CREATEDB` on PostgreSQL, role membership (**Member of**, with **Edit…** for a checklist), and the connection limit. These stage and apply with everything else. -**Change Password**, in the context menu and on the Attributes form, sets a new one. Statements that carry a password, `CREATE USER` and `ALTER USER` among them, are never written to [query history](/features/query-history), which is stored unencrypted on disk. +**Change Password**, in the context menu and on the Attributes form, sets a new one. Statements that carry a password are never written to [query history](/features/query-history), which is stored unencrypted on disk. The review sheet still shows every one of them. + +`ALTER USER` does not exist on MySQL before 5.7.6 or MariaDB before 10.2. A password and a connection limit go in there as `GRANT USAGE ON *.* … IDENTIFIED BY` and `GRANT USAGE ON *.* … WITH MAX_USER_CONNECTIONS`, which needs `GRANT OPTION` as well as write access to `mysql.*`. ## Applying changes @@ -56,6 +58,8 @@ The bar at the bottom counts staged changes. **Discard** throws the set away; ** PostgreSQL runs them in one transaction, so a failure rolls the set back. MySQL commits user management statements implicitly, so a failure there leaves the earlier ones applied and reports how many. A successful apply re-reads the server. Changes take the same guards as any other write: a read-only connection blocks them, and [Safe Mode](/features/safe-mode) asks for confirmation or authentication as configured. +An apply made while a transaction is already open on the connection joins it instead of opening one of its own. The statements are pending in that transaction until you commit or roll it back, and the failure sheet counts them as pending rather than as written. + Dropping the account this connection uses, altering it, or revoking every privilege from it is called out in the review sheet before the SQL runs. It is not blocked: revoking your own admin rights can be deliberate. diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 3170f25c6a..7b2662bb1c 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -79,6 +79,8 @@ 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. + ### Querying The editor highlights SQL, runs a statement, and stops one mid-flight. **Stop** ends the result stream at once; on MySQL and Redis the statement keeps running on the server until it finishes. Results copy or export as JSON, CSV, or SQL `INSERT`. diff --git a/docs/switching.mdx b/docs/switching.mdx index 54f8525b8e..36f95d0d1f 100644 --- a/docs/switching.mdx +++ b/docs/switching.mdx @@ -79,7 +79,7 @@ Set these up on your first day: | Jump to any table, database or saved query by name | `Cmd+Shift+O` | | Switch database | `Cmd+K` | | Query history | `Cmd+Y` | -| Cancel a running query | `Cmd+.` | +| Cancel the selected tab's query | `Cmd+.` | | Format the query | `Cmd+Shift+L` | | Explain the query | `Cmd+Option+E` | | Save the query as a favorite | `Cmd+D` | diff --git a/project.yml b/project.yml index 6d891ed9c6..3d748151bf 100644 --- a/project.yml +++ b/project.yml @@ -432,6 +432,7 @@ targets: - Plugins/DuckDBDriverPlugin/DuckDBLockConflict.swift - Plugins/DuckDBDriverPlugin/DuckDBPositionParser.swift - Plugins/DuckDBDriverPlugin/DuckDBSchemaQueries.swift + - Plugins/DuckDBDriverPlugin/DuckDBTransactionProbe.swift - Plugins/DuckDBDriverPlugin/DuckDBTypeRendering.swift - Plugins/DuckDBDriverPlugin/DuckDBViewDefinition.swift - Plugins/DuckDBDriverPlugin/QuackConnectBuilder.swift @@ -467,6 +468,7 @@ targets: - Plugins/MSSQLDriverPlugin/MSSQLTypeDefinition.swift - Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift - Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift + - Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift - Plugins/MQLExportPlugin/MQLExportHelpers.swift - Plugins/MongoDBDriverPlugin/BsonDocumentFlattener.swift - Plugins/MongoDBDriverPlugin/MongoDBDecimal128.swift @@ -504,6 +506,7 @@ targets: - Plugins/MySQLDriverPlugin/MySQLIdleRelease.swift - Plugins/MySQLDriverPlugin/MySQLSessionFootprint.swift - Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift + - Plugins/MySQLDriverPlugin/MySQLKillLatch.swift - Plugins/MySQLDriverPlugin/MySQLKillTarget.swift - Plugins/MySQLDriverPlugin/MySQLMaintenance.swift - Plugins/MySQLDriverPlugin/MySQLObjectQueries.swift @@ -513,12 +516,16 @@ targets: - Plugins/MySQLDriverPlugin/MySQLServerFlavor.swift - Plugins/MySQLDriverPlugin/MySQLSystemDatabases.swift - Plugins/MySQLDriverPlugin/MySQLServerVersion.swift + - Plugins/MySQLDriverPlugin/MySQLAccountStatements.swift - Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift - Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift + - Plugins/MySQLDriverPlugin/MySQLQueryTimeout.swift + - Plugins/MySQLDriverPlugin/MySQLStatementWatch.swift + - Plugins/MySQLDriverPlugin/MySQLStatementDeadlineRunner.swift - Plugins/MySQLDriverPlugin/DatabendCatalog.swift - Plugins/MySQLDriverPlugin/DatabendLiteral.swift - Plugins/MySQLDriverPlugin/DatabendResultShape.swift - - Plugins/MySQLDriverPlugin/TiDBCheckConstraints.swift + - Plugins/MySQLDriverPlugin/MySQLCheckConstraints.swift - Plugins/MySQLDriverPlugin/MySQLCreateTableScanner.swift - Plugins/MySQLDriverPlugin/MySQLCatalogVisibility.swift - Plugins/MySQLDriverPlugin/MySQLCatalogFallback.swift @@ -616,6 +623,7 @@ targets: - Plugins/RedisDriverPlugin/RedisClusterCursor.swift - Plugins/RedisDriverPlugin/RedisClusterRedirect.swift - Plugins/RedisDriverPlugin/RedisClusterTopology.swift + - Plugins/RedisDriverPlugin/RedisCommandChannel.swift - Plugins/RedisDriverPlugin/RedisCommandParser.swift - Plugins/RedisDriverPlugin/RedisCommandRouting.swift - Plugins/RedisDriverPlugin/RedisConnectProbe.swift @@ -625,10 +633,13 @@ targets: - Plugins/RedisDriverPlugin/RedisKeySummary.swift - Plugins/RedisDriverPlugin/RedisMultiShardPlanner.swift - Plugins/RedisDriverPlugin/RedisQueryBuilder.swift + - Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift + - Plugins/RedisDriverPlugin/RedisQueuedDatabase.swift - Plugins/RedisDriverPlugin/RedisReply.swift - Plugins/RedisDriverPlugin/RedisSentinelResolver.swift - Plugins/RedisDriverPlugin/RedisStatementGenerator.swift - Plugins/RedisDriverPlugin/RedisTopologyDiagnostics.swift + - Plugins/RedisDriverPlugin/RedisTransactionOutcome.swift - Plugins/SQLExportPlugin/SQLExportBinaryLiteral.swift - Plugins/SQLExportPlugin/SQLExportCompressor.swift - Plugins/SQLExportPlugin/SQLExportDDLRewriter.swift diff --git a/scripts/check-mysql-autocommit-only-variables.sh b/scripts/check-mysql-autocommit-only-variables.sh new file mode 100755 index 0000000000..92f29858f0 --- /dev/null +++ b/scripts/check-mysql-autocommit-only-variables.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# Compare the curated MySQL autocommit-only variable table against a real server. +# +# MySQLAutocommitOnlyVariables lists the system variables MySQL and MariaDB refuse to set while a +# transaction is open, keyed by scope. A batch holding one of those statements runs without the +# app's transaction; a variable missing from the table leaves the batch wrapped and the statement +# fails with ERROR 1694, 1766, 1192 or 1179. The table is hand written and nothing at runtime +# checks it, which is how seven MySQL 8.4 variables went missing at once. +# +# This asks the server for every variable it has, tries each one twice (once in autocommit, once +# inside a transaction) and reports both directions: a variable the server refuses that the table +# does not list, and a variable the table lists that the server takes. A variable the server +# refuses in both runs is not a transaction rule at all (read only, wrong scope, no privilege) and +# is skipped. +# +# The table is the union of what MySQL and MariaDB refuse, and one server can only answer for +# itself: measured on MariaDB 12.3.3, explicit_defaults_for_timestamp and pseudo_slave_mode are +# taken inside a transaction, while MySQL 8.4.11 answers ERROR 1766 for both. So check a reported +# entry against the other engine before removing it, and run this with an account that holds +# SYSTEM_VARIABLES_ADMIN (or MariaDB's BINLOG ADMIN and REPLICATION SLAVE ADMIN), or every binlog +# and GTID variable is refused for want of a privilege and measures nothing. +# +# Usage: +# scripts/check-mysql-autocommit-only-variables.sh [host] [port] [user] +# +# Needs the mysql client and a MySQL 8 or MariaDB 10.5+ server. The password, if any, comes from +# MYSQL_PWD. Point it at a scratch server: each probe assigns a variable its own current value, at +# global scope as well as session scope. Exits non-zero on a disagreement. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-3306}" +USER_NAME="${3:-root}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SOURCE="$ROOT/TablePro/Core/Services/Execution/MySQLAutocommitOnlyVariables.swift" +BLOCK_LINES=5 + +command -v mysql > /dev/null || { + echo "mysql client not found" >&2 + exit 3 +} + +[ -f "$SOURCE" ] || { + echo "no curated table at $SOURCE" >&2 + exit 3 +} + +MYSQL=(mysql --no-defaults -h "$HOST" -P "$PORT" -u "$USER_NAME" -N -B -r --default-character-set=utf8mb4) +if ! "${MYSQL[@]}" -e "SELECT 1" > /dev/null 2>&1; then + echo "no MySQL at $HOST:$PORT for $USER_NAME" >&2 + exit 3 +fi + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# The curated table, one "namescope" line per entry, lowercased to match the server. +sed -n 's/^ *"\([A-Z0-9_]*\)": \[\([^]]*\)\].*$/\1 \2/p' "$SOURCE" | tr -d ',' | + while read -r NAME SCOPES; do + for SCOPE in $SCOPES; do + printf '%s\t%s\n' "$NAME" "${SCOPE#.}" + done + done | tr '[:upper:]' '[:lower:]' | sort > "$WORK/curated.tsv" + +[ -s "$WORK/curated.tsv" ] || { + echo "the curated table parsed to nothing: check the literal in $SOURCE" >&2 + exit 3 +} + +# A server with performance_schema turned off answers the modern table with no rows and no error, +# so the fallback has to key off the rows rather than off the exit status. +variables_in() { + local table="$1" + local legacy names + legacy="$(echo "$table" | tr '[:lower:]' '[:upper:]')" + names="$("${MYSQL[@]}" -e "SELECT LOWER(VARIABLE_NAME) FROM performance_schema.$table" 2> /dev/null)" + [ -n "$names" ] || + names="$("${MYSQL[@]}" -e "SELECT LOWER(VARIABLE_NAME) FROM information_schema.$legacy" 2> /dev/null)" + printf '%s\n' "$names" +} + +: > "$WORK/pairs.tsv" +for SCOPE in session global; do + VARIABLES="$(variables_in "${SCOPE}_variables")" + [ -n "$VARIABLES" ] || { + echo "the server listed no $SCOPE variables" >&2 + exit 3 + } + while read -r NAME; do + [ -n "$NAME" ] || continue + printf '%s\t%s\n' "$NAME" "$SCOPE" >> "$WORK/pairs.tsv" + done <<< "$VARIABLES" +done + +# Five lines per pair: the autocommit probe, then the same assignment inside a transaction. An +# error on the first line means the server refuses the assignment whatever the transaction is +# doing, which is not what this table is about. +: > "$WORK/probe.sql" +while IFS=$'\t' read -r NAME SCOPE; do + { + echo "SET @@$SCOPE.$NAME = @@$SCOPE.$NAME;" + echo "START TRANSACTION;" + echo "DO 1;" + echo "SET @@$SCOPE.$NAME = @@$SCOPE.$NAME;" + echo "ROLLBACK;" + } >> "$WORK/probe.sql" +done < "$WORK/pairs.tsv" + +"${MYSQL[@]}" --force < "$WORK/probe.sql" > /dev/null 2> "$WORK/errors.txt" + +# "ERROR 1766 (HY000) at line 4: ..." -> "4 1766" +sed -n 's/^ERROR \([0-9]*\) ([^)]*) at line \([0-9]*\):.*$/\2 \1/p' "$WORK/errors.txt" | sort -n -u > "$WORK/errors.tsv" + +awk -v blockLines="$BLOCK_LINES" ' + NR == FNR { failed[$1] = $2; next } + { + first = (FNR - 1) * blockLines + 1 + inTransaction = first + 3 + state = "allowed" + if (inTransaction in failed) { + state = (first in failed) ? "skipped" : "refused" + } + printf "%s\t%s\t%s\n", $1, $2, state + } +' "$WORK/errors.tsv" "$WORK/pairs.tsv" | sort > "$WORK/probed.tsv" + +awk -F'\t' '$3 == "refused" { print $1 "\t" $2 }' "$WORK/probed.tsv" | sort > "$WORK/refused.tsv" +awk -F'\t' '$3 == "allowed" { print $1 "\t" $2 }' "$WORK/probed.tsv" | sort > "$WORK/allowed.tsv" +cut -f1,2 "$WORK/probed.tsv" | sort > "$WORK/known.tsv" + +comm -23 "$WORK/refused.tsv" "$WORK/curated.tsv" > "$WORK/missing.tsv" +comm -12 "$WORK/curated.tsv" "$WORK/allowed.tsv" > "$WORK/stale.tsv" +comm -23 "$WORK/curated.tsv" "$WORK/known.tsv" > "$WORK/absent.tsv" + +while IFS=$'\t' read -r NAME SCOPE; do + [ -n "$NAME" ] && echo "not on this server, so unchecked: $NAME $SCOPE" +done < "$WORK/absent.tsv" + +FAILURES=0 +while IFS=$'\t' read -r NAME SCOPE; do + [ -n "$NAME" ] || continue + echo "missing from the table: $NAME $SCOPE is refused inside a transaction" + FAILURES=$((FAILURES + 1)) +done < "$WORK/missing.tsv" + +while IFS=$'\t' read -r NAME SCOPE; do + [ -n "$NAME" ] || continue + echo "stale in the table: $NAME $SCOPE is allowed inside a transaction on this server" + FAILURES=$((FAILURES + 1)) +done < "$WORK/stale.tsv" + +echo "probed $(wc -l < "$WORK/pairs.tsv" | tr -d ' ') variable scopes, $(wc -l < "$WORK/refused.tsv" | tr -d ' ') refused" +[ "$FAILURES" -eq 0 ] && echo "OK" || echo "$FAILURES disagreements" +exit $((FAILURES == 0 ? 0 : 1)) diff --git a/scripts/check-mysql-query-timeout.sh b/scripts/check-mysql-query-timeout.sh new file mode 100755 index 0000000000..92b5b8f5d9 --- /dev/null +++ b/scripts/check-mysql-query-timeout.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# +# Check which side enforces the MySQL query timeout on a real server. +# +# MySQL gained `max_execution_time` in 5.7.8 and MariaDB `max_statement_time` in 10.1.1. Below +# those the `SET SESSION` is `ERROR 1193 Unknown system variable`, the driver has no server-side +# timeout, and it stops a statement that runs past the limit with `KILL QUERY` from a second +# connection instead. The driver picks that at runtime from the server's own answer, so the version +# floor in MySQLServerVersion.hasStatementTimeout is only a prediction, and nothing at runtime +# checks it. This compiles that floor, asks the server, and fails when they disagree. +# +# On the client path it also checks the thing that path depends on: a `KILL QUERY` that lands after +# the statement it was meant for has finished leaves a flag the *next* statement consumes, and the +# driver runs a throwaway `SELECT 1` to absorb it. The check kills an idle session and confirms the +# absorb works, so a server where it does not is reported rather than shipped. +# +# Usage: +# scripts/check-mysql-query-timeout.sh [host] [port] [user] +# +# Needs the mysql client, xcrun swiftc, and a user that can read information_schema and KILL its +# own threads. The password, if any, comes from MYSQL_PWD. Exits 0 when the server agrees with the +# floor, 1 when it does not, and 3 when the check could not run. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-3306}" +USER_NAME="${3:-root}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLUGIN="$ROOT/Plugins/MySQLDriverPlugin" +KIT="$ROOT/Plugins/TableProPluginKit" +PROBE_SECONDS=1 + +command -v mysql > /dev/null || { + echo "mysql client not found" >&2 + exit 3 +} + +MYSQL=(mysql --no-defaults --comments -h "$HOST" -P "$PORT" -u "$USER_NAME" -N -B) +if ! "${MYSQL[@]}" -e "SELECT 1" > /dev/null 2>&1; then + echo "no MySQL at $HOST:$PORT for $USER_NAME" >&2 + exit 3 +fi + +WORK="$(mktemp -d)" +cleanup() { + rm -rf "$WORK" +} +trap cleanup EXIT + +cat > "$WORK/main.swift" <<'SWIFT' +import Foundation + +let argument = CommandLine.arguments.dropFirst().first ?? "mysql" +let banner = CommandLine.arguments.dropFirst(2).first +let flavor: MySQLServerFlavor = argument == "mariadb" ? .mariadb : .mysql +let seconds = Int(CommandLine.arguments.dropFirst(3).first ?? "1") ?? 1 +print(MySQLServerVersion.hasStatementTimeout(banner: banner, flavor: flavor) ? "server" : "client") +for statement in flavor.queryTimeoutStatements(seconds: seconds) { + print(statement) +} +SWIFT + +mkdir -p "$WORK/modules" +xcrun swiftc -emit-library -emit-module -module-name TableProPluginKit \ + -emit-module-path "$WORK/modules/TableProPluginKit.swiftmodule" \ + -o "$WORK/libTableProPluginKit.dylib" \ + "$KIT/PluginTransactionAccessMode.swift" "$KIT/PrincipalTypes.swift" > "$WORK/build.log" 2>&1 && + xcrun swiftc -I "$WORK/modules" -L "$WORK" -lTableProPluginKit -Xlinker -rpath -Xlinker "$WORK" \ + -o "$WORK/gate" "$WORK/main.swift" \ + "$PLUGIN/MySQLServerFlavor.swift" "$PLUGIN/MySQLServerVersion.swift" \ + "$PLUGIN/MySQLAccountStatements.swift" >> "$WORK/build.log" 2>&1 || { + cat "$WORK/build.log" >&2 + exit 3 +} + +VERSION="$("${MYSQL[@]}" -e "SELECT VERSION()")" +FLAVOR="mysql" +case "$VERSION" in + *[Mm]aria[Dd][Bb]*) FLAVOR="mariadb" ;; +esac + +"$WORK/gate" "$FLAVOR" "$VERSION" "$PROBE_SECONDS" > "$WORK/gate.out" || { + echo "the gate could not be evaluated" >&2 + exit 3 +} +EXPECTED="$(sed -n '1p' "$WORK/gate.out")" +STATEMENT="$(sed -n '2p' "$WORK/gate.out")" +echo "$VERSION ($FLAVOR): floor says $EXPECTED, statement is [$STATEMENT]" + +SET_OUTPUT="$("${MYSQL[@]}" -e "$STATEMENT" 2>&1)" +SET_STATUS=$? +if [ "$SET_STATUS" -eq 0 ]; then + ANSWER="server" +elif printf '%s' "$SET_OUTPUT" | grep -q "1193"; then + ANSWER="client" +else + echo "the server refused the statement for another reason: $SET_OUTPUT" >&2 + exit 3 +fi +echo "server answers: $ANSWER" + +if [ "$EXPECTED" != "$ANSWER" ]; then + echo "MISMATCH: the floor predicted $EXPECTED and the server answers $ANSWER" + echo "the driver follows the server, so this is a stale floor in MySQLServerVersion.hasStatementTimeout" + exit 1 +fi + +if [ "$ANSWER" = "server" ]; then + echo "OK: this server enforces the timeout itself" + exit 0 +fi + +# The client path only. A kill that lands on an idle session is consumed by whatever runs next, so +# the driver runs a throwaway statement first. Both runs kill the session while it sits in the +# client's own `sleep`, which is what makes the timing deterministic. +HEAVY="SELECT COUNT(*) FROM information_schema.COLLATIONS a, information_schema.COLLATIONS b" +cat > "$WORK/no-flush.sql" < "$WORK/flush.sql" < "$output" 2>&1 & + local runner=$! + sleep 2 + local thread + thread="$("${MYSQL[@]}" -e "SELECT ID FROM information_schema.PROCESSLIST + WHERE COMMAND = 'Sleep' AND ID <> CONNECTION_ID() ORDER BY TIME LIMIT 1" 2>/dev/null)" + if [ -z "$thread" ]; then + wait "$runner" + echo "could not find the idle session to kill" >&2 + return 3 + fi + "${MYSQL[@]}" -e "KILL QUERY $thread" > /dev/null 2>&1 + wait "$runner" + return 0 +} + +kill_idle_session_during "$WORK/no-flush.sql" "$WORK/no-flush.out" || exit 3 +if grep -q "1317" "$WORK/no-flush.out"; then + echo "this server carries a pending kill into the next statement, so the absorb is needed" +else + echo "this server does not carry a pending kill into the next statement" +fi + +kill_idle_session_during "$WORK/flush.sql" "$WORK/flush.out" || exit 3 +if grep -q "1317" "$WORK/flush.out"; then + echo "the throwaway SELECT 1 did not absorb the pending kill on this server:" + cat "$WORK/flush.out" + exit 1 +fi + +echo "OK: this server has no statement timeout, and a pending kill is absorbed before the next statement" +exit 0 diff --git a/scripts/check-redis-multi-semantics.sh b/scripts/check-redis-multi-semantics.sh new file mode 100755 index 0000000000..fad3da3aaf --- /dev/null +++ b/scripts/check-redis-multi-semantics.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# +# Check the MULTI behaviours the Redis driver is built on against a real server. +# +# The driver does not wrap a query-tab batch, because every command inside a MULTI block answers +# +QUEUED instead of its own reply, and it does wrap a generated write, because a command the +# server refuses at queue time aborts the whole block instead of leaving half of it applied. It +# also holds a queued SELECT aside until the block resolves, because the session only moves on +# EXEC. None of those is a table this repo can diff: they are behaviours, and a Redis release that +# changed any of them would silently invalidate the reply handling in RedisCommandChannel.run, +# RedisPluginDriver.commitTransaction and RedisQueuedDatabase. +# +# Usage: +# scripts/check-redis-multi-semantics.sh [host] [port] +# +# Needs redis-cli and a reachable Redis. The ACL and OOM checks need a privileged user and are +# reported as skipped otherwise. Exits 0 when every behaviour holds, 1 on a disagreement, 3 when +# the check could not run. + +set -uo pipefail + +HOST="${1:-127.0.0.1}" +PORT="${2:-6379}" + +command -v redis-cli > /dev/null || { + echo "redis-cli not found" >&2 + exit 3 +} + +if ! redis-cli -h "$HOST" -p "$PORT" ping > /dev/null 2>&1; then + echo "no Redis at $HOST:$PORT" >&2 + exit 3 +fi + +VERSION="$(redis-cli -h "$HOST" -p "$PORT" info server | tr -d '\r' | awk -F: '/^redis_version:/ {print $2}')" +KEY_PREFIX="tablepro:multicheck" +FAILURES=0 +SKIPPED=0 + +echo "Checking MULTI behaviour against Redis $VERSION at $HOST:$PORT" + +cleanup() { + redis-cli -h "$HOST" -p "$PORT" -n 0 --scan --pattern "$KEY_PREFIX:*" 2> /dev/null \ + | while read -r key; do redis-cli -h "$HOST" -p "$PORT" -n 0 del "$key" > /dev/null 2>&1; done + redis-cli -h "$HOST" -p "$PORT" -n 2 --scan --pattern "$KEY_PREFIX:*" 2> /dev/null \ + | while read -r key; do redis-cli -h "$HOST" -p "$PORT" -n 2 del "$key" > /dev/null 2>&1; done +} +trap cleanup EXIT + +# One connection per case, because every behaviour here is per-session state. redis-cli reads the +# commands from stdin and prints one line per reply, so the whole exchange is one process. +session() { + redis-cli -h "$HOST" -p "$PORT" --no-raw 2> /dev/null +} + +report() { + local label="$1" expected="$2" actual="$3" + if [ "$actual" = "$expected" ]; then + echo " ok $label" + return + fi + echo " FAIL $label" + echo " expected: $expected" + echo " actual: $actual" + FAILURES=$((FAILURES + 1)) +} + +skip() { + echo " skip $1 ($2)" + SKIPPED=$((SKIPPED + 1)) +} + +echo +echo "A command inside a block is acknowledged, not answered" +QUEUED="$(printf 'SET %s:q one\nMULTI\nGET %s:q\nDISCARD\n' "$KEY_PREFIX" "$KEY_PREFIX" | session | sed -n '3p')" +report "GET answers QUEUED rather than the stored value" "QUEUED" "$QUEUED" + +echo +echo "EXEC applies the block and reports each command's own reply" +MIXED="$( + printf 'SET %s:s hello\nMULTI\nGET %s:s\nLPUSH %s:s x\nSET %s:t 1\nEXEC\nGET %s:t\n' \ + "$KEY_PREFIX" "$KEY_PREFIX" "$KEY_PREFIX" "$KEY_PREFIX" "$KEY_PREFIX" | session +)" +report "a failed command inside EXEC names WRONGTYPE" "yes" \ + "$(echo "$MIXED" | grep -qi 'WRONGTYPE' && echo yes || echo no)" +report "the commands beside it were applied anyway" "\"1\"" "$(echo "$MIXED" | tail -n 1)" + +echo +echo "A refusal at queue time aborts the whole block" +ABORT="$( + printf 'MULTI\nSET %s:a 1\nSET %s:a\nEXEC\nEXISTS %s:a\n' \ + "$KEY_PREFIX" "$KEY_PREFIX" "$KEY_PREFIX" | session +)" +report "EXEC answers EXECABORT" "yes" "$(echo "$ABORT" | grep -qi 'EXECABORT' && echo yes || echo no)" +report "nothing in the block was written" "(integer) 0" "$(echo "$ABORT" | tail -n 1)" + +echo +echo "An ACL refusal is a queue-time refusal too, which is why a generated write keeps the block" +ACL_USER="$KEY_PREFIX:acl" +if redis-cli -h "$HOST" -p "$PORT" acl setuser "$ACL_USER" on '>pw' '~*' \ + +set +get +multi +exec +discard +exists +auth +reset > /dev/null 2>&1; then + ACL="$( + printf 'AUTH %s pw\nMULTI\nSET %s:b 1\nEXPIRE %s:b 10\nEXEC\nEXISTS %s:b\n' \ + "$ACL_USER" "$KEY_PREFIX" "$KEY_PREFIX" "$KEY_PREFIX" | session + )" + report "EXPIRE is refused at queue time" "yes" \ + "$(echo "$ACL" | grep -qi 'NOPERM' && echo yes || echo no)" + report "the SET the user was allowed is not applied" "(integer) 0" "$(echo "$ACL" | tail -n 1)" + UNWRAPPED="$( + printf 'AUTH %s pw\nSET %s:c 1\nEXPIRE %s:c 10\nEXISTS %s:c\n' \ + "$ACL_USER" "$KEY_PREFIX" "$KEY_PREFIX" "$KEY_PREFIX" | session + )" + report "the same two commands sent unwrapped leave the SET applied" "(integer) 1" \ + "$(echo "$UNWRAPPED" | tail -n 1)" + redis-cli -h "$HOST" -p "$PORT" acl deluser "$ACL_USER" > /dev/null 2>&1 +else + skip "an ACL refusal aborts the block" "ACL SETUSER was refused" +fi + +echo +echo "A queued SELECT moves the session only when the block applies" +AFTER_EXEC="$(printf 'MULTI\nSELECT 2\nEXEC\nCLIENT INFO\n' | session | tr ' ' '\n' | grep -m1 '^db=')" +report "EXEC leaves the session on the selected database" "db=2" "$AFTER_EXEC" +AFTER_DISCARD="$(printf 'MULTI\nSELECT 2\nDISCARD\nCLIENT INFO\n' | session | tr ' ' '\n' | grep -m1 '^db=')" +report "DISCARD leaves the session where it was" "db=0" "$AFTER_DISCARD" +AFTER_RESET="$(printf 'MULTI\nSELECT 2\nRESET\nCLIENT INFO\n' | session | tr ' ' '\n' | grep -m1 '^db=')" +report "RESET leaves the session where it was" "db=0" "$AFTER_RESET" + +echo +echo "The block's own vocabulary" +NESTED="$(printf 'MULTI\nMULTI\nDISCARD\n' | session | sed -n '2p')" +report "a nested MULTI is refused" "yes" "$(echo "$NESTED" | grep -qi 'nested' && echo yes || echo no)" +LONE="$(printf 'DISCARD\nEXEC\n' | session)" +report "DISCARD outside a block is refused" "yes" \ + "$(echo "$LONE" | sed -n '1p' | grep -qi 'without MULTI' && echo yes || echo no)" +report "EXEC outside a block is refused" "yes" \ + "$(echo "$LONE" | sed -n '2p' | grep -qi 'without MULTI' && echo yes || echo no)" + +echo +if [ "$SKIPPED" -gt 0 ]; then + echo "$SKIPPED behaviour(s) could not be checked with this user" +fi +if [ "$FAILURES" -gt 0 ]; then + echo "$FAILURES behaviour(s) disagree with what the driver assumes" + exit 1 +fi +echo "every checked behaviour holds on Redis $VERSION"