Merge branch 'vitorpamplona:main' into kmp-completeness
This commit is contained in:
+40
-4
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.fail
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
@@ -41,15 +42,12 @@ class AddressableTest {
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
context.deleteDatabase("test.db")
|
||||
db = EventStore(context, "test.db", relayUrl = "testUrl")
|
||||
db = EventStore(context, null)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
context.deleteDatabase("test.db")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,20 +57,25 @@ class AddressableTest {
|
||||
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(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
|
||||
@@ -82,6 +85,8 @@ class AddressableTest {
|
||||
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)))
|
||||
@@ -101,7 +106,38 @@ class AddressableTest {
|
||||
}
|
||||
|
||||
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(
|
||||
"""
|
||||
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
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+239
@@ -23,12 +23,15 @@ 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
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
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
|
||||
@@ -170,4 +173,240 @@ class DeletionTest {
|
||||
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()
|
||||
|
||||
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.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() {
|
||||
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()
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteById() {
|
||||
val sql =
|
||||
db.store.deletionModule
|
||||
.deleteSQL(
|
||||
pubkey = "key1",
|
||||
idValues = listOf("ca29c211f", "ca29c211d"),
|
||||
addresses = emptyList(),
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteAddressable() {
|
||||
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 (
|
||||
(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 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()
|
||||
|
||||
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 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()
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class ExpirationTest {
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = EventStore(context, null, relayUrl = "testUrl")
|
||||
db = EventStore(context, null)
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
+1
-4
@@ -56,15 +56,12 @@ class LargeDBTests {
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
context.deleteDatabase("test_large.db")
|
||||
db = EventStore(context, "largeDBTest.db")
|
||||
db = EventStore(context, null)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
context.deleteDatabase("test_large.db")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+480
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import junit.framework.TestCase
|
||||
import org.junit.After
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class QueryAssemblerTest {
|
||||
val hasher = TagNameValueHasher(0L)
|
||||
val builder = EventIndexesModule(FullTextSearchModule(), { hasher })
|
||||
|
||||
val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d"
|
||||
val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14"
|
||||
val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9"
|
||||
|
||||
private lateinit var db: EventStore
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = EventStore(context, null)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
fun explain(f: Filter) = builder.planQuery(f, hasher, db.store.readableDatabase)
|
||||
|
||||
fun explain(f: List<Filter>) = builder.planQuery(f, hasher, db.store.readableDatabase)
|
||||
|
||||
@Test
|
||||
fun testEmpty() {
|
||||
Assert.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id
|
||||
└── SCAN event_headers USING INDEX query_by_created_at_id
|
||||
""".trimIndent(),
|
||||
explain(Filter()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testCheckDeletionEventExists() {
|
||||
val query =
|
||||
explain(
|
||||
Filter(
|
||||
kinds = listOf(5),
|
||||
authors = listOf(key1),
|
||||
tags = mapOf("e" to listOf(key2)),
|
||||
since = 1750889190,
|
||||
),
|
||||
)
|
||||
|
||||
println(query)
|
||||
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_headers.created_at >= "1750889190") AND (event_tags.tag_hash = "2657743813502222172") AND (event_headers.kind = "5") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d")
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
query,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLimit() {
|
||||
val explainer = explain(Filter(limit = 10))
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
explainer,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLimits() {
|
||||
val explainer = explain(listOf(Filter(limit = 10), Filter(limit = 30)))
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10)
|
||||
UNION
|
||||
SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30)
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ └── COMPOUND QUERY
|
||||
│ ├── LEFT-MOST SUBQUERY
|
||||
│ │ ├── CO-ROUTINE (subquery-1)
|
||||
│ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id
|
||||
│ │ └── SCAN (subquery-1)
|
||||
│ └── UNION USING TEMP B-TREE
|
||||
│ ├── CO-ROUTINE (subquery-3)
|
||||
│ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id
|
||||
│ └── SCAN (subquery-3)
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
explainer,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAllFeatures() {
|
||||
val sql =
|
||||
explain(
|
||||
listOf(
|
||||
Filter(limit = 10),
|
||||
Filter(
|
||||
authors = listOf(key1),
|
||||
kinds = listOf(1, 1111),
|
||||
search = "keywords",
|
||||
limit = 100,
|
||||
),
|
||||
Filter(kinds = listOf(20), search = "cats", limit = 30),
|
||||
),
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10)
|
||||
UNION
|
||||
SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 100)
|
||||
UNION
|
||||
SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30)
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ └── COMPOUND QUERY
|
||||
│ ├── LEFT-MOST SUBQUERY
|
||||
│ │ ├── CO-ROUTINE (subquery-1)
|
||||
│ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id
|
||||
│ │ └── SCAN (subquery-1)
|
||||
│ ├── UNION USING TEMP B-TREE
|
||||
│ │ ├── CO-ROUTINE (subquery-3)
|
||||
│ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4:
|
||||
│ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
│ │ │ └── USE TEMP B-TREE FOR ORDER BY
|
||||
│ │ └── SCAN (subquery-3)
|
||||
│ └── UNION USING TEMP B-TREE
|
||||
│ ├── CO-ROUTINE (subquery-5)
|
||||
│ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4:
|
||||
│ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
│ │ └── USE TEMP B-TREE FOR ORDER BY
|
||||
│ └── SCAN (subquery-5)
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind() {
|
||||
val sql =
|
||||
explain(
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
limit = 30,
|
||||
),
|
||||
),
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?)
|
||||
│ └── USE TEMP B-TREE FOR ORDER BY
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKindAndDTag() {
|
||||
val sql =
|
||||
explain(
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
tags = mapOf("d" to listOf("")),
|
||||
limit = 30,
|
||||
),
|
||||
),
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind = "3") AND (event_headers.d_tag = "") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?)
|
||||
│ └── USE TEMP B-TREE FOR ORDER BY
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFollowersOf() {
|
||||
val sql =
|
||||
explain(
|
||||
listOf(
|
||||
Filter(
|
||||
tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")),
|
||||
limit = 30,
|
||||
),
|
||||
),
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
|
||||
│ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
│ └── USE TEMP B-TREE FOR ORDER BY
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTagsAndKinds() {
|
||||
val sql =
|
||||
explain(
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(ContactListEvent.KIND),
|
||||
tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")),
|
||||
limit = 30,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
println(sql)
|
||||
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.kind = "3") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
|
||||
│ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
│ └── USE TEMP B-TREE FOR ORDER BY
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTagsAndAuthors() {
|
||||
val sql =
|
||||
explain(
|
||||
listOf(
|
||||
Filter(
|
||||
authors = listOf(key1),
|
||||
tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")),
|
||||
limit = 30,
|
||||
),
|
||||
),
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
|
||||
│ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
│ └── USE TEMP B-TREE FOR ORDER BY
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTwoTags() {
|
||||
val sql =
|
||||
explain(
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(1),
|
||||
tags =
|
||||
mapOf(
|
||||
"p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"),
|
||||
"t" to listOf("hashtag"),
|
||||
),
|
||||
limit = 30,
|
||||
),
|
||||
),
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_tags as event_tagst ON event_tagst.event_header_row_id = event_tags.event_header_row_id INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagst.tag_hash = "-6379614208644810021") AND (event_headers.kind = "1") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
|
||||
│ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
│ ├── SEARCH event_tagst USING COVERING INDEX query_by_tags_hash (tag_hash=? AND event_header_row_id=?)
|
||||
│ └── USE TEMP B-TREE FOR ORDER BY
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIdQuery() {
|
||||
val sql = explain(Filter(ids = listOf(key1)))
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d"
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── SEARCH event_headers USING COVERING INDEX event_headers_id (id=?)
|
||||
└── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthors() {
|
||||
val sql = explain(Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300))
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind IN ("1", "30023")) AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 300
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── CO-ROUTINE filtered
|
||||
│ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=? AND pubkey=?)
|
||||
│ └── USE TEMP B-TREE FOR ORDER BY
|
||||
├── SCAN filtered
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthorsAndSearch() {
|
||||
val sql = explain(Filter(authors = listOf(key1, key2, key3), search = "keywords"))
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) AND (event_fts MATCH "keywords")
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── SCAN event_fts VIRTUAL TABLE INDEX 4:
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKindAndSearch() {
|
||||
val sql = explain(Filter(kinds = listOf(1, 1111, 10000), search = "keywords"))
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (
|
||||
SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111", "10000")) AND (event_fts MATCH "keywords")
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
├── SCAN event_fts VIRTUAL TABLE INDEX 4:
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── USE TEMP B-TREE FOR ORDER BY
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
}
|
||||
+66
-1
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.fail
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
@@ -41,7 +42,7 @@ class ReplaceableTest {
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = EventStore(context, null, relayUrl = "testUrl")
|
||||
db = EventStore(context, null)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -56,20 +57,25 @@ class ReplaceableTest {
|
||||
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(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
|
||||
@@ -79,6 +85,8 @@ class ReplaceableTest {
|
||||
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)))
|
||||
@@ -98,7 +106,64 @@ class ReplaceableTest {
|
||||
}
|
||||
|
||||
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(
|
||||
"""
|
||||
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(),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTriggersIndexUsageKind3() {
|
||||
val explainer =
|
||||
db.store.explainQuery(
|
||||
"""
|
||||
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(),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -26,6 +26,7 @@ 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.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import junit.framework.TestCase.fail
|
||||
@@ -87,4 +88,49 @@ class RightToVanishTest {
|
||||
db.assertQuery(null, Filter(ids = listOf(note2.id)))
|
||||
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertDeleteGiftWrap() {
|
||||
val time = TimeUtils.now()
|
||||
|
||||
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)
|
||||
|
||||
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.assertQuery(vanish, Filter(ids = listOf(vanish.id)))
|
||||
db.assertQuery(null, Filter(ids = listOf(wrap1.id)))
|
||||
db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id)))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -36,6 +36,8 @@ class AddressableModule : IModule {
|
||||
// if a newer addressable is inserted the unique index
|
||||
// above will be triggered. Delete cascade will take
|
||||
// care of the event_tags table
|
||||
// the duplicate: kind >= 30000 AND kind < 40000
|
||||
// helps SQLlite find the index above
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER delete_older_addressable_event
|
||||
@@ -48,7 +50,8 @@ class AddressableModule : IModule {
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.d_tag = NEW.d_tag AND
|
||||
event_headers.created_at < NEW.created_at;
|
||||
event_headers.created_at < NEW.created_at AND
|
||||
event_headers.kind >= 30000 AND event_headers.kind < 40000;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+115
-31
@@ -21,12 +21,22 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
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.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
|
||||
class DeletionRequestModule : IModule {
|
||||
class DeletionRequestModule(
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
) : IModule {
|
||||
/**
|
||||
* 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) {
|
||||
// rejects deleted events.
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER reject_deleted_events
|
||||
@@ -36,14 +46,14 @@ class DeletionRequestModule : IModule {
|
||||
-- Check for ID-based deletion record
|
||||
SELECT RAISE(ABORT, 'blocked: a deletion event exists')
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_headers
|
||||
INNER JOIN event_tags
|
||||
SELECT 1 FROM event_tags
|
||||
INNER JOIN event_headers
|
||||
ON event_headers.row_id = event_tags.event_header_row_id
|
||||
WHERE
|
||||
event_headers.created_at >= NEW.created_at AND
|
||||
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND
|
||||
event_headers.kind = 5 AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash)
|
||||
event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND
|
||||
event_headers.created_at >= NEW.created_at
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
@@ -56,36 +66,110 @@ class DeletionRequestModule : IModule {
|
||||
|
||||
fun insert(
|
||||
event: Event,
|
||||
headerId: Long,
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
if (event is DeletionEvent) {
|
||||
val idValues = event.deleteEventIds()
|
||||
val idParams = idValues.joinToString(",") { "?" }
|
||||
|
||||
val addresses = event.deleteAddresses()
|
||||
val addressParams = addresses.joinToString(",") { "(?, ?)" }
|
||||
val addressValues = addresses.flatMap { listOf<Any>(it.kind, it.dTag) }
|
||||
|
||||
val whereClause =
|
||||
if (idValues.isNotEmpty() && addresses.isNotEmpty()) {
|
||||
"(id IN ($idParams) OR (kind, d_tag) IN ($addressParams)) AND pubkey = ?"
|
||||
} else if (idValues.isNotEmpty()) {
|
||||
"id IN ($idParams) AND pubkey = ?"
|
||||
} else if (addresses.isNotEmpty()) {
|
||||
"(kind, d_tag) IN ($addressParams) AND pubkey = ?"
|
||||
} else {
|
||||
return
|
||||
}
|
||||
val whereParams = idValues.plus(addressValues).plus(event.pubKey).toTypedArray()
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE $whereClause;
|
||||
""".trimIndent(),
|
||||
whereParams,
|
||||
)
|
||||
deleteSQL(event.pubKey, idValues, addresses, hasher(db)).forEach { delete ->
|
||||
db.execSQL(delete.sql, delete.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Delete statement that correctly deletes by id,
|
||||
* by address and by replaceable (no d-tag) using each index
|
||||
* appropriately, including GiftWraps where the owner is the
|
||||
* p-tag (via event_header.pubkey_owner_hash)
|
||||
*/
|
||||
fun deleteSQL(
|
||||
pubkey: HexKey,
|
||||
idValues: List<String>,
|
||||
addresses: List<Address>,
|
||||
hasher: TagNameValueHasher,
|
||||
): List<SqlArgs> {
|
||||
val owner = hasher.hash(pubkey)
|
||||
val idParams = idValues.joinToString(",") { "?" }
|
||||
|
||||
// aligns each type of param with the need to filter d-tag
|
||||
// and thus each index type
|
||||
val addressablesByKind = addresses.filter { it.kind.isAddressable() && it.pubKeyHex == pubkey }.groupBy { it.kind }
|
||||
val replaceablesByKind = addresses.filter { it.kind.isReplaceable() && it.pubKeyHex == pubkey }.groupBy { it.kind }
|
||||
|
||||
val addressableParams =
|
||||
addressablesByKind.keys.joinToString("\n OR\n ") {
|
||||
val tagList = addressablesByKind[it]
|
||||
if (tagList == null) {
|
||||
""
|
||||
} else if (tagList.size == 1) {
|
||||
"(kind = ? AND pubkey = ? AND d_tag = ?)"
|
||||
} else {
|
||||
"(kind = ? AND pubkey = ? AND d_tag IN (${tagList.joinToString(",") { "?" }}))"
|
||||
}
|
||||
}
|
||||
|
||||
val addressableValues =
|
||||
addressablesByKind.flatMap {
|
||||
listOf<Any>(it.key.toLong(), pubkey) + it.value.map { it.dTag }
|
||||
}
|
||||
|
||||
val replaceableKindsParam = replaceablesByKind.keys.joinToString(",") { "?" }
|
||||
val replaceableKindsValues = replaceablesByKind.keys.map { it.toLong() }
|
||||
|
||||
val deleteById =
|
||||
if (idValues.isNotEmpty()) {
|
||||
SqlArgs(
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
id IN ($idParams) AND
|
||||
pubkey_owner_hash = ?
|
||||
""".trimIndent(),
|
||||
idValues.plus(owner).toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val deleteByAddress =
|
||||
if (addressableValues.isNotEmpty()) {
|
||||
SqlArgs(
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE (
|
||||
$addressableParams
|
||||
) AND
|
||||
kind >= 30000 AND kind < 40000
|
||||
""".trimIndent(),
|
||||
addressableValues.toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val deleteByReplaceable =
|
||||
if (replaceableKindsParam.isNotEmpty()) {
|
||||
SqlArgs(
|
||||
"""
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
kind IN ($replaceableKindsParam) AND
|
||||
pubkey = ? AND
|
||||
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
|
||||
""".trimIndent(),
|
||||
replaceableKindsValues.plus(pubkey).toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return listOfNotNull(deleteById, deleteByAddress, deleteByReplaceable)
|
||||
}
|
||||
|
||||
class SqlArgs(
|
||||
val sql: String,
|
||||
val args: Array<Any>,
|
||||
)
|
||||
}
|
||||
|
||||
+242
-169
@@ -25,21 +25,19 @@ 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.core.Tag
|
||||
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 seedModule: SeedModule,
|
||||
val tagIndexStrategy: IndexingStrategy = IndexingStrategy(),
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IModule {
|
||||
private var hasherCache: TagNameValueHasher? = null
|
||||
|
||||
fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(seedModule.getSeed(db)).also { hasherCache = it }
|
||||
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
@@ -50,11 +48,12 @@ class EventIndexesModule(
|
||||
created_at INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
d_tag TEXT,
|
||||
etag_hash INTEGER NOT NULL,
|
||||
atag_hash INTEGER,
|
||||
tags TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
sig TEXT NOT NULL
|
||||
sig TEXT NOT NULL,
|
||||
pubkey_owner_hash INTEGER NOT NULL,
|
||||
etag_hash INTEGER,
|
||||
atag_hash INTEGER
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
@@ -69,10 +68,14 @@ class EventIndexesModule(
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)")
|
||||
db.execSQL("CREATE INDEX query_by_kind_pubkey_idx ON event_headers (created_at desc, kind, pubkey, d_tag)")
|
||||
db.execSQL("CREATE INDEX query_by_id_idx ON event_headers (created_at desc, id)")
|
||||
db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_hash)")
|
||||
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)")
|
||||
|
||||
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)")
|
||||
|
||||
// Prevent updates to maintain immutability
|
||||
db.execSQL(
|
||||
@@ -106,9 +109,9 @@ class EventIndexesModule(
|
||||
val sqlInsertHeader =
|
||||
"""
|
||||
INSERT INTO event_headers
|
||||
(id, pubkey, created_at, kind, tags, content, sig, d_tag, etag_hash, atag_hash)
|
||||
(id, pubkey, created_at, kind, tags, content, sig, d_tag, pubkey_owner_hash, etag_hash, atag_hash)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
val sqlInsertTags =
|
||||
@@ -125,56 +128,75 @@ class EventIndexesModule(
|
||||
): Long {
|
||||
val hasher = hasher(db)
|
||||
val stmt = db.compileStatement(sqlInsertHeader)
|
||||
|
||||
val kindLong = event.kind.toLong()
|
||||
val pubkeyHash = hasher.hash(event.pubKey)
|
||||
|
||||
val eventOwnerHash =
|
||||
if (event is GiftWrapEvent) {
|
||||
event.recipientPubKey()?.let { hasher.hash(it) } ?: pubkeyHash
|
||||
} else {
|
||||
pubkeyHash
|
||||
}
|
||||
|
||||
val eTagHash = hasher.hashETag(event.id)
|
||||
|
||||
stmt.bindString(1, event.id)
|
||||
stmt.bindString(2, event.pubKey)
|
||||
stmt.bindLong(3, event.createdAt)
|
||||
stmt.bindLong(4, event.kind.toLong())
|
||||
stmt.bindLong(4, kindLong)
|
||||
stmt.bindString(5, OptimizedJsonMapper.toJson(event.tags))
|
||||
stmt.bindString(6, event.content)
|
||||
stmt.bindString(7, event.sig)
|
||||
if (event is AddressableEvent) {
|
||||
val dTag = event.dTag()
|
||||
stmt.bindString(8, dTag)
|
||||
stmt.bindLong(9, hasher.hashETag(event.id))
|
||||
stmt.bindLong(10, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag)))
|
||||
stmt.bindLong(9, eventOwnerHash)
|
||||
stmt.bindLong(10, eTagHash)
|
||||
stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag)))
|
||||
} else {
|
||||
stmt.bindNull(8)
|
||||
stmt.bindLong(9, hasher.hashETag(event.id))
|
||||
stmt.bindNull(10)
|
||||
stmt.bindLong(9, eventOwnerHash)
|
||||
stmt.bindLong(10, eTagHash)
|
||||
stmt.bindNull(11)
|
||||
}
|
||||
|
||||
val headerId = stmt.executeInsert()
|
||||
|
||||
val stmtTags = db.compileStatement(sqlInsertTags)
|
||||
|
||||
event.tags.forEach { tag ->
|
||||
if (tagIndexStrategy.shouldIndex(event.kind, tag)) {
|
||||
stmtTags.bindLong(1, headerId)
|
||||
stmtTags.bindLong(2, hasher.hash(tag[0], tag[1]))
|
||||
stmtTags.executeInsert()
|
||||
// sorting helps SQLLite by avoiding
|
||||
// rebalancing the tree every new insert
|
||||
val indexableTags = ArrayList<Long>()
|
||||
for (idx in event.tags.indices) {
|
||||
if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) {
|
||||
indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1]))
|
||||
}
|
||||
}
|
||||
indexableTags.sort()
|
||||
indexableTags.forEach {
|
||||
stmtTags.bindLong(1, headerId)
|
||||
stmtTags.bindLong(2, it)
|
||||
stmtTags.executeInsert()
|
||||
}
|
||||
|
||||
return headerId
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, we index all tags that have a single letter name and some value
|
||||
*/
|
||||
class IndexingStrategy {
|
||||
fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
) = tag.size >= 2 && tag[0].length == 1
|
||||
}
|
||||
|
||||
fun planQuery(
|
||||
filter: Filter,
|
||||
hasher: TagNameValueHasher,
|
||||
db: SQLiteDatabase,
|
||||
): String {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) ?: return makeEverythingQuery()
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher)
|
||||
|
||||
return makeQueryIn(rowIdSubQuery.sql)
|
||||
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(
|
||||
@@ -195,33 +217,37 @@ class EventIndexesModule(
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach)
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach)
|
||||
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 rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher) }
|
||||
if (rowIdSubQueries.isEmpty()) return makeEverythingQuery()
|
||||
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
|
||||
return makeQueryIn(unions)
|
||||
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 = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
|
||||
if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery())
|
||||
|
||||
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
|
||||
val args = rowIdSubQueries.flatMap { it.args }
|
||||
|
||||
return db.runQuery(makeQueryIn(unions), args)
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery())
|
||||
return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
fun <T : Event> query(
|
||||
@@ -229,14 +255,13 @@ class EventIndexesModule(
|
||||
db: SQLiteDatabase,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db))
|
||||
|
||||
if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach)
|
||||
|
||||
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
|
||||
val args = rowIdSubQueries.flatMap { it.args }
|
||||
|
||||
db.runQueryEmitting(makeQueryIn(unions), args, onEach)
|
||||
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"
|
||||
@@ -244,7 +269,9 @@ class EventIndexesModule(
|
||||
private fun makeQueryIn(rowIdQuery: String) =
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN ($rowIdQuery) AS filtered
|
||||
INNER JOIN (
|
||||
$rowIdQuery
|
||||
) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent()
|
||||
@@ -254,56 +281,66 @@ class EventIndexesModule(
|
||||
args: List<String> = emptyList(),
|
||||
): List<T> =
|
||||
rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
parseResults(cursor)
|
||||
ArrayList<T>(cursor.count).apply {
|
||||
while (cursor.moveToNext()) {
|
||||
add(cursor.toEvent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Event> parseResults(cursor: Cursor): List<T> {
|
||||
val events = ArrayList<T>(cursor.count)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
events.add(
|
||||
EventFactory.create<T>(
|
||||
cursor.getString(0).intern(),
|
||||
cursor.getString(1).intern(),
|
||||
cursor.getLong(2),
|
||||
cursor.getInt(3),
|
||||
OptimizedJsonMapper.fromJsonToTagArray(cursor.getString(4)),
|
||||
cursor.getString(5),
|
||||
cursor.getString(6),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
private fun <T : Event> SQLiteDatabase.runQueryEmitting(
|
||||
private inline fun <T : Event> SQLiteDatabase.runQuery(
|
||||
sql: String,
|
||||
args: List<String> = emptyList(),
|
||||
onEach: (T) -> Unit,
|
||||
) = rawQuery(sql, args.toTypedArray()).use { cursor ->
|
||||
emitResults(cursor, onEach)
|
||||
}
|
||||
|
||||
private fun <T : Event> emitResults(
|
||||
cursor: Cursor,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
while (cursor.moveToNext()) {
|
||||
onEach(
|
||||
EventFactory.create<T>(
|
||||
cursor.getString(0).intern(),
|
||||
cursor.getString(1).intern(),
|
||||
cursor.getLong(2),
|
||||
cursor.getInt(3),
|
||||
OptimizedJsonMapper.fromJsonToTagArray(cursor.getString(4)),
|
||||
cursor.getString(5),
|
||||
cursor.getString(6),
|
||||
),
|
||||
)
|
||||
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
|
||||
// -------------
|
||||
@@ -311,23 +348,22 @@ class EventIndexesModule(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.countEverything()
|
||||
val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db))
|
||||
|
||||
return db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
|
||||
return if (rowIdSubQuery == null) {
|
||||
db.countEverything()
|
||||
} else {
|
||||
db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args)
|
||||
}
|
||||
}
|
||||
|
||||
fun count(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything()
|
||||
|
||||
if (rowIdSubQueries.isEmpty()) return db.countEverything()
|
||||
|
||||
val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql }
|
||||
val args = rowIdSubQueries.flatMap { it.args }
|
||||
|
||||
return db.countIn(unions, args)
|
||||
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
|
||||
@@ -353,22 +389,22 @@ class EventIndexesModule(
|
||||
filter: Filter,
|
||||
db: SQLiteDatabase,
|
||||
): Int {
|
||||
val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return 0
|
||||
return db.runDelete(rowIdQuery.sql, rowIdQuery.args)
|
||||
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 = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) }
|
||||
val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0
|
||||
|
||||
if (rowIdSubqueries.isEmpty()) return 0
|
||||
|
||||
val unions = rowIdSubqueries.joinToString(" UNION ") { it.sql }
|
||||
val args = rowIdSubqueries.flatMap { it.args }
|
||||
|
||||
return db.runDelete(unions, args)
|
||||
return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args)
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.runDelete(
|
||||
@@ -376,6 +412,30 @@ class EventIndexesModule(
|
||||
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
|
||||
// ----------------------------
|
||||
@@ -385,92 +445,105 @@ class EventIndexesModule(
|
||||
): 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 && ids.isNotEmpty()) ||
|
||||
(ids != null) ||
|
||||
(authors != null && authors.isNotEmpty()) ||
|
||||
(kinds != null && kinds.isNotEmpty()) ||
|
||||
(tags != null && tags.containsKey("d")) ||
|
||||
(since != null) ||
|
||||
(until != null) ||
|
||||
(tags != null && tags.containsKey("d"))
|
||||
(limit != null)
|
||||
}
|
||||
|
||||
val hasSearch = (filter.search != null && filter.search.isNotBlank())
|
||||
var defaultTagKey: String? = null
|
||||
|
||||
val projection =
|
||||
buildString {
|
||||
val joins = mutableListOf<String>()
|
||||
// always do tags if there are any
|
||||
if (nonDTags.isNotEmpty()) {
|
||||
append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ")
|
||||
|
||||
if (hasHeaders) {
|
||||
append("SELECT event_headers.row_id as row_id FROM event_headers")
|
||||
|
||||
if (hasSearch) {
|
||||
joins.add("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_headers.row_id")
|
||||
}
|
||||
|
||||
filter.tags?.forEach { (tagName, _) ->
|
||||
if (tagName != "d") {
|
||||
joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = event_headers.row_id")
|
||||
// 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
|
||||
}
|
||||
}
|
||||
} else if (hasSearch) {
|
||||
append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}")
|
||||
|
||||
filter.tags?.forEach { (tagName, _) ->
|
||||
if (tagName != "d") {
|
||||
joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}")
|
||||
}
|
||||
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 {
|
||||
// has only tags
|
||||
filter.tags?.forEach { (tagName, _) ->
|
||||
if (tagName != "d") {
|
||||
if (isEmpty()) {
|
||||
append("SELECT tag$tagName.event_header_row_id as row_id FROM event_tags as tag$tagName")
|
||||
} else {
|
||||
joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = tag${tagName.takeLast(1)}.event_header_row_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty()) {
|
||||
// only limit is present
|
||||
append("SELECT event_headers.row_id as row_id FROM event_headers")
|
||||
}
|
||||
}
|
||||
|
||||
if (joins.isNotEmpty()) {
|
||||
append(" ${joins.joinToString(" ")}")
|
||||
// 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) }
|
||||
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
|
||||
filter.authors?.let { equalsOrIn("event_headers.pubkey", 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)
|
||||
} else {
|
||||
equalsOrIn(
|
||||
"tag$tagName.tag_hash",
|
||||
tagValues.map {
|
||||
hasher.hash(tagName, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
filter.search?.let { match(fts.tableName, it) }
|
||||
// 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 created_at DESC, id ASC LIMIT ${filter.limit}"
|
||||
"${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}"
|
||||
} else {
|
||||
clause.conditions
|
||||
}
|
||||
@@ -483,7 +556,7 @@ class EventIndexesModule(
|
||||
db.execSQL("DELETE FROM event_headers")
|
||||
}
|
||||
|
||||
class RowIdSubQuery(
|
||||
data class RowIdSubQuery(
|
||||
val sql: String,
|
||||
val args: List<String>,
|
||||
)
|
||||
|
||||
+1
-2
@@ -24,13 +24,12 @@ import android.content.Context
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventIndexesModule.IndexingStrategy
|
||||
|
||||
class EventStore(
|
||||
context: Context,
|
||||
dbName: String? = "events.db",
|
||||
val relayUrl: String? = "wss://quartz.local",
|
||||
val tagIndexStrategy: IndexingStrategy = IndexingStrategy(),
|
||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : IEventStore {
|
||||
val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy)
|
||||
|
||||
|
||||
+1
-3
@@ -29,7 +29,7 @@ class ExpirationModule : IModule {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE event_expirations (
|
||||
event_header_row_id INTEGER,
|
||||
event_header_row_id INTEGER PRIMARY KEY NOT NULL,
|
||||
expiration INTEGER NOT NULL,
|
||||
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -49,8 +49,6 @@ class ExpirationModule : IModule {
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL("CREATE UNIQUE INDEX events_exp_id ON event_expirations (event_header_row_id)")
|
||||
}
|
||||
|
||||
override fun drop(db: SQLiteDatabase) {
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Tag
|
||||
|
||||
interface IndexingStrategy {
|
||||
fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* By default, we index all tags that have a single letter name and some value
|
||||
*/
|
||||
class DefaultIndexingStrategy : IndexingStrategy {
|
||||
override fun shouldIndex(
|
||||
kind: Int,
|
||||
tag: Tag,
|
||||
) = tag.size >= 2 && tag[0].length == 1
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
|
||||
fun SQLiteEventStore.explainQuery(
|
||||
sql: String,
|
||||
args: Array<Any> = emptyArray(),
|
||||
) = readableDatabase.explainQuery(sql, args.map { it.toString() }.toTypedArray())
|
||||
|
||||
fun SQLiteDatabase.explainQuery(
|
||||
sql: String,
|
||||
args: Array<String> = emptyArray(),
|
||||
): String =
|
||||
rawQuery("EXPLAIN QUERY PLAN $sql", args).use { cursor ->
|
||||
val treeIndex = mutableMapOf<Int, PlanNode>()
|
||||
val rootNodes = mutableListOf<PlanNode>()
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getInt(0)
|
||||
val parentId = cursor.getInt(1)
|
||||
val detail = cursor.getString(3)
|
||||
|
||||
val line = PlanNode(detail)
|
||||
|
||||
treeIndex[id] = line
|
||||
|
||||
val parent = treeIndex[parentId]
|
||||
if (parent != null) {
|
||||
parent.children.add(line)
|
||||
} else {
|
||||
rootNodes.add(line)
|
||||
}
|
||||
}
|
||||
|
||||
buildString {
|
||||
appendLine(populateArgs(sql, args))
|
||||
for (idx in rootNodes.indices) {
|
||||
printNode(rootNodes[idx], "", idx == rootNodes.size - 1, idx > 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateArgs(
|
||||
sql: String,
|
||||
args: Array<String> = emptyArray(),
|
||||
): String {
|
||||
var result = sql
|
||||
args.forEach {
|
||||
result = result.replaceFirst("?", "\"$it\"")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun StringBuilder.printNode(
|
||||
node: PlanNode,
|
||||
prefix: String,
|
||||
isLast: Boolean,
|
||||
newLine: Boolean,
|
||||
) {
|
||||
if (newLine) append('\n')
|
||||
append(prefix)
|
||||
append(if (isLast) "└── " else "├── ")
|
||||
append(node.detail)
|
||||
|
||||
val newPrefix = prefix + if (isLast) " " else "│ "
|
||||
for (i in node.children.indices) {
|
||||
printNode(node.children[i], newPrefix, i == node.children.size - 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
data class PlanNode(
|
||||
val detail: String,
|
||||
val children: MutableList<PlanNode> = mutableListOf(),
|
||||
)
|
||||
+4
-3
@@ -28,7 +28,7 @@ class ReplaceableModule : IModule {
|
||||
"""
|
||||
CREATE UNIQUE INDEX replaceable_idx
|
||||
ON event_headers (kind, pubkey)
|
||||
WHERE (kind >= 10000 AND kind < 20000) OR (kind IN (0, 3))
|
||||
WHERE (kind IN (0, 3)) OR (kind >= 10000 AND kind < 20000)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
@@ -41,14 +41,15 @@ class ReplaceableModule : IModule {
|
||||
CREATE TRIGGER delete_older_replaceable_event
|
||||
BEFORE INSERT ON event_headers
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.kind >= 10000 AND NEW.kind < 20000) OR (NEW.kind IN (0, 3))
|
||||
WHEN (NEW.kind IN (0, 3)) OR (NEW.kind >= 10000 AND NEW.kind < 20000)
|
||||
BEGIN
|
||||
-- Delete older records if this is the newest
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.created_at < NEW.created_at;
|
||||
event_headers.created_at < NEW.created_at AND
|
||||
((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000));
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+14
-12
@@ -24,20 +24,22 @@ import android.database.sqlite.SQLiteDatabase
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
|
||||
class RightToVanishModule : IModule {
|
||||
class RightToVanishModule(
|
||||
val hasher: (db: SQLiteDatabase) -> TagNameValueHasher,
|
||||
) : IModule {
|
||||
override fun create(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE event_vanish (
|
||||
event_header_row_id INTEGER,
|
||||
pubkey TEXT NOT NULL,
|
||||
event_header_row_id INTEGER PRIMARY KEY NOT NULL,
|
||||
pubkey_hash INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey)")
|
||||
db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey_hash)")
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
@@ -48,8 +50,8 @@ class RightToVanishModule : IModule {
|
||||
-- Delete older records if this is the newest
|
||||
DELETE FROM event_vanish
|
||||
WHERE
|
||||
event_vanish.created_at < NEW.created_at AND
|
||||
event_vanish.pubkey = NEW.pubkey;
|
||||
event_vanish.pubkey_hash = NEW.pubkey_hash AND
|
||||
event_vanish.created_at < NEW.created_at;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
@@ -61,8 +63,8 @@ class RightToVanishModule : IModule {
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
DELETE FROM event_headers
|
||||
WHERE created_at < NEW.created_at AND
|
||||
pubkey = NEW.pubkey;
|
||||
WHERE event_headers.created_at < NEW.created_at AND
|
||||
event_headers.pubkey_owner_hash = NEW.pubkey_hash;
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
@@ -78,8 +80,8 @@ class RightToVanishModule : IModule {
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM event_vanish
|
||||
WHERE
|
||||
event_vanish.created_at >= NEW.created_at AND
|
||||
event_vanish.pubkey = NEW.pubkey
|
||||
event_vanish.pubkey_hash = NEW.pubkey_owner_hash AND
|
||||
event_vanish.created_at >= NEW.created_at
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
@@ -92,7 +94,7 @@ class RightToVanishModule : IModule {
|
||||
|
||||
val insertRTV =
|
||||
"""
|
||||
INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey, created_at)
|
||||
INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey_hash, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
""".trimIndent()
|
||||
|
||||
@@ -105,7 +107,7 @@ class RightToVanishModule : IModule {
|
||||
if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) {
|
||||
val stmt = db.compileStatement(insertRTV)
|
||||
stmt.bindLong(1, headerId)
|
||||
stmt.bindString(2, event.pubKey)
|
||||
stmt.bindLong(2, hasher(db).hash(event.pubKey))
|
||||
stmt.bindLong(3, event.createdAt)
|
||||
stmt.executeInsert()
|
||||
}
|
||||
|
||||
+24
-6
@@ -30,30 +30,32 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventIndexesModule.IndexingStrategy
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SQLiteEventStore(
|
||||
val context: Context,
|
||||
val dbName: String? = "events.db",
|
||||
val relayUrl: String? = null,
|
||||
val tagIndexStrategy: IndexingStrategy = IndexingStrategy(),
|
||||
val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) {
|
||||
companion object {
|
||||
const val DATABASE_VERSION = 2
|
||||
}
|
||||
|
||||
val seedModule = SeedModule()
|
||||
|
||||
val fullTextSearchModule = FullTextSearchModule()
|
||||
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule, tagIndexStrategy)
|
||||
val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy)
|
||||
|
||||
val replaceableModule = ReplaceableModule()
|
||||
val addressableModule = AddressableModule()
|
||||
val ephemeralModule = EphemeralModule()
|
||||
|
||||
val deletionModule = DeletionRequestModule()
|
||||
val deletionModule = DeletionRequestModule(seedModule::hasher)
|
||||
val expirationModule = ExpirationModule()
|
||||
val rightToVanishModule = RightToVanishModule()
|
||||
val rightToVanishModule = RightToVanishModule(seedModule::hasher)
|
||||
|
||||
val modules =
|
||||
listOf(
|
||||
@@ -112,12 +114,28 @@ class SQLiteEventStore(
|
||||
modules.reversed().forEach { it.deleteAll(db) }
|
||||
}
|
||||
|
||||
suspend fun vacuum() {
|
||||
// 1. ANALYZE: Collects statistics about tables and indices
|
||||
// to help the query planner optimize queries.
|
||||
withContext(Dispatchers.IO) {
|
||||
writableDatabase.execSQL("VACUUM")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun analyse() {
|
||||
// 2. VACUUM: Rebuilds the database file, reclaiming unused space
|
||||
// and reducing fragmentation.
|
||||
withContext(Dispatchers.IO) {
|
||||
writableDatabase.execSQL("ANALYZE")
|
||||
}
|
||||
}
|
||||
|
||||
private fun innerInsertEvent(
|
||||
event: Event,
|
||||
db: SQLiteDatabase,
|
||||
) {
|
||||
val headerId = eventIndexModule.insert(event, db)
|
||||
deletionModule.insert(event, headerId, db)
|
||||
deletionModule.insert(event, db)
|
||||
expirationModule.insert(event, headerId, db)
|
||||
fullTextSearchModule.insert(event, headerId, db)
|
||||
rightToVanishModule.insert(event, relayUrl, headerId, db)
|
||||
|
||||
+4
@@ -76,4 +76,8 @@ class SeedModule : IModule {
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteDatabase) {}
|
||||
|
||||
private var hasherCache: TagNameValueHasher? = null
|
||||
|
||||
fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it }
|
||||
}
|
||||
|
||||
+8
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.bloom.MurmurHash3
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,9 @@ class TagNameValueHasher(
|
||||
val hasher = MurmurHash3()
|
||||
|
||||
// small performance improvements on inserting
|
||||
val pTagHash by lazy {
|
||||
hasher.hash128x64Half("p".encodeToByteArray(), seed)
|
||||
}
|
||||
val eTagHash by lazy {
|
||||
hasher.hash128x64Half("e".encodeToByteArray(), seed)
|
||||
}
|
||||
@@ -56,4 +60,8 @@ class TagNameValueHasher(
|
||||
fun hashATag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), aTagHash)
|
||||
|
||||
fun hashETag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), eTagHash)
|
||||
|
||||
fun hashPTag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), pTagHash)
|
||||
|
||||
fun hash(value: HexKey) = hasher.hash128x64Half(value.encodeToByteArray(), seed)
|
||||
}
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import junit.framework.TestCase
|
||||
import kotlin.test.Test
|
||||
|
||||
class EventDbQueryAssemblerTest {
|
||||
val builder = EventIndexesModule(FullTextSearchModule(), SeedModule())
|
||||
val hasher = TagNameValueHasher(0L)
|
||||
|
||||
val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d"
|
||||
val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14"
|
||||
val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9"
|
||||
|
||||
@Test
|
||||
fun testEmpty() {
|
||||
val sql = builder.planQuery(Filter(), hasher)
|
||||
TestCase.assertEquals(
|
||||
"SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id",
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLimit() {
|
||||
val sql = builder.planQuery(Filter(limit = 10), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAllFearures() {
|
||||
val sql =
|
||||
builder.planQuery(
|
||||
listOf(
|
||||
Filter(limit = 10),
|
||||
Filter(authors = listOf(key1), kinds = listOf(1, 1111), search = "keywords", limit = 100),
|
||||
Filter(kinds = listOf(20), search = "cats", limit = 30),
|
||||
),
|
||||
hasher,
|
||||
)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?)) AND (event_headers.pubkey = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 100 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 30) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIdQuery() {
|
||||
val sql = builder.planQuery(Filter(ids = listOf(key1)), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = ?) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthors() {
|
||||
val sql = builder.planQuery(Filter(authors = listOf(key1, key2)), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.pubkey IN (?, ?)) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAuthorsAndSearch() {
|
||||
val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords"), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.pubkey IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKindAndSearch() {
|
||||
val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords"), hasher)
|
||||
TestCase.assertEquals(
|
||||
"""
|
||||
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
|
||||
INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered
|
||||
ON event_headers.row_id = filtered.row_id
|
||||
ORDER BY created_at DESC, id
|
||||
""".trimIndent(),
|
||||
sql,
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 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.experimental.nipA3
|
||||
|
||||
class PaymentTarget(
|
||||
val type: String,
|
||||
val authority: String,
|
||||
)
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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.experimental.nipA3
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
class PaymentTargetTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "payto"
|
||||
|
||||
fun match(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun notMatch(tag: Array<String>) = !(tag.has(0) && tag[0] == TAG_NAME)
|
||||
|
||||
fun parse(tag: Array<String>): PaymentTarget? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
ensure(tag[2].isNotEmpty()) { return null }
|
||||
|
||||
val paymentTarget = PaymentTarget(tag[1], tag[2])
|
||||
|
||||
return paymentTarget
|
||||
}
|
||||
|
||||
fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority)
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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.experimental.nipA3
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
class PaymentTargetsEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
companion object {
|
||||
const val KIND = 10133
|
||||
|
||||
suspend fun updatePaymentTargets(
|
||||
earlierVersion: PaymentTargetsEvent,
|
||||
targets: List<PaymentTarget>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): PaymentTargetsEvent {
|
||||
val tags =
|
||||
earlierVersion.tags
|
||||
.filter(PaymentTargetTag::notMatch)
|
||||
.plus(targets.map { PaymentTargetTag.assemble(it) })
|
||||
.toTypedArray()
|
||||
|
||||
return signer.sign(createdAt, KIND, tags, earlierVersion.content)
|
||||
}
|
||||
|
||||
fun createPaymentTargets(targets: List<PaymentTarget>) = targets.map { PaymentTargetTag.assemble(it) }.toTypedArray()
|
||||
|
||||
suspend fun create(
|
||||
targets: List<PaymentTarget>,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): PaymentTargetsEvent = signer.sign(createdAt, KIND, createPaymentTargets(targets), "")
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this)
|
||||
|
||||
fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this)
|
||||
|
||||
fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null
|
||||
|
||||
const val PUBKEY_LENGTH = 64
|
||||
|
||||
const val EVENT_ID_LENGTH = 64
|
||||
|
||||
@@ -47,7 +47,7 @@ object Merkle {
|
||||
// left.ops[OpAppend(right.msg)] = right_prepend_stamp
|
||||
// leftAppendStamp = left.ops.add(OpAppend(right.msg))
|
||||
// Timestamp leftPrependStamp = left.add(new OpAppend(right.msg));
|
||||
left.ops.put(OpAppend(right.digest), rightPrependStamp!!)
|
||||
left.ops[OpAppend(right.digest)] = rightPrependStamp
|
||||
|
||||
// return rightPrependStamp.ops.add(unaryOpCls())
|
||||
val res = rightPrependStamp.add(OpSHA256())
|
||||
|
||||
+2
-2
@@ -453,12 +453,12 @@ class Timestamp(
|
||||
fun allTips(): MutableSet<ByteArray?> {
|
||||
val set: MutableSet<ByteArray?> = HashSet<ByteArray?>()
|
||||
|
||||
if (this.ops.size == 0) {
|
||||
if (this.ops.isEmpty()) {
|
||||
set.add(this.digest)
|
||||
}
|
||||
|
||||
for (entry in this.ops.entries) {
|
||||
val ts: Timestamp = entry.value!!
|
||||
val ts: Timestamp = entry.value
|
||||
|
||||
// Op op = entry.getKey();
|
||||
val subSet = ts.allTips()
|
||||
|
||||
+18
-39
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip10Notes.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag
|
||||
|
||||
/**
|
||||
* Returns a list of NIP-10 marked tags that are also ordered at best effort to support the
|
||||
@@ -32,47 +33,25 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
* The tag to the root of the reply chain goes first. The tag to the reply event being responded
|
||||
* to goes last. The order for any other tag does not matter, so keep the relative order.
|
||||
*/
|
||||
fun List<String>.positionalMarkedTags(
|
||||
tagName: String,
|
||||
root: String?,
|
||||
replyingTo: String?,
|
||||
forkedFrom: String?,
|
||||
) = sortedWith { o1, o2 ->
|
||||
when {
|
||||
o1 == o2 -> 0
|
||||
o1 == root -> -1 // root goes first
|
||||
o2 == root -> 1 // root goes first
|
||||
o1 == replyingTo -> 1 // reply event being responded to goes last
|
||||
o2 == replyingTo -> -1 // reply event being responded to goes last
|
||||
else -> 0 // keep the relative order for any other tag
|
||||
}
|
||||
}.map {
|
||||
when (it) {
|
||||
root -> arrayOf(tagName, it, "", MarkedETag.MARKER.ROOT)
|
||||
replyingTo -> arrayOf(tagName, it, "", MarkedETag.MARKER.REPLY)
|
||||
forkedFrom -> arrayOf(tagName, it, "", MarkedETag.MARKER.FORK)
|
||||
else -> arrayOf(tagName, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun List<ETag>.positionalMarkedTags(
|
||||
root: ETag?,
|
||||
replyingTo: ETag?,
|
||||
forkedFrom: ETag?,
|
||||
) = sortedWith { o1, o2 ->
|
||||
when {
|
||||
o1.eventId == o2.eventId -> 0
|
||||
o1.eventId == root?.eventId -> -1 // root goes first
|
||||
o2.eventId == root?.eventId -> 1 // root goes first
|
||||
o1.eventId == replyingTo?.eventId -> 1 // reply event being responded to goes last
|
||||
o2.eventId == replyingTo?.eventId -> -1 // reply event being responded to goes last
|
||||
else -> 0 // keep the relative order for any other tag
|
||||
): List<GenericETag> =
|
||||
sortedWith { o1, o2 ->
|
||||
when {
|
||||
o1.eventId == o2.eventId -> 0
|
||||
o1.eventId == root?.eventId -> -1 // root goes first
|
||||
o2.eventId == root?.eventId -> 1 // root goes first
|
||||
o1.eventId == replyingTo?.eventId -> 1 // reply event being responded to goes last
|
||||
o2.eventId == replyingTo?.eventId -> -1 // reply event being responded to goes last
|
||||
else -> 0 // keep the relative order for any other tag
|
||||
}
|
||||
}.map {
|
||||
when (it.eventId) {
|
||||
root?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.ROOT, it.author)
|
||||
replyingTo?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.REPLY, it.author)
|
||||
forkedFrom?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.FORK, it.author)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
}.map {
|
||||
when (it.eventId) {
|
||||
root?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.ROOT, it.author)
|
||||
replyingTo?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.REPLY, it.author)
|
||||
forkedFrom?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.FORK, it.author)
|
||||
else -> it
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ open class BaseDMGroupEvent(
|
||||
return result
|
||||
}
|
||||
|
||||
override fun isIncluded(pubKey: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey)
|
||||
override fun isIncluded(user: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey)
|
||||
|
||||
override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet()
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm.base
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
interface NIP17Group {
|
||||
fun isIncluded(pubKey: HexKey): Boolean
|
||||
fun isIncluded(user: HexKey): Boolean
|
||||
|
||||
fun groupMembers(): Set<HexKey>
|
||||
}
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ class ChannelMetadataEvent(
|
||||
if (content.isEmpty() || !content.startsWith("{") || isEncrypted()) {
|
||||
ChannelDataNorm()
|
||||
} else {
|
||||
ChannelData.parse(content).normalize() ?: ChannelDataNorm()
|
||||
ChannelData.parse(content).normalize()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.firstTagValue
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
|
||||
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
|
||||
@@ -104,13 +104,13 @@ class GiftWrapEvent(
|
||||
const val KIND = 1059
|
||||
const val ALT = "Encrypted event"
|
||||
|
||||
suspend fun create(
|
||||
fun create(
|
||||
event: Event,
|
||||
recipientPubKey: HexKey,
|
||||
expirationDelta: Long? = null,
|
||||
createdAt: Long = TimeUtils.randomWithTwoDays(),
|
||||
): GiftWrapEvent {
|
||||
val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key
|
||||
val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key
|
||||
|
||||
val tags =
|
||||
expirationDelta?.let {
|
||||
|
||||
+2
-2
@@ -65,12 +65,12 @@ class VoiceReplyEvent(
|
||||
hash: String,
|
||||
duration: Int,
|
||||
waveform: List<Float>,
|
||||
replyingTo: EventHintBundle<VoiceEvent>,
|
||||
replyingTo: EventHintBundle<BaseVoiceEvent>,
|
||||
) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo)
|
||||
|
||||
fun build(
|
||||
voiceMessage: AudioMeta,
|
||||
replyingTo: EventHintBundle<VoiceEvent>,
|
||||
replyingTo: EventHintBundle<BaseVoiceEvent>,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<VoiceReplyEvent>.() -> Unit = {},
|
||||
) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory
|
||||
import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
|
||||
@@ -272,6 +273,7 @@ class EventFactory {
|
||||
NIP90UserDiscoveryRequestEvent.KIND -> NIP90UserDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ class EventDeserializerTest {
|
||||
val templateJson = """{"kind":1,"content":"This is an unsigned event.","created_at":1234,"tags":[]}"""
|
||||
val template = EventTemplate.fromJson(templateJson)
|
||||
|
||||
val signedEvent = signer.signerSync.sign(template)!!
|
||||
val signedEvent = signer.signerSync.sign(template)
|
||||
|
||||
assertTrue(signedEvent.id.isNotEmpty())
|
||||
assertTrue(signedEvent.sig.isNotEmpty())
|
||||
|
||||
+53
-49
@@ -107,8 +107,8 @@ class NIP19ParserTest {
|
||||
|
||||
assertNotNull(actual)
|
||||
assertTrue(actual?.entity is NProfile)
|
||||
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", (actual?.entity as? NProfile)?.hex)
|
||||
assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), (actual?.entity as? NProfile)?.relay?.first())
|
||||
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex)
|
||||
assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay?.first())
|
||||
}
|
||||
|
||||
@Test()
|
||||
@@ -116,7 +116,7 @@ class NIP19ParserTest {
|
||||
val actual = Nip19Parser.uriToRoute("nostr:nprofile1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqzq9thu3vem5gvsc6f3l3uyz7c92h6lq56t9wws0zulzkrgc6nrvym5jfztf")
|
||||
|
||||
assertTrue(actual?.entity is NProfile)
|
||||
assertEquals("1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", (actual?.entity as? NProfile)?.hex)
|
||||
assertEquals("1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", actual.entity.hex)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@@ -124,7 +124,7 @@ class NIP19ParserTest {
|
||||
val actual = Nip19Parser.uriToRoute("nostr:nevent1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq36amnwvaz7tmjv4kxz7fwvd5xjcmpvahhqmr9vfejucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshsz8thwden5te0dehhxarj9ekh2arfdeuhwctvd3jhgtnrdakj7qg3waehxw309ucngvpwvcmh5tnfduhszythwden5te0dehhxarj9emkjmn99uq3jamnwvaz7tmhv4kxxmmdv5hxummnw3ezuamfdejj7qpqvsup5xk3e2quedxjvn2gjppc0lqny5dmnr2ypc9tftwmdxta0yjqrd6n50")
|
||||
|
||||
assertTrue(actual?.entity is NEvent)
|
||||
assertEquals("64381a1ad1ca81ccb4d264d48904387fc13251bb98d440e0ab4addb6997d7924", (actual?.entity as? NEvent)?.hex)
|
||||
assertEquals("64381a1ad1ca81ccb4d264d48904387fc13251bb98d440e0ab4addb6997d7924", actual.entity.hex)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@@ -132,7 +132,7 @@ class NIP19ParserTest {
|
||||
val actual = Nip19Parser.uriToRoute("nostr:naddr1qqyxzmt9w358jum5qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypd7v3r24z33cydnk3fmlrd0exe5dlej3506zxs05q4puerp765mzqcyqqq8scsq6mk7u")
|
||||
|
||||
assertTrue(actual?.entity is NAddress)
|
||||
assertEquals("30818:5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1:amethyst", (actual?.entity as? NAddress)?.aTag())
|
||||
assertEquals("30818:5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1:amethyst", actual.entity.aTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -141,9 +141,10 @@ class NIP19ParserTest {
|
||||
Nip19Parser.uriToRoute(
|
||||
"nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus",
|
||||
)
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:",
|
||||
(result?.entity as? NAddress)?.aTag(),
|
||||
(result.entity as? NAddress)?.aTag(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,9 +154,10 @@ class NIP19ParserTest {
|
||||
Nip19Parser.uriToRoute(
|
||||
"nostr:naddr1qqqqzxthwden5te0wfjkccte9ejxjanfdejjuanfv3jk7tczyrv4428upmlcujyf2fy4hqrynywj07ukakr99ufvmmw95n5tttj5qqcyqqqgt0qeresua",
|
||||
)
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"34236:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:",
|
||||
(result?.entity as? NAddress)?.aTag(),
|
||||
(result.entity as? NAddress)?.aTag(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -165,9 +167,10 @@ class NIP19ParserTest {
|
||||
Nip19Parser.uriToRoute(
|
||||
"nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8",
|
||||
)
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard",
|
||||
(result?.entity as? NAddress)?.aTag(),
|
||||
(result.entity as? NAddress)?.aTag(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,12 +180,13 @@ class NIP19ParserTest {
|
||||
Nip19Parser.uriToRoute(
|
||||
"naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38",
|
||||
)
|
||||
assertTrue(result?.entity is NAddress)
|
||||
assertNotNull(result)
|
||||
assertTrue(result.entity is NAddress)
|
||||
assertEquals(
|
||||
"30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920",
|
||||
(result?.entity as? NAddress)?.aTag(),
|
||||
result.entity.aTag(),
|
||||
)
|
||||
assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), (result?.entity as? NAddress)?.relay?.get(0))
|
||||
assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.entity.relay[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -261,11 +265,11 @@ class NIP19ParserTest {
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far",
|
||||
result?.aTag(),
|
||||
result.aTag(),
|
||||
)
|
||||
assertEquals(true, result?.relay?.isEmpty())
|
||||
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author)
|
||||
assertEquals(31337, result?.kind)
|
||||
assertEquals(true, result.relay?.isEmpty())
|
||||
assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author)
|
||||
assertEquals(31337, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -279,11 +283,11 @@ class NIP19ParserTest {
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509",
|
||||
result?.aTag(),
|
||||
result.aTag(),
|
||||
)
|
||||
assertEquals(true, result?.relay?.isEmpty())
|
||||
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author)
|
||||
assertEquals(30023, result?.kind)
|
||||
assertEquals(true, result.relay?.isEmpty())
|
||||
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
|
||||
assertEquals(30023, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -297,11 +301,11 @@ class NIP19ParserTest {
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418",
|
||||
result?.aTag(),
|
||||
result.aTag(),
|
||||
)
|
||||
assertEquals(true, result?.relay?.isEmpty())
|
||||
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author)
|
||||
assertEquals(30023, result?.kind)
|
||||
assertEquals(true, result.relay?.isEmpty())
|
||||
assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author)
|
||||
assertEquals(30023, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -310,10 +314,10 @@ class NIP19ParserTest {
|
||||
Nip19Parser.uriToRoute("nostr:nevent1qqsdw6xpk28tjnrajz4xhy2jqg0md8ywxj6997rsutjzxs0207tedjspz4mhxue69uhhyetvv9ujumn0wd68ytnzvuhsygx2crjrydvqdksffurc0fdsfc566pxtrg78afw0v8kursecwdqg9vpsgqqqqqqsnknas6")?.entity as? NEvent
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result?.hex)
|
||||
assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()?.url)
|
||||
assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result?.author)
|
||||
assertEquals(1, result?.kind)
|
||||
assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result.hex)
|
||||
assertEquals("wss://relay.nostr.bg/", result.relay.firstOrNull()?.url)
|
||||
assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result.author)
|
||||
assertEquals(1, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -322,10 +326,10 @@ class NIP19ParserTest {
|
||||
Nip19Parser.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy")?.entity as? NEvent
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex)
|
||||
assertEquals(true, result?.relay?.isEmpty())
|
||||
assertEquals(null, result?.author)
|
||||
assertEquals(null, result?.kind)
|
||||
assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result.hex)
|
||||
assertEquals(true, result.relay.isEmpty())
|
||||
assertEquals(null, result.author)
|
||||
assertEquals(null, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -334,10 +338,10 @@ class NIP19ParserTest {
|
||||
Nip19Parser.uriToRoute("nostr:nevent1qqsfvaa2w3nkw472lt2ezr6x5x347k8hht398vp7hrl6wrdjldry86sprfmhxue69uhhyetvv9ujuam9wd6x2unwvf6xxtnrdaks5myyah")?.entity as? NEvent
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result?.hex)
|
||||
assertEquals("wss://relay.westernbtc.com/", result?.relay?.firstOrNull()?.url)
|
||||
assertEquals(null, result?.author)
|
||||
assertEquals(null, result?.kind)
|
||||
assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result.hex)
|
||||
assertEquals("wss://relay.westernbtc.com/", result.relay.firstOrNull()?.url)
|
||||
assertEquals(null, result.author)
|
||||
assertEquals(null, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -349,13 +353,13 @@ class NIP19ParserTest {
|
||||
)?.entity as? NEvent
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex)
|
||||
assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex)
|
||||
assertEquals(
|
||||
NormalizedRelayUrl("wss://nostr.mom/"),
|
||||
result?.relay?.get(0),
|
||||
result.relay?.get(0),
|
||||
)
|
||||
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author)
|
||||
assertEquals(1, result?.kind)
|
||||
assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author)
|
||||
assertEquals(1, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -367,10 +371,10 @@ class NIP19ParserTest {
|
||||
)?.entity as? NEvent
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex)
|
||||
assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.relay.get(0))
|
||||
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author)
|
||||
assertEquals(1, result?.kind)
|
||||
assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result.hex)
|
||||
assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.relay[0])
|
||||
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author)
|
||||
assertEquals(1, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -382,10 +386,10 @@ class NIP19ParserTest {
|
||||
)?.entity as? NEvent
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex)
|
||||
assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result.hex)
|
||||
assertEquals(
|
||||
"wss://nos.lol/,wss://nostr.oxtr.dev/,wss://relay.nostr.bg/,wss://nostr.einundzwanzig.space/,wss://relay.nostr.band/,wss://relay.damus.io/",
|
||||
result?.relay?.joinToString(",") { it.url },
|
||||
result.relay.joinToString(",") { it.url },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -398,10 +402,10 @@ class NIP19ParserTest {
|
||||
)?.entity as? NEvent
|
||||
|
||||
assertNotNull(result)
|
||||
assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex)
|
||||
assertEquals("wss://relay.damus.io/", result?.relay?.get(0)?.url)
|
||||
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author)
|
||||
assertEquals(1, result?.kind)
|
||||
assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex)
|
||||
assertEquals("wss://relay.damus.io/", result.relay.get(0)?.url)
|
||||
assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author)
|
||||
assertEquals(1, result.kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BunkerRequestTest {
|
||||
@@ -31,6 +32,6 @@ class BunkerRequestTest {
|
||||
val bunkerRequest = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(requestJson)
|
||||
|
||||
assertTrue(bunkerRequest is BunkerRequestSign)
|
||||
assertTrue((bunkerRequest as BunkerRequestSign).event.kind == 1)
|
||||
assertEquals(1, bunkerRequest.event.kind)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user