diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48b2792..4b44327 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,6 +43,7 @@ jobs: ios_scheme_name: sqlite-nio with_musl: true with_android: true + with_wasm: true submit-dependencies: permissions: diff --git a/Package.swift b/Package.swift index aaaa37e..b2957cb 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,12 @@ // swift-tools-version:6.1 import PackageDescription +/// `.when(platforms:)` can only include, never exclude, so excluding WASI means listing everything else. +/// This list matches the [supported platforms on the Swift 6.1 release of SPM](https://github.com/swiftlang/swift-package-manager/blob/release/6.1/Sources/PackageDescription/SupportedPlatforms.swift). +/// Don't add new platforms here unless raising the swift-tools-version of this manifest. +let allPlatforms: [Platform] = [.macOS, .macCatalyst, .iOS, .tvOS, .watchOS, .visionOS, .driverKit, .linux, .windows, .android, .wasi, .openbsd] +let nonWASIPlatforms: [Platform] = allPlatforms.filter { $0 != .wasi } + let package = Package( name: "sqlite-nio", platforms: [ @@ -37,11 +43,16 @@ let package = Package( dependencies: [ .target(name: "VaporCSQLite"), .product(name: "Logging", package: "swift-log"), - .product(name: "NIOCore", package: "swift-nio"), - .product(name: "NIOPosix", package: "swift-nio"), + // SwiftNIO does not support wasm32-unknown-wasip1: NIOPosix is built around POSIX + // sockets and threads, neither of which WASI preview 1 provides. On WASI these + // products are therefore not linked, and the `#if canImport(NIOCore)` gates in + // Sources/ drop the `EventLoopFuture` API in favor of the `async` one. + // (`NIOFoundationCompat` keeps its Darwin-only condition, which already excludes WASI.) + .product(name: "NIOCore", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), + .product(name: "NIOPosix", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), .product(name: "NIOFoundationCompat", package: "swift-nio", condition: .when(platforms: [.macOS, .iOS, .tvOS, .watchOS, .macCatalyst, .visionOS])), - .product(name: "NIOFoundationEssentialsCompat", package: "swift-nio"), + .product(name: "NIOFoundationEssentialsCompat", package: "swift-nio", condition: .when(platforms: nonWASIPlatforms)), ], swiftSettings: swiftSettings ), diff --git a/Sources/SQLiteNIO/Exports.swift b/Sources/SQLiteNIO/Exports.swift index 0542622..07d619d 100644 --- a/Sources/SQLiteNIO/Exports.swift +++ b/Sources/SQLiteNIO/Exports.swift @@ -1,5 +1,80 @@ +#if canImport(NIOCore) @_documentation(visibility: internal) @_exported import struct NIOCore.ByteBuffer @_documentation(visibility: internal) @_exported import class NIOPosix.NIOThreadPool @_documentation(visibility: internal) @_exported import protocol NIOCore.EventLoop @_documentation(visibility: internal) @_exported import protocol NIOCore.EventLoopGroup @_documentation(visibility: internal) @_exported import class NIOPosix.MultiThreadedEventLoopGroup +#else // !canImport(NIOCore) +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// `BLOB` values are carried by `[UInt8]` on platforms where SwiftNIO is not available. +/// +/// SwiftNIO does not support `wasm32-unknown-wasip1`, so `NIOCore.ByteBuffer` cannot be re-exported +/// above (see the product conditions in `Package.swift`). Declaring the substitution as a typealias +/// rather than forking every declaration that mentions `ByteBuffer` keeps ``SQLiteData`` and friends +/// as a single implementation across both configurations. +public typealias ByteBuffer = [UInt8] + +/// The subset of `NIOCore.ByteBuffer`'s API that this package uses, expressed over `[UInt8]`. +/// +/// These are deliberately not `public`: they exist only so the shared implementation compiles +/// unchanged, and making them public would graft NIO-flavored members onto every `[UInt8]` in any +/// module that imports `SQLiteNIO`. +extension ByteBuffer { + init(bytes: some Sequence) { + self.init(bytes) + } + + init(data: Data) { + self.init(data) + } + + var readableBytes: Int { + self.count + } + + var readableBytesView: Self { + self + } + + func withUnsafeReadableBytes(_ body: (UnsafeRawBufferPointer) throws -> T) rethrows -> T { + try self.withUnsafeBytes(body) + } +} + +// N.B.: A tripwire, not a platform gate. The stand-in below takes no lock, so it only supports +// single-threaded use: `wasm32-unknown-wasip1` qualifies, `wasm32-unknown-wasip1-threads` does not. +#if _runtime(_multithreaded) +#error(""" + SQLiteNIO's `canImport(NIOCore)` fallback has been selected for a multithreaded runtime, and the \ + `NIOLockedValueBox` below does not support multithreaded locking. Please use a \ + multithreading-capable lock for this platform. + """) +#endif + +/// A single-threaded stand-in for `NIOConcurrencyHelpers.NIOLockedValueBox`. +/// +/// The one supported SwiftNIO-free target is single-threaded, so no lock is needed and none is +/// taken: the box exists so the `sqlite3_initialize()` guard and the hook observer storage in +/// ``SQLiteConnection`` read the same in both configurations. `@unchecked Sendable` is sound for +/// the same reason. +final class NIOLockedValueBox: @unchecked Sendable { + private var value: Value + private var isLocked = false + + init(_ value: Value) { + self.value = value + } + + func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + assert(!self.isLocked, "NIOLockedValueBox was re-entered unexpectedly; this implementation supports single-threaded use only") + self.isLocked = true + defer { self.isLocked = false } + return try mutate(&self.value) + } +} +#endif // !canImport(NIOCore) diff --git a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift index e9f9c93..be96916 100644 --- a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift +++ b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift @@ -3,8 +3,10 @@ import FoundationEssentials #else import Foundation #endif +#if canImport(NIOCore) import NIOConcurrencyHelpers import NIOCore +#endif // canImport(NIOCore) import VaporCSQLite // MARK: - Hook Types and Events @@ -495,7 +497,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when update events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addUpdateObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteUpdateHookCallback) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .update, action: { $0.updateHooks[id] = callback }) @@ -526,7 +528,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when commit events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addCommitObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteCommitObserver) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .commit, action: { $0.commitObservers[id] = callback }) @@ -557,7 +559,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when commit events occur. /// - Returns: A ``SQLiteHookToken`` that removes the validator when canceled. public func setCommitValidator(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteCommitValidator) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.addHookAndInstallDispatcherIfNeeded(kind: .commit, action: { $0.commitValidator = callback }) return .init(lifetime: lifetime) { @Sendable [weak self = self] in _ = self?.removeHookAndUninstallDispatcherIfNeeded(kind: .commit, action: { $0.commitValidator = nil }) @@ -585,7 +587,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when rollback events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addRollbackObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteRollbackHookCallback) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .rollback, action: { $0.rollbackHooks[id] = callback }) @@ -616,7 +618,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when authorization events occur. /// - Returns: A ``SQLiteHookToken`` that removes the observer when canceled. public func addAuthorizerObserver(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteAuthorizerObserver) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { let id = UUID() self.addHookAndInstallDispatcherIfNeeded(kind: .authorizer, action: { $0.authorizerObservers[id] = callback }) @@ -650,7 +652,7 @@ extension SQLiteConnection { /// - Parameter callback: Closure to invoke when authorization events occur. /// - Returns: A ``SQLiteHookToken`` that removes the validator when canceled. public func setAuthorizerValidator(lifetime: SQLiteObserverLifetime, _ callback: @escaping SQLiteAuthorizerValidator) async throws -> SQLiteHookToken { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.addHookAndInstallDispatcherIfNeeded(kind: .authorizer, action: { $0.authorizerValidator = callback }) return .init(lifetime: lifetime) { @Sendable [weak self = self] in _ = self?.removeHookAndUninstallDispatcherIfNeeded(kind: .commit, action: { $0.authorizerValidator = nil }) diff --git a/Sources/SQLiteNIO/SQLiteConnection.swift b/Sources/SQLiteNIO/SQLiteConnection.swift index ced69ce..20f67e2 100644 --- a/Sources/SQLiteNIO/SQLiteConnection.swift +++ b/Sources/SQLiteNIO/SQLiteConnection.swift @@ -1,6 +1,8 @@ +#if canImport(NIOCore) import NIOConcurrencyHelpers import NIOCore import NIOPosix +#endif import VaporCSQLite import Logging @@ -13,7 +15,8 @@ import Logging /// /// The use of `@unchecked Sendable` is safe for this type because: /// -/// - We ensure that access to the raw handle only ever takes place while running on an `NIOThreadPool`. +/// - We ensure that access to the raw handle only ever takes place while running on an `NIOThreadPool`, +/// or, on single-threaded targets without SwiftNIO, on the only thread there is. /// This does not prevent concurrent access to the handle from multiple threads, but does tend to limit /// the possibility of misuse (and of course prevents CPU-bound work from ending up on an event loop). /// - The embedded SQLite is built with `SQLITE_THREADSAFE=1` (serialized mode, permitting safe use of a @@ -139,6 +142,7 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { String(cString: sqlite_nio_sqlite3_libversion()) } + #if canImport(NIOCore) /// Open a new connection to an SQLite database. /// /// This is equivalent to invoking ``open(storage:threadPool:logger:on:)-64n3x`` using the @@ -188,6 +192,19 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { logger: Logger, eventLoop: any EventLoop ) throws -> SQLiteConnection { + SQLiteConnection( + handle: try self.openHandle(storage: storage, logger: logger), + threadPool: threadPool, + logger: logger, + on: eventLoop + ) + } + #endif // canImport(NIOCore) + + /// Open the libsqlite3 handle for the given storage, configure it, and return it. + /// + /// Every `open(…)` overload funnels through this method, so the actual open sequence exists once. + private static func openHandle(storage: Storage, logger: Logger) throws -> OpaquePointer? { let path: String switch storage { case .memory: path = ":memory:" @@ -222,24 +239,27 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { } logger.debug("Connected to sqlite database", metadata: ["path": .string(path)]) - return SQLiteConnection(handle: handle, threadPool: threadPool, logger: logger, on: eventLoop) + return handle } + #if canImport(NIOCore) // See `SQLiteDatabase.eventLoop`. public let eventLoop: any EventLoop + #endif // See `SQLiteDatabase.logger`. public let logger: Logger /// The underlying `sqlite3` connection handle. let handle: SQLiteConnectionHandle - - /// The thread pool used by this connection when calling libsqlite3 APIs. - let threadPool: NIOThreadPool - + /// Container for storing multiple observers per hook type. let observerBuckets = NIOLockedValueBox(.init()) + #if canImport(NIOCore) + /// The thread pool used by this connection when calling libsqlite3 APIs. + let threadPool: NIOThreadPool + /// Initialize a new ``SQLiteConnection``. Internal use only. private init( handle: OpaquePointer?, @@ -252,7 +272,14 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { self.logger = logger self.eventLoop = eventLoop } - + #else + /// Initialize a new ``SQLiteConnection``. Internal use only. + private init(handle: OpaquePointer?, logger: Logger) { + self.handle = .init(handle) + self.logger = logger + } + #endif // canImport(NIOCore) + /// Returns the most recent error message from the connection as a string. /// /// This is only valid until another operation is performed on the connection; watch out for races. @@ -265,6 +292,7 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { self.handle.raw == nil } + #if canImport(NIOCore) /// Returns the last value generated by auto-increment functionality (either the version implied by /// `INTEGER PRIMARY KEY` or that of the explicit `AUTO_INCREMENT` modifier) on this database. /// @@ -302,10 +330,7 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { } var futures: [EventLoopFuture] = [] do { - var statement = try SQLiteStatement(query: query, on: self) - let columns = try statement.columns() - try statement.bind(binds) - while let row = try statement.nextRow(for: columns) { + try self.execute(query, binds) { row in futures.append(promise.futureResult.eventLoop.submit { onRow(row) }) } } catch { @@ -350,6 +375,40 @@ public final class SQLiteConnection: SQLiteDatabase, Sendable { try customFunction.uninstall(in: self) } } + #endif // canImport(NIOCore) + + /// Prepare, bind, and step `query`, invoking `handleRow` once per result row. + /// + /// This is the only place statements are actually executed; both the `EventLoopFuture` and the + /// `async` entry points funnel through it. + /// + /// > Important: This blocks. Callers are responsible for ensuring it never runs on an `EventLoop`. + func execute( + _ query: String, + _ binds: [SQLiteData], + _ handleRow: (SQLiteRow) throws -> Void + ) throws { + var statement = try SQLiteStatement(query: query, on: self) + let columns = try statement.columns() + + try statement.bind(binds) + while let row = try statement.nextRow(for: columns) { + try handleRow(row) + } + } + + /// Run `body`, which performs blocking libsqlite3 work, somewhere it is safe to block. + /// + /// On SwiftNIO builds that is the connection's `NIOThreadPool`, exactly as for the futures-based + /// API. Where SwiftNIO is unavailable the only supported target is single-threaded, so there is no + /// pool to offload to and `body` runs inline on the calling task. + func withBlockingIO(_ body: @escaping @Sendable () throws -> T) async throws -> T { + #if canImport(NIOCore) + try await self.threadPool.runIfActive(body) + #else + try body() + #endif + } /// Deinitializer for ``SQLiteConnection``. deinit { @@ -372,14 +431,19 @@ extension SQLiteConnection { storage: Storage = .memory, logger: Logger = .init(label: "codes.vapor.sqlite") ) async throws -> SQLiteConnection { + #if canImport(NIOCore) try await Self.open( storage: storage, threadPool: NIOThreadPool.singleton, logger: logger, on: MultiThreadedEventLoopGroup.singleton.any() ) + #else + SQLiteConnection(handle: try Self.openHandle(storage: storage, logger: logger), logger: logger) + #endif } - + + #if canImport(NIOCore) /// Open a new connection to an SQLite database. /// /// - Parameters: @@ -398,7 +462,8 @@ extension SQLiteConnection { try self.openInternal(storage: storage, threadPool: threadPool, logger: logger, eventLoop: eventLoop) } } - + #endif // canImport(NIOCore) + /// Returns the last value generated by auto-increment functionality (either the version implied by /// `INTEGER PRIMARY KEY` or that of the explicit `AUTO_INCREMENT` modifier) on this database. /// @@ -406,7 +471,7 @@ extension SQLiteConnection { /// /// - Returns: The most recently inserted rowid value. public func lastAutoincrementID() async throws -> Int { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { numericCast(sqlite_nio_sqlite3_last_insert_rowid(self.handle.raw)) } } @@ -424,13 +489,8 @@ extension SQLiteConnection { _ binds: [SQLiteData], _ onRow: @escaping @Sendable (SQLiteRow) -> Void ) async throws { - try await self.threadPool.runIfActive { - var statement = try SQLiteStatement(query: query, on: self) - let columns = try statement.columns() - try statement.bind(binds) - while let row = try statement.nextRow(for: columns) { - onRow(row) - } + try await self.withBlockingIO { + try self.execute(query, binds, onRow) } } @@ -438,7 +498,7 @@ extension SQLiteConnection { /// /// No further operations may be performed on the connection after calling this method. public func close() async throws { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.clearAllHooks() sqlite_nio_sqlite3_close(self.handle.raw) self.handle.raw = nil @@ -449,7 +509,7 @@ extension SQLiteConnection { /// /// - Parameter customFunction: The function to install. public func install(customFunction: SQLiteCustomFunction) async throws { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.logger.trace("Adding custom function \(customFunction.name)") try customFunction.install(in: self) } @@ -459,7 +519,7 @@ extension SQLiteConnection { /// /// - Parameter customFunction: The function to remove. public func uninstall(customFunction: SQLiteCustomFunction) async throws { - try await self.threadPool.runIfActive { + try await self.withBlockingIO { self.logger.trace("Removing custom function \(customFunction.name)") try customFunction.uninstall(in: self) } diff --git a/Sources/SQLiteNIO/SQLiteData.swift b/Sources/SQLiteNIO/SQLiteData.swift index 0008332..860a57e 100644 --- a/Sources/SQLiteNIO/SQLiteData.swift +++ b/Sources/SQLiteNIO/SQLiteData.swift @@ -1,5 +1,7 @@ import VaporCSQLite +#if canImport(NIOCore) import NIOCore +#endif /// On 32-bit platforms, use an explicitly 64-bit integer type. On other platforms, use the platform-native /// integer width, as we don't expect to ever support platforms with less than 32 bits. Most users will not diff --git a/Sources/SQLiteNIO/SQLiteDataConvertible.swift b/Sources/SQLiteNIO/SQLiteDataConvertible.swift index b2cf49c..67deaa2 100644 --- a/Sources/SQLiteNIO/SQLiteDataConvertible.swift +++ b/Sources/SQLiteNIO/SQLiteDataConvertible.swift @@ -12,12 +12,18 @@ import WASILibc import CRT #endif +#if canImport(NIOCore) import NIOCore +#endif #if canImport(FoundationEssentials) +#if canImport(NIOCore) import NIOFoundationEssentialsCompat +#endif import FoundationEssentials #else +#if canImport(NIOCore) import NIOFoundationCompat +#endif import Foundation #endif @@ -111,7 +117,9 @@ extension Data: SQLiteDataConvertible { guard case .blob(let value) = sqliteData else { return nil } - self = .init(buffer: value, byteTransferStrategy: .copy) + // N.B.: Spelled this way, rather than as `Data(buffer:byteTransferStrategy:)`, so that the + // same expression compiles against the `[UInt8]` stand-in used where SwiftNIO is absent. + self = .init(value.readableBytesView) } public var sqliteData: SQLiteData? { @@ -164,10 +172,11 @@ extension Date: SQLiteDataConvertible { // this code actually does work. It's ugly, but it works. And deeply sadly, it too is // much, much faster than ISO8601DateFormatter... More importantly, it allows us to actually // stick to importing FoundationEssentials. - var stm = tm( - tm_sec: 0, tm_min: 0, tm_hour: 0, tm_mday: 0, tm_mon: 0, tm_year: 0, - tm_wday: -1, tm_yday: -1, tm_isdst: 0, tm_gmtoff: 0, tm_zone: nil - ) + // N.B.: Spelled with the zeroing initializer, rather than memberwise, because libcs + // disagree on what `tm`'s fields are (wasi-libc adds a `__tm_nsec` member, for + // example), and the zeroing initializer compiles against all of them. `timegm()` + // ignores `tm_wday` and `tm_yday`, so all-zero is a valid starting point. + var stm = tm() guard v.count == 10 || v.count == 19, v.prefix(5).last == "-", v.prefix(8).last == "-", let y = Int32(v.prefix(4)), let n = Int32(v.prefix(7).suffix(2)), let d = Int32(v.prefix(10).suffix(2)) else { return nil } diff --git a/Sources/SQLiteNIO/SQLiteDatabase.swift b/Sources/SQLiteNIO/SQLiteDatabase.swift index 2ccfb66..bc147c7 100644 --- a/Sources/SQLiteNIO/SQLiteDatabase.swift +++ b/Sources/SQLiteNIO/SQLiteDatabase.swift @@ -1,5 +1,7 @@ +#if canImport(NIOCore) import NIOCore import NIOPosix +#endif import VaporCSQLite import Logging @@ -12,9 +14,12 @@ public protocol SQLiteDatabase: Sendable { /// The logger used by the connection. var logger: Logger { get } + #if canImport(NIOCore) /// The event loop on which operations on the connection execute. var eventLoop: any EventLoop { get } + #endif + #if canImport(NIOCore) /// Execute a query on the connection, calling the provided closure for each result row (if any). /// /// This is the primary interface to connections vended via this protocol. @@ -40,7 +45,8 @@ public protocol SQLiteDatabase: Sendable { logger: Logger, _ onRow: @escaping @Sendable (SQLiteRow) -> Void ) -> EventLoopFuture - + #endif // canImport(NIOCore) + /// Execute a query on the connection, calling the provided closure for each result row (if any). /// /// This is the primary Concurrency-based interface to connections vended via this protocol. A default @@ -57,6 +63,7 @@ public protocol SQLiteDatabase: Sendable { _ onRow: @escaping @Sendable (SQLiteRow) -> Void ) async throws + #if canImport(NIOCore) /// Call the provided closure with a concrete ``SQLiteConnection`` instance. /// /// This method is required to provide a connection object which executes all queries directed to it in the @@ -70,6 +77,7 @@ public protocol SQLiteDatabase: Sendable { func withConnection( _ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture ) -> EventLoopFuture + #endif // canImport(NIOCore) /// Call the provided closure with a concrete ``SQLiteConnection`` instance, concurrency version. /// @@ -88,6 +96,7 @@ public protocol SQLiteDatabase: Sendable { /// Convenience helpers and Concurrency-aware variants. extension SQLiteDatabase { + #if canImport(NIOCore) /// Convenience method for calling ``query(_:_:logger:_:)`` with the connection's logger. /// /// Callers are strongly encouraged to always use this method or its async equivalent (``query(_:_:_:)``) instead @@ -119,6 +128,7 @@ extension SQLiteDatabase { return self.query(query, binds, logger: self.logger) { rows.append($0) }.map { rows } } + #endif // canImport(NIOCore) /// Wrapper for ``query(_:_:_:)`` which returns the result rows (if any) rather than calling a /// closure (async version). @@ -129,6 +139,7 @@ extension SQLiteDatabase { return rows } + #if canImport(NIOCore) /// Async version of ``withConnection(_:)-48y34``. public func withConnection( _ closure: @escaping @Sendable (SQLiteConnection) async throws -> T @@ -139,6 +150,7 @@ extension SQLiteDatabase { } }.get() } + #endif // canImport(NIOCore) } #if swift(<5.10) @@ -181,6 +193,7 @@ private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { // See `SQLiteDatabase.logger`. let logger: Logger + #if canImport(NIOCore) // See `SQLiteDatabase.eventLoop`. var eventLoop: any EventLoop { self.database.eventLoop } @@ -188,11 +201,13 @@ private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { func withConnection(_ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture) -> EventLoopFuture { self.database.withConnection(closure) } + #endif // canImport(NIOCore) // See `SQLiteDatabase.withConnection(_:)`. func withConnection(_ closure: @escaping @Sendable (SQLiteConnection) async throws -> T) async throws -> T { try await self.database.withConnection(closure) } + #if canImport(NIOCore) // See `SQLiteDatabase.query(_:_:_:)`. func query(_ query: String, _ binds: [SQLiteData], logger: Logger, _ onRow: @escaping @Sendable (SQLiteRow) -> Void) -> EventLoopFuture { self.database.query(query, binds, logger: logger, onRow) @@ -202,16 +217,19 @@ private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { func query(_ query: String, _ binds: [SQLiteData] = [], _ onRow: @escaping @Sendable (SQLiteRow) -> Void) -> EventLoopFuture { self.database.query(query, binds, onRow) } + #endif // canImport(NIOCore) // See `SQLiteDatabase.query(_:_:_:)`. func query(_ query: String, _ binds: [SQLiteData], _ onRow: @escaping @Sendable (SQLiteRow) -> Void) async throws { try await self.database.query(query, binds, onRow) } + #if canImport(NIOCore) // See `SQLiteDatabase.query(_:_:)`. func query(_ query: String, _ binds: [SQLiteData] = []) -> EventLoopFuture<[SQLiteRow]> { self.database.query(query, binds) } + #endif // See `SQLiteDatabase.query(_:_:)`. func query(_ query: String, _ binds: [SQLiteData] = []) async throws -> [SQLiteRow] { diff --git a/Sources/SQLiteNIO/SQLiteStatement.swift b/Sources/SQLiteNIO/SQLiteStatement.swift index 2a30ed7..1f0cb3b 100644 --- a/Sources/SQLiteNIO/SQLiteStatement.swift +++ b/Sources/SQLiteNIO/SQLiteStatement.swift @@ -1,4 +1,6 @@ +#if canImport(NIOCore) import NIOCore +#endif import VaporCSQLite struct SQLiteStatement { @@ -102,12 +104,13 @@ struct SQLiteStatement { return .text(.init(cString: val)) case SQLITE_BLOB: let length = Int(sqlite_nio_sqlite3_column_bytes(self.handle, offset)) - var buffer = ByteBufferAllocator().buffer(capacity: length) - - if let blobPointer = sqlite_nio_sqlite3_column_blob(self.handle, offset) { - buffer.writeBytes(UnsafeRawBufferPointer(start: blobPointer, count: length)) + + guard let blobPointer = sqlite_nio_sqlite3_column_blob(self.handle, offset) else { + return .blob(ByteBuffer()) } - return .blob(buffer) + // N.B.: This is `ByteBuffer(bytes:)` rather than an allocate-then-write pair so that the + // same expression compiles against the `[UInt8]` stand-in used where SwiftNIO is absent. + return .blob(ByteBuffer(bytes: UnsafeRawBufferPointer(start: blobPointer, count: length))) case SQLITE_NULL: return .null default: diff --git a/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift b/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift index 8b74fe0..5ee613d 100644 --- a/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteConnectionHookTests.swift @@ -1,6 +1,8 @@ import SQLiteNIO import Testing +#if canImport(NIOCore) import NIOConcurrencyHelpers +#endif @Suite("SQLite Connection Hook Tests") struct SQLiteConnectionHookTests { diff --git a/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift b/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift index ae591b9..1a328d1 100644 --- a/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift @@ -271,6 +271,19 @@ struct DatabaseFunctionTests { } } + @Test + func uninstallRemovesFunction() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("removable", argumentCount: 0) { values in 1 } + try await conn.install(customFunction: fn) + #expect(try await Int(1) == conn.query("SELECT removable() as result").first?.column("result")?.integer) + + try await conn.uninstall(customFunction: fn) + + await #expect(throws: (any Error).self) { try await conn.query("SELECT removable()") } + } + } + // MARK: - setup init() { diff --git a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift index 8618464..46376ec 100644 --- a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift @@ -4,6 +4,7 @@ import FoundationEssentials import Foundation #endif import Logging +#if canImport(NIOCore) import NIOCore import NIOPosix #if canImport(FoundationEssentials) @@ -11,6 +12,7 @@ import NIOFoundationEssentialsCompat #else import NIOFoundationCompat #endif +#endif // canImport(NIOCore) import SQLiteNIO import Testing @@ -63,6 +65,90 @@ struct SQLiteNIOTests { } } + /// `INTEGER` columns must round-trip the full 64-bit range; a construction site which converted + /// through `Int` rather than ``SQLiteInt64`` would trap above `Int32.max` on a 32-bit target. + @Test + func largeIntegerRoundTrip() async throws { + try await withOpenedConnection { conn in + let values: [SQLiteInt64] = [.max, .min, 0, 1, -1, 0x7fff_ffff, 0x8000_0000, -0x8000_0001] + + _ = try await conn.query("CREATE TABLE bigints (value INTEGER)") + for value in values { + _ = try await conn.query("INSERT INTO bigints (value) VALUES (?)", [.integer(value)]) + } + + let rows = try await conn.query("SELECT value FROM bigints ORDER BY rowid") + + #expect(rows.compactMap { $0.column("value")?.integer } == values) + } + } + + /// The same range must survive `sqlite3_value` conversion, which is a separate code path from + /// column reads (it is the one custom functions see). + @Test + func largeIntegerThroughCustomFunction() async throws { + try await withOpenedConnection { conn in + let echo = SQLiteCustomFunction("echo_int", argumentCount: 1, pure: true) { args in + args[0].integer + } + + _ = try await conn.install(customFunction: echo) + let rows = try await conn.query("SELECT echo_int(?) as value", [.integer(.max)]) + + #expect(rows.first?.column("value")?.integer == .max) + } + } + + /// A `BLOB` must survive the bind/column round trip byte-for-byte, including the empty case. + /// + /// The blob read in ``SQLiteStatement`` is a single expression shared by the SwiftNIO and + /// NIO-free builds, so its behavior is worth pinning down directly. + @Test + func blobRoundTrip() async throws { + try await withOpenedConnection { conn in + let payloads: [[UInt8]] = [[], [0x00], [0xde, 0xad, 0xbe, 0xef], .init(0...255)] + + _ = try await conn.query("CREATE TABLE blobs (value BLOB)") + for payload in payloads { + _ = try await conn.query("INSERT INTO blobs (value) VALUES (?)", [.blob(ByteBuffer(bytes: payload))]) + } + + let rows = try await conn.query("SELECT value FROM blobs ORDER BY rowid") + + #expect(rows.compactMap { $0.column("value")?.blob.map { Array($0.readableBytesView) } } == payloads) + } + } + + /// `Data` round-trips through `BLOB` in both directions. + /// + /// Its ``SQLiteDataConvertible`` conformance is spelled so that one implementation compiles against + /// both `ByteBuffer` and the `[UInt8]` stand-in used where SwiftNIO is absent. + @Test + func dataRoundTrip() async throws { + try await withOpenedConnection { conn in + let payload = Data([0x00, 0x01, 0xfe, 0xff]) + + _ = try await conn.query("CREATE TABLE datas (value BLOB)") + _ = try await conn.query("INSERT INTO datas (value) VALUES (?)", [payload.sqliteData!]) + + let rows = try await conn.query("SELECT value FROM datas") + + #expect(rows.first?.column("value").flatMap(Data.init(sqliteData:)) == payload) + #expect(Data(sqliteData: .blob(ByteBuffer())) == Data()) + #expect(Data(sqliteData: .null) == nil) + } + } + + /// ``SQLiteData`` encodes blobs as raw bytes rather than using `ByteBuffer`'s Base64 `Codable` + /// conformance. The encoding goes through `readableBytesView`, one of the members the NIO-free + /// build supplies for `[UInt8]`. + @Test + func blobEncodesAsRawBytes() throws { + let encoded = try JSONEncoder().encode([SQLiteData.blob(ByteBuffer(bytes: [0x01, 0x02, 0x03]))]) + + #expect(String(decoding: encoded, as: UTF8.self) == "[[1,2,3]]") + } + @Test func dateFormat() async throws { try await withOpenedConnection { conn in @@ -207,6 +293,33 @@ struct SQLiteNIOTests { } } + @Test + func lastAutoincrementIDTracksInserts() async throws { + try await withOpenedConnection { conn in + _ = try await conn.query("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)") + _ = try await conn.query("INSERT INTO t (v) VALUES ('a')") + + #expect(try await conn.lastAutoincrementID() == 1) + + _ = try await conn.query("INSERT INTO t (v) VALUES ('b')") + + #expect(try await conn.lastAutoincrementID() == 2) + } + } + + @Test + func futuresQuerySurfaceReturnsRows() async throws { + try await withOpenedConnection { conn in + _ = try await conn.query("CREATE TABLE t (v TEXT)").get() + _ = try await conn.query("INSERT INTO t (v) VALUES (?)", [.text("a")]).get() + + let rows = try await conn.query("SELECT v FROM t").get() + + #expect(rows.count == 1) + #expect(try await "a" == rows.first?.column("v")?.string) + } + } + init() { #expect(isLoggingConfigured) }