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 agroupBy. - Time-series: bucket a timestamp with a function, then group by the alias.
- Top-N:
orderByan aggregate alias and set atake. - Conditional aggregation:
countIfandsumIftake 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
- Writes: the create, update, and delete grammar.
- Saved queries & dashboards: store a query and re-run it without the model.