#10: TemporalFieldTraits<T> — decouple decorator from canonical column names

Replace hard-coded dto->entity_id/valid_from/valid_until accesses in
TemporalRepository with trait calls (F::entityId/validFrom/validUntil).
DTOs register canonical→actual member name mapping via
OATPP_AUTHKIT_REGISTER_TEMPORAL. Forgetting to register is a hard
compile error. ITemporalEntity marker is gone; the trait specialisation
carries the contract. Bumps version 0.4.0 → 0.5.0.

New test verifies the full save/close/history/softDelete flow against a
DTO whose columns are id/effective_from/effective_until rather than the
canonical names — exercises the renaming the trait enables.

Closes #10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-29 14:23:40 +02:00
parent 55516d4cf1
commit 1baff07b71
9 changed files with 225 additions and 68 deletions

View file

@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.14) cmake_minimum_required(VERSION 3.14)
project(oatpp-authkit VERSION 0.4.0 LANGUAGES CXX) project(oatpp-authkit VERSION 0.5.0 LANGUAGES CXX)
# Header-only interface library — no compilation, just an include path and # Header-only interface library — no compilation, just an include path and
# a CMake config package so consumers do: # a CMake config package so consumers do:

View file

@ -13,8 +13,8 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
| `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). | | `util/RateLimiter.hpp` | In-memory token-bucket keyed on an arbitrary string (typically the client IP from `clientIpTrusted`). |
| `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). | | `util/TokenExtract.hpp` | `extractToken` (Cookie/Bearer), `isValidIp` (IPv4/IPv6 via `inet_pton`), `clientIpTrusted` (loopback-gated XFF). |
| `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. | | `startup/RequireEncryptionKey.hpp` | `requireEncryptionKey(envVarName, encryptionEnabled, allowPlaintext)` — refuse startup without a symmetric key unless a dev flag overrides. |
| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `ITemporalEntity.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. | | `repo/Repository.hpp` + `IHistoryRepository.hpp` + `TemporalFieldTraits.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `TemporalFieldTraits<T>` to map canonical (entity_id, valid_from, valid_until) onto whatever a DTO actually calls them, `ActorContext` placeholder for the scope-guard decorator. |
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto : ITemporalEntity>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. | | `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. DTOs register their three temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL`. |
| `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. | | `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. | | `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. |

View file

@ -1,32 +0,0 @@
#ifndef OATPP_AUTHKIT_REPO_I_TEMPORAL_ENTITY_HPP
#define OATPP_AUTHKIT_REPO_I_TEMPORAL_ENTITY_HPP
namespace oatpp_authkit::repo {
/**
* @brief Marker for DTOs that carry temporal-versioning columns.
*
* A DTO opts in by inheriting this empty marker, signalling to the temporal
* decorator (oatpp-authkit#8) that the DTO has the three required oatpp
* `String` fields:
*
* @code
* DTO_FIELD(String, entity_id); // stable across versions
* DTO_FIELD(String, valid_from); // ISO-8601 UTC
* DTO_FIELD(String, valid_until); // ISO-8601 UTC; '9999-12-31T23:59:59Z' = live
* @endcode
*
* The marker is intentionally a plain empty struct rather than a C++20
* concept the issue explicitly chose "pure abstract interface, not
* `requires` clause" for the wider Repository<T> shape, and this matches.
*
* Because oatpp DTOs use macros (`DTO_INIT` / `DTO_FIELD`), the field-shape
* contract above is documentation-enforced, not compiler-enforced. The
* temporal decorator dynamic-casts and accesses fields by name; a missing
* field surfaces as a clean runtime error at decorator construction.
*/
struct ITemporalEntity {};
} // namespace oatpp_authkit::repo
#endif

View file

@ -0,0 +1,47 @@
#ifndef OATPP_AUTHKIT_REPO_TEMPORAL_FIELD_TRAITS_HPP
#define OATPP_AUTHKIT_REPO_TEMPORAL_FIELD_TRAITS_HPP
#include "oatpp/core/Types.hpp"
namespace oatpp_authkit::repo {
/**
* @brief Trait that tells `TemporalRepository<T>` where `T` keeps its
* identity, valid_from and valid_until columns.
*
* Primary template is intentionally undefined using
* `TemporalFieldTraits<MyDto>` against a DTO that hasn't been registered
* is a hard compile error pointing at the call site. Specialise with
* `OATPP_AUTHKIT_REGISTER_TEMPORAL` once per temporal DTO.
*
* Each accessor returns `oatpp::String&` so the repository can both read
* and rewrite the value (closing a prior version sets `valid_until` on a
* loaded row). Field names on the DTO are arbitrary the trait is the
* canonical name, the DTO column is whatever the consumer picked.
*/
template <class TDto>
struct TemporalFieldTraits; // intentionally undefined
} // namespace oatpp_authkit::repo
/**
* Register a temporal DTO with the trait machinery. Place at namespace
* scope (typically right after the DTO definition):
*
* OATPP_AUTHKIT_REGISTER_TEMPORAL(PersonDto,
* entity_id, valid_from, valid_until)
*
* The three trailing identifiers are the actual DTO_FIELD member names
* they don't have to match the canonical names. A DTO that uses
* `effective_from` / `effective_until` registers exactly the same way.
*/
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, FromMember, UntilMember) \
namespace oatpp_authkit::repo { \
template<> struct TemporalFieldTraits<Dto> { \
static ::oatpp::String& entityId (const ::oatpp::Object<Dto>& d) { return d->IdMember; } \
static ::oatpp::String& validFrom (const ::oatpp::Object<Dto>& d) { return d->FromMember; } \
static ::oatpp::String& validUntil (const ::oatpp::Object<Dto>& d) { return d->UntilMember; } \
}; \
}
#endif

View file

@ -3,7 +3,7 @@
#include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IHistoryRepository.hpp" #include "oatpp-authkit/repo/IHistoryRepository.hpp"
#include "oatpp-authkit/repo/ITemporalEntity.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp" #include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp/core/Types.hpp" #include "oatpp/core/Types.hpp"
@ -24,11 +24,12 @@ namespace oatpp_authkit::repo {
/** /**
* @brief Decorator that turns any `Repository<TDto>` into a temporally-versioned one. * @brief Decorator that turns any `Repository<TDto>` into a temporally-versioned one.
* *
* `TDto` must inherit `ITemporalEntity` and expose three oatpp `String` fields * `TDto` must register a `TemporalFieldTraits<TDto>` specialisation (use
* declared via `DTO_FIELD`: `entity_id`, `valid_from`, `valid_until`. The * the `OATPP_AUTHKIT_REGISTER_TEMPORAL` macro right after the DTO
* marker is asserted at compile time; the field shape is documentation- * definition). The trait names the DTO members that hold the canonical
* enforced (oatpp DTOs don't expose a static field-list mechanism the * `entity_id`, `valid_from`, `valid_until` columns actual member names
* decorator could verify via `static_assert`). * 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 * @section contract Inner repository contract
* *
@ -84,18 +85,19 @@ public:
: m_inner(std::move(inner)) : m_inner(std::move(inner))
, m_clock(clock ? std::move(clock) : defaultClock()) , m_clock(clock ? std::move(clock) : defaultClock())
, m_idgen(idgen ? std::move(idgen) : defaultIdGen()) , m_idgen(idgen ? std::move(idgen) : defaultIdGen())
{ {}
static_assert(std::is_base_of_v<ITemporalEntity, TDto>,
"TemporalRepository<TDto> requires TDto to inherit ITemporalEntity"); using F = TemporalFieldTraits<TDto>;
}
/** @brief Live row for the given entity_id, or null. */ /** @brief Live row for the given entity_id, or null. */
oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override { oatpp::Object<TDto> findByEntityId(const oatpp::String& entityId) override {
auto all = m_inner->list(); auto all = m_inner->list();
for (auto& row : *all) { for (auto& row : *all) {
if (row->entity_id && row->valid_until auto& id = F::entityId(row);
&& std::string(*row->entity_id) == std::string(*entityId) auto& vu = F::validUntil(row);
&& std::string(*row->valid_until) == SENTINEL) { if (id && vu
&& std::string(*id) == std::string(*entityId)
&& std::string(*vu) == SENTINEL) {
return row; return row;
} }
} }
@ -110,9 +112,12 @@ public:
const std::string atIso = isoFromMillis(at.timestamp); const std::string atIso = isoFromMillis(at.timestamp);
auto all = m_inner->list(); auto all = m_inner->list();
for (auto& row : *all) { for (auto& row : *all) {
if (!row->entity_id || std::string(*row->entity_id) != std::string(*entityId)) continue; auto& id = F::entityId(row);
const std::string from = row->valid_from ? std::string(*row->valid_from) : std::string(); if (!id || std::string(*id) != std::string(*entityId)) continue;
const std::string until = row->valid_until ? std::string(*row->valid_until) : std::string(); 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; if (from <= atIso && atIso < until) return row;
} }
return nullptr; return nullptr;
@ -123,7 +128,8 @@ public:
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared(); auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();
auto all = m_inner->list(); auto all = m_inner->list();
for (auto& row : *all) { for (auto& row : *all) {
if (row->valid_until && std::string(*row->valid_until) == SENTINEL) { auto& vu = F::validUntil(row);
if (vu && std::string(*vu) == SENTINEL) {
out->push_back(row); out->push_back(row);
} }
} }
@ -136,21 +142,21 @@ public:
* and `valid_until`. * and `valid_until`.
*/ */
void save(const oatpp::Object<TDto>& dto) override { void save(const oatpp::Object<TDto>& dto) override {
if (!dto->entity_id) dto->entity_id = m_idgen(); if (!F::entityId(dto)) F::entityId(dto) = m_idgen();
const int64_t nowMs = m_clock(); const int64_t nowMs = m_clock();
const std::string nowIso = isoFromMillis(nowMs); const std::string nowIso = isoFromMillis(nowMs);
// Close the existing live version (if any). // Close the existing live version (if any).
auto live = findByEntityId(dto->entity_id); auto live = findByEntityId(F::entityId(dto));
if (live) { if (live) {
live->valid_until = oatpp::String(nowIso); F::validUntil(live) = oatpp::String(nowIso);
m_inner->save(live); m_inner->save(live);
} }
// Insert the new live version. // Insert the new live version.
dto->valid_from = oatpp::String(nowIso); F::validFrom(dto) = oatpp::String(nowIso);
dto->valid_until = oatpp::String(SENTINEL); F::validUntil(dto) = oatpp::String(SENTINEL);
m_inner->save(dto); m_inner->save(dto);
} }
@ -158,7 +164,7 @@ public:
void softDelete(const oatpp::String& entityId) override { void softDelete(const oatpp::String& entityId) override {
auto live = findByEntityId(entityId); auto live = findByEntityId(entityId);
if (!live) return; if (!live) return;
live->valid_until = oatpp::String(isoFromMillis(m_clock())); F::validUntil(live) = oatpp::String(isoFromMillis(m_clock()));
m_inner->save(live); m_inner->save(live);
} }
@ -169,14 +175,17 @@ public:
std::vector<oatpp::Object<TDto>> bucket; std::vector<oatpp::Object<TDto>> bucket;
auto all = m_inner->list(); auto all = m_inner->list();
for (auto& row : *all) { for (auto& row : *all) {
if (row->entity_id && std::string(*row->entity_id) == std::string(*entityId)) { auto& id = F::entityId(row);
if (id && std::string(*id) == std::string(*entityId)) {
bucket.push_back(row); bucket.push_back(row);
} }
} }
std::sort(bucket.begin(), bucket.end(), std::sort(bucket.begin(), bucket.end(),
[](const oatpp::Object<TDto>& a, const oatpp::Object<TDto>& b) { [](const oatpp::Object<TDto>& a, const oatpp::Object<TDto>& b) {
const std::string af = a->valid_from ? std::string(*a->valid_from) : std::string(); auto& af_s = F::validFrom(a);
const std::string bf = b->valid_from ? std::string(*b->valid_from) : std::string(); 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; return af < bf;
}); });
auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared(); auto out = oatpp::Vector<oatpp::Object<TDto>>::createShared();

View file

@ -33,3 +33,7 @@ add_test(NAME repository_decorators COMMAND test_repository_decorators)
add_executable(test_queryable test_queryable.cpp) add_executable(test_queryable test_queryable.cpp)
target_link_libraries(test_queryable PRIVATE oatpp::authkit oatpp::oatpp) target_link_libraries(test_queryable PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME queryable COMMAND test_queryable) add_test(NAME queryable COMMAND test_queryable)
add_executable(test_temporal_field_traits test_temporal_field_traits.cpp)
target_link_libraries(test_temporal_field_traits PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME temporal_field_traits COMMAND test_temporal_field_traits)

View file

@ -12,7 +12,7 @@
#include "oatpp-authkit/repo/TemporalRepository.hpp" #include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/ScopeGuardRepository.hpp" #include "oatpp-authkit/repo/ScopeGuardRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/ITemporalEntity.hpp" #include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp" #include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp" #include "oatpp-authkit/repo/ActorContext.hpp"
@ -29,7 +29,7 @@
namespace { namespace {
class MockTemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity { class MockTemporalDto : public oatpp::DTO {
DTO_INIT(MockTemporalDto, DTO) DTO_INIT(MockTemporalDto, DTO)
DTO_FIELD(String, entity_id); DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from); DTO_FIELD(String, valid_from);
@ -40,6 +40,10 @@ class MockTemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporal
#include OATPP_CODEGEN_END(DTO) #include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, entity_id, valid_from, valid_until)
namespace {
int g_failures = 0; int g_failures = 0;
#define REQUIRE(expr) do { \ #define REQUIRE(expr) do { \

View file

@ -7,7 +7,6 @@
#include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/IHistoryRepository.hpp" #include "oatpp-authkit/repo/IHistoryRepository.hpp"
#include "oatpp-authkit/repo/ITemporalEntity.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp" #include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp-authkit/repo/ActorContext.hpp" #include "oatpp-authkit/repo/ActorContext.hpp"
@ -165,10 +164,10 @@ void test_actor_context_minimal() {
REQUIRE(ctx.allowedScopes.size() == 2); REQUIRE(ctx.allowedScopes.size() == 2);
} }
// Compile-time check: ITemporalEntity is usable as a base marker. // Compile-time check: a temporal DTO with all three canonical fields builds.
#include OATPP_CODEGEN_BEGIN(DTO) #include OATPP_CODEGEN_BEGIN(DTO)
class TemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity { class TemporalDto : public oatpp::DTO {
DTO_INIT(TemporalDto, DTO) DTO_INIT(TemporalDto, DTO)
DTO_FIELD(String, entity_id); DTO_FIELD(String, entity_id);
DTO_FIELD(String, valid_from); DTO_FIELD(String, valid_from);
@ -177,7 +176,7 @@ class TemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEnti
#include OATPP_CODEGEN_END(DTO) #include OATPP_CODEGEN_END(DTO)
void test_temporal_marker_compiles() { void test_temporal_dto_compiles() {
auto dto = TemporalDto::createShared(); auto dto = TemporalDto::createShared();
dto->entity_id = oatpp::String("t"); dto->entity_id = oatpp::String("t");
REQUIRE(std::string(*dto->entity_id) == "t"); REQUIRE(std::string(*dto->entity_id) == "t");
@ -193,7 +192,7 @@ int main() {
test_soft_delete_removes_from_live_view(); test_soft_delete_removes_from_live_view();
test_temporal_at_value_type(); test_temporal_at_value_type();
test_actor_context_minimal(); test_actor_context_minimal();
test_temporal_marker_compiles(); test_temporal_dto_compiles();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures); std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0; return g_failures ? 1 : 0;

View file

@ -0,0 +1,126 @@
// Tests for the oatpp-authkit#10 TemporalFieldTraits<T> extension.
//
// Exercises the temporal decorator against a DTO whose column names are
// NOT entity_id / valid_from / valid_until. The trait specialisation
// supplied via OATPP_AUTHKIT_REGISTER_TEMPORAL bridges the canonical
// names used by TemporalRepository to whatever the DTO actually calls
// them — here `id`, `effective_from`, `effective_until`. Same save/close/
// history flow as the existing decorator tests; only the field names move.
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
#include "oatpp-authkit/repo/TemporalAt.hpp"
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdio>
#include <map>
#include <memory>
#include <string>
#include <utility>
#include OATPP_CODEGEN_BEGIN(DTO)
namespace {
// DTO with intentionally non-canonical column names. Without the trait,
// TemporalRepository<T> couldn't reach these fields.
class OddNamesDto : public oatpp::DTO {
DTO_INIT(OddNamesDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, effective_from);
DTO_FIELD(String, effective_until);
DTO_FIELD(String, payload);
};
#include OATPP_CODEGEN_END(DTO)
} // namespace
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, id, effective_from, effective_until)
namespace {
int g_failures = 0;
#define REQUIRE(expr) do { \
if (!(expr)) { \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \
++g_failures; \
} \
} while (0)
// Same in-memory adapter shape as the decorator tests — keys rows by
// (id, effective_from), exposes ALL rows via list().
class InMemoryAllRows : public oatpp_authkit::repo::Repository<OddNamesDto> {
std::map<std::pair<std::string, std::string>, oatpp::Object<OddNamesDto>> rows;
public:
oatpp::Object<OddNamesDto> findByEntityId(const oatpp::String& id) override {
for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second;
return nullptr;
}
oatpp::Vector<oatpp::Object<OddNamesDto>> list() override {
auto v = oatpp::Vector<oatpp::Object<OddNamesDto>>::createShared();
for (auto& kv : rows) v->push_back(kv.second);
return v;
}
void save(const oatpp::Object<OddNamesDto>& dto) override {
rows[{*dto->id, *dto->effective_from}] = dto;
}
void softDelete(const oatpp::String& id) override {
for (auto it = rows.begin(); it != rows.end(); ) {
if (it->first.first == std::string(*id)) it = rows.erase(it); else ++it;
}
}
};
struct StepClock {
int64_t ms{1700000000000LL};
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
};
void test_save_close_and_history_against_renamed_columns() {
using namespace oatpp_authkit::repo;
auto inner = std::make_shared<InMemoryAllRows>();
auto clock = std::make_shared<StepClock>();
TemporalRepository<OddNamesDto> repo(inner,
[clock]{ return (*clock)(); });
// First save — id auto-allocated, effective_from = now, effective_until = SENTINEL.
auto v1 = OddNamesDto::createShared();
v1->payload = oatpp::String("first");
repo.save(v1);
REQUIRE(v1->id);
REQUIRE(std::string(*v1->effective_until)
== TemporalRepository<OddNamesDto>::SENTINEL);
// Second save — close prior, insert new live.
auto v2 = OddNamesDto::createShared();
v2->id = v1->id;
v2->payload = oatpp::String("second");
repo.save(v2);
auto live = repo.findByEntityId(v1->id);
REQUIRE(live);
REQUIRE(std::string(*live->payload) == "second");
// history() returns both versions, oldest first.
auto h = repo.history(v1->id);
REQUIRE(h->size() == 2);
REQUIRE(std::string(*(*h)[0]->payload) == "first");
REQUIRE(std::string(*(*h)[1]->payload) == "second");
// softDelete closes live.
repo.softDelete(v1->id);
REQUIRE(!repo.findByEntityId(v1->id));
}
} // namespace
int main() {
test_save_close_and_history_against_renamed_columns();
std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures);
return g_failures ? 1 : 0;
}