PostgreSQL من Node.js — SQL الأساسيات
PostgreSQL من Node.js
درست SQL على SQLite. التطبيقات الحقيقية تستخدم غالباً PostgreSQL: قاعدة خادم تتحمل آلاف الاتصالات المتزامنة، بأنواع وميزات أغنى (وهي ما يقوم عليه Supabase). هذا الدرس يجمع الطرفين: ما يختلف في PostgreSQL، وكيف يتحدث معه كود Node بشكل صحيح.
ميزات PostgreSQL اليومية
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- الترقيم التلقائي الحديث
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
settings JSONB NOT NULL DEFAULT '{}', -- بيانات مرنة قابلة للفهرسة
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- وقت بالمنطقة الزمنية
);
-- RETURNING: أعد الصف المُدرج (والمعرّف الجديد) في نفس العبارة
INSERT INTO users (email, name) VALUES ('sara@example.com', 'سارة') RETURNING id, created_at;
-- ON CONFLICT: إدراج أو تحديث (upsert) دون سباق بين "هل يوجد؟" و "أدرج"
INSERT INTO users (email, name) VALUES ('sara@example.com', 'سارة أحمد')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id, name;
-- ILIKE: بحث غير حساس لحالة الأحرف، و JSONB
UPDATE users SET settings = settings || '{"theme": "dark"}' WHERE email ILIKE 'SARA@%';
SELECT name, settings->>'theme' AS theme FROM users;| الميزة | لماذا تهمك |
|---|---|
| GENERATED ALWAYS AS IDENTITY | بديل SERIAL القديم، أوضح وأأمن |
| TIMESTAMPTZ | يخزن لحظة زمنية حقيقية؛ TIMESTAMP بدونها مصدر أخطاء مناطق زمنية |
| RETURNING | لا حاجة لاستعلام ثانٍ لمعرفة المعرّف الجديد |
| ON CONFLICT | upsert ذري |
| JSONB | حقول مرنة مع فهارس GIN — لا تجعلها بديلاً عن الأعمدة العادية للبيانات المنظمة |
الاتصال من Node: حزمة pg
// npm install pg
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL, // postgres://user:pass@host:5432/db — من متغيرات البيئة
max: 10, // أقصى عدد اتصالات مفتوحة
idleTimeoutMillis: 30_000,
});لماذا مجمّع (Pool)؟
فتح اتصال بقاعدة البيانات مكلف (مصافحة شبكة ومصادقة). المجمّع يحتفظ باتصالات جاهزة ويعيرها للطلبات. أنشئ مجمّعاً واحداً للتطبيق كله، لا اتصالاً لكل طلب. و PostgreSQL له حد أقصى للاتصالات (100 افتراضياً) — عشر نسخ من تطبيقك بمجمّع 20 لكل منها تتجاوزه. في البيئات بلا خوادم (Serverless) استخدم مُجمِّعاً خارجياً (PgBouncer أو Supavisor في Supabase).
الاستعلامات: معاملات دائماً
// const { rows } = await pool.query("SELECT id, name FROM users WHERE email = $1", [email]);
// const { rows: [user] } = await pool.query(
// "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, name, created_at", [email, name]);
const placeholders = ["$1", "$2"]; // PostgreSQL يستخدم $1 و $2 (لا ? كما في SQLite و MySQL)
console.log(placeholders);- rows مصفوفة كائنات بأسماء الأعمدة.
- BIGINT و NUMERIC تصل نصوصاً افتراضياً (لتجنب فقدان الدقة في أرقام JavaScript). حوّلها بوعي، أو اضبط محلل الأنواع.
المعاملات: اتصال واحد لكل المعاملة
pool.query قد يستخدم اتصالاً مختلفاً لكل استدعاء — و BEGIN على اتصال ثم COMMIT على آخر لا معنى له. للمعاملة احجز اتصالاً واحداً (pool.connect()) وحرره في finally دائماً — المثال الكامل يغلّف ذلك في دالة withTransaction.
رموز الأخطاء
أخطاء PostgreSQL تحمل code قياسياً — حوّلها لاستجابات مفهومة بدل 500:
| الرمز | المعنى | الاستجابة المناسبة |
|---|---|---|
| 23505 | unique_violation (بريد مكرر) | 409 Conflict |
| 23503 | foreign_key_violation | 400 أو 409 |
| 23514 | check_violation | 400 |
| 40001 | serialization_failure | أعد المعاملة |
ORMs و Query Builders
Prisma و Drizzle و Knex و Kysely تولّد SQL وتعطيك أنواع TypeScript. مفيدة جداً — لكن افهم SQL الذي تولّده، وراقب مشكلة N+1، واستخدم نسخها المعاملية لأي SQL خام.
تمرين
اكتب مستودع products في Node: create (يعيد الصف بـ RETURNING)، upsertBySku (ON CONFLICT)، و decreaseStock(id, qty) ذري يرمي "نفد المخزون" إن لم يُعدَّل صف. حوّل خطأ 23505 إلى رسالة "SKU مستخدم".
// db.js — طبقة الوصول لـ PostgreSQL (npm install pg)
import pg from "pg";
export const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
});
// معاملة على اتصال واحد، مع تحرير الاتصال دائماً
export async function withTransaction(fn) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
export class ConflictError extends Error {
constructor(message) {
super(message);
this.status = 409;
}
}
// usersRepository.js
export async function createUser({ email, name }) {
try {
const { rows } = await pool.query(
"INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id, email, name, created_at",
[email.toLowerCase(), name]
);
return rows[0];
} catch (err) {
if (err.code === "23505") throw new ConflictError("البريد مستخدم مسبقاً");
throw err;
}
}
export async function findUserByEmail(email) {
const { rows } = await pool.query("SELECT id, email, name FROM users WHERE email = $1", [email.toLowerCase()]);
return rows[0] ?? null;
}
// تحويل رصيد بين محفظتين: كل شيء أو لا شيء
export async function transferCredits(fromId, toId, amount) {
return withTransaction(async (client) => {
const debit = await client.query(
"UPDATE wallets SET balance = balance - $1 WHERE user_id = $2 AND balance >= $1 RETURNING balance",
[amount, fromId]
);
if (debit.rowCount === 0) throw new Error("رصيد غير كافٍ");
await client.query("UPDATE wallets SET balance = balance + $1 WHERE user_id = $2", [amount, toId]);
await client.query("INSERT INTO transfers (from_user, to_user, amount) VALUES ($1, $2, $3)", [fromId, toId, amount]);
return { remaining: Number(debit.rows[0].balance) }; // NUMERIC يصل نصاً
});
}
// عند إيقاف الخادم: أغلق المجمّع
process.on("SIGTERM", () => pool.end());اختبر فهمك