Add nip 91 tags
This commit is contained in:
+102
-56
@@ -51,10 +51,9 @@ tell "don't care" from "impossible."
|
||||
checks regardless of the key type, and deterministic iteration order for
|
||||
serialization and hashing.
|
||||
|
||||
The `tags` field is a `BTreeMap` from tag name to a set of acceptable
|
||||
values. Tag names are arbitrary strings — not restricted to single
|
||||
letters. Single-letter indexing is a relay optimization, not a protocol
|
||||
constraint, and the filter type should not encode relay policy.
|
||||
The `tags` field is a `BTreeMap` from a tag key to a set of values. The
|
||||
key is the tag name, prefixed either by `#` (any value matches, defined
|
||||
in NIP-01) or `&` (all values match, defined in NIP-91).
|
||||
|
||||
```rust {file=coracle-lib/src/filters.rs}
|
||||
/// A predicate over nostr events.
|
||||
@@ -73,8 +72,7 @@ pub struct Filter {
|
||||
pub authors: Option<BTreeSet<PublicKey>>,
|
||||
/// Event kinds to match.
|
||||
pub kinds: Option<BTreeSet<u16>>,
|
||||
/// Tag filters: for each entry, the event must have at least one tag
|
||||
/// with that name whose value appears in the set.
|
||||
/// Tag filters, keyed by prefixed tag name.
|
||||
pub tags: BTreeMap<String, BTreeSet<String>>,
|
||||
/// Lower bound on `created_at` (inclusive).
|
||||
pub since: Option<u64>,
|
||||
@@ -106,11 +104,8 @@ fails. Scalar checks — ids, kinds, authors — come first because they
|
||||
are a simple set-membership test and reject most non-matching events
|
||||
immediately, before the more involved tag iteration.
|
||||
|
||||
Tag matching checks every entry in the filter's `tags` map. For each
|
||||
tag name, the event must contain at least one tag with that name whose
|
||||
value appears in the filter's set. Within a single tag name the values
|
||||
are disjunctive (OR): any match suffices. Across tag names the
|
||||
constraints are conjunctive (AND): all must be satisfied.
|
||||
Tag matching checks every entry in the filter's `tags` map, and the key's
|
||||
leading character determines the semantics.
|
||||
|
||||
```rust {file=coracle-lib/src/filters.rs}
|
||||
impl Filter {
|
||||
@@ -137,11 +132,22 @@ impl Filter {
|
||||
}
|
||||
}
|
||||
|
||||
for (name, values) in &self.tags {
|
||||
let has_match = event
|
||||
.tags
|
||||
.find_all(name)
|
||||
.any(|tag| values.contains(tag.value()));
|
||||
for (key, values) in &self.tags {
|
||||
let name = &key[1..];
|
||||
let has_match = match key.chars().next() {
|
||||
Some('#') => event
|
||||
.tags
|
||||
.find_all(name)
|
||||
.any(|tag| values.contains(tag.value())),
|
||||
Some('&') => values.iter().all(|value| {
|
||||
event
|
||||
.tags
|
||||
.find_all(name)
|
||||
.any(|tag| tag.value() == value.as_str())
|
||||
}),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !has_match {
|
||||
return false;
|
||||
}
|
||||
@@ -186,7 +192,36 @@ returning `self` so calls can be chained. Matching `remove_*` methods
|
||||
take values back out, and `clear_*` methods reset a field to `None` —
|
||||
removing the constraint entirely.
|
||||
|
||||
Tag filters carry a one-character wire prefix — `#` for NIP-01, `&`
|
||||
for NIP-91. Rather than ask callers to spell that prefix into the key
|
||||
string, where a typo (`"t"` instead of `"#t"`) would silently produce a
|
||||
constraint that never matches, the tag methods take a [`TagMatch`] and a
|
||||
bare name.
|
||||
|
||||
```rust {file=coracle-lib/src/filters.rs}
|
||||
/// How the values of a tag constraint combine when matching events.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TagMatch {
|
||||
/// NIP-01 (`#`): the event matches if it carries the tag with *any* of
|
||||
/// the listed values.
|
||||
Any,
|
||||
/// NIP-91 (`&`): the event must carry the tag for *every* listed value.
|
||||
All,
|
||||
}
|
||||
|
||||
impl TagMatch {
|
||||
/// Build the prefixed map key for a bare tag name. This is the only
|
||||
/// place the wire prefix is produced, so a key can never be anything
|
||||
/// other than `#`- or `&`-prefixed.
|
||||
fn key(self, name: &str) -> String {
|
||||
let prefix = match self {
|
||||
TagMatch::Any => '#',
|
||||
TagMatch::All => '&',
|
||||
};
|
||||
format!("{prefix}{name}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Create an empty filter that matches every event.
|
||||
pub fn new() -> Self {
|
||||
@@ -286,11 +321,10 @@ impl Filter {
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a value to a tag filter: the event must have at least one tag
|
||||
/// with this name whose value appears in the set.
|
||||
pub fn add_tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
/// Add a value to a tag filter.
|
||||
pub fn add_tag(mut self, mode: TagMatch, name: &str, value: impl Into<String>) -> Self {
|
||||
self.tags
|
||||
.entry(name.into())
|
||||
.entry(mode.key(name))
|
||||
.or_default()
|
||||
.insert(value.into());
|
||||
self
|
||||
@@ -299,31 +333,32 @@ impl Filter {
|
||||
/// Add multiple values to a tag filter.
|
||||
pub fn add_tags(
|
||||
mut self,
|
||||
name: impl Into<String>,
|
||||
mode: TagMatch,
|
||||
name: &str,
|
||||
values: impl IntoIterator<Item = impl Into<String>>,
|
||||
) -> Self {
|
||||
self.tags
|
||||
.entry(name.into())
|
||||
.entry(mode.key(name))
|
||||
.or_default()
|
||||
.extend(values.into_iter().map(Into::into));
|
||||
self
|
||||
}
|
||||
|
||||
/// Remove a value from a tag filter. If the value set becomes empty,
|
||||
/// the tag entry is removed from the map.
|
||||
pub fn remove_tag(mut self, name: &str, value: &str) -> Self {
|
||||
if let Some(values) = self.tags.get_mut(name) {
|
||||
/// Remove a value from a tag filter.
|
||||
pub fn remove_tag(mut self, mode: TagMatch, name: &str, value: &str) -> Self {
|
||||
let key = mode.key(name);
|
||||
if let Some(values) = self.tags.get_mut(&key) {
|
||||
values.remove(value);
|
||||
if values.is_empty() {
|
||||
self.tags.remove(name);
|
||||
self.tags.remove(&key);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Remove an entire tag filter by name.
|
||||
pub fn clear_tag(mut self, name: &str) -> Self {
|
||||
self.tags.remove(name);
|
||||
/// Remove an entire tag filter by its mode and name.
|
||||
pub fn clear_tag(mut self, mode: TagMatch, name: &str) -> Self {
|
||||
self.tags.remove(&mode.key(name));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -382,14 +417,14 @@ translates these into the corresponding filter fields.
|
||||
impl Filter {
|
||||
/// Add constraints that match events at the given address.
|
||||
///
|
||||
/// Sets the kind, author, and `d` tag filter from the address's
|
||||
/// Sets the kind, author, and `#d` tag filter from the address's
|
||||
/// components. If the identifier is empty (plain replaceable events),
|
||||
/// the `d` tag filter is still set — the event must have a `d` tag
|
||||
/// the `#d` tag filter is still set — the event must have a `d` tag
|
||||
/// with an empty value.
|
||||
pub fn add_address(self, addr: &Address) -> Self {
|
||||
self.add_kind(addr.kind)
|
||||
.add_author(addr.pubkey)
|
||||
.add_tag("d", &addr.identifier)
|
||||
.add_tag(TagMatch::Any, "d", &addr.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,13 +444,13 @@ use coracle_lib::addresses::Address;
|
||||
let addr: Address = "30023:ab12...cd34:my-article".parse().unwrap();
|
||||
let filter = Filter::new().add_address(&addr);
|
||||
// Equivalent to:
|
||||
// Filter::new().add_kind(30023).add_author(pubkey).add_tag("d", "my-article")
|
||||
// Filter::new().add_kind(30023).add_author(pubkey).add_tag(TagMatch::Any, "d", "my-article")
|
||||
```
|
||||
|
||||
## Serialization
|
||||
|
||||
The NIP-01 wire format for a filter is a flat JSON object where tag
|
||||
filters appear as keys prefixed with `#`:
|
||||
filters appear as keys prefixed with `#` or `&`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -427,9 +462,10 @@ filters appear as keys prefixed with `#`:
|
||||
}
|
||||
```
|
||||
|
||||
The `tags` map in our struct needs to be flattened into the top-level
|
||||
object during serialization and reconstituted during deserialization.
|
||||
This rules out `#[derive(Serialize, Deserialize)]` — we need a hand-
|
||||
The `tags` map already holds prefixed keys, so flattening is direct: each
|
||||
key becomes a top-level field verbatim, and reconstituting it on the way
|
||||
back is just as simple. Still, interleaving tag keys with the fixed
|
||||
fields rules out `#[derive(Serialize, Deserialize)]` — we need a hand-
|
||||
written implementation.
|
||||
|
||||
```rust {file=coracle-lib/src/filters.rs}
|
||||
@@ -474,10 +510,9 @@ impl Serialize for Filter {
|
||||
map.serialize_entry("kinds", &kinds_vec)?;
|
||||
}
|
||||
|
||||
for (name, values) in &self.tags {
|
||||
let key = format!("#{name}");
|
||||
for (key, values) in &self.tags {
|
||||
let vals: Vec<&str> = values.iter().map(String::as_str).collect();
|
||||
map.serialize_entry(&key, &vals)?;
|
||||
map.serialize_entry(key, &vals)?;
|
||||
}
|
||||
|
||||
if let Some(since) = self.since {
|
||||
@@ -499,8 +534,8 @@ impl Serialize for Filter {
|
||||
```
|
||||
|
||||
Deserialization collects known keys into their fields and routes any key
|
||||
starting with `#` into the `tags` map. Unknown keys are silently
|
||||
ignored for forward compatibility.
|
||||
starting with `#` or `&` into the `tags` map, prefix and all. Unknown
|
||||
keys are silently ignored for forward compatibility.
|
||||
|
||||
```rust {file=coracle-lib/src/filters.rs}
|
||||
impl<'de> Deserialize<'de> for Filter {
|
||||
@@ -561,10 +596,9 @@ impl<'de> Visitor<'de> for FilterVisitor {
|
||||
"until" => until = Some(map.next_value()?),
|
||||
"limit" => limit = Some(map.next_value()?),
|
||||
"search" => search = Some(map.next_value()?),
|
||||
other if other.starts_with('#') => {
|
||||
let tag_name = other[1..].to_string();
|
||||
other if other.starts_with('#') || other.starts_with('&') => {
|
||||
let values: Vec<String> = map.next_value()?;
|
||||
tags.insert(tag_name, values.into_iter().collect());
|
||||
tags.insert(other.to_string(), values.into_iter().collect());
|
||||
}
|
||||
_ => {
|
||||
let _: de::IgnoredAny = map.next_value()?;
|
||||
@@ -589,11 +623,12 @@ impl<'de> Visitor<'de> for FilterVisitor {
|
||||
A round-trip through JSON preserves all fields:
|
||||
|
||||
```rust
|
||||
use coracle_lib::filters::Filter;
|
||||
use coracle_lib::filters::{Filter, TagMatch};
|
||||
|
||||
let filter = Filter::new()
|
||||
.add_kind(1)
|
||||
.add_tag("t", "nostr")
|
||||
.add_tag(TagMatch::Any, "t", "nostr")
|
||||
.add_tag(TagMatch::All, "t", "rust")
|
||||
.add_since(1_700_000_000)
|
||||
.add_limit(10);
|
||||
let json = serde_json::to_string(&filter).unwrap();
|
||||
@@ -648,8 +683,10 @@ impl Filter {
|
||||
/// fields (`ids`, `authors`, `kinds`, tag values). The group key
|
||||
/// captures:
|
||||
///
|
||||
/// - Which set fields are present and which tag names appear
|
||||
/// - Which set fields are present and which tag keys appear
|
||||
/// (structural shape)
|
||||
/// - For `&` (AND) tag keys, the exact values too — they cannot be
|
||||
/// unioned, so different ones cannot be combined
|
||||
/// - The exact `since` and `until` values (different time windows
|
||||
/// cannot be combined)
|
||||
/// - The exact `search` query (different searches cannot be combined)
|
||||
@@ -662,8 +699,13 @@ impl Filter {
|
||||
self.ids.is_some().hash(&mut hasher);
|
||||
self.authors.is_some().hash(&mut hasher);
|
||||
self.kinds.is_some().hash(&mut hasher);
|
||||
for name in self.tags.keys() {
|
||||
name.hash(&mut hasher);
|
||||
for (key, values) in &self.tags {
|
||||
key.hash(&mut hasher);
|
||||
if key.starts_with('&') {
|
||||
for value in values {
|
||||
value.hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.since.hash(&mut hasher);
|
||||
@@ -691,7 +733,11 @@ filters with different `since` or `until` values land in different
|
||||
groups, because a union of their sets under one time window would either
|
||||
over-fetch or under-fetch relative to what was requested. The `search`
|
||||
query is treated the same way: two filters with different searches can
|
||||
never be merged, so each distinct search forms its own group.
|
||||
never be merged, so each distinct search forms its own group. AND tag
|
||||
keys go a step further: because `&t: ["meme"]` and `&t: ["cat"]` can
|
||||
never be unioned without demanding both at once, a `&` key folds its
|
||||
values into the group identity too, so each distinct AND constraint
|
||||
lands in its own group.
|
||||
|
||||
## Union and intersection
|
||||
|
||||
@@ -720,10 +766,10 @@ pub fn union_filters(filters: &[Filter]) -> Vec<Filter> {
|
||||
merge_sets(&mut existing.ids, &filter.ids);
|
||||
merge_sets(&mut existing.authors, &filter.authors);
|
||||
merge_sets(&mut existing.kinds, &filter.kinds);
|
||||
for (name, values) in &filter.tags {
|
||||
for (key, values) in &filter.tags {
|
||||
existing
|
||||
.tags
|
||||
.entry(name.clone())
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.extend(values.iter().cloned());
|
||||
}
|
||||
@@ -812,9 +858,9 @@ fn combine_pair(a: &Filter, b: &Filter) -> Option<Filter> {
|
||||
f.authors = union_option_sets(&a.authors, &b.authors);
|
||||
f.kinds = union_option_sets(&a.kinds, &b.kinds);
|
||||
|
||||
for (name, values) in a.tags.iter().chain(b.tags.iter()) {
|
||||
for (key, values) in a.tags.iter().chain(b.tags.iter()) {
|
||||
f.tags
|
||||
.entry(name.clone())
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.extend(values.iter().cloned());
|
||||
}
|
||||
|
||||
+145
-40
@@ -1,7 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use coracle_lib::events::{Event, EventContent};
|
||||
use coracle_lib::filters::{intersect_filters, matches_any, union_filters, Filter};
|
||||
use coracle_lib::filters::{intersect_filters, matches_any, union_filters, Filter, TagMatch};
|
||||
use coracle_lib::keys::SecretKey;
|
||||
use coracle_lib::tags::Tag;
|
||||
|
||||
@@ -76,15 +76,15 @@ fn filter_matches_by_tag() {
|
||||
1_700_000_000,
|
||||
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc123"])],
|
||||
);
|
||||
assert!(Filter::new().add_tag("t", "nostr").matches(&event));
|
||||
assert!(!Filter::new().add_tag("e", "something").matches(&event));
|
||||
assert!(!Filter::new().add_tag("t", "bitcoin").matches(&event));
|
||||
assert!(Filter::new().add_tag(TagMatch::Any, "t", "nostr").matches(&event));
|
||||
assert!(!Filter::new().add_tag(TagMatch::Any, "e", "something").matches(&event));
|
||||
assert!(!Filter::new().add_tag(TagMatch::Any, "t", "bitcoin").matches(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tag_or_within_same_name() {
|
||||
let event = make_event(1, 1_700_000_000, vec![Tag::new("t", ["nostr"])]);
|
||||
let filter = Filter::new().add_tags("t", ["nostr", "bitcoin"]);
|
||||
let filter = Filter::new().add_tags(TagMatch::Any, "t", ["nostr", "bitcoin"]);
|
||||
assert!(filter.matches(&event));
|
||||
}
|
||||
|
||||
@@ -95,13 +95,55 @@ fn filter_tag_and_across_names() {
|
||||
1_700_000_000,
|
||||
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc"])],
|
||||
);
|
||||
let filter = Filter::new().add_tag("t", "nostr").add_tag("p", "abc");
|
||||
let filter = Filter::new().add_tag(TagMatch::Any, "t", "nostr").add_tag(TagMatch::Any, "p", "abc");
|
||||
assert!(filter.matches(&event));
|
||||
|
||||
let filter = Filter::new().add_tag("t", "nostr").add_tag("e", "missing");
|
||||
let filter = Filter::new().add_tag(TagMatch::Any, "t", "nostr").add_tag(TagMatch::Any, "e", "missing");
|
||||
assert!(!filter.matches(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_and_tag_requires_all_values() {
|
||||
let both = make_event(
|
||||
1,
|
||||
1_700_000_000,
|
||||
vec![Tag::new("t", ["meme"]), Tag::new("t", ["cat"])],
|
||||
);
|
||||
let one = make_event(1, 1_700_000_000, vec![Tag::new("t", ["meme"])]);
|
||||
|
||||
let filter = Filter::new().add_tags(TagMatch::All, "t", ["meme", "cat"]);
|
||||
assert!(filter.matches(&both));
|
||||
assert!(!filter.matches(&one)); // missing "cat"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_or_and_keys_combine() {
|
||||
// `#t` (OR) and `&t` (AND) are independent entries; both must hold.
|
||||
let filter = Filter::new()
|
||||
.add_tags(TagMatch::All, "t", ["meme", "cat"])
|
||||
.add_tags(TagMatch::Any, "t", ["black", "white"]);
|
||||
|
||||
// Has meme and cat but neither black nor white: the `#t` OR fails.
|
||||
let no_color = make_event(
|
||||
1,
|
||||
1_700_000_000,
|
||||
vec![Tag::new("t", ["meme"]), Tag::new("t", ["cat"])],
|
||||
);
|
||||
assert!(!filter.matches(&no_color));
|
||||
|
||||
// meme, cat, black: both the AND and the OR are satisfied.
|
||||
let ok = make_event(
|
||||
1,
|
||||
1_700_000_000,
|
||||
vec![
|
||||
Tag::new("t", ["meme"]),
|
||||
Tag::new("t", ["cat"]),
|
||||
Tag::new("t", ["black"]),
|
||||
],
|
||||
);
|
||||
assert!(filter.matches(&ok));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_since_inclusive() {
|
||||
let event = make_event(1, 1000, vec![]);
|
||||
@@ -178,7 +220,7 @@ fn builder_chaining() {
|
||||
.add_kind(1)
|
||||
.add_kinds([2, 3])
|
||||
.add_author(pk)
|
||||
.add_tag("t", "nostr")
|
||||
.add_tag(TagMatch::Any, "t", "nostr")
|
||||
.add_since(1000)
|
||||
.add_until(2000)
|
||||
.add_limit(10);
|
||||
@@ -200,7 +242,7 @@ fn address_convenience() {
|
||||
|
||||
assert!(filter.kinds.as_ref().unwrap().contains(&30023));
|
||||
assert!(filter.authors.as_ref().unwrap().contains(&pk));
|
||||
assert!(filter.tags.get("d").unwrap().contains("my-article"));
|
||||
assert!(filter.tags.get("#d").unwrap().contains("my-article"));
|
||||
}
|
||||
|
||||
// --- Construction: remove ---
|
||||
@@ -235,9 +277,9 @@ fn remove_kind() {
|
||||
#[test]
|
||||
fn remove_tag_value() {
|
||||
let filter = Filter::new()
|
||||
.add_tags("t", ["nostr", "bitcoin"])
|
||||
.remove_tag("t", "bitcoin");
|
||||
let values = filter.tags.get("t").unwrap();
|
||||
.add_tags(TagMatch::Any, "t", ["nostr", "bitcoin"])
|
||||
.remove_tag(TagMatch::Any, "t", "bitcoin");
|
||||
let values = filter.tags.get("#t").unwrap();
|
||||
assert!(values.contains("nostr"));
|
||||
assert!(!values.contains("bitcoin"));
|
||||
}
|
||||
@@ -245,9 +287,9 @@ fn remove_tag_value() {
|
||||
#[test]
|
||||
fn remove_tag_last_value_removes_entry() {
|
||||
let filter = Filter::new()
|
||||
.add_tag("t", "nostr")
|
||||
.remove_tag("t", "nostr");
|
||||
assert!(!filter.tags.contains_key("t"));
|
||||
.add_tag(TagMatch::Any, "t", "nostr")
|
||||
.remove_tag(TagMatch::Any, "t", "nostr");
|
||||
assert!(!filter.tags.contains_key("#t"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -257,7 +299,7 @@ fn remove_on_none_is_noop() {
|
||||
.remove_id(&[0u8; 32])
|
||||
.remove_kind(&1)
|
||||
.remove_author(&fixed_secret().public_key())
|
||||
.remove_tag("t", "nostr");
|
||||
.remove_tag(TagMatch::Any, "t", "nostr");
|
||||
assert!(filter.ids.is_none());
|
||||
assert!(filter.kinds.is_none());
|
||||
assert!(filter.authors.is_none());
|
||||
@@ -288,18 +330,18 @@ fn clear_kinds() {
|
||||
#[test]
|
||||
fn clear_tag() {
|
||||
let filter = Filter::new()
|
||||
.add_tag("t", "nostr")
|
||||
.add_tag("e", "abc")
|
||||
.clear_tag("t");
|
||||
assert!(!filter.tags.contains_key("t"));
|
||||
assert!(filter.tags.contains_key("e"));
|
||||
.add_tag(TagMatch::Any, "t", "nostr")
|
||||
.add_tag(TagMatch::Any, "e", "abc")
|
||||
.clear_tag(TagMatch::Any, "t");
|
||||
assert!(!filter.tags.contains_key("#t"));
|
||||
assert!(filter.tags.contains_key("#e"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_tags() {
|
||||
let filter = Filter::new()
|
||||
.add_tag("t", "nostr")
|
||||
.add_tag("e", "abc")
|
||||
.add_tag(TagMatch::Any, "t", "nostr")
|
||||
.add_tag(TagMatch::Any, "e", "abc")
|
||||
.clear_tags();
|
||||
assert!(filter.tags.is_empty());
|
||||
}
|
||||
@@ -330,8 +372,8 @@ fn json_round_trip() {
|
||||
let filter = Filter::new()
|
||||
.add_kind(1)
|
||||
.add_author(pk)
|
||||
.add_tag("t", "nostr")
|
||||
.add_tag("e", "abc123")
|
||||
.add_tag(TagMatch::Any, "t", "nostr")
|
||||
.add_tag(TagMatch::Any, "e", "abc123")
|
||||
.add_since(1000)
|
||||
.add_until(2000)
|
||||
.add_limit(10);
|
||||
@@ -343,12 +385,25 @@ fn json_round_trip() {
|
||||
|
||||
#[test]
|
||||
fn json_tag_flattening() {
|
||||
let filter = Filter::new().add_tag("t", "nostr");
|
||||
let filter = Filter::new().add_tag(TagMatch::Any, "t", "nostr");
|
||||
let json = serde_json::to_string(&filter).unwrap();
|
||||
assert!(json.contains("\"#t\""));
|
||||
assert!(json.contains("\"nostr\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_and_tag_flattening() {
|
||||
let filter = Filter::new()
|
||||
.add_tags(TagMatch::All, "t", ["meme", "cat"])
|
||||
.add_tags(TagMatch::Any, "t", ["black", "white"]);
|
||||
let json = serde_json::to_string(&filter).unwrap();
|
||||
assert!(json.contains("\"&t\""), "json was: {json}");
|
||||
assert!(json.contains("\"#t\""), "json was: {json}");
|
||||
|
||||
let parsed: Filter = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(filter, parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_empty_filter() {
|
||||
let filter = Filter::new();
|
||||
@@ -385,8 +440,8 @@ fn json_ignores_unknown_keys() {
|
||||
|
||||
#[test]
|
||||
fn filter_id_deterministic() {
|
||||
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
|
||||
let f2 = Filter::new().add_kind(1).add_tag("t", "nostr");
|
||||
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
|
||||
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
|
||||
assert_eq!(f1.id(), f2.id());
|
||||
}
|
||||
|
||||
@@ -399,15 +454,15 @@ fn filter_id_differs_for_different_filters() {
|
||||
|
||||
#[test]
|
||||
fn filter_group_same_shape() {
|
||||
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
|
||||
let f2 = Filter::new().add_kind(2).add_tag("t", "bitcoin");
|
||||
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
|
||||
let f2 = Filter::new().add_kind(2).add_tag(TagMatch::Any, "t", "bitcoin");
|
||||
assert_eq!(f1.group(), f2.group());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_group_different_shape() {
|
||||
let f1 = Filter::new().add_kind(1).add_tag("t", "nostr");
|
||||
let f2 = Filter::new().add_kind(1).add_tag("e", "abc");
|
||||
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
|
||||
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "e", "abc");
|
||||
assert_ne!(f1.group(), f2.group());
|
||||
}
|
||||
|
||||
@@ -440,6 +495,22 @@ fn group_key_no_limit_is_mergeable() {
|
||||
assert_eq!(f1.group(), f2.group());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_distinguishes_or_and_keys() {
|
||||
// `#t` and `&t` are different keys, so they never share a group.
|
||||
let or = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "meme");
|
||||
let and = Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "meme");
|
||||
assert_ne!(or.group(), and.group());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_distinct_and_values_not_mergeable() {
|
||||
// AND values are part of the group identity, so they can't be merged.
|
||||
let f1 = Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "meme");
|
||||
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "cat");
|
||||
assert_ne!(f1.group(), f2.group());
|
||||
}
|
||||
|
||||
// --- union_filters ---
|
||||
|
||||
#[test]
|
||||
@@ -462,7 +533,7 @@ fn union_merges_same_shape() {
|
||||
fn union_keeps_different_shapes_separate() {
|
||||
let filters = vec![
|
||||
Filter::new().add_kind(1),
|
||||
Filter::new().add_kind(1).add_tag("t", "nostr"),
|
||||
Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr"),
|
||||
];
|
||||
let result = union_filters(&filters);
|
||||
assert_eq!(result.len(), 2);
|
||||
@@ -497,30 +568,40 @@ fn union_empty_input() {
|
||||
#[test]
|
||||
fn union_merges_tag_values() {
|
||||
let filters = vec![
|
||||
Filter::new().add_tag("t", "nostr"),
|
||||
Filter::new().add_tag("t", "bitcoin"),
|
||||
Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
|
||||
Filter::new().add_tag(TagMatch::Any, "t", "bitcoin"),
|
||||
];
|
||||
let result = union_filters(&filters);
|
||||
assert_eq!(result.len(), 1);
|
||||
let values = result[0].tags.get("t").unwrap();
|
||||
let values = result[0].tags.get("#t").unwrap();
|
||||
assert!(values.contains("nostr"));
|
||||
assert!(values.contains("bitcoin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_keeps_distinct_and_values_separate() {
|
||||
let filters = vec![
|
||||
Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "meme"),
|
||||
Filter::new().add_kind(1).add_tag(TagMatch::All, "t", "cat"),
|
||||
];
|
||||
let result = union_filters(&filters);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
// --- intersect_filters ---
|
||||
|
||||
#[test]
|
||||
fn intersect_cartesian_product() {
|
||||
let groups = vec![
|
||||
vec![Filter::new().add_kind(1), Filter::new().add_kind(2)],
|
||||
vec![Filter::new().add_tag("t", "nostr")],
|
||||
vec![Filter::new().add_tag(TagMatch::Any, "t", "nostr")],
|
||||
];
|
||||
let result = intersect_filters(&groups);
|
||||
// Two combinations merged by union into one: kinds(1,2)+tag(t)
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].kinds.as_ref().unwrap().contains(&1));
|
||||
assert!(result[0].kinds.as_ref().unwrap().contains(&2));
|
||||
assert!(result[0].tags.contains_key("t"));
|
||||
assert!(result[0].tags.contains_key("#t"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -528,8 +609,8 @@ fn intersect_incompatible_shapes_stay_separate() {
|
||||
let groups = vec![
|
||||
vec![Filter::new().add_kind(1)],
|
||||
vec![
|
||||
Filter::new().add_tag("t", "nostr"),
|
||||
Filter::new().add_tag("e", "abc"),
|
||||
Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
|
||||
Filter::new().add_tag(TagMatch::Any, "e", "abc"),
|
||||
],
|
||||
];
|
||||
let result = intersect_filters(&groups);
|
||||
@@ -549,6 +630,30 @@ fn intersect_tightens_time_window() {
|
||||
assert_eq!(result[0].until, Some(3000)); // min
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_accumulates_and_values() {
|
||||
// Each group requires a different `&t` value; the intersection must
|
||||
// require both, so the values accumulate into one filter.
|
||||
let groups = vec![
|
||||
vec![Filter::new().add_tag(TagMatch::All, "t", "meme")],
|
||||
vec![Filter::new().add_tag(TagMatch::All, "t", "cat")],
|
||||
];
|
||||
let result = intersect_filters(&groups);
|
||||
assert_eq!(result.len(), 1);
|
||||
let required = result[0].tags.get("&t").unwrap();
|
||||
assert!(required.contains("meme"));
|
||||
assert!(required.contains("cat"));
|
||||
|
||||
let both = make_event(
|
||||
1,
|
||||
1_700_000_000,
|
||||
vec![Tag::new("t", ["meme"]), Tag::new("t", ["cat"])],
|
||||
);
|
||||
assert!(result[0].matches(&both));
|
||||
let one = make_event(1, 1_700_000_000, vec![Tag::new("t", ["meme"])]);
|
||||
assert!(!result[0].matches(&one));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_empty_groups() {
|
||||
let result = intersect_filters(&[]);
|
||||
|
||||
Reference in New Issue
Block a user