Escape Hatch

When the model API does not cover your query needs, app.db gives you direct access to the full Drizzle API. The escape hatch is intentional and always available.

app.db

app.db is a standard Drizzle BaseSQLiteDatabase instance bound to the adapter. Any Drizzle feature works here.

SELECT with a WHERE clause:

import { eq } from 'drizzle-orm'

const rows = await app.db
  .select()
  .from(User.table)
  .where(eq(User.table.email, 'alice@example.com'))
  .limit(1)

return c.json(User.toResponse(rows[0]))

Aggregation:

import { count } from 'drizzle-orm'

const [{ total }] = await app.db
  .select({ total: count() })
  .from(User.table)
  .where(eq(User.table.isActive, true))

User.table

Each model exposes its Drizzle table via .table. Use it for column references in raw queries.

// Type-safe column access
User.table.id
User.table.email
User.table.createdAt

Warning: always pass raw rows through toResponse / toResponseMany

Raw rows returned by app.db include every column in the table — including serverOnly fields like passwordHash. Always pass them through the response shaper before returning to clients.

// WRONG — leaks serverOnly fields
const rows = await app.db.select().from(User.table)
return c.json(rows)

// CORRECT
const rows = await app.db.select().from(User.table)
return c.json(User.toResponseMany(rows))

Avoid SQL injection

Always use Drizzle's parameterized query API. Drizzle escapes values automatically when you pass them as arguments.

// SAFE — parameterized
import { eq } from 'drizzle-orm'
await app.db.select().from(User.table).where(eq(User.table.email, userInput))

// DANGEROUS — never interpolate external input into sql.raw()
import { sql } from 'drizzle-orm'
await app.db.execute(sql.raw(`SELECT * FROM users WHERE email = '${userInput}'`))

Use sql tagged template (not sql.raw()) when you need raw SQL fragments — Drizzle still parameterizes tagged-template values.

import { sql } from 'drizzle-orm'

// SAFE — sql tagged template parameterizes the value
await app.db.execute(sql`SELECT * FROM users WHERE email = ${userInput}`)

app.batch()

Executes multiple queries in a single D1 batch request (D1's batch API). Useful for writes that must all succeed or fail together at the network level.

const [newUser, newPost] = await app.batch([
  app.db.insert(User.table).values({ id: crypto.randomUUID(), email: 'alice@example.com', name: 'Alice' }).returning(),
  app.db.insert(Post.table).values({ id: crypto.randomUUID(), title: 'Hello', userId: 'alice-id' }).returning(),
])

Note: D1 batch is not a transaction. If the second query fails, the first may already have been applied. For true transactional semantics, use SQLite transactions via raw SQL or Drizzle's transaction API where supported.

Relations: using joins manually

Nanoka does not implement t.hasMany() or t.belongsTo() (non-goal, see Issue #14). Use Drizzle's join API directly for relational queries.

Inner join:

import { eq } from 'drizzle-orm'

const results = await app.db
  .select({
    user:  User.table,
    post:  Post.table,
  })
  .from(User.table)
  .innerJoin(Post.table, eq(Post.table.userId, User.table.id))
  .where(eq(User.table.id, userId))

Left join (include users with no posts):

const results = await app.db
  .select({
    user: User.table,
    post: Post.table,
  })
  .from(User.table)
  .leftJoin(Post.table, eq(Post.table.userId, User.table.id))

Remember to pass the user portions of the result through User.toResponse() before returning.

When to use the escape hatch

Use case Recommendation
Paginated list, single lookup, create, update, delete Use model CRUD methods
Complex WHERE with multiple conditions app.db with Drizzle SQL operators
JOIN across multiple tables app.db with .innerJoin() / .leftJoin()
Aggregation (COUNT, SUM, AVG) app.db with Drizzle aggregate functions
Full-text search app.db with raw SQL fragment
Multiple inserts that must batch app.batch()