Skip to content

Commit 4bb253c

Browse files
committed
ResultCursor reports command complete status immediately with a zero-result query. Column metadata is now held on ResultCursor if requested. SqlStatementTest passes.
1 parent f5d08b7 commit 4bb253c

27 files changed

+669
-152
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import PackageDescription
66
let package = Package(
77
name: "SwiftPostgresClient",
88
platforms: [
9-
.macOS(.v10_15), .iOS(.v14), .tvOS(.v13)
9+
.macOS(.v11), .iOS(.v14), .tvOS(.v14)
1010
],
1111
products: [
1212
// Products define the executables and libraries a package produces, making them visible to other packages.

README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,9 @@ This project has been adapted from PostgresClientKit, with the following changes
3030

3131
- **Channel binding support.** A security feature for SCRAM-SHA-256 authentication over TLS, channel binding links the TLS session to the authentication exchange, protecting against man-in-the-middle (MitM) attacks.
3232

33-
Sounds good? Let's look at an example.
34-
3533
## Example
3634

37-
This is a basic, but complete, example of how to connect to Postgres, perform a SQL `SELECT` command, and process the resulting rows. It uses the `weather` table in the [Postgres tutorial](https://www.postgresql.org/docs/11/tutorial-table.html).
35+
This is a basic, but complete, example of how to connect to a PostgresSQL server, perform a SQL `SELECT` command, and process the resulting rows. It uses the `weather` table in the [Postgres tutorial](https://www.postgresql.org/docs/current/tutorial-table.html).
3836

3937
```swift
4038
import SwiftPostgresClient

Sources/SwiftPostgresClient/Codable/PostgresError.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
/// Errors thrown by PostgresClientKit.
2121
public enum PostgresError: Error {
2222

23+
2324
case awaitingAuthentication
2425

2526
case invalidState(String)

Sources/SwiftPostgresClient/Connection+portal.swift

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,13 @@ extension Connection {
3535

3636
try await receiveResponse(type: BindCompleteResponse.self)
3737

38-
var rowDecoder: RowDecoder?
38+
var metadata: [ColumnMetadata]?
3939
if columnMetadata {
40-
let metadata = try await retrieveColumnMetadata(portalName: portalName)
41-
if let metadata {
42-
rowDecoder = RowDecoder(columns: metadata)
43-
}
40+
metadata = try await retrieveColumnMetadata(portalName: portalName)
4441
}
4542
portalStatus[portalName] = .open
4643

47-
return Portal(name: portalName, rowDecoder: rowDecoder, statement: statement, connection: self)
44+
return Portal(name: portalName, metadata: metadata, statement: statement, connection: self)
4845
}
4946

5047
func cleanupPortal(name: String) async throws {

Sources/SwiftPostgresClient/Connection+query.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ extension Connection {
4545
func query(
4646
portalName: String,
4747
statement: Statement,
48-
rowDecoder: RowDecoder?
48+
metadata: [ColumnMetadata]?
4949
) async throws -> ResultCursor {
5050

5151
state = .querySent
@@ -56,7 +56,14 @@ extension Connection {
5656
let flushRequest = FlushRequest()
5757
try await sendRequest(flushRequest)
5858

59-
return ResultCursor(connection: self, portalName: portalName, rowDecoder: rowDecoder)
59+
// The first response of the query is evaluated eagerly to check its
60+
// type.
61+
// An zero-result query could immediately give a CommandCompleteResponse
62+
// This also prevents some issues with undrained message queues.
63+
// TODO: check error response
64+
let response = await receiveResponse()
65+
66+
return ResultCursor(connection: self, portalName: portalName, metadata: metadata, initialResponse: response)
6067
}
6168

6269
public func closeStatement(name: String) async throws {

Sources/SwiftPostgresClient/Connection+transaction.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ extension Connection {
3636
}
3737
}
3838

39-
func beginTransaction() async throws {
39+
public func beginTransaction() async throws {
4040
try await executeSimpleQuery("BEGIN;")
4141
}
4242

43-
func commitTransaction() async throws {
43+
public func commitTransaction() async throws {
4444
try await executeSimpleQuery("COMMIT;")
4545
}
4646

Sources/SwiftPostgresClient/Connection.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import Network
2222
import CryptoKit
2323

2424
/// A Connection actor provides asynchronous access to a PostgreSQL server.
25-
actor Connection {
25+
public actor Connection {
2626

2727
enum ConnectionState {
2828
case awaitingAuthentication
@@ -50,6 +50,7 @@ actor Connection {
5050
var transactionStatus: TransactionStatus = .idle
5151

5252
var commandStatus: CommandStatus?
53+
var peekedResponse: Response?
5354

5455
private init(socket: NetworkConnection, certificateHash: Data) {
5556
self.socket = socket
@@ -122,7 +123,6 @@ actor Connection {
122123
}
123124

124125
func updateState(for response: Response) {
125-
print("updating state for \(response)")
126126
switch response {
127127
case is DataRowResponse, is RowDescriptionResponse:
128128
state = .awaitingQueryResult
@@ -143,7 +143,6 @@ actor Connection {
143143
}
144144
}
145145

146-
147146
@discardableResult
148147
func receiveResponse<T: Response>(type: T.Type) async throws -> T {
149148
while true {
@@ -232,6 +231,7 @@ actor Connection {
232231
return
233232

234233
case .querySent, .awaitingQueryResult, .needsDrain:
234+
logWarning("State is \(state), emptying message queue and sending sync request.")
235235
// Cleanup all open portals
236236
for name in self.portalStatus.keys {
237237
try await sendRequest(ClosePortalRequest(name: name))
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Logger.swift
2+
// SwiftPostgresClient
3+
//
4+
// Created by Will Temperley on 18/07/2025. All rights reserved.
5+
// Copyright 2025 Will Temperley.
6+
//
7+
// Copying or reproduction of this file via any medium requires prior express
8+
// written permission from the copyright holder.
9+
// -----------------------------------------------------------------------------
10+
///
11+
/// Implementation notes, links and internal documentation go here.
12+
///
13+
// -----------------------------------------------------------------------------
14+
15+
16+
#if DEBUG
17+
#if canImport(os)
18+
import os
19+
private let logger = Logger(subsystem: "com.geolocalised.swiftpostgresclient", category: "debug")
20+
#endif
21+
#endif
22+
23+
public func logWarning(_ message: String) {
24+
#if DEBUG
25+
#if canImport(os)
26+
logger.warning("\(message, privacy: .public)")
27+
#else
28+
print("Warning: \(message)")
29+
#endif
30+
#endif
31+
}
32+

Sources/SwiftPostgresClient/Portal.swift

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,20 +28,20 @@ import Foundation
2828
public struct Portal {
2929

3030
let name: String
31-
let rowDecoder: RowDecoder?
31+
let metadata: [ColumnMetadata]?
3232
let statement: Statement
3333
unowned let connection: Connection
3434

3535
/// Executes the associated prepared statement with already bound parameters.
3636
///
3737
/// - Returns: an `AsyncSequence` of rows. This is a single use iterator.
38-
func query() async throws -> ResultCursor {
38+
public func query() async throws -> ResultCursor {
3939

40-
return try await connection.query(portalName: name, statement: statement, rowDecoder: rowDecoder)
40+
return try await connection.query(portalName: name, statement: statement, metadata: metadata)
4141
}
4242

4343
@discardableResult
44-
func execute() async throws -> CommandStatus {
44+
public func execute() async throws -> CommandStatus {
4545

4646
let executeRequest = ExecuteRequest(portalName: name, statement: statement)
4747
try await connection.sendRequest(executeRequest)
@@ -52,5 +52,21 @@ public struct Portal {
5252
try await connection.cleanupPortal(name: name)
5353
return response.status
5454
}
55-
}
55+
56+
/// Executes a query that returns a single value (e.g. COUNT, SUM, etc.).
57+
public func singleValue() async throws -> PostgresValue? {
58+
59+
let cursor = try await query()
60+
var iterator = cursor.makeAsyncIterator()
61+
let row = try await iterator.next()
62+
if let row {
63+
let next = try await iterator.next()
64+
if next != nil {
65+
try await connection.recoverIfNeeded()
66+
}
67+
return row.columns.first?.postgresValue
68+
}
69+
return nil
70+
}
5671

72+
}

Sources/SwiftPostgresClient/ResultCursor.swift

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,50 @@ public struct ResultCursor: AsyncSequence, Sendable {
2222

2323
let connection: Connection
2424
let portalName: String
25+
let metatdata: [ColumnMetadata]?
2526
let rowDecoder: RowDecoder?
27+
let initialResponse: Response?
28+
let commandStatus: CommandStatus?
2629

2730
/// Either the number of rows returned or the number of rows affected by the associated statement.
2831
/// This will not be available until after the result set has been consumed.
32+
/// The specific interpretation of this value depends on the SQL command performed:
33+
///
34+
/// - `INSERT`: the number of rows inserted
35+
/// - `UPDATE`: the number of rows updated
36+
/// - `DELETE`: the number of rows deleted
37+
/// - `SELECT` or `CREATE TABLE AS`: the number of rows retrieved
38+
/// - `MOVE`: the number of rows by which the SQL cursor's position changed
39+
/// - `FETCH`: the number of rows retrieved from the SQL cursor
40+
/// - `COPY`: the number of rows copied
41+
///
42+
/// If this `Cursor` has one or more rows, this property is `nil` until the final row has been
43+
/// retrieved (in other words, until `next()` returns `nil`).
2944
var rowCount: Int? {
3045
get async {
31-
await connection.commandStatus?.rowCount
46+
if let commandStatus {
47+
return commandStatus.rowCount
48+
} else {
49+
return await connection.commandStatus?.rowCount
50+
}
3251
}
3352
}
3453

35-
init(connection: Connection, portalName: String, rowDecoder: RowDecoder? = nil) {
54+
init(connection: Connection, portalName: String, metadata: [ColumnMetadata]? = nil, initialResponse: Response?) {
3655
self.connection = connection
3756
self.portalName = portalName
38-
self.rowDecoder = rowDecoder
57+
self.metatdata = metadata
58+
if let metadata = metadata {
59+
self.rowDecoder = RowDecoder(columns: metadata)
60+
} else {
61+
self.rowDecoder = nil
62+
}
63+
self.initialResponse = initialResponse
64+
if case let response as CommandCompleteResponse = initialResponse {
65+
self.commandStatus = response.status
66+
} else {
67+
self.commandStatus = nil
68+
}
3969
}
4070

4171
/// Creates the asynchronous iterator that produces elements of this
@@ -44,7 +74,7 @@ public struct ResultCursor: AsyncSequence, Sendable {
4474
/// - Returns: An instance of the `AsyncIterator` type used to produce
4575
/// messages in the asynchronous sequence.
4676
public func makeAsyncIterator() -> AsyncIterator {
47-
return AsyncIterator(connection: connection, portalName: portalName, rowDecoder: rowDecoder)
77+
return AsyncIterator(connection: connection, portalName: portalName, rowDecoder: rowDecoder, initialResponse: initialResponse)
4878
}
4979

5080
/// An asynchronous iterator that produces the rows of this asynchronous sequence.
@@ -54,26 +84,34 @@ public struct ResultCursor: AsyncSequence, Sendable {
5484
var connection: Connection
5585
var portalName: String
5686
var rowDecoder: RowDecoder?
87+
var initialResponse: Response?
5788

89+
mutating func nextResponse() async throws -> Response {
90+
if let nextResponse = initialResponse {
91+
self.initialResponse = nil
92+
return nextResponse
93+
} else {
94+
return await connection.receiveResponse()
95+
}
96+
}
97+
5898
mutating public func next() async throws -> Row? {
5999

60100
guard await connection.connected else {
61101
throw PostgresError.connectionClosed
62102
}
63103

64104
while true {
65-
let response = await connection.receiveResponse()
105+
let response = try await nextResponse()
66106

67107
switch response {
68108
case let dataRow as DataRowResponse:
69-
do {
70-
let row = Row(columns: dataRow.columns, columnNameRowDecoder: rowDecoder)
71-
return row
72-
} catch {
73-
throw error
74-
}
109+
110+
let row = Row(columns: dataRow.columns, columnNameRowDecoder: rowDecoder)
111+
return row
75112

76113
case is EmptyQueryResponse:
114+
77115
try await connection.setCommandStatus(to: .empty)
78116
try await connection.cleanupPortal(name: portalName)
79117
return nil

0 commit comments

Comments
 (0)