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>
266 lines
11 KiB
C++
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
|