From 98104ad741cf47bb345269586139bdd110eb93a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Wed, 14 Oct 2026 21:23:08 +0200 Subject: [PATCH 1/8] new decoder improvements --- Package.swift | 33 +-- Sources/FeatherDatabase/DatabaseError.swift | 6 +- Sources/FeatherDatabase/DatabaseRow.swift | 22 ++ .../FeatherDatabase/DatabaseRowDecoder.swift | 254 ++++++++++++++++++ .../FeatherDatabase/DatabaseRowSequence.swift | 32 +++ .../DatabaseTransactionError.swift | 8 +- .../FeatherDatabaseTestSuite.swift | 225 +++++++++++++++- .../Mocks/MockDatabaseClient.swift | 2 +- .../Mocks/MockDatabaseRow.swift | 45 ++++ docker/tests/Dockerfile | 3 +- 10 files changed, 597 insertions(+), 33 deletions(-) create mode 100644 Sources/FeatherDatabase/DatabaseRowDecoder.swift diff --git a/Package.swift b/Package.swift index 1e9d437..02c65d1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,26 +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", @@ -35,20 +31,19 @@ let package = Package( .library(name: "FeatherDatabase", targets: ["FeatherDatabase"]), ], dependencies: [ - // [docc-plugin-placeholder] ], targets: [ .target( name: "FeatherDatabase", dependencies: [], - swiftSettings: defaultSwiftSettings + swiftSettings: swiftSettings ), .testTarget( name: "FeatherDatabaseTests", dependencies: [ .target(name: "FeatherDatabase"), ], - swiftSettings: defaultSwiftSettings + swiftSettings: swiftSettings ), ] ) 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/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..0e7f49b --- /dev/null +++ b/Sources/FeatherDatabase/DatabaseRowDecoder.swift @@ -0,0 +1,254 @@ +import Foundation + +/// 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 + } + } +} + +private extension String { + 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 } +} + +private extension DecodingError { + 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/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift b/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift index 5b9a168..d6a1688 100644 --- a/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift +++ b/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift @@ -6,6 +6,7 @@ // import Testing +import Foundation @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,230 @@ 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..04b8638 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,28 @@ 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"] From 5d6417cb6441fabd3ea5b357a3bfc49e384d1ffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Mon, 19 Oct 2026 15:58:09 +0200 Subject: [PATCH 2/8] Prepare FeatherDatabase 1.0.0-rc.3 --- Package.swift | 10 +- README.md | 4 +- .../DatabaseQueryBinding.swift | 14 ++ .../FeatherDatabase/DatabaseRowDecoder.swift | 237 ++++++++++++++---- Sources/Testing/DatabaseConnection+Mock.swift | 92 +++++++ Sources/Testing/DatabaseQueryKey.swift | 2 + Sources/Testing/DatabaseRow+Mock.swift | 107 ++++++++ .../Testing/DatabaseRowSequence+Mock.swift | 35 +++ .../FeatherDatabaseTestSuite.swift | 94 +++++-- .../Mocks/MockDatabaseRow.swift | 40 ++- 10 files changed, 551 insertions(+), 84 deletions(-) create mode 100644 Sources/Testing/DatabaseConnection+Mock.swift create mode 100644 Sources/Testing/DatabaseQueryKey.swift create mode 100644 Sources/Testing/DatabaseRow+Mock.swift create mode 100644 Sources/Testing/DatabaseRowSequence+Mock.swift diff --git a/Package.swift b/Package.swift index 02c65d1..f01857b 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,6 @@ let swiftSettings: [SwiftSetting] = [ .enableExperimentalFeature("Lifetimes"), ] - let package = Package( name: "feather-database", platforms: [ @@ -29,6 +28,7 @@ let package = Package( ], products: [ .library(name: "FeatherDatabase", targets: ["FeatherDatabase"]), + .library(name: "FeatherDatabaseTesting", targets: ["FeatherDatabaseTesting"]), ], dependencies: [ ], @@ -38,6 +38,14 @@ let package = Package( dependencies: [], swiftSettings: swiftSettings ), + .target( + name: "FeatherDatabaseTesting", + dependencies: [ + .target(name: "FeatherDatabase"), + ], + path: "Sources/Testing", + swiftSettings: swiftSettings + ), .testTarget( name: "FeatherDatabaseTests", dependencies: [ diff --git a/README.md b/README.md index 28d522e..73bdf88 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 @@ -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/DatabaseQueryBinding.swift b/Sources/FeatherDatabase/DatabaseQueryBinding.swift index 0e8ed24..c70514e 100644 --- a/Sources/FeatherDatabase/DatabaseQueryBinding.swift +++ b/Sources/FeatherDatabase/DatabaseQueryBinding.swift @@ -5,14 +5,28 @@ // Created by Tibor Bödecs on 2026. 02. 07.. // +public import Foundation + /// 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/DatabaseRowDecoder.swift b/Sources/FeatherDatabase/DatabaseRowDecoder.swift index 0e7f49b..037ac34 100644 --- a/Sources/FeatherDatabase/DatabaseRowDecoder.swift +++ b/Sources/FeatherDatabase/DatabaseRowDecoder.swift @@ -38,13 +38,17 @@ public struct DatabaseRowDecoder: Sendable { case .convertFromSnakeCase: transformed = key.convertedToSnakeCase case .custom(let transform): - transformed = transform([DatabaseCodingKey(stringValue: key)]).stringValue + 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 + let column = + prefix.flatMap { + column.hasPrefix($0) ? String(column.dropFirst($0.count)) : nil + } ?? column switch keyDecodingStrategy { case .useDefaultKeys, .custom: return column @@ -54,17 +58,22 @@ public struct DatabaseRowDecoder: Sendable { } } -private extension String { - var convertedToSnakeCase: String { +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) { + while let upperIndex = self[searchIndex...] + .firstIndex(where: \.isUppercase) + { words.append(wordStart.. first && self[last] == "_" + repeat { formIndex(before: &last) } + while last > 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)" + guard words.count > 1 else { + return "\(leading)\(self[first...last])\(trailing)" + } + return + "\(leading)\(words[0].lowercased())\(words.dropFirst().map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined())\(trailing)" } } @@ -97,11 +116,17 @@ private struct RowDecoder: Decoder { configuration.userInfo } - func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer { + 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.") + .init( + codingPath: codingPath, + debugDescription: + "Nested keyed values are not supported when decoding a flat database row." + ) ) } return .init(RowKeyedDecodingContainer(decoder: self)) @@ -110,7 +135,11 @@ private struct RowDecoder: Decoder { 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.") + .init( + codingPath: codingPath, + debugDescription: + "Unkeyed values are not supported when decoding a flat database row." + ) ) } @@ -119,7 +148,9 @@ private struct RowDecoder: Decoder { } } -private struct RowKeyedDecodingContainer: KeyedDecodingContainerProtocol { +private struct RowKeyedDecodingContainer: + KeyedDecodingContainerProtocol +{ let decoder: RowDecoder var codingPath: [any CodingKey] { decoder.codingPath } @@ -130,7 +161,9 @@ private struct RowKeyedDecodingContainer: Keye } func contains(_ key: Key) -> Bool { - decoder.row.contains(column: decoder.configuration.columnName(for: key.stringValue)) + decoder.row.contains( + column: decoder.configuration.columnName(for: key.stringValue) + ) } func decodeNil(forKey key: Key) throws -> Bool { @@ -138,7 +171,8 @@ private struct RowKeyedDecodingContainer: Keye guard decoder.row.contains(column: column) else { return true } do { return try decoder.row.decodeNil(column: column) - } catch { + } + catch { throw error.withCodingPath(codingPath + [key]) } } @@ -148,83 +182,152 @@ private struct RowKeyedDecodingContainer: Keye guard decoder.row.contains(column: column) else { throw DecodingError.keyNotFound( key, - .init(codingPath: codingPath, debugDescription: "No value for key \"\(key.stringValue)\" (column \"\(column)\").") + .init( + codingPath: codingPath, + debugDescription: + "No value for key \"\(key.stringValue)\" (column \"\(column)\")." + ) ) } do { return try decoder.row.decode(column: column, as: type) - } catch { + } + catch { throw error.withCodingPath(codingPath + [key]) } } - func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer { + 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.") + .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 { + 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.") + .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.") + .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]) + RowDecoder( + configuration: decoder.configuration, + row: decoder.row, + codingPath: codingPath + [key] + ) } } -private struct RowSingleValueDecodingContainer: SingleValueDecodingContainer { +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 + 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.") + .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 { + } + 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(_: 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) } + 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.")) + .typeMismatch( + type, + .init( + codingPath: codingPath, + debugDescription: + "A flat database row must be decoded as a keyed value." + ) + ) } } @@ -236,19 +339,51 @@ private struct DatabaseCodingKey: CodingKey { init?(intValue: Int) { return nil } } -private extension DecodingError { - func withCodingPath(_ path: [any CodingKey]) -> DecodingError { +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)) + .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)) + .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)) + .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)) + .dataCorrupted( + .init( + codingPath: path, + debugDescription: context.debugDescription, + underlyingError: context.underlyingError + ) + ) @unknown default: - .dataCorrupted(.init(codingPath: path, debugDescription: String(describing: self))) + .dataCorrupted( + .init( + codingPath: path, + debugDescription: String(describing: self) + ) + ) } } } 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..929a8ba --- /dev/null +++ b/Sources/Testing/DatabaseRow+Mock.swift @@ -0,0 +1,107 @@ +public import FeatherDatabase +public import Foundation + +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 d6a1688..ff62717 100644 --- a/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift +++ b/Tests/FeatherDatabaseTests/FeatherDatabaseTestSuite.swift @@ -5,8 +5,8 @@ // Created by Tibor Bödecs on 2026. 01. 10.. // -import Testing import Foundation +import Testing @testable import FeatherDatabase @@ -123,7 +123,14 @@ struct FeatherDatabaseTestSuite { with: .init(keyDecodingStrategy: .convertFromSnakeCase) ) - #expect(account == Account(displayName: "Feather", nickname: nil, isActive: true)) + #expect( + account + == Account( + displayName: "Feather", + nickname: nil, + isActive: true + ) + ) } @Test @@ -134,7 +141,9 @@ struct FeatherDatabaseTestSuite { struct UppercaseKey: CodingKey { let stringValue: String let intValue: Int? = nil - init(stringValue: String) { self.stringValue = stringValue.uppercased() } + init(stringValue: String) { + self.stringValue = stringValue.uppercased() + } init?(intValue: Int) { return nil } } @@ -146,7 +155,10 @@ struct FeatherDatabaseTestSuite { } ) - #expect(try row.decode(model: Account.self, with: decoder) == Account(name: "Feather")) + #expect( + try row.decode(model: Account.self, with: decoder) + == Account(name: "Feather") + ) } @Test @@ -179,16 +191,19 @@ struct FeatherDatabaseTestSuite { with: .init(keyDecodingStrategy: .convertFromSnakeCase) ) - #expect(record == Record( - id: 42, - enabled: true, - score: 9.5, - title: "Feather", - createdAt: createdAt, - payload: payload, - nullableTitle: nil, - missingTitle: nil - )) + #expect( + record + == Record( + id: 42, + enabled: true, + score: 9.5, + title: "Feather", + createdAt: createdAt, + payload: payload, + nullableTitle: nil, + missingTitle: nil + ) + ) } @Test @@ -200,7 +215,10 @@ struct FeatherDatabaseTestSuite { let values = try row.decode( model: [String: String].self, - with: .init(prefix: "p_", keyDecodingStrategy: .convertFromSnakeCase) + with: .init( + prefix: "p_", + keyDecodingStrategy: .convertFromSnakeCase + ) ) #expect(values == ["displayName": "Feather", "isActive": "true"]) @@ -220,7 +238,9 @@ struct FeatherDatabaseTestSuite { let row = MockDatabaseRow(storage: [:]) let value = try row.decode( model: ContextValue.self, - with: .init(userInfo: [CodingUserInfoKey(rawValue: "source")!: "database-row"]) + with: .init(userInfo: [ + CodingUserInfoKey(rawValue: "source")!: "database-row" + ]) ) #expect(value.source == "database-row") @@ -238,13 +258,21 @@ struct FeatherDatabaseTestSuite { func decodeNil(column: String) throws(DecodingError) -> Bool { throw .valueNotFound( Optional.self, - .init(codingPath: [], debugDescription: "Cannot inspect SQL NULL.") + .init( + codingPath: [], + debugDescription: "Cannot inspect SQL NULL." + ) ) } - func decode(column: String, as: T.Type) throws(DecodingError) -> T { + func decode(column: String, as: T.Type) + throws(DecodingError) -> T + { throw .typeMismatch( T.self, - .init(codingPath: [], debugDescription: "Unexpected decode call.") + .init( + codingPath: [], + debugDescription: "Unexpected decode call." + ) ) } } @@ -268,7 +296,9 @@ struct FeatherDatabaseTestSuite { let missingError = #expect(throws: DecodingError.self) { try emptyRow.decode(model: Required.self) } - guard case .keyNotFound(let missingKey, let missingContext) = missingError else { + guard + case .keyNotFound(let missingKey, let missingContext) = missingError + else { Issue.record("Expected keyNotFound for a missing required column.") return } @@ -280,7 +310,9 @@ struct FeatherDatabaseTestSuite { try row.decode(model: WrongType.self) } guard case .typeMismatch(_, let typeContext) = typeError else { - Issue.record("Expected typeMismatch for a column with the wrong type.") + Issue.record( + "Expected typeMismatch for a column with the wrong type." + ) return } #expect(typeContext.codingPath.map(\.stringValue) == ["age"]) @@ -291,7 +323,9 @@ struct FeatherDatabaseTestSuite { struct Address: Decodable { let city: String } struct Account: Decodable { let address: Address } - let row = MockDatabaseRow(storage: ["address": .string("not a nested object")]) + let row = MockDatabaseRow(storage: [ + "address": .string("not a nested object") + ]) #expect(throws: DecodingError.self) { try row.decode(model: Account.self) } @@ -305,7 +339,11 @@ struct FeatherDatabaseTestSuite { MockDatabaseRow(storage: ["name": .string("beta")]), ]) - #expect(try await rows.decode(model: Account.self) == [Account(name: "alpha"), Account(name: "beta")]) + #expect( + try await rows.decode(model: Account.self) == [ + Account(name: "alpha"), Account(name: "beta"), + ] + ) } @Test @@ -316,8 +354,14 @@ struct FeatherDatabaseTestSuite { 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) + #expect( + try await rows.first(decoding: Account.self) + == Account(name: "alpha") + ) + #expect( + try await MockDatabaseRowSequence(rows: []) + .first(decoding: Account.self) == nil + ) } @Test diff --git a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift index 04b8638..2b8bf7a 100644 --- a/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift +++ b/Tests/FeatherDatabaseTests/Mocks/MockDatabaseRow.swift @@ -93,26 +93,56 @@ 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.")) + 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.")) + 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.")) + 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.")) + 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.")) + throw DecodingError.valueNotFound( + T.self, + .init( + codingPath: [key], + debugDescription: "Column value is SQL NULL." + ) + ) } } } From 818458f3474b7c05d4ac1b8b0e4e5579fce32803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Mon, 19 Oct 2026 16:00:30 +0200 Subject: [PATCH 3/8] Pin workflows and test Swift 6.3 and 6.4 --- .github/workflows/deployment.yml | 4 ++-- .github/workflows/testing.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) 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..597ab12 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -8,7 +8,7 @@ on: jobs: api_breakage: name: Check API breakage - uses: BinaryBirds/github-workflows/.github/workflows/api_breakage.yml@main + uses: BinaryBirds/github-workflows/.github/workflows/api_breakage.yml@0.2.0 swiftlang_checks: name: Swiftlang Checks @@ -27,10 +27,10 @@ jobs: bb_checks: name: BB Checks - uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main + uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@0.2.0 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"]' + run_tests_swift_versions: '["6.3", "6.4"]' From 42986411915fb35b774c02bbc8d858046eb5d006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Mon, 19 Oct 2026 16:03:51 +0200 Subject: [PATCH 4/8] Fix workflow API checks and Foundation imports --- .github/workflows/testing.yml | 6 +----- Sources/FeatherDatabase/DatabaseQueryBinding.swift | 4 ++++ Sources/FeatherDatabase/DatabaseRowDecoder.swift | 4 ++++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 597ab12..a22e5de 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -6,10 +6,6 @@ on: - main jobs: - api_breakage: - name: Check API breakage - uses: BinaryBirds/github-workflows/.github/workflows/api_breakage.yml@0.2.0 - swiftlang_checks: name: Swiftlang Checks uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main @@ -20,7 +16,7 @@ jobs: unacceptable_language_check_enabled: true shell_check_enabled: false docs_check_enabled: false - api_breakage_check_enabled: false + api_breakage_check_enabled: true license_header_check_enabled: false yamllint_check_enabled: false python_lint_check_enabled: false diff --git a/Sources/FeatherDatabase/DatabaseQueryBinding.swift b/Sources/FeatherDatabase/DatabaseQueryBinding.swift index c70514e..2b1bea4 100644 --- a/Sources/FeatherDatabase/DatabaseQueryBinding.swift +++ b/Sources/FeatherDatabase/DatabaseQueryBinding.swift @@ -5,7 +5,11 @@ // 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 { diff --git a/Sources/FeatherDatabase/DatabaseRowDecoder.swift b/Sources/FeatherDatabase/DatabaseRowDecoder.swift index 037ac34..c6e395e 100644 --- a/Sources/FeatherDatabase/DatabaseRowDecoder.swift +++ b/Sources/FeatherDatabase/DatabaseRowDecoder.swift @@ -1,4 +1,8 @@ +#if canImport(FoundationEssentials) +import FoundationEssentials +#else import Foundation +#endif /// Decodes flat `Decodable` values from database rows. public struct DatabaseRowDecoder: Sendable { From d55308ccbb6c3051dc38f6e7ac3f084567571b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Mon, 19 Oct 2026 16:06:44 +0200 Subject: [PATCH 5/8] Remove unsupported DocC workflow input --- .github/workflows/testing.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index a22e5de..41aeb95 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -28,5 +28,4 @@ jobs: 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.3", "6.4"]' From 89d2cb42dbc12ab71468e1b365ec05eb0f923466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Mon, 19 Oct 2026 16:07:36 +0200 Subject: [PATCH 6/8] Use Swiftlang test workflow --- .github/workflows/testing.yml | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 41aeb95..b780a19 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -6,26 +6,25 @@ on: - main jobs: - 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: true + 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@0.2.0 + 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 - run_tests_swift_versions: '["6.3", "6.4"]' + enable_windows_checks: false + linux_build_command: "swift test --parallel --enable-code-coverage" + linux_swift_versions: '["6.3", "6.4"]' From d0d67904dfdec8da73ca2e2cf0a765101b1d6e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Mon, 19 Oct 2026 16:11:20 +0200 Subject: [PATCH 7/8] Document Swift 6.3 minimum and use FoundationEssentials --- README.md | 4 ++-- Sources/Testing/DatabaseRow+Mock.swift | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 73bdf88..ed7eb1e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Testing/DatabaseRow+Mock.swift b/Sources/Testing/DatabaseRow+Mock.swift index 929a8ba..9ce14d5 100644 --- a/Sources/Testing/DatabaseRow+Mock.swift +++ b/Sources/Testing/DatabaseRow+Mock.swift @@ -1,5 +1,9 @@ public import FeatherDatabase +#if canImport(FoundationEssentials) +public import FoundationEssentials +#else public import Foundation +#endif public struct MockDatabaseRow: DatabaseRow { public enum Value: Sendable { From 888a8a326829263f2841f0bb9d963a497118dd5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20B=C3=B6decs?= Date: Mon, 19 Oct 2026 16:25:31 +0200 Subject: [PATCH 8/8] Fix Swift formatting in database row mock --- Sources/Testing/DatabaseRow+Mock.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Testing/DatabaseRow+Mock.swift b/Sources/Testing/DatabaseRow+Mock.swift index 9ce14d5..21f318d 100644 --- a/Sources/Testing/DatabaseRow+Mock.swift +++ b/Sources/Testing/DatabaseRow+Mock.swift @@ -1,4 +1,5 @@ public import FeatherDatabase + #if canImport(FoundationEssentials) public import FoundationEssentials #else