diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fe287d0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*.swift] +indent_style = space +indent_size = 4 +tab_width = 4 +insert_final_newline = true +trim_trailing_whitespace = true + diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6ff9614 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,8 @@ +* @gwynne +/.github/CONTRIBUTING.md @gwynne @0xTim +/.github/workflows/*.yml @gwynne @0xTim +/.github/workflows/test.yml @gwynne +/.spi.yml @gwynne @0xTim +/.gitignore @gwynne @0xTim +/LICENSE @gwynne @0xTim +/README.md @gwynne @0xTim diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index 849dbe1..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,46 +0,0 @@ -# Contributing to Fluent's PostgreSQL Driver - -πŸ‘‹ Welcome to the Vapor team! - -## Docker - -In order to build and test against Postgres, you will need a database running. The easiest way to do this is using Docker and the included `docker-compose.yml` file. - -If you have Docker installed on your computer, all you will need to do is: - -```sh -docker-compose up -``` - -This will start the two databases required for running this package's unit tests. - -## Xcode - -To open the project in Xcode: - -- Clone the repo to your computer -- Drag and drop the folder onto Xcode - -To test within Xcode, press `CMD+U`. - -## SPM - -To develop using SPM, open the code in your favorite code editor. Use the following commands from within the project's root folder to build and test. - -```sh -swift build -swift test -``` - -## SemVer - -Vapor follows [SemVer](https://semver.org). This means that any changes to the source code that can cause -existing code to stop compiling _must_ wait until the next major version to be included. - -Code that is only additive and will not break any existing code can be included in the next minor release. - ----------- - -Join us on Discord if you have any questions: [vapor.team](http://vapor.team). - -— Thanks! πŸ™Œ diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index f27346a..829c389 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -1,18 +1,17 @@ name: deploy-api-docs on: - push: - branches: - - master + push: + branches: + - main +permissions: + contents: read + id-token: write jobs: - deploy: - name: api.vapor.codes - runs-on: ubuntu-latest - steps: - - name: Deploy api-docs - uses: appleboy/ssh-action@master - with: - host: vapor.codes - username: vapor - key: ${{ secrets.VAPOR_CODES_SSH_KEY }} - script: ./github-actions/deploy-api-docs.sh + build-and-deploy: + uses: vapor/api-docs/.github/workflows/build-and-deploy-docs-workflow.yml@main + secrets: inherit + with: + package_name: fluent-postgres-driver + modules: FluentPostgresDriver + pathsToInvalidate: /fluentpostgresdriver/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 882a93c..e2f5a40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,92 +1,140 @@ name: test +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true on: - pull_request: - push: - branches: - - master + pull_request: { types: [opened, reopened, synchronize, ready_for_review] } + push: { branches: [ main ] } +permissions: + contents: read +env: + LOG_LEVEL: info + POSTGRES_HOSTNAME_A: &postgres_host_a 'psql-a' + POSTGRES_HOSTNAME_B: &postgres_host_b 'psql-b' + POSTGRES_HOSTNAME: *postgres_host_a + POSTGRES_DB_A: &postgres_db_a 'test_database_a' + POSTGRES_DB_B: &postgres_db_b 'test_database_b' + POSTGRES_DB: *postgres_db_a + POSTGRES_USER_A: &postgres_user_a 'test_username' + POSTGRES_USER_B: &postgres_user_b 'test_username' + POSTGRES_USER: *postgres_user_a + POSTGRES_PASSWORD_A: &postgres_pass_a 'test_password' + POSTGRES_PASSWORD_B: &postgres_pass_b 'test_password' + POSTGRES_PASSWORD: *postgres_pass_a + jobs: - linux: + api-breakage: + if: ${{ github.event_name == 'pull_request' && !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest + container: swift:noble + steps: + - name: Checkout + uses: actions/checkout@v5 + with: { 'fetch-depth': 0 } + - name: API breaking changes + run: | + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + swift package diagnose-api-breaking-changes origin/main + + linux-all: + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: - image: - # 5.2 Stable - - swift:5.2-xenial - - swift:5.2-bionic - # 5.2 Unstable - - swiftlang/swift:nightly-5.2-xenial - - swiftlang/swift:nightly-5.2-bionic - # 5.3 Unstable - - swiftlang/swift:nightly-5.3-xenial - - swiftlang/swift:nightly-5.3-bionic - # Master Unsable - - swiftlang/swift:nightly-master-xenial - - swiftlang/swift:nightly-master-bionic - - swiftlang/swift:nightly-master-focal - - swiftlang/swift:nightly-master-centos8 - - swiftlang/swift:nightly-master-amazonlinux2 - dbimage: - - postgres:11 - - postgres:12 - - postgres:13 - container: ${{ matrix.image }} + include: + - postgres-image-a: 'postgres:13' + postgres-image-b: 'postgres:14' + postgres-auth: 'trust' + swift-image: 'swift:6.0-noble' + - postgres-image-a: 'postgres:15' + postgres-image-b: 'postgres:16' + postgres-auth: 'md5' + swift-image: 'swift:6.1-noble' + - postgres-image-a: 'postgres:17' + postgres-image-b: 'postgres:18' + postgres-auth: 'scram-sha-256' + swift-image: 'swift:6.2-noble' + container: ${{ matrix.swift-image }} + runs-on: ubuntu-latest services: - postgres-a: - image: ${{ matrix.dbimage }} - env: - POSTGRES_USER: vapor_username - POSTGRES_PASSWORD: vapor_password - POSTGRES_DB: vapor_database - postgres-b: - image: ${{ matrix.dbimage }} + *postgres_host_a: + image: ${{ matrix.postgres-image-a }} env: - POSTGRES_USER: vapor_username - POSTGRES_PASSWORD: vapor_password - POSTGRES_DB: vapor_database - env: - POSTGRES_HOSTNAME_A: postgres-a - POSTGRES_HOSTNAME_B: postgres-b - LOG_LEVEL: info + POSTGRES_USER: *postgres_user_a + POSTGRES_DB: *postgres_db_a + POSTGRES_PASSWORD: *postgres_pass_a + POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }} + POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }} + *postgres_host_b: + image: ${{ matrix.postgres-image-b }} + env: + POSTGRES_USER: *postgres_user_b + POSTGRES_DB: *postgres_db_b + POSTGRES_PASSWORD: *postgres_pass_b + POSTGRES_HOST_AUTH_METHOD: ${{ matrix.postgres-auth }} + POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.postgres-auth }} steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Run tests with Thread Sanitizer - run: swift test --enable-test-discovery --sanitize=thread - macOS: + - name: Ensure curl is available + run: apt-get update -y && apt-get install -y curl + - name: Check out package + uses: actions/checkout@v5 + - name: Run all tests + run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable + - name: Submit coverage report to Codecov.io + uses: vapor/swift-codecov-action@v0.3 + with: + codecov_token: ${{ secrets.CODECOV_TOKEN }} + + macos-all: + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: - include: - - formula: postgresql@11 - datadir: postgresql@11 - - formula: postgresql@12 - datadir: postgres + include: + - macos-version: macos-15 + xcode-version: latest-stable + - macos-version: macos-26 + xcode-version: latest-stable + runs-on: ${{ matrix.macos-version }} env: - POSTGRES_DATABASE_A: vapor_database_a - POSTGRES_DATABASE_B: vapor_database_b - runs-on: macos-latest + LOG_LEVEL: debug + POSTGRES_HOSTNAME: 127.0.0.1 + POSTGRES_HOSTNAME_A: 127.0.0.1 + POSTGRES_HOSTNAME_B: 127.0.0.1 steps: - name: Select latest available Xcode - uses: maxim-lobanov/setup-xcode@1.0 + uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: latest - - name: Replace Postgres install and start server - run: | - brew uninstall --force postgresql php && rm -rf /usr/local/{etc,var}/{postgres,pg}* - brew install ${{ matrix.formula }} && brew link --force ${{ matrix.formula }} - initdb --locale=C -E UTF-8 $(brew --prefix)/var/${{ matrix.datadir }} - brew services start ${{ matrix.formula }} - - name: Wait for server to be ready - run: until pg_isready; do sleep 1; done - timeout-minutes: 2 - - name: Setup users and databases for Postgres + xcode-version: ${{ matrix.xcode-version }} + - name: Install Postgres, setup DB and auth, and wait for server start run: | - createuser --createdb --login vapor_username - for db in vapor_database_{a,b}; do - createdb -Ovapor_username $db && psql $db <<<"alter SCHEMA public OWNER TO vapor_username;" - done + brew upgrade || true + export PATH="$(brew --prefix)/opt/postgresql@16/bin:$PATH" PGDATA=/tmp/vapor-postgres-test PGUSER="${POSTGRES_USER_A}" + brew install postgresql@18 && brew link --force postgresql@18 + initdb --locale=C --auth-host "scram-sha-256" -U "${POSTGRES_USER_A}" --pwfile=<(echo "${POSTGRES_PASSWORD_A}") + pg_ctl start --wait + PGPASSWORD="${POSTGRES_PASSWORD_A}" createdb -w -O "${POSTGRES_USER_A}" "${POSTGRES_DB_A}" + PGPASSWORD="${POSTGRES_PASSWORD_A}" createdb -w -O "${POSTGRES_USER_B}" "${POSTGRES_DB_B}" + PGPASSWORD="${POSTGRES_PASSWORD_A}" psql -w "${POSTGRES_DB_A}" <<<"alter SCHEMA public OWNER TO ${POSTGRES_USER_A};" + PGPASSWORD="${POSTGRES_PASSWORD_A}" psql -w "${POSTGRES_DB_B}" <<<"alter SCHEMA public OWNER TO ${POSTGRES_USER_B};" + timeout-minutes: 15 - name: Checkout code - uses: actions/checkout@v2 - - name: Run tests with Thread Sanitizer - run: swift test --enable-test-discovery --sanitize=thread + uses: actions/checkout@v5 + - name: Run all tests + run: swift test --enable-code-coverage --explicit-target-dependency-import-check error -Xswiftc -require-explicit-sendable + - name: Submit coverage report to Codecov.io + uses: vapor/swift-codecov-action@v0.3 + with: + codecov_token: ${{ secrets.CODECOV_TOKEN }} + + musl: + runs-on: ubuntu-latest + container: swift:6.2-noble + timeout-minutes: 30 + steps: + - name: Check out code + uses: actions/checkout@v5 + - name: Install SDK + run: swift sdk install https://download.swift.org/swift-6.2-release/static-sdk/swift-6.2-RELEASE/swift-6.2-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum d2225840e592389ca517bbf71652f7003dbf45ac35d1e57d98b9250368769378 + - name: Build + run: swift build --swift-sdk x86_64-swift-linux-musl diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..04c7ade --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +metadata: + authors: "Maintained by the Vapor Core Team with hundreds of contributions from the Vapor Community." +external_links: + documentation: "https://api.vapor.codes/fluentpostgresdriver/documentation/fluentpostgresdriver/" diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..e95ade0 --- /dev/null +++ b/.swift-format @@ -0,0 +1,77 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : false, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 4 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 150, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : { + "never" : { + } + }, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : true, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : true, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : true, + "spacesBeforeEndOfLineComments" : 1, + "tabversion" : 1 +} diff --git a/Package.swift b/Package.swift index ff0cc7c..b211c69 100644 --- a/Package.swift +++ b/Package.swift @@ -1,29 +1,49 @@ -// swift-tools-version:5.2 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "fluent-postgres-driver", platforms: [ - .macOS(.v10_15) + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), ], products: [ .library(name: "FluentPostgresDriver", targets: ["FluentPostgresDriver"]), ], dependencies: [ - .package(url: "https://github.com/vapor/async-kit.git", from: "1.2.0"), - .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.0.0"), - .package(url: "https://github.com/vapor/postgres-kit.git", from: "2.3.0"), + .package(url: "https://github.com/vapor/fluent-kit.git", from: "1.52.2"), + .package(url: "https://github.com/vapor/postgres-kit.git", from: "2.14.1"), + .package(url: "https://github.com/vapor/async-kit.git", from: "1.21.0"), ], targets: [ - .target(name: "FluentPostgresDriver", dependencies: [ - .product(name: "AsyncKit", package: "async-kit"), - .product(name: "FluentKit", package: "fluent-kit"), - .product(name: "FluentSQL", package: "fluent-kit"), - .product(name: "PostgresKit", package: "postgres-kit"), - ]), - .testTarget(name: "FluentPostgresDriverTests", dependencies: [ - .product(name: "FluentBenchmark", package: "fluent-kit"), - .target(name: "FluentPostgresDriver"), - ]), + .target( + name: "FluentPostgresDriver", + dependencies: [ + .product(name: "FluentKit", package: "fluent-kit"), + .product(name: "FluentSQL", package: "fluent-kit"), + .product(name: "PostgresKit", package: "postgres-kit"), + .product(name: "AsyncKit", package: "async-kit"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "FluentPostgresDriverTests", + dependencies: [ + .product(name: "FluentBenchmark", package: "fluent-kit"), + .target(name: "FluentPostgresDriver"), + ], + swiftSettings: swiftSettings + ), ] ) + +var swiftSettings: [SwiftSetting] { [ + .enableUpcomingFeature("ExistentialAny"), + //.enableUpcomingFeature("InternalImportsByDefault"), + .enableUpcomingFeature("MemberImportVisibility"), + .enableUpcomingFeature("InferIsolatedConformances"), + //.enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("ImmutableWeakCaptures"), +] } diff --git a/README.md b/README.md index 84be6fa..08a2ba1 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,31 @@

- FluentPostgresDriver -
-
- - Documentation - - - Team Chat - - - MIT License - - - Continuous Integration - - - Swift 5.2 - +FluentPostgresDriver +
+
+Documentation +Team Chat +MIT License +Continuous Integration +Code Coverage +Swift 6.0+

+ +
+ +FluentPostgresDriver is a [FluentKit] driver for PostgreSQL clients. It provides support for using the Fluent ORM with PostgreSQL databases, and uses [PostgresKit] to provide [SQLKit] driver services, [PostgresNIO] to connect and communicate with the database server asynchronously, and [AsyncKit] to provide connection pooling. + +[FluentKit]: https://github.com/vapor/fluent-kit +[SQLKit]: https://github.com/vapor/sql-kit +[PostgresKit]: https://github.com/vapor/postgres-kit +[PostgresNIO]: https://github.com/vapor/postgres-nio +[AsyncKit]: https://github.com/vapor/async-kit + +### Usage + +Use the SPM string to easily include the dependendency in your `Package.swift` file: + +```swift +.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0") +``` + +For additional information, see [the Fluent documentation](https://docs.vapor.codes/fluent/overview/). diff --git a/Sources/FluentPostgresDriver/DatabaseID+PostgreSQL.swift b/Sources/FluentPostgresDriver/DatabaseID+PostgreSQL.swift new file mode 100644 index 0000000..d50397f --- /dev/null +++ b/Sources/FluentPostgresDriver/DatabaseID+PostgreSQL.swift @@ -0,0 +1,7 @@ +import FluentKit + +extension DatabaseID { + public static var psql: DatabaseID { + .init(string: "psql") + } +} diff --git a/Sources/FluentPostgresDriver/Deprecations/FluentPostgresConfiguration+Deprecated.swift b/Sources/FluentPostgresDriver/Deprecations/FluentPostgresConfiguration+Deprecated.swift new file mode 100644 index 0000000..5948911 --- /dev/null +++ b/Sources/FluentPostgresDriver/Deprecations/FluentPostgresConfiguration+Deprecated.swift @@ -0,0 +1,135 @@ +import FluentKit +import Foundation +import Logging +import NIOCore +import PostgresKit +import PostgresNIO + +// N.B.: This excessive method duplication is required to maintain the original public API, which allowed defaulting +// either the encoder, decoder, or both. The "defaulting both" versions now forward to the new API, the others are here. + +// Factory methods accepting both encoder and decoder +extension DatabaseConfigurationFactory { + enum FluentPostgresError: Error { + case invalidURL(String) + } + + @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + url: String, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10), + encoder: PostgresDataEncoder, decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug + ) throws -> DatabaseConfigurationFactory { + guard let configuration = PostgresConfiguration(url: url) else { throw FluentPostgresError.invalidURL(url) } + return .postgres(configuration: configuration, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encoder: encoder, decoder: decoder, sqlLogLevel: sqlLogLevel + ) + } + + @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + url: URL, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10), + encoder: PostgresDataEncoder, decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug + ) throws -> DatabaseConfigurationFactory { + guard let configuration = PostgresConfiguration(url: url) else { throw FluentPostgresError.invalidURL(url.absoluteString) } + return .postgres( configuration: configuration, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encoder: encoder, decoder: decoder, sqlLogLevel: sqlLogLevel + ) + } + + @available(*, deprecated, message: "Use `.postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + hostname: String, port: Int = PostgresConfiguration.ianaPortNumber, + username: String, password: String, database: String? = nil, tlsConfiguration: TLSConfiguration? = nil, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + encoder: PostgresDataEncoder = .init(), + decoder: PostgresDataDecoder = .init(), + sqlLogLevel: Logger.Level = .debug + ) -> DatabaseConfigurationFactory { + .postgres(configuration: .init( + hostname: hostname, port: port, username: username, password: password, database: database, tlsConfiguration: tlsConfiguration), + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encoder: encoder, decoder: decoder, sqlLogLevel: sqlLogLevel + ) + } + + @available(*, deprecated, message: "Use `.postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + configuration: PostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + encoder: PostgresDataEncoder = .init(), + decoder: PostgresDataDecoder = .init(), + sqlLogLevel: Logger.Level = .debug + ) -> DatabaseConfigurationFactory { + .postgres( + configuration: .init(legacyConfiguration: configuration), + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encodingContext: .init(jsonEncoder: TypeErasedPostgresJSONEncoder(json: encoder.json)), + decodingContext: .init(jsonDecoder: TypeErasedPostgresJSONDecoder(json: decoder.json)), + sqlLogLevel: sqlLogLevel + ) + } +} + +// Factory methods accepting only encoder or only decoder. +extension DatabaseConfigurationFactory { + @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + url: String, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10), + encoder: PostgresDataEncoder, sqlLogLevel: Logger.Level = .debug + ) throws -> DatabaseConfigurationFactory { + try .postgres(url: url, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encoder: encoder, decoder: .init(), sqlLogLevel: sqlLogLevel + ) + } + + @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + url: String, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10), + decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug + ) throws -> DatabaseConfigurationFactory { + try .postgres(url: url, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encoder: .init(), decoder: decoder, sqlLogLevel: sqlLogLevel + ) + } + + @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + url: URL, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10), + encoder: PostgresDataEncoder, sqlLogLevel: Logger.Level = .debug + ) throws -> DatabaseConfigurationFactory { + try .postgres(url: url, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encoder: encoder, decoder: .init(), sqlLogLevel: sqlLogLevel + ) + } + + @available(*, deprecated, message: "Use `.postgres(url:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)` instead.") + public static func postgres( + url: URL, maxConnectionsPerEventLoop: Int = 1, connectionPoolTimeout: TimeAmount = .seconds(10), + decoder: PostgresDataDecoder, sqlLogLevel: Logger.Level = .debug + ) throws -> DatabaseConfigurationFactory { + try .postgres(url: url, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, + encoder: .init(), decoder: decoder, sqlLogLevel: sqlLogLevel + ) + } +} + +// N.B.: If you change something in these two types, update the copies in PostgresKit too. +fileprivate struct TypeErasedPostgresJSONDecoder: PostgresJSONDecoder { + let json: any PostgresJSONDecoder + func decode(_: T.Type, from: Data) throws -> T { try self.json.decode(T.self, from: from) } + func decode(_: T.Type, from: ByteBuffer) throws -> T { try self.json.decode(T.self, from: from) } +} + +fileprivate struct TypeErasedPostgresJSONEncoder: PostgresJSONEncoder { + let json: any PostgresJSONEncoder + func encode(_ value: T) throws -> Data { try self.json.encode(value) } + func encode(_ value: T, into: inout ByteBuffer) throws { try self.json.encode(value, into: &into) } +} diff --git a/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg b/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg new file mode 100644 index 0000000..79211b7 --- /dev/null +++ b/Sources/FluentPostgresDriver/Docs.docc/Resources/vapor-fluentpostgresdriver-logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/FluentPostgresDriver/Docs.docc/index.md b/Sources/FluentPostgresDriver/Docs.docc/index.md new file mode 100644 index 0000000..08593bc --- /dev/null +++ b/Sources/FluentPostgresDriver/Docs.docc/index.md @@ -0,0 +1,14 @@ +# ``FluentPostgresDriver`` + +FluentPostgresDriver is a [FluentKit] driver for PostgreSQL clients. + +## Overview + +FluentPostgresDriver provides support for using the Fluent ORM with PostgresSQL databases. It uses [PostgresKit] to provide [SQLKit] driver services, [PostgresNIO] to connect and communicate with the database server asynchronously, and [AsyncKit] to provide connection pooling. + +[FluentKit]: https://github.com/vapor/fluent-kit +[SQLKit]: https://github.com/vapor/sql-kit +[PostgresKit]: https://github.com/vapor/postgres-kit +[PostgresNIO]: https://github.com/vapor/postgres-nio +[AsyncKit]: https://github.com/vapor/async-kit + diff --git a/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json b/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json new file mode 100644 index 0000000..c0625a0 --- /dev/null +++ b/Sources/FluentPostgresDriver/Docs.docc/theme-settings.json @@ -0,0 +1,24 @@ +{ + "theme": { + "aside": { "border-radius": "16px", "border-width": "3px", "border-style": "double" }, + "border-radius": "0", + "button": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "code": { "border-radius": "16px", "border-width": "1px", "border-style": "solid" }, + "color": { + "fluentpsqldriver": "#336791", + "documentation-intro-fill": "radial-gradient(circle at top, var(--color-fluentpsqldriver) 30%, #000 100%)", + "documentation-intro-accent": "var(--color-fluentpsqldriver)", + "hero-eyebrow": "white", + "documentation-intro-figure": "white", + "hero-title": "white", + "logo-base": { "dark": "#fff", "light": "#000" }, + "logo-shape": { "dark": "#000", "light": "#fff" }, + "fill": { "dark": "#000", "light": "#fff" } + }, + "icons": { "technology": "/fluentpostgresdriver/images/FluentPostgresDriver/vapor-fluentpostgresdriver-logo.svg" } + }, + "features": { + "quickNavigation": { "enable": true }, + "i18n": { "enable": true } + } +} diff --git a/Sources/FluentPostgresDriver/Exports.swift b/Sources/FluentPostgresDriver/Exports.swift index 0febffa..f33f24c 100644 --- a/Sources/FluentPostgresDriver/Exports.swift +++ b/Sources/FluentPostgresDriver/Exports.swift @@ -1,8 +1,2 @@ -@_exported import FluentKit -@_exported import PostgresKit - -extension DatabaseID { - public static var psql: DatabaseID { - return .init(string: "psql") - } -} +@_documentation(visibility: internal) @_exported import FluentKit +@_documentation(visibility: internal) @_exported import PostgresKit diff --git a/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift b/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift index 582322d..56ca567 100644 --- a/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift +++ b/Sources/FluentPostgresDriver/FluentPostgresConfiguration.swift @@ -1,110 +1,385 @@ +import AsyncKit +import FluentKit +import Foundation +import Logging +import NIOCore +import PostgresKit +import PostgresNIO + extension DatabaseConfigurationFactory { + /// Create a PostgreSQL database configuration from a URL string. + /// + /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format. + /// + /// - Parameters: + /// - urlString: The URL describing the connection, as a string. + /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop. + /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request. + /// - encodingContext: Encoding context to use for serializing data. + /// - decodingContext: Decoding context to use for deserializing data. + /// - sqlLogLevel: Level at which to log SQL queries. public static func postgres( url urlString: String, maxConnectionsPerEventLoop: Int = 1, - connectionPoolTimeout: NIO.TimeAmount = .seconds(10), - encoder: PostgresDataEncoder = .init(), - decoder: PostgresDataDecoder = .init() - ) throws -> DatabaseConfigurationFactory { - guard let url = URL(string: urlString) else { - throw FluentPostgresError.invalidURL(urlString) - } - return try .postgres( - url: url, + connectionPoolTimeout: TimeAmount = .seconds(10), + encodingContext: PostgresEncodingContext = .default, + decodingContext: PostgresDecodingContext = .default, + sqlLogLevel: Logger.Level = .debug + ) throws -> Self { + .postgres( + configuration: try .init(url: urlString), maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, - encoder: encoder, - decoder: decoder + pruneInterval: nil, + encodingContext: encodingContext, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel ) } + /// Create a PostgreSQL database configuration from a URL. + /// + /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format. + /// + /// - Parameters: + /// - url: The URL describing the connection. + /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop. + /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request. + /// - encodingContext: Encoding context to use for serializing data. + /// - decodingContext: Decoding context to use for deserializing data. + /// - sqlLogLevel: Level at which to log SQL queries. public static func postgres( url: URL, maxConnectionsPerEventLoop: Int = 1, - connectionPoolTimeout: NIO.TimeAmount = .seconds(10), - encoder: PostgresDataEncoder = .init(), - decoder: PostgresDataDecoder = .init() - ) throws -> DatabaseConfigurationFactory { - guard let configuration = PostgresConfiguration(url: url) else { - throw FluentPostgresError.invalidURL(url.absoluteString) - } - return .postgres( + connectionPoolTimeout: TimeAmount = .seconds(10), + encodingContext: PostgresEncodingContext = .default, + decodingContext: PostgresDecodingContext = .default, + sqlLogLevel: Logger.Level = .debug + ) throws -> Self { + .postgres( + configuration: try .init(url: url), + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: nil, + encodingContext: encodingContext, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel + ) + } + + /// Create a PostgreSQL database configuration from lower-level configuration. + /// + /// - Parameters: + /// - configuration: A ``PostgresKit/SQLPostgresConfiguration`` describing the connection. + /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop. + /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request. + /// - encodingContext: Encoding context to use for serializing data. + /// - decodingContext: Decoding context to use for deserializing data. + /// - sqlLogLevel: Level at which to log SQL queries. + public static func postgres( + configuration: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + encodingContext: PostgresEncodingContext, + decodingContext: PostgresDecodingContext, + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .postgres( + configuration: configuration, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: nil, + encodingContext: encodingContext, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel + ) + } +} + +extension DatabaseConfigurationFactory { + /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)`` + /// with the `decodingContext` defaulted. + public static func postgres( + configuration: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + encodingContext: PostgresEncodingContext, + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .postgres( + configuration: configuration, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: nil, + encodingContext: encodingContext, + decodingContext: .default, + sqlLogLevel: sqlLogLevel + ) + } + + /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)`` + /// with the `encodingContext` defaulted. + public static func postgres( + configuration: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + decodingContext: PostgresDecodingContext, + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .postgres( configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, - connectionPoolTimeout: connectionPoolTimeout + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: nil, + encodingContext: .default, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel + ) + } + + /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:encodingContext:decodingContext:sqlLogLevel:)`` + /// with both `encodingContext` and `decodingContext` defaulted. + public static func postgres( + configuration: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .postgres( + configuration: configuration, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: nil, + encodingContext: .default, + decodingContext: .default, + sqlLogLevel: sqlLogLevel + ) + } +} + +extension DatabaseConfigurationFactory { + /// Create a PostgreSQL database configuration from a URL string. + /// + /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format. + /// + /// - Parameters: + /// - urlString: The URL describing the connection, as a string. + /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop. + /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request. + /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), + /// no pruning is performed. + /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. + /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. + /// - encodingContext: Encoding context to use for serializing data. + /// - decodingContext: Decoding context to use for deserializing data. + /// - sqlLogLevel: Level at which to log SQL queries. + public static func postgres( + url urlString: String, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + pruneInterval: TimeAmount?, + maxIdleTimeBeforePruning: TimeAmount = .seconds(120), + encodingContext: PostgresEncodingContext = .default, + decodingContext: PostgresDecodingContext = .default, + sqlLogLevel: Logger.Level = .debug + ) throws -> Self { + .postgres( + configuration: try .init(url: urlString), + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, + encodingContext: encodingContext, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel ) } + /// Create a PostgreSQL database configuration from a URL. + /// + /// See ``PostgresKit/SQLPostgresConfiguration/init(url:)`` for the allowed URL format. + /// + /// - Parameters: + /// - url: The URL describing the connection. + /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop. + /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request. + /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), + /// no pruning is performed. + /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. + /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. + /// - encodingContext: Encoding context to use for serializing data. + /// - decodingContext: Decoding context to use for deserializing data. + /// - sqlLogLevel: Level at which to log SQL queries. public static func postgres( - hostname: String, - port: Int = PostgresConfiguration.ianaPortNumber, - username: String, - password: String, - database: String? = nil, - tlsConfiguration: TLSConfiguration? = nil, + url: URL, maxConnectionsPerEventLoop: Int = 1, - connectionPoolTimeout: NIO.TimeAmount = .seconds(10), - encoder: PostgresDataEncoder = .init(), - decoder: PostgresDataDecoder = .init() - ) -> DatabaseConfigurationFactory { - return .postgres( - configuration: .init( - hostname: hostname, - port: port, - username: username, - password: password, - database: database, - tlsConfiguration: tlsConfiguration - ), + connectionPoolTimeout: TimeAmount = .seconds(10), + pruneInterval: TimeAmount?, + maxIdleTimeBeforePruning: TimeAmount = .seconds(120), + encodingContext: PostgresEncodingContext = .default, + decodingContext: PostgresDecodingContext = .default, + sqlLogLevel: Logger.Level = .debug + ) throws -> Self { + .postgres( + configuration: try .init(url: url), maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, - connectionPoolTimeout: connectionPoolTimeout + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, + encodingContext: encodingContext, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel ) } + /// Create a PostgreSQL database configuration from lower-level configuration. + /// + /// - Parameters: + /// - configuration: A ``PostgresKit/SQLPostgresConfiguration`` describing the connection. + /// - maxConnectionsPerEventLoop: Maximum number of connections to open per event loop. + /// - connectionPoolTimeout: Maximum time to wait for a connection to become available per request. + /// - pruneInterval: How often to check for and prune idle database connections. If `nil` (the default), + /// no pruning is performed. + /// - maxIdleTimeBeforePruning: How long a connection may remain idle before being pruned, if pruning is enabled. + /// Defaults to 2 minutes. Ignored if `pruneInterval` is `nil`. + /// - encodingContext: Encoding context to use for serializing data. + /// - decodingContext: Decoding context to use for deserializing data. + /// - sqlLogLevel: Level at which to log SQL queries. public static func postgres( - configuration: PostgresConfiguration, + configuration: SQLPostgresConfiguration, maxConnectionsPerEventLoop: Int = 1, - connectionPoolTimeout: NIO.TimeAmount = .seconds(10), - encoder: PostgresDataEncoder = .init(), - decoder: PostgresDataDecoder = .init() - ) -> DatabaseConfigurationFactory { - return DatabaseConfigurationFactory { + connectionPoolTimeout: TimeAmount = .seconds(10), + pruneInterval: TimeAmount?, + maxIdleTimeBeforePruning: TimeAmount = .seconds(120), + encodingContext: PostgresEncodingContext, + decodingContext: PostgresDecodingContext, + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .init { FluentPostgresConfiguration( - middleware: [], configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, connectionPoolTimeout: connectionPoolTimeout, - encoder: encoder, - decoder: decoder + pruningInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, + encodingContext: encodingContext, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel ) } } } -struct FluentPostgresConfiguration: DatabaseConfiguration { - var middleware: [AnyModelMiddleware] - let configuration: PostgresConfiguration - let maxConnectionsPerEventLoop: Int - /// The amount of time to wait for a connection from - /// the connection pool before timing out. - let connectionPoolTimeout: NIO.TimeAmount - let encoder: PostgresDataEncoder - let decoder: PostgresDataDecoder +/// We'd like to just default the context parameters of the "actual" method. Unfortunately, there are a few +/// cases involving the UNIX domain socket initalizer where usage can resolve to either the new +/// `SQLPostgresConfiguration`-based method or the deprecated `PostgresConfiguration`-based method, with no +/// obvious way to disambiguate which to call. Because the context parameters are generic, if they are defaulted, +/// the compiler resolves the ambiguity in favor of the deprecated method (which has no generic parameters). +/// However, by adding the non-defaulted-parameter variant which takes neither context, we've provided a version +/// which has no generic parameters either, which allows the compiler to resolve the ambiguity in favor of +/// the one with better availability (i.e. the one that isn't deprecated). +/// +/// Example affected code: +/// +/// _ = DatabaseConfigurationFactory.postgres(configuration: .init(unixDomainSocketPath: "", username: "")) +extension DatabaseConfigurationFactory { + /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:pruneInterval:maxIdleTimeBeforePruning:encodingContext:decodingContext:sqlLogLevel:)`` + /// with the `decodingContext` defaulted. + public static func postgres( + configuration: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + pruneInterval: TimeAmount?, + maxIdleTimeBeforePruning: TimeAmount = .seconds(120), + encodingContext: PostgresEncodingContext, + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .postgres( + configuration: configuration, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, + encodingContext: encodingContext, + decodingContext: .default, + sqlLogLevel: sqlLogLevel + ) + } - func makeDriver(for databases: Databases) -> DatabaseDriver { - let db = PostgresConnectionSource( - configuration: configuration + /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:pruneInterval:maxIdleTimeBeforePruning:encodingContext:decodingContext:sqlLogLevel:)`` + /// with the `encodingContext` defaulted. + public static func postgres( + configuration: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + pruneInterval: TimeAmount?, + maxIdleTimeBeforePruning: TimeAmount = .seconds(120), + decodingContext: PostgresDecodingContext, + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .postgres( + configuration: configuration, + maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, + encodingContext: .default, + decodingContext: decodingContext, + sqlLogLevel: sqlLogLevel ) - let pool = EventLoopGroupConnectionPool( - source: db, + } + + /// ``postgres(configuration:maxConnectionsPerEventLoop:connectionPoolTimeout:pruneInterval:maxIdleTimeBeforePruning:encodingContext:decodingContext:sqlLogLevel:)`` + /// with both `encodingContext` and `decodingContext` defaulted. + public static func postgres( + configuration: SQLPostgresConfiguration, + maxConnectionsPerEventLoop: Int = 1, + connectionPoolTimeout: TimeAmount = .seconds(10), + pruneInterval: TimeAmount?, + maxIdleTimeBeforePruning: TimeAmount = .seconds(120), + sqlLogLevel: Logger.Level = .debug + ) -> Self { + .postgres( + configuration: configuration, maxConnectionsPerEventLoop: maxConnectionsPerEventLoop, - requestTimeout: connectionPoolTimeout, + connectionPoolTimeout: connectionPoolTimeout, + pruneInterval: pruneInterval, + maxIdleTimeBeforePruning: maxIdleTimeBeforePruning, + encodingContext: .default, + decodingContext: .default, + sqlLogLevel: sqlLogLevel + ) + } +} + +/// The actual concrete configuration type produced by a configuration factory. +struct FluentPostgresConfiguration: DatabaseConfiguration { + var middleware: [any AnyModelMiddleware] = [] + fileprivate let configuration: SQLPostgresConfiguration + let maxConnectionsPerEventLoop: Int + let connectionPoolTimeout: TimeAmount + let pruningInterval: TimeAmount? + let maxIdleTimeBeforePruning: TimeAmount + let encodingContext: PostgresEncodingContext + let decodingContext: PostgresDecodingContext + let sqlLogLevel: Logger.Level + + func makeDriver(for databases: Databases) -> any DatabaseDriver { + let connectionSource = PostgresConnectionSource(sqlConfiguration: self.configuration) + let elgPool = EventLoopGroupConnectionPool( + source: connectionSource, + maxConnectionsPerEventLoop: self.maxConnectionsPerEventLoop, + requestTimeout: self.connectionPoolTimeout, + pruneInterval: self.pruningInterval, + maxIdleTimeBeforePruning: self.maxIdleTimeBeforePruning, on: databases.eventLoopGroup ) + return _FluentPostgresDriver( - pool: pool, - encoder: encoder, - decoder: decoder + pool: elgPool, + encodingContext: self.encodingContext, + decodingContext: self.decodingContext, + sqlLogLevel: self.sqlLogLevel ) } } diff --git a/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift b/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift index 4ecdbac..e54b10d 100644 --- a/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift +++ b/Sources/FluentPostgresDriver/FluentPostgresDatabase.swift @@ -1,147 +1,155 @@ +import FluentKit import FluentSQL +import Logging +import PostgresKit +import PostgresNIO +import SQLKit -struct _FluentPostgresDatabase { - let database: PostgresDatabase +struct _FluentPostgresDatabase { + let database: any SQLDatabase let context: DatabaseContext - let encoder: PostgresDataEncoder - let decoder: PostgresDataDecoder + let encodingContext: PostgresEncodingContext + let decodingContext: PostgresDecodingContext let inTransaction: Bool } extension _FluentPostgresDatabase: Database { func execute( query: DatabaseQuery, - onOutput: @escaping (DatabaseOutput) -> () + onOutput: @escaping @Sendable (any DatabaseOutput) -> Void ) -> EventLoopFuture { - var expression = SQLQueryConverter(delegate: PostgresConverterDelegate()) - .convert(query) - switch query.action { - case .create: - expression = PostgresReturningID( - base: expression, - idKey: query.customIDKey ?? .id - ) - default: break - } - let (sql, binds) = self.serialize(expression) - do { - return try self.query(sql, binds.map { try self.encoder.encode(0ドル) }) { - onOutput(0ドル.databaseOutput(using: self.decoder)) - } - } catch { - return self.eventLoop.makeFailedFuture(error) + var expression = SQLQueryConverter(delegate: PostgresConverterDelegate()).convert(query) + + /// For `.create` query actions, we want to return the generated IDs, unless the `customIDKey` is the + /// empty string, which we use as a very hacky signal for "we don't implement this for composite IDs yet". + if case .create = query.action, query.customIDKey != .some(.string("")) { + expression = SQLKit.SQLList([expression, SQLReturning(.init((query.customIDKey ?? .id).description))], separator: SQLRaw(" ")) } + + return self.execute(sql: expression, { onOutput(0ドル.databaseOutput()) }) } func execute(schema: DatabaseSchema) -> EventLoopFuture { - let expression = SQLSchemaConverter(delegate: PostgresConverterDelegate()) - .convert(schema) - let (sql, binds) = self.serialize(expression) - do { - return try self.query(sql, binds.map { try self.encoder.encode(0ドル) }) { - fatalError("unexpected row: \(0ドル)") - } - } catch { - return self.eventLoop.makeFailedFuture(error) - } + let expression = SQLSchemaConverter(delegate: PostgresConverterDelegate()).convert(schema) + + return self.execute( + sql: expression, + // N.B.: Don't fatalError() here; what're users supposed to do about it? + { self.logger.debug("Unexpected row returned from schema query: \(0ドル)") } + ) } func execute(enum e: DatabaseEnum) -> EventLoopFuture { switch e.action { case .create: - let builder = self.sql().create(enum: e.name) - for c in e.createCases { - _ = builder.value(c) - } - return builder.run() + return e.createCases.reduce(self.create(enum: e.name)) { 0ドル.value(1ドル) }.run() case .update: if !e.deleteCases.isEmpty { - self.logger.error("PostgreSQL does not support deleting enum cases.") + self.logger.debug("PostgreSQL does not support deleting enum cases.") } guard !e.createCases.isEmpty else { return self.eventLoop.makeSucceededFuture(()) } - let builder = self.sql().alter(enum: e.name) - for create in e.createCases { - _ = builder.add(value: create) - } - return builder.run() + + return self.eventLoop.flatten( + e.createCases.map { create in + self.alter(enum: e.name).add(value: create).run() + } + ) case .delete: - return self.sql().drop(enum: e.name).run() + return self.drop(enum: e.name).run() } } - func transaction(_ closure: @escaping (Database) -> EventLoopFuture) -> EventLoopFuture { + func transaction(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { guard !self.inTransaction else { return closure(self) } - return self.database.withConnection { conn in - conn.simpleQuery("BEGIN").flatMap { _ in - let db = _FluentPostgresDatabase( - database: conn, - context: self.context, - encoder: self.encoder, - decoder: self.decoder, - inTransaction: true + return self.withConnection { conn in + guard let sqlConn = conn as? any SQLDatabase else { + fatalError( + """ + Connection yielded by a Fluent+Postgres database is not also an SQLDatabase. + This is a bug in Fluent; please report it at https://github.com/vapor/fluent-postgres-driver/issues + """ ) - return closure(db).flatMap { result in - conn.simpleQuery("COMMIT").map { _ in - result - } + } + return sqlConn.raw("BEGIN").run().flatMap { + closure(conn).flatMap { result in + sqlConn.raw("COMMIT").run().and(value: result).map { 1ドル } }.flatMapError { error in - conn.simpleQuery("ROLLBACK").flatMapThrowing { _ in - throw error - } + sqlConn.raw("ROLLBACK").run().flatMapThrowing { throw error } } } } } - - func withConnection(_ closure: @escaping (Database) -> EventLoopFuture) -> EventLoopFuture { - self.database.withConnection { - closure(_FluentPostgresDatabase( - database: 0,ドル - context: self.context, - encoder: self.encoder, - decoder: self.decoder, - inTransaction: self.inTransaction - )) + + func withConnection(_ closure: @escaping @Sendable (any Database) -> EventLoopFuture) -> EventLoopFuture { + self.withConnection { (underlying: any PostgresDatabase) in + closure( + _FluentPostgresDatabase( + database: underlying.sql( + encodingContext: self.encodingContext, + decodingContext: self.decodingContext, + queryLogLevel: self.database.queryLogLevel + ), + context: self.context, + encodingContext: self.encodingContext, + decodingContext: self.decodingContext, + inTransaction: true + ) + ) } } } -extension _FluentPostgresDatabase: SQLDatabase { - var dialect: SQLDialect { - PostgresDialect() +extension _FluentPostgresDatabase: TransactionControlDatabase { + func beginTransaction() -> EventLoopFuture { + self.raw("BEGIN").run() } - - public func execute( - sql query: SQLExpression, - _ onRow: @escaping (SQLRow) -> () - ) -> EventLoopFuture { - self.sql(encoder: encoder, decoder: decoder).execute(sql: query, onRow) + + func commitTransaction() -> EventLoopFuture { + self.raw("COMMIT").run() + } + + func rollbackTransaction() -> EventLoopFuture { + self.raw("ROLLBACK").run() } } -extension _FluentPostgresDatabase: PostgresDatabase { - func send(_ request: PostgresRequest, logger: Logger) -> EventLoopFuture { - self.database.send(request, logger: logger) +extension _FluentPostgresDatabase: SQLDatabase { + var version: (any SQLDatabaseReportedVersion)? { self.database.version } + var dialect: any SQLDialect { self.database.dialect } + var queryLogLevel: Logger.Level? { self.database.queryLogLevel } + + func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> Void) -> EventLoopFuture { + self.database.execute(sql: query, onRow) } - - func withConnection(_ closure: @escaping (PostgresConnection) -> EventLoopFuture) -> EventLoopFuture { - self.database.withConnection(closure) + + func execute(sql query: any SQLExpression, _ onRow: @escaping @Sendable (any SQLRow) -> Void) async throws { + try await self.database.execute(sql: query, onRow) + } + + func withSession(_ closure: @escaping @Sendable (any SQLDatabase) async throws -> R) async throws -> R { + try await self.database.withSession(closure) } } -private struct PostgresReturningID: SQLExpression { - let base: SQLExpression - let idKey: FieldKey +extension _FluentPostgresDatabase: PostgresDatabase { + func send(_ request: any PostgresRequest, logger: Logger) -> EventLoopFuture { + self.withConnection { 0ドル.send(request, logger: logger) } + } - func serialize(to serializer: inout SQLSerializer) { - serializer.statement { - 0ドル.append(self.base) - 0ドル.append("RETURNING") - 0ドル.append(SQLIdentifier(self.idKey.description)) + func withConnection(_ closure: @escaping (PostgresConnection) -> EventLoopFuture) -> EventLoopFuture { + guard let psqlDb: any PostgresDatabase = self.database as? any PostgresDatabase else { + fatalError( + """ + Connection yielded by a Fluent+Postgres database is not also a PostgresDatabase. + This is a bug in Fluent; please report it at https://github.com/vapor/fluent-postgres-driver/issues + """ + ) } + + return psqlDb.withConnection(closure) } } diff --git a/Sources/FluentPostgresDriver/FluentPostgresDriver.swift b/Sources/FluentPostgresDriver/FluentPostgresDriver.swift index 3f4d9fe..53ebcb0 100644 --- a/Sources/FluentPostgresDriver/FluentPostgresDriver.swift +++ b/Sources/FluentPostgresDriver/FluentPostgresDriver.swift @@ -1,27 +1,34 @@ -enum FluentPostgresError: Error { - case invalidURL(String) -} +import AsyncKit +import FluentKit +import Logging +import NIOCore +import PostgresKit -struct _FluentPostgresDriver: DatabaseDriver { +/// Marked `@unchecked Sendable` to silence warning about `PostgresConnectionSource` +struct _FluentPostgresDriver: DatabaseDriver, @unchecked Sendable { let pool: EventLoopGroupConnectionPool - let encoder: PostgresDataEncoder - let decoder: PostgresDataDecoder - - var eventLoopGroup: EventLoopGroup { - self.pool.eventLoopGroup - } - - func makeDatabase(with context: DatabaseContext) -> Database { + let encodingContext: PostgresEncodingContext + let decodingContext: PostgresDecodingContext + let sqlLogLevel: Logger.Level + + func makeDatabase(with context: DatabaseContext) -> any Database { _FluentPostgresDatabase( - database: self.pool.pool(for: context.eventLoop).database(logger: context.logger), + database: self.pool + .pool(for: context.eventLoop) + .database(logger: context.logger) + .sql(encodingContext: self.encodingContext, decodingContext: self.decodingContext, queryLogLevel: self.sqlLogLevel), context: context, - encoder: self.encoder, - decoder: self.decoder, + encodingContext: self.encodingContext, + decodingContext: self.decodingContext, inTransaction: false ) } - + func shutdown() { - self.pool.shutdown() + try? self.pool.syncShutdownGracefully() + } + + func shutdownAsync() async { + try? await self.pool.shutdownAsync() } } diff --git a/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift b/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift index 7156cd2..ed525dd 100644 --- a/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift +++ b/Sources/FluentPostgresDriver/PostgresConverterDelegate.swift @@ -1,64 +1,55 @@ +import FluentKit import FluentSQL +import SQLKit struct PostgresConverterDelegate: SQLConverterDelegate { - func customDataType(_ dataType: DatabaseSchema.DataType) -> SQLExpression? { + func customDataType(_ dataType: DatabaseSchema.DataType) -> (any SQLExpression)? { switch dataType { case .uuid: - return SQLRaw("UUID") + SQLRaw("UUID") case .bool: - return SQLRaw("BOOL") + SQLRaw("BOOL") case .data: - return SQLRaw("BYTEA") + SQLRaw("BYTEA") case .date: - return SQLRaw("DATE") + SQLRaw("DATE") case .datetime: - return SQLRaw("TIMESTAMPTZ") + SQLRaw("TIMESTAMPTZ") case .double: - return SQLRaw("DOUBLE PRECISION") + SQLRaw("DOUBLE PRECISION") case .dictionary: - return SQLRaw("JSONB") + SQLRaw("JSONB") case .array(of: let type): - if let type = type, let dataType = self.customDataType(type) { - return SQLArrayDataType(dataType: dataType) + if let type, let dataType = self.customDataType(type) { + SQLArrayDataType(dataType: dataType) } else { - return SQLRaw("JSONB") + SQLRaw("JSONB") } case .enum(let value): - return SQLIdentifier(value.name) + SQLIdentifier(value.name) case .int8, .uint8: - return SQLRaw(#""char""#) + SQLIdentifier("char") case .int16, .uint16: - return SQLRaw("SMALLINT") + SQLRaw("SMALLINT") case .int32, .uint32: - return SQLRaw("INT") + SQLRaw("INT") case .int64, .uint64: - return SQLRaw("BIGINT") + SQLRaw("BIGINT") case .string: - return SQLRaw("TEXT") + SQLRaw("TEXT") case .time: - return SQLRaw("TIME") + SQLRaw("TIME") case .float: - return SQLRaw("FLOAT") + SQLRaw("FLOAT") case .custom: - return nil - } - } - - func nestedFieldExpression(_ column: String, _ path: [String]) -> SQLExpression { - switch path.count { - case 1: - return SQLRaw("\(column)->>'\(path[0])'") - case 2...: - let inner = path[0..") - return SQLRaw("\(column)->\(inner)->>'\(path.last!)'") - default: - fatalError() + nil } } } private struct SQLArrayDataType: SQLExpression { - let dataType: SQLExpression + let dataType: any SQLExpression + func serialize(to serializer: inout SQLSerializer) { self.dataType.serialize(to: &serializer) serializer.write("[]") diff --git a/Sources/FluentPostgresDriver/PostgresError+Database.swift b/Sources/FluentPostgresDriver/PostgresError+Database.swift index 4ea2df3..595df63 100644 --- a/Sources/FluentPostgresDriver/PostgresError+Database.swift +++ b/Sources/FluentPostgresDriver/PostgresError+Database.swift @@ -1,73 +1,111 @@ +import FluentKit import FluentSQL +import PostgresKit +import PostgresNIO -extension PostgresError: DatabaseError { - public var isSyntaxError: Bool { - switch self.code { +extension PostgresError.Code { + fileprivate var isSyntaxError: Bool { + switch self { case .syntaxErrorOrAccessRuleViolation, - .syntaxError, - .insufficientPrivilege, - .cannotCoerce, - .groupingError, - .windowingError, - .invalidRecursion, - .invalidForeignKey, - .invalidName, - .nameTooLong, - .reservedName, - .datatypeMismatch, - .indeterminateDatatype, - .collationMismatch, - .indeterminateCollation, - .wrongObjectType, - .undefinedColumn, - .undefinedFunction, - .undefinedTable, - .undefinedParameter, - .undefinedObject, - .duplicateColumn, - .duplicateCursor, - .duplicateDatabase, - .duplicateFunction, - .duplicatePreparedStatement, - .duplicateSchema, - .duplicateTable, - .duplicateAlias, - .duplicateObject, - .ambiguousColumn, - .ambiguousFunction, - .ambiguousParameter, - .ambiguousAlias, - .invalidColumnReference, - .invalidColumnDefinition, - .invalidCursorDefinition, - .invalidDatabaseDefinition, - .invalidFunctionDefinition, - .invalidPreparedStatementDefinition, - .invalidSchemaDefinition, - .invalidTableDefinition, - .invalidObjectDefinition: - return true + .syntaxError, + .insufficientPrivilege, + .cannotCoerce, + .groupingError, + .windowingError, + .invalidRecursion, + .invalidForeignKey, + .invalidName, + .nameTooLong, + .reservedName, + .datatypeMismatch, + .indeterminateDatatype, + .collationMismatch, + .indeterminateCollation, + .wrongObjectType, + .undefinedColumn, + .undefinedFunction, + .undefinedTable, + .undefinedParameter, + .undefinedObject, + .duplicateColumn, + .duplicateCursor, + .duplicateDatabase, + .duplicateFunction, + .duplicatePreparedStatement, + .duplicateSchema, + .duplicateTable, + .duplicateAlias, + .duplicateObject, + .ambiguousColumn, + .ambiguousFunction, + .ambiguousParameter, + .ambiguousAlias, + .invalidColumnReference, + .invalidColumnDefinition, + .invalidCursorDefinition, + .invalidDatabaseDefinition, + .invalidFunctionDefinition, + .invalidPreparedStatementDefinition, + .invalidSchemaDefinition, + .invalidTableDefinition, + .invalidObjectDefinition: + true + default: + false + } + } + + fileprivate var isConstraintFailure: Bool { + switch self { + case .integrityConstraintViolation, + .restrictViolation, + .notNullViolation, + .foreignKeyViolation, + .uniqueViolation, + .checkViolation, + .exclusionViolation: + true default: - return false + false } } - +} + +// Used for DatabaseError conformance +extension PostgresError { + public var isSyntaxError: Bool { self.code.isSyntaxError } public var isConnectionClosed: Bool { - return false + switch self { + case .connectionClosed: true + default: false + } } - + public var isConstraintFailure: Bool { self.code.isConstraintFailure } +} + +// Used for DatabaseError conformance +extension PSQLError { + public var isSyntaxError: Bool { + switch self.code { + case .server: self.serverInfo?[.sqlState].map { PostgresError.Code(raw: 0ドル).isSyntaxError } ?? false + default: false + } + } + + public var isConnectionClosed: Bool { + switch self.code { + case .serverClosedConnection, .clientClosedConnection: true + default: false + } + } + public var isConstraintFailure: Bool { switch self.code { - case .integrityConstraintViolation, - .restrictViolation, - .notNullViolation, - .foreignKeyViolation, - .uniqueViolation, - .checkViolation, - .exclusionViolation: - return true - default: - return false + case .server: self.serverInfo?[.sqlState].map { PostgresError.Code(raw: 0ドル).isConstraintFailure } ?? false + default: false } } } + +extension PostgresError: @retroactive DatabaseError {} +extension PSQLError: @retroactive DatabaseError {} diff --git a/Sources/FluentPostgresDriver/PostgresRow+Database.swift b/Sources/FluentPostgresDriver/PostgresRow+Database.swift index 1747bf1..beea4b6 100644 --- a/Sources/FluentPostgresDriver/PostgresRow+Database.swift +++ b/Sources/FluentPostgresDriver/PostgresRow+Database.swift @@ -1,89 +1,39 @@ -import class Foundation.JSONDecoder +import FluentKit +import PostgresKit +import PostgresNIO +import SQLKit -extension PostgresRow { - internal func databaseOutput(using decoder: PostgresDataDecoder) -> DatabaseOutput { - _PostgresDatabaseOutput( - row: self, - decoder: decoder - ) +extension SQLRow { + func databaseOutput() -> some DatabaseOutput { + _PostgresDatabaseOutput(row: self, schema: nil) } } private struct _PostgresDatabaseOutput: DatabaseOutput { - let row: PostgresRow - let decoder: PostgresDataDecoder + let row: any SQLRow + let schema: String? var description: String { - self.row.description + String(describing: self.row) } - func decodeNil(_ key: FieldKey) throws -> Bool { - if let data = self.row.column(self.columnName(key)) { - return data.type == .null - } else { - return true - } + private func adjust(key: FieldKey) -> FieldKey { + self.schema.map { .prefix(.prefix(.string(0ドル), "_"), key) } ?? key } - func contains(_ key: FieldKey) -> Bool { - self.row.column(self.columnName(key)) != nil - } - - func schema(_ schema: String) -> DatabaseOutput { - _SchemaDatabaseOutput( - output: self, - schema: schema - ) - } - - func decode(_ key: FieldKey, as type: T.Type) throws -> T - where T: Decodable - { - try self.row.sql(decoder: self.decoder) - .decode(column: self.columnName(key), as: T.self) - } - - func columnName(_ key: FieldKey) -> String { - switch key { - case .id: - return "id" - case .aggregate: - return key.description - case .string(let name): - return name - case .prefix(let prefix, let key): - return self.columnName(prefix) + self.columnName(key) - } - } -} - -private struct _SchemaDatabaseOutput: DatabaseOutput { - let output: DatabaseOutput - let schema: String - - var description: String { - self.output.description - } - - func schema(_ schema: String) -> DatabaseOutput { - self.output.schema(schema) + func schema(_ schema: String) -> any DatabaseOutput { + _PostgresDatabaseOutput(row: self.row, schema: schema) } func contains(_ key: FieldKey) -> Bool { - self.output.contains(self.key(key)) + self.row.contains(column: self.adjust(key: key).description) } func decodeNil(_ key: FieldKey) throws -> Bool { - try self.output.decodeNil(self.key(key)) - } - - func decode(_ key: FieldKey, as type: T.Type) throws -> T - where T: Decodable - { - try self.output.decode(self.key(key), as: T.self) + try self.row.decodeNil(column: self.adjust(key: key).description) } - private func key(_ key: FieldKey) -> FieldKey { - .prefix(.string(self.schema + "_"), key) + func decode(_ key: FieldKey, as: T.Type) throws -> T { + try self.row.decode(column: self.adjust(key: key).description, as: T.self) } } diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift index 1fade7d..97ee68d 100644 --- a/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift +++ b/Tests/FluentPostgresDriverTests/FluentPostgresDriverTests.swift @@ -1,18 +1,72 @@ -import Logging import FluentBenchmark +import FluentKit import FluentPostgresDriver +import FluentSQL +import Logging +import PostgresKit +import SQLKit +import Testing import XCTest -final class FluentPostgresDriverTests: XCTestCase { - func testAll() throws { try self.benchmarker.testAll() } - - #if Xcode +func withDbs(_ closure: @escaping @Sendable (_ dbs: Databases, _ db: any Database) async throws -> Void) async throws { + let databases = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton) + + databases.use(.testPostgres(subconfig: "A"), as: .a) + databases.use(.testPostgres(subconfig: "B"), as: .b) + + do { + let a = databases.database(.a, logger: .init(label: "test.fluent.a"), on: databases.eventLoopGroup.any())! + _ = try await (a as! any SQLDatabase).raw("drop schema if exists public cascade").run() + _ = try await (a as! any SQLDatabase).raw("create schema public").run() + + let b = databases.database(.b, logger: .init(label: "test.fluent.b"), on: databases.eventLoopGroup.any())! + _ = try await (b as! any SQLDatabase).raw("drop schema if exists public cascade").run() + _ = try await (b as! any SQLDatabase).raw("create schema public").run() + + try await closure(databases, a) + await databases.shutdownAsync() + } catch { + print(String(reflecting: error)) + await databases.shutdownAsync() + throw error + } +} + +final class FluentBenchmarksTests: XCTestCase { + var benchmarker: FluentBenchmarker { .init(databases: self.dbs) } + var dbs: Databases! + + override func setUp() async throws { + try await super.setUp() + + XCTAssert(isLoggingConfigured) + self.dbs = Databases(threadPool: .singleton, on: MultiThreadedEventLoopGroup.singleton) + + self.dbs.use(.testPostgres(subconfig: "A"), as: .a) + self.dbs.use(.testPostgres(subconfig: "B"), as: .b) + + let a = self.dbs.database(.a, logger: .init(label: "test.fluent.a"), on: self.dbs.eventLoopGroup.any()) + _ = try await (a as! any PostgresDatabase).query("drop schema public cascade").get() + _ = try await (a as! any PostgresDatabase).query("create schema public").get() + + let b = self.dbs.database(.b, logger: .init(label: "test.fluent.b"), on: self.dbs.eventLoopGroup.any()) + _ = try await (b as! any PostgresDatabase).query("drop schema public cascade").get() + _ = try await (b as! any PostgresDatabase).query("create schema public").get() + } + + override func tearDown() async throws { + await self.dbs.shutdownAsync() + try await super.tearDown() + } + func testAggregate() throws { try self.benchmarker.testAggregate() } func testArray() throws { try self.benchmarker.testArray() } func testBatch() throws { try self.benchmarker.testBatch() } + func testChild() throws { try self.benchmarker.testChildren() } func testChildren() throws { try self.benchmarker.testChildren() } func testChunk() throws { try self.benchmarker.testChunk() } func testCodable() throws { try self.benchmarker.testCodable() } + func testCompositeID() throws { try self.benchmarker.testCompositeID() } func testCRUD() throws { try self.benchmarker.testCRUD() } func testEagerLoad() throws { try self.benchmarker.testEagerLoad() } func testEnum() throws { try self.benchmarker.testEnum() } @@ -33,188 +87,257 @@ final class FluentPostgresDriverTests: XCTestCase { func testSiblings() throws { try self.benchmarker.testSiblings() } func testSoftDelete() throws { try self.benchmarker.testSoftDelete() } func testSort() throws { try self.benchmarker.testSort() } + func testSQL() throws { try self.benchmarker.testSQL() } func testTimestamp() throws { try self.benchmarker.testTimestamp() } func testTransaction() throws { try self.benchmarker.testTransaction() } func testUnique() throws { try self.benchmarker.testUnique() } - #endif - - func testDatabaseError() throws { - let sql = (self.db as! SQLDatabase) - do { - try sql.raw("asd").run().wait() - } catch let error as DatabaseError where error.isSyntaxError { - // PASS - } catch { - XCTFail("\(error)") - } - do { - try sql.raw("CREATE TABLE foo (name TEXT UNIQUE)").run().wait() - try sql.raw("INSERT INTO foo (name) VALUES ('bar')").run().wait() - try sql.raw("INSERT INTO foo (name) VALUES ('bar')").run().wait() - } catch let error as DatabaseError where error.isConstraintFailure { - // pass - } catch { - XCTFail("\(error)") - } - } - - func testBlob() throws { - final class Foo: Model { - static let schema = "foos" +} - @ID(key: "id") - var id: Int? +@Suite(.serialized) +struct AllSuites {} - @Field(key: "data") - var data: [UInt8] +extension AllSuites { +@Suite +struct FluentPostgresDriverTests { + init() { + #expect(isLoggingConfigured) + } - init() { } + #if !compiler(<6.1) // #expect(throws:) doesn't return the Error until 6.1 + @Test + func databaseError() async throws { + try await withDbs { dbs, db in + let sql1 = (db as! any SQLDatabase) + let error1 = await #expect(throws: (any Error).self) { try await sql1.raw("asdf").run() } + #expect((error1 as? any DatabaseError)?.isSyntaxError ?? false, "\(String(reflecting: error1))") + #expect(!((error1 as? any DatabaseError)?.isConstraintFailure ?? true), "\(String(reflecting: error1))") + #expect(!((error1 as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error1))") + + let sql2 = (dbs.database(.a, logger: .init(label: "test.fluent.a"), on: dbs.eventLoopGroup.any())!) as! any SQLDatabase + try await sql2.drop(table: "foo").ifExists().run() + try await sql2.create(table: "foo").column("name", type: .text, .unique).run() + try await sql2.insert(into: "foo").columns("name").values("bar").run() + let error2 = await #expect(throws: (any Error).self) { try await sql2.insert(into: "foo").columns("name").values("bar").run() } + #expect(!((error2 as? any DatabaseError)?.isSyntaxError ?? true), "\(String(reflecting: error2))") + #expect((error2 as? any DatabaseError)?.isConstraintFailure ?? false, "\(String(reflecting: error2))") + #expect(!((error2 as? any DatabaseError)?.isConnectionClosed ?? true), "\(String(reflecting: error2))") } - struct CreateFoo: Migration { - func prepare(on database: Database) -> EventLoopFuture { - return database.schema("foos") + // Disabled until we figure out why it hangs instead of throwing an error. + //let postgres = (self.dbs.database(.a, logger: .init(label: "test.fluent.a"), on: self.eventLoopGroup.any())!) as! any PostgresDatabase + //await XCTAssertThrowsErrorAsync(try await postgres.withConnection { conn in + // conn.close().flatMap { + // conn.sql().insert(into: "foo").columns("name").values("bar").run() + // } + //}.get()) { + // XCTAssertTrue((0ドル as? any DatabaseError)?.isConnectionClosed ?? false, "\(String(reflecting: 0ドル))") + // XCTAssertFalse((0ドル as? any DatabaseError)?.isSyntaxError ?? true, "\(String(reflecting: 0ドル))") + // XCTAssertFalse((0ドル as? any DatabaseError)?.isConstraintFailure ?? true, "\(String(reflecting: 0ドル))") + //} + } + #endif + + @Test + func blob() async throws { + struct CreateFoo: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema("foos") .field("id", .int, .identifier(auto: true)) .field("data", .data, .required) .create() } - func revert(on database: Database) -> EventLoopFuture { - return database.schema("foos").delete() + func revert(on database: any Database) async throws { + try await database.schema("foos").delete() } } - try CreateFoo().prepare(on: self.db).wait() - try CreateFoo().revert(on: self.db).wait() + try await withDbs { _, db in + try await CreateFoo().prepare(on: db) + try await CreateFoo().revert(on: db) + } } - func testSaveModelWithBool() throws { - final class Organization: Model { + @Test + func saveModelWithBool() async throws { + final class Organization: Model, @unchecked Sendable { static let schema = "orgs" - @ID(custom: "id", generatedBy: .database) - var id: Int? - - @Field(key: "disabled") - var disabled: Bool + @ID(custom: "id", generatedBy: .database) var id: Int? + @Field(key: "disabled") var disabled: Bool - init() { } + init() {} } - struct CreateOrganization: Migration { - func prepare(on database: Database) -> EventLoopFuture { - return database.schema("orgs") + struct CreateOrganization: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema("orgs") .field("id", .int, .identifier(auto: true)) .field("disabled", .bool, .required) .create() } - func revert(on database: Database) -> EventLoopFuture { - return database.schema("orgs").delete() + func revert(on database: any Database) async throws { + try await database.schema("orgs").delete() } } - try CreateOrganization().prepare(on: self.db).wait() - defer { - try! CreateOrganization().revert(on: self.db).wait() + try await withDbs { _, db in + try await CreateOrganization().prepare(on: db) + do { + let new = Organization() + new.disabled = false + try await new.save(on: db) + } catch { + try? await CreateOrganization().revert(on: db) + throw error + } + try await CreateOrganization().revert(on: db) } - - let new = Organization() - new.disabled = false - try new.save(on: self.db).wait() } - func testCustomJSON() throws { - try EventMigration().prepare(on: self.db).wait() - defer { try! EventMigration().revert(on: self.db).wait() } - - let jsonEncoder = JSONEncoder() - jsonEncoder.dateEncodingStrategy = .iso8601 - let jsonDecoder = JSONDecoder() - jsonDecoder.dateDecodingStrategy = .iso8601 - - let configuration = PostgresConfiguration( - hostname: env("POSTGRES_HOSTNAME_A") ?? "localhost", - port: env("POSTGRES_PORT_A").flatMap(Int.init) ?? PostgresConfiguration.ianaPortNumber, - username: "vapor_username", - password: "vapor_password", - database: env("POSTGRES_DATABASE_A") ?? "vapor_database" - ) - self.dbs.use(.postgres( - configuration: configuration, - encoder: PostgresDataEncoder(json: jsonEncoder), - decoder: PostgresDataDecoder(json: jsonDecoder) - ), as: .iso8601) - let db = self.dbs.database( - .iso8601, - logger: .init(label: "test"), - on: self.eventLoopGroup.next() - )! - - let date = Date() - let event = Event() - event.id = 1 - event.metadata = Metadata(createdAt: date) - try event.save(on: db).wait() - - let rows = try EventStringlyTyped.query(on: db).filter(\.$id == 1).all().wait() - let expected = ISO8601DateFormatter().string(from: date) - XCTAssertEqual(rows[0].metadata["createdAt"], expected) + @Test + func customJSON() async throws { + try await withDbs { dbs, _ in + let jsonEncoder = JSONEncoder() + jsonEncoder.dateEncodingStrategy = .iso8601 + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .iso8601 + + dbs.use( + .testPostgres( + subconfig: "A", + encodingContext: .init(jsonEncoder: jsonEncoder), + decodingContext: .init(jsonDecoder: jsonDecoder) + ), + as: .iso8601 + ) + let db = dbs.database( + .iso8601, + logger: .init(label: "test"), + on: dbs.eventLoopGroup.any() + )! + + try await EventMigration().prepare(on: db) + do { + let date = Date() + let event = Event() + event.id = 1 + event.metadata = Metadata(createdAt: date) + try await event.save(on: db) + + let rows = try await EventStringlyTyped.query(on: db).filter(\.$id == 1).all() + let expected = ISO8601DateFormatter().string(from: date) + #expect(rows[0].metadata["createdAt"] == expected) + } catch { + try? await EventMigration().revert(on: db) + throw error + } + try await EventMigration().revert(on: db) + } } - - var benchmarker: FluentBenchmarker { - return .init(databases: self.dbs) - } - var eventLoopGroup: EventLoopGroup! - var threadPool: NIOThreadPool! - var dbs: Databases! - var db: Database { - self.benchmarker.database - } - var postgres: PostgresDatabase { - self.db as! PostgresDatabase + @Test + func enumAddingMultipleCases() async throws { + try await withDbs { _, db in + try await EnumMigration().prepare(on: db) + do { + try await EventWithFooMigration().prepare(on: db) + do { + let event = EventWithFoo() + event.foobar = .foo + try await event.save(on: db) + + await #expect(throws: Never.self) { try await EnumAddMultipleCasesMigration().prepare(on: db) } + + event.foobar = .baz + await #expect(throws: Never.self) { try await event.update(on: db) } + event.foobar = .qux + await #expect(throws: Never.self) { try await event.update(on: db) } + + await #expect(throws: Never.self) { try await EnumAddMultipleCasesMigration().revert(on: db) } + } catch { + try? await EventWithFooMigration().revert(on: db) + throw error + } + } catch { + try? await EnumMigration().revert(on: db) + throw error + } + } } - - override func setUpWithError() throws { - try super.setUpWithError() - - let aConfig = PostgresConfiguration( - hostname: env("POSTGRES_HOSTNAME_A") ?? "localhost", - port: env("POSTGRES_PORT_A").flatMap(Int.init) ?? PostgresConfiguration.ianaPortNumber, - username: env("POSTGRES_USERNAME_A") ?? "vapor_username", - password: env("POSTGRES_PASSWORD_A") ?? "vapor_password", - database: env("POSTGRES_DATABASE_A") ?? "vapor_database" - ) - let bConfig = PostgresConfiguration( - hostname: env("POSTGRES_HOSTNAME_B") ?? "localhost", - port: env("POSTGRES_PORT_B").flatMap(Int.init) ?? PostgresConfiguration.ianaPortNumber, - username: env("POSTGRES_USERNAME_B") ?? "vapor_username", - password: env("POSTGRES_PASSWORD_B") ?? "vapor_password", - database: env("POSTGRES_DATABASE_B") ?? "vapor_database" - ) - XCTAssert(isLoggingConfigured) - self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - self.threadPool = NIOThreadPool(numberOfThreads: System.coreCount) - self.dbs = Databases(threadPool: threadPool, on: self.eventLoopGroup) - self.dbs.use(.postgres(configuration: aConfig), as: .a) - self.dbs.use(.postgres(configuration: bConfig), as: .b) - - let a = self.dbs.database(.a, logger: Logger(label: "test.fluent.a"), on: self.eventLoopGroup.next()) - _ = try (a as! PostgresDatabase).query("drop schema public cascade").wait() - _ = try (a as! PostgresDatabase).query("create schema public").wait() - - let b = self.dbs.database(.b, logger: Logger(label: "test.fluent.b"), on: self.eventLoopGroup.next()) - _ = try (b as! PostgresDatabase).query("drop schema public cascade").wait() - _ = try (b as! PostgresDatabase).query("create schema public").wait() + @Test + func encodingArrayOfModels() async throws { + final class Elem: Model, ExpressibleByIntegerLiteral, @unchecked Sendable { + static let schema = "" + @ID(custom: .id) var id: Int? + init() {} + init(integerLiteral l: Int) { self.id = l } + } + final class Seq: Model, ExpressibleByNilLiteral, ExpressibleByArrayLiteral, @unchecked Sendable { + static let schema = "seqs" + @ID(custom: .id) var id: Int? + @OptionalField(key: "list") var list: [Elem]? + init() {} + init(nilLiteral: ()) { self.list = nil } + init(arrayLiteral el: Elem...) { self.list = el } + } + try await withDbs { _, db in + do { + try await db.schema(Seq.schema).field(.id, .int, .identifier(auto: true)).field("list", .sql(embed: "JSONB[]")).create() + + let s1: Seq = [1, 2] + let s2: Seq = nil + try await s1.create(on: db) + try await s2.create(on: db) + + // Make sure it went into the DB as "array of jsonb" rather than as "array of one jsonb containing array" or such. + let raws = try await (db as! any SQLDatabase).raw("SELECT array_to_json(list)::text t FROM seqs").all().map { + try 0ドル.decode(column: "t", as: String?.self) + } + #expect(raws == [#"[{"id": 1},{"id": 2}]"#, nil]) + + // Make sure it round-trips through Fluent. + let seqs = try await Seq.query(on: db).all() + + #expect(seqs.count == 2) + #expect(seqs.dropFirst(0).first?.id == s1.id) + #expect(seqs.dropFirst(0).first?.list?.map(\.id) == s1.list?.map(\.id)) + #expect(seqs.dropFirst(1).first?.id == s2.id) + #expect(seqs.dropFirst(1).first?.list?.map(\.id) == s2.list?.map(\.id)) + } catch let error { + Issue.record("caught error: \(String(reflecting: error))") + } + try await db.schema(Seq.schema).delete() + } } +} +} + +extension DatabaseConfigurationFactory { + static func testPostgres( + subconfig: String, + encodingContext: PostgresEncodingContext = .default, + decodingContext: PostgresDecodingContext = .default + ) -> Self { + let baseSubconfig = SQLPostgresConfiguration( + hostname: env("POSTGRES_HOSTNAME_\(subconfig)") ?? env("POSTGRES_HOSTNAME_A") ?? env("POSTGRES_HOSTNAME") ?? "localhost", + port: (env("POSTGRES_PORT_\(subconfig)") ?? env("POSTGRES_PORT_A") ?? env("POSTGRES_PORT")).flatMap(Int.init) ?? SQLPostgresConfiguration.ianaPortNumber, + username: env("POSTGRES_USER_\(subconfig)") ?? env("POSTGRES_USER_A") ?? env("POSTGRES_USER") ?? "test_username", + password: env("POSTGRES_PASSWORD_\(subconfig)") ?? env("POSTGRES_PASSWORD_A") ?? env("POSTGRES_PASSWORD") ?? "test_password", + database: env("POSTGRES_DB_\(subconfig)") ?? env("POSTGRES_DB_A") ?? env("POSTGRES_DB") ?? "test_database", + tls: try! .prefer(.init(configuration: .makeClientConfiguration())) + ) - override func tearDownWithError() throws { - self.dbs.shutdown() - try self.threadPool.syncShutdownGracefully() - try self.eventLoopGroup.syncShutdownGracefully() - try super.tearDownWithError() + return .postgres( + configuration: baseSubconfig, + connectionPoolTimeout: .seconds(30), + pruneInterval: .seconds(30), + maxIdleTimeBeforePruning: .seconds(60), + encodingContext: encodingContext, + decodingContext: decodingContext + ) } } @@ -224,15 +347,11 @@ extension DatabaseID { static let b = DatabaseID(string: "b") } -func env(_ name: String) -> String? { - ProcessInfo.processInfo.environment[name] -} - struct Metadata: Codable { let createdAt: Date } -final class Event: Model { +final class Event: Model, @unchecked Sendable { static let schema = "events" @ID(custom: "id", generatedBy: .database) @@ -242,7 +361,7 @@ final class Event: Model { var metadata: Metadata } -final class EventStringlyTyped: Model { +final class EventStringlyTyped: Model, @unchecked Sendable { static let schema = "events" @ID(custom: "id", generatedBy: .database) @@ -252,24 +371,115 @@ final class EventStringlyTyped: Model { var metadata: [String: String] } -struct EventMigration: Migration { - func prepare(on database: Database) -> EventLoopFuture { - return database.schema(Event.schema) +struct EventMigration: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema(Event.schema) .field("id", .int, .identifier(auto: true)) .field("metadata", .json, .required) .create() } - func revert(on database: Database) -> EventLoopFuture { - return database.schema(Event.schema).delete() + func revert(on database: any Database) async throws { + try await database.schema(Event.schema).delete() } } -let isLoggingConfigured: Bool = { - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: 0ドル) } ?? .debug - return handler +final class EventWithFoo: Model, @unchecked Sendable { + static let schema = "foobar_events" + + @ID + var id: UUID? + + @Enum(key: "foo") + var foobar: Foobar +} + +enum Foobar: String, Codable { + static let schema = "foobars" + case foo + case bar + case baz + case qux +} + +struct EventWithFooMigration: AsyncMigration { + func prepare(on database: any Database) async throws { + let foobar = try await database.enum(Foobar.schema).read() + try await database.schema(EventWithFoo.schema) + .id() + .field("foo", foobar, .required) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema(EventWithFoo.schema).delete() } +} + +struct EnumMigration: AsyncMigration { + func prepare(on database: any Database) async throws { + _ = try await database.enum(Foobar.schema) + .case("foo") + .case("bar") + .create() + } + + func revert(on database: any Database) async throws { + try await database.enum(Foobar.schema).delete() + } +} + +struct EnumAddMultipleCasesMigration: AsyncMigration { + func prepare(on database: any Database) async throws { + _ = try await database.enum(Foobar.schema) + .case("baz") + .case("qux") + .update() + } + + func revert(on database: any Database) async throws { + _ = try await database.enum(Foobar.schema) + .deleteCase("baz") + .deleteCase("qux") + .update() + } +} + +func env(_ e: String) -> String? { + ProcessInfo.processInfo.environment[e] +} + +let isLoggingConfigured: Bool = { + LoggingSystem.bootstrap { QuickLogHandler(label: 0,ドル level: env("LOG_LEVEL").flatMap { .init(rawValue: 0ドル) } ?? .info) } return true }() + +struct QuickLogHandler: LogHandler { + private let label: String + var logLevel = Logger.Level.info, metadataProvider = LoggingSystem.metadataProvider, metadata = Logger.Metadata() + subscript(metadataKey key: String) -> Logger.Metadata.Value? { get { self.metadata[key] } set { self.metadata[key] = newValue } } + init(label: String, level: Logger.Level) { (self.label, self.logLevel) = (label, level) } + func log(level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, source: String, file: String, function: String, line: UInt) { + print("\(self.timestamp()) \(level) \(self.label) :\(self.prettify(metadata ?? [:]).map { " \(0ドル)" } ?? "") [\(source)] \(message)") + } + private func prettify(_ metadata: Logger.Metadata) -> String? { + self.metadata.merging(self.metadataProvider?.get() ?? [:]) { 1ドル }.merging(metadata) { 1ドル }.sorted { 0ドル.0 < 1ドル.0 }.map { "\(0ドル)=\(1ドル.mvDesc)" }.joined(separator: " ") + } + private func timestamp() -> String { .init(unsafeUninitializedCapacity: 255) { buffer in + var timestamp = time(nil) + return localtime(×tamp).map { strftime(buffer.baseAddress!, buffer.count, "%Y-%m-%dT%H:%M:%S%z", 0ドル) } ?? buffer.initialize(fromContentsOf: "".utf8) + } } +} +extension Logger.MetadataValue { + var mvDesc: String { switch self { + case .dictionary(let dict): "[\(dict.mapValues(\.mvDesc).lazy.sorted { 0ドル.0 < 1ドル.0 }.map { "\(0ドル): \(1ドル)" }.joined(separator: ", "))]" + case .array(let list): "[\(list.map(\.mvDesc).joined(separator: ", "))]" + case .string(let str): #""\#(str)""# + case .stringConvertible(let repr): switch repr { + case let repr as Bool: "\(repr)" + case let repr as any FixedWidthInteger: "\(repr)" + case let repr as any BinaryFloatingPoint: "\(repr)" + default: #""\#(String(describing: repr))""# + } + } } +} diff --git a/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift new file mode 100644 index 0000000..50d2e35 --- /dev/null +++ b/Tests/FluentPostgresDriverTests/FluentPostgresTransactionControlTests.swift @@ -0,0 +1,45 @@ +import FluentKit +import Testing + +extension AllSuites { +@Suite +struct FluentPostgresTransactionControlTests { + init() { #expect(isLoggingConfigured) } + + #if !compiler(<6.1) // #expect(throws:) doesn't return the Error until 6.1 + @Test + func rollback() async throws { + try await withDbs { _, db in try await db.withConnection { db in + try await CreateTodo().prepare(on: db) + do { + try await (db as! any TransactionControlDatabase).beginTransaction().get() + let error = await #expect(throws: (any Error).self) { + try await [Todo(title: "Test"), Todo(title: "Test")].create(on: db) + try await (db as! any TransactionControlDatabase).commitTransaction().get() + } + #expect(String(reflecting: error).contains("sqlState: 23505"), "\(String(reflecting: error))") + try await (db as! any TransactionControlDatabase).rollbackTransaction().get() + #expect(try await Todo.query(on: db).count() == 0) + } catch { + try? await CreateTodo().revert(on: db) + throw error + } + try await CreateTodo().revert(on: db) + } } + } + #endif + + final class Todo: Model, @unchecked Sendable { + static let schema = "todos" + @ID var id + @Field(key: "title") var title: String + init() {} + init(title: String) { self.title = title } + } + + struct CreateTodo: AsyncMigration { + func prepare(on database: any Database) async throws { try await database.schema(Todo.schema).id().field("title", .string, .required).unique(on: "title").create() } + func revert(on database: any Database) async throws { try await database.schema(Todo.schema).delete() } + } +} +} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 13b7a21..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,19 +0,0 @@ -version: '3' - -services: - a: - image: postgres - environment: - POSTGRES_USER: vapor_username - POSTGRES_DB: vapor_database - POSTGRES_PASSWORD: vapor_password - ports: - - 5432:5432 - b: - image: postgres - environment: - POSTGRES_USER: vapor_username - POSTGRES_DB: vapor_database - POSTGRES_PASSWORD: vapor_password - ports: - - 5433:5432

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /