Quick event store using SQLite directly.
This commit is contained in:
+115
@@ -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<Context>()
|
||||
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<Context>()
|
||||
val deletions = allEvents.filterIsInstance<DeletionEvent>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -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<Context>()
|
||||
|
||||
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")))
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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<Context>()
|
||||
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)))
|
||||
}
|
||||
}
|
||||
+83
@@ -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<Event>,
|
||||
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<Event>,
|
||||
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())
|
||||
}
|
||||
}
|
||||
+207
@@ -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<Context>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -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<Context>()
|
||||
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)))
|
||||
}
|
||||
}
|
||||
+103
@@ -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<Context>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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<Event> {
|
||||
// This file includes duplicates
|
||||
val fullDBInputStream = getInstrumentation().context.assets.open("nostr_vitor_startup_data.json")
|
||||
|
||||
return JsonMapper.mapper.readValue<ArrayList<Event>>(
|
||||
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<Context>()
|
||||
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()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -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<Context>()
|
||||
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)))
|
||||
}
|
||||
}
|
||||
+90
@@ -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<Context>()
|
||||
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)))
|
||||
}
|
||||
}
|
||||
+108
@@ -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<Context>()
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -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<ExpirationWorker>(15, TimeUnit.MINUTES).build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(periodicWorkRequest)
|
||||
}
|
||||
```
|
||||
+72
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+98
@@ -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<Any>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+469
@@ -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<Tag> {
|
||||
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<Event> {
|
||||
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<Filter>): 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<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): List<Event> {
|
||||
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<Filter>,
|
||||
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<String> = emptyList(),
|
||||
): List<Event> =
|
||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
parseResults(cursor)
|
||||
}
|
||||
|
||||
private fun parseResults(cursor: Cursor): List<Event> {
|
||||
val events = ArrayList<Event>()
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
events.add(
|
||||
EventFactory.create(
|
||||
cursor.getString(0).intern(),
|
||||
cursor.getString(1).intern(),
|
||||
cursor.getLong(2),
|
||||
cursor.getInt(3),
|
||||
JsonMapper.mapper.readValue<Array<Array<String>>>(cursor.getString(4)),
|
||||
cursor.getString(5).intern(),
|
||||
cursor.getString(6).intern(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.runQueryEmitting(
|
||||
sql: String,
|
||||
args: List<String> = 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<Array<Array<String>>>(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<Filter>,
|
||||
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<String>,
|
||||
) = runCount("SELECT COUNT(*) as count FROM ($rowIdQuery)", args)
|
||||
|
||||
private fun SQLiteDatabase.runCount(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
): Int =
|
||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
cursor.moveToNext()
|
||||
cursor.getInt(0)
|
||||
}
|
||||
|
||||
// --------------
|
||||
// Deletes
|
||||
// -------------
|
||||
fun delete(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int? {
|
||||
val rowIdQuery = prepareRowIDSubQueries(filter) ?: return null
|
||||
return db.runDelete(rowIdQuery.sql, rowIdQuery.args)
|
||||
}
|
||||
|
||||
fun delete(
|
||||
filters: List<Filter>,
|
||||
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<String> = 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<String>()
|
||||
|
||||
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<String>,
|
||||
)
|
||||
}
|
||||
@@ -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<Filter>) = store.query(filters)
|
||||
|
||||
fun query(
|
||||
filter: Filter,
|
||||
onEach: (Event) -> Unit,
|
||||
) = store.query(filter, onEach)
|
||||
|
||||
fun query(
|
||||
filters: List<Filter>,
|
||||
onEach: (Event) -> Unit,
|
||||
) = store.query(filters, onEach)
|
||||
|
||||
fun count(filter: Filter) = store.count(filter)
|
||||
|
||||
fun count(filters: List<Filter>) = store.count(filters)
|
||||
|
||||
fun delete(filter: Filter) = store.delete(filter)
|
||||
|
||||
fun delete(filters: List<Filter>) = store.delete(filters)
|
||||
|
||||
fun deleteExpiredEvents() = store.deleteExpiredEvents()
|
||||
|
||||
fun close() = store.close()
|
||||
}
|
||||
+92
@@ -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")
|
||||
}
|
||||
}
|
||||
+92
@@ -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")
|
||||
}
|
||||
}
|
||||
+70
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+111
@@ -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")
|
||||
}
|
||||
}
|
||||
+134
@@ -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<Event> = eventIndexModule.query(filter, readableDatabase)
|
||||
|
||||
fun query(filters: List<Filter>): List<Event> = eventIndexModule.query(filters, readableDatabase)
|
||||
|
||||
fun query(
|
||||
filter: Filter,
|
||||
onEach: (Event) -> Unit,
|
||||
) = eventIndexModule.query(filter, readableDatabase, onEach)
|
||||
|
||||
fun query(
|
||||
filters: List<Filter>,
|
||||
onEach: (Event) -> Unit,
|
||||
) = eventIndexModule.query(filters, readableDatabase, onEach)
|
||||
|
||||
fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase)
|
||||
|
||||
fun count(filters: List<Filter>): Int = eventIndexModule.count(filters, readableDatabase)
|
||||
|
||||
fun delete(filter: Filter): Int? = eventIndexModule.delete(filter, writableDatabase)
|
||||
|
||||
fun delete(filters: List<Filter>): Int? = eventIndexModule.delete(filters, writableDatabase)
|
||||
|
||||
fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
|
||||
|
||||
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase)
|
||||
}
|
||||
+50
@@ -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<StatementKey, SQLiteStatement>(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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Any>,
|
||||
) : Condition()
|
||||
|
||||
data class And(
|
||||
val conditions: List<Condition>,
|
||||
) : Condition()
|
||||
|
||||
data class Or(
|
||||
val conditions: List<Condition>,
|
||||
) : Condition()
|
||||
}
|
||||
+106
@@ -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<String>()
|
||||
|
||||
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)})" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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<Condition>()
|
||||
|
||||
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<Any>,
|
||||
) = apply { conditions.add(Condition.In(column, values)) }
|
||||
|
||||
fun equalsOrIn(
|
||||
column: String,
|
||||
values: List<Any>,
|
||||
) = 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<String>,
|
||||
)
|
||||
+133
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user