Add core database module.
This commit is contained in:
parent
3b56d97cb4
commit
22529babc5
16 changed files with 45 additions and 57 deletions
|
|
@ -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"))
|
||||
|
|
|
|||
21
core/database/build.gradle.kts
Normal file
21
core/database/build.gradle.kts
Normal 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)
|
||||
}
|
||||
5
core/database/src/main/AndroidManifest.xml
Normal file
5
core/database/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
)
|
||||
534
core/database/src/main/java/org/signal/core/util/SqlUtil.kt
Normal file
534
core/database/src/main/java/org/signal/core/util/SqlUtil.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation project(':core:util')
|
||||
implementation project(':core:database')
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ android {
|
|||
|
||||
dependencies {
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":core:database"))
|
||||
|
||||
implementation(libs.jknack.handlebars)
|
||||
implementation(libs.nanohttpd.webserver)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue