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