Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions Sources/PostgREST/PostgrestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
) async throws -> PostgrestResponse<T> {
try await execute(options: options) { [configuration] data in
do {
return try configuration.decoder.decode(T.self, from: data)

Check warning on line 164 in Sources/PostgREST/PostgrestBuilder.swift

View workflow job for this annotation

GitHub Actions / Examples (UserManagement)

capture of non-Sendable type 'T.Type' in an isolated closure

Check warning on line 164 in Sources/PostgREST/PostgrestBuilder.swift

View workflow job for this annotation

GitHub Actions / Examples (Examples)

capture of non-Sendable type 'T.Type' in an isolated closure

Check warning on line 164 in Sources/PostgREST/PostgrestBuilder.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (test, MACOS, 26.4)

capture of non-Sendable type 'T.Type' in an isolated closure

Check warning on line 164 in Sources/PostgREST/PostgrestBuilder.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (IOS, 26.4)

capture of non-Sendable type 'T.Type' in an isolated closure

Check warning on line 164 in Sources/PostgREST/PostgrestBuilder.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (IOS, 26.4)

capture of non-Sendable type 'T.Type' in an isolated closure

Check warning on line 164 in Sources/PostgREST/PostgrestBuilder.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (test, IOS, 26.4)

capture of non-Sendable type 'T.Type' in an isolated closure
} catch {
configuration.logger?.error("Failed to decode type '\(T.self) with error: \(error)")
throw error
Expand Down Expand Up @@ -243,8 +243,10 @@
}

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)
Expand Down Expand Up @@ -290,3 +292,22 @@
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
}
}
12 changes: 7 additions & 5 deletions Sources/PostgREST/PostgrestTransformBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions Tests/PostgRESTTests/PostgrestBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
Loading