commit dd0d18e637388ae1ef344a9544801acbb3a41bfe
parent 73797f160e67e5e98f201e56f1af731108b54032
Author: William Casarin <jb55@jb55.com>
Date: Wed, 9 Apr 2025 13:21:56 -0700
filter: add custom note filtering
Add the ability to add custom filter conditions. These conditions are
executed for each matching note when nostrdb walks the index during
a query. If the callback returns false, the note is not added to
the query result.
This is useful for things like filtering note replies, etc
Changelog-Added: Added the ability to add custom filter conditions to Filter
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
| M | src/filter.rs | | | 143 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- |
| M | src/note.rs | | | 5 | +++++ |
2 files changed, 146 insertions(+), 2 deletions(-)
diff --git a/src/filter.rs b/src/filter.rs
@@ -1,19 +1,53 @@
use crate::{bindings, Error, FilterError, Note, Result};
use std::ffi::CString;
+use std::fmt;
use std::os::raw::c_char;
use std::ptr::null_mut;
use tracing::debug;
-#[derive(Debug)]
pub struct FilterBuilder {
pub data: bindings::ndb_filter,
}
-#[derive(Debug)]
pub struct Filter {
pub data: bindings::ndb_filter,
}
+fn filter_fmt<'a, F>(filter: F, f: &mut fmt::Formatter<'_>) -> fmt::Result
+where
+ F: IntoIterator<Item = FilterField<'a>>,
+{
+ let mut dfmt = f.debug_struct("Filter");
+ let mut fmt = &mut dfmt;
+
+ for field in filter {
+ fmt = match field {
+ FilterField::Search(ref search) => fmt.field("search", search),
+ FilterField::Ids(ref ids) => fmt.field("ids", ids),
+ FilterField::Authors(ref authors) => fmt.field("authors", authors),
+ FilterField::Kinds(ref kinds) => fmt.field("kinds", kinds),
+ FilterField::Tags(ref chr, _tags) => fmt.field("tags", chr),
+ FilterField::Since(ref n) => fmt.field("since", n),
+ FilterField::Until(ref n) => fmt.field("until", n),
+ FilterField::Limit(ref n) => fmt.field("limit", n),
+ }
+ }
+
+ fmt.finish()
+}
+
+impl fmt::Debug for Filter {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ filter_fmt(self, f)
+ }
+}
+
+impl fmt::Debug for FilterBuilder {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ filter_fmt(self, f)
+ }
+}
+
impl Clone for Filter {
fn clone(&self) -> Self {
// Default inits...
@@ -360,6 +394,34 @@ impl FilterBuilder {
Ok(())
}
+ /// Set a callback to add custom filtering logic to the query
+ pub fn add_custom_filter_element<F>(&mut self, closure: F) -> Result<()>
+ where
+ F: FnMut(Note<'_>) -> bool,
+ {
+ // Box the closure to ensure it has a stable address.
+ let boxed_closure: Box<dyn FnMut(Note<'_>) -> bool> = Box::new(closure);
+
+ // Convert it to a raw pointer to store in sub_cb_ctx.
+ // FIXME: THIS LEAKS! we need some way to clean this up after the filter
+ // is destroyed.
+ let ctx_ptr = Box::into_raw(Box::new(boxed_closure)) as *mut ::std::os::raw::c_void;
+
+ let r = unsafe {
+ bindings::ndb_filter_add_custom_filter_element(
+ self.as_mut_ptr(),
+ Some(custom_filter_trampoline),
+ ctx_ptr,
+ )
+ };
+
+ if r == 0 {
+ return Err(FilterError::already_exists());
+ }
+
+ Ok(())
+ }
+
pub fn add_id_element(&mut self, id: &[u8; 32]) -> Result<()> {
let ptr: *const ::std::os::raw::c_uchar = id.as_ptr() as *const ::std::os::raw::c_uchar;
let r = unsafe { bindings::ndb_filter_add_id_element(self.as_mut_ptr(), ptr) };
@@ -402,6 +464,10 @@ impl FilterBuilder {
self.start_field(bindings::ndb_filter_fieldtype_NDB_FILTER_SINCE)
}
+ pub fn start_custom_field(&mut self) -> Result<()> {
+ self.start_field(bindings::ndb_filter_fieldtype_NDB_FILTER_CUSTOM)
+ }
+
pub fn start_until_field(&mut self) -> Result<()> {
self.start_field(bindings::ndb_filter_fieldtype_NDB_FILTER_UNTIL)
}
@@ -540,6 +606,16 @@ impl FilterBuilder {
self
}
+ pub fn custom<F>(mut self, filter: F) -> Self
+ where
+ F: FnMut(Note<'_>) -> bool,
+ {
+ self.start_custom_field().unwrap();
+ self.add_custom_filter_element(filter).unwrap();
+ self.end_field();
+ self
+ }
+
pub fn since(mut self, since: u64) -> Self {
for field in self.mut_iter() {
if let MutFilterField::Since(val) = field {
@@ -1131,6 +1207,21 @@ impl<'a> FilterElemIter<'a> {
}
}
+extern "C" fn custom_filter_trampoline(
+ ctx: *mut ::std::os::raw::c_void,
+ note: *mut bindings::ndb_note,
+) -> bool {
+ unsafe {
+ // Convert the raw pointer back into a reference to our closure.
+ // We know this pointer was created by Box::into_raw in `set_sub_callback_rust`.
+ let closure_ptr = ctx as *mut Box<dyn FnMut(Note<'_>) -> bool>;
+ assert!(!closure_ptr.is_null());
+ let closure = &mut *closure_ptr;
+ let note = Note::new_unowned(&*note);
+ closure(note)
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -1253,4 +1344,52 @@ mod tests {
}
assert!(hit == 2);
}
+
+ #[test]
+ fn custom_filter_works() {
+ use crate::NoteBuilder;
+
+ let seckey: [u8; 32] = [
+ 0xfb, 0x16, 0x5b, 0xe2, 0x2c, 0x7b, 0x25, 0x18, 0xb7, 0x49, 0xaa, 0xbb, 0x71, 0x40,
+ 0xc7, 0x3f, 0x08, 0x87, 0xfe, 0x84, 0x47, 0x5c, 0x82, 0x78, 0x57, 0x00, 0x66, 0x3b,
+ 0xe8, 0x5b, 0xa8, 0x59,
+ ];
+
+ let note = NoteBuilder::new()
+ .kind(1)
+ .content("this is the content")
+ .created_at(42)
+ .start_tag()
+ .tag_str("comment")
+ .tag_str("this is a comment")
+ .start_tag()
+ .tag_str("blah")
+ .tag_str("something")
+ .sign(&seckey)
+ .build()
+ .expect("expected build to work");
+
+ {
+ let filter = Filter::new().custom(|n| n.created_at() == 43).build();
+ assert!(!filter.matches(¬e));
+ }
+
+ {
+ let filter = Filter::new().custom(|n| n.created_at() == 42).build();
+ assert!(filter.matches(¬e));
+ }
+
+ {
+ let filter = Filter::new()
+ .custom(|n| {
+ n.tags()
+ .into_iter()
+ .next()
+ .and_then(|t| t.get_str(1))
+ .map_or(false, |s| s == "this is a comment")
+ })
+ .build();
+ assert!(filter.matches(¬e));
+ }
+ }
}
diff --git a/src/note.rs b/src/note.rs
@@ -128,6 +128,11 @@ impl<'a> Note<'a> {
Note::Owned { ptr, size }
}
+ #[allow(dead_code)]
+ pub(crate) fn new_unowned(ptr: &'a bindings::ndb_note) -> Note<'a> {
+ Note::Unowned { ptr }
+ }
+
/// Constructs a `Note` in a transactional context.
/// Use [Note::new_transactional] to create a new transactional note.
/// You normally wouldn't use this method directly, it is used by