diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift new file mode 100644 index 0000000..e588eed --- /dev/null +++ b/Package@swift-6.1.swift @@ -0,0 +1,135 @@ +// swift-tools-version:6.1 +import PackageDescription + +/// This list matches the [supported platforms on the Swift 5.10 release of SPM](https://github.com/swiftlang/swift-package-manager/blob/release/5.10/Sources/PackageDescription/SupportedPlatforms.swift#L34-L71) +/// 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 wasiPlatform: [Platform] = [.wasi] + +let package = Package( + name: "sqlite-nio", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + ], + products: [ + .library(name: "SQLiteNIO", targets: ["SQLiteNIO"]), + ], + traits: [ + .default(enabledTraits: ["NIO"]), + .trait( + name: "NIO", + description: "Default backend: SwiftNIO (EventLoopFuture/ByteBuffer, NIOThreadPool)." + ), + .trait( + name: "NativeConcurrency", + description: "NIO-free backend on Swift concurrency (async/await, [UInt8] blobs). Build with `--traits NativeConcurrency` (replaces the default NIO backend)." + ), + .trait( + name: "Freestanding", + description: "Embedded/freestanding flavor (implies NativeConcurrency). No additional source effect in this package beyond NativeConcurrency; declared so a root's `--traits Freestanding` configuration names a known trait when this package is wired by path.", + enabledTraits: ["NativeConcurrency"] + ), + ], + dependencies: [ + // TODO: SM: Update swift-nio version once NIOAsyncRuntime is available from swift-nio + // .package(url: "https://github.com/apple/swift-nio.git", from: "2.89.0"), + .package(url: "https://github.com/PassiveLogic/swift-nio.git", branch: "feat/khasmPAL-2026"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), + ], + targets: [ + .plugin( + name: "VendorSQLite", + capability: .command( + intent: .custom(verb: "vendor-sqlite", description: "Vendor SQLite"), + permissions: [ + .allowNetworkConnections(scope: .all(ports: [443]), reason: "Retrieve the latest build of SQLite"), + .writeToPackageDirectory(reason: "Update the vendored SQLite files"), + ] + ), + exclude: ["001-warnings-and-data-race.patch"] + ), + .target( + name: "CSQLite", + cSettings: sqliteCSettings + ), + .target( + name: "SQLiteNIO", + dependencies: [ + .target(name: "CSQLite"), + .product(name: "Logging", package: "swift-log"), + // The SwiftNIO stack rides the default `NIO` trait. With `NativeConcurrency` + // enabled instead, SQLiteNIO is a NIO-free, Swift-Concurrency driver over + // CSQLite, gated in source with `#if NativeConcurrency`. + .product(name: "NIOCore", package: "swift-nio", condition: .when(traits: ["NIO"])), + .product(name: "NIOAsyncRuntime", package: "swift-nio", condition: .when(platforms: wasiPlatform, traits: ["NIO"])), + .product(name: "NIOPosix", package: "swift-nio", condition: .when(traits: ["NIO"])), + .product(name: "NIOFoundationCompat", package: "swift-nio", condition: .when(traits: ["NIO"])), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SQLiteNIOTests", + dependencies: [ + .target(name: "SQLiteNIO"), + ], + swiftSettings: swiftSettings + ), + ], + swiftLanguageModes: [.v5] +) + +var swiftSettings: [SwiftSetting] { [ + // This manifest raises the tools-version to 6.1 (for package traits); the package sources + // stay in the Swift 5 language mode of the base manifest. + .swiftLanguageMode(.v5), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] } + +var sqliteCSettings: [CSetting] { [ + // Derived from sqlite3 version 3.43.0 + .define("SQLITE_DEFAULT_MEMSTATUS", to: "0"), + .define("SQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS"), + .define("SQLITE_DQS", to: "0"), + .define("SQLITE_ENABLE_API_ARMOR", .when(configuration: .debug)), + .define("SQLITE_ENABLE_COLUMN_METADATA"), + .define("SQLITE_ENABLE_DBSTAT_VTAB"), + .define("SQLITE_ENABLE_FTS3"), + .define("SQLITE_ENABLE_FTS3_PARENTHESIS"), + .define("SQLITE_ENABLE_FTS3_TOKENIZER"), + .define("SQLITE_ENABLE_FTS4"), + .define("SQLITE_ENABLE_FTS5"), + .define("SQLITE_ENABLE_NULL_TRIM"), + .define("SQLITE_ENABLE_RTREE"), + .define("SQLITE_ENABLE_SESSION"), + .define("SQLITE_ENABLE_STMTVTAB"), + .define("SQLITE_ENABLE_UNKNOWN_SQL_FUNCTION"), + .define("SQLITE_ENABLE_UNLOCK_NOTIFY"), + .define("SQLITE_MAX_VARIABLE_NUMBER", to: "250000"), + .define("SQLITE_LIKE_DOESNT_MATCH_BLOBS"), + .define("SQLITE_OMIT_COMPLETE"), + .define("SQLITE_OMIT_DEPRECATED"), + .define("SQLITE_OMIT_DESERIALIZE"), + .define("SQLITE_OMIT_GET_TABLE"), + .define("SQLITE_OMIT_LOAD_EXTENSION"), + .define("SQLITE_OMIT_PROGRESS_CALLBACK"), + .define("SQLITE_OMIT_SHARED_CACHE"), + .define("SQLITE_OMIT_TCL_VARIABLE"), + .define("SQLITE_OMIT_TRACE"), + .define("SQLITE_SECURE_DELETE"), + .define("SQLITE_THREADSAFE", to: "1", .when(platforms: nonWASIPlatforms)), + // For now, we use the single threaded sqlite variation for the WASI platform + // since single-threaded operation is the least common denominator capability + // for Wasm executables and it is considered unreliable to use canImport(wasi_pthread) + // in a manifest file to distinguish between the two capabilities. + .define("SQLITE_THREADSAFE", to: "0", .when(platforms: wasiPlatform)), + .define("SQLITE_UNTESTABLE"), + .define("SQLITE_USE_URI"), +] } diff --git a/Plugins/VendorSQLite/VendorSQLite3.swift b/Plugins/VendorSQLite/VendorSQLite3.swift index 5d5da7c..8fc6445 100644 --- a/Plugins/VendorSQLite/VendorSQLite3.swift +++ b/Plugins/VendorSQLite/VendorSQLite3.swift @@ -19,7 +19,9 @@ struct VendorSQLite: CommandPlugin { static let sqliteURL = URL(string: "https://sqlite.org")! static let vendorPrefix = "sqlite_nio" - static var verbose = false + // `nonisolated(unsafe)`: set once from `performCommand()` before any concurrent work; needed + // because plugins compile in the Swift 6 language mode under the tools-6.1 traits manifest. + nonisolated(unsafe) static var verbose = false var verbose: Bool { Self.verbose } diff --git a/Sources/SQLiteNIO/Exports.swift b/Sources/SQLiteNIO/Exports.swift index 3dce47a..e1a334f 100644 --- a/Sources/SQLiteNIO/Exports.swift +++ b/Sources/SQLiteNIO/Exports.swift @@ -1,3 +1,6 @@ +// These re-exports are all SwiftNIO types, which are elided on the NativeConcurrency build (the +// NIO-free path uses Swift Concurrency + `[UInt8]` instead of EventLoop/ByteBuffer). +#if !NativeConcurrency @_documentation(visibility: internal) @_exported import struct NIOCore.ByteBuffer // See SQLiteConnection.swift for why we gate on `os(WASI)` rather than @@ -19,3 +22,4 @@ #else @_documentation(visibility: internal) @_exported import class NIOPosix.MultiThreadedEventLoopGroup #endif +#endif // !NativeConcurrency diff --git a/Sources/SQLiteNIO/NativeConcurrencyLock.swift b/Sources/SQLiteNIO/NativeConcurrencyLock.swift new file mode 100644 index 0000000..6053d7d --- /dev/null +++ b/Sources/SQLiteNIO/NativeConcurrencyLock.swift @@ -0,0 +1,74 @@ +#if NativeConcurrency +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#elseif canImport(Android) +import Android +#endif + +/// A minimal mutex-protected value box for the NativeConcurrency (NIO-free) build. +/// +/// `NIOConcurrencyHelpers.NIOLock` is unavailable here (NIO is not linked), and +/// `Synchronization.Mutex` would force a platform-floor bump (macOS 15 et al.), so this uses +/// `os_unfair_lock` on Darwin and `pthread_mutex_t` elsewhere. On single-threaded targets with +/// no lock primitive (Embedded WASI) it degrades to direct access, which is sound because that +/// runtime has exactly one thread. +final class NativeConcurrencyLockedBox: @unchecked Sendable { + #if hasFeature(Embedded) || os(WASI) + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + try body(&self.value) + } + #elseif canImport(Darwin) + private let lockPointer: os_unfair_lock_t + private var value: Value + + init(_ value: Value) { + self.lockPointer = .allocate(capacity: 1) + self.lockPointer.initialize(to: os_unfair_lock()) + self.value = value + } + + deinit { + self.lockPointer.deinitialize(count: 1) + self.lockPointer.deallocate() + } + + func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + os_unfair_lock_lock(self.lockPointer) + defer { os_unfair_lock_unlock(self.lockPointer) } + return try body(&self.value) + } + #else + private let mutexPointer: UnsafeMutablePointer + private var value: Value + + init(_ value: Value) { + self.mutexPointer = .allocate(capacity: 1) + self.mutexPointer.initialize(to: pthread_mutex_t()) + pthread_mutex_init(self.mutexPointer, nil) + self.value = value + } + + deinit { + pthread_mutex_destroy(self.mutexPointer) + self.mutexPointer.deinitialize(count: 1) + self.mutexPointer.deallocate() + } + + func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + pthread_mutex_lock(self.mutexPointer) + defer { pthread_mutex_unlock(self.mutexPointer) } + return try body(&self.value) + } + #endif +} +#endif // NativeConcurrency diff --git a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift index 93977e5..dff89e5 100644 --- a/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift +++ b/Sources/SQLiteNIO/SQLiteConnection+Hooks.swift @@ -1,3 +1,4 @@ +#if !NativeConcurrency // NIO-based hooks; the NativeConcurrency build uses SQLiteConnection+NativeConcurrency.swift (no hooks surface) import Foundation import NIOConcurrencyHelpers import NIOCore @@ -865,3 +866,5 @@ extension SQLiteConnection { self.observerBuckets.withLockedValue { $0 = .init() } } } + +#endif // !NativeConcurrency diff --git a/Sources/SQLiteNIO/SQLiteConnection+NativeConcurrency.swift b/Sources/SQLiteNIO/SQLiteConnection+NativeConcurrency.swift new file mode 100644 index 0000000..201f9fc --- /dev/null +++ b/Sources/SQLiteNIO/SQLiteConnection+NativeConcurrency.swift @@ -0,0 +1,154 @@ +#if NativeConcurrency +import CSQLite +import Logging + +/// A wrapper for the `OpaquePointer` used to represent an open `sqlite3` handle. +/// +/// The pointer itself is guarded by a lock (it is read on every query and written to `nil` on +/// close); the SQLite handle behind it is opened `SQLITE_OPEN_FULLMUTEX`, so concurrent use of +/// the handle is serialized by SQLite itself, as on the SwiftNIO build. On single-threaded +/// targets (Embedded WASI) the lock degrades to direct access. +final class SQLiteConnectionHandle: @unchecked Sendable { + private let storage: NativeConcurrencyLockedBox + + var raw: OpaquePointer? { + get { self.storage.withLock { $0 } } + set { self.storage.withLock { $0 = newValue } } + } + + init(_ raw: OpaquePointer?) { + self.storage = .init(raw) + } +} + +/// A single open connection to an SQLite database (NativeConcurrency build). +/// +/// This is the NIO-free variant: it exposes a Swift-Concurrency (`async`/`await`) API over CSQLite +/// with no `EventLoopFuture`, `EventLoopGroup`, or `NIOThreadPool`. The blocking libsqlite3 calls +/// run inline on the calling task. The observable hook API and connection pooling are not +/// available on this build. +public final class SQLiteConnection: SQLiteDatabase, Sendable { + /// The possible storage types for an SQLite database. + public enum Storage: Equatable, Sendable { + /// An SQLite database stored entirely in memory. + case memory + + /// An SQLite database stored in a file at the specified path. + case file(path: String) + } + + /// Return the version of the embedded libsqlite3 as a 32-bit integer value. + public static func libraryVersion() -> Int32 { + sqlite_nio_sqlite3_libversion_number() + } + + /// Return the version of the embedded libsqlite3 as a string. + public static func libraryVersionString() -> String { + String(cString: sqlite_nio_sqlite3_libversion()) + } + + // See `SQLiteDatabase.logger`. + public let logger: Logger + + /// The underlying `sqlite3` connection handle. + let handle: SQLiteConnectionHandle + + /// Initialize a new ``SQLiteConnection``. Internal use only. + private init(handle: OpaquePointer?, logger: Logger) { + self.handle = .init(handle) + self.logger = logger + } + + /// Returns the most recent error message from the connection as a string. + var errorMessage: String? { + sqlite_nio_sqlite3_errmsg(self.handle.raw).map { String(cString: $0) } + } + + /// `false` if the connection is valid, `true` if not. + public var isClosed: Bool { + self.handle.raw == nil + } + + /// Open a new connection to an SQLite database. + /// + /// - Parameters: + /// - storage: Specifies the location of the database for the connection. See ``Storage`` for details. + /// - logger: The logger used by the connection. Defaults to a new `Logger`. + /// - Returns: A new connection object. + public static func open( + storage: Storage = .memory, + logger: Logger = .init(label: "codes.vapor.sqlite") + ) async throws -> SQLiteConnection { + let path: String + switch storage { + case .memory: path = ":memory:" + case .file(let file): path = file + } + + var handle: OpaquePointer? + let openOptions = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_URI | SQLITE_OPEN_EXRESCODE + let openRet = sqlite_nio_sqlite3_open_v2(path, &handle, openOptions, nil) + guard openRet == SQLITE_OK else { + throw SQLiteError(reason: .init(statusCode: openRet), message: "Failed to open to SQLite database at \(path)") + } + + let busyRet = sqlite_nio_sqlite3_busy_handler(handle, { _, _ in 1 }, nil) + guard busyRet == SQLITE_OK else { + sqlite_nio_sqlite3_close(handle) + throw SQLiteError(reason: .init(statusCode: busyRet), message: "Failed to set busy handler for SQLite database at \(path)") + } + + logger.debug("Connected to sqlite database", metadata: ["path": .string(path)]) + return SQLiteConnection(handle: handle, logger: logger) + } + + /// Returns the last value generated by auto-increment functionality on this database. + public func lastAutoincrementID() async throws -> Int { + numericCast(sqlite_nio_sqlite3_last_insert_rowid(self.handle.raw)) + } + + /// Run the provided closure with this connection. + public func withConnection( + _ closure: @escaping @Sendable (SQLiteConnection) async throws -> T + ) async throws -> T { + try await closure(self) + } + + // See `SQLiteDatabase.query(_:_:logger:_:)`. + public func query( + _ query: String, + _ binds: [SQLiteData], + logger: Logger, + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) async 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) { + onRow(row) + } + } + + /// Close the connection and invalidate its handle. + public func close() async throws { + sqlite_nio_sqlite3_close(self.handle.raw) + self.handle.raw = nil + } + + /// Install the provided ``SQLiteCustomFunction`` on the connection. + public func install(customFunction: SQLiteCustomFunction) async throws { + self.logger.trace("Adding custom function \(customFunction.name)") + try customFunction.install(in: self) + } + + /// Uninstall the provided ``SQLiteCustomFunction`` from the connection. + public func uninstall(customFunction: SQLiteCustomFunction) async throws { + self.logger.trace("Removing custom function \(customFunction.name)") + try customFunction.uninstall(in: self) + } + + deinit { + assert(self.handle.raw == nil, "SQLiteConnection was not closed before deinitializing") + } +} +#endif // NativeConcurrency diff --git a/Sources/SQLiteNIO/SQLiteConnection.swift b/Sources/SQLiteNIO/SQLiteConnection.swift index 14caf0f..572ce29 100644 --- a/Sources/SQLiteNIO/SQLiteConnection.swift +++ b/Sources/SQLiteNIO/SQLiteConnection.swift @@ -1,3 +1,4 @@ +#if !NativeConcurrency // NIO-based connection; the NativeConcurrency build uses SQLiteConnection+NativeConcurrency.swift import NIOConcurrencyHelpers import NIOCore // Use NIOPosix on every host platform that supports it (macOS, Linux, etc.) @@ -457,3 +458,5 @@ extension SQLiteConnection { } } } + +#endif // !NativeConcurrency diff --git a/Sources/SQLiteNIO/SQLiteCustomFunction.swift b/Sources/SQLiteNIO/SQLiteCustomFunction.swift index 4f18b4e..935ee01 100644 --- a/Sources/SQLiteNIO/SQLiteCustomFunction.swift +++ b/Sources/SQLiteNIO/SQLiteCustomFunction.swift @@ -300,9 +300,15 @@ public final class SQLiteCustomFunction: Hashable { case .text(let string): sqlite_nio_sqlite3_result_text(sqliteContext, string, -1, SQLITE_TRANSIENT) case .blob(let value): + #if NativeConcurrency + value.withUnsafeBytes { pointer in + sqlite_nio_sqlite3_result_blob(sqliteContext, pointer.baseAddress, Int32(value.count), SQLITE_TRANSIENT) + } + #else value.withUnsafeReadableBytes { pointer in sqlite_nio_sqlite3_result_blob(sqliteContext, pointer.baseAddress, Int32(value.readableBytes), SQLITE_TRANSIENT) } + #endif } } @@ -311,7 +317,11 @@ public final class SQLiteCustomFunction: Hashable { sqlite_nio_sqlite3_result_error(sqliteContext, error.message, -1) sqlite_nio_sqlite3_result_error_code(sqliteContext, error.reason.statusCode) } else { + #if hasFeature(Embedded) + sqlite_nio_sqlite3_result_error(sqliteContext, "custom function error", -1) // `any Error` interpolation needs reflection + #else sqlite_nio_sqlite3_result_error(sqliteContext, "\(error)", -1) + #endif } } } diff --git a/Sources/SQLiteNIO/SQLiteData.swift b/Sources/SQLiteNIO/SQLiteData.swift index 8455a15..850ef65 100644 --- a/Sources/SQLiteNIO/SQLiteData.swift +++ b/Sources/SQLiteNIO/SQLiteData.swift @@ -1,5 +1,7 @@ import CSQLite +#if !NativeConcurrency import NIOCore +#endif #if _pointerBitWidth(_64) /// We use `Int` on 64-bit systems due to public API breakage concerns. @@ -17,7 +19,9 @@ public typealias SQLiteInt64 = Int64 // On 32-bit platforms, we want to use 64 b /// /// SQLite supports four data type "affinities" - INTEGER, REAL, TEXT, and BLOB - plus the `NULL` value, which has no /// innate affinity. -public enum SQLiteData: Equatable, Encodable, CustomStringConvertible, Sendable { +// `Encodable` is added via a conditional extension below: it relies on `Encoder`, which is +// unavailable in Embedded Swift. +public enum SQLiteData: Equatable, CustomStringConvertible, Sendable { /// `INTEGER` affinity, represented in Swift by `Int`. case integer(SQLiteInt64) @@ -27,8 +31,13 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible, Sendable /// `TEXT` affinity, represented in Swift by `String`. case text(String) - /// `BLOB` affinity, represented in Swift by `ByteBuffer`. + /// `BLOB` affinity. Represented by SwiftNIO's `ByteBuffer`, or by `[UInt8]` on the + /// NativeConcurrency (NIO-free) build. + #if NativeConcurrency + case blob([UInt8]) + #else case blob(ByteBuffer) + #endif /// A `NULL` value. case null @@ -95,6 +104,14 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible, Sendable /// Returns the data as a blob, if it has `BLOB` affinity. /// /// `INTEGER`, `REAL`, `TEXT`, and `NULL` values always return `nil`. + #if NativeConcurrency + public var blob: [UInt8]? { + switch self { + case .blob(let buffer): return buffer + case .integer, .float, .text, .null: return nil + } + } + #else public var blob: ByteBuffer? { switch self { case .blob(let buffer): @@ -103,6 +120,7 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible, Sendable return nil } } + #endif /// `true` if the value is `NULL`, `false` otherwise. public var isNull: Bool { @@ -117,7 +135,11 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible, Sendable // See `CustomStringConvertible.description`. public var description: String { switch self { + #if NativeConcurrency + case .blob(let data): return "<\(data.count) bytes>" + #else case .blob(let data): return "<\(data.readableBytes) bytes>" + #endif case .float(let float): return float.description case .integer(let int): return int.description case .null: return "null" @@ -125,19 +147,29 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible, Sendable } } - // See `Encodable.encode(to:)`. + // See `Encodable.encode(to:)`. Unavailable in Embedded Swift (no `Encoder`). + #if !hasFeature(Embedded) public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .integer(let value): try container.encode(value) case .float(let value): try container.encode(value) case .text(let value): try container.encode(value) + #if NativeConcurrency + case .blob(let value): try container.encode(value) // [UInt8] encodes as raw bytes, matching the ByteBuffer branch + #else case .blob(let value): try container.encode(Array(value.readableBytesView)) // N.B.: Don't use ByteBuffer's Codable conformance; it encodes as Base64, not raw bytes + #endif case .null: try container.encodeNil() } } + #endif } +#if !hasFeature(Embedded) +extension SQLiteData: Encodable {} +#endif + extension SQLiteData { /// Attempt to interpret an `sqlite3_value` as an equivalent ``SQLiteData``. init(sqliteValue: OpaquePointer) throws { @@ -157,10 +189,18 @@ extension SQLiteData { case SQLITE_BLOB: if let bytes = sqlite_nio_sqlite3_value_blob(sqliteValue) { let count = Int(sqlite_nio_sqlite3_value_bytes(sqliteValue)) + #if NativeConcurrency + self = .blob([UInt8](UnsafeRawBufferPointer(start: bytes, count: count))) // copy bytes + #else let buffer = ByteBuffer(bytes: UnsafeRawBufferPointer(start: bytes, count: count)) self = .blob(buffer) // copy bytes + #endif } else { + #if NativeConcurrency + self = .blob([]) + #else self = .blob(ByteBuffer()) + #endif } case let type: throw SQLiteCustomFunctionUnexpectedValueTypeError(type: type) diff --git a/Sources/SQLiteNIO/SQLiteDataConvertible.swift b/Sources/SQLiteNIO/SQLiteDataConvertible.swift index abbe578..b97e734 100644 --- a/Sources/SQLiteNIO/SQLiteDataConvertible.swift +++ b/Sources/SQLiteNIO/SQLiteDataConvertible.swift @@ -1,6 +1,12 @@ +// ByteBuffer (NIOCore) and the NIOFoundationCompat bridges are elided on the NativeConcurrency +// (NIO-free) build; Foundation (`Data`/`Date`) is used whenever it is available. +#if !NativeConcurrency import NIOCore import NIOFoundationCompat +#endif +#if canImport(Foundation) import Foundation +#endif public protocol SQLiteDataConvertible { init?(sqliteData: SQLiteData) @@ -74,6 +80,35 @@ extension Float: SQLiteDataConvertible { } } +#if NativeConcurrency +extension [UInt8]: SQLiteDataConvertible { + public init?(sqliteData: SQLiteData) { + guard case .blob(let value) = sqliteData else { + return nil + } + self = value + } + + public var sqliteData: SQLiteData? { + .blob(self) + } +} + +#if canImport(Foundation) +extension Data: SQLiteDataConvertible { + public init?(sqliteData: SQLiteData) { + guard case .blob(let value) = sqliteData else { + return nil + } + self = .init(value) + } + + public var sqliteData: SQLiteData? { + .blob([UInt8](self)) + } +} +#endif +#else extension ByteBuffer: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { guard case .blob(let value) = sqliteData else { @@ -99,6 +134,7 @@ extension Data: SQLiteDataConvertible { .blob(.init(data: self)) } } +#endif extension Bool: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { @@ -113,6 +149,9 @@ extension Bool: SQLiteDataConvertible { } } +// Date conversions rely on Foundation (`Date`, `ISO8601DateFormatter`), unavailable in +// Embedded Swift. +#if !hasFeature(Embedded) extension Date: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { let value: Double @@ -171,3 +210,4 @@ var dateFormatter: ISO8601DateFormatter { ] return formatter } +#endif // !hasFeature(Embedded) diff --git a/Sources/SQLiteNIO/SQLiteDataType.swift b/Sources/SQLiteNIO/SQLiteDataType.swift index a899699..db5bba3 100644 --- a/Sources/SQLiteNIO/SQLiteDataType.swift +++ b/Sources/SQLiteNIO/SQLiteDataType.swift @@ -16,6 +16,8 @@ public enum SQLiteDataType { /// `NULL`. case null + // `any Encodable` is unavailable in Embedded Swift; this type is deprecated/unused anyway. + #if !hasFeature(Embedded) public func serialize(_ binds: inout [any Encodable]) -> String { switch self { case .integer: return "INTEGER" @@ -25,4 +27,5 @@ public enum SQLiteDataType { case .null: return "NULL" } } + #endif } diff --git a/Sources/SQLiteNIO/SQLiteDatabase.swift b/Sources/SQLiteNIO/SQLiteDatabase.swift index 8fc3518..29e69d2 100644 --- a/Sources/SQLiteNIO/SQLiteDatabase.swift +++ b/Sources/SQLiteNIO/SQLiteDatabase.swift @@ -1,3 +1,4 @@ +#if !NativeConcurrency // NIO/EventLoopFuture protocol; the NativeConcurrency build uses the async protocol below import NIOCore import CSQLite import Logging @@ -195,3 +196,71 @@ private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { Self(database: self.database, logger: logger) } } + +#endif // !NativeConcurrency + +#if NativeConcurrency +import CSQLite +import Logging + +/// NIO-free (`async`/`await`) variant of ``SQLiteDatabase`` for the NativeConcurrency build. +/// +/// The protocol deliberately has only non-generic requirements so it remains usable as an +/// existential (`any SQLiteDatabase`) under Embedded Swift, which cannot place a generic method in a +/// protocol witness table. `withConnection(_:)` is therefore provided on the concrete +/// ``SQLiteConnection`` rather than as a protocol requirement. +public protocol SQLiteDatabase: Sendable { + /// The logger used by the connection. + var logger: Logger { get } + + /// Execute a query, invoking `onRow` for each result row. + func query( + _ query: String, + _ binds: [SQLiteData], + logger: Logger, + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) async throws +} + +extension SQLiteDatabase { + /// Convenience: execute a query using the database's own logger. + public func query( + _ query: String, + _ binds: [SQLiteData] = [], + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) async throws { + try await self.query(query, binds, logger: self.logger, onRow) + } + + /// Execute a query and collect the result rows. + public func query(_ query: String, _ binds: [SQLiteData] = []) async throws -> [SQLiteRow] { + let rows = NativeConcurrencyLockedBox<[SQLiteRow]>([]) + try await self.query(query, binds) { row in rows.withLock { $0.append(row) } } + return rows.withLock { $0 } + } + + /// Return a database that logs to `logger`, forwarding everything else to `self`. + public func logging(to logger: Logger) -> any SQLiteDatabase { + SQLiteDatabaseCustomLogger(database: self, logger: logger) + } +} + +/// Replaces the `Logger` of an existing ``SQLiteDatabase`` while forwarding queries to the original. +private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { + let database: D + let logger: Logger + + func query( + _ query: String, + _ binds: [SQLiteData], + logger: Logger, + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) async throws { + try await self.database.query(query, binds, logger: logger, onRow) + } + + func logging(to logger: Logger) -> any SQLiteDatabase { + Self(database: self.database, logger: logger) + } +} +#endif // NativeConcurrency diff --git a/Sources/SQLiteNIO/SQLiteError.swift b/Sources/SQLiteNIO/SQLiteError.swift index 57deaa8..4310943 100644 --- a/Sources/SQLiteNIO/SQLiteError.swift +++ b/Sources/SQLiteNIO/SQLiteError.swift @@ -1,7 +1,17 @@ import CSQLite +// Foundation provides `LocalizedError`; unavailable on WASI (`errorDescription` is kept as a plain +// property there). +#if !hasFeature(Embedded) import Foundation +#endif -public struct SQLiteError: Error, CustomStringConvertible, LocalizedError { +// `LocalizedError` is Foundation-only; declared via a gated extension (the `errorDescription` +// witness lives in the struct body and is simply unused on WASI). +#if !hasFeature(Embedded) +extension SQLiteError: LocalizedError {} +#endif + +public struct SQLiteError: Error, CustomStringConvertible { public let reason: Reason public let message: String diff --git a/Sources/SQLiteNIO/SQLiteRow.swift b/Sources/SQLiteNIO/SQLiteRow.swift index abddcad..f4bfe50 100644 --- a/Sources/SQLiteNIO/SQLiteRow.swift +++ b/Sources/SQLiteNIO/SQLiteRow.swift @@ -25,7 +25,12 @@ public struct SQLiteRow: CustomStringConvertible, Sendable { } public var description: String { + #if hasFeature(Embedded) + // `Array.description` uses reflection, unavailable in Embedded Swift. + "[" + self.columns.map { $0.description }.joined(separator: ", ") + "]" + #else self.columns.description + #endif } } diff --git a/Sources/SQLiteNIO/SQLiteStatement.swift b/Sources/SQLiteNIO/SQLiteStatement.swift index 21912b4..6b71cea 100644 --- a/Sources/SQLiteNIO/SQLiteStatement.swift +++ b/Sources/SQLiteNIO/SQLiteStatement.swift @@ -1,4 +1,6 @@ +#if !NativeConcurrency import NIOCore +#endif import CSQLite struct SQLiteStatement { @@ -40,9 +42,15 @@ struct SQLiteStatement { switch bind { case .blob(let value): + #if NativeConcurrency + ret = value.withUnsafeBytes { + sqlite_nio_sqlite3_bind_blob64(self.handle, i, $0.baseAddress, UInt64($0.count), SQLITE_TRANSIENT) + } + #else ret = value.withUnsafeReadableBytes { sqlite_nio_sqlite3_bind_blob64(self.handle, i, $0.baseAddress, UInt64($0.count), SQLITE_TRANSIENT) } + #endif case .float(let value): ret = sqlite_nio_sqlite3_bind_double(self.handle, i, value) case .integer(let value): @@ -102,12 +110,21 @@ struct SQLiteStatement { return .text(.init(cString: val)) case SQLITE_BLOB: let length = Int(sqlite_nio_sqlite3_column_bytes(self.handle, offset)) + #if NativeConcurrency + var bytes = [UInt8]() + bytes.reserveCapacity(length) + if let blobPointer = sqlite_nio_sqlite3_column_blob(self.handle, offset) { + bytes.append(contentsOf: UnsafeRawBufferPointer(start: blobPointer, count: length)) + } + return .blob(bytes) + #else var buffer = ByteBufferAllocator().buffer(capacity: length) - + if let blobPointer = sqlite_nio_sqlite3_column_blob(self.handle, offset) { buffer.writeBytes(UnsafeRawBufferPointer(start: blobPointer, count: length)) } return .blob(buffer) + #endif case SQLITE_NULL: return .null default: