Room té com a objectiu facilitar l'ús de SQLite mitjançant una implementació lleugera basada en anotacions d'un motor d'object-relational mapping (ORM).
Introducció
En la programació orientada a objectes, estem acostumats que els objectes mantinguin referències a altres objectes, formant algun tipus de graf d’objectes. No obstant això, les bases de dades relacionals tradicionals d’estil SQL treballen amb taules de dades primitives, utilitzant claus foranes i taules d’unió per expressar relacions. Esbrinar com mapar les nostres classes a taules relacionals pot ser frustrant i normalment comporta molt codi repetitiu.
Un ORM típic treballa a partir de codi Kotlin i, o bé genera una estructura de base de dades adequada, o bé col·labora amb tu per identificar com s’han de mapar les classes a una estructura de taules existent. L’ORM normalment et genera part del codi i et proporciona una llibreria que, combinades, amaguen bona part dels detalls de la base de dades.
ORM
Crea un projecte amb el nom room-basic
amb Amper i Idea.
Utilitza la plantilla “JVM Console Application”.
Per utilitzar Room, necessites dues dependències al teu fitxer module.yaml
:
- La llibreria de temps d’execució (runtime)
- Un processador d’anotacions (veure Kotlin Symbol Processing)
product: jvm/app
dependencies:
- androidx.room:room-ktx:2.8.0
settings:
kotlin:
ksp:
processors:
- androidx.room:room-compiler:2.8.0
Maven Repository - androidx.room
A grans trets, l’ús de Room es basa en tres conjunts de classes:
- Entitats (Entities), que són classes senzilles que modelen les dades que transfereixes cap a i des de la base de dades
- L’objecte d’accés a dades (DAO), que proporciona la descripció de l’API que vols per treballar amb determinades entitats
- La base de dades, que uneix totes les entitats i els DAOs per a una única base de dades SQLite
Entities
Les entitats són classes que representen:
- les dades que vols emmagatzemar en una taula, i
- una unitat típica d’un conjunt de resultats que intentes recuperar de la base de dades
Des del punt de vista del codi, una entitat és una classe Kotlin marcada amb l’anotació @Entity
.
Crea el fitxer src/Database.kt
.
Per exemple, aquí tens una classe Note
que fa d’entitat de Room:
import androidx.room.*
@Entity(tableName = "Note")
data class Note(
@PrimaryKey val id: String,
val title: String,
val text: String,
val version: Int
)
No hi ha cap superclasse específica requerida per a les entitats, i s’espera que sovint siguin classes de dades senzilles, com veiem aquí.
L’anotació @Entity
pot tenir propietats per personalitzar el comportament de la teva entitat i com Room hi treballa. En aquest cas, tenim la propietat tableName
. El nom per defecte de la taula SQLite és el mateix que el de la classe de l’entitat, però tableName
et permet sobreescriure’l i proporcionar el teu propi nom de taula. Aquí, el sobreescrivim perquè sigui Note
.
De vegades, les teves propietats estaran marcades amb anotacions que descriuen el seu rol.
En aquest exemple, el camp id
té l’anotació @PrimaryKey
, que indica a Room que és l’identificador únic d’aquesta entitat. Room ho utilitzarà per saber com actualitzar i eliminar objectes Note
pels seus valors de clau primària.
DAO
“Data access object” (DAO) és una manera elegant de dir “l’API cap a les dades”. La idea és que tinguis un DAO que proporcioni mètodes per a les operacions de base de dades que necessites: consultes, insercions, actualitzacions, eliminacions, etc.
A Room, el DAO s’identifica amb l’anotació @Dao
, aplicada a una classe abstracta o a una interfície. La implementació concreta real te la generarà el processador d’anotacions de Room.
El rol principal de la interface
anotada amb @Dao
és tenir un o més mètodes, cadascun amb les seves anotacions de Room, que identifiquen què vols fer amb la base de dades i les teves entitats. Això fa el mateix paper que les funcions anotades amb @GET
o @POST
en una interfície de Ktorfit.
Modifica el fitxer src/Database.kt
per afegir el DAO.
@Entity(tableName = "Note")
data class Note(
@PrimaryKey val id: String,
val title: String,
val text: String,
val version: Int
) {
@Dao
interface SQL {
@Query("select * from Note")
suspend fun select(): List<Note>
@Insert
suspend fun insert(note: Note)
@Update
suspend fun update(note: Note)
@Delete
suspend fun delete(vararg note: Note)
}
}
A banda de l’anotació @Dao
a la interfície SQL
, tenim quatre funcions, cadascuna amb la seva pròpia anotació: @Query
, @Insert
, @Update
i @Delete
, que es corresponen amb les operacions de base de dades.
La funció select()
té l’anotació @Query
. Principalment, @Query
s’utilitzarà per a sentències SQL SELECT
, on poses l’SQL mateix dins de l’anotació. Aquí, estem recuperant tot de la taula Note
.
Les tres funcions restants utilitzen les anotacions @Insert
, @Update
i @Delete
, mapejades a funcions del mateix nom. Els noms de les funcions, de fet, no importen: podrien ser larry()
, curly()
i moe()
i funcionarien igual. Com pots esperar, @Insert
insereix una entitat a la taula, @Update
actualitza una fila existent per reflectir les propietats de l’entitat subministrada, i @Delete
elimina les files corresponents a les claus primàries de les entitats proporcionades. En aquest exemple, insert()
i update()
accepten cadascuna una Note
, mentre que delete()
accepta un vararg
de Note
. Room admet ambdós patrons, així com d’altres, com ara un List<Note>
— tria el que s’ajusti a les teves necessitats.
Database
A més de les entitats i els DAOs, tindràs almenys una classe abstracta anotada amb @Database
, que estèn RoomDatabase
. Aquesta classe connecta el fitxer de base de dades, les entitats i els DAOs.
Al projecte d’exemple, tenim una NoteDatabase
que compleix aquest rol:
@Database(entities = [NoteEntity::class], version = 1)
abstract class NoteDatabase : RoomDatabase() {
abstract fun note(): Note.SQL
}
L’anotació @Database
configura el procés de generació de codi, incloent:
-
Identificar totes les classes d’entitat que t’importen a la col·lecció entities
-
Identificar la versió d’esquema de la base de dades
Aquí, estem dient que tenim només una classe d’entitat (Note
), i que aquesta és la versió d’esquema 1
.
També necessites funcions abstractes per a cada classe DAO que en retornin una instància. Aquí, tenim una funció note()
que retorna Note.SQL
.
Testing
Instantiate the database
La nostra NoteDatabase
és una abstract class
. En algun lloc, però, necessitem obtenir-ne una instància, per poder cridar note()
i començar a manipular la base de dades.
Per crear una NoteDatabase
, necessites un RoomDatabase.Builder
. Hi ha dues funcions a la classe Room
per obtenir-ne un:
-
databaseBuilder()
t’ajudarà a crear una base de dades recolzada per un fitxer SQLite tradicional. -
inMemoryDatabaseBuilder()
crea una base de dades SQLite el contingut de la qual només s’emmagatzema a memòria — tan bon punt es tanqui la base de dades, la memòria que conté el seu contingut s’allibera.
Afegeix el paquet Sqlite integrat i les llibreries de test al teu fitxer module.yaml
:
dependencies:
- androidx.room:room-ktx:2.8.0
- androidx.sqlite:sqlite-bundled:2.6.0
test-dependencies:
- io.kotest:kotest-property-jvm:6.0.1
- org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2
MVN Repository - sqlite-bundled
Un cop obtinguis el RoomDatabase.Builder
des d’un dels constructors específics de la plataforma, pots configurar la resta de la base de dades de Room en codi comú juntament amb la instanciació real de la base de dades.
Crea un fitxer test/DatabaseTest.kt
import androidx.room.*
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
class DatabaseTest {
private val db = Room.inMemoryDatabaseBuilder<NoteDatabase>().setDriver(BundledSQLiteDriver()).build()
}
El fragment de codi anterior crida la funció del constructor setDriver
per definir quin controlador de SQLite ha d’utilitzar la base de dades de Room. Aquests controladors difereixen segons la plataforma de destinació.
El codi anterior utilitza el BundledSQLiteDriver
. Aquest és el controlador recomanat que inclou SQLite compilat a partir del codi font, i proporciona la versió més consistent i actualitzada de SQLite a totes les plataformes.
A partir d’aquí, podem:
-
Cridar
note()
aNoteDatabase
per obtenir el DAONote.SQL
. -
Cridar mètodes a
Note.SQL
per consultar, inserir, actualitzar o eliminar entitatsNote
.
Configurar un context de Coroutines (Opcional)
Un objecte RoomDatabase
a Android es pot configurar opcionalment amb executors compartits de l’aplicació utilitzant RoomDatabase.Builder.setQueryExecutor()
per dur a terme operacions de base de dades.
Com que els executors no són compatibles amb KMP, l’API setQueryExecutor()
de Room no està disponible a commonMain
. En lloc d’això, l’objecte RoomDatabase
s’ha de configurar amb un CoroutineContext
, que es pot establir amb RoomDatabase.Builder.setCoroutineContext()
. Si no s’estableix cap context, l’objecte RoomDatabase
farà servir per defecte Dispatchers.IO
.
Escriure proves instrumentades
En general, escriure proves instrumentades per a Room no té res d’especial. Obtens una instància de la teva subclasse de RoomDatabase i l’executes a partir d’aquí.
Per exemple, aquí tens una classe de cas de prova instrumentada per exercitar la NoteDatabase
:
import androidx.room.*
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import io.kotest.matchers.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlin.test.*
import kotlin.uuid.*
class DatabaseTest {
private val db = Room.inMemoryDatabaseBuilder<NoteDatabase>().setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO).build()
@OptIn(ExperimentalUuidApi::class)
@Test
fun insertAndDelete() = runTest {
db.note().select() shouldBe emptyList()
val entity = Note(
id = Uuid.random().toString(),
title = "This is a title",
text = "This is some text",
version = 1
)
db.note().insert(entity)
db.note().select().let {
it.size shouldBe 1
it.first() shouldBe entity
}
db.note().delete(entity)
db.note().select() shouldBe emptyList()
}
@OptIn(ExperimentalUuidApi::class)
@Test
fun update() = runTest {
val entity = Note(
id = Uuid.random().toString(),
title = "This is a title",
text = "This is some text",
version = 1
)
db.note().insert(entity)
val updated = entity.copy(title = "This is new", text="So is this", version = 2)
db.note().update(updated)
db.note().select().let {
it.size shouldBe 1
it.first() shouldBe updated
}
}
}
Ús de bases de dades en memòria
Quan fem proves d’una base de dades, un dels reptes és fer que aquestes proves siguin “hermètiques”, o autosuficients. Un mètode de prova no hauria de dependre d’un altre mètode de prova, i un mètode de prova no hauria d’afectar accidentalment els resultats d’un altre mètode de prova. Això vol dir que volem començar des d’un punt inicial conegut abans de cada prova, i cal considerar com fer-ho.
Un enfocament —el que s’ha pres a la classe DatabaseTest
anterior— és utilitzar una base de dades en memòria. La propietat db
s’inicialitza utilitzant Room.inMemoryDatabaseBuilder()
, de manera que obtenim una base de dades en memòria ràpida i descartable.
Hi ha dos avantatges clau d’utilitzar una base de dades en memòria per a proves instrumentades:
-
És intrínsecament autosuficient. Un cop es tanca (o el garbage collector allibera) la
NoteDatabase
, la seva memòria s’allibera, i si proves separades utilitzen instàncies separades deNoteDatabase
, una no afectarà l’altra. -
Llegir i escriure des i cap a la memòria és molt més ràpid que llegir i escriure des i cap al disc, així que les proves s’executen molt més de pressa. D’altra banda, això significa que les proves instrumentades no serveixen per a proves de rendiment, ja que (presumiblement) la teva app de producció realment desarà la base de dades al disc. Pots usar flags de línia d’ordres de Gradle, tipus de build personalitzats i
buildConfigField
, o altres mecanismes per decidir, quan s’executen les proves, si han d’utilitzar memòria o disc.
Les funcions de prova
Les nostres funcions de prova fan coses com:
- Crear instàncies de
Note
, utilitzant un UUID per a l’id
- Cridar
insert()
,update()
idelete()
per manipular el contingut de la taula - Cridar
select()
per recuperar el que hi ha a la taula
I, pel camí, utilitzem els matchers de Kotest per confirmar que tot funciona com esperem.