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

@ManyToMany — علاقة كثير بكثير

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

@ManyToMany — علاقة كثير بكثير

تنشأ علاقة كثير بكثير عندما يستطيع كل صف في الجدول A الارتباط بصفوف متعددة في الجدول B، وكذلك يستطيع كل صف في B الارتباط بصفوف متعددة في A. المثال الكلاسيكي هو العلاقة بين Student وCourse: يلتحق الطالب بمقررات عدة، والمقرر الواحد يضم طلابًا كثيرين. في قواعد البيانات العلائقية يتطلب ذلك جدول وصل (join table) — يُعرف أحيانًا بجدول الجسر أو جدول الترابط — يحتوي على أزواج من المفاتيح الخارجية. يستطيع Hibernate إدارة هذا الجدول تلقائيًا، أو يمكنك التحكم فيه بنفسك عندما تحتاج العلاقة إلى سمات إضافية.

التعيين الأساسي

ضع تعليق @ManyToMany على حقل المجموعة في أحد الطرفين، وأخبر Hibernate بجدول الوصل عبر @JoinTable:

import jakarta.persistence.*; import java.util.HashSet; import java.util.Set; @Entity @Table(name = "students") public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @ManyToMany @JoinTable( name = "student_course", // اسم جدول الوصل joinColumns = @JoinColumn(name = "student_id"), // FK يعود إلى Student inverseJoinColumns = @JoinColumn(name = "course_id") // FK إلى Course ) private Set<Course> courses = new HashSet<>(); // المنشئات، الحاصل، المحدِّد … }
@Entity @Table(name = "courses") public class Course { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; @ManyToMany(mappedBy = "courses") // الجانب العكسي — لا يمتلك جدول الوصل private Set<Course> students = new HashSet<>(); // المنشئات، الحاصل، المحدِّد … }

الكيان الذي يُعلن @JoinTable هو الجانب المالك. يستخدم الكيان الآخر mappedBy للإشارة إلى الحقل في المالك. ينتج Hibernate جدول الوصل في DDL على النحو التالي:

CREATE TABLE student_course ( student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES students(id), FOREIGN KEY (course_id) REFERENCES courses(id) );
لماذا Set وليس List؟ عند تعيين @ManyToMany مع List، يستخدم Hibernate استراتيجية الحقيبة (bag): عند حذف عنصر واحد يُصدر أمر DELETE ALL لذلك المالك ثم يُعيد إدراج الصفوف المتبقية. أما Set فيتجنب ذلك بإصدار أمر DELETE واحد يستهدف العنصر المحذوف فحسب. استخدم Set دائمًا في علاقات كثير بكثير.

إدارة الطرفين — دوال المساعدة

في علاقات الاتجاه المزدوج أنت مسؤول عن إبقاء كلا طرفي رسم الكائنات في الذاكرة متسقَين. دالة المساعدة على الجانب المالك هي النمط المتعارف عليه:

// داخل Student public void enrollIn(Course course) { this.courses.add(course); course.getStudents().add(this); // إبقاء الجانب العكسي متزامنًا } public void dropCourse(Course course) { this.courses.remove(course); course.getStudents().remove(this); }
احرص دائمًا على الحفاظ على كلا طرفي العلاقة الثنائية الاتجاه. عدم إضافة الكيان إلى المجموعة العكسية لا يُسبب خطأ في قاعدة البيانات — فـ Hibernate يكتب عبر الجانب المالك — لكنه يُبقي رسم الكائنات غير متسق داخل الجلسة ذاتها، مما يُفضي إلى أخطاء خفية عند استعراض المجموعة العكسية قبل إفراغ الجلسة (flush).

نوع الجلب والقيمة الافتراضية

نوع الجلب الافتراضي لـ @ManyToMany هو FetchType.LAZY، وهو ما تريده في الغالب. يؤدي التبديل إلى EAGER إلى تحميل Hibernate لجدول الوصل بأكمله مع كل أصل تلمسه، حتى وإن لم تحتج المجموعة المرتبطة.

@ManyToMany(fetch = FetchType.LAZY) // صريح، لكنه الافتراضي أصلًا @JoinTable(…) private Set<Course> courses = new HashSet<>();

عندما يحتوي جدول الوصل على أعمدة إضافية

لنفترض أن التسجيل يخزن أيضًا grade وختم زمني enrolled_at. لا يستطيع @ManyToMany البسيط تمثيل هذا لأنه لا يستطيع تعيين أعمدة إضافية في جدول الوصل. الحل هو ترقية جدول الوصل إلى كيان مستقل:

@Entity @Table(name = "enrollments") public class Enrollment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne @JoinColumn(name = "student_id") private Student student; @ManyToOne @JoinColumn(name = "course_id") private Course course; private String grade; @Column(name = "enrolled_at") private LocalDateTime enrolledAt; // المنشئات، الحاصل، المحدِّد … }

الآن لدى Student علاقة @OneToMany نحو Enrollment، وكذلك Course. يُعرف هذا النمط بـكيان الوصل (join entity) أو كيان الترابط (association entity) وهو في الغالب الخيار الأفضل على المدى البعيد — إذ تكاد جداول الوصل في الأنظمة الحقيقية تنمو لتكتسب سمات إضافية بمرور الوقت.

المفتاح المركّب مقابل المفتاح البديل على كيان الوصل. قد يغريك استخدام @EmbeddedId مؤلَّف من كلا المفتاحَين الخارجيَّين. هذا يعمل لكنه أطول بكثير في التعبير. استخدام مفتاح بديل بسيط مُولَّد بـ @GeneratedValue (كما في المثال أعلاه) أوضح ويُحقق أداءً مماثلًا. اختر المفتاح المركَّب فقط عندما يكون للمفتاح الطبيعي معنى خارج Hibernate.

الحذف والتتالي

بصورة افتراضية، لا ينتشر حفظ أو حذف Student إلى صفوف جدول الوصل أو إلى كيانات Course. يمكنك تفعيل التتالي لعمليات بعينها:

@ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable(…) private Set<Course> courses = new HashSet<>();
لا تستخدم CascadeType.REMOVE في علاقة كثير بكثير أبدًا. سيؤدي تتالي الحذف عبر الجانب المالك إلى حذف الكيانات المرتبطة نفسها (صفوف Course ذاتها) وليس صفوف جدول الوصل فحسب، مما يُدمّر بيانات يشاركها كيانات أخرى. لحذف العلاقة فقط، استدع دالة المساعدة التي تُزيل العنصر من كلتا المجموعتين ودع Hibernate يحذف صف جدول الوصل اليتيم.

الاستعلام

الانضمام عبر علاقة كثير بكثير في JPQL أمر مباشر؛ يُترجم Hibernate اجتياز المجموعة تلقائيًا إلى انضمام عبر جدول الوصل:

// إيجاد جميع الطلاب المسجَّلين في مقرر بعينه List<Student> students = em.createQuery( "SELECT s FROM Student s JOIN s.courses c WHERE c.title = :title", Student.class) .setParameter("title", "Algorithms") .getResultList();

لا تحتاج إلى الإشارة إلى جدول الوصل باسمه في JPQL — فـ Hibernate يعرفه من بيانات التعيين الوصفية. وهذا أحد مزايا الإنتاجية عند العمل على مستوى الكائنات بدلًا من مستوى SQL.

الخلاصة

يُعيِّن @ManyToMany علاقة كثير بكثير ثنائية الاتجاه عبر جدول وصل تديره Hibernate تلقائيًا. يُعلن الجانب المالك @JoinTable؛ ويستخدم الجانب العكسي mappedBy. استخدم Set بدلًا من List لنوع المجموعة، وأبقِ كلا جانبَي الرسم في الذاكرة متسقَين عبر دوال المساعدة، ولا تُتلي عملية REMOVE أبدًا عبر هذا الترابط. عندما يحتاج جدول الوصل إلى سمات إضافية، استبدل التعليق بكيان وصل مخصص مدعوم بعلاقتَي @ManyToOne — وهذا هو الخيار الصائب دائمًا تقريبًا في أنظمة الإنتاج.