mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
fix: home search matches each term instead of the whole query verbatim (#10663)
* fix: home search matches each term instead of the whole query verbatim * docs: state the search term cap and drop unreachable test cases * fix: treat a term-less search as no filter and trim the comment * fix: a term-less search matches nothing instead of the whole page * feat: match the homepage fuzzy search exactly in the runnables endpoint * docs: say apostrophes stay in terms; test summary-less and draft rows * docs: separate an empty search from one holding no terms * docs: state that terms split on ASCII alphanumerics only
This commit is contained in:
@@ -379,6 +379,22 @@ async fn test_runnables_search_and_kind_filters(db: Pool<Postgres>) -> anyhow::R
|
||||
"search must substring-match the summary only"
|
||||
);
|
||||
|
||||
// Terms may sit apart and span the summary and the path — what the homepage's
|
||||
// fuzzy ranking accepts but a single contiguous ILIKE would withhold.
|
||||
assert_eq!(
|
||||
list_once(port, "search=deploy%20one").await,
|
||||
vec!["script:f/alpha/one".to_string()],
|
||||
"terms may sit apart, in the summary and the path"
|
||||
);
|
||||
|
||||
// The three rules that bound it, each pinned by a query that must return nothing:
|
||||
// terms hold their order, every term must appear, and a query of pure separators
|
||||
// is a search that matches nothing rather than no search at all.
|
||||
for query in ["search=one%20deploy", "search=deploy%20beta", "search=%20"] {
|
||||
let none = list_once(port, query).await;
|
||||
assert!(none.is_empty(), "{query} must match nothing, got {none:?}");
|
||||
}
|
||||
|
||||
// kinds filter selects a single kind.
|
||||
let flows = list_once(port, "kinds=flow").await;
|
||||
assert_eq!(flows, vec!["flow:f/alpha/flowy".to_string()], "kinds=flow");
|
||||
@@ -390,6 +406,45 @@ async fn test_runnables_search_and_kind_filters(db: Pool<Postgres>) -> anyhow::R
|
||||
4,
|
||||
"kinds=script -> 4 scripts, got {scripts:?}"
|
||||
);
|
||||
|
||||
// Seeded last: the counts above are asserted exactly.
|
||||
// Without a summary the haystack is the bare path, so such a row stays findable.
|
||||
let r = authed(
|
||||
client().post(format!("{base}/scripts/create")),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&new_script("f/alpha/unsummarized", ""))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(r.status(), 201, "create: {}", r.text().await?);
|
||||
assert_eq!(
|
||||
list_once(port, "search=alpha%20unsummarized").await,
|
||||
vec!["script:f/alpha/unsummarized".to_string()],
|
||||
"a row with no summary is searchable by its path"
|
||||
);
|
||||
|
||||
// A draft is named by the path typed in the editor, so that is what a search has
|
||||
// to match — never the generated `draft_<uuid>` it is parked at.
|
||||
let r = authed(
|
||||
client().post(format!(
|
||||
"{base}/drafts/update/script/u/test-user/draft_9f2a"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&json!({ "value": { "path": "f/beta/typed_name", "summary": "" } }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(r.status(), 200, "save draft: {}", r.text().await?);
|
||||
assert_eq!(
|
||||
list_once(port, "search=beta%20typed&include_draft_only=true").await,
|
||||
vec!["script:u/test-user/draft_9f2a".to_string()],
|
||||
"a draft-only row is searchable by its typed path"
|
||||
);
|
||||
let by_storage_path = list_once(port, "search=9f2a&include_draft_only=true").await;
|
||||
assert!(
|
||||
by_storage_path.is_empty(),
|
||||
"the draft_<uuid> path is not searchable, got {by_storage_path:?}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -10964,7 +10964,15 @@ paths:
|
||||
type: string
|
||||
- name: search
|
||||
in: query
|
||||
description: case-insensitive substring match on summary or path
|
||||
description: >-
|
||||
case-insensitive fuzzy match on "summary (path)": the query is split into
|
||||
terms on runs of anything but ASCII letters, digits and apostrophes, and
|
||||
each of the first 8 must appear whole and in order, with anything in
|
||||
between. Terms past the 8th are ignored, so an over-long query matches more
|
||||
rows rather than fewer. Omitted or empty filters nothing; a query holding no
|
||||
ASCII-alphanumeric character at all (a lone space, "_", or text in a
|
||||
non-Latin script) yields no terms and matches nothing, mirroring the
|
||||
homepage, whose matcher discards those queries too.
|
||||
schema:
|
||||
type: string
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
|
||||
@@ -59,7 +59,12 @@ struct ListRunnablesQuery {
|
||||
path_start: Option<String>,
|
||||
/// Comma-separated labels; a row matches if it (or its folder) carries all.
|
||||
label: Option<String>,
|
||||
/// Case-insensitive substring match on summary or path.
|
||||
/// Case-insensitive fuzzy match on `summary (path)`, mirroring how the homepage
|
||||
/// ranks: split into terms on anything but ASCII letters, digits and apostrophes,
|
||||
/// then every term must appear whole and in order, with anything in between. Only
|
||||
/// the first `MAX_SEARCH_TERMS` apply. Omitted or empty filters nothing; a query
|
||||
/// that holds no ASCII-alphanumeric character at all — `" "`, `"_"`, `"привет"` —
|
||||
/// yields no terms and matches nothing, as it does on the homepage.
|
||||
search: Option<String>,
|
||||
per_page: Option<usize>,
|
||||
/// Opaque keyset cursor from a previous page's `next_cursor`.
|
||||
@@ -167,6 +172,28 @@ fn decode_cursor(raw: &str) -> Result<Cursor, Error> {
|
||||
serde_json::from_slice(&bytes).map_err(|_| Error::BadRequest("invalid cursor".to_string()))
|
||||
}
|
||||
|
||||
/// Upper bound on the terms one search query contributes to the pattern, so a
|
||||
/// pasted paragraph cannot grow it without limit.
|
||||
const MAX_SEARCH_TERMS: usize = 8;
|
||||
|
||||
/// Split a query into terms on runs of anything that is not an ASCII letter, digit
|
||||
/// or apostrophe — the rule the homepage's fuzzy matcher uses, so `f/foo_bar` looks
|
||||
/// for `foo` then `bar` rather than for the punctuation between them.
|
||||
fn search_terms(search: &str) -> Vec<&str> {
|
||||
search
|
||||
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '\''))
|
||||
.filter(|t| !t.is_empty())
|
||||
.take(MAX_SEARCH_TERMS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The string a search matches against: `summary (path)`, or the bare path when
|
||||
/// there is no summary — what the homepage concatenates before ranking, so both
|
||||
/// halves are searchable as one and a query may span them.
|
||||
fn searchable_name(path_expr: &str) -> String {
|
||||
format!("COALESCE(NULLIF(o.summary, '') || ' (' || {path_expr} || ')', {path_expr})")
|
||||
}
|
||||
|
||||
/// Escape LIKE/ILIKE wildcards so a caller value (search term, path/scope
|
||||
/// prefix) matches literally. Relies on the default `\` escape character.
|
||||
fn escape_like(s: &str) -> String {
|
||||
@@ -406,11 +433,34 @@ async fn list_runnables(
|
||||
draft_common.push(format!("COALESCE(o.draft_path, o.path) LIKE {}", p));
|
||||
}
|
||||
if let Some(search) = q.search.as_ref().filter(|s| !s.is_empty()) {
|
||||
let p = add_bind(&mut binds, format!("%{}%", escape_like(search)));
|
||||
common.push(format!("(o.summary ILIKE {p} OR o.path ILIKE {p})"));
|
||||
draft_common.push(format!(
|
||||
"(o.summary ILIKE {p} OR o.path ILIKE {p} OR o.draft_path ILIKE {p})"
|
||||
));
|
||||
let terms = search_terms(search);
|
||||
if terms.is_empty() {
|
||||
// A search of nothing but separators is still a search: it must match nothing,
|
||||
// where no predicate at all would answer it with the whole workspace.
|
||||
common.push("false".to_string());
|
||||
draft_common.push("false".to_string());
|
||||
} else {
|
||||
// `%` between the terms is what makes them a fuzzy match rather than a literal
|
||||
// one: each must appear whole, in this order, with anything in between. The
|
||||
// haystack is the summary-and-path string the homepage matches on, so a query
|
||||
// may span the two and the endpoint withholds nothing the homepage would rank.
|
||||
let p = add_bind(
|
||||
&mut binds,
|
||||
format!(
|
||||
"%{}%",
|
||||
terms
|
||||
.iter()
|
||||
.map(|t| escape_like(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join("%")
|
||||
),
|
||||
);
|
||||
common.push(format!("{} ILIKE {p}", searchable_name("o.path")));
|
||||
draft_common.push(format!(
|
||||
"{} ILIKE {p}",
|
||||
searchable_name("COALESCE(o.draft_path, o.path)")
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(label) = q.label.as_ref().filter(|s| !s.is_empty()) {
|
||||
for l in label.split(',') {
|
||||
|
||||
Reference in New Issue
Block a user