From 510b5d9561d82b5bf5f21ecc4f8717fa41f50296 Mon Sep 17 00:00:00 2001 From: Farhan Ahmed Date: Tue, 28 Oct 2025 16:07:37 -0500 Subject: [PATCH] Add ability to configure pragmas These pragmas are applied after opening a connection; e.g. enforcing foreign keys. --- .../GenerateDatabaseStruct.swift | 14 +++-- .../SQLConnectionHandler.swift | 43 ++++++++++--- .../ConnectionHandlers/SimplePool.swift | 8 ++- Sources/Lighter/Database/SQLDatabase.swift | 9 ++- .../Database/SQLDatabaseCreation.swift | 62 ++++++++++++++----- 5 files changed, 102 insertions(+), 34 deletions(-) diff --git a/Plugins/Libraries/LighterGeneration/RecordGeneration/GenerateDatabaseStruct.swift b/Plugins/Libraries/LighterGeneration/RecordGeneration/GenerateDatabaseStruct.swift index ad65784..bb666c7 100644 --- a/Plugins/Libraries/LighterGeneration/RecordGeneration/GenerateDatabaseStruct.swift +++ b/Plugins/Libraries/LighterGeneration/RecordGeneration/GenerateDatabaseStruct.swift @@ -306,7 +306,9 @@ extension EnlighterASTGenerator { info: "A `URL` pointing to the database to be used."), .init(name: "readOnly", info: - "For protocol conformance, only allowed value: `true`.") + "For protocol conformance, only allowed value: `true`."), + .init(name: "pragmas", + info: "SQLite pragmas to apply to each connection."), ] ), inlinable: options.inlinable @@ -318,11 +320,13 @@ extension EnlighterASTGenerator { .init( declaration: .makeInit(public: options.public, .init(keywordArg: "url", .name("URL")), - .init(keywordArg: "readOnly", .bool, .false) + .init(keywordArg: "readOnly", .bool, .false), + .init(keywordArg: "pragmas", + .name("[SQLConnectionHandler.Pragma] = []")), ), statements: [ .raw( - "self.\(api.connectionHandler) = .simplePool(url: url, readOnly: readOnly)" + "self.\(api.connectionHandler) = .simplePool(url: url, readOnly: readOnly, pragmas: pragmas)" ) ], comment: .init( @@ -348,7 +352,9 @@ extension EnlighterASTGenerator { info: "A `URL` pointing to the database to be used."), .init(name: "readOnly", info: "Whether the database should be opened " - + "readonly (default: `false`).") + + "readonly (default: `false`)."), + .init(name: "pragmas", + info: "SQLite pragmas to apply to each connection."), ] ), inlinable: options.inlinable diff --git a/Sources/Lighter/ConnectionHandlers/SQLConnectionHandler.swift b/Sources/Lighter/ConnectionHandlers/SQLConnectionHandler.swift index ed39733..1e17e0f 100644 --- a/Sources/Lighter/ConnectionHandlers/SQLConnectionHandler.swift +++ b/Sources/Lighter/ConnectionHandlers/SQLConnectionHandler.swift @@ -22,22 +22,27 @@ open class SQLConnectionHandler: @unchecked Sendable { * a lot of async calls. */ public static func reopen(url: URL, readOnly: Bool = false, - writeTimeout: TimeInterval = 10.0) - -> SQLConnectionHandler + writeTimeout: TimeInterval = 10.0, + pragmas: [Pragma] = []) -> SQLConnectionHandler { - SQLConnectionHandler(url: url, readOnly: readOnly, - writeTimeout: writeTimeout) + SQLConnectionHandler(url: url, + readOnly: readOnly, + writeTimeout: writeTimeout, + pragmas: pragmas) } public static func simplePool(url: URL, readOnly: Bool, maxAge: TimeInterval = 3.0, maximumPoolSizePerConfiguration: Int = 8, - writeTimeout: TimeInterval = 10.0) - -> SimplePool + writeTimeout: TimeInterval = 10.0, + pragmas: [Pragma] = []) -> SimplePool { - SimplePool(url: url, readOnly: readOnly, maxAge: maxAge, + SimplePool(url: url, + readOnly: readOnly, + maxAge: maxAge, maximumPoolSizePerConfiguration: maximumPoolSizePerConfiguration, - writeTimeout: writeTimeout) + writeTimeout: writeTimeout, + pragmas: pragmas) } /** @@ -56,6 +61,16 @@ open class SQLConnectionHandler: @unchecked Sendable { // MARK: - Configuration + public struct Pragma: Hashable, Sendable + { + public let statement: String + + @inlinable + public init(_ statement: String) { + self.statement = statement + } + } + public struct Configuration: Hashable { // Note: Would be nicer to just have the URL attached to a config and have // the handlers independent of specific URLs, but that makes other @@ -72,14 +87,18 @@ open class SQLConnectionHandler: @unchecked Sendable { public let url : URL public let readOnly : Bool public let writeTimeout : TimeInterval + public let pragmas : [Pragma] @inlinable - public init(url: URL, readOnly: Bool = false, - writeTimeout: TimeInterval = 10.0) + public init(url : URL, + readOnly : Bool = false, + writeTimeout : TimeInterval = 10.0, + pragmas : [Pragma] = []) { self.url = url self.readOnly = readOnly self.writeTimeout = writeTimeout + self.pragmas = pragmas } @@ -140,6 +159,10 @@ open class SQLConnectionHandler: @unchecked Sendable { sqlite3_busy_timeout(db, Int32(writeTimeout * 1000 /* ms */)) + for pragma in pragmas { + sqlite3_exec(db, pragma.statement, nil, nil, nil) + } + return db } diff --git a/Sources/Lighter/ConnectionHandlers/SimplePool.swift b/Sources/Lighter/ConnectionHandlers/SimplePool.swift index 67fe943..6d377a3 100644 --- a/Sources/Lighter/ConnectionHandlers/SimplePool.swift +++ b/Sources/Lighter/ConnectionHandlers/SimplePool.swift @@ -47,12 +47,16 @@ extension SQLConnectionHandler { public init(url: URL, readOnly: Bool, maxAge: TimeInterval = 3.0, maximumPoolSizePerConfiguration: Int = 8, - writeTimeout: TimeInterval) + writeTimeout: TimeInterval, + pragmas: [Pragma] = []) { self.maxAge = maxAge self.maxPerConfiguration = maximumPoolSizePerConfiguration - super.init(url: url, readOnly: readOnly, writeTimeout: writeTimeout) + super.init(url: url, + readOnly: readOnly, + writeTimeout: writeTimeout, + pragmas: pragmas) #if os(iOS) lifecycle = AppLifecycleHandler(owner: self) diff --git a/Sources/Lighter/Database/SQLDatabase.swift b/Sources/Lighter/Database/SQLDatabase.swift index 2e7d403..61dbff0 100644 --- a/Sources/Lighter/Database/SQLDatabase.swift +++ b/Sources/Lighter/Database/SQLDatabase.swift @@ -54,8 +54,9 @@ public protocol SQLDatabase: SQLDatabaseOperations { * - Parameters: * - url: The filesystem `URL` to a SQLite3 database file. * - readOnly: Whether the database should be opened read-only. + * - pragmas: SQLite pragmas to apply to each connection. */ - init(url: URL, readOnly: Bool) + init(url: URL, readOnly: Bool, pragmas: [SQLConnectionHandler.Pragma]) /** * Create or open the SQL database at the given URL. @@ -80,7 +81,11 @@ public protocol SQLDatabase: SQLDatabaseOperations { * - overwrite: Whether the database should be deleted if it * exists already (useful during development). * - databaseFileURL: The "source" database to be copied. + * - pragmas: SQLite pragmas to apply to each connection. */ - static func bootstrap(at url: URL, readOnly: Bool, overwrite: Bool, + static func bootstrap(at url: URL, + readOnly: Bool, + pragmas: [SQLConnectionHandler.Pragma], + overwrite: Bool, copying databaseFileURL: URL) throws -> Self } diff --git a/Sources/Lighter/Database/SQLDatabaseCreation.swift b/Sources/Lighter/Database/SQLDatabaseCreation.swift index c42572d..205cb35 100644 --- a/Sources/Lighter/Database/SQLDatabaseCreation.swift +++ b/Sources/Lighter/Database/SQLDatabaseCreation.swift @@ -45,7 +45,9 @@ public extension SQLDatabase { * - databaseFileURL: The URL of the database to be copied. * - Returns: The initialized database if successful. */ - static func bootstrap(at url: URL, readOnly: Bool = false, + static func bootstrap(at url: URL, + readOnly: Bool = false, + pragmas: [SQLConnectionHandler.Pragma], overwrite: Bool = false, copying databaseFileURL: URL) throws -> Self { @@ -56,7 +58,7 @@ public extension SQLDatabase { try fm.removeItem(at: url) } else { - return Self.init(url: url, readOnly: readOnly) + return Self.init(url: url, readOnly: readOnly, pragmas: pragmas) } } @@ -66,7 +68,7 @@ public extension SQLDatabase { } try fm.copyItem(at: databaseFileURL, to: url) - return Self.init(url: url, readOnly: readOnly) + return Self.init(url: url, readOnly: readOnly, pragmas: pragmas) } /** @@ -97,6 +99,7 @@ public extension SQLDatabase { domains : FileManager.SearchPathDomainMask = .userDomainMask, readOnly : Bool = false, + pragmas : [SQLConnectionHandler.Pragma], overwrite : Bool = false, copying databaseFileURL: URL) throws -> Self { @@ -106,7 +109,10 @@ public extension SQLDatabase { } let url = dir.appendingPathComponent(databaseFileURL.lastPathComponent) - return try bootstrap(at: url, readOnly: readOnly, overwrite: overwrite, + return try bootstrap(at: url, + readOnly: readOnly, + pragmas: pragmas, + overwrite: overwrite, copying: databaseFileURL) } } @@ -139,7 +145,9 @@ public extension SQLDatabase where Self: SQLCreationStatementsHolder { * exists already (useful during development). * - Returns: The initialized database if successful. */ - static func bootstrap(at url: URL, readOnly: Bool = false, + static func bootstrap(at url: URL, + readOnly: Bool = false, + pragmas: [SQLConnectionHandler.Pragma], overwrite: Bool = false) throws -> Self { let fm = FileManager.default @@ -148,7 +156,7 @@ public extension SQLDatabase where Self: SQLCreationStatementsHolder { try fm.removeItem(at: url) } else { - return Self.init(url: url, readOnly: readOnly) + return Self.init(url: url, readOnly: readOnly, pragmas: pragmas) } } @@ -170,7 +178,7 @@ public extension SQLDatabase where Self: SQLCreationStatementsHolder { throw SQLError(db) } - return Self(url: url, readOnly: readOnly) + return Self(url: url, readOnly: readOnly, pragmas: pragmas) } /** @@ -206,6 +214,7 @@ public extension SQLDatabase where Self: SQLCreationStatementsHolder { = .userDomainMask, filename : String? = nil, readOnly : Bool = false, + pragmas : [SQLConnectionHandler.Pragma], overwrite : Bool = false) throws -> Self { guard let dir = FileManager.default.urls(for: directory, in: domains).first @@ -216,7 +225,12 @@ public extension SQLDatabase where Self: SQLCreationStatementsHolder { let filename = filename ?? String(describing: self) + ".sqlite3" let url = dir.appendingPathComponent(filename) - return try bootstrap(at: url, readOnly: readOnly, overwrite: overwrite) + return try bootstrap( + at: url, + readOnly: readOnly, + pragmas: pragmas, + overwrite: overwrite + ) } } #endif // canImport(Foundation) @@ -254,14 +268,18 @@ public extension SQLDatabase where Self: SQLDatabaseAsyncOperations { * exists already (useful during development). * - databaseFileURL: The "source" database to be copied. */ - static func bootstrap(at url: URL, readOnly: Bool = false, + static func bootstrap(at url: URL, + readOnly: Bool = false, + pragmas: [SQLConnectionHandler.Pragma], overwrite: Bool = false, copying databaseFileURL: URL) async throws -> Self { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().async { do { - let db = try self.bootstrap(at: url, readOnly: readOnly, + let db = try self.bootstrap(at: url, + readOnly: readOnly, + pragmas: pragmas, overwrite: overwrite, copying: databaseFileURL) continuation.resume(returning: db) @@ -299,14 +317,18 @@ public extension SQLDatabase where Self: SQLDatabaseAsyncOperations { domains : FileManager.SearchPathDomainMask = .userDomainMask, readOnly : Bool = false, + pragmas : [SQLConnectionHandler.Pragma], overwrite : Bool = false, copying databaseFileURL: URL) async throws -> Self { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().async { do { - let db = try self.bootstrap(into: directory, domains: domains, - readOnly: readOnly, overwrite: overwrite, + let db = try self.bootstrap(into: directory, + domains: domains, + readOnly: readOnly, + pragmas: pragmas, + overwrite: overwrite, copying: databaseFileURL) continuation.resume(returning: db) } @@ -345,14 +367,18 @@ public extension SQLDatabase where Self: SQLCreationStatementsHolder, * exists already (useful during development). * - Returns: The initialized database if successful. */ - static func bootstrap(at url: URL, readOnly: Bool = false, + static func bootstrap(at url: URL, + readOnly: Bool = false, + pragmas: [SQLConnectionHandler.Pragma], overwrite: Bool = false) async throws -> Self { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().async { do { - let db = try self.bootstrap(at: url, readOnly: readOnly, + let db = try self.bootstrap(at: url, + readOnly: readOnly, + pragmas: pragmas, overwrite: overwrite) continuation.resume(returning: db) } @@ -394,13 +420,17 @@ public extension SQLDatabase where Self: SQLCreationStatementsHolder, = .userDomainMask, filename : String? = nil, readOnly : Bool = false, + pragmas : [SQLConnectionHandler.Pragma], overwrite : Bool = false) async throws -> Self { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().async { do { - let db = try self.bootstrap(into: directory, domains: domains, - filename: filename, readOnly: readOnly, + let db = try self.bootstrap(into: directory, + domains: domains, + filename: filename, + readOnly: readOnly, + pragmas: pragmas, overwrite: overwrite) continuation.resume(returning: db) }