# Query builder

Build parameterized, dialect-aware SELECT statements with the core/query package.

Source: https://docs.ptah.run/v0.25.0/extend/query-builder/

Ptah's `core/query` package is a fluent builder for parameterized `SELECT`
statements. It is the DML counterpart to the DDL AST: a builder produces an
`*query.SelectStatement`, and `query.RenderSelect` turns that into a SQL string
plus its arguments for every dialect Ptah renders, and for an engine whose
executable provider declares a query dialect, such as YDB. See
[Dialect coverage](#dialect-coverage) for how each dialect is written and for
what each one refuses.

This is a bounded slice of the DML work in issue
[`#98`](https://github.com/stokaro/ptah/issues/98). It exists so callers can stop
hand-rolling dynamic `WHERE` / `ORDER BY` / `IN (…)` clauses with manual
placeholder counters.

## Scope

Implemented so far:

- `SELECT` with an explicit column list or `*`, and `SELECT DISTINCT`;
- `INNER`, `LEFT`, `RIGHT`, and `FULL OUTER` joins, with table aliases and
  columns qualified by a table or alias (see [Joins](#joins));
- a composable `WHERE` (and join `ON`) expression tree: `=`, `<>`, `<`, `<=`,
  `>`, `>=`, `IN`, `IS NULL`, `IS NOT NULL`, and the boolean combinators `AND`,
  `OR`, `NOT`;
- aggregate functions — `COUNT(*)`, `COUNT`, `COUNT(DISTINCT …)`, `SUM`, `AVG`,
  `MIN`, `MAX` — in the projection and in `HAVING` (see
  [Aggregates, GROUP BY, and HAVING](#aggregates-group-by-and-having));
- `GROUP BY` (bare or qualified columns) and `HAVING`;
- `ORDER BY` with per-column `ASC`/`DESC`;
- `LIMIT` and `OFFSET`;
- single-table `INSERT` (one or more `VALUES` rows), `UPDATE`, and `DELETE`,
  each with an optional `RETURNING` clause (see [Writes](#writes));
- `LIKE` and `NOT LIKE`, with the pattern bound rather than interpolated;
- arithmetic (`+`, `-`, `*`, `/`, `%`) and non-aggregate function calls, in the
  projection and anywhere else an expression is accepted;
- subqueries: `IN (SELECT …)`, `EXISTS` / `NOT EXISTS`, and a derived table in
  `FROM`;
- window functions — an aggregate with `OVER (PARTITION BY … ORDER BY …)`;
- common table expressions (`WITH name AS (…)`, one or more);
- `ON CONFLICT DO NOTHING` and `ON CONFLICT DO UPDATE`, and `INSERT … SELECT`;
- YDB's `UPSERT INTO`, built by `UpsertInto` (see [UPSERT](#upsert)).

One thing is deliberately absent: a window **frame** clause. Without one the
engine applies its default, which is what an unframed window means everywhere,
and guessing a frame would change results.

## Dialect coverage

`RenderSelect`, `RenderInsert`, `RenderUpdate`, and `RenderDelete` render for
every dialect `builtin.SupportedDialects()` returns. What differs between them
is the placeholder, the identifier quoting, and how a row limit is written. The
first column holds the strings you pass as the dialect, so the table is also
that list of names:

| Dialect | Placeholder | Identifiers | `LIMIT` / `OFFSET` |
| --- | --- | --- | --- |
| `postgresql` `postgres` `cockroachdb` `yugabytedb` | `$1`, `$2`, … | `"id"` | `LIMIT $n OFFSET $n` |
| `mysql` `mariadb` | `?` | `` `id` `` | `LIMIT ? OFFSET ?` |
| `clickhouse` | `?` | `` `id` `` | `LIMIT ? OFFSET ?` |
| `sqlite` `sqlite3` | `?` | `"id"` | `LIMIT ? OFFSET ?` |
| `sqlserver` `mssql` | `@p1`, `@p2`, … | `[id]` | `OFFSET 0 ROWS FETCH NEXT @pn ROWS ONLY` |
| `oracle` | `:1`, `:2`, … | bare `id` | `OFFSET 0 ROWS FETCH NEXT :n ROWS ONLY` |

These rows carry a decision worth stating, and so does a dialect an executable
provider registers:

- **SQL Server** writes its row limit as T-SQL's row-limiting clause, which
  requires an `OFFSET` before the `FETCH` and an `ORDER BY` before either — a
  limited query with no ordering is a syntax error there rather than an
  unordered result. `RenderSelect` therefore synthesizes `ORDER BY (SELECT NULL)
  OFFSET 0 ROWS` when the caller ordered nothing. Both are structural constants
  and bind no placeholder, so the argument list stays the caller's values in
  caller order.
- **Oracle** takes the same ANSI row-limiting clause and no sentinel `ORDER BY`,
  because it needs none. The omission is load-bearing: `ORDER BY (SELECT NULL)`
  is accepted on 23.26, where a `SELECT` needs no `FROM`, and refused on 21.3
  with `ORA-00923`. Identifiers are bare because a table created as `ora_posts`
  is `ORA_POSTS` in the catalog, and a query naming `"ora_posts"` would look for
  a table nobody created — the same decision the Oracle DDL renderer makes.
- **Spanner** renders while its [provider](../providers/spanner/) is
  selected, with the spelling the provider declares: `$1`, `$2`, … placeholders,
  `"id"` quoting, `ON CONFLICT` for an upsert, and `RETURNING`.

- **YDB** renders while its [provider](../providers/ydb/) is selected,
  with the spelling the provider declares, which the builder registers for the
  run: `` `id` `` quoting and `LIMIT $pn OFFSET $pn`. YQL has no positional
  parameter, so the builder writes YQL's named parameters, `$p1`, `$p2`, …,
  and returns each argument as a `sql.NamedArg` of that name. A YDB connection binds a parameter by name, Ptah's own and the
  YDB SDK's alike. YQL types every value, and a bound value's Go type is the
  type it binds as: an `int64` is `Int64`, so an `Int32` column takes an
  `int32`. `LIMIT` and `OFFSET` bind as `uint64`, the type YQL reads them as,
  and a negative one is refused. A table in a directory is one name,
  `` `app/users` ``.

YDB lacks constructs the other dialects render. Its provider's spelling has the
builder read the target's capabilities for each, and the builder refuses one
before it writes anything, with the capability key the target lacks: a `WITH`
clause (`common_table_expressions`), a subquery that reads a column of the query
around it (`correlated_subqueries`), a join condition other than equalities
between the joined tables' columns (`non_equi_joins`), and an `OFFSET` without a
`LIMIT` (`offset_without_limit`). `ON CONFLICT` is refused too: YDB has none and
refuses `INSERT OR IGNORE`, and its upsert is `UPSERT INTO`.

One statement is refused, and for an engine reason rather than an untaught one:

```text
renderer: UPDATE is not a portable statement on ClickHouse: a plain UPDATE runs
only on a table with the materialized _block_number column, which a statement
cannot declare; use ALTER TABLE … UPDATE, or enable enable_block_number_column
on the table
```

Every dialect name `builtin.SupportedDialects()` returns is pinned against all
four render functions — one cell per (dialect, verb) pair, each pinned to an
exact SQL string or an exact error — in `core/query/dialect_matrix_test.go`, so
the builder cannot acquire or lose a dialect without that table saying so. What
each dialect renders is also executed against a live server for SQL Server and
ClickHouse in `integration/gonative/dml_execution_integration_test.go`: a
renderer test proves the string, and only the server proves the SQL. YDB is not
among those dialect names; its spelling is the provider's declaration.

### Release lines

The four render functions render for the dialect's current release line, the
capabilities `capability.ForDialect` returns. `RenderSelectWithCapabilities`,
`RenderInsertWithCapabilities`, `RenderUpdateWithCapabilities` and
`RenderDeleteWithCapabilities` take the capabilities of the line you run
against, such as the ones a connection reports in `conn.Info().Capabilities`.
They decide `RETURNING` on every dialect, and on YDB the constructs above.

```go
stmt := query.Update("users").Set("plan", "paid").Where(query.Eq("id", int64(7))).Returning("id").Build()
sql, args, err := query.RenderUpdateWithCapabilities(stmt, platform.MariaDB, capability.MariaDB1011())
// err: renderer: RETURNING, which requires target capability returning_clause,
//      unavailable on this mariadb target
```

## Safety model

The builder keeps identifiers and values in separate lanes, so the classic
"concatenate a value into SQL" injection cannot happen through this API:

- **Values are always bound.** Arguments to `Eq`, `In`, and the other
  comparison helpers are typed as `any` and travel to the database as bound
  parameters. They are never interpolated into the SQL text. `LIMIT` and
  `OFFSET` values are bound the same way. The renderer emits the dialect's
  placeholder — `$1`, `$2`, … for PostgreSQL, `?` for MySQL, MariaDB, ClickHouse
  and SQLite, `@p1`, `@p2`, … for SQL Server, `:1`, `:2`, … for Oracle, and
  `$p1`, `$p2`, … for YDB — and returns the values in a matching `[]any`, as
  `sql.NamedArg` values on YDB.
- **Identifiers are always quoted.** Table and column names are emitted through
  dialect-aware identifier quoting, so an attacker-shaped identifier cannot
  terminate the quoted identifier and inject SQL. As with Ptah's DDL rendering,
  deciding *which* identifiers a caller may supply — for example, an allow-list
  of sortable columns — remains the caller's responsibility. The builder
  guarantees quoting and the absence of value interpolation.

Placeholder numbering is assigned by the renderer in a single left-to-right
pass, so argument order always matches placeholder order and callers never
manage indices by hand.

## Usage

```go
import (
	"ptah.run/core/platform"
	"ptah.run/core/query"
)

stmt := query.Select("id", "name").
	From("commodities").
	Where(query.And(
		query.Eq("draft", false),
		query.In("status", []string{"in_use", "sold"}),
		query.Or(
			query.IsNotNull("deleted_at"),
			query.Not(query.Gt("count", int64(10))),
		),
	)).
	OrderBy(query.Asc("name"), query.Asc("id")).
	Limit(24).
	Offset(0).
	Build()

sql, args, err := query.RenderSelect(stmt, platform.Postgres)
```

The PostgreSQL output is:

```sql
SELECT "id", "name" FROM "commodities"
WHERE ("draft" = $1 AND "status" IN ($2, $3)
       AND ("deleted_at" IS NOT NULL OR NOT ("count" > $4)))
ORDER BY "name" ASC, "id" ASC
LIMIT $5 OFFSET $6
```

with `args` equal to `[]any{false, "in_use", "sold", int64(10), int64(24), int64(0)}`.

The same statement rendered with `platform.MySQL` uses backtick-quoted
identifiers and `?` placeholders; `platform.SQLite` uses double-quoted
identifiers and `?` placeholders. The argument slice is identical across
dialects.

## Shared WHERE fragments

Expression constructors return plain expression nodes, so a filter can be built
once and attached to more than one statement — for example, the paged list and
the `COUNT(*)` that share the same filter:

```go
filter := query.And(query.Eq("draft", false), query.Eq("tenant_id", tenantID))

page := query.Select("id", "name").From("commodities").
	Where(filter).OrderBy(query.Asc("name")).Limit(20).Offset(0).Build()

total := query.Select("id").From("commodities").
	Where(filter).Build()
```

## Joins

Alias the source table with `FromAs`, add joins with `InnerJoin`, `LeftJoin`,
`RightJoin`, or `FullJoin`, and qualify columns with `Col` so they render as
`"alias"."col"`. A qualified column works everywhere a column is accepted: the
projection (via `.Columns`), the join `ON` condition, `WHERE`, and `ORDER BY`.

A join `ON` is an ordinary expression, so an equi-join is
`Col(left).EqCol(Col(right))` and richer predicates compose with `And`, `Or`, and
`Not`. `Col(table, name)` also carries the comparison helpers (`Eq`, `Ne`, `Lt`,
`Le`, `Gt`, `Ge`, `IsNull`, `IsNotNull`) and the ordering helpers (`Asc`, `Desc`)
for the qualified column.

```go
stmt := query.Select().
	Columns(query.Col("u", "id"), query.Col("u", "name"), query.Col("o", "total")).
	FromAs("users", "u").
	InnerJoin("orders", "o", query.Col("o", "user_id").EqCol(query.Col("u", "id"))).
	Where(query.And(
		query.Col("o", "status").Eq("paid"),
		query.Col("u", "active").Eq(true),
	)).
	OrderBy(query.Col("u", "name").Asc()).
	Limit(20).
	Build()

sql, args, err := query.RenderSelect(stmt, platform.Postgres)
```

The PostgreSQL output is:

```sql
SELECT "u"."id", "u"."name", "o"."total"
FROM "users" "u"
INNER JOIN "orders" "o" ON "o"."user_id" = "u"."id"
WHERE ("o"."status" = $1 AND "u"."active" = $2)
ORDER BY "u"."name" ASC
LIMIT $3
```

with `args` equal to `[]any{"paid", true, int64(20)}`. Tables render as
`table alias` (no `AS`), which every supported dialect accepts. A value inside a
join `ON` is bound **before** any `WHERE` value, because joins render first — so
placeholder numbering still follows left-to-right emission order across `ON`,
`WHERE`, and `LIMIT`/`OFFSET`.

### Join-type support by dialect

Not every dialect can express every join type. `RenderSelect` rejects an
unsupported join at render time — returning a clear error — rather than emit SQL
that fails at execution time against the database.

| Join type | PostgreSQL family, SQL Server, Oracle, ClickHouse, YDB | MySQL / MariaDB | SQLite |
| --- | --- | --- | --- |
| `INNER` | yes | yes | yes |
| `LEFT` | yes | yes | yes |
| `RIGHT` | yes | yes | no (added in 3.39) |
| `FULL OUTER` | yes | no (never supported) | no (added in 3.39) |

- **SQLite** gained `RIGHT` and `FULL OUTER JOIN` only in version 3.39 (2022).
  Because Ptah targets a range of SQLite versions and cannot assume 3.39+, both
  are rejected (`renderer: sqlite does not support RIGHT JOIN`).
- **MySQL and MariaDB** have no `FULL [OUTER] JOIN` in any version — it must be
  emulated with a `UNION` of a `LEFT` and a `RIGHT` join — so `FULL` is rejected
  (`renderer: mysql does not support FULL OUTER JOIN`). `RIGHT` renders normally.
- The **PostgreSQL family** (including CockroachDB and YugabyteDB),
  **SQL Server**, **Oracle**, **ClickHouse** and **YDB** support all four.
- **YDB** joins only on equalities between columns of the two joined tables, or
  a conjunction of them. Any other `ON` condition — an inequality, a comparison
  with a value, an `OR` — is refused with the key `non_equi_joins`; the server
  answers `JOIN ON expression must be a conjunction of equality predicates`.

## Aggregates, GROUP BY, and HAVING

`Distinct()` renders `SELECT DISTINCT`. `GroupBy` adds `GROUP BY` columns — pass
`Col(table, name)` for a qualified column across joins, or `Col("", name)` for a
bare column. GROUP BY carries only identifiers, so it never binds a placeholder.

Aggregates are built with `CountStar`, `Count`, `CountDistinct`, `Sum`, `Avg`,
`Min`, and `Max` (bare-column free functions), or the matching methods on a
qualified column: `Col("o", "total").Sum()`, `Col("u", "id").Count()`, and so on.
Each returns an expression usable in two places:

- **the projection**, via `Exprs` (no alias) or `ExprAs` (with an `AS` alias);
- **a `HAVING` predicate**, by wrapping it with `Expr` to reach the comparison
  helpers — `Expr(query.CountStar()).Gt(int64(5))` — which compose with `And`,
  `Or`, and `Not` like any other expression.

`CountStar()` is the primary way to count all rows; `Count("*")` is an equivalent
convenience for `COUNT(*)`. Every other `"*"` aggregate argument — a non-`COUNT`
aggregate, `COUNT(DISTINCT *)`, or a qualified star such as `Col("u", "*").Count()`
— has no portable star form and is rejected at render time rather than emitting an
invalid quoted `"*"`.

A function name (`COUNT`, `SUM`, …) is a keyword emitted verbatim and never
quoted; its column arguments are quoted, and any value it is compared against is
bound. The renderer rejects a function name that is not a simple identifier
rather than emit it.

```go
stmt := query.Select("status").
	ExprAs(query.CountStar(), "n").
	From("orders").
	Where(query.Eq("tenant_id", tenantID)).
	GroupBy(query.Col("", "status")).
	Having(query.Expr(query.CountStar()).Gt(int64(5))).
	OrderBy(query.Asc("status")).
	Limit(10).
	Build()

sql, args, err := query.RenderSelect(stmt, platform.Postgres)
```

The PostgreSQL output is:

```sql
SELECT "status", COUNT(*) AS "n"
FROM "orders"
WHERE "tenant_id" = $1
GROUP BY "status"
HAVING COUNT(*) > $2
ORDER BY "status" ASC
LIMIT $3
```

with `args` equal to `[]any{tenantID, int64(5), int64(10)}`. A `HAVING` value is
bound **after** every `WHERE` value and **before** `LIMIT`/`OFFSET`, so
placeholder numbering still follows left-to-right emission order across `WHERE`,
`HAVING`, and `LIMIT`/`OFFSET`.

Aggregates work over qualified columns in join queries too:

```go
stmt := query.Select().
	Columns(query.Col("u", "name")).
	ExprAs(query.Col("o", "id").Count(), "orders").
	ExprAs(query.Col("o", "total").Sum(), "spent").
	FromAs("users", "u").
	InnerJoin("orders", "o", query.Col("o", "user_id").EqCol(query.Col("u", "id"))).
	GroupBy(query.Col("u", "name")).
	Build()

// SELECT "u"."name", COUNT("o"."id") AS "orders", SUM("o"."total") AS "spent"
// FROM "users" "u" INNER JOIN "orders" "o" ON "o"."user_id" = "u"."id"
// GROUP BY "u"."name"
```

## Writes

`InsertInto`, `Update`, and `DeleteFrom` build the write-side statements. Values
passed to `Values` and `Set` are bound exactly like `WHERE` values — never
concatenated into SQL — and table and column names are quoted. Each statement has
its own renderer entry point, all returning `(sql string, args []any, err error)`:

- `query.RenderInsert(stmt, dialect)`
- `query.RenderUpdate(stmt, dialect)`
- `query.RenderDelete(stmt, dialect)`

Validation of degenerate input happens at render time (as with `SELECT`), so a
builder call never fails and `Build` never returns an error.

### UPSERT

`UpsertInto` builds `UPSERT INTO` for an engine whose spelling has one, such as
YDB while its provider is selected. It writes each row over the row with
the same primary key, keeps the columns the statement does not name, and
inserts the row where there is none. `Columns`, `Values`, `FromSelect` and
`Returning` work as they do for `InsertInto`.

```go
stmt := query.UpsertInto("users").
	Columns("id", "name").
	Values(int64(1), "alice").
	Values(int64(2), "bob").
	Build()

sql, args, err := query.RenderInsert(stmt, "ydb")
// UPSERT INTO `users` (`id`, `name`) VALUES ($p1, $p2), ($p3, $p4)
// args: sql.Named("p1", int64(1)), sql.Named("p2", "alice"), ...
```

The other dialects refuse it. Each of them names the key an upsert watches in
the statement, which the builder does not know for a table, so use
`OnConflictDoUpdate` there.

On a table with a unique index, name every column. YDB 26.2 fails an `UPSERT`
that names no column a synchronous index is keyed on with `INTERNAL_ERROR`, and
the builder cannot see the table's indexes to refuse it; see
[YDB](../providers/ydb/).

### INSERT

Declare the column list with `Columns` and add one row per `Values` call. A
multi-row insert numbers its values row by row, left to right. Passing `nil` as a
value binds SQL `NULL`.

```go
stmt := query.InsertInto("users").
	Columns("id", "name").
	Values(int64(1), "alice").
	Values(int64(2), "bob").
	Returning("id").
	Build()

sql, args, err := query.RenderInsert(stmt, platform.Postgres)
```

The PostgreSQL output is:

```sql
INSERT INTO "users" ("id", "name") VALUES ($1, $2), ($3, $4) RETURNING "id"
```

with `args` equal to `[]any{int64(1), "alice", int64(2), "bob"}`. `RenderInsert`
rejects a statement with no columns, no rows, or a row whose length does not match
the column count — ragged input fails cleanly instead of producing mismatched SQL.

### UPDATE

Add assignments with `Set` (each value bound) and a filter with `Where`. An
`UPDATE`'s `SET` values are numbered **before** its `WHERE` values, matching
emission order, so placeholder numbering follows the SQL left to right.

```go
stmt := query.Update("users").
	Set("name", "bob").
	Set("email", "bob@example.com").
	Where(query.Eq("id", int64(7))).
	Build()

sql, args, err := query.RenderUpdate(stmt, platform.Postgres)
```

The PostgreSQL output is:

```sql
UPDATE "users" SET "name" = $1, "email" = $2 WHERE "id" = $3
```

with `args` equal to `[]any{"bob", "bob@example.com", int64(7)}`. An empty `SET`
list is rejected.

### DELETE

```go
stmt := query.DeleteFrom("users").Where(query.Eq("id", int64(7))).Build()

sql, args, err := query.RenderDelete(stmt, platform.Postgres)
// DELETE FROM "users" WHERE "id" = $1   args: []any{int64(7)}
```

A `WHERE` expression can be built once and shared across statement kinds, exactly
as with `SELECT` — the expression constructors return plain nodes.

### Whole-table UPDATE / DELETE guard

An `UPDATE` or `DELETE` with no `WHERE` clause mutates every row, which is rarely
intended. Rather than make that the accidental default, the builder requires an
explicit opt-in: call `.Unconditional()`. Without it, the renderer rejects a
`WHERE`-less statement rather than run a whole-table mutation.

```go
// Rejected: "renderer: delete without a WHERE clause must be marked unconditional"
query.DeleteFrom("sessions").Build()

// Deliberate whole-table delete — renders as DELETE FROM "sessions"
query.DeleteFrom("sessions").Unconditional().Build()
```

### RETURNING support by dialect

`Returning` adds a `RETURNING` projection to any of the three statements.
`RETURNING` is not portable across every dialect, so `RenderInsert` /
`RenderUpdate` / `RenderDelete` reject it — returning a clear error — on a target
without the capability `returning_clause`, rather than emit SQL that fails at
execution time.

| Dialect | `RETURNING` |
| --- | --- |
| PostgreSQL family (incl. CockroachDB, YugabyteDB) | yes |
| SQLite | yes (since 3.35, 2021) |
| Spanner | as its provider states (yes) |
| YDB | as its provider states (see note) |
| MySQL | no |
| MariaDB | no (see note) |
| SQL Server, Azure SQL | no (see note) |
| Oracle | no (see note) |
| ClickHouse | no |

- **MySQL** has no `RETURNING` at all.
- **MariaDB** supports `RETURNING` for `INSERT` and `DELETE` but not `UPDATE`. To
  keep one rule across all three write statements, Ptah treats MariaDB as
  unsupported and rejects a non-empty `RETURNING` (`renderer: RETURNING, which
  requires target capability returning_clause, unavailable on this mariadb
  target`).
- **SQLite** gained `RETURNING` in 3.35 (2021). `RenderInsert` emits it for the
  current SQLite line; `RenderInsertWithCapabilities` with `capability.SQLite324()`
  refuses it.
- **YDB** takes `returning_clause` from its provider's session. YDB 25.1 and
  25.2 run `INSERT`, `DELETE` and `UPSERT` with `RETURNING` and fail
  `UPDATE … RETURNING` on a table with a unique index (`INTERNAL_ERROR … wrong
  returning expr type`).
- **SQL Server** has `OUTPUT`, which is a different clause in a different
  position, not a spelling of this one. Mapping `Returning` onto it would change
  what the statement means, so a non-empty `Returning` is rejected.
- **Oracle** has the keyword and not this shape: measured on 23.26,
  `INSERT INTO t (id) VALUES (99) RETURNING id INTO :out` is accepted with an
  out-parameter bound, while the same statement ending at `RETURNING id` answers
  `ORA-00925`. A projection has nowhere to go there.

## Builder reference

`Select` starts a read query; `InsertInto`, `Update`, and `DeleteFrom` start the
write statements.

| Function | Result |
| --- | --- |
| `Select(cols ...string)` | Start a builder; `"*"` or no columns selects all. |
| `.Distinct()` | Render `SELECT DISTINCT`. |
| `.Columns(cols ...Column)` | Append qualified columns (from `Col`) to the projection. |
| `.Exprs(exprs ...ast.Expression)` | Append expression projections (for example aggregates). |
| `.ExprAs(expr, alias)` | Append one expression projection with an `AS` alias. |
| `.From(table)` | Set the source table (required); clears any alias. |
| `.FromAs(table, alias)` | Set the source table with an alias. |
| `.InnerJoin` / `.LeftJoin` / `.RightJoin` / `.FullJoin` `(table, alias, on)` | Append a join with an `ON` condition. |
| `.Where(expr)` | Set the filter expression; a later call replaces the earlier one. |
| `.GroupBy(cols ...Column)` | Append `GROUP BY` columns across calls. |
| `.Having(expr)` | Set the `HAVING` predicate; a later call replaces the earlier one. |
| `.OrderBy(terms ...)` | Append sort terms across calls. |
| `.Limit(n)` / `.Offset(n)` | Set bound row limit/offset. |
| `.Build()` | Produce the `*ast.SelectStatement`. |

Write builders:

| Function | Result |
| --- | --- |
| `InsertInto(table)` | Start an `INSERT`. |
| `UpsertInto(table)` | Start an `UPSERT`, for an engine that spells one. |
| `.Columns(cols ...string)` | Declare the inserted column list. |
| `.Values(vals ...any)` | Append one row of bound values; call once per row. |
| `Update(table)` | Start an `UPDATE`. |
| `.Set(col, value)` | Append a bound `column = value` assignment. |
| `DeleteFrom(table)` | Start a `DELETE`. |
| `.Where(expr)` | Set the filter (shared by `Update` and `DeleteFrom`). |
| `.Unconditional()` | Opt in to a whole-table `UPDATE`/`DELETE` (required when no `Where`). |
| `.Returning(cols ...string)` | Add a `RETURNING` projection, where the target has `returning_clause`. |
| `.Build()` | Produce the `*ast.InsertStatement` / `*ast.UpdateStatement` / `*ast.DeleteStatement`. |

Expression helpers: `Eq`, `Ne`, `Lt`, `Le`, `Gt`, `Ge`, `In`, `IsNull`,
`IsNotNull`, `And`, `Or`, `Not`. Ordering helpers: `Asc`, `Desc`. Aggregate
helpers: `CountStar`, `Count`, `CountDistinct`, `Sum`, `Avg`, `Min`, `Max`, and
`Expr(expr)` with `.Eq`/`.Ne`/`.Lt`/`.Le`/`.Gt`/`.Ge` for `HAVING` comparisons.
Qualified columns: `Col(table, name)`, with `.Eq`/`.Ne`/`.Lt`/`.Le`/`.Gt`/`.Ge`,
`.EqCol` (column-to-column, for `ON`), `.IsNull`/`.IsNotNull`, `.Asc`/`.Desc`, and
the aggregate methods `.Count`/`.CountDistinct`/`.Sum`/`.Avg`/`.Min`/`.Max`.

`query.RenderSelect(stmt, dialect)` returns `(sql string, args []any, err error)`.
It returns an error for an unsupported dialect, a missing `FROM` table, an empty
`IN` list, or a malformed statement.

### OFFSET without LIMIT

MySQL, MariaDB, and SQLite only accept `OFFSET` as a suffix of `LIMIT`, so
setting `Offset` without `Limit` renders a dialect-specific "no limit" sentinel
in front of the bound `OFFSET`: `LIMIT -1` for SQLite and
`LIMIT 18446744073709551615` for MySQL and MariaDB. PostgreSQL and ClickHouse
accept a bare `OFFSET` and emit one. SQL Server and Oracle write it as the
row-limiting clause with no `FETCH`, `OFFSET @p1 ROWS` and `OFFSET :1 ROWS`, and
SQL Server keeps the sentinel `ORDER BY (SELECT NULL)` that clause requires. The sentinel is a structural constant, not caller data,
so it is emitted as a literal and the `OFFSET` value stays a bound parameter.

YDB has no sentinel that works: a `LIMIT` of the `Uint64` maximum fails the
query, and the `Int64` maximum leaves it running without an answer. So
`Offset` without `Limit` is refused there with the key `offset_without_limit`;
set a limit.
