Классы в Java могут не только наследоваться друг от друга, но и включать в себя другие классы или даже коллекции классов в качестве полей. Мы уже знаем, что в столбцах таблиц, за некоторыми исключениями, нельзя хранить сложные составные типы и коллекции таких типов, что не позволяет сохранять весь подобный объект в одну таблицу. Зато можно сохранять каждый класс в свою собственную таблицу и сохранять связи между ними.
@OneToOne
Предположим что мы пишем приложение для какой-нибудь государственной организации, занимающейся учётом граждан. Мы хотим в этом приложении хранить данные о гражданах и о выданных им паспортах. Очевидно, что у нас будет сущность «гражданин», описывающая конкретного человека и сущность «паспорт», описывающая конкретный паспорт. Мы так же знаем, что каждый гражданин имеет паспорт и при этом каждый паспорт принадлежит какому-либо гражданину. Такая связь называется один к одному.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
@Entity
public class Person extends AbstractIdentifiableObject {
@Getter
@Setter
private String firstName;
@Getter
@Setter
private String lastName;
@Getter
@Setter
private LocalDate dob;
@Getter
@Setter
@OneToOne(optional = false, cascade = CascadeType.ALL)
@JoinColumn(name = "PASSPORT_ID")
private Passport passport;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
@Entity
public class Passport extends AbstractIdentifiableObject {
@Getter
@Setter
private String series;
@Getter
@Setter
private String no;
@Getter
@Setter
private LocalDate issueDate;
@Getter
@Setter
private Period validity;
@Getter
@Setter
@OneToOne(optional = false, mappedBy = "passport")
private Person owner;
}
|
Оба класса наследованы от @MappedSuperclass, который определяет суррогатный первичный ключ.
В каждой связи есть понятие владелец и владеемый. В примере выше класс Person владеет классом Passport. Для связи один к одному в обоих классах к полю добавляется аннотация @OneToOne, параметр optional которой говорит JPA, является ли значение в этом поле обязательным или нет.
Со стороны владельца к аннотации @OneToOne добавляется так же параметр cascade, который говорит JPA, что делать с владеемыми объектами при операциях над владельцем.
Про каскадирование стоит рассказать подробнее. Предположим, что никакого каскадирования в JPA нет и никогда не было. В это случае, если мы хотим удалить гражданина из нашей базы данных, мы должны помнить, что у него есть паспорт и должны вначале вручную удалить паспорт, затем только удалить гражданина. Если мы ничего не сделаем с паспортом, в базе останется паспорт, указывающий на несуществующего гражданина, что не хорошо. На самом деле, конечно же, в базе будет foreign key constraint, которые просто не даст сделать такое действие, что вообщем-то, не сильно лучше для конечного пользователя.
Каскадирование позволяет сказать JPA «сделай с владеемыми объектами класса тоже самое, что ты делаешь с владельцем». То есть, когда мы удаляем гражданина из базы, JPA самостоятельно увидит, что гражданин владеет паспорт и удалит вначале паспорт, потом гражданина.
Другой случай использования каскадирования характерен именно для связи один к одному. Если мы без каскадирования создадим паспорт и гражданина, мы не сможем их по раздельности сохранить, потому что паспорт будет ссылаться на гражданина, которого ещё нет в базе данных, а гражданин будет ссылаться на паспорт, которого тоже ещё нет в базе данных. Каскадирование позволяет, в данном случае, обойти эту проблему.
Размеется, CascadeType.ALL это не единственный возможный тип каскадирования, но их отличиям я посвящу отдельную статью.
Итак, кроме настроек каскадирования владелец связи один к одному добавляет к полю кроме аннотации @OneToOne ещё и аннотацию @JoinColumn, которая задаёт имя столбца, в котором будет храниться ссылка на владеемый объект. Физически это будет столбец с заданным именем, таким же типом, как у первичного ключа владеемого объекта и содержаться в нём будут значения первичных ключей владеемых объектов.
Со стороны паспорта, то есть владеемого объекта, такой столбец не требуется, а требуется в аннотации @OneToOne задать параметр mappedBy, который указывает, какое поле в объекте владельце (то есть классе Person) соответствует владеемому объекту (то есть passport).
@OneToMany
Кроме паспорта мы хотим хранить для граждан так же и место проживания. Очевидно, что один гражданин имеет один основной адрес проживания, но при этом по одному адресу может проживать несколько человек. Граждане и адреса образуют связь один ко многим и это самый распространённый тип связи между классами.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
@Entity
public class Person extends AbstractIdentifiableObject {
@Getter
@Setter
private String firstName;
@Getter
@Setter
private String lastName;
@Getter
@Setter
private LocalDate dob;
@Getter
@Setter
@OneToOne(optional = false, cascade = CascadeType.ALL)
@JoinColumn(name = "PASSPORT_ID")
private Passport passport;
@Getter
@Setter
@ManyToOne(optional = false, cascade = CascadeType.ALL)
@JoinColumn(name = "ADDRESS_ID")
private Address primaryAddress;
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
@Entity
public class Address extends AbstractIdentifiableObject {
@Getter
@Setter
private String city;
@Getter
@Setter
private String street;
@Getter
@Setter
private String building;
@Getter
@Setter
@OneToMany(mappedBy = "primaryAddress", fetch = FetchType.EAGER)
private Collection<Person> tenants;
}
|
Владельцем в данном случае опять будет класс Person, который аннотирует поле primaryAddress аннотациями @ManyToOne и @JoinColumn. Параметры этих аннотаций несут ту же смысловую нагрузку, что и у связи один к одному.
У владеемого объекта в этот раз всё по другому. Жильцы, которых по одному адресу может быть несколько, представлены коллекцией, которая аннотирована @OneToMany. Параметр mappedBy так же указывает на поле с владеемым в классе владельце. А вот параметр fetch = FetchType.EAGER говорит, что при загрузке владеемого объекта необходимо сразу загрузить и коллекцию владельцев.
Стратегии загрузки (fetch) бывает две: EAGER и LAZY. В первом случае объекты коллекции сразу загружаются в память, во втором случае только при обращении к ним. Оба подхода имеют достоинства и недостатки. В случае FetchType.EAGER в памяти будут находиться полностью загруженные и готовые к употреблению объекты. При этом они будут эту самую память занимать и если вам нужен только один объект из сотен (тысяч), то занимать они её будут просто так. Кроме того, при загрузке какого-нибудь корневого объекта, который связан со всеми остальными объектами и коллекциями, можно случайно попытаться загрузить в память и всю базу 🙂
С другой стороны, FetchType.LAZY загружает объекты только по мере обращения, но при этом требует, чтобы соединение с базой (или транзакция) сохранялись. Если быть точно, требует, чтобы объект был attached, но и про это я расскажу позднее. Поэтому для работы с lazy объектами тратится больше ресурсов на поддержку соединений.
@ManyToMany
И наконец, мы бы хотели хранить сведения о местах работы. Один гражданин может работать в нескольких компаниях, а в каждой компании работает несколько человек. Это — связь многие ко многим.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
@Entity
public class Person extends AbstractIdentifiableObject {
@Getter
@Setter
private String firstName;
@Getter
@Setter
private String lastName;
@Getter
@Setter
private LocalDate dob;
@Getter
@Setter
@OneToOne(optional = false, cascade = CascadeType.ALL)
@JoinColumn(name = "PASSPORT_ID")
private Passport passport;
@Getter
@Setter
@ManyToOne(optional = false, cascade = CascadeType.ALL)
@JoinColumn(name = "ADDRESS_ID")
private Address primaryAddress;
@Getter
@Setter
@ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinTable(name = "PERSON_COMPANIES",
joinColumns = @JoinColumn(name = "PERSON_ID"),
inverseJoinColumns = @JoinColumn(name = "COMPANY_ID")
)
private Collection<Company> workingPlaces;
}
|
1
2
3
4
5
6
7
8
9
10
11
|
@Entity
public class Company extends AbstractIdentifiableObject {
@Getter
@Setter
private String name;
@Getter
@Setter
@ManyToMany(mappedBy = "workingPlaces")
private Collection<Person> workers;
}
|
Связь многие ко многим с обоих сторон представлена коллекцией объектов. Так как напрямую в реляционных базах данных такая связь не поддерживается, JPA реализует её с помощью промежуточной таблицы, которая описывается аннотацией @JoinTable у объекта владельца. Параметр name задаёт имя промежуточной таблицы, joinColumns — имя столбца, связывающего с классом владельцем, inverseJoinColumns — имя столбца, связывающего с владеемым классом.
Во владеемом классе аннотацией @ManyToMany отмечаем поле с коллекцией объектов класса владельца. Параметр mappedBy опять указывает на поле с коллекцией владеемых объектов в классе владельце
Использовать классы со связями довольно просто — вначале наполняем их данными, как обычные классы без всякого JPA, а потом, благодаря каскадированию, сохраняем объект класса владельца и JPA делает всё остальное:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
Passport p = new Passport();
p.setSeries("AS");
p.setNo("123456");
p.setIssueDate(LocalDate.now());
p.setValidity(Period.ofYears(20));
Address a = new Address();
a.setCity("Kickapoo");
a.setStreet("Main street");
a.setBuilding("1");
Person person = new Person();
person.setFirstName("Test");
person.setLastName("Testoff");
person.setDob(LocalDate.now());
person.setPrimaryAddress(a);
person.setPassport(p);
Company c = new Company();
c.setName("Acme Ltd");
p.setOwner(person);
person.setWorkingPlaces(Collections.singletonList(c));
entityManagerFactory = Persistence.createEntityManagerFactory("ru.easyjava.data.jpa.hibernate");
EntityManager em = entityManagerFactory.createEntityManager();
em.getTransaction().begin();
em.merge(person);
em.getTransaction().commit();
em.close();
|
Опасность, которая ожидает нас при использовании связей в классах, это циклические связи и вытекающая из них рекурсия. Например метод toString() в классе Passport реализован без вызова toString() у класса Person
1
2
3
4
5
6
7
8
9
10
|
@Override
public String toString() {
return "Passport{" +
"series='" + series + '\'' +
", no='" + no + '\'' +
", issueDate=" + issueDate +
", validity=" + validity +
", owner=" + owner.getLastName() +
'}';
}
|
Потому что, если бы он просто вызывал toString() у Person, то тот, в свою очередь, вызывал бы toString() у своего поля passport и всё закончилось бы бесконечной рекурсией и StackOverFlowException. Пример с toString() разумеется весьма банален, но в реальных приложениях вляпаться в такую рекурсию при обходе иерархии связей к сожалению не просто, а очень просто.
Код примера доступен на github.