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
import android.database.sqlite.SQLiteDatabase
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.core.IsEqual.equalTo
import org.isoron.platform.io.StepResult
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.core.database.Cursor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AndroidDatabaseTest : BaseAndroidTest() {
private lateinit var db: AndroidDatabase
override fun setUp() {
super.setUp()
db = AndroidDatabase(SQLiteDatabase.create(null), null)
db.execute("create table test(color int, name string)")
db = AndroidDatabase(SQLiteDatabase.create(null))
db.run("create table test(color int, name string)")
}
@Test
@Throws(Exception::class)
fun testInsert() {
val map = mapOf(Pair("name", "asd"), Pair("color", null))
db.insert("test", map)
val c: Cursor = db.query("select * from test")
c.moveToNext()
c.getInt(0)!!
assertThat(c.getString(1), equalTo("asd"))
fun testInsertAndQuery() {
db.run("insert into test(color, name) values (?, ?)") {
bindNull(1)
bindText(2, "asd")
}
val stmt = db.prepareStatement("select color, name from test")
assertEquals(StepResult.ROW, stmt.step())
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.uhabits.core.database.UnsupportedDatabaseVersionException
import org.isoron.uhabits.database.AndroidDatabase
import java.io.File
class HabitsDatabaseOpener(
private val context: Context,
@ -52,7 +51,7 @@ class HabitsDatabaseOpener(
) {
db.disableWriteAheadLogging()
if (db.version < 8) throw UnsupportedDatabaseVersionException()
val wrappedDb = AndroidDatabase(db, File(databaseFilename))
val wrappedDb = AndroidDatabase(db)
wrappedDb.setVersion(db.version)
wrappedDb.migrateTo(newVersion) { version ->
val filename = "%02d.sql".format(version)

View File

@ -19,12 +19,10 @@
package org.isoron.uhabits.database
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteStatement
import org.isoron.platform.io.PreparedStatement
import org.isoron.platform.io.StepResult
import java.io.File
class AndroidPreparedStatement(
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(
private val db: SQLiteDatabase,
override val file: File? = null
) : org.isoron.platform.io.Database, org.isoron.uhabits.core.database.Database {
private val db: SQLiteDatabase
) : org.isoron.platform.io.Database {
// New PreparedStatement-based interface
override fun prepareStatement(sql: String): PreparedStatement =
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 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 me.tatarka.inject.annotations.Inject
import org.isoron.uhabits.core.database.DatabaseOpener
import java.io.File
import org.isoron.platform.io.DatabaseOpener
@Inject
class AndroidDatabaseOpener() : DatabaseOpener {
override fun open(file: File): AndroidDatabase {
override fun open(path: String): AndroidDatabase {
return AndroidDatabase(
db = SQLiteDatabase.openDatabase(
file.absolutePath,
path,
null,
SQLiteDatabase.OPEN_READWRITE
),
file = file
)
)
}
}

View File

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

View File

@ -77,14 +77,41 @@ fun Database.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) {
val currentVersion = getVersion()
if (currentVersion >= targetVersion) return
begin()
for (v in (currentVersion + 1)..targetVersion) {
val commands = SQLParser.parse(loadMigrationSQL(v))
for (cmd in commands) run(cmd)
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
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.uhabits.core.AppScope
import org.isoron.uhabits.core.DATABASE_VERSION
import org.isoron.uhabits.core.commands.CommandRunner
import org.isoron.uhabits.core.commands.CreateHabitCommand
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.MigrationHelper
import org.isoron.uhabits.core.models.Entry
import org.isoron.uhabits.core.models.HabitList
import org.isoron.uhabits.core.models.ModelFactory
@ -53,26 +55,30 @@ class LoopDBImporter(
override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false
val db = opener.open(file)
val db = opener.open(file.absolutePath)
var canHandle = true
val c = db.query("select count(*) from SQLITE_MASTER where name='Habits' or name='Repetitions'")
if (!c.moveToNext() || c.getInt(0) != 2) {
val count = db.querySingle(
"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")
canHandle = false
}
if (db.version > DATABASE_VERSION) {
logger.error("Cannot handle file: incompatible version: ${db.version} > $DATABASE_VERSION")
if (db.getVersion() > DATABASE_VERSION) {
logger.error("Cannot handle file: incompatible version: ${db.getVersion()} > $DATABASE_VERSION")
canHandle = false
}
c.close()
db.close()
return canHandle
}
override fun importHabitsFromFile(file: File) {
val db = opener.open(file)
val helper = MigrationHelper(db)
helper.migrateTo(DATABASE_VERSION)
val db = opener.open(file.absolutePath)
db.migrateTo(DATABASE_VERSION) { version ->
val filename = "%02d.sql".format(version)
javaClass.getResourceAsStream("/migrations/$filename")!!
.bufferedReader().readText()
}
val habitDataList = loadHabits(db)
for (habitData in habitDataList) {
@ -89,21 +95,20 @@ class LoopDBImporter(
EditHabitCommand(habitList, habit.id!!, modified).run()
}
// Reload saved version of the habit
habit = habitList.getByUUID(habitData.uuid)!!
val entries = habit.originalEntries
// Import entries
loadEntries(db, habitData.id!!).use { c ->
while (c.moveToNext()) {
val timestamp = c.getLong(0) ?: continue
val value = c.getInt(1) ?: continue
val notes = c.getString(2) ?: ""
val date = LocalDate.fromUnixTime(timestamp)
val (_, existingValue, existingNotes) = entries.get(date)
if (existingValue != value || existingNotes != notes) {
entries.add(Entry(date, value, notes))
}
db.query(
"SELECT timestamp, value, notes FROM Repetitions WHERE habit = ? ORDER BY timestamp DESC",
habitData.id.toString()
) { stmt ->
val timestamp = stmt.getLongOrNull(0) ?: return@query
val value = stmt.getIntOrNull(1) ?: return@query
val notes = stmt.getTextOrNull(2) ?: ""
val date = LocalDate.fromUnixTime(timestamp)
val (_, existingValue, existingNotes) = entries.get(date)
if (existingValue != value || existingNotes != notes) {
entries.add(Entry(date, value, notes))
}
}
habit.recompute()
@ -119,41 +124,30 @@ class LoopDBImporter(
"position, reminder_hour, reminder_min, reminder_days, highlight, " +
"archived, type, target_value, target_type, unit, uuid " +
"FROM Habits ORDER BY position"
).use { c ->
while (c.moveToNext()) {
result.add(cursorToHabitData(c))
}
) { stmt ->
result.add(
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
}
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
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.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.Frequency
import org.isoron.uhabits.core.models.Habit
@ -45,124 +48,98 @@ class RewireDBImporter(
override fun canHandle(file: File): Boolean {
if (!file.isSQLite3File()) return false
val db = opener.open(file)
val c = db.query(
"select count(*) from SQLITE_MASTER " +
"where name='CHECKINS' or name='UNIT'"
)
val result = c.moveToNext() && c.getInt(0) == 2
c.close()
val db = opener.open(file.absolutePath)
val count = db.querySingle(
"select count(*) from SQLITE_MASTER where name='CHECKINS' or name='UNIT'"
) { it.getInt(0) }
db.close()
return result
return count == 2
}
override fun importHabitsFromFile(file: File) {
val db = opener.open(file)
db.beginTransaction()
val db = opener.open(file.absolutePath)
db.begin()
createHabits(db)
db.setTransactionSuccessful()
db.endTransaction()
db.commit()
db.close()
}
private fun createHabits(db: Database) {
var c: Cursor? = null
try {
c = db.query(
"select _id, name, description, schedule, " +
"active_days, repeating_count, days, period " +
"from habits"
)
if (!c.moveToNext()) return
do {
val id = c.getInt(0)!!
val name = c.getString(1)
val description = c.getString(2)
val schedule = c.getInt(3)!!
val activeDays = c.getString(4)
val repeatingCount = c.getInt(5)!!
val days = c.getInt(6)!!
val periodIndex = c.getInt(7)!!
db.query(
"select _id, name, description, schedule, " +
"active_days, repeating_count, days, period " +
"from habits"
) { stmt ->
val id = stmt.getInt(0)
val name = stmt.getText(1)
val description = stmt.getTextOrNull(2) ?: ""
val schedule = stmt.getInt(3)
val activeDays = stmt.getTextOrNull(4)
val repeatingCount = stmt.getInt(5)
val days = stmt.getInt(6)
val periodIndex = stmt.getInt(7)
val habit = modelFactory.buildHabit()
habit.name = name!!
habit.description = description ?: ""
val periods = intArrayOf(7, 31, 365)
var numerator: Int
var denominator: Int
when (schedule) {
0 -> {
numerator = activeDays!!.split(",").toTypedArray().size
denominator = 7
}
1 -> {
numerator = days
denominator = periods[periodIndex]
}
2 -> {
numerator = 1
denominator = repeatingCount
}
else -> throw IllegalStateException()
val habit = modelFactory.buildHabit()
habit.name = name
habit.description = description
val periods = intArrayOf(7, 31, 365)
var numerator: Int
var denominator: Int
when (schedule) {
0 -> {
numerator = activeDays!!.split(",").toTypedArray().size
denominator = 7
}
habit.frequency = Frequency(numerator, denominator)
habitList.add(habit)
createReminder(db, habit, id)
createCheckmarks(db, habit, id)
} while (c.moveToNext())
} finally {
c?.close()
1 -> {
numerator = days
denominator = periods[periodIndex]
}
2 -> {
numerator = 1
denominator = repeatingCount
}
else -> throw IllegalStateException()
}
habit.frequency = Frequency(numerator, denominator)
habitList.add(habit)
createReminder(db, habit, id)
createCheckmarks(db, habit, id)
}
}
private fun createCheckmarks(
db: Database,
habit: Habit,
rewireHabitId: Int
) {
var c: Cursor? = null
try {
c = db.query(
"select distinct date from checkins where habit_id=? and type=2",
rewireHabitId.toString()
)
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 createCheckmarks(db: Database, habit: Habit, rewireHabitId: Int) {
db.query(
"select distinct date from checkins where habit_id=? and type=2",
rewireHabitId.toString()
) { stmt ->
val dateStr = stmt.getText(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))
}
}
private fun createReminder(db: Database, habit: Habit, rewireHabitId: Int) {
var c: Cursor? = null
try {
c = db.query(
"select time, active_days from reminders where habit_id=? limit 1",
rewireHabitId.toString()
)
if (!c.moveToNext()) return
val rewireReminder = c.getInt(0)!!
if (rewireReminder <= 0 || rewireReminder >= 1440) return
val reminder = db.querySingle(
"select time, active_days from reminders where habit_id=? limit 1",
rewireHabitId.toString()
) { stmt ->
val rewireReminder = stmt.getInt(0)
if (rewireReminder <= 0 || rewireReminder >= 1440) return@querySingle null
val reminderDays = BooleanArray(7)
val activeDays = c.getString(1)!!.split(",").toTypedArray()
for (d in activeDays) {
val activeDaysStr = stmt.getText(1).split(",").toTypedArray()
for (d in activeDaysStr) {
val idx = (d.toInt() + 1) % 7
reminderDays[idx] = true
}
val hour = rewireReminder / 60
val minute = rewireReminder % 60
val days = WeekdayList(reminderDays)
val reminder = Reminder(hour, minute, days)
Reminder(hour, minute, WeekdayList(reminderDays))
}
if (reminder != null) {
habit.reminder = reminder
habitList.update(habit)
} finally {
c?.close()
}
}
}

View File

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

View File

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

View File

@ -21,132 +21,132 @@ package org.isoron.uhabits.core.database.migrations
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers
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.database.Cursor
import org.isoron.uhabits.core.database.Database
import org.isoron.uhabits.core.database.MigrationHelper
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertThrows
class Version22Test : BaseUnitTest() {
private lateinit var db: Database
private lateinit var helper: MigrationHelper
@Throws(Exception::class)
override fun setUp() {
super.setUp()
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
@Throws(Exception::class)
fun testKeepValidReps() {
db.query("select count(*) from repetitions") { c: Cursor ->
assertThat(c.getInt(0), equalTo(3))
}
helper.migrateTo(22)
db.query("select count(*) from repetitions") { c: Cursor ->
assertThat(c.getInt(0), equalTo(3))
}
val before = db.querySingle("select count(*) from repetitions") { it.getInt(0) }
assertThat(before, equalTo(3))
migrateTo(22)
val after = db.querySingle("select count(*) from repetitions") { it.getInt(0) }
assertThat(after, equalTo(3))
}
@Test
@Throws(Exception::class)
fun testRemoveRepsWithInvalidId() {
db.execute("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)")
db.query("select count(*) from repetitions where habit = 99999") { c: Cursor ->
assertThat(c.getInt(0), equalTo(1))
}
helper.migrateTo(22)
db.query("select count(*) from repetitions where habit = 99999") { c: Cursor ->
assertThat(c.getInt(0), equalTo(0))
}
db.run("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)")
val before = db.querySingle(
"select count(*) from repetitions where habit = 99999"
) { it.getInt(0) }
assertThat(before, equalTo(1))
migrateTo(22)
val after = db.querySingle(
"select count(*) from repetitions where habit = 99999"
) { it.getInt(0) }
assertThat(after, equalTo(0))
}
@Test
@Throws(Exception::class)
fun testDisallowNewRepsWithInvalidRef() {
helper.migrateTo(22)
val exception = assertThrows(java.lang.RuntimeException::class.java) { db.execute("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)") }
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT"))
migrateTo(22)
val exception = assertThrows(Exception::class.java) {
db.run("insert into Repetitions(habit, timestamp, value) values (99999, 100, 2)")
}
assertThat(exception.message, Matchers.containsString("constraint"))
}
@Test
@Throws(Exception::class)
fun testRemoveRepetitionsWithNullTimestamp() {
db.execute("insert into repetitions(habit, value) values (0, 2)")
db.query("select count(*) from repetitions where timestamp is null") { c: Cursor ->
assertThat(c.getInt(0), equalTo(1))
}
helper.migrateTo(22)
db.query("select count(*) from repetitions where timestamp is null") { c: Cursor ->
assertThat(c.getInt(0), equalTo(0))
}
db.run("insert into repetitions(habit, value) values (0, 2)")
val before = db.querySingle(
"select count(*) from repetitions where timestamp is null"
) { it.getInt(0) }
assertThat(before, equalTo(1))
migrateTo(22)
val after = db.querySingle(
"select count(*) from repetitions where timestamp is null"
) { it.getInt(0) }
assertThat(after, equalTo(0))
}
@Test
@Throws(Exception::class)
fun testDisallowNullTimestamp() {
helper.migrateTo(22)
val exception = assertThrows(java.lang.RuntimeException::class.java) {
db.execute("insert into Repetitions(habit, value) " + "values (0, 2)")
migrateTo(22)
val exception = assertThrows(Exception::class.java) {
db.run("insert into Repetitions(habit, value) values (0, 2)")
}
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT"))
assertThat(exception.message, Matchers.containsString("constraint"))
}
@Test
@Throws(Exception::class)
fun testRemoveRepetitionsWithNullHabit() {
db.execute("insert into repetitions(timestamp, value) values (0, 2)")
db.query("select count(*) from repetitions where habit is null") { c: Cursor ->
assertThat(c.getInt(0), equalTo(1))
}
helper.migrateTo(22)
db.query("select count(*) from repetitions where habit is null") { c: Cursor ->
assertThat(c.getInt(0), equalTo(0))
}
db.run("insert into repetitions(timestamp, value) values (0, 2)")
val before = db.querySingle(
"select count(*) from repetitions where habit is null"
) { it.getInt(0) }
assertThat(before, equalTo(1))
migrateTo(22)
val after = db.querySingle(
"select count(*) from repetitions where habit is null"
) { it.getInt(0) }
assertThat(after, equalTo(0))
}
@Test
@Throws(Exception::class)
fun testDisallowNullHabit() {
helper.migrateTo(22)
val exception = assertThrows(java.lang.RuntimeException::class.java) {
db.execute("insert into Repetitions(timestamp, value) " + "values (5, 2)")
migrateTo(22)
val exception = assertThrows(Exception::class.java) {
db.run("insert into Repetitions(timestamp, value) values (5, 2)")
}
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT"))
assertThat(exception.message, Matchers.containsString("constraint"))
}
@Test
@Throws(Exception::class)
fun testRemoveDuplicateRepetitions() {
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 2)")
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 10)")
db.query("select count(*) from repetitions where timestamp=100 and habit=0") { c: Cursor ->
assertThat(c.getInt(0), equalTo(3))
}
helper.migrateTo(22)
db.query("select count(*) from repetitions where timestamp=100 and habit=0") { c: Cursor ->
assertThat(c.getInt(0), equalTo(1))
}
db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 2)")
db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 10)")
val before = db.querySingle(
"select count(*) from repetitions where timestamp=100 and habit=0"
) { it.getInt(0) }
assertThat(before, equalTo(3))
migrateTo(22)
val after = db.querySingle(
"select count(*) from repetitions where timestamp=100 and habit=0"
) { it.getInt(0) }
assertThat(after, equalTo(1))
}
@Test
@Throws(Exception::class)
fun testDisallowNewDuplicateTimestamps() {
helper.migrateTo(22)
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 2)")
val exception = assertThrows(java.lang.RuntimeException::class.java) {
db.execute("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
migrateTo(22)
db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 2)")
val exception = assertThrows(Exception::class.java) {
db.run("insert into repetitions(habit, timestamp, value)values (0, 100, 5)")
}
assertThat(exception.message, Matchers.containsString("SQLITE_CONSTRAINT"))
assertThat(exception.message, Matchers.containsString("constraint"))
}
}

View File

@ -21,57 +21,59 @@ package org.isoron.uhabits.core.database.migrations
import org.hamcrest.CoreMatchers.equalTo
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.database.Database
import org.isoron.uhabits.core.database.MigrationHelper
import org.junit.Test
class Version23Test : BaseUnitTest() {
private lateinit var db: Database
private lateinit var helper: MigrationHelper
override fun setUp() {
super.setUp()
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
fun `test migrate to 23 creates question column`() {
migrateTo23()
val cursor = db.query("select question from Habits")
cursor.moveToNext()
migrateTo(23)
db.query("select question from Habits") {}
}
@Test
fun `test migrate to 23 moves description to question column`() {
var cursor = db.query("select description from Habits")
val descriptions = mutableListOf<String?>()
while (cursor.moveToNext()) {
descriptions.add(cursor.getString(0))
db.query("select description from Habits") { stmt ->
descriptions.add(stmt.getTextOrNull(0))
}
migrateTo23()
cursor = db.query("select question from Habits")
migrateTo(23)
for (i in 0 until descriptions.size) {
cursor.moveToNext()
assertThat(cursor.getString(0), equalTo(descriptions[i]))
val questions = mutableListOf<String?>()
db.query("select question from Habits") { stmt ->
questions.add(stmt.getTextOrNull(0))
}
for (i in descriptions.indices) {
assertThat(questions[i], equalTo(descriptions[i]))
}
}
@Test
fun `test migrate to 23 sets description to null`() {
migrateTo23()
val cursor = db.query("select description from Habits")
while (cursor.moveToNext()) {
assertThat(cursor.getString(0), equalTo(""))
migrateTo(23)
db.query("select description from Habits") { stmt ->
assertThat(stmt.getTextOrNull(0), equalTo(""))
}
}
}

View File

@ -20,7 +20,7 @@
package org.isoron.uhabits.core.models.sqlite
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.models.Entry
import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN
@ -30,7 +30,7 @@ import kotlin.test.assertEquals
class SQLiteEntryListTest {
private val database = buildNewMemoryDatabase()
private val database = buildMemoryDatabase()
private val factory = SQLModelFactory(database)
private val entryRepository = factory.entryRepository
private lateinit var entries: SQLiteEntryList

View File

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