From 55516d4cf120ab6cf679fbed71fe9b5330654c99 Mon Sep 17 00:00:00 2001 From: Uwe Schuster Date: Wed, 29 Apr 2026 12:55:29 +0200 Subject: [PATCH] #9: Optional IQueryable capability + in-house query AST Closes #9 Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + include/oatpp-authkit/repo/IQueryable.hpp | 323 ++++++++++++++++++++++ test/CMakeLists.txt | 4 + test/test_queryable.cpp | 219 +++++++++++++++ 4 files changed, 547 insertions(+) create mode 100644 include/oatpp-authkit/repo/IQueryable.hpp create mode 100644 test/test_queryable.cpp diff --git a/README.md b/README.md index c7530b7..02aba50 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ hardened auth / security stack. Header-only, oatpp 1.3+, C++17. | `repo/Repository.hpp` + `IHistoryRepository.hpp` + `ITemporalEntity.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. | | `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. | | `repo/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. | +| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable`. | ## Consume via CMake diff --git a/include/oatpp-authkit/repo/IQueryable.hpp b/include/oatpp-authkit/repo/IQueryable.hpp new file mode 100644 index 0000000..e84528f --- /dev/null +++ b/include/oatpp-authkit/repo/IQueryable.hpp @@ -0,0 +1,323 @@ +#ifndef OATPP_AUTHKIT_REPO_IQUERYABLE_HPP +#define OATPP_AUTHKIT_REPO_IQUERYABLE_HPP + +// Optional IQueryable capability for the Repository layer (authkit#9). +// +// A typed query DSL that emits parameterised SQL plus a bind bag. Bounded to +// equality / range / IN / LIKE / NULL / AND / OR / NOT / ORDER BY / +// LIMIT / OFFSET — no joins, subqueries, or aggregates. Concrete repos opt +// into the capability by deriving from `IQueryable` and translating +// `Query::toSql()` into their underlying store's prepared statements. + +#include "oatpp-authkit/repo/Repository.hpp" +#include "oatpp/core/Types.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace oatpp_authkit::repo { + +// ─── Schema registration ──────────────────────────────────────────────────── +// +// Concrete DTOs register their column / table names by specialising these +// two function templates. The primary templates are intentionally declared +// without a definition: forgetting to register a field is a hard compile or +// link error rather than a runtime surprise. + +template +const char* columnName(); + +template +const char* tableName(); + +#define OATPP_AUTHKIT_REGISTER_FIELD(Dto, mem, colName) \ + template <> \ + inline const char* \ + ::oatpp_authkit::repo::columnName<&Dto::mem>() { return colName; } + +#define OATPP_AUTHKIT_REGISTER_TABLE(Dto, name) \ + template <> \ + inline const char* \ + ::oatpp_authkit::repo::tableName() { return name; } + +// ─── Bind values ──────────────────────────────────────────────────────────── + +using BindValue = std::variant; + +inline BindValue toBindValue(std::nullptr_t) { return BindValue{}; } +inline BindValue toBindValue(bool v) { return BindValue{v}; } +inline BindValue toBindValue(int v) { return BindValue{static_cast(v)}; } +inline BindValue toBindValue(long v) { return BindValue{static_cast(v)}; } +inline BindValue toBindValue(long long v) { return BindValue{static_cast(v)}; } +inline BindValue toBindValue(unsigned v) { return BindValue{static_cast(v)}; } +inline BindValue toBindValue(double v) { return BindValue{v}; } +inline BindValue toBindValue(float v) { return BindValue{static_cast(v)}; } +inline BindValue toBindValue(const char* v) { return BindValue{std::string(v)}; } +inline BindValue toBindValue(const std::string& v) { return BindValue{v}; } +inline BindValue toBindValue(const oatpp::String& v) { + return v ? BindValue{std::string(*v)} : BindValue{}; +} + +// ─── AST nodes ────────────────────────────────────────────────────────────── + +class AstNode { +public: + virtual ~AstNode() = default; + virtual void emit(std::ostringstream& sql, + std::vector& binds) const = 0; +}; + +class CompareNode : public AstNode { + std::string col_; + const char* op_; + BindValue val_; +public: + CompareNode(std::string c, const char* o, BindValue v) + : col_(std::move(c)), op_(o), val_(std::move(v)) {} + void emit(std::ostringstream& sql, + std::vector& binds) const override { + sql << col_ << ' ' << op_ << " ?"; + binds.push_back(val_); + } +}; + +class InNode : public AstNode { + std::string col_; + std::vector vals_; +public: + InNode(std::string c, std::vector vs) + : col_(std::move(c)), vals_(std::move(vs)) {} + void emit(std::ostringstream& sql, + std::vector& binds) const override { + if (vals_.empty()) { sql << "0"; return; } // empty IN ⇒ always false + sql << col_ << " IN ("; + for (std::size_t i = 0; i < vals_.size(); ++i) { + if (i) sql << ", "; + sql << "?"; + binds.push_back(vals_[i]); + } + sql << ")"; + } +}; + +class IsNullNode : public AstNode { + std::string col_; + bool isNull_; +public: + IsNullNode(std::string c, bool n) : col_(std::move(c)), isNull_(n) {} + void emit(std::ostringstream& sql, + std::vector&) const override { + sql << col_ << (isNull_ ? " IS NULL" : " IS NOT NULL"); + } +}; + +class CombineNode : public AstNode { + const char* sep_; + std::vector> children_; +public: + CombineNode(const char* sep, + std::vector> kids) + : sep_(sep), children_(std::move(kids)) {} + void emit(std::ostringstream& sql, + std::vector& binds) const override { + if (children_.empty()) { sql << "1"; return; } + sql << "("; + for (std::size_t i = 0; i < children_.size(); ++i) { + if (i) sql << ' ' << sep_ << ' '; + children_[i]->emit(sql, binds); + } + sql << ")"; + } +}; + +class NotNode : public AstNode { + std::shared_ptr child_; +public: + explicit NotNode(std::shared_ptr c) : child_(std::move(c)) {} + void emit(std::ostringstream& sql, + std::vector& binds) const override { + sql << "NOT ("; + child_->emit(sql, binds); + sql << ")"; + } +}; + +// ─── Predicate composition wrapper ────────────────────────────────────────── + +class Predicate { + std::shared_ptr node_; +public: + Predicate() = default; + explicit Predicate(std::shared_ptr n) : node_(std::move(n)) {} + + bool empty() const noexcept { return !node_; } + std::shared_ptr node() const noexcept { return node_; } + + void emit(std::ostringstream& sql, + std::vector& binds) const { + if (node_) node_->emit(sql, binds); + } + + friend Predicate operator&&(const Predicate& a, const Predicate& b) { + if (a.empty()) return b; + if (b.empty()) return a; + return Predicate{std::make_shared( + "AND", std::vector>{a.node_, b.node_})}; + } + friend Predicate operator||(const Predicate& a, const Predicate& b) { + if (a.empty()) return b; + if (b.empty()) return a; + return Predicate{std::make_shared( + "OR", std::vector>{a.node_, b.node_})}; + } + friend Predicate operator!(const Predicate& a) { + if (a.empty()) return a; + return Predicate{std::make_shared(a.node_)}; + } +}; + +// ─── Field references ─────────────────────────────────────────────────────── +// +// `field<&PersonDto::email>().eq("foo@bar")` resolves the column name through +// the `columnName<>` specialisation. Comparison methods return `Predicate`s +// that compose with `operator&&` / `||` / `!`. + +template +class Field { +public: + const char* column() const { return columnName(); } + + template Predicate eq(V&& v) const { return mk("=", std::forward(v)); } + template Predicate ne(V&& v) const { return mk("!=", std::forward(v)); } + template Predicate lt(V&& v) const { return mk("<", std::forward(v)); } + template Predicate gt(V&& v) const { return mk(">", std::forward(v)); } + template Predicate le(V&& v) const { return mk("<=", std::forward(v)); } + template Predicate ge(V&& v) const { return mk(">=", std::forward(v)); } + + template + Predicate in(const C& values) const { + std::vector bs; + for (auto& v : values) bs.push_back(toBindValue(v)); + return Predicate{std::make_shared(column(), std::move(bs))}; + } + template + Predicate in(std::initializer_list values) const { + std::vector bs; + for (auto& v : values) bs.push_back(toBindValue(v)); + return Predicate{std::make_shared(column(), std::move(bs))}; + } + + Predicate like(const std::string& pat) const { + return Predicate{std::make_shared( + column(), "LIKE", BindValue{pat})}; + } + Predicate isNull() const { return Predicate{std::make_shared(column(), true)}; } + Predicate isNotNull() const { return Predicate{std::make_shared(column(), false)}; } + +private: + template + Predicate mk(const char* op, V&& v) const { + return Predicate{std::make_shared( + column(), op, toBindValue(std::forward(v)))}; + } +}; + +template +inline Field field() { return Field{}; } + +// ─── Query builder ────────────────────────────────────────────────────────── + +struct OrderBySpec { + std::string column; + bool ascending; +}; + +template +class Query { + Predicate where_; + std::vector orderBy_; + std::int64_t limit_ = -1; + std::int64_t offset_ = 0; +public: + Query& where(Predicate p) { + where_ = where_.empty() ? std::move(p) : (where_ && std::move(p)); + return *this; + } + template + Query& orderBy(Field f, bool ascending = true) { + orderBy_.push_back({f.column(), ascending}); + return *this; + } + template + Query& orderByDesc(Field f) { return orderBy(f, false); } + + Query& limit(std::int64_t n) { limit_ = n; return *this; } + Query& offset(std::int64_t n) { offset_ = n; return *this; } + + const Predicate& wherePredicate() const { return where_; } + const std::vector& orderBySpecs() const { return orderBy_; } + std::int64_t limitValue() const { return limit_; } + std::int64_t offsetValue() const { return offset_; } + + /** + * Render the query as a parameterised `SELECT * FROM ...`. + * Concrete repositories take the returned text + bind bag and feed + * them into their underlying prepared-statement mechanism. + */ + struct Sql { + std::string text; + std::vector binds; + }; + Sql toSql() const { + std::ostringstream s; + std::vector binds; + s << "SELECT * FROM " << tableName(); + if (!where_.empty()) { + s << " WHERE "; + where_.emit(s, binds); + } + if (!orderBy_.empty()) { + s << " ORDER BY "; + for (std::size_t i = 0; i < orderBy_.size(); ++i) { + if (i) s << ", "; + s << orderBy_[i].column + << (orderBy_[i].ascending ? " ASC" : " DESC"); + } + } + if (limit_ >= 0) s << " LIMIT " << limit_; + if (offset_ > 0) s << " OFFSET " << offset_; + return {s.str(), std::move(binds)}; + } +}; + +// ─── Capability interface ─────────────────────────────────────────────────── + +/** + * @brief Optional capability for repositories that can resolve a typed `Query`. + * + * Concrete repos derive from `IQueryable` (instead of plain + * `Repository`) when they want to expose AST-driven filtering. + * Decorators stay agnostic — they wrap `Repository` and downcast only + * when a caller specifically asks for the queryable surface. + */ +template +class IQueryable : public Repository { +public: + virtual oatpp::Vector> + query(const Query& q) = 0; +}; + +} // namespace oatpp_authkit::repo + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 94d686b..0ea1c8e 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -29,3 +29,7 @@ add_test(NAME repository_interface COMMAND test_repository_interface) add_executable(test_repository_decorators test_repository_decorators.cpp) target_link_libraries(test_repository_decorators PRIVATE oatpp::authkit oatpp::oatpp) add_test(NAME repository_decorators COMMAND test_repository_decorators) + +add_executable(test_queryable test_queryable.cpp) +target_link_libraries(test_queryable PRIVATE oatpp::authkit oatpp::oatpp) +add_test(NAME queryable COMMAND test_queryable) diff --git a/test/test_queryable.cpp b/test/test_queryable.cpp new file mode 100644 index 0000000..ca776b1 --- /dev/null +++ b/test/test_queryable.cpp @@ -0,0 +1,219 @@ +// Tests for the oatpp-authkit#9 IQueryable capability. +// +// Verifies that the AST emits the expected parameterised SQL and that the +// bind bag captures the values in order. No real database is involved — +// these tests exercise the SQL emitter and the builder API. + +#include "oatpp-authkit/repo/IQueryable.hpp" + +#include "oatpp/core/macro/codegen.hpp" +#include "oatpp/core/Types.hpp" + +#include +#include +#include +#include + +#include OATPP_CODEGEN_BEGIN(DTO) + +namespace { + +class MockQueryDto : public oatpp::DTO { + DTO_INIT(MockQueryDto, DTO) + DTO_FIELD(String, entity_id); + DTO_FIELD(String, name); + DTO_FIELD(String, email); + DTO_FIELD(Int64, age); + DTO_FIELD(Boolean, active); +}; + +#include OATPP_CODEGEN_END(DTO) + +} // namespace + +OATPP_AUTHKIT_REGISTER_TABLE(MockQueryDto, "mock_query") +OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, entity_id, "entity_id") +OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, name, "name") +OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, email, "email") +OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, age, "age") +OATPP_AUTHKIT_REGISTER_FIELD(MockQueryDto, active, "active") + +namespace { + +int g_failures = 0; + +#define REQUIRE(expr) do { \ + if (!(expr)) { \ + std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #expr); \ + ++g_failures; \ + } \ +} while (0) + +#define REQUIRE_EQ(a, b) do { \ + auto _av = (a); auto _bv = (b); \ + if (!(_av == _bv)) { \ + std::fprintf(stderr, "FAIL %s:%d %s == %s ('%s' vs '%s')\n", \ + __FILE__, __LINE__, #a, #b, \ + std::string(_av).c_str(), std::string(_bv).c_str()); \ + ++g_failures; \ + } \ +} while (0) + +using namespace oatpp_authkit::repo; + +// Helper: pull a typed value out of a BindValue, or "" / "". +template +std::string bindAsString(const BindValue& v) { + if (auto* p = std::get_if(&v)) { + if constexpr (std::is_same_v) return *p; + else return std::to_string(*p); + } + if (std::holds_alternative(v)) return ""; + return ""; +} + +void test_equality_emits_parameterised_sql() { + auto sql = Query() + .where(field<&MockQueryDto::email>().eq("foo@bar")) + .toSql(); + REQUIRE_EQ(sql.text, + std::string("SELECT * FROM mock_query WHERE email = ?")); + REQUIRE(sql.binds.size() == 1); + REQUIRE_EQ(bindAsString(sql.binds[0]), + std::string("foo@bar")); +} + +void test_and_or_combines_predicates() { + auto sql = Query() + .where(field<&MockQueryDto::active>().eq(true) + && (field<&MockQueryDto::age>().gt(18) + || field<&MockQueryDto::age>().lt(5))) + .toSql(); + REQUIRE_EQ(sql.text, std::string( + "SELECT * FROM mock_query WHERE " + "(active = ? AND (age > ? OR age < ?))")); + REQUIRE(sql.binds.size() == 3); + REQUIRE(std::get(sql.binds[0]) == true); + REQUIRE(std::get(sql.binds[1]) == 18); + REQUIRE(std::get(sql.binds[2]) == 5); +} + +void test_repeated_where_implies_and() { + auto sql = Query() + .where(field<&MockQueryDto::email>().eq("foo@bar")) + .where(field<&MockQueryDto::active>().eq(true)) + .toSql(); + REQUIRE_EQ(sql.text, std::string( + "SELECT * FROM mock_query WHERE " + "(email = ? AND active = ?)")); +} + +void test_range_emits_inclusive_and_exclusive() { + auto sql = Query() + .where(field<&MockQueryDto::age>().ge(18) + && field<&MockQueryDto::age>().le(65)) + .toSql(); + REQUIRE_EQ(sql.text, std::string( + "SELECT * FROM mock_query WHERE (age >= ? AND age <= ?)")); + REQUIRE(std::get(sql.binds[0]) == 18); + REQUIRE(std::get(sql.binds[1]) == 65); +} + +void test_in_with_multiple_values() { + auto sql = Query() + .where(field<&MockQueryDto::email>().in({"a@x", "b@x", "c@x"})) + .toSql(); + REQUIRE_EQ(sql.text, std::string( + "SELECT * FROM mock_query WHERE email IN (?, ?, ?)")); + REQUIRE(sql.binds.size() == 3); + REQUIRE_EQ(std::get(sql.binds[0]), std::string("a@x")); + REQUIRE_EQ(std::get(sql.binds[2]), std::string("c@x")); +} + +void test_in_with_empty_list_is_always_false() { + std::vector empty; + auto sql = Query() + .where(field<&MockQueryDto::email>().in(empty)) + .toSql(); + REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query WHERE 0")); + REQUIRE(sql.binds.empty()); +} + +void test_like_pattern_is_bound_not_interpolated() { + auto sql = Query() + .where(field<&MockQueryDto::name>().like("Al%")) + .toSql(); + REQUIRE_EQ(sql.text, std::string( + "SELECT * FROM mock_query WHERE name LIKE ?")); + REQUIRE_EQ(std::get(sql.binds[0]), std::string("Al%")); +} + +void test_is_null_and_is_not_null() { + auto a = Query() + .where(field<&MockQueryDto::email>().isNull()) + .toSql(); + REQUIRE_EQ(a.text, std::string( + "SELECT * FROM mock_query WHERE email IS NULL")); + REQUIRE(a.binds.empty()); + + auto b = Query() + .where(field<&MockQueryDto::email>().isNotNull()) + .toSql(); + REQUIRE_EQ(b.text, std::string( + "SELECT * FROM mock_query WHERE email IS NOT NULL")); +} + +void test_not_negates_predicate() { + auto sql = Query() + .where(!field<&MockQueryDto::active>().eq(true)) + .toSql(); + REQUIRE_EQ(sql.text, std::string( + "SELECT * FROM mock_query WHERE NOT (active = ?)")); +} + +void test_order_by_and_limit_offset() { + auto sql = Query() + .where(field<&MockQueryDto::active>().eq(true)) + .orderBy(field<&MockQueryDto::name>()) + .orderByDesc(field<&MockQueryDto::age>()) + .limit(50) + .offset(100) + .toSql(); + REQUIRE_EQ(sql.text, std::string( + "SELECT * FROM mock_query WHERE active = ? " + "ORDER BY name ASC, age DESC LIMIT 50 OFFSET 100")); + REQUIRE(std::get(sql.binds[0]) == true); +} + +void test_no_where_no_clauses_is_plain_select() { + auto sql = Query().toSql(); + REQUIRE_EQ(sql.text, std::string("SELECT * FROM mock_query")); + REQUIRE(sql.binds.empty()); +} + +void test_oatpp_string_value_is_unwrapped() { + auto sql = Query() + .where(field<&MockQueryDto::email>().eq(oatpp::String("z@x"))) + .toSql(); + REQUIRE_EQ(std::get(sql.binds[0]), std::string("z@x")); +} + +} // namespace + +int main() { + test_equality_emits_parameterised_sql(); + test_and_or_combines_predicates(); + test_repeated_where_implies_and(); + test_range_emits_inclusive_and_exclusive(); + test_in_with_multiple_values(); + test_in_with_empty_list_is_always_false(); + test_like_pattern_is_bound_not_interpolated(); + test_is_null_and_is_not_null(); + test_not_negates_predicate(); + test_order_by_and_limit_offset(); + test_no_where_no_clauses_is_plain_select(); + test_oatpp_string_value_is_unwrapped(); + + std::printf("%s (%d failures)\n", g_failures ? "FAIL" : "OK", g_failures); + return g_failures ? 1 : 0; +}