#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:
parent
792e509b67
commit
606db5a109
10 changed files with 612 additions and 485 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
42
README.md
42
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
289
include/oatpp-authkit/repo/SchemaContract.hpp
Normal file
289
include/oatpp-authkit/repo/SchemaContract.hpp
Normal 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
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()>;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
242
test/test_schema_contract.cpp
Normal file
242
test/test_schema_contract.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue