Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

Raw SQL

Tagged-template $queryRaw/$executeRaw, the Unsafe variants, and the sql composition helpers

When the query builder cannot express what you need, drop to SQL. There are four methods, in two families.

Method Argument Returns
$queryRaw`...` tagged template (values are bound) T[] — the rows
$queryRawUnsafe(sql, ...params) statement string + positional params T[] — the rows
$executeRaw`...` tagged template (values are bound) number — affected rows
$executeRawUnsafe(sql, ...params) statement string + positional params number — affected rows

Tagged templates bind, they never splice

Every ${...} in a tagged call becomes a bound parameter, rendered in the driver’s own placeholder style ($1 on PostgreSQL, ? on MySQL and SQLite). The value can never reach the statement as text, so this is safe by construction:

const search = req.query.name; // untrusted

const users = await client.$queryRaw<{ id: string; name: string }>`
  SELECT id, name FROM "user" WHERE name = ${search}
`;
// → SELECT id, name FROM "user" WHERE name = $1   params: [search]

$executeRaw is the same, for statements that change rows:

const updated = await client.$executeRaw`
  UPDATE "user" SET active = ${false} WHERE last_seen < ${cutoff}
`;
// updated: number

The Unsafe variants

Use these when the statement text itself is dynamic — a column name, an ORDER BY direction, a generated statement. The string is used verbatim; you own the escaping.

const rows = await client.$queryRawUnsafe<{ id: string }>(
  'SELECT id FROM "user" WHERE age >= $1',
  minimumAge
);

const deleted = await client.$executeRawUnsafe(
  'DELETE FROM "session" WHERE expires_at < $1',
  cutoff
);

Composing fragments

sql, join, empty and raw are exported from the package root (also available as viborm/sql). They build Sql fragments that nest inside each other and inside a $queryRaw/$executeRaw call.

import { empty, join, raw, sql } from "viborm";

// join binds plain values — one placeholder per value
const ids = ["u1", "u2", "u3"];
const rows = await client.$queryRaw<{ id: string }>(
  sql`SELECT id FROM "user" WHERE id IN (${join(ids)})`
);

// join also splices nested fragments, keeping their bound values
const filters = [sql`age >= ${18}`, sql`active = ${true}`];
const adults = await client.$queryRaw<{ id: string }>(
  sql`SELECT id FROM "user" WHERE ${join(filters, " AND ")}`
);

// empty contributes nothing — useful for an optional clause
const clause = onlyActive ? sql`WHERE active = ${true}` : empty;
await client.$queryRaw(sql`SELECT id FROM "user" ${clause}`);

// raw(text) splices text with no binding — identifiers, keywords, directions
await client.$queryRaw(
  sql`SELECT id FROM "user" ${raw(`ORDER BY ${column} DESC`)}`
);

A prebuilt fragment can be passed straight to either safe method:

const fragment = sql`SELECT id FROM "user" WHERE age >= ${18}`;
const rows = await client.$queryRaw<{ id: string }>(fragment);

Passing a fragment and extra values is refused — the fragment already carries its parameters, so there is nowhere for the extras to go.

Raw SQL in a transaction

The interactive transaction client carries the same four methods, bound to the open transaction. They travel the transaction’s connection, see its uncommitted writes, and roll back with it:

await client.$transaction(async (tx) => {
  await tx.user.create({ data: { id: "u1", name: "Alice" } });

  // Sees the row the line above wrote, even though nothing is committed yet
  const [row] = await tx.$queryRaw<{
    name: string;
  }>`SELECT name FROM "user" WHERE id = ${"u1"}`;

  await tx.$executeRaw`UPDATE "user" SET active = ${true} WHERE id = ${"u1"}`;
});

The array form of $transaction is model operations only. A raw query runs the moment you call it and returns a plain Promise, so there is no deferred operation left to batch — passing one is refused with a typed UnsupportedOperationError. Use the interactive form above.

Rows are driver-native

Raw results are not passed through the ORM’s typed read path. A column comes back exactly as the driver hands it over — for example LibSQL reads integer columns with intMode: "bigint", so an INTEGER arrives as a BigInt where other drivers give a number. Convert at the call site when it matters.

Deprecated: the string form of $queryRaw/$executeRaw

Before tagged templates, $queryRaw took (sql: string, params?: unknown[]). That shape still runs for one release, and announces itself once per method on the warning log channel.

// Deprecated — removed next release
await client.$queryRaw('SELECT id FROM "user" WHERE age >= $1', [18]);

// Replace with either:
await client.$queryRaw`SELECT id FROM "user" WHERE age >= ${18}`;
await client.$queryRawUnsafe('SELECT id FROM "user" WHERE age >= $1', 18);

The return type changed with it: these methods used to answer a QueryResult<T> envelope ({ rows, rowCount }). They now answer T[] and number.

Was this page helpful?