From e9fd69b54b04acf9ea94d3ba174195d20a07370f Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Thu, 18 Jan 2024 06:21:16 -0600 Subject: [PATCH] Handle JSON better (#107) * General package cleanup * Send JSON to SQLite as TEXT, not BLOB. This restores compatibility with SQLite 3.45.0 and above, due to the new JSONB support changing the interpretation of JSON data in BLOB form (and is what should've been happening all along regardless). * Some tests cleanup * Add AnyExistential correctness. * Quick and dirty fix for yet another of Tanner's disastrous misuses of Codable. --- Package.swift | 6 +- Package@swift-5.9.swift | 46 +++++++ README.md | 34 ++++-- .../SQLiteKit/Docs.docc/images/article.svg | 1 - .../Docs.docc/images/vapor-sqlite-logo.svg | 58 --------- .../Docs.docc/images/vapor-sqlitekit-logo.svg | 22 ++++ .../SQLiteKit/Docs.docc/theme-settings.json | 61 +++------- Sources/SQLiteKit/SQLiteConfiguration.swift | 4 +- .../SQLiteKit/SQLiteConnection+SQLKit.swift | 15 +-- .../SQLiteKit/SQLiteConnectionSource.swift | 2 +- Sources/SQLiteKit/SQLiteDataDecoder.swift | 77 +++++------- Sources/SQLiteKit/SQLiteDataEncoder.swift | 96 +++++++-------- Tests/SQLiteKitTests/SQLiteKitTests.swift | 114 +++++++++++------- 13 files changed, 271 insertions(+), 265 deletions(-) create mode 100644 Package@swift-5.9.swift delete mode 100644 Sources/SQLiteKit/Docs.docc/images/article.svg delete mode 100644 Sources/SQLiteKit/Docs.docc/images/vapor-sqlite-logo.svg create mode 100644 Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg diff --git a/Package.swift b/Package.swift index d1ccfb3..7ef451d 100644 --- a/Package.swift +++ b/Package.swift @@ -13,12 +13,14 @@ let package = Package( .library(name: "SQLiteKit", targets: ["SQLiteKit"]), ], dependencies: [ - .package(url: "https://github.com/vapor/sqlite-nio.git", from: "1.6.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.62.0"), + .package(url: "https://github.com/vapor/sqlite-nio.git", from: "1.8.4"), .package(url: "https://github.com/vapor/sql-kit.git", from: "3.28.0"), - .package(url: "https://github.com/vapor/async-kit.git", from: "1.14.0"), + .package(url: "https://github.com/vapor/async-kit.git", from: "1.19.0"), ], targets: [ .target(name: "SQLiteKit", dependencies: [ + .product(name: "NIOFoundationCompat", package: "swift-nio"), .product(name: "AsyncKit", package: "async-kit"), .product(name: "SQLiteNIO", package: "sqlite-nio"), .product(name: "SQLKit", package: "sql-kit"), diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift new file mode 100644 index 0000000..43bd242 --- /dev/null +++ b/Package@swift-5.9.swift @@ -0,0 +1,46 @@ +// swift-tools-version:5.9 +import PackageDescription + +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("ExistentialAny"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] + +let package = Package( + name: "sqlite-kit", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + ], + products: [ + .library(name: "SQLiteKit", targets: ["SQLiteKit"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "2.62.0"), + .package(url: "https://github.com/vapor/sqlite-nio.git", from: "1.8.4"), + .package(url: "https://github.com/vapor/sql-kit.git", from: "3.28.0"), + .package(url: "https://github.com/vapor/async-kit.git", from: "1.19.0"), + ], + targets: [ + .target( + name: "SQLiteKit", + dependencies: [ + .product(name: "NIOFoundationCompat", package: "swift-nio"), + .product(name: "AsyncKit", package: "async-kit"), + .product(name: "SQLiteNIO", package: "sqlite-nio"), + .product(name: "SQLKit", package: "sql-kit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "SQLiteKitTests", + dependencies: [ + .product(name: "SQLKitBenchmark", package: "sql-kit"), + .target(name: "SQLiteKit"), + ], + swiftSettings: swiftSettings + ), + ] +) diff --git a/README.md b/README.md index dca42c2..9102bb7 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,28 @@

- - - - SQLiteKit - + + + + SQLiteKit +

-SSWG Incubation -Documentation -MIT License -Continuous Integration -Swift 5.7 +Documentation +Team Chat +MIT License +Continuous Integration + +Swift 5.7+ +SSWG Incubation Level: Graduated

+ +
+ +SQLiteKit is a library providing an [SQLKit] driver for [SQLiteNIO]. + +> [!NOTE] +> The [FluentKit] driver for SQLite is provided by the [FluentSQLiteDriver] package. + +[SQLKit]: https://swiftpackageindex.com/vapor/sql-kit +[SQLiteNIO]: https://swiftpackageindex.com/vapor/sqlite-nio +[Fluent]: https://swiftpackageindex.com/vapor/fluent-kit +[FluentSQLiteDriver]: https://swiftpackageindex.com/vapor/fluent-sqlite-driver diff --git a/Sources/SQLiteKit/Docs.docc/images/article.svg b/Sources/SQLiteKit/Docs.docc/images/article.svg deleted file mode 100644 index 3dc6a66..0000000 --- a/Sources/SQLiteKit/Docs.docc/images/article.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Sources/SQLiteKit/Docs.docc/images/vapor-sqlite-logo.svg b/Sources/SQLiteKit/Docs.docc/images/vapor-sqlite-logo.svg deleted file mode 100644 index e87cade..0000000 --- a/Sources/SQLiteKit/Docs.docc/images/vapor-sqlite-logo.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - SQLiteNIO - - - - - - - - - - - - - - - - - - diff --git a/Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg b/Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg new file mode 100644 index 0000000..a3d3287 --- /dev/null +++ b/Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/SQLiteKit/Docs.docc/theme-settings.json b/Sources/SQLiteKit/Docs.docc/theme-settings.json index 437875c..806ccb9 100644 --- a/Sources/SQLiteKit/Docs.docc/theme-settings.json +++ b/Sources/SQLiteKit/Docs.docc/theme-settings.json @@ -1,46 +1,21 @@ { - "theme": { - "aside": { - "border-radius": "6px", - "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" - }, - "color": { - "fill": { - "dark": "rgb(0, 0, 0)", - "light": "rgb(255, 255, 255)" - }, - "sqlite-teal": "hsl(215, 45%, 58%)", - "documentation-intro-fill": "radial-gradient(circle at top, var(--color-documentation-intro-accent) 30%, #000 100%)", - "documentation-intro-accent": "var(--color-sqlite-teal)", - "documentation-intro-accent-outer": { - "dark": "rgb(255, 255, 255)", - "light": "rgb(0, 0, 0)" - }, - "documentation-intro-accent-inner": { - "dark": "rgb(0, 0, 0)", - "light": "rgb(255, 255, 255)" - } - }, - "icons": { - "technology": "/sqlitekit/images/vapor-sqlite-logo.svg", - "article": "/sqlitekit/images/article.svg" - } + "theme": { + "aside": { "border-radius": "6px", "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" }, + "color": { + "sqlite": "hsl(215, 45%, 58%)", + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-sqlite) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-sqlite)", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } }, - "features": { - "quickNavigation": { - "enable": true - } - } + "icons": { "technology": "/sqlitekit/images/vapor-sqlitekit-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } } diff --git a/Sources/SQLiteKit/SQLiteConfiguration.swift b/Sources/SQLiteKit/SQLiteConfiguration.swift index 0a11cf8..9177522 100644 --- a/Sources/SQLiteKit/SQLiteConfiguration.swift +++ b/Sources/SQLiteKit/SQLiteConfiguration.swift @@ -1,7 +1,7 @@ import struct Foundation.UUID -public struct SQLiteConfiguration { - public enum Storage { +public struct SQLiteConfiguration: Sendable { + public enum Storage: Sendable { /// Stores the SQLite database in memory. /// /// Uses a randomly generated identifier. See `memory(identifier:)`. diff --git a/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift b/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift index 3c2cd85..bde2384 100644 --- a/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift +++ b/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift @@ -82,7 +82,7 @@ internal struct _SQLiteDatabaseVersion: SQLDatabaseReportedVersion { } private struct _SQLiteSQLDatabase: SQLDatabase { - let database: SQLiteDatabase + let database: any SQLiteDatabase var eventLoop: any EventLoop { self.database.eventLoop @@ -109,17 +109,18 @@ private struct _SQLiteSQLDatabase: SQLDatabase { let binds: [SQLiteData] do { binds = try serializer.binds.map { encodable in - return try SQLiteDataEncoder().encode(encodable) + try SQLiteDataEncoder().encode(encodable) } } catch { return self.eventLoop.makeFailedFuture(error) } - return self.database.query( - serializer.sql, - binds, - logger: self.logger - ) { row in + + // This temporary silliness silences a Sendable capture warning whose correct resolution + // requires updating SQLKit itself to be fully Sendable-compliant. + @Sendable func onRowWorkaround(_ row: any SQLRow) { onRow(row) } + return self.database.query(serializer.sql, binds, logger: self.logger, onRowWorkaround) } } + diff --git a/Sources/SQLiteKit/SQLiteConnectionSource.swift b/Sources/SQLiteKit/SQLiteConnectionSource.swift index 5283528..8768b31 100644 --- a/Sources/SQLiteKit/SQLiteConnectionSource.swift +++ b/Sources/SQLiteKit/SQLiteConnectionSource.swift @@ -5,7 +5,7 @@ import NIOPosix import SQLiteNIO import NIOCore -public struct SQLiteConnectionSource: ConnectionPoolSource { +public struct SQLiteConnectionSource: ConnectionPoolSource, Sendable { private let configuration: SQLiteConfiguration private let actualURL: URL private let threadPool: NIOThreadPool diff --git a/Sources/SQLiteKit/SQLiteDataDecoder.swift b/Sources/SQLiteKit/SQLiteDataDecoder.swift index 51209ff..71cecde 100644 --- a/Sources/SQLiteKit/SQLiteDataDecoder.swift +++ b/Sources/SQLiteKit/SQLiteDataDecoder.swift @@ -1,10 +1,14 @@ import Foundation import SQLiteNIO +import NIOFoundationCompat public struct SQLiteDataDecoder { + let json = JSONDecoder() // TODO: Add API to make this configurable + public init() {} public func decode(_ type: T.Type, from data: SQLiteData) throws -> T { + // If `T` can be converted directly, just do so. if let type = type as? any SQLiteDataConvertible.Type { guard let value = type.init(sqliteData: data) else { throw DecodingError.typeMismatch(T.self, .init( @@ -14,54 +18,37 @@ public struct SQLiteDataDecoder { } return value as! T } else { - return try T.init(from: _Decoder(data: data)) - } - } - - private final class _Decoder: Decoder { - var codingPath: [any CodingKey] = [] - var userInfo: [CodingUserInfoKey: Any] = [:] - - let data: SQLiteData - init(data: SQLiteData) { self.data = data } - - func unkeyedContainer() throws -> any UnkeyedDecodingContainer { - try self.jsonDecoder().unkeyedContainer() - } - - func container(keyedBy: Key.Type) throws -> KeyedDecodingContainer { - try self.jsonDecoder().container(keyedBy: Key.self) - } - - func jsonDecoder() throws -> any Decoder { - let data: Data - switch self.data { - case .blob(let buffer): data = Data(buffer.readableBytesView) - case .text(let string): data = Data(string.utf8) - default: data = .init() + do { + return try T.init(from: GiftBoxUnwrapDecoder(decoder: self, data: data)) + } catch is TryJSONSentinel { + // Couldn't unwrap it either. Fall back to attempting a JSON decode. + let buf: Data + switch data { + case .text(let str): buf = .init(str.utf8) + case .blob(let blob): buf = .init(buffer: blob, byteTransferStrategy: .noCopy) + // The remaining cases should never happen, but we implement them anyway just in case. + case .integer(let n): buf = .init(String(n).utf8) + case .float(let n): buf = .init(String(n).utf8) + case .null: buf = .init() + } + return try self.json.decode(T.self, from: buf) } - return try JSONDecoder().decode(DecoderUnwrapper.self, from: data).decoder } - - func singleValueContainer() throws -> any SingleValueDecodingContainer { _SingleValueDecoder(self) } } + + private struct TryJSONSentinel: Swift.Error {} - private struct _SingleValueDecoder: SingleValueDecodingContainer { - var codingPath: [any CodingKey] { self.decoder.codingPath } - let decoder: _Decoder - init(_ decoder: _Decoder) { self.decoder = decoder } - - func decodeNil() -> Bool { self.decoder.data == .null } - - func decode(_: T.Type) throws -> T { - try SQLiteDataDecoder().decode(T.self, from: self.decoder.data) - } - } -} - -private struct DecoderUnwrapper: Decodable { - let decoder: Decoder - init(from decoder: Decoder) { - self.decoder = decoder + private struct GiftBoxUnwrapDecoder: Decoder, SingleValueDecodingContainer { + let decoder: SQLiteDataDecoder + let data: SQLiteData + + var codingPath: [any CodingKey] { [] } + var userInfo: [CodingUserInfoKey: Any] { [:] } + + func container(keyedBy: K.Type) throws -> KeyedDecodingContainer { throw TryJSONSentinel() } + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { throw TryJSONSentinel() } + func singleValueContainer() throws -> any SingleValueDecodingContainer { self } + func decodeNil() -> Bool { self.data.isNull } + func decode(_: T.Type) throws -> T { try self.decoder.decode(T.self, from: self.data) } } } diff --git a/Sources/SQLiteKit/SQLiteDataEncoder.swift b/Sources/SQLiteKit/SQLiteDataEncoder.swift index bbf49b6..04ecfbb 100644 --- a/Sources/SQLiteKit/SQLiteDataEncoder.swift +++ b/Sources/SQLiteKit/SQLiteDataEncoder.swift @@ -9,16 +9,22 @@ public struct SQLiteDataEncoder { if let data = (value as? any SQLiteDataConvertible)?.sqliteData { return data } else { - let encoder = _Encoder() + let encoder = EncoderImpl() + try value.encode(to: encoder) switch encoder.result { case .data(let data): return data case .unkeyed, .keyed: - let json = try JSONEncoder().encode(AnyEncodable(value)) - var buffer = ByteBufferAllocator().buffer(capacity: json.count) - buffer.writeBytes(json) - return SQLiteData.blob(buffer) + // Starting with SQLite 3.45.0 (2024-01-15), sending textual JSON as a blob will cause inexplicable + // errors due to the data being interpreted as JSONB (arguably not the best behavior for SQLite's API, + // but not technically a compatibility break). As there is no good way to get at the underlying SQLite + // version from the data encoder, and extending `SQLiteData` would make a rather epic mess, we now just + // always send JSON as text instead. This is technically what we should have been doing all along + // anyway, meaning this change is a bugfix. Good thing, too - otherwise we'd be stuck trying to retain + // bug-for-bug compatibility, starting with reverse-engineering SQLite's JSONB format (which is not the + // same as PostgreSQL's, of course). + return .text(.init(decoding: try JSONEncoder().encode(value), as: UTF8.self)) } } } @@ -29,67 +35,49 @@ public struct SQLiteDataEncoder { case data(SQLiteData) } - private final class _Encoder: Encoder { - var codingPath: [any CodingKey] = [] - var userInfo: [CodingUserInfoKey: Any] = [:] + private final class EncoderImpl: Encoder, SingleValueEncodingContainer { + private struct KeyedEncoderImpl: KeyedEncodingContainerProtocol { + var codingPath: [any CodingKey] { [] } + mutating func encodeNil(forKey: K) throws {} + mutating func encode(_: some Encodable, forKey: K) throws {} + mutating func nestedContainer(keyedBy: N.Type, forKey: K) -> KeyedEncodingContainer { .init(KeyedEncoderImpl()) } + mutating func nestedUnkeyedContainer(forKey: K) -> any UnkeyedEncodingContainer { UnkeyedEncoderImpl() } + mutating func superEncoder() -> any Encoder { EncoderImpl() } + mutating func superEncoder(forKey: K) -> any Encoder { EncoderImpl() } + } + + private struct UnkeyedEncoderImpl: UnkeyedEncodingContainer { + var codingPath: [any CodingKey] { [] } + var count: Int = 0 + mutating func encodeNil() throws {} + mutating func encode(_: some Encodable) throws {} + mutating func nestedContainer(keyedBy: N.Type) -> KeyedEncodingContainer { .init(KeyedEncoderImpl()) } + mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { UnkeyedEncoderImpl() } + mutating func superEncoder() -> any Encoder { EncoderImpl() } + } + + var codingPath: [any CodingKey] { [] } + var userInfo: [CodingUserInfoKey: Any] { [:] } var result: Result init() { self.result = .data(.null) } - func container(keyedBy: Key.Type) -> KeyedEncodingContainer { + func container(keyedBy: K.Type) -> KeyedEncodingContainer { self.result = .keyed - return .init(_KeyedEncoder()) + return .init(KeyedEncoderImpl()) } func unkeyedContainer() -> any UnkeyedEncodingContainer { self.result = .unkeyed - return _UnkeyedEncoder() + return UnkeyedEncoderImpl() } - func singleValueContainer() -> any SingleValueEncodingContainer { _SingleValueEncoder(encoder: self) } - } - - private struct _KeyedEncoder: KeyedEncodingContainerProtocol { - var codingPath: [any CodingKey] = [] - mutating func encodeNil(forKey: Key) throws {} - mutating func encode(_ value: some Encodable, forKey: Key) throws {} - mutating func nestedContainer(keyedBy: Nested.Type, forKey: Key) -> KeyedEncodingContainer { - .init(_KeyedEncoder()) - } - mutating func nestedUnkeyedContainer(forKey: Key) -> any UnkeyedEncodingContainer { _UnkeyedEncoder() } - mutating func superEncoder() -> any Encoder { _Encoder() } - mutating func superEncoder(forKey: Key) -> any Encoder { _Encoder() } - } + func singleValueContainer() -> any SingleValueEncodingContainer { self } - private struct _UnkeyedEncoder: UnkeyedEncodingContainer { - var codingPath: [any CodingKey] = [] - var count: Int = 0 - mutating func encodeNil() throws {} - mutating func encode(_ value: some Encodable) throws {} - mutating func nestedContainer(keyedBy: Nested.Type) -> KeyedEncodingContainer { - .init(_KeyedEncoder()) - } - mutating func nestedUnkeyedContainer() -> any UnkeyedEncodingContainer { _UnkeyedEncoder() } - mutating func superEncoder() -> any Encoder { _Encoder() } - } - - private struct _SingleValueEncoder: SingleValueEncodingContainer { - var codingPath: [any CodingKey] { self.encoder.codingPath } - let encoder: _Encoder - mutating func encodeNil() throws { self.encoder.result = .data(.null) } - mutating func encode(_ value: some Encodable) throws { - let data = try SQLiteDataEncoder().encode(value) - self.encoder.result = .data(data) + func encodeNil() throws { self.result = .data(.null) } + + func encode(_ value: some Encodable) throws { + self.result = .data(try SQLiteDataEncoder().encode(value)) } } } - -private struct AnyEncodable: Encodable { - let encodable: Encodable - init(_ encodable: Encodable) { - self.encodable = encodable - } - func encode(to encoder: Encoder) throws { - try self.encodable.encode(to: encoder) - } -} diff --git a/Tests/SQLiteKitTests/SQLiteKitTests.swift b/Tests/SQLiteKitTests/SQLiteKitTests.swift index 600641a..c9cd241 100644 --- a/Tests/SQLiteKitTests/SQLiteKitTests.swift +++ b/Tests/SQLiteKitTests/SQLiteKitTests.swift @@ -8,6 +8,7 @@ import SQLKit final class SQLiteKitTests: XCTestCase { func testSQLKitBenchmark() throws { let benchmark = SQLBenchmarker(on: self.db) + try benchmark.run() } @@ -18,15 +19,16 @@ final class SQLiteKitTests: XCTestCase { try await self.db.drop(table: "galaxies") .ifExists() .run() + try await self.db.create(table: "galaxies") .column("id", type: .int, .primaryKey) .column("name", type: .text) .run() - try await self.db.create(table: "planets") - .ifNotExists() + try await self.db.create(table: "planets").ifNotExists() .column("id", type: .int, .primaryKey) .column("galaxyID", type: .int, .references("galaxies", "id")) .run() + try await self.db.alter(table: "planets") .column("name", type: .text, .default(SQLLiteral.string("Unamed Planet"))) .run() @@ -35,21 +37,22 @@ final class SQLiteKitTests: XCTestCase { .column("id") .unique() .run() + // INSERT INTO "galaxies" ("id", "name") VALUES (DEFAULT, $1) try await self.db.insert(into: "galaxies") .columns("id", "name") .values(SQLLiteral.null, SQLBind("Milky Way")) .values(SQLLiteral.null, SQLBind("Andromeda")) - // .value(Galaxy(name: "Milky Way")) .run() + // SELECT * FROM galaxies WHERE name != NULL AND (name == ? OR name == ?) _ = try await self.db.select() .column("*") .from("galaxies") .where("name", .notEqual, SQLLiteral.null) - .where { - $0.where("name", .equal, SQLBind("Milky Way")) - .orWhere("name", .equal, SQLBind("Andromeda")) + .where { $0 + .orWhere("name", .equal, SQLBind("Milky Way")) + .orWhere("name", .equal, SQLBind("Andromeda")) } .all() @@ -90,6 +93,7 @@ final class SQLiteKitTests: XCTestCase { func testForeignKeysEnabledOnlyWhenRequested() async throws { let res = try await self.connection.query("PRAGMA foreign_keys").get() + XCTAssertEqual(res[0].column("foreign_keys"), .integer(1)) // Using `.file` storage here is a quick and dirty nod to increasing test coverage. @@ -97,10 +101,10 @@ final class SQLiteKitTests: XCTestCase { configuration: .init(storage: .file( path: FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID()).sqlite3", isDirectory: false).path ), enableForeignKeys: false), - threadPool: self.threadPool + threadPool: .singleton ) - let conn2 = try await source.makeConnection(logger: self.connection.logger, on: self.eventLoopGroup.any()).get() + let conn2 = try await source.makeConnection(logger: self.connection.logger, on: MultiThreadedEventLoopGroup.singleton.any()).get() defer { try! conn2.close().wait() } let res2 = try await conn2.query("PRAGMA foreign_keys").get() @@ -122,20 +126,20 @@ final class SQLiteKitTests: XCTestCase { func testMultipleInMemoryDatabases() async throws { let a = SQLiteConnectionSource( configuration: .init(storage: .memory, enableForeignKeys: true), - threadPool: self.threadPool + threadPool: .singleton ) let b = SQLiteConnectionSource( configuration: .init(storage: .memory, enableForeignKeys: true), - threadPool: self.threadPool + threadPool: .singleton ) - let a1 = try await a.makeConnection(logger: .init(label: "test"), on: self.eventLoopGroup.any()).get() + let a1 = try await a.makeConnection(logger: .init(label: "test"), on: MultiThreadedEventLoopGroup.singleton.any()).get() defer { try! a1.close().wait() } - let a2 = try await a.makeConnection(logger: .init(label: "test"), on: self.eventLoopGroup.any()).get() + let a2 = try await a.makeConnection(logger: .init(label: "test"), on: MultiThreadedEventLoopGroup.singleton.any()).get() defer { try! a2.close().wait() } - let b1 = try await b.makeConnection(logger: .init(label: "test"), on: self.eventLoopGroup.any()).get() + let b1 = try await b.makeConnection(logger: .init(label: "test"), on: MultiThreadedEventLoopGroup.singleton.any()).get() defer { try! b1.close().wait() } - let b2 = try await b.makeConnection(logger: .init(label: "test"), on: self.eventLoopGroup.any()).get() + let b2 = try await b.makeConnection(logger: .init(label: "test"), on: MultiThreadedEventLoopGroup.singleton.any()).get() defer { try! b2.close().wait() } _ = try await a1.query("CREATE TABLE foo (bar INTEGER)").get() @@ -192,20 +196,26 @@ final class SQLiteKitTests: XCTestCase { let val: String let nest: NestFoo } - try await self.db.create(table: "foo") - .column("id", type: .int, .primaryKey(autoIncrement: false), .notNull) - .column("value", type: .custom(SQLRaw("json"))) - .run() - try await self.db.insert(into: "foo") - .columns("id", "value") - .values(SQLLiteral.numeric("1"), SQLBind(SubFoo(arr: [1,2,3], val: "a", nest: .init(x: 1.1)))) - .values(SQLLiteral.numeric("2"), SQLBind(SubFoo?.none)) - .run() - let rows = try await self.db.select() - .column(self.db.dialect.nestedSubpathExpression(in: SQLColumn("value"), for: ["nest", "x"])!, as: "x") - .from("foo") - .orderBy("id") - .all() + await XCTAssertNoThrowAsync( + try await self.db.create(table: "foo") + .column("id", type: .int, .primaryKey(autoIncrement: false), .notNull) + .column("value", type: .custom(SQLRaw("json"))) + .run() + ) + await XCTAssertNoThrowAsync( + try await self.db.insert(into: "foo") + .columns("id", "value") + .values(SQLLiteral.numeric("1"), SQLBind(SubFoo(arr: [1,2,3], val: "a", nest: .init(x: 1.1)))) + .values(SQLLiteral.numeric("2"), SQLBind(SubFoo?.none)) + .run() + ) + let rows = try await XCTUnwrapAsync( + try await self.db.select() + .column(self.db.dialect.nestedSubpathExpression(in: SQLColumn("value"), for: ["nest", "x"])!, as: "x") + .from("foo") + .orderBy("id") + .all() + ) XCTAssertEqual(rows.count, 2) let row1 = try XCTUnwrap(rows.dropFirst(0).first), @@ -226,30 +236,23 @@ final class SQLiteKitTests: XCTestCase { } var db: any SQLDatabase { self.connection.sql() } - var benchmark: SQLBenchmarker { .init(on: self.db) } - - var eventLoopGroup: (any EventLoopGroup)! - var threadPool: NIOThreadPool! var connection: SQLiteConnection! override func setUp() async throws { XCTAssertTrue(isLoggingConfigured) - self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 2) - self.threadPool = NIOThreadPool(numberOfThreads: 2) - self.threadPool.start() + self.connection = try await SQLiteConnectionSource( configuration: .init(storage: .memory, enableForeignKeys: true), - threadPool: self.threadPool - ).makeConnection(logger: .init(label: "test"), on: self.eventLoopGroup.any()).get() + threadPool: .singleton + ).makeConnection( + logger: .init(label: "test"), + on: MultiThreadedEventLoopGroup.singleton.any() + ).get() } override func tearDown() async throws { try await self.connection.close().get() self.connection = nil - try await self.threadPool.shutdownGracefully() - self.threadPool = nil - try await self.eventLoopGroup.shutdownGracefully() - self.eventLoopGroup = nil } } @@ -260,8 +263,35 @@ func env(_ name: String) -> String? { let isLoggingConfigured: Bool = { LoggingSystem.bootstrap { label in var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .debug + handler.logLevel = env("LOG_LEVEL").flatMap { .init(rawValue: $0) } ?? .debug return handler } return true }() + +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) + } +} + +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) +}