Queries

The model reads your database through one query tool. Instead of SQL, it emits a structured query object that valv validates and compiles. The grammar is Prisma-idiomatic — a shape models already know well — so an agent composes queries reliably, and valv desugars them server-side into a checked query.

A query starts from one resource and combines a select object, an optional where filter, and optional groupBy, orderBy, and take. It can also pull in related resources through joins. That covers real analytics without exposing arbitrary SQL.

Selecting columns and aggregates

select is an object keyed by output name. Each value says what that column is:

  • true — the column of that name.
  • { "col": "path" } — a column under a different output name, or a joined column.
  • { fn: args } — a function call; the key names the output.
{
  "from": "orders",
  "select": {
    "status": true, // a plain column
    "orders": { "count": true }, // count(*) — the key names the output
    "revenue": { "sum": "total" } // an aggregate
  },
  "groupBy": ["status"]
}

A function’s arguments follow its signature. Call it as { name: column } for a single column, { name: true } for no arguments (like count), or { name: [args] } positionally. Each positional argument is a column name, a number, an enum value, or a predicate — valv reads the function’s signature to know which, so you never tag arguments by hand:

{
  "p95": { "quantileTiming": [0.95, "latency"] }, // number, column
  "bucket": { "toStartOfInterval": ["ts", 1, "hour"] }, // column, number, enum
  "errors": { "countIf": { "status": { "gte": 500 } } } // a predicate (a filter)
}

Filtering with where

where is a Prisma-style filter. A bare value is equality; an operator object applies comparisons; and AND, OR, and NOT combine sub-filters:

{
  "status": "paid", // equality
  "total": { "gte": 100 }, // an operator
  "created_at": { "gte": "2026-06-01", "lt": "2026-07-01" } // a range (AND-ed)
}

Multiple keys in one object are AND-ed together. Use the logical keys for anything else:

{
  "OR": [{ "status": "paid" }, { "status": "shipped" }],
  "NOT": { "region": "internal" }
}

The operators are equals, not, gt, gte, lt, lte, in, notIn, and contains / startsWith / endsWith for text. Values are always bound parameters — never concatenated into SQL. Scope filters are added server-side, so you never write a tenant or owner filter yourself.

Lists

in and notIn match a column against a set of values:

{ "status": { "in": ["paid", "shipped", "delivered"] } }

Pattern matching

contains, startsWith, and endsWith match text without you writing raw wildcards — valv escapes any % or _ in your value, so it searches for the literal text. Add "mode": "insensitive" for a case-insensitive match:

{ "email": { "endsWith": "@acme.com" } }
{ "name": { "contains": "acme", "mode": "insensitive" } }

Case-insensitive matching maps to each database’s form: ILIKE on Postgres, Cockroach, and ClickHouse, and LIKE on MySQL and SQLite (where LIKE is already case-insensitive).

Common shapes

The grammar is small, but it expresses the questions agents actually ask:

  • Aggregates: count, sum, and friends over a groupBy.
  • Time-series: bucket a timestamp with a function, then group by the alias.
  • Top-N: orderBy an aggregate alias and set a take.
  • Conditional aggregation: countIf and sumIf take a predicate argument.
{
  "from": "orders",
  "select": {
    "status": true,
    "revenue": { "sum": "total" }
  },
  "where": { "created_at": { "gte": "2026-06-01" } },
  "groupBy": ["status"],
  "orderBy": { "revenue": "desc" },
  "take": 10
}

orderBy is { column: "asc" | "desc" } — a select alias (like an aggregate) or a column — or an array of them for multiple sort keys, in order.

Joins

To read from a related resource, reference its column with a dotted path from the query’s root: "customer.name", or "customer.region.name" across multiple hops. The model can only follow relations declared in your schema; valv derives the joins and picks the keys.

{
  "from": "orders",
  "select": {
    "customer_name": { "col": "customer.name" }, // one hop: orders → customer
    "region": { "col": "customer.region.name" }, // multi-hop: → customer → region
    "revenue": { "sum": "total" }
  },
  "groupBy": ["customer.name"]
}

A dotted path works anywhere a column does: in select, where, groupBy, and orderBy. A root column takes no dot.

A join doesn’t widen access. valv composes the policy of every table it touches, so each joined resource is scoped by its own row filter and field rules. A join can’t reach a column you hid on the related table or rows outside the caller’s scope. See Policies. To bound cost, valv caps the join depth, the number of joined tables, and the fan-out from hasMany relations, and every query runs under a statement timeout.

Allowed functions

Every function the model uses must be in valv’s registry, and its arguments are type-checked against the function’s signature. Core ships a base set of functions; an adapter adds its dialect’s functions. ClickHouse, for example, adds functions like toStartOfInterval and quantileTiming. Because the grammar is signature-driven, a dialect function needs no special handling — the query tool advertises its name and argument order automatically. A function that isn’t registered fails validation.

Limits

valv caps how many rows a query can return. A wide-open query can’t dump an unbounded result set into the model’s context or run away with your token budget. A query can request a smaller take, but not exceed the cap.

Next steps