Android Architecture Components: Room – relacje

Jest to cykl artykułów poświęcony komponentom architektury Androida. Wszystkie artykuły znajdziesz na tej stronie.

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.