This commit is contained in:
Vitor Pamplona
2025-12-30 12:29:04 -05:00
parent df97b7c444
commit c3d741ebd1
8 changed files with 442 additions and 15 deletions
@@ -0,0 +1,173 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import org.junit.After
import org.junit.Before
import org.junit.Test
class FilterMatcherTest {
private lateinit var db: EventStore
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = EventStore(context, null)
}
@After
fun tearDown() {
db.close()
}
val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val createdAt: Long = 1683596206
val kind = 1
val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"
val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"
val note =
Event(
id,
pubkey,
createdAt,
kind,
arrayOf(
arrayOf("e", rootETag, "", "root"),
arrayOf("e", replyETag, "", "reply"),
arrayOf("p", pTag1),
arrayOf("p", pTag1),
arrayOf("p", pTag2),
arrayOf("p", pTag3),
arrayOf("p", pTag4),
arrayOf("p", pTag5),
arrayOf("p", pTag6),
arrayOf("p", pTag7),
arrayOf("p", pTag8),
),
"Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
)
@Test
fun matchIds() {
db.insert(note)
db.assertQuery(note, Filter(ids = listOf(id)))
db.assertQuery(note, Filter(ids = listOf(id, rootETag)))
db.assertQuery(null, Filter(ids = listOf(rootETag)))
}
@Test
fun matchPubkeys() {
db.insert(note)
db.assertQuery(note, Filter(authors = listOf(pubkey)))
db.assertQuery(note, Filter(authors = listOf(pubkey, rootETag)))
db.assertQuery(null, Filter(authors = listOf(rootETag)))
}
@Test
fun matchTags() {
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id))))
}
@Test
fun matchDualTags() {
db.insert(note)
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchAllTags() {
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id))))
}
@Test
fun matchDualAllTags() {
db.insert(note)
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
db.assertQuery(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
db.assertQuery(null, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchKinds() {
db.insert(note)
db.assertQuery(note, Filter(kinds = listOf(kind)))
db.assertQuery(note, Filter(kinds = listOf(kind, 1221)))
db.assertQuery(null, Filter(kinds = listOf(1221)))
}
@Test
fun matchSince() {
db.insert(note)
db.assertQuery(note, Filter(since = createdAt))
db.assertQuery(note, Filter(since = createdAt - 1))
db.assertQuery(null, Filter(since = createdAt + 1))
}
@Test
fun matchUntil() {
db.insert(note)
db.assertQuery(note, Filter(until = createdAt))
db.assertQuery(note, Filter(until = createdAt + 1))
db.assertQuery(null, Filter(until = createdAt - 1))
}
}
@@ -479,4 +479,27 @@ class QueryAssemblerTest {
sql,
)
}
@Test
fun testAllTag() {
val sql = explain(Filter(tagsAll = mapOf("p" to listOf(key1, key2))))
println(sql)
TestCase.assertEquals(
"""
SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers
INNER JOIN (
SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags INNER JOIN event_tags as event_tagsAll0_1 ON event_tagsAll0_1.event_header_row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "884286737453847614") AND (event_tagsAll0_1.tag_hash = "-4988851810256311323")
) AS filtered
ON event_headers.row_id = filtered.row_id
ORDER BY created_at DESC, id
├── CO-ROUTINE filtered
│ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?)
│ └── SEARCH event_tagsAll0_1 USING COVERING INDEX query_by_tags_hash (tag_hash=? AND event_header_row_id=?)
├── SCAN filtered
├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?)
└── USE TEMP B-TREE FOR ORDER BY
""".trimIndent(),
sql,
)
}
}
@@ -436,6 +436,17 @@ class EventIndexesModule(
}
}
sealed class TagNameForQuery {
class InTags(
val tagName: String,
) : TagNameForQuery()
class AllTags(
val tagName: String,
val tagValueIndex: Int,
) : TagNameForQuery()
}
// ----------------------------
// Inner row id selections
// ----------------------------
@@ -447,7 +458,9 @@ class EventIndexesModule(
val mustJoinSearch = (filter.search != null)
val nonDTags = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap()
val nonDTagsAll = filter.tagsAll?.filter { it.key != "d" } ?: emptyMap()
val hasHeaders =
with(filter) {
@@ -460,20 +473,30 @@ class EventIndexesModule(
(limit != null)
}
var defaultTagKey: String? = null
var defaultTagKey: TagNameForQuery? = null
val projection =
buildString {
// always do tags if there are any
if (nonDTags.isNotEmpty()) {
if (nonDTagsIn.isNotEmpty() || nonDTagsAll.isNotEmpty()) {
append("SELECT DISTINCT(event_tags.event_header_row_id) as row_id FROM event_tags ")
// it's quite rare to have 2 tags in the filter, but possible
nonDTags.keys.forEachIndexed { index, tagName ->
if (index > 0) {
nonDTagsIn.keys.forEachIndexed { index, tagName ->
if (defaultTagKey != null) {
append("INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id ")
} else {
defaultTagKey = tagName
defaultTagKey = TagNameForQuery.InTags(tagName)
}
}
nonDTagsAll.keys.forEachIndexed { index, tagName ->
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
if (defaultTagKey != null) {
append("INNER JOIN event_tags as event_tagsAll${index}_$valueIndex ON event_tagsAll${index}_$valueIndex.event_header_row_id = event_tags.event_header_row_id ")
} else {
defaultTagKey = TagNameForQuery.AllTags(tagName, valueIndex)
}
}
}
@@ -506,10 +529,10 @@ class EventIndexesModule(
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) ->
// it's quite rare to have 2 tags in the filter, but possible
nonDTagsIn.keys.forEachIndexed { index, tagName ->
val column =
if (defaultTagKey == null || defaultTagKey == tagName) {
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) {
"event_tags.tag_hash"
} else {
"event_tagsIn$index.tag_hash"
@@ -517,12 +540,26 @@ class EventIndexesModule(
equalsOrIn(
column,
tagValues.map {
nonDTagsIn[tagName]!!.map {
hasher.hash(tagName, it)
},
)
}
// there are indexes for these, starting with tags.
nonDTagsAll.keys.forEachIndexed { index, tagName ->
nonDTagsAll[tagName]!!.forEachIndexed { valueIndex, tagValue ->
val column =
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.AllTags && defaultTagKey.tagName == tagName && defaultTagKey.tagValueIndex == valueIndex)) {
"event_tags.tag_hash"
} else {
"event_tagsAll${index}_$valueIndex.tag_hash"
}
equals(column, hasher.hash(tagName, tagValue))
}
}
filter.kinds?.let { equalsOrIn("event_headers.kind", it) }
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
@@ -48,6 +48,7 @@ class Filter(
val authors: List<String>? = null,
val kinds: List<Int>? = null,
val tags: Map<String, List<String>>? = null,
val tagsAll: Map<String, List<String>>? = null,
val since: Long? = null,
val until: Long? = null,
val limit: Int? = null,
@@ -55,18 +56,19 @@ class Filter(
) : OptimizedSerializable {
fun toJson() = OptimizedJsonMapper.toJson(this)
fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, since, until)
fun match(event: Event) = FilterMatcher.match(event, ids, authors, kinds, tags, tagsAll, since, until)
fun copy(
ids: List<String>? = this.ids,
authors: List<String>? = this.authors,
kinds: List<Int>? = this.kinds,
tags: Map<String, List<String>>? = this.tags,
tagsAll: Map<String, List<String>>? = this.tagsAll,
since: Long? = this.since,
until: Long? = this.until,
limit: Int? = this.limit,
search: String? = this.search,
) = Filter(ids, authors, kinds, tags, since, until, limit, search)
) = Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search)
/**
* Returns true if this filter contains any non-null and non-empty criteria.
@@ -76,6 +78,7 @@ class Filter(
(authors != null && authors.isNotEmpty()) ||
(kinds != null && kinds.isNotEmpty()) ||
(tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) ||
(tagsAll != null && tagsAll.isNotEmpty() && tagsAll.values.all { it.isNotEmpty() }) ||
(since != null) ||
(until != null) ||
(limit != null) ||
@@ -100,6 +103,16 @@ class Filter(
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
}
}
if (tagsAll != null) {
tagsAll["p"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid p-tag length $it on ${toJson()}")
}
tagsAll["e"]?.forEach {
if (it.length != 64) Log.e("FilterError", "Invalid e-tag length $it on ${toJson()}")
}
tagsAll["a"]?.forEach {
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
}
}
}
}
@@ -29,6 +29,7 @@ object FilterMatcher {
authors: List<String>? = null,
kinds: List<Int>? = null,
tags: Map<String, List<String>>? = null,
tagsAll: Map<String, List<String>>? = null,
since: Long? = null,
until: Long? = null,
): Boolean {
@@ -40,6 +41,20 @@ object FilterMatcher {
// AND between keys, OR between values
if (!event.tags.any { it.size > 1 && it[0] == tag.key && it[1] in valueSet }) return false
}
tagsAll?.forEach { tag ->
val eventTagValueSet =
event.tags.mapNotNullTo(mutableSetOf()) {
if (it.size > 1 && it[0] == tag.key) {
it[1]
} else {
null
}
}
// AND between keys, AND between values
for (tagValue in tag.value) {
if (tagValue !in eventTagValueSet) return false
}
}
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
return false
}
@@ -0,0 +1,149 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.relay.filters
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FilterMatcherTest {
val id = "98b574c3527f0ffb30b7271084e3f07480733c7289f8de424d29eae82e36c758"
val pubkey = "46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d"
val createdAt: Long = 1683596206
val kind = 1
val pTag1 = "22aa81510ee63fe2b16cae16e0921f78e9ba9882e2868e7e63ad6d08ae9b5954"
val pTag2 = "3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24"
val pTag3 = "ec4d241c334311b3a304433ee3442be29d0e88e7ec19b85edf2bba29b93565e2"
val pTag4 = "0fe0b18b4dbf0e0aa40fcd47209b2a49b3431fc453b460efcf45ca0bd16bd6ac"
val pTag5 = "8c0da4862130283ff9e67d889df264177a508974e2feb96de139804ea66d6168"
val pTag6 = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"
val pTag7 = "4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"
val pTag8 = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
val rootETag = "27ac621d7dc4a932e1a79f984308e7d20656dd6fddb2ce9cdfcb6a67b9a7bcc3"
val replyETag = "be7245af96210a0dd048cab4ad38e52dbd6c09a53ea21a7edb6be8898e5727cc"
val note =
Event(
id,
pubkey,
createdAt,
kind,
arrayOf(
arrayOf("e", rootETag, "", "root"),
arrayOf("e", replyETag, "", "reply"),
arrayOf("p", pTag1),
arrayOf("p", pTag1),
arrayOf("p", pTag2),
arrayOf("p", pTag3),
arrayOf("p", pTag4),
arrayOf("p", pTag5),
arrayOf("p", pTag6),
arrayOf("p", pTag7),
arrayOf("p", pTag8),
),
"Astral:\n\nhttps://void.cat/d/A5Fba5B1bcxwEmeyoD9nBs.webp\n\nIris:\n\nhttps://void.cat/d/44hTcVvhRps6xYYs99QsqA.webp\n\nSnort:\n\nhttps://void.cat/d/4nJD5TRePuQChM5tzteYbU.webp\n\nAmethyst agrees with Astral which I suspect are both wrong. nostr:npub13sx6fp3pxq5rl70x0kyfmunyzaa9pzt5utltjm0p8xqyafndv95q3saapa nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49 nostr:npub1g53mukxnjkcmr94fhryzkqutdz2ukq4ks0gvy5af25rgmwsl4ngq43drvk nostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z ",
"4aa5264965018fa12a326686ad3d3bd8beae3218dcc83689b19ca1e6baeb791531943c15363aa6707c7c0c8b2d601deca1f20c32078b2872d356cdca03b04cce",
)
@Test
fun matchIds() {
assertTrue(note, Filter(ids = listOf(id)))
assertTrue(note, Filter(ids = listOf(id, rootETag)))
assertFalse(note, Filter(ids = listOf(rootETag)))
}
@Test
fun matchPubkeys() {
assertTrue(note, Filter(authors = listOf(pubkey)))
assertTrue(note, Filter(authors = listOf(pubkey, rootETag)))
assertFalse(note, Filter(authors = listOf(rootETag)))
}
@Test
fun matchTags() {
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(id))))
}
@Test
fun matchDualTags() {
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
assertTrue(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
assertFalse(note, Filter(tags = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchAllTags() {
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3))))
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id))))
}
@Test
fun matchDualAllTags() {
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1), "e" to listOf(rootETag))))
assertTrue(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, pTag2, pTag3), "e" to listOf(rootETag, replyETag))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, replyETag))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(rootETag, pubkey))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(pTag1, id), "e" to listOf(id, pubkey))))
assertFalse(note, Filter(tagsAll = mapOf("p" to listOf(id), "e" to listOf(rootETag))))
}
@Test
fun matchKinds() {
assertTrue(note, Filter(kinds = listOf(kind)))
assertTrue(note, Filter(kinds = listOf(kind, 1221)))
assertFalse(note, Filter(kinds = listOf(1221)))
}
@Test
fun matchSince() {
assertTrue(note, Filter(since = createdAt))
assertTrue(note, Filter(since = createdAt - 1))
assertFalse(note, Filter(since = createdAt + 1))
}
@Test
fun matchUntil() {
assertTrue(note, Filter(until = createdAt))
assertTrue(note, Filter(until = createdAt + 1))
assertFalse(note, Filter(until = createdAt - 1))
}
fun assertTrue(
event: Event,
filter: Filter,
) = assertTrue(filter.match(event))
fun assertFalse(
event: Event,
filter: Filter,
) = assertFalse(filter.match(event))
}
@@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
class ManualFilterDeserializer {
companion object {
fun fromJson(jsonObject: ObjectNode): Filter {
val tags = mutableListOf<String>()
val tagsIn = mutableListOf<String>()
jsonObject.fieldNames().forEach {
if (it.startsWith("#")) {
tags.add(it.substring(1))
tagsIn.add(it.substring(1))
}
}
val tagsAll = mutableListOf<String>()
jsonObject.fieldNames().forEach {
if (it.startsWith("&")) {
tagsAll.add(it.substring(1))
}
}
@@ -49,7 +55,8 @@ class ManualFilterDeserializer {
ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() },
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() },
tags = tags.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
tags = tagsIn.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
tagsAll = tagsAll.associateWith { jsonObject.get(it).mapNotNull { it.asTextOrNull() } },
since = jsonObject.get("since").asLongOrNull(),
until = jsonObject.get("until").asLongOrNull(),
limit = jsonObject.get("limit").asIntOrNull(),
@@ -66,6 +66,16 @@ class FilterSerializer : StdSerializer<Filter>(Filter::class.java) {
}
}
filter.tagsAll?.run {
entries.forEach { kv ->
gen.writeArrayFieldStart("&${kv.key}")
for (i in kv.value.indices) {
gen.writeString(kv.value[i])
}
gen.writeEndArray()
}
}
filter.since?.run { gen.writeNumberField("since", this) }
filter.until?.run { gen.writeNumberField("until", this) }
filter.limit?.run { gen.writeNumberField("limit", this) }