diff --git a/cagliostr.hxx b/cagliostr.hxx index abf51db..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; @@ -27,6 +30,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}; @@ -126,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 6f3f658..d777430 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}, @@ -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", @@ -294,17 +296,37 @@ 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 == '&') { + 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)); } } if (data.count("since") > 0) { @@ -458,93 +480,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.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/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..aedb6ed 100644 --- a/test.cxx +++ b/test.cxx @@ -11,6 +11,7 @@ #include #include +#include #include static event_t string2event(const std::string& string) { @@ -247,6 +248,174 @@ static void test_send_records_filters() { storage_ctx.deinit(); } +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 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; +} + +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, nullptr), + "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 flt; + flt.and_tags = {{"t", "meme", "unicorn"}}; + _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"); + + 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, nullptr), + "send_records succeeds for single-value AND filter"); + _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"); + + 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 flt; + flt.tags = {{"t", "meme", "cat"}}; + _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"); + + f.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, nullptr), + "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, nullptr), + "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, 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"); + _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() { auto storage_ctx = init_test_storage(); auto id = "event-ptag-1"; @@ -397,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 fails 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); @@ -404,6 +738,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_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); @@ -412,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(); }