diff --git a/Package.resolved b/Package.resolved index afe3c94..e76e062 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "9477039766900a876852733dc60c616e209d09ef46f39d32ca540a12c6ec7b4b", + "originHash" : "e50c3719c50b624936c95eb0ed73d05ef04316baae0ce0a39d0040afbc061d2b", "pins" : [ { "identity" : "feather-database", "kind" : "remoteSourceControl", "location" : "https://github.com/feather-framework/feather-database", "state" : { - "revision" : "8bd475b24dcf18b9b03534c99c5ccf626a8d38b9", - "version" : "1.0.0-beta.4" + "revision" : "55b08d7bd028c7eddbd231efaaaa0d38e78c0b9b", + "version" : "1.0.0-beta.5" } }, { diff --git a/Package.swift b/Package.swift index 9bdbd56..4a683fb 100644 --- a/Package.swift +++ b/Package.swift @@ -46,7 +46,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/vapor/sqlite-nio", from: "1.12.0"), .package(url: "https://github.com/swift-server/swift-service-lifecycle", from: "2.8.0"), - .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.4"), + .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.5"), // [docc-plugin-placeholder] ], targets: [ diff --git a/README.md b/README.md index 805357e..b78bf11 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ SQLite driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package. [ - ![Release: 1.0.0-beta.5](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E5-F05138) + ![Release: 1.0.0-beta.6](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E6-F05138) ]( - https://github.com/feather-framework/feather-sqlite-database/releases/tag/1.0.0-beta.5 + https://github.com/feather-framework/feather-sqlite-database/releases/tag/1.0.0-beta.6 ) ## Features diff --git a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseConnection.swift b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseConnection.swift index 46e9490..c3d7528 100644 --- a/Sources/FeatherSQLiteDatabase/SQLiteDatabaseConnection.swift +++ b/Sources/FeatherSQLiteDatabase/SQLiteDatabaseConnection.swift @@ -27,6 +27,8 @@ extension DatabaseQuery { .replacing("{{\(idx)}}", with: "?") switch binding.binding { + case .bool(let value): + sqliteBindings.append(.integer(value ? 1 : 0)) case .int(let value): sqliteBindings.append(.integer(value)) case .double(let value): diff --git a/Tests/FeatherSQLiteDatabaseTests/FeatherSQLiteDatabaseTestSuite.swift b/Tests/FeatherSQLiteDatabaseTests/FeatherSQLiteDatabaseTestSuite.swift index 0106e5c..2acdbce 100644 --- a/Tests/FeatherSQLiteDatabaseTests/FeatherSQLiteDatabaseTestSuite.swift +++ b/Tests/FeatherSQLiteDatabaseTests/FeatherSQLiteDatabaseTestSuite.swift @@ -16,7 +16,7 @@ import Testing @Suite struct FeatherSQLiteDatabaseTestSuite { - private func runUsingTestDatabaseClient( + func runUsingTestDatabaseClient( _ closure: ((SQLiteDatabaseClient) async throws -> Void) ) async throws { var logger = Logger(label: "test") @@ -35,8 +35,14 @@ struct FeatherSQLiteDatabaseTestSuite { ) try await client.run() - try await closure(database) - await client.shutdown() + do { + try await closure(database) + await client.shutdown() + } + catch { + await client.shutdown() + throw error + } } @Test @@ -382,6 +388,68 @@ struct FeatherSQLiteDatabaseTestSuite { } } + @Test + func booleanInterpolation() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + let result = try await connection.run( + query: #""" + SELECT + \#(true) AS "enabled", + \#(false) AS "disabled"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "enabled", as: Int.self) == 1 + ) + #expect( + try result[0].decode(column: "disabled", as: Int.self) == 0 + ) + } + } + } + + @Test + func booleanInterpolationInWhereClause() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "flags" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "is_enabled" INTEGER NOT NULL + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "flags" + ("id", "is_enabled") + VALUES + (1, 1), + (2, 0); + """# + ) + + let result = try await connection.run( + query: #""" + SELECT COUNT(*) AS "count" + FROM "flags" + WHERE "is_enabled" = \#(true); + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "count", as: Int.self) == 1 + ) + } + } + } + @Test func resultSequenceIterator() async throws { try await runUsingTestDatabaseClient { database in @@ -586,6 +654,35 @@ struct FeatherSQLiteDatabaseTestSuite { } } + @Test + func transactionClosureErrorPropagates() async throws { + try await runUsingTestDatabaseClient { database in + enum TestError: Error, Equatable { + case boom + } + + do { + _ = try await database.withTransaction { _ in + throw TestError.boom + } + Issue.record("Expected transaction error to be thrown.") + } + catch DatabaseError.transaction(let error) { + #expect(error.beginError == nil) + #expect(error.commitError == nil) + #expect(error.rollbackError == nil) + #expect((error.closureError as? TestError) == .boom) + #expect(error.file.isEmpty == false) + #expect(error.line > 0) + } + catch { + Issue.record( + "Expected database transaction error to be thrown." + ) + } + } + } + @Test func doubleRoundTrip() async throws { try await runUsingTestDatabaseClient { database in @@ -724,6 +821,260 @@ struct FeatherSQLiteDatabaseTestSuite { } } + @Test + func nullDecodingThrowsTypeMismatch() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "nullable_values" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" INTEGER + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "nullable_values" + ("id", "value") + VALUES + (1, NULL); + """# + ) + + let result = try await connection.run( + query: #""" + SELECT "value" + FROM "nullable_values"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + + do { + _ = try result[0].decode(column: "value", as: Int.self) + Issue.record("Expected decoding NULL as Int to throw.") + } + catch let DecodingError.typeMismatch(_, context) { + #expect( + context.debugDescription.contains("Could not convert") + ) + } + catch { + Issue.record( + "Expected a typeMismatch error when decoding NULL as Int." + ) + } + } + } + } + + @Test + func nonSQLiteDecodableTypeMismatch() async throws { + try await runUsingTestDatabaseClient { database in + struct CustomValue: Decodable, Sendable { + let value: String + } + + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE "custom_types" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "custom_types" + ("id", "value") + VALUES + (1, 'alpha'); + """# + ) + + let result = try await connection.run( + query: #""" + SELECT "value" + FROM "custom_types"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + + do { + _ = try result[0] + .decode( + column: "value", + as: CustomValue.self + ) + Issue.record( + "Expected decoding non-SQLiteDecodable type to throw." + ) + } + catch let DecodingError.typeMismatch(_, context) { + #expect( + context.debugDescription.contains( + "Keyed decoding is not supported." + ) + ) + } + catch { + Issue.record( + "Expected a typeMismatch error for non-SQLiteDecodable types." + ) + } + } + } + } + + @Test + func singleValueDecodingTypeMismatch() async throws { + try await runUsingTestDatabaseClient { database in + struct CustomValue: Decodable, Sendable { + let value: String + } + + struct Wrapper: Decodable, Sendable { + let value: CustomValue + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + value = try container.decode(CustomValue.self) + } + } + + try await database.withConnection { connection in + let result = try await connection.run( + query: #""" + SELECT 'abc' AS "value"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + + do { + _ = try result[0] + .decode( + column: "value", + as: Wrapper.self + ) + Issue.record( + "Expected single-value decoding to throw typeMismatch." + ) + } + catch let DecodingError.typeMismatch(_, context) { + #expect( + context.debugDescription.contains( + "Data is not convertible" + ) + ) + } + catch { + Issue.record( + "Expected a typeMismatch error for single-value decoding." + ) + } + } + } + } + + @Test + func nonDecodingErrorThrownFromDecodeIsMapped() async throws { + try await runUsingTestDatabaseClient { database in + enum TestError: Error { + case boom + } + + struct ThrowingValue: Decodable, Sendable { + init(from _: Decoder) throws { + throw TestError.boom + } + } + + try await database.withConnection { connection in + let result = try await connection.run( + query: #""" + SELECT 'abc' AS "value"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + + do { + _ = try result[0] + .decode( + column: "value", + as: ThrowingValue.self + ) + Issue.record( + "Expected non-DecodingError to map to typeMismatch." + ) + } + catch let DecodingError.typeMismatch(_, context) { + #expect( + context.debugDescription.contains( + "Data is not convertible" + ) + ) + } + catch { + Issue.record( + "Expected typeMismatch when decoding throws non-DecodingError." + ) + } + } + } + } + + @Test + func unkeyedRowDecodingThrowsTypeMismatch() async throws { + try await runUsingTestDatabaseClient { database in + struct UnkeyedValue: Decodable, Sendable { + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + _ = try container.decode(String.self) + } + } + + try await database.withConnection { connection in + let result = try await connection.run( + query: #""" + SELECT 'value' AS "value"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + + do { + _ = try result[0] + .decode( + column: "value", + as: UnkeyedValue.self + ) + Issue.record( + "Expected unkeyed decoding to throw a type mismatch." + ) + } + catch let DecodingError.typeMismatch(_, context) { + #expect( + context.debugDescription.contains( + "Unkeyed decoding is not supported." + ) + ) + } + catch { + Issue.record( + "Expected a typeMismatch error for unkeyed decoding." + ) + } + } + } + } + @Test func queryFailureErrorText() async throws { try await runUsingTestDatabaseClient { database in diff --git a/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseRowSequenceTests.swift b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseRowSequenceTests.swift new file mode 100644 index 0000000..6662a8c --- /dev/null +++ b/Tests/FeatherSQLiteDatabaseTests/SQLiteDatabaseRowSequenceTests.swift @@ -0,0 +1,154 @@ +// +// SQLiteDatabaseRowSequenceTests.swift +// feather-sqlite-database +// +// Created by Tibor Bödecs on 2026. 02. 09. +// + +import FeatherDatabase +import SQLiteNIO +import SQLiteNIOExtras +import Testing + +@testable import FeatherSQLiteDatabase + +extension FeatherSQLiteDatabaseTestSuite { + + @Test + func rowSequenceIteratesRowsInOrder() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE IF NOT EXISTS "planets" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "planets" ("id", "name") + VALUES + (1, 'Mercury'), + (2, 'Venus'); + """# + ) + + let sequence = try await connection.run( + query: #""" + SELECT * + FROM "planets" + ORDER BY "id" ASC; + """# + ) + + var iterator = sequence.makeAsyncIterator() + + let first = await iterator.next() + #expect(first != nil) + #expect( + try first?.decode(column: "name", as: String.self) + == "Mercury" + ) + + let second = await iterator.next() + #expect(second != nil) + #expect( + try second?.decode(column: "name", as: String.self) + == "Venus" + ) + + let third = await iterator.next() + #expect(third == nil) + } + } + } + + @Test + func rowSequenceCollectReturnsAllRows() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE IF NOT EXISTS "greetings" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "greetings" ("id", "name") + VALUES + (1, 'Hello'), + (2, 'World'); + """# + ) + + let sequence = try await connection.run( + query: #""" + SELECT + "id", + "name" + FROM "greetings" + ORDER BY "id" ASC; + """# + ) + + let rows = try await sequence.collect() + #expect(rows.count == 2) + + let firstName = try rows[0] + .decode( + column: "name", + as: String.self + ) + let secondName = try rows[1] + .decode( + column: "name", + as: String.self + ) + + #expect(firstName == "Hello") + #expect(secondName == "World") + } + } + } + + @Test + func rowSequenceHandlesEmptyResults() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + try await connection.run( + query: #""" + CREATE TABLE IF NOT EXISTS "empty_rows" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) + + let sequence = try await connection.run( + query: #""" + SELECT + "id", + "name" + FROM "empty_rows" + WHERE + 1=0; + """# + ) + + let rows = try await sequence.collect() + #expect(rows.isEmpty) + + var iterator = sequence.makeAsyncIterator() + let first = await iterator.next() + #expect(first == nil) + } + } + } +}