diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index ccd6cb0..3c06c40 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -40,22 +40,5 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Generate certificates - run: make test-certs - - name: Start Postgres Docker Container - run: docker compose up -d --build - - name: Wait For Postgres Docker Container - run: | - for i in {1..30}; do - if docker compose exec -T postgres pg_isready -U postgres -d postgres; then - exit 0 - fi - sleep 1 - done - docker compose logs postgres - exit 1 - - name: Run Tests - run: swift test --parallel - - name: Stop Postgres Docker Container - if: always() - run: docker compose down -v + - name: Run Docker Tests + run: make docker-test diff --git a/Makefile b/Makefile index 451dc41..74984a9 100644 --- a/Makefile +++ b/Makefile @@ -47,4 +47,4 @@ test: swift test --parallel docker-test: - docker build -t feather-database-postgres-tests . -f ./docker/tests/Dockerfile && docker run --rm feather-database-postgres-tests + make test-certs && docker compose up --build --abort-on-container-exit --exit-code-from test test diff --git a/Package.resolved b/Package.resolved index f441167..92dedf6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "fddef8e123ffd0eb2b495cf8a5ea0cd186b58473bda64972ec5461e410defef1", + "originHash" : "b8fbf14a005f63f486e18520087e8ea910d9145811a225424d0aa92a8896bd30", "pins" : [ { "identity" : "feather-database", "kind" : "remoteSourceControl", "location" : "https://github.com/feather-framework/feather-database", "state" : { - "revision" : "55b08d7bd028c7eddbd231efaaaa0d38e78c0b9b", - "version" : "1.0.0-beta.5" + "revision" : "0b862ac3ee31859e2c8a54b06390ee92d1a7c0b4", + "version" : "1.0.0-rc.1" } }, { diff --git a/Package.swift b/Package.swift index 22d4dd7..98b3c41 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), .package(url: "https://github.com/vapor/postgres-nio", from: "1.27.0"), - .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.5"), + .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-rc.1"), // [docc-plugin-placeholder] ], targets: [ diff --git a/README.md b/README.md index 6ee166f..c746dd9 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Postgres driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package. [ - ![Release: 1.0.0-beta.6](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E6-F05138) + ![Release: 1.0.0-rc.1](https://img.shields.io/badge/Release-1%2E0%2E0--rc%2E1-F05138) ]( - https://github.com/feather-framework/feather-database-postgres/releases/tag/1.0.0-beta.6 + https://github.com/feather-framework/feather-database-postgres/releases/tag/1.0.0-rc.1 ) ## Features @@ -37,7 +37,7 @@ Postgres driver implementation for the abstract [Feather Database](https://githu Add the dependency to your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-database-postgres", exact: "1.0.0-beta.6"), +.package(url: "https://github.com/feather-framework/feather-database-postgres", exact: "1.0.0-rc.1"), ``` Then add `FeatherDatabasePostgres` to your target dependencies: @@ -119,9 +119,6 @@ try await withThrowingTaskGroup(of: Void.self) { group in } ``` -> [!WARNING] -> This repository is a work in progress, things can break until it reaches v1.0.0. - ## Other database drivers The following database driver implementations are available for use: diff --git a/Sources/FeatherDatabasePostgres/DatabaseRowSequencePostgres.swift b/Sources/FeatherDatabasePostgres/DatabaseRowSequencePostgres.swift index 310679e..ecb2c0b 100644 --- a/Sources/FeatherDatabasePostgres/DatabaseRowSequencePostgres.swift +++ b/Sources/FeatherDatabasePostgres/DatabaseRowSequencePostgres.swift @@ -12,6 +12,7 @@ import PostgresNIO /// /// Use this type to iterate or collect Postgres query results. public struct DatabaseRowSequencePostgres: DatabaseRowSequence { + public typealias Row = DatabaseRowPostgres var backingSequence: PostgresRowSequence @@ -38,14 +39,14 @@ public struct DatabaseRowSequencePostgres: DatabaseRowSequence { return .init(row: postgresRow) } #else - public mutating func next() async throws -> PostgresRow? { + public mutating func next() async throws -> DatabaseRowPostgres? { guard !Task.isCancelled else { return nil } guard let postgresRow = try await backingIterator.next() else { return nil } - return postgresRow + return .init(row: postgresRow) } #endif } diff --git a/Tests/FeatherDatabasePostgresTests/FeatherDatabasePostgresTestSuite.swift b/Tests/FeatherDatabasePostgresTests/FeatherDatabasePostgresTestSuite.swift index 6d43b96..15e8713 100644 --- a/Tests/FeatherDatabasePostgresTests/FeatherDatabasePostgresTestSuite.swift +++ b/Tests/FeatherDatabasePostgresTests/FeatherDatabasePostgresTestSuite.swift @@ -7,6 +7,7 @@ import FeatherDatabase import Logging +import NIOPosix import NIOSSL import PostgresNIO import Testing @@ -39,7 +40,13 @@ struct FeatherDatabasePostgresTestSuite { var logger = Logger(label: "test") logger.logLevel = .info - let finalCertPath = URL(fileURLWithPath: #filePath) + let environment = ProcessInfo.processInfo.environment + + let finalCertPath = + environment["POSTGRES_CA_CERT_PATH"] + ?? URL( + fileURLWithPath: #filePath + ) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() @@ -49,37 +56,125 @@ struct FeatherDatabasePostgresTestSuite { .appendingPathComponent("ca.pem") .path() + let host = environment["POSTGRES_HOST"] ?? "127.0.0.1" + let port = environment["POSTGRES_PORT"].flatMap(Int.init) ?? 5432 + let testDatabaseName = "test_\(randomTableSuffix())" + var tlsConfig = TLSConfiguration.makeClientConfiguration() let rootCert = try NIOSSLCertificate.fromPEMFile(finalCertPath) tlsConfig.trustRoots = .certificates(rootCert) tlsConfig.certificateVerification = .fullVerification - - let client = PostgresClient( - configuration: .init( - host: "127.0.0.1", - port: 5432, - username: "postgres", - password: "postgres", - database: "postgres", - tls: .require(tlsConfig) - ), - backgroundLogger: logger + let clientTLSConfig = tlsConfig + let sslContext = try NIOSSLContext(configuration: tlsConfig) + + let createDatabaseQuery = PostgresQuery( + unsafeSQL: #""" + CREATE DATABASE "\#(testDatabaseName)" + """#, + binds: .init() ) - let database = DatabaseClientPostgres( - client: client, - logger: logger + let dropDatabaseQuery = PostgresQuery( + unsafeSQL: #""" + DROP DATABASE IF EXISTS "\#(testDatabaseName)" WITH (FORCE) + """#, + binds: .init() ) - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await client.run() + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + + do { + let rootConnection = + try await PostgresConnection.connect( + on: eventLoopGroup.next(), + configuration: .init( + host: host, + port: port, + username: "postgres", + password: "postgres", + database: "postgres", + tls: .require(sslContext) + ), + id: 1, + logger: logger + ) + + func cleanup() async { + do { + _ = + try await rootConnection + .query(dropDatabaseQuery, logger: logger) + .get() + } + catch { + // The temporary database may already be gone. + } + + do { + try await rootConnection.close().get() + } + catch { + // Ignore close failures during teardown. + } + + do { + try await eventLoopGroup.shutdownGracefully() + } + catch { + // Ignore shutdown failures during teardown. + } } - group.addTask { - try await closure(database) + + do { + _ = + try await rootConnection + .query(createDatabaseQuery, logger: logger) + .get() + } + catch { + await cleanup() + Issue.record(error) + return } - try await group.next() - group.cancelAll() + + let client = PostgresClient( + configuration: .init( + host: host, + port: port, + username: "postgres", + password: "postgres", + database: testDatabaseName, + tls: .require(clientTLSConfig) + ), + backgroundLogger: logger + ) + let database = DatabaseClientPostgres( + client: client, + logger: logger + ) + + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await client.run() + } + group.addTask { + try await closure(database) + } + _ = try await group.next() + group.cancelAll() + _ = try await group.next() + } + } + catch { + Issue.record(error) + } + + await cleanup() + } + catch { + try? await eventLoopGroup.shutdownGracefully() + Issue.record(error) } } @@ -557,6 +652,342 @@ struct FeatherDatabasePostgresTestSuite { } } + @Test + func boundOptionalInterpolationRoundTrip() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + let boundString: String? = "alpha" + let missingString: String? = nil + let boundInt: Int? = 21 + let missingInt: Int? = nil + let boundFloat: Float? = 1.25 + let missingFloat: Float? = nil + let boundDouble: Double? = 3.75 + let missingDouble: Double? = nil + let boundBool: Bool? = true + let missingBool: Bool? = nil + + struct OptionalRow: Sendable { + let boundString: String? + let missingString: String? + let boundInt: Int? + let missingInt: Int? + let boundFloat: Double? + let missingFloat: Double? + let boundDouble: Double? + let missingDouble: Double? + let boundBool: Bool? + let missingBool: Bool? + + init(_ row: DatabaseRow) throws { + self.boundString = try row.decode( + column: "bound_string", + as: String?.self + ) + self.missingString = try row.decode( + column: "missing_string", + as: String?.self + ) + self.boundInt = try row.decode( + column: "bound_int", + as: Int?.self + ) + self.missingInt = try row.decode( + column: "missing_int", + as: Int?.self + ) + self.boundFloat = try row.decode( + column: "bound_float", + as: Double?.self + ) + self.missingFloat = try row.decode( + column: "missing_float", + as: Double?.self + ) + self.boundDouble = try row.decode( + column: "bound_double", + as: Double?.self + ) + self.missingDouble = try row.decode( + column: "missing_double", + as: Double?.self + ) + self.boundBool = try row.decode( + column: "bound_bool", + as: Bool?.self + ) + self.missingBool = try row.decode( + column: "missing_bool", + as: Bool?.self + ) + } + } + + let result = try await connection.run( + query: #""" + SELECT + (\#(boundString))::TEXT AS "bound_string", + (\#(missingString))::TEXT AS "missing_string", + (\#(boundInt))::INTEGER AS "bound_int", + (\#(missingInt))::INTEGER AS "missing_int", + (\#(boundFloat))::DOUBLE PRECISION AS "bound_float", + (\#(missingFloat))::DOUBLE PRECISION AS "missing_float", + (\#(boundDouble))::DOUBLE PRECISION AS "bound_double", + (\#(missingDouble))::DOUBLE PRECISION AS "missing_double", + (\#(boundBool))::BOOLEAN AS "bound_bool", + (\#(missingBool))::BOOLEAN AS "missing_bool"; + """# + ) { try await $0.collect().map { try OptionalRow($0) } } + + #expect(result.count == 1) + #expect(result[0].boundString == "alpha") + #expect(result[0].missingString == nil) + #expect(result[0].boundInt == 21) + #expect(result[0].missingInt == nil) + #expect(result[0].boundFloat == 1.25) + #expect(result[0].missingFloat == nil) + #expect(result[0].boundDouble == 3.75) + #expect(result[0].missingDouble == nil) + #expect(result[0].boundBool == true) + #expect(result[0].missingBool == nil) + } + } + } + + @Test + func unescapedOptionalInterpolationRoundTrip() async throws { + try await runUsingTestDatabaseClient { database in + try await database.withConnection { connection in + let rawString: String? = "beta" + let missingString: String? = nil + let rawInt: Int? = 7 + let missingInt: Int? = nil + let rawFloat: Float? = 2.5 + let missingFloat: Float? = nil + let rawDouble: Double? = 4.5 + let missingDouble: Double? = nil + let rawBool: Bool? = false + let missingBool: Bool? = nil + + struct RawOptionalRow: Sendable { + let rawString: String? + let missingString: String? + let rawInt: Int? + let missingInt: Int? + let rawFloat: Double? + let missingFloat: Double? + let rawDouble: Double? + let missingDouble: Double? + let rawBool: Bool? + let missingBool: Bool? + + init(_ row: DatabaseRow) throws { + self.rawString = try row.decode( + column: "raw_string", + as: String?.self + ) + self.missingString = try row.decode( + column: "missing_string", + as: String?.self + ) + self.rawInt = try row.decode( + column: "raw_int", + as: Int?.self + ) + self.missingInt = try row.decode( + column: "missing_int", + as: Int?.self + ) + self.rawFloat = try row.decode( + column: "raw_float", + as: Double?.self + ) + self.missingFloat = try row.decode( + column: "missing_float", + as: Double?.self + ) + self.rawDouble = try row.decode( + column: "raw_double", + as: Double?.self + ) + self.missingDouble = try row.decode( + column: "missing_double", + as: Double?.self + ) + self.rawBool = try row.decode( + column: "raw_bool", + as: Bool?.self + ) + self.missingBool = try row.decode( + column: "missing_bool", + as: Bool?.self + ) + } + } + + let result = try await connection.run( + query: #""" + SELECT + '\#(unescaped: rawString)'::TEXT AS "raw_string", + \#(unescaped: missingString)::TEXT AS "missing_string", + \#(unescaped: rawInt)::INTEGER AS "raw_int", + \#(unescaped: missingInt)::INTEGER AS "missing_int", + \#(unescaped: rawFloat)::DOUBLE PRECISION AS "raw_float", + \#(unescaped: missingFloat)::DOUBLE PRECISION AS "missing_float", + \#(unescaped: rawDouble)::DOUBLE PRECISION AS "raw_double", + \#(unescaped: missingDouble)::DOUBLE PRECISION AS "missing_double", + \#(unescaped: rawBool)::BOOLEAN AS "raw_bool", + \#(unescaped: missingBool)::BOOLEAN AS "missing_bool"; + """# + ) { try await $0.collect().map { try RawOptionalRow($0) } } + + #expect(result.count == 1) + #expect(result[0].rawString == "beta") + #expect(result[0].missingString == nil) + #expect(result[0].rawInt == 7) + #expect(result[0].missingInt == nil) + #expect(result[0].rawFloat == 2.5) + #expect(result[0].missingFloat == nil) + #expect(result[0].rawDouble == 4.5) + #expect(result[0].missingDouble == nil) + #expect(result[0].rawBool == false) + #expect(result[0].missingBool == nil) + } + } + } + + @Test + func arrayInterpolationRoundTrip() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "array_samples_\(suffix)" + + try await database.withConnection { connection in + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "ratio" DOUBLE PRECISION NOT NULL, + "score" DOUBLE PRECISION NOT NULL, + "active" BOOLEAN NOT NULL + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name", "ratio", "score", "active") + VALUES + (1, 'alpha', 1.5, 3.5, true), + (2, 'beta', 2.25, 4.75, false); + """# + ) + + let names = ["alpha", "omega"] + let ids = [1, 99] + let ratios: [Float] = [1.5, 9.5] + let scores = [3.5, 9.75] + let flags = [true, false] + + let result = try await connection.run( + query: #""" + SELECT + "id", + "name" + FROM "\#(unescaped: table)" + WHERE + "name" IN (\#(names)) + AND "id" IN (\#(ids)) + AND "ratio" IN (\#(ratios)) + AND "score" IN (\#(scores)) + AND "active" IN (\#(flags)) + ORDER BY "id"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect(try result[0].decode(column: "id", as: Int.self) == 1) + #expect( + try result[0].decode(column: "name", as: String.self) + == "alpha" + ) + } + } + } + + @Test + func optionalArrayInterpolationRoundTrip() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "optional_array_samples_\(suffix)" + + try await database.withConnection { connection in + try await connection.run( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await connection.run( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "ratio" DOUBLE PRECISION NOT NULL, + "score" DOUBLE PRECISION NOT NULL, + "active" BOOLEAN NOT NULL + ); + """# + ) + + try await connection.run( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name", "ratio", "score", "active") + VALUES + (1, 'alpha', 1.5, 3.5, true), + (2, 'beta', 2.25, 4.75, false); + """# + ) + + let names: [String?] = ["alpha", nil, "omega"] + let ids: [Int?] = [1, nil, 99] + let ratios: [Float?] = [1.5, nil, 9.5] + let scores: [Double?] = [3.5, nil, 9.75] + let flags: [Bool?] = [true, nil, false] + + let result = try await connection.run( + query: #""" + SELECT + "id", + "name" + FROM "\#(unescaped: table)" + WHERE + "name" IN (\#(names)) + AND "id" IN (\#(ids)) + AND "ratio" IN (\#(ratios)) + AND "score" IN (\#(scores)) + AND "active" IN (\#(flags)) + ORDER BY "id"; + """# + ) { try await $0.collect() } + + #expect(result.count == 1) + #expect(try result[0].decode(column: "id", as: Int.self) == 1) + #expect( + try result[0].decode(column: "name", as: String.self) + == "alpha" + ) + } + } + } + @Test func resultSequenceIterator() async throws { try await runUsingTestDatabaseClient { database in diff --git a/docker-compose.yaml b/docker-compose.yaml index bf42234..ac1a4b2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -29,6 +29,22 @@ services: timeout: 5s retries: 10 + test: + build: + context: . + dockerfile: docker/tests/Dockerfile + depends_on: + postgres: + condition: service_healthy + environment: + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_CA_CERT_PATH: /app/docker/postgres/certificates/ca.pem + volumes: + - .:/app + working_dir: /app + command: ["swift", "test", "--parallel", "--enable-code-coverage"] + volumes: postgres: diff --git a/docker/postgres/scripts/generate-certificates.sh b/docker/postgres/scripts/generate-certificates.sh index a0d945a..8e76ee0 100755 --- a/docker/postgres/scripts/generate-certificates.sh +++ b/docker/postgres/scripts/generate-certificates.sh @@ -12,8 +12,8 @@ openssl genpkey -algorithm RSA -out server.key # Create a CSR with correct CN (Common Name) & SAN (Subject Alternative Name) openssl req -new -key server.key -out server.csr -subj "/CN=localhost" -# SAN for localhost, db, and 127.0.0.1 -echo "subjectAltName=DNS:localhost,DNS:db,IP:127.0.0.1" > san.cnf +# SAN for localhost, postgres, db, and 127.0.0.1 +echo "subjectAltName=DNS:localhost,DNS:postgres,DNS:db,IP:127.0.0.1" > san.cnf # Sign the server certificate with CA & SAN openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out server.pem -days 365 -extfile san.cnf