#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>
This commit is contained in:
parent
b5e1ea1894
commit
792e509b67
8 changed files with 134 additions and 49 deletions
|
|
@ -1,5 +1,5 @@
|
|||
cmake_minimum_required(VERSION 3.14)
|
||||
project(oatpp-authkit VERSION 0.7.0 LANGUAGES CXX)
|
||||
project(oatpp-authkit VERSION 0.8.0 LANGUAGES CXX)
|
||||
|
||||
# Header-only interface library — no compilation, just an include path and
|
||||
# a CMake config package so consumers do:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
|
|||
| `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` + `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>` 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/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto>` and turns it into a temporally-versioned one. **Stable-live + historical-copy semantics (authkit#13):** the live row's `id` PK is preserved across updates; each prior version is captured as a fresh row with a new `id`. `softDelete` closes the live row in place; with `ON UPDATE CASCADE` on consumer-side composite child FKs, child rows follow automatically. `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 **`id`** (per-row PK). DTOs register their four temporal columns via `OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, id, entity_id, valid_from, valid_until)`. |
|
||||
| `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/IAuditSink.hpp` + `repo/AuditLogRepository.hpp` | Cross-cutting audit-trail decorator. Emits an `AuditEvent` (actor, entity type/id, op, timestamp) per mutation through a consumer-supplied `IAuditSink`. Ops are `Create` / `Update` / `Delete` / `Read`; pre-write `findByEntityId` lookup distinguishes Create from Update. Configurable enabled-op set (default `{Create,Update,Delete}` — `Read` is opt-in, `list()` never audited). Sink failures are caught and swallowed unless a `bool(const std::exception&)` handler asks to rethrow. Stacks with `TemporalRepository` and `ScopeGuardRepository`. |
|
||||
|
|
@ -24,7 +24,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17.
|
|||
|
||||
| Decorator | `PREREQ` | `RESHAPE_STEPS` |
|
||||
|-----------|----------|------------------|
|
||||
| `TemporalRepository<T>` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)` so close-then-insert can run inside a deferred-FK transaction. |
|
||||
| `TemporalRepository<T>` | (none) | `add_valid_from`, `add_valid_until`, `drop_unique_entity_id` (consumer-overridable noop), `composite_unique` — composite `UNIQUE(entity_id, valid_until)`. With stable-live save semantics, no FK deferral is required; consumer-side child FKs use `ON UPDATE CASCADE` to follow `valid_until` flips on delete. |
|
||||
| `AuditLogRepository<T>` | `CREATE TABLE IF NOT EXISTS audit_log (…)` — fixed shape, no `{table}` placeholder. | (none) |
|
||||
| `ScopeGuardRepository<T>` | (none) | (none) |
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ namespace oatpp_authkit::repo {
|
|||
|
||||
/**
|
||||
* @brief Trait that tells `TemporalRepository<T>` where `T` keeps its
|
||||
* identity, valid_from and valid_until columns.
|
||||
* row PK, entity identity, valid_from and valid_until columns.
|
||||
*
|
||||
* Primary template is intentionally undefined — using
|
||||
* `TemporalFieldTraits<MyDto>` against a DTO that hasn't been registered
|
||||
|
|
@ -15,9 +15,19 @@ namespace oatpp_authkit::repo {
|
|||
* `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.
|
||||
* and rewrite the value. Field names on the DTO are arbitrary — the
|
||||
* trait is the canonical name, the DTO column is whatever the consumer
|
||||
* picked.
|
||||
*
|
||||
* Four canonical fields:
|
||||
* - `id` : per-row PK (version UUID). Preserved across in-place
|
||||
* updates of the live row; freshly allocated for each
|
||||
* historical copy.
|
||||
* - `entityId` : stable logical identity, shared by every version of
|
||||
* the same logical entity.
|
||||
* - `validFrom` : ISO-8601 timestamp when this version became effective.
|
||||
* - `validUntil` : ISO-8601 timestamp when this version ceased to be
|
||||
* effective; SENTINEL while live.
|
||||
*/
|
||||
template <class TDto>
|
||||
struct TemporalFieldTraits; // intentionally undefined
|
||||
|
|
@ -29,16 +39,20 @@ struct TemporalFieldTraits; // intentionally undefined
|
|||
* scope (typically right after the DTO definition):
|
||||
*
|
||||
* OATPP_AUTHKIT_REGISTER_TEMPORAL(PersonDto,
|
||||
* entity_id, valid_from, valid_until)
|
||||
* id, entity_id, valid_from, valid_until)
|
||||
*
|
||||
* The three trailing identifiers are the actual DTO_FIELD member names —
|
||||
* The four 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.
|
||||
*
|
||||
* `IdMember` is the per-row PK. `EntityIdMember` is the stable logical
|
||||
* identity that's shared by every version of the same entity.
|
||||
*/
|
||||
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, FromMember, UntilMember) \
|
||||
#define OATPP_AUTHKIT_REGISTER_TEMPORAL(Dto, IdMember, EntityIdMember, 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& id (const ::oatpp::Object<Dto>& d) { return d->IdMember; } \
|
||||
static ::oatpp::String& entityId (const ::oatpp::Object<Dto>& d) { return d->EntityIdMember; } \
|
||||
static ::oatpp::String& validFrom (const ::oatpp::Object<Dto>& d) { return d->FromMember; } \
|
||||
static ::oatpp::String& validUntil (const ::oatpp::Object<Dto>& d) { return d->UntilMember; } \
|
||||
}; \
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ namespace oatpp_authkit::repo {
|
|||
*
|
||||
* 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 `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.
|
||||
|
|
@ -48,16 +48,33 @@ namespace oatpp_authkit::repo {
|
|||
*
|
||||
* @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.
|
||||
* 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.
|
||||
* - `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
|
||||
|
|
@ -170,9 +187,9 @@ public:
|
|||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* 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();
|
||||
|
|
@ -180,14 +197,24 @@ public:
|
|||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
// Insert the new live version.
|
||||
// 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);
|
||||
|
|
@ -234,6 +261,21 @@ private:
|
|||
};
|
||||
}
|
||||
|
||||
/// 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{}()};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ namespace {
|
|||
|
||||
class AuditDto : public oatpp::DTO {
|
||||
DTO_INIT(AuditDto, DTO)
|
||||
DTO_FIELD(String, id); // per-row PK
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
|
|
@ -39,7 +40,7 @@ class AuditDto : public oatpp::DTO {
|
|||
|
||||
} // namespace
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, entity_id, valid_from, valid_until)
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(AuditDto, id, entity_id, valid_from, valid_until)
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ namespace {
|
|||
|
||||
class TestDto : public oatpp::DTO {
|
||||
DTO_INIT(TestDto, DTO)
|
||||
DTO_FIELD(String, id); // per-row PK
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
|
|
@ -51,7 +52,7 @@ class TestDto : public oatpp::DTO {
|
|||
|
||||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, entity_id, valid_from, valid_until)
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(TestDto, id, entity_id, valid_from, valid_until)
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ namespace {
|
|||
|
||||
class MockTemporalDto : public oatpp::DTO {
|
||||
DTO_INIT(MockTemporalDto, DTO)
|
||||
DTO_FIELD(String, id); // per-row PK (version UUID)
|
||||
DTO_FIELD(String, entity_id);
|
||||
DTO_FIELD(String, valid_from);
|
||||
DTO_FIELD(String, valid_until);
|
||||
|
|
@ -41,7 +42,7 @@ class MockTemporalDto : public oatpp::DTO {
|
|||
#include OATPP_CODEGEN_END(DTO)
|
||||
|
||||
} // namespace
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, entity_id, valid_from, valid_until)
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(MockTemporalDto, id, entity_id, valid_from, valid_until)
|
||||
namespace {
|
||||
|
||||
int g_failures = 0;
|
||||
|
|
@ -53,14 +54,15 @@ int g_failures = 0;
|
|||
} \
|
||||
} while (0)
|
||||
|
||||
// In-memory adapter: rows keyed by (entity_id, valid_from). save() upserts.
|
||||
// In-memory adapter: rows keyed by `id` PK (per-row UUID). save() upserts —
|
||||
// matches the new TemporalRepository inner contract (authkit#13).
|
||||
// Exposes ALL rows via list() — the temporal decorator filters to live.
|
||||
class InMemoryAllRows : public oatpp_authkit::repo::Repository<MockTemporalDto> {
|
||||
std::map<std::pair<std::string, std::string>, oatpp::Object<MockTemporalDto>> rows;
|
||||
std::map<std::string, oatpp::Object<MockTemporalDto>> rows;
|
||||
public:
|
||||
oatpp::Object<MockTemporalDto> findByEntityId(const oatpp::String& id) override {
|
||||
// Not used by TemporalRepository — included for interface completeness.
|
||||
for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second;
|
||||
for (auto& kv : rows) if (kv.second->entity_id && std::string(*kv.second->entity_id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<MockTemporalDto>> list() override {
|
||||
|
|
@ -69,11 +71,11 @@ public:
|
|||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<MockTemporalDto>& dto) override {
|
||||
rows[{*dto->entity_id, *dto->valid_from}] = dto;
|
||||
rows[std::string(*dto->id)] = 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;
|
||||
if (it->second->entity_id && std::string(*it->second->entity_id) == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -86,25 +88,39 @@ struct StepClock {
|
|||
int64_t operator()() { int64_t v = ms; ms += 1000; return v; }
|
||||
};
|
||||
|
||||
// Sequencing idgen so each call returns a fresh string — needed now that
|
||||
// the decorator allocates both entity_id and per-row PK.
|
||||
struct SeqIdGen {
|
||||
int n{0};
|
||||
oatpp::String operator()() {
|
||||
char buf[16];
|
||||
std::snprintf(buf, sizeof(buf), "id%04d", n++);
|
||||
return oatpp::String(buf);
|
||||
}
|
||||
};
|
||||
|
||||
void test_save_closes_prior_version_and_inserts_new() {
|
||||
using namespace oatpp_authkit::repo;
|
||||
auto inner = std::make_shared<InMemoryAllRows>();
|
||||
auto clock = std::make_shared<StepClock>();
|
||||
auto ids = std::make_shared<SeqIdGen>();
|
||||
TemporalRepository<MockTemporalDto> repo(inner,
|
||||
[clock]{ return (*clock)(); },
|
||||
[]{ return oatpp::String("alice"); });
|
||||
[ids]{ return (*ids)(); });
|
||||
|
||||
// First save — entity_id auto-allocated, valid_from = now1, valid_until = SENTINEL.
|
||||
// First save — entity_id + id auto-allocated, valid_from = now1, valid_until = SENTINEL.
|
||||
auto v1 = MockTemporalDto::createShared();
|
||||
v1->name = oatpp::String("alice v1");
|
||||
repo.save(v1);
|
||||
|
||||
REQUIRE(v1->entity_id);
|
||||
REQUIRE(v1->id);
|
||||
REQUIRE(std::string(*v1->valid_until)
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL);
|
||||
REQUIRE(inner->list()->size() == 1);
|
||||
const std::string livePkAfterFirst = std::string(*v1->id);
|
||||
|
||||
// Second save — old version's valid_until is closed; new live row inserted.
|
||||
// Second save — historical copy with new PK, live row updated in place.
|
||||
auto v2 = MockTemporalDto::createShared();
|
||||
v2->entity_id = v1->entity_id;
|
||||
v2->name = oatpp::String("alice v2");
|
||||
|
|
@ -113,11 +129,20 @@ void test_save_closes_prior_version_and_inserts_new() {
|
|||
auto allAfter = inner->list();
|
||||
REQUIRE(allAfter->size() == 2);
|
||||
int liveCount = 0;
|
||||
std::string livePkAfterSecond, historicalPk;
|
||||
for (auto& row : *allAfter) {
|
||||
if (std::string(*row->valid_until)
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL) ++liveCount;
|
||||
== TemporalRepository<MockTemporalDto>::SENTINEL) {
|
||||
++liveCount;
|
||||
livePkAfterSecond = std::string(*row->id);
|
||||
} else {
|
||||
historicalPk = std::string(*row->id);
|
||||
}
|
||||
REQUIRE(liveCount == 1); // Only one row is live.
|
||||
}
|
||||
REQUIRE(liveCount == 1); // exactly one live
|
||||
REQUIRE(livePkAfterSecond == livePkAfterFirst); // stable live PK
|
||||
REQUIRE(historicalPk != livePkAfterFirst); // historical has fresh PK
|
||||
REQUIRE(std::string(*v2->id) == livePkAfterFirst); // dto reflects preserved PK
|
||||
}
|
||||
|
||||
void test_live_read_returns_only_sentinel_row() {
|
||||
|
|
@ -229,6 +254,7 @@ void test_scope_guard_denies_when_predicate_false() {
|
|||
// Seed inner with two rows in different scopes.
|
||||
for (const char* sc : {"prop-A", "prop-B"}) {
|
||||
auto dto = MockTemporalDto::createShared();
|
||||
dto->id = oatpp::String(std::string("pk-") + sc);
|
||||
dto->entity_id = oatpp::String(sc); // reuse scope as id for simplicity
|
||||
dto->valid_from = oatpp::String("2020-01-01T00:00:00Z");
|
||||
dto->valid_until = oatpp::String("9999-12-31T23:59:59Z");
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ namespace {
|
|||
// TemporalRepository<T> couldn't reach these fields.
|
||||
class OddNamesDto : public oatpp::DTO {
|
||||
DTO_INIT(OddNamesDto, DTO)
|
||||
DTO_FIELD(String, id);
|
||||
DTO_FIELD(String, row_pk);
|
||||
DTO_FIELD(String, id); // entity_id (logical), per the original test intent
|
||||
DTO_FIELD(String, effective_from);
|
||||
DTO_FIELD(String, effective_until);
|
||||
DTO_FIELD(String, payload);
|
||||
|
|
@ -39,7 +40,7 @@ class OddNamesDto : public oatpp::DTO {
|
|||
|
||||
} // namespace
|
||||
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, id, effective_from, effective_until)
|
||||
OATPP_AUTHKIT_REGISTER_TEMPORAL(OddNamesDto, row_pk, id, effective_from, effective_until)
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
@ -55,10 +56,10 @@ int g_failures = 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;
|
||||
std::map<std::string, oatpp::Object<OddNamesDto>> rows; // keyed by row_pk
|
||||
public:
|
||||
oatpp::Object<OddNamesDto> findByEntityId(const oatpp::String& id) override {
|
||||
for (auto& kv : rows) if (kv.first.first == std::string(*id)) return kv.second;
|
||||
for (auto& kv : rows) if (kv.second->id && std::string(*kv.second->id) == std::string(*id)) return kv.second;
|
||||
return nullptr;
|
||||
}
|
||||
oatpp::Vector<oatpp::Object<OddNamesDto>> list() override {
|
||||
|
|
@ -67,11 +68,11 @@ public:
|
|||
return v;
|
||||
}
|
||||
void save(const oatpp::Object<OddNamesDto>& dto) override {
|
||||
rows[{*dto->id, *dto->effective_from}] = dto;
|
||||
rows[std::string(*dto->row_pk)] = 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;
|
||||
if (it->second->id && std::string(*it->second->id) == std::string(*id)) it = rows.erase(it); else ++it;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue