12 KiB
12 KiB
Database Operations
This document covers database best practices using Drizzle ORM with PostgreSQL.
Critical Rules
1. NO await in Loops (N+1 Problem)
Never use await inside a loop. This creates the N+1 query problem, causing severe performance degradation.
// BAD - N+1 queries (1 query per iteration)
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
for (const order of orders) {
const items = await db.select().from(orderItemTable).where(eq(orderItemTable.orderId, order.id));
order.items = items;
}
// GOOD - 2 queries total using inArray
const orders = await db.select().from(orderTable).where(eq(orderTable.userId, userId));
const orderIds = orders.map(o => o.id);
// Single query for all items
const allItems = await db
.select()
.from(orderItemTable)
.where(inArray(orderItemTable.orderId, orderIds));
// Group items by orderId in memory
const itemsByOrder = new Map<string, typeof allItems>();
for (const item of allItems) {
const existing = itemsByOrder.get(item.orderId) || [];
existing.push(item);
itemsByOrder.set(item.orderId, existing);
}
// Attach to orders
const ordersWithItems = orders.map(order => ({
...order,
items: itemsByOrder.get(order.id) || [],
}));
2. Batch Insert Pattern
Use batch inserts for multiple records instead of individual inserts.
// BAD - Multiple insert statements
for (const item of items) {
await db.insert(orderItemTable).values(item);
}
// GOOD - Single batch insert
await db.insert(orderItemTable).values(items);
// With returning clause
const insertedItems = await db
.insert(orderItemTable)
.values(items)
.returning();
3. Conflict Handling with onConflictDoUpdate
Handle upserts efficiently with conflict resolution.
// Upsert single record
await db
.insert(userSettingsTable)
.values({
userId,
theme: "dark",
notifications: true,
})
.onConflictDoUpdate({
target: userSettingsTable.userId,
set: {
theme: sql`excluded.theme`,
notifications: sql`excluded.notifications`,
updatedAt: sql`NOW()`,
},
});
// Batch upsert with composite key
const upsertedRecords = await db
.insert(inventoryTable)
.values(inventoryData)
.onConflictDoUpdate({
target: [inventoryTable.warehouseId, inventoryTable.productId],
set: {
quantity: sql`excluded.quantity`,
updatedAt: sql`NOW()`,
},
})
.returning({
id: inventoryTable.id,
productId: inventoryTable.productId,
});
Query Organization
Database queries should be organized in packages/database/drizzle/queries/.
packages/database/drizzle/queries/
├── index.ts # Re-exports all query modules
├── types.ts # Shared query types
├── users.ts # User-related queries
├── orders.ts # Order-related queries
└── products.ts # Product-related queries
Example: queries/orders.ts
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import { db } from "../client";
import { order as orderTable, orderItem as orderItemTable } from "../schema/postgres";
/**
* Bulk upsert orders with conflict handling
*/
export async function bulkUpsertOrders(
ordersData: Array<{
externalId: string;
customerId: string;
status: string;
total: number;
}>,
) {
if (ordersData.length === 0) {
return [];
}
const upserted = await db
.insert(orderTable)
.values(ordersData)
.onConflictDoUpdate({
target: [orderTable.externalId],
set: {
status: sql`excluded.status`,
total: sql`excluded.total`,
updatedAt: sql`NOW()`,
},
})
.returning({
id: orderTable.id,
externalId: orderTable.externalId,
});
return upserted;
}
/**
* Get orders with items for a user
*/
export async function getOrdersWithItems(params: {
userId: string;
limit?: number;
}) {
const { userId, limit = 20 } = params;
const orders = await db
.select()
.from(orderTable)
.where(eq(orderTable.userId, userId))
.orderBy(desc(orderTable.createdAt))
.limit(limit);
if (orders.length === 0) {
return [];
}
const orderIds = orders.map(o => o.id);
const items = await db
.select()
.from(orderItemTable)
.where(inArray(orderItemTable.orderId, orderIds));
const itemsByOrder = groupBy(items, "orderId");
return orders.map(order => ({
...order,
items: itemsByOrder.get(order.id) || [],
}));
}
Advanced SQL Patterns
Scenario: Admission and Bed Mutations
1. Scope / Trigger
- Trigger: changing elder admission, transfer, discharge, or bed occupancy behavior.
- These mutations span
admissions,beds, andelders, so partial writes are not acceptable.
2. Signatures
POST /api/admissions: admit an elder or transfer an active elder to a new available bed.PATCH /api/admissions/[id]: discharge an active admission and release its bed.GET /api/elders: list elders with current active bed labels joined fromadmissions -> beds -> rooms.readData(): compatibility read model must expose the same active bed labels onElder.room,Elder.bed, andElder.bedId.- Mutations use
getDatabase().transaction(async (transaction) => ...).
3. Contracts
POST /api/admissionsrequest:{ elderId: string; bedId: string; notes?: string }.PATCH /api/admissions/[id]request:{ notes?: string }.- Success responses include
{ success: true, reason: string, admission }. - Failure responses include
{ success: false, reason: string }.
4. Validation & Error Matrix
- Missing active organization ->
400. - Missing elder or bed ->
404. - Target bed not
available->409. - Discharge target not found ->
404. - Discharge target not
active->409. - Insert/update returning no row ->
500.
5. Good/Base/Bad Cases
- Good: return a typed mutation result from inside the transaction, then convert it to
jsonFailureorjsonSuccessoutside the transaction. - Good: display elder bed fields as read-only data derived from the active admission; bed assignment actions live in the bed/admission workspace.
- Base: read-only admission lists may use the temporary
readData()compatibility model while the domain helper layer is being extracted. - Bad: throw ordinary
Errorfor business conflicts such as occupied beds, because it loses the structured status/reason contract. - Bad: allow free-text room/bed edits on the elder form when those strings are not persisted relationship data.
6. Tests Required
pnpm lintpnpm type-checkpnpm build- Integration assertions: occupied-bed admission returns
409; transfer closes the old active admission and frees the old bed; discharge closes the active admission, frees the bed, and marks the elder discharged.
7. Wrong vs Correct
Wrong
if (bed.status !== "available") {
throw new Error("床位不可分配");
}
Correct
if (bed.status !== "available") {
return { success: false, reason: "床位不可分配", status: 409 };
}
JSON Column Operations
When using PostgreSQL JSON/JSONB columns, proper casting is required for JSON functions.
// BAD - Missing cast for jsonb functions
const result = await db
.select()
.from(productTable)
.where(sql`${productTable.metadata}->>'category' = 'electronics'`);
// GOOD - Explicit cast for jsonb operations
const result = await db
.select()
.from(productTable)
.where(sql`${productTable.metadata}::jsonb->>'category' = 'electronics'`);
// JSON array contains check
const withTag = await db
.select()
.from(productTable)
.where(sql`${productTable.tags}::jsonb ? 'featured'`);
// JSON array length
const withMultipleTags = await db
.select()
.from(productTable)
.where(sql`jsonb_array_length(${productTable.tags}::jsonb) > 3`);
Raw SQL Column Names (camelCase)
When using raw SQL with Drizzle, column names must use double quotes for camelCase names.
// BAD - PostgreSQL will lowercase unquoted identifiers
await db.execute(sql`
UPDATE order
SET lastUpdatedAt = NOW()
WHERE userId = ${userId}
`);
// GOOD - Double quotes preserve camelCase
await db.execute(sql`
UPDATE "order"
SET "lastUpdatedAt" = NOW()
WHERE "userId" = ${userId}
`);
// Complex raw SQL example
await db.execute(sql`
UPDATE "order" AS o
SET
"totalAmount" = sub."calculatedTotal",
"updatedAt" = NOW()
FROM (
SELECT
"orderId",
SUM("price" * "quantity") AS "calculatedTotal"
FROM "orderItem"
WHERE "orderId" = ANY(${sql.raw(arrayLiteral)})
GROUP BY "orderId"
) AS sub
WHERE o.id = sub."orderId"
`);
Enum Comparison
When comparing enum columns in raw SQL, cast the column to text.
// BAD - Direct enum comparison may fail
await db.execute(sql`
SELECT * FROM "order"
WHERE status != 'DRAFT'
`);
// GOOD - Cast enum column to text
await db.execute(sql`
SELECT * FROM "order"
WHERE status::text != 'DRAFT'
`);
// In Drizzle query builder (works correctly)
const orders = await db
.select()
.from(orderTable)
.where(ne(orderTable.status, "DRAFT"));
Aggregation with Filtering
Use FILTER clause for conditional aggregation.
await db.execute(sql`
UPDATE "category" AS c
SET
"productCount" = sub."count",
"activeProductCount" = sub."activeCount",
"updatedAt" = NOW()
FROM (
SELECT
"categoryId",
COUNT(*)::int AS "count",
COUNT(*) FILTER (WHERE "status" = 'ACTIVE')::int AS "activeCount"
FROM "product"
WHERE "categoryId" = ANY(${sql.raw(categoryIds)})
GROUP BY "categoryId"
) AS sub
WHERE c.id = sub."categoryId"
`);
Transaction Patterns
Basic Transaction
import { db } from "@your-app/database";
const result = await db.transaction(async (tx) => {
// All operations use tx instead of db
const [order] = await tx
.insert(orderTable)
.values({ userId, total: 0 })
.returning();
await tx.insert(orderItemTable).values(
items.map(item => ({
orderId: order.id,
...item,
}))
);
// Update order total
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
await tx
.update(orderTable)
.set({ total })
.where(eq(orderTable.id, order.id));
return order;
});
Transaction with Rollback
try {
await db.transaction(async (tx) => {
await tx.insert(orderTable).values(orderData);
// This will cause rollback if payment fails
const paymentResult = await processPayment(orderData.total);
if (!paymentResult.success) {
throw new Error("Payment failed");
}
await tx.update(orderTable)
.set({ paymentId: paymentResult.id })
.where(eq(orderTable.id, orderData.id));
});
} catch (error) {
// Transaction automatically rolled back
logger.error("Order creation failed", { error });
}
Query Performance Tips
Use Indexes
Ensure your queries use appropriate indexes:
// Good for index on (userId, createdAt DESC)
const recentOrders = await db
.select()
.from(orderTable)
.where(eq(orderTable.userId, userId))
.orderBy(desc(orderTable.createdAt))
.limit(10);
Select Only Needed Columns
// BAD - Selects all columns including large text fields
const orders = await db.select().from(orderTable);
// GOOD - Select only needed columns
const orders = await db
.select({
id: orderTable.id,
status: orderTable.status,
total: orderTable.total,
})
.from(orderTable);
Use Relations for Complex Queries
// Using Drizzle relations for nested data
const ordersWithDetails = await db.query.order.findMany({
where: eq(orderTable.userId, userId),
with: {
items: {
with: {
product: true,
},
},
customer: {
columns: {
id: true,
name: true,
email: true,
},
},
},
orderBy: (orders, { desc }) => desc(orders.createdAt),
limit: 20,
});