refactor(core): switch from room to sqldelight

sqldelight provides far more control over the sql and allows me to make
more optimisations such as removing generated rowid etc. sql also just
looks better than the annotation hell from room.
This commit is contained in:
Cilly Leang 2026-05-02 02:31:18 +10:00
parent ff2af310fb
commit f1770744db
Signed by: cilly
GPG key ID: 6500251E087653C9
74 changed files with 601 additions and 5037 deletions

View file

@ -0,0 +1,52 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.sqldelight)
}
kotlin {
android {
namespace = "moe.lava.banksia.core.sqld"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
iosArm64()
iosSimulatorArm64()
jvm()
sourceSets {
androidMain.dependencies {
implementation(libs.sqldelight.driver.android)
}
commonMain.dependencies {
implementation(libs.okio)
implementation(libs.koin.core)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.datetime)
implementation(projects.core)
}
nativeMain.dependencies {
implementation(libs.sqldelight.driver.native)
}
jvmMain.dependencies {
implementation(libs.sqldelight.driver.jvm)
}
}
}
sqldelight {
databases {
register("BanksiaDatabase") {
packageName.set("moe.lava.banksia.core.sqld")
}
}
}

View file

@ -0,0 +1,14 @@
package moe.lava.banksia.core.sqld
import android.content.Context
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
actual class DatabaseManager actual constructor() : KoinComponent {
actual val database by lazy {
val ctx = get<Context>().applicationContext
val driver = AndroidSqliteDriver(BanksiaDatabase.Schema, ctx, "timetable.db")
BanksiaDatabase(driver)
}
}

View file

@ -0,0 +1,7 @@
package moe.lava.banksia.core.sqld
import org.koin.core.component.KoinComponent
expect class DatabaseManager() : KoinComponent {
val database: BanksiaDatabase
}

View file

@ -0,0 +1,16 @@
package moe.lava.banksia.core.sqld
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val sqldDiModule = module {
singleOf(::DatabaseManager)
factory { get<DatabaseManager>().database }
factory { get<BanksiaDatabase>().routeQueries }
factory { get<BanksiaDatabase>().serviceQueries }
factory { get<BanksiaDatabase>().serviceExceptionQueries }
factory { get<BanksiaDatabase>().shapeQueries }
factory { get<BanksiaDatabase>().stopQueries }
factory { get<BanksiaDatabase>().stopTimeQueries }
factory { get<BanksiaDatabase>().tripQueries }
}

View file

@ -0,0 +1,14 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Route
import moe.lava.banksia.core.model.RouteType
import moe.lava.banksia.core.sqld.Route as DbRoute
fun DbRoute.asModel() = Route(
id = id,
type = RouteType.from(type.toInt()),
number = number,
name = name,
)
fun Route.asDb() = DbRoute(id, type.value.toLong(), number, name)

View file

@ -0,0 +1,21 @@
package moe.lava.banksia.core.sqld.mappers
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.util.deserialiseDaysBitflag
import moe.lava.banksia.core.util.serialise
import moe.lava.banksia.core.sqld.Service as DbService
fun DbService.asModel() = Service(
id = id,
days = days.toInt().deserialiseDaysBitflag(),
start = LocalDate.fromEpochDays(start),
end = LocalDate.fromEpochDays(end),
)
fun Service.asDb() = DbService(
id = id,
days = days.serialise().toLong(),
start = start.toEpochDays(),
end = end.toEpochDays(),
)

View file

@ -0,0 +1,17 @@
package moe.lava.banksia.core.sqld.mappers
import kotlinx.datetime.LocalDate
import moe.lava.banksia.core.model.ServiceException
import moe.lava.banksia.core.sqld.ServiceException as DbServiceException
fun DbServiceException.asModel() = ServiceException(
serviceId = serviceId,
date = LocalDate.fromEpochDays(date),
type = type.toInt(),
)
fun ServiceException.asDb() = DbServiceException(
serviceId = serviceId,
type = date.toEpochDays(),
date = type.toLong(),
)

View file

@ -0,0 +1,52 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Shape
import moe.lava.banksia.core.model.ShapePath
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.core.sqld.Shape as DbShape
fun DbShape.asModel() = Shape(
id = id,
path = bytesToPath(path),
)
fun Shape.asDb() = DbShape(
id = id,
path = bytesFromPath(path),
)
private fun bytesToPath(value: ByteArray): ShapePath {
return value
.asSequence()
.asIterable()
.chunked(8) {
(it[0].toLong() and 0xFF) or
(it[1].toLong() and 0xFF shl 8) or
(it[2].toLong() and 0xFF shl 16) or
(it[3].toLong() and 0xFF shl 24) or
(it[4].toLong() and 0xFF shl 32) or
(it[5].toLong() and 0xFF shl 40) or
(it[6].toLong() and 0xFF shl 48) or
(it[7].toLong() and 0xFF shl 56)
}
.map { Double.fromBits(it) }
.chunked(2)
.map { (lat, lng) -> Point(lat, lng) }
.toList()
}
private fun bytesFromPath(path: ShapePath): ByteArray {
return path
.flatMap { (lat, lng) -> listOf(lat.toBits(), lng.toBits()) }
.flatMap { i -> listOf(
i.toByte(),
(i shr 8).toByte(),
(i shr 16).toByte(),
(i shr 24).toByte(),
(i shr 32).toByte(),
(i shr 40).toByte(),
(i shr 48).toByte(),
(i shr 56).toByte(),
) }
.toByteArray()
}

View file

@ -0,0 +1,26 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Stop
import moe.lava.banksia.core.util.Point
import moe.lava.banksia.core.sqld.Stop as DbStop
fun DbStop.asModel() = Stop(
id = id,
name = name,
pos = Point(lat, lng),
parent = parent,
hasWheelChairBoarding = hasWheelChairBoarding == 1L,
level = level,
platformCode = platformCode,
)
fun Stop.asDb() = DbStop(
id = id,
name = name,
lat = pos.lat,
lng = pos.lng,
parent = parent,
hasWheelChairBoarding = if (hasWheelChairBoarding) 1L else 0L,
level = level,
platformCode = platformCode
)

View file

@ -0,0 +1,25 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.FutureTime
import moe.lava.banksia.core.model.FutureTime.Companion.asInt
import moe.lava.banksia.core.model.StopTime
import moe.lava.banksia.core.sqld.StopTime as DbStopTime
fun DbStopTime.asModel() = StopTime(
tripId = tripId,
stopId = stopId,
arrivalTime = FutureTime.fromInt(arrivalTime.toInt()),
departureTime = FutureTime.fromInt(departureTime.toInt()),
headsign = null,
pickupType = pickupType.toInt(),
dropOffType = dropOffType.toInt(),
)
fun StopTime.asDb() = DbStopTime(
tripId = tripId,
stopId = stopId,
arrivalTime = arrivalTime.asInt().toLong(),
departureTime = departureTime.asInt().toLong(),
pickupType = pickupType.toLong(),
dropOffType = dropOffType.toLong(),
)

View file

@ -0,0 +1,32 @@
package moe.lava.banksia.core.sqld.mappers
import moe.lava.banksia.core.model.Service
import moe.lava.banksia.core.model.Trip
import moe.lava.banksia.core.sqld.Trip as DbTrip
fun DbTrip.asModel(service: Service): Trip {
if (serviceId != service.id) {
throw IllegalArgumentException("trip and service id mismatch (${serviceId} != ${service.id})")
}
return Trip(
id = id,
routeId = routeId,
service = service,
shapeId = shapeId,
tripHeadsign = tripHeadsign,
directionId = directionId,
blockId = blockId,
wheelchairAccessible = wheelchairAccessible == 1L
)
}
fun Trip.asDb() = DbTrip(
id = id,
routeId = routeId,
serviceId = service.id,
shapeId = shapeId,
tripHeadsign = tripHeadsign,
directionId = directionId,
blockId = blockId,
wheelchairAccessible = if (wheelchairAccessible) 1L else 0L
)

View file

@ -0,0 +1,15 @@
CREATE TABLE Route (
id TEXT PRIMARY KEY NOT NULL,
type INTEGER NOT NULL,
number TEXT,
name TEXT NOT NULL
);
getAll:
SELECT * FROM Route;
get:
SELECT * FROM Route WHERE id == ?;
insert:
INSERT INTO Route VALUES ?;

View file

@ -0,0 +1,11 @@
CREATE TABLE Service (
id TEXT PRIMARY KEY NOT NULL,
days INTEGER NOT NULL,
start INTEGER NOT NULL,
end INTEGER NOT NULL
);
CREATE INDEX idx_Service_days ON Service (days);
insert:
INSERT INTO Service VALUES ?;

View file

@ -0,0 +1,9 @@
CREATE TABLE ServiceException (
serviceId TEXT NOT NULL,
type INTEGER NOT NULL,
date INTEGER NOT NULL,
PRIMARY KEY (serviceId, type)
);
insert:
INSERT INTO ServiceException VALUES ?;

View file

@ -0,0 +1,7 @@
CREATE TABLE Shape (
id TEXT PRIMARY KEY NOT NULL,
path BLOB NOT NULL
);
insert:
INSERT INTO Shape VALUES ?;

View file

@ -0,0 +1,54 @@
CREATE TABLE Stop (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
lat REAL NOT NULL,
lng REAL NOT NULL,
parent TEXT REFERENCES Stop(id),
hasWheelChairBoarding INTEGER NOT NULL,
level TEXT,
platformCode TEXT
);
CREATE INDEX idx_Stop_parent ON Stop (parent);
getAll:
SELECT * FROM Stop;
getAllParentless:
SELECT * FROM Stop WHERE platformCode IS NULL AND parent IS NULL;
get:
SELECT * FROM Stop WHERE id == ?;
getMany:
SELECT * FROM Stop WHERE id IN ?;
insert:
INSERT INTO Stop VALUES ?;
updateParents:
UPDATE Stop SET parent = ? WHERE id IN ?;
getByRoute:
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN Trip ON Trip.id == StopTime.tripId
WHERE Trip.routeId == :id
GROUP BY Stop.id;
-- I vibecoded this, sorry
getParentsByRoute:
WITH RECURSIVE Tree AS (
SELECT Stop.* FROM Stop
INNER JOIN StopTime ON StopTime.stopId == Stop.id
INNER JOIN Trip ON Trip.id == StopTime.tripId
WHERE Trip.routeId == :id
GROUP BY Stop.id
UNION ALL
SELECT s.*
FROM Stop s
INNER JOIN Tree t ON s.id = t.parent
)
SELECT DISTINCT * FROM Tree WHERE parent IS NULL;

View file

@ -0,0 +1,23 @@
CREATE TABLE StopTime (
tripId TEXT NOT NULL REFERENCES Trip (id),
stopId TEXT NOT NULL REFERENCES Stop (id),
arrivalTime INTEGER NOT NULL,
departureTime INTEGER NOT NULL,
pickupType INTEGER NOT NULL,
dropOffType INTEGER NOT NULL,
PRIMARY KEY (tripId, stopId)
) WITHOUT ROWID;
CREATE INDEX idx_StopTime_stopId ON StopTime (stopId);
insert:
INSERT OR REPLACE INTO StopTime VALUES ?;
getForStopDated:
SELECT DISTINCT StopTime.* FROM StopTime
INNER JOIN Service ON Service.days & :days = :days AND :date BETWEEN Service.start AND Service.`end`
INNER JOIN Trip ON Trip.serviceId == Service.id
LEFT JOIN ServiceException ON ServiceException.serviceId == Service.id AND ServiceException.date == :date
WHERE StopTime.tripId == Trip.id
AND StopTime.stopId IN (SELECT Stop.id FROM Stop WHERE Stop.parent == :stopId OR Stop.id == :stopId)
AND ServiceException.type IS NULL;

View file

@ -0,0 +1,15 @@
CREATE TABLE Trip (
id TEXT PRIMARY KEY NOT NULL,
routeId TEXT NOT NULL REFERENCES Route (id),
serviceId TEXT NOT NULL REFERENCES Service (id),
shapeId TEXT NOT NULL REFERENCES Shape (id),
tripHeadsign TEXT NOT NULL,
directionId TEXT NOT NULL,
blockId TEXT,
wheelchairAccessible INTEGER NOT NULL
);
CREATE INDEX idx_Trip_serviceId ON Trip (serviceId);
insert:
INSERT OR REPLACE INTO Trip VALUES ?;

View file

@ -0,0 +1,10 @@
package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseManager actual constructor() : org.koin.core.component.KoinComponent {
actual val database by lazy {
val driver = NativeSqliteDriver(BanksiaDatabase.Schema, "timetable.db")
BanksiaDatabase(driver)
}
}

View file

@ -0,0 +1,58 @@
package moe.lava.banksia.core.sqld
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import moe.lava.banksia.core.util.error
import org.koin.core.component.KoinComponent
import java.io.File
import java.util.Properties
import kotlin.system.exitProcess
private const val DBNAME = "timetable"
actual class DatabaseManager actual constructor() : KoinComponent {
private var driver = connect()
actual val database get() = BanksiaDatabase(driver)
private fun connect(path: String = "./data/${DBNAME}.db") =
JdbcSqliteDriver("jdbc:sqlite:${path}", Properties(), BanksiaDatabase.Schema)
.apply { execute(null, "PRAGMA journal_mode = OFF;", 0) }
fun makeAlt() = run {
File("./data/${DBNAME}_alt.db").takeIf { it.exists() }?.delete()
val driver = connect("./data/${DBNAME}_alt.db")
BanksiaDatabase(driver) to { driver.close() }
}
fun swap(scope: CoroutineScope = CoroutineScope(Dispatchers.IO)) {
val live = File("./data/${DBNAME}.db")
val alt = File("./data/${DBNAME}_alt.db")
val old = File("./data/${DBNAME}_old.db")
if (live.takeIf { it.exists() }?.renameTo(old) == false) {
error("DatabaseManager", "Failed to rename database from live to old (${live.absolutePath} -> ${old.absolutePath})")
return
}
if (alt.takeIf { it.exists() }?.renameTo(live) == false) {
error("DatabaseManager", "Failed to rename database from alt to live, trying to undo.. (${alt.absolutePath} -> ${live.absolutePath})")
if (!live.renameTo(old)) {
error("DatabaseManager", "Failed to undo, critical failure, exiting..")
exitProcess(1)
}
return
}
val oldDriver = driver
driver = connect()
scope.launch {
delay(5000)
if (old.takeIf { it.exists() }?.delete() == false) {
error("DatabaseManager", "Failed to unlink old database, stray files! (${old.absolutePath})")
}
oldDriver.close()
}
}
}