From 4a4fe65e45d22333089bb7de54e11389a228c5d1 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:27:12 -0600 Subject: [PATCH 01/13] Depend on Perfect-CRUD from 3.0.0 instead of main A package that depends on another by branch can't be resolved by a version-pinned consumer, so 5.0.0 needs CRUD's release. Resolves to CRUD 3.0.0 (swift-log 1.15.1). No Package.resolved is committed (*.resolved is ignored). 53/53 tests pass against postgres:17 with -warnings-as-errors in debug and release on Xcode 27 (Swift 6.4), Xcode 26.3 (Swift 6.2.4), and Linux swift:6.2-noble / swift:6.4-noble. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 42a17a1..e099a07 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.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), ], targets: [ From 7ca25eeee22fec853d0fae609ee5828161d7b543 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:27:12 -0600 Subject: [PATCH 02/13] CI: Linux and macOS, debug and release, warnings as errors Modeled on Perfect-CRUD's workflow. Linux runs Swift 6.2 and 6.4 in the swift:X-noble container with libpq-dev and a postgres:17 service (health checked), and runs the live tests with PG_TESTS=1 PGUSER=postgres. The tests connect with host=localhost, which PGHOST can't override, so the job sets libpq's PGHOSTADDR to the service's address; that mechanism was checked locally (Linux containers reaching a separate postgres:17 container through PGHOSTADDR), not on GitHub. macOS runs Xcode 26.0.1 and 26.6 on macos-26 with Homebrew libpq and PKG_CONFIG_PATH (Swift 6.2's SwiftPM doesn't find the keg-only libpq without it); there's no server there, so the live tests skip. No iOS job: libpq is a host system library and the package declares only macOS. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 102 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf5029e --- /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 skip 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 From 120baba5c73187943c826a2be890b8ef2967d737 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:28:47 -0600 Subject: [PATCH 03/13] Quote conninfo values in PostgresDatabaseConfiguration(database:host:...) The values were pasted into the libpq conninfo string unquoted, so a password (or database, host or user name) containing a space made the string unparseable, and a value such as "x sslmode=disable" added a connection option of its own. Each value is now single-quoted with \ and ' backslash-escaped, as libpq's conninfo syntax requires. The behaviour is unchanged since 4.0.0 for values without spaces, quotes or backslashes. ConnectionInfoTests parses the result with libpq's PQconninfoParse (no server needed) and, with PG_TESTS=1, connects with a password containing a space. Without the fix 4 of the 5 tests fail; plainValues passes on both. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectPostgreSQL/PostgresCRUD.swift | 24 +++++-- .../ConnectionInfoTests.swift | 68 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 Tests/PerfectPostgreSQLTests/ConnectionInfoTests.swift diff --git a/Sources/PerfectPostgreSQL/PostgresCRUD.swift b/Sources/PerfectPostgreSQL/PostgresCRUD.swift index eb04766..a448989 100644 --- a/Sources/PerfectPostgreSQL/PostgresCRUD.swift +++ b/Sources/PerfectPostgreSQL/PostgresCRUD.swift @@ -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/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) + } +} From 1fd1fee3c8cf11d966bbe9b4a2336a1822df0aa2 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:30:50 -0600 Subject: [PATCH 04/13] README: 5.0.0 install, requirements, libpq setup, migration notes - Install snippet uses from: "5.0.0" (was branch: "main" with a "no tagged releases" note). - Requirements: Linux is supported and CI-tested; the old text called it implicit and not guaranteed. Badges list macOS 12+ and Linux. - libpq setup: Homebrew's libpq is keg-only; Swift 6.2's SwiftPM doesn't find it without PKG_CONFIG_PATH (checked with Xcode 26.3: 'libpq-fe.h' file not found), Swift 6.4's does. - A usage example, and what the CRUD backend doesn't support: Dynamic select (throws once a row comes back), affectedRows (always 0), insertedID (nil) and transaction isolation levels (ignored). The example and each of these were checked against postgres:17 from a scratch consumer package. - Testing: PG_TESTS, the databases the tests drop, and PGPORT / PGHOSTADDR for other servers. - A "Migrating from 4.x" section and the legacy branch pointer (legacy is the 4.0.0 commit). Co-Authored-By: Claude Opus 5.5 --- README.md | 151 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 99 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index faf08b3..c9c3e1a 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,133 @@ # Perfect - PostgreSQL Connector

- - Swift 6.2 - - - Platforms macOS 12+ - - - License Apache - + Swift 6.2 + Platforms macOS 12+ | Linux + License Apache 2.0

-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. `mutate(_: DynamicMutation)` + and row-less `DynamicSQL` work, but the result's `affectedRows` is always 0 and its `insertedID` is nil. +- **Isolation levels:** `transaction(isolation:)` ignores the level and uses the server's default. -A test target is included. Run it with: +`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. There's no async/await API. + +## 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, and fails with "must be owner" on a table the role doesn't own. +* **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. +* **Logging.** Diagnostics go through [swift-log](https://github.com/apple/swift-log) (label `perfect.postgresql`) + instead of `print`. + ## License -Apache 2.0 — see [LICENSE](LICENSE). +Apache 2.0. See [LICENSE](LICENSE). From c7713a92ade30866d782139a8e3e2a7644f400af Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:34:15 -0600 Subject: [PATCH 05/13] CI: say what the live tests do without a server Most of them return early and count as passed rather than skipping; only SubTableCreateOrderLiveTests uses .enabled(if:). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf5029e..e556140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: macos: # GitHub's macOS runners have no PostgreSQL server: this builds everything and runs the - # tests that don't need one (the live tests skip without PG_TESTS=1). + # tests that don't need one (the live tests do nothing without PG_TESTS=1). name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) runs-on: macos-26 timeout-minutes: 30 From 9778554754340c38686feca8e908612d807d8667 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:38:23 -0600 Subject: [PATCH 06/13] Tests: pin down Dynamic API behaviour on PostgreSQL Checks for the crash patterns found in the Perfect-SQLite review. None of them applies here, so there's no code change: - The connector doesn't implement nextDynamicRow(), so Dynamic select (and execute(DynamicSQL) returning rows) throws CRUD's CRUDSQLExeError once a row comes back; no DynamicRow is built, so columns differing only by case (SELECT 1 AS a, 2 AS "A") can't hit DynamicRow.init's duplicate-key trap. An empty result returns empty. - DynamicSQL with allowsMultipleStatements: true throws: PQexecParams takes one statement ("cannot insert multiple commands into a prepared statement") and neither statement runs. Without the flag, CRUD's guard throws CRUDSQLGenError. - Unsigned values are bound as decimal text, with no Int conversion: .uint(UInt64.max) stores exactly in a numeric column and gets a server error (not a trap) for a bigint one; .uinteger/.uinteger64/ 32/16/8 at their maximums round-trip. - affectedRows is 0 and insertedID nil for mutations. 62/62 pass against postgres:17 with -warnings-as-errors in debug and release on Xcode 27, Xcode 26.3, and Linux swift:6.2-noble and swift:6.4-noble. Co-Authored-By: Claude Opus 5.5 --- .../DynamicAPITests.swift | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 Tests/PerfectPostgreSQLTests/DynamicAPITests.swift diff --git a/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift b/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift new file mode 100644 index 0000000..4e4e76b --- /dev/null +++ b/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift @@ -0,0 +1,98 @@ +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 struct DynamicAPITests { + let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1" + + func connect() throws -> Database { + Database(configuration: try PostgresDatabaseConfiguration(postgresInitConnInfo)) + } + + private struct Count: Codable { let c: Int } + + @Test func selectThrowsOnceARowComesBack() throws { + guard pgEnabled else { return } + 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 { + guard pgEnabled else { return } + 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 { + guard pgEnabled else { return } + 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 { + guard pgEnabled else { return } + 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)") + } +} From f0fbb971539b9fb742380b6e2c1bb73b932d90cf Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 07:38:23 -0600 Subject: [PATCH 07/13] README: DynamicSQL with several statements throws on PostgreSQL Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c9c3e1a..ff05fc9 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ 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. `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 From 34d984a798cf428fb3cb23cf25284e1b38ea0efb Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:31:42 -0600 Subject: [PATCH 08/13] Make PostgresDatabaseConfiguration Sendable Perfect-CRUD 3.0.0's DatabaseConnectionPool, transactionAsync, sqlAsync, insertAsync, fetchAll and the other async entry points require a Sendable configuration, so none of them compiled with this connector. Its only stored property is a PGConnection, which is Sendable. PoolAndAsyncTests (live, skipped without PG_TESTS=1) runs a pool with a transactionAsync round trip, a rolled-back transactionAsync, insertAsync, fetchAll, firstAsync, and 20 concurrent pooled inserts. Without the conformance the test target doesn't compile ("type 'PostgresDatabaseConfiguration' does not conform to the 'Sendable' protocol"). Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectPostgreSQL/PostgresCRUD.swift | 2 +- .../PoolAndAsyncTests.swift | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 Tests/PerfectPostgreSQLTests/PoolAndAsyncTests.swift diff --git a/Sources/PerfectPostgreSQL/PostgresCRUD.swift b/Sources/PerfectPostgreSQL/PostgresCRUD.swift index a448989..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?, 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) + } +} From 54da493f3b84626a057c9ee956cdbfae6db67bd5 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:34:09 -0600 Subject: [PATCH 09/13] Return [] from returning(insert:) for an empty array Both _insert variants read instances[0] before checking the count, so returning(insert: []) and returning(_:insert: []) trapped with "Index out of range". They now return [] without touching the database. returningInsertEmptyArray (live) covers the whole-row and key-path forms, with and without setKeys/ignoreKeys; it crashes the test process without the fix. Co-Authored-By: Claude Opus 5.5 --- .../PerfectPostgreSQL/PostgresCRUDInsert.swift | 6 ++++++ .../PerfectPostgreSQLTests.swift | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift b/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift index 1185851..4a25297 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) @@ -90,6 +93,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/PerfectPostgreSQLTests.swift b/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift index 1964562..52ff450 100644 --- a/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift +++ b/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift @@ -842,6 +842,22 @@ func getDB(reset: Bool = true) throws -> Database { _ = try table.returning(\.def, insert: ReturningItem(id: 0, def: 0), ignoreKeys: \ReturningItem.id, \ReturningItem.def) } + + // An empty array inserts nothing and returns nothing; it used to trap on instances[0]. + @Test func returningInsertEmptyArray() throws { + guard pgEnabled else { return } + 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) + } } /// Low-level PGConnection checks: every parameter type `exec(statement:params:)` accepts must reach From 1716cbc7657c11d36843f131dc415e93a24d1d5c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:34:10 -0600 Subject: [PATCH 10/13] Use DEFAULT VALUES in the whole-row returning(insert:) too With every key ignored, the whole-row form generated INSERT INTO "t" () VALUES () RETURNING *, a syntax error in PostgreSQL; the key-path form already used DEFAULT VALUES. Both now do. emptyInsert (live) now checks the key-path form's returned default and the whole-row form for one row and for several; without the fix it fails with 'syntax error at or near ")"'. Co-Authored-By: Claude Opus 5.5 --- .../PerfectPostgreSQL/PostgresCRUDInsert.swift | 16 +++++++++++----- .../PerfectPostgreSQLTests.swift | 11 ++++++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift b/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift index 4a25297..86bd1be 100644 --- a/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift +++ b/Sources/PerfectPostgreSQL/PostgresCRUDInsert.swift @@ -58,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) diff --git a/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift b/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift index 52ff450..5b17580 100644 --- a/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift +++ b/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift @@ -839,8 +839,17 @@ 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]. From 5bd0a771ed213f64549ae63847b2e619d6f4cf8d Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:35:44 -0600 Subject: [PATCH 11/13] Tests: skip the live suites without a server instead of passing them 46 live tests began with 'guard pgEnabled else { return }', so without PG_TESTS=1 they reported as passed. PerfectPostgreSQLTests (and its QuoteIdentifierTests extension), PGConnectionParameterTests, PGConnectionConcurrencyTests and DynamicAPITests now carry a suite-level .enabled(if: pgTestsEnabled) trait and the guards are gone, so without a server they show as skipped. The two server-free tests that lived in those suites moved to their own suites so they still run: dateFormat (DateFormatTests) and quoteIdentifierDoublesEmbeddedQuotes (QuoteIdentifierUnitTests). Without a server: 64 tests, 9 run and pass, the rest skipped. With postgres:17: 64/64 pass. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .../DynamicAPITests.swift | 7 +- .../PerfectPostgreSQLTests.swift | 68 +++++-------------- .../QuoteIdentifierTests.swift | 43 ++++++------ 4 files changed, 39 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e556140..f238885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: macos: # GitHub's macOS runners have no PostgreSQL server: this builds everything and runs the - # tests that don't need one (the live tests do nothing without PG_TESTS=1). + # 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 diff --git a/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift b/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift index 4e4e76b..0bb54c3 100644 --- a/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift +++ b/Tests/PerfectPostgreSQLTests/DynamicAPITests.swift @@ -8,8 +8,7 @@ import PerfectCRUD // `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 struct DynamicAPITests { - let pgEnabled = ProcessInfo.processInfo.environment["PG_TESTS"] == "1" +@Suite(.enabled(if: pgTestsEnabled)) struct DynamicAPITests { func connect() throws -> Database { Database(configuration: try PostgresDatabaseConfiguration(postgresInitConnInfo)) @@ -18,7 +17,6 @@ import PerfectCRUD private struct Count: Codable { let c: Int } @Test func selectThrowsOnceARowComesBack() throws { - guard pgEnabled else { return } 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) @@ -34,7 +32,6 @@ import PerfectCRUD } @Test func mutationCountsAreNotReported() throws { - guard pgEnabled else { return } 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)])) @@ -49,7 +46,6 @@ import PerfectCRUD // PQexecParams accepts one statement only, so neither statement runs and the call throws. @Test func multipleStatementsThrowAndRunNothing() throws { - guard pgEnabled else { return } let db = try connect() try db.sql("CREATE TEMP TABLE dyn_multi (id int)") let error = #expect(throws: CRUDSQLExeError.self) { @@ -64,7 +60,6 @@ import PerfectCRUD } @Test func unsignedValuesBindExactly() throws { - guard pgEnabled else { return } 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", diff --git a/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift b/Tests/PerfectPostgreSQLTests/PerfectPostgreSQLTests.swift index 5b17580..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 } @@ -854,7 +816,6 @@ func getDB(reset: Bool = true) throws -> Database { // An empty array inserts nothing and returns nothing; it used to trap on instances[0]. @Test func returningInsertEmptyArray() throws { - guard pgEnabled else { return } struct ReturningItem: Codable, Equatable { let id: Int; let def: Int? } @@ -869,12 +830,21 @@ func getDB(reset: Bool = true) throws -> Database { } } +/// 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) + } +} + /// Low-level PGConnection checks: every parameter type `exec(statement:params:)` accepts must reach /// 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() @@ -886,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 ✓ 日本語" @@ -906,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. @@ -918,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) @@ -935,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() @@ -947,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 @@ -964,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/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\"") + } +} From 4e5e4c6b2a0082caf3432aba2bac32676ef92079 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 6 Oct 2026 08:36:25 -0600 Subject: [PATCH 12/13] README: review fixes - Dynamic select/execute throw only after the statement has run (and committed under autocommit), so a retried INSERT ... RETURNING through execute inserts twice. - PGConnection's lock doesn't cover CRUD's Database.transaction / transactionAsync, which send BEGIN and COMMIT/ROLLBACK as separate calls; a Database used for transactions mustn't be shared between tasks. Points at the pool, which now works (the configuration is Sendable). Drops "there's no async/await API". - Known limitations: NUL in a String, bytea_output = escape, NULL into a non-optional Int/String, millisecond Dates, and CRUD 3.0.0's trap for a Codable UInt/UInt64 above Int.max. - Reconcile fails with "must be owner" only when it needs to alter a table the role doesn't own. - Migration: the quoted connection parameters, including options that were appended inside a value; swift-log covers only the connector's own diagnostic, CRUD still logs through CRUDLogging. Co-Authored-By: Claude Opus 5.5 --- README.md | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ff05fc9..67812b1 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,9 @@ See [Perfect-CRUD](https://github.com/PerfectlySoft/Perfect-CRUD) for the query 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. `mutate(_: DynamicMutation)` + 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. @@ -88,7 +90,25 @@ doesn't support: `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. There's no async/await API. +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. +- In Perfect-CRUD 3.0.0, a `UInt` or `UInt64` property of a `Codable` model holding a value above `Int.max` traps + when it's bound (a fix is planned for CRUD 3.0.1). The Dynamic API's `.uint` and `PGConnection`'s own parameters + handle the full range. ## Testing @@ -124,11 +144,16 @@ need attention when upgrading: 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, and fails with "must be owner" on a table the role doesn't own. + 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. -* **Logging.** Diagnostics go through [swift-log](https://github.com/apple/swift-log) (label `perfect.postgresql`) - instead of `print`. +* **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 From 8fac518b42177aa65e84f96b329d038940dcbe4f Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Tue, 13 Oct 2026 09:08:08 -0500 Subject: [PATCH 13/13] Depend on Perfect-CRUD from 3.0.1 3.0.1 fixes four crashes in 3.0.0 reachable from public API, including a Codable UInt/UInt64 above Int.max trapping in CRUD's encoder (now bound as unsigned). Tests pass against 3.0.1. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 2 +- README.md | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index e099a07..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", from: "3.0.0"), + .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 67812b1..c8ba1ae 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,8 @@ Known limitations: 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. -- In Perfect-CRUD 3.0.0, a `UInt` or `UInt64` property of a `Codable` model holding a value above `Int.max` traps - when it's bound (a fix is planned for CRUD 3.0.1). The Dynamic API's `.uint` and `PGConnection`'s own parameters - handle the full range. +- 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. ## Testing