Relacja @OneToOne

Prawie każdy kto miał doczynienia z relacjami w JPA i Hibernate nieraz wylewał z siebie siódme poty, aby kod zaczął działać. Co prawda to relacje typu jeden do wielu lub wiele do wielu przysparzają najwięcej kłopotów. Jednak relacja @OneToOne też potrafi zaskoczyć.

Jak najlepiej stworzyć relację jeden do jeden?

Między innymi odpowiedź na to pytanie znajdziesz w tym artykule. Jednak na początku zahaczymy o podstawowe zagadnienia. Dowiesz się więc też:

  • Na czym polega relacja @OneToOne?
  • Jaka jest różnica w relacji jedno i dwukierunkowej?
  • Czy koniecznie trzeba używać relacji dwukierunkowej?

Do zrozumienia tego wpisu wystarczy podstawowa wiedza z baz danych i standardu JPA. Dobrze by było, aby pojęcia typu: encja, relacja, lazy loading lub klucz główny nie były Ci obce. W sumie pojęcie klucza obcego też powinno być Ci znajome.

Do czego służy adnotacja @OneToOne?

Relacja one to one – czyli jeden do jednego – polega na połączeniu dokładnie dwóch rekordów. Czyli jeden rekord ma dokładnie przypisany drugi rekord. Zazwyczaj drugi rekord znajduje się w innej tabeli niż rekord pierwszy. Zdarza się jednak, że czasami potrzebujemy połączyć dwa rekordy tej samej tabeli.

Najprostszym przykładem takiej relacji mogą być dwa obiekty: użytkownik oraz jego dane kontaktowe. Zakładamy, że użytkownik może mieć tylko jedne dane kontaktowe, więc idealnie pasuje tutaj relacja @OneToOne.

Na poziomie bazy danych takie połączenie wykonujemy przy dodaniu klucza obcego.

Dla niewtajemniczonych: klucz obcy służy do powiązania dwóch rekordów, dzięki czemu z bazy danych nie może zostać usunięta tylko jedna strona tej relacji. Dla powyższego przykładu gdybyśmy próbowali usunąć jedynie użytkownika z bazy danych to otrzymalibyśmy błąd SQL, który powiadomiłby nas, że w tabeli danych kontaktowych nadal istnieje rekord powiązany z tym użytkownikiem.

W dalszej części przyjrzymy się relacji jeden do jeden od strony technicznej. Na warsztat weźmiemy przykład, który opisałem powyżej.

Teraz powinieneś być już gotowy do dalszej drogi – czyli poznania jak stworzyć taką relację zgodnie ze standardem JPA dla dwóch encji: User oraz ContactDetails. Obie encje będą super proste – wrzucimy do nich pola typu: id, imię i jakiś email. Nie ma sensu komplikować.

Jako ORM wykorzystam Hibernate. Będzie jego minimalna ilość, tak aby nie zaśmiecać istotnych części.

Jak stworzyć relację dwukierunkową @OneToOne w JPA?

Na start stworzymy relację jeden do jednego dwukierunkową. Relacja dwukierunkowa polega na tym, że obydwoje uczestników relacji ma dostęp do drugiego “końca” relacji (tzn. do obiektu, z którym są powiązani). W takiej relacji musimy określić kto jest właścicielem całej relacji. W przypadku relacji @OneToOne właścicielem relacji jest posiadacz klucza obcego.

Na początek zanim stworzymy encję, zapoznaj się z diagramem bazy. Chcemy uzyskać taki efekt jak poniżej. Klucz obcy (user_id) będziemy przechowywać w tabeli ContactDetails.

schemat bazy tabel user i contact details

Spróbujmy teraz odwzorować tę relację przy użyciu encji. Zacznijmy od stworzenia reprezentacji użytkownika. User będzie zawierać automatycznie generowane id, imię i nazwisko oraz referencję do klasy ContactDetails – w końcu relacja ma być dwukierunkowa. Aby relacja została utworzona nad polem typu User musimy dodać adnotację @OneToOne.

@Entity(name = "bidirectional_user")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class User implements Serializable {

    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String lastName;

    @OneToOne
    private ContactDetails contact;

}

Teraz przejdźmy na drugą stronę relacji. W encji ContactDetails będziemy posiadać też automatycznie generowane id, adres e-mail, number telefonu oraz referencję do encji User. Analogicznie jak poprzednio musimy stworzyć relację poprzez dodanie @OneToOne nad polem typu User.

@Entity(name = "bidirectional_contact")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class ContactDetails implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String phoneNumber;

    @OneToOne
    private User user;
}

Czy jest to wystarczające? I tak i nie.

Na ten moment nie określiliśmy właściciela relacji. Hibernate nie jest w stanie tego określić samemu. W takiej sytuacji dla pewności Hibernate tworzy dwa klucze obce. Nie jest to, więc najlepsze rozwiązanie. Powodzenia z usuwaniem takich rekordów w przyszłości. 

Na podstawie poniższych logów można już wywnioskować, że zostały utworzone dla klucze obce. Najpierw mamy dwa inserty – a po dodaniu obu rekordów, aktualizujemy klucz obcy contact_id w tabeli użytkownika.

Hibernate: insert into bidirectional_user (id, contact_id, lastName, name) values (default, ?, ?, ?)

Hibernate: insert into bidirectional_contact (id, email, phoneNumber, user_id) values (default, ?, ?, ?)

Hibernate: update bidirectional_user set contact_id=?, lastName=?, name=? where id=?

Do określenia właściciela relacji potrzebujemy użyć adnotacji @JoinColumn. Przy użyciu tej adnotacji określamy kolumnę, w której będzie trzymany klucz obcy. I w taki oto sposób ContactDetails staje się właścicielem relacji.

@Entity(name = "bidirectional_contact")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class ContactDetails implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String phoneNumber;

    @OneToOne
    @JoinColumn(name = "user_id")
    private User user;
}

Dodatkowo dziecko relacji potrzebuje wiedzieć jakie pole w encji rodzica przechowuje relację, aby poprawnie się zmapować. Dlatego musimy w encji User dodać argument mapped w adnotacji @OneToOne, aby wskazać nazwę pola.

@Entity(name = "bidirectional_user")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class User implements Serializable {

    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String lastName;

    @OneToOne(mappedBy = "user")
    private ContactDetails contact;

}

Spróbujmy pobrać teraz użytkownika.

User user = entityManager.find(User.class, 1L);

Po poniższych logach widać, że wszystko działa poprawnie.

Hibernate: select u1_0.id,c1_0.id,c1_0.email,c1_0.phoneNumber,u1_0.lastName,u1_0.name from bidirectional_user u1_0 left join bidirectional_contact c1_0 on u1_0.id=c1_0.user_id where u1_0.id=?

Jakie problemy powoduje dwukierunkowa relacja @OneToOne? 

Na ten moment nie ma żadnego problemu. Ale jak to bywa – problem bardzo łatwo jest stworzyć. Wystarczy, że będziemy chcieli, aby ładowanie ContactDetails odbywało się leniwie tzn. lazy. W końcu nie zawsze potrzebujemy informacji kontaktowych, gdy dotykamy encji User.

Dodajmy, więc lazy loading w encjach User oraz ContactDetails. W przypadku relacji @OneToOne trzeba to zdefiniować, ponieważ domyślnie jest ustawione pobieranie natychmiastowe (EAGER).

@Entity(name = "bidirectional_user")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class User implements Serializable {

    @Id
    @GeneratedValue(strategy= GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String lastName;

    @OneToOne(mappedBy = "user", fetch = FetchType.LAZY)
    private ContactDetails contact;

}
@Entity(name = "bidirectional_contact")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class ContactDetails implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;

    private String phoneNumber;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;
}

Dodaliśmy ładowanie lazy, spróbujmy pobrać użytkownika.

User user= entityManager.find(User.class, 1L);

Hibernate: select u1_0.id,u1_0.lastName,u1_0.name from bidirectional_user u1_0 where u1_0.id=?
Hibernate: select c1_0.id,c1_0.email,c1_0.phoneNumber,c1_0.user_id from bidirectional_contact c1_0 where c1_0.user_id=?

Jak widać Hibernate nadal wczytuje oba obiekty z bazy danych. Nie mamy tutaj do czynienia z żadnym ładowaniem leniwym. Spróbujmy jednak jeszcze do adnotacji @OneToOne dodać argument optional ustawiony na false – może to coś zmieni.

@OneToOne(mappedBy = "user", fetch = FetchType.LAZY, optional = false)
private ContactDetails contact;
Hibernate: select u1_0.id,u1_0.lastName,u1_0.name from bidirectional_user u1_0 where u1_0.id=?
Hibernate: select c1_0.id,c1_0.email,c1_0.phoneNumber,c1_0.user_id from bidirectional_contact c1_0 where c1_0.user_id=?
Hibernate: select c1_0.id,c1_0.email,c1_0.phoneNumber,c1_0.user_id from bidirectional_contact c1_0 where c1_0.user_id=?

Nadal żadnych efektów. Nawet jest gorzej niż poprzednio.Ustawienie optional powoduje, że Hibernate dodatkowo sprawdza czy rodzic relacji istnieje. Co więc jest problemem? Czy w ogóle możemy uzyskać lazy loading dla relacji jeden do jednego?

Na szczęście Hibernate oferuje użycie lazy loadingu w przypadku relacji @OneToOne. Aby to zadziałało musimy jednak wprowadzić pewną zmianę. Właściciel relacji nie może posiadać automatycznie generowanego id. Zamiast tego musi używać id encji rodzica, z którą jest w relacji. Diagram wygląda wtedy mniej więcej tak – nie mamy już kolumny id, a funkcję klucza głównego przejmuje klucz obcy – czyli user_id.

schemat bazy tabel user i contact details bez id

JPA dostarcza adnotację @MapsId, która umożliwia skopiowanie klucza głównego rodzica relacji do encji dziecka. I to wszystko załatwi nam sprawę. 

W takim razie musimy wykonać teraz dwa kroki:

  1. nie generować id dla encji ContactDetails,
  2. w ContactDetails dodać adnotację @MapsId przy relacji @OneToOne, tak aby klucz obcy został skopiowany z rodzica.

Dodatkowo można pozbyć się adnotacji @JoinColumn – jest już zbędna. @MapsId załatwia wszystko.

@Entity(name = "bidirectional_contact")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class ContactDetails implements Serializable {

    @Id
    private Long id;

    private String email;

    private String phoneNumber;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    private User user;
}

Przy użyciu prostego finda sprawdźmy czy ContactDetails jest ładowany leniwie.

Hibernate: select u1_0.id,u1_0.lastName,u1_0.name from bidirectional_user u1_0 where u1_0.id=?

Jak widać przy pobraniu encji User nie pobieramy dodatkowo informacji kontaktowych. Sukces. Mamy ładowanie leniwe.

Jaka jest dodatkowa korzyść z użycia @MapsId?

Najlepiej zawsze używać @MapsId w przypadku relacji @OneToOne (nieopcjonalnych, połączenie zawsze musi istnieć).

Nie ma co ukrywać, to rozwiązanie jest również efektywne od strony bazy danych. Zazwyczaj w bazach indeksuje się klucze obce i główne. Przy użyciu @MapsId mamy tylko jeden klucz obcy. Dlatego też odpada nam połowa indeksów, które musielibyśmy stworzyć.

Dlaczego problem lazy loadingu występuje w Hibernate? 

Pozwól, że wrócę jeszcze do kwestii problemu ładowania leniwego w Hibernate – dlaczego lazy loading nie zadziałał z automatu?

Hibernate lazy implementuje przy użyciu wzorca proxy. Pole inicjalizuje albo null-em, albo obiektem proxy. Aby podjąć decyzję jak zainicjalizować pole, Hibernate potrzebuje minimum informacji. Musi w ogóle wiedzieć czy relacja istnieje – czy rekord faktycznie znajduje się w drugiej tabeli.

W przypadku, gdy identyfikator i klucz obcy różnią się od siebie Hibernate nie jest w stanie utworzyć proxy. Dlatego wykonuje dodatkowe query, aby podjąć decyzje – zainicjalizować null-em czy stworzyć proxy?

W momencie, gdy klucz główny jest mapowany na klucz obcy nie ma już tego problemu. Hibernate dokładnie zna identyfikator właściciela relacji i bez problemu może go pobrać w momencie, gdy zostanie użyty.

Problem ten nie występuje dla lazy loading po stronie właściciela relacji. Hibernate od razu wie, że musi stworzyć proxy, ponieważ to po tej stronie znajduje się klucz obcy. W takim razie musi być druga strona i będzie mógł ją pobrać później. 

Na StackOverflow znalazłem informację, że ten problem nie musi koniecznie występować na każdej wersji Hibernate. Nie znalazłem jednak informacji, na których dokładnie wersjach tak się dzieje. Dlatego lepiej zawsze stosować @MapsId.

Jak stworzyć relację jednokierunkową @OneToOne w JPA?

W bazach danych SQL nie istnieje coś takiego jak relacja dwukierunkowa. Jest to jedynie wymysł ORM. Dzięki temu mamy dostęp do drugiej strony relacji z obu miejsc. Często ułatwia nam to pracę.

Nie zawsze jednak jest to nam potrzebne i wtedy wystarcza nam relacja jednokierunkowa. W takiej relacji mamy właściciela relacji i tylko on ma dostęp do dziecka. Dziecko nie może odwołać się bezpośrednio do rodzica.

Do zobrazowania Ci takiego modelu zmieńmy nasze aktualne encje. W przykładzie to ContactDetails będzie nadal właścicielem relacji. W takim razie usuwamy relację do ContactDetails w encji User.

@Entity(name = "bidirectional_user")
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
class User implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String lastName; 
}

I to tyle. Udało się nam utworzyć relację jeden do jednego jednokierunkową. Co więcej, poprzez użycie @MapsId możemy nadal bez problemu wyszukiwać dane kontaktowe przy użyciu id usera.

ContactDetails contact = entityManager.find(ContactDetails.class, user.getId());
Hibernate: select c1_0.user_id,c1_0.email,c1_0.phoneNumber from bidirectional_contact c1_0 where c1_0.user_id=?

Podsumowanie

Najlepszym sposobem na stworzenie relacji one to one jest użycie adnotacji @MapsId. Dzięki temu klucz główny i obcy są identyczne. Nie tylko naprawia to problem lazy loadingu po stronie dziecka relacji, ale również zwiększa wydajność bazy danych.

Czy do tej pory miałeś świadomość jakie pułapki czyhają na Ciebie podczas tworzenia relacji @OneToOne? 

 

Kamil Klimek

Od 2016 jestem programistą Java. Przez pierwsze 4 lata pracowałem jako Full Stack Java Developer. Później postanowiłem postawić nacisk na Javę, żeby jeszcze lepiej ją poznać.

Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x