#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. // // SECURITY INVARIANT (authkit#16 L-8): column and table *identifiers* are // emitted into SQL unparameterised (SQL placeholders can't bind identifiers). // They come ONLY from these compile-time registrations / `Field<&Dto::mem>`, // never from request data — so there is no injection vector. Never construct // an `OrderBySpec`/`Field` column name from a runtime/user string; map a // client sort field to a registered `Field` via an allowlist first. All // *values* (eq/ne/in/like/...) are always bound as `?` parameters. 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{}; } /** * @brief Escape LIKE wildcards (`%`, `_`) and the escape char (`\`) in a * user-supplied search term so they're matched literally (authkit#16 * L-8). Pair with the `LIKE ? ESCAPE '\'` clause emitted by * `Field::likeContains` / `Field::likePrefix`. */ inline std::string likeEscape(const std::string& term) { std::string out; out.reserve(term.size() + 4); for (char c : term) { if (c == '\\' || c == '%' || c == '_') out.push_back('\\'); out.push_back(c); } return out; } // ─── 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"); } }; /** @brief `col LIKE ? ESCAPE '\'` — the explicit ESCAPE makes a `\`-escaped * pattern (see `likeEscape`) treat `%`/`_` literally. */ class LikeNode : public AstNode { std::string col_; BindValue val_; public: LikeNode(std::string c, BindValue v) : col_(std::move(c)), val_(std::move(v)) {} void emit(std::ostringstream& sql, std::vector& binds) const override { sql << col_ << " LIKE ? ESCAPE '\\'"; binds.push_back(val_); } }; 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))}; } /** @brief Raw `col LIKE ?` with the pattern bound verbatim. The caller owns * the `%`/`_` wildcards — only pass a TRUSTED pattern here. For a * user-supplied search term use `likeContains` / `likePrefix` (which * escape the metacharacters), or wrap it with `likeEscape`. */ Predicate like(const std::string& pat) const { return Predicate{std::make_shared( column(), "LIKE", BindValue{pat})}; } /** @brief Substring match of a user-supplied `term` with LIKE wildcards * escaped — emits `col LIKE '%%' ESCAPE '\'` (authkit#16 L-8). */ Predicate likeContains(const std::string& term) const { return Predicate{std::make_shared( column(), BindValue{"%" + likeEscape(term) + "%"})}; } /** @brief Prefix match of a user-supplied `term` with LIKE wildcards * escaped — emits `col LIKE '%' ESCAPE '\'`. */ Predicate likePrefix(const std::string& term) const { return Predicate{std::make_shared( column(), BindValue{likeEscape(term) + "%"})}; } 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