diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fc7772f21..5e9c66ff5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - MCP server runs only while Enable MCP Server is on in Settings > MCP. Clients and pairing no longer start it while it is off. - Edit > Data shown only in data file windows. - Smaller updates: about 6 MB from any of the last five releases and 21 MB from older ones, down from 34 MB. +- Several statements run together on PostgreSQL from iPhone and iPad commit one at a time, and a failure keeps the ones before it. (#3399) ### Removed @@ -79,6 +80,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Reopen Last Session forgetting its connections after an MCP client launched TablePro in the background. - GitHub Copilot starting at launch when not in use, and running on after AI was turned off or Copilot was removed. - Settings > AI reading sign-in tokens and running command line tools for providers that were never added. +- Only the first statement running on iPhone and iPad when the query holds several. (#3399) +- Query on iPhone and iPad appearing to do nothing after CREATE, DROP or a write that changed no rows, including every Oracle write. (#3399) +- SQLite on iPhone and iPad reporting a CHECK or UNIQUE violation as a saved row in Add Row, the row editor and Shortcuts. (#3399) +- Previous write's row count shown after a SQLite CREATE, DROP or ALTER. (#3399) +- SQLite, PostgreSQL, MySQL and DuckDB query on iPhone and iPad hiding an error raised after the first rows. (#3399) +- Stop on iPhone and iPad letting a running SQLite or PostgreSQL statement finish and commit, and running a DuckDB query again from the start. (#3399) +- Query run right after Stop on iPhone and iPad showing no rows. (#3399) +- PostgreSQL query on iPhone and iPad downloading the whole table before showing the first 10,000 rows. (#3399) +- DuckDB result on iPhone and iPad cut at 10,000 rows without the notice that more rows exist. (#3399) +- MySQL connection failing every query after calling a procedure that returns rows, and a procedure that failed after its first rows reported as a success. (#3399) +- Redis command on iPhone and iPad reading a line break as part of a value, and dropping an empty quoted value. (#3399) - View DDL shown as a made-up CREATE TABLE on SQL Server, Oracle, Dameng, DuckDB, Redshift and Cassandra. (#3402) - Show DDL and Copy DDL failing on every view on SQLite, libSQL, Cloudflare D1, SAP HANA, Spanner, Trino and Teradata. - SQL Server view definitions naming the view's old name after a rename, and blank for an encrypted view or a name with a period. diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 3659fba6b4..e8da5e1fc1 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -89,7 +89,7 @@ let package = Package( ), .target( name: "TableProDatabase", - dependencies: ["TableProModels", "TableProCoreTypes", "TableProPluginKit"], + dependencies: ["TableProModels", "TableProCoreTypes", "TableProPluginKit", "TableProSQLGrammar"], path: "Sources/TableProDatabase" ), .target( diff --git a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift index 35bcf3fff4..8d1bf03bf1 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift @@ -1,6 +1,7 @@ import Foundation import TableProModels import TableProPluginKit +import TableProSQLGrammar public protocol DatabaseDriver: AnyObject, Sendable { func connect() async throws @@ -12,6 +13,13 @@ public protocol DatabaseDriver: AnyObject, Sendable { func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream func cancelCurrentQuery() async throws + /// Whether one request may carry a whole `GO` batch, so a script keeps its batch-scoped variables. + var sendsBatchesWhole: Bool { get } + + /// How this session reads a string literal, where a server setting decides it, so a script is split the way the + /// server will read it. Nil when the engine has no such setting or the session cannot say. + func sessionLexicalFacts() async -> SQLSessionLexicalFacts? + 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] @@ -39,6 +47,10 @@ public protocol DatabaseDriver: AnyObject, Sendable { public extension DatabaseDriver { var holdsSuspensionBlockingResource: Bool { false } + var sendsBatchesWhole: Bool { false } + + func sessionLexicalFacts() async -> SQLSessionLexicalFacts? { nil } + func beginTransaction(mode: PluginTransactionAccessMode) async throws { try await beginTransaction() } diff --git a/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift b/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift new file mode 100644 index 0000000000..f8968eb867 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProQuery/QueryScript.swift @@ -0,0 +1,120 @@ +import Foundation +import TableProModels +import TableProSQLGrammar + +/// The requests one run of a query text makes, in the order it makes them. +/// +/// A text sent whole runs one statement or reports one result on every engine the iOS app speaks: SQLite and DuckDB +/// compile one statement per prepare, MySQL is connected without multi-statement support, and libpq answers with one +/// result. So the text is split here, by the grammar the engine's own lexer follows, as the Mac splits it. A driver +/// that sends batches whole (SQL Server) gets its `GO` batches instead, because T-SQL scopes a variable to its batch. +public struct QueryScript: Sendable, Equatable { + public enum UnitKind: Sendable, Equatable { + case statement + case batch + } + + public struct Unit: Sendable, Equatable { + public let sql: String + + /// How many times the script asks for the unit to run: `GO 5` runs its batch five times. + public let repeatCount: Int + + public init(sql: String, repeatCount: Int = 1) { + self.sql = sql + self.repeatCount = repeatCount + } + } + + public let units: [Unit] + public let unitKind: UnitKind + + /// How the session read literals when the text was split. + public let session: SQLSessionLexicalFacts? + + /// The text that was split, where each unit ends in it, and its engine, so the rest can be split again. + private let source: String? + private let unitEnds: [Int] + private let databaseType: DatabaseType? + + public init(units: [Unit], unitKind: UnitKind) { + self.init(units: units, unitKind: unitKind, session: nil, source: nil, unitEnds: [], databaseType: nil) + } + + private init( + units: [Unit], + unitKind: UnitKind, + session: SQLSessionLexicalFacts?, + source: String?, + unitEnds: [Int], + databaseType: DatabaseType? + ) { + self.units = units + self.unitKind = unitKind + self.session = session + self.source = source + self.unitEnds = unitEnds + self.databaseType = databaseType + } + + public var isEmpty: Bool { units.isEmpty } + + /// Every request the run sends, a `GO n` batch counted `n` times. + public var requestCount: Int { + units.reduce(0) { $0 + max($1.repeatCount, 1) } + } + + /// Redis speaks commands, not SQL, and its driver reads the whole text as one command. + /// + /// `session` is what the connection says about its own literals, such as PostgreSQL's + /// `standard_conforming_strings`: with it off, `'a\';b'` is one literal, and the default reading would cut it. + public static func plan( + _ text: String, + databaseType: DatabaseType, + sendsBatchesWhole: Bool, + session: SQLSessionLexicalFacts? = nil + ) -> QueryScript { + if databaseType == .redis { + let command = text.trimmingCharacters(in: .whitespacesAndNewlines) + return QueryScript(units: command.isEmpty ? [] : [Unit(sql: command)], unitKind: .statement) + } + + let grammar = SQLLexicalReadings.resolve( + databaseTypeId: databaseType.rawValue, + declared: nil, + session: session + ).execution + let statements = SQLStatementScanner.executableStatements(in: text, grammar: grammar) + + guard sendsBatchesWhole else { + return QueryScript( + units: statements.map { Unit(sql: $0.sql) }, + unitKind: .statement, + session: session, + source: text, + unitEnds: statements.map { $0.range.location + $0.range.length }, + databaseType: databaseType + ) + } + let batches = SQLStatementScanner.batches( + in: text, + statements: statements, + separators: SQLStatementScanner.batchSeparators(in: text, grammar: grammar) + ) + return QueryScript( + units: batches.map { Unit(sql: $0.sql, repeatCount: $0.repeatCount) }, + unitKind: .batch + ) + } + + /// The text after unit `index`, split again under `session`, for a statement that changed how the session reads + /// literals: after `SET sql_mode = 'NO_BACKSLASH_ESCAPES'` a backslash no longer escapes a quote, so the split + /// made before it ran no longer matches what the server will read. Nil when the script cannot be split again. + public func replanned(after index: Int, session: SQLSessionLexicalFacts?) -> QueryScript? { + guard unitKind == .statement, let source, let databaseType, unitEnds.indices.contains(index) else { + return nil + } + let rest = (source as NSString).substring(from: unitEnds[index]) + return QueryScript.plan(rest, databaseType: databaseType, sendsBatchesWhole: false, session: session) + } +} diff --git a/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift b/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift index aa61ae7a70..e0b4d74e03 100644 --- a/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift +++ b/Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift @@ -10,10 +10,10 @@ import TableProSQLGrammar /// taught, or anything hidden behind a leading comment, runs unguarded. /// /// The batch is split into statements the way its engine lexes it, by the grammar the Mac reads -/// too, and under every reading the engine could be using: iOS drivers send the whole text in one -/// call, so a `DELETE` hidden behind a quote only one reading closes still runs. On an engine that -/// needs no `;`, every statement that begins inside one is read as well, because SQL Server runs -/// `SELECT 1` followed by `DROP TABLE t` as two statements. +/// too, and under every reading the engine could be using: the run splits by one reading, and a +/// `DELETE` hidden behind a quote that only another reading closes still reaches the server. On an +/// engine that needs no `;`, every statement that begins inside one is read as well, because SQL +/// Server runs `SELECT 1` followed by `DROP TABLE t` as two statements. public enum SQLWriteClassifier { /// `EXPLAIN` and `PRAGMA` are deliberately absent: `EXPLAIN ANALYZE DELETE …` runs the delete on /// PostgreSQL, and `PRAGMA journal_mode = WAL` writes on SQLite and DuckDB. diff --git a/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBatch.swift b/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBatch.swift new file mode 100644 index 0000000000..ebd80a3240 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProSQLGrammar/SQLStatementBatch.swift @@ -0,0 +1,73 @@ +import Foundation + +/// The statements the server receives in one request, and the text they are sent as. +/// +/// SQL Server scopes a local variable, a table variable and a `TRY...CATCH` to one batch, and its own tools cut a script +/// into batches only at a line holding `GO`. A text with no such line is one batch, and only a driver that sends +/// batches whole runs it as one: every other run takes its statements one by one. +public struct SQLStatementBatch: Sendable { + /// The source between the first statement's start and the last one's end, inner `;` and comments included. + public let sql: String + + /// The statements inside, in the caller's coordinates, as the statement scanner found them. + public let statements: [SQLStatementScanner.ExecutableStatement] + + /// How many times the script asks for the batch to run: `GO 5` runs it five times. + public let repeatCount: Int + + public init(sql: String, statements: [SQLStatementScanner.ExecutableStatement], repeatCount: Int) { + self.sql = sql + self.statements = statements + self.repeatCount = repeatCount + } + + /// The batch's span in the caller's coordinates. + public var range: NSRange { + guard let first = statements.first, let last = statements.last else { + return NSRange(location: NSNotFound, length: 0) + } + return NSRange(location: first.range.location, length: last.range.upperBound - first.range.location) + } +} + +public extension SQLStatementScanner { + /// Groups a scanned text's statements into the batches its engine runs. + /// + /// `statements` and `separators` are in `text`'s coordinates; the batches come back shifted by `sourceOffset`, the + /// way a run started from a selection already shifts its statements onto the whole document. + static func batches( + in text: String, + statements: [ExecutableStatement], + separators: [SQLBatchSeparator], + sourceOffset: Int = 0 + ) -> [SQLStatementBatch] { + let source = text as NSString + var batches: [SQLStatementBatch] = [] + var pending: [ExecutableStatement] = [] + var remaining = statements[...] + + func close(repeatCount: Int) { + guard let first = pending.first, let last = pending.last else { return } + let span = NSRange(location: first.range.location, length: last.range.upperBound - first.range.location) + batches.append( + SQLStatementBatch( + sql: source.substring(with: span), + statements: pending.map { $0.offset(by: sourceOffset) }, + repeatCount: repeatCount + ) + ) + pending = [] + } + + for separator in separators { + while let next = remaining.first, next.range.location < separator.range.location { + pending.append(next) + remaining = remaining.dropFirst() + } + close(repeatCount: separator.repeatCount) + } + pending.append(contentsOf: remaining) + close(repeatCount: 1) + return batches + } +} diff --git a/Packages/TableProCore/Tests/TableProQueryTests/QueryScriptTests.swift b/Packages/TableProCore/Tests/TableProQueryTests/QueryScriptTests.swift new file mode 100644 index 0000000000..f1f8713870 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProQueryTests/QueryScriptTests.swift @@ -0,0 +1,159 @@ +import Foundation +import TableProModels +import TableProQuery +import TableProSQLGrammar +import Testing + +@Suite("QueryScript") +struct QueryScriptTests { + private static let issue3399 = """ + CREATE TABLE STUDENT ( + SID CHAR(5) NOT NULL PRIMARY KEY, + NAME VARCHAR(20) NOT NULL, + SEX CHAR(1) CHECK (SEX IN ('M', 'F')), + CLASS VARCHAR(5), + DOB DATE, + SCORE INT + ); + + INSERT INTO STUDENT (SID, NAME, SEX, CLASS, DOB, SCORE) VALUES + ('03493', 'Ada', 'F', '3A', '2013-09-21', 0), + ('03517', 'Ben', 'M', '3A', '2013-11-17', 86); + """ + + private func plan(_ text: String, _ type: DatabaseType, batches: Bool = false) -> QueryScript { + QueryScript.plan(text, databaseType: type, sendsBatchesWhole: batches) + } + + @Test("The reported CREATE TABLE and INSERT run as two statements, each without its terminator") + func reportedScriptIsTwoStatements() throws { + let script = plan(Self.issue3399, .sqlite) + #expect(script.unitKind == .statement) + #expect(script.units.count == 2) + let create = try #require(script.units.first) + let insert = try #require(script.units.last) + #expect(create.sql.hasPrefix("CREATE TABLE STUDENT (")) + #expect(create.sql.hasSuffix("SCORE INT\n)")) + #expect(insert.sql.hasPrefix("INSERT INTO STUDENT")) + #expect(insert.sql.hasSuffix("86)")) + #expect(script.requestCount == 2) + } + + @Test("Every SQL engine the iOS app speaks splits the reported script the same way") + func everyEngineSplitsTheReportedScript() { + for type in [DatabaseType.sqlite, .mysql, .mariadb, .postgresql, .duckdb, .oracle] { + #expect(plan(Self.issue3399, type).units.count == 2, "\(type.rawValue)") + } + } + + @Test("A semicolon inside a literal, a quoted name or a comment does not end a statement") + func semicolonsInsideTokensDoNotSplit() { + let script = plan("INSERT INTO t VALUES ('a;b'); -- c;d\nSELECT \"x;y\" FROM t /* ; */", .sqlite) + #expect(script.units.count == 2) + #expect(script.units.first?.sql == "INSERT INTO t VALUES ('a;b')") + } + + @Test("A trigger body stays one statement") + func triggerBodyStaysWhole() { + let text = """ + CREATE TABLE t (id INTEGER); + CREATE TRIGGER trg AFTER INSERT ON t BEGIN + INSERT INTO log VALUES (new.id); + UPDATE log SET n = n + 1; + END; + INSERT INTO t VALUES (1); + """ + let script = plan(text, .sqlite) + #expect(script.units.count == 3) + #expect(script.units[1].sql.hasPrefix("CREATE TRIGGER")) + #expect(script.units[1].sql.contains("UPDATE log SET n = n + 1;")) + } + + @Test("Text holding only comments and blank lines has nothing to run") + func commentsOnlyIsEmpty() { + #expect(plan("-- nothing here\n/* or here */\n;\n", .sqlite).isEmpty) + #expect(plan(" \n", .postgresql).isEmpty) + } + + @Test("An Oracle PL/SQL block keeps its inner semicolons and its own END terminator") + func plsqlBlockStaysWhole() { + let script = plan("BEGIN\n NULL;\nEND;\n/\nSELECT 1 FROM dual;", .oracle) + #expect(script.units.map(\.sql) == ["BEGIN\n NULL;\nEND;", "SELECT 1 FROM dual"]) + } + + @Test("A driver that sends batches whole gets GO batches, a GO count kept as the batch's repeat count") + func batchesForADriverThatSendsThemWhole() { + let text = "DECLARE @n int = 1;\nSELECT @n;\nGO\nINSERT INTO t VALUES (1)\nGO 3" + let script = plan(text, .mssql, batches: true) + #expect(script.unitKind == .batch) + #expect(script.units == [ + QueryScript.Unit(sql: "DECLARE @n int = 1;\nSELECT @n", repeatCount: 1), + QueryScript.Unit(sql: "INSERT INTO t VALUES (1)", repeatCount: 3), + ]) + #expect(script.requestCount == 4) + } + + @Test("Without GO lines a batch driver sends the script as one batch") + func oneBatchWithoutSeparators() { + let script = plan("DECLARE @n int = 1; SELECT @n", .mssql, batches: true) + #expect(script.units == [QueryScript.Unit(sql: "DECLARE @n int = 1; SELECT @n")]) + } + + @Test("A text whose planned statements include a write is gated as a write, so one confirmation covers the run") + func gateCoversEveryPlannedStatement() { + let scripts: [(String, DatabaseType, Bool)] = [ + ("SELECT 1; DELETE FROM t", .sqlite, false), + ("SELECT ';'; DROP TABLE t", .postgresql, false), + ("SELECT 1 -- ;\n; UPDATE t SET a = 1", .mysql, false), + ("SELECT 1\nGO\nDELETE FROM t", .mssql, true), + (Self.issue3399, .sqlite, false), + ] + for (text, type, batches) in scripts { + let units = plan(text, type, batches: batches).units + let anyWrite = units.contains { SQLWriteClassifier.isWriteQuery($0.sql, databaseType: type) } + #expect(anyWrite, "\(text)") + #expect(SQLWriteClassifier.isWriteQuery(text, databaseType: type), "\(text)") + } + } + + @Test("A session whose literals escape backslashes keeps an escaped quote and its semicolon in one statement") + func sessionFactsDecideTheSplit() { + let text = "SELECT 'a\\';b'; SELECT 2" + let escapes = SQLSessionLexicalFacts( + determined: .backslashEscapesInSingleQuotes, + enabled: .backslashEscapesInSingleQuotes + ) + let standard = SQLSessionLexicalFacts(determined: .backslashEscapesInSingleQuotes, enabled: []) + let withEscapes = QueryScript.plan(text, databaseType: .postgresql, sendsBatchesWhole: false, session: escapes) + let standardStrings = QueryScript.plan(text, databaseType: .postgresql, sendsBatchesWhole: false, session: standard) + + #expect(withEscapes.units.map(\.sql) == ["SELECT 'a\\';b'", "SELECT 2"]) + #expect(standardStrings.units.map(\.sql) == ["SELECT 'a\\'", "b'; SELECT 2"]) + } + + @Test("The text after a statement that changed the session's literals is split again by the new reading") + func replanAfterTheSessionChanges() throws { + let escapes = SQLSessionLexicalFacts( + determined: .backslashEscapesInSingleQuotes, + enabled: .backslashEscapesInSingleQuotes + ) + let noEscapes = SQLSessionLexicalFacts( + determined: [.backslashEscapesInSingleQuotes, .backslashEscapesInDoubleQuotes], + enabled: [] + ) + let text = "SET sql_mode = 'NO_BACKSLASH_ESCAPES'; SELECT 'a\\'; SELECT 2;" + let script = QueryScript.plan(text, databaseType: .mysql, sendsBatchesWhole: false, session: escapes) + #expect(script.units.map(\.sql) == ["SET sql_mode = 'NO_BACKSLASH_ESCAPES'", "SELECT 'a\\'; SELECT 2"]) + + let rest = try #require(script.replanned(after: 0, session: noEscapes)) + #expect(rest.units.map(\.sql) == ["SELECT 'a\\'", "SELECT 2"]) + #expect(rest.session == noEscapes) + #expect(QueryScript(units: script.units, unitKind: .statement).replanned(after: 0, session: noEscapes) == nil) + } + + @Test("Redis text is one command, as its driver reads it") + func redisIsOneCommand() { + #expect(plan(" SET k \"a;b\"; GET k \n", .redis).units == [QueryScript.Unit(sql: "SET k \"a;b\"; GET k")]) + #expect(plan(" \n ", .redis).isEmpty) + } +} diff --git a/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLStatementBatchTests.swift b/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLStatementBatchTests.swift new file mode 100644 index 0000000000..9e5bba3d6d --- /dev/null +++ b/Packages/TableProCore/Tests/TableProSQLGrammarTests/SQLStatementBatchTests.swift @@ -0,0 +1,54 @@ +import Foundation +import TableProSQLGrammar +import Testing + +@Suite("SQL statement batches") +struct SQLStatementBatchTests { + private static let sqlServer = SQLLexicalReadings.resolve( + databaseTypeId: "SQL Server", + declared: nil, + session: nil + ).execution + + private func batches(_ text: String, sourceOffset: Int = 0) -> [SQLStatementBatch] { + SQLStatementScanner.batches( + in: text, + statements: SQLStatementScanner.executableStatements(in: text, grammar: Self.sqlServer), + separators: SQLStatementScanner.batchSeparators(in: text, grammar: Self.sqlServer), + sourceOffset: sourceOffset + ) + } + + @Test("A script with no GO line is one batch holding every statement and the text between them") + func noSeparatorIsOneBatch() throws { + let text = "DECLARE @n int = 1;\nSELECT @n;" + let result = batches(text) + #expect(result.count == 1) + let batch = try #require(result.first) + #expect(batch.statements.map(\.sql) == ["DECLARE @n int = 1", "SELECT @n"]) + #expect(batch.sql == "DECLARE @n int = 1;\nSELECT @n") + #expect(batch.repeatCount == 1) + } + + @Test("GO lines end batches, and a GO count belongs to the batch before it") + func separatorsSplitAndRepeat() { + let text = "CREATE TABLE t (id int)\nGO\nINSERT INTO t VALUES (1)\nGO 3\nSELECT * FROM t" + let result = batches(text) + #expect(result.map(\.sql) == ["CREATE TABLE t (id int)", "INSERT INTO t VALUES (1)", "SELECT * FROM t"]) + #expect(result.map(\.repeatCount) == [1, 3, 1]) + } + + @Test("A GO line with nothing before it makes no empty batch") + func leadingSeparatorMakesNoBatch() { + let result = batches("GO\nSELECT 1\nGO\nGO\n") + #expect(result.map(\.sql) == ["SELECT 1"]) + } + + @Test("Statements and the batch range move by the source offset") + func sourceOffsetShiftsRanges() throws { + let text = "SELECT 1;\nSELECT 2" + let batch = try #require(batches(text, sourceOffset: 100).first) + #expect(batch.statements.map(\.range.location) == [100, 110]) + #expect(batch.range == NSRange(location: 100, length: 18)) + } +} diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index 92f70c34f5..a28dad43c4 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -894,6 +894,9 @@ public final class OracleCoreConnection: @unchecked Sendable { continuation.yield(.rows([decoder.decode(row)])) } await warnOnce(about: decoder.unsupportedTypeNames) + + guard columns.isEmpty else { return } + continuation.yield(.affectedRows(try await stream.affectedRows)) } // MARK: - Session Time Zones diff --git a/Packages/TableProOracle/Sources/TableProOracleCore/OracleRawResult.swift b/Packages/TableProOracle/Sources/TableProOracleCore/OracleRawResult.swift index 683edfc256..0c6906ccee 100644 --- a/Packages/TableProOracle/Sources/TableProOracleCore/OracleRawResult.swift +++ b/Packages/TableProOracle/Sources/TableProOracleCore/OracleRawResult.swift @@ -46,4 +46,7 @@ public struct OracleRawResult: Sendable { public enum OracleStreamElement: Sendable { case header(columns: [OracleColumnDescriptor]) case rows([[OracleRawCell]]) + /// Sent last, and only for a statement with no result columns: Oracle reports the count once the statement + /// has completed, so a write, DDL or PL/SQL block would otherwise stream nothing after its empty header. + case affectedRows(Int) } diff --git a/Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift b/Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift index 7dc612d2cd..f37874f1dd 100644 --- a/Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift +++ b/Plugins/LibSQLDriverPlugin/SQLiteLocalBackend.swift @@ -116,6 +116,7 @@ actor SQLiteLocalBackend { try bind(parameters, to: statement, db: db) + let totalChangesBefore = sqlite3_total_changes64(db) let firstStep = runtime.stepFirst(statement) let columnCount = firstStep.count let columns = firstStep.names @@ -139,8 +140,9 @@ actor SQLiteLocalBackend { try validateCompletion(stepResult, on: db) } - if columns.isEmpty { - rowsAffected = Int(sqlite3_changes(db)) + // `sqlite3_changes64` keeps the last write's count, so a statement that wrote nothing would repeat it. + if columns.isEmpty, sqlite3_total_changes64(db) != totalChangesBefore { + rowsAffected = Int(sqlite3_changes64(db)) } return LibSQLLocalRawResult( diff --git a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift index 11ba1533a1..b3f687458b 100644 --- a/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift +++ b/Plugins/MySQLDriverPlugin/MariaDBPluginConnection.swift @@ -919,6 +919,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if fieldCount == 0 { let affected = mysql_affected_rows(mysql) let insertId = mysql_insert_id(mysql) + try consumeTrailingResults(on: mysql, generation: generation) return MariaDBPluginQueryResult( columns: [], columnTypes: [], columnTypeNames: [], rows: [], affectedRows: affected, insertId: insertId, isTruncated: false, @@ -952,6 +953,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if cancellationGate.isCancelled(generation) { while mysql_fetch_row(resultPtr) != nil {} mysql_free_result(resultPtr) + discardTrailingResults(on: mysql, generation: generation) throw CancellationError() } @@ -982,6 +984,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if cancellationGate.isCancelled(generation) { mysql_free_result(resultPtr) + discardTrailingResults(on: mysql, generation: generation) throw CancellationError() } @@ -997,6 +1000,13 @@ final class MariaDBPluginConnection: @unchecked Sendable { } mysql_free_result(resultPtr) + do { + try consumeTrailingResults(on: mysql, generation: generation) + } catch let error as MariaDBPluginError where isExpectedInterruption( + errno: error.code, message: error.message, wasTruncated: outcome.serverIgnoredLimit + ) { + // The row cap's own kill stopped the routine after its first result set, the truncation already reported. + } if sessionFlavor.isDatabend, let affected = DatabendResultShape.affectedRowCount(columns: columns.names, rows: rows) { return MariaDBPluginQueryResult( @@ -1277,6 +1287,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if fieldCount == 0 { let affected = mysql_stmt_affected_rows(stmt) let insertId = mysql_stmt_insert_id(stmt) + try consumeTrailingResults(of: stmt, generation: generation) return MariaDBPluginQueryResult( columns: [], columnTypes: [], columnTypeNames: [], rows: [], affectedRows: UInt64(affected), insertId: UInt64(insertId), isTruncated: false, @@ -1306,6 +1317,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { from: stmt, metadata: metadata, columns: columns, rowCap: rowCap, generation: generation, sentAt: sentAt ) + try consumeTrailingResults(of: stmt, generation: generation) return MariaDBPluginQueryResult( columns: columns.names, columnTypes: columns.typeCodes, columnTypeNames: columns.typeNames, @@ -1365,6 +1377,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { if resultPtr == nil { guard mysql_field_count(mysql) == 0 else { throw noting(getError(), generation: generation) } + try consumeTrailingResults(on: mysql, generation: generation) return } @@ -1393,6 +1406,7 @@ final class MariaDBPluginConnection: @unchecked Sendable { killStatementOnServer(on: mysql, generation: generation) while mysql_fetch_row(resultPtr) != nil {} mysql_free_result(resultPtr) + discardTrailingResults(on: mysql, generation: generation) throw CancellationError() } @@ -1413,6 +1427,43 @@ final class MariaDBPluginConnection: @unchecked Sendable { } mysql_free_result(resultPtr) + try consumeTrailingResults(on: mysql, generation: generation) + } + + // MARK: - Trailing Results + + /// The first result set is the statement's answer. A later one, such as a `CALL` that selects twice, is read and + /// dropped because the session refuses the next statement with `2014` until it is, and an error in one fails this one. + private func consumeTrailingResults(on mysql: UnsafeMutablePointer, generation: Int) throws { + while mysql_more_results(mysql) != 0 { + let status = mysql_next_result(mysql) + if status > 0 { throw noting(readError(from: mysql), generation: generation) } + if status < 0 { return } + guard let result = mysql_use_result(mysql) else { + if mysql_field_count(mysql) != 0 { throw noting(readError(from: mysql), generation: generation) } + continue + } + while mysql_fetch_row(result) != nil {} + let failure = mysql_errno(mysql) != 0 ? noting(readError(from: mysql), generation: generation) : nil + mysql_free_result(result) + if let failure { throw failure } + } + } + + /// A stopped statement answers with the stop, so these are read only to free the session. A kill's `1317` among + /// them is still noted, so the latch does not absorb that kill a second time. + private func discardTrailingResults(on mysql: UnsafeMutablePointer, generation: Int) { + try? consumeTrailingResults(on: mysql, generation: generation) + } + + /// `mysql_stmt_close` reads these on its own but drops an error among them, so they are read here first. + /// `mysql_stmt_next_result` skips any rows a capped fetch left unread. + private func consumeTrailingResults(of stmt: UnsafeMutablePointer, generation: Int) throws { + while mysql_stmt_more_results(stmt) != 0 { + let status = mysql_stmt_next_result(stmt) + if status > 0 { throw noting(getStmtError(stmt), generation: generation) } + if status < 0 { return } + } } // MARK: - Server Information diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index a74cf19263..968c34d4a2 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -458,6 +458,10 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { ))) case .rows(let batch): continuation.yield(.rows(batch.map { $0.map(\.asPluginCell) })) + case .affectedRows: + // The plugin stream carries rows only. The app sends writes and DDL through + // `execute`, which reports the count, and bounds only reads through this stream. + break } } continuation.finish() diff --git a/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift b/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift index ec020be93c..69c3925531 100644 --- a/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift +++ b/Plugins/SQLiteDriverPlugin/SQLiteExecutionBackend.swift @@ -268,6 +268,7 @@ actor SQLiteLocalBackend: SQLiteExecutionBackend { try bind(parameters, to: statement, db: db) } + let totalChangesBefore = sqlite3_total_changes64(db) let firstStep = SQLiteResultColumns.stepFirst(statement) let columnCount = firstStep.count let columns = firstStep.names @@ -289,7 +290,7 @@ actor SQLiteLocalBackend: SQLiteExecutionBackend { throw SQLitePluginError.queryFailed(String(cString: sqlite3_errmsg(db))) } - let rowsAffected = columns.isEmpty ? Int(sqlite3_changes(db)) : 0 + let rowsAffected = columns.isEmpty ? Self.changes(of: db, sinceTotal: totalChangesBefore) : 0 return SQLiteRawResult( columns: columns, columnTypeNames: columnTypeNames, @@ -300,6 +301,13 @@ actor SQLiteLocalBackend: SQLiteExecutionBackend { ) } + /// `sqlite3_changes64` keeps the count of the last INSERT, UPDATE or DELETE, so a statement that + /// wrote nothing, DDL included, would report it again. The total moves only when this one wrote. + private static func changes(of db: OpaquePointer, sinceTotal totalBefore: Int64) -> Int { + guard sqlite3_total_changes64(db) != totalBefore else { return 0 } + return Int(sqlite3_changes64(db)) + } + func streamQuery( _ query: String, continuation: AsyncThrowingStream.Continuation diff --git a/TablePro/Core/RemoteSQLite/RemoteSQLiteAgentSource.swift b/TablePro/Core/RemoteSQLite/RemoteSQLiteAgentSource.swift index 88e2b025da..6f6f443380 100644 --- a/TablePro/Core/RemoteSQLite/RemoteSQLiteAgentSource.swift +++ b/TablePro/Core/RemoteSQLite/RemoteSQLiteAgentSource.swift @@ -139,6 +139,7 @@ def configure(lib): lib.sqlite3_column_text.argtypes = [p, ctypes.c_int] lib.sqlite3_column_text.restype = p lib.sqlite3_changes.argtypes = [p] + lib.sqlite3_total_changes.argtypes = [p] lib.sqlite3_finalize.argtypes = [p] lib.sqlite3_reset.argtypes = [p] lib.sqlite3_interrupt.argtypes = [p] @@ -331,6 +332,7 @@ def execute(lib, db, sql, parameters, row_cap): header += bytes([0]) send(OP_HEADER, header) + total_changes_before = lib.sqlite3_total_changes(db) batch = [] batch_bytes = 0 row_count = 0 @@ -356,7 +358,9 @@ def execute(lib, db, sql, parameters, row_cap): if step != SQLITE_ROW and step != SQLITE_DONE and not truncated: send(OP_ERROR, struct.pack(">I", step & 0xFFFFFFFF) + encode_str(lib.sqlite3_errmsg(db).decode("utf-8", "replace"))) return - changes = lib.sqlite3_changes(db) if column_count == 0 else 0 + # sqlite3_changes keeps the last write's count, so a statement that wrote nothing would repeat it. + wrote = lib.sqlite3_total_changes(db) != total_changes_before + changes = lib.sqlite3_changes(db) if column_count == 0 and wrote else 0 send(OP_DONE, struct.pack(">q", changes) + bytes([1 if truncated else 0])) finally: lib.sqlite3_finalize(stmt) diff --git a/TablePro/Core/Services/Execution/ExecutableBatch.swift b/TablePro/Core/Services/Execution/ExecutableBatch.swift index 1d81673d2a..eacca11002 100644 --- a/TablePro/Core/Services/Execution/ExecutableBatch.swift +++ b/TablePro/Core/Services/Execution/ExecutableBatch.swift @@ -6,29 +6,10 @@ import Foundation import TableProSQLGrammar -/// The statements the server receives in one request, and the text they are sent as. -/// -/// SQL Server scopes a local variable, a table variable and a `TRY...CATCH` to one batch, and its own tools cut a script -/// into batches only at a line holding `GO`. A text with no such line is one batch, and only a driver that sends -/// batches whole runs it as one: every other run takes its statements one by one. -struct ExecutableBatch: Sendable { - /// The source between the first statement's start and the last one's end, inner `;` and comments included. - let sql: String - - /// The statements inside, in tab coordinates, as the statement scanner found them. - let statements: [SQLStatementScanner.ExecutableStatement] - - /// How many times the script asks for the batch to run: `GO 5` runs it five times. - let repeatCount: Int - - /// The batch's span in tab coordinates. - var range: NSRange { - guard let first = statements.first, let last = statements.last else { - return NSRange(location: NSNotFound, length: 0) - } - return NSRange(location: first.range.location, length: last.range.upperBound - first.range.location) - } +/// Defined with the scanner because the iOS editor groups SQL Server batches by the same rules. +typealias ExecutableBatch = SQLStatementBatch +extension SQLStatementBatch { /// Every statement the server ran for the batch, in the order it ran them: `GO 3` runs all of them three /// times, and a rename chain run twice leaves each table where it started. var executedStatementTexts: [String] { @@ -70,34 +51,7 @@ enum QueryBatchPlanner { separators: [SQLBatchSeparator], sourceOffset: Int ) -> [ExecutableBatch] { - let source = text as NSString - var batches: [ExecutableBatch] = [] - var pending: [SQLStatementScanner.ExecutableStatement] = [] - var remaining = statements[...] - - func close(repeatCount: Int) { - guard let first = pending.first, let last = pending.last else { return } - let span = NSRange(location: first.range.location, length: last.range.upperBound - first.range.location) - batches.append( - ExecutableBatch( - sql: source.substring(with: span), - statements: pending.map { $0.offset(by: sourceOffset) }, - repeatCount: repeatCount - ) - ) - pending = [] - } - - for separator in separators { - while let next = remaining.first, next.range.location < separator.range.location { - pending.append(next) - remaining = remaining.dropFirst() - } - close(repeatCount: separator.repeatCount) - } - pending.append(contentsOf: remaining) - close(repeatCount: 1) - return batches + SQLStatementScanner.batches(in: text, statements: statements, separators: separators, sourceOffset: sourceOffset) } } diff --git a/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Streaming.swift b/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Streaming.swift index 44f9b748f8..75e8b0386f 100644 --- a/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Streaming.swift +++ b/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Streaming.swift @@ -1,5 +1,6 @@ import CDuckDB import Foundation +import os import TableProDatabase import TableProModels @@ -10,6 +11,26 @@ nonisolated struct DuckDBStreamColumn: Sendable { let castToText: Bool } +nonisolated enum DuckDBBeginStreamResult: Sendable { + case rowSet([DuckDBStreamColumn], stream: UInt64) + /// The statement cannot be read as a subquery (DDL, DML, PRAGMA), so it has no columns to plan and runs whole. + case materialize +} + +/// Carries a stream's generation from its producer, which learns it on the actor, to its consumer's termination +/// handler, which runs on whatever thread cancels the stream. +nonisolated struct DuckDBStreamToken: Sendable { + private let generation = OSAllocatedUnfairLock(initialState: nil) + + var stream: UInt64? { + generation.withLock { $0 } + } + + func assign(_ stream: UInt64) { + generation.withLock { $0 = stream } + } +} + nonisolated extension DuckDBDriver { private static func yieldMaterialized( query: String, @@ -41,86 +62,109 @@ nonisolated extension DuckDBDriver { func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { let actor = self.actor + let interruptHandle = actor.interruptHandle + let token = DuckDBStreamToken() return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in let task = Task { + var stream: UInt64? do { - let columns: [DuckDBStreamColumn] - do { - columns = try await actor.beginStream(query: query) - } catch { + switch try await actor.beginStream(query: query, token: token) { + case .materialize: + try Task.checkCancellation() try await Self.yieldMaterialized(query: query, options: options, actor: actor, continuation: continuation) - continuation.finish() - return + case .rowSet(let columns, let id): + stream = id + continuation.yield(.columns(columns.map { + ColumnInfo(name: $0.name, typeName: $0.typeName, ordinalPosition: 0) + })) + try await Self.yieldRows(stream: id, options: options, actor: actor, continuation: continuation) + await actor.endStream(id) } - continuation.yield(.columns(columns.map { - ColumnInfo(name: $0.name, typeName: $0.typeName, ordinalPosition: 0) - })) - - var emitted = 0 - rows: while !Task.isCancelled, emitted < options.maxRows { - guard let chunk = await actor.fetchStreamChunk(options: options) else { break } - for cells in chunk { - if emitted >= options.maxRows { - continuation.yield(.truncated(reason: .rowCap(options.maxRows))) - break rows - } - continuation.yield(.row(Row(cells: cells))) - emitted += 1 - } + continuation.finish() + } catch { + if let stream { + await actor.endStream(stream) } - - if Task.isCancelled { + // A cancelled consumer interrupts the query, so the interrupt error is the cancel itself. + if error is CancellationError || Task.isCancelled { continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + } else { + continuation.finish(throwing: error) } - await actor.endStream() - continuation.finish() - } catch is CancellationError { - await actor.endStream() - continuation.yield(.truncated(reason: .cancelled)) - continuation.finish() - } catch { - await actor.endStream() - continuation.finish(throwing: error) } } continuation.onTermination = { reason in task.cancel() - if case .cancelled = reason { - Task { await actor.interrupt() } + if case .cancelled = reason, let stream = token.stream { + interruptHandle.interrupt(stream: stream) + } + } + } + } + + private static func yieldRows( + stream: UInt64, + options: StreamOptions, + actor: DuckDBActor, + continuation: AsyncThrowingStream.Continuation + ) async throws { + var emitted = 0 + // The cap is checked against the next row rather than the count, so a result that fills the cap at a chunk + // boundary still reads one more chunk to learn whether rows were left out. + rows: while !Task.isCancelled { + guard let chunk = try await actor.fetchStreamChunk(stream: stream, options: options) else { break } + for cells in chunk { + if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + break rows } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 } } + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + } } } extension DuckDBActor { - func beginStream(query: String) throws -> [DuckDBStreamColumn] { + /// A failure before the query runs falls back to the materialized path, which reports the statement's own error. + /// A failure while it runs is thrown: running it again materialized would repeat the work, an interrupted query + /// included. + func beginStream(query: String, token: DuckDBStreamToken) throws -> DuckDBBeginStreamResult { guard let connection else { throw DuckDBDriverError.notConnected } - endStream() + // Stop can land while the stream waits for the actor, and an interrupt then reaches nothing. + try Task.checkCancellation() + closeStreamResult() - let plan = try planColumns(connection: connection, query: query) + guard let plan = try? planColumns(connection: connection, query: query) else { return .materialize } let streamQuery = plan.contains(where: { $0.castToText }) ? Self.castedQuery(originalQuery: query, columns: plan) : query var stmt: duckdb_prepared_statement? guard duckdb_prepare(connection, streamQuery, &stmt) != DuckDBError, let preparedStmt = stmt else { - let message = stmt.flatMap { duckdb_prepare_error($0).map { String(cString: $0) } } ?? "Failed to prepare query" duckdb_destroy_prepare(&stmt) - throw DuckDBDriverError.queryFailed(message) + return .materialize } defer { duckdb_destroy_prepare(&stmt) } var pending: duckdb_pending_result? guard duckdb_pending_prepared_streaming(preparedStmt, &pending) != DuckDBError, pending != nil else { - let message = pending.flatMap { duckdb_pending_error($0).map { String(cString: $0) } } ?? "Failed to start streaming" duckdb_destroy_pending(&pending) - throw DuckDBDriverError.queryFailed(message) + return .materialize } defer { duckdb_destroy_pending(&pending) } + streamGeneration &+= 1 + let stream = streamGeneration + token.assign(stream) + var result = duckdb_result() - guard duckdb_execute_pending(pending, &result) != DuckDBError else { + let state = try running(stream) { duckdb_execute_pending(pending, &result) } + guard state != DuckDBError else { let message = duckdb_result_error(&result).map { String(cString: $0) } ?? "Failed to execute query" duckdb_destroy_result(&result) throw DuckDBDriverError.queryFailed(message) @@ -128,13 +172,21 @@ extension DuckDBActor { streamResult = result streamColumns = plan - return plan + return .rowSet(plan, stream: stream) } - func fetchStreamChunk(options: StreamOptions) -> [[Cell]]? { - guard let result = streamResult else { return nil } - var chunk = duckdb_stream_fetch_chunk(result) - guard chunk != nil else { return nil } + /// A stream whose result is gone, replaced by a later `beginStream`, closed by another statement or destroyed by + /// a close, ends as cancelled rather than reading rows that belong to another statement. + func fetchStreamChunk(stream: UInt64, options: StreamOptions) throws -> [[Cell]]? { + guard stream == streamGeneration, var result = streamResult else { throw CancellationError() } + var chunk = try running(stream) { duckdb_stream_fetch_chunk(result) } + guard chunk != nil else { + // A failed fetch returns no chunk either; only the result's error tells it from the last chunk. + if let message = duckdb_result_error(&result) { + throw DuckDBDriverError.queryFailed(String(cString: message)) + } + return nil + } defer { duckdb_destroy_data_chunk(&chunk) } let rowCount = duckdb_data_chunk_get_size(chunk) @@ -164,7 +216,12 @@ extension DuckDBActor { return rows } - func endStream() { + func endStream(_ stream: UInt64) { + guard stream == streamGeneration else { return } + closeStreamResult() + } + + func closeStreamResult() { if var result = streamResult { duckdb_destroy_result(&result) streamResult = nil @@ -172,6 +229,15 @@ extension DuckDBActor { streamColumns = [] } + /// Marks `stream` as the running query for the length of `body`, so its consumer's cancel can interrupt it. The + /// cancel check follows the mark, so a cancel either finds the mark or is caught here. + private func running(_ stream: UInt64, _ body: () -> T) throws -> T { + interruptHandle.enter(stream) + defer { interruptHandle.leave(stream) } + try Task.checkCancellation() + return body() + } + // MARK: - Column Planning private func planColumns(connection: duckdb_connection, query: String) throws -> [DuckDBStreamColumn] { diff --git a/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift b/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift index caa5b89cae..65e0e3081e 100644 --- a/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift @@ -11,7 +11,6 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { private let fileAccess = LocalDatabaseFileAccess() private let stateLock = NSLock() private var currentSchemaName = "main" - nonisolated(unsafe) private var interruptHandle: duckdb_connection? var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } @@ -41,11 +40,9 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { } try? await actor.query("SET autoinstall_known_extensions=false") try? await actor.query("SET autoload_known_extensions=false") - setInterruptHandle(await actor.connectionHandle.connection) } func disconnect() async throws { - setInterruptHandle(nil) await actor.close() fileAccess.end() } @@ -75,10 +72,9 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { ) } + /// Runs off the actor: the actor is busy inside the query for as long as it runs. func cancelCurrentQuery() async throws { - let handle = stateLock.withLock { interruptHandle } - guard let handle else { return } - duckdb_interrupt(handle) + actor.interruptHandle.interrupt() } // MARK: - Transactions @@ -109,21 +105,59 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { currentSchemaName = schema stateLock.unlock() } +} - private func setInterruptHandle(_ handle: duckdb_connection?) { - stateLock.lock() - interruptHandle = handle - stateLock.unlock() +// MARK: - Interrupt Handle + +/// The open connection, reachable without the actor so a cancel can interrupt the query the actor is running. +/// `duckdb_interrupt` on a disconnected connection reads freed memory, so the interrupt and the detach that precedes +/// `duckdb_disconnect` share one lock. +nonisolated final class DuckDBInterruptHandle: @unchecked Sendable { + private let lock = NSLock() + private var connection: duckdb_connection? + private var runningStream: UInt64? + + func attach(_ connection: duckdb_connection) { + lock.withLock { self.connection = connection } } -} -// MARK: - DuckDB Actor (thread-safe C API access) + func detach() { + lock.withLock { + connection = nil + runningStream = nil + } + } + + /// The user's Stop, which ends whatever the connection is running. + func interrupt() { + lock.withLock { + guard let connection else { return } + duckdb_interrupt(connection) + } + } -/// Hands the open connection pointer out of the actor so a cancel can interrupt the running query. -nonisolated struct DuckDBInterruptHandle: @unchecked Sendable { - let connection: duckdb_connection? + /// The editor and the data browser share this connection, so a stream's consumer interrupts only while that + /// stream's own query is the one running. + func interrupt(stream: UInt64) { + lock.withLock { + guard runningStream == stream, let connection else { return } + duckdb_interrupt(connection) + } + } + + func enter(_ stream: UInt64) { + lock.withLock { runningStream = stream } + } + + func leave(_ stream: UInt64) { + lock.withLock { + if runningStream == stream { runningStream = nil } + } + } } +// MARK: - DuckDB Actor (thread-safe C API access) + actor DuckDBActor { static let maxRows = 100_000 @@ -131,18 +165,15 @@ actor DuckDBActor { nonisolated var unownedExecutor: UnownedSerialExecutor { queue.asUnownedSerialExecutor() } + nonisolated let interruptHandle = DuckDBInterruptHandle() private var database: duckdb_database? var connection: duckdb_connection? + /// One result streams at a time. Each `beginStream` gets a new generation, so the producer of a stream that a + /// later one replaced cannot fetch from or destroy the later result. var streamResult: duckdb_result? var streamColumns: [DuckDBStreamColumn] = [] - - var connectionHandle: DuckDBInterruptHandle { DuckDBInterruptHandle(connection: connection) } - - func interrupt() { - guard let connection else { return } - duckdb_interrupt(connection) - } + var streamGeneration: UInt64 = 0 func open(path: String) throws { var db: duckdb_database? @@ -172,9 +203,12 @@ actor DuckDBActor { database = db connection = conn + if let conn { interruptHandle.attach(conn) } } func close() { + interruptHandle.detach() + closeStreamResult() if connection != nil { duckdb_disconnect(&connection) connection = nil @@ -187,6 +221,9 @@ actor DuckDBActor { func query(_ sql: String) throws -> DuckDBRawResult { guard let connection else { throw DuckDBDriverError.notConnected } + // Another statement closes an open streaming result inside DuckDB, and its next fetch would fail with an + // internal error. Closing it here ends that stream as cancelled. + closeStreamResult() let startTime = Date() var result = duckdb_result() diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 2630e12820..1c387ffae3 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -28,6 +28,7 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { var supportsSchemas: Bool { true } var supportsTransactions: Bool { true } + var sendsBatchesWhole: Bool { true } nonisolated(unsafe) private(set) var currentSchema: String? = "dbo" nonisolated(unsafe) private(set) var serverVersion: String? diff --git a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index 324041f9ae..3a0ea0c899 100644 --- a/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -6,6 +6,7 @@ import TableProDatabase import TableProModels import TableProMSSQLCore import TableProPluginKit +import TableProSQLGrammar nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { private let actor = MySQLActor() @@ -147,20 +148,21 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { let actor = self.actor return AsyncThrowingStream { continuation in let task = Task { + var openStream: MySQLStreamID? do { - let beginResult = try await actor.beginStream(query: query) - switch beginResult { + switch try await actor.beginStream(query: query) { case .noResult(let affectedRows): if affectedRows != 0 { continuation.yield(.rowsAffected(affectedRows)) } - continuation.finish() - return - case .rowSet(let columns): + case .rowSet(let stream, let columns): + openStream = stream continuation.yield(.columns(columns)) var emitted = 0 while !Task.isCancelled, emitted < options.maxRows { - guard let cells = await actor.fetchNextRow(options: options, columns: columns) else { + guard let cells = try await actor.fetchNextRow( + stream, options: options, columns: columns + ) else { break } continuation.yield(.row(Row(cells: cells))) @@ -171,15 +173,17 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } else if emitted >= options.maxRows { continuation.yield(.truncated(reason: .rowCap(options.maxRows))) } - await actor.endStream() - continuation.finish() + openStream = nil + try await actor.endStream(stream) } + continuation.finish() } catch is CancellationError { - await actor.endStream() + if let openStream { try? await actor.endStream(openStream) } continuation.yield(.truncated(reason: .cancelled)) continuation.finish() } catch { - await actor.endStream() + // The statement already failed with `error`; releasing its result cannot add a second one. + if let openStream { try? await actor.endStream(openStream) } continuation.finish(throwing: error) } } @@ -356,6 +360,12 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { func sessionTransactionState() async -> DriverTransactionState { await actor.transactionState() } + + /// Databend reports no `NO_BACKSLASH_ESCAPES` flag, so its session says nothing. + func sessionLexicalFacts() async -> SQLSessionLexicalFacts? { + guard !Self.serverFlavor(for: databaseType, banner: serverVersion).isDatabend else { return nil } + return await actor.lexicalFacts() + } } nonisolated enum MySQLSessionTransaction { @@ -384,7 +394,7 @@ private actor MySQLActor { self.encoding = encoding errorLanguage = nil showTextIsBinary = false - if let mysql { mysql_close(mysql); self.mysql = nil } + close() guard let handle = mysql_init(nil) else { throw MySQLError.connectionFailed("Failed to initialize MySQL client") @@ -466,10 +476,16 @@ private actor MySQLActor { } func close() { - if let mysql { - mysql_close(mysql) - self.mysql = nil + guard let mysql else { return } + if let result = streamingResult { + // An open result points at the handle `mysql_close` frees, so it goes first; shutting the socket + // makes `mysql_free_result` stop reading the rows it would otherwise skip one by one. + mariadb_cancel(mysql) + mysql_free_result(result) + clearStream() } + mysql_close(mysql) + self.mysql = nil } func ping() throws -> Bool { @@ -518,6 +534,25 @@ private actor MySQLActor { return MySQLSessionTransaction.state(infoResult: infoResult, serverStatus: serverStatus) } + /// `NO_BACKSLASH_ESCAPES` from the status flags of the last reply turns the backslash off in both string quotes. + /// With it off, a double-quoted token is a string or an identifier depending on `ANSI_QUOTES`, which MySQL reports + /// nowhere, so only the single quote is settled then. + func lexicalFacts() -> SQLSessionLexicalFacts? { + guard let mysql else { return nil } + var serverStatus: UInt32 = 0 + guard mariadb_get_info(mysql, MARIADB_CONNECTION_SERVER_STATUS, &serverStatus) == 0 else { return nil } + guard serverStatus & UInt32(SERVER_STATUS_NO_BACKSLASH_ESCAPES) != 0 else { + return SQLSessionLexicalFacts( + determined: .backslashEscapesInSingleQuotes, + enabled: .backslashEscapesInSingleQuotes + ) + } + return SQLSessionLexicalFacts( + determined: [.backslashEscapesInSingleQuotes, .backslashEscapesInDoubleQuotes], + enabled: [] + ) + } + func execute(_ query: String) throws -> RawMySQLResult { guard let mysql else { throw MySQLError.notConnected } @@ -533,12 +568,12 @@ private actor MySQLActor { } let raw = mysql_affected_rows(mysql) let affected = raw == .max ? 0 : Int(clamping: raw) + try consumeTrailingResults(of: mysql) return RawMySQLResult( columns: [], columnTypes: [], rows: [], rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false ) } - defer { mysql_free_result(result) } let fieldCount = Int(mysql_num_fields(result)) let described = MariaDBCharacterSet.describeColumns( @@ -562,6 +597,7 @@ private actor MySQLActor { return UnsafeRawBufferPointer(start: value, count: Int(clamping: lengths?[index] ?? 0)) }.map(legacyText)) } + mysql_free_result(result) let isTruncated = rows.count >= maxRows let affected: Int @@ -571,6 +607,7 @@ private actor MySQLActor { } else { affected = 0 } + try consumeTrailingResults(of: mysql) return RawMySQLResult( columns: columns, columnTypes: columnTypes, rows: rows, rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: isTruncated @@ -579,15 +616,19 @@ private actor MySQLActor { // MARK: - Streaming + private static let logger = Logger(subsystem: "com.TablePro", category: "MySQLDriver") + + /// `streamingResult`, while set, belongs to the stream `streamGeneration` names. private var streamingResult: UnsafeMutablePointer? + private var streamGeneration: UInt64 = 0 private var streamingColumns: [ColumnInfo] = [] private var streamingDecoding = MySQLResultColumns() func beginStream(query: String) throws -> MySQLBeginStreamResult { guard let mysql else { throw MySQLError.notConnected } - if streamingResult != nil { - endStream() - } + endLeftoverStream(of: mysql) + streamGeneration &+= 1 + let stream = MySQLStreamID(generation: streamGeneration) guard mysql_real_query(mysql, query, UInt(query.utf8.count)) == 0 else { throw MySQLError.queryFailed(message(from: mysql)) @@ -599,6 +640,7 @@ private actor MySQLActor { } let raw = mysql_affected_rows(mysql) let affected = raw == .max ? 0 : Int(clamping: raw) + try consumeTrailingResults(of: mysql) return .noResult(affectedRows: affected) } @@ -622,12 +664,20 @@ private actor MySQLActor { ) } streamingColumns = columns - return .rowSet(columns) + return .rowSet(stream: stream, columns: columns) } - func fetchNextRow(options: StreamOptions, columns: [ColumnInfo]) -> [Cell]? { - guard let result = streamingResult else { return nil } - guard let row = mysql_fetch_row(result) else { return nil } + /// A stream that a newer `beginStream` or a close has ended stops short, so it reports as cut off rather + /// than complete. + func fetchNextRow(_ stream: MySQLStreamID, options: StreamOptions, columns: [ColumnInfo]) throws -> [Cell]? { + guard let mysql, stream.generation == streamGeneration, let result = streamingResult else { + throw CancellationError() + } + let errorBefore = mysql_errno(mysql) + guard let row = mysql_fetch_row(result) else { + if let failure = fetchFailure(of: mysql, errorBefore: errorBefore) { throw failure } + return nil + } let lengths = mysql_fetch_lengths(result) let values = streamingDecoding.row(encoding: encoding) { index in @@ -655,15 +705,67 @@ private actor MySQLActor { } } - func endStream() { + func endStream(_ stream: MySQLStreamID) throws { + guard let mysql, stream.generation == streamGeneration else { return } + try releaseStream(of: mysql) + } + + /// The producer of a superseded stream only learns that its rows were cut off, so an error in the rest of + /// its statement has no one to reach and is logged. + private func endLeftoverStream(of mysql: UnsafeMutablePointer) { + do { + try releaseStream(of: mysql) + } catch { + Self.logger.warning( + "A superseded statement failed after its stream ended: \(error.localizedDescription, privacy: .private)" + ) + } + } + + private func releaseStream(of mysql: UnsafeMutablePointer) throws { guard let result = streamingResult else { return } - while mysql_fetch_row(result) != nil {} - mysql_free_result(result) + clearStream() + try drain(result, of: mysql) + try consumeTrailingResults(of: mysql) + } + + private func clearStream() { streamingResult = nil streamingColumns = [] streamingDecoding = MySQLResultColumns() } + /// The first result set is the statement's answer, as before. A later one, such as a `CALL` that selects + /// twice, is read and dropped so the session can take the next command, and an error in one fails the statement. + private func consumeTrailingResults(of mysql: UnsafeMutablePointer) throws { + while mysql_more_results(mysql) != 0 { + let status = mysql_next_result(mysql) + if status > 0 { throw MySQLError.queryFailed(message(from: mysql)) } + if status < 0 { return } + if let result = mysql_use_result(mysql) { + try drain(result, of: mysql) + } else if mysql_field_count(mysql) != 0 { + throw MySQLError.queryFailed(message(from: mysql)) + } + } + } + + private func drain(_ result: UnsafeMutablePointer, of mysql: UnsafeMutablePointer) throws { + let errorBefore = mysql_errno(mysql) + while mysql_fetch_row(result) != nil {} + let failure = fetchFailure(of: mysql, errorBefore: errorBefore) + mysql_free_result(result) + if let failure { throw failure } + } + + /// `mysql_fetch_row` returns nil both at the end and on an error, and a command refused while the result + /// was open leaves its own code on the handle, so only a code the fetch set means the rows ended in an error. + private func fetchFailure(of mysql: UnsafeMutablePointer, errorBefore: UInt32) -> MySQLError? { + let code = mysql_errno(mysql) + guard code != 0, code != errorBefore else { return nil } + return .queryFailed(message(from: mysql)) + } + private func makeCellRef(column: String, row: MYSQL_ROW, options: StreamOptions, columns: [ColumnInfo]) -> CellRef? { guard let lazyContext = options.lazyContext, !lazyContext.primaryKeyColumns.isEmpty else { return nil } @@ -678,8 +780,12 @@ private actor MySQLActor { } } -nonisolated enum MySQLBeginStreamResult: Sendable { - case rowSet([ColumnInfo]) +nonisolated private struct MySQLStreamID: Sendable { + let generation: UInt64 +} + +nonisolated private enum MySQLBeginStreamResult: Sendable { + case rowSet(stream: MySQLStreamID, columns: [ColumnInfo]) case noResult(affectedRows: Int) } diff --git a/TableProMobile/TableProMobile/Drivers/OracleDriver.swift b/TableProMobile/TableProMobile/Drivers/OracleDriver.swift index 128afbe49a..5cbb6a0d9e 100644 --- a/TableProMobile/TableProMobile/Drivers/OracleDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/OracleDriver.swift @@ -152,6 +152,10 @@ nonisolated final class OracleDriver: DatabaseDriver, @unchecked Sendable { continuation.yield(.row(Row(cells: cells))) emitted += 1 } + case .affectedRows(let count): + if count != 0 { + continuation.yield(.rowsAffected(count)) + } } } continuation.finish() diff --git a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index b49d39e05a..74781996a2 100644 --- a/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -4,6 +4,8 @@ import os import TableProDatabase import TableProModels import TableProPluginKit +import TableProQuery +import TableProSQLGrammar nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { static let logger = Logger(subsystem: "com.TablePro", category: "PostgreSQLDriver") @@ -114,32 +116,31 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { ) } + /// Runs without the actor, which stays blocked in libpq for as long as the statement runs. func cancelCurrentQuery() async throws { - await actor.cancel() + actor.cancelHandle.cancelInFlight() } + /// The query id is taken before the producer starts, so `onTermination` always knows which query + /// is this stream's, and a consumer going away cancels that query and never another surface's. func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { let actor = self.actor + let cancelHandle = actor.cancelHandle + let id = cancelHandle.reserveQuery() + let readsOnly = !SQLWriteClassifier.isWriteQuery(query, databaseType: databaseType) return AsyncThrowingStream { continuation in let task = Task { do { - let beginResult = try await actor.beginStream(query: query) - switch beginResult { + switch try await actor.beginStream(query: query, id: id, readsOnly: readsOnly) { case .commandOk(let affectedRows): - if let abandoned = await actor.takeAbandonedCopyError() { - continuation.finish(throwing: abandoned) - return - } if affectedRows != 0 { continuation.yield(.rowsAffected(affectedRows)) } - continuation.finish() - return case .tuples(let columns): continuation.yield(.columns(columns)) var emitted = 0 while !Task.isCancelled, emitted < options.maxRows { - guard let cells = await actor.fetchNextRow(options: options, columns: columns) else { + guard let cells = try await actor.fetchNextRow(stream: id, options: options, columns: columns) else { break } continuation.yield(.row(Row(cells: cells))) @@ -147,29 +148,28 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { } if Task.isCancelled { continuation.yield(.truncated(reason: .cancelled)) + try await actor.endStream(id) } else if emitted >= options.maxRows { continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + try await actor.endStreamAtRowCap(id) + } else { + try await actor.endStream(id) } - await actor.endStream() - if let abandoned = await actor.takeAbandonedCopyError() { - continuation.finish(throwing: abandoned) - return - } - continuation.finish() } + continuation.finish() } catch is CancellationError { - await actor.endStream() + try? await actor.endStream(id) continuation.yield(.truncated(reason: .cancelled)) continuation.finish() } catch { - await actor.endStream() + try? await actor.endStream(id) continuation.finish(throwing: error) } } continuation.onTermination = { reason in task.cancel() if case .cancelled = reason { - Task { await actor.cancel() } + cancelHandle.cancel(id) } } } @@ -250,6 +250,10 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { func sessionTransactionState() async -> DriverTransactionState { await actor.transactionState() } + + func sessionLexicalFacts() async -> SQLSessionLexicalFacts? { + await actor.lexicalFacts() + } } nonisolated enum PostgreSQLSessionTransaction { @@ -268,6 +272,7 @@ nonisolated enum PostgreSQLSessionTransaction { // MARK: - PostgreSQL Actor (thread-safe C API access) private actor PostgreSQLActor { + nonisolated let cancelHandle = PostgreSQLCancelHandle() private var conn: OpaquePointer? private static let connectTimeout: TimeInterval = 15 @@ -280,8 +285,7 @@ private actor PostgreSQLActor { "Port \(port) is out of range. Use a value between 1 and 65535." ) } - // Close existing connection if reconnecting - if let conn { PQfinish(conn); self.conn = nil } + close() let connStr = PostgreSQLConnectionString.build( host: host, @@ -307,6 +311,7 @@ private actor PostgreSQLActor { self.conn = connection adopted = true + cancelHandle.attach(PostgreSQLCancelKey(connection: connection)) } private func pollUntilConnected(_ connection: OpaquePointer) async throws { @@ -352,22 +357,14 @@ private actor PostgreSQLActor { } func close() { + cancelHandle.detach() + discardStream() if let conn { PQfinish(conn) self.conn = nil } } - func cancel() { - guard let conn else { return } - let cancel = PQgetCancel(conn) - if let cancel { - var errbuf = [CChar](repeating: 0, count: 256) - PQcancel(cancel, &errbuf, Int32(errbuf.count)) - PQfreeCancel(cancel) - } - } - func serverVersionNumber() -> Int32 { guard let conn else { return 0 } return PQserverVersion(conn) @@ -378,6 +375,16 @@ private actor PostgreSQLActor { return PostgreSQLSessionTransaction.state(from: PQtransactionStatus(conn)) } + /// libpq keeps the value the server last reported in a `ParameterStatus`, so a `SET` the user ran is already in it. + func lexicalFacts() -> SQLSessionLexicalFacts? { + guard let conn, let reported = PQparameterStatus(conn, "standard_conforming_strings") else { return nil } + let backslashEscapes = String(cString: reported) != "on" + return SQLSessionLexicalFacts( + determined: .backslashEscapesInSingleQuotes, + enabled: backslashEscapes ? .backslashEscapesInSingleQuotes : [] + ) + } + func serverVersion() -> String? { guard let conn else { return nil } let version = PQserverVersion(conn) @@ -394,10 +401,15 @@ private actor PostgreSQLActor { } func execute(_ query: String) throws -> RawPGResult { + endLeftoverStream() guard let conn else { throw PostgreSQLError.notConnected } let start = Date() let cancelsOutput = cancelsAbandonedOutput(conn) + let id = cancelHandle.reserveQuery() + cancelHandle.waitForPendingCancels() + cancelHandle.querySent(id) + defer { cancelHandle.queryFinished(id) } let result = PQexec(conn, query) defer { if result != nil { PQclear(result) } @@ -411,6 +423,7 @@ private actor PostgreSQLActor { } if status == PGRES_FATAL_ERROR { + if cancelHandle.isCancelled(id) { throw CancellationError() } let msg = result.flatMap { String(cString: PQresultErrorMessage($0)) } ?? "Unknown error" throw PostgreSQLError.queryFailed(msg) } @@ -468,92 +481,190 @@ private actor PostgreSQLActor { // MARK: - Streaming - private var pendingResult: OpaquePointer? - private var streamingFinished = true - private var abandonedCopy: LibPQCopy? - private var streamCancelsAbandonedOutput = false + /// One query streams at a time on a connection. Each stream carries its query's id, so the producer + /// of a stream that a later query replaced can neither read nor end the later one. + private var stream: PostgreSQLStreamState? - func beginStream(query: String) throws -> PGBeginStreamResult { + func beginStream(query: String, id: UInt64, readsOnly: Bool) throws -> PGBeginStreamResult { + endLeftoverStream() guard let conn else { throw PostgreSQLError.notConnected } - streamCancelsAbandonedOutput = cancelsAbandonedOutput(conn) - endStream() - abandonedCopy = nil - guard PQsendQuery(conn, query) == 1 else { - throw PostgreSQLError.queryFailed(String(cString: PQerrorMessage(conn))) - } + let cancelsOutput = cancelsAbandonedOutput(conn) + let state = PostgreSQLStreamState( + id: id, + cancelsAbandonedOutput: cancelsOutput, + cancelsUnreadRows: cancelsOutput && readsOnly + ) + try send(query, id: id, on: conn) guard PQsetSingleRowMode(conn) == 1 else { - drainResults() + _ = drain(state) throw PostgreSQLError.queryFailed("Failed to enter single-row streaming mode") } - streamingFinished = false - guard let firstResult = PQgetResult(conn) else { - streamingFinished = true + markDrained(state) return .commandOk(affectedRows: 0) } - let status = PQresultStatus(firstResult) - if let copy = LibPQCopyState.copy(of: firstResult) { - PQclear(firstResult) - drainResults() - abandonedCopy = nil - throw PostgreSQLError.unsupported(copy.direction.unsupportedMessage) - } - switch status { - case PGRES_COMMAND_OK: - let affectedStr = String(cString: PQcmdTuples(firstResult)) - let affected = Int(affectedStr) ?? 0 - PQclear(firstResult) - drainResults() - return .commandOk(affectedRows: affected) + switch PQresultStatus(firstResult) { + case PGRES_SINGLE_TUPLE: + state.pendingResult = firstResult + stream = state + return .tuples(parseColumns(firstResult)) case PGRES_TUPLES_OK: let columns = parseColumns(firstResult) PQclear(firstResult) - drainResults() - return .tuples(columns) - case PGRES_SINGLE_TUPLE: - let columns = parseColumns(firstResult) - pendingResult = firstResult + if let error = drain(state).error { throw error } + stream = state return .tuples(columns) - default: - let msg = String(cString: PQresultErrorMessage(firstResult)) + case PGRES_COMMAND_OK: + let affected = Int(String(cString: PQcmdTuples(firstResult))) ?? 0 PQclear(firstResult) - drainResults() - throw PostgreSQLError.queryFailed(msg) + if let error = drain(state).error { throw error } + return .commandOk(affectedRows: affected) + default: + state.pendingResult = firstResult + if let error = drain(state).error { throw error } + return .commandOk(affectedRows: 0) } } - func fetchNextRow(options: StreamOptions, columns: [ColumnInfo]) -> [Cell]? { - guard !streamingFinished else { return nil } - - let result: OpaquePointer? - if let pending = pendingResult { - result = pending - pendingResult = nil - } else { - guard let conn else { streamingFinished = true; return nil } - result = PQgetResult(conn) + /// A cancel still on its way could reach the server after this query and stop it instead, so the + /// query waits for it. A consumer that leaves as the query goes out finds nothing in flight to + /// cancel, so its cancel is sent here once the query is registered. + private func send(_ query: String, id: UInt64, on conn: OpaquePointer) throws { + cancelHandle.waitForPendingCancels() + try Task.checkCancellation() + guard PQsendQuery(conn, query) == 1 else { + throw PostgreSQLError.queryFailed(String(cString: PQerrorMessage(conn))) + } + cancelHandle.querySent(id) + if Task.isCancelled { + cancelHandle.cancel(id) } + } + + /// Returns nil at the end of the rows. Single-row mode reports a statement that fails after its + /// first rows as an error result in place of the next row, and that error ends the stream. + /// A stream that a later query replaced ends as cancelled. + func fetchNextRow(stream id: UInt64, options: StreamOptions, columns: [ColumnInfo]) throws -> [Cell]? { + guard let state = stream, state.id == id else { throw CancellationError() } + guard !state.isDrained else { return nil } + guard let conn else { throw PostgreSQLError.notConnected } - guard let result else { - streamingFinished = true + let readAhead = state.pendingResult + state.pendingResult = nil + guard let result = readAhead ?? PQgetResult(conn) else { + markDrained(state) return nil } - - let status = PQresultStatus(result) - if status == PGRES_TUPLES_OK { - PQclear(result) - drainResults() + guard PQresultStatus(result) == PGRES_SINGLE_TUPLE else { + state.pendingResult = result + if let error = drain(state).error { throw error } return nil } + defer { PQclear(result) } + return cells(of: result, options: options, columns: columns) + } - guard status == PGRES_SINGLE_TUPLE else { - PQclear(result) - drainResults() - return nil + /// An error after a cancel is the cancel's own, so it ends the stream without a failure. + func endStream(_ id: UInt64) throws { + guard let state = stream, state.id == id else { return } + stream = nil + let end = drain(state) + guard end != .cancelled, let error = end.error else { return } + throw error + } + + func endStreamAtRowCap(_ id: UInt64) throws { + if let state = stream, state.id == id { + cancelUnreadRows(of: state) + } + try endStream(id) + } + + /// Draining rows nobody will read would pull every one of them over the network, so the statement is + /// cancelled first, as the Mac does; not when the rows have all arrived, or when the cancel would abort the + /// open transaction. + private func cancelUnreadRows(of state: PostgreSQLStreamState) { + guard state.cancelsUnreadRows, let conn, rowsRemain(in: state, conn: conn) else { return } + cancelHandle.cancel(state.id) + } + + /// Reads only what has already arrived, so the cap never waits on the server for a row it would + /// discard. A result taken here that is not a row is left for the drain to report. + private func rowsRemain(in state: PostgreSQLStreamState, conn: OpaquePointer) -> Bool { + guard !state.isDrained else { return false } + if let readAhead = state.pendingResult { + return PQresultStatus(readAhead) == PGRES_SINGLE_TUPLE + } + guard PQconsumeInput(conn) == 1 else { return false } + guard PQisBusy(conn) == 0 else { return true } + guard let next = PQgetResult(conn) else { + markDrained(state) + return false + } + guard PQresultStatus(next) == PGRES_SINGLE_TUPLE else { + state.pendingResult = next + return false } + PQclear(next) + return true + } + /// A new query ends the stream still in flight; that stream's producer then finds its id gone. + private func endLeftoverStream() { + guard let leftover = stream else { return } + stream = nil + cancelUnreadRows(of: leftover) + _ = drain(leftover) + } + + private func discardStream() { + guard let state = stream else { return } + stream = nil + if let readAhead = state.pendingResult { PQclear(readAhead) } + state.pendingResult = nil + markDrained(state) + } + + private func markDrained(_ state: PostgreSQLStreamState) { + state.isDrained = true + cancelHandle.queryFinished(state.id) + } + + /// Reads every result the stream's query still has in flight, ending any COPY among them. + private func drain(_ state: PostgreSQLStreamState) -> PostgreSQLStreamEnd { + guard !state.isDrained else { return .completed } + state.isDrained = true + defer { cancelHandle.queryFinished(state.id) } + var readAhead = state.pendingResult + state.pendingResult = nil + guard let conn else { + if let readAhead { PQclear(readAhead) } + return .completed + } + let cancellingOutput = state.cancelsAbandonedOutput + let drained = PostgreSQLStreamDrain.drain( + nextResult: { + guard let result = readAhead ?? PQgetResult(conn) else { return nil } + readAhead = nil + defer { PQclear(result) } + return Self.pendingResult(of: result) + }, + endCopy: { LibPQCopyState.end($0, conn: conn, cancellingOutput: cancellingOutput) }, + afterCancel: cancelHandle.isCancelled(state.id) + ) + dropConnectionIfStuck(drained.outcome) + return drained.end + } + + private static func pendingResult(of result: OpaquePointer) -> PostgreSQLPendingResult { + if let copy = LibPQCopyState.copy(of: result) { return .copy(copy) } + guard PostgreSQLStreamDrain.isFailure(PQresultStatus(result)) else { return .completed } + return .failed(String(cString: PQresultErrorMessage(result))) + } + + private func cells(of result: OpaquePointer, options: StreamOptions, columns: [ColumnInfo]) -> [Cell] { var cells: [Cell] = [] cells.reserveCapacity(columns.count) for c in 0.. PostgreSQLError? { - guard let copy = abandonedCopy else { return nil } - abandonedCopy = nil - return PostgreSQLError.unsupported(copy.direction.unsupportedMessage) - } - private func finishPendingResults(_ conn: OpaquePointer, cancellingOutput: Bool) -> LibPQDrainOutcome { let outcome = LibPQCopyState.finishPendingResults(conn, cancellingOutput: cancellingOutput) - guard let stuck = outcome.stuckInCopy else { return outcome } + dropConnectionIfStuck(outcome) + return outcome + } + + private func dropConnectionIfStuck(_ outcome: LibPQDrainOutcome) { + guard let stuck = outcome.stuckInCopy else { return } Self.logger.fault( "libpq stayed in \(String(describing: stuck.direction), privacy: .public); dropping the connection" ) close() - return outcome } /// Cancelling inside a transaction block aborts it, so the cancel that keeps a `COPY TO STDOUT` @@ -658,6 +748,237 @@ nonisolated enum PGBeginStreamResult: Sendable { case commandOk(affectedRows: Int) } +/// Confined to `PostgreSQLActor`, which owns the connection the stream reads. +nonisolated private final class PostgreSQLStreamState { + let id: UInt64 + /// Read before the query goes out: once it is in flight the status is PQTRANS_ACTIVE. + let cancelsAbandonedOutput: Bool + /// A cancel rolls back the whole statement, so rows nobody will read are cut short only for a read: an + /// `UPDATE ... RETURNING` past the row cap has to finish, or its write is undone. + let cancelsUnreadRows: Bool + /// A result read before its turn: the first row, or what the row-cap check took off the wire. + var pendingResult: OpaquePointer? + var isDrained = false + + init(id: UInt64, cancelsAbandonedOutput: Bool, cancelsUnreadRows: Bool) { + self.id = id + self.cancelsAbandonedOutput = cancelsAbandonedOutput + self.cancelsUnreadRows = cancelsUnreadRows + } +} + +// MARK: - Cancellation + +/// Which query a cancel is for. A connection runs one query at a time, so a cancel is taken only for +/// the query in flight; one that arrives before its query is sent, or after it ends, is dropped and +/// can never mark the next query. +nonisolated struct PostgreSQLQueryCancels: Equatable, Sendable { + private var lastQuery: UInt64 = 0 + private(set) var inFlight: UInt64? + private var cancelled: UInt64? + + mutating func reserve() -> UInt64 { + lastQuery &+= 1 + return lastQuery + } + + mutating func sent(_ query: UInt64) { + inFlight = query + } + + mutating func finished(_ query: UInt64) { + guard inFlight == query else { return } + inFlight = nil + } + + mutating func detach() { + inFlight = nil + } + + /// Returns whether a cancel has to go out: once per query, and only while that query is in flight. + mutating func cancel(_ query: UInt64) -> Bool { + guard inFlight == query, cancelled != query else { return false } + cancelled = query + return true + } + + mutating func cancelInFlight() -> Bool { + guard let inFlight else { return false } + return cancel(inFlight) + } + + func isCancelled(_ query: UInt64) -> Bool { + cancelled == query + } +} + +/// A copy of the backend's identity that holds none of the connection, so `PQcancel` may run on any +/// thread, even while the connection closes. It is freed when its last holder lets go, so a cancel +/// still under way never meets a freed object. +nonisolated private final class PostgreSQLCancelKey: @unchecked Sendable { + private let object: OpaquePointer + + init?(connection: OpaquePointer) { + guard let object = PQgetCancel(connection) else { return nil } + self.object = object + } + + deinit { + PQfreeCancel(object) + } + + func send() { + var errbuf = [CChar](repeating: 0, count: 256) + PQcancel(object, &errbuf, Int32(errbuf.count)) + } +} + +/// Stop has to reach a statement while the actor sits blocked in libpq on it, so the cancel key and +/// the query in flight live here, outside the actor. The lock is never held across a libpq call: +/// `PQcancel` is a network round trip, and holding it there would stall every query start and close. +nonisolated private final class PostgreSQLCancelHandle: @unchecked Sendable { + private static let queue = DispatchQueue( + label: "com.TablePro.mobile.libpq.cancel", + qos: .userInitiated, + attributes: .concurrent + ) + + private let lock = NSLock() + private let sending = DispatchGroup() + private var key: PostgreSQLCancelKey? + private var queries = PostgreSQLQueryCancels() + + func attach(_ key: PostgreSQLCancelKey?) { + lock.withLock { self.key = key } + } + + /// Runs before `PQfinish`. A cancel already under way holds its own reference to the key. + func detach() { + lock.withLock { + key = nil + queries.detach() + } + } + + func reserveQuery() -> UInt64 { + lock.withLock { queries.reserve() } + } + + func querySent(_ query: UInt64) { + lock.withLock { queries.sent(query) } + } + + func queryFinished(_ query: UInt64) { + lock.withLock { queries.finished(query) } + } + + func isCancelled(_ query: UInt64) -> Bool { + lock.withLock { queries.isCancelled(query) } + } + + /// The send is registered in the same critical section that marks the query, so a query that ends + /// right after the mark still finds the cancel pending and waits for it before the next one goes out. + func cancel(_ query: UInt64) { + send(lock.withLock { pendingSend(if: queries.cancel(query)) }) + } + + func cancelInFlight() { + send(lock.withLock { pendingSend(if: queries.cancelInFlight()) }) + } + + private func pendingSend(if accepted: Bool) -> PostgreSQLCancelKey? { + guard accepted, let key else { return nil } + sending.enter() + return key + } + + /// A cancel still on its way could reach the server after the next query and stop that one instead. + func waitForPendingCancels() { + sending.wait() + } + + /// `PQcancel` connects to the server and waits on it, so it never runs on the caller's thread, + /// which can be the main thread. + private func send(_ key: PostgreSQLCancelKey?) { + guard let key else { return } + Self.queue.async { + key.send() + self.sending.leave() + } + } +} + +/// A result read while a stream ends, reduced to what decides how the stream ends. +nonisolated enum PostgreSQLPendingResult: Equatable, Sendable { + case copy(LibPQCopy) + case failed(String) + case completed +} + +nonisolated enum PostgreSQLStreamEnd: Equatable, Sendable { + case completed + case cancelled + case abandonedCopy(LibPQCopyDirection) + case failed(String) + + var error: (any Error)? { + switch self { + case .completed: + return nil + case .cancelled: + return CancellationError() + case .abandonedCopy(let direction): + return PostgreSQLError.unsupported(direction.unsupportedMessage) + case .failed(let message): + return PostgreSQLError.queryFailed(message) + } + } +} + +nonisolated enum PostgreSQLStreamDrain { + /// libpq reports a failed statement as FATAL_ERROR and a reply it could not parse as BAD_RESPONSE. + static func isFailure(_ status: ExecStatusType) -> Bool { + status == PGRES_FATAL_ERROR || status == PGRES_BAD_RESPONSE + } + + /// `LibPQPendingResultDrain` keeps COPY states alone, so a statement that failed behind the first + /// result ended as a success. The first failure is kept here; after a cancel it is that cancel's + /// error, which is asked only once the results are in, since Stop can land while they drain. + /// A COPY wins, because ending it is what provokes an error after it. + static func drain( + nextResult: () -> PostgreSQLPendingResult?, + endCopy: (LibPQCopy) -> Void, + afterCancel: @autoclosure () -> Bool + ) -> (end: PostgreSQLStreamEnd, outcome: LibPQDrainOutcome) { + var firstFailure: String? + let outcome = LibPQPendingResultDrain.drain( + nextResult: { + switch nextResult() { + case .copy(let copy): + return .copy(copy) + case .failed(let message): + if firstFailure == nil { firstFailure = message } + return .completed + case .completed: + return .completed + case nil: + return nil + } + }, + endCopy: endCopy + ) + let end: PostgreSQLStreamEnd + if let copy = outcome.abandonedCopy { + end = .abandonedCopy(copy.direction) + } else if let firstFailure { + end = afterCancel() ? .cancelled : .failed(firstFailure) + } else { + end = .completed + } + return (end: end, outcome: outcome) + } +} + // MARK: - PostgreSQL OID Type Names nonisolated private func pgOidToTypeName(_ oid: UInt32) -> String { diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index e4ac5afb62..ab8bdfc134 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -66,7 +66,7 @@ nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unche func execute(query: String) async throws -> QueryResult { let start = Date() - let args = parseRedisCommand(query) + let args = Self.parseRedisCommand(query) guard !args.isEmpty else { throw RedisError.queryFailed("Empty command") } @@ -175,9 +175,16 @@ nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unche try await actor.command(arguments) } - private func parseRedisCommand(_ input: String) -> [String] { + /// Swift reads "\r\n" as one `Character`, so it is listed whole beside "\r" and "\n". Line breaks + /// separate arguments outside quotes, as they do in the Mac's `RedisArgumentCodec`. + private static let argumentSeparators: Set = [" ", "\t", "\n", "\r", "\r\n"] + + /// A quote opens an argument even when nothing is inside it: `LPUSH l "" x` pushes two values, and dropping the + /// empty one pushed only `x`. + static func parseRedisCommand(_ input: String) -> [String] { var args: [String] = [] var current = "" + var hasArgument = false var inQuote: Character? var escape = false @@ -189,6 +196,7 @@ nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unche } if ch == "\\" { escape = true + hasArgument = true continue } if let q = inQuote { @@ -201,18 +209,21 @@ nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unche } if ch == "\"" || ch == "'" { inQuote = ch + hasArgument = true continue } - if ch == " " || ch == "\t" { - if !current.isEmpty { + if argumentSeparators.contains(ch) { + if hasArgument { args.append(current) current = "" + hasArgument = false } continue } current.append(ch) + hasArgument = true } - if !current.isEmpty { args.append(current) } + if hasArgument { args.append(current) } return args } } diff --git a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift index 0d49e096e4..1cf2833e9b 100644 --- a/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -71,28 +71,28 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { ) } + /// Runs off the actor: the actor is busy inside `sqlite3_step` for as long as the statement runs. func cancelCurrentQuery() async throws { - await actor.interrupt() + actor.interruptHandle.interrupt() } func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { let actor = self.actor + let interruptHandle = actor.interruptHandle return AsyncThrowingStream { continuation in + let stream = SQLiteStreamToken() let task = Task { do { - let beginResult = try await actor.beginStream(query: query) - switch beginResult { + switch try await actor.beginStream(query: query, stream: stream) { case .commandOk(let affectedRows): if affectedRows != 0 { continuation.yield(.rowsAffected(affectedRows)) } - continuation.finish() - return case .rowSet(let columns): continuation.yield(.columns(columns)) var emitted = 0 while !Task.isCancelled, emitted < options.maxRows { - guard let cells = await actor.fetchNextRow(options: options, columns: columns) else { + guard let cells = try await actor.fetchNextRow(stream: stream, options: options, columns: columns) else { break } continuation.yield(.row(Row(cells: cells))) @@ -103,27 +103,33 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { } else if emitted >= options.maxRows { continuation.yield(.truncated(reason: .rowCap(options.maxRows))) } - await actor.endStream() - continuation.finish() + await actor.endStream(stream) } - } catch is CancellationError { - await actor.endStream() - continuation.yield(.truncated(reason: .cancelled)) continuation.finish() } catch { - await actor.endStream() - continuation.finish(throwing: error) + await actor.endStream(stream) + // A cancelled consumer interrupts the step, so the interrupt error is the cancel itself. + if error is CancellationError || Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + } else { + continuation.finish(throwing: error) + } } } continuation.onTermination = { reason in task.cancel() if case .cancelled = reason { - Task { await actor.interrupt() } + interruptHandle.interrupt(stream) } } } } + func sessionTransactionState() async -> DriverTransactionState { + await actor.transactionState() + } + // MARK: - Schema func fetchTables(schema: String?) async throws -> [TableInfo] { @@ -263,9 +269,75 @@ nonisolated final class SQLiteDriver: DatabaseDriver, @unchecked Sendable { } } +// MARK: - Interrupt Handle + +/// Identifies one `executeStreaming` call, so the actor and the interrupt handle act only on the +/// statement that call began. +nonisolated final class SQLiteStreamToken: Sendable {} + +/// The open connection, reachable without the actor so Stop can interrupt a statement the actor is +/// stepping. SQLite forbids `sqlite3_interrupt` on a connection that is closed or might close before +/// the call returns, so every interrupt and the detach that precedes `sqlite3_close` share one lock. +/// +/// An interrupt stops every statement on the connection, and while any statement is active it also +/// stops the next one to start. The data browser and the query editor share a driver, so a stream's +/// consumer interrupts only while its own statement is stepping. +nonisolated final class SQLiteInterruptHandle: @unchecked Sendable { + private let lock = NSLock() + private var db: OpaquePointer? + private var steppingStream: SQLiteStreamToken? + private var interruptedDuringStep = false + + func attach(_ db: OpaquePointer) { + lock.withLock { self.db = db } + } + + func detach() { + lock.withLock { db = nil } + } + + /// The user's Stop, which ends whatever is running. + func interrupt() { + lock.withLock { + guard let db else { return } + if steppingStream != nil { interruptedDuringStep = true } + sqlite3_interrupt(db) + } + } + + /// Between steps the cancelled producer stops by itself, and an interrupt then would reach the + /// next statement on the connection instead. + func interrupt(_ stream: SQLiteStreamToken) { + lock.withLock { + guard let db, steppingStream === stream else { return } + interruptedDuringStep = true + sqlite3_interrupt(db) + } + } + + func beginStep(of stream: SQLiteStreamToken) { + lock.withLock { + steppingStream = stream + interruptedDuringStep = false + } + } + + /// Returns whether an interrupt arrived while the step ran. + func endStep() -> Bool { + lock.withLock { + defer { + steppingStream = nil + interruptedDuringStep = false + } + return interruptedDuringStep + } + } +} + // MARK: - SQLite Actor (thread-safe C API access) private actor SQLiteActor { + nonisolated let interruptHandle = SQLiteInterruptHandle() private var db: OpaquePointer? func open(path: String, flags: Int32) throws { @@ -276,17 +348,25 @@ private actor SQLiteActor { throw SQLiteError.connectionFailed(msg) } sqlite3_busy_timeout(db, 5_000) + if let db { interruptHandle.attach(db) } } + /// `sqlite3_close` refuses a connection with an unfinalized statement and leaves it open, so a + /// stream still in progress is finalized first. func close() { + interruptHandle.detach() + finalizeStreamingStatement() if let db { sqlite3_close(db) self.db = nil } } - func interrupt() { - if let db { sqlite3_interrupt(db) } + /// SQLite has no implicit transaction to report: autocommit is off from a `BEGIN` or a bare + /// `SAVEPOINT` until the matching `COMMIT` or `ROLLBACK`. + func transactionState() -> DriverTransactionState { + guard let db else { return .unknown } + return sqlite3_get_autocommit(db) == 0 ? .explicitTransaction : .idle } func execute(_ query: String) throws -> RawResult { @@ -299,6 +379,11 @@ private actor SQLiteActor { throw SQLiteError.queryFailed(String(cString: sqlite3_errmsg(db))) } defer { sqlite3_finalize(stmt) } + // Text with only whitespace or comments prepares to no statement, and stepping nothing is a misuse. + guard stmt != nil else { + return RawResult(columns: [], columnTypes: [], rows: [], + rowsAffected: 0, executionTime: Date().timeIntervalSince(start), isTruncated: false) + } let colCount = sqlite3_column_count(stmt) var columns: [String] = [] @@ -312,7 +397,9 @@ private actor SQLiteActor { var rows: [[String?]] = [] let maxRows = 100_000 - while sqlite3_step(stmt) == SQLITE_ROW { + let totalChangesBefore = sqlite3_total_changes64(db) + var stepResult = sqlite3_step(stmt) + while stepResult == SQLITE_ROW { if rows.count >= maxRows { return RawResult(columns: columns, columnTypes: columnTypes, rows: rows, rowsAffected: 0, executionTime: Date().timeIntervalSince(start), isTruncated: true) @@ -336,23 +423,36 @@ private actor SQLiteActor { } } rows.append(row) + stepResult = sqlite3_step(stmt) + } + guard stepResult == SQLITE_DONE else { + throw SQLiteError.queryFailed(String(cString: sqlite3_errmsg(db))) } - let affected = columns.isEmpty ? Int(sqlite3_changes(db)) : 0 + let affected = columns.isEmpty ? changes(of: db, sinceTotal: totalChangesBefore) : 0 return RawResult(columns: columns, columnTypes: columnTypes, rows: rows, rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false) } + /// `sqlite3_changes64` keeps the count of the last INSERT, UPDATE or DELETE, so a statement that + /// wrote nothing, DDL included, would report it again. The total moves only when this one wrote. + private func changes(of db: OpaquePointer, sinceTotal totalBefore: Int64) -> Int { + guard sqlite3_total_changes64(db) != totalBefore else { return 0 } + return Int(sqlite3_changes64(db)) + } + // MARK: - Streaming + /// One statement streams at a time, held with the token of the stream that began it, so the + /// producer of a stream that a later `beginStream` replaced cannot step or finalize the later one. private var streamingStmt: OpaquePointer? - private var streamingColumns: [ColumnInfo] = [] + private var streamingToken: SQLiteStreamToken? - func beginStream(query: String) throws -> SQLiteBeginStreamResult { + func beginStream(query: String, stream: SQLiteStreamToken) throws -> SQLiteBeginStreamResult { guard let db else { throw SQLiteError.notConnected } - if streamingStmt != nil { - endStream() - } + // Stop can land while the stream waits for the actor, and an interrupt then reaches nothing. + try Task.checkCancellation() + finalizeStreamingStatement() var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { @@ -364,14 +464,13 @@ private actor SQLiteActor { let colCount = Int(sqlite3_column_count(stmt)) if colCount == 0 { - let stepResult = sqlite3_step(stmt) - sqlite3_finalize(stmt) - if stepResult == SQLITE_DONE || stepResult == SQLITE_ROW { - let affected = Int(sqlite3_changes(db)) - return .commandOk(affectedRows: affected) - } else { + defer { sqlite3_finalize(stmt) } + let totalChangesBefore = sqlite3_total_changes64(db) + let stepResult = try step(stmt, of: stream).result + guard stepResult == SQLITE_DONE || stepResult == SQLITE_ROW else { throw SQLiteError.queryFailed(String(cString: sqlite3_errmsg(db))) } + return .commandOk(affectedRows: changes(of: db, sinceTotal: totalChangesBefore)) } var columns: [ColumnInfo] = [] @@ -390,14 +489,29 @@ private actor SQLiteActor { )) } streamingStmt = stmt - streamingColumns = columns + streamingToken = stream return .rowSet(columns) } - func fetchNextRow(options: StreamOptions, columns: [ColumnInfo]) -> [Cell]? { - guard let stmt = streamingStmt else { return nil } - let stepResult = sqlite3_step(stmt) - guard stepResult == SQLITE_ROW else { return nil } + /// A stream whose statement is gone, replaced by a later `beginStream` or finalized by a close, + /// ends as cancelled rather than reading rows that belong to another statement. + func fetchNextRow(stream: SQLiteStreamToken, options: StreamOptions, columns: [ColumnInfo]) throws -> [Cell]? { + guard stream === streamingToken, let stmt = streamingStmt else { throw CancellationError() } + let outcome = try step(stmt, of: stream) + if outcome.interrupted, outcome.result == SQLITE_ROW { + // The row was ready before the interrupt took effect, so the interrupt is still pending + // and would stop the next statement on the connection unless this one ends now. + finalizeStreamingStatement() + throw CancellationError() + } + switch outcome.result { + case SQLITE_ROW: + break + case SQLITE_DONE: + return nil + default: + throw SQLiteError.queryFailed(String(cString: sqlite3_errmsg(sqlite3_db_handle(stmt)))) + } var cells: [Cell] = [] cells.reserveCapacity(columns.count) @@ -429,12 +543,28 @@ private actor SQLiteActor { return cells } - func endStream() { - if let stmt = streamingStmt { - sqlite3_finalize(stmt) - streamingStmt = nil - streamingColumns = [] + func endStream(_ stream: SQLiteStreamToken) { + guard stream === streamingToken else { return } + finalizeStreamingStatement() + } + + private func finalizeStreamingStatement() { + guard let stmt = streamingStmt else { return } + sqlite3_finalize(stmt) + streamingStmt = nil + streamingToken = nil + } + + /// Marks the step before reading the cancel, so a consumer that leaves at any moment is either + /// seen here or finds its stream stepping and interrupts it. + private func step(_ stmt: OpaquePointer, of stream: SQLiteStreamToken) throws -> (result: Int32, interrupted: Bool) { + interruptHandle.beginStep(of: stream) + guard !Task.isCancelled else { + _ = interruptHandle.endStep() + throw CancellationError() } + let result = sqlite3_step(stmt) + return (result, interruptHandle.endStep()) } private func makeCellRef(column: String, columns: [ColumnInfo], statement: OpaquePointer, options: StreamOptions) -> CellRef? { diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index f73f8a494d..b99084e7f7 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -1994,6 +1994,9 @@ } } } + }, + "Any statement in the batch that ran stays applied." : { + }, "App" : { "extractionState" : "manual", @@ -2354,6 +2357,9 @@ } } } + }, + "Batch %1$d/%2$d failed: %3$@" : { + }, "Bell" : { "extractionState" : "manual", @@ -10827,6 +10833,9 @@ } } } + }, + "Nothing to Run" : { + }, "Nullable" : { "localizations" : { @@ -12504,6 +12513,9 @@ } } } + }, + "Query executed successfully" : { + }, "Query execution failed" : { "extractionState" : "stale", @@ -12683,6 +12695,12 @@ "Raised Hand" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Ran %d batches" : { + + }, + "Ran %d statements" : { + }, "Read-Only" : { "extractionState" : "stale", @@ -13771,6 +13789,9 @@ } } } + }, + "Running batch %1$d of %2$d" : { + }, "Running query" : { "extractionState" : "manual", @@ -13800,6 +13821,9 @@ } } } + }, + "Running statement %1$d of %2$d" : { + }, "Search Icons" : { @@ -15364,6 +15388,9 @@ "Stack" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Statement %1$d/%2$d failed: %3$@" : { + }, "Stats" : { "localizations" : { @@ -15538,6 +15565,18 @@ } } } + }, + "Stopped at batch %1$d of %2$d to stay within memory limits." : { + + }, + "Stopped at batch %1$d of %2$d." : { + + }, + "Stopped at statement %1$d of %2$d to stay within memory limits." : { + + }, + "Stopped at statement %1$d of %2$d." : { + }, "Stopped before the query finished." : { "localizations" : { @@ -15959,10 +15998,28 @@ }, "System Table" : { + }, + "The %d batches before it stay applied." : { + + }, + "The %d statements before it stay applied." : { + + }, + "The batch before it stays applied." : { + + }, + "The statement before it stays applied." : { + + }, + "The transaction on this connection is still open. Commit or roll it back." : { + }, "Theater Masks" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "There is no statement to run." : { + }, "Ticket" : { "extractionState" : "manual", diff --git a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index b9c75557f7..9f0a29f6e0 100644 --- a/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -2,6 +2,7 @@ import Foundation import os import TableProDatabase import TableProModels +import TableProQuery @MainActor @Observable @@ -14,6 +15,31 @@ final class QueryEditorViewModel { case error(AppError) } + /// What the requests before an early end left behind. Each request commits as it runs, unless the script opened + /// a transaction of its own, which then holds them. + nonisolated enum StandingWork: Sendable, Equatable { + case nothing + case applied(count: Int) + case openTransaction + /// The session was in a transaction at some point, or cannot say, so whether earlier work stands is not known. + case unknown + } + + /// Where a run of several requests ended before its last one finished. + nonisolated struct ScriptInterruption: Sendable, Equatable { + nonisolated enum Cause: Sendable, Equatable { + case failed(sql: String) + case stopped + case memoryPressure + } + + let cause: Cause + let position: Int + let total: Int + let kind: QueryScript.UnitKind + let standingWork: StandingWork + } + private static let logger = Logger(subsystem: "com.TablePro", category: "QueryEditorViewModel") static let maxBufferedRows = 10_000 private static let memorySafetyMarginBytes = 64 * 1_024 * 1_024 @@ -22,7 +48,13 @@ final class QueryEditorViewModel { private let buffer: StreamingResultBuffer private(set) var phase: Phase = .idle private(set) var executionTime: TimeInterval = 0 + private(set) var requestCount = 0 + private(set) var currentRequest = 0 + private(set) var rowsAffectedTotal = 0 + private(set) var interruption: ScriptInterruption? + @ObservationIgnored private var unitKind: QueryScript.UnitKind = .statement + @ObservationIgnored private var runID: UInt64 = 0 @ObservationIgnored private var fetchTask: Task? @ObservationIgnored private var startedAt: Date? @@ -33,7 +65,6 @@ final class QueryEditorViewModel { var columns: [ColumnInfo] { buffer.columns } var window: RowWindow { buffer.window } var legacyRows: [[String?]] { buffer.legacyRows } - var rowsAffected: Int? { buffer.rowsAffected } var statusMessage: String? { buffer.statusMessage } var truncationReason: TruncationReason? { buffer.truncation } @@ -42,6 +73,15 @@ final class QueryEditorViewModel { return false } + /// A run that ended, whatever it produced. A statement that changes no rows yields nothing at all, so the buffer + /// alone cannot tell a finished run from one that never started. + var hasOutcome: Bool { + switch phase { + case .idle, .running: false + case .finished, .truncated, .error: true + } + } + var truncationMessage: String? { guard let truncationReason else { return nil } let shown = buffer.legacyRows.count @@ -57,7 +97,119 @@ final class QueryEditorViewModel { } } - func run(driver: DatabaseDriver, query: String, maxRows: Int = QueryEditorViewModel.maxBufferedRows) async { + var progressMessage: String { + guard requestCount > 1 else { return String(localized: "Executing...") } + switch unitKind { + case .statement: + return String(format: String(localized: "Running statement %1$d of %2$d"), currentRequest, requestCount) + case .batch: + return String(format: String(localized: "Running batch %1$d of %2$d"), currentRequest, requestCount) + } + } + + /// The result of a statement that returns no rows: what it changed, or that it ran. + var commandMessage: String { + if rowsAffectedTotal > 0 { + return String(format: String(localized: "%d row(s) affected"), rowsAffectedTotal) + } + if let statusMessage, !statusMessage.isEmpty { + return statusMessage + } + return String(localized: "Query executed successfully") + } + + /// Only the last request's result is on screen, so a script that ran to the end says how many ran before it. + var scriptSummary: String? { + guard requestCount > 1, interruption == nil, QueryExecutionOutcome(phase: phase) == .completed else { + return nil + } + switch unitKind { + case .statement: + return String(format: String(localized: "Ran %d statements"), requestCount) + case .batch: + return String(format: String(localized: "Ran %d batches"), requestCount) + } + } + + var interruptionHeadline: String? { + guard let interruption else { return nil } + switch (interruption.cause, interruption.kind) { + case (.failed, .statement): + return String( + format: String(localized: "Statement %1$d/%2$d failed: %3$@"), + interruption.position, interruption.total, errorMessage ?? "" + ) + case (.failed, .batch): + return String( + format: String(localized: "Batch %1$d/%2$d failed: %3$@"), + interruption.position, interruption.total, errorMessage ?? "" + ) + case (.stopped, .statement): + return String( + format: String(localized: "Stopped at statement %1$d of %2$d."), + interruption.position, interruption.total + ) + case (.stopped, .batch): + return String( + format: String(localized: "Stopped at batch %1$d of %2$d."), + interruption.position, interruption.total + ) + case (.memoryPressure, .statement): + return String( + format: String(localized: "Stopped at statement %1$d of %2$d to stay within memory limits."), + interruption.position, interruption.total + ) + case (.memoryPressure, .batch): + return String( + format: String(localized: "Stopped at batch %1$d of %2$d to stay within memory limits."), + interruption.position, interruption.total + ) + } + } + + var standingWorkNote: String? { + guard let interruption else { return nil } + let earlier: String? + switch (interruption.standingWork, interruption.kind) { + case (.openTransaction, _): + return String(localized: "The transaction on this connection is still open. Commit or roll it back.") + case (.unknown, _): + return nil + case (.nothing, _): + earlier = nil + case (.applied(let count), .statement): + earlier = count == 1 + ? String(localized: "The statement before it stays applied.") + : String(format: String(localized: "The %d statements before it stay applied."), count) + case (.applied(let count), .batch): + earlier = count == 1 + ? String(localized: "The batch before it stays applied.") + : String(format: String(localized: "The %d batches before it stay applied."), count) + } + // SQL Server carries on past most errors inside a batch and does not say which statements it reached. + guard interruption.kind == .batch, case .failed = interruption.cause else { return earlier } + let withinBatch = String(localized: "Any statement in the batch that ran stays applied.") + return earlier.map { "\($0) \(withinBatch)" } ?? withinBatch + } + + var failedSQL: String? { + guard case .failed(let sql) = interruption?.cause else { return nil } + return sql + } + + /// The line history keeps for a run that ended early, so a script's row says which request it ended at. + var interruptionSummary: String? { + guard let interruptionHeadline else { return nil } + guard let standingWorkNote else { return interruptionHeadline } + return "\(interruptionHeadline) \(standingWorkNote)" + } + + private var errorMessage: String? { + guard case .error(let error) = phase else { return nil } + return error.message + } + + func run(driver: DatabaseDriver, script: QueryScript, maxRows: Int = QueryEditorViewModel.maxBufferedRows) async { fetchTask?.cancel() let options = StreamOptions( textTruncationBytes: 4_096, @@ -65,40 +217,21 @@ final class QueryEditorViewModel { maxRows: maxRows, lazyContext: nil ) + runID &+= 1 + let run = runID phase = .running buffer.reset() executionTime = 0 + rowsAffectedTotal = 0 + interruption = nil + unitKind = script.unitKind + requestCount = script.requestCount + currentRequest = 0 startedAt = Date() let task = Task { [weak self] in guard let self else { return } - do { - var sinceBudgetCheck = 0 - for try await element in driver.executeStreaming(query: query, options: options) { - if Task.isCancelled { break } - self.buffer.apply(element) - guard case .row = element else { continue } - sinceBudgetCheck += 1 - if sinceBudgetCheck >= Self.budgetCheckInterval { - sinceBudgetCheck = 0 - if self.isMemoryConstrained() { - self.buffer.markTruncated(.memoryPressure) - break - } - } - } - self.buffer.flush() - self.finalizeTiming() - self.resolvePhase() - } catch is CancellationError { - self.buffer.flush() - self.finalizeTiming() - self.phase = .truncated(reason: self.buffer.truncation ?? .cancelled) - } catch { - self.buffer.flush() - self.finalizeTiming() - self.phase = .error(self.classify(error: error)) - } + await self.runRequests(of: script, run: run, driver: driver, options: options) } fetchTask = task await task.value @@ -111,9 +244,14 @@ final class QueryEditorViewModel { } func reset() { + runID &+= 1 fetchTask?.cancel() buffer.reset() executionTime = 0 + rowsAffectedTotal = 0 + interruption = nil + requestCount = 0 + currentRequest = 0 phase = .idle } @@ -132,6 +270,169 @@ final class QueryEditorViewModel { } } + /// Runs each request only after the one before it has finished, and none after Stop, a failure or memory + /// pressure. A driver's interrupt reaches only a statement that is running, so the check before each request is + /// what keeps a stopped script from going on. + /// + /// The session's transaction state is read after each request finishes, while the connection is idle: a stopped + /// request may still hold the driver, and reading it then would wait for the statement Stop is meant to end. + private func runRequests(of initial: QueryScript, run: UInt64, driver: DatabaseDriver, options: StreamOptions) async { + var script = initial + var index = 0 + var position = 0 + var observedStates: [DriverTransactionState] = [] + while script.units.indices.contains(index) { + let unit = script.units[index] + for _ in 0.. 1 { + buffer.reset() + } + do { + guard try await stream(unit.sql, driver: driver, options: options) else { + settleStopped(atRequest: position, run: run, observedStates: observedStates) + return + } + rowsAffectedTotal += buffer.rowsAffected ?? 0 + } catch is CancellationError { + settleStopped(atRequest: position, run: run, observedStates: observedStates) + return + } catch where Task.isCancelled || !isRunning { + // Stop interrupts the statement, and the driver may answer with its interrupt error. + settleStopped(atRequest: position, run: run, observedStates: observedStates) + return + } catch { + let stateAfterFailure = requestCount > 1 ? await driver.sessionTransactionState() : .unknown + settleFailed( + error, + sql: unit.sql, + atRequest: position, + run: run, + standingWork: Self.standingWork( + before: position, observedStates: observedStates, now: stateAfterFailure + ) + ) + return + } + if requestCount > 1 { + observedStates.append(await driver.sessionTransactionState()) + } + } + if script.units.indices.contains(index + 1), + let rest = await replannedIfSessionChanged(script, after: index, driver: driver) { + script = rest + index = 0 + requestCount = position + rest.requestCount + continue + } + index += 1 + } + guard run == runID else { return } + buffer.flush() + finalizeTiming() + resolvePhase() + } + + /// A statement such as `SET sql_mode = 'NO_BACKSLASH_ESCAPES'` changes how the server reads every literal after + /// it, so the rest of the text is split again by what the session now says. + private func replannedIfSessionChanged( + _ script: QueryScript, + after index: Int, + driver: DatabaseDriver + ) async -> QueryScript? { + guard script.unitKind == .statement else { return nil } + let facts = await driver.sessionLexicalFacts() + guard facts != script.session else { return nil } + return script.replanned(after: index, session: facts) + } + + /// False when the request was cut short: by Stop, by memory pressure, or by another query that took over the + /// connection, whose driver then ends this stream as cancelled. + private func stream(_ sql: String, driver: DatabaseDriver, options: StreamOptions) async throws -> Bool { + var sinceBudgetCheck = 0 + for try await element in driver.executeStreaming(query: sql, options: options) { + if Task.isCancelled { return false } + buffer.apply(element) + guard case .row = element else { continue } + sinceBudgetCheck += 1 + if sinceBudgetCheck >= Self.budgetCheckInterval { + sinceBudgetCheck = 0 + if isMemoryConstrained() { + buffer.markTruncated(.memoryPressure) + return false + } + } + } + if case .cancelled? = buffer.truncation { return false } + return !Task.isCancelled + } + + /// A run that Clear or a newer run replaced settles nothing, so its late end cannot overwrite what replaced it. + private func settleStopped(atRequest position: Int, run: UInt64, observedStates: [DriverTransactionState]) { + guard run == runID else { return } + buffer.flush() + finalizeTiming() + let reason = buffer.truncation ?? .cancelled + if requestCount > 1 { + let cause: ScriptInterruption.Cause + if case .memoryPressure = reason { + cause = .memoryPressure + } else { + cause = .stopped + } + interruption = ScriptInterruption( + cause: cause, + position: min(position, requestCount), + total: requestCount, + kind: unitKind, + standingWork: Self.standingWork( + before: position, observedStates: observedStates, now: observedStates.last ?? .unknown + ) + ) + } + phase = .truncated(reason: reason) + } + + private func settleFailed( + _ error: Error, + sql: String, + atRequest position: Int, + run: UInt64, + standingWork: StandingWork + ) { + guard run == runID else { return } + buffer.flush() + finalizeTiming() + if requestCount > 1 { + interruption = ScriptInterruption( + cause: .failed(sql: sql), + position: position, + total: requestCount, + kind: unitKind, + standingWork: standingWork + ) + } + phase = .error(classify(error: error)) + } + + /// Earlier requests stand only when the session was seen outside a transaction after every one of them: a + /// script's own `BEGIN` and `ROLLBACK` leaves an idle session with nothing kept, and an engine that cannot say + /// leaves nothing certain. + private static func standingWork( + before position: Int, + observedStates: [DriverTransactionState], + now: DriverTransactionState + ) -> StandingWork { + if now == .explicitTransaction || now == .implicitTransaction { return .openTransaction } + guard now == .idle, observedStates.allSatisfy({ $0 == .idle }) else { return .unknown } + return position > 1 ? .applied(count: position - 1) : .nothing + } + private func isMemoryConstrained() -> Bool { !MemoryPressureMonitor.shared.hasHeadroom(forBytes: Self.memorySafetyMarginBytes) } diff --git a/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 500fe402e1..28e0bf935d 100644 --- a/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -19,10 +19,6 @@ struct QueryEditorView: View { @State private var executionTime: TimeInterval? @State private var executeTask: Task? - private var hasResult: Bool { - !viewModel.columns.isEmpty || viewModel.rowsAffected != nil - } - private var resultRowCount: Int { viewModel.legacyRows.count } @@ -127,7 +123,7 @@ struct QueryEditorView: View { for: DuoLayoutContext( size: containerSize, widthClass: duoWidthClass, - showsResult: hasResult || appError != nil + showsResult: viewModel.hasOutcome || appError != nil ) ) } @@ -198,72 +194,140 @@ struct QueryEditorView: View { if isExecuting { VStack(spacing: 12) { ProgressView() - Text("Executing...") + Text(verbatim: viewModel.progressMessage) .font(.footnote) .foregroundStyle(.secondary) + .monospacedDigit() } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let appError { - ScrollView { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.red) - Text(verbatim: appError.message) - .font(.system(.footnote, design: .monospaced)) - .foregroundStyle(.red) - } - if let recovery = appError.recovery { - Text(verbatim: recovery) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 28) - } - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) + errorView(appError) + } else if !viewModel.hasOutcome { + ContentUnavailableView { + Label("Run a Query", systemImage: "terminal") + } description: { + Text("Write SQL and tap the play button.") } - } else if hasResult { - if viewModel.columns.isEmpty { - VStack(spacing: 8) { - Image(systemName: "checkmark.circle.fill") - .font(.largeTitle) - .foregroundStyle(.green) - Text(String(format: String(localized: "%d row(s) affected"), viewModel.rowsAffected ?? 0)) - .font(.body) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if viewModel.legacyRows.isEmpty { + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if viewModel.columns.isEmpty || viewModel.legacyRows.isEmpty && !runCompleted { + commandOutcomeView + } else if viewModel.legacyRows.isEmpty { + VStack(spacing: 0) { + resultNotices(includesTruncation: false) ContentUnavailableView( "No Results", systemImage: "tray", description: Text("The query returned no rows.") ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - VStack(spacing: 0) { - if let message = viewModel.truncationMessage { - truncationBanner(message) - Divider() - } - resultList - } } } else { - ContentUnavailableView { - Label("Run a Query", systemImage: "terminal") - } description: { - Text("Write SQL and tap the play button.") + VStack(spacing: 0) { + resultNotices(includesTruncation: true) + resultList } - .frame(maxWidth: .infinity, maxHeight: .infinity) } } } - private func truncationBanner(_ message: String) -> some View { + private func errorView(_ appError: AppError) -> some View { + let isScriptFailure = isScriptFailure + return ScrollView { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .accessibilityHidden(true) + Text(verbatim: isScriptFailure ? viewModel.interruptionHeadline ?? appError.message : appError.message) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(.red) + } + if isScriptFailure, let note = viewModel.standingWorkNote { + Text(verbatim: note) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 28) + } + if isScriptFailure, let sql = viewModel.failedSQL { + Text(verbatim: sql) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(3) + .textSelection(.enabled) + .padding(.leading, 28) + } + if let recovery = appError.recovery { + Text(verbatim: recovery) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, 28) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// The error on screen is the run's own only while the view model still holds the failure that produced it. + private var isScriptFailure: Bool { + guard case .error = viewModel.phase else { return false } + return viewModel.interruption != nil + } + + private var runCompleted: Bool { + QueryExecutionOutcome(phase: viewModel.phase) == .completed + } + + /// A statement that returns no rows still ran, so it gets an outcome of its own rather than the empty placeholder. + private var commandOutcomeView: some View { + let outcome = QueryExecutionOutcome(phase: viewModel.phase) + let completed = outcome == .completed + return VStack(spacing: 8) { + Image(systemName: completed ? "checkmark.circle.fill" : "stop.circle.fill") + .font(.largeTitle) + .foregroundStyle(completed ? Color.green : Color.secondary) + .accessibilityHidden(true) + Text(verbatim: completed + ? viewModel.commandMessage + : viewModel.interruptionHeadline ?? outcome.historyMessage ?? viewModel.commandMessage) + .font(.body) + .multilineTextAlignment(.center) + if let detail = completed ? viewModel.scriptSummary : viewModel.standingWorkNote { + Text(verbatim: detail) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding() + .accessibilityElement(children: .combine) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func resultNotices(includesTruncation: Bool) -> some View { + if let headline = viewModel.interruptionHeadline { + noticeBanner( + [headline, viewModel.standingWorkNote].compactMap { $0 }.joined(separator: " "), + systemImage: "stop.circle.fill", + tint: .secondary + ) + Divider() + } else if includesTruncation, let message = viewModel.truncationMessage { + noticeBanner(message, systemImage: "exclamationmark.triangle.fill", tint: .orange) + Divider() + } + if let summary = viewModel.scriptSummary { + noticeBanner(summary, systemImage: "checkmark.circle.fill", tint: .green) + Divider() + } + } + + private func noticeBanner(_ message: String, systemImage: String, tint: Color) -> some View { HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + Image(systemName: systemImage) + .foregroundStyle(tint) + .accessibilityHidden(true) Text(verbatim: message) .font(.caption) .foregroundStyle(.secondary) @@ -421,6 +485,15 @@ struct QueryEditorView: View { private func executeQuery() async { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } + guard let session else { return } + // Text that is only comments plans to nothing under any session setting, so this needs no driver round trip. + let outline = QueryScript.plan( + trimmed, databaseType: databaseType, sendsBatchesWhole: session.driver.sendsBatchesWhole + ) + guard !outline.isEmpty else { + reportNothingToRun() + return + } if SQLWriteClassifier.isWriteQuery(trimmed, databaseType: databaseType) { switch safeModeLevel.writePermission { @@ -439,8 +512,10 @@ struct QueryEditorView: View { await executeQueryDirect(trimmed) } + /// The run is claimed before the first await, so a second tap on Run while the session is asked how it reads + /// literals finds it running and does not start another. private func executeQueryDirect(_ trimmed: String) async { - guard let session else { return } + guard let session, !isExecuting else { return } editorFocused = false isExecuting = true @@ -448,6 +523,14 @@ struct QueryEditorView: View { executionStartTime = startedAt appError = nil + let script = await plan(trimmed, on: session) + guard !script.isEmpty else { + isExecuting = false + executionStartTime = nil + reportNothingToRun() + return + } + let token = await appState.queryActivities.start( connectionId: connectionId, connectionName: coordinator.displayName, @@ -465,7 +548,7 @@ struct QueryEditorView: View { let progressUpdater = startActivityProgressUpdater(token: token) - await viewModel.run(driver: session.driver, query: trimmed) + await viewModel.run(driver: session.driver, script: script) progressUpdater.cancel() let phase = viewModel.phase @@ -474,14 +557,22 @@ struct QueryEditorView: View { if case .error(let err) = phase { appError = err hapticError.toggle() - recordHistory(query: trimmed, outcome: outcome, errorMessage: err.localizedDescription) + recordHistory( + query: trimmed, + outcome: outcome, + errorMessage: viewModel.interruptionSummary ?? err.localizedDescription + ) } else { executionTime = viewModel.executionTime if outcome == .completed { hapticSuccess.toggle() IOSAnalyticsProvider.shared.markFirstQueryExecuted() } - recordHistory(query: trimmed, outcome: outcome, errorMessage: outcome.historyMessage) + recordHistory( + query: trimmed, + outcome: outcome, + errorMessage: viewModel.interruptionSummary ?? outcome.historyMessage + ) } isExecuting = false @@ -490,6 +581,28 @@ struct QueryEditorView: View { await appState.queryActivities.end(token: token, outcome: outcome.activityOutcome) } + private func plan(_ text: String, on session: ConnectionSession) async -> QueryScript { + QueryScript.plan( + text, + databaseType: databaseType, + sendsBatchesWhole: session.driver.sendsBatchesWhole, + session: await session.driver.sessionLexicalFacts() + ) + } + + /// Text that holds only comments reaches no driver, so the tap still gets an answer. + private func reportNothingToRun() { + viewModel.reset() + executionTime = nil + appError = AppError( + category: .query, + title: String(localized: "Nothing to Run"), + message: String(localized: "There is no statement to run."), + recovery: nil, + underlying: nil + ) + } + private func recordHistory(query: String, outcome: QueryExecutionOutcome, errorMessage: String?) { coordinator.addHistoryItem( QueryHistoryItem( diff --git a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift index 79161f5668..cdcf4ded22 100644 --- a/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift @@ -161,6 +161,80 @@ final class DuckDBDriverTests: XCTestCase { XCTAssertTrue(value.contains("42"), "expected rendered variant text, got \(value)") } + func testStreamThrowsAnErrorRaisedAfterItsFirstChunks() async throws { + let driver = try XCTUnwrap(driver) + // DuckDB buffers about 128k rows of a streaming result ahead of the reader, so the failing row has to lie + // past that for the error to come from a fetch rather than from executing the query. + let query = "SELECT CASE WHEN i = 250000 THEN error('boom at 250000') ELSE i END AS v FROM range(250001) t(i)" + var rows = 0 + do { + for try await element in driver.executeStreaming(query: query, options: StreamOptions(maxRows: 1_000_000)) { + if case .row = element { rows += 1 } + } + XCTFail("a stream whose later rows fail must throw, not end after \(rows) rows") + } catch { + XCTAssertTrue(error.localizedDescription.contains("boom at 250000"), "unexpected error: \(error)") + } + XCTAssertGreaterThan(rows, 0, "the error must come from a later fetch for this test to cover it") + } + + func testSupersededStreamCannotReadOrEndTheNextStream() async throws { + let actor = try XCTUnwrap(driver).actor + guard case .rowSet(_, let first) = try await actor.beginStream( + query: "SELECT i FROM range(5) t(i)", token: DuckDBStreamToken() + ) else { + return XCTFail("a SELECT must stream") + } + guard case .rowSet(_, let second) = try await actor.beginStream( + query: "SELECT i FROM range(100, 103) t(i)", token: DuckDBStreamToken() + ) else { + return XCTFail("a SELECT must stream") + } + + do { + _ = try await actor.fetchStreamChunk(stream: first, options: .default) + XCTFail("a superseded stream must not read the stream that replaced it") + } catch is CancellationError {} + + await actor.endStream(first) + let chunk = try await actor.fetchStreamChunk(stream: second, options: .default) + XCTAssertEqual(chunk?.map { $0.first?.displayString }, ["100", "101", "102"]) + await actor.endStream(second) + } + + func testAnotherStatementEndsTheOpenStreamAsCancelled() async throws { + let driver = try XCTUnwrap(driver) + guard case .rowSet(_, let stream) = try await driver.actor.beginStream( + query: "SELECT i FROM range(100000) t(i)", token: DuckDBStreamToken() + ) else { + return XCTFail("a SELECT must stream") + } + _ = try await driver.actor.fetchStreamChunk(stream: stream, options: .default) + + _ = try await driver.execute(query: "SELECT 42") + + do { + _ = try await driver.actor.fetchStreamChunk(stream: stream, options: .default) + XCTFail("a stream closed by another statement must end as cancelled") + } catch is CancellationError {} + } + + func testCancellingTheConsumerInterruptsTheRunningQuery() async throws { + let driver = try XCTUnwrap(driver) + // About 30 seconds of work on ten cores, all of it before the first chunk is ready. + let query = "SELECT sum(i) FROM range(50000000000) t(i)" + let consumer = Task { + for try await _ in driver.executeStreaming(query: query, options: .default) {} + } + try await Task.sleep(for: .milliseconds(300)) + consumer.cancel() + + let start = ContinuousClock.now + let alive = try await driver.ping() + XCTAssertTrue(alive) + XCTAssertLessThan(ContinuousClock.now - start, .seconds(5), "the cancel must interrupt the query, not queue behind it") + } + func testCancelWithoutRunningQueryIsSafe() async throws { let driver = try XCTUnwrap(driver) try await driver.cancelCurrentQuery() diff --git a/TableProMobile/TableProMobileTests/Drivers/PostgreSQLStreamTests.swift b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLStreamTests.swift new file mode 100644 index 0000000000..2fecbc715d --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/PostgreSQLStreamTests.swift @@ -0,0 +1,170 @@ +import CLibPQ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("PostgreSQL stream end") +struct PostgreSQLStreamTests { + private func drain( + _ results: [PostgreSQLPendingResult], + afterCancel: Bool = false + ) -> (end: PostgreSQLStreamEnd, ended: [LibPQCopy], unread: Int) { + var pending = results + var ended: [LibPQCopy] = [] + let drained = PostgreSQLStreamDrain.drain( + nextResult: { pending.isEmpty ? nil : pending.removeFirst() }, + endCopy: { ended.append($0) }, + afterCancel: afterCancel + ) + return (drained.end, ended, pending.count) + } + + @Test("A failed statement and an unreadable reply count as failures; rows and completions do not") + func failureStatuses() { + #expect(PostgreSQLStreamDrain.isFailure(PGRES_FATAL_ERROR)) + #expect(PostgreSQLStreamDrain.isFailure(PGRES_BAD_RESPONSE)) + #expect(!PostgreSQLStreamDrain.isFailure(PGRES_SINGLE_TUPLE)) + #expect(!PostgreSQLStreamDrain.isFailure(PGRES_TUPLES_OK)) + #expect(!PostgreSQLStreamDrain.isFailure(PGRES_COMMAND_OK)) + #expect(!PostgreSQLStreamDrain.isFailure(PGRES_EMPTY_QUERY)) + } + + @Test("A statement that fails behind a completed result ends the stream with that failure") + func failureBehindCompletedResult() { + let message = "ERROR: duplicate key value violates unique constraint \"s_pkey\"\n" + let drained = drain([.completed, .failed(message)]) + #expect(drained.end == .failed(message)) + #expect(drained.unread == 0) + } + + @Test("The first failure is the one reported, and every result is still read") + func firstFailureWins() { + let drained = drain([.failed("ERROR: division by zero\n"), .failed("ERROR: later\n"), .completed]) + #expect(drained.end == .failed("ERROR: division by zero\n")) + #expect(drained.unread == 0) + } + + @Test("A failure after the driver's own cancel ends the stream as cancelled") + func failureAfterCancelIsTheCancel() { + let drained = drain( + [.completed, .failed("ERROR: canceling statement due to user request\n")], + afterCancel: true + ) + #expect(drained.end == .cancelled) + } + + @Test("A COPY is reported over the error that ending it provokes") + func copyWinsOverItsOwnError() { + let copy = LibPQCopy(direction: .copyIn, format: .binary) + let drained = drain([.copy(copy), .failed("ERROR: COPY from stdin failed: no COPY data was sent\n")]) + #expect(drained.ended == [copy]) + #expect(drained.end == .abandonedCopy(.copyIn)) + } + + @Test("Rows and completions alone end the stream cleanly") + func cleanDrainCompletes() { + let drained = drain([.completed, .completed], afterCancel: true) + #expect(drained.end == .completed) + #expect(drained.unread == 0) + } + + @Test("Whether the cancel happened is asked only once every result is read") + func cancelIsReadAfterTheResults() { + var pending: [PostgreSQLPendingResult] = [.completed, .failed("ERROR: canceling statement due to user request\n")] + var cancelled = false + let drained = PostgreSQLStreamDrain.drain( + nextResult: { + guard !pending.isEmpty else { return nil } + cancelled = true + return pending.removeFirst() + }, + endCopy: { _ in }, + afterCancel: cancelled + ) + #expect(drained.end == .cancelled) + } + + @Test("A failed end carries the server's message, and a cancelled end is a cancellation") + func endErrors() { + let failure = PostgreSQLStreamEnd.failed("ERROR: division by zero\n").error as? PostgreSQLError + #expect(failure?.errorDescription?.contains("division by zero") == true) + #expect(PostgreSQLStreamEnd.cancelled.error is CancellationError) + #expect(PostgreSQLStreamEnd.completed.error == nil) + let copy = PostgreSQLStreamEnd.abandonedCopy(.copyOut).error as? PostgreSQLError + #expect(copy?.errorDescription == LibPQCopyDirection.copyOut.unsupportedMessage) + } +} + +@Suite("PostgreSQL query cancels") +struct PostgreSQLQueryCancelsTests { + @Test("A cancel for the query in flight marks it and goes out once") + func cancelInFlightQuery() { + var cancels = PostgreSQLQueryCancels() + let query = cancels.reserve() + cancels.sent(query) + let firstCancelSent = cancels.cancel(query) + #expect(firstCancelSent) + let secondCancelSent = cancels.cancel(query) + #expect(!secondCancelSent) + #expect(cancels.isCancelled(query)) + } + + @Test("A cancel for a query that is not in flight yet sends nothing and marks nothing") + func cancelBeforeSendIsDropped() { + var cancels = PostgreSQLQueryCancels() + let query = cancels.reserve() + let cancelSent = cancels.cancel(query) + #expect(!cancelSent) + cancels.sent(query) + #expect(!cancels.isCancelled(query)) + } + + @Test("A cancel aimed at a finished query never marks the next one") + func lateCancelDoesNotReachTheNextQuery() { + var cancels = PostgreSQLQueryCancels() + let first = cancels.reserve() + cancels.sent(first) + cancels.finished(first) + let second = cancels.reserve() + cancels.sent(second) + let lateCancelSent = cancels.cancel(first) + #expect(!lateCancelSent) + #expect(!cancels.isCancelled(second)) + #expect(cancels.inFlight == second) + } + + @Test("Stop cancels whatever query is in flight, and nothing when none is") + func stopTargetsTheQueryInFlight() { + var cancels = PostgreSQLQueryCancels() + let idleStopSent = cancels.cancelInFlight() + #expect(!idleStopSent) + let browse = cancels.reserve() + let editor = cancels.reserve() + cancels.sent(editor) + let stopSent = cancels.cancelInFlight() + #expect(stopSent) + #expect(cancels.isCancelled(editor)) + #expect(!cancels.isCancelled(browse)) + } + + @Test("The end of another query leaves the one in flight in place") + func finishingAnotherQueryKeepsTheInFlightOne() { + var cancels = PostgreSQLQueryCancels() + let stale = cancels.reserve() + let current = cancels.reserve() + cancels.sent(current) + cancels.finished(stale) + #expect(cancels.inFlight == current) + } + + @Test("Closing the connection leaves nothing in flight to cancel") + func detachClearsTheQueryInFlight() { + var cancels = PostgreSQLQueryCancels() + let query = cancels.reserve() + cancels.sent(query) + cancels.detach() + #expect(cancels.inFlight == nil) + let cancelSent = cancels.cancel(query) + #expect(!cancelSent) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/RedisCommandParsingTests.swift b/TableProMobile/TableProMobileTests/Drivers/RedisCommandParsingTests.swift new file mode 100644 index 0000000000..3723b379de --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/RedisCommandParsingTests.swift @@ -0,0 +1,73 @@ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("Redis command parsing") +struct RedisCommandParsingTests { + private func parse(_ input: String) -> [String] { + RedisDriver.parseRedisCommand(input) + } + + @Test("a newline separates arguments") + func newlineSeparates() { + #expect(parse("DEL a\nDEL b") == ["DEL", "a", "DEL", "b"]) + #expect(parse("LPUSH l x\nLPUSH l y") == ["LPUSH", "l", "x", "LPUSH", "l", "y"]) + } + + @Test("a CRLF line break separates arguments") + func crlfSeparates() { + #expect(parse("SET k v\r\nGET k") == ["SET", "k", "v", "GET", "k"]) + } + + @Test("a lone carriage return separates arguments") + func carriageReturnSeparates() { + #expect(parse("SET k v\rGET k") == ["SET", "k", "v", "GET", "k"]) + } + + @Test("leading and trailing line breaks add no argument") + func surroundingLineBreaks() { + #expect(parse("\n\r\nGET k\n\n") == ["GET", "k"]) + #expect(parse("\n\r\n\r").isEmpty) + } + + @Test("a double-quoted argument keeps its line breaks") + func doubleQuotedKeepsLineBreaks() { + #expect(parse("SET k \"line1\nline2\"") == ["SET", "k", "line1\nline2"]) + #expect(parse("SET k \"a\r\nb\"") == ["SET", "k", "a\r\nb"]) + } + + @Test("a single-quoted argument keeps its line breaks") + func singleQuotedKeepsLineBreaks() { + #expect(parse("SET k 'a\nb'") == ["SET", "k", "a\nb"]) + } + + @Test("spaces and tabs still separate arguments") + func spacesAndTabsSeparate() { + #expect(parse(" GET\t\tk ") == ["GET", "k"]) + } + + @Test("a quoted argument keeps its spaces") + func quotedKeepsSpaces() { + #expect(parse("SET greeting \"hello world\"") == ["SET", "greeting", "hello world"]) + #expect(parse("SET greeting 'hello world'") == ["SET", "greeting", "hello world"]) + } + + @Test("a backslash escapes the next character") + func backslashEscapes() { + #expect(parse("SET k \"say \\\"hi\\\"\"") == ["SET", "k", "say \"hi\""]) + #expect(parse("SET k a\\ b") == ["SET", "k", "a b"]) + } + + @Test("an empty quoted argument is kept, so a write receives every value it was given") + func emptyQuotedArgumentIsKept() { + #expect(parse("LPUSH l \"\" x") == ["LPUSH", "l", "", "x"]) + #expect(parse("SET k ''") == ["SET", "k", ""]) + #expect(parse("HSET h f \"\"\nGET k") == ["HSET", "h", "f", "", "GET", "k"]) + } + + @Test("empty and blank input give no arguments") + func emptyInput() { + #expect(parse("").isEmpty) + #expect(parse(" \t ").isEmpty) + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift b/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift index ba519e2276..9fe8db90bb 100644 --- a/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift @@ -1,7 +1,9 @@ import Foundation +import SQLite3 import TableProDatabase @testable import TableProMobile import TableProModels +import TableProQuery import Testing @Suite("SQLite driver file handling") @@ -42,4 +44,257 @@ struct SQLiteDriverFileTests { #expect(body == "kept") } + + @Test("The text from #3399, planned and run statement by statement, leaves the table holding seven rows") + func reportedScriptInsertsSevenRows() async throws { + let driver = try await connectedDriver("students.db") + let script = QueryScript.plan( + "\(Self.createStudent);\n\n\(Self.insertStudents);", + databaseType: .sqlite, + sendsBatchesWhole: driver.sendsBatchesWhole + ) + var affected: [Int] = [] + for unit in script.units { + affected.append(try await driver.execute(query: unit.sql).rowsAffected) + } + let count = try await driver.execute(query: "SELECT count(*) FROM STUDENT") + try await driver.disconnect() + + #expect(affected == [0, 7]) + #expect((count.rows.first?.first ?? nil) == "7") + } + + @Test("CREATE TABLE after a seven-row INSERT reports no rows affected, through execute and the stream") + func createTableAfterInsertReportsZero() async throws { + let driver = try await connectedDriver("ddl.db") + _ = try await driver.execute(query: Self.createStudent) + _ = try await driver.execute(query: Self.insertStudents) + + let executed = try await driver.execute(query: "CREATE TABLE executed_after (id INTEGER)") + let streamed = try await streamedElements(driver, "CREATE TABLE streamed_after (id INTEGER)") + try await driver.disconnect() + + let streamedCounts = streamed.compactMap { element -> Int? in + guard case .rowsAffected(let count) = element else { return nil } + return count + } + #expect(executed.rowsAffected == 0) + #expect(streamedCounts.allSatisfy { $0 == 0 }) + } + + @Test("An INSERT that breaks a CHECK constraint throws from execute and adds no row") + func checkViolationThrows() async throws { + let driver = try await connectedDriver("check.db") + _ = try await driver.execute(query: Self.createStudent) + + await #expect(throws: SQLiteError.self) { + _ = try await driver.execute( + query: "INSERT INTO STUDENT (SID, NAME, SEX) VALUES ('09999', 'Zed', 'X')" + ) + } + let count = try await driver.execute(query: "SELECT count(*) FROM STUDENT") + try await driver.disconnect() + + #expect((count.rows.first?.first ?? nil) == "0") + } + + @Test("A SELECT that fails on its third row streams the first two and then throws") + func streamedRowErrorThrows() async throws { + let driver = try await connectedDriver("json.db") + _ = try await driver.execute(query: "CREATE TABLE j (id INTEGER PRIMARY KEY, doc TEXT)") + _ = try await driver.execute(query: """ + INSERT INTO j (id, doc) VALUES (1, '{"a":1}'), (2, '{"a":2}'), (3, 'not json'), (4, '{"a":4}') + """) + + var rowCount = 0 + var thrown: Error? + do { + let query = "SELECT id, json_extract(doc, '$.a') FROM j ORDER BY id" + for try await element in driver.executeStreaming(query: query, options: .default) { + if case .row = element { rowCount += 1 } + } + } catch { + thrown = error + } + try await driver.disconnect() + + #expect(rowCount == 2) + #expect(thrown is SQLiteError) + } + + @Test("A consumer that stops after the first row leaves the connection free for the next statement") + func abandonedStreamLeavesConnectionUsable() async throws { + let driver = try await connectedDriver("abandoned.db") + _ = try await driver.execute(query: "CREATE TABLE n (x INTEGER)") + _ = try await driver.execute(query: """ + WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c WHERE x < 1000) + INSERT INTO n SELECT x FROM c + """) + + for try await element in driver.executeStreaming(query: "SELECT x FROM n", options: .default) { + if case .row = element { break } + } + let count = try await driver.execute(query: "SELECT count(*) FROM n") + try await driver.disconnect() + + #expect((count.rows.first?.first ?? nil) == "1000") + } + + @Test("The session reports an open transaction from BEGIN until COMMIT") + func transactionStateFollowsBegin() async throws { + let driver = try await connectedDriver("transaction.db") + let before = await driver.sessionTransactionState() + _ = try await driver.execute(query: "BEGIN") + let inside = await driver.sessionTransactionState() + _ = try await driver.execute(query: "COMMIT") + let after = await driver.sessionTransactionState() + try await driver.disconnect() + let disconnected = await driver.sessionTransactionState() + + #expect(before == .idle) + #expect(inside == .explicitTransaction) + #expect(after == .idle) + #expect(disconnected == .unknown) + } + + @Test("A write of several statements is all or nothing when a later one breaks a constraint") + func writeRollsBackOnALaterFailure() async throws { + let driver = try await connectedDriver("atomic.db") + _ = try await driver.execute(query: Self.createStudent) + + await #expect(throws: SQLiteError.self) { + try await driver.executeWrite([ + "INSERT INTO STUDENT (SID, NAME, SEX) VALUES ('00001', 'Ann', 'F')", + "INSERT INTO STUDENT (SID, NAME, SEX) VALUES ('00002', 'Bob', 'X')", + ]) + } + let count = try await driver.execute(query: "SELECT count(*) FROM STUDENT") + let state = await driver.sessionTransactionState() + try await driver.disconnect() + + #expect((count.rows.first?.first ?? nil) == "0") + #expect(state == .idle) + } + + @Test("A write inside a transaction the user opened joins it instead of opening another") + func writeJoinsTheOpenTransaction() async throws { + let driver = try await connectedDriver("joined.db") + _ = try await driver.execute(query: Self.createStudent) + _ = try await driver.execute(query: "BEGIN") + + let affected = try await driver.executeWrite([ + "INSERT INTO STUDENT (SID, NAME, SEX) VALUES ('00001', 'Ann', 'F')", + "INSERT INTO STUDENT (SID, NAME, SEX) VALUES ('00002', 'Bob', 'M')", + ]) + let stateAfterWrite = await driver.sessionTransactionState() + _ = try await driver.execute(query: "ROLLBACK") + let count = try await driver.execute(query: "SELECT count(*) FROM STUDENT") + try await driver.disconnect() + + #expect(affected == 2) + #expect(stateAfterWrite == .explicitTransaction) + #expect((count.rows.first?.first ?? nil) == "0") + } + + // MARK: - Helpers + + private static let createStudent = """ + CREATE TABLE STUDENT ( + SID CHAR(5) NOT NULL PRIMARY KEY, + NAME VARCHAR(20) NOT NULL, + SEX CHAR(1) CHECK (SEX IN ('M', 'F')), + CLASS VARCHAR(5), + DOB DATE, + SCORE INT + ) + """ + + private static let insertStudents = """ + INSERT INTO STUDENT (SID, NAME, SEX, CLASS, DOB, SCORE) VALUES + ('03493', 'Ada', 'F', '3A', '2013-09-21', 0), + ('03496', 'Clem', 'F', '3B', '2013-10-08', 89), + ('03505', 'Eva', 'F', '3B', '2013-03-19', 83), + ('03506', 'Gabe', 'M', '3C', '2013-08-23', 47), + ('03510', 'Finn', 'M', '3B', '2013-01-24', 35), + ('03513', 'Dio', 'M', '3C', '2013-06-25', 67), + ('03517', 'Ben', 'M', '3A', '2013-11-17', 86) + """ + + private func connectedDriver(_ fileName: String) async throws -> SQLiteDriver { + let driver = SQLiteDriver(source: .file(directory.appendingPathComponent(fileName)), openMode: .createNew) + try await driver.connect() + return driver + } + + private func streamedElements(_ driver: SQLiteDriver, _ query: String) async throws -> [StreamElement] { + var elements: [StreamElement] = [] + for try await element in driver.executeStreaming(query: query, options: .default) { + elements.append(element) + } + return elements + } +} + +@Suite("SQLite interrupt handle") +struct SQLiteInterruptHandleTests { + @Test("A stream's consumer interrupts only while that stream's own statement is stepping") + func streamInterruptNeedsItsOwnStep() throws { + let connection = try openConnection() + defer { sqlite3_close(connection) } + let handle = SQLiteInterruptHandle() + handle.attach(connection) + let browser = SQLiteStreamToken() + let editor = SQLiteStreamToken() + + handle.interrupt(browser) + #expect(sqlite3_is_interrupted(connection) == 0) + + handle.beginStep(of: editor) + handle.interrupt(browser) + #expect(sqlite3_is_interrupted(connection) == 0) + #expect(!handle.endStep()) + + handle.beginStep(of: browser) + handle.interrupt(browser) + #expect(sqlite3_is_interrupted(connection) == 1) + #expect(handle.endStep()) + } + + @Test("Stop interrupts the connection whether or not a stream is stepping") + func stopInterruptsWhateverRuns() throws { + let connection = try openConnection() + defer { sqlite3_close(connection) } + let handle = SQLiteInterruptHandle() + handle.attach(connection) + let stream = SQLiteStreamToken() + + handle.beginStep(of: stream) + handle.interrupt() + #expect(handle.endStep()) + #expect(sqlite3_is_interrupted(connection) == 1) + } + + @Test("A handle detached for a close sends no interrupt") + func detachedHandleSendsNothing() throws { + let connection = try openConnection() + defer { sqlite3_close(connection) } + let handle = SQLiteInterruptHandle() + handle.attach(connection) + handle.detach() + let stream = SQLiteStreamToken() + + handle.interrupt() + handle.beginStep(of: stream) + handle.interrupt(stream) + #expect(!handle.endStep()) + #expect(sqlite3_is_interrupted(connection) == 0) + } + + private func openConnection() throws -> OpaquePointer { + var db: OpaquePointer? + let opened = sqlite3_open(":memory:", &db) + let connection = try #require(db) + try #require(opened == SQLITE_OK) + return connection + } } diff --git a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 12e17ab327..1ae0151d33 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -2,6 +2,7 @@ import Foundation import TableProDatabase import TableProModels import TableProPluginKit +import TableProSQLGrammar final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { enum MockError: Error { case scripted } @@ -13,6 +14,10 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var scriptedDatabases: [String] = [] var scriptedSchemas: [String] = [] var scriptedTransactionState: DriverTransactionState = .unknown + /// Answered in order, one per call, before falling back to `scriptedTransactionState`. + var scriptedTransactionStates: [DriverTransactionState] = [] + /// Answered in order, one per call, then nil. + var scriptedLexicalFacts: [SQLSessionLexicalFacts?] = [] private(set) var executedQueries: [String] = [] private(set) var fetchColumnsCalls: Int = 0 @@ -27,6 +32,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool = true var serverVersion: String? = "Mock 1.0" var holdsSuspensionBlockingResource: Bool = false + var sendsBatchesWhole: Bool = false var usesBackslashEscaping: Bool = false func escapeStringLiteral(_ value: String) -> String { @@ -84,7 +90,15 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func commitTransaction() async throws { didCommitTransaction = true } func rollbackTransaction() async throws { didRollbackTransaction = true } - func sessionTransactionState() async -> DriverTransactionState { scriptedTransactionState } + func sessionLexicalFacts() async -> SQLSessionLexicalFacts? { + guard !scriptedLexicalFacts.isEmpty else { return nil } + return scriptedLexicalFacts.removeFirst() + } + + func sessionTransactionState() async -> DriverTransactionState { + guard !scriptedTransactionStates.isEmpty else { return scriptedTransactionState } + return scriptedTransactionStates.removeFirst() + } } final class MockSecureStore: SecureStore, @unchecked Sendable { diff --git a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift index cf2ec5998d..2114a7970b 100644 --- a/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -1,13 +1,14 @@ import Foundation -import Testing import TableProDatabase -import TableProModels @testable import TableProMobile +import TableProModels +import TableProQuery +import TableProSQLGrammar +import Testing @MainActor @Suite("QueryEditorViewModel") struct QueryEditorViewModelTests { - private func makeColumns() -> [ColumnInfo] { [ColumnInfo(name: "id", typeName: "INT", isPrimaryKey: true, isNullable: false, ordinalPosition: 0)] } @@ -21,7 +22,7 @@ struct QueryEditorViewModelTests { ] let vm = QueryEditorViewModel(windowCapacity: 100) - await vm.run(driver: driver, query: "SELECT * FROM t", maxRows: 3) + await vm.run(driver: driver, script: .statements("SELECT * FROM t"), maxRows: 3) #expect(vm.legacyRows.count == 3) #expect(vm.legacyRows.first?.first == "0") @@ -45,7 +46,7 @@ struct QueryEditorViewModelTests { ] let vm = QueryEditorViewModel(windowCapacity: 100) - let run = Task { await vm.run(driver: driver, query: "SELECT 1") } + let run = Task { await vm.run(driver: driver, script: .statements("SELECT 1")) } await ObservedCondition.wait { vm.isRunning } vm.stop() await gate.open() @@ -70,7 +71,7 @@ struct QueryEditorViewModelTests { ] let vm = QueryEditorViewModel(windowCapacity: 100) - let run = Task { await vm.run(driver: driver, query: "UPDATE t SET a = 1") } + let run = Task { await vm.run(driver: driver, script: .statements("UPDATE t SET a = 1")) } await ObservedCondition.wait { vm.isRunning } await vm.handlePressure(.critical) await gate.open() @@ -103,7 +104,7 @@ struct QueryEditorViewModelTests { ] let vm = QueryEditorViewModel(windowCapacity: 100) - await vm.run(driver: driver, query: "SELECT id FROM t", maxRows: 100) + await vm.run(driver: driver, script: .statements("SELECT id FROM t"), maxRows: 100) #expect(vm.legacyRows.count == 2) #expect(vm.truncationReason == nil) @@ -121,7 +122,7 @@ struct QueryEditorViewModelTests { ] let vm = QueryEditorViewModel(windowCapacity: 100) - await vm.run(driver: driver, query: "SELECT id FROM t", maxRows: 100) + await vm.run(driver: driver, script: .statements("SELECT id FROM t"), maxRows: 100) #expect(vm.truncationReason == nil) await vm.handlePressure(.warning) @@ -142,7 +143,7 @@ struct QueryEditorViewModelTests { ] let vm = QueryEditorViewModel(windowCapacity: 100) - await vm.run(driver: driver, query: "SELECT id FROM t", maxRows: 100) + await vm.run(driver: driver, script: .statements("SELECT id FROM t"), maxRows: 100) #expect(vm.legacyRows.count == 1) vm.reset() @@ -153,6 +154,239 @@ struct QueryEditorViewModelTests { Issue.record("expected idle phase after reset") } } + + // MARK: - Scripts + + private static let createStudent = "CREATE TABLE STUDENT (SID CHAR(5) NOT NULL PRIMARY KEY, NAME VARCHAR(20))" + private static let insertStudents = "INSERT INTO STUDENT (SID, NAME) VALUES ('03493', 'Ada'), ('03496', 'Clem')" + + @Test("every statement of a script runs, in order, and the rows they changed are summed") + func scriptRunsEveryStatementInOrder() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [], rowsAffected: 7, executionTime: 0)), + ] + let script = QueryScript.plan( + "\(Self.createStudent);\n\n\(Self.insertStudents);", + databaseType: .sqlite, + sendsBatchesWhole: false + ) + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: script) + + #expect(driver.executedQueries == [Self.createStudent, Self.insertStudents]) + #expect(vm.requestCount == 2) + #expect(vm.rowsAffectedTotal == 7) + #expect(vm.interruption == nil) + #expect(vm.scriptSummary != nil) + #expect(vm.hasOutcome) + if case .finished = vm.phase {} else { + Issue.record("expected finished phase, got \(vm.phase)") + } + } + + @Test("a statement that changes no rows still ends the run with an outcome, not the idle placeholder") + func commandWithoutRowsHasAnOutcome() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)) + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + #expect(!vm.hasOutcome) + await vm.run(driver: driver, script: .statements(Self.createStudent)) + + #expect(vm.hasOutcome) + #expect(vm.columns.isEmpty) + #expect(vm.rowsAffectedTotal == 0) + #expect(vm.scriptSummary == nil) + #expect(QueryExecutionOutcome(phase: vm.phase) == .completed) + } + + @Test("a failing statement ends the script there and names its position") + func failureStopsTheScript() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionState = .idle + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .failure(MockDatabaseDriver.MockError.scripted), + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: .statements("CREATE TABLE t (x INT)", "INSERT INTO t VALUES ('bad')", "DELETE FROM t")) + + #expect(driver.executedQueries == ["CREATE TABLE t (x INT)", "INSERT INTO t VALUES ('bad')"]) + #expect(vm.interruption == QueryEditorViewModel.ScriptInterruption( + cause: .failed(sql: "INSERT INTO t VALUES ('bad')"), + position: 2, + total: 3, + kind: .statement, + standingWork: .applied(count: 1) + )) + #expect(vm.interruptionHeadline != nil) + #expect(vm.standingWorkNote != nil) + #expect(vm.failedSQL == "INSERT INTO t VALUES ('bad')") + #expect(QueryExecutionOutcome(phase: vm.phase) == .failed) + } + + @Test("statements before a failure inside the script's own transaction are reported as held, not applied") + func failureInsideOpenTransaction() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionState = .explicitTransaction + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .failure(MockDatabaseDriver.MockError.scripted), + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: .statements("BEGIN", "INSERT INTO t VALUES (1)")) + + #expect(vm.interruption?.standingWork == .openTransaction) + } + + @Test("a failing first statement leaves nothing standing behind it") + func failureOfTheFirstStatement() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionState = .idle + driver.scriptedExecuteResults = [.failure(MockDatabaseDriver.MockError.scripted)] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: .statements("INSERT INTO t VALUES (1)", "SELECT 1")) + + #expect(driver.executedQueries == ["INSERT INTO t VALUES (1)"]) + #expect(vm.interruption?.standingWork == .nothing) + #expect(vm.interruption?.position == 1) + } + + @Test("statements a script rolled back itself are not reported as applied") + func scriptRollbackIsNotReportedAsApplied() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionStates = [.explicitTransaction, .explicitTransaction, .idle, .idle] + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .failure(MockDatabaseDriver.MockError.scripted), + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run( + driver: driver, + script: .statements("BEGIN", "INSERT INTO t VALUES (1)", "ROLLBACK", "SELECT * FROM missing_table") + ) + + #expect(vm.interruption?.standingWork == .unknown) + #expect(vm.standingWorkNote == nil) + #expect(vm.interruptionHeadline != nil) + } + + @Test("an engine that cannot report its transaction state gets no claim about earlier statements") + func unknownSessionStateMakesNoClaim() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + .failure(MockDatabaseDriver.MockError.scripted), + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: .statements("INSERT INTO t VALUES (1)", "INSERT INTO t VALUES ('bad')")) + + #expect(vm.interruption?.standingWork == .unknown) + #expect(vm.standingWorkNote == nil) + } + + @Test("a single failing statement keeps the plain error, with no script position") + func singleStatementFailureHasNoPosition() async { + let driver = MockDatabaseDriver() + driver.scriptedExecuteResults = [.failure(MockDatabaseDriver.MockError.scripted)] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: .statements("INSERT INTO t VALUES (1)")) + + #expect(vm.interruption == nil) + #expect(vm.interruptionHeadline == nil) + #expect(QueryExecutionOutcome(phase: vm.phase) == .failed) + } + + @Test("Stop during a statement starts no statement after it") + func stopStartsNoFurtherStatement() async { + let driver = MockDatabaseDriver() + let gate = QueryGate() + driver.beforeExecute = { await gate.wait() } + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + let run = Task { await vm.run(driver: driver, script: .statements("UPDATE a SET x = 1", "UPDATE b SET x = 1")) } + await ObservedCondition.wait { vm.isRunning } + vm.stop() + await run.value + await gate.open() + + #expect(!driver.executedQueries.contains("UPDATE b SET x = 1")) + #expect(vm.interruption?.cause == .stopped) + #expect(vm.interruption?.position == 1) + #expect(QueryExecutionOutcome(phase: vm.phase) == .stopped) + } + + @Test("a statement that changes how the session reads literals has the rest of the script split again") + func scriptIsSplitAgainAfterTheSessionChanges() async { + let escapes = SQLSessionLexicalFacts( + determined: .backslashEscapesInSingleQuotes, + enabled: .backslashEscapesInSingleQuotes + ) + let noEscapes = SQLSessionLexicalFacts( + determined: [.backslashEscapesInSingleQuotes, .backslashEscapesInDoubleQuotes], + enabled: [] + ) + let driver = MockDatabaseDriver() + driver.scriptedLexicalFacts = [noEscapes, noEscapes] + let script = QueryScript.plan( + "SET sql_mode = 'NO_BACKSLASH_ESCAPES'; SELECT 'a\\'; SELECT 2;", + databaseType: .mysql, + sendsBatchesWhole: false, + session: escapes + ) + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: script) + + #expect(driver.executedQueries == ["SET sql_mode = 'NO_BACKSLASH_ESCAPES'", "SELECT 'a\\'", "SELECT 2"]) + #expect(vm.requestCount == 3) + #expect(QueryExecutionOutcome(phase: vm.phase) == .completed) + } + + @Test("a driver that sends batches whole runs each GO batch whole, repeated as GO n asks") + func batchesRunWholeAndRepeat() async { + let driver = MockDatabaseDriver() + driver.sendsBatchesWhole = true + let script = QueryScript.plan( + "DECLARE @n int = 1; SELECT @n\nGO\nINSERT INTO t VALUES (1)\nGO 2", + databaseType: .mssql, + sendsBatchesWhole: driver.sendsBatchesWhole + ) + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: script) + + #expect(driver.executedQueries == [ + "DECLARE @n int = 1; SELECT @n", + "INSERT INTO t VALUES (1)", + "INSERT INTO t VALUES (1)", + ]) + #expect(vm.requestCount == 3) + } +} + +private extension QueryScript { + static func statements(_ sql: String...) -> QueryScript { + QueryScript(units: sql.map { Unit(sql: $0) }, unitKind: .statement) + } } private actor QueryGate { diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index c1d5055bd1..9976e4ed35 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -139,6 +139,7 @@ targets: - TableProMSSQLCore - TableProPluginKit - TableProQuery + - TableProSQLGrammar - TableProSSHTransport - TableProSync - TableProSyncTransport diff --git a/TableProTests/RemoteSQLite/RemoteSQLiteAgentPipeTests.swift b/TableProTests/RemoteSQLite/RemoteSQLiteAgentPipeTests.swift index 0666e947dc..d4b96752de 100644 --- a/TableProTests/RemoteSQLite/RemoteSQLiteAgentPipeTests.swift +++ b/TableProTests/RemoteSQLite/RemoteSQLiteAgentPipeTests.swift @@ -141,6 +141,21 @@ struct RemoteSQLiteAgentPipeTests { } } + @Test(.enabled(if: RemoteSQLiteAgentPipeTests.hasPython3)) + func statementThatWritesNothingReportsNoRowsAfterAWrite() throws { + try withEmptyDatabase { _, agent in + _ = try agent.runExecute("CREATE TABLE t(a INTEGER)") + _ = try agent.runExecute("INSERT INTO t VALUES(1), (2), (3)") + guard case .ok(_, _, let createChanges, _) = try agent.runExecute("CREATE TABLE u(b INTEGER)"), + case .ok(_, _, let deleteChanges, _) = try agent.runExecute("DELETE FROM t WHERE a > 9") else { + Issue.record("statement failed") + return + } + #expect(createChanges == 0) + #expect(deleteChanges == 0) + } + } + @Test(.enabled(if: RemoteSQLiteAgentPipeTests.hasPython3)) func fts3TokenizerIsDeniedByTheAuthorizer() throws { try withEmptyDatabase { _, agent in