BLOG-133 Unique label name and semantic ID (#135)
All checks were successful
Frontend CI / build (push) Successful in 1m23s

### Description

- It returns status `409 CONFLICT` if there is already a label with same name or a post with same semantic ID.

### Package Changes

_No response_

### Screenshots

_No response_

### Reference

Resolves #133.

### Checklist

- [x] A milestone is set
- [x] The related issuse has been linked to this branch

Reviewed-on: #135
Co-authored-by: SquidSpirit <squid@squidspirit.com>
Co-committed-by: SquidSpirit <squid@squidspirit.com>
This commit is contained in:
SquidSpirit 2025-10-12 21:16:36 +08:00 committed by squid
parent 565df7aace
commit 1ae104cd56
16 changed files with 112 additions and 31 deletions

View File

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM post\n WHERE semantic_id = $1 AND deleted_time IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "4e022312b61c557d23f4541961f07b7afa8bf1f62f4a8e248d6520864b126a6a"
}

View File

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n INSERT INTO post (\n title, description, preview_image_url, content, published_time\n ) VALUES ($1, $2, $3, $4, $5)\n RETURNING id\n ", "query": "\n INSERT INTO post (\n semantic_id, title, description, preview_image_url, content, published_time\n ) VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -11,6 +11,7 @@
], ],
"parameters": { "parameters": {
"Left": [ "Left": [
"Varchar",
"Text", "Text",
"Text", "Text",
"Text", "Text",
@ -22,5 +23,5 @@
false false
] ]
}, },
"hash": "f0c2c0fe0a30790e88449da79c859d4e3829b9b2a6a496c9a429a05fbdb2e30a" "hash": "82c4c8d03298009e776d42ae6069182cc318e0b1d8497a675cf4449adcaf1927"
} }

View File

@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE post\n SET \n semantic_id = $1,\n title = $2, \n description = $3, \n preview_image_url = $4, \n content = $5, \n published_time = $6\n WHERE id = $7\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Text",
"Text",
"Timestamp",
"Int4"
]
},
"nullable": []
},
"hash": "9e486528becb873ace62656c78805034ddca950db7306ee04257d6b589b5a0d9"
}

View File

@ -1,19 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE post\n SET \n title = $1, \n description = $2, \n preview_image_url = $3, \n content = $4, \n published_time = $5\n WHERE id = $6\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"Timestamp",
"Int4"
]
},
"nullable": []
},
"hash": "d0867ba2857fedcdc9a754d0394c4f040d559118d0b9f8b6f4dcd6e6fde5d381"
}

View File

@ -5,6 +5,8 @@ pub enum PostError {
NotFound, NotFound,
Unauthorized, Unauthorized,
InvalidSemanticId, InvalidSemanticId,
DuplicatedSemanticId,
DuplicatedLabelName,
Unexpected(anyhow::Error), Unexpected(anyhow::Error),
} }
@ -17,6 +19,8 @@ impl Display for PostError {
f, f,
"Semantic ID shouldn't be numeric and must conform to `^[0-9a-zA-Z_\\-]+$`" "Semantic ID shouldn't be numeric and must conform to `^[0-9a-zA-Z_\\-]+$`"
), ),
PostError::DuplicatedSemanticId => write!(f, "Semantic ID already exists"),
PostError::DuplicatedLabelName => write!(f, "Label name already exists"),
PostError::Unexpected(e) => write!(f, "Unexpected error: {}", e), PostError::Unexpected(e) => write!(f, "Unexpected error: {}", e),
} }
} }

View File

@ -32,7 +32,14 @@ impl LabelDbService for LabelDbServiceImpl {
) )
.fetch_one(&self.db_pool) .fetch_one(&self.db_pool)
.await .await
.map_err(|e| PostError::Unexpected(DatabaseError(e).into()))?; .map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("idx_label_name") {
return PostError::DuplicatedLabelName;
}
}
PostError::Unexpected(DatabaseError(e).into())
})?;
Ok(id) Ok(id)
} }
@ -50,7 +57,14 @@ impl LabelDbService for LabelDbServiceImpl {
) )
.execute(&self.db_pool) .execute(&self.db_pool)
.await .await
.map_err(|e| PostError::Unexpected(DatabaseError(e).into()))? .map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("idx_label_name") {
return PostError::DuplicatedLabelName;
}
}
PostError::Unexpected(DatabaseError(e).into())
})?
.rows_affected(); .rows_affected();
if affected_rows == 0 { if affected_rows == 0 {

View File

@ -211,7 +211,14 @@ impl PostDbService for PostDbServiceImpl {
) )
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| PostError::Unexpected(DatabaseError(e).into()))?; .map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("idx_post_semantic_id") {
return PostError::DuplicatedSemanticId;
}
}
PostError::Unexpected(DatabaseError(e).into())
})?;
for (order, &label_id) in label_ids.iter().enumerate() { for (order, &label_id) in label_ids.iter().enumerate() {
sqlx::query!( sqlx::query!(
@ -266,7 +273,14 @@ impl PostDbService for PostDbServiceImpl {
) )
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| PostError::Unexpected(DatabaseError(e).into()))? .map_err(|e| {
if let sqlx::Error::Database(db_err) = &e {
if db_err.constraint() == Some("idx_post_semantic_id") {
return PostError::DuplicatedSemanticId;
}
}
PostError::Unexpected(DatabaseError(e).into())
})?
.rows_affected(); .rows_affected();
if affected_rows == 0 { if affected_rows == 0 {

View File

@ -34,7 +34,10 @@ pub async fn create_label_handler(
Ok(label) => HttpResponse::Created().json(label), Ok(label) => HttpResponse::Created().json(label),
Err(e) => match e { Err(e) => match e {
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::NotFound | PostError::InvalidSemanticId => { PostError::DuplicatedLabelName => HttpResponse::Conflict().finish(),
PostError::NotFound
| PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId => {
capture_anyhow(&anyhow!(e)); capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }

View File

@ -37,7 +37,8 @@ pub async fn create_post_handler(
Err(e) => match e { Err(e) => match e {
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(), PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
PostError::NotFound => { PostError::DuplicatedSemanticId => HttpResponse::Conflict().finish(),
PostError::NotFound | PostError::DuplicatedLabelName => {
capture_anyhow(&anyhow!(e)); capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }

View File

@ -24,7 +24,11 @@ pub async fn get_all_labels_handler(
match result { match result {
Ok(labels) => HttpResponse::Ok().json(labels), Ok(labels) => HttpResponse::Ok().json(labels),
Err(e) => match e { Err(e) => match e {
PostError::NotFound | PostError::Unauthorized | PostError::InvalidSemanticId => { PostError::NotFound
| PostError::Unauthorized
| PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId
| PostError::DuplicatedLabelName => {
capture_anyhow(&anyhow!(e)); capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }

View File

@ -36,7 +36,11 @@ pub async fn get_all_post_info_handler(
match result { match result {
Ok(post_info_list) => HttpResponse::Ok().json(post_info_list), Ok(post_info_list) => HttpResponse::Ok().json(post_info_list),
Err(e) => match e { Err(e) => match e {
PostError::NotFound | PostError::Unauthorized | PostError::InvalidSemanticId => { PostError::NotFound
| PostError::Unauthorized
| PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId
| PostError::DuplicatedLabelName => {
capture_anyhow(&anyhow!(e)); capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }

View File

@ -34,7 +34,9 @@ pub async fn get_post_by_id_handler(
Err(e) => match e { Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(), PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId => { PostError::InvalidSemanticId
| PostError::DuplicatedSemanticId
| PostError::DuplicatedLabelName => {
capture_anyhow(&anyhow!(e)); capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }

View File

@ -40,7 +40,8 @@ pub async fn update_label_handler(
Err(e) => match e { Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(), PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::InvalidSemanticId => { PostError::DuplicatedLabelName => HttpResponse::Conflict().finish(),
PostError::InvalidSemanticId | PostError::DuplicatedSemanticId => {
capture_anyhow(&anyhow!(e)); capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()
} }

View File

@ -1,4 +1,5 @@
use actix_web::{HttpResponse, Responder, web}; use actix_web::{HttpResponse, Responder, web};
use anyhow::anyhow;
use auth::framework::web::auth_middleware::UserId; use auth::framework::web::auth_middleware::UserId;
use sentry::integrations::anyhow::capture_anyhow; use sentry::integrations::anyhow::capture_anyhow;
@ -39,7 +40,12 @@ pub async fn update_post_handler(
Err(e) => match e { Err(e) => match e {
PostError::NotFound => HttpResponse::NotFound().finish(), PostError::NotFound => HttpResponse::NotFound().finish(),
PostError::Unauthorized => HttpResponse::Unauthorized().finish(), PostError::Unauthorized => HttpResponse::Unauthorized().finish(),
PostError::DuplicatedSemanticId => HttpResponse::Conflict().finish(),
PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(), PostError::InvalidSemanticId => HttpResponse::BadRequest().finish(),
PostError::DuplicatedLabelName => {
capture_anyhow(&anyhow!(e));
HttpResponse::InternalServerError().finish()
}
PostError::Unexpected(e) => { PostError::Unexpected(e) => {
capture_anyhow(&e); capture_anyhow(&e);
HttpResponse::InternalServerError().finish() HttpResponse::InternalServerError().finish()

View File

@ -0,0 +1,2 @@
-- Remove unique index from label name column
DROP INDEX IF EXISTS "idx_label_name";

View File

@ -0,0 +1,2 @@
-- Add unique index to label name column
CREATE UNIQUE INDEX "idx_label_name" ON "label" ("name");