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

استعلامات SQL الأصلية

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

استعلامات SQL الأصلية

تُغطّي JPQL وواجهة Criteria API الغالبية العظمى من احتياجات الاستعلام في تطبيقات JPA. لكن كل طبقة تجريد لها سقف، وJPA ليست استثناءً. حين تحتاج إلى دالة نافذة (window function)، أو CTE تكراري، أو تعبير بحث نصي كامل، أو تلميح خاص بقاعدة البيانات، أو عملية مجمّعة تتجاوز ORM كليًا، عليك النزول إلى SQL الأصلية. يجعل Spring Boot 3 وHibernate 6 هذا الانتقال سلسًا مع إبقاء تعيين النتائج تحت سيطرتك الكاملة.

متى تلجأ إلى SQL الأصلية

قبل كتابة استعلام أصلي، تأكد من عجز JPQL أو Criteria عن تلبية الحاجة. إن انطبق أيٌّ مما يلي، فالSQL الأصلية هي الأداة المناسبة:

  • تحتاج إلى ميزة خاصة بالمورّد — مثل DISTINCT ON في PostgreSQL، أو GROUP_CONCAT في MySQL، أو CROSS APPLY في SQL Server.
  • يستخدم الاستعلام دوال النوافذ (ROW_NUMBER()، وRANK()، وLEAD()/LAG()) التي لا تستطيع JPQL التعبير عنها.
  • تحتاج إلى CTE تكراري لاجتياز شجرة أو تسلسل هرمي مخزون في جدول ذاتي المرجع.
  • يجب تنفيذ استعلام قدّمه مسؤول قاعدة البيانات مع تلميحات للمحسّن (USE INDEX، NOLOCK) حرفيًا.
  • عملية UPDATE أو DELETE مجمّعة على ملايين الصفوف حيث يكون تحميل الكيانات مُكلفًا جدًا.
  • استعلام تقرير يربط جداول عديدة في إسقاط مسطّح — أسرع في الكتابة وأسرع في التنفيذ.
SQL الأصلية ليست بديلًا عن JPQL البطيئة. إن كان استعلام JPQL بطيئًا، أضف فهرسًا أو أعد كتابته — لا تنتقل إلى SQL الأصلية فقط للشعور بالقرب من قاعدة البيانات. استخدمها حين تحتاج فعلًا لما لا تستطيع JPQL التعبير عنه.

إنشاء استعلام أصلي عبر EntityManager

تعرض EntityManager الدالة createNativeQuery(sql). في أبسط صورها، تُعاد النتائج كصفوف من نوع Object[]:

import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; import org.springframework.stereotype.Repository; import java.util.List; @Repository public class OrderNativeRepository { @PersistenceContext private EntityManager em; @SuppressWarnings("unchecked") public List<Object[]> findRecentOrderSummaries(int limit) { String sql = """ SELECT o.id, c.full_name, SUM(oi.unit_price * oi.quantity) AS total, COUNT(oi.id) AS item_count FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id, c.full_name ORDER BY o.created_at DESC LIMIT :lim """; return em.createNativeQuery(sql) .setParameter("lim", limit) .getResultList(); } }

كل عنصر في القائمة عبارة عن Object[] تتوافق مؤشراته مع أعمدة SELECT بالترتيب. افضّل دائمًا المعاملات المُسمّاة (:lim) على المعاملات الموضعية (?1) لمزيد من القابلية للقراءة وتجنّب أخطاء التعداد.

لا أمان على مستوى الترجمة لترتيب الأعمدة. إن غيّرت قائمة SELECT دون تحديث الكود المستهلك، سيقرأ التطبيق قيمًا خاطئة دون أي تحذير. استخدام تعيين مجموعة النتائج (موضّح أدناه) أو إسقاط DTO يزيل هذا الهشاشة.

تعيين النتائج إلى فئة كيان

حين يختار الاستعلام الأصلي كل أعمدة جدول واحد، مرّر فئة الكيان كوسيطة ثانية لـ createNativeQuery وسيعيّن Hibernate الأعمدة إلى نسخ كيانات مُدارة تلقائيًا:

@SuppressWarnings("unchecked") public List<Order> findOrdersByStatus(String status) { return em.createNativeQuery( "SELECT * FROM orders WHERE status = :status ORDER BY created_at DESC", Order.class) .setParameter("status", status) .getResultList(); }

الكائنات المُعادة مُدارة بالكامل: يمكن تهيئة الارتباطات الكسولة، وتُحترم حقول @Version، وأي تعديلات داخل المعاملة ذاتها ستُنظَّف. هذه أنظف صورة للاستعلام الأصلي حين يمسّ SQL جدولًا مُعيَّنًا واحدًا.

SqlResultSetMapping — تعيين الأعمدة إلى الحقول يدويًا

حين يضمّ الاستعلام الأصلي جداول متعددة أو يستخدم أعمدة محسوبة، تحتاج إلى @SqlResultSetMapping لإخبار Hibernate بكيفية بناء النتيجة. ضعه على أي فئة كيان (اسمه عالمي):

import jakarta.persistence.*; @Entity @Table(name = "orders") @SqlResultSetMapping( name = "OrderSummaryMapping", classes = @ConstructorResult( targetClass = OrderSummaryDTO.class, columns = { @ColumnResult(name = "id", type = Long.class), @ColumnResult(name = "full_name", type = String.class), @ColumnResult(name = "total", type = Double.class), @ColumnResult(name = "item_count", type = Long.class) } ) ) public class Order { // ... حقول الكيان ... }

مرّر الآن اسم التعيين كوسيطة ثالثة:

@SuppressWarnings("unchecked") public List<OrderSummaryDTO> findOrderSummaries(int limit) { String sql = """ SELECT o.id, c.full_name, SUM(oi.unit_price * oi.quantity) AS total, COUNT(oi.id) AS item_count FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id, c.full_name ORDER BY total DESC LIMIT :lim """; return em.createNativeQuery(sql, "OrderSummaryMapping") .setParameter("lim", limit) .getResultList(); }

يستدعي @ConstructorResult الدالة new OrderSummaryDTO(id, fullName, total, itemCount) لكل صف. OrderSummaryDTO سجل Java أو فئة عادية — لا حاجة لأن يكون كيان JPA.

الاستعلامات الأصلية المُسمّاة

تمامًا كـ JPQL، يمكن تصريح SQL الأصلية وتجميعها مسبقًا عند بدء التشغيل باستخدام @NamedNativeQuery. هذا يُخرج SQL من سلاسل نصية داخل Java إلى موضع مركزي واضح:

@NamedNativeQuery( name = "Order.topByRevenue", query = """ SELECT o.id, c.full_name, SUM(oi.unit_price * oi.quantity) AS total, COUNT(oi.id) AS item_count FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id, c.full_name ORDER BY total DESC LIMIT :lim """, resultSetMapping = "OrderSummaryMapping" ) @Entity @Table(name = "orders") public class Order { /* ... */ }
// الاستخدام return em.createNamedQuery("Order.topByRevenue", OrderSummaryDTO.class) .setParameter("lim", 10) .getResultList();

الاستعلامات الأصلية في Spring Data JPA باستخدام @Query

إن كنت تستخدم مستودعات Spring Data، أضف nativeQuery = true إلى تعليقة @Query. يتولى Spring Data الباقي:

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> { @Query(value = """ SELECT o.id, c.full_name AS fullName, SUM(oi.unit_price * oi.quantity) AS total FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id, c.full_name ORDER BY total DESC LIMIT :limit """, nativeQuery = true) List<OrderSummaryProjection> findTopOrderSummaries(@Param("limit") int limit); }

نوع الإعادة OrderSummaryProjection هو إسقاط قائم على الواجهة في Spring Data — واجهة أسماء getter فيها تطابق أسماء مستعارة الأعمدة في SELECT:

public interface OrderSummaryProjection { Long getId(); String getFullName(); Double getTotal(); }

يُولّد Spring Data وكيلًا (proxy) في وقت التشغيل يقرأ الأعمدة المقابلة. هذا أكثر الأساليب إيجازًا ويتجنّب @SqlResultSetMapping كليًا.

افضّل الإسقاطات القائمة على الواجهة مع الاستعلامات الأصلية في Spring Data. الربط بين getter والاسم المستعار آمن عند إعادة التسمية (سيُحذّرك IDE من التعارضات)، وتحصل على نتيجة مكتوبة دون أي ضبط إضافي للتعليقات.

التصفيح مع الاستعلامات الأصلية

دعم Pageable التلقائي في Spring Data لا يعمل مع الاستعلامات الأصلية لأن الإطار لا يستطيع تغليف SQL عشوائية بشكل موثوق في استعلام عدّ. قدّم countQuery صراحةً:

@Query( value = """ SELECT o.id, c.full_name AS fullName, SUM(oi.unit_price * oi.quantity) AS total FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id, c.full_name ORDER BY total DESC """, countQuery = """ SELECT COUNT(DISTINCT o.id) FROM orders o JOIN order_items oi ON oi.order_id = o.id """, nativeQuery = true ) Page<OrderSummaryProjection> findPagedOrderSummaries(Pageable pageable);

ذاكرة التخزين المؤقت من المستوى الأول والاستعلامات الأصلية

تفصيل جوهري في Hibernate: الاستعلامات الأصلية تتجاوز ذاكرة التخزين المؤقت من المستوى الأول (سياق الاستمرارية). إن حمّلت كيانًا ثم نفّذت UPDATE أصليًا على الصف ذاته دون flush، ستصبح حالة Hibernate في الذاكرة قديمة. نظّف EntityManager دائمًا قبل تشغيل استعلام كتابة أصلي، أو اعمل داخل معاملة جديدة:

// نظّف التغييرات المعلّقة قبل الكتابة الأصلية em.flush(); int updated = em.createNativeQuery( "UPDATE orders SET status = 'ARCHIVED' WHERE created_at < :cutoff") .setParameter("cutoff", LocalDate.now().minusYears(2)) .executeUpdate(); // امسح الذاكرة المؤقتة حتى تعكس القراءات اللاحقة الحالة الجديدة em.clear();
بعد أي كتابة مجمّعة أصلية، استدع em.clear(). إن لم تفعل، سيعكس أي كيان موجود في سياق الاستمرارية الحالةَ القديمة، مما يُفضي إلى تناقضات صامتة في البيانات داخل المعاملة ذاتها.

المفاضلات في الأداء

  • لا حمل من dirty checking — الكيانات المُعادة من الاستعلامات الأصلية لا تُتابَع تلقائيًا ما لم تستخدم التحميل بفئة الكيان مع صفوف كاملة الأعمدة.
  • تحكم دقيق بالSQL — يمكنك إضافة تلميحات للفهارس، وتجنّب الضمّات الضمنية التي تُنشئها ORM، واختيار الأعمدة التي تحتاجها فقط.
  • تكلفة قابلية النقل — SQL الخاصة بمورّد مُعيَّن تربطك بذلك المورّد. وثّق كل استعلام أصلي مع سبب عجز JPQL عنه.
  • خطر انجراف المخطط — تُشير SQL الأصلية مباشرةً إلى أسماء الجداول والأعمدة. هجرة إعادة التسمية التي تُحدّث تعليقات الكيانات لا تُحدّث سلاسل الاستعلامات الأصلية تلقائيًا.

الخلاصة

الاستعلامات الأصلية بالSQL هي مخرج الطوارئ حين تصل JPQL إلى حدودها. استخدم createNativeQuery على EntityManager مباشرةً، أو علّق دوال المستودع بـ @Query(nativeQuery = true). عيّن النتائج إلى فئات الكيانات للاستعلامات أحادية الجدول، وإلى إسقاطات قائمة على الواجهة للتجميعات متعددة الجداول، وإلى تعيينات @ConstructorResult حين تحتاج إلى أقصى قدر من التحكم. نظّف دائمًا قبل الكتابات الأصلية وامسح الذاكرة المؤقتة بعدها، ووثّق سبب وجود كل استعلام أصلي حتى يفهم المطوّرون المستقبليون النيّة من ورائه.