#14 PR 0: replace imperative migration kit with declarative SchemaContract

D-replace per #14: rip out PREREQ + RESHAPE_STEPS + applyDecoratorMigrations
and replace with declarative DecoratorSchema (entity columns + indexes +
sidecar tables). SchemaBuilder<Decorators...>::create composes the stack
into a single CREATE TABLE per entity table; SchemaContract::verify
introspects-and-asserts at runtime so code can never run against an
under-migrated DB. Atlas (atlasgo.io) becomes the authority for schema
evolution between deploys — decorator code never runs ALTER at runtime.

- TemporalRepository contributes valid_from/valid_until + UNIQUE composite index
- AuditLogRepository contributes the audit_log sidecar table
- ScopeGuardRepository declares empty contributions for clean stacking
- 8 new tests in test_schema_contract.cpp covering compose / dedup / verify
- README updated; bumped 0.8.0 → 0.9.0

fewo-webapp does not yet call applyDecoratorMigrations, so this is a
clean cut — no consumer-side breakage. PRs 1-4 (role_templates,
user_property_permissions, user_group_permissions, users) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-05-06 12:14:51 +02:00
parent 792e509b67
commit 606db5a109
10 changed files with 612 additions and 485 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14) cmake_minimum_required(VERSION 3.14)
project(oatpp-authkit VERSION 0.8.0 LANGUAGES CXX) project(oatpp-authkit VERSION 0.9.0 LANGUAGES CXX)
# Header-only interface library — no compilation, just an include path and # Header-only interface library — no compilation, just an include path and
# a CMake config package so consumers do: # a CMake config package so consumers do:

View file

@ -18,32 +18,46 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. | | `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. | | `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. |
| `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}``Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. | | `repo/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}``Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. |
| `repo/Prereq.hpp` | Per-decorator migration kit. Each decorator that touches schema bundles its prereq SQL alongside its code: `DecoratorPrereq` for additive (`CREATE TABLE IF NOT EXISTS …`) and `ReshapeStep` for non-idempotent reshape with a `detectSql` probe. `applyDecoratorMigrations<Decorators...>(table, probe, exec, recorder)` walks the listed decorators at startup, runs every PREREQ, runs every reshape step whose probe returns false, optionally records applied steps via `oatpp_authkit_schema_migrations`. Database-agnostic — consumer wires `probe`/`exec` to whatever DbClient they use. | | `repo/SchemaContract.hpp` | Declarative schema model for the decorator stack (authkit#14). Each decorator exposes a `static constexpr DecoratorSchema kSchema` listing the columns/indexes it contributes to the entity table plus any sidecar tables it owns. `SchemaBuilder<Decorators…>::create(table, exec)` composes contributions into a single `CREATE TABLE` per entity table; sidecars emit separately. `SchemaContract<Decorators…>::verify(table, probe)` is a runtime introspect-and-assert that throws `SchemaContractViolation` if any required column or sidecar is missing. Decorator code never runs ALTER at runtime — Atlas (atlasgo.io) owns evolution between deploys; the C++ side only declares desired state and checks it. |
## Decorator migrations ## Decorator schema contributions
| Decorator | `PREREQ` | `RESHAPE_STEPS` | | Decorator | Entity columns | Entity indexes | Sidecar tables |
|-----------|----------|------------------| |-----------|----------------|----------------|----------------|
| `TemporalRepository<T>` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)`. With stable-live save semantics, no FK deferral is required; consumer-side child FKs use `ON UPDATE CASCADE` to follow `valid_until` flips on delete. | | `TemporalRepository<T>` | `valid_from TEXT NOT NULL DEFAULT ''`, `valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'` | `UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until)` | (none) |
| `AuditLogRepository<T>` | `CREATE TABLE IF NOT EXISTS audit_log (…)` — fixed shape, no `{table}` placeholder. | (none) | | `AuditLogRepository<T>` | (none) | (none) | `audit_log (id, actor_user_id, entity_type, entity_id, op, timestamp_ms)` |
| `ScopeGuardRepository<T>` | (none) | (none) | | `ScopeGuardRepository<T>` | (none) | (none) | (none) |
The concrete repo at the bottom of the stack contributes the entity_id +
business columns. Stacking is declarative; column dedup keeps duplicate
contributions safe.
Wiring it up: Wiring it up:
```cpp ```cpp
#include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp-authkit/repo/SchemaContract.hpp"
// probe: returns true iff the SQL yields ≥1 row
auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ };
auto exec = [&](const std::string& sql) { /* run DDL */ }; auto exec = [&](const std::string& sql) { /* run DDL */ };
auto probe = [&](const std::string& sql) { /* run SELECT, return bool */ };
oatpp_authkit::repo::applyDecoratorMigrations< // On a fresh DB (e.g. CI dev DB that Atlas inspects):
oatpp_authkit::repo::SchemaBuilder<
ConcretePersonRepository,
oatpp_authkit::repo::TemporalRepository<PersonDto>, oatpp_authkit::repo::TemporalRepository<PersonDto>,
oatpp_authkit::repo::AuditLogRepository<PersonDto>>( oatpp_authkit::repo::AuditLogRepository<PersonDto>>::create("persons", exec);
"persons", probe, exec);
// At every app startup, against a populated DB:
oatpp_authkit::repo::SchemaContract<
ConcretePersonRepository,
oatpp_authkit::repo::TemporalRepository<PersonDto>,
oatpp_authkit::repo::AuditLogRepository<PersonDto>>::verify("persons", probe);
``` ```
Re-running on every startup is safe by construction. Atlas wiring (out of scope for this header): point `atlas migrate diff`'s
`--dev-url` at a SQLite that `SchemaBuilder` has populated, and `--url`
at the live prod DB. Atlas emits versioned migration SQL; the deploy
pipeline applies it. The decorator code stays unchanged across schema
evolutions.
## Consume via CMake ## Consume via CMake

View file

@ -11,7 +11,7 @@
#include "oatpp-authkit/repo/IAuditSink.hpp" #include "oatpp-authkit/repo/IAuditSink.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp" #include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp/core/Types.hpp" #include "oatpp/core/Types.hpp"
@ -65,20 +65,27 @@ public:
using Clock = std::function<std::int64_t()>; using Clock = std::function<std::int64_t()>;
using SinkErrorHandler = std::function<bool(const std::exception&)>; using SinkErrorHandler = std::function<bool(const std::exception&)>;
/// Decorator-local migration kit (authkit#12). /// Declarative schema contribution (authkit#14, D-replace).
/// `{table}` is unused — the audit_log table is fixed across consumers. /// AuditLog touches no entity-table columns; it owns one sidecar
static constexpr const char* DECORATOR_NAME = "AuditLogRepository"; /// `audit_log` table fixed across consumers.
static constexpr DecoratorPrereq PREREQ = { inline static constexpr ColumnSpec kAuditLogColumns[] = {
"CREATE TABLE IF NOT EXISTS audit_log (" {"id", "INTEGER PRIMARY KEY AUTOINCREMENT"},
" id INTEGER PRIMARY KEY AUTOINCREMENT," {"actor_user_id", "TEXT"},
" actor_user_id TEXT," {"entity_type", "TEXT NOT NULL"},
" entity_type TEXT NOT NULL," {"entity_id", "TEXT NOT NULL"},
" entity_id TEXT NOT NULL," {"op", "TEXT NOT NULL"},
" op TEXT NOT NULL," {"timestamp_ms", "INTEGER NOT NULL"},
" timestamp_ms INTEGER NOT NULL" };
")" inline static constexpr SidecarTableSpec kSidecars[] = {
{"audit_log", kAuditLogColumns,
sizeof(kAuditLogColumns) / sizeof(kAuditLogColumns[0])},
};
inline static constexpr DecoratorSchema kSchema = {
"AuditLogRepository",
nullptr, 0,
nullptr, 0,
kSidecars, sizeof(kSidecars) / sizeof(kSidecars[0]),
}; };
static constexpr std::array<ReshapeStep, 0> RESHAPE_STEPS = {};
AuditLogRepository(std::shared_ptr<Repository<TDto>> inner, AuditLogRepository(std::shared_ptr<Repository<TDto>> inner,
std::shared_ptr<IAuditSink> sink, std::shared_ptr<IAuditSink> sink,

View file

@ -1,174 +0,0 @@
#ifndef OATPP_AUTHKIT_REPO_PREREQ_HPP
#define OATPP_AUTHKIT_REPO_PREREQ_HPP
// Per-decorator migration kit (authkit#12). Each decorator that touches a
// schema bundles its prereq SQL alongside its code, so the consumer's
// startup wiring is "stack the decorators, run the migrations" — no hand-
// pasted DDL, no drift between header doc-comments and the actual schema.
//
// Two migration kinds, both decorator-local, both idempotent:
//
// 1. Additive prereqs — `PREREQ` (a `DecoratorPrereq`). For decorators
// that need extra tables/indexes alongside the entity table. Pure
// `CREATE TABLE IF NOT EXISTS …` style; re-running is a no-op by
// construction.
//
// 2. Reshape migrations — `RESHAPE_STEPS` (a `std::array<ReshapeStep, N>`).
// For decorators that change the entity's own table shape. Naively
// non-idempotent (`ALTER TABLE … ADD COLUMN …` errors on second run),
// so each step ships a `detectSql` probe that returns true iff the
// step has already been applied. The runner skips applied steps.
//
// The runner is `applyDecoratorMigrations<Decorators...>(table, probe, exec)`.
// It runs every decorator's PREREQ then its RESHAPE_STEPS in declaration
// order. Both `PREREQ.sql` and `ReshapeStep.{detectSql,applySql}` may
// contain `{table}` placeholders that the runner substitutes.
#include <array>
#include <cstddef>
#include <functional>
#include <string>
#include <string_view>
namespace oatpp_authkit::repo {
/**
* @brief Bookkeeping table for applied migration steps. Optional
* the detect-probe is the source of truth, so the runner is safe even
* if this table is wiped between invocations. The schema is exposed
* for consumers that want observability ("which steps ran when").
*/
constexpr const char* SCHEMA_MIGRATIONS_TABLE_SQL =
"CREATE TABLE IF NOT EXISTS oatpp_authkit_schema_migrations ("
" decorator TEXT NOT NULL,"
" table_name TEXT NOT NULL,"
" step TEXT NOT NULL,"
" applied_at TEXT NOT NULL,"
" PRIMARY KEY (decorator, table_name, step)"
")";
/**
* @brief Additive schema prereq for one decorator. Empty `sql` means the
* decorator declares no additive prereq (it's still required to expose
* `PREREQ` so the runner doesn't need SFINAE). May contain `{table}`
* which the runner substitutes most additive prereqs name fixed tables
* (e.g. `audit_log`) and won't use the placeholder.
*/
struct DecoratorPrereq {
const char* sql{""};
};
/**
* @brief One step of a reshape migration. The runner calls `probe(detectSql)`
* first; if it returns true the step is treated as already applied and
* skipped. Otherwise `exec(applySql)` runs and the step is recorded.
*
* Both `detectSql` and `applySql` may contain `{table}` placeholders.
*
* `name` is a stable identifier it's what gets recorded in
* `oatpp_authkit_schema_migrations` and what tests assert against.
*/
struct ReshapeStep {
const char* name{""};
const char* detectSql{""};
const char* applySql{""};
};
/**
* @brief Substitutes `{table}` -> `tableName` in `sqlTemplate`. All
* occurrences are replaced. Pure string transform no SQL parsing.
*/
inline std::string instantiate(std::string_view sqlTemplate,
std::string_view tableName) {
std::string out(sqlTemplate);
static constexpr std::string_view ph = "{table}";
for (std::size_t pos = out.find(ph); pos != std::string::npos;
pos = out.find(ph, pos + tableName.size())) {
out.replace(pos, ph.size(), tableName);
}
return out;
}
/**
* @brief Runs a single statement against the consumer's database. The
* consumer wires this to whatever DbClient / ORM they use the kit stays
* database-agnostic by routing all DDL through these two callbacks.
*/
using SqlExec = std::function<void(const std::string& sql)>;
/**
* @brief Returns true iff the given query yields at least one row. Used
* by the runner to skip already-applied reshape steps.
*/
using SqlProbe = std::function<bool(const std::string& sql)>;
/**
* @brief Optional callback invoked for every step the runner actually
* applies (skipped steps are not reported). Consumers wire this to an
* INSERT into `oatpp_authkit_schema_migrations` when they want
* observability.
*/
using StepRecorder = std::function<void(const char* decorator,
const std::string& table,
const char* step)>;
namespace detail {
inline bool nonEmpty(const char* s) { return s && s[0] != '\0'; }
} // namespace detail
/**
* @brief Applies one decorator's migrations (PREREQ + RESHAPE_STEPS) to
* `tableName`. Idempotent: PREREQ is `CREATE IF NOT EXISTS`-style by
* convention, RESHAPE_STEPS are gated on `probe(detectSql)`.
*
* @tparam Decorator Type exposing `static constexpr const char* DECORATOR_NAME`,
* `static constexpr DecoratorPrereq PREREQ`, and
* `static constexpr std::array<ReshapeStep, N> RESHAPE_STEPS`.
*/
template <typename Decorator>
void applyDecoratorMigration(const std::string& tableName,
const SqlProbe& probe,
const SqlExec& exec,
const StepRecorder& record = {}) {
if (detail::nonEmpty(Decorator::PREREQ.sql)) {
exec(instantiate(Decorator::PREREQ.sql, tableName));
if (record) record(Decorator::DECORATOR_NAME, tableName, "PREREQ_SQL");
}
for (const auto& step : Decorator::RESHAPE_STEPS) {
if (!detail::nonEmpty(step.applySql)) continue;
if (detail::nonEmpty(step.detectSql) &&
probe(instantiate(step.detectSql, tableName))) {
continue; // already applied
}
exec(instantiate(step.applySql, tableName));
if (record) record(Decorator::DECORATOR_NAME, tableName, step.name);
}
}
/**
* @brief Variadic convenience: walks every listed decorator and runs its
* migrations against the same `tableName`. Order matches the parameter
* pack. Typical use:
*
* @code
* applyDecoratorMigrations<
* TemporalRepository<PersonDto>,
* AuditLogRepository<PersonDto>>(
* "persons", probe, exec, recorder);
* @endcode
*
* `ScopeGuardRepository` and other decorators with no schema needs are
* fine to include they expose an empty PREREQ and zero-length
* RESHAPE_STEPS, which the runner skips cleanly.
*/
template <typename... Decorators>
void applyDecoratorMigrations(const std::string& tableName,
const SqlProbe& probe,
const SqlExec& exec,
const StepRecorder& record = {}) {
(applyDecoratorMigration<Decorators>(tableName, probe, exec, record), ...);
}
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,289 @@
#ifndef OATPP_AUTHKIT_REPO_SCHEMA_CONTRACT_HPP
#define OATPP_AUTHKIT_REPO_SCHEMA_CONTRACT_HPP
// Declarative schema model for the decorator stack (authkit#14).
//
// Each decorator (and the concrete repo at the bottom of the stack)
// exposes a `static constexpr DecoratorSchema kSchema = {…}` listing the
// columns/indexes it contributes to the entity table plus any sidecar
// tables it owns. A `SchemaBuilder<Decorators…>::create(table, exec)`
// composes all contributions into a single `CREATE TABLE` per entity
// table; sidecar tables are emitted separately.
//
// Schema authority lives **in the C++ decorator code**. Atlas
// (atlasgo.io) consumes the result by inspecting an empty SQLite that the
// builder has populated and treats that as the desired state for diff/
// apply against running prod databases. Decorator code never runs ALTER
// at runtime; `SchemaContract::verify` only introspects-and-asserts.
//
// This replaces the imperative PREREQ + RESHAPE_STEPS kit from
// authkit#12 (D-replace per the issue thread).
#include <cstddef>
#include <functional>
#include <stdexcept>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief One column a decorator contributes to a table.
*
* `type` is the full SQL type fragment as it would appear in
* `CREATE TABLE` after the column name e.g. `"TEXT NOT NULL"` or
* `"TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"`. Kept as a single
* string so consumers don't have to decompose constraints; Atlas's
* inspector parses it once on the round-trip.
*/
struct ColumnSpec {
const char* name;
const char* type;
};
/**
* @brief One index a decorator contributes to the entity table.
*
* `nameTemplate` may contain `{table}` substituted with the live table
* name at build time. The decorator-local convention is to prefix
* decorator-owned indexes (e.g. `ux_{table}_`) so multiple decorators
* stacking on the same table don't collide.
*/
struct IndexSpec {
const char* nameTemplate;
bool unique;
const char* columns; ///< e.g. `"(entity_id, valid_until)"`
};
/**
* @brief A sidecar table owned by one decorator (e.g. `audit_log`).
*
* Sidecar tables have fixed names the placeholder substitution that
* applies to entity-table contributions does not apply here. Multiple
* decorators referencing the same sidecar (e.g. two repos auditing
* through the same `audit_log`) is fine `SchemaBuilder::create` emits
* each sidecar with `CREATE TABLE IF NOT EXISTS`, idempotent.
*/
struct SidecarTableSpec {
const char* name;
const ColumnSpec* columns;
std::size_t numColumns;
};
/**
* @brief What one decorator (or concrete repo) contributes to the schema.
*
* The concrete repo at the bottom of the stack typically contributes
* the entity_id + business columns; decorators above contribute their
* cross-cutting columns (temporal triple, scope columns, ).
*/
struct DecoratorSchema {
const char* decoratorName;
const ColumnSpec* entityColumns;
std::size_t numEntityColumns;
const IndexSpec* entityIndexes;
std::size_t numEntityIndexes;
const SidecarTableSpec* sidecarTables;
std::size_t numSidecarTables;
};
/**
* @brief Wraps a callable that runs a single SQL statement against the
* consumer's database. Decoupled from any specific ORM so authkit stays
* portable.
*/
using SqlExec = std::function<void(const std::string& sql)>;
/**
* @brief Wraps a callable that returns true iff the query yields 1 row.
* Used by `SchemaContract::verify` to introspect column presence.
*/
using SqlProbe = std::function<bool(const std::string& sql)>;
/**
* @brief Substitutes `{table}` -> `tableName` in `sqlTemplate`.
* Pure string replacement no SQL parsing.
*/
inline std::string instantiate(const std::string& sqlTemplate,
const std::string& tableName) {
std::string out(sqlTemplate);
static const std::string ph = "{table}";
for (std::size_t pos = out.find(ph); pos != std::string::npos;
pos = out.find(ph, pos + tableName.size())) {
out.replace(pos, ph.size(), tableName);
}
return out;
}
/**
* @brief Thrown by `SchemaContract::verify` when a decorator's required
* columns are absent from the live DB. Catchers (typically the consumer's
* startup wiring) translate to a fatal startup error.
*/
class SchemaContractViolation : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
namespace detail {
inline std::string emitColumnList(const std::vector<ColumnSpec>& cols) {
std::string out;
bool first = true;
for (const auto& c : cols) {
if (!first) out += ",\n ";
first = false;
out += c.name;
out += " ";
out += c.type;
}
return out;
}
inline std::string emitCreateTable(const std::string& tableName,
const std::vector<ColumnSpec>& cols) {
return "CREATE TABLE IF NOT EXISTS " + tableName + " (\n "
+ emitColumnList(cols) + "\n)";
}
inline std::string emitCreateIndex(const std::string& tableName,
const IndexSpec& idx) {
std::string name = instantiate(idx.nameTemplate, tableName);
std::string out = "CREATE ";
if (idx.unique) out += "UNIQUE ";
out += "INDEX IF NOT EXISTS " + name + " ON " + tableName + " " + idx.columns;
return out;
}
inline void appendUniqueColumns(std::vector<ColumnSpec>& acc,
std::unordered_set<std::string>& seen,
const ColumnSpec* src,
std::size_t n) {
for (std::size_t i = 0; i < n; ++i) {
std::string key(src[i].name ? src[i].name : "");
if (seen.insert(key).second) acc.push_back(src[i]);
}
}
inline void appendIndexes(std::vector<IndexSpec>& acc,
const IndexSpec* src,
std::size_t n) {
for (std::size_t i = 0; i < n; ++i) acc.push_back(src[i]);
}
inline void emitSidecars(const DecoratorSchema& s, const SqlExec& exec) {
for (std::size_t i = 0; i < s.numSidecarTables; ++i) {
const auto& t = s.sidecarTables[i];
std::vector<ColumnSpec> cols(t.columns, t.columns + t.numColumns);
exec(emitCreateTable(t.name, cols));
}
}
} // namespace detail
/**
* @brief Composes every decorator's contributions into a single CREATE
* per entity table + sidecar CREATEs.
*
* The parameter pack is the full repository stack, bottom-up. Typical use:
*
* @code
* SchemaBuilder<
* ConcretePersonRepository,
* TemporalRepository<PersonDto>,
* ScopeGuardRepository<PersonDto>,
* AuditLogRepository<PersonDto>
* >::create("persons", exec);
* @endcode
*
* The builder runs in two passes:
*
* 1. **Sidecars first**, so any cross-table FK from the entity table
* (none today, but future-proofing) can resolve.
* 2. **Entity table** with the union of all `entityColumns`. Columns are
* deduplicated by name on first appearance; later decorators
* contributing the same column name are silently skipped (current
* behavior). Indexes are emitted in declaration order.
*/
template <typename... Decorators>
class SchemaBuilder {
public:
static void create(const std::string& tableName, const SqlExec& exec) {
(detail::emitSidecars(Decorators::kSchema, exec), ...);
std::vector<ColumnSpec> cols;
std::unordered_set<std::string> seen;
std::vector<IndexSpec> idxs;
(detail::appendUniqueColumns(cols, seen,
Decorators::kSchema.entityColumns,
Decorators::kSchema.numEntityColumns), ...);
(detail::appendIndexes(idxs,
Decorators::kSchema.entityIndexes,
Decorators::kSchema.numEntityIndexes), ...);
if (!cols.empty()) {
exec(detail::emitCreateTable(tableName, cols));
}
for (const auto& idx : idxs) {
exec(detail::emitCreateIndex(tableName, idx));
}
}
};
/**
* @brief Runtime introspect-and-assert. On startup, walks the decorator
* stack and probes the live DB for every required column. Throws
* `SchemaContractViolation` if any are missing guarantees the running
* code can never operate against an under-migrated DB.
*
* Atlas owns the migration that put the columns there; this only checks
* that the migration ran. SQLite-specific probe SQL is used by default
* (`pragma_table_info`); consumers on other engines wire their own probe
* via the `SqlProbe` callback signature, which receives a fully-formed
* `SELECT 1 FROM pragma_table_info('table') WHERE name='col'` query.
*/
template <typename... Decorators>
class SchemaContract {
public:
static void verify(const std::string& tableName, const SqlProbe& probe) {
(verifyOne<Decorators>(tableName, probe), ...);
}
private:
template <typename D>
static void verifyOne(const std::string& tableName, const SqlProbe& probe) {
const auto& s = D::kSchema;
for (std::size_t i = 0; i < s.numEntityColumns; ++i) {
const char* col = s.entityColumns[i].name;
if (!col || !*col) continue;
std::string q = "SELECT 1 FROM pragma_table_info('" + tableName +
"') WHERE name='" + col + "'";
if (!probe(q)) {
throw SchemaContractViolation(
std::string("schema contract violation: decorator '") +
s.decoratorName + "' requires column '" + col +
"' on table '" + tableName + "', but it is missing");
}
}
for (std::size_t i = 0; i < s.numSidecarTables; ++i) {
const auto& t = s.sidecarTables[i];
std::string q = std::string("SELECT 1 FROM sqlite_master WHERE "
"type='table' AND name='") + t.name + "'";
if (!probe(q)) {
throw SchemaContractViolation(
std::string("schema contract violation: decorator '") +
s.decoratorName + "' requires sidecar table '" + t.name +
"', but it is missing");
}
}
}
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -3,7 +3,7 @@
#include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp" #include "oatpp-authkit/repo/ActorContext.hpp"
#include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp/core/Types.hpp" #include "oatpp/core/Types.hpp"
@ -54,13 +54,15 @@ public:
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>; using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
using ActorAccess = std::function<ActorContext()>; using ActorAccess = std::function<ActorContext()>;
/// Decorator-local migration kit (authkit#12). ScopeGuard touches no /// Declarative schema contribution (authkit#14, D-replace).
/// schema — both PREREQ and RESHAPE_STEPS are empty. Exposed so the /// ScopeGuard touches no schema — empty contributions exposed so it
/// migration runner can list ScopeGuard alongside other decorators /// composes cleanly into `SchemaBuilder<…>` parameter packs.
/// without SFINAE. inline static constexpr DecoratorSchema kSchema = {
static constexpr const char* DECORATOR_NAME = "ScopeGuardRepository"; "ScopeGuardRepository",
static constexpr DecoratorPrereq PREREQ = {}; nullptr, 0,
static constexpr std::array<ReshapeStep, 0> RESHAPE_STEPS = {}; nullptr, 0,
nullptr, 0,
};
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner, ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
Predicate isAllowed, Predicate isAllowed,

View file

@ -5,7 +5,7 @@
#include "oatpp-authkit/repo/IHistoryRepository.hpp" #include "oatpp-authkit/repo/IHistoryRepository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp" #include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/Prereq.hpp" #include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp/core/Types.hpp" #include "oatpp/core/Types.hpp"
@ -89,37 +89,23 @@ public:
*/ */
static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z"; static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z";
/// Decorator-local migration kit (authkit#12). /// Declarative schema contribution (authkit#14, D-replace).
/// Composite-FK temporal schema: enforces uniqueness on (entity_id, /// Atlas owns evolution between deploys; this declares what the
/// valid_until) so close-then-insert can run inside a transaction. /// decorator needs the live entity table to look like. The composite
static constexpr const char* DECORATOR_NAME = "TemporalRepository"; /// UNIQUE index makes close-then-insert safe inside a transaction.
static constexpr DecoratorPrereq PREREQ = {}; inline static constexpr ColumnSpec kEntityColumns[] = {
static constexpr std::array<ReshapeStep, 4> RESHAPE_STEPS = {{ {"valid_from", "TEXT NOT NULL DEFAULT ''"},
{"add_valid_from", {"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", };
"ALTER TABLE {table} ADD COLUMN valid_from TEXT NOT NULL DEFAULT ''"}, inline static constexpr IndexSpec kEntityIndexes[] = {
{"add_valid_until", {"ux_{table}_entity_valid_until", true, "(entity_id, valid_until)"},
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'", };
"ALTER TABLE {table} ADD COLUMN valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"}, inline static constexpr DecoratorSchema kSchema = {
{"drop_unique_entity_id", "TemporalRepository",
// Detect that no plain UNIQUE(entity_id) index remains. Whether kEntityColumns, sizeof(kEntityColumns) / sizeof(kEntityColumns[0]),
// one was ever there is consumer-specific — common case is the kEntityIndexes, sizeof(kEntityIndexes) / sizeof(kEntityIndexes[0]),
// index was auto-named "sqlite_autoindex_<table>_1" by SQLite nullptr, 0,
// for an inline `entity_id TEXT UNIQUE`. Detect by checking that };
// no index named `ux_{table}_entity_only` exists *and* that the
// composite index (next step) hasn't been created yet — once the
// composite is in place this step's detect probe must pass too.
"SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'",
// No-op apply; reshape is owned by the consumer's schema. The
// step exists as a hook for consumers that want to drop a
// legacy unique index before composite_unique runs. Override at
// schema-load time if needed; default is noop on systems where
// the original schema didn't carry a UNIQUE(entity_id).
"SELECT 1"},
{"composite_unique",
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'",
"CREATE UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until)"}
}};
using Clock = std::function<int64_t()>; ///< Returns milliseconds since epoch. using Clock = std::function<int64_t()>; ///< Returns milliseconds since epoch.
using IdGen = std::function<oatpp::String()>; using IdGen = std::function<oatpp::String()>;

View file

@ -42,6 +42,6 @@ add_executable(test_audit_log_repository test_audit_log_repository.cpp)
target_link_libraries(test_audit_log_repository PRIVATE oatpp::authkit oatpp::oatpp) target_link_libraries(test_audit_log_repository PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME audit_log_repository COMMAND test_audit_log_repository) add_test(NAME audit_log_repository COMMAND test_audit_log_repository)
add_executable(test_decorator_migrations test_decorator_migrations.cpp) add_executable(test_schema_contract test_schema_contract.cpp)
target_link_libraries(test_decorator_migrations PRIVATE oatpp::authkit oatpp::oatpp) target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME decorator_migrations COMMAND test_decorator_migrations) add_test(NAME schema_contract COMMAND test_schema_contract)

View file

@ -1,239 +0,0 @@
// Tests for authkit#12 — per-decorator migration kit.
//
// Verifies the runner against a tiny in-memory "schema" (a set of applied
// step names + a boolean per "table-has-column" probe). No real SQL
// engine — the kit's contract is to call `probe`/`exec` in the right
// shape, and that's what we test.
//
// Coverage:
// - PREREQ runs once per applyDecoratorMigration call
// - RESHAPE_STEPS skip when probe returns true (already applied)
// - RESHAPE_STEPS apply when probe returns false (fresh)
// - Re-running on a fully-applied schema is a no-op
// - Partial-state recovery: probes mark some steps as applied, runner
// applies only the rest
// - {table} placeholder substitution
// - StepRecorder fires for every applied step, never for skipped
// - ScopeGuardRepository's empty migrations are a clean no-op
#include "oatpp-authkit/repo/Prereq.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/AuditLogRepository.hpp"
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cassert>
#include <cstdio>
#include <set>
#include <string>
#include <vector>
#define REQUIRE(cond) do { \
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class TestDto : public oatpp::DTO {
DTO_INIT(TestDto, DTO)
DTO_FIELD(String, id); // per-row PK
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, name);
};
} // namespace
#include OATPP_CODEGEN_END(DTO)
OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, id, entity_id, valid_from, valid_until)
namespace {
using namespace oatpp_authkit::repo;
// Tiny fake schema for the runner to talk to.
struct FakeDb {
std::set<std::string> appliedDdl; // every exec(sql) lands here
std::set<std::string> knownTrue; // probe(sql) returns true iff sql is in this set
std::vector<std::string> execLog; // ordered exec history
SqlExec exec() {
return [this](const std::string& sql) {
appliedDdl.insert(sql);
execLog.push_back(sql);
};
}
SqlProbe probe() {
return [this](const std::string& sql) {
return knownTrue.count(sql) > 0;
};
}
};
struct RecordedStep {
std::string decorator;
std::string table;
std::string step;
};
StepRecorder makeRecorder(std::vector<RecordedStep>& out) {
return [&out](const char* d, const std::string& t, const char* s) {
out.push_back({d, t, s});
};
}
// Test 1: instantiate substitutes {table} everywhere.
void test_instantiate() {
REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons");
REQUIRE(instantiate("ALTER TABLE {table} ADD COLUMN x; CREATE INDEX i ON {table}(y)",
"addr") == "ALTER TABLE addr ADD COLUMN x; CREATE INDEX i ON addr(y)");
REQUIRE(instantiate("no placeholder here", "tbl") == "no placeholder here");
REQUIRE(instantiate("", "tbl") == "");
}
// Test 2: AuditLog has PREREQ, no RESHAPE — fresh apply records exactly one step.
void test_audit_fresh_apply() {
FakeDb db;
std::vector<RecordedStep> recorded;
applyDecoratorMigration<AuditLogRepository<TestDto>>(
"ignored", db.probe(), db.exec(), makeRecorder(recorded));
REQUIRE(db.execLog.size() == 1);
REQUIRE(db.execLog[0].find("CREATE TABLE IF NOT EXISTS audit_log") != std::string::npos);
REQUIRE(recorded.size() == 1);
REQUIRE(recorded[0].decorator == std::string("AuditLogRepository"));
REQUIRE(recorded[0].step == std::string("PREREQ_SQL"));
}
// Test 3: ScopeGuard has neither — runner is a no-op.
void test_scopeguard_noop() {
FakeDb db;
std::vector<RecordedStep> recorded;
applyDecoratorMigration<ScopeGuardRepository<TestDto>>(
"persons", db.probe(), db.exec(), makeRecorder(recorded));
REQUIRE(db.execLog.empty());
REQUIRE(recorded.empty());
}
// Test 4: TemporalRepository fresh apply — probe returns false for every
// detect, every reshape step runs, recorder fires per step.
void test_temporal_fresh_apply() {
FakeDb db;
std::vector<RecordedStep> recorded;
applyDecoratorMigration<TemporalRepository<TestDto>>(
"persons", db.probe(), db.exec(), makeRecorder(recorded));
// Four reshape steps, all executed (drop_unique_entity_id has a noop
// apply but still counts as executed).
REQUIRE(db.execLog.size() == 4);
REQUIRE(recorded.size() == 4);
REQUIRE(recorded[0].step == std::string("add_valid_from"));
REQUIRE(recorded[1].step == std::string("add_valid_until"));
REQUIRE(recorded[2].step == std::string("drop_unique_entity_id"));
REQUIRE(recorded[3].step == std::string("composite_unique"));
// {table} substituted in apply SQL.
REQUIRE(db.execLog[0].find("ALTER TABLE persons") != std::string::npos);
REQUIRE(db.execLog[3].find("ON persons(entity_id, valid_until)") != std::string::npos);
}
// Test 5: re-apply on already-shaped table is fully no-op.
void test_temporal_reapply_noop() {
FakeDb db;
// Pre-populate probe truth set with every detect query — every step
// is "already applied".
db.knownTrue.insert(instantiate(
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons"));
db.knownTrue.insert(instantiate(
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'", "persons"));
db.knownTrue.insert(instantiate(
"SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'", "persons"));
db.knownTrue.insert(instantiate(
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'", "persons"));
std::vector<RecordedStep> recorded;
applyDecoratorMigration<TemporalRepository<TestDto>>(
"persons", db.probe(), db.exec(), makeRecorder(recorded));
REQUIRE(db.execLog.empty());
REQUIRE(recorded.empty());
}
// Test 6: partial state recovery — only the missing step runs.
void test_temporal_partial_recovery() {
FakeDb db;
// valid_from already exists, valid_until doesn't, composite index doesn't.
db.knownTrue.insert(instantiate(
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'", "persons"));
std::vector<RecordedStep> recorded;
applyDecoratorMigration<TemporalRepository<TestDto>>(
"persons", db.probe(), db.exec(), makeRecorder(recorded));
REQUIRE(db.execLog.size() == 3); // skipped add_valid_from, ran the rest
REQUIRE(recorded.size() == 3);
REQUIRE(recorded[0].step == std::string("add_valid_until"));
REQUIRE(recorded[1].step == std::string("drop_unique_entity_id"));
REQUIRE(recorded[2].step == std::string("composite_unique"));
}
// Test 7: variadic stack runs every decorator's migrations in order.
void test_stack_runs_in_order() {
FakeDb db;
std::vector<RecordedStep> recorded;
applyDecoratorMigrations<
AuditLogRepository<TestDto>,
TemporalRepository<TestDto>,
ScopeGuardRepository<TestDto>>(
"persons", db.probe(), db.exec(), makeRecorder(recorded));
// Audit (1) + Temporal (4) + ScopeGuard (0) = 5
REQUIRE(recorded.size() == 5);
REQUIRE(recorded[0].decorator == std::string("AuditLogRepository"));
REQUIRE(recorded[1].decorator == std::string("TemporalRepository"));
REQUIRE(recorded[4].decorator == std::string("TemporalRepository"));
// ScopeGuard contributed zero — never appears in the log.
for (const auto& r : recorded) {
REQUIRE(r.decorator != std::string("ScopeGuardRepository"));
}
}
// Test 8: schema_migrations table SQL is a CREATE IF NOT EXISTS (idempotent
// by construction). Asserting the literal so consumers can rely on the
// column shape.
void test_schema_migrations_table_sql() {
std::string sql(SCHEMA_MIGRATIONS_TABLE_SQL);
REQUIRE(sql.find("CREATE TABLE IF NOT EXISTS oatpp_authkit_schema_migrations") != std::string::npos);
REQUIRE(sql.find("decorator") != std::string::npos);
REQUIRE(sql.find("table_name") != std::string::npos);
REQUIRE(sql.find("step") != std::string::npos);
REQUIRE(sql.find("applied_at") != std::string::npos);
REQUIRE(sql.find("PRIMARY KEY (decorator, table_name, step)") != std::string::npos);
}
// Test 9: missing recorder is fine (default-constructed callable is nullable).
void test_recorder_optional() {
FakeDb db;
applyDecoratorMigrations<
AuditLogRepository<TestDto>,
TemporalRepository<TestDto>>(
"persons", db.probe(), db.exec()); // no recorder
REQUIRE(db.execLog.size() == 5);
}
} // namespace
int main() {
test_instantiate();
test_audit_fresh_apply();
test_scopeguard_noop();
test_temporal_fresh_apply();
test_temporal_reapply_noop();
test_temporal_partial_recovery();
test_stack_runs_in_order();
test_schema_migrations_table_sql();
test_recorder_optional();
std::printf("test_decorator_migrations: OK\n");
return 0;
}

View file

@ -0,0 +1,242 @@
// Tests for authkit#14 — declarative schema contract (D-replace).
//
// Verifies SchemaBuilder composes decorator contributions into a single
// CREATE TABLE per entity, sidecar tables emit separately, and
// SchemaContract::verify catches missing columns/tables.
//
// No real SQL engine — `exec` collects emitted DDL strings and `probe`
// is driven by a fake "known-rows" set.
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/AuditLogRepository.hpp"
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cassert>
#include <cstdio>
#include <set>
#include <string>
#include <vector>
#define REQUIRE(cond) do { \
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
class TestDto : public oatpp::DTO {
DTO_INIT(TestDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from);
DTO_FIELD(String, valid_until);
DTO_FIELD(String, name);
};
} // namespace
#include OATPP_CODEGEN_END(DTO)
OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, id, entity_id, valid_from, valid_until)
namespace {
using namespace oatpp_authkit::repo;
// A minimal "concrete repo" stand-in that contributes the entity_id +
// id + name columns. Concrete repos in real consumers (e.g. fewo's
// ConcretePersonRepository) would expose kSchema the same way.
class TestConcreteRepo {
public:
inline static constexpr ColumnSpec kColumns[] = {
{"id", "INTEGER PRIMARY KEY AUTOINCREMENT"},
{"entity_id", "TEXT NOT NULL"},
{"name", "TEXT NOT NULL"},
};
inline static constexpr DecoratorSchema kSchema = {
"TestConcreteRepo",
kColumns, sizeof(kColumns) / sizeof(kColumns[0]),
nullptr, 0,
nullptr, 0,
};
};
struct FakeDb {
std::vector<std::string> execLog;
std::set<std::string> knownTrue;
SqlExec exec() { return [this](const std::string& s){ execLog.push_back(s); }; }
SqlProbe probe() { return [this](const std::string& s){ return knownTrue.count(s) > 0; }; }
};
bool contains(const std::string& haystack, const std::string& needle) {
return haystack.find(needle) != std::string::npos;
}
// Test 1: instantiate substitutes {table}.
void test_instantiate() {
REQUIRE(instantiate("SELECT * FROM {table}", "persons") == "SELECT * FROM persons");
REQUIRE(instantiate("ix_{table}_a_{table}_b", "p") == "ix_p_a_p_b");
REQUIRE(instantiate("no placeholder", "tbl") == "no placeholder");
}
// Test 2: SchemaBuilder emits one CREATE per entity table with all
// composed columns and a CREATE INDEX per index spec.
void test_builder_composes_entity_table() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
TemporalRepository<TestDto>,
ScopeGuardRepository<TestDto>>::create("persons", db.exec());
// No sidecars from this stack → one CREATE TABLE + one CREATE INDEX.
REQUIRE(db.execLog.size() == 2);
const auto& table = db.execLog[0];
REQUIRE(contains(table, "CREATE TABLE IF NOT EXISTS persons"));
REQUIRE(contains(table, "id INTEGER PRIMARY KEY AUTOINCREMENT"));
REQUIRE(contains(table, "entity_id TEXT NOT NULL"));
REQUIRE(contains(table, "name TEXT NOT NULL"));
REQUIRE(contains(table, "valid_from"));
REQUIRE(contains(table, "valid_until"));
const auto& idx = db.execLog[1];
REQUIRE(contains(idx, "CREATE UNIQUE INDEX IF NOT EXISTS ux_persons_entity_valid_until"));
REQUIRE(contains(idx, "ON persons (entity_id, valid_until)"));
}
// Test 3: AuditLog contributes a sidecar table; entity table is unaffected.
void test_builder_emits_sidecar() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
AuditLogRepository<TestDto>>::create("persons", db.exec());
// sidecar (audit_log) emitted first, then entity table.
REQUIRE(db.execLog.size() == 2);
REQUIRE(contains(db.execLog[0], "CREATE TABLE IF NOT EXISTS audit_log"));
REQUIRE(contains(db.execLog[0], "actor_user_id TEXT"));
REQUIRE(contains(db.execLog[0], "timestamp_ms INTEGER NOT NULL"));
REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons"));
// AuditLog adds nothing to the entity table.
REQUIRE(!contains(db.execLog[1], "actor_user_id"));
}
// Test 4: ScopeGuard contributes nothing; full stack emits sidecars from
// AuditLog + entity table with Temporal columns + temporal index.
void test_builder_full_stack() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
TemporalRepository<TestDto>,
ScopeGuardRepository<TestDto>,
AuditLogRepository<TestDto>>::create("persons", db.exec());
// audit_log sidecar + persons table + temporal index = 3
REQUIRE(db.execLog.size() == 3);
REQUIRE(contains(db.execLog[0], "audit_log"));
REQUIRE(contains(db.execLog[1], "CREATE TABLE IF NOT EXISTS persons"));
REQUIRE(contains(db.execLog[1], "valid_until"));
REQUIRE(contains(db.execLog[2], "ux_persons_entity_valid_until"));
}
// Defined at namespace scope: local classes can't carry static data members.
struct DupRepo {
inline static constexpr ColumnSpec kCols[] = {
{"valid_from", "TEXT NOT NULL DEFAULT 'override'"},
};
inline static constexpr DecoratorSchema kSchema = {
"DupRepo",
kCols, sizeof(kCols)/sizeof(kCols[0]),
nullptr, 0, nullptr, 0,
};
};
// Test 5: column dedup — if two layers contribute the same column name,
// first wins, no duplicates in the CREATE.
void test_builder_dedups_columns() {
FakeDb db;
SchemaBuilder<
TestConcreteRepo,
TemporalRepository<TestDto>,
DupRepo>::create("persons", db.exec());
// The DupRepo's contribution is silently skipped (Temporal got there first).
REQUIRE(db.execLog.size() == 2);
REQUIRE(!contains(db.execLog[0], "DEFAULT 'override'"));
}
// Test 6: SchemaContract::verify passes when every column is present.
void test_verify_pass() {
FakeDb db;
// Mark every required column as present.
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_from'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'");
db.knownTrue.insert("SELECT 1 FROM sqlite_master WHERE type='table' AND name='audit_log'");
// Should not throw.
SchemaContract<
TestConcreteRepo,
TemporalRepository<TestDto>,
ScopeGuardRepository<TestDto>,
AuditLogRepository<TestDto>>::verify("persons", db.probe());
}
// Test 7: SchemaContract::verify throws when a required column is missing.
void test_verify_throws_on_missing_column() {
FakeDb db;
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='entity_id'");
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='name'");
// valid_from missing on purpose.
db.knownTrue.insert("SELECT 1 FROM pragma_table_info('persons') WHERE name='valid_until'");
bool threw = false;
try {
SchemaContract<
TestConcreteRepo,
TemporalRepository<TestDto>>::verify("persons", db.probe());
} catch (const SchemaContractViolation& e) {
threw = true;
REQUIRE(contains(e.what(), "valid_from"));
REQUIRE(contains(e.what(), "TemporalRepository"));
}
REQUIRE(threw);
}
// Test 8: SchemaContract::verify throws when a sidecar is missing.
void test_verify_throws_on_missing_sidecar() {
FakeDb db;
// No audit_log table registered.
bool threw = false;
try {
SchemaContract<AuditLogRepository<TestDto>>::verify("persons", db.probe());
} catch (const SchemaContractViolation& e) {
threw = true;
REQUIRE(contains(e.what(), "audit_log"));
REQUIRE(contains(e.what(), "AuditLogRepository"));
}
REQUIRE(threw);
}
} // namespace
int main() {
test_instantiate();
test_builder_composes_entity_table();
test_builder_emits_sidecar();
test_builder_full_stack();
test_builder_dedups_columns();
test_verify_pass();
test_verify_throws_on_missing_column();
test_verify_throws_on_missing_sidecar();
std::printf("test_schema_contract: OK\n");
return 0;
}