#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:
parent
f5b33a5857
commit
b5e1ea1894
8 changed files with 501 additions and 1 deletions
|
|
@ -1,5 +1,5 @@
|
|||
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
|
||||
# a CMake config package so consumers do:
|
||||
|
|
|
|||
26
README.md
26
README.md
|
|
@ -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/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/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
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include "oatpp-authkit/repo/IAuditSink.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/Prereq.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
|
|
@ -64,6 +65,21 @@ public:
|
|||
using Clock = std::function<std::int64_t()>;
|
||||
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,
|
||||
std::shared_ptr<IAuditSink> sink,
|
||||
ActorAccess currentActor,
|
||||
|
|
|
|||
174
include/oatpp-authkit/repo/Prereq.hpp
Normal file
174
include/oatpp-authkit/repo/Prereq.hpp
Normal 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
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
#include "oatpp-authkit/repo/Repository.hpp"
|
||||
#include "oatpp-authkit/repo/ActorContext.hpp"
|
||||
#include "oatpp-authkit/repo/Prereq.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
|
|
@ -53,6 +54,14 @@ public:
|
|||
using Predicate = std::function<bool(const ActorContext&, const oatpp::Object<TDto>&)>;
|
||||
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,
|
||||
Predicate isAllowed,
|
||||
ActorAccess currentActor)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
|
||||
#include "oatpp-authkit/repo/TemporalAt.hpp"
|
||||
#include "oatpp-authkit/repo/Prereq.hpp"
|
||||
|
||||
#include "oatpp/core/Types.hpp"
|
||||
|
||||
|
|
@ -71,6 +72,38 @@ public:
|
|||
*/
|
||||
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 IdGen = std::function<oatpp::String()>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
target_link_libraries(test_audit_log_repository PRIVATE oatpp::authkit oatpp::oatpp)
|
||||
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)
|
||||
|
|
|
|||
238
test/test_decorator_migrations.cpp
Normal file
238
test/test_decorator_migrations.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue