From 90742536b4fc0b3f6e1cab0afd044a66f3f07b7c Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Fri, 17 Apr 2026 23:32:35 +0900 Subject: [PATCH 1/6] feat/nip-91-and-operator Add support for the AND operator in REQ filters as proposed in nostr-protocol/nips#2252 (NIP-91 / NIP-119). Filter keys prefixed with "&" require every listed value to be present on a tag of that name, complementing the existing "#"-prefixed OR semantics. --- cagliostr.hxx | 1 + main.cxx | 54 ++++++++++++++++++++++++++++++++++++++++---------- postgresql.cxx | 14 +++++++++++++ sqlite3.cxx | 14 +++++++++++++ test.cxx | 47 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 10 deletions(-) diff --git a/cagliostr.hxx b/cagliostr.hxx index abf51db..30008d2 100644 --- a/cagliostr.hxx +++ b/cagliostr.hxx @@ -27,6 +27,7 @@ using filter_t = struct filter_t { std::vector authors{}; std::vector kinds{}; std::vector> tags{}; + std::vector> and_tags{}; std::time_t since{}; std::time_t until{}; int limit{500}; diff --git a/main.cxx b/main.cxx index 6f3f658..31a1c7e 100644 --- a/main.cxx +++ b/main.cxx @@ -294,17 +294,25 @@ static bool make_filter(filter_t &filter, const nlohmann::json &data) { } } for (auto it = data.cbegin(); it != data.cend(); ++it) { - if (!it.key().empty() && it.key().front() == '#' && it.value().is_array()) { - std::vector tag = {it.key().substr(1)}; - for (const auto &v : it.value()) { - if (!v.is_string()) { - console->warn("make_filter: tag {} elements must be string", - it.key()); - return false; - } - tag.push_back(v.get()); + if (it.key().size() < 2 || !it.value().is_array()) { + continue; + } + const auto prefix = it.key().front(); + if (prefix != '#' && prefix != '&') { + continue; + } + std::vector tag = {it.key().substr(1)}; + for (const auto &v : it.value()) { + if (!v.is_string()) { + console->warn("make_filter: tag {} elements must be string", it.key()); + return false; } - filter.tags.push_back(tag); + tag.push_back(v.get()); + } + if (prefix == '&') { + filter.and_tags.push_back(std::move(tag)); + } else { + filter.tags.push_back(std::move(tag)); } } if (data.count("since") > 0) { @@ -522,6 +530,32 @@ static bool matched_filters(const std::vector &filters, continue; } } + if (!filter.and_tags.empty()) { + auto all_and_tags_matched = true; + for (const auto &filter_tag : filter.and_tags) { + if (filter_tag.size() < 2) + continue; + const auto &key = filter_tag[0]; + for (size_t fi = 1; fi < filter_tag.size(); fi++) { + bool found = false; + for (const auto &tag : ev.tags) { + if (tag.size() >= 2 && tag[0] == key && tag[1] == filter_tag[fi]) { + found = true; + break; + } + } + if (!found) { + all_and_tags_matched = false; + break; + } + } + if (!all_and_tags_matched) + break; + } + if (!all_and_tags_matched) { + continue; + } + } if (!filter.search.empty()) { auto found_search = true; std::string content = ev.content; diff --git a/postgresql.cxx b/postgresql.cxx index d0ffc13..20a38f7 100644 --- a/postgresql.cxx +++ b/postgresql.cxx @@ -231,6 +231,20 @@ static bool send_records(std::function sender, conditions.push_back("(" + join(match, " OR ") + ")"); } } + if (!filter.and_tags.empty()) { + for (const auto &tag : filter.and_tags) { + if (tag.size() < 2) { + continue; + } + const auto &first = tag[0]; + for (decltype(tag.size()) i = 1; i < tag.size(); i++) { + nlohmann::json data = nlohmann::json::array( + {nlohmann::json::array({first, tag[i]})}); + params.append(data.dump()); + conditions.push_back("tags @> $" + std::to_string(++pno) + "::jsonb"); + } + } + } if (filter.since != 0) { std::ostringstream os; os << filter.since; diff --git a/sqlite3.cxx b/sqlite3.cxx index 01ee773..4c52928 100644 --- a/sqlite3.cxx +++ b/sqlite3.cxx @@ -199,6 +199,20 @@ static bool send_records(std::function sender, conditions.push_back("(" + join(match, " OR ") + ")"); } } + if (!filter.and_tags.empty()) { + for (const auto &tag : filter.and_tags) { + if (tag.size() < 2) { + continue; + } + const auto &first = tag[0]; + for (decltype(tag.size()) i = 1; i < tag.size(); i++) { + nlohmann::json data = {first, tag[i]}; + params.push_back({.t = PARAM_TYPE_STRING, + .s = "%" + escape_like(data.dump()) + "%"}); + conditions.push_back(R"(tags LIKE ? ESCAPE '\')"); + } + } + } if (filter.since != 0) { std::ostringstream os; os << filter.since; diff --git a/test.cxx b/test.cxx index a0711f7..1ec4a23 100644 --- a/test.cxx +++ b/test.cxx @@ -247,6 +247,52 @@ static void test_send_records_filters() { storage_ctx.deinit(); } +static void test_send_records_and_tags() { + auto storage_ctx = init_test_storage(); + auto pubkey = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + auto both = make_event("event-and-1", pubkey, 1700001000, 1, + {{"t", "meme"}, {"t", "cat"}}, "meme cat"); + auto only_meme = make_event("event-and-2", pubkey, 1700001001, 1, + {{"t", "meme"}}, "meme only"); + auto only_cat = make_event("event-and-3", pubkey, 1700001002, 1, + {{"t", "cat"}}, "cat only"); + + _ok(storage_ctx.insert_record(both), "and_tags setup insert both"); + _ok(storage_ctx.insert_record(only_meme), "and_tags setup insert only_meme"); + _ok(storage_ctx.insert_record(only_cat), "and_tags setup insert only_cat"); + + std::vector replies; + auto sender = [&replies](const nlohmann::json& reply) { replies.push_back(reply); }; + + filter_t and_filter; + and_filter.and_tags = {{"t", "meme", "cat"}}; + _ok(storage_ctx.send_records(sender, "sub-and", {and_filter}, false), + "send_records succeeds for and_tags filter"); + _ok(replies.size() == 1, "and_tags filter returns only the event with both values"); + _ok(replies[0][2]["id"] == both.id, + "and_tags filter matches the event holding all required values"); + replies.clear(); + + filter_t or_filter; + or_filter.tags = {{"t", "meme", "cat"}}; + _ok(storage_ctx.send_records(sender, "sub-or", {or_filter}, false), + "send_records succeeds for OR tags filter"); + _ok(replies.size() == 3, "OR tags filter returns all events with either value"); + replies.clear(); + + filter_t mixed; + mixed.and_tags = {{"t", "meme", "cat"}}; + mixed.kinds = {1}; + _ok(storage_ctx.send_records(sender, "sub-mixed", {mixed}, false), + "send_records succeeds for and_tags combined with kinds"); + _ok(replies.size() == 1, "and_tags combined with kinds keeps AND semantics"); + _ok(replies[0][2]["id"] == both.id, + "and_tags combined with kinds returns the matching event"); + + storage_ctx.deinit(); +} + static void test_delete_record_by_id_and_kind_and_ptag() { auto storage_ctx = init_test_storage(); auto id = "event-ptag-1"; @@ -404,6 +450,7 @@ int main() { subtest("test_event_json_roundtrip", test_event_json_roundtrip); subtest("test_get_event_by_id", test_get_event_by_id); subtest("test_send_records_filters", test_send_records_filters); + subtest("test_send_records_and_tags", test_send_records_and_tags); subtest("test_delete_record_by_id_and_kind_and_ptag", test_delete_record_by_id_and_kind_and_ptag); subtest("test_delete_all_events_by_pubkey", test_delete_all_events_by_pubkey); From 296199e48436697acf65fc537231b51d247fccc2 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Fri, 17 Apr 2026 23:38:32 +0900 Subject: [PATCH 2/6] add NIP-91 to supported_nips --- main.cxx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.cxx b/main.cxx index 31a1c7e..6c1cc82 100644 --- a/main.cxx +++ b/main.cxx @@ -58,7 +58,7 @@ static auto nip11 = nlohmann::json{ {"contact", "mattn.jp@gmail.com"}, {"supported_nips", nlohmann::json::array({1, 2, 4, 9, 11, 12, 13, 15, 16, 20, 22, 26, 28, 33, - 40, 42, 45, 50, 62, 67, 70})}, + 40, 42, 45, 50, 62, 67, 70, 91})}, {"software", "https://github.com/mattn/cagliostr"}, {"version", VERSION}, {"limitation", nlohmann::json{{"max_message_length", 1024 * 1024 * 5}, From dca1838796351e42cc9903bd63ff8139e00b8ed8 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Fri, 17 Apr 2026 23:42:42 +0900 Subject: [PATCH 3/6] expand AND filter tests with focused subtests Split the single test_send_records_and_tags case into seven targeted subtests covering basic match, empty result, single-value parity with OR, OR-semantics regression, multiple AND tag names, AND combined with OR, and COUNT queries. --- test.cxx | 201 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 166 insertions(+), 35 deletions(-) diff --git a/test.cxx b/test.cxx index 1ec4a23..892a5b3 100644 --- a/test.cxx +++ b/test.cxx @@ -11,6 +11,7 @@ #include #include +#include #include static event_t string2event(const std::string& string) { @@ -247,50 +248,172 @@ static void test_send_records_filters() { storage_ctx.deinit(); } -static void test_send_records_and_tags() { - auto storage_ctx = init_test_storage(); - auto pubkey = +struct and_tags_fixture { + storage_context_t ctx; + event_t both_meme_cat; + event_t only_meme; + event_t only_cat; + event_t meme_dog; + event_t bob_meme_cat; +}; + +static and_tags_fixture make_and_tags_fixture() { + and_tags_fixture f{}; + f.ctx = init_test_storage(); + auto alice = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; - auto both = make_event("event-and-1", pubkey, 1700001000, 1, - {{"t", "meme"}, {"t", "cat"}}, "meme cat"); - auto only_meme = make_event("event-and-2", pubkey, 1700001001, 1, - {{"t", "meme"}}, "meme only"); - auto only_cat = make_event("event-and-3", pubkey, 1700001002, 1, - {{"t", "cat"}}, "cat only"); + auto bob = + "1111111111111111111111111111111111111111111111111111111111111111"; + f.both_meme_cat = make_event( + "event-and-1", alice, 1700001000, 1, + {{"t", "meme"}, {"t", "cat"}, {"p", alice}}, "meme cat by alice"); + f.only_meme = make_event("event-and-2", alice, 1700001001, 1, + {{"t", "meme"}, {"p", alice}}, "meme only"); + f.only_cat = make_event("event-and-3", alice, 1700001002, 1, + {{"t", "cat"}, {"p", alice}}, "cat only"); + f.meme_dog = make_event("event-and-4", alice, 1700001003, 1, + {{"t", "meme"}, {"t", "dog"}, {"p", alice}}, + "meme dog"); + f.bob_meme_cat = make_event( + "event-and-5", bob, 1700001004, 1, + {{"t", "meme"}, {"t", "cat"}, {"p", bob}}, "meme cat by bob"); + _ok(f.ctx.insert_record(f.both_meme_cat), "and_tags fixture insert both_meme_cat"); + _ok(f.ctx.insert_record(f.only_meme), "and_tags fixture insert only_meme"); + _ok(f.ctx.insert_record(f.only_cat), "and_tags fixture insert only_cat"); + _ok(f.ctx.insert_record(f.meme_dog), "and_tags fixture insert meme_dog"); + _ok(f.ctx.insert_record(f.bob_meme_cat), "and_tags fixture insert bob_meme_cat"); + return f; +} - _ok(storage_ctx.insert_record(both), "and_tags setup insert both"); - _ok(storage_ctx.insert_record(only_meme), "and_tags setup insert only_meme"); - _ok(storage_ctx.insert_record(only_cat), "and_tags setup insert only_cat"); +static void test_and_tags_basic_match() { + auto f = make_and_tags_fixture(); + std::vector replies; + auto sender = [&replies](const nlohmann::json& reply) { replies.push_back(reply); }; + + filter_t flt; + flt.and_tags = {{"t", "meme", "cat"}}; + _ok(f.ctx.send_records(sender, "sub-and-basic", {flt}, false), + "send_records succeeds for AND filter"); + _ok(replies.size() == 2, + "AND filter returns only events carrying every requested value"); + std::set ids; + for (const auto &r : replies) ids.insert(r[2]["id"].get()); + _ok(ids.count(f.both_meme_cat.id) == 1, + "AND filter includes alice's meme+cat event"); + _ok(ids.count(f.bob_meme_cat.id) == 1, + "AND filter includes bob's meme+cat event"); + _ok(ids.count(f.only_meme.id) == 0, "AND filter excludes meme-only event"); + _ok(ids.count(f.only_cat.id) == 0, "AND filter excludes cat-only event"); + _ok(ids.count(f.meme_dog.id) == 0, "AND filter excludes meme+dog event"); + + f.ctx.deinit(); +} +static void test_and_tags_no_match() { + auto f = make_and_tags_fixture(); std::vector replies; auto sender = [&replies](const nlohmann::json& reply) { replies.push_back(reply); }; - filter_t and_filter; - and_filter.and_tags = {{"t", "meme", "cat"}}; - _ok(storage_ctx.send_records(sender, "sub-and", {and_filter}, false), - "send_records succeeds for and_tags filter"); - _ok(replies.size() == 1, "and_tags filter returns only the event with both values"); - _ok(replies[0][2]["id"] == both.id, - "and_tags filter matches the event holding all required values"); - replies.clear(); + filter_t flt; + flt.and_tags = {{"t", "meme", "unicorn"}}; + _ok(f.ctx.send_records(sender, "sub-and-empty", {flt}, false), + "send_records succeeds when AND filter matches nothing"); + _ok(replies.empty(), "AND filter with unmet value returns no events"); + + f.ctx.deinit(); +} + +static void test_and_tags_single_value_equivalent_to_or() { + auto f = make_and_tags_fixture(); + std::vector and_replies; + std::vector or_replies; + auto and_sender = [&](const nlohmann::json& r) { and_replies.push_back(r); }; + auto or_sender = [&](const nlohmann::json& r) { or_replies.push_back(r); }; + + filter_t and_flt; + and_flt.and_tags = {{"t", "meme"}}; + filter_t or_flt; + or_flt.tags = {{"t", "meme"}}; + + _ok(f.ctx.send_records(and_sender, "sub-and-single", {and_flt}, false), + "send_records succeeds for single-value AND filter"); + _ok(f.ctx.send_records(or_sender, "sub-or-single", {or_flt}, false), + "send_records succeeds for single-value OR filter"); + _ok(and_replies.size() == or_replies.size(), + "single-value AND matches the same count as single-value OR"); + + f.ctx.deinit(); +} + +static void test_and_tags_or_semantics_unchanged() { + auto f = make_and_tags_fixture(); + std::vector replies; + auto sender = [&](const nlohmann::json& r) { replies.push_back(r); }; - filter_t or_filter; - or_filter.tags = {{"t", "meme", "cat"}}; - _ok(storage_ctx.send_records(sender, "sub-or", {or_filter}, false), + filter_t flt; + flt.tags = {{"t", "meme", "cat"}}; + _ok(f.ctx.send_records(sender, "sub-or-multi", {flt}, false), "send_records succeeds for OR tags filter"); - _ok(replies.size() == 3, "OR tags filter returns all events with either value"); - replies.clear(); + _ok(replies.size() == 5, + "OR filter still returns every event having at least one value"); - filter_t mixed; - mixed.and_tags = {{"t", "meme", "cat"}}; - mixed.kinds = {1}; - _ok(storage_ctx.send_records(sender, "sub-mixed", {mixed}, false), - "send_records succeeds for and_tags combined with kinds"); - _ok(replies.size() == 1, "and_tags combined with kinds keeps AND semantics"); - _ok(replies[0][2]["id"] == both.id, - "and_tags combined with kinds returns the matching event"); + f.ctx.deinit(); +} - storage_ctx.deinit(); +static void test_and_tags_multiple_keys() { + auto f = make_and_tags_fixture(); + auto alice = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + std::vector replies; + auto sender = [&](const nlohmann::json& r) { replies.push_back(r); }; + + filter_t flt; + flt.and_tags = {{"t", "meme", "cat"}, {"p", alice}}; + _ok(f.ctx.send_records(sender, "sub-and-multi-key", {flt}, false), + "send_records succeeds for AND filter with multiple tag names"); + _ok(replies.size() == 1, + "AND across distinct tag names intersects correctly"); + _ok(replies[0][2]["id"] == f.both_meme_cat.id, + "AND across distinct tag names returns the only event matching all"); + + f.ctx.deinit(); +} + +static void test_and_tags_combined_with_or() { + auto f = make_and_tags_fixture(); + auto alice = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + std::vector replies; + auto sender = [&](const nlohmann::json& r) { replies.push_back(r); }; + + filter_t flt; + flt.and_tags = {{"t", "meme", "cat"}}; + flt.tags = {{"p", alice}}; + _ok(f.ctx.send_records(sender, "sub-and-or", {flt}, false), + "send_records succeeds for AND combined with OR filter"); + _ok(replies.size() == 1, + "AND combined with OR narrows to events satisfying both clauses"); + _ok(replies[0][2]["id"] == f.both_meme_cat.id, + "AND+OR returns the alice meme+cat event"); + + f.ctx.deinit(); +} + +static void test_and_tags_count_query() { + auto f = make_and_tags_fixture(); + std::vector replies; + auto sender = [&](const nlohmann::json& r) { replies.push_back(r); }; + + filter_t flt; + flt.and_tags = {{"t", "meme", "cat"}}; + _ok(f.ctx.send_records(sender, "sub-and-count", {flt}, true), + "send_records succeeds for COUNT with AND filter"); + _ok(replies.size() == 1, "AND COUNT returns one COUNT message"); + _ok(replies[0][0] == "COUNT", "AND COUNT message has COUNT verb"); + _ok(replies[0][2]["count"] == 2, + "AND COUNT reflects events satisfying every value"); + + f.ctx.deinit(); } static void test_delete_record_by_id_and_kind_and_ptag() { @@ -450,7 +573,15 @@ int main() { subtest("test_event_json_roundtrip", test_event_json_roundtrip); subtest("test_get_event_by_id", test_get_event_by_id); subtest("test_send_records_filters", test_send_records_filters); - subtest("test_send_records_and_tags", test_send_records_and_tags); + subtest("test_and_tags_basic_match", test_and_tags_basic_match); + subtest("test_and_tags_no_match", test_and_tags_no_match); + subtest("test_and_tags_single_value_equivalent_to_or", + test_and_tags_single_value_equivalent_to_or); + subtest("test_and_tags_or_semantics_unchanged", + test_and_tags_or_semantics_unchanged); + subtest("test_and_tags_multiple_keys", test_and_tags_multiple_keys); + subtest("test_and_tags_combined_with_or", test_and_tags_combined_with_or); + subtest("test_and_tags_count_query", test_and_tags_count_query); subtest("test_delete_record_by_id_and_kind_and_ptag", test_delete_record_by_id_and_kind_and_ptag); subtest("test_delete_all_events_by_pubkey", test_delete_all_events_by_pubkey); From 4f7aaf721ac5d45583e0e60ef8dcb169a11a09e6 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Thu, 7 May 2026 23:28:45 +0900 Subject: [PATCH 4/6] test matched_filters directly via inline header relocation Move matched_filters from main.cxx (static, untestable) into cagliostr.hxx as inline so the test binary can link against it without pulling in main. Add direct tests covering id/author/kind, since/until, OR tags, AND tags (single key, multiple keys, combined with OR, ignoring extra tag elements), search, and multi-filter OR semantics. --- cagliostr.hxx | 116 ++++++++++++++++++++++++++++++++ main.cxx | 113 ------------------------------- test.cxx | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 113 deletions(-) diff --git a/cagliostr.hxx b/cagliostr.hxx index 30008d2..425d8d9 100644 --- a/cagliostr.hxx +++ b/cagliostr.hxx @@ -6,11 +6,14 @@ #include #endif +#include +#include #include #include #include #include #include +#include using event_t = struct event_t { std::string id; @@ -127,6 +130,119 @@ inline bool created_at_within_limits(std::time_t created_at, std::time_t now, return true; } +inline bool matched_filters(const std::vector &filters, + const event_t &ev) { + auto found = false; + for (const auto &filter : filters) { + if (!filter.ids.empty()) { + const auto result = + std::find(filter.ids.begin(), filter.ids.end(), ev.id); + if (result == filter.ids.end()) { + continue; + } + } + if (!filter.authors.empty()) { + const auto result = + std::find(filter.authors.begin(), filter.authors.end(), ev.pubkey); + if (result == filter.authors.end()) { + continue; + } + } + if (!filter.kinds.empty()) { + const auto result = + std::find(filter.kinds.begin(), filter.kinds.end(), ev.kind); + if (result == filter.kinds.end()) { + continue; + } + } + if (filter.since > 0) { + if (filter.since > ev.created_at) { + continue; + } + } + if (filter.until > 0) { + if (ev.created_at > filter.until) { + continue; + } + } + if (!filter.tags.empty()) { + auto all_tags_matched = true; + for (const auto &filter_tag : filter.tags) { + if (filter_tag.size() < 2) + continue; + bool this_tag_matched = false; + for (const auto &tag : ev.tags) { + if (tag.size() < 2) + continue; + if (tag[0] != filter_tag[0]) + continue; + for (size_t fi = 1; fi < filter_tag.size(); fi++) { + if (tag[1] == filter_tag[fi]) { + this_tag_matched = true; + break; + } + } + if (this_tag_matched) + break; + } + if (!this_tag_matched) { + all_tags_matched = false; + break; + } + } + if (!all_tags_matched) { + continue; + } + } + if (!filter.and_tags.empty()) { + auto all_and_tags_matched = true; + for (const auto &filter_tag : filter.and_tags) { + if (filter_tag.size() < 2) + continue; + const auto &key = filter_tag[0]; + for (size_t fi = 1; fi < filter_tag.size(); fi++) { + bool found_value = false; + for (const auto &tag : ev.tags) { + if (tag.size() >= 2 && tag[0] == key && tag[1] == filter_tag[fi]) { + found_value = true; + break; + } + } + if (!found_value) { + all_and_tags_matched = false; + break; + } + } + if (!all_and_tags_matched) + break; + } + if (!all_and_tags_matched) { + continue; + } + } + if (!filter.search.empty()) { + auto found_search = true; + std::string content = ev.content; + std::transform(content.begin(), content.end(), content.begin(), + ::tolower); + std::istringstream iss(filter.search); + std::string word; + while (iss >> word) { + std::transform(word.begin(), word.end(), word.begin(), ::tolower); + if (content.find(word) == std::string::npos) { + found_search = false; + break; + } + } + if (!found_search) { + continue; + } + } + found = true; + } + return found; +} + inline std::string escape_like(const std::string &data) { std::string result; for (const auto c : data) { diff --git a/main.cxx b/main.cxx index 6c1cc82..b404dc9 100644 --- a/main.cxx +++ b/main.cxx @@ -466,119 +466,6 @@ static void do_relay_close(WebSocket *ws, const nlohmann::json &data) { } } -static bool matched_filters(const std::vector &filters, - const event_t &ev) { - auto found = false; - for (const auto &filter : filters) { - if (!filter.ids.empty()) { - const auto result = - std::find(filter.ids.begin(), filter.ids.end(), ev.id); - if (result == filter.ids.end()) { - continue; - } - } - if (!filter.authors.empty()) { - const auto result = - std::find(filter.authors.begin(), filter.authors.end(), ev.pubkey); - if (result == filter.authors.end()) { - continue; - } - } - if (!filter.kinds.empty()) { - const auto result = - std::find(filter.kinds.begin(), filter.kinds.end(), ev.kind); - if (result == filter.kinds.end()) { - continue; - } - } - if (filter.since > 0) { - if (filter.since > ev.created_at) { - continue; - } - } - if (filter.until > 0) { - if (ev.created_at > filter.until) { - continue; - } - } - if (!filter.tags.empty()) { - auto all_tags_matched = true; - for (const auto &filter_tag : filter.tags) { - if (filter_tag.size() < 2) - continue; - bool this_tag_matched = false; - for (const auto &tag : ev.tags) { - if (tag.size() < 2) - continue; - if (tag[0] != filter_tag[0]) - continue; - for (size_t fi = 1; fi < filter_tag.size(); fi++) { - if (tag[1] == filter_tag[fi]) { - this_tag_matched = true; - break; - } - } - if (this_tag_matched) - break; - } - if (!this_tag_matched) { - all_tags_matched = false; - break; - } - } - if (!all_tags_matched) { - continue; - } - } - if (!filter.and_tags.empty()) { - auto all_and_tags_matched = true; - for (const auto &filter_tag : filter.and_tags) { - if (filter_tag.size() < 2) - continue; - const auto &key = filter_tag[0]; - for (size_t fi = 1; fi < filter_tag.size(); fi++) { - bool found = false; - for (const auto &tag : ev.tags) { - if (tag.size() >= 2 && tag[0] == key && tag[1] == filter_tag[fi]) { - found = true; - break; - } - } - if (!found) { - all_and_tags_matched = false; - break; - } - } - if (!all_and_tags_matched) - break; - } - if (!all_and_tags_matched) { - continue; - } - } - if (!filter.search.empty()) { - auto found_search = true; - std::string content = ev.content; - std::transform(content.begin(), content.end(), content.begin(), - ::tolower); - std::istringstream iss(filter.search); - std::string word; - while (iss >> word) { - std::transform(word.begin(), word.end(), word.begin(), ::tolower); - if (content.find(word) == std::string::npos) { - found_search = false; - break; - } - } - if (!found_search) { - continue; - } - } - found = true; - } - return found; -} - static void do_relay_event(WebSocket *ws, const nlohmann::json &data) { try { const event_t ev = data[1]; diff --git a/test.cxx b/test.cxx index 892a5b3..affc304 100644 --- a/test.cxx +++ b/test.cxx @@ -566,6 +566,171 @@ static void test_created_at_within_limits() { "created_at_within_limits rejects beyond the lower limit"); } +static event_t make_match_event() { + auto alice = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + return make_event( + "event-match-1", alice, 1700002000, 1, + {{"t", "meme"}, {"t", "cat"}, {"p", alice}, {"e", "deadbeef"}}, + "Hello Nostr World"); +} + +static void test_matched_filters_empty_filter_matches_any() { + auto ev = make_match_event(); + filter_t flt; + _ok(matched_filters({flt}, ev), + "empty filter matches any event"); +} + +static void test_matched_filters_id_author_kind() { + auto ev = make_match_event(); + filter_t flt; + flt.ids = {ev.id}; + _ok(matched_filters({flt}, ev), "ids matches by event id"); + + flt = {}; + flt.ids = {"nope"}; + _ok(!matched_filters({flt}, ev), "ids excludes when id not listed"); + + flt = {}; + flt.authors = {ev.pubkey}; + _ok(matched_filters({flt}, ev), "authors matches by pubkey"); + + flt = {}; + flt.authors = {"0000000000000000000000000000000000000000000000000000000000000000"}; + _ok(!matched_filters({flt}, ev), "authors excludes other pubkey"); + + flt = {}; + flt.kinds = {1}; + _ok(matched_filters({flt}, ev), "kinds matches by kind"); + + flt = {}; + flt.kinds = {2, 3}; + _ok(!matched_filters({flt}, ev), "kinds excludes when kind not listed"); +} + +static void test_matched_filters_since_until() { + auto ev = make_match_event(); + filter_t flt; + flt.since = ev.created_at - 10; + flt.until = ev.created_at + 10; + _ok(matched_filters({flt}, ev), "since/until brackets matching event"); + + flt = {}; + flt.since = ev.created_at + 1; + _ok(!matched_filters({flt}, ev), "since after created_at excludes event"); + + flt = {}; + flt.until = ev.created_at - 1; + _ok(!matched_filters({flt}, ev), "until before created_at excludes event"); +} + +static void test_matched_filters_or_tags() { + auto ev = make_match_event(); + filter_t flt; + flt.tags = {{"t", "meme"}}; + _ok(matched_filters({flt}, ev), "OR tag matches when value present"); + + flt = {}; + flt.tags = {{"t", "missing", "cat"}}; + _ok(matched_filters({flt}, ev), + "OR tag matches when any of multiple values is present"); + + flt = {}; + flt.tags = {{"t", "missing"}}; + _ok(!matched_filters({flt}, ev), + "OR tag rejects when none of the values is present"); + + flt = {}; + flt.tags = {{"t", "meme"}, {"p", "missing"}}; + _ok(!matched_filters({flt}, ev), + "OR tag clauses are conjoined across distinct keys"); +} + +static void test_matched_filters_and_tags_basic() { + auto ev = make_match_event(); + filter_t flt; + flt.and_tags = {{"t", "meme", "cat"}}; + _ok(matched_filters({flt}, ev), + "AND tag matches event carrying every requested value"); + + flt = {}; + flt.and_tags = {{"t", "meme", "unicorn"}}; + _ok(!matched_filters({flt}, ev), + "AND tag rejects when any requested value is missing"); +} + +static void test_matched_filters_and_tags_multiple_keys() { + auto ev = make_match_event(); + filter_t flt; + flt.and_tags = {{"t", "meme", "cat"}, {"e", "deadbeef"}}; + _ok(matched_filters({flt}, ev), + "AND tag intersects across distinct tag names"); + + flt = {}; + flt.and_tags = {{"t", "meme", "cat"}, {"e", "missing"}}; + _ok(!matched_filters({flt}, ev), + "AND tag rejects when one key clause fails"); +} + +static void test_matched_filters_and_tags_with_or() { + auto ev = make_match_event(); + filter_t flt; + flt.and_tags = {{"t", "meme", "cat"}}; + flt.tags = {{"p", ev.pubkey}}; + _ok(matched_filters({flt}, ev), + "AND combined with OR matches when both clauses pass"); + + flt = {}; + flt.and_tags = {{"t", "meme", "cat"}}; + flt.tags = {{"p", "no-such-pubkey"}}; + _ok(!matched_filters({flt}, ev), + "AND with failing OR clause rejects event"); +} + +static void test_matched_filters_and_tags_ignore_extra_tag_elements() { + auto alice = + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + auto ev = make_event("event-match-extra", alice, 1700002001, 1, + {{"e", "deadbeef", "wss://relay.example", "root"}}, + "with relay hint"); + filter_t flt; + flt.and_tags = {{"e", "deadbeef"}}; + _ok(matched_filters({flt}, ev), + "AND tag matches even when stored tag carries extra elements"); +} + +static void test_matched_filters_search() { + auto ev = make_match_event(); + filter_t flt; + flt.search = "Hello World"; + _ok(matched_filters({flt}, ev), + "search matches when every word appears (case-insensitive)"); + + flt = {}; + flt.search = "hello missing"; + _ok(!matched_filters({flt}, ev), + "search rejects when any word is absent"); + + flt = {}; + flt.search = "anything"; + ev.content = ""; + _ok(matched_filters({flt}, ev), + "search is bypassed when event content is empty"); +} + +static void test_matched_filters_multi_filter_or() { + auto ev = make_match_event(); + filter_t miss; + miss.kinds = {99}; + filter_t hit; + hit.kinds = {1}; + _ok(matched_filters({miss, hit}, ev), + "any matching filter in the list yields true"); + _ok(!matched_filters({miss, miss}, ev), + "no matching filter in the list yields false"); +} + int main() { spdlog::set_level(spdlog::level::off); @@ -590,5 +755,23 @@ int main() { subtest("test_parse_a_coordinate", test_parse_a_coordinate); subtest("test_created_at_within_limits", test_created_at_within_limits); subtest("test_sql_injection_protection", test_sql_injection_protection); + subtest("test_matched_filters_empty_filter_matches_any", + test_matched_filters_empty_filter_matches_any); + subtest("test_matched_filters_id_author_kind", + test_matched_filters_id_author_kind); + subtest("test_matched_filters_since_until", + test_matched_filters_since_until); + subtest("test_matched_filters_or_tags", test_matched_filters_or_tags); + subtest("test_matched_filters_and_tags_basic", + test_matched_filters_and_tags_basic); + subtest("test_matched_filters_and_tags_multiple_keys", + test_matched_filters_and_tags_multiple_keys); + subtest("test_matched_filters_and_tags_with_or", + test_matched_filters_and_tags_with_or); + subtest("test_matched_filters_and_tags_ignore_extra_tag_elements", + test_matched_filters_and_tags_ignore_extra_tag_elements); + subtest("test_matched_filters_search", test_matched_filters_search); + subtest("test_matched_filters_multi_filter_or", + test_matched_filters_multi_filter_or); return done_testing(); } From b6e2afd3f7a3603dc9372e56ab68268cb04e8e11 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Thu, 7 May 2026 23:41:44 +0900 Subject: [PATCH 5/6] expose and enforce NIP-91 limitations in NIP-11 Advertise max_tags_and and max_tags_per_and (both 20) in the NIP-11 limitation block as recommended by the NIP-91 proposal, and reject filters that exceed either limit in make_filter so the advertised values are actually authoritative. --- main.cxx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/main.cxx b/main.cxx index b404dc9..d777430 100644 --- a/main.cxx +++ b/main.cxx @@ -71,7 +71,9 @@ static auto nip11 = nlohmann::json{ {"min_pow_difficulty", 0}, {"auth_required", false}, {"payment_required", false}, - {"restricted_writes", false}}}, + {"restricted_writes", false}, + {"max_tags_and", 20}, + {"max_tags_per_and", 20}}}, {"fees", nlohmann::json::object()}, {"relay_countries", nlohmann::json::array({"JP"})}, {"icon", @@ -310,6 +312,18 @@ static bool make_filter(filter_t &filter, const nlohmann::json &data) { tag.push_back(v.get()); } if (prefix == '&') { + const int max_per = nip11["limitation"]["max_tags_per_and"]; + if (static_cast(tag.size()) - 1 > max_per) { + console->warn("make_filter: AND tag {} exceeds max_tags_per_and ({})", + it.key(), max_per); + return false; + } + const int max_and = nip11["limitation"]["max_tags_and"]; + if (static_cast(filter.and_tags.size()) >= max_and) { + console->warn("make_filter: too many AND tag clauses (max {})", + max_and); + return false; + } filter.and_tags.push_back(std::move(tag)); } else { filter.tags.push_back(std::move(tag)); From 2a60282ec0a1d0c3b5dafbb04efc4ecf8bf72d50 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Thu, 18 Jun 2026 18:36:34 +0900 Subject: [PATCH 6/6] test: follow main's send_records signature and empty-content search Rebase integration fixups: - pass the new has_more argument (nullptr) to send_records in the AND-filter tests, matching the NIP-67 EOSE hint signature change - empty content now fails search instead of bypassing it, per the upstream fix dropping the !ev.content.empty() guard --- test.cxx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/test.cxx b/test.cxx index affc304..aedb6ed 100644 --- a/test.cxx +++ b/test.cxx @@ -292,7 +292,7 @@ static void test_and_tags_basic_match() { filter_t flt; flt.and_tags = {{"t", "meme", "cat"}}; - _ok(f.ctx.send_records(sender, "sub-and-basic", {flt}, false), + _ok(f.ctx.send_records(sender, "sub-and-basic", {flt}, false, nullptr), "send_records succeeds for AND filter"); _ok(replies.size() == 2, "AND filter returns only events carrying every requested value"); @@ -316,7 +316,7 @@ static void test_and_tags_no_match() { filter_t flt; flt.and_tags = {{"t", "meme", "unicorn"}}; - _ok(f.ctx.send_records(sender, "sub-and-empty", {flt}, false), + _ok(f.ctx.send_records(sender, "sub-and-empty", {flt}, false, nullptr), "send_records succeeds when AND filter matches nothing"); _ok(replies.empty(), "AND filter with unmet value returns no events"); @@ -335,9 +335,9 @@ static void test_and_tags_single_value_equivalent_to_or() { filter_t or_flt; or_flt.tags = {{"t", "meme"}}; - _ok(f.ctx.send_records(and_sender, "sub-and-single", {and_flt}, false), + _ok(f.ctx.send_records(and_sender, "sub-and-single", {and_flt}, false, nullptr), "send_records succeeds for single-value AND filter"); - _ok(f.ctx.send_records(or_sender, "sub-or-single", {or_flt}, false), + _ok(f.ctx.send_records(or_sender, "sub-or-single", {or_flt}, false, nullptr), "send_records succeeds for single-value OR filter"); _ok(and_replies.size() == or_replies.size(), "single-value AND matches the same count as single-value OR"); @@ -352,7 +352,7 @@ static void test_and_tags_or_semantics_unchanged() { filter_t flt; flt.tags = {{"t", "meme", "cat"}}; - _ok(f.ctx.send_records(sender, "sub-or-multi", {flt}, false), + _ok(f.ctx.send_records(sender, "sub-or-multi", {flt}, false, nullptr), "send_records succeeds for OR tags filter"); _ok(replies.size() == 5, "OR filter still returns every event having at least one value"); @@ -369,7 +369,7 @@ static void test_and_tags_multiple_keys() { filter_t flt; flt.and_tags = {{"t", "meme", "cat"}, {"p", alice}}; - _ok(f.ctx.send_records(sender, "sub-and-multi-key", {flt}, false), + _ok(f.ctx.send_records(sender, "sub-and-multi-key", {flt}, false, nullptr), "send_records succeeds for AND filter with multiple tag names"); _ok(replies.size() == 1, "AND across distinct tag names intersects correctly"); @@ -389,7 +389,7 @@ static void test_and_tags_combined_with_or() { filter_t flt; flt.and_tags = {{"t", "meme", "cat"}}; flt.tags = {{"p", alice}}; - _ok(f.ctx.send_records(sender, "sub-and-or", {flt}, false), + _ok(f.ctx.send_records(sender, "sub-and-or", {flt}, false, nullptr), "send_records succeeds for AND combined with OR filter"); _ok(replies.size() == 1, "AND combined with OR narrows to events satisfying both clauses"); @@ -406,7 +406,7 @@ static void test_and_tags_count_query() { filter_t flt; flt.and_tags = {{"t", "meme", "cat"}}; - _ok(f.ctx.send_records(sender, "sub-and-count", {flt}, true), + _ok(f.ctx.send_records(sender, "sub-and-count", {flt}, true, nullptr), "send_records succeeds for COUNT with AND filter"); _ok(replies.size() == 1, "AND COUNT returns one COUNT message"); _ok(replies[0][0] == "COUNT", "AND COUNT message has COUNT verb"); @@ -715,8 +715,8 @@ static void test_matched_filters_search() { flt = {}; flt.search = "anything"; ev.content = ""; - _ok(matched_filters({flt}, ev), - "search is bypassed when event content is empty"); + _ok(!matched_filters({flt}, ev), + "search fails when event content is empty"); } static void test_matched_filters_multi_filter_or() {