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:
Guilhem
2026-08-12 21:10:56 +02:00
committed by GitHub
parent 84f3b0094d
commit 603b2012a7
3 changed files with 120 additions and 7 deletions
@@ -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(())
}
+9 -1
View File
@@ -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"
+56 -6
View File
@@ -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(',') {