#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:
parent
606db5a109
commit
3ccc25f231
7 changed files with 704 additions and 1 deletions
|
|
@ -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
102
docs/MIGRATIONS.md
Normal 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.
|
||||
277
include/oatpp-authkit/db/RoleTemplateDb.hpp
Normal file
277
include/oatpp-authkit/db/RoleTemplateDb.hpp
Normal 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
|
||||
84
include/oatpp-authkit/dto/RoleTemplateDto.hpp
Normal file
84
include/oatpp-authkit/dto/RoleTemplateDto.hpp
Normal 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
|
||||
113
include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp
Normal file
113
include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
114
test/test_role_template_schema.cpp
Normal file
114
test/test_role_template_schema.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue