diff --git a/Sources/SQLite/AggregateFunction.swift b/Sources/SQLite/AggregateFunction.swift index 9df0406..6eb79dc 100644 --- a/Sources/SQLite/AggregateFunction.swift +++ b/Sources/SQLite/AggregateFunction.swift @@ -32,7 +32,7 @@ public extension Connection { deterministic: Bool = false, initialState: @escaping () -> State, step: @escaping (inout State, borrowing [Binding]) -> Void, - final: @escaping (State) -> Binding + final: @escaping (State) throws -> Binding ) throws(SQLiteError) { try handle.createAggregateFunction( name, @@ -66,12 +66,12 @@ internal final class AggregateFunctionBox { let step: (AnyObject, borrowing [Binding]) -> Void - let final: (AnyObject) -> Binding + let final: (AnyObject) throws -> Binding init( initialState: @escaping () -> State, step: @escaping (inout State, borrowing [Binding]) -> Void, - final: @escaping (State) -> Binding + final: @escaping (State) throws -> Binding ) { self.makeState = { AggregateStateBox(initialState()) } self.step = { boxed, arguments in @@ -80,7 +80,7 @@ internal final class AggregateFunctionBox { } self.final = { boxed in let box = boxed as! AggregateStateBox - return final(box.state) + return try final(box.state) } } } @@ -93,7 +93,7 @@ internal extension Connection.Handle { deterministic: Bool, initialState: @escaping () -> State, step: @escaping (inout State, borrowing [Binding]) -> Void, - final: @escaping (State) -> Binding + final: @escaping (State) throws -> Binding ) -> Result { let box = AggregateFunctionBox(initialState: initialState, step: step, final: final) let context = Unmanaged.passRetained(box).toOpaque() @@ -125,7 +125,11 @@ internal extension Connection.Handle { } let functionBox = Unmanaged.fromOpaque(boxPointer).takeUnretainedValue() let stateObject = sqliteContext.finalizeAggregateState() ?? functionBox.makeState() - sqliteContext.setResult(functionBox.final(stateObject)) + do { + sqliteContext.setResult(try functionBox.final(stateObject)) + } catch { + sqliteContext.setError(error) + } }, { boxPointer in guard let boxPointer else { return } diff --git a/Sources/SQLite/Backup.swift b/Sources/SQLite/Backup.swift new file mode 100644 index 0000000..64ba127 --- /dev/null +++ b/Sources/SQLite/Backup.swift @@ -0,0 +1,125 @@ +// +// Backup.swift +// SQLite +// +// Created by Alsey Coleman Miller on 7/17/26. +// + +#if SQLITE_SWIFT_STANDALONE +import sqlite3 +#elseif SQLITE_SWIFT_SQLCIPHER +import SQLCipher +#elseif os(Linux) +import SwiftToolchainCSQLite +#else +import SQLite3 +#endif + +/// Copies the contents of one database connection to another, optionally in incremental steps. +/// +/// See: +public struct Backup: ~Copyable { + + let handle: Handle + + /// Initializes a backup of `source` into `destination`. + /// + /// - Parameters: + /// - source: Connection to copy from. + /// - sourceDatabase: Name of the attached database to copy from (`"main"` by default). + /// - destination: Connection to copy into. + /// - destinationDatabase: Name of the attached database to copy into (`"main"` by default). + public init( + source: borrowing Connection, + sourceDatabase: String = "main", + destination: borrowing Connection, + destinationDatabase: String = "main" + ) throws(SQLiteError) { + self.handle = try Handle.open( + source: source.handle, + sourceDatabase: sourceDatabase, + destination: destination.handle, + destinationDatabase: destinationDatabase + ).get() + } + + deinit { + handle.finish() + } +} + +public extension Backup { + + /// The number of pages still to be copied as of the most recent call to `step(pageCount:)`. + var remainingPageCount: Int32 { + handle.remainingPageCount + } + + /// The total number of pages in the source database as of the most recent call to `step(pageCount:)`. + var totalPageCount: Int32 { + handle.totalPageCount + } + + /// Copies up to `pageCount` pages from the source to the destination database. + /// + /// - Parameter pageCount: The number of pages to copy, or a negative value to copy every remaining page. + /// - Returns: `true` if there are more pages left to copy, `false` if the backup is complete. + mutating func step(pageCount: Int32 = -1) throws(SQLiteError) -> Bool { + try handle.step(pageCount: pageCount).get() + } +} + +// MARK: - Supporting Types + +internal extension Backup { + + struct Handle { + + let pointer: OpaquePointer + + let destination: Connection.Handle + } +} + +internal extension Backup.Handle { + + static func open( + source: Connection.Handle, + sourceDatabase: String, + destination: Connection.Handle, + destinationDatabase: String + ) -> Result { + guard let pointer = sqlite3_backup_init( + destination.pointer, + destinationDatabase, + source.pointer, + sourceDatabase + ) else { + return .failure(destination.forceError(destination.errorCode ?? .init(SQLITE_ERROR))) + } + return .success(Backup.Handle(pointer: pointer, destination: destination)) + } + + consuming func finish() { + sqlite3_backup_finish(pointer) + } + + var remainingPageCount: Int32 { + sqlite3_backup_remaining(pointer) + } + + var totalPageCount: Int32 { + sqlite3_backup_pagecount(pointer) + } + + func step(pageCount: Int32) -> Result { + let resultCode = sqlite3_backup_step(pointer, pageCount) + if resultCode == SQLITE_DONE { + return .success(false) + } + if resultCode == SQLITE_OK { + return .success(true) + } + return .failure(destination.forceError(SQLiteError.ErrorCode(resultCode))) + } +} diff --git a/Sources/SQLite/Connection.swift b/Sources/SQLite/Connection.swift index 6a42ed2..6f23a40 100644 --- a/Sources/SQLite/Connection.swift +++ b/Sources/SQLite/Connection.swift @@ -72,6 +72,23 @@ public extension Connection { var filename: String { handle.filename } + + /// Whether the connection is currently outside of an explicit transaction (i.e. in autocommit mode). + var isAutocommit: Bool { + handle.isAutocommit + } + + /// Sets a busy timeout, causing operations on locked tables to retry for up to `milliseconds` + /// before returning `SQLITE_BUSY`, instead of failing immediately. + func setBusyTimeout(_ milliseconds: Int32) { + handle.setBusyTimeout(milliseconds) + } + + /// Interrupts a long-running query on this connection from another thread, causing it to fail + /// with `SQLITE_INTERRUPT` as soon as possible. + func interrupt() { + handle.interrupt() + } } // MARK: - Methods @@ -118,7 +135,10 @@ internal extension Connection { internal extension Connection.Handle { consuming func close() { - sqlite3_close(pointer) + // sqlite3_close_v2 allows the connection to be deallocated even if statements or + // blob handles created against it haven't been finalized/closed yet, deferring + // actual deallocation until they are. + sqlite3_close_v2(pointer) } static func open(path: String, readonly: Bool = false) -> Result { @@ -210,4 +230,17 @@ internal extension Connection.Handle { var filename: String { sqlite3_db_filename(pointer, nil).map { String(cString: $0) } ?? "" } + + /// Whether the connection is currently outside of an explicit transaction (i.e. in autocommit mode). + var isAutocommit: Bool { + sqlite3_get_autocommit(pointer) != 0 + } + + func setBusyTimeout(_ milliseconds: Int32) { + sqlite3_busy_timeout(pointer, milliseconds) + } + + func interrupt() { + sqlite3_interrupt(pointer) + } } diff --git a/Sources/SQLite/Function.swift b/Sources/SQLite/Function.swift index 55c5c57..7975a88 100644 --- a/Sources/SQLite/Function.swift +++ b/Sources/SQLite/Function.swift @@ -28,7 +28,7 @@ public extension Connection { _ name: String, argumentCount: Int32? = nil, deterministic: Bool = false, - _ block: @escaping (borrowing [Binding]) -> Binding + _ block: @escaping (borrowing [Binding]) throws -> Binding ) throws(SQLiteError) { try handle.createFunction( name, @@ -48,9 +48,9 @@ public extension Connection { fileprivate final class FunctionBox { - let block: (borrowing [Binding]) -> Binding + let block: (borrowing [Binding]) throws -> Binding - init(_ block: @escaping (borrowing [Binding]) -> Binding) { + init(_ block: @escaping (borrowing [Binding]) throws -> Binding) { self.block = block } } @@ -61,7 +61,7 @@ internal extension Connection.Handle { _ name: String, argumentCount: Int32, deterministic: Bool, - block: @escaping (borrowing [Binding]) -> Binding + block: @escaping (borrowing [Binding]) throws -> Binding ) -> Result { let box = FunctionBox(block) let context = Unmanaged.passRetained(box).toOpaque() @@ -84,8 +84,12 @@ internal extension Connection.Handle { let value = argv?[index] return Binding(sqliteValue: value) } - let result = box.block(arguments) - sqliteContext.setResult(result) + do { + let result = try box.block(arguments) + sqliteContext.setResult(result) + } catch { + sqliteContext.setError(error) + } }, nil, nil, @@ -161,4 +165,10 @@ internal extension OpaquePointer { } } } + + /// Reports `error` as the result of a SQL function invocation, given `self` as the `sqlite3_context`. + func setError(_ error: Error) { + let message = String(describing: error) + sqlite3_result_error(self, message, -1) + } } diff --git a/Sources/SQLite/IncrementalBlob.swift b/Sources/SQLite/IncrementalBlob.swift new file mode 100644 index 0000000..727e1bc --- /dev/null +++ b/Sources/SQLite/IncrementalBlob.swift @@ -0,0 +1,151 @@ +// +// IncrementalBlob.swift +// SQLite +// +// Created by Alsey Coleman Miller on 7/17/26. +// + +#if SQLITE_SWIFT_STANDALONE +import sqlite3 +#elseif SQLITE_SWIFT_SQLCIPHER +import SQLCipher +#elseif os(Linux) +import SwiftToolchainCSQLite +#else +import SQLite3 +#endif + +/// Streams a single BLOB value's bytes in and out without loading the whole value into memory at once. +/// +/// The target column must have been populated (e.g. via `.blob(.zero(_:))`) before opening a handle to it; +/// incremental I/O never changes the BLOB's length, only its content. +public struct IncrementalBlob: ~Copyable { + + let handle: Handle + + /// Opens an incremental I/O handle onto a BLOB value. + /// + /// - Parameters: + /// - table: Name of the table containing the BLOB. + /// - column: Name of the column containing the BLOB. + /// - row: `rowid` of the row containing the BLOB. + /// - database: Name of the attached database containing the table (`"main"` by default). + /// - writable: Whether the handle should permit writes in addition to reads. + public init( + connection: borrowing Connection, + table: String, + column: String, + row: Int64, + database: String = "main", + writable: Bool = false + ) throws(SQLiteError) { + self.handle = try Handle.open( + connection: connection.handle, + database: database, + table: table, + column: column, + row: row, + writable: writable + ).get() + } + + deinit { + handle.close() + } +} + +public extension IncrementalBlob { + + /// The size, in bytes, of the BLOB this handle was opened onto. + var byteCount: Int32 { + handle.byteCount + } + + /// Reads `count` bytes starting at `offset` from the BLOB. + func read(at offset: Int32 = 0, count: Int32) throws(SQLiteError) -> [UInt8] { + try handle.read(at: offset, count: count).get() + } + + /// Writes `bytes` into the BLOB starting at `offset`. + /// + /// The write must not extend past the BLOB's existing length; use a `.blob(.zero(_:))` binding + /// of the desired final size to reserve space beforehand. + func write(_ bytes: [UInt8], at offset: Int32 = 0) throws(SQLiteError) { + try handle.write(bytes, at: offset).get() + } + + /// Re-points this handle at a different row, avoiding the overhead of closing and reopening it. + mutating func reopen(row: Int64) throws(SQLiteError) { + try handle.reopen(row: row).get() + } +} + +// MARK: - Supporting Types + +internal extension IncrementalBlob { + + struct Handle { + + let pointer: OpaquePointer + + let connection: Connection.Handle + } +} + +internal extension IncrementalBlob.Handle { + + static func open( + connection: Connection.Handle, + database: String, + table: String, + column: String, + row: Int64, + writable: Bool + ) -> Result { + var pointer: OpaquePointer? + let resultCode = sqlite3_blob_open( + connection.pointer, + database, + table, + column, + row, + writable ? 1 : 0, + &pointer + ) + guard let pointer else { + return .failure(connection.forceError(SQLiteError.ErrorCode(resultCode))) + } + let handle = IncrementalBlob.Handle(pointer: pointer, connection: connection) + guard resultCode == SQLITE_OK else { + return .failure(connection.forceError(SQLiteError.ErrorCode(resultCode))) + } + return .success(handle) + } + + consuming func close() { + sqlite3_blob_close(pointer) + } + + var byteCount: Int32 { + sqlite3_blob_bytes(pointer) + } + + func read(at offset: Int32, count: Int32) -> Result<[UInt8], SQLiteError> { + var buffer = [UInt8](repeating: 0, count: Int(count)) + let resultCode = buffer.withUnsafeMutableBytes { rawBuffer in + sqlite3_blob_read(pointer, rawBuffer.baseAddress, count, offset) + } + return connection.check(resultCode).map { buffer } + } + + func write(_ bytes: [UInt8], at offset: Int32) -> Result { + let resultCode = bytes.withUnsafeBytes { rawBuffer in + sqlite3_blob_write(pointer, rawBuffer.baseAddress, Int32(bytes.count), offset) + } + return connection.check(resultCode) + } + + func reopen(row: Int64) -> Result { + connection.check(sqlite3_blob_reopen(pointer, row)) + } +} diff --git a/Sources/SQLite/Statement.swift b/Sources/SQLite/Statement.swift index a8f7217..77dd89a 100644 --- a/Sources/SQLite/Statement.swift +++ b/Sources/SQLite/Statement.swift @@ -48,6 +48,24 @@ public extension Statement { func columnName(at index: Int) -> String { handle.columnName(at: Int32(index)) } + + /// The SQL text of the statement with bound parameters expanded to their values. + var expandedSQL: String? { + handle.expandedSQL + } +} + +public extension Statement { + + /// Resets the statement so it can be re-executed, without changing any of its bindings. + mutating func reset() { + handle.reset() + } + + /// Clears all bindings on the statement, setting every parameter back to `NULL`. + mutating func clearBindings() { + handle.clearBindings() + } } public extension Statement { @@ -106,7 +124,23 @@ internal extension Statement.Handle { var sql: String { String(cString: sqlite3_sql(pointer)) } - + + var expandedSQL: String? { + guard let cString = sqlite3_expanded_sql(pointer) else { + return nil + } + defer { sqlite3_free(cString) } + return String(cString: cString) + } + + func reset() { + sqlite3_reset(pointer) + } + + func clearBindings() { + sqlite3_clear_bindings(pointer) + } + var columnCount: Int32 { sqlite3_column_count(pointer) } diff --git a/Sources/SQLite/WAL.swift b/Sources/SQLite/WAL.swift new file mode 100644 index 0000000..05569c4 --- /dev/null +++ b/Sources/SQLite/WAL.swift @@ -0,0 +1,120 @@ +// +// WAL.swift +// SQLite +// +// Created by Alsey Coleman Miller on 7/17/26. +// + +#if SQLITE_SWIFT_STANDALONE +import sqlite3 +#elseif SQLITE_SWIFT_SQLCIPHER +import SQLCipher +#elseif os(Linux) +import SwiftToolchainCSQLite +#else +import SQLite3 +#endif + +public extension Connection { + + /// Enables or disables [write-ahead logging](https://www.sqlite.org/wal.html) for this connection. + /// + /// There is no dedicated C API for changing the journal mode; this issues `PRAGMA journal_mode` + /// and reports back the mode SQLite actually applied (WAL mode cannot be entered for in-memory + /// or temporary databases, for example). + @discardableResult + func setJournalMode(_ mode: JournalMode) throws(SQLiteError) -> JournalMode { + guard let rawValue = try scalar("PRAGMA journal_mode = \(mode.rawValue)")?.string, + let appliedMode = JournalMode(rawValue: rawValue.lowercased()) else { + return try journalMode + } + return appliedMode + } + + /// The connection's current journal mode. + var journalMode: JournalMode { + get throws(SQLiteError) { + guard let rawValue = try scalar("PRAGMA journal_mode")?.string, + let mode = JournalMode(rawValue: rawValue.lowercased()) else { + return .delete + } + return mode + } + } + + /// Checkpoints the write-ahead log, copying its contents back into the main database file. + /// + /// - Parameters: + /// - mode: How aggressively to checkpoint; see `Connection.CheckpointMode`. + /// - database: Name of the attached database to checkpoint, or `nil` for all attached databases. + /// - Returns: The WAL's size in frames, and how many of those frames were checkpointed. + @discardableResult + func walCheckpoint( + mode: CheckpointMode = .passive, + database: String? = nil + ) throws(SQLiteError) -> (logFrameCount: Int32, checkpointedFrameCount: Int32) { + try handle.walCheckpoint(mode: mode, database: database).get() + } + + /// Configures the WAL auto-checkpoint threshold: the write-ahead log is checkpointed automatically + /// once it grows past `pageCount` pages. Pass `0` to disable automatic checkpointing. + func setWALAutoCheckpoint(pageCount: Int32) throws(SQLiteError) { + try handle.setWALAutoCheckpoint(pageCount: pageCount).get() + } +} + +// MARK: - Supporting Types + +public extension Connection { + + /// A SQLite [journal mode](https://www.sqlite.org/pragma.html#pragma_journal_mode). + enum JournalMode: String, Equatable, Hashable, Sendable { + + case delete + case truncate + case persist + case memory + case wal + case off + } + + /// A [checkpoint mode](https://www.sqlite.org/c3ref/wal_checkpoint_v2.html) for `walCheckpoint(mode:database:)`. + enum CheckpointMode: Int32, Equatable, Hashable, Sendable { + + /// Checkpoints as many frames as possible without blocking readers or writers. + case passive = 0 // SQLITE_CHECKPOINT_PASSIVE + + /// Blocks until all writers are done, then checkpoints. + case full = 1 // SQLITE_CHECKPOINT_FULL + + /// Like `.full`, but also blocks until all readers are done, so the log can be reset. + case restart = 2 // SQLITE_CHECKPOINT_RESTART + + /// Like `.restart`, and additionally truncates the WAL file to zero bytes on completion. + case truncate = 3 // SQLITE_CHECKPOINT_TRUNCATE + } +} + +internal extension Connection.Handle { + + func walCheckpoint( + mode: Connection.CheckpointMode, + database: String? + ) -> Result<(logFrameCount: Int32, checkpointedFrameCount: Int32), SQLiteError> { + var logFrameCount: Int32 = 0 + var checkpointedFrameCount: Int32 = 0 + let resultCode = sqlite3_wal_checkpoint_v2( + pointer, + database, + mode.rawValue, + &logFrameCount, + &checkpointedFrameCount + ) + return check(resultCode).map { (logFrameCount, checkpointedFrameCount) } + } + + func setWALAutoCheckpoint(pageCount: Int32) -> Result { + let resultCode = sqlite3_wal_autocheckpoint(pointer, pageCount) + return check(resultCode) + } +} diff --git a/Tests/SQLiteTests/BackupTests.swift b/Tests/SQLiteTests/BackupTests.swift new file mode 100644 index 0000000..5de348e --- /dev/null +++ b/Tests/SQLiteTests/BackupTests.swift @@ -0,0 +1,48 @@ +// +// BackupTests.swift +// SQLite +// +// Created by Alsey Coleman Miller on 7/17/26. +// + +import Testing +@testable import SQLite + +@Suite struct BackupTests { + + @Test func backupCopiesAllPagesInOneStep() throws { + let source = try Connection(path: ":memory:") + try source.run("CREATE TABLE t (value TEXT)") + try source.run("INSERT INTO t (value) VALUES ('a')") + try source.run("INSERT INTO t (value) VALUES ('b')") + + let destination = try Connection(path: ":memory:") + var backup = try Backup(source: source, destination: destination) + #expect(try backup.step() == false) // a negative page count copies everything in one call + #expect(backup.remainingPageCount == 0) + + #expect(try destination.scalar("SELECT COUNT(*) FROM t")?.integer == 2) + } + + @Test func backupCopiesIncrementally() throws { + let source = try Connection(path: ":memory:") + try source.run("CREATE TABLE t (value INTEGER)") + for value in 1...50 { + try source.run("INSERT INTO t (value) VALUES (?)", [value.binding]) + } + + let destination = try Connection(path: ":memory:") + var backup = try Backup(source: source, destination: destination) + var stepCount = 0 + while try backup.step(pageCount: 1) { + stepCount += 1 + } + // more than one page was required to copy the whole database + #expect(stepCount > 0) + #expect(backup.totalPageCount > 0) + #expect(backup.remainingPageCount == 0) + let sourceSum = try source.scalar("SELECT SUM(value) FROM t")?.integer + let destinationSum = try destination.scalar("SELECT SUM(value) FROM t")?.integer + #expect(destinationSum == sourceSum) + } +} diff --git a/Tests/SQLiteTests/ConnectionStatementTests.swift b/Tests/SQLiteTests/ConnectionStatementTests.swift index 8a56c02..a3589a2 100644 --- a/Tests/SQLiteTests/ConnectionStatementTests.swift +++ b/Tests/SQLiteTests/ConnectionStatementTests.swift @@ -151,4 +151,76 @@ import Testing try connection.run("INSERT INTO t (value) VALUES (1)") } } + + // MARK: - reset / clearBindings / expandedSQL + + @Test func resetAllowsStatementReuse() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (value INTEGER)") + let statement = try Statement("INSERT INTO t (value) VALUES (?)", connection: connection) + let handle = statement.handle + try handle.bind(.integer(1), at: 1, connection: connection.handle).get() + _ = try handle.step(connection: connection.handle).get() + handle.reset() + try handle.bind(.integer(2), at: 1, connection: connection.handle).get() + _ = try handle.step(connection: connection.handle).get() + #expect(try connection.scalar("SELECT COUNT(*) FROM t")?.integer == 2) + #expect(try connection.scalar("SELECT SUM(value) FROM t")?.integer == 3) + } + + @Test func clearBindingsResetsParametersToNull() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (value INTEGER)") + let statement = try Statement("INSERT INTO t (value) VALUES (?)", connection: connection) + let handle = statement.handle + try handle.bind(.integer(1), at: 1, connection: connection.handle).get() + handle.clearBindings() + _ = try handle.step(connection: connection.handle).get() + #expect(try connection.scalar("SELECT value FROM t")?.integer == nil) + #expect(try connection.scalar("SELECT value IS NULL FROM t")?.integer == 1) + } + + @Test func expandedSQLSubstitutesBoundValues() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (value TEXT)") + let statement = try Statement.prepare( + "INSERT INTO t (value) VALUES (?)", + bindings: [.text("hello")], + connection: connection + ) + #expect(statement.expandedSQL == "INSERT INTO t (value) VALUES ('hello')") + } + + // MARK: - Interrupt / busy timeout / autocommit + + @Test func autocommitReflectsTransactionState() throws { + let connection = try Connection(path: ":memory:") + #expect(connection.isAutocommit == true) + try connection.run("BEGIN") + #expect(connection.isAutocommit == false) + try connection.run("COMMIT") + #expect(connection.isAutocommit == true) + } + + @Test func interruptFailsPendingStep() throws { + let connection = try Connection(path: ":memory:") + // a recursive query that yields many rows, so a second step is still pending + // when interrupt() is called between the first and second sqlite3_step calls + let statement = try Statement( + "WITH RECURSIVE cnt(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM cnt WHERE x < 1000000) SELECT x FROM cnt", + connection: connection + ) + let handle = statement.handle + #expect(try handle.step(connection: connection.handle).get()) + connection.interrupt() + #expect(throws: SQLiteError.self) { + _ = try handle.step(connection: connection.handle).get() + } + } + + @Test func setBusyTimeoutDoesNotThrow() throws { + let connection = try Connection(path: ":memory:") + // no observable state to assert on beyond the call succeeding + connection.setBusyTimeout(50) + } } diff --git a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift index f2aaadc..e2f7f0c 100644 --- a/Tests/SQLiteTests/FunctionEdgeCaseTests.swift +++ b/Tests/SQLiteTests/FunctionEdgeCaseTests.swift @@ -61,6 +61,54 @@ import Testing #expect(try connection.scalar("SELECT hex(make_zeroblob())")?.string == "0000") } + @Test func scalarFunctionThrowsReportsErrorToCaller() throws { + struct DivideByZero: Error, CustomStringConvertible { + var description: String { "cannot divide by zero" } + } + let connection = try Connection(path: ":memory:") + try connection.createFunction("safe_divide", argumentCount: 2) { arguments in + let divisor = arguments[1].integer ?? 0 + guard divisor != 0 else { throw DivideByZero() } + return .integer((arguments[0].integer ?? 0) / divisor) + } + #expect(try connection.scalar("SELECT safe_divide(10, 2)")?.integer == 5) + do { + _ = try connection.scalar("SELECT safe_divide(10, 0)") + Issue.record("Expected safe_divide(10, 0) to throw") + } catch { + #expect(error.message == "cannot divide by zero") + } + } + + @Test func aggregateFunctionFinalThrowsReportsErrorToCaller() throws { + struct EmptyGroup: Error, CustomStringConvertible { + var description: String { "group must not be empty" } + } + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (value INTEGER)") + try connection.createAggregateFunction( + "require_nonempty_sum", + argumentCount: 1, + initialState: { (Int64(0), false) }, + step: { state, arguments in + state.0 += arguments[0].integer ?? 0 + state.1 = true + }, + final: { state throws in + guard state.1 else { throw EmptyGroup() } + return .integer(state.0) + } + ) + try connection.run("INSERT INTO t (value) VALUES (5)") + #expect(try connection.scalar("SELECT require_nonempty_sum(value) FROM t")?.integer == 5) + do { + _ = try connection.scalar("SELECT require_nonempty_sum(value) FROM t WHERE 0") + Issue.record("Expected require_nonempty_sum over an empty group to throw") + } catch { + #expect(error.message == "group must not be empty") + } + } + // MARK: - Collations @Test func removeCollation() throws { diff --git a/Tests/SQLiteTests/IncrementalBlobTests.swift b/Tests/SQLiteTests/IncrementalBlobTests.swift new file mode 100644 index 0000000..f073714 --- /dev/null +++ b/Tests/SQLiteTests/IncrementalBlobTests.swift @@ -0,0 +1,68 @@ +// +// IncrementalBlobTests.swift +// SQLite +// +// Created by Alsey Coleman Miller on 7/17/26. +// + +import Testing +@testable import SQLite + +@Suite struct IncrementalBlobTests { + + @Test func readWholeBlob() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value BLOB)") + try connection.run("INSERT INTO t (id, value) VALUES (1, ?)", [Blob(bytes: [0xCA, 0xFE, 0xF0, 0x0D]).binding]) + let blob = try IncrementalBlob(connection: connection, table: "t", column: "value", row: 1) + #expect(blob.byteCount == 4) + #expect(try blob.read(count: 4) == [0xCA, 0xFE, 0xF0, 0x0D]) + } + + @Test func readPartialRange() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value BLOB)") + try connection.run("INSERT INTO t (id, value) VALUES (1, ?)", [Blob(bytes: [0, 1, 2, 3, 4, 5]).binding]) + let blob = try IncrementalBlob(connection: connection, table: "t", column: "value", row: 1) + #expect(try blob.read(at: 2, count: 3) == [2, 3, 4]) + } + + @Test func writeUpdatesUnderlyingColumn() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value BLOB)") + // reserve a fixed-size placeholder; incremental writes never change a BLOB's length + try connection.run("INSERT INTO t (id, value) VALUES (1, ?)", [.blob(.zero(4))]) + let blob = try IncrementalBlob(connection: connection, table: "t", column: "value", row: 1, writable: true) + try blob.write([0xDE, 0xAD, 0xBE, 0xEF]) + #expect(try connection.scalar("SELECT hex(value) FROM t")?.string == "DEADBEEF") + } + + @Test func writeToReadOnlyHandleFails() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value BLOB)") + try connection.run("INSERT INTO t (id, value) VALUES (1, ?)", [.blob(.zero(4))]) + let blob = try IncrementalBlob(connection: connection, table: "t", column: "value", row: 1, writable: false) + #expect(throws: SQLiteError.self) { + try blob.write([0xDE, 0xAD, 0xBE, 0xEF]) + } + } + + @Test func reopenPointsAtDifferentRow() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value BLOB)") + try connection.run("INSERT INTO t (id, value) VALUES (1, ?)", [Blob(bytes: [1, 1, 1, 1]).binding]) + try connection.run("INSERT INTO t (id, value) VALUES (2, ?)", [Blob(bytes: [2, 2, 2, 2]).binding]) + var blob = try IncrementalBlob(connection: connection, table: "t", column: "value", row: 1) + #expect(try blob.read(count: 4) == [1, 1, 1, 1]) + try blob.reopen(row: 2) + #expect(try blob.read(count: 4) == [2, 2, 2, 2]) + } + + @Test func openOnMissingRowFails() throws { + let connection = try Connection(path: ":memory:") + try connection.run("CREATE TABLE t (id INTEGER PRIMARY KEY, value BLOB)") + #expect(throws: SQLiteError.self) { + _ = try IncrementalBlob(connection: connection, table: "t", column: "value", row: 999) + } + } +} diff --git a/Tests/SQLiteTests/WALTests.swift b/Tests/SQLiteTests/WALTests.swift new file mode 100644 index 0000000..e95f5b2 --- /dev/null +++ b/Tests/SQLiteTests/WALTests.swift @@ -0,0 +1,58 @@ +// +// WALTests.swift +// SQLite +// +// Created by Alsey Coleman Miller on 7/17/26. +// + +import Foundation +import Testing +@testable import SQLite + +@Suite struct WALTests { + + @Test func enableWALModeOnFileBackedConnection() throws { + let path = NSTemporaryDirectory() + "wal-\(UUID().uuidString).sqlite" + defer { + try? FileManager.default.removeItem(atPath: path) + try? FileManager.default.removeItem(atPath: path + "-wal") + try? FileManager.default.removeItem(atPath: path + "-shm") + } + let connection = try Connection(path: path) + let applied = try connection.setJournalMode(.wal) + #expect(applied == .wal) + let current = try connection.journalMode + #expect(current == .wal) + } + + @Test func inMemoryDatabaseCannotEnterWALMode() throws { + // WAL mode is unsupported for in-memory databases; SQLite silently keeps "memory" instead + let connection = try Connection(path: ":memory:") + let applied = try connection.setJournalMode(.wal) + #expect(applied == .memory) + } + + @Test func checkpointReportsFrameCounts() throws { + let path = NSTemporaryDirectory() + "wal-checkpoint-\(UUID().uuidString).sqlite" + defer { + try? FileManager.default.removeItem(atPath: path) + try? FileManager.default.removeItem(atPath: path + "-wal") + try? FileManager.default.removeItem(atPath: path + "-shm") + } + let connection = try Connection(path: path) + _ = try connection.setJournalMode(.wal) + try connection.run("CREATE TABLE t (value INTEGER)") + try connection.run("INSERT INTO t (value) VALUES (1)") + + let (logFrameCount, checkpointedFrameCount) = try connection.walCheckpoint(mode: .truncate) + #expect(logFrameCount >= 0) + #expect(checkpointedFrameCount >= 0) + #expect(checkpointedFrameCount <= logFrameCount) + } + + @Test func setWALAutoCheckpointDoesNotThrow() throws { + let connection = try Connection(path: ":memory:") + try connection.setWALAutoCheckpoint(pageCount: 1000) + try connection.setWALAutoCheckpoint(pageCount: 0) // disables auto-checkpointing + } +}