SQLDelight és un plugin que genera tot el codi necessari per gestionar una base de dades a partir de sentències SQL.
Introducció
SQLDelight verifica els teus esquemes, sentències i migracions en temps de compilació i afegeix funcions a la IDE com autocompletar i refactoritzar de tal manera que facilita escriure i matenir codi SQL.
En aquest enllaç tens el projecte de consulta: https://gitlab.com/xtec/kotlin/sqldelight
Entorn de treball
Crea la carpeta sql
:
> md sql
> cd sql
Crea el projecte sql
amb Gradle:
gradle init --package sql --project-name sql --java-version 21 --type kotlin-application --dsl kotlin --test-framework kotlintest --no-split-project --no-incubating --overwrite
Construeix el projecte i obre'l amb Idea:
> .\gradlew build
> idea .
Modifica el fitxer build.gradle.kts
:
plugins {
...
id("app.cash.sqldelight") version "2.0.2"
}
dependencies {
...
implementation("app.cash.sqldelight:sqlite-driver:2.0.2")
implementation("org.slf4j:slf4j-nop:1.7.36")
}
sqldelight {
databases {
create("Database") {
packageName.set("sql.data")
dialect("app.cash.sqldelight:sqlite-3-38-dialect:2.0.2")
}
}
}
Verifica que la versió de sqlite és superior a la 3.38:
sqlite > select sqlite_version()
Esquema
Crea el fitxer app/src/main/sqldelight/sql/data/Dog.sq
:
CREATE TABLE IF NOT EXISTS Dog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
Aquest fitxer crea una taula, però també pots crear indexs o afegir registres que han d'estar per defecte a la taula.
Al crear el fitxer, IDEA et dirà si vols instal.lar el plugin SQLDelight:
A partir d'aquestes sentències SQL, la tasca Gradle generateSqlInterface
genera al directori build/generated/
:
> .\gradlew generateSqlInterface
Pots veure que ha partir del fitxer SQL s'han generat el fitxers: Database.kt
, Dog.kt
i DatabaseImpl.kt
en el directori app/build/generated
:
Aquesta tasca s'executa de manera automàtica:
- Pel plugin SQLDelight quan edites un fitxer
.sq
- Quan excutes
gradlew build
.
Modifica el fitxer App.kt
:
package sql
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import sql.data.*
fun main() {
// Construim un driver amb una connexió JDBC
val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:pets.db")
// Creem l'esquema a la base de dades
Database.Schema.create(driver)
// Podem crear una instància de gos
val trufa = Dog(1,"Trufa")
println(trufa) // Dog(id=1, name=Trufa)
}
Al executar l'aplicació, a més d'imprimir el valor trufa
, has creat el fitxer pets.db
amb la taula Dog
.
Obre la base de dades pets.db
amb Sqlite:
> sqlite3.exe .\pets.db
sqlite> .schema Dog
CREATE TABLE Dog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
sqlite> .quit
Consultes
A més de l'esquema, també pots generar funcions a partir de sentències SQL.
L'únic que has de fer és anotar la sentència SQL del fitxer .sq
per tal de que SQLDeligh generi la funció corresponent.
Per exemple, pots fer que SQLDelight generi la classe DogQueries
amb les funcions select()
i insert()
:
CREATE TABLE IF NOT EXISTS Dog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
insert:
INSERT INTO Dog(name) VALUES (?);
select:
SELECT * FROM Dog;
A continuació tens un exemple d'us de la classe DogQueries
:
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import sql.data.*
fun main() {
// Borrem la base de dades per començar sempre de cero (només desenvolupament)
Path("pets.db").deleteIfExists()
val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:pets.db")
Database.Schema.create(driver)
// Creem una instància de Database amb el driver
val db = Database(driver)
// Inserim unes dades de mostra
db.dogQueries.insert("Pujol")
db.dogQueries.insert("Ketzu")
db.dogQueries.insert("Indy")
// Consultem la taula gossos amb màxim 2 resultats
db.dogQueries.select().executeAsList().forEach { dog -> println(dog) }
}
Pots executar el codi tants cops com vulguis, el resultat és:
Dog(id=1, name=Pujol)
Dog(id=2, name=Ketzu)
Dog(id=3, name=Indy)
El codi crea un objecte Database
amb el driver corresponent, i té l'atribut dogQueries
que té una instància de DogQueries
.
La classe DogQueries
té les funcions:
insert(name: String) |
Insereix un "dog" a la taula: només has de passar el nom perquè la clau primària és subrogada. |
select() |
Executa el select i torna un objecte Query<Dog> |
Arguments
Si una sentència SQL té arguments, la funció que es genera tindrà també els arguments que necessita la sentència SQL.
En el nostre exemple tenim la sentència SQL "insert":
insert:
INSERT INTO Dog(name) VALUES (?);
La sentència INSERT
espera un valor per l'atribute name
.
Per tant, la funció Kotlin que es genera té un paràmetre name
de tipus String
:
DogQueries.insert(name: String)
Normalment SqlDelight pot deduir el nom dels paràmetres, però si no pot, o vull utilitzar un altre nom, puc especificar el nom que s'ha d'utlitzar.
Per exemple, pots modificar la senèntica SELECT
per limitar el número de resulats amb LIMIT
i fer un "offset" dels resultats amb OFFSET
.
En aquest cas, com que SQLDelight no pot deduïr els noms els indiques de manera expressa amb nom :limit
i :offset
:
select:
SELECT * FROM Dog LIMIT :limit OFFSET :offset;
Com que has modificar el fitxer Dog.sq
, ara la funció select
té paràmetre i has d'adaptar el codi:
```kt
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import sql.data.*
fun main() {
val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:pets.db")
Database.Schema.create(driver)
// Creem una instància de Database amb el driver
val db = Database(driver)
// Mirem si la taula està buida
if (db.dogQueries.select(1,0).executeAsList().isEmpty()) {
db.dogQueries.insert("Pujol")
db.dogQueries.insert("Ketzu")
db.dogQueries.insert("Indy")
}
// Consultem la taula gossos amb màxim 2 resultats
db.dogQueries.select(5,0).executeAsList().forEach { dog -> println(dog) }
}
Llista
L'objecte Query<Dog>
té la funció executeAsList()
que torna una llista amb tots els resultats de la consulta.
És molt útil si la llista és curta i anem ha utilitzar tots els resultats de la llista.
// Consultem la taula gossos amb màxim 2 resultats
db.dogQueries.select(2,0).executeAsList().forEach { dog -> println(dog) }
One
Si estas segur que la consulta només ha de tornar un únic resultat pots utilitzar la funció executeAsOne
:
val query = db.dogQueries.select()
query.executeAsOne()
Però el nostre select
retorna més d'un resultat i ...
Exception in thread "main" java.lang.IllegalStateException:
ResultSet returned more than 1 row for Dog.sq:select
...
Afageix una consulta selectById
que torni un gos mitjançant la id
:
selectById:
SELECT * FROM Dog WHERE id = ?;
Executem el codi amb la nova consulta:
val query = db.dogQueries.selectById(2000)
query.executeAsOne()
I ara es produeix un NullPointerException
perquè no existeix cap gos amb id
2000:
Exception in thread "main" java.lang.NullPointerException:
ResultSet returned null for Dog.sq:selectById
Per utlitzar la funció executeAsOne()
has d'estar segur que la consulta torna un, i només un resultat.
En altre cas és millor utilitzar la funció executeAsOneOrNull
:
val query = db.dogQueries.selectById(2000)
val dog: Dog? = query.executeAsOneOrNull()
if (dog == null) {
println("Not found")
} else {
println(dog.name)
}
Ajuda: https://kotlinlang.org/docs/null-safety.html
El mètode insert
retorna un objecte Query
amb el resultat de la sentència INSERT INTO ...
.
Si vols obetnir el registre resultant (en aquest cas amb la id
):
val maverick = db.dogQueries.insert("Maverick").executeAsOne()
println(maverick)
Si executes el codi el resultat és:
Dog(id=5, name=Maverick)
Cursor
Però si la llista és molt llarga, i vols processar molts elements de la llista és millor utilitzar un cursor:
val query = db.dogQueries.select(Long.MAX_VALUE,0)
query.execute { cursor ->
while (cursor.next().value) {
val dog = query.mapper(cursor)
println(dog)
}
QueryResult.Unit
}
La manera més fàcil d'accedir a les dades, és mitjançant la funció mapper
que forma part de las classe Query<Dog>
, i que transforma el registre al que apunta el cursor
a un objecte Dog
.
Però si vols, pots accedir als elements de registre de manera posicional:
val query = db.dogQueries.select()
query.execute { cursor ->
while (cursor.next().value) {
println(cursor.getString(1))
}
QueryResult.Unit
}
No tens seguretat a nivell de tipus, però és molt més eficient si no necessites crear l'objecte!
I el que es evident, no cal que recorris tots els elements de la llista!
// Consultem la taula gossos amb màxim 2 resultats
val query = db.dogQueries.select(Long.MAX_VALUE,0)
query.execute { cursor ->
while (cursor.next().value) {
val dog = query.mapper(cursor)
println(dog)
if (dog.name == "Ketzu") {
break
}
}
QueryResult.Unit
}
Listener
Quan estas processant un select
en un entorn concurrent és possible que s'afegeixin o es borrin registres.
Pots registrar un "listener":
val query = db.dogQueries.select(Long.MAX_VALUE,0);
query.addListener {
app.cash.sqldelight.Query.Listener { println("queryResultsChanged") }
}
però només té sentint en un entorn concurrent ...
TODO primer l'alumne ha de saber com s'executa en entorn concurrent.
Insert
A vegades quan fas un INSERT
vols que la consulta retorni la fila que s'ha creat perquè hi ha dades que crea la base de dades, com és el cas de la clau primari subrogada.
Afegeix la consulta insertWithReturn
al fitxer Dog.sq
:
insertWithReturn:
INSERT INTO Dog(name) VALUES (?) RETURNING *;
Observació. RETURNING
només està disponible a partir de la versió 3.35.0.
Registra un gos a la base de dades:
val idefix = db.dogQueries.insertWithReturn("Idefix").executeAsOne()
println(idefix)
A diferència de la funció insert
, amb la funció insertWithReturn
has d'executar la consulta.
El resultat és el gos amb el valor de l'atribut id
generat per la base de dades.
Dog(id=6, name=Idefix)
Activitat: Veterinari
Borra la taula Dog
:
> sqlite3 drop table Dog;
A continuació tens un disseny ampliat:
classDiagram direction LR class Dog { id integer primary key name text not null number text } class Breed { id integer primary key name text not null description text not null } class Owner { id integer primary key name text not null } Dog --> "NUL" Breed Dog --> "NUL" Owner
Observacions Un gos pot no tenir owner
(està abandonat), no tenir número de registre al REIAC (number
) o no ser de raça (breed
).
https://www.sqlite.org/foreignkeys.html
Escriu els fitxers Breed.sq
, Dog.sq
i Owner.sq
Dog.sq
CREATE TABLE IF NOT EXISTS Dog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
number TEXT,
breed INTEGER,
owner INTEGER,
FOREIGN KEY (breed) REFERENCES Breed(id),
FOREIGN KEY (owner) REFERENCES Owner(id)
);
insert:
INSERT INTO Dog(name, number, breed, owner) VALUES (?, ?,?,?) RETURNING *;
select:
SELECT * FROM Dog LIMIT :limit OFFSET :offset;
selectById:
SELECT * FROM Dog WHERE id = ?;
Breed.sq
...
Modifica el fitxer App.kt
:
-
Omple la base de dades amb unes quantes races que han de constar per defecte a la taula
Breed
. -
Registra alguns gossos, alguns amb propietari, altres no, alguns de raça, altres no.
-
Pensa en algunes consultes que ha de tenir la teva aplicació, modifica els fitxers
sq
correspoenents, i prova en codi que funciona. -
Implementa el mètode update i delete en alguna taula i prova en codi que funciona.
Mai has de dissenyar consultes genèriques perqué si!!
Cada consulta ha d'estar pensada i implica avaluar la necessitat de crear els índexs corresponents a la base de dades.
Transaccions
Si vols executar més d'una sentència dins una transacció has d'utlitzar la funció transaction()
:
db.transaction {
val eva = db.ownerQueries.insert("Eva").executeAsOne()
val milú = db.dogQueries.insert("Milú", null, null, eva.id).executeAsOne()
println(milú)
}
El resultat és:
Dog(id=7, name=Milú, number=null, breed=null, owner=3)
Per tornar el valor des de una transacció, utilitza la funció transactionWithResult
:
val dogs = db.transactionWithResult {
db.dogQueries.select(5,0).executeAsList()
}
println(dogs)
Rollback
Si durant l'execució del codi es produeix una execpció, automàticamen es farà un "rollback" de la transació.
try {
db.transaction {
db.dogQueries.insert("Kokoro", null, null, null)
throw Exception("Ups!")
}
} catch (e: Exception) {
println("No hi a gossos: ${db.dogQueries.select(5, 0).executeAsList().isEmpty()}")
}
Si vols pots fer un "rollback" de la transacció en qualsevol moment (no es produeix cap excepció):
db.transaction {
db.dogQueries.insert("Kokoro", null, null, null)
rollback()
}
println("No hi a gossos: ${db.dogQueries.select(5, 0).executeAsList().isEmpty()}"
Però si la transacció retorna un valor, has de retornar un valor amb el rollback:
val dogs: Dog? = db.transactionWithResult {
db.dogQueries.insert("Kokoro", null, null, null)
rollback(null)
}
Callbacks
Pots registrar "callbacks" que s'executarn un con la transacció ha finalitzar o s'ha fet un "rollback":
val dog: Dog? = db.transactionWithResult {
afterRollback { println("No s'ha afegit Kokoro") }
afterCommit { println("S'ha afegit Kokoro") }
db.dogQueries.insert("Kokoro", null, null, null)
rollback(null)
}
Grouping Statements
TODO
You can group multiple SQL statements together to be executed at once inside a transaction:
Tipus
https://sqldelight.github.io/sqldelight/2.0.2/jvm_sqlite/types/
Activitat: Chinook
En l'activitat SQL - Relació vas utilitzar la base de dades "Chinook".
Entorn de treball:
- Borra els fitxers
.sq
(o crea un nou projecte) - Afageix la base de dades
chinook.sq
a l'arrel del projecte - Crea els fitxers
.sq
que corresponen a les taules de la base de dades (amb.showtables
pots veure els esquemes)
A continuació has de crear vàries consultes com les de l'activitat anterior i les de l'activitat SQL - Relació
Album.sq
CREATE TABLE IF NOT EXISTS Album (
AlbumId INTEGER PRIMARY KEY,
Title TEXT,
ArtistId INTEGER
);
select:
SELECT * FROM Album LIMIT :limit OFFSET :offset;
App.kt
import app.cash.sqldelight.db.*
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import sql.data.*
fun main() {
val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:chinook.sqlite")
val db = Database(driver)
db.albumQueries.select(6, 0).executeAsList().forEach(::println)
}
TODO
Configuració driver: