@mmerterden/multi-agent-pipeline 20.1.0 → 20.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/docs/facts.json +5 -5
- package/manifest.json +52 -31
- package/package.json +1 -1
- package/pipeline/skills/.skill-manifest.json +36 -20
- package/pipeline/skills/.skills-index.json +75 -9
- package/pipeline/skills/shared/README.md +13 -7
- package/pipeline/skills/shared/external/android-architecture/SKILL.md +71 -0
- package/pipeline/skills/shared/external/android-architecture/references/patterns.md +142 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/SKILL.md +314 -0
- package/pipeline/skills/shared/external/android-build-quality-gates/references/patterns.md +432 -0
- package/pipeline/skills/shared/external/android-datastore/SKILL.md +236 -0
- package/pipeline/skills/shared/external/android-datastore/references/patterns.md +297 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/SKILL.md +249 -0
- package/pipeline/skills/shared/external/android-design-tokens-codegen/references/patterns.md +270 -0
- package/pipeline/skills/shared/external/android-jetpack-compose-expert/SKILL.md +62 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/SKILL.md +255 -0
- package/pipeline/skills/shared/external/android-mvi-viewmodel/references/patterns.md +257 -0
- package/pipeline/skills/shared/external/android-performance/SKILL.md +86 -602
- package/pipeline/skills/shared/external/android-performance/references/patterns.md +659 -0
- package/pipeline/skills/shared/external/android-security/SKILL.md +117 -430
- package/pipeline/skills/shared/external/android-security/references/patterns.md +690 -0
- package/pipeline/skills/shared/external/{android_ui_verification → android-ui-verification}/SKILL.md +1 -1
- package/pipeline/skills/shared/external/api-security-best-practices/SKILL.md +35 -733
- package/pipeline/skills/shared/external/api-security-best-practices/references/auth.md +299 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/input-validation.md +255 -0
- package/pipeline/skills/shared/external/api-security-best-practices/references/rate-limiting.md +167 -0
- package/pipeline/skills/shared/external/app-intents/SKILL.md +39 -174
- package/pipeline/skills/shared/external/app-intents/references/appintents-advanced.md +178 -0
- package/pipeline/skills/shared/external/compose-components/SKILL.md +48 -0
- package/pipeline/skills/shared/external/compose-components/references/patterns.md +200 -0
- package/pipeline/skills/shared/external/compose-navigation/SKILL.md +66 -3
- package/pipeline/skills/shared/external/compose-navigation/references/patterns.md +191 -0
- package/pipeline/skills/shared/external/compose-testing/SKILL.md +107 -397
- package/pipeline/skills/shared/external/compose-testing/references/patterns.md +631 -0
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/SKILL.md +121 -449
- package/pipeline/skills/shared/external/gradle-kotlin-dsl/references/patterns.md +715 -0
- package/pipeline/skills/shared/external/kotlin-coroutines-expert/SKILL.md +143 -0
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +27 -102
- package/pipeline/skills/shared/external/mapkit-location/references/mapkit-patterns.md +42 -0
- package/pipeline/skills/shared/external/retrofit-networking/SKILL.md +94 -383
- package/pipeline/skills/shared/external/retrofit-networking/references/patterns.md +640 -0
- package/pipeline/skills/shared/external/room-database/SKILL.md +101 -440
- package/pipeline/skills/shared/external/room-database/references/patterns.md +614 -0
- package/pipeline/skills/shared/external/storekit/SKILL.md +69 -343
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +371 -0
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +25 -101
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +107 -0
- package/pipeline/skills/skills-index.md +8 -2
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
# Room Database Patterns
|
|
2
|
+
|
|
3
|
+
Full code examples backing the guidance in `SKILL.md`. Headings mirror the
|
|
4
|
+
SKILL.md sections.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Entity Definition](#entity-definition)
|
|
9
|
+
- [DAO Interface](#dao-interface)
|
|
10
|
+
- [Database Setup](#database-setup)
|
|
11
|
+
- [Reactive Queries with Flow](#reactive-queries-with-flow)
|
|
12
|
+
- [TypeConverters](#typeconverters)
|
|
13
|
+
- [Relations](#relations)
|
|
14
|
+
- [Migration Strategies](#migration-strategies)
|
|
15
|
+
- [Hilt Integration](#hilt-integration)
|
|
16
|
+
- [Testing with In-Memory Database](#testing-with-in-memory-database)
|
|
17
|
+
- [Encrypted Database (SQLCipher)](#encrypted-database-sqlcipher)
|
|
18
|
+
- [Async Database Provisioning](#async-database-provisioning)
|
|
19
|
+
- [Runtime Hygiene](#runtime-hygiene)
|
|
20
|
+
|
|
21
|
+
## Entity Definition
|
|
22
|
+
|
|
23
|
+
```kotlin
|
|
24
|
+
@Entity(
|
|
25
|
+
tableName = "flights",
|
|
26
|
+
indices = [
|
|
27
|
+
Index(value = ["origin_code", "destination_code"]),
|
|
28
|
+
Index(value = ["departure_time"]),
|
|
29
|
+
],
|
|
30
|
+
)
|
|
31
|
+
data class FlightEntity(
|
|
32
|
+
@PrimaryKey
|
|
33
|
+
val id: String,
|
|
34
|
+
@ColumnInfo(name = "origin_code")
|
|
35
|
+
val originCode: String,
|
|
36
|
+
@ColumnInfo(name = "origin_name")
|
|
37
|
+
val originName: String,
|
|
38
|
+
@ColumnInfo(name = "destination_code")
|
|
39
|
+
val destinationCode: String,
|
|
40
|
+
@ColumnInfo(name = "destination_name")
|
|
41
|
+
val destinationName: String,
|
|
42
|
+
@ColumnInfo(name = "departure_time")
|
|
43
|
+
val departureTime: String,
|
|
44
|
+
@ColumnInfo(name = "arrival_time")
|
|
45
|
+
val arrivalTime: String,
|
|
46
|
+
@ColumnInfo(name = "price_amount")
|
|
47
|
+
val priceAmount: Double,
|
|
48
|
+
@ColumnInfo(name = "price_currency")
|
|
49
|
+
val priceCurrency: String,
|
|
50
|
+
val status: String,
|
|
51
|
+
@ColumnInfo(name = "updated_at", defaultValue = "0")
|
|
52
|
+
val updatedAt: Long = System.currentTimeMillis(),
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Composite Primary Key
|
|
57
|
+
|
|
58
|
+
```kotlin
|
|
59
|
+
@Entity(
|
|
60
|
+
tableName = "booking_passengers",
|
|
61
|
+
primaryKeys = ["booking_id", "passenger_id"],
|
|
62
|
+
)
|
|
63
|
+
data class BookingPassengerEntity(
|
|
64
|
+
@ColumnInfo(name = "booking_id")
|
|
65
|
+
val bookingId: String,
|
|
66
|
+
@ColumnInfo(name = "passenger_id")
|
|
67
|
+
val passengerId: String,
|
|
68
|
+
@ColumnInfo(name = "seat_number")
|
|
69
|
+
val seatNumber: String?,
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## DAO Interface
|
|
74
|
+
|
|
75
|
+
```kotlin
|
|
76
|
+
@Dao
|
|
77
|
+
interface FlightDao {
|
|
78
|
+
|
|
79
|
+
@Query("SELECT * FROM flights WHERE origin_code = :origin AND destination_code = :destination ORDER BY departure_time ASC")
|
|
80
|
+
fun getFlights(origin: String, destination: String): Flow<List<FlightEntity>>
|
|
81
|
+
|
|
82
|
+
@Query("SELECT * FROM flights WHERE id = :id")
|
|
83
|
+
fun getFlightById(id: String): Flow<FlightEntity?>
|
|
84
|
+
|
|
85
|
+
@Query("SELECT * FROM flights WHERE id = :id")
|
|
86
|
+
suspend fun getFlightByIdOnce(id: String): FlightEntity?
|
|
87
|
+
|
|
88
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
89
|
+
suspend fun insertFlight(flight: FlightEntity)
|
|
90
|
+
|
|
91
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
92
|
+
suspend fun insertFlights(flights: List<FlightEntity>)
|
|
93
|
+
|
|
94
|
+
@Upsert
|
|
95
|
+
suspend fun upsertFlights(flights: List<FlightEntity>)
|
|
96
|
+
|
|
97
|
+
@Update
|
|
98
|
+
suspend fun updateFlight(flight: FlightEntity)
|
|
99
|
+
|
|
100
|
+
@Delete
|
|
101
|
+
suspend fun deleteFlight(flight: FlightEntity)
|
|
102
|
+
|
|
103
|
+
@Query("DELETE FROM flights WHERE id = :id")
|
|
104
|
+
suspend fun deleteFlightById(id: String)
|
|
105
|
+
|
|
106
|
+
@Query("DELETE FROM flights WHERE updated_at < :threshold")
|
|
107
|
+
suspend fun deleteStaleFlights(threshold: Long)
|
|
108
|
+
|
|
109
|
+
@Query("SELECT COUNT(*) FROM flights")
|
|
110
|
+
fun getFlightCount(): Flow<Int>
|
|
111
|
+
|
|
112
|
+
@Transaction
|
|
113
|
+
suspend fun replaceAllFlights(flights: List<FlightEntity>) {
|
|
114
|
+
deleteAllFlights()
|
|
115
|
+
insertFlights(flights)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Query("DELETE FROM flights")
|
|
119
|
+
suspend fun deleteAllFlights()
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### @Upsert vs @Insert(onConflict = REPLACE)
|
|
124
|
+
|
|
125
|
+
| Strategy | Behavior | Use When |
|
|
126
|
+
|----------|----------|----------|
|
|
127
|
+
| `@Upsert` | Insert if absent, update if present (by PK) | Syncing remote data; preserves non-conflicting columns |
|
|
128
|
+
| `@Insert(REPLACE)` | Delete + re-insert on conflict | Simpler; triggers delete cascade |
|
|
129
|
+
| `@Insert(IGNORE)` | Skip on conflict | Inserting if not exists, no update needed |
|
|
130
|
+
|
|
131
|
+
## Database Setup
|
|
132
|
+
|
|
133
|
+
```kotlin
|
|
134
|
+
@Database(
|
|
135
|
+
entities = [
|
|
136
|
+
FlightEntity::class,
|
|
137
|
+
BookingEntity::class,
|
|
138
|
+
BookingPassengerEntity::class,
|
|
139
|
+
],
|
|
140
|
+
version = 2,
|
|
141
|
+
autoMigrations = [
|
|
142
|
+
AutoMigration(from = 1, to = 2),
|
|
143
|
+
],
|
|
144
|
+
exportSchema = true,
|
|
145
|
+
)
|
|
146
|
+
@TypeConverters(Converters::class)
|
|
147
|
+
abstract class AppDatabase : RoomDatabase() {
|
|
148
|
+
abstract fun flightDao(): FlightDao
|
|
149
|
+
abstract fun bookingDao(): BookingDao
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Always set `exportSchema = true` and configure the schema export directory in
|
|
154
|
+
`build.gradle.kts`:
|
|
155
|
+
|
|
156
|
+
```kotlin
|
|
157
|
+
ksp {
|
|
158
|
+
arg("room.schemaLocation", "$projectDir/schemas")
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Reactive Queries with Flow
|
|
163
|
+
|
|
164
|
+
DAO methods returning `Flow` automatically emit new values when the underlying
|
|
165
|
+
table changes.
|
|
166
|
+
|
|
167
|
+
```kotlin
|
|
168
|
+
// DAO returns Flow
|
|
169
|
+
@Query("SELECT * FROM flights WHERE origin_code = :origin")
|
|
170
|
+
fun getFlightsByOrigin(origin: String): Flow<List<FlightEntity>>
|
|
171
|
+
|
|
172
|
+
// Repository maps to domain models
|
|
173
|
+
class FlightRepositoryImpl @Inject constructor(
|
|
174
|
+
private val dao: FlightDao,
|
|
175
|
+
private val mapper: FlightMapper,
|
|
176
|
+
) : FlightRepository {
|
|
177
|
+
|
|
178
|
+
override fun getFlights(origin: String): Flow<List<Flight>> =
|
|
179
|
+
dao.getFlightsByOrigin(origin)
|
|
180
|
+
.map { entities -> entities.map(mapper::toDomain) }
|
|
181
|
+
.flowOn(Dispatchers.IO)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ViewModel collects in viewModelScope
|
|
185
|
+
@HiltViewModel
|
|
186
|
+
class FlightListViewModel @Inject constructor(
|
|
187
|
+
private val repository: FlightRepository,
|
|
188
|
+
) : ViewModel() {
|
|
189
|
+
|
|
190
|
+
val flights: StateFlow<List<Flight>> = repository.getFlights("IST")
|
|
191
|
+
.stateIn(
|
|
192
|
+
scope = viewModelScope,
|
|
193
|
+
started = SharingStarted.WhileSubscribed(5_000),
|
|
194
|
+
initialValue = emptyList(),
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Compose collects as state
|
|
199
|
+
@Composable
|
|
200
|
+
fun FlightListScreen(viewModel: FlightListViewModel = hiltViewModel()) {
|
|
201
|
+
val flights by viewModel.flights.collectAsStateWithLifecycle()
|
|
202
|
+
LazyColumn {
|
|
203
|
+
items(flights, key = { it.id }) { flight ->
|
|
204
|
+
FlightCard(flight = flight)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## TypeConverters
|
|
211
|
+
|
|
212
|
+
For types Room does not natively support (lists, enums, dates).
|
|
213
|
+
|
|
214
|
+
```kotlin
|
|
215
|
+
class Converters {
|
|
216
|
+
|
|
217
|
+
private val json = Json { ignoreUnknownKeys = true }
|
|
218
|
+
|
|
219
|
+
@TypeConverter
|
|
220
|
+
fun fromStringList(value: List<String>): String =
|
|
221
|
+
json.encodeToString(value)
|
|
222
|
+
|
|
223
|
+
@TypeConverter
|
|
224
|
+
fun toStringList(value: String): List<String> =
|
|
225
|
+
json.decodeFromString(value)
|
|
226
|
+
|
|
227
|
+
@TypeConverter
|
|
228
|
+
fun fromInstant(value: Instant?): Long? = value?.toEpochMilliseconds()
|
|
229
|
+
|
|
230
|
+
@TypeConverter
|
|
231
|
+
fun toInstant(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) }
|
|
232
|
+
|
|
233
|
+
@TypeConverter
|
|
234
|
+
fun fromFlightStatus(value: FlightStatus): String = value.name
|
|
235
|
+
|
|
236
|
+
@TypeConverter
|
|
237
|
+
fun toFlightStatus(value: String): FlightStatus = FlightStatus.valueOf(value)
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Register in `@Database`:
|
|
242
|
+
|
|
243
|
+
```kotlin
|
|
244
|
+
@Database(entities = [...], version = 1)
|
|
245
|
+
@TypeConverters(Converters::class)
|
|
246
|
+
abstract class AppDatabase : RoomDatabase()
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Relations
|
|
250
|
+
|
|
251
|
+
### @Embedded
|
|
252
|
+
|
|
253
|
+
Flatten a nested object into the parent table columns:
|
|
254
|
+
|
|
255
|
+
```kotlin
|
|
256
|
+
data class AddressEntity(
|
|
257
|
+
val street: String,
|
|
258
|
+
val city: String,
|
|
259
|
+
val country: String,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@Entity(tableName = "passengers")
|
|
263
|
+
data class PassengerEntity(
|
|
264
|
+
@PrimaryKey val id: String,
|
|
265
|
+
val name: String,
|
|
266
|
+
@Embedded(prefix = "address_")
|
|
267
|
+
val address: AddressEntity,
|
|
268
|
+
)
|
|
269
|
+
// Creates columns: id, name, address_street, address_city, address_country
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### @Relation (One-to-Many)
|
|
273
|
+
|
|
274
|
+
```kotlin
|
|
275
|
+
data class BookingWithPassengers(
|
|
276
|
+
@Embedded val booking: BookingEntity,
|
|
277
|
+
@Relation(
|
|
278
|
+
parentColumn = "id",
|
|
279
|
+
entityColumn = "booking_id",
|
|
280
|
+
)
|
|
281
|
+
val passengers: List<BookingPassengerEntity>,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
@Dao
|
|
285
|
+
interface BookingDao {
|
|
286
|
+
@Transaction
|
|
287
|
+
@Query("SELECT * FROM bookings WHERE id = :bookingId")
|
|
288
|
+
fun getBookingWithPassengers(bookingId: String): Flow<BookingWithPassengers?>
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Junction Table (Many-to-Many)
|
|
293
|
+
|
|
294
|
+
```kotlin
|
|
295
|
+
@Entity(
|
|
296
|
+
tableName = "flight_tags",
|
|
297
|
+
primaryKeys = ["flight_id", "tag_id"],
|
|
298
|
+
)
|
|
299
|
+
data class FlightTagCrossRef(
|
|
300
|
+
@ColumnInfo(name = "flight_id") val flightId: String,
|
|
301
|
+
@ColumnInfo(name = "tag_id") val tagId: String,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
data class FlightWithTags(
|
|
305
|
+
@Embedded val flight: FlightEntity,
|
|
306
|
+
@Relation(
|
|
307
|
+
parentColumn = "id",
|
|
308
|
+
entityColumn = "id",
|
|
309
|
+
associateBy = Junction(
|
|
310
|
+
value = FlightTagCrossRef::class,
|
|
311
|
+
parentColumn = "flight_id",
|
|
312
|
+
entityColumn = "tag_id",
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
val tags: List<TagEntity>,
|
|
316
|
+
)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Always annotate relation queries with `@Transaction` to ensure consistent reads.
|
|
320
|
+
|
|
321
|
+
## Migration Strategies
|
|
322
|
+
|
|
323
|
+
### Auto Migration (Room 2.4+)
|
|
324
|
+
|
|
325
|
+
For simple schema changes (add column, add table, add index):
|
|
326
|
+
|
|
327
|
+
```kotlin
|
|
328
|
+
@Database(
|
|
329
|
+
entities = [FlightEntity::class],
|
|
330
|
+
version = 3,
|
|
331
|
+
autoMigrations = [
|
|
332
|
+
AutoMigration(from = 1, to = 2),
|
|
333
|
+
AutoMigration(from = 2, to = 3, spec = Migration2To3::class),
|
|
334
|
+
],
|
|
335
|
+
)
|
|
336
|
+
abstract class AppDatabase : RoomDatabase()
|
|
337
|
+
|
|
338
|
+
// Spec needed for column/table renames or deletes
|
|
339
|
+
@RenameColumn(tableName = "flights", fromColumnName = "price", toColumnName = "price_amount")
|
|
340
|
+
class Migration2To3 : AutoMigrationSpec
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Manual Migration
|
|
344
|
+
|
|
345
|
+
For complex changes (data transformation, merging tables):
|
|
346
|
+
|
|
347
|
+
```kotlin
|
|
348
|
+
val MIGRATION_3_4 = object : Migration(3, 4) {
|
|
349
|
+
override fun migrate(db: SupportSQLiteDatabase) {
|
|
350
|
+
db.execSQL("""
|
|
351
|
+
CREATE TABLE IF NOT EXISTS bookings_new (
|
|
352
|
+
id TEXT NOT NULL PRIMARY KEY,
|
|
353
|
+
flight_id TEXT NOT NULL,
|
|
354
|
+
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
355
|
+
created_at INTEGER NOT NULL DEFAULT 0,
|
|
356
|
+
FOREIGN KEY (flight_id) REFERENCES flights(id) ON DELETE CASCADE
|
|
357
|
+
)
|
|
358
|
+
""")
|
|
359
|
+
db.execSQL("""
|
|
360
|
+
INSERT INTO bookings_new (id, flight_id, status, created_at)
|
|
361
|
+
SELECT id, flight_id, status, 0 FROM bookings
|
|
362
|
+
""")
|
|
363
|
+
db.execSQL("DROP TABLE bookings")
|
|
364
|
+
db.execSQL("ALTER TABLE bookings_new RENAME TO bookings")
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Register in Hilt module
|
|
369
|
+
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
|
|
370
|
+
.addMigrations(MIGRATION_3_4)
|
|
371
|
+
.build()
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Fallback to Destructive Migration
|
|
375
|
+
|
|
376
|
+
Only for development or non-critical data:
|
|
377
|
+
|
|
378
|
+
```kotlin
|
|
379
|
+
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
|
|
380
|
+
.fallbackToDestructiveMigration()
|
|
381
|
+
.build()
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Hilt Integration
|
|
385
|
+
|
|
386
|
+
```kotlin
|
|
387
|
+
@Module
|
|
388
|
+
@InstallIn(SingletonComponent::class)
|
|
389
|
+
object DatabaseModule {
|
|
390
|
+
|
|
391
|
+
@Provides
|
|
392
|
+
@Singleton
|
|
393
|
+
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
|
394
|
+
Room.databaseBuilder(
|
|
395
|
+
context,
|
|
396
|
+
AppDatabase::class.java,
|
|
397
|
+
"app.db",
|
|
398
|
+
)
|
|
399
|
+
.addMigrations(MIGRATION_3_4)
|
|
400
|
+
.build()
|
|
401
|
+
|
|
402
|
+
@Provides
|
|
403
|
+
fun provideFlightDao(database: AppDatabase): FlightDao =
|
|
404
|
+
database.flightDao()
|
|
405
|
+
|
|
406
|
+
@Provides
|
|
407
|
+
fun provideBookingDao(database: AppDatabase): BookingDao =
|
|
408
|
+
database.bookingDao()
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
## Testing with In-Memory Database
|
|
413
|
+
|
|
414
|
+
```kotlin
|
|
415
|
+
class FlightDaoTest {
|
|
416
|
+
|
|
417
|
+
private lateinit var database: AppDatabase
|
|
418
|
+
private lateinit var dao: FlightDao
|
|
419
|
+
|
|
420
|
+
@Before
|
|
421
|
+
fun setup() {
|
|
422
|
+
database = Room.inMemoryDatabaseBuilder(
|
|
423
|
+
ApplicationProvider.getApplicationContext(),
|
|
424
|
+
AppDatabase::class.java,
|
|
425
|
+
)
|
|
426
|
+
.allowMainThreadQueries() // OK for tests only
|
|
427
|
+
.build()
|
|
428
|
+
|
|
429
|
+
dao = database.flightDao()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
@After
|
|
433
|
+
fun tearDown() {
|
|
434
|
+
database.close()
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
@Test
|
|
438
|
+
fun insertAndQuery_returnsCorrectFlight() = runTest {
|
|
439
|
+
val flight = FlightEntity(
|
|
440
|
+
id = "TK1",
|
|
441
|
+
originCode = "IST",
|
|
442
|
+
originName = "Istanbul",
|
|
443
|
+
destinationCode = "JFK",
|
|
444
|
+
destinationName = "New York",
|
|
445
|
+
departureTime = "2025-01-15T10:00:00Z",
|
|
446
|
+
arrivalTime = "2025-01-15T18:00:00Z",
|
|
447
|
+
priceAmount = 799.0,
|
|
448
|
+
priceCurrency = "USD",
|
|
449
|
+
status = "SCHEDULED",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
dao.insertFlight(flight)
|
|
453
|
+
|
|
454
|
+
val result = dao.getFlightByIdOnce("TK1")
|
|
455
|
+
assertThat(result).isNotNull()
|
|
456
|
+
assertThat(result?.originCode).isEqualTo("IST")
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
@Test
|
|
460
|
+
fun flowQuery_emitsUpdates() = runTest {
|
|
461
|
+
dao.getFlights("IST", "JFK").test {
|
|
462
|
+
assertThat(awaitItem()).isEmpty()
|
|
463
|
+
|
|
464
|
+
dao.insertFlight(testFlight)
|
|
465
|
+
|
|
466
|
+
val updated = awaitItem()
|
|
467
|
+
assertThat(updated).hasSize(1)
|
|
468
|
+
|
|
469
|
+
cancelAndIgnoreRemainingEvents()
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
@Test
|
|
474
|
+
fun upsert_updatesExistingFlight() = runTest {
|
|
475
|
+
dao.insertFlight(testFlight)
|
|
476
|
+
val updated = testFlight.copy(status = "DELAYED")
|
|
477
|
+
dao.upsertFlights(listOf(updated))
|
|
478
|
+
|
|
479
|
+
val result = dao.getFlightByIdOnce(testFlight.id)
|
|
480
|
+
assertThat(result?.status).isEqualTo("DELAYED")
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### Testing Migrations
|
|
486
|
+
|
|
487
|
+
```kotlin
|
|
488
|
+
@RunWith(AndroidJUnit4::class)
|
|
489
|
+
class MigrationTest {
|
|
490
|
+
|
|
491
|
+
@get:Rule
|
|
492
|
+
val helper = MigrationTestHelper(
|
|
493
|
+
InstrumentationRegistry.getInstrumentation(),
|
|
494
|
+
AppDatabase::class.java,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
@Test
|
|
498
|
+
fun migrate3To4() {
|
|
499
|
+
// Create database at version 3
|
|
500
|
+
helper.createDatabase("test-db", 3).apply {
|
|
501
|
+
execSQL("INSERT INTO bookings (id, flight_id, status) VALUES ('B1', 'F1', 'CONFIRMED')")
|
|
502
|
+
close()
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Run migration and validate
|
|
506
|
+
val db = helper.runMigrationsAndValidate("test-db", 4, true, MIGRATION_3_4)
|
|
507
|
+
val cursor = db.query("SELECT * FROM bookings WHERE id = 'B1'")
|
|
508
|
+
assertThat(cursor.moveToFirst()).isTrue()
|
|
509
|
+
assertThat(cursor.getColumnIndex("created_at")).isAtLeast(0)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## Encrypted Database (SQLCipher)
|
|
515
|
+
|
|
516
|
+
Encrypt the database file by passing a SQLCipher `SupportOpenHelperFactory` to
|
|
517
|
+
`.openHelperFactory(...)`. This skill owns only the Room wiring. The passphrase
|
|
518
|
+
must come from a hardware-backed key hierarchy; the derivation and storage of
|
|
519
|
+
that key are the `android-security` skill's responsibility, not reproduced here.
|
|
520
|
+
|
|
521
|
+
```kotlin
|
|
522
|
+
fun buildEncryptedDatabase(
|
|
523
|
+
context: Context,
|
|
524
|
+
passphrase: ByteArray,
|
|
525
|
+
): AppDatabase {
|
|
526
|
+
val factory = SupportOpenHelperFactory(passphrase)
|
|
527
|
+
return Room.databaseBuilder(context, AppDatabase::class.java, "secure-app.db")
|
|
528
|
+
.openHelperFactory(factory)
|
|
529
|
+
.addCallback(WalHygieneCallback())
|
|
530
|
+
.build()
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
`SupportOpenHelperFactory` is from the AndroidX-compatible SQLCipher artifact
|
|
535
|
+
(`net.zetetic.database.sqlcipher`); it zeroes the passphrase array after use.
|
|
536
|
+
|
|
537
|
+
## Async Database Provisioning
|
|
538
|
+
|
|
539
|
+
When the passphrase must be derived or fetched before `Room.databaseBuilder`, a
|
|
540
|
+
synchronous `@Provides` cannot express the await. Expose the database through a
|
|
541
|
+
suspend, `Mutex`-guarded, double-checked lazy singleton provider.
|
|
542
|
+
|
|
543
|
+
```kotlin
|
|
544
|
+
@Singleton
|
|
545
|
+
class DatabaseProvider @Inject constructor(
|
|
546
|
+
@ApplicationContext private val context: Context,
|
|
547
|
+
private val keyProvider: DatabaseKeyProvider,
|
|
548
|
+
) {
|
|
549
|
+
private val mutex = Mutex()
|
|
550
|
+
@Volatile private var instance: AppDatabase? = null
|
|
551
|
+
|
|
552
|
+
suspend fun database(): AppDatabase {
|
|
553
|
+
instance?.let { return it }
|
|
554
|
+
return mutex.withLock {
|
|
555
|
+
instance ?: buildDatabase().also { instance = it }
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private suspend fun buildDatabase(): AppDatabase {
|
|
560
|
+
val passphrase = keyProvider.passphrase()
|
|
561
|
+
return Room.databaseBuilder(context, AppDatabase::class.java, "secure-app.db")
|
|
562
|
+
.openHelperFactory(SupportOpenHelperFactory(passphrase))
|
|
563
|
+
.addCallback(WalHygieneCallback())
|
|
564
|
+
.build()
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
The Hilt module provides `DatabaseProvider` (no I/O); repositories obtain DAOs
|
|
570
|
+
from a suspend accessor (`provider.database().recentSearchDao()`) at call time.
|
|
571
|
+
|
|
572
|
+
## Runtime Hygiene
|
|
573
|
+
|
|
574
|
+
Cap WAL growth on a write-heavy database with an `onOpen` PRAGMA:
|
|
575
|
+
|
|
576
|
+
```kotlin
|
|
577
|
+
class WalHygieneCallback : RoomDatabase.Callback() {
|
|
578
|
+
override fun onOpen(db: SupportSQLiteDatabase) {
|
|
579
|
+
db.query("PRAGMA wal_autocheckpoint=1000").close()
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Do multi-step read-then-write logic atomically in a `@Transaction` DAO default
|
|
585
|
+
method -- dedupe, insert, and trim-to-cap in one transaction:
|
|
586
|
+
|
|
587
|
+
```kotlin
|
|
588
|
+
@Dao
|
|
589
|
+
interface RecentSearchDao {
|
|
590
|
+
@Query("SELECT id FROM recent_searches WHERE query = :query LIMIT 1")
|
|
591
|
+
suspend fun findId(query: String): Long?
|
|
592
|
+
|
|
593
|
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
594
|
+
suspend fun insert(entity: RecentSearchEntity): Long
|
|
595
|
+
|
|
596
|
+
@Query(
|
|
597
|
+
"DELETE FROM recent_searches WHERE id IN " +
|
|
598
|
+
"(SELECT id FROM recent_searches ORDER BY updated_at DESC LIMIT -1 OFFSET :cap)",
|
|
599
|
+
)
|
|
600
|
+
suspend fun trimToCap(cap: Int)
|
|
601
|
+
|
|
602
|
+
@Transaction
|
|
603
|
+
suspend fun record(entity: RecentSearchEntity, cap: Int) {
|
|
604
|
+
val existing = findId(entity.query)
|
|
605
|
+
insert(if (existing != null) entity.copy(id = existing) else entity)
|
|
606
|
+
trimToCap(cap)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
`exportSchema = false` is acceptable only for a cache or destructive-fallback
|
|
612
|
+
database, where the file is disposable. A migration-tested production database
|
|
613
|
+
must keep `exportSchema = true` and commit the schema JSON, since
|
|
614
|
+
`MigrationTestHelper` validates against the exported schema.
|