Remove old Database/Cursor/JdbcDatabase/MigrationHelper interface

This commit is contained in:
Alinson S. Xavier 2026-04-06 04:19:49 -05:00
parent b2f2e1f562
commit 0dbebecbfb
21 changed files with 531 additions and 893 deletions

View File

@ -19,28 +19,93 @@
package org.isoron.uhabits.database package org.isoron.uhabits.database
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import org.hamcrest.MatcherAssert.assertThat import org.isoron.platform.io.StepResult
import org.hamcrest.core.IsEqual.equalTo import org.isoron.platform.io.begin
import org.isoron.platform.io.commit
import org.isoron.platform.io.query
import org.isoron.platform.io.queryInt
import org.isoron.platform.io.querySingle
import org.isoron.platform.io.run
import org.isoron.uhabits.BaseAndroidTest import org.isoron.uhabits.BaseAndroidTest
import org.isoron.uhabits.core.database.Cursor import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
class AndroidDatabaseTest : BaseAndroidTest() { class AndroidDatabaseTest : BaseAndroidTest() {
private lateinit var db: AndroidDatabase private lateinit var db: AndroidDatabase
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
db = AndroidDatabase(SQLiteDatabase.create(null), null) db = AndroidDatabase(SQLiteDatabase.create(null))
db.execute("create table test(color int, name string)") db.run("create table test(color int, name string)")
} }
@Test @Test
@Throws(Exception::class) fun testInsertAndQuery() {
fun testInsert() { db.run("insert into test(color, name) values (?, ?)") {
val map = mapOf(Pair("name", "asd"), Pair("color", null)) bindNull(1)
db.insert("test", map) bindText(2, "asd")
val c: Cursor = db.query("select * from test") }
c.moveToNext() val stmt = db.prepareStatement("select color, name from test")
c.getInt(0)!! assertEquals(StepResult.ROW, stmt.step())
assertThat(c.getString(1), equalTo("asd")) assertNull(stmt.getIntOrNull(0))
assertEquals("asd", stmt.getText(1))
assertEquals(StepResult.DONE, stmt.step())
stmt.finalize()
}
@Test
fun testTransactionsViaRawSQL() {
db.run("create table t(v int)")
db.begin()
db.run("insert into t(v) values (1)")
db.run("insert into t(v) values (2)")
db.commit()
assertEquals(2, db.queryInt("select count(*) from t"))
}
@Test
fun testQueryHelpers() {
db.run("create table t(id int, name text)")
db.run("insert into t(id, name) values (1, 'Alice')")
db.run("insert into t(id, name) values (2, 'Bob')")
val names = mutableListOf<String>()
db.query("select name from t order by id") { stmt ->
names.add(stmt.getText(0))
}
assertEquals(listOf("Alice", "Bob"), names)
val single = db.querySingle("select name from t where id = ?", "2") {
it.getText(0)
}
assertEquals("Bob", single)
}
@Test
fun testNestedQueriesOnAndroid() {
db.run("create table groups_t(id int, name text)")
db.run("create table items(group_id int, label text)")
db.run("insert into groups_t(id, name) values (1, 'G1')")
db.run("insert into groups_t(id, name) values (2, 'G2')")
db.run("insert into items(group_id, label) values (1, 'A')")
db.run("insert into items(group_id, label) values (1, 'B')")
db.run("insert into items(group_id, label) values (2, 'C')")
val result = mutableMapOf<String, MutableList<String>>()
db.query("select id, name from groups_t order by id") { g ->
val gId = g.getInt(0)
val gName = g.getText(1)
val items = mutableListOf<String>()
db.query(
"select label from items where group_id = ? order by label",
gId.toString()
) { item ->
items.add(item.getText(0))
}
result[gName] = items
}
assertEquals(listOf("A", "B"), result["G1"]!!.toList())
assertEquals(listOf("C"), result["G2"]!!.toList())
} }
} }

View File

@ -26,7 +26,6 @@ import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.setVersion import org.isoron.platform.io.setVersion
import org.isoron.uhabits.core.database.UnsupportedDatabaseVersionException import org.isoron.uhabits.core.database.UnsupportedDatabaseVersionException
import org.isoron.uhabits.database.AndroidDatabase import org.isoron.uhabits.database.AndroidDatabase
import java.io.File
class HabitsDatabaseOpener( class HabitsDatabaseOpener(
private val context: Context, private val context: Context,
@ -52,7 +51,7 @@ class HabitsDatabaseOpener(
) { ) {
db.disableWriteAheadLogging() db.disableWriteAheadLogging()
if (db.version < 8) throw UnsupportedDatabaseVersionException() if (db.version < 8) throw UnsupportedDatabaseVersionException()
val wrappedDb = AndroidDatabase(db, File(databaseFilename)) val wrappedDb = AndroidDatabase(db)
wrappedDb.setVersion(db.version) wrappedDb.setVersion(db.version)
wrappedDb.migrateTo(newVersion) { version -> wrappedDb.migrateTo(newVersion) { version ->
val filename = "%02d.sql".format(version) val filename = "%02d.sql".format(version)

View File

@ -19,12 +19,10 @@
package org.isoron.uhabits.database package org.isoron.uhabits.database
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteStatement import android.database.sqlite.SQLiteStatement
import org.isoron.platform.io.PreparedStatement import org.isoron.platform.io.PreparedStatement
import org.isoron.platform.io.StepResult import org.isoron.platform.io.StepResult
import java.io.File
class AndroidPreparedStatement( class AndroidPreparedStatement(
private val db: SQLiteDatabase, private val db: SQLiteDatabase,
@ -128,81 +126,12 @@ class AndroidPreparedStatement(
} }
} }
/**
* Implements both the new multiplatform Database interface (for repositories)
* and the old JVM-only Database interface (for importers that still use Cursor).
*/
class AndroidDatabase( class AndroidDatabase(
private val db: SQLiteDatabase, private val db: SQLiteDatabase
override val file: File? = null ) : org.isoron.platform.io.Database {
) : org.isoron.platform.io.Database, org.isoron.uhabits.core.database.Database {
// New PreparedStatement-based interface
override fun prepareStatement(sql: String): PreparedStatement = override fun prepareStatement(sql: String): PreparedStatement =
AndroidPreparedStatement(db, sql) AndroidPreparedStatement(db, sql)
// Old Cursor-based interface
override fun query(q: String, vararg params: String) = AndroidCursor(db.rawQuery(q, params))
override fun execute(query: String, vararg params: Any) = db.execSQL(query, params)
override fun update(
tableName: String,
values: Map<String, Any?>,
where: String,
vararg params: String
): Int {
val contValues = mapToContentValues(values)
return db.update(tableName, contValues, where, params)
}
override fun insert(tableName: String, values: Map<String, Any?>): Long {
val contValues = mapToContentValues(values)
return db.insert(tableName, null, contValues)
}
override fun delete(tableName: String, where: String, vararg params: String) {
db.delete(tableName, where, params)
}
override fun beginTransaction() = db.beginTransaction()
override fun setTransactionSuccessful() = db.setTransactionSuccessful()
override fun endTransaction() = db.endTransaction()
override fun close() = db.close() override fun close() = db.close()
override val version: Int
get() = db.version
private fun mapToContentValues(map: Map<String, Any?>): ContentValues {
val values = ContentValues()
for ((key, value) in map) {
when (value) {
null -> values.putNull(key)
is Int -> values.put(key, value)
is Long -> values.put(key, value)
is Double -> values.put(key, value)
is String -> values.put(key, value)
else -> throw IllegalStateException("unsupported type: $value")
}
}
return values
}
}
class AndroidCursor(private val cursor: android.database.Cursor) : org.isoron.uhabits.core.database.Cursor {
override fun close() = cursor.close()
override fun moveToNext() = cursor.moveToNext()
override fun getInt(index: Int): Int? =
if (cursor.isNull(index)) null else cursor.getInt(index)
override fun getLong(index: Int): Long? =
if (cursor.isNull(index)) null else cursor.getLong(index)
override fun getDouble(index: Int): Double? =
if (cursor.isNull(index)) null else cursor.getDouble(index)
override fun getString(index: Int): String? =
if (cursor.isNull(index)) null else cursor.getString(index)
} }

View File

@ -21,19 +21,17 @@ package org.isoron.uhabits.database
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
import org.isoron.uhabits.core.database.DatabaseOpener import org.isoron.platform.io.DatabaseOpener
import java.io.File
@Inject @Inject
class AndroidDatabaseOpener() : DatabaseOpener { class AndroidDatabaseOpener() : DatabaseOpener {
override fun open(file: File): AndroidDatabase { override fun open(path: String): AndroidDatabase {
return AndroidDatabase( return AndroidDatabase(
db = SQLiteDatabase.openDatabase( db = SQLiteDatabase.openDatabase(
file.absolutePath, path,
null, null,
SQLiteDatabase.OPEN_READWRITE SQLiteDatabase.OPEN_READWRITE
), )
file = file
) )
} }
} }

View File

@ -21,10 +21,9 @@ package org.isoron.uhabits.inject
import android.content.Context import android.content.Context
import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides import me.tatarka.inject.annotations.Provides
import org.isoron.platform.io.DatabaseOpener
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.io.GenericImporter
import org.isoron.uhabits.core.io.Logging import org.isoron.uhabits.core.io.Logging
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
@ -85,13 +84,9 @@ abstract class HabitsApplicationComponent(
get() = providedDb get() = providedDb
private val providedDb: AndroidDatabase by lazy { private val providedDb: AndroidDatabase by lazy {
AndroidDatabase(DatabaseUtils.openDatabase(), dbFile) AndroidDatabase(DatabaseUtils.openDatabase())
} }
@AppScope
@Provides
open fun database(): Database = providedDb
@AppScope @AppScope
@Provides @Provides
open fun preferences(storage: SharedPreferencesStorage): Preferences = open fun preferences(storage: SharedPreferencesStorage): Preferences =

View File

@ -77,14 +77,41 @@ fun Database.commit() {
run("COMMIT") run("COMMIT")
} }
inline fun Database.query(
sql: String,
vararg params: String,
block: (PreparedStatement) -> Unit
) {
val stmt = prepareStatement(sql)
for (i in params.indices) {
stmt.bindText(i + 1, params[i])
}
while (stmt.step() == StepResult.ROW) {
block(stmt)
}
stmt.finalize()
}
inline fun <T> Database.querySingle(
sql: String,
vararg params: String,
block: (PreparedStatement) -> T
): T? {
val stmt = prepareStatement(sql)
for (i in params.indices) {
stmt.bindText(i + 1, params[i])
}
val result = if (stmt.step() == StepResult.ROW) block(stmt) else null
stmt.finalize()
return result
}
fun Database.migrateTo(targetVersion: Int, loadMigrationSQL: (Int) -> String) { fun Database.migrateTo(targetVersion: Int, loadMigrationSQL: (Int) -> String) {
val currentVersion = getVersion() val currentVersion = getVersion()
if (currentVersion >= targetVersion) return if (currentVersion >= targetVersion) return
begin()
for (v in (currentVersion + 1)..targetVersion) { for (v in (currentVersion + 1)..targetVersion) {
val commands = SQLParser.parse(loadMigrationSQL(v)) val commands = SQLParser.parse(loadMigrationSQL(v))
for (cmd in commands) run(cmd) for (cmd in commands) run(cmd)
setVersion(v) setVersion(v)
} }
commit()
} }

View File

@ -0,0 +1,146 @@
package org.isoron.platform.io
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DatabaseQueryHelpersTest {
@Test
fun testQueryIteratesAllRows() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table t(v int)")
db.run("insert into t(v) values (10)")
db.run("insert into t(v) values (20)")
db.run("insert into t(v) values (30)")
val values = mutableListOf<Int>()
db.query("select v from t order by v") { stmt ->
values.add(stmt.getInt(0))
}
assertEquals(listOf(10, 20, 30), values)
db.close()
}
@Test
fun testQueryWithParameters() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table t(name text, age int)")
db.run("insert into t(name, age) values ('Alice', 30)")
db.run("insert into t(name, age) values ('Bob', 25)")
db.run("insert into t(name, age) values ('Carol', 35)")
val names = mutableListOf<String>()
db.query("select name from t where age > ?", "28") { stmt ->
names.add(stmt.getText(0))
}
assertEquals(2, names.size)
assertTrue(names.contains("Alice"))
assertTrue(names.contains("Carol"))
db.close()
}
@Test
fun testQueryWithNoResults() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table t(v int)")
var called = false
db.query("select v from t") { called = true }
assertFalse(called)
db.close()
}
@Test
fun testQuerySingleReturnsFirstRow() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table t(v int)")
db.run("insert into t(v) values (42)")
db.run("insert into t(v) values (99)")
val result = db.querySingle("select v from t order by v") { it.getInt(0) }
assertEquals(42, result)
db.close()
}
@Test
fun testQuerySingleReturnsNullForEmptyResult() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table t(v int)")
val result = db.querySingle("select v from t") { it.getInt(0) }
assertNull(result)
db.close()
}
@Test
fun testQuerySingleWithParameters() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table t(name text, score int)")
db.run("insert into t(name, score) values ('Alice', 90)")
db.run("insert into t(name, score) values ('Bob', 80)")
val score = db.querySingle(
"select score from t where name = ?",
"Alice"
) { it.getInt(0) }
assertEquals(90, score)
val missing = db.querySingle(
"select score from t where name = ?",
"Nobody"
) { it.getInt(0) }
assertNull(missing)
db.close()
}
@Test
fun testNestedQueries() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table parents(id int, name text)")
db.run("create table children(parent_id int, name text)")
db.run("insert into parents(id, name) values (1, 'Alice')")
db.run("insert into parents(id, name) values (2, 'Bob')")
db.run("insert into children(parent_id, name) values (1, 'Charlie')")
db.run("insert into children(parent_id, name) values (1, 'Diana')")
db.run("insert into children(parent_id, name) values (2, 'Eve')")
val result = mutableMapOf<String, MutableList<String>>()
db.query("select id, name from parents order by id") { parent ->
val parentId = parent.getInt(0)
val parentName = parent.getText(1)
val kids = mutableListOf<String>()
db.query(
"select name from children where parent_id = ? order by name",
parentId.toString()
) { child ->
kids.add(child.getText(0))
}
result[parentName] = kids
}
assertEquals(listOf("Charlie", "Diana"), result["Alice"]!!.toList())
assertEquals(listOf("Eve"), result["Bob"]!!.toList())
db.close()
}
@Test
fun testQueryHandlesNullableColumns() {
val db = TestDatabaseHelper.createEmptyDatabase()
db.run("create table t(a int, b text)")
db.run("insert into t(a, b) values (1, 'hello')")
db.run("insert into t(a, b) values (null, null)")
val results = mutableListOf<Pair<Int?, String?>>()
db.query("select a, b from t order by rowid") { stmt ->
results.add(Pair(stmt.getIntOrNull(0), stmt.getTextOrNull(1)))
}
assertEquals(2, results.size)
assertEquals(1, results[0].first)
assertEquals("hello", results[0].second)
assertNull(results[1].first)
assertNull(results[1].second)
db.close()
}
}

View File

@ -1,62 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* 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.core.database
import java.io.Closeable
interface Cursor : Closeable {
override fun close()
/**
* Moves the cursor forward one row from its current position. Returns
* true if the current position is valid, or false if the cursor is already
* past the last row. The cursor start at position -1, so this method must
* be called first.
*/
fun moveToNext(): Boolean
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as an Integer. If the value is null, returns null. The first
* column has index zero.
*/
fun getInt(index: Int): Int?
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as a Long. If the value is null, returns null. The first
* column has index zero.
*/
fun getLong(index: Int): Long?
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as a Double. If the value is null, returns null. The first
* column has index zero.
*/
fun getDouble(index: Int): Double?
/**
* Retrieves the value of the designated column in the current row of this
* Cursor as a String. If the value is null, returns null. The first
* column has index zero.
*/
fun getString(index: Int): String?
}

View File

@ -1,62 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* 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.core.database
import java.io.File
interface Database {
fun query(q: String, vararg params: String): Cursor
fun query(q: String, callback: ProcessCallback) {
query(q).use { c ->
c.moveToNext()
callback.process(c)
}
}
fun update(
tableName: String,
values: Map<String, Any?>,
where: String,
vararg params: String
): Int
fun insert(tableName: String, values: Map<String, Any?>): Long?
fun delete(tableName: String, where: String, vararg params: String)
fun execute(query: String, vararg params: Any)
fun beginTransaction()
fun setTransactionSuccessful()
fun endTransaction()
fun close()
val version: Int
val file: File?
fun interface ProcessCallback {
fun process(cursor: Cursor)
}
}

View File

@ -1,25 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* 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.core.database
import java.io.File
interface DatabaseOpener {
fun open(file: File): Database
}

View File

@ -1,76 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* 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.core.database
import java.sql.ResultSet
import java.sql.SQLException
class JdbcCursor(private val resultSet: ResultSet) : Cursor {
override fun close() {
try {
resultSet.close()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun moveToNext(): Boolean {
return try {
resultSet.next()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getInt(index: Int): Int? {
return try {
val value = resultSet.getInt(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getLong(index: Int): Long? {
return try {
val value = resultSet.getLong(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getDouble(index: Int): Double? {
return try {
val value = resultSet.getDouble(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun getString(index: Int): String? {
return try {
val value = resultSet.getString(index + 1)
if (resultSet.wasNull()) null else value
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
}

View File

@ -1,165 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* 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.core.database
import java.io.File
import java.sql.Connection
import java.sql.PreparedStatement
import java.sql.SQLException
import java.sql.Types
import java.util.ArrayList
class JdbcDatabase(private val connection: Connection) : Database {
private var transactionSuccessful = false
override fun query(q: String, vararg params: String): Cursor {
return try {
val st = buildStatement(q, params)
JdbcCursor(st.executeQuery())
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun update(
tableName: String,
values: Map<String, Any?>,
where: String,
vararg params: String
): Int {
return try {
val fields = ArrayList<String?>()
val valuesStr = ArrayList<String>()
for ((key, value) in values) {
fields.add("$key=?")
valuesStr.add(value.toString())
}
valuesStr.addAll(listOf(*params))
val query = String.format(
"update %s set %s where %s",
tableName,
fields.joinToString(", "),
where
)
val st = buildStatement(query, valuesStr.toTypedArray())
st.executeUpdate()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun insert(tableName: String, values: Map<String, Any?>): Long? {
return try {
val fields = ArrayList<String?>()
val params = ArrayList<Any?>()
val questionMarks = ArrayList<String?>()
for ((key, value) in values) {
fields.add(key)
params.add(value)
questionMarks.add("?")
}
val query = String.format(
"insert into %s(%s) values(%s)",
tableName,
fields.joinToString(", "),
questionMarks.joinToString(", ")
)
val st = buildStatement(query, params.toTypedArray())
st.execute()
var id: Long? = null
val keys = st.generatedKeys
if (keys.next()) id = keys.getLong(1)
id
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun delete(tableName: String, where: String, vararg params: String) {
val query = String.format("delete from %s where %s", tableName, where)
execute(query, *params)
}
override fun execute(query: String, vararg params: Any) {
try {
buildStatement(query, params).execute()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
private fun buildStatement(query: String, params: Array<out Any?>): PreparedStatement {
val st = connection.prepareStatement(query)
var index = 1
for (param in params) {
when (param) {
null -> st.setNull(index++, Types.INTEGER)
is Int -> st.setInt(index++, param)
is Double -> st.setDouble(index++, param)
is String -> st.setString(index++, param)
is Long -> st.setLong(index++, param)
else -> throw IllegalArgumentException()
}
}
return st
}
@Synchronized
override fun beginTransaction() {
try {
connection.autoCommit = false
transactionSuccessful = false
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
@Synchronized
override fun setTransactionSuccessful() {
transactionSuccessful = true
}
@Synchronized
override fun endTransaction() {
try {
if (transactionSuccessful) connection.commit() else connection.rollback()
connection.autoCommit = true
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override fun close() {
try {
connection.close()
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
override val version: Int
get() {
query("PRAGMA user_version").use { c ->
c.moveToNext()
return c.getInt(0)!!
}
}
override val file: File?
get() = null
}

View File

@ -1,51 +0,0 @@
/*
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
*
* 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.core.database
import java.io.File
import java.io.FileInputStream
import java.util.Locale
class MigrationHelper(
private val db: Database
) {
fun migrateTo(newVersion: Int) {
try {
for (v in db.version + 1..newVersion) {
val fname = String.format(Locale.US, "/migrations/%02d.sql", v)
val sql = open(fname)
for (command in SQLParser.parse(sql)) db.execute(command)
}
} catch (e: Exception) {
throw RuntimeException(e)
}
}
private fun open(fname: String): String {
val resource = javaClass.getResourceAsStream(fname)
if (resource != null) return resource.bufferedReader().readText()
// Workaround for bug in Android Studio / IntelliJ. Removing this
// causes unit tests to fail when run from within the IDE, although
// everything works fine from the command line.
val file = File("uhabits-core/src/main/resources/$fname")
if (file.exists()) return FileInputStream(file).bufferedReader().readText()
throw RuntimeException("resource not found: $fname")
}
}

View File

@ -19,17 +19,19 @@
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
import org.isoron.platform.io.Database
import org.isoron.platform.io.DatabaseOpener
import org.isoron.platform.io.getVersion
import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.query
import org.isoron.platform.io.querySingle
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.AppScope import org.isoron.uhabits.core.AppScope
import org.isoron.uhabits.core.DATABASE_VERSION import org.isoron.uhabits.core.DATABASE_VERSION
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand import org.isoron.uhabits.core.commands.CreateHabitCommand
import org.isoron.uhabits.core.commands.EditHabitCommand import org.isoron.uhabits.core.commands.EditHabitCommand
import org.isoron.uhabits.core.database.Cursor
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.database.HabitData import org.isoron.uhabits.core.database.HabitData
import org.isoron.uhabits.core.database.MigrationHelper
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
@ -53,26 +55,30 @@ class LoopDBImporter(
override fun canHandle(file: File): Boolean { override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false if (!file.isSQLite3File()) return false
val db = opener.open(file) val db = opener.open(file.absolutePath)
var canHandle = true var canHandle = true
val c = db.query("select count(*) from SQLITE_MASTER where name='Habits' or name='Repetitions'") val count = db.querySingle(
if (!c.moveToNext() || c.getInt(0) != 2) { "select count(*) from SQLITE_MASTER where name='Habits' or name='Repetitions'"
) { it.getInt(0) }
if (count == null || count != 2) {
logger.error("Cannot handle file: tables not found") logger.error("Cannot handle file: tables not found")
canHandle = false canHandle = false
} }
if (db.version > DATABASE_VERSION) { if (db.getVersion() > DATABASE_VERSION) {
logger.error("Cannot handle file: incompatible version: ${db.version} > $DATABASE_VERSION") logger.error("Cannot handle file: incompatible version: ${db.getVersion()} > $DATABASE_VERSION")
canHandle = false canHandle = false
} }
c.close()
db.close() db.close()
return canHandle return canHandle
} }
override fun importHabitsFromFile(file: File) { override fun importHabitsFromFile(file: File) {
val db = opener.open(file) val db = opener.open(file.absolutePath)
val helper = MigrationHelper(db) db.migrateTo(DATABASE_VERSION) { version ->
helper.migrateTo(DATABASE_VERSION) val filename = "%02d.sql".format(version)
javaClass.getResourceAsStream("/migrations/$filename")!!
.bufferedReader().readText()
}
val habitDataList = loadHabits(db) val habitDataList = loadHabits(db)
for (habitData in habitDataList) { for (habitData in habitDataList) {
@ -89,21 +95,20 @@ class LoopDBImporter(
EditHabitCommand(habitList, habit.id!!, modified).run() EditHabitCommand(habitList, habit.id!!, modified).run()
} }
// Reload saved version of the habit
habit = habitList.getByUUID(habitData.uuid)!! habit = habitList.getByUUID(habitData.uuid)!!
val entries = habit.originalEntries val entries = habit.originalEntries
// Import entries db.query(
loadEntries(db, habitData.id!!).use { c -> "SELECT timestamp, value, notes FROM Repetitions WHERE habit = ? ORDER BY timestamp DESC",
while (c.moveToNext()) { habitData.id.toString()
val timestamp = c.getLong(0) ?: continue ) { stmt ->
val value = c.getInt(1) ?: continue val timestamp = stmt.getLongOrNull(0) ?: return@query
val notes = c.getString(2) ?: "" val value = stmt.getIntOrNull(1) ?: return@query
val date = LocalDate.fromUnixTime(timestamp) val notes = stmt.getTextOrNull(2) ?: ""
val (_, existingValue, existingNotes) = entries.get(date) val date = LocalDate.fromUnixTime(timestamp)
if (existingValue != value || existingNotes != notes) { val (_, existingValue, existingNotes) = entries.get(date)
entries.add(Entry(date, value, notes)) if (existingValue != value || existingNotes != notes) {
} entries.add(Entry(date, value, notes))
} }
} }
habit.recompute() habit.recompute()
@ -119,41 +124,30 @@ class LoopDBImporter(
"position, reminder_hour, reminder_min, reminder_days, highlight, " + "position, reminder_hour, reminder_min, reminder_days, highlight, " +
"archived, type, target_value, target_type, unit, uuid " + "archived, type, target_value, target_type, unit, uuid " +
"FROM Habits ORDER BY position" "FROM Habits ORDER BY position"
).use { c -> ) { stmt ->
while (c.moveToNext()) { result.add(
result.add(cursorToHabitData(c)) HabitData(
} id = stmt.getLongOrNull(0),
name = stmt.getTextOrNull(1) ?: "",
description = stmt.getTextOrNull(2) ?: "",
question = stmt.getTextOrNull(3) ?: "",
freqNum = stmt.getIntOrNull(4) ?: 1,
freqDen = stmt.getIntOrNull(5) ?: 1,
color = stmt.getIntOrNull(6) ?: 0,
position = stmt.getIntOrNull(7) ?: 0,
reminderHour = stmt.getIntOrNull(8),
reminderMin = stmt.getIntOrNull(9),
reminderDays = stmt.getIntOrNull(10) ?: 0,
highlight = stmt.getIntOrNull(11) ?: 0,
archived = stmt.getIntOrNull(12) ?: 0,
type = stmt.getIntOrNull(13) ?: 0,
targetValue = stmt.getRealOrNull(14) ?: 0.0,
targetType = stmt.getIntOrNull(15) ?: 0,
unit = stmt.getTextOrNull(16) ?: "",
uuid = stmt.getTextOrNull(17)
)
)
} }
return result return result
} }
private fun loadEntries(db: Database, habitId: Long): Cursor {
return db.query(
"SELECT timestamp, value, notes FROM Repetitions WHERE habit = ? ORDER BY timestamp DESC",
habitId.toString()
)
}
private fun cursorToHabitData(c: Cursor): HabitData {
return HabitData(
id = c.getLong(0),
name = c.getString(1) ?: "",
description = c.getString(2) ?: "",
question = c.getString(3) ?: "",
freqNum = c.getInt(4) ?: 1,
freqDen = c.getInt(5) ?: 1,
color = c.getInt(6) ?: 0,
position = c.getInt(7) ?: 0,
reminderHour = c.getInt(8),
reminderMin = c.getInt(9),
reminderDays = c.getInt(10) ?: 0,
highlight = c.getInt(11) ?: 0,
archived = c.getInt(12) ?: 0,
type = c.getInt(13) ?: 0,
targetValue = c.getDouble(14) ?: 0.0,
targetType = c.getInt(15) ?: 0,
unit = c.getString(16) ?: "",
uuid = c.getString(17)
)
}
} }

View File

@ -19,10 +19,13 @@
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
import org.isoron.platform.io.Database
import org.isoron.platform.io.DatabaseOpener
import org.isoron.platform.io.begin
import org.isoron.platform.io.commit
import org.isoron.platform.io.query
import org.isoron.platform.io.querySingle
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.database.Cursor
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
@ -45,124 +48,98 @@ class RewireDBImporter(
override fun canHandle(file: File): Boolean { override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false if (!file.isSQLite3File()) return false
val db = opener.open(file) val db = opener.open(file.absolutePath)
val c = db.query( val count = db.querySingle(
"select count(*) from SQLITE_MASTER " + "select count(*) from SQLITE_MASTER where name='CHECKINS' or name='UNIT'"
"where name='CHECKINS' or name='UNIT'" ) { it.getInt(0) }
)
val result = c.moveToNext() && c.getInt(0) == 2
c.close()
db.close() db.close()
return result return count == 2
} }
override fun importHabitsFromFile(file: File) { override fun importHabitsFromFile(file: File) {
val db = opener.open(file) val db = opener.open(file.absolutePath)
db.beginTransaction() db.begin()
createHabits(db) createHabits(db)
db.setTransactionSuccessful() db.commit()
db.endTransaction()
db.close() db.close()
} }
private fun createHabits(db: Database) { private fun createHabits(db: Database) {
var c: Cursor? = null db.query(
try { "select _id, name, description, schedule, " +
c = db.query( "active_days, repeating_count, days, period " +
"select _id, name, description, schedule, " + "from habits"
"active_days, repeating_count, days, period " + ) { stmt ->
"from habits" val id = stmt.getInt(0)
) val name = stmt.getText(1)
if (!c.moveToNext()) return val description = stmt.getTextOrNull(2) ?: ""
do { val schedule = stmt.getInt(3)
val id = c.getInt(0)!! val activeDays = stmt.getTextOrNull(4)
val name = c.getString(1) val repeatingCount = stmt.getInt(5)
val description = c.getString(2) val days = stmt.getInt(6)
val schedule = c.getInt(3)!! val periodIndex = stmt.getInt(7)
val activeDays = c.getString(4)
val repeatingCount = c.getInt(5)!!
val days = c.getInt(6)!!
val periodIndex = c.getInt(7)!!
val habit = modelFactory.buildHabit() val habit = modelFactory.buildHabit()
habit.name = name!! habit.name = name
habit.description = description ?: "" habit.description = description
val periods = intArrayOf(7, 31, 365) val periods = intArrayOf(7, 31, 365)
var numerator: Int var numerator: Int
var denominator: Int var denominator: Int
when (schedule) { when (schedule) {
0 -> { 0 -> {
numerator = activeDays!!.split(",").toTypedArray().size numerator = activeDays!!.split(",").toTypedArray().size
denominator = 7 denominator = 7
}
1 -> {
numerator = days
denominator = periods[periodIndex]
}
2 -> {
numerator = 1
denominator = repeatingCount
}
else -> throw IllegalStateException()
} }
habit.frequency = Frequency(numerator, denominator) 1 -> {
habitList.add(habit) numerator = days
createReminder(db, habit, id) denominator = periods[periodIndex]
createCheckmarks(db, habit, id) }
} while (c.moveToNext()) 2 -> {
} finally { numerator = 1
c?.close() denominator = repeatingCount
}
else -> throw IllegalStateException()
}
habit.frequency = Frequency(numerator, denominator)
habitList.add(habit)
createReminder(db, habit, id)
createCheckmarks(db, habit, id)
} }
} }
private fun createCheckmarks( private fun createCheckmarks(db: Database, habit: Habit, rewireHabitId: Int) {
db: Database, db.query(
habit: Habit, "select distinct date from checkins where habit_id=? and type=2",
rewireHabitId: Int rewireHabitId.toString()
) { ) { stmt ->
var c: Cursor? = null val dateStr = stmt.getText(0)
try { val year = dateStr.substring(0, 4).toInt()
c = db.query( val month = dateStr.substring(4, 6).toInt()
"select distinct date from checkins where habit_id=? and type=2", val day = dateStr.substring(6, 8).toInt()
rewireHabitId.toString() habit.originalEntries.add(Entry(LocalDate(year, month, day), Entry.YES_MANUAL))
)
if (!c.moveToNext()) return
do {
val dateStr = c.getString(0)
val year = dateStr!!.substring(0, 4).toInt()
val month = dateStr.substring(4, 6).toInt()
val day = dateStr.substring(6, 8).toInt()
habit.originalEntries.add(Entry(LocalDate(year, month, day), Entry.YES_MANUAL))
} while (c.moveToNext())
} finally {
c?.close()
} }
} }
private fun createReminder(db: Database, habit: Habit, rewireHabitId: Int) { private fun createReminder(db: Database, habit: Habit, rewireHabitId: Int) {
var c: Cursor? = null val reminder = db.querySingle(
try { "select time, active_days from reminders where habit_id=? limit 1",
c = db.query( rewireHabitId.toString()
"select time, active_days from reminders where habit_id=? limit 1", ) { stmt ->
rewireHabitId.toString() val rewireReminder = stmt.getInt(0)
) if (rewireReminder <= 0 || rewireReminder >= 1440) return@querySingle null
if (!c.moveToNext()) return
val rewireReminder = c.getInt(0)!!
if (rewireReminder <= 0 || rewireReminder >= 1440) return
val reminderDays = BooleanArray(7) val reminderDays = BooleanArray(7)
val activeDays = c.getString(1)!!.split(",").toTypedArray() val activeDaysStr = stmt.getText(1).split(",").toTypedArray()
for (d in activeDays) { for (d in activeDaysStr) {
val idx = (d.toInt() + 1) % 7 val idx = (d.toInt() + 1) % 7
reminderDays[idx] = true reminderDays[idx] = true
} }
val hour = rewireReminder / 60 val hour = rewireReminder / 60
val minute = rewireReminder % 60 val minute = rewireReminder % 60
val days = WeekdayList(reminderDays) Reminder(hour, minute, WeekdayList(reminderDays))
val reminder = Reminder(hour, minute, days) }
if (reminder != null) {
habit.reminder = reminder habit.reminder = reminder
habitList.update(habit) habitList.update(habit)
} finally {
c?.close()
} }
} }
} }

View File

@ -19,10 +19,13 @@
package org.isoron.uhabits.core.io package org.isoron.uhabits.core.io
import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Inject
import org.isoron.platform.io.Database
import org.isoron.platform.io.DatabaseOpener
import org.isoron.platform.io.begin
import org.isoron.platform.io.commit
import org.isoron.platform.io.query
import org.isoron.platform.io.querySingle
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.database.Cursor
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Frequency import org.isoron.uhabits.core.models.Frequency
import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.Habit
@ -43,67 +46,45 @@ class TickmateDBImporter(
override fun canHandle(file: File): Boolean { override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false if (!file.isSQLite3File()) return false
val db = opener.open(file) val db = opener.open(file.absolutePath)
val c = db.query( val count = db.querySingle(
"select count(*) from SQLITE_MASTER " + "select count(*) from SQLITE_MASTER where name='tracks' or name='track2groups'"
"where name='tracks' or name='track2groups'" ) { it.getInt(0) }
)
val result = c.moveToNext() && c.getInt(0) == 2
c.close()
db.close() db.close()
return result return count == 2
} }
override fun importHabitsFromFile(file: File) { override fun importHabitsFromFile(file: File) {
val db = opener.open(file) val db = opener.open(file.absolutePath)
db.beginTransaction() db.begin()
createHabits(db) createHabits(db)
db.setTransactionSuccessful() db.commit()
db.endTransaction()
db.close() db.close()
} }
private fun createCheckmarks( private fun createCheckmarks(db: Database, habit: Habit, tickmateTrackId: Int) {
db: Database, db.query(
habit: Habit, "select distinct year, month, day from ticks where _track_id=?",
tickmateTrackId: Int tickmateTrackId.toString()
) { ) { stmt ->
var c: Cursor? = null val year = stmt.getInt(0)
try { val month = stmt.getInt(1)
c = db.query( val day = stmt.getInt(2)
"select distinct year, month, day from ticks where _track_id=?", habit.originalEntries.add(Entry(LocalDate(year, month + 1, day), Entry.YES_MANUAL))
tickmateTrackId.toString()
)
if (!c.moveToNext()) return
do {
val year = c.getInt(0)!!
val month = c.getInt(1)!!
val day = c.getInt(2)!!
habit.originalEntries.add(Entry(LocalDate(year, month + 1, day), Entry.YES_MANUAL))
} while (c.moveToNext())
} finally {
c?.close()
} }
} }
private fun createHabits(db: Database) { private fun createHabits(db: Database) {
var c: Cursor? = null db.query("select _id, name, description from tracks") { stmt ->
try { val id = stmt.getInt(0)
c = db.query("select _id, name, description from tracks") val name = stmt.getText(1)
if (!c.moveToNext()) return val description = stmt.getTextOrNull(2) ?: ""
do { val habit = modelFactory.buildHabit()
val id = c.getInt(0)!! habit.name = name
val name = c.getString(1) habit.description = description
val description = c.getString(2) habit.frequency = Frequency.DAILY
val habit = modelFactory.buildHabit() habitList.add(habit)
habit.name = name!! createCheckmarks(db, habit, id)
habit.description = description ?: ""
habit.frequency = Frequency.DAILY
habitList.add(habit)
createCheckmarks(db, habit, id)
} while (c.moveToNext())
} finally {
c?.close()
} }
} }
} }

View File

@ -19,14 +19,11 @@
package org.isoron.uhabits.core package org.isoron.uhabits.core
import org.apache.commons.io.IOUtils import org.apache.commons.io.IOUtils
import org.isoron.platform.io.JavaDatabaseOpener
import org.isoron.platform.io.TestDatabaseHelper import org.isoron.platform.io.TestDatabaseHelper
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.platform.time.setToday import org.isoron.platform.time.setToday
import org.isoron.uhabits.core.commands.CommandRunner import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.DatabaseOpener
import org.isoron.uhabits.core.database.JdbcDatabase
import org.isoron.uhabits.core.database.MigrationHelper
import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ModelFactory
import org.isoron.uhabits.core.models.memory.MemoryModelFactory import org.isoron.uhabits.core.models.memory.MemoryModelFactory
@ -45,8 +42,6 @@ import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.nio.file.Paths import java.nio.file.Paths
import java.sql.DriverManager
import java.sql.SQLException
import java.util.GregorianCalendar import java.util.GregorianCalendar
import java.util.TimeZone import java.util.TimeZone
@ -57,22 +52,7 @@ open class BaseUnitTest {
protected lateinit var modelFactory: ModelFactory protected lateinit var modelFactory: ModelFactory
protected lateinit var taskRunner: SingleThreadTaskRunner protected lateinit var taskRunner: SingleThreadTaskRunner
protected open lateinit var commandRunner: CommandRunner protected open lateinit var commandRunner: CommandRunner
protected var databaseOpener: DatabaseOpener = object : DatabaseOpener { protected var databaseOpener: org.isoron.platform.io.DatabaseOpener = JavaDatabaseOpener()
override fun open(file: File): Database {
return try {
JdbcDatabase(
DriverManager.getConnection(
String.format(
"jdbc:sqlite:%s",
file.absolutePath
)
)
)
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
}
@Before @Before
@Throws(Exception::class) @Throws(Exception::class)
@ -125,31 +105,17 @@ open class BaseUnitTest {
} }
@Throws(IOException::class) @Throws(IOException::class)
protected fun openDatabaseResource(path: String): Database { protected fun openDatabaseResource(path: String): org.isoron.platform.io.Database {
val original = openAsset(path) val original = openAsset(path)
val tmpDbFile = File.createTempFile("database", ".db") val tmpDbFile = File.createTempFile("database", ".db")
tmpDbFile.deleteOnExit() tmpDbFile.deleteOnExit()
IOUtils.copy(original, FileOutputStream(tmpDbFile)) IOUtils.copy(original, FileOutputStream(tmpDbFile))
return databaseOpener.open(tmpDbFile) return databaseOpener.open(tmpDbFile.absolutePath)
} }
companion object { companion object {
fun buildMemoryDatabase(): Database { fun buildMemoryDatabase(): org.isoron.platform.io.Database {
return try { return TestDatabaseHelper.createEmptyDatabase()
val db: Database = JdbcDatabase(
DriverManager.getConnection("jdbc:sqlite::memory:")
)
db.execute("pragma user_version=8;")
val helper = MigrationHelper(db)
helper.migrateTo(DATABASE_VERSION)
db
} catch (e: SQLException) {
throw RuntimeException(e)
}
}
fun buildNewMemoryDatabase(): org.isoron.platform.io.Database {
return org.isoron.platform.io.TestDatabaseHelper.createEmptyDatabase()
} }
} }
} }

View File

@ -21,132 +21,132 @@ package org.isoron.uhabits.core.database.migrations
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers import org.hamcrest.Matchers
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.isoron.platform.io.Database
import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.querySingle
import org.isoron.platform.io.run
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.database.Cursor
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.MigrationHelper
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
class Version22Test : BaseUnitTest() { class Version22Test : BaseUnitTest() {
private lateinit var db: Database private lateinit var db: Database
private lateinit var helper: MigrationHelper
@Throws(Exception::class) @Throws(Exception::class)
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
db = openDatabaseResource("/databases/021.db") db = openDatabaseResource("/databases/021.db")
helper = MigrationHelper(db) }
private fun migrateTo(version: Int) {
db.migrateTo(version) { v ->
val filename = "%02d.sql".format(v)
javaClass.getResourceAsStream("/migrations/$filename")!!
.bufferedReader().readText()
}
} }
@Test @Test
@Throws(Exception::class)
fun testKeepValidReps() { fun testKeepValidReps() {
db.query("select count(*) from repetitions") { c: Cursor -> val before = db.querySingle("select count(*) from repetitions") { it.getInt(0) }
assertThat(c.getInt(0), equalTo(3)) assertThat(before, equalTo(3))
} migrateTo(22)
helper.migrateTo(22) val after = db.querySingle("select count(*) from repetitions") { it.getInt(0) }
db.query("select count(*) from repetitions") { c: Cursor -> assertThat(after, equalTo(3))
assertThat(c.getInt(0), equalTo(3))
}
} }
@Test @Test
@Throws(Exception::class)
fun testRemoveRepsWithInvalidId() { fun testRemoveRepsWithInvalidId() {
db.execute("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)") db.run("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)")
db.query("select count(*) from repetitions where habit = 99999") { c: Cursor -> val before = db.querySingle(
assertThat(c.getInt(0), equalTo(1)) "select count(*) from repetitions where habit = 99999"
} ) { it.getInt(0) }
helper.migrateTo(22) assertThat(before, equalTo(1))
db.query("select count(*) from repetitions where habit = 99999") { c: Cursor -> migrateTo(22)
assertThat(c.getInt(0), equalTo(0)) val after = db.querySingle(
} "select count(*) from repetitions where habit = 99999"
) { it.getInt(0) }
assertThat(after, equalTo(0))
} }
@Test @Test
@Throws(Exception::class)
fun testDisallowNewRepsWithInvalidRef() { fun testDisallowNewRepsWithInvalidRef() {
helper.migrateTo(22) migrateTo(22)
val exception = assertThrows(java.lang.RuntimeException::class.java) { db.execute("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)") } val exception = assertThrows(Exception::class.java) {
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT")) db.run("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)")
}
assertThat(exception.message, Matchers.containsString("constraint"))
} }
@Test @Test
@Throws(Exception::class)
fun testRemoveRepetitionsWithNullTimestamp() { fun testRemoveRepetitionsWithNullTimestamp() {
db.execute("insert into repetitions(habit, value) values (0, 2)") db.run("insert into repetitions(habit, value) values (0, 2)")
db.query("select count(*) from repetitions where timestamp is null") { c: Cursor -> val before = db.querySingle(
assertThat(c.getInt(0), equalTo(1)) "select count(*) from repetitions where timestamp is null"
} ) { it.getInt(0) }
helper.migrateTo(22) assertThat(before, equalTo(1))
db.query("select count(*) from repetitions where timestamp is null") { c: Cursor -> migrateTo(22)
assertThat(c.getInt(0), equalTo(0)) val after = db.querySingle(
} "select count(*) from repetitions where timestamp is null"
) { it.getInt(0) }
assertThat(after, equalTo(0))
} }
@Test @Test
@Throws(Exception::class)
fun testDisallowNullTimestamp() { fun testDisallowNullTimestamp() {
helper.migrateTo(22) migrateTo(22)
val exception = assertThrows(Exception::class.java) {
val exception = assertThrows(java.lang.RuntimeException::class.java) { db.run("insert into Repetitions(habit, value) values (0, 2)")
db.execute("insert into Repetitions(habit, value) " + "values (0, 2)")
} }
assertThat(exception.message, Matchers.containsString("constraint"))
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT"))
} }
@Test @Test
@Throws(Exception::class)
fun testRemoveRepetitionsWithNullHabit() { fun testRemoveRepetitionsWithNullHabit() {
db.execute("insert into repetitions(timestamp, value) values (0, 2)") db.run("insert into repetitions(timestamp, value) values (0, 2)")
db.query("select count(*) from repetitions where habit is null") { c: Cursor -> val before = db.querySingle(
assertThat(c.getInt(0), equalTo(1)) "select count(*) from repetitions where habit is null"
} ) { it.getInt(0) }
helper.migrateTo(22) assertThat(before, equalTo(1))
db.query("select count(*) from repetitions where habit is null") { c: Cursor -> migrateTo(22)
assertThat(c.getInt(0), equalTo(0)) val after = db.querySingle(
} "select count(*) from repetitions where habit is null"
) { it.getInt(0) }
assertThat(after, equalTo(0))
} }
@Test @Test
@Throws(Exception::class)
fun testDisallowNullHabit() { fun testDisallowNullHabit() {
helper.migrateTo(22) migrateTo(22)
val exception = assertThrows(Exception::class.java) {
val exception = assertThrows(java.lang.RuntimeException::class.java) { db.run("insert into Repetitions(timestamp, value) values (5, 2)")
db.execute("insert into Repetitions(timestamp, value) " + "values (5, 2)")
} }
assertThat(exception.message, Matchers.containsString("constraint"))
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT"))
} }
@Test @Test
@Throws(Exception::class)
fun testRemoveDuplicateRepetitions() { fun testRemoveDuplicateRepetitions() {
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 2)") db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 2)")
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 5)") db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 10)") db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 10)")
db.query("select count(*) from repetitions where timestamp=100 and habit=0") { c: Cursor -> val before = db.querySingle(
assertThat(c.getInt(0), equalTo(3)) "select count(*) from repetitions where timestamp=100 and habit=0"
} ) { it.getInt(0) }
helper.migrateTo(22) assertThat(before, equalTo(3))
db.query("select count(*) from repetitions where timestamp=100 and habit=0") { c: Cursor -> migrateTo(22)
assertThat(c.getInt(0), equalTo(1)) val after = db.querySingle(
} "select count(*) from repetitions where timestamp=100 and habit=0"
) { it.getInt(0) }
assertThat(after, equalTo(1))
} }
@Test @Test
@Throws(Exception::class)
fun testDisallowNewDuplicateTimestamps() { fun testDisallowNewDuplicateTimestamps() {
helper.migrateTo(22) migrateTo(22)
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 2)") db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 2)")
val exception = assertThrows(Exception::class.java) {
val exception = assertThrows(java.lang.RuntimeException::class.java) { db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
} }
assertThat(exception.message, Matchers.containsString("constraint"))
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT"))
} }
} }

View File

@ -21,57 +21,59 @@ package org.isoron.uhabits.core.database.migrations
import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.MatcherAssert.assertThat
import org.isoron.platform.io.Database
import org.isoron.platform.io.migrateTo
import org.isoron.platform.io.query
import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.BaseUnitTest
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.MigrationHelper
import org.junit.Test import org.junit.Test
class Version23Test : BaseUnitTest() { class Version23Test : BaseUnitTest() {
private lateinit var db: Database private lateinit var db: Database
private lateinit var helper: MigrationHelper
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
db = openDatabaseResource("/databases/022.db") db = openDatabaseResource("/databases/022.db")
helper = MigrationHelper(db)
} }
private fun migrateTo23() = helper.migrateTo(23) private fun migrateTo(version: Int) {
db.migrateTo(version) { v ->
val filename = "%02d.sql".format(v)
javaClass.getResourceAsStream("/migrations/$filename")!!
.bufferedReader().readText()
}
}
@Test @Test
fun `test migrate to 23 creates question column`() { fun `test migrate to 23 creates question column`() {
migrateTo23() migrateTo(23)
val cursor = db.query("select question from Habits") db.query("select question from Habits") {}
cursor.moveToNext()
} }
@Test @Test
fun `test migrate to 23 moves description to question column`() { fun `test migrate to 23 moves description to question column`() {
var cursor = db.query("select description from Habits")
val descriptions = mutableListOf<String?>() val descriptions = mutableListOf<String?>()
while (cursor.moveToNext()) { db.query("select description from Habits") { stmt ->
descriptions.add(cursor.getString(0)) descriptions.add(stmt.getTextOrNull(0))
} }
migrateTo23() migrateTo(23)
cursor = db.query("select question from Habits")
for (i in 0 until descriptions.size) { val questions = mutableListOf<String?>()
cursor.moveToNext() db.query("select question from Habits") { stmt ->
assertThat(cursor.getString(0), equalTo(descriptions[i])) questions.add(stmt.getTextOrNull(0))
}
for (i in descriptions.indices) {
assertThat(questions[i], equalTo(descriptions[i]))
} }
} }
@Test @Test
fun `test migrate to 23 sets description to null`() { fun `test migrate to 23 sets description to null`() {
migrateTo23() migrateTo(23)
val cursor = db.query("select description from Habits") db.query("select description from Habits") { stmt ->
assertThat(stmt.getTextOrNull(0), equalTo(""))
while (cursor.moveToNext()) {
assertThat(cursor.getString(0), equalTo(""))
} }
} }
} }

View File

@ -20,7 +20,7 @@
package org.isoron.uhabits.core.models.sqlite package org.isoron.uhabits.core.models.sqlite
import org.isoron.platform.time.LocalDate import org.isoron.platform.time.LocalDate
import org.isoron.uhabits.core.BaseUnitTest.Companion.buildNewMemoryDatabase import org.isoron.uhabits.core.BaseUnitTest.Companion.buildMemoryDatabase
import org.isoron.uhabits.core.database.EntryData import org.isoron.uhabits.core.database.EntryData
import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
@ -30,7 +30,7 @@ import kotlin.test.assertEquals
class SQLiteEntryListTest { class SQLiteEntryListTest {
private val database = buildNewMemoryDatabase() private val database = buildMemoryDatabase()
private val factory = SQLModelFactory(database) private val factory = SQLModelFactory(database)
private val entryRepository = factory.entryRepository private val entryRepository = factory.entryRepository
private lateinit var entries: SQLiteEntryList private lateinit var entries: SQLiteEntryList

View File

@ -46,7 +46,7 @@ class SQLiteHabitListTest : BaseUnitTest() {
@Throws(Exception::class) @Throws(Exception::class)
override fun setUp() { override fun setUp() {
super.setUp() super.setUp()
val db = buildNewMemoryDatabase() val db = buildMemoryDatabase()
modelFactory = SQLModelFactory(db) modelFactory = SQLModelFactory(db)
habitList = SQLiteHabitList(modelFactory) habitList = SQLiteHabitList(modelFactory)
fixtures = HabitFixtures(modelFactory, habitList) fixtures = HabitFixtures(modelFactory, habitList)