#12: per-decorator migration kit (Prereq.hpp)

Each decorator now bundles its schema prereqs alongside its code via
DecoratorPrereq (additive CREATE-IF-NOT-EXISTS) and ReshapeStep
(non-idempotent reshape gated on 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. Database-agnostic — consumer
wires probe/exec to their DbClient. SCHEMA_MIGRATIONS_TABLE_SQL is
provided for observability; the detect-probe is the source of truth.

TemporalRepository ships add_valid_from / add_valid_until /
drop_unique_entity_id / composite_unique (UNIQUE(entity_id, valid_until)
so close-then-insert can run in a deferred-FK transaction).
AuditLogRepository ships the audit_log CREATE TABLE.
ScopeGuardRepository ships nothing — exposes empty PREREQ + zero-length
RESHAPE_STEPS so it can be listed in applyDecoratorMigrations alongside
the schema-touching decorators without SFINAE.

Closes #12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-29 21:47:03 +02:00
parent f5b33a5857
commit b5e1ea1894
8 changed files with 501 additions and 1 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14) cmake_minimum_required(VERSION 3.14)
project(oatpp-authkit VERSION 0.6.1 LANGUAGES CXX) project(oatpp-authkit VERSION 0.7.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,6 +18,32 @@ 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. |
## Decorator migrations
| Decorator | `PREREQ` | `RESHAPE_STEPS` |
|-----------|----------|------------------|
| `TemporalRepository<T>` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)` so close-then-insert can run inside a deferred-FK transaction. |
| `AuditLogRepository<T>` | `CREATE TABLE IF NOT EXISTS audit_log (…)` — fixed shape, no `{table}` placeholder. | (none) |
| `ScopeGuardRepository<T>` | (none) | (none) |
Wiring it up:
```cpp
#include "oatpp-authkit/repo/Prereq.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 */ };
oatpp_authkit::repo::applyDecoratorMigrations<
oatpp_authkit::repo::TemporalRepository<PersonDto>,
oatpp_authkit::repo::AuditLogRepository<PersonDto>>(
"persons", probe, exec);
```
Re-running on every startup is safe by construction.
## Consume via CMake ## Consume via CMake

View file

@ -11,6 +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/core/Types.hpp" #include "oatpp/core/Types.hpp"
@ -64,6 +65,21 @@ 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).
/// `{table}` is unused — the audit_log table is fixed across consumers.
static constexpr const char* DECORATOR_NAME = "AuditLogRepository";
static constexpr DecoratorPrereq PREREQ = {
"CREATE TABLE IF NOT EXISTS audit_log ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" actor_user_id TEXT,"
" entity_type TEXT NOT NULL,"
" entity_id TEXT NOT NULL,"
" op TEXT NOT NULL,"
" timestamp_ms INTEGER NOT NULL"
")"
};
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,
ActorAccess currentActor, ActorAccess currentActor,

View file

@ -0,0 +1,174 @@
#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

@ -3,6 +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/core/Types.hpp" #include "oatpp/core/Types.hpp"
@ -53,6 +54,14 @@ 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
/// schema — both PREREQ and RESHAPE_STEPS are empty. Exposed so the
/// migration runner can list ScopeGuard alongside other decorators
/// without SFINAE.
static constexpr const char* DECORATOR_NAME = "ScopeGuardRepository";
static constexpr DecoratorPrereq PREREQ = {};
static constexpr std::array<ReshapeStep, 0> RESHAPE_STEPS = {};
ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner, ScopeGuardRepository(std::shared_ptr<Repository<TDto>> inner,
Predicate isAllowed, Predicate isAllowed,
ActorAccess currentActor) ActorAccess currentActor)

View file

@ -5,6 +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/core/Types.hpp" #include "oatpp/core/Types.hpp"
@ -71,6 +72,38 @@ 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).
/// Composite-FK temporal schema: enforces uniqueness on (entity_id,
/// valid_until) so close-then-insert can run inside a transaction.
static constexpr const char* DECORATOR_NAME = "TemporalRepository";
static constexpr DecoratorPrereq PREREQ = {};
static constexpr std::array<ReshapeStep, 4> RESHAPE_STEPS = {{
{"add_valid_from",
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'",
"ALTER TABLE {table} ADD COLUMN valid_from TEXT NOT NULL DEFAULT ''"},
{"add_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'"},
{"drop_unique_entity_id",
// Detect that no plain UNIQUE(entity_id) index remains. Whether
// one was ever there is consumer-specific — common case is the
// index was auto-named "sqlite_autoindex_<table>_1" by SQLite
// 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

@ -41,3 +41,7 @@ add_test(NAME temporal_field_traits COMMAND test_temporal_field_traits)
add_executable(test_audit_log_repository test_audit_log_repository.cpp) 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)
target_link_libraries(test_decorator_migrations PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME decorator_migrations COMMAND test_decorator_migrations)

View file

@ -0,0 +1,238 @@
// 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, 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, 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;
}