Implements AND queries for tags: https://github.com/nostr-protocol/nips/pull/1365
This commit is contained in:
+173
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -479,4 +479,27 @@ class QueryAssemblerTest {
|
|||||||
sql,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-10
@@ -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
|
// Inner row id selections
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
@@ -447,7 +458,9 @@ class EventIndexesModule(
|
|||||||
|
|
||||||
val mustJoinSearch = (filter.search != null)
|
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 =
|
val hasHeaders =
|
||||||
with(filter) {
|
with(filter) {
|
||||||
@@ -460,20 +473,30 @@ class EventIndexesModule(
|
|||||||
(limit != null)
|
(limit != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultTagKey: String? = null
|
var defaultTagKey: TagNameForQuery? = null
|
||||||
|
|
||||||
val projection =
|
val projection =
|
||||||
buildString {
|
buildString {
|
||||||
// always do tags if there are any
|
// 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 ")
|
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
|
// it's quite rare to have 2 tags in the filter, but possible
|
||||||
nonDTags.keys.forEachIndexed { index, tagName ->
|
nonDTagsIn.keys.forEachIndexed { index, tagName ->
|
||||||
if (index > 0) {
|
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 ")
|
append("INNER JOIN event_tags as event_tagsIn$index ON event_tagsIn$index.event_header_row_id = event_tags.event_header_row_id ")
|
||||||
} else {
|
} 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.since?.let { greaterThanOrEquals("event_headers.created_at", it) }
|
||||||
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
|
filter.until?.let { lessThanOrEquals("event_headers.created_at", it) }
|
||||||
|
|
||||||
// there are indexes for these, starting with tags.
|
// it's quite rare to have 2 tags in the filter, but possible
|
||||||
nonDTags.forEach { (tagName, tagValues) ->
|
nonDTagsIn.keys.forEachIndexed { index, tagName ->
|
||||||
val column =
|
val column =
|
||||||
if (defaultTagKey == null || defaultTagKey == tagName) {
|
if (defaultTagKey == null || (defaultTagKey is TagNameForQuery.InTags && defaultTagKey.tagName == tagName)) {
|
||||||
"event_tags.tag_hash"
|
"event_tags.tag_hash"
|
||||||
} else {
|
} else {
|
||||||
"event_tagsIn$index.tag_hash"
|
"event_tagsIn$index.tag_hash"
|
||||||
@@ -517,12 +540,26 @@ class EventIndexesModule(
|
|||||||
|
|
||||||
equalsOrIn(
|
equalsOrIn(
|
||||||
column,
|
column,
|
||||||
tagValues.map {
|
nonDTagsIn[tagName]!!.map {
|
||||||
hasher.hash(tagName, it)
|
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.kinds?.let { equalsOrIn("event_headers.kind", it) }
|
||||||
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
|
filter.authors?.let { equalsOrIn("event_headers.pubkey", it) }
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -48,6 +48,7 @@ class Filter(
|
|||||||
val authors: List<String>? = null,
|
val authors: List<String>? = null,
|
||||||
val kinds: List<Int>? = null,
|
val kinds: List<Int>? = null,
|
||||||
val tags: Map<String, List<String>>? = null,
|
val tags: Map<String, List<String>>? = null,
|
||||||
|
val tagsAll: Map<String, List<String>>? = null,
|
||||||
val since: Long? = null,
|
val since: Long? = null,
|
||||||
val until: Long? = null,
|
val until: Long? = null,
|
||||||
val limit: Int? = null,
|
val limit: Int? = null,
|
||||||
@@ -55,18 +56,19 @@ class Filter(
|
|||||||
) : OptimizedSerializable {
|
) : OptimizedSerializable {
|
||||||
fun toJson() = OptimizedJsonMapper.toJson(this)
|
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(
|
fun copy(
|
||||||
ids: List<String>? = this.ids,
|
ids: List<String>? = this.ids,
|
||||||
authors: List<String>? = this.authors,
|
authors: List<String>? = this.authors,
|
||||||
kinds: List<Int>? = this.kinds,
|
kinds: List<Int>? = this.kinds,
|
||||||
tags: Map<String, List<String>>? = this.tags,
|
tags: Map<String, List<String>>? = this.tags,
|
||||||
|
tagsAll: Map<String, List<String>>? = this.tagsAll,
|
||||||
since: Long? = this.since,
|
since: Long? = this.since,
|
||||||
until: Long? = this.until,
|
until: Long? = this.until,
|
||||||
limit: Int? = this.limit,
|
limit: Int? = this.limit,
|
||||||
search: String? = this.search,
|
search: String? = this.search,
|
||||||
) = Filter(ids, authors, kinds, tags, since, until, limit, search)
|
) = Filter(ids, authors, kinds, tags, tagsAll, since, until, limit, search)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if this filter contains any non-null and non-empty criteria.
|
* Returns true if this filter contains any non-null and non-empty criteria.
|
||||||
@@ -76,6 +78,7 @@ class Filter(
|
|||||||
(authors != null && authors.isNotEmpty()) ||
|
(authors != null && authors.isNotEmpty()) ||
|
||||||
(kinds != null && kinds.isNotEmpty()) ||
|
(kinds != null && kinds.isNotEmpty()) ||
|
||||||
(tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) ||
|
(tags != null && tags.isNotEmpty() && tags.values.all { it.isNotEmpty() }) ||
|
||||||
|
(tagsAll != null && tagsAll.isNotEmpty() && tagsAll.values.all { it.isNotEmpty() }) ||
|
||||||
(since != null) ||
|
(since != null) ||
|
||||||
(until != null) ||
|
(until != null) ||
|
||||||
(limit != null) ||
|
(limit != null) ||
|
||||||
@@ -100,6 +103,16 @@ class Filter(
|
|||||||
if (Address.parse(it) == null) Log.e("FilterError", "Invalid a-tag $it on ${toJson()}")
|
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()}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
@@ -29,6 +29,7 @@ object FilterMatcher {
|
|||||||
authors: List<String>? = null,
|
authors: List<String>? = null,
|
||||||
kinds: List<Int>? = null,
|
kinds: List<Int>? = null,
|
||||||
tags: Map<String, List<String>>? = null,
|
tags: Map<String, List<String>>? = null,
|
||||||
|
tagsAll: Map<String, List<String>>? = null,
|
||||||
since: Long? = null,
|
since: Long? = null,
|
||||||
until: Long? = null,
|
until: Long? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
@@ -40,6 +41,20 @@ object FilterMatcher {
|
|||||||
// AND between keys, OR between values
|
// AND between keys, OR between values
|
||||||
if (!event.tags.any { it.size > 1 && it[0] == tag.key && it[1] in valueSet }) return false
|
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)) {
|
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
+149
@@ -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))
|
||||||
|
}
|
||||||
+10
-3
@@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
|
|||||||
class ManualFilterDeserializer {
|
class ManualFilterDeserializer {
|
||||||
companion object {
|
companion object {
|
||||||
fun fromJson(jsonObject: ObjectNode): Filter {
|
fun fromJson(jsonObject: ObjectNode): Filter {
|
||||||
val tags = mutableListOf<String>()
|
val tagsIn = mutableListOf<String>()
|
||||||
jsonObject.fieldNames().forEach {
|
jsonObject.fieldNames().forEach {
|
||||||
if (it.startsWith("#")) {
|
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() },
|
ids = jsonObject.get("ids").mapNotNull { it.asTextOrNull() },
|
||||||
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
|
authors = jsonObject.get("authors").mapNotNull { it.asTextOrNull() },
|
||||||
kinds = jsonObject.get("kinds").mapNotNull { it.asIntOrNull() },
|
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(),
|
since = jsonObject.get("since").asLongOrNull(),
|
||||||
until = jsonObject.get("until").asLongOrNull(),
|
until = jsonObject.get("until").asLongOrNull(),
|
||||||
limit = jsonObject.get("limit").asIntOrNull(),
|
limit = jsonObject.get("limit").asIntOrNull(),
|
||||||
|
|||||||
+10
@@ -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.since?.run { gen.writeNumberField("since", this) }
|
||||||
filter.until?.run { gen.writeNumberField("until", this) }
|
filter.until?.run { gen.writeNumberField("until", this) }
|
||||||
filter.limit?.run { gen.writeNumberField("limit", this) }
|
filter.limit?.run { gen.writeNumberField("limit", this) }
|
||||||
|
|||||||
Reference in New Issue
Block a user