Bàsic

  • 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:

    1. La llibreria de temps d’execució (runtime)
    2. 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:

    1. Entitats (Entities), que són classes senzilles que modelen les dades que transfereixes cap a i des de la base de dades
    2. L’objecte d’accés a dades (DAO), que proporciona la descripció de l’API que vols per treballar amb determinades entitats
    3. 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() a NoteDatabase per obtenir el DAO Note.SQL.

    • Cridar mètodes a Note.SQL per consultar, inserir, actualitzar o eliminar entitats Note.

    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
            }
        }
    }

    kotlin.uuid

    Ú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:

    1. É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 de NoteDatabase, una no afectarà l’altra.

    2. 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() i delete() 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.