From 1baff07b717c101db334bd20c3dd797243ded010 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Wed, 29 Apr 2026 14:23:40 +0200 Subject: [PATCH] =?UTF-8?q?#10:=20TemporalFieldTraits=20=E2=80=94=20dec?= =?UTF-8?q?ouple=20decorator=20from=20canonical=20column=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CMakeLists.txt | 2 +- README.md | 4 +- .../oatpp-authkit/repo/ITemporalEntity.hpp | 32 ----- .../repo/TemporalFieldTraits.hpp | 47 +++++++ .../oatpp-authkit/repo/TemporalRepository.hpp | 61 +++++---- test/CMakeLists.txt | 4 + test/test_repository_decorators.cpp | 8 +- test/test_repository_interface.cpp | 9 +- test/test_temporal_field_traits.cpp | 126 ++++++++++++++++++ 9 files changed, 225 insertions(+), 68 deletions(-) delete mode 100644 include/oatpp-authkit/repo/ITemporalEntity.hpp create mode 100644 include/oatpp-authkit/repo/TemporalFieldTraits.hpp create mode 100644 test/test_temporal_field_traits.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8bee831..5140a71 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ 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 # a CMake config package so consumers do: diff --git a/README.md b/README.md index 02aba50..040d098 100644 --- a/README.md +++ b/README.md @@ -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/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. | -| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `ITemporalEntity.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. | -| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository` 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`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. | +| `repo/Repository.hpp` + `IHistoryRepository.hpp` + `TemporalFieldTraits.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository` for temporal versions, `TemporalFieldTraits` 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` 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`. 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/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query::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`. | diff --git a/include/oatpp-authkit/repo/ITemporalEntity.hpp b/include/oatpp-authkit/repo/ITemporalEntity.hpp deleted file mode 100644 index d82768e..0000000 --- a/include/oatpp-authkit/repo/ITemporalEntity.hpp +++ /dev/null @@ -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 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 diff --git a/include/oatpp-authkit/repo/TemporalFieldTraits.hpp b/include/oatpp-authkit/repo/TemporalFieldTraits.hpp new file mode 100644 index 0000000..10bee52 --- /dev/null +++ b/include/oatpp-authkit/repo/TemporalFieldTraits.hpp @@ -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` where `T` keeps its + * identity, valid_from and valid_until columns. + * + * Primary template is intentionally undefined — using + * `TemporalFieldTraits` 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 +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 { \ + static ::oatpp::String& entityId (const ::oatpp::Object& d) { return d->IdMember; } \ + static ::oatpp::String& validFrom (const ::oatpp::Object& d) { return d->FromMember; } \ + static ::oatpp::String& validUntil (const ::oatpp::Object& d) { return d->UntilMember; } \ + }; \ + } + +#endif diff --git a/include/oatpp-authkit/repo/TemporalRepository.hpp b/include/oatpp-authkit/repo/TemporalRepository.hpp index 5665c1c..f767895 100644 --- a/include/oatpp-authkit/repo/TemporalRepository.hpp +++ b/include/oatpp-authkit/repo/TemporalRepository.hpp @@ -3,7 +3,7 @@ #include "oatpp-authkit/repo/Repository.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/core/Types.hpp" @@ -24,11 +24,12 @@ namespace oatpp_authkit::repo { /** * @brief Decorator that turns any `Repository` into a temporally-versioned one. * - * `TDto` must inherit `ITemporalEntity` and expose three oatpp `String` fields - * declared via `DTO_FIELD`: `entity_id`, `valid_from`, `valid_until`. The - * marker is asserted at compile time; the field shape is documentation- - * enforced (oatpp DTOs don't expose a static field-list mechanism the - * decorator could verify via `static_assert`). + * `TDto` must register a `TemporalFieldTraits` 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 * @@ -84,18 +85,19 @@ public: : m_inner(std::move(inner)) , m_clock(clock ? std::move(clock) : defaultClock()) , m_idgen(idgen ? std::move(idgen) : defaultIdGen()) - { - static_assert(std::is_base_of_v, - "TemporalRepository requires TDto to inherit ITemporalEntity"); - } + {} + + using F = TemporalFieldTraits; /** @brief Live row for the given entity_id, or null. */ oatpp::Object findByEntityId(const oatpp::String& entityId) override { auto all = m_inner->list(); for (auto& row : *all) { - if (row->entity_id && row->valid_until - && std::string(*row->entity_id) == std::string(*entityId) - && std::string(*row->valid_until) == SENTINEL) { + 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; } } @@ -110,9 +112,12 @@ public: const std::string atIso = isoFromMillis(at.timestamp); auto all = m_inner->list(); for (auto& row : *all) { - if (!row->entity_id || std::string(*row->entity_id) != std::string(*entityId)) continue; - const std::string from = row->valid_from ? std::string(*row->valid_from) : std::string(); - const std::string until = row->valid_until ? std::string(*row->valid_until) : std::string(); + 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; @@ -123,7 +128,8 @@ public: auto out = oatpp::Vector>::createShared(); auto all = m_inner->list(); 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); } } @@ -136,21 +142,21 @@ public: * and `valid_until`. */ void save(const oatpp::Object& 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 std::string nowIso = isoFromMillis(nowMs); // Close the existing live version (if any). - auto live = findByEntityId(dto->entity_id); + auto live = findByEntityId(F::entityId(dto)); if (live) { - live->valid_until = oatpp::String(nowIso); + F::validUntil(live) = oatpp::String(nowIso); m_inner->save(live); } // Insert the new live version. - dto->valid_from = oatpp::String(nowIso); - dto->valid_until = oatpp::String(SENTINEL); + F::validFrom(dto) = oatpp::String(nowIso); + F::validUntil(dto) = oatpp::String(SENTINEL); m_inner->save(dto); } @@ -158,7 +164,7 @@ public: void softDelete(const oatpp::String& entityId) override { auto live = findByEntityId(entityId); if (!live) return; - live->valid_until = oatpp::String(isoFromMillis(m_clock())); + F::validUntil(live) = oatpp::String(isoFromMillis(m_clock())); m_inner->save(live); } @@ -169,14 +175,17 @@ public: std::vector> bucket; auto all = m_inner->list(); 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); } } std::sort(bucket.begin(), bucket.end(), [](const oatpp::Object& a, const oatpp::Object& b) { - const std::string af = a->valid_from ? std::string(*a->valid_from) : std::string(); - const std::string bf = b->valid_from ? std::string(*b->valid_from) : std::string(); + 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>::createShared(); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0ea1c8e..97a6d1b 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -33,3 +33,7 @@ add_test(NAME repository_decorators COMMAND test_repository_decorators) add_executable(test_queryable test_queryable.cpp) target_link_libraries(test_queryable PRIVATE oatpp::authkit oatpp::oatpp) 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) diff --git a/test/test_repository_decorators.cpp b/test/test_repository_decorators.cpp index 2714827..df219be 100644 --- a/test/test_repository_decorators.cpp +++ b/test/test_repository_decorators.cpp @@ -12,7 +12,7 @@ #include "oatpp-authkit/repo/TemporalRepository.hpp" #include "oatpp-authkit/repo/ScopeGuardRepository.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/ActorContext.hpp" @@ -29,7 +29,7 @@ namespace { -class MockTemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity { +class MockTemporalDto : public oatpp::DTO { DTO_INIT(MockTemporalDto, DTO) DTO_FIELD(String, entity_id); DTO_FIELD(String, valid_from); @@ -40,6 +40,10 @@ class MockTemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporal #include OATPP_CODEGEN_END(DTO) +} // namespace +OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, entity_id, valid_from, valid_until) +namespace { + int g_failures = 0; #define REQUIRE(expr) do { \ diff --git a/test/test_repository_interface.cpp b/test/test_repository_interface.cpp index 20ecdea..f1c671e 100644 --- a/test/test_repository_interface.cpp +++ b/test/test_repository_interface.cpp @@ -7,7 +7,6 @@ #include "oatpp-authkit/repo/Repository.hpp" #include "oatpp-authkit/repo/IHistoryRepository.hpp" -#include "oatpp-authkit/repo/ITemporalEntity.hpp" #include "oatpp-authkit/repo/TemporalAt.hpp" #include "oatpp-authkit/repo/ActorContext.hpp" @@ -165,10 +164,10 @@ void test_actor_context_minimal() { 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) -class TemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEntity { +class TemporalDto : public oatpp::DTO { DTO_INIT(TemporalDto, DTO) DTO_FIELD(String, entity_id); DTO_FIELD(String, valid_from); @@ -177,7 +176,7 @@ class TemporalDto : public oatpp::DTO, public oatpp_authkit::repo::ITemporalEnti #include OATPP_CODEGEN_END(DTO) -void test_temporal_marker_compiles() { +void test_temporal_dto_compiles() { auto dto = TemporalDto::createShared(); dto->entity_id = oatpp::String("t"); REQUIRE(std::string(*dto->entity_id) == "t"); @@ -193,7 +192,7 @@ int main() { test_soft_delete_removes_from_live_view(); test_temporal_at_value_type(); test_actor_context_minimal(); - test_temporal_marker_compiles(); + test_temporal_dto_compiles(); std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures); return g_failures ? 1 : 0; diff --git a/test/test_temporal_field_traits.cpp b/test/test_temporal_field_traits.cpp new file mode 100644 index 0000000..0b0d125 --- /dev/null +++ b/test/test_temporal_field_traits.cpp @@ -0,0 +1,126 @@ +// Tests for the oatpp-authkit#10 TemporalFieldTraits 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 +#include +#include +#include +#include + +#include OATPP_CODEGEN_BEGIN(DTO) + +namespace { + +// DTO with intentionally non-canonical column names. Without the trait, +// TemporalRepository 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 { + std::map, oatpp::Object> rows; +public: + oatpp::Object findByEntityId(const oatpp::String& id) override { + for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second; + return nullptr; + } + oatpp::Vector> list() override { + auto v = oatpp::Vector>::createShared(); + for (auto& kv : rows) v->push_back(kv.second); + return v; + } + void save(const oatpp::Object& 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(); + auto clock = std::make_shared(); + TemporalRepository 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::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; +}