diff --git a/Sources/PostgREST/PostgrestBuilder.swift b/Sources/PostgREST/PostgrestBuilder.swift index b618d2252..925bea5ab 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,22 @@ 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. 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 else { return false } + let words = details.split(separator: " ") + guard let rowsIndex = words.firstIndex(where: { $0.hasPrefix("row") }), rowsIndex > 0, + let count = Int(words[rowsIndex - 1]) + else { return false } + return count == 0 + } +} diff --git a/Sources/PostgREST/PostgrestTransformBuilder.swift b/Sources/PostgREST/PostgrestTransformBuilder.swift index 1113354a3..73c2809ba 100644 --- a/Sources/PostgREST/PostgrestTransformBuilder.swift +++ b/Sources/PostgREST/PostgrestTransformBuilder.swift @@ -205,13 +205,15 @@ public class PostgrestTransformBuilder: PostgrestBuilder, @unchecked Sendable { /// Instructs PostgREST to return a single JSON object, returning `nil` when no row matches. /// /// Like ``single()``, this sets the `application/vnd.pgrst.object+json` accept header so the - /// server enforces a single result. Unlike ``single()``, when the query does not match exactly - /// one row the resulting `PGRST116` error is not thrown — ``PostgrestResponse/value`` is `nil` - /// instead. Decode into an optional type to observe the `nil`. + /// server enforces a single result. Unlike ``single()``, when the query matches zero rows the + /// resulting `PGRST116` error is not thrown — ``PostgrestResponse/value`` is `nil` instead. + /// Decode into an optional type to observe the `nil`. /// /// > 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..c731c3e85 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,71 @@ extension PostgrestMockerTests { #expect(user == nil) } + @Test + func maybeSingleReturnsNilOnZeroRowsWithNewerPostgrestWording() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 406, + data: [ + .get: Data( + """ + { + "code": "PGRST116", + "details": "The result contains 0 rows", + "message": "Cannot coerce the result to a single JSON object" + } + """.utf8 + ) + ] + ) + .register() + + let user: User? = + try await sut + .from("users") + .select() + .maybeSingle() + .execute() + .value + + #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(