عمليات CRUD مع PreparedStatement
توجد كائنات Statement الخام في JDBC، لكن لا يجب استخدامها أبدًا في الاستعلامات التي تتضمن بيانات يُدخلها المستخدم. PreparedStatement هو الخيار الاحترافي الافتراضي لكل استعلام مُحدَّد بمعاملات: يُصرِّف SQL مرةً واحدة مسبقًا، ويقبل معاملات مُقيَّدة بأنواع محددة، ويُزيل حقن SQL بطبيعة تصميمه. يتناول هذا الدرس العمليات الأربع — إنشاء وقراءة وتحديث وحذف — باستخدام PreparedStatement بصورة صحيحة ومنهجية داخل فئة DAO.
نموذج البيانات
تعمل العمليات الأربع على جدول واحد. إليك SQL وسجل Java المطابق الذي يعيِّنه مستوى DAO:
-- المخطط
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
package com.example.model;
public record Product(int id, String name, double price, int stock) {}
استخدام record (Java 16 وما فوق) يمنحك كائن قيمة غير قابل للتغيير مع equals وhashCode وtoString مُولَّدة تلقائيًا. لا يخزن DAO أي حالة قابلة للتغيير — يقتصر دوره على تعيين الصفوف إلى سجلات والسجلات إلى صفوف.
CREATE — إدراج صف
استخدم Statement.RETURN_GENERATED_KEYS لكي يتوفر الـ id الذي تعيّنه قاعدة البيانات بعد الإدراج. بدون هذه العلامة، لا توجد طريقة موثوقة لاسترداد المفتاح الأساسي الجديد عبر جميع قواعد البيانات.
public Product create(Connection conn, String name, double price, int stock)
throws SQLException {
String sql = "INSERT INTO products (name, price, stock) VALUES (?, ?, ?)";
try (PreparedStatement ps =
conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, name);
ps.setDouble(2, price);
ps.setInt(3, stock);
int affected = ps.executeUpdate();
if (affected == 0) {
throw new SQLException("فشل الإدراج — لم تتأثر أي صفوف.");
}
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) {
return new Product(keys.getInt(1), name, price, stock);
}
throw new SQLException("فشل الإدراج — لم يُعاد أي مفتاح مُولَّد.");
}
}
}
مؤشرات المعاملات تبدأ من 1 وليس من 0. أول عنصر نائب ? يُعيَّن بمؤشر 1، والثاني بمؤشر 2، وهكذا. هذا مصدر دائم لأخطاء الإزاحة بمقدار واحد عند إضافة أعمدة أو إعادة ترتيبها — احسب دائمًا علامات ? في SQL للتحقق.
READ — الاستعلام عن الصفوف
قراءة صف واحد بالمفتاح الأساسي وقراءة جميع الصفوف تتبعان نفس النمط. الفرق الوحيد هو SQL وما إذا كانت مجموعة النتائج تُعيد صفًا واحدًا أم عدة صفوف.
// البحث بالمفتاح الأساسي — يُعيد Optional للإشارة إلى احتمال الغياب
public Optional<Product> findById(Connection conn, int id) throws SQLException {
String sql = "SELECT id, name, price, stock FROM products WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return Optional.of(mapRow(rs));
}
return Optional.empty();
}
}
}
// جلب جميع الصفوف
public List<Product> findAll(Connection conn) throws SQLException {
String sql = "SELECT id, name, price, stock FROM products ORDER BY id";
List<Product> list = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
list.add(mapRow(rs));
}
}
return list;
}
// مساعد خاص — يعيّن الصف الحالي إلى سجل Product
private Product mapRow(ResultSet rs) throws SQLException {
return new Product(
rs.getInt("id"),
rs.getString("name"),
rs.getDouble("price"),
rs.getInt("stock")
);
}
استخدم دائمًا أسماء الأعمدة وليس مؤشراتها في استدعاءات ResultSet. rs.getString("name") يبقى صحيحًا بعد إعادة ترتيب الأعمدة في قائمة SELECT أو في DDL الجدول. أما rs.getString(2) فيُعيد القيمة الخاطئة بصمت فور تغيير ترتيب الأعمدة.
UPDATE — تعديل صف قائم
يُعيد executeUpdate() عدد الصفوف المتأثرة. التحقق من هذا العدد يُتيح التمييز بين "جرى تحديث المنتج" و"لا يوجد منتج بهذا المعرّف" — تفصيل يهتم به المُستدعون غالبًا.
public boolean update(Connection conn, Product product) throws SQLException {
String sql = "UPDATE products SET name = ?, price = ?, stock = ? WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, product.name());
ps.setDouble(2, product.price());
ps.setInt(3, product.stock());
ps.setInt(4, product.id()); // شرط WHERE يأتي في النهاية
return ps.executeUpdate() == 1; // true ⟹ صف واحد بالضبط جرى تحديثه
}
}
نسيان شرط WHERE في UPDATE يمحو بياناتك بالكامل. UPDATE products SET price = ? بدون WHERE يُعيِّن ذلك السعر على كل صف في الجدول. تأكد دائمًا من وجود شرط WHERE وأن معاملته مُقيَّدة قبل تنفيذ أي DML على بيانات الإنتاج. بيئة الاختبار واستراتيجية النسخ الاحتياطي للقاعدة ضمانتان لا غنى عنهما.
DELETE — حذف صف
تتبع عمليات الحذف نفس شكل التحديثات تمامًا. أعد عدد الصفوف المتأثرة لكي يعرف المُستدعي ما إذا جرى حذف شيء فعلًا.
public boolean delete(Connection conn, int id) throws SQLException {
String sql = "DELETE FROM products WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
return ps.executeUpdate() == 1;
}
}
المُعيِّنات المكتوبة ومعالجة القيم الخالية
يوفر PreparedStatement مُعيِّنًا مكتوبًا لكل نوع SQL: setString وsetInt وsetLong وsetDouble وsetBigDecimal وsetBoolean وsetTimestamp وغيرها. عندما يكون العمود قابلًا للإسناد إلى null، لا تمرر null في Java إلى مُعيِّن مكتوب — استخدم setNull مع ثابت نوع SQL المناسب بدلًا من ذلك:
// معالجة صحيحة للقيمة الخالية في عمود يقبل null
if (product.description() != null) {
ps.setString(5, product.description());
} else {
ps.setNull(5, Types.VARCHAR);
}
تمرير null إلى setString يعمل في معظم المشغّلات، لكن الاعتماد على ذلك سلوك خاص بالمشغّل. setNull صريح وقابل للنقل.
الجمع معًا — DAO مُختصر
يجمع DAO كامل لجدول products العمليات الأربع تحت فئة واحدة. تُستقبل Connection دائمًا من المُستدعي (حقن)، ولا تُنشأ أبدًا داخل DAO — هذا ما يُبقي DAO قابلًا للاختبار ويُبقي التحكم في المعاملات في طبقة الخدمة، وهو ما يُغطيه الدرس السادس.
package com.example.dao;
import com.example.model.Product;
import java.sql.*;
import java.util.*;
public class ProductDao {
public Product create(Connection conn, String name, double price, int stock)
throws SQLException { /* ... كما ورد أعلاه ... */ }
public Optional<Product> findById(Connection conn, int id)
throws SQLException { /* ... كما ورد أعلاه ... */ }
public List<Product> findAll(Connection conn)
throws SQLException { /* ... كما ورد أعلاه ... */ }
public boolean update(Connection conn, Product product)
throws SQLException { /* ... كما ورد أعلاه ... */ }
public boolean delete(Connection conn, int id)
throws SQLException { /* ... كما ورد أعلاه ... */ }
private Product mapRow(ResultSet rs) throws SQLException { /* ... كما ورد أعلاه ... */ }
}
لماذا نمرر Connection إلى كل دالة عوضًا عن تخزينها كحقل؟ الاتصال المخزَّن ليس آمنًا للخيوط المتعددة ويربط DAO بوحدة عمل واحدة. قبول الاتصال كمعامل يعني أن طبقة الخدمة تقرر متى تفتحه وتُقرُّه وتغلقه — مما يُتيح لاستدعاءات DAO متعددة مشاركة معاملة واحدة دون أن يعلم DAO أي شيء عن إدارة المعاملات.
الخلاصة
تتبع كل عملية CRUD نمطًا محكمًا وقابلًا للتكرار: حضِّر SQL بعناصر نائبة ?، اربط المعاملات بمُعيِّنات مكتوبة (مؤشرات تبدأ من 1)، نفِّذ بـ executeUpdate() أو executeQuery()، وأغلق كل شيء في كتلة try-with-resources. أعد قيمًا ذات معنى — مفاتيح مُولَّدة للإدراجات، وOptional للبحث الذي قد يُفضي إلى عدم وجود نتيجة، وقيمة منطقية أو عداد صفوف للتعديلات. أبقِ الاتصال خارج DAO وستحصل على إمكانية الاختبار ومرونة المعاملات مجانًا.