Filter API

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


The shape

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.


Operators

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 null

A 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."


Boolean composition

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.


Shape narrowing

By default filter() returns every scalar field of the type (the EdgeQL { * } splat). 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 }
  }
});

Computed properties are opt-in

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
  }
});

Narrowing a linked set

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.

Ordering a linked set

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). Capping a linked set (limit/offset on a sub-shape) is not yet supported; see Not yet supported.


Link traversal

Linked objects are nested filter objects. The link’s name in your schema is the key.

Single link, terminal id — no subquery

await 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.

Single link, terminal property — correlated subquery

await client.payment.filter({
  merchant: { email: "x@y.com" }
});
// → ... filter (SELECT email FROM merchants WHERE id = p.merchant_id) = $1

N-hop chain — nested correlated subqueries

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.

Multi-link (one-to-many) — EXISTS

For 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" }
});
// → ... filter EXISTS (SELECT 1 FROM posts p WHERE p.author_id = u.id AND p.title = $1)

EdgeQL set-comparison semantics say set OP scalar is true if any element matches. The compiler rewrites the whole comparison to EXISTS — no need for ANY/SOME juggling.

Junction table (many-to-many) — EXISTS + JOIN

For 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))

Multi-link chain — nested EXISTS

Nested 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 a trailing single-FK link freely. The one rule: the first hop must be a multi link — a chain that starts with a single link and only later reaches a multi link still needs raw EdgeQL (see Not yet supported).

Sibling keys each become their own 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 (standard EdgeQL set semantics — not necessarily the same video).


Computed field filters

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/sumbigint):

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).


Ordering, limit, offset

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.


Single-row queries

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.


Putting it all together

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.


Escape hatches

When the object form doesn’t fit (deeply custom EdgeQL, schema features the filter compiler doesn’t yet cover):


Not yet supported

A few patterns lower to compiler errors today and should fall back to raw EdgeQL until they land:

These are tracked alongside the closed gaps in the test suite at sdk/filter-compiler-edgeql.test.ts and compiler/compiler.test.ts.