The generated client.<type>.filter() method takes a single object whose shape mirrors your schema. Object keys are implicit-AND across fields, scalar fields accept either a bare value (equality) or a Mongo-style operator object, links recurse into the target type, and reserved keys (select, order_by, limit, offset) shape the query result.
The API is designed so most queries read like data — no string EdgeQL, no expression DSL, no .run() step. For the cases that do need composition (OR, negation, mixed predicates), three combinators (and, or, not) wrap Filter objects.
Related documentation: Codegen | Client SDK | EdgeQL
import DiscClient from "./dbschema/disc-client";
const client = new DiscClient(/* config */);
const merchants = await client.merchant.filter({
email: "user@example.com",
active: true
});
That object compiles to roughly select Merchant { * } filter .email = <str>$p0 and .active = <bool>$p1 and runs against your PostgreSQL instance.
Multiple keys at the top level are implicit AND. Each scalar field is typed against your schema, so a typo or wrong-type value is a TypeScript error before runtime.
Per-field operators are nested objects. The supported set covers the common cases:
| Operator | EdgeQL | Notes |
|---|---|---|
eq |
.f = $p |
Equivalent to a bare value ({ f: x } ≡ { f: { eq: x } }) |
ne |
.f != $p |
|
gt, gte, lt, lte |
.f > $p etc. |
Numbers, dates, durations, strings |
like, ilike |
.f like $p, .f ilike $p |
String pattern matching |
in, not_in |
.f in array_unpack(<array<T>>$p) |
Array of values, lowers to UNNEST |
await client.payment.filter({
amount: { gte: 100, lt: 1000 }, // range
email: { ilike: "%@example.com" }, // pattern
status: { in: ["paid", "refunded"] } // membership
});
Range queries on one field stay grouped ({ amount: { gte, lt } }) instead of being split into two unrelated keys.
undefined vs nullA field whose value is undefined is skipped — it adds no constraint. This lets you pass optional predicates without branching:
// Filter by id OR slug — whichever is set. The undefined one is ignored.
await client.video.filter({ id: maybeId, limit: 1, slug: maybeSlug });
null is a real value (.f = <T>$p with a null binding), so use undefined, not null, to mean "no constraint."
Top-level keys are AND. For everything else, three combinators import from your generated client:
import DiscClient, { and, not, or } from "./dbschema/disc-client";
const client = new DiscClient(/* config */);
await client.payment.filter(
or({ status: "paid" }, { status: "refunded" })
);
await client.user.filter(
and({ active: true }, or({ tier: "gold" }, { spend: { gte: 1000 } }))
);
await client.user.filter(not({ active: false }));
The combinators accept either Filter objects or other combinators, so they nest freely. A bare Filter object never needs and(...) since its keys already AND together.
A condition on a multi property, or reached through a multi link — through any mix of single and multi links, in either order ({ best: { tags: { name: "x" } } }, { posts: { author: { name: "x" } } }) — holds when some element matches. The generated client compiles it to any(<comparison>) — one boolean, false when there is no element — so the combinators read the way they look:
await client.user.filter({ nicks: "a1" }); // some nick is "a1"
await client.user.filter(not({ nicks: "a1" })); // no nick is "a1" (a user with no nicks included)
await client.user.filter({ nicks: { ne: "a1" } }); // some nick is not "a1"
or(...) holds when either side does, and sibling keys are independent conditions: { posts: { title: "a", published: true } } is a user with a post titled "a" and a published post, not necessarily the same one. Each operator in an operator object is its own condition too ({ nicks: { gte: "a", lt: "b" } } asks for some nick ≥ "a" and some nick < "b").
A condition on a single property or link compares the way the filter object reads: an empty value (an optional field with none) matches no condition on it — neither the condition nor its not — and or still holds when the other side does:
await client.user.filter(or({ visits: 1 }, { name: "ann" })); // ann matches even with no visits
await client.user.filter(not({ visits: 1 })); // users with visits other than 1; none without visits
In EdgeQL an or with an empty operand is empty, so the client emits a condition under or as (<comparison>) ?? false, and moves not inwards onto each condition (not(and(a, b)) is not a or not b).
The emitted any(…) and ?? false mean the same in Gel whichever path scoping it uses. Raw EdgeQL follows Gel instead, which differs for not and or over multi paths — see EdgeQL → Filtering on multi paths — and makes filter .visits = 1 or .name = "ann" keep no user without visits (Logical Operators).
By default filter() returns every scalar field of the type (the EdgeQL { * } splat), typed as the generated <Type>Row (id and the stored properties, no links). Pass select to narrow:
await client.merchant.filter({
active: true,
select: {
email: true,
id: true,
name: true
}
});
select follows the schema shape: field: true includes a scalar, link: true pulls all of the linked object’s fields, link: { ... } narrows the linked object too.
await client.payment.filter({
select: {
amount: true,
id: true,
merchant: { name: true, tier: true }
}
});
The { * } splat (and link: true, which expands to { * }) returns stored columns only — computed properties are excluded. Select them explicitly, including inside a nested link:
await client.channel.filter({
select: {
"*": true, // stored columns
counts: true, // computed — must be named explicitly
owner: { name: true, counts: true } // computed on a linked object, too
}
});
A link sub-shape can carry its own filter to narrow the rows returned for that link. It takes the same object as a top-level filter — operators, implicit AND, combinators — but its fields are read against the linked type.
await client.channel.filter({
select: {
"*": true,
videos: {
"*": true,
filter: {
isDraft: 0n,
isPrivate: 0n,
isUnlisted: 0n
},
order_by: ["-created"]
}
},
slug: "music"
});
// → select Channel { *, videos: { * } filter .isDraft = <int64>$p0 and …
// order by .created desc } filter .slug = <str>$p3
This is not the same as a sibling link key, and the difference matters:
| Where the predicate goes | What it constrains | Result |
|---|---|---|
{ videos: { isDraft: 0n } } — sibling key |
The channel (compiles to EXISTS) |
Channels having at least one non-draft video, each with all its videos |
{ select: { videos: { filter: { isDraft: 0n } } } } |
The video set | Every channel, each with only its non-draft videos |
A sub-shape filter never drops the parent row — a channel with no matching videos still comes back, with an empty array. The two forms compose: use the sibling key to pick which channels you want, and the sub-shape filter to pick which videos come with them.
A filter placed at the top level of select is ignored — root narrowing uses the filter object’s own fields.
A link sub-shape can carry its own order_by to sort that link’s rows. Same convention as the top-level key: a string or array of strings, - prefix for descending.
await client.channel.filter({
select: {
"*": true,
videos: { "*": true, order_by: ["-created"] } // newest videos first
}
});
// → ... { *, videos: { * } order by .created desc }
The ordering applies to that link only (it compiles to an ORDER BY inside the link’s jsonb_agg) and nests to any depth. An order_by placed at the top level of select is ignored — top-level result ordering uses the sibling order_by (see Ordering, limit, offset).
A link sub-shape also takes offset and limit (non-negative integers) to page that link’s rows, after its filter and order_by:
await client.channel.filter({
select: {
"*": true,
videos: { "*": true, limit: 5, order_by: ["-created"] } // the five newest videos
}
});
// → ... { *, videos: { * } order by .created desc limit 5 }
Like filter and order_by, an offset or limit at the top level of select is ignored — top-level paging uses the sibling offset / limit.
Linked objects are nested filter objects. The link’s name in your schema is the key.
id — no subqueryawait client.payment.filter({
merchant: { id: merchantId }
});
// → ... filter .merchant_id = $1
When the path ends at .id, the foreign-key column on the source table is the target’s id, so the SQL collapses to a plain column comparison — no subquery, no JOIN.
await client.payment.filter({
merchant: { email: "x@y.com" }
});
// → ... filter (SELECT email FROM merchants WHERE id = p.merchant_id) = $1
await client.payment.filter({
merchant: { owner: { email: "owner@y.com" } }
});
// → ... filter (SELECT email FROM owners WHERE id =
// (SELECT owner_id FROM merchants WHERE id = p.merchant_id)) = $1
Any number of single-link hops compose by recursive subquery wrapping. When the terminal step is id, one nesting layer is elided.
EXISTSFor multi links with a backlink (e.g., User has multi posts: Post linked back via Post.author):
await client.user.filter({
posts: { title: "hello world" }
});
// EdgeQL → ... filter any(.posts.title = <str>$p0)
// SQL → ... WHERE EXISTS (SELECT 1 FROM posts p WHERE p.author_id = u.id AND p.title = $1)
The condition holds when any linked post matches (see Multi properties and multi links). The client wraps it in any(…), and the compiler turns that into one EXISTS — no need for ANY/SOME juggling.
EXISTS + JOINFor multi tags: Tag linked through user_tags(user_id, tag_id):
await client.user.filter({
tags: { name: "important" }
});
// → ... filter EXISTS (
// SELECT 1 FROM user_tags j
// INNER JOIN tags t ON t.id = j.tag_id
// WHERE j.user_id = u.id AND t.name = $1)
When the terminal step is id, the JOIN is elided since the junction’s target column already holds the target’s id:
await client.user.filter({ tags: { id: tagId } });
// → ... filter EXISTS (SELECT 1 FROM user_tags j WHERE j.user_id = u.id AND j.tag_id = $1)
Operators work through the link too. Membership (in) over a multi-link means "has a related row whose field is in the set" — e.g. videos sharing any tag with a set of ids:
await client.video.filter({ tags: { id: { in: tagIds } } });
// → ... filter EXISTS (SELECT 1 FROM video_tags j WHERE j.video_id = v.id AND j.tag_id = ANY($1))
EXISTSNested filter objects can descend through more than one multi link. Each multi hop adds an EXISTS layer, correlated to the one above it:
await client.customer.filter({
channels: { videos: { isDraft: 0n } }
});
// → ... filter EXISTS (SELECT 1 FROM channels c WHERE c.customer_id = cu.id
// AND EXISTS (SELECT 1 FROM videos v WHERE v.channel_id = c.id AND v.is_draft = $1))
This composes to any depth, and the hops can mix junction-table, backlink and single-FK links freely, in any order: a chain that starts with a single link and later reaches a multi link ({ author: { posts: { title: "x" } } }) also means "some element matches".
Sibling keys are independent conditions, each its own any(…) and nested EXISTS, AND-ed together, so { channels: { videos: { isDraft: 0n, isPrivate: 0n } } } matches a customer that has a channel with a non-draft video and a channel with a non-private video — not necessarily the same video. This is the path scoping of Gel’s future simple_scoping; Gel 7’s default (legacy path factoring) would require one video matching both. To require one linked object that meets every condition, use raw EdgeQL: filter exists (select .channels.videos filter .isDraft = 0 and .isPrivate = 0).
You can filter on a field of a computed named-tuple property. Given:
type Channel {
counts := ( videos := count(.<channel[is Video]), posts := count(.<channel[is Post]) );
}
filter on counts.videos with the same operator objects as any scalar — the field is typed (count/sum → bigint):
await client.channel.filter({
counts: { videos: { gte: 5n } } // channels with ≥ 5 videos
});
// → ... filter (SELECT count(*) FROM video WHERE video.channel_id = channel.id) >= <int64>$p
The compiler inlines the named field’s underlying expression, so counts.videos becomes the same correlated aggregate it computes on read — no stored column, no extra query. Only named-tuple computeds whose fields are aggregates/simple scalars are exposed in the typed filter; anything else stays reachable via raw EdgeQL (filter count(.<channel[is Video]) >= 5).
Reserved keys order_by, limit, offset sit alongside your predicates at the top level.
await client.payment.filter({
status: "paid",
order_by: "-created", // `-` prefix means desc
limit: 10,
offset: 20
});
order_by accepts either a string or an array of strings for multi-key sort. The - prefix on any field flips that key to descending; otherwise it ascends.
await client.payment.filter({
order_by: ["-created", "amount"] // newest first, then amount asc
});
// → ... order by .created desc then .amount
For a random ordering, pass "random()" as an order_by entry. It compiles to SQL order by random() and can be combined with field keys (later keys break ties). random() is the only function form accepted here; any other value must be a plain field name.
await client.payment.filter({
order_by: "random()", // random row order
limit: 1
});
await client.payment.filter({
order_by: ["-created", "random()"] // newest first, ties broken randomly
});
// → ... order by .created desc then random()
limit and offset work together or alone, in either order at the EdgeQL level.
filter() always returns an array. For single-row lookups, set limit: 1 and destructure:
const [merchant] = await client.merchant.filter({
email: this.query.email,
limit: 1
});
This intentionally avoids a separate findOne() method on every type — one mental model, one method.
Every feature in one query:
import DiscClient, { or } from "./dbschema/disc-client";
const client = new DiscClient(/* config */);
const [PAYMENT] = await client.payment.filter({
// Predicate fields
amount: { gte: 100 },
merchant: { id: this.query.merchantId },
status: { in: ["pending", "active"] },
// Boolean composition mid-object stays clean — outer keys AND
// these together, but you can also nest combinators where
// they're needed:
// ...or({ tier: "gold" }, { spend: { gte: 1000 } }),
// Shape narrowing (links can recurse and order their own set)
select: {
amount: true,
id: true,
merchant: { name: true, tier: true },
refunds: { "*": true, order_by: ["-created"] }
},
// Result shaping
limit: 1,
order_by: "-created"
});
That object lowers to one EdgeQL query, one round-trip to PostgreSQL, with parameters bound through the wire codec — no string interpolation, no SQL escape hatch needed for the common case.
When the object form doesn’t fit (deeply custom EdgeQL, schema features the filter compiler doesn’t yet cover):
await client.query<T>("select X { ... } filter ...", { params }) is always available. The codegen is a layer on top, never in the way. Two things the builders cannot express yet and raw EdgeQL can: observing exactly which rows a mutation touched (select (update X filter … set { … }) { id }, the compare-and-swap idiom — see EdgeQL → Selecting over a mutation) and a bulk insert from a JSON array in one statement (EdgeQL → Bulk insert from JSON). With raw queries, name bytes fields to revive: client.query(q, vars, { revive: { bytes: ["content"] } }) — the generated builders do this for you.from("X").select({...}).filter(u => u.email.eq("x")).toEdgeQL() is the Phase 1 builder for ad-hoc queries. See Client SDK → Codegen-free query builder.A few patterns have no filter-object form yet and need raw EdgeQL:
filter .<author[is Post].title = <str>$t).These are tracked alongside the closed gaps in the test suite at sdk/filter-compiler-edgeql.test.ts and compiler/compiler.test.ts.