oatpp-authkit/include/oatpp-authkit/repo/TemporalRepository.hpp
Uwe Schuster 792e509b67 #13: TemporalRepository save — stable-live + historical-copy semantics
The decorator's save() flow now preserves the live row's id PK across
updates and captures each prior version as a fresh row with a new id.
This unblocks fewo-webapp#459: the consumer's composite-FK schema needs
stable child references to the live row (UNIQUE(entity_id, valid_until)
with ON UPDATE CASCADE on every child FK), which the previous
close-then-insert flow couldn't provide.

New flow on update (when a live row exists for entity_id):
  1. Clone the live row in memory (cloneDto via oatpp reflection),
     assign a fresh id and set valid_until=now, save → INSERT historical.
  2. Set the new dto's id=live.id (preserve PK), valid_from=now,
     valid_until=SENTINEL, save → inner UPDATEs the live row in place by
     PK.

Inner adapter contract changes from "upsert keyed by (entity_id,
valid_from)" to "upsert keyed by id (per-row PK)". TemporalFieldTraits
gains an id() accessor; OATPP_AUTHKIT_REGISTER_TEMPORAL grows from 4 to
5 args (Dto + IdMember + EntityIdMember + FromMember + UntilMember).

Tests: test_repository_decorators asserts livePk stability across saves
and fresh historicalPk per version; remaining decorator tests updated to
the 5-arg macro form. README's TemporalRepository.hpp row rewritten to
describe the new write semantics.

Bumped CMake version 0.7.0 → 0.8.0 (semantic break — save() no longer
reallocates the live PK; consumers depending on the old contract need
audit).

Closes #13

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

308 lines
13 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 `id`** (the per-row PK). If
* `dto->id` matches an existing row, UPDATE it in place. Otherwise
* INSERT a new row.
* - 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
*
* Write semantics (authkit#13): **stable-live row + historical copy.**
* The live row's `id` PK is preserved across every update; only its
* mutable columns (and `valid_from`) change. Each prior version is
* captured as a fresh row with a new `id`.
*
* - `save(dto)`:
* - If no live row exists for `dto->entity_id` (or `entity_id` is null),
* this is a fresh insert: allocate `entity_id` if null, set `id` if
* null, `valid_from = now`, `valid_until = SENTINEL`, save.
* - Otherwise:
* 1. Clone the existing live row in memory (`b`). Give `b` a fresh `id`
* and set `b.valid_until = now()`. Save `b` — it's the historical copy.
* 2. Set `dto.id = liveRow.id` (preserve the live PK). Set
* `dto.valid_from = now()`, `dto.valid_until = SENTINEL`. Save `dto`
* — the inner UPDATEs the live row in place by PK.
*
* FK consequence: child rows referencing the live row via the composite
* key `(entity_id, valid_until)` continue to resolve to the same row
* identity throughout the operation; no FK deferral required.
*
* - `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. With `ON UPDATE CASCADE` on every
* composite child FK, child rows follow automatically.
*/
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;
}
/**
* Save a new dto. Stable-live + historical-copy semantics: the live
* row's `id` PK is preserved across updates; the prior version is
* captured as a fresh row with a new `id`. See class doc for details.
*/
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);
auto live = findByEntityId(F::entityId(dto));
if (!live) {
// Fresh insert.
if (!F::id(dto)) F::id(dto) = m_idgen();
F::validFrom(dto) = oatpp::String(nowIso);
F::validUntil(dto) = oatpp::String(SENTINEL);
m_inner->save(dto);
return;
}
// Update path: insert a historical copy with a new PK, then
// update the live row in place by its existing PK.
auto historical = cloneDto(live);
F::id(historical) = m_idgen();
F::validUntil(historical) = oatpp::String(nowIso);
m_inner->save(historical);
F::id(dto) = F::id(live); // preserve live PK
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();
};
}
/// Field-wise deep copy via oatpp's DTO reflection. Used to capture
/// the live row's content as the historical copy before the live row
/// is updated in place.
static oatpp::Object<TDto> cloneDto(const oatpp::Object<TDto>& src) {
auto dst = TDto::createShared();
const auto* dispatcher = static_cast<
const oatpp::data::mapping::type::__class::AbstractObject::PolymorphicDispatcher*>(
oatpp::Object<TDto>::Class::getType()->polymorphicDispatcher);
for (auto* p : dispatcher->getProperties()->getList()) {
p->set(static_cast<oatpp::BaseObject*>(dst.get()),
p->get(static_cast<oatpp::BaseObject*>(src.get())));
}
return dst;
}
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