diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index fde0d95665..02789a115d 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -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))) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt index ce9d0ce51a..27f625d663 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -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(), ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index 216ed70c5f..51c81d6ee6 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -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(), diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index 3e2ac60837..f8ceb93cce 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -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() - - 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 { - 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): String { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + fun planQuery( + filters: List, + 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, db: SQLiteDatabase, ): List { - 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, 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, 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) + }, + ) } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt index 85a72c7489..04a2d3c7fb 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -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(), ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 1041c68230..3c86d92710 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -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.. { + // 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() } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt new file mode 100644 index 0000000000..ece45f6f17 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt @@ -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) {} +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt new file mode 100644 index 0000000000..876f26d010 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt @@ -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 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) +} diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt index 6eacd02329..8575333c51 100644 --- a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt +++ b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt @@ -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