How to efficiently group expenses by category in Room (Kotlin/Android) instead of loading all rows and using groupBy in memory?
I’m building an expense tracker in Kotlin with Room. Each transaction stores its category as a String (the enum name), and I need to show the total spent per category.
Right now my DAO only returns all of a user’s transactions:
@Query("SELECT * FROM transacciones WHERE usuarioId = :usuarioId ORDER BY id DESC")
suspend fun porUsuario(usuarioId: Long): List<TransaccionEntity>
and I group them in memory:
repositorio.listar(usuarioId)
.filter { it.tipo == TipoMovimiento.EGRESO }
.groupBy { it.categoria }
.mapValues { (_, lista) -> lista.sumOf { it.monto } }
It works, but it loads every row just to compute a handful of totals, and I’m worried it won’t scale as the table grows.
What is the recommended way to optimize this?
Should I use a GROUP BY query in the DAO (e.g. SELECT categoria, SUM(monto) AS total ... GROUP BY categoria) and map the result to a small data class?
Should the DAO return a Flow so the UI updates automatically when a transaction is added or deleted?
Is storing the category as a String a problem for this kind of query, or would a TypeConverter / separate categories table be better?
1. Create a data class for the aggregated result.
data class CategoryExpenseTotal(
val categoria: String,
val total: Double
)
Room can map the columns returned by the SQL query into this class.
@Dao
interface TransaccionDao {
@Query("""
SELECT categoria, SUM(monto) AS total
FROM transacciones
WHERE usuarioId = :usuarioId
AND tipo = :tipo
GROUP BY categoria
ORDER BY total DESC
""")
fun getExpenseTotalsByCategory(
usuarioId: Long,
tipo: String
): Flow<List<CategoryExpenseTotal>>
}
3. Collect the results in the UI.
class ExpenseViewModel(
private val dao: TransaccionDao
) : ViewModel() {
fun observeCategoryTotals(
usuarioId: Long
): Flow<List<CategoryExpenseTotal>> {
return dao.getExpenseTotalsByCategory(
usuarioId = usuarioId,
tipo = "EGRESO"
)
}
}
Observe the results from an Activity or Fragment using a lifecycle-aware coroutine
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.observeCategoryTotals(usuarioId)
.collect { totals ->
// Update your RecyclerView or chart adapter.
adapter.submitList(totals)
}
}
}