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
*ast.SelectStatement, and renderer.RenderSelect turns that into a SQL string
plus its positional arguments for the PostgreSQL family, MySQL, MariaDB, and
SQLite. See Dialect coverage for the exact set and for the
dialects that are refused.
This is a bounded slice of the DML work in issue
#98. It exists so callers can stop
hand-rolling dynamic WHERE / ORDER BY / IN (…) clauses with manual
placeholder counters.
Implemented so far:
SELECTwith an explicit column list or*, andSELECT DISTINCT;INNER,LEFT,RIGHT, andFULL OUTERjoins, with table aliases and columns qualified by a table or alias (see Joins);- a composable
WHERE(and joinON) expression tree:=,<>,<,<=,>,>=,IN,IS NULL,IS NOT NULL, and the boolean combinatorsAND,OR,NOT; - aggregate functions —
COUNT(*),COUNT,COUNT(DISTINCT …),SUM,AVG,MIN,MAX— in the projection and inHAVING(see Aggregates, GROUP BY, and HAVING); GROUP BY(bare or qualified columns) andHAVING;ORDER BYwith per-columnASC/DESC;LIMITandOFFSET;- single-table
INSERT(one or moreVALUESrows),UPDATE, andDELETE, each with an optionalRETURNINGclause (see Writes); LIKEandNOT 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 inFROM; - window functions — an aggregate with
OVER (PARTITION BY … ORDER BY …); - common table expressions (
WITH name AS (…), one or more); ON CONFLICT DO NOTHINGandON CONFLICT DO UPDATE, andINSERT … SELECT.
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
Section titled “Dialect coverage”RenderSelect, RenderInsert, RenderUpdate, and RenderDelete render for
every dialect renderer.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 spanner |
$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 |
Three of those rows carry a decision worth stating:
- SQL Server writes its row limit as T-SQL’s row-limiting clause, which
requires an
OFFSETbefore theFETCHand anORDER BYbefore either — a limited query with no ordering is a syntax error there rather than an unordered result.RenderSelecttherefore synthesizesORDER BY (SELECT NULL) OFFSET 0 ROWSwhen 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 aSELECTneeds noFROM, and refused on 21.3 withORA-00923. Identifiers are bare because a table created asora_postsisORA_POSTSin 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 byte-identically to PostgreSQL, including
RETURNING. It has no live coverage in this repository, so the support matrix caveat applies — review the generated SQL before relying on it.
One statement is refused, and for an engine reason rather than an untaught one:
renderer: UPDATE is not a portable statement on ClickHouse: a plain UPDATE runsonly on a table with the materialized _block_number column, which a statementcannot declare; use ALTER TABLE … UPDATE, or enable enable_block_number_columnon the tableEvery dialect name renderer.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/renderer/dml_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.
Safety model
Section titled “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 asanyand travel to the database as bound parameters. They are never interpolated into the SQL text.LIMITandOFFSETvalues 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, and:1,:2, … for Oracle — and returns the values in a matching[]any. - 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.
import ( "go.5x5.cz/ptah/core/platform" "go.5x5.cz/ptah/core/query" "go.5x5.cz/ptah/core/renderer")
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 := renderer.RenderSelect(stmt, platform.Postgres)The PostgreSQL output is:
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" ASCLIMIT $5 OFFSET $6with 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
Section titled “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:
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()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.
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 := renderer.RenderSelect(stmt, platform.Postgres)The PostgreSQL output is:
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" ASCLIMIT $3with 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
Section titled “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 | 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
RIGHTandFULL OUTER JOINonly 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] JOINin any version — it must be emulated with aUNIONof aLEFTand aRIGHTjoin — soFULLis rejected (renderer: mysql does not support FULL OUTER JOIN).RIGHTrenders normally. - The PostgreSQL family (including CockroachDB, YugabyteDB, and Spanner), SQL Server, Oracle, and ClickHouse support all four.
Aggregates, GROUP BY, and HAVING
Section titled “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) orExprAs(with anASalias); - a
HAVINGpredicate, by wrapping it withExprto reach the comparison helpers —Expr(query.CountStar()).Gt(int64(5))— which compose withAnd,Or, andNotlike 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.
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 := renderer.RenderSelect(stmt, platform.Postgres)The PostgreSQL output is:
SELECT "status", COUNT(*) AS "n"FROM "orders"WHERE "tenant_id" = $1GROUP BY "status"HAVING COUNT(*) > $2ORDER BY "status" ASCLIMIT $3with 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:
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
Section titled “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):
renderer.RenderInsert(stmt, dialect)renderer.RenderUpdate(stmt, dialect)renderer.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.
INSERT
Section titled “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.
stmt := query.InsertInto("users"). Columns("id", "name"). Values(int64(1), "alice"). Values(int64(2), "bob"). Returning("id"). Build()
sql, args, err := renderer.RenderInsert(stmt, platform.Postgres)The PostgreSQL output is:
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
Section titled “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.
stmt := query.Update("users"). Set("name", "bob"). Set("email", "bob@example.com"). Where(query.Eq("id", int64(7))). Build()
sql, args, err := renderer.RenderUpdate(stmt, platform.Postgres)The PostgreSQL output is:
UPDATE "users" SET "name" = $1, "email" = $2 WHERE "id" = $3with args equal to []any{"bob", "bob@example.com", int64(7)}. An empty SET
list is rejected.
DELETE
Section titled “DELETE”stmt := query.DeleteFrom("users").Where(query.Eq("id", int64(7))).Build()
sql, args, err := renderer.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
Section titled “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.
// 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
Section titled “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 dialect
that cannot execute it, rather than emit SQL that fails at execution time.
| Dialect | RETURNING |
|---|---|
| PostgreSQL family (incl. CockroachDB, YugabyteDB, Spanner) | yes |
| SQLite | yes (since 3.35, 2021) |
| MySQL | no |
| MariaDB | no (see note) |
| SQL Server, Azure SQL | no (see note) |
| Oracle | no (see note) |
| ClickHouse | no |
- MySQL has no
RETURNINGat all. - MariaDB supports
RETURNINGforINSERTandDELETEbut notUPDATE. To keep one rule across all three write statements, Ptah treats MariaDB as unsupported and rejects a non-emptyRETURNING(renderer: mariadb does not support RETURNING). - SQLite gained
RETURNINGin 3.35 (2021). Ptah emits it; if you must target an older SQLite, avoidReturning. - SQL Server has
OUTPUT, which is a different clause in a different position, not a spelling of this one. MappingReturningonto it would change what the statement means, so a non-emptyReturningis rejected (renderer: sqlserver does not support RETURNING). - Oracle has the keyword and not this shape: measured on 23.26,
INSERT INTO t (id) VALUES (99) RETURNING id INTO :outis accepted with an out-parameter bound, while the same statement ending atRETURNING idanswersORA-00925. A projection has nowhere to go there.
Builder reference
Section titled “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. |
.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 (PostgreSQL family and SQLite only). |
.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.
renderer.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
Section titled “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.