Skip to content

Commit 5768fb9

Browse files
authored
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.
1 parent a414397 commit 5768fb9

6 files changed

Lines changed: 281 additions & 6 deletions

File tree

docs/postgres.md

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
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.
44

5-
## Usage
5+
## Basic Usage
66

7-
### Basic Connection
7+
This section describes the key aspects needed in order to use the module.
8+
9+
### Connection
810

911
Create a connection to a PostgreSQL database using credentials:
1012

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

45-
## Notes
47+
### Notes
4648

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

65+
# Features
66+
67+
This section describes more advanced aspects of the `sqlgen::postgres` module, which may not be necessary for a typical user.
68+
6369
## LISTEN/NOTIFY
6470

6571
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) {
148154
// Handle error...
149155
}
150156
```
151-
152157
## Notice Processor
153158
154159
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{
213218

214219
auto pool = sqlgen::make_connection_pool<sqlgen::postgres::Connection>(
215220
sqlgen::ConnectionPoolConfig{.size = 4},
216-
creds
217-
);
221+
creds);
218222
```
219223
220224
### Notes
@@ -223,3 +227,54 @@ auto pool = sqlgen::make_connection_pool<sqlgen::postgres::Connection>(
223227
- The handler receives the full message including any trailing newline
224228
- The handler should be thread-safe when used with connection pools, as multiple connections may invoke it concurrently
225229
230+
## Parameterized Queries
231+
232+
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.
233+
234+
*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.
235+
236+
### Basic Usage
237+
238+
```cpp
239+
auto conn = sqlgen::postgres::connect(creds);
240+
if (!conn) {
241+
// Handle error...
242+
return;
243+
}
244+
245+
// Call a stored function with parameters
246+
auto result = (*conn)->execute(
247+
"SELECT provision_tenant($1, $2)",
248+
tenant_id,
249+
user_email
250+
);
251+
```
252+
253+
### Supported Parameter Types
254+
255+
The following types are automatically converted to SQL parameters:
256+
257+
- `std::string` - passed as-is
258+
- `const char*` / `char*` - converted to string (nullptr becomes NULL)
259+
- Numeric types (`int`, `long`, `double`, etc.) - converted via `std::to_string`
260+
- `bool` - converted to `"true"` or `"false"`
261+
- `std::optional<T>` - value or NULL if `std::nullopt`
262+
- `std::nullopt` / `nullptr` - NULL value
263+
264+
### Handling NULL Values
265+
266+
Use `std::optional` or `std::nullopt` to pass NULL values:
267+
268+
```cpp
269+
std::optional<std::string> maybe_value = std::nullopt;
270+
auto result = (*conn)->execute(
271+
"INSERT INTO data (nullable_field) VALUES ($1)",
272+
maybe_value
273+
);
274+
```
275+
276+
### Notes
277+
278+
- Parameters are sent in text format and type inference is handled by PostgreSQL
279+
- This feature uses `PQexecParams` internally for safe parameter binding
280+
- The original `execute(sql)` overload without parameters remains available

include/sqlgen/postgres/Connection.hpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,45 @@ class SQLGEN_API Connection {
6767

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

70+
template <class... Args>
71+
Result<Nothing> execute(const std::string& _sql, Args&&... _args) noexcept {
72+
return execute_params(_sql, {to_param(std::forward<Args>(_args))...});
73+
}
74+
75+
private:
76+
template <class T>
77+
static std::optional<std::string> to_param(const T& _val) {
78+
if constexpr (std::is_same_v<std::decay_t<T>, std::nullopt_t>) {
79+
return std::nullopt;
80+
} else if constexpr (std::is_same_v<std::decay_t<T>, std::nullptr_t>) {
81+
return std::nullopt;
82+
} else if constexpr (std::is_same_v<std::decay_t<T>, std::string>) {
83+
return _val;
84+
} else if constexpr (std::is_same_v<std::decay_t<T>, const char*> ||
85+
std::is_same_v<std::decay_t<T>, char*>) {
86+
return _val ? std::optional<std::string>(_val) : std::nullopt;
87+
} else if constexpr (std::is_same_v<std::decay_t<T>, bool>) {
88+
return _val ? "true" : "false";
89+
} else if constexpr (std::is_arithmetic_v<std::decay_t<T>>) {
90+
return std::to_string(_val);
91+
} else {
92+
static_assert(std::is_convertible_v<T, std::string>,
93+
"Parameter type must be convertible to string");
94+
return std::string(_val);
95+
}
96+
}
97+
98+
template <class T>
99+
static std::optional<std::string> to_param(const std::optional<T>& _val) {
100+
return _val ? to_param(*_val) : std::nullopt;
101+
}
102+
103+
Result<Nothing> execute_params(
104+
const std::string& _sql,
105+
const std::vector<std::optional<std::string>>& _params) noexcept;
106+
107+
public:
108+
70109
template <class ItBegin, class ItEnd>
71110
Result<Nothing> insert(const dynamic::Insert& _stmt, ItBegin _begin,
72111
ItEnd _end) noexcept {

include/sqlgen/postgres/PostgresV2Result.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ class SQLGEN_API PostgresV2Result {
2525
static rfl::Result<PostgresV2Result> make(
2626
const std::string& _query, const PostgresV2Connection& _conn) noexcept;
2727

28+
static rfl::Result<PostgresV2Result> make(
29+
const std::string& _query, const PostgresV2Connection& _conn,
30+
const std::vector<std::optional<std::string>>& _params) noexcept;
31+
2832
static rfl::Result<PostgresV2Result> make(PGresult* _ptr) noexcept {
2933
try {
3034
return PostgresV2Result(_ptr);

src/sqlgen/postgres/Connection.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ Result<Nothing> Connection::execute(const std::string& _sql) noexcept {
3232
});
3333
}
3434

35+
Result<Nothing> Connection::execute_params(
36+
const std::string& _sql,
37+
const std::vector<std::optional<std::string>>& _params) noexcept {
38+
return PostgresV2Result::make(_sql, conn_, _params).transform([](auto&&) {
39+
return Nothing{};
40+
});
41+
}
42+
3543
Result<Nothing> Connection::end_write() {
3644
if (PQputCopyEnd(conn_.ptr(), NULL) == -1) {
3745
return error(PQerrorMessage(conn_.ptr()));

src/sqlgen/postgres/PostgresV2Result.cpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include "sqlgen/postgres/PostgresV2Connection.hpp"
2+
#include "sqlgen/postgres/PostgresV2Result.hpp"
23

34
namespace sqlgen::postgres {
45

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

20+
rfl::Result<PostgresV2Result> PostgresV2Result::make(
21+
const std::string& _query, const PostgresV2Connection& _conn,
22+
const std::vector<std::optional<std::string>>& _params) noexcept {
23+
std::vector<const char*> param_values(_params.size());
24+
for (size_t i = 0; i < _params.size(); ++i) {
25+
param_values[i] = _params[i] ? _params[i]->c_str() : nullptr;
26+
}
27+
28+
auto res = PQexecParams(_conn.ptr(), _query.c_str(),
29+
static_cast<int>(_params.size()),
30+
nullptr, // paramTypes (let server infer)
31+
param_values.data(), // paramValues
32+
nullptr, // paramLengths (text format)
33+
nullptr, // paramFormats (text format)
34+
0); // resultFormat (text)
35+
36+
const auto status = PQresultStatus(res);
37+
if (status != PGRES_COMMAND_OK && status != PGRES_TUPLES_OK &&
38+
status != PGRES_COPY_IN) {
39+
const auto msg =
40+
std::string("Query execution failed: ") + PQerrorMessage(_conn.ptr());
41+
PQclear(res);
42+
return error(msg);
43+
}
44+
return PostgresV2Result(res);
45+
}
46+
1947
} // namespace sqlgen::postgres
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#ifndef SQLGEN_BUILD_DRY_TESTS_ONLY
2+
3+
#include <gtest/gtest.h>
4+
5+
#include <optional>
6+
#include <sqlgen/postgres.hpp>
7+
#include <string>
8+
9+
namespace test_execute_params {
10+
11+
TEST(postgres, execute_with_string_params) {
12+
const auto credentials = sqlgen::postgres::Credentials{
13+
.user = "postgres",
14+
.password = "password",
15+
.host = "localhost",
16+
.dbname = "postgres"};
17+
auto conn_result = sqlgen::postgres::connect(credentials);
18+
ASSERT_TRUE(conn_result);
19+
auto conn = conn_result.value();
20+
21+
// Create a test table
22+
auto create_result = conn->execute(R"(
23+
CREATE TABLE IF NOT EXISTS test_execute_params (
24+
id SERIAL PRIMARY KEY,
25+
name TEXT,
26+
value INTEGER
27+
);
28+
)");
29+
ASSERT_TRUE(create_result) << create_result.error().what();
30+
31+
// Clean up any existing data
32+
auto truncate_result = conn->execute("TRUNCATE test_execute_params;");
33+
ASSERT_TRUE(truncate_result) << truncate_result.error().what();
34+
35+
// Insert using parameterized execute
36+
auto insert_result = conn->execute(
37+
"INSERT INTO test_execute_params (name, value) VALUES ($1, $2);",
38+
std::string("test_name"), 42);
39+
ASSERT_TRUE(insert_result) << insert_result.error().what();
40+
41+
// Clean up
42+
auto drop_result = conn->execute("DROP TABLE test_execute_params;");
43+
ASSERT_TRUE(drop_result) << drop_result.error().what();
44+
}
45+
46+
TEST(postgres, execute_with_null_param) {
47+
const auto credentials = sqlgen::postgres::Credentials{
48+
.user = "postgres",
49+
.password = "password",
50+
.host = "localhost",
51+
.dbname = "postgres"};
52+
auto conn_result = sqlgen::postgres::connect(credentials);
53+
ASSERT_TRUE(conn_result);
54+
auto conn = conn_result.value();
55+
56+
// Create a test table
57+
auto create_result = conn->execute(R"(
58+
CREATE TABLE IF NOT EXISTS test_execute_null (
59+
id SERIAL PRIMARY KEY,
60+
name TEXT
61+
);
62+
)");
63+
ASSERT_TRUE(create_result) << create_result.error().what();
64+
65+
// Insert with null parameter using std::optional
66+
std::optional<std::string> null_val = std::nullopt;
67+
auto insert_result = conn->execute(
68+
"INSERT INTO test_execute_null (name) VALUES ($1);", null_val);
69+
ASSERT_TRUE(insert_result) << insert_result.error().what();
70+
71+
// Clean up
72+
auto drop_result = conn->execute("DROP TABLE test_execute_null;");
73+
ASSERT_TRUE(drop_result) << drop_result.error().what();
74+
}
75+
76+
TEST(postgres, execute_with_numeric_params) {
77+
const auto credentials = sqlgen::postgres::Credentials{
78+
.user = "postgres",
79+
.password = "password",
80+
.host = "localhost",
81+
.dbname = "postgres"};
82+
auto conn_result = sqlgen::postgres::connect(credentials);
83+
ASSERT_TRUE(conn_result);
84+
auto conn = conn_result.value();
85+
86+
// Create a test table
87+
auto create_result = conn->execute(R"(
88+
CREATE TABLE IF NOT EXISTS test_execute_numeric (
89+
id SERIAL PRIMARY KEY,
90+
int_val INTEGER,
91+
float_val DOUBLE PRECISION,
92+
bool_val BOOLEAN
93+
);
94+
)");
95+
ASSERT_TRUE(create_result) << create_result.error().what();
96+
97+
// Insert with various numeric types
98+
auto insert_result = conn->execute(
99+
"INSERT INTO test_execute_numeric (int_val, float_val, bool_val) "
100+
"VALUES ($1, $2, $3);",
101+
123, 3.14159, true);
102+
ASSERT_TRUE(insert_result) << insert_result.error().what();
103+
104+
// Clean up
105+
auto drop_result = conn->execute("DROP TABLE test_execute_numeric;");
106+
ASSERT_TRUE(drop_result) << drop_result.error().what();
107+
}
108+
109+
TEST(postgres, execute_call_function) {
110+
const auto credentials = sqlgen::postgres::Credentials{
111+
.user = "postgres",
112+
.password = "password",
113+
.host = "localhost",
114+
.dbname = "postgres"};
115+
auto conn_result = sqlgen::postgres::connect(credentials);
116+
ASSERT_TRUE(conn_result);
117+
auto conn = conn_result.value();
118+
119+
// Create a simple test function
120+
auto create_fn_result = conn->execute(R"(
121+
CREATE OR REPLACE FUNCTION test_add(a INTEGER, b INTEGER)
122+
RETURNS INTEGER AS $$
123+
BEGIN
124+
RETURN a + b;
125+
END;
126+
$$ LANGUAGE plpgsql;
127+
)");
128+
ASSERT_TRUE(create_fn_result) << create_fn_result.error().what();
129+
130+
// Call the function with parameters
131+
auto call_result = conn->execute("SELECT test_add($1, $2);", 5, 3);
132+
ASSERT_TRUE(call_result) << call_result.error().what();
133+
134+
// Clean up
135+
auto drop_fn_result = conn->execute("DROP FUNCTION test_add;");
136+
ASSERT_TRUE(drop_fn_result) << drop_fn_result.error().what();
137+
}
138+
139+
} // namespace test_execute_params
140+
141+
#endif

0 commit comments

Comments
 (0)