From 959d813630b7756c8e0ffcd7cf43bd48ef652e31 Mon Sep 17 00:00:00 2001 From: Sonetto Date: Mon, 8 Jun 2026 18:26:35 +0800 Subject: [PATCH] feat: pg_trgm full-text search for posts (#2) * feat(db): add pg_trgm search index on posts * feat(api): use pg_trgm similarity search for posts * fix(api): use ILIKE + word_similarity instead of % operator for search --- migrations/003_search_trgm.sql | 11 +++++++++++ src/api/posts.rs | 17 +++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 migrations/003_search_trgm.sql diff --git a/migrations/003_search_trgm.sql b/migrations/003_search_trgm.sql new file mode 100644 index 0000000..dc100bb --- /dev/null +++ b/migrations/003_search_trgm.sql @@ -0,0 +1,11 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +ALTER TABLE posts ADD COLUMN IF NOT EXISTS search_text TEXT + GENERATED ALWAYS AS ( + COALESCE(title, '') || ' ' || + COALESCE(summary, '') || ' ' || + COALESCE(content_md, '') + ) STORED; + +CREATE INDEX IF NOT EXISTS idx_posts_search_trgm + ON posts USING GIN (search_text gin_trgm_ops); diff --git a/src/api/posts.rs b/src/api/posts.rs index f66e82d..0d25923 100644 --- a/src/api/posts.rs +++ b/src/api/posts.rs @@ -801,22 +801,27 @@ pub async fn get_post_stats() -> Result { pub async fn search_posts(query: String) -> Result { let client = get_conn().await.map_err(db_conn_error)?; - let search_pattern = format!("%{}%", query); + let q = query.trim(); + if q.is_empty() { + return Ok(PostListResponse { posts: Vec::new() }); + } let rows = client .query( "SELECT p.id, p.author_id, p.title, p.slug, p.summary, p.content_md, p.content_html, p.status, p.published_at, p.created_at, p.updated_at, p.cover_image, - COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags + COALESCE(array_agg(t.name) FILTER (WHERE t.name IS NOT NULL), '{}') as tags, + word_similarity(p.search_text, $1) AS sml FROM posts p LEFT JOIN post_tags pt ON p.id = pt.post_id LEFT JOIN tags t ON pt.tag_id = t.id WHERE p.status = 'published' AND p.deleted_at IS NULL - AND (p.title ILIKE $1 OR p.content_md ILIKE $1) - GROUP BY p.id - ORDER BY p.published_at DESC", - &[&search_pattern], + AND p.search_text ILIKE '%' || $1 || '%' + GROUP BY p.id, p.search_text + ORDER BY sml DESC, p.published_at DESC + LIMIT 50", + &[&q], ) .await .map_err(query_error)?;