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.
