Hibernate: Связи вида Многие-ко-Многим и Один-к-Одному

Добавлено : 2 Nov 2008, 15:09

Прошлые две статьи были посвящены работе с ассоциацими “один-ко-многим”. Фактически этот вид ассоциаций является наиболее ценным и часто используемым. В теории СУБД (и соответственно, в hibernate) есть еще два вида связей: один-к-одному и многие-ко-многим. Сначала разберем пример, когда могут потребоваться именно такие отношения между таблицами.

В качестве примера связей “один-к-одному” я вспоминаю свое босоногое детство и то, как я писал на старом добром foxpro под ДОС небольшое приложение с табличкой, ну пусть это будет, сотрудник, у которой количество полей просто зашкаливало (их было много, очень много). Я уже точно не помню, что меня подвигло на разделение этого перечня полей на две таблицы с последующим связыванием их как “один-к-одному”, но подобная ситуация является наиболее частой причиной, когда нужно разделять одну логическую сущность на несколько физических таблиц в СУБД и делать между ними связь один-к-одному. Особенно, это полезно в тех случаях, когда часть полей этой мега-таблицы являются редко-используемыми. Например, признак того болел ли данный сотрудник в детстве ветрянкой, пригодится в лучшем случае раз в год. Так что тягать общим “select * from бла-бла-бла” данные из таблицы было глупо. Напоминаю тем, кто не знает, что foxpro - это файловая СУБД и фактически возможности попросить сервер вернуть по сети только нужные поля, без поля с ветрянкой, не было – по сети гонялся весь объем файла с данными. Так, что поддержание небольшого размера файла таблицы - было одним из условий повысить производительность. В hibernate с его идеей избежать ручных select-запросов к СУБД, проблема “зачем мы вытянули из СУБД это гигантское и никому не нужное поле” вернулась. Когда вы работаете с ассоциациями, то можете настроить hibernate получить связанную сущность только по мере необходимости (при первом обращении). Однако, если у вас есть TEXT поле хранящее биографию сотрудника – то это никак на ассоциацию не тянет и легкого способа указать, что поле должно загружаться только по требованию – нет. На самом деле, я немного лукавлю: такая возможность есть. В hibernate можно пометить некоторое поле как lazy, тогда при первом обращении к сущности оно автоматически загружаться не будет, до тех пор, пока вы явно не обратитесь к значению свойства. Единственный минус в том, что вам нужно после компиляции вашего кода, выполнить его “инструментирование”, в ходе чего байт код меняется и вместо вашего “String biography” подставляется hibernate-proxy. Больших проблем это не вызывает, т.к. подобную процедуру можно включить в состав вашего ant-сценария сборки проекта.

<target name="makeinstrument">
    <taskdef name="instrument"
            classname="org.hibernate.tool.instrument.cglib.InstrumentTask"
            classpathref="library.hibernate.classpath"/>
    <instrument verbose="true" extended="false">
        <fileset dir="${testmach.output.dir}/experimental/business">
           <include name="**/*.class"/>
            </fileset>
    </instrument>
</target>

Предполагается, что classpathref="library.hibernate.classpath" задает информацию о местоположении библиотек нужных для hibernate & cglib. А значение выражения ="${testmach.output.dir}/experimental/business ссылается на каталог с class-файлами которые нужно обработать. Сразу предупреждаю, что применять инструментирование нужно очень аккуратно и целенаправленно: так если применить его для POJO вашей модели данных, то проблем нет, но если заинструментировать код, который сам работает с dynamic proxy или aop, то проблемы будут – проверено.

Теперь давайте внесем небольшие правки в код маппинга для Employee, добавим ему поле fio с функцией “загрузки по требованию”:

<property name="bio" lazy="true" type="text" />

Само собой, что в классе Employee должно было появиться новое поле bio.

StringBuffer bigBio = new StringBuffer();
int __x = 1000;
while (__x-- > 0)
 
bigBio.append("bigbuf");

Employee lena =
new Employee("lena");
lena.setBio
(bigBio.toString());

lena.setDepartment
(designers);



Employee lena_2 =
(Employee) ses.load(Employee.class, 1);
// какие-то действия …
System.out.println (lena.getBio ());

А вот журнал выполнения приложения (как видите, изначальный запрос select не включал в себя поле bio, и только спустя некоторое время был выполнен второй запрос только для одного поля bio):

21:05:26,656 DEBUG SQL:401 - select employies0_.fk_department_id as fk3_1_, 
employies0_.employee_id as employee1_1_, employies0_.employee_id as employee1_3_0_, 
employies0_.fio as fio3_0_, employies0_.fk_department_id as fk3_3_0_ 
from Employee employies0_ where employies0_.fk_department_id=?
 
 
21:06:48,125 DEBUG SQL:401 - select employee_.bio as bio3_ from Employee employee_ where employee_.employee_id=?
21:06:48,125 DEBUG IntegerType:133 - binding '72' to parameter: 1
21:06:48,140 DEBUG TextType:172 - returning 'bigbufbi .... bigbuf' as column: bio3_

Теперь поспотрим как можно реализовать связь один-к-одному с использование действительно двух таблиц. Для этого я добавлю нашему сотруднику дополнительное поле car. Хранящее ссылку на автомобиль сотрудника (вот такая у нас счастливая страна, что у каждого работника есть свой автомобиль, или наоборот, учитывая что у работника может быть не более одного автомобильчика). Мне нужен класс Car со следующим полями:

/**
* Автомобиль сотрудника.
*/
public class Car {
   
// идентификатор автомобиля
   
Integer car_id;

   
// название модели авто
   
String model;
   
// и дата его выпуска
   
Calendar productionDate;

   
// также мы добавляем ссылку на сотрудника владеющего данным авто
   
Employee employee;

   
public Car() {
    }

   
public Car(String model, Calendar productionDate) {
       
this.model = model;
       
this.productionDate = productionDate;
   
}

   
public Integer getCar_id() {
       
return car_id;
   
}

   
public void setCar_id(Integer car_id) {
       
this.car_id = car_id;
   
}

   
public String getModel() {
       
return model;
   
}

   
public void setModel(String model) {
       
this.model = model;
   
}

   
public Calendar getProductionDate() {
       
return productionDate;
   
}

   
public void setProductionDate(Calendar productionDate) {
       
this.productionDate = productionDate;
   
}

   
public Employee getEmployee() {
       
return employee;
   
}

   
public void setEmployee(Employee employee) {
       
this.employee = employee;
   
}
}

Как видите, ничего не обычного: разве что я добавил поле employee для ссылки на сотрудника владеющего данным авто. Теперь смотрим, какие изменения произошли в коде класса сотрудника:

/**
* Сотрудник
*/
public class Employee {
   
Integer employee_id;
    String fio;
    Department department;
    Integer uo;
    String bio;

   
// автомобиль работника
   
Car car;
   
public Car getCar() {
       
return car;
   
}
   
public void setCar(Car car) {
       
this.car = car;
   
}
}

Теперь пример файлов маппинга:

<class name="Employee" dynamic-insert="true" dynamic-update="true">
   <id name="employee_id">
       <generator class="native"/>
    </id>
    <property name="fio"/>
    <many-to-one name="department" class="Department" column="fk_department_id" not-null="true"
               cascade="save-update"/>
 
    <property name="bio" lazy="true" type="text" />
    <one-to-one name="car" class="Car" property-ref="employee" cascade="all" />
 
</class>

И второй файл для автомобиля:

<class name="Car" dynamic-insert="true" dynamic-update="true">
  <id name="car_id">
     <generator class="native"/>
  </id>
  <property name="model"/>
  <property name="productionDate" type="calendar"/>
 
  <!-- важно что данное поле должно быть уникальным -->
  <many-to-one name="employee" class="Employee"
       column="fk_employee_id" cascade="all"
       unique="true"/>
</class>

Теперь краткий анализ: на самом деле я сделал небольшой финт ушами – у таблицы car есть свой, вполне таки обычный первичный ключ в виде автосчетчика. Который, в общем случае, никак не зависит от значения первичного ключа во второй таблице – сотруднике. Я имитирую связь один-к-одному с помощью внедрения в состав таблицы Авто внешнего ключа ссылающегося на таблицу сотрудников (fk_employee_id). Важно, что я должен пометить данный ключ как unique, иначе на уровне базы данных вполне возможна ситуация, что у какого-то сотрудника окажется сразу два автомобиля. На стороне сотрудника я использую специализированный тег one-to-one, в качестве атрибутов для него я должен указать то, как называется свойство в классе-партнере property-ref="employee". Каскадные операции для обоих сторон установлены как all, т.к. вполне очевидно, что удаление одной записи должно приводить к автоматическому уничтожению другой (типа, разбил авто – и тебя продали на органы, вот так-то).

Теперь пример использования:

Employee lena = new Employee("lena");

Calendar calen_mazzzda =
new GregorianCalendar();
calen_mazzzda.set
(2006, 1, 1);
Car mazzzda =
new Car("mazzzda", calen_mazzzda);
lena.setCar
(mazzzda);
mazzzda.setEmployee
(lena);
lena.setDepartment
(designers);


// любое из этих двух действий приведет у уничтожению связанной записи
//ses.delete(lena_2);
ses.delete(lena_2.getCar());

Второй способ организации связи один-к-одному не предполагает внедрение в одну из таблиц внешнего ключа. Более того, теперь у второй таблицы нет собственного независимого генератора значений первичного ключа – эти величины должны совпадать для обоих объектов. Никаких изменений в коде java-классов эта демонстрация не требует, основные изменения только в файле маппинга:

<class name="Employee" dynamic-insert="true" dynamic-update="true">
   <id name="employee_id">
      <generator class="native"/>
   </id>
   <property name="fio"/>
   <many-to-one name="department" class="Department" column="fk_department_id" not-null="true"
         cascade="save-update"/>
 
   <property name="bio" lazy="true" type="text" />
   <one-to-one name="car" class="Car" cascade="all" />
 
</class>

И теперь пример файла маппинга для Авто:

<class name="Car" dynamic-insert="true" dynamic-update="true">
   <id name="car_id">
       <generator class="foreign">
          <param name="property">employee</param>
      </generator>
   </id>
   <property name="model"/>
   <property name="productionDate" type="calendar"/>
 
   <one-to-one name="employee" class="Employee" constrained="true" cascade="all"/>
</class>

Как видите, здесь изменений больше: прежде всего я изменил запись управлющую первичным ключом сущности. Теперь генерация выполняется не средствами СУБД а подставляется самим hibernate на основании значения идентификатора того объекта (сотрудника, конечно), который хранится в поле employee. Также в теге one-to-one мне нужно записать атрибут constrainded=true.

На этом про связи один-к-одному хватит и переходим к связям многие-ко-многим. Все вы знаете, что на уровне СУБД такие связи создаются только с помощью промежуточной таблицы. Например связь между людьми и газетами (люди подписываются на газеты, очевидно, что один человек может читать много газет, также как и одно издание может выписываться множеством людей). так вот такая связь предполагает наличие промежуточной таблицы employee2newspaper, содержащей как минимум два поля: идентификаторы читателя-сотрудника и газеты. И вот в этом слове “как минимум” и кроется корень зла. Дело в том, что я за свою практику, за исключением крайне надуманных ситуаций, не встречался с тем, что промежуточная таблица была только из двух полей, всегда приходилось сразу или попозже добавлять характеристики связи: длительность подписки, стоимость доставки … Таким образом в реальности связь многие-ко-многим трансформировалась в выделение трех классов, трех файлов маппинга и двух последовательных соединений между ними в виде один-ко-многим и еще раз один-ко-многим. Если же случится такое чудо, что вам не нужны параметры связи то можно сделать так:

Я создаю новый класс Газеты:

/**
* Газета, которую будут выписывать наши сотрудники
*/
public class NewsPaper {
   
// идентификатор газеты
   
Integer newspaper_id;
   
// название газеты
   
String caption;

    Set<Employee> readers =
new HashSet<Employee>();

   
public NewsPaper() {
    }

   
public NewsPaper(String caption) {
       
this.caption = caption;
   
}

   
public Integer getNewspaper_id() {
       
return newspaper_id;
   
}

   
public void setNewspaper_id(Integer newspaper_id) {
       
this.newspaper_id = newspaper_id;
   
}

   
public String getCaption() {
       
return caption;
   
}

   
public void setCaption(String caption) {
       
this.caption = caption;
   
}

   
public Set<Employee> getReaders() {
       
return readers;
   
}

   
public void setReaders(Set<Employee> readers) {
       
this.readers = readers;
   
}
}

В состав класса Сотрудник добавляется новая коллекция newspapers:

/**
* Сотрудник
*/
public class Employee {
   
Integer employee_id;
    String fio;
    Department department;
    Integer uo;
    String bio;

   
// газеты, которые читает наш работник вместо того, чтобы приносить боссу бабло
   
Set <NewsPaper> newspapers = new HashSet<NewsPaper>();
   
public Set<NewsPaper> getNewspapers() {
       
return newspapers;
   
}
   
public void setNewspapers(Set<NewsPaper> newspapers) {
       
this.newspapers = newspapers;
   
}

//...
}

Теперь нужно переписать файлы маппинга, сначала файл для газеты:

<class name="NewsPaper" dynamic-insert="true" dynamic-update="true">
   <id name="newspaper_id">
        <generator class="native"/>
   </id>
   <property name="caption"/>
 
   <set name="readers" cascade="save-update" inverse="true" table="employee2newspaper">
     <key column="fk_newspaper_id" not-null="true" />
     <many-to-many class="Employee" column="fk_employee_id"  />
  </set>
</class>

Основное внимание на запись set. Я должен указать имя промежуточной таблицы, которая будет связывать моих читателей и газеты. Затем я указываю как будут называться поля играющие роль внешних ключей – со стороны газеты, поле будет называться fk_newspaper_id (очевидно, что оно не должно хранить null значения). На стороне читателя поле будет называться fk_employee_id. Изменения в файле маппинга для сотрудника синхронны:

<class name="Employee" dynamic-insert="true" dynamic-update="true">
  <id name="employee_id">
    <generator class="native"/>
  </id>
  <property name="fio"/>
  <many-to-one name="department" class="Department" column="fk_department_id" not-null="true"
        cascade="save-update"/>
  <property name="bio" lazy="true" type="text" />
  <one-to-one name="car" class="Car" cascade="all" />
 
  <set name="newspapers" cascade="save-update" table="employee2newspaper">
    <key column="fk_employee_id" />
    <many-to-many class="NewsPaper" column="fk_newspaper_id" />
  </set>
</class>

Я указал те же названия промежуточной таблицы и служебных полей. Основное внимание на то, что одну из сторон связи я должен пометить как inverse=”true”, в противном случае мы столкнемся со всеми проблемами, о которых я упоминал еще в первой статье. Еще очень важно то, что я для обоих сторон установил каскадные операции как “save-update” – никаких all или delete. Дело в том, что если я так не сделаю, то при попытке удалить сотрудника удалению подвергнутся все газеты которые он выписывают и удалены они будут не только из промежуточной таблицы, но и из главной таблицы, справочника газет – оно нам нужно?

В результате будет сгенерирована следующая структура данных:

CREATE TABLE `employee2newspaper` (
  `fk_employee_id` int(11) NOT NULL,
  `fk_newspaper_id` int(11) NOT NULL,
  PRIMARY KEY (`fk_employee_id`,`fk_newspaper_id`),
  KEY `FKB9DE4FD5632D9988` (`fk_employee_id`),
  KEY `FKB9DE4FD55575D22C` (`fk_newspaper_id`),
  CONSTRAINT `FKB9DE4FD55575D22C` FOREIGN KEY (`fk_newspaper_id`) REFERENCES `newspaper` (`newspaper_id`),
  CONSTRAINT `FKB9DE4FD5632D9988` FOREIGN KEY (`fk_employee_id`) REFERENCES `employee` (`employee_id`)
) ENGINE=InnoDB

Теперь пример использования:

Employee jim = new Employee("jim");
Employee tom =
new Employee("tom");
Employee ron =
new Employee("ron");
Department managers =
new Department("managers");
Department designers =
new Department("designers");

jim.setDepartment
(managers);
tom.setDepartment
(managers);
ron.setDepartment
(managers);

Employee lena =
new Employee("lena");

Calendar calen_mazzzda =
new GregorianCalendar();
calen_mazzzda.set
(2006, 1, 1);
Car mazzzda =
new Car("mazzzda", calen_mazzzda);
lena.setCar
(mazzzda);
mazzzda.setEmployee
(lena);
lena.setDepartment
(designers);

NewsPaper pravda =
new NewsPaper("pravda");
NewsPaper trud =
new NewsPaper("trud");
NewsPaper znamia_lenina =
new NewsPaper("znamia_lenina");

// заметьте что эти действия будут проигнорированы т.к.
// они выполняются на стороне не управляющей связью
pravda.getReaders().add(lena);
pravda.getReaders
().add(jim);
trud.getReaders
().add(ron);
trud.getReaders
().add(tom);

// а эти операции сохранения будут успешными
jim.getNewspapers().add(pravda);
ron.getNewspapers
().add(pravda);
lena.getNewspapers
().add(pravda);
lena.getNewspapers
().add(trud);
lena.getNewspapers
().add(znamia_lenina);


----

ses.beginTransaction
();

Department managers_2 =
(Department) ses.load(Department.class, 1);
Employee jim_2 =
(Employee) ses.load(Employee.class, jim.getEmployee_id());
Employee lena_2 =
(Employee) ses.load(Employee.class, lena.getEmployee_id());
NewsPaper pravda_2 =
(NewsPaper) ses.load(NewsPaper.class, pravda.getNewspaper_id());


// выполнить удаление газеты мы не можем т.к. у нее есть два читателя
// и это нарушает ограничения на уровне базы данных
// так что единственный способ выполнить удаление газеты и всей связанной информации
// - использовать sql&hql
//ses.delete(pravda_2);

// а вот выполнить удаление сотрудника-читателя мы можем т.к. hibernate знает,
// что предварительно нужно удалить все записи в связывающей таблице
ses.delete(lena_2);

// и второй способ удаления – когда удаляется только одна запись
// в промежуточной таблице. И сотрудник и газеты при этом никуда не исчезают.
lena_2.getNewspapers().remove(pravda_2);

Общие замечания: напоминаю, что лишь те изменения, которые были сделаны на “владеющей стороне” (не помеченной как inverse будут учитываться как при добавлении данных, так и при их очистке). Прочитайте также в тексте программы комментарии по поводу удаления связанных сущностей.

Что касается подбора возможных видов коллекций для хранения перечня газет или сотрудников, то можно использовать не только set, но и bag, idbag, map, list. Нужно только помнить, что есть ограничения. Если мы говорим о стороне управляющей связью, то проблем нет, и можно использовать любую из перечисленных выше коллекций, но как только мы упоминаем сторону, не владеющую связью, то из перечня допустимых вариантов выпадают map и list. Вспомните, как в прошлой статье я рассказывал о том, что не владеющая сторона не может устанавливать значение ни индекса, ни ключа для map.

Есть еще один особый вариант организации связи многие-ко-многим. Когда мы не используем промежуточную сущность (отдельный java-класс замапленный на таблицу), но при этом пользуемся возможностью “присоединить к каждой из связей” некоторые атрибуты. Для этого нужно использовать компонент. Ведь в качестве его полей могут быть и ассоциации: Сначала пример класса Подписка:

/**
* Промежуточный класс хранящий информацию об подписке на некоторые издания
*/
public class Subscription {
   
Integer subscription_id;

    Employee employee;
    NewsPaper newspaper;

    Calendar dateof;

   
public Subscription() {
    }

   
public Subscription(Employee employee, NewsPaper newspaper, Calendar dateof) {
       
this.employee = employee;
       
this.newspaper = newspaper;
       
this.dateof = dateof;
   
}

   
public Integer getSubscription_id() {
       
return subscription_id;
   
}

   
public void setSubscription_id(Integer subscription_id) {
       
this.subscription_id = subscription_id;
   
}

   
public Employee getEmployee() {
       
return employee;
   
}

   
public void setEmployee(Employee employee) {
       
this.employee = employee;
   
}

   
public NewsPaper getNewspaper() {
       
return newspaper;
   
}

   
public void setNewspaper(NewsPaper newspaper) {
       
this.newspaper = newspaper;
   
}

   
public Calendar getDateof() {
       
return dateof;
   
}

   
public void setDateof(Calendar dateof) {
       
this.dateof = dateof;
   
}
}

Для этого файла нет никакого маппинга – он используется как компонент. Теперь примеры файлов маппинга для сотрудника и газеты. Опять таки обратите внимание, что каскадные операции у меня уже не включают удаление связанной записи (на высшем уровне). Что касается каскадов для вложенных ассоциаций (many-to-one), то их значение является несущественным т.к. если каскадного удаления нет для родителя, то и до вложенного элемента оно не дойдет:

<class name="Employee" dynamic-INSERT="true" dynamic-UPDATE="true" >
   <id name="employee_id">
       <generator class="native"/>
   </id>
   <property name="fio"/>
   <many-to-one name="department" class="Department" COLUMN="fk_department_id" not-NULL="true"
                cascade="save-update"/>
 
   <property name="bio" lazy="true" type="text"/>
   <one-to-one name="car" class="Car" cascade="all"/>
   <set name="subscriptions" cascade="save-update" inverse="false" TABLE="employee2newspaper" >
       <key COLUMN="fk_employee_id" not-NULL="true"/>
       <composite-element class="Subscription" >
          <parent name="employee" />
          <property name="dateof" type="calendar" not-NULL="true"/>
          <many-to-one name="newspaper" class="NewsPaper" COLUMN="fk_newspaper_id" not-NULL="true" cascade="save-update"/>
       </composite-element>
   </set>
</class>

А это пример маппинга для Газеты:

<class name="NewsPaper" dynamic-insert="true" dynamic-update="true">
   <id name="newspaper_id">
      <generator class="native"/>
   </id>
   <property name="caption"/>
 
   <set name="subscriptions" cascade="save-update" inverse="true" table="employee2newspaper">
      <key column="fk_newspaper_id" not-null="true"/>
 
      <composite-element class="Subscription">
         <parent name="newspaper"/>
         <property name="dateof" type="calendar" not-null="true"/>
         <many-to-one name="employee" class="Employee" column="fk_employee_id" not-null="true"  cascade="save-update"/>
      </composite-element>
   </set>
</class>

Теперь я привожу пример структуры данных для сгенерированной hibernate промежуточной таблицы:

CREATE TABLE `employee2newspaper` (
  `fk_employee_id` int(11) NOT NULL,
  `dateof` datetime DEFAULT NULL,
  `fk_newspaper_id` int(11) NOT NULL,
  KEY `FKB9DE4FD5632D9988` (`fk_employee_id`),
  KEY `FKB9DE4FD55575D22C` (`fk_newspaper_id`),
  CONSTRAINT `FKB9DE4FD55575D22C` FOREIGN KEY (`fk_newspaper_id`) REFERENCES `newspaper` (`newspaper_id`),
  CONSTRAINT `FKB9DE4FD5632D9988` FOREIGN KEY (`fk_employee_id`) REFERENCES `employee` (`employee_id`)
) ENGINE=InnoDB

Обратите внимание на то, что здесь в таблице нет первичного ключа. Надо сказать, что здесь находится не слабенькая дырка в системе безопасности т.к. вполне возможны дубляжи, когда один человек (Джим) будет подписан на газету (Правда) несколько раз и даже на одну и туже дату. В принципе, когда создается связывающая таблица, то hibernate создает первичный ключ, только тогда, когда все поля входящие в состав такого класса помечены как not null. Это не сделано для поля хранящего дату подписки. К сожалению, я никак не могу добиться подобного поведения. Заметка для себя: перечитать избранные главы документации по hibernate. Здесь же возникает еще один интересный вопрос о том, как запретить положить в set идентичные пары Читатель-Газета, фактически нам нужно реализовать методы equals & hashCode, но делать это нужно аккуратно т.к. подводных камней хватает.

Получено с http://www.black-zorro.com/mediawiki/