Replace reflection-based Repository with multiplatform PreparedStatement API
This commit is contained in:
parent
273dc5b104
commit
b2f2e1f562
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.isoron.uhabits.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Int, Any?>()
|
||||
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<String>? {
|
||||
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<String, Any?>): 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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -51,7 +51,6 @@ kotlin {
|
||||
implementation(libs.jsr305)
|
||||
implementation(libs.opencsv)
|
||||
implementation(libs.commons.codec)
|
||||
implementation(libs.commons.lang3)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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<EntryData> {
|
||||
findAllByHabitStmt.reset()
|
||||
findAllByHabitStmt.bindLong(1, habitId)
|
||||
val results = mutableListOf<EntryData>()
|
||||
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)
|
||||
}
|
||||
@ -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<HabitData> {
|
||||
findAllStmt.reset()
|
||||
val results = mutableListOf<HabitData>()
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<String> {
|
||||
val commands = mutableListOf<String>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package org.isoron.uhabits.core.database
|
||||
|
||||
class UnsupportedDatabaseVersionException : RuntimeException()
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package org.isoron.platform.io
|
||||
|
||||
expect object TestDatabaseHelper {
|
||||
fun createEmptyDatabase(): Database
|
||||
fun loadMigrationSQL(version: Int): String
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.database
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Column(val name: String = "")
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,256 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.database
|
||||
|
||||
import 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<T>(
|
||||
private val klass: Class<T>,
|
||||
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<T> {
|
||||
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<String, Any?> = 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<T> {
|
||||
val records: MutableList<T> = 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<Pair<Field, Column>>
|
||||
get() {
|
||||
val fields: MutableList<Pair<Field, Column>> = 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<Field>? = null
|
||||
|
||||
private fun getFields(): Array<Field> {
|
||||
if (cacheFields == null) {
|
||||
val fields: MutableList<Field> = ArrayList()
|
||||
val columns = fieldColumnPairs
|
||||
for (pair in columns) fields.add(pair.left)
|
||||
cacheFields = fields.toTypedArray()
|
||||
}
|
||||
return cacheFields!!
|
||||
}
|
||||
|
||||
private var cacheColumnNames: Array<String>? = null
|
||||
|
||||
private fun getColumnNames(): Array<String> {
|
||||
if (cacheColumnNames == null) {
|
||||
val names: MutableList<String> = 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
|
||||
}
|
||||
}
|
||||
@ -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<String> {
|
||||
val commands: MutableList<String> = 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 == ' '
|
||||
}
|
||||
}
|
||||
@ -1,23 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.database
|
||||
|
||||
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
annotation class Table(val name: String, val id: String = "id")
|
||||
@ -1,21 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.database
|
||||
|
||||
class UnsupportedDatabaseVersionException : RuntimeException()
|
||||
@ -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<HabitData> {
|
||||
val result = mutableListOf<HabitData>()
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<HabitRecord>
|
||||
fun buildRepetitionListRepository(): Repository<EntryRecord>
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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!!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<HabitRecord> = 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.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 ?: "")
|
||||
}
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.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!!)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.database
|
||||
|
||||
import 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<ThingRecord>
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<HabitRecord>
|
||||
private lateinit var repository: HabitRepository
|
||||
private var listener: ModelObservable.Listener = mock()
|
||||
private lateinit var habitsArray: ArrayList<Habit>
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.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()))
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016-2025 Álinson Santos Xavier <git@axavier.org>
|
||||
*
|
||||
* This file is part of Loop Habit Tracker.
|
||||
*
|
||||
* Loop Habit Tracker is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation, either version 3 of the License, or (at your
|
||||
* option) any later version.
|
||||
*
|
||||
* Loop Habit Tracker is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.isoron.uhabits.core.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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user