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>
308 lines
13 KiB
C++
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
|