Merge branch 'main' into nrobi144/desktop-phase1

This commit is contained in:
Vitor Pamplona
2026-01-07 09:23:57 -05:00
committed by GitHub
81 changed files with 4650 additions and 2445 deletions
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -30,91 +28,91 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Before
import org.junit.Test
class AddressableTest {
private lateinit var db: EventStore
class AddressableTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testReplacingAddressables() {
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
fun testReplacingAddressables() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version1)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
@Test
fun testBlockingOldAddressables() {
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test
fun testBlockingOldAddressables() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test
fun testTriggersIndexUsage() {
val explainer =
db.store.explainQuery(
fun testTriggersIndexUsage() =
forEachDB { db ->
val explainer =
db.store.explainQuery(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 30000 AND
event_headers.pubkey = 'aa' AND
event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000
""".trimIndent(),
)
assertEquals(
"""
SELECT * FROM event_headers
WHERE
@@ -123,21 +121,9 @@ class AddressableTest {
event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000
└── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
""".trimIndent(),
explainer,
)
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 30000 AND
event_headers.pubkey = 'aa' AND
event_headers.d_tag = 'test-tag' AND
event_headers.created_at < 1766686500 AND
event_headers.kind >= 30000 AND event_headers.kind < 40000
└── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
""".trimIndent(),
explainer,
)
}
}
}
@@ -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
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Before
open class BaseDBTest {
private lateinit var dbs: MutableMap<String, EventStore>
fun DefaultIndexingStrategy.name(): String =
"""
indexEventsByCreatedAtAlone=$indexEventsByCreatedAtAlone
indexTagsByCreatedAtAlone=$indexTagsByCreatedAtAlone
indexTagsWithKindAndPubkey=$indexTagsWithKindAndPubkey
useAndIndexIdOnOrderBy=$useAndIndexIdOnOrderBy
""".trimIndent()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
val booleans = listOf(true, false)
dbs = mutableMapOf<String, EventStore>()
// tests all possible DBs
for (indexEventsByCreatedAtAlone in booleans) {
for (indexTagsByCreatedAtAlone in booleans) {
for (indexTagsWithKindAndPubkey in booleans) {
for (useAndIndexIdOnOrderBy in booleans) {
val indexStrategy =
DefaultIndexingStrategy(
indexEventsByCreatedAtAlone,
indexTagsByCreatedAtAlone,
indexTagsWithKindAndPubkey,
useAndIndexIdOnOrderBy,
)
dbs[indexStrategy.name()] =
EventStore(
context = context,
dbName = null,
indexStrategy = indexStrategy,
)
}
}
}
}
}
@After
fun tearDown() {
dbs.forEach { it.value.close() }
}
fun forEachDB(action: (EventStore) -> Unit) {
dbs.forEach {
println("--------------------")
println(it.key)
println("--------------------")
action(it.value)
}
}
}
@@ -20,8 +20,6 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -30,18 +28,14 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class BasicTest {
private lateinit var db: SQLiteEventStore
class BasicTest : BaseDBTest() {
val signer = NostrSignerSync()
companion object Companion {
companion object {
val profile =
MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
@@ -82,168 +76,166 @@ class BasicTest {
)
}
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = SQLiteEventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testInsertDeleteEvent() {
val note = signer.sign(TextNoteEvent.build("test1"))
fun testInsertDeleteEvent() =
forEachDB { db ->
val note = signer.sign(TextNoteEvent.build("test1"))
db.insertEvent(note)
db.store.insertEvent(note)
db.assertQuery(note, Filter(ids = listOf(note.id)))
db.store.assertQuery(note, Filter(ids = listOf(note.id)))
db.delete(note.id)
db.store.delete(note.id)
db.assertQuery(null, Filter(ids = listOf(note.id)))
db.store.assertQuery(null, Filter(ids = listOf(note.id)))
db.insertEvent(note)
db.store.insertEvent(note)
db.assertQuery(note, Filter(ids = listOf(note.id)))
}
@Test
fun testEmptyFilter() {
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
db.insertEvent(note1)
db.assertQuery(note1, Filter())
db.insertEvent(note2)
db.assertQuery(listOf(note2, note1), Filter())
}
@Test
fun testLimitFilter() {
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3))
val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4))
db.insertEvent(note1)
db.assertQuery(note1, Filter(limit = 1))
db.insertEvent(note2)
db.insertEvent(note3)
db.insertEvent(note4)
db.assertQuery(listOf(note4), Filter(limit = 1))
}
@Test
fun testPubkeyTag() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(
comment,
Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))),
)
}
@Test
fun testTagOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n"))))
}
@Test
fun testTagWithSinceOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1),
)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt),
)
db.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnly() {
db.insertEvent(comment)
db.insertEvent(profile)
db.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1),
)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt),
)
db.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnlyEmitting() {
db.insertEvent(comment)
db.insertEvent(profile)
db.query<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
assertEquals(comment.toJson(), event.toJson())
db.store.assertQuery(note, Filter(ids = listOf(note.id)))
}
}
@Test
fun hashCodeTest() {
val note1 =
signer.sign(
TextNoteEvent.build("test1") {
hashtag("AaAa")
},
)
val note2 =
signer.sign(
TextNoteEvent.build("test2") {
hashtag("AaAa")
},
)
val note3 =
signer.sign(
TextNoteEvent.build("test3") {
hashtag("BBBB")
},
)
fun testEmptyFilter() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
db.insertEvent(note1)
db.insertEvent(note2)
db.insertEvent(note3)
db.store.insertEvent(note1)
val list =
db.query<TextNoteEvent>(
Filter(
tags = mapOf("t" to listOf("AaAa")),
),
)
db.store.assertQuery(note1, Filter())
assertEquals(2, list.size)
list.forEach {
assertTrue(it.isTaggedHash("AaAa"))
db.store.insertEvent(note2)
db.store.assertQuery(listOf(note2, note1), Filter())
}
@Test
fun testLimitFilter() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = 3))
val note4 = signer.sign(TextNoteEvent.build("test4", createdAt = 4))
db.store.insertEvent(note1)
db.store.assertQuery(note1, Filter(limit = 1))
db.store.insertEvent(note2)
db.store.insertEvent(note3)
db.store.insertEvent(note4)
db.store.assertQuery(listOf(note4), Filter(limit = 1))
}
@Test
fun testPubkeyTag() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
comment,
Filter(authors = listOf(comment.pubKey), tags = mapOf("I" to listOf("geo:drt3n"))),
)
}
@Test
fun testTagOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(comment, Filter(tags = mapOf("I" to listOf("geo:drt3n"))))
}
@Test
fun testTagWithSinceOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt - 1),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt),
)
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), since = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnly() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.assertQuery(
null,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt - 1),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt),
)
db.store.assertQuery(
comment,
Filter(tags = mapOf("I" to listOf("geo:drt3n")), until = comment.createdAt + 1),
)
}
@Test
fun testTagWithUntilOnlyEmitting() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.store.query<Event>(Filter(tags = mapOf("I" to listOf("geo:drt3n")))) { event ->
assertEquals(comment.toJson(), event.toJson())
}
}
@Test
fun hashCodeTest() =
forEachDB { db ->
val note1 =
signer.sign(
TextNoteEvent.build("test1") {
hashtag("AaAa")
},
)
val note2 =
signer.sign(
TextNoteEvent.build("test2") {
hashtag("AaAa")
},
)
val note3 =
signer.sign(
TextNoteEvent.build("test3") {
hashtag("BBBB")
},
)
db.store.insertEvent(note1)
db.store.insertEvent(note2)
db.store.insertEvent(note3)
val list =
db.query<TextNoteEvent>(
Filter(
tags = mapOf("t" to listOf("AaAa")),
),
)
assertEquals(2, list.size)
list.forEach {
assertTrue(it.isTaggedHash("AaAa"))
}
}
}
}
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -33,380 +31,378 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class DeletionTest {
private lateinit var db: EventStore
class DeletionTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<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"))
fun testInsertDeleteEvent() =
forEachDB { db ->
val note1 = signer.sign(TextNoteEvent.build("test1"))
val note2 = signer.sign(TextNoteEvent.build("test2"))
val note3 = signer.sign(TextNoteEvent.build("test3"))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
db.insert(note2)
db.insert(note3)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteEventOfAddressable() {
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
fun testInsertDeleteEventOfAddressable() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
db.insert(note1)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.insert(note2)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.insert(note2)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteEventOfAddressable2() {
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
fun testInsertDeleteEventOfAddressable2() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(LongTextNoteEvent.build("my cool blog, version 1", "title", dTag = "my-cool-blog", createdAt = time))
val note2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1))
val note3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
db.insert(note2)
db.insert(note3)
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(null, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteWrap() {
val me = NostrSignerSync()
val myFriend = NostrSignerSync()
fun testInsertDeleteWrap() =
forEachDB { db ->
val me = NostrSignerSync()
val myFriend = NostrSignerSync()
val note1 = me.sign(TextNoteEvent.build("test1"))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
val note1 = me.sign(TextNoteEvent.build("test1"))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
db.insert(wrap1)
db.insert(wrap2)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(randomDeletionToWrap)
db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id)))
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val deletion = me.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
db.insert(wrap2)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(randomDeletionToWrap)
db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id)))
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val deletion = me.sign(DeletionEvent.build(listOf(wrap1)))
db.insert(deletion)
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
}
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
}
@Test
fun testTriggersIndexUsage() =
forEachDB { db ->
var sql = db.store.deletionModule.rejectDeletedEventsSQLTemplate()
sql = sql.replace("NEW.etag_hash", "3221122")
sql = sql.replace("NEW.atag_hash", "223322")
sql = sql.replace("NEW.pubkey_owner_hash", "22332323")
sql = sql.replace("NEW.created_at", "1766686500")
val explainer = db.store.explainQuery(sql)
if (db.indexStrategy.indexTagsWithKindAndPubkey) {
TestCase.assertEquals(
"""
|$sql
|└── SEARCH event_tags USING COVERING INDEX query_by_tags_hash_kind_pubkey (tag_hash=? AND kind=? AND pubkey_hash=? AND created_at>?)
""".trimMargin(),
explainer,
)
} else {
TestCase.assertEquals(
"""
|$sql
|└── SEARCH event_tags USING INDEX query_by_tags_hash_kind (tag_hash=? AND kind=? AND created_at>?)
""".trimMargin(),
explainer,
)
}
}
@Test
fun testTriggersIndexUsage() {
val sql =
"""
SELECT 1 FROM event_tags
INNER JOIN event_headers
ON event_headers.row_id = event_tags.event_header_row_id
WHERE
event_tags.tag_hash IN (3221122, 223322) AND
event_headers.kind = 5 AND
event_headers.created_at >= 1766686500 AND
event_headers.pubkey_owner_hash = 22332323
""".trimIndent()
fun testDeleteById() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = listOf("ca29c211f", "ca29c211d"),
addresses = emptyList(),
hasher = TagNameValueHasher(0),
).first()
val explainer =
db.store.explainQuery(sql)
TestCase.assertEquals(
"""
${sql.replace("\n","\n ")}
├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
""".trimIndent(),
explainer,
)
}
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE
id IN ("ca29c211f","ca29c211d") AND
pubkey_owner_hash = "1573573083296714675"
├── SEARCH event_headers USING INDEX event_headers_id (id=?)
── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteById() {
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = listOf("ca29c211f", "ca29c211d"),
addresses = emptyList(),
hasher = TagNameValueHasher(0),
).first()
fun testDeleteAddressable() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE
id IN ("ca29c211f","ca29c211d") AND
pubkey_owner_hash = "1573573083296714675"
├── SEARCH event_headers USING INDEX event_headers_id (id=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag = "a")
) AND
kind >= 30000 AND kind < 40000
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteAddressable() {
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
),
hasher = TagNameValueHasher(0),
).first()
fun testDeleteAddressablesSingleKind() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
Address(30000, "key1", "b"),
Address(30000, "key1", "c"),
Address(30000, "key1", "d"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag = "a")
) AND
kind >= 30000 AND kind < 40000
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d"))
) AND
kind >= 30000 AND kind < 40000
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteAddressablesSingleKind() {
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
Address(30000, "key1", "b"),
Address(30000, "key1", "c"),
Address(30000, "key1", "d"),
),
hasher = TagNameValueHasher(0),
).first()
fun testDeleteAddressablesMultipleKinds() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
Address(30000, "key1", "b"),
Address(30101, "key1", "c"),
Address(30101, "key1", "d"),
Address(30001, "key2", "e"),
Address(30001, "key2", "f"),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d"))
) AND
kind >= 30000 AND kind < 40000
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b"))
OR
(kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d"))
) AND
kind >= 30000 AND kind < 40000
├── MULTI-INDEX OR
│ ├── INDEX 1
│ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
│ └── INDEX 2
│ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteAddressablesMultipleKinds() {
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(30000, "key1", "a"),
Address(30000, "key1", "b"),
Address(30101, "key1", "c"),
Address(30101, "key1", "d"),
Address(30001, "key2", "e"),
Address(30001, "key2", "f"),
),
hasher = TagNameValueHasher(0),
).first()
fun testDeleteReplaceables() =
forEachDB { db ->
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(10000, "key1", ""),
Address(10000, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key2", ""),
Address(10001, "key2", ""),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE (
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b"))
OR
(kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d"))
) AND
kind >= 30000 AND kind < 40000
├── MULTI-INDEX OR
── INDEX 1
│ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
│ └── INDEX 2
│ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
@Test
fun testDeleteReplaceables() {
val sql =
db.store.deletionModule
.deleteSQL(
pubkey = "key1",
idValues = emptyList(),
addresses =
listOf(
Address(10000, "key1", ""),
Address(10000, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key1", ""),
Address(10001, "key2", ""),
Address(10001, "key2", ""),
),
hasher = TagNameValueHasher(0),
).first()
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE
kind IN ("10000","10001") AND
pubkey = "key1" AND
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
TestCase.assertEquals(
"""
DELETE FROM event_headers
WHERE
kind IN ("10000","10001") AND
pubkey = "key1" AND
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?)
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
""".trimIndent(),
db.store.explainQuery(sql.sql, sql.args),
)
}
}
@@ -20,84 +20,69 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ExpirationTest {
private lateinit var db: EventStore
class ExpirationTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testDeletingExpiredEvents() {
val time = TimeUtils.now()
fun testDeletingExpiredEvents() =
forEachDB { db ->
val time = TimeUtils.now()
val noteSafe =
signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 100)
},
)
val noteSafe =
signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 100)
},
)
db.insert(noteSafe)
db.insert(noteSafe)
val noteToExpire =
signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 1)
},
)
val noteToExpire =
signer.sign(
TextNoteEvent.build("test1", createdAt = time + 1) {
expiration(time + 1)
},
)
db.insert(noteToExpire)
db.insert(noteToExpire)
db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id)))
db.assertQuery(noteToExpire, Filter(ids = listOf(noteToExpire.id)))
Thread.sleep(2000)
Thread.sleep(2000)
db.deleteExpiredEvents()
db.deleteExpiredEvents()
db.assertQuery(null, Filter(ids = listOf(noteToExpire.id)))
db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id)))
}
@Test
fun testInsertingExpiredEvents() {
val time = TimeUtils.now()
val note1 =
signer.sign(
TextNoteEvent.build("test1", createdAt = time - 12) {
expiration(time - 10)
},
)
try {
db.insert(note1)
fail("Should not be able to insert expired events")
} catch (e: Exception) {
assertTrue(e is SQLiteConstraintException)
db.assertQuery(null, Filter(ids = listOf(noteToExpire.id)))
db.assertQuery(noteSafe, Filter(ids = listOf(noteSafe.id)))
}
@Test
fun testInsertingExpiredEvents() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 =
signer.sign(
TextNoteEvent.build("test1", createdAt = time - 12) {
expiration(time - 10)
},
)
try {
db.insert(note1)
fail("Should not be able to insert expired events")
} catch (e: Exception) {
assertTrue(e is SQLiteConstraintException)
}
}
}
}
@@ -0,0 +1,165 @@
/**
* 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 org.junit.Test
class FilterMatcherTest : BaseDBTest() {
val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val createdAt: Long = 1683596206
val kind = 1
val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"
val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"
val note =
Event(
id,
pubkey,
createdAt,
kind,
arrayOf(
arrayOf("e", rootETag, "", "root"),
arrayOf("e", replyETag, "", "reply"),
arrayOf("p", pTag1),
arrayOf("p", pTag1),
arrayOf("p", pTag2),
arrayOf("p", pTag3),
arrayOf("p", pTag4),
arrayOf("p", pTag5),
arrayOf("p", pTag6),
arrayOf("p", pTag7),
arrayOf("p", pTag8),
),
"Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
)
@Test
fun matchIds() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(ids = listOf(id)))
db.assertQuery(note, Filter(ids = listOf(id, rootETag)))
db.assertQuery(null, Filter(ids = listOf(rootETag)))
}
@Test
fun matchPubkeys() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(authors = listOf(pubkey)))
db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag)))
db.assertQuery(null, Filter(authors = listOf(rootETag)))
}
@Test
fun matchTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id))))
}
@Test
fun matchDualTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchAllTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id))))
}
@Test
fun matchDualAllTags() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchKinds() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(kinds = listOf(kind)))
db.assertQuery(note, Filter(kinds = listOf(kind, 1221)))
db.assertQuery(null, Filter(kinds = listOf(1221)))
}
@Test
fun matchSince() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(since = createdAt))
db.assertQuery(note, Filter(since = createdAt - 1))
db.assertQuery(null, Filter(since = createdAt + 1))
}
@Test
fun matchUntil() =
forEachDB { db ->
db.insert(note)
db.assertQuery(note, Filter(until = createdAt))
db.assertQuery(note, Filter(until = createdAt + 1))
db.assertQuery(null, Filter(until = createdAt - 1))
}
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.Log
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
import java.util.zip.GZIPInputStream
@@ -82,6 +83,7 @@ class LargeDBTests {
}
@Test
@Ignore("Not testing")
fun insertDatabase() {
events.forEach { event ->
try {
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
@@ -30,140 +28,125 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.fail
import org.junit.After
import org.junit.Before
import org.junit.Test
class ReplaceableTest {
private lateinit var db: EventStore
class ReplaceableTest : BaseDBTest() {
val signer = NostrSignerSync()
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testReplacing() {
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
fun testReplacing() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version1)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
@Test
fun testBlockingOldVersions() {
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
db.assertQuery(version1, Filter(ids = listOf(version1.id)))
db.assertQuery(version1, addressableQuery)
db.insert(version2)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(version2, Filter(ids = listOf(version2.id)))
db.assertQuery(version2, addressableQuery)
db.insert(version3)
db.assertQuery(null, Filter(ids = listOf(version1.id)))
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test
fun testBlockingOldVersions() =
forEachDB { db ->
val time = TimeUtils.now()
val version1 = signer.sign(MetadataEvent.createNew("Vitor 1", createdAt = time))
val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1))
val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2))
val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag())))
db.insert(version3)
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
try {
db.insert(version2)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
try {
db.insert(version1)
fail("It should not allow inserting an older version")
} catch (e: Exception) {
TestCase.assertTrue(e is SQLiteConstraintException)
}
db.assertQuery(version3, Filter(ids = listOf(version3.id)))
db.assertQuery(version3, addressableQuery)
db.assertQuery(null, Filter(ids = listOf(version2.id)))
db.assertQuery(null, Filter(ids = listOf(version1.id)))
}
@Test
fun testTriggersIndexUsageKind0() {
val explainer =
db.store.explainQuery(
fun testTriggersIndexUsageKind0() =
forEachDB { db ->
val sql =
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
""".trimIndent(),
)
event_headers.created_at < 1766686500
""".trimIndent()
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
└── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?)
""".trimIndent(),
explainer,
)
}
val explainer = db.store.explainQuery(sql)
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 0 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500
└── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
""".trimIndent(),
explainer,
)
}
@Test
fun testTriggersIndexUsageKind3() {
val explainer =
db.store.explainQuery(
fun testTriggersIndexUsageKind3() =
forEachDB { db ->
val sql =
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
""".trimIndent(),
)
event_headers.created_at < 1766686500
""".trimIndent()
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500 AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
└── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?)
""".trimIndent(),
explainer,
)
}
val explainer = db.store.explainQuery(sql)
assertEquals(
"""
SELECT * FROM event_headers
WHERE
event_headers.kind = 3 AND
event_headers.pubkey = 'aa' AND
event_headers.created_at < 1766686500
└── SEARCH event_headers USING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
""".trimIndent(),
explainer,
)
}
}
@@ -20,9 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import android.database.sqlite.SQLiteConstraintException
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -30,107 +28,94 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
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
class RightToVanishTest : BaseDBTest() {
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))
fun testInsertDeleteEvent() =
forEachDB { db ->
val time = TimeUtils.now()
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = time))
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = time + 1))
val note3 = signer.sign(TextNoteEvent.build("test3", createdAt = time + 2))
db.insert(note1)
db.insert(note2)
db.insert(note3)
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val vanish = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.insert(note2)
db.insert(note3)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
val vanish = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
// trying to insert again should fail.
try {
db.insert(note1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(note1.id)))
db.assertQuery(null, Filter(ids = listOf(note2.id)))
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
}
@Test
fun testInsertDeleteGiftWrap() {
val time = TimeUtils.now()
fun testInsertDeleteGiftWrap() =
forEachDB { db ->
val time = TimeUtils.now()
val me = NostrSignerSync()
val myFriend = NostrSignerSync()
val me = NostrSignerSync()
val myFriend = NostrSignerSync()
val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time))
val wrap1 = GiftWrapEvent.create(note1, me.pubKey)
val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey)
db.insert(wrap1)
db.insert(wrap2)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2))
db.insert(randomVanishToWrap)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val vanish = me.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.insert(wrap2)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
}
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(randomVanishToWrap)
db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
val vanish = me.sign(RequestToVanishEvent.build("wss://quartz.local", createdAt = time + 2))
db.insert(vanish)
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
// trying to insert again should fail.
try {
db.insert(wrap1)
fail("Should not be able to insert a deleted event")
} catch (e: SQLiteConstraintException) {
assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message)
}
db.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
}
}
@@ -20,20 +20,14 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import org.junit.After
import org.junit.Before
import org.junit.Test
class SearchTest {
private lateinit var db: SQLiteEventStore
companion object Companion {
class SearchTest : BaseDBTest() {
companion object {
val profile =
MetadataEvent(
id = "490d7439e530423f2540d4f2bdb73a0a2935f3df9e1f2a6f699a140c7db311fe",
@@ -74,35 +68,25 @@ class SearchTest {
)
}
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = SQLiteEventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
@Test
fun testTagWithSearch() {
db.insertEvent(comment)
db.insertEvent(profile)
fun testTagWithSearch() =
forEachDB { db ->
db.store.insertEvent(comment)
db.store.insertEvent(profile)
db.assertQuery(null, Filter(search = "testing1"))
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing"))
db.assertQuery(null, Filter(search = "testing1"))
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "testing"))
db.delete(comment.id)
db.store.delete(comment.id)
db.assertQuery(null, Filter(search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.assertQuery(null, Filter(search = "testing"))
db.assertQuery(null, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
db.insertEvent(comment)
db.store.insertEvent(comment)
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
}
db.assertQuery(comment, Filter(search = "testing"))
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
}
}
@@ -30,30 +30,45 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
class DeletionRequestModule(
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IModule {
fun rejectDeletedEventsSQLTemplate(): String =
if (indexStrategy.indexTagsWithKindAndPubkey) {
"""
|SELECT 1 FROM event_tags
|WHERE
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
| event_tags.kind = 5 AND
| event_tags.pubkey_hash = NEW.pubkey_owner_hash AND
| event_tags.created_at >= NEW.created_at
""".trimMargin()
} else {
"""
|SELECT 1 FROM event_tags
|WHERE
| event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
| event_tags.kind = 5 AND
| event_tags.created_at >= NEW.created_at AND
| event_tags.pubkey_hash = NEW.pubkey_owner_hash
""".trimMargin()
}
/**
* Creates a trigger to reject events that have been
* deleted by ID or ATag including GiftWraps that
* must be checked against the p-tag (pubkey_owner_hash)
*/
override fun create(db: SQLiteDatabase) {
val sql = rejectDeletedEventsSQLTemplate().replace("\n", "\n ")
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 exists')
WHERE EXISTS (
SELECT 1 FROM event_tags
INNER JOIN event_headers
ON event_headers.row_id = event_tags.event_header_row_id
WHERE
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
event_headers.kind = 5 AND
event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND
event_headers.created_at >= NEW.created_at
$sql
);
END;
""".trimIndent(),
@@ -20,23 +20,16 @@
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.EventFactory
class EventIndexesModule(
val fts: FullTextSearchModule,
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IModule {
override fun create(db: SQLiteDatabase) {
db.execSQL(
@@ -63,19 +56,53 @@ class EventIndexesModule(
CREATE TABLE event_tags (
event_header_row_id INTEGER NOT NULL,
tag_hash INTEGER NOT NULL,
created_at INTEGER NOT NULL,
kind INTEGER NOT NULL,
pubkey_hash INTEGER NOT NULL,
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
)
""".trimIndent(),
)
// queries by ID (load events)
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)")
db.execSQL("CREATE INDEX query_by_kind_pubkey_dtag_idx ON event_headers (kind, pubkey, d_tag)")
db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers (created_at desc, id)")
// need to check if this is actually needed.
db.execSQL("CREATE INDEX query_by_created_at_kind_key ON event_headers (created_at desc, kind, pubkey)")
val orderBy =
if (indexStrategy.useAndIndexIdOnOrderBy) {
"created_at DESC, id ASC"
} else {
"created_at DESC"
}
// queries by limit (latest records), since, until (sync all) alone without any filter by kind.. rare
if (indexStrategy.indexEventsByCreatedAtAlone) {
db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers ($orderBy)")
}
// queries by kind only, mostly used in Global Feeds when author is not important.
db.execSQL("CREATE INDEX query_by_kind_created ON event_headers (kind, $orderBy)")
// queries by kind + pubkey, but not d-tag, even if they are replaceables and addressables, by date.
db.execSQL("CREATE INDEX query_by_kind_pubkey_created ON event_headers (kind, pubkey, $orderBy)")
// makes deletions on the event_header fast
db.execSQL("CREATE INDEX fk_event_tags_header_id ON event_tags (event_header_row_id)")
db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, event_header_row_id)")
// ---------------------------------------------------------------------------
// These next 3 are a very slow indexes (80% of the insert time goes here)
// ---------------------------------------------------------------------------
if (indexStrategy.indexTagsByCreatedAtAlone) {
// First one is only needed if the user is searching by tags without a kind.
db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, created_at DESC)")
}
// This is the default index for most clients: tags by specific kinds that are supported by the client.
db.execSQL("CREATE INDEX query_by_tags_hash_kind ON event_tags (tag_hash, kind, created_at DESC)")
// this one is to allow search of tags by kind and author at the same time: NIP-04 DMs, reports,
if (indexStrategy.indexTagsWithKindAndPubkey) {
db.execSQL("CREATE INDEX query_by_tags_hash_kind_pubkey ON event_tags (tag_hash, kind, pubkey_hash, created_at DESC)")
}
// Prevent updates to maintain immutability
db.execSQL(
@@ -117,9 +144,9 @@ class EventIndexesModule(
val sqlInsertTags =
"""
INSERT OR ROLLBACK INTO event_tags
(event_header_row_id, tag_hash)
(event_header_row_id, tag_hash, created_at, kind, pubkey_hash)
VALUES
(?,?)
(?,?,?,?,?)
""".trimIndent()
fun insert(
@@ -169,7 +196,7 @@ class EventIndexesModule(
// rebalancing the tree every new insert
val indexableTags = ArrayList<Long>()
for (idx in event.tags.indices) {
if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) {
if (indexStrategy.shouldIndex(event.kind, event.tags[idx])) {
indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1]))
}
}
@@ -177,387 +204,17 @@ class EventIndexesModule(
indexableTags.forEach {
stmtTags.bindLong(1, headerId)
stmtTags.bindLong(2, it)
stmtTags.bindLong(3, event.createdAt)
stmtTags.bindLong(4, kindLong)
stmtTags.bindLong(5, pubkeyHash)
stmtTags.executeInsert()
}
return headerId
}
fun planQuery(
filter: Filter,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher)
return if (rowIdSubQuery == null) {
val query = makeEverythingQuery()
db.explainQuery(query)
} else {
val query = makeQueryIn(rowIdSubQuery.sql)
db.explainQuery(query, rowIdSubQuery.args.toTypedArray())
}
}
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
): List<T> {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.runQuery(makeEverythingQuery())
} else {
db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args)
}
}
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.runQuery(makeEverythingQuery(), onEach = onEach)
} else {
db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach)
}
}
fun planQuery(
filters: List<Filter>,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val rowIdSubQuery = unionSubqueriesIfNeeded(filters, hasher)
return if (rowIdSubQuery == null) {
val query = makeEverythingQuery()
db.explainQuery(query)
} else {
val query = makeQueryIn(rowIdSubQuery.sql)
db.explainQuery(query, rowIdSubQuery.args.toTypedArray())
}
}
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
): List<T> {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery())
return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args)
}
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db))
if (rowIdSubqueries == null) {
db.runQuery(makeEverythingQuery(), onEach = onEach)
} else {
db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.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 <T : Event> SQLiteDatabase.runQuery(
sql: String,
args: List<String> = emptyList(),
): List<T> =
rawQuery(sql, args.toTypedArray()).use { cursor ->
ArrayList<T>(cursor.count).apply {
while (cursor.moveToNext()) {
add(cursor.toEvent())
}
}
}
private inline fun <T : Event> SQLiteDatabase.runQuery(
sql: String,
args: List<String> = emptyList(),
onEach: (T) -> Unit,
) = rawQuery(sql, args.toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
onEach(cursor.toEvent())
}
}
private fun <T : Event> Cursor.toEvent() =
EventFactory.create<T>(
getString(0).intern(),
getString(1).intern(),
getLong(2),
getInt(3),
OptimizedJsonMapper.fromJsonToTagArray(getString(4)),
getString(5),
getString(6),
)
class RawEvent(
val id: HexKey,
val pubKey: HexKey,
val createdAt: Long,
val kind: Kind,
val jsonTags: String,
val content: String,
val sig: HexKey,
) {
fun <T : Event> toEvent() =
EventFactory.create<T>(
id.intern(),
pubKey.intern(),
createdAt,
kind,
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
content,
sig,
)
}
private fun Cursor.toRawEvent() =
RawEvent(
getString(0),
getString(1),
getLong(2),
getInt(3),
getString(4),
getString(5),
getString(6),
)
// --------------
// Counts
// -------------
fun count(
filter: Filter,
db: SQLiteDatabase,
): Int {
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.countEverything()
} else {
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
}
}
fun count(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.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, hasher(db))
return if (rowIdQuery == null) {
0
} else {
db.runDelete(rowIdQuery.sql, rowIdQuery.args)
}
}
fun delete(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.runDelete(
sql: String,
args: List<String> = emptyList(),
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
// ---------------------------------
// Prepare unions of all the filters
// ---------------------------------
fun unionSubqueriesIfNeeded(
filters: List<Filter>,
hasher: TagNameValueHasher,
): RowIdSubQuery? {
val inner =
filters.mapNotNull { filter ->
prepareRowIDSubQueries(filter, hasher)
}
if (inner.isEmpty()) return null
return if (inner.size == 1) {
inner.first()
} else {
RowIdSubQuery(
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
args = inner.flatMap { it.args },
)
}
}
// ----------------------------
// Inner row id selections
// ----------------------------
fun prepareRowIDSubQueries(
filter: Filter,
hasher: TagNameValueHasher,
): RowIdSubQuery? {
if (!filter.isFilledFilter()) return null
val mustJoinSearch = (filter.search != null)
val nonDTags = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val hasHeaders =
with(filter) {
(ids != null) ||
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.containsKey("d")) ||
(since != null) ||
(until != null) ||
(limit != null)
}
var defaultTagKey: String? = null
val projection =
buildString {
// always do tags if there are any
if (nonDTags.isNotEmpty()) {
append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ")
// it's quite rare to have 2 tags in the filter, but possible
nonDTags.keys.forEachIndexed { index, tagName ->
if (index > 0) {
append("INNER JOIN event_tags as event_tags$tagName ON event_tags$tagName.event_header_row_id = event_tags.event_header_row_id ")
} else {
defaultTagKey = tagName
}
}
if (hasHeaders) {
append("INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id ")
}
if (mustJoinSearch) {
append("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id ")
}
} else if (mustJoinSearch) {
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName} ")
if (hasHeaders) {
append("INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
}
} else {
// no tags and no search.
append("SELECT event_headers.row_id as row_id FROM event_headers ")
}
}
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
filter.ids?.let { equalsOrIn("event_headers.id", it) }
// range search is bad but most of the time these are up the top with few elements.
filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
// there are indexes for these, starting with tags.
nonDTags.forEach { (tagName, tagValues) ->
val column =
if (defaultTagKey == null || defaultTagKey == tagName) {
"event_tags.tag_hash"
} else {
"event_tags$tagName.tag_hash"
}
equalsOrIn(
column,
tagValues.map {
hasher.hash(tagName, it)
},
)
}
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
// there are indexes for these, starting with tags.
filter.tags?.forEach { (tagName, tagValues) ->
if (tagName == "d") {
equalsOrIn("event_headers.d_tag", tagValues)
}
}
// if search is included, SQLLite will always start here.
filter.search?.let {
if (it.isNotBlank()) {
match(fts.tableName, it)
}
}
}
val whereClause =
if (filter.limit != null) {
"${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}"
} else {
clause.conditions
}
return RowIdSubQuery("$projection WHERE $whereClause", clause.args)
}
override fun deleteAll(db: SQLiteDatabase) {
db.execSQL("DELETE FROM event_tags")
db.execSQL("DELETE FROM event_headers")
}
data class RowIdSubQuery(
val sql: String,
val args: List<String>,
)
}
@@ -29,9 +29,9 @@ class EventStore(
context: Context,
dbName: String? = "events.db",
val relayUrl: String? = "wss://quartz.local",
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IEventStore {
val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy)
val store = SQLiteEventStore(context, dbName, relayUrl, indexStrategy)
override fun insert(event: Event) = store.insertEvent(event)
@@ -23,6 +23,64 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Tag
interface IndexingStrategy {
/**
* Activate this if you see too many Filters with just LIMIT, SINCE and
* UNTIL filled up.
*
* Clients never support all kinds, so this is usually
* only done with syncing services that must download ALL kinds from
* ALL authors.
*
* The index will make these queries significantly faster, but maybe speed
* is not a requirement on Sync services. The size of this index is
* considerable.
*
* Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring
* frequent reloadings of the index itself from disk.
*/
val indexEventsByCreatedAtAlone: Boolean
/**
* Activate this if you see too many Tag-centric Filters without
* kind, pubkey or id.
*
* Clients never support all kinds, so this is usually
* only done in rare usecases where the client supports all
* kinds.
*
* The index will make these queries significantly faster, but maybe speed
* is not a requirement on such services. Because this is an index in
* event tags, it becomes QUITE BIG.
*
* Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring
* frequent reloadings of the index itself from disk.
*/
val indexTagsByCreatedAtAlone: Boolean
/**
* Activate this if you see too many Tag-centric Filters without
* kind AND pubkey at the same time.
*
* This is a rarely used index (reports by your follows or
* NIP-04 DMs for instance) that becomes quite large without
* major gains.
*
* Keep in mind that activating too many indexes increases the size of the
* DB so much that the indexes themselves won't fit in memory, requiring
* frequent reloadings of the index itself from disk.
*/
val indexTagsWithKindAndPubkey: Boolean
/**
* Activate this to make sure queries are always in order when
* the same created_at exists. This will impact performance and
* the size of indexes, but it provides results that are compliant
* with the Nostr Spec
*/
val useAndIndexIdOnOrderBy: Boolean
fun shouldIndex(
kind: Int,
tag: Tag,
@@ -32,7 +90,12 @@ interface IndexingStrategy {
/**
* By default, we index all tags that have a single letter name and some value
*/
class DefaultIndexingStrategy : IndexingStrategy {
class DefaultIndexingStrategy(
override val indexEventsByCreatedAtAlone: Boolean = false,
override val indexTagsByCreatedAtAlone: Boolean = false,
override val indexTagsWithKindAndPubkey: Boolean = false,
override val useAndIndexIdOnOrderBy: Boolean = false,
) : IndexingStrategy {
override fun shouldIndex(
kind: Int,
tag: Tag,
@@ -0,0 +1,710 @@
/**
* 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.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where
import com.vitorpamplona.quartz.utils.EventFactory
class QueryBuilder(
val fts: FullTextSearchModule,
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
val indexStrategy: IndexingStrategy,
) {
// ------------
// Main methods
// ------------
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
): List<T> = db.runQuery(toSql(filter, hasher(db)))
fun <T : Event> query(
filter: Filter,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) = db.runQuery(toSql(filter, hasher(db)), onEach)
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
): List<T> = db.runQuery(toSql(filters, hasher(db)))
fun <T : Event> query(
filters: List<Filter>,
db: SQLiteDatabase,
onEach: (T) -> Unit,
) = db.runQuery(toSql(filters, hasher(db)), onEach)
// ---------------------------
// Raw methods for performance
// ---------------------------
fun rawQuery(
filter: Filter,
db: SQLiteDatabase,
): List<RawEvent> = db.runRawQuery(toSql(filter, hasher(db)))
fun rawQuery(
filter: Filter,
db: SQLiteDatabase,
onEach: (RawEvent) -> Unit,
) = db.runRawQuery(toSql(filter, hasher(db)), onEach)
fun rawQuery(
filters: List<Filter>,
db: SQLiteDatabase,
): List<RawEvent> = db.runRawQuery(toSql(filters, hasher(db)))
fun rawQuery(
filters: List<Filter>,
db: SQLiteDatabase,
onEach: (RawEvent) -> Unit,
) = db.runRawQuery(toSql(filters, hasher(db)), onEach)
// -----------
// Debug Tools
// -----------
fun planQuery(
filter: Filter,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val query = toSql(filter, hasher)
return db.explainQuery(query.sql, query.args.toTypedArray())
}
fun planQuery(
filters: List<Filter>,
hasher: TagNameValueHasher,
db: SQLiteDatabase,
): String {
val query = toSql(filters, hasher)
return db.explainQuery(query.sql, query.args.toTypedArray())
}
fun toSql(
filter: Filter,
hasher: TagNameValueHasher,
): QuerySpec {
val newFilter = filter.toFilterWithDTags()
if (newFilter.isSimpleQuery()) {
return makeSimpleQuery(
project = true,
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
}
if (newFilter.isSimpleSearch()) {
return makeSimpleSearch(
search = newFilter.search!!,
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
}
val rowIdSubqueries = prepareRowIDSubQueries(filter, hasher)
return if (rowIdSubqueries == null) {
QuerySpec(makeEverythingQuery())
} else {
QuerySpec(
makeQueryIn(rowIdSubqueries.sql),
rowIdSubqueries.args,
)
}
}
fun toSql(
filters: List<Filter>,
hasher: TagNameValueHasher,
): QuerySpec {
if (filters.size == 1) return toSql(filters.first(), hasher)
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher)
return if (rowIdSubqueries == null) {
QuerySpec(
makeEverythingQuery(),
emptyList(),
)
} else {
QuerySpec(
makeQueryIn(rowIdSubqueries.sql),
rowIdSubqueries.args,
)
}
}
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}"
private fun makeQueryIn(rowIdQuery: String) =
"""
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${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}
""".trimIndent()
private fun <T : Event> SQLiteDatabase.runQuery(query: QuerySpec): List<T> =
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
ArrayList<T>(cursor.count).apply {
while (cursor.moveToNext()) {
add(cursor.toEvent())
}
}
}
private fun SQLiteDatabase.runRawQuery(query: QuerySpec): List<RawEvent> =
rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
ArrayList<RawEvent>(cursor.count).apply {
while (cursor.moveToNext()) {
add(cursor.toRawEvent())
}
}
}
private inline fun <T : Event> SQLiteDatabase.runQuery(
query: QuerySpec,
onEach: (T) -> Unit,
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
onEach(cursor.toEvent())
}
}
private inline fun SQLiteDatabase.runRawQuery(
query: QuerySpec,
onEach: (RawEvent) -> Unit,
) = rawQuery(query.sql, query.args.toTypedArray()).use { cursor ->
while (cursor.moveToNext()) {
onEach(cursor.toRawEvent())
}
}
private fun <T : Event> Cursor.toEvent() =
EventFactory.create<T>(
getString(0).intern(),
getString(1).intern(),
getLong(2),
getInt(3),
OptimizedJsonMapper.fromJsonToTagArray(getString(4)),
getString(5),
getString(6),
)
private fun Cursor.toRawEvent() =
RawEvent(
getString(0),
getString(1),
getLong(2),
getInt(3),
getString(4),
getString(5),
getString(6),
)
// --------------
// Counts
// -------------
fun count(
filter: Filter,
db: SQLiteDatabase,
): Int {
val newFilter = filter.toFilterWithDTags()
if (newFilter.isSimpleQuery()) {
val sql =
makeSimpleQuery(
project = false,
ids = newFilter.ids,
authors = newFilter.authors,
kinds = newFilter.kinds,
dTags = newFilter.dTags,
since = newFilter.since,
until = newFilter.until,
limit = newFilter.limit,
)
return db.countIn(sql.sql, sql.args)
}
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
return if (rowIdSubQuery == null) {
db.countEverything()
} else {
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
}
}
fun count(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.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, hasher(db))
return if (rowIdQuery == null) {
0
} else {
db.runDelete(rowIdQuery.sql, rowIdQuery.args)
}
}
fun delete(
filters: List<Filter>,
db: SQLiteDatabase,
): Int {
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
}
private fun SQLiteDatabase.runDelete(
sql: String,
args: List<String> = emptyList(),
): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray())
// ---------------------------------
// Prepare unions of all the filters
// ---------------------------------
fun unionSubqueriesIfNeeded(
filters: List<Filter>,
hasher: TagNameValueHasher,
): QuerySpec? {
val inner =
filters.mapNotNull { filter ->
prepareRowIDSubQueries(filter, hasher)
}
if (inner.isEmpty()) return null
return if (inner.size == 1) {
inner.first()
} else {
QuerySpec(
sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" },
args = inner.flatMap { it.args },
)
}
}
sealed class TagNameForQuery {
class InTags(
val tagName: String,
) : TagNameForQuery()
class AllTags(
val tagName: String,
val tagValueIndex: Int,
) : TagNameForQuery()
}
// ----------------------------
// Inner row id selections
// ----------------------------
fun prepareRowIDSubQueries(
filter: Filter,
hasher: TagNameValueHasher,
): QuerySpec? {
if (filter.isEmpty()) return null
val mustJoinSearch = (filter.search != null)
val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap()
val reverseLookup = nonDTagsIn.isNotEmpty() || nonDTagsAll.isNotEmpty()
val needHeaders =
with(filter) {
(ids != null) || (tags != null && tags.containsKey("d"))
}
val hasHeaders =
with(filter) {
(ids != null) ||
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.containsKey("d")) ||
(since != null) ||
(until != null) ||
(limit != null)
}
var defaultTagKey: TagNameForQuery? = null
val projection =
buildString {
// always do tags if there are any
if (reverseLookup) {
append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags")
// it's quite rare to have 2 tags in the filter, but possible
nonDTagsIn.keys.forEachIndexed { index, tagName ->
if (defaultTagKey != null) {
append(" INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id AND event_tagsIn$index.created_at = event_tags.created_at")
} else {
defaultTagKey = TagNameForQuery.InTags(tagName)
}
}
nonDTagsAll.keys.forEachIndexed { index, tagName ->
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
if (defaultTagKey != null) {
append(" INNER JOIN event_tags as event_tagsAll${index}_$valueIndex ON event_tagsAll${index}_$valueIndex.event_header_row_id = event_tags.event_header_row_id AND event_tagsAll${index}_$valueIndex.created_at = event_tags.created_at")
} else {
defaultTagKey = TagNameForQuery.AllTags(tagName, valueIndex)
}
}
}
if (needHeaders) {
append(" INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id")
}
if (mustJoinSearch) {
append(" INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id")
}
} else if (mustJoinSearch) {
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}")
if (hasHeaders) {
append(" INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
}
} else {
// no tags and no search.
append("SELECT event_headers.row_id as row_id FROM event_headers")
}
}
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
filter.ids?.let { equalsOrIn("event_headers.id", it) }
// it's quite rare to have 2 tags in the filter, but possible
nonDTagsIn.keys.forEachIndexed { index, tagName ->
val column =
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) {
"event_tags.tag_hash"
} else {
"event_tagsIn$index.tag_hash"
}
equalsOrIn(
column,
nonDTagsIn[tagName]!!.map {
hasher.hash(tagName, it)
},
)
}
// there are indexes for these, starting with tags.
nonDTagsAll.keys.forEachIndexed { index, tagName ->
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
val column =
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.AllTags && defaultTagKey.tagName == tagName && defaultTagKey.tagValueIndex == valueIndex)) {
"event_tags.tag_hash"
} else {
"event_tagsAll${index}_$valueIndex.tag_hash"
}
equals(column, hasher.hash(tagName, tagValue))
}
}
// range search is bad but most of the time these are up the top with few elements.
if (reverseLookup) {
filter.kinds?.let { equalsOrIn("event_tags.kind", it) }
filter.authors?.let { equalsOrIn("event_tags.pubkey_hash", it.map { hasher.hash(it) }) }
filter.since?.let { greaterThanOrEquals("event_tags.created_at", it) }
filter.until?.let { lessThanOrEquals("event_tags.created_at", it) }
// there are indexes for these, starting with tags.
filter.tags?.forEach { (tagName, tagValues) ->
if (tagName == "d") {
equalsOrIn("event_headers.d_tag", tagValues)
}
}
} else {
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
// there are indexes for these, starting with tags.
filter.tags?.forEach { (tagName, tagValues) ->
if (tagName == "d") {
equalsOrIn("event_headers.d_tag", tagValues)
}
}
filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
// no need to add the replaceable because query_by_kind_pubkey_created already covers it
val isAllAddressable = filter.kinds?.all { it.isAddressable() } ?: false
if (isAllAddressable) {
// matches unique index kind >= 30000 AND kind < 40000
raw("(event_headers.kind >= 30000 AND event_headers.kind < 40000)")
}
}
// if search is included, SQLLite will always start here.
filter.search?.let {
if (it.isNotBlank()) {
match(fts.tableName, it)
}
}
}
val sql =
buildString {
append(projection)
if (clause.conditions.isNotEmpty()) {
append(" WHERE ${clause.conditions}")
}
if (filter.limit != null) {
if (reverseLookup) {
append(" ORDER BY event_tags.created_at DESC")
append(" LIMIT ")
append(filter.limit)
} else {
append(" ORDER BY event_headers.created_at DESC")
append(" LIMIT ")
append(filter.limit)
}
}
}
return QuerySpec(sql, clause.args)
}
private fun makeSimpleSearch(
search: String,
ids: List<HexKey>? = null,
authors: List<HexKey>? = null,
kinds: List<Kind>? = null,
dTags: List<String>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
): QuerySpec {
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
ids?.let { equalsOrIn("event_headers.id", it) }
match(fts.tableName, search)
kinds?.let { equalsOrIn("event_headers.kind", it) }
authors?.let { equalsOrIn("event_headers.pubkey", it) }
// there are indexes for these, starting with tags.
dTags?.let { equalsOrIn("event_headers.d_tag", it) }
since?.let { greaterThanOrEquals("event_headers.created_at", it) }
until?.let { lessThanOrEquals("event_headers.created_at", it) }
// if this is a dTag filter, it is likely that all kinds are addressables
// and so force the use of the addressable index
if (dTags != null && kinds != null) {
if (kinds.all { it.isAddressable() }) {
// matches unique index kind >= 30000 AND kind < 40000
raw("(event_headers.kind >= 30000 AND kind < 40000)")
}
}
}
val sql =
buildString {
append("SELECT event_headers.id, event_headers.pubkey, event_headers.created_at, event_headers.kind, event_headers.tags, event_headers.content, event_headers.sig FROM event_headers")
append("\nINNER JOIN ${fts.tableName} ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
if (clause.conditions.isNotEmpty()) {
append("\nWHERE ${clause.conditions}")
}
append("\nORDER BY event_headers.created_at DESC")
if (indexStrategy.useAndIndexIdOnOrderBy) {
append(", event_headers.id ASC")
}
if (limit != null) {
append("\nLIMIT ")
append(limit)
}
}
return QuerySpec(sql, clause.args)
}
private fun makeSimpleQuery(
project: Boolean,
ids: List<HexKey>? = null,
authors: List<HexKey>? = null,
kinds: List<Kind>? = null,
dTags: List<String>? = null,
since: Long? = null,
until: Long? = null,
limit: Int? = null,
): QuerySpec {
val clause =
where {
// the order should match indexes
// ids reduce the filter the most
ids?.let { equalsOrIn("id", it) }
kinds?.let { equalsOrIn("kind", it) }
authors?.let { equalsOrIn("pubkey", it) }
// there are indexes for these, starting with tags.
dTags?.let { equalsOrIn("d_tag", it) }
since?.let { greaterThanOrEquals("created_at", it) }
until?.let { lessThanOrEquals("created_at", it) }
// if this is a dTag filter, it is likely that all kinds are addressables
// and so force the use of the addressable index
if (dTags != null && kinds != null) {
if (kinds.all { it.isAddressable() }) {
// matches unique index kind >= 30000 AND kind < 40000
raw("(kind >= 30000 AND kind < 40000)")
}
}
}
val sql =
buildString {
if (project) {
append("SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers")
} else {
append("SELECT row_id FROM event_headers")
}
if (clause.conditions.isNotEmpty()) {
append("\nWHERE ")
append(clause.conditions)
}
if (project) {
append("\nORDER BY created_at DESC")
if (indexStrategy.useAndIndexIdOnOrderBy) {
append(", id ASC")
}
}
if (limit != null) {
append("\nLIMIT ")
append(limit)
}
}
return QuerySpec(sql, clause.args)
}
class FilterWithDTags(
val ids: List<HexKey>? = null,
val authors: List<HexKey>? = null,
val kinds: List<Kind>? = null,
val dTags: List<String>? = null,
val nonDTagsIn: Map<String, List<String>>? = null,
val nonDTagsAll: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
val search: String? = null,
) {
fun isSimpleSearch() =
search != null && search.isNotEmpty() &&
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
(nonDTagsAll == null || nonDTagsAll.isEmpty())
// can be resolved with just event_headers
fun isSimpleQuery() =
(nonDTagsIn == null || nonDTagsIn.isEmpty()) &&
(nonDTagsAll == null || nonDTagsAll.isEmpty()) &&
(search == null || search.isEmpty())
}
fun Filter.toFilterWithDTags(): FilterWithDTags =
FilterWithDTags(
ids = ids,
authors = authors,
kinds = kinds,
dTags = tags?.get("d") ?: tagsAll?.get("d"),
nonDTagsIn = tags?.filter { it.key != "d" }?.ifEmpty { null },
nonDTagsAll = tagsAll?.filter { it.key != "d" }?.ifEmpty { null },
since = since,
until = until,
limit = limit,
search = search,
)
data class QuerySpec(
val sql: String,
val args: List<String> = emptyList(),
)
}
@@ -48,8 +48,7 @@ class ReplaceableModule : IModule {
WHERE
event_headers.kind = NEW.kind AND
event_headers.pubkey = NEW.pubkey AND
event_headers.created_at < NEW.created_at AND
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
event_headers.created_at < NEW.created_at;
END;
""".trimIndent(),
)
@@ -27,10 +27,13 @@ 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.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -38,7 +41,7 @@ class SQLiteEventStore(
val context: Context,
val dbName: String? = "events.db",
val relayUrl: String? = null,
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
companion object {
const val DATABASE_VERSION = 2
@@ -47,7 +50,7 @@ class SQLiteEventStore(
val seedModule = SeedModule()
val fullTextSearchModule = FullTextSearchModule()
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy)
val eventIndexModule = EventIndexesModule(seedModule::hasher, indexStrategy)
val replaceableModule = ReplaceableModule()
val addressableModule = AddressableModule()
@@ -57,6 +60,8 @@ class SQLiteEventStore(
val expirationModule = ExpirationModule()
val rightToVanishModule = RightToVanishModule(seedModule::hasher)
val queryBuilder = QueryBuilder(fullTextSearchModule, seedModule::hasher, indexStrategy)
val modules =
listOf(
seedModule,
@@ -73,6 +78,9 @@ class SQLiteEventStore(
override fun onConfigure(db: SQLiteDatabase) {
super.onConfigure(db)
// 32MB memory cache
db.execSQL("PRAGMA cache_size=-32000;")
// makes sure the FKs are sane
db.setForeignKeyConstraintsEnabled(true)
@@ -82,7 +90,14 @@ class SQLiteEventStore(
// The DB can be corrupted if the OS is shutdown before sync, which generally
// doesn't happen on Android
db.execSQL("PRAGMA synchronous = OFF")
db.execSQL("PRAGMA synchronous = OFF;")
}
fun dbSizeMB(): Int {
val f1 = context.getDatabasePath(dbName)
val f2 = context.getDatabasePath("$dbName-wal")
val total = f1.length() + f2.length()
return (total / (1024 * 1024)).toInt()
}
override fun onCreate(db: SQLiteDatabase) {
@@ -171,33 +186,72 @@ class SQLiteEventStore(
}
}
fun <T : Event> query(filter: Filter): List<T> = eventIndexModule.query(filter, readableDatabase)
fun <T : Event> query(filter: Filter): List<T> = queryBuilder.query(filter, readableDatabase)
fun <T : Event> query(filters: List<Filter>): List<T> = eventIndexModule.query(filters, readableDatabase)
fun <T : Event> query(filters: List<Filter>): List<T> = queryBuilder.query(filters, readableDatabase)
fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) = eventIndexModule.query(filter, readableDatabase, onEach)
) = queryBuilder.query(filter, readableDatabase, onEach)
fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) = eventIndexModule.query(filters, readableDatabase, onEach)
) = queryBuilder.query(filters, readableDatabase, onEach)
fun count(filter: Filter): Int = eventIndexModule.count(filter, readableDatabase)
fun rawQuery(filter: Filter): List<RawEvent> = queryBuilder.rawQuery(filter, readableDatabase)
fun count(filters: List<Filter>): Int = eventIndexModule.count(filters, readableDatabase)
fun rawQuery(filters: List<Filter>): List<RawEvent> = queryBuilder.rawQuery(filters, readableDatabase)
fun rawQuery(
filter: Filter,
onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filter, readableDatabase, onEach)
fun rawQuery(
filters: List<Filter>,
onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filters, readableDatabase, onEach)
fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(readableDatabase), readableDatabase)
fun planQuery(filters: List<Filter>) = queryBuilder.planQuery(filters, seedModule.hasher(readableDatabase), readableDatabase)
fun count(filter: Filter): Int = queryBuilder.count(filter, readableDatabase)
fun count(filters: List<Filter>): Int = queryBuilder.count(filters, readableDatabase)
fun delete(filter: Filter) {
eventIndexModule.delete(filter, writableDatabase)
queryBuilder.delete(filter, writableDatabase)
}
fun delete(filters: List<Filter>) {
eventIndexModule.delete(filters, writableDatabase)
queryBuilder.delete(filters, writableDatabase)
}
fun delete(id: HexKey): Int = writableDatabase.delete("event_headers", "id = ?", arrayOf(id))
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(writableDatabase)
}
class RawEvent(
val id: HexKey,
val pubKey: HexKey,
val createdAt: Long,
val kind: Kind,
val jsonTags: String,
val content: String,
val sig: HexKey,
) {
fun <T : Event> toEvent() =
EventFactory.create<T>(
id.intern(),
pubKey.intern(),
createdAt,
kind,
OptimizedJsonMapper.fromJsonToTagArray(jsonTags),
content,
sig,
)
}
@@ -21,6 +21,10 @@
package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
sealed class Condition {
data class Raw(
val condition: String,
) : Condition()
data class Equals(
val column: String,
val value: Any?,
@@ -81,4 +85,6 @@ sealed class Condition {
data class Or(
val conditions: List<Condition>,
) : Condition()
class Empty : Condition()
}
@@ -38,13 +38,27 @@ class SqlSelectionBuilder(
*/
private fun buildCondition(cond: Condition): String =
when (cond) {
is Condition.Empty -> {
""
}
is Condition.Raw -> {
cond.condition
}
is Condition.Equals -> {
selectionArgs.add(cond.value.toString())
"${cond.column} = ?"
if (cond.value == null) {
"${cond.column} IS NULL"
} else {
selectionArgs.add(cond.value.toString())
"${cond.column} = ?"
}
}
is Condition.NotEquals -> {
selectionArgs.add(cond.value.toString())
"${cond.column} != ?"
if (cond.value == null) {
"${cond.column} IS NULL"
} else {
selectionArgs.add(cond.value.toString())
"${cond.column} != ?"
}
}
is Condition.GreaterThan -> {
selectionArgs.add(cond.value.toString())
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite.sql
class WhereClauseBuilder {
private val conditions = mutableListOf<Condition>()
fun raw(condition: String) = apply { conditions.add(Condition.Raw(condition)) }
fun equals(
column: String,
value: Any?,
@@ -86,7 +88,7 @@ class WhereClauseBuilder {
fun and(block: WhereClauseBuilder.() -> Unit) =
apply {
val builder = WhereClauseBuilder().apply(block)
val builtCondition = builder.build()
val builtCondition = builder.buildAnd()
if (builtCondition != null) {
conditions.add(builtCondition)
}
@@ -95,22 +97,29 @@ class WhereClauseBuilder {
fun or(block: WhereClauseBuilder.() -> Unit) =
apply {
val builder = WhereClauseBuilder().apply(block)
val builtCondition = builder.build()
val builtCondition = builder.buildOr()
if (builtCondition != null) {
conditions.add(builtCondition)
}
}
fun build(): Condition? =
fun buildAnd(): Condition? =
when (conditions.size) {
0 -> null
1 -> conditions.first()
else -> Condition.And(conditions.toList())
}
fun buildOr(): Condition? =
when (conditions.size) {
0 -> null
1 -> conditions.first()
else -> Condition.Or(conditions.toList())
}
}
fun where(block: WhereClauseBuilder.() -> Unit): WhereClause {
val condition = WhereClauseBuilder().apply(block).build() ?: Condition.And(emptyList())
val condition = WhereClauseBuilder().apply(block).buildAnd() ?: Condition.Empty()
return SqlSelectionBuilder(condition).build()
}
@@ -44,7 +44,13 @@ class AddressSerializer {
return try {
val parts = addressId.split(":", limit = 3)
if (parts.size > 2 && parts[1].length == 64 && Hex.isHex(parts[1])) {
Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "")
if (parts[0].length > 5) {
// invalid kind
Log.w("AddressableId", "Error parsing. invalid kind $addressId")
null
} else {
Address(parts[0].toInt(), parts[1], parts.getOrNull(2) ?: "")
}
} else {
if (addressId.startsWith("naddr1")) {
val addr = Nip19Parser.uriToRoute(addressId)?.entity
@@ -60,7 +66,7 @@ class AddressSerializer {
}
}
} catch (t: Throwable) {
Log.e("AddressableId", "Error parsing: $addressId: ${t.message}", t)
Log.w("AddressableId", "Error parsing: $addressId: ${t.message}", t)
null
}
}
@@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
typealias Kind = Int
fun Kind.isRegular() = this > 0 && this != ContactListEvent.KIND && this < 10_000
fun Kind.isEphemeral() = this >= 20_000 && this < 30_000
fun Kind.isReplaceable() = this == MetadataEvent.KIND || this == ContactListEvent.KIND || (this >= 10_000 && this < 20_000)
@@ -54,6 +54,7 @@ object JsonMapper {
val jsonInstance =
Json {
ignoreUnknownKeys = true
isLenient = true
}
inline fun <reified T> fromJson(json: String): T = jsonInstance.decodeFromString<T>(json)
@@ -35,10 +35,10 @@ class RelayBasedFilter(
fun List<RelayBasedFilter>.groupByRelay(): Map<NormalizedRelayUrl, List<Filter>> {
val result = mutableMapOf<NormalizedRelayUrl, MutableList<Filter>>()
for (relayBasedFilter in this) {
if (relayBasedFilter.filter.isFilledFilter()) {
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
} else {
if (relayBasedFilter.filter.isEmpty()) {
Log.e("FilterError", "Ignoring empty filter for ${relayBasedFilter.relay}")
} else {
result.getOrPut(relayBasedFilter.relay) { mutableListOf() }.add(relayBasedFilter.filter)
}
}
return result
@@ -128,7 +128,7 @@ open class BasicRelayClient(
} catch (e: Throwable) {
if (e is CancellationException) throw e
// doesn't expose parsing errors to lib users as errors
Log.e("BasicRelayClient", "Failure to parse message from Relay", e)
Log.e("BasicRelayClient", "Failure to parse message from Relay: $text", e)
}
}
@@ -21,8 +21,10 @@
package com.vitorpamplona.quartz.nip01Core.relay.client.stats
import androidx.collection.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
class RelayStat(
var receivedBytes: Int = 0,
var sentBytes: Int = 0,
@@ -31,12 +33,11 @@ class RelayStat(
var pingInMs: Int = 0,
var compression: Boolean = false,
) {
val messages = LruCache<RelayDebugMessage, RelayDebugMessage>(100)
val messages = LruCache<IRelayDebugMessage, IRelayDebugMessage>(100)
fun newNotice(notice: String?) {
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.NOTICE,
NoticeDebugMessage(
message = notice ?: "No error message provided",
)
@@ -47,8 +48,7 @@ class RelayStat(
errorCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.ERROR,
ErrorDebugMessage(
message = error ?: "No error message provided",
)
@@ -63,27 +63,35 @@ class RelayStat(
sentBytes += bytesUsedInMemory
}
fun newSpam(spamDescriptor: String) {
fun newSpam(
link1: String,
link2: String,
) {
spamCounter++
val debugMessage =
RelayDebugMessage(
type = RelayDebugMessageType.SPAM,
message = spamDescriptor,
)
val debugMessage = SpamDebugMessage(link1, link2)
messages.put(debugMessage, debugMessage)
}
}
class RelayDebugMessage(
val type: RelayDebugMessageType,
val message: String,
val time: Long = TimeUtils.now(),
)
enum class RelayDebugMessageType {
SPAM,
NOTICE,
ERROR,
@Stable
sealed interface IRelayDebugMessage {
val time: Long
}
class SpamDebugMessage(
val link1: String,
val link2: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
class NoticeDebugMessage(
val message: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
class ErrorDebugMessage(
val message: String,
override val time: Long = TimeUtils.now(),
) : IRelayDebugMessage
@@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.utils.Log
@@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.utils.Log
* - authors: Optional list of author public keys (must be 64 characters).
* - kinds: Optional list of event kinds to include.
* - tags: Optional map of tag names to values arrays (common tags like 'p', 'e', 'a' are validated).
* - tagsAll: Optional map of tag names to values arrays that must all match (common tags like 'p', 'e', 'a' are validated).
* - since: Optional timestamp for filtering events with publication time ≥ this value.
* - until: Optional timestamp for filtering events with publication time ≤ this value.
* - limit: Optional maximum number of events to request.
@@ -44,10 +47,11 @@ import com.vitorpamplona.quartz.utils.Log
* follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs.
*/
class Filter(
val ids: List<String>? = null,
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val ids: List<HexKey>? = null,
val authors: List<HexKey>? = null,
val kinds: List<Kind>? = null,
val tags: Map<String, List<String>>? = null,
val tagsAll: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
@@ -55,31 +59,33 @@ class Filter(
) : OptimizedSerializable {
fun toJson() = OptimizedJsonMapper.toJson(this)
fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until)
fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, tagsAll, since, until)
fun copy(
ids: List<String>? = this.ids,
authors: List<String>? = this.authors,
kinds: List<Int>? = this.kinds,
tags: Map<String, List<String>>? = this.tags,
tagsAll: Map<String, List<String>>? = this.tagsAll,
since: Long? = this.since,
until: Long? = this.until,
limit: Int? = this.limit,
search: String? = this.search,
) = Filter(ids, authors, kinds, tags, since, until, limit, search)
) = Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search)
/**
* Returns true if this filter contains any non-null and non-empty criteria.
* Returns true if this filter doesn't filter for anything.
*/
fun isFilledFilter() =
(ids != null && ids.isNotEmpty()) ||
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) ||
(since != null) ||
(until != null) ||
(limit != null) ||
(search != null && search.isNotEmpty())
fun isEmpty() =
(ids == null || ids.isEmpty()) &&
(authors == null || authors.isEmpty()) &&
(kinds == null || kinds.isEmpty()) &&
(tags == null || tags.isEmpty() && tags.values.all { it.isNotEmpty() }) &&
(tagsAll == null || tagsAll.isEmpty() && tagsAll.values.all { it.isNotEmpty() }) &&
(since == null) &&
(until == null) &&
(limit == null) &&
(search == null || search.isEmpty())
init {
ids?.forEach {
@@ -89,14 +95,27 @@ class Filter(
if (it.length != 64) Log.e("FilterError", "Invalid author length $it on ${toJson()}")
}
// tests common tags.
tags?.get("p")?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
if (tags != null) {
tags["p"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
}
tags["e"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
}
tags["a"]?.forEach {
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
}
}
tags?.get("e")?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
}
tags?.get("a")?.forEach {
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
if (tagsAll != null) {
tagsAll["p"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
}
tagsAll["e"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
}
tagsAll["a"]?.forEach {
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
}
}
}
}
@@ -29,6 +29,7 @@ object FilterMatcher {
authors: List<String>? = null,
kinds: List<Int>? = null,
tags: Map<String, List<String>>? = null,
tagsAll: Map<String, List<String>>? = null,
since: Long? = null,
until: Long? = null,
): Boolean {
@@ -36,7 +37,23 @@ object FilterMatcher {
if (kinds?.contains(event.kind) == false) return false
if (authors?.contains(event.pubKey) == false) return false
tags?.forEach { tag ->
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
val valueSet = tag.value.toSet()
// AND between keys, OR between values
if (!event.tags.any { it.size > 1 && it[0] == tag.key && it[1] in valueSet }) return false
}
tagsAll?.forEach { tag ->
val eventTagValueSet =
event.tags.mapNotNullTo(mutableSetOf()) {
if (it.size > 1 && it[0] == tag.key) {
it[1]
} else {
null
}
}
// AND between keys, AND between values
for (tagValue in tag.value) {
if (tagValue !in eventTagValueSet) return false
}
}
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
import androidx.compose.runtime.Stable
@Stable
data class NormalizedRelayUrl(
val url: String,
) : Comparable<NormalizedRelayUrl> {
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.text
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
@@ -32,15 +33,14 @@ import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
object FlexibleIntListSerializer : KSerializer<List<Int>?> {
private val listSerializer = ListSerializer(Int.serializer())
object FlexibleIntListSerializer : KSerializer<List<String>?> {
private val listSerializer = ListSerializer(String.serializer())
override val descriptor: SerialDescriptor = listSerializer.descriptor
override fun deserialize(decoder: Decoder): List<Int>? {
override fun deserialize(decoder: Decoder): List<String>? {
require(decoder is JsonDecoder) { "This serializer can only be used with Json format" }
return when (val element = decoder.decodeJsonElement()) {
@@ -51,7 +51,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
is JsonArray -> {
element.mapNotNull { arrayElement ->
try {
arrayElement.jsonPrimitive.int
arrayElement.jsonPrimitive.text
} catch (e: Exception) {
// Skip elements that aren't valid integers (strings, booleans, floats, etc.)
Log.w("FlexibleIntListSerializer", "Invalid element in array: $arrayElement", e)
@@ -63,7 +63,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
// Handle single integer format (malformed but found in the wild): 1
is JsonPrimitive if !element.isString -> {
try {
listOf(element.int)
listOf(element.text)
} catch (e: Exception) {
// Can't parse as integer (e.g., float, boolean), treat as missing data
Log.w("FlexibleIntListSerializer", "Invalid primitive: $element", e)
@@ -79,7 +79,7 @@ object FlexibleIntListSerializer : KSerializer<List<Int>?> {
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(
encoder: Encoder,
value: List<Int>?,
value: List<String>?,
) {
if (value == null) {
encoder.encodeNull()
@@ -21,19 +21,22 @@
package com.vitorpamplona.quartz.nip11RelayInfo
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.serialization.Serializable
@Stable
@Serializable
class Nip11RelayInformation(
val id: String? = null,
val name: String? = null,
val description: String? = null,
val icon: String? = null,
val pubkey: String? = null,
val pubkey: HexKey? = null,
val self: HexKey? = null,
val contact: String? = null,
@Serializable(with = FlexibleIntListSerializer::class)
val supported_nips: List<Int>? = null,
val supported_nips: List<String>? = null,
val supported_nip_extensions: List<String>? = null,
val software: String? = null,
val version: String? = null,
@@ -42,53 +45,60 @@ class Nip11RelayInformation(
val language_tags: List<String>? = null,
val tags: List<String>? = null,
val posting_policy: String? = null,
val privacy_policy: String? = null,
val terms_of_service: String? = null,
val payments_url: String? = null,
val retention: List<RelayInformationRetentionData>? = null,
val fees: RelayInformationFees? = null,
val nip50: List<String>? = null,
val supported_grasps: List<String>? = null,
) {
companion object {
fun fromJson(json: String): Nip11RelayInformation = JsonMapper.fromJson<Nip11RelayInformation>(json)
}
@Stable
@Serializable
class RelayInformationFee(
val amount: Int? = null,
val unit: String? = null,
val period: Int? = null,
val kinds: List<Int>? = null,
)
@Stable
@Serializable
class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null,
val publication: List<RelayInformationFee>? = null,
)
@Stable
@Serializable
class RelayInformationLimitation(
val max_message_length: Int? = null,
val max_subscriptions: Int? = null,
val max_filters: Int? = null,
val max_limit: Int? = null,
val default_limit: Int? = null,
val max_subid_length: Int? = null,
val min_prefix: Int? = null,
val max_event_tags: Int? = null,
val max_content_length: Int? = null,
val min_pow_difficulty: Int? = null,
val auth_required: Boolean? = null,
val payment_required: Boolean? = null,
val restricted_writes: Boolean? = null,
val created_at_lower_limit: Int? = null,
val created_at_upper_limit: Int? = null,
)
@Stable
@Serializable
class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null,
val time: Int? = null,
val count: Int? = null,
)
}
@Stable
@Serializable
class RelayInformationFee(
val amount: Int? = null,
val unit: String? = null,
val period: Int? = null,
val kinds: List<Int>? = null,
)
@Serializable
class RelayInformationFees(
val admission: List<RelayInformationFee>? = null,
val subscription: List<RelayInformationFee>? = null,
val publication: List<RelayInformationFee>? = null,
)
@Serializable
class RelayInformationLimitation(
val max_message_length: Int? = null,
val max_subscriptions: Int? = null,
val max_filters: Int? = null,
val max_limit: Int? = null,
val max_subid_length: Int? = null,
val min_prefix: Int? = null,
val max_event_tags: Int? = null,
val max_content_length: Int? = null,
val min_pow_difficulty: Int? = null,
val auth_required: Boolean? = null,
val payment_required: Boolean? = null,
val restricted_writes: Boolean? = null,
val created_at_lower_limit: Int? = null,
val created_at_upper_limit: Int? = null,
)
@Serializable
class RelayInformationRetentionData(
val kinds: ArrayList<Int>? = null,
val time: Int? = null,
val count: Int? = null,
)
@@ -148,6 +148,21 @@ class BookmarkListEvent(
)
}
suspend fun remove(
earlierVersion: BookmarkListEvent,
bookmarkIdTag: BookmarkIdTag,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): BookmarkListEvent {
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
return resign(
privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()),
tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()),
signer = signer,
createdAt = createdAt,
)
}
suspend fun resign(
tags: TagArray,
privateTags: TagArray,
@@ -20,11 +20,13 @@
*/
package com.vitorpamplona.quartz.nip94FileMetadata.tags
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
import kotlinx.serialization.Serializable
@Serializable
@Stable
class DimensionTag(
val width: Int,
val height: Int,
@@ -0,0 +1,149 @@
/**
* 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.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilterMatcherTest {
val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val createdAt: Long = 1683596206
val kind = 1
val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"
val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"
val note =
Event(
id,
pubkey,
createdAt,
kind,
arrayOf(
arrayOf("e", rootETag, "", "root"),
arrayOf("e", replyETag, "", "reply"),
arrayOf("p", pTag1),
arrayOf("p", pTag1),
arrayOf("p", pTag2),
arrayOf("p", pTag3),
arrayOf("p", pTag4),
arrayOf("p", pTag5),
arrayOf("p", pTag6),
arrayOf("p", pTag7),
arrayOf("p", pTag8),
),
"Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
)
@Test
fun matchIds() {
assertTrue(note, Filter(ids = listOf(id)))
assertTrue(note, Filter(ids = listOf(id, rootETag)))
assertFalse(note, Filter(ids = listOf(rootETag)))
}
@Test
fun matchPubkeys() {
assertTrue(note, Filter(authors = listOf(pubkey)))
assertTrue(note, Filter(authors = listOf(pubkey, rootETag)))
assertFalse(note, Filter(authors = listOf(rootETag)))
}
@Test
fun matchTags() {
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(id))))
}
@Test
fun matchDualTags() {
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchAllTags() {
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id))))
}
@Test
fun matchDualAllTags() {
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchKinds() {
assertTrue(note, Filter(kinds = listOf(kind)))
assertTrue(note, Filter(kinds = listOf(kind, 1221)))
assertFalse(note, Filter(kinds = listOf(1221)))
}
@Test
fun matchSince() {
assertTrue(note, Filter(since = createdAt))
assertTrue(note, Filter(since = createdAt - 1))
assertFalse(note, Filter(since = createdAt + 1))
}
@Test
fun matchUntil() {
assertTrue(note, Filter(until = createdAt))
assertTrue(note, Filter(until = createdAt + 1))
assertFalse(note, Filter(until = createdAt - 1))
}
fun assertTrue(
event: Event,
filter: Filter,
) = assertTrue(filter.match(event))
fun assertFalse(
event: Event,
filter: Filter,
) = assertFalse(filter.match(event))
}
@@ -74,7 +74,7 @@ class Nip11RelayInformationTest {
assertEquals("purplepag.es", info.name)
assertEquals("Nostr's Purple Pages", info.description)
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
assertEquals(listOf(1, 2, 9, 11), info.supported_nips)
assertEquals(listOf("1", "2", "9", "11"), info.supported_nips)
assertEquals("1.0.4", info.version)
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
}
@@ -88,7 +88,7 @@ class Nip11RelayInformationTest {
assertEquals("Nostr's Purple Pages", info.description)
assertEquals("fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52", info.pubkey)
// Single integer should be converted to a list with one element
assertEquals(listOf(1), info.supported_nips)
assertEquals(listOf("1"), info.supported_nips)
assertEquals("1.0.4", info.version)
assertEquals("git+https://github.com/hoytech/strfry.git", info.software)
}
@@ -132,7 +132,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// Should skip invalid elements and keep only valid integers
assertEquals(listOf(1, 3, 11), info.supported_nips)
assertEquals(listOf("1", "invalid", "3", "4.5", "true", "11"), info.supported_nips)
}
@Test
@@ -144,7 +144,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// All elements invalid, should result in empty list
assertEquals(emptyList(), info.supported_nips)
assertEquals(listOf("one", "two", "three"), info.supported_nips)
}
@Test
@@ -156,7 +156,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// Cannot parse float as integer, should be null
assertNull(info.supported_nips)
assertEquals(listOf("1.5"), info.supported_nips)
}
@Test
@@ -168,7 +168,7 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
// Cannot parse boolean as integer, should be null
assertNull(info.supported_nips)
assertEquals(listOf("true"), info.supported_nips)
}
@Test
@@ -204,6 +204,6 @@ class Nip11RelayInformationTest {
assertNotNull(info)
assertEquals("test relay", info.name)
assertEquals((1..100).toList(), info.supported_nips)
assertEquals((1..100).map { it.toString() }, info.supported_nips)
}
}
@@ -106,9 +106,9 @@ class NIP19ParserTest {
val actual = Nip19Parser.uriToRoute("nostr:nprofile1qqsyvrp9u6p0mfur9dfdru3d853tx9mdjuhkphxuxgfwmryja7zsvhqpzamhxue69uhhv6t5daezumn0wd68yvfwvdhk6tcpz9mhxue69uhkummnw3ezuamfdejj7qgwwaehxw309ahx7uewd3hkctcscpyug")
assertNotNull(actual)
assertTrue(actual?.entity is NProfile)
assertTrue(actual.entity is NProfile)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex)
assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay?.first())
assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay.first())
}
@Test()
@@ -267,7 +267,7 @@ class NIP19ParserTest {
"31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far",
result.aTag(),
)
assertEquals(true, result.relay?.isEmpty())
assertEquals(true, result.relay.isEmpty())
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author)
assertEquals(31337, result.kind)
}
@@ -285,7 +285,7 @@ class NIP19ParserTest {
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509",
result.aTag(),
)
assertEquals(true, result.relay?.isEmpty())
assertEquals(true, result.relay.isEmpty())
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
assertEquals(30023, result.kind)
}
@@ -303,7 +303,7 @@ class NIP19ParserTest {
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418",
result.aTag(),
)
assertEquals(true, result.relay?.isEmpty())
assertEquals(true, result.relay.isEmpty())
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
assertEquals(30023, result.kind)
}
@@ -356,7 +356,7 @@ class NIP19ParserTest {
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex)
assertEquals(
NormalizedRelayUrl("wss://nostr.mom/"),
result.relay?.get(0),
result.relay[0],
)
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author)
assertEquals(1, result.kind)
@@ -403,7 +403,7 @@ class NIP19ParserTest {
assertNotNull(result)
assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex)
assertEquals("wss://relay.damus.io/", result.relay.get(0)?.url)
assertEquals("wss://relay.damus.io/", result.relay[0].url)
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author)
assertEquals(1, result.kind)
}
@@ -24,7 +24,6 @@ import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import platform.Security.SecRandomCopyBytes
import platform.Security.kSecRandomDefault
import kotlin.random.Random.Default.nextBytes
actual class SecureRandom {
actual fun nextInt(): Int {
@@ -63,6 +63,10 @@ class EventDeserializer : StdDeserializer<Event>(Event::class.java) {
}
}
if (pubKey.isEmpty()) {
throw IllegalArgumentException("Event not found")
}
return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig)
}
}
@@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
import kotlinx.serialization.json.JsonNull.content
import java.io.InputStream
class JacksonMapper {
@@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
class ManualFilterDeserializer {
companion object {
fun fromJson(jsonObject: ObjectNode): Filter {
val tags = mutableListOf<String>()
val tagsIn = mutableListOf<String>()
jsonObject.fieldNames().forEach {
if (it.startsWith("#")) {
tags.add(it.substring(1))
tagsIn.add(it.substring(1))
}
}
val tagsAll = mutableListOf<String>()
jsonObject.fieldNames().forEach {
if (it.startsWith("&")) {
tagsAll.add(it.substring(1))
}
}
@@ -49,7 +55,8 @@ class ManualFilterDeserializer {
ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() },
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() },
tags = tags.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
tags = tagsIn.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
tagsAll = tagsAll.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
since = jsonObject.get("since").asLongOrNull(),
until = jsonObject.get("until").asLongOrNull(),
limit = jsonObject.get("limit").asIntOrNull(),
@@ -66,6 +66,16 @@ class FilterSerializer : StdSerializer<Filter>(Filter::class.java) {
}
}
filter.tagsAll?.run {
entries.forEach { kv ->
gen.writeArrayFieldStart("&${kv.key}")
for (i in kv.value.indices) {
gen.writeString(kv.value[i])
}
gen.writeEndArray()
}
}
filter.since?.run { gen.writeNumberField("since", this) }
filter.until?.run { gen.writeNumberField("until", this) }
filter.limit?.run { gen.writeNumberField("limit", this) }