Faster SQLLite DB with probabilistic hashvalues
This commit is contained in:
+3
-3
@@ -79,7 +79,7 @@ class DeletionTest {
|
||||
db.insert(note1)
|
||||
fail("Should not be able to insert a deleted event")
|
||||
} catch (e: SQLiteConstraintException) {
|
||||
assertEquals("blocked: a deletion event for this event id exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
||||
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
||||
}
|
||||
|
||||
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
|
||||
@@ -124,7 +124,7 @@ class DeletionTest {
|
||||
db.insert(note1)
|
||||
fail("Should not be able to insert a deleted event")
|
||||
} catch (e: SQLiteConstraintException) {
|
||||
assertEquals("blocked: a deletion event for this event id exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
||||
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
||||
}
|
||||
|
||||
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
|
||||
@@ -162,7 +162,7 @@ class DeletionTest {
|
||||
db.insert(note1)
|
||||
fail("Should not be able to insert a deleted event")
|
||||
} catch (e: SQLiteConstraintException) {
|
||||
assertEquals("blocked: a deletion event for this address exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
||||
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
|
||||
}
|
||||
|
||||
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
|
||||
|
||||
+7
-23
@@ -32,39 +32,23 @@ class AddressableModule : IModule {
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// Rejects old addressables
|
||||
// deletes older addressables when inserting new ones
|
||||
// if a newer addressable is inserted the unique index
|
||||
// above will be triggered. Delete cascade will take
|
||||
// care of the event_tags table
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_older_addressable_event
|
||||
CREATE TRIGGER delete_older_addressable_event
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.kind >= 30000 AND NEW.kind < 40000)
|
||||
BEGIN
|
||||
-- Check for existing newer record
|
||||
SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_headers
|
||||
WHERE
|
||||
event_headers.created_at >= NEW.created_at AND
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.d_tag = NEW.d_tag
|
||||
);
|
||||
|
||||
DELETE FROM event_tags
|
||||
WHERE event_header_row_id in (
|
||||
SELECT row_id FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.d_tag = NEW.d_tag
|
||||
);
|
||||
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.d_tag = NEW.d_tag;
|
||||
event_headers.d_tag = NEW.d_tag AND
|
||||
event_headers.created_at < NEW.created_at;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+5
-16
@@ -34,27 +34,16 @@ class DeletionRequestModule : IModule {
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
-- Check for ID-based deletion record
|
||||
SELECT RAISE(ABORT, 'blocked: a deletion event for this event id exists')
|
||||
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id
|
||||
SELECT 1 FROM event_headers
|
||||
INNER JOIN event_tags
|
||||
ON event_headers.row_id = event_tags.event_header_row_id
|
||||
WHERE
|
||||
event_headers.created_at >= NEW.created_at AND
|
||||
event_headers.kind = 5 AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_tags.tag_name = 'e' AND
|
||||
event_tags.tag_value = NEW.id
|
||||
);
|
||||
|
||||
-- Check for address-based deletion record
|
||||
SELECT RAISE(ABORT, 'blocked: a deletion event for this address exists')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id
|
||||
WHERE
|
||||
event_headers.created_at >= NEW.created_at AND
|
||||
event_headers.kind = 5 AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_tags.tag_name = 'a' AND
|
||||
event_tags.tag_value = NEW.kind || ':' || NEW.pubkey || ':' || NEW.d_tag
|
||||
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash)
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
|
||||
+60
-53
@@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteStatement
|
||||
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.OptimizedJsonMapper
|
||||
@@ -33,8 +33,13 @@ import com.vitorpamplona.quartz.utils.EventFactory
|
||||
|
||||
class EventIndexesModule(
|
||||
val fts: FullTextSearchModule,
|
||||
val seedModule: SeedModule,
|
||||
val tagIndexStrategy: IndexingStrategy = IndexingStrategy(),
|
||||
) : IModule {
|
||||
private var hasherCache: TagNameValueHasher? = null
|
||||
|
||||
fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(seedModule.getSeed(db)).also { hasherCache = it }
|
||||
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
@@ -45,6 +50,8 @@ 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
|
||||
@@ -55,9 +62,8 @@ class EventIndexesModule(
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE event_tags (
|
||||
event_header_row_id INTEGER,
|
||||
tag_name TEXT NOT NULL,
|
||||
tag_value TEXT NOT NULL,
|
||||
event_header_row_id INTEGER NOT NULL,
|
||||
tag_hash INTEGER NOT NULL,
|
||||
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent(),
|
||||
@@ -66,7 +72,7 @@ class EventIndexesModule(
|
||||
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_name, tag_value)")
|
||||
db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_hash)")
|
||||
|
||||
// Prevent updates to maintain immutability
|
||||
db.execSQL(
|
||||
@@ -100,23 +106,24 @@ class EventIndexesModule(
|
||||
val sqlInsertHeader =
|
||||
"""
|
||||
INSERT INTO event_headers
|
||||
(id, pubkey, created_at, kind, tags, content, sig, d_tag)
|
||||
(id, pubkey, created_at, kind, tags, content, sig, d_tag, etag_hash, atag_hash)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
val sqlInsertTags =
|
||||
"""
|
||||
INSERT OR ROLLBACK INTO event_tags
|
||||
(event_header_row_id, tag_name, tag_value)
|
||||
(event_header_row_id, tag_hash)
|
||||
VALUES
|
||||
(?,?,?)
|
||||
(?,?)
|
||||
""".trimIndent()
|
||||
|
||||
fun insert(
|
||||
event: Event,
|
||||
db: SQLiteDatabase,
|
||||
): Long {
|
||||
val hasher = hasher(db)
|
||||
val stmt = db.compileStatement(sqlInsertHeader)
|
||||
stmt.bindString(1, event.id)
|
||||
stmt.bindString(2, event.pubKey)
|
||||
@@ -126,40 +133,24 @@ class EventIndexesModule(
|
||||
stmt.bindString(6, event.content)
|
||||
stmt.bindString(7, event.sig)
|
||||
if (event is AddressableEvent) {
|
||||
stmt.bindString(8, event.dTag())
|
||||
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)))
|
||||
} else {
|
||||
stmt.bindNull(8)
|
||||
stmt.bindLong(9, hasher.hashETag(event.id))
|
||||
stmt.bindNull(10)
|
||||
}
|
||||
|
||||
val headerId = stmt.executeInsert()
|
||||
|
||||
val tagsToIndex = event.tags.filter(tagIndexStrategy::shouldIndex)
|
||||
|
||||
val reuseStatements = mutableMapOf<Int, SQLiteStatement>()
|
||||
|
||||
for (chunk in tagsToIndex.chunked(300)) {
|
||||
if (chunk.isNotEmpty()) {
|
||||
val stmtTags =
|
||||
reuseStatements[chunk.size - 1] ?: run {
|
||||
val sql =
|
||||
buildString {
|
||||
append(sqlInsertTags)
|
||||
repeat(chunk.size - 1) {
|
||||
append(",(?,?,?)")
|
||||
}
|
||||
}
|
||||
|
||||
val new = db.compileStatement(sql)
|
||||
reuseStatements[chunk.size - 1] = new
|
||||
new
|
||||
}
|
||||
|
||||
var index = 1
|
||||
chunk.forEach { tag ->
|
||||
stmtTags.bindLong(index++, headerId)
|
||||
stmtTags.bindString(index++, tag[0])
|
||||
stmtTags.bindString(index++, tag[1])
|
||||
}
|
||||
val stmtTags = db.compileStatement(sqlInsertTags)
|
||||
|
||||
event.tags.forEach { tag ->
|
||||
if (tagIndexStrategy.shouldIndex(event.kind, tag)) {
|
||||
stmtTags.bindLong(1, headerId)
|
||||
stmtTags.bindLong(2, hasher.hash(tag[0], tag[1]))
|
||||
stmtTags.executeInsert()
|
||||
}
|
||||
}
|
||||
@@ -171,11 +162,17 @@ class EventIndexesModule(
|
||||
* By default, we index all tags that have a single letter name and some value
|
||||
*/
|
||||
class IndexingStrategy {
|
||||
fun shouldIndex(tag: Tag) = tag.size >= 2 && tag[0].length == 1
|
||||
fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
) = tag.size >= 2 && tag[0].length == 1
|
||||
}
|
||||
|
||||
fun planQuery(filter: Filter): String {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return makeEverythingQuery()
|
||||
fun planQuery(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
): String {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) ?: return makeEverythingQuery()
|
||||
|
||||
return makeQueryIn(rowIdSubQuery.sql)
|
||||
}
|
||||
@@ -184,7 +181,7 @@ class EventIndexesModule(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): List<T> {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter)
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return if (rowIdSubQuery == null) {
|
||||
db.runQuery(makeEverythingQuery())
|
||||
@@ -198,13 +195,16 @@ class EventIndexesModule(
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach)
|
||||
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>): String {
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) }
|
||||
fun planQuery(
|
||||
filters: List<Filter>,
|
||||
hasher: TagNameValueHasher,
|
||||
): String {
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher) }
|
||||
if (rowIdSubQueries.isEmpty()) return makeEverythingQuery()
|
||||
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
|
||||
return makeQueryIn(unions)
|
||||
@@ -214,7 +214,7 @@ class EventIndexesModule(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): List<T> {
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) }
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
|
||||
if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery())
|
||||
|
||||
@@ -229,7 +229,7 @@ class EventIndexesModule(
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) }
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
|
||||
if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach)
|
||||
|
||||
@@ -311,7 +311,7 @@ class EventIndexesModule(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.countEverything()
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.countEverything()
|
||||
|
||||
return db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
|
||||
}
|
||||
@@ -320,7 +320,7 @@ class EventIndexesModule(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) }
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
|
||||
if (rowIdSubQueries.isEmpty()) return db.countEverything()
|
||||
|
||||
@@ -353,7 +353,7 @@ class EventIndexesModule(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdQuery = prepareRowIDSubQueries(filter) ?: return 0
|
||||
val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return 0
|
||||
return db.runDelete(rowIdQuery.sql, rowIdQuery.args)
|
||||
}
|
||||
|
||||
@@ -361,7 +361,7 @@ class EventIndexesModule(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it) }
|
||||
val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
|
||||
if (rowIdSubqueries.isEmpty()) return 0
|
||||
|
||||
@@ -379,7 +379,10 @@ class EventIndexesModule(
|
||||
// ----------------------------
|
||||
// Inner row id selections
|
||||
// ----------------------------
|
||||
fun prepareRowIDSubQueries(filter: Filter): RowIdSubQuery? {
|
||||
fun prepareRowIDSubQueries(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
): RowIdSubQuery? {
|
||||
if (!filter.isFilledFilter()) return null
|
||||
|
||||
val hasHeaders =
|
||||
@@ -453,8 +456,12 @@ class EventIndexesModule(
|
||||
if (tagName == "d") {
|
||||
equalsOrIn("event_headers.d_tag", tagValues)
|
||||
} else {
|
||||
equals("tag$tagName.tag_name", tagName)
|
||||
equalsOrIn("tag$tagName.tag_value", tagValues)
|
||||
equalsOrIn(
|
||||
"tag$tagName.tag_hash",
|
||||
tagValues.map {
|
||||
hasher.hash(tagName, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-21
@@ -32,37 +32,23 @@ class ReplaceableModule : IModule {
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// Rejects old replaceables
|
||||
// deletes older addressables when inserting new ones
|
||||
// if a newer addressable is inserted the unique index
|
||||
// above will be triggered. Delete cascade will take
|
||||
// care of the event_tags table
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_older_replaceable_event
|
||||
CREATE TRIGGER delete_older_replaceable_event
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.kind >= 10000 AND NEW.kind < 20000) OR (NEW.kind IN (0, 3))
|
||||
BEGIN
|
||||
-- Check for existing newer record
|
||||
SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_headers
|
||||
WHERE
|
||||
event_headers.created_at >= NEW.created_at AND
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey
|
||||
);
|
||||
|
||||
DELETE FROM event_tags
|
||||
WHERE event_header_row_id in (
|
||||
SELECT row_id FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey
|
||||
);
|
||||
|
||||
-- Delete older records if this is the newest
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey;
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.created_at < NEW.created_at;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+19
-9
@@ -40,11 +40,12 @@ class SQLiteEventStore(
|
||||
val tagIndexStrategy: IndexingStrategy = IndexingStrategy(),
|
||||
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
|
||||
companion object {
|
||||
const val DATABASE_VERSION = 1
|
||||
const val DATABASE_VERSION = 2
|
||||
}
|
||||
|
||||
val seedModule = SeedModule()
|
||||
val fullTextSearchModule = FullTextSearchModule()
|
||||
val eventIndexModule = EventIndexesModule(fullTextSearchModule, tagIndexStrategy)
|
||||
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule, tagIndexStrategy)
|
||||
|
||||
val replaceableModule = ReplaceableModule()
|
||||
val addressableModule = AddressableModule()
|
||||
@@ -56,6 +57,7 @@ class SQLiteEventStore(
|
||||
|
||||
val modules =
|
||||
listOf(
|
||||
seedModule,
|
||||
eventIndexModule,
|
||||
replaceableModule,
|
||||
addressableModule,
|
||||
@@ -91,7 +93,19 @@ class SQLiteEventStore(
|
||||
db: SQLiteDatabase,
|
||||
oldVersion: Int,
|
||||
newVersion: Int,
|
||||
) {}
|
||||
) {
|
||||
// Handle all intermediate versions
|
||||
for (version in oldVersion..<newVersion) {
|
||||
when (version) {
|
||||
1 -> {
|
||||
// Upgrade from version 1 to 2
|
||||
// We changed event_tags to use a probabilistic hash as tag
|
||||
modules.reversed().forEach { it.drop(db) }
|
||||
modules.forEach { it.create(db) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDB() {
|
||||
val db = writableDatabase
|
||||
@@ -132,14 +146,10 @@ class SQLiteEventStore(
|
||||
}
|
||||
|
||||
fun transaction(body: Transaction.() -> Unit) {
|
||||
val db = writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
with(Transaction(db)) {
|
||||
writableDatabase.transaction {
|
||||
with(Transaction(this)) {
|
||||
body()
|
||||
}
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
|
||||
class SeedModule : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE seeds (seed_value INTEGER)")
|
||||
|
||||
val insertSeed = "INSERT INTO seeds (seed_value) VALUES (?)"
|
||||
|
||||
val stmt = db.compileStatement(insertSeed)
|
||||
stmt.bindLong(1, RandomInstance.long())
|
||||
stmt.executeInsert()
|
||||
|
||||
// Prevent updates to maintain immutability
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER block_insert_seeds
|
||||
BEFORE INSERT ON seeds
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Inserts are not allowed on this table');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER block_update_seeds
|
||||
BEFORE UPDATE ON seeds
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Inserts are not allowed on this table');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER block_delete_seeds
|
||||
BEFORE DELETE ON seeds
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'Inserts are not allowed on this table');
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getSeed(db: SQLiteDatabase): Long =
|
||||
db.rawQuery("SELECT seed_value FROM seeds LIMIT 1", null).use {
|
||||
it.moveToFirst()
|
||||
it.getLong(0)
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {
|
||||
db.execSQL("DROP TABLE IF EXISTS seeds")
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 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 com.vitorpamplona.quartz.nip01Core.hints.bloom.MurmurHash3
|
||||
|
||||
/**
|
||||
* The seed is used to compute the hash of the Key, which
|
||||
* becomes the seed for the hash of the value
|
||||
*/
|
||||
class TagNameValueHasher(
|
||||
val seed: Long,
|
||||
) {
|
||||
val hasher = MurmurHash3()
|
||||
|
||||
// small performance improvements on inserting
|
||||
val eTagHash by lazy {
|
||||
hasher.hash128x64Half("e".encodeToByteArray(), seed)
|
||||
}
|
||||
val aTagHash by lazy {
|
||||
hasher.hash128x64Half("a".encodeToByteArray(), seed)
|
||||
}
|
||||
|
||||
fun hash(
|
||||
key: ByteArray,
|
||||
value: ByteArray,
|
||||
) = hasher.hash128x64Half(value, hasher.hash128x64Half(key, seed))
|
||||
|
||||
/*
|
||||
* We tried caching these values to avoid recomputation of the hash
|
||||
* But caching on a LruCache<String, Long> is slower than recomputing
|
||||
*/
|
||||
fun hash(
|
||||
key: String,
|
||||
value: String,
|
||||
) = hash(key.encodeToByteArray(), value.encodeToByteArray())
|
||||
|
||||
fun hashATag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), aTagHash)
|
||||
|
||||
fun hashETag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), eTagHash)
|
||||
}
|
||||
+9
-7
@@ -25,7 +25,8 @@ import junit.framework.TestCase
|
||||
import kotlin.test.Test
|
||||
|
||||
class EventDbQueryAssemblerTest {
|
||||
val builder = EventIndexesModule(FullTextSearchModule())
|
||||
val builder = EventIndexesModule(FullTextSearchModule(), SeedModule())
|
||||
val hasher = TagNameValueHasher(0L)
|
||||
|
||||
val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d"
|
||||
val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14"
|
||||
@@ -33,7 +34,7 @@ class EventDbQueryAssemblerTest {
|
||||
|
||||
@Test
|
||||
fun testEmpty() {
|
||||
val sql = builder.planQuery(Filter())
|
||||
val sql = builder.planQuery(Filter(), hasher)
|
||||
TestCase.assertEquals(
|
||||
"SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id",
|
||||
sql,
|
||||
@@ -42,7 +43,7 @@ class EventDbQueryAssemblerTest {
|
||||
|
||||
@Test
|
||||
fun testLimit() {
|
||||
val sql = builder.planQuery(Filter(limit = 10))
|
||||
val sql = builder.planQuery(Filter(limit = 10), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
@@ -63,6 +64,7 @@ class EventDbQueryAssemblerTest {
|
||||
Filter(authors = listOf(key1), kinds = listOf(1, 1111), search = "keywords", limit = 100),
|
||||
Filter(kinds = listOf(20), search = "cats", limit = 30),
|
||||
),
|
||||
hasher,
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
@@ -77,7 +79,7 @@ class EventDbQueryAssemblerTest {
|
||||
|
||||
@Test
|
||||
fun testIdQuery() {
|
||||
val sql = builder.planQuery(Filter(ids = listOf(key1)))
|
||||
val sql = builder.planQuery(Filter(ids = listOf(key1)), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
@@ -91,7 +93,7 @@ class EventDbQueryAssemblerTest {
|
||||
|
||||
@Test
|
||||
fun testAuthors() {
|
||||
val sql = builder.planQuery(Filter(authors = listOf(key1, key2)))
|
||||
val sql = builder.planQuery(Filter(authors = listOf(key1, key2)), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
@@ -105,7 +107,7 @@ class EventDbQueryAssemblerTest {
|
||||
|
||||
@Test
|
||||
fun testAuthorsAndSearch() {
|
||||
val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords"))
|
||||
val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords"), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
@@ -119,7 +121,7 @@ class EventDbQueryAssemblerTest {
|
||||
|
||||
@Test
|
||||
fun testKindAndSearch() {
|
||||
val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords"))
|
||||
val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords"), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
|
||||
Reference in New Issue
Block a user