feat: Refactor user and thought models to use UUIDs instead of integers

- Updated user and thought models to utilize UUIDs for primary keys.
- Modified persistence functions to accommodate UUIDs for user and thought IDs.
- Implemented tag functionality with new Tag and ThoughtTag models.
- Added migration scripts to create new tables for tags and thought-tag relationships.
- Enhanced thought creation to parse hashtags and link them to thoughts.
- Updated tests to reflect changes in user and thought ID types.
This commit is contained in:
2025-09-06 15:29:38 +02:00
parent c9e99e6f23
commit b83b7acf1c
38 changed files with 638 additions and 107 deletions

View File

@@ -1,11 +1,13 @@
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
use sea_orm::{
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
};
use crate::{error::UserError, persistence::user::get_user_by_username};
use models::domains::follow;
pub async fn add_follower(
db: &DbConn,
followed_id: i32,
followed_id: Uuid,
follower_actor_id: &str,
) -> Result<(), UserError> {
let follower_username = follower_actor_id
@@ -25,7 +27,7 @@ pub async fn add_follower(
Ok(())
}
pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Result<(), DbErr> {
pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> Result<(), DbErr> {
if follower_id == followed_id {
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
}
@@ -41,8 +43,8 @@ pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Res
pub async fn unfollow_user(
db: &DbConn,
follower_id: i32,
followed_id: i32,
follower_id: Uuid,
followed_id: Uuid,
) -> Result<(), UserError> {
let deleted_result = follow::Entity::delete_many()
.filter(follow::Column::FollowerId.eq(follower_id))
@@ -58,7 +60,7 @@ pub async fn unfollow_user(
Ok(())
}
pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followed_users = follow::Entity::find()
.filter(follow::Column::FollowerId.eq(user_id))
.all(db)
@@ -67,7 +69,7 @@ pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbE
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
}
pub async fn get_follower_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followers = follow::Entity::find()
.filter(follow::Column::FollowedId.eq(user_id))
.all(db)

View File

@@ -1,4 +1,5 @@
pub mod auth;
pub mod follow;
pub mod tag;
pub mod thought;
pub mod user;

View File

@@ -0,0 +1,86 @@
use models::domains::{tag, thought_tag};
use sea_orm::{
sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, Set,
};
use std::collections::HashSet;
pub fn parse_hashtags(content: &str) -> Vec<String> {
content
.split_whitespace()
.filter_map(|word| {
if word.starts_with('#') && word.len() > 1 {
Some(word[1..].to_lowercase().to_string())
} else {
None
}
})
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
pub async fn find_or_create_tags<C>(db: &C, names: Vec<String>) -> Result<Vec<tag::Model>, DbErr>
where
C: ConnectionTrait,
{
if names.is_empty() {
return Ok(vec![]);
}
let existing_tags = tag::Entity::find()
.filter(tag::Column::Name.is_in(names.clone()))
.all(db)
.await?;
let existing_names: HashSet<String> = existing_tags.iter().map(|t| t.name.clone()).collect();
let new_names: Vec<String> = names
.into_iter()
.filter(|n| !existing_names.contains(n))
.collect();
if !new_names.is_empty() {
let new_tags: Vec<tag::ActiveModel> = new_names
.clone()
.into_iter()
.map(|name| tag::ActiveModel {
name: Set(name),
..Default::default()
})
.collect();
tag::Entity::insert_many(new_tags).exec(db).await?;
}
tag::Entity::find()
.filter(
tag::Column::Name.is_in(
existing_names
.union(&new_names.into_iter().collect())
.cloned()
.collect::<Vec<_>>(),
),
)
.all(db)
.await
}
pub async fn link_tags_to_thought<C>(
db: &C,
thought_id: uuid::Uuid,
tags: Vec<tag::Model>,
) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
if tags.is_empty() {
return Ok(());
}
let links: Vec<thought_tag::ActiveModel> = tags
.into_iter()
.map(|tag| thought_tag::ActiveModel {
thought_id: Set(thought_id),
tag_id: Set(tag.id),
})
.collect();
thought_tag::Entity::insert_many(links).exec(db).await?;
Ok(())
}

View File

@@ -1,42 +1,56 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
QuerySelect, RelationTrait, Set,
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
};
use models::{
domains::{thought, user},
domains::{tag, thought, thought_tag, user},
params::thought::CreateThoughtParams,
schemas::thought::ThoughtWithAuthor,
};
use crate::error::UserError;
use crate::{
error::UserError,
persistence::tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags},
};
pub async fn create_thought(
db: &DbConn,
author_id: i32,
author_id: Uuid,
params: CreateThoughtParams,
) -> Result<thought::Model, DbErr> {
thought::ActiveModel {
let txn = db.begin().await?;
let new_thought = thought::ActiveModel {
author_id: Set(author_id),
content: Set(params.content),
content: Set(params.content.clone()),
..Default::default()
}
.insert(db)
.await
.insert(&txn)
.await?;
let tag_names = parse_hashtags(&params.content);
if !tag_names.is_empty() {
let tags = find_or_create_tags(&txn, tag_names).await?;
link_tags_to_thought(&txn, new_thought.id, tags).await?;
}
txn.commit().await?;
Ok(new_thought)
}
pub async fn get_thought(db: &DbConn, thought_id: i32) -> Result<Option<thought::Model>, DbErr> {
pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result<Option<thought::Model>, DbErr> {
thought::Entity::find_by_id(thought_id).one(db).await
}
pub async fn delete_thought(db: &DbConn, thought_id: i32) -> Result<(), DbErr> {
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
thought::Entity::delete_by_id(thought_id).exec(db).await?;
Ok(())
}
pub async fn get_thoughts_by_user(
db: &DbConn,
user_id: i32,
user_id: Uuid,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
thought::Entity::find()
.select_only()
@@ -55,7 +69,7 @@ pub async fn get_thoughts_by_user(
pub async fn get_feed_for_user(
db: &DbConn,
followed_ids: Vec<i32>,
followed_ids: Vec<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
if followed_ids.is_empty() {
return Ok(vec![]);
@@ -76,3 +90,24 @@ pub async fn get_feed_for_user(
.await
.map_err(|e| UserError::Internal(e.to_string()))
}
pub async fn get_thoughts_by_tag_name(
db: &DbConn,
tag_name: &str,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::CreatedAt)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
.filter(tag::Column::Name.eq(tag_name.to_lowercase()))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
}

View File

@@ -1,8 +1,9 @@
use sea_orm::prelude::Uuid;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, TransactionTrait,
};
use models::domains::user;
use models::domains::{top_friends, user};
use models::params::user::{CreateUserParams, UpdateUserParams};
use models::queries::user::UserQuery;
@@ -27,7 +28,7 @@ pub async fn search_users(db: &DbConn, query: UserQuery) -> Result<Vec<user::Mod
.await
}
pub async fn get_user(db: &DbConn, id: i32) -> Result<Option<user::Model>, DbErr> {
pub async fn get_user(db: &DbConn, id: Uuid) -> Result<Option<user::Model>, DbErr> {
user::Entity::find_by_id(id).one(db).await
}
@@ -41,7 +42,7 @@ pub async fn get_user_by_username(
.await
}
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<i32>) -> Result<Vec<user::Model>, DbErr> {
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<Uuid>) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.filter(user::Column::Id.is_in(ids))
.all(db)
@@ -50,7 +51,7 @@ pub async fn get_users_by_ids(db: &DbConn, ids: Vec<i32>) -> Result<Vec<user::Mo
pub async fn update_user_profile(
db: &DbConn,
user_id: i32,
user_id: Uuid,
params: UpdateUserParams,
) -> Result<user::Model, UserError> {
let mut user: user::ActiveModel = get_user(db, user_id)
@@ -75,26 +76,47 @@ pub async fn update_user_profile(
user.custom_css = Set(Some(custom_css));
}
// This is a complex operation, so we use a transaction
if let Some(friend_usernames) = params.top_friends {
let txn = db
.begin()
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
// 1. Delete old top friends
// In a real app, you would create a `top_friends` entity and use it here.
// For now, we'll skip this to avoid creating the model.
top_friends::Entity::delete_many()
.filter(top_friends::Column::UserId.eq(user_id))
.exec(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
// 2. Find new friends by username
let _friends = user::Entity::find()
.filter(user::Column::Username.is_in(friend_usernames))
let friends = user::Entity::find()
.filter(user::Column::Username.is_in(friend_usernames.clone()))
.all(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
// 3. Insert new friends
// This part would involve inserting into the `top_friends` table.
if friends.len() != friend_usernames.len() {
return Err(UserError::Validation(
"One or more usernames in top_friends do not exist".to_string(),
));
}
let new_top_friends: Vec<top_friends::ActiveModel> = friends
.iter()
.enumerate()
.map(|(index, friend)| top_friends::ActiveModel {
user_id: Set(user_id),
friend_id: Set(friend.id),
position: Set((index + 1) as i16),
..Default::default()
})
.collect();
if !new_top_friends.is_empty() {
top_friends::Entity::insert_many(new_top_friends)
.exec(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
}
txn.commit()
.await