feat: implement full-text search functionality with API integration, add search router and persistence logic, and create related schemas and tests
This commit is contained in:
@@ -5,6 +5,7 @@ pub mod auth;
|
||||
pub mod feed;
|
||||
pub mod friends;
|
||||
pub mod root;
|
||||
pub mod search;
|
||||
pub mod tag;
|
||||
pub mod thought;
|
||||
pub mod user;
|
||||
@@ -30,6 +31,7 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.nest("/feed", create_feed_router())
|
||||
.nest("/tags", tag::create_tag_router())
|
||||
.nest("/friends", friends::create_friends_router())
|
||||
.nest("/search", search::create_search_router())
|
||||
.with_state(state)
|
||||
.layer(cors)
|
||||
}
|
||||
|
53
thoughts-backend/api/src/routers/search.rs
Normal file
53
thoughts-backend/api/src/routers/search.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::{error::ApiError, extractor::OptionalAuthUser};
|
||||
use app::{persistence::search, state::AppState};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use models::schemas::{
|
||||
search::SearchResultsSchema,
|
||||
thought::{ThoughtListSchema, ThoughtSchema},
|
||||
user::UserListSchema,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "",
|
||||
params(SearchQuery),
|
||||
responses((status = 200, body = SearchResultsSchema))
|
||||
)]
|
||||
async fn search_all(
|
||||
State(state): State<AppState>,
|
||||
viewer: OptionalAuthUser,
|
||||
Query(query): Query<SearchQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let viewer_id = viewer.0.map(|u| u.id);
|
||||
|
||||
let (users, thoughts) = tokio::try_join!(
|
||||
search::search_users(&state.conn, &query.q),
|
||||
search::search_thoughts(&state.conn, &query.q, viewer_id)
|
||||
)?;
|
||||
|
||||
let thought_schemas: Vec<ThoughtSchema> =
|
||||
thoughts.into_iter().map(ThoughtSchema::from).collect();
|
||||
|
||||
let response = SearchResultsSchema {
|
||||
users: UserListSchema::from(users),
|
||||
thoughts: ThoughtListSchema::from(thought_schemas),
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub fn create_search_router() -> Router<AppState> {
|
||||
Router::new().route("/", get(search_all))
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
pub mod api_key;
|
||||
pub mod auth;
|
||||
pub mod follow;
|
||||
pub mod search;
|
||||
pub mod tag;
|
||||
pub mod thought;
|
||||
pub mod user;
|
||||
|
65
thoughts-backend/app/src/persistence/search.rs
Normal file
65
thoughts-backend/app/src/persistence/search.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use models::{
|
||||
domains::{thought, user},
|
||||
schemas::thought::ThoughtWithAuthor,
|
||||
};
|
||||
use sea_orm::{
|
||||
prelude::{Expr, Uuid},
|
||||
DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait,
|
||||
Value,
|
||||
};
|
||||
|
||||
use crate::persistence::follow;
|
||||
|
||||
fn is_visible(
|
||||
author_id: Uuid,
|
||||
viewer_id: Option<Uuid>,
|
||||
friend_ids: &[Uuid],
|
||||
visibility: &thought::Visibility,
|
||||
) -> bool {
|
||||
match visibility {
|
||||
thought::Visibility::Public => true,
|
||||
thought::Visibility::Private => viewer_id.map_or(false, |v| v == author_id),
|
||||
thought::Visibility::FriendsOnly => {
|
||||
viewer_id.map_or(false, |v| v == author_id || friend_ids.contains(&author_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_thoughts(
|
||||
db: &DatabaseConnection,
|
||||
query: &str,
|
||||
viewer_id: Option<Uuid>,
|
||||
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||
let mut friend_ids = Vec::new();
|
||||
if let Some(viewer) = viewer_id {
|
||||
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||
}
|
||||
|
||||
// We must join with the user table to get the author's username
|
||||
let thoughts_with_authors = thought::Entity::find()
|
||||
.column_as(user::Column::Username, "author_username")
|
||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||
.filter(Expr::cust_with_values(
|
||||
"thought.search_document @@ websearch_to_tsquery('english', $1)",
|
||||
[Value::from(query)],
|
||||
))
|
||||
.into_model::<ThoughtWithAuthor>() // Convert directly in the query
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// Apply visibility filtering in Rust after the search
|
||||
Ok(thoughts_with_authors
|
||||
.into_iter()
|
||||
.filter(|t| is_visible(t.author_id, viewer_id, &friend_ids, &t.visibility))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn search_users(db: &DatabaseConnection, query: &str) -> Result<Vec<user::Model>, DbErr> {
|
||||
user::Entity::find()
|
||||
.filter(Expr::cust_with_values(
|
||||
"\"user\".search_document @@ websearch_to_tsquery('english', $1)",
|
||||
[Value::from(query)],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
@@ -201,7 +201,7 @@ pub async fn get_thoughts_by_tag_name(
|
||||
Ok(visible_thoughts)
|
||||
}
|
||||
|
||||
fn apply_visibility_filter(
|
||||
pub fn apply_visibility_filter(
|
||||
user_id: Uuid,
|
||||
viewer_id: Option<Uuid>,
|
||||
friend_ids: &[Uuid],
|
||||
|
@@ -11,10 +11,10 @@ mod auth;
|
||||
mod feed;
|
||||
mod friends;
|
||||
mod root;
|
||||
mod search;
|
||||
mod tag;
|
||||
mod thought;
|
||||
mod user;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
nest(
|
||||
@@ -26,6 +26,7 @@ mod user;
|
||||
(path = "/feed", api = feed::FeedApi),
|
||||
(path = "/tags", api = tag::TagApi),
|
||||
(path = "/friends", api = friends::FriendsApi),
|
||||
(path = "/search", api = search::SearchApi),
|
||||
),
|
||||
tags(
|
||||
(name = "root", description = "Root API"),
|
||||
@@ -35,6 +36,7 @@ mod user;
|
||||
(name = "feed", description = "Feed API"),
|
||||
(name = "tag", description = "Tag Discovery API"),
|
||||
(name = "friends", description = "Friends API"),
|
||||
(name = "search", description = "Search API"),
|
||||
),
|
||||
modifiers(&SecurityAddon),
|
||||
)]
|
||||
|
21
thoughts-backend/doc/src/search.rs
Normal file
21
thoughts-backend/doc/src/search.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use api::{models::ApiErrorResponse, routers::search::*};
|
||||
use models::schemas::{
|
||||
search::SearchResultsSchema,
|
||||
thought::{ThoughtListSchema, ThoughtSchema},
|
||||
user::{UserListSchema, UserSchema},
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(search_all),
|
||||
components(schemas(
|
||||
SearchResultsSchema,
|
||||
ApiErrorResponse,
|
||||
ThoughtSchema,
|
||||
ThoughtListSchema,
|
||||
UserSchema,
|
||||
UserListSchema
|
||||
))
|
||||
)]
|
||||
pub(super) struct SearchApi;
|
@@ -7,6 +7,7 @@ mod m20250906_130237_add_tags;
|
||||
mod m20250906_134056_add_api_keys;
|
||||
mod m20250906_145148_add_reply_to_thoughts;
|
||||
mod m20250906_145755_add_visibility_to_thoughts;
|
||||
mod m20250906_231359_add_full_text_search;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -21,6 +22,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20250906_134056_add_api_keys::Migration),
|
||||
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
|
||||
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
|
||||
Box::new(m20250906_231359_add_full_text_search::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,48 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// --- Users Table ---
|
||||
// Add the tsvector column for users
|
||||
manager.get_connection().execute_unprepared(
|
||||
"ALTER TABLE \"user\" ADD COLUMN \"search_document\" tsvector \
|
||||
GENERATED ALWAYS AS (to_tsvector('english', username || ' ' || coalesce(display_name, ''))) STORED"
|
||||
).await?;
|
||||
// Add the GIN index for users
|
||||
manager.get_connection().execute_unprepared(
|
||||
"CREATE INDEX \"user_search_document_idx\" ON \"user\" USING GIN(\"search_document\")"
|
||||
).await?;
|
||||
|
||||
// --- Thoughts Table ---
|
||||
// Add the tsvector column for thoughts
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared(
|
||||
"ALTER TABLE \"thought\" ADD COLUMN \"search_document\" tsvector \
|
||||
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED",
|
||||
)
|
||||
.await?;
|
||||
// Add the GIN index for thoughts
|
||||
manager.get_connection().execute_unprepared(
|
||||
"CREATE INDEX \"thought_search_document_idx\" ON \"thought\" USING GIN(\"search_document\")"
|
||||
).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared("ALTER TABLE \"user\" DROP COLUMN \"search_document\"")
|
||||
.await?;
|
||||
manager
|
||||
.get_connection()
|
||||
.execute_unprepared("ALTER TABLE \"thought\" DROP COLUMN \"search_document\"")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
@@ -25,6 +25,8 @@ pub struct Model {
|
||||
pub reply_to_id: Option<Uuid>,
|
||||
pub visibility: Visibility,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||
pub search_document: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
@@ -19,6 +19,8 @@ pub struct Model {
|
||||
pub custom_css: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
|
||||
pub search_document: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
@@ -1,3 +1,4 @@
|
||||
pub mod api_key;
|
||||
pub mod search;
|
||||
pub mod thought;
|
||||
pub mod user;
|
||||
|
9
thoughts-backend/models/src/schemas/search.rs
Normal file
9
thoughts-backend/models/src/schemas/search.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use super::{thought::ThoughtListSchema, user::UserListSchema};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct SearchResultsSchema {
|
||||
pub users: UserListSchema,
|
||||
pub thoughts: ThoughtListSchema,
|
||||
}
|
@@ -4,6 +4,7 @@ mod auth;
|
||||
mod feed;
|
||||
mod follow;
|
||||
mod main;
|
||||
mod search;
|
||||
mod tag;
|
||||
mod thought;
|
||||
mod user;
|
||||
|
198
thoughts-backend/tests/api/search.rs
Normal file
198
thoughts-backend/tests/api/search.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||
use axum::http::StatusCode;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
use utils::testing::{make_get_request, make_jwt_request};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_search_all() {
|
||||
let app = setup().await;
|
||||
|
||||
// 1. Setup users and data
|
||||
let user1 =
|
||||
create_user_with_password(&app.db, "search_user1", "password123", "s1@test.com").await;
|
||||
let user2 =
|
||||
create_user_with_password(&app.db, "search_user2", "password123", "s2@test.com").await;
|
||||
let _user3 =
|
||||
create_user_with_password(&app.db, "stranger_user", "password123", "s3@test.com").await;
|
||||
|
||||
// Make user1 and user2 friends
|
||||
app::persistence::follow::follow_user(&app.db, user1.id, user2.id)
|
||||
.await
|
||||
.unwrap();
|
||||
app::persistence::follow::follow_user(&app.db, user2.id, user1.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let token1 = login_user(app.router.clone(), "search_user1", "password123").await;
|
||||
let token2 = login_user(app.router.clone(), "search_user2", "password123").await;
|
||||
let token3 = login_user(app.router.clone(), "stranger_user", "password123").await;
|
||||
|
||||
// User1 posts thoughts with different visibilities
|
||||
let thought_public =
|
||||
json!({ "content": "A very public thought about Rust.", "visibility": "Public" })
|
||||
.to_string();
|
||||
let thought_friends =
|
||||
json!({ "content": "A friendly thought, just for pals.", "visibility": "FriendsOnly" })
|
||||
.to_string();
|
||||
let thought_private =
|
||||
json!({ "content": "A private thought, for my eyes only.", "visibility": "Private" })
|
||||
.to_string();
|
||||
|
||||
make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/thoughts",
|
||||
"POST",
|
||||
Some(thought_public),
|
||||
&token1,
|
||||
)
|
||||
.await;
|
||||
make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/thoughts",
|
||||
"POST",
|
||||
Some(thought_friends),
|
||||
&token1,
|
||||
)
|
||||
.await;
|
||||
make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/thoughts",
|
||||
"POST",
|
||||
Some(thought_private),
|
||||
&token1,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 2. Run search tests
|
||||
|
||||
// -- User Search --
|
||||
let response = make_get_request(app.router.clone(), "/search?q=search_user1", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(v["users"]["users"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(v["users"]["users"][0]["username"], "search_user1");
|
||||
|
||||
// -- Thought Search (Public) --
|
||||
let response = make_get_request(app.router.clone(), "/search?q=public", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
1,
|
||||
"Guest should find public thought"
|
||||
);
|
||||
assert!(v["thoughts"]["thoughts"][0]["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("public"));
|
||||
|
||||
// -- Thought Search (FriendsOnly) --
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/search?q=friendly",
|
||||
"GET",
|
||||
None,
|
||||
&token1,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
1,
|
||||
"Author should find friends thought"
|
||||
);
|
||||
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/search?q=friendly",
|
||||
"GET",
|
||||
None,
|
||||
&token2,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
1,
|
||||
"Friend should find friends thought"
|
||||
);
|
||||
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/search?q=friendly",
|
||||
"GET",
|
||||
None,
|
||||
&token3,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
0,
|
||||
"Stranger should NOT find friends thought"
|
||||
);
|
||||
|
||||
let response = make_get_request(app.router.clone(), "/search?q=friendly", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
0,
|
||||
"Guest should NOT find friends thought"
|
||||
);
|
||||
|
||||
// -- Thought Search (Private) --
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/search?q=private",
|
||||
"GET",
|
||||
None,
|
||||
&token1,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
1,
|
||||
"Author should find private thought"
|
||||
);
|
||||
|
||||
let response = make_jwt_request(
|
||||
app.router.clone(),
|
||||
"/search?q=private",
|
||||
"GET",
|
||||
None,
|
||||
&token2,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
0,
|
||||
"Friend should NOT find private thought"
|
||||
);
|
||||
|
||||
let response = make_get_request(app.router.clone(), "/search?q=private", None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
v["thoughts"]["thoughts"].as_array().unwrap().len(),
|
||||
0,
|
||||
"Guest should NOT find private thought"
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user