ORM / Query Builder
Garvan's data layer has two pieces. Builder generates parameterized statements through a per-database Grammar, then runs them through a DbClient. OModel is the base class your model objects extend to expose those builder methods directly.
Builder
The builder uses a fluent API. Each call returns the builder so you can chain conditions:
Builder qb(dbClient, grammar, "users",
/*public*/{"id","name","email"},
/*private*/{"password"});
auto rows = qb
.where("active", "=", "1")
->where("role", "=", "admin")
->get();
The query is parameterized end-to-end — user-supplied values flow through native PreparedStatement binding rather than string concatenation. SQL injection is not on the table.
Other shortcuts: first(), firstOrFail(), find(id), findOrFail(id), plus insert(json), update(json) and remove().
OModel
Models extend ORM::OModel and declare their table name plus column lists:
class User : public ORM::OModel
{
public:
User() {
table = "users";
primaryKey = "id";
public_columns = {"id", "name", "email"};
private_columns = {"password"};
}
};
Grammars per database
Each driver provides a Grammar subclass that knows how to emit the right dialect:
PostgresGrammarMysqlGrammarSqliteGrammarMonetdbGrammarMongoGrammar— produces Mongo commands rather than SQL.
The right grammar is picked automatically from DATABASE_TYPE. You won't normally touch these classes directly.
TypedQuery — typed model pipeline
Since 2026-08-21 the ORM ships a typed pipeline that hydrates results into real model instances (populating attributes and id), enabling a full CRUD cycle on the loaded object without leaving type safety. The classical Model::where<T>(...)->first() (returning raw json) stays untouched for BC.
// Chain by any column, mutate, save (UPDATE)
User u = User::query<User>()
->where("email", email)
->where("active","=", true)
->firstOrFail();
u.set("last_login", now);
u.save(); // UPDATE ... WHERE id=<hydrated>
// Load by id
User u = User::findAs<User>(id); // throws if missing
auto opt = User::tryFindAs<User>(id); // std::optional<User>
// Instance DELETE
User u = User::findAs<User>(id);
u.remove(); // DELETE ... WHERE id=<hydrated>
// Bulk UPDATE / DELETE (empty WHERE -> throws)
// Status transition (state machine on a cohort)
Invoice::query<Invoice>()->where("status","=","pending")
->update();
// Soft-delete on a coherent cohort
User::query<User>()->where("banned","=", true)
->update(); // soft-delete
// Hard-delete stale rows
FcmToken::query<FcmToken>()->where("expires_at","<", now)
->remove(); // hard delete
// IS NULL on the typed surface
Settings s = Settings::query<Settings>()
->where("user_id","IS", nullptr)
->firstOrFail();
// List rehydration
std::vector<User> active = User::query<User>()
->where("active","=", true)
->get();
Terminal cheat sheet
| Method | Returns | On no-match |
|---|---|---|
first() | std::optional<T> | nullopt |
firstOrFail() | T | throws |
find(int id) | std::optional<T> | nullopt |
findOrFail(int id) | T | throws |
get() | std::vector<T> | empty |
update(json) | void | throws if no WHERE |
remove() | void | throws if no WHERE |
Model::findAs<T>(id) | T | throws |
Model::tryFindAs<T>(id) | std::optional<T> | nullopt |
Guards. Bulk update() / remove() refuse to run without at least one where(...) clause (protection against accidental full-table writes). Model::remove() on an unhydrated instance (empty id) also throws.
Behind the scenes. Connection layers ship results as JsonValue::RawJson (a JSON string). TypedQuery parses that back into a walkable Object/Array tree via JsonValue::parse(std::string_view) (RFC 8259, dependency-free) and calls Model::hydrate(row) — populating attributes and setting id so the next save() naturally hits the UPDATE branch.
C++23 API additions
Alongside the classical API the vendor exposes a family of C++23-only surfaces (full BC preserved). Rebuilding libgarvan.a from source requires GCC 14+ or Clang 18+.
ModelType concept
template <ModelType T>
static T* where(std::string field, std::string value);
// ModelType = std::derived_from<T, Garvan::Model>
// && std::is_default_constructible_v<T>
Errors on a wrong T are short and readable instead of pages of template noise.
No delete this — scratchpad ownership
Terminal methods (get, find, first, …) no longer delete this. Stack instances are safe. Models returned from static where<T>() live in a thread_local scratchpad, cleared on the next where<T>() on the same thread or via an explicit Garvan::Model::flushScratch().
Typed WHERE overloads
builder->where("id", "=", 42); // int -> "42"
builder->where("active", "=", true); // bool -> "true"
builder->where("deleted_at", "IS", nullptr); // -> "NULL"
Concept-guarded overloads for integral / floating_point / bool / nullptr_t. The existing string API stays intact.
Deducing-this fluent chain
Builder b(...);
b.whereRef("id", "1").whereRef("age", ">", "18").get();
whereRef returns Self& instead of Self*, enabling value chains on stack builders. The old Builder* where(...) is kept for BC.
std::expected<json, DbError> instead of exceptions
auto result = user->tryFirst();
if (!result) {
log(result.error().message);
return;
}
json data = *result;
DbError::Code = { Connection, Syntax, Constraint, NotFound, Unknown }. Classification is currently heuristic (matched on what()); precise SQLSTATE-driven classification lands in a future connection-layer extension. Throwing methods stay for BC.
Hygiene
[[nodiscard]]on all query terminals and getters.std::string_viewoverloads inwhere,sanitizeOperator,sanitizeOrderBy,assertSafeIdentifier.- Operator allowlist is a
constexpr std::array— no runtime heap allocation for the static set. - Include guards renamed to
GARVAN_*. #include <pqxx/pqxx>removed fromorm/omodel.h(ORM-neutral header).