#9: Optional IQueryable<T> capability + in-house query AST

Closes #9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Uwe Schuster 2026-04-29 12:55:29 +02:00
parent 08cd32446f
commit 55516d4cf1
4 changed files with 547 additions and 0 deletions

View file

@ -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<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. | | `repo/Repository.hpp` + `IHistoryRepository.hpp` + `ITemporalEntity.hpp` + `TemporalAt.hpp` + `ActorContext.hpp` | Pure-abstract `Repository<TDto>` interface set distilled from fewo-webapp's per-entity `*Db` clients. Mixed UUID allocation on `save`, separate `IHistoryRepository<T>` for temporal versions, `ActorContext` placeholder for the upcoming scope-guard decorator. |
| `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto : ITemporalEntity>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. | | `repo/TemporalRepository.hpp` | Decorator that wraps any `Repository<TDto : ITemporalEntity>` and turns it into a temporally-versioned one. `save` closes the prior live version and inserts a new one; `findByEntityIdAt(id, at)` returns the version live at a point in time; implements `IHistoryRepository<T>`. Inner adapter is expected to expose all rows (live + historical) and treat `save` as upsert keyed by `(entity_id, valid_from)`. |
| `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/ScopeGuardRepository.hpp` | Generic resource-scope decorator. Takes a `bool(ActorContext, TDto)` predicate at construction; gates every method on it. Throws `ScopeDeniedException` on deny (catchers translate to 403). Knows nothing about consumer-specific concepts like "property" or "tenant" — the predicate decides. |
| `repo/IQueryable.hpp` | Optional capability for repos that resolve a typed query AST. `field<&Dto::col>().eq(...)` style DSL composes via `&&` / `||` / `!`; `Query<TDto>::toSql()` emits parameterised SQL plus a bind bag. Bounded surface — equality, range, IN, LIKE, NULL, ORDER BY, LIMIT/OFFSET. No joins, subqueries, or aggregates. Concrete repos opt in by deriving `IQueryable<TDto>`. |
## Consume via CMake ## Consume via CMake

View file

@ -0,0 +1,323 @@
#ifndef OATPP_AUTHKIT_REPO_IQUERYABLE_HPP
#define OATPP_AUTHKIT_REPO_IQUERYABLE_HPP
// Optional IQueryable<T> capability for the Repository<T> 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<TDto>` and translating
// `Query<T>::toSql()` into their underlying store's prepared statements.
#include "oatpp-authkit/repo/Repository.hpp"
#include "oatpp/core/Types.hpp"
#include <cstdint>
#include <initializer_list>
#include <memory>
#include <sstream>
#include <string>
#include <type_traits>
#include <utility>
#include <variant>
#include <vector>
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 <auto MemPtr>
const char* columnName();
template <typename TDto>
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<Dto>() { return name; }
// ─── Bind values ────────────────────────────────────────────────────────────
using BindValue = std::variant<std::monostate, // null
std::int64_t,
double,
std::string,
bool>;
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<std::int64_t>(v)}; }
inline BindValue toBindValue(long v) { return BindValue{static_cast<std::int64_t>(v)}; }
inline BindValue toBindValue(long long v) { return BindValue{static_cast<std::int64_t>(v)}; }
inline BindValue toBindValue(unsigned v) { return BindValue{static_cast<std::int64_t>(v)}; }
inline BindValue toBindValue(double v) { return BindValue{v}; }
inline BindValue toBindValue(float v) { return BindValue{static_cast<double>(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<BindValue>& 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<BindValue>& binds) const override {
sql << col_ << ' ' << op_ << " ?";
binds.push_back(val_);
}
};
class InNode : public AstNode {
std::string col_;
std::vector<BindValue> vals_;
public:
InNode(std::string c, std::vector<BindValue> vs)
: col_(std::move(c)), vals_(std::move(vs)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& 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<BindValue>&) const override {
sql << col_ << (isNull_ ? " IS NULL" : " IS NOT NULL");
}
};
class CombineNode : public AstNode {
const char* sep_;
std::vector<std::shared_ptr<AstNode>> children_;
public:
CombineNode(const char* sep,
std::vector<std::shared_ptr<AstNode>> kids)
: sep_(sep), children_(std::move(kids)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& 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<AstNode> child_;
public:
explicit NotNode(std::shared_ptr<AstNode> c) : child_(std::move(c)) {}
void emit(std::ostringstream& sql,
std::vector<BindValue>& binds) const override {
sql << "NOT (";
child_->emit(sql, binds);
sql << ")";
}
};
// ─── Predicate composition wrapper ──────────────────────────────────────────
class Predicate {
std::shared_ptr<AstNode> node_;
public:
Predicate() = default;
explicit Predicate(std::shared_ptr<AstNode> n) : node_(std::move(n)) {}
bool empty() const noexcept { return !node_; }
std::shared_ptr<AstNode> node() const noexcept { return node_; }
void emit(std::ostringstream& sql,
std::vector<BindValue>& 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<CombineNode>(
"AND", std::vector<std::shared_ptr<AstNode>>{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<CombineNode>(
"OR", std::vector<std::shared_ptr<AstNode>>{a.node_, b.node_})};
}
friend Predicate operator!(const Predicate& a) {
if (a.empty()) return a;
return Predicate{std::make_shared<NotNode>(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 <auto MemPtr>
class Field {
public:
const char* column() const { return columnName<MemPtr>(); }
template <typename V> Predicate eq(V&& v) const { return mk("=", std::forward<V>(v)); }
template <typename V> Predicate ne(V&& v) const { return mk("!=", std::forward<V>(v)); }
template <typename V> Predicate lt(V&& v) const { return mk("<", std::forward<V>(v)); }
template <typename V> Predicate gt(V&& v) const { return mk(">", std::forward<V>(v)); }
template <typename V> Predicate le(V&& v) const { return mk("<=", std::forward<V>(v)); }
template <typename V> Predicate ge(V&& v) const { return mk(">=", std::forward<V>(v)); }
template <typename C>
Predicate in(const C& values) const {
std::vector<BindValue> bs;
for (auto& v : values) bs.push_back(toBindValue(v));
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
}
template <typename V>
Predicate in(std::initializer_list<V> values) const {
std::vector<BindValue> bs;
for (auto& v : values) bs.push_back(toBindValue(v));
return Predicate{std::make_shared<InNode>(column(), std::move(bs))};
}
Predicate like(const std::string& pat) const {
return Predicate{std::make_shared<CompareNode>(
column(), "LIKE", BindValue{pat})};
}
Predicate isNull() const { return Predicate{std::make_shared<IsNullNode>(column(), true)}; }
Predicate isNotNull() const { return Predicate{std::make_shared<IsNullNode>(column(), false)}; }
private:
template <typename V>
Predicate mk(const char* op, V&& v) const {
return Predicate{std::make_shared<CompareNode>(
column(), op, toBindValue(std::forward<V>(v)))};
}
};
template <auto MemPtr>
inline Field<MemPtr> field() { return Field<MemPtr>{}; }
// ─── Query builder ──────────────────────────────────────────────────────────
struct OrderBySpec {
std::string column;
bool ascending;
};
template <typename TDto>
class Query {
Predicate where_;
std::vector<OrderBySpec> 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 <auto MemPtr>
Query& orderBy(Field<MemPtr> f, bool ascending = true) {
orderBy_.push_back({f.column(), ascending});
return *this;
}
template <auto MemPtr>
Query& orderByDesc(Field<MemPtr> 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<OrderBySpec>& 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 <table> ...`.
* Concrete repositories take the returned text + bind bag and feed
* them into their underlying prepared-statement mechanism.
*/
struct Sql {
std::string text;
std::vector<BindValue> binds;
};
Sql toSql() const {
std::ostringstream s;
std::vector<BindValue> binds;
s << "SELECT * FROM " << tableName<TDto>();
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<TDto>` (instead of plain
* `Repository<TDto>`) when they want to expose AST-driven filtering.
* Decorators stay agnostic they wrap `Repository<TDto>` and downcast only
* when a caller specifically asks for the queryable surface.
*/
template <typename TDto>
class IQueryable : public Repository<TDto> {
public:
virtual oatpp::Vector<oatpp::Object<TDto>>
query(const Query<TDto>& q) = 0;
};
} // namespace oatpp_authkit::repo
#endif

View file

@ -29,3 +29,7 @@ add_test(NAME repository_interface COMMAND test_repository_interface)
add_executable(test_repository_decorators test_repository_decorators.cpp) add_executable(test_repository_decorators test_repository_decorators.cpp)
target_link_libraries(test_repository_decorators PRIVATE oatpp::authkit oatpp::oatpp) target_link_libraries(test_repository_decorators PRIVATE oatpp::authkit oatpp::oatpp)
add_test(NAME repository_decorators COMMAND test_repository_decorators) 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)

219
test/test_queryable.cpp Normal file
View file

@ -0,0 +1,219 @@
// Tests for the oatpp-authkit#9 IQueryable<T> 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 <cstdio>
#include <string>
#include <variant>
#include <vector>
#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 "<null>" / "<wrong>".
template <typename T>
std::string bindAsString(const BindValue& v) {
if (auto* p = std::get_if<T>(&v)) {
if constexpr (std::is_same_v<T, std::string>) return *p;
else return std::to_string(*p);
}
if (std::holds_alternative<std::monostate>(v)) return "<null>";
return "<wrong-type>";
}
void test_equality_emits_parameterised_sql() {
auto sql = Query<MockQueryDto>()
.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<std::string>(sql.binds[0]),
std::string("foo@bar"));
}
void test_and_or_combines_predicates() {
auto sql = Query<MockQueryDto>()
.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<bool>(sql.binds[0]) == true);
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 18);
REQUIRE(std::get<std::int64_t>(sql.binds[2]) == 5);
}
void test_repeated_where_implies_and() {
auto sql = Query<MockQueryDto>()
.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<MockQueryDto>()
.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<std::int64_t>(sql.binds[0]) == 18);
REQUIRE(std::get<std::int64_t>(sql.binds[1]) == 65);
}
void test_in_with_multiple_values() {
auto sql = Query<MockQueryDto>()
.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<std::string>(sql.binds[0]), std::string("a@x"));
REQUIRE_EQ(std::get<std::string>(sql.binds[2]), std::string("c@x"));
}
void test_in_with_empty_list_is_always_false() {
std::vector<std::string> empty;
auto sql = Query<MockQueryDto>()
.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<MockQueryDto>()
.where(field<&MockQueryDto::name>().like("Al%"))
.toSql();
REQUIRE_EQ(sql.text, std::string(
"SELECT * FROM mock_query WHERE name LIKE ?"));
REQUIRE_EQ(std::get<std::string>(sql.binds[0]), std::string("Al%"));
}
void test_is_null_and_is_not_null() {
auto a = Query<MockQueryDto>()
.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<MockQueryDto>()
.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<MockQueryDto>()
.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<MockQueryDto>()
.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<bool>(sql.binds[0]) == true);
}
void test_no_where_no_clauses_is_plain_select() {
auto sql = Query<MockQueryDto>().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<MockQueryDto>()
.where(field<&MockQueryDto::email>().eq(oatpp::String("z@x")))
.toSql();
REQUIRE_EQ(std::get<std::string>(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;
}