fix(relay): treat empty filter sets as match-nothing in SQL (#9342)

Fix for a COUNT regression introduced by #9339 (now on main), found in
its review.

With #9339, a filter with a nonempty `#a` takes the exact SQL COUNT
path. But `filter_to_query_params` dropped an empty `authors`, `ids` or
`#e` list and never represented an empty `#p`, `#d` or unpushed tag set.
So `{"kinds":[7], "#a":[A], "authors":[]}` counted every reaction to
`A`, while REQ (which post-filters) correctly returned nothing. Before
#9339 that COUNT returned 0. The same gap already existed for filters
without `#a`.

Any empty `authors`, `ids` or tag set now maps to the existing `kinds:
[]` match-nothing sentinel, which every read (REQ, COUNT, `/query`,
`/count`) answers without a query. An empty string value such as `"#d":
[""]` is still a real constraint.

Why nothing gets worse: only filters that provably match nothing change,
and they now skip the database instead of reading or counting.

### Validation

Added tests:
- Unit table over empty `authors`, `ids`, `#e`, `#p`, `#d`, `#h`, `#k`
and `#t`, each with and without `#a`, plus `"#d": [""]`. It fails with
the sentinel disabled.
- Postgres table: WebSocket and HTTP COUNT are 0, and REQ and `/query`
are empty, for each empty set beside a matching `#a` and for
`{kinds:[7], authors:[]}`. The first commit contains only the tests.

Existing workspace unit tests pass (pre-push). The Postgres lanes run
separately on an isolated cluster.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-12 06:21:21 -08:00
committed by GitHub
parent 0e6037c74d
commit e715fbb362
3 changed files with 121 additions and 1 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1342,6 +1342,13 @@ fn filter_to_query_params(
ks.iter().map(|k| k.as_u16() as i32).collect()
}
});
// An empty `authors`, `ids` or tag set also matches nothing (NIP-01), but
// the conversions below drop or ignore it, so COUNT would count without
// it. Use the same sentinel, so every read answers it without a query.
let matches_nothing = filter.authors.as_ref().is_some_and(|a| a.is_empty())
|| filter.ids.as_ref().is_some_and(|i| i.is_empty())
|| filter.generic_tags.values().any(|values| values.is_empty());
let kinds = if matches_nothing { Some(vec![]) } else { kinds };
let since = filter
.since
@@ -3482,6 +3489,34 @@ mod tests {
);
}
#[test]
fn empty_filter_sets_match_nothing_in_sql() {
let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil());
let addr = format!("30175:{}:persona", "ab".repeat(32));
for key in ["authors", "ids", "#e", "#p", "#d", "#h", "#k", "#t"] {
for with_a in [false, true] {
let mut json = serde_json::json!({"kinds": [7], key: []});
if with_a {
json["#a"] = serde_json::json!([addr]);
}
let filter: Filter = serde_json::from_value(json).unwrap();
assert_eq!(
filter_to_query_params(&filter, None, community).kinds,
Some(vec![]),
"empty {key} (with #a: {with_a}) must match nothing"
);
}
}
let empty_d_value: Filter =
serde_json::from_value(serde_json::json!({"kinds": [30175], "#d": [""]})).unwrap();
assert_eq!(
filter_to_query_params(&empty_d_value, None, community).kinds,
Some(vec![30175]),
"an empty string is a real #d value, not an empty set"
);
}
#[test]
fn test_extract_channel_id_single_channel() {
let channel_id = uuid::Uuid::new_v4();

View File

@@ -158,3 +158,88 @@ async fn address_reaction_reqs_over_several_channels() {
);
assert_eq!(count(&state, &tenant, &member, filter).await, 1);
}
/// POST one filter to the HTTP bridge as `reader` and return the JSON reply.
async fn bridge(
state: &Arc<AppState>,
tenant: &TenantContext,
reader: &Keys,
path: &str,
filter: &serde_json::Value,
) -> serde_json::Value {
use axum::body::Body;
use axum::http::{header, Request};
use tower::ServiceExt;
let request = Request::post(path)
.header(header::HOST, tenant.host())
.header("x-pubkey", reader.public_key().to_hex())
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&[filter]).unwrap()))
.unwrap();
let response = crate::router::build_router(state.clone())
.oneshot(request)
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 1 << 20)
.await
.unwrap();
assert!(status.is_success(), "{path}: {status}");
serde_json::from_slice(&body).expect("bridge JSON")
}
/// An empty `authors`, `ids` or tag set matches nothing, so COUNT is 0 and
/// REQ is empty on WebSocket and HTTP, even beside a matching `#a`.
/// Mutation: drop the empty-set sentinel from `filter_to_query_params` → RED
/// (each COUNT counts the `#a` match, or every kind:7 without `#a`).
#[tokio::test]
#[ignore = "requires Postgres"]
async fn empty_sets_beside_an_address_match_nothing() {
let Some(state) = crate::api::bridge::postgres_tests::bridge_handler_test_state().await else {
panic!("requires local Postgres and Redis");
};
let tenant = community(&state, "a-tag-empty-sets").await;
let reactor = Keys::generate();
let reader = Keys::generate();
let needle = format!("30175:{}:needle", reader.public_key().to_hex());
let event = EventBuilder::new(Kind::Reaction, "+")
.tags([
Tag::parse(["a", &needle]).unwrap(),
Tag::parse(["e", &"cd".repeat(32)]).unwrap(),
Tag::parse(["p", &reader.public_key().to_hex()]).unwrap(),
Tag::parse(["k", "30175"]).unwrap(),
])
.sign_with_keys(&reactor)
.unwrap();
state
.db
.insert_event(tenant.community(), &event, None)
.await
.expect("insert reaction");
let base = serde_json::json!({"kinds": [7], "#a": [needle]});
assert_eq!(count(&state, &tenant, &reader, base.clone()).await, 1);
let mut filters: Vec<_> = ["authors", "ids", "#e", "#p", "#d", "#k", "#t", "#A"]
.into_iter()
.map(|key| {
let mut filter = base.clone();
filter[key] = serde_json::json!([]);
filter
})
.collect();
filters.push(serde_json::json!({"kinds": [7], "authors": []}));
for filter in filters {
let ws_count = count(&state, &tenant, &reader, filter.clone()).await;
let http_count = bridge(&state, &tenant, &reader, "/count", &filter).await["count"].clone();
let ws_req = req_event_ids(&state, &tenant, &reader, filter.clone()).await;
let http_req = bridge(&state, &tenant, &reader, "/query", &filter).await;
assert_eq!(
(ws_count, http_count, ws_req.len()),
(0, serde_json::json!(0), 0),
"{filter}"
);
assert_eq!(http_req, serde_json::json!([]), "{filter} /query");
}
}