mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user