diff --git a/backend/tests/runnables_list_pagination.rs b/backend/tests/runnables_list_pagination.rs index e9513097cb..663bd6c080 100644 --- a/backend/tests/runnables_list_pagination.rs +++ b/backend/tests/runnables_list_pagination.rs @@ -379,6 +379,22 @@ async fn test_runnables_search_and_kind_filters(db: Pool) -> 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) -> 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_` 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_ path is not searchable, got {by_storage_path:?}" + ); Ok(()) } diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2cd2a508ba..5ac61377df 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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" diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index 58b62eede1..c145252251 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -59,7 +59,12 @@ struct ListRunnablesQuery { path_start: Option, /// Comma-separated labels; a row matches if it (or its folder) carries all. label: Option, - /// 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, per_page: Option, /// Opaque keyset cursor from a previous page's `next_cursor`. @@ -167,6 +172,28 @@ fn decode_cursor(raw: &str) -> Result { 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::>() + .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(',') {