SQLite

Przechowywanie

  |   17 min czytania

Bazy danych

Przechowywanie informacji w bazie danych jest jednym z najpopularniejszych sposobów zarządzania zbiorem danych o ustalonej strukturze. Doskonale spełnia swoją rolę w sytuacji gromadzenia przede wszystkim wielu obiektów zdefiniowanych typów (np. kontakty). Podobnie jak w przypadku plików w storage czy wartości prymitywnych w SharedPreferences dane pozostają dostępne do odczytu i zapisu niezależnie od cyklu życia aplikacji. Są zachowane w pamięci wewnętrznej urządzenia dopóki nie zostaną usunięte programowo lub ręcznie przez użytkownika poprzez wyczyszczenie danych aplikacji. Android wykorzystuje bazy danych w schemacie SQL w implementacji SQLite. Pomimo możliwości bezpośredniego operowania na bazie danych za pomocą zapytań SQLite przez klienta SQLiteOpenHelper wysoce zalecane jest użycie biblioteki Room dostarczającej dodatkowej warstwy abstrakcji.

Implementacja

Jedną z głównych zasad tworzenia baz danych SQL jest formalna deklaracja struktury. Definiuje ona sposób w jaki baza danych jest organizowana oraz jakie typy obiektów mogą być przechowywane i modyfikowane. Dane znajdują się w tabelach zbudowanych z kolumn i wierszy. Reprezentacją jednego wpisu (porcji informacji) jest encja. Aby zaimplementować bazę danych należy rozszerzyć klasę SQLiteOpenHelper oraz nadpisać metody onCreate i onUpgrade definiując jej strukturę. Modyfikacja zawartości bazy odbywa się przy pomocy zapytań SQL w metodach put, query, delete, update.

//use SQLiteDatabase in some client
class SQLiteActivity : AppCompatActivity() {
    
    private lateinit var database : SQLiteDatabase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //get instance of writable or readable database to reuse it in multiple queries
        database = Database(this).writableDatabase
        //use database by defined methods below
    }

    override fun onDestroy() {
        //close costly database connection
        database.close()
        super.onDestroy()
    }
    
    //some database manage methods, there could be inside some manager with conversion some object type into raw data
    //notice how many lines of code are needed to do simple single query operation

    fun put() {
        //prepare data
        val values = ContentValues()
        values.put(DatabaseContract.Person.COLUMN_NAME, "Jack")
        values.put(DatabaseContract.Person.COLUMN_AGE, 50)

        //put the data and get id of new entry, returns -1 if fails
        val id = database.insert(DatabaseContract.Entry.TABLE_PERSON, null, values)
    }

    fun read() {
        //define the query params
        val table = DatabaseContract.Person.TABLE_PERSON
        val columns : Array<String>? = null //get all columns
        val selection = DatabaseContract.Person.COLUMN_NAME + " = ?" //columns for WHERE
        val args = arrayOf("Jack") //values for WHERE
        val groupBy = null //ignore
        val filterBy = null //ignore
        val sortOrder = DatabaseContract.Person.COLUMN_AGE + " DESC"

        //make query and read data from the cursor by iterator methods
        val cursor : Cursor = database.query(table, columns, selection, args, groupBy, filterBy, sortOrder)
        while (cursor.moveToNext()) {
            val age = cursor.getString(cursor.getColumnIndex(DatabaseContract.Person.COLUMN_AGE))
            //do something with item
        }
        cursor.close()
    }

    fun delete() {
        //define query params
        val table = DatabaseContract.Person.TABLE_PERSON
        val selection = DatabaseContract.Person.COLUMN_NAME + " LIKE ?"
        val args = arrayOf("Jack")

        //delete entries and get number of the removed items
        val count = database.delete(table, selection, args)
    }

    fun update() { 
        //prepare data
        val values = ContentValues()
        values.put(DatabaseContract.Person.COLUMN_AGE, 51)
        
        //define query params
        val table = DatabaseContract.Person.TABLE_PERSON
        val selection = DatabaseContract.Person.COLUMN_NAME + " LIKE ?"
        val args = arrayOf("Jack")
        
        //update entries and updated count
        val count = database.update(table, values, selection, args)
    }
}

//extend SQLiteOpenHelper
class Database(context : Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {

    companion object {
        const val DATABASE_VERSION = 1
        const val DATABASE_NAME = "Person.db"
    }

    //define SQL queries, use DatabaseContract
    private val SQL_CREATE =
        "CREATE TABLE ${DatabaseContract.Person.TABLE_PERSON} (" +
            "${BaseColumns._ID} INTEGER PRIMARY KEY," +
            "${DatabaseContract.Person.COLUMN_NAME} TEXT," +
            "${DatabaseContract.Person.COLUMN_AGE} INTEGER)"

    private val SQL_DELETE =
        "DROP TABLE IF EXISTS ${DatabaseContract.Person.TABLE_PERSON}"

    override fun onCreate(db: SQLiteDatabase?) {
        //just create database with specific structure
        db?.execSQL(SQL_CREATE)
    }

    override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
        //do something when database structure has updated, e.g. clear all old data
        db?.execSQL(SQL_DELETE)
        onCreate(db)
    }

    //implement other methods like onDowngrade, onConfigure
}

//good practice is to define database scheme contract to simpler manage it
object DatabaseContract {

    //create inner class for each Table, BaseColumns has primary key field called _ID
    object Person : BaseColumns {
        const val TABLE_PERSON = "person"
        const val COLUMN_NAME = "name"
        const val COLUMN_AGE = "age"
    }
}

Ograniczenia

SQLiteOpenHelper umożliwia zarządzanie zarówno strukturą jak i zawartością bazy danych. Jest narzędziem niższego poziomu, które wymaga od programisty implementacji kodu związanego ze strukturą i zapytaniami do bazy co jest podatne na błędy z uwagi na brak weryfikacji poprawności poleceń SQL i zgodności ze schematem bazy. Wymaga generowania nadmiarowego kodu konwersji zapytań SQL do obiektów i na odwrót (praktycznie jedna metoda dla każdej atomowej operacji dla danego typu). Rozwiązaniem tych problemów może być wykorzystanie biblioteki Room.

Room

Room dostarcza warstwy abstrakcji dla SQLite dzięki czemu dostęp i zarządzanie bazą danych staje się łatwiejsze, a pisanie kodu szybsze. Weryfikuje poprawność zapytań SQL już w trakcie kompilacji co znacznie zmniejsza możliwość popełnienia błędu. Ponadto wspiera mechanizm transakcji i dostarcza wiele adnotacji redukując tym samym potrzebę pisania wielu zapytań. Aby umożliwić współpracę z Room należy dostarczyć trzy komponenty oznaczone jako @Database, @Entity, @Dao.

Entity

Klasa oznaczona jako @Entity reprezentuje tabele, gdzie każde jej pole jest kolumną. Pełni ona rolę modelu w bazie danych i nie zawiera żadnej logiki. @PrimaryKey ustawia klucz podstawowy na wskazanym polu, a @ForeignKey wskazuje klucz obcy i łączy. @ColumnInfo definiuje nazwę kolumny, @Embedded umożliwia dostęp do wewnętrznych pól klasy jako kolumn tabeli, natomiast @Ignore pozwala zignorowanie konstruktorów czy pól przy tworzeniu obiektu przez RoomDatabase (jeśli jest kilka konstruktorów).

//represents a table of database, define scheme here
@Entity(
    tableName = "person",
    foreignKeys = [ForeignKey(entity = Address::class, parentColumns = ["id"], childColumns = ["addressId"])])
data class Person
(
    @PrimaryKey(autoGenerate = true)
    val id : Long,

    @ColumnInfo(name = "name") //set column nam for this field
    var fullName : String,

    //by default column name is field name so no need to use @ColumnInfo
    var age : Int,

    //some annotations can be declared as @Entity parameters e.g. foreignKeys instead of @ForeignKey
    var addressId : Long,

    @Embedded //fields of this class are columns of the table
    var Contact : Contact
)
{
    @Ignore //tell Room to ignore this constructor
    constructor(fullName : String, age : Int, addressId : Long, contact : Contact) : this(0, fullName, age, addressId, contact)

    //some class body
}

//by default tableName is a class name, indices speeds up select but slows down insert or update
@Entity(tableName = "address", indices = [Index(value = ["street"])])
data class Address(@PrimaryKey(autoGenerate = true) val id : Long, var city : String, var street : String) {

    @Ignore
    constructor(city : String, street: String) : this(0, city, street)
}

data class Contact(var phone : String, var email : String)

Dao

Klasa oznaczona jako @Dao odpowiedzialna jest za dostarczenie deklaracji metod dostępowych do baz danych. Są one tworzone przy użyciu różnych adnotacji takich jak m.in. @Insert, @Update, @Delete które automatycznie generują kod zapytań czy też przez adnotację @Query wymagającej definicji zapytania. W przypadku ciągu operacji, które muszą zostać wykonane w ramach jednej transakcji należy użyć adnotacji @Transaction.

//define methods to use database
@Dao
interface PersonDao {

    //provide query command for @Query annotation
    @Query("SELECT * FROM person")
    fun getAll() : List<Person>

    @Query("SELECT * FROM person WHERE name LIKE :name LIMIT 1")
    fun findByName(name : String) : Person

    @Query("SELECT * FROM person INNER JOIN address ON address.id = person.id WHERE address.city LIKE :city")
    fun findByCity(city : String) : List<Person>

    //do not have to provide any SQL with @Insert, when conflict just replace so it works also as update
    //return id primary key
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(person : Person) : Long

    @Insert
    fun insertAll(vararg persons : Person)

    //do not have to provide any SQL with @Update or @Delete but it can be also replace by normal @Query
    @Update
    fun update(person : Person)

    @Delete
    fun delete(person : Person)

    //make transaction with atomic operations
    @Transaction
    suspend fun increaseAgeForAll() {
        for (person in getAll()) {
            person.age = person.age + 1
            update(person)
        }
    }
}

@Dao
interface AddressDao {

    @Query("SELECT * FROM address")
    fun getAll() : List<Address>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(address: Address) : Long

    @Delete
    fun delete(address: Address)
}

Database

Klasa oznaczona jako @Database łączy wybrane tabele klas @Entity i metody dostępowe klas @Dao w jedną całość. Taka klasa musi być abstrakcyjna i rozszerzać RoomDatabase oraz deklarować metody abstrakcyjne zwracające obiekty @Dao.

//serves as the access point, define list of entities associated with database
@Database(entities = [Person::class, Address::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() { //must be abstract and extends RoomDatabase

    //abstract no arg methods with returns of entities
    abstract fun personDao() : PersonDao
    abstract fun addressDao() : AddressDao

    //define more methods for other entities class
}

Użycie

Tworzenie instancji bazy danych RoomDatabase odbywa się przy użyciu budowniczego. Ze względu na kosztowność bazy warto rozważyć zastosowanie wzorca Singleton i tym samym ograniczyć instancję do jednej dla całej aplikacji.

class RoomActivity : AppCompatActivity(), CoroutineScope by MainScope() {

    lateinit var database : AppDatabase
    lateinit var personDao : PersonDao
    lateinit var addressDao : AddressDao

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        //create or inject instance, it could be Singleton
        //can be created also by inMemoryDatabaseBuilder() which helps testing
        database = Room
            .databaseBuilder(this, AppDatabase::class.java, "AppDatabase")
            .build()
        //there are more builders options like allowMainThreadQueries()

        //access to database by dao
        personDao = database.personDao()
        addressDao = database.addressDao()

        //must be run on the background thread otherwise exception will be thrown
        doSomeOperations()
    }

    fun doSomeOperations() {
        launch(Dispatchers.IO) {
            createData()
            val person = selectData()
            updateData(person)
            deleteData(person)
            runTransaction()
        }
    }

    fun createData() {
        //put some data using dao example methods
        val address = Address("Poznan", "sw. Marcin")
        val contact = Contact("111 222 333", "jack@daniels.com")
        val addressId = addressDao.insert(address)
        personDao.insert(Person("Jack Daniels", 50, addressId, contact))

        val polwiejskaId = addressDao.insert(Address("Poznan", "Polwiejska"))
        val johnnie = Person("Johnnie Walker", 60, polwiejskaId, Contact("222 333 444", "johnnie@walker.com"))
        val william = Person("William Grant", 70, polwiejskaId, Contact("333 444 555", "william@grant.com"))
        personDao.insertAll(johnnie, william)
    }

    fun selectData() : Person {
        //example of getting data usage
        val persons = personDao.getAll()
        val person = personDao.findByName("Jack Daniels")
        return person
    }

    fun updateData(person : Person) {
        person.age = 20
        personDao.update(person)
    }

    fun deleteData(person : Person) {
        personDao.delete(person)
    }

    suspend fun runTransaction() {
        //run transaction by annotated method
        personDao.increaseAgeForAll()

        //or this can be achieve also by transaction directly on database
        database.runInTransaction {
            for (person in personDao.getAll()) {
                person.age = person.age + 1
                personDao.update(person)
            }
        }
    }
}

Migracja

W trakcie rozwijania aplikacji nierzadko występuje potrzeba zmiany struktury bazy danych. W takiej sytuacji może okazać się, że jakaś część danych z poprzedniego formatu bazy jest nadal potrzebna. W związku z tym należy zapewnić poprawną migrację danych między wersjami bazy poprzez skonstrukowanie odpowiedniego zapytania SQL w obiekcie Migration oraz dodanie go przez addMigrations w trakcie tworzenia bazy. Z uwagi na to, że proces migracji narażony jest na występowanie poważnych błędów przed wypuszczeniem wersji produkcyjnej aplikacji nie można zapomnieć o lokalnej kopii zapasowej danych i właściwych testach migracji wspieranych przez MigrationTestHelper.

class MigrationActivity : AppCompatActivity() {

    //address entity is extended by new building number column and database upgraded version

    lateinit var database : AppDatabase

    //provide proper SQL migration
    val MIGRATION_1_2 = object: Migration(1, 2) {
        override fun migrate(database: SupportSQLiteDatabase) {
            database.execSQL("ALTER TABLE address ADD COLUMN number TEXT")
            //if new column can not be null inject some default values by execSQL
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        database = Room
            .databaseBuilder(this, AppDatabase::class.java, "RoomDatabase")
            .addMigrations(MIGRATION_1_2) //pass more of them if needed
            .build()
    }
}