diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f238885
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,102 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ linux:
+ # Everything, including the live tests, against a PostgreSQL service container.
+ name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }})
+ runs-on: ubuntu-24.04
+ container: swift:${{ matrix.swift }}-noble
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest supported Swift (swift-tools-version 6.2) and the newest.
+ swift: ["6.2", "6.4"]
+ configuration: [debug, release]
+ services:
+ postgres:
+ image: postgres:17
+ env:
+ POSTGRES_HOST_AUTH_METHOD: trust
+ options: >-
+ --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 20
+ env:
+ PG_TESTS: "1"
+ PGUSER: postgres
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Install client libraries
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends libpq-dev pkg-config
+
+ # The tests connect with "host=localhost", which PGHOST can't override. libpq's
+ # PGHOSTADDR can: the connection goes to hostaddr, and host is only used for
+ # authentication and TLS.
+ - name: Point libpq at the service
+ run: |
+ addr=$(getent hosts postgres | awk '{ print $1; exit }')
+ if [ -z "$addr" ]; then
+ echo "Could not resolve the postgres service"
+ exit 1
+ fi
+ echo "PGHOSTADDR=$addr" >> "$GITHUB_ENV"
+
+ - name: Toolchain
+ run: swift --version
+
+ - name: Build (warnings are errors)
+ run: |
+ flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
+ # @testable imports need testing enabled in an optimized build.
+ [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
+ echo "FLAGS=$flags" >> "$GITHUB_ENV"
+ swift build --build-tests $flags
+
+ - name: Test
+ run: swift test $FLAGS
+
+ macos:
+ # GitHub's macOS runners have no PostgreSQL server: this builds everything and runs the
+ # tests that don't need one (the live tests are skipped without PG_TESTS=1).
+ name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }})
+ runs-on: macos-26
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ # The oldest Xcode with Swift 6.2, and the runner's default.
+ xcode: ["26.0.1", "26.6"]
+ configuration: [debug, release]
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Select Xcode
+ run: |
+ sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
+ swift --version
+
+ - name: Install client libraries
+ run: |
+ brew install libpq
+ # libpq is keg-only, so pkg-config doesn't find it without this.
+ echo "PKG_CONFIG_PATH=$(brew --prefix libpq)/lib/pkgconfig" >> "$GITHUB_ENV"
+
+ - name: Build (warnings are errors)
+ run: |
+ flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors"
+ # @testable imports need testing enabled in an optimized build.
+ [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing"
+ echo "FLAGS=$flags" >> "$GITHUB_ENV"
+ swift build --build-tests $flags
+
+ - name: Test
+ run: swift test $FLAGS
diff --git a/Package.swift b/Package.swift
index 42a17a1..124c204 100644
--- a/Package.swift
+++ b/Package.swift
@@ -8,7 +8,7 @@ let package = Package(
.library(name: "PerfectPostgreSQL", targets: ["PerfectPostgreSQL"]),
],
dependencies: [
- .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", branch: "main"),
+ .package(url: "https://github.com/PerfectlySoft/Perfect-CRUD.git", from: "3.0.1"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"),
],
targets: [
diff --git a/README.md b/README.md
index faf08b3..c8ba1ae 100644
--- a/README.md
+++ b/README.md
@@ -1,86 +1,159 @@
# Perfect - PostgreSQL Connector
-
-
-
-
-
-
-
-
-
+
+
+
-This package has been modernized for Swift 6.
+A PostgreSQL connector for Swift, built on the `libpq` client library. It has two layers:
-It provides a Swift wrapper around the `libpq` client library for connecting to PostgreSQL servers, **and** a full [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) SQL provider backend for PostgreSQL. `Sources/PerfectPostgreSQL/PostgresCRUD.swift` implements Perfect-CRUD's `SQLGenDelegate`, `SQLExeDelegate`, and `DatabaseConfigurationProtocol`, so this is no longer a bare, stand-alone libpq connector — it's a CRUD driver that happens to also expose the lower-level `PGConnection`/`PGResult` libpq wrapper (`Sources/PerfectPostgreSQL/PerfectPostgreSQL.swift`, using `swift-log` for diagnostics rather than `print`).
+- a [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) backend, `PostgresDatabaseConfiguration`, so
+ `Codable` types can be created, inserted, queried, joined, updated and deleted through CRUD, plus
+ PostgreSQL-only `returning(...)` variants of insert and update;
+- the lower-level `PGConnection` / `PGResult` wrapper around libpq.
-**Usage status:** this driver is real, tested, and consumed today — [Perfect-Session](https://github.com/PerfectlySoft/Perfect-Session)'s `PostgreSQLSessionDriver` imports `PerfectPostgreSQL` directly and is one of Perfect-Session's four supported backend drivers.
+[Perfect-Session](https://github.com/PerfectlySoft/Perfect-Session)'s PostgreSQL session driver is built on it.
-The pre-Swift-6 version of this package is preserved on the [`legacy`](../../tree/legacy) branch.
+The pre-Swift-6 version of this package (4.0.0 and earlier) is preserved on the [`legacy`](../../tree/legacy) branch.
-Both `libpq` wrapper types (`PGConnection`, `PGResult`) are synchronous/blocking and marked `@unchecked Sendable`; there is no async/await API in this package.
+**Requirements:** Swift 6.2 or newer (Swift 6 language mode). macOS 12+ and Linux, with the libpq client library
+installed (see below). CI runs the tests on Linux (Swift 6.2 and 6.4, Ubuntu 24.04, against PostgreSQL 17) and
+macOS (Xcode 26.0.1 and 26.6) in debug and release.
-This package builds with Swift Package Manager. Ensure you have installed and activated a Swift 6.2 (or later) tool chain.
-
-## Requirements
-
-- swift-tools-version: 6.2, built with `swiftLanguageMode(.v6)` (strict concurrency)
-- Platform: `platforms: [.macOS(.v12)]` — macOS 12 or later (no other Apple platforms declared)
-- Linux is buildable via SwiftPM/systemLibrary (the `libpq` system-library target declares an `.apt(["libpq-dev"])` provider) but is not asserted in the `platforms` array, so Linux support is implicit, not a guaranteed contract of this manifest.
-
-## Dependencies
-
-Declared in `Package.swift`:
-
-- [`Perfect-CRUD`](https://github.com/PerfectlySoft/Perfect-CRUD) (`.package(url:, branch: "main")`), product `PerfectCRUD`.
-- [`swift-log`](https://github.com/apple/swift-log.git) `from: 1.5.0`, product `Logging`.
-- A `.systemLibrary` target `libpq` (via `pkgConfig: "libpq"`) providing the C libpq bindings.
+```swift
+dependencies: [
+ .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", from: "5.0.0")
+],
+targets: [
+ .target(name: "MyTarget", dependencies: [
+ .product(name: "PerfectPostgreSQL", package: "Perfect-PostgreSQL")
+ ])
+]
+```
-## macOS Build Notes
+## Installing libpq
-This package requires the `libpq` client library, available via Homebrew:
+On macOS, install it with Homebrew:
```
brew install libpq
```
-## Linux Build Notes
-
-Ensure that you have installed `libpq-dev`.
+Homebrew's libpq is keg-only (not linked into `/opt/homebrew`). Swift 6.4's SwiftPM finds it anyway, but Swift 6.2's
+doesn't, and the build fails with `'libpq-fe.h' file not found`. Pointing pkg-config at it works with both:
```
-sudo apt-get install libpq-dev
+export PKG_CONFIG_PATH="$(brew --prefix libpq)/lib/pkgconfig"
```
-## Building
+On Linux (Debian/Ubuntu):
+
+```
+sudo apt-get install libpq-dev pkg-config
+```
-Add this project as a dependency in your `Package.swift` file:
+## Usage
```swift
-dependencies: [
- // No tagged releases exist yet, so pin a branch rather than a version:
- .package(url: "https://github.com/PerfectlySoft/Perfect-PostgreSQL.git", branch: "main"),
-],
-targets: [
- .target(
- name: "YourTarget",
- dependencies: [
- .product(name: "PerfectPostgreSQL", package: "Perfect-PostgreSQL"),
- ]
- ),
-]
+import PerfectCRUD
+import PerfectPostgreSQL
+
+struct Person: Codable {
+ let id: Int
+ let name: String
+}
+
+let db = Database(configuration: try PostgresDatabaseConfiguration(
+ database: "mydb", host: "localhost", username: "me", password: "secret"))
+try db.create(Person.self, policy: .reconcileTable)
+let people = db.table(Person.self)
+try people.insert(Person(id: 1, name: "Ada"))
+let ada = try people.where(\Person.id == 1).first()
```
-## Testing
+`PostgresDatabaseConfiguration("host=localhost dbname=mydb")` takes a libpq connection string instead. libpq's
+environment variables (`PGPORT`, `PGUSER`, `PGPASSWORD` and so on) fill in anything the connection string leaves out.
+
+See [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) for the query API. What the PostgreSQL backend
+doesn't support:
+
+- **Dynamic Select:** `select(_: DynamicQuery)` throws "Dynamic rows are not supported by this connector" as soon as
+ a row comes back, and so does `execute(_: DynamicSQL)` for a statement that returns rows. The error comes only
+ after the statement has run (and, outside a transaction, been committed): an `INSERT … RETURNING` through
+ `execute` has inserted its row when it throws, so retrying it inserts the row again. `mutate(_: DynamicMutation)`
+ and row-less `DynamicSQL` work, but the result's `affectedRows` is always 0 and its `insertedID` is nil.
+ `DynamicSQL` with `allowsMultipleStatements: true` throws (PostgreSQL's extended protocol takes one statement per
+ call) and runs none of them.
+- **Isolation levels:** `transaction(isolation:)` ignores the level and uses the server's default.
+
+`PGConnection` and `PGResult` are synchronous (blocking) and `@unchecked Sendable`. Every `PGConnection` method holds
+an internal lock, and `doWithTransaction` holds it for the whole transaction, so a connection can be shared between
+tasks. Separate calls from different tasks can still interleave, so give each task its own connection (for example
+through Perfect-CRUD's `DatabaseConnectionPool`) when that matters.
+
+That lock does not cover Perfect-CRUD's `Database.transaction` / `transactionAsync`: they send `BEGIN`, the body's
+statements and `COMMIT` or `ROLLBACK` as separate calls, so another task's statement on the same `Database` runs
+inside the transaction and is committed or rolled back with it. Don't share a `Database` that runs transactions
+between tasks; take one connection per task from a `DatabaseConnectionPool`. `PostgresDatabaseConfiguration` is
+`Sendable`, so the pool and CRUD's async entry points (`transactionAsync`, `insertAsync`, `fetchAll` and so on) work
+with it.
+
+Known limitations:
+
+- A `String` value containing a NUL character is cut off at the NUL when it's sent.
+- `[UInt8]` / `Data` columns are decoded from PostgreSQL's default hex `bytea` output; with `bytea_output = escape`
+ they decode as empty.
+- A NULL read into a non-optional `Int` or `String` property gives 0 or `""` rather than an error.
+- `Date` values are stored to the millisecond.
+- A `UInt` or `UInt64` value above `Int64.max` binds (Perfect-CRUD 3.0.1 sends it as unsigned), but a `bigint`
+ column can't hold it, so the server rejects it with an error.
-A test target is included. Run it with:
+## Testing
```
swift test
```
+The tests that need a server only run with `PG_TESTS=1`. They connect with `host=localhost` and the
+`postgres` and `testing123` databases (the tests drop and recreate `testing123` and
+`perfect_subtable_order_test`), so use a throwaway server. libpq's environment variables set the rest, for example:
+
+```
+docker run -d --rm -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17
+PG_TESTS=1 PGUSER=postgres swift test
+```
+
+Use `PGPORT` for a server on another port, and `PGHOSTADDR` for one that isn't on localhost.
+
+## Migrating from 4.x
+
+5.0.0 is a major release. The [release notes](../../releases/tag/5.0.0) list every change; the ones most likely to
+need attention when upgrading:
+
+* **Toolchain and platforms.** Swift tools 6.2 and the Swift 6 language mode are required, and the minimum macOS
+ is now 12 (was 10.15).
+* **libpq comes from the system.** The Perfect-libpq and Perfect-libpq-linux packages are no longer used: install
+ libpq as above, and remove those packages from your own manifest if you added them.
+* **Perfect-CRUD 3.0.0.** CRUD's own breaking changes apply; see its
+ [migration notes](https://github.com/PerfectlySoft/Perfect-CRUD#migrating-from-2x).
+* **`Sendable`.** Closures passed to `PGConnection.setReceiver` and `setProcessor` must be `@Sendable`.
+* **Shared connections are serialized.** `PGConnection` calls from different threads now wait for each other, and
+ `doWithTransaction` blocks other callers on that connection until it commits or rolls back. Code inside the
+ transaction closure that waits for another thread using the same connection will deadlock.
+* **`create()` with sub-tables.** Tables are created in foreign-key order, and `.dropTable` drops every table in the
+ set before creating any. `.reconcileTable` now reconciles existing sub-tables too, which drops columns their models
+ no longer have. When it needs to alter a table the role doesn't own, it fails with "must be owner".
+* **Identifiers containing `"`.** `quote(identifier:)` now doubles an embedded `"`, so such a name refers to the
+ table or column of that exact name. Before, it ended the quoted identifier and let a Dynamic API name inject SQL.
+* **Connection parameters are quoted.** `PostgresDatabaseConfiguration(database:host:port:username:password:)` now
+ quotes each value, so a value is used exactly as given. If you escaped values yourself, or appended options inside
+ one (such as `host: "localhost port=5433"`), pass the plain value and use the matching parameter or a connection
+ string with `PostgresDatabaseConfiguration(_:)` instead.
+* **Logging.** The connector's one diagnostic of its own (an unhandled result status) goes through
+ [swift-log](https://github.com/apple/swift-log) (label `perfect.postgresql`) instead of `print`. Errors and queries
+ logged by the CRUD layer still go through Perfect-CRUD's `CRUDLogging`.
+
## License
-Apache 2.0 — see [LICENSE](LICENSE).
+Apache 2.0. See [LICENSE](LICENSE).
diff --git a/Sources/PerfectPostgreSQL/PostgresCRUD.swift b/Sources/PerfectPostgreSQL/PostgresCRUD.swift
index eb04766..4fd3641 100644
--- a/Sources/PerfectPostgreSQL/PostgresCRUD.swift
+++ b/Sources/PerfectPostgreSQL/PostgresCRUD.swift
@@ -586,7 +586,7 @@ class PostgresExeDelegate: SQLExeDelegate, @unchecked Sendable {
}
}
-public struct PostgresDatabaseConfiguration: DatabaseConfigurationProtocol {
+public struct PostgresDatabaseConfiguration: DatabaseConfigurationProtocol, Sendable {
let connection: PGConnection
public init(url: String?,
@@ -606,17 +606,33 @@ public struct PostgresDatabaseConfiguration: DatabaseConfigurationProtocol {
}
public init(database: String, host: String, port: Int? = nil, username: String? = nil, password: String? = nil) throws {
- var s = "host=\(host) dbname=\(database)"
+ try self.init(Self.connectionInfo(database: database, host: host, port: port, username: username, password: password))
+ }
+ // Each value is single-quoted, with `\` and `'` backslash-escaped, as libpq's conninfo
+ // syntax requires. Unquoted, a password containing a space failed to parse, and a value
+ // like "x sslmode=disable" added a connection option of its own.
+ static func connectionInfo(database: String, host: String, port: Int?, username: String?, password: String?) -> String {
+ func quoted(_ value: String) -> String {
+ var escaped = String.UnicodeScalarView()
+ for scalar in value.unicodeScalars {
+ if scalar == "\\" || scalar == "'" {
+ escaped.append("\\")
+ }
+ escaped.append(scalar)
+ }
+ return "'\(String(escaped))'"
+ }
+ var s = "host=\(quoted(host)) dbname=\(quoted(database))"
if let p = port {
s += " port=\(p)"
}
if let u = username {
- s += " user=\(u)"
+ s += " user=\(quoted(u))"
}
if let p = password {
- s += " password=\(p)"
+ s += " password=\(quoted(p))"
}
- try self.init(s)
+ return s
}
public init(_ connectionInfo: String) throws {
let con = PGConnection()
diff --git a/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift b/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift
index 1185851..86bd1be 100644
--- a/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift
+++ b/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift
@@ -14,6 +14,9 @@ private func _insert(fromTab
instances: [OverAllForm],
includeKeys: [PartialKeyPath],
excludeKeys: [PartialKeyPath]) throws -> [OverAllForm] {
+ guard !instances.isEmpty else {
+ return []
+ }
typealias OAF = OverAllForm
let delegate = ft.databaseConfiguration.sqlGenDelegate
var state = SQLGenState(delegate: delegate)
@@ -55,11 +58,17 @@ private func _insert(fromTab
let bindIdentifiers = bindings.map { $0.identifier }
let nameQ = try delegate.quote(identifier: "\(OAF.CRUDTableName)")
- let sqlStr = """
- INSERT INTO \(nameQ) (\(columnNames.joined(separator: ", ")))
- VALUES (\(bindIdentifiers.joined(separator: ", ")))
- RETURNING *
- """
+ let sqlStr: String
+ if columnNames.isEmpty {
+ // "INSERT INTO t () VALUES ()" isn't valid PostgreSQL.
+ sqlStr = "INSERT INTO \(nameQ) DEFAULT VALUES RETURNING *"
+ } else {
+ sqlStr = """
+ INSERT INTO \(nameQ) (\(columnNames.joined(separator: ", ")))
+ VALUES (\(bindIdentifiers.joined(separator: ", ")))
+ RETURNING *
+ """
+ }
CRUDLogging.log(.query, sqlStr)
let exeDelegate = PostgresExeDelegate(connection: databaseConfiguration.connection, sql: sqlStr)
try exeDelegate.bind(delegate.bindings)
@@ -90,6 +99,9 @@ private func _insert,
includeKeys: [PartialKeyPath],
excludeKeys: [PartialKeyPath]) throws -> [R] {
+ guard !instances.isEmpty else {
+ return []
+ }
typealias OAF = OverAllForm
let delegate = ft.databaseConfiguration.sqlGenDelegate
var state = SQLGenState(delegate: delegate)
diff --git a/Tests/PerfectPostgreSQLTests/ConnectionInfoTests.swift b/Tests/PerfectPostgreSQLTests/ConnectionInfoTests.swift
new file mode 100644
index 0000000..384d13a
--- /dev/null
+++ b/Tests/PerfectPostgreSQLTests/ConnectionInfoTests.swift
@@ -0,0 +1,68 @@
+import Foundation
+import Testing
+import libpq
+@testable import PerfectPostgreSQL
+
+// PostgresDatabaseConfiguration(database:host:port:username:password:) builds a libpq conninfo
+// string. Every value must come back out of libpq's parser unchanged, and must not be able to
+// add options of its own.
+@Suite struct ConnectionInfoTests {
+ // Parses with libpq itself (no server needed) and returns the options that were set.
+ private func parse(_ conninfo: String) throws -> [String: String] {
+ var errmsg: UnsafeMutablePointer?
+ guard let options = PQconninfoParse(conninfo, &errmsg) else {
+ let message = errmsg.map { String(cString: $0) } ?? "unknown error"
+ PQfreemem(errmsg)
+ throw PostgresCRUDError("conninfo didn't parse: \(message)")
+ }
+ defer { PQconninfoFree(options) }
+ var ret: [String: String] = [:]
+ var option = options
+ while let keyword = option.pointee.keyword {
+ if let value = option.pointee.val {
+ ret[String(cString: keyword)] = String(cString: value)
+ }
+ option += 1
+ }
+ return ret
+ }
+
+ @Test func plainValues() throws {
+ let info = PostgresDatabaseConfiguration.connectionInfo(
+ database: "db", host: "localhost", port: 5432, username: "me", password: "secret")
+ #expect(try parse(info) == ["host": "localhost", "dbname": "db", "port": "5432", "user": "me", "password": "secret"])
+ }
+
+ @Test func valuesWithSpacesQuotesAndBackslashes() throws {
+ let password = #"a b'c\d "e" \'"#
+ let info = PostgresDatabaseConfiguration.connectionInfo(
+ database: "my db", host: "localhost", port: nil, username: #"o'brien"#, password: password)
+ #expect(try parse(info) == ["host": "localhost", "dbname": "my db", "user": #"o'brien"#, "password": password])
+ }
+
+ @Test func valueCannotAddAnOption() throws {
+ let info = PostgresDatabaseConfiguration.connectionInfo(
+ database: "db", host: "localhost", port: nil, username: nil, password: "x sslmode=disable")
+ let options = try parse(info)
+ #expect(options["password"] == "x sslmode=disable")
+ #expect(options["sslmode"] == nil)
+ }
+
+ @Test func emptyValues() throws {
+ let info = PostgresDatabaseConfiguration.connectionInfo(
+ database: "", host: "", port: nil, username: "", password: "")
+ #expect(try parse(info) == ["host": "", "dbname": "", "user": "", "password": ""])
+ }
+
+ // With trust authentication (the test server's), the password is ignored once parsed; before
+ // the fix, this one made the conninfo unparseable and the connection failed.
+ @Test(.enabled(if: ProcessInfo.processInfo.environment["PG_TESTS"] == "1"))
+ func connectsWithAPasswordContainingASpace() throws {
+ let config = try PostgresDatabaseConfiguration(
+ database: "postgres", host: "localhost",
+ port: ProcessInfo.processInfo.environment["PGPORT"].flatMap { Int($0) },
+ username: ProcessInfo.processInfo.environment["PGUSER"],
+ password: "two words")
+ #expect(config.connection.status() == .ok)
+ }
+}
diff --git a/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift b/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift
new file mode 100644
index 0000000..0bb54c3
--- /dev/null
+++ b/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift
@@ -0,0 +1,93 @@
+import Foundation
+import Testing
+import PerfectCRUD
+@testable import PerfectPostgreSQL
+
+// How Perfect-CRUD's Dynamic API behaves on this connector. The connector doesn't implement
+// `nextDynamicRow()`, so reading rows throws (CRUD's default) rather than building a
+// `DynamicRow`; a statement string with several statements is rejected by the server; and
+// unsigned values are bound as their decimal text, so even UInt64.max can't trap.
+// Uses only `dbname=postgres` and TEMP tables, which are private to the connection.
+@Suite(.enabled(if: pgTestsEnabled)) struct DynamicAPITests {
+
+ func connect() throws -> Database {
+ Database(configuration: try PostgresDatabaseConfiguration(postgresInitConnInfo))
+ }
+
+ private struct Count: Codable { let c: Int }
+
+ @Test func selectThrowsOnceARowComesBack() throws {
+ let db = try connect()
+ try db.sql("CREATE TEMP TABLE dyn_select (id int, name text)")
+ #expect(try db.select(DynamicQuery(table: "dyn_select")).rows.isEmpty)
+ _ = try db.mutate(DynamicMutation(action: .insert, table: "dyn_select",
+ values: ["id": .int(1), "name": .string("a")]))
+ #expect(throws: CRUDSQLExeError.self) {
+ try db.select(DynamicQuery(table: "dyn_select"))
+ }
+ // Columns differing only in case would trap in DynamicRow.init; here no row is built.
+ #expect(throws: CRUDSQLExeError.self) {
+ try db.execute(DynamicSQL(sql: #"SELECT 1 AS a, 2 AS "A""#))
+ }
+ }
+
+ @Test func mutationCountsAreNotReported() throws {
+ let db = try connect()
+ try db.sql("CREATE TEMP TABLE dyn_count (id int)")
+ let insert = try db.mutate(DynamicMutation(action: .insert, table: "dyn_count", values: ["id": .int(1)]))
+ #expect(insert.affectedRows == 0)
+ #expect(insert.insertedID == nil)
+ _ = try db.mutate(DynamicMutation(action: .insert, table: "dyn_count", values: ["id": .int(2)]))
+ let update = try db.mutate(DynamicMutation(action: .update, table: "dyn_count", values: ["id": .int(3)],
+ predicates: [.init(field: "id", comparison: .greaterThan, value: .int(0))]))
+ #expect(update.affectedRows == 0)
+ #expect(try db.sql("SELECT count(*)::int AS c FROM dyn_count WHERE id = 3", Count.self).first?.c == 2)
+ }
+
+ // PQexecParams accepts one statement only, so neither statement runs and the call throws.
+ @Test func multipleStatementsThrowAndRunNothing() throws {
+ let db = try connect()
+ try db.sql("CREATE TEMP TABLE dyn_multi (id int)")
+ let error = #expect(throws: CRUDSQLExeError.self) {
+ try db.execute(DynamicSQL(sql: "INSERT INTO dyn_multi VALUES (1); INSERT INTO dyn_multi VALUES (2)",
+ allowsMultipleStatements: true))
+ }
+ #expect("\(error.map { $0 as Any } ?? "")".contains("cannot insert multiple commands into a prepared statement"))
+ #expect(throws: CRUDSQLGenError.self) {
+ try db.execute(DynamicSQL(sql: "INSERT INTO dyn_multi VALUES (1); INSERT INTO dyn_multi VALUES (2)"))
+ }
+ #expect(try db.sql("SELECT count(*)::int AS c FROM dyn_multi", Count.self).first?.c == 0)
+ }
+
+ @Test func unsignedValuesBindExactly() throws {
+ let db = try connect()
+ try db.sql("CREATE TEMP TABLE dyn_uint (id int, n numeric, b bigint)")
+ _ = try db.mutate(DynamicMutation(action: .insert, table: "dyn_uint",
+ values: ["id": .int(1), "n": .uint(UInt64.max)]))
+ struct Text: Codable { let t: String }
+ #expect(try db.sql("SELECT n::text AS t FROM dyn_uint WHERE id = 1", Text.self).first?.t == "\(UInt64.max)")
+ // Too big for bigint: the server rejects it, nothing traps.
+ #expect(throws: CRUDSQLExeError.self) {
+ try db.mutate(DynamicMutation(action: .insert, table: "dyn_uint",
+ values: ["id": .int(2), "b": .uint(UInt64.max)]))
+ }
+ // The same through the typed API's unsigned expressions.
+ let conn = db.configuration.connection
+ for (index, value) in [UInt64.max, UInt64(UInt32.max), UInt64(UInt16.max), UInt64(UInt8.max)].enumerated() {
+ let exe = PostgresExeDelegate(connection: conn, sql: "SELECT $1::numeric::text AS t")
+ let expr: CRUDExpression = switch index {
+ case 0: .uinteger64(value)
+ case 1: .uinteger32(UInt32(value))
+ case 2: .uinteger16(UInt16(value))
+ default: .uinteger8(UInt8(value))
+ }
+ exe.nextBindings = [("$1", expr)]
+ #expect(try exe.hasNext())
+ #expect(exe.results?.getFieldString(tupleIndex: 0, fieldIndex: 0) == "\(value)")
+ }
+ let exe = PostgresExeDelegate(connection: conn, sql: "SELECT $1::numeric::text AS t")
+ exe.nextBindings = [("$1", .uinteger(UInt.max))]
+ #expect(try exe.hasNext())
+ #expect(exe.results?.getFieldString(tupleIndex: 0, fieldIndex: 0) == "\(UInt.max)")
+ }
+}
diff --git a/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift b/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift
index 1964562..e540959 100644
--- a/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift
+++ b/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift
@@ -10,6 +10,9 @@ let postgresInitConnInfo = "host=localhost dbname=postgres"
let postgresTestConnInfo = "host=localhost dbname=testing123"
typealias DBConfiguration = PostgresDatabaseConfiguration
+/// The tests that need a server run only with `PG_TESTS=1`; without it they show as skipped.
+let pgTestsEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1"
+
func getDB(reset: Bool = true) throws -> Database {
if reset {
let db = Database(configuration: try DBConfiguration(postgresInitConnInfo))
@@ -20,7 +23,7 @@ func getDB(reset: Bool = true) throws -> Database {
}
// Serialized: every test drops and recreates the same database.
-@Suite(.serialized) struct PerfectPostgreSQLTests {
+@Suite(.serialized, .enabled(if: pgTestsEnabled)) struct PerfectPostgreSQLTests {
struct TestTable1: Codable, TableNameProvider {
enum CodingKeys: String, CodingKey {
@@ -64,7 +67,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
}
- let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1"
init() {
CRUDClearTableStructureCache()
@@ -98,7 +100,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func create1() throws {
- guard pgEnabled else { return }
let db = try getDB()
try db.create(TestTable1.self, policy: .dropTable)
do {
@@ -147,7 +148,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func create2() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
try db.create(TestTable1.self, primaryKey: \.id, policy: .dropTable)
do {
@@ -179,7 +179,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func create3() throws {
- guard pgEnabled else { return }
struct FakeTestTable1: Codable, TableNameProvider {
enum CodingKeys: String, CodingKey {
case id, name, double = "doub", double2 = "doub2", blob, subTables
@@ -208,7 +207,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectAll() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let j2 = db.table(TestTable1.self)
for row in try j2.select() {
@@ -217,7 +215,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectIn() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let table = db.table(TestTable1.self)
#expect(try table.where(\TestTable1.id ~ [2, 4]).count() == 2)
@@ -225,7 +222,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectLikeString() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let table = db.table(TestTable2.self)
#expect(try table.where(\TestTable2.name %=% "me").count() == 25)
@@ -237,7 +233,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectJoin() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let j2 = try db.table(TestTable1.self)
.order(by: \TestTable1.name)
@@ -255,7 +250,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func insert1() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let t1 = db.table(TestTable1.self)
let newOne = TestTable1(id: 2000, name: "New One", integer: 40)
@@ -267,7 +261,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func insert2() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let t1 = db.table(TestTable1.self)
let newOne = TestTable1(id: 2000, name: "New One", integer: 40)
@@ -280,7 +273,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func insert3() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let t1 = db.table(TestTable1.self)
let newOne = TestTable1(id: 2000, name: "New One", integer: 40)
@@ -295,7 +287,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func update() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let newOne = TestTable1(id: 2000, name: "New One", integer: 40)
let newId: Int = try db.transaction {
@@ -316,7 +307,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func delete() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let t1 = db.table(TestTable1.self)
let newOne = TestTable1(id: 2000, name: "New One", integer: 40)
@@ -330,7 +320,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectLimit() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
#expect(try db.table(TestTable1.self).limit(2, skip: 2).select().map { $0 }.count == 2)
#expect(try db.table(TestTable1.self).limit(2...3).select().map { $0 }.count == 2)
@@ -347,7 +336,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectLimitWhere() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let j2 = db.table(TestTable1.self).limit(3).where(\TestTable1.id > 3)
#expect(try j2.count() == 2)
@@ -355,7 +343,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectOrderLimitWhere() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let j2 = db.table(TestTable1.self).order(by: \TestTable1.id).limit(3).where(\TestTable1.id > 3)
#expect(try j2.count() == 2)
@@ -363,7 +350,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selectWhereNULL() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let t1 = db.table(TestTable1.self)
let j1 = t1.where(\TestTable1.blob == nil)
@@ -374,7 +360,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func personThing() throws {
- guard pgEnabled else { return }
struct PhoneNumber: Codable {
let personId: UUID
let planetCode: Int
@@ -412,7 +397,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func standardJoin() throws {
- guard pgEnabled else { return }
struct Parent: Codable {
let id: Int
let children: [Child]?
@@ -439,7 +423,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func junctionJoin() throws {
- guard pgEnabled else { return }
struct Student: Codable {
let id: Int
let classes: [Class]?
@@ -480,7 +463,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selfJoin() throws {
- guard pgEnabled else { return }
struct Me: Codable {
let id: Int
let parentId: Int
@@ -500,7 +482,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func selfJunctionJoin() throws {
- guard pgEnabled else { return }
struct Me: Codable {
let id: Int
let us: [Me]?
@@ -524,7 +505,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func codableProperty() throws {
- guard pgEnabled else { return }
struct Sub: Codable { let id: Int }
struct Top: Codable { let id: Int; let sub: Sub? }
let db = try getTestDB()
@@ -537,7 +517,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func badDecoding() throws {
- guard pgEnabled else { return }
struct Top: Codable, TableNameProvider {
static let tableName = "Top"
let id: Int
@@ -555,7 +534,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func allPrimTypes1() throws {
- guard pgEnabled else { return }
struct AllTypes: Codable {
let int: Int; let uint: UInt; let int64: Int64; let uint64: UInt64
let int32: Int32?; let uint32: UInt32?; let int16: Int16; let uint16: UInt16
@@ -599,7 +577,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func allPrimTypes2() throws {
- guard pgEnabled else { return }
struct AllTypes2: Codable {
let int: Int?; let uint: UInt?; let int64: Int64?; let uint64: UInt64?
let int32: Int32?; let uint32: UInt32?; let int16: Int16?; let uint16: UInt16?
@@ -647,7 +624,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func bespokeSQL() throws {
- guard pgEnabled else { return }
let db = try getTestDB()
let r1 = try db.sql("SELECT * FROM \(TestTable1.CRUDTableName) WHERE id = 2", TestTable1.self)
#expect(r1.count == 1)
@@ -656,7 +632,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func modelClasses() throws {
- guard pgEnabled else { return }
class BaseClass: Codable {
let id: Int
let name: String
@@ -702,7 +677,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func url() throws {
- guard pgEnabled else { return }
struct TableWithURL: Codable { let id: Int; let url: URL }
let db = try getTestDB()
try db.create(TableWithURL.self)
@@ -717,7 +691,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func manyJoins() throws {
- guard pgEnabled else { return }
struct Person2: Codable { var id: UUID; var name: String; let cars: [Car]?; let boats: [Boat]?; let houses: [House]? }
struct Car: Codable { var id: UUID; var owner: UUID }
struct Boat: Codable { var id: UUID; var owner: UUID }
@@ -745,15 +718,7 @@ func getDB(reset: Bool = true) throws -> Database {
#expect(j2.houses?.count == 5)
}
- @Test func dateFormat() {
- #expect(Date(fromISO8601: "2018-08-18 08:10:51-04") != nil)
- #expect(Date(fromISO8601: "2018-08-18 08:10:51.32-04:00") != nil)
- #expect(Date(fromISO8601: "2018-08-18 08:10:51Z") != nil)
- #expect(Date(fromISO8601: "2018-08-18 08:10:51.43Z") != nil)
- }
-
@Test func assets() throws {
- guard pgEnabled else { return }
struct Asset: Codable {
let id: UUID; let name: String?; let assetLog: [AssetLog]?
init(id: UUID, name: String? = nil, assetLog: [AssetLog]? = nil) {
@@ -783,7 +748,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func returningInsert() throws {
- guard pgEnabled else { return }
struct ReturningItem: Codable, Equatable {
let id: UUID; let def: Int?
init(id: UUID, def: Int? = nil) { self.id = id; self.def = def }
@@ -808,7 +772,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func returningUpdate() throws {
- guard pgEnabled else { return }
struct ReturningItem: Codable, Equatable {
let id: UUID; var def: Int?
init(id: UUID, def: Int? = nil) { self.id = id; self.def = def }
@@ -829,7 +792,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func emptyInsert() throws {
- guard pgEnabled else { return }
struct ReturningItem: Codable, Equatable {
let id: Int?; var def: Int?
init(id: Int, def: Int? = nil) { self.id = id; self.def = def }
@@ -839,8 +801,42 @@ func getDB(reset: Bool = true) throws -> Database {
try db.sql("CREATE TABLE \(ReturningItem.CRUDTableName) (id SERIAL PRIMARY KEY, def INT DEFAULT 42)")
let table = db.table(ReturningItem.self)
try table.insert(ReturningItem(id: 0, def: 0), ignoreKeys: \ReturningItem.id, \ReturningItem.def)
- _ = try table.returning(\.def, insert: ReturningItem(id: 0, def: 0),
+ let def = try table.returning(\.def, insert: ReturningItem(id: 0, def: 0),
ignoreKeys: \ReturningItem.id, \ReturningItem.def)
+ #expect(def == 42)
+ // The whole-row form with every key ignored: DEFAULT VALUES, like the key-path form.
+ let row = try table.returning(insert: ReturningItem(id: 0, def: 0),
+ ignoreKeys: \ReturningItem.id, \ReturningItem.def)
+ #expect(row.id == 3)
+ #expect(row.def == 42)
+ let rows = try table.returning(insert: [ReturningItem(id: 0), ReturningItem(id: 0)],
+ ignoreKeys: \ReturningItem.id, \ReturningItem.def)
+ #expect(rows.map(\.id) == [4, 5])
+ }
+
+ // An empty array inserts nothing and returns nothing; it used to trap on instances[0].
+ @Test func returningInsertEmptyArray() throws {
+ struct ReturningItem: Codable, Equatable {
+ let id: Int; let def: Int?
+ }
+ let db = try getTestDB()
+ try db.sql("CREATE TABLE \(ReturningItem.CRUDTableName) (id INT PRIMARY KEY, def INT DEFAULT 42)")
+ let table = db.table(ReturningItem.self)
+ #expect(try table.returning(insert: [ReturningItem]()).isEmpty)
+ #expect(try table.returning(insert: [ReturningItem](), ignoreKeys: \ReturningItem.def).isEmpty)
+ #expect(try table.returning(\.def, insert: [ReturningItem]()).isEmpty)
+ #expect(try table.returning(\.id, insert: [ReturningItem](), setKeys: \ReturningItem.id).isEmpty)
+ #expect(try table.count() == 0)
+ }
+}
+
+/// Needs no server: PostgreSQL's timestamp text formats parse as Dates.
+@Suite struct DateFormatTests {
+ @Test func dateFormat() {
+ #expect(Date(fromISO8601: "2018-08-18 08:10:51-04") != nil)
+ #expect(Date(fromISO8601: "2018-08-18 08:10:51.32-04:00") != nil)
+ #expect(Date(fromISO8601: "2018-08-18 08:10:51Z") != nil)
+ #expect(Date(fromISO8601: "2018-08-18 08:10:51.43Z") != nil)
}
}
@@ -848,8 +844,7 @@ func getDB(reset: Bool = true) throws -> Database {
/// the server intact, and COPY data must be sent with its byte length.
/// Uses only `dbname=postgres` and TEMP tables; don't call `getDB()` here, because
/// PerfectPostgreSQLTests drops and recreates its database and this suite runs alongside it.
-@Suite struct PGConnectionParameterTests {
- let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1"
+@Suite(.enabled(if: pgTestsEnabled)) struct PGConnectionParameterTests {
func connect() throws -> PGConnection {
let conn = PGConnection()
@@ -861,7 +856,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func execParamsRoundTrip() throws {
- guard pgEnabled else { return }
let conn = try connect()
defer { conn.finish() }
let text = "héllo wörld ✓ 日本語"
@@ -881,7 +875,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func manyParamsInOneStatement() throws {
- guard pgEnabled else { return }
let conn = try connect()
defer { conn.finish() }
// Many parameters in one call: guards the per-parameter buffers against mix-ups.
@@ -893,7 +886,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func copyDataWithNonASCIIText() throws {
- guard pgEnabled else { return }
let conn = try connect()
defer { conn.finish() }
#expect(conn.exec(statement: "CREATE TEMP TABLE copy_test (t text)").status() == .commandOK)
@@ -910,8 +902,7 @@ func getDB(reset: Bool = true) throws -> Database {
/// PGConnection is `Sendable`: one connection shared between tasks must stay usable, and a
/// transaction must not have other callers' statements slipped into it.
-@Suite struct PGConnectionConcurrencyTests {
- let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1"
+@Suite(.enabled(if: pgTestsEnabled)) struct PGConnectionConcurrencyTests {
func connect() throws -> PGConnection {
let conn = PGConnection()
@@ -922,7 +913,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func sharedConnectionAcrossTasks() async throws {
- guard pgEnabled else { return }
let conn = try connect()
defer { conn.finish() }
try await withThrowingTaskGroup(of: Void.self) { group in
@@ -939,7 +929,6 @@ func getDB(reset: Bool = true) throws -> Database {
}
@Test func transactionIsNotInterleaved() async throws {
- guard pgEnabled else { return }
let conn = try connect()
defer { conn.finish() }
try conn.execute(statement: "CREATE TEMP TABLE tx_test (n int)")
diff --git a/Tests/PerfectPostgreSQLTests/PoolAndAsyncTests.swift b/Tests/PerfectPostgreSQLTests/PoolAndAsyncTests.swift
new file mode 100644
index 0000000..5b495db
--- /dev/null
+++ b/Tests/PerfectPostgreSQLTests/PoolAndAsyncTests.swift
@@ -0,0 +1,68 @@
+import Foundation
+import Testing
+import PerfectCRUD
+@testable import PerfectPostgreSQL
+
+// Perfect-CRUD's pool and async entry points need a Sendable configuration. These would not
+// compile if PostgresDatabaseConfiguration weren't Sendable.
+// Pooled connections are separate sessions, so this uses a real table in `dbname=postgres`
+// with a name no other suite uses.
+@Suite(.enabled(if: ProcessInfo.processInfo.environment["PG_TESTS"] == "1"), .serialized)
+struct PoolAndAsyncTests {
+ struct PoolItem: Codable, Sendable, TableNameProvider {
+ static let tableName = "pool_async_item"
+ let id: Int
+ let name: String
+ }
+
+ private func makePool(max: Int) throws -> DatabaseConnectionPool {
+ try DatabaseConnectionPool(configuration: .init(minConnections: 1, maxConnections: max)) {
+ try PostgresDatabaseConfiguration(postgresInitConnInfo)
+ }
+ }
+
+ @Test func poolTransactionAsyncRoundTrip() async throws {
+ let pool = try makePool(max: 3)
+ try await pool.prewarm()
+ try await pool.withConnection { db in
+ try db.create(PoolItem.self, policy: .dropTable)
+ let table = db.table(PoolItem.self)
+ try await db.transactionAsync {
+ try table.insert(PoolItem(id: 1, name: "one"))
+ try table.insert(PoolItem(id: 2, name: "two"))
+ }
+ try await table.insertAsync(PoolItem(id: 3, name: "three"))
+ let all = try await table.order(by: \.id).fetchAll()
+ #expect(all.map(\.id) == [1, 2, 3])
+ let two = try await table.where(\PoolItem.id == 2).firstAsync()
+ #expect(two?.name == "two")
+ // A failing transaction rolls back.
+ struct Boom: Error {}
+ await #expect(throws: Boom.self) {
+ try await db.transactionAsync {
+ try table.insert(PoolItem(id: 4, name: "four"))
+ throw Boom()
+ }
+ }
+ #expect(try table.count() == 3)
+ }
+ // Many tasks share the pool; each gets its own connection.
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for id in 10..<30 {
+ group.addTask {
+ try await pool.withConnection { db in
+ try await db.table(PoolItem.self).insertAsync(PoolItem(id: id, name: "n\(id)"))
+ }
+ }
+ }
+ try await group.waitForAll()
+ }
+ #expect(await pool.currentSize <= 3)
+ let count = try await pool.withConnection { db in
+ let n = try db.table(PoolItem.self).count()
+ try db.sql("DROP TABLE pool_async_item")
+ return n
+ }
+ #expect(count == 23)
+ }
+}
diff --git a/Tests/PerfectPostgreSQLTests/QuoteIdentifierTests.swift b/Tests/PerfectPostgreSQLTests/QuoteIdentifierTests.swift
index 310c91d..56287c9 100644
--- a/Tests/PerfectPostgreSQLTests/QuoteIdentifierTests.swift
+++ b/Tests/PerfectPostgreSQLTests/QuoteIdentifierTests.swift
@@ -13,27 +13,7 @@ extension PerfectPostgreSQLTests {
private struct Count: Codable { let c: Int }
private struct IdName: Codable { let id: Int; let name: String }
- // No server needed: quote() doesn't touch the connection.
- @Test func quoteIdentifierDoublesEmbeddedQuotes() throws {
- let gen = PostgresGenDelegate(connection: PGConnection())
- #expect(try gen.quote(identifier: "plain") == #""plain""#)
- #expect(try gen.quote(identifier: "MixedCase") == #""mixedcase""#)
- #expect(try gen.quote(identifier: #"A"b"#) == #""a""b""#)
- #expect(try gen.quote(identifier: #"""#) == #""""""#)
- #expect(try gen.quote(identifier: #"x" = "x" OR "x"#) == #""x"" = ""x"" or ""x""#)
- #expect(try gen.quote(identifier: "") == #""""#)
- // A `"` followed by a combining mark or variation selector is one
- // Character with it; it must still be doubled as a scalar.
- for name in ["a\"\u{301}b", "\"\u{301}", "a\"\u{FE0F}b", "a\"\u{200D}b", "\u{600}\"x"] {
- let quoted = try gen.quote(identifier: name)
- let inner = name.unicodeScalars.filter { $0 == "\"" }.count
- #expect(quoted.utf8.filter { $0 == UInt8(ascii: "\"") }.count == 2 + 2 * inner, "\(name.unicodeScalars.map { $0.value })")
- }
- #expect(try gen.quote(identifier: "a\"\u{301}b") == "\"a\"\"\u{301}b\"")
- }
-
@Test func dynamicMutationWithQuoteInTableAndFieldNames() throws {
- guard pgEnabled else { return }
let db = try getDB()
try db.sql(#"CREATE TABLE "we""ird" ("id" INT PRIMARY KEY, "na""me" TEXT)"#)
for (id, name) in [(1, "one"), (2, "two")] {
@@ -56,7 +36,6 @@ extension PerfectPostgreSQLTests {
// On main each of these names closed the quoted identifier and added SQL,
// turning a one-row DELETE/UPDATE into one that hits the whole table.
@Test func dynamicNamesCannotInjectSQL() throws {
- guard pgEnabled else { return }
let db = try getDB()
try db.sql(#"CREATE TABLE "quote_victim" ("id" INT PRIMARY KEY, "name" TEXT)"#)
try db.sql(#"INSERT INTO "quote_victim" ("id", "name") VALUES (1, 'a'), (2, 'b'), (3, 'c')"#)
@@ -133,7 +112,6 @@ extension PerfectPostgreSQLTests {
}
@Test func schemaWithQuoteAndKeywordNames() throws {
- guard pgEnabled else { return }
let db = try getDB()
try db.create(QuoteParent.self, policy: .dropTable)
try db.table(QuoteChild.self).index(\.note)
@@ -161,3 +139,24 @@ extension PerfectPostgreSQLTests {
#expect(columns == 2)
}
}
+
+// No server needed: quote() doesn't touch the connection.
+@Suite struct QuoteIdentifierUnitTests {
+ @Test func quoteIdentifierDoublesEmbeddedQuotes() throws {
+ let gen = PostgresGenDelegate(connection: PGConnection())
+ #expect(try gen.quote(identifier: "plain") == #""plain""#)
+ #expect(try gen.quote(identifier: "MixedCase") == #""mixedcase""#)
+ #expect(try gen.quote(identifier: #"A"b"#) == #""a""b""#)
+ #expect(try gen.quote(identifier: #"""#) == #""""""#)
+ #expect(try gen.quote(identifier: #"x" = "x" OR "x"#) == #""x"" = ""x"" or ""x""#)
+ #expect(try gen.quote(identifier: "") == #""""#)
+ // A `"` followed by a combining mark or variation selector is one
+ // Character with it; it must still be doubled as a scalar.
+ for name in ["a\"\u{301}b", "\"\u{301}", "a\"\u{FE0F}b", "a\"\u{200D}b", "\u{600}\"x"] {
+ let quoted = try gen.quote(identifier: name)
+ let inner = name.unicodeScalars.filter { $0 == "\"" }.count
+ #expect(quoted.utf8.filter { $0 == UInt8(ascii: "\"") }.count == 2 + 2 * inner, "\(name.unicodeScalars.map { $0.value })")
+ }
+ #expect(try gen.quote(identifier: "a\"\u{301}b") == "\"a\"\"\u{301}b\"")
+ }
+}