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:

  • PostgresGrammar
  • MysqlGrammar
  • SqliteGrammar
  • MonetdbGrammar
  • MongoGrammar — 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

MethodReturnsOn no-match
first()std::optional<T>nullopt
firstOrFail()Tthrows
find(int id)std::optional<T>nullopt
findOrFail(int id)Tthrows
get()std::vector<T>empty
update(json)voidthrows if no WHERE
remove()voidthrows if no WHERE
Model::findAs<T>(id)Tthrows
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_view overloads in where, 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 from orm/omodel.h (ORM-neutral header).