مشروع: الاستعلامات المتقدمة
تجمع هذه الدرس الأخير من البرنامج التعليمي كلّ تقنية تعلّمتها — JPQL وواجهة Criteria API وجلسات الجلب المسبق (Fetch Joins) والإسقاطات (Projections) والمحددات الديناميكية والاستعلامات المسماة — في ميزة واحدة واقعية. ستبني واجهة برمجية للبحث عن الطلبات والتحليلات لخلفية متجر إلكتروني: خدمة تتيح للواجهة الأمامية تصفية الطلبات بمعايير اختيارية متعددة واسترجاع إحصاءات إجمالية، كلّ ذلك ضمن تطبيق Spring Boot 3 / Hibernate 6 واحد.
نموذج المجال
يستخدم المجال ثلاث كيانات. ينتمي Order إلى Customer ويحتوي على مجموعة من سطور OrderItem، كلٌّ منها مرتبط بـ Product. هذه العلاقات شائعة في أيّ نظام معاملات وتمنحنا مساحة فعلية للعمل مع الجلسات والتجميعات.
// Order.java
@Entity
@Table(name = "orders")
@NamedQuery(
name = "Order.findRecentByStatus",
query = "SELECT o FROM Order o WHERE o.status = :status AND o.createdAt >= :since ORDER BY o.createdAt DESC"
)
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderItem> items = new ArrayList<>();
@Enumerated(EnumType.STRING)
private OrderStatus status; // PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED
private BigDecimal totalAmount;
private LocalDateTime createdAt;
}
// OrderItem.java
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
private int quantity;
private BigDecimal unitPrice;
}
التحميل الكسول هو الإعداد الافتراضي الصحيح لعلاقات @ManyToOne و@OneToMany. في هذا المشروع ستختار بوضوح متى تجلب البيانات المرتبطة مسبقًا — عبر جلسات الجلب المسبق — بدلًا من ترك Hibernate يقرر ذلك.
الميزة الأولى — البحث الديناميكي عن الطلبات (Criteria API)
يقبل نقطة نهاية البحث أيّ تركيبة من: جزء من اسم العميل، وحالة الطلب، ونطاق زمني، وحدٌّ أدنى للمبلغ الإجمالي. بما أنّ أيّ حقل قد يكون غائبًا، فهذا بالضبط هو السيناريو الذي يتفوق فيه البناء البرمجي لـ Criteria API على سلاسل JPQL المُدمجة يدويًا.
// OrderSearchRequest.java (سجل بسيط — لا تعليقات JPA)
public record OrderSearchRequest(
String customerName, // اختياري
OrderStatus status, // اختياري
LocalDateTime from, // اختياري
LocalDateTime to, // اختياري
BigDecimal minTotal // اختياري
) {}
// OrderSearchService.java
@Service
@Transactional(readOnly = true)
public class OrderSearchService {
@PersistenceContext
private EntityManager em;
public List<Order> search(OrderSearchRequest req) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> order = cq.from(Order.class);
// جلب العميل والبنود في استعلام واحد لتجنب مشكلة N+1
Fetch<Order, Customer> customerFetch = order.fetch("customer", JoinType.LEFT);
order.fetch("items", JoinType.LEFT);
List<Predicate> predicates = new ArrayList<>();
if (req.customerName() != null && !req.customerName().isBlank()) {
Join<Order, Customer> c = (Join<Order, Customer>) customerFetch;
predicates.add(
cb.like(cb.lower(c.get("name")), "%" + req.customerName().toLowerCase() + "%")
);
}
if (req.status() != null) {
predicates.add(cb.equal(order.get("status"), req.status()));
}
if (req.from() != null) {
predicates.add(cb.greaterThanOrEqualTo(order.get("createdAt"), req.from()));
}
if (req.to() != null) {
predicates.add(cb.lessThanOrEqualTo(order.get("createdAt"), req.to()));
}
if (req.minTotal() != null) {
predicates.add(cb.greaterThanOrEqualTo(order.get("totalAmount"), req.minTotal()));
}
cq.select(order)
.where(predicates.toArray(Predicate[]::new))
.distinct(true) // تجنب التكرار الناتج عن جلب البنود
.orderBy(cb.desc(order.get("createdAt")));
return em.createQuery(cq).getResultList();
}
}
تحويل Fetch إلى Join عند الحاجة للتصفية عليه. عند استدعاء order.fetch("customer", JoinType.LEFT)، تُعيد Hibernate كائن Fetch. بما أنّ Fetch يمتد من Join، يمكنك تحويله بأمان وإعادة استخدامه في محددات like() أو equal() دون إصدار جلسة ثانية لنفس الجدول.
الميزة الثانية — التحليلات عبر تجميع JPQL
تُعيد نقطة نهاية التحليلات ملخصًا لكل حالة طلب: عدد الطلبات، والإيراد الإجمالي، ومتوسط قيمة الطلب. هذا تجميع بحت — لا حاجة لرسم بياني للكيانات — لذا فإنّ JPQL مع إسقاط DTO أنظف وأسرع من Criteria API هنا.
// OrderStatusSummary.java (DTO إسقاط)
public record OrderStatusSummary(
OrderStatus status,
long orderCount,
BigDecimal totalRevenue,
BigDecimal avgOrderValue
) {}
// في OrderAnalyticsService.java
@Service
@Transactional(readOnly = true)
public class OrderAnalyticsService {
@PersistenceContext
private EntityManager em;
public List<OrderStatusSummary> summariseByStatus() {
String jpql = """
SELECT new com.example.shop.dto.OrderStatusSummary(
o.status,
COUNT(o),
SUM(o.totalAmount),
AVG(o.totalAmount))
FROM Order o
GROUP BY o.status
ORDER BY o.status
""";
return em.createQuery(jpql, OrderStatusSummary.class).getResultList();
}
}
الميزة الثالثة — استعلام مسمى للطلبات العاجلة الحديثة
تحتاج لوحة دعم العملاء إلى طلبات آخر 24 ساعة بحالة PROCESSING. يُستدعى هذا الاستعلام عند كل تحديث للصفحة، مما يجعله مرشحًا مثاليًا للـ @NamedQuery الذي رأيته مُعلَّقًا على الكيان أعلاه — تُترجمه Hibernate وتُحققه عند بدء التشغيل، لذا يفشل JPQL غير الصحيح بسرعة بدلًا من الفشل في الساعة الثانية صباحًا في الإنتاج.
// في OrderSearchService.java (أضف هذه الطريقة)
public List<Order> findRecentProcessing() {
return em.createNamedQuery("Order.findRecentByStatus", Order.class)
.setParameter("status", OrderStatus.PROCESSING)
.setParameter("since", LocalDateTime.now().minusHours(24))
.setMaxResults(100) // حدٌّ أقصى دفاعي
.getResultList();
}
الميزة الرابعة — أفضل المنتجات مبيعًا (SQL أصلي مع DTO)
يريد مديرو المنتجات أفضل 10 منتجات حسب الإيراد لأيّ نطاق زمني، بالانضمام عبر ثلاثة جداول. يستفيد مخطط الاستعلام من تلميح فهرس محدد لا تستطيع ORM التعبير عنه، لذا تنزل إلى SQL الأصلي وتُعيّن النتيجة إلى DTO.
// TopProductDto.java
public record TopProductDto(Long productId, String productName, BigDecimal revenue) {}
// في ProductAnalyticsService.java
@Service
@Transactional(readOnly = true)
public class ProductAnalyticsService {
@PersistenceContext
private EntityManager em;
@SuppressWarnings("unchecked")
public List<TopProductDto> topProductsByRevenue(LocalDateTime from, LocalDateTime to) {
String sql = """
SELECT p.id, p.name, SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.created_at BETWEEN :from AND :to
AND o.status = 'DELIVERED'
GROUP BY p.id, p.name
ORDER BY revenue DESC
LIMIT 10
""";
List<Object[]> rows = em.createNativeQuery(sql)
.setParameter("from", from)
.setParameter("to", to)
.getResultList();
return rows.stream()
.map(r -> new TopProductDto(
((Number) r[0]).longValue(),
(String) r[1],
(BigDecimal) r[2]))
.toList();
}
}
تتجاوز الاستعلامات الأصلية فحص تلوث Hibernate وذاكرته المؤقتة. إذا عدّلت كيانات ثم نفّذت استعلامًا أصليًا في نفس المعاملة، فافلش EntityManager أولًا (em.flush()) لضمان ظهور تعديلاتك في SQL الأصلي. تجدر الإشارة أيضًا إلى أنّ الاستعلامات الأصلية تتجاوز ذاكرة Hibernate الثانوية المؤقتة كليًا.
ربطها معًا — متحكم REST
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderSearchService searchService;
private final OrderAnalyticsService analyticsService;
@GetMapping("/search")
public List<Order> search(
@RequestParam(required = false) String customerName,
@RequestParam(required = false) OrderStatus status,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime from,
@RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE_TIME) LocalDateTime to,
@RequestParam(required = false) BigDecimal minTotal) {
return searchService.search(
new OrderSearchRequest(customerName, status, from, to, minTotal));
}
@GetMapping("/analytics/status-summary")
public List<OrderStatusSummary> statusSummary() {
return analyticsService.summariseByStatus();
}
}
مقارنة الأداء والمفاضلات
- JPQL مع إسقاط DTO — الأفضل للتجميع والتقارير؛ لا تكلفة لإنشاء الكيانات؛ يعمل جيدًا مع ذاكرة التخزين الثانوية.
- Criteria API — الأفضل للتصفية الديناميكية؛ آمن عند إعادة الهيكلة؛ أكثر تفصيلًا لكنه يقضي على أخطاء تسلسل السلاسل.
- الاستعلامات المسماة — الأفضل للمسارات الساخنة ذات البنية الثابتة؛ التحقق عند بدء التشغيل يكتشف الأخطاء المطبعية قبل وصولها للإنتاج.
- SQL الأصلي — الملاذ الأخير للميزات الخاصة بقاعدة البيانات (دوال النافذة، تلميحات الفهرس، عوامل JSON)؛ يُضحّي بقابلية النقل وتكامل ذاكرة التخزين المؤقتة.
الخلاصة
لقد جمعت كلّ أساليب الاستعلام في هذا البرنامج التعليمي في ميزة متماسكة. يتولى Criteria API البحث الديناميكي المفتوح؛ ويُشغّل JPQL مع إسقاط DTO لوحة التحليلات بوضوح وكفاءة؛ ويُغطي الاستعلام المسمى مسار لوحة الدعم الساخن مع التحقق عند بدء التشغيل؛ ويتعامل SQL الأصلي مع استعلام التقارير الذي يتطلب صياغة خاصة بالمورّد. الاختيار بينها ليس اعتباطيًا — فلكلٍّ منها ملف تكلفة/فائدة مميز. إنّ تطبيق الأداة الصحيحة لكل مشكلة هو ما يُميّز المطوّر النشط عن المطوّر الأول.