diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c99cc74..6ff9614 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,8 @@ -* @0xTim @gwynne +* @gwynne +/.github/CONTRIBUTING.md @gwynne @0xTim +/.github/workflows/*.yml @gwynne @0xTim +/.github/workflows/test.yml @gwynne +/.spi.yml @gwynne @0xTim +/.gitignore @gwynne @0xTim +/LICENSE @gwynne @0xTim +/README.md @gwynne @0xTim diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 68cfb1a..998a0eb 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: "/" @@ -9,14 +8,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 2423c1d..f15c073 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,12 +15,13 @@ jobs: # also serves as code coverage baseline update unit-tests: uses: vapor/ci/.github/workflows/run-unit-tests.yml@main + secrets: inherit # Make sure downstream dependents still work 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 diff --git a/Package.swift b/Package.swift index 7ef451d..c9ef1eb 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,21 +13,34 @@ let package = Package( .library(name: "SQLiteKit", targets: ["SQLiteKit"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.62.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.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/sql-kit.git", from: "3.29.3"), .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"), - ]), - .testTarget(name: "SQLiteKitTests", dependencies: [ - .product(name: "SQLKitBenchmark", package: "sql-kit"), - .target(name: "SQLiteKit"), - ]), + .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 + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), +] } diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index 43bd242..67965f2 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -1,11 +1,6 @@ // swift-tools-version:5.9 import PackageDescription -let swiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("ExistentialAny"), - .enableExperimentalFeature("StrictConcurrency=complete"), -] - let package = Package( name: "sqlite-kit", platforms: [ @@ -18,9 +13,9 @@ let package = Package( .library(name: "SQLiteKit", targets: ["SQLiteKit"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.62.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.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/sql-kit.git", from: "3.29.3"), .package(url: "https://github.com/vapor/async-kit.git", from: "1.19.0"), ], targets: [ @@ -44,3 +39,11 @@ let package = Package( ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ConciseMagicFile"), + .enableUpcomingFeature("ForwardTrailingClosures"), + .enableUpcomingFeature("DisableOutwardActorInference"), + .enableExperimentalFeature("StrictConcurrency=complete"), +] } diff --git a/README.md b/README.md index 9102bb7..86287bc 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@

- - - SQLiteKit + + + SQLiteKit

@@ -10,19 +10,30 @@ Team Chat MIT License Continuous Integration - -Swift 5.7+ -SSWG Incubation Level: Graduated + +Swift 5.8+ +SSWG Incubation Level: Graduated


-SQLiteKit is a library providing an [SQLKit] driver for [SQLiteNIO]. +SQLiteKit is an [SQLKit] driver for SQLite clients. It supports building and serializing SQLite-dialect SQL queries. SQLiteKit uses [SQLiteNIO] to connect and communicate with the database server asynchronously. [AsyncKit] is used to provide connection pooling. -> [!NOTE] -> The [FluentKit] driver for SQLite is provided by the [FluentSQLiteDriver] package. +[SQLKit]: https://github.com/vapor/sql-kit +[SQLiteNIO]: https://github.com/vapor/sqlite-nio +[AsyncKit]: https://github.com/vapor/async-kit -[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 +### Usage + +Use the SPM string to easily include the dependendency in your `Package.swift` file. + +```swift +.package(url: "https://github.com/vapor/sqlite-kit.git", from: "4.0.0") +``` + +### Supported Platforms + +SQLiteKit supports the following platforms: + +- Ubuntu 20.04+ +- macOS 10.15+ diff --git a/Sources/SQLiteKit/Docs.docc/Documentation.md b/Sources/SQLiteKit/Docs.docc/Documentation.md index cbf97f2..84d3fea 100644 --- a/Sources/SQLiteKit/Docs.docc/Documentation.md +++ b/Sources/SQLiteKit/Docs.docc/Documentation.md @@ -12,11 +12,16 @@ This package provides the "foundational" level of support for using [Fluent] wit - Managing the underlying SQLite library ([SQLiteNIO]), - Providing a two-way bridge between SQLiteNIO and SQLKit's generic data and metadata formats, -- Presenting an interface for establishing, managing, and interacting with database connections. +- Presenting an interface for establishing, managing, and interacting with database connections via [AsyncKit]. -> Note: The FluentKit driver for SQLite is provided by the [FluentSQLiteDriver] package. +> Tip: The FluentKit driver for SQLite is provided by the [FluentSQLiteDriver] package. + +## Version Support + +This package uses [SQLiteNIO] for all underlying database interactions. The version of SQLite embedded by that package is always the one used. [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 +[AsyncKit]: https://swiftpackageindex.com/vapor/async-kit diff --git a/Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg b/Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg index a3d3287..fc638a3 100644 --- a/Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg +++ b/Sources/SQLiteKit/Docs.docc/images/vapor-sqlitekit-logo.svg @@ -1,22 +1,21 @@ - + - - - + + + - - + + - - + + - - - + + + \ No newline at end of file diff --git a/Sources/SQLiteKit/Docs.docc/theme-settings.json b/Sources/SQLiteKit/Docs.docc/theme-settings.json index 806ccb9..bc7e116 100644 --- a/Sources/SQLiteKit/Docs.docc/theme-settings.json +++ b/Sources/SQLiteKit/Docs.docc/theme-settings.json @@ -1,13 +1,13 @@ { "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" }, "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)", + "sqlitekit": "hsl(215, 45%, 58%)", + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-sqlitekit) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-sqlitekit)", "logo-base": { "dark": "#fff", "light": "#000" }, "logo-shape": { "dark": "#000", "light": "#fff" }, "fill": { "dark": "#000", "light": "#fff" } diff --git a/Sources/SQLiteKit/Exports.swift b/Sources/SQLiteKit/Exports.swift index 2719894..6656d7a 100644 --- a/Sources/SQLiteKit/Exports.swift +++ b/Sources/SQLiteKit/Exports.swift @@ -1,15 +1,4 @@ -#if swift(>=5.8) - @_documentation(visibility: internal) @_exported import SQLKit @_documentation(visibility: internal) @_exported import SQLiteNIO @_documentation(visibility: internal) @_exported import AsyncKit @_documentation(visibility: internal) @_exported import struct Logging.Logger - -#else - -@_exported import SQLKit -@_exported import SQLiteNIO -@_exported import AsyncKit -@_exported import struct Logging.Logger - -#endif diff --git a/Sources/SQLiteKit/SQLiteConfiguration.swift b/Sources/SQLiteKit/SQLiteConfiguration.swift index 9177522..66e8857 100644 --- a/Sources/SQLiteKit/SQLiteConfiguration.swift +++ b/Sources/SQLiteKit/SQLiteConfiguration.swift @@ -1,32 +1,52 @@ import struct Foundation.UUID +/// Describes a configuration for an SQLite database connection. public struct SQLiteConfiguration: Sendable { + /// The possible storage types for an SQLite database. public enum Storage: Sendable { - /// Stores the SQLite database in memory. - /// - /// Uses a randomly generated identifier. See `memory(identifier:)`. + /// Specify an SQLite database stored in memory, using a randomly generated identifier. + /// + /// See ``memory(identifier:)``. public static var memory: Self { .memory(identifier: UUID().uuidString) } - /// Stores the SQLite database in memory. - /// - parameters: - /// - identifier: Uniquely identifies the in-memory storage. - /// Connections using the same identifier share data. + /// Specify an SQLite database stored in memory, using a given identifier string. + /// + /// An in-memory database persists only until the last connection to it is closed. If a new connection is + /// opened after that point, even using the same identifier, a new, empty database is created. + /// + /// - Parameter identifier: Uniquely identifies the in-memory storage. Multiple connections may use this + /// identifier to connect to the same in-memory storage for the duration of its lifetime. The identifer + /// has no predefined format or restrictions on its content. case memory(identifier: String) - /// Uses the SQLite database file at the specified path. + /// Specify an SQLite database stored in a file at the specified path. /// - /// Non-absolute paths will check the current working directory. + /// If a relative path is specified, it is interpreted relative to the current working directory of the + /// current process (e.g. `NIOFileSystem.shared.currentWorkingDirectory`). It is recommended to always use + /// absolute paths whenever possible. + /// + /// - Parameter path: The filesystem path at which to store the database. case file(path: String) } - + + /// The storage type for the database. + /// + /// See ``Storage-swift.enum`` for the available storage types. public var storage: Storage - /// If `true`, foreign keys will be enabled automatically on new connections. + /// When `true`, foreign key support is automatically enabled on all connections using this configuration. + /// + /// Internally issues a `PRAGMA foreign_keys = ON` query when enabled. public var enableForeignKeys: Bool - /// Creates a new `SQLiteConfiguration`. + /// Create a new ``SQLiteConfiguration``. + /// + /// - Parameters: + /// - storage: The storage type to use for the database. See ``Storage-swift.enum``. + /// - enableForeignKeys: Whether to enable foreign key support by default for all connections. + /// Defaults to `true`. public init(storage: Storage, enableForeignKeys: Bool = true) { self.storage = storage self.enableForeignKeys = enableForeignKeys diff --git a/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift b/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift index bde2384..02950bf 100644 --- a/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift +++ b/Sources/SQLiteKit/SQLiteConnection+SQLKit.swift @@ -1,39 +1,107 @@ import SQLKit import SQLiteNIO +import Logging + +// Hint: Yes, I know what default arguments are. This ridiculous spelling out of each alternative avoids public API +// breakage from adding the defaults. extension SQLiteDatabase { + /// Shorthand for ``sql(encoder:decoder:queryLogLevel:)``. + @inlinable public func sql() -> any SQLDatabase { - _SQLiteSQLDatabase(database: self) + self.sql(encoder: .init(), decoder: .init(), queryLogLevel: .debug) + } + + /// Shorthand for ``sql(encoder:decoder:queryLogLevel:)``. + @inlinable + public func sql(encoder: SQLiteDataEncoder) -> any SQLDatabase { + self.sql(encoder: encoder, decoder: .init(), queryLogLevel: .debug) + } + + /// Shorthand for ``sql(encoder:decoder:queryLogLevel:)``. + @inlinable + public func sql(decoder: SQLiteDataDecoder) -> any SQLDatabase { + self.sql(encoder: .init(), decoder: decoder, queryLogLevel: .debug) + } + + /// Shorthand for ``sql(encoder:decoder:queryLogLevel:)``. + @inlinable + public func sql(encoder: SQLiteDataEncoder, decoder: SQLiteDataDecoder) -> any SQLDatabase { + self.sql(encoder: encoder, decoder: decoder, queryLogLevel: .debug) + } + + /// Shorthand for ``sql(encoder:decoder:queryLogLevel:)``. + @inlinable + public func sql(queryLogLevel: Logger.Level?) -> any SQLDatabase { + self.sql(encoder: .init(), decoder: .init(), queryLogLevel: queryLogLevel) + } + + /// Shorthand for ``sql(encoder:decoder:queryLogLevel:)``. + @inlinable + public func sql(encoder: SQLiteDataEncoder, queryLogLevel: Logger.Level?) -> any SQLDatabase { + self.sql(encoder: encoder, decoder: .init(), queryLogLevel: queryLogLevel) + } + + /// Shorthand for ``sql(encoder:decoder:queryLogLevel:)``. + @inlinable + public func sql(decoder: SQLiteDataDecoder, queryLogLevel: Logger.Level?) -> any SQLDatabase { + self.sql(encoder: .init(), decoder: decoder, queryLogLevel: queryLogLevel) + } + + /// Return an object allowing access to this database via the `SQLDatabase` interface. + /// + /// - Parameters: + /// - encoder: An ``SQLiteDataEncoder`` used to translate bound query parameters into `SQLiteData` values. + /// - decoder: An ``SQLiteDataDecoder`` used to translate `SQLiteData` values into output values in `SQLRow`s. + /// - queryLogLevel: The level at which SQL queries issued through the SQLKit interface will be logged. + /// - Returns: An instance of `SQLDatabase` which accesses the same database as `self`. + @inlinable + public func sql( + encoder: SQLiteDataEncoder, + decoder: SQLiteDataDecoder, + queryLogLevel: Logger.Level? + ) -> any SQLDatabase { + SQLiteSQLDatabase(database: self, encoder: encoder, decoder: decoder, queryLogLevel: queryLogLevel) } } -internal struct _SQLiteDatabaseVersion: SQLDatabaseReportedVersion { - /// The numeric value of the version. The format of the value is the one described in - /// https://sqlite.org/c3ref/c_source_id.html for the `SQLITE_VERSION_NUMBER` constant. +struct SQLiteDatabaseVersion: SQLDatabaseReportedVersion { + /// The numeric value of the version. + /// + /// 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 let intValue: Int - /// The string representation of the version. The string is formatted according to the description in - /// https://sqlite.org/c3ref/c_source_id.html for the `SQLITE_VERSION` constant. + /// The string representation of the version. + /// + /// The string is formatted identically to [the `SQLITE_VERSION` constant](c_source_id). /// - /// This value is not used for equality or ordering comparisons; it is really only useful as a display value. We - /// maintain a stored property for it here rather than always generating it as-needed from the numeric value so - /// that we don't accidentally drop any additional information a particular library version might contain. + /// [c_source_id]: https://sqlite.org/c3ref/c_source_id.html /// - /// - Note: The string value should always represent the same version as the numeric value. This requirement is - /// asserted in debug builds, but not otherwise enforced. + /// This value is not used for equality or ordering comparisons; it is really only useful for display. We + /// maintain a stored property for it rather than generating it as-needed from the numeric value in order to + /// preserve any additional information the original value may contain. + /// + /// > Note: The string value should always represent the same version as the numeric value. This requirement is + /// > asserted in debug builds, but is not otherwise enforced. let stringValue: String - /// Separates a numeric value into individual components and returns them. + /// Separates an appropriately formatted numeric value into its individual components. static func components(of intValue: Int) -> (major: Int, minor: Int, patch: Int) { - let major = intValue / 1_000_000, - minor = (intValue - major * 1_000_000) / 1_000, - patch = intValue - major * 1_000_000 - minor * 1_000 - return (major: major, minor: minor, patch: patch) + ( + major: intValue / 1_000_000, + minor: intValue % 1_000_000 / 1_000, + patch: intValue % 1_000 + ) } - /// Get the version value representing the runtime version of the SQLite3 library in use. - static var runtimeVersion: _SQLiteDatabaseVersion { - self.init(intValue: Int(SQLiteConnection.libraryVersion()), stringValue: SQLiteConnection.libraryVersionString()) + /// Get the runtime version of the SQLite3 library in use. + static var runtimeVersion: Self { + self.init( + intValue: Int(SQLiteConnection.libraryVersion()), + stringValue: SQLiteConnection.libraryVersionString() + ) } /// Build a version value from individual components and synthesize the approiate string value. @@ -47,80 +115,150 @@ internal struct _SQLiteDatabaseVersion: SQLDatabaseReportedVersion { let components = Self.components(of: intValue) self.intValue = intValue - if let stringValue = stringValue { - assert(stringValue.hasPrefix("\(components.major).\(components.minor).\(components.patch)"), "SQLite version string '\(stringValue)' must match numeric version '\(intValue)'") + if let stringValue { + assert( + stringValue.hasPrefix("\(components.major).\(components.minor).\(components.patch)"), + "SQLite version string '\(stringValue)' must prefix-match numeric version '\(intValue)'" + ) self.stringValue = stringValue } else { self.stringValue = "\(components.major).\(components.major).\(components.patch)" } } - /// The major version number. This is likely to be 3 for a long time to come yet. - var majorVersion: Int { Self.components(of: self.intValue).major } + /// The major version number. + /// + /// This is likely to be 3 for a long time to come yet. + var majorVersion: Int { + Self.components(of: self.intValue).major + } /// The minor version number. - var minorVersion: Int { Self.components(of: self.intValue).minor } + var minorVersion: Int { + Self.components(of: self.intValue).minor + } /// The patch version number. - var patchVersion: Int { Self.components(of: self.intValue).patch } + var patchVersion: Int { + Self.components(of: self.intValue).patch + } - /// See ``SQLDatabaseReportedVersion/isEqual(to:)``. + // See `SQLDatabaseReportedVersion.isEqual(to:)`. func isEqual(to otherVersion: any SQLDatabaseReportedVersion) -> Bool { - (otherVersion as? _SQLiteDatabaseVersion).map { $0.intValue == self.intValue } ?? false + (otherVersion as? Self).map { $0.intValue == self.intValue } ?? false } - /// See ``SQLDatabaseReportedVersion/isOlder(than:)``. + // See `SQLDatabaseReportedVersion.isOlder(than:)`. func isOlder(than otherVersion: any SQLDatabaseReportedVersion) -> Bool { - (otherVersion as? _SQLiteDatabaseVersion).map { - (self.majorVersion < $0.majorVersion ? true : - (self.majorVersion > $0.majorVersion ? false : - (self.minorVersion < $0.minorVersion ? true : - (self.minorVersion > $0.minorVersion ? false : - (self.patchVersion < $0.patchVersion ? true : false))))) + (otherVersion as? Self).map { + (self.majorVersion != $0.majorVersion ? self.majorVersion < $0.majorVersion : + (self.minorVersion != $0.minorVersion ? self.minorVersion < $0.minorVersion : + (self.patchVersion < $0.patchVersion))) } ?? false } } -private struct _SQLiteSQLDatabase: SQLDatabase { - let database: any SQLiteDatabase +/// Wraps a `SQLiteDatabase` with the `SQLDatabase` protocol. +@usableFromInline +/*private*/ struct SQLiteSQLDatabase: SQLDatabase { + /// The underlying database. + @usableFromInline + let database: D + /// An ``SQLiteDataEncoder`` used to translate bindings into `SQLiteData` values. + @usableFromInline + let encoder: SQLiteDataEncoder + + /// An ``SQLiteDataDecoder`` used to translate `SQLiteData` values into output values in `SQLRow`s. + @usableFromInline + let decoder: SQLiteDataDecoder + + // See `SQLDatabase.eventLoop`. + @usableFromInline var eventLoop: any EventLoop { self.database.eventLoop } + // See `SQLDatabase.version`. + @usableFromInline var version: (any SQLDatabaseReportedVersion)? { - _SQLiteDatabaseVersion.runtimeVersion + SQLiteDatabaseVersion.runtimeVersion } + // See `SQLDatabase.logger`. + @usableFromInline var logger: Logger { self.database.logger } + // See `SQLDatabase.dialect`. + @usableFromInline var dialect: any SQLDialect { SQLiteDialect() } + // See `SQLDatabase.queryLogLevel`. + @usableFromInline + let queryLogLevel: Logger.Level? + + @inlinable + init(database: D, encoder: SQLiteDataEncoder, decoder: SQLiteDataDecoder, queryLogLevel: Logger.Level?) { + self.database = database + self.encoder = encoder + self.decoder = decoder + self.queryLogLevel = queryLogLevel + } + + // See `SQLDatabase.execute(sql:_:)`. + @usableFromInline func execute( sql query: any SQLExpression, - _ onRow: @escaping (any SQLRow) -> () + _ onRow: @escaping @Sendable (any SQLRow) -> () ) -> EventLoopFuture { - var serializer = SQLSerializer(database: self) - query.serialize(to: &serializer) + let (sql, rawBinds) = self.serialize(query) + + if let queryLogLevel = self.queryLogLevel { + self.logger.log(level: queryLogLevel, "\(sql) [\(rawBinds)]") + } + let binds: [SQLiteData] do { - binds = try serializer.binds.map { encodable in - try SQLiteDataEncoder().encode(encodable) - } + binds = try rawBinds.map { try self.encoder.encode($0) } } catch { return self.eventLoop.makeFailedFuture(error) } - // 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( + sql, + binds, + { onRow($0.sql(decoder: self.decoder)) } + ) + } + + // See `SQLDatabase.execute(sql:_:)`. + @usableFromInline + func execute( + sql query: any SQLExpression, + _ onRow: @escaping @Sendable (any SQLRow) -> () + ) async throws { + let (sql, rawBinds) = self.serialize(query) + + if let queryLogLevel = self.queryLogLevel { + self.logger.log(level: queryLogLevel, "\(sql) [\(rawBinds)]") + } + + try await self.database.query( + sql, + rawBinds.map { try self.encoder.encode($0) }, + { onRow($0.sql(decoder: self.decoder)) } + ) + } + + // See `SQLDatabase.withSession(_:)`. + @usableFromInline + func withSession(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> R) async throws -> R { + try await self.database.withConnection { + try await closure($0.sql(encoder: self.encoder, decoder: self.decoder, queryLogLevel: self.queryLogLevel)) } - return self.database.query(serializer.sql, binds, logger: self.logger, onRowWorkaround) } } - diff --git a/Sources/SQLiteKit/SQLiteConnectionSource.swift b/Sources/SQLiteKit/SQLiteConnectionSource.swift index 8768b31..f946f36 100644 --- a/Sources/SQLiteKit/SQLiteConnectionSource.swift +++ b/Sources/SQLiteKit/SQLiteConnectionSource.swift @@ -1,10 +1,15 @@ +#if canImport(Darwin) import Foundation +#else +@preconcurrency import Foundation +#endif import Logging import AsyncKit import NIOPosix import SQLiteNIO import NIOCore +/// A `ConnectionPoolSource` providing SQLite database connections for a given ``SQLiteConfiguration``. public struct SQLiteConnectionSource: ConnectionPoolSource, Sendable { private let configuration: SQLiteConfiguration private let actualURL: URL @@ -14,20 +19,30 @@ public struct SQLiteConnectionSource: ConnectionPoolSource, Sendable { .file(path: self.actualURL.absoluteString) } + /// Create a new ``SQLiteConnectionSource``. + /// + /// > Important: If the caller provides a thread pool other than the default, they are responsible for starting + /// > the pool before any connections are made and shutting it down only after all connections are closed. It is + /// > strongly recommended that all callers use the default. + /// + /// - Parameters: + /// - configuration: The configuration for new connections. + /// - threadPool: The thread pool used by connections. Defaults to the global singleton. public init( configuration: SQLiteConfiguration, - threadPool: NIOThreadPool + threadPool: NIOThreadPool = .singleton ) { self.configuration = configuration self.actualURL = configuration.storage.urlForSQLite self.threadPool = threadPool } + // See `ConnectionPoolSource.makeConnection(logger:on:)`. public func makeConnection( logger: Logger, on eventLoop: any EventLoop ) -> EventLoopFuture { - return SQLiteConnection.open( + SQLiteConnection.open( storage: self.connectionStorage, threadPool: self.threadPool, logger: logger, @@ -43,9 +58,10 @@ public struct SQLiteConnectionSource: ConnectionPoolSource, Sendable { } } -extension SQLiteConnection: ConnectionPoolItem { } +extension SQLiteNIO.SQLiteConnection: AsyncKit.ConnectionPoolItem {} fileprivate extension String { + /// Attempt to "sanitize" a string for use as a filename. This is quick and dirty and probably not 100% correct. var asSafeFilename: String { #if os(Windows) self.replacingOccurrences(of: ":", with: "_").replacingOccurrences(of: "\\", with: "-") @@ -56,6 +72,16 @@ fileprivate extension String { } fileprivate extension SQLiteConfiguration.Storage { + /// Because SQLiteNIO specifies the recommended `SQLITE_OMIT_SHARED_CACHE` build flag, we cannot implement our + /// claimed support for multiple connections to in-memory databases using actual in-memory databases. + /// Unfortunately, Fluent relies on having this support, and it had been public API since long before the change + /// in build flags. Therefore, we work around it by using temporary files to fake in-memory databases. + /// + /// This has the unfortunate side effect of violating the "when the last connection to an in-memory database is + /// closed, it is immediately deleted" semantics, but fortunately no one seems to have relied on that behavior. + /// + /// We include both the user-provided identifer and the current process ID in the filename for the temporary + /// file because in-memory databases are expected to be process-specific. var urlForSQLite: URL { switch self { case .memory(identifier: let identifier): diff --git a/Sources/SQLiteKit/SQLiteDataDecoder.swift b/Sources/SQLiteKit/SQLiteDataDecoder.swift index 71cecde..68fe89a 100644 --- a/Sources/SQLiteKit/SQLiteDataDecoder.swift +++ b/Sources/SQLiteKit/SQLiteDataDecoder.swift @@ -1,28 +1,54 @@ import Foundation import SQLiteNIO +@_spi(CodableUtilities) import SQLKit import NIOFoundationCompat -public struct SQLiteDataDecoder { - let json = JSONDecoder() // TODO: Add API to make this configurable +/// Translates `SQLiteData` values received from the database into `Decodable` values. +/// +/// Types which conform to `SQLiteDataConvertible` are converted directly to the requested type. For other types, +/// an attempt is made to interpret the database value as JSON and decode the type from it. +public struct SQLiteDataDecoder: Sendable { + /// A wrapper to silence `Sendable` warnings for `JSONDecoder` when not on macOS. + struct FakeSendable: @unchecked Sendable { let value: T } - public init() {} + /// The `JSONDecoder` used for decoding values that can't be directly converted. + let json: FakeSendable + /// Initialize a ``SQLiteDataDecoder`` with an unconfigured JSON decoder. + public init() { + self.init(json: .init()) + } + + /// Initialize a ``SQLiteDataDecoder`` with a JSON decoder. + /// + /// - Parameter json: A `JSONDecoder` to use for decoding types that can't be directly converted. + public init(json: JSONDecoder) { + self.json = .init(value: json) + } + + /// Convert the given `SQLiteData` into a value of type `T`, if possible. + /// + /// - Parameters: + /// - type: The desired result type. + /// - data: The data to decode. + /// - Returns: The decoded value, if successful. 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( codingPath: [], - debugDescription: "Could not initialize \(T.self) from \(data)." + debugDescription: "Could not convert SQLite data to \(T.self): \(data)." )) } return value as! T } else { do { - return try T.init(from: GiftBoxUnwrapDecoder(decoder: self, data: data)) - } catch is TryJSONSentinel { + return try T.init(from: NestedSingleValueUnwrappingDecoder(decoder: self, data: data)) + } catch is SQLCodingError { // 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) @@ -31,24 +57,55 @@ public struct SQLiteDataDecoder { case .float(let n): buf = .init(String(n).utf8) case .null: buf = .init() } - return try self.json.decode(T.self, from: buf) + return try self.json.value.decode(T.self, from: buf) } } } - private struct TryJSONSentinel: Swift.Error {} + /// A trivial decoder for unwrapping types which decode as trivial single-value containers. This allows for + /// correct handling of types such as `Optional` when they do not conform to `SQLiteDataCovnertible`. + private final class NestedSingleValueUnwrappingDecoder: Decoder, SingleValueDecodingContainer { + // See `Decoder.codingPath` and `SingleValueDecodingContainer.codingPath`. + var codingPath: [any CodingKey] { [] } - private struct GiftBoxUnwrapDecoder: Decoder, SingleValueDecodingContainer { - let decoder: SQLiteDataDecoder + // See `Decoder.userInfo`. + var userInfo: [CodingUserInfoKey: Any] { [:] } + + /// The parent ``SQLiteDataDecoder``. + let dataDecoder: SQLiteDataDecoder + + /// The data to decode. let data: SQLiteData - var codingPath: [any CodingKey] { [] } - var userInfo: [CodingUserInfoKey: Any] { [:] } + /// Create a new decoder with an ``SQLiteDataDecoder`` and the data to decode. + init(decoder: SQLiteDataDecoder, data: SQLiteData) { + self.dataDecoder = decoder + self.data = data + } + + // See `Decoder.container(keyedBy:)`. + func container(keyedBy: Key.Type) throws -> KeyedDecodingContainer { + throw .invalid(at: self.codingPath) + } + + // See `Decoder.unkeyedContainer()`. + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + throw .invalid(at: self.codingPath) + } + + // See `Decoder.singleValueContainer()`. + func singleValueContainer() throws -> any SingleValueDecodingContainer { + self + } - 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) } + // See `SingleValueDecodingContainer.decodeNil()`. + func decodeNil() -> Bool { + self.data.isNull + } + + // See `SingleValueDecodingContainer.decode(_:)`. + func decode(_: T.Type) throws -> T { + try self.dataDecoder.decode(T.self, from: self.data) + } } } diff --git a/Sources/SQLiteKit/SQLiteDataEncoder.swift b/Sources/SQLiteKit/SQLiteDataEncoder.swift index 04ecfbb..97e7ed3 100644 --- a/Sources/SQLiteKit/SQLiteDataEncoder.swift +++ b/Sources/SQLiteKit/SQLiteDataEncoder.swift @@ -1,21 +1,48 @@ import NIOCore import Foundation +@_spi(CodableUtilities) import SQLKit import SQLiteNIO -public struct SQLiteDataEncoder { - public init() {} - +/// Translates `Encodable` values into `SQLiteData` values suitable for use with an `SQLiteDatabase`. +/// +/// Types which conform to `SQLiteDataConvertible` are converted directly to `SQLiteData`. Other types are +/// encoded as JSON and sent to the database as text. +public struct SQLiteDataEncoder: Sendable { + /// A wrapper to silence `Sendable` warnings for `JSONEncoder` when not on macOS. + struct FakeSendable: @unchecked Sendable { let value: T } + + /// The `JSONEncoder` used for encoding values that can't be directly converted. + let json: FakeSendable + + /// Initialize a ``SQLiteDataEncoder`` with an unconfigured JSON encoder. + public init() { + self.init(json: .init()) + } + + /// Initialize a ``SQLiteDataEncoder`` with a JSON encoder. + /// + /// - Parameter json: A `JSONEncoder` to use for encoding types that can't be directly converted. + public init(json: JSONEncoder) { + self.json = .init(value: json) + } + + /// Convert the given `Encodable` value to an `SQLiteData` value, if possible. + /// + /// - Parameter value: The value to convert. + /// - Returns: A converted `SQLiteData` value, if successful. public func encode(_ value: any Encodable) throws -> SQLiteData { if let data = (value as? any SQLiteDataConvertible)?.sqliteData { return data } else { - let encoder = EncoderImpl() + let encoder = NestedSingleValueUnwrappingEncoder(dataEncoder: self) - try value.encode(to: encoder) - switch encoder.result { - case .data(let data): - return data - case .unkeyed, .keyed: + do { + try value.encode(to: encoder) + guard let value = encoder.value else { + throw SQLCodingError.unsupportedOperation("missing value", codingPath: []) + } + return value + } catch is SQLCodingError { // 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 @@ -24,60 +51,127 @@ public struct SQLiteDataEncoder { // 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)) + // + // Update: SQLite 3.45.1 (2024-01-30) fixed the JSON-blob behavior, but as noted above, we prefer + // sending JSON as text anyway, so we've left it as-is. + return .text(.init(decoding: try self.json.value.encode(value), as: UTF8.self)) } } } - private enum Result { - case keyed - case unkeyed - case data(SQLiteData) - } - - 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] { [] } + /// A trivial encoder for unwrapping types which encode as trivial single-value containers. This allows for + /// correct handling of types such as `Optional` when they do not conform to `SQLiteDataConvertible`. + private final class NestedSingleValueUnwrappingEncoder: Encoder, SingleValueEncodingContainer { + // See `Encoder.userInfo`. var userInfo: [CodingUserInfoKey: Any] { [:] } - var result: Result - init() { self.result = .data(.null) } - + // See `Encoder.codingPath` and `SingleValueEncodingContainer.codingPath`. + var codingPath: [any CodingKey] { [] } + + /// The parent ``SQLiteDataEncoder``. + let dataEncoder: SQLiteDataEncoder + + /// Storage for the resulting converted value. + var value: SQLiteData? = nil + + /// Create a new encoder with an ``SQLiteDataEncoder``. + init(dataEncoder: SQLiteDataEncoder) { + self.dataEncoder = dataEncoder + } + + // See `Encoder.container(keyedBy:)`. func container(keyedBy: K.Type) -> KeyedEncodingContainer { - self.result = .keyed - return .init(KeyedEncoderImpl()) + .invalid(at: self.codingPath) } - + + // See `Encoder.unkeyedContainer`. func unkeyedContainer() -> any UnkeyedEncodingContainer { - self.result = .unkeyed - return UnkeyedEncoderImpl() + .invalid(at: self.codingPath) + } + + // See `Encoder.singleValueContainer`. + func singleValueContainer() -> any SingleValueEncodingContainer { + self + } + + // See `SingleValueEncodingContainer.encodeNil()`. + func encodeNil() throws { + self.value = .null + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Bool) throws { + self.value = .integer(value ? 1 : 0) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: String) throws { + self.value = .text(value) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Float) throws { + self.value = .float(Double(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Double) throws { + self.value = .float(value) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Int8) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Int16) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Int32) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Int64) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: Int) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: UInt8) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: UInt16) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: UInt32) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: UInt64) throws { + self.value = .integer(numericCast(value)) + } + + // See `SingleValueEncodingContainer.encode(_:)`. + func encode(_ value: UInt) throws { + self.value = .integer(numericCast(value)) } - - func singleValueContainer() -> any SingleValueEncodingContainer { self } - - func encodeNil() throws { self.result = .data(.null) } + // See `SingleValueEncodingContainer.encode(_:)`. func encode(_ value: some Encodable) throws { - self.result = .data(try SQLiteDataEncoder().encode(value)) + self.value = try self.dataEncoder.encode(value) } } } diff --git a/Sources/SQLiteKit/SQLiteDialect.swift b/Sources/SQLiteKit/SQLiteDialect.swift index c0031fb..1f4c55b 100644 --- a/Sources/SQLiteKit/SQLiteDialect.swift +++ b/Sources/SQLiteKit/SQLiteDialect.swift @@ -2,29 +2,95 @@ import SQLKit /// The ``SQLDialect`` defintions for SQLite. /// -/// - Note: There is only ever one SQLite library in use by SQLiteNIO in any given process (even if there are -/// other versions of the library being used by other things). As such, there is no need for the dialect to -/// concern itself with what version the connection using it "connected" to - it can always just look up the -/// global "runtime version". +/// > Note: There is only ever one SQLite library in use by SQLiteNIO in any given process (even if there are +/// > other versions of the library being used by other things). As such, there is no need for the dialect to +/// > concern itself with what version the connection using it "connected" to - it can always just look up the +/// > global "runtime version". public struct SQLiteDialect: SQLDialect { - public var name: String { "sqlite" } + /// Create a new ``SQLiteDialect``. + public init() {} + + // See `SQLDialect.name`. + public var name: String { + "sqlite" + } + + // See `SQLDialect.identifierQuote`. + public var identifierQuote: any SQLExpression { + SQLRaw(#"""#) + } + + // See `SQLDialect.literalStringQuote`. + public var literalStringQuote: any SQLExpression { + SQLRaw("'") + } + + // See `SQLDialect.bindPlaceholder(at:)`. + public func bindPlaceholder(at position: Int) -> any SQLExpression { + SQLRaw("?\(position)") + } + + // See `SQLDialect.literalBoolean(_:)`. + public func literalBoolean(_ value: Bool) -> any SQLExpression { + SQLRaw(value ? "TRUE" : "FALSE") + } + + // See `SQLDialect.literalDefault`. + public var literalDefault: any SQLExpression { + SQLLiteral.null + } + + // See `SQLDialect.supportsIfExists`. + public var supportsIfExists: Bool { + true + } - public var identifierQuote: any SQLExpression { SQLRaw("\"") } - public var literalStringQuote: any SQLExpression { SQLRaw("'") } - public func bindPlaceholder(at position: Int) -> any SQLExpression { SQLRaw("?\(position)") } - public func literalBoolean(_ value: Bool) -> any SQLExpression { SQLRaw(value ? "TRUE" : "FALSE") } - public var literalDefault: any SQLExpression { SQLLiteral.null } + // See `SQLDialect.supportsDropBehavior`. + public var supportsDropBehavior: Bool { + false + } - public var supportsAutoIncrement: Bool { false } - public var autoIncrementClause: any SQLExpression { SQLRaw("AUTOINCREMENT") } + // See `SQLDialect.supportsAutoIncrement`. + public var supportsAutoIncrement: Bool { + false + } + + // See `SQLDialect.autoIncrementClause`. + public var autoIncrementClause: any SQLExpression { + SQLRaw("AUTOINCREMENT") + } - public var enumSyntax: SQLEnumSyntax { .unsupported } - public var triggerSyntax: SQLTriggerSyntax { .init(create: [.supportsBody, .supportsCondition]) } - public var alterTableSyntax: SQLAlterTableSyntax { .init(allowsBatch: false) } - public var upsertSyntax: SQLUpsertSyntax { self.isAtLeastVersion(3, 24, 0) ? .standard : .unsupported } // `UPSERT` was added to SQLite in 3.24.0. - public var supportsReturning: Bool { self.isAtLeastVersion(3, 35, 0) } // `RETURNING` was added to SQLite in 3.35.0. - public var unionFeatures: SQLUnionFeatures { [.union, .unionAll, .intersect, .except] } + // See `SQLDialect.enumSyntax`. + public var enumSyntax: SQLEnumSyntax { + .unsupported + } + + // See `SQLDialect.triggerSyntax`. + public var triggerSyntax: SQLTriggerSyntax { + .init(create: [.supportsBody, .supportsCondition, .supportsUpdateColumns], drop: []) + } + // See `SQLDialect.alterTableSyntax`. + public var alterTableSyntax: SQLAlterTableSyntax { + .init(allowsBatch: false) + } + + // See `SQLDialect.upsertSyntax`. + public var upsertSyntax: SQLUpsertSyntax { + self.isAtLeastVersion(3, 24, 0) ? .standard : .unsupported // `UPSERT` was added to SQLite in 3.24.0. + } + + // See `SQLDialect.supportsReturning`. + public var supportsReturning: Bool { + self.isAtLeastVersion(3, 35, 0) // `RETURNING` was added to SQLite in 3.35.0. + } + + // See `SQLDialect.unionFeatures`. + public var unionFeatures: SQLUnionFeatures { + [.union, .unionAll, .intersect, .except] + } + + // See `SQLDialect.customDataType(for:)`. public func customDataType(for dataType: SQLDataType) -> (any SQLExpression)? { if case .bigint = dataType { // Translate requests for bigint to requests for SQLite's plain integer type. This yields the autoincrement @@ -34,6 +100,7 @@ public struct SQLiteDialect: SQLDialect { return nil } + // See `SQLDialect.nestedSubpathExpression(in:for:)`. public func nestedSubpathExpression(in column: any SQLExpression, for path: [String]) -> (any SQLExpression)? { guard !path.isEmpty else { return nil } @@ -43,9 +110,8 @@ public struct SQLiteDialect: SQLDialect { ]) } - public init() {} - + /// Convenience utility for checking current SQLite version. private func isAtLeastVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool { - _SQLiteDatabaseVersion.runtimeVersion.isNotOlder(than: _SQLiteDatabaseVersion(major: major, minor: minor, patch: patch)) + SQLiteDatabaseVersion.runtimeVersion >= SQLiteDatabaseVersion(major: major, minor: minor, patch: patch) } } diff --git a/Sources/SQLiteKit/SQLiteRow+SQLRow.swift b/Sources/SQLiteKit/SQLiteRow+SQLRow.swift index 75081cf..77edf2f 100644 --- a/Sources/SQLiteKit/SQLiteRow+SQLRow.swift +++ b/Sources/SQLiteKit/SQLiteRow+SQLRow.swift @@ -1,30 +1,90 @@ import SQLKit import SQLiteNIO -extension SQLiteRow: SQLRow { - public var allColumns: [String] { - self.columns.map { $0.name } +extension SQLiteRow { + /// Return an `SQLRow` interface to this row. + /// + /// - Parameter decoder: An ``SQLiteDataDecoder`` used to translate `SQLiteData` values into output values in + /// `SQLRow`s. + /// - Returns: An instance of `SQLRow` which accesses the same data as `self`. + public func sql(decoder: SQLiteDataDecoder = .init()) -> any SQLRow { + SQLiteSQLRow(row: self, decoder: decoder) } +} + +/// An error used to signal that a column requested from a `MySQLRow` using the `SQLRow` interface is not present. +struct MissingColumn: Error { + let column: String +} + +/// Wraps an `SQLiteRow` with the `SQLRow` protocol. +private struct SQLiteSQLRow: SQLRow { + /// The underlying `SQLiteRow`. + let row: SQLiteRow + + /// A ``SQLiteDataDecoder`` used to translate `SQLiteData` values into output values. + let decoder: SQLiteDataDecoder - public func decodeNil(column: String) throws -> Bool { - guard let data = self.column(column) else { + // See `SQLRow.allColumns`. + var allColumns: [String] { + self.row.columns.map { $0.name } + } + + // See `SQLRow.contains(column:)`. + func contains(column: String) -> Bool { + self.row.column(column) != nil + } + + // See `SQLRow.decodeNil(column:)`. + func decodeNil(column: String) throws -> Bool { + guard let data = self.row.column(column) else { return true } return data == .null } - public func contains(column: String) -> Bool { - self.column(column) != nil + // See `SQLRow.decode(column:as:)`. + func decode(column: String, as: D.Type) throws -> D { + guard let data = self.row.column(column) else { + throw MissingColumn(column: column) + } + return try self.decoder.decode(D.self, from: data) } +} + +/// A legacy deprecated conformance of `SQLiteRow` directly to `SQLRow`. This interface exists solely +/// because its absence would be a public API break. +/// +/// Do not use these methods directly. Call `sql(decoder:)` instead to access `SQLiteRow`s through +/// an `SQLKit` interface. +@available(*, deprecated, message: "Use SQLiteRow.sql(decoder:) to access an SQLiteRow as an SQLRow.") +extension SQLiteNIO.SQLiteRow: SQLKit.SQLRow { + // See `SQLRow.allColumns`. + public var allColumns: [String] { self.columns.map { $0.name } } + + // See `SQLRow.contains(column:)`. + public func contains(column: String) -> Bool { self.column(column) != nil } + // See `SQLRow.decodeNil(column:)`. + public func decodeNil(column: String) throws -> Bool { (self.column(column) ?? .null) == .null } + + // See `SQLRow.decode(column:as:)`. public func decode(column: String, as: D.Type) throws -> D { - guard let data = self.column(column) else { - throw MissingColumn(column: column) - } + guard let data = self.column(column) else { throw MissingColumn(column: column) } return try SQLiteDataDecoder().decode(D.self, from: data) } -} -struct MissingColumn: Error { - let column: String + // See `SQLRow.decode(column:inferringAs:)`. + public func decode(column c: String, inferringAs: D.Type = D.self) throws -> D { try self.decode(column: c, as: D.self) } + + // See `SQLRow.decode(model:prefix:keyDecodingStrategy:userInfo:)`. + public func decode( + model: D.Type, prefix: String? = nil, keyDecodingStrategy: SQLRowDecoder.KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) throws -> D { + try self.decode(model: D.self, with: .init(prefix: prefix, keyDecodingStrategy: keyDecodingStrategy, userInfo: userInfo)) + } + + // See `SQLRow.decode(model:with:)`. + public func decode(model: D.Type, with: SQLRowDecoder) throws -> D { try with.decode(D.self, from: self) } } diff --git a/Tests/SQLiteKitTests/SQLiteKitTests.swift b/Tests/SQLiteKitTests/SQLiteKitTests.swift index c9cd241..599a9f0 100644 --- a/Tests/SQLiteKitTests/SQLiteKitTests.swift +++ b/Tests/SQLiteKitTests/SQLiteKitTests.swift @@ -6,10 +6,10 @@ import SQLiteNIO import SQLKit final class SQLiteKitTests: XCTestCase { - func testSQLKitBenchmark() throws { + func testSQLKitBenchmark() async throws { let benchmark = SQLBenchmarker(on: self.db) - try benchmark.run() + try await benchmark.runAllTests() } func testPlanets() async throws {