Hibernate وتخطيط الكيانات

كائنات القيمة القابلة للتضمين و@Embedded

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

كائنات القيمة القابلة للتضمين و@Embedded

ليست كل كائنات نموذج المجال بحاجة إلى جدول قاعدة بيانات خاص بها. أحيانًا تُشكّل مجموعة من الحقول المترابطة — عنوان بريدي، أو مبلغ مالي، أو نطاق زمني — كائنَ قيمة متماسكًا مفهوميًا، وفي الوقت ذاته يكون تخزين كل أعمدته في جدول الكيان المالك أمرًا بالغ المنطقية. يُرسّخ JPA هذا النمط عبر تعليقَيْن توضيحيَّيْن: @Embeddable و@Embedded.

المشكلة: أعمدة مسطّحة ونموذج مجال غني

تأمّل كيانًا Customer يملك عنوان شحن. يوزّع التعيين السطحي حقول العنوان مباشرةً على الكيان:

@Entity public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; // حقول العنوان مبعثرة على الكيان private String street; private String city; private String postalCode; private String country; }

هذا يُصرَّف ويعمل، لكنك تفقد مفهوم المجال لـالعنوان. لا يمكنك إعادة استخدام بنية العنوان في كيان Order، ولا إضافة التحقق الخاص بالعنوان في مكان واحد، ولا تمرير كائن Address في طبقة الخدمة. مخطط قاعدة البيانات متطابق في الحالتين — الهدف هو Java أكثر ثراءً دون تغيير المخطط.

@Embeddable و@Embedded

ضع تعليق @Embeddable على فئة عادية لتُخبر Hibernate بأنها كائن قيمة يجب إدراج حقوله مضمَّنةً في جدول المالك. ثم ضع تعليق @Embedded على الحقل في الكيان المالك.

import jakarta.persistence.Embeddable; @Embeddable public class Address { private String street; private String city; private String postalCode; private String country; // يشترط JPA وجود مُنشئ بدون وسائط (يمكن أن يكون protected) protected Address() {} public Address(String street, String city, String postalCode, String country) { this.street = street; this.city = city; this.postalCode = postalCode; this.country = country; } // getters محذوفة للإيجاز }
import jakarta.persistence.*; @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @Embedded private Address shippingAddress; protected Customer() {} public Customer(String name, Address shippingAddress) { this.name = name; this.shippingAddress = shippingAddress; } // getters / setters محذوفة }

يُعيّن Hibernate هذا إلى جدول customers واحد يحتوي على أعمدة id وname وstreet وcity وpostal_code وcountry. لا وصل (join)، ولا جدول إضافي، ولا مفتاح أجنبي — مجرد أعمدة مسطّحة.

@Embedded اختياري حين يكون نوع الحقل @Embeddable. يستنتج Hibernate 6 التعيين المضمَّن تلقائيًا. ومع ذلك، يُعدّ كتابة @Embedded صراحةً ممارسةً جيدة لأنه يوصّل النية بوضوح للقرّاء الذين قد لا يتذكّرون أن Address تحمل التعليق.

تجاوز أسماء الأعمدة بـ@AttributeOverride

تأتي أسماء الأعمدة الافتراضية من أسماء الحقول في فئة @Embeddable. تنشأ مشكلات حين تضمّن النوع ذاته مرتين في كيان واحد — مثلًا Customer يملك عنوان شحن وعنوان فوترة معًا. كلاهما سيحاول إنشاء عمود باسم street، مما يُسبّب تعارضًا في التعيين.

حلّ التعارض باستخدام @AttributeOverride:

@Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @Embedded @AttributeOverrides({ @AttributeOverride(name = "street", column = @Column(name = "ship_street")), @AttributeOverride(name = "city", column = @Column(name = "ship_city")), @AttributeOverride(name = "postalCode", column = @Column(name = "ship_postal_code")), @AttributeOverride(name = "country", column = @Column(name = "ship_country")) }) private Address shippingAddress; @Embedded @AttributeOverrides({ @AttributeOverride(name = "street", column = @Column(name = "bill_street")), @AttributeOverride(name = "city", column = @Column(name = "bill_city")), @AttributeOverride(name = "postalCode", column = @Column(name = "bill_postal_code")), @AttributeOverride(name = "country", column = @Column(name = "bill_country")) }) private Address billingAddress; }

يحتوي الجدول الآن على ثمانية أعمدة متمايزة مسبوقة بـship_ وbill_، بينما تعمل الشيفرة البرمجية مع كائنَيْ Address ذوَيْ نوع واضح.

الكائنات المضمَّنة الفارغة (Null Embeddables)

حين تكون كل الأعمدة المنتمية إلى قيمة مضمَّنة NULL في قاعدة البيانات، يُعيد Hibernate null للحقل المضمَّن بأكمله افتراضيًا. قد ينتج عن ذلك NullPointerException أول مرة تستدعي فيها customer.getShippingAddress().getCity(). احمِ نفسك بفحص null، أو هيّئ الحقل بنسخة أولية في مُنشئ الكيان.

لا تستدعِ الحقول المضمَّنة القابلة للقيمة الفارغة دون فحص null. إذا لم يُضبَط shippingAddress قبل حفظ سجل، فسيُعطيك Hibernate قيمة null عند تحميله — لا كائن Address فارغ. هيّئ دائمًا الحقول المضمَّنة بقيمة افتراضية آمنة في مُنشئ الكيان، أو تحقق من null في موقع الاستدعاء.

تداخل الكائنات المضمَّنة

يمكن لفئة @Embeddable أن تحتوي بدورها على @Embeddable أخرى. مثال حقيقي شائع هو Address تضمّن GeoPoint:

@Embeddable public class GeoPoint { private double latitude; private double longitude; protected GeoPoint() {} public GeoPoint(double lat, double lon) { this.latitude = lat; this.longitude = lon; } } @Embeddable public class Address { private String street; private String city; private String postalCode; private String country; @Embedded private GeoPoint coordinates; // كائن مضمَّن متداخل protected Address() {} // المُنشئ والـ getters محذوفة }

يُسطّح Hibernate جميع الأعمدة المتداخلة في الجدول ذاته. ابقِ التداخل ضحلًا — مستوى أو مستويان هو الحد العملي قبل أن تُصبح قراءة الشيفرة وإدارة تجاوزات الأعمدة مُجهِدَيْن.

الكائنات المضمَّنة كـقيم غير قابلة للتغيير: equals وhashCode

يجب أن يكون كائن القيمة غير قابل للتغيير ومُقارَنًا بقيمته لا بهويته. اجعل فئات @Embeddable غير قابلة للتغيير حيثما أمكن — وفّر getters فقط، وضع كل الحقول في المُنشئ، ونفّذ equals() وhashCode() بناءً على قيم الحقول:

@Embeddable public class Money { private BigDecimal amount; private String currency; protected Money() {} public Money(BigDecimal amount, String currency) { this.amount = amount.setScale(2, RoundingMode.HALF_UP); this.currency = currency; } public BigDecimal getAmount() { return amount; } public String getCurrency() { return currency; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Money m)) return false; return amount.compareTo(m.amount) == 0 && currency.equals(m.currency); } @Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); } }
تعامل مع فئات @Embeddable كـrecords في Java. حقول غير قابلة للتغيير، ومُنشئ يقبل جميع الوسائط، وequals()/hashCode() مبنيَّيْن على القيمة، ومُنشئ محمي بلا وسائط لـJPA. في Java 16 وما بعده يمكنك استخدام record حقيقي مُعلَّق بـ@Embeddable — يدعم Hibernate 6.2 وما بعده ذلك أصلًا، إذ يحلّ المُنشئ الأساسي (canonical constructor) محل مُنشئ الوسائط الكاملة ويستخدم JPA المُنشئ المُدمج للترطيب (hydration).

المفاضلات في الأداء

تُخزَّن القيم المضمَّنة في الصف ذاته مع مالكها، مما يعني:

  • لا وصل (join) مطلوب — قراءة Customer تسترجع Address دائمًا في نفس SELECT. لا خيار للتحميل الكسول ولا خطر N+1 للجزء المضمَّن.
  • لا تحميل جزئي — إذا احتجت فقط اسم العميل، تُجلَب أعمدة العنوان رغم ذلك. استخدم الإسقاطات (تعبيرات المُنشئ في JPQL أو Spring Data Projections) حين تحتاج فعلًا تجنّب تحميل كائنات مضمَّنة كبيرة.
  • جداول عريضة — تضمين كائنات قيمة كثيرة في كيان واحد يُنتج جداول ذات أعمدة كثيرة. هذا عادةً مقبول؛ محرّكات العلاقات تتعامل مع الصفوف العريضة جيدًا. فكّر في التطبيع فقط حين تكون البيانات المضمَّنة اختيارية فعلًا وضخمة الحجم.

الخلاصة

استخدم @Embeddable و@Embedded لتقسيم مجموعة أعمدة مسطّحة إلى كائنات قيمة غنية وقابلة لإعادة الاستخدام دون تغيير مخطط قاعدة البيانات. طبّق @AttributeOverride كلّما ظهر النوع المضمَّن ذاته أكثر من مرة في كيان واحد. اجعل الفئات المضمَّنة غير قابلة للتغيير ونفّذ المساواة المبنية على القيمة. المفاضلة بسيطة: تكسب غنى النموذج وإمكانية إعادة الاستخدام بدون أي وصل إضافي، لكنك لا تستطيع التحميل الكسول لكائن مضمَّن ولا مشاركته عبر صفوف متعددة. هذه الخصائص تجعل الكائنات المضمَّنة الخيار الصحيح لأنواع العنوان والمبلغ المالي والنطاق الزمني والإحداثيات التي تنتمي طبيعيًا إلى مالكها.