From c7fb18a1a1dc4e7660c95b05d13126230caf49c9 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 6 Aug 2026 17:07:05 -0300 Subject: [PATCH 1/2] fix(postgrest): throw on multi-row result from maybeSingle when throw-on-error is enabled maybeSingle() swallowed PGRST116 unconditionally, so a query that matched more than one row silently returned nil instead of surfacing the error -- hiding a real bug where the query should have been scoped to match at most one row. Zero rows still returns nil; more than one row now throws, same as single() already does. PostgREST reports both cases with the same error code, distinguishable only via the details message ("Results contain N rows, ..."), so maybeSingle now inspects that to decide whether to swallow or rethrow. Fixes #1170 --- Sources/PostgREST/PostgrestBuilder.swift | 23 ++++++++++-- .../PostgREST/PostgrestTransformBuilder.swift | 12 ++++--- .../PostgrestBuilderTests.swift | 35 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/Sources/PostgREST/PostgrestBuilder.swift b/Sources/PostgREST/PostgrestBuilder.swift index b618d2252..cc27c859b 100644 --- a/Sources/PostgREST/PostgrestBuilder.swift +++ b/Sources/PostgREST/PostgrestBuilder.swift @@ -243,8 +243,10 @@ public class PostgrestBuilder: @unchecked Sendable { } if let error = try? configuration.decoder.decode(PostgrestError.self, from: response.data) { - // `maybeSingle()` turns the "no/too many rows" error (PGRST116) into a `nil` value. - if isMaybeSingle, error.code == "PGRST116" { + // `maybeSingle()` turns the "no rows" variant of PGRST116 into a `nil` value, but + // rethrows the "multiple rows" variant since that indicates a query that should have + // been scoped to match at most one row. + if isMaybeSingle, error.code == "PGRST116", error.matchedZeroRows { let value = try decode(Data("null".utf8)) return PostgrestResponse( data: response.data, response: response.underlyingResponse, value: value) @@ -290,3 +292,20 @@ extension HTTPField.Name { static let contentProfile = Self("Content-Profile")! static let xRetryCount = Self("X-Retry-Count")! } + +extension PostgrestError { + /// Whether a `PGRST116` error was caused by the query matching zero rows, as opposed to more + /// than one row. + /// + /// PostgREST reports both cases with the same error code; the row count is only distinguishable + /// via the `details` message, e.g. "Results contain 0 rows, application/vnd.pgrst.object+json + /// requires 1 row". + fileprivate var matchedZeroRows: Bool { + guard let details, + let rangeAfterPrefix = details.range(of: "Results contain "), + let rangeOfRowsSuffix = details.range( + of: " row", range: rangeAfterPrefix.upperBound.. Note: PostgREST returns `PGRST116` both when zero rows match and when more than one row - /// > matches. This method returns `nil` for either case; use ``single()`` for the strict variant - /// > that always throws when the query does not match exactly one row. + /// > matches. Only the zero-row case is turned into `nil`; a match of more than one row still + /// > throws, since it indicates the query should have been scoped to match at most one row. + /// > Use ``single()`` for the strict variant that always throws when the query does not match + /// > exactly one row. /// /// ```swift /// let todo: Todo? = try await client diff --git a/Tests/PostgRESTTests/PostgrestBuilderTests.swift b/Tests/PostgRESTTests/PostgrestBuilderTests.swift index 30afea629..c4565b049 100644 --- a/Tests/PostgRESTTests/PostgrestBuilderTests.swift +++ b/Tests/PostgRESTTests/PostgrestBuilderTests.swift @@ -104,6 +104,7 @@ extension PostgrestMockerTests { """ { "code": "PGRST116", + "details": "Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row", "message": "JSON object requested, multiple (or no) rows returned" } """.utf8 @@ -123,6 +124,40 @@ extension PostgrestMockerTests { #expect(user == nil) } + @Test + func maybeSingleThrowsOnMultipleRows() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 406, + data: [ + .get: Data( + """ + { + "code": "PGRST116", + "details": "Results contain 2 rows, application/vnd.pgrst.object+json requires 1 row", + "message": "JSON object requested, multiple (or no) rows returned" + } + """.utf8 + ) + ] + ) + .register() + + do { + let _: User? = + try await sut + .from("users") + .select() + .maybeSingle() + .execute() + .value + Issue.record("Expected error to be thrown") + } catch let error as PostgrestError { + #expect(error.code == "PGRST116") + } + } + @Test func maybeSingleReturnsValueOnSingleRow() async throws { Mock( From 2f307f55b443e869be608c1e181aa0c2b0cd2fa8 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 7 Aug 2026 11:29:01 -0300 Subject: [PATCH 2/2] fix(postgrest): recognize both PGRST116 zero-row message wordings maybeSingle()'s zero-vs-multiple-row detection only matched the "Results contain N rows" wording, but the real PostgREST server (used in the Linux integration CI job) instead sends "The result contains N rows", causing maybeSingle() to rethrow on zero rows and fail the maybeSingleReturnsNilOnZeroRows integration test. Extract the row count by scanning for the number preceding a "row"/"rows" token instead of matching a fixed prefix, so both wordings work. --- Sources/PostgREST/PostgrestBuilder.swift | 16 +++++----- .../PostgrestBuilderTests.swift | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Sources/PostgREST/PostgrestBuilder.swift b/Sources/PostgREST/PostgrestBuilder.swift index cc27c859b..925bea5ab 100644 --- a/Sources/PostgREST/PostgrestBuilder.swift +++ b/Sources/PostgREST/PostgrestBuilder.swift @@ -298,14 +298,16 @@ extension PostgrestError { /// than one row. /// /// PostgREST reports both cases with the same error code; the row count is only distinguishable - /// via the `details` message, e.g. "Results contain 0 rows, application/vnd.pgrst.object+json - /// requires 1 row". + /// via the `details` message. The exact wording has varied across PostgREST versions, e.g. + /// "Results contain 0 rows, application/vnd.pgrst.object+json requires 1 row" and "The result + /// contains 0 rows". Both mention the matched row count immediately before a "row"/"rows" word, + /// so look for that instead of matching a fixed prefix. fileprivate var matchedZeroRows: Bool { - guard let details, - let rangeAfterPrefix = details.range(of: "Results contain "), - let rangeOfRowsSuffix = details.range( - of: " row", range: rangeAfterPrefix.upperBound.. 0, + let count = Int(words[rowsIndex - 1]) else { return false } - return details[rangeAfterPrefix.upperBound..