From 307cff87e768e045bf39b73e19308a995f13515f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 2 Jan 2026 14:54:30 -0500 Subject: [PATCH] Adds more settings to pick different ways to build the SQL database --- .../nip01Core/store/sqlite/AddressableTest.kt | 166 ++--- .../nip01Core/store/sqlite/BaseDBTest.kt | 57 ++ .../nip01Core/store/sqlite/BasicTest.kt | 308 ++++---- .../nip01Core/store/sqlite/DeletionTest.kt | 664 +++++++++--------- .../nip01Core/store/sqlite/ExpirationTest.kt | 101 ++- .../store/sqlite/FilterMatcherTest.kt | 152 ++-- .../nip01Core/store/sqlite/LargeDBTests.kt | 2 + .../store/sqlite/QueryAssemblerTest.kt | 558 +++++++-------- .../nip01Core/store/sqlite/ReplaceableTest.kt | 219 +++--- .../store/sqlite/RightToVanishTest.kt | 163 ++--- .../nip01Core/store/sqlite/SearchTest.kt | 50 +- .../store/sqlite/EventIndexesModule.kt | 34 +- .../nip01Core/store/sqlite/EventStore.kt | 4 +- .../store/sqlite/IndexingStrategy.kt | 8 +- .../nip01Core/store/sqlite/QueryBuilder.kt | 238 ++++++- .../store/sqlite/SQLiteEventStore.kt | 6 +- .../nip01Core/store/sqlite/sql/Condition.kt | 2 + .../store/sqlite/sql/SqlSelectionBuilder.kt | 3 + .../store/sqlite/sql/WhereClauseBuilder.kt | 2 +- 19 files changed, 1452 insertions(+), 1285 deletions(-) create mode 100644 quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 6214aecc6..d5b2b7ae7 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -30,91 +28,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail -import org.junit.After -import org.junit.Before import org.junit.Test -class AddressableTest { - private lateinit var db: EventStore - +class AddressableTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testReplacingAddressables() { - val time = TimeUtils.now() - val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + fun testReplacingAddressables() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - db.insert(version1) - - db.assertQuery(version1, Filter(ids = listOf(version1.id))) - db.assertQuery(version1, addressableQuery) - - db.insert(version2) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(version2, Filter(ids = listOf(version2.id))) - db.assertQuery(version2, addressableQuery) - - db.insert(version3) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - } - - @Test - fun testBlockingOldAddressables() { - val time = TimeUtils.now() - val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - - db.insert(version3) - - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - - try { - db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) - } - - try { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) } - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(null, Filter(ids = listOf(version1.id))) - } + @Test + fun testBlockingOldAddressables() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } @Test - fun testTriggersIndexUsage() { - val explainer = - db.store.explainQuery( + fun testTriggersIndexUsage() = + forEachDB { db -> + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 30000 AND + event_headers.pubkey = 'aa' AND + event_headers.d_tag = 'test-tag' AND + event_headers.created_at < 1766686500 AND + event_headers.kind >= 30000 AND event_headers.kind < 40000 + """.trimIndent(), + ) + + assertEquals( """ SELECT * FROM event_headers WHERE @@ -123,21 +121,9 @@ class AddressableTest { event_headers.d_tag = 'test-tag' AND event_headers.created_at < 1766686500 AND event_headers.kind >= 30000 AND event_headers.kind < 40000 + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) """.trimIndent(), + explainer, ) - - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 30000 AND - event_headers.pubkey = 'aa' AND - event_headers.d_tag = 'test-tag' AND - event_headers.created_at < 1766686500 AND - event_headers.kind >= 30000 AND event_headers.kind < 40000 - └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - """.trimIndent(), - explainer, - ) - } + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt new file mode 100644 index 000000000..a1fe2259a --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt @@ -0,0 +1,57 @@ +/** + * 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.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Before + +open class BaseDBTest { + private lateinit var dbs: Map + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + + dbs = + mapOf( + "Default" to EventStore(context, null, indexStrategy = DefaultIndexingStrategy(false, false)), + "IndexAll" to EventStore(context, null, indexStrategy = DefaultIndexingStrategy(true, false)), + "Order by ID" to EventStore(context, null, indexStrategy = DefaultIndexingStrategy(false, true)), + "IndexAll, Order by ID" to EventStore(context, null, indexStrategy = DefaultIndexingStrategy(true, true)), + ) + } + + @After + fun tearDown() { + dbs.forEach { it.value.close() } + } + + fun forEachDB(action: (EventStore) -> Unit) { + dbs.forEach { + println("--------------------") + println(it.key) + println("--------------------") + action(it.value) + } + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt index e0a6537bf..256689628 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -30,18 +28,14 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -class BasicTest { - private lateinit var db: SQLiteEventStore - +class BasicTest : BaseDBTest() { val signer = NostrSignerSync() - companion object Companion { + companion object { val profile = MetadataEvent( id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", @@ -82,168 +76,166 @@ class BasicTest { ) } - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = SQLiteEventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testInsertDeleteEvent() { - val note = signer.sign(TextNoteEvent.build("test1")) + fun testInsertDeleteEvent() = + forEachDB { db -> + val note = signer.sign(TextNoteEvent.build("test1")) - db.insertEvent(note) + db.store.insertEvent(note) - db.assertQuery(note, Filter(ids = listOf(note.id))) + db.store.assertQuery(note, Filter(ids = listOf(note.id))) - db.delete(note.id) + db.store.delete(note.id) - db.assertQuery(null, Filter(ids = listOf(note.id))) + db.store.assertQuery(null, Filter(ids = listOf(note.id))) - db.insertEvent(note) + db.store.insertEvent(note) - db.assertQuery(note, Filter(ids = listOf(note.id))) - } - - @Test - fun testEmptyFilter() { - val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) - val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) - - db.insertEvent(note1) - - db.assertQuery(note1, Filter()) - - db.insertEvent(note2) - - db.assertQuery(listOf(note2, note1), Filter()) - } - - @Test - fun testLimitFilter() { - val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) - val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) - val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3)) - val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4)) - - db.insertEvent(note1) - - db.assertQuery(note1, Filter(limit = 1)) - - db.insertEvent(note2) - db.insertEvent(note3) - db.insertEvent(note4) - - db.assertQuery(listOf(note4), Filter(limit = 1)) - } - - @Test - fun testPubkeyTag() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery( - comment, - Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))), - ) - } - - @Test - fun testTagOnly() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n")))) - } - - @Test - fun testTagWithSinceOnly() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1), - ) - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt), - ) - db.assertQuery( - null, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1), - ) - } - - @Test - fun testTagWithUntilOnly() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.assertQuery( - null, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1), - ) - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt), - ) - db.assertQuery( - comment, - Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1), - ) - } - - @Test - fun testTagWithUntilOnlyEmitting() { - db.insertEvent(comment) - db.insertEvent(profile) - - db.query(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event -> - assertEquals(comment.toJson(), event.toJson()) + db.store.assertQuery(note, Filter(ids = listOf(note.id))) } - } @Test - fun hashCodeTest() { - val note1 = - signer.sign( - TextNoteEvent.build("test1") { - hashtag("AaAa") - }, - ) - val note2 = - signer.sign( - TextNoteEvent.build("test2") { - hashtag("AaAa") - }, - ) - val note3 = - signer.sign( - TextNoteEvent.build("test3") { - hashtag("BBBB") - }, - ) + fun testEmptyFilter() = + forEachDB { db -> + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) - db.insertEvent(note1) - db.insertEvent(note2) - db.insertEvent(note3) + db.store.insertEvent(note1) - val list = - db.query( - Filter( - tags = mapOf("t" to listOf("AaAa")), - ), - ) + db.store.assertQuery(note1, Filter()) - assertEquals(2, list.size) - list.forEach { - assertTrue(it.isTaggedHash("AaAa")) + db.store.insertEvent(note2) + + db.store.assertQuery(listOf(note2, note1), Filter()) + } + + @Test + fun testLimitFilter() = + forEachDB { db -> + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3)) + val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4)) + + db.store.insertEvent(note1) + + db.store.assertQuery(note1, Filter(limit = 1)) + + db.store.insertEvent(note2) + db.store.insertEvent(note3) + db.store.insertEvent(note4) + + db.store.assertQuery(listOf(note4), Filter(limit = 1)) + } + + @Test + fun testPubkeyTag() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + comment, + Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))), + ) + } + + @Test + fun testTagOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n")))) + } + + @Test + fun testTagWithSinceOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt), + ) + db.store.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnly() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.assertQuery( + null, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt), + ) + db.store.assertQuery( + comment, + Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1), + ) + } + + @Test + fun testTagWithUntilOnlyEmitting() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) + + db.store.query(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event -> + assertEquals(comment.toJson(), event.toJson()) + } + } + + @Test + fun hashCodeTest() = + forEachDB { db -> + val note1 = + signer.sign( + TextNoteEvent.build("test1") { + hashtag("AaAa") + }, + ) + val note2 = + signer.sign( + TextNoteEvent.build("test2") { + hashtag("AaAa") + }, + ) + val note3 = + signer.sign( + TextNoteEvent.build("test3") { + hashtag("BBBB") + }, + ) + + db.store.insertEvent(note1) + db.store.insertEvent(note2) + db.store.insertEvent(note3) + + val list = + db.query( + Filter( + tags = mapOf("t" to listOf("AaAa")), + ), + ) + + assertEquals(2, list.size) + list.forEach { + assertTrue(it.isTaggedHash("AaAa")) + } } - } } 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 064024322..9d1ffc3c3 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 @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -33,380 +31,372 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.fail -import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Before import org.junit.Test -class DeletionTest { - private lateinit var db: EventStore - +class DeletionTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testInsertDeleteEvent() { - val note1 = signer.sign(TextNoteEvent.build("test1")) - val note2 = signer.sign(TextNoteEvent.build("test2")) - val note3 = signer.sign(TextNoteEvent.build("test3")) + fun testInsertDeleteEvent() = + forEachDB { db -> + val note1 = signer.sign(TextNoteEvent.build("test1")) + val note2 = signer.sign(TextNoteEvent.build("test2")) + val note3 = signer.sign(TextNoteEvent.build("test3")) - db.insert(note1) - db.insert(note2) - db.insert(note3) - - db.assertQuery(note1, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val deletion = signer.sign(DeletionEvent.build(listOf(note1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + db.insert(note2) + db.insert(note3) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - } - @Test - fun testInsertDeleteEventOfAddressable() { - val time = TimeUtils.now() - val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + fun testInsertDeleteEventOfAddressable() = + forEachDB { db -> + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - db.insert(note1) - - db.assertQuery(note1, Filter(ids = listOf(note1.id))) - - db.insert(note2) - - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - - db.insert(note3) - - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val deletion = signer.sign(DeletionEvent.build(listOf(note1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + + db.insert(note2) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.build(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - } - @Test - fun testInsertDeleteEventOfAddressable2() { - val time = TimeUtils.now() - val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) - val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) - val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + fun testInsertDeleteEventOfAddressable2() = + forEachDB { db -> + val time = TimeUtils.now() + val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time)) + val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) + val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) - db.insert(note1) - db.insert(note2) - db.insert(note3) - - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + db.insert(note2) + db.insert(note3) + + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(null, Filter(ids = listOf(note3.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(null, Filter(ids = listOf(note3.id))) - } - @Test - fun testInsertDeleteWrap() { - val me = NostrSignerSync() - val myFriend = NostrSignerSync() + fun testInsertDeleteWrap() = + forEachDB { db -> + val me = NostrSignerSync() + val myFriend = NostrSignerSync() - val note1 = me.sign(TextNoteEvent.build("test1")) - val wrap1 = GiftWrapEvent.create(note1, me.pubKey) - val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + val note1 = me.sign(TextNoteEvent.build("test1")) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) - db.insert(wrap1) - db.insert(wrap2) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) - - db.insert(randomDeletionToWrap) - - db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) - - db.insert(deletion) - - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - // trying to insert again should fail. - try { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + db.insert(wrap2) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(randomDeletionToWrap) + + db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) } - db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - } + @Test + fun testTriggersIndexUsage() = + forEachDB { db -> + val sql = + """ + SELECT 1 FROM event_tags + WHERE + event_tags.tag_hash IN (3221122, 223322) AND + event_tags.kind = 5 AND + event_tags.pubkey_hash = 22332323 AND + event_tags.created_at >= 1766686500 + """.trimIndent() + + val explainer = + db.store.explainQuery(sql) + + TestCase.assertEquals( + """ + ${sql.replace("\n","\n ")} + └── SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) + """.trimIndent(), + explainer, + ) + } @Test - fun testTriggersIndexUsage() { - val sql = - """ - 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 (3221122, 223322) AND - event_tags.kind = 5 AND - event_tags.pubkey_hash = 22332323 AND - event_tags.created_at >= 1766686500 - """.trimIndent() + fun testDeleteById() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = listOf("ca29c211f", "ca29c211d"), + addresses = emptyList(), + hasher = TagNameValueHasher(0), + ).first() - val explainer = - db.store.explainQuery(sql) - - TestCase.assertEquals( - """ - ${sql.replace("\n","\n ")} - ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) - └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - """.trimIndent(), - explainer, - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + id IN ("ca29c211f","ca29c211d") AND + pubkey_owner_hash = "1573573083296714675" + ├── SEARCH event_headers USING INDEX event_headers_id (id=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteById() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = listOf("ca29c211f", "ca29c211d"), - addresses = emptyList(), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressable() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE - id IN ("ca29c211f","ca29c211d") AND - pubkey_owner_hash = "1573573083296714675" - ├── SEARCH event_headers USING INDEX event_headers_id (id=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag = "a") + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressable() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressablesSingleKind() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30000, "key1", "c"), + Address(30000, "key1", "d"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag = "a") - ) AND - kind >= 30000 AND kind < 40000 - ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressablesSingleKind() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - Address(30000, "key1", "b"), - Address(30000, "key1", "c"), - Address(30000, "key1", "d"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteAddressablesMultipleKinds() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30101, "key1", "c"), + Address(30101, "key1", "d"), + Address(30001, "key2", "e"), + Address(30001, "key2", "f"), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) - ) AND - kind >= 30000 AND kind < 40000 - ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) + OR + (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── MULTI-INDEX OR + │ ├── INDEX 1 + │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + │ └── INDEX 2 + │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } @Test - fun testDeleteAddressablesMultipleKinds() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(30000, "key1", "a"), - Address(30000, "key1", "b"), - Address(30101, "key1", "c"), - Address(30101, "key1", "d"), - Address(30001, "key2", "e"), - Address(30001, "key2", "f"), - ), - hasher = TagNameValueHasher(0), - ).first() + fun testDeleteReplaceables() = + forEachDB { db -> + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(10000, "key1", ""), + Address(10000, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key2", ""), + Address(10001, "key2", ""), + ), + hasher = TagNameValueHasher(0), + ).first() - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE ( - (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) - OR - (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) - ) AND - kind >= 30000 AND kind < 40000 - ├── MULTI-INDEX OR - │ ├── INDEX 1 - │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - │ └── INDEX 2 - │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } - - @Test - fun testDeleteReplaceables() { - val sql = - db.store.deletionModule - .deleteSQL( - pubkey = "key1", - idValues = emptyList(), - addresses = - listOf( - Address(10000, "key1", ""), - Address(10000, "key1", ""), - Address(10001, "key1", ""), - Address(10001, "key1", ""), - Address(10001, "key2", ""), - Address(10001, "key2", ""), - ), - hasher = TagNameValueHasher(0), - ).first() - - TestCase.assertEquals( - """ - DELETE FROM event_headers - WHERE - kind IN ("10000","10001") AND - pubkey = "key1" AND - ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) - ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) - ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) - └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) - """.trimIndent(), - db.store.explainQuery(sql.sql, sql.args), - ) - } + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + kind IN ("10000","10001") AND + pubkey = "key1" AND + ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) + ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt index 6197a8967..0b86746bc 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -20,84 +20,69 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.fail -import org.junit.After import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -class ExpirationTest { - private lateinit var db: EventStore - +class ExpirationTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testDeletingExpiredEvents() { - val time = TimeUtils.now() + fun testDeletingExpiredEvents() = + forEachDB { db -> + val time = TimeUtils.now() - val noteSafe = - signer.sign( - TextNoteEvent.build("test1", createdAt = time + 1) { - expiration(time + 100) - }, - ) + val noteSafe = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 100) + }, + ) - db.insert(noteSafe) + db.insert(noteSafe) - val noteToExpire = - signer.sign( - TextNoteEvent.build("test1", createdAt = time + 1) { - expiration(time + 1) - }, - ) + val noteToExpire = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 1) + }, + ) - db.insert(noteToExpire) + db.insert(noteToExpire) - db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) - Thread.sleep(2000) + Thread.sleep(2000) - db.deleteExpiredEvents() + db.deleteExpiredEvents() - db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) - db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) - } - - @Test - fun testInsertingExpiredEvents() { - val time = TimeUtils.now() - - val note1 = - signer.sign( - TextNoteEvent.build("test1", createdAt = time - 12) { - expiration(time - 10) - }, - ) - - try { - db.insert(note1) - fail("Should not be able to insert expired events") - } catch (e: Exception) { - assertTrue(e is SQLiteConstraintException) + db.assertQuery(null, Filter(ids = listOf(noteToExpire.id))) + db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id))) + } + + @Test + fun testInsertingExpiredEvents() = + forEachDB { db -> + val time = TimeUtils.now() + + val note1 = + signer.sign( + TextNoteEvent.build("test1", createdAt = time - 12) { + expiration(time - 10) + }, + ) + + try { + db.insert(note1) + fail("Should not be able to insert expired events") + } catch (e: Exception) { + assertTrue(e is SQLiteConstraintException) + } } - } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt index 22f3b5c17..88a53e734 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt @@ -20,28 +20,11 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import org.junit.After -import org.junit.Before import org.junit.Test -class FilterMatcherTest { - private lateinit var db: EventStore - - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - +class FilterMatcherTest : BaseDBTest() { val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758" val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d" val createdAt: Long = 1683596206 @@ -83,91 +66,100 @@ class FilterMatcherTest { ) @Test - fun matchIds() { - db.insert(note) + fun matchIds() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(ids = listOf(id))) - db.assertQuery(note, Filter(ids = listOf(id, rootETag))) - db.assertQuery(null, Filter(ids = listOf(rootETag))) - } + db.assertQuery(note, Filter(ids = listOf(id))) + db.assertQuery(note, Filter(ids = listOf(id, rootETag))) + db.assertQuery(null, Filter(ids = listOf(rootETag))) + } @Test - fun matchPubkeys() { - db.insert(note) + fun matchPubkeys() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(authors = listOf(pubkey))) - db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag))) - db.assertQuery(null, Filter(authors = listOf(rootETag))) - } + db.assertQuery(note, Filter(authors = listOf(pubkey))) + db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag))) + db.assertQuery(null, Filter(authors = listOf(rootETag))) + } @Test - fun matchTags() { - db.insert(note) + fun matchTags() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1)))) - db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) - db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id)))) - db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id)))) - } + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id)))) + } @Test - fun matchDualTags() { - db.insert(note) + fun matchDualTags() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) - db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) - db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) - db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) - db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) - db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) - } + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } @Test - fun matchAllTags() { - db.insert(note) + fun matchAllTags() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1)))) - db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) - db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id)))) - db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id)))) - } + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1)))) + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id)))) + } @Test - fun matchDualAllTags() { - db.insert(note) + fun matchDualAllTags() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) - db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) - db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) - db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) - db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) - db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) - } + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag)))) + db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey)))) + db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag)))) + } @Test - fun matchKinds() { - db.insert(note) + fun matchKinds() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(kinds = listOf(kind))) - db.assertQuery(note, Filter(kinds = listOf(kind, 1221))) - db.assertQuery(null, Filter(kinds = listOf(1221))) - } + db.assertQuery(note, Filter(kinds = listOf(kind))) + db.assertQuery(note, Filter(kinds = listOf(kind, 1221))) + db.assertQuery(null, Filter(kinds = listOf(1221))) + } @Test - fun matchSince() { - db.insert(note) + fun matchSince() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(since = createdAt)) - db.assertQuery(note, Filter(since = createdAt - 1)) - db.assertQuery(null, Filter(since = createdAt + 1)) - } + db.assertQuery(note, Filter(since = createdAt)) + db.assertQuery(note, Filter(since = createdAt - 1)) + db.assertQuery(null, Filter(since = createdAt + 1)) + } @Test - fun matchUntil() { - db.insert(note) + fun matchUntil() = + forEachDB { db -> + db.insert(note) - db.assertQuery(note, Filter(until = createdAt)) - db.assertQuery(note, Filter(until = createdAt + 1)) - db.assertQuery(null, Filter(until = createdAt - 1)) - } + db.assertQuery(note, Filter(until = createdAt)) + db.assertQuery(note, Filter(until = createdAt + 1)) + db.assertQuery(null, Filter(until = createdAt - 1)) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index a16e2909b..a62de74ee 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.utils.Log import org.junit.After import org.junit.Before +import org.junit.Ignore import org.junit.Test import org.junit.runner.RunWith import java.util.zip.GZIPInputStream @@ -82,6 +83,7 @@ class LargeDBTests { } @Test + @Ignore("Not testing") fun insertDatabase() { events.forEach { event -> try { diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index 05f30020c..52d4c5423 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -38,59 +39,68 @@ import org.junit.Test class QueryAssemblerTest { val hasher = TagNameValueHasher(0) - val builder = QueryBuilder(FullTextSearchModule(), { hasher }) - val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - private lateinit var db: EventStore + private lateinit var dbAllIndexes: EventStore + private lateinit var dbNoKindIndexes: EventStore @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) + dbAllIndexes = EventStore(context, null, indexStrategy = DefaultIndexingStrategy(true)) + dbNoKindIndexes = EventStore(context, null, indexStrategy = DefaultIndexingStrategy(false)) } @After fun tearDown() { - db.close() + dbAllIndexes.close() + dbNoKindIndexes.close() } - fun explain(f: Filter) = builder.planQuery(f, hasher, db.store.readableDatabase) + fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) - fun explain(f: List) = builder.planQuery(f, hasher, db.store.readableDatabase) + fun EventStore.explain(f: List) = store.queryBuilder.planQuery(f, hasher, store.readableDatabase) @Test fun testEmpty() { Assert.assertEquals( """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY created_at DESC └── SCAN event_headers USING INDEX query_by_created_at_id """.trimIndent(), - explain(Filter()), + dbAllIndexes.explain(Filter()), + ) + + Assert.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + ORDER BY created_at DESC + └── SCAN event_headers USING INDEX query_by_created_at_id + """.trimIndent(), + dbNoKindIndexes.explain(Filter()), ) } @Test fun testCheckDeletionEventExists() { - val query = - explain( - Filter( - kinds = listOf(5), - authors = listOf(key1), - tags = mapOf("e" to listOf(key2)), - since = 1750889190, - ), + val filter = + Filter( + kinds = listOf(5), + authors = listOf(key1), + tags = mapOf("e" to listOf(key2)), + since = 1750889190, ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "2657743813502222172") AND (event_tags.kind = "5") AND (event_tags.pubkey_hash = "1730514094536529999") AND (event_tags.created_at >= "1750889190") + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "2657743813502222172") AND (event_tags.kind = "5") AND (event_tags.pubkey_hash = "1730514094536529999") AND (event_tags.created_at >= "1750889190") ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) │ └── USE TEMP B-TREE FOR DISTINCT @@ -98,44 +108,55 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - query, + dbAllIndexes.explain(filter), + ) + + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "2657743813502222172") AND (event_tags.kind = "5") AND (event_tags.pubkey_hash = "1730514094536529999") AND (event_tags.created_at >= "1750889190") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?) + │ └── USE TEMP B-TREE FOR DISTINCT + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + dbNoKindIndexes.explain(filter), ) } @Test fun testLimit() { - val explainer = explain(Filter(limit = 10)) + val filter = Filter(limit = 10) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY + ORDER BY created_at DESC + LIMIT 10 + └── SCAN event_headers USING INDEX query_by_created_at_id """.trimIndent(), - explainer, + dbAllIndexes.explain(filter), ) } @Test fun testLimits() { - val explainer = explain(listOf(Filter(limit = 10), Filter(limit = 30))) + val filter = listOf(Filter(limit = 10), Filter(limit = 30)) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) UNION - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 30) ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ └── COMPOUND QUERY │ ├── LEFT-MOST SUBQUERY @@ -150,37 +171,35 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - explainer, + dbAllIndexes.explain(filter), ) } @Test fun testAllFeatures() { - val sql = - explain( - listOf( - Filter(limit = 10), - Filter( - authors = listOf(key1), - kinds = listOf(1, 1111), - search = "keywords", - limit = 100, - ), - Filter(kinds = listOf(20), search = "cats", limit = 30), + val filter = + listOf( + Filter(limit = 10), + Filter( + authors = listOf(key1), + kinds = listOf(1, 1111), + search = "keywords", + limit = 100, ), + Filter(kinds = listOf(20), search = "cats", limit = 30), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers ORDER BY event_headers.created_at DESC LIMIT 10) UNION - SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 100) + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC LIMIT 100) UNION - SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC LIMIT 30) ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ └── COMPOUND QUERY │ ├── LEFT-MOST SUBQUERY @@ -195,99 +214,119 @@ class QueryAssemblerTest { │ │ └── SCAN (subquery-3) │ └── UNION USING TEMP B-TREE │ ├── CO-ROUTINE (subquery-5) - │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: - │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ ├── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN event_fts VIRTUAL TABLE INDEX 4: │ └── SCAN (subquery-5) ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test - fun testKind() { - val sql = - explain( - listOf( - Filter( - kinds = listOf(ContactListEvent.KIND), - limit = 30, - ), + fun testSingleFilterConversionToSimpleQuery() { + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 30, + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + WHERE kind = "3" + ORDER BY created_at DESC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) + """.trimIndent(), + dbAllIndexes.explain(filter), + ) + } + + @Test + fun testKinds() { + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 30, + ), + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 30, ), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC LIMIT 30) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "1" ORDER BY event_headers.created_at DESC LIMIT 30) ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=?) - │ └── USE TEMP B-TREE FOR ORDER BY + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ └── SEARCH event_headers USING COVERING INDEX query_by_kind_created (kind=?) + │ └── SCAN (subquery-3) ├── SCAN filtered ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testKindAndDTag() { - val sql = - explain( - listOf( - Filter( - kinds = listOf(ContactListEvent.KIND), - tags = mapOf("d" to listOf("")), - limit = 30, - ), + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("d" to listOf("")), + limit = 30, ), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind = "3") AND (event_headers.d_tag = "") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY + WHERE (kind = "3") AND (d_tag = "") + ORDER BY created_at DESC + LIMIT 30 + └── SEARCH event_headers USING INDEX query_by_kind_created (kind=?) """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testFollowersOf() { - val sql = - explain( - listOf( - Filter( - kinds = listOf(ContactListEvent.KIND), - tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), - limit = 30, - ), + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, ), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) │ └── USE TEMP B-TREE FOR DISTINCT @@ -295,29 +334,27 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testNotificationsOf() { - val sql = - explain( - listOf( - Filter( - tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), - limit = 30, - ), + val filter = + listOf( + Filter( + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, ), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_tags.created_at DESC LIMIT 30 + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_tags.created_at DESC LIMIT 30 ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash (tag_hash=?) │ └── USE TEMP B-TREE FOR DISTINCT @@ -325,30 +362,28 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testTagsAndKinds() { - val sql = - explain( - listOf( - Filter( - kinds = listOf(ContactListEvent.KIND), - tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), - limit = 30, - ), + val filter = + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, ), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") ORDER BY event_tags.created_at DESC LIMIT 30 ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) │ └── USE TEMP B-TREE FOR DISTINCT @@ -356,30 +391,28 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testTagsAndAuthors() { - val sql = - explain( - listOf( - Filter( - authors = listOf(key1), - tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), - limit = 30, - ), + val filter = + listOf( + Filter( + authors = listOf(key1), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, ), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.pubkey_hash = "1730514094536529999") ORDER BY event_tags.created_at DESC LIMIT 30 + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.pubkey_hash = "1730514094536529999") ORDER BY event_tags.created_at DESC LIMIT 30 ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash (tag_hash=?) │ └── USE TEMP B-TREE FOR DISTINCT @@ -387,34 +420,32 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testTwoTags() { - val sql = - explain( - listOf( - Filter( - kinds = listOf(1), - tags = - mapOf( - "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - "t" to listOf("hashtag"), - ), - limit = 30, - ), + val filter = + listOf( + Filter( + kinds = listOf(1), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + "t" to listOf("hashtag"), + ), + limit = 30, ), ) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsIn1 ON event_tagsIn1.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagsIn1.tag_hash = "-6379614208644810021") AND (event_tags.kind = "1") ORDER BY event_tags.created_at DESC LIMIT 30 + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsIn1 ON event_tagsIn1.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagsIn1.tag_hash = "-6379614208644810021") AND (event_tags.kind = "1") ORDER BY event_tags.created_at DESC LIMIT 30 ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=?) │ ├── SEARCH event_tagsIn1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) @@ -423,101 +454,87 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testIdQuery() { - val sql = explain(Filter(ids = listOf(key1))) + val filter = Filter(ids = listOf(key1)) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SEARCH event_headers USING COVERING INDEX event_headers_id (id=?) - └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + WHERE id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + ORDER BY created_at DESC + └── SEARCH event_headers USING INDEX event_headers_id (id=?) """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testAuthors() { - val sql = explain(Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300)) + val filter = Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind IN ("1", "30023")) AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 300 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + WHERE (kind IN ("1", "30023")) AND (pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) + ORDER BY created_at DESC + LIMIT 300 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testAuthorsAndSearch() { - val sql = explain(Filter(authors = listOf(key1, key2, key3), search = "keywords")) + val filter = Filter(authors = listOf(key1, key2, key3), search = "keywords") + println(dbAllIndexes.explain(filter)) TestCase.assertEquals( """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) AND (event_fts MATCH "keywords") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + 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 + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) + ORDER BY event_headers.created_at DESC ├── SCAN event_fts VIRTUAL TABLE INDEX 4: ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testKindAndSearch() { - val sql = explain(Filter(kinds = listOf(1, 1111, 10000), search = "keywords")) + val filter = Filter(kinds = listOf(1, 1111, 10000), search = "keywords") + println(dbAllIndexes.explain(filter)) TestCase.assertEquals( """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111", "10000")) AND (event_fts MATCH "keywords") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + 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 + INNER JOIN event_fts ON event_headers.row_id = event_fts.event_header_row_id + WHERE (event_fts MATCH "keywords") AND (event_headers.kind IN ("1", "1111", "10000")) + ORDER BY event_headers.created_at DESC ├── SCAN event_fts VIRTUAL TABLE INDEX 4: ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testAllTag() { - val sql = explain(Filter(tagsAll = mapOf("p" to listOf(key1, key2)))) + val filter = Filter(tagsAll = mapOf("p" to listOf(key1, key2))) TestCase.assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsAll0_1 ON event_tagsAll0_1.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll0_1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "884286737453847614") AND (event_tagsAll0_1.tag_hash = "-4988851810256311323") + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsAll0_1 ON event_tagsAll0_1.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll0_1.created_at = event_tags.created_at WHERE (event_tags.tag_hash = "884286737453847614") AND (event_tagsAll0_1.tag_hash = "-4988851810256311323") ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=?) │ ├── SEARCH event_tagsAll0_1 USING INDEX query_by_tags_hash (tag_hash=? AND created_at=?) @@ -526,32 +543,30 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testReportLikeFilter() { - val sql = - explain( - Filter( - kinds = listOf(ContactListEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - tags = - mapOf( - "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - ), - limit = 500, - ), + val filter = + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + limit = 500, ) assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.pubkey_hash = "5446767199141196776") ORDER BY event_tags.created_at DESC LIMIT 500 + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.pubkey_hash = "5446767199141196776") ORDER BY event_tags.created_at DESC LIMIT 500 ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=?) │ └── USE TEMP B-TREE FOR DISTINCT @@ -559,32 +574,29 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testFollowersSinceNov2025() { - val sql = - explain( - Filter( - kinds = listOf(ContactListEvent.KIND), - tags = - mapOf( - "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - ), - since = 1764553447, // Nov 2025 - ), + val filter = + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + since = 1764553447, // Nov 2025 ) - println(sql) assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers INNER JOIN ( - SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.created_at >= "1764553447") + SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tags.kind = "3") AND (event_tags.created_at >= "1764553447") ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC ├── CO-ROUTINE filtered │ ├── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?) │ └── USE TEMP B-TREE FOR DISTINCT @@ -592,119 +604,93 @@ class QueryAssemblerTest { ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testAllAddressablesOfAKindDownload() { - val sql = - explain( - Filter( - kinds = listOf(DraftWrapEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - since = 1764553447, // Nov 2025 - ), + val filter = + Filter( + kinds = listOf(DraftWrapEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + since = 1764553447, // Nov 2025 ) - println(sql) assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind = "31234") AND (event_headers.pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (event_headers.created_at >= "1764553447") AND ((event_headers.kind >= 30000 AND event_headers.kind < 40000)) - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SEARCH event_headers USING COVERING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) - └── USE TEMP B-TREE FOR ORDER BY + WHERE (kind = "31234") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY created_at DESC + └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testReplaceablesOfMultipleKindsDownloadByDateLimit() { - val sql = - explain( - Filter( - kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - limit = 20, - since = 1764553447, // Nov 2025 - ), + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + limit = 20, + since = 1764553447, // Nov 2025 ) assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind IN ("0", "10007", "10002")) AND (event_headers.pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (event_headers.created_at >= "1764553447") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 20 - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── CO-ROUTINE filtered - │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) - │ └── USE TEMP B-TREE FOR ORDER BY - ├── SCAN filtered - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") + ORDER BY created_at DESC + LIMIT 20 + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at>?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testReplaceablesOfMultipleKindsDownload() { - val sql = - explain( - Filter( - kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - ), + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND, SearchRelayListEvent.KIND, AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), ) + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind IN ("0", "10007", "10002")) AND (event_headers.pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - ├── SEARCH event_headers USING COVERING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + WHERE (kind IN ("0", "10007", "10002")) AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + ORDER BY created_at DESC + ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } @Test fun testContactCardDownloadFromTrustedKeys() { - val sql = - explain( - Filter( - kinds = listOf(ContactCardEvent.KIND), - authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - tags = - mapOf( - "d" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), - ), - since = 1764553447, // Nov 2025 - ), + val filter = + Filter( + kinds = listOf(ContactCardEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + tags = + mapOf( + "d" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + since = 1764553447, // Nov 2025 ) + assertEquals( """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ( - SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind = "30382") AND (event_headers.pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (event_headers.d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (event_headers.created_at >= "1764553447") AND ((event_headers.kind >= 30000 AND event_headers.kind < 40000)) - ) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + WHERE (kind = "30382") AND (pubkey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (d_tag = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") AND (created_at >= "1764553447") AND ((kind >= 30000 AND kind < 40000)) + ORDER BY created_at DESC ├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) - ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) └── USE TEMP B-TREE FOR ORDER BY """.trimIndent(), - sql, + dbAllIndexes.explain(filter), ) } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index 182414ef2..620b5301f 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -20,9 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context import android.database.sqlite.SQLiteConstraintException -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -30,136 +28,125 @@ import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail -import org.junit.After -import org.junit.Before import org.junit.Test -class ReplaceableTest { - private lateinit var db: EventStore - +class ReplaceableTest : BaseDBTest() { val signer = NostrSignerSync() - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testReplacing() { - val time = TimeUtils.now() - val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) - val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) - val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + fun testReplacing() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - db.insert(version1) - - db.assertQuery(version1, Filter(ids = listOf(version1.id))) - db.assertQuery(version1, addressableQuery) - - db.insert(version2) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(version2, Filter(ids = listOf(version2.id))) - db.assertQuery(version2, addressableQuery) - - db.insert(version3) - - db.assertQuery(null, Filter(ids = listOf(version1.id))) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - } - - @Test - fun testBlockingOldVersions() { - val time = TimeUtils.now() - val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) - val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) - val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) - - val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) - - db.insert(version3) - - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - - try { - db.insert(version2) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) - } - - try { db.insert(version1) - fail("It should not allow inserting an older version") - } catch (e: Exception) { - TestCase.assertTrue(e is SQLiteConstraintException) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) + + db.insert(version3) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) } - db.assertQuery(version3, Filter(ids = listOf(version3.id))) - db.assertQuery(version3, addressableQuery) - db.assertQuery(null, Filter(ids = listOf(version2.id))) - db.assertQuery(null, Filter(ids = listOf(version1.id))) - } + @Test + fun testBlockingOldVersions() = + forEachDB { db -> + val time = TimeUtils.now() + val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time)) + val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) + val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + + db.insert(version3) + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + + try { + db.insert(version2) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + try { + db.insert(version1) + fail("It should not allow inserting an older version") + } catch (e: Exception) { + TestCase.assertTrue(e is SQLiteConstraintException) + } + + db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) + db.assertQuery(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } @Test - fun testTriggersIndexUsageKind0() { - val sql = - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 0 AND - event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 - """.trimIndent() + fun testTriggersIndexUsageKind0() = + forEachDB { db -> + val sql = + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 0 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 + """.trimIndent() - val explainer = db.store.explainQuery(sql) + val explainer = db.store.explainQuery(sql) - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 0 AND - event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 - └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at + val sql = + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 3 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 + """.trimIndent() - val explainer = db.store.explainQuery(sql) + val explainer = db.store.explainQuery(sql) - assertEquals( - """ - SELECT * FROM event_headers - WHERE - event_headers.kind = 3 AND - event_headers.pubkey = 'aa' AND - event_headers.created_at < 1766686500 - └── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at() - db = EventStore(context, null, relayUrl = "testUrl") - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testInsertDeleteEvent() { - val time = TimeUtils.now() - val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time)) - val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1)) - val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2)) + fun testInsertDeleteEvent() = + forEachDB { db -> + val time = TimeUtils.now() + val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time)) + val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1)) + val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2)) - db.insert(note1) - db.insert(note2) - db.insert(note3) - - db.assertQuery(note1, Filter(ids = listOf(note1.id))) - db.assertQuery(note2, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - val vanish = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(vanish) - - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - - // trying to insert again should fail. - try { db.insert(note1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) - } + db.insert(note2) + db.insert(note3) - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(note1.id))) - db.assertQuery(null, Filter(ids = listOf(note2.id))) - db.assertQuery(note3, Filter(ids = listOf(note3.id))) - } + db.assertQuery(note1, Filter(ids = listOf(note1.id))) + db.assertQuery(note2, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + val vanish = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + + // trying to insert again should fail. + try { + db.insert(note1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(note1.id))) + db.assertQuery(null, Filter(ids = listOf(note2.id))) + db.assertQuery(note3, Filter(ids = listOf(note3.id))) + } @Test - fun testInsertDeleteGiftWrap() { - val time = TimeUtils.now() + fun testInsertDeleteGiftWrap() = + forEachDB { db -> + val time = TimeUtils.now() - val me = NostrSignerSync() - val myFriend = NostrSignerSync() + val me = NostrSignerSync() + val myFriend = NostrSignerSync() - val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) - val wrap1 = GiftWrapEvent.create(note1, me.pubKey) - val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) - db.insert(wrap1) - db.insert(wrap2) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(randomVanishToWrap) - - db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - val vanish = me.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) - - db.insert(vanish) - - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - - // trying to insert again should fail. - try { db.insert(wrap1) - fail("Should not be able to insert a deleted event") - } catch (e: SQLiteConstraintException) { - assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) - } + db.insert(wrap2) - db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) - db.assertQuery(null, Filter(ids = listOf(wrap1.id))) - db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) - } + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(randomVanishToWrap) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val vanish = me.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt index f73a3372d..851c95417 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -20,20 +20,14 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import android.content.Context -import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import org.junit.After -import org.junit.Before import org.junit.Test -class SearchTest { - private lateinit var db: SQLiteEventStore - - companion object Companion { +class SearchTest : BaseDBTest() { + companion object { val profile = MetadataEvent( id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", @@ -74,35 +68,25 @@ class SearchTest { ) } - @Before - fun setup() { - val context = ApplicationProvider.getApplicationContext() - db = SQLiteEventStore(context, null) - } - - @After - fun tearDown() { - db.close() - } - @Test - fun testTagWithSearch() { - db.insertEvent(comment) - db.insertEvent(profile) + fun testTagWithSearch() = + forEachDB { db -> + db.store.insertEvent(comment) + db.store.insertEvent(profile) - db.assertQuery(null, Filter(search = "testing1")) - db.assertQuery(comment, Filter(search = "testing")) - db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(search = "testing1")) + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing")) - db.delete(comment.id) + db.store.delete(comment.id) - db.assertQuery(null, Filter(search = "testing")) - db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + db.assertQuery(null, Filter(search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - db.insertEvent(comment) + db.store.insertEvent(comment) - db.assertQuery(comment, Filter(search = "testing")) - db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) - } + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + } } 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 e96c9f93c..4dbe73aa4 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 @@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent class EventIndexesModule( val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, - val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), + val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { override fun create(db: SQLiteDatabase) { db.execSQL( @@ -67,18 +67,38 @@ class EventIndexesModule( // queries by ID (load events) db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") - // queries by limit (latest records), since, until (sync all) alone - db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers (created_at DESC, id)") + 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 + // serves as fall back for the lack of query_by_tags_hash index by default + 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, created_at DESC)") + 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)") - // This is a very slow index to build (80% of the insert time goes here) but it is extremely effective. - db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, created_at DESC)") + // --------------------------------------------------------------- + // This are a very slow indexes (80% of the insert time goes here) + // --------------------------------------------------------------- + if (indexStrategy.indexTagQueriesWithoutKinds) { + // 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: DMs, reports, 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 @@ -173,7 +193,7 @@ class EventIndexesModule( // rebalancing the tree every new insert val indexableTags = ArrayList() 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])) } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index a4c85102e..53653f55d 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -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) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt index 4e50b4225..1f5cf7745 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt @@ -23,6 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Tag interface IndexingStrategy { + val indexTagQueriesWithoutKinds: Boolean + val useAndIndexIdOnOrderBy: Boolean + fun shouldIndex( kind: Int, tag: Tag, @@ -32,7 +35,10 @@ interface IndexingStrategy { /** * By default, we index all tags that have a single letter name and some value */ -class DefaultIndexingStrategy : IndexingStrategy { +class DefaultIndexingStrategy( + override val indexTagQueriesWithoutKinds: Boolean = false, + override val useAndIndexIdOnOrderBy: Boolean = false, +) : IndexingStrategy { override fun shouldIndex( kind: Int, tag: Tag, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index 29bbf3f54..b782ab5f5 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -23,6 +23,8 @@ 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 @@ -34,6 +36,7 @@ import kotlin.collections.component2 class QueryBuilder( val fts: FullTextSearchModule, val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val indexStrategy: IndexingStrategy, ) { // ------------ // Main methods @@ -110,13 +113,37 @@ class QueryBuilder( filter: Filter, hasher: TagNameValueHasher, ): QuerySpec { + val newFilter = filter.toFilterWithDTags() + + if (newFilter.isSimpleQuery()) { + return makeSimpleQuery( + 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(), - emptyList(), - ) + QuerySpec(makeEverythingQuery()) } else { QuerySpec( makeQueryIn(rowIdSubqueries.sql), @@ -129,6 +156,8 @@ class QueryBuilder( filters: List, hasher: TagNameValueHasher, ): QuerySpec { + if (filters.size == 1) return toSql(filters.first(), hasher) + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher) return if (rowIdSubqueries == null) { @@ -144,7 +173,7 @@ class QueryBuilder( } } - private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id" + 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) = """ @@ -153,7 +182,7 @@ class QueryBuilder( $rowIdQuery ) AS filtered ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id + ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""} """.trimIndent() private fun SQLiteDatabase.runQuery(query: QuerySpec): List = @@ -359,12 +388,12 @@ class QueryBuilder( 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 ") + 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 ") + 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) } @@ -373,7 +402,7 @@ class QueryBuilder( 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 ") + 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) } @@ -381,21 +410,21 @@ class QueryBuilder( } if (needHeaders) { - append("INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id ") + 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 ") + 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} ") + 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}") + 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 ") + append("SELECT event_headers.row_id as row_id FROM event_headers") } } @@ -480,22 +509,183 @@ class QueryBuilder( } } - val whereClause = - if (filter.limit != null) { - if (reverseLookup) { - "${clause.conditions} ORDER BY event_tags.created_at DESC LIMIT ${filter.limit}" - } else { - "${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}" + 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) + } } - } else { - clause.conditions } - return QuerySpec("$projection WHERE $whereClause", clause.args) + return QuerySpec(sql, clause.args) } + private fun makeSimpleSearch( + search: String, + ids: List? = null, + authors: List? = null, + kinds: List? = null, + dTags: List? = 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) + } + } + + println(sql) + + return QuerySpec(sql, clause.args) + } + + private fun makeSimpleQuery( + ids: List? = null, + authors: List? = null, + kinds: List? = null, + dTags: List? = 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 { + append("SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers") + if (clause.conditions.isNotEmpty()) { + append("\nWHERE ") + append(clause.conditions) + } + append("\nORDER BY created_at DESC") + if (indexStrategy.useAndIndexIdOnOrderBy) { + append(", event_headers.id ASC") + } + if (limit != null) { + append("\nLIMIT ") + append(limit) + } + } + + println(sql) + + return QuerySpec(sql, clause.args) + } + + class FilterWithDTags( + val ids: List? = null, + val authors: List? = null, + val kinds: List? = null, + val dTags: List? = null, + val nonDTagsIn: Map>? = null, + val nonDTagsAll: Map>? = 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, + val args: List = emptyList(), ) } 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 bc9c837e4..ff02edf6b 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 @@ -41,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 @@ -50,7 +50,7 @@ class SQLiteEventStore( val seedModule = SeedModule() val fullTextSearchModule = FullTextSearchModule() - val eventIndexModule = EventIndexesModule(seedModule::hasher, tagIndexStrategy) + val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy) val replaceableModule = ReplaceableModule() val addressableModule = AddressableModule() @@ -60,7 +60,7 @@ class SQLiteEventStore( val expirationModule = ExpirationModule() val rightToVanishModule = RightToVanishModule(seedModule::hasher) - val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher) + val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy) val modules = listOf( diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt index 738cef423..dd4eb90ca 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -85,4 +85,6 @@ sealed class Condition { data class Or( val conditions: List, ) : Condition() + + class Empty : Condition() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt index 78606720d..b109aff26 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -38,6 +38,9 @@ class SqlSelectionBuilder( */ private fun buildCondition(cond: Condition): String = when (cond) { + is Condition.Empty -> { + "" + } is Condition.Raw -> { cond.condition } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt index 1a42921f6..0cb89f573 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -119,7 +119,7 @@ class WhereClauseBuilder { } fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { - val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.And(emptyList()) + val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty() return SqlSelectionBuilder(condition).build() }