علاقات الكيانات والارتباطات

@OneToMany و @ManyToOne

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

@OneToMany و @ManyToOne

يُعدّ الزوج @OneToMany / @ManyToOne أكثر العلاقات استخدامًا في أي نموذج نطاق علائقي. العميل يُقدّم طلبات متعددة؛ القسم يضم موظفين كثيرين؛ المنشور يحمل تعليقات عديدة. إتقان تعيين هذه العلاقة بصورة صحيحة — وتجنب الأخطاء الشائعة — سيخدمك في كل مشروع Spring Boot تبنيه.

نموذج النطاق

سنستخدم طوال هذا الدرس نطاقًا للتجارة الإلكترونية. يستطيع Customer تقديم طلبات متعددة من نوع Order، وكل Order يعرف أي Customer تنتمي إليه. يُمثَّل هذا في قاعدة البيانات العلائقية بعمود مفتاح خارجي customer_id في جدول orders — عمود وحيد يشير إلى جدول customers.

تعيين الجانب المتعدد: @ManyToOne

الجانب المتعدد — الكيان الذي يحمل المفتاح الخارجي — هو دائمًا الجانب المالك للعلاقة. يُعيَّن باستخدام @ManyToOne مع @JoinColumn:

package com.example.shop.domain; import jakarta.persistence.*; import java.math.BigDecimal; import java.time.Instant; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private Instant placedAt = Instant.now(); @Column(nullable = false, precision = 10, scale = 2) private BigDecimal total; // الجانب المالك — هذا العمود موجود في جدول "orders" @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id", nullable = false) private Customer customer; // المنشئات والمُسرِّعات والمُعيِّنات محذوفة للإيجاز }

القرارات الرئيسية على @ManyToOne:

  • fetch = FetchType.LAZY — لا تُحمَّل Customer حتى يُصل إليها فعليًا. هذا أهم إعداد للأداء ويُغطَّى بعمق في الدرس السادس. حدّده دائمًا بشكل صريح ولا تعتمد على القيمة الافتراضية.
  • optional = false — يُخبر Hibernate بأن كل طلب لديه عميل دائمًا، مما يُمكّنه من استخدام inner join بدلًا من left outer join عند استعلام يتضمن هذا المسار.
  • @JoinColumn(name = "customer_id") — يُسمّي عمود المفتاح الخارجي. بدونه سيستنتج Hibernate اسمًا قد لا يطابق اتفاقيات مخططك.
الجانب المالك يتحكم في المفتاح الخارجي. عند استدعاء entityManager.persist(order)، يكتب Hibernate قيمة customer_id بناءً على حقل customer في كيان Order — لا بناءً على أي مجموعة في Customer. هذا المصدر الأكثر شيوعًا لأخطاء "أضفت إلى القائمة لكن لم يُحفظ شيء".

تعيين الجانب الفردي: @OneToMany

الجانب الفردي هو الجانب العكسي للعلاقة. يُعيَّن باستخدام @OneToMany(mappedBy = ...)، حيث يُشير mappedBy إلى اسم الحقل في الكيان المالك:

package com.example.shop.domain; import jakarta.persistence.*; import java.util.ArrayList; import java.util.Collections; import java.util.List; @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(nullable = false, unique = true) private String email; // الجانب العكسي — mappedBy = اسم الحقل في Order @OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true) private List<Order> orders = new ArrayList<>(); // أساليب مساعدة لإبقاء كلا الجانبين متزامنَين public void addOrder(Order order) { orders.add(order); order.setCustomer(this); // تعيين الجانب المالك أيضًا } public void removeOrder(Order order) { orders.remove(order); order.setCustomer(null); } public List<Order> getOrders() { return Collections.unmodifiableList(orders); } // مُسرِّعات/مُعيِّنات أخرى ... }

النقاط الحاسمة في جانب @OneToMany:

  • mappedBy = "customer" إلزامي. بدونه يُنشئ Hibernate جدول ربط منفصلًا بدلًا من استخدام المفتاح الخارجي الموجود — مما يُضاعف تعقيد المخطط دون مبرر.
  • هيِّئ المجموعة دائمًا (new ArrayList<>()). المجموعة الفارغة null تُسبب NullPointerException في أول محاولة يُهيِّئ فيها Hibernate الوكيل.
  • الأساليب المساعدة (addOrder / removeOrder) تُزامن كلا الجانبين ضمن سياق المثابرة نفسه. نسيان تعيين الجانب المالك هو سبب الصفوف الوهمية وحالة الذاكرة القديمة.
  • أرجع عرضًا غير قابل للتعديل من المُسرِّع لمنع المُستدعين من تجاوز الأساليب المساعدة وكسر الثابت.

أحادي الاتجاه مقابل ثنائي الاتجاه

لا تحتاج دائمًا إلى كلا الجانبين. يُظهر التعيين أحادي الاتجاه اتجاهًا واحدًا فقط للتصفح:

// @ManyToOne أحادي الاتجاه فقط — Order تعرف Customer الخاص بها، // لكن Customer لا تحمل مجموعة من Orders. // هذا غالبًا الخيار الصحيح عندما لا تتصفح أبدًا // من Customer إلى طلباتها في هذا السياق المحدود. @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "customer_id") private Customer customer;

يجب تجنب @OneToMany أحادي الاتجاه بدون mappedBy دائمًا تقريبًا — سيُنشئ Hibernate جدول ربط حتى لو كان عمود مفتاح خارجي كافيًا، وينتج SQL غير فعال. إذا كنت بحاجة إلى جانب المجموعة فقط، عرِّف @ManyToOne على كيان الطفل واستخدم mappedBy على الأصل.

لا تستخدم أبدًا @OneToMany أحادي الاتجاه بدون mappedBy في كود الإنتاج. يُنشئ جدول ربط مخفيًا ويُنتج جمل INSERT وDELETE إضافية ويُهدر فهرس قاعدة البيانات. اقرنه دائمًا بـ @ManyToOne على الكيان الطفل واستخدم mappedBy.

الحفظ والاستعلام

في نمط مستودع Spring Data JPA النموذجي:

// طبقة الخدمة — يجب تعيين كلا الجانبين قبل الحفظ @Transactional public Order placeOrder(Long customerId, BigDecimal total) { Customer customer = customerRepository.findById(customerId) .orElseThrow(() -> new EntityNotFoundException("Customer not found")); Order order = new Order(); order.setTotal(total); customer.addOrder(order); // يُعيّن order.customer ويُضيف إلى المجموعة customerRepository.save(customer); // يتتالى إلى Order عبر CascadeType.ALL return order; }

جلب الطلبات لعميل معين في مستودع:

// Spring Data — استعلام مشتق من حقل الجانب المالك List<Order> findByCustomerId(Long customerId); // JPQL — ربط صريح @Query("SELECT o FROM Order o WHERE o.customer.id = :customerId ORDER BY o.placedAt DESC") List<Order> findOrdersForCustomer(@Param("customerId") Long customerId);

مقايضات الأداء التي يجب معرفتها

مجموعة @OneToMany هي وكيل مجموعة Hibernate يُهيَّأ بشكل كسول افتراضيًا. هذا يعني:

  • الوصول إلى customer.getOrders() خارج معاملة نشطة سيُلقي LazyInitializationException. حمِّل دائمًا ما تحتاجه داخل المعاملة.
  • إذا حمّلت قائمة من العملاء ثم وصلت إلى طلباتهم واحدًا تلو الآخر، فأنت تُطلق مشكلة N+1 للاستعلامات — استعلام واحد لكل عميل. يُغطَّى هذا في الدروس 7 و8.
  • للمجموعات الكبيرة فكّر فيما إذا كنت تحتاج فعلًا إلى @OneToMany على الأصل، أو إذا كان الاستعلام من الجانب الطفل باستخدام @ManyToOne كافيًا.
فضِّل الاستعلام من الجانب المالك (@ManyToOne). استعلام من نوع SELECT o FROM Order o WHERE o.customer.id = :id يستخدم عمود المفتاح الخارجي المُفهرَس ولا يُحمِّل كيان Customer أو وكيل مجموعته قط. إنه دائمًا تقريبًا أسرع وأبسط من تصفح مجموعة كسولة.

الخلاصة

يُعيِّن الزوج @ManyToOne / @OneToMany عمود مفتاح خارجي واحد إلى رسم بياني ثنائي الاتجاه للكائنات. الجانب المتعدد (@ManyToOne) هو الجانب المالك — هو ما يتحكم فيما يكتبه Hibernate إلى قاعدة البيانات. الجانب الفردي (@OneToMany(mappedBy = ...)) هو الجانب العكسي — يوفر تصفحًا مريحًا لكنه لا يؤثر على المثابرة ما لم تُعيِّن الجانب المالك أيضًا. استخدم دائمًا fetch = FetchType.LAZY على @ManyToOne، وهيِّئ دائمًا مجموعاتك، واكتب دائمًا أساليب مساعدة تُبقي كلا الجانبين متزامنَين. الدرس التالي يتناول الجانب الآخر من الأساسية: @ManyToMany.