Skip to content
Merged
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
67 changes: 61 additions & 6 deletions docs/postgres.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

The `sqlgen::postgres` module provides a type-safe and efficient interface for interacting with PostgreSQL databases. It implements the core database operations through a connection-based API with support for prepared statements, transactions, and efficient data iteration.

## Usage
## Basic Usage

### Basic Connection
This section describes the key aspects needed in order to use the module.

### Connection

Create a connection to a PostgreSQL database using credentials:

Expand Down Expand Up @@ -42,7 +44,7 @@ const auto query = sqlgen::read<std::vector<Person>> |
const auto minors = query(conn);
```

## Notes
### Notes

- The module provides a type-safe interface for PostgreSQL operations
- All operations return `sqlgen::Result<T>` for error handling
Expand All @@ -60,6 +62,10 @@ const auto minors = query(conn);
- Customizable connection parameters (host, port, database name, etc.)
- LISTEN/NOTIFY for real-time event notifications

# Features

This section describes more advanced aspects of the `sqlgen::postgres` module, which may not be necessary for a typical user.

## LISTEN/NOTIFY

PostgreSQL provides a simple publish-subscribe mechanism through `LISTEN` and `NOTIFY` commands. This allows database clients to receive real-time notifications when events occur, without polling. Any client can send a notification to a channel, and all clients listening on that channel will receive it asynchronously.
Expand Down Expand Up @@ -148,7 +154,6 @@ if (!result) {
// Handle error...
}
```

## Notice Processor

PostgreSQL functions can emit NOTICE messages using `RAISE NOTICE` in PL/pgSQL. By default, libpq prints these to stderr. sqlgen allows you to capture these messages by providing a custom notice handler in the connection credentials.
Expand Down Expand Up @@ -213,8 +218,7 @@ const auto creds = sqlgen::postgres::Credentials{

auto pool = sqlgen::make_connection_pool<sqlgen::postgres::Connection>(
sqlgen::ConnectionPoolConfig{.size = 4},
creds
);
creds);
```

### Notes
Expand All @@ -223,3 +227,54 @@ auto pool = sqlgen::make_connection_pool<sqlgen::postgres::Connection>(
- The handler receives the full message including any trailing newline
- The handler should be thread-safe when used with connection pools, as multiple connections may invoke it concurrently

## Parameterized Queries

The `execute` method supports parameterized queries using PostgreSQL's `$1, $2, ...` placeholder syntax. This prevents SQL injection and allows safe execution of dynamic queries without needing to define custom types.

*Note*: using parameterized queries in this manner is highly discouraged within `sqlgen`, and should be used only as a last resort. You should consider first using the type-safe API. However, there are cases where this is useful such as when calling stored procedures that do not return results.

### Basic Usage

```cpp
auto conn = sqlgen::postgres::connect(creds);
if (!conn) {
// Handle error...
return;
}

// Call a stored function with parameters
auto result = (*conn)->execute(
"SELECT provision_tenant($1, $2)",
tenant_id,
user_email
);
```

### Supported Parameter Types

The following types are automatically converted to SQL parameters:

- `std::string` - passed as-is
- `const char*` / `char*` - converted to string (nullptr becomes NULL)
- Numeric types (`int`, `long`, `double`, etc.) - converted via `std::to_string`
- `bool` - converted to `"true"` or `"false"`
- `std::optional<T>` - value or NULL if `std::nullopt`
- `std::nullopt` / `nullptr` - NULL value

### Handling NULL Values

Use `std::optional` or `std::nullopt` to pass NULL values:

```cpp
std::optional<std::string> maybe_value = std::nullopt;
auto result = (*conn)->execute(
"INSERT INTO data (nullable_field) VALUES ($1)",
maybe_value
);
```

### Notes

- Parameters are sent in text format and type inference is handled by PostgreSQL
- This feature uses `PQexecParams` internally for safe parameter binding
- The original `execute(sql)` overload without parameters remains available
39 changes: 39 additions & 0 deletions include/sqlgen/postgres/Connection.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,45 @@ class SQLGEN_API Connection {

Result<Nothing> execute(const std::string& _sql) noexcept;

template <class... Args>
Result<Nothing> execute(const std::string& _sql, Args&&... _args) noexcept {
return execute_params(_sql, {to_param(std::forward<Args>(_args))...});
}

private:
template <class T>
static std::optional<std::string> to_param(const T& _val) {
if constexpr (std::is_same_v<std::decay_t<T>, std::nullopt_t>) {
return std::nullopt;
} else if constexpr (std::is_same_v<std::decay_t<T>, std::nullptr_t>) {
return std::nullopt;
} else if constexpr (std::is_same_v<std::decay_t<T>, std::string>) {
return _val;
} else if constexpr (std::is_same_v<std::decay_t<T>, const char*> ||
std::is_same_v<std::decay_t<T>, char*>) {
return _val ? std::optional<std::string>(_val) : std::nullopt;
} else if constexpr (std::is_same_v<std::decay_t<T>, bool>) {
return _val ? "true" : "false";
} else if constexpr (std::is_arithmetic_v<std::decay_t<T>>) {
return std::to_string(_val);
} else {
static_assert(std::is_convertible_v<T, std::string>,
"Parameter type must be convertible to string");
return std::string(_val);
}
}

template <class T>
static std::optional<std::string> to_param(const std::optional<T>& _val) {
return _val ? to_param(*_val) : std::nullopt;
}

Result<Nothing> execute_params(
const std::string& _sql,
const std::vector<std::optional<std::string>>& _params) noexcept;

public:

template <class ItBegin, class ItEnd>
Result<Nothing> insert(const dynamic::Insert& _stmt, ItBegin _begin,
ItEnd _end) noexcept {
Expand Down
4 changes: 4 additions & 0 deletions include/sqlgen/postgres/PostgresV2Result.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class SQLGEN_API PostgresV2Result {
static rfl::Result<PostgresV2Result> make(
const std::string& _query, const PostgresV2Connection& _conn) noexcept;

static rfl::Result<PostgresV2Result> make(
const std::string& _query, const PostgresV2Connection& _conn,
const std::vector<std::optional<std::string>>& _params) noexcept;

static rfl::Result<PostgresV2Result> make(PGresult* _ptr) noexcept {
try {
return PostgresV2Result(_ptr);
Expand Down
8 changes: 8 additions & 0 deletions src/sqlgen/postgres/Connection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ Result<Nothing> Connection::execute(const std::string& _sql) noexcept {
});
}

Result<Nothing> Connection::execute_params(
const std::string& _sql,
const std::vector<std::optional<std::string>>& _params) noexcept {
return PostgresV2Result::make(_sql, conn_, _params).transform([](auto&&) {
return Nothing{};
});
}

Result<Nothing> Connection::end_write() {
if (PQputCopyEnd(conn_.ptr(), NULL) == -1) {
return error(PQerrorMessage(conn_.ptr()));
Expand Down
28 changes: 28 additions & 0 deletions src/sqlgen/postgres/PostgresV2Result.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "sqlgen/postgres/PostgresV2Connection.hpp"
#include "sqlgen/postgres/PostgresV2Result.hpp"

namespace sqlgen::postgres {

Expand All @@ -16,4 +17,31 @@ rfl::Result<PostgresV2Result> PostgresV2Result::make(
return PostgresV2Result(res);
}

rfl::Result<PostgresV2Result> PostgresV2Result::make(
const std::string& _query, const PostgresV2Connection& _conn,
const std::vector<std::optional<std::string>>& _params) noexcept {
std::vector<const char*> param_values(_params.size());
for (size_t i = 0; i < _params.size(); ++i) {
param_values[i] = _params[i] ? _params[i]->c_str() : nullptr;
}

auto res = PQexecParams(_conn.ptr(), _query.c_str(),
static_cast<int>(_params.size()),
nullptr, // paramTypes (let server infer)
param_values.data(), // paramValues
nullptr, // paramLengths (text format)
nullptr, // paramFormats (text format)
0); // resultFormat (text)

const auto status = PQresultStatus(res);
if (status != PGRES_COMMAND_OK && status != PGRES_TUPLES_OK &&
status != PGRES_COPY_IN) {
const auto msg =
std::string("Query execution failed: ") + PQerrorMessage(_conn.ptr());
PQclear(res);
return error(msg);
}
return PostgresV2Result(res);
}

} // namespace sqlgen::postgres
141 changes: 141 additions & 0 deletions tests/postgres/test_execute_params.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#ifndef SQLGEN_BUILD_DRY_TESTS_ONLY

#include <gtest/gtest.h>

#include <optional>
#include <sqlgen/postgres.hpp>
#include <string>

namespace test_execute_params {

TEST(postgres, execute_with_string_params) {
const auto credentials = sqlgen::postgres::Credentials{
.user = "postgres",
.password = "password",
.host = "localhost",
.dbname = "postgres"};
auto conn_result = sqlgen::postgres::connect(credentials);
ASSERT_TRUE(conn_result);
auto conn = conn_result.value();

// Create a test table
auto create_result = conn->execute(R"(
CREATE TABLE IF NOT EXISTS test_execute_params (
id SERIAL PRIMARY KEY,
name TEXT,
value INTEGER
);
)");
ASSERT_TRUE(create_result) << create_result.error().what();

// Clean up any existing data
auto truncate_result = conn->execute("TRUNCATE test_execute_params;");
ASSERT_TRUE(truncate_result) << truncate_result.error().what();

// Insert using parameterized execute
auto insert_result = conn->execute(
"INSERT INTO test_execute_params (name, value) VALUES ($1, $2);",
std::string("test_name"), 42);
ASSERT_TRUE(insert_result) << insert_result.error().what();

// Clean up
auto drop_result = conn->execute("DROP TABLE test_execute_params;");
ASSERT_TRUE(drop_result) << drop_result.error().what();
}

TEST(postgres, execute_with_null_param) {
const auto credentials = sqlgen::postgres::Credentials{
.user = "postgres",
.password = "password",
.host = "localhost",
.dbname = "postgres"};
auto conn_result = sqlgen::postgres::connect(credentials);
ASSERT_TRUE(conn_result);
auto conn = conn_result.value();

// Create a test table
auto create_result = conn->execute(R"(
CREATE TABLE IF NOT EXISTS test_execute_null (
id SERIAL PRIMARY KEY,
name TEXT
);
)");
ASSERT_TRUE(create_result) << create_result.error().what();

// Insert with null parameter using std::optional
std::optional<std::string> null_val = std::nullopt;
auto insert_result = conn->execute(
"INSERT INTO test_execute_null (name) VALUES ($1);", null_val);
ASSERT_TRUE(insert_result) << insert_result.error().what();

// Clean up
auto drop_result = conn->execute("DROP TABLE test_execute_null;");
ASSERT_TRUE(drop_result) << drop_result.error().what();
}

TEST(postgres, execute_with_numeric_params) {
const auto credentials = sqlgen::postgres::Credentials{
.user = "postgres",
.password = "password",
.host = "localhost",
.dbname = "postgres"};
auto conn_result = sqlgen::postgres::connect(credentials);
ASSERT_TRUE(conn_result);
auto conn = conn_result.value();

// Create a test table
auto create_result = conn->execute(R"(
CREATE TABLE IF NOT EXISTS test_execute_numeric (
id SERIAL PRIMARY KEY,
int_val INTEGER,
float_val DOUBLE PRECISION,
bool_val BOOLEAN
);
)");
ASSERT_TRUE(create_result) << create_result.error().what();

// Insert with various numeric types
auto insert_result = conn->execute(
"INSERT INTO test_execute_numeric (int_val, float_val, bool_val) "
"VALUES ($1, $2, $3);",
123, 3.14159, true);
ASSERT_TRUE(insert_result) << insert_result.error().what();

// Clean up
auto drop_result = conn->execute("DROP TABLE test_execute_numeric;");
ASSERT_TRUE(drop_result) << drop_result.error().what();
}

TEST(postgres, execute_call_function) {
const auto credentials = sqlgen::postgres::Credentials{
.user = "postgres",
.password = "password",
.host = "localhost",
.dbname = "postgres"};
auto conn_result = sqlgen::postgres::connect(credentials);
ASSERT_TRUE(conn_result);
auto conn = conn_result.value();

// Create a simple test function
auto create_fn_result = conn->execute(R"(
CREATE OR REPLACE FUNCTION test_add(a INTEGER, b INTEGER)
RETURNS INTEGER AS $$
BEGIN
RETURN a + b;
END;
$$ LANGUAGE plpgsql;
)");
ASSERT_TRUE(create_fn_result) << create_fn_result.error().what();

// Call the function with parameters
auto call_result = conn->execute("SELECT test_add($1, $2);", 5, 3);
ASSERT_TRUE(call_result) << call_result.error().what();

// Clean up
auto drop_fn_result = conn->execute("DROP FUNCTION test_add;");
ASSERT_TRUE(drop_fn_result) << drop_fn_result.error().what();
}

} // namespace test_execute_params

#endif
Loading