Queries

The model reads your database through one query tool. Instead of SQL, it emits a structured query object that valv validates and compiles. This page describes that grammar, so you know what an agent can express and what valv will reject.

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

Selecting columns and aggregates

select is a list of entries. Each entry is either a column or a function call, and as names the output column:

{
  "from": "orders",
  "select": [
    { "col": "status" },
    { "fn": "count", "args": [], "as": "orders" },
    { "fn": "sum", "args": [{ "kind": "col", "name": "total" }], "as": "revenue" }
  ],
  "groupBy": ["status"]
}

A function’s arguments are expressions, interpreted positionally against the function’s signature. An argument can be a column, a literal value, or a predicate.

Filtering with where

where is an expression tree. You compose comparisons with and, or, and not, to any depth:

{
  "kind": "and",
  "args": [
    { "kind": "cmp", "op": ">=", "left": { "kind": "col", "name": "created_at" },
      "right": { "kind": "value", "value": "2026-06-01" } },
    { "kind": "cmp", "op": "=", "left": { "kind": "col", "name": "status" },
      "right": { "kind": "value", "value": "paid" } }
  ]
}

The comparison operators are =, !=, >, <, >=, and <=. A value node becomes a bound parameter at emit time; values are never concatenated into SQL.

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 limit.
  • Conditional aggregation: countIf and sumIf take a predicate argument.
{
  "from": "orders",
  "select": [
    { "col": "status" },
    { "fn": "sum", "args": [{ "kind": "col", "name": "total" }], "as": "revenue" }
  ],
  "groupBy": ["status"],
  "orderBy": [{ "col": "revenue", "dir": "desc" }],
  "limit": 10
}

Joins

To read from a related resource, qualify a column with rel, a relation path from the query’s root. The model can only follow relations declared in your schema; valv derives the joins and picks the keys.

{
  "from": "orders",
  "select": [
    { "col": "name", "rel": ["customer"] }, // one hop: orders → customer
    { "col": "name", "rel": ["customer", "region"] }, // multi-hop: → customer → region
    { "fn": "sum", "args": [{ "kind": "col", "name": "total" }], "as": "revenue" }
  ],
  "groupBy": [{ "col": "name", "rel": ["customer"] }]
}

A rel path works anywhere a column does: in select, where, groupBy, and orderBy.

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. 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 limit, but not exceed the cap.

Next steps