Replace reflection-based Repository with multiplatform PreparedStatement API

This commit is contained in:
Alinson S. Xavier 2026-04-05 21:14:24 -05:00
parent 273dc5b104
commit b2f2e1f562
43 changed files with 1487 additions and 1208 deletions

View File

@ -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()
}
}

View File

@ -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(

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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() {

View File

@ -51,7 +51,6 @@ kotlin {
implementation(libs.jsr305)
implementation(libs.opencsv)
implementation(libs.commons.codec)
implementation(libs.commons.lang3)
}
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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)
)
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,3 @@
package org.isoron.uhabits.core.database
class UnsupportedDatabaseVersionException : RuntimeException()

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -0,0 +1,6 @@
package org.isoron.platform.io
expect object TestDatabaseHelper {
fun createEmptyDatabase(): Database
fun loadMigrationSQL(version: Int): String
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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 = "")

View File

@ -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()

View File

@ -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")
}
}

View File

@ -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
}
}

View File

@ -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 == ' '
}
}

View File

@ -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")

View File

@ -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()

View File

@ -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)
)
}
}

View File

@ -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>
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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!!)
}
}

View File

@ -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)
)
}
}
}
}

View File

@ -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 ?: "")
}
}

View File

@ -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!!)
)
}
}
}

View File

@ -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()
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}
}

View File

@ -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

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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()))
}
}

View File

@ -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))
}
}