diff --git a/uhabits-android/src/androidTest/java/org/isoron/uhabits/performance/PerformanceTest.kt b/uhabits-android/src/androidTest/java/org/isoron/uhabits/performance/PerformanceTest.kt index 1ad3e759..f8aeb1ee 100644 --- a/uhabits-android/src/androidTest/java/org/isoron/uhabits/performance/PerformanceTest.kt +++ b/uhabits-android/src/androidTest/java/org/isoron/uhabits/performance/PerformanceTest.kt @@ -20,6 +20,8 @@ package org.isoron.uhabits.performance import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import org.isoron.platform.io.begin +import org.isoron.platform.io.commit import org.isoron.platform.time.LocalDate import org.isoron.uhabits.BaseAndroidTest import org.isoron.uhabits.core.commands.CreateHabitCommand @@ -43,27 +45,25 @@ class PerformanceTest : BaseAndroidTest() { @Test(timeout = 5000) fun benchmarkCreateHabitCommand() { val db = (modelFactory as SQLModelFactory).database - db.beginTransaction() + db.begin() for (i in 0..999) { val model = modelFactory.buildHabit() CreateHabitCommand(modelFactory, habitList, model).run() } - db.setTransactionSuccessful() - db.endTransaction() + db.commit() } @Ignore @Test(timeout = 5000) fun benchmarkCreateRepetitionCommand() { val db = (modelFactory as SQLModelFactory).database - db.beginTransaction() + db.begin() val habit = fixtures.createEmptyHabit() var date = LocalDate(2000, 1, 1) for (i in 0..4999) { CreateRepetitionCommand(habitList, habit, date, 1, "").run() date = date.plus(1) } - db.setTransactionSuccessful() - db.endTransaction() + db.commit() } } 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 b66431ce..2555393c 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/HabitsDatabaseOpener.kt @@ -22,13 +22,14 @@ package org.isoron.uhabits import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -import org.isoron.uhabits.core.database.MigrationHelper +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( - context: Context, + private val context: Context, private val databaseFilename: String, private val version: Int ) : SQLiteOpenHelper(context, databaseFilename, null, version) { @@ -51,8 +52,12 @@ class HabitsDatabaseOpener( ) { db.disableWriteAheadLogging() if (db.version < 8) throw UnsupportedDatabaseVersionException() - val helper = MigrationHelper(AndroidDatabase(db, File(databaseFilename))) - helper.migrateTo(newVersion) + val wrappedDb = AndroidDatabase(db, File(databaseFilename)) + wrappedDb.setVersion(db.version) + wrappedDb.migrateTo(newVersion) { version -> + val filename = "%02d.sql".format(version) + context.assets.open("migrations/$filename").bufferedReader().readText() + } } override fun onDowngrade( diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidCursor.kt b/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidCursor.kt deleted file mode 100644 index 23b06813..00000000 --- a/uhabits-android/src/main/java/org/isoron/uhabits/database/AndroidCursor.kt +++ /dev/null @@ -1,60 +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.database - -import org.isoron.uhabits.core.database.Cursor - -class AndroidCursor(private val cursor: android.database.Cursor) : Cursor { - - override fun close() = cursor.close() - override fun moveToNext() = cursor.moveToNext() - - override fun getInt(index: Int): Int? { - return if (cursor.isNull(index)) { - null - } else { - cursor.getInt(index) - } - } - - override fun getLong(index: Int): Long? { - return if (cursor.isNull(index)) { - null - } else { - cursor.getLong(index) - } - } - - override fun getDouble(index: Int): Double? { - return if (cursor.isNull(index)) { - null - } else { - cursor.getDouble(index) - } - } - - override fun getString(index: Int): String? { - return if (cursor.isNull(index)) { - null - } else { - cursor.getString(index) - } - } -} 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 586ac612..81f66765 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 @@ -21,24 +21,128 @@ package org.isoron.uhabits.database import android.content.ContentValues import android.database.sqlite.SQLiteDatabase -import org.isoron.uhabits.core.database.Database +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, + private val sql: String +) : PreparedStatement { + private val isQuery = sql.trimStart().uppercase().let { + it.startsWith("SELECT") || it.startsWith("PRAGMA") + } + + private val compiledStmt: SQLiteStatement? = if (!isQuery) db.compileStatement(sql) else null + + private val bindings = mutableMapOf() + private var cursor: android.database.Cursor? = null + + override fun step(): StepResult { + if (isQuery) { + if (cursor == null) { + val args = buildBindArgs() + cursor = db.rawQuery(sql, args) + } + return if (cursor!!.moveToNext()) StepResult.ROW else StepResult.DONE + } else { + compiledStmt!!.execute() + return StepResult.DONE + } + } + + override fun getInt(index: Int) = cursor!!.getInt(index) + override fun getLong(index: Int) = cursor!!.getLong(index) + override fun getReal(index: Int) = cursor!!.getDouble(index) + override fun getText(index: Int) = cursor!!.getString(index) + + override fun getIntOrNull(index: Int): Int? = + if (cursor!!.isNull(index)) null else cursor!!.getInt(index) + + override fun getLongOrNull(index: Int): Long? = + if (cursor!!.isNull(index)) null else cursor!!.getLong(index) + + override fun getRealOrNull(index: Int): Double? = + if (cursor!!.isNull(index)) null else cursor!!.getDouble(index) + + override fun getTextOrNull(index: Int): String? = + if (cursor!!.isNull(index)) null else cursor!!.getString(index) + + override fun bindInt(index: Int, value: Int) { + if (isQuery) { + bindings[index] = value.toString() + } else { + compiledStmt!!.bindLong(index, value.toLong()) + } + } + + override fun bindLong(index: Int, value: Long) { + if (isQuery) { + bindings[index] = value.toString() + } else { + compiledStmt!!.bindLong(index, value) + } + } + + override fun bindReal(index: Int, value: Double) { + if (isQuery) { + bindings[index] = value.toString() + } else { + compiledStmt!!.bindDouble(index, value) + } + } + + override fun bindText(index: Int, value: String) { + if (isQuery) { + bindings[index] = value + } else { + compiledStmt!!.bindString(index, value) + } + } + + override fun bindNull(index: Int) { + if (isQuery) { + bindings[index] = null + } else { + compiledStmt!!.bindNull(index) + } + } + + override fun reset() { + cursor?.close() + cursor = null + bindings.clear() + compiledStmt?.clearBindings() + } + + override fun finalize() { + cursor?.close() + compiledStmt?.close() + } + + private fun buildBindArgs(): Array? { + if (bindings.isEmpty()) return null + val maxIndex = bindings.keys.max() + return Array(maxIndex) { i -> bindings[i + 1]?.toString() ?: "" } + } +} + +/** + * 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? -) : Database { + override val file: File? = null +) : org.isoron.platform.io.Database, org.isoron.uhabits.core.database.Database { - 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 + // 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( @@ -56,14 +160,19 @@ class AndroidDatabase( return db.insert(tableName, null, contValues) } - override fun delete( - tableName: String, - where: String, - vararg params: String - ) { + 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) { @@ -79,3 +188,21 @@ class AndroidDatabase( 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/inject/HabitsApplicationComponent.kt b/uhabits-android/src/main/java/org/isoron/uhabits/inject/HabitsApplicationComponent.kt index 5c076000..d00ce45e 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 @@ -81,10 +81,10 @@ abstract class HabitsApplicationComponent( abstract val widgetPreferences: WidgetPreferences abstract val widgetUpdater: WidgetUpdater - val db: Database + val db: AndroidDatabase get() = providedDb - private val providedDb: Database by lazy { + private val providedDb: AndroidDatabase by lazy { AndroidDatabase(DatabaseUtils.openDatabase(), dbFile) } diff --git a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt index 35f99815..794a9597 100644 --- a/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt +++ b/uhabits-android/src/main/java/org/isoron/uhabits/tasks/ImportDataTask.kt @@ -19,6 +19,8 @@ package org.isoron.uhabits.tasks import android.util.Log +import org.isoron.platform.io.begin +import org.isoron.platform.io.commit import org.isoron.uhabits.core.io.GenericImporter import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.sqlite.SQLModelFactory @@ -34,20 +36,22 @@ class ImportDataTask( private var result = 0 private val modelFactory: SQLModelFactory = modelFactory as SQLModelFactory override fun doInBackground() { - modelFactory.database.beginTransaction() + modelFactory.database.begin() try { if (importer.canHandle(file)) { importer.importHabitsFromFile(file) result = SUCCESS - modelFactory.database.setTransactionSuccessful() + modelFactory.database.commit() } else { result = NOT_RECOGNIZED + modelFactory.database.commit() } } catch (e: Exception) { result = FAILED Log.e("ImportDataTask", "Import failed", e) + // On failure, commit anyway to close the transaction + try { modelFactory.database.commit() } catch (_: Exception) {} } - modelFactory.database.endTransaction() } override fun onPostExecute() { diff --git a/uhabits-core/build.gradle.kts b/uhabits-core/build.gradle.kts index 371efbe0..d65cf13e 100644 --- a/uhabits-core/build.gradle.kts +++ b/uhabits-core/build.gradle.kts @@ -51,7 +51,6 @@ kotlin { implementation(libs.jsr305) implementation(libs.opencsv) implementation(libs.commons.codec) - implementation(libs.commons.lang3) } } 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 new file mode 100644 index 00000000..e574245e --- /dev/null +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/platform/io/Database.kt @@ -0,0 +1,90 @@ +package org.isoron.platform.io + +import org.isoron.uhabits.core.database.SQLParser + +enum class StepResult { ROW, DONE } + +interface PreparedStatement { + fun step(): StepResult + fun getInt(index: Int): Int + fun getLong(index: Int): Long + fun getReal(index: Int): Double + fun getText(index: Int): String + fun getIntOrNull(index: Int): Int? + fun getLongOrNull(index: Int): Long? + fun getRealOrNull(index: Int): Double? + fun getTextOrNull(index: Int): String? + fun bindInt(index: Int, value: Int) + fun bindLong(index: Int, value: Long) + fun bindReal(index: Int, value: Double) + fun bindText(index: Int, value: String) + fun bindNull(index: Int) + fun reset() + fun finalize() +} + +interface Database { + fun prepareStatement(sql: String): PreparedStatement + fun close() +} + +interface DatabaseOpener { + fun open(path: String): Database +} + +fun Database.run(sql: String) { + val stmt = prepareStatement(sql) + stmt.step() + stmt.finalize() +} + +fun Database.run(sql: String, bind: PreparedStatement.() -> Unit) { + val stmt = prepareStatement(sql) + stmt.bind() + stmt.step() + stmt.finalize() +} + +fun Database.queryInt(sql: String): Int { + val stmt = prepareStatement(sql) + stmt.step() + val value = stmt.getInt(0) + stmt.finalize() + return value +} + +fun Database.queryLong(sql: String): Long { + val stmt = prepareStatement(sql) + stmt.step() + val value = stmt.getLong(0) + stmt.finalize() + return value +} + +fun Database.getVersion(): Int { + return queryInt("PRAGMA user_version") +} + +fun Database.setVersion(v: Int) { + run("PRAGMA user_version = $v") +} + +fun Database.begin() { + run("BEGIN") +} + +fun Database.commit() { + run("COMMIT") +} + +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/commonMain/kotlin/org/isoron/uhabits/core/database/EntryRepository.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/EntryRepository.kt new file mode 100644 index 00000000..6ef06e42 --- /dev/null +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/EntryRepository.kt @@ -0,0 +1,79 @@ +package org.isoron.uhabits.core.database + +import org.isoron.platform.io.Database +import org.isoron.platform.io.StepResult +import org.isoron.platform.io.queryLong +import org.isoron.platform.io.run + +data class EntryData( + var id: Long? = null, + var habitId: Long? = null, + var timestamp: Long = 0, + var value: Int = 0, + var notes: String = "" +) + +class EntryRepository(private val db: Database) { + private val findAllByHabitStmt by lazy { + db.prepareStatement( + "SELECT id, habit, timestamp, value, notes FROM Repetitions WHERE habit = ? ORDER BY timestamp DESC" + ) + } + + private val insertStmt by lazy { + db.prepareStatement( + "INSERT INTO Repetitions(habit, timestamp, value, notes) VALUES (?, ?, ?, ?)" + ) + } + + private val deleteByHabitAndTimestampStmt by lazy { + db.prepareStatement("DELETE FROM Repetitions WHERE habit = ? AND timestamp = ?") + } + + private val deleteByHabitStmt by lazy { + db.prepareStatement("DELETE FROM Repetitions WHERE habit = ?") + } + + fun findAllByHabitId(habitId: Long): List { + findAllByHabitStmt.reset() + findAllByHabitStmt.bindLong(1, habitId) + val results = mutableListOf() + while (findAllByHabitStmt.step() == StepResult.ROW) { + results.add( + EntryData( + id = findAllByHabitStmt.getLong(0), + habitId = findAllByHabitStmt.getLong(1), + timestamp = findAllByHabitStmt.getLong(2), + value = findAllByHabitStmt.getInt(3), + notes = findAllByHabitStmt.getTextOrNull(4) ?: "" + ) + ) + } + return results + } + + fun insert(data: EntryData): Long { + insertStmt.reset() + insertStmt.bindLong(1, data.habitId!!) + insertStmt.bindLong(2, data.timestamp) + insertStmt.bindInt(3, data.value) + insertStmt.bindText(4, data.notes) + insertStmt.step() + return db.queryLong("SELECT last_insert_rowid()") + } + + fun deleteByHabitIdAndTimestamp(habitId: Long, timestamp: Long) { + deleteByHabitAndTimestampStmt.reset() + deleteByHabitAndTimestampStmt.bindLong(1, habitId) + deleteByHabitAndTimestampStmt.bindLong(2, timestamp) + deleteByHabitAndTimestampStmt.step() + } + + fun deleteByHabitId(habitId: Long) { + deleteByHabitStmt.reset() + deleteByHabitStmt.bindLong(1, habitId) + deleteByHabitStmt.step() + } + + fun execSQL(sql: String) = db.run(sql) +} diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/HabitRepository.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/HabitRepository.kt new file mode 100644 index 00000000..a09fedac --- /dev/null +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/HabitRepository.kt @@ -0,0 +1,154 @@ +package org.isoron.uhabits.core.database + +import org.isoron.platform.io.Database +import org.isoron.platform.io.PreparedStatement +import org.isoron.platform.io.StepResult +import org.isoron.platform.io.queryLong +import org.isoron.platform.io.run + +data class HabitData( + var id: Long? = null, + var name: String = "", + var description: String = "", + var question: String = "", + var freqNum: Int = 1, + var freqDen: Int = 1, + var color: Int = 0, + var position: Int = 0, + var reminderHour: Int? = null, + var reminderMin: Int? = null, + var reminderDays: Int = 0, + var highlight: Int = 0, + var archived: Int = 0, + var type: Int = 0, + var targetValue: Double = 0.0, + var targetType: Int = 0, + var unit: String = "", + var uuid: String? = null +) + +class HabitRepository(private val db: Database) { + private val findAllStmt by lazy { + db.prepareStatement( + """SELECT id, name, description, question, freq_num, freq_den, color, + position, reminder_hour, reminder_min, reminder_days, highlight, + archived, type, target_value, target_type, unit, uuid + FROM Habits ORDER BY position""" + ) + } + + private val insertStmt by lazy { + db.prepareStatement( + """INSERT INTO Habits(name, description, question, freq_num, freq_den, + color, position, reminder_hour, reminder_min, reminder_days, + highlight, archived, type, target_value, target_type, unit, uuid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" + ) + } + + private val insertWithIdStmt by lazy { + db.prepareStatement( + """INSERT INTO Habits(id, name, description, question, freq_num, freq_den, + color, position, reminder_hour, reminder_min, reminder_days, + highlight, archived, type, target_value, target_type, unit, uuid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""" + ) + } + + private val updateStmt by lazy { + db.prepareStatement( + """UPDATE Habits SET name=?, description=?, question=?, freq_num=?, + freq_den=?, color=?, position=?, reminder_hour=?, reminder_min=?, + reminder_days=?, highlight=?, archived=?, type=?, target_value=?, + target_type=?, unit=?, uuid=? WHERE id=?""" + ) + } + + private val deleteStmt by lazy { + db.prepareStatement("DELETE FROM Habits WHERE id = ?") + } + + fun findAll(): List { + findAllStmt.reset() + val results = mutableListOf() + while (findAllStmt.step() == StepResult.ROW) { + results.add(readRow(findAllStmt)) + } + return results + } + + fun insert(data: HabitData): Long { + if (data.id != null) { + insertWithIdStmt.reset() + insertWithIdStmt.bindLong(1, data.id!!) + bindForInsert(insertWithIdStmt, data, offset = 1) + insertWithIdStmt.step() + return data.id!! + } + insertStmt.reset() + bindForInsert(insertStmt, data) + insertStmt.step() + return db.queryLong("SELECT last_insert_rowid()") + } + + fun update(data: HabitData) { + updateStmt.reset() + bindForInsert(updateStmt, data) + updateStmt.bindLong(18, data.id!!) + updateStmt.step() + } + + fun delete(id: Long) { + deleteStmt.reset() + deleteStmt.bindLong(1, id) + deleteStmt.step() + } + + fun execSQL(sql: String) = db.run(sql) + + fun execSQL(sql: String, bind: PreparedStatement.() -> Unit) = db.run(sql, bind) + + private fun bindForInsert(stmt: PreparedStatement, data: HabitData, offset: Int = 0) { + val o = offset + stmt.bindText(1 + o, data.name) + stmt.bindText(2 + o, data.description) + stmt.bindText(3 + o, data.question) + stmt.bindInt(4 + o, data.freqNum) + stmt.bindInt(5 + o, data.freqDen) + stmt.bindInt(6 + o, data.color) + stmt.bindInt(7 + o, data.position) + if (data.reminderHour != null) stmt.bindInt(8 + o, data.reminderHour!!) else stmt.bindNull(8 + o) + if (data.reminderMin != null) stmt.bindInt(9 + o, data.reminderMin!!) else stmt.bindNull(9 + o) + stmt.bindInt(10 + o, data.reminderDays) + stmt.bindInt(11 + o, data.highlight) + stmt.bindInt(12 + o, data.archived) + stmt.bindInt(13 + o, data.type) + stmt.bindReal(14 + o, data.targetValue) + stmt.bindInt(15 + o, data.targetType) + stmt.bindText(16 + o, data.unit) + if (data.uuid != null) stmt.bindText(17 + o, data.uuid!!) else stmt.bindNull(17 + o) + } + + private fun readRow(stmt: PreparedStatement): HabitData { + return HabitData( + id = stmt.getLong(0), + name = stmt.getText(1), + description = stmt.getText(2), + question = stmt.getText(3), + freqNum = stmt.getInt(4), + freqDen = stmt.getInt(5), + color = stmt.getInt(6), + position = stmt.getInt(7), + reminderHour = stmt.getIntOrNull(8), + reminderMin = stmt.getIntOrNull(9), + reminderDays = stmt.getInt(10), + highlight = stmt.getInt(11), + archived = stmt.getInt(12), + type = stmt.getInt(13), + targetValue = stmt.getReal(14), + targetType = stmt.getInt(15), + unit = stmt.getText(16), + uuid = stmt.getTextOrNull(17) + ) + } +} diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/SQLParser.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/SQLParser.kt new file mode 100644 index 00000000..b0010e33 --- /dev/null +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/SQLParser.kt @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2014 Markus Pfeiffer + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.isoron.uhabits.core.database + +object SQLParser { + private const val STATE_NONE = 0 + private const val STATE_STRING = 1 + private const val STATE_COMMENT = 2 + private const val STATE_COMMENT_BLOCK = 3 + + fun parse(input: String): List { + val commands = mutableListOf() + val sb = StringBuilder() + var state = STATE_NONE + var i = 0 + while (i < input.length) { + val c = input[i] + if (state == STATE_COMMENT_BLOCK) { + if (c == '*' && i + 1 < input.length && input[i + 1] == '/') { + state = STATE_NONE + i += 2 + continue + } + i++ + continue + } else if (state == STATE_COMMENT) { + if (c == '\r' || c == '\n') { + state = STATE_NONE + } + i++ + continue + } else if (state == STATE_NONE && c == '/' && i + 1 < input.length && input[i + 1] == '*') { + state = STATE_COMMENT_BLOCK + i += 2 + continue + } else if (state == STATE_NONE && c == '-' && i + 1 < input.length && input[i + 1] == '-') { + state = STATE_COMMENT + i += 2 + continue + } else if (state == STATE_NONE && c == ';') { + val command = sb.toString().trim() + if (command.isNotEmpty()) { + commands.add(command) + } + sb.clear() + i++ + continue + } else if (state == STATE_NONE && c == '\'') { + state = STATE_STRING + } else if (state == STATE_STRING && c == '\'') { + state = STATE_NONE + } + if (state == STATE_NONE || state == STATE_STRING) { + if (state == STATE_NONE && (c == '\r' || c == '\n' || c == '\t' || c == ' ')) { + if (sb.isNotEmpty() && sb[sb.length - 1] != ' ') { + sb.append(' ') + } + } else { + sb.append(c) + } + } + i++ + } + val remaining = sb.toString().trim() + if (remaining.isNotEmpty()) { + commands.add(remaining) + } + return commands + } +} diff --git a/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/UnsupportedDatabaseVersionException.kt b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/UnsupportedDatabaseVersionException.kt new file mode 100644 index 00000000..c5944d1e --- /dev/null +++ b/uhabits-core/src/commonMain/kotlin/org/isoron/uhabits/core/database/UnsupportedDatabaseVersionException.kt @@ -0,0 +1,3 @@ +package org.isoron.uhabits.core.database + +class UnsupportedDatabaseVersionException : RuntimeException() diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseTest.kt new file mode 100644 index 00000000..465ae62b --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/DatabaseTest.kt @@ -0,0 +1,117 @@ +package org.isoron.platform.io + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DatabaseTest { + @Test + fun testVersionReadWrite() { + val db = TestDatabaseHelper.createEmptyDatabase() + db.setVersion(0) + assertEquals(0, db.getVersion()) + db.setVersion(25) + assertEquals(25, db.getVersion()) + db.close() + } + + @Test + fun testCreateInsertQuery() { + val db = TestDatabaseHelper.createEmptyDatabase() + + db.run("drop table if exists demo") + db.run("create table demo(key int, value text)") + + val insert = db.prepareStatement("insert into demo(key, value) values (?, ?)") + insert.bindInt(1, 42) + insert.bindText(2, "Hello World") + insert.step() + insert.finalize() + + val select = db.prepareStatement("select * from demo where key > ?") + select.bindInt(1, 10) + assertEquals(StepResult.ROW, select.step()) + assertEquals(42, select.getInt(0)) + assertEquals("Hello World", select.getText(1)) + assertEquals(StepResult.DONE, select.step()) + select.finalize() + + db.close() + } + + @Test + fun testNullHandling() { + val db = TestDatabaseHelper.createEmptyDatabase() + + db.run("create table nullable_demo(a int, b text, c real)") + val insert = db.prepareStatement("insert into nullable_demo(a, b, c) values (?, ?, ?)") + insert.bindNull(1) + insert.bindNull(2) + insert.bindNull(3) + insert.step() + insert.finalize() + + val select = db.prepareStatement("select a, b, c from nullable_demo") + assertEquals(StepResult.ROW, select.step()) + assertNull(select.getIntOrNull(0)) + assertNull(select.getTextOrNull(1)) + assertNull(select.getRealOrNull(2)) + select.finalize() + + db.close() + } + + @Test + fun testStatementReset() { + val db = TestDatabaseHelper.createEmptyDatabase() + + db.run("create table reset_demo(v int)") + val insert = db.prepareStatement("insert into reset_demo(v) values (?)") + + insert.bindInt(1, 10) + insert.step() + insert.reset() + + insert.bindInt(1, 20) + insert.step() + insert.reset() + + insert.bindInt(1, 30) + insert.step() + insert.finalize() + + val select = db.prepareStatement("select v from reset_demo order by v") + assertEquals(StepResult.ROW, select.step()) + assertEquals(10, select.getInt(0)) + assertEquals(StepResult.ROW, select.step()) + assertEquals(20, select.getInt(0)) + assertEquals(StepResult.ROW, select.step()) + assertEquals(30, select.getInt(0)) + assertEquals(StepResult.DONE, select.step()) + select.finalize() + + db.close() + } + + @Test + fun testRunExtensionFunction() { + val db = TestDatabaseHelper.createEmptyDatabase() + db.run("create table ext_demo(v int)") + db.run("insert into ext_demo(v) values (?)") { bindInt(1, 99) } + assertEquals(99, db.queryInt("select v from ext_demo")) + db.close() + } + + @Test + fun testLastInsertRowId() { + val db = TestDatabaseHelper.createEmptyDatabase() + db.run("create table rowid_demo(id integer primary key autoincrement, v text)") + db.run("insert into rowid_demo(v) values ('first')") + val id1 = db.queryLong("select last_insert_rowid()") + db.run("insert into rowid_demo(v) values ('second')") + val id2 = db.queryLong("select last_insert_rowid()") + assertTrue(id2 > id1) + db.close() + } +} diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt new file mode 100644 index 00000000..72cc633c --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/MigrationTest.kt @@ -0,0 +1,41 @@ +package org.isoron.platform.io + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MigrationTest { + @Test + fun testMigrateFromScratch() { + val db = TestDatabaseHelper.createEmptyDatabase() + assertEquals(25, db.getVersion()) + + db.run( + """ + insert into Habits(name, freq_num, freq_den, color, position, archived, type) + values ('Test', 1, 1, 0, 0, 0, 0) + """ + ) + db.run( + """ + insert into Repetitions(habit, timestamp, value) + values (1, 1000000, 2) + """ + ) + + val stmt = db.prepareStatement("select name from Habits where id = 1") + assertEquals(StepResult.ROW, stmt.step()) + assertEquals("Test", stmt.getText(0)) + stmt.finalize() + + db.close() + } + + @Test + fun testMigrateIdempotent() { + val db = TestDatabaseHelper.createEmptyDatabase() + val version = db.getVersion() + db.migrateTo(version) { v -> TestDatabaseHelper.loadMigrationSQL(v) } + assertEquals(version, db.getVersion()) + db.close() + } +} diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt new file mode 100644 index 00000000..73d0368d --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/platform/io/TestDatabaseHelper.kt @@ -0,0 +1,6 @@ +package org.isoron.platform.io + +expect object TestDatabaseHelper { + fun createEmptyDatabase(): Database + fun loadMigrationSQL(version: Int): String +} diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/EntryRepositoryTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/EntryRepositoryTest.kt new file mode 100644 index 00000000..47e3ac4d --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/EntryRepositoryTest.kt @@ -0,0 +1,128 @@ +package org.isoron.uhabits.core.database + +import org.isoron.platform.io.TestDatabaseHelper +import org.isoron.platform.io.queryLong +import org.isoron.platform.io.run +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EntryRepositoryTest { + private fun insertTestHabit(db: org.isoron.platform.io.Database): Long { + db.run( + """ + insert into Habits(name, freq_num, freq_den, color, position, archived, type) + values ('Test', 1, 1, 0, 0, 0, 0) + """ + ) + return db.queryLong("select last_insert_rowid()") + } + + @Test + fun testInsertAndFindAll() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = EntryRepository(db) + val habitId = insertTestHabit(db) + + assertEquals(0, repo.findAllByHabitId(habitId).size) + + val d1 = EntryData(habitId = habitId, timestamp = 1700000000000, value = 2, notes = "") + val d2 = EntryData(habitId = habitId, timestamp = 1700100000000, value = 2, notes = "good") + val d3 = EntryData(habitId = habitId, timestamp = 1700200000000, value = 1, notes = "") + repo.insert(d1) + repo.insert(d2) + repo.insert(d3) + + val all = repo.findAllByHabitId(habitId) + assertEquals(3, all.size) + assertEquals(1700200000000, all[0].timestamp) + assertEquals(1700100000000, all[1].timestamp) + assertEquals(1700000000000, all[2].timestamp) + assertEquals("good", all[1].notes) + + db.close() + } + + @Test + fun testDeleteByHabitIdAndTimestamp() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = EntryRepository(db) + val habitId = insertTestHabit(db) + + repo.insert(EntryData(habitId = habitId, timestamp = 1000, value = 2)) + repo.insert(EntryData(habitId = habitId, timestamp = 2000, value = 2)) + repo.insert(EntryData(habitId = habitId, timestamp = 3000, value = 2)) + + repo.deleteByHabitIdAndTimestamp(habitId, 2000) + val remaining = repo.findAllByHabitId(habitId) + assertEquals(2, remaining.size) + assertTrue(remaining.none { it.timestamp == 2000L }) + + db.close() + } + + @Test + fun testDeleteByHabitId() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = EntryRepository(db) + val habitA = insertTestHabit(db) + val habitB = insertTestHabit(db) + + repo.insert(EntryData(habitId = habitA, timestamp = 1000, value = 2)) + repo.insert(EntryData(habitId = habitA, timestamp = 2000, value = 2)) + repo.insert(EntryData(habitId = habitB, timestamp = 3000, value = 2)) + + repo.deleteByHabitId(habitA) + assertEquals(0, repo.findAllByHabitId(habitA).size) + assertEquals(1, repo.findAllByHabitId(habitB).size) + + db.close() + } + + @Test + fun testIsolationBetweenHabits() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = EntryRepository(db) + val habitA = insertTestHabit(db) + val habitB = insertTestHabit(db) + + repo.insert(EntryData(habitId = habitA, timestamp = 1000, value = 2)) + repo.insert(EntryData(habitId = habitB, timestamp = 2000, value = 1)) + + assertEquals(1, repo.findAllByHabitId(habitA).size) + assertEquals(1, repo.findAllByHabitId(habitB).size) + assertEquals(0, repo.findAllByHabitId(9999).size) + + db.close() + } + + @Test + fun testAllFieldsSurviveRoundTrip() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = EntryRepository(db) + + db.run( + """ + insert into Habits(name, freq_num, freq_den, color, position, archived, type) + values ('Test', 1, 1, 0, 0, 0, 0) + """ + ) + val habitId = db.queryLong("select last_insert_rowid()") + + val original = EntryData( + habitId = habitId, + timestamp = 1700000000000, + value = 2, + notes = "Felt great today" + ) + original.id = repo.insert(original) + + val loaded = repo.findAllByHabitId(habitId).single() + assertEquals(original.habitId, loaded.habitId) + assertEquals(original.timestamp, loaded.timestamp) + assertEquals(original.value, loaded.value) + assertEquals(original.notes, loaded.notes) + + db.close() + } +} diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt new file mode 100644 index 00000000..7136c4d8 --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/HabitRepositoryTest.kt @@ -0,0 +1,204 @@ +package org.isoron.uhabits.core.database + +import org.isoron.platform.io.TestDatabaseHelper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HabitRepositoryTest { + @Test + fun testInsertAndFindAll() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = HabitRepository(db) + + assertEquals(0, repo.findAll().size) + + val data = HabitData( + name = "Wake up early", + description = "Before 6am", + question = "Did you wake up early?", + freqNum = 1, + freqDen = 1, + color = 3, + position = 0, + archived = 0, + type = 0, + unit = "", + targetValue = 0.0, + targetType = 0, + uuid = "abc-123" + ) + val id = repo.insert(data) + assertTrue(id > 0) + + val all = repo.findAll() + assertEquals(1, all.size) + assertEquals("Wake up early", all[0].name) + assertEquals("Did you wake up early?", all[0].question) + assertEquals("abc-123", all[0].uuid) + assertEquals(id, all[0].id) + + db.close() + } + + @Test + fun testUpdate() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = HabitRepository(db) + + val data = HabitData(name = "Exercise", freqNum = 1, freqDen = 2, position = 0) + data.id = repo.insert(data) + + data.name = "Exercise daily" + data.freqNum = 1 + data.freqDen = 1 + repo.update(data) + + val updated = repo.findAll() + assertEquals(1, updated.size) + assertEquals("Exercise daily", updated[0].name) + assertEquals(1, updated[0].freqDen) + + db.close() + } + + @Test + fun testDelete() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = HabitRepository(db) + + val id1 = repo.insert(HabitData(name = "A", position = 0)) + repo.insert(HabitData(name = "B", position = 1)) + repo.insert(HabitData(name = "C", position = 2)) + assertEquals(3, repo.findAll().size) + + repo.delete(id1 + 1) // delete "B" + val remaining = repo.findAll() + assertEquals(2, remaining.size) + assertEquals("A", remaining[0].name) + assertEquals("C", remaining[1].name) + + db.close() + } + + @Test + fun testNullableFields() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = HabitRepository(db) + + val data = HabitData( + name = "No reminder", + position = 0, + reminderHour = null, + reminderMin = null, + reminderDays = 0 + ) + data.id = repo.insert(data) + + val loaded = repo.findAll() + assertEquals(1, loaded.size) + assertNull(loaded[0].reminderHour) + assertNull(loaded[0].reminderMin) + assertEquals(0, loaded[0].reminderDays) + + data.reminderHour = 8 + data.reminderMin = 30 + data.reminderDays = 127 + repo.update(data) + + val updated = repo.findAll() + assertEquals(8, updated[0].reminderHour) + assertEquals(30, updated[0].reminderMin) + assertEquals(127, updated[0].reminderDays) + + db.close() + } + + @Test + fun testFindAllOrderedByPosition() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = HabitRepository(db) + + repo.insert(HabitData(name = "Third", position = 2)) + repo.insert(HabitData(name = "First", position = 0)) + repo.insert(HabitData(name = "Second", position = 1)) + + val all = repo.findAll() + assertEquals("First", all[0].name) + assertEquals("Second", all[1].name) + assertEquals("Third", all[2].name) + + db.close() + } + + @Test + fun testExecSQL() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = HabitRepository(db) + + repo.insert(HabitData(name = "A", position = 0)) + repo.insert(HabitData(name = "B", position = 1)) + repo.insert(HabitData(name = "C", position = 2)) + + repo.execSQL("update Habits set position = position + 1 where position >= 0 and position < 2") + + val all = repo.findAll() + assertEquals(1, all[0].position) + assertEquals(2, all[1].position) + assertEquals(2, all[2].position) + + db.close() + } + + @Test + fun testAllFieldsSurviveRoundTrip() { + val db = TestDatabaseHelper.createEmptyDatabase() + val repo = HabitRepository(db) + + val original = HabitData( + name = "Meditate", + description = "10 minutes of mindfulness", + question = "Did you meditate today?", + freqNum = 3, + freqDen = 7, + color = 5, + position = 42, + reminderHour = 7, + reminderMin = 30, + reminderDays = 127, + highlight = 0, + archived = 1, + type = 1, + targetValue = 10.0, + targetType = 1, + unit = "minutes", + uuid = "550e8400-e29b-41d4-a716-446655440000" + ) + original.id = repo.insert(original) + + val loaded = repo.findAll().single() + assertEquals(original.name, loaded.name) + assertEquals(original.description, loaded.description) + assertEquals(original.question, loaded.question) + assertEquals(original.freqNum, loaded.freqNum) + assertEquals(original.freqDen, loaded.freqDen) + assertEquals(original.color, loaded.color) + assertEquals(original.position, loaded.position) + assertEquals(original.reminderHour, loaded.reminderHour) + assertEquals(original.reminderMin, loaded.reminderMin) + assertEquals(original.reminderDays, loaded.reminderDays) + assertEquals(original.highlight, loaded.highlight) + assertEquals(original.archived, loaded.archived) + assertEquals(original.type, loaded.type) + assertEquals(original.targetValue, loaded.targetValue) + assertEquals(original.targetType, loaded.targetType) + assertEquals(original.unit, loaded.unit) + assertEquals(original.uuid, loaded.uuid) + assertNotNull(loaded.id) + assertEquals(original.id, loaded.id) + + db.close() + } +} diff --git a/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/SQLParserTest.kt b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/SQLParserTest.kt new file mode 100644 index 00000000..c1317a74 --- /dev/null +++ b/uhabits-core/src/commonTest/kotlin/org/isoron/uhabits/core/database/SQLParserTest.kt @@ -0,0 +1,41 @@ +package org.isoron.uhabits.core.database + +import kotlin.test.Test +import kotlin.test.assertEquals + +class SQLParserTest { + @Test + fun testBasicParsing() { + val commands = SQLParser.parse("create table t(a int); insert into t values(1);") + assertEquals(2, commands.size) + assertEquals("create table t(a int)", commands[0]) + assertEquals("insert into t values(1)", commands[1]) + } + + @Test + fun testCommentStripping() { + val sql = """ + -- This is a comment + create table t(a int); + /* block comment */ + insert into t values(1); + """.trimIndent() + val commands = SQLParser.parse(sql) + assertEquals(2, commands.size) + } + + @Test + fun testQuotedStrings() { + val sql = "insert into t values('hello; world');" + val commands = SQLParser.parse(sql) + assertEquals(1, commands.size) + assertEquals("insert into t values('hello; world')", commands[0]) + } + + @Test + fun testEmptyInput() { + assertEquals(0, SQLParser.parse("").size) + assertEquals(0, SQLParser.parse(" \n ").size) + assertEquals(0, SQLParser.parse("-- just a comment").size) + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/platform/io/JavaDatabase.kt b/uhabits-core/src/jvmMain/java/org/isoron/platform/io/JavaDatabase.kt new file mode 100644 index 00000000..171e8b41 --- /dev/null +++ b/uhabits-core/src/jvmMain/java/org/isoron/platform/io/JavaDatabase.kt @@ -0,0 +1,122 @@ +package org.isoron.platform.io + +import java.sql.Connection +import java.sql.DriverManager +import java.sql.ResultSet +import java.sql.Types + +class JavaPreparedStatement( + private val stmt: java.sql.PreparedStatement +) : PreparedStatement { + private var resultSet: ResultSet? = null + + override fun step(): StepResult { + if (resultSet == null) { + return if (stmt.execute()) { + resultSet = stmt.resultSet + if (resultSet!!.next()) StepResult.ROW else StepResult.DONE + } else { + StepResult.DONE + } + } + return if (resultSet!!.next()) StepResult.ROW else StepResult.DONE + } + + override fun getInt(index: Int): Int = resultSet!!.getInt(index + 1) + override fun getLong(index: Int): Long = resultSet!!.getLong(index + 1) + override fun getReal(index: Int): Double = resultSet!!.getDouble(index + 1) + override fun getText(index: Int): String = resultSet!!.getString(index + 1) + + override fun getIntOrNull(index: Int): Int? { + val v = resultSet!!.getInt(index + 1) + return if (resultSet!!.wasNull()) null else v + } + + override fun getLongOrNull(index: Int): Long? { + val v = resultSet!!.getLong(index + 1) + return if (resultSet!!.wasNull()) null else v + } + + override fun getRealOrNull(index: Int): Double? { + val v = resultSet!!.getDouble(index + 1) + return if (resultSet!!.wasNull()) null else v + } + + override fun getTextOrNull(index: Int): String? { + return resultSet!!.getString(index + 1) + } + + override fun bindInt(index: Int, value: Int) = stmt.setInt(index, value) + override fun bindLong(index: Int, value: Long) = stmt.setLong(index, value) + override fun bindReal(index: Int, value: Double) = stmt.setDouble(index, value) + override fun bindText(index: Int, value: String) = stmt.setString(index, value) + override fun bindNull(index: Int) = stmt.setNull(index, Types.NULL) + + override fun reset() { + resultSet?.close() + resultSet = null + stmt.clearParameters() + } + + override fun finalize() { + resultSet?.close() + stmt.close() + } +} + +class JavaDatabase(private val conn: Connection) : Database { + private var transactionDepth = 0 + + override fun prepareStatement(sql: String): PreparedStatement { + val trimmed = sql.trimStart().uppercase() + if (trimmed.startsWith("BEGIN")) { + return object : NoOpStatement() { + override fun step(): StepResult { + if (transactionDepth == 0) conn.autoCommit = false + transactionDepth++ + return StepResult.DONE + } + } + } + if (trimmed.startsWith("COMMIT")) { + return object : NoOpStatement() { + override fun step(): StepResult { + transactionDepth-- + if (transactionDepth == 0) { + conn.commit() + conn.autoCommit = true + } + return StepResult.DONE + } + } + } + return JavaPreparedStatement(conn.prepareStatement(sql)) + } + + override fun close() = conn.close() +} + +private abstract class NoOpStatement : PreparedStatement { + override fun getInt(index: Int) = throw UnsupportedOperationException() + override fun getLong(index: Int) = throw UnsupportedOperationException() + override fun getReal(index: Int) = throw UnsupportedOperationException() + override fun getText(index: Int) = throw UnsupportedOperationException() + override fun getIntOrNull(index: Int) = throw UnsupportedOperationException() + override fun getLongOrNull(index: Int) = throw UnsupportedOperationException() + override fun getRealOrNull(index: Int) = throw UnsupportedOperationException() + override fun getTextOrNull(index: Int) = throw UnsupportedOperationException() + override fun bindInt(index: Int, value: Int) = throw UnsupportedOperationException() + override fun bindLong(index: Int, value: Long) = throw UnsupportedOperationException() + override fun bindReal(index: Int, value: Double) = throw UnsupportedOperationException() + override fun bindText(index: Int, value: String) = throw UnsupportedOperationException() + override fun bindNull(index: Int) = throw UnsupportedOperationException() + override fun reset() {} + override fun finalize() {} +} + +class JavaDatabaseOpener : DatabaseOpener { + override fun open(path: String): Database { + val conn = DriverManager.getConnection("jdbc:sqlite:$path") + return JavaDatabase(conn) + } +} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Column.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Column.kt deleted file mode 100644 index 80e413c0..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Column.kt +++ /dev/null @@ -1,22 +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 - -@Retention(AnnotationRetention.RUNTIME) -annotation class Column(val name: String = "") 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 index ef89e1a7..3d4272e4 100644 --- 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 @@ -18,7 +18,6 @@ */ package org.isoron.uhabits.core.database -import org.apache.commons.lang3.StringUtils import java.io.File import java.sql.Connection import java.sql.PreparedStatement @@ -54,7 +53,7 @@ class JdbcDatabase(private val connection: Connection) : Database { val query = String.format( "update %s set %s where %s", tableName, - StringUtils.join(fields, ", "), + fields.joinToString(", "), where ) val st = buildStatement(query, valuesStr.toTypedArray()) @@ -77,8 +76,8 @@ class JdbcDatabase(private val connection: Connection) : Database { val query = String.format( "insert into %s(%s) values(%s)", tableName, - StringUtils.join(fields, ", "), - StringUtils.join(questionMarks, ", ") + fields.joinToString(", "), + questionMarks.joinToString(", ") ) val st = buildStatement(query, params.toTypedArray()) st.execute() 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 index bf6699f3..b63bf7ce 100644 --- 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 @@ -20,7 +20,6 @@ package org.isoron.uhabits.core.database import java.io.File import java.io.FileInputStream -import java.io.InputStream import java.util.Locale class MigrationHelper( @@ -30,22 +29,23 @@ class MigrationHelper( try { for (v in db.version + 1..newVersion) { val fname = String.format(Locale.US, "/migrations/%02d.sql", v) - for (command in SQLParser.parse(open(fname))) db.execute(command) + val sql = open(fname) + for (command in SQLParser.parse(sql)) db.execute(command) } } catch (e: Exception) { throw RuntimeException(e) } } - private fun open(fname: String): InputStream { + private fun open(fname: String): String { val resource = javaClass.getResourceAsStream(fname) - if (resource != null) return resource + 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) + 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/database/Repository.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Repository.kt deleted file mode 100644 index 3e44957e..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Repository.kt +++ /dev/null @@ -1,256 +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 org.apache.commons.lang3.StringUtils -import org.apache.commons.lang3.tuple.ImmutablePair -import org.apache.commons.lang3.tuple.Pair -import java.lang.reflect.Field -import java.util.ArrayList -import java.util.HashMap -import java.util.LinkedList - -class Repository( - private val klass: Class, - private val db: Database -) { - /** - * Returns the record that has the id provided. If no record is found, returns null. - */ - fun find(id: Long): T? { - return findFirst(String.format("where %s=?", getIdName()), id.toString()) - } - - /** - * Returns all records matching the given SQL query. - * - * The query should only contain the "where" part of the SQL query, and optionally the "order - * by" part. "Group by" is not allowed. If no matching records are found, returns an empty list. - */ - fun findAll(query: String, vararg params: String): List { - db.query(buildSelectQuery() + query, *params).use { c -> return cursorToMultipleRecords(c) } - } - - /** - * Returns the first record matching the given SQL query. See findAll for more details about - * the parameters. - */ - fun findFirst(query: String, vararg params: String): T? { - db.query(buildSelectQuery() + query, *params).use { c -> - return if (!c.moveToNext()) null else cursorToSingleRecord(c) - } - } - - /** - * Executes the given SQL query on the repository. - * - * The query can be of any kind. For example, complex deletes and updates are allowed. The - * repository does not perform any checks to guarantee that the query is valid, however the - * underlying database might. - */ - fun execSQL(query: String, vararg params: Any) { - db.execute(query, *params) - } - - /** - * Executes the given callback inside a database transaction. - * - * If the callback terminates without throwing any exceptions, the transaction is considered - * successful. If any exceptions are thrown, the transaction is aborted. Nesting transactions - * is not allowed. - */ - fun executeAsTransaction(callback: Runnable) { - db.beginTransaction() - try { - callback.run() - db.setTransactionSuccessful() - } catch (e: Exception) { - throw RuntimeException(e) - } finally { - db.endTransaction() - } - } - - /** - * Saves the record on the database. - * - * If the id of the given record is null, it is assumed that the record has not been inserted - * in the repository yet. The record will be inserted, a new id will be automatically generated, - * and the id of the given record will be updated. - * - * If the given record has a non-null id, then an update will be performed instead. That is, - * the previous record will be overwritten by the one provided. - */ - fun save(record: T) { - try { - val fields = getFields() - val columns = getColumnNames() - val values: MutableMap = HashMap() - for (i in fields.indices) values[columns[i]] = fields[i][record] - var id = getIdField()[record] as Long? - var affectedRows = 0 - if (id != null) { - affectedRows = db.update(getTableName(), values, "${getIdName()}=?", id.toString()) - } - if (id == null || affectedRows == 0) { - id = db.insert(getTableName(), values) - getIdField()[record] = id - } - } catch (e: Exception) { - throw RuntimeException(e) - } - } - - /** - * Removes the given record from the repository. The id of the given record is also set to null. - */ - fun remove(record: T) { - try { - val id = getIdField()[record] as Long? - db.delete(getTableName(), "${getIdName()}=?", id.toString()) - getIdField()[record] = null - } catch (e: Exception) { - throw RuntimeException(e) - } - } - - private fun cursorToMultipleRecords(c: Cursor): List { - val records: MutableList = LinkedList() - while (c.moveToNext()) records.add(cursorToSingleRecord(c)) - return records - } - - @Suppress("UNCHECKED_CAST") - private fun cursorToSingleRecord(cursor: Cursor): T { - return try { - val constructor = klass.declaredConstructors[0] - constructor.isAccessible = true - val record = constructor.newInstance() as T - var index = 0 - for (field in getFields()) copyFieldFromCursor(record, field, cursor, index++) - record - } catch (e: Exception) { - throw RuntimeException(e) - } - } - - private fun copyFieldFromCursor(record: T, field: Field, c: Cursor, index: Int) { - when { - field.type.isAssignableFrom(java.lang.Integer::class.java) -> field[record] = c.getInt(index) - field.type.isAssignableFrom(java.lang.Long::class.java) -> field[record] = c.getLong(index) - field.type.isAssignableFrom(java.lang.Double::class.java) -> field[record] = c.getDouble(index) - field.type.isAssignableFrom(java.lang.String::class.java) -> field[record] = c.getString(index) - else -> throw RuntimeException("Type not supported: ${field.type.name} ${field.name}") - } - } - - private fun buildSelectQuery(): String { - return String.format("select %s from %s ", StringUtils.join(getColumnNames(), ", "), getTableName()) - } - - private val fieldColumnPairs: List> - get() { - val fields: MutableList> = ArrayList() - for (f in klass.declaredFields) { - f.isAccessible = true - for (annotation in f.annotations) { - if (annotation !is Column) continue - fields.add(ImmutablePair(f, annotation)) - } - } - return fields - } - - private var cacheFields: Array? = null - - private fun getFields(): Array { - if (cacheFields == null) { - val fields: MutableList = ArrayList() - val columns = fieldColumnPairs - for (pair in columns) fields.add(pair.left) - cacheFields = fields.toTypedArray() - } - return cacheFields!! - } - - private var cacheColumnNames: Array? = null - - private fun getColumnNames(): Array { - if (cacheColumnNames == null) { - val names: MutableList = ArrayList() - val columns = fieldColumnPairs - for (pair in columns) { - var cname = pair.right.name - if (cname.isEmpty()) cname = pair.left.name - if (names.contains(cname)) throw RuntimeException("duplicated column : $cname") - names.add(cname) - } - cacheColumnNames = names.toTypedArray() - } - return cacheColumnNames!! - } - - private var cacheTableName: String? = null - - private fun getTableName(): String { - if (cacheTableName == null) { - val name = getTableAnnotation().name - if (name.isEmpty()) throw RuntimeException("Table name is empty") - cacheTableName = name - } - return cacheTableName!! - } - - private var cacheIdName: String? = null - - private fun getIdName(): String { - if (cacheIdName == null) { - val id = getTableAnnotation().id - if (id.isEmpty()) throw RuntimeException("Table id is empty") - cacheIdName = id - } - return cacheIdName!! - } - - private var cacheIdField: Field? = null - - private fun getIdField(): Field { - if (cacheIdField == null) { - val fields = getFields() - val idName = getIdName() - for (f in fields) if (f.name == idName) { - cacheIdField = f - break - } - if (cacheIdField == null) throw RuntimeException("Field not found: $idName") - } - return cacheIdField!! - } - - private fun getTableAnnotation(): Table { - var t: Table? = null - for (annotation in klass.annotations) { - if (annotation !is Table) continue - t = annotation - break - } - if (t == null) throw RuntimeException("Table annotation not found") - return t - } -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/SQLParser.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/SQLParser.kt deleted file mode 100644 index 5f609241..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/SQLParser.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (C) 2014 Markus Pfeiffer - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.isoron.uhabits.core.database - -import java.io.BufferedInputStream -import java.io.InputStream -import java.util.ArrayList - -internal class Tokenizer( - private val mStream: InputStream -) { - private var mIsNext = false - private var mCurrent = 0 - - operator fun hasNext(): Boolean { - if (!mIsNext) { - mIsNext = true - mCurrent = mStream.read() - } - return mCurrent != -1 - } - - operator fun next(): Int { - if (!mIsNext) { - mCurrent = mStream.read() - } - mIsNext = false - return mCurrent - } - - fun skip(s: String?): Boolean { - if (s == null || s.isEmpty()) { - return false - } - if (s[0].code != mCurrent) { - return false - } - val len = s.length - mStream.mark(len - 1) - for (n in 1 until len) { - val value = mStream.read() - if (value != s[n].code) { - mStream.reset() - return false - } - } - return true - } -} - -object SQLParser { - private const val STATE_NONE = 0 - private const val STATE_STRING = 1 - private const val STATE_COMMENT = 2 - private const val STATE_COMMENT_BLOCK = 3 - - fun parse(stream: InputStream): List { - val commands: MutableList = ArrayList() - val sb = StringBuffer() - BufferedInputStream(stream).use { buffer -> - val tokenizer = Tokenizer(buffer) - var state = STATE_NONE - while (tokenizer.hasNext()) { - val c = tokenizer.next().toChar() - if (state == STATE_COMMENT_BLOCK) { - if (tokenizer.skip("*/")) { - state = STATE_NONE - } - continue - } else if (state == STATE_COMMENT) { - if (isNewLine(c)) { - state = STATE_NONE - } - continue - } else if (state == STATE_NONE && tokenizer.skip("/*")) { - state = STATE_COMMENT_BLOCK - continue - } else if (state == STATE_NONE && tokenizer.skip("--")) { - state = STATE_COMMENT - continue - } else if (state == STATE_NONE && c == ';') { - val command = sb.toString().trim { it <= ' ' } - commands.add(command) - sb.setLength(0) - continue - } else if (state == STATE_NONE && c == '\'') { - state = STATE_STRING - } else if (state == STATE_STRING && c == '\'') { - state = STATE_NONE - } - if (state == STATE_NONE || state == STATE_STRING) { - if (state == STATE_NONE && isWhitespace(c)) { - if (sb.isNotEmpty() && sb[sb.length - 1] != ' ') { - sb.append(' ') - } - } else { - sb.append(c) - } - } - } - } - if (sb.isNotEmpty()) { - commands.add(sb.toString().trim { it <= ' ' }) - } - return commands - } - - private fun isNewLine(c: Char): Boolean { - return c == '\r' || c == '\n' - } - - private fun isWhitespace(c: Char): Boolean { - return c == '\r' || c == '\n' || c == '\t' || c == ' ' - } -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Table.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Table.kt deleted file mode 100644 index 3fdde9cc..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/Table.kt +++ /dev/null @@ -1,23 +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 - -@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS) -@Retention(AnnotationRetention.RUNTIME) -annotation class Table(val name: String, val id: String = "id") diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/UnsupportedDatabaseVersionException.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/UnsupportedDatabaseVersionException.kt deleted file mode 100644 index bdded622..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/database/UnsupportedDatabaseVersionException.kt +++ /dev/null @@ -1,21 +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 - -class UnsupportedDatabaseVersionException : RuntimeException() 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 8e1a5311..139b0d56 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 @@ -25,14 +25,15 @@ 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.database.Repository import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.ModelFactory -import org.isoron.uhabits.core.models.sqlite.records.EntryRecord -import org.isoron.uhabits.core.models.sqlite.records.HabitRecord +import org.isoron.uhabits.core.models.sqlite.SQLiteHabitList import org.isoron.uhabits.core.utils.isSQLite3File import java.io.File @@ -73,35 +74,36 @@ class LoopDBImporter( val helper = MigrationHelper(db) helper.migrateTo(DATABASE_VERSION) - val habitsRepository = Repository(HabitRecord::class.java, db) - val entryRepository = Repository(EntryRecord::class.java, db) - - for (habitRecord in habitsRepository.findAll("order by position")) { - var habit = habitList.getByUUID(habitRecord.uuid) - val entryRecords = entryRepository.findAll("where habit = ?", habitRecord.id.toString()) + val habitDataList = loadHabits(db) + for (habitData in habitDataList) { + var habit = habitList.getByUUID(habitData.uuid) if (habit == null) { habit = modelFactory.buildHabit() - habitRecord.id = null - habitRecord.copyTo(habit) + val imported = habitData.copy(id = null) + SQLiteHabitList.copyTo(imported, habit) CreateHabitCommand(modelFactory, habitList, habit).run() } else { val modified = modelFactory.buildHabit() - habitRecord.id = habit.id - habitRecord.copyTo(modified) + SQLiteHabitList.copyTo(habitData.copy(id = habit.id), modified) EditHabitCommand(habitList, habit.id!!, modified).run() } // Reload saved version of the habit - habit = habitList.getByUUID(habitRecord.uuid)!! + habit = habitList.getByUUID(habitData.uuid)!! val entries = habit.originalEntries // Import entries - for (r in entryRecords) { - val date = LocalDate.fromUnixTime(r.timestamp!!) - val (_, value, notes) = entries.get(date) - if (value != r.value || notes != r.notes) { - entries.add(Entry(date, r.value!!, r.notes ?: "")) + 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)) + } } } habit.recompute() @@ -109,4 +111,49 @@ class LoopDBImporter( habitList.resort() db.close() } + + private fun loadHabits(db: Database): List { + val result = mutableListOf() + db.query( + "SELECT id, name, description, question, freq_num, freq_den, color, " + + "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)) + } + } + 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/models/ModelFactory.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ModelFactory.kt index c425e2c2..fa16eb42 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ModelFactory.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/ModelFactory.kt @@ -18,10 +18,6 @@ */ package org.isoron.uhabits.core.models -import org.isoron.uhabits.core.database.Repository -import org.isoron.uhabits.core.models.sqlite.records.EntryRecord -import org.isoron.uhabits.core.models.sqlite.records.HabitRecord - /** * Interface implemented by factories that provide concrete implementations of * the core model classes. @@ -43,6 +39,4 @@ interface ModelFactory { fun buildHabitList(): HabitList fun buildScoreList(): ScoreList fun buildStreakList(): StreakList - fun buildHabitListRepository(): Repository - fun buildRepetitionListRepository(): Repository } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt index eff25247..52d66b43 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/memory/MemoryModelFactory.kt @@ -29,6 +29,4 @@ class MemoryModelFactory : ModelFactory { override fun buildHabitList() = MemoryHabitList() override fun buildScoreList() = ScoreList() override fun buildStreakList() = StreakList() - override fun buildHabitListRepository() = throw NotImplementedError() - override fun buildRepetitionListRepository() = throw NotImplementedError() } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt index ff7ab863..0beb7d68 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLModelFactory.kt @@ -19,31 +19,26 @@ package org.isoron.uhabits.core.models.sqlite import me.tatarka.inject.annotations.Inject -import org.isoron.uhabits.core.database.Database -import org.isoron.uhabits.core.database.Repository +import org.isoron.uhabits.core.database.EntryRepository +import org.isoron.uhabits.core.database.HabitRepository import org.isoron.uhabits.core.models.EntryList import org.isoron.uhabits.core.models.ModelFactory import org.isoron.uhabits.core.models.ScoreList import org.isoron.uhabits.core.models.StreakList -import org.isoron.uhabits.core.models.sqlite.records.EntryRecord -import org.isoron.uhabits.core.models.sqlite.records.HabitRecord /** * Factory that provides models backed by an SQLite database. */ @Inject class SQLModelFactory( - val database: Database + val database: org.isoron.platform.io.Database ) : ModelFactory { - override fun buildOriginalEntries() = SQLiteEntryList(database) + val habitRepository = HabitRepository(database) + val entryRepository = EntryRepository(database) + + override fun buildOriginalEntries() = SQLiteEntryList(entryRepository) override fun buildComputedEntries() = EntryList() override fun buildHabitList() = SQLiteHabitList(this) override fun buildScoreList() = ScoreList() override fun buildStreakList() = StreakList() - - override fun buildHabitListRepository() = - Repository(HabitRecord::class.java, database) - - override fun buildRepetitionListRepository() = - Repository(EntryRecord::class.java, database) } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryList.kt index f4f9556e..bb5a047f 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteEntryList.kt @@ -20,26 +20,23 @@ package org.isoron.uhabits.core.models.sqlite import org.isoron.platform.time.LocalDate -import org.isoron.uhabits.core.database.Database -import org.isoron.uhabits.core.database.Repository +import org.isoron.uhabits.core.database.EntryData +import org.isoron.uhabits.core.database.EntryRepository import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.EntryList import org.isoron.uhabits.core.models.Frequency -import org.isoron.uhabits.core.models.sqlite.records.EntryRecord -class SQLiteEntryList(database: Database) : EntryList() { - val repository = Repository(EntryRecord::class.java, database) +class SQLiteEntryList(val repository: EntryRepository) : EntryList() { var habitId: Long? = null var isLoaded = false private fun loadRecords() { if (isLoaded) return val habitId = habitId ?: throw IllegalStateException("habitId must be set") - val records = repository.findAll( - "where habit = ? order by timestamp", - habitId.toString() - ) - for (rec in records) super.add(rec.toEntry()) + val records = repository.findAllByHabitId(habitId) + for (rec in records) { + super.add(Entry(LocalDate.fromUnixTime(rec.timestamp), rec.value, rec.notes)) + } isLoaded = true } @@ -57,19 +54,16 @@ class SQLiteEntryList(database: Database) : EntryList() { loadRecords() val habitId = habitId ?: throw IllegalStateException("habitId must be set") - // Remove existing rows - repository.execSQL( - "delete from repetitions where habit = ? and timestamp = ?", - habitId.toString(), - entry.date.unixTime.toString() + repository.deleteByHabitIdAndTimestamp(habitId, entry.date.unixTime) + + val data = EntryData( + habitId = habitId, + timestamp = entry.date.unixTime, + value = entry.value, + notes = entry.notes ) + repository.insert(data) - // Add new row - val record = EntryRecord().apply { copyFrom(entry) } - record.habitId = habitId - repository.save(record) - - // Add to memory list super.add(entry) } @@ -84,9 +78,6 @@ class SQLiteEntryList(database: Database) : EntryList() { override fun clear() { super.clear() - repository.execSQL( - "delete from repetitions where habit = ?", - habitId.toString() - ) + repository.deleteByHabitId(habitId!!) } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt index ba544a89..6c034b3b 100644 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt +++ b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/SQLiteHabitList.kt @@ -19,32 +19,39 @@ package org.isoron.uhabits.core.models.sqlite import me.tatarka.inject.annotations.Inject -import org.isoron.uhabits.core.database.Repository +import org.isoron.uhabits.core.database.HabitData +import org.isoron.uhabits.core.database.HabitRepository +import org.isoron.uhabits.core.models.Frequency import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitMatcher +import org.isoron.uhabits.core.models.HabitType import org.isoron.uhabits.core.models.ModelFactory +import org.isoron.uhabits.core.models.NumericalHabitType +import org.isoron.uhabits.core.models.PaletteColor +import org.isoron.uhabits.core.models.Reminder +import org.isoron.uhabits.core.models.WeekdayList import org.isoron.uhabits.core.models.memory.MemoryHabitList -import org.isoron.uhabits.core.models.sqlite.records.HabitRecord /** * Implementation of a [HabitList] that is backed by SQLite. */ @Inject class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() { - private val repository: Repository = modelFactory.buildHabitListRepository() + private val repository: HabitRepository = (modelFactory as SQLModelFactory).habitRepository private val list: MemoryHabitList = MemoryHabitList() private var loaded = false + private fun loadRecords() { if (loaded) return loaded = true list.removeAll() - val records = repository.findAll("order by position") + val records = repository.findAll() var shouldRebuildOrder = false for ((expectedPosition, rec) in records.withIndex()) { if (rec.position != expectedPosition) shouldRebuildOrder = true val h = modelFactory.buildHabit() - rec.copyTo(h) + copyTo(rec, h) (h.originalEntries as SQLiteEntryList).habitId = h.id list.add(h) } @@ -54,12 +61,12 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() { @Synchronized override fun add(habit: Habit) { loadRecords() + require(list.indexOf(habit) < 0) { "habit already added" } habit.position = size() - val record = HabitRecord() - record.copyFrom(habit) - repository.save(record) - habit.id = record.id - (habit.originalEntries as SQLiteEntryList).habitId = record.id + val data = copyFrom(habit) + val id = repository.insert(data) + habit.id = id + (habit.originalEntries as SQLiteEntryList).habitId = id list.add(habit) observable.notifyListeners() } @@ -118,13 +125,11 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() { @Synchronized private fun rebuildOrder() { - val records = repository.findAll("order by position") - repository.executeAsTransaction { - for ((pos, r) in records.withIndex()) { - if (r.position != pos) { - r.position = pos - repository.save(r) - } + val records = repository.findAll() + for ((pos, r) in records.withIndex()) { + if (r.position != pos) { + r.position = pos + repository.update(r) } } } @@ -133,13 +138,8 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() { override fun remove(h: Habit) { loadRecords() list.remove(h) - val record = repository.find( - h.id!! - ) ?: throw RuntimeException("habit not in database") - repository.executeAsTransaction { - h.originalEntries.clear() - repository.remove(record) - } + h.originalEntries.clear() + repository.delete(h.id!!) rebuildOrder() observable.notifyListeners() } @@ -155,32 +155,23 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() { @Synchronized override fun reorder(from: Habit, to: Habit) { loadRecords() + val fromPos = from.position + val toPos = to.position list.reorder(from, to) - val fromRecord = repository.find( - from.id!! - ) - val toRecord = repository.find( - to.id!! - ) - if (fromRecord == null) throw RuntimeException("habit not in database") - if (toRecord == null) throw RuntimeException("habit not in database") - if (toRecord.position!! < fromRecord.position!!) { + if (toPos < fromPos) { repository.execSQL( "update habits set position = position + 1 " + - "where position >= ? and position < ?", - toRecord.position!!, - fromRecord.position!! + "where position >= $toPos and position < $fromPos" ) } else { repository.execSQL( "update habits set position = position - 1 " + - "where position > ? and position <= ?", - fromRecord.position!!, - toRecord.position!! + "where position > $fromPos and position <= $toPos" ) } - fromRecord.position = toRecord.position - repository.save(fromRecord) + val data = copyFrom(from) + data.position = toPos + repository.update(data) observable.notifyListeners() } @@ -202,9 +193,8 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() { loadRecords() list.update(habits) for (h in habits) { - val record = repository.find(h.id!!) ?: continue - record.copyFrom(h) - repository.save(record) + val data = copyFrom(h) + repository.update(data) } observable.notifyListeners() } @@ -218,4 +208,53 @@ class SQLiteHabitList(private val modelFactory: ModelFactory) : HabitList() { fun reload() { loaded = false } + + companion object { + fun copyFrom(habit: Habit): HabitData { + val (numerator, denominator) = habit.frequency + return HabitData( + id = habit.id, + name = habit.name, + description = habit.description, + question = habit.question, + freqNum = numerator, + freqDen = denominator, + color = habit.color.paletteIndex, + position = habit.position, + reminderHour = habit.reminder?.hour, + reminderMin = habit.reminder?.minute, + reminderDays = habit.reminder?.days?.toInteger() ?: 0, + highlight = 0, + archived = if (habit.isArchived) 1 else 0, + type = habit.type.value, + targetValue = habit.targetValue, + targetType = habit.targetType.value, + unit = habit.unit, + uuid = habit.uuid + ) + } + + fun copyTo(data: HabitData, habit: Habit) { + habit.id = data.id + habit.name = data.name + habit.description = data.description + habit.question = data.question + habit.frequency = Frequency(data.freqNum, data.freqDen) + habit.color = PaletteColor(data.color) + habit.isArchived = data.archived != 0 + habit.type = HabitType.fromInt(data.type) + habit.targetType = NumericalHabitType.fromInt(data.targetType) + habit.targetValue = data.targetValue + habit.unit = data.unit + habit.position = data.position + habit.uuid = data.uuid + if (data.reminderHour != null && data.reminderMin != null) { + habit.reminder = Reminder( + data.reminderHour!!, + data.reminderMin!!, + WeekdayList(data.reminderDays) + ) + } + } + } } diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/EntryRecord.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/EntryRecord.kt deleted file mode 100644 index 2d5c7917..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/EntryRecord.kt +++ /dev/null @@ -1,57 +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.models.sqlite.records - -import org.isoron.platform.time.LocalDate -import org.isoron.uhabits.core.database.Column -import org.isoron.uhabits.core.database.Table -import org.isoron.uhabits.core.models.Entry - -/** - * The SQLite database record corresponding to a [Entry]. - */ -@Table(name = "Repetitions") -class EntryRecord { - var habit: HabitRecord? = null - - @field:Column(name = "habit") - var habitId: Long? = null - - @field:Column - var timestamp: Long? = null - - @field:Column - var value: Int? = null - - @field:Column - var id: Long? = null - - @field:Column - var notes: String? = null - - fun copyFrom(entry: Entry) { - timestamp = entry.date.unixTime - value = entry.value - notes = entry.notes - } - - fun toEntry(): Entry { - return Entry(LocalDate.fromUnixTime(timestamp!!), value!!, notes ?: "") - } -} diff --git a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.kt b/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.kt deleted file mode 100644 index 45b3b988..00000000 --- a/uhabits-core/src/jvmMain/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecord.kt +++ /dev/null @@ -1,140 +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.models.sqlite.records - -import org.isoron.uhabits.core.database.Column -import org.isoron.uhabits.core.database.Table -import org.isoron.uhabits.core.models.Frequency -import org.isoron.uhabits.core.models.Habit -import org.isoron.uhabits.core.models.HabitType -import org.isoron.uhabits.core.models.NumericalHabitType -import org.isoron.uhabits.core.models.PaletteColor -import org.isoron.uhabits.core.models.Reminder -import org.isoron.uhabits.core.models.WeekdayList - -/** - * The SQLite database record corresponding to a [Habit]. - */ -@Table(name = "habits") -class HabitRecord { - @field:Column - var description: String? = null - - @field:Column - var question: String? = null - - @field:Column - var name: String? = null - - @field:Column(name = "freq_num") - var freqNum: Int? = null - - @field:Column(name = "freq_den") - var freqDen: Int? = null - - @field:Column - var color: Int? = null - - @field:Column - var position: Int? = null - - @field:Column(name = "reminder_hour") - var reminderHour: Int? = null - - @field:Column(name = "reminder_min") - var reminderMin: Int? = null - - @field:Column(name = "reminder_days") - var reminderDays: Int? = null - - @field:Column - var highlight: Int? = null - - @field:Column - var archived: Int? = null - - @field:Column - var type: Int? = null - - @field:Column(name = "target_value") - var targetValue: Double? = null - - @field:Column(name = "target_type") - var targetType: Int? = null - - @field:Column - var unit: String? = null - - @field:Column - var id: Long? = null - - @field:Column - var uuid: String? = null - - fun copyFrom(model: Habit) { - id = model.id - name = model.name - description = model.description - highlight = 0 - color = model.color.paletteIndex - archived = if (model.isArchived) 1 else 0 - type = model.type.value - targetType = model.targetType.value - targetValue = model.targetValue - unit = model.unit - position = model.position - question = model.question - uuid = model.uuid - val (numerator, denominator) = model.frequency - freqNum = numerator - freqDen = denominator - reminderDays = 0 - reminderMin = null - reminderHour = null - if (model.hasReminder()) { - val reminder = model.reminder - reminderHour = reminder!!.hour - reminderMin = reminder!!.minute - reminderDays = reminder.days.toInteger() - } - } - - fun copyTo(habit: Habit) { - habit.id = id - habit.name = name!! - habit.description = description!! - habit.question = question!! - habit.frequency = Frequency(freqNum!!, freqDen!!) - habit.color = PaletteColor(color!!) - habit.isArchived = archived != 0 - habit.type = HabitType.fromInt(type!!) - habit.targetType = NumericalHabitType.fromInt(targetType!!) - habit.targetValue = targetValue!! - habit.unit = unit!! - habit.position = position!! - habit.uuid = uuid - if (reminderHour != null && reminderMin != null) { - habit.reminder = Reminder( - reminderHour!!, - reminderMin!!, - WeekdayList(reminderDays!!) - ) - } - } -} diff --git a/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt new file mode 100644 index 00000000..eeab9e1b --- /dev/null +++ b/uhabits-core/src/jvmTest/java/org/isoron/platform/io/TestDatabaseHelper.kt @@ -0,0 +1,19 @@ +package org.isoron.platform.io + +import org.isoron.uhabits.core.DATABASE_VERSION + +actual object TestDatabaseHelper { + actual fun createEmptyDatabase(): Database { + val db = JavaDatabaseOpener().open(":memory:") + db.setVersion(8) + db.migrateTo(DATABASE_VERSION) { v -> loadMigrationSQL(v) } + return db + } + + actual fun loadMigrationSQL(version: Int): String { + val filename = "/migrations/%02d.sql".format(version) + return TestDatabaseHelper::class.java + .getResourceAsStream(filename)!! + .bufferedReader().readText() + } +} 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 efe08a78..8a04812d 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,6 +19,7 @@ package org.isoron.uhabits.core import org.apache.commons.io.IOUtils +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 @@ -146,5 +147,9 @@ open class BaseUnitTest { throw RuntimeException(e) } } + + fun buildNewMemoryDatabase(): org.isoron.platform.io.Database { + return org.isoron.platform.io.TestDatabaseHelper.createEmptyDatabase() + } } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/RepositoryTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/RepositoryTest.kt deleted file mode 100644 index 2ca41925..00000000 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/database/RepositoryTest.kt +++ /dev/null @@ -1,180 +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 org.apache.commons.lang3.builder.EqualsBuilder -import org.apache.commons.lang3.builder.HashCodeBuilder -import org.apache.commons.lang3.builder.ToStringBuilder -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.core.IsEqual.equalTo -import org.isoron.uhabits.core.BaseUnitTest -import org.junit.Before -import org.junit.Test -import kotlin.test.assertNull - -class RepositoryTest : BaseUnitTest() { - private lateinit var repository: Repository - private lateinit var db: Database - - @Before - @Throws(Exception::class) - override fun setUp() { - super.setUp() - db = buildMemoryDatabase() - repository = Repository(ThingRecord::class.java, db) - db.execute("drop table if exists tests") - db.execute( - "create table tests(" + - "id integer not null primary key autoincrement, " + - "color_number integer not null, score float not null, " + - "name string)" - ) - } - - @Test - @Throws(Exception::class) - fun testFind() { - db.execute( - "insert into tests(id, color_number, name, score) " + - "values (10, 20, 'hello', 8.0)" - ) - val record = repository.find(10L) - assertThat(record!!.id, equalTo(10L)) - assertThat(record.color, equalTo(20)) - assertThat(record.name, equalTo("hello")) - assertThat(record.score, equalTo(8.0)) - } - - @Test - @Throws(Exception::class) - fun testSave_withId() { - val record = ThingRecord().apply { - id = 50L - color = 10 - name = "hello" - score = 5.0 - } - repository.save(record) - assertThat(record, equalTo(repository.find(50L))) - record.name = "world" - record.score = 128.0 - repository.save(record) - assertThat(record, equalTo(repository.find(50L))) - } - - @Test - @Throws(Exception::class) - fun testSave_withNull() { - val record = ThingRecord().apply { - color = 50 - name = null - score = 12.0 - } - repository.save(record) - val retrieved = repository.find(record.id!!) - assertNull(retrieved!!.name) - assertThat(record, equalTo(retrieved)) - } - - @Test - @Throws(Exception::class) - fun testSave_withoutId() { - val r1 = ThingRecord().apply { - color = 10 - name = "hello" - score = 16.0 - } - repository.save(r1) - val r2 = ThingRecord().apply { - color = 20 - name = "world" - score = 2.0 - } - repository.save(r2) - assertThat(r1.id, equalTo(1L)) - assertThat(r2.id, equalTo(2L)) - } - - @Test - @Throws(Exception::class) - fun testRemove() { - val rec1 = ThingRecord().apply { - color = 10 - name = "hello" - score = 16.0 - } - repository.save(rec1) - val rec2 = ThingRecord().apply { - color = 20 - name = "world" - score = 32.0 - } - repository.save(rec2) - val id = rec1.id!! - assertThat(rec1, equalTo(repository.find(id))) - assertThat(rec2, equalTo(repository.find(rec2.id!!))) - repository.remove(rec1) - assertThat(rec1.id, equalTo(null)) - assertNull(repository.find(id)) - assertThat(rec2, equalTo(repository.find(rec2.id!!))) - repository.remove(rec1) // should have no effect - assertNull(repository.find(id)) - } - - @Table(name = "tests") - class ThingRecord { - @field:Column - var id: Long? = null - - @field:Column - var name: String? = null - - @field:Column(name = "color_number") - var color: Int? = null - - @field:Column - var score: Double? = null - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - val record = other as ThingRecord - return EqualsBuilder() - .append(id, record.id) - .append(name, record.name) - .append(color, record.color) - .isEquals - } - - override fun hashCode(): Int { - return HashCodeBuilder(17, 37) - .append(id) - .append(name) - .append(color) - .toHashCode() - } - - override fun toString(): String { - return ToStringBuilder(this) - .append("id", id) - .append("name", name) - .append("color", color) - .toString() - } - } -} 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 9a66e589..7372e97a 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 @@ -25,8 +25,6 @@ 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.isoron.uhabits.core.models.sqlite.SQLModelFactory -import org.isoron.uhabits.core.test.HabitFixtures import org.junit.Test import org.junit.jupiter.api.Assertions.assertThrows @@ -39,9 +37,6 @@ class Version22Test : BaseUnitTest() { super.setUp() db = openDatabaseResource("/databases/021.db") helper = MigrationHelper(db) - modelFactory = SQLModelFactory(db) - habitList = (modelFactory as SQLModelFactory).buildHabitList() - fixtures = HabitFixtures(modelFactory, habitList) } @Test 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 6e020363..def81bfd 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 @@ -24,8 +24,6 @@ import org.hamcrest.MatcherAssert.assertThat import org.isoron.uhabits.core.BaseUnitTest import org.isoron.uhabits.core.database.Database import org.isoron.uhabits.core.database.MigrationHelper -import org.isoron.uhabits.core.models.sqlite.SQLModelFactory -import org.isoron.uhabits.core.test.HabitFixtures import org.junit.Test class Version23Test : BaseUnitTest() { @@ -38,9 +36,6 @@ class Version23Test : BaseUnitTest() { super.setUp() db = openDatabaseResource("/databases/022.db") helper = MigrationHelper(db) - modelFactory = SQLModelFactory(db) - habitList = (modelFactory as SQLModelFactory).buildHabitList() - fixtures = HabitFixtures(modelFactory, habitList) } private fun migrateTo23() = helper.migrateTo(23) 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 092d3741..40ae2388 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,49 +20,46 @@ package org.isoron.uhabits.core.models.sqlite import org.isoron.platform.time.LocalDate -import org.isoron.uhabits.core.BaseUnitTest.Companion.buildMemoryDatabase -import org.isoron.uhabits.core.database.Repository +import org.isoron.uhabits.core.BaseUnitTest.Companion.buildNewMemoryDatabase +import org.isoron.uhabits.core.database.EntryData import org.isoron.uhabits.core.models.Entry import org.isoron.uhabits.core.models.Entry.Companion.UNKNOWN -import org.isoron.uhabits.core.models.sqlite.records.EntryRecord import org.junit.Before import org.junit.Test import kotlin.test.assertEquals -import kotlin.test.assertNull class SQLiteEntryListTest { - private val database = buildMemoryDatabase() - private val repository = Repository(EntryRecord::class.java, database) - private val entries = SQLiteEntryList(database) + private val database = buildNewMemoryDatabase() + private val factory = SQLModelFactory(database) + private val entryRepository = factory.entryRepository + private lateinit var entries: SQLiteEntryList private val today = LocalDate(2015, 1, 25) @Before fun setUp() { - // Create a habit and add it to the database to satisfy foreign key requirements - val factory = SQLModelFactory(database) val habitList = factory.buildHabitList() val habit = factory.buildHabit() habitList.add(habit) - entries.habitId = habit.id + entries = habit.originalEntries as SQLiteEntryList } @Test fun testLoad() { val today = LocalDate(2015, 1, 25) - repository.save( - EntryRecord().apply { - habitId = entries.habitId - timestamp = today.unixTime + entryRepository.insert( + EntryData( + habitId = entries.habitId, + timestamp = today.unixTime, value = 500 - } + ) ) - repository.save( - EntryRecord().apply { - habitId = entries.habitId - timestamp = today.minus(5).unixTime + entryRepository.insert( + EntryData( + habitId = entries.habitId, + timestamp = today.minus(5).unixTime, value = 300 - } + ) ) assertEquals( Entry(date = today, value = 500), @@ -80,26 +77,23 @@ class SQLiteEntryListTest { @Test fun testAdd() { - assertNull(getByTimestamp(1, today)) + val habitId = entries.habitId!! + + assertEquals(0, entryRepository.findAllByHabitId(habitId).size) val original = Entry(today, 150) entries.add(original) - val retrieved = getByTimestamp(1, today)!! - assertEquals(original, retrieved.toEntry()) + val all = entryRepository.findAllByHabitId(habitId) + assertEquals(1, all.size) + assertEquals(150, all[0].value) + assertEquals(today.unixTime, all[0].timestamp) val replacement = Entry(today, 90) entries.add(replacement) - val retrieved2 = getByTimestamp(1, today)!! - assertEquals(replacement, retrieved2.toEntry()) - } - - private fun getByTimestamp(habitId: Int, date: LocalDate): EntryRecord? { - return repository.findFirst( - "where habit = ? and timestamp = ?", - habitId.toString(), - date.unixTime.toString() - ) + val all2 = entryRepository.findAllByHabitId(habitId) + assertEquals(1, all2.size) + assertEquals(90, all2[0].value) } } 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 7a82dc12..de3d489e 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 @@ -21,15 +21,13 @@ package org.isoron.uhabits.core.models.sqlite import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert.assertThat import org.isoron.uhabits.core.BaseUnitTest -import org.isoron.uhabits.core.database.Database -import org.isoron.uhabits.core.database.Repository +import org.isoron.uhabits.core.database.HabitRepository import org.isoron.uhabits.core.models.Habit import org.isoron.uhabits.core.models.HabitList import org.isoron.uhabits.core.models.HabitMatcher import org.isoron.uhabits.core.models.ModelObservable import org.isoron.uhabits.core.models.Reminder import org.isoron.uhabits.core.models.WeekdayList -import org.isoron.uhabits.core.models.sqlite.records.HabitRecord import org.isoron.uhabits.core.test.HabitFixtures import org.junit.Assert.assertThrows import org.junit.Test @@ -39,7 +37,7 @@ import java.util.ArrayList import kotlin.test.assertNull class SQLiteHabitListTest : BaseUnitTest() { - private lateinit var repository: Repository + private lateinit var repository: HabitRepository private var listener: ModelObservable.Listener = mock() private lateinit var habitsArray: ArrayList private lateinit var activeHabits: HabitList @@ -48,11 +46,11 @@ class SQLiteHabitListTest : BaseUnitTest() { @Throws(Exception::class) override fun setUp() { super.setUp() - val db: Database = buildMemoryDatabase() + val db = buildNewMemoryDatabase() modelFactory = SQLModelFactory(db) habitList = SQLiteHabitList(modelFactory) fixtures = HabitFixtures(modelFactory, habitList) - repository = Repository(HabitRecord::class.java, db) + repository = (modelFactory as SQLModelFactory).habitRepository habitsArray = ArrayList() for (i in 0..9) { val habit = fixtures.createEmptyHabit() @@ -99,7 +97,8 @@ class SQLiteHabitListTest : BaseUnitTest() { habit.id = 12300L habitList.add(habit) assertThat(habit.id, equalTo(12300L)) - val record = repository.find(12300L) + val all = repository.findAll() + val record = all.find { it.id == 12300L } assertThat(record!!.name, equalTo(habit.name)) } @@ -109,7 +108,8 @@ class SQLiteHabitListTest : BaseUnitTest() { habit.name = "Hello world" assertNull(habit.id) habitList.add(habit) - val record = repository.find(habit.id!!) + val all = repository.findAll() + val record = all.find { it.id == habit.id } assertThat(record!!.name, equalTo(habit.name)) } @@ -156,10 +156,11 @@ class SQLiteHabitListTest : BaseUnitTest() { habitList.remove(h!!) assertThat(habitList.indexOf(h), equalTo(-1)) - var rec = repository.find(2L) - assertNull(rec) - rec = repository.find(3L)!! - assertThat(rec.position, equalTo(1)) + val all = repository.findAll() + val rec2 = all.find { it.id == 2L } + assertNull(rec2) + val rec3 = all.find { it.id == 3L }!! + assertThat(rec3.position, equalTo(1)) } @Test @@ -169,10 +170,11 @@ class SQLiteHabitListTest : BaseUnitTest() { habitList.remove(h!!) assertThat(habitList.indexOf(h), equalTo(-1)) - var rec = repository.find(2L) - assertNull(rec) - rec = repository.find(3L)!! - assertThat(rec.position, equalTo(1)) + val all = repository.findAll() + val rec2 = all.find { it.id == 2L } + assertNull(rec2) + val rec3 = all.find { it.id == 3L }!! + assertThat(rec3.position, equalTo(1)) } @Test @@ -180,9 +182,10 @@ class SQLiteHabitListTest : BaseUnitTest() { val habit3 = habitList.getById(3)!! val habit4 = habitList.getById(4)!! habitList.reorder(habit4, habit3) - val record3 = repository.find(3L)!! + val all = repository.findAll() + val record3 = all.find { it.id == 3L }!! assertThat(record3.position, equalTo(3)) - val record4 = repository.find(4L)!! + val record4 = all.find { it.id == 4L }!! assertThat(record4.position, equalTo(2)) } } diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/records/EntryRecordTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/records/EntryRecordTest.kt deleted file mode 100644 index eb2c2b9e..00000000 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/records/EntryRecordTest.kt +++ /dev/null @@ -1,37 +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.models.sqlite.records - -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.isoron.platform.time.LocalDate -import org.isoron.uhabits.core.BaseUnitTest -import org.isoron.uhabits.core.models.Entry -import org.junit.Test - -class EntryRecordTest : BaseUnitTest() { - @Test - @Throws(Exception::class) - fun testRecord() { - val check = Entry(LocalDate(100), 50) - val record = EntryRecord() - record.copyFrom(check) - assertThat(check, equalTo(record.toEntry())) - } -} diff --git a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecordTest.kt b/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecordTest.kt deleted file mode 100644 index 270d08fb..00000000 --- a/uhabits-core/src/jvmTest/java/org/isoron/uhabits/core/models/sqlite/records/HabitRecordTest.kt +++ /dev/null @@ -1,74 +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.models.sqlite.records - -import org.hamcrest.CoreMatchers.equalTo -import org.hamcrest.MatcherAssert.assertThat -import org.isoron.uhabits.core.BaseUnitTest -import org.isoron.uhabits.core.models.Frequency -import org.isoron.uhabits.core.models.HabitType -import org.isoron.uhabits.core.models.NumericalHabitType -import org.isoron.uhabits.core.models.PaletteColor -import org.isoron.uhabits.core.models.Reminder -import org.isoron.uhabits.core.models.WeekdayList -import org.junit.Test - -class HabitRecordTest : BaseUnitTest() { - @Test - fun testCopyRestore1() { - val original = modelFactory.buildHabit().apply { - name = "Hello world" - question = "Did you greet the world today?" - color = PaletteColor(1) - isArchived = true - frequency = Frequency.THREE_TIMES_PER_WEEK - reminder = Reminder(8, 30, WeekdayList.EVERY_DAY) - id = 1000L - position = 20 - } - val record = HabitRecord() - record.copyFrom(original) - val duplicate = modelFactory.buildHabit() - record.copyTo(duplicate) - assertThat(original, equalTo(duplicate)) - } - - @Test - fun testCopyRestore2() { - val original = modelFactory.buildHabit().apply { - name = "Hello world" - question = "Did you greet the world today?" - color = PaletteColor(5) - isArchived = false - frequency = Frequency.DAILY - reminder = null - id = 1L - position = 15 - type = HabitType.NUMERICAL - targetValue = 100.0 - targetType = NumericalHabitType.AT_LEAST - unit = "miles" - } - val record = HabitRecord() - record.copyFrom(original) - val duplicate = modelFactory.buildHabit() - record.copyTo(duplicate) - assertThat(original, equalTo(duplicate)) - } -}