From 43d72a81f7472083eafb71316c7705aed4a4154d Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Thu, 15 Jan 2026 20:36:49 +0100 Subject: [PATCH 1/8] swift 6 updates --- .github/workflows/deployment.yml | 16 + .github/workflows/testing.yml | 39 + .gitignore | 4 +- .swift-format | 114 ++- .swiftformatignore | 2 + .swiftheaderignore | 13 + AGENTS.md | 80 ++ LICENSE | 2 +- Makefile | 58 +- Package.resolved | 113 +-- Package.swift | 60 +- .../PostgresConnection.swift | 37 + .../PostgresDatabaseClient.swift | 131 +++ .../PostgresQuery.swift | 21 + .../PostgresQueryResult.swift | 63 ++ .../PostgresRow+DatabaseRow.swift | 69 ++ .../PostgresRelationalDatabaseComponent.swift | 39 - ...esRelationalDatabaseComponentBuilder.swift | 33 - ...esRelationalDatabaseComponentContext.swift | 27 - .../FeatherPostgresDatabaseTestSuite.swift | 953 ++++++++++++++++++ ...elationalDatabaseDriverPostgresTests.swift | 113 --- docker-compose.yaml | 34 + docker/postgres/Dockerfile | 23 + docker/postgres/scripts/config-ssl.sh | 13 + .../postgres/scripts/generate-certificates.sh | 19 + docker/tests/Dockerfile | 11 + 26 files changed, 1716 insertions(+), 371 deletions(-) create mode 100644 .github/workflows/deployment.yml create mode 100644 .github/workflows/testing.yml create mode 100644 .swiftformatignore create mode 100644 .swiftheaderignore create mode 100644 AGENTS.md create mode 100644 Sources/FeatherPostgresDatabase/PostgresConnection.swift create mode 100644 Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift create mode 100644 Sources/FeatherPostgresDatabase/PostgresQuery.swift create mode 100644 Sources/FeatherPostgresDatabase/PostgresQueryResult.swift create mode 100644 Sources/FeatherPostgresDatabase/PostgresRow+DatabaseRow.swift delete mode 100644 Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponent.swift delete mode 100644 Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentBuilder.swift delete mode 100644 Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentContext.swift create mode 100644 Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift delete mode 100644 Tests/FeatherRelationalDatabaseDriverPostgresTests/FeatherRelationalDatabaseDriverPostgresTests.swift create mode 100644 docker-compose.yaml create mode 100644 docker/postgres/Dockerfile create mode 100644 docker/postgres/scripts/config-ssl.sh create mode 100755 docker/postgres/scripts/generate-certificates.sh create mode 100644 docker/tests/Dockerfile diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml new file mode 100644 index 0000000..680623b --- /dev/null +++ b/.github/workflows/deployment.yml @@ -0,0 +1,16 @@ +name: Deployment + +on: + push: + tags: + - 'v*' + - '[0-9]*' + +jobs: + + create-docc-and-deploy: + uses: BinaryBirds/github-workflows/.github/workflows/docc_deploy.yml@main + permissions: + contents: read + pages: write + id-token: write \ No newline at end of file diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..cd98d5f --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,39 @@ +name: Testing + +on: + pull_request: + branches: + - main + +jobs: + + swiftlang_checks: + name: Swiftlang Checks + uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main + with: + license_header_check_project_name: "project" + format_check_enabled : true + broken_symlink_check_enabled : true + unacceptable_language_check_enabled : true + shell_check_enabled : true + docs_check_enabled : false + api_breakage_check_enabled : false + license_header_check_enabled : false + yamllint_check_enabled : false + python_lint_check_enabled : false + + bb_checks: + name: BB Checks + uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main + with: + local_swift_dependencies_check_enabled : true + headers_check_enabled : true + docc_warnings_check_enabled : true + + swiftlang_tests: + name: Swiftlang Tests + uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main + with: + enable_windows_checks : false + linux_build_command: "swift test --parallel --enable-code-coverage" + linux_exclude_swift_versions: "[{\"swift_version\": \"5.8\"}, {\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"nightly\"}, {\"swift_version\": \"nightly-main\"}, {\"swift_version\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}, {\"swift_version\": \"nightly-6.3\"}]" \ No newline at end of file diff --git a/.gitignore b/.gitignore index ec45f2b..0417c26 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ Packages xcuserdata DerivedData .swiftpm -*.xctestplan \ No newline at end of file +*.xctestplan +docker/postgres/certificates +docker/mariadb/certificates diff --git a/.swift-format b/.swift-format index c8502a7..b60310c 100644 --- a/.swift-format +++ b/.swift-format @@ -1,58 +1,64 @@ { - "fileScopedDeclarationPrivacy" : { - "accessLevel" : "private" + "version": 1, + "lineLength": 80, + "maximumBlankLines": 1, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" }, - "indentation" : { - "spaces" : 4 + "tabWidth": 4, + "indentation": { + "spaces": 4 }, - "indentConditionalCompilationBlocks" : false, - "indentSwitchCaseLabels" : false, - "lineBreakAroundMultilineExpressionChainComponents" : true, - "lineBreakBeforeControlFlowKeywords" : true, - "lineBreakBeforeEachArgument" : true, - "lineBreakBeforeEachGenericRequirement" : true, - "lineLength" : 80, - "maximumBlankLines" : 1, - "prioritizeKeepingFunctionOutputTogether" : false, - "respectsExistingLineBreaks" : true, - "rules" : { - "AllPublicDeclarationsHaveDocumentation" : true, - "AlwaysUseLowerCamelCase" : false, - "AmbiguousTrailingClosureOverload" : true, - "BeginDocumentationCommentWithOneLineSummary" : false, - "DoNotUseSemicolons" : true, - "DontRepeatTypeInStaticProperties" : false, - "FileScopedDeclarationPrivacy" : true, - "FullyIndirectEnum" : true, - "GroupNumericLiterals" : true, - "IdentifiersMustBeASCII" : true, - "NeverForceUnwrap" : false, - "NeverUseForceTry" : false, - "NeverUseImplicitlyUnwrappedOptionals" : false, - "NoAccessLevelOnExtensionDeclaration" : false, - "NoAssignmentInExpressions" : true, - "NoBlockComments" : true, - "NoCasesWithOnlyFallthrough" : true, - "NoEmptyTrailingClosureParentheses" : true, - "NoLabelsInCasePatterns" : false, - "NoLeadingUnderscores" : false, - "NoParensAroundConditions" : true, - "NoVoidReturnOnFunctionSignature" : true, - "OneCasePerLine" : true, - "OneVariableDeclarationPerLine" : true, - "OnlyOneTrailingClosureArgument" : true, - "OrderedImports" : false, - "ReturnVoidInsteadOfEmptyTuple" : true, - "UseEarlyExits" : false, - "UseLetInEveryBoundCaseVariable" : false, - "UseShorthandTypeNames" : true, - "UseSingleLinePropertyGetter" : false, - "UseSynthesizedInitializer" : true, - "UseTripleSlashForDocumentationComments" : true, - "UseWhereClausesInForLoops" : false, - "ValidateDocumentationComments" : true - }, - "spacesAroundRangeFormationOperators" : false, - "tabWidth" : 4, - "version" : 1 + "indentConditionalCompilationBlocks": false, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": true, + "lineBreakBeforeControlFlowKeywords": true, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "prioritizeKeepingFunctionOutputTogether": false, + "respectsExistingLineBreaks": true, + "spacesAroundRangeFormationOperators": false, + "multiElementCollectionTrailingCommas": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": true, + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": true, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": true, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": true, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": true, + "ValidateDocumentationComments": true + } } diff --git a/.swiftformatignore b/.swiftformatignore new file mode 100644 index 0000000..bf235af --- /dev/null +++ b/.swiftformatignore @@ -0,0 +1,2 @@ +Package.swift +Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift diff --git a/.swiftheaderignore b/.swiftheaderignore new file mode 100644 index 0000000..da97ca9 --- /dev/null +++ b/.swiftheaderignore @@ -0,0 +1,13 @@ +.github/** +docker/** +AGENTS.md +.gitignore +.swift-format +.swiftformatignore +.swiftheaderignore +LICENSE +Makefile +Package.swift +Package.resolved +README.md +docker-compose.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..157e1a4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,80 @@ +# Repository Guidelines + +This repository contains an project written with Swift 6. Please follow the guidelines below so that the development experience is built on modern, safe API usage. + + +## Role + +You are a **Senior Swift Engineer**, specializing in server-side Swift development, and related frameworks (Vapor, Hummingbird). + + +## Core instructions + +- Swift 6.2 or later, using modern Swift concurrency. +- Do not introduce third-party frameworks without asking first. +- Avoid Foundation unless requested. +- Build system: Swift Package Manager. +- Testing framework: Swift Testing (`swift test`). + + +## Swift instructions + +- Assume strict Swift concurrency rules are being applied. +- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`. +- Prefer modern Foundation API, if Foundation can not be avoided, for example `URL.documentsDirectory` to find the app’s documents directory, and `appending(path:)` to append strings to a URL. +- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead. +- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`. +- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency. +- Avoid force unwraps and force `try` unless it is unrecoverable. +- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead. + + +## Project structure + +- Use a consistent project structure. +- Follow strict naming conventions for types, properties, methods, and data models. +- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file. +- Write unit tests for core application logic. +- Add code comments and documentation comments as needed. +- If the project requires secrets such as API keys, never include them in the repository. + + +## Build, Test, and Development Commands + +- Preferred workflow: after any code change run `swift build` to rebuild the project, and run `swift test` when you actually need tests. + + +## PR instructions + +- If installed, make sure the `make format` & `make check` commands returns no warnings or errors before committing. + + +## Coding Style & Naming Conventions + +- Enforce formatting with the `make format` command. +- Swift 6.2, prefer strict typing and small files (<500 LOC as a guardrail) +- Naming: types UpperCamelCase; methods/properties lowerCamelCase; tests mirror subject names; avoid abbreviations except common GitHub/API terms. + + +## Testing Guidelines + +- Framework: Swift Testing via `swift test`. Name suites `Tests` and functions `behavior()`. +- Cover new logic. Use deterministic fixtures/mocks for data. +- Run `swift test` before pushing; prefer adding tests alongside bug fixes. + + +## Commit & Pull Request Guidelines + +- Commit messages follow the existing short, imperative style; optional scoped prefixes. Keep them concise; present tense; no trailing period. +- PRs: include a brief summary, linked issue ticket if any. + + +## Security & Configuration Tips + +- Keep GitHub App secrets/private key out of the repo; +- Do not log tokens or traffic stats responses; prefer redacted diagnostics. + + +## Agent-Specific Notes + +- Reminder: ignore files you do not recognize (just list them); multiple agents often work here. diff --git a/LICENSE b/LICENSE index 9a07ee3..d48549a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License Copyright (c) 2018-2022 Tibor Bödecs -Copyright (c) 2022-2024 Binary Birds Ltd. +Copyright (c) 2022-2026 Binary Birds Kft. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/Makefile b/Makefile index 9898306..21d5def 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,51 @@ -build: - swift build +SHELL=/bin/bash -release: - swift build -c release - -test: - swift test --parallel +baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts + +check: symlinks language deps lint headers + +symlinks: + curl -s $(baseUrl)/check-broken-symlinks.sh | bash + +language: + curl -s $(baseUrl)/check-unacceptable-language.sh | bash -test-with-coverage: - swift test --parallel --enable-code-coverage +deps: + curl -s $(baseUrl)/check-local-swift-dependencies.sh | bash -clean: - rm -rf .build +lint: + curl -s $(baseUrl)/run-swift-format.sh | bash format: - swift-format -i -r ./Sources && swift-format -i -r ./Tests + curl -s $(baseUrl)/run-swift-format.sh | bash -s -- --fix + find Sources -type f -name '*.swift' -print0 | xargs -0 sed -i '' 's/nonisolated (nonsending/nonisolated(nonsending/g' + find Tests -type f -name '*.swift' -print0 | xargs -0 sed -i '' 's/nonisolated (nonsending/nonisolated(nonsending/g' + +docc-local: + curl -s $(baseUrl)/generate-docc.sh | bash -s -- --local + +run-docc: + curl -s $(baseUrl)/run-docc-docker.sh | bash + +docc-warnings: + curl -s $(baseUrl)/check-docc-warnings.sh | bash + +headers: + curl -s $(baseUrl)/check-swift-headers.sh | bash + +fix-headers: + curl -s $(baseUrl)/check-swift-headers.sh | bash -s -- --fix + + +testprep: + rm -rf docker/postgres/certificates && mkdir -p docker/postgres/certificates && cd docker/postgres/certificates && ../scripts/generate-certificates.sh + docker compose up -d --build postgres + +testrun: testprep + swift test --parallel + +test: testrun + docker compose down + +docker-test: + docker build -t feather-postgres-database-tests . -f ./docker/tests/Dockerfile && docker run --rm feather-postgres-database-tests diff --git a/Package.resolved b/Package.resolved index 9b52632..308bf1a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,66 +1,31 @@ { + "originHash" : "f450d3e0fe73cd18bfe464b620507152a43243ff1efd831929aeccd7d724d4af", "pins" : [ { - "identity" : "async-kit", + "identity" : "feather-database", "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/async-kit.git", + "location" : "https://github.com/feather-framework/feather-database", "state" : { - "revision" : "7ece208cd401687641c88367a00e3ea2b04311f1", - "version" : "1.19.0" - } - }, - { - "identity" : "feather-component", - "kind" : "remoteSourceControl", - "location" : "https://github.com/feather-framework/feather-component", - "state" : { - "revision" : "d8373cf9ab5db3e7b21fbcb8e6d1d5718b3bb73b", - "version" : "0.4.0" - } - }, - { - "identity" : "feather-relational-database", - "kind" : "remoteSourceControl", - "location" : "https://github.com/feather-framework/feather-relational-database", - "state" : { - "revision" : "fd4fc17854f3a17fe33b1a80c02615bd9da34a7a", - "version" : "0.2.0" - } - }, - { - "identity" : "postgres-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/postgres-kit", - "state" : { - "revision" : "80ab7737dac4fccd4a8ad38743828dcb71ba7ac8", - "version" : "2.12.2" + "branch" : "feature/swift-6", + "revision" : "4b79c46d427e400ef2c6fa959db77890e2847cc3" } }, { "identity" : "postgres-nio", "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/postgres-nio.git", + "location" : "https://github.com/vapor/postgres-nio", "state" : { - "revision" : "036931d968aab819f5e380a932237118ac4e87ba", - "version" : "1.19.1" + "revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6", + "version" : "1.30.1" } }, { - "identity" : "sql-kit", + "identity" : "swift-asn1", "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/sql-kit.git", + "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "b2f128cb62a3abfbb1e3b2893ff3ee69e70f4f0f", - "version" : "3.28.0" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms.git", - "state" : { - "revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42", - "version" : "1.2.0" + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" } }, { @@ -68,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "da4e36f86544cdf733a40d59b3a2267e3a7bbf36", - "version" : "1.0.0" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -77,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { @@ -86,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "a902f1823a7ff3c9ab2fba0f992396b948eda307", - "version" : "1.0.5" + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" } }, { @@ -95,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "b51f1d6845b353a2121de1c6a670738ec33561a6", - "version" : "3.1.0" + "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", + "version" : "4.2.0" } }, { @@ -104,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", - "version" : "1.5.3" + "revision" : "7ee16e465622412764b0ff0c1301801dc71b8f61", + "version" : "1.9.0" } }, { @@ -113,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-metrics.git", "state" : { - "revision" : "971ba26378ab69c43737ee7ba967a896cb74c0d1", - "version" : "2.4.1" + "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", + "version" : "2.7.1" } }, { @@ -122,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "702cd7c56d5d44eeba73fdf83918339b26dc855c", - "version" : "2.62.0" + "revision" : "4a9a97111099376854a7f8f0f9f88b9d61f52eff", + "version" : "2.92.2" } }, { @@ -131,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "320bd978cceb8e88c125dcbb774943a92f6286e9", - "version" : "2.25.0" + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" } }, { @@ -140,28 +105,28 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "ebf8b9c365a6ce043bf6e6326a04b15589bd285e", - "version" : "1.20.0" + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" } }, { - "identity" : "swift-numerics", + "identity" : "swift-service-lifecycle", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", - "version" : "1.0.2" + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" } }, { - "identity" : "swift-service-lifecycle", + "identity" : "swift-system", "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle", + "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "55f45e39dd23c6cad82d4d529e22961cb5e493aa", - "version" : "2.4.0" + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" } } ], - "version" : 2 + "version" : 3 } diff --git a/Package.swift b/Package.swift index 218312c..cd7908a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,35 +1,61 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.1 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 + .swiftLanguageMode(.v6), + // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md + .enableUpcomingFeature("MemberImportVisibility"), + // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 + .enableExperimentalFeature("Lifetimes"), + // https://github.com/swiftlang/swift/pull/65218 + .enableExperimentalFeature("AvailabilityMacro=featherDatabase 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.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-relational-database-driver-postgres", + name: "feather-postgres-database", + // NOTE: platfroms is needed because of dependencies, remove when remove pg & sqlite platforms: [ - .macOS(.v13), - .iOS(.v16), - .tvOS(.v16), - .watchOS(.v9), - .visionOS(.v1), + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), ], products: [ - .library(name: "FeatherRelationalDatabaseDriverPostgres", targets: ["FeatherRelationalDatabaseDriverPostgres"]), + .library(name: "FeatherPostgresDatabase", targets: ["FeatherPostgresDatabase"]), ], dependencies: [ - .package(url: "https://github.com/feather-framework/feather-relational-database", .upToNextMinor(from: "0.2.0")), - .package(url: "https://github.com/vapor/postgres-kit", from: "2.0.0"), + .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", branch: "feature/swift-6"), ], targets: [ .target( - name: "FeatherRelationalDatabaseDriverPostgres", + name: "FeatherPostgresDatabase", dependencies: [ - .product(name: "FeatherRelationalDatabase", package: "feather-relational-database"), - .product(name: "PostgresKit", package: "postgres-kit"), - ] + .product(name: "Logging", package: "swift-log"), + .product(name: "PostgresNIO", package: "postgres-nio"), + .product(name: "FeatherDatabase", package: "feather-database"), + ], + swiftSettings: defaultSwiftSettings ), .testTarget( - name: "FeatherRelationalDatabaseDriverPostgresTests", + name: "FeatherPostgresDatabaseTests", dependencies: [ - .target(name: "FeatherRelationalDatabaseDriverPostgres"), - ] + .target(name: "FeatherPostgresDatabase"), + ], + swiftSettings: defaultSwiftSettings ), ] ) diff --git a/Sources/FeatherPostgresDatabase/PostgresConnection.swift b/Sources/FeatherPostgresDatabase/PostgresConnection.swift new file mode 100644 index 0000000..d712f90 --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresConnection.swift @@ -0,0 +1,37 @@ +// +// PostgresConnection.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// + +import FeatherDatabase +import PostgresNIO + +extension PostgresConnection: @retroactive DatabaseConnection { + + /// Execute a Postgres query on this connection. + /// + /// This wraps `PostgresNIO` query execution and maps errors. + /// - Parameter query: The Postgres query to execute. + /// - Throws: A `DatabaseError` if the query fails. + /// - Returns: A query result containing the returned rows. + @discardableResult + public func execute( + query: PostgresQuery + ) async throws(DatabaseError) -> PostgresQueryResult { + do { + let result = try await self.query( + .init( + unsafeSQL: query.sql, + binds: query.bindings + ), + logger: logger + ) + return PostgresQueryResult(backingSequence: result) + } + catch { + throw DatabaseError.query(error) + } + } +} diff --git a/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift new file mode 100644 index 0000000..1a1c0c2 --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift @@ -0,0 +1,131 @@ +// +// PostgresDatabaseClient.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// + +import FeatherDatabase +import Logging +import PostgresNIO + +/// Make Postgres transaction errors conform to `DatabaseTransactionError`. +/// +/// This allows Postgres errors to flow through `DatabaseError`. +extension PostgresTransactionError: @retroactive DatabaseTransactionError {} + +/// A Postgres-backed database client. +/// +/// Use this client to execute queries and manage transactions on Postgres. +public struct PostgresDatabaseClient: DatabaseClient { + + var client: PostgresClient + var logger: Logger + + /// Create a Postgres database client. + /// + /// Use this initializer to provide an existing Postgres client. + /// - Parameters: + /// - client: The underlying Postgres client. + /// - logger: The logger for database operations. + public init( + client: PostgresClient, + logger: Logger + ) { + self.client = client + self.logger = logger + } + + // MARK: - database api + + #if compiler(>=6.2) + + /// Execute work using a managed Postgres connection. + /// + /// The closure receives a Postgres connection for the duration of the call. + /// - Parameter closure: A closure that receives the connection. + /// - Throws: A `DatabaseError` if connection handling fails. + /// - Returns: The query result produced by the closure. + @discardableResult + public func connection( + _ closure: + nonisolated(nonsending)(PostgresConnection) async throws + -> sending PostgresQueryResult + ) async throws(DatabaseError) -> sending PostgresQueryResult { + do { + return try await client.withConnection(closure) + } + catch let error as DatabaseError { + throw error + } + catch { + throw .connection(error) + } + } + + /// Execute work inside a Postgres transaction. + /// + /// The closure is wrapped in a transactional scope. + /// - Parameter closure: A closure that receives the connection. + /// - Throws: A `DatabaseError` if the transaction fails. + /// - Returns: The query result produced by the closure. + @discardableResult + public func transaction( + _ closure: + nonisolated(nonsending)(PostgresConnection) async throws + -> sending PostgresQueryResult + ) async throws(DatabaseError) -> sending PostgresQueryResult { + do { + return try await client.withTransaction(logger: logger, closure) + } + catch let error as PostgresTransactionError { + throw .transaction(error) + } + catch { + throw .connection(error) + } + } + #else + /// Execute work using a managed Postgres connection. + /// + /// The closure receives a Postgres connection for the duration of the call. + /// - Parameter closure: A closure that receives the connection. + /// - Throws: A `DatabaseError` if connection handling fails. + /// - Returns: The query result produced by the closure. + @discardableResult + public func connection( + _ closure: (PostgresConnection) async throws -> PostgresQueryResult + ) async throws(DatabaseError) -> PostgresQueryResult { + do { + return try await client.withConnection(closure) + } + catch let error as DatabaseError { + throw error + } + catch { + throw .connection(error) + } + } + + /// Execute work inside a Postgres transaction. + /// + /// The closure is wrapped in a transactional scope. + /// - Parameter closure: A closure that receives the connection. + /// - Throws: A `DatabaseError` if the transaction fails. + /// - Returns: The query result produced by the closure. + @discardableResult + public func transaction( + _ closure: (PostgresConnection) async throws -> PostgresQueryResult + ) async throws(DatabaseError) -> PostgresQueryResult { + do { + return try await client.withTransaction(logger: logger, closure) + } + catch let error as PostgresTransactionError { + throw .transaction(error) + } + catch { + throw .connection(error) + } + } + #endif +} diff --git a/Sources/FeatherPostgresDatabase/PostgresQuery.swift b/Sources/FeatherPostgresDatabase/PostgresQuery.swift new file mode 100644 index 0000000..8a609d6 --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresQuery.swift @@ -0,0 +1,21 @@ +// +// PostgresQuery.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// + +import FeatherDatabase +import PostgresNIO + +extension PostgresQuery: @retroactive DatabaseQuery { + /// The bindings type for Postgres queries. + /// + /// This type represents parameter bindings for PostgresNIO. + public typealias Bindings = PostgresBindings + + /// The bound parameters for the SQL text. + /// + /// This exposes the underlying `binds` storage. + public var bindings: PostgresBindings { binds } +} diff --git a/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift b/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift new file mode 100644 index 0000000..41f9a2f --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift @@ -0,0 +1,63 @@ +// +// PostgresQueryResult.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// + +import FeatherDatabase +import PostgresNIO + +/// A query result backed by a Postgres row sequence. +/// +/// Use this type to iterate or collect Postgres query results. +public struct PostgresQueryResult: DatabaseQueryResult { + + var backingSequence: PostgresRowSequence + + /// An async iterator over Postgres rows. + /// + /// This iterator pulls rows from the backing sequence. + public struct AsyncIterator: AsyncIteratorProtocol { + var backingIterator: PostgresRowSequence.AsyncIterator + + @concurrent + /// Return the next row in the sequence. + /// + /// This stops when the sequence ends or the task is cancelled. + /// - Throws: An error if the underlying sequence fails. + /// - Returns: The next `PostgresRow`, or `nil` when finished. + public mutating func next() async throws -> PostgresRow? { + guard !Task.isCancelled else { + return nil + } + guard let postgresRow = try await backingIterator.next() else { + return nil + } + return postgresRow + } + } + + /// Create an async iterator over the result rows. + /// + /// Use this to iterate the result as an `AsyncSequence`. + /// - Returns: An iterator over the result rows. + public func makeAsyncIterator() -> AsyncIterator { + .init( + backingIterator: backingSequence.makeAsyncIterator(), + ) + } + + /// Collect all rows into an array. + /// + /// This consumes the sequence and returns all rows. + /// - Throws: An error if iteration fails. + /// - Returns: An array of `PostgresRow` values. + public func collect() async throws -> [PostgresRow] { + var items: [PostgresRow] = [] + for try await item in self { + items.append(item) + } + return items + } +} diff --git a/Sources/FeatherPostgresDatabase/PostgresRow+DatabaseRow.swift b/Sources/FeatherPostgresDatabase/PostgresRow+DatabaseRow.swift new file mode 100644 index 0000000..9204e01 --- /dev/null +++ b/Sources/FeatherPostgresDatabase/PostgresRow+DatabaseRow.swift @@ -0,0 +1,69 @@ +// +// PostgresRow+DatabaseRow.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// + +import FeatherDatabase +import PostgresNIO + +extension PostgresRow: @retroactive DatabaseRow { + + /// Decode a column value as the given type. + /// + /// This uses Postgres decoding rules for `Decodable` types. + /// - Parameters: + /// - column: The column name to decode. + /// - type: The expected type to decode as. + /// - Throws: A `DecodingError` if the value cannot be decoded. + /// - Returns: The decoded value. + public func decode( + column: String, + as type: T.Type + ) throws(DecodingError) -> T { + let row = makeRandomAccess() + guard row.contains(column) else { + throw .dataCorrupted( + .init( + codingPath: [], + debugDescription: "Missing data for column \(column)." + ) + ) + } + let cell = row[column] + if let type = type as? any PostgresDecodable.Type { + do { + guard let value = try cell.decode(type, context: .default) as? T + else { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [], + debugDescription: + "Could not convert data to \(T.self)." + ) + ) + } + return value + } + catch { + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [], + debugDescription: "\(error)" + ) + ) + } + } + + throw DecodingError.typeMismatch( + T.self, + .init( + codingPath: [], + debugDescription: "Data is not convertible to \(type)." + ) + ) + } +} diff --git a/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponent.swift b/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponent.swift deleted file mode 100644 index 86638c4..0000000 --- a/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponent.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PostgresRelationalDatabaseComponent.swift -// PostgresRelationalDatabaseDriverPostgres -// -// Created by Tibor Bodecs on 03/12/2023. -// - -import FeatherComponent -import FeatherRelationalDatabase -import SQLKit -import PostgresKit -@preconcurrency import AsyncKit - -@dynamicMemberLookup -struct PostgresRelationalDatabaseComponent: RelationalDatabaseComponent { - - public let config: ComponentConfig - let pool: EventLoopGroupConnectionPool - - subscript( - dynamicMember keyPath: KeyPath - ) -> T { - let context = config.context as! PostgresRelationalDatabaseComponentContext - return context[keyPath: keyPath] - } - - init( - config: ComponentConfig, - pool: EventLoopGroupConnectionPool - ) { - self.config = config - self.pool = pool - } - - public func connection() async throws -> SQLKit.SQLDatabase { - pool.database(logger: self.logger).sql() - } - -} diff --git a/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentBuilder.swift b/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentBuilder.swift deleted file mode 100644 index 635c4f5..0000000 --- a/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentBuilder.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// PostgresRelationalDatabaseComponentBuilder.swift -// PostgresRelationalDatabaseDriverPostgres -// -// Created by Tibor Bodecs on 18/11/2023. -// - -import FeatherComponent -import AsyncKit -import PostgresKit - -struct PostgresRelationalDatabaseComponentBuilder: ComponentBuilder { - - let context: PostgresRelationalDatabaseComponentContext - let pool: EventLoopGroupConnectionPool - - init(context: PostgresRelationalDatabaseComponentContext) { - self.context = context - - self.pool = EventLoopGroupConnectionPool( - source: context.connectionSource, - on: context.eventLoopGroup - ) - } - - func build(using config: ComponentConfig) throws -> Component { - PostgresRelationalDatabaseComponent(config: config, pool: pool) - } - - func shutdown() throws { - pool.shutdown() - } -} diff --git a/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentContext.swift b/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentContext.swift deleted file mode 100644 index d82947d..0000000 --- a/Sources/FeatherRelationalDatabaseDriverPostgres/Driver/PostgresRelationalDatabaseComponentContext.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// PostgresRelationalDatabaseComponentContext.swift -// PostgresRelationalDatabaseDriverPostgres -// -// Created by Tibor Bodecs on 18/11/2023. -// - -import FeatherComponent -@preconcurrency import PostgresKit - -public struct PostgresRelationalDatabaseComponentContext: ComponentContext { - - let eventLoopGroup: EventLoopGroup - let connectionSource: PostgresConnectionSource - - public init( - eventLoopGroup: EventLoopGroup, - connectionSource: PostgresConnectionSource - ) { - self.eventLoopGroup = eventLoopGroup - self.connectionSource = connectionSource - } - - public func make() throws -> ComponentBuilder { - PostgresRelationalDatabaseComponentBuilder(context: self) - } -} diff --git a/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift b/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift new file mode 100644 index 0000000..07e1692 --- /dev/null +++ b/Tests/FeatherPostgresDatabaseTests/FeatherPostgresDatabaseTestSuite.swift @@ -0,0 +1,953 @@ +// +// FeatherPostgresDatabaseTestSuite.swift +// feather-postgres-database +// +// Created by Tibor Bödecs on 2026. 01. 10.. +// + +import FeatherDatabase +import Logging +import NIOSSL +import PostgresNIO +import Testing + +@testable import FeatherPostgresDatabase + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +@Suite +struct FeatherPostgresDatabaseTestSuite { + + private func randomTableSuffix() -> String { + let characters = Array("abcdefghijklmnopqrstuvwxyz0123456789") + var suffix = "" + suffix.reserveCapacity(16) + for _ in 0..<16 { + suffix.append(characters.randomElement() ?? "a") + } + return suffix + } + + private func runUsingTestDatabaseClient( + _ closure: + @escaping (@Sendable (PostgresDatabaseClient) async throws -> Void) + ) async throws { + var logger = Logger(label: "test") + logger.logLevel = .info + + let finalCertPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("docker") + .appendingPathComponent("postgres") + .appendingPathComponent("certificates") + .appendingPathComponent("ca.pem") + .path() + + 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 database = PostgresDatabaseClient( + client: client, + logger: logger + ) + + 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() + } + } + + // MARK: - + + @Test + func foreignKeySupport() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let planetsTable = "planets_\(suffix)" + let moonsTable = "moons_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: moonsTable)" CASCADE; + """# + ) + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: planetsTable)" CASCADE; + """# + ) + + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: planetsTable)" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: moonsTable)" ( + "id" INTEGER PRIMARY KEY, + "planet_id" INTEGER NOT NULL + REFERENCES "\#(unescaped: planetsTable)" ("id") + ); + """# + ) + + do { + _ = try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: moonsTable)" + ("id", "planet_id") + VALUES + (1, 999); + """# + ) + Issue.record("Expected foreign key constraint violation.") + } + catch DatabaseError.query(let error) { + #expect( + String(reflecting: error) + .contains("violates foreign key constraint") + ) + } + catch { + Issue.record("Expected database query error to be thrown.") + } + } + } + + @Test + func tableCreation() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "galaxies_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + + try await database.execute( + query: #""" + CREATE TABLE IF NOT EXISTS "\#(unescaped: table)" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) + + let results = try await database.execute( + query: #""" + SELECT "tablename" + FROM "pg_tables" + WHERE "schemaname" = 'public' + AND "tablename" = '\#(unescaped: table)' + ORDER BY "tablename"; + """# + ) + + let resultArray = try await results.collect() + #expect(resultArray.count == 1) + + let item = resultArray[0] + let name = try item.decode(column: "tablename", as: String.self) + #expect(name == table) + } + } + + @Test + func tableInsert() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "galaxies_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE IF NOT EXISTS "\#(unescaped: table)" ( + "id" INTEGER PRIMARY KEY, + "name" TEXT + ); + """# + ) + + let name1 = "Andromeda" + let name2 = "Milky Way" + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (\#(1), \#(name1)), + (\#(2), \#(name2)); + """# + ) + + let results = try await database.execute( + query: #""" + SELECT * FROM "\#(unescaped: table)" ORDER BY "name" ASC; + """# + ) + + let resultArray = try await results.collect() + #expect(resultArray.count == 2) + + let item1 = resultArray[0] + let name1result = try item1.decode(column: "name", as: String.self) + #expect(name1result == name1) + + let item2 = resultArray[1] + let name2result = try item2.decode(column: "name", as: String.self) + #expect(name2result == name2) + } + } + + @Test + func rowDecoding() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "foo_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'abc'), + (2, NULL); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "id", "value" + FROM "\#(unescaped: table)" + ORDER BY "id"; + """# + ) + .collect() + + #expect(result.count == 2) + + let item1 = result[0] + let item2 = result[1] + + #expect(try item1.decode(column: "id", as: Int.self) == 1) + #expect(try item2.decode(column: "id", as: Int.self) == 2) + + #expect(try item1.decode(column: "id", as: Int?.self) == .some(1)) + #expect((try? item1.decode(column: "value", as: Int?.self)) == nil) + + #expect(try item1.decode(column: "value", as: String.self) == "abc") + #expect( + (try? item2.decode(column: "value", as: String.self)) == nil + ) + + #expect( + (try item1.decode(column: "value", as: String?.self)) + == .some("abc") + ) + #expect( + (try item2.decode(column: "value", as: String?.self)) == .none + ) + } + } + + @Test + func queryEncoding() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "foo_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + + let row1: (Int, String?) = (1, "abc") + let row2: (Int, String?) = (2, nil) + + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (\#(row1.0), \#(row1.1)), + (\#(row2.0), \#(row2.1)); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "id", "value" + FROM "\#(unescaped: table)" + ORDER BY "id" ASC; + """# + ) + .collect() + + #expect(result.count == 2) + + let item1 = result[0] + let item2 = result[1] + + #expect(try item1.decode(column: "id", as: Int.self) == 1) + #expect(try item2.decode(column: "id", as: Int.self) == 2) + + #expect( + try item1.decode(column: "value", as: String?.self) == "abc" + ) + #expect(try item2.decode(column: "value", as: String?.self) == nil) + } + } + + @Test + func unsafeSQLBindings() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "widgets_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + + let name = "gizmo" + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (\#(1), \#(name)); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "name" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) + .collect() + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "name", as: String.self) == "gizmo" + ) + } + } + + @Test + func optionalStringInterpolationNil() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "notes_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "body" TEXT + ); + """# + ) + + let body: String? = nil + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "body") + VALUES + (1, \#(body)); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "body" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) + .collect() + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "body", as: String?.self) == nil + ) + } + } + + @Test + func postgresDataInterpolation() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "tags_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "label" TEXT NOT NULL + ); + """# + ) + + let label = "alpha" + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "label") + VALUES + (1, \#(label)); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "label" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) + .collect() + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "label", as: String.self) + == "alpha" + ) + } + } + + @Test + func resultSequenceIterator() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "numbers_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT NOT NULL + ); + """# + ) + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'one'), + (2, 'two'); + """# + ) + + let result = try await database.execute( + query: #""" + SELECT "id", "value" + FROM "\#(unescaped: table)" + ORDER BY "id"; + """# + ) + + var iterator = result.makeAsyncIterator() + let first = try await iterator.next() + let second = try await iterator.next() + let third = try await iterator.next() + + #expect(first != nil) + #expect(second != nil) + #expect(third == nil) + + if let first { + #expect(try first.decode(column: "id", as: Int.self) == 1) + #expect( + try first.decode(column: "value", as: String.self) == "one" + ) + } + else { + Issue.record("Expected first iterator element to exist.") + } + + if let second { + #expect(try second.decode(column: "id", as: Int.self) == 2) + #expect( + try second.decode(column: "value", as: String.self) == "two" + ) + } + else { + Issue.record("Expected second iterator element to exist.") + } + } + } + + @Test + func collectFirstReturnsFirstRow() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "widgets_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" SERIAL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("name") + VALUES + ('alpha'), + ('beta'); + """# + ) + + let result = try await database.execute( + query: #""" + SELECT "name" + FROM "\#(unescaped: table)" + ORDER BY "id" ASC; + """# + ) + + let first = try await result.collectFirst() + + #expect(first != nil) + #expect( + try first?.decode(column: "name", as: String.self) == "alpha" + ) + } + } + + @Test + func transactionSuccess() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "items_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + + try await database.transaction { connection in + try await connection.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (1, 'widget'); + """# + ) + } + + let result = + try await database.execute( + query: #""" + SELECT "name" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) + .collect() + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "name", as: String.self) + == "widget" + ) + } + } + + @Test + func transactionFailurePropagates() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "dummy_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL + ); + """# + ) + + do { + _ = try await database.transaction { connection in + try await connection.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (1, 'ok'); + """# + ) + + return try await connection.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "name") + VALUES + (2, NULL); + """# + ) + } + Issue.record( + "Expected database transaction error to be thrown." + ) + } + catch DatabaseError.transaction(let error) { + #expect(error.beginError == nil) + #expect(error.closureError != nil) + #expect( + error.closureError.debugDescription.contains( + "null value in column" + ) + ) + #expect(error.rollbackError == nil) + #expect(error.commitError == nil) + } + catch { + Issue.record( + "Expected database transaction error to be thrown." + ) + } + + let result = + try await database.execute( + query: #""" + SELECT "id" + FROM "\#(unescaped: table)"; + """# + ) + .collect() + + #expect(result.isEmpty) + } + } + + @Test + func doubleRoundTrip() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "measurements_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" DOUBLE PRECISION NOT NULL + ); + """# + ) + + let expected = 1.5 + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, \#(expected)); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "value" + FROM "\#(unescaped: table)" + WHERE "id" = 1; + """# + ) + .collect() + + #expect(result.count == 1) + #expect( + try result[0].decode(column: "value", as: Double.self) + == expected + ) + } + } + + @Test + func missingColumnThrows() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "items_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'abc'); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "id" + FROM "\#(unescaped: table)"; + """# + ) + .collect() + + #expect(result.count == 1) + + do { + _ = try result[0].decode(column: "value", as: String.self) + Issue.record("Expected decoding a missing column to throw.") + } + catch DecodingError.dataCorrupted { + + } + catch { + Issue.record( + "Expected a dataCorrupted error for missing column." + ) + } + } + } + + @Test + func typeMismatchThrows() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "items_\(suffix)" + + try await database.execute( + query: #""" + DROP TABLE IF EXISTS "\#(unescaped: table)" CASCADE; + """# + ) + try await database.execute( + query: #""" + CREATE TABLE "\#(unescaped: table)" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "value" TEXT + ); + """# + ) + + try await database.execute( + query: #""" + INSERT INTO "\#(unescaped: table)" + ("id", "value") + VALUES + (1, 'abc'); + """# + ) + + let result = + try await database.execute( + query: #""" + SELECT "value" + FROM "\#(unescaped: table)"; + """# + ) + .collect() + + #expect(result.count == 1) + + do { + _ = try result[0].decode(column: "value", as: Int.self) + Issue.record("Expected decoding a string as Int to throw.") + } + catch DecodingError.typeMismatch { + + } + catch { + Issue.record( + "Expected a typeMismatch error when decoding a string as Int." + ) + } + } + } + + @Test + func queryFailureErrorText() async throws { + try await runUsingTestDatabaseClient { database in + let suffix = randomTableSuffix() + let table = "missing_table_\(suffix)" + + do { + _ = try await database.execute( + query: #""" + SELECT * + FROM "\#(unescaped: table)"; + """# + ) + Issue.record("Expected query to fail for missing table.") + } + catch DatabaseError.query(let error) { + #expect( + String(reflecting: error).contains("does not exist") + ) + } + catch { + Issue.record("Expected database query error to be thrown.") + } + } + } + + @Test + func versionCheck() async throws { + try await runUsingTestDatabaseClient { database in + let result = try await database.execute( + query: #""" + SELECT + version() AS "version" + WHERE + 1=\#(1); + """# + ) + + let resultArray = try await result.collect() + #expect(resultArray.count == 1) + + let item = resultArray[0] + let version = try item.decode(column: "version", as: String.self) + #expect(version.contains("PostgreSQL")) + } + } +} diff --git a/Tests/FeatherRelationalDatabaseDriverPostgresTests/FeatherRelationalDatabaseDriverPostgresTests.swift b/Tests/FeatherRelationalDatabaseDriverPostgresTests/FeatherRelationalDatabaseDriverPostgresTests.swift deleted file mode 100644 index 7c43f2e..0000000 --- a/Tests/FeatherRelationalDatabaseDriverPostgresTests/FeatherRelationalDatabaseDriverPostgresTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// FeatherRelationalDatabaseDriverPostgresTests.swift -// FeatherRelationalDatabaseDriverPostgresTests -// -// Created by Tibor Bodecs on 2023. 01. 16.. -// - -import NIO -import XCTest -import FeatherComponent -import FeatherRelationalDatabase -import FeatherRelationalDatabaseDriverPostgres -import PostgresKit - -final class FeatherRelationalDatabaseDriverPostgresTests: XCTestCase { - - var host: String { - ProcessInfo.processInfo.environment["PG_HOST"]! - } - - var user: String { - ProcessInfo.processInfo.environment["PG_USER"]! - } - - var pass: String { - ProcessInfo.processInfo.environment["PG_PASS"]! - } - - var db: String { - ProcessInfo.processInfo.environment["PG_DB"]! - } - - func testExample() async throws { - do { - let registry = ComponentRegistry() - - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) - let threadPool = NIOThreadPool(numberOfThreads: 1) - threadPool.start() - - let configuration = SQLPostgresConfiguration( - hostname: host, - username: user, - password: pass, - database: db, - tls: .disable - ) - - let connectionSource = PostgresConnectionSource( - sqlConfiguration: configuration - ) - - try await registry.addRelationalDatabase( - PostgresRelationalDatabaseComponentContext( - eventLoopGroup: eventLoopGroup, - connectionSource: connectionSource - ) - ) - - try await registry.run() - let dbComponent = try await registry.relationalDatabase() - let db = try await dbComponent.connection() - - do { - - struct Galaxy: Codable { - let id: Int - let name: String - } - - try await db - .create(table: "galaxies") - .ifNotExists() - // TODO: figure out how to auto increment - .column("id", type: .int, .primaryKey(autoIncrement: false)) - .column("name", type: .text) - .run() - - try await db.delete(from: "galaxies").run() - - try await db - .insert(into: "galaxies") - .columns("id", "name") - .values(SQLBind(1), SQLBind("Milky Way")) - .values(SQLBind(2), SQLBind("Andromeda")) - .run() - - let galaxies = try await db - .select() - .column("*") - .from("galaxies") - .all(decoding: Galaxy.self) - - print("------------------------------") - for galaxy in galaxies { - print(galaxy.id, galaxy.name) - } - print("------------------------------") - - try await registry.shutdown() - } - catch { - try await registry.shutdown() - - throw error - } - } - catch { - XCTFail("\(String(reflecting: error))") - } - - } -} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..bf42234 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,34 @@ +services: + postgres: + build: + context: . + dockerfile: docker/postgres/Dockerfile + volumes: + - 'postgres:/var/lib/postgresql' + ports: + - '5432:5432' + restart: always + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + healthcheck: + test: + - CMD-SHELL + - | + sh -c "pg_isready -d ' + sslmode=verify-full + sslrootcert=/var/lib/postgresql/ca.crt + sslcert=/var/lib/postgresql/server.crt + sslkey=/var/lib/postgresql/server.key + user=$${POSTGRES_USER} + password=$${POSTGRES_PASSWORD} + dbname=$${POSTGRES_DB} + '" + interval: 1s + timeout: 5s + retries: 10 + +volumes: + postgres: + diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile new file mode 100644 index 0000000..7f3f7d0 --- /dev/null +++ b/docker/postgres/Dockerfile @@ -0,0 +1,23 @@ +# Use the official PostgreSQL image +FROM postgres:18.1 + +# Update package lists, install netcat-openbsd (useful for waiting on network connections), and clean up apt cache +#RUN apt-get update && apt-get install -y netcat-openbsd && rm -rf /var/lib/apt/lists/* + +# Set the working directory +WORKDIR /var/lib/postgresql + +RUN rm -rf data/* + +# Copy SSL certificate files into the /certs/ directory in the container +COPY docker/postgres/certificates/server.pem docker/postgres/certificates/server.key docker/postgres/certificates/ca.pem /certs/ +# Set ownership to the postgres user and restrict permissions on the private key for security +RUN chown postgres:postgres /certs/* && chmod 600 /certs/server.key + +# Copy an initialization script that sets up SSL into the Docker entrypoint directory +# Scripts in this directory are executed when the container is initialized +COPY docker/postgres/scripts/config-ssl.sh /docker-entrypoint-initdb.d/ +RUN chmod +x /docker-entrypoint-initdb.d/config-ssl.sh + +# Expose the PostgreSQL default port to allow external connections +EXPOSE 5432 diff --git a/docker/postgres/scripts/config-ssl.sh b/docker/postgres/scripts/config-ssl.sh new file mode 100644 index 0000000..1ec4c98 --- /dev/null +++ b/docker/postgres/scripts/config-ssl.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +# Append SSL configuration to postgresql.conf +echo "ssl = on" >> "$PGDATA/postgresql.conf" +echo "ssl_cert_file = '/certs/server.pem'" >> "$PGDATA/postgresql.conf" +echo "ssl_key_file = '/certs/server.key'" >> "$PGDATA/postgresql.conf" +echo "ssl_ca_file = '/certs/ca.pem'" >> "$PGDATA/postgresql.conf" + +# Allow SSL connections only +echo "local all all trust" > "$PGDATA/pg_hba.conf" +echo "hostssl all all 0.0.0.0/0 md5" >> "$PGDATA/pg_hba.conf" +echo "hostssl all all ::/0 md5" >> "$PGDATA/pg_hba.conf" diff --git a/docker/postgres/scripts/generate-certificates.sh b/docker/postgres/scripts/generate-certificates.sh new file mode 100755 index 0000000..a0d945a --- /dev/null +++ b/docker/postgres/scripts/generate-certificates.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# Generate CA (Certificate Authority) private key +openssl genpkey -algorithm RSA -out ca.key + +# Generate CA certificate (PEM format) +openssl req -new -x509 -key ca.key -out ca.pem -days 365 -subj "/CN=PostgreSQL-CA" + +# Generate server private key +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 + +# 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 diff --git a/docker/tests/Dockerfile b/docker/tests/Dockerfile new file mode 100644 index 0000000..73be175 --- /dev/null +++ b/docker/tests/Dockerfile @@ -0,0 +1,11 @@ +FROM swift:6.1 + +WORKDIR /app + +COPY . ./ + +RUN swift package resolve +RUN swift package clean +RUN swift package update + +CMD ["swift", "test", "--parallel", "--enable-code-coverage"] From a47ae1462da3fa5c96b455b607829d8ec5c268fd Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 16 Jan 2026 12:11:25 +0100 Subject: [PATCH 2/8] fix concurrency / isolation issues --- .swiftformatignore | 1 - Makefile | 11 +-- Package.resolved | 4 +- Package.swift | 3 +- .../PostgresDatabaseClient.swift | 71 +++++-------------- .../PostgresQueryResult.swift | 14 +++- 6 files changed, 36 insertions(+), 68 deletions(-) diff --git a/.swiftformatignore b/.swiftformatignore index bf235af..4308420 100644 --- a/.swiftformatignore +++ b/.swiftformatignore @@ -1,2 +1 @@ Package.swift -Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift diff --git a/Makefile b/Makefile index 21d5def..e165a15 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,6 @@ lint: format: curl -s $(baseUrl)/run-swift-format.sh | bash -s -- --fix - find Sources -type f -name '*.swift' -print0 | xargs -0 sed -i '' 's/nonisolated (nonsending/nonisolated(nonsending/g' - find Tests -type f -name '*.swift' -print0 | xargs -0 sed -i '' 's/nonisolated (nonsending/nonisolated(nonsending/g' docc-local: curl -s $(baseUrl)/generate-docc.sh | bash -s -- --local @@ -36,16 +34,11 @@ headers: fix-headers: curl -s $(baseUrl)/check-swift-headers.sh | bash -s -- --fix - -testprep: +test-certs: rm -rf docker/postgres/certificates && mkdir -p docker/postgres/certificates && cd docker/postgres/certificates && ../scripts/generate-certificates.sh - docker compose up -d --build postgres -testrun: testprep +test: swift test --parallel -test: testrun - docker compose down - docker-test: docker build -t feather-postgres-database-tests . -f ./docker/tests/Dockerfile && docker run --rm feather-postgres-database-tests diff --git a/Package.resolved b/Package.resolved index 308bf1a..14d9e9f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f450d3e0fe73cd18bfe464b620507152a43243ff1efd831929aeccd7d724d4af", + "originHash" : "ce28c2508daf1ba093b9d088913f73761d503126f6e8c90a437e346f3d6e2f5c", "pins" : [ { "identity" : "feather-database", @@ -7,7 +7,7 @@ "location" : "https://github.com/feather-framework/feather-database", "state" : { "branch" : "feature/swift-6", - "revision" : "4b79c46d427e400ef2c6fa959db77890e2847cc3" + "revision" : "9e299d72490e7a135b6b00557e5bc60285eb531c" } }, { diff --git a/Package.swift b/Package.swift index cd7908a..4b248ac 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ var defaultSwiftSettings: [SwiftSetting] = // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 .enableExperimentalFeature("Lifetimes"), // https://github.com/swiftlang/swift/pull/65218 - .enableExperimentalFeature("AvailabilityMacro=featherDatabase 1.0:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"), + .enableExperimentalFeature("AvailabilityMacro=featherPostgresDatabase 1.0:macOS 15.0, iOS 18.0, tvOS 18.0, watchOS 11.0, visionOS 2.0"), ] #if compiler(>=6.2) @@ -24,7 +24,6 @@ defaultSwiftSettings.append( let package = Package( name: "feather-postgres-database", - // NOTE: platfroms is needed because of dependencies, remove when remove pg & sqlite platforms: [ .macOS(.v15), .iOS(.v18), diff --git a/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift index 1a1c0c2..bfd7645 100644 --- a/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift +++ b/Sources/FeatherPostgresDatabase/PostgresDatabaseClient.swift @@ -38,19 +38,19 @@ public struct PostgresDatabaseClient: DatabaseClient { // MARK: - database api - #if compiler(>=6.2) - /// Execute work using a managed Postgres connection. /// /// The closure receives a Postgres connection for the duration of the call. - /// - Parameter closure: A closure that receives the connection. + /// - Parameters: + /// - isolation: The actor isolation for the operation. + /// - closure: A closure that receives the connection. /// - Throws: A `DatabaseError` if connection handling fails. /// - Returns: The query result produced by the closure. @discardableResult public func connection( - _ closure: - nonisolated(nonsending)(PostgresConnection) async throws - -> sending PostgresQueryResult + isolation: isolated (any Actor)? = #isolation, + _ closure: (PostgresConnection) async throws -> + sending PostgresQueryResult, ) async throws(DatabaseError) -> sending PostgresQueryResult { do { return try await client.withConnection(closure) @@ -66,17 +66,24 @@ public struct PostgresDatabaseClient: DatabaseClient { /// Execute work inside a Postgres transaction. /// /// The closure is wrapped in a transactional scope. - /// - Parameter closure: A closure that receives the connection. + /// - Parameters: + /// - isolation: The actor isolation for the operation. + /// - closure: A closure that receives the connection. /// - Throws: A `DatabaseError` if the transaction fails. /// - Returns: The query result produced by the closure. @discardableResult public func transaction( - _ closure: - nonisolated(nonsending)(PostgresConnection) async throws - -> sending PostgresQueryResult + isolation: isolated (any Actor)? = #isolation, + _ closure: ( + (PostgresConnection) async throws -> sending PostgresQueryResult + ), ) async throws(DatabaseError) -> sending PostgresQueryResult { do { - return try await client.withTransaction(logger: logger, closure) + return try await client.withTransaction( + logger: logger, + isolation: isolation, + closure + ) } catch let error as PostgresTransactionError { throw .transaction(error) @@ -85,47 +92,5 @@ public struct PostgresDatabaseClient: DatabaseClient { throw .connection(error) } } - #else - /// Execute work using a managed Postgres connection. - /// - /// The closure receives a Postgres connection for the duration of the call. - /// - Parameter closure: A closure that receives the connection. - /// - Throws: A `DatabaseError` if connection handling fails. - /// - Returns: The query result produced by the closure. - @discardableResult - public func connection( - _ closure: (PostgresConnection) async throws -> PostgresQueryResult - ) async throws(DatabaseError) -> PostgresQueryResult { - do { - return try await client.withConnection(closure) - } - catch let error as DatabaseError { - throw error - } - catch { - throw .connection(error) - } - } - /// Execute work inside a Postgres transaction. - /// - /// The closure is wrapped in a transactional scope. - /// - Parameter closure: A closure that receives the connection. - /// - Throws: A `DatabaseError` if the transaction fails. - /// - Returns: The query result produced by the closure. - @discardableResult - public func transaction( - _ closure: (PostgresConnection) async throws -> PostgresQueryResult - ) async throws(DatabaseError) -> PostgresQueryResult { - do { - return try await client.withTransaction(logger: logger, closure) - } - catch let error as PostgresTransactionError { - throw .transaction(error) - } - catch { - throw .connection(error) - } - } - #endif } diff --git a/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift b/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift index 41f9a2f..f0b4dc9 100644 --- a/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift +++ b/Sources/FeatherPostgresDatabase/PostgresQueryResult.swift @@ -21,12 +21,23 @@ public struct PostgresQueryResult: DatabaseQueryResult { public struct AsyncIterator: AsyncIteratorProtocol { var backingIterator: PostgresRowSequence.AsyncIterator - @concurrent /// Return the next row in the sequence. /// /// This stops when the sequence ends or the task is cancelled. /// - Throws: An error if the underlying sequence fails. /// - Returns: The next `PostgresRow`, or `nil` when finished. + #if compiler(>=6.2) + @concurrent + public mutating func next() async throws -> PostgresRow? { + guard !Task.isCancelled else { + return nil + } + guard let postgresRow = try await backingIterator.next() else { + return nil + } + return postgresRow + } + #else public mutating func next() async throws -> PostgresRow? { guard !Task.isCancelled else { return nil @@ -36,6 +47,7 @@ public struct PostgresQueryResult: DatabaseQueryResult { } return postgresRow } + #endif } /// Create an async iterator over the result rows. From 171be2845895a9f65d83dc0a7e2bdf073ed202e2 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 16 Jan 2026 14:44:40 +0100 Subject: [PATCH 3/8] try to run integration tests on ci --- .github/workflows/testing.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index cd98d5f..251c786 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -32,8 +32,28 @@ jobs: swiftlang_tests: name: Swiftlang Tests - uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main - with: - enable_windows_checks : false - linux_build_command: "swift test --parallel --enable-code-coverage" - linux_exclude_swift_versions: "[{\"swift_version\": \"5.8\"}, {\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"nightly\"}, {\"swift_version\": \"nightly-main\"}, {\"swift_version\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}, {\"swift_version\": \"nightly-6.3\"}]" \ No newline at end of file + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Swift + uses: swiftlang/setup-swift@v2 + with: + swift-version: "6.2" + - name: Start Postgres + run: docker compose up -d --build + - name: Wait For Postgres + 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 + if: always() + run: docker compose down -v From 9e71d2ca10f58fd79f30ac47678eb296adf37675 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 16 Jan 2026 14:45:31 +0100 Subject: [PATCH 4/8] remove setup swift --- .github/workflows/testing.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 251c786..82d542f 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -36,10 +36,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup Swift - uses: swiftlang/setup-swift@v2 - with: - swift-version: "6.2" - name: Start Postgres run: docker compose up -d --build - name: Wait For Postgres From 21caf491f9256c721a63bf9628b29cb517fb9b97 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 16 Jan 2026 15:45:54 +0100 Subject: [PATCH 5/8] try to fix ci --- .github/workflows/testing.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 82d542f..a9773ae 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -36,9 +36,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Start Postgres + - name: Generate certificates + run: make test-certs + - name: Start Postgres Docker Container run: docker compose up -d --build - - name: Wait For Postgres + - 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 @@ -50,6 +52,6 @@ jobs: exit 1 - name: Run Tests run: swift test --parallel - - name: Stop Postgres + - name: Stop Postgres Docker Container if: always() run: docker compose down -v From fd6363ec4e0b5d12b6ac5a0dd22e21a84d2ab9ca Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 16 Jan 2026 15:50:32 +0100 Subject: [PATCH 6/8] fix shell check --- docker/postgres/scripts/config-ssl.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docker/postgres/scripts/config-ssl.sh b/docker/postgres/scripts/config-ssl.sh index 1ec4c98..4ccfbbc 100644 --- a/docker/postgres/scripts/config-ssl.sh +++ b/docker/postgres/scripts/config-ssl.sh @@ -2,12 +2,16 @@ set -e # Append SSL configuration to postgresql.conf -echo "ssl = on" >> "$PGDATA/postgresql.conf" -echo "ssl_cert_file = '/certs/server.pem'" >> "$PGDATA/postgresql.conf" -echo "ssl_key_file = '/certs/server.key'" >> "$PGDATA/postgresql.conf" -echo "ssl_ca_file = '/certs/ca.pem'" >> "$PGDATA/postgresql.conf" +cat >> "$PGDATA/postgresql.conf" <<'EOF' +ssl = on +ssl_cert_file = '/certs/server.pem' +ssl_key_file = '/certs/server.key' +ssl_ca_file = '/certs/ca.pem' +EOF # Allow SSL connections only -echo "local all all trust" > "$PGDATA/pg_hba.conf" -echo "hostssl all all 0.0.0.0/0 md5" >> "$PGDATA/pg_hba.conf" -echo "hostssl all all ::/0 md5" >> "$PGDATA/pg_hba.conf" +cat > "$PGDATA/pg_hba.conf" <<'EOF' +local all all trust +hostssl all all 0.0.0.0/0 md5 +hostssl all all ::/0 md5 +EOF From 3d6d9248743556ad5cf8031b1363df260f629ca6 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 16 Jan 2026 16:03:58 +0100 Subject: [PATCH 7/8] update gitignore --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0417c26..4e16840 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,3 @@ DerivedData .swiftpm *.xctestplan docker/postgres/certificates -docker/mariadb/certificates From c4472ed03797ae6479048d333a94555318174d8b Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Fri, 16 Jan 2026 20:14:40 +0100 Subject: [PATCH 8/8] dep & readme --- Package.resolved | 6 +- Package.swift | 2 +- README.md | 144 ++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 121 insertions(+), 31 deletions(-) diff --git a/Package.resolved b/Package.resolved index 14d9e9f..a44b7e2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "ce28c2508daf1ba093b9d088913f73761d503126f6e8c90a437e346f3d6e2f5c", + "originHash" : "ea3b79b78be18e85af3a26baa51616c380e4cd43f77304dd1688f0bcc6cbe204", "pins" : [ { "identity" : "feather-database", "kind" : "remoteSourceControl", "location" : "https://github.com/feather-framework/feather-database", "state" : { - "branch" : "feature/swift-6", - "revision" : "9e299d72490e7a135b6b00557e5bc60285eb531c" + "revision" : "34b05e9ca725bf857c9bc6e29603a4e457f9969a", + "version" : "1.0.0-beta.1" } }, { diff --git a/Package.swift b/Package.swift index 4b248ac..416bba3 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", branch: "feature/swift-6"), + .package(url: "https://github.com/feather-framework/feather-database", exact: "1.0.0-beta.1"), ], targets: [ .target( diff --git a/README.md b/README.md index f1e9ac4..6492c41 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,136 @@ -# Feather SQL Database +# Feather Postgres Database -An abstract sql-database component for Feather CMS. +Postgres driver implementation for the abstract [Feather Database](https://github.com/feather-framework/feather-database) Swift API package. -## Getting started +![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138) -⚠️ This repository is a work in progress, things can break until it reaches v1.0.0. +## Features -Use at your own risk. +- 🤝 Postgres driver for Feather Database +- 😱 Automatic query parameter escaping via Swift string interpolation. +- 🔄 Async sequence query results with `Decodable` row support. +- 🧵 Designed for modern Swift concurrency +- 📚 DocC-based API Documentation +- ✅ Unit tests and code coverage -### Adding the dependency +## Requirements -To add a dependency on the package, declare it in your `Package.swift`: +![Swift 6.1+](https://img.shields.io/badge/Swift-6%2E1%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+ + +- Platforms: + - Linux + - macOS 15+ + - iOS 18+ + - tvOS 18+ + - watchOS 11+ + - visionOS 2+ + +## Installation + +Add the dependency to your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-sql-database.git", .upToNextMinor(from: "0.1.0")), +.package(url: "https://github.com/feather-framework/feather-postgres-database", exact: "1.0.0-beta.1"), ``` -and to your application target, add `FeatherSQLDatabase` to your dependencies: +Then add `FeatherPostgresDatabase` to your target dependencies: ```swift -.product(name: "FeatherSQLDatabase", package: "feather-sql-database") +.product(name: "FeatherPostgresDatabase", package: "feather-postgres-database"), ``` -Example `Package.swift` file with `FeatherSQLDatabase` as a dependency: + +## Usage + +![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) + +API documentation is available at the following link. + +> [!TIP] +> Avoid calling `database.execute` while in a transaction; use the transaction `connection` instead. ```swift -// swift-tools-version:5.9 -import PackageDescription - -let package = Package( - name: "my-application", - dependencies: [ - .package(url: "https://github.com/feather-framework/feather-sql-database.git", .upToNextMinor(from: "0.1.0")), - ], - targets: [ - .target(name: "MyApplication", dependencies: [ - .product(name: "FeatherSQLDatabase", package: "feather-sql-database") - ]), - .testTarget(name: "MyApplicationTests", dependencies: [ - .target(name: "MyApplication"), - ]), - ] +import Logging +import NIOSSL +import PostgresNIO +import FeatherDatabase +import FeatherPostgresDatabase + +var logger = Logger(label: "example") +logger.logLevel = .info + +let finalCertPath = URL(fileURLWithPath: "/path/to/ca.pem") +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 database = PostgresDatabaseClient( + client: client, + logger: logger ) + +try await withThrowingTaskGroup(of: Void.self) { group in + // run the client as a service + group.addTask { + await client.run() + } + // execute some query + group.addTask { + let result = try await database.execute( + query: #""" + SELECT + version() AS "version" + WHERE + 1=\#(1); + """# + ) + + for try await item in result { + let version = try item.decode(column: "version", as: String.self) + print(version) + } + } + try await group.next() + group.cancelAll() +} ``` +> [!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: + +- [SQLite](https://github.com/feather-framework/feather-sqlite-database) +- [MySQL](https://github.com/feather-framework/feather-mysql-database) + +## Development + +- Build: `swift build` +- Test: + - local: `swift test` + - using Docker: `swift docker-test` +- Format: `make format` +- Check: `make check` + +## Contributing + +[Pull requests](https://github.com/feather-framework/feather-postgres-database/pulls) are welcome. Please keep changes focused and include tests for new logic. 🙏