Классы в 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.