From 0dbebecbfb9b03f097104be7e4153d4c0792098f Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Mon, 6 Apr 2026 04:19:49 -0500 Subject: [PATCH] Remove old Database/Cursor/JdbcDatabase/MigrationHelper interface --- .../uhabits/database/AndroidDatabaseTest.kt | 91 ++++++++-- .../isoron/uhabits/HabitsDatabaseOpener.kt | 3 +- .../uhabits/database/AndroidDatabase.kt | 75 +------- .../uhabits/database/AndroidDatabaseOpener.kt | 10 +- .../inject/HabitsApplicationComponent.kt | 9 +- .../kotlin/org/isoron/platform/io/Database.kt | 31 +++- .../platform/io/DatabaseQueryHelpersTest.kt | 146 +++++++++++++++ .../isoron/uhabits/core/database/Cursor.kt | 62 ------- .../isoron/uhabits/core/database/Database.kt | 62 ------- .../uhabits/core/database/DatabaseOpener.kt | 25 --- .../uhabits/core/database/JdbcCursor.kt | 76 -------- .../uhabits/core/database/JdbcDatabase.kt | 165 ----------------- .../uhabits/core/database/MigrationHelper.kt | 51 ------ .../isoron/uhabits/core/io/LoopDBImporter.kt | 112 ++++++------ .../uhabits/core/io/RewireDBImporter.kt | 167 ++++++++---------- .../uhabits/core/io/TickmateDBImporter.kt | 85 ++++----- .../org/isoron/uhabits/core/BaseUnitTest.kt | 46 +---- .../core/database/migrations/Version22Test.kt | 154 ++++++++-------- .../core/database/migrations/Version23Test.kt | 48 ++--- .../core/models/sqlite/SQLiteEntryListTest.kt | 4 +- .../core/models/sqlite/SQLiteHabitListTest.kt | 2 +- 21 files changed, 531 insertions(+), 893 deletions(-) create mode 100644 uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt delete mode 100644 uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Cursor.kt delete mode 100644 uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Database.kt delete mode 100644 uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/DatabaseOpener.kt delete mode 100644 uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcCursor.kt delete mode 100644 uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcDatabase.kt delete mode 100644 uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/MigrationHelper.kt diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/database/AndroidDatabaseTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/database/AndroidDatabaseTest.kt index ab5be2cc..654762e4 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/database/AndroidDatabaseTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/database/AndroidDatabaseTest.kt @@ -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() + 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>() + 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() + 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()) } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt index 2555393c..f6d4a68a 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt @@ -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) diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabase.kt b/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabase.kt index 81f66765..dcc7c61d 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabase.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabase.kt @@ -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, - where: String, - vararg params: String - ): Int { - val contValues = mapToContentValues(values) - return db.update(tableName, contValues, where, params) - } - - override fun insert(tableName: String, values: Map): 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): 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) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabaseOpener.kt b/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabaseOpener.kt index 1c6aa0c6..1edcb152 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabaseOpener.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidDatabaseOpener.kt @@ -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 + ) ) } } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt index d00ce45e..ad368220 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt @@ -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 = diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt index e574245e..6e39ae71 100644 --- a/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt @@ -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 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() } diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt new file mode 100644 index 00000000..e279a958 --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseQueryHelpersTest.kt @@ -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() + 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() + 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>() + db.query("select id, name from parents order by id") { parent -> + val parentId = parent.getInt(0) + val parentName = parent.getText(1) + val kids = mutableListOf() + 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>() + 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() + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Cursor.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Cursor.kt deleted file mode 100644 index d200f038..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Cursor.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -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? -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Database.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Database.kt deleted file mode 100644 index 08316ee1..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Database.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -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, - where: String, - vararg params: String - ): Int - - fun insert(tableName: String, values: Map): 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) - } -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/DatabaseOpener.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/DatabaseOpener.kt deleted file mode 100644 index a2d6b3b7..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/DatabaseOpener.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -package org.isoron.uhabits.core.database - -import java.io.File - -interface DatabaseOpener { - fun open(file: File): Database -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcCursor.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcCursor.kt deleted file mode 100644 index 8bc7c257..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcCursor.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -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) - } - } -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcDatabase.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcDatabase.kt deleted file mode 100644 index 3d4272e4..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/JdbcDatabase.kt +++ /dev/null @@ -1,165 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -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, - where: String, - vararg params: String - ): Int { - return try { - val fields = ArrayList() - val valuesStr = ArrayList() - 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): Long? { - return try { - val fields = ArrayList() - val params = ArrayList() - val questionMarks = ArrayList() - 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): 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 -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/MigrationHelper.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/MigrationHelper.kt deleted file mode 100644 index b63bf7ce..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/MigrationHelper.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2016-2025 Álinson Santos Xavier - * - * 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 . - */ -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") - } -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt index 139b0d56..aa8a2226 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/LoopDBImporter.kt @@ -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) - ) - } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt index c04b5044..15738de7 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/RewireDBImporter.kt @@ -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() } } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt index 100c63a1..244c4a09 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/io/TickmateDBImporter.kt @@ -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) } } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt index 8a04812d..c91d7a8a 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/BaseUnitTest.kt @@ -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() } } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt index 7372e97a..ab772d96 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version22Test.kt @@ -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")) } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt index def81bfd..4e6af63b 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/migrations/Version23Test.kt @@ -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() - 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() + 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("")) } } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt index 40ae2388..3f8b4fa8 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryListTest.kt @@ -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 diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt index de3d489e..220db96e 100644 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt +++ b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitListTest.kt @@ -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)