From 7852b1670c7588875fc43ef2613c8348893d5ab6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 15 Oct 2026 18:02:10 -0400 Subject: [PATCH] fix(search): normalize profile name tiers in one place (#8334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit đŸ¤– ## Summary Profile name search (`POST /profiles/search`, added in #8301) sorts results into tiers: an exact name first, then a name that starts with the query, then a name with a later word that starts with it. This PR stacks on #8301 and fixes two review items. **Tier normalization in one place.** Before this change, the query was lower-cased in Rust (`to_lowercase`), and the names were lower-cased in SQL (`lower()`). These can disagree. Under a `C` collation, PostgreSQL's `lower()` changes only ASCII letters, while Rust also lower-cases letters like `É`. So a search for `Émile` did not put the profile named `Émile` in the exact tier. Now the query and both name fields go through the same SQL expression: lower-case, collapse whitespace runs to one space, then trim. The query is bound once and both sides compare equal by construction. The trim also moved after the collapse, so surrounding whitespace that the regex turns into a space is now trimmed too. **Ranking wording.** The doc comment and `docs/profile-search.md` said "ties keep the NIP-50 order". That was wrong. Ties are ordered by `ts_rank_cd` over the two name fields only (not the whole profile, as in NIP-50), then newest first, then event id. Out of scope, not changed here: matching names that contain escaped Unicode or newlines, and any new name index. ## Related issue Review on #8301: https://github.com/block/buzz/pull/8301#pullrequestreview-5524491230 ## Testing - New case in `profile_names_match_name_words_only`: profiles `Émile` and a newer `Émilee`, query `Émile`. The exact match must come first. - Ran the Postgres integration tests against a `postgres:17-alpine` database created with `--locale=C`: - this branch: `profile_names_match_name_words_only` and `profile_names_survive_unparsable_content` pass; - #8301 code with the new case: `profile_names_match_name_words_only` fails (`Émilee` sorts before `Émile`). - Nine other tests in that file fail locally on both this branch and #8301 because they reuse one database (duplicate community hosts). CI gives each test its own database. - `cargo fmt --check`, `cargo clippy -p buzz-search --all-targets -D warnings`, and `cargo test -p buzz-search`: clean. - The pre-push hook's `rust-tests` and `desktop-tauri-checks` passed. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- crates/buzz-search/src/query.rs | 54 +++++++++++-------- .../tests/postgres_fts_integration.rs | 9 ++++ docs/profile-search.md | 7 ++- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index ce75a7ea7a..ec2ce027ea 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -356,6 +356,14 @@ pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result String { + format!("{}{column}{}", TIER_TEXT[0], TIER_TEXT[1]) +} + /// Largest page size [`search_profile_names`] accepts. pub const PROFILE_NAMES_PER_PAGE_MAX: u32 = 100; @@ -369,9 +377,12 @@ pub const PROFILE_NAMES_PER_PAGE_MAX: u32 = 100; /// names; the existing `search_tsv` index stays the access path. /// /// Order: a name equal to the query, then a name that starts with it, then a -/// name with a word that starts with it, then any other match. Ties keep the -/// NIP-50 order (rank, newest first, id), so pages are stable. Pages are -/// 1-based, like [`SearchQuery::page`]. +/// name with a word that starts with it, then any other match. The query and +/// names are compared lower-cased with whitespace collapsed, both normalized +/// by the same SQL. Ties go by relevance of the query to the two names +/// (`ts_rank_cd` over the names alone, not the whole profile), then newest +/// first, then event id, so pages are stable. Pages are 1-based, like +/// [`SearchQuery::page`]. #[datastore_span(name = "search_profile_names", system = "postgresql")] pub async fn search_profile_names( pool: &PgPool, @@ -389,14 +400,8 @@ pub async fn search_profile_names( }; let per_page = per_page.clamp(1, PROFILE_NAMES_PER_PAGE_MAX); let offset = ((page - 1) as i64) * (per_page as i64); - // Tiers compare the query as typed, with whitespace runs collapsed, to - // each lower-cased name (spaces collapsed too). - let typed = search_text - .split_whitespace() - .collect::>() - .join(" ") - .to_lowercase(); - + // Tiers compare the query and each name after the same SQL normalization + // ([`TIER_TEXT`]), so the two sides agree by construction. let mut qb: QueryBuilder = QueryBuilder::new( "SELECT id, kind, pubkey, channel_id, created_at_s, rank FROM (\ SELECT e.id, e.kind, e.pubkey, e.channel_id, \ @@ -405,13 +410,11 @@ pub async fn search_profile_names( LEAST(", ); for field in ["names.name", "names.display_name"] { - qb.push(format!("CASE WHEN {field} = ")); - qb.push_bind(typed.clone()); - qb.push(format!(" THEN 0 WHEN starts_with({field}, ")); - qb.push_bind(typed.clone()); - qb.push(format!(") THEN 1 WHEN strpos({field}, ' ' || ")); - qb.push_bind(typed.clone()); - qb.push(") > 0 THEN 2 ELSE 3 END"); + qb.push(format!( + "CASE WHEN {field} = search_query.typed THEN 0 \ + WHEN starts_with({field}, search_query.typed) THEN 1 \ + WHEN strpos({field}, ' ' || search_query.typed) > 0 THEN 2 ELSE 3 END" + )); if field == "names.name" { qb.push(", "); } @@ -421,15 +424,20 @@ pub async fn search_profile_names( FROM events e CROSS JOIN LATERAL (SELECT ", ); push_tsquery(&mut qb, SearchMode::Prefix, &search_text); - qb.push( - " AS query) AS search_query \ + qb.push(" AS query, "); + qb.push(TIER_TEXT[0]); + qb.push_bind(search_text.clone()); + qb.push(TIER_TEXT[1]); + qb.push(format!( + " AS typed) AS search_query \ CROSS JOIN LATERAL profile_name_fields(e.content) AS fields \ CROSS JOIN LATERAL (SELECT \ to_tsvector('simple', concat_ws(' ', fields.name, fields.display_name)) AS tsv, \ - regexp_replace(lower(btrim(fields.name)), '\\s+', ' ', 'g') AS name, \ - regexp_replace(lower(btrim(fields.display_name)), '\\s+', ' ', 'g') AS display_name) AS names \ + {} AS name, {} AS display_name) AS names \ WHERE e.community_id = ", - ); + tier_name("fields.name"), + tier_name("fields.display_name"), + )); qb.push_bind(*community.as_uuid()); qb.push( " AND e.kind = 0 AND e.channel_id IS NULL AND e.deleted_at IS NULL \ diff --git a/crates/buzz-search/tests/postgres_fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs index cb859b445e..4fe4b69b1b 100644 --- a/crates/buzz-search/tests/postgres_fts_integration.rs +++ b/crates/buzz-search/tests/postgres_fts_integration.rs @@ -1730,6 +1730,15 @@ async fn profile_names_match_name_words_only() { assert!(profile_names(&pool, c, "%", 1, 100).await.is_empty()); assert!(profile_names(&pool, c, " ", 1, 100).await.is_empty()); + // The query and the names are normalized by the same SQL, so a non-ASCII + // exact name is tier 0 whatever the database collation lower-cases. + let emile = put_profile(&pool, c, r#"{"name":"Émile"}"#, 1_700_000_200).await; + let emilee = put_profile(&pool, c, r#"{"name":"Émilee"}"#, 1_700_000_210).await; + assert_eq!( + profile_names(&pool, c, "Émile", 1, 100).await, + vec![emile, emilee] + ); + teardown(pool, &schema).await; } diff --git a/docs/profile-search.md b/docs/profile-search.md index 33f91ca029..4373aa432d 100644 --- a/docs/profile-search.md +++ b/docs/profile-search.md @@ -42,8 +42,11 @@ Results are ordered: 3. a name with a later word that starts with the query; 4. any other match. -Ties keep the NIP-50 order (relevance, newest first, event id), so pages do -not overlap. Profile content that is not a JSON object, or a name field that +For these tiers the query and both names are lower-cased, with whitespace +runs collapsed to one space and trimmed, by the same database expression. +Ties go by relevance of the query to the two names alone (not the whole +profile, as in NIP-50), then newest first, then event id, so pages do not +overlap. Profile content that is not a JSON object, or a name field that is not a string, never matches. Content that PostgreSQL cannot read as `jsonb` (for example a `\u0000` escape or very deep nesting) has no names, so it never matches and never makes the search fail