diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt new file mode 100644 index 000000000..22f3b5c17 --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FilterMatcherTest.kt @@ -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() + 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)) + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt index c84fc1848..b972beae0 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -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, + ) + } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index c2b28d739..9fdd08982 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index f4d1f5aad..2d1fb0188 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -48,6 +48,7 @@ class Filter( val authors: List? = null, val kinds: List? = null, val tags: Map>? = null, + val tagsAll: Map>? = 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? = this.ids, authors: List? = this.authors, kinds: List? = this.kinds, tags: Map>? = this.tags, + tagsAll: Map>? = 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()}") + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt index 7c7a579c2..0539a9d76 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcher.kt @@ -29,6 +29,7 @@ object FilterMatcher { authors: List? = null, kinds: List? = null, tags: Map>? = null, + tagsAll: Map>? = 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 } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt new file mode 100644 index 000000000..ea697b2f0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterMatcherTest.kt @@ -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)) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt index 7b7cbeeca..556dda5de 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterDeserializer.kt @@ -38,10 +38,16 @@ class FilterDeserializer : StdDeserializer(Filter::class.java) { class ManualFilterDeserializer { companion object { fun fromJson(jsonObject: ObjectNode): Filter { - val tags = mutableListOf() + val tagsIn = mutableListOf() jsonObject.fieldNames().forEach { if (it.startsWith("#")) { - tags.add(it.substring(1)) + tagsIn.add(it.substring(1)) + } + } + val tagsAll = mutableListOf() + 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(), diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt index afd06b36b..47fdcc674 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/FilterSerializer.kt @@ -66,6 +66,16 @@ class FilterSerializer : StdSerializer(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) }