Skip to content

Commit a82d0d0

Browse files
Pierre-Luc Gagnéclaude
andcommitted
feat: add prepared statements, transaction guard, CTE fluent API; remove col<T,I>
Add prepared_statement class wrapping MYSQL_STMT* with type-safe parameter binding (execute/query), transaction_guard RAII helper with auto-rollback, and a new CTE fluent API: with(cte(...)).select(...).from(cte_ref{...}). Remove col<T,I> and col_of (col.hpp deleted), replace ColumnDescriptor with ColumnFieldType everywhere, inline column_traits calls. Extract predicates from sql_core.hpp into sql_predicates.hpp. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ce32fda commit a82d0d0

16 files changed

Lines changed: 2113 additions & 1296 deletions

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,26 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- `prepared_statement` — RAII wrapper for MySQL prepared statements (`MYSQL_STMT*`); created via `mysql_connection::prepare(sql)` or `prepare(builder)`; supports type-safe parameter binding via `execute(params...)` and typed result fetching via `query<RowType>(params...)`
14+
- `transaction_guard` — RAII scoped transaction helper; `transaction_guard::begin(conn)` disables autocommit, destructor auto-rolls-back unless `commit()` is called; also provides explicit `rollback()`
15+
- CTE fluent API — `with(cte("name", query)).select(...).from(cte_ref{"name"})` and `with(recursive(cte("name", sql))).select(...)` replace the old `with_cte()`/`with_recursive_cte()` builders
16+
- `sql_predicates.hpp` — predicates, operators, and `col_expr`/`col_ref` extracted from `sql_core.hpp` into their own header for readability
17+
18+
### Changed
19+
20+
- `ColumnDescriptor` concept replaced by `ColumnFieldType` everywhere — the dual-concept system is removed
21+
- `column_traits<T>` removed — callers now use `T::column_name()` and `T::value_type` directly
22+
- `qual<Col>` now derives the table name from the tag type via compile-time reflection, replacing the old `col<T,I>` approach
23+
- `sql_core.hpp` reduced from ~1 035 to ~340 lines via the predicate extraction
24+
25+
### Removed
26+
27+
- `col<Table, Index>` (`col.hpp`) — index-based column descriptor removed; use `tagged_column_field` (or `COLUMN_FIELD` macro) instead
28+
- `col_of<&T::field>` — member-pointer column alias removed alongside `col<T,I>`
29+
- `cte_builder`, `with_cte()`, `with_recursive_cte()` — replaced by the new `with(cte(...))` fluent API
30+
1131
---
1232

1333
## [4.6.3] – 2026-03-30

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,56 @@ auto cnt = db.query(count(product{}).where(equal<product::price>(9.99)));
252252
auto cols = db.query(describe(product{}));
253253
```
254254

255+
### CTEs (Common Table Expressions)
256+
257+
```cpp
258+
// Non-recursive CTE
259+
auto sql = with(cte("active", select(product::id{}).from(product{}).where(equal<product::price>(9.99))))
260+
.select(count_all{})
261+
.from(cte_ref{"active"})
262+
.build_sql();
263+
// → WITH active AS (SELECT id FROM product WHERE price = 9.99) SELECT COUNT(*) FROM active
264+
265+
// Multiple CTEs
266+
auto c1 = cte("expensive", select(product::id{}).from(product{}).where(greater_than<product::price>(100.0)));
267+
auto c2 = cte("cheap", select(product::id{}).from(product{}).where(less_than<product::price>(10.0)));
268+
auto sql = with(c1, c2).select(count_all{}).from(cte_ref{"expensive"}).build_sql();
269+
270+
// Recursive CTE
271+
auto sql = with(recursive(cte("nums", "SELECT 1 AS n UNION ALL SELECT n+1 FROM nums WHERE n < 10")))
272+
.select(count_all{})
273+
.from(cte_ref{"nums"})
274+
.build_sql();
275+
// → WITH RECURSIVE nums AS (...) SELECT COUNT(*) FROM nums
276+
```
277+
278+
### Prepared Statements
279+
280+
```cpp
281+
// Prepare once, execute many times with different parameters
282+
auto stmt = db.prepare("SELECT id, code FROM trade WHERE type = ?").value();
283+
auto stocks = stmt.query<std::tuple<uint32_t, std::string>>(std::string{"Stock"}).value();
284+
auto bonds = stmt.query<std::tuple<uint32_t, std::string>>(std::string{"Bond"}).value();
285+
286+
// DML with prepared statements
287+
auto ins = db.prepare("INSERT INTO trade (code, type) VALUES (?, ?)").value();
288+
auto affected = ins.execute(std::string{"AAPL"}, std::string{"Stock"}).value();
289+
auto id = ins.last_insert_id();
290+
```
291+
292+
### Transaction Guard
293+
294+
```cpp
295+
// RAII scoped transaction — auto-rollback on destruction if not committed
296+
{
297+
auto guard = transaction_guard::begin(db).value();
298+
db.execute(insert_into(product{}).values(row));
299+
db.execute(update(product{}).set(product::price{12.99}).where(equal<product::id>(1u)));
300+
guard.commit().value(); // explicit commit
301+
}
302+
// If commit() is never called, the destructor rolls back automatically.
303+
```
304+
255305
### Schema Validation
256306

257307
```cpp

lib/include/ds_mysql/col.hpp

Lines changed: 0 additions & 129 deletions
This file was deleted.

lib/include/ds_mysql/column_field.hpp

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,10 @@ struct unwrap_column_field<T> {
108108
template <typename T>
109109
using unwrap_column_field_t = typename unwrap_column_field<T>::type;
110110

111-
} // namespace ds_mysql
112-
113111
// ===================================================================
114112
// tagged_column_field — tag-struct based column descriptor
115113
// ===================================================================
116114

117-
namespace ds_mysql {
118-
119115
/**
120116
* tagged_column_field<Tag, T> — tag-struct based column descriptor.
121117
*

lib/include/ds_mysql/ds_mysql.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
#include "ds_mysql/metadata.hpp"
1717
#include "ds_mysql/mysql_connection.hpp"
18+
#include "ds_mysql/prepared_statement.hpp"
1819
#include "ds_mysql/sql_dcl.hpp"
1920
#include "ds_mysql/sql_ddl.hpp"
2021
#include "ds_mysql/sql_dml.hpp"

lib/include/ds_mysql/mysql_connection.hpp

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "ds_mysql/database_name.hpp"
2020
#include "ds_mysql/host_name.hpp"
2121
#include "ds_mysql/port_number.hpp"
22+
#include "ds_mysql/prepared_statement.hpp"
2223
#include "ds_mysql/sql_ddl.hpp"
2324
#include "ds_mysql/sql_dml.hpp"
2425
#include "ds_mysql/sql_dql.hpp"
@@ -392,6 +393,28 @@ class mysql_connection {
392393
return out;
393394
}
394395

396+
// ---------------------------------------------------------------
397+
// Prepared statements
398+
// ---------------------------------------------------------------
399+
400+
// Prepare a SQL string for repeated execution with bound parameters.
401+
[[nodiscard]] std::expected<prepared_statement, std::string> prepare(std::string_view sql) const {
402+
auto stmt = std::unique_ptr<MYSQL_STMT, prepared_statement::stmt_deleter>(mysql_stmt_init(connection_.get()));
403+
if (!stmt) {
404+
return std::unexpected(last_error());
405+
}
406+
if (mysql_stmt_prepare(stmt.get(), sql.data(), static_cast<unsigned long>(sql.size())) != 0) {
407+
return std::unexpected(std::string(mysql_stmt_error(stmt.get())));
408+
}
409+
return prepared_statement{std::move(stmt)};
410+
}
411+
412+
// Prepare a typed query builder for repeated execution.
413+
template <SqlBuilder Stmt>
414+
[[nodiscard]] std::expected<prepared_statement, std::string> prepare(Stmt const& stmt) const {
415+
return prepare(stmt.build_sql());
416+
}
417+
395418
// Validate that the C++ table struct T matches the live schema in the database.
396419
//
397420
// Runs DESCRIBE <table> and checks:
@@ -598,4 +621,97 @@ class mysql_connection {
598621
std::unique_ptr<MYSQL, decltype(&mysql_close)> connection_;
599622
};
600623

624+
// ===================================================================
625+
// transaction_guard — RAII scoped transaction helper.
626+
//
627+
// This is a C++ resource-management utility, not a MySQL semantic.
628+
// Disables autocommit on construction and automatically rolls back on
629+
// destruction unless commit() has been called. Move-only.
630+
//
631+
// {
632+
// auto guard = transaction_guard::begin(conn);
633+
// if (!guard) { /* handle error */ }
634+
// conn.execute(insert_into(t{}).values(row));
635+
// auto result = guard->commit();
636+
// if (!result) { /* handle commit error */ }
637+
// }
638+
// // If commit() was never called, destructor rolls back.
639+
// ===================================================================
640+
641+
class transaction_guard {
642+
public:
643+
transaction_guard(transaction_guard const&) = delete;
644+
transaction_guard& operator=(transaction_guard const&) = delete;
645+
646+
transaction_guard(transaction_guard&& other) noexcept : conn_(other.conn_), committed_(other.committed_) {
647+
other.conn_ = nullptr;
648+
}
649+
650+
transaction_guard& operator=(transaction_guard&& other) noexcept {
651+
if (this != &other) {
652+
if (conn_ && !committed_) {
653+
(void)conn_->rollback();
654+
}
655+
conn_ = other.conn_;
656+
committed_ = other.committed_;
657+
other.conn_ = nullptr;
658+
}
659+
return *this;
660+
}
661+
662+
~transaction_guard() {
663+
if (conn_ && !committed_) {
664+
(void)conn_->rollback();
665+
}
666+
}
667+
668+
// Factory: begin a transaction by disabling autocommit.
669+
[[nodiscard]] static std::expected<transaction_guard, std::string> begin(mysql_connection const& conn) {
670+
auto result = conn.autocommit(false);
671+
if (!result) {
672+
return std::unexpected(result.error());
673+
}
674+
return transaction_guard{&conn};
675+
}
676+
677+
// Commit the transaction and re-enable autocommit.
678+
[[nodiscard]] std::expected<void, std::string> commit() {
679+
if (!conn_) {
680+
return std::unexpected("transaction_guard: no active connection");
681+
}
682+
auto result = conn_->commit();
683+
if (!result) {
684+
return std::unexpected(result.error());
685+
}
686+
committed_ = true;
687+
// Re-enable autocommit so the connection returns to its default state.
688+
return conn_->autocommit(true);
689+
}
690+
691+
// Explicitly roll back the transaction and re-enable autocommit.
692+
[[nodiscard]] std::expected<void, std::string> rollback() {
693+
if (!conn_) {
694+
return std::unexpected("transaction_guard: no active connection");
695+
}
696+
auto result = conn_->rollback();
697+
if (!result) {
698+
return std::unexpected(result.error());
699+
}
700+
committed_ = true; // prevent double-rollback in destructor
701+
return conn_->autocommit(true);
702+
}
703+
704+
// Check whether commit() has been called.
705+
[[nodiscard]] bool is_committed() const noexcept {
706+
return committed_;
707+
}
708+
709+
private:
710+
explicit transaction_guard(mysql_connection const* conn) : conn_(conn) {
711+
}
712+
713+
mysql_connection const* conn_ = nullptr;
714+
bool committed_ = false;
715+
};
716+
601717
} // namespace ds_mysql

0 commit comments

Comments
 (0)