Sub-queries (IN / NOT IN / EXISTS)

Predicates that reference other queries — col IN (SELECT ...), EXISTS (SELECT ...).

inSubquery / notInSubquery / exists / notExists let a WHERE predicate reference the results of another query. Use when you'd otherwise pull a list of IDs to the app and filter client-side.

Quick start

import { inSubquery, eq, queryFor } from '@voltro/database'

// Find every post by an actively-banned user
const blockedUserIds = queryFor(database.blockedUsers).select('userId')

const banned = await ctx.store.query(
  queryFor(database.posts)
    .where(inSubquery('userId', blockedUserIds))
    .descriptor,
)

Compiles to:

SELECT * FROM "posts" WHERE "userId" IN (SELECT "userId" FROM "blockedUsers")

The sub-query runs as part of the same SQL statement — one round- trip, the DB optimiser decides whether to materialise the inner set or use a hash semi-join.

inSubquery / notInSubquery

Both expect the sub-query to project a single column (use .select('colName') on the inner query). The predicate matches rows whose specified outer column appears (or doesn't) in the inner result set.

// Not in
const visibleUsers = await ctx.store.query(
  queryFor(database.users)
    .where(notInSubquery('id', queryFor(database.blockedUsers).select('userId')))
    .descriptor,
)

Empty inner set semantics: inSubquery against an empty set matches NOTHING (no row's column is "in" an empty list). notInSubquery against an empty set matches EVERYTHING (every row's column is "not in" an empty list). The framework matches SQL exactly.

exists / notExists

Doesn't bind to a specific outer column — the predicate's truth depends only on whether the sub-query produces any rows at all.

import { exists, queryFor } from '@voltro/database'

// Users who have at least one post
const authors = await ctx.store.query(
  queryFor(database.users)
    .where(exists(queryFor(database.posts).where(eq('userId', 'placeholder'))))
    .descriptor,
)

exists is typically faster than inSubquery when the inner set is large but you only need yes-or-no — the DB stops after the first match.

Composing with and / or

Sub-query predicates compose with the regular boolean combinators:

import { and, or, eq, inSubquery } from '@voltro/database'

queryFor(database.users).where(
  and(
    eq('tenantId', tenantId),
    inSubquery('id', queryFor(database.bannedUsers).select('userId')),
  ),
)

Limitations (v1)

Non-correlated only. The inner query can NOT reference outer-row columns like WHERE inner.userId = users.id. For correlated sub-queries (a common shape: "user who has at least one post created in the last hour") use a Self-join or an Eager-load — both can express the same query without the correlation reference.

Reactivity

Sub-query predicates ARE reactive. The matcher tracks BOTH the outer descriptor's table AND every sub-query's table. A write to the inner table re-evaluates the outer query.

In the in-memory store the framework pre-materialises every sub-query before evaluating the outer predicate (one pass per sub-query, not per row). Same fast-path applies to SQL stores via the standard IN (SELECT ...) query plan.

Cross-dialect

IN (SELECT ...) and EXISTS (SELECT ...) are standard SQL on every dialect we ship. No per-dialect dispatch.

See also

  • Aggregations — sub-queries paired with count() etc. for "count of X where Y belongs to Z"
  • Self-joins — when the relationship can be expressed as a join instead
  • CTEs — for naming a sub-query you reuse multiple times in the same outer query