From: Dominik Csapak <d.csapak@proxmox.com>
To: Stefan Hanreich <s.hanreich@proxmox.com>,
Proxmox Datacenter Manager development discussion
<pdm-devel@lists.proxmox.com>
Subject: Re: [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate
Date: Tue, 26 Aug 2025 14:23:40 +0200 [thread overview]
Message-ID: <992c730b-ed50-4338-bc85-5ab7c6c8ea1a@proxmox.com> (raw)
In-Reply-To: <18d57db1-a6fc-43cd-a8ca-fb04fabd8cb4@proxmox.com>
On 8/25/25 3:14 PM, Stefan Hanreich wrote:
> some comments inline, I think the FromIterator<Item = SearchTerm> would
> be a nice improvement, the rest is just potential for future / follow-ups.
>
> On 8/25/25 11:01 AM, 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.
>>
>> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
>> ---
>> Cargo.toml | 2 +
>> lib/pdm-search/Cargo.toml | 12 ++
>> lib/pdm-search/src/lib.rs | 259 ++++++++++++++++++++++++++++++++++++++
>> 3 files changed, 273 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 08b9373..236f00b 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..8d6cca3
>> --- /dev/null
>> +++ b/lib/pdm-search/src/lib.rs
>> @@ -0,0 +1,259 @@
>> +//! 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::{fmt::Display, str::FromStr};
>> +
>> +use anyhow::bail;
>> +
>> +#[derive(Clone)]
>
> implement Default as well?
>
>> +pub struct Search {
>> + required_terms: Vec<SearchTerm>,
>> + optional_terms: Vec<SearchTerm>,
>> +}
>> +
>> +impl<S: AsRef<str>> From<S> for Search {
>> + fn from(value: S) -> Self {
>> + let mut optional_terms = Vec::new();
>> + let mut required_terms = Vec::new();
>> + for term in value.as_ref().split_whitespace() {
>> + match term.parse::<SearchTerm>() {
>> + Ok(term) => {
>> + if term.optional {
>> + optional_terms.push(term)
>> + } else {
>> + required_terms.push(term)
>> + }
>> + }
>> + Err(_) => {} // ignore invalid search terms
>> + }> + }
>> +
>> + Self {
>> + required_terms,
>> + optional_terms,
>> + }
>> + }
>> +}
>
> this implementation could use a potential FromIterator implementation
> for Search, avoiding the duplicated code shared w/ with_terms?
>
> i.e.
>
> value.split_whitespace().filter_map(|term| term.parse().ok()).collect()
>
>> +
>> +impl Search {
>> + pub fn new() -> Self {
>> + Self::with_terms(Vec::new())
>> + }
>
> use Default::default() here then? (see next mail for further reasoning).
>
>> +
>> + pub fn is_empty(&self) -> bool {
>> + self.required_terms.is_empty() && self.optional_terms.is_empty()
>> + }
>> +
>> + pub fn with_terms(terms: Vec<SearchTerm>) -> Self {
>> + let mut optional_terms = Vec::new();
>> + let mut required_terms = Vec::new();
>> +
>> + for term in terms {
>> + if term.optional {
>> + optional_terms.push(term);
>> + } else {
>> + required_terms.push(term);
>> + }
>> + }
>
> nit: partition could be used, although in the future there might be more
> than required / optional.
>
>> +
>> + Self {
>> + optional_terms,
>> + required_terms,
>> + }
>> + }
>
> potentially moving this into a FromIterator<Item = SearchTerm>
> implementation would be more appropriate?
>
> Potentially a FromIterator<Item = (String, String)> or FromIterator<Item
> = (String, Option<String>)> might also be interesting (with respective
> From implementations for SearchTerm)?
>
> From<SearchTerm> might also be interesting? or even a From<T: impl
> Into<SearchTerm>>
>
>> +
>> + /// 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;
>> + }
>> +
>> + let optional_matches: Vec<bool> = self.optional_terms.iter().map(&matches).collect();
>> + let required_matches: Vec<bool> = self.required_terms.iter().map(&matches).collect();
>> +
>> + if !required_matches.is_empty() && required_matches.iter().any(|f| !f) {
>> + return false;
>> + }
>> +
>> + if !optional_matches.is_empty() && optional_matches.iter().all(|f| !f) {
>> + return false;
>> + }
>> +
>> + true
>> + }
>
> nit: we could avoid collecting altogether and shortcircuit, removing the
> need for calling is_empty twice as well?
>
> if !self.optional_matches.is_empty() &&
> optional_matches.iter().map(&matches).all(..)
>
> for required_matches we don't even need the empty check since any is
> always false for empty collections
>
>> +
>> + /// 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) {
>> + 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 Default for Search {
>> + fn default() -> Self {
>> + Self::new()
>> + }
>> +}
>> +
>> +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}")?;
>> + }
>> +
>> + if !self.required_terms.is_empty() && !self.optional_terms.is_empty() {
>> + write!(f, " ")?;
>> + }
>> +
>> + 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 {
>
> are spaces potentially a problem here?
>
since we use 'split_whitespace' when we convert from the search
string to the terms, it shouldn't be?
isn't be able to generate a SearchTerm directly, so I don't
see how it's currently a problem
>> + Self {
>> + value: term.into(),
>> + optional: false,
>> + category: None,
>> + }
>> + }
>> +
>> + pub fn category<S: Into<String>>(mut self, category: Option<S>) -> Self {
>
> same remark w.r.t. spaces
>
> Is ToString potentially better as trait bound here, since I often see
> .to_string() at the call sites?
>
> I usually prefer impl Into<Option<>> for functions taking optional
> values, since it avoids having to type out Some(_) everywhere, but in
> this case with Into<String> this breaks type inference when passing None.
yeah Into<Option> does not work for a trait type (without manually
specifying the type when using none
but yeah, i can try ToString, i wanted to avoid the extra copying in
case we can convert the object into a string
>
>> + self.category = category.map(|s| s.into());
>> + self
>> + }
>> +
>> + 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> {
>> + let mut optional = true;
>> + let mut term: String = s.into();
>> + if term.starts_with("+") {
>> + optional = false;
>> + term.remove(0);
>> + }
>> +
>> + let (term, category) = if let Some(idx) = term.find(":") {
>> + let mut real_term = term.split_off(idx);
>> + real_term.remove(0); // remove ':'
>> + (real_term, Some(term))
>> + } else {
>> + (term, None)
>> + };
>> +
>> + if term.is_empty() {
>> + bail!("term cannot be empty");
>> + }
>> +
>> + if category == Some("".into()) {
>> + 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::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".into()))
>> + );
>> + assert_eq!(
>> + "+foo:bar".parse::<SearchTerm>().unwrap(),
>> + SearchTerm::new("bar").category(Some("foo".into()))
>> + );
>> + }
>> +
>> + #[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());
>> + }
>> +}
>
>
_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel
next prev parent reply other threads:[~2025-08-26 12:24 UTC|newest]
Thread overview: 15+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-08-25 8:58 [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 1/9] pdm-api-types: resources: add helper methods for fields Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 2/9] lib: add pdm-search crate Dominik Csapak
2025-08-25 13:14 ` Stefan Hanreich
2025-08-26 12:23 ` Dominik Csapak [this message]
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 3/9] server: api: resources: add more complex filter syntax Dominik Csapak
2025-08-25 13:14 ` Stefan Hanreich
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 4/9] ui: add possibility to insert into search box Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 5/9] ui: dashboard: remotes panel: open search on click Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 6/9] ui: dashboard: guest panel: search for guest states when clicking on them Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 7/9] ui: dashboard: search for nodes when clicking on the nodes panel Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 8/9] ui: search box: add clear trigger Dominik Csapak
2025-08-25 8:58 ` [pdm-devel] [PATCH datacenter-manager v2 9/9] ui: dashboard: guest panel: improve column widths Dominik Csapak
2025-08-25 13:48 ` [pdm-devel] [PATCH datacenter-manager v2 0/9] implement more complex search syntax Stefan Hanreich
2025-08-26 12:36 ` [pdm-devel] superseded: " 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=992c730b-ed50-4338-bc85-5ab7c6c8ea1a@proxmox.com \
--to=d.csapak@proxmox.com \
--cc=pdm-devel@lists.proxmox.com \
--cc=s.hanreich@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