From 7ba2810423c1d2ff45655af199852984a977b33a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 28 Jul 2025 16:49:39 -0400 Subject: [PATCH] Quick event store using SQLite directly. --- .../benchmark/LargeDBInsertBenchmark.kt | 115 +++++ .../benchmark/LargeDBQueryingBenchmark.kt | 139 ++++++ .../nip01Core/store/sqlite/AddressableTest.kt | 104 ++++ .../nip01Core/store/sqlite/AssertUtils.kt | 83 ++++ .../nip01Core/store/sqlite/BasicTest.kt | 207 ++++++++ .../nip01Core/store/sqlite/DeletionTest.kt | 173 +++++++ .../nip01Core/store/sqlite/ExpirationTest.kt | 103 ++++ .../nip01Core/store/sqlite/LargeDBTests.kt | 104 ++++ .../nip01Core/store/sqlite/ReplaceableTest.kt | 104 ++++ .../store/sqlite/RightToVanishTest.kt | 90 ++++ .../nip01Core/store/sqlite/SearchTest.kt | 108 ++++ .../quartz/nip01Core/store/README.md | 193 +++++++ .../store/sqlite/AddressableModule.kt | 72 +++ .../store/sqlite/DeletionRequestModule.kt | 98 ++++ .../nip01Core/store/sqlite/EphemeralModule.kt | 40 ++ .../store/sqlite/EventIndexesModule.kt | 469 ++++++++++++++++++ .../nip01Core/store/sqlite/EventStore.kt | 61 +++ .../store/sqlite/ExpirationModule.kt | 92 ++++ .../store/sqlite/FullTextSearchModule.kt | 92 ++++ .../store/sqlite/ReplaceableModule.kt | 70 +++ .../store/sqlite/RightToVanishModule.kt | 111 +++++ .../store/sqlite/SQLiteEventStore.kt | 134 +++++ .../nip01Core/store/sqlite/StatementCache.kt | 50 ++ .../nip01Core/store/sqlite/sql/Condition.kt | 84 ++++ .../store/sqlite/sql/SqlSelectionBuilder.kt | 106 ++++ .../store/sqlite/sql/WhereClauseBuilder.kt | 120 +++++ .../store/sqlite/EventDbQueryAssemblerTest.kt | 133 +++++ 27 files changed, 3255 insertions(+) create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt create mode 100644 benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt create mode 100644 quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt create mode 100644 quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt new file mode 100644 index 000000000..9dd389743 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBInsertBenchmark.kt @@ -0,0 +1,115 @@ +/** + * 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.benchmark + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import android.util.Log.e +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.collections.flatten + +@RunWith(AndroidJUnit4::class) +class LargeDBInsertBenchmark : BaseLargeCacheBenchmark() { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val allEvents = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + val firstThousandEvents = allEvents.take(1000) + } + + @Test + fun benchInserting1000Events() { + val context = ApplicationProvider.getApplicationContext() + benchmarkRule.measureRepeated { + val db = + this.runWithTimingDisabled { + val db = EventStore(context, "test1.db") + db.store.clearDB() + db + } + firstThousandEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + this.runWithTimingDisabled { + db.store.clearDB() + db.close() + } + } + } + + @Test + fun bench40DeletionRequestsEvents() { + val context = ApplicationProvider.getApplicationContext() + val deletions = allEvents.filterIsInstance() + val deletionIds = deletions.map { it.deleteEventIds() }.flatten().toSet() + val deletionAddresses = deletions.map { it.deleteAddressIds() }.flatten().toSet() + + val toBeDeletedEvents = + allEvents.filter { + (it.id in deletionIds || (it is AddressableEvent && it.addressTag() in deletionAddresses)) + } + + benchmarkRule.measureRepeated { + val db = + this.runWithTimingDisabled { + val db = EventStore(context, "test1.db") + db.store.clearDB() + toBeDeletedEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + + db + } + + deletions.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBInsertBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + + runWithTimingDisabled { + db.store.clearDB() + db.close() + } + } + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt new file mode 100644 index 000000000..805cb08b5 --- /dev/null +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/LargeDBQueryingBenchmark.kt @@ -0,0 +1,139 @@ +/** + * 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.benchmark + +import android.content.Context +import android.database.sqlite.SQLiteException +import android.util.Log +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LargeDBQueryingBenchmark : BaseLargeCacheBenchmark() { + @get:Rule val benchmarkRule = BenchmarkRule() + + companion object { + val allEvents = getEventDB().distinctBy { it.id }.sortedBy { it.createdAt } + } + + lateinit var db: EventStore + + @Before + fun setup() { + println("Running Setup") + val context = ApplicationProvider.getApplicationContext() + + db = EventStore(context, "allEvents.db") + db.store.clearDB() + allEvents.forEach { event -> + try { + db.insert(event) + } catch (e: SQLiteException) { + Log.w("LargeDBQueryingBenchmark", "Error inserting event: ${e.message} for event: $event") + } + } + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun benchQuerying1000Events() { + benchmarkRule.measureRepeated { + db.query(Filter(limit = 1000)) + } + } + + @Test + fun benchQuerying1614GiftWrapsEvents() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + ), + ) + } + } + + @Test + fun benchQueryingEventsByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingMetadataByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingRelayListByAuthor() { + benchmarkRule.measureRepeated { + db.query( + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + ), + ) + } + } + + @Test + fun benchQueryingEmptyReturnByTags() { + benchmarkRule.measureRepeated { + db.query(Filter(tags = mapOf("p" to listOf("176d972f60dcdc3212ed8c92ef85065c176d972f60dcdc3212ed8c92ef85065c")))) + } + } + + @Test + fun benchQueryingEmptyReturnByIds() { + benchmarkRule.measureRepeated { + db.query(Filter(ids = listOf("176d972f60dcdc3212ed8c92ef85065c176d972f60dcdc3212ed8c92ef85065c"))) + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt new file mode 100644 index 000000000..628f24c34 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -0,0 +1,104 @@ +/** + * 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 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 +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class AddressableTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, "test.db", relayUrl = "testUrl") + } + + @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)) + + db.insert(version1) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + + 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))) + } + + @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)) + + 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(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt new file mode 100644 index 000000000..6dee85580 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import junit.framework.TestCase + +fun EventStore.assertQuery( + expected: Event?, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + if (expected == null) { + TestCase.assertEquals(0, queryResult.size) + TestCase.assertEquals(0, countResult) + } else { + TestCase.assertEquals(1, queryResult.size) + TestCase.assertEquals(1, countResult) + TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + } +} + +fun EventStore.assertQuery( + expected: List, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + TestCase.assertEquals(expected.size, queryResult.size) + TestCase.assertEquals(expected.size, countResult) + expected.forEachIndexed { index, event -> + TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + } +} + +fun SQLiteEventStore.assertQuery( + expected: Event?, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + if (expected == null) { + TestCase.assertEquals(0, queryResult.size) + TestCase.assertEquals(0, countResult) + } else { + TestCase.assertEquals(1, queryResult.size) + TestCase.assertEquals(1, countResult) + TestCase.assertEquals(expected.toJson(), queryResult.first().toJson()) + } +} + +fun SQLiteEventStore.assertQuery( + expected: List, + filter: Filter, +) { + val queryResult = query(filter) + val countResult = count(filter) + TestCase.assertEquals(expected.size, queryResult.size) + TestCase.assertEquals(expected.size, countResult) + expected.forEachIndexed { index, event -> + TestCase.assertEquals(event.toJson(), queryResult[index].toJson()) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt new file mode 100644 index 000000000..8aa2a7f3b --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/BasicTest.kt @@ -0,0 +1,207 @@ +/** + * 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 com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +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.nip22Comments.CommentEvent +import junit.framework.TestCase.assertEquals +import org.junit.After +import org.junit.Before +import org.junit.Test + +class BasicTest { + private lateinit var db: SQLiteEventStore + + val signer = NostrSignerSync() + + companion object Companion { + val profile = + MetadataEvent( + id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + createdAt = 1740669816, + tags = + arrayOf( + arrayOf("alt", "User profile for Vitor"), + arrayOf("name", "Vitor"), + ), + content = "{\"name\":\"Vitor\"}", + sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4", + ) + + val comment = + CommentEvent( + id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592", + pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + createdAt = 1747753115, + tags = + arrayOf( + arrayOf("alt", "Reply to geo:drt3n"), + arrayOf("I", "geo:drt3n"), + arrayOf("I", "geo:drt3"), + arrayOf("I", "geo:drt"), + arrayOf("I", "geo:dr"), + arrayOf("I", "geo:d"), + arrayOf("K", "geo"), + arrayOf("i", "geo:drt3n"), + arrayOf("i", "geo:drt3"), + arrayOf("i", "geo:drt"), + arrayOf("i", "geo:dr"), + arrayOf("i", "geo:d"), + arrayOf("k", "geo"), + ), + content = "testing", + sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60", + ) + } + + @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")) + + db.insertEvent(note) + + db.assertQuery(note, Filter(ids = listOf(note.id))) + + db.delete(note.id) + + db.assertQuery(null, Filter(ids = listOf(note.id))) + + db.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()) + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt new file mode 100644 index 000000000..fde0d9566 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -0,0 +1,173 @@ +/** + * 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 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.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +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 + + 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")) + + 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 for this event id 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))) + } + + @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)) + + 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 for this event id 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))) + } + + @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)) + + 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 for this address 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))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt new file mode 100644 index 000000000..9194df913 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -0,0 +1,103 @@ +/** + * 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 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.assertTrue +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ExpirationTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun testDeletingExpiredEvents() { + val time = TimeUtils.now() + + val noteSafe = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 100) + }, + ) + + db.insert(noteSafe) + + val noteToExpire = + signer.sign( + TextNoteEvent.build("test1", createdAt = time + 1) { + expiration(time + 1) + }, + ) + + db.insert(noteToExpire) + + db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id))) + + Thread.sleep(2000) + + 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) + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt new file mode 100644 index 000000000..ac62d9ff3 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -0,0 +1,104 @@ +/** + * 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 android.database.sqlite.SQLiteException +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip40Expiration.isExpired +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.zip.GZIPInputStream +import kotlin.system.measureTimeMillis + +@RunWith(AndroidJUnit4::class) +class LargeDBTests { + companion object { + fun getEventDB(): List { + // This file includes duplicates + val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json") + + return JsonMapper.mapper.readValue>( + GZIPInputStream(fullDBInputStream), + ) + } + + val events = getEventDB().distinctBy { it.id }.filter { !it.isExpired() }.sortedBy { it.createdAt } + } + + private lateinit var db: EventStore + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, "largeDBTest.db") + db.store.clearDB() + } + + @After + fun tearDown() { + db.store.clearDB() + db.close() + } + + @Test + fun insertHeavyEvent() { + events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> + println(event.toJson()) + try { + val measure = + measureTimeMillis { + db.insert(event) + } + if (measure > 1) { + println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") + } + } catch (e: SQLiteException) { + Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") + } + } + } + + @Test + fun insertDatabase() { + events.forEach { event -> + try { + val measure = + measureTimeMillis { + db.insert(event) + } + if (measure > 1) { + println("Inserted event ${event.id} of kind ${event.kind} in $measure ms") + } + } catch (e: SQLiteException) { + Log.w("LargeDBTests", "Error inserting event: ${e.message} for event: ${event.toJson()}") + } + } + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt new file mode 100644 index 000000000..0a85cfc75 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -0,0 +1,104 @@ +/** + * 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 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 +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test + +class ReplaceableTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null, relayUrl = "testUrl") + } + + @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)) + + db.insert(version1) + + db.assertQuery(version1, Filter(ids = listOf(version1.id))) + + db.insert(version2) + + db.assertQuery(null, Filter(ids = listOf(version1.id))) + db.assertQuery(version2, Filter(ids = listOf(version2.id))) + + 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))) + } + + @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)) + + 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(null, Filter(ids = listOf(version2.id))) + db.assertQuery(null, Filter(ids = listOf(version1.id))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt new file mode 100644 index 000000000..578d6aeb7 --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -0,0 +1,90 @@ +/** + * 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 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.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class RightToVanishTest { + private lateinit var db: EventStore + + val signer = NostrSignerSync() + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + 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)) + + 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.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))) + } +} diff --git a/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt new file mode 100644 index 000000000..f73a3372d --- /dev/null +++ b/quartz/src/androidTest/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SearchTest.kt @@ -0,0 +1,108 @@ +/** + * 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 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 { + val profile = + MetadataEvent( + id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe", + pubKey = "70a9b3c312a6b83e476739bd29d60ca700da1d5b982cbca87b5f3d27d4038d67", + createdAt = 1740669816, + tags = + arrayOf( + arrayOf("alt", "User profile for Vitor"), + arrayOf("name", "Vitor"), + ), + content = "{\"name\":\"Vitor\"}", + sig = "977a6152199f17d103d8d56736ed1b7767054464cf9423d017c01c8cdd2344698f0a5e13da8dff98d01bb1f798837e3b6271e1fd1cac861bb90686f622ae6ef4", + ) + + val comment = + CommentEvent( + id = "fecb2ecf61a1433d417a784d10bd1e8ec19a916170a53ca8fb3a15fc666a6592", + pubKey = "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + createdAt = 1747753115, + tags = + arrayOf( + arrayOf("alt", "Reply to geo:drt3n"), + arrayOf("I", "geo:drt3n"), + arrayOf("I", "geo:drt3"), + arrayOf("I", "geo:drt"), + arrayOf("I", "geo:dr"), + arrayOf("I", "geo:d"), + arrayOf("K", "geo"), + arrayOf("i", "geo:drt3n"), + arrayOf("i", "geo:drt3"), + arrayOf("i", "geo:drt"), + arrayOf("i", "geo:dr"), + arrayOf("i", "geo:d"), + arrayOf("k", "geo"), + ), + content = "testing", + sig = "12070e663272f1227c639fb834eb2122fc7bb995f4c49e55ebb1dfe2135ef7347d44810bacd2e64fd26b8826fd47d2800ce6c3d3b579bb3afe39088ffd4faa60", + ) + } + + @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) + + 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.assertQuery(null, Filter(search = "testing")) + db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + + db.insertEvent(comment) + + db.assertQuery(comment, Filter(search = "testing")) + db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing")) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md new file mode 100644 index 000000000..c8084f0a2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/README.md @@ -0,0 +1,193 @@ +# Event Store Module + +This module implements an **Event Store** with nostr-native queries. + +The goal was not to make the fastest database, since there could be multiple optimizations made if +consistency can be sacrificed, but a database that will never crash and never go corrupt. + +## Responsibilities + +- **Storage & Retrieval**: + Stores Nostr events and enables retrieval using Nostr filters + +- **Replaceable Events**: + - Old versions are removed when newer versions arrive. + - Old versions are blocked if newer versions exist. + +- **Ephemeral Events** + - Ephemeral events never stored. + +- **NIP-40 Expirations** + - Manages expiration timestamps and prunes expired events. + - Blocks expired events from being reinserted + +- **NIP-09 Deletion Events** + - Deletes by event id + - Deletes by address until the `created_at` + - Blocks deleted events from being re-inserted. + +- **NIP-62 Right to Vanish** + - Supports deleting an entire user until the `created_at` for enhanced privacy + +- **NIP-50 Full Text Search**: + - Implements content indexing and full text search supporting rich queries over event content. + +- **Immutable Tables** + Triggers ensure event immutability. + +## Indexing Strategy + +The store indexes events using five dedicated tables: +- `event_headers`: stores the canonical event fields. +- `event_tags`: indexes tag values for fast filtering on tag-based queries. +- `event_fts`: for the content of full text search +- `event_expirations`: to control when expired events must be deleted. +- `event_vanish`: to control up to when vanished accounts must be blocked. + +SQL triggers ensure the **immutability of stored events**, preventing accidental or intentional +modifications. + +## Querying + +This module supports optimized query planning, producing efficient SQL for multi-filter evaluation +across fields and tags, including `limit` clauses per filter: + +For instance, the following filter: +```kotlin +store.query( + listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey), kinds = listOf(1, 1111), search = "keywords", limit = 100), + Filter(kinds = listOf(20), search = "cats", limit = 30), + ) +) +``` + +Becomes + +```sql +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 + ORDER BY created_at DESC,id ASC + LIMIT 10 + + UNION + + SELECT event_headers.row_id AS row_id + FROM event_headers + INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id + WHERE event_headers.kind IN (1, 1111) + AND event_headers.pubkey = hexkey + AND event_fts MATCH "keywords" + ORDER BY created_at DESC, id ASC + LIMIT 100 + + UNION + + SELECT event_headers.row_id AS row_id + FROM event_headers + INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id + WHERE event_headers.kind = 20 + AND event_fts MATCH "cats" + ORDER BY created_at DESC,id ASC + LIMIT 30 +) AS filtered ON event_headers.row_id = filtered.row_id +ORDER BY created_at DESC,id +``` + +The union operations support complex filter lists while avoiding redundant data fetching and +duplicated outstreams. + +## How to Use + +The `EventStore` class provides a high-level interface for interacting with the event database. +It is initialized with a `SQLiteDatabase` instance, and it manages the underlying tables and query planning. + +### Initialization + +To initialize the `EventStore` in your Application class: + +```kotlin +val eventStore = EventStore(context, "dbname.db", relayUrlIdentifier) +``` + +### Querying Events + +To query events, use the `query` method with one or more `Filter` objects: + +```kotlin +val filters = listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey)) +) + +val events = eventStore.query(filters) +``` + +or to receive events as the cursor sends: + +```kotlin +val filters = listOf( + Filter(limit = 10), + Filter(authors = listOf(hexkey)) +) + +eventStore.query(filters) { event -> + // do something +} +``` + +`count` and `delete` also accept one or more filters. + +### Inserting Events + +Insert a single event using the `insert` method: + +```kotlin +eventStore.insert(event) +``` + +### Deleting Events + +Events should be deleted by adding a DeletionRequest or a VanishRequest to the db, but to manually +delete an event by ID, use the `delete` method: + +```kotlin +eventStore.delete(event.id) +``` + +### Full-Text Search + +The store supports full-text search using the `search` parameter in filters: + +```kotlin +val result = eventStore.query(Filter(search = "bitcoin", limit = 20)) +``` + +This will match any event whose content contains "bitcoin", returning the most recent 20 results. + +### Periodic cleanup of expired events. + +The store exposes a `deleteExpiredEvents` to be used in a periodic clean up procedure. Users +should use a WorkManager or a coroutine to periodically call `store.deleteExpiredEvents()`. We +recommend a 15-minute window to remove recently expired events from the database. + +Here's an example of a Worker that should be added to your application class. + +```kotlin +class ExpirationWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) { + override fun doWork(): Result { + YourApplication.store.deleteExpiredEvents() + return Result.success() + } +} + +fun schedulePeriodicWork(context: Context) { + val periodicWorkRequest = + PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + + WorkManager.getInstance(context).enqueue(periodicWorkRequest) +} +``` diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt new file mode 100644 index 000000000..b89d2a14e --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class AddressableModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE UNIQUE INDEX addressable_idx + ON event_headers (kind, pubkey, d_tag) + WHERE kind >= 30000 AND kind < 40000 + """.trimIndent(), + ) + + // Rejects old addressables + db.execSQL( + """ + CREATE TRIGGER reject_older_addressable_event + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 30000 AND NEW.kind < 40000) + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag + ); + + DELETE FROM event_tags + WHERE event_header_row_id in ( + SELECT row_id FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag + ); + + DELETE FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey AND + event_headers.d_tag = NEW.d_tag; + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt new file mode 100644 index 000000000..3a20e47ca --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent + +class DeletionRequestModule { + fun create(db: SQLiteDatabase) { + // rejects deleted events. + db.execSQL( + """ + CREATE TRIGGER reject_deleted_events + BEFORE INSERT ON event_headers + FOR EACH ROW + BEGIN + -- Check for ID-based deletion record + SELECT RAISE(ABORT, 'blocked: a deletion event for this event id exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = 5 AND + event_headers.pubkey = NEW.pubkey AND + event_tags.tag_name = 'e' AND + event_tags.tag_value = NEW.id + ); + + -- Check for address-based deletion record + SELECT RAISE(ABORT, 'blocked: a deletion event for this address exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers INNER JOIN event_tags ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = 5 AND + event_headers.pubkey = NEW.pubkey AND + event_tags.tag_name = 'a' AND + event_tags.tag_value = NEW.kind || ':' || NEW.pubkey || ':' || NEW.d_tag + ); + END; + """.trimIndent(), + ) + } + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is DeletionEvent) { + val idValues = event.deleteEventIds() + val idParams = idValues.joinToString(",") { "?" } + + val addresses = event.deleteAddresses() + val addressParams = addresses.joinToString(",") { "(?, ?)" } + val addressValues = addresses.flatMap { listOf(it.kind, it.dTag) } + + val whereClause = + if (idValues.isNotEmpty() && addresses.isNotEmpty()) { + "(id IN ($idParams) OR (kind, d_tag) IN ($addressParams)) AND pubkey = ?" + } else if (idValues.isNotEmpty()) { + "id IN ($idParams) AND pubkey = ?" + } else if (addresses.isNotEmpty()) { + "(kind, d_tag) IN ($addressParams) AND pubkey = ?" + } else { + return + } + val whereParams = idValues.plus(addressValues).plus(event.pubKey).toTypedArray() + + db.execSQL( + """ + DELETE FROM event_headers + WHERE $whereClause; + """.trimIndent(), + whereParams, + ) + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt new file mode 100644 index 000000000..011f114e0 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EphemeralModule.kt @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class EphemeralModule { + fun create(db: SQLiteDatabase) { + // Rejects all ephemeral events. + db.execSQL( + """ + CREATE TRIGGER reject_ephemeral_events + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 20000 AND NEW.kind < 30000) + BEGIN + SELECT RAISE(ABORT, 'blocked: cannot store ephemeral events'); + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt new file mode 100644 index 000000000..d1a2e2436 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -0,0 +1,469 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.EventFactory +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where + +class EventIndexesModule( + val fts: FullTextSearchModule, +) { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_headers ( + row_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + kind INTEGER NOT NULL, + d_tag TEXT, + tags TEXT NOT NULL, + content TEXT NOT NULL, + sig TEXT NOT NULL + ) + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TABLE event_tags ( + event_header_row_id INTEGER, + tag_name TEXT NOT NULL, + tag_value TEXT NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") + db.execSQL("CREATE INDEX query_by_kind_pubkey_idx ON event_headers (created_at desc, kind, pubkey, d_tag)") + db.execSQL("CREATE INDEX query_by_id_idx ON event_headers (created_at desc, id)") + db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_name, tag_value)") + + // Prevent updates to maintain immutability + db.execSQL( + """ + CREATE TRIGGER event_headers_prevent_update + BEFORE UPDATE ON event_headers + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'Error: Updates are not allowed.'); + END; + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TRIGGER event_tags_prevent_update + BEFORE UPDATE ON event_tags + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'Error: Updates are not allowed.'); + END; + """.trimIndent(), + ) + } + + val sqlInsertHeader = + """ + INSERT INTO event_headers + (id, pubkey, created_at, kind, tags, content, sig, d_tag) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + + val sqlInsertTags = + """ + INSERT OR ROLLBACK INTO event_tags + (event_header_row_id, tag_name, tag_value) + VALUES + (?,?,?) + """.trimIndent() + + fun insert( + event: Event, + db: SQLiteDatabase, + ): Long { + val stmt = db.compileStatement(sqlInsertHeader) + stmt.bindString(1, event.id) + stmt.bindString(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.bindLong(4, event.kind.toLong()) + stmt.bindString(5, JsonMapper.mapper.writeValueAsString(event.tags)) + stmt.bindString(6, event.content) + stmt.bindString(7, event.sig) + if (event is AddressableEvent) { + stmt.bindString(8, event.dTag()) + } else { + stmt.bindNull(8) + } + val headerId = stmt.executeInsert() + + val tagsToIndex = event.indexableTags() + + if (tagsToIndex.isNotEmpty()) { + val sql = + buildString { + append(sqlInsertTags) + repeat(tagsToIndex.size - 1) { + append(",(?,?,?)") + } + } + + val stmtTags = db.compileStatement(sql) + + var index = 1 + tagsToIndex.forEach { tag -> + stmtTags.bindLong(index++, headerId) + stmtTags.bindString(index++, tag[0]) + stmtTags.bindString(index++, tag[1]) + } + + stmtTags.executeInsert() + } + + return headerId + } + + fun Event.indexableTags(): List { + val indexableTagNames = extraIndexableTagNames() + return if (indexableTagNames.isNotEmpty()) { + tags.filter { it.size >= 2 && (it[0].length == 1 || it[0] in indexableTagNames) } + } else { + tags.filter { it.size >= 2 && it[0].length == 1 } + } + } + + fun planQuery(filter: Filter): String { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return makeEverythingQuery() + + return makeQueryIn(rowIdSubQuery.sql) + } + + fun query( + filter: Filter, + db: SQLiteDatabase, + ): List { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.runQuery(makeEverythingQuery()) + + return db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args) + } + + fun query( + filter: Filter, + db: SQLiteDatabase, + onEach: (Event) -> Unit, + ) { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + + db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) + } + + fun planQuery(filters: List): String { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + if (rowIdSubQueries.isEmpty()) return makeEverythingQuery() + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + return makeQueryIn(unions) + } + + fun query( + filters: List, + db: SQLiteDatabase, + ): List { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery()) + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + return db.runQuery(makeQueryIn(unions), args) + } + + fun query( + filters: List, + db: SQLiteDatabase, + onEach: (Event) -> Unit, + ) { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + db.runQueryEmitting(makeQueryIn(unions), args, onEach) + } + + private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id" + + private fun makeQueryIn(rowIdQuery: String) = + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ($rowIdQuery) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent() + + private fun SQLiteDatabase.runQuery( + sql: String, + args: List = emptyList(), + ): List = + rawQuery(sql, args.toTypedArray()).use { cursor -> + parseResults(cursor) + } + + private fun parseResults(cursor: Cursor): List { + val events = ArrayList() + + while (cursor.moveToNext()) { + events.add( + EventFactory.create( + cursor.getString(0).intern(), + cursor.getString(1).intern(), + cursor.getLong(2), + cursor.getInt(3), + JsonMapper.mapper.readValue>>(cursor.getString(4)), + cursor.getString(5).intern(), + cursor.getString(6).intern(), + ), + ) + } + + return events + } + + private fun SQLiteDatabase.runQueryEmitting( + sql: String, + args: List = emptyList(), + onEach: (Event) -> Unit, + ) = rawQuery(sql, args.toTypedArray()).use { cursor -> + emitResults(cursor, onEach) + } + + private fun emitResults( + cursor: Cursor, + onEach: (Event) -> Unit, + ) { + while (cursor.moveToNext()) { + onEach( + EventFactory.create( + cursor.getString(0).intern(), + cursor.getString(1).intern(), + cursor.getLong(2), + cursor.getInt(3), + JsonMapper.mapper.readValue>>(cursor.getString(4)), + cursor.getString(5).intern(), + cursor.getString(6).intern(), + ), + ) + } + } + + // -------------- + // Counts + // ------------- + fun count( + filter: Filter, + db: SQLiteDatabase, + ): Int { + val rowIdSubQuery = prepareRowIDSubQueries(filter) ?: return db.countEverything() + + return db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + } + + fun count( + filters: List, + db: SQLiteDatabase, + ): Int { + val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubQueries.isEmpty()) return db.countEverything() + + val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubQueries.flatMap { it.args } + + return db.countIn(unions, args) + } + + private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") + + private fun SQLiteDatabase.countIn( + rowIdQuery: String, + args: List, + ) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args) + + private fun SQLiteDatabase.runCount( + sql: String, + args: List = emptyList(), + ): Int = + rawQuery(sql, args.toTypedArray()).use { cursor -> + cursor.moveToNext() + cursor.getInt(0) + } + + // -------------- + // Deletes + // ------------- + fun delete( + filter: Filter, + db: SQLiteDatabase, + ): Int? { + val rowIdQuery = prepareRowIDSubQueries(filter) ?: return null + return db.runDelete(rowIdQuery.sql, rowIdQuery.args) + } + + fun delete( + filters: List, + db: SQLiteDatabase, + ): Int? { + val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it) } + + if (rowIdSubqueries.isEmpty()) return null + + val unions = rowIdSubqueries.joinToString(" UNION ") { it.sql } + val args = rowIdSubqueries.flatMap { it.args } + + return db.runDelete(unions, args) + } + + private fun SQLiteDatabase.runDelete( + sql: String, + args: List = emptyList(), + ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + + // ---------------------------- + // Inner row id selections + // ---------------------------- + fun prepareRowIDSubQueries(filter: Filter): RowIdSubQuery? { + if (!filter.isFilledFilter()) return null + + val hasHeaders = + with(filter) { + (ids != null && ids.isNotEmpty()) || + (authors != null && authors.isNotEmpty()) || + (kinds != null && kinds.isNotEmpty()) || + (since != null) || + (until != null) || + (tags != null && tags.containsKey("d")) + } + + val hasSearch = (filter.search != null && filter.search.isNotBlank()) + + val projection = + buildString { + val anchorColumn: String + val joins = mutableListOf() + + if (hasHeaders) { + append("SELECT event_headers.row_id as row_id FROM event_headers") + anchorColumn = "event_headers.row_id" + + if (hasSearch) { + joins.add("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = $anchorColumn") + } + + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = $anchorColumn") + } + } + } else if (hasSearch) { + append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") + anchorColumn = "${fts.tableName}.${fts.eventHeaderRowIdName}" + + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = $anchorColumn") + } + } + } else { + // has only tags + filter.tags?.forEach { (tagName, _) -> + if (tagName != "d") { + if (isEmpty()) { + append("SELECT tag$tagName.event_header_row_id as row_id FROM event_tags as tag$tagName") + } else { + joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = tag${tagName.takeLast(1)}.event_header_row_id") + } + } + } + + if (isEmpty()) { + // only limit is present + append("SELECT event_headers.row_id as row_id FROM event_headers") + } + } + + if (joins.isNotEmpty()) { + append(" ${joins.joinToString(" ")}") + } + } + + val clause = + where { + filter.ids?.let { equalsOrIn("event_headers.id", it) } + filter.kinds?.let { equalsOrIn("event_headers.kind", it) } + filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } + filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + + filter.tags?.forEach { (tagName, tagValues) -> + if (tagName == "d") { + equalsOrIn("event_headers.d_tag", tagValues) + } else { + equals("tag$tagName.tag_name", tagName) + equalsOrIn("tag$tagName.tag_value", tagValues) + } + } + + filter.search?.let { match(fts.tableName, it) } + } + + val whereClause = + if (filter.limit != null) { + "${clause.conditions} ORDER BY created_at DESC, id ASC LIMIT ${filter.limit}" + } else { + clause.conditions + } + + return RowIdSubQuery("$projection WHERE $whereClause", clause.args) + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_tags") + db.execSQL("DELETE FROM event_headers") + } + + class RowIdSubQuery( + val sql: String, + val args: List, + ) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt new file mode 100644 index 000000000..4794e4c7b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -0,0 +1,61 @@ +/** + * 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 com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +class EventStore( + context: Context, + dbName: String? = "events.db", + val relayUrl: String? = "wss://quartz.local", +) { + val store = SQLiteEventStore(context, dbName, relayUrl) + + fun insert(event: Event) = store.insertEvent(event) + + fun query(filter: Filter) = store.query(filter) + + fun query(filters: List) = store.query(filters) + + fun query( + filter: Filter, + onEach: (Event) -> Unit, + ) = store.query(filter, onEach) + + fun query( + filters: List, + onEach: (Event) -> Unit, + ) = store.query(filters, onEach) + + fun count(filter: Filter) = store.count(filter) + + fun count(filters: List) = store.count(filters) + + fun delete(filter: Filter) = store.delete(filter) + + fun delete(filters: List) = store.delete(filters) + + fun deleteExpiredEvents() = store.deleteExpiredEvents() + + fun close() = store.close() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt new file mode 100644 index 000000000..39f738712 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip40Expiration.expiration + +class ExpirationModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_expirations ( + event_header_row_id INTEGER, + expiration INTEGER NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + // Rejects old addressables + db.execSQL( + """ + CREATE TRIGGER reject_expired_events + BEFORE INSERT ON event_expirations + FOR EACH ROW + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'blocked: this event is expired') + WHERE NEW.expiration <= unixepoch(); + END; + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX events_exp_id ON event_expirations (event_header_row_id)") + } + + val insertExpiration = + """ + INSERT OR ROLLBACK INTO event_expirations (event_header_row_id, expiration) + VALUES (?, ?) + """.trimIndent() + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + val exp = event.expiration() + if (exp != null && exp > 0) { + val stmt = StatementCache.get(insertExpiration, db) + stmt.bindLong(1, headerId) + stmt.bindLong(2, exp) + stmt.executeInsert() + } + } + + val deleteExpiredEvents = + """ + DELETE FROM event_headers + WHERE row_id IN ( + SELECT event_expirations.event_header_row_id FROM event_expirations + WHERE event_expirations.expiration < unixepoch() + ); + """.trimIndent() + + fun deleteExpiredEvents(db: SQLiteDatabase) { + StatementCache.get(deleteExpiredEvents, db).execute() + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_expirations") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt new file mode 100644 index 000000000..855d9cb99 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +class FullTextSearchModule { + val tableName = "event_fts" + val eventHeaderRowIdName = "event_header_row_id" + val contentName = "content" + + fun create(db: SQLiteDatabase) { + val ftsVersion = FullTextSearchModule().versionFinder(db) + db.execSQL( + """ + CREATE VIRTUAL TABLE $tableName + USING fts$ftsVersion($eventHeaderRowIdName, $contentName) + """, + ) + + // Foreign key cleanup for full text search + db.execSQL( + """ + CREATE TRIGGER fts_foreign_key + AFTER DELETE ON event_headers + FOR EACH ROW + BEGIN + DELETE FROM $tableName + WHERE old.row_id = $tableName.$eventHeaderRowIdName; + END; + """, + ) + } + + val insertFTS = + """ + INSERT OR ROLLBACK INTO $tableName ($eventHeaderRowIdName, $contentName) + VALUES (?, ?) + """.trimIndent() + + fun insert( + event: Event, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is SearchableEvent) { + val stmt = StatementCache.get(insertFTS, db) + stmt.bindLong(1, headerId) + stmt.bindString(2, event.indexableContent()) + stmt.executeInsert() + } + } + + fun versionFinder(db: SQLiteDatabase): Int = + try { + try { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)") + 5 + } catch (e: SQLiteException) { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts4 USING fts4(dummy)") + 4 + } + } catch (e: SQLiteException) { + db.execSQL("CREATE VIRTUAL TABLE dummy_fts3 USING fts3(dummy)") + 3 + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_fts") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt new file mode 100644 index 000000000..688aff89d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +class ReplaceableModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE UNIQUE INDEX replaceable_idx + ON event_headers (kind, pubkey) + WHERE (kind >= 10000 AND kind < 20000) OR (kind IN (0, 3)) + """.trimIndent(), + ) + + // Rejects old replaceables + db.execSQL( + """ + CREATE TRIGGER reject_older_replaceable_event + BEFORE INSERT ON event_headers + FOR EACH ROW + WHEN (NEW.kind >= 10000 AND NEW.kind < 20000) OR (NEW.kind IN (0, 3)) + BEGIN + -- Check for existing newer record + SELECT RAISE(ABORT, 'duplicate: A newer or equally new record already exists') + WHERE EXISTS ( + SELECT 1 FROM event_headers + WHERE + event_headers.created_at >= NEW.created_at AND + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey + ); + + DELETE FROM event_tags + WHERE event_header_row_id in ( + SELECT row_id FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey + ); + + -- Delete older records if this is the newest + DELETE FROM event_headers + WHERE + event_headers.kind = NEW.kind AND + event_headers.pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt new file mode 100644 index 000000000..715caeaba --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent + +class RightToVanishModule { + fun create(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE event_vanish ( + event_header_row_id INTEGER, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE + ) + """.trimIndent(), + ) + + db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey)") + + db.execSQL( + """ + CREATE TRIGGER delete_older_event_vanish + BEFORE INSERT ON event_vanish + FOR EACH ROW + BEGIN + -- Delete older records if this is the newest + DELETE FROM event_vanish + WHERE + event_vanish.created_at < NEW.created_at AND + event_vanish.pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + + db.execSQL( + """ + CREATE TRIGGER delete_events_on_event_vanish + AFTER INSERT ON event_vanish + FOR EACH ROW + BEGIN + DELETE FROM event_headers WHERE created_at < NEW.created_at AND pubkey = NEW.pubkey; + END; + """.trimIndent(), + ) + + // reject new events inside a right to vanish request + db.execSQL( + """ + CREATE TRIGGER reject_events_on_event_vanish + BEFORE INSERT ON event_headers + FOR EACH ROW + BEGIN + SELECT RAISE(ABORT, 'blocked: a request to vanish event exists') + WHERE EXISTS ( + SELECT 1 FROM event_vanish + WHERE + event_vanish.created_at >= NEW.created_at AND + event_vanish.pubkey = NEW.pubkey + ); + END; + """.trimIndent(), + ) + } + + val insertRTV = + """ + INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey, created_at) + VALUES (?, ?, ?) + """.trimIndent() + + fun insert( + event: Event, + relayUrl: String?, + headerId: Long, + db: SQLiteDatabase, + ) { + if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { + val stmt = StatementCache.get(insertRTV, db) + stmt.bindLong(1, headerId) + stmt.bindString(2, event.pubKey) + stmt.bindLong(3, event.createdAt) + stmt.executeInsert() + } + } + + fun deleteAll(db: SQLiteDatabase) { + db.execSQL("DELETE FROM event_vanish") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt new file mode 100644 index 000000000..3d80a4898 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -0,0 +1,134 @@ +/** + * 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 android.database.sqlite.SQLiteConstraintException +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import androidx.core.database.sqlite.transaction +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isEphemeral +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip40Expiration.isExpired + +class SQLiteEventStore( + context: Context, + dbName: String? = "events.db", + val relayUrl: String? = null, +) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { + companion object { + const val DATABASE_VERSION = 1 + } + + val fullTextSearchModule = FullTextSearchModule() + val eventIndexModule = EventIndexesModule(fullTextSearchModule) + + val replaceableModule = ReplaceableModule() + val addressableModule = AddressableModule() + val ephemeralModule = EphemeralModule() + + val deletionModule = DeletionRequestModule() + val expirationModule = ExpirationModule() + val rightToVanishModule = RightToVanishModule() + + override fun onConfigure(db: SQLiteDatabase) { + super.onConfigure(db) + + // makes sure the FKs are sane + db.setForeignKeyConstraintsEnabled(true) + + // SQLite implements mutations by appending them to a log, which it occasionally + // compacts into the database. This is called Write-Ahead Logging (WAL) + db.enableWriteAheadLogging() + + // The DB can be corrupted if the OS is shutdown before sync, which generally + // doesn't happen on Android + db.execSQL("PRAGMA synchronous = OFF") + } + + override fun onCreate(db: SQLiteDatabase) { + eventIndexModule.create(db) + replaceableModule.create(db) + addressableModule.create(db) + ephemeralModule.create(db) + deletionModule.create(db) + expirationModule.create(db) + rightToVanishModule.create(db) + fullTextSearchModule.create(db) + } + + override fun onUpgrade( + db: SQLiteDatabase, + oldVersion: Int, + newVersion: Int, + ) {} + + fun clearDB() { + val db = writableDatabase + fullTextSearchModule.deleteAll(db) + rightToVanishModule.deleteAll(db) + expirationModule.deleteAll(db) + eventIndexModule.deleteAll(db) + } + + fun insertEvent(event: Event): Boolean { + if (event.isExpired()) throw SQLiteConstraintException("blocked: Cannot insert an expired event") + if (event.kind.isEphemeral()) return false + + val db = writableDatabase + db.transaction { + val headerId = eventIndexModule.insert(event, db) + deletionModule.insert(event, headerId, db) + expirationModule.insert(event, headerId, db) + fullTextSearchModule.insert(event, headerId, db) + rightToVanishModule.insert(event, relayUrl, headerId, db) + } + return true + } + + fun query(filter: Filter): List = eventIndexModule.query(filter, readableDatabase) + + fun query(filters: List): List = eventIndexModule.query(filters, readableDatabase) + + fun query( + filter: Filter, + onEach: (Event) -> Unit, + ) = eventIndexModule.query(filter, readableDatabase, onEach) + + fun query( + filters: List, + onEach: (Event) -> Unit, + ) = eventIndexModule.query(filters, readableDatabase, onEach) + + fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase) + + fun count(filters: List): Int = eventIndexModule.count(filters, readableDatabase) + + fun delete(filter: Filter): Int? = eventIndexModule.delete(filter, writableDatabase) + + fun delete(filters: List): Int? = eventIndexModule.delete(filters, writableDatabase) + + fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id)) + + fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt new file mode 100644 index 000000000..8d3078453 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/StatementCache.kt @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteStatement +import android.util.LruCache + +object StatementCache { + data class StatementKey( + val sql: String, + val dbHashcode: Int, + ) + + val cachedStatements = LruCache(10) + + fun get( + sql: String, + db: SQLiteDatabase, + ): SQLiteStatement { + val key = StatementKey(sql, db.hashCode()) + val cached = cachedStatements.get(key) + return if (cached != null) { + cached.clearBindings() + cached + } else { + val stat = db.compileStatement(sql) + cachedStatements.put(key, stat) + stat + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt new file mode 100644 index 000000000..c2cd3a6e6 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/Condition.kt @@ -0,0 +1,84 @@ +/** + * 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.sql + +sealed class Condition { + data class Equals( + val column: String, + val value: Any?, + ) : Condition() + + data class NotEquals( + val column: String, + val value: Any?, + ) : Condition() + + data class GreaterThan( + val column: String, + val value: Any, + ) : Condition() + + data class GreaterThanOrEquals( + val column: String, + val value: Any, + ) : Condition() + + data class LessThan( + val column: String, + val value: Any, + ) : Condition() + + data class LessThanOrEquals( + val column: String, + val value: Any, + ) : Condition() + + data class Like( + val column: String, + val value: String, + ) : Condition() + + data class Match( + val table: String, + val value: String, + ) : Condition() + + data class IsNull( + val column: String, + ) : Condition() + + data class IsNotNull( + val column: String, + ) : Condition() + + data class In( + val column: String, + val values: List, + ) : Condition() + + data class And( + val conditions: List, + ) : Condition() + + data class Or( + val conditions: List, + ) : Condition() +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt new file mode 100644 index 000000000..7e898c244 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/SqlSelectionBuilder.kt @@ -0,0 +1,106 @@ +/** + * 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.sql + +class SqlSelectionBuilder( + private val condition: Condition, +) { + private val selectionArgs = mutableListOf() + + fun build(): WhereClause { + selectionArgs.clear() // Clear previous args for a fresh build + val conditions = buildCondition(condition) + return WhereClause(conditions, selectionArgs) + } + + /** + * Recursively builds the SQL string for a given condition. + * @param cond The [Condition] to build the SQL string for. + * @return The SQL string representation of the condition. + */ + private fun buildCondition(cond: Condition): String = + when (cond) { + is Condition.Equals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} = ?" + } + is Condition.NotEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} != ?" + } + is Condition.GreaterThan -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} > ?" + } + is Condition.GreaterThanOrEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} >= ?" + } + is Condition.LessThan -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} < ?" + } + is Condition.LessThanOrEquals -> { + selectionArgs.add(cond.value.toString()) + "${cond.column} <= ?" + } + is Condition.Like -> { + selectionArgs.add(cond.value) + "${cond.column} LIKE ?" + } + is Condition.Match -> { + selectionArgs.add(cond.value) + "${cond.table} MATCH ?" + } + is Condition.IsNull -> { + "${cond.column} IS NULL" + } + is Condition.IsNotNull -> { + "${cond.column} IS NOT NULL" + } + is Condition.In -> { + if (cond.values.isEmpty()) { + // Handle empty IN clause gracefully, perhaps by making it always false + // or throwing an error, depending on desired behavior. + // For now, let's make it an always false condition to avoid SQL errors. + "1 = 0" // Always false + } else { + val placeholders = cond.values.joinToString(", ") { "?" } + cond.values.forEach { selectionArgs.add(it.toString()) } + "${cond.column} IN ($placeholders)" + } + } + is Condition.And -> { + if (cond.conditions.isEmpty()) { + "1 = 1" // Always true for an empty AND + } else { + cond.conditions.joinToString(" AND ") { "(${buildCondition(it)})" } + } + } + is Condition.Or -> { + if (cond.conditions.isEmpty()) { + "1 = 0" // Always false for an empty OR + } else { + cond.conditions.joinToString(" OR ") { "(${buildCondition(it)})" } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt new file mode 100644 index 000000000..817ab0ea2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/sql/WhereClauseBuilder.kt @@ -0,0 +1,120 @@ +/** + * 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.sql + +class WhereClauseBuilder { + private val conditions = mutableListOf() + + fun equals( + column: String, + value: Any?, + ) = apply { conditions.add(Condition.Equals(column, value)) } + + fun notEquals( + column: String, + value: Any?, + ) = apply { conditions.add(Condition.NotEquals(column, value)) } + + fun greaterThan( + column: String, + value: Any, + ) = apply { conditions.add(Condition.GreaterThan(column, value)) } + + fun greaterThanOrEquals( + column: String, + value: Any, + ) = apply { conditions.add(Condition.GreaterThanOrEquals(column, value)) } + + fun lessThan( + column: String, + value: Any, + ) = apply { conditions.add(Condition.LessThan(column, value)) } + + fun lessThanOrEquals( + column: String, + value: Any, + ) = apply { conditions.add(Condition.LessThanOrEquals(column, value)) } + + fun like( + column: String, + pattern: String, + ) = apply { conditions.add(Condition.Like(column, pattern)) } + + fun match( + table: String, + pattern: String, + ) = apply { conditions.add(Condition.Match(table, pattern)) } + + fun isNull(column: String) = apply { conditions.add(Condition.IsNull(column)) } + + fun isNotNull(column: String) = apply { conditions.add(Condition.IsNotNull(column)) } + + fun isIn( + column: String, + values: List, + ) = apply { conditions.add(Condition.In(column, values)) } + + fun equalsOrIn( + column: String, + values: List, + ) = apply { + if (values.size == 1) { + equals(column, values.first()) + } else { + isIn(column, values) + } + } + + fun and(block: WhereClauseBuilder.() -> Unit) = + apply { + val builder = WhereClauseBuilder().apply(block) + val builtCondition = builder.build() + if (builtCondition != null) { + conditions.add(builtCondition) + } + } + + fun or(block: WhereClauseBuilder.() -> Unit) = + apply { + val builder = WhereClauseBuilder().apply(block) + val builtCondition = builder.build() + if (builtCondition != null) { + conditions.add(builtCondition) + } + } + + fun build(): Condition? = + when (conditions.size) { + 0 -> null + 1 -> conditions.first() + else -> Condition.And(conditions.toList()) + } +} + +fun where(block: WhereClauseBuilder.() -> Unit): WhereClause { + val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList()) + return SqlSelectionBuilder(condition).build() +} + +class WhereClause( + val conditions: String, + val args: List, +) diff --git a/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt new file mode 100644 index 000000000..78e75b101 --- /dev/null +++ b/quartz/src/test/java/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventDbQueryAssemblerTest.kt @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import junit.framework.TestCase +import org.junit.Test + +class EventDbQueryAssemblerTest { + val builder = EventIndexesModule(FullTextSearchModule()) + + val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" + val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" + + @Test + fun testEmpty() { + val sql = builder.planQuery(Filter()) + TestCase.assertEquals( + "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id", + sql, + ) + } + + @Test + fun testLimit() { + val sql = builder.planQuery(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 created_at DESC, id ASC LIMIT 10) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAllFearures() { + val sql = + builder.planQuery( + 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 event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?)) AND (event_headers.pubkey = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 100 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 30) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testIdQuery() { + val sql = builder.planQuery(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 = ?) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthors() { + val sql = builder.planQuery(Filter(authors = listOf(key1, key2))) + 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.pubkey IN (?, ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthorsAndSearch() { + val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords")) + 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 INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.pubkey IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } + + @Test + fun testKindAndSearch() { + val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords")) + 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 INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + """.trimIndent(), + sql, + ) + } +}