From b582bfab8715c99af82c7690a7c5e63dd7214a0b Mon Sep 17 00:00:00 2001 From: Fang Ling <61139150+fang-ling@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:15:34 +0800 Subject: [PATCH 1/2] Add support for changing the journal mode for databases --- Sources/SQLiteKit/SQLiteConfiguration.swift | 56 +++- .../SQLiteKit/SQLiteConnectionSource.swift | 10 +- Tests/SQLiteKitTests/SQLiteKitTests.swift | 295 ++++++++++++++++++ 3 files changed, 356 insertions(+), 5 deletions(-) diff --git a/Sources/SQLiteKit/SQLiteConfiguration.swift b/Sources/SQLiteKit/SQLiteConfiguration.swift index 66e8857..b8af7f0 100644 --- a/Sources/SQLiteKit/SQLiteConfiguration.swift +++ b/Sources/SQLiteKit/SQLiteConfiguration.swift @@ -2,6 +2,40 @@ import struct Foundation.UUID /// Describes a configuration for an SQLite database connection. public struct SQLiteConfiguration: Sendable { + /// The possible journal modes for an SQLite database. + public enum JournalMode: String, Sendable { + /// The `DELETE` journaling mode is the default. In the `DELETE` mode, + /// the rollback journal is deleted at the conclusion of each + /// transaction. + case delete = "DELETE" + + /// The `TRUNCATE` journaling mode commits transactions by truncating + /// the rollback journal to zero-length instead of deleting it. + case truncate = "TRUNCATE" + + /// The `PERSIST` journaling mode prevents the rollback journal from + /// being deleted at the end of each transaction. Instead, the header of + /// the journal is overwritten with zeros. + case persist = "PERSIST" + + /// The `MEMORY` journaling mode stores the rollback journal in volatile + /// RAM. This saves disk I/O but at the expense of database safety and + /// integrity. + case memory = "MEMORY" + + /// The `WAL` journaling mode uses a write-ahead log instead of a + /// rollback journal to implement transactions. Provides better + /// concurrency and performance. + case wal = "WAL" + + /// The `OFF` journaling mode disables the rollback journal completely. + /// No rollback journal is ever created and hence there is never a + /// rollback journal to delete. The OFF journaling mode disables the + /// atomic commit and rollback capabilities of SQLite, which is + /// considered **dangerous**. + case off = "OFF" + } + /// The possible storage types for an SQLite database. public enum Storage: Sendable { /// Specify an SQLite database stored in memory, using a randomly generated identifier. @@ -41,14 +75,34 @@ public struct SQLiteConfiguration: Sendable { /// Internally issues a `PRAGMA foreign_keys = ON` query when enabled. public var enableForeignKeys: Bool + /// The journal mode to use for the database. + /// + /// Internally issues a `PRAGMA journal_mode = ` query when the connection is opened. + /// + /// Defaults to `.delete` (SQLite's default behavior). + public var journalMode: JournalMode + /// Create a new ``SQLiteConfiguration``. /// + /// Note that the `journalMode` for an in-memory database is either `MEMORY` + /// or `OFF` and can not be changed to a different value. An attempt to + /// change the `journalMode` of an in-memory database to any setting other + /// than `MEMORY` or `OFF` is ignored. Note also that the `journalMode` + /// cannot be changed while a transaction is active. + /// /// - Parameters: /// - storage: The storage type to use for the database. See ``Storage-swift.enum``. /// - enableForeignKeys: Whether to enable foreign key support by default for all connections. /// Defaults to `true`. - public init(storage: Storage, enableForeignKeys: Bool = true) { + /// - journalMode: The journal mode to use for the database. + /// See ``JournalMode-swift.enum``. Defaults to `.delete`. + public init( + storage: Storage, + enableForeignKeys: Bool = true, + journalMode: JournalMode = .delete + ) { self.storage = storage self.enableForeignKeys = enableForeignKeys + self.journalMode = journalMode } } diff --git a/Sources/SQLiteKit/SQLiteConnectionSource.swift b/Sources/SQLiteKit/SQLiteConnectionSource.swift index e327a50..0124aba 100644 --- a/Sources/SQLiteKit/SQLiteConnectionSource.swift +++ b/Sources/SQLiteKit/SQLiteConnectionSource.swift @@ -48,10 +48,12 @@ public struct SQLiteConnectionSource: ConnectionPoolSource, Sendable { logger: logger, on: eventLoop ).flatMap { conn in - if self.configuration.enableForeignKeys { - return conn.query("PRAGMA foreign_keys = ON").map { _ in conn } - } else { - return eventLoop.makeSucceededFuture(conn) + conn.query("PRAGMA journal_mode = \(self.configuration.journalMode.rawValue)").flatMap { _ in + if self.configuration.enableForeignKeys { + return conn.query("PRAGMA foreign_keys = ON").map { _ in conn } + } else { + return eventLoop.makeSucceededFuture(conn) + } } } } diff --git a/Tests/SQLiteKitTests/SQLiteKitTests.swift b/Tests/SQLiteKitTests/SQLiteKitTests.swift index 599a9f0..8b456a2 100644 --- a/Tests/SQLiteKitTests/SQLiteKitTests.swift +++ b/Tests/SQLiteKitTests/SQLiteKitTests.swift @@ -111,6 +111,301 @@ final class SQLiteKitTests: XCTestCase { XCTAssertEqual(res2[0].column("foreign_keys"), .integer(0)) } + func testJournalModeDelete() async throws { + let source = SQLiteConnectionSource( + configuration: .init( + storage: .file( + path: FileManager.default.temporaryDirectory.appendingPathComponent( + "\(UUID()).sqlite3", + isDirectory: false + ).path + ), + enableForeignKeys: false, + journalMode: .delete + ), + threadPool: .singleton + ) + + let conn = try await source.makeConnection(logger: self.connection.logger, on: MultiThreadedEventLoopGroup.singleton.any()).get() + defer { try! conn.close().wait() } + + let res = try await conn.query("PRAGMA journal_mode").get() + XCTAssertEqual(res[0].column("journal_mode")?.string?.uppercased(), "DELETE") + } + + func testJournalModeWAL() async throws { + let dbPath = FileManager.default.temporaryDirectory.appendingPathComponent( + "\(UUID()).sqlite3", + isDirectory: false + ).path + let source = SQLiteConnectionSource( + configuration: .init( + storage: .file(path: dbPath), + enableForeignKeys: false, + journalMode: .wal + ), + threadPool: .singleton + ) + + let conn = try await source.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + defer { try! conn.close().wait() } + + let res = try await conn.query("PRAGMA journal_mode").get() + XCTAssertEqual(res[0].column("journal_mode")?.string?.uppercased(), "WAL") + + // Perform a write operation to ensure WAL and SHM files are created + _ = try await conn.query("CREATE TABLE test_wal (id INTEGER PRIMARY KEY)").get() + _ = try await conn.query("INSERT INTO test_wal (id) VALUES (1)").get() + + // Verify that -wal and -shm files exist + let walPath = dbPath + "-wal" + let shmPath = dbPath + "-shm" + + XCTAssertTrue( + FileManager.default.fileExists(atPath: walPath), + "WAL file should exist at \(walPath)" + ) + XCTAssertTrue( + FileManager.default.fileExists(atPath: shmPath), + "SHM file should exist at \(shmPath)" + ) + } + + func testJournalModeTruncate() async throws { + let source = SQLiteConnectionSource( + configuration: .init( + storage: .file( + path: FileManager.default.temporaryDirectory.appendingPathComponent( + "\(UUID()).sqlite3", + isDirectory: false + ).path + ), + enableForeignKeys: false, + journalMode: .truncate + ), + threadPool: .singleton + ) + + let conn = try await source.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + defer { try! conn.close().wait() } + + let res = try await conn.query("PRAGMA journal_mode").get() + XCTAssertEqual( + res[0].column("journal_mode")?.string?.uppercased(), + "TRUNCATE" + ) + } + + func testJournalModePersist() async throws { + let source = SQLiteConnectionSource( + configuration: .init( + storage: .file( + path: FileManager.default.temporaryDirectory.appendingPathComponent( + "\(UUID()).sqlite3", + isDirectory: false + ).path + ), + enableForeignKeys: false, + journalMode: .persist + ), + threadPool: .singleton + ) + + let conn = try await source.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + defer { try! conn.close().wait() } + + let res = try await conn.query("PRAGMA journal_mode").get() + XCTAssertEqual( + res[0].column("journal_mode")?.string?.uppercased(), + "PERSIST" + ) + } + + func testJournalModeMemory() async throws { + let source = SQLiteConnectionSource( + configuration: .init( + storage: .file( + path: FileManager.default.temporaryDirectory.appendingPathComponent( + "\(UUID()).sqlite3", + isDirectory: false + ).path + ), + enableForeignKeys: false, + journalMode: .memory + ), + threadPool: .singleton + ) + + let conn = try await source.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + defer { try! conn.close().wait() } + + let res = try await conn.query("PRAGMA journal_mode").get() + XCTAssertEqual( + res[0].column("journal_mode")?.string?.uppercased(), + "MEMORY" + ) + } + + func testJournalModeOff() async throws { + let source = SQLiteConnectionSource( + configuration: .init( + storage: .file( + path: FileManager.default.temporaryDirectory.appendingPathComponent( + "\(UUID()).sqlite3", + isDirectory: false + ).path + ), + enableForeignKeys: false, + journalMode: .off + ), + threadPool: .singleton + ) + + let conn = try await source.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + defer { try! conn.close().wait() } + + let res = try await conn.query("PRAGMA journal_mode").get() + XCTAssertEqual( + res[0].column("journal_mode")?.string?.uppercased(), + "OFF" + ) + } + + func testJournalModeWithForeignKeys() async throws { + // Test that both foreign keys and WAL mode can be enabled together + let source = SQLiteConnectionSource( + configuration: .init( + storage: .file( + path: FileManager.default.temporaryDirectory.appendingPathComponent( + "\(UUID()).sqlite3", + isDirectory: false + ).path + ), + enableForeignKeys: true, + journalMode: .wal + ), + threadPool: .singleton + ) + + let conn = try await source.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + defer { try! conn.close().wait() } + + let journalRes = try await conn.query("PRAGMA journal_mode").get() + XCTAssertEqual(journalRes[0].column("journal_mode")?.string?.uppercased(), "WAL") + + let foreignKeysRes = try await conn.query("PRAGMA foreign_keys").get() + XCTAssertEqual(foreignKeysRes[0].column("foreign_keys"), .integer(1)) + + // Create tables with foreign key relationship + _ = try await conn.query("CREATE TABLE parent (id INTEGER PRIMARY KEY)").get() + _ = try await conn.query("CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))").get() + + // Insert valid parent row + _ = try await conn.query("INSERT INTO parent (id) VALUES (1)").get() + + // Insert valid child row (should succeed) + _ = try await conn.query("INSERT INTO child (id, parent_id) VALUES (1, 1)").get() + + // Try to insert child row with non-existent parent (should fail) + do { + _ = try await conn.query("INSERT INTO child (id, parent_id) VALUES (2, 999)").get() + XCTFail("Expected foreign key constraint violation") + } catch { + // Expected to fail with foreign key constraint violation + XCTAssertTrue( + error.localizedDescription.contains("FOREIGN KEY constraint failed") || + error.localizedDescription.contains("foreign key") + ) + } + + // Verify only the valid child row exists + let rows = try await conn.query("SELECT COUNT(*) as count FROM child").get() + XCTAssertEqual(rows[0].column("count"), .integer(1)) + } + + func testJournalModeWALToDelete() async throws { + // Test switching from WAL mode to DELETE mode + let dbPath = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID()).sqlite3", isDirectory: false).path + + // First, create a connection with WAL mode + let walSource = SQLiteConnectionSource( + configuration: .init( + storage: .file(path: dbPath), + enableForeignKeys: false, + journalMode: .wal + ), + threadPool: .singleton + ) + + let walConn = try await walSource.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + + // Verify WAL mode is active + let walRes = try await walConn.query("PRAGMA journal_mode").get() + XCTAssertEqual( + walRes[0].column("journal_mode")?.string?.uppercased(), + "WAL" + ) + + // Write some data + _ = try await walConn.query("CREATE TABLE test_wal_switch (id INTEGER PRIMARY KEY)").get() + _ = try await walConn.query("INSERT INTO test_wal_switch (id) VALUES (1)").get() + + // Close the connection + try await walConn.close().get() + + // Now create a new connection with DELETE mode (switching from WAL) + let deleteSource = SQLiteConnectionSource( + configuration: .init( + storage: .file(path: dbPath), + enableForeignKeys: false, + journalMode: .delete + ), + threadPool: .singleton + ) + + let deleteConn = try await deleteSource.makeConnection( + logger: self.connection.logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() + defer { try! deleteConn.close().wait() } + + // Verify the mode switched to DELETE (we now explicitly set journal mode) + let modeRes = try await deleteConn.query("PRAGMA journal_mode").get() + let currentMode = modeRes[0].column("journal_mode")?.string?.uppercased() + + // The journal mode should now be DELETE + XCTAssertEqual( + currentMode, + "DELETE", + "Should successfully switch from WAL to DELETE mode" + ) + + // Verify data is still accessible + let rows = try await deleteConn.query("SELECT COUNT(*) as count FROM test_wal_switch").get() + XCTAssertEqual(rows[0].column("count"), .integer(1)) + } + func testJSONStringColumn() async throws { _ = try await self.connection.query("CREATE TABLE foo (bar TEXT)").get() _ = try await self.connection.query(#"INSERT INTO foo (bar) VALUES ('{"baz": "qux"}')"#).get() From 1c3aa14719e6ae801daa0ed9477bc7e7ca043aac Mon Sep 17 00:00:00 2001 From: Fang Ling <61139150+fang-ling@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:25:56 +0800 Subject: [PATCH 2/2] Update the comments --- Sources/SQLiteKit/SQLiteConfiguration.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/SQLiteKit/SQLiteConfiguration.swift b/Sources/SQLiteKit/SQLiteConfiguration.swift index b8af7f0..de04ae8 100644 --- a/Sources/SQLiteKit/SQLiteConfiguration.swift +++ b/Sources/SQLiteKit/SQLiteConfiguration.swift @@ -77,9 +77,8 @@ public struct SQLiteConfiguration: Sendable { /// The journal mode to use for the database. /// - /// Internally issues a `PRAGMA journal_mode = ` query when the connection is opened. - /// - /// Defaults to `.delete` (SQLite's default behavior). + /// Internally issues a `PRAGMA journal_mode = ` query when the + /// connection is opened. public var journalMode: JournalMode /// Create a new ``SQLiteConfiguration``.