oatpp-authkit/include/oatpp-authkit/repo/TemporalRepository.hpp
Uwe Schuster b5e1ea1894 #12: per-decorator migration kit (Prereq.hpp)
Each decorator now bundles its schema prereqs alongside its code via
DecoratorPrereq (additive CREATE-IF-NOT-EXISTS) and ReshapeStep
(non-idempotent reshape gated on a detectSql probe).

applyDecoratorMigrations<Decorators...>(table, probe, exec, recorder)
walks the listed decorators at startup, runs every PREREQ, runs every
reshape step whose probe returns false. Database-agnostic — consumer
wires probe/exec to their DbClient. SCHEMA_MIGRATIONS_TABLE_SQL is
provided for observability; the detect-probe is the source of truth.

TemporalRepository ships add_valid_from / add_valid_until /
drop_unique_entity_id / composite_unique (UNIQUE(entity_id, valid_until)
so close-then-insert can run in a deferred-FK transaction).
AuditLogRepository ships the audit_log CREATE TABLE.
ScopeGuardRepository ships nothing — exposes empty PREREQ + zero-length
RESHAPE_STEPS so it can be listed in applyDecoratorMigrations alongside
the schema-touching decorators without SFINAE.

Closes #12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:47:03 +02:00

266 lines
11 KiB
C++

#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_TEMPORAL_REPOSITORY_HPP
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IHistoryRepository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/Prereq.hpp"
#include "oatpp/core/Types.hpp"
#include <chrono>
#include <ctime>
#include <cstdio>
#include <functional>
#include <memory>
#include <random>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
namespace oatpp_authkit::repo {
/**
* @brief Decorator that turns any `Repository<TDto>` into a temporally-versioned one.
*
* `TDto` must register a `TemporalFieldTraits<TDto>` specialisation (use
* the `OATPP_AUTHKIT_REGISTER_TEMPORAL` macro right after the DTO
* definition). The trait names the DTO members that hold the canonical
* `entity_id`, `valid_from`, `valid_until` columns — actual member names
* on the DTO are arbitrary, the trait does the mapping. Forgetting to
* register surfaces as a hard compile error at the first trait use.
*
* @section contract Inner repository contract
*
* The wrapped inner `Repository<TDto>` is expected to:
*
* - Treat `save(dto)` as **upsert keyed by (entity_id, valid_from)**. New
* `valid_from` ⇒ insert a new row. Existing `valid_from` ⇒ update the row
* (this is how `save(closedPrior)` closes a prior version).
* - Treat `list()` as **all rows including historical ones** — no filtering
* by `valid_until`. This decorator does the live-vs-historical filtering
* itself.
* - `findByEntityId` and `softDelete` on the inner are **not used by the
* decorator**; the decorator overrides them with temporal-aware
* implementations.
*
* @section semantics Decorator semantics
*
* - `save(dto)`: if `dto->entity_id` is null, allocate one. Look up the
* currently live row for that entity id; if present, copy it, set its
* `valid_until = now`, and `save` it (closes the old version). Then set
* the new dto's `valid_from = now`, `valid_until = SENTINEL`, and `save` it.
* - `findByEntityId(id)` returns the row whose `valid_until == SENTINEL`.
* - `findByEntityIdAt(id, at)` returns the version live at that timestamp.
* - `list()` returns only live rows.
* - `history(id)` returns all versions ordered ascending by `valid_from`.
* - `softDelete(id)` closes the live row (sets its `valid_until = now`) but
* does not insert a new version.
*/
template <class TDto>
class TemporalRepository
: public Repository<TDto>
, public IHistoryRepository<TDto>
{
public:
/**
* Sentinel valid_until value indicating the row is currently live.
* ISO-8601 UTC, lexically greater than any plausible real timestamp,
* matches the convention used by fewo-webapp's existing temporal tables.
*/
static constexpr const char* SENTINEL = "9999-12-31T23:59:59Z";
/// Decorator-local migration kit (authkit#12).
/// Composite-FK temporal schema: enforces uniqueness on (entity_id,
/// valid_until) so close-then-insert can run inside a transaction.
static constexpr const char* DECORATOR_NAME = "TemporalRepository";
static constexpr DecoratorPrereq PREREQ = {};
static constexpr std::array<ReshapeStep, 4> RESHAPE_STEPS = {{
{"add_valid_from",
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_from'",
"ALTER TABLE {table} ADD COLUMN valid_from TEXT NOT NULL DEFAULT ''"},
{"add_valid_until",
"SELECT 1 FROM pragma_table_info('{table}') WHERE name='valid_until'",
"ALTER TABLE {table} ADD COLUMN valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
{"drop_unique_entity_id",
// Detect that no plain UNIQUE(entity_id) index remains. Whether
// one was ever there is consumer-specific — common case is the
// index was auto-named "sqlite_autoindex_<table>_1" by SQLite
// for an inline `entity_id TEXT UNIQUE`. Detect by checking that
// no index named `ux_{table}_entity_only` exists *and* that the
// composite index (next step) hasn't been created yet — once the
// composite is in place this step's detect probe must pass too.
"SELECT 1 FROM sqlite_master WHERE type='index' AND tbl_name='{table}' AND name='ux_{table}_entity_valid_until'",
// No-op apply; reshape is owned by the consumer's schema. The
// step exists as a hook for consumers that want to drop a
// legacy unique index before composite_unique runs. Override at
// schema-load time if needed; default is noop on systems where
// the original schema didn't carry a UNIQUE(entity_id).
"SELECT 1"},
{"composite_unique",
"SELECT 1 FROM sqlite_master WHERE type='index' AND name='ux_{table}_entity_valid_until'",
"CREATE UNIQUE INDEX ux_{table}_entity_valid_until ON {table}(entity_id, valid_until)"}
}};
using Clock = std::function<int64_t()>; ///< Returns milliseconds since epoch.
using IdGen = std::function<oatpp::String()>;
/**
* @param inner Concrete adapter that exposes all-rows-including-historical.
* @param clock Optional injected clock for tests; default uses system_clock.
* @param idgen Optional injected id generator for tests; default is a 32-char hex from mt19937_64.
*/
explicit TemporalRepository(std::shared_ptr<Repository<TDto>> inner,
Clock clock = {},
IdGen idgen = {})
: m_inner(std::move(inner))
, m_clock(clock ? std::move(clock) : defaultClock())
, m_idgen(idgen ? std::move(idgen) : defaultIdGen())
{}
using F = TemporalFieldTraits<TDto>;
/** @brief Live row for the given entity_id, or null. */
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
auto all = m_inner->list();
for (auto& row : *all) {
auto& id = F::entityId(row);
auto& vu = F::validUntil(row);
if (id && vu
&& std::string(*id) == std::string(*entityId)
&& std::string(*vu) == SENTINEL) {
return row;
}
}
return nullptr;
}
/** @brief Version of `entityId` live at the given point in time. */
oatpp::Object<TDto> findByEntityIdAt(const oatpp::String& entityId, const TemporalAt& at) {
if (at.kind == TemporalAt::Kind::Live) {
return findByEntityId(entityId);
}
const std::string atIso = isoFromMillis(at.timestamp);
auto all = m_inner->list();
for (auto& row : *all) {
auto& id = F::entityId(row);
if (!id || std::string(*id) != std::string(*entityId)) continue;
auto& vf = F::validFrom(row);
auto& vu = F::validUntil(row);
const std::string from = vf ? std::string(*vf) : std::string();
const std::string until = vu ? std::string(*vu) : std::string();
if (from <= atIso && atIso < until) return row;
}
return nullptr;
}
/** @brief All currently-live rows. */
oatpp::Vector<oatpp::Object<TDto>> list() override {
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
auto all = m_inner->list();
for (auto& row : *all) {
auto& vu = F::validUntil(row);
if (vu && std::string(*vu) == SENTINEL) {
out->push_back(row);
}
}
return out;
}
/**
* Close the previous live version (if any) and insert a new live row.
* Mutates `dto` in place to fill in `entity_id` (if null), `valid_from`,
* and `valid_until`.
*/
void save(const oatpp::Object<TDto>& dto) override {
if (!F::entityId(dto)) F::entityId(dto) = m_idgen();
const int64_t nowMs = m_clock();
const std::string nowIso = isoFromMillis(nowMs);
// Close the existing live version (if any).
auto live = findByEntityId(F::entityId(dto));
if (live) {
F::validUntil(live) = oatpp::String(nowIso);
m_inner->save(live);
}
// Insert the new live version.
F::validFrom(dto) = oatpp::String(nowIso);
F::validUntil(dto) = oatpp::String(SENTINEL);
m_inner->save(dto);
}
/** @brief Close the live row without inserting a new version. */
void softDelete(const oatpp::String& entityId) override {
auto live = findByEntityId(entityId);
if (!live) return;
F::validUntil(live) = oatpp::String(isoFromMillis(m_clock()));
m_inner->save(live);
}
/** @brief All versions for `entityId`, oldest first. */
oatpp::Vector<oatpp::Object<TDto>>
history(const oatpp::String& entityId) override
{
std::vector<oatpp::Object<TDto>> bucket;
auto all = m_inner->list();
for (auto& row : *all) {
auto& id = F::entityId(row);
if (id && std::string(*id) == std::string(*entityId)) {
bucket.push_back(row);
}
}
std::sort(bucket.begin(), bucket.end(),
[](const oatpp::Object<TDto>& a, const oatpp::Object<TDto>& b) {
auto& af_s = F::validFrom(a);
auto& bf_s = F::validFrom(b);
const std::string af = af_s ? std::string(*af_s) : std::string();
const std::string bf = bf_s ? std::string(*bf_s) : std::string();
return af < bf;
});
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
for (auto& r : bucket) out->push_back(r);
return out;
}
private:
static Clock defaultClock() {
return [] {
using namespace std::chrono;
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
};
}
static IdGen defaultIdGen() {
return [] {
static thread_local std::mt19937_64 rng{std::random_device{}()};
char buf[33];
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
(unsigned long long)rng(), (unsigned long long)rng());
return oatpp::String(buf);
};
}
static std::string isoFromMillis(int64_t ms) {
std::time_t secs = static_cast<std::time_t>(ms / 1000);
std::tm tmv{};
gmtime_r(&secs, &tmv);
char buf[32];
std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03lldZ",
tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday,
tmv.tm_hour, tmv.tm_min, tmv.tm_sec,
(long long)(ms % 1000));
return std::string(buf);
}
std::shared_ptr<Repository<TDto>> m_inner;
Clock m_clock;
IdGen m_idgen;
};
} // namespace oatpp_authkit::repo
#endif