diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92dc7f88e..0ed43d516 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,6 +85,8 @@ jobs: run: cmake --preset gcc-release -D LIGHTWEIGHT_BUILD_DOCUMENTATION=ON -D LIGHTWEIGHT_DOCS_WARN_AS_ERROR=ON - name: "check documentation" run: cmake --build --preset gcc-release --target doc + - name: "check doc code snippets are in sync with tested source" + run: python3 scripts/check-doc-snippets.py check_clang_tidy: name: "Check clang-tidy" diff --git a/AGENT.md b/AGENT.md index a29da5e91..e43bd08d5 100644 --- a/AGENT.md +++ b/AGENT.md @@ -209,6 +209,10 @@ If a database is **deliberately** skipped (e.g., feature genuinely doesn't apply When touching anything in `DataBinder/`, also exercise both `u8`/`u16`/`wchar_t` paths (the `WideChar` typedef in `Utils.hpp` selects the platform-appropriate UTF-16 code unit). See `.agent/testing.md` and `.agent/databases.md` for deeper guidance. +### Documentation code snippets must stay test-backed + +`docs/sql-to-lightweight.md` tags each C++ example with ``; the identical code lives in `src/tests/DocExampleTests.cpp` between `//! []` markers, where it is compiled and executed against every DBMS. `scripts/check-doc-snippets.py` (run in the `check_docs` CI job) fails if the two drift. When you change such an example, edit the `//! []` region first, copy the same lines into the doc block, then run `python3 scripts/check-doc-snippets.py`. Prefer this snippet-backed pattern for any new SQL/DataMapper usage examples you add to `docs/`. + ## Adding Features ### New `SqlDataBinder` specialization diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index 32b05748f..5f1401f22 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -56,6 +56,7 @@ message(STATUS "Doxygen found: ${DOXYGEN_EXECUTABLE}") "${PROJECT_SOURCE_DIR}/docs/how-to.md" "${PROJECT_SOURCE_DIR}/docs/usage.md" "${PROJECT_SOURCE_DIR}/docs/sqlquery.md" + "${PROJECT_SOURCE_DIR}/docs/sql-to-lightweight.md" "${PROJECT_SOURCE_DIR}/docs/best-practices.md" "${PROJECT_SOURCE_DIR}/docs/data-binder.md" "${PROJECT_SOURCE_DIR}/docs/sql-backup.md" diff --git a/docs/sql-to-lightweight.md b/docs/sql-to-lightweight.md new file mode 100644 index 000000000..9c0ced3d3 --- /dev/null +++ b/docs/sql-to-lightweight.md @@ -0,0 +1,710 @@ +# SQL to Lightweight + +This page is a side-by-side cookbook: for a given piece of SQL it shows the equivalent +**Lightweight** code. Lightweight offers three layers, and most queries can be expressed in +any of them — pick the one that fits the situation: + +| Layer | Entry point | When to reach for it | +|-------|-------------|----------------------| +| **Raw SQL** | `SqlStatement` (`ExecuteDirect`, `Prepare`/`Execute`) | You already have the SQL, need full control, or are running DDL/vendor-specific statements. | +| **Query builder** | `SqlStatement::Query(...)` / `DataMapper::FromTable(...)` | You want the SQL shaped per-DBMS for you (quoting, `LIMIT`/`TOP`, `OFFSET`) but still think in tables and columns. | +| **DataMapper** | `DataMapper::Query()`, `Create`, `Update`, `Delete` | You map C++ structs to tables and want CRUD, relationships, and type-safe column references. | + +All three produce the same SQL against the same database; the query builder and the `DataMapper` +route every dialect difference through `SqlQueryFormatter`, so the same C++ runs unchanged on +SQLite, PostgreSQL, and Microsoft SQL Server. + +> Every C++ example on this page is compiled and executed by `src/tests/DocExampleTests.cpp`, and a +> CI check (`scripts/check-doc-snippets.py`) fails the build if the code here ever drifts from the +> tested version. See the *Keeping these examples honest* section at the end of this page. + +Examples below assume: + +```cpp +#include + +using namespace Lightweight; // or qualify everything with Lightweight:: / Light:: +``` + +## The example schema + +Most snippets map onto two records and their relationship: + + +```cpp +struct Employee; // forward declaration for the relationship below + +struct Department +{ + static constexpr std::string_view TableName = "Departments"; + + Field id {}; + Field> name {}; + + HasMany employees {}; // one department, many employees +}; + +struct Employee +{ + static constexpr std::string_view TableName = "Employees"; + + Field id {}; + Field> firstName {}; + + // FK -> Departments.id. A HasMany and its inverse BelongsTo are matched by field + // position, so this member sits at the same index as Department::employees above. + BelongsTo<&Department::id, SqlRealName { "department_id" }, SqlNullable::Null> department {}; + + Field> lastName {}; + Field salary {}; + Field> age {}; +}; +``` + +`Field` declares a column; the second template argument customises it +(`PrimaryKey::ServerSideAutoIncrement` lets the database assign the id, a `SqlRealName { "..." }` +overrides the column name, etc.). `FieldNameOf<&Employee::salary>` yields the column name +(`"salary"`, or the `SqlRealName` override) and `FullyQualifiedNameOf<&Employee::salary>` yields +`"Employees"."salary"` — use these instead of hand-writing column-name strings so a rename is caught +at compile time. + +--- + +## SELECT + +### Select all rows + +```sql +SELECT * FROM "Employees"; +``` + + +```cpp +// DataMapper — materialises Employee records (relations lazily configured) +auto employees = dm.Query().All(); + +// Skip relationship loading when you only need the row's own columns +auto rows = dm.Query().All(); +``` + + +```cpp +// Raw SQL +auto stmt = SqlStatement { dm.Connection() }; +auto cursor = stmt.ExecuteDirect(R"(SELECT "firstName", "lastName", "salary" FROM "Employees")"); +while (cursor.FetchRow()) +{ + auto firstName = cursor.GetColumn(1); // columns are 1-based + auto lastName = cursor.GetColumn(2); + auto salary = cursor.GetColumn(3); + std::println("{} {} {}", firstName, lastName, salary); +} +``` + +### Select specific columns + +```sql +SELECT "firstName", "lastName" FROM "Employees"; +``` + + +```cpp +// Query builder +auto query = dm.FromTable("Employees").Select().Fields("firstName", "lastName").All(); + +// DataMapper — project to a subset of fields; All<>() returns just those values +auto names = dm.Query() + .All<&Employee::firstName, &Employee::lastName>(); +``` + +### WHERE — a single condition + +```sql +SELECT * FROM "Employees" WHERE "salary" >= 55000; +``` + + +```cpp +// DataMapper +auto rows = dm.Query().Where(FieldNameOf<&Employee::salary>, ">=", 55'000).All(); +``` + + +```cpp +// Raw, prepared + parameter binding (use ? placeholders, never string concatenation) +auto stmt = SqlStatement { dm.Connection() }; +stmt.Prepare(R"(SELECT "firstName", "lastName", "salary" FROM "Employees" WHERE "salary" >= ?)"); +auto cursor = stmt.Execute(55'000); +while (cursor.FetchRow()) +{ + auto firstName = cursor.GetColumn(1); + std::println("{}", firstName); +} +``` + +> The two-argument `Where(column, value)` is shorthand for equality: +> `Where(FieldNameOf<&Employee::id>, id)` emits `WHERE "id" = ?`. + +### WHERE — multiple conditions (AND / OR) + +```sql +SELECT * FROM "Employees" WHERE "salary" >= 55000 AND "age" < 40; +``` + + +```cpp +auto rows = dm.Query() + .Where(FieldNameOf<&Employee::salary>, ">=", 55'000) + .And() + .Where(FieldNameOf<&Employee::age>, "<", 40) + .All(); +``` + +`And()`, `Or()`, and `Not()` apply to the *next* `Where(...)`. The query builder exposes the same +clause builder, plus `WhereRaw(...)` when you want to inject a literal predicate. + +### WHERE IN + +```sql +SELECT * FROM "Employees" WHERE "department_id" IN (1, 2, 3); +``` + + +```cpp +auto departmentIds = std::vector { 1, 2, 3 }; +auto rows = dm.Query().WhereIn(FieldNameOf<&Employee::department>, departmentIds).All(); +``` + +`WhereIn` accepts any range (`std::vector`, `std::set`, an initializer list) or a sub-select query. + +### WHERE — NULL / NOT NULL + +```sql +SELECT * FROM "Employees" WHERE "age" IS NOT NULL; +``` + + +```cpp +auto rows = dm.Query().WhereNotNull(FieldNameOf<&Employee::age>).All(); +// WhereNull(...) emits IS NULL; WhereNotEqual(column, value) emits "<> ?" +``` + +### Optional / conditional filters + +Search, report, and "filter form" queries usually have several criteria that each apply *only* when +the caller supplied a value. Done by hand that becomes a chain of `if (opt) query.Where(...)` +statements that mutate the builder. Lightweight expresses the same thing inline with +`If(optional).ThenWhere(column[, binaryOp])`: + +- `If(opt)` guards the single `ThenWhere(...)` that immediately follows it. +- When `opt` **holds a value**, `ThenWhere(column)` appends `WHERE column = *opt`, and + `ThenWhere(column, binaryOp)` appends `WHERE column *opt` (e.g. `">="`, `"<"`, `"LIKE"`). +- When `opt` is **empty**, the call is a no-op: the predicate is omitted and the rest of the query is + left untouched. + +So one piece of code produces a different `WHERE` depending on which inputs are present — no manual +branching. In the example below (`Events` has `id`, `userId`, and `createdAt` columns) the helpers +return `userId = 42` with both timestamps absent, so only the first predicate survives: + +```sql +SELECT "id" FROM "Events" WHERE "Events"."userId" = 42 ORDER BY "id"; +``` + + +```cpp +std::optional userId = MaybeUserIdFromRequest(); +std::optional since = MaybeSinceFromRequest(); +std::optional until = MaybeUntilFromRequest(); // empty -> skipped + +auto query = dm.FromTable("Events") + .Select() + .Field("id") + .If(userId) + .ThenWhere(FullyQualifiedNameOf<&Events::userId>) + .If(since) + .ThenWhere(FullyQualifiedNameOf<&Events::createdAt>, ">=") + .If(until) + .ThenWhere(FullyQualifiedNameOf<&Events::createdAt>, "<") + .OrderBy("id") + .All(); +``` + +The same builder yields different SQL as the inputs change: + +| `userId` | `since` | `until` | Emitted `WHERE` | +|----------|---------|---------|-----------------| +| `42` | — | — | `WHERE "Events"."userId" = 42` | +| `42` | `2026-01-01` | — | `WHERE "Events"."userId" = 42 AND "Events"."createdAt" >= '2026-01-01T00:00:00.000'` | +| — | — | `2026-05-18` | `WHERE "Events"."createdAt" < '2026-05-18T00:00:00.000'` | +| — | — | — | *(no `WHERE` clause is emitted)* | + +`If` / `ThenWhere` accept any column-name form that `Where` does (plain strings, +`SqlQualifiedTableColumnName`, `FullyQualifiedNameOf<&Record::field>`) and are available on the +`Select`, `Update`, and `Delete` builders. + +### ORDER BY + +```sql +SELECT * FROM "Employees" ORDER BY "lastName" DESC; +``` + + +```cpp +auto rows = dm.Query().OrderBy(FieldNameOf<&Employee::lastName>, SqlResultOrdering::DESCENDING).All(); +// SqlResultOrdering::ASCENDING is the default when the ordering argument is omitted. +``` + +### LIMIT / TOP (fetch the first row) + +```sql +-- SQLite / PostgreSQL: ... ORDER BY "salary" DESC LIMIT 1 +-- SQL Server: SELECT TOP 1 ... ORDER BY "salary" DESC +``` + + +```cpp +// First() returns std::optional; the formatter emits LIMIT 1 or TOP 1 per DBMS. +auto highestPaid = + dm.Query().OrderBy(FieldNameOf<&Employee::salary>, SqlResultOrdering::DESCENDING).First(); +if (highestPaid) + std::println("{}", highestPaid->lastName.Value()); +``` + +### OFFSET / LIMIT (pagination) + +```sql +-- SQLite / PostgreSQL: ... ORDER BY "id" LIMIT 50 OFFSET 200 +-- SQL Server: ... ORDER BY "id" OFFSET 200 ROWS FETCH NEXT 50 ROWS ONLY +``` + + +```cpp +auto page = dm.Query().OrderBy(FieldNameOf<&Employee::id>).Range(/*offset*/ 200, /*limit*/ 50); +``` + +### DISTINCT + +```sql +SELECT DISTINCT "department_id" FROM "Employees"; +``` + + +```cpp +auto query = dm.FromTable("Employees").Select().Distinct().Field("department_id").All(); +``` + +### COUNT and aggregates + +```sql +SELECT COUNT(*) FROM "Employees" WHERE "salary" >= 55000; +``` + + +```cpp +// DataMapper — Count() is a finalizer +auto n = dm.Query().Where(FieldNameOf<&Employee::salary>, ">=", 55'000).Count(); +``` + + +```cpp +// Raw — a single scalar result +auto stmt = SqlStatement { dm.Connection() }; +auto total = stmt.ExecuteDirectScalar(R"(SELECT COUNT(*) FROM "Employees")"); // std::optional +``` + +```sql +SELECT MAX("salary") AS "maxSalary" FROM "Employees"; +``` + + +```cpp +// Query builder — Aggregate::{Min,Max,Sum,Avg,Count} +auto query = dm.FromTable("Employees").Select().Field(Aggregate::Max("salary")).As("maxSalary").All(); +``` + +### GROUP BY + +```sql +SELECT "department_id", COUNT(*) FROM "Employees" GROUP BY "department_id"; +``` + + +```cpp +auto query = dm.FromTable("Employees") + .Select() + .Field("department_id") + .Field(Aggregate::Count("*")) + .As("headcount") + .GroupBy("department_id") + .All(); +``` + +--- + +## JOIN + +### INNER JOIN + +```sql +SELECT "Employees".*, "Departments"."name" + FROM "Employees" + INNER JOIN "Departments" ON "Departments"."id" = "Employees"."department_id"; +``` + + +```cpp +// DataMapper — the join columns are given as member pointers: <&Parent::pk, &Child::fk> +auto rows = dm.Query() + .InnerJoin<&Department::id, &Employee::department>() + .All(); +``` + + +```cpp +// Query builder — InnerJoin(otherTable, otherColumn, thisColumn) +auto query = dm.FromTable("Employees") + .Select() + .Fields({ "firstName"sv, "lastName"sv }, "Employees") + .Field(SqlQualifiedTableColumnName { .tableName = "Departments", .columnName = "name" }) + .InnerJoin("Departments", "id", "department_id") + .All(); +``` + +### LEFT OUTER JOIN + +```sql +SELECT * FROM "Employees" + LEFT OUTER JOIN "Departments" ON "Departments"."id" = "Employees"."department_id"; +``` + + +```cpp +auto query = dm.FromTable("Employees") + .Select() + .Fields({ "firstName"sv, "lastName"sv }, "Employees") + .LeftOuterJoin("Departments", "id", "department_id") + .All(); +``` + +### Multi-condition / aliased joins + +```sql +SELECT ... FROM "Table_A" + INNER JOIN "Table_B" + ON "Table_B"."id" = "Table_A"."that_id" + AND "Table_B"."that_foo" = "Table_A"."foo"; +``` + + +```cpp +auto query = dm.FromTable("Table_A") + .Select() + .Fields({ "foo"sv, "bar"sv }, "Table_A") + .Fields({ "that_foo"sv, "that_id"sv }, "Table_B") + .InnerJoin("Table_B", + [](SqlJoinConditionBuilder join) { + return join.On("id", { .tableName = "Table_A", .columnName = "that_id" }) + .On("that_foo", { .tableName = "Table_A", .columnName = "foo" }); + }) + .All(); +``` + +Use `AliasedTableName { .tableName = "Departments", .alias = "D" }` as the join target (and +`FromTableAs("Employees", "E")`) for self-joins or when the same table appears more than once. + +--- + +## INSERT + +```sql +INSERT INTO "Employees" ("firstName", "lastName", "salary") VALUES ('Alice', 'Smith', 50000); +``` + + +```cpp +// DataMapper — populate a record and Create it; the auto-assigned id is written back. +auto employee = Employee { .firstName = "Alice", .lastName = "Smith", .salary = 50'000 }; +dm.Create(employee); +// employee.id is now set +``` + + +```cpp +// Raw — prepare once, execute per row +auto stmt = SqlStatement { dm.Connection() }; +stmt.Prepare(R"(INSERT INTO "Employees" ("firstName", "lastName", "salary") VALUES (?, ?, ?))"); +std::ignore = stmt.Execute("Alice", "Smith", 50'000); +std::ignore = stmt.Execute("Bob", "Johnson", 60'000); +``` + + +```cpp +// Query builder — captures the bound values for a later prepared execution +std::vector bound; +auto query = dm.FromTable("Employees") + .Insert(&bound) + .Set("firstName", "Alice") + .Set("lastName", "Smith") + .Set("salary", 50'000); +``` + +### Bulk insert + + +```cpp +// DataMapper — one prepared statement for the whole batch (native ODBC array binding when possible) +auto people = std::vector { + Employee { .firstName = "Alice", .lastName = "Smith", .salary = 50'000 }, + Employee { .firstName = "Bob", .lastName = "Johnson", .salary = 60'000 }, +}; +dm.CreateAll(people); +``` + + +```cpp +// Raw — column-wise batch +auto stmt = SqlStatement { dm.Connection() }; +stmt.Prepare(R"(INSERT INTO "Employees" ("firstName", "lastName", "salary") VALUES (?, ?, ?))"); +auto const firstNames = std::array { "Alice"sv, "Bob"sv, "Charlie"sv }; +auto const lastNames = std::array { "Smith"sv, "Johnson"sv, "Brown"sv }; +auto const salaries = std::array { 50'000, 60'000, 70'000 }; +std::ignore = stmt.ExecuteBatch(firstNames, lastNames, salaries); +``` + +--- + +## UPDATE + +```sql +UPDATE "Employees" SET "salary" = 55000 WHERE "salary" = 50000; +``` + + +```cpp +// DataMapper — load, mutate, write back by primary key. +// Only Field<>s you changed are written; the WHERE is the primary key. +if (auto employee = dm.QuerySingle(id)) +{ + employee->salary = 55'000; + dm.Update(*employee); +} +``` + + +```cpp +// Query builder — set/where, then prepare & execute with the captured bindings +std::vector bound; +auto query = dm.FromTable("Employees").Update(&bound).Set("salary", 55'000).Where("salary", 50'000); + +auto stmt = SqlStatement { dm.Connection() }; +stmt.Prepare(query); +std::ignore = stmt.ExecuteWithVariants(bound); +``` + + +```cpp +// Raw +auto stmt = SqlStatement { dm.Connection() }; +stmt.Prepare(R"(UPDATE "Employees" SET "salary" = ? WHERE "salary" = ?)"); +auto cursor = stmt.Execute(55'000, 50'000); +auto changed = cursor.NumRowsAffected(); +``` + +Use `dm.UpdateAll(people)` to write a whole range in one prepared statement. + +--- + +## DELETE + +```sql +DELETE FROM "Employees" WHERE "department_id" IN (1, 2, 3); +``` + + +```cpp +// DataMapper — bulk delete by predicate +dm.Query() + .WhereIn(FieldNameOf<&Employee::department>, std::vector { 1, 2, 3 }) + .Delete(); + +// Delete a single loaded record by its primary key +dm.Delete(employee); +``` + + +```cpp +// Query builder +auto query = dm.FromTable("Employees").Delete().WhereIn("department_id", std::vector { 1, 2, 3 }); +``` + +--- + +## Relationships + +`BelongsTo` (many-to-one) and `HasMany` (one-to-many) replace hand-written join queries when +navigating between records. After loading a record, `ConfigureRelationAutoLoading` lets related rows +be fetched on first access: + +```sql +-- Conceptually: the employee plus its department, and a department's employees +SELECT * FROM "Employees" WHERE "id" = ?; +SELECT * FROM "Departments" WHERE "id" = ; +SELECT * FROM "Employees" WHERE "department_id" = ; +``` + + +```cpp +if (auto employee = dm.QuerySingle(id)) +{ + dm.ConfigureRelationAutoLoading(*employee); + + // BelongsTo: the parent record is fetched on demand. The FK here is nullable, so + // Record() yields an optional; Unwrap turns the optional-reference into a value. + if (auto const dept = employee->department.Record().transform(Unwrap)) + std::println("Department: {}", dept->name.Value()); +} + +// HasMany: Count() and All() on the collection +if (auto department = dm.QuerySingle(deptId)) +{ + dm.ConfigureRelationAutoLoading(*department); + std::println("{} employees", department->employees.Count()); + for (auto const& emp: department->employees.All()) + std::println(" {}", emp->lastName.Value()); +} +``` + +When the `BelongsTo` is **mandatory** (omit `SqlNullable::Null`), the parent is reached with the +cleaner `employee.department->name` / `*employee.department`. Query with +`DataMapperOptions { .loadRelations = false }` when you do not want relations populated; accessing an +unloaded relation then throws rather than issuing a query. `HasManyThrough` and +`HasOneThrough<...>` model many-to-many / one-through relationships across a junction table. + +--- + +## CREATE TABLE + +```sql +CREATE TABLE "Appointment" ( + "id" BIGINT PRIMARY KEY AUTOINCREMENT, + "date" DATETIME NOT NULL, + "comment" VARCHAR(80), + "physician_id" GUID REFERENCES "Physician"("id"), + "patient_id" GUID REFERENCES "Patient"("id") +); +``` + + +```cpp +// Derive the schema from the record definitions, in dependency order. +dm.CreateTables(); +``` + +`dm.CreateTable()` creates a single table. For explicit column-by-column DDL use the +migration query builder: + + +```cpp +using namespace Lightweight::SqlColumnTypeDefinitions; + +auto migration = dm.Connection().Migration(); +migration.CreateTable("Appointment") + .PrimaryKeyWithAutoIncrement("id") + .RequiredColumn("date", DateTime {}) + .Column("comment", Varchar { 80 }) + .ForeignKey( + "physician_id", Guid {}, SqlForeignKeyReferenceDefinition { .tableName = "Physician", .columnName = "id" }) + .ForeignKey( + "patient_id", Guid {}, SqlForeignKeyReferenceDefinition { .tableName = "Patient", .columnName = "id" }); +auto const plan = migration.GetPlan(); +``` + +See [sql-migrations.md](#sql-migrations) and [sqlquery.md](sqlquery.md) for the full DDL surface +(`AlterTable`, `Index`, `UniqueIndex`, `Timestamps`, `DropTable`, ...). + +--- + +## Transactions + +```sql +BEGIN; + INSERT INTO "Employees" (...) VALUES (...); +COMMIT; -- or ROLLBACK +``` + + +```cpp +auto stmt = SqlStatement { dm.Connection() }; +{ + // SqlTransactionMode::COMMIT auto-commits on scope exit; ROLLBACK auto-rolls back. + auto tx = SqlTransaction { stmt.Connection(), SqlTransactionMode::COMMIT }; + stmt.Prepare(R"(INSERT INTO "Employees" ("firstName", "lastName", "salary") VALUES (?, ?, ?))"); + std::ignore = stmt.Execute("Eve", "Stone", 70'000); + + tx.Commit(); // or tx.Rollback(); explicit calls are also available +} +``` + +For an asynchronous transaction (`AsyncSqlTransaction`) over the coroutine layer, see +[async.md](async.md). + +--- + +## Mapping a custom result shape + +When a query's columns don't match a full record — joins, projections, aggregates — define a plain +struct (no `Field<>` wrapper needed for read-only rows) whose members line up, in order, with the +selected columns: + + +```cpp +struct DepartmentHeadcount +{ + SqlAnsiString<40> name; + int headcount = 0; +}; +``` + +then pass the query to `Query`: + + +```cpp +auto query = dm.FromTable("Employees") + .Select() + .Field(SqlQualifiedTableColumnName { .tableName = "Departments", .columnName = "name" }) + .Field(Aggregate::Count("*")) + .As("headcount") + .InnerJoin("Departments", "id", "department_id") + .GroupBy(SqlQualifiedTableColumnName { .tableName = "Departments", .columnName = "name" }) + .All(); + +for (auto const& row: dm.Query(query)) + std::println("{}: {}", row.name, row.headcount); +``` + +The same struct trick works with `SqlRowIterator` for streaming large result sets one row at a +time. + +--- + +## Keeping these examples honest + +Each C++ block above is mirrored by a region in `src/tests/DocExampleTests.cpp`, delimited by +`//! [id]` markers, and tagged here with ``. The test file compiles and runs +every example against a real database (SQLite locally, plus PostgreSQL and SQL Server in CI), and +`scripts/check-doc-snippets.py` asserts the doc text and the tested code are identical (modulo +indentation). To change an example: + +1. Edit the `//! [id]` region in `src/tests/DocExampleTests.cpp`, keeping it passing. +2. Copy the same lines into the matching `` block here. +3. Run `python3 scripts/check-doc-snippets.py` — it prints a diff for any block that drifted. + +--- + +## See also + +- [usage.md](usage.md) — getting started: connections, prepared statements, the `DataMapper` CRUD loop. +- [sqlquery.md](sqlquery.md) — the query-builder DSL in depth. +- [sql-migrations.md](#sql-migrations) — schema creation and migrations. +- [best-practices.md](best-practices.md) — choosing between the layers, performance notes. +- [async.md](async.md) — the coroutine / async API. diff --git a/scripts/check-doc-snippets.py b/scripts/check-doc-snippets.py new file mode 100644 index 000000000..afc1862cf --- /dev/null +++ b/scripts/check-doc-snippets.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Verify that documentation code snippets stay in sync with tested source. + +Markdown files tag a fenced code block with an HTML comment: + + + ```cpp + ... code ... + ``` + +and a source file carries the *same* code between Doxygen-style region markers: + + //! [my-id] + ... code ... + //! [my-id] + +This script checks, for every tagged block in the doc(s), that a region with the +same id exists in the source and that the two are identical after normalisation +(common leading indentation removed, trailing whitespace stripped, blank edge +lines dropped). Any drift exits non-zero with a unified diff, so CI fails when an +example is edited in one place but not the other. + +Usage: + check-doc-snippets.py [--doc DOC]... [--source SRC]... + +With no arguments it checks the default pairing for this repository: + docs/sql-to-lightweight.md <-> src/tests/DocExampleTests.cpp +""" + +from __future__ import annotations + +import argparse +import difflib +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def rel(path: Path) -> str: + """Repo-relative path for display, falling back to the raw path.""" + try: + return str(path.resolve().relative_to(REPO_ROOT)) + except ValueError: + return str(path) + + +DEFAULT_DOCS = ["docs/sql-to-lightweight.md"] +DEFAULT_SOURCES = ["src/tests/DocExampleTests.cpp"] + +# `` immediately followed by a ```cpp ... ``` fence. +DOC_SNIPPET_RE = re.compile( + r"\s*\n```[a-zA-Z0-9+]*\n(?P.*?)\n```", + re.DOTALL, +) +# `//! [id]` ... `//! [id]` region markers. +SOURCE_MARKER_RE = re.compile(r"//!\s*\[(?P[\w./-]+)\]\s*$") + + +def normalize(text: str) -> list[str]: + """Drop blank edge lines, strip the common indentation, rstrip each line.""" + lines = [line.rstrip() for line in text.split("\n")] + while lines and lines[0] == "": + lines.pop(0) + while lines and lines[-1] == "": + lines.pop() + indents = [len(line) - len(line.lstrip()) for line in lines if line] + common = min(indents) if indents else 0 + return [line[common:] if line else "" for line in lines] + + +def extract_doc_snippets(path: Path) -> dict[str, list[str]]: + snippets: dict[str, list[str]] = {} + text = path.read_text(encoding="utf-8") + for match in DOC_SNIPPET_RE.finditer(text): + sid = match.group("id") + if sid in snippets: + raise SystemExit(f"{path}: duplicate doc snippet id '{sid}'") + snippets[sid] = normalize(match.group("body")) + return snippets + + +def extract_source_regions(path: Path) -> dict[str, list[str]]: + regions: dict[str, list[str]] = {} + open_id: str | None = None + buffer: list[str] = [] + for line in path.read_text(encoding="utf-8").split("\n"): + marker = SOURCE_MARKER_RE.search(line) + if marker: + sid = marker.group("id") + if open_id is None: + open_id, buffer = sid, [] + elif open_id == sid: + if sid in regions: + raise SystemExit(f"{path}: duplicate source region id '{sid}'") + regions[sid] = normalize("\n".join(buffer)) + open_id = None + else: + raise SystemExit( + f"{path}: region '{open_id}' not closed before '{sid}' opens" + ) + continue + if open_id is not None: + buffer.append(line) + if open_id is not None: + raise SystemExit(f"{path}: region '{open_id}' is never closed") + return regions + + +def fix_docs(docs: list[Path], regions: dict[str, tuple[Path, list[str]]]) -> int: + """Rewrite each doc snippet body from its source region. Returns count changed.""" + changed = 0 + for doc_path in docs: + text = doc_path.read_text(encoding="utf-8") + + def replace(match: re.Match[str]) -> str: + sid = match.group("id") + if sid not in regions: + return match.group(0) + body = "\n".join(regions[sid][1]) + return f"\n```cpp\n{body}\n```" + + new_text = DOC_SNIPPET_RE.sub(replace, text) + if new_text != text: + doc_path.write_text(new_text, encoding="utf-8") + changed += 1 + return changed + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--doc", action="append", default=[], metavar="PATH") + parser.add_argument("--source", action="append", default=[], metavar="PATH") + parser.add_argument( + "--fix", + action="store_true", + help="rewrite doc snippet bodies from their tested source regions", + ) + args = parser.parse_args() + + docs = [REPO_ROOT / p for p in (args.doc or DEFAULT_DOCS)] + sources = [REPO_ROOT / p for p in (args.source or DEFAULT_SOURCES)] + + if args.fix: + regions: dict[str, tuple[Path, list[str]]] = {} + for path in sources: + for sid, body in extract_source_regions(path).items(): + regions[sid] = (path, body) + changed = fix_docs(docs, regions) + print(f"Updated {changed} doc file(s) from source regions.") + # Fall through to a verification pass below. + + doc_snippets: dict[str, tuple[Path, list[str]]] = {} + for path in docs: + for sid, body in extract_doc_snippets(path).items(): + doc_snippets[sid] = (path, body) + + source_regions: dict[str, tuple[Path, list[str]]] = {} + for path in sources: + for sid, body in extract_source_regions(path).items(): + source_regions[sid] = (path, body) + + if not doc_snippets: + print("error: no '' blocks found in docs", file=sys.stderr) + return 1 + + failures = 0 + for sid in sorted(doc_snippets): + doc_path, doc_body = doc_snippets[sid] + if sid not in source_regions: + print( + f"MISSING: snippet '{sid}' in {rel(doc_path)} has no " + f"//! [{sid}] region in any source file", + file=sys.stderr, + ) + failures += 1 + continue + src_path, src_body = source_regions[sid] + if doc_body != src_body: + failures += 1 + diff = difflib.unified_diff( + src_body, + doc_body, + fromfile=f"{rel(src_path)} //! [{sid}]", + tofile=f"{rel(doc_path)} snippet:{sid}", + lineterm="", + ) + print(f"DRIFT: snippet '{sid}' differs:", file=sys.stderr) + print("\n".join(diff), file=sys.stderr) + print("", file=sys.stderr) + + unused = sorted(set(source_regions) - set(doc_snippets)) + for sid in unused: + print( + f"note: source region '{sid}' in " + f"{rel(source_regions[sid][0])} is not referenced by any doc snippet", + file=sys.stderr, + ) + + checked = len(doc_snippets) + if failures: + print(f"\n{failures} of {checked} doc snippet(s) out of sync.", file=sys.stderr) + return 1 + print(f"OK: {checked} doc snippet(s) match their tested source regions.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index d41b66765..3736b05d1 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -122,6 +122,15 @@ list(APPEND SOURCE_FILES Async/SenderTests.cpp ) +# DocExampleTests.cpp mirrors docs/sql-to-lightweight.md verbatim (enforced by +# scripts/check-doc-snippets.py), so its snippet regions must use the stable C++20 member-pointer +# API (e.g. &Employee::salary) that users actually write — not the reflection-only Member(...) test +# macro. That API is not expressible under the experimental C++26 reflection mode, so exclude this +# translation unit there. It is still compiled and executed against every DBMS in all other builds. +if(NOT LIGHTWEIGHT_CXX26_REFLECTION) + list(APPEND SOURCE_FILES DocExampleTests.cpp) +endif() + target_sources(LightweightTest PRIVATE ${SOURCE_FILES}) enable_testing() diff --git a/src/tests/DocExampleTests.cpp b/src/tests/DocExampleTests.cpp new file mode 100644 index 000000000..ad6c34c06 --- /dev/null +++ b/src/tests/DocExampleTests.cpp @@ -0,0 +1,675 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Tests backing the code examples in docs/sql-to-lightweight.md. +// +// Every fenced ```cpp block in that document is tagged with an +// `` marker and mirrored here by a matching +// `//! []` ... `//! []` region. The script scripts/check-doc-snippets.py +// compares the two at CI time and fails on any drift, so the documentation can +// never show code that does not compile and pass here. +// +// Guidelines for editing: +// * Keep the lines *between* the `//! [id]` markers byte-identical (modulo +// common indentation) to the corresponding doc block. +// * Put any setup the example relies on *before* the opening marker and any +// assertions *after* the closing marker, so they stay out of the doc. + +#include "Utils.hpp" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace Lightweight; +using namespace std::string_view_literals; + +// Record types must have linkage (not in an anonymous namespace) for reflection-cpp, +// so the documentation entities live in a named namespace. +namespace docex +{ + +//! [doc-schema] +struct Employee; // forward declaration for the relationship below + +struct Department +{ + static constexpr std::string_view TableName = "Departments"; + + Field id {}; + Field> name {}; + + HasMany employees {}; // one department, many employees +}; + +struct Employee +{ + static constexpr std::string_view TableName = "Employees"; + + Field id {}; + Field> firstName {}; + + // FK -> Departments.id. A HasMany and its inverse BelongsTo are matched by field + // position, so this member sits at the same index as Department::employees above. + BelongsTo<&Department::id, SqlRealName { "department_id" }, SqlNullable::Null> department {}; + + Field> lastName {}; + Field salary {}; + Field> age {}; +}; +//! [doc-schema] + +//! [doc-custom-result-struct] +struct DepartmentHeadcount +{ + SqlAnsiString<40> name; + int headcount = 0; +}; +//! [doc-custom-result-struct] + +// Record used by the conditional-WHERE example. +struct Events +{ + static constexpr std::string_view TableName = "Events"; + + Field id; + Field userId; + Field createdAt; +}; + +inline std::optional MaybeUserIdFromRequest() +{ + return 42; +} +inline std::optional MaybeSinceFromRequest() +{ + return std::nullopt; +} +inline std::optional MaybeUntilFromRequest() +{ + return std::nullopt; +} + +// Seeds two departments and five employees and returns the departments (ids populated). +struct SeededCompany +{ + Department engineering; + Department sales; +}; + +inline SeededCompany SeedCompany(DataMapper& dm) +{ + dm.CreateTables(); + + auto engineering = Department { .name = "Engineering" }; + dm.Create(engineering); + auto sales = Department { .name = "Sales" }; + dm.Create(sales); + + auto alice = + Employee { .firstName = "Alice", .department = engineering, .lastName = "Anders", .salary = 50'000, .age = 30 }; + dm.Create(alice); + auto bob = Employee { .firstName = "Bob", .department = engineering, .lastName = "Brown", .salary = 60'000, .age = 40 }; + dm.Create(bob); + auto carol = Employee { .firstName = "Carol", .department = sales, .lastName = "Clark", .salary = 55'000, .age = 35 }; + dm.Create(carol); + auto dave = Employee { .firstName = "Dave", .department = sales, .lastName = "Davis", .salary = 70'000, .age = 50 }; + dm.Create(dave); + auto erin = Employee { .firstName = "Erin", .department = engineering, .lastName = "Evans", .salary = 45'000 }; + dm.Create(erin); + + return { .engineering = engineering, .sales = sales }; +} + +} // namespace docex + +using namespace docex; + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Select", "[DocExample]") +{ + auto dm = DataMapper(); + SeedCompany(dm); + + SECTION("select all") + { + //! [doc-select-all] + // DataMapper — materialises Employee records (relations lazily configured) + auto employees = dm.Query().All(); + + // Skip relationship loading when you only need the row's own columns + auto rows = dm.Query().All(); + //! [doc-select-all] + CHECK(employees.size() == 5); + CHECK(rows.size() == 5); + } + + SECTION("select columns") + { + //! [doc-select-columns] + // Query builder + auto query = dm.FromTable("Employees").Select().Fields("firstName", "lastName").All(); + + // DataMapper — project to a subset of fields; All<>() returns just those values + auto names = dm.Query() + .All<&Employee::firstName, &Employee::lastName>(); + //! [doc-select-columns] + CHECK(query.ToSql().contains(R"("firstName")")); + CHECK(names.size() == 5); + } + + SECTION("where single") + { + //! [doc-where-single-datamapper] + // DataMapper + auto rows = dm.Query().Where(FieldNameOf<&Employee::salary>, ">=", 55'000).All(); + //! [doc-where-single-datamapper] + CHECK(rows.size() == 3); + } + + SECTION("where and") + { + //! [doc-where-and] + auto rows = dm.Query() + .Where(FieldNameOf<&Employee::salary>, ">=", 55'000) + .And() + .Where(FieldNameOf<&Employee::age>, "<", 40) + .All(); + //! [doc-where-and] + CHECK(rows.size() == 1); + } + + SECTION("where in") + { + //! [doc-where-in] + auto departmentIds = std::vector { 1, 2, 3 }; + auto rows = dm.Query().WhereIn(FieldNameOf<&Employee::department>, departmentIds).All(); + //! [doc-where-in] + CHECK(rows.size() == 5); + } + + SECTION("where not null") + { + //! [doc-where-null] + auto rows = dm.Query().WhereNotNull(FieldNameOf<&Employee::age>).All(); + // WhereNull(...) emits IS NULL; WhereNotEqual(column, value) emits "<> ?" + //! [doc-where-null] + CHECK(rows.size() == 4); + } + + SECTION("order by") + { + //! [doc-order-by] + auto rows = dm.Query().OrderBy(FieldNameOf<&Employee::lastName>, SqlResultOrdering::DESCENDING).All(); + // SqlResultOrdering::ASCENDING is the default when the ordering argument is omitted. + //! [doc-order-by] + CHECK(rows.size() == 5); + } + + SECTION("limit first") + { + //! [doc-limit-first] + // First() returns std::optional; the formatter emits LIMIT 1 or TOP 1 per DBMS. + auto highestPaid = + dm.Query().OrderBy(FieldNameOf<&Employee::salary>, SqlResultOrdering::DESCENDING).First(); + if (highestPaid) + std::println("{}", highestPaid->lastName.Value()); + //! [doc-limit-first] + REQUIRE(highestPaid.has_value()); + if (highestPaid) + CHECK(highestPaid->salary.Value() == 70'000); + } + + SECTION("pagination") + { + //! [doc-pagination-range] + auto page = dm.Query().OrderBy(FieldNameOf<&Employee::id>).Range(/*offset*/ 200, /*limit*/ 50); + //! [doc-pagination-range] + CHECK(page.size() <= 50); + } + + SECTION("distinct") + { + //! [doc-distinct] + auto query = dm.FromTable("Employees").Select().Distinct().Field("department_id").All(); + //! [doc-distinct] + CHECK(query.ToSql().contains("DISTINCT")); + } + + SECTION("count") + { + //! [doc-count-datamapper] + // DataMapper — Count() is a finalizer + auto n = dm.Query().Where(FieldNameOf<&Employee::salary>, ">=", 55'000).Count(); + //! [doc-count-datamapper] + CHECK(n == 3); + + //! [doc-count-raw] + // Raw — a single scalar result + auto stmt = SqlStatement { dm.Connection() }; + auto total = stmt.ExecuteDirectScalar(R"(SELECT COUNT(*) FROM "Employees")"); // std::optional + //! [doc-count-raw] + REQUIRE(total.has_value()); + CHECK(total == 5); + } + + SECTION("aggregate") + { + //! [doc-aggregate-max] + // Query builder — Aggregate::{Min,Max,Sum,Avg,Count} + auto query = dm.FromTable("Employees").Select().Field(Aggregate::Max("salary")).As("maxSalary").All(); + //! [doc-aggregate-max] + CHECK(query.ToSql().contains(R"(MAX("salary"))")); + } + + SECTION("group by") + { + //! [doc-group-by] + auto query = dm.FromTable("Employees") + .Select() + .Field("department_id") + .Field(Aggregate::Count("*")) + .As("headcount") + .GroupBy("department_id") + .All(); + //! [doc-group-by] + CHECK(query.ToSql().contains("GROUP BY")); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Select.Raw", "[DocExample]") +{ + auto dm = DataMapper(); + SeedCompany(dm); + + SECTION("select all raw") + { + //! [doc-select-all-raw] + // Raw SQL + auto stmt = SqlStatement { dm.Connection() }; + auto cursor = stmt.ExecuteDirect(R"(SELECT "firstName", "lastName", "salary" FROM "Employees")"); + while (cursor.FetchRow()) + { + auto firstName = cursor.GetColumn(1); // columns are 1-based + auto lastName = cursor.GetColumn(2); + auto salary = cursor.GetColumn(3); + std::println("{} {} {}", firstName, lastName, salary); + } + //! [doc-select-all-raw] + } + + SECTION("where single raw") + { + //! [doc-where-single-raw] + // Raw, prepared + parameter binding (use ? placeholders, never string concatenation) + auto stmt = SqlStatement { dm.Connection() }; + stmt.Prepare(R"(SELECT "firstName", "lastName", "salary" FROM "Employees" WHERE "salary" >= ?)"); + auto cursor = stmt.Execute(55'000); + while (cursor.FetchRow()) + { + auto firstName = cursor.GetColumn(1); + std::println("{}", firstName); + } + //! [doc-where-single-raw] + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.OptionalFilters", "[DocExample]") +{ + auto dm = DataMapper(); + + //! [doc-optional-filters] + std::optional userId = MaybeUserIdFromRequest(); + std::optional since = MaybeSinceFromRequest(); + std::optional until = MaybeUntilFromRequest(); // empty -> skipped + + auto query = dm.FromTable("Events") + .Select() + .Field("id") + .If(userId) + .ThenWhere(FullyQualifiedNameOf<&Events::userId>) + .If(since) + .ThenWhere(FullyQualifiedNameOf<&Events::createdAt>, ">=") + .If(until) + .ThenWhere(FullyQualifiedNameOf<&Events::createdAt>, "<") + .OrderBy("id") + .All(); + //! [doc-optional-filters] + CHECK(query.ToSql().contains(R"("Events"."userId")")); + CHECK_FALSE(query.ToSql().contains(R"("Events"."createdAt")")); +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Join", "[DocExample]") +{ + auto dm = DataMapper(); + SeedCompany(dm); + + SECTION("inner join datamapper") + { + //! [doc-join-inner-datamapper] + // DataMapper — the join columns are given as member pointers: <&Parent::pk, &Child::fk> + auto rows = dm.Query() + .InnerJoin<&Department::id, &Employee::department>() + .All(); + //! [doc-join-inner-datamapper] + CHECK(rows.size() == 5); + } + + SECTION("inner join builder") + { + //! [doc-join-inner-builder] + // Query builder — InnerJoin(otherTable, otherColumn, thisColumn) + auto query = dm.FromTable("Employees") + .Select() + .Fields({ "firstName"sv, "lastName"sv }, "Employees") + .Field(SqlQualifiedTableColumnName { .tableName = "Departments", .columnName = "name" }) + .InnerJoin("Departments", "id", "department_id") + .All(); + //! [doc-join-inner-builder] + CHECK(query.ToSql().contains("INNER JOIN")); + } + + SECTION("left outer join") + { + //! [doc-join-left] + auto query = dm.FromTable("Employees") + .Select() + .Fields({ "firstName"sv, "lastName"sv }, "Employees") + .LeftOuterJoin("Departments", "id", "department_id") + .All(); + //! [doc-join-left] + CHECK(query.ToSql().contains("LEFT OUTER JOIN")); + } + + SECTION("multi-condition join") + { + //! [doc-join-multi] + auto query = dm.FromTable("Table_A") + .Select() + .Fields({ "foo"sv, "bar"sv }, "Table_A") + .Fields({ "that_foo"sv, "that_id"sv }, "Table_B") + .InnerJoin("Table_B", + [](SqlJoinConditionBuilder join) { + return join.On("id", { .tableName = "Table_A", .columnName = "that_id" }) + .On("that_foo", { .tableName = "Table_A", .columnName = "foo" }); + }) + .All(); + //! [doc-join-multi] + CHECK(query.ToSql().contains("INNER JOIN")); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Insert", "[DocExample]") +{ + auto dm = DataMapper(); + dm.CreateTables(); + + SECTION("insert datamapper") + { + //! [doc-insert-datamapper] + // DataMapper — populate a record and Create it; the auto-assigned id is written back. + auto employee = Employee { .firstName = "Alice", .lastName = "Smith", .salary = 50'000 }; + dm.Create(employee); + // employee.id is now set + //! [doc-insert-datamapper] + CHECK(employee.id.Value() != 0); + } + + SECTION("insert raw") + { + //! [doc-insert-raw] + // Raw — prepare once, execute per row + auto stmt = SqlStatement { dm.Connection() }; + stmt.Prepare(R"(INSERT INTO "Employees" ("firstName", "lastName", "salary") VALUES (?, ?, ?))"); + std::ignore = stmt.Execute("Alice", "Smith", 50'000); + std::ignore = stmt.Execute("Bob", "Johnson", 60'000); + //! [doc-insert-raw] + CHECK(dm.Query().Count() == 2); + } + + SECTION("insert builder") + { + //! [doc-insert-builder] + // Query builder — captures the bound values for a later prepared execution + std::vector bound; + auto query = dm.FromTable("Employees") + .Insert(&bound) + .Set("firstName", "Alice") + .Set("lastName", "Smith") + .Set("salary", 50'000); + //! [doc-insert-builder] + CHECK(query.ToSql().contains("INSERT INTO")); + } + + SECTION("bulk insert datamapper") + { + //! [doc-bulk-insert-datamapper] + // DataMapper — one prepared statement for the whole batch (native ODBC array binding when possible) + auto people = std::vector { + Employee { .firstName = "Alice", .lastName = "Smith", .salary = 50'000 }, + Employee { .firstName = "Bob", .lastName = "Johnson", .salary = 60'000 }, + }; + dm.CreateAll(people); + //! [doc-bulk-insert-datamapper] + CHECK(dm.Query().Count() == 2); + } + + SECTION("bulk insert raw") + { + //! [doc-bulk-insert-raw] + // Raw — column-wise batch + auto stmt = SqlStatement { dm.Connection() }; + stmt.Prepare(R"(INSERT INTO "Employees" ("firstName", "lastName", "salary") VALUES (?, ?, ?))"); + auto const firstNames = std::array { "Alice"sv, "Bob"sv, "Charlie"sv }; + auto const lastNames = std::array { "Smith"sv, "Johnson"sv, "Brown"sv }; + auto const salaries = std::array { 50'000, 60'000, 70'000 }; + std::ignore = stmt.ExecuteBatch(firstNames, lastNames, salaries); + //! [doc-bulk-insert-raw] + CHECK(dm.Query().Count() == 3); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Update", "[DocExample]") +{ + auto dm = DataMapper(); + SeedCompany(dm); + auto const employees = dm.Query().All(); + REQUIRE(!employees.empty()); + auto const id = employees.front().id.Value(); + + SECTION("update datamapper") + { + //! [doc-update-datamapper] + // DataMapper — load, mutate, write back by primary key. + // Only Field<>s you changed are written; the WHERE is the primary key. + if (auto employee = dm.QuerySingle(id)) + { + employee->salary = 55'000; + dm.Update(*employee); + } + //! [doc-update-datamapper] + auto const updated = dm.QuerySingle(id); + REQUIRE(updated.has_value()); + if (updated) + CHECK(updated->salary.Value() == 55'000); + } + + SECTION("update builder") + { + //! [doc-update-builder] + // Query builder — set/where, then prepare & execute with the captured bindings + std::vector bound; + auto query = dm.FromTable("Employees").Update(&bound).Set("salary", 55'000).Where("salary", 50'000); + + auto stmt = SqlStatement { dm.Connection() }; + stmt.Prepare(query); + std::ignore = stmt.ExecuteWithVariants(bound); + //! [doc-update-builder] + CHECK(dm.Query().Where(FieldNameOf<&Employee::salary>, 55'000).Count() >= 1); + } + + SECTION("update raw") + { + //! [doc-update-raw] + // Raw + auto stmt = SqlStatement { dm.Connection() }; + stmt.Prepare(R"(UPDATE "Employees" SET "salary" = ? WHERE "salary" = ?)"); + auto cursor = stmt.Execute(55'000, 50'000); + auto changed = cursor.NumRowsAffected(); + //! [doc-update-raw] + CHECK(changed >= 1); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Delete", "[DocExample]") +{ + auto dm = DataMapper(); + SeedCompany(dm); + + SECTION("delete datamapper") + { + auto const employees = dm.Query().All(); + REQUIRE(!employees.empty()); + auto const& employee = employees.front(); + //! [doc-delete-datamapper] + // DataMapper — bulk delete by predicate + dm.Query() + .WhereIn(FieldNameOf<&Employee::department>, std::vector { 1, 2, 3 }) + .Delete(); + + // Delete a single loaded record by its primary key + dm.Delete(employee); + //! [doc-delete-datamapper] + CHECK(dm.Query().Count() == 0); + } + + SECTION("delete builder") + { + //! [doc-delete-builder] + // Query builder + auto query = dm.FromTable("Employees").Delete().WhereIn("department_id", std::vector { 1, 2, 3 }); + //! [doc-delete-builder] + CHECK(query.ToSql().contains("DELETE FROM")); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Relationships", "[DocExample]") +{ + auto dm = DataMapper(); + auto const company = SeedCompany(dm); + auto const employees = dm.Query().All(); + REQUIRE(!employees.empty()); + auto const id = employees.front().id.Value(); + auto const deptId = company.engineering.id.Value(); + + //! [doc-relationships] + if (auto employee = dm.QuerySingle(id)) + { + dm.ConfigureRelationAutoLoading(*employee); + + // BelongsTo: the parent record is fetched on demand. The FK here is nullable, so + // Record() yields an optional; Unwrap turns the optional-reference into a value. + if (auto const dept = employee->department.Record().transform(Unwrap)) + std::println("Department: {}", dept->name.Value()); + } + + // HasMany: Count() and All() on the collection + if (auto department = dm.QuerySingle(deptId)) + { + dm.ConfigureRelationAutoLoading(*department); + std::println("{} employees", department->employees.Count()); + for (auto const& emp: department->employees.All()) + std::println(" {}", emp->lastName.Value()); + } + //! [doc-relationships] + + auto reloaded = dm.QuerySingle(deptId); + REQUIRE(reloaded.has_value()); + if (reloaded) + { + dm.ConfigureRelationAutoLoading(*reloaded); + CHECK(reloaded->employees.Count() == 3); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.DDL", "[DocExample]") +{ + auto dm = DataMapper(); + + SECTION("create table datamapper") + { + //! [doc-create-table-datamapper] + // Derive the schema from the record definitions, in dependency order. + dm.CreateTables(); + //! [doc-create-table-datamapper] + CHECK(dm.Query().Count() == 0); + } + + SECTION("create table migration") + { + //! [doc-create-table-migration] + using namespace Lightweight::SqlColumnTypeDefinitions; + + auto migration = dm.Connection().Migration(); + migration.CreateTable("Appointment") + .PrimaryKeyWithAutoIncrement("id") + .RequiredColumn("date", DateTime {}) + .Column("comment", Varchar { 80 }) + .ForeignKey( + "physician_id", Guid {}, SqlForeignKeyReferenceDefinition { .tableName = "Physician", .columnName = "id" }) + .ForeignKey( + "patient_id", Guid {}, SqlForeignKeyReferenceDefinition { .tableName = "Patient", .columnName = "id" }); + auto const plan = migration.GetPlan(); + //! [doc-create-table-migration] + CHECK(!plan.ToSql().empty()); + } +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.Transactions", "[DocExample]") +{ + auto dm = DataMapper(); + dm.CreateTables(); + + //! [doc-transaction] + auto stmt = SqlStatement { dm.Connection() }; + { + // SqlTransactionMode::COMMIT auto-commits on scope exit; ROLLBACK auto-rolls back. + auto tx = SqlTransaction { stmt.Connection(), SqlTransactionMode::COMMIT }; + stmt.Prepare(R"(INSERT INTO "Employees" ("firstName", "lastName", "salary") VALUES (?, ?, ?))"); + std::ignore = stmt.Execute("Eve", "Stone", 70'000); + + tx.Commit(); // or tx.Rollback(); explicit calls are also available + } + //! [doc-transaction] + CHECK(dm.Query().Count() == 1); +} + +TEST_CASE_METHOD(SqlTestFixture, "Doc.CustomResult", "[DocExample]") +{ + auto dm = DataMapper(); + SeedCompany(dm); + + //! [doc-custom-result-usage] + auto query = dm.FromTable("Employees") + .Select() + .Field(SqlQualifiedTableColumnName { .tableName = "Departments", .columnName = "name" }) + .Field(Aggregate::Count("*")) + .As("headcount") + .InnerJoin("Departments", "id", "department_id") + .GroupBy(SqlQualifiedTableColumnName { .tableName = "Departments", .columnName = "name" }) + .All(); + + for (auto const& row: dm.Query(query)) + std::println("{}: {}", row.name, row.headcount); + //! [doc-custom-result-usage] + + CHECK(dm.Query(query).size() == 2); +}