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

الجانب المالك مقابل الجانب العكسي (mappedBy)

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

الجانب المالك مقابل الجانب العكسي (mappedBy)

عندما تُنمذج علاقةً ثنائية الاتجاه في JPA — مثلاً Customer يحمل قائمةً من كائنات Order، وكل Order يحمل مرجعًا عكسيًا إلى Customer الخاص به — يجب أن يعرف Hibernate أيّ الحقلين في Java يقابل عمود المفتاح الأجنبي الفعلي في قاعدة البيانات. هذا هو السؤال الوحيد الذي تُجيب عنه ثنائية الجانب المالك / الجانب العكسي، والخطأ فيها هو أحد أكثر الأسباب شيوعًا لتجاهل تغييرات الارتباطات بصمت عند وقت الإفراغ (flush).

القاعدة الأساسية

في كل علاقة ثنائية الاتجاه يوجد جانبان بالضبط:

  • الجانب المالك — الحقل الذي يحمل عمود المفتاح الأجنبي الفعلي. يقرأ Hibernate هذا الحقل ليقرر ما يكتبه في قاعدة البيانات. يُزيَّن هذا الجانب بـ @JoinColumn (لـ @ManyToOne / @OneToOne) أو @JoinTable (لـ @ManyToMany).
  • الجانب العكسي — الحقل "المرآة" على الكيان الآخر. يوجد فقط للتنقل في رسم كائنات Java. يُهمل Hibernate التغييرات التي تُجرى حصرًا على الجانب العكسي عند الكتابة إلى قاعدة البيانات. يُعلَن عن هذا الجانب بالخاصية mappedBy.
قاعدة mappedBy في جملة واحدة: mappedBy = "fieldName" تقول "المفتاح الأجنبي لهذه العلاقة تديره الحقل المسمى fieldName على الكيان الآخر — أنا مجرد مرآة للقراءة فحسب."

مثال ثنائي الاتجاه: @ManyToOne / @OneToMany

هذا هو أكثر أنواع العلاقات ثنائية الاتجاه شيوعًا. عميل (Customer) لديه طلبات عديدة (Order)؛ وكل طلب ينتمي إلى عميل واحد. يقع المفتاح الأجنبي (customer_id) في جدول orders، لذا فإن حقل Order.customer هو الجانب المالك.

// ---- Order.java (الجانب المالك — يحمل عمود FK) ---- @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String reference; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") // ← يُعلن عمود FK private Customer customer; // getters وsetters … } // ---- Customer.java (الجانب العكسي — mappedBy يعكس Order.customer) ---- @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToMany(mappedBy = "customer") // ← "أنا مُدار من قِبَل Order.customer" private List<Order> orders = new ArrayList<>(); // getters وsetters … }

ما يحدث إذا ضبطتَ الجانب العكسي فقط

هذا هو الفخ الكلاسيكي. يُضيف مطوّر طلبًا إلى قائمة العميل لكنه ينسى تعيين المرجع العكسي على الطلب:

// خطأ — يُحدَّث الجانب العكسي فقط customer.getOrders().add(order); entityManager.persist(order); // يتجاهل Hibernate التغيير الذي أُجري على customer.orders عند الإفراغ. // يبقى customer_id في صف orders فارغًا (NULL).

لأن customer.orders هو الجانب العكسي (يحمل mappedBy)، فإن Hibernate ببساطة لا يفحصه عند توليد SQL. الحل دائمًا هو تعيين الجانبين معًا:

// صحيح — تعيين الجانب المالك (order.customer) order.setCustomer(customer); customer.getOrders().add(order); // يُبقي رسم الكائنات في الذاكرة متسقًا entityManager.persist(order); // يقرأ Hibernate الحقل order.customer → INSERT INTO orders (customer_id, …) VALUES (…)
أخفِ مزامنة الجانبين في دالة مساعدة. أضف دالةً مريحة على الكيان تُعيّن الجانبين معًا دائمًا، واستدع تلك الدالة فقط من كود التطبيق:
// داخل Customer.java public void addOrder(Order order) { orders.add(order); // الجانب العكسي — يحافظ على اتساق رسم الكائنات في الذاكرة order.setCustomer(this); // الجانب المالك — ما يكتبه Hibernate فعلًا }
يُلغي هذا النمط فئةً كاملةً من أخطاء "بياناتي لا تُحفظ".

علاقة @OneToOne ثنائية الاتجاه

تنطبق القاعدة ذاتها. اختر كيانًا واحدًا ليحمل عمود FK وزيّن الآخر بـ mappedBy:

@Entity public class Employee { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "passport_id", unique = true) // الجانب المالك private Passport passport; } @Entity public class Passport { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne(mappedBy = "passport") // الجانب العكسي private Employee employee; }

علاقة @ManyToMany ثنائية الاتجاه

في علاقة كثير-إلى-كثير، يقع المفتاح الأجنبي في جدول ربط. يحتاج كيان واحد فقط إلى تعريف جدول الربط بـ @JoinTable؛ بينما يستخدم الآخر mappedBy:

@Entity public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToMany @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set<Course> courses = new HashSet<>(); } @Entity public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToMany(mappedBy = "courses") // الجانب العكسي — جدول الربط يديره Student private Set<Student> students = new HashSet<>(); }
كثير-إلى-كثير: حدِّث دائمًا الجانب المالك (Student.courses). إضافة طالب إلى course.students وحدها لن تُدرج صفًا في جدول الربط student_course أبدًا، لأن course.students هو الجانب العكسي. أضف دائمًا المقرر إلى مجموعة الطالب (أو استخدم دالةً مساعدةً ثنائية الجانب) حتى يرى Hibernate التغيير على الجانب المالك.

اختيار الجانب الذي يملك المفتاح الأجنبي

في علاقة @ManyToOne / @OneToMany يكون الاختيار طبيعيًا: يقع عمود FK دائمًا على جانب "الكثير" في الجدول، لذا فإن حقل @ManyToOne في كيان "الكثير" هو دائمًا الجانب المالك. لا يمكنك عكس ذلك دون إعادة هيكلة المخطط.

في علاقات @OneToOne و@ManyToMany لديك مرونة أكبر. اتفاق شائع هو وضع الجانب المالك على الكيان الأكثر "تبعيةً" أو "أبناءً" — الذي لا يمكنه الوجود دون الآخر. بهذه الطريقة يقع المفتاح الأجنبي أو جدول الربط بشكل طبيعي بجانب البيانات التي تعتمد عليه.

مرجع سريع: قواعد mappedBy

  • يستخدم mappedBy جانب واحد بالضبط في كل زوج ثنائي الاتجاه؛ بينما يملك الجانب الآخر المفتاح الأجنبي.
  • قيمة mappedBy هي اسم الحقل على الكيان المالك، وليس اسم العمود.
  • يجب أن لا يحمل الجانب الذي يستخدم mappedBy أيضًا @JoinColumn أو @JoinTable — فهذه تنتمي إلى الجانب المالك.
  • التغييرات التي تُجرى فقط على الجانب العكسي (mappedBy) يتجاهلها Hibernate بصمت عند وقت الإفراغ.
  • زامن كلا الجانبين في الذاكرة دائمًا، حتى لو كان الجانب المالك فقط هو الذي يقود SQL — فالحالة القديمة في الذاكرة تُسبب أخطاءً صعبة التتبع في الذاكرة المخبئية ذات المستوى الثاني أو في عمليات القراءة ضمن المعاملة.

الخلاصة

الجانب المالك هو الحقل الذي يحمل عمود المفتاح الأجنبي (مُزيَّن بـ @JoinColumn أو @JoinTable). الجانب العكسي يُعلَن بـ mappedBy وهو مجرد وسيلة تنقل في Java — لا يكتب Hibernate شيئًا في قاعدة البيانات بناءً عليه. إن فهم هذا التمييز يمنع أكثر أخطاء JPA شيوعًا: تحديث الجانب العكسي فقط وعدم رؤية أي تغيير في قاعدة البيانات. استخدم دوالًا مساعدةً تُزامن كلا الجانبين معًا، ولن تعاني من هذا مرةً أخرى.