diff --git a/README.md b/README.md index fa4495e2..806af071 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,41 @@ try await client.query(""" While this looks at first glance like a classic case of [SQL injection](https://en.wikipedia.org/wiki/SQL_injection) 😱, PostgresNIO's API ensures that this usage is safe. The first parameter of the [`query(_:logger:)`] method is not a plain `String`, but a [`PostgresQuery`], which implements Swift's `ExpressibleByStringInterpolation` protocol. PostgresNIO uses the literal parts of the provided string as the SQL query and replaces each interpolated value with a parameter binding. Only values which implement the [`PostgresEncodable`] protocol may be interpolated in this way. As with [`PostgresDecodable`], PostgresNIO provides default implementations for most common types. +#### Manual query construction with PostgresBindings + +For more complex scenarios where you need to build queries dynamically, you can use `PostgresBindings` together with `PostgresQuery`: + +```swift +func buildSearchQuery(filters: [String: Any]) -> PostgresQuery { + var bindings = PostgresBindings() + var sql = "SELECT * FROM products WHERE 1=1" + + if let name = filters["name"] as? String { + bindings.append(name) + sql += " AND name = $\(bindings.count)" + } + + if let minPrice = filters["minPrice"] as? Double { + bindings.append(minPrice) + sql += " AND price >= $\(bindings.count)" + } + + if let category = filters["category"] as? String { + bindings.append(category) + sql += " AND category = $\(bindings.count)" + } + + return PostgresQuery(unsafeSQL: sql, binds: bindings) +} + +// Usage +let filters = ["name": "Widget", "minPrice": 9.99] +let query = buildSearchQuery(filters: filters) +let rows = try await client.query(query, logger: logger) +``` + +This approach is particularly useful when you need to conditionally add filters or build complex queries programmatically. + Some queries do not receive any rows from the server (most often `INSERT`, `UPDATE`, and `DELETE` queries with no `RETURNING` clause, not to mention most DDL queries). To support this, the [`query(_:logger:)`] method is marked `@discardableResult`, so that the compiler does not issue a warning if the return value is not used. ## Security diff --git a/Sources/PostgresNIO/Docs.docc/index.md b/Sources/PostgresNIO/Docs.docc/index.md index 6355a7a4..ae594072 100644 --- a/Sources/PostgresNIO/Docs.docc/index.md +++ b/Sources/PostgresNIO/Docs.docc/index.md @@ -26,9 +26,198 @@ applications. task cancellation. The query interface makes use of backpressure to ensure that memory can not grow unbounded for queries that return thousands of rows. -``PostgresNIO`` runs efficiently on Linux and Apple platforms. On Apple platforms developers can -configure ``PostgresConnection`` to use `Network.framework` as the underlying transport framework. - +``PostgresNIO`` runs efficiently on Linux and Apple platforms. On Apple platforms developers can +configure ``PostgresConnection`` to use `Network.framework` as the underlying transport framework. + +## Quick Start + +### 1. Create and Run a PostgresClient + +First, create a ``PostgresClient/Configuration`` and initialize your client: + +```swift +import PostgresNIO + +// Configure the client with individual parameters +let config = PostgresClient.Configuration( + host: "localhost", + port: 5432, + username: "my_username", + password: "my_password", + database: "my_database", + tls: .disable +) + +// Or parse from a PostgreSQL URL string +let urlString = "postgresql://username:password@localhost:5432/my_database" +let url = URL(string: urlString)! +let config = PostgresClient.Configuration( + host: url.host!, + port: url.port ?? 5432, + username: url.user!, + password: url.password, + database: url.path.trimmingPrefix("/"), + tls: .disable +) + +// Create the client +let client = PostgresClient(configuration: config) + +// Run the client (required) +await withTaskGroup(of: Void.self) { taskGroup in + taskGroup.addTask { + await client.run() + } + + // Your application code using the client goes here + + // Shutdown when done + taskGroup.cancelAll() +} +``` + +### 2. Running Queries with PostgresQuery + +Use string interpolation to safely execute queries with parameters: + +```swift +// Simple SELECT query +let minAge = 21 +let rows = try await client.query( + "SELECT * FROM users WHERE age > \(minAge)", + logger: logger +) + +for try await row in rows { + let randomAccessRow = row.makeRandomAccess() + let id: Int = try randomAccessRow.decode(column: "id", as: Int.self, context: .default) + let name: String = try randomAccessRow.decode(column: "name", as: String.self, context: .default) + print("User: \(name) (ID: \(id))") +} + +// INSERT query +let name = "Alice" +let email = "alice@example.com" +try await client.execute( + "INSERT INTO users (name, email) VALUES (\(name), \(email))", + logger: logger +) +``` + +### 3. Building Dynamic Queries with PostgresBindings + +For complex or dynamic queries, manually construct bindings: + +```swift +func buildSearchQuery(filters: [String: Any]) -> PostgresQuery { + var bindings = PostgresBindings() + var sql = "SELECT * FROM products WHERE 1=1" + + if let name = filters["name"] as? String { + bindings.append(name) + sql += " AND name = $\(bindings.count)" + } + + if let minPrice = filters["minPrice"] as? Double { + bindings.append(minPrice) + sql += " AND price >= $\(bindings.count)" + } + + return PostgresQuery(unsafeSQL: sql, binds: bindings) +} + +// Execute the dynamic query +let filters = ["name": "Widget", "minPrice": 9.99] +let query = buildSearchQuery(filters: filters) +let rows = try await client.query(query, logger: logger) +``` + +### 4. Using Transactions with withTransaction + +Execute multiple queries atomically: + +```swift +try await client.withTransaction { connection in + // All queries execute within a transaction + + // Debit from account + try await connection.execute( + "UPDATE accounts SET balance = balance - \(amount) WHERE id = \(fromAccount)", + logger: logger + ) + + // Credit to account + try await connection.execute( + "UPDATE accounts SET balance = balance + \(amount) WHERE id = \(toAccount)", + logger: logger + ) + + // If any query fails, the entire transaction rolls back + // If this closure completes successfully, the transaction commits +} +``` + +### 5. Using withConnection for Multiple Queries + +Execute multiple queries on the same connection for better performance: + +```swift +try await client.withConnection { connection in + let userRows = try await connection.query( + "SELECT * FROM users WHERE id = \(userID)", + logger: logger + ) + + let orderRows = try await connection.query( + "SELECT * FROM orders WHERE user_id = \(userID)", + logger: logger + ) + + // Process results... +} +``` + +For more details, see . + +### 6. Using Custom Types with PostgresCodable + +Many Swift types already work out of the box. For custom types, implement ``PostgresEncodable`` and ``PostgresDecodable``: + +```swift +// Store complex data as JSONB +struct UserProfile: Codable { + let displayName: String + let bio: String + let interests: [String] +} + +// Use directly in queries (encodes as JSONB automatically via Codable) +let profile = UserProfile( + displayName: "Alice", + bio: "Swift developer", + interests: ["coding", "hiking"] +) + +try await client.execute( + "UPDATE users SET profile = \(profile) WHERE id = \(userID)", + logger: logger +) + +// Decode from results +let rows = try await client.query( + "SELECT profile FROM users WHERE id = \(userID)", + logger: logger +) + +for try await row in rows { + let randomAccessRow = row.makeRandomAccess() + let profile = try randomAccessRow.decode(column: "profile", as: UserProfile.self, context: .default) + print("Display name: \(profile.displayName)") +} +``` + +For advanced usage including custom PostgreSQL types, binary encoding, and RawRepresentable enums, see . + ## Topics ### Essentials @@ -40,6 +229,7 @@ configure ``PostgresConnection`` to use `Network.framework` as the underlying tr ### Advanced +- - - - diff --git a/Sources/PostgresNIO/Docs.docc/postgres-codable.md b/Sources/PostgresNIO/Docs.docc/postgres-codable.md new file mode 100644 index 00000000..3b4c9329 --- /dev/null +++ b/Sources/PostgresNIO/Docs.docc/postgres-codable.md @@ -0,0 +1,442 @@ +# PostgresCodable + +Encode and decode custom Swift types to and from PostgreSQL wire format. + +## Overview + +``PostgresEncodable`` and ``PostgresDecodable`` (collectively known as ``PostgresCodable``) allow you to define how your custom Swift types are encoded to and decoded from the PostgreSQL wire format. This enables you to use your custom types directly in queries and decode them from query results. + +## Using Built-in Codable Types + +Many standard Swift and Foundation types already conform to ``PostgresCodable``: + +Note: Schema used in this example +```sql +-- Minimal table to match the example below +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + age INT NOT NULL, + active BOOLEAN NOT NULL, + created TIMESTAMPTZ NOT NULL +); +``` + +```swift +// Numeric types +let age: Int = 30 +let price: Double = 99.99 + +// Text types +let name: String = "Alice" + +// Other common types +let isActive: Bool = true +let id: UUID = UUID() +let timestamp: Date = Date() + +// Collections +let tags: [String] = ["swift", "postgres", "nio"] + +// Use them directly in queries +let rows = try await client.query( + "INSERT INTO users (name, age, active, id, created) VALUES (\(name), \(age), \(isActive), \(id), \(timestamp))", + logger: logger +) +``` + +## Using Codable Structs with JSONB + +For custom Swift structs that you want to store as JSONB in PostgreSQL, simply conform to `Codable`. PostgresNIO automatically handles the encoding and decoding: + +Note: Schema used in this example +```sql +-- Users table with a JSONB profile column +CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY, + profile JSONB NOT NULL +); +``` + +```swift +// Define a Codable struct +struct UserProfile: Codable { + let displayName: String + let bio: String + let interests: [String] +} + +// Insert into a JSONB column +let profile = UserProfile( + displayName: "Alice", + bio: "Swift developer", + interests: ["coding", "hiking"] +) + +try await client.query( + "INSERT INTO users (id, profile) VALUES (\(userID), \(profile))", + logger: logger +) + +// Retrieve from a JSONB column +let rows = try await client.query( + "SELECT profile FROM users WHERE id = \(userID)", + logger: logger +) + +for try await row in rows { + let randomAccessRow = row.makeRandomAccess() + let profile = try randomAccessRow.decode(column: "profile", as: UserProfile.self, context: .default) + print("Display name: \(profile.displayName)") +} +``` + +This works for any Swift type that conforms to `Codable`, including nested structs, enums, and arrays. No manual encoding or decoding implementation is needed! + +```swift +// Complex nested structure - just add Codable! +struct Address: Codable { + let street: String + let city: String + let zipCode: String +} + +struct Company: Codable { + let name: String + let founded: Date + let address: Address + let employees: Int +} + +// Works automatically with JSONB columns +let company = Company( + name: "Acme Inc", + founded: Date(), + address: Address(street: "123 Main St", city: "Springfield", zipCode: "12345"), + employees: 50 +) + +// Example schema for the `companies` table +// (a single JSONB column storing your Codable payload) +// +// CREATE TABLE IF NOT EXISTS companies ( +// id SERIAL PRIMARY KEY, +// data JSONB NOT NULL +// ); + +try await client.query( + "INSERT INTO companies (data) VALUES (\(company))", + logger: logger +) +``` + +## Implementing PostgresEncodable + +To make a custom type encodable to PostgreSQL, implement the ``PostgresEncodable`` protocol: + +Note: Schema used in this example +```sql +-- A table with a PostgreSQL point column +CREATE TABLE IF NOT EXISTS locations ( + name TEXT PRIMARY KEY, + coordinate POINT NOT NULL +); +``` + +```swift +import NIOCore + +struct Point: PostgresEncodable { + let x: Double + let y: Double + + // Specify the PostgreSQL data type + static var psqlType: PostgresDataType { .point } + + // Specify the encoding format (binary or text) + static var psqlFormat: PostgresFormat { .binary } + + // Encode the value into a ByteBuffer + func encode( + into buffer: inout ByteBuffer, + context: PostgresEncodingContext + ) throws { + // Encode as PostgreSQL point format: (x,y) + buffer.writeDouble(x) + buffer.writeDouble(y) + } +} + +// Use it in queries +let location = Point(x: 37.7749, y: -122.4194) +try await client.execute( + "INSERT INTO locations (name, coordinate) VALUES (\(locationName), \(location))", + logger: logger +) +``` + +## Implementing PostgresDecodable + +To decode a custom type from PostgreSQL results, implement the ``PostgresDecodable`` protocol: + +Note: Same `locations` schema as above is used. + +```swift +import NIOCore + +struct Point: PostgresDecodable { + let x: Double + let y: Double + + // Decode from a ByteBuffer + init( + from buffer: inout ByteBuffer, + type: PostgresDataType, + format: PostgresFormat, + context: PostgresDecodingContext + ) throws { + // Verify we're decoding the expected type + guard type == .point else { + throw PostgresDecodingError.Code.typeMismatch + } + + // Read the coordinates + guard let x = buffer.readDouble(), + let y = buffer.readDouble() else { + throw PostgresDecodingError.Code.missingData + } + + self.x = x + self.y = y + } +} + +// Decode from query results +let rows = try await client.query( + "SELECT coordinate FROM locations WHERE name = \(locationName)", + logger: logger +) + +for try await row in rows { + let randomAccessRow = row.makeRandomAccess() + let point = try randomAccessRow.decode(column: "coordinate", as: Point.self, context: .default) + print("Location: (\(point.x), \(point.y))") +} +``` + +## Advanced: Manual JSON Encoding and Decoding + +> Note: For most use cases, simply conforming your struct to `Codable` is sufficient (see ). Only implement manual encoding/decoding if you need fine-grained control over the JSON representation or need to handle both JSON and JSONB types differently. + +For advanced scenarios where you need manual control over JSON encoding: + +```swift +struct UserProfile: Codable, PostgresCodable { + let displayName: String + let bio: String + let interests: [String] + + // Encode as JSONB + static var psqlType: PostgresDataType { .jsonb } + static var psqlFormat: PostgresFormat { .binary } + + func encode( + into buffer: inout ByteBuffer, + context: PostgresEncodingContext + ) throws { + // JSONB format version byte + buffer.writeInteger(1, as: UInt8.self) + // Encode as JSON + let data = try context.jsonEncoder.encode(self) + buffer.writeBytes(data) + } + + init( + from buffer: inout ByteBuffer, + type: PostgresDataType, + format: PostgresFormat, + context: PostgresDecodingContext + ) throws { + guard type == .jsonb || type == .json else { + throw PostgresDecodingError.Code.typeMismatch + } + + var jsonBuffer = buffer + if type == .jsonb { + // Skip JSONB version byte + _ = jsonBuffer.readInteger(as: UInt8.self) + } + + guard let data = jsonBuffer.readData(length: jsonBuffer.readableBytes) else { + throw PostgresDecodingError.Code.missingData + } + + self = try context.jsonDecoder.decode(UserProfile.self, from: data) + } +} + +// Use with queries +let profile = UserProfile( + displayName: "Alice", + bio: "Swift developer", + interests: ["coding", "hiking"] +) + +// Example users table with a JSONB profile column +// +// CREATE TABLE IF NOT EXISTS users ( +// id INT PRIMARY KEY, +// profile JSONB NOT NULL +// ); + +try await client.execute( + "UPDATE users SET profile = \(profile) WHERE id = \(userID)", + logger: logger +) +``` + +## Custom JSON Encoding Context + +When you need custom JSON encoding/decoding behavior: + +```swift +// Create a custom encoder +let jsonEncoder = JSONEncoder() +jsonEncoder.dateEncodingStrategy = .iso8601 +jsonEncoder.keyEncodingStrategy = .convertToSnakeCase + +let context = PostgresEncodingContext(jsonEncoder: jsonEncoder) + +// Use with bindings +var bindings = PostgresBindings() +try bindings.append(profile, context: context) + +let query = PostgresQuery( + unsafeSQL: "INSERT INTO users (profile) VALUES ($1)", + binds: bindings +) +``` + +## RawRepresentable Types + +For enums with encodable raw values: + +Note: Schema used in this example +```sql +-- Add a status column to users as TEXT (enum stored as raw value) +ALTER TABLE users ADD COLUMN IF NOT EXISTS status TEXT; +``` + +```swift +enum UserStatus: String, PostgresCodable { + case active + case inactive + case suspended + + static var psqlType: PostgresDataType { .text } + static var psqlFormat: PostgresFormat { .binary } + + func encode( + into buffer: inout ByteBuffer, + context: PostgresEncodingContext + ) throws { + try rawValue.encode(into: &buffer, context: context) + } + + init( + from buffer: inout ByteBuffer, + type: PostgresDataType, + format: PostgresFormat, + context: PostgresDecodingContext + ) throws { + let rawValue = try String(from: &buffer, type: type, format: format, context: context) + guard let value = Self(rawValue: rawValue) else { + throw PostgresDecodingError.Code.failure + } + self = value + } +} + +// Use in queries +let status = UserStatus.active +try await client.execute( + "UPDATE users SET status = \(status) WHERE id = \(userID)", + logger: logger +) +``` + +## Decoding Rows with Multiple Columns + +Decode multiple values from a single row: + +Note: Schema used in this example +```sql +-- Users table used for multi-column decoding +CREATE TABLE IF NOT EXISTS users ( + id INT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + age INT NOT NULL +); +``` + +```swift +struct User: PostgresDecodable { + let id: Int + let name: String + let email: String + let createdAt: Date + + init( + from buffer: inout ByteBuffer, + type: PostgresDataType, + format: PostgresFormat, + context: PostgresDecodingContext + ) throws { + // This is for decoding a single complex value + // For rows with multiple columns, use the row decoding API below + fatalError("Use row decoding instead") + } +} + +// Decode from query results using the row API +let rows = try await client.query( + "SELECT id, name, email, created_at FROM users WHERE age > \(minAge)", + logger: logger +) + +for try await row in rows { + let randomAccessRow = row.makeRandomAccess() + let id: Int = try randomAccessRow.decode(column: "id", as: Int.self, context: .default) + let name: String = try randomAccessRow.decode(column: "name", as: String.self, context: .default) + let email: String = try randomAccessRow.decode(column: "email", as: String.self, context: .default) + let createdAt: Date = try randomAccessRow.decode(column: "created_at", as: Date.self, context: .default) + + let user = User(id: id, name: name, email: email, createdAt: createdAt) + print("User: \(user.name)") +} + +// Or use tuple decoding for convenience +for try await (id, name, email, createdAt) in rows.decode((Int, String, String, Date).self) { + print("User: \(name) (ID: \(id))") +} +``` + +## Topics + +### Protocols + +- ``PostgresEncodable`` +- ``PostgresDecodable`` +- ``PostgresCodable`` +- ``PostgresNonThrowingEncodable`` +- ``PostgresDynamicTypeEncodable`` +- ``PostgresThrowingDynamicTypeEncodable`` + +### Supporting Types + +- ``PostgresEncodingContext`` +- ``PostgresDecodingContext`` +- ``PostgresDataType`` +- ``PostgresFormat`` diff --git a/Sources/PostgresNIO/Docs.docc/prepared-statement.md b/Sources/PostgresNIO/Docs.docc/prepared-statement.md index ff4b1c62..6392b703 100644 --- a/Sources/PostgresNIO/Docs.docc/prepared-statement.md +++ b/Sources/PostgresNIO/Docs.docc/prepared-statement.md @@ -1,7 +1,136 @@ # Boosting Performance with Prepared Statements -Improve performance by leveraging PostgreSQL's prepared statements. +Prepared statements let PostgreSQL plan a query once and reuse it efficiently. In PostgresNIO, you model a prepared statement as a Swift type that conforms to ``PostgresPreparedStatement`` and execute it with ``PostgresClient/execute(_:logger:file:line:)`` or ``PostgresConnection/execute(_:logger:file:line:)``. + +## Define a Prepared Statement + +Create a type that provides the SQL, the bindings, and how to decode a row: + +```swift +import PostgresNIO + +/// Insert a user and return the generated id +struct InsertUser: PostgresPreparedStatement { + static let sql = """ + INSERT INTO users (name, age, active) + VALUES ($1, $2, $3) + RETURNING id + """ + typealias Row = Int + + var name: String + var age: Int + var active: Bool + + func makeBindings() throws -> PostgresBindings { + var b = PostgresBindings() + b.append(self.name) + b.append(self.age) + b.append(self.active) + return b + } + + func decodeRow(_ row: PostgresRow) throws -> Row { + // Single column: decode as Int + try row.makeRandomAccess().decode(column: 0, as: Int.self, context: .default) + } +} + +/// Load a single user by id +struct LoadUser: PostgresPreparedStatement { + static let sql = "SELECT id, name, age, active FROM users WHERE id = $1" + typealias Row = (Int, String, Int, Bool) + + var id: Int + + func makeBindings() throws -> PostgresBindings { + var b = PostgresBindings() + b.append(self.id) + return b + } + + func decodeRow(_ row: PostgresRow) throws -> Row { + try row.decode(Row.self) + } +} +``` + +## Use with `.query` + +You can freely mix prepared statements with regular queries created via ``PostgresQuery``. For example, create a table using ``PostgresClient/query(_:logger:)`` and then use your prepared statements: + +```swift +let logger = Logger(label: "example") + +// Create table with a regular query +try await client.query( + """ + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + age INT NOT NULL, + active BOOLEAN NOT NULL + ); + """, + logger: logger +) + +// Execute prepared INSERT and read the generated id +let insertRows = try await client.execute(InsertUser(name: "Alice", age: 30, active: true), logger: logger) +for try await newID in insertRows { + print("Inserted user with id: \(newID)") +} + +// Execute prepared SELECT +let selectRows = try await client.execute(LoadUser(id: 1), logger: logger) +for try await (id, name, age, active) in selectRows { + print("Loaded user #\(id): \(name), \(age), active? \(active)") +} +``` + +## Use with `.withTransaction` + +Run multiple prepared statements atomically using ``PostgresClient/withTransaction(logger:file:line:isolation:_:)``: + +```swift +try await client.withTransaction { connection in + // Insert a user and fetch the id + let ids = try await connection.execute( + InsertUser(name: "Bob", age: 42, active: false), + logger: logger + ) + + var newUserID: Int? + for try await id in ids { newUserID = id } + + // Mix in a regular query within the same transaction + if let id = newUserID { + try await connection.query( + "UPDATE users SET active = \(true) WHERE id = \(id)", + logger: logger + ) + } + + // Load and verify the user, still inside the transaction + let rows = try await connection.execute(LoadUser(id: newUserID!), logger: logger) + for try await (id, name, age, active) in rows { + print("Transaction saw: #\(id) \(name) active? \(active)") + } +} +``` + +If any call inside the closure throws, the transaction is rolled back. If the closure completes successfully, the transaction is committed. + +## Tips + +- Prefer prepared statements for frequently executed queries; it reduces parse/plan overhead on the server. +- Use tuple rows (e.g. `(Int, String)`) for ergonomic decoding, or create lightweight model initializers in `decodeRow(_:)`. +- You can omit ``PostgresPreparedStatement/bindingDataTypes`` for automatic inference in most cases. ## Topics - ``PostgresPreparedStatement`` +- ``PostgresClient/execute(_:logger:file:line:)`` +- ``PostgresConnection/execute(_:logger:file:line:)`` +- ``PostgresClient/query(_:logger:)`` +- ``PostgresClient/withTransaction(logger:file:line:isolation:_:)`` diff --git a/Sources/PostgresNIO/Docs.docc/running-queries.md b/Sources/PostgresNIO/Docs.docc/running-queries.md index b2c4586f..da3ddb66 100644 --- a/Sources/PostgresNIO/Docs.docc/running-queries.md +++ b/Sources/PostgresNIO/Docs.docc/running-queries.md @@ -4,13 +4,90 @@ Interact with the PostgreSQL database by running Queries. ## Overview +You interact with the Postgres database by running SQL queries using ``PostgresQuery``. PostgresNIO provides several methods for executing queries depending on your needs. +### Quick Start: Running Queries -You interact with the Postgres database by running SQL [Queries]. +#### Using query() for Simple Queries +The most common way to run a query is with the ``PostgresClient/query(_:logger:)`` method: +```swift +let rows = try await client.query("SELECT * FROM users WHERE age > \(minAge)", logger: logger) -``PostgresQuery`` conforms to +for try await row in rows { + let id: Int = try row.decode(column: "id", as: Int.self) + let name: String = try row.decode(column: "name", as: String.self) + print("User: \(name) (ID: \(id))") +} +``` + +#### Using execute() for Non-Returning Queries + +For queries that don't return rows (INSERT, UPDATE, DELETE without RETURNING), use ``PostgresConnection/execute(_:logger:file:line:)``: + +```swift +try await client.execute( + "UPDATE users SET last_login = \(Date()) WHERE id = \(userID)", + logger: logger +) +``` + +#### Using withConnection for Multiple Queries + +When you need to run multiple queries on the same connection: + +```swift +try await client.withConnection { connection in + // Execute multiple queries on the same connection + let userRows = try await connection.query( + "SELECT * FROM users WHERE id = \(userID)", + logger: logger + ) + + let orderRows = try await connection.query( + "SELECT * FROM orders WHERE user_id = \(userID)", + logger: logger + ) + + // Process results... +} +``` + +#### Using withTransaction for Atomic Operations + +When you need multiple queries to succeed or fail together: + +```swift +try await client.withTransaction { connection in + // All queries execute within a transaction + + // Debit from one account + try await connection.execute( + "UPDATE accounts SET balance = balance - \(amount) WHERE id = \(fromAccount)", + logger: logger + ) + + // Credit to another account + try await connection.execute( + "UPDATE accounts SET balance = balance + \(amount) WHERE id = \(toAccount)", + logger: logger + ) + + // If any query fails, the entire transaction rolls back + // If the closure completes successfully, the transaction commits +} +``` + +### String Interpolation and Safety + +``PostgresQuery`` conforms to [`ExpressibleByStringInterpolation`], allowing you to safely embed values in your SQL queries. Interpolated values are automatically converted to parameterized query bindings, preventing SQL injection: + +```swift +let username = "alice" +let query: PostgresQuery = "SELECT * FROM users WHERE username = \(username)" +// Generates: "SELECT * FROM users WHERE username = $1" with binding: ["alice"] +``` ## Topics diff --git a/Sources/PostgresNIO/New/PostgresCodable.swift b/Sources/PostgresNIO/New/PostgresCodable.swift index fd82c8ea..6bf18293 100644 --- a/Sources/PostgresNIO/New/PostgresCodable.swift +++ b/Sources/PostgresNIO/New/PostgresCodable.swift @@ -42,6 +42,102 @@ public protocol PostgresDynamicTypeEncodable: PostgresThrowingDynamicTypeEncodab } /// A type that can encode itself to a postgres wire binary representation. +/// +/// Conform your custom types to `PostgresEncodable` to enable them to be used as query parameters +/// with `PostgresQuery` and `PostgresBindings`. +/// +/// ## Conforming Built-in Types +/// +/// Many standard Swift types already conform to `PostgresEncodable`: +/// - Numeric types: `Int`, `Int8`, `Int16`, `Int32`, `Int64`, `UInt`, `UInt8`, `UInt16`, `UInt32`, `Float`, `Double` +/// - Text types: `String`, `Substring` +/// - Other types: `Bool`, `Date`, `UUID`, `Data` +/// - Collections: `Array` (where Element is encodable) +/// +/// ## Implementing PostgresEncodable +/// +/// To make a custom type encodable, implement the protocol requirements: +/// +/// ```swift +/// import NIOCore +/// +/// struct Point: PostgresEncodable { +/// let x: Double +/// let y: Double +/// +/// static var psqlType: PostgresDataType { .point } +/// static var psqlFormat: PostgresFormat { .binary } +/// +/// func encode( +/// into buffer: inout ByteBuffer, +/// context: PostgresEncodingContext +/// ) throws { +/// buffer.writeDouble(x) +/// buffer.writeDouble(y) +/// } +/// } +/// ``` +/// +/// ## Using Custom Encodable Types +/// +/// Once your type conforms to `PostgresEncodable`, use it in queries: +/// +/// ```swift +/// let point = Point(x: 10.5, y: 20.3) +/// let query: PostgresQuery = "INSERT INTO locations (coordinate) VALUES (\(point))" +/// ``` +/// +/// ## Encoding as JSON +/// +/// For complex types, you can encode them as JSONB: +/// +/// ```swift +/// struct User: Codable, PostgresEncodable { +/// let name: String +/// let email: String +/// let age: Int +/// +/// static var psqlType: PostgresDataType { .jsonb } +/// static var psqlFormat: PostgresFormat { .binary } +/// +/// func encode( +/// into buffer: inout ByteBuffer, +/// context: PostgresEncodingContext +/// ) throws { +/// // JSONB format version byte +/// buffer.writeInteger(1, as: UInt8.self) +/// // Encode as JSON using the provided encoder +/// let data = try context.jsonEncoder.encode(self) +/// buffer.writeBytes(data) +/// } +/// } +/// ``` +/// +/// ## RawRepresentable Types +/// +/// For enums backed by encodable raw values: +/// +/// ```swift +/// enum Status: String, PostgresEncodable { +/// case active +/// case inactive +/// case pending +/// +/// static var psqlType: PostgresDataType { .text } +/// static var psqlFormat: PostgresFormat { .binary } +/// +/// func encode( +/// into buffer: inout ByteBuffer, +/// context: PostgresEncodingContext +/// ) throws { +/// try rawValue.encode(into: &buffer, context: context) +/// } +/// } +/// ``` +/// +/// > Note: ``PostgresNonThrowingEncodable`` is a variant that doesn't throw, allowing usage without `try`. +/// +/// - SeeAlso: ``PostgresDecodable`` for decoding values from Postgres. public protocol PostgresEncodable: PostgresThrowingDynamicTypeEncodable { // TODO: Rename to `PostgresThrowingEncodable` with next major release @@ -62,7 +158,129 @@ public protocol PostgresNonThrowingEncodable: PostgresEncodable, PostgresDynamic /// A type that can decode itself from a postgres wire binary representation. /// -/// If you want to conform a type to PostgresDecodable you must implement the decode method. +/// Conform your custom types to ``PostgresDecodable`` to enable them to be decoded from query results. +/// +/// ## Conforming Built-in Types +/// +/// Many standard Swift types already conform to ``PostgresDecodable``: +/// - Numeric types: `Int`, `Int8`, `Int16`, `Int32`, `Int64`, `UInt`, `UInt8`, `UInt16`, `UInt32`, `Float`, `Double` +/// - Text types: `String`, `Substring` +/// - Other types: `Bool`, `Date`, `UUID`, `Data` +/// - Collections: `Array` (where Element is decodable) +/// +/// ## Implementing PostgresDecodable +/// +/// To make a custom type decodable, implement the required initializer: +/// +/// ```swift +/// import NIOCore +/// +/// struct Point: PostgresDecodable { +/// let x: Double +/// let y: Double +/// +/// init( +/// from buffer: inout ByteBuffer, +/// type: PostgresDataType, +/// format: PostgresFormat, +/// context: PostgresDecodingContext +/// ) throws { +/// guard type == .point else { +/// throw PostgresDecodingError.Code.typeMismatch +/// } +/// guard let x = buffer.readDouble(), let y = buffer.readDouble() else { +/// throw PostgresDecodingError.Code.missingData +/// } +/// self.x = x +/// self.y = y +/// } +/// } +/// ``` +/// +/// ## Using Custom Decodable Types +/// +/// Once your type conforms to `PostgresDecodable`, decode it from query results: +/// +/// ```swift +/// let rows = try await connection.query("SELECT coordinate FROM locations", logger: logger) +/// for try await row in rows { +/// let point = try row.decode(Point.self, context: .default) +/// print("Point: (\(point.x), \(point.y))") +/// } +/// ``` +/// +/// ## Decoding from JSON +/// +/// For complex types stored as JSON or JSONB: +/// +/// ```swift +/// struct User: Codable, PostgresDecodable { +/// let name: String +/// let email: String +/// let age: Int +/// +/// init( +/// from buffer: inout ByteBuffer, +/// type: PostgresDataType, +/// format: PostgresFormat, +/// context: PostgresDecodingContext +/// ) throws { +/// guard type == .jsonb || type == .json else { +/// throw PostgresDecodingError.Code.typeMismatch +/// } +/// +/// var jsonBuffer = buffer +/// if type == .jsonb { +/// // Skip JSONB version byte +/// _ = jsonBuffer.readInteger(as: UInt8.self) +/// } +/// +/// guard let data = jsonBuffer.readData(length: jsonBuffer.readableBytes) else { +/// throw PostgresDecodingError.Code.missingData +/// } +/// +/// self = try context.jsonDecoder.decode(User.self, from: data) +/// } +/// } +/// ``` +/// +/// ## Decoding RawRepresentable Types +/// +/// For enums backed by decodable raw values: +/// +/// ```swift +/// enum Status: String, PostgresDecodable { +/// case active +/// case inactive +/// case pending +/// +/// init( +/// from buffer: inout ByteBuffer, +/// type: PostgresDataType, +/// format: PostgresFormat, +/// context: PostgresDecodingContext +/// ) throws { +/// let rawValue = try String(from: &buffer, type: type, format: format, context: context) +/// guard let value = Self(rawValue: rawValue) else { +/// throw PostgresDecodingError.Code.failure +/// } +/// self = value +/// } +/// } +/// ``` +/// +/// ## Handling Optional Values +/// +/// Optional values are automatically handled by the protocol: +/// +/// ```swift +/// let email: String? = try row.decode(String?.self, context: .default) +/// // Returns nil if the database value is NULL +/// ``` +/// +/// > Note: The ``_DecodableType`` associated type is an implementation detail for Optional handling. +/// +/// - SeeAlso: ``PostgresEncodable`` for encoding values to Postgres. public protocol PostgresDecodable { /// A type definition of the type that actually implements the PostgresDecodable protocol. This is an escape hatch to /// prevent a cycle in the conformace of the Optional type to PostgresDecodable. diff --git a/Sources/PostgresNIO/New/PostgresQuery.swift b/Sources/PostgresNIO/New/PostgresQuery.swift index 6449ab29..00beffbe 100644 --- a/Sources/PostgresNIO/New/PostgresQuery.swift +++ b/Sources/PostgresNIO/New/PostgresQuery.swift @@ -1,6 +1,195 @@ import NIOCore /// A Postgres SQL query, that can be executed on a Postgres server. Contains the raw sql string and bindings. +/// +/// `PostgresQuery` supports safe string interpolation to automatically bind parameters and prevent SQL injection. +/// +/// ## Basic Usage +/// +/// Create a query using string interpolation with automatic parameter binding: +/// +/// ```swift +/// let userID = 42 +/// let query: PostgresQuery = "SELECT * FROM users WHERE id = \(userID)" +/// // Generates: "SELECT * FROM users WHERE id = $1" with bindings: [42] +/// ``` +/// +/// ## String Interpolation with Various Types +/// +/// String interpolation works with any type conforming to `PostgresEncodable`: +/// +/// ```swift +/// let name = "Alice" +/// let age = 30 +/// let isActive = true +/// let query: PostgresQuery = """ +/// INSERT INTO users (name, age, active) +/// VALUES (\(name), \(age), \(isActive)) +/// """ +/// ``` +/// +/// ## Optional Values +/// +/// Optional values are automatically handled and encoded as NULL when nil: +/// +/// ```swift +/// let email: String? = nil +/// let query: PostgresQuery = "UPDATE users SET email = \(email) WHERE id = \(userID)" +/// // email will be encoded as NULL in the database +/// ``` +/// +/// ## Unsafe Raw SQL +/// +/// For dynamic table/column names or SQL keywords, use `unescaped` interpolation (use with caution): +/// +/// ```swift +/// let tableName = "users" +/// let columnName = "created_at" +/// let query: PostgresQuery = "SELECT * FROM \(unescaped: tableName) ORDER BY \(unescaped: columnName) DESC" +/// ``` +/// +/// ## Manual Construction with PostgresBindings +/// +/// You can also create queries manually using `PostgresBindings` without string interpolation. +/// This is useful when building dynamic queries programmatically: +/// +/// ```swift +/// var bindings = PostgresBindings() +/// bindings.append("Alice") +/// bindings.append(30) +/// let query = PostgresQuery(unsafeSQL: "INSERT INTO users (name, age) VALUES ($1, $2)", binds: bindings) +/// ``` +/// +/// ## Building Dynamic Queries +/// +/// For complex scenarios where you need to build queries dynamically: +/// +/// ```swift +/// func buildSearchQuery(filters: [String: Any]) -> PostgresQuery { +/// var bindings = PostgresBindings() +/// var sql = "SELECT * FROM products WHERE 1=1" +/// +/// if let name = filters["name"] as? String { +/// bindings.append(name) +/// sql += " AND name = $\(bindings.count)" +/// } +/// +/// if let minPrice = filters["minPrice"] as? Double { +/// bindings.append(minPrice) +/// sql += " AND price >= $\(bindings.count)" +/// } +/// +/// if let category = filters["category"] as? String { +/// bindings.append(category) +/// sql += " AND category = $\(bindings.count)" +/// } +/// +/// return PostgresQuery(unsafeSQL: sql, binds: bindings) +/// } +/// +/// let filters = ["name": "Widget", "minPrice": 9.99] +/// let query = buildSearchQuery(filters: filters) +/// // Generates: "SELECT * FROM products WHERE 1=1 AND name = $1 AND price >= $2" +/// // With bindings: ["Widget", 9.99] +/// ``` +/// +/// ## Executing Queries +/// +/// Once you've created a query, execute it using various methods on `PostgresClient`: +/// +/// ### Basic Query Execution +/// +/// Execute a query and iterate over results: +/// +/// ```swift +/// let client = PostgresClient(configuration: config) +/// let query: PostgresQuery = "SELECT * FROM users WHERE age > \(minAge)" +/// +/// let rows = try await client.query(query, logger: logger) +/// for try await row in rows { +/// let randomAccessRow = row.makeRandomAccess() +/// let id: Int = try randomAccessRow["id"].decode(Int.self, context: .default) +/// let name: String = try randomAccessRow["name"].decode(String.self, context: .default) +/// print("User: \(name) (ID: \(id))") +/// } +/// ``` +/// +/// ### Using withConnection +/// +/// Execute multiple queries on the same connection: +/// +/// ```swift +/// try await client.withConnection { connection in +/// // First query +/// let userID = 42 +/// let userRows = try await connection.query( +/// "SELECT * FROM users WHERE id = \(userID)", +/// logger: logger +/// ) +/// +/// // Second query on the same connection +/// let orderRows = try await connection.query( +/// "SELECT * FROM orders WHERE user_id = \(userID)", +/// logger: logger +/// ) +/// +/// // Process results... +/// } +/// ``` +/// +/// ### Using withTransaction +/// +/// Execute queries within a transaction for atomicity: +/// +/// ```swift +/// try await client.withTransaction { connection in +/// // All queries execute in a transaction +/// let fromAccount = "account123" +/// let toAccount = "account456" +/// let amount = 100.0 +/// +/// // Debit from account +/// try await connection.query( +/// "UPDATE accounts SET balance = balance - \(amount) WHERE id = \(fromAccount)", +/// logger: logger +/// ) +/// +/// // Credit to account +/// try await connection.query( +/// "UPDATE accounts SET balance = balance + \(amount) WHERE id = \(toAccount)", +/// logger: logger +/// ) +/// +/// // If any query fails or throws, the entire transaction is rolled back +/// // If this closure completes successfully, the transaction is committed +/// } +/// ``` +/// +/// ### Insert and Return Generated IDs +/// +/// Insert data and retrieve auto-generated values: +/// +/// ```swift +/// let name = "Alice" +/// let email = "alice@example.com" +/// let rows = try await client.query( +/// "INSERT INTO users (name, email) VALUES (\(name), \(email)) RETURNING id", +/// logger: logger +/// ) +/// +/// for try await row in rows { +/// let randomAccessRow = row.makeRandomAccess() +/// let newID: Int = try randomAccessRow["id"].decode(Int.self, context: .default) +/// print("Created user with ID: \(newID)") +/// } +/// ``` +/// +/// > Note: String interpolation is the recommended approach for simple queries as it automatically handles parameter counting and binding. +/// +/// > Warning: Always use parameter binding for user input. Never concatenate user input directly into SQL strings. +/// +/// - SeeAlso: ``PostgresBindings`` for more details on manual binding construction. +/// - SeeAlso: ``PostgresClient`` for connection pool management and query execution. public struct PostgresQuery: Sendable, Hashable { /// The query string public var sql: String @@ -118,6 +307,182 @@ struct PSQLExecuteStatement { var rowDescription: RowDescription? } +/// A collection of parameter bindings for a Postgres query. +/// +/// `PostgresBindings` manages the parameters that are safely bound to a SQL query, preventing SQL injection +/// and handling type conversions to the Postgres wire format. +/// +/// ## Basic Usage +/// +/// Typically, you don't need to create `PostgresBindings` directly when using `PostgresQuery` with string interpolation. +/// However, you can manually construct bindings when needed: +/// +/// ```swift +/// var bindings = PostgresBindings() +/// bindings.append("Alice") +/// bindings.append(30) +/// bindings.append(true) +/// // bindings now contains 3 parameters +/// ``` +/// +/// ## Appending Different Types +/// +/// `PostgresBindings` can store any type conforming to `PostgresEncodable`: +/// +/// ```swift +/// var bindings = PostgresBindings() +/// bindings.append("John Doe") // String +/// bindings.append(42) // Int +/// bindings.append(3.14) // Double +/// bindings.append(Date()) // Date +/// bindings.append(true) // Bool +/// bindings.append([1, 2, 3]) // Array +/// ``` +/// +/// ## Handling Optional Values +/// +/// Optional values can be appended and will be encoded as NULL when nil: +/// +/// ```swift +/// var bindings = PostgresBindings() +/// let email: String? = nil +/// bindings.append(email) // Encodes as NULL +/// +/// let name: String? = "Alice" +/// bindings.append(name) // Encodes as "Alice" +/// ``` +/// +/// ## Manual NULL Values +/// +/// You can explicitly append NULL values: +/// +/// ```swift +/// var bindings = PostgresBindings() +/// bindings.appendNull() +/// ``` +/// +/// ## Using with Custom Encoding Context +/// +/// For custom JSON encoding, use a custom encoding context: +/// +/// ```swift +/// var bindings = PostgresBindings() +/// let jsonEncoder = JSONEncoder() +/// jsonEncoder.dateEncodingStrategy = .iso8601 +/// let context = PostgresEncodingContext(jsonEncoder: jsonEncoder) +/// +/// struct User: Codable { +/// let name: String +/// let age: Int +/// } +/// let user = User(name: "Alice", age: 30) +/// try bindings.append(user, context: context) +/// ``` +/// +/// ## Pre-allocating Capacity +/// +/// For better performance with known parameter counts: +/// +/// ```swift +/// var bindings = PostgresBindings(capacity: 10) // Pre-allocate space for 10 bindings +/// ``` +/// +/// ## Using with PostgresQuery +/// +/// Combine `PostgresBindings` with `PostgresQuery` for manual query construction. +/// This is particularly useful when building dynamic queries: +/// +/// ```swift +/// func buildSearchQuery(filters: [String: Any]) -> PostgresQuery { +/// var bindings = PostgresBindings() +/// var sql = "SELECT * FROM products WHERE 1=1" +/// +/// if let name = filters["name"] as? String { +/// bindings.append(name) +/// sql += " AND name = $\(bindings.count)" +/// } +/// +/// if let minPrice = filters["minPrice"] as? Double { +/// bindings.append(minPrice) +/// sql += " AND price >= $\(bindings.count)" +/// } +/// +/// if let category = filters["category"] as? String { +/// bindings.append(category) +/// sql += " AND category = $\(bindings.count)" +/// } +/// +/// return PostgresQuery(unsafeSQL: sql, binds: bindings) +/// } +/// +/// // Usage +/// let filters = ["name": "Widget", "minPrice": 9.99] +/// let query = buildSearchQuery(filters: filters) +/// let rows = try await client.query(query, logger: logger) +/// ``` +/// +/// ## Using with withConnection +/// +/// Execute multiple dynamically-built queries on the same connection: +/// +/// ```swift +/// try await client.withConnection { connection in +/// // Build and execute first query +/// var bindings1 = PostgresBindings() +/// bindings1.append(userID) +/// let query1 = PostgresQuery( +/// unsafeSQL: "SELECT * FROM users WHERE id = $\(bindings1.count)", +/// binds: bindings1 +/// ) +/// let userRows = try await connection.query(query1, logger: logger) +/// +/// // Build and execute second query on same connection +/// var bindings2 = PostgresBindings() +/// bindings2.append(userID) +/// bindings2.append(startDate) +/// let query2 = PostgresQuery( +/// unsafeSQL: "SELECT * FROM orders WHERE user_id = $1 AND created_at >= $2", +/// binds: bindings2 +/// ) +/// let orderRows = try await connection.query(query2, logger: logger) +/// } +/// ``` +/// +/// ## Using with withTransaction +/// +/// Build and execute transactional queries with manual bindings: +/// +/// ```swift +/// try await client.withTransaction { connection in +/// // Debit query +/// var debitBindings = PostgresBindings() +/// debitBindings.append(amount) +/// debitBindings.append(fromAccountID) +/// let debitQuery = PostgresQuery( +/// unsafeSQL: "UPDATE accounts SET balance = balance - $1 WHERE id = $2", +/// binds: debitBindings +/// ) +/// try await connection.query(debitQuery, logger: logger) +/// +/// // Credit query +/// var creditBindings = PostgresBindings() +/// creditBindings.append(amount) +/// creditBindings.append(toAccountID) +/// let creditQuery = PostgresQuery( +/// unsafeSQL: "UPDATE accounts SET balance = balance + $1 WHERE id = $2", +/// binds: creditBindings +/// ) +/// try await connection.query(creditQuery, logger: logger) +/// +/// // Both queries commit together, or roll back on error +/// } +/// ``` +/// +/// > Note: Bindings are indexed starting from 1 in SQL (e.g., $1, $2, $3). +/// +/// > Note: The ``count`` property returns the number of bindings currently stored. +/// +/// - SeeAlso: ``PostgresQuery`` for creating complete queries with bindings. public struct PostgresBindings: Sendable, Hashable { @usableFromInline struct Metadata: Sendable, Hashable { diff --git a/Tests/IntegrationTests/PostgresClientTests.swift b/Tests/IntegrationTests/PostgresClientTests.swift index 9ac92754..e5bbb25c 100644 --- a/Tests/IntegrationTests/PostgresClientTests.swift +++ b/Tests/IntegrationTests/PostgresClientTests.swift @@ -348,25 +348,3 @@ final class PostgresClientTests: XCTestCase { } } - -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -extension PostgresClient.Configuration { - static func makeTestConfiguration() -> PostgresClient.Configuration { - var tlsConfiguration = TLSConfiguration.makeClientConfiguration() - tlsConfiguration.certificateVerification = .none - var clientConfig = PostgresClient.Configuration( - host: env("POSTGRES_HOSTNAME") ?? "localhost", - port: env("POSTGRES_PORT").flatMap({ Int($0) }) ?? 5432, - username: env("POSTGRES_USER") ?? "test_username", - password: env("POSTGRES_PASSWORD") ?? "test_password", - database: env("POSTGRES_DB") ?? "test_database", - tls: .prefer(tlsConfiguration) - ) - clientConfig.options.minimumConnections = 0 - clientConfig.options.maximumConnections = 12*4 - clientConfig.options.keepAliveBehavior = .init(frequency: .seconds(5)) - clientConfig.options.connectionIdleTimeout = .seconds(15) - - return clientConfig - } -} diff --git a/Tests/IntegrationTests/PostgresCodableTests.swift b/Tests/IntegrationTests/PostgresCodableTests.swift new file mode 100644 index 00000000..72ad34e3 --- /dev/null +++ b/Tests/IntegrationTests/PostgresCodableTests.swift @@ -0,0 +1,298 @@ +import Logging +import NIOPosix +import NIOSSL +@_spi(ConnectionPool) import PostgresNIO +import XCTest + +/// Tests for PostgresCodable protocol and JSONB encoding/decoding examples from documentation +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +final class PostgresCodableTests: XCTestCase { + + func testStructWithPrimitivesMapping() async throws { + // Test a struct with various primitive types that maps directly to a database row + // + // PostgresNIO doesn't support decoding multi-column rows directly to custom structs + // (i.e., rows.decode(Car.self)) without implementing complex PostgresDecodable protocol. + // + // Instead, use tuple decoding which PostgresNIO fully supports for all primitive types. + struct Car: PostgresCodable, Codable { + let id: Int + let make: String + let model: String + let year: Int + let price: Double + let isElectric: Bool + let registeredAt: Date + let vin: UUID + } + + let tableName = "test_cars" + + var mlogger = Logger(label: "test") + mlogger.logLevel = .debug + let logger = mlogger + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + self.addTeardownBlock { + try await eventLoopGroup.shutdownGracefully() + } + + let clientConfig = PostgresClient.Configuration.makeTestConfiguration() + let client = PostgresClient( + configuration: clientConfig, eventLoopGroup: eventLoopGroup, backgroundLogger: logger) + + try await withThrowingTaskGroup(of: Void.self) { taskGroup in + taskGroup.addTask { + await client.run() + } + + // Create table with columns matching Car struct fields + try await client.query( + """ + CREATE TABLE IF NOT EXISTS "\(unescaped: tableName)" ( + id SERIAL PRIMARY KEY, + make TEXT NOT NULL, + model TEXT NOT NULL, + year INT NOT NULL, + price DOUBLE PRECISION NOT NULL, + is_electric BOOLEAN NOT NULL, + registered_at TIMESTAMPTZ NOT NULL, + vin UUID NOT NULL + ); + """, + logger: logger + ) + + // Insert and immediately decode using RETURNING - cleaner than separate INSERT + SELECT! + let registeredDate = Date() + let vin = UUID() + let rows = try await client.query( + """ + INSERT INTO "\(unescaped: tableName)" + (make, model, year, price, is_electric, registered_at, vin) + VALUES (\("Tesla"), \("Model 3"), \(2024), \(45000.0), \(true), \(registeredDate), \(vin)) + RETURNING id, make, model, year, price, is_electric, registered_at, vin + """, + logger: logger + ) + + + // Decode using tuple and then construct our custom struct. + // PostgresNIO supports tuple decoding for multi-column rows out of the box. + for try await (id, make, model, year, price, isElectric, registeredAt, vinValue) in rows.decode((Int, String, String, Int, Double, Bool, Date, UUID).self) { + let car = Car( + id: id, + make: make, + model: model, + year: year, + price: price, + isElectric: isElectric, + registeredAt: registeredAt, + vin: vinValue + ) + print("Car: \(car.year) \(car.make) \(car.model)") + // Verify all fields decoded correctly + XCTAssertEqual(car.make, "Tesla") + XCTAssertEqual(car.model, "Model 3") + XCTAssertEqual(car.year, 2024) + XCTAssertEqual(car.price, 45000.0) + XCTAssertEqual(car.isElectric, true) + XCTAssertEqual(car.vin, vin) + XCTAssertNotNil(car.registeredAt) + } + + try await client.query( + """ + DROP TABLE "\(unescaped: tableName)"; + """, + logger: logger + ) + + taskGroup.cancelAll() + } + } + + func testJSONBCodableRoundTrip() async throws { + // Test the example from our documentation + struct UserProfile: Codable, PostgresCodable, Equatable { + let displayName: String + let bio: String + let interests: [String] + } + + let tableName = "test_user_profiles" + + var mlogger = Logger(label: "test") + mlogger.logLevel = .debug + let logger = mlogger + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + self.addTeardownBlock { + try await eventLoopGroup.shutdownGracefully() + } + + let clientConfig = PostgresClient.Configuration.makeTestConfiguration() + let client = PostgresClient( + configuration: clientConfig, eventLoopGroup: eventLoopGroup, backgroundLogger: logger) + + try await withThrowingTaskGroup(of: Void.self) { taskGroup in + taskGroup.addTask { + await client.run() + } + + // Create table + try await client.query( + """ + CREATE TABLE IF NOT EXISTS "\(unescaped: tableName)" ( + id SERIAL PRIMARY KEY, + profile JSONB NOT NULL + ); + """, + logger: logger + ) + + // Insert with Codable struct (from documentation example) + let userID = 1 + let profile = UserProfile( + displayName: "Alice", + bio: "Swift developer", + interests: ["coding", "hiking"] + ) + + try await client.query( + """ + INSERT INTO \(unescaped: tableName) (id, profile) VALUES (\(userID), \(profile)) + """, + logger: logger + ) + + // Decode from results (from documentation example) + let rows = try await client.query( + """ + SELECT profile FROM "\(unescaped: tableName)" WHERE id = \(userID) + """, + logger: logger + ) + + var decodedProfile: UserProfile? + for try await row in rows { + let randomAccessRow = row.makeRandomAccess() + decodedProfile = try randomAccessRow["profile"].decode(UserProfile.self, context: .default) + print("Display name: \(decodedProfile!.displayName)") + } + + // Verify the round-trip + XCTAssertEqual(decodedProfile, profile) + XCTAssertEqual(decodedProfile?.displayName, "Alice") + XCTAssertEqual(decodedProfile?.bio, "Swift developer") + XCTAssertEqual(decodedProfile?.interests, ["coding", "hiking"]) + + // Clean up + try await client.query( + """ + DROP TABLE "\(unescaped: tableName)"; + """, + logger: logger + ) + + taskGroup.cancelAll() + } + } + + func testNestedCodableStructsWithJSONB() async throws { + // Test the nested Codable structs example from our documentation + // This verifies that complex nested structures work automatically with JSONB + struct Address: Codable, Equatable { + let street: String + let city: String + let zipCode: String + } + + struct Company: Codable, PostgresCodable, Equatable { + let name: String + let founded: Date + let address: Address + let employees: Int + } + + let tableName = "test_companies" + + var mlogger = Logger(label: "test") + mlogger.logLevel = .debug + let logger = mlogger + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + self.addTeardownBlock { + try await eventLoopGroup.shutdownGracefully() + } + + let clientConfig = PostgresClient.Configuration.makeTestConfiguration() + let client = PostgresClient( + configuration: clientConfig, eventLoopGroup: eventLoopGroup, backgroundLogger: logger) + + try await withThrowingTaskGroup(of: Void.self) { taskGroup in + taskGroup.addTask { + await client.run() + } + + // Create table with JSONB column + try await client.query( + """ + CREATE TABLE IF NOT EXISTS "\(unescaped: tableName)" ( + id SERIAL PRIMARY KEY, + data JSONB NOT NULL + ); + """, + logger: logger + ) + + // Insert nested Codable struct (from documentation example) + let foundedDate = Date() + let company = Company( + name: "Acme Inc", + founded: foundedDate, + address: Address(street: "123 Main St", city: "Springfield", zipCode: "12345"), + employees: 50 + ) + + try await client.query( + """ + INSERT INTO "\(unescaped: tableName)" (data) VALUES (\(company)) + """, + logger: logger + ) + + // Retrieve and decode the nested structure + let rows = try await client.query( + """ + SELECT data FROM "\(unescaped: tableName)" + """, + logger: logger + ) + + var decodedCompany: Company? + for try await row in rows { + let randomAccessRow = row.makeRandomAccess() + decodedCompany = try randomAccessRow["data"].decode(Company.self, context: .default) + print("Company: \(decodedCompany!.name)") + } + + // Verify the round-trip of the nested structure + XCTAssertEqual(decodedCompany?.name, "Acme Inc") + XCTAssertEqual(decodedCompany?.employees, 50) + XCTAssertEqual(decodedCompany?.address.street, "123 Main St") + XCTAssertEqual(decodedCompany?.address.city, "Springfield") + XCTAssertEqual(decodedCompany?.address.zipCode, "12345") + // Note: Date comparison may have slight precision differences, so we check it exists + XCTAssertNotNil(decodedCompany?.founded) + + // Clean up + try await client.query( + """ + DROP TABLE "\(unescaped: tableName)"; + """, + logger: logger + ) + + taskGroup.cancelAll() + } + } + +} diff --git a/Tests/IntegrationTests/Utilities.swift b/Tests/IntegrationTests/Utilities.swift index 91dbb62e..628c4368 100644 --- a/Tests/IntegrationTests/Utilities.swift +++ b/Tests/IntegrationTests/Utilities.swift @@ -76,8 +76,28 @@ func env(_ name: String) -> String? { getenv(name).flatMap { String(cString: $0) } } +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +extension PostgresClient.Configuration { + static func makeTestConfiguration() -> PostgresClient.Configuration { + var clientConfig = PostgresClient.Configuration( + host: env("POSTGRES_HOSTNAME") ?? "localhost", + port: env("POSTGRES_PORT").flatMap({ Int($0) }) ?? 5432, + username: env("POSTGRES_USER") ?? "test_username", + password: env("POSTGRES_PASSWORD") ?? "test_password", + database: env("POSTGRES_DB") ?? "test_database", + tls: .disable + ) + clientConfig.options.minimumConnections = 0 + clientConfig.options.maximumConnections = 12*4 + clientConfig.options.keepAliveBehavior = .init(frequency: .seconds(5)) + clientConfig.options.connectionIdleTimeout = .seconds(15) + + return clientConfig + } +} + extension XCTestCase { - + public static var shouldRunLongRunningTests: Bool { // The env var must be set and have the value `"true"`, `"1"`, or `"yes"` (case-insensitive). // For the sake of sheer annoying pedantry, values like `"2"` are treated as false.