diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7810eff..14c39b4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,4 @@ version: 2 -enable-beta-ecosystems: true updates: - package-ecosystem: "github-actions" directory: "/" @@ -11,14 +10,3 @@ updates: dependencies: patterns: - "*" - - package-ecosystem: "swift" - directory: "/" - schedule: - interval: "daily" - open-pull-requests-limit: 6 - allow: - - dependency-type: all - groups: - all-dependencies: - patterns: - - "*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce62c58..92622a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: dependents-check: if: ${{ !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest - container: swift:5.9-jammy + container: swift:5.10-jammy steps: - name: Check out package uses: actions/checkout@v4 @@ -38,3 +38,4 @@ jobs: unit-tests: uses: vapor/ci/.github/workflows/run-unit-tests.yml@main + secrets: inherit \ No newline at end of file diff --git a/Package.swift b/Package.swift index d421ea7..d7d6fe4 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:5.8 import PackageDescription let package = Package( @@ -13,8 +13,8 @@ let package = Package( .library(name: "SQLiteNIO", targets: ["SQLiteNIO"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.58.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), ], targets: [ .plugin( @@ -25,38 +25,69 @@ let package = Package( ), exclude: ["001-warnings-and-data-race.patch"] ), - .target(name: "CSQLite", cSettings: [ - // Derived from sqlite3 version 3.43.0 - .define("SQLITE_DQS", to: "0"), - .define("SQLITE_ENABLE_API_ARMOR"), - .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_MEMORY_MANAGEMENT"), - .define("SQLITE_ENABLE_PREUPDATE_HOOK"), - .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_DEPRECATED"), - .define("SQLITE_OMIT_LOAD_EXTENSION"), - .define("SQLITE_OMIT_SHARED_CACHE"), - .define("SQLITE_SECURE_DELETE"), - .define("SQLITE_THREADSAFE", to: "2"), - .define("SQLITE_USE_URI"), - ]), - .target(name: "SQLiteNIO", dependencies: [ - .target(name: "CSQLite"), - .product(name: "Logging", package: "swift-log"), - .product(name: "NIO", package: "swift-nio"), - ]), - .testTarget(name: "SQLiteNIOTests", dependencies: ["SQLiteNIO"]), + .target( + name: "CSQLite", + cSettings: sqliteCSettings + ), + .target( + name: "SQLiteNIO", + dependencies: [ + .target(name: "CSQLite"), + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOFoundationCompat", package: "swift-nio"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SQLiteNIOTests", + dependencies: [ + .target(name: "SQLiteNIO"), + ], + swiftSettings: swiftSettings + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), +] } + +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_AUTHORIZATION"), + .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"), + .define("SQLITE_UNTESTABLE"), + .define("SQLITE_USE_URI"), +] } diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..9aa260b --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,99 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "sqlite-nio", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + ], + products: [ + .library(name: "SQLiteNIO", targets: ["SQLiteNIO"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .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"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOFoundationCompat", package: "swift-nio"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SQLiteNIOTests", + dependencies: [ + .target(name: "SQLiteNIO"), + ], + swiftSettings: swiftSettings + ), + ] +) + +var swiftSettings: [SwiftSetting] { [ + .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_AUTHORIZATION"), + .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"), + .define("SQLITE_UNTESTABLE"), + .define("SQLITE_USE_URI"), +] } diff --git a/README.md b/README.md index 9c7e5ce..e191d80 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ Documentation Team Chat MIT License -Continuous Integration - -Swift 5.7+ +Continuous Integration + +Swift 5.8+


diff --git a/Sources/SQLiteNIO/Docs.docc/Documentation.md b/Sources/SQLiteNIO/Docs.docc/Documentation.md index 4ef954f..780d865 100644 --- a/Sources/SQLiteNIO/Docs.docc/Documentation.md +++ b/Sources/SQLiteNIO/Docs.docc/Documentation.md @@ -4,12 +4,11 @@ @TitleHeading(Package) } -🪶 Non-blocking, event-driven Swift client for SQLite with embedded libsqlite +🪶 Non-blocking, event-driven Swift client for SQLite with embedded `libsqlite`. ## Supported Versions -This package is compatible with all platforms supported by [SwiftNIO 2.x](https://github.com/apple/swift-nio/). It has -been specifically tested on the following platforms: +This package is compatible with all platforms supported by [SwiftNIO 2.x](https://github.com/apple/swift-nio/). It has been specifically tested on the following platforms: - Ubuntu 20.04 ("Focal") and 22.04 ("Jammy") - Amazon Linux 2 diff --git a/Sources/SQLiteNIO/Docs.docc/images/vapor-sqlitenio-logo.svg b/Sources/SQLiteNIO/Docs.docc/Resources/vapor-sqlitenio-logo.svg similarity index 100% rename from Sources/SQLiteNIO/Docs.docc/images/vapor-sqlitenio-logo.svg rename to Sources/SQLiteNIO/Docs.docc/Resources/vapor-sqlitenio-logo.svg diff --git a/Sources/SQLiteNIO/Docs.docc/theme-settings.json b/Sources/SQLiteNIO/Docs.docc/theme-settings.json index 2266669..640f319 100644 --- a/Sources/SQLiteNIO/Docs.docc/theme-settings.json +++ b/Sources/SQLiteNIO/Docs.docc/theme-settings.json @@ -1,6 +1,6 @@ { "theme": { - "aside": { "border-radius": "6px", "border-style": "double", "border-width": "3px" }, + "aside": { "border-radius": "16px", "border-style": "double", "border-width": "3px" }, "border-radius": "0", "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, diff --git a/Sources/SQLiteNIO/Exports.swift b/Sources/SQLiteNIO/Exports.swift index 65b24ee..0542622 100644 --- a/Sources/SQLiteNIO/Exports.swift +++ b/Sources/SQLiteNIO/Exports.swift @@ -1,17 +1,5 @@ -#if swift(>=5.8) - @_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 - -@_exported import struct NIOCore.ByteBuffer -@_exported import class NIOPosix.NIOThreadPool -@_exported import protocol NIOCore.EventLoop -@_exported import protocol NIOCore.EventLoopGroup -@_exported import class NIOPosix.MultiThreadedEventLoopGroup - -#endif diff --git a/Sources/SQLiteNIO/SQLiteConnection.swift b/Sources/SQLiteNIO/SQLiteConnection.swift index 62543b5..2b0f306 100644 --- a/Sources/SQLiteNIO/SQLiteConnection.swift +++ b/Sources/SQLiteNIO/SQLiteConnection.swift @@ -1,103 +1,114 @@ import NIOCore +#if swift(<5.9) +import NIOConcurrencyHelpers +#endif import NIOPosix import CSQLite import Logging -public protocol SQLiteDatabase { - var logger: Logger { get } - var eventLoop: any EventLoop { get } - - @preconcurrency func query( - _ query: String, - _ binds: [SQLiteData], - logger: Logger, - _ onRow: @escaping @Sendable (SQLiteRow) -> Void - ) -> EventLoopFuture - - @preconcurrency func withConnection( - _: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture - ) -> EventLoopFuture -} - -extension SQLiteDatabase { - @preconcurrency - public func query( - _ query: String, - _ binds: [SQLiteData] = [], - _ onRow: @escaping @Sendable (SQLiteRow) -> Void - ) -> EventLoopFuture { - self.query(query, binds, logger: self.logger, onRow) - } - - public func query( - _ query: String, - _ binds: [SQLiteData] = [] - ) -> EventLoopFuture<[SQLiteRow]> { - let rows: UnsafeMutableTransferBox<[SQLiteRow]> = .init([]) - return self.query(query, binds, logger: self.logger) { row in - rows.wrappedValue.append(row) - }.map { rows.wrappedValue } - } - } - -extension SQLiteDatabase { - public func logging(to logger: Logger) -> any SQLiteDatabase { - _SQLiteDatabaseCustomLogger(database: self, logger: logger) +/// A wrapper for the `OpaquePointer` used to represent an open `sqlite3` handle. +/// +/// This wrapper serves two purposes: +/// +/// - Silencing `Sendable` warnings relating to use of the pointer, and +/// - Preventing confusion with other C types which import as opaque pointers. +/// +/// 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`. +/// 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 +/// given connection handle simultaneously from multiple threads). +/// - We include `SQLITE_OPEN_FULLMUTEX` when calling `sqlite_open_v2()`, guaranteeing the use of the +/// serialized threading mode for each connection even if someone uses `sqlite3_config()` to make the +/// less strict multithreaded mode the default. +/// +/// And finally, the use of `@unchecked` in particular is justified because: +/// +/// 1. We need to be able to mutate the value in order to make it `nil` when the connection it represented +/// is closed. We use the `nil` value as a sentinel by which we determine a connection's validity. Also, +/// _not_ `nil`-ing it out would leave a dangling/freed pointer in place, which is just begging for a +/// segfault. +/// 2. An `OpaquePointer` can not be natively `Sendable`, by definition; it's opaque! The `@unchecked` +/// annotation is how we tell the compiler "we've taken the appropriate precautions to make moving +/// values of this type between isolation regions safe". +/// +/// > Note: It appears that in Swift 5.8, TSan likes to throw false positive warnings about this type, hence +/// > the compiler conditionals around using bogus extra locking. +final class SQLiteConnectionHandle: @unchecked Sendable { + #if swift(<5.9) + private let _raw: NIOLockedValueBox + var raw: OpaquePointer? { + get { self._raw.withLockedValue { $0 } } + set { self._raw.withLockedValue { $0 = newValue } } } -} - -private struct _SQLiteDatabaseCustomLogger: SQLiteDatabase { - let database: any SQLiteDatabase - var eventLoop: any EventLoop { - self.database.eventLoop - } - let logger: Logger - @preconcurrency func withConnection( - _ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture - ) -> EventLoopFuture { - self.database.withConnection(closure) - } - - @preconcurrency func query( - _ query: String, - _ binds: [SQLiteData], - logger: Logger, - _ onRow: @escaping @Sendable (SQLiteRow) -> Void - ) -> EventLoopFuture { - self.database.query(query, binds, logger: logger, onRow) + init(_ raw: OpaquePointer?) { + self._raw = .init(raw) } -} - -internal final class SQLiteConnectionHandle: @unchecked Sendable { + #else var raw: OpaquePointer? init(_ raw: OpaquePointer?) { self.raw = raw } + #endif } -public final class SQLiteConnection: SQLiteDatabase { - /// Available SQLite storage methods. - public enum Storage { - /// In-memory storage. Not persisted between application launches. - /// Good for unit testing or caching. +/// Represents a single open connection to an SQLite database, either on disk or in memory. +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. + /// + /// In-memory databases persist only so long as the connection to them is open, and are not shared + /// between processes. In addition, because this package builds the sqlite3 amalgamation with the + /// recommended `SQLITE_OMIT_SHARED_CACHE` option, it is not possible to open multiple connections + /// to a single in-memory database; use a temporary file instead. + /// + /// In-memory databases are useful for unit testing or caching purposes. case memory - /// File-based storage, persisted between application launches. + /// An SQLite database stored in a file at the specified path. + /// + /// If a relative path is specified, it is interpreted relative to the current working directory of the + /// current process (e.g. `NIOFileSystem.shared.currentWorkingDirectory`) at the time of establishing + /// the connection. It is strongly recommended that users always use absolute paths whenever possible. + /// + /// File-based databases persist as long as the files representing them on disk does, and can be opened + /// multiple times within the same process or even by multiple processes if configured properly. case file(path: String) } - public let eventLoop: any EventLoop - - internal let handle: SQLiteConnectionHandle - internal let threadPool: NIOThreadPool - public let logger: Logger + /// Return the version of the embedded libsqlite3 as a 32-bit integer value. + /// + /// The value is laid out identicallly to [the `SQLITE_VERSION_NUMBER` constant](c_source_id). + /// + /// [c_source_id]: https://sqlite.org/c3ref/c_source_id.html + public static func libraryVersion() -> Int32 { + sqlite_nio_sqlite3_libversion_number() + } - public var isClosed: Bool { - self.handle.raw == nil + /// Return the version of the embedded libsqlite3 as a string. + /// + /// The string is formatted identically to [the `SQLITE_VERSION` constant](c_source_id). + /// + /// [c_source_id]: https://sqlite.org/c3ref/c_source_id.html + public static func libraryVersionString() -> String { + String(cString: sqlite_nio_sqlite3_libversion()) } - + + /// Open a new connection to an SQLite database. + /// + /// This is equivalent to invoking ``open(storage:threadPool:logger:on:)-64n3x`` using the + /// `NIOThreadPool` and `MultiThreadedEventLoopGroup` singletons. This is the recommended configuration + /// for all users. + /// + /// - 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 future whose value on success is a new connection object. public static func open( storage: Storage = .memory, logger: Logger = .init(label: "codes.vapor.sqlite") @@ -109,42 +120,71 @@ public final class SQLiteConnection: SQLiteDatabase { on: MultiThreadedEventLoopGroup.singleton.any() ) } - + + /// Open a new connection to an SQLite database. + /// + /// - Parameters: + /// - storage: Specifies the location of the database for the connection. See ``Storage`` for details. + /// - threadPool: An `NIOThreadPool` used to execute all libsqlite3 API calls for this connection. + /// - logger: The logger used by the connection. Defaults to a new `Logger`. + /// - eventLoop: An `EventLoop` to associate with the connection for creating futures. + /// - Returns: A future whose value on success is a new connection object. public static func open( storage: Storage = .memory, threadPool: NIOThreadPool, logger: Logger = .init(label: "codes.vapor.sqlite"), on eventLoop: any EventLoop ) -> EventLoopFuture { + threadPool.runIfActive(eventLoop: eventLoop) { + try self.openInternal(storage: storage, threadPool: threadPool, logger: logger, eventLoop: eventLoop) + } + } + + /// The underlying implementation of ``open(storage:threadPool:logger:on:)-64n3x`` and + /// ``open(storage:threadPool:logger:on:)-3m3lb``. + private static func openInternal( + storage: Storage, + threadPool: NIOThreadPool, + logger: Logger, + eventLoop: any EventLoop + ) throws -> SQLiteConnection { let path: String switch storage { - case .memory: - path = ":memory:" - case .file(let file): - path = file + case .memory: path = ":memory:" + case .file(let file): path = file } - return threadPool.runIfActive(eventLoop: eventLoop) { - var handle: OpaquePointer? - let options = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX | SQLITE_OPEN_URI - - if sqlite_nio_sqlite3_open_v2(path, &handle, options, nil) == SQLITE_OK, sqlite_nio_sqlite3_busy_handler(handle, { _, _ in 1 }, nil) == SQLITE_OK { - let connection = SQLiteConnection( - handle: handle, - threadPool: threadPool, - logger: logger, - on: eventLoop - ) - logger.debug("Connected to sqlite db: \(path)") - return connection - } else { - logger.error("Failed to connect to sqlite db: \(path)") - throw SQLiteError(reason: .cantOpen, message: "Cannot open SQLite database: \(storage)") - } + 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, threadPool: threadPool, logger: logger, on: eventLoop) } - init( + // See `SQLiteDatabase.eventLoop`. + public let eventLoop: any EventLoop + + // 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. + private let threadPool: NIOThreadPool + + /// Initialize a new ``SQLiteConnection``. Internal use only. + private init( handle: OpaquePointer?, threadPool: NIOThreadPool, logger: Logger, @@ -156,36 +196,41 @@ public final class SQLiteConnection: SQLiteDatabase { self.eventLoop = eventLoop } - public static func libraryVersion() -> Int32 { - sqlite_nio_sqlite3_libversion_number() + /// 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. + var errorMessage: String? { + sqlite_nio_sqlite3_errmsg(self.handle.raw).map { String(cString: $0) } } - public static func libraryVersionString() -> String { - String(cString: sqlite_nio_sqlite3_libversion()) + /// `false` if the connection is valid, `true` if not. + public var isClosed: Bool { + self.handle.raw == nil } - + + /// 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. + /// + /// Only valid until the next operation is performed on the connection; watch out for races. + /// + /// - Returns: A future containing the most recently inserted rowid value. public func lastAutoincrementID() -> EventLoopFuture { self.threadPool.runIfActive(eventLoop: self.eventLoop) { - let rowid = sqlite_nio_sqlite3_last_insert_rowid(self.handle.raw) - return numericCast(rowid) + numericCast(sqlite_nio_sqlite3_last_insert_rowid(self.handle.raw)) } } - internal var errorMessage: String? { - if let raw = sqlite_nio_sqlite3_errmsg(self.handle.raw) { - return String(cString: raw) - } else { - return nil - } - } - - @preconcurrency public func withConnection( + // See `SQLiteDatabase.withConnection(_:)`. + @preconcurrency + public func withConnection( _ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture ) -> EventLoopFuture { closure(self) } - @preconcurrency public func query( + // See `SQLiteDatabase.query(_:_:logger:_:)`. + @preconcurrency + public func query( _ query: String, _ binds: [SQLiteData], logger: Logger, @@ -201,7 +246,7 @@ public final class SQLiteConnection: SQLiteDatabase { } var futures: [EventLoopFuture] = [] do { - let statement = try SQLiteStatement(query: query, on: self) + 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) { @@ -215,36 +260,143 @@ public final class SQLiteConnection: SQLiteDatabase { return promise.futureResult } + /// Close the connection and invalidate its handle. + /// + /// No further operations may be performed on the connection after calling this method. + /// + /// - Returns: A future indicating completion of connection closure. public func close() -> EventLoopFuture { self.threadPool.runIfActive(eventLoop: self.eventLoop) { sqlite_nio_sqlite3_close(self.handle.raw) self.handle.raw = nil } } - + + /// Install the provided ``SQLiteCustomFunction`` on the connection. + /// + /// - Parameter customFunction: The function to install. + /// - Returns: A future indicating completion of the install operation. public func install(customFunction: SQLiteCustomFunction) -> EventLoopFuture { - logger.trace("Adding custom function \(customFunction.name)") + self.logger.trace("Adding custom function \(customFunction.name)") return self.threadPool.runIfActive(eventLoop: self.eventLoop) { try customFunction.install(in: self) } } + /// Uninstall the provided ``SQLiteCustomFunction`` from the connection. + /// + /// - Parameter customFunction: The function to remove. + /// - Returns: A future indicating completion of the uninstall operation. public func uninstall(customFunction: SQLiteCustomFunction) -> EventLoopFuture { - logger.trace("Removing custom function \(customFunction.name)") + self.logger.trace("Removing custom function \(customFunction.name)") return self.threadPool.runIfActive(eventLoop: self.eventLoop) { try customFunction.uninstall(in: self) } } + /// Deinitializer for ``SQLiteConnection``. deinit { assert(self.handle.raw == nil, "SQLiteConnection was not closed before deinitializing") } } -fileprivate final class UnsafeMutableTransferBox: @unchecked Sendable { - var wrappedValue: Wrapped - init(_ wrappedValue: Wrapped) { self.wrappedValue = wrappedValue } -} +extension SQLiteConnection { + /// Open a new connection to an SQLite database. + /// + /// This is equivalent to invoking ``open(storage:threadPool:logger:on:)-3m3lb`` using the + /// `NIOThreadPool` and `MultiThreadedEventLoopGroup` singletons. This is the recommended configuration + /// for all users. + /// + /// - 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 future whose value on success is a new connection object. + public static func open( + storage: Storage = .memory, + logger: Logger = .init(label: "codes.vapor.sqlite") + ) async throws -> SQLiteConnection { + try await Self.open( + storage: storage, + threadPool: NIOThreadPool.singleton, + logger: logger, + on: MultiThreadedEventLoopGroup.singleton.any() + ) + } + + /// Open a new connection to an SQLite database. + /// + /// - Parameters: + /// - storage: Specifies the location of the database for the connection. See ``Storage`` for details. + /// - threadPool: An `NIOThreadPool` used to execute all libsqlite3 API calls for this connection. + /// - logger: The logger used by the connection. Defaults to a new `Logger`. + /// - eventLoop: An `EventLoop` to associate with the connection for creating futures. + /// - Returns: A new connection object. + public static func open( + storage: Storage = .memory, + threadPool: NIOThreadPool, + logger: Logger = .init(label: "codes.vapor.sqlite"), + on eventLoop: any EventLoop + ) async throws -> SQLiteConnection { + try await threadPool.runIfActive { + try self.openInternal(storage: storage, threadPool: threadPool, logger: logger, eventLoop: eventLoop) + } + } + + /// 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. + /// + /// Only valid until the next operation is performed on the connection; watch out for races. + /// + /// - Returns: The most recently inserted rowid value. + public func lastAutoincrementID() async throws -> Int { + try await self.threadPool.runIfActive { + numericCast(sqlite_nio_sqlite3_last_insert_rowid(self.handle.raw)) + } + } + + /// Concurrency-aware variant of ``withConnection(_:)-8cmxp``. + public func withConnection( + _ closure: @escaping @Sendable (SQLiteConnection) async throws -> T + ) async throws -> T { + try await closure(self) + } + + /// Concurrency-aware variant of ``query(_:_:_:)-etrj``. + public func query( + _ query: String, + _ binds: [SQLiteData], + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) async throws { + try await self.query(query, binds, onRow).get() + } + + /// Close the connection and invalidate its handle. + /// + /// No further operations may be performed on the connection after calling this method. + public func close() async throws { + try await self.threadPool.runIfActive { + sqlite_nio_sqlite3_close(self.handle.raw) + self.handle.raw = nil + } + } -extension SQLiteConnection: Sendable {} -extension SQLiteConnection.Storage: Sendable {} + /// Install the provided ``SQLiteCustomFunction`` on the connection. + /// + /// - Parameter customFunction: The function to install. + public func install(customFunction: SQLiteCustomFunction) async throws { + self.logger.trace("Adding custom function \(customFunction.name)") + return try await self.threadPool.runIfActive { + try customFunction.install(in: self) + } + } + + /// Uninstall the provided ``SQLiteCustomFunction`` from the connection. + /// + /// - Parameter customFunction: The function to remove. + public func uninstall(customFunction: SQLiteCustomFunction) async throws { + self.logger.trace("Removing custom function \(customFunction.name)") + return try await self.threadPool.runIfActive { + try customFunction.uninstall(in: self) + } + } +} diff --git a/Sources/SQLiteNIO/SQLiteCustomFunction.swift b/Sources/SQLiteNIO/SQLiteCustomFunction.swift index b9a58f0..64e990d 100644 --- a/Sources/SQLiteNIO/SQLiteCustomFunction.swift +++ b/Sources/SQLiteNIO/SQLiteCustomFunction.swift @@ -24,10 +24,12 @@ public final class SQLiteCustomFunction: Hashable { /// The name of the SQL function public var name: String { identity.name } + private let identity: Identity private let pure: Bool + private let indirect: Bool private let kind: Kind - private var eTextRep: Int32 { (SQLITE_UTF8 | (pure ? SQLITE_DETERMINISTIC : 0)) } + private var eTextRep: Int32 { (SQLITE_UTF8 | (pure ? SQLITE_DETERMINISTIC : 0) | (indirect ? 0 : SQLITE_DIRECTONLY)) } public struct SQLiteCustomFunctionArgumentError: Error { public let count: Int @@ -38,19 +40,19 @@ public final class SQLiteCustomFunction: Hashable { _ name: String, argumentCount: Int32? = nil, pure: Bool = false, + indirect: Bool = false, function: @Sendable @escaping ([SQLiteData]) throws -> (any SQLiteDataConvertible)?) { self.identity = Identity(name: name, nArg: argumentCount ?? -1) self.pure = pure + self.indirect = indirect self.kind = .function { (argc, argv) in - let count = Int(argc) - let arguments = try (0 ..< count).map { index -> SQLiteData in + try function((0 ..< Int(argc)).map { index -> SQLiteData in guard let value = argv?[index] else { - throw SQLiteCustomFunctionArgumentError(count: count, index: index) + throw SQLiteCustomFunctionArgumentError(count: Int(argc), index: index) } return try SQLiteData(sqliteValue: value) - } - return try function(arguments) + }) } } @@ -95,10 +97,12 @@ public final class SQLiteCustomFunction: Hashable { _ name: String, argumentCount: Int32? = nil, pure: Bool = false, + indirect: Bool = false, aggregate: Aggregate.Type ) { self.identity = Identity(name: name, nArg: argumentCount ?? -1) self.pure = pure + self.indirect = indirect self.kind = .aggregate { Aggregate() } } @@ -106,22 +110,17 @@ public final class SQLiteCustomFunction: Hashable { /// See https://sqlite.org/c3ref/create_function.html func install(in connection: SQLiteConnection) throws { // Retain the function definition - let definition = kind.definition - let definitionP = Unmanaged.passRetained(definition).toOpaque() - let code = sqlite_nio_sqlite3_create_function_v2( connection.handle.raw, - identity.name, - identity.nArg, - eTextRep, - definitionP, - kind.xFunc, - kind.xStep, - kind.xFinal, - { definitionP in - // Release the function definition - Unmanaged.fromOpaque(definitionP!).release() - }) + self.identity.name, + self.identity.nArg, + self.eTextRep, + Unmanaged.passRetained(self.kind.definition).toOpaque(), + self.kind.xFunc, + self.kind.xStep, + self.kind.xFinal, + { Unmanaged.fromOpaque($0!).release() } // Release the function definition + ) guard code == SQLITE_OK else { throw SQLiteError(statusCode: code, connection: connection) @@ -133,10 +132,11 @@ public final class SQLiteCustomFunction: Hashable { func uninstall(in connection: SQLiteConnection) throws { let code = sqlite_nio_sqlite3_create_function_v2( connection.handle.raw, - identity.name, - identity.nArg, - eTextRep, - nil, nil, nil, nil, nil) + self.identity.name, + self.identity.nArg, + self.eTextRep, + nil, nil, nil, nil, nil + ) guard code == SQLITE_OK else { throw SQLiteError(statusCode: code, connection: connection) diff --git a/Sources/SQLiteNIO/SQLiteData.swift b/Sources/SQLiteNIO/SQLiteData.swift index b93a91f..adf7588 100644 --- a/Sources/SQLiteNIO/SQLiteData.swift +++ b/Sources/SQLiteNIO/SQLiteData.swift @@ -1,23 +1,30 @@ import CSQLite import NIOCore -/// Supported SQLite data types. -public enum SQLiteData: Equatable, Encodable, CustomStringConvertible { - /// `Int`. +/// Encapsulates a single data item provided by or to SQLite. +/// +/// 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 { + /// `INTEGER` affinity, represented in Swift by `Int`. case integer(Int) - /// `Double`. + /// `REAL` affinity, represented in Swift by `Double`. case float(Double) - /// `String`. + /// `TEXT` affinity, represented in Swift by `String`. case text(String) - /// `ByteBuffer`. + /// `BLOB` affinity, represented in Swift by `ByteBuffer`. case blob(ByteBuffer) - /// `NULL`. + /// A `NULL` value. case null + /// Returns the integer value of the data, performing conversions where possible. + /// + /// If the data has `REAL` or `TEXT` affinity, an attempt is made to interpret the value as an integer. `BLOB` + /// and `NULL` values always return `nil`. public var integer: Int? { switch self { case .integer(let integer): @@ -31,6 +38,10 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible { } } + /// Returns the real number value of the data, performing conversions where possible. + /// + /// If the data has `INTEGER` or `TEXT` affinity, an attempt is made to interpret the value as a `Double`. `BLOB` + /// and `NULL` values always return `nil`. public var double: Double? { switch self { case .integer(let integer): @@ -44,6 +55,10 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible { } } + /// Returns the textual value of the data, performing conversions where possible. + /// + /// If the data has `INTEGER` or `REAL` affinity, the value is converted to text. `BLOB` and `NULL` values always + /// return `nil`. public var string: String? { switch self { case .integer(let integer): @@ -57,6 +72,10 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible { } } + /// Returns the boolean value of the data, where possible. + /// + /// Returns `true` if the value of ``integer`` is exactly `1`, `false` if the value of ``integer`` is exactly + /// `0`, or `nil` for all other cases. public var bool: Bool? { switch self.integer { case 1: return true @@ -65,6 +84,9 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible { } } + /// Returns the data as a blob, if it has `BLOB` affinity. + /// + /// `INTEGER`, `REAL`, `TEXT`, and `NULL` values always return `nil`. public var blob: ByteBuffer? { switch self { case .blob(let buffer): @@ -74,6 +96,7 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible { } } + /// `true` if the value is `NULL`, `false` otherwise. public var isNull: Bool { switch self { case .null: @@ -83,33 +106,32 @@ public enum SQLiteData: Equatable, Encodable, CustomStringConvertible { } } - /// Description of data + // See `CustomStringConvertible.description`. public var description: String { switch self { case .blob(let data): return "<\(data.readableBytes) bytes>" case .float(let float): return float.description case .integer(let int): return int.description case .null: return "null" - case .text(let text): return "\"" + text + "\"" + case .text(let text): return #""\#(text)""# } } - /// See `Encodable`. + // See `Encodable.encode(to:)`. 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) - case .blob(var value): - let bytes = value.readBytes(length: value.readableBytes) ?? [] - try container.encode(bytes) + 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 case .null: try container.encodeNil() } } } extension SQLiteData { + /// Attempt to interpret an `sqlite3_value` as an equivalent ``SQLiteData``. init(sqliteValue: OpaquePointer) throws { switch sqlite_nio_sqlite3_value_type(sqliteValue) { case SQLITE_NULL: @@ -119,7 +141,11 @@ extension SQLiteData { case SQLITE_FLOAT: self = .float(sqlite_nio_sqlite3_value_double(sqliteValue)) case SQLITE_TEXT: - self = .text(String(cString: sqlite_nio_sqlite3_value_text(sqliteValue)!)) + if let raw = sqlite_nio_sqlite3_value_text(sqliteValue) { + self = .text(String.init(cString: raw)) + } else { + self = .text("") + } case SQLITE_BLOB: if let bytes = sqlite_nio_sqlite3_value_blob(sqliteValue) { let count = Int(sqlite_nio_sqlite3_value_bytes(sqliteValue)) @@ -133,9 +159,10 @@ extension SQLiteData { } } + /// The error thrown by ``init(sqliteValue:)`` if an `sqlite3_value` has an unknown type. + /// + /// This should never happen, and this error should not have been made `public`. public struct SQLiteCustomFunctionUnexpectedValueTypeError: Error { public let type: Int32 } } - -extension SQLiteData: Sendable {} diff --git a/Sources/SQLiteNIO/SQLiteDataConvertible.swift b/Sources/SQLiteNIO/SQLiteDataConvertible.swift index f6af2c7..2f788c1 100644 --- a/Sources/SQLiteNIO/SQLiteDataConvertible.swift +++ b/Sources/SQLiteNIO/SQLiteDataConvertible.swift @@ -1,4 +1,5 @@ import NIOCore +import NIOFoundationCompat import Foundation public protocol SQLiteDataConvertible { @@ -8,19 +9,20 @@ public protocol SQLiteDataConvertible { extension String: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { - guard case .text(let value) = sqliteData else { + guard let value = sqliteData.string else { return nil } self = value } public var sqliteData: SQLiteData? { - return .text(self) + .text(self) } } extension FixedWidthInteger { public init?(sqliteData: SQLiteData) { + // Don't use `SQLiteData.integer`, we don't want to attempt converting strings here. guard case .integer(let value) = sqliteData else { return nil } @@ -28,7 +30,7 @@ extension FixedWidthInteger { } public var sqliteData: SQLiteData? { - return .integer(numericCast(self)) + .integer(numericCast(self)) } } @@ -45,27 +47,30 @@ extension UInt64: SQLiteDataConvertible { } extension Double: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { - guard case .float(let value) = sqliteData else { - return nil + // Don't use `SQLiteData.double`, we don't want to attempt converting strings here. + switch sqliteData { + case .integer(let int): self.init(int) + case .float(let double): self = double + case .text(_), .blob(_), .null: return nil } - self = value } public var sqliteData: SQLiteData? { - return .float(self) + .float(self) } } extension Float: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { - guard case .float(let value) = sqliteData else { - return nil + switch sqliteData { + case .integer(let int): self.init(int) + case .float(let double): self.init(double) + case .text(_), .blob(_), .null: return nil } - self = Float(value) } public var sqliteData: SQLiteData? { - return .float(Double(self)) + .float(Double(self)) } } @@ -78,38 +83,33 @@ extension ByteBuffer: SQLiteDataConvertible { } public var sqliteData: SQLiteData? { - return .blob(self) + .blob(self) } } extension Data: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { - guard case .blob(var value) = sqliteData else { - return nil - } - guard let data = value.readBytes(length: value.readableBytes) else { + guard case .blob(let value) = sqliteData else { return nil } - self = Data(data) + self = .init(buffer: value, byteTransferStrategy: .copy) } public var sqliteData: SQLiteData? { - var buffer = ByteBufferAllocator().buffer(capacity: self.count) - buffer.writeBytes(self) - return .blob(buffer) + .blob(.init(data: self)) } } extension Bool: SQLiteDataConvertible { public init?(sqliteData: SQLiteData) { guard let bool = sqliteData.bool else { - return nil - } - self = bool + return nil } + self = bool + } public var sqliteData: SQLiteData? { - return .integer(self ? 1 : 0) + .integer(self ? 1 : 0) } } @@ -139,7 +139,7 @@ extension Date: SQLiteDataConvertible { } public var sqliteData: SQLiteData? { - return .float(timeIntervalSince1970) + .float(timeIntervalSince1970) } } diff --git a/Sources/SQLiteNIO/SQLiteDataType.swift b/Sources/SQLiteNIO/SQLiteDataType.swift index f5bb47b..a899699 100644 --- a/Sources/SQLiteNIO/SQLiteDataType.swift +++ b/Sources/SQLiteNIO/SQLiteDataType.swift @@ -16,7 +16,6 @@ public enum SQLiteDataType { /// `NULL`. case null - /// See `SQLSerializable`. public func serialize(_ binds: inout [any Encodable]) -> String { switch self { case .integer: return "INTEGER" diff --git a/Sources/SQLiteNIO/SQLiteDatabase.swift b/Sources/SQLiteNIO/SQLiteDatabase.swift new file mode 100644 index 0000000..539a182 --- /dev/null +++ b/Sources/SQLiteNIO/SQLiteDatabase.swift @@ -0,0 +1,198 @@ +import NIOCore +import NIOPosix +import CSQLite +import Logging + +/// A protocol describing the minimum requirements for an object allowing access to a generic SQLite database. +/// +/// This protocol is intended to assist with connection pooling and other "smells like a simple database but isn't" +/// use cases. In retrospect, it has become clear that it was poorly designed. Users and implementations alike +/// should try to use ``SQLiteConnection`` directly whenever possible. +public protocol SQLiteDatabase: Sendable { + /// The logger used by the connection. + var logger: Logger { get } + + /// The event loop on which operations on the connection execute. + var eventLoop: any EventLoop { get } + + /// 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. + /// + /// > Warning: The `logger` parameter of this method is a holdover from Fluent 4's development cycle that + /// > should have been removed before the final release. Unfortunately, this didn't happen, and semantic + /// > versioning has left the API stuck with it ever single. Callers of this API should either always pass + /// > the value of the ``logger`` property or use ``query(_:_:_:)`` instead. Implementations that wish to + /// > conform to this protocol should ignore the parameter entirely in favor of the ``logger`` property. + /// > At no time during SQLiteNIO's lifetime has this parameter ever been honored; indeed, at the time of + /// > this writing, ``SQLiteConnection``'s implementation of this method doesn't use _any_ logger at all. + /// + /// - Parameters: + /// - query: The query string to execute. + /// - binds: An ordered list of ``SQLiteData`` items to use as bound parameters for the query. + /// - logger: Ignored. See above discussion for details. + /// - onRow: A closure to invoke for each result row returned by the query, if any. + /// - Returns: A future completed when the query has executed and returned all results (if any). + @preconcurrency + func query( + _ query: String, + _ binds: [SQLiteData], + logger: Logger, + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) -> EventLoopFuture + + /// 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 + /// same "session" (e.g. always on the same connection, such as without rotating through a pool). + /// + /// - Parameter closure: The closure to invoke. Unless the closure changes the connection's state itself or the + /// connection is closed by SQLite due to error, it is guaranteed to remain valid until the future returned by + /// the closure is completed or failed. + /// - Returns: A future signaling completion of the closure and containing the closure's result, if any. + @preconcurrency + func withConnection( + _ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture + ) -> EventLoopFuture +} + +/// Convenience helpers and Concurrency-aware variants. +extension SQLiteDatabase { + /// 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 + /// of the protocol requirement. + @preconcurrency + public func query( + _ query: String, + _ binds: [SQLiteData] = [], + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) -> EventLoopFuture { + self.query(query, binds, logger: self.logger, onRow) + } + + /// Convenience method for calling ``query(_:_:logger:_:)`` with the connection's logger (async version). + /// + /// Callers are strongly encouraged to always use this method or its futures-based equivalent (``query(_:_:_:)``) + /// instead of the protocol requirement. + public func query( + _ query: String, + _ binds: [SQLiteData] = [], + _ onRow: @escaping @Sendable (SQLiteRow) -> Void + ) async throws { + try await self.query(query, binds, logger: self.logger, onRow).get() + } + + /// Wrapper for ``query(_:_:_:)`` which returns the result rows (if any) rather than calling a closure. + public func query(_ query: String, _ binds: [SQLiteData] = []) -> EventLoopFuture<[SQLiteRow]> { + #if swift(<5.10) + let rows: UnsafeMutableTransferBox<[SQLiteRow]> = .init([]) + + return self.query(query, binds, logger: self.logger) { rows.wrappedValue.append($0) }.map { rows.wrappedValue } + #else + nonisolated(unsafe) var rows: [SQLiteRow] = [] + + return self.query(query, binds, logger: self.logger) { rows.append($0) }.map { rows } + #endif + } + + /// Wrapper for ``query(_:_:_:)`` which returns the result rows (if any) rather than calling a + /// closure (async version). + public func query(_ query: String, _ binds: [SQLiteData] = []) async throws -> [SQLiteRow] { + try await self.query(query, binds).get() + } + + /// Async version of ``withConnection(_:)-48y34``. + public func withConnection( + _ closure: @escaping @Sendable (SQLiteConnection) async throws -> T + ) async throws -> T { + try await self.withConnection { conn in + conn.eventLoop.makeFutureWithTask { + try await closure(conn) + } + }.get() + } +} + +#if swift(<5.10) +/// A wrapper type to avoid `Sendable` warnings for mutable captures that are otherwise safe. +/// +/// This effectively acts as workaround for the absence of `nonisolated(unsafe)` before Swift 5.10. +fileprivate final class UnsafeMutableTransferBox: @unchecked Sendable { + var wrappedValue: Wrapped + init(_ wrappedValue: Wrapped) { self.wrappedValue = wrappedValue } +} +#endif + +extension SQLiteDatabase { + /// Return a new ``SQLiteDatabase`` which is indistinguishable from the original save that its + /// ``SQLiteDatabase/logger`` property is replaced by the given `Logger`. + /// + /// This has the effect of redirecting logging performed on or by the original database to the + /// provided `Logger`. + /// + /// > Warning: The log redirection applies only to the new ``SQLiteDatabase`` that is returned from + /// > this method; logging operations performed on the original (i.e. `self`) are unaffected. + /// + /// > Note: Because this method returns a generic ``SQLiteDatabase``, the type it returns need not be public + /// > API. Unfortunately, this also means that no inlining or static dispatch of the implementation is + /// > possible, thus imposing a performance penalty on the use of this otherwise trivial utility. + /// + /// - Parameter logger: The new `Logger` to use. + /// - Returns: A database object which logs to the new `Logger`. + public func logging(to logger: Logger) -> any SQLiteDatabase { + SQLiteDatabaseCustomLogger(database: self, logger: logger) + } +} + +/// Replaces the `Logger` of an existing ``SQLiteDatabase`` while forwarding all other properties and +/// methods to the original. +private struct SQLiteDatabaseCustomLogger: SQLiteDatabase { + /// The underlying database. + let database: D + + // See `SQLiteDatabase.logger`. + let logger: Logger + + // See `SQLiteDatabase.eventLoop`. + var eventLoop: any EventLoop { self.database.eventLoop } + + // See `SQLiteDatabase.withConnection(_:)`. + func withConnection(_ closure: @escaping @Sendable (SQLiteConnection) -> EventLoopFuture) -> EventLoopFuture { + self.database.withConnection(closure) + } + // See `SQLiteDatabase.withConnection(_:)`. + func withConnection(_ closure: @escaping @Sendable (SQLiteConnection) async throws -> T) async throws -> T { + try await self.database.withConnection(closure) + } + + // 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) + } + + // See `SQLiteDatabase.query(_:_:_:)`. + func query(_ query: String, _ binds: [SQLiteData] = [], _ onRow: @escaping @Sendable (SQLiteRow) -> Void) -> EventLoopFuture { + self.database.query(query, binds, onRow) + } + // See `SQLiteDatabase.query(_:_:_:)`. + func query(_ query: String, _ binds: [SQLiteData], _ onRow: @escaping @Sendable (SQLiteRow) -> Void) async throws { + try await self.database.query(query, binds, onRow) + } + + // See `SQLiteDatabase.query(_:_:)`. + func query(_ query: String, _ binds: [SQLiteData] = []) -> EventLoopFuture<[SQLiteRow]> { + self.database.query(query, binds) + } + // See `SQLiteDatabase.query(_:_:)`. + func query(_ query: String, _ binds: [SQLiteData] = []) async throws -> [SQLiteRow] { + try await self.database.query(query, binds) + } + + // See `SQLiteDatabase.logger(_:)`. + func logging(to logger: Logger) -> any SQLiteDatabase { + /// N.B.: We explicitly override this method so that if ``SQLiteDatabase/logging(to:)`` is called in a nested + /// or chained fashion, methods still only have to be forwarded at most once. + Self(database: self.database, logger: logger) + } +} diff --git a/Sources/SQLiteNIO/SQLiteError.swift b/Sources/SQLiteNIO/SQLiteError.swift index 611b74c..57deaa8 100644 --- a/Sources/SQLiteNIO/SQLiteError.swift +++ b/Sources/SQLiteNIO/SQLiteError.swift @@ -6,24 +6,25 @@ public struct SQLiteError: Error, CustomStringConvertible, LocalizedError { public let message: String public var description: String { - return "\(self.reason): \(self.message)" + "\(self.reason): \(self.message)" } public var errorDescription: String? { - return self.description + self.description } - internal init(reason: Reason, message: String) { + init(reason: Reason, message: String) { self.reason = reason self.message = message } - internal init(statusCode: Int32, connection: SQLiteConnection) { + init(statusCode: Int32, connection: SQLiteConnection) { self.reason = .init(statusCode: statusCode) self.message = connection.errorMessage ?? "Unknown" } - public enum Reason { + public enum Reason: Sendable { + // SQLite "basic" errors case error case intern case permission @@ -54,6 +55,33 @@ public struct SQLiteError: Error, CustomStringConvertible, LocalizedError { case warning case row case done + + // SQLite "extended" result codes + case errorMissingCollatingSequence, errorRetry, errorMissingSnapshot + case abortByRollback + case busyInRecovery, busyInSnapshot, busyTimeout + case lockedBySharedCache, lockedVirtualTable + case readonlyInRecovery, readonlyCantLock, readonlyInRollback, readonlyBackingMoved, readonlyDirectory + case ioErrorFailedRead, ioErrorIncompleteRead, ioErrorFailedWrite, ioErrorFailedSync, ioErrorFailedDirSync, + ioErrorFailedTruncate, ioErrorFailedStat, ioErrorFailedUnlock, ioErrorFailedReadLock, + ioErrorFailedDelete, ioErrorNoMemory, ioErrorFailedAccess, ioErrorFailedLockCheck, + ioErrorFailedAdvisoryLock, ioErrorFailedClose, ioErrorFailedSharedMemOpen, ioErrorFailedSharedMemSize, + ioErrorFailedSharedMemMap, ioErrorFailedDeleteNonexistent, ioErrorFailedMemoryMap, ioErrorCantFindTempdir, + ioErrorCygwinPath, ioErrorBadDataChecksum, ioErrorCorruptedFilesystem + case corruptVirtualTable, corruptSequenceSchema, corruptIndex + case cantOpenDirectory, cantOpenInvalidPath, cantOpenCygwinPath, cantOpenUnfollowedSymlink + case constraintCheckFailed, constraintCommitHookFailed, constraintForeignKeyFailed, + constraintUserFunctionFailed, constraintNotNullFailed, constraintPrimaryKeyFailed, + constraintTriggerFailed, constraintUniqueFailed, constraintVirtualTableFailed, + constraintUniqueRowIDFailed, constraintUpdateTriggerDeletedRow, constraintStrictDataTypeFailed + case authUnauthorizedUser + case noticeRecoverWAL, noticeRecoverRollback + case warningAutoindex + + + // The following five "reasons" are holdovers from early development; they have never used by the package + // are do not correspond to SQLite error codes. They should be considered deprecated, but are not marked + // as such as there would be no way to avoid the warning for users who switch over this enum. case connection case close case prepare @@ -62,138 +90,275 @@ public struct SQLiteError: Error, CustomStringConvertible, LocalizedError { var statusCode: Int32 { switch self { - case .error: - return SQLITE_ERROR - case .intern: - return SQLITE_INTERNAL - case .abort: - return SQLITE_ABORT - case .permission: - return SQLITE_PERM - case .busy: - return SQLITE_BUSY - case .locked: - return SQLITE_LOCKED - case .noMemory: - return SQLITE_NOMEM - case .readOnly: - return SQLITE_READONLY - case .interrupt: - return SQLITE_INTERRUPT - case .ioError: - return SQLITE_IOERR - case .corrupt: - return SQLITE_CORRUPT - case .notFound: - return SQLITE_NOTFOUND - case .full: - return SQLITE_FULL - case .cantOpen: - return SQLITE_CANTOPEN - case .proto: - return SQLITE_PROTOCOL - case .empty: - return SQLITE_EMPTY - case .schema: - return SQLITE_SCHEMA - case .tooBig: - return SQLITE_TOOBIG - case .constraint: - return SQLITE_CONSTRAINT - case .mismatch: - return SQLITE_MISMATCH - case .misuse: - return SQLITE_MISUSE - case .noLFS: - return SQLITE_NOLFS - case .auth: - return SQLITE_AUTH - case .format: - return SQLITE_FORMAT - case .range: - return SQLITE_RANGE - case .notADatabase: - return SQLITE_NOTADB - case .notice: - return SQLITE_NOTICE - case .warning: - return SQLITE_WARNING - case .row: - return SQLITE_ROW - case .done: - return SQLITE_DONE - case .connection, .close, .prepare, .bind, .execute: - return -1 + case .error: return SQLITE_ERROR + case .intern: return SQLITE_INTERNAL + case .abort: return SQLITE_ABORT + case .permission: return SQLITE_PERM + case .busy: return SQLITE_BUSY + case .locked: return SQLITE_LOCKED + case .noMemory: return SQLITE_NOMEM + case .readOnly: return SQLITE_READONLY + case .interrupt: return SQLITE_INTERRUPT + case .ioError: return SQLITE_IOERR + case .corrupt: return SQLITE_CORRUPT + case .notFound: return SQLITE_NOTFOUND + case .full: return SQLITE_FULL + case .cantOpen: return SQLITE_CANTOPEN + case .proto: return SQLITE_PROTOCOL + case .empty: return SQLITE_EMPTY + case .schema: return SQLITE_SCHEMA + case .tooBig: return SQLITE_TOOBIG + case .constraint: return SQLITE_CONSTRAINT + case .mismatch: return SQLITE_MISMATCH + case .misuse: return SQLITE_MISUSE + case .noLFS: return SQLITE_NOLFS + case .auth: return SQLITE_AUTH + case .format: return SQLITE_FORMAT + case .range: return SQLITE_RANGE + case .notADatabase: return SQLITE_NOTADB + case .notice: return SQLITE_NOTICE + case .warning: return SQLITE_WARNING + case .row: return SQLITE_ROW + case .done: return SQLITE_DONE + case .errorMissingCollatingSequence: return SQLITE_ERROR_MISSING_COLLSEQ + case .errorRetry: return SQLITE_ERROR_RETRY + case .errorMissingSnapshot: return SQLITE_ERROR_SNAPSHOT + case .abortByRollback: return SQLITE_ABORT_ROLLBACK + case .busyInRecovery: return SQLITE_BUSY_RECOVERY + case .busyInSnapshot: return SQLITE_BUSY_SNAPSHOT + case .busyTimeout: return SQLITE_BUSY_TIMEOUT + case .lockedBySharedCache: return SQLITE_LOCKED_SHAREDCACHE + case .lockedVirtualTable: return SQLITE_LOCKED_VTAB + case .readonlyInRecovery: return SQLITE_READONLY_RECOVERY + case .readonlyCantLock: return SQLITE_READONLY_CANTLOCK + case .readonlyInRollback: return SQLITE_READONLY_ROLLBACK + case .readonlyBackingMoved: return SQLITE_READONLY_DBMOVED + case .readonlyDirectory: return SQLITE_READONLY_DIRECTORY + case .ioErrorFailedRead: return SQLITE_IOERR_READ + case .ioErrorIncompleteRead: return SQLITE_IOERR_SHORT_READ + case .ioErrorFailedWrite: return SQLITE_IOERR_WRITE + case .ioErrorFailedSync: return SQLITE_IOERR_FSYNC + case .ioErrorFailedDirSync: return SQLITE_IOERR_DIR_FSYNC + case .ioErrorFailedTruncate: return SQLITE_IOERR_TRUNCATE + case .ioErrorFailedStat: return SQLITE_IOERR_FSTAT + case .ioErrorFailedUnlock: return SQLITE_IOERR_UNLOCK + case .ioErrorFailedReadLock: return SQLITE_IOERR_RDLOCK + case .ioErrorFailedDelete: return SQLITE_IOERR_DELETE + case .ioErrorNoMemory: return SQLITE_IOERR_NOMEM + case .ioErrorFailedAccess: return SQLITE_IOERR_ACCESS + case .ioErrorFailedLockCheck: return SQLITE_IOERR_LOCK + case .ioErrorFailedAdvisoryLock: return SQLITE_IOERR_CHECKRESERVEDLOCK + case .ioErrorFailedClose: return SQLITE_IOERR_CLOSE + case .ioErrorFailedSharedMemOpen: return SQLITE_IOERR_SHMOPEN + case .ioErrorFailedSharedMemSize: return SQLITE_IOERR_SHMSIZE + case .ioErrorFailedSharedMemMap: return SQLITE_IOERR_SHMMAP + case .ioErrorFailedDeleteNonexistent: return SQLITE_IOERR_DELETE_NOENT + case .ioErrorFailedMemoryMap: return SQLITE_IOERR_MMAP + case .ioErrorCantFindTempdir: return SQLITE_IOERR_GETTEMPPATH + case .ioErrorCygwinPath: return SQLITE_IOERR_CONVPATH + case .ioErrorBadDataChecksum: return SQLITE_IOERR_DATA + case .ioErrorCorruptedFilesystem: return SQLITE_IOERR_CORRUPTFS + case .corruptVirtualTable: return SQLITE_CORRUPT_VTAB + case .corruptSequenceSchema: return SQLITE_CORRUPT_SEQUENCE + case .corruptIndex: return SQLITE_CORRUPT_INDEX + case .cantOpenDirectory: return SQLITE_CANTOPEN_ISDIR + case .cantOpenInvalidPath: return SQLITE_CANTOPEN_FULLPATH + case .cantOpenCygwinPath: return SQLITE_CANTOPEN_CONVPATH + case .cantOpenUnfollowedSymlink: return SQLITE_CANTOPEN_SYMLINK + case .constraintCheckFailed: return SQLITE_CONSTRAINT_CHECK + case .constraintCommitHookFailed: return SQLITE_CONSTRAINT_COMMITHOOK + case .constraintForeignKeyFailed: return SQLITE_CONSTRAINT_FOREIGNKEY + case .constraintUserFunctionFailed: return SQLITE_CONSTRAINT_FUNCTION + case .constraintNotNullFailed: return SQLITE_CONSTRAINT_NOTNULL + case .constraintPrimaryKeyFailed: return SQLITE_CONSTRAINT_PRIMARYKEY + case .constraintTriggerFailed: return SQLITE_CONSTRAINT_TRIGGER + case .constraintUniqueFailed: return SQLITE_CONSTRAINT_UNIQUE + case .constraintVirtualTableFailed: return SQLITE_CONSTRAINT_VTAB + case .constraintUniqueRowIDFailed: return SQLITE_CONSTRAINT_ROWID + case .constraintUpdateTriggerDeletedRow: return SQLITE_CONSTRAINT_PINNED + case .constraintStrictDataTypeFailed: return SQLITE_CONSTRAINT_DATATYPE + case .authUnauthorizedUser: return SQLITE_AUTH_USER + case .noticeRecoverWAL: return SQLITE_NOTICE_RECOVER_WAL + case .noticeRecoverRollback: return SQLITE_NOTICE_RECOVER_ROLLBACK + case .warningAutoindex: return SQLITE_WARNING_AUTOINDEX + + case .connection, .close, .prepare, .bind, .execute: return -1 } } - internal init(statusCode: Int32) { + init(statusCode: Int32) { switch statusCode { - case SQLITE_ERROR: - self = .error - case SQLITE_INTERNAL: - self = .intern - case SQLITE_PERM: - self = .permission - case SQLITE_ABORT: - self = .abort - case SQLITE_BUSY: - self = .busy - case SQLITE_LOCKED: - self = .locked - case SQLITE_NOMEM: - self = .noMemory - case SQLITE_READONLY: - self = .readOnly - case SQLITE_INTERRUPT: - self = .interrupt - case SQLITE_IOERR: - self = .ioError - case SQLITE_CORRUPT: - self = .corrupt - case SQLITE_NOTFOUND: - self = .notFound - case SQLITE_FULL: - self = .full - case SQLITE_CANTOPEN: - self = .cantOpen - case SQLITE_PROTOCOL: - self = .proto - case SQLITE_EMPTY: - self = .empty - case SQLITE_SCHEMA: - self = .schema - case SQLITE_TOOBIG: - self = .tooBig - case SQLITE_CONSTRAINT: - self = .constraint - case SQLITE_MISMATCH: - self = .mismatch - case SQLITE_MISUSE: - self = .misuse - case SQLITE_NOLFS: - self = .noLFS - case SQLITE_AUTH: - self = .auth - case SQLITE_FORMAT: - self = .format - case SQLITE_RANGE: - self = .range - case SQLITE_NOTADB: - self = .notADatabase - case SQLITE_NOTICE: - self = .notice - case SQLITE_WARNING: - self = .warning - case SQLITE_ROW: - self = .row - case SQLITE_DONE: - self = .done - default: - self = .error + case SQLITE_ERROR: self = .error + case SQLITE_INTERNAL: self = .intern + case SQLITE_PERM: self = .permission + case SQLITE_ABORT: self = .abort + case SQLITE_BUSY: self = .busy + case SQLITE_LOCKED: self = .locked + case SQLITE_NOMEM: self = .noMemory + case SQLITE_READONLY: self = .readOnly + case SQLITE_INTERRUPT: self = .interrupt + case SQLITE_IOERR: self = .ioError + case SQLITE_CORRUPT: self = .corrupt + case SQLITE_NOTFOUND: self = .notFound + case SQLITE_FULL: self = .full + case SQLITE_CANTOPEN: self = .cantOpen + case SQLITE_PROTOCOL: self = .proto + case SQLITE_EMPTY: self = .empty + case SQLITE_SCHEMA: self = .schema + case SQLITE_TOOBIG: self = .tooBig + case SQLITE_CONSTRAINT: self = .constraint + case SQLITE_MISMATCH: self = .mismatch + case SQLITE_MISUSE: self = .misuse + case SQLITE_NOLFS: self = .noLFS + case SQLITE_AUTH: self = .auth + case SQLITE_FORMAT: self = .format + case SQLITE_RANGE: self = .range + case SQLITE_NOTADB: self = .notADatabase + case SQLITE_NOTICE: self = .notice + case SQLITE_WARNING: self = .warning + case SQLITE_ROW: self = .row + case SQLITE_DONE: self = .done + + case SQLITE_ERROR_MISSING_COLLSEQ: self = .errorMissingCollatingSequence + case SQLITE_ERROR_RETRY: self = .errorRetry + case SQLITE_ERROR_SNAPSHOT: self = .errorMissingSnapshot + case SQLITE_ABORT_ROLLBACK: self = .abortByRollback + case SQLITE_BUSY_RECOVERY: self = .busyInRecovery + case SQLITE_BUSY_SNAPSHOT: self = .busyInSnapshot + case SQLITE_BUSY_TIMEOUT: self = .busyTimeout + case SQLITE_LOCKED_SHAREDCACHE: self = .lockedBySharedCache + case SQLITE_LOCKED_VTAB: self = .lockedVirtualTable + case SQLITE_READONLY_RECOVERY: self = .readonlyInRecovery + case SQLITE_READONLY_CANTLOCK: self = .readonlyCantLock + case SQLITE_READONLY_ROLLBACK: self = .readonlyInRollback + case SQLITE_READONLY_DBMOVED: self = .readonlyBackingMoved + case SQLITE_READONLY_DIRECTORY: self = .readonlyDirectory + case SQLITE_IOERR_READ: self = .ioErrorFailedRead + case SQLITE_IOERR_SHORT_READ: self = .ioErrorIncompleteRead + case SQLITE_IOERR_WRITE: self = .ioErrorFailedWrite + case SQLITE_IOERR_FSYNC: self = .ioErrorFailedSync + case SQLITE_IOERR_DIR_FSYNC: self = .ioErrorFailedDirSync + case SQLITE_IOERR_TRUNCATE: self = .ioErrorFailedTruncate + case SQLITE_IOERR_FSTAT: self = .ioErrorFailedStat + case SQLITE_IOERR_UNLOCK: self = .ioErrorFailedUnlock + case SQLITE_IOERR_RDLOCK: self = .ioErrorFailedReadLock + case SQLITE_IOERR_DELETE: self = .ioErrorFailedDelete + case SQLITE_IOERR_NOMEM: self = .ioErrorNoMemory + case SQLITE_IOERR_ACCESS: self = .ioErrorFailedAccess + case SQLITE_IOERR_LOCK: self = .ioErrorFailedLockCheck + case SQLITE_IOERR_CHECKRESERVEDLOCK: self = .ioErrorFailedAdvisoryLock + case SQLITE_IOERR_CLOSE: self = .ioErrorFailedClose + case SQLITE_IOERR_SHMOPEN: self = .ioErrorFailedSharedMemOpen + case SQLITE_IOERR_SHMSIZE: self = .ioErrorFailedSharedMemSize + case SQLITE_IOERR_SHMMAP: self = .ioErrorFailedSharedMemMap + case SQLITE_IOERR_DELETE_NOENT: self = .ioErrorFailedDeleteNonexistent + case SQLITE_IOERR_MMAP: self = .ioErrorFailedMemoryMap + case SQLITE_IOERR_GETTEMPPATH: self = .ioErrorCantFindTempdir + case SQLITE_IOERR_CONVPATH: self = .ioErrorCygwinPath + case SQLITE_IOERR_DATA: self = .ioErrorBadDataChecksum + case SQLITE_IOERR_CORRUPTFS: self = .ioErrorCorruptedFilesystem + case SQLITE_CORRUPT_VTAB: self = .corruptVirtualTable + case SQLITE_CORRUPT_SEQUENCE: self = .corruptSequenceSchema + case SQLITE_CORRUPT_INDEX: self = .corruptIndex + case SQLITE_CANTOPEN_ISDIR: self = .cantOpenDirectory + case SQLITE_CANTOPEN_FULLPATH: self = .cantOpenInvalidPath + case SQLITE_CANTOPEN_CONVPATH: self = .cantOpenCygwinPath + case SQLITE_CANTOPEN_SYMLINK: self = .cantOpenUnfollowedSymlink + case SQLITE_CONSTRAINT_CHECK: self = .constraintCheckFailed + case SQLITE_CONSTRAINT_COMMITHOOK: self = .constraintCommitHookFailed + case SQLITE_CONSTRAINT_FOREIGNKEY: self = .constraintForeignKeyFailed + case SQLITE_CONSTRAINT_FUNCTION: self = .constraintUserFunctionFailed + case SQLITE_CONSTRAINT_NOTNULL: self = .constraintNotNullFailed + case SQLITE_CONSTRAINT_PRIMARYKEY: self = .constraintPrimaryKeyFailed + case SQLITE_CONSTRAINT_TRIGGER: self = .constraintTriggerFailed + case SQLITE_CONSTRAINT_UNIQUE: self = .constraintUniqueFailed + case SQLITE_CONSTRAINT_VTAB: self = .constraintVirtualTableFailed + case SQLITE_CONSTRAINT_ROWID: self = .constraintUniqueRowIDFailed + case SQLITE_CONSTRAINT_PINNED: self = .constraintUpdateTriggerDeletedRow + case SQLITE_CONSTRAINT_DATATYPE: self = .constraintStrictDataTypeFailed + case SQLITE_AUTH_USER: self = .authUnauthorizedUser + case SQLITE_NOTICE_RECOVER_WAL: self = .noticeRecoverWAL + case SQLITE_NOTICE_RECOVER_ROLLBACK: self = .noticeRecoverRollback + case SQLITE_WARNING_AUTOINDEX: self = .warningAutoindex + + default: self = .error } } } } -extension SQLiteError.Reason: Sendable {} +/// Redefinitions of SQLite's extended result codes, from `sqlite3.h`. ClangImporter still doesn't import these. +let SQLITE_ERROR_MISSING_COLLSEQ: Int32 = (SQLITE_ERROR | (1<<8)) +let SQLITE_ERROR_RETRY: Int32 = (SQLITE_ERROR | (2<<8)) +let SQLITE_ERROR_SNAPSHOT: Int32 = (SQLITE_ERROR | (3<<8)) +let SQLITE_IOERR_READ: Int32 = (SQLITE_IOERR | (1<<8)) +let SQLITE_IOERR_SHORT_READ: Int32 = (SQLITE_IOERR | (2<<8)) +let SQLITE_IOERR_WRITE: Int32 = (SQLITE_IOERR | (3<<8)) +let SQLITE_IOERR_FSYNC: Int32 = (SQLITE_IOERR | (4<<8)) +let SQLITE_IOERR_DIR_FSYNC: Int32 = (SQLITE_IOERR | (5<<8)) +let SQLITE_IOERR_TRUNCATE: Int32 = (SQLITE_IOERR | (6<<8)) +let SQLITE_IOERR_FSTAT: Int32 = (SQLITE_IOERR | (7<<8)) +let SQLITE_IOERR_UNLOCK: Int32 = (SQLITE_IOERR | (8<<8)) +let SQLITE_IOERR_RDLOCK: Int32 = (SQLITE_IOERR | (9<<8)) +let SQLITE_IOERR_DELETE: Int32 = (SQLITE_IOERR | (10<<8)) +let SQLITE_IOERR_BLOCKED: Int32 = (SQLITE_IOERR | (11<<8)) +let SQLITE_IOERR_NOMEM: Int32 = (SQLITE_IOERR | (12<<8)) +let SQLITE_IOERR_ACCESS: Int32 = (SQLITE_IOERR | (13<<8)) +let SQLITE_IOERR_CHECKRESERVEDLOCK: Int32 = (SQLITE_IOERR | (14<<8)) +let SQLITE_IOERR_LOCK: Int32 = (SQLITE_IOERR | (15<<8)) +let SQLITE_IOERR_CLOSE: Int32 = (SQLITE_IOERR | (16<<8)) +let SQLITE_IOERR_DIR_CLOSE: Int32 = (SQLITE_IOERR | (17<<8)) +let SQLITE_IOERR_SHMOPEN: Int32 = (SQLITE_IOERR | (18<<8)) +let SQLITE_IOERR_SHMSIZE: Int32 = (SQLITE_IOERR | (19<<8)) +let SQLITE_IOERR_SHMLOCK: Int32 = (SQLITE_IOERR | (20<<8)) +let SQLITE_IOERR_SHMMAP: Int32 = (SQLITE_IOERR | (21<<8)) +let SQLITE_IOERR_SEEK: Int32 = (SQLITE_IOERR | (22<<8)) +let SQLITE_IOERR_DELETE_NOENT: Int32 = (SQLITE_IOERR | (23<<8)) +let SQLITE_IOERR_MMAP: Int32 = (SQLITE_IOERR | (24<<8)) +let SQLITE_IOERR_GETTEMPPATH: Int32 = (SQLITE_IOERR | (25<<8)) +let SQLITE_IOERR_CONVPATH: Int32 = (SQLITE_IOERR | (26<<8)) +let SQLITE_IOERR_VNODE: Int32 = (SQLITE_IOERR | (27<<8)) +let SQLITE_IOERR_AUTH: Int32 = (SQLITE_IOERR | (28<<8)) +let SQLITE_IOERR_BEGIN_ATOMIC: Int32 = (SQLITE_IOERR | (29<<8)) +let SQLITE_IOERR_COMMIT_ATOMIC: Int32 = (SQLITE_IOERR | (30<<8)) +let SQLITE_IOERR_ROLLBACK_ATOMIC: Int32 = (SQLITE_IOERR | (31<<8)) +let SQLITE_IOERR_DATA: Int32 = (SQLITE_IOERR | (32<<8)) +let SQLITE_IOERR_CORRUPTFS: Int32 = (SQLITE_IOERR | (33<<8)) +let SQLITE_IOERR_IN_PAGE: Int32 = (SQLITE_IOERR | (34<<8)) +let SQLITE_LOCKED_SHAREDCACHE: Int32 = (SQLITE_LOCKED | (1<<8)) +let SQLITE_LOCKED_VTAB: Int32 = (SQLITE_LOCKED | (2<<8)) +let SQLITE_BUSY_RECOVERY: Int32 = (SQLITE_BUSY | (1<<8)) +let SQLITE_BUSY_SNAPSHOT: Int32 = (SQLITE_BUSY | (2<<8)) +let SQLITE_BUSY_TIMEOUT: Int32 = (SQLITE_BUSY | (3<<8)) +let SQLITE_CANTOPEN_NOTEMPDIR: Int32 = (SQLITE_CANTOPEN | (1<<8)) +let SQLITE_CANTOPEN_ISDIR: Int32 = (SQLITE_CANTOPEN | (2<<8)) +let SQLITE_CANTOPEN_FULLPATH: Int32 = (SQLITE_CANTOPEN | (3<<8)) +let SQLITE_CANTOPEN_CONVPATH: Int32 = (SQLITE_CANTOPEN | (4<<8)) +let SQLITE_CANTOPEN_SYMLINK: Int32 = (SQLITE_CANTOPEN | (6<<8)) +let SQLITE_CORRUPT_VTAB: Int32 = (SQLITE_CORRUPT | (1<<8)) +let SQLITE_CORRUPT_SEQUENCE: Int32 = (SQLITE_CORRUPT | (2<<8)) +let SQLITE_CORRUPT_INDEX: Int32 = (SQLITE_CORRUPT | (3<<8)) +let SQLITE_READONLY_RECOVERY: Int32 = (SQLITE_READONLY | (1<<8)) +let SQLITE_READONLY_CANTLOCK: Int32 = (SQLITE_READONLY | (2<<8)) +let SQLITE_READONLY_ROLLBACK: Int32 = (SQLITE_READONLY | (3<<8)) +let SQLITE_READONLY_DBMOVED: Int32 = (SQLITE_READONLY | (4<<8)) +let SQLITE_READONLY_CANTINIT: Int32 = (SQLITE_READONLY | (5<<8)) +let SQLITE_READONLY_DIRECTORY: Int32 = (SQLITE_READONLY | (6<<8)) +let SQLITE_ABORT_ROLLBACK: Int32 = (SQLITE_ABORT | (2<<8)) +let SQLITE_CONSTRAINT_CHECK: Int32 = (SQLITE_CONSTRAINT | (1<<8)) +let SQLITE_CONSTRAINT_COMMITHOOK: Int32 = (SQLITE_CONSTRAINT | (2<<8)) +let SQLITE_CONSTRAINT_FOREIGNKEY: Int32 = (SQLITE_CONSTRAINT | (3<<8)) +let SQLITE_CONSTRAINT_FUNCTION: Int32 = (SQLITE_CONSTRAINT | (4<<8)) +let SQLITE_CONSTRAINT_NOTNULL: Int32 = (SQLITE_CONSTRAINT | (5<<8)) +let SQLITE_CONSTRAINT_PRIMARYKEY: Int32 = (SQLITE_CONSTRAINT | (6<<8)) +let SQLITE_CONSTRAINT_TRIGGER: Int32 = (SQLITE_CONSTRAINT | (7<<8)) +let SQLITE_CONSTRAINT_UNIQUE: Int32 = (SQLITE_CONSTRAINT | (8<<8)) +let SQLITE_CONSTRAINT_VTAB: Int32 = (SQLITE_CONSTRAINT | (9<<8)) +let SQLITE_CONSTRAINT_ROWID: Int32 = (SQLITE_CONSTRAINT | (10<<8)) +let SQLITE_CONSTRAINT_PINNED: Int32 = (SQLITE_CONSTRAINT | (11<<8)) +let SQLITE_CONSTRAINT_DATATYPE: Int32 = (SQLITE_CONSTRAINT | (12<<8)) +let SQLITE_NOTICE_RECOVER_WAL: Int32 = (SQLITE_NOTICE | (1<<8)) +let SQLITE_NOTICE_RECOVER_ROLLBACK: Int32 = (SQLITE_NOTICE | (2<<8)) +let SQLITE_NOTICE_RBU: Int32 = (SQLITE_NOTICE | (3<<8)) +let SQLITE_WARNING_AUTOINDEX: Int32 = (SQLITE_WARNING | (1<<8)) +let SQLITE_AUTH_USER: Int32 = (SQLITE_AUTH | (1<<8)) diff --git a/Sources/SQLiteNIO/SQLiteRow.swift b/Sources/SQLiteNIO/SQLiteRow.swift index f940368..abddcad 100644 --- a/Sources/SQLiteNIO/SQLiteRow.swift +++ b/Sources/SQLiteNIO/SQLiteRow.swift @@ -1,4 +1,4 @@ -public struct SQLiteColumn: CustomStringConvertible { +public struct SQLiteColumn: CustomStringConvertible, Sendable { public let name: String public let data: SQLiteData @@ -7,7 +7,7 @@ public struct SQLiteColumn: CustomStringConvertible { } } -public struct SQLiteRow { +public struct SQLiteRow: CustomStringConvertible, Sendable { let columnOffsets: SQLiteColumnOffsets let data: [SQLiteData] @@ -23,23 +23,18 @@ public struct SQLiteRow { } return self.data[offset] } -} -extension SQLiteRow: CustomStringConvertible { public var description: String { self.columns.description } } -final class SQLiteColumnOffsets { +struct SQLiteColumnOffsets: Sendable { let offsets: [(String, Int)] let lookupTable: [String: Int] init(offsets: [(String, Int)]) { self.offsets = offsets - self.lookupTable = .init(offsets, uniquingKeysWith: { a, b in a }) + self.lookupTable = .init(offsets, uniquingKeysWith: { a, _ in a }) } } - -extension SQLiteRow: Sendable {} -extension SQLiteColumnOffsets: Sendable {} diff --git a/Sources/SQLiteNIO/SQLiteStatement.swift b/Sources/SQLiteNIO/SQLiteStatement.swift index 5a7e470..e32c897 100644 --- a/Sources/SQLiteNIO/SQLiteStatement.swift +++ b/Sources/SQLiteNIO/SQLiteStatement.swift @@ -1,56 +1,62 @@ import NIOCore import CSQLite -internal struct SQLiteStatement { +struct SQLiteStatement { private var handle: OpaquePointer? private let connection: SQLiteConnection - internal init(query: String, on connection: SQLiteConnection) throws { + init(query: String, on connection: SQLiteConnection) throws { self.connection = connection - let ret = sqlite_nio_sqlite3_prepare_v2(connection.handle.raw, query, -1, &self.handle, nil) + + let ret = sqlite_nio_sqlite3_prepare_v3( + connection.handle.raw, + query, + -1, + 0, // TODO: Look into figuring out when passing SQLITE_PREPARE_PERSISTENT would be apropos. + &self.handle, + nil + ) + // Can't use self.check() here, there's nohting to finalize yet on failure. guard ret == SQLITE_OK else { throw SQLiteError(statusCode: ret, connection: connection) } } - - internal func bind(_ binds: [SQLiteData]) throws { + + private mutating func check(_ ret: Int32) throws { + // We check it this way so that `SQLITE_DONE` causes a finalize without throwing an error. + if ret != SQLITE_OK, let handle = self.handle { + sqlite_nio_sqlite3_finalize(handle) + self.handle = nil + } + + guard ret == SQLITE_OK || ret == SQLITE_DONE || ret == SQLITE_ROW else { + throw SQLiteError(statusCode: ret, connection: self.connection) + } + } + + mutating func bind(_ binds: [SQLiteData]) throws { for (i, bind) in binds.enumerated() { - let i = Int32(i + 1) + let i = Int32(i + 1), ret: Int32 + switch bind { case .blob(let value): - let count = Int32(value.readableBytes) - let ret = value.withUnsafeReadableBytes { pointer in - return sqlite_nio_sqlite3_bind_blob(self.handle, i, pointer.baseAddress, count, SQLITE_TRANSIENT) - } - guard ret == SQLITE_OK else { - throw SQLiteError(statusCode: ret, connection: connection) + ret = value.withUnsafeReadableBytes { + sqlite_nio_sqlite3_bind_blob64(self.handle, i, $0.baseAddress, UInt64($0.count), SQLITE_TRANSIENT) } case .float(let value): - let ret = sqlite_nio_sqlite3_bind_double(self.handle, i, value) - guard ret == SQLITE_OK else { - throw SQLiteError(statusCode: ret, connection: connection) - } + ret = sqlite_nio_sqlite3_bind_double(self.handle, i, value) case .integer(let value): - let ret = sqlite_nio_sqlite3_bind_int64(self.handle, i, Int64(value)) - guard ret == SQLITE_OK else { - throw SQLiteError(statusCode: ret, connection: connection) - } + ret = sqlite_nio_sqlite3_bind_int64(self.handle, i, Int64(value)) case .null: - let ret = sqlite_nio_sqlite3_bind_null(self.handle, i) - if ret != SQLITE_OK { - throw SQLiteError(statusCode: ret, connection: connection) - } + ret = sqlite_nio_sqlite3_bind_null(self.handle, i) case .text(let value): - let strlen = Int32(value.utf8.count) - let ret = sqlite_nio_sqlite3_bind_text(self.handle, i, value, strlen, SQLITE_TRANSIENT) - guard ret == SQLITE_OK else { - throw SQLiteError(statusCode: ret, connection: connection) - } + ret = sqlite_nio_sqlite3_bind_text64(self.handle, i, value, UInt64(value.utf8.count), SQLITE_TRANSIENT, UInt8(SQLITE_UTF8)) } + try self.check(ret) } } - internal func columns() throws -> SQLiteColumnOffsets { + mutating func columns() throws -> SQLiteColumnOffsets { var columns: [(String, Int)] = [] let count = sqlite_nio_sqlite3_column_count(self.handle) @@ -58,37 +64,27 @@ internal struct SQLiteStatement { // iterate over column count and intialize columns once // we will then re-use the columns for each row - for i in 0.. SQLiteRow? { - // step over the query, this will continue to return SQLITE_ROW - // for as long as there are new rows to be fetched - let step = sqlite_nio_sqlite3_step(self.handle) - switch step { - case SQLITE_DONE: - // no results left - let ret = sqlite_nio_sqlite3_finalize(self.handle) - guard ret == SQLITE_OK else { - throw SQLiteError(statusCode: ret, connection: connection) - } - return nil + mutating func nextRow(for columns: SQLiteColumnOffsets) throws -> SQLiteRow? { + /// Step over the query. This will continue to return `SQLITE_ROW` for as long as there are new rows to be fetched. + switch sqlite_nio_sqlite3_step(self.handle) { case SQLITE_ROW: + // Row returned. break - default: - throw SQLiteError(statusCode: step, connection: connection) + case let ret: + // No results left, or error. + // This check is explicitly guaranteed to finalize the statement if the code is SQLITE_DONE. + try self.check(ret) + return nil } - let count = sqlite_nio_sqlite3_column_count(self.handle) - var row: [SQLiteData] = [] - for i in 0.. SQLiteData { switch sqlite_nio_sqlite3_column_type(self.handle, offset) { case SQLITE_INTEGER: - let val = sqlite_nio_sqlite3_column_int64(self.handle, offset) - let integer = Int(val) - return .integer(integer) + return .integer(Int(sqlite_nio_sqlite3_column_int64(self.handle, offset))) case SQLITE_FLOAT: - let val = sqlite_nio_sqlite3_column_double(self.handle, offset) - let double = Double(val) - return .float(double) + return .float(Double(sqlite_nio_sqlite3_column_double(self.handle, offset))) case SQLITE_TEXT: guard let val = sqlite_nio_sqlite3_column_text(self.handle, offset) else { throw SQLiteError(reason: .error, message: "Unexpected nil column text") } - let string = String(cString: val) - return .text(string) + 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(UnsafeBufferPointer( - start: blobPointer.assumingMemoryBound(to: UInt8.self), - count: length - )) + buffer.writeBytes(UnsafeRawBufferPointer(start: blobPointer, count: length)) } return .blob(buffer) - case SQLITE_NULL: return .null - default: throw SQLiteError(reason: .error, message: "Unexpected column type.") + case SQLITE_NULL: + return .null + default: + throw SQLiteError(reason: .error, message: "Unexpected column type") } } private func column(at offset: Int32) throws -> String { guard let cName = sqlite_nio_sqlite3_column_name(self.handle, offset) else { - throw SQLiteError(reason: .error, message: "Unexpected nil column name") + throw SQLiteError(reason: .error, message: "Unexpectedly found a nil column name at offset \(offset)") } return String(cString: cName) } } -internal let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) +let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + diff --git a/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift b/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift index c63ce1b..0ca82bf 100644 --- a/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteCustomFunctionTests.swift @@ -14,6 +14,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI */ import XCTest import SQLiteNIO +import NIOFoundationCompat private struct CustomValueType: SQLiteDataConvertible, Equatable { init() {} @@ -32,328 +33,236 @@ private struct CustomValueType: SQLiteDataConvertible, Equatable { final class DatabaseFunctionTests: XCTestCase { // MARK: - Return values - func testFunctionReturningNull() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } + func testFunctionReturningNull() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in nil } + try await conn.install(customFunction: fn) - let fn = SQLiteCustomFunction("f", argumentCount: 0) { dbValues in - return nil - } - try conn.install(customFunction: fn).wait() - - XCTAssertTrue(try conn.query("SELECT f() as result").map { rows in rows[0].column("result")!.isNull }.wait()) + await XCTAssertAsync(try await conn.query("SELECT f() as result").first?.column("result")?.isNull ?? false) + } } - func testFunctionReturningInt64() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - let fn = SQLiteCustomFunction("f", argumentCount: 0) { dbValues in - return Int(1) - } - try conn.install(customFunction: fn).wait() - XCTAssertEqual(Int(1), try conn.query("SELECT f() as result").map { rows in rows[0].column("result")?.integer }.wait()) + func testFunctionReturningInt64() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in 1 } + try await conn.install(customFunction: fn) + await XCTAssertEqualAsync(Int(1), try await conn.query("SELECT f() as result").first?.column("result")?.integer) + } } - func testFunctionReturningDouble() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 0) { dbValues in - return 1e100 - } - try conn.install(customFunction: fn).wait() - XCTAssertEqual(1e100, try conn.query("SELECT f() as result").map { rows in rows[0].column("result")?.double }.wait()) + func testFunctionReturningDouble() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in 1e100 } + + try await conn.install(customFunction: fn) + await XCTAssertEqualAsync(1e100, try await conn.query("SELECT f() as result").first?.column("result")?.double) + } } - func testFunctionReturningString() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in - return "foo" - } - try conn.install(customFunction: fn).wait() - XCTAssertEqual("foo", try conn.query("SELECT f() as result").map { rows in rows[0].column("result")?.string }.wait()) + func testFunctionReturningString() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in "foo" } + + try await conn.install(customFunction: fn) + await XCTAssertEqualAsync("foo", try await conn.query("SELECT f() as result").first?.column("result")?.string) + } } - func testFunctionReturningData() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in - return "foo".data(using: .utf8) - } - try conn.install(customFunction: fn).wait() - - XCTAssertEqual("foo".data(using: .utf8)!.sqliteData!.blob!, - try conn.query("SELECT f() as result").map { rows in rows[0].column("result")?.blob }.wait()) + func testFunctionReturningData() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in Data("foo".utf8) } + try await conn.install(customFunction: fn) - XCTAssertNotEqual("bar".data(using: .utf8)!.sqliteData!.blob!, - try conn.query("SELECT f() as result").map { rows in rows[0].column("result")?.blob }.wait()) + await XCTAssertEqualAsync(ByteBuffer(string: "foo"), try await conn.query("SELECT f() as result").first?.column("result")?.blob) + await XCTAssertNotEqualAsync(ByteBuffer(string: "bar"), try await conn.query("SELECT f() as result").first?.column("result")?.blob) + } } - func testFunctionReturningCustomValueType() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 0) { dbValues in - return CustomValueType() - } - try conn.install(customFunction: fn).wait() - XCTAssertEqual(CustomValueType().sqliteData, try conn.query("SELECT f() as result").map { rows in rows[0].column("result") }.wait()) + func testFunctionReturningCustomValueType() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in CustomValueType() } + + try await conn.install(customFunction: fn) + await XCTAssertEqualAsync(CustomValueType().sqliteData, try await conn.query("SELECT f() as result").first?.column("result")) + } } // MARK: - Argument values - func testFunctionArgumentNil() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 1) { (values: [SQLiteData]) in - return values[0].isNull - } - try conn.install(customFunction: fn).wait() - - XCTAssertTrue(try conn.query("SELECT f(NULL) as result") - .map { rows in rows[0].column("result")!.bool! }.wait()) - XCTAssertFalse(try conn.query("SELECT f(1) as result") - .map { rows in rows[0].column("result")!.bool! }.wait()) - XCTAssertFalse(try conn.query("SELECT f(1.1) as result") - .map { rows in rows[0].column("result")!.bool! }.wait()) - XCTAssertFalse(try conn.query("SELECT f('foo') as result") - .map { rows in rows[0].column("result")!.bool! }.wait()) - XCTAssertFalse(try conn.query("SELECT f(?) as result", [.text("foo")]) - .map { rows in rows[0].column("result")!.bool! }.wait()) - } + func testFunctionArgumentNil() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 1) { values in values[0].isNull } + try await conn.install(customFunction: fn) - func testFunctionArgumentInt64() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 1) { (values: [SQLiteData]) in - return values[0].integer - } - try conn.install(customFunction: fn).wait() - XCTAssertNil(try conn.query("SELECT f(NULL) as result") - .map { rows in rows[0].column("result")?.integer }.wait()) - XCTAssertEqual(1, try conn.query("SELECT f(1) as result") - .map { rows in rows[0].column("result")?.integer }.wait()) - XCTAssertEqual(1, try conn.query("SELECT f(1.1) as result") - .map { rows in rows[0].column("result")?.integer }.wait()) + await XCTAssertTrueAsync(try await conn.query("SELECT f(NULL) as result").first?.column("result")?.bool ?? false) + await XCTAssertFalseAsync(try await conn.query("SELECT f(1) as result").first?.column("result")?.bool ?? true) + await XCTAssertFalseAsync(try await conn.query("SELECT f(1.1) as result").first?.column("result")?.bool ?? true) + await XCTAssertFalseAsync(try await conn.query("SELECT f('foo') as result").first?.column("result")?.bool ?? true) + await XCTAssertFalseAsync(try await conn.query("SELECT f(?) as result", [.text("foo")]).first?.column("result")?.bool ?? true) + } } - func testFunctionArgumentDouble() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 1) { (values: [SQLiteData]) in - return values[0].double - } - try conn.install(customFunction: fn).wait() - XCTAssertNil(try conn.query("SELECT f(NULL) as result") - .map { rows in rows[0].column("result")?.double }.wait()) - XCTAssertEqual(1.0, try conn.query("SELECT f(1) as result") - .map { rows in rows[0].column("result")?.double }.wait()) - XCTAssertEqual(1.1, try conn.query("SELECT f(1.1) as result") - .map { rows in rows[0].column("result")?.double }.wait()) + func testFunctionArgumentInt64() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 1) { values in values[0].integer } + try await conn.install(customFunction: fn) + + await XCTAssertNilAsync(try await conn.query("SELECT f(NULL) as result").first?.column("result")?.integer) + await XCTAssertEqualAsync(1, try await conn.query("SELECT f(1) as result").first?.column("result")?.integer) + await XCTAssertEqualAsync(1, try await conn.query("SELECT f(1.1) as result").first?.column("result")?.integer) + } } - func testFunctionArgumentString() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 1) { (values: [SQLiteData]) in - return values[0].string - } - try conn.install(customFunction: fn).wait() - XCTAssertNil(try conn.query("SELECT f(NULL) as result") - .map { rows in rows[0].column("result")?.string }.wait()) - XCTAssertEqual("foo", try conn.query("SELECT f('foo') as result") - .map { rows in rows[0].column("result")?.string }.wait()) + func testFunctionArgumentDouble() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 1) { values in values[0].double } + try await conn.install(customFunction: fn) + + await XCTAssertNilAsync(try await conn.query("SELECT f(NULL) as result").first?.column("result")?.double) + await XCTAssertEqualAsync(1.0, try await conn.query("SELECT f(1) as result").first?.column("result")?.double) + await XCTAssertEqualAsync(1.1, try await conn.query("SELECT f(1.1) as result").first?.column("result")?.double) + } } - func testFunctionArgumentBlob() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 1) { (values: [SQLiteData]) in - return values[0].blob - } - try conn.install(customFunction: fn).wait() + func testFunctionArgumentString() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 1) { values in values[0].string } + try await conn.install(customFunction: fn) - XCTAssertNil(try conn.query("SELECT f(NULL) as result") - .map { rows in rows[0].column("result")?.blob }.wait()) + await XCTAssertNilAsync(try await conn.query("SELECT f(NULL) as result").first?.column("result")?.string) + await XCTAssertEqualAsync("foo", try await conn.query("SELECT f('foo') as result").first?.column("result")?.string) + } + } - XCTAssertEqual("foo".data(using: .utf8)!.sqliteData!.blob, try conn.query("SELECT f(?) as result", ["foo".data(using: .utf8)!.sqliteData!]) - .map { rows in rows[0].column("result")?.blob }.wait()) + func testFunctionArgumentBlob() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 1) { values in values[0].blob } + try await conn.install(customFunction: fn) - XCTAssertEqual(ByteBuffer(), try conn.query("SELECT f(?) as result", [.blob(ByteBuffer())]) - .map { rows in rows[0].column("result")?.blob }.wait()) + await XCTAssertNilAsync(try await conn.query("SELECT f(NULL) as result").first?.column("result")?.blob) + await XCTAssertEqualAsync(ByteBuffer(string: "foo"), try await conn.query("SELECT f(?) as result", [.blob(ByteBuffer(string: "foo"))]).first?.column("result")?.blob) + await XCTAssertEqualAsync(ByteBuffer(), try await conn.query("SELECT f(?) as result", [.blob(ByteBuffer())]).first?.column("result")?.blob) + } } - func testFunctionArgumentCustomValueType() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 1) { (values: [SQLiteData]) in - return CustomValueType(sqliteData: values[0]) - } - try conn.install(customFunction: fn).wait() - XCTAssertNil(try conn.query("SELECT f(NULL) as result") - .map { rows in CustomValueType(sqliteData: rows[0].column("result")!) }.wait()) - XCTAssertEqual(CustomValueType(), try conn.query("SELECT f('CustomValueType') as result") - .map { rows in CustomValueType(sqliteData: rows[0].column("result")!) }.wait()) + func testFunctionArgumentCustomValueType() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 1) { values in CustomValueType(sqliteData: values[0]) } + try await conn.install(customFunction: fn) + + await XCTAssertNilAsync(try await conn.query("SELECT f(NULL) as result").first?.column("result").flatMap(CustomValueType.init(sqliteData:))) + await XCTAssertEqualAsync(CustomValueType(), try await conn.query("SELECT f('CustomValueType') as result").first?.column("result").flatMap(CustomValueType.init(sqliteData:))) + } } // MARK: - Argument count - func testFunctionWithoutArgument() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 0) { (values: [SQLiteData]) in - return "foo" - } - try conn.install(customFunction: fn).wait() - XCTAssertEqual("foo", try conn.query("SELECT f() as result") - .map { rows in rows[0].column("result")?.string }.wait()) - - do { - _ = try conn.query("SELECT f(1)").wait() - } catch let error as SQLiteError { - XCTAssertEqual(error.reason, .error) - XCTAssertEqual(error.message, "wrong number of arguments to function f()") - } - } - - func testFunctionOfOneArgument() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f", argumentCount: 1) { (values: [SQLiteData]) in - return values.first?.string?.uppercased() - } - - try conn.install(customFunction: fn).wait() - - XCTAssertNil(try conn.query("SELECT f(NULL) as result") - .map { rows in rows[0].column("result")?.string }.wait()) - XCTAssertEqual("ROUé", try conn.query("SELECT upper(?) as result", [.text("Roué")]) - .map { rows in rows[0].column("result")?.string }.wait()) - XCTAssertEqual("ROUÉ", try conn.query("SELECT f(?) as result", [.text("Roué")]) - .map { rows in rows[0].column("result")?.string }.wait()) - - do { - _ = try conn.query("SELECT f()").wait() - } catch let error as SQLiteError { - XCTAssertEqual(error.reason, .error) - XCTAssertEqual(error.message, "wrong number of arguments to function f()") - } + func testFunctionWithoutArgument() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in "foo" } + try await conn.install(customFunction: fn) + + await XCTAssertEqualAsync("foo", try await conn.query("SELECT f() as result").first?.column("result")?.string) + await XCTAssertThrowsErrorAsync(try await conn.query("SELECT f(1)")) { + guard let error = $0 as? SQLiteError else { return XCTFail("Expected SQLiteError, got \(String(reflecting: $0))") } + XCTAssertEqual(error.reason, .error) + XCTAssertEqual(error.message, "wrong number of arguments to function f()") + } + } } - func testFunctionOfTwoArguments() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - let fn = SQLiteCustomFunction("f", argumentCount: 2) { (values: [SQLiteData]) in - values - .compactMap { $0.integer } - .reduce(0, +) - } - - try conn.install(customFunction: fn).wait() - XCTAssertEqual(3, try conn.query("SELECT f(1, 2) as result") - .map { rows in rows[0].column("result")?.integer }.wait()) + func testFunctionOfOneArgument() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 1) { values in values.first?.string?.uppercased() } + try await conn.install(customFunction: fn) + + await XCTAssertNilAsync(try await conn.query("SELECT f(NULL) as result").first?.column("result")?.string) + await XCTAssertEqualAsync("ROUé", try await conn.query("SELECT upper(?) as result", [.text("Roué")]).first?.column("result")?.string) + await XCTAssertEqualAsync("ROUÉ", try await conn.query("SELECT f(?) as result", [.text("Roué")]).first?.column("result")?.string) + await XCTAssertThrowsErrorAsync(try await conn.query("SELECT f()")) { + guard let error = $0 as? SQLiteError else { return XCTFail("Expected SQLiteError, got \(String(reflecting: $0))") } + XCTAssertEqual(error.reason, .error) + XCTAssertEqual(error.message, "wrong number of arguments to function f()") + } + } + } - do { - _ = try conn.query("SELECT f()").wait() - } catch let error as SQLiteError { - XCTAssertEqual(error.reason, .error) - XCTAssertEqual(error.message, "wrong number of arguments to function f()") - } + func testFunctionOfTwoArguments() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f", argumentCount: 2) { values in values.compactMap { $0.integer }.reduce(0, +) } + try await conn.install(customFunction: fn) + + await XCTAssertEqualAsync(3, try await conn.query("SELECT f(1, 2) as result").first?.column("result")?.integer) + await XCTAssertThrowsErrorAsync(try await conn.query("SELECT f()")) { + guard let error = $0 as? SQLiteError else { return XCTFail("Expected SQLiteError, got \(String(reflecting: $0))") } + XCTAssertEqual(error.reason, .error) + XCTAssertEqual(error.message, "wrong number of arguments to function f()") + } + } } - func testVariadicFunction() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } + func testVariadicFunction() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f") { values in values.count } + try await conn.install(customFunction: fn) - let fn = SQLiteCustomFunction("f") { (values: [SQLiteData]) in - values.count - } - try conn.install(customFunction: fn).wait() - - XCTAssertEqual(0, try conn.query("SELECT f() as result") - .map { rows in rows[0].column("result")?.integer }.wait()) - XCTAssertEqual(1, try conn.query("SELECT f(1) as result") - .map { rows in rows[0].column("result")?.integer }.wait()) - XCTAssertEqual(2, try conn.query("SELECT f(1, 2) as result") - .map { rows in rows[0].column("result")?.integer }.wait()) - XCTAssertEqual(3, try conn.query("SELECT f(1, 1, 1) as result") - .map { rows in rows[0].column("result")?.integer }.wait()) + await XCTAssertEqualAsync(0, try await conn.query("SELECT f() as result").first?.column("result")?.integer) + await XCTAssertEqualAsync(1, try await conn.query("SELECT f(1) as result").first?.column("result")?.integer) + await XCTAssertEqualAsync(2, try await conn.query("SELECT f(1, 2) as result").first?.column("result")?.integer) + await XCTAssertEqualAsync(3, try await conn.query("SELECT f(1, 1, 1) as result").first?.column("result")?.integer) + } } // MARK: - Errors - func testFunctionThrowingDatabaseCustomErrorWithMessage() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - struct MyError: Error { - let message: String - } - - let fn = SQLiteCustomFunction("f") { _ in - throw MyError(message: "custom message") - } - - try conn.install(customFunction: fn).wait() - - do { - _ = try conn.query("SELECT f()").wait() - XCTFail("Expected Error") - } catch let error as MyError { - XCTFail("expected this not to match") - XCTAssertEqual(error.message, "custom message") - } catch let error as SQLiteError { - - XCTAssertEqual(error.reason, .error) - XCTAssertEqual(error.message, "MyError(message: \"custom message\")") - } + func testFunctionThrowingDatabaseCustomErrorWithMessage() async throws { + try await withOpenedConnection { conn in + struct MyError: Error { let message: String } + let fn = SQLiteCustomFunction("f") { _ in throw MyError(message: "custom message") } + try await conn.install(customFunction: fn) + + await XCTAssertThrowsErrorAsync(try await conn.query("SELECT f()")) { + guard let error = $0 as? SQLiteError else { return XCTFail("Expected SQLiteError, got \(String(reflecting: $0))") } + XCTAssertEqual(error.reason, .error) + XCTAssertEqual(error.message, "MyError(message: \"custom message\")") + } + } } - func testFunctionThrowingNSError() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - let fn = SQLiteCustomFunction("f") { _ in - throw NSError(domain: "CustomErrorDomain", code: 123, userInfo: [NSLocalizedDescriptionKey: "custom error message", NSLocalizedFailureReasonErrorKey: "custom error message"]) - } - - try conn.install(customFunction: fn).wait() - - do { - _ = try conn.query("SELECT f()").wait() - XCTFail("Expected Error") - } catch let error as SQLiteError { - XCTAssertEqual(error.reason, .error) - XCTAssertTrue(error.message.contains("CustomErrorDomain")) - XCTAssertTrue(error.message.contains("123")) - XCTAssertTrue(error.message.contains("custom error message"), "expected '\(error.message)' to contain 'custom error message'") - } + func testFunctionThrowingNSError() async throws { + try await withOpenedConnection { conn in + let fn = SQLiteCustomFunction("f") { _ in + throw NSError(domain: "CustomErrorDomain", code: 123, userInfo: [NSLocalizedDescriptionKey: "custom error message", NSLocalizedFailureReasonErrorKey: "custom error message"]) + } + try await conn.install(customFunction: fn) + + await XCTAssertThrowsErrorAsync(try await conn.query("SELECT f()")) { + guard let error = $0 as? SQLiteError else { return XCTFail("Expected SQLiteError, got \(String(reflecting: $0))") } + XCTAssertEqual(error.reason, .error) + XCTAssertTrue(error.message.contains("CustomErrorDomain")) + XCTAssertTrue(error.message.contains("123")) + XCTAssertTrue(error.message.contains("custom error message"), "expected '\(error.message)' to contain 'custom error message'") + } + } } // MARK: - Misc - func testFunctionsAreClosures() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - final class QuickBox: @unchecked Sendable { - var value: T - init(_ value: T) { self.value = value } + func testFunctionsCanBeExtremelyUnsafeClosures() async throws { + try await withOpenedConnection { conn in + final class QuickBox: @unchecked Sendable { var value: T; init(_ value: T) { self.value = value } } + let x = QuickBox(123) + let fn = SQLiteCustomFunction("f", argumentCount: 0) { values in x.value } + try await conn.install(customFunction: fn) + + x.value = 321 + await XCTAssertEqualAsync(321, try await conn.query("SELECT f() as result").first?.column("result")?.integer) } - let x = QuickBox(123) - let fn = SQLiteCustomFunction("f", argumentCount: 0) { dbValues in - x.value - } - try conn.install(customFunction: fn).wait() - x.value = 321 - XCTAssertEqual(321, try conn.query("SELECT f() as result").map({ rows in rows[0].column("result")?.integer }).wait()) } // MARK: - setup - var eventLoop: any EventLoop { MultiThreadedEventLoopGroup.singleton.any() } - - override func setUpWithError() throws { + override class func setUp() { XCTAssert(isLoggingConfigured) } } diff --git a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift index 2b0fa9d..bfe2162 100644 --- a/Tests/SQLiteNIOTests/SQLiteNIOTests.swift +++ b/Tests/SQLiteNIOTests/SQLiteNIOTests.swift @@ -3,76 +3,92 @@ import SQLiteNIO import Logging import NIOCore import NIOPosix +import NIOFoundationCompat + +/// Run the provided closure with an opened ``SQLiteConnection`` using an in-memory database and the singleton thread +/// pool and event loop, guaranteeing that the connection is correctly cleaned up afterwards regardless of errors. +func withOpenedConnection( + _ closure: @escaping @Sendable (SQLiteConnection) async throws -> T +) async throws -> T { + let connection = try await SQLiteConnection.open(storage: .memory) + + do { + let result = try await closure(connection) + try await connection.close() + + return result + } catch { + try? await connection.close() + throw error + } + +} final class SQLiteNIOTests: XCTestCase { - func testBasicConnection() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } + func testBasicConnection() async throws { + try await withOpenedConnection { conn in + let rows = try await conn.query("SELECT sqlite_version()") - let rows = try conn.query("SELECT sqlite_version()").wait() - XCTAssertEqual(rows.count, 1) - XCTAssertNoThrow(try conn.query("PRAGMA compile_options").wait()) + XCTAssertEqual(rows.count, 1) + await XCTAssertNoThrowAsync(try await conn.query("PRAGMA compile_options")) + } } - func testConnectionClosedThreadPool() throws { + func testConnectionClosedThreadPool() async throws { let threadPool = NIOThreadPool(numberOfThreads: 1) - try threadPool.syncShutdownGracefully() + try await threadPool.shutdownGracefully() + // This should error, but not create a leaking promise fatal error - XCTAssertThrowsError(try SQLiteConnection.open(storage: .memory, threadPool: threadPool, on: self.eventLoop).wait()) + await XCTAssertThrowsErrorAsync(try await SQLiteConnection.open(storage: .memory, threadPool: threadPool, on: MultiThreadedEventLoopGroup.singleton.any())) } - func testZeroLengthBlob() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - let rows = try conn.query("SELECT zeroblob(0) as zblob").wait() + func testZeroLengthBlob() async throws { + try await withOpenedConnection { conn in + let rows = try await conn.query("SELECT zeroblob(0) as zblob") - XCTAssertEqual(rows.count, 1) + XCTAssertEqual(rows.count, 1) + } } - func testDateFormat() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - XCTAssertEqual(Date(sqliteData: .text("2023-03-10"))?.timeIntervalSince1970, 1678406400) - - let rows = try conn.query("SELECT CURRENT_DATE").wait() - XCTAssertNotNil(Date(sqliteData: rows[0].column("CURRENT_DATE")!)) + func testDateFormat() async throws { + try await withOpenedConnection { conn in + XCTAssertEqual(Date(sqliteData: .text("2023-03-10"))?.timeIntervalSince1970, 1678406400) + + let rows = try await conn.query("SELECT CURRENT_DATE") + XCTAssertNotNil(rows.first?.column("CURRENT_DATE").flatMap(Date.init(sqliteData:))) + } } - func testDateTimeFormat() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - XCTAssertEqual(Date(sqliteData: .text("2023-03-10 23:54:27"))?.timeIntervalSince1970, 1678492467) - - let rows = try conn.query("SELECT CURRENT_TIMESTAMP").wait() - XCTAssertNotNil(Date(sqliteData: rows[0].column("CURRENT_TIMESTAMP")!)) + func testDateTimeFormat() async throws { + try await withOpenedConnection { conn in + XCTAssertEqual(Date(sqliteData: .text("2023-03-10 23:54:27"))?.timeIntervalSince1970, 1678492467) + + let rows = try await conn.query("SELECT CURRENT_TIMESTAMP") + XCTAssertNotNil(rows.first?.column("CURRENT_TIMESTAMP").flatMap(Date.init(sqliteData:))) + } } - func testTimestampStorage() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - let date = Date() - let rows = try conn.query("SELECT ? as date", [date.sqliteData!]).wait() - XCTAssertEqual(rows[0].column("date"), .float(date.timeIntervalSince1970)) - XCTAssertEqual(Date(sqliteData: rows[0].column("date")!)?.description, date.description) - XCTAssertEqual(Date(sqliteData: rows[0].column("date")!), date) - XCTAssertEqual(Date(sqliteData: rows[0].column("date")!)?.timeIntervalSinceReferenceDate, date.timeIntervalSinceReferenceDate) + func testTimestampStorage() async throws { + try await withOpenedConnection { conn in + let date = Date() + let rows = try await conn.query("SELECT ? as date", [date.sqliteData!]) + XCTAssertEqual(rows.first?.column("date"), .float(date.timeIntervalSince1970)) + XCTAssertEqual(rows.first?.column("date").flatMap(Date.init(sqliteData:))?.description, date.description) + XCTAssertEqual(rows.first?.column("date").flatMap(Date.init(sqliteData:)), date) + XCTAssertEqual(rows.first?.column("date").flatMap(Date.init(sqliteData:))?.timeIntervalSinceReferenceDate, date.timeIntervalSinceReferenceDate) + } } - func testTimestampStorageRoundToMicroseconds() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - // Test value that when read back out of sqlite results in 7 decimal places that we need to round to microseconds - let date = Date(timeIntervalSinceReferenceDate: 689658914.293192) - let rows = try conn.query("SELECT ? as date", [date.sqliteData!]).wait() - XCTAssertEqual(rows[0].column("date"), .float(date.timeIntervalSince1970)) - XCTAssertEqual(Date(sqliteData: rows[0].column("date")!)?.description, date.description) - XCTAssertEqual(Date(sqliteData: rows[0].column("date")!), date) - XCTAssertEqual(Date(sqliteData: rows[0].column("date")!)?.timeIntervalSinceReferenceDate, date.timeIntervalSinceReferenceDate) + func testTimestampStorageRoundToMicroseconds() async throws { + try await withOpenedConnection { conn in + // Test value that when read back out of sqlite results in 7 decimal places that we need to round to microseconds + let date = Date(timeIntervalSinceReferenceDate: 689658914.293192) + let rows = try await conn.query("SELECT ? as date", [date.sqliteData!]) + XCTAssertEqual(rows.first?.column("date"), .float(date.timeIntervalSince1970)) + XCTAssertEqual(rows.first?.column("date").flatMap(Date.init(sqliteData:))?.description, date.description) + XCTAssertEqual(rows.first?.column("date").flatMap(Date.init(sqliteData:)), date) + XCTAssertEqual(rows.first?.column("date").flatMap(Date.init(sqliteData:))?.timeIntervalSinceReferenceDate, date.timeIntervalSinceReferenceDate) + } } func testDateRoundToMicroseconds() throws { @@ -85,108 +101,113 @@ final class SQLiteNIOTests: XCTestCase { XCTAssertEqual(date.sqliteData, .float(secondsSinceUnixEpoch)) } - func testTimestampStorageInDateColumnIntegralValue() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - let date = Date(timeIntervalSince1970: 42) - // This is how a column of type .date is crated when using Vapor’s - // scheme table creation. - _ = try conn.query(#"CREATE TABLE "test" ("date" DATE NOT NULL);"#).wait() - _ = try conn.query(#"INSERT INTO test (date) VALUES (?);"#, [date.sqliteData!]).wait() - let rows = try conn.query("SELECT * FROM test;").wait() - XCTAssertTrue(rows[0].column("date") == .float(date.timeIntervalSince1970) || rows[0].column("date") == .integer(Int(date.timeIntervalSince1970))) - XCTAssertEqual(Date(sqliteData: rows[0].column("date")!)?.description, date.description) + func testTimestampStorageInDateColumnIntegralValue() async throws { + try await withOpenedConnection { conn in + let date = Date(timeIntervalSince1970: 42) + // This is how a column of type .date is crated when using Vapor’s + // scheme table creation. + _ = try await conn.query(#"CREATE TABLE "test" ("date" DATE NOT NULL)"#) + _ = try await conn.query(#"INSERT INTO test (date) VALUES (?)"#, [date.sqliteData!]) + let rows = try await conn.query("SELECT * FROM test") + + XCTAssertTrue(rows.first?.column("date") == .float(date.timeIntervalSince1970) || rows.first?.column("date") == .integer(Int(date.timeIntervalSince1970))) + XCTAssertEqual(rows.first?.column("date").flatMap(Date.init(sqliteData:))?.description, date.description) + } } - func testDuplicateColumnName() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - let rows = try conn.query("SELECT 1 as foo, 2 as foo").wait() - var i = 0 - for column in rows[0].columns { - XCTAssertEqual(column.name, "foo") - i += column.data.integer! + func testDuplicateColumnName() async throws { + try await withOpenedConnection { conn in + let rows = try await conn.query("SELECT 1 as foo, 2 as foo") + let row0 = try XCTUnwrap(rows.first) + var i = 0 + for column in row0.columns { + XCTAssertEqual(column.name, "foo") + i += column.data.integer ?? 0 + } + XCTAssertEqual(i, 3) + XCTAssertEqual(row0.column("foo")?.integer, 1) + XCTAssertEqual(row0.columns.filter { $0.name == "foo" }.dropFirst(0).first?.data.integer, 1) + XCTAssertEqual(row0.columns.filter { $0.name == "foo" }.dropFirst(1).first?.data.integer, 2) } - XCTAssertEqual(i, 3) - XCTAssertEqual(rows[0].column("foo")?.integer, 1) - XCTAssertEqual(rows[0].columns.filter { $0.name == "foo" }[0].data.integer, 1) - XCTAssertEqual(rows[0].columns.filter { $0.name == "foo" }[1].data.integer, 2) } - func testCustomAggregate() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } + func testCustomAggregate() async throws { + try await withOpenedConnection { conn in + _ = try await conn.query(#"CREATE TABLE "scores" ("score" INTEGER NOT NULL)"#) + _ = try await conn.query(#"INSERT INTO scores (score) VALUES (?), (?), (?)"#, [.integer(3), .integer(4), .integer(5)]) - _ = try conn.query(#"CREATE TABLE "scores" ("score" INTEGER NOT NULL);"#).wait() - _ = try conn.query(#"INSERT INTO scores (score) VALUES (?), (?), (?);"#, [.integer(3), .integer(4), .integer(5)]).wait() + struct MyAggregate: SQLiteCustomAggregate { + var sum: Int = 0 + mutating func step(_ values: [SQLiteData]) throws { + self.sum += (values.first?.integer ?? 0) + } - struct MyAggregate: SQLiteCustomAggregate { - var sum: Int = 0 - mutating func step(_ values: [SQLiteData]) throws { - sum = sum + (values.first?.integer ?? 0) - } + func finalize() throws -> (any SQLiteDataConvertible)? { + self.sum + } + } - func finalize() throws -> (any SQLiteDataConvertible)? { - sum - } - } + let function = SQLiteCustomFunction("my_sum", argumentCount: 1, pure: true, aggregate: MyAggregate.self) + try await conn.install(customFunction: function) - let function = SQLiteCustomFunction("my_sum", argumentCount: 1, pure: true, aggregate: MyAggregate.self) - _ = try conn.install(customFunction: function).wait() - - let rows = try conn.query("SELECT my_sum(score) as total_score FROM scores").wait() - XCTAssertEqual(rows.first?.column("total_score")?.integer, 12) + let rows = try await conn.query("SELECT my_sum(score) as total_score FROM scores") + XCTAssertEqual(rows.first?.column("total_score")?.integer, 12) + } } - func testDatabaseFunction() throws { - let conn = try SQLiteConnection.open(storage: .memory, threadPool: .singleton, on: self.eventLoop).wait() - defer { try! conn.close().wait() } - - let function = SQLiteCustomFunction("my_custom_function", argumentCount: 1, pure: true) { args in - return Int(args[0].integer! * 3) - } + func testDatabaseFunction() async throws { + try await withOpenedConnection { conn in + let function = SQLiteCustomFunction("my_custom_function", argumentCount: 1, pure: true) { args in + Int(args[0].integer! * 3) + } - _ = try conn.install(customFunction: function).wait() - let rows = try conn.query("SELECT my_custom_function(2) as my_value").wait() - XCTAssertEqual(rows.first?.column("my_value")?.integer, 6) + _ = try await conn.install(customFunction: function) + let rows = try await conn.query("SELECT my_custom_function(2) as my_value") + XCTAssertEqual(rows.first?.column("my_value")?.integer, 6) + } } func testSingletonEventLoopOpen() async throws { - var conn: SQLiteConnection! = nil + var conn: SQLiteConnection? = nil await XCTAssertNoThrowAsync(conn = try await SQLiteConnection.open(storage: .memory).get()) - try await conn.close().get() + try await conn?.close().get() + } + + func testSerializedConnectionAccess() async throws { + /// Although this test has no assertions, it does serve a useful purpose: when run with Thread Sanitizer + /// enabed, it validates that we are using SQLite in "serialized" mode (e.g. it is safe to use a single + /// connection simultaneously from multiple threads) rather than single- or multi-threaded mode. + try await withOpenedConnection { conn in + let t1 = Task { + for _ in 0 ..< 100 { + _ = try await conn.query("SELECT random()", [], { _ in }) + } + } + let t2 = Task { + for _ in 0 ..< 100 { + _ = try await conn.query("SELECT random()", [], { _ in }) + } + } + + try await t1.value + try await t2.value + } } - var eventLoop: any EventLoop { MultiThreadedEventLoopGroup.singleton.any() } - - override func setUpWithError() throws { + override class func setUp() { XCTAssert(isLoggingConfigured) } } +func env(_ name: String) -> String? { + ProcessInfo.processInfo.environment[name] +} + let isLoggingConfigured: Bool = { LoggingSystem.bootstrap { label in var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .info + handler.logLevel = env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .info return handler } return true }() - -func env(_ name: String) -> String? { - ProcessInfo.processInfo.environment[name] -} - -func XCTAssertNoThrowAsync( - _ expression: @autoclosure () async throws -> T, - _ message: @autoclosure () -> String = "", - file: StaticString = #filePath, line: UInt = #line -) async { - do { - _ = try await expression() - } catch { - XCTAssertNoThrow(try { throw error }(), message(), file: file, line: line) - } -} diff --git a/Tests/SQLiteNIOTests/XCTAsyncAssertions.swift b/Tests/SQLiteNIOTests/XCTAsyncAssertions.swift new file mode 100644 index 0000000..1f8b6e1 --- /dev/null +++ b/Tests/SQLiteNIOTests/XCTAsyncAssertions.swift @@ -0,0 +1,266 @@ +import XCTest + +// MARK: - Unwrap + +func XCTUnwrapAsync( + _ expression: @autoclosure () async throws -> T?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async throws -> T { + let result: T? + + do { + result = try await expression() + } catch { + return try XCTUnwrap(try { throw error }(), message(), file: file, line: line) + } + return try XCTUnwrap(result, message(), file: file, line: line) +} + +// MARK: - Equality + +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Equatable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertNotEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Equatable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertNotEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertNotEqual(try { () -> Bool in throw error }(), true, message(), file: file, line: line) + } +} + +// MARK: - Fuzzy equality + +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Numeric { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertNotEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Numeric { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertNotEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertNotEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: FloatingPoint { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +func XCTAssertNotEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + accuracy: T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: FloatingPoint { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertNotEqual(expr1, expr2, accuracy: accuracy, message(), file: file, line: line) + } catch { + return XCTAssertNotEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + +// MARK: - Comparability + +func XCTAssertGreaterThanAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertGreaterThan(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertGreaterThan(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + +func XCTAssertGreaterThanOrEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertGreaterThanOrEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertGreaterThanOrEqual(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + + +func XCTAssertLessThanAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertLessThan(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertLessThan(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + +func XCTAssertLessThanOrEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async where T: Comparable { + do { + let expr1 = try await expression1(), expr2 = try await expression2() + return XCTAssertLessThanOrEqual(expr1, expr2, message(), file: file, line: line) + } catch { + return XCTAssertLessThanOrEqual(try { () -> Int in throw error }(), 0, message(), file: file, line: line) + } +} + +// MARK: - Truthiness + +func XCTAssertAsync( + _ predicate: @autoclosure () async throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await predicate() + XCTAssert(result, message(), file: file, line: line) + } catch { + return XCTAssert(try { throw error }(), message(), file: file, line: line) + } +} + +func XCTAssertTrueAsync( + _ predicate: @autoclosure () async throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await predicate() + XCTAssertTrue(result, message(), file: file, line: line) + } catch { + return XCTAssertTrue(try { throw error }(), message(), file: file, line: line) + } +} + +func XCTAssertFalseAsync( + _ predicate: @autoclosure () async throws -> Bool, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await predicate() + XCTAssertFalse(result, message(), file: file, line: line) + } catch { + return XCTAssertFalse(try { throw error }(), message(), file: file, line: line) + } +} + +// MARK: - Existence + +func XCTAssertNilAsync( + _ expression: @autoclosure () async throws -> Any?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await expression() + return XCTAssertNil(result, message(), file: file, line: line) + } catch { + return XCTAssertNil(try { throw error }(), message(), file: file, line: line) + } +} + +func XCTAssertNotNilAsync( + _ expression: @autoclosure () async throws -> Any?, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + let result = try await expression() + XCTAssertNotNil(result, message(), file: file, line: line) + } catch { + return XCTAssertNotNil(try { throw error }(), message(), file: file, line: line) + } +} + +// MARK: - Exceptionality + +func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line, + _ callback: (any Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + XCTAssertThrowsError({}(), message(), file: file, line: line, callback) + } catch { + XCTAssertThrowsError(try { throw error }(), message(), file: file, line: line, callback) + } +} + +func XCTAssertNoThrowAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line +) async { + do { + _ = try await expression() + } catch { + XCTAssertNoThrow(try { throw error }(), message(), file: file, line: line) + } +}