/ دورة PHP الشاملة
0/17 مكتملة
درس 15 من 17

قاعدة البيانات — تحسين وأمان

Indexes · SQL Injection · Normalization · Query Optimization · Migrations

🕐 70 دقيقة ⚡ تحسين الأداء 🔒 الأمان 📝 6 أسئلة

SQL Injection — أخطر ثغرة في قواعد البيانات

SQL Injection هجوم يُدخل فيه المهاجم كود SQL عبر مدخلات المستخدم لتنفيذ استعلامات ضارة.
PHP — SQL Injection ❌ vs Prepared Statements ✅
<?php
// ─── ❌ كود خطير — عُرضة لـ SQL Injection ─────────────────
$username = $_POST['username']; // يمكن أن يكون: ' OR '1'='1
$password = $_POST['password'];

// إذا أدخل المهاجم:  username = ' OR '1'='1' --
// الاستعلام يصبح:
// SELECT * FROM users WHERE username = '' OR '1'='1' -- AND password = 'x'
// ويُعيد كل المستخدمين!
$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = $pdo->query($sql); // خطر!

// ─── ✅ الطريقة الصحيحة — Prepared Statements ─────────────
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ? AND password = ?");
$stmt->execute([$username, $password]);
$user = $stmt->fetch();
// مهما كان المدخل، سيُعامَل كنص عادي وليس كود SQL

// ─── ✅ Named Parameters (أوضح) ────────────────────────────
$stmt = $pdo->prepare("
    SELECT id, username, email
    FROM users
    WHERE email = :email
    AND is_active = :active
    LIMIT 1
");
$stmt->execute([
    ':email'  => filter_var($email, FILTER_SANITIZE_EMAIL),
    ':active' => 1,
]);
$user = $stmt->fetch();

// ─── ✅ Dynamic ORDER BY آمن (لا يمكن parameterize) ────────
$allowed_cols  = ['name', 'price', 'created_at', 'stock'];
$allowed_dirs  = ['ASC', 'DESC'];
$sort_col = in_array($_GET['sort'] ?? '', $allowed_cols) ? $_GET['sort'] : 'created_at';
$sort_dir = in_array(strtoupper($_GET['dir'] ?? ''), $allowed_dirs) ? strtoupper($_GET['dir']) : 'DESC';

// الآن آمن للاستخدام في الاستعلام
$sql = "SELECT * FROM products ORDER BY $sort_col $sort_dir";
?>
⚠️ القاعدة الذهبية أي قيمة تأتي من المستخدم (GET, POST, COOKIE, حتى DB) لا تُضمّنها في SQL مباشرةً. استخدم Prepared Statements دائماً.

Indexes — تسريع الاستعلامات بشكل هائل

الـ Index هو هيكل بيانات يجعل البحث في الجدول أسرع. بدون Index، MySQL تفحص كل صف (Full Table Scan). مع Index، تقفز مباشرةً للصف المطلوب.
SQL — Indexes المتقدمة
-- ─── أنواع الـ Indexes ───────────────────────────────────────

-- 1. PRIMARY KEY — معرّف فريد (ينشأ تلقائياً)
ALTER TABLE users ADD PRIMARY KEY (id);

-- 2. UNIQUE INDEX — لا تكرار (مثل البريد الإلكتروني)
CREATE UNIQUE INDEX idx_email ON users(email);
CREATE UNIQUE INDEX idx_username ON users(username);

-- 3. Regular INDEX — تسريع البحث والفلترة
CREATE INDEX idx_category ON products(category);
CREATE INDEX idx_status   ON orders(status);
CREATE INDEX idx_role     ON users(role);

-- 4. Composite INDEX — لعدة أعمدة معاً (ترتيب مهم!)
-- مثالي للاستعلامات: WHERE status='active' AND role='admin'
CREATE INDEX idx_status_role ON users(is_active, role);

-- 5. FULLTEXT INDEX — للبحث النصي الكامل
CREATE FULLTEXT INDEX ft_products ON products(name, description);

-- البحث بـ FULLTEXT
SELECT * FROM products
WHERE MATCH(name, description) AGAINST('PHP برمجة' IN BOOLEAN MODE);

-- ─── EXPLAIN — لفهم كيف MySQL تنفذ الاستعلام ────────────────
EXPLAIN SELECT * FROM users WHERE email = 'ahmed@example.com';
-- type=ref (يستخدم index) = سريع ✅
-- type=ALL (full scan)   = بطيء ❌

-- ─── متى تضيف Index؟ ────────────────────────────────────────
-- ✅ أعمدة WHERE المتكررة
-- ✅ أعمدة ORDER BY
-- ✅ أعمدة JOIN ... ON
-- ✅ أعمدة UNIQUE (email, username)
-- ❌ أعمدة نادراً ما تُفلتر (تُبطئ INSERT/UPDATE)
-- ❌ جداول صغيرة جداً (< 1000 صف) — لا فرق ملحوظ

-- ─── فحص الـ Indexes الموجودة ────────────────────────────────
SHOW INDEX FROM users;
SHOW INDEX FROM products;

-- حذف Index غير ضروري
DROP INDEX idx_old ON users;

Database Normalization — تنظيم قاعدة البيانات

الـ Normalization هو مبدأ تصميم قواعد البيانات لتقليل التكرار وضمان سلامة البيانات.

المستوىالقاعدةالمشكلة التي يحلها
1NFكل عمود يحتوي قيمة واحدة فقط، لا مصفوفاتتخزين "كتب,برمجة" في خلية واحدة
2NFكل عمود يعتمد على المفتاح الأساسي كاملاًتكرار بيانات المستخدم في كل طلب
3NFلا عمود يعتمد على عمود غير المفتاحتخزين اسم الفئة مع ID الفئة
BCNFنسخة أقوى من 3NFحالات خاصة نادرة
SQL — مثال Normalization
-- ─── ❌ تصميم سيء — تكرار وبيانات مختلطة ─────────────────
CREATE TABLE orders_bad (
  id           INT PRIMARY KEY,
  user_name    VARCHAR(100),  -- تكرار! لو غيّر المستخدم اسمه؟
  user_email   VARCHAR(100),  -- تكرار!
  product_name VARCHAR(200),  -- تكرار!
  product_price DECIMAL(10,2), -- تكرار!
  quantity     INT,
  categories   VARCHAR(500)   -- '1,2,3' في خلية واحدة = ❌ 1NF
);

-- ─── ✅ تصميم صحيح — Normalized ────────────────────────────
-- جدول المستخدمين (بيانات واحدة لكل مستخدم)
CREATE TABLE users (
  id       INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(50)  NOT NULL UNIQUE,
  email    VARCHAR(100) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- جدول الفئات (مستقل)
CREATE TABLE categories (
  id   INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(100) NOT NULL UNIQUE,
  slug VARCHAR(100) NOT NULL UNIQUE
);

-- جدول المنتجات (Foreign Key للفئة)
CREATE TABLE products (
  id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  category_id INT UNSIGNED NOT NULL,
  name        VARCHAR(200) NOT NULL,
  price       DECIMAL(10,2) NOT NULL,
  stock       INT UNSIGNED DEFAULT 0,
  FOREIGN KEY (category_id) REFERENCES categories(id)
);

-- جدول الطلبات (Foreign Key للمستخدم)
CREATE TABLE orders (
  id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id    INT UNSIGNED NOT NULL,
  status     ENUM('pending','paid','shipped','delivered') DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

-- جدول تفاصيل الطلب (Many-to-Many بين orders وproducts)
CREATE TABLE order_items (
  id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id   INT UNSIGNED NOT NULL,
  product_id INT UNSIGNED NOT NULL,
  quantity   INT UNSIGNED NOT NULL DEFAULT 1,
  unit_price DECIMAL(10,2) NOT NULL,  -- السعر وقت الشراء
  FOREIGN KEY (order_id)   REFERENCES orders(id)   ON DELETE CASCADE,
  FOREIGN KEY (product_id) REFERENCES products(id)
);

-- ─── الاستعلام الكامل مع JOIN ─────────────────────────────
SELECT
    o.id           AS order_id,
    u.username,
    u.email,
    p.name         AS product_name,
    c.name         AS category,
    oi.quantity,
    oi.unit_price,
    (oi.quantity * oi.unit_price) AS subtotal
FROM orders o
JOIN users       u  ON o.user_id    = u.id
JOIN order_items oi ON o.id         = oi.order_id
JOIN products    p  ON oi.product_id = p.id
JOIN categories  c  ON p.category_id = c.id
WHERE o.status = 'paid'
ORDER BY o.created_at DESC;

Query Optimization — كتابة استعلامات فعّالة

SQL + PHP — تحسين الأداء
-- ─── ❌ أخطاء شائعة تُبطئ الاستعلامات ─────────────────────

-- 1. SELECT * بدل الأعمدة المطلوبة
SELECT * FROM users;               -- ❌
SELECT id, username, email FROM users; -- ✅

-- 2. استخدام دالة على عمود مفهرس (يُعطّل الـ Index)
SELECT * FROM users WHERE YEAR(created_at) = 2024; -- ❌
SELECT * FROM users
  WHERE created_at >= '2024-01-01'
    AND created_at <  '2025-01-01'; -- ✅

-- 3. LIKE يبدأ بـ % (يُعطّل الـ Index)
SELECT * FROM products WHERE name LIKE '%كتاب%'; -- ❌ Full Scan
SELECT * FROM products WHERE name LIKE 'كتاب%';  -- ✅ يستخدم Index

-- 4. N+1 Problem — استعلام لكل صف!
-- ❌ المشكلة: استعلام للطلبات ثم استعلام لكل مستخدم
$orders = $pdo->query("SELECT * FROM orders")->fetchAll();
foreach ($orders as $order) {
    $user = $pdo->query("SELECT * FROM users WHERE id = " . $order['user_id'])->fetch(); // N استعلام!
    echo $user['username'];
}

-- ✅ الحل: JOIN واحد
$orders = $pdo->query("
    SELECT o.*, u.username, u.email
    FROM orders o
    JOIN users u ON o.user_id = u.id
")->fetchAll();
foreach ($orders as $order) {
    echo $order['username']; // لا استعلامات إضافية!
}

-- ─── Caching — تخزين نتائج الاستعلامات الثقيلة ──────────
<?php
function getCachedProducts(PDO $pdo): array {
    $cache_file = sys_get_temp_dir() . '/products_cache.json';
    $cache_ttl  = 300; // 5 دقائق

    // هل الـ Cache موجود وحديث؟
    if (file_exists($cache_file) && (time() - filemtime($cache_file)) < $cache_ttl) {
        return json_decode(file_get_contents($cache_file), true);
    }

    // تنفيذ الاستعلام الثقيل
    $products = $pdo->query("
        SELECT p.*, c.name as category_name, COUNT(oi.id) as orders_count
        FROM products p
        LEFT JOIN categories c ON p.category_id = c.id
        LEFT JOIN order_items oi ON p.id = oi.product_id
        GROUP BY p.id
        ORDER BY orders_count DESC
        LIMIT 50
    ")->fetchAll();

    // حفظ في الـ Cache
    file_put_contents($cache_file, json_encode($products));

    return $products;
}
?>

Database Migrations — إدارة تغييرات الـ Schema

الـ Migrations هي ملفات PHP تُطبّق تغييرات على قاعدة البيانات بشكل منظم وقابل للتتبع والتراجع.
PHP — نظام Migrations بسيط
<?php
// ─── migrations/001_create_users_table.php ────────────────
return [
    'up' => "
        CREATE TABLE IF NOT EXISTS users (
          id         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
          username   VARCHAR(50)  NOT NULL UNIQUE,
          email      VARCHAR(100) NOT NULL UNIQUE,
          password   VARCHAR(255) NOT NULL,
          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    ",
    'down' => "DROP TABLE IF EXISTS users",
];

// ─── migrations/002_add_role_to_users.php ─────────────────
return [
    'up'   => "ALTER TABLE users ADD COLUMN role ENUM('user','admin') DEFAULT 'user' AFTER password",
    'down' => "ALTER TABLE users DROP COLUMN role",
];

// ─── migrations/003_create_products_table.php ─────────────
return [
    'up' => "
        CREATE TABLE IF NOT EXISTS products (
          id          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
          name        VARCHAR(200) NOT NULL,
          price       DECIMAL(10,2) NOT NULL DEFAULT 0,
          stock       INT UNSIGNED DEFAULT 0,
          category_id INT UNSIGNED NULL,
          created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
          INDEX idx_category (category_id)
        ) ENGINE=InnoDB
    ",
    'down' => "DROP TABLE IF EXISTS products",
];

// ─── migrate.php — تشغيل الـ Migrations ──────────────────
require_once 'db.php';

// جدول لتتبع الـ Migrations المُطبّقة
$pdo->exec("
    CREATE TABLE IF NOT EXISTS migrations (
        id         INT AUTO_INCREMENT PRIMARY KEY,
        filename   VARCHAR(255) NOT NULL UNIQUE,
        applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )
");

$applied = $pdo->query("SELECT filename FROM migrations")->fetchAll(PDO::FETCH_COLUMN);
$files   = glob('migrations/*.php');
sort($files);

foreach ($files as $file) {
    $name = basename($file);

    if (in_array($name, $applied)) {
        echo "⏩ تجاوز: $name (مُطبَّق مسبقاً)\n";
        continue;
    }

    $migration = require $file;
    try {
        $pdo->beginTransaction();
        $pdo->exec($migration['up']);
        $pdo->prepare("INSERT INTO migrations (filename) VALUES (?)")->execute([$name]);
        $pdo->commit();
        echo "✅ تطبيق: $name\n";
    } catch (Exception $e) {
        $pdo->rollBack();
        echo "❌ فشل: $name — " . $e->getMessage() . "\n";
        break;
    }
}
echo "اكتملت الـ Migrations!\n";
?>
ناتج migrate.php
✅ تطبيق: 001_create_users_table.php ✅ تطبيق: 002_add_role_to_users.php ✅ تطبيق: 003_create_products_table.php اكتملت الـ Migrations!

Database Backup وـ Restore

PHP + Shell — Backup تلقائي
<?php
// ─── backup.php — نسخ احتياطي لـ MySQL ───────────────────
define('DB_HOST', 'localhost');
define('DB_NAME', 'php_course_db');
define('DB_USER', 'root');
define('DB_PASS', '');
define('MYSQLDUMP_PATH', 'C:\\xampp\\mysql\\bin\\mysqldump.exe'); // Windows
// define('MYSQLDUMP_PATH', '/usr/bin/mysqldump'); // Linux/Mac

$backup_dir  = __DIR__ . '/backups/';
$backup_file = $backup_dir . DB_NAME . '_' . date('Y-m-d_H-i-s') . '.sql';

// إنشاء مجلد الـ Backup
if (!is_dir($backup_dir)) mkdir($backup_dir, 0755, true);

// أمر الـ dump
$command = sprintf(
    '"%s" --host=%s --user=%s --password=%s %s > "%s" 2>&1',
    MYSQLDUMP_PATH,
    DB_HOST,
    DB_USER,
    DB_PASS,
    DB_NAME,
    $backup_file
);

exec($command, $output, $return_code);

if ($return_code === 0) {
    $size = round(filesize($backup_file) / 1024, 2);
    echo "✅ تم الـ Backup بنجاح!\n";
    echo "الملف: $backup_file\n";
    echo "الحجم: {$size} KB\n";

    // حذف النسخ القديمة (أكثر من 7 أيام)
    foreach (glob($backup_dir . '*.sql') as $old_file) {
        if (time() - filemtime($old_file) > 7 * 24 * 3600) {
            unlink($old_file);
            echo "🗑️ حُذف: " . basename($old_file) . "\n";
        }
    }
} else {
    echo "❌ فشل الـ Backup!\n";
    echo implode("\n", $output);
}

// ─── Restore من ملف SQL ───────────────────────────────────
function restoreBackup(string $sql_file): bool {
    if (!file_exists($sql_file)) return false;

    $command = sprintf(
        '"%s" --host=%s --user=%s --password=%s %s < "%s" 2>&1',
        'C:\\xampp\\mysql\\bin\\mysql.exe',
        DB_HOST, DB_USER, DB_PASS, DB_NAME,
        $sql_file
    );

    exec($command, $output, $code);
    return $code === 0;
}
?>

ملخص — أفضل ممارسات قاعدة البيانات

المجالالممارسة الموصى بها
الأمانPrepared Statements دائماً، لا تُضمّن قيم المستخدم في SQL مباشرةً
كلمات المرورpassword_hash() مع PASSWORD_BCRYPT، لا MD5 ولا SHA1
Indexesأضف Index على كل عمود في WHERE وJOIN وORDER BY
أنواع البياناتDECIMAL للمال، TINYINT للـ boolean، VARCHAR للنصوص القصيرة
Encodingutf8mb4 دائماً لدعم العربية والـ Emojis
Foreign Keysاستخدمها دائماً لضمان سلامة البيانات
Transactionsلأي عمليات مترابطة (يجب أن تنجح معاً أو تفشل معاً)
Backupنسخ احتياطية يومية تلقائية مع الاحتفاظ بـ 7 أيام
Namingsnake_case للجداول والأعمدة، جمع للجداول (users لا user)
Normalization3NF كحد أدنى، تجنب التكرار

🧪 اختبر فهمك — تحسين قاعدة البيانات

6 أسئلة
سؤال 1
ما الأمر الذي يُظهر كيف MySQL تنفّذ استعلاماً وهل يستخدم Index؟
سؤال 2
لماذا يُفضَّل LIKE 'كتاب%' على LIKE '%كتاب%' في MySQL؟
سؤال 3
ما هي مشكلة N+1 في قواعد البيانات؟
سؤال 4
ما هدف الـ Database Normalization؟
سؤال 5
ما نوع الـ Index المناسب للبحث في محتوى مقالات نصية طويلة؟
سؤال 6
ما فائدة الـ Database Migrations؟