From b1a55c363fb40a005cb713e8ad3960b2a8fdd076 Mon Sep 17 00:00:00 2001 From: Lumither Tao Date: Thu, 9 Oct 2025 23:26:08 -0400 Subject: [PATCH] feat(backend::api) add post tag filter --- backend/server/Cargo.toml | 1 + .../server/src/modules/blog/api/posts/get.rs | 93 +++++++++++++++---- .../select_post_with_tag_filter_paginated.sql | 21 +++++ 3 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql diff --git a/backend/server/Cargo.toml b/backend/server/Cargo.toml index 80fa351..368017e 100644 --- a/backend/server/Cargo.toml +++ b/backend/server/Cargo.toml @@ -8,6 +8,7 @@ macros = { path = "../macros" } markdown = { path = "../markdown" } axum = { version = "0.8.1", features = ["macros"] } +axum-extra = { version = "0.10.3", features = ["query"] } tracing = { version = "0.1.41", features = ["log"] } git2 = "0.20.0" chrono = { version = "0.4.39", features = ["serde"] } diff --git a/backend/server/src/modules/blog/api/posts/get.rs b/backend/server/src/modules/blog/api/posts/get.rs index b8c2d4a..30841f1 100644 --- a/backend/server/src/modules/blog/api/posts/get.rs +++ b/backend/server/src/modules/blog/api/posts/get.rs @@ -4,17 +4,65 @@ use crate::{ utils::axum_response::{err_resp, succ_resp}, }; -use axum::{ - extract::{Query, State}, - http::StatusCode, - response::IntoResponse, -}; +use std::collections::HashSet; + +use axum::{extract::State, http::StatusCode, response::IntoResponse}; +use axum_extra::extract::Query; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use serde_json::json; -use sqlx::{FromRow, PgPool, query_as}; +use sqlx::{query_as, FromRow, PgPool}; use uuid::Uuid; +fn tags_deserializer<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let deserialize_each = |input: String| -> Result, D::Error> { + Ok(input.split(',').map(|s| s.to_string()).collect()) + }; + + #[derive(Deserialize)] + #[serde(untagged)] + enum Cardinality { + One(String), + Many(Vec), + } + let cardinality: Option = Deserialize::deserialize(deserializer)?; + + let mut tags = HashSet::new(); + + match cardinality { + Some(Cardinality::One(s)) => { + for item in deserialize_each(s)? { + let item = item.trim().to_string(); + if !item.is_empty() { + tags.insert(item); + } + } + let ret = tags.into_iter().collect::>(); + Ok(if ret.is_empty() { None } else { Some(ret) }) + } + Some(Cardinality::Many(v)) => { + for items in v + .into_iter() + .map(deserialize_each) + .collect::, _>>()? + { + for item in items { + let item = item.trim().to_string(); + if !item.is_empty() { + tags.insert(item); + } + } + } + let ret = tags.into_iter().collect::>(); + Ok(if ret.is_empty() { None } else { Some(ret) }) + } + None => Ok(None), + } +} + #[derive(FromRow, Serialize)] struct PostBriefModel { id: Uuid, @@ -29,9 +77,15 @@ struct PostBriefModel { } #[derive(Deserialize, Serialize)] -pub struct Pagination { +pub struct SearchParams { page: Option, page_size: Option, + + #[serde(default, deserialize_with = "tags_deserializer")] + tags: Option>, + + #[serde(default)] + match_all: bool, } #[derive(Serialize)] @@ -53,13 +107,18 @@ struct UuidRow { } const GET_POST_WITH_BRIEF_MODEL: &str = include_str!("get_post_with_brief_model.sql"); +const SELECT_POST_WITH_TAG_FILTER_PAGINATED: &str = + include_str!("select_post_with_tag_filter_paginated.sql"); pub async fn handler( State(db): State, - Query(pagination): Query, + Query(search_params): Query, ) -> impl IntoResponse { - let page = pagination.page.unwrap_or(1); - let page_size = pagination.page_size.unwrap_or(PAGE_SIZE); + dbg!(&search_params.tags); + dbg!(&search_params.match_all); + + let page = search_params.page.unwrap_or(1); + let page_size = search_params.page_size.unwrap_or(PAGE_SIZE); let page_count = match sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM posts") .fetch_one(&db) .await @@ -84,13 +143,13 @@ pub async fn handler( let offset = page_size * (page - 1); - let ids = match query_as::<_, UuidRow>( - "SELECT id FROM posts ORDER BY date_created DESC LIMIT $1 OFFSET $2", - ) - .bind(page_size as i32) - .bind(offset as i32) - .fetch_all(&db) - .await + let ids = match query_as::<_, UuidRow>(SELECT_POST_WITH_TAG_FILTER_PAGINATED) + .bind(search_params.tags) + .bind(page_size as i32) + .bind(offset as i32) + .bind(search_params.match_all) + .fetch_all(&db) + .await { Ok(res) => res.into_iter().map(|item| item.id).collect::>(), Err(e) => return err_resp_log!(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), diff --git a/backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql b/backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql new file mode 100644 index 0000000..b5fa96e --- /dev/null +++ b/backend/server/src/modules/blog/api/posts/select_post_with_tag_filter_paginated.sql @@ -0,0 +1,21 @@ +SELECT p.id +FROM posts p +WHERE $1::text[] IS NULL + OR ( + CASE + WHEN $4 THEN (SELECT COUNT(DISTINCT t.name) + FROM post_tag pt + JOIN tags t ON pt.tag = t.id + WHERE pt.post = p.id + AND t.name = ANY ($1::text[])) + = cardinality($1) + ELSE + EXISTS (SELECT 1 + FROM post_tag pt + JOIN tags t ON pt.tag = t.id + WHERE pt.post = p.id + AND t.name = ANY ($1::text[])) + END + ) +ORDER BY p.date_created DESC +LIMIT $2 OFFSET $3;