Merge branch 'main' into nrobi144/desktop-phase1
This commit is contained in:
+24
-9
@@ -30,30 +30,45 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
|
||||
class DeletionRequestModule(
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IModule {
|
||||
fun rejectDeletedEventsSQLTemplate(): String =
|
||||
if (indexStrategy.indexTagsWithKindAndPubkey) {
|
||||
"""
|
||||
|SELECT 1 FROM event_tags
|
||||
|WHERE
|
||||
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
|
||||
| event_tags.kind = 5 AND
|
||||
| event_tags.pubkey_hash = NEW.pubkey_owner_hash AND
|
||||
| event_tags.created_at >= NEW.created_at
|
||||
""".trimMargin()
|
||||
} else {
|
||||
"""
|
||||
|SELECT 1 FROM event_tags
|
||||
|WHERE
|
||||
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
|
||||
| event_tags.kind = 5 AND
|
||||
| event_tags.created_at >= NEW.created_at AND
|
||||
| event_tags.pubkey_hash = NEW.pubkey_owner_hash
|
||||
""".trimMargin()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ")
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_deleted_events
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- Check for ID-based deletion record
|
||||
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_tags
|
||||
INNER JOIN event_headers
|
||||
ON event_headers.row_id = event_tags.event_header_row_id
|
||||
WHERE
|
||||
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
|
||||
event_headers.kind = 5 AND
|
||||
event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND
|
||||
event_headers.created_at >= NEW.created_at
|
||||
$sql
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
|
||||
+46
-389
@@ -20,23 +20,16 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
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(
|
||||
val fts: FullTextSearchModule,
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
@@ -63,19 +56,53 @@ class EventIndexesModule(
|
||||
CREATE TABLE event_tags (
|
||||
event_header_row_id INTEGER NOT NULL,
|
||||
tag_hash INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
pubkey_hash INTEGER NOT NULL,
|
||||
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// queries by ID (load events)
|
||||
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)")
|
||||
// need to check if this is actually needed.
|
||||
db.execSQL("CREATE INDEX query_by_created_at_kind_key ON event_headers (created_at desc, kind, pubkey)")
|
||||
|
||||
val orderBy =
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
"created_at DESC, id ASC"
|
||||
} else {
|
||||
"created_at DESC"
|
||||
}
|
||||
|
||||
// queries by limit (latest records), since, until (sync all) alone without any filter by kind.. rare
|
||||
if (indexStrategy.indexEventsByCreatedAtAlone) {
|
||||
db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers ($orderBy)")
|
||||
}
|
||||
|
||||
// queries by kind only, mostly used in Global Feeds when author is not important.
|
||||
db.execSQL("CREATE INDEX query_by_kind_created ON event_headers (kind, $orderBy)")
|
||||
|
||||
// queries by kind + pubkey, but not d-tag, even if they are replaceables and addressables, by date.
|
||||
db.execSQL("CREATE INDEX query_by_kind_pubkey_created ON event_headers (kind, pubkey, $orderBy)")
|
||||
|
||||
// makes deletions on the event_header fast
|
||||
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)")
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// These next 3 are a very slow indexes (80% of the insert time goes here)
|
||||
// ---------------------------------------------------------------------------
|
||||
if (indexStrategy.indexTagsByCreatedAtAlone) {
|
||||
// First one is only needed if the user is searching by tags without a kind.
|
||||
db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
|
||||
}
|
||||
|
||||
// This is the default index for most clients: tags by specific kinds that are supported by the client.
|
||||
db.execSQL("CREATE INDEX query_by_tags_hash_kind ON event_tags (tag_hash, kind, created_at DESC)")
|
||||
|
||||
// this one is to allow search of tags by kind and author at the same time: NIP-04 DMs, reports,
|
||||
if (indexStrategy.indexTagsWithKindAndPubkey) {
|
||||
db.execSQL("CREATE INDEX query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
|
||||
}
|
||||
|
||||
// Prevent updates to maintain immutability
|
||||
db.execSQL(
|
||||
@@ -117,9 +144,9 @@ class EventIndexesModule(
|
||||
val sqlInsertTags =
|
||||
"""
|
||||
INSERT OR ROLLBACK INTO event_tags
|
||||
(event_header_row_id, tag_hash)
|
||||
(event_header_row_id, tag_hash, created_at, kind, pubkey_hash)
|
||||
VALUES
|
||||
(?,?)
|
||||
(?,?,?,?,?)
|
||||
""".trimIndent()
|
||||
|
||||
fun insert(
|
||||
@@ -169,7 +196,7 @@ class EventIndexesModule(
|
||||
// rebalancing the tree every new insert
|
||||
val indexableTags = ArrayList<Long>()
|
||||
for (idx in event.tags.indices) {
|
||||
if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) {
|
||||
if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) {
|
||||
indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1]))
|
||||
}
|
||||
}
|
||||
@@ -177,387 +204,17 @@ class EventIndexesModule(
|
||||
indexableTags.forEach {
|
||||
stmtTags.bindLong(1, headerId)
|
||||
stmtTags.bindLong(2, it)
|
||||
stmtTags.bindLong(3, event.createdAt)
|
||||
stmtTags.bindLong(4, kindLong)
|
||||
stmtTags.bindLong(5, pubkeyHash)
|
||||
stmtTags.executeInsert()
|
||||
}
|
||||
|
||||
return headerId
|
||||
}
|
||||
|
||||
fun planQuery(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
db: SQLiteDatabase,
|
||||
): String {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, 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(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): List<T> {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdSubQuery == null) {
|
||||
db.runQuery(makeEverythingQuery())
|
||||
} else {
|
||||
db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdSubQuery == null) {
|
||||
db.runQuery(makeEverythingQuery(), onEach = onEach)
|
||||
} else {
|
||||
db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach)
|
||||
}
|
||||
}
|
||||
|
||||
fun planQuery(
|
||||
filters: List<Filter>,
|
||||
hasher: TagNameValueHasher,
|
||||
db: SQLiteDatabase,
|
||||
): String {
|
||||
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 = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery())
|
||||
return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db))
|
||||
|
||||
if (rowIdSubqueries == null) {
|
||||
db.runQuery(makeEverythingQuery(), onEach = onEach)
|
||||
} else {
|
||||
db.runQuery(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"
|
||||
|
||||
private fun makeQueryIn(rowIdQuery: String) =
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
$rowIdQuery
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent()
|
||||
|
||||
private fun <T : Event> SQLiteDatabase.runQuery(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
): List<T> =
|
||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
ArrayList<T>(cursor.count).apply {
|
||||
while (cursor.moveToNext()) {
|
||||
add(cursor.toEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T : Event> SQLiteDatabase.runQuery(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
onEach: (T) -> Unit,
|
||||
) = rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
onEach(cursor.toEvent())
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Event> Cursor.toEvent() =
|
||||
EventFactory.create<T>(
|
||||
getString(0).intern(),
|
||||
getString(1).intern(),
|
||||
getLong(2),
|
||||
getInt(3),
|
||||
OptimizedJsonMapper.fromJsonToTagArray(getString(4)),
|
||||
getString(5),
|
||||
getString(6),
|
||||
)
|
||||
|
||||
class RawEvent(
|
||||
val id: HexKey,
|
||||
val pubKey: HexKey,
|
||||
val createdAt: Long,
|
||||
val kind: Kind,
|
||||
val jsonTags: String,
|
||||
val content: String,
|
||||
val sig: HexKey,
|
||||
) {
|
||||
fun <T : Event> toEvent() =
|
||||
EventFactory.create<T>(
|
||||
id.intern(),
|
||||
pubKey.intern(),
|
||||
createdAt,
|
||||
kind,
|
||||
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.toRawEvent() =
|
||||
RawEvent(
|
||||
getString(0),
|
||||
getString(1),
|
||||
getLong(2),
|
||||
getInt(3),
|
||||
getString(4),
|
||||
getString(5),
|
||||
getString(6),
|
||||
)
|
||||
|
||||
// --------------
|
||||
// Counts
|
||||
// -------------
|
||||
fun count(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdSubQuery == null) {
|
||||
db.countEverything()
|
||||
} else {
|
||||
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun count(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
|
||||
|
||||
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
|
||||
|
||||
private fun SQLiteDatabase.countIn(
|
||||
rowIdQuery: String,
|
||||
args: List<String>,
|
||||
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
|
||||
|
||||
private fun SQLiteDatabase.runCount(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
): Int =
|
||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
cursor.moveToNext()
|
||||
cursor.getInt(0)
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Deletes
|
||||
// -------------
|
||||
fun delete(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdQuery == null) {
|
||||
0
|
||||
} else {
|
||||
db.runDelete(rowIdQuery.sql, rowIdQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
|
||||
|
||||
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.runDelete(
|
||||
sql: String,
|
||||
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
|
||||
// ----------------------------
|
||||
fun prepareRowIDSubQueries(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
): 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) ||
|
||||
(authors != null && authors.isNotEmpty()) ||
|
||||
(kinds != null && kinds.isNotEmpty()) ||
|
||||
(tags != null && tags.containsKey("d")) ||
|
||||
(since != null) ||
|
||||
(until != null) ||
|
||||
(limit != null)
|
||||
}
|
||||
|
||||
var defaultTagKey: String? = null
|
||||
|
||||
val projection =
|
||||
buildString {
|
||||
// always do tags if there are any
|
||||
if (nonDTags.isNotEmpty()) {
|
||||
append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ")
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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) }
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}"
|
||||
} else {
|
||||
clause.conditions
|
||||
}
|
||||
|
||||
return RowIdSubQuery("$projection WHERE $whereClause", clause.args)
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {
|
||||
db.execSQL("DELETE FROM event_tags")
|
||||
db.execSQL("DELETE FROM event_headers")
|
||||
}
|
||||
|
||||
data class RowIdSubQuery(
|
||||
val sql: String,
|
||||
val args: List<String>,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -29,9 +29,9 @@ class EventStore(
|
||||
context: Context,
|
||||
dbName: String? = "events.db",
|
||||
val relayUrl: String? = "wss://quartz.local",
|
||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IEventStore {
|
||||
val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy)
|
||||
val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy)
|
||||
|
||||
override fun insert(event: Event) = store.insertEvent(event)
|
||||
|
||||
|
||||
+64
-1
@@ -23,6 +23,64 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
|
||||
interface IndexingStrategy {
|
||||
/**
|
||||
* Activate this if you see too many Filters with just LIMIT, SINCE and
|
||||
* UNTIL filled up.
|
||||
*
|
||||
* Clients never support all kinds, so this is usually
|
||||
* only done with syncing services that must download ALL kinds from
|
||||
* ALL authors.
|
||||
*
|
||||
* The index will make these queries significantly faster, but maybe speed
|
||||
* is not a requirement on Sync services. The size of this index is
|
||||
* considerable.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
* frequent reloadings of the index itself from disk.
|
||||
*/
|
||||
val indexEventsByCreatedAtAlone: Boolean
|
||||
|
||||
/**
|
||||
* Activate this if you see too many Tag-centric Filters without
|
||||
* kind, pubkey or id.
|
||||
*
|
||||
* Clients never support all kinds, so this is usually
|
||||
* only done in rare usecases where the client supports all
|
||||
* kinds.
|
||||
*
|
||||
* The index will make these queries significantly faster, but maybe speed
|
||||
* is not a requirement on such services. Because this is an index in
|
||||
* event tags, it becomes QUITE BIG.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
* frequent reloadings of the index itself from disk.
|
||||
*/
|
||||
val indexTagsByCreatedAtAlone: Boolean
|
||||
|
||||
/**
|
||||
* Activate this if you see too many Tag-centric Filters without
|
||||
* kind AND pubkey at the same time.
|
||||
*
|
||||
* This is a rarely used index (reports by your follows or
|
||||
* NIP-04 DMs for instance) that becomes quite large without
|
||||
* major gains.
|
||||
*
|
||||
* Keep in mind that activating too many indexes increases the size of the
|
||||
* DB so much that the indexes themselves won't fit in memory, requiring
|
||||
* frequent reloadings of the index itself from disk.
|
||||
*/
|
||||
val indexTagsWithKindAndPubkey: Boolean
|
||||
|
||||
/**
|
||||
* Activate this to make sure queries are always in order when
|
||||
* the same created_at exists. This will impact performance and
|
||||
* the size of indexes, but it provides results that are compliant
|
||||
* with the Nostr Spec
|
||||
*/
|
||||
val useAndIndexIdOnOrderBy: Boolean
|
||||
|
||||
fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
@@ -32,7 +90,12 @@ interface IndexingStrategy {
|
||||
/**
|
||||
* By default, we index all tags that have a single letter name and some value
|
||||
*/
|
||||
class DefaultIndexingStrategy : IndexingStrategy {
|
||||
class DefaultIndexingStrategy(
|
||||
override val indexEventsByCreatedAtAlone: Boolean = false,
|
||||
override val indexTagsByCreatedAtAlone: Boolean = false,
|
||||
override val indexTagsWithKindAndPubkey: Boolean = false,
|
||||
override val useAndIndexIdOnOrderBy: Boolean = false,
|
||||
) : IndexingStrategy {
|
||||
override fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* 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.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
|
||||
class QueryBuilder(
|
||||
val fts: FullTextSearchModule,
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
val indexStrategy: IndexingStrategy,
|
||||
) {
|
||||
// ------------
|
||||
// Main methods
|
||||
// ------------
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): List<T> = db.runQuery(toSql(filter, hasher(db)))
|
||||
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) = db.runQuery(toSql(filter, hasher(db)), onEach)
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): List<T> = db.runQuery(toSql(filters, hasher(db)))
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) = db.runQuery(toSql(filters, hasher(db)), onEach)
|
||||
|
||||
// ---------------------------
|
||||
// Raw methods for performance
|
||||
// ---------------------------
|
||||
fun rawQuery(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): List<RawEvent> = db.runRawQuery(toSql(filter, hasher(db)))
|
||||
|
||||
fun rawQuery(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = db.runRawQuery(toSql(filter, hasher(db)), onEach)
|
||||
|
||||
fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): List<RawEvent> = db.runRawQuery(toSql(filters, hasher(db)))
|
||||
|
||||
fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = db.runRawQuery(toSql(filters, hasher(db)), onEach)
|
||||
|
||||
// -----------
|
||||
// Debug Tools
|
||||
// -----------
|
||||
fun planQuery(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
db: SQLiteDatabase,
|
||||
): String {
|
||||
val query = toSql(filter, hasher)
|
||||
return db.explainQuery(query.sql, query.args.toTypedArray())
|
||||
}
|
||||
|
||||
fun planQuery(
|
||||
filters: List<Filter>,
|
||||
hasher: TagNameValueHasher,
|
||||
db: SQLiteDatabase,
|
||||
): String {
|
||||
val query = toSql(filters, hasher)
|
||||
return db.explainQuery(query.sql, query.args.toTypedArray())
|
||||
}
|
||||
|
||||
fun toSql(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
): QuerySpec {
|
||||
val newFilter = filter.toFilterWithDTags()
|
||||
|
||||
if (newFilter.isSimpleQuery()) {
|
||||
return makeSimpleQuery(
|
||||
project = true,
|
||||
ids = newFilter.ids,
|
||||
authors = newFilter.authors,
|
||||
kinds = newFilter.kinds,
|
||||
dTags = newFilter.dTags,
|
||||
since = newFilter.since,
|
||||
until = newFilter.until,
|
||||
limit = newFilter.limit,
|
||||
)
|
||||
}
|
||||
|
||||
if (newFilter.isSimpleSearch()) {
|
||||
return makeSimpleSearch(
|
||||
search = newFilter.search!!,
|
||||
ids = newFilter.ids,
|
||||
authors = newFilter.authors,
|
||||
kinds = newFilter.kinds,
|
||||
dTags = newFilter.dTags,
|
||||
since = newFilter.since,
|
||||
until = newFilter.until,
|
||||
limit = newFilter.limit,
|
||||
)
|
||||
}
|
||||
|
||||
val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher)
|
||||
|
||||
return if (rowIdSubqueries == null) {
|
||||
QuerySpec(makeEverythingQuery())
|
||||
} else {
|
||||
QuerySpec(
|
||||
makeQueryIn(rowIdSubqueries.sql),
|
||||
rowIdSubqueries.args,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toSql(
|
||||
filters: List<Filter>,
|
||||
hasher: TagNameValueHasher,
|
||||
): QuerySpec {
|
||||
if (filters.size == 1) return toSql(filters.first(), hasher)
|
||||
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher)
|
||||
|
||||
return if (rowIdSubqueries == null) {
|
||||
QuerySpec(
|
||||
makeEverythingQuery(),
|
||||
emptyList(),
|
||||
)
|
||||
} else {
|
||||
QuerySpec(
|
||||
makeQueryIn(rowIdSubqueries.sql),
|
||||
rowIdSubqueries.args,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}"
|
||||
|
||||
private fun makeQueryIn(rowIdQuery: String) =
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
$rowIdQuery
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}
|
||||
""".trimIndent()
|
||||
|
||||
private fun <T : Event> SQLiteDatabase.runQuery(query: QuerySpec): List<T> =
|
||||
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
ArrayList<T>(cursor.count).apply {
|
||||
while (cursor.moveToNext()) {
|
||||
add(cursor.toEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List<RawEvent> =
|
||||
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
ArrayList<RawEvent>(cursor.count).apply {
|
||||
while (cursor.moveToNext()) {
|
||||
add(cursor.toRawEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T : Event> SQLiteDatabase.runQuery(
|
||||
query: QuerySpec,
|
||||
onEach: (T) -> Unit,
|
||||
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
onEach(cursor.toEvent())
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun SQLiteDatabase.runRawQuery(
|
||||
query: QuerySpec,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
onEach(cursor.toRawEvent())
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Event> Cursor.toEvent() =
|
||||
EventFactory.create<T>(
|
||||
getString(0).intern(),
|
||||
getString(1).intern(),
|
||||
getLong(2),
|
||||
getInt(3),
|
||||
OptimizedJsonMapper.fromJsonToTagArray(getString(4)),
|
||||
getString(5),
|
||||
getString(6),
|
||||
)
|
||||
|
||||
private fun Cursor.toRawEvent() =
|
||||
RawEvent(
|
||||
getString(0),
|
||||
getString(1),
|
||||
getLong(2),
|
||||
getInt(3),
|
||||
getString(4),
|
||||
getString(5),
|
||||
getString(6),
|
||||
)
|
||||
|
||||
// --------------
|
||||
// Counts
|
||||
// -------------
|
||||
fun count(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val newFilter = filter.toFilterWithDTags()
|
||||
|
||||
if (newFilter.isSimpleQuery()) {
|
||||
val sql =
|
||||
makeSimpleQuery(
|
||||
project = false,
|
||||
ids = newFilter.ids,
|
||||
authors = newFilter.authors,
|
||||
kinds = newFilter.kinds,
|
||||
dTags = newFilter.dTags,
|
||||
since = newFilter.since,
|
||||
until = newFilter.until,
|
||||
limit = newFilter.limit,
|
||||
)
|
||||
return db.countIn(sql.sql, sql.args)
|
||||
}
|
||||
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdSubQuery == null) {
|
||||
db.countEverything()
|
||||
} else {
|
||||
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun count(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
|
||||
|
||||
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
|
||||
|
||||
private fun SQLiteDatabase.countIn(
|
||||
rowIdQuery: String,
|
||||
args: List<String>,
|
||||
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
|
||||
|
||||
private fun SQLiteDatabase.runCount(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
): Int =
|
||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
cursor.moveToNext()
|
||||
cursor.getInt(0)
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Deletes
|
||||
// -------------
|
||||
fun delete(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdQuery == null) {
|
||||
0
|
||||
} else {
|
||||
db.runDelete(rowIdQuery.sql, rowIdQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
|
||||
|
||||
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.runDelete(
|
||||
sql: String,
|
||||
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,
|
||||
): QuerySpec? {
|
||||
val inner =
|
||||
filters.mapNotNull { filter ->
|
||||
prepareRowIDSubQueries(filter, hasher)
|
||||
}
|
||||
|
||||
if (inner.isEmpty()) return null
|
||||
|
||||
return if (inner.size == 1) {
|
||||
inner.first()
|
||||
} else {
|
||||
QuerySpec(
|
||||
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
|
||||
args = inner.flatMap { it.args },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TagNameForQuery {
|
||||
class InTags(
|
||||
val tagName: String,
|
||||
) : TagNameForQuery()
|
||||
|
||||
class AllTags(
|
||||
val tagName: String,
|
||||
val tagValueIndex: Int,
|
||||
) : TagNameForQuery()
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Inner row id selections
|
||||
// ----------------------------
|
||||
fun prepareRowIDSubQueries(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
): QuerySpec? {
|
||||
if (filter.isEmpty()) return null
|
||||
|
||||
val mustJoinSearch = (filter.search != null)
|
||||
|
||||
val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap()
|
||||
|
||||
val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap()
|
||||
|
||||
val reverseLookup = nonDTagsIn.isNotEmpty() || nonDTagsAll.isNotEmpty()
|
||||
|
||||
val needHeaders =
|
||||
with(filter) {
|
||||
(ids != null) || (tags != null && tags.containsKey("d"))
|
||||
}
|
||||
|
||||
val hasHeaders =
|
||||
with(filter) {
|
||||
(ids != null) ||
|
||||
(authors != null && authors.isNotEmpty()) ||
|
||||
(kinds != null && kinds.isNotEmpty()) ||
|
||||
(tags != null && tags.containsKey("d")) ||
|
||||
(since != null) ||
|
||||
(until != null) ||
|
||||
(limit != null)
|
||||
}
|
||||
|
||||
var defaultTagKey: TagNameForQuery? = null
|
||||
|
||||
val projection =
|
||||
buildString {
|
||||
// always do tags if there are any
|
||||
if (reverseLookup) {
|
||||
append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags")
|
||||
|
||||
// it's quite rare to have 2 tags in the filter, but possible
|
||||
nonDTagsIn.keys.forEachIndexed { index, tagName ->
|
||||
if (defaultTagKey != null) {
|
||||
append(" INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn$index.created_at = event_tags.created_at")
|
||||
} else {
|
||||
defaultTagKey = TagNameForQuery.InTags(tagName)
|
||||
}
|
||||
}
|
||||
|
||||
nonDTagsAll.keys.forEachIndexed { index, tagName ->
|
||||
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
|
||||
if (defaultTagKey != null) {
|
||||
append(" INNER JOIN event_tags as event_tagsAll${index}_$valueIndex ON event_tagsAll${index}_$valueIndex.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll${index}_$valueIndex.created_at = event_tags.created_at")
|
||||
} else {
|
||||
defaultTagKey = TagNameForQuery.AllTags(tagName, valueIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needHeaders) {
|
||||
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 {
|
||||
// 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) }
|
||||
|
||||
// it's quite rare to have 2 tags in the filter, but possible
|
||||
nonDTagsIn.keys.forEachIndexed { index, tagName ->
|
||||
val column =
|
||||
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) {
|
||||
"event_tags.tag_hash"
|
||||
} else {
|
||||
"event_tagsIn$index.tag_hash"
|
||||
}
|
||||
|
||||
equalsOrIn(
|
||||
column,
|
||||
nonDTagsIn[tagName]!!.map {
|
||||
hasher.hash(tagName, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
nonDTagsAll.keys.forEachIndexed { index, tagName ->
|
||||
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
|
||||
val column =
|
||||
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.AllTags && defaultTagKey.tagName == tagName && defaultTagKey.tagValueIndex == valueIndex)) {
|
||||
"event_tags.tag_hash"
|
||||
} else {
|
||||
"event_tagsAll${index}_$valueIndex.tag_hash"
|
||||
}
|
||||
|
||||
equals(column, hasher.hash(tagName, tagValue))
|
||||
}
|
||||
}
|
||||
|
||||
// range search is bad but most of the time these are up the top with few elements.
|
||||
if (reverseLookup) {
|
||||
filter.kinds?.let { equalsOrIn("event_tags.kind", it) }
|
||||
filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) }
|
||||
|
||||
filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) }
|
||||
filter.until?.let { lessThanOrEquals("event_tags.created_at", it) }
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
filter.tags?.forEach { (tagName, tagValues) ->
|
||||
if (tagName == "d") {
|
||||
equalsOrIn("event_headers.d_tag", tagValues)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
|
||||
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
|
||||
|
||||
// no need to add the replaceable because query_by_kind_pubkey_created already covers it
|
||||
val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false
|
||||
if (isAllAddressable) {
|
||||
// matches unique index kind >= 30000 AND kind < 40000
|
||||
raw("(event_headers.kind >= 30000 AND event_headers.kind < 40000)")
|
||||
}
|
||||
}
|
||||
|
||||
// if search is included, SQLLite will always start here.
|
||||
filter.search?.let {
|
||||
if (it.isNotBlank()) {
|
||||
match(fts.tableName, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sql =
|
||||
buildString {
|
||||
append(projection)
|
||||
if (clause.conditions.isNotEmpty()) {
|
||||
append(" WHERE ${clause.conditions}")
|
||||
}
|
||||
if (filter.limit != null) {
|
||||
if (reverseLookup) {
|
||||
append(" ORDER BY event_tags.created_at DESC")
|
||||
append(" LIMIT ")
|
||||
append(filter.limit)
|
||||
} else {
|
||||
append(" ORDER BY event_headers.created_at DESC")
|
||||
append(" LIMIT ")
|
||||
append(filter.limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return QuerySpec(sql, clause.args)
|
||||
}
|
||||
|
||||
private fun makeSimpleSearch(
|
||||
search: String,
|
||||
ids: List<HexKey>? = null,
|
||||
authors: List<HexKey>? = null,
|
||||
kinds: List<Kind>? = null,
|
||||
dTags: List<String>? = null,
|
||||
since: Long? = null,
|
||||
until: Long? = null,
|
||||
limit: Int? = null,
|
||||
): QuerySpec {
|
||||
val clause =
|
||||
where {
|
||||
// the order should match indexes
|
||||
// ids reduce the filter the most
|
||||
ids?.let { equalsOrIn("event_headers.id", it) }
|
||||
|
||||
match(fts.tableName, search)
|
||||
|
||||
kinds?.let { equalsOrIn("event_headers.kind", it) }
|
||||
authors?.let { equalsOrIn("event_headers.pubkey", it) }
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
dTags?.let { equalsOrIn("event_headers.d_tag", it) }
|
||||
|
||||
since?.let { greaterThanOrEquals("event_headers.created_at", it) }
|
||||
until?.let { lessThanOrEquals("event_headers.created_at", it) }
|
||||
|
||||
// if this is a dTag filter, it is likely that all kinds are addressables
|
||||
// and so force the use of the addressable index
|
||||
if (dTags != null && kinds != null) {
|
||||
if (kinds.all { it.isAddressable() }) {
|
||||
// matches unique index kind >= 30000 AND kind < 40000
|
||||
raw("(event_headers.kind >= 30000 AND kind < 40000)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sql =
|
||||
buildString {
|
||||
append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers")
|
||||
append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
|
||||
if (clause.conditions.isNotEmpty()) {
|
||||
append("\nWHERE ${clause.conditions}")
|
||||
}
|
||||
append("\nORDER BY event_headers.created_at DESC")
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
append(", event_headers.id ASC")
|
||||
}
|
||||
if (limit != null) {
|
||||
append("\nLIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
return QuerySpec(sql, clause.args)
|
||||
}
|
||||
|
||||
private fun makeSimpleQuery(
|
||||
project: Boolean,
|
||||
ids: List<HexKey>? = null,
|
||||
authors: List<HexKey>? = null,
|
||||
kinds: List<Kind>? = null,
|
||||
dTags: List<String>? = null,
|
||||
since: Long? = null,
|
||||
until: Long? = null,
|
||||
limit: Int? = null,
|
||||
): QuerySpec {
|
||||
val clause =
|
||||
where {
|
||||
// the order should match indexes
|
||||
// ids reduce the filter the most
|
||||
ids?.let { equalsOrIn("id", it) }
|
||||
|
||||
kinds?.let { equalsOrIn("kind", it) }
|
||||
authors?.let { equalsOrIn("pubkey", it) }
|
||||
|
||||
// there are indexes for these, starting with tags.
|
||||
dTags?.let { equalsOrIn("d_tag", it) }
|
||||
|
||||
since?.let { greaterThanOrEquals("created_at", it) }
|
||||
until?.let { lessThanOrEquals("created_at", it) }
|
||||
|
||||
// if this is a dTag filter, it is likely that all kinds are addressables
|
||||
// and so force the use of the addressable index
|
||||
if (dTags != null && kinds != null) {
|
||||
if (kinds.all { it.isAddressable() }) {
|
||||
// matches unique index kind >= 30000 AND kind < 40000
|
||||
raw("(kind >= 30000 AND kind < 40000)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val sql =
|
||||
buildString {
|
||||
if (project) {
|
||||
append("SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers")
|
||||
} else {
|
||||
append("SELECT row_id FROM event_headers")
|
||||
}
|
||||
if (clause.conditions.isNotEmpty()) {
|
||||
append("\nWHERE ")
|
||||
append(clause.conditions)
|
||||
}
|
||||
if (project) {
|
||||
append("\nORDER BY created_at DESC")
|
||||
if (indexStrategy.useAndIndexIdOnOrderBy) {
|
||||
append(", id ASC")
|
||||
}
|
||||
}
|
||||
if (limit != null) {
|
||||
append("\nLIMIT ")
|
||||
append(limit)
|
||||
}
|
||||
}
|
||||
|
||||
return QuerySpec(sql, clause.args)
|
||||
}
|
||||
|
||||
class FilterWithDTags(
|
||||
val ids: List<HexKey>? = null,
|
||||
val authors: List<HexKey>? = null,
|
||||
val kinds: List<Kind>? = null,
|
||||
val dTags: List<String>? = null,
|
||||
val nonDTagsIn: Map<String, List<String>>? = null,
|
||||
val nonDTagsAll: Map<String, List<String>>? = null,
|
||||
val since: Long? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val search: String? = null,
|
||||
) {
|
||||
fun isSimpleSearch() =
|
||||
search != null && search.isNotEmpty() &&
|
||||
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
|
||||
(nonDTagsAll == null || nonDTagsAll.isEmpty())
|
||||
|
||||
// can be resolved with just event_headers
|
||||
fun isSimpleQuery() =
|
||||
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
|
||||
(nonDTagsAll == null || nonDTagsAll.isEmpty()) &&
|
||||
(search == null || search.isEmpty())
|
||||
}
|
||||
|
||||
fun Filter.toFilterWithDTags(): FilterWithDTags =
|
||||
FilterWithDTags(
|
||||
ids = ids,
|
||||
authors = authors,
|
||||
kinds = kinds,
|
||||
dTags = tags?.get("d") ?: tagsAll?.get("d"),
|
||||
nonDTagsIn = tags?.filter { it.key != "d" }?.ifEmpty { null },
|
||||
nonDTagsAll = tagsAll?.filter { it.key != "d" }?.ifEmpty { null },
|
||||
since = since,
|
||||
until = until,
|
||||
limit = limit,
|
||||
search = search,
|
||||
)
|
||||
|
||||
data class QuerySpec(
|
||||
val sql: String,
|
||||
val args: List<String> = emptyList(),
|
||||
)
|
||||
}
|
||||
+1
-2
@@ -48,8 +48,7 @@ class ReplaceableModule : IModule {
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.created_at < NEW.created_at AND
|
||||
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
|
||||
event_headers.created_at < NEW.created_at;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+65
-11
@@ -27,10 +27,13 @@ import android.database.sqlite.SQLiteOpenHelper
|
||||
import androidx.core.database.sqlite.transaction
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -38,7 +41,7 @@ class SQLiteEventStore(
|
||||
val context: Context,
|
||||
val dbName: String? = "events.db",
|
||||
val relayUrl: String? = null,
|
||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
|
||||
companion object {
|
||||
const val DATABASE_VERSION = 2
|
||||
@@ -47,7 +50,7 @@ class SQLiteEventStore(
|
||||
val seedModule = SeedModule()
|
||||
|
||||
val fullTextSearchModule = FullTextSearchModule()
|
||||
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy)
|
||||
val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy)
|
||||
|
||||
val replaceableModule = ReplaceableModule()
|
||||
val addressableModule = AddressableModule()
|
||||
@@ -57,6 +60,8 @@ class SQLiteEventStore(
|
||||
val expirationModule = ExpirationModule()
|
||||
val rightToVanishModule = RightToVanishModule(seedModule::hasher)
|
||||
|
||||
val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy)
|
||||
|
||||
val modules =
|
||||
listOf(
|
||||
seedModule,
|
||||
@@ -73,6 +78,9 @@ class SQLiteEventStore(
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
super.onConfigure(db)
|
||||
|
||||
// 32MB memory cache
|
||||
db.execSQL("PRAGMA cache_size=-32000;")
|
||||
|
||||
// makes sure the FKs are sane
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
|
||||
@@ -82,7 +90,14 @@ class SQLiteEventStore(
|
||||
|
||||
// The DB can be corrupted if the OS is shutdown before sync, which generally
|
||||
// doesn't happen on Android
|
||||
db.execSQL("PRAGMA synchronous = OFF")
|
||||
db.execSQL("PRAGMA synchronous = OFF;")
|
||||
}
|
||||
|
||||
fun dbSizeMB(): Int {
|
||||
val f1 = context.getDatabasePath(dbName)
|
||||
val f2 = context.getDatabasePath("$dbName-wal")
|
||||
val total = f1.length() + f2.length()
|
||||
return (total / (1024 * 1024)).toInt()
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
@@ -171,33 +186,72 @@ class SQLiteEventStore(
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> query(filter: Filter): List<T> = eventIndexModule.query(filter, readableDatabase)
|
||||
fun <T : Event> query(filter: Filter): List<T> = queryBuilder.query(filter, readableDatabase)
|
||||
|
||||
fun <T : Event> query(filters: List<Filter>): List<T> = eventIndexModule.query(filters, readableDatabase)
|
||||
fun <T : Event> query(filters: List<Filter>): List<T> = queryBuilder.query(filters, readableDatabase)
|
||||
|
||||
fun <T : Event> query(
|
||||
filter: Filter,
|
||||
onEach: (T) -> Unit,
|
||||
) = eventIndexModule.query(filter, readableDatabase, onEach)
|
||||
) = queryBuilder.query(filter, readableDatabase, onEach)
|
||||
|
||||
fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
onEach: (T) -> Unit,
|
||||
) = eventIndexModule.query(filters, readableDatabase, onEach)
|
||||
) = queryBuilder.query(filters, readableDatabase, onEach)
|
||||
|
||||
fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase)
|
||||
fun rawQuery(filter: Filter): List<RawEvent> = queryBuilder.rawQuery(filter, readableDatabase)
|
||||
|
||||
fun count(filters: List<Filter>): Int = eventIndexModule.count(filters, readableDatabase)
|
||||
fun rawQuery(filters: List<Filter>): List<RawEvent> = queryBuilder.rawQuery(filters, readableDatabase)
|
||||
|
||||
fun rawQuery(
|
||||
filter: Filter,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = queryBuilder.rawQuery(filter, readableDatabase, onEach)
|
||||
|
||||
fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = queryBuilder.rawQuery(filters, readableDatabase, onEach)
|
||||
|
||||
fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase)
|
||||
|
||||
fun planQuery(filters: List<Filter>) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase)
|
||||
|
||||
fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase)
|
||||
|
||||
fun count(filters: List<Filter>): Int = queryBuilder.count(filters, readableDatabase)
|
||||
|
||||
fun delete(filter: Filter) {
|
||||
eventIndexModule.delete(filter, writableDatabase)
|
||||
queryBuilder.delete(filter, writableDatabase)
|
||||
}
|
||||
|
||||
fun delete(filters: List<Filter>) {
|
||||
eventIndexModule.delete(filters, writableDatabase)
|
||||
queryBuilder.delete(filters, writableDatabase)
|
||||
}
|
||||
|
||||
fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
|
||||
|
||||
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase)
|
||||
}
|
||||
|
||||
class RawEvent(
|
||||
val id: HexKey,
|
||||
val pubKey: HexKey,
|
||||
val createdAt: Long,
|
||||
val kind: Kind,
|
||||
val jsonTags: String,
|
||||
val content: String,
|
||||
val sig: HexKey,
|
||||
) {
|
||||
fun <T : Event> toEvent() =
|
||||
EventFactory.create<T>(
|
||||
id.intern(),
|
||||
pubKey.intern(),
|
||||
createdAt,
|
||||
kind,
|
||||
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
|
||||
content,
|
||||
sig,
|
||||
)
|
||||
}
|
||||
|
||||
+6
@@ -21,6 +21,10 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
||||
|
||||
sealed class Condition {
|
||||
data class Raw(
|
||||
val condition: String,
|
||||
) : Condition()
|
||||
|
||||
data class Equals(
|
||||
val column: String,
|
||||
val value: Any?,
|
||||
@@ -81,4 +85,6 @@ sealed class Condition {
|
||||
data class Or(
|
||||
val conditions: List<Condition>,
|
||||
) : Condition()
|
||||
|
||||
class Empty : Condition()
|
||||
}
|
||||
|
||||
+18
-4
@@ -38,13 +38,27 @@ class SqlSelectionBuilder(
|
||||
*/
|
||||
private fun buildCondition(cond: Condition): String =
|
||||
when (cond) {
|
||||
is Condition.Empty -> {
|
||||
""
|
||||
}
|
||||
is Condition.Raw -> {
|
||||
cond.condition
|
||||
}
|
||||
is Condition.Equals -> {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} = ?"
|
||||
if (cond.value == null) {
|
||||
"${cond.column} IS NULL"
|
||||
} else {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} = ?"
|
||||
}
|
||||
}
|
||||
is Condition.NotEquals -> {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} != ?"
|
||||
if (cond.value == null) {
|
||||
"${cond.column} IS NULL"
|
||||
} else {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} != ?"
|
||||
}
|
||||
}
|
||||
is Condition.GreaterThan -> {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
|
||||
+13
-4
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
|
||||
class WhereClauseBuilder {
|
||||
private val conditions = mutableListOf<Condition>()
|
||||
|
||||
fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) }
|
||||
|
||||
fun equals(
|
||||
column: String,
|
||||
value: Any?,
|
||||
@@ -86,7 +88,7 @@ class WhereClauseBuilder {
|
||||
fun and(block: WhereClauseBuilder.() -> Unit) =
|
||||
apply {
|
||||
val builder = WhereClauseBuilder().apply(block)
|
||||
val builtCondition = builder.build()
|
||||
val builtCondition = builder.buildAnd()
|
||||
if (builtCondition != null) {
|
||||
conditions.add(builtCondition)
|
||||
}
|
||||
@@ -95,22 +97,29 @@ class WhereClauseBuilder {
|
||||
fun or(block: WhereClauseBuilder.() -> Unit) =
|
||||
apply {
|
||||
val builder = WhereClauseBuilder().apply(block)
|
||||
val builtCondition = builder.build()
|
||||
val builtCondition = builder.buildOr()
|
||||
if (builtCondition != null) {
|
||||
conditions.add(builtCondition)
|
||||
}
|
||||
}
|
||||
|
||||
fun build(): Condition? =
|
||||
fun buildAnd(): Condition? =
|
||||
when (conditions.size) {
|
||||
0 -> null
|
||||
1 -> conditions.first()
|
||||
else -> Condition.And(conditions.toList())
|
||||
}
|
||||
|
||||
fun buildOr(): Condition? =
|
||||
when (conditions.size) {
|
||||
0 -> null
|
||||
1 -> conditions.first()
|
||||
else -> Condition.Or(conditions.toList())
|
||||
}
|
||||
}
|
||||
|
||||
fun where(block: WhereClauseBuilder.() -> Unit): WhereClause {
|
||||
val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList())
|
||||
val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty()
|
||||
return SqlSelectionBuilder(condition).build()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user