From 606db5a109fa7492e9fcd741085861d4529a5e61 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Wed, 6 May 2026 12:14:51 +0200 Subject: [PATCH] #14 PR 0: replace imperative migration kit with declarative SchemaContract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-replace per #14: rip out PREREQ + RESHAPE_STEPS + applyDecoratorMigrations and replace with declarative DecoratorSchema (entity columns + indexes + sidecar tables). SchemaBuilder::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) --- CMakeLists.txt | 2 +- README.md | 42 ++- .../oatpp-authkit/repo/AuditLogRepository.hpp | 35 ++- include/oatpp-authkit/repo/Prereq.hpp | 174 ----------- include/oatpp-authkit/repo/SchemaContract.hpp | 289 ++++++++++++++++++ .../repo/ScopeGuardRepository.hpp | 18 +- .../oatpp-authkit/repo/TemporalRepository.hpp | 50 ++- test/CMakeLists.txt | 6 +- test/test_decorator_migrations.cpp | 239 --------------- test/test_schema_contract.cpp | 242 +++++++++++++++ 10 files changed, 612 insertions(+), 485 deletions(-) delete mode 100644 include/oatpp-authkit/repo/Prereq.hpp create mode 100644 include/oatpp-authkit/repo/SchemaContract.hpp delete mode 100644 test/test_decorator_migrations.cpp create mode 100644 test/test_schema_contract.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5740e0b..669958a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ 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 # a CMake config package so consumers do: diff --git a/README.md b/README.md index 419d9c3..32fb830 100644 --- a/README.md +++ b/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/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query::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`. | | `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(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::create(table, exec)` composes contributions into a single `CREATE TABLE` per entity table; sidecars emit separately. `SchemaContract::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` | -|-----------|----------|------------------| -| `TemporalRepository` | (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. | -| `AuditLogRepository` | `CREATE TABLE IF NOT EXISTS audit_log (…)` — fixed shape, no `{table}` placeholder. | (none) | -| `ScopeGuardRepository` | (none) | (none) | +| Decorator | Entity columns | Entity indexes | Sidecar tables | +|-----------|----------------|----------------|----------------| +| `TemporalRepository` | `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` | (none) | (none) | `audit_log (id, actor_user_id, entity_type, entity_id, op, timestamp_ms)` | +| `ScopeGuardRepository` | (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: ```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 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, - oatpp_authkit::repo::AuditLogRepository>( - "persons", probe, exec); + oatpp_authkit::repo::AuditLogRepository>::create("persons", exec); + +// At every app startup, against a populated DB: +oatpp_authkit::repo::SchemaContract< + ConcretePersonRepository, + oatpp_authkit::repo::TemporalRepository, + oatpp_authkit::repo::AuditLogRepository>::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 diff --git a/include/oatpp-authkit/repo/AuditLogRepository.hpp b/include/oatpp-authkit/repo/AuditLogRepository.hpp index 57f8523..46df195 100644 --- a/include/oatpp-authkit/repo/AuditLogRepository.hpp +++ b/include/oatpp-authkit/repo/AuditLogRepository.hpp @@ -11,7 +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-authkit/repo/SchemaContract.hpp" #include "oatpp/core/Types.hpp" @@ -65,20 +65,27 @@ public: using Clock = std::function; using SinkErrorHandler = std::function; - /// 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" - ")" + /// Declarative schema contribution (authkit#14, D-replace). + /// AuditLog touches no entity-table columns; it owns one sidecar + /// `audit_log` table fixed across consumers. + inline static constexpr ColumnSpec kAuditLogColumns[] = { + {"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"}, + }; + 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 RESHAPE_STEPS = {}; AuditLogRepository(std::shared_ptr> inner, std::shared_ptr sink, diff --git a/include/oatpp-authkit/repo/Prereq.hpp b/include/oatpp-authkit/repo/Prereq.hpp deleted file mode 100644 index 2502bb6..0000000 --- a/include/oatpp-authkit/repo/Prereq.hpp +++ /dev/null @@ -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`). -// 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(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 -#include -#include -#include -#include - -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; - -/** - * @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; - -/** - * @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; - -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 RESHAPE_STEPS`. - */ -template -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, - * AuditLogRepository>( - * "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 -void applyDecoratorMigrations(const std::string& tableName, - const SqlProbe& probe, - const SqlExec& exec, - const StepRecorder& record = {}) { - (applyDecoratorMigration(tableName, probe, exec, record), ...); -} - -} // namespace oatpp_authkit::repo - -#endif diff --git a/include/oatpp-authkit/repo/SchemaContract.hpp b/include/oatpp-authkit/repo/SchemaContract.hpp new file mode 100644 index 0000000..b9d69a1 --- /dev/null +++ b/include/oatpp-authkit/repo/SchemaContract.hpp @@ -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::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 +#include +#include +#include +#include +#include +#include + +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; + +/** + * @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; + +/** + * @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& 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& 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& acc, + std::unordered_set& 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& 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 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, + * ScopeGuardRepository, + * AuditLogRepository + * >::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 +class SchemaBuilder { +public: + static void create(const std::string& tableName, const SqlExec& exec) { + (detail::emitSidecars(Decorators::kSchema, exec), ...); + + std::vector cols; + std::unordered_set seen; + std::vector 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 +class SchemaContract { +public: + static void verify(const std::string& tableName, const SqlProbe& probe) { + (verifyOne(tableName, probe), ...); + } + +private: + template + 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 diff --git a/include/oatpp-authkit/repo/ScopeGuardRepository.hpp b/include/oatpp-authkit/repo/ScopeGuardRepository.hpp index ca3f145..0e23b39 100644 --- a/include/oatpp-authkit/repo/ScopeGuardRepository.hpp +++ b/include/oatpp-authkit/repo/ScopeGuardRepository.hpp @@ -3,7 +3,7 @@ #include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/ActorContext.hpp" -#include "oatpp-authkit/repo/Prereq.hpp" +#include "oatpp-authkit/repo/SchemaContract.hpp" #include "oatpp/core/Types.hpp" @@ -54,13 +54,15 @@ public: using Predicate = std::function&)>; using ActorAccess = std::function; - /// 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 RESHAPE_STEPS = {}; + /// Declarative schema contribution (authkit#14, D-replace). + /// ScopeGuard touches no schema — empty contributions exposed so it + /// composes cleanly into `SchemaBuilder<…>` parameter packs. + inline static constexpr DecoratorSchema kSchema = { + "ScopeGuardRepository", + nullptr, 0, + nullptr, 0, + nullptr, 0, + }; ScopeGuardRepository(std::shared_ptr> inner, Predicate isAllowed, diff --git a/include/oatpp-authkit/repo/TemporalRepository.hpp b/include/oatpp-authkit/repo/TemporalRepository.hpp index 022e91a..da16cba 100644 --- a/include/oatpp-authkit/repo/TemporalRepository.hpp +++ b/include/oatpp-authkit/repo/TemporalRepository.hpp @@ -5,7 +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-authkit/repo/SchemaContract.hpp" #include "oatpp/core/Types.hpp" @@ -89,37 +89,23 @@ 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 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__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)"} - }}; + /// Declarative schema contribution (authkit#14, D-replace). + /// Atlas owns evolution between deploys; this declares what the + /// decorator needs the live entity table to look like. The composite + /// UNIQUE index makes close-then-insert safe inside a transaction. + inline static constexpr ColumnSpec kEntityColumns[] = { + {"valid_from", "TEXT NOT NULL DEFAULT ''"}, + {"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"}, + }; + inline static constexpr IndexSpec kEntityIndexes[] = { + {"ux_{table}_entity_valid_until", true, "(entity_id, valid_until)"}, + }; + inline static constexpr DecoratorSchema kSchema = { + "TemporalRepository", + kEntityColumns, sizeof(kEntityColumns) / sizeof(kEntityColumns[0]), + kEntityIndexes, sizeof(kEntityIndexes) / sizeof(kEntityIndexes[0]), + nullptr, 0, + }; using Clock = std::function; ///< Returns milliseconds since epoch. using IdGen = std::function; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a09d420..b4ace7b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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) 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) +add_executable(test_schema_contract test_schema_contract.cpp) +target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp) +add_test(NAME schema_contract COMMAND test_schema_contract) diff --git a/test/test_decorator_migrations.cpp b/test/test_decorator_migrations.cpp deleted file mode 100644 index 128184a..0000000 --- a/test/test_decorator_migrations.cpp +++ /dev/null @@ -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 -#include -#include -#include -#include - -#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 appliedDdl; // every exec(sql) lands here - std::set knownTrue; // probe(sql) returns true iff sql is in this set - std::vector 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& 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 recorded; - applyDecoratorMigration>( - "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 recorded; - applyDecoratorMigration>( - "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 recorded; - applyDecoratorMigration>( - "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 recorded; - applyDecoratorMigration>( - "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 recorded; - applyDecoratorMigration>( - "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 recorded; - applyDecoratorMigrations< - AuditLogRepository, - TemporalRepository, - ScopeGuardRepository>( - "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, - TemporalRepository>( - "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; -} diff --git a/test/test_schema_contract.cpp b/test/test_schema_contract.cpp new file mode 100644 index 0000000..395aedf --- /dev/null +++ b/test/test_schema_contract.cpp @@ -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 +#include +#include +#include +#include + +#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 execLog; + std::set 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, + ScopeGuardRepository>::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>::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, + ScopeGuardRepository, + AuditLogRepository>::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, + 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, + ScopeGuardRepository, + AuditLogRepository>::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>::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>::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; +}