public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Wolfgang Bumiller <w.bumiller@proxmox.com>
To: Dominik Csapak <d.csapak@proxmox.com>
Cc: pdm-devel@lists.proxmox.com
Subject: Re: [pdm-devel] [PATCH datacenter-manager v4 02/10] lib: add pdm-search crate
Date: Tue, 2 Sep 2025 17:33:00 +0200	[thread overview]
Message-ID: <5zrkhdsc4sw2jsqlqrn7ijbj2oyav6bcpa7f42ybawwrv6zu4a@kjhewrghg7xa> (raw)
In-Reply-To: <20250828131832.4058422-3-d.csapak@proxmox.com>

On Thu, Aug 28, 2025 at 03:16:01PM +0200, Dominik Csapak wrote:
> Introduce a new create for search & filter related code. It currently
> includes basic parsing & testing of search terms. Intended to be used on
> some API calls that allow for more complex filters, such as the
> resources API.
> 
> Contains a `SearchTerm` and a `Search` struct. The former represents
> a single term to search for, with an optional category and if it's
> optional or not. The latter represents a full search with multiple
> terms.
> 
> Short syntax summary:
> 'sometext'  : normal search term
> '+sometext' : required search term
> 'cat:text'  : looks for 'text' in the category 'cat'
> 
> required terms have to exist in the resulting match, while optional
> ones are OR'd (so at least one optional match must exist)

^ Would be good to clarify that (and fix if not) if *no* optional one
matches *but* there *are* matching required ones, it's also a positive
match...

> 
> This is loosely inspired by various search syntaxes, e.g. the one from
> gitlab[0] or query-dsl from elastic[1]
> 
> [1] https://docs.gitlab.com/user/search/advanced_search/
> [2] https://www.elastic.co/docs/reference/query-languages/query-dsl/query-dsl-simple-query-string-query
> 
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
> changes from v3:
> * add missing doc comments
> * more tests
> 
>  Cargo.toml                |   2 +
>  lib/pdm-search/Cargo.toml |  12 ++
>  lib/pdm-search/src/lib.rs | 339 ++++++++++++++++++++++++++++++++++++++
>  3 files changed, 353 insertions(+)
>  create mode 100644 lib/pdm-search/Cargo.toml
>  create mode 100644 lib/pdm-search/src/lib.rs
> 
> diff --git a/Cargo.toml b/Cargo.toml
> index f07733d..ae2816a 100644
> --- a/Cargo.toml
> +++ b/Cargo.toml
> @@ -19,6 +19,7 @@ members = [
>      "lib/pdm-api-types",
>      "lib/pdm-client",
>      "lib/pdm-config",
> +    "lib/pdm-search",
>      "lib/pdm-ui-shared",
>  
>      "cli/client",
> @@ -86,6 +87,7 @@ pdm-api-types = { path = "lib/pdm-api-types" }
>  pdm-buildcfg = { path = "lib/pdm-buildcfg" }
>  pdm-config = { path = "lib/pdm-config" }
>  pdm-client = { version = "0.2", path = "lib/pdm-client" }
> +pdm-search = { version = "0.2", path = "lib/pdm-search" }
>  pdm-ui-shared = { version = "0.2", path = "lib/pdm-ui-shared" }
>  proxmox-fido2 = { path = "cli/proxmox-fido2" }
>  
> diff --git a/lib/pdm-search/Cargo.toml b/lib/pdm-search/Cargo.toml
> new file mode 100644
> index 0000000..5f51e75
> --- /dev/null
> +++ b/lib/pdm-search/Cargo.toml
> @@ -0,0 +1,12 @@
> +[package]
> +name = "pdm-search"
> +description = "Proxmox Datacenter Manager shared ui modules"
> +homepage = "https://www.proxmox.com"
> +
> +version.workspace = true
> +edition.workspace = true
> +license.workspace = true
> +repository.workspace = true
> +
> +[dependencies]
> +anyhow.workspace = true
> diff --git a/lib/pdm-search/src/lib.rs b/lib/pdm-search/src/lib.rs
> new file mode 100644
> index 0000000..4854166
> --- /dev/null
> +++ b/lib/pdm-search/src/lib.rs
> @@ -0,0 +1,339 @@
> +//! Abstraction over a [`Search`] that contains multiple [`SearchTerm`]s.
> +//!
> +//! Provides methods to filter an item over a combination of such terms and
> +//! construct them from text, and serialize them back to text.
> +use std::str::FromStr;

↑ you never use any of its methods, for the single trait implementation
it's fine to just `impl std::str::FromStr` below

OTOH you do implement `Display` twice without importing it (which is
fine, don't but importing just `std::fmt` will shorten it all...)


> +
> +use anyhow::bail;
> +
> +#[derive(Clone)]
> +pub struct Search {
> +    required_terms: Vec<SearchTerm>,
> +    optional_terms: Vec<SearchTerm>,

↑ In theory - is partitioning them very useful, or would it be a better
UX if we kept them in a single vec such that `.parse().to_string()`
doesn't reorder them?

> +}
> +
> +impl Default for Search {

A derive should work here

> +    fn default() -> Self {
> +        Self::new()
> +    }
> +}
> +
> +impl FromIterator<SearchTerm> for Search {
> +    fn from_iter<T: IntoIterator<Item = SearchTerm>>(iter: T) -> Self {
> +        let (optional_terms, required_terms) = iter.into_iter().partition(|term| term.optional);
> +
> +        Self {
> +            required_terms,
> +            optional_terms,
> +        }
> +    }
> +}
> +
> +impl<S: AsRef<str>> From<S> for Search {
> +    fn from(value: S) -> Self {
> +        value
> +            .as_ref()
> +            .split_whitespace()
> +            .filter_map(|term| term.parse().ok())

Maybe add a note here why we do this, as this is quite painful to read.
We throw away errors because we expect users to produce them? ;-)

> +            .collect()
> +    }
> +}
> +
> +impl Search {
> +    /// Create a new empty [`Search`]
> +    pub fn new() -> Self {
> +        Self::with_terms(Vec::new())
> +    }
> +
> +    /// Returns true if no [`SearchTerm`] exist
> +    pub fn is_empty(&self) -> bool {
> +        self.required_terms.is_empty() && self.optional_terms.is_empty()
> +    }
> +
> +    /// Create a new [`Search`] with the given [`SearchTerm`]s
> +    pub fn with_terms(terms: Vec<SearchTerm>) -> Self {

(
Since we already have `FromIterator`, this could be

    pub fn with_terms<I>(terms: I) -> Self
    where
        I: IntoIterator<Item = SearchTerm>;
)

> +        terms.into_iter().collect()
> +    }
> +
> +    /// Test if the given `Fn(&SearchTerm) -> bool` for all [`SearchTerm`] configured matches
> +    ///
> +    /// Returns true if it matches considering the constraints:
> +    /// if there are no filters, returns true
> +    pub fn matches<F: Fn(&SearchTerm) -> bool>(&self, matches: F) -> bool {
> +        if self.is_empty() {
> +            return true;
> +        }
> +
> +        if self.required_terms.iter().map(&matches).any(|f| !f) {
> +            return false;
> +        }
> +
> +        if !self.optional_terms.is_empty() && self.optional_terms.iter().map(&matches).all(|f| !f) {
> +            return false;
> +        }
> +
> +        true
> +    }
> +
> +    /// Returns true if the combination of [`SearchTerm`]s require that this category value must be
> +    /// true. Useful to find out if some condition is required (e.g. type == 'remote')
> +    pub fn category_value_required(&self, category: &str, value: &str) -> bool {
> +        for term in &self.required_terms {
> +            if term.category.as_deref() == Some(category) && value.contains(&term.value) {

Why does this use `.contains()` but the loop over optional ones uses
`==`?

> +                return true;
> +            }
> +        }
> +
> +        let mut optional_count = 0;
> +
> +        for term in &self.optional_terms {
> +            if term.category.as_deref() == Some(category) && term.value == value {
> +                optional_count += 1;
> +            }
> +        }
> +
> +        self.required_terms.is_empty()
> +            && self.optional_terms.len() == optional_count
> +            && optional_count > 0
> +    }
> +}
> +
> +impl std::fmt::Display for Search {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        for (count, term) in self.required_terms.iter().enumerate() {
> +            if count != 0 {
> +                write!(f, " ")?;
> +            }
> +
> +            write!(f, "{term}")?;
> +        }

personal opinion: enumerate() to only handle the first element
differently seems a bit much, if you consider that it keeps a counter,
you could keep a "separator" instead which you just update once:

    let mut sep = "";
    for term in &self.required_terms {
        write!(f, "{sep}{term}")?;
        sep = " ";
    }

> +
> +        if !self.required_terms.is_empty() && !self.optional_terms.is_empty() {
> +            write!(f, " ")?;
> +        }

↑ This could then be skipped entirely and we can reuse `sep` inside the
lower loop the same way - if no previous text was there, the first
element will still be using an empty string for `sep` before updating it
to be `" "` for the remaining elements...

Then the entire implementation is just:
    let mut sep = "";
    for term in &self.required_terms {
        write!(f, "{sep}{term}")?;
        sep = " ";
    }
    for term in &self.optional_terms {
        write!(f, "{sep}{term}")?;
        sep = " ";
    }

of if you're feeling fancy:

    let mut sep = "";
    for term in self.required_terms.iter().chain(self.optional_terms.iter()) {
        write!(f, "{sep}{term}")?;
        sep = " ";
    }

> +
> +        for (count, term) in self.optional_terms.iter().enumerate() {
> +            if count != 0 {
> +                write!(f, " ")?;
> +            }
> +
> +            write!(f, "{term}")?;
> +        }
> +
> +        Ok(())
> +    }
> +}
> +
> +#[derive(Debug, Clone, PartialEq)]
> +pub struct SearchTerm {
> +    optional: bool,
> +    pub value: String,
> +    pub category: Option<String>,
> +}
> +
> +impl SearchTerm {
> +    /// Creates a new [`SearchTerm`].
> +    pub fn new<S: Into<String>>(term: S) -> Self {

↑ use `Into<String>`, `ToString` will cause an unnecessary clone when
passing an already owned `String` as it is implemented via `Display`.

> +        Self {
> +            value: term.to_string(),
> +            optional: false,
> +            category: None,
> +        }
> +    }
> +
> +    /// Builder style method to set the category
> +    pub fn category<S: ToString>(mut self, category: Option<S>) -> Self {
> +        self.category = category.map(|s| s.to_string());
> +        self
> +    }
> +
> +    /// Builder style method to mark this [`SearchTerm`] as optional
> +    pub fn optional(mut self, optional: bool) -> Self {
> +        self.optional = optional;
> +        self
> +    }
> +}
> +
> +impl FromStr for SearchTerm {
> +    type Err = anyhow::Error;
> +
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {

↑ use: (mut term: &str)

> +        let mut optional = true;
> +        let mut term: String = s.into();

↑ skip the premature allocation ;-)

> +        if term.starts_with("+") {
> +            optional = false;
> +            term.remove(0);
> +        }

↑
    if let Some(rest) = term.strip_prefix("+") {
        term = rest;
        optional = false;
    }

> +
> +        let (term, category) = if let Some(idx) = term.find(":") {
> +            let mut real_term = term.split_off(idx);
> +            real_term.remove(0); // remove ':'

↑ 2 expensive avoidable ops, see below

> +            (real_term, Some(term))
> +        } else {
> +            (term, None)
> +        };

↑
    let category;
    (term, category) = match term.split_once(':') {
        Some((category, term)) => (term, Some(category)),
        None => (term, None),
    }

> +
> +        if term.is_empty() {
> +            bail!("term cannot be empty");
> +        }
> +
> +        if category == Some("".into()) {

↑ Can avoid this (also, `String::new()` would be cheaper) via:

    if category.is_some_and(str::is_empty) {


> +            bail!("category cannot be empty");
> +        }
> +
> +        Ok(SearchTerm::new(term).optional(optional).category(category))
> +    }
> +}
> +
> +impl std::fmt::Display for SearchTerm {
> +    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
> +        if !self.optional {
> +            f.write_str("+")?;
> +        }
> +
> +        if let Some(cat) = &self.category {
> +            f.write_str(cat)?;
> +            f.write_str(":")?;
> +        }
> +
> +        f.write_str(&self.value)
> +    }
> +}
> +
> +#[cfg(test)]
> +mod tests {
> +    use crate::{Search, SearchTerm};
> +
> +    #[test]
> +    fn parse_test_simple_filter() {
> +        assert_eq!(
> +            "foo".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("foo").optional(true),
> +        );
> +    }
> +
> +    #[test]
> +    fn parse_test_requires_filter() {
> +        assert_eq!(
> +            "+foo".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("foo"),
> +        );
> +    }
> +
> +    #[test]
> +    fn parse_test_category_filter() {
> +        assert_eq!(
> +            "foo:bar".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("bar").optional(true).category(Some("foo"))
> +        );
> +        assert_eq!(
> +            "+foo:bar".parse::<SearchTerm>().unwrap(),
> +            SearchTerm::new("bar").category(Some("foo"))
> +        );
> +    }
> +
> +    #[test]
> +    fn parse_test_invalid_filter() {
> +        assert!(":bar".parse::<SearchTerm>().is_err());
> +        assert!("+cat:".parse::<SearchTerm>().is_err());
> +        assert!("+".parse::<SearchTerm>().is_err());
> +        assert!(":".parse::<SearchTerm>().is_err());
> +    }
> +
> +    #[test]
> +    fn match_tests() {
> +        let search = Search::from_iter(vec![
> +            SearchTerm::new("required1").optional(false),
> +            SearchTerm::new("required2").optional(false),
> +            SearchTerm::new("optional1").optional(true),
> +            SearchTerm::new("optional2").optional(true),
> +        ]);
> +
> +        // each case contains results for
> +        // required1, required2, optional1, optional2
> +        // and if it should match or not
> +        let cases = [
> +            ((true, true, true, false), true),
> +            ((true, true, false, true), true),
> +            ((true, true, true, true), true),
> +            ((true, true, false, false), false),
> +            ((true, false, false, false), false),
> +            ((false, false, false, false), false),
> +            ((false, true, false, false), false),
> +            ((false, false, true, true), false),
> +            ((false, true, true, true), false),
> +            ((true, false, true, true), false),
> +            ((true, false, true, false), false),
> +            ((false, true, true, false), false),
> +            ((false, false, true, false), false),
> +            ((true, false, false, true), false),
> +            ((false, true, false, true), false),
> +            ((false, false, false, true), false),
> +        ];
> +        for (input, expected) in cases {
> +            assert!(
> +                search.matches(|term| {
> +                    match term.value.as_str() {
> +                        "required1" => input.0,
> +                        "required2" => input.1,
> +                        "optional1" => input.2,
> +                        "optional2" => input.3,
> +                        _ => unreachable!(),
> +                    }
> +                }) == expected
> +            )
> +        }
> +    }
> +
> +    #[test]
> +    fn category_value_required() {
> +        let search: Search = Search::from_iter(vec![SearchTerm::new("foo")]);
> +        assert!(!search.category_value_required("bar", "baz"));
> +
> +        let search: Search = Search::from_iter(vec![SearchTerm::new("foo").optional(true)]);
> +        assert!(!search.category_value_required("bar", "baz"));
> +
> +        let search: Search = Search::from_iter(vec![SearchTerm::new("foo").category(Some("bar"))]);
> +        assert!(!search.category_value_required("bar", "baz"));
> +
> +        let search: Search = Search::from_iter(vec![SearchTerm::new("baz").category(Some("bar"))]);
> +        assert!(search.category_value_required("bar", "baz"));
> +
> +        let search: Search = Search::from_iter(vec![SearchTerm::new("foo")
> +            .optional(true)
> +            .category(Some("bar"))]);
> +        assert!(!search.category_value_required("bar", "baz"));
> +
> +        let search: Search = Search::from_iter(vec![SearchTerm::new("baz")
> +            .optional(true)
> +            .category(Some("bar"))]);
> +        assert!(search.category_value_required("bar", "baz"));
> +
> +        let search: Search = Search::from_iter(vec![
> +            SearchTerm::new("baz").optional(true).category(Some("bar")),
> +            SearchTerm::new("foo").optional(true),
> +        ]);
> +        assert!(!search.category_value_required("bar", "baz"));
> +    }
> +
> +    #[test]
> +    fn test_display() {
> +        let term = SearchTerm::new("foo");
> +        assert_eq!("+foo", &term.to_string());
> +
> +        let term = SearchTerm::new("foo").optional(true);
> +        assert_eq!("foo", &term.to_string());
> +
> +        let term = SearchTerm::new("foo").optional(false);
> +        assert_eq!("+foo", &term.to_string());
> +
> +        let term = SearchTerm::new("foo").category(Some("bar"));
> +        assert_eq!("+bar:foo", &term.to_string());
> +
> +        let term = SearchTerm::new("foo").optional(true).category(Some("bar"));
> +        assert_eq!("bar:foo", &term.to_string());
> +
> +        let term = SearchTerm::new("foo").optional(false).category(Some("bar"));
> +        assert_eq!("+bar:foo", &term.to_string());
> +    }
> +}
> -- 
> 2.47.2


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel

  reply	other threads:[~2025-09-02 15:33 UTC|newest]

Thread overview: 20+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-08-28 13:15 [pdm-devel] [PATCH datacenter-manager v4 00/10] implement more complex search syntax Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 01/10] pdm-api-types: resources: add helper methods for fields Dominik Csapak
2025-09-02 14:29   ` Wolfgang Bumiller
2025-09-03  7:38     ` Dominik Csapak
2025-09-03  7:53       ` Wolfgang Bumiller
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 02/10] lib: add pdm-search crate Dominik Csapak
2025-09-02 15:33   ` Wolfgang Bumiller [this message]
2025-09-03  8:14     ` Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 03/10] server: api: resources: add more complex filter syntax Dominik Csapak
2025-08-28 13:53   ` Shannon Sterz
2025-08-28 14:18     ` Dominik Csapak
2025-09-03  8:49   ` Wolfgang Bumiller
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 04/10] ui: add possibility to insert into search box Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 05/10] ui: dashboard: remotes panel: open search on click Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 06/10] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 07/10] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 08/10] ui: search box: add clear trigger Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 09/10] ui: dashboard: guest panel: use `List` instead of `DataTable` Dominik Csapak
2025-08-28 13:16 ` [pdm-devel] [PATCH datacenter-manager v4 10/10] ui: dashboard: guest panel: add search icon for better discoverability Dominik Csapak
2025-09-03 13:34 ` [pdm-devel] superseded: [PATCH datacenter-manager v4 00/10] implement more complex search syntax Dominik Csapak

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=5zrkhdsc4sw2jsqlqrn7ijbj2oyav6bcpa7f42ybawwrv6zu4a@kjhewrghg7xa \
    --to=w.bumiller@proxmox.com \
    --cc=d.csapak@proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal