Implement database access (with migrations)

This commit is contained in:
2019-01-26 22:58:53 -06:00
parent e19339d808
commit 7cab0a39e5
67 changed files with 1826 additions and 136 deletions

View File

@@ -19,37 +19,38 @@
package org.isoron.uhabits
import org.isoron.uhabits.models.Color
import org.isoron.uhabits.models.Frequency
import org.isoron.uhabits.models.Habit
import org.isoron.uhabits.models.HabitType
import kotlin.random.Random
import org.isoron.uhabits.models.HabitList
import org.isoron.uhabits.utils.*
class Backend {
var nextId = 1
var habits = mutableListOf<Habit>()
class Backend(var databaseOpener: DatabaseOpener,
var fileOpener: FileOpener,
var log: Log) {
fun getHabitList(): Map<Int, Map<String, *>> {
return habits.map { h ->
h.id to mapOf("name" to h.name,
"color" to h.color.paletteIndex)
}.toMap()
var db: Database
var habits: HabitList
init {
val dbFile = fileOpener.openUserFile("uhabits.sqlite3")
db = databaseOpener.open(dbFile)
db.migrateTo(LOOP_DATABASE_VERSION, fileOpener, log)
habits = HabitList(db)
}
fun getHabitList(): List<Map<String, *>> {
return habits.getActive().map { h ->
mapOf("key" to h.id.toString(),
"name" to h.name,
"color" to h.color.paletteIndex)
}
}
fun createHabit(name: String) {
val c = (nextId / 4) % 5
habits.add(Habit(nextId, name, "", Frequency(1, 1), Color(c),
false, habits.size, "", 0, HabitType.YES_NO_HABIT))
nextId += 1
}
fun deleteHabit(id: Int) {
val h = habits.find { h -> h.id == id }
if (h != null) habits.remove(h)
}
fun updateHabit(id: Int, name: String) {
val h = habits.find { h -> h.id == id }
h?.name = name
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
const val LOOP_DATABASE_VERSION = 22

View File

@@ -0,0 +1,57 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.models
import org.isoron.uhabits.utils.Database
import org.isoron.uhabits.utils.StepResult
class HabitList(var db: Database) {
var habits = mutableListOf<Habit>()
init {
loadHabitsFromDatabase()
}
fun getActive(): List<Habit> {
return habits.filter { h -> !h.isArchived }
}
private fun loadHabitsFromDatabase() {
val stmt = db.prepareStatement(
"select id, name, description, freq_num, freq_den, color, " +
"archived, position, unit, target_value, type " +
"from habits")
while (stmt.step() == StepResult.ROW) {
habits.add(Habit(id = stmt.getInt(0),
name = stmt.getText(1),
description = stmt.getText(2),
frequency = Frequency(stmt.getInt(3),
stmt.getInt(4)),
color = Color(stmt.getInt(5)),
isArchived = (stmt.getInt(6) != 0),
position = stmt.getInt(7),
unit = stmt.getText(8),
target = 0,
type = HabitType.YES_NO_HABIT))
}
stmt.finalize()
}
}

View File

@@ -0,0 +1,88 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
enum class StepResult {
ROW,
DONE
}
interface PreparedStatement {
fun step(): StepResult
fun finalize()
fun getInt(index: Int): Int
fun getText(index: Int): String
fun bindInt(index: Int, value: Int)
fun bindText(index: Int, value: String)
fun reset()
}
interface Database {
fun prepareStatement(sql: String): PreparedStatement
fun close()
}
interface DatabaseOpener {
fun open(file: UserFile): Database
}
fun Database.execute(sql: String) {
val stmt = prepareStatement(sql)
stmt.step()
stmt.finalize()
}
fun Database.queryInt(sql: String): Int {
val stmt = prepareStatement(sql)
stmt.step()
val result = stmt.getInt(0)
stmt.finalize()
return result
}
fun Database.begin() = execute("begin")
fun Database.commit() = execute("commit")
fun Database.rollback() = execute("rollback")
fun Database.getVersion() = queryInt("pragma user_version")
fun Database.setVersion(v: Int) = execute("pragma user_version = $v")
fun Database.migrateTo(newVersion: Int, fileOpener: FileOpener, log: Log) {
val currentVersion = getVersion()
log.debug("Current database version: $currentVersion")
if (currentVersion == newVersion) return
log.debug("Upgrading to version: $newVersion")
if (currentVersion > newVersion)
throw RuntimeException("database produced by future version of the application")
begin()
for (v in (currentVersion + 1)..newVersion) {
log.debug("Running migration $v")
val filename = sprintf("migrations/%03d.sql", v)
val migrationFile = fileOpener.openResourceFile(filename)
for (line in migrationFile.readLines()) {
if (line.isEmpty()) continue
execute(line)
}
setVersion(v)
}
commit()
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
/**
* Represents a file that was shipped with the application, such as migration
* files or translations. These files cannot be deleted.
*/
interface ResourceFile {
fun readLines(): List<String>
}
/**
* Represents a file that was created after the application was installed, as a
* result of some user action, such as databases and logs. These files can be
* deleted.
*/
interface UserFile {
fun delete()
fun exists(): Boolean
}
interface FileOpener {
/**
* Opens a file which was shipped bundled with the application, such as a
* migration file.
*
* The path is relative to the assets folder. For example, to open
* assets/main/migrations/09.sql you should provide migrations/09.sql
* as the filename.
*/
fun openResourceFile(filename: String): ResourceFile
/**
* Opens a file which was not shipped with the application, such as
* databases and logs.
*
* The path is relative to the user folder. For example, if the application
* stores the user data at /home/user/.loop/ and you wish to open the file
* /home/user/.loop/crash.log, you should provide crash.log as the filename.
*/
fun openUserFile(filename: String): UserFile
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
interface Log {
fun info(msg: String)
fun debug(msg: String)
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
expect fun sprintf(format: String, vararg args: Any?): String

View File

@@ -17,33 +17,14 @@
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
package org.isoron.uhabits.utils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class BackendTest {
class StringsTest {
@Test
fun testBackend() {
val backend = Backend()
assertEquals(backend.getHabitList().size, 0)
backend.createHabit("Brush teeth")
backend.createHabit("Wake up early")
var result = backend.getHabitList()
assertEquals(result.size, 2)
assertEquals(result[1]!!["name"], "Brush teeth")
assertEquals(result[2]!!["name"], "Wake up early")
backend.deleteHabit(1)
result = backend.getHabitList()
assertEquals(result.size, 1)
assertTrue(2 in result.keys)
backend.updateHabit(2, "Wake up late")
result = backend.getHabitList()
assertEquals(result[2]!!["name"], "Wake up late")
fun testSprintf() {
assertEquals("Number 004", sprintf("Number %03d", 4))
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
import kotlinx.cinterop.*
actual fun sprintf(format: String, vararg args: Any?): String {
val buffer = ByteArray(1000)
buffer.usePinned { p ->
platform.posix.sprintf(p.addressOf(0), format, *args)
}
return buffer.stringFromUtf8()
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.database
import org.isoron.uhabits.utils.*
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
import java.sql.ResultSet
class JavaPreparedStatement(private var stmt : PreparedStatement) : org.isoron.uhabits.utils.PreparedStatement {
private var rs: ResultSet? = null
private var hasExecuted = false
override fun step(): StepResult {
if (!hasExecuted) {
hasExecuted = true
val hasResult = stmt.execute()
if (hasResult) rs = stmt.resultSet
}
if (rs == null || !rs!!.next()) return StepResult.DONE
return StepResult.ROW
}
override fun finalize() {
stmt.close()
}
override fun getInt(index: Int): Int {
return rs!!.getInt(index + 1)
}
override fun getText(index: Int): String {
return rs!!.getString(index + 1)
}
override fun bindInt(index: Int, value: Int) {
stmt.setInt(index, value)
}
override fun bindText(index: Int, value: String) {
stmt.setString(index, value)
}
override fun reset() {
stmt.clearParameters()
}
}
class JavaDatabase(private var conn: Connection,
private val log: Log) : Database {
override fun prepareStatement(sql: String): org.isoron.uhabits.utils.PreparedStatement {
log.debug("Running SQL: ${sql}")
return JavaPreparedStatement(conn.prepareStatement(sql))
}
override fun close() {
conn.close()
}
}
class JavaDatabaseOpener(private val log: Log) : DatabaseOpener {
override fun open(file: UserFile): Database {
val platformFile = file as JavaUserFile
val conn = DriverManager.getConnection("jdbc:sqlite:${platformFile.path}")
return JavaDatabase(conn, log)
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
class JavaResourceFile(private val path: Path) : ResourceFile {
override fun readLines(): List<String> {
return Files.readAllLines(path)
}
}
class JavaUserFile(val path: Path) : UserFile {
override fun exists(): Boolean {
return Files.exists(path)
}
override fun delete() {
Files.delete(path)
}
}
class JavaFileOpener : FileOpener {
override fun openUserFile(filename: String): UserFile {
val path = Paths.get("/tmp/$filename")
return JavaUserFile(path)
}
override fun openResourceFile(filename: String): ResourceFile {
val rootFolders = listOf("assets/main",
"assets/test")
for (root in rootFolders) {
val path = Paths.get("$root/$filename")
if (Files.exists(path) && Files.isReadable(path)) {
return JavaResourceFile(path)
}
}
throw RuntimeException("file not found")
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
class JavaLog : Log {
override fun info(msg: String) {
println("[I] $msg")
}
override fun debug(msg: String) {
println("[D] $msg")
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
actual fun sprintf(format: String, vararg args: Any?): String {
return String.format(format, *args)
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertTrue
import org.junit.Test
class BackendTest : BaseTest() {
@Test
fun testBackend() {
// val backend = Backend(databaseOpener, fileOpener)
// assertEquals(backend.getHabitList().size, 0)
//
// backend.createHabit("Brush teeth")
// backend.createHabit("Wake up early")
// var result = backend.getHabitList()
// assertEquals(2, result.size)
// assertEquals(result[0]["name"], "Brush teeth")
// assertEquals(result[0]["name"], "Wake up early")
//
// backend.deleteHabit(1)
// result = backend.getHabitList()
// assertEquals(result.size, 1)
//
// backend.updateHabit(2, "Wake up late")
// result = backend.getHabitList()
// assertEquals(result[2]["name"], "Wake up late")
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits
import org.isoron.uhabits.utils.JavaFileOpener
import org.isoron.uhabits.database.JavaDatabaseOpener
import org.isoron.uhabits.utils.JavaLog
open class BaseTest {
val fileOpener = JavaFileOpener()
val databaseOpener = JavaDatabaseOpener(JavaLog())
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.database
import org.isoron.uhabits.BaseTest
import org.isoron.uhabits.utils.*
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
class JavaDatabaseTest : BaseTest() {
private lateinit var db: Database
@Before
fun setup() {
val dbFile = fileOpener.openUserFile("test.sqlite3")
if (dbFile.exists()) dbFile.delete()
db = databaseOpener.open(dbFile)
}
@Test
fun testUsage() {
db.setVersion(0)
assertEquals(db.getVersion(), 0)
db.setVersion(23)
assertEquals(db.getVersion(), 23)
var stmt = db.prepareStatement("drop table if exists demo")
stmt.step()
stmt.finalize()
stmt = db.prepareStatement("create table if not exists demo(key int, value text)")
stmt.step()
stmt.finalize()
stmt = db.prepareStatement("insert into demo(key, value) values (?1, ?2)")
stmt.bindInt(1, 42)
stmt.bindText(2, "Hello World")
stmt.step()
stmt.finalize()
stmt = db.prepareStatement("select * from demo where key > ?1")
stmt.bindInt(1, 10)
var result = stmt.step()
assertEquals(result, StepResult.ROW)
assertEquals(stmt.getInt(0), 42)
assertEquals(stmt.getText(1), "Hello World")
result = stmt.step()
assertEquals(result, StepResult.DONE)
stmt.finalize()
db.close()
}
@Test
fun testMigrateTo() {
assertEquals(0, db.getVersion())
db.migrateTo(22, fileOpener)
assertEquals(22, db.getVersion())
db.execute("select * from habits")
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) 2016-2019 Álinson Santos Xavier <isoron@gmail.com>
*
* This file is part of Loop Habit Tracker.
*
* Loop Habit Tracker is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by the
* Free Software Foundation, either version 3 of the License, or (at your
* option) any later version.
*
* Loop Habit Tracker is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
*
* You should have received a copy of the GNU General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.isoron.uhabits.utils
import org.isoron.uhabits.BaseTest
import org.junit.Test
import kotlin.test.assertEquals
class JavaFilesTest : BaseTest() {
@Test
fun testReadLines() {
val hello = fileOpener.openResourceFile("hello.txt")
var lines = hello.readLines()
assertEquals("Hello World!", lines[0])
assertEquals("This is a resource.", lines[1])
val migration = fileOpener.openResourceFile("migrations/012.sql")
lines = migration.readLines()
assertEquals("delete from Score", lines[0])
}
}