MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Basic Room is straightforward. Relations between entities, full-text search, complex queries, and custom type converters are where most developers hit walls. Here's how to handle the advanced cases correctly.
On this page
You've set up Room, you have CRUD working, your basic queries run. Now you need foreign keys, one-to-many relations, full-text search, or a column type Room doesn't understand natively. Here's how to handle them.
Room stores primitive types. For anything else, you need a
TypeConverter// Store LocalDate as String
class DateConverters {
@TypeConverter
fun fromLocalDate(date: LocalDate?): String? = date?.toString()
@TypeConverter
fun toLocalDate(dateString: String?): LocalDate? =
dateString?.let { LocalDate.parse(it) }
}
// Store List<String> as JSON string
class ListConverters {
private val json = Json { ignoreUnknownKeys = true }
@TypeConverter
fun fromList(list: List<String>?): String? =
list?.let { json.encodeToString(it) }
@TypeConverter
fun toList(jsonString: String?): List<String>? =
jsonString?.let { json.decodeFromString(it) }
}
// Register with the database
@Database(entities = [TaskEntity::class], version = 1)
@TypeConverters(DateConverters::class, ListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
}[!WARNING] Avoid storing complex objects as JSON blobs in Room columns. It defeats the purpose of a relational database and makes querying difficult. Store related data as separate entities with foreign keys.
A
ProjectTasks@Entity(tableName = "projects")
data class ProjectEntity(
@PrimaryKey val id: String,
val name: String
)
@Entity(
tableName = "tasks",
foreignKeys = [ForeignKey(
entity = ProjectEntity::class,
parentColumns = ["id"],
childColumns = ["projectId"],
onDelete = ForeignKey.CASCADE // Delete tasks when project is deleted
)],
indices = [Index("projectId")] // Required for foreign key performance
)
data class TaskEntity(
@PrimaryKey val id: String,
val title: String,
val projectId: String
)
// Data class for the join result
data class ProjectWithTasks(
@Embedded val project: ProjectEntity,
@Relation(
parentColumn = "id",
entityColumn = "projectId"
)
val tasks: List<TaskEntity>
)
// DAO
@Dao
interface ProjectDao {
@Transaction // Required for @Relation queries
@Query("SELECT * FROM projects WHERE id = :projectId")
fun getProjectWithTasks(projectId: String): Flow<ProjectWithTasks>
@Transaction
@Query("SELECT * FROM projects")
fun getAllProjectsWithTasks(): Flow<List<ProjectWithTasks>>
}@Transaction@RelationA
TaskTagsTagTasks@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey val id: String,
val name: String
)
// Junction table
@Entity(
tableName = "task_tags",
primaryKeys = ["taskId", "tagId"],
foreignKeys = [
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["taskId"]),
ForeignKey(entity = TagEntity::class, parentColumns = ["id"], childColumns = ["tagId"])
]
)
data class TaskTagCrossRef(
val taskId: String,
val tagId: String
)
data class TaskWithTags(
@Embedded val task: TaskEntity,
@Relation(
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(TaskTagCrossRef::class)
)
val tags: List<TagEntity>
)For search functionality, FTS4/FTS5 is dramatically faster than
LIKE '%query%'@Fts4(contentEntity = TaskEntity::class)
@Entity(tableName = "tasks_fts")
data class TaskFtsEntity(
@PrimaryKey @ColumnInfo(name = "rowid") val rowId: Int,
val title: String,
val description: String
)
@Dao
interface TaskSearchDao {
@Query("""
SELECT tasks.* FROM tasks
INNER JOIN tasks_fts ON tasks.id = tasks_fts.rowid
WHERE tasks_fts MATCH :query
ORDER BY rank
""")
fun searchTasks(query: String): Flow<List<TaskEntity>>
}
// Usage — note the * wildcard for prefix matching
val results = searchDao.searchTasks("buy*") // Matches "buy", "buying", "buyer"FTS search is indexed and significantly faster than
LIKEFor dynamic queries where the structure isn't known at compile time:
@Dao
interface TaskDao {
@RawQuery(observedEntities = [TaskEntity::class])
fun getTasksByRawQuery(query: SupportSQLiteQuery): Flow<List<TaskEntity>>
}
// Build the query dynamically
fun buildFilterQuery(
completed: Boolean?,
priority: Priority?,
tagId: String?
): SupportSQLiteQuery {
val queryBuilder = StringBuilder("SELECT * FROM tasks WHERE 1=1")
val args = mutableListOf<Any>()
completed?.let {
queryBuilder.append(" AND completed = ?")
args.add(if (it) 1 else 0)
}
priority?.let {
queryBuilder.append(" AND priority = ?")
args.add(it.name)
}
tagId?.let {
queryBuilder.append(" AND id IN (SELECT taskId FROM task_tags WHERE tagId = ?)")
args.add(it)
}
queryBuilder.append(" ORDER BY createdAt DESC")
return SimpleSQLiteQuery(queryBuilder.toString(), args.toTypedArray())
}Every schema change requires a migration. Don't use
fallbackToDestructiveMigration()val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
// Add a new column
db.execSQL("ALTER TABLE tasks ADD COLUMN priority TEXT NOT NULL DEFAULT 'MEDIUM'")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// Create a new table
db.execSQL("""
CREATE TABLE IF NOT EXISTS tags (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL
)
""")
}
}
// In your database builder
Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.build()Always test migrations with
MigrationTestHelperTypeConverter@Relation@TransactionLIKE '%query%'@RawQueryfallbackToDestructiveMigration()Sudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read