Relationships are explicit in Room 234 . A reading note references its book with a foreign key, and a data class with @Embedded and @Relation describes "a book with its notes". Room fills it with two queries, so the DAO function in Reactive Queries carries @Transaction to give both the same snapshot.
@Entity(
tableName = "notes",
foreignKeys = [ForeignKey(entity = BookEntity::class, parentColumns = ["id"],
childColumns = ["bookId"], onDelete = ForeignKey.CASCADE)],
indices = [Index("bookId")],
)
data class NoteEntity(
@PrimaryKey(autoGenerate = true) val noteId: Long = 0,
val bookId: Int,
val text: String,
val createdAt: Instant, // SQLite has no date type: see Converters
)
// 5.25.3 a one-to-many relation, assembled by Room from two queries
data class BookWithNotes(
@Embedded val book: BookEntity,
@Relation(parentColumns = ["id"], entityColumns = ["bookId"]) val notes: List<NoteEntity>,
)
class Converters {
@ColumnTypeConverter fun toEpochMillis(value: Instant): Long = value.toEpochMilli()
@ColumnTypeConverter fun fromEpochMillis(value: Long): Instant = Instant.ofEpochMilli(value)
}Room 3.0 changed two details that older tutorials get wrong: @Relation takes arrays (parentColumns, entityColumns; Room 2.x had parentColumn), and converters are @ColumnTypeConverter. The Room 2.x form failed here with Cannot have empty 'parentColumns' in @Relation. CASCADE deletes a book's notes with it, and the index on bookId keeps that fast. Many-to-many relations add a cross-reference entity through @Relation(associateBy = Junction(...)). The converter stores each Instant as UTC epoch milliseconds (1790285143957 for the first note), and the notes screen formats it for display; never store local-time strings.