diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 672e272..4b9e568 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -10,8 +10,8 @@ on: jobs: create-docc-and-deploy: - uses: BinaryBirds/github-workflows/.github/workflows/docc_deploy.yml@main + uses: BinaryBirds/github-workflows/.github/workflows/docc_deploy.yml@0.2.0 permissions: contents: read pages: write - id-token: write \ No newline at end of file + id-token: write diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 05d587b..b780a19 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -6,31 +6,25 @@ on: - main jobs: - api_breakage: - name: Check API breakage - uses: BinaryBirds/github-workflows/.github/workflows/api_breakage.yml@main - - swiftlang_checks: - name: Swiftlang Checks - uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + soundness: + name: Soundness + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.16 with: license_header_check_project_name: "project" format_check_enabled: true broken_symlink_check_enabled: true unacceptable_language_check_enabled: true - shell_check_enabled: false + shell_check_enabled: true docs_check_enabled: false api_breakage_check_enabled: false license_header_check_enabled: false yamllint_check_enabled: false python_lint_check_enabled: false - bb_checks: - name: BB Checks - uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main + tests: + name: Tests + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@0.0.16 with: - local_swift_dependencies_check_enabled: true - run_tests_with_cache_enabled: true - headers_check_enabled: false - docc_warnings_check_enabled: true - run_tests_swift_versions: '["6.1", "6.2", "6.3"]' + enable_windows_checks: false + linux_build_command: "swift test --parallel --enable-code-coverage" + linux_swift_versions: '["6.3", "6.4"]' diff --git a/Package.swift b/Package.swift index 1e9d437..f01857b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,27 +1,22 @@ -// swift-tools-version:6.1 +// swift-tools-version:6.3 import PackageDescription -// NOTE: https://github.com/swift-server/swift-http-server/blob/main/Package.swift -var defaultSwiftSettings: [SwiftSetting] = -[ - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0441-formalize-language-mode-terminology.md +let swiftSettings: [SwiftSetting] = [ .swiftLanguageMode(.v6), - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md + .strictMemorySafety(), + .treatAllWarnings(as: .error), + .enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("MemberImportVisibility"), - // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 + .enableUpcomingFeature("InferIsolatedConformances"), + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("ImmutableWeakCaptures"), + .enableUpcomingFeature("StrictConcurrency"), + .enableExperimentalFeature("SuppressedAssociatedTypes"), + .enableExperimentalFeature("LifetimeDependence"), .enableExperimentalFeature("Lifetimes"), - // https://github.com/swiftlang/swift/pull/65218 - .enableExperimentalFeature("AvailabilityMacro=featherDatabase 1.0:macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0"), ] -#if compiler(>=6.2) -defaultSwiftSettings.append( - // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md - .enableUpcomingFeature("NonisolatedNonsendingByDefault") -) -#endif - - let package = Package( name: "feather-database", platforms: [ @@ -33,22 +28,30 @@ let package = Package( ], products: [ .library(name: "FeatherDatabase", targets: ["FeatherDatabase"]), + .library(name: "FeatherDatabaseTesting", targets: ["FeatherDatabaseTesting"]), ], dependencies: [ - // [docc-plugin-placeholder] ], targets: [ .target( name: "FeatherDatabase", dependencies: [], - swiftSettings: defaultSwiftSettings + swiftSettings: swiftSettings + ), + .target( + name: "FeatherDatabaseTesting", + dependencies: [ + .target(name: "FeatherDatabase"), + ], + path: "Sources/Testing", + swiftSettings: swiftSettings ), .testTarget( name: "FeatherDatabaseTests", dependencies: [ .target(name: "FeatherDatabase"), ], - swiftSettings: defaultSwiftSettings + swiftSettings: swiftSettings ), ] ) diff --git a/README.md b/README.md index 28d522e..ed7eb1e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Abstract database component, providing a shared API surface for database drivers written in Swift. -[![Release: 1.0.0-rc.2](https://img.shields.io/badge/Release-1%2E0%2E0--rc%2E1-F05138)](https://github.com/feather-framework/feather-database/releases/tag/1.0.0-rc.2) +[![Release: 1.0.0-rc.3](https://img.shields.io/badge/Release-1%2E0%2E0--rc%2E3-F05138)](https://github.com/feather-framework/feather-database/releases/tag/1.0.0-rc.3) ## Features @@ -13,10 +13,10 @@ Abstract database component, providing a shared API surface for database drivers ## Requirements -![Swift 6.1+](https://img.shields.io/badge/Swift-6%2E1%2B-F05138) +![Swift 6.3+](https://img.shields.io/badge/Swift-6%2E3%2B-F05138) ![Platforms: Linux, macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-Linux_%7C_macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138) -- Swift 6.1+ +- Swift 6.3+ - Platforms: - Linux @@ -31,7 +31,7 @@ Abstract database component, providing a shared API surface for database drivers Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift -.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-rc.2"), +.package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-rc.3"), ``` Then add `FeatherDatabase` to your target dependencies: diff --git a/Sources/FeatherDatabase/DatabaseError.swift b/Sources/FeatherDatabase/DatabaseError.swift index 22253f8..3bddf8f 100644 --- a/Sources/FeatherDatabase/DatabaseError.swift +++ b/Sources/FeatherDatabase/DatabaseError.swift @@ -13,16 +13,16 @@ public enum DatabaseError: Error { /// A connection-level failure. /// /// The associated error provides the underlying cause. - case connection(Error) + case connection(any Error) /// A transaction failure. /// /// The associated error includes phase-specific details. - case transaction(DatabaseTransactionError) + case transaction(any DatabaseTransactionError) /// A query execution failure. /// /// The associated error provides the underlying cause. - case query(Error) + case query(any Error) } diff --git a/Sources/FeatherDatabase/DatabaseQueryBinding.swift b/Sources/FeatherDatabase/DatabaseQueryBinding.swift index 0e8ed24..2b1bea4 100644 --- a/Sources/FeatherDatabase/DatabaseQueryBinding.swift +++ b/Sources/FeatherDatabase/DatabaseQueryBinding.swift @@ -5,14 +5,32 @@ // Created by Tibor Bödecs on 2026. 02. 07.. // +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else +public import Foundation +#endif + /// A single bindable value used by a database query. public enum DatabaseQueryBinding: Sendable, Equatable, Hashable, Codable { /// A text value. case string(String) /// An integer value. case int(Int) + /// A 32-bit integer value. + case int32(Int32) /// A floating-point value. case double(Double) + /// A 32-bit floating-point value. + case float(Float) /// A boolean value. case bool(Bool) + /// A UUID value. + case uuid(UUID) + /// A date and time value. + case date(Date) + /// A binary value. + case data(Data) + /// A JSON document encoded as UTF-8. + case json(Data) } diff --git a/Sources/FeatherDatabase/DatabaseRow.swift b/Sources/FeatherDatabase/DatabaseRow.swift index 9a708b0..ff074f1 100644 --- a/Sources/FeatherDatabase/DatabaseRow.swift +++ b/Sources/FeatherDatabase/DatabaseRow.swift @@ -10,6 +10,17 @@ /// Conforming types provide type-safe decoding for column values. public protocol DatabaseRow: Sendable { + /// The names of the columns available in this row. + var columns: [String] { get } + + /// Returns whether this row contains a column with the given name. + func contains(column: String) -> Bool + + /// Returns whether the given column is SQL `NULL`. + /// + /// Missing columns should throw `DecodingError.keyNotFound`. + func decodeNil(column: String) throws(DecodingError) -> Bool + /// Decode a column value as the given type. /// /// This method converts the column value into a `Decodable` type. @@ -23,3 +34,14 @@ public protocol DatabaseRow: Sendable { as: T.Type ) throws(DecodingError) -> T } + +extension DatabaseRow { + + /// Decode this row into a flat `Decodable` value. + public func decode( + model type: T.Type, + with decoder: DatabaseRowDecoder = .init() + ) throws -> T { + try decoder.decode(type, from: self) + } +} diff --git a/Sources/FeatherDatabase/DatabaseRowDecoder.swift b/Sources/FeatherDatabase/DatabaseRowDecoder.swift new file mode 100644 index 0000000..c6e395e --- /dev/null +++ b/Sources/FeatherDatabase/DatabaseRowDecoder.swift @@ -0,0 +1,393 @@ +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +/// Decodes flat `Decodable` values from database rows. +public struct DatabaseRowDecoder: Sendable { + + public enum KeyDecodingStrategy: Sendable { + case useDefaultKeys + case convertFromSnakeCase + case custom(@Sendable ([any CodingKey]) -> any CodingKey) + } + + public var prefix: String? + public var keyDecodingStrategy: KeyDecodingStrategy + public var userInfo: [CodingUserInfoKey: any Sendable] + + public init( + prefix: String? = nil, + keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: any Sendable] = [:] + ) { + self.prefix = prefix + self.keyDecodingStrategy = keyDecodingStrategy + self.userInfo = userInfo + } + + public func decode( + _ type: T.Type, + from row: some DatabaseRow + ) throws -> T { + try T(from: RowDecoder(configuration: self, row: row)) + } + + fileprivate func columnName(for key: String) -> String { + let transformed: String + switch keyDecodingStrategy { + case .useDefaultKeys: + transformed = key + case .convertFromSnakeCase: + transformed = key.convertedToSnakeCase + case .custom(let transform): + transformed = + transform([DatabaseCodingKey(stringValue: key)]).stringValue + } + return "\(prefix ?? "")\(transformed)" + } + + fileprivate func keyName(for column: String) -> String { + let column = + prefix.flatMap { + column.hasPrefix($0) ? String(column.dropFirst($0.count)) : nil + } ?? column + switch keyDecodingStrategy { + case .useDefaultKeys, .custom: + return column + case .convertFromSnakeCase: + return column.convertedFromSnakeCase + } + } +} + +extension String { + fileprivate var convertedToSnakeCase: String { + guard !isEmpty else { return self } + var words: [Range] = [] + var wordStart = startIndex + var searchIndex = index(after: wordStart) + + while let upperIndex = self[searchIndex...] + .firstIndex(where: \.isUppercase) + { + words.append(wordStart.. first && self[last] == "_" + let leading = self[.. 1 else { + return "\(leading)\(self[first...last])\(trailing)" + } + return + "\(leading)\(words[0].lowercased())\(words.dropFirst().map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined())\(trailing)" + } +} + +private struct RowDecoder: Decoder { + let configuration: DatabaseRowDecoder + let row: Row + var codingPath: [any CodingKey] = [] + + var userInfo: [CodingUserInfoKey: Any] { + configuration.userInfo + } + + func container(keyedBy type: Key.Type) throws + -> KeyedDecodingContainer + { + guard codingPath.isEmpty else { + throw DecodingError.typeMismatch( + KeyedDecodingContainer.self, + .init( + codingPath: codingPath, + debugDescription: + "Nested keyed values are not supported when decoding a flat database row." + ) + ) + } + return .init(RowKeyedDecodingContainer(decoder: self)) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + throw DecodingError.typeMismatch( + (any UnkeyedDecodingContainer).self, + .init( + codingPath: codingPath, + debugDescription: + "Unkeyed values are not supported when decoding a flat database row." + ) + ) + } + + func singleValueContainer() throws -> any SingleValueDecodingContainer { + RowSingleValueDecodingContainer(decoder: self) + } +} + +private struct RowKeyedDecodingContainer: + KeyedDecodingContainerProtocol +{ + let decoder: RowDecoder + var codingPath: [any CodingKey] { decoder.codingPath } + + var allKeys: [Key] { + decoder.row.columns + .map { decoder.configuration.keyName(for: $0) } + .compactMap(Key.init(stringValue:)) + } + + func contains(_ key: Key) -> Bool { + decoder.row.contains( + column: decoder.configuration.columnName(for: key.stringValue) + ) + } + + func decodeNil(forKey key: Key) throws -> Bool { + let column = decoder.configuration.columnName(for: key.stringValue) + guard decoder.row.contains(column: column) else { return true } + do { + return try decoder.row.decodeNil(column: column) + } + catch { + throw error.withCodingPath(codingPath + [key]) + } + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T { + let column = decoder.configuration.columnName(for: key.stringValue) + guard decoder.row.contains(column: column) else { + throw DecodingError.keyNotFound( + key, + .init( + codingPath: codingPath, + debugDescription: + "No value for key \"\(key.stringValue)\" (column \"\(column)\")." + ) + ) + } + do { + return try decoder.row.decode(column: column, as: type) + } + catch { + throw error.withCodingPath(codingPath + [key]) + } + } + + func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: Key + ) throws -> KeyedDecodingContainer { + throw DecodingError.typeMismatch( + KeyedDecodingContainer.self, + .init( + codingPath: codingPath + [key], + debugDescription: + "Nested keyed values are not supported when decoding a flat database row." + ) + ) + } + + func nestedUnkeyedContainer(forKey key: Key) throws + -> any UnkeyedDecodingContainer + { + throw DecodingError.typeMismatch( + (any UnkeyedDecodingContainer).self, + .init( + codingPath: codingPath + [key], + debugDescription: + "Nested unkeyed values are not supported when decoding a flat database row." + ) + ) + } + + func superDecoder() throws -> any Decoder { + throw DecodingError.typeMismatch( + (any Decoder).self, + .init( + codingPath: codingPath, + debugDescription: + "A key is required to decode a flat database row." + ) + ) + } + + func superDecoder(forKey key: Key) throws -> any Decoder { + RowDecoder( + configuration: decoder.configuration, + row: decoder.row, + codingPath: codingPath + [key] + ) + } +} + +private struct RowSingleValueDecodingContainer: + SingleValueDecodingContainer +{ + let decoder: RowDecoder + var codingPath: [any CodingKey] { decoder.codingPath } + + func decodeNil() -> Bool { + guard let key = codingPath.last else { return false } + return + (try? decoder.row.decodeNil( + column: decoder.configuration.columnName(for: key.stringValue) + )) ?? false + } + + func decode(_ type: T.Type) throws -> T { + guard let key = codingPath.last else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: codingPath, + debugDescription: + "A flat database row cannot be decoded as a single value." + ) + ) + } + let column = decoder.configuration.columnName(for: key.stringValue) + do { + return try decoder.row.decode(column: column, as: type) + } + catch { + throw error.withCodingPath(codingPath) + } + } + + func decode(_: Bool.Type) throws -> Bool { + throw unsupportedScalar(Bool.self) + } + func decode(_: String.Type) throws -> String { + throw unsupportedScalar(String.self) + } + func decode(_: Double.Type) throws -> Double { + throw unsupportedScalar(Double.self) + } + func decode(_: Float.Type) throws -> Float { + throw unsupportedScalar(Float.self) + } + func decode(_: Int.Type) throws -> Int { throw unsupportedScalar(Int.self) } + func decode(_: Int8.Type) throws -> Int8 { + throw unsupportedScalar(Int8.self) + } + func decode(_: Int16.Type) throws -> Int16 { + throw unsupportedScalar(Int16.self) + } + func decode(_: Int32.Type) throws -> Int32 { + throw unsupportedScalar(Int32.self) + } + func decode(_: Int64.Type) throws -> Int64 { + throw unsupportedScalar(Int64.self) + } + func decode(_: UInt.Type) throws -> UInt { + throw unsupportedScalar(UInt.self) + } + func decode(_: UInt8.Type) throws -> UInt8 { + throw unsupportedScalar(UInt8.self) + } + func decode(_: UInt16.Type) throws -> UInt16 { + throw unsupportedScalar(UInt16.self) + } + func decode(_: UInt32.Type) throws -> UInt32 { + throw unsupportedScalar(UInt32.self) + } + func decode(_: UInt64.Type) throws -> UInt64 { + throw unsupportedScalar(UInt64.self) + } + + private func unsupportedScalar(_ type: T.Type) -> DecodingError { + .typeMismatch( + type, + .init( + codingPath: codingPath, + debugDescription: + "A flat database row must be decoded as a keyed value." + ) + ) + } +} + +private struct DatabaseCodingKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + + init(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} + +extension DecodingError { + fileprivate func withCodingPath(_ path: [any CodingKey]) -> DecodingError { + switch self { + case .typeMismatch(let type, let context): + .typeMismatch( + type, + .init( + codingPath: path, + debugDescription: context.debugDescription, + underlyingError: context.underlyingError + ) + ) + case .valueNotFound(let type, let context): + .valueNotFound( + type, + .init( + codingPath: path, + debugDescription: context.debugDescription, + underlyingError: context.underlyingError + ) + ) + case .keyNotFound(let key, let context): + .keyNotFound( + key, + .init( + codingPath: path, + debugDescription: context.debugDescription, + underlyingError: context.underlyingError + ) + ) + case .dataCorrupted(let context): + .dataCorrupted( + .init( + codingPath: path, + debugDescription: context.debugDescription, + underlyingError: context.underlyingError + ) + ) + @unknown default: + .dataCorrupted( + .init( + codingPath: path, + debugDescription: String(describing: self) + ) + ) + } + } +} diff --git a/Sources/FeatherDatabase/DatabaseRowSequence.swift b/Sources/FeatherDatabase/DatabaseRowSequence.swift index 54af26d..9b08067 100644 --- a/Sources/FeatherDatabase/DatabaseRowSequence.swift +++ b/Sources/FeatherDatabase/DatabaseRowSequence.swift @@ -26,3 +26,35 @@ where /// - Returns: An array of all rows produced by the query. func collect() async throws -> [Element] } + +extension DatabaseRowSequence { + + /// Returns the first row without collecting the rest of the result sequence. + public func first() async throws -> Element? { + for try await row in self { + return row + } + return nil + } + + /// Decodes the first row without collecting the rest of the result sequence. + public func first( + decoding type: T.Type, + with decoder: DatabaseRowDecoder = .init() + ) async throws -> T? { + guard let row = try await first() else { return nil } + return try row.decode(model: type, with: decoder) + } + + /// Decodes each row as a flat `Decodable` value while consuming the sequence. + public func decode( + model type: T.Type, + with decoder: DatabaseRowDecoder = .init() + ) async throws -> [T] { + var result: [T] = [] + for try await row in self { + result.append(try row.decode(model: type, with: decoder)) + } + return result + } +} diff --git a/Sources/FeatherDatabase/DatabaseTransactionError.swift b/Sources/FeatherDatabase/DatabaseTransactionError.swift index 522b0b7..505d50e 100644 --- a/Sources/FeatherDatabase/DatabaseTransactionError.swift +++ b/Sources/FeatherDatabase/DatabaseTransactionError.swift @@ -21,17 +21,17 @@ public protocol DatabaseTransactionError: Error { /// The error thrown while beginning the transaction. /// /// This is set when the begin step fails. - var beginError: Error? { get } + var beginError: (any Error)? { get } /// The error thrown inside the transaction closure. /// /// This is set when the closure fails before commit. - var closureError: Error? { get } + var closureError: (any Error)? { get } /// The error thrown while committing the transaction. /// /// This is set when the commit step fails. - var commitError: Error? { get } + var commitError: (any Error)? { get } /// The error thrown while rolling back the transaction. /// /// This is set when the rollback step fails. - var rollbackError: Error? { get } + var rollbackError: (any Error)? { get } } diff --git a/Sources/Testing/DatabaseConnection+Mock.swift b/Sources/Testing/DatabaseConnection+Mock.swift new file mode 100644 index 0000000..187de38 --- /dev/null +++ b/Sources/Testing/DatabaseConnection+Mock.swift @@ -0,0 +1,92 @@ +public import FeatherDatabase + +public struct MockDatabaseConnection: DatabaseConnection +{ + public typealias RowSequence = MockDatabaseRowSequence + + public struct Response: Sendable { + public let key: Key + public let rows: [MockDatabaseRow] + + public init(key: Key, rows: [MockDatabaseRow] = []) { + self.key = key + self.rows = rows + } + } + + private let state: State + + public init(responses: [Response]) { + self.state = State(responses: responses) + } + + public func run( + query: DatabaseQuery, + _ handler: (RowSequence) async throws -> T + ) async throws(DatabaseError) -> T { + let rows: [MockDatabaseRow] + do { + rows = try await state.recordAndNextResponse(for: query) + } + catch { + throw .query(error) + } + do { + return try await handler(.init(rows: rows)) + } + catch let error as DatabaseError { + throw error + } + catch { + throw .query(error) + } + } + + public func executedQueries() async -> [Key: [DatabaseQuery]] { + await state.executedQueries() + } + + public func assertAllResponsesConsumed() async throws { + try await state.assertAllResponsesConsumed() + } + + private actor State { + private var responses: [Response] + private var queries: [Key: [DatabaseQuery]] = [:] + + init(responses: [Response]) { + self.responses = responses + } + + func recordAndNextResponse(for query: DatabaseQuery) throws + -> [MockDatabaseRow] + { + guard !responses.isEmpty else { + throw UnexpectedQueryError( + message: "Received an unexpected query: \(query.sql)" + ) + } + let response = responses.removeFirst() + queries[response.key, default: []].append(query) + return response.rows + } + + func executedQueries() -> [Key: [DatabaseQuery]] { queries } + + func assertAllResponsesConsumed() throws { + guard !responses.isEmpty else { return } + let keys = responses.map { String(reflecting: $0.key) } + .joined(separator: ", ") + throw UnexpectedQueryError( + message: "Expected query responses were not consumed: \(keys)" + ) + } + } + + private struct UnexpectedQueryError: Error, Sendable, + CustomStringConvertible + { + let message: String + var description: String { message } + } +} diff --git a/Sources/Testing/DatabaseQueryKey.swift b/Sources/Testing/DatabaseQueryKey.swift new file mode 100644 index 0000000..b3ecccb --- /dev/null +++ b/Sources/Testing/DatabaseQueryKey.swift @@ -0,0 +1,2 @@ +/// A stable, unique test key used to identify an executed database query. +public protocol DatabaseQueryKey: Hashable, Sendable {} diff --git a/Sources/Testing/DatabaseRow+Mock.swift b/Sources/Testing/DatabaseRow+Mock.swift new file mode 100644 index 0000000..21f318d --- /dev/null +++ b/Sources/Testing/DatabaseRow+Mock.swift @@ -0,0 +1,112 @@ +public import FeatherDatabase + +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else +public import Foundation +#endif + +public struct MockDatabaseRow: DatabaseRow { + public enum Value: Sendable { + case string(String) + case int(Int) + case int32(Int32) + case double(Double) + case float(Float) + case bool(Bool) + case uuid(UUID) + case date(Date) + case data(Data) + case null + } + + private struct Key: CodingKey { + let stringValue: String + var intValue: Int? { nil } + + init(_ value: String) { stringValue = value } + init?(stringValue: String) { self.init(stringValue) } + init?(intValue: Int) { return nil } + } + + private let values: [String: Value] + + public init(_ values: [String: Value]) { + self.values = values + } + + public var columns: [String] { Array(values.keys).sorted() } + + public func contains(column: String) -> Bool { + values[column] != nil + } + + public func decodeNil(column: String) throws(DecodingError) -> Bool { + guard let value = values[column] else { + throw missingColumn(column) + } + if case .null = value { return true } + return false + } + + public func decode( + column: String, + as: T.Type + ) throws(DecodingError) -> T { + guard let value = values[column] else { + throw missingColumn(column) + } + if case .null = value { + do { + return try JSONDecoder().decode(T.self, from: Data("null".utf8)) + } + catch { + throw DecodingError.valueNotFound( + T.self, + .init( + codingPath: [Key(column)], + debugDescription: "Mock column is SQL NULL." + ) + ) + } + } + guard let decoded = value.decode(as: T.self) else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [Key(column)], + debugDescription: + "Mock value cannot be decoded as the requested type." + ) + ) + } + return decoded + } + + private func missingColumn(_ column: String) -> DecodingError { + .keyNotFound( + Key(column), + .init( + codingPath: [], + debugDescription: "Missing mock column: \(column)" + ) + ) + } +} + +extension MockDatabaseRow.Value { + fileprivate func decode(as type: T.Type) -> T? { + switch self { + case .string(let value): value as? T + case .int(let value): value as? T + case .int32(let value): value as? T + case .double(let value): value as? T + case .float(let value): value as? T + case .bool(let value): value as? T + case .uuid(let value): value as? T + case .date(let value): value as? T + case .data(let value): value as? T + case .null: nil + } + } +} diff --git a/Sources/Testing/DatabaseRowSequence+Mock.swift b/Sources/Testing/DatabaseRowSequence+Mock.swift new file mode 100644 index 0000000..d9d6e97 --- /dev/null +++ b/Sources/Testing/DatabaseRowSequence+Mock.swift @@ -0,0 +1,35 @@ +public import FeatherDatabase + +public struct MockDatabaseRowSequence: DatabaseRowSequence { + public typealias Row = MockDatabaseRow + public typealias Element = Row + + private let rows: [MockDatabaseRow] + + public init(rows: [MockDatabaseRow] = []) { + self.rows = rows + } + + public struct Iterator: AsyncIteratorProtocol { + private let rows: [MockDatabaseRow] + private var index = 0 + + fileprivate init(rows: [MockDatabaseRow]) { + self.rows = rows + } + + public mutating func next() async -> MockDatabaseRow? { + guard index < rows.count else { return nil } + defer { index += 1 } + return rows[index] + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(rows: rows) + } + + public func collect() async throws(DatabaseError) -> [MockDatabaseRow] { + rows + } +} diff --git a/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift b/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift index 5b9a168..ff62717 100644 --- a/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift +++ b/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift @@ -5,6 +5,7 @@ // Created by Tibor Bödecs on 2026. 01. 10.. // +import Foundation import Testing @testable import FeatherDatabase @@ -79,14 +80,14 @@ struct FeatherDatabaseTestSuite { } @Test - func collectFirstReturnsFirstRow() async throws { + func firstReturnsFirstRow() async throws { let rows = [ MockDatabaseRow(storage: ["name": .string("alpha")]), MockDatabaseRow(storage: ["name": .string("beta")]), ] let result = MockDatabaseRowSequence(rows: rows) - let first = try await result.collect().first + let first = try await result.first() #expect(first != nil) #expect( @@ -95,14 +96,274 @@ struct FeatherDatabaseTestSuite { } @Test - func collectFirstReturnsNilWhenEmpty() async throws { + func firstReturnsNilWhenEmpty() async throws { let result = MockDatabaseRowSequence(rows: []) - let first = try await result.collect().first + let first = try await result.first() #expect(first == nil) } + @Test + func decodesFlatModelsWithSnakeCaseAndNulls() throws { + struct Account: Decodable, Equatable { + let displayName: String + let nickname: String? + let isActive: Bool + } + + let row = MockDatabaseRow(storage: [ + "display_name": .string("Feather"), + "nickname": .null, + "is_active": .bool(true), + ]) + + let account = try row.decode( + model: Account.self, + with: .init(keyDecodingStrategy: .convertFromSnakeCase) + ) + + #expect( + account + == Account( + displayName: "Feather", + nickname: nil, + isActive: true + ) + ) + } + + @Test + func decodesFlatModelsWithColumnPrefixAndCustomKeys() throws { + struct Account: Decodable, Equatable { + let name: String + } + struct UppercaseKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init(stringValue: String) { + self.stringValue = stringValue.uppercased() + } + init?(intValue: Int) { return nil } + } + + let row = MockDatabaseRow(storage: ["p_NAME": .string("Feather")]) + let decoder = DatabaseRowDecoder( + prefix: "p_", + keyDecodingStrategy: .custom { path in + UppercaseKey(stringValue: path.last!.stringValue) + } + ) + + #expect( + try row.decode(model: Account.self, with: decoder) + == Account(name: "Feather") + ) + } + + @Test + func decodesFlatDatabaseScalarTypesAndNullValues() throws { + struct Record: Decodable, Equatable { + let id: Int + let enabled: Bool + let score: Double + let title: String + let createdAt: Date + let payload: Data? + let nullableTitle: String? + let missingTitle: String? + } + + let createdAt = Date(timeIntervalSince1970: 1_760_000_000) + let payload = Data([0x00, 0x2A, 0xFF]) + let row = MockDatabaseRow(storage: [ + "id": .int(42), + "enabled": .bool(true), + "score": .double(9.5), + "title": .string("Feather"), + "created_at": .date(createdAt), + "payload": .data(payload), + "nullable_title": .null, + ]) + + let record = try row.decode( + model: Record.self, + with: .init(keyDecodingStrategy: .convertFromSnakeCase) + ) + + #expect( + record + == Record( + id: 42, + enabled: true, + score: 9.5, + title: "Feather", + createdAt: createdAt, + payload: payload, + nullableTitle: nil, + missingTitle: nil + ) + ) + } + + @Test + func decodesFlatStringDictionaryWithPrefixAndSnakeCaseKeys() throws { + let row = MockDatabaseRow(storage: [ + "p_display_name": .string("Feather"), + "p_is_active": .string("true"), + ]) + + let values = try row.decode( + model: [String: String].self, + with: .init( + prefix: "p_", + keyDecodingStrategy: .convertFromSnakeCase + ) + ) + + #expect(values == ["displayName": "Feather", "isActive": "true"]) + } + + @Test + func decoderPassesUserInfoToDecodableValues() throws { + struct ContextValue: Decodable, Equatable { + let source: String + + init(from decoder: any Decoder) throws { + let key = CodingUserInfoKey(rawValue: "source")! + source = decoder.userInfo[key] as? String ?? "missing" + } + } + + let row = MockDatabaseRow(storage: [:]) + let value = try row.decode( + model: ContextValue.self, + with: .init(userInfo: [ + CodingUserInfoKey(rawValue: "source")!: "database-row" + ]) + ) + + #expect(value.source == "database-row") + } + + @Test + func decodeNilErrorsIncludeTheModelCodingPath() throws { + struct NullableValue: Decodable { + let name: String? + } + + struct FailingRow: DatabaseRow { + var columns: [String] { ["name"] } + func contains(column: String) -> Bool { column == "name" } + func decodeNil(column: String) throws(DecodingError) -> Bool { + throw .valueNotFound( + Optional.self, + .init( + codingPath: [], + debugDescription: "Cannot inspect SQL NULL." + ) + ) + } + func decode(column: String, as: T.Type) + throws(DecodingError) -> T + { + throw .typeMismatch( + T.self, + .init( + codingPath: [], + debugDescription: "Unexpected decode call." + ) + ) + } + } + + let error = #expect(throws: DecodingError.self) { + try FailingRow().decode(model: NullableValue.self) + } + guard case .valueNotFound(_, let context) = error else { + Issue.record("Expected the row's decodeNil error.") + return + } + #expect(context.codingPath.map(\.stringValue) == ["name"]) + } + + @Test + func rowDecoderReportsMissingColumnAndTypeMismatchPaths() throws { + struct Required: Decodable { let name: String } + struct WrongType: Decodable { let age: String } + + let emptyRow = MockDatabaseRow(storage: [:]) + let missingError = #expect(throws: DecodingError.self) { + try emptyRow.decode(model: Required.self) + } + guard + case .keyNotFound(let missingKey, let missingContext) = missingError + else { + Issue.record("Expected keyNotFound for a missing required column.") + return + } + #expect(missingKey.stringValue == "name") + #expect(missingContext.codingPath.isEmpty) + + let row = MockDatabaseRow(storage: ["age": .int(42)]) + let typeError = #expect(throws: DecodingError.self) { + try row.decode(model: WrongType.self) + } + guard case .typeMismatch(_, let typeContext) = typeError else { + Issue.record( + "Expected typeMismatch for a column with the wrong type." + ) + return + } + #expect(typeContext.codingPath.map(\.stringValue) == ["age"]) + } + + @Test + func rowDecoderRejectsNestedObjects() { + struct Address: Decodable { let city: String } + struct Account: Decodable { let address: Address } + + let row = MockDatabaseRow(storage: [ + "address": .string("not a nested object") + ]) + #expect(throws: DecodingError.self) { + try row.decode(model: Account.self) + } + } + + @Test + func sequenceDecodesModels() async throws { + struct Account: Decodable, Equatable { let name: String } + let rows = MockDatabaseRowSequence(rows: [ + MockDatabaseRow(storage: ["name": .string("alpha")]), + MockDatabaseRow(storage: ["name": .string("beta")]), + ]) + + #expect( + try await rows.decode(model: Account.self) == [ + Account(name: "alpha"), Account(name: "beta"), + ] + ) + } + + @Test + func sequenceDecodesFirstModelWithoutCollectingAllRows() async throws { + struct Account: Decodable, Equatable { let name: String } + let rows = MockDatabaseRowSequence(rows: [ + MockDatabaseRow(storage: ["name": .string("alpha")]), + MockDatabaseRow(storage: ["name": .string("beta")]), + ]) + + #expect( + try await rows.first(decoding: Account.self) + == Account(name: "alpha") + ) + #expect( + try await MockDatabaseRowSequence(rows: []) + .first(decoding: Account.self) == nil + ) + } + @Test func queryInterpolation() async throws { let table = "foo" diff --git a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift index d076863..d51b511 100644 --- a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift +++ b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseClient.swift @@ -40,7 +40,7 @@ struct MockDatabaseClient: DatabaseClient { catch let error as DatabaseError { throw error } - catch let error as DatabaseTransactionError { + catch let error as any DatabaseTransactionError { throw .transaction(error) } catch { diff --git a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift index 3cb1a30..2b8bf7a 100644 --- a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift +++ b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift @@ -6,6 +6,7 @@ // import FeatherDatabase +import Foundation struct MockDatabaseRow: DatabaseRow { @@ -25,10 +26,32 @@ struct MockDatabaseRow: DatabaseRow { enum Value: Sendable { case int(Int) case string(String) + case bool(Bool) + case double(Double) + case date(Date) + case data(Data) + case null } let storage: [String: Value] + var columns: [String] { Array(storage.keys) } + + func contains(column: String) -> Bool { + storage[column] != nil + } + + func decodeNil(column: String) throws(DecodingError) -> Bool { + guard let value = storage[column] else { + throw DecodingError.keyNotFound( + ColumnKey(stringValue: column), + .init(codingPath: [], debugDescription: "Missing column value.") + ) + } + if case .null = value { return true } + return false + } + func decode( column: String, as: T.Type @@ -68,6 +91,58 @@ struct MockDatabaseRow: DatabaseRow { ) } return typed + case .bool(let value): + guard let typed = value as? T else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [key], + debugDescription: "Type mismatch for column value." + ) + ) + } + return typed + case .double(let value): + guard let typed = value as? T else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [key], + debugDescription: "Type mismatch for column value." + ) + ) + } + return typed + case .date(let value): + guard let typed = value as? T else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [key], + debugDescription: "Type mismatch for column value." + ) + ) + } + return typed + case .data(let value): + guard let typed = value as? T else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [key], + debugDescription: "Type mismatch for column value." + ) + ) + } + return typed + case .null: + throw DecodingError.valueNotFound( + T.self, + .init( + codingPath: [key], + debugDescription: "Column value is SQL NULL." + ) + ) } } } diff --git a/docker/tests/Dockerfile b/docker/tests/Dockerfile index 73be175..c1a783f 100644 --- a/docker/tests/Dockerfile +++ b/docker/tests/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:6.1 +FROM swift:6.3 WORKDIR /app @@ -6,6 +6,5 @@ COPY . ./ RUN swift package resolve RUN swift package clean -RUN swift package update CMD ["swift", "test", "--parallel", "--enable-code-coverage"]