A PostgreSQL driver looks like a really great thing to have and I think what you propose looks great!
I think supporting clients that aren't NIO apps without forcing them to import NIO is a good idea. Initially I was planning on making quite a short post here but now it's actually a bit long, apologies...
I thought about how an API could look that supports both NIO and non-NIO users fully without compromising on one of them. Below there's an outline on how a Postgres library could look, taking @tanner0101's API as the basis.
There's a fundamental difference between NIO users and non-NIO users. NIO users will know what EventLoop/EventLoopGroup they want to run their connections on to achieve best performance. Non-NIO users on the other hand shouldn't need to know what an EventLoop(Group) even is. To rectify this fundamental difference I'd propose to have an enum Executor where a user can choose where they want to run the network IO on.
So in the example below, NIO users would choose .eventLoopGroup(myAlreadyExistingNIOEventLoopGroup) and all others can just do .spawnThreads. To have a place to store the executor I'd use a class called Postgres which would also hold the connect method.
(disclaimer: all code here has never been compiled so there will be mistakes here, this is just meant as an example)
public enum Executor {
case spawnThreads
case eventLoopGroup(EventLoopGroup)
}
public class Postgres {
private let executor: Executor
private let group: EventLoopGroup
private let running = Atomic<Bool>(value: true)
public init(executor: Executor) {
switch executor {
case .spawnThreads:
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
case .eventLoopGroup(let group):
self.group = group
}
}
public static func connect(host: String,
port: Int) -> EventLoopFuture<FooConnection> {
/* actual implementation */
}
public func terminate() throws {
guard self.running.exchange(value: false) else {
return
}
switch executor {
case .spawnThreads:
try self.group.syncShutdownGracefully()
case .eventLoopGroup:
()
}
}
deinit {
assert(!self.running.load(), "Postgres not terminated")
}
}
So what's this odd terminate method doing there? There might be resources (like the threads spawned) attached to a Postgres object so we need to have a place where we free those resources up again. As soon as we have an SSWG logging library this might also be a place to store a logger.
This Postgres object shouldn't be a burden at all, just initialise it in main.swift (and terminate it in a defer block right after). It's really just a handle to the library itself which will also work well with dependency injection mechanisms.
For now though, this library isn't usable without using NIO's futures but that's really easy to fix and should IMHO also be part of the library:
public extension Postgres { /* queue + callback support */
public static func connect(host: String,
port: Int,
queue: DispatchQueue,
_ body: @escaping (Result<FooConnection, Error>) -> Void) {
self.connect(host: host, port: port).then { value in
queue.async {
body(.success(value))
}
}.whenFailure { error in
queue.async {
body(.error(error))
}
}
}
}
I know that's quite a bit of boilerplate for the library author to write...
Next, we obviously need some useful operations:
public class PostgresConnection {
func simpleQuery(_ query: String) -> EventLoopFuture<[PostgresRow]> { ... }
/* more operations */
}
public extension PostgresConnection { /* queue + callback support */
public static func simpleQuery(_ query: String,
queue: DispatchQueue,
_ body: @escaping (Result<FooConnection, Error>) -> Void) {
self.simpleQuery(query).then { value in
queue.async {
body(.success(value))
}
}.whenFailure { error in
queue.async {
body(.error(error))
}
}
}
}
If we give the library this shape, we can use it in the NIO way:
import NIO
let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
let pg = Postgres(executor: .eventLoopGroup(group)
defer {
try! pg.terminate()
}
func giveMeSomeRows() -> EventLoopFuture<[PostgresRow]> {
let result = pg.connect(host: "localhost", port: 5432).then { conn in
conn.simpleQuery("SELECT * FROM foo;")
}
result.whenComplete
conn.close()
}
return result
}
do {
let rows = try giveMeSomeRows().wait()
print("OK: \(rows)")
} catch {
print("ERROR: \(error)")
}
but also without NIO in the usual Swift way by having completion blocks. So the example from above without any NIOisms would look like the following (assuming Result lands):
import Dispatch
let pg = Postgres(executor: .spawnThreads)
defer {
try! pg.terminate()
}
func giveMeSomeRows(queue: DispatchQueue, _ body: Result<[PostgresRow], Error>) {
pg.connect(host: "localhost", port: 5432, queue: .global) { maybeConnection in
switch maybeConnection {
case .success(let conn):
conn.simpleQuery("SELECT * FROM foo;", queue: .global) { maybeRows in
queue.async {
body(maybeRows)
}
conn.close() // technically close can probably also return an error but let's ignore that here...
}
}
case .error(let error):
queue.async {
body(maybeRows)
}
}
}
let dispatchGroup = DispatchGroup()
giveMeSomeRows { maybeRows in
switch maybeRows {
case .success(let rows):
print("OK: \(rows)")
case .failure(let error
print("ERROR: \(error)")
}
dispatchGroup.signal()
}
dispatchGroup.wait()
or in case Result does not land (so everything would take the unfortunate @escaping (Type?, Error?) -> Void closures):
import Dispatch
let pg = Postgres(executor: .spawnThreads)
defer {
try! pg.terminate()
}
func giveMeSomeRows(queue: DispatchQueue, _ body: Result<[PostgresRow], Error>) {
pg.connect(host: "localhost", port: 5432, queue: .global) { (conn, error) in
if let conn = conn {
conn.simpleQuery("SELECT * FROM foo;", queue: .global) { (rows, error) in
queue.async {
body(rows, error)
}
conn.close() // technically close can probably also return an error but let's ignore that here...
}
}
} else {
queue.async {
body(error!)
}
}
}
let dispatchGroup = DispatchGroup()
giveMeSomeRows { (rows, error) in
if rows = rows {
print("OK: \(rows)")
} else {
print("ERROR: \(error!)")
}
dispatchGroup.signal()
}
dispatchGroup.wait()
Please let me know what you think.