Kodax

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());
اختبر فهمك