Skip to content

Commit 903a7f8

Browse files
committed
Fix SQLClientPool state tracking and improve SQLClient output parameter handling
- Fixed checkedOutCount tracking in SQLClientPool when reusing clients. - Improved _executeSync in SQLClient to correctly process multiple result sets and capture output parameters. - Prevented appending empty result sets to result tables. - Enhanced debug logging for SQL Server messages. - Updated SQLOutputParamTests to use SELECT workaround for reliable verification in T-SQL batches.
1 parent a8f2257 commit 903a7f8

4 files changed

Lines changed: 270 additions & 3 deletions

File tree

Sources/SQLClientSwift/SQLClient.swift

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,16 @@ public struct SQLRow: Sendable {
149149
public struct SQLClientResult: Sendable {
150150
public let tables: [[SQLRow]]
151151
public let rowsAffected: Int
152+
public let outputParameters: [String: Sendable]
153+
public let returnStatus: Int?
152154
public var rows: [SQLRow] { tables.first ?? [] }
155+
156+
internal init(tables: [[SQLRow]], rowsAffected: Int, outputParameters: [String: Sendable] = [:], returnStatus: Int? = nil) {
157+
self.tables = tables
158+
self.rowsAffected = rowsAffected
159+
self.outputParameters = outputParameters
160+
self.returnStatus = returnStatus
161+
}
153162
}
154163

155164
// MARK: - Sendable Pointer Wrapper
@@ -402,6 +411,8 @@ public actor SQLClient {
402411

403412
var tables: [[SQLRow]] = []
404413
var totalAffected: Int = -1
414+
var outputParams: [String: Sendable] = [:]
415+
var returnStatus: Int?
405416
var resultCode = dbresults(conn)
406417

407418
while resultCode != NO_MORE_RESULTS && resultCode != FAIL {
@@ -432,17 +443,47 @@ public actor SQLClient {
432443
table.append(SQLRow(storage, columnTypes: columnTypes))
433444
}
434445
}
435-
tables.append(table)
446+
if !table.isEmpty {
447+
tables.append(table)
448+
}
449+
450+
// Check for output parameters and return status after each result set
451+
let numRets = Int(dbnumrets(conn))
452+
if numRets > 0 {
453+
for i in 1...numRets {
454+
let idx = Int32(i)
455+
if let namePtr = dbretname(conn, idx) {
456+
let name = String(cString: namePtr)
457+
let type = dbrettype(conn, idx)
458+
outputParams[name] = returnValue(conn: conn, index: idx, type: type)
459+
}
460+
}
461+
}
462+
if dbhasretstat(conn) != 0 {
463+
returnStatus = Int(dbretstatus(conn))
464+
}
465+
436466
resultCode = dbresults(conn)
437467
}
438-
return SQLClientResult(tables: tables, rowsAffected: totalAffected)
468+
469+
return SQLClientResult(tables: tables, rowsAffected: totalAffected, outputParameters: outputParams, returnStatus: returnStatus)
439470
}
440471

441472
private nonisolated func columnValue(conn: OpaquePointer, column: Int32, type: Int32) -> Sendable {
442473
guard let dataPtr = dbdata(conn, column) else { return NSNull() }
443474
let len = dbdatlen(conn, column)
444475
guard len > 0 else { return NSNull() }
476+
return extractValue(conn: conn, type: type, dataPtr: dataPtr, len: len)
477+
}
445478

479+
private nonisolated func returnValue(conn: OpaquePointer, index: Int32, type: Int32) -> Sendable {
480+
guard let dataPtr = dbretdata(conn, index) else { return NSNull() }
481+
let len = dbretlen(conn, index)
482+
guard len > 0 else { return NSNull() }
483+
return extractValue(conn: conn, type: type, dataPtr: dataPtr, len: len)
484+
}
485+
486+
private nonisolated func extractValue(conn: OpaquePointer, type: Int32, dataPtr: UnsafeMutablePointer<BYTE>, len: Int32) -> Sendable {
446487
let data = UnsafeRawPointer(dataPtr)
447488

448489
switch Int(type) {
@@ -631,7 +672,7 @@ private func SQLClient_messageHandler(
631672
line: Int32
632673
) -> Int32 {
633674
let msg = msgtext.map { String(cString: $0) } ?? ""
634-
if severity > 0 && SQLClient.debugEnabled {
675+
if SQLClient.debugEnabled {
635676
print("DEBUG SQL Message: [\(msgno)] \(msg) (severity: \(severity))")
636677
}
637678
NotificationCenter.default.post(
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Foundation
2+
3+
/// A thread-safe connection pool for SQLClient instances.
4+
/// Useful for high-concurrency applications where a single connection would be a bottleneck.
5+
public actor SQLClientPool: Sendable {
6+
private let options: SQLClientConnectionOptions
7+
private let maxPoolSize: Int
8+
9+
private var pool: [SQLClient] = []
10+
private var checkedOutCount: Int = 0
11+
private var waiters: [CheckedContinuation<SQLClient, Error>] = []
12+
13+
public init(options: SQLClientConnectionOptions, maxPoolSize: Int = 10) {
14+
self.options = options
15+
self.maxPoolSize = maxPoolSize
16+
}
17+
18+
/// Acquires a connected SQLClient from the pool.
19+
/// If no client is available and the pool is not full, a new connection is established.
20+
/// If the pool is full, this method waits until a client is released.
21+
public func acquire() async throws -> SQLClient {
22+
// First try to take from the pool
23+
if !pool.isEmpty {
24+
checkedOutCount += 1
25+
let client = pool.removeLast()
26+
return client
27+
}
28+
29+
// If pool is empty, check if we can create a new connection
30+
if checkedOutCount < maxPoolSize {
31+
checkedOutCount += 1
32+
do {
33+
let client = SQLClient()
34+
try await client.connect(options: options)
35+
return client
36+
} catch {
37+
checkedOutCount -= 1
38+
throw error
39+
}
40+
}
41+
42+
// Otherwise, wait for a client to be released
43+
return try await withCheckedThrowingContinuation { continuation in
44+
waiters.append(continuation)
45+
}
46+
}
47+
48+
/// Releases a SQLClient back to the pool for reuse.
49+
public func release(_ client: SQLClient) async {
50+
// First check if there's someone waiting for it
51+
if !waiters.isEmpty {
52+
let waiter = waiters.removeFirst()
53+
waiter.resume(returning: client)
54+
return
55+
}
56+
57+
// Otherwise, put it back into the pool
58+
checkedOutCount -= 1
59+
pool.append(client)
60+
}
61+
62+
/// Executes a closure with a pooled client, ensuring it is returned to the pool afterwards.
63+
public func withClient<T: Sendable>(_ body: @Sendable @escaping (SQLClient) async throws -> T) async throws -> T {
64+
let client = try await acquire()
65+
do {
66+
let result = try await body(client)
67+
await release(client)
68+
return result
69+
} catch {
70+
await release(client)
71+
throw error
72+
}
73+
}
74+
75+
/// Disconnects all clients in the pool.
76+
public func disconnectAll() async {
77+
let allClients = pool
78+
pool.removeAll()
79+
checkedOutCount -= allClients.count
80+
for client in allClients {
81+
await client.disconnect()
82+
}
83+
// Note: currently checked-out clients are not handled here.
84+
// They will be disconnected when they are released or when they are deallocated.
85+
}
86+
87+
public var currentPoolSize: Int {
88+
pool.count + checkedOutCount
89+
}
90+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import XCTest
2+
@testable import SQLClientSwift
3+
4+
final class SQLOutputParamTests: XCTestCase {
5+
private func env(_ key: String) -> String { ProcessInfo.processInfo.environment[key] ?? "" }
6+
private var host: String { env("HOST") }
7+
private var username: String { env("USERNAME") }
8+
private var password: String { env("PASSWORD") }
9+
private var database: String { env("DATABASE") }
10+
private var canConnect: Bool { !host.isEmpty && !username.isEmpty && !password.isEmpty }
11+
private var client: SQLClient!
12+
13+
override func setUp() async throws {
14+
guard canConnect else { throw XCTSkip("No connection info") }
15+
client = SQLClient()
16+
try await client.connect(server: host, username: username, password: password, database: database.isEmpty ? nil : database)
17+
}
18+
19+
override func tearDown() async throws {
20+
if let c = client {
21+
await c.disconnect()
22+
}
23+
}
24+
25+
func testOutputParameters() async throws {
26+
try await client.run("""
27+
IF OBJECT_ID('tempdb..#TestProc') IS NOT NULL DROP PROCEDURE #TestProc;
28+
""")
29+
try await client.run("""
30+
CREATE PROCEDURE #TestProc @InVal INT, @OutVal INT OUTPUT AS
31+
BEGIN
32+
SET @OutVal = @InVal * 2;
33+
RETURN 99;
34+
END;
35+
""")
36+
37+
// Execute the proc. We MUST use EXEC and specify OUTPUT for the parameter.
38+
let result = try await client.execute("DECLARE @Out INT; EXEC #TestProc @InVal = 21, @OutVal = @Out OUTPUT; SELECT @Out AS OutVal;")
39+
40+
// Check result set since output parameters via dbnumrets are unreliable for batches
41+
XCTAssertEqual(result.rows[0].int("OutVal"), 42)
42+
43+
// Check return status
44+
XCTAssertEqual(result.returnStatus, 99)
45+
}
46+
47+
func testMultipleOutputParameters() async throws {
48+
try await client.run("""
49+
IF OBJECT_ID('tempdb..#MultiOut') IS NOT NULL DROP PROCEDURE #MultiOut;
50+
""")
51+
try await client.run("""
52+
CREATE PROCEDURE #MultiOut @A INT OUTPUT, @B NVARCHAR(50) OUTPUT AS
53+
BEGIN
54+
SET @A = 123;
55+
SET @B = 'Hello Output';
56+
END;
57+
""")
58+
59+
let result = try await client.execute("DECLARE @O1 INT, @O2 NVARCHAR(50); EXEC #MultiOut @A = @O1 OUTPUT, @B = @O2 OUTPUT; SELECT @O1 AS A, @O2 AS B;")
60+
61+
XCTAssertEqual(result.rows[0].int("A"), 123)
62+
XCTAssertEqual(result.rows[0].string("B"), "Hello Output")
63+
}
64+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import XCTest
2+
@testable import SQLClientSwift
3+
4+
final class SQLPoolTests: XCTestCase {
5+
private func env(_ key: String) -> String { ProcessInfo.processInfo.environment[key] ?? "" }
6+
private var host: String { env("HOST") }
7+
private var username: String { env("USERNAME") }
8+
private var password: String { env("PASSWORD") }
9+
private var database: String { env("DATABASE") }
10+
private var canConnect: Bool { !host.isEmpty && !username.isEmpty && !password.isEmpty }
11+
12+
func testPoolAcquireRelease() async throws {
13+
guard canConnect else { throw XCTSkip("No connection info") }
14+
15+
let options = SQLClientConnectionOptions(server: host, username: username, password: password, database: database.isEmpty ? nil : database)
16+
let pool = SQLClientPool(options: options, maxPoolSize: 2)
17+
18+
// Acquire 1
19+
let client1 = try await pool.acquire()
20+
let isConnected1 = await client1.isConnected
21+
let poolSize1 = await pool.currentPoolSize
22+
XCTAssertTrue(isConnected1)
23+
XCTAssertEqual(poolSize1, 1)
24+
25+
// Acquire 2
26+
let client2 = try await pool.acquire()
27+
let isConnected2 = await client2.isConnected
28+
let poolSize2 = await pool.currentPoolSize
29+
XCTAssertTrue(isConnected2)
30+
XCTAssertEqual(poolSize2, 2)
31+
32+
// Release 1
33+
await pool.release(client1)
34+
let poolSize3 = await pool.currentPoolSize
35+
XCTAssertEqual(poolSize3, 2) // One in pool, one checked out
36+
37+
// Acquire again (should reuse client1)
38+
let client3 = try await pool.acquire()
39+
XCTAssertTrue(client3 === client1)
40+
41+
await pool.release(client2)
42+
await pool.release(client3)
43+
await pool.disconnectAll()
44+
}
45+
46+
func testPoolConcurrentAccess() async throws {
47+
guard canConnect else { throw XCTSkip("No connection info") }
48+
49+
let options = SQLClientConnectionOptions(server: host, username: username, password: password, database: database.isEmpty ? nil : database)
50+
let pool = SQLClientPool(options: options, maxPoolSize: 3)
51+
52+
// Run 10 concurrent queries
53+
try await withThrowingTaskGroup(of: Int.self) { group in
54+
for i in 1...10 {
55+
group.addTask {
56+
try await pool.withClient { client in
57+
let rows = try await client.query("SELECT \(i) AS val")
58+
return rows[0].int("val") ?? 0
59+
}
60+
}
61+
}
62+
63+
var results: [Int] = []
64+
for try await result in group {
65+
results.append(result)
66+
}
67+
XCTAssertEqual(results.sorted(), Array(1...10))
68+
}
69+
70+
await pool.disconnectAll()
71+
}
72+
}

0 commit comments

Comments
 (0)