#14 PR 1: relocate role_templates module + Atlas migration docs

Lifts role_templates / role_template_fields / user_role_assignments from
fewo-webapp into oatpp-authkit, exposed via the declarative SchemaContract
introduced in PR 0.

New files (all in oatpp-authkit):
- dto/RoleTemplateDto.hpp — RoleTemplateDto, RoleTemplateFieldDto,
  UserRoleAssignmentDto. UserWithPermissionsDto stays in fewo (fewo-
  specific /api/auth/me response shape).
- db/RoleTemplateDb.hpp — DbClient with all queries (CRUD + cascade
  soft-delete + getEffectiveFieldPermissions). RoleTemplateSchema struct
  declares the three tables' columns/indexes/sidecar tables in the new
  declarative form. TemporalRepository overlays valid_until + the
  composite UNIQUE(entity_id, valid_until) index.
- repo/ConcreteRoleTemplateRepository.hpp — Repository<RoleTemplateDto>
  inner adapter; makeRoleTemplateRepository helper composes the stack.
- docs/MIGRATIONS.md — Atlas workflow for consumers (atlasgo.io as the
  diff-driven migration tool; SchemaBuilder produces desired state, Atlas
  generates versioned SQL, SchemaContract::verify asserts at runtime).
- test/test_role_template_schema.cpp — verifies SchemaBuilder<
  RoleTemplateSchema, TemporalRepository<RoleTemplateDto>> emits the
  expected 5 DDL statements (2 sidecars + entity table + 2 indexes) with
  composite-FK + ON UPDATE CASCADE on both sidecars.

11 of 11 tests pass. RoleTemplateDto is registered as temporal via
OATPP_AUTHKIT_REGISTER_TEMPORAL so TemporalRepository compiles cleanly.

Atlas binary integration in CI is documented but not yet wired — owner
deferred to a follow-up after the first concrete consumer migration. The
shipped role_templates stack itself is fully consumable today; fewo-
webapp's switch from local copies to oatpp-authkit-shipped headers is
the natural next PR.

Bumped 0.9.0 → 0.10.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-05-06 12:36:18 +02:00
parent 606db5a109
commit 3ccc25f231
7 changed files with 704 additions and 1 deletions

View file

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

102
docs/MIGRATIONS.md Normal file
View file

@ -0,0 +1,102 @@
# Schema migrations with oatpp-authkit
oatpp-authkit (since v0.9.0) ships a declarative schema model: each
decorator in a `Repository<TDto>` stack exposes a static
`DecoratorSchema kSchema` listing the columns/indexes/sidecar tables it
needs. `SchemaBuilder<…>::create(table, exec)` composes the contributions
into a single `CREATE TABLE` per entity table. `SchemaContract::verify`
asserts the live DB matches at runtime.
This document covers the **deploy-time** companion: how to evolve a live
database between releases when the decorator stack changes. The
recommended tool is [Atlas](https://atlasgo.io) (declarative schema-as-
code, language-agnostic).
## The model: dev DB as desired state
Atlas's "diff-driven migration" workflow is a clean fit:
1. **Desired state** — a schema produced by running `SchemaBuilder` once
against an empty SQLite. The output of all `CREATE TABLE` /
`CREATE INDEX` statements *is* the desired state.
2. **Current state** — what the production database actually contains.
3. **Migration**`atlas migrate diff` compares (1) and (2) and emits
versioned SQL files.
4. **Apply** — at deploy time, `atlas migrate apply` runs the new
migration files against prod.
Decorator code never runs ALTER at runtime. It only:
- declares `kSchema` (compile-time);
- runs `SchemaBuilder` against an empty DB (CI) — produces desired state;
- runs `SchemaContract::verify` at app startup against the live DB — fails
loud if a column/sidecar required by the stack is missing (i.e. the
migration didn't run).
## Wiring it into a consumer's CI
A consumer of oatpp-authkit (e.g. fewo-webapp, palibu, …) has its own DB
schema that combines the authkit-shipped contributions with its own
local tables. The schema-snapshot workflow:
1. **Build a small standalone tool** (`tools/schema_snapshot.cpp`) that
instantiates the full set of `SchemaBuilder<…>::create` calls for every
entity in the app, writing all DDL to a temporary SQLite.
2. **Atlas inspects** the resulting SQLite:
```
atlas schema inspect --url "sqlite://./tmp_schema.db" --format '{{ hcl . }}' > schema.hcl
```
3. **Commit `schema.hcl`** to the repo. Diffs are reviewable per change.
4. **At deploy**:
```
atlas migrate diff --to "file://schema.hcl" --dir "file://migrations" \
--dev-url "sqlite://file?mode=memory" \
--format atlas
atlas migrate apply --url "sqlite://prod.db" --dir "file://migrations"
```
The first `migrate diff` emits a versioned migration file; subsequent
schema changes (decorator-level or app-level) regenerate the migration
list. Each release includes the new migration files; deploy applies them.
## Atlas-free fallback
For consumers that don't want Atlas as a dependency, `SchemaBuilder`'s
output is plain SQL — pipe it into any migration tool (Flyway, goose,
hand-rolled scripts). The C++ side stays unchanged.
The runtime guarantee — `SchemaContract::verify` throwing on missing
columns/sidecars — works regardless of which migration tool you used.
## Example: a consumer using oatpp-authkit's role_templates module
The `dto::RoleTemplateDto` + `db::RoleTemplateSchema` +
`repo::ConcreteRoleTemplateRepository` + `repo::TemporalRepository<…>`
stack ships in oatpp-authkit since v0.10.0. A consumer wires it up like:
```cpp
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
#include "oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp"
// One-shot at CI: produce desired-state DDL.
oatpp_authkit::repo::SchemaBuilder<
oatpp_authkit::db::RoleTemplateSchema,
oatpp_authkit::repo::TemporalRepository<oatpp_authkit::dto::RoleTemplateDto>
>::create("role_templates", exec);
// Every app startup: assert the live DB matches.
oatpp_authkit::repo::SchemaContract<
oatpp_authkit::db::RoleTemplateSchema,
oatpp_authkit::repo::TemporalRepository<oatpp_authkit::dto::RoleTemplateDto>
>::verify("role_templates", probe);
// Routine repository use.
auto rtdb = std::make_shared<oatpp_authkit::db::RoleTemplateDb>(executor);
auto repo = oatpp_authkit::repo::makeRoleTemplateRepository(rtdb);
auto liveTemplate = repo->findByEntityId("seed00000000000000000000role01rt");
```
Atlas treats the `CREATE TABLE` output of `SchemaBuilder::create` as the
desired state for those three tables (`role_templates` +
`role_template_fields` + `user_role_assignments`); the consumer's own
schema-snapshot tool aggregates these alongside its app-specific tables.

View file

@ -0,0 +1,277 @@
#ifndef OATPP_AUTHKIT_DB_ROLE_TEMPLATE_DB_HPP
#define OATPP_AUTHKIT_DB_ROLE_TEMPLATE_DB_HPP
// DbClient + declarative schema contribution for role templates,
// field permissions, and user role assignments (authkit#14 PR 1).
//
// Lifted from fewo-webapp's `src/db/RoleTemplateDb.hpp`. The queries are
// unchanged; new in this header is `RoleTemplateSchema::kSchema`, which
// declares the columns/indexes/sidecar tables this module needs in the
// declarative `SchemaContract` style introduced in PR 0.
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-sqlite/orm.hpp"
#include OATPP_CODEGEN_BEGIN(DbClient)
namespace oatpp_authkit::db {
/**
* @brief DbClient for role templates / field permissions / user assignments.
*
* @section schema Schema contract
*
* `RoleTemplateSchema::kSchema` (defined below) names the three tables
* this module owns: `role_templates` (entity), `role_template_fields`
* (sidecar with composite-FK), `user_role_assignments` (sidecar with
* composite-FK). Composes into a `SchemaBuilder` parameter pack alongside
* `TemporalRepository<RoleTemplateDto>` to produce the full schema.
*
* @section queries Queries
*
* All temporal CRUD goes through the `TemporalRepository<RoleTemplateDto>`
* decorator on top of `ConcreteRoleTemplateRepository`. The DbClient
* methods below cover queries that don't fit the basic Repository<T>
* contract (effective-permission resolution, cascade soft-delete) and the
* raw queries the concrete repo uses internally.
*/
class RoleTemplateDb : public oatpp::orm::DbClient {
public:
RoleTemplateDb(const std::shared_ptr<oatpp::orm::Executor>& executor)
: oatpp::orm::DbClient(executor) {}
// ========== Role Templates (basic temporal CRUD) ==========
/// All live templates, ordered by name. Used by the controller list endpoint.
QUERY(getAllTemplates,
"SELECT * FROM role_templates "
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
"ORDER BY name;")
/// All rows (live + historical) for the temporal decorator's filter.
QUERY(getAllTemplatesRaw,
"SELECT * FROM role_templates;")
QUERY(getTemplateByEntityId,
"SELECT * FROM role_templates "
"WHERE entity_id = :id "
"AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, id))
/// Upsert keyed by `id` (per-row PK), per the Repository<T> contract
/// for temporal stacks. The temporal decorator drives close-then-insert
/// via this single method.
QUERY(upsertTemplateById,
"INSERT INTO role_templates "
" (id, entity_id, name, description, is_system, valid_from, valid_until) "
"VALUES "
" (:dto.id, :dto.entityId, :dto.name, :dto.description, "
" :dto.isSystem, :dto.validFrom, :dto.validUntil) "
"ON CONFLICT(id) DO UPDATE SET "
" entity_id = excluded.entity_id, "
" name = excluded.name, "
" description = excluded.description, "
" is_system = excluded.is_system, "
" valid_from = excluded.valid_from, "
" valid_until = excluded.valid_until;",
PARAM(oatpp::Object<dto::RoleTemplateDto>, dto))
QUERY(softDeleteTemplate,
"UPDATE role_templates SET valid_until = datetime('now') "
"WHERE entity_id = :id AND valid_until > datetime('now');",
PARAM(oatpp::String, id))
/// Cascade the soft-delete to link rows. On fresh installs the
/// composite FK + ON UPDATE CASCADE handles this automatically; these
/// explicit UPDATEs are defensive.
QUERY(cascadeSoftDeleteFields,
"UPDATE role_template_fields SET template_valid_until = datetime('now') "
"WHERE template_id = :id AND template_valid_until > datetime('now');",
PARAM(oatpp::String, id))
QUERY(cascadeSoftDeleteAssignments,
"UPDATE user_role_assignments SET template_valid_until = datetime('now') "
"WHERE template_id = :id AND template_valid_until > datetime('now');",
PARAM(oatpp::String, id))
// ========== Template Fields ==========
QUERY(getFieldsForTemplate,
"SELECT * FROM role_template_fields "
"WHERE template_id = :templateId "
"ORDER BY entity_type, field_name;",
PARAM(oatpp::String, templateId))
QUERY(insertField,
"INSERT OR REPLACE INTO role_template_fields "
" (id, template_id, entity_type, field_name, permission) "
"VALUES "
" (:dto.id, :dto.templateId, :dto.entityType, :dto.fieldName, "
" :dto.permission);",
PARAM(oatpp::Object<dto::RoleTemplateFieldDto>, dto))
QUERY(deleteField,
"DELETE FROM role_template_fields WHERE id = :id;",
PARAM(oatpp::String, id))
QUERY(deleteFieldsForTemplate,
"DELETE FROM role_template_fields WHERE template_id = :templateId;",
PARAM(oatpp::String, templateId))
// ========== User Assignments ==========
QUERY(getAllAssignments,
"SELECT * FROM user_role_assignments "
"WHERE valid_from <= datetime('now') AND valid_until > datetime('now') "
"ORDER BY user_id;")
QUERY(getAssignmentsForUser,
"SELECT * FROM user_role_assignments "
"WHERE user_id = :userId "
"AND valid_from <= datetime('now') AND valid_until > datetime('now');",
PARAM(oatpp::String, userId))
QUERY(insertAssignment,
"INSERT INTO user_role_assignments "
" (id, entity_id, user_id, template_id, property_id) "
"VALUES "
" (:dto.id, :dto.entityId, :dto.userId, :dto.templateId, "
" :dto.propertyId);",
PARAM(oatpp::Object<dto::UserRoleAssignmentDto>, dto))
QUERY(softDeleteAssignment,
"UPDATE user_role_assignments SET valid_until = datetime('now') "
"WHERE entity_id = :entityId AND valid_until > datetime('now');",
PARAM(oatpp::String, entityId))
// ========== Field Permission Resolution ==========
/**
* @brief Effective field permissions for a user on one entity type.
*
* Combines all active template assignments. If a user has multiple
* templates (e.g. property-scoped), takes the MAX permission per field
* (write > readonly > hidden). Returns only explicitly granted fields
* unlisted fields are denied.
*
* The composite-temporal joins (`template_valid_until > now()`) make
* soft-deleted templates drop out automatically.
*/
QUERY(getEffectiveFieldPermissions,
"SELECT rtf.entity_type, rtf.field_name, MAX(rtf.permission) AS permission "
"FROM user_role_assignments ura "
"JOIN role_template_fields rtf ON rtf.template_id = ura.template_id "
"WHERE ura.user_id = :userId "
" AND ura.valid_from <= datetime('now') AND ura.valid_until > datetime('now') "
" AND ura.template_valid_until > datetime('now') "
" AND rtf.template_valid_until > datetime('now') "
" AND rtf.entity_type = :entityType "
" AND (ura.property_id IS NULL OR ura.property_id = :propertyId) "
"GROUP BY rtf.entity_type, rtf.field_name;",
PARAM(oatpp::String, userId),
PARAM(oatpp::String, entityType),
PARAM(oatpp::String, propertyId))
QUERY(getAllEffectiveFieldPermissions,
"SELECT rtf.entity_type, rtf.field_name, MAX(rtf.permission) AS permission "
"FROM user_role_assignments ura "
"JOIN role_template_fields rtf ON rtf.template_id = ura.template_id "
"WHERE ura.user_id = :userId "
" AND ura.valid_from <= datetime('now') AND ura.valid_until > datetime('now') "
" AND ura.template_valid_until > datetime('now') "
" AND rtf.template_valid_until > datetime('now') "
"GROUP BY rtf.entity_type, rtf.field_name;",
PARAM(oatpp::String, userId))
};
/**
* @brief Declarative schema contribution for the role-templates module.
*
* Three tables: `role_templates` (the entity), plus the two sidecars
* `role_template_fields` and `user_role_assignments` that carry the
* composite-FK temporal partner column `template_valid_until`.
*
* Designed to compose into a `SchemaBuilder` parameter pack alongside
* `TemporalRepository<RoleTemplateDto>`:
*
* @code
* SchemaBuilder<
* RoleTemplateSchema,
* TemporalRepository<RoleTemplateDto>>::create("role_templates", exec);
* @endcode
*
* The `id`, `entity_id`, business columns, and a `valid_from`-with-default
* are contributed here; the temporal decorator's `kSchema` overlays
* `valid_until` with the SENTINEL default + the composite UNIQUE index.
*
* The sidecar tables are emitted by name (no `{table}` substitution) and
* carry the composite FK to `role_templates(entity_id, valid_until)`.
*/
struct RoleTemplateSchema {
inline static constexpr repo::ColumnSpec kRoleTemplateColumns[] = {
{"id", "TEXT PRIMARY KEY"},
{"entity_id", "TEXT NOT NULL"},
{"name", "TEXT NOT NULL"},
{"description", "TEXT NOT NULL DEFAULT ''"},
{"is_system", "INTEGER NOT NULL DEFAULT 0"},
{"valid_from", "TEXT NOT NULL DEFAULT (datetime('now'))"},
// valid_until is contributed by TemporalRepository's kSchema,
// along with the composite UNIQUE(entity_id, valid_until).
};
inline static constexpr repo::IndexSpec kRoleTemplateIndexes[] = {
{"ix_{table}_entity_id", false, "(entity_id)"},
};
inline static constexpr repo::ColumnSpec kFieldColumns[] = {
{"id", "TEXT PRIMARY KEY"},
{"template_id", "TEXT NOT NULL"},
{"template_valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
{"entity_type", "TEXT NOT NULL"},
{"field_name", "TEXT NOT NULL"},
{"permission", "TEXT NOT NULL"},
{"_fk_to_role_templates",
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"},
};
inline static constexpr repo::ColumnSpec kAssignmentColumns[] = {
{"id", "TEXT PRIMARY KEY"},
{"entity_id", "TEXT NOT NULL"},
{"user_id", "TEXT NOT NULL"},
{"template_id", "TEXT NOT NULL"},
{"template_valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
{"property_id", "TEXT"},
{"valid_from", "TEXT NOT NULL DEFAULT (datetime('now'))"},
{"valid_until", "TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"},
{"_fk_to_role_templates",
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"},
};
inline static constexpr repo::SidecarTableSpec kSidecars[] = {
{"role_template_fields",
kFieldColumns,
sizeof(kFieldColumns) / sizeof(kFieldColumns[0])},
{"user_role_assignments",
kAssignmentColumns,
sizeof(kAssignmentColumns) / sizeof(kAssignmentColumns[0])},
};
inline static constexpr repo::DecoratorSchema kSchema = {
"RoleTemplateSchema",
kRoleTemplateColumns,
sizeof(kRoleTemplateColumns) / sizeof(kRoleTemplateColumns[0]),
kRoleTemplateIndexes,
sizeof(kRoleTemplateIndexes) / sizeof(kRoleTemplateIndexes[0]),
kSidecars,
sizeof(kSidecars) / sizeof(kSidecars[0]),
};
};
} // namespace oatpp_authkit::db
#include OATPP_CODEGEN_END(DbClient)
#endif

View file

@ -0,0 +1,84 @@
#ifndef OATPP_AUTHKIT_DTO_ROLE_TEMPLATE_DTO_HPP
#define OATPP_AUTHKIT_DTO_ROLE_TEMPLATE_DTO_HPP
// Role template + field-permission + user-assignment DTOs (authkit#14 PR 1).
// Lifted from fewo-webapp's `src/dto/RoleTemplateDto.hpp` (consumer-side
// `UserWithPermissionsDto` stays in fewo — it's the /api/auth/me response
// shape, application-specific).
//
// The composite-temporal FK partner field (`templateValidUntil`) was added
// by fewo-webapp#459 PR 7 and follows the same convention here: every child
// row of a temporal `role_templates` row carries a sidecar that tracks the
// parent's `valid_until` via `ON UPDATE CASCADE` on the composite FK.
#include "oatpp/core/macro/codegen.hpp"
#include "oatpp/core/Types.hpp"
#include OATPP_CODEGEN_BEGIN(DTO)
namespace oatpp_authkit::dto {
/**
* @brief A role template (e.g. Cleaning, Accountant, Co-Host).
*/
class RoleTemplateDto : public oatpp::DTO {
DTO_INIT(RoleTemplateDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entityId, "entity_id");
DTO_FIELD(String, name);
DTO_FIELD(String, description);
DTO_FIELD(Int32, isSystem, "is_system");
DTO_FIELD(String, validFrom, "valid_from");
DTO_FIELD(String, validUntil, "valid_until");
};
/**
* @brief A field-level permission within a role template.
*
* `templateValidUntil` is the composite-temporal FK partner tracks the
* parent `role_templates` row's `valid_until` via ON UPDATE CASCADE so a
* soft-delete of the template (which moves its `valid_until` from the
* sentinel to `now()`) propagates here without an explicit UPDATE.
*/
class RoleTemplateFieldDto : public oatpp::DTO {
DTO_INIT(RoleTemplateFieldDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, templateId, "template_id");
DTO_FIELD(String, templateValidUntil, "template_valid_until");
DTO_FIELD(String, entityType, "entity_type");
DTO_FIELD(String, fieldName, "field_name");
DTO_FIELD(String, permission); ///< 'hidden' | 'readonly' | 'write'
};
/**
* @brief Assignment of a role template to a user (optionally property-scoped).
*
* `templateValidUntil` mirrors `RoleTemplateFieldDto::templateValidUntil`
* the composite-FK partner for the temporal FK to `role_templates`.
*/
class UserRoleAssignmentDto : public oatpp::DTO {
DTO_INIT(UserRoleAssignmentDto, DTO)
DTO_FIELD(String, id);
DTO_FIELD(String, entityId, "entity_id");
DTO_FIELD(String, userId, "user_id");
DTO_FIELD(String, templateId, "template_id");
DTO_FIELD(String, templateValidUntil, "template_valid_until");
DTO_FIELD(String, propertyId, "property_id"); ///< optional
DTO_FIELD(String, validFrom, "valid_from");
DTO_FIELD(String, validUntil, "valid_until");
};
} // namespace oatpp_authkit::dto
#include OATPP_CODEGEN_END(DTO)
#include "oatpp-authkit/repo/TemporalFieldTraits.hpp"
OATPP_AUTHKIT_REGISTER_TEMPORAL(
oatpp_authkit::dto::RoleTemplateDto,
id, entityId, validFrom, validUntil)
#endif

View file

@ -0,0 +1,113 @@
#ifndef OATPP_AUTHKIT_REPO_CONCRETE_ROLE_TEMPLATE_REPOSITORY_HPP
#define OATPP_AUTHKIT_REPO_CONCRETE_ROLE_TEMPLATE_REPOSITORY_HPP
// Concrete inner adapter of `Repository<RoleTemplateDto>` (authkit#14 PR 1).
// Stacks under TemporalRepository<RoleTemplateDto> via `makeRoleTemplateRepository`.
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp/core/Types.hpp"
#include <memory>
namespace oatpp_authkit::repo {
/**
* @brief Inner adapter of `Repository<RoleTemplateDto>`, delegating to
* `RoleTemplateDb`.
*
* Per the inner-repository contract documented on `TemporalRepository`:
*
* - `save(dto)` is upsert keyed by `id` (per-row PK), via
* `RoleTemplateDb::upsertTemplateById`. The decorator calls this twice
* per update once for the live row in place, once for the historical
* clone with a new `id`.
* - `list()` returns ALL rows (live + historical) via `getAllTemplatesRaw`;
* `TemporalRepository` filters live-vs-historical itself.
* - `findByEntityId` / `softDelete` are not used by `TemporalRepository`
* (it overrides them with temporal-aware versions). They're implemented
* here so the type satisfies `Repository<RoleTemplateDto>`.
*
* Schema contribution is deliberately empty `RoleTemplateSchema`
* (defined in `db/RoleTemplateDb.hpp`) owns the table declarations. This
* concrete repo only adapts. Stack as:
*
* @code
* SchemaBuilder<
* db::RoleTemplateSchema,
* TemporalRepository<dto::RoleTemplateDto>>::create("role_templates", exec);
* @endcode
*/
class ConcreteRoleTemplateRepository
: public Repository<dto::RoleTemplateDto>
{
public:
/// Empty schema — `db::RoleTemplateSchema` is the schema partner that
/// goes into the SchemaBuilder parameter pack.
inline static constexpr DecoratorSchema kSchema = {
"ConcreteRoleTemplateRepository",
nullptr, 0,
nullptr, 0,
nullptr, 0,
};
explicit ConcreteRoleTemplateRepository(std::shared_ptr<db::RoleTemplateDb> rtdb)
: m_db(std::move(rtdb)) {}
oatpp::Object<dto::RoleTemplateDto>
findByEntityId(const oatpp::String& entityId) override
{
auto res = m_db->getTemplateByEntityId(entityId);
if (!res || !res->isSuccess()) return nullptr;
auto rows = res->template fetch<oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>>();
if (!rows || rows->empty()) return nullptr;
return (*rows)[0];
}
oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>> list() override {
auto res = m_db->getAllTemplatesRaw();
auto out = oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>::createShared();
if (!res || !res->isSuccess()) return out;
auto fetched = res->template fetch<
oatpp::Vector<oatpp::Object<dto::RoleTemplateDto>>>();
if (!fetched) return out;
for (auto& row : *fetched) {
if (row) out->push_back(row);
}
return out;
}
void save(const oatpp::Object<dto::RoleTemplateDto>& d) override {
m_db->upsertTemplateById(d);
}
void softDelete(const oatpp::String& entityId) override {
m_db->softDeleteTemplate(entityId);
}
private:
std::shared_ptr<db::RoleTemplateDb> m_db;
};
/**
* @brief Compose the role-template repository stack.
*
* Wraps the concrete repo in `TemporalRepository<RoleTemplateDto>` so
* callers get versioning + soft-delete-via-valid-until semantics. No
* scope guard is added at this layer role-template management is
* admin-only at the controller level, and there's no per-property scope.
*/
inline std::shared_ptr<Repository<dto::RoleTemplateDto>>
makeRoleTemplateRepository(std::shared_ptr<db::RoleTemplateDb> rtdb)
{
auto concrete = std::make_shared<ConcreteRoleTemplateRepository>(std::move(rtdb));
return std::make_shared<TemporalRepository<dto::RoleTemplateDto>>(concrete);
}
} // namespace oatpp_authkit::repo
#endif

View file

@ -45,3 +45,16 @@ add_test(NAME audit_log_repository COMMAND test_audit_log_repository)
add_executable(test_schema_contract test_schema_contract.cpp)
target_link_libraries(test_schema_contract PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME schema_contract COMMAND test_schema_contract)
# RoleTemplateDb pulls in oatpp-sqlite for its DbClient queries. Linking
# the test against oatpp::oatpp-sqlite provides the QUERY codegen
# definitions; the test itself doesn't open a real DB, only compiles
# against the schema declarations.
find_package(oatpp-sqlite QUIET)
find_package(Threads QUIET)
if(oatpp-sqlite_FOUND AND Threads_FOUND)
add_executable(test_role_template_schema test_role_template_schema.cpp)
target_link_libraries(test_role_template_schema
PRIVATE oatpp::authkit oatpp::oatpp oatpp::oatpp-sqlite Threads::Threads)
add_test(NAME role_template_schema COMMAND test_role_template_schema)
endif()

View file

@ -0,0 +1,114 @@
// Tests for authkit#14 PR 1 — role_templates schema contribution composes
// correctly with the TemporalRepository decorator.
#include "oatpp-authkit/db/RoleTemplateDb.hpp"
#include "oatpp-authkit/dto/RoleTemplateDto.hpp"
#include "oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp"
#include "oatpp-authkit/repo/SchemaContract.hpp"
#include "oatpp-authkit/repo/TemporalRepository.hpp"
#include <cassert>
#include <cstdio>
#include <string>
#include <vector>
#define REQUIRE(cond) do { \
if (!(cond)) { std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", \
#cond, __FILE__, __LINE__); std::abort(); } } while (0)
namespace {
bool contains(const std::string& haystack, const std::string& needle) {
return haystack.find(needle) != std::string::npos;
}
// SchemaBuilder<RoleTemplateSchema, TemporalRepository<RoleTemplateDto>>
// emits the three tables + their indexes. Verify the composition.
void test_role_templates_full_create() {
using namespace oatpp_authkit::repo;
using namespace oatpp_authkit::db;
using namespace oatpp_authkit::dto;
std::vector<std::string> sqls;
SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); };
SchemaBuilder<
RoleTemplateSchema,
TemporalRepository<RoleTemplateDto>>::create("role_templates", exec);
// Two sidecars (role_template_fields + user_role_assignments) +
// one entity table + one entity_id index + one composite UNIQUE index
// = 5
REQUIRE(sqls.size() == 5);
// Sidecar 1: role_template_fields with composite FK
REQUIRE(contains(sqls[0], "CREATE TABLE IF NOT EXISTS role_template_fields"));
REQUIRE(contains(sqls[0], "template_id TEXT NOT NULL"));
REQUIRE(contains(sqls[0], "template_valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
REQUIRE(contains(sqls[0],
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"));
// Sidecar 2: user_role_assignments with composite FK
REQUIRE(contains(sqls[1], "CREATE TABLE IF NOT EXISTS user_role_assignments"));
REQUIRE(contains(sqls[1], "user_id TEXT NOT NULL"));
REQUIRE(contains(sqls[1],
"FOREIGN KEY (template_id, template_valid_until) REFERENCES "
"role_templates(entity_id, valid_until) ON UPDATE CASCADE"));
// Entity table: role_templates with all RoleTemplateSchema columns +
// valid_until from TemporalRepository.
REQUIRE(contains(sqls[2], "CREATE TABLE IF NOT EXISTS role_templates"));
REQUIRE(contains(sqls[2], "id TEXT PRIMARY KEY"));
REQUIRE(contains(sqls[2], "entity_id TEXT NOT NULL"));
REQUIRE(contains(sqls[2], "name TEXT NOT NULL"));
REQUIRE(contains(sqls[2], "is_system INTEGER NOT NULL DEFAULT 0"));
REQUIRE(contains(sqls[2], "valid_from TEXT NOT NULL DEFAULT (datetime('now'))"));
REQUIRE(contains(sqls[2], "valid_until TEXT NOT NULL DEFAULT '9999-12-31T23:59:59Z'"));
// Indexes: ix_role_templates_entity_id (RoleTemplateSchema)
// ux_role_templates_entity_valid_until (TemporalRepository)
REQUIRE(contains(sqls[3], "CREATE INDEX IF NOT EXISTS ix_role_templates_entity_id"));
REQUIRE(contains(sqls[3], "ON role_templates (entity_id)"));
REQUIRE(contains(sqls[4], "CREATE UNIQUE INDEX IF NOT EXISTS ux_role_templates_entity_valid_until"));
REQUIRE(contains(sqls[4], "ON role_templates (entity_id, valid_until)"));
}
// Verify that ConcreteRoleTemplateRepository contributes nothing to the
// schema — RoleTemplateSchema owns the table declarations, the concrete
// repo only adapts queries. Stacking the concrete repo into the builder
// must not duplicate columns.
void test_concrete_repo_contributes_no_schema() {
using namespace oatpp_authkit::repo;
using namespace oatpp_authkit::db;
using namespace oatpp_authkit::dto;
std::vector<std::string> sqls_with;
std::vector<std::string> sqls_without;
SchemaBuilder<
RoleTemplateSchema,
ConcreteRoleTemplateRepository,
TemporalRepository<RoleTemplateDto>>::create(
"role_templates",
[&](const std::string& s){ sqls_with.push_back(s); });
SchemaBuilder<
RoleTemplateSchema,
TemporalRepository<RoleTemplateDto>>::create(
"role_templates",
[&](const std::string& s){ sqls_without.push_back(s); });
// Including ConcreteRoleTemplateRepository in the pack changes nothing
// — empty kSchema contributes no DDL.
REQUIRE(sqls_with == sqls_without);
}
} // namespace
int main() {
test_role_templates_full_create();
test_concrete_repo_contributes_no_schema();
std::printf("test_role_template_schema: OK\n");
return 0;
}