Skip to content

The SQL tag: a function called without parentheses

Here is a line of UQL 0.100:

await pool.all`SELECT * FROM "User" WHERE "email" = ${email}`;

No parentheses, yet it is a function call. How JavaScript makes that call is what keeps email out of the SQL: it is sent as a bound parameter, never spliced into the text. Here is how.

A template literal with a function name is a tagged template. It is plain JavaScript, there since ES2015, so it runs in every browser, Node and Bun with no build step.

The engine calls the tag with the template cut into pieces: first an array of the literal text, then each ${} value as an argument of its own. A tag that prints its arguments shows the split:

function show(texts: TemplateStringsArray, ...values: unknown[]) {
console.log(texts, values);
}
show`SELECT * FROM "User" WHERE "email" = ${'ana@example.com'} AND "age" > ${18}`;
// ['SELECT * FROM "User" WHERE "email" = ', ' AND "age" > ', ''] ['ana@example.com', 18]

There is always one more string than there are values; here the last one is empty.

The text pieces are fixed in your source code. The values arrive at run time, from users, other services or anywhere else. A database driver expects exactly that split: SQL with placeholders, and the parameters beside it.

So this:

function sql(texts: TemplateStringsArray, ...values: unknown[]) {
const text = texts.reduce((t, part, i) => `${t}$${i}${part}`);
return { text, values };
}
sql`SELECT * FROM "User" WHERE "email" = ${email}`;

Produces this:

{ text: 'SELECT * FROM "User" WHERE "email" = $1', values: ['ana@example.com'] }

Whatever email holds, an injection attempt like '; DROP TABLE "User"; -- included, is sent as the value of $1, never as SQL. Nothing to escape by hand.

In UQL the method is the tag: all and run take the template directly, so there is no sql helper to import and wrap.

Add parentheses and you have a different program:

await pool.all`SELECT * FROM "User" WHERE "email" = ${email}`;
// @ts-expect-error a plain string, with the value already inside it
await pool.all(`SELECT * FROM "User" WHERE "email" = ${email}`);

The second line builds the whole string first, email and all, and hands the function the finished text. No library can take the value back out, so in UQL that line does not compile.

TypeScript checks each ${} against the tag’s parameter types, so UQL can state what a database can bind: strings, numbers, booleans, bigint, Date, bytes, null, lists of those, and SQL fragments. Two common slips stop at the compiler:

// @ts-expect-error `status` may be undefined, which binds nothing
await pool.all`SELECT * FROM "User" WHERE "status" = ${status}`;
// @ts-expect-error an object is not a SQL value
await pool.run`UPDATE "User" SET "prefs" = ${prefs} WHERE "id" = ${id}`;
// this one compiles
await pool.run`UPDATE "User" SET "prefs" = ${JSON.stringify(prefs)} WHERE "id" = ${id}`;

A tag also takes a type argument, so pool.all<{ id: number; email: string }>`...` hands back typed rows.

sql is the same tag without running anything: it returns a fragment. A fragment inside another template is spliced in, its values still bound:

import { sql } from 'uql-orm';
const conditions = [sql`"createdAt" > ${since}`];
if (status) conditions.push(sql`"status" = ${status}`);
await pool.all`SELECT * FROM "User" WHERE ${sql.join(conditions, ' AND ')}`;

For trusted SQL held in a string, such as a file’s contents, use sql.text(sql). It is the only call that binds nothing, so it is easy to find in a review. A tag binds values, not names: a table or column taken from user input still needs checking against a list you control.

Prisma’s docs warn that deleteMany({ where: { id: undefined } }) deletes every user, because an undefined key is dropped from the filter. UQL used to drop it too; now it throws:

await pool.deleteMany(User, { $where: { email: maybeEmail } });
// UqlUsageError: $where on 'User' holds undefined at 'email': leave the key out not to filter by it, or name null

A transaction() inside another is a savepoint, so a failing inner step rolls back alone. A to-many update keeps the children the payload lists and removes the rest. Any write can take returning and resolve to the rows it wrote, typed by the fields you name.

npx uql-codemod updates your imports and turns all(sql, [a, b]) into a tag. Anything it cannot convert safely is listed for you. The upgrade guide has the rest.



UQL is a JSON-native TypeScript ORM for Node.js and Bun. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.