- Adds support to delete GiftWraps by p-tag for Deletion and Vanish modules

- Adds Query plans to the test cases
- Reduces the amount of indexes that weren't used.
This commit is contained in:
Vitor Pamplona
2025-12-26 15:43:51 -05:00
parent 4455509e32
commit 273d5feb94
10 changed files with 1214 additions and 268 deletions
@@ -21,12 +21,22 @@
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.sqlite.SQLiteDatabase
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
class DeletionRequestModule : IModule {
class DeletionRequestModule(
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
) : IModule {
/**
* Creates a trigger to reject events that have been
* deleted by ID or ATag including GiftWraps that
* must be checked against the p-tag (pubkey_owner_hash)
*/
override fun create(db: SQLiteDatabase) {
// rejects deleted events.
db.execSQL(
"""
CREATE TRIGGER reject_deleted_events
@@ -36,14 +46,14 @@ class DeletionRequestModule : IModule {
-- Check for ID-based deletion record
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
WHERE EXISTS (
SELECT 1 FROM event_headers
INNER JOIN event_tags
SELECT 1 FROM event_tags
INNER JOIN event_headers
ON event_headers.row_id = event_tags.event_header_row_id
WHERE
event_headers.created_at >= NEW.created_at AND
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
event_headers.kind = 5 AND
event_headers.pubkey = NEW.pubkey AND
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash)
event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND
event_headers.created_at >= NEW.created_at
);
END;
""".trimIndent(),
@@ -60,31 +70,106 @@ class DeletionRequestModule : IModule {
) {
if (event is DeletionEvent) {
val idValues = event.deleteEventIds()
val idParams = idValues.joinToString(",") { "?" }
val addresses = event.deleteAddresses()
val addressParams = addresses.joinToString(",") { "(?, ?)" }
val addressValues = addresses.flatMap { listOf<Any>(it.kind, it.dTag) }
val whereClause =
if (idValues.isNotEmpty() && addresses.isNotEmpty()) {
"(id IN ($idParams) OR (kind, d_tag) IN ($addressParams)) AND pubkey = ?"
} else if (idValues.isNotEmpty()) {
"id IN ($idParams) AND pubkey = ?"
} else if (addresses.isNotEmpty()) {
"(kind, d_tag) IN ($addressParams) AND pubkey = ?"
} else {
return
}
val whereParams = idValues.plus(addressValues).plus(event.pubKey).toTypedArray()
db.execSQL(
"""
DELETE FROM event_headers
WHERE $whereClause;
""".trimIndent(),
whereParams,
)
deleteSQL(event.pubKey, idValues, addresses, hasher(db)).forEach { delete ->
db.execSQL(delete.sql, delete.args)
}
}
}
/**
* Creates a Delete statement that correctly deletes by id,
* by address and by replaceable (no d-tag) using each index
* appropriately, including GiftWraps where the owner is the
* p-tag (via event_header.pubkey_owner_hash)
*/
fun deleteSQL(
pubkey: HexKey,
idValues: List<String>,
addresses: List<Address>,
hasher: TagNameValueHasher,
): List<SqlArgs> {
val owner = hasher.hash(pubkey)
val idParams = idValues.joinToString(",") { "?" }
// aligns each type of param with the need to filter d-tag
// and thus each index type
val addressablesByKind = addresses.filter { it.kind.isAddressable() && it.pubKeyHex == pubkey }.groupBy { it.kind }
val replaceablesByKind = addresses.filter { it.kind.isReplaceable() && it.pubKeyHex == pubkey }.groupBy { it.kind }
val addressableParams =
addressablesByKind.keys.joinToString("\n OR\n ") {
val tagList = addressablesByKind[it]
if (tagList == null) {
""
} else if (tagList.size == 1) {
"(kind = ? AND pubkey = ? AND d_tag = ?)"
} else {
"(kind = ? AND pubkey = ? AND d_tag IN (${tagList.joinToString(",") { "?" }}))"
}
}
val addressableValues =
addressablesByKind.flatMap {
listOf<Any>(it.key.toLong(), pubkey) + it.value.map { it.dTag }
}
val replaceableKindsParam = replaceablesByKind.keys.joinToString(",") { "?" }
val replaceableKindsValues = replaceablesByKind.keys.map { it.toLong() }
val deleteById =
if (idValues.isNotEmpty()) {
SqlArgs(
"""
DELETE FROM event_headers
WHERE
id IN ($idParams) AND
pubkey_owner_hash = ?
""".trimIndent(),
idValues.plus(owner).toTypedArray(),
)
} else {
null
}
val deleteByAddress =
if (addressableValues.isNotEmpty()) {
SqlArgs(
"""
DELETE FROM event_headers
WHERE (
$addressableParams
) AND
kind >= 30000 AND kind < 40000
""".trimIndent(),
addressableValues.toTypedArray(),
)
} else {
null
}
val deleteByReplaceable =
if (replaceableKindsParam.isNotEmpty()) {
SqlArgs(
"""
DELETE FROM event_headers
WHERE
kind IN ($replaceableKindsParam) AND
pubkey = ? AND
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
""".trimIndent(),
replaceableKindsValues.plus(pubkey).toTypedArray(),
)
} else {
null
}
return listOfNotNull(deleteById, deleteByAddress, deleteByReplaceable)
}
class SqlArgs(
val sql: String,
val args: Array<Any>,
)
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.Tag
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.EventFactory
class EventIndexesModule(
@@ -46,11 +47,12 @@ class EventIndexesModule(
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL,
d_tag TEXT,
etag_hash INTEGER NOT NULL,
atag_hash INTEGER,
tags TEXT NOT NULL,
content TEXT NOT NULL,
sig TEXT NOT NULL
sig TEXT NOT NULL,
pubkey_owner_hash INTEGER NOT NULL,
etag_hash INTEGER,
atag_hash INTEGER
)
""".trimIndent(),
)
@@ -65,10 +67,12 @@ class EventIndexesModule(
""".trimIndent(),
)
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)")
db.execSQL("CREATE INDEX query_by_kind_pubkey_idx ON event_headers (created_at desc, kind, pubkey, d_tag)")
db.execSQL("CREATE INDEX query_by_id_idx ON event_headers (created_at desc, id)")
db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_hash)")
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)")
db.execSQL("CREATE INDEX query_by_kind_pubkey_dtag_idx ON event_headers (kind, pubkey, d_tag)")
db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers (created_at desc, id)")
db.execSQL("CREATE INDEX fk_event_tags_header_id ON event_tags (event_header_row_id)")
db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, event_header_row_id)")
// Prevent updates to maintain immutability
db.execSQL(
@@ -102,9 +106,9 @@ class EventIndexesModule(
val sqlInsertHeader =
"""
INSERT INTO event_headers
(id, pubkey, created_at, kind, tags, content, sig, d_tag, etag_hash, atag_hash)
(id, pubkey, created_at, kind, tags, content, sig, d_tag, pubkey_owner_hash, etag_hash, atag_hash)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent()
val sqlInsertTags =
@@ -121,22 +125,37 @@ class EventIndexesModule(
): Long {
val hasher = hasher(db)
val stmt = db.compileStatement(sqlInsertHeader)
val kindLong = event.kind.toLong()
val pubkeyHash = hasher.hash(event.pubKey)
val eventOwnerHash =
if (event is GiftWrapEvent) {
event.recipientPubKey()?.let { hasher.hash(it) } ?: pubkeyHash
} else {
pubkeyHash
}
val eTagHash = hasher.hashETag(event.id)
stmt.bindString(1, event.id)
stmt.bindString(2, event.pubKey)
stmt.bindLong(3, event.createdAt)
stmt.bindLong(4, event.kind.toLong())
stmt.bindLong(4, kindLong)
stmt.bindString(5, OptimizedJsonMapper.toJson(event.tags))
stmt.bindString(6, event.content)
stmt.bindString(7, event.sig)
if (event is AddressableEvent) {
val dTag = event.dTag()
stmt.bindString(8, dTag)
stmt.bindLong(9, hasher.hashETag(event.id))
stmt.bindLong(10, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag)))
stmt.bindLong(9, eventOwnerHash)
stmt.bindLong(10, eTagHash)
stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag)))
} else {
stmt.bindNull(8)
stmt.bindLong(9, hasher.hashETag(event.id))
stmt.bindNull(10)
stmt.bindLong(9, eventOwnerHash)
stmt.bindLong(10, eTagHash)
stmt.bindNull(11)
}
val headerId = stmt.executeInsert()
@@ -167,10 +186,17 @@ class EventIndexesModule(
fun planQuery(
filter: Filter,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) ?: return makeEverythingQuery()
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher)
return makeQueryIn(rowIdSubQuery.sql)
return if (rowIdSubQuery == null) {
val query = makeEverythingQuery()
db.explainQuery(query)
} else {
val query = makeQueryIn(rowIdSubQuery.sql)
db.explainQuery(query, rowIdSubQuery.args.toTypedArray())
}
}
fun <T : Event> query(
@@ -192,32 +218,31 @@ class EventIndexesModule(
onEach: (T) -> Unit,
) {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach)
db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach)
}
fun planQuery(
filters: List<Filter>,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher) }
if (rowIdSubQueries.isEmpty()) return makeEverythingQuery()
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
return makeQueryIn(unions)
val rowIdSubQuery = unionSubqueriesIfNeeded(filters, hasher)
return if (rowIdSubQuery == null) {
val query = makeEverythingQuery()
db.explainQuery(query)
} else {
val query = makeQueryIn(rowIdSubQuery.sql)
db.explainQuery(query, rowIdSubQuery.args.toTypedArray())
}
}
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
): List<T> {
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery())
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
val args = rowIdSubQueries.flatMap { it.args }
return db.runQuery(makeQueryIn(unions), args)
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery())
return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args)
}
fun <T : Event> query(
@@ -225,14 +250,9 @@ class EventIndexesModule(
db: SQLiteDatabase,
onEach: (T) -> Unit,
) {
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach)
if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach)
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
val args = rowIdSubQueries.flatMap { it.args }
db.runQueryEmitting(makeQueryIn(unions), args, onEach)
db.runQueryEmitting(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args, onEach)
}
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id"
@@ -240,7 +260,9 @@ class EventIndexesModule(
private fun makeQueryIn(rowIdQuery: String) =
"""
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
INNER JOIN ($rowIdQuery) AS filtered
INNER JOIN (
$rowIdQuery
) AS filtered
ON event_headers.row_id = filtered.row_id
ORDER BY created_at DESC, id
""".trimIndent()
@@ -316,14 +338,9 @@ class EventIndexesModule(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
if (rowIdSubQueries.isEmpty()) return db.countEverything()
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
val args = rowIdSubQueries.flatMap { it.args }
return db.countIn(unions, args)
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
@@ -357,14 +374,9 @@ class EventIndexesModule(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
if (rowIdSubqueries.isEmpty()) return 0
val unions = rowIdSubqueries.joinToString(" UNION ") { it.sql }
val args = rowIdSubqueries.flatMap { it.args }
return db.runDelete(unions, args)
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.runDelete(
@@ -372,6 +384,30 @@ class EventIndexesModule(
args: List<String> = emptyList(),
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
// ---------------------------------
// Prepare unions of all the filters
// ---------------------------------
fun unionSubqueriesIfNeeded(
filters: List<Filter>,
hasher: TagNameValueHasher,
): RowIdSubQuery? {
val inner =
filters.mapNotNull { filter ->
prepareRowIDSubQueries(filter, hasher)
}
if (inner.isEmpty()) return null
return if (inner.size == 1) {
inner.first()
} else {
RowIdSubQuery(
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
args = inner.flatMap { it.args },
)
}
}
// ----------------------------
// Inner row id selections
// ----------------------------
@@ -381,92 +417,105 @@ class EventIndexesModule(
): RowIdSubQuery? {
if (!filter.isFilledFilter()) return null
val mustJoinSearch = (filter.search != null)
val nonDTags = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val hasHeaders =
with(filter) {
(ids != null && ids.isNotEmpty()) ||
(ids != null) ||
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.containsKey("d")) ||
(since != null) ||
(until != null) ||
(tags != null && tags.containsKey("d"))
(limit != null)
}
val hasSearch = (filter.search != null && filter.search.isNotBlank())
var defaultTagKey: String? = null
val projection =
buildString {
val joins = mutableListOf<String>()
// always do tags if there are any
if (nonDTags.isNotEmpty()) {
append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ")
if (hasHeaders) {
append("SELECT event_headers.row_id as row_id FROM event_headers")
if (hasSearch) {
joins.add("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_headers.row_id")
}
filter.tags?.forEach { (tagName, _) ->
if (tagName != "d") {
joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = event_headers.row_id")
// it's quite rare to have 2 tags in the filter, but possible
nonDTags.keys.forEachIndexed { index, tagName ->
if (index > 0) {
append("INNER JOIN event_tags as event_tags$tagName ON event_tags$tagName.event_header_row_id = event_tags.event_header_row_id ")
} else {
defaultTagKey = tagName
}
}
} else if (hasSearch) {
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}")
filter.tags?.forEach { (tagName, _) ->
if (tagName != "d") {
joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
}
if (hasHeaders) {
append("INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id ")
}
if (mustJoinSearch) {
append("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id ")
}
} else if (mustJoinSearch) {
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName} ")
if (hasHeaders) {
append("INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
}
} else {
// has only tags
filter.tags?.forEach { (tagName, _) ->
if (tagName != "d") {
if (isEmpty()) {
append("SELECT tag$tagName.event_header_row_id as row_id FROM event_tags as tag$tagName")
} else {
joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = tag${tagName.takeLast(1)}.event_header_row_id")
}
}
}
if (isEmpty()) {
// only limit is present
append("SELECT event_headers.row_id as row_id FROM event_headers")
}
}
if (joins.isNotEmpty()) {
append(" ${joins.joinToString(" ")}")
// no tags and no search.
append("SELECT event_headers.row_id as row_id FROM event_headers ")
}
}
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
filter.ids?.let { equalsOrIn("event_headers.id", it) }
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
// range search is bad but most of the time these are up the top with few elements.
filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
// there are indexes for these, starting with tags.
nonDTags.forEach { (tagName, tagValues) ->
val column =
if (defaultTagKey == null || defaultTagKey == tagName) {
"event_tags.tag_hash"
} else {
"event_tags$tagName.tag_hash"
}
equalsOrIn(
column,
tagValues.map {
hasher.hash(tagName, it)
},
)
}
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
// there are indexes for these, starting with tags.
filter.tags?.forEach { (tagName, tagValues) ->
if (tagName == "d") {
equalsOrIn("event_headers.d_tag", tagValues)
} else {
equalsOrIn(
"tag$tagName.tag_hash",
tagValues.map {
hasher.hash(tagName, it)
},
)
}
}
filter.search?.let { match(fts.tableName, it) }
// if search is included, SQLLite will always start here.
filter.search?.let {
if (it.isNotBlank()) {
match(fts.tableName, it)
}
}
}
val whereClause =
if (filter.limit != null) {
"${clause.conditions} ORDER BY created_at DESC, id ASC LIMIT ${filter.limit}"
"${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}"
} else {
clause.conditions
}
@@ -479,7 +528,7 @@ class EventIndexesModule(
db.execSQL("DELETE FROM event_headers")
}
class RowIdSubQuery(
data class RowIdSubQuery(
val sql: String,
val args: List<String>,
)
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.sqlite.SQLiteDatabase
fun SQLiteEventStore.explainQuery(
sql: String,
args: Array<Any> = emptyArray(),
) = readableDatabase.explainQuery(sql, args.map { it.toString() }.toTypedArray())
fun SQLiteDatabase.explainQuery(
sql: String,
args: Array<String> = emptyArray(),
): String =
rawQuery("EXPLAIN QUERY PLAN $sql", args).use { cursor ->
val treeIndex = mutableMapOf<Int, PlanNode>()
val rootNodes = mutableListOf<PlanNode>()
while (cursor.moveToNext()) {
val id = cursor.getInt(0)
val parentId = cursor.getInt(1)
val detail = cursor.getString(3)
val line = PlanNode(detail)
treeIndex[id] = line
val parent = treeIndex[parentId]
if (parent != null) {
parent.children.add(line)
} else {
rootNodes.add(line)
}
}
buildString {
appendLine(populateArgs(sql, args))
for (idx in rootNodes.indices) {
printNode(rootNodes[idx], "", idx == rootNodes.size - 1, idx > 0)
}
}
}
private fun populateArgs(
sql: String,
args: Array<String> = emptyArray(),
): String {
var result = sql
args.forEach {
result = result.replaceFirst("?", "\"$it\"")
}
return result
}
fun StringBuilder.printNode(
node: PlanNode,
prefix: String,
isLast: Boolean,
newLine: Boolean,
) {
if (newLine) append('\n')
append(prefix)
append(if (isLast) "└── " else "├── ")
append(node.detail)
val newPrefix = prefix + if (isLast) " " else ""
for (i in node.children.indices) {
printNode(node.children[i], newPrefix, i == node.children.size - 1, true)
}
}
data class PlanNode(
val detail: String,
val children: MutableList<PlanNode> = mutableListOf(),
)
@@ -52,7 +52,7 @@ class SQLiteEventStore(
val addressableModule = AddressableModule()
val ephemeralModule = EphemeralModule()
val deletionModule = DeletionRequestModule()
val deletionModule = DeletionRequestModule(seedModule::hasher)
val expirationModule = ExpirationModule()
val rightToVanishModule = RightToVanishModule(seedModule::hasher)