تحويل صفوف قاعدة البيانات إلى كائنات
كل ResultSet تُنفّذه DAO الخاص بك يمنحك مؤشرًا فوق قيم أعمدة خام — نصوص وأعداد صحيحة وطوابع زمنية. تطبيقك لا يُفكّر بالصفوف؛ بل يُفكّر بكائنات نطاق مثل User أو Order أو Product. طبقة الترجمة بين هذين العالمين تُسمّى ربط الصفوف (Row Mapping)، وإتقانها أدق مما يبدو للوهلة الأولى.
المشكلة مع الربط المضمّن
الأسلوب الأكثر وضوحًا هو تحويل الأعمدة إلى حقول مباشرةً داخل دالة DAO التي تُنفّذ الاستعلام:
public List<User> findAll() throws SQLException {
List<User> users = new ArrayList<>();
String sql = "SELECT id, email, full_name, created_at FROM users";
try (Connection conn = DataSourceFactory.get().getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
User u = new User();
u.setId(rs.getLong("id"));
u.setEmail(rs.getString("email"));
u.setFullName(rs.getString("full_name"));
u.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
users.add(u);
}
}
return users;
}
هذا يعمل لدالة واحدة. لحظة أن تمتلك DAO خمس دوال تُعيد كائنات User — findById وfindByEmail وfindByRole وغيرها — ستجد كتلة الربط الست أسطر ذاتها منسوخة في كل مكان. عند إعادة تسمية عمود في المخطط ستضطر لتتبّع كل نسخة.
استخراج Row Mapper
الحل هو استخراج منطق الربط إلى دالة خاصة (أو فئة مستقلة). الدالة الخاصة هي أبسط الأشكال:
// داخل UserDao
private User mapRow(ResultSet rs) throws SQLException {
User u = new User();
u.setId(rs.getLong("id"));
u.setEmail(rs.getString("email"));
u.setFullName(rs.getString("full_name"));
u.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return u;
}
public List<User> findAll() throws SQLException {
List<User> users = new ArrayList<>();
String sql = "SELECT id, email, full_name, created_at FROM users";
try (Connection conn = DataSourceFactory.get().getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
users.add(mapRow(rs));
}
}
return users;
}
public Optional<User> findById(long id) throws SQLException {
String sql = "SELECT id, email, full_name, created_at FROM users WHERE id = ?";
try (Connection conn = DataSourceFactory.get().getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Optional.of(mapRow(rs)) : Optional.empty();
}
}
}
نتائج صف واحد مقابل متعددة: للاستعلامات التي تُعيد صفًا واحدًا على أقصى تقدير، استخدم Optional<T> كنوع إرجاع. فهو يُجبر المُستدعين على معالجة حالة "غير موجود" صراحةً بدلًا من إعادة null والأمل أن لا يُشكّل ذلك مشكلة.
التعامل مع الأعمدة القابلة للقيمة الخالية
يُخفي JDBC فخًّا دقيقًا مع الأنواع العددية: rs.getInt("discount") يُعيد 0 إذا كان العمود NULL لا استثناءً. يتعامل كودك بصمت مع الخصم المفقود باعتباره صفرًا. الفحص الصحيح هو:
Integer discount = rs.getInt("discount");
if (rs.wasNull()) {
discount = null;
}
// أو بشكل أكثر إيجازًا:
Integer discount = rs.getObject("discount", Integer.class);
rs.getObject(column, Class) متاح منذ Java 7 ويُعيد null عندما يكون العمود NULL، مما يجعل التعامل مع القيم الخالية صريحًا ونظيفًا. استخدمه لأي عمود عددي أو منطقي قابل للقيمة الخالية.
لا تستخدم أبدًا getInt / getLong / getDouble للأعمدة القابلة للقيمة الخالية. هذه الدوال التي تُعيد أنواعًا أوليّة تُعيد صفرًا بصمت عند NULL. استخدم دائمًا getObject(col, BoxedType.class) أو تحقق من wasNull() فورًا بعدها.
ربط التواريخ والطوابع الزمنية
قدّم Java 8 الـ java.time API وهو أفضل بكثير من java.sql.Date وjava.sql.Timestamp. برامج تشغيل JDBC الحديثة (MySQL Connector/J 8+ وPostgreSQL JDBC 42.x) تدعم getObject مع أنواع java.time مباشرةً:
// الطريقة المفضّلة — لا sql.Timestamp قديمة
LocalDateTime createdAt = rs.getObject("created_at", LocalDateTime.class);
LocalDate birthDate = rs.getObject("birth_date", LocalDate.class);
Instant updatedAt = rs.getObject("updated_at", Instant.class);
إذا لم يدعم برنامج التشغيل الخاص بك ذلك (إصدارات أقدم)، ارجع إلى getTimestamp وحوّل:
Timestamp ts = rs.getTimestamp("created_at");
LocalDateTime createdAt = ts != null ? ts.toLocalDateTime() : null;
بناء واجهة RowMapper عامة
عندما يحتوي المشروع على DAOs متعددة، يصبح تكرار نمط الربط عبرها أمرًا مُرهِقًا. واجهة دالية خفيفة الوزن تعكس ما تفعله RowMapper الخاصة بـ Spring — وتعمل مع تعابير lambda:
@FunctionalInterface
public interface RowMapper<T> {
T mapRow(ResultSet rs, int rowNum) throws SQLException;
}
مساعد مشترك على غرار JdbcTemplate يقبل الـ mapper:
public class JdbcHelper {
private final DataSource dataSource;
public JdbcHelper(DataSource dataSource) {
this.dataSource = dataSource;
}
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... params)
throws SQLException {
List<T> results = new ArrayList<>();
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
for (int i = 0; i < params.length; i++) {
ps.setObject(i + 1, params[i]);
}
try (ResultSet rs = ps.executeQuery()) {
int row = 0;
while (rs.next()) {
results.add(mapper.mapRow(rs, ++row));
}
}
}
return results;
}
}
الاستخدام على مستوى DAO يصبح تعبيرًا واحدًا واضحًا:
// Mapper معرّف مرة واحدة كـ lambda أو مرجع دالة
private static final RowMapper<User> USER_MAPPER = (rs, n) -> {
User u = new User();
u.setId(rs.getLong("id"));
u.setEmail(rs.getString("email"));
u.setFullName(rs.getString("full_name"));
u.setCreatedAt(rs.getObject("created_at", LocalDateTime.class));
return u;
};
// يُستخدم عبر جميع دوال الاستعلام
public List<User> findAll() throws SQLException {
return jdbc.query("SELECT id, email, full_name, created_at FROM users", USER_MAPPER);
}
public List<User> findByRole(String role) throws SQLException {
return jdbc.query(
"SELECT id, email, full_name, created_at FROM users WHERE role = ?",
USER_MAPPER, role
);
}
أعلن الـ mapper كثابت static. ليس لديه حالة قابلة للتغيير، لذا فمثيل واحد مشترك عبر جميع استدعاءات الدوال آمن وخفيف ويتجنب تخصيص lambda متكررًا على المسارات السريعة.
ربط الاستعلامات المرتبطة (علاقة واحد-إلى-كثير)
جلب جدولين مرتبطين في استعلام واحد أكثر كفاءة من N+1 استدعاء. منطق الربط يجب أن يكتشف أي صف أب ينتمي إليه الصف الحالي ويُلحق الكائنات الابن وفقًا لذلك:
public List<Order> findOrdersWithItems(long customerId) throws SQLException {
String sql =
"SELECT o.id AS order_id, o.total, " +
" i.id AS item_id, i.product_name, i.quantity " +
"FROM orders o " +
"JOIN order_items i ON i.order_id = o.id " +
"WHERE o.customer_id = ? " +
"ORDER BY o.id";
Map<Long, Order> orderMap = new LinkedHashMap<>();
try (Connection conn = DataSourceFactory.get().getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, customerId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long orderId = rs.getLong("order_id");
Order order = orderMap.computeIfAbsent(orderId, id -> {
Order o = new Order();
o.setId(id);
try { o.setTotal(rs.getBigDecimal("total")); }
catch (SQLException e) { throw new RuntimeException(e); }
o.setItems(new ArrayList<>());
return o;
});
OrderItem item = new OrderItem();
item.setId(rs.getLong("item_id"));
item.setProductName(rs.getString("product_name"));
item.setQuantity(rs.getInt("quantity"));
order.getItems().add(item);
}
}
}
return new ArrayList<>(orderMap.values());
}
LinkedHashMap يحفظ ترتيب الإدراج لكي تُعاد الطلبات بنفس تسلسل إرسال قاعدة البيانات. computeIfAbsent ينشئ كائن Order الأب فقط عند ظهوره الأول، ثم كل صف لاحق يُلحق OrderItem فحسب.
أسماء مستعارة للأعمدة وتجنّب SELECT *
اختر الأعمدة دائمًا بشكل صريح — لا تستخدم SELECT * أبدًا في DAOs الإنتاجية. القوائم الصريحة للأعمدة تعني:
- إضافة عمود إلى المخطط لن تُغيّر مجموعة نتائجك بصمت أو تكسر الـ mapper.
- الأسماء المستعارة (
o.id AS order_id) تُزيل الغموض في الاستعلامات المرتبطة.
- تنقل فقط الأعمدة التي تستخدمها فعلًا، مما يُقلّل حركة مرور الشبكة.
الخلاصة
ربط الصفوف هو الجسر بين عالم JDBC الجدولي ونموذج النطاق الخاص بك. ابدأ بدالة mapRow خاصة لتوحيد منطق الربط. استخدم getObject(col, Type.class) للأعمدة القابلة للقيمة الخالية وأعمدة التاريخ. للمشاريع الكبيرة، ارفع النمط إلى واجهة RowMapper دالية حتى تُعرّف كل DAO ثابتًا واحدًا للـ mapper يُعاد استخدامه عبر جميع دوال الاستعلام. للاستعلامات المرتبطة، اجمع الكائنات الآباء في LinkedHashMap وأضف الأبناء أثناء التكرار. هذه الأساليب تُبقي DAOs الخاصة بك موجزةً وصحيحةً وقابلة للصيانة مع تطور المخططات.