Większość współczesnych aplikacji korzysta obecnie z baz danych do przechowywania informacji. Wspomniałem już, w jaki sposób stworzyć bazę danych w bibliotece Room. Dziś poruszymy temat, w jaki sposób pracować z wieloma tabelami, które mają ze sobą relacje. Najpierw omówimy kilka podstawowych pojęć, a następnie zaczniemy pracować z kodem.
Czym jest relacja w bazach danych?
Podczas projektowania baz danych stosujemy oddzielne tabele dla różnych typów podmiotów, na przykład: użytkowników, artykułów, wiadomości. W bazach danych SQLite możemy określić również relację między obiektami (tabelami), dzięki czemu możemy powiązać jeden lub wiele obiektów z jednym lub wieloma innymi obiektami.
I tak na przykład możemy mieć użytkownika w bazie, który może być właścicielem jednego artykułu. Jest to relacja jeden do jednego. Jeżeli użytkownik jest autorem wielu artykułów to występuje relacja jeden do wielu. Z drugiej strony, każdy artykuł może mieć wielu współtwórców, więc dla każdego użytkownika możemy mieć wiele artykułów, a każde artykuł może mieć wielu użytkowników. W tym przypadku jest to relacją wiele do wielu.
SQLite jest relacyjną bazą danych, więc rozumie relacje między jednostkami. W bibliotece Room również możemy zastosować te relacje. Mamy do dyspozycji 3 podejścia, które można wykorzystać do zdefiniowania relacji między obiektami:
- @ForeignKey
- @Embedded
- @Relation
Relacja za pomocą @ForeignKey
Biblioteka Room umożliwia zdefiniowanie ograniczeń klucza obcego między jednostkami. Klucz obcy należy do Constraint, czyli ograniczników, które uniemożliwiają użytkownikowi wprowadzić dane do tabeli w bazie danych. Tak ja @NotNull nie pozwala na to, aby dana wartość była nullem. Jak tworzymy klucz obcy? W tabeli nadrzędnej musimy stworzyć klucz podstawowy — zapewnia unikalność danych. Natomiast w tabeli podrzędnej musimy stworzyć klucz obcy — on pilnuje by wartość w tej kolumnie była jedną z wartości z klucza podstawowego w tabeli nadrzędnej. Oczywiście wartości w kluczu obcym mogą się powtarzać. Spójrz na przykład.
@Entity(tableName = "Users") class UsersEntity { @PrimaryKey(autoGenerate = true) private int uid; private String name; public int getUid() { return uid; } public void setUid(int uid) { this.uid = uid; } public String getName() { return name; } public void setName(String name) { this.name = name; } }
@Entity (tableName = "Articles", foreignKeys = @ForeignKey(entity = UsersEntity.class, parentColumns = "uid", childColumns = "usersId", onDelete = CASCADE, onUpdate = CASCADE), indices = @Index("usersId") ) public class ArticlesEntity { @PrimaryKey(autoGenerate = true) private int uid; private String title; private int usersId; public int getUid() { return uid; } public void setUid(int uid) { this.uid = uid; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public int getUsersId() { return usersId; } public void setUsersId(int usersId) { this.usersId = usersId; } }
Mamy tabele użytkowników i artykułów. W klasie ArticlesEntity jest kolumna (zmienna) usersid — w niej właśnie będziemy przechowywać id użytkownika. Gdy będziemy chcieli wprowadzić id usera to on musi istnieć w tabeli Users. Inaczej mówiąc id usera musi odpowiadać jakiemuś uid w tabeli Users. W przeciwnym wypadku dostaniemy błąd i artykuł nie zostanie zapisany w bazie danych. Nawet gdyby nam się udało zapisać taki obiekt to dane nie będą spójne. A skąd to wiemy? Jest tam zdefiniowany @ForeignKey. Właśnie ten parametr nam definiuje tę relację. Jak już wiemy bazy relacyjne opierają się na relacjach. A relacja to nic innego jak powiązanie między tabelami. Powiązania te sprawiają, że jesteśmy wstanie wyciągnać jakiś wniosek, którego nie zapisaliśmy wprost w bazie danych. Ok, wytłumaczmy ten klucz. Parametr parentColumns jest nazwą kolumny uid z klasy UsersEntity, a childColumns jest nazwą kolumny userid w klasie ArticlesEntity. Utworzenie tego połączenia nie jest konieczne do posiadania relacji, ale pomaga zdefiniować, co ma się stać z artykułem w bazie danych w przypadku usunięcia lub aktualizacji wiersza użytkownika. Parametr onDelete mówi nam, że jeśli użytkownik zostanie usunięty, chcielibyśmy również usunąć wszystkie jego artykuły. Podobnie jest z onUpdate. Jeżeli zmieni się uid użytkownika, również wartość iduser z tabeli Articles ma się zmienić. Jakie opcje oprócz CASCADE mamy dostępne? Oto one:
- NO_ACTION (domyślnie) — kiedy klucz nadrzędny jest modyfikowany lub usuwany z bazy danych, żadne działanie nie jest podejmowane.
- RESTRICT — ta akcja oznacza, że aplikacja nie może usuwać (onDelete()) lub modyfikować (onUpdate()) klucza nadrzędnego, gdy istnieje jeden lub więcej kluczy potomnych powiązanych w tabeli zależnej.
- SET_DEFAULT — w przypadku zmiany klucza podstawowego w tabeli nadrzędnej zostanie ustawiona wartość domyślna dla klucza obcego. W naszym przypadku pola w klasach nie zostały zdefiniowane.
- SET_NULL — w przypadku zmiany klucza podstawowego w tabeli nadrzędnej wstawi wartość NULL dla klucza obcego w tabeli podrzędnej.
Teraz, po przygotowaniu naszych modeli, musimy utworzyć odpowiednie zapytanie SQL, Klasy z DAO nie zmieniają się z poprzedniego artykułu, zatem nie widzę potrzeby umieszczanie tego tutaj. Powyższy kod zalicza się do relacji jeden do wielu. Co w sytuacji, gdy artykuł posiada więcej niż jednego autora? Musimy zastosować relację wiele do wielu.
Relacja wiele do wielu
W bazach relacyjnych relacja wiele do wielu wymaga posiadania dodatkowej tabeli sprzężenia z kluczami obcymi. Możemy zmienić przykład z poprzedniego punktu. Teraz nie tylko każdy użytkownik może mieć wiele artykułów, ale także każdy artykuł może należeć do wielu użytkowników.
@Entity(tableName = "UserArticleJoin", primaryKeys = { "userId", "articleId" }, foreignKeys = { @ForeignKey(entity = UsersEntity.class, parentColumns = "uid", childColumns = "userId"), @ForeignKey(entity = ArticlesEntity.class, parentColumns = "uid", childColumns = "articleId") }) public class UserArticleJoin { private int userId; private int articleId; public int getUserId() { return userId; } public void setUserId(int userId) { this.userId = userId; } public int getArticleId() { return articleId; } public void setArticleId(int articleId) { this.articleId = articleId; } }
Z czego składa się klasa UserArticleJoin?
- Parametr tableName nadaje naszej tabeli nazwę.
- Parametr primaryKeys dla posiadania wielu kluczy podstawowych — w SQL możemy mieć nie tylko pojedynczy klucz podstawowy, ale także zestaw kluczy głównych. Używany jest do deklarowania, że każdy wiersz w naszej dodatkowej tabeli powinien być unikalny dla każdej pary z userId i articleId
- Parametr foreignKey służy do deklarowania kluczy obcych do innych tabel. Tutaj mówimy, że userId z naszej dodatkowej tabeli jest identyfikatorem potomka dla klasy użytkowników i podobnie jest dla klasy ArticlesEntity. Pamiętaj o tym, że w klasach UsersEntity i ArticlesEntity nie definiujemy już klucza obcego.
Relacja za pomocą @Embedded
Inną formą relacji jest adnotacja @Embedded. Służy do przechowywania pól zagnieżdżonych — w ten sposób utworzymy klasę UsersEntity i ArticlesEntity w klasie UserArticleJoin2.
public class UserArticleJoin2 { @Embedded(prefix = "user_") private UsersEntity usersEntity; @Embedded(prefix = "articles_") private ArticlesEntity2 articlesEntity; public UsersEntity getUsersEntity() { return usersEntity; } public void setUsersEntity(UsersEntity usersEntity) { this.usersEntity = usersEntity; } public ArticlesEntity2 getArticlesEntity() { return articlesEntity; } public void setArticlesEntity(ArticlesEntity2 articlesEntity) { this.articlesEntity = articlesEntity; } }
W klasie UserArticleJoin2 mamy model, który posłuży nam do zapytania SQL. Zwróć uwagę, że przy UsersEntity, ArticlesEntity dałem prefiks. Jest to w tym przypadku wymagane, ponieważ w klasie UsersEntity i ArticlesEntity mamy pola „uid„. To tworzy nam konflikt. W związku z tym musimy odróżnić, który uid należy do użytkownika, a który do artykułu. A jak może wyglądać nasze zapytanie SQL w interfejsie DAO?
@Query("SELECT " + "Users.uid AS 'user_uid', Users.name AS 'user_name', " + "Articles_2.uid AS 'articles_uid', Articles_2.title AS 'articles_title', Articles_2.userId AS 'articles_userId' " + "FROM Users INNER JOIN Articles_2 " + "ON Users.uid = Articles_2.userId " + "WHERE Users.uid = :id") UserArticleJoin2 getUsersWithArticles (int id);
Dzięki temu otrzymamy wynik wspólny dla obu obiektów. Myślę, że ta relacja jest prosta i zrozumiała 🙂 Przejdźmy teraz do innej relacji.
Relacja za pomocą @Relation
Zobaczmy pierwsze jak ją zbudować.
public class UserArticleJoin3 { @Embedded private UsersEntity usersEntity; @Relation(parentColumn = "uid", entityColumn = "usersId") private List<ArticlesEntity> articlesEntity; public UsersEntity getUsersEntity() { return usersEntity; } public void setUsersEntity(UsersEntity usersEntity) { this.usersEntity = usersEntity; } public List<ArticlesEntity> getArticlesEntity() { return articlesEntity; } public void setArticlesEntity(List<ArticlesEntity> articlesEntity) { this.articlesEntity = articlesEntity; } }
@Relation odnosi się do relacji z inną klasą modelu. Te dwa parametry mówią, że nazwa kolumny nadrzędnej z klasy użytkownika to uid, a parametr entityColumn jest odwołaniem do klasy ArticlesEntity — pola userId. W tej relacji nie tworzymy tabeli w bazie danych, tylko opieramy się na modelach danych. A jak nasze zapytanie?
@Query ("SELECT * FROM Users") List<UserArticleJoin3> getUsersWithArticles2();
W ten sposób otrzymujemy listę użytkowników ze wszystkimi ich artykułami. Proste? Na pewno 🙂
Relacja @Embedded i @Relation są najprostszymi sposobami, aby pobrać informację z dwóch tabel jednocześnie. Jednak nie można ustawić akcji po usunięciu lub aktualizacji rodziców, jak to było z adnotacją @ForeignKey.