oatpp-authkit/include/oatpp-authkit/repo/SchemaContract.hpp
Uwe Schuster 606db5a109 #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>
2026-05-06 12:14:51 +02:00

289 lines
10 KiB
C++

#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