Filter & Lookup

The query builder is chainable, lazy, and type-safe.

Basic filtering

#![allow(unused)]
fn main() {
// All active users
let users = User::filter(&pool)
    .eq("active", true)
    .all()
    .await?;

// Users with score > 100
let users = User::filter(&pool)
    .gt("score", 100)
    .all()
    .await?;
}

Typed columns

#[derive(Model)] generates one constant per field (User::EMAIL, User::SCORE, …), typed by model and field type (Column<User, FieldInt>). Use them instead of strings: a typo, a renamed field, a column from another model, or a value of the wrong type becomes a compile error instead of a database error.

#![allow(unused)]
fn main() {
let users = User::filter(&pool)
    .eq(User::ACTIVE, true)
    .gt(User::SCORE, 100)
    .order_by(User::CREATED_AT.desc())
    .all()
    .await?;

let total = Order::filter(&pool).sum(Order::PRICE).await?;

// Compile errors:
// User::filter(&pool).eq(Article::TITLE, "x");    // column of another model
// User::filter(&pool).eq(User::SCORE, "high");    // `&str` is not a `FieldInt` value
// User::filter(&pool).eq(User::PASSWORD, "pw");   // passwords are salted hashes
}

A typed column accepts its field type or the plain Rust equivalent: i32 for FieldInt, i64 for FieldBigInt and FieldRange, &str/String for FieldText, FieldEmail, FieldUrl and FieldVarchar, Uuid or FieldUuid for ForeignKey<M>. Filter values are not validated like field constructors: searching for "not-an-email" is allowed.

String names ("email", "-created_at") are still accepted everywhere; they accept any value and are checked by the database at runtime.

Operators

MethodSQLExample
.eq(col, val)col = val.eq("role", "admin")
.ne(col, val)col != val.ne("status", "banned")
.gt(col, val)col > val.gt("age", 18)
.gte(col, val)col >= val.gte("score", 100)
.lt(col, val)col < val.lt("price", 50)
.lte(col, val)col <= val.lte("stock", 0)
.like(col, pat)col LIKE pat.like("name", "Al%")
.ilike(col, pat)col ILIKE pat.ilike("email", "%@gmail%")
.in_values(col, vals)col IN (...).in_values("id", ids)
.is_null(col)col IS NULL.is_null("deleted_at")
.is_not_null(col)col IS NOT NULL.is_not_null("email")

Logical connectors

#![allow(unused)]
fn main() {
// AND (default)
User::filter(&pool)
    .eq("active", true)
    .eq("role", "admin")  // AND

// OR
User::filter(&pool)
    .eq("role", "admin")
    .or()
    .eq("role", "moderator")

// NOT
User::filter(&pool)
    .not()
    .eq("banned", true)

// Grouped OR
User::filter(&pool)
    .eq("active", true)
    .group(|q| q.eq("role", "admin").or().eq("role", "mod"))
// → WHERE "active" = $1 AND ("role" = $2 OR "role" = $3)
}

Subqueries

Filter on another table without loading it. The closure gets an empty filter() on the other model and adds conditions to it; only those conditions are used (ordering, limit and relations are ignored). The foreign key column tells Rango which model the subquery runs on. Values stay bound parameters.

where_exists keeps rows that have at least one related row through a foreign key pointing at them:

#![allow(unused)]
fn main() {
// Users with at least one published article
let authors = User::filter(&pool)
    .where_exists(Article::AUTHOR_ID, |q| q.eq(Article::PUBLISHED, true))
    .all()
    .await?;
// WHERE EXISTS (SELECT 1 FROM "article"
//               WHERE "article"."author_id" = "user"."id" AND ("published" = $1))

// Users without any article
let idle = User::filter(&pool)
    .not()
    .where_exists(Article::AUTHOR_ID, |q| q)
    .all()
    .await?;
}

in_subquery compares a column with the values another query returns:

#![allow(unused)]
fn main() {
// Posts written by active users
let posts = Post::filter(&pool)
    .in_subquery(Post::AUTHOR_ID, User::ID, |q| q.eq(User::ACTIVE, true))
    .all()
    .await?;
// WHERE "author_id" IN (SELECT "id" FROM "user" WHERE "active" = $1)
}

Both compose with .or(), .not() and .group() like any other filter. For "has none", use .not().where_exists(...) rather than .not().in_subquery(...): NOT IN matches nothing as soon as the subquery returns a NULL.

When to use a subquery, a JOIN, or a prefetch

Rango does not pick between these for you — you express the intent, and each maps to the cheapest shape for that intent:

You want…UseWhy it is the cheap shape
Keep/drop rows based on whether a related row existswhere_exists (correlated EXISTS)The DB stops scanning the inner table at the first matching row; no row multiplication, no DISTINCT, no JOIN to clean up
Compare a column against a set of values from another queryin_subquery (IN (SELECT …))One subquery, values stay bound; avoids loading them into Rust
Select columns from a related table alongside the rowselect_related (single JOIN)You need the related columns in the result, so a JOIN is the right tool — a subquery would drop them
Load the related rows themselves to iterate themprefetch_related (one batched IN / = ANY)One extra query for the whole page, which is what avoids the N+1 of a per-row lookup

The rule of thumb: a subquery answers "does it exist?" or "is it in this set?" without touching the related rows; a JOIN or prefetch is only for when you actually need the related data in hand. Reaching for a JOIN just to filter (then DISTINCT-ing the duplicates) is the N+1-adjacent anti-pattern where_exists exists to replace.

Ordering, limit, offset

#![allow(unused)]
fn main() {
User::filter(&pool)
    .order_by("name")       // ASC
    .order_by("-created_at") // DESC (prefix with -)
    .limit(20)
    .offset(40)
    .all()
    .await?
}

Default limit

By default, .all() returns at most 1000 rows. This is a safety cap, not a pagination boundary: when exactly 1000 rows come back and you set neither .limit(n) nor .unlimited(), Rango logs a tracing WARN naming the table, because rows may have been cut off.

Override with .limit(n) or remove with .unlimited():

#![allow(unused)]
fn main() {
User::filter(&pool).limit(5000).all().await?  // explicit limit
User::filter(&pool).unlimited().all().await?   // no limit
}

The cap applies to the query builder only (all(), values(), select_as::<P>().all()). The plain rango::all::<M>(&pool) function has no cap and returns every row of the table.

Terminal methods

WithRelated<M> derefs to M; call .into_inner() to get the owned M (models can #[derive(Model, Clone, Debug)]).

MethodReturns
.all()Vec<WithRelated<M>>
.one()Option<M> (no relation loading)
.count()i64
.exists()bool
.explain()String — the SQL that would run
.explain_analyze()String — the plan with real timings (runs the query)

Inspecting the SQL

#![allow(unused)]
fn main() {
let sql = User::filter(&pool)
    .eq("active", true)
    .order_by("name")
    .limit(10)
    .explain();

println!("{}", sql);
// SELECT * FROM "user" WHERE "active" = $1 ORDER BY "name" ASC LIMIT 10
}

A query builder takes &pool or &mut **tx (inside atomic()). .explain_analyze().await? (it takes &mut self, so the builder stays usable afterwards) runs the query under the engine's profiler and returns the plan:

BackendStatementExecutes the query
PostgresEXPLAIN ANALYZEyes
MySQL 8.0.18+EXPLAIN ANALYZEyes
MariaDBANALYZE FORMAT=JSONyes
SQLiteEXPLAIN QUERY PLAN (no timings)no

The rows are discarded, but the query does run: keep it for debugging, not hot paths.