diff --git a/lemmy_db/src/aggregates/user_aggregates.rs b/lemmy_db/src/aggregates/user_aggregates.rs index 26c2c067c..622bce113 100644 --- a/lemmy_db/src/aggregates/user_aggregates.rs +++ b/lemmy_db/src/aggregates/user_aggregates.rs @@ -14,9 +14,218 @@ pub struct UserAggregates { } impl UserAggregates { - pub fn read(conn: &PgConnection, id: i32) -> Result { - user_aggregates::table.find(id).first::(conn) + pub fn read(conn: &PgConnection, user_id: i32) -> Result { + user_aggregates::table + .filter(user_aggregates::user_id.eq(user_id)) + .first::(conn) } } -// TODO add unit tests, to make sure triggers are working +#[cfg(test)] +mod tests { + use crate::{ + aggregates::user_aggregates::UserAggregates, + comment::{Comment, CommentForm, CommentLike, CommentLikeForm}, + community::{Community, CommunityForm}, + post::{Post, PostForm, PostLike, PostLikeForm}, + tests::establish_unpooled_connection, + user::{UserForm, User_}, + Crud, + Likeable, + ListingType, + SortType, + }; + + #[test] + fn test_crud() { + let conn = establish_unpooled_connection(); + + let new_user = UserForm { + name: "thommy_user_agg".into(), + preferred_username: None, + password_encrypted: "nope".into(), + email: None, + matrix_user_id: None, + avatar: None, + banner: None, + admin: false, + banned: Some(false), + published: None, + updated: None, + show_nsfw: false, + theme: "browser".into(), + default_sort_type: SortType::Hot as i16, + default_listing_type: ListingType::Subscribed as i16, + lang: "browser".into(), + show_avatars: true, + send_notifications_to_email: false, + actor_id: None, + bio: None, + local: true, + private_key: None, + public_key: None, + last_refreshed_at: None, + }; + + let inserted_user = User_::create(&conn, &new_user).unwrap(); + + let another_user = UserForm { + name: "jerry_user_agg".into(), + preferred_username: None, + password_encrypted: "nope".into(), + email: None, + matrix_user_id: None, + avatar: None, + banner: None, + admin: false, + banned: Some(false), + published: None, + updated: None, + show_nsfw: false, + theme: "browser".into(), + default_sort_type: SortType::Hot as i16, + default_listing_type: ListingType::Subscribed as i16, + lang: "browser".into(), + show_avatars: true, + send_notifications_to_email: false, + actor_id: None, + bio: None, + local: true, + private_key: None, + public_key: None, + last_refreshed_at: None, + }; + + let another_inserted_user = User_::create(&conn, &another_user).unwrap(); + + let new_community = CommunityForm { + name: "TIL_site_agg".into(), + creator_id: inserted_user.id, + title: "nada".to_owned(), + description: None, + category_id: 1, + nsfw: false, + removed: None, + deleted: None, + updated: None, + actor_id: None, + local: true, + private_key: None, + public_key: None, + last_refreshed_at: None, + published: None, + icon: None, + banner: None, + }; + + let inserted_community = Community::create(&conn, &new_community).unwrap(); + + let new_post = PostForm { + name: "A test post".into(), + url: None, + body: None, + creator_id: inserted_user.id, + community_id: inserted_community.id, + removed: None, + deleted: None, + locked: None, + stickied: None, + nsfw: false, + updated: None, + embed_title: None, + embed_description: None, + embed_html: None, + thumbnail_url: None, + ap_id: None, + local: true, + published: None, + }; + + let inserted_post = Post::create(&conn, &new_post).unwrap(); + + let post_like = PostLikeForm { + post_id: inserted_post.id, + user_id: inserted_user.id, + score: 1, + }; + + let _inserted_post_like = PostLike::like(&conn, &post_like).unwrap(); + + let comment_form = CommentForm { + content: "A test comment".into(), + creator_id: inserted_user.id, + post_id: inserted_post.id, + removed: None, + deleted: None, + read: None, + parent_id: None, + published: None, + updated: None, + ap_id: None, + local: true, + }; + + let inserted_comment = Comment::create(&conn, &comment_form).unwrap(); + + let comment_like = CommentLikeForm { + comment_id: inserted_comment.id, + user_id: inserted_user.id, + post_id: inserted_post.id, + score: 1, + }; + + let _inserted_comment_like = CommentLike::like(&conn, &comment_like).unwrap(); + + let child_comment_form = CommentForm { + content: "A test comment".into(), + creator_id: inserted_user.id, + post_id: inserted_post.id, + removed: None, + deleted: None, + read: None, + parent_id: Some(inserted_comment.id), + published: None, + updated: None, + ap_id: None, + local: true, + }; + + let inserted_child_comment = Comment::create(&conn, &child_comment_form).unwrap(); + + let child_comment_like = CommentLikeForm { + comment_id: inserted_child_comment.id, + user_id: another_inserted_user.id, + post_id: inserted_post.id, + score: 1, + }; + + let _inserted_child_comment_like = CommentLike::like(&conn, &child_comment_like).unwrap(); + + let user_aggregates_before_delete = UserAggregates::read(&conn, inserted_user.id).unwrap(); + + assert_eq!(1, user_aggregates_before_delete.post_count); + assert_eq!(1, user_aggregates_before_delete.post_score); + assert_eq!(2, user_aggregates_before_delete.comment_count); + assert_eq!(2, user_aggregates_before_delete.comment_score); + + // Remove a post like + PostLike::remove(&conn, inserted_user.id, inserted_post.id).unwrap(); + let after_post_like_remove = UserAggregates::read(&conn, inserted_user.id).unwrap(); + assert_eq!(0, after_post_like_remove.post_score); + + // Remove a parent comment (the scores should also be removed) + Comment::delete(&conn, inserted_comment.id).unwrap(); + let after_parent_comment_delete = UserAggregates::read(&conn, inserted_user.id).unwrap(); + assert_eq!(0, after_parent_comment_delete.comment_count); + assert_eq!(0, after_parent_comment_delete.comment_score); + + // This should delete all the associated rows, and fire triggers + let user_num_deleted = User_::delete(&conn, inserted_user.id).unwrap(); + assert_eq!(1, user_num_deleted); + User_::delete(&conn, another_inserted_user.id).unwrap(); + + // Should be none found + let after_delete = UserAggregates::read(&conn, inserted_user.id); + assert!(after_delete.is_err()); + } +} diff --git a/lemmy_db/src/comment.rs b/lemmy_db/src/comment.rs index 9b0928257..c88eb9adb 100644 --- a/lemmy_db/src/comment.rs +++ b/lemmy_db/src/comment.rs @@ -187,7 +187,7 @@ pub struct CommentLike { pub id: i32, pub user_id: i32, pub comment_id: i32, - pub post_id: i32, + pub post_id: i32, // TODO this is redundant pub score: i16, pub published: chrono::NaiveDateTime, } @@ -197,7 +197,7 @@ pub struct CommentLike { pub struct CommentLikeForm { pub user_id: i32, pub comment_id: i32, - pub post_id: i32, + pub post_id: i32, // TODO this is redundant pub score: i16, } diff --git a/migrations/2020-12-03-035643_create_user_aggregates/down.sql b/migrations/2020-12-03-035643_create_user_aggregates/down.sql index 4e3e7fcb9..a7b5e4737 100644 --- a/migrations/2020-12-03-035643_create_user_aggregates/down.sql +++ b/migrations/2020-12-03-035643_create_user_aggregates/down.sql @@ -1,10 +1,12 @@ -- User aggregates drop table user_aggregates; +drop trigger user_aggregates_user on user_; drop trigger user_aggregates_post_count on post; drop trigger user_aggregates_post_score on post_like; drop trigger user_aggregates_comment_count on comment; drop trigger user_aggregates_comment_score on comment_like; drop function + user_aggregates_user, user_aggregates_post_count, user_aggregates_post_score, user_aggregates_comment_count, diff --git a/migrations/2020-12-03-035643_create_user_aggregates/up.sql b/migrations/2020-12-03-035643_create_user_aggregates/up.sql index e0c39be60..1bebfe305 100644 --- a/migrations/2020-12-03-035643_create_user_aggregates/up.sql +++ b/migrations/2020-12-03-035643_create_user_aggregates/up.sql @@ -2,10 +2,10 @@ create table user_aggregates ( id serial primary key, user_id int references user_ on update cascade on delete cascade not null, - post_count bigint not null, - post_score bigint not null, - comment_count bigint not null, - comment_score bigint not null, + post_count bigint not null default 0, + post_score bigint not null default 0, + comment_count bigint not null default 0, + comment_score bigint not null default 0, unique (user_id) ); @@ -35,6 +35,25 @@ insert into user_aggregates (user_id, post_count, post_score, comment_count, com -- Add user aggregate triggers + +-- initial user add +create function user_aggregates_user() +returns trigger language plpgsql +as $$ +begin + IF (TG_OP = 'INSERT') THEN + insert into user_aggregates (user_id) values (NEW.id); + ELSIF (TG_OP = 'DELETE') THEN + delete from user_aggregates where user_id = OLD.id; + END IF; + return null; +end $$; + +create trigger user_aggregates_user +after insert or delete on user_ +for each row +execute procedure user_aggregates_user(); + -- post count create function user_aggregates_post_count() returns trigger language plpgsql @@ -43,9 +62,26 @@ begin IF (TG_OP = 'INSERT') THEN update user_aggregates set post_count = post_count + 1 where user_id = NEW.creator_id; + ELSIF (TG_OP = 'DELETE') THEN update user_aggregates set post_count = post_count - 1 where user_id = OLD.creator_id; + + -- If the post gets deleted, the score calculation trigger won't fire, + -- so you need to re-calculate + update user_aggregates ua + set post_score = pd.score + from ( + select u.id, + coalesce(0, sum(pl.score)) as score + -- User join because posts could be empty + from user_ u + left join post p on u.id = p.creator_id + left join post_like pl on p.id = pl.post_id + group by u.id + ) pd + where ua.user_id = pd.id; + END IF; return null; end $$; @@ -63,15 +99,16 @@ begin IF (TG_OP = 'INSERT') THEN -- TODO not sure if this is working right -- Need to get the post creator, not the voter - update user_aggregates + update user_aggregates ua set post_score = post_score + NEW.score - from post_like pl join post p on p.id = pl.post_id - where p.id = NEW.post_id and p.creator_id = NEW.user_id; + from post p + where ua.user_id = p.creator_id and p.id = NEW.post_id; + ELSIF (TG_OP = 'DELETE') THEN - update user_aggregates + update user_aggregates ua set post_score = post_score - OLD.score - from post_like pl join post p on p.id = pl.post_id - where p.id = OLD.post_id and p.creator_id = OLD.user_id; + from post p + where ua.user_id = p.creator_id and p.id = OLD.post_id; END IF; return null; end $$; @@ -92,6 +129,21 @@ begin ELSIF (TG_OP = 'DELETE') THEN update user_aggregates set comment_count = comment_count - 1 where user_id = OLD.creator_id; + + -- If the comment gets deleted, the score calculation trigger won't fire, + -- so you need to re-calculate + update user_aggregates ua + set comment_score = cd.score + from ( + select u.id, + coalesce(0, sum(cl.score)) as score + -- User join because comments could be empty + from user_ u + left join comment c on u.id = c.creator_id + left join comment_like cl on c.id = cl.comment_id + group by u.id + ) cd + where ua.user_id = cd.id; END IF; return null; end $$; @@ -108,19 +160,20 @@ as $$ begin IF (TG_OP = 'INSERT') THEN -- Need to get the post creator, not the voter - update user_aggregates + update user_aggregates ua set comment_score = comment_score + NEW.score - from comment_like pl join comment p on p.id = pl.comment_id - where p.id = NEW.comment_id and p.creator_id = NEW.user_id; + from comment c + where ua.user_id = c.creator_id and c.id = NEW.comment_id; ELSIF (TG_OP = 'DELETE') THEN - update user_aggregates + update user_aggregates ua set comment_score = comment_score - OLD.score - from comment_like pl join comment p on p.id = pl.comment_id - where p.id = OLD.comment_id and p.creator_id = OLD.user_id; + from comment c + where ua.user_id = c.creator_id and c.id = OLD.comment_id; END IF; return null; end $$; create trigger user_aggregates_comment_score after insert or delete on comment_like +for each row execute procedure user_aggregates_comment_score();