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

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

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

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

عندما تُثبّت كيانًا أو تُحدّثه أو تحذفه، غالبًا ما تحتاج إلى انتشار نفس العملية تلقائيًا عبر الارتباطات المرتبطة به. تمنحك آلية التتالي (cascade) في JPA وميزة حذف الأيتام (orphan removal) في Hibernate تحكمًا دقيقًا في كيفية انتشار عمليات دورة الحياة عبر رسم الكائنات المترابطة. إذا استُخدمت بشكل صحيح، فإنها تقلل من الكود المكرر بشكل كبير. أما إذا استُخدمت باستهتار، فقد تُفضي إلى عمليات حذف جماعية مفاجئة أو تمنع إعادة استخدام الكيانات المشتركة.

ما هو التتالي؟

كل تعليق توضيحي @OneToOne أو @OneToMany أو @ManyToOne أو @ManyToMany يقبل خاصية cascade تأخذ قيمة واحدة أو أكثر من CascadeType. عند تنفيذ عملية دورة حياة على الكيان المالك، تُطبّق JPA تلقائيًا نفس العملية على كل كيان مرتبط مُهيَّأت علاقته بنوع التتالي المطابق.

القائمة الكاملة لقيم CascadeType:

  • PERSIST — عند استدعاء em.persist(parent)، يتمّ تثبيت الأبناء أيضًا.
  • MERGE — عند استدعاء em.merge(parent)، يتمّ دمج الأبناء أيضًا.
  • REMOVE — عند استدعاء em.remove(parent)، يتمّ حذف الأبناء أيضًا.
  • REFRESH — عند استدعاء em.refresh(parent)، يتمّ تحديث الأبناء من قاعدة البيانات أيضًا.
  • DETACH — عند فصل الأب عن سياق المثابرة، يُفصَل الأبناء أيضًا.
  • ALL — ثابت مختصر يعادل تحديد الأنواع الخمسة جميعها.

CascadeType.PERSIST عمليًا

أكثر نقطة بداية شيوعًا هي PERSIST. تخيّل كيان Order يمتلك مجموعة من كيانات LineItem. بدون التتالي ستضطر إلى تثبيت كل بند بشكل فردي قبل تثبيت الطلب، لأن Hibernate سيشكو من وجود نسخة عابرة في المجموعة.

@Entity public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST, fetch = FetchType.LAZY) private List<LineItem> lineItems = new ArrayList<>(); // دالة مساعدة تُبقي الجانبين متزامنَين public void addLineItem(LineItem item) { item.setOrder(this); lineItems.add(item); } }

الآن استدعاء em.persist(order) واحد (أو في Spring Data: orderRepository.save(order)) يتدفق إلى الأسفل ويُدرج كل LineItem في القائمة:

Order order = new Order(); order.addLineItem(new LineItem("Widget A", 2, BigDecimal.valueOf(9.99))); order.addLineItem(new LineItem("Widget B", 1, BigDecimal.valueOf(24.99))); orderRepository.save(order); // حفظ واحد — ثلاثة إدراجات (1 طلب + 2 بند)

CascadeType.REMOVE ومخاطره

يُعدّ REMOVE قويًا لكنه خطير. عند حذف الأب، يُحذف كل ابن في المجموعة أيضًا. هذا صحيح لعلاقات التأليف (composition) — حيث لا يستطيع الابن الوجود بدون الأب — لكنه خاطئ لعلاقات الارتباط (association) حيث يكون الابن مشتركًا بين آباء متعددين.

@Entity public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // تأليف: التعليق ينتمي حصريًا لمقالة واحدة @OneToMany(mappedBy = "article", cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) private List<Comment> comments = new ArrayList<>(); }
لا تضع CascadeType.REMOVE (أو ALL) أبدًا على @ManyToMany أو أي علاقة يُشار فيها إلى الكيان الهدف من آباء متعددة. حذف أحد الآباء سيتتالى إلى الابن المشترك ويُفسد جميع الآباء الأخرى التي تشير إليه. احتفظ بـ REMOVE لعلاقات التأليف الصارمة بين الأب والابن فقط.

CascadeType.ALL — اختصار مع تحفّظ

CascadeType.ALL اختصار للأنواع الخمسة جميعها. وهو مناسب عندما يكون الابن مملوكًا حصريًا للأب وينبغي أن تتبع دورة حياته الأب بالكامل. مثال كلاسيكي هو Address مُضمَّنة كيانًا منفصلًا داخل Customer:

@Entity public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "address_id") private Address address; }
فكّر من منظور الملكية. اسأل نفسك: "هل يمكن لهذا الكيان الابن الوجود بشكل مستقل، أم أنه ذو معنى فقط كجزء من هذا الأب؟" إذا كانت الإجابة "فقط كجزء من الأب"، فإن CascadeType.ALL مقترنًا بـ orphanRemoval = true هو عادةً الاختيار الصحيح. أما إذا كان الابن مستقلًا أو يُشار إليه من مكان آخر، فتتالَ فقط PERSIST وMERGE.

حذف الأيتام

orphanRemoval = true ميزة في Hibernate/JPA تتجاوز CascadeType.REMOVE خطوة إضافية. بينما يُطلَق REMOVE فقط عند استدعاء remove() صراحةً على الأب، يُطلَق orphanRemoval أيضًا عند فصل الابن عن المجموعة — أي عند إزالة مرجع الابن من قائمة الأب دون حذف الأب نفسه.

@Entity public class Order { @OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) private List<LineItem> lineItems = new ArrayList<>(); } // في دالة خدمة مُعلَّمة بـ @Transactional: Order order = orderRepository.findById(orderId).orElseThrow(); order.getLineItems().removeIf(item -> item.getId().equals(lineItemId)); // لا حاجة لاستدعاء حذف صريح — يُصدر Hibernate DELETE تلقائيًا عند الفلاش

عند الالتزام بالمعاملة، يكتشف Hibernate أن LineItem لم يعد مُشارًا إليه من أي مجموعة Order ويُصدر جملة DELETE تلقائيًا. هذا يُبقي كود الخدمة نظيفًا ويُلغي الحاجة إلى حقن مستودع الابن لمجرد حذف ابن.

التتالي PERSIST + MERGE (الإعداد الافتراضي الآمن لمعظم الحالات)

لمعظم علاقات الإنتاج يُوصى بتهيئة التتالي على {CascadeType.PERSIST, CascadeType.MERGE} بدون REMOVE وبدون orphanRemoval. هذا يتيح حفظ رسم الكائنات وتحديثها بسهولة دون إطلاق عمليات حذف جماعية غير متوقعة. أضف orphanRemoval = true و/أو CascadeType.REMOVE فقط بعد أن تقرر بوعي أن العلاقة هي تأليف صارم.

// الإعداد الافتراضي الآمن لمعظم علاقات @OneToMany @OneToMany(mappedBy = "post", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY) private List<Tag> tags = new ArrayList<>();

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

يُهيَّأ التتالي دائمًا على الكيان المالك في العلاقة — الجانب الذي يحمل المفتاح الخارجي، أو في زوج @OneToMany / @ManyToOne، عادةً على جانب @OneToMany الذي يمثل المجموعة. غير أن التتالي يعمل من خلال رسم الكائنات في الذاكرة وليس قاعدة البيانات. وهذا يعني أنك يجب أن تُبقي كلا جانبي علاقة ثنائية الاتجاه متزامنَين؛ وإلا قد لا يتتالى Hibernate إلى كيانات لا يستطيع رؤيتها في المجموعة.

// استخدم دائمًا دالة مساعدة على جانب الأب للحفاظ على كلا الطرفَين public void addComment(Comment comment) { comment.setArticle(this); // ضبط جانب FK المالك this.comments.add(comment); // إضافة إلى المجموعة العكسية } public void removeComment(Comment comment) { comment.setArticle(null); this.comments.remove(comment); }

اعتبارات الأداء

يُصدر تتالي REMOVE عبر JPA جمل DELETE فردية لكل كيان ابن — جملة SQL واحدة لكل ابن. إذا كان للأب آلاف الأبناء، فهذا أبطأ بشكل درامي من جملة DELETE FROM line_items WHERE order_id = ? واحدة على مستوى قاعدة البيانات. لسيناريوهات الحذف الجماعي، يُفضَّل استخدام استعلام أصلي أو تعليق @Query بدلًا من الاعتماد على تتالي الحذف.

قِس خياراتك في التتالي. بالنسبة للمجموعات الصغيرة المحدودة (طلب بحد أقصى ~50 بندًا) يُعدّ تتالي الحذف مقبولًا. أما للمجموعات الكبيرة أو الجداول التي تحتوي على ملايين الصفوف، فاستخدم استعلام DELETE JPQL أو أصليًا موجَّهًا بدلًا من ذلك وتجنّب تتالي REMOVE.

الخلاصة

يتحكم CascadeType في عمليات دورة حياة JPA التي تنتشر تلقائيًا من كيان أب إلى أبنائه المرتبطين. يُعدّ PERSIST وMERGE آمنَين للاستخدام الواسع. أما REMOVE وCascadeType.ALL فمناسبان فقط لعلاقات التأليف الصارمة حيث لا يستطيع الابن تجاوز الأب. يمتد orphanRemoval = true ليشمل حالة إزالة الابن من مجموعة أبيه في الذاكرة. معًا تُتيح لك هذه الميزات إدارة رسم كائنات غني دون كود مكرر — شريطة تطبيقها بعناية مع مراقبة SQL الذي تُولّده.