Add parameterized execute for postgres (#122)

Support variadic execute with $1, $2, ... placeholders for safe parameter
binding. This allows calling PostgreSQL functions without defining custom
types.

Example:
  conn->execute("SELECT my_func($1, $2)", arg1, arg2);

Supported types: string, numeric, bool, optional, nullptr/nullopt for NULL.
This commit is contained in:
Marco Craveiro
2026-02-08 00:57:01 +00:00
committed by GitHub
parent a414397705
commit 5768fb9aa2
6 changed files with 281 additions and 6 deletions
+61 -6
View File
@@ -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:
@@ -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
@@ -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.
@@ -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.
@@ -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
@@ -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
View File
@@ -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 {
@@ -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);
+8
View File
@@ -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()));
+28
View File
@@ -1,4 +1,5 @@
#include "sqlgen/postgres/PostgresV2Connection.hpp"
#include "sqlgen/postgres/PostgresV2Result.hpp"
namespace sqlgen::postgres {
@@ -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
View File
@@ -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