Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion Sources/SQLiteKit/SQLiteConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -41,14 +75,33 @@ 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 = <mode>` query when the
/// connection is opened.
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
}
}
10 changes: 6 additions & 4 deletions Sources/SQLiteKit/SQLiteConnectionSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
295 changes: 295 additions & 0 deletions Tests/SQLiteKitTests/SQLiteKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down