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

@OneToOne

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

@OneToOne

ارتباط "واحد بواحد" هو أبسط العلاقات في النموذج العلائقي: يقابل صفٌّ واحد في الجدول A صفًّا واحدًا بالضبط في الجدول B. في JPA / Hibernate تُعيّن التعليمة @OneToOne هذه العلاقة إلى كائنات Java. معرفة أين يقع المفتاح الأجنبي وأيّ الطرفين يمتلكه تُحدّد كيفية توليد DDL وآلية الإدراج وطريقة تحميل الارتباط، لذلك سنستعرض كل متغير ذي معنى بالتفصيل.

النطاق التطبيقي: User و UserProfile

مثال كلاسيكي هو كيان User مع كيان UserProfile مقابل له يحمل البيانات الموسّعة. لكل مستخدم ملف شخصي واحد على الأكثر، وكل ملف شخصي ينتمي إلى مستخدم واحد بالضبط.

المتغير الأول — المفتاح الأجنبي على الطرف المالك

يخزّن التخطيط الأكثر شيوعًا المفتاح الأجنبي في جدول الطفل (user_profiles). يُسمّى الكيان الذي يحمل عمود المفتاح الأجنبي فعليًا الطرف المالك. أما الكيان الآخر فيُسمّى الطرف العكسي (تناولناه بالتفصيل في الدرس الخامس).

// --- الكيان: User (الطرف العكسي — لا يوجد عمود FK هنا) --- import jakarta.persistence.*; @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; @OneToOne(mappedBy = "user", // اسم الحقل في UserProfile cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true) private UserProfile profile; // getters / setters محذوفة للإيجاز }
// --- الكيان: UserProfile (الطرف المالك — يحمل عمود FK) --- import jakarta.persistence.*; @Entity @Table(name = "user_profiles") public class UserProfile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String bio; private String avatarUrl; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", // عمود FK في user_profiles nullable = false, unique = true) // يفرض العلاقة الواحدة بواحدة على مستوى DB private User user; // getters / setters محذوفة للإيجاز }

يولّد Hibernate عمود user_id مع قيد UNIQUE في جدول user_profiles. سمة unique = true في @JoinColumn هي ما يُخبر قاعدة البيانات بفرض عدد "واحد على الأكثر" — بدونها تصبح العلاقة many-to-one على مستوى قاعدة البيانات.

mappedBy تُشير إلى الطرف العكسي. في كيان User، تُخبر mappedBy = "user" Hibernate قائلةً: "عمود المفتاح الأجنبي الحقيقي موجود في UserProfile.user". طرف واحد فقط يمكنه امتلاك الارتباط؛ ونسيان هذا يجعل Hibernate يُنشئ عمودَي مفتاح أجنبي — أحدهما لكل كيان — وهو خطأ في الغالب.

المتغير الثاني — المفتاح الأساسي المشترك

بديل آخر يستخدم قيمة المفتاح الأساسي ذاتها في الجدولين. يُعيد صف الطفل استخدام PK الأب بوصفه PK الخاص به. هذا كفء (لا حاجة لفهرس FK إضافي) وواضح دلاليًا حين لا يمكن للطفل أن يوجد بصورة مستقلة. استخدم @MapsId لتوصيل هذا:

@Entity @Table(name = "user_profiles") public class UserProfile { @Id // PK مطابق لـ User private Long id; private String bio; @OneToOne(fetch = FetchType.LAZY) @MapsId // ينسخ PK الخاص بـ User إلى UserProfile.id @JoinColumn(name = "id") private User user; }
فضّل @MapsId حين لا يمتلك الطفل دورة حياة مستقلة. يُجنّبك المفتاح المشترك عمود FK بديلًا، ويبقي الربط على المفتاح الأساسي المُفهرَس، ويجعل من المستحيل عرضيًا فصل الطفل عن والده بمفتاح أجنبي قديم.

حفظ ارتباط OneToOne

نظرًا لأن User يمتلك سياسة الـ cascade (CascadeType.ALL)، يحفظ حفظُ الأب الطفلَ تلقائيًا:

@Service @Transactional public class UserService { private final UserRepository userRepo; public UserService(UserRepository userRepo) { this.userRepo = userRepo; } public User createUser(String email, String bio) { User user = new User(); user.setEmail(email); UserProfile profile = new UserProfile(); profile.setBio(bio); profile.setUser(user); // ضع الطرف المالك — مطلوب user.setProfile(profile); // ضع الطرف العكسي لاتساق الرسم في الذاكرة return userRepo.save(user);// ينسكب تلقائيًا إلى profile } }
اضبط دائمًا طرفَي العلاقة في الذاكرة. يستخدم Hibernate الطرف المالك لتوليد SQL، لذا فإن نسيان profile.setUser(user) يُبقي عمود FK بقيمة NULL. ضبط الطرف المالك وحده كافٍ للحفظ، لكن ضبط الطرفين يُبقي الـ cache من المستوى الأول متسقًا ويمنع NullPointerException المُربكة عند استعراض user.getProfile() داخل نفس المعاملة.

خيارات نوع التحميل

القيمة الافتراضية في JPA لـ @OneToOne هي EAGER، ما يعني أن كل مرة تُحمّل فيها كيان User، يُجري Hibernate فورًا join ليُحمّل UserProfile. في معظم التطبيقات يُعدّ هذا مبذّرًا حين لا تحتاج إلى الملف الشخصي.

  • EAGER (الافتراضي) — يُضمَّن في نفس SELECT. بسيط، لكنه يُحمّل بيانات قد لا تحتاجها.
  • LAZY — يُحمَّل الملف الشخصي فقط عند استدعاء user.getProfile(). الخيار الصحيح تقريبًا دائمًا؛ يتطلب أن تكون الجلسة مفتوحة عند الوصول.

لتفعيل LAZY على الطرف العكسي يحتاج Hibernate إلى توليد فئة بروكسي فرعية. يعمل هذا بشكل موثوق مع تحسين كود البايت (مُفعَّل افتراضيًا في Spring Boot 3 عبر Hibernate Enhancer) أو مع تلميح @LazyToOne(LazyToOneOption.NO_PROXY) في الإعدادات الأقدم.

// الطرف المالك — LAZY موثوق، Hibernate يُغلّف كيان الربط @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", unique = true) private User user; // الطرف العكسي — LAZY يتطلب تحسين كود البايت أو تلميح NO_PROXY @OneToOne(mappedBy = "user", fetch = FetchType.LAZY) private UserProfile profile;

القراءة عبر Spring Data JPA

تبدو الـ repository كأي repository آخر:

public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByEmail(String email); }

نظرًا لأن الملف الشخصي LAZY، يعمل الوصول إليه داخل method خدمة @Transactional بسلاسة. أما الوصول خارج المعاملة (في controller بعد إغلاق الجلسة) فيُلقي LazyInitializationException — الحل الاعتيادي هو تحميل ما تحتاجه داخل الخدمة أو استخدام DTO projection.

الخلاصة

يُعيّن ارتباط @OneToOne كيانَين يتشاركان العلاقة الواحدة بواحدة. الطرف المالك يحمل @JoinColumn (وعمود FK الفعلي)؛ أما الطرف العكسي فيستخدم mappedBy. لتخطيط المفتاح المشترك استخدم @MapsId. أعلن دائمًا fetch = LAZY لتجنّب تحميل بيانات غير ضرورية، واضبط كلا طرفي العلاقة في الذاكرة عند الحفظ، وأضف unique = true إلى @JoinColumn لتفرض قاعدة البيانات قيد الأعداد. الدرس التالي يوسّع هذا المفهوم إلى علاقتَي one-to-many و many-to-one الأكثر شيوعًا.