Skip to main content

Raw SQL

For complex queries that don't fit the ORM's query builder, use raw SQL with type safety.

Basic Queries

Use the query method with a Zod schema:

import { z } from "zod";
import { sql } from "@casekit/sql";

const results = await db.query(
z.object({
id: z.number(),
name: z.string(),
bookCount: z.number(),
}),
sql`
SELECT
a.id,
a.name,
COUNT(b.id) as book_count
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.id, a.name
`
);

The result is fully typed based on your Zod schema.

Template Literals

Use template literals to safely interpolate values:

const authorId = 1;

const books = await db.query(
z.object({ id: z.number(), title: z.string() }),
sql`SELECT id, title FROM books WHERE author_id = ${authorId}`
);

Values are automatically parameterized to prevent SQL injection.

Multiple Values

Interpolate multiple values:

const minDate = new Date("2024-01-01");
const status = "published";

const posts = await db.query(
z.object({ id: z.number(), title: z.string() }),
sql`
SELECT id, title
FROM posts
WHERE created_at >= ${minDate}
AND status = ${status}
`
);

Identifiers

Use sql.ident for safe identifier interpolation:

const tableName = "users";
const columnName = "email";

const results = await db.query(
z.object({ email: z.string() }),
sql`SELECT ${sql.ident(columnName)} FROM ${sql.ident(tableName)}`
);

Arrays

Use sql.array for array values:

const ids = [1, 2, 3];

const authors = await db.query(
z.object({ id: z.number(), name: z.string() }),
sql`SELECT id, name FROM authors WHERE id = ANY(${sql.array(ids)})`
);

Composing SQL

Combine SQL fragments:

const baseQuery = sql`SELECT id, name FROM authors`;
const whereClause = sql`WHERE active = true`;
const orderClause = sql`ORDER BY name ASC`;

const authors = await db.query(
z.object({ id: z.number(), name: z.string() }),
sql`${baseQuery} ${whereClause} ${orderClause}`
);

Aggregations

Raw SQL is ideal for complex aggregations:

const stats = await db.query(
z.object({
totalAuthors: z.number(),
totalBooks: z.number(),
avgBooksPerAuthor: z.number(),
mostProlificAuthor: z.string(),
}),
sql`
WITH author_stats AS (
SELECT
a.name,
COUNT(b.id) as book_count
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.id, a.name
)
SELECT
(SELECT COUNT(*) FROM authors) as total_authors,
(SELECT COUNT(*) FROM books) as total_books,
(SELECT AVG(book_count) FROM author_stats) as avg_books_per_author,
(SELECT name FROM author_stats ORDER BY book_count DESC LIMIT 1) as most_prolific_author
`
);

Transactions

Raw SQL works within transactions:

await db.transact(async (tx) => {
// Mix ORM operations with raw SQL
const author = await tx.createOne("author", {
values: { name: "Jane Austen", email: "jane@example.com" },
returning: ["id"],
});

// Use raw SQL for complex operations
await tx.query`
INSERT INTO author_stats (author_id, views, likes)
VALUES (${author.id}, 0, 0)
`;
});

Without Schema Validation

For queries where you don't need result validation:

// Returns unknown[]
const results = await db.query`
SELECT id, name FROM authors
`;

Best Practices

Use Raw SQL For

  • Complex aggregations and analytics
  • Recursive CTEs
  • Window functions
  • Full-text search
  • Operations not supported by the query builder

Use the Query Builder For

  • Standard CRUD operations
  • Type-safe queries and results
  • Relational data loading
  • Simple filtering and sorting

Type Safety

Always define a Zod schema for raw SQL queries to get type safety:

// ✅ Good: Typed result
const results = await db.query(
z.object({ count: z.number() }),
sql`SELECT COUNT(*) as count FROM authors`
);
console.log(results[0].count); // number

// ⚠️ Less safe: Untyped result
const untyped = await db.query(
sql`SELECT COUNT(*) as count FROM authors`
);
console.log(untyped[0].count); // unknown

Creating a typed SQL statement to use later

You can pass a zod schema to the sql tagged template literal to get a query with a zod schema attached that you can pass to db.query() later, and get a properly typed result.

const BookCountResult = z.object({
name: z.string(),
total: z.number(),
})

const booksQuery = sql(BookCountResult)`
select
first_name || last_name as name,
count(1) as total
from books;
`;

const totals = await db.query(booksQuery)
// => { name: string, total: number }[]