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
|
checks regardless of the key type, and deterministic iteration order for
|
||||||
serialization and hashing.
|
serialization and hashing.
|
||||||
|
|
||||||
The `tags` field is a `BTreeMap` from tag name to a set of acceptable
|
The `tags` field is a `BTreeMap` from a tag key to a set of values. The
|
||||||
values. Tag names are arbitrary strings — not restricted to single
|
key is the tag name, prefixed either by `#` (any value matches, defined
|
||||||
letters. Single-letter indexing is a relay optimization, not a protocol
|
in NIP-01) or `&` (all values match, defined in NIP-91).
|
||||||
constraint, and the filter type should not encode relay policy.
|
|
||||||
|
|
||||||
```rust {file=coracle-lib/src/filters.rs}
|
```rust {file=coracle-lib/src/filters.rs}
|
||||||
/// A predicate over nostr events.
|
/// A predicate over nostr events.
|
||||||
@@ -73,8 +72,7 @@ pub struct Filter {
|
|||||||
pub authors: Option<BTreeSet<PublicKey>>,
|
pub authors: Option<BTreeSet<PublicKey>>,
|
||||||
/// Event kinds to match.
|
/// Event kinds to match.
|
||||||
pub kinds: Option<BTreeSet<u16>>,
|
pub kinds: Option<BTreeSet<u16>>,
|
||||||
/// Tag filters: for each entry, the event must have at least one tag
|
/// Tag filters, keyed by prefixed tag name.
|
||||||
/// with that name whose value appears in the set.
|
|
||||||
pub tags: BTreeMap<String, BTreeSet<String>>,
|
pub tags: BTreeMap<String, BTreeSet<String>>,
|
||||||
/// Lower bound on `created_at` (inclusive).
|
/// Lower bound on `created_at` (inclusive).
|
||||||
pub since: Option<u64>,
|
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
|
are a simple set-membership test and reject most non-matching events
|
||||||
immediately, before the more involved tag iteration.
|
immediately, before the more involved tag iteration.
|
||||||
|
|
||||||
Tag matching checks every entry in the filter's `tags` map. For each
|
Tag matching checks every entry in the filter's `tags` map, and the key's
|
||||||
tag name, the event must contain at least one tag with that name whose
|
leading character determines the semantics.
|
||||||
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.
|
|
||||||
|
|
||||||
```rust {file=coracle-lib/src/filters.rs}
|
```rust {file=coracle-lib/src/filters.rs}
|
||||||
impl Filter {
|
impl Filter {
|
||||||
@@ -137,11 +132,22 @@ impl Filter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (name, values) in &self.tags {
|
for (key, values) in &self.tags {
|
||||||
let has_match = event
|
let name = &key[1..];
|
||||||
.tags
|
let has_match = match key.chars().next() {
|
||||||
.find_all(name)
|
Some('#') => event
|
||||||
.any(|tag| values.contains(tag.value()));
|
.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 {
|
if !has_match {
|
||||||
return false;
|
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` —
|
take values back out, and `clear_*` methods reset a field to `None` —
|
||||||
removing the constraint entirely.
|
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}
|
```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 {
|
impl Filter {
|
||||||
/// Create an empty filter that matches every event.
|
/// Create an empty filter that matches every event.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
@@ -286,11 +321,10 @@ impl Filter {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a value to a tag filter: the event must have at least one tag
|
/// Add a value to a tag filter.
|
||||||
/// with this name whose value appears in the set.
|
pub fn add_tag(mut self, mode: TagMatch, name: &str, value: impl Into<String>) -> Self {
|
||||||
pub fn add_tag(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
|
||||||
self.tags
|
self.tags
|
||||||
.entry(name.into())
|
.entry(mode.key(name))
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(value.into());
|
.insert(value.into());
|
||||||
self
|
self
|
||||||
@@ -299,31 +333,32 @@ impl Filter {
|
|||||||
/// Add multiple values to a tag filter.
|
/// Add multiple values to a tag filter.
|
||||||
pub fn add_tags(
|
pub fn add_tags(
|
||||||
mut self,
|
mut self,
|
||||||
name: impl Into<String>,
|
mode: TagMatch,
|
||||||
|
name: &str,
|
||||||
values: impl IntoIterator<Item = impl Into<String>>,
|
values: impl IntoIterator<Item = impl Into<String>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
self.tags
|
self.tags
|
||||||
.entry(name.into())
|
.entry(mode.key(name))
|
||||||
.or_default()
|
.or_default()
|
||||||
.extend(values.into_iter().map(Into::into));
|
.extend(values.into_iter().map(Into::into));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a value from a tag filter. If the value set becomes empty,
|
/// Remove a value from a tag filter.
|
||||||
/// the tag entry is removed from the map.
|
pub fn remove_tag(mut self, mode: TagMatch, name: &str, value: &str) -> Self {
|
||||||
pub fn remove_tag(mut self, name: &str, value: &str) -> Self {
|
let key = mode.key(name);
|
||||||
if let Some(values) = self.tags.get_mut(name) {
|
if let Some(values) = self.tags.get_mut(&key) {
|
||||||
values.remove(value);
|
values.remove(value);
|
||||||
if values.is_empty() {
|
if values.is_empty() {
|
||||||
self.tags.remove(name);
|
self.tags.remove(&key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove an entire tag filter by name.
|
/// Remove an entire tag filter by its mode and name.
|
||||||
pub fn clear_tag(mut self, name: &str) -> Self {
|
pub fn clear_tag(mut self, mode: TagMatch, name: &str) -> Self {
|
||||||
self.tags.remove(name);
|
self.tags.remove(&mode.key(name));
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,14 +417,14 @@ translates these into the corresponding filter fields.
|
|||||||
impl Filter {
|
impl Filter {
|
||||||
/// Add constraints that match events at the given address.
|
/// 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),
|
/// 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.
|
/// with an empty value.
|
||||||
pub fn add_address(self, addr: &Address) -> Self {
|
pub fn add_address(self, addr: &Address) -> Self {
|
||||||
self.add_kind(addr.kind)
|
self.add_kind(addr.kind)
|
||||||
.add_author(addr.pubkey)
|
.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 addr: Address = "30023:ab12...cd34:my-article".parse().unwrap();
|
||||||
let filter = Filter::new().add_address(&addr);
|
let filter = Filter::new().add_address(&addr);
|
||||||
// Equivalent to:
|
// 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
|
## Serialization
|
||||||
|
|
||||||
The NIP-01 wire format for a filter is a flat JSON object where tag
|
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
|
```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
|
The `tags` map already holds prefixed keys, so flattening is direct: each
|
||||||
object during serialization and reconstituted during deserialization.
|
key becomes a top-level field verbatim, and reconstituting it on the way
|
||||||
This rules out `#[derive(Serialize, Deserialize)]` — we need a hand-
|
back is just as simple. Still, interleaving tag keys with the fixed
|
||||||
|
fields rules out `#[derive(Serialize, Deserialize)]` — we need a hand-
|
||||||
written implementation.
|
written implementation.
|
||||||
|
|
||||||
```rust {file=coracle-lib/src/filters.rs}
|
```rust {file=coracle-lib/src/filters.rs}
|
||||||
@@ -474,10 +510,9 @@ impl Serialize for Filter {
|
|||||||
map.serialize_entry("kinds", &kinds_vec)?;
|
map.serialize_entry("kinds", &kinds_vec)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (name, values) in &self.tags {
|
for (key, values) in &self.tags {
|
||||||
let key = format!("#{name}");
|
|
||||||
let vals: Vec<&str> = values.iter().map(String::as_str).collect();
|
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 {
|
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
|
Deserialization collects known keys into their fields and routes any key
|
||||||
starting with `#` into the `tags` map. Unknown keys are silently
|
starting with `#` or `&` into the `tags` map, prefix and all. Unknown
|
||||||
ignored for forward compatibility.
|
keys are silently ignored for forward compatibility.
|
||||||
|
|
||||||
```rust {file=coracle-lib/src/filters.rs}
|
```rust {file=coracle-lib/src/filters.rs}
|
||||||
impl<'de> Deserialize<'de> for Filter {
|
impl<'de> Deserialize<'de> for Filter {
|
||||||
@@ -561,10 +596,9 @@ impl<'de> Visitor<'de> for FilterVisitor {
|
|||||||
"until" => until = Some(map.next_value()?),
|
"until" => until = Some(map.next_value()?),
|
||||||
"limit" => limit = Some(map.next_value()?),
|
"limit" => limit = Some(map.next_value()?),
|
||||||
"search" => search = Some(map.next_value()?),
|
"search" => search = Some(map.next_value()?),
|
||||||
other if other.starts_with('#') => {
|
other if other.starts_with('#') || other.starts_with('&') => {
|
||||||
let tag_name = other[1..].to_string();
|
|
||||||
let values: Vec<String> = map.next_value()?;
|
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()?;
|
let _: de::IgnoredAny = map.next_value()?;
|
||||||
@@ -589,11 +623,12 @@ impl<'de> Visitor<'de> for FilterVisitor {
|
|||||||
A round-trip through JSON preserves all fields:
|
A round-trip through JSON preserves all fields:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use coracle_lib::filters::Filter;
|
use coracle_lib::filters::{Filter, TagMatch};
|
||||||
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.add_kind(1)
|
.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_since(1_700_000_000)
|
||||||
.add_limit(10);
|
.add_limit(10);
|
||||||
let json = serde_json::to_string(&filter).unwrap();
|
let json = serde_json::to_string(&filter).unwrap();
|
||||||
@@ -648,8 +683,10 @@ impl Filter {
|
|||||||
/// fields (`ids`, `authors`, `kinds`, tag values). The group key
|
/// fields (`ids`, `authors`, `kinds`, tag values). The group key
|
||||||
/// captures:
|
/// captures:
|
||||||
///
|
///
|
||||||
/// - Which set fields are present and which tag names appear
|
/// - Which set fields are present and which tag keys appear
|
||||||
/// (structural shape)
|
/// (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
|
/// - The exact `since` and `until` values (different time windows
|
||||||
/// cannot be combined)
|
/// cannot be combined)
|
||||||
/// - The exact `search` query (different searches 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.ids.is_some().hash(&mut hasher);
|
||||||
self.authors.is_some().hash(&mut hasher);
|
self.authors.is_some().hash(&mut hasher);
|
||||||
self.kinds.is_some().hash(&mut hasher);
|
self.kinds.is_some().hash(&mut hasher);
|
||||||
for name in self.tags.keys() {
|
for (key, values) in &self.tags {
|
||||||
name.hash(&mut hasher);
|
key.hash(&mut hasher);
|
||||||
|
if key.starts_with('&') {
|
||||||
|
for value in values {
|
||||||
|
value.hash(&mut hasher);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.since.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
|
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`
|
over-fetch or under-fetch relative to what was requested. The `search`
|
||||||
query is treated the same way: two filters with different searches can
|
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
|
## 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.ids, &filter.ids);
|
||||||
merge_sets(&mut existing.authors, &filter.authors);
|
merge_sets(&mut existing.authors, &filter.authors);
|
||||||
merge_sets(&mut existing.kinds, &filter.kinds);
|
merge_sets(&mut existing.kinds, &filter.kinds);
|
||||||
for (name, values) in &filter.tags {
|
for (key, values) in &filter.tags {
|
||||||
existing
|
existing
|
||||||
.tags
|
.tags
|
||||||
.entry(name.clone())
|
.entry(key.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.extend(values.iter().cloned());
|
.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.authors = union_option_sets(&a.authors, &b.authors);
|
||||||
f.kinds = union_option_sets(&a.kinds, &b.kinds);
|
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
|
f.tags
|
||||||
.entry(name.clone())
|
.entry(key.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
.extend(values.iter().cloned());
|
.extend(values.iter().cloned());
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-40
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use coracle_lib::events::{Event, EventContent};
|
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::keys::SecretKey;
|
||||||
use coracle_lib::tags::Tag;
|
use coracle_lib::tags::Tag;
|
||||||
|
|
||||||
@@ -76,15 +76,15 @@ fn filter_matches_by_tag() {
|
|||||||
1_700_000_000,
|
1_700_000_000,
|
||||||
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc123"])],
|
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc123"])],
|
||||||
);
|
);
|
||||||
assert!(Filter::new().add_tag("t", "nostr").matches(&event));
|
assert!(Filter::new().add_tag(TagMatch::Any, "t", "nostr").matches(&event));
|
||||||
assert!(!Filter::new().add_tag("e", "something").matches(&event));
|
assert!(!Filter::new().add_tag(TagMatch::Any, "e", "something").matches(&event));
|
||||||
assert!(!Filter::new().add_tag("t", "bitcoin").matches(&event));
|
assert!(!Filter::new().add_tag(TagMatch::Any, "t", "bitcoin").matches(&event));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_tag_or_within_same_name() {
|
fn filter_tag_or_within_same_name() {
|
||||||
let event = make_event(1, 1_700_000_000, vec![Tag::new("t", ["nostr"])]);
|
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));
|
assert!(filter.matches(&event));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,13 +95,55 @@ fn filter_tag_and_across_names() {
|
|||||||
1_700_000_000,
|
1_700_000_000,
|
||||||
vec![Tag::new("t", ["nostr"]), Tag::new("p", ["abc"])],
|
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));
|
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));
|
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]
|
#[test]
|
||||||
fn filter_since_inclusive() {
|
fn filter_since_inclusive() {
|
||||||
let event = make_event(1, 1000, vec![]);
|
let event = make_event(1, 1000, vec![]);
|
||||||
@@ -178,7 +220,7 @@ fn builder_chaining() {
|
|||||||
.add_kind(1)
|
.add_kind(1)
|
||||||
.add_kinds([2, 3])
|
.add_kinds([2, 3])
|
||||||
.add_author(pk)
|
.add_author(pk)
|
||||||
.add_tag("t", "nostr")
|
.add_tag(TagMatch::Any, "t", "nostr")
|
||||||
.add_since(1000)
|
.add_since(1000)
|
||||||
.add_until(2000)
|
.add_until(2000)
|
||||||
.add_limit(10);
|
.add_limit(10);
|
||||||
@@ -200,7 +242,7 @@ fn address_convenience() {
|
|||||||
|
|
||||||
assert!(filter.kinds.as_ref().unwrap().contains(&30023));
|
assert!(filter.kinds.as_ref().unwrap().contains(&30023));
|
||||||
assert!(filter.authors.as_ref().unwrap().contains(&pk));
|
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 ---
|
// --- Construction: remove ---
|
||||||
@@ -235,9 +277,9 @@ fn remove_kind() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remove_tag_value() {
|
fn remove_tag_value() {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.add_tags("t", ["nostr", "bitcoin"])
|
.add_tags(TagMatch::Any, "t", ["nostr", "bitcoin"])
|
||||||
.remove_tag("t", "bitcoin");
|
.remove_tag(TagMatch::Any, "t", "bitcoin");
|
||||||
let values = filter.tags.get("t").unwrap();
|
let values = filter.tags.get("#t").unwrap();
|
||||||
assert!(values.contains("nostr"));
|
assert!(values.contains("nostr"));
|
||||||
assert!(!values.contains("bitcoin"));
|
assert!(!values.contains("bitcoin"));
|
||||||
}
|
}
|
||||||
@@ -245,9 +287,9 @@ fn remove_tag_value() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn remove_tag_last_value_removes_entry() {
|
fn remove_tag_last_value_removes_entry() {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.add_tag("t", "nostr")
|
.add_tag(TagMatch::Any, "t", "nostr")
|
||||||
.remove_tag("t", "nostr");
|
.remove_tag(TagMatch::Any, "t", "nostr");
|
||||||
assert!(!filter.tags.contains_key("t"));
|
assert!(!filter.tags.contains_key("#t"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -257,7 +299,7 @@ fn remove_on_none_is_noop() {
|
|||||||
.remove_id(&[0u8; 32])
|
.remove_id(&[0u8; 32])
|
||||||
.remove_kind(&1)
|
.remove_kind(&1)
|
||||||
.remove_author(&fixed_secret().public_key())
|
.remove_author(&fixed_secret().public_key())
|
||||||
.remove_tag("t", "nostr");
|
.remove_tag(TagMatch::Any, "t", "nostr");
|
||||||
assert!(filter.ids.is_none());
|
assert!(filter.ids.is_none());
|
||||||
assert!(filter.kinds.is_none());
|
assert!(filter.kinds.is_none());
|
||||||
assert!(filter.authors.is_none());
|
assert!(filter.authors.is_none());
|
||||||
@@ -288,18 +330,18 @@ fn clear_kinds() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn clear_tag() {
|
fn clear_tag() {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.add_tag("t", "nostr")
|
.add_tag(TagMatch::Any, "t", "nostr")
|
||||||
.add_tag("e", "abc")
|
.add_tag(TagMatch::Any, "e", "abc")
|
||||||
.clear_tag("t");
|
.clear_tag(TagMatch::Any, "t");
|
||||||
assert!(!filter.tags.contains_key("t"));
|
assert!(!filter.tags.contains_key("#t"));
|
||||||
assert!(filter.tags.contains_key("e"));
|
assert!(filter.tags.contains_key("#e"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_tags() {
|
fn clear_tags() {
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.add_tag("t", "nostr")
|
.add_tag(TagMatch::Any, "t", "nostr")
|
||||||
.add_tag("e", "abc")
|
.add_tag(TagMatch::Any, "e", "abc")
|
||||||
.clear_tags();
|
.clear_tags();
|
||||||
assert!(filter.tags.is_empty());
|
assert!(filter.tags.is_empty());
|
||||||
}
|
}
|
||||||
@@ -330,8 +372,8 @@ fn json_round_trip() {
|
|||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.add_kind(1)
|
.add_kind(1)
|
||||||
.add_author(pk)
|
.add_author(pk)
|
||||||
.add_tag("t", "nostr")
|
.add_tag(TagMatch::Any, "t", "nostr")
|
||||||
.add_tag("e", "abc123")
|
.add_tag(TagMatch::Any, "e", "abc123")
|
||||||
.add_since(1000)
|
.add_since(1000)
|
||||||
.add_until(2000)
|
.add_until(2000)
|
||||||
.add_limit(10);
|
.add_limit(10);
|
||||||
@@ -343,12 +385,25 @@ fn json_round_trip() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn json_tag_flattening() {
|
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();
|
let json = serde_json::to_string(&filter).unwrap();
|
||||||
assert!(json.contains("\"#t\""));
|
assert!(json.contains("\"#t\""));
|
||||||
assert!(json.contains("\"nostr\""));
|
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]
|
#[test]
|
||||||
fn json_empty_filter() {
|
fn json_empty_filter() {
|
||||||
let filter = Filter::new();
|
let filter = Filter::new();
|
||||||
@@ -385,8 +440,8 @@ fn json_ignores_unknown_keys() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_id_deterministic() {
|
fn filter_id_deterministic() {
|
||||||
let f1 = 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("t", "nostr");
|
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "t", "nostr");
|
||||||
assert_eq!(f1.id(), f2.id());
|
assert_eq!(f1.id(), f2.id());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,15 +454,15 @@ fn filter_id_differs_for_different_filters() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_group_same_shape() {
|
fn filter_group_same_shape() {
|
||||||
let f1 = 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(2).add_tag("t", "bitcoin");
|
let f2 = Filter::new().add_kind(2).add_tag(TagMatch::Any, "t", "bitcoin");
|
||||||
assert_eq!(f1.group(), f2.group());
|
assert_eq!(f1.group(), f2.group());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_group_different_shape() {
|
fn filter_group_different_shape() {
|
||||||
let f1 = 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("e", "abc");
|
let f2 = Filter::new().add_kind(1).add_tag(TagMatch::Any, "e", "abc");
|
||||||
assert_ne!(f1.group(), f2.group());
|
assert_ne!(f1.group(), f2.group());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +495,22 @@ fn group_key_no_limit_is_mergeable() {
|
|||||||
assert_eq!(f1.group(), f2.group());
|
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 ---
|
// --- union_filters ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -462,7 +533,7 @@ fn union_merges_same_shape() {
|
|||||||
fn union_keeps_different_shapes_separate() {
|
fn union_keeps_different_shapes_separate() {
|
||||||
let filters = vec![
|
let filters = vec![
|
||||||
Filter::new().add_kind(1),
|
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);
|
let result = union_filters(&filters);
|
||||||
assert_eq!(result.len(), 2);
|
assert_eq!(result.len(), 2);
|
||||||
@@ -497,30 +568,40 @@ fn union_empty_input() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn union_merges_tag_values() {
|
fn union_merges_tag_values() {
|
||||||
let filters = vec![
|
let filters = vec![
|
||||||
Filter::new().add_tag("t", "nostr"),
|
Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
|
||||||
Filter::new().add_tag("t", "bitcoin"),
|
Filter::new().add_tag(TagMatch::Any, "t", "bitcoin"),
|
||||||
];
|
];
|
||||||
let result = union_filters(&filters);
|
let result = union_filters(&filters);
|
||||||
assert_eq!(result.len(), 1);
|
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("nostr"));
|
||||||
assert!(values.contains("bitcoin"));
|
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 ---
|
// --- intersect_filters ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn intersect_cartesian_product() {
|
fn intersect_cartesian_product() {
|
||||||
let groups = vec![
|
let groups = vec![
|
||||||
vec![Filter::new().add_kind(1), Filter::new().add_kind(2)],
|
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);
|
let result = intersect_filters(&groups);
|
||||||
// Two combinations merged by union into one: kinds(1,2)+tag(t)
|
// Two combinations merged by union into one: kinds(1,2)+tag(t)
|
||||||
assert_eq!(result.len(), 1);
|
assert_eq!(result.len(), 1);
|
||||||
assert!(result[0].kinds.as_ref().unwrap().contains(&1));
|
assert!(result[0].kinds.as_ref().unwrap().contains(&1));
|
||||||
assert!(result[0].kinds.as_ref().unwrap().contains(&2));
|
assert!(result[0].kinds.as_ref().unwrap().contains(&2));
|
||||||
assert!(result[0].tags.contains_key("t"));
|
assert!(result[0].tags.contains_key("#t"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -528,8 +609,8 @@ fn intersect_incompatible_shapes_stay_separate() {
|
|||||||
let groups = vec![
|
let groups = vec![
|
||||||
vec![Filter::new().add_kind(1)],
|
vec![Filter::new().add_kind(1)],
|
||||||
vec![
|
vec![
|
||||||
Filter::new().add_tag("t", "nostr"),
|
Filter::new().add_tag(TagMatch::Any, "t", "nostr"),
|
||||||
Filter::new().add_tag("e", "abc"),
|
Filter::new().add_tag(TagMatch::Any, "e", "abc"),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
let result = intersect_filters(&groups);
|
let result = intersect_filters(&groups);
|
||||||
@@ -549,6 +630,30 @@ fn intersect_tightens_time_window() {
|
|||||||
assert_eq!(result[0].until, Some(3000)); // min
|
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]
|
#[test]
|
||||||
fn intersect_empty_groups() {
|
fn intersect_empty_groups() {
|
||||||
let result = intersect_filters(&[]);
|
let result = intersect_filters(&[]);
|
||||||
|
|||||||
Reference in New Issue
Block a user