diff --git a/CMakeLists.txt b/CMakeLists.txt index 669958a..9d40454 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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: diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md new file mode 100644 index 0000000..4fc357a --- /dev/null +++ b/docs/MIGRATIONS.md @@ -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` 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 +>::create("role_templates", exec); + +// Every app startup: assert the live DB matches. +oatpp_authkit::repo::SchemaContract< + oatpp_authkit::db::RoleTemplateSchema, + oatpp_authkit::repo::TemporalRepository +>::verify("role_templates", probe); + +// Routine repository use. +auto rtdb = std::make_shared(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. diff --git a/include/oatpp-authkit/db/RoleTemplateDb.hpp b/include/oatpp-authkit/db/RoleTemplateDb.hpp new file mode 100644 index 0000000..9824535 --- /dev/null +++ b/include/oatpp-authkit/db/RoleTemplateDb.hpp @@ -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` to produce the full schema. + * + * @section queries Queries + * + * All temporal CRUD goes through the `TemporalRepository` + * decorator on top of `ConcreteRoleTemplateRepository`. The DbClient + * methods below cover queries that don't fit the basic Repository + * 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& 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 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)) + + 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)) + + 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)) + + 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`: + * + * @code + * SchemaBuilder< + * RoleTemplateSchema, + * TemporalRepository>::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 diff --git a/include/oatpp-authkit/dto/RoleTemplateDto.hpp b/include/oatpp-authkit/dto/RoleTemplateDto.hpp new file mode 100644 index 0000000..81f9b23 --- /dev/null +++ b/include/oatpp-authkit/dto/RoleTemplateDto.hpp @@ -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 diff --git a/include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp b/include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp new file mode 100644 index 0000000..b65eba0 --- /dev/null +++ b/include/oatpp-authkit/repo/ConcreteRoleTemplateRepository.hpp @@ -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` (authkit#14 PR 1). +// Stacks under TemporalRepository 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 + +namespace oatpp_authkit::repo { + +/** + * @brief Inner adapter of `Repository`, 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`. + * + * 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>::create("role_templates", exec); + * @endcode + */ +class ConcreteRoleTemplateRepository + : public Repository +{ +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 rtdb) + : m_db(std::move(rtdb)) {} + + oatpp::Object + findByEntityId(const oatpp::String& entityId) override + { + auto res = m_db->getTemplateByEntityId(entityId); + if (!res || !res->isSuccess()) return nullptr; + auto rows = res->template fetch>>(); + if (!rows || rows->empty()) return nullptr; + return (*rows)[0]; + } + + oatpp::Vector> list() override { + auto res = m_db->getAllTemplatesRaw(); + auto out = oatpp::Vector>::createShared(); + if (!res || !res->isSuccess()) return out; + auto fetched = res->template fetch< + oatpp::Vector>>(); + if (!fetched) return out; + for (auto& row : *fetched) { + if (row) out->push_back(row); + } + return out; + } + + void save(const oatpp::Object& d) override { + m_db->upsertTemplateById(d); + } + + void softDelete(const oatpp::String& entityId) override { + m_db->softDeleteTemplate(entityId); + } + +private: + std::shared_ptr m_db; +}; + +/** + * @brief Compose the role-template repository stack. + * + * Wraps the concrete repo in `TemporalRepository` 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> +makeRoleTemplateRepository(std::shared_ptr rtdb) +{ + auto concrete = std::make_shared(std::move(rtdb)); + return std::make_shared>(concrete); +} + +} // namespace oatpp_authkit::repo + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b4ace7b..4950a52 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -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() diff --git a/test/test_role_template_schema.cpp b/test/test_role_template_schema.cpp new file mode 100644 index 0000000..b382e8e --- /dev/null +++ b/test/test_role_template_schema.cpp @@ -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 +#include +#include +#include + +#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> +// 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 sqls; + SqlExec exec = [&](const std::string& sql) { sqls.push_back(sql); }; + + SchemaBuilder< + RoleTemplateSchema, + TemporalRepository>::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 sqls_with; + std::vector sqls_without; + + SchemaBuilder< + RoleTemplateSchema, + ConcreteRoleTemplateRepository, + TemporalRepository>::create( + "role_templates", + [&](const std::string& s){ sqls_with.push_back(s); }); + + SchemaBuilder< + RoleTemplateSchema, + TemporalRepository>::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; +}