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

Criteria: المسندات والاستعلامات الديناميكية

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

Criteria: المسندات والاستعلامات الديناميكية

قدّم الدرس السابق Criteria API وأوضح كيفية بناء استعلام مكتوب (typed) بسيط. إلا أن الفائدة الحقيقية من هذه الواجهة تكمن في قدرتها على تأليف شروط الاستعلام أثناء وقت التشغيل. فحين يتمكّن المستخدم من تصفية قائمة منتجات حسب الاسم أو الفئة أو الحد الأدنى للسعر أو الأقصى أو أي تركيبة من هذه المعايير، يعجز نص JPQL الثابت عن التعبير عن كل الاحتمالات. المسندات (predicates) هي اللبنات الأساسية التي تحل هذه المشكلة.

ما هو المسند (Predicate)؟

في Criteria API، يمثّل Predicate تعبيرًا بوليانيًا في SQL — أي شيء يمكن أن يظهر في جملة WHERE أو شرط انضمام. CriteriaBuilder هو المصنع الذي ينشئ هذه المسندات، ويمكنك دمجها بمعاملات منطقية لنمذجة شروط بالغة التعقيد.

الطرق الشائعة في المُنشئ التي تُنتج مسندات:

  • cb.equal(expr, value) — اختبار المساواة
  • cb.like(expr, pattern) — SQL LIKE
  • cb.greaterThanOrEqualTo(expr, value) / cb.lessThanOrEqualTo(expr, value)
  • cb.between(expr, lower, upper)
  • cb.isNull(expr) / cb.isNotNull(expr)
  • cb.in(expr).value(v1).value(v2) — SQL IN
  • cb.and(p1, p2, ...) / cb.or(p1, p2, ...) — الدمج المنطقي
  • cb.not(predicate) — النفي

بناء مسند بسيط

لنفترض وجود كيان Order بحقول status وtotalAmount وعلاقة كثيرة-لواحد مع Customer. يبدو استعلام الطلبات ذات الحالة SHIPPED والمبلغ الذي يتجاوز حدًا معينًا هكذا:

import jakarta.persistence.*; import jakarta.persistence.criteria.*; import org.springframework.stereotype.Repository; import java.math.BigDecimal; import java.util.List; @Repository public class OrderRepository { @PersistenceContext private EntityManager em; public List<Order> findShippedAbove(BigDecimal minAmount) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> cq = cb.createQuery(Order.class); Root<Order> order = cq.from(Order.class); Predicate isShipped = cb.equal(order.get("status"), "SHIPPED"); Predicate aboveLimit = cb.greaterThanOrEqualTo( order.get("totalAmount"), minAmount); cq.select(order).where(cb.and(isShipped, aboveLimit)); return em.createQuery(cq).getResultList(); } }

كل مسند كائن مستقل بذاته. تدمجها بتمريرها إلى cb.and() قبل تسليم النتيجة إلى cq.where().

تأليف استعلامات ديناميكية

النمط الأساسي لنماذج البحث هو تجميع المسندات في قائمة وإضافة المسند فقط عندما تكون قيمة التصفية المقابلة موجودة فعلًا. هذا يبقي SQL المولَّد موجزًا — لا حاجة لخدع AND 1=1 الزائفة.

import java.util.ArrayList; public List<Order> search(String status, BigDecimal minAmount, BigDecimal maxAmount, Long customerId) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> cq = cb.createQuery(Order.class); Root<Order> order = cq.from(Order.class); List<Predicate> predicates = new ArrayList<>(); if (status != null && !status.isBlank()) { predicates.add(cb.equal(order.get("status"), status)); } if (minAmount != null) { predicates.add(cb.greaterThanOrEqualTo( order.get("totalAmount"), minAmount)); } if (maxAmount != null) { predicates.add(cb.lessThanOrEqualTo( order.get("totalAmount"), maxAmount)); } if (customerId != null) { Join<Order, Customer> customer = order.join("customer", JoinType.INNER); predicates.add(cb.equal(customer.get("id"), customerId)); } cq.select(order) .where(predicates.toArray(new Predicate[0])) .orderBy(cb.desc(order.get("createdAt"))); return em.createQuery(cq).getResultList(); }
تمرير مصفوفة مسندات فارغة إلى where(): عندما لا تُوفَّر أي مرشحات، تكون predicates فارغة ويُنتج toArray مصفوفة بطول صفر. يعامل JPA هذا الاستدعاء where(new Predicate[0]) على أنه لا قيد — ما يعادل حذف جملة WHERE كليًا. أي أن الطريقة ذاتها تتعامل بسلاسة مع "إرجاع الكل" والاستعلامات المصفَّاة بالكامل.

شروط OR والمنطق المتداخل

امزج cb.and() وcb.or() لنمذجة شروط مركّبة. لنفترض أنك تريد طلبات إما PENDING أو متأخرة عن الموعد (حالة OVERDUE)، ومبلغها يتجاوز حدًا أدنى:

Predicate isPending = cb.equal(order.get("status"), "PENDING"); Predicate isOverdue = cb.equal(order.get("status"), "OVERDUE"); Predicate statusPart = cb.or(isPending, isOverdue); Predicate aboveMin = cb.greaterThan(order.get("totalAmount"), minAmount); cq.where(cb.and(statusPart, aboveMin));

يُترجم هذا إلى:

WHERE (o.status = 'PENDING' OR o.status = 'OVERDUE') AND o.total_amount > ?

LIKE والبحث غير الحساس لحالة الأحرف

مطابقة السلاسل النصية الجزئية متطلب شائع. لف تعبير العمود بـ cb.lower() وتحويل سلسلة البحث إلى أحرف صغيرة في Java لتحقيق مطابقة محمولة غير حساسة لحالة الأحرف:

if (nameFragment != null && !nameFragment.isBlank()) { predicates.add( cb.like( cb.lower(order.get("description")), "%" + nameFragment.toLowerCase() + "%" ) ); }
LIKE واستخدام الفهارس: البدء بحرف بدل (%term) يعطّل مسح فهارس B-tree في معظم قواعد البيانات. للبحث النصي الكامل على الجداول الكبيرة في الإنتاج، فضّل حلًا متخصصًا (مثل tsvector في PostgreSQL أو Elasticsearch أو فهرس نص كامل) بدلًا من LIKE '%...'. احتفظ بـ LIKE ذي الحرف البدل الأمامي للوحات الإدارة والبحث قليل الحجم.

الاستعلامات الفرعية كمسندات

تدعم Criteria API الاستعلامات الفرعية المترابطة عبر cq.subquery(). هذا مفيد لشروط "exists" — كإرجاع العملاء الذين لديهم طلب مشحون واحد على الأقل:

CriteriaQuery<Customer> cq = cb.createQuery(Customer.class); Root<Customer> customer = cq.from(Customer.class); Subquery<Long> sub = cq.subquery(Long.class); Root<Order> subOrder = sub.from(Order.class); sub.select(subOrder.get("id")) .where( cb.equal(subOrder.get("customer"), customer), cb.equal(subOrder.get("status"), "SHIPPED") ); cq.select(customer).where(cb.exists(sub));

نمط المواصفة (Specification) في Spring Data JPA

كتابة كل منطق المسندات داخل مستودع واحد يصبح مطوّلًا بسرعة. تلفّ واجهة Specification<T> في Spring Data JPA بناءَ مسند واحد في وحدة lambda قابلة لإعادة الاستخدام والتأليف. فعّلها بتوسيع JpaSpecificationExecutor<T> في واجهة مستودعك:

import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.*; public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {}

عرّف مواصفات فردية كطرق مصنع ثابتة في فئة أدوات مساعدة:

public class OrderSpecs { public static Specification<Order> hasStatus(String status) { return (root, query, cb) -> status == null ? cb.conjunction() : cb.equal(root.get("status"), status); } public static Specification<Order> amountAtLeast(BigDecimal min) { return (root, query, cb) -> min == null ? cb.conjunction() : cb.greaterThanOrEqualTo(root.get("totalAmount"), min); } }

ادمجها ونفّذها بواجهة برمجية سلسلة:

import static com.example.OrderSpecs.*; import static org.springframework.data.jpa.domain.Specification.where; List<Order> results = orderRepository.findAll( where(hasStatus(status)) .and(amountAtLeast(minAmount)) );
لماذا cb.conjunction()؟ تُعيد مسندًا يكون دائمًا TRUE، تعمل بوصفها عملية لا تأثير لها عند غياب قيمة المرشّح. هذا يتيح لكل طريقة مواصفة معالجة فحص القيم الفارغة داخليًا، فلا يضطر المستدعي أبدًا للحراسة من القيم الفارغة.

اعتبارات الأداء

  • ربط المعاملات لا تسلسل النصوص. تُربط جميع قيم Criteria API تلقائيًا كمعاملات JDBC — حقن SQL مستحيل، وتستطيع قاعدة البيانات تخزين خطط التنفيذ مؤقتًا.
  • تجنّب مجموعات النتائج الفضفاضة. يمكن للاستعلامات الديناميكية أن تُعيد ملايين الصفوف إن كانت جميع المرشحات فارغة. طبّق ترقيمًا للصفحات (setFirstResult / setMaxResults) لأي بحث يواجه المستخدم.
  • انضمامات الجلب في الاستعلامات الديناميكية. إن انضممت بشكل مشروط إلى ارتباط للتصفية به، اجلبه أيضًا لتجنّب تحميل N+1. استخدم root.fetch("customer", JoinType.LEFT) حين يكون الانضمام لتحميل البيانات، وroot.join() حين يكون للتصفية فحسب.
  • التمييز عند جلب مجموعات. الانضمام لعلاقة واحد-لكثير ينفخ مجموعة النتائج. أضف cq.distinct(true) أو استخدم DISTINCT في الاستعلام لإلغاء التكرار.

الخلاصة

المسندات هي الوحدات الذرية في استعلامات Criteria API. ابنها بشكل فردي باستخدام CriteriaBuilder، اجمعها في قائمة، وادمجها بـ cb.and() / cb.or() لتمثيل أي تركيبة مرشحات قد يختارها المستخدم. يرفع نمط المواصفة من Spring Data JPA هذا إلى مستوى أعلى من إعادة الاستخدام بتغليف كل مسند ككائن مُسمّى قابل للتأليف. معًا، تستبدل هاتان التقنيتان نهج تسلسل السلاسل الهش في SQL الديناميكي ببديل آمن من حيث الأنواع وقابل للاختبار وسهل القراءة.