Add core database module.

This commit is contained in:
tina-signal 2026-10-15 12:38:57 -07:00 • committed by Michelle Tang
commit 22529babc5
16 changed files with 45 additions and 57 deletions

View file

@ -687,6 +687,7 @@ dependencies {
ktlintRuleset(libs.ktlint.twitter.compose)
coreLibraryDesugaring(libs.android.tools.desugar)
implementation(project(":core:database"))
implementation(project(":core:models"))
implementation(project(":core:models-jvm"))
implementation(project(":core:serialization"))

View file

@ -0,0 +1,21 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("signal-library")
}
android {
namespace = "org.signal.core.database"
}
dependencies {
api(project(":core:util-jvm"))
api(libs.androidx.sqlite)
implementation(project(":core:util"))
testImplementation(testLibs.assertk)
testImplementation(libs.androidx.sqlite.framework)
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

@ -0,0 +1,14 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.content.ContentValues
import android.database.Cursor
/**
* Serializer specifically for working with SQLite
*/
interface DatabaseSerializer<Data> : BaseSerializer<Data, Cursor, ContentValues>

View file

@ -0,0 +1,677 @@
package org.signal.core.util
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import androidx.core.content.contentValuesOf
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteQueryBuilder
import androidx.sqlite.db.SupportSQLiteStatement
import org.signal.core.util.SqlUtil.ForeignKeyViolation
import org.signal.core.util.database.DatabaseId
import org.signal.core.util.logging.Log
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
private val TAG = "SQLiteDatabaseExtensions"
/**
* Begins a transaction on the `this` database, runs the provided [block] providing the `this` value as it's argument
* within the transaction, and then ends the transaction successfully.
*
* @return The value returned by [block] if any
*/
inline fun <T : SupportSQLiteDatabase, R> T.withinTransaction(block: (T) -> R): R {
beginTransaction()
try {
val toReturn = block(this)
if (inTransaction()) {
setTransactionSuccessful()
}
return toReturn
} finally {
if (inTransaction()) {
endTransaction()
}
}
}
/**
* Runs [block] with the FTS5 "secure-delete" option enabled on [ftsTable], guaranteeing it is turned back off afterwards.
*
* Secure delete is slow, so we keep it off by default, but can selectively enable it for smaller delete operations.
*
* https://www.sqlite.org/fts5.html#the_secure_delete_configuration_option
*/
inline fun <R> SupportSQLiteDatabase.withFtsSecureDelete(ftsTable: String, block: () -> R): R {
return this.withinTransaction {
execSQL("INSERT INTO $ftsTable ($ftsTable, rank) VALUES('secure-delete', 1)")
try {
block()
} finally {
execSQL("INSERT INTO $ftsTable ($ftsTable, rank) VALUES('secure-delete', 0)")
}
}
}
fun SupportSQLiteDatabase.getTableRowCount(table: String): Int {
return this.query("SELECT COUNT(*) FROM $table").use {
if (it.moveToFirst()) {
it.getInt(0)
} else {
0
}
}
}
fun SupportSQLiteDatabase.getAllTables(): List<String> {
return SqlUtil.getAllTables(this)
}
/**
* Returns a list of objects that represent the table definitions in the database. Basically the table name and then the SQL that was used to create it.
*/
fun SupportSQLiteDatabase.getAllTableDefinitions(): List<CreateStatement> {
return this
.select("name", "sql")
.from("sqlite_schema")
.where("type = ? AND sql NOT NULL AND name != ?", "table", "sqlite_sequence")
.run()
.readToList { cursor ->
CreateStatement(
name = cursor.requireNonNullString("name"),
statement = cursor.requireNonNullString("sql").replace(" ", "")
)
}
.filterNot { it.name.startsWith("sqlite_stat") }
.sortedBy { it.name }
}
/**
* Returns a list of objects that represent the index definitions in the database. Basically the index name and then the SQL that was used to create it.
*/
fun SupportSQLiteDatabase.getAllIndexDefinitions(): List<CreateStatement> {
return this
.select("name", "sql")
.from("sqlite_schema")
.where("type = ? AND sql NOT NULL", "index")
.run()
.readToList { cursor ->
CreateStatement(
name = cursor.requireNonNullString("name"),
statement = cursor.requireNonNullString("sql")
)
}
.sortedBy { it.name }
}
/**
* Retrieves the names of all triggers, sorted alphabetically.
*/
fun SupportSQLiteDatabase.getAllTriggerDefinitions(): List<CreateStatement> {
return this
.select("name", "sql")
.from("sqlite_schema")
.where("type = ? AND sql NOT NULL", "trigger")
.run()
.readToList {
CreateStatement(
name = it.requireNonNullString("name"),
statement = it.requireNonNullString("sql")
)
}
.sortedBy { it.name }
}
fun SupportSQLiteDatabase.getForeignKeys(): List<ForeignKeyConstraint> {
return SqlUtil.getAllTables(this)
.map { table ->
this.query("PRAGMA foreign_key_list($table)").readToList { cursor ->
ForeignKeyConstraint(
table = table,
column = cursor.requireNonNullString("from"),
dependsOnTable = cursor.requireNonNullString("table"),
dependsOnColumn = cursor.requireNonNullString("to"),
onDelete = cursor.requireString("on_delete") ?: "NOTHING"
)
}
}
.flatten()
}
fun SupportSQLiteDatabase.areForeignKeyConstraintsEnabled(): Boolean {
return this.query("PRAGMA foreign_keys", arrayOf()).use { cursor ->
cursor.moveToFirst() && cursor.getInt(0) != 0
}
}
/**
* Provides a list of all foreign key violations present.
* If a [targetTable] is specified, results will be limited to that table specifically.
* Otherwise, the check will be performed across all tables.
*/
@JvmOverloads
fun SupportSQLiteDatabase.getForeignKeyViolations(targetTable: String? = null): List<ForeignKeyViolation> {
return SqlUtil.getForeignKeyViolations(this, targetTable)
}
/**
* For tables that have an autoincrementing primary key, this will reset the key to start back at 1.
* IMPORTANT: This is quite dangerous! Only do this if you're effectively resetting the entire database.
*/
fun SupportSQLiteDatabase.resetAutoIncrementValue(targetTable: String) {
SqlUtil.resetAutoIncrementValue(this, targetTable)
}
/**
* Does a full WAL checkpoint (TRUNCATE mode, where the log is for sure flushed and the log is zero'd out).
* Will try up to [maxAttempts] times. Can technically fail if the database is too active and the checkpoint
* can't complete in a reasonable amount of time.
*
* See: https://www.sqlite.org/pragma.html#pragma_wal_checkpoint
*/
fun SupportSQLiteDatabase.fullWalCheckpoint(maxAttempts: Int = 3): Boolean {
var attempts = 0
while (attempts < maxAttempts) {
if (this.walCheckpoint()) {
return true
}
attempts++
}
return false
}
private fun SupportSQLiteDatabase.walCheckpoint(): Boolean {
return this.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
cursor.moveToFirst() && cursor.getInt(0) == 0
}
}
fun SupportSQLiteDatabase.getIndexes(): List<Index> {
return this.query("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name ASC").readToList { cursor ->
val indexName = cursor.requireNonNullString("name")
Index(
name = indexName,
table = cursor.requireNonNullString("tbl_name"),
columns = this.query("PRAGMA index_info($indexName)").readToList { it.requireNonNullString("name") }
)
}
}
fun SupportSQLiteDatabase.forceForeignKeyConstraintsEnabled(enabled: Boolean, timeout: Duration = 10.seconds) {
val startTime = System.currentTimeMillis()
while (true) {
try {
this.setForeignKeyConstraintsEnabled(enabled)
break
} catch (e: IllegalStateException) {
if (System.currentTimeMillis() - startTime > timeout.inWholeMilliseconds) {
throw IllegalStateException("Failed to force foreign keys to '$enabled' within the timeout of $timeout", e)
}
Log.w(TAG, "Failed to set foreign keys because we're in a transaction. Waiting 100ms then trying again.")
ThreadUtil.sleep(100)
}
}
}
/**
* Checks if a row exists that matches the query.
*/
fun SupportSQLiteDatabase.exists(table: String): ExistsBuilderPart1 {
return ExistsBuilderPart1(this, table)
}
/**
* Begins a SELECT statement with a helpful builder pattern.
*/
fun SupportSQLiteDatabase.select(vararg columns: String): SelectBuilderPart1 {
return SelectBuilderPart1(this, arrayOf(*columns))
}
/**
* Begins a COUNT statement with a helpful builder pattern.
*/
fun SupportSQLiteDatabase.count(): SelectBuilderPart1 {
return SelectBuilderPart1(this, SqlUtil.COUNT)
}
/**
* Begins an UPDATE statement with a helpful builder pattern.
* Requires a WHERE clause as a way of mitigating mistakes. If you'd like to update all items in the table, use [updateAll].
*/
fun SupportSQLiteDatabase.update(tableName: String): UpdateBuilderPart1 {
return UpdateBuilderPart1(this, tableName)
}
fun SupportSQLiteDatabase.updateAll(tableName: String): UpdateAllBuilderPart1 {
return UpdateAllBuilderPart1(this, tableName)
}
/**
* Begins a DELETE statement with a helpful builder pattern.
* Requires a WHERE clause as a way of mitigating mistakes. If you'd like to delete all items in the table, use [deleteAll].
*/
fun SupportSQLiteDatabase.delete(tableName: String): DeleteBuilderPart1 {
return DeleteBuilderPart1(this, tableName)
}
/**
* Deletes all data in the table.
*/
fun SupportSQLiteDatabase.deleteAll(tableName: String): Int {
return this.delete(tableName, null, arrayOfNulls<String>(0))
}
/**
* Begins an INSERT statement with a helpful builder pattern.
*/
fun SupportSQLiteDatabase.insertInto(tableName: String): InsertBuilderPart1 {
return InsertBuilderPart1(this, tableName)
}
/**
* Bind an arbitrary value to an index. It will handle calling the correct bind method based on the class type.
* @param index The index you want to bind to. Important: Indexes start at 1, not 0.
*/
fun SupportSQLiteStatement.bindValue(index: Int, value: Any?) {
when (value) {
null -> this.bindNull(index)
is DatabaseId -> this.bindString(index, value.serialize())
is Boolean -> this.bindLong(index, value.toInt().toLong())
is ByteArray -> this.bindBlob(index, value)
is Number -> {
if (value.toLong() == value || value.toInt() == value || value.toShort() == value || value.toByte() == value) {
this.bindLong(index, value.toLong())
} else {
this.bindDouble(index, value.toDouble())
}
}
else -> this.bindString(index, value.toString())
}
}
class SelectBuilderPart1(
private val db: SupportSQLiteDatabase,
private val columns: Array<String>
) {
fun from(tableName: String): SelectBuilderPart2 {
return SelectBuilderPart2(db, columns, tableName)
}
}
class SelectBuilderPart2(
private val db: SupportSQLiteDatabase,
private val columns: Array<String>,
private val tableName: String
) {
fun where(where: String, vararg whereArgs: Any): SelectBuilderPart3 {
return SelectBuilderPart3(db, columns, tableName, where, SqlUtil.buildArgs(*whereArgs))
}
fun where(where: String, whereArgs: Array<String>): SelectBuilderPart3 {
return SelectBuilderPart3(db, columns, tableName, where, whereArgs)
}
fun orderBy(orderBy: String): SelectBuilderPart4a {
return SelectBuilderPart4a(db, columns, tableName, "", arrayOf(), orderBy)
}
fun limit(limit: Int): SelectBuilderPart4b {
return SelectBuilderPart4b(db, columns, tableName, "", arrayOf(), limit.toString())
}
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder
.builder(tableName)
.columns(columns)
.create()
)
}
}
class SelectBuilderPart3(
private val db: SupportSQLiteDatabase,
private val columns: Array<String>,
private val tableName: String,
private val where: String,
private val whereArgs: Array<String>
) {
fun orderBy(orderBy: String): SelectBuilderPart4a {
return SelectBuilderPart4a(db, columns, tableName, where, whereArgs, orderBy)
}
fun limit(limit: Int): SelectBuilderPart4b {
return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, limit.toString())
}
fun limit(limit: String): SelectBuilderPart4b {
return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, limit)
}
fun limit(limit: Int, offset: Int): SelectBuilderPart4b {
return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, "$offset,$limit")
}
fun groupBy(groupBy: String): SelectBuilderPart4c {
return SelectBuilderPart4c(db, columns, tableName, where, whereArgs, groupBy)
}
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder
.builder(tableName)
.columns(columns)
.selection(where, whereArgs)
.create()
)
}
}
class SelectBuilderPart4a(
private val db: SupportSQLiteDatabase,
private val columns: Array<String>,
private val tableName: String,
private val where: String,
private val whereArgs: Array<String>,
private val orderBy: String
) {
fun limit(limit: Int): SelectBuilderPart5 {
return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit.toString())
}
fun limit(limit: String): SelectBuilderPart5 {
return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit)
}
fun limit(limit: Int, offset: Int): SelectBuilderPart5 {
return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, "$offset,$limit")
}
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder
.builder(tableName)
.columns(columns)
.selection(where, whereArgs)
.orderBy(orderBy)
.create()
)
}
}
class SelectBuilderPart4b(
private val db: SupportSQLiteDatabase,
private val columns: Array<String>,
private val tableName: String,
private val where: String,
private val whereArgs: Array<String>,
private val limit: String
) {
fun orderBy(orderBy: String): SelectBuilderPart5 {
return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit)
}
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder
.builder(tableName)
.columns(columns)
.selection(where, whereArgs)
.limit(limit)
.create()
)
}
}
class SelectBuilderPart4c(
private val db: SupportSQLiteDatabase,
private val columns: Array<String>,
private val tableName: String,
private val where: String,
private val whereArgs: Array<String>,
private val groupBy: String
) {
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder
.builder(tableName)
.columns(columns)
.selection(where, whereArgs)
.groupBy(groupBy)
.create()
)
}
}
class SelectBuilderPart5(
private val db: SupportSQLiteDatabase,
private val columns: Array<String>,
private val tableName: String,
private val where: String,
private val whereArgs: Array<String>,
private val orderBy: String,
private val limit: String
) {
fun run(): Cursor {
return db.query(
SupportSQLiteQueryBuilder
.builder(tableName)
.columns(columns)
.selection(where, whereArgs)
.orderBy(orderBy)
.limit(limit)
.create()
)
}
}
class UpdateBuilderPart1(
private val db: SupportSQLiteDatabase,
private val tableName: String
) {
fun values(values: ContentValues): UpdateBuilderPart2 {
return UpdateBuilderPart2(db, tableName, values)
}
fun values(vararg values: Pair<String, Any?>): UpdateBuilderPart2 {
return UpdateBuilderPart2(db, tableName, contentValuesOf(*values))
}
}
class UpdateBuilderPart2(
private val db: SupportSQLiteDatabase,
private val tableName: String,
private val values: ContentValues
) {
fun where(where: String, vararg whereArgs: Any): UpdateBuilderPart3 {
require(where.isNotBlank())
return UpdateBuilderPart3(db, tableName, values, where, whereArgs.toArgs())
}
fun where(where: String, whereArgs: Array<String>): UpdateBuilderPart3 {
require(where.isNotBlank())
return UpdateBuilderPart3(db, tableName, values, where, whereArgs)
}
}
class UpdateBuilderPart3(
private val db: SupportSQLiteDatabase,
private val tableName: String,
private val values: ContentValues,
private val where: String,
private val whereArgs: Array<out Any?>
) {
@JvmOverloads
fun run(): Int {
val query = StringBuilder("UPDATE $tableName SET ")
val contentValuesKeys = values.keySet()
for ((index, column) in contentValuesKeys.withIndex()) {
query.append(column).append(" = ?")
if (index < contentValuesKeys.size - 1) {
query.append(", ")
}
}
query.append(" WHERE ").append(where)
val statement = db.compileStatement(query.toString())
var bindIndex = 1
for (key in contentValuesKeys) {
statement.bindValue(bindIndex, values.get(key))
bindIndex++
}
for (arg in whereArgs) {
statement.bindValue(bindIndex, arg)
bindIndex++
}
return statement.use { it.executeUpdateDelete() }
}
}
class UpdateAllBuilderPart1(
private val db: SupportSQLiteDatabase,
private val tableName: String
) {
fun values(values: ContentValues): UpdateAllBuilderPart2 {
return UpdateAllBuilderPart2(db, tableName, values)
}
fun values(vararg values: Pair<String, Any?>): UpdateAllBuilderPart2 {
return UpdateAllBuilderPart2(db, tableName, contentValuesOf(*values))
}
}
class UpdateAllBuilderPart2(
private val db: SupportSQLiteDatabase,
private val tableName: String,
private val values: ContentValues
) {
@JvmOverloads
fun run(conflictStrategy: Int = SQLiteDatabase.CONFLICT_NONE): Int {
return db.update(tableName, conflictStrategy, values, null, emptyArray<String>())
}
}
class DeleteBuilderPart1(
private val db: SupportSQLiteDatabase,
private val tableName: String
) {
fun where(where: String, vararg whereArgs: Any): DeleteBuilderPart2 {
require(where.isNotBlank())
return DeleteBuilderPart2(db, tableName, where, SqlUtil.buildArgs(*whereArgs))
}
fun where(where: String, whereArgs: Array<String>): DeleteBuilderPart2 {
require(where.isNotBlank())
return DeleteBuilderPart2(db, tableName, where, whereArgs)
}
}
class DeleteBuilderPart2(
private val db: SupportSQLiteDatabase,
private val tableName: String,
private val where: String,
private val whereArgs: Array<String>
) {
fun run(): Int {
return db.delete(tableName, where, whereArgs)
}
}
class ExistsBuilderPart1(
private val db: SupportSQLiteDatabase,
private val tableName: String
) {
fun where(where: String, vararg whereArgs: Any): ExistsBuilderPart2 {
return ExistsBuilderPart2(db, tableName, where, SqlUtil.buildArgs(*whereArgs))
}
fun where(where: String, whereArgs: Array<String>): ExistsBuilderPart2 {
return ExistsBuilderPart2(db, tableName, where, whereArgs)
}
fun run(): Boolean {
return db.query("SELECT EXISTS(SELECT 1 FROM $tableName)", arrayOf()).use { cursor ->
cursor.moveToFirst() && cursor.getInt(0) == 1
}
}
}
class ExistsBuilderPart2(
private val db: SupportSQLiteDatabase,
private val tableName: String,
private val where: String,
private val whereArgs: Array<String>
) {
fun run(): Boolean {
return db.query("SELECT EXISTS(SELECT 1 FROM $tableName WHERE $where)", SqlUtil.buildArgs(*whereArgs)).use { cursor ->
cursor.moveToFirst() && cursor.getInt(0) == 1
}
}
}
class InsertBuilderPart1(
private val db: SupportSQLiteDatabase,
private val tableName: String
) {
fun values(values: ContentValues): InsertBuilderPart2 {
return InsertBuilderPart2(db, tableName, values)
}
fun values(vararg values: Pair<String, Any?>): InsertBuilderPart2 {
return InsertBuilderPart2(db, tableName, contentValuesOf(*values))
}
}
class InsertBuilderPart2(
private val db: SupportSQLiteDatabase,
private val tableName: String,
private val values: ContentValues
) {
fun run(conflictStrategy: Int = SQLiteDatabase.CONFLICT_IGNORE): Long {
return db.insert(tableName, conflictStrategy, values)
}
}
/**
* Helper function to massage passed-in arguments into a better form to give to the database.
*/
private fun Array<out Any?>.toArgs(): Array<Any?> {
return this
.map {
when (it) {
is DatabaseId -> it.serialize()
else -> it
}
}
.toTypedArray()
}
data class ForeignKeyConstraint(
val table: String,
val column: String,
val dependsOnTable: String,
val dependsOnColumn: String,
val onDelete: String
)
data class Index(
val name: String,
val table: String,
val columns: List<String>
)
data class CreateStatement(
val name: String,
val statement: String
)

View file

@ -0,0 +1,534 @@
package org.signal.core.util
import android.content.ContentValues
import android.text.TextUtils
import androidx.annotation.VisibleForTesting
import androidx.sqlite.db.SupportSQLiteDatabase
import org.signal.core.util.database.DatabaseId
import org.signal.core.util.logging.Log
import java.lang.Exception
import java.util.LinkedList
import java.util.Locale
import java.util.stream.Collectors
object SqlUtil {
private val TAG = Log.tag(SqlUtil::class.java)
/** The maximum number of arguments (i.e. question marks) allowed in a SQL statement. */
const val MAX_QUERY_ARGS = 999
@JvmField
val COUNT = arrayOf("COUNT(*)")
@JvmStatic
fun tableExists(db: SupportSQLiteDatabase, table: String): Boolean {
db.query("SELECT name FROM sqlite_master WHERE type=? AND name=?", arrayOf("table", table)).use { cursor ->
return cursor != null && cursor.moveToNext()
}
}
@JvmStatic
fun getAllTables(db: SupportSQLiteDatabase): List<String> {
val tables: MutableList<String> = LinkedList()
db.query("SELECT name FROM sqlite_master WHERE type=?", arrayOf("table")).use { cursor ->
while (cursor.moveToNext()) {
tables.add(cursor.getString(0))
}
}
return tables
}
/**
* Returns the total number of changes that have been made since the creation of this database connection.
*
* IMPORTANT: Due to how connection pooling is handled in the app, the only way to have this return useful numbers is to call it within a transaction.
*/
fun getTotalChanges(db: SupportSQLiteDatabase): Long {
return db.query("SELECT total_changes()", arrayOf()).readToSingleLong()
}
@JvmStatic
fun getAllTriggers(db: SupportSQLiteDatabase): List<String> {
val tables: MutableList<String> = LinkedList()
db.query("SELECT name FROM sqlite_master WHERE type=?", arrayOf("trigger")).use { cursor ->
while (cursor.moveToNext()) {
tables.add(cursor.getString(0))
}
}
return tables
}
@JvmStatic
fun getNextAutoIncrementId(db: SupportSQLiteDatabase, table: String): Long {
db.query("SELECT * FROM sqlite_sequence WHERE name = ?", arrayOf(table)).use { cursor ->
if (cursor.moveToFirst()) {
val current = cursor.requireLong("seq")
return current + 1
} else if (db.query("SELECT COUNT(*) FROM $table").readToSingleLong(defaultValue = 0) == 0L) {
Log.w(TAG, "No entries exist in $table. Returning 1.")
return 1
} else if (columnExists(db, table, "_id")) {
Log.w(TAG, "There are entries in $table, but we couldn't get the auto-incrementing id? Using the max _id in the table.")
val current = db.query("SELECT MAX(_id) FROM $table").readToSingleLong(defaultValue = 0)
return current + 1
} else {
Log.w(TAG, "No autoincrement _id, non-empty table, no _id column!")
throw IllegalArgumentException("Table must have an auto-incrementing primary key!")
}
}
}
/**
* Given a table, this will return a set of tables that it has a foreign key dependency on.
*/
@JvmStatic
fun getForeignKeyDependencies(db: SupportSQLiteDatabase, table: String): Set<String> {
return db.query("PRAGMA foreign_key_list($table)")
.readToSet { cursor ->
cursor.requireNonNullString("table")
}
}
/**
* Provides a list of all foreign key violations present.
* If a [targetTable] is specified, results will be limited to that table specifically.
* Otherwise, the check will be performed across all tables.
*/
@JvmStatic
@JvmOverloads
fun getForeignKeyViolations(db: SupportSQLiteDatabase, targetTable: String? = null): List<ForeignKeyViolation> {
val tableString = if (targetTable != null) "($targetTable)" else ""
return db.query("PRAGMA foreign_key_check$tableString").readToList { cursor ->
val table = cursor.requireNonNullString("table")
ForeignKeyViolation(
table = table,
violatingRowId = cursor.requireLongOrNull("rowid"),
dependsOnTable = cursor.requireNonNullString("parent"),
column = getForeignKeyViolationColumn(db, table, cursor.requireLong("fkid"))
)
}
}
/**
* For tables that have an autoincrementing primary key, this will reset the key to start back at 1.
* IMPORTANT: This is quite dangerous! Only do this if you're effectively resetting the entire database.
*/
@JvmStatic
fun resetAutoIncrementValue(db: SupportSQLiteDatabase, targetTable: String) {
db.execSQL("DELETE FROM sqlite_sequence WHERE name=?", arrayOf(targetTable))
}
@JvmStatic
fun isEmpty(db: SupportSQLiteDatabase, table: String): Boolean {
db.query("SELECT COUNT(*) FROM $table", arrayOf()).use { cursor ->
return if (cursor.moveToFirst()) {
cursor.getInt(0) == 0
} else {
true
}
}
}
@JvmStatic
fun columnExists(db: SupportSQLiteDatabase, table: String, column: String): Boolean {
db.query("PRAGMA table_info($table)", arrayOf()).use { cursor ->
val nameColumnIndex = cursor.getColumnIndexOrThrow("name")
while (cursor.moveToNext()) {
val name = cursor.getString(nameColumnIndex)
if (name == column) {
return true
}
}
}
return false
}
@JvmStatic
fun getAllColumns(db: SupportSQLiteDatabase, table: String): Set<String> {
val columns = mutableSetOf<String>()
db.query("PRAGMA table_info($table)", arrayOf()).use { cursor ->
val nameColumnIndex = cursor.getColumnIndexOrThrow("name")
while (cursor.moveToNext()) {
columns += cursor.getString(nameColumnIndex)
}
}
return columns
}
@JvmStatic
fun buildArgs(vararg objects: Any?): Array<String> {
return objects.map {
when (it) {
null -> throw NullPointerException("Cannot have null arg!")
is DatabaseId -> it.serialize()
else -> it.toString()
}
}.toTypedArray()
}
@JvmStatic
fun buildArgs(objects: Collection<Any?>): Array<String> {
return objects.map {
when (it) {
null -> throw NullPointerException("Cannot have null arg!")
is DatabaseId -> it.serialize()
else -> it.toString()
}
}.toTypedArray()
}
@JvmStatic
fun buildArgs(argument: Long): Array<String> {
return arrayOf(argument.toString())
}
/**
* Builds a case-insensitive GLOB pattern for fuzzy text queries. Works with all unicode
* characters.
*
* Ex:
* cat -> [cC][aA][tT]
*/
@JvmStatic
fun buildCaseInsensitiveGlobPattern(query: String): String {
if (TextUtils.isEmpty(query)) {
return "*"
}
val pattern = StringBuilder()
var i = 0
val len = query.codePointCount(0, query.length)
while (i < len) {
val point = StringUtil.codePointToString(query.codePointAt(i))
pattern.append("[")
pattern.append(point.lowercase(Locale.getDefault()))
pattern.append(point.uppercase(Locale.getDefault()))
pattern.append(getAccentuatedCharRegex(point.lowercase(Locale.getDefault())))
pattern.append("]")
i++
}
return "*$pattern*"
}
private fun getAccentuatedCharRegex(query: String): String {
return when (query) {
"a" -> "À-Åà-åĀ-ąǍǎǞ-ǡǺ-ǻȀ-ȃȦȧȺɐ-ɒḀḁẚẠ-ặ"
"b" -> "ßƀ-ƅɃɓḂ-ḇ"
"c" -> "çÇĆ-čƆ-ƈȻȼɔḈḉ"
"d" -> "ÐðĎ-đƉ-ƍȡɖɗḊ-ḓ"
"e" -> "È-Ëè-ëĒ-ěƎ-ƐǝȄ-ȇȨȩɆɇɘ-ɞḔ-ḝẸ-ệ"
"f" -> "ƑƒḞḟ"
"g" -> "Ĝ-ģƓǤ-ǧǴǵḠḡ"
"h" -> "Ĥ-ħƕǶȞȟḢ-ḫẖ"
"i" -> "Ì-Ïì-ïĨ-ıƖƗǏǐȈ-ȋɨɪḬ-ḯỈ-ị"
"j" -> "ĴĵǰȷɈɉɟ"
"k" -> "Ķ-ĸƘƙǨǩḰ-ḵ"
"l" -> "Ĺ-łƚȴȽɫ-ɭḶ-ḽ"
"m" -> "Ɯɯ-ɱḾ-ṃ"
"n" -> "ÑñŃ-ŋƝƞǸǹȠȵɲ-ɴṄ-ṋ"
"o" -> "Ò-ÖØò-öøŌ-őƟ-ơǑǒǪ-ǭǾǿȌ-ȏȪ-ȱṌ-ṓỌ-ợ"
"p" -> "ƤƥṔ-ṗ"
"q" -> ""
"r" -> "Ŕ-řƦȐ-ȓɌɍṘ-ṟ"
"s" -> "Ś-šƧƨȘșȿṠ-ṩ"
"t" -> "Ţ-ŧƫ-ƮȚțȾṪ-ṱẗ"
"u" -> "Ù-Üù-üŨ-ųƯ-ƱǓ-ǜȔ-ȗɄṲ-ṻỤ-ự"
"v" -> "ƲɅṼ-ṿ"
"w" -> "ŴŵẀ-ẉẘ"
"x" -> "Ẋ-ẍ"
"y" -> "ÝýÿŶ-ŸƔƳƴȲȳɎɏẎẏỲ-ỹỾỿẙ"
"z" -> "Ź-žƵƶɀẐ-ẕ"
"α" -> "\u0386\u0391\u03AC\u03B1\u1F00-\u1F0F\u1F70\u1F71\u1F80-\u1F8F\u1FB0-\u1FB4\u1FB6-\u1FBC"
"ε" -> "\u0388\u0395\u03AD\u03B5\u1F10-\u1F15\u1F18-\u1F1D\u1F72\u1F73\u1FC8\u1FC9"
"η" -> "\u0389\u0397\u03AE\u03B7\u1F20-\u1F2F\u1F74\u1F75\u1F90-\u1F9F\u1F20-\u1F2F\u1F74\u1F75\u1F90-\u1F9F\u1fc2\u1fc3\u1fc4\u1fc6\u1FC7\u1FCA\u1FCB\u1FCC"
"ι" -> "\u038A\u0390\u0399\u03AA\u03AF\u03B9\u03CA\u1F30-\u1F3F\u1F76\u1F77\u1FD0-\u1FD3\u1FD6-\u1FDB"
"ο" -> "\u038C\u039F\u03BF\u03CC\u1F40-\u1F45\u1F48-\u1F4D\u1F78\u1F79\u1FF8\u1FF9"
"σ" -> "\u03A3\u03C2\u03C3"
"ς" -> "\u03A3\u03C2\u03C3"
"υ" -> "\u038E\u03A5\u03AB\u03C5\u03CB\u03CD\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F\u1F7A\u1F7B\u1FE0-\u1FE3\u1FE6-\u1FEB"
"ω" -> "\u038F\u03A9\u03C9\u03CE\u1F60-\u1F6F\u1F7C\u1F7D\u1FA0-\u1FAF\u1FF2-\u1FF4\u1FF6\u1FF7\u1FFA-\u1FFC"
else -> ""
}
}
/**
* Returns an updated query and args pairing that will only update rows that would *actually*
* change. In other words, if [SupportSQLiteDatabase.update]
* returns > 0, then you know something *actually* changed.
*/
@JvmStatic
fun buildTrueUpdateQuery(
selection: String,
args: Array<String>,
contentValues: ContentValues
): Query {
val qualifier = StringBuilder()
val valueSet = contentValues.valueSet()
val fullArgs: MutableList<String> = ArrayList(args.size + valueSet.size)
fullArgs.addAll(args)
var i = 0
for ((key, value) in valueSet) {
if (value != null) {
if (value is ByteArray) {
qualifier.append("hex(").append(key).append(") != ? OR ").append(key).append(" IS NULL")
fullArgs.add(Hex.toStringCondensed(value).uppercase(Locale.US))
} else {
qualifier.append(key).append(" != ? OR ").append(key).append(" IS NULL")
fullArgs.add(value.toString())
}
} else {
qualifier.append(key).append(" NOT NULL")
}
if (i != valueSet.size - 1) {
qualifier.append(" OR ")
}
i++
}
return Query("($selection) AND ($qualifier)", fullArgs.toTypedArray())
}
/**
* A convenient way of making queries in the form: WHERE [column] IN (?, ?, ..., ?)
* Handles breaking it
*/
@JvmOverloads
@JvmStatic
fun buildCollectionQuery(
column: String,
values: Collection<Any?>,
prefix: String = "",
maxSize: Int = MAX_QUERY_ARGS,
collectionOperator: CollectionOperator = CollectionOperator.IN
): List<Query> {
return if (values.isEmpty()) {
emptyList()
} else {
values
.chunked(maxSize)
.map { batch -> buildSingleCollectionQuery(column, batch, prefix, collectionOperator) }
}
}
/**
* A convenient way of making queries that are _equivalent_ to `WHERE [column] IN (?, ?, ..., ?)`
* Under the hood, it uses JSON1 functions which can both be surprisingly faster than normal (?, ?, ?) lists, as well as removes the [MAX_QUERY_ARGS] limit.
* This means chunking isn't necessary for any practical collection length.
*/
@JvmStatic
@JvmOverloads
fun buildFastCollectionQuery(
column: String,
values: Collection<Any?>,
prefix: String = ""
): Query {
require(!values.isEmpty()) { "Must have values!" }
val prefixFilter = if (prefix.isEmpty() || prefix.endsWith(" ")) prefix else "$prefix "
return Query("$prefixFilter$column IN (SELECT e.value FROM json_each(?) e)", arrayOf(jsonEncode(buildArgs(values))))
}
/**
* A convenient way of making queries in the form: WHERE [column] IN (?, ?, ..., ?)
*
* Important: Should only be used if you know the number of values is < 1000. Otherwise you risk creating a SQL statement this is too large.
* Prefer [buildCollectionQuery] when possible.
*/
@JvmOverloads
@JvmStatic
fun buildSingleCollectionQuery(
column: String,
values: Collection<Any?>,
prefix: String = "",
collectionOperator: CollectionOperator = CollectionOperator.IN
): Query {
require(!values.isEmpty()) { "Must have values!" }
val query = StringBuilder()
val args = arrayOfNulls<Any>(values.size)
var i = 0
for (value in values) {
query.append("?")
args[i] = value
if (i != values.size - 1) {
query.append(", ")
}
i++
}
return Query("$prefix $column ${collectionOperator.sql} ($query)".trim(), buildArgs(*args))
}
@JvmStatic
fun buildCustomCollectionQuery(query: String, argList: List<Array<String>>): List<Query> {
return buildCustomCollectionQuery(query, argList, MAX_QUERY_ARGS)
}
@JvmStatic
@VisibleForTesting
fun buildCustomCollectionQuery(query: String, argList: List<Array<String>>, maxQueryArgs: Int): List<Query> {
val batchSize: Int = maxQueryArgs / argList[0].size
return ListUtil.chunk(argList, batchSize)
.stream()
.map { argBatch -> buildSingleCustomCollectionQuery(query, argBatch) }
.collect(Collectors.toList())
}
private fun buildSingleCustomCollectionQuery(query: String, argList: List<Array<String>>): Query {
val outputQuery = StringBuilder()
val outputArgs: MutableList<String> = mutableListOf()
var i = 0
val len = argList.size
while (i < len) {
outputQuery.append("(").append(query).append(")")
if (i < len - 1) {
outputQuery.append(" OR ")
}
val args = argList[i]
for (arg in args) {
outputArgs += arg
}
i++
}
return Query(outputQuery.toString(), outputArgs.toTypedArray())
}
@JvmStatic
fun buildQuery(where: String, vararg args: Any): Query {
return Query(where, buildArgs(*args))
}
@JvmStatic
fun appendArg(args: Array<String>, addition: String): Array<String> {
return args.toMutableList().apply {
add(addition)
}.toTypedArray()
}
@JvmStatic
fun appendArgs(args: Array<String>, vararg objects: Any?): Array<String> {
return args + buildArgs(objects)
}
@JvmStatic
fun buildBulkInsert(tableName: String, columns: Array<String>, contentValues: List<ContentValues>, onConflict: String? = null): List<Query> {
return buildBulkInsert(tableName, columns, contentValues, MAX_QUERY_ARGS)
}
@JvmStatic
@VisibleForTesting
fun buildBulkInsert(tableName: String, columns: Array<String>, contentValues: List<ContentValues>, maxQueryArgs: Int, onConflict: String? = null): List<Query> {
val batchSize = maxQueryArgs / columns.size
return contentValues
.chunked(batchSize)
.map { batch: List<ContentValues> -> buildSingleBulkInsert(tableName, columns, batch) }
.toList()
}
fun buildSingleBulkInsert(tableName: String, columns: Array<String>, contentValues: List<ContentValues>, onConflict: String? = null): Query {
val conflictString = onConflict?.let { " OR $onConflict" } ?: ""
val builder = StringBuilder()
builder.append("INSERT$conflictString INTO ").append(tableName).append(" (")
val columnString = columns.joinToString(separator = ", ")
builder.append(columnString)
builder.append(") VALUES ")
val placeholders = contentValues
.map { values ->
columns
.map { column ->
if (values[column] != null) {
if (values[column] is ByteArray) {
"X'${Hex.toStringCondensed(values[column] as ByteArray).uppercase()}'"
} else {
"?"
}
} else {
"null"
}
}
.joinToString(separator = ", ", prefix = "(", postfix = ")")
}
.joinToString(separator = ", ")
builder.append(placeholders)
val query = builder.toString()
val args: MutableList<String> = mutableListOf()
for (values in contentValues) {
for (column in columns) {
val value = values[column]
if (value != null && value !is ByteArray) {
args += value.toString()
}
}
}
return Query(query, args.toTypedArray())
}
/** Helper that gets the specific column for a foreign key violation */
private fun getForeignKeyViolationColumn(db: SupportSQLiteDatabase, table: String, id: Long): String? {
try {
db.query("PRAGMA foreign_key_list($table)").forEach { cursor ->
if (cursor.requireLong("id") == id) {
return cursor.requireString("from")
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to find violation details for id: $id")
}
return null
}
/** Simple encoding of a string array as a json array */
private fun jsonEncode(strings: Array<String>): String {
return strings.joinToString(prefix = "[", postfix = "]", separator = ",") { "\"$it\"" }
}
class Query(val where: String, val whereArgs: Array<String>) {
infix fun and(other: Query): Query {
return if (where.isNotEmpty() && other.where.isNotEmpty()) {
Query("($where) AND (${other.where})", whereArgs + other.whereArgs)
} else if (where.isNotEmpty()) {
this
} else {
other
}
}
}
data class ForeignKeyViolation(
/** The table that declared the REFERENCES clause. */
val table: String,
/** The rowId of the message in [table] that violates the constraint. Will not be present if the table has now rowId. */
val violatingRowId: Long?,
/** The table that [table] has a dependency on. */
val dependsOnTable: String,
/** The column from [table] that has the constraint. A separate query needs to be made to get this, so it's best-effor. */
val column: String?
)
enum class CollectionOperator(val sql: String) {
IN("IN"),
NOT_IN("NOT IN")
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
/**
* Helper to create an in-memory database used for testing SQLite stuff.
*/
object InMemorySqliteOpenHelper {
fun create(
onCreate: (db: SupportSQLiteDatabase) -> Unit,
onUpgrade: (db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit = { _, _, _ -> }
): SupportSQLiteOpenHelper {
val configuration = SupportSQLiteOpenHelper.Configuration(
context = ApplicationProvider.getApplicationContext(),
name = "test",
callback = object : SupportSQLiteOpenHelper.Callback(1) {
override fun onCreate(db: SupportSQLiteDatabase) = onCreate(db)
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = onUpgrade(db, oldVersion, newVersion)
},
useNoBackupDirectory = false,
allowDataLossOnRecovery = true
)
return FrameworkSQLiteOpenHelperFactory().create(configuration)
}
}

View file

@ -0,0 +1,124 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import android.app.Application
import androidx.sqlite.db.SupportSQLiteOpenHelper
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotNull
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class SQLiteDatabaseExtensionsTest {
lateinit var db: SupportSQLiteOpenHelper
companion object {
const val TABLE_NAME = "test"
const val ID = "_id"
const val STRING_COLUMN = "string_column"
const val LONG_COLUMN = "long_column"
const val DOUBLE_COLUMN = "double_column"
const val BLOB_COLUMN = "blob_column"
}
@Before
fun setup() {
db = InMemorySqliteOpenHelper.create(
onCreate = { db ->
db.execSQL("CREATE TABLE $TABLE_NAME ($ID INTEGER PRIMARY KEY AUTOINCREMENT, $STRING_COLUMN TEXT, $LONG_COLUMN INTEGER, $DOUBLE_COLUMN DOUBLE, $BLOB_COLUMN BLOB)")
}
)
db.writableDatabase.insertInto(TABLE_NAME)
.values(
STRING_COLUMN to "asdf",
LONG_COLUMN to 1,
DOUBLE_COLUMN to 0.5f,
BLOB_COLUMN to byteArrayOf(1, 2, 3)
)
.run()
}
@After
fun cleanUp() {
db.close()
}
@Test
fun `update - content values work`() {
val updateCount: Int = db.writableDatabase
.update("test")
.values(
STRING_COLUMN to "asdf2",
LONG_COLUMN to 2,
DOUBLE_COLUMN to 1.5f,
BLOB_COLUMN to byteArrayOf(4, 5, 6)
)
.where("$ID = ?", 1)
.run()
val record = readRecord(1)
assertThat(updateCount).isEqualTo(1)
assertThat(record).isNotNull()
assertThat(record!!.id).isEqualTo(1)
assertThat(record.stringColumn).isEqualTo("asdf2")
assertThat(record.longColumn).isEqualTo(2)
assertThat(record.doubleColumn).isEqualTo(1.5f)
assertArrayEquals(record.blobColumn, byteArrayOf(4, 5, 6))
}
@Test
fun `update - querying by blob works`() {
val updateCount: Int = db.writableDatabase
.update("test")
.values(
STRING_COLUMN to "asdf2"
)
.where("$BLOB_COLUMN = ?", byteArrayOf(1, 2, 3))
.run()
val record = readRecord(1)
assertThat(updateCount).isEqualTo(1)
assertThat(record).isNotNull()
assertThat(record!!.stringColumn).isEqualTo("asdf2")
}
private fun readRecord(id: Long): TestRecord? {
return db.readableDatabase
.select()
.from(TABLE_NAME)
.where("$ID = ?", id)
.run()
.readToSingleObject {
TestRecord(
id = it.requireLong(ID),
stringColumn = it.requireString(STRING_COLUMN),
longColumn = it.requireLong(LONG_COLUMN),
doubleColumn = it.requireFloat(DOUBLE_COLUMN),
blobColumn = it.requireBlob(BLOB_COLUMN)
)
}
}
class TestRecord(
val id: Long,
val stringColumn: String?,
val longColumn: Long,
val doubleColumn: Float,
val blobColumn: ByteArray?
)
}

View file

@ -0,0 +1,374 @@
package org.signal.core.util;
import android.app.Application;
import android.content.ContentValues;
import androidx.annotation.NonNull;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.signal.core.util.database.DatabaseId;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, application = Application.class)
public final class SqlUtilTest {
@Test
public void buildTrueUpdateQuery_simple() {
String selection = "_id = ?";
String[] args = new String[]{"1"};
ContentValues values = new ContentValues();
values.put("a", 2);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
assertEquals("(_id = ?) AND (a != ? OR a IS NULL)", updateQuery.getWhere());
assertArrayEquals(new String[] { "1", "2" }, updateQuery.getWhereArgs());
}
@Test
public void buildTrueUpdateQuery_complexSelection() {
String selection = "_id = ? AND (foo = ? OR bar != ?)";
String[] args = new String[]{"1", "2", "3"};
ContentValues values = new ContentValues();
values.put("a", 4);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
assertEquals("(_id = ? AND (foo = ? OR bar != ?)) AND (a != ? OR a IS NULL)", updateQuery.getWhere());
assertArrayEquals(new String[] { "1", "2", "3", "4" }, updateQuery.getWhereArgs());
}
@Test
public void buildTrueUpdateQuery_multipleContentValues() {
String selection = "_id = ?";
String[] args = new String[]{"1"};
ContentValues values = new ContentValues();
values.put("a", 2);
values.put("b", 3);
values.put("c", 4);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
assertEquals("(_id = ?) AND (a != ? OR a IS NULL OR b != ? OR b IS NULL OR c != ? OR c IS NULL)", updateQuery.getWhere());
assertArrayEquals(new String[] { "1", "2", "3", "4"}, updateQuery.getWhereArgs());
}
@Test
public void buildTrueUpdateQuery_nullContentValue() {
String selection = "_id = ?";
String[] args = new String[]{"1"};
ContentValues values = new ContentValues();
values.put("a", (String) null);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
assertEquals("(_id = ?) AND (a NOT NULL)", updateQuery.getWhere());
assertArrayEquals(new String[] { "1" }, updateQuery.getWhereArgs());
}
@Test
public void buildTrueUpdateQuery_complexContentValue() {
String selection = "_id = ?";
String[] args = new String[]{"1"};
ContentValues values = new ContentValues();
values.put("a", (String) null);
values.put("b", 2);
values.put("c", 3);
values.put("d", (String) null);
values.put("e", (String) null);
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
assertEquals("(_id = ?) AND (a NOT NULL OR b != ? OR b IS NULL OR c != ? OR c IS NULL OR d NOT NULL OR e NOT NULL)", updateQuery.getWhere());
assertArrayEquals(new String[] { "1", "2", "3" }, updateQuery.getWhereArgs());
}
@Test
public void buildTrueUpdateQuery_blobComplex() {
String selection = "_id = ?";
String[] args = new String[]{"1"};
ContentValues values = new ContentValues();
values.put("a", hexToBytes("FF"));
values.put("b", 2);
values.putNull("c");
SqlUtil.Query updateQuery = SqlUtil.buildTrueUpdateQuery(selection, args, values);
assertEquals("(_id = ?) AND (hex(a) != ? OR a IS NULL OR b != ? OR b IS NULL OR c NOT NULL)", updateQuery.getWhere());
assertArrayEquals(new String[] { "1", "FF", "2" }, updateQuery.getWhereArgs());
}
@Test
public void buildCollectionQuery_single() {
List<SqlUtil.Query> updateQuery = SqlUtil.buildCollectionQuery("a", Arrays.asList(1));
assertEquals(1, updateQuery.size());
assertEquals("a IN (?)", updateQuery.get(0).getWhere());
assertArrayEquals(new String[] { "1" }, updateQuery.get(0).getWhereArgs());
}
@Test
public void buildCollectionQuery_single_withPrefix() {
List<SqlUtil.Query> updateQuery = SqlUtil.buildCollectionQuery("a", Arrays.asList(1), "b = 1 AND");
assertEquals(1, updateQuery.size());
assertEquals("b = 1 AND a IN (?)", updateQuery.get(0).getWhere());
assertArrayEquals(new String[] { "1" }, updateQuery.get(0).getWhereArgs());
}
@Test
public void buildCollectionQuery_multiple() {
List<SqlUtil.Query> updateQuery = SqlUtil.buildCollectionQuery("a", Arrays.asList(1, 2, 3));
assertEquals(1, updateQuery.size());
assertEquals("a IN (?, ?, ?)", updateQuery.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2", "3" }, updateQuery.get(0).getWhereArgs());
}
@Test
public void buildCollectionQuery_multiple_twoBatches() {
List<SqlUtil.Query> updateQuery = SqlUtil.buildCollectionQuery("a", Arrays.asList(1, 2, 3), "", 2);
assertEquals(2, updateQuery.size());
assertEquals("a IN (?, ?)", updateQuery.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2" }, updateQuery.get(0).getWhereArgs());
assertEquals("a IN (?)", updateQuery.get(1).getWhere());
assertArrayEquals(new String[] { "3" }, updateQuery.get(1).getWhereArgs());
}
@Test
public void buildCollectionQuery_multipleRecipientIds() {
List<SqlUtil.Query> updateQuery = SqlUtil.buildCollectionQuery("a", Arrays.asList(new TestId(1), new TestId(2), new TestId(3)));
assertEquals(1, updateQuery.size());
assertEquals("a IN (?, ?, ?)", updateQuery.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2", "3" }, updateQuery.get(0).getWhereArgs());
}
public void buildCollectionQuery_none() {
List<SqlUtil.Query> results = SqlUtil.buildCollectionQuery("a", Collections.emptyList());
assertTrue(results.isEmpty());
}
@Test
public void buildFastCollectionQuery_single() {
SqlUtil.Query updateQuery = SqlUtil.buildFastCollectionQuery("a", Arrays.asList(1));
assertEquals("a IN (SELECT e.value FROM json_each(?) e)", updateQuery.getWhere());
assertArrayEquals(new String[] { "[\"1\"]" }, updateQuery.getWhereArgs());
}
@Test
public void buildFastCollectionQuery_multiple() {
SqlUtil.Query updateQuery = SqlUtil.buildFastCollectionQuery("a", Arrays.asList(1, 2, 3));
assertEquals("a IN (SELECT e.value FROM json_each(?) e)", updateQuery.getWhere());
assertArrayEquals(new String[] { "[\"1\",\"2\",\"3\"]" }, updateQuery.getWhereArgs());
}
@Test
public void buildFastCollectionQuery_withPrefix() {
SqlUtil.Query updateQuery = SqlUtil.buildFastCollectionQuery("a", Arrays.asList(1), "b = 1 AND");
assertEquals("b = 1 AND a IN (SELECT e.value FROM json_each(?) e)", updateQuery.getWhere());
assertArrayEquals(new String[] { "[\"1\"]" }, updateQuery.getWhereArgs());
}
@Test
public void buildFastCollectionQuery_withPrefixTrailingSpace() {
SqlUtil.Query updateQuery = SqlUtil.buildFastCollectionQuery("a", Arrays.asList(1), "b = 1 AND ");
assertEquals("b = 1 AND a IN (SELECT e.value FROM json_each(?) e)", updateQuery.getWhere());
assertArrayEquals(new String[] { "[\"1\"]" }, updateQuery.getWhereArgs());
}
@Test
public void buildFastCollectionQuery_emptyPrefix() {
SqlUtil.Query updateQuery = SqlUtil.buildFastCollectionQuery("a", Arrays.asList(1), "");
assertEquals("a IN (SELECT e.value FROM json_each(?) e)", updateQuery.getWhere());
assertArrayEquals(new String[] { "[\"1\"]" }, updateQuery.getWhereArgs());
}
@Test
public void buildCustomCollectionQuery_single_singleBatch() {
List<String[]> args = new ArrayList<>();
args.add(SqlUtil.buildArgs(1, 2));
List<SqlUtil.Query> queries = SqlUtil.buildCustomCollectionQuery("a = ? AND b = ?", args);
assertEquals(1, queries.size());
assertEquals("(a = ? AND b = ?)", queries.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2" }, queries.get(0).getWhereArgs());
}
@Test
public void buildCustomCollectionQuery_multiple_singleBatch() {
List<String[]> args = new ArrayList<>();
args.add(SqlUtil.buildArgs(1, 2));
args.add(SqlUtil.buildArgs(3, 4));
args.add(SqlUtil.buildArgs(5, 6));
List<SqlUtil.Query> queries = SqlUtil.buildCustomCollectionQuery("a = ? AND b = ?", args);
assertEquals(1, queries.size());
assertEquals("(a = ? AND b = ?) OR (a = ? AND b = ?) OR (a = ? AND b = ?)", queries.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2", "3", "4", "5", "6" }, queries.get(0).getWhereArgs());
}
@Test
public void buildCustomCollectionQuery_twoBatches() {
List<String[]> args = new ArrayList<>();
args.add(SqlUtil.buildArgs(1, 2));
args.add(SqlUtil.buildArgs(3, 4));
args.add(SqlUtil.buildArgs(5, 6));
List<SqlUtil.Query> queries = SqlUtil.buildCustomCollectionQuery("a = ? AND b = ?", args, 4);
assertEquals(2, queries.size());
assertEquals("(a = ? AND b = ?) OR (a = ? AND b = ?)", queries.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2", "3", "4" }, queries.get(0).getWhereArgs());
assertEquals("(a = ? AND b = ?)", queries.get(1).getWhere());
assertArrayEquals(new String[] { "5", "6" }, queries.get(1).getWhereArgs());
}
@Test
public void buildBulkInsert_single_singleBatch() {
List<ContentValues> contentValues = new ArrayList<>();
ContentValues cv1 = new ContentValues();
cv1.put("a", 1);
cv1.put("b", 2);
contentValues.add(cv1);
List<SqlUtil.Query> output = SqlUtil.buildBulkInsert("mytable", new String[] { "a", "b"}, contentValues, null);
assertEquals(1, output.size());
assertEquals("INSERT INTO mytable (a, b) VALUES (?, ?)", output.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2" }, output.get(0).getWhereArgs());
}
@Test
public void buildBulkInsert_single_singleBatch_containsNulls() {
List<ContentValues> contentValues = new ArrayList<>();
ContentValues cv1 = new ContentValues();
cv1.put("a", 1);
cv1.put("b", 2);
cv1.put("c", (String) null);
contentValues.add(cv1);
List<SqlUtil.Query> output = SqlUtil.buildBulkInsert("mytable", new String[] { "a", "b", "c"}, contentValues, null);
assertEquals(1, output.size());
assertEquals("INSERT INTO mytable (a, b, c) VALUES (?, ?, null)", output.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2" }, output.get(0).getWhereArgs());
}
@Test
public void buildBulkInsert_multiple_singleBatch() {
List<ContentValues> contentValues = new ArrayList<>();
ContentValues cv1 = new ContentValues();
cv1.put("a", 1);
cv1.put("b", 2);
ContentValues cv2 = new ContentValues();
cv2.put("a", 3);
cv2.put("b", 4);
contentValues.add(cv1);
contentValues.add(cv2);
List<SqlUtil.Query> output = SqlUtil.buildBulkInsert("mytable", new String[] { "a", "b"}, contentValues, null);
assertEquals(1, output.size());
assertEquals("INSERT INTO mytable (a, b) VALUES (?, ?), (?, ?)", output.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2", "3", "4" }, output.get(0).getWhereArgs());
}
@Test
public void buildBulkInsert_twoBatches() {
List<ContentValues> contentValues = new ArrayList<>();
ContentValues cv1 = new ContentValues();
cv1.put("a", 1);
cv1.put("b", 2);
ContentValues cv2 = new ContentValues();
cv2.put("a", 3);
cv2.put("b", 4);
ContentValues cv3 = new ContentValues();
cv3.put("a", 5);
cv3.put("b", 6);
contentValues.add(cv1);
contentValues.add(cv2);
contentValues.add(cv3);
List<SqlUtil.Query> output = SqlUtil.buildBulkInsert("mytable", new String[] { "a", "b"}, contentValues, 4, null);
assertEquals(2, output.size());
assertEquals("INSERT INTO mytable (a, b) VALUES (?, ?), (?, ?)", output.get(0).getWhere());
assertArrayEquals(new String[] { "1", "2", "3", "4" }, output.get(0).getWhereArgs());
assertEquals("INSERT INTO mytable (a, b) VALUES (?, ?)", output.get(1).getWhere());
assertArrayEquals(new String[] { "5", "6" }, output.get(1).getWhereArgs());
}
@Test
public void aggregateQueries() {
SqlUtil.Query q1 = SqlUtil.buildQuery("a = ?", 1);
SqlUtil.Query q2 = SqlUtil.buildQuery("b = ?", 2);
SqlUtil.Query q3 = q1.and(q2);
assertEquals("(a = ?) AND (b = ?)", q3.getWhere());
assertArrayEquals(new String[]{"1", "2"}, q3.getWhereArgs());
}
private static byte[] hexToBytes(String hex) {
try {
return Hex.fromStringCondensed(hex);
} catch (IOException e) {
throw new AssertionError(e);
}
}
private static class TestId implements DatabaseId {
private final long id;
private TestId(long id) {
this.id = id;
}
@Override
public @NonNull String serialize() {
return String.valueOf(id);
}
}
}

View file

@ -0,0 +1,33 @@
package org.signal.core.util
/**
* Generalized serializer for finer control
*/
interface BaseSerializer<Data, Input, Output> {
fun serialize(data: Data): Output
fun deserialize(input: Input): Data
}
/**
* Generic serialization interface for use with database and store operations.
*/
interface Serializer<T, R> : BaseSerializer<T, R, R>
interface StringSerializer<T> : Serializer<T, String>
interface IntSerializer<T> : Serializer<T, Int>
interface LongSerializer<T> : Serializer<T, Long>
interface ByteSerializer<T> : Serializer<T, ByteArray>
object StringStringSerializer : StringSerializer<String?> {
override fun serialize(data: String?): String {
return data ?: ""
}
override fun deserialize(data: String): String {
return data
}
}

View file

@ -12,7 +12,6 @@ android {
dependencies {
api(project(":core:util-jvm"))
implementation(libs.androidx.sqlite)
implementation(libs.androidx.documentfile)
implementation(libs.google.jsr305)
implementation(libs.androidx.lifecycle.process)
@ -22,7 +21,6 @@ dependencies {
implementation(libs.jackson.module.kotlin)
implementation(libs.google.libphonenumber)
implementation(libs.google.play.services.base)
testImplementation(libs.androidx.sqlite.framework)
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.assertk)

View file

@ -1,47 +0,0 @@
package org.signal.core.util
import androidx.sqlite.db.SupportSQLiteProgram
import androidx.sqlite.db.SupportSQLiteQuery
fun SupportSQLiteQuery.toAndroidQuery(): SqlUtil.Query {
val program = CapturingSqliteProgram(this.argCount)
this.bindTo(program)
return SqlUtil.Query(this.sql, program.args())
}
private class CapturingSqliteProgram(count: Int) : SupportSQLiteProgram {
private val args: Array<String?> = arrayOfNulls(count)
fun args(): Array<String> {
return args.filterNotNull().toTypedArray()
}
override fun close() {
}
override fun bindNull(index: Int) {
throw UnsupportedOperationException()
}
override fun bindLong(index: Int, value: Long) {
args[index - 1] = value.toString()
}
override fun bindDouble(index: Int, value: Double) {
args[index - 1] = value.toString()
}
override fun bindString(index: Int, value: String) {
args[index - 1] = value
}
override fun bindBlob(index: Int, value: ByteArray) {
throw UnsupportedOperationException()
}
override fun clearBindings() {
for (i in args.indices) {
args[i] = null
}
}
}

View file

@ -58,6 +58,7 @@ dependencies {
// Core dependencies
implementation(project(":core:ui"))
implementation(project(":core:util"))
implementation(project(":core:database"))
implementation(project(":core:models-jvm"))
implementation(project(":lib:libsignal-service"))
implementation(project(":lib:network"))

View file

@ -8,4 +8,5 @@ android {
dependencies {
implementation project(':core:util')
implementation project(':core:database')
}

View file

@ -8,6 +8,7 @@ android {
dependencies {
implementation(project(":core:util"))
implementation(project(":core:database"))
implementation(libs.jknack.handlebars)
implementation(libs.nanohttpd.webserver)

View file

@ -103,6 +103,7 @@ include(":core:models-jvm")
include(":core:network")
include(":core:ui")
include(":core:serialization")
include(":core:database")
// Lib modules
include(":lib:libsignal-service")