JPQL وواجهة Criteria والاستعلامات

الإسقاطات واستعلامات DTO

18 دقيقة الدرس 9 من 13

الإسقاطات واستعلامات DTO

حين تُنفّذ استعلام JPQL يُعيد كيانًا مُدارًا، يُحمّل Hibernate كل عمود مُعيَّن، ويُسجّل الكائن في ذاكرة التخزين المؤقت من المستوى الأول، ويتتبّعه للكشف عن التعديلات. بالنسبة لعمليات القراءة الإبلاغية وردود واجهات API التي تحتاج إلى حقول قليلة فحسب، هذا التكلف مجرد هدر. تُتيح لك الإسقاطات (Projections) اختيار الأعمدة التي تحتاجها تحديدًا وتحويلها مباشرةً إلى كائن DTO خفيف الوزن — متجاوزًا تمامًا تكاليف الكيانات.

لماذا تتجنب تحميل الكيانات الكاملة للبيانات للقراءة فقط

تخيّل نقطة نهاية لكتالوج المنتجات تُعيد معرّف ومقبوض واسم وسعر لكل منتج. تحميل كيان Product كاملًا يجلب أيضًا وصف الكتلة وروابط الصور والطوابع الزمنية للتدقيق وأي علاقات محمّلة مسبقًا. كل واحدة من هذه تصبح كائنًا على الكومة. تحت الحمل مع مئات الطلبات المتزامنة، هذا الفارق قابل للقياس: ضغط أكبر على جمع القمامة، وإخفاقات أكبر في ذاكرة التخزين المؤقت L1، وتنفيذ استعلام أبطأ لأن قاعدة البيانات ترسل مزيدًا من البايتات عبر الشبكة.

الفكرة الأساسية: لا يُدير Hibernate إلا ما يُحمّله. DTO هو مجرد كائن Java عادي — لا يُضاف أبدًا إلى سياق الثبات، ولا يُتحقق منه للكشف عن التعديلات، ولا يُوكَّل. هذا بالضبط ما تريده لعمليات القراءة.

الإسقاطات القياسية مع مصفوفات Object

أبسط إسقاط هو تحديد الحقول المُسمّاة وإعادة مصفوفات Object[]:

// repository or service method List<Object[]> rows = em.createQuery( "SELECT p.id, p.name, p.price FROM Product p WHERE p.active = true", Object[].class) .getResultList(); for (Object[] row : rows) { Long id = (Long) row[0]; String name = (String) row[1]; BigDecimal price = (BigDecimal) row[2]; }

يعمل هذا لكنه هشّ: التعيين من الفهرس إلى الحقل ضمني ويكسر بصمت إذا أُعيد ترتيب عبارة SELECT. استخدمه فقط للنماذج الأولية السريعة.

تعبيرات المنشئ — الأسلوب المعياري

يدعم JPQL تعبير NEW الذي يستدعي منشئ Java مباشرةً داخل الاستعلام:

// DTO — سجل أو فئة عادية، بلا تعليق @Entity public record ProductSummary(Long id, String name, BigDecimal price) {}
List<ProductSummary> summaries = em.createQuery( "SELECT NEW com.example.dto.ProductSummary(p.id, p.name, p.price) " + "FROM Product p WHERE p.active = true ORDER BY p.name", ProductSummary.class) .getResultList();

يقرأ Hibernate اسم الفئة المؤهَّل بالكامل، ويحل المنشئ المطابق، ويستدعيه لكل صف. النتيجة قائمة ذات نوع صارم بلا تحويل نوع صريح.

استخدم سجلات Java كـ DTOs. يُعلن السجل المنشئ القانوني تلقائيًا، وهو بالضبط ما يستدعيه تعبير NEW في JPQL. السجلات غير قابلة للتغيير وموجزة وتعمل بشكل مثالي لنماذج القراءة.

إسقاطات الواجهة في Spring Data JPA

يُضيف Spring Data JPA آلية إسقاط على مستوى أعلى: تُعرّف واجهة بأساليب getter تُطابق الحقول التي تريدها، ويُنشئ Spring وكيلًا في وقت التشغيل.

// واجهة الإسقاط — لا حاجة لأي تنفيذ public interface ProductSummaryView { Long getId(); String getName(); BigDecimal getPrice(); }
// المستودع public interface ProductRepository extends JpaRepository<Product, Long> { // يستنتج Spring Data SQL تلقائيًا من نوع الإعادة List<ProductSummaryView> findByActiveTrue(); // أو الجمع مع @Query للتحكم الكامل @Query("SELECT p.id AS id, p.name AS name, p.price AS price " + "FROM Product p WHERE p.category = :cat") List<ProductSummaryView> findSummariesByCategory(@Param("cat") String category); }
فخ N+1 مع إسقاطات الواجهة: إذا استدعى getter في الواجهة علاقة مُحمَّلة بكسل (مثل getCategoryName() التي تفوّض إلى product.getCategory().getName())، سيُطلق Spring استعلامًا منفصلًا لكل صف. تحقق دائمًا من SQL المُولَّد بواسطة spring.jpa.show-sql=true عند استخدام إسقاطات متداخلة.

إسقاطات الفئة (DTO) مع @Query

للنهج الأكثر وضوحًا وأمانًا أمام إعادة البناء، اجمع @Query مع تعبير المنشئ:

import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface OrderRepository extends JpaRepository<Order, Long> { @Query("SELECT NEW com.example.dto.OrderLineDto(" + " o.id, o.createdAt, p.name, oi.quantity, oi.unitPrice) " + "FROM Order o " + "JOIN o.items oi " + "JOIN oi.product p " + "WHERE o.customer.id = :customerId " + "ORDER BY o.createdAt DESC") List<OrderLineDto> findOrderLinesByCustomer(@Param("customerId") Long customerId); }
public record OrderLineDto( Long orderId, LocalDateTime createdAt, String productName, int quantity, BigDecimal unitPrice ) { // حقل مشتق — لا عمود قاعدة بيانات مطلوب public BigDecimal lineTotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); } }

لاحظ أن lineTotal() يُحسب في Java من الحقول المُحمَّلة بالفعل — لا حاجة لاستعلام إضافي. هذا نمط شائع: اجلب الأرقام الخام، واحسب القيم المشتقة في DTO.

إسقاطات DTO في Criteria API باستخدام CriteriaBuilder.construct()

حين يُبنى الاستعلام ديناميكيًا (الدرس 6)، يمكنك الإسقاط في DTO باستخدام CriteriaBuilder.construct():

CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<ProductSummary> cq = cb.createQuery(ProductSummary.class); Root<Product> p = cq.from(Product.class); cq.select(cb.construct( ProductSummary.class, p.get("id"), p.get("name"), p.get("price") )); cq.where(cb.isTrue(p.get("active"))); List<ProductSummary> results = em.createQuery(cq).getResultList();

هذا هو المكافئ الآمن للنوع لـ NEW في JPQL، وقابل للاستخدام مع المحددات المبنية ديناميكيًا.

اختيار استراتيجية الإسقاط الصحيحة

  • تحميل الكيان الكامل — حين تحتاج إلى تعديل البيانات وتثبيت التغييرات. يوفّر لك الكشف عن التعديلات في سياق الثبات جمل UPDATE الصريحة.
  • تعبير المنشئ / سجل DTO — الافتراضي لنقاط النهاية للقراءة فقط. مكتوب بنوع صارم، بلا تكاليف إضافية، محمول عبر موفّري JPA.
  • إسقاط الواجهة — مناسب حين تستخدم استعلامات Spring Data المشتقة والإسقاط ضحل (بلا اجتياز علاقات). كود أقل لكن به المزيد من الخفاء.
  • مصفوفة Object[] القياسية — تجنّبها في كود الإنتاج؛ مفيدة للتصحيح المؤقت.

مقارنة الأداء

تخيّل استعلامًا يُعيد 1,000 طلبية بخمسة أعمدة لكل منها. قد يُطلق تحميل كيانات Order الكاملة وكلاء التحميل الكسول لعلاقة Customer، مما يُنتج 1,001 جملة SQL (N+1 الكلاسيكية). يُصدر إسقاط DTO مع JOIN في استعلام JPQL جملة واحدة بالضبط وينقل فقط الأعمدة الخمسة المطلوبة — أسرع بـ 3 إلى 10 أضعاف عادةً لأحمال الإبلاغ.

قِس دائمًا. استخدم إحصائيات Hibernate (hibernate.generate_statistics=true) أو P6Spy لحساب جمل SQL الفعلية في اختبارات التكامل قبل التبديل إلى الإسقاطات وبعده.

الخلاصة

الإسقاطات هي الأداة الأساسية للحفاظ على مسارات القراءة خفيفة في تطبيق JPA. تعبير المنشئ (NEW com.example.dto.SomeDto(...)) هو الاختيار المحمول والصريح ويتزاوج تمامًا مع سجلات Java. تُقلّل إسقاطات واجهة Spring Data الكود المتكرر للحالات البسيطة لكنها تستلزم الحذر حول اجتياز العلاقات. تُمدّد إسقاطات Criteria API عبر cb.construct() نفس النمط للاستعلامات الديناميكية. في جميع الحالات الهدف واحد: احمل فقط البيانات التي تحتاجها، وتجاوز تكاليف سياق الثبات، وأعد كائن قيمة عاديًا وغير قابل للتغيير لمستدعييك.