الإقليم ٥ — Prisma: ORM، وإعادةُ النوع الذي محته اللغة
النبذة
في الإقليم ٤ صرتَ تكتب SQL بيدك وتفهم ما تحته. الآن نضع فوقه طبقةً — لكن انتبه: ليست بديلاً عن فهمك، بل راحةً فوقه. Prisma ستحلّ ثلاث مشكلاتٍ حقيقيةٍ في وصل خادمٍ مكتوبِ-النوع بقاعدةٍ علائقيّة، وأهمُّها لغزٌ تركناه مفتوحاً منذ الإقليم ١: إن كانت الأنواع تُمحى وقت التشغيل، فمن أين أتى newUser بنوعٍ كاملٍ يعرف حقولَه؟ الجواب هنا، وهو أنيقٌ ويختم دائرةً فكرية.
اللغز المستفزّ
عندك الآن قدرتان: تكتب SQL صحيحاً (٤)، وتكتب TypeScript مكتوبَ-النوع (١). صِلْهما: اكتب متحكّمَ تسجيلٍ يكتب مستخدماً في Postgres. الطريقة المباشرة — SQL خامٌ من داخل Node:
tsconst q = `INSERT INTO users (username, email, password_hash) VALUES ('${username}', '${email}', '${hash}') RETURNING *`; const rows = await db.query(q); const user = rows[0]; // ما نوعُ user؟
توقّف عند كل عطبٍ هنا قبل أن تقرأ الحلّ:
- الحقن (SQL injection): لو كان
usernameهو'); DROP TABLE users; --، فأنت تلصقه نصّاً في الاستعلام. غرسَ المهاجمُ SQL في بياناتك. (تعرف خطورةَ خلطِ الكود بالبيانات من أمن C — هذا نظيرُه.) - بلا نوع:
rows[0]نوعُهany— قاعدةُ البيانات ترجّع بايتاتٍ/صفوفاً وقت التشغيل، حيث لا أنواع (الـ erasure من الإقليم ١). المترجمُ أعمى عن شكلuser. تكتبuser.emialبخطأٍ مطبعيٍّ فيمرّ صامتاً. - عدمُ التطابق (impedance mismatch): SQL يفكّر بـصفوفٍ وجداولَ (مجموعات)، وJS يفكّر بـكائناتٍ ورسومٍ بيانية (graphs). الترجمةُ بينهما يدوياً — خصوصاً مع العلاقات (منهجٌ وسبرنتاتُه المتداخلة) — مملّةٌ ومعرّضةٌ للخطأ.
- انجرافُ السكيمة (schema drift): SQL في كودك وافتراضاتُ كودك عن الأعمدة تتباعدان بصمت؛ تغيّر عموداً في القاعدة، ولا شيءَ يخبر كودَك حتى ينفجر إنتاجاً.
السؤال المستفزّ:
كيف تخاطب قاعدةً علائقيّةً من TypeScript مكتوبِ-النوع — بأمانٍ من الحقن، وبنوعٍ كاملٍ للنتائج، وبلا ترجمةٍ يدويّةٍ هشّة — مع الاحتفاظ بفهمك لِما يجري تحت من SQL؟
ليش: ما الـ ORM، ولماذا (وحدوده)
الجوابُ صنفُ أدواتٍ اسمه ORM (Object-Relational Mapper): يجسر العالمين — كائناتُ برنامجك ⇄ علاقاتُ قاعدتك. تكتب بلغة الكائنات، وهو يولّد SQL المعامَل (parameterized) ويحوّل الصفوفَ العائدةَ إلى كائنات.
ليش يفيد: يقتل الحقنَ (لا يلصق قيمَك في النصّ — يمرّرها كمعامِلاتٍ منفصلة)، ويختصر الترجمةَ المتكرّرة، ويعطي نوعاً للنتائج. وليش احذر: ORM يخفي SQL، فقد يولّد استعلاماتٍ غبيّةً (مشكلةُ N+1 أدناه) إن لم تفهم ما تحته. لهذا رتّبنا المنهج هكذا عمداً: تعلّمتَ SQL أولاً (٤)، فالـ ORM الآن راحةٌ تتحكّم فيها، لا ستارٌ يعميك. القاعدةُ الذهبية لإدارة المشروع: اعرف دائماً أيَّ SQL يولّده استدعاؤك — وستفعل ذلك حرفياً في اللغز (Prisma يطبع لك SQL).
ليش: Prisma ليس ORM تقليدياً — هو "schema-first" بمولّد
أغلبُ ORMs تعرّف النموذجَ بكلاسات/ديكوريترز في الكود. Prisma يقلب الترتيب: ملفُّ سكيمةٍ واحدٌ (schema.prisma) هو مصدرُ الحقيقة الوحيد، ومنه:
خطّان ينطلقان من السكيمة نفسها التي صمّمتَها في الإقليم ٤. نفكّكهما.
الخطّ الأول: migrate — تطوّرُ بنية القاعدة
shprisma migrate dev --name add_curriculum
ماذا يفعل؟ يقرأ schema.prisma، يقارنه بحالة القاعدة، يولّد ملفَّ SQL migration يسدّ الفرق (CREATE TABLE, ALTER TABLE ADD COLUMN, CREATE TYPE ... ENUM...) — نفسُ DDL الذي كتبتَه بيدك في الإقليم ٤ — ثم يطبّقه ويسجّله. تاريخُ مشروعك يريك هذا حيّاً: migration الأولى أنشأت users، والثانية حوّلت role من نصٍّ إلى enum وأضافت user_name فريداً (تذكّر تحذيرَها عن فقدِ البيانات — تطوّرُ السكيمة قرارٌ له ثمن).
ليش migrations لا مجرّد CREATE TABLE يدويّ؟ لأنها تاريخٌ مرتّبٌ ومُتحكَّمٌ به بالإصدار لتطوّر بنية قاعدتك — يطبّقه أيُّ بيئةٍ (تطوير، CI، إنتاج) بنفس الترتيب فتتطابق. هذا يحلّ "انجراف السكيمة": البنيةُ موصوفةٌ في git، لا في ذاكرة أحد.
prisma migrate deploy (لا dev) يطبّق الـ migrations المعلّقة فقط، مُحايِدٌ للتكرار (idempotent) وآمنٌ في كل إقلاع — لهذا وضعه Dockerfile مشروعك في CMD: npx prisma migrate deploy && npx tsx src/server.ts. كلُّ إقلاعٍ يضمن أن بنية القاعدة محدَّثة، بلا ضرر.
الخطّ الثاني: generate — وهنا يُعاد النوعُ الذي مُحي (ختمُ لغز الإقليم ١)
shprisma generate
يقرأ السكيمة (التي تصف شكلَ بياناتك وقت التشغيل في القاعدة)، ويولّد كوداً: عميلٌ مكتوبُ-النوع + تعريفاتُ TypeScript لكل نموذج. الآن:
tsconst newUser = await prisma.user.create({ data: { ... } }); // ^? النوع: User { id: string; username: string; email: string; role: Role; ... }
من أين أتى نوعُ newUser الكامل، والأنواعُ تُمحى وقت التشغيل؟ من خطوةِ توليدِ كودٍ قرأت السكيمة وكتبت الأنواعَ بنفسها. الـ erasure يقول "لا معلوماتِ نوعٍ تنشأ وقت التشغيل"؛ Prisma لا يخالفه — بل ينقل المعلومةَ إلى زمنٍ أبكر: السكيمةُ المكتوبةُ سلفاً تُترجَم إلى أنواعِ TS مكتوبةٍ سلفاً، فيحرسك المترجمُ وقتَ الكتابة، ثم تُمحى كالعادة وقت التشغيل. توليدُ الكود هو الجسر فوق هاوية الـ erasure.
لاحظ التناظر الجميل القادم: Prisma يولّد الأنواعَ من سكيمة القاعدة؛ وZod (الإقليم ٦) يولّدها من سكيمة التحقّق. كلاهما يجسر نفس الهاوية من طرفين: أحدُهما عند حدّ القاعدة، والآخر عند حدّ الشبكة. سترى الصورةَ تكتمل.
في مشروعك: السكيمة تقول output = "../src/generated/prisma"، وprisma.ts يستورد منها PrismaClient. والعميلُ المولَّد مُتجاهَلٌ في git (لأنه مُشتقّ)، لهذا Dockerfile يشغّل prisma generate داخل الصورة. كلُّ سطرٍ صار مفهوماً.
كيف: لغةُ استعلام Prisma (وكلٌّ يقابل SQL تعرفه)
كلُّ استدعاءٍ هنا يولّد SQL من الإقليم ٤ — اربطهما دائماً:
ts// SELECT ... WHERE id = ... LIMIT 1 (على عمودٍ فريد) const u = await prisma.user.findUnique({ where: { email } }); // SELECT id, username, role FROM users WHERE role = 'ADMIN' ORDER BY created_at DESC const admins = await prisma.user.findMany({ where: { role: "ADMIN" }, select: { id: true, username: true, role: true }, // = الإسقاط (projection) orderBy: { createdAt: "desc" }, }); // INSERT ... RETURNING (مع select لتقييد ما يعود — مشروعك يفعلها ليُخفي كلمةَ المرور) const created = await prisma.user.create({ data: { firstName, lastName, username, email, password: hash, role }, select: { id: true, username: true, email: true, role: true, createdAt: true }, }); // UPDATE / DELETE await prisma.user.update({ where: { id }, data: { role: "ADMIN" } }); await prisma.user.delete({ where: { id } });
include = JOIN، وselect = الإسقاط
العلاقاتُ التي بنيتَها بمفاتيحَ أجنبيةٍ في الإقليم ٤، يعبرها Prisma بـ include:
ts// = JOIN: منهجٌ مع كل سبرنتاته (الواحد-لكثير) const c = await prisma.curriculum.findUnique({ where: { id }, include: { sprints: true }, // يضمّ الجدول المرتبط }); // c.sprints موجودةٌ ومكتوبةُ النوع — الضمُّ الذي كتبته يدوياً، بنوعٍ كامل
المعاملات — ACID للتصحيح (الإقليم ١٠)
$transaction يلفّ عدّةَ عملياتٍ في معاملةٍ ذرّيّة (تذكّر BEGIN/COMMIT/ROLLBACK):
tsawait prisma.$transaction(async (tx) => { await tx.submission.create({ data: { ... } }); await tx.taskProgress.update({ where: { ... }, data: { score: 80, status: "passed" } }); }); // إمّا الاثنان يثبتان، أو يُلغى الكلُّ عند أي خطأ — الذرّيّةُ التي أثبتّها بـ SQL
هذا حرفياً كيف يكتب نظامُ التصحيح نتيجتَه: نتيجةٌ + تحديثُ تقدّمٍ، ذرّياً.
الأمان من الحقن — مجّاناً
كلُّ قيمةٍ تمرّرها (where: { email }) تذهب معامِلاً منفصلاً، لا تُلصق في نصّ الـ SQL. فمحاولةُ الحقن '); DROP TABLE... تُعامَل كقيمةِ بريدٍ حرفيّةٍ لا ككود. (للحالات النادرة التي تحتاج SQL خاماً، $queryRaw بقوالبَ مُعامَلةٍ آمنة — لا تلصق أبداً.)
كيف: فخّ N+1 — لماذا فهمُك لـ SQL يبقى ضرورياً
الـ ORM يخفي SQL، فيسهل أن تكتب حلقةً تطلق استعلاماً لكل عنصر:
tsconst curricula = await prisma.curriculum.findMany(); // استعلامٌ ١ for (const c of curricula) { c.sprints = await prisma.sprint.findMany({ where: { curriculumId: c.id } }); // N استعلام! } // المجموع: 1 + N استعلام — كارثةُ أداءٍ مع كثرة المناهج
العلاج: استعلامٌ واحدٌ بـ include (ضمٌّ واحد) بدل N. هذا بالضبط لماذا لا يكفي حفظُ الـ ORM: لو لم تفهم أنه يولّد استعلامات، لما رأيتَ الـ N+1 ولا أصلحتَه. فهمُ الإقليم ٤ هو ما يجعلك تقرأ ما يولّده Prisma بعينٍ ناقدة (والفهارسُ التي تعلّمتَها تسرّع هذه الضمّات).
اللغز / البناء من الصفر
أدواتك: مشروعٌ Node + Postgres (حاوية الإقليم ٤) + Prisma. القيد الحاكم: فعّل طباعةَ SQL — new PrismaClient({ log: ["query"] }) — ولكل استدعاءِ Prisma، انظر الـ SQL المطبوع وتأكّد أنه ما كنتَ ستكتبه بيدك. ممنوع معاملةُ Prisma صندوقاً أسود.
اللغز أ — من DDL يدويٍّ إلى سكيمةِ Prisma. خذ سكيمتك المطبَّعةَ من الإقليم ٤ (users → curricula → sprints → ...، enrollments، task_progress). عبّر عنها في schema.prisma (نماذج، حقول، @id/@unique/@default، علاقاتٌ بمفاتيحَ أجنبية، enum للدور). شغّل prisma migrate dev، ثم افتح ملفَّ الـ SQL migration المولَّد وقارنه بـ DDL الذي كتبتَه بيدك. أين تطابقا؟ أين اختار Prisma صياغةً مختلفة؟ هذا يثبت أن Prisma لا يخترع شيئاً — يولّد SQL الذي تتقنه.
اللغز ب — شاهد الـ erasure يُجبَر. شغّل prisma generate، ثم افتح ملفَّ الأنواع المولَّد في مجلّد الإخراج، وجِد نوعَ User. ثم في كودٍ .ts اكتب const u = await prisma.user.create(...) ومرّر الفأرةَ على u (أو اطبع خطأً متعمّداً u.emial). النوعُ كاملٌ، والمترجمُ يحرسه — مع أن الأنواع تُمحى. اكتب بجملتين: من أين أتى هذا النوع، ولماذا لا يناقض الـ erasure؟ (إن لم تجب بثقة، ارجع لقسم "generate" أعلاه — هذه الجملةُ هي ختمُ دائرةٍ فكريّةٍ بدأت في الإقليم ١.)
اللغز ج — أعِد ضمّاتِ الإقليم ٤ بـ Prisma، وتحقّق بالـ SQL. حقّق بـ Prisma نفسَ أسئلة الإقليم ٤ (سبرنتات منهج عبر include؛ مناهجُ مستخدمٍ عبر جدول الوصل؛ عددُ مهامِّ مشروع عبر تجميع Prisma)، ولكلٍّ قارن الـ SQL المطبوعَ بالاستعلام الذي كتبتَه بيدك في الإقليم ٤. أين طابق؟ أين أضاف Prisma شيئاً؟
اللغز د — معاملةٌ ذرّيّة (بروفةُ التصحيح، الجولة الثانية). بـ $transaction، نفّذ كتابتين (نتيجةُ تصحيحٍ + تحديثُ تقدّم) ذرّياً. ثم اجبر خطأً في الثانية وتأكّد أن الأولى تراجعت — تماماً كما أثبتّ بـ SQL خام، الآن من Node. هذا اللبنةُ التي يقف عليها الإقليم ١٠.
اللغز هـ — اصنع N+1 ثم اقتله. اكتب الحلقةَ ذاتَ الـ 1+N أعلاه ولاحظ في سجلّ الـ SQL عدد الاستعلامات تنفجر مع البيانات. ثم استبدلها باستعلامٍ واحدٍ بـ include ولاحظ السجلَّ يهبط لاستعلامٍ واحد. هذا هو لماذا تعلّمتَ SQL قبل الـ ORM: لتراها وتصلحها.
لا تنتقل قبل أن تجيب لغز (ب) بثقة (مصدرُ النوع) وترى لغز (هـ) بعينك (N+1 في السجلّ). الأول يختم فهمَك النظريّ، والثاني يحصّنك من أكبر فخٍّ عمليٍّ في الـ ORM.
الخلاصة — أين تتّصل هذه العقدة بالشجرة
- تحت: Prisma طبقةٌ فوق SQL الذي تتقنه (٤)، تحلّ الحقنَ (معامِلات) وعدمَ التطابق (تحويلُ صفوفٍ↔كائنات) وانجرافَ السكيمة (migrations في git). و
includeهو JOIN، و$transactionهو ACID، وselectهو الإسقاط. - العقدة الجديدة: جسرٌ مكتوبُ-النوع بين الكائنات والعلاقات — schema-first، بخطّين: migrate (يطوّر البنية)، generate (يعيد الأنواعَ المُحاةَ عبر توليدِ كودٍ من السكيمة). فهمُ N+1 يبقيك سيّداً على ما يولّده.
- فوق: Prisma هي قلبُ طبقة الخدمة التي يستدعيها المتحكّم في التدفّق الكامل (٩)؛ ومعاملتُه الذرّيّةُ هي كيف يثبّت التصحيحُ نتيجتَه (١٠). وأنواعُه المولَّدة هي نصفُ جسر الـ erasure — النصفُ الآخر قادمٌ الآن.
الأبواب المفتوحة: Prisma يعطيك نوعاً لِما يأتي من القاعدة. لكن ما يأتي من الشبكة (req.body) ما زال any لا يُوثَق، والمترجمُ أعمى عنه (الـ erasure من الجهة الأخرى). من يحرس هذا الحدّ وقتَ التشغيل، ويعيد له النوعَ أيضاً؟ (٦).
الوصلة للواقع (نمط SelfLab)
في user.controller.ts يصير كلُّ سطرٍ شفّافاً:
tsimport { prisma } from "../../database/prisma"; const newUser = await prisma.user.create({ data: { firstName, lastName, username, email, password: await bcrypt.hash(password, 10), role }, select: { id: true, firstName: true, username: true, email: true, role: true, createdAt: true }, }); return res.status(201).json(newUser);
prisma.user.create = INSERT ... RETURNING؛ وselect = إسقاطٌ يحذف password عمداً من الردّ (لا تُسرَّب التجزئةُ أبداً — درسٌ أمنيّ)؛ وnewUser مكتوبُ النوع من العميل المولَّد؛ وawait لا يحجب خيطَ الحلقة (٢). وفي prisma.ts: new PrismaClient() يقرأ DATABASE_URL من process.env (٢) عبر prisma.config.ts. كلُّ القطع التقت.
ولاحظ ما تملكه الآن: نقلُ منطق Prisma هذا من المتحكّم إلى user.service.ts (فصلُ الطبقات، ٣)، وبناءُ بقيّة النماذج (curriculum → ... → testCase، enrollment، taskProgress) في السكيمة كما صمّمتها.
البذرة التالية: Prisma حرس حدَّ القاعدة وأعاد نوعَه. لكن createUser يبدأ بـ createUserSchema.safeParse(req.body) — لأن req.body يأتي من الشبكة: any، مجهولٌ، كاذبٌ محتمل (كلمةُ مرورٍ ناقصة، role: "SUPERADMIN"، حقولٌ زائدة). المترجمُ أعمى عنه، وPrisma بعيدٌ عنه. من يحرس هذا الحدّ وقت التشغيل؟ ننزل للإقليم ٦ نبني الحارسَ بأيدينا، نفهم Zod، ونكمل الصورةَ الكبرى للـ erasure: توليدُ النوع من سكيمة التحقّق، الوجهُ المقابل لِما فعله Prisma.