الإضافة والتعديل والحذف بأمان
افصل الأمر عن البيانات، وتصبح قاعدة بياناتك في أمان. ⏱ نحو 40 دقيقة.
- تشرح حقن
SQLوتكتشفه في الكود. - تكتب استعلامات مُعدّة بأماكن مرقّمة ومسمّاة.
- تنفّذ الإضافة والتعديل والحذف، وتستخدم
lastInsertIdوrowCount. - تجمع عدة أوامر في معاملة بـ
beginTransactionوcommitوrollBack.
١أخطر سطر في الواجهة الخلفية
صفحة بحث تأخذ كلمة من المستخدم وتضعها داخل الاستعلام بدمج النصوص. تبدو بريئة:
<?phprequire __DIR__ . '/db.php';$q = $_GET['q'] ?? ''; // ✗ خطر: نص المستخدم يصير جزءًا من أمر SQL$sql = "SELECT name, phone FROM customers WHERE name = '$q'";echo "<p dir='ltr'><small>$sql</small></p>"; foreach ($pdo->query($sql) as $r) { echo "<p>{$r['name']} — {$r['phone']}</p>";}
<?php// db.php: الاتصال بقاعدة البيانات في مكان واحد// في المختبر نستخدم SQLite (ملف). وعلى XAMPP بدّل السطر بسطر MySQL الذي تحته$pdo = new PDO('sqlite:' . __DIR__ . '/store.sqlite');// $pdo = new PDO('mysql:host=localhost;dbname=store;charset=utf8mb4', 'root', ''); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // الأخطاء استثناءات$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // الصفوف مصفوفات ترابطية
الطلب الأول بحث عادي. والثاني كتب فيه المهاجم: x' OR '1'='1 . علامة التنصيص أغلقت النص، ثم صار الشرط «أو 1 يساوي 1» صحيحًا دائمًا، فعُرضت أرقام جوالات كل العملاء!
هذا حقن SQL SQL Injection، من أخطر الثغرات وأكثرها انتشارًا لسنوات طويلة. وبالأسلوب نفسه قد يحذف المهاجم جداول، أو يدخل بلا كلمة مرور. والسبب واحد: خلطنا الأمر بالبيانات.
٢الحل: الاستعلام المُعدّ
الاستعلام المُعدّ Prepared statement يفصل الأمر عن البيانات: نرسل الأمر أولًا وفيه أماكن فارغة ?، ثم نرسل القيم منفصلة. قاعدة البيانات تعامل القيم بيانات فقط مهما كان فيها:
<?phprequire __DIR__ . '/db.php';$q = $_GET['q'] ?? ''; // ✓ آمن: الأمر ثابت، والقيمة تُرسل منفصلة$stmt = $pdo->prepare('SELECT name, phone FROM customers WHERE name = ?');$stmt->execute([$q]);$rows = $stmt->fetchAll(); echo "<p>النتائج: " . count($rows) . "</p>";foreach ($rows as $r) { echo "<p>{$r['name']} — {$r['phone']}</p>";}
<?php// db.php: الاتصال بقاعدة البيانات في مكان واحد// في المختبر نستخدم SQLite (ملف). وعلى XAMPP بدّل السطر بسطر MySQL الذي تحته$pdo = new PDO('sqlite:' . __DIR__ . '/store.sqlite');// $pdo = new PDO('mysql:host=localhost;dbname=store;charset=utf8mb4', 'root', ''); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // الأخطاء استثناءات$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // الصفوف مصفوفات ترابطية
الهجوم نفسه الآن مجرد اسم غريب لا يوجد عميل به: صفر نتائج.
| الشكل | مثال |
|---|---|
أماكن مرقّمة ? | $s = $pdo->prepare('SELECT * FROM products WHERE price < ? AND stock > ?');$s->execute([50, 0]); |
أماكن مسمّاة :name | $s = $pdo->prepare('SELECT * FROM products WHERE category = :cat');$s->execute(['cat' => $cat]); |
٣الإضافة: INSERT
<?phprequire __DIR__ . '/db.php'; $name = trim($_POST['name'] ?? '');$price = filter_var($_POST['price'] ?? '', FILTER_VALIDATE_FLOAT);$cat = trim($_POST['category'] ?? ''); if ($name === '' || $cat === '' || $price === false || $price <= 0) { http_response_code(422); exit('بيانات المنتج غير صحيحة');} $stmt = $pdo->prepare('INSERT INTO products (name, category, price, stock) VALUES (?, ?, ?, 0)');$stmt->execute([$name, $cat, $price]);$id = $pdo->lastInsertId(); // رقم الصف الجديد http_response_code(201);echo "أُضيف المنتج رقم $id: $name";
<?php// db.php: الاتصال بقاعدة البيانات في مكان واحد// في المختبر نستخدم SQLite (ملف). وعلى XAMPP بدّل السطر بسطر MySQL الذي تحته$pdo = new PDO('sqlite:' . __DIR__ . '/store.sqlite');// $pdo = new PDO('mysql:host=localhost;dbname=store;charset=utf8mb4', 'root', ''); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // الأخطاء استثناءات$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // الصفوف مصفوفات ترابطية
٤التعديل والحذف: UPDATE و DELETE
و rowCount() تخبرك كم صفًّا تأثّر، فتعرف إن كان العنصر موجودًا أصلًا:
<?phprequire __DIR__ . '/db.php';$id = (int) ($_POST['id'] ?? 0); if (($_POST['action'] ?? '') === 'restock') { $stmt = $pdo->prepare('UPDATE products SET stock = stock + :qty WHERE id = :id'); $stmt->execute(['qty' => (int) ($_POST['qty'] ?? 0), 'id' => $id]);} else { $stmt = $pdo->prepare('DELETE FROM products WHERE id = ? AND stock = 0'); $stmt->execute([$id]);} if ($stmt->rowCount() === 0) { http_response_code(404); exit('لم يتأثر أي منتج');}$p = $pdo->prepare('SELECT name, stock FROM products WHERE id = ?');$p->execute([$id]);$row = $p->fetch();echo $row ? "{$row['name']}: المخزون {$row['stock']}" : "حُذف المنتج $id";
<?php// db.php: الاتصال بقاعدة البيانات في مكان واحد// في المختبر نستخدم SQLite (ملف). وعلى XAMPP بدّل السطر بسطر MySQL الذي تحته$pdo = new PDO('sqlite:' . __DIR__ . '/store.sqlite');// $pdo = new PDO('mysql:host=localhost;dbname=store;charset=utf8mb4', 'root', ''); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // الأخطاء استثناءات$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // الصفوف مصفوفات ترابطية
الحذف مشروط بأن يكون المخزون صفرًا (قاعدة عمل داخل WHERE)، فالمنتج 1 لم يُحذف لأن مخزونه صار 10+.
٥المعاملات: كل شيء أو لا شيء
تسجيل طلب شراء يحتاج عدة أوامر: إنشاء الطلب، وإضافة عناصره، وإنقاص المخزون. لو فشل الأمر الثالث بعد نجاح الأولين، يبقى طلب بلا خصم مخزون! الحل المعاملة Transaction التي عرفتها في 121، لكن من PHP:
<?phprequire __DIR__ . '/db.php';$pid = (int) ($_POST['product'] ?? 0);$qty = (int) ($_POST['qty'] ?? 1); try { $pdo->beginTransaction(); $s = $pdo->prepare('UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?'); $s->execute([$qty, $pid, $qty]); if ($s->rowCount() === 0) { throw new RuntimeException('الكمية غير متوفرة'); } $pdo->prepare("INSERT INTO orders (customer_id, order_date) VALUES (1, date('now'))")->execute(); $orderId = $pdo->lastInsertId(); $pdo->prepare('INSERT INTO order_items (order_id, product_id, qty, price) SELECT ?, id, ?, price FROM products WHERE id = ?') ->execute([$orderId, $qty, $pid]); $pdo->commit(); // ثبّت كل شيء معًا http_response_code(201); echo "تم الطلب رقم $orderId";} catch (Throwable $e) { $pdo->rollBack(); // تراجع عن كل شيء http_response_code(409); echo 'لم يتم الطلب: ' . $e->getMessage();}
<?php// db.php: الاتصال بقاعدة البيانات في مكان واحد// في المختبر نستخدم SQLite (ملف). وعلى XAMPP بدّل السطر بسطر MySQL الذي تحته$pdo = new PDO('sqlite:' . __DIR__ . '/store.sqlite');// $pdo = new PDO('mysql:host=localhost;dbname=store;charset=utf8mb4', 'root', ''); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // الأخطاء استثناءات$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // الصفوف مصفوفات ترابطية
الشرط stock >= ? داخل UPDATE يجعل الفحص والخصم أمرًا واحدًا، فيمنع حالة السباق من الدرس 15. وإن لم يتأثر صف رمينا استثناءً فتراجعت المعاملة كلها.
توقّع وتحقّق
سبب حقن SQL:
الحماية من حقن SQL:
رقم الصف الذي أُضيف للتو:
INSERT مباشرة.في المعاملة، إن فشل أمر من الأوامر:
الخلاصة
- حقن
SQL: بيانات المستخدم تصير جزءًا من الأمر. السبب خلط الأمر بالبيانات. - الحل الوحيد الصحيح:
prepareبأماكن ? أو :nameثمexecuteبالقيم. lastInsertIdبعدINSERT، وrowCountبعدUPDATE/DELETEلمعرفة الأثر.- المعاملة:
beginTransaction← الأوامر ←commit، وrollBackعند أي فشل. - ضع الشرط داخل
UPDATE(stock>=?) ليكون الفحص والتعديل خطوة واحدة.
تحدٍّ برمجي: تحديث السعر بأمان
متقدمالملف price.php فيه ثغرة حقن SQL، ولا يتحقق من شيء. أصلحه.
- يستقبل POST فيه
idوprice. - السعر عدد موجب، وإلا 422 والنص
سعر غير صحيح. - التحديث باستعلام مُعدّ. إن لم يتأثر صف: 404 والنص
المنتج غير موجود. - عند النجاح: 200 والنص
تم: قهوة عربية بسعر 40(اسم المنتج من القاعدة، والسعر الجديد). - لا دمج لأي قيمة من الطلب داخل نص SQL.
👀 اعرض الاستجابة المطلوبة
POST /price.php(id=1&price=40)POST /price.php(id=1&price=-5)POST /price.php(id=999&price=10)POST /price.php(id=1 OR 1=1&price=1)POST /price.php(id=2&price=abc)
- الكود يعمل بلا أخطاء أو تحذيرات
id=1وprice=40← تم: قهوة عربية بسعر 40- سعر سالب ← 422
- منتج غير موجود ← 404
- هجوم
id=1 OR 1=1لا يغيّر كل الأسعار - سعر نصي ← 422
- تستخدم prepare و execute
- لا متغيرات داخل نص SQL
💡 تلميح 1
$stmt = $pdo->prepare('UPDATE products SET price = ? WHERE id = ?'); $stmt->execute([$price, $id]);💡 تلميح 2
rowCount() بعد execute تخبرك هل وُجد المنتج.حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.
<?phprequire __DIR__ . '/db.php';$id = (int) ($_POST['id'] ?? 0);$price = filter_var($_POST['price'] ?? '', FILTER_VALIDATE_FLOAT); if ($price === false || $price <= 0) { http_response_code(422); exit('سعر غير صحيح');}$stmt = $pdo->prepare('UPDATE products SET price = ? WHERE id = ?');$stmt->execute([$price, $id]);if ($stmt->rowCount() === 0) { http_response_code(404); exit('المنتج غير موجود');}$s = $pdo->prepare('SELECT name, price FROM products WHERE id = ?');$s->execute([$id]);$p = $s->fetch();echo "تم: {$p['name']} بسعر {$p['price']}";
<?php// db.php: الاتصال بقاعدة البيانات في مكان واحد// في المختبر نستخدم SQLite (ملف). وعلى XAMPP بدّل السطر بسطر MySQL الذي تحته$pdo = new PDO('sqlite:' . __DIR__ . '/store.sqlite');// $pdo = new PDO('mysql:host=localhost;dbname=store;charset=utf8mb4', 'root', ''); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // الأخطاء استثناءات$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); // الصفوف مصفوفات ترابطية