#Drizzle ORM integration
This example shows how to integrate Drizzle ORM into a VextJS project to implement type-safe database operations by writing a database plug-in.
#Why choose Drizzle ORM?
| Features | Description |
|---|---|
| Type Safety | Schema is the type, and the query results automatically infer the TypeScript type |
| Lightweight | No runtime code generation, zero abstraction overhead |
| SQL-like | API is close to SQL syntax, low learning cost |
| Multiple databases | Support PostgreSQL, MySQL, SQLite |
| Migration Tool | Built-in drizzle-kit for schema migration |
| Performance | No additional query layer, compiled directly into SQL |
#Project structure
drizzle-orm/
├── src/
│ ├── config/
│ │ ├── default.ts
│ │ └── production.ts
│ ├── db/
│ │ ├── schema.ts ← Drizzle schema definition
│ │ ├── index.ts ← Database connection factory
│ │ └── migrate.ts ← Migration script
│ ├── plugins/
│ │ └── database.ts ← Database plug-in
│ ├── routes/
│ │ ├── index.ts
│ │ └── users.ts
│ ├── services/
│ │ └── user.ts
│ └── index.ts
├── drizzle/ ← Migration file (generated by drizzle-kit)
├── drizzle.config.ts ← drizzle-kit configuration
├── package.json
└── tsconfig.json#1. Install dependencies
This example uses SQLite (via better-sqlite3) as the demo database. Can be replaced by PostgreSQL or MySQL in a production environment.
npx vextjs create drizzle-orm-demo
cd drizzle-orm-demo
# Drizzle ORM + SQLite driver
pnpm add drizzle-orm better-sqlite3
# Development dependencies
pnpm add -D drizzle-kit @types/better-sqlite3Other database drivers:
- PostgreSQL:
pnpm add drizzle-orm postgresorpnpm add drizzle-orm @neondatabase/serverless - MySQL:
pnpm add drizzle-orm mysql2
After replacing the driver, you only need to modify the connection logic in src/db/index.ts and the table definition import path in src/db/schema.ts, and there is almost no need to change the business code.
#2. Database Schema
Define table structures using Drizzle's TypeScript-first schema. Schema is both a database table definition and the source of TypeScript types.
// src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
//──User table ───────────────────────────────────────────────
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
age: integer("age"),
role: text("role", { enum: ["admin", "user", "editor"] })
.notNull()
.default("user"),
bio: text("bio"),
avatarUrl: text("avatar_url"),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
});
//──Article table (demo related query)──────────────────────────────────
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull(),
authorId: integer("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
published: integer("published", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(datetime('now'))`),
});
//──Export inferred type ──────────────────────────────────────────
/** User complete type (query result) */
export type User = typeof users.$inferSelect;
/** User insertion type (used when creating) */
export type NewUser = typeof users.$inferInsert;
/** Complete article type */
export type Post = typeof posts.$inferSelect;
/** Article insertion type */
export type NewPost = typeof posts.$inferInsert;
Drizzle's $inferSelect and $inferInsert type tools are very powerful:- $inferSelect — Infer query result type from table definition (includes all fields, id, createdAt, etc. are required)
$inferInsert— infer the insertion type from the table definition (fields with default values become optional, such asrole,createdAt)
These types can be used directly in the service layer's parameter and return value declarations, eliminating the need to manually maintain interface definitions.
#3. Database connection factory
// src/db/index.ts
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "./schema.js";
export interface DatabaseConfig {
/** SQLite database file path */
url: string;
/** Whether to enable WAL mode (Write-Ahead Logging, to improve concurrency performance) */
wal?: boolean;
/** Whether to automatically create tables during connection (for development environment) */
autoCreate?: boolean;
}
/**
* Create Drizzle ORM database instance
*
* @param config database configuration
* @returns { db, client }
* - db: Drizzle ORM instance (for querying)
* - client: better-sqlite3 original client (used to close the connection)
*/
export function createDatabase(config: DatabaseConfig) {
const client = new Database(config.url);
// WAL mode: improve concurrent read and write performance
if (config.wal !== false) {
client.pragma("journal_mode = WAL");
}
// Enable foreign key constraints
client.pragma("foreign_keys = ON");
const db = drizzle(client, { schema });
//The development environment automatically creates tables
if (config.autoCreate) {
client.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
age INTEGER,
role TEXT NOT NULL DEFAULT 'user',
bioTEXT,
avatar_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
published INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
return { db, client };
}
/** Drizzle DB instance type */
export type DrizzleDB = ReturnType<typeof createDatabase>["db"];
Do not use autoCreate: true in a production environment. Database structure changes should be managed using the drizzle-kit migration tool. autoCreate is for quick development and demonstration purposes only.
#4. Database migration configuration
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL ?? "./data/app.db",
},
});Migration command:
# Generate migration file
pnpm drizzle-kit generate
# Execute migration
pnpm drizzle-kit migrate
# Open Drizzle Studio (visual database management)
pnpm drizzle-kit studio#5. Database plug-in
// src/plugins/database.ts
import { definePlugin } from "vextjs";
import { createDatabase } from "../db/index.js";
import type { DrizzleDB } from "../db/index.js";
import { users, posts } from "../db/schema.js";
export default definePlugin({
name: "database",
async setup(app) {
const dbConfig = (app.config as any).database ?? {
url: "./data/app.db",
wal: true,
autoCreate: true,
};
app.logger.info({ url: dbConfig.url }, "Connecting to database...");
const { db, client } = createDatabase(dbConfig);
//Mount to app
app.extend("db", db);
//Ready hook: verify connection + fill in seed data
app.onReady(async () => {
try {
// Verify connection
client.pragma("quick_check");
app.logger.info("Database connection verification successful");// Check if seed data is required
const userCount = db.select().from(users).all();
if (userCount.length === 0) {
app.logger.info("The database is empty, filling seed data...");
await seedDatabase(db);
app.logger.info("Seed data filling completed");
}
} catch (err) {
app.logger.error({ error: err }, "Database initialization failed");
throw err;
}
});
//Close hook: disconnect
app.onClose(() => {
client.close();
app.logger.info("Database connection has been closed");
});
},
});
/**
* Fill in seed data (only executed when the database is empty)
*/
async function seedDatabase(db: DrizzleDB): Promise<void> {
//Insert user
const insertedUsers = db
.insert(users)
.values([
{
name: "Alice",
email: "alice@example.com",
age: 28,
role: "admin",
bio: "Full stack developer, loves open source",
},
{
name: "Bob",
email: "bob@example.com",
age: 32,
role: "user",
bio: "front-end engineer",
},
{
name: "Charlie",
email: "charlie@example.com",
age: 25,
role: "editor",
bio: "Technology Blogger",
},
])
.returning()
.all();
//Insert article
db.insert(posts)
.values([
{
title: "VextJS Getting Started Guide",
content: "This article introduces how to use VextJS to build a high-performance RESTful API...",
authorId: insertedUsers[0].id,
published: true,
},
{
title: "Drizzle ORM Best Practices",
content: "Explore advanced query techniques of Drizzle ORM...",
authorId: insertedUsers[0].id,
published: true,
},
{
title: "TypeScript type gymnastics",
content: "In-depth understanding of TypeScript's advanced type system...",
authorId: insertedUsers[2].id,
published: false,
},
])
.run();
}Add a type declaration for app.db:
// types/vext.d.ts
import type { DrizzleDB } from "../src/db/index.js";
declare module "vextjs" {
interface VextApp {
db: DrizzleDB;
}
interface VextConfig {
database?: {
url: string;
wal?: boolean;
autoCreate?: boolean;
};
}
interface VextRequest {
user?: {
id: string;
role: string;
};
}
}#6. Configuration
// src/config/default.ts
export default {
port: 3000,
adapter: "native",
logger: {
level: "debug",
pretty: true,
},
cors: {
enabled: true,
origins: ["*"],
},
response: {
wrap: true,
hideInternalErrors: false,
},
openapi: {
enabled: true,
title: "Drizzle ORM Example",
version: "1.0.0",
description: "VextJS RESTful API integrated with Drizzle ORM",
tags: [
{ name: "Basic", description: "Basic interface" },
{ name: "User", description: "User management interface" },
{ name: "Article", description: "Article Management Interface" },
],
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
guardSecurityMap: {
auth: "bearerAuth",
},
},
middlewares: [{ name: "auth" }],
//Database configuration
database: {
url: "./data/app.db",
wal: true,
autoCreate: true,
},
};// src/config/production.ts
export default {
logger: {
level: "info",
pretty: false,
},
response: {
hideInternalErrors: true,
},
openapi: {
enabled: false,
},
database: {
url: process.env.DATABASE_URL ?? "./data/production.db",
autoCreate: false, // Use migration in production environment
},
};#7. Service layer
// src/services/user.ts
import type { VextApp, VextLogger } from "vextjs";
import { users, posts } from "../db/schema.js";
import type { User, NewUser } from "../db/schema.js";
import { eq, like, or, sql, desc, asc, count } from "drizzle-orm";export default class UserService {
private logger: VextLogger;
constructor(private app: VextApp) {
this.logger = app.logger.child({ service: "UserService" });
}
/**
* Query user list by page
*
* Supports keyword search (fuzzy matching by name or email) and sorting.
*/
async findAll(options: {
page: number;
limit: number;
keyword?: string;
sortBy?: "name" | "createdAt";
sortOrder?: "asc" | "desc";
}): Promise<{
items: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
this.logger.debug(options, "Query user list");
const db = this.app.db;
const offset = (options.page - 1) * options.limit;
//Construct WHERE condition
const whereCondition = options.keyword
? or(
like(users.name, `%${options.keyword}%`),
like(users.email, `%${options.keyword}%`),
)
: undefined;
// Build sort
const orderBy =
options.sortBy === "name"
? options.sortOrder === "desc"
?desc(users.name)
: asc(users.name)
: options.sortOrder === "asc"
? asc(users.createdAt)
: desc(users.createdAt);
// Query data
const items = db
.select()
.from(users)
.where(whereCondition)
.orderBy(orderBy)
.limit(options.limit)
.offset(offset)
.all();
//Total number of queries
const [{ total }] = db
.select({ total: count() })
.from(users)
.where(whereCondition)
.all();
return {
items,
total,
page: options.page,
limit: options.limit,
totalPages: Math.ceil(total/options.limit),
};
}
/**
* Query users based on ID (including article list)
*/
async findById(id: number): Promise<(User & { posts: any[] }) | null> {
this.logger.debug({ userId: id }, "Query user details");
const db = this.app.db;
const user = db.select().from(users).where(eq(users.id, id)).get();
if (!user) return null;
// Query the user's articles
const userPosts = db
.select()
.from(posts)
.where(eq(posts.authorId, id))
.orderBy(desc(posts.createdAt))
.all();
return {
...user,
posts: userPosts,
};
}
/**
* Create user
*/
async create(data: {
name: string;
email: string;
age?: number;
role?: "admin" | "user" | "editor";
bio?: string;
avatarUrl?: string;
}): Promise<User> {
this.logger.info({ email: data.email }, "Create user");
const db = this.app.db;
// Check email uniqueness
const existing = db
.select({ id: users.id })
.from(users)
.where(eq(users.email, data.email))
.get();
if (existing) {
this.app.throw(409, "Email has been registered", 10001);
}
//Insert and return the complete record
const user = db
.insert(users)
.values({
name: data.name,
email: data.email,
age: data.age,
role: data.role ?? "user",
bio: data.bio,
avatarUrl: data.avatarUrl,
})
.returning()
.get();
this.logger.info({ userId: user.id, email: user.email }, "User created successfully");
return user;
}
/**
* Update user
*/
async update(
ID: number,
data: {
name?: string;
email?: string;
age?: number;
bio?: string;
avatarUrl?: string;
},
): Promise<User> {
this.logger.info({ userId: id }, "Update user");
const db = this.app.db;
// Check if the user exists
const existing = db.select().from(users).where(eq(users.id, id)).get();
if (!existing) {
this.app.throw(404, "User does not exist");
}// If updating mailbox, check for uniqueness
if (data.email && data.email !== existing.email) {
const emailTaken = db
.select({ id: users.id })
.from(users)
.where(eq(users.email, data.email))
.get();
if (emailTaken) {
this.app.throw(409, "The mailbox is already used by another user", 10002);
}
}
//Update record
const updated = db
.update(users)
.set({
...data,
updatedAt: sql`datetime('now')`,
})
.where(eq(users.id, id))
.returning()
.get();
this.logger.info({ userId: id }, "User updated successfully");
return updated;
}
/**
* Delete user
*
* Since ON DELETE CASCADE is set, the associated articles will be automatically deleted.
*/
async delete(id: number): Promise<void> {
this.logger.info({ userId: id }, "Delete user");
const db = this.app.db;
const existing = db
.select({ id: users.id })
.from(users)
.where(eq(users.id, id))
.get();
if (!existing) {
this.app.throw(404, "User does not exist");
}
db.delete(users).where(eq(users.id, id)).run();
this.logger.info({ userId: id }, "User deleted successfully");
}
/**
* Count the number of users
*/
async count(): Promise<number> {
const [{ total }] = this.app.db
.select({ total: count() })
.from(users)
.all();
return total;
}
}#8. Routing
#Root route
// src/routes/index.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.get(
"/",
{
docs: {
summary: "Health Check",
tags: ["Basics"],
},
},
async (_req, res) => {
const userCount = await app.services.user.count();
res.json({
status: "ok",
uptime: Math.floor(process.uptime()),
database: "connected",
users: userCount,
timestamp: new Date().toISOString(),
});
},
);
});#User routing
// src/routes/users.ts
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// GET /users/list — Query the user list in pages
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.get(
"/list",
{
validate: {
query: {
page: "number:1-",
limit: "number:1-100",
keyword: "string?",
},
},
docs: {
summary: "User list",
description: "Query the user list in pages. Search by name or email keywords is supported.",
tags: ["user"],
responses: {
200: {
description: "Query successful",
example: {
items: [
{
id: 1,
name: "Alice",
email: "alice@example.com",
age: 28,
role: "admin",
bio: "Full stack developer",
createdAt: "2026-03-05 00:00:00",
},
],
total: 3,
page: 1,
limit: 10,
totalPages: 1,
},
},
},
},
},
async (req, res) => {
const { page, limit, keyword } = req.valid("query");
const result = await app.services.user.findAll({ page, limit, keyword });
res.json(result);
},
);// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// GET /users/:id — Query user details (including articles)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.get(
"/:id",
{
validate: {
param: { id: "number:1-" },
},
docs: {
summary: "Get user details",
description: "Query user details, including all articles published by this user.",
tags: ["user"],
responses: {
200: { description: "Query successful" },
404: { description: "User does not exist" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
const user = await app.services.user.findById(id);
if (!user) {
app.throw(404, "User does not exist");
}
res.json(user);
},
);
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// POST /users — Create users (authentication required)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.post(
"/",
{
validate: {
body: {
name: "string:1-50",
email: "email",
age: "number:0-200?",
role: "enum:admin,user,editor?",
},
},
middlewares: ["auth"],
docs: {
summary: "Create user",
description: "Create a new user. Bearer Token authentication is required. Email must be unique.",
tags: ["user"],
responses: {
201: { description: "Created successfully" },
422: { description: "Parameter verification failed" },
401: { description: "Not authenticated" },
409: { description: "Email has been registered" },
},
},
},
async (req, res) => {
const body = req.valid("body");
const user = await app.services.user.create(body);
res.json(user, 201);
},
);
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// PUT /users/:id — update user (authentication required)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.put(
"/:id",
{
validate: {
param: { id: "number:1-" },
body: {
name: "string:1-50?",
email: "email?",
age: "number:0-200?",
},
},
middlewares: ["auth"],
docs: {
summary: "Update user",
description: "Update user information. Just pass in the fields that need to be updated.",
tags: ["user"],
responses: {
200: { description: "Update successful" },
422: { description: "Parameter verification failed" },
401: { description: "Not authenticated" },
404: { description: "User does not exist" },
409: { description: "The mailbox has been occupied" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
const body = req.valid("body");
const user = await app.services.user.update(id, body);
res.json(user);
},
);
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
// DELETE /users/:id — delete user (authentication required)
// ━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━
app.delete(
"/:id",
{
validate: {
param: { id: "number:1-" },
},
middlewares: ["auth"],
docs: {
summary: "Delete user",
description: "Delete user and all articles (CASCADE). Irreversible operation.",
tags: ["user"],
responses: {
204: { description: "Delete successfully" },
401: { description: "Not authenticated" },
404: { description: "User does not exist" },
},
},
},
async (req, res) => {
const { id } = req.valid("param");
await app.services.user.delete(id);
res.status(204).json(null);
},
);
});#9. Entry file
// src/index.ts
import { bootstrap } from "vextjs";
bootstrap().catch((err) => {
console.error("Startup failed:", err);
process.exit(1);
});#10. Operation and verification
#Create data directory
mkdir -p data
# Start development server
pnpmdev#Interface verification
#HealthCheck
curl http://localhost:3000/
# → {"code":0,"data":{"status":"ok","database":"connected","users":3,...},"requestId":"..."}# Query user list
curl "http://localhost:3000/users/list?page=1&limit=10"
# → {"code":0,"data":{"items":[...],"total":3,"page":1,"limit":10,"totalPages":1},"requestId":"..."}
#Search for users
curl "http://localhost:3000/users/list?page=1&limit=10&keyword=alice"
# → Only return users matching "alice"
# Query user details (including articles)
curl http://localhost:3000/users/1
# → {"code":0,"data":{"id":1,"name":"Alice",...,"posts":[...]},"requestId":"..."}
#Create user
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Diana","email":"diana@example.com","age":26}'
# → 201 {"code":0,"data":{"id":4,"name":"Diana","email":"diana@example.com",...},"requestId":"..."}
# Update user
curl -X PUT http://localhost:3000/users/4 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Diana Updated","age":27}'
# → {"code":0,"data":{"id":4,"name":"Diana Updated","age":27,...},"requestId":"..."}
# Delete user
curl -X DELETE http://localhost:3000/users/4 \
-H "Authorization: Bearer user-1-admin"
# → 204 No Content
# Email uniqueness verification
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer user-1-admin" \
-d '{"name":"Test","email":"alice@example.com"}'
# → 409 {"code":10001,"message":"Email has been registered","requestId":"..."}#11. Test
// test/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createTestApp } from "vextjs/testing";
import type { TestApp } from "vextjs";
describe("User CRUD (Drizzle)", () => {
let testApp: TestApp;
const AUTH = "Bearer user-1-admin";
beforeEach(async () => {
testApp = await createTestApp({
plugins: true, // Load database plugins
config: {
database: {
url: ":memory:", // Use in-memory database to isolate between tests
autoCreate: true,
},
},
});
});
afterEach(async () => {
await testApp?.close();
});
it("GET /users/list should return seed data", async () => {
const res = await testApp.request
.get("/users/list")
.query({ page: 1, limit: 10 });
expect(res.status).toBe(200);
expect(res.body.data.total).toBe(3); // Seed data 3 users
expect(res.body.data.items[0]).toHaveProperty("id");
expect(res.body.data.items[0]).toHaveProperty("name");
expect(res.body.data.items[0]).toHaveProperty("email");
});
it("GET /users/:id should contain associated articles", async () => {
const res = await testApp.request.get("/users/1");
expect(res.status).toBe(200);
expect(res.body.data.name).toBe("Alice");
expect(Array.isArray(res.body.data.posts)).toBe(true);
expect(res.body.data.posts.length).toBeGreaterThan(0);
});
it("POST /users should create users and return auto-incremented IDs", async () => {
const res = await testApp.request
.post("/users")
.set("Authorization", AUTH)
.send({
name: "Diana",
email: "diana@example.com",
age: 26,
});
expect(res.status).toBe(201);
expect(res.body.data.id).toBe(4); // Seed data accounts for 1-3
expect(res.body.data.role).toBe("user"); //Default value
expect(res.body.data.createdAt).toBeDefined();
});
it("DELETE /users/:id should cascade delete articles", async () => {
// Alice (id=1) has 2 articles
const deleteRes = await testApp.request
.delete("/users/1")
.set("Authorization", AUTH);
expect(deleteRes.status).toBe(204);
// Confirm that both users and articles have been deleted
const getRes = await testApp.request.get("/users/1");
expect(getRes.status).toBe(404);
});
});#Drizzle Advanced Usage
#Related query (Relations API)
Drizzle supports declarative related queries to avoid manual JOIN:
import { relations } from "drizzle-orm";
import { users, posts } from "./schema.js";
// Declare the relationship
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
// Use related query
const usersWithPosts = await db.query.users.findMany({
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
},
},
});#Transaction
import { eq, sql } from "drizzle-orm";
//Transaction: transfer operation
async function transfer(fromId: number, toId: number, amount: number) {
await db.transaction(async (tx) => {
//deduction
tx.update(accounts)
.set({ balance: sql`balance - ${amount}` })
.where(eq(accounts.id, fromId))
.run();
// increase
tx.update(accounts)
.set({ balance: sql`balance + ${amount}` })
.where(eq(accounts.id, toId))
.run();
// record transaction
tx.insert(transactions)
.values({ fromId, toId, amount, type: "transfer" })
.run();
});
}#Subquery and aggregation
import { eq, count, avg, gt, sql } from "drizzle-orm";
// Count the number of articles for each user
const userPostCounts = db
.select({
userId: users.id,
name: users.name,
postCount: count(posts.id),
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.groupBy(users.id)
.having(gt(count(posts.id), 0))
.all();
//Subquery: Query users whose number of articles is greater than the average
const avgPostCount = db
.select({ avg: avg(count(posts.id)) })
.from(posts)
.groupBy(posts.authorId);
const activeAuthors = db
.select()
.from(users)
.where(
gt(
db
.select({ count: count() })
.from(posts)
.where(eq(posts.authorId, users.id)),
sql`(SELECT AVG(cnt) FROM (SELECT COUNT(*) as cnt FROM posts GROUP BY author_id))`,
),
)
.all();#Dynamic condition construction
import { and, or, eq, like, gte, lte, sql } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
interface UserFilter {
keyword?: string;
role?: string;
minAge?: number;
maxAge?: number;
}
function buildUserFilter(filter: UserFilter): SQL | undefined {
const conditions: SQL[] = [];
if (filter.keyword) {
conditions.push(
or(
like(users.name, `%${filter.keyword}%`),
like(users.email, `%${filter.keyword}%`),
)!,
);
}
if (filter.role) {
conditions.push(eq(users.role, filter.role));
}
if (filter.minAge !== undefined) {
conditions.push(gte(users.age, filter.minAge));
}
if (filter.maxAge !== undefined) {
conditions.push(lte(users.age, filter.maxAge));
}
return conditions.length > 0 ? and(...conditions) : undefined;
}
// use
const items = db
.select()
.from(users)
.where(buildUserFilter({ keyword: "alice", role: "admin", minAge: 20 }))
.all();#Using PostgreSQL
Switching to PostgreSQL only requires modifying two files:
// src/db/schema.ts — use pg import instead
import {
pgTable,
serial,
text,
integer,
timestamp,
boolean,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
age: integer("age"),
role: text("role").notNull().default("user"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});// src/db/index.ts — use pg driver instead
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.js";
export function createDatabase(config: { url: string }) {
const client = postgres(config.url);
const db = drizzle(client, { schema });
return { db, client };
}The business code (service layer, routing layer) does not need to be modified at all.
#Project mode summary
| Hierarchy | Responsibilities | Documentation |
|---|---|---|
| Schema | Table definition + type inference | src/db/schema.ts |
| Connection Factory | Create database instance | src/db/index.ts |
| Plug-in | Initialize connection, mount app.db, clean up | src/plugins/database.ts |
| Service layer | Business logic + Drizzle query | src/services/user.ts |
| Routing layer | Request verification + calling service + response | src/routes/users.ts |
| Type declaration | app.db type extension | types/vext.d.ts |
#Core Principles
- Schema is type: Use
$inferSelect/$inferInsertto infer types from Drizzle schema without manually maintaining the interface - Plug-in management life cycle: The creation and destruction of database connections is the responsibility of the plug-in and is managed through
app.extend()andapp.onClose() - Service layer encapsulation query: All Drizzle queries are encapsulated in the service layer, and the routing layer only performs orchestration
- Declarative routing layer: Routing focuses on verification rules, document configuration and service invocation
#Next step
- 📖 Prisma ORM Integration — Another popular ORM integration solution
- 📖Zod Validation Integration — More powerful parameter validation
- 📖 Plugins — Learn more about the VextJS plug-in system
- 📖 Service Layer — Get an in-depth understanding of the service layer design pattern
- 📖 Testing — Test database-related business logic