Spring Data JPA

@Query واستعلامات JPQL

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

@Query واستعلامات JPQL

أساليب الاستعلام المشتقة مريحة، غير أن لها حدودًا. حالما يتطلب استعلامٌ ما JOIN عبر جدولين، أو دالة تجميع، أو استعلامًا فرعيًا، أو أي منطق لا يمكن التعبير عنه باسم دالة واضح، تحتاج إلى كتابة الاستعلام بنفسك. تمنحك Spring Data JPA annotation واحدة لهذا الغرض: @Query.

ما هي JPQL؟

JPQL (لغة استعلام Jakarta Persistence) هي لغة الاستعلام المعرَّفة في مواصفة JPA. تبدو شبه متطابقة مع SQL، لكنها تعمل على الكيانات وحقولها، لا على الجداول والأعمدة. تترجم Hibernate (مزوّد JPA داخل Spring Boot) استعلام JPQL إلى نكهة SQL الصحيحة في وقت التشغيل — PostgreSQL أو MySQL أو H2 أو غيرها مما قمت بتهيئته.

فكر في كيان بسيط:

import jakarta.persistence.*; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String status; // "PENDING", "SHIPPED", "DELIVERED" private BigDecimal total; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // getters / setters محذوفة للإيجاز }

في JPQL تشير إلى Order (اسم الفئة) وo.status (الحقل)، وليس إلى اسم الجدول orders أو العمود status. هذا الفصل هو ما يجعل الكود مستقلًا عن المخطط المادي.

الاستخدام الأساسي لـ @Query

ضع الـ annotation مباشرةً فوق دالة المستودع. يحمل الخاصية value نص JPQL. اربط المعاملات بصيغة :name وطابقها مع معاملات الدالة باستخدام @Param:

import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; public interface OrderRepository extends JpaRepository<Order, Long> { // JPQL — اسم الكيان، ليس اسم الجدول @Query("SELECT o FROM Order o WHERE o.status = :status") List<Order> findByStatus(@Param("status") String status); // تجميع — يعيد قيمة مفردة، لا كيانًا @Query("SELECT COUNT(o) FROM Order o WHERE o.status = 'PENDING'") long countPendingOrders(); }
اسم الفئة مقابل اسم الجدول: تستخدم JPQL اسم فئة الكيان (Order)، وليس اسم جدول قاعدة البيانات (orders). إذا أعدت تسمية الجدول مع إبقاء اسم الفئة كما هو، فلن يتغير استعلام JPQL.

المعاملات الموضعية مقابل المسمّاة

تدعم JPQL كلا الأسلوبين. المعاملات المسمّاة (:name مع @Param) هي الأفضل بشدة لأنها تصمد أمام إعادة ترتيب المعاملات عند إعادة الهيكلة:

// مسمّاة — موصى بها @Query("SELECT o FROM Order o WHERE o.status = :status AND o.total > :minTotal") List<Order> findByStatusAndMinTotal(@Param("status") String status, @Param("minTotal") BigDecimal minTotal); // موضعية — هشّة؛ ?1 يشير إلى المعامل الأول حسب الموضع @Query("SELECT o FROM Order o WHERE o.status = ?1 AND o.total > ?2") List<Order> findByStatusAndMinTotalPositional(String status, BigDecimal minTotal);

الربط عبر العلاقات

لأن JPQL تفهم نموذج الكائن، يمكنك اجتياز العلاقات بنقطة أو بـ JOIN صريح. للكود الذي يهتم بالأداء، يُعدّ JOIN FETCH الصريح الأداة الصحيحة — فهو يأمر Hibernate بتحميل العلاقة في استعلام SQL واحد بدلًا من إصدار استعلام لكل كيان (مشكلة N+1):

// اجتياز المسار البسيط (قد يُطلق N+1 إذا كان customer محملًا بشكل كسول LAZY) @Query("SELECT o FROM Order o WHERE o.customer.email = :email") List<Order> findByCustomerEmail(@Param("email") String email); // JOIN FETCH — يحمّل Order و Customer في JOIN SQL واحد، يتجنب N+1 @Query("SELECT o FROM Order o JOIN FETCH o.customer c WHERE c.email = :email") List<Order> findByCustomerEmailFetch(@Param("email") String email);
لا يتوافق JOIN FETCH مع التصفح بالصفحات (pagination) بشكل جيد. عند دمج JOIN FETCH مع Pageable، يجب على Hibernate تحميل جميع الصفوف المطابقة في الذاكرة قبل التصفح، مما يلغي الغرض منه. استخدم بدلًا من ذلك استراتيجية الاستعلامين: استعلام @Query مع JOIN FETCH واستعلام عدد منفصل عبر خاصية countQuery في @Query، أو استخدم إسقاط DTO (مُغطّى في الدرس التالي).

إعادة نتائج غير كيانية

لا تقتصر على إعادة كائنات الكيان. يمكن لـ @Query الإسقاط على مجموعة فرعية من الحقول باستخدام تعبير المنشئ (constructor expression) أو إسقاط الواجهة:

// فئة DTO (يعمل record جافا بشكل ممتاز في Spring Boot 3) public record OrderSummary(Long id, String status, BigDecimal total) {} // تعبير المنشئ — يستدعي new OrderSummary(o.id, o.status, o.total) @Query("SELECT new com.example.shop.dto.OrderSummary(o.id, o.status, o.total) " + "FROM Order o WHERE o.customer.id = :customerId") List<OrderSummary> findSummariesByCustomer(@Param("customerId") Long customerId);
استخدم تعبيرات المنشئ عندما تحتاج فقط لبعض الحقول. جلب كيان كامل يحمّل كل عمود، بما في ذلك BLOBs والعلاقات الكسولة التي قد لا تحتاجها. إسقاط DTO مع تعبير المنشئ يسترجع فقط الأعمدة المشار إليها — استعلام أسرع، استهلاك ذاكرة أقل، واجهة برمجية أنظف.

الاستعلامات المعدِّلة: UPDATE و DELETE

الـ @Query للقراءة فقط بشكل افتراضي. لتشغيل جملة DML (UPDATE أو DELETE في JPQL، أو أي تعديل SQL) يجب إضافة annotation اثنتين:

  • @Modifying — تُخبر Spring Data أن هذا الاستعلام يُعدِّل الحالة.
  • @Transactional — كل عملية كتابة يجب أن تعمل داخل معاملة.
import org.springframework.data.jpa.repository.Modifying; import org.springframework.transaction.annotation.Transactional; @Modifying @Transactional @Query("UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus") int bulkUpdateStatus(@Param("oldStatus") String oldStatus, @Param("newStatus") String newStatus); @Modifying @Transactional @Query("DELETE FROM Order o WHERE o.status = 'CANCELLED' AND o.total = 0") int deleteZeroValueCancelledOrders();

نوع الإعادة int (أو Integer) يمنحك عدد الصفوف المتأثرة. يمكن للدالة أيضًا أن تُعيد void.

تتجاوز DML الجماعية ذاكرة التخزين المؤقت للمستوى الأول. إذا حمّلت كيان Order ثم شغّلت UPDATE جماعية في نفس المعاملة، فإن الكيان في الذاكرة لا يزال يحمل القيمة القديمة — Hibernate لا تحدّثه تلقائيًا. إما استدع entityManager.clear() بعد التحديث الجماعي، أو اضبط @Modifying(clearAutomatically = true) لتتولى Spring Data ذلك.

خاصية countQuery

عندما يستخدم @Query خاصتك JOIN FETCH أو SELECT معقدة لا تستطيع Hibernate تحويلها تلقائيًا إلى استعلام COUNT للتصفح بالصفحات، قدّم استعلام عدد صريحًا:

@Query( value = "SELECT o FROM Order o JOIN FETCH o.customer c WHERE o.status = :status", countQuery = "SELECT COUNT(o) FROM Order o WHERE o.status = :status" ) Page<Order> findByStatusPageable(@Param("status") String status, Pageable pageable);

JPQL مقابل HQL مقابل Criteria

JPQL هو المعيار الخاص بـ JPA. نكهة Hibernate الخاصة (HQL) هي مجموعة عليا منه — تضيف ميزات كتحويلات TREAT والحسابات الموسّعة — لكن فضّل JPQL القياسية ما لم يكن لديك سبب ملموس. واجهة Criteria API (مُغطّاة في درس لاحق من سلسلة هذا البرنامج التعليمي) هي البديل الآمن النوع البرمجي، مفيد عندما يجب تحديد شكل الاستعلام في وقت التشغيل.

الخلاصة

يُعدّ @Query مع JPQL الأداة الأساسية لأي استعلام لا يمكن التعبير عنه كاسم دالة مشتقة. استخدم المعاملات المسمّاة مع @Param للمحافظة على القابلية للصيانة، واستخدم JOIN FETCH لإزالة مشكلات N+1، واستخدم تعبيرات المنشئ أو إسقاطات الواجهة عندما تحتاج فقط مجموعة فرعية من الحقول، وأضف @Modifying مع @Transactional لأي جملة DML. تغطي هذه الأنماط الغالبية العظمى من استعلامات المستودع في العالم الحقيقي.