Implementing Foreign Keys in Room with Jetpack Compose

Jetpack Compose has revolutionized the way Android developers build user interfaces. Pairing it with Room, Android's powerful SQLite object-mapping library, creates an efficient toolkit for managing app data. One essential feature of Room is its ability to define and enforce relationships between tables using foreign keys. In this blog post, we'll explore how to implement foreign keys in Room and integrate them seamlessly with Jetpack Compose, focusing on advanced concepts, best practices, and use cases.

Understanding Foreign Keys in Room

Foreign keys establish a relationship between two database tables, ensuring data integrity and enabling efficient queries. In Room, foreign keys help link entities, such as a parent "Category" table and a child "Item" table. Here's a quick recap:

  • A primary key uniquely identifies rows in a table.

  • A foreign key in the child table refers to a primary key in the parent table.

Room leverages foreign keys for cascading updates, deletions, and ensuring that relationships remain consistent.

Defining Foreign Keys in Room Entities

To define foreign keys in Room, use the @Entity annotation with the foreignKeys attribute. Below is an example of a "Category" and "Item" relationship:

Category Entity

@Entity
data class Category(
    @PrimaryKey(autoGenerate = true) val categoryId: Long,
    val categoryName: String
)

Item Entity with Foreign Key

@Entity(
    foreignKeys = [
        ForeignKey(
            entity = Category::class,
            parentColumns = ["categoryId"],
            childColumns = ["categoryOwnerId"],
            onDelete = ForeignKey.CASCADE
        )
    ]
)
data class Item(
    @PrimaryKey(autoGenerate = true) val itemId: Long,
    val itemName: String,
    val categoryOwnerId: Long // Foreign key reference
)

In the above example:

  • entity specifies the parent entity.

  • parentColumns and childColumns define the relationship.

  • onDelete = ForeignKey.CASCADE ensures that deleting a parent automatically deletes its children.

Database Setup with Room

Define a RoomDatabase that integrates these entities and provides DAOs for data access.

AppDatabase

@Database(entities = [Category::class, Item::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun categoryDao(): CategoryDao
    abstract fun itemDao(): ItemDao
}

Configure the database instance:

val db = Room.databaseBuilder(
    context,
    AppDatabase::class.java,
    "app_database"
).build()

Creating DAOs for Data Access

Data Access Objects (DAOs) provide methods to interact with the database. Let’s create DAOs for the Category and Item entities.

CategoryDao

@Dao
interface CategoryDao {
    @Insert
    suspend fun insertCategory(category: Category): Long

    @Query("SELECT * FROM Category")
    suspend fun getAllCategories(): List<Category>
}

ItemDao

@Dao
interface ItemDao {
    @Insert
    suspend fun insertItem(item: Item): Long

    @Query("SELECT * FROM Item WHERE categoryOwnerId = :categoryId")
    suspend fun getItemsForCategory(categoryId: Long): List<Item>
}

Integrating with Jetpack Compose

Now, let’s bridge Room's data-handling capabilities with Jetpack Compose's UI-building power. Compose’s state management and lifecycle-aware components make it ideal for displaying and interacting with Room data.

Displaying Categories and Items

Use Compose’s LazyColumn to display a list of categories and their corresponding items.

ViewModel for Data Handling

@HiltViewModel
class MainViewModel @Inject constructor(
    private val categoryDao: CategoryDao,
    private val itemDao: ItemDao
) : ViewModel() {

    private val _categories = mutableStateOf<List<Category>>(emptyList())
    val categories: State<List<Category>> = _categories

    init {
        viewModelScope.launch {
            _categories.value = categoryDao.getAllCategories()
        }
    }

    fun getItemsForCategory(categoryId: Long): List<Item> {
        return runBlocking { itemDao.getItemsForCategory(categoryId) }
    }
}

UI Code

@Composable
fun CategoryList(viewModel: MainViewModel) {
    val categories by viewModel.categories

    LazyColumn {
        items(categories) { category ->
            CategoryItem(category, viewModel)
        }
    }
}

@Composable
fun CategoryItem(category: Category, viewModel: MainViewModel) {
    val items = viewModel.getItemsForCategory(category.categoryId)

    Column {
        Text(text = category.categoryName, style = MaterialTheme.typography.h6)
        LazyColumn {
            items(items) { item ->
                Text(text = item.itemName)
            }
        }
    }
}

Best Practices and Advanced Tips

1. Use Cascading Deletes Wisely

While ForeignKey.CASCADE simplifies relationship handling, ensure you use it judiciously. Unintended deletions can disrupt user data.

2. Optimize Queries with @Transaction

For complex relationships, use @Transaction to fetch parent and child data in a single operation:

@Transaction
@Query("SELECT * FROM Category")
suspend fun getCategoriesWithItems(): List<CategoryWithItems>

3. Leverage State Management in Compose

Keep Compose’s state updated with changes in the database by using Flow or LiveData in DAOs:

@Query("SELECT * FROM Category")
fun getAllCategoriesFlow(): Flow<List<Category>>

Testing and Debugging

Unit Tests for DAOs

Use Room's in-memory database for testing:

@RunWith(AndroidJUnit4::class)
class DaoTest {
    private lateinit var db: AppDatabase

    @Before
    fun setup() {
        db = Room.inMemoryDatabaseBuilder(
            InstrumentationRegistry.getInstrumentation().context,
            AppDatabase::class.java
        ).build()
    }

    @Test
    fun testInsertCategory() = runBlocking {
        val category = Category(0, "Sample Category")
        val id = db.categoryDao().insertCategory(category)
        assertNotNull(id)
    }
}

Debugging Foreign Key Issues

Enable SQLite’s foreign key constraints during testing:

val db = Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
    .addCallback(object : RoomDatabase.Callback() {
        override fun onOpen(db: SupportSQLiteDatabase) {
            super.onOpen(db)
            db.execSQL("PRAGMA foreign_keys=ON;")
        }
    })
    .build()

Conclusion

By combining Room’s foreign key capabilities with Jetpack Compose’s modern UI paradigm, you can build robust and data-driven Android applications. Following the best practices and leveraging advanced features ensures your app is efficient, maintainable, and user-friendly. Whether you're building a simple inventory app or a complex multi-entity solution, mastering these techniques will elevate your development journey.