Android Room:資料表關聯

Photo by Jonathan Francisca on Unsplash
Photo by Jonathan Francisca on Unsplash
在使用資料庫時,我們查詢的資料可能會關聯到多個資料表。Android Room 允許我們定義物件間的關聯。在查詢資料時,Android Room 會自動連同關聯的資料一起讀取出來。本文章將介紹如何定義這些關聯。

在使用資料庫時,我們查詢的資料可能會關聯到多個資料表。Android Room 允許我們定義物件間的關聯。在查詢資料時,Android Room 會自動連同關聯的資料一起讀取出來。本文章將介紹如何定義這些關聯。

Room

本文章中,我們不會介紹如何使用 Room。如果你還不熟悉 Room 的話,可先參考以下文章。

內嵌物件

假設我們有一個資料表 books 如下。它包含了書本和作者的資料。

Embedded author.
Embedded author.

Room 提供一個物件內嵌的方法。如下程式碼中,Book 包含書本的資料,而將作者的資料放在 Author 中。然後將 Author 內嵌到 Book 中。這樣可以使程式碼更加地結構化。使用方式就是在 Book::author 前面加上 @Embedded

值得注意的是,Author 並不是一個 entity,它只是一個包含 books 中的一些資料的物件。所以不要在 Author 上加上 @Entity

@Entity("books")
data class Book(
    @PrimaryKey val bookId: String,
    val bookName: String,
    @Embedded val author: Author,
)

data class Author(
    val authorName: String,
    val authorEmail: String,
)
@Dao
interface BookDao {
    @Upsert
    suspend fun upsert(entity: Book)

    @Query("SELECT * FROM books")
    fun findAll(): Flow<List<Book>>
}
val dao = BookDatabase.getInstance(context).bookDao()

dao.upsert(
    Book(
        "b1",
        "How to read a book",
        Author("Charles", "charles@gmail.com"),
    )
)
dao.upsert(
    Book(
        "b2",
        "Atomic Habits",
        Author("James", "james@gmail.com"),
    )
)

dao.findAll()
    .collect {
        Log.d("MainViewModel", "books=$it")
    }
}

另外,我們可以在 @Embedded 中加上 prefix 字串來改善 Author 的 fields 的命名。

Embedded author with prefix.
Embedded author with prefix.
@Entity("books")
data class Book(
    @PrimaryKey @ColumnInfo("book_id") val bookId: String,
    val name: String,
    @Embedded(prefix = "author_") val author: Author,
)

data class Author(
    val name: String,
    val email: String,
)

一對一關係

接下來,我們想要將作者的資料從 books 中分離到另一個資料表 authors,如下圖。和之前將書本和作者資料放在同一個資料表相比,此種方式在實作上是比較常見的。

1 to 1 relationship.
1 to 1 relationship.

Room 可以在查詢 books 時,也會將在 authors 中相關的資料一起讀取出來。在以下的程式碼中,我們要將 Book 和 Author 加上 @Entity,因為他們都是資料表。

宣告 BookAndAuthor 物件,在裡面宣告一個 Book 和一個 Author。因為我們主要是查詢 books 資料表,然後希望可以一起讀取在 authors 中相關的資料,所以用 @Embedded 將 Book 內嵌到 BookAndAuthor 裡面。然後,用 @Relation 來定義 Book 和 Author 之間的關係。用 parentColumn 指定在 Book 中要被關聯的 field,也就是 Book::id。然後,用 entityColumn 指定在 Author 中要關聯到 parentColumn 的 field,也就是 Author::bookId。

@Entity("books")
data class Book(
    @PrimaryKey val id: String,
    val name: String,
)

@Entity("authors")
data class Author(
    @PrimaryKey val id: String,
    val name: String,
    val email: String,
    val bookId: String,
)

data class BookAndAuthor(
    @Embedded val book: Book,

    @Relation(
        parentColumn = "id",
        entityColumn = "bookId",
    )
    val author: Author,
)

在 BookAndAuthor 中定義好 Book 與 Author 間的關聯。然後,在 BookDao 中的 findAll(),我們只需要查詢 books 即可,Room 就會依照定義好的關聯,將在 authors 中相關的資料一起讀取出來。

另外,我們還要在 BookDao::findAll() 上加上 @Transaction。因為 Room 實際上會執行兩個 queries,為了確保整個查詢動作是 atomically,我們必須要加上 @Transaction。

@Dao
interface BookDao {
    @Upsert
    suspend fun upsert(entity: Book)

    @Transaction
    @Query("SELECT * FROM books")
    fun findAll(): Flow<List<BookAndAuthor>>
}

@Dao
interface AuthorDao {
    @Upsert
    suspend fun upsert(entity: Author)
}
val db = BookDatabase.getInstance(context)

db.bookDao().apply {
    upsert(Book("b1", "How to read a book"))
    upsert(Book("b2", "Atomic Habits"))
}
db.authorDao().apply {
    upsert(Author("a1", "Charles", "charles@gmail.com", "b1"))
    upsert(Author("a2", "James", "james@gmail.com", "b2"))
}

db.bookDao().findAll()
    .collect {
        Log.d("WAYNESTALK", "books=$it")
    }

一對多關係

至目前為止,booksauthors 是一對一的關係。也就是說,一本書只能有一位作者。現在我們想調整成,一本書可以有多位作者。這也就是一對多的關係,如下圖。

1 to many relationship.
1 to many relationship.

程式碼與一對一關係幾乎相同,差別在於我們將 BookAuthor 重新命名為 BookAuthors。然後,將 BookAuthor::product 改為 BookAuthors::products,且將型態改為 List<Author>。

@Entity("books")
data class Book(
    @PrimaryKey val id: String,
    val name: String,
)

@Entity("authors")
data class Author(
    @PrimaryKey val id: String,
    val name: String,
    val email: String,
    val bookId: String,
)

data class BookAndAuthors(
    @Embedded val book: Book,

    @Relation(
        parentColumn = "id",
        entityColumn = "bookId",
    )
    val authors: List<Author>,
)
@Dao
interface BookDao {
    @Upsert
    suspend fun upsert(entity: Book)

    @Transaction
    @Query("SELECT * FROM books")
    fun findAll(): Flow<List<BookAndAuthors>>
}

@Dao
interface AuthorDao {
    @Upsert
    suspend fun upsert(entity: Author)
}
val db = BookDatabase.getInstance(context)

db.bookDao().apply {
    upsert(Book("b1", "How to read a book"))
    upsert(Book("b2", "Atomic Habits"))
}
db.authorDao().apply {
    upsert(Author("a11", "Charles", "charles@gmail.com", "b1"))
    upsert(Author("a12", "Mortimer", "mortimer@gmail.com", "b1"))
    upsert(Author("a2", "James", "james@gmail.com", "b2"))
}

db.bookDao().findAll()
    .collect {
        Log.d("WAYNESTALK", "books=$it")
    }

多對多關係

最後一種關係是多對多的關係。也就是說,一本書可以有多位作者,而一位作者也可以有多本書,如下圖。在使用多對多關係時,我們需要一個額外的 cross-reference table,如圖中的 book_author_cross_ref。它記錄著 booksauthors 的對應關係。

Many to many relationship.
Many to many relationship.

在以下的程式碼中,我們新增 BookAuthorCrossRef 作為 booksauthors 之間的 cross-reference table。BookAuthorCrossRef 中的 bookId 和 authorId 必須要設定為 primary keys。

如果你想要查詢書本資料,並且連同其關聯的作者資料一起讀取起來的話,就使用 BookAndAuthors。在 BookAndAuthors 中,用 @Relation 來定義 Book 和 Author 之間的關係。用 parentColumn 指定在 Book 中要被關聯的 field,也就是 Book::id。然後,用 entityColumn 指定在 Author 中要關聯到 parentColumn 的 field,也就是 Author::bookId。此外,再用 associateBy 指定 cross-reference table。

反之,如果你想要查詢作者資料,並且連同其關聯的書本資料一起讀取起來的話,就使用 AuthorAndBooks。

@Entity("books")
data class Book(
    @PrimaryKey val bookId: String,
    val name: String,
)

@Entity("authors")
data class Author(
    @PrimaryKey val authorId: String,
    val name: String,
    val email: String,
)

@Entity(
    tableName = "book_author_cross_ref",
    primaryKeys = ["bookId", "authorId"],
)
data class BookAuthorCrossRef(
    val bookId: String,
    val authorId: String,
)

data class BookAndAuthors(
    @Embedded val book: Book,

    @Relation(
        parentColumn = "bookId",
        entityColumn = "authorId",
        associateBy = Junction(BookAuthorCrossRef::class),
    )
    val authors: List<Author>,
)

data class AuthorAndBooks(
    @Embedded val author: Author,

    @Relation(
        parentColumn = "authorId",
        entityColumn = "bookId",
        associateBy = Junction(BookAuthorCrossRef::class),
    )
    val books: List<Book>,
)
@Dao
interface BookDao {
    @Upsert
    suspend fun upsert(entity: Book)

    @Transaction
    @Query("SELECT * FROM books")
    fun findAll(): List<BookAndAuthors>
}

@Dao
interface AuthorDao {
    @Upsert
    suspend fun upsert(entity: Author)

    @Transaction
    @Query("SELECT * FROM authors")
    fun findAll(): List<AuthorAndBooks>
}

@Dao
interface BookAuthorCrossRefDao {
    @Upsert
    suspend fun upsert(entity: BookAuthorCrossRef)
}
val db = BookDatabase.getInstance(context)

db.bookDao().apply {
    upsert(Book("b1", "How to read a book"))
    upsert(Book("b2", "Atomic Habits"))
}
db.authorDao().apply {
    upsert(Author("a11", "Charles", "charles@gmail.com"))
    upsert(Author("a12", "Mortimer", "mortimer@gmail.com"))
    upsert(Author("a2", "James", "james@gmail.com"))
}
db.bookAuthorCrossRefDao().apply {
    upsert(BookAuthorCrossRef("b1", "a11"))
    upsert(BookAuthorCrossRef("b1", "a12"))
    upsert(BookAuthorCrossRef("b2", "a2"))
}

val books = db.bookDao().findAll()
Log.d("WAYNESTALK", "books=$books")

val authors = db.authorDao().findAll()
Log.d("WAYNESTALK", "authors=$authors")

Foreign Keys

Room 還允許我們定義 foreign key。下圖中,booksauthors 是一對多的關係,其中 authorsbookId 為 foreign key。

1 to many with Foreign Key.
1 to many with Foreign Key.

在以下程式碼中,我們使用 @ForeignKey 定義 foreign key。在 entity 中指定 foreign key 對應到 Book,而在 parentColumns 中指定 foreign key 對應到 Book 的 bookId。在 childColumns 中指定 foreign key 的 field,也就是 Author::bookId。

onDelete 是指當 Author::bookId 對應的 Book 被刪除時,SQLite 要執行的動作。這裡我們指定為 ForiegnKey.CASCADE。當某本書被刪除時,擁有相同 bookId 的 Author 也都會被刪除。

@Entity("books")
data class Book(
    @PrimaryKey val id: String,
    val name: String,
)

@Entity(
    tableName = "authors",
    foreignKeys = [
        ForeignKey(
            entity = Book::class,
            parentColumns = ["id"],
            childColumns = ["bookId"],
            onDelete = ForeignKey.CASCADE,
        )
    ]
)
data class Author(
    @PrimaryKey val authorId: String,
    val name: String,
    val email: String,
    val bookId: String,
)

data class BookAndAuthors(
    @Embedded val book: Book,

    @Relation(
        parentColumn = "bookId",
        entityColumn = "bookId",
    )
    val authors: List<Author>,
)
@Dao
interface BookDao {
    @Upsert
    suspend fun upsert(entity: Book)

    @Transaction
    @Query("SELECT * FROM books")
    fun findAll(): List<BookAndAuthors>

    @Delete
    suspend fun delete(entity: Book)
}

@Dao
interface AuthorDao {
    @Upsert
    suspend fun upsert(entity: Author)
}
val db = BookDatabase.getInstance(context)

db.bookDao().apply {
    upsert(Book("b1", "How to read a book"))
    upsert(Book("b2", "Atomic Habits"))
}
db.authorDao().apply {
    upsert(Author("a11", "Charles", "charles@gmail.com", "b1"))
    upsert(Author("a12", "Mortimer", "mortimer@gmail.com", "b1"))
    upsert(Author("a2", "James", "james@gmail.com", "b2"))
}

var books = db.bookDao().findAll()
Log.d("WAYNESTALK", "books=$books")

db.bookDao().delete(Book("b1", "How to read a book"))

books = db.bookDao().findAll()
Log.d("WAYNESTALK", "books=$books")

結語

Android Room 允許我們定義物件間的關係,尤其當資料表間的關係複雜時,是非常有用的工具。它會自動連同關聯的資料一起讀取出來,而不需要我們手動寫程式在多個資料表間一一讀取。這增加了開發的速度,也降低了錯誤的發生。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Hans-Jurgen Mager on Unsplash
Read More

Kotlin Coroutine 教學

Kotlin 的 coroutine 是用來取代 thread。它不會阻塞 thread,而且還可以被取消。Coroutine core 會幫你管理 thread 的數量,讓你不需要自行管理,這也可以避免不小心建立過多的 thread。
Read More