Drizzle ORM مع PostgreSQL: الدليل الكامل
يحتل Drizzle ORM موقعًا فريدًا في النظام البيئي لقاعدة بيانات TypeScript: فهو في الواقع مجرد أداة إنشاء استعلام مقنعة، مع مخطط كرمز ينشئ SQL يمكنك قراءته، ونظام ترحيل يعامل قاعدة البيانات الخاصة بك كمصدر الحقيقة. بعد سنوات من استخدام Prisma وTypeORM، يبدو Drizzle وكأنه يعود إلى لغة SQL الأولية - ولكن مع استدلال TypeScript الكامل وبدون أي تكاليف تشغيل.
يغطي هذا الدليل كل شيء بدءًا من الإعداد الأولي وحتى أنماط الإنتاج، مستمدًا من قاعدة تعليمات برمجية تحتوي على أكثر من 65 ملف مخطط Drizzle واستعلامات متعددة المستأجرين ونمط اتصال الوكيل البطيء الذي يعمل بشكل موثوق في سياقات Next.js وNestJS.
الوجبات الرئيسية
- استخدم وكيلًا كسولًا لاتصال قاعدة البيانات - لا تقم أبدًا بإنشاء اتصالات حريصة في وقت تحميل الوحدة
- يجب تشغيل PostgreSQL المحلي على منفذ غير افتراضي (5433) لتجنب التعارضات مع عمليات تثبيت النظام
- تحتاج كافة الجداول إلى
organizationIdللإيجار المتعدد - يتم فرضها على مستوى المخطط- تحتاج مقارنات تعداد Drizzle إلى صب TypeScript واضح لتضييق النوع
- لا تستخدم
sql.raw()أبدًا - استخدم دائمًا قالبsqlالحرفي مع القيم ذات المعلمات- العلاقات منفصلة عن المفاتيح الخارجية - حدد كلاهما لضمان أمان النوع الكامل
- التفاف المعاملات على عمليات متعددة الخطوات؛ قم بتمرير الكائن
txإلى الخدمات- يجب أن يقوم فهرس المخطط (
schema/index.ts) بتصدير جميع الجداول حتى تعمل عمليات الترحيل
التثبيت والإعداد
يتطلب Drizzle حزمتين: ORM الأساسي وبرنامج تشغيل قاعدة البيانات.
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
بالنسبة للمكدس الكامل بما في ذلك عمليات الترحيل والاستوديو:
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
تعد الحزمة postgres (تختلف عن pg) هي برنامج التشغيل الموصى به لـ Drizzle في مشاريع TypeScript الحديثة - فهي تدعم تجميع الاتصالات والبيانات المعدة ولديها أنواع أفضل من TypeScript.
نمط الوكيل الكسول
القرار المعماري الأكثر أهمية مع Drizzle في Next.js أو NestJS monorepo هو عدم إنشاء اتصال قاعدة بيانات متحمس مطلقًا. تتسبب الاتصالات المتحمسة في وقت تحميل الوحدة النمطية في حدوث مشكلات في:
- Next.js: يتم استيراد الوحدات أثناء عملية الإنشاء، حيث لا تتوفر قاعدة بيانات
- NestJS: يتم استيراد الحزمة قبل تحميل متغيرات البيئة
- بدون خادم: يتأخر بدء التشغيل البارد بسبب حمل الاتصال
يحل نمط الوكيل البطيء كل هذه الأمور:
// packages/db/src/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let _db: ReturnType<typeof drizzle> | null = null;
function getDb() {
if (!_db) {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is not set');
}
const client = postgres(connectionString, {
max: 10, // connection pool size
idle_timeout: 20,
connect_timeout: 10,
});
_db = drizzle(client, { schema });
}
return _db;
}
// Export a Proxy that initializes the connection on first use
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
get(_, prop) {
return getDb()[prop as keyof ReturnType<typeof drizzle>];
},
});
export * from './schema';
يعني هذا النمط أن استيراد @ecosire/db لا يفتح اتصال قاعدة البيانات مطلقًا. يتم إنشاء الاتصال فقط عند تشغيل الاستعلام الأول.
تصميم المخطط
مخططات Drizzle هي ملفات TypeScript عادية تقوم بتصدير تعريفات الجدول. الانضباط هنا هو الاحتفاظ بالجداول المرتبطة في نفس الملف وتصدير كل شيء من خلال فهرس مركزي.
// packages/db/src/schema/contacts.ts
import {
pgTable,
uuid,
text,
varchar,
timestamp,
pgEnum,
boolean,
index,
} from 'drizzle-orm/pg-core';
export const contactTypeEnum = pgEnum('contact_type', ['individual', 'company', 'partner']);
export const contacts = pgTable(
'contacts',
{
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id').notNull(), // Multi-tenancy
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }),
phone: varchar('phone', { length: 50 }),
type: contactTypeEnum('type').default('individual').notNull(),
isActive: boolean('is_active').default(true).notNull(),
notes: text('notes'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => ({
organizationIdx: index('contacts_organization_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
})
);
export type Contact = typeof contacts.$inferSelect;
export type NewContact = typeof contacts.$inferInsert;
يمنحك النوعان $inferSelect و$inferInsert استدلالًا كاملاً لـ TypeScript — يعرف Drizzle الحقول المطلوبة مقابل الحقول الاختيارية في وقت الإدراج بناءً على قيود المخطط الخاص بك.
// packages/db/src/schema/index.ts
export * from './contacts';
export * from './orders';
export * from './licenses';
export * from './products';
// ... all tables
يجب أن يقوم ملف الفهرس بتصدير كل جدول لـ drizzle-kit لالتقاطها أثناء إنشاء الترحيل.
العلاقات
يفصل Drizzle قيود المفاتيح الخارجية (التي تفرضها PostgreSQL) عن تعريفات العلاقات (التي تستخدمها واجهة برمجة تطبيقات الاستعلام العلائقية الخاصة بـ Drizzle). أنت بحاجة إلى كليهما:
// packages/db/src/schema/relations.ts
import { relations } from 'drizzle-orm';
import { contacts } from './contacts';
import { orders } from './orders';
import { orderItems } from './order-items';
import { products } from './products';
export const contactsRelations = relations(contacts, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
contact: one(contacts, {
fields: [orders.contactId],
references: [contacts.id],
}),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, {
fields: [orderItems.orderId],
references: [orders.id],
}),
product: one(products, {
fields: [orderItems.productId],
references: [products.id],
}),
}));
من خلال تحديد العلاقات، يمكنك استخدام واجهة برمجة تطبيقات الاستعلام العلائقي الخاصة بـ Drizzle:
// Fetch order with contact and items in one query
const order = await db.query.orders.findFirst({
where: eq(orders.id, orderId),
with: {
contact: true,
items: {
with: {
product: true,
},
},
},
});
يقوم Drizzle بإنشاء استعلام JOIN واحد - لا توجد مشكلة N+1.
استعلامات النوع الآمن
واجهة برمجة تطبيقات الاستعلام الخاصة بـ Drizzle آمنة تمامًا للنوع. يتم تضييق نوع التحديد بناءً على الأعمدة التي تقوم بتضمينها:
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and, like, sql, count } from 'drizzle-orm';
// Full select — returns Contact[]
const allContacts = await db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(50);
// Partial select — returns only specified columns
const contactNames = await db
.select({
id: contacts.id,
name: contacts.name,
email: contacts.email,
})
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
eq(contacts.isActive, true)
)
);
// Count query
const [{ total }] = await db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId));
// Search with ILIKE (case-insensitive)
const searchResults = await db
.select()
.from(contacts)
.where(
and(
eq(contacts.organizationId, orgId),
like(contacts.name, `%${searchTerm}%`)
)
)
.limit(20);
بالنسبة للعبارات الديناميكية (مرشحات من واجهة المستخدم)، قم ببناء الشروط بشكل مشروط:
import { SQL } from 'drizzle-orm';
async function searchContacts(orgId: string, filters: {
search?: string;
type?: 'individual' | 'company';
isActive?: boolean;
}) {
const conditions: SQL[] = [eq(contacts.organizationId, orgId)];
if (filters.search) {
conditions.push(like(contacts.name, `%${filters.search}%`));
}
if (filters.type) {
// Enum comparisons need explicit casting
conditions.push(eq(contacts.type, filters.type as 'individual' | 'company' | 'partner'));
}
if (filters.isActive !== undefined) {
conditions.push(eq(contacts.isActive, filters.isActive));
}
return db
.select()
.from(contacts)
.where(and(...conditions))
.limit(100);
}
لاحظ التحويل الصريح لقيم التعداد: filters.type as 'individual' | 'company' | 'partner'. لا يقبل نوع التعداد الخاص بـ Drizzle تلقائيًا أنواعًا فرعية من سلسلة أضيق بدون طاقم الممثلين.
إدراج وتحديث وحذف
import { db } from '@ecosire/db';
import { contacts, NewContact } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';
// Insert with RETURNING
async function createContact(data: NewContact) {
const [created] = await db
.insert(contacts)
.values(data)
.returning();
return created; // Fully typed as Contact
}
// Upsert (insert or update on conflict)
async function upsertContact(data: NewContact) {
const [result] = await db
.insert(contacts)
.values(data)
.onConflictDoUpdate({
target: [contacts.organizationId, contacts.email],
set: {
name: data.name,
updatedAt: new Date(),
},
})
.returning();
return result;
}
// Update with RETURNING
async function updateContact(
id: string,
orgId: string,
updates: Partial<NewContact>
) {
const [updated] = await db
.update(contacts)
.set({ ...updates, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId) // Always filter by org
)
)
.returning();
return updated;
}
// Soft delete (recommended over hard delete)
async function deleteContact(id: string, orgId: string) {
return db
.update(contacts)
.set({ isActive: false, updatedAt: new Date() })
.where(
and(
eq(contacts.id, id),
eq(contacts.organizationId, orgId)
)
);
}
استخدم دائمًا .returning() في عبارات INSERT وUPDATE - فهو يتجنب رحلة SELECT ذهابًا وإيابًا ثانية للحصول على السجل الذي تم إنشاؤه/تحديثه.
المعاملات
العمليات متعددة الخطوات التي يجب أن تنجح أو تفشل معًا تحتاج إلى معاملات:
async function createOrderWithItems(
orderData: NewOrder,
items: NewOrderItem[]
) {
return db.transaction(async (tx) => {
// Create the order
const [order] = await tx
.insert(orders)
.values(orderData)
.returning();
// Create order items
const createdItems = await tx
.insert(orderItems)
.values(items.map((item) => ({ ...item, orderId: order.id })))
.returning();
// Update inventory (must succeed or entire transaction rolls back)
for (const item of items) {
await tx
.update(products)
.set({
stock: sql`${products.stock} - ${item.quantity}`,
})
.where(eq(products.id, item.productId));
}
return { order, items: createdItems };
});
}
عند تمرير سياق المعاملة إلى طرق الخدمة:
// Service accepts optional transaction for composability
async function activateLicense(
licenseId: string,
tx?: typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
) {
const queryRunner = tx || db;
return queryRunner
.update(licenses)
.set({ status: 'active', activatedAt: new Date() })
.where(eq(licenses.id, licenseId))
.returning();
}
الهجرات باستخدام مجموعة الرذاذ
يقوم drizzle-kit CLI بإنشاء عمليات ترحيل من تغييرات المخطط:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/schema/index.ts',
out: './src/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
سير العمل:
# Generate migration SQL from schema changes
npx drizzle-kit generate
# Apply migrations to database
npx drizzle-kit migrate
# Push schema directly (dev only — skips migration files)
npx drizzle-kit push
# Open Drizzle Studio (visual database browser)
npx drizzle-kit studio
للإنتاج، استخدم دائمًا migrate (وليس push). الأمر push مخصص لتكرار التطوير المحلي - فهو لا ينشئ ملفات ترحيل، لذلك لا يتم تعقب التغييرات.
نمط ترقيم الصفحات
يتطلب ترقيم الصفحات الفعال استعلام بيانات واستعلام عدد:
interface PaginationParams {
page: number;
pageSize: number;
}
async function getContactsPaginated(orgId: string, { page, pageSize }: PaginationParams) {
const offset = (page - 1) * pageSize;
const [data, [{ total }]] = await Promise.all([
db
.select()
.from(contacts)
.where(eq(contacts.organizationId, orgId))
.limit(pageSize)
.offset(offset)
.orderBy(contacts.createdAt),
db
.select({ total: count() })
.from(contacts)
.where(eq(contacts.organizationId, orgId)),
]);
return {
data,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
};
}
يؤدي تشغيل كلا الاستعلامين بالتوازي مع Promise.all إلى خفض وقت الاستجابة إلى النصف مقارنة بالتنفيذ المتسلسل.
المخاطر والحلول الشائعة
المأزق 1: استخدام sql.raw() للاستعلامات الديناميكية
// Dangerous — SQL injection vulnerability
const results = await db.execute(
sql.raw(`SELECT * FROM contacts WHERE name = '${userInput}'`)
);
// Safe — parameterized
const results = await db.execute(
sql`SELECT * FROM contacts WHERE name = ${userInput}`
);
المأزق 2: فهارس مفقودة في الأعمدة التي يتم الاستعلام عنها بشكل متكرر
قم دائمًا بإضافة فهارس إلى الأعمدة التي تقوم بالتصفية حسبها. الفهارس المفقودة الأكثر شيوعًا:
(table) => ({
organizationIdx: index('contacts_org_idx').on(table.organizationId),
emailIdx: index('contacts_email_idx').on(table.email),
compositeIdx: index('contacts_org_active_idx').on(
table.organizationId,
table.isActive
),
})
المأزق 3: استعلامات N+1 من تحميل العلاقة اليدوية
// N+1 problem — one query per order
const orders = await db.select().from(ordersTable);
for (const order of orders) {
order.contact = await db
.select()
.from(contacts)
.where(eq(contacts.id, order.contactId));
}
// Solution — use Drizzle's relational query API
const orders = await db.query.orders.findMany({
with: { contact: true },
});
المأزق 4: نسيان تصدير جداول جديدة من المخطط/index.ts
إذا قمت بإنشاء ملف مخطط جديد ولكن نسيت إعادة التصدير من schema/index.ts، فلن يرى drizzle-kit الجدول الجديد ولن يتم إنشاء عمليات الترحيل.
الأسئلة المتداولة
كيف يمكن مقارنة Drizzle بـ Prisma لمشاريع TypeScript؟
يعتبر Drizzle أقرب إلى SQL الخام مع أنواع TypeScript - حيث تكتب استعلامات تعين 1:1 إلى SQL. تلخص Prisma المزيد، باستخدام لغة استعلام مخصصة وملف مخطط منفصل. يتمتع Drizzle باستدلال أفضل لـ TypeScript في الاستعلامات المعقدة، ووقت تشغيل أقل، ولا يتطلب خطوة إنشاء التعليمات البرمجية. يتمتع Prisma بنظام بيئي أكبر والمزيد من الميزات المضمنة مثل عمليات الحذف البسيطة وتسجيل التدقيق. بالنسبة للفرق الكبيرة التي تتعامل مع SQL، يُفضل Drizzle بشكل عام.
هل يجب علي استخدام نظام drizzle-kit Push أو الترحيل في CI/CD؟
استخدم دائمًا drizzle-kit migrate في CI/CD. الأمر push مخصص للتطوير المحلي السريع - فهو يطبق تغييرات المخطط مباشرة دون إنشاء ملفات ترحيل، لذلك لا يوجد أي مسار للتدقيق ويكون التراجع أمرًا صعبًا. في مسار النشر الخاص بك، قم بتشغيل drizzle-kit migrate قبل بدء التطبيق للتأكد من مزامنة المخطط.
كيف أتعامل مع عملية زرع قاعدة البيانات باستخدام Drizzle؟
أنشئ ملفًا أوليًا منفصلاً واستورد مخططك مباشرةً. استخدم onConflictDoNothing() للبذور العاجزة التي لن تفشل عند إعادة تشغيلها. قم بتخزين البيانات الأولية في الثوابت لتسهيل التحديثات، وتشغيل البذور بعد عمليات الترحيل في البرنامج النصي لإعداد التطوير الخاص بك.
كيف يمكنني إجراء بحث عن النص الكامل باستخدام Drizzle وPostgreSQL؟
استخدم قالب sql الحرفي مع وظائف to_tsvector وto_tsquery الخاصة بـ PostgreSQL. لا يقوم Drizzle بتجريد البحث عن النص الكامل، ولكن يمكنك كتابته مباشرةً. أضف فهرس GIN في العمود tsvector للأداء، واستخدم websearch_to_tsquery لمصطلحات البحث المقدمة من المستخدم لتحليل استعلامات اللغة الطبيعية بأمان.
كيف أتعامل مع تغييرات المخطط دون توقف؟
استخدم عمليات الترحيل الإضافية - قم بإضافة الأعمدة باعتبارها فارغة أولاً، ثم قم بإعادة ملء البيانات، ثم قم بإضافة القيد NOT NULL. لا تقم مطلقًا بإسقاط الأعمدة في نفس عملية الترحيل التي تؤدي إلى إزالتها من التعليمات البرمجية. التسلسل هو: 1) نشر التعليمات البرمجية التي تتجاهل العمود القديم، 2) نشر الترحيل لإسقاطه. وهذا يضمن أن التراجع ممكن دائمًا دون فقدان البيانات.
الخطوات التالية
تعتبر طبقة قاعدة البيانات المصممة جيدًا هي الأساس الذي يتم بناء كل تطبيق إنتاج عليه. يعمل فريق ECOSIRE الهندسي مع Drizzle ORM وPostgreSQL يوميًا، لإدارة أكثر من 65 ملف مخطط عبر نظام معقد متعدد المستأجرين.
سواء كنت بحاجة إلى استشارات في مجال هندسة قاعدة البيانات، أو تكامل Odoo ERP، أو نظام خلفي كامل مصمم باستخدام أدوات TypeScript الحديثة، استكشف خدماتنا لمعرفة كيف يمكننا مساعدتك.
بقلم
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
مقالات ذات صلة
Zero-Downtime Database Migrations with Drizzle ORM
Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Natural Language Database Queries with OpenClaw
How OpenClaw enables natural language database queries, translating plain English business questions into accurate SQL without exposing database credentials or query complexity.