Raw SQL safely
sdk.sql for parameterized queries, why a string-built sdk.query will not save, and what raw SQL does not do for you.
The typed helpers (readList, aggregate, updateWhere, …) cover most of what a handler needs,
and they do a lot for you: org scoping, column redaction, table locks, uids at the edge. When you
need SQL they cannot express (a join, a window function, a report), use raw SQL. It is powerful
because it skips all of that, so it comes with two rules: it must be parameterized, and you
must scope it yourself.
invoices, vendors, payments). Your app has only the tables you create, so put your own table and column names in their place. About the example appBoth sdk.sql and sdk.query need the sdk:query capability (deny by default, high risk). See
Capabilities & governance.
Use sdk.sql
sdk.sql is a tagged template. Every ${…} becomes a bound parameter, so a value can never become
part of the statement, whatever it contains:
const rows = await sdk.sql`
SELECT uid, invoice_number, created_at
FROM invoices
WHERE status = ${data.status}
AND created_at >= ${data.since}
ORDER BY created_at DESC
LIMIT ${50}
`;Awaiting the template runs it and resolves to the rows. Without await, it is a fragment you
can compose:
| You write | It becomes |
|---|---|
${value} | ?, with value bound |
${[a, b, c]} | ?, ?, ? (an empty array becomes NULL) |
${sdk.sql.id(name)} | A table or column name, checked to be letters, digits and underscores |
${otherFragment} | That fragment's SQL, with its values bound in order |
${sdk.sql.join(items, " AND ")} | The items, joined by the separator (default ", ") |
// A dynamic sort column, an IN list, and an optional condition.
const sortColumn = sdk.sql.id(data.sortBy === "total" ? "total_cents" : "created_at");
const openOnly = data.openOnly ? sdk.sql`AND status = ${"open"}` : sdk.sql``;
const rows = await sdk.sql`
SELECT uid, status, total_cents
FROM orders
WHERE uid IN (${data.orderUids})
${openOnly}
ORDER BY ${sortColumn} DESC
`;sdk.sql.id validates the name; it does not decide whether the caller should be able to sort by
that column. Map user input onto names you chose, as above, rather than passing it straight in.
sdk.query takes only a static string
sdk.query(sql, params) runs a fixed statement with ? placeholders. Its first argument must be a
string literal, and the call must be written as sdk.query(…) itself. A handler that builds the
statement any other way, or reaches query indirectly, does not save:
// Fine: a literal, values bound.
await sdk.query("SELECT uid FROM invoices WHERE status = ? AND spend_category = ?", [status, category]);
// Refused at save: HANDLER_UNSAFE_RAW_SQL.
await sdk.query(`SELECT uid FROM invoices WHERE status = '${status}'`);
await sdk.query("SELECT uid FROM invoices WHERE status = '" + status + "'");
await sdk.query(data.sql);
const sql = "SELECT uid FROM invoices WHERE status = '" + status + "'";
await sdk.query(sql);
// Also refused: each reaches the same method without a literal the check can read.
const { query } = sdk;
await sdk["query"](sql);
await sdk.query.call(sdk, sql);The check runs on every save, dry run, approval and publish, for code written by a person and by
AI alike, so no stored version contains an injectable query. Anything dynamic belongs in
sdk.sql.
Each call runs one statement: stacked statements separated by ; are refused by the database
connection.
Raw SQL is inside the database
The typed helpers present rows as they look from outside: uid, <name>_uid references, no ids,
no org column. Raw SQL sees rows as stored: id, raw foreign keys, org_id. That is what
makes it fast (joins on id use the primary key), and it means three things are now yours:
Raw SQL is not scoped for you
The typed helpers confine every call to the caller's organization and mask redacted columns. Raw SQL does neither, and table locks do not apply to it. A query with no org condition reads every organization's rows. Scope it explicitly.
Scope it. The caller's organization arrives as a uid (ctx.user.orgUid); join through the
app's orgs table to use it:
const rows = await sdk.sql`
SELECT i.uid, i.invoice_number, i.status
FROM invoices i
JOIN orgs o ON o.id = i.org_id
WHERE o.uid = ${ctx.user.orgUid}
AND i.status = ${"received"}
`;Return uids, never ids. Select uid, and a referenced row's uid through its join, for anything
that leaves the handler:
const rows = await sdk.sql`
SELECT i.uid, i.status, v.uid AS vendor_uid
FROM invoices i
JOIN vendors v ON v.id = i.vendor_id
WHERE i.status = ${"received"}
`;
return rows; // uid, status, vendor_uid: no id anywhereMind what you select. A column you would have seen as redacted through readList comes back
in clear through raw SQL. Select only the columns you mean to use.
Schema changes
A statement that changes schema (CREATE, ALTER, DROP, RENAME, TRUNCATE) needs
sdk:query_ddl, the critical-risk capability, which implies sdk:query. DDL commits on its own
and cannot be rolled back, so it never runs in a dry run (DDL_NOT_DRY_RUNNABLE). Changing schema
from a handler is rarely right; the console's table editor applies schema changes, foreign keys
included, as one atomic step.
Errors
| Code | When |
|---|---|
HANDLER_UNSAFE_RAW_SQL (400) | At save: sdk.query was given something other than a string literal. |
SDK_CAP_NOT_GRANTED (403) | At save, dry run or approval: you lack sdk:query (or sdk:query_ddl). |
SDK_CAP_NOT_IN_MANIFEST (403) | At run time: the published version was not frozen with sdk:query. Publish again. |
DDL_NOT_DRY_RUNNABLE (422) | A dry run reached a schema-changing statement. |