Hibernate: Связи вида Многие-ко-Многим и Один-к-Одному
Прошлые две статьи были посвящены работе с ассоциацими “один-ко-многим”. Фактически этот вид ассоциаций является наиболее ценным и часто используемым. В теории СУБД (и соответственно, в 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/