Przejdź do treści

Spring Boot – Spring Data JPA część II: Powiązania między tabelami

W pierwszej części postu Spring Boot – Interakcja z bazą danych, czyli Spring Data JPA, opisywałem jak się połączyć z bazą i jak stworzyć pierwszą encję. W tej części postaram się pokazać, jak możemy zdefiniować powiązania pomiędzy encjami oraz jak możemy wykonywać zapytania z ich użyciem.

Cały kod z przykładów można znaleźć w repozytorium w GitHubie pod adresem https://github.com/mloza/spring-boot-database-II

Powiązania – ale po co?

Czasem musimy przedstawić w bazie bardziej złożone struktury, które przechowują nie tylko typy proste, ale również inne obiekty lub całe ich kolekcje. Przykładowo chcielibyśmy przechowywać wpisy na blogu oraz komentarze dotyczące każdego wpisu. W takim przypadku będziemy używać powiązania. W jednej tabelce zapiszemy posty, a w 2 komentarze. Następnie musimy tylko odpowiednio powiązać który komentarz należy do którego postu. Takie powiązanie nazywamy jeden do wielu (1:n), ponieważ mamy jeden post z wieloma komentarzami.

W sumie wyróżniamy 3 typy powiązań:

  • Wspomniane już jeden do wielu, na przykład jeden post ma wiele komentarzy
  • Jeden do jednego, przykładowo każdy wpis może (nie musi) mieć jeden obrazek tytułowy i każdy obrazek może być użyty tylko w jednym poście.
  • Wiele do wielu – przykładowo każdy post może mieć przypisanych wiele tagów oraz każdy tag może być przypisany do wielu postów

Relacja jeden do wielu

Relacja jeden do wielu (1:N) to najpopularniejszy typ relacji, dlatego od niej zacznę. Do zaprezentowania przykładów użyję kodu aplikacji z poprzedniego postu. Zdefiniowaliśmy tam encję Task, która reprezentowała zadanie do wykonania. Załóżmy, że chcielibyśmy pogrupować teraz te zadania w projekty, czyli każdy projekt będzie miał wiele zadań.

Powiązanie takie możemy osiągnąć poprzez dodanie dodatkowego pola do tabelki posiadającej wiele wierszy. Posłużmy się poprzednim przykładem z postem i komentarzami. Każdy post posiada unikalne pole id. Aby więc powiązać komentarz z postem, tabelka będzie zawierać pole post_id, w którym umieścimy id powiązanego z nim postu. Dzięki temu możemy tworzyć wiele komentarzy, które będą miały zapisane id postu z nim związanego.

Zacznijmy od stworzenia encji Project. Dla uproszczenia będzie ona posiadała tylko 2 pola: id oraz name. Definicja w kodzie będzie wyglądała następująco:

@Entity
public class Project {
@GeneratedValue
@Id
private int id;
@Column
private String name;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@Override
public String toString() {
return "Project{" +
"id=" + id +
", name='" + name + '\'' +
"}";
}
}
view raw Project.java hosted with ❤ by GitHub

Następnie dodajmy repozytorium, które dostarczy nam podstawowych operacji:

public interface ProjectRepository extends CrudRepository<Project, Integer> { }

Tworzenie powiązania

Jak do tej pory wszystko wygląda normalnie, tak jak to zostało opisane w poprzedniej części pliku. Jeśli do tej pory coś było dla Ciebie nowego, polecam przeczytać poprzednią część wpisu na temat Spring Data JPA.

Przejdźmy teraz do powiązania naszej encji Task z encją Project. W encji Task będziemy potrzebowali nowego pola z typem Project. Będzie ono reprezentowało projekt, do którego należy nasze zadanie.

@ManyToOne
private Project project;
view raw ManyToOne.java hosted with ❤ by GitHub

Tutaj pojawia się pierwsza nowość, adnotacja @ManyToOne, która mówi nam, że przez to pole połączymy wiele zadań (Many) do (To) jednego projektu (One).

Mając już zdefiniowaną relację z encji Task do Project, powinniśmy zdefiniować teraz relację w 2 stronę. W encji Project dodajemy pole w następujący sposób:

@OneToMany(mappedBy = "project")
private List<Task> tasks;
view raw OneToMany.java hosted with ❤ by GitHub

Kolejna nowa adnotacja @OneToMany (czyli odwrotnie jak poprzednio @ManyToOne) mówiąca, że będziemy mieć jeden projekt (One) powiązany do (To) wielu zadań (Many). Dodatkowo musimy podać które pole mapuje relację w 2 stronę, czyli nazwa pola, które tworzyliśmy w poprzednim kroku.

Do obydwu pól od razu tworzę gettery i settery, które dla przejrzystości pomijam tutaj, ale będę z nich korzystał w późniejszych przykładach.

Jeśli uruchomimy teraz nasz projekt, w konsoli zostaną wypisane zapytania, które tworzą nasze relacje. Powinny wyglądać mniej więcej tak:

Hibernate: create table project (id integer generated by default as identity, name varchar(255), primary key (id))
Hibernate: create table task (id bigint generated by default as identity, budget double, description clob, done boolean, name varchar(255), project_id integer, primary key (id))
Hibernate: alter table task add constraint FK_b7i81l1tk1ph95xnhtoftyv53 foreign key (project_id) references project

Zauważ, że w tabelce Task zostało dodane pole project_id, w którym będziemy przechowywać numer projektu powiązanego z tym zadaniem. W ostatnim zapytaniu tworzymy powiązanie, dodajemy ograniczenie do tabelki Task, które użyje klucza obcego project_id z tabelki Projects.

Zależnie od użytej bazy danych, zapytania te mogą się różnić od siebie. W podanym, przykładzie używam bazy H2.

Zmodyfikujmy też plik data.sql tab, aby stworzyć od razu projekt i dodać do niego nasze zadania.

INSERT INTO project(name) VALUES('Projekt 1');

INSERT INTO task(name, description, budget, done, project_id) VALUES('Task 1', 'Description of task 1', 100.00, 1, SELECT id FROM project WHERE name = 'Projekt 1');
INSERT INTO task(name, description, budget, done, project_id) VALUES('Task 2', 'Description of task 2 Do', 100.00, 1, SELECT id FROM project WHERE name = 'Projekt 1');
INSERT INTO task(name, description, budget, done, project_id) VALUES('Task 3', 'Description of task 3 Do', 50.00, 0, SELECT id FROM project WHERE name = 'Projekt 1');

Listowanie i pobieranie powiązanych elementów

Przyszedł czas, aby podjąć jakąś interakcję z naszą bazą i powiązanymi tabelkami z kodu. Zacznijmy od wypisania listy projektów i powiązanych z nimi zadań. W tym celu dodajmy nową metodę do klasy MainController.

@Autowired
public ProjectRepository projectRepository;
@RequestMapping("/project-tasks")
@ResponseBody
public String projectsAndTasks() {
StringBuilder response = new StringBuilder();
for(Project project: projectRepository.findAll()) {
response.append(project).append("<br>");
for(Task task: project.getTasks()) { // 1
response.append("- ").append(task).append("<br>");
}
}
return response.toString();
}

Bardzo podobny kod omawialiśmy już w poprzedniej części wpisu. Jedyną nowością może być tutaj linia oznaczona jako // 1. Pobieramy w niej listę tasków, jest to zwykły getter, jednak jego wywołanie powoduje pobranie danych z powiązanej tabelki. Dla nas jest to przeźroczyste i wszystko zapewnia nam Spring Data JPA oraz Hibernate.

Możemy teraz przejść przeglądarką pod adres http://localhost:8080/project-tasks, gdzie naszym oczom powinien ukazać się następujący widok:

Lista naszych projektów oraz powiązanych z nimi zadań

Tworzenie powiązań

Tworzenie nowych elementów w bazie mamy już opanowane. Jeśli je teraz chcemy powiązać, wystarczy ustawić pole na powiązany element. Zobaczmy przykład:

@RequestMapping("/add-task")
@ResponseBody
public String addTask() {
Project project = projectRepository.findOne(1);
Task task = new Task()
.withBudget(12.00)
.withDescription("New task")
.withName("New task")
.withProject(project); // 1
project.getTasks().add(task); // 2
taskRepository.save(task);
return projectsAndTasks();
}
view raw addTask.java hosted with ❤ by GitHub

Trzeba pamiętać jedynie, aby dodać powiązanie w 2 strony. Czyli ustawiamy pole Project na wybrany projekt (// 1) oraz dodajemy zadanie do listy zadań przechowywanej w projekcie (// 2). Jest to wymagane, aby zachować spójność wszystkich obiektów. W wielu przypadkach jeśli ustawimy relację tylko w jedną stronę, nie zostanie ona odzwierciedlona w bazie danych, mimo że, nie pojawi się nam żaden błąd.

Jeśli przejdziemy teraz pod adres http://localhost:8080/add-task, powinniśmy zobaczyć taką samą listę jak w poprzednim kroku wraz z nową pozycją na liście zadań:

Lista zadań z nowym elementem przypisanym do naszego projektu

Mamy już opanowane listowanie i tworzenie nowych elementów wraz z powiązaniami. Czas teraz na usuwanie.

Usuwanie elementów powiązanych

Przy usuwaniu, podobnie jak przy tworzeniu, musimy pamiętać, aby usunąć powiązanie z obu stron. Czyli w naszym przykładzie usuwamy zadanie z listy w projekcie, a następnie usuwamy to zadanie.

@RequestMapping("/delete-task")
@ResponseBody
public String deleteTask() {
Task task = taskRepository.findOne(1L);
Project project = task.getProject();
project.getTasks().remove(task);
taskRepository.delete(task);
return projectsAndTasks();
}
view raw deleteTask.java hosted with ❤ by GitHub

Takie usuwanie jest najbezpieczniejsze i pozwoli nam zachować spójność obiektów i stanu bazy.

Jeśli jednak chcemy usunąć Projekt, musimy najpierw pozbyć się zadań z nim powiązanych. Możemy to osiągnąć poprzez usunięcie tych zadań wcześniej, przypisanie ich do innego projektu lub usunięcie projektu (przypisanie nulla do pola projekt – domyślnie podanie wartości tego pola jest opcjonalne, jeśli ma być wymagane, należy to ustawić w adnotacji @ManyToOne).

Jeżeli nie dopełnimy tych wymagań, możemy ujrzeć taką stronę błędu:

Strona błędu usunięcia wpisu posiadającego powiązane wpisy w innej tabelce.

Powiązanie OneToOne

Powiązanie jeden do jednego (1:1) jest reprezentowana w bazie tak samo jak jeden do wielu, jednak tutaj, w połączonej tabeli znajduje się tylko jeden rekord.

Takiego powiązania możemy użyć gdy mamy jakiś element, który występuje tylko sporadycznie i nie koniecznie chcemy go mieć ew tej samej tabeli. Przykładowo, możemy dodać szczegóły projektu zawierające szerszy opis.

Zacznijmy od stworzenia Encji, która będzie przechowywała nam szczegółowe informacje na temat projektu. Na ten moment encja ta będzie posiadała jedynie pole z opisem naszego projektu.

@Entity
public class ProjectDetails {
@GeneratedValue
@Id
private int id;
@Column
private String description;
// pomijam gettery i settery dla czytelności
@Override
public String toString() {
return "ProjectDetails{" +
"id=" + id +
", description='" + description + '\'' +
'}';
}
}

Aby utworzyć powiązanie między tabelkami, musimy dodać odpowiednie pola w obu klasach. W klasie Project details dodajemy odnośnik do projektu:

@OneToOne
Project project;
view raw OneToOne.java hosted with ❤ by GitHub

A w klasie Project powiązanie z ProjectDetails:

@OneToOne(mappedBy = "project")
private ProjectDetails projectDetails;
view raw OneToOne2.java hosted with ❤ by GitHub

I to wszystko. Mamy już powiązanie. Aby wykonać jakieś operacje, musimy stworzyć jeszcze repozytorium dla ProjectDetails:

public interface ProjectDetailsRepository extends CrudRepository<ProjectDetails, Integer> { }

Wszystkie operacje są analogiczne jak w połączeniu ManyToOne, dlatego je tutaj pominę.

Powiązanie ManyToMany

Ostatnim typem powiązań jest powiązanie wiele do wielu (n:m). Stosuje się je kiedy chcemy połączyć wzajemnie różne wpisy, przykładowo post na blogu może mieć przypisane wiele tagów, ale każdy tak może być przypisany do wielu postów. Jest to najbardziej skomplikowany typ powiązań. Wymaga on dodatkowej tabelki pośredniej, w której będziemy zapisywać który wpis z jednej tabelki jest połączony z drugim wpisem w innej tabelce. Dzięki temu możemy mieć wiele rekordów opisujących nasze powiązania.

Idąc za wcześniejszym przykładem, dodajmy możliwość tagowania projektów w naszej aplikacji. Do każdego projektu będziemy mogli przypisać wiele tagów, ale i do każdego tagu będziemy mogli przypisać wiele projektów. Zacznijmy od stworzenia encji Tag.

@Entity
public class Tag {
@GeneratedValue
@Id
private int id;
private String name;
// Gettery i settery
}
view raw Tag.java hosted with ❤ by GitHub

Od razu stwórzmy Repozytorium dla tej encji:

public interface TagRepository extends CrudRepository<Tag, Integer> { }

Teraz musimy stworzyć powiązanie poprzez dodanie odpowiednich pól w encjach Tag i Project. Zacznijmy od encji Tag, dodajmy pole:

@ManyToMany
private List<Project> projects;
view raw ManyToMany.java hosted with ❤ by GitHub

Następnie w encji Project dodajemy powiązanie w 2 stronę:

@ManyToMany(mappedBy = "projects")
private List<Tag> tags;

Tworzenie powiązań

Wszystkie operacje wykonujemy bardzo podobnie jak w poprzednich powiązaniach, z tą różnicą, że tutaj w obu przypadkach mamy listy. Stwórzmy dla przykładu 2 projekty i 2 tagi. Pierwszy projekt będzie miał przypisany tylko jeden tag, a 2 projekt oba.

@Autowired
public TagRepository tagRepository;
@RequestMapping("/tagging")
@ResponseBody
public String tagsAndProjects() {
Tag tag1 = new Tag();
tag1.setName("Tag 1");
Tag tag2 = new Tag();
tag2.setName("Tag 2");
Project project1 = new Project();
project1.setName("Project 1");
Project project2 = new Project();
project2.setName("Project 2");
projectRepository.save(project1);
projectRepository.save(project2);
project1.setTags(new ArrayList<>());
project1.getTags().add(tag1);
project2.setTags(new ArrayList<>());
project2.getTags().add(tag1);
project2.getTags().add(tag2);
tag1.setProjects(new ArrayList<>());
tag1.getProjects().add(project1);
tag1.getProjects().add(project2);
tag2.setProjects(new ArrayList<>());
tag2.getProjects().add(project2);
tagRepository.save(tag1);
tagRepository.save(tag2);
return "OK";
}

Po przejściu na stronę http://localhost:8080/tagging powinniśmy w przeglądarce zobaczyć napis OK, a w konsoli logi:

Hibernate: insert into project (id, name) values (null, ?)
Hibernate: insert into project (id, name) values (null, ?)
Hibernate: insert into tag (id, name) values (null, ?)
Hibernate: insert into tag_projects (tags_id, projects_id) values (?, ?)
Hibernate: insert into tag_projects (tags_id, projects_id) values (?, ?)
Hibernate: insert into tag (id, name) values (null, ?)
Hibernate: insert into tag_projects (tags_id, projects_id) values (?, ?)

Jak widać, powiązania zostały zapisane w tabelce tag_projects. Wszystko wygląda poprawnie. Spróbujmy teraz wylistować wszystkie projekty i przypisane do nich tagi.

@RequestMapping("/by-project")
@ResponseBody
public String tagsByProject() {
StringBuilder out = new StringBuilder();
projectRepository.findAll().forEach(project -> { // 1
out.append("project: ").append(project.getName()).append("<br>");
project.getTags().forEach(tag -> { // 2
out.append("- ").append(tag.getName()).append("<br>");
});
});
return out.toString();
}

Kod jest bardzo prosty. Wyciągamy wszystkie projekty z bazy (//1), iterujemy po nich i dodajemy nazwy do wyjścia, następnie wyciągamy wszystkie powiązane tagi (//2) i wypisujemy je pod projektami. Po przejściu na stronę http://localhost:8080/by-project powinniśmy zobaczyć następujący tekst.

project: Projekt 1
project: Project 1
- Tag 1
project: Project 2
- Tag 1
- Tag 2

Powiązanie działa w 2 strony, możemy w ten sam sposób wylistować tagi i powiązane projekty.

@RequestMapping("/by-tag")
@ResponseBody
public String projectsByTag() {
StringBuilder out = new StringBuilder();
tagRepository.findAll().forEach(tag -> {
out.append("tag: ").append(tag.getName()).append("<br>");
tag.getProjects().forEach(project -> {
out.append("- ").append(project.getName()).append("<br>");
});
});
return out.toString();
}

Przejście na stronę http://localhost:8080/by-tag powinniśmy zobaczyć listę tagów wraz z projektami. Mniej więcej powinno to wyglądać następująco:

tag: Tag 1
- Project 1
- Project 2
tag: Tag 2
- Project 2

Usuwanie powiązań

Przy usuwaniu powiązań, musimy je usunąć obustronnie. Załóżmy, że chcemy usunąć Tag 1 z Project 2

@RequestMapping("/remove-tag")
@ResponseBody
public String removeTag() {
Optional<Project> project =
StreamSupport
.stream(projectRepository
.findAll()
.spliterator(), false)
.filter(p -> p.getName().equals("Project 2"))
.findAny();
project.ifPresent(p -> {
Optional<Tag> tag = p.getTags().stream()
.filter(t -> t.getName().equals("Tag 2"))
.findAny();
tag.ifPresent(t -> {
t.getProjects().remove(p);
p.getTags().remove(t);
tagRepository.save(t);
projectRepository.save(p);
});
});
return "OK";
}
view raw removeTag.java hosted with ❤ by GitHub

Po przejściu na stronę http://localhost:8080/remove-tag w logu powinniśmy znaleźć zapytanie usuwające powiązanie.

Hibernate: delete from tag_projects where tags_id=?

Podobnie jak w przypadku relacji jeden-do-wielu, nie możemy usunąć wpisu, który ma istniejące powiązania. Przed wykasowaniem takiego rekordu należy usunąć najpierw wszystkie powiązania.

Podsumowanie

W poście zaprezentowałam 3 podstawowe powiązania. Za ich pomocą możemy wyrazić większość potrzebnych nam w aplikacji powiązań. Hibernate i JPA pozwalają nam zdefiniować jeszcze kilka innych powiązań pomiędzy obiektami, które wykraczają ponad temat tego wpisu, jednak końcowo sprowadza się to do wykorzystania opisanych powiązań.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

%d