Spring Data JPA

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

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

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

يغطي JPQL الغالبية العظمى من الاستعلامات اليومية، إلا أن هناك حالات تحتاج فيها إلى تجاوز طبقة التجريد وكتابة SQL خام: دوال خاصة بقاعدة البيانات، ودوال النوافذ، والتعبيرات الجدولية المشتركة المتكررة (CTEs)، أو الاستعلامات الحرجة الأداء التي لا تستطيع آلية توليد SQL في Hibernate مجاراتها. يجعل Spring Data JPA ذلك سهلًا من خلال @Query(nativeQuery = true). في الوقت ذاته، كثيرًا ما تحتاج فقط إلى شريحة من كيانك — بضعة أعمدة بدلًا من الرسم البياني الكامل للكائنات. تتيح لك الإسقاطات التعبير عن هذه الحاجة بشكل واضح مع فوائد أداء قابلة للقياس.

متى تستخدم الاستعلامات الأصيلة

قبل اللجوء إلى SQL الأصيل، اسأل نفسك: هل يستطيع JPQL أو الدوال المشتقة التعبير عن هذا؟ إن كانت الإجابة نعم، فضّل ذلك — فـ JPQL مستقل عن قاعدة البيانات ويعمل مع ذاكرة التخزين المؤقت الأولى في Hibernate. استخدم الاستعلامات الأصيلة عندما تحتاج إلى:

  • صيغة خاصة بمورّد معين (REGEXP_REPLACE، GENERATE_SERIES، دوال النوافذ مثل ROW_NUMBER() OVER).
  • التعبيرات الجدولية المشتركة المتكررة (WITH RECURSIVE).
  • عمليات INSERT … SELECT أو MERGE الضخمة.
  • استدعاء الإجراءات المخزّنة التي لا يوجد لها ما يعادلها في JPQL.
  • الحالات التي يكون فيها SQL المُولَّد من Hibernate أبطأ بشكل موثَّق من استعلام محسَّن يدويًا بعد التنميط.
تجاوز ذاكرة التخزين المؤقت: تتجاوز الاستعلامات الأصيلة ذاكرة تخزين الاستعلامات في Hibernate وقد تتفاعل بشكل غير متوقع مع ذاكرة التخزين المؤقت الثانية. بعد تحديث أو حذف أصيل ضخم، استدع entityManager.clear() أو أبطل منطقة التخزين المؤقت المتأثرة لتجنب القراءات القديمة.

كتابة استعلام أصيل باستخدام @Query

أضف nativeQuery = true إلى التعليق التوضيحي واكتب SQL كما تتوقعه قاعدة بياناتك تمامًا:

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> { // SQL أصيل عادي — يُعيد كيانات Order مُحمَّلة بالكامل @Query(value = """ SELECT o.* FROM orders o WHERE o.status = :status AND o.created_at >= NOW() - INTERVAL '7 days' ORDER BY o.total_amount DESC LIMIT :limit """, nativeQuery = true) List<Order> findRecentByStatus(@Param("status") String status, @Param("limit") int limit); }

يعمل نوع الإرجاع List<Order> لأن الاستعلام يختار جميع الأعمدة (o.*) ويستطيع Hibernate تعيين مجموعة النتائج إلى كيان Order. إذا اختار SQL الخاص بك أعمدة محددة فقط أو استخدم أسماء مستعارة مختلفة، فإن تعيين الكيان يفشل — وهذه بالضبط المشكلة التي تحلها الإسقاطات.

الاستعلامات الأصيلة المسمّاة (موضع بديل)

لإعادة الاستخدام أو لإبقاء SQL خارج واجهات المستودعات، صرّح بالاستعلامات الأصيلة المسمّاة على فئة الكيان باستخدام @NamedNativeQuery:

import jakarta.persistence.Entity; import jakarta.persistence.NamedNativeQuery; import jakarta.persistence.SqlResultSetMapping; import jakarta.persistence.ConstructorResult; import jakarta.persistence.ColumnResult; @Entity @NamedNativeQuery( name = "Order.summaryByMonth", query = "SELECT DATE_TRUNC('month', created_at) AS month, " + " COUNT(*) AS order_count, SUM(total_amount) AS revenue " + "FROM orders GROUP BY 1 ORDER BY 1 DESC", resultSetMapping = "OrderMonthlySummaryMapping" ) @SqlResultSetMapping( name = "OrderMonthlySummaryMapping", classes = @ConstructorResult( targetClass = OrderMonthlySummaryDto.class, columns = { @ColumnResult(name = "month", type = java.time.LocalDate.class), @ColumnResult(name = "order_count", type = Long.class), @ColumnResult(name = "revenue", type = java.math.BigDecimal.class) } ) ) public class Order { /* … */ }
فضّل إسقاطات الواجهات على @SqlResultSetMapping: يُعدّ @SqlResultSetMapping مطوَّلًا ومن حقبة XML. في الكود الجديد، إسقاطات واجهات Spring Data (المبيّنة أدناه) أكثر إيجازًا وبنفس الكفاءة.

الإسقاطات: المشكلة التي تحلّها

تخيّل كيان Order يحتوي على 20 عمودًا بما في ذلك حقل ملاحظات @Lob وعدة ارتباطات @ManyToOne. نقطة نهاية واجهة المستخدم التي تحتاج فقط إلى id وstatus وtotalAmount لعرض قائمة لا ينبغي لها جلب كل ذلك. تتيح لك الإسقاطات تحديد الأعمدة التي تريدها بالضبط، ويُولّد Hibernate استعلام SELECT يجلب تلك الأعمدة فقط.

إسقاطات الواجهات (المغلقة)

عرّف واجهة Java بأساليب getter تتطابق مع أسماء خصائص الكيان. يُولّد Spring Data وكيلًا (proxy) في وقت التشغيل:

// واجهة الإسقاط — لا يلزم أي تنفيذ public interface OrderSummary { Long getId(); String getStatus(); java.math.BigDecimal getTotalAmount(); }
public interface OrderRepository extends JpaRepository<Order, Long> { // استعلام مشتق — يُولّد Spring Data: // SELECT o.id, o.status, o.total_amount FROM orders o WHERE o.status = ? List<OrderSummary> findByStatus(String status); // يعمل مع @Query أيضًا @Query("SELECT o.id AS id, o.status AS status, o.totalAmount AS totalAmount " + "FROM Order o WHERE o.customer.id = :customerId") List<OrderSummary> findSummariesByCustomerId(@Param("customerId") Long customerId); }

عند استخدام @Query مع إسقاط، أضف اسمًا مستعارًا لكل تعبير محدد يطابق اسم الخاصية من واجهة الإسقاط بالضبط (AS id، AS totalAmount). يُطابق Hibernate الأعمدة بأساليب getter عن طريق الاسم المستعار.

إسقاطات الواجهات (المفتوحة) — تعبيرات SpEL

يمكن للإسقاطات المفتوحة دمج القيم أو حسابها باستخدام لغة تعبير Spring:

public interface OrderSummaryWithLabel { Long getId(); String getStatus(); java.math.BigDecimal getTotalAmount(); // الهدف في SpEL هو كائن الكيان @Value("#{target.customer.firstName + ' ' + target.customer.lastName}") String getCustomerFullName(); }
الإسقاطات المفتوحة تُحمّل الكيان بالكامل. لأن تعبير SpEL يُقيَّم على كائن الكيان الفعلي، يجب على Hibernate جلب السطر الكامل (والارتباطات المُشار إليها) لتقييمه. تضيع ميزة تقليل الأعمدة. استخدم الإسقاطات المفتوحة باعتدال — فقط عندما تحتاج حقًا إلى قيم محسوبة لا يمكن التعبير عنها كاسم مستعار في JPQL.

إسقاطات DTO (المستندة إلى الفئات)

إذا أردت DTO محددًا وغير قابل للتغيير بدلًا من وكيل، استخدم سجل Java (record) أو فئة مع تعبير بنّاء JPQL:

// سجل Java — كائن قيمة غير قابل للتغيير public record OrderSummaryDto(Long id, String status, java.math.BigDecimal totalAmount) {}
public interface OrderRepository extends JpaRepository<Order, Long> { @Query("SELECT new com.example.shop.dto.OrderSummaryDto(o.id, o.status, o.totalAmount) " + "FROM Order o WHERE o.status = :status") List<OrderSummaryDto> findDtoByStatus(@Param("status") String status); }

يستدعي تعبير new في JPQL بنّاء DTO مباشرةً. يختار الاستعلام الأعمدة الثلاثة المعيَّنة فقط — دون تكلفة وكيل، ودون استدعاء انعكاس لكل getter. هذا هو الخيار الأفضل عندما يُسلسَل DTO إلى JSON في استجابة REST، لأن Jackson يعمل بشكل أفضل مع الأنواع الملموسة من وكلاء Hibernate.

الإسقاطات مع الاستعلامات الأصيلة

تعمل إسقاطات الواجهات أيضًا مع nativeQuery = true. أضف أسماء مستعارة لأعمدة SQL لتتطابق مع أسماء getters في الإسقاط:

public interface ProductRevenueSummary { String getProductName(); Long getUnitsSold(); java.math.BigDecimal getTotalRevenue(); }
public interface ProductRepository extends JpaRepository<Product, Long> { @Query(value = """ SELECT p.name AS productName, SUM(oi.qty) AS unitsSold, SUM(oi.price * oi.qty) AS totalRevenue FROM order_items oi JOIN products p ON p.id = oi.product_id GROUP BY p.id, p.name ORDER BY totalRevenue DESC """, nativeQuery = true) List<ProductRevenueSummary> findRevenueByProduct(); }
حساسية حالة الاسم المستعار للعمود: تُعيد بعض قواعد البيانات (MySQL وMariaDB) الأسماء المستعارة كما كُتبت؛ وتحوّل أخرى الحروف إلى حالة صغيرة. إذا أعاد getter في الإسقاط قيمة null، تحقق من أن الاسم المستعار في SQL يطابق اسم getter بالضبط (مطابقة حساسة لحالة الأحرف مقابل اسم getter بنمط camelCase مع إزالة "get").

مقارنة مفاضلات الأداء

  • جلب الكيان الكامل: تُحمَّل جميع الأعمدة؛ يُتتبَّع الكيان بواسطة سياق المثابرة؛ مثالي لعمليات التحديث والحذف.
  • إسقاط الواجهة المغلقة: تُجلب فقط الأعمدة المحددة من قاعدة البيانات؛ الوكيل يُغلّف مجموعة — تكلفة CPU طفيفة لكل استدعاء getter، لكن مكسب كبير في عرض النطاق الترددي على الكيانات العريضة.
  • إسقاط DTO (سجل/فئة): تُجلب الأعمدة المحددة فقط؛ تعيين بالبنّاء؛ لا وكيل؛ لا تتبع بسياق المثابرة. الأفضل لاستجابات API للقراءة فقط.
  • إسقاط الواجهة المفتوحة: يُحمَّل الكيان الكامل داخليًا؛ يُقيَّم SpEL؛ لا ميزة تقليل أعمدة — استخدمه فقط عند الحاجة إلى خصائص محسوبة.

الخلاصة

تمنحك الاستعلامات الأصيلة مخرجًا إلى SQL الخام عندما لا يستطيع JPQL التعبير عما تحتاجه، مع البقاء متكاملًا تمامًا مع نموذج مستودع Spring Data. تُحدّد الإسقاطات — سواء كانت مستندة إلى واجهات أو DTOs — استعلاماتك لاسترداد البيانات التي يستهلكها المُستدعي فعلًا فقط، مما يقلل من إدخال/إخراج قاعدة البيانات وتخصيص الكائنات. ادمجهما: استعلام تجميع أصيل يُعيد إسقاط واجهة هو نمط نظيف وعالي الأداء لنقاط نهاية التقارير. في الدرس القادم ستضيف التصفح الصفحي والترتيب إلى هذه الاستعلامات، مما يتيح نقاط نهاية قوائم فعّالة بغض النظر عن حجم مجموعة البيانات.