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>
289 lines
10 KiB
C++
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
|