Queries
ZenDB is not a query builder. Instead of fluent .where().orderBy() chains,
you write SQL directly with tagged templates. Values are automatically
parameterized, and helper fragments handle the tedious parts.
Query methods #
const title = "Hello";
const userId = "u1";
// Single-table query, with normalization
const posts = await db.all(Posts)`WHERE published = ${true}`;
// Multi-table with joins โ pass an array of tables
const joined = await db.all([Posts, Users])`
JOIN "users" ON ${Users.on(Posts)}
WHERE ${Posts.cols.published} = ${true}
`;
// Get a single entity
const post = await db.get(Posts)`WHERE ${Posts.cols.title} = ${title}`;
// Get by primary key (convenience)
const byId = await db.get(Posts, "p1");
// Raw query (no normalization)
const counts = await db.query<{count: number}>`
SELECT COUNT(*) as count FROM ${Posts} WHERE ${Posts.cols.authorId} = ${userId}
`;
// Execute a statement
await db.exec`CREATE INDEX idx_posts_author ON ${Posts}(${Posts.cols.authorId})`;
// Single scalar value
const total = await db.val<number>`SELECT COUNT(*) FROM ${Posts}`;Every value interpolated into a template becomes a bound parameter โ there is no string concatenation, so injection isn't possible.
Fragment helpers #
Tables expose type-safe SQL fragments as methods. They compose inside templates:
const rows = [
{id: "p1", title: "Hello", published: true},
{id: "p2", title: "World", published: false},
];
// UPDATE ... SET with set()
await db.exec`
UPDATE ${Posts}
SET ${Posts.set({title: "New Title", published: true})}
WHERE ${Posts.cols.id} = ${"p1"}
`;
// -> UPDATE "posts" SET "title" = ?, "published" = ? WHERE "posts"."id" = ?
// JOIN ... ON with on()
const posts = await db.all([Posts, Users])`
JOIN "users" ON ${Users.on(Posts)}
`;
// -> JOIN "users" ON "users"."id" = "posts"."authorId"
// Bulk INSERT with values()
await db.exec`INSERT INTO ${Posts} ${Posts.values(rows)}`;
// -> INSERT INTO "posts" ("id", "title", "published") VALUES (?, ?, ?), (?, ?, ?)
// Qualified column names with cols
const ordered = await db.all([Posts, Users])`
JOIN "users" ON ${Users.on(Posts)}
ORDER BY ${Posts.cols.title} DESC
`;
// Safe IN clause with in()
const some = await db.all(Posts)`WHERE ${Posts.in("id", ["id1", "id2"])}`;
// -> WHERE "posts"."id" IN (?, ?)
// Empty arrays are handled correctly
const none = await db.all(Posts)`WHERE ${Posts.in("id", [])}`;
// -> WHERE 1 = 0Caching #
Tagged template queries are cached by template-object identity โ each call site is compiled once. Interpolating different values reuses the same compiled SQL with new parameters.
Debugging #
Inspect generated SQL and query plans without executing:
const query = db.print`SELECT * FROM ${Posts} WHERE ${Posts.cols.published} = ${true}`;
query.sql; // SELECT * FROM "posts" WHERE "posts"."published" = ?
query.params; // [true]
// Fragments stringify too
Posts.set({title: "Updated"}).toString();
// SQLFragment { sql: "\"title\" = ?", params: ["Updated"] }
// Query execution plan
await db.explain`SELECT * FROM ${Posts}`;See Relationships for how all() and get()
normalize and resolve joined data.