BLOG-43 feat: using join table & is published query
All checks were successful
Frontend CI / build (push) Successful in 1m29s
PR Title Check / pr-title-check (pull_request) Successful in 16s

This commit is contained in:
SquidSpirit 2025-05-23 02:34:18 +08:00
parent 3b374b2d75
commit c73ce31e01
14 changed files with 194 additions and 132 deletions

View File

@ -1,4 +1,5 @@
pub mod label_response_dto; pub mod label_response_dto;
pub mod post_controller; pub mod post_controller;
pub mod post_info_query_dto;
pub mod post_info_response_dto; pub mod post_info_response_dto;
pub mod post_response_dto; pub mod post_response_dto;

View File

@ -14,7 +14,11 @@ use super::{post_info_response_dto::PostInfoResponseDto, post_response_dto::Post
#[async_trait] #[async_trait]
pub trait PostController: Send + Sync { pub trait PostController: Send + Sync {
async fn get_all_post_info(&self) -> Result<Vec<PostInfoResponseDto>, PostError>; async fn get_all_post_info(
&self,
is_published_only: bool,
) -> Result<Vec<PostInfoResponseDto>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<PostResponseDto, PostError>; async fn get_full_post(&self, id: i32) -> Result<PostResponseDto, PostError>;
} }
@ -37,8 +41,11 @@ impl PostControllerImpl {
#[async_trait] #[async_trait]
impl PostController for PostControllerImpl { impl PostController for PostControllerImpl {
async fn get_all_post_info(&self) -> Result<Vec<PostInfoResponseDto>, PostError> { async fn get_all_post_info(
let result = self.get_all_post_info_use_case.execute().await; &self,
is_published_only: bool,
) -> Result<Vec<PostInfoResponseDto>, PostError> {
let result = self.get_all_post_info_use_case.execute(is_published_only).await;
result.map(|post_info_list| { result.map(|post_info_list| {
let post_info_response_dto_list: Vec<PostInfoResponseDto> = post_info_list let post_info_response_dto_list: Vec<PostInfoResponseDto> = post_info_list

View File

@ -0,0 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct PostQueryDto {
pub is_published_only: Option<bool>,
}

View File

@ -7,6 +7,6 @@ use crate::{
#[async_trait] #[async_trait]
pub trait PostDbService: Send + Sync { pub trait PostDbService: Send + Sync {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError>; async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<Post, PostError>; async fn get_full_post(&self, id: i32) -> Result<Post, PostError>;
} }

View File

@ -21,8 +21,8 @@ impl PostRepositoryImpl {
#[async_trait] #[async_trait]
impl PostRepository for PostRepositoryImpl { impl PostRepository for PostRepositoryImpl {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError> { async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError> {
self.post_db_service.get_all_post_info().await self.post_db_service.get_all_post_info(is_published_only).await
} }
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> { async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {

View File

@ -1,4 +1,4 @@
#[derive(Debug)] #[derive(Debug, PartialEq)]
pub enum PostError { pub enum PostError {
DatabaseError(String), DatabaseError(String),
NotFound, NotFound,

View File

@ -7,6 +7,6 @@ use crate::{
#[async_trait] #[async_trait]
pub trait PostRepository: Send + Sync { pub trait PostRepository: Send + Sync {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError>; async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError>;
async fn get_full_post(&self, id: i32) -> Result<Post, PostError>; async fn get_full_post(&self, id: i32) -> Result<Post, PostError>;
} }

View File

@ -9,7 +9,7 @@ use crate::{
#[async_trait] #[async_trait]
pub trait GetAllPostInfoUseCase: Send + Sync { pub trait GetAllPostInfoUseCase: Send + Sync {
async fn execute(&self) -> Result<Vec<PostInfo>, PostError>; async fn execute(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError>;
} }
pub struct GetAllPostInfoUseCaseImpl { pub struct GetAllPostInfoUseCaseImpl {
@ -24,7 +24,7 @@ impl GetAllPostInfoUseCaseImpl {
#[async_trait] #[async_trait]
impl GetAllPostInfoUseCase for GetAllPostInfoUseCaseImpl { impl GetAllPostInfoUseCase for GetAllPostInfoUseCaseImpl {
async fn execute(&self) -> Result<Vec<PostInfo>, PostError> { async fn execute(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError> {
self.post_repository.get_all_post_info().await self.post_repository.get_all_post_info(is_published_only).await
} }
} }

View File

@ -10,4 +10,3 @@ pub struct PostInfo {
pub labels: Vec<Label>, pub labels: Vec<Label>,
pub published_time: Option<DateTime<Utc>>, pub published_time: Option<DateTime<Utc>>,
} }

View File

@ -1 +1,4 @@
pub mod post_db_service_impl; pub mod post_db_service_impl;
mod post_info_with_label_record;
mod post_with_label_record;

View File

@ -1,13 +1,18 @@
use std::{any::Any, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{Error, Pool, Postgres}; use sqlx::{Pool, Postgres};
use crate::{ use crate::{
adapter::gateway::post_db_service::PostDbService, adapter::gateway::post_db_service::PostDbService,
application::error::post_error::PostError, application::error::post_error::PostError,
domain::entity::{post::Post, post_info::PostInfo}, domain::entity::{label::Label, post::Post, post_info::PostInfo},
};
use super::{
post_info_with_label_record::PostInfoWithLabelRecord,
post_with_label_record::PostWithLabelRecord,
}; };
pub struct PostDbServiceImpl { pub struct PostDbServiceImpl {
@ -22,135 +27,142 @@ impl PostDbServiceImpl {
#[async_trait] #[async_trait]
impl PostDbService for PostDbServiceImpl { impl PostDbService for PostDbServiceImpl {
async fn get_all_post_info(&self) -> Result<Vec<PostInfo>, PostError> { async fn get_all_post_info(&self, is_published_only: bool) -> Result<Vec<PostInfo>, PostError> {
let post_records = sqlx::query!( let mut query_builder = sqlx::QueryBuilder::new(
r#" r#"
SELECT SELECT
p.id, p.id AS post_id,
p.title, p.title,
p.description, p.description,
p.preview_image_url, p.preview_image_url,
p.published_time p.published_time,
FROM post p l.id AS label_id,
WHERE p.deleted_time IS NULL l.name AS label_name,
"# l.color AS label_color
) FROM
.fetch_all(&*self.db_pool) post p
.await LEFT JOIN
.map_err(|err| PostError::DatabaseError(err.to_string()))?; post_label pl ON p.id = pl.post_id
LEFT JOIN
label l ON pl.label_id = l.id AND l.deleted_time IS NULL
WHERE
p.deleted_time IS NULL
"#,
);
let mut post_infos = Vec::<PostInfo>::new(); if is_published_only {
query_builder.push(r#" AND p.published_time IS NOT NULL"#);
}
for post_record in post_records { query_builder.push(r#" ORDER BY p.id"#);
let label_records = sqlx::query!(
r#" let records = query_builder
SELECT .build_query_as::<PostInfoWithLabelRecord>()
l.id,
l.name,
l.color
FROM label l
JOIN post_label pl ON l.id = pl.label_id
WHERE pl.post_id = $1
AND l.deleted_time IS NULL
"#,
post_record.id
)
.fetch_all(&*self.db_pool) .fetch_all(&*self.db_pool)
.await .await
.map_err(|err| PostError::DatabaseError(err.to_string()))?; .map_err(|err| PostError::DatabaseError(err.to_string()))?;
let labels = label_records let mut post_info_map = HashMap::<i32, PostInfo>::new();
.into_iter()
.map(|label| crate::domain::entity::label::Label {
id: label.id,
name: label.name,
color: label.color as u32,
})
.collect();
post_infos.push(PostInfo { for record in records {
id: post_record.id, let post_info = post_info_map
title: post_record.title, .entry(record.post_id)
description: post_record.description, .or_insert_with(|| PostInfo {
preview_image_url: post_record.preview_image_url, id: record.post_id,
labels: labels, title: record.title,
published_time: post_record description: record.description,
.published_time preview_image_url: record.preview_image_url,
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)), labels: Vec::new(),
}); published_time: record
.published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
});
if let (Some(label_id), Some(label_name), Some(label_color)) =
(record.label_id, record.label_name, record.label_color)
{
post_info.labels.push(Label {
id: label_id,
name: label_name,
color: label_color as u32,
});
}
} }
Ok(post_infos) Ok(post_info_map.into_values().collect())
} }
async fn get_full_post(&self, id: i32) -> Result<Post, PostError> { async fn get_full_post(&self, id: i32) -> Result<Post, PostError> {
let post_record = sqlx::query!( let mut query_builder = sqlx::QueryBuilder::new(
r#" r#"
SELECT SELECT
p.id, p.id AS post_id,
p.title, p.title,
p.description, p.description,
p.preview_image_url, p.preview_image_url,
p.content, p.content,
p.published_time p.published_time,
FROM post p l.id AS label_id,
WHERE p.id = $1 l.name AS label_name,
AND p.deleted_time IS NULL l.color AS label_color
FROM
post p
LEFT JOIN
post_label pl ON p.id = pl.post_id
LEFT JOIN
label l ON pl.label_id = l.id AND l.deleted_time IS NULL
WHERE
p.deleted_time IS NULL AND p.id =
"#, "#,
id );
)
.fetch_one(&*self.db_pool) query_builder.push_bind(id);
.await query_builder.push(r#" ORDER BY p.id"#);
.map_err(|err| {
if err.type_id() == Error::RowNotFound.type_id() { let records = query_builder
PostError::NotFound .build_query_as::<PostWithLabelRecord>()
} else { .fetch_all(&*self.db_pool)
PostError::DatabaseError(err.to_string()) .await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
if records.is_empty() {
return Err(PostError::NotFound);
}
let mut post_map = HashMap::<i32, Post>::new();
for record in records {
let post = post_map.entry(record.post_id).or_insert_with(|| Post {
id: record.post_id,
info: PostInfo {
id: record.post_id,
title: record.title,
description: record.description,
preview_image_url: record.preview_image_url,
labels: Vec::new(),
published_time: record
.published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
},
content: record.content,
});
if let (Some(label_id), Some(label_name), Some(label_color)) =
(record.label_id, record.label_name, record.label_color)
{
post.info.labels.push(Label {
id: label_id,
name: label_name,
color: label_color as u32,
});
} }
})?; }
// TODO: extract label selecting process let post = post_map.into_values().next();
let label_records = sqlx::query!(
r#"
SELECT
l.id,
l.name,
l.color
FROM label l
JOIN post_label pl ON l.id = pl.label_id
WHERE pl.post_id = $1
AND l.deleted_time IS NULL
"#,
post_record.id
)
.fetch_all(&*self.db_pool)
.await
.map_err(|err| PostError::DatabaseError(err.to_string()))?;
let labels = label_records match post {
.into_iter() Some(v) => Ok(v),
.map(|label| crate::domain::entity::label::Label { None => Err(PostError::NotFound),
id: label.id, }
name: label.name,
color: label.color as u32,
})
.collect();
let post = Post {
id: post_record.id,
content: post_record.content,
info: PostInfo {
id: post_record.id,
title: post_record.title,
description: post_record.description,
preview_image_url: post_record.preview_image_url,
labels: labels,
published_time: post_record
.published_time
.map(|dt| DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc)),
},
};
Ok(post)
} }
} }

View File

@ -0,0 +1,14 @@
use chrono::NaiveDateTime;
#[derive(sqlx::FromRow)]
pub struct PostInfoWithLabelRecord {
pub post_id: i32,
pub title: String,
pub description: String,
pub preview_image_url: String,
pub published_time: Option<NaiveDateTime>,
pub label_id: Option<i32>,
pub label_name: Option<String>,
pub label_color: Option<i64>,
}

View File

@ -0,0 +1,15 @@
use chrono::NaiveDateTime;
#[derive(sqlx::FromRow, Debug)]
pub struct PostWithLabelRecord {
pub post_id: i32,
pub title: String,
pub description: String,
pub preview_image_url: String,
pub content: String,
pub published_time: Option<NaiveDateTime>,
pub label_id: Option<i32>,
pub label_name: Option<String>,
pub label_color: Option<i64>,
}

View File

@ -1,9 +1,10 @@
use std::{any::Any, sync::Arc}; use std::sync::Arc;
use actix_web::{HttpResponse, Responder, web}; use actix_web::{HttpResponse, Responder, web};
use crate::{ use crate::{
adapter::delivery::post_controller::PostController, application::error::post_error::PostError, adapter::delivery::{post_controller::PostController, post_info_query_dto::PostQueryDto},
application::error::post_error::PostError,
}; };
pub fn configure_post_routes(cfg: &mut web::ServiceConfig) { pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
@ -11,8 +12,12 @@ pub fn configure_post_routes(cfg: &mut web::ServiceConfig) {
cfg.service(web::resource("/post/{id}").route(web::get().to(get_full_post))); cfg.service(web::resource("/post/{id}").route(web::get().to(get_full_post)));
} }
async fn get_all_post_info(post_controller: web::Data<Arc<dyn PostController>>) -> impl Responder { async fn get_all_post_info(
let result = post_controller.get_all_post_info().await; post_controller: web::Data<Arc<dyn PostController>>,
query: web::Query<PostQueryDto>,
) -> impl Responder {
let is_published_only = query.is_published_only.unwrap_or_else(|| true);
let result = post_controller.get_all_post_info(is_published_only).await;
match result { match result {
Ok(post_info_list) => HttpResponse::Ok().json(post_info_list), Ok(post_info_list) => HttpResponse::Ok().json(post_info_list),
@ -33,7 +38,7 @@ async fn get_full_post(
match result { match result {
Ok(post) => HttpResponse::Ok().json(post), Ok(post) => HttpResponse::Ok().json(post),
Err(e) => { Err(e) => {
if e.type_id() == PostError::NotFound.type_id() { if e == PostError::NotFound {
HttpResponse::NotFound().finish() HttpResponse::NotFound().finish()
} else { } else {
log::error!("{e:?}"); log::error!("{e:?}");