From 0bfbd74e59f57a0ff0cb8df3f6604d3819599752 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Sun, 5 Jan 2025 12:48:57 -0500 Subject: [PATCH] Adding combined person content and person saved tables. (#5251) * Combined tables try 2 * Finishing up combined report table. * Fix ts optionals. * Adding tests, triggers, and history updates for report_combined. * Adding profile. * Add cursor pagination to report_combined view (#5244) * add pagination cursor * store timestamp instead of id in cursor (partial) * Revert "store timestamp instead of id in cursor (partial)" This reverts commit 89359dde4bc5fee39fdd2840828330f398444a36. * use paginated query builder * Fixing migration and paged API. * Using dullbananas trigger procedure * Removing pointless list routes, reorganizing tests. * Fixing column XOR check. * Forgot to remove list report actions. * Cleanup. * Use internal tagging. * Fixing api tests. * Adding a few indexes. * Fixing migration name. * Fixing unique constraints. * Addressing PR comments. * Start working on profile combined * Adding views and replaceable schema. * A few changes to profile view. - Separating the profile fetch from its combined content fetch. - Starting to separate saved_only into its own combined view. * Finishing up combined person_saved and person_content. * Fixing api tests. * Moving to api-v4 routes. * Fixing imports. * Update crates/db_views/src/report_combined_view.rs Co-authored-by: dullbananas * Update crates/db_views/src/report_combined_view.rs Co-authored-by: dullbananas * Update crates/db_views/src/report_combined_view.rs Co-authored-by: dullbananas * Update migrations/2024-12-02-181601_add_report_combined_table/up.sql Co-authored-by: dullbananas * Update migrations/2024-12-02-181601_add_report_combined_table/up.sql Co-authored-by: dullbananas * Fixing import and fmt. * Fixing null types in postgres. * Comment out err. * Fixing TS issues. * Using dullbananas trigger procedure * Addressing PR comments. * Removing serialization * Removing serialization * Fixing duped trigger. * Remove saved_only test. * Remove pointless post_tags types. * Remove pointless index. * Changing published to saved for person_saved_combined. --------- Co-authored-by: dullbananas --- Cargo.lock | 1 + crates/api/src/comment/save.rs | 5 +- crates/api/src/local_user/list_saved.rs | 42 ++ crates/api/src/local_user/mod.rs | 1 + crates/api_common/src/comment.rs | 2 - crates/api_common/src/person.rs | 73 ++- crates/api_common/src/site.rs | 2 - crates/apub/src/api/list_comments.rs | 2 - crates/apub/src/api/list_person_content.rs | 52 ++ crates/apub/src/api/list_posts.rs | 2 - crates/apub/src/api/mod.rs | 35 +- crates/apub/src/api/read_person.rs | 80 +-- crates/apub/src/api/search.rs | 3 - crates/apub/src/api/user_settings_backup.rs | 5 +- .../db_schema/replaceable_schema/triggers.sql | 76 +++ crates/db_schema/src/impls/comment.rs | 10 +- crates/db_schema/src/lib.rs | 10 + crates/db_schema/src/newtypes.rs | 10 + crates/db_schema/src/schema.rs | 26 + crates/db_schema/src/source/combined/mod.rs | 2 + .../src/source/combined/person_content.rs | 22 + .../src/source/combined/person_saved.rs | 23 + crates/db_schema/src/source/comment.rs | 3 + crates/db_views/Cargo.toml | 1 + crates/db_views/src/comment_view.rs | 61 +-- crates/db_views/src/lib.rs | 11 + .../src/person_content_combined_view.rs | 456 ++++++++++++++++++ .../src/person_saved_combined_view.rs | 417 ++++++++++++++++ crates/db_views/src/post_view.rs | 68 +-- crates/db_views/src/report_combined_view.rs | 165 ++++--- crates/db_views/src/structs.rs | 53 ++ .../down.sql | 4 + .../up.sql | 67 +++ src/api_routes_v4.rs | 11 +- 34 files changed, 1492 insertions(+), 309 deletions(-) create mode 100644 crates/api/src/local_user/list_saved.rs create mode 100644 crates/apub/src/api/list_person_content.rs create mode 100644 crates/db_schema/src/source/combined/person_content.rs create mode 100644 crates/db_schema/src/source/combined/person_saved.rs create mode 100644 crates/db_views/src/person_content_combined_view.rs create mode 100644 crates/db_views/src/person_saved_combined_view.rs create mode 100644 migrations/2024-12-05-233704_add_person_content_combined_table/down.sql create mode 100644 migrations/2024-12-05-233704_add_person_content_combined_table/up.sql diff --git a/Cargo.lock b/Cargo.lock index 3226ea2e7..6f7fedfb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2676,6 +2676,7 @@ version = "0.19.6-beta.7" dependencies = [ "actix-web", "chrono", + "derive-new", "diesel", "diesel-async", "diesel_ltree", diff --git a/crates/api/src/comment/save.rs b/crates/api/src/comment/save.rs index 6efa6296d..cca6d06bc 100644 --- a/crates/api/src/comment/save.rs +++ b/crates/api/src/comment/save.rs @@ -16,10 +16,7 @@ pub async fn save_comment( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { - let comment_saved_form = CommentSavedForm { - comment_id: data.comment_id, - person_id: local_user_view.person.id, - }; + let comment_saved_form = CommentSavedForm::new(data.comment_id, local_user_view.person.id); if data.save { CommentSaved::save(&mut context.pool(), &comment_saved_form) diff --git a/crates/api/src/local_user/list_saved.rs b/crates/api/src/local_user/list_saved.rs new file mode 100644 index 000000000..bdb1b6b0a --- /dev/null +++ b/crates/api/src/local_user/list_saved.rs @@ -0,0 +1,42 @@ +use activitypub_federation::config::Data; +use actix_web::web::{Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + person::{ListPersonSaved, ListPersonSavedResponse}, + utils::check_private_instance, +}; +use lemmy_db_views::{ + person_saved_combined_view::PersonSavedCombinedQuery, + structs::{LocalUserView, SiteView}, +}; +use lemmy_utils::error::LemmyResult; + +#[tracing::instrument(skip(context))] +pub async fn list_person_saved( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let local_site = SiteView::read_local(&mut context.pool()).await?; + + check_private_instance(&Some(local_user_view.clone()), &local_site.local_site)?; + + // parse pagination token + let page_after = if let Some(pa) = &data.page_cursor { + Some(pa.read(&mut context.pool()).await?) + } else { + None + }; + let page_back = data.page_back; + let type_ = data.type_; + + let saved = PersonSavedCombinedQuery { + type_, + page_after, + page_back, + } + .list(&mut context.pool(), &local_user_view) + .await?; + + Ok(Json(ListPersonSavedResponse { saved })) +} diff --git a/crates/api/src/local_user/mod.rs b/crates/api/src/local_user/mod.rs index d3fc37a73..0dcd24aff 100644 --- a/crates/api/src/local_user/mod.rs +++ b/crates/api/src/local_user/mod.rs @@ -8,6 +8,7 @@ pub mod get_captcha; pub mod list_banned; pub mod list_logins; pub mod list_media; +pub mod list_saved; pub mod login; pub mod logout; pub mod notifications; diff --git a/crates/api_common/src/comment.rs b/crates/api_common/src/comment.rs index bae9c4de4..0d416e9f0 100644 --- a/crates/api_common/src/comment.rs +++ b/crates/api_common/src/comment.rs @@ -131,8 +131,6 @@ pub struct GetComments { #[cfg_attr(feature = "full", ts(optional))] pub parent_id: Option, #[cfg_attr(feature = "full", ts(optional))] - pub saved_only: Option, - #[cfg_attr(feature = "full", ts(optional))] pub liked_only: Option, #[cfg_attr(feature = "full", ts(optional))] pub disliked_only: Option, diff --git a/crates/api_common/src/person.rs b/crates/api_common/src/person.rs index 797946d65..4781683d4 100644 --- a/crates/api_common/src/person.rs +++ b/crates/api_common/src/person.rs @@ -4,10 +4,16 @@ use lemmy_db_schema::{ source::{login_token::LoginToken, site::Site}, CommentSortType, ListingType, + PersonContentType, PostListingMode, PostSortType, }; -use lemmy_db_views::structs::{CommentView, LocalImageView, PostView}; +use lemmy_db_views::structs::{ + LocalImageView, + PersonContentCombinedPaginationCursor, + PersonContentCombinedView, + PersonSavedCombinedPaginationCursor, +}; use lemmy_db_views_actor::structs::{ CommentReplyView, CommunityModeratorView, @@ -222,16 +228,6 @@ pub struct GetPersonDetails { /// Example: dessalines , or dessalines@xyz.tld #[cfg_attr(feature = "full", ts(optional))] pub username: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub sort: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub page: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub limit: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub community_id: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub saved_only: Option, } #[skip_serializing_none] @@ -243,11 +239,62 @@ pub struct GetPersonDetailsResponse { pub person_view: PersonView, #[cfg_attr(feature = "full", ts(optional))] pub site: Option, - pub comments: Vec, - pub posts: Vec, pub moderates: Vec, } +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Gets a person's content (posts and comments) +/// +/// Either person_id, or username are required. +pub struct ListPersonContent { + #[cfg_attr(feature = "full", ts(optional))] + pub type_: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub person_id: Option, + /// Example: dessalines , or dessalines@xyz.tld + #[cfg_attr(feature = "full", ts(optional))] + pub username: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub page_cursor: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub page_back: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// A person's content response. +pub struct ListPersonContentResponse { + pub content: Vec, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// Gets your saved posts and comments +pub struct ListPersonSaved { + #[cfg_attr(feature = "full", ts(optional))] + pub type_: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub page_cursor: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub page_back: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// A person's saved content response. +pub struct ListPersonSavedResponse { + pub saved: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] diff --git a/crates/api_common/src/site.rs b/crates/api_common/src/site.rs index 7f1000b14..35f59fd44 100644 --- a/crates/api_common/src/site.rs +++ b/crates/api_common/src/site.rs @@ -94,8 +94,6 @@ pub struct Search { #[cfg_attr(feature = "full", ts(optional))] pub post_url_only: Option, #[cfg_attr(feature = "full", ts(optional))] - pub saved_only: Option, - #[cfg_attr(feature = "full", ts(optional))] pub liked_only: Option, #[cfg_attr(feature = "full", ts(optional))] pub disliked_only: Option, diff --git a/crates/apub/src/api/list_comments.rs b/crates/apub/src/api/list_comments.rs index 05195b3af..45d241073 100644 --- a/crates/apub/src/api/list_comments.rs +++ b/crates/apub/src/api/list_comments.rs @@ -46,7 +46,6 @@ pub async fn list_comments( &site_view.local_site, )); let max_depth = data.max_depth; - let saved_only = data.saved_only; let liked_only = data.liked_only; let disliked_only = data.disliked_only; @@ -78,7 +77,6 @@ pub async fn list_comments( listing_type, sort, max_depth, - saved_only, liked_only, disliked_only, community_id, diff --git a/crates/apub/src/api/list_person_content.rs b/crates/apub/src/api/list_person_content.rs new file mode 100644 index 000000000..774f80766 --- /dev/null +++ b/crates/apub/src/api/list_person_content.rs @@ -0,0 +1,52 @@ +use super::resolve_person_id_from_id_or_username; +use activitypub_federation::config::Data; +use actix_web::web::{Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + person::{ListPersonContent, ListPersonContentResponse}, + utils::check_private_instance, +}; +use lemmy_db_views::{ + person_content_combined_view::PersonContentCombinedQuery, + structs::{LocalUserView, SiteView}, +}; +use lemmy_utils::error::LemmyResult; + +#[tracing::instrument(skip(context))] +pub async fn list_person_content( + data: Query, + context: Data, + local_user_view: Option, +) -> LemmyResult> { + let local_site = SiteView::read_local(&mut context.pool()).await?; + + check_private_instance(&local_user_view, &local_site.local_site)?; + + let person_details_id = resolve_person_id_from_id_or_username( + &data.person_id, + &data.username, + &context, + &local_user_view, + ) + .await?; + + // parse pagination token + let page_after = if let Some(pa) = &data.page_cursor { + Some(pa.read(&mut context.pool()).await?) + } else { + None + }; + let page_back = data.page_back; + let type_ = data.type_; + + let content = PersonContentCombinedQuery { + creator_id: person_details_id, + type_, + page_after, + page_back, + } + .list(&mut context.pool(), &local_user_view) + .await?; + + Ok(Json(ListPersonContentResponse { content })) +} diff --git a/crates/apub/src/api/list_posts.rs b/crates/apub/src/api/list_posts.rs index 20e25f9a5..7361c8f35 100644 --- a/crates/apub/src/api/list_posts.rs +++ b/crates/apub/src/api/list_posts.rs @@ -41,7 +41,6 @@ pub async fn list_posts( } else { data.community_id }; - let saved_only = data.saved_only; let read_only = data.read_only; let show_hidden = data.show_hidden; let show_read = data.show_read; @@ -78,7 +77,6 @@ pub async fn list_posts( listing_type, sort, community_id, - saved_only, read_only, liked_only, disliked_only, diff --git a/crates/apub/src/api/mod.rs b/crates/apub/src/api/mod.rs index 580be3228..9359eabc4 100644 --- a/crates/apub/src/api/mod.rs +++ b/crates/apub/src/api/mod.rs @@ -1,12 +1,18 @@ +use crate::{fetcher::resolve_actor_identifier, objects::person::ApubPerson}; +use activitypub_federation::config::Data; +use lemmy_api_common::{context::LemmyContext, LemmyErrorType}; use lemmy_db_schema::{ - newtypes::CommunityId, - source::{local_site::LocalSite, local_user::LocalUser}, + newtypes::{CommunityId, PersonId}, + source::{local_site::LocalSite, local_user::LocalUser, person::Person}, CommentSortType, ListingType, PostSortType, }; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::LemmyResult; pub mod list_comments; +pub mod list_person_content; pub mod list_posts; pub mod read_community; pub mod read_person; @@ -61,3 +67,28 @@ fn comment_sort_type_with_default( .unwrap_or(local_site.default_comment_sort_type), ) } + +async fn resolve_person_id_from_id_or_username( + person_id: &Option, + username: &Option, + context: &Data, + local_user_view: &Option, +) -> LemmyResult { + // Check to make sure a person name or an id is given + if username.is_none() && person_id.is_none() { + Err(LemmyErrorType::NoIdGiven)? + } + + Ok(match person_id { + Some(id) => *id, + None => { + if let Some(username) = username { + resolve_actor_identifier::(username, context, local_user_view, true) + .await? + .id + } else { + Err(LemmyErrorType::NotFound)? + } + } + }) +} diff --git a/crates/apub/src/api/read_person.rs b/crates/apub/src/api/read_person.rs index 72dce8140..b79871b93 100644 --- a/crates/apub/src/api/read_person.rs +++ b/crates/apub/src/api/read_person.rs @@ -1,4 +1,4 @@ -use crate::{fetcher::resolve_actor_identifier, objects::person::ApubPerson}; +use super::resolve_person_id_from_id_or_username; use activitypub_federation::config::Data; use actix_web::web::{Json, Query}; use lemmy_api_common::{ @@ -6,14 +6,9 @@ use lemmy_api_common::{ person::{GetPersonDetails, GetPersonDetailsResponse}, utils::{check_private_instance, is_admin, read_site_for_actor}, }; -use lemmy_db_schema::{source::person::Person, utils::post_to_comment_sort_type}; -use lemmy_db_views::{ - comment_view::CommentQuery, - post_view::PostQuery, - structs::{LocalUserView, SiteView}, -}; +use lemmy_db_views::structs::{LocalUserView, SiteView}; use lemmy_db_views_actor::structs::{CommunityModeratorView, PersonView}; -use lemmy_utils::error::{LemmyErrorType, LemmyResult}; +use lemmy_utils::error::LemmyResult; #[tracing::instrument(skip(context))] pub async fn read_person( @@ -21,27 +16,17 @@ pub async fn read_person( context: Data, local_user_view: Option, ) -> LemmyResult> { - // Check to make sure a person name or an id is given - if data.username.is_none() && data.person_id.is_none() { - Err(LemmyErrorType::NoIdGiven)? - } - let local_site = SiteView::read_local(&mut context.pool()).await?; check_private_instance(&local_user_view, &local_site.local_site)?; - let person_details_id = match data.person_id { - Some(id) => id, - None => { - if let Some(username) = &data.username { - resolve_actor_identifier::(username, &context, &local_user_view, true) - .await? - .id - } else { - Err(LemmyErrorType::NotFound)? - } - } - }; + let person_details_id = resolve_person_id_from_id_or_username( + &data.person_id, + &data.username, + &context, + &local_user_view, + ) + .await?; // You don't need to return settings for the user, since this comes back with GetSite // `my_user` @@ -50,48 +35,6 @@ pub async fn read_person( .map(|l| is_admin(l).is_ok()) .unwrap_or_default(); let person_view = PersonView::read(&mut context.pool(), person_details_id, is_admin).await?; - - let sort = data.sort; - let page = data.page; - let limit = data.limit; - let saved_only = data.saved_only; - let community_id = data.community_id; - // If its saved only, you don't care what creator it was - // Or, if its not saved, then you only want it for that specific creator - let creator_id = if !saved_only.unwrap_or_default() { - Some(person_details_id) - } else { - None - }; - - let local_user = local_user_view.as_ref().map(|l| &l.local_user); - - let posts = PostQuery { - sort, - saved_only, - local_user, - community_id, - page, - limit, - creator_id, - ..Default::default() - } - .list(&local_site.site, &mut context.pool()) - .await?; - - let comments = CommentQuery { - local_user, - sort: sort.map(post_to_comment_sort_type), - saved_only, - community_id, - page, - limit, - creator_id, - ..Default::default() - } - .list(&local_site.site, &mut context.pool()) - .await?; - let moderates = CommunityModeratorView::for_person( &mut context.pool(), person_details_id, @@ -101,12 +44,9 @@ pub async fn read_person( let site = read_site_for_actor(person_view.person.actor_id.clone(), &context).await?; - // Return the jwt Ok(Json(GetPersonDetailsResponse { person_view, site, moderates, - comments, - posts, })) } diff --git a/crates/apub/src/api/search.rs b/crates/apub/src/api/search.rs index cdc9bc55e..0ae7053d3 100644 --- a/crates/apub/src/api/search.rs +++ b/crates/apub/src/api/search.rs @@ -53,7 +53,6 @@ pub async fn search( limit, title_only, post_url_only, - saved_only, liked_only, disliked_only, }) = data; @@ -86,7 +85,6 @@ pub async fn search( url_only: post_url_only, liked_only, disliked_only, - saved_only, ..Default::default() }; @@ -101,7 +99,6 @@ pub async fn search( limit, liked_only, disliked_only, - saved_only, ..Default::default() }; diff --git a/crates/apub/src/api/user_settings_backup.rs b/crates/apub/src/api/user_settings_backup.rs index 6184df7d3..d98df25ad 100644 --- a/crates/apub/src/api/user_settings_backup.rs +++ b/crates/apub/src/api/user_settings_backup.rs @@ -212,10 +212,7 @@ pub async fn import_settings( &context, |(saved, context)| async move { let comment = saved.dereference(&context).await?; - let form = CommentSavedForm { - person_id, - comment_id: comment.id, - }; + let form = CommentSavedForm::new(comment.id, person_id); CommentSaved::save(&mut context.pool(), &form).await?; LemmyResult::Ok(()) }, diff --git a/crates/db_schema/replaceable_schema/triggers.sql b/crates/db_schema/replaceable_schema/triggers.sql index 2d9b0df6e..77d368287 100644 --- a/crates/db_schema/replaceable_schema/triggers.sql +++ b/crates/db_schema/replaceable_schema/triggers.sql @@ -685,3 +685,79 @@ CALL r.create_report_combined_trigger ('comment_report'); CALL r.create_report_combined_trigger ('private_message_report'); +-- person_content (comment, post) +CREATE PROCEDURE r.create_person_content_combined_trigger (table_name text) +LANGUAGE plpgsql +AS $a$ +BEGIN + EXECUTE replace($b$ CREATE FUNCTION r.person_content_combined_thing_insert ( ) + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + BEGIN + INSERT INTO person_content_combined (published, thing_id) + VALUES (NEW.published, NEW.id); + RETURN NEW; + END $$; + CREATE TRIGGER person_content_combined + AFTER INSERT ON thing + FOR EACH ROW + EXECUTE FUNCTION r.person_content_combined_thing_insert ( ); + $b$, + 'thing', + table_name); +END; +$a$; + +CALL r.create_person_content_combined_trigger ('post'); + +CALL r.create_person_content_combined_trigger ('comment'); + +-- person_saved (comment, post) +-- This one is a little different, because it triggers using x_actions.saved, +-- Rather than any row insert +CREATE PROCEDURE r.create_person_saved_combined_trigger (table_name text) +LANGUAGE plpgsql +AS $a$ +BEGIN + EXECUTE replace($b$ CREATE FUNCTION r.person_saved_combined_change_values_thing ( ) + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + BEGIN + IF (TG_OP = 'DELETE') THEN + DELETE FROM person_saved_combined AS p + WHERE p.person_id = OLD.person_id + AND p.thing_id = OLD.thing_id; + ELSIF (TG_OP = 'INSERT') THEN + IF NEW.saved IS NOT NULL THEN + INSERT INTO person_saved_combined (saved, person_id, thing_id) + VALUES (NEW.saved, NEW.person_id, NEW.thing_id); + END IF; + ELSIF (TG_OP = 'UPDATE') THEN + IF NEW.saved IS NOT NULL THEN + INSERT INTO person_saved_combined (saved, person_id, thing_id) + VALUES (NEW.saved, NEW.person_id, NEW.thing_id); + -- If saved gets set as null, delete the row + ELSE + DELETE FROM person_saved_combined AS p + WHERE p.person_id = NEW.person_id + AND p.thing_id = NEW.thing_id; + END IF; + END IF; + RETURN NULL; + END $$; + CREATE TRIGGER person_saved_combined + AFTER INSERT OR DELETE OR UPDATE OF saved ON thing_actions + FOR EACH ROW + EXECUTE FUNCTION r.person_saved_combined_change_values_thing ( ); + $b$, + 'thing', + table_name); +END; +$a$; + +CALL r.create_person_saved_combined_trigger ('post'); + +CALL r.create_person_saved_combined_trigger ('comment'); + diff --git a/crates/db_schema/src/impls/comment.rs b/crates/db_schema/src/impls/comment.rs index 7dcc033a1..17cd6ce5c 100644 --- a/crates/db_schema/src/impls/comment.rs +++ b/crates/db_schema/src/impls/comment.rs @@ -184,10 +184,6 @@ impl Saveable for CommentSaved { comment_saved_form: &CommentSavedForm, ) -> Result { let conn = &mut get_conn(pool).await?; - let comment_saved_form = ( - comment_saved_form, - comment_actions::saved.eq(now().nullable()), - ); insert_into(comment_actions::table) .values(comment_saved_form) .on_conflict((comment_actions::comment_id, comment_actions::person_id)) @@ -319,11 +315,7 @@ mod tests { }; // Comment Saved - let comment_saved_form = CommentSavedForm { - comment_id: inserted_comment.id, - person_id: inserted_person.id, - }; - + let comment_saved_form = CommentSavedForm::new(inserted_comment.id, inserted_person.id); let inserted_comment_saved = CommentSaved::save(pool, &comment_saved_form).await?; let expected_comment_saved = CommentSaved { diff --git a/crates/db_schema/src/lib.rs b/crates/db_schema/src/lib.rs index ad6e93619..9a292cf82 100644 --- a/crates/db_schema/src/lib.rs +++ b/crates/db_schema/src/lib.rs @@ -219,6 +219,16 @@ pub enum ModlogActionType { AdminAllowInstance, } +#[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// A list of possible types for the various modlog actions. +pub enum PersonContentType { + All, + Comments, + Posts, +} + #[derive( EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash, )] diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index 4c4a9b66c..f4f53ee3d 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -184,6 +184,16 @@ pub struct DbUrl(pub(crate) Box); /// The report combined id pub struct ReportCombinedId(i32); +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default)] +#[cfg_attr(feature = "full", derive(DieselNewType))] +/// The person content combined id +pub struct PersonContentCombinedId(i32); + +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default)] +#[cfg_attr(feature = "full", derive(DieselNewType))] +/// The person saved combined id +pub struct PersonSavedCombinedId(i32); + impl DbUrl { pub fn inner(&self) -> &Url { &self.0 diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 64aff118b..7bdeeb6f2 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -729,6 +729,15 @@ diesel::table! { } } +diesel::table! { + person_content_combined (id) { + id -> Int4, + published -> Timestamptz, + post_id -> Nullable, + comment_id -> Nullable, + } +} + diesel::table! { person_mention (id) { id -> Int4, @@ -739,6 +748,16 @@ diesel::table! { } } +diesel::table! { + person_saved_combined (id) { + id -> Int4, + saved -> Timestamptz, + person_id -> Int4, + post_id -> Nullable, + comment_id -> Nullable, + } +} + diesel::table! { post (id) { id -> Int4, @@ -1050,8 +1069,13 @@ diesel::joinable!(password_reset_request -> local_user (local_user_id)); diesel::joinable!(person -> instance (instance_id)); diesel::joinable!(person_aggregates -> person (person_id)); diesel::joinable!(person_ban -> person (person_id)); +diesel::joinable!(person_content_combined -> comment (comment_id)); +diesel::joinable!(person_content_combined -> post (post_id)); diesel::joinable!(person_mention -> comment (comment_id)); diesel::joinable!(person_mention -> person (recipient_id)); +diesel::joinable!(person_saved_combined -> comment (comment_id)); +diesel::joinable!(person_saved_combined -> person (person_id)); +diesel::joinable!(person_saved_combined -> post (post_id)); diesel::joinable!(post -> community (community_id)); diesel::joinable!(post -> language (language_id)); diesel::joinable!(post -> person (creator_id)); @@ -1129,7 +1153,9 @@ diesel::allow_tables_to_appear_in_same_query!( person_actions, person_aggregates, person_ban, + person_content_combined, person_mention, + person_saved_combined, post, post_actions, post_aggregates, diff --git a/crates/db_schema/src/source/combined/mod.rs b/crates/db_schema/src/source/combined/mod.rs index 7352eef8e..b2b5e7d8e 100644 --- a/crates/db_schema/src/source/combined/mod.rs +++ b/crates/db_schema/src/source/combined/mod.rs @@ -1 +1,3 @@ +pub mod person_content; +pub mod person_saved; pub mod report; diff --git a/crates/db_schema/src/source/combined/person_content.rs b/crates/db_schema/src/source/combined/person_content.rs new file mode 100644 index 000000000..05f8c1a46 --- /dev/null +++ b/crates/db_schema/src/source/combined/person_content.rs @@ -0,0 +1,22 @@ +use crate::newtypes::{CommentId, PersonContentCombinedId, PostId}; +#[cfg(feature = "full")] +use crate::schema::person_content_combined; +use chrono::{DateTime, Utc}; +#[cfg(feature = "full")] +use i_love_jesus::CursorKeysModule; + +#[derive(PartialEq, Eq, Debug, Clone)] +#[cfg_attr( + feature = "full", + derive(Identifiable, Queryable, Selectable, CursorKeysModule) +)] +#[cfg_attr(feature = "full", diesel(table_name = person_content_combined))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", cursor_keys_module(name = person_content_combined_keys))] +/// A combined table for a persons contents (posts and comments) +pub struct PersonContentCombined { + pub id: PersonContentCombinedId, + pub published: DateTime, + pub post_id: Option, + pub comment_id: Option, +} diff --git a/crates/db_schema/src/source/combined/person_saved.rs b/crates/db_schema/src/source/combined/person_saved.rs new file mode 100644 index 000000000..bee11e8b8 --- /dev/null +++ b/crates/db_schema/src/source/combined/person_saved.rs @@ -0,0 +1,23 @@ +use crate::newtypes::{CommentId, PersonId, PersonSavedCombinedId, PostId}; +#[cfg(feature = "full")] +use crate::schema::person_saved_combined; +use chrono::{DateTime, Utc}; +#[cfg(feature = "full")] +use i_love_jesus::CursorKeysModule; + +#[derive(PartialEq, Eq, Debug, Clone)] +#[cfg_attr( + feature = "full", + derive(Identifiable, Queryable, Selectable, CursorKeysModule) +)] +#[cfg_attr(feature = "full", diesel(table_name = person_saved_combined))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", cursor_keys_module(name = person_saved_combined_keys))] +/// A combined person_saved table. +pub struct PersonSavedCombined { + pub id: PersonSavedCombinedId, + pub saved: DateTime, + pub person_id: PersonId, + pub post_id: Option, + pub comment_id: Option, +} diff --git a/crates/db_schema/src/source/comment.rs b/crates/db_schema/src/source/comment.rs index d4001807f..cc5d8c20c 100644 --- a/crates/db_schema/src/source/comment.rs +++ b/crates/db_schema/src/source/comment.rs @@ -142,7 +142,10 @@ pub struct CommentSaved { #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] #[cfg_attr(feature = "full", diesel(table_name = comment_actions))] +#[derive(derive_new::new)] pub struct CommentSavedForm { pub comment_id: CommentId, pub person_id: PersonId, + #[new(value = "Utc::now()")] + pub saved: DateTime, } diff --git a/crates/db_views/Cargo.toml b/crates/db_views/Cargo.toml index 8b0669ff9..f7d4b1d7a 100644 --- a/crates/db_views/Cargo.toml +++ b/crates/db_views/Cargo.toml @@ -41,6 +41,7 @@ ts-rs = { workspace = true, optional = true } actix-web = { workspace = true, optional = true } i-love-jesus = { workspace = true, optional = true } chrono = { workspace = true } +derive-new.workspace = true [dev-dependencies] serial_test = { workspace = true } diff --git a/crates/db_views/src/comment_view.rs b/crates/db_views/src/comment_view.rs index 504f3cc4e..776cab8e2 100644 --- a/crates/db_views/src/comment_view.rs +++ b/crates/db_views/src/comment_view.rs @@ -186,13 +186,6 @@ fn queries<'a>() -> Queries< } } - // If its saved only, then filter, and order by the saved time, not the comment creation time. - if o.saved_only.unwrap_or_default() { - query = query - .filter(comment_actions::saved.is_not_null()) - .then_order_by(comment_actions::saved.desc()); - } - if let Some(my_id) = o.local_user.person_id() { let not_creator_filter = comment::creator_id.ne(my_id); if o.liked_only.unwrap_or_default() { @@ -332,7 +325,6 @@ pub struct CommentQuery<'a> { pub creator_id: Option, pub local_user: Option<&'a LocalUser>, pub search_term: Option, - pub saved_only: Option, pub liked_only: Option, pub disliked_only: Option, pub page: Option, @@ -376,15 +368,7 @@ mod tests { newtypes::LanguageId, source::{ actor_language::LocalUserLanguage, - comment::{ - Comment, - CommentInsertForm, - CommentLike, - CommentLikeForm, - CommentSaved, - CommentSavedForm, - CommentUpdateForm, - }, + comment::{Comment, CommentInsertForm, CommentLike, CommentLikeForm, CommentUpdateForm}, community::{ Community, CommunityFollower, @@ -406,7 +390,7 @@ mod tests { post::{Post, PostInsertForm, PostUpdateForm}, site::{Site, SiteInsertForm}, }, - traits::{Bannable, Blockable, Crud, Followable, Joinable, Likeable, Saveable}, + traits::{Bannable, Blockable, Crud, Followable, Joinable, Likeable}, utils::{build_db_pool_for_tests, RANK_DEFAULT}, CommunityVisibility, SubscribedType, @@ -892,47 +876,6 @@ mod tests { cleanup(data, pool).await } - #[tokio::test] - #[serial] - async fn test_saved_order() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - let data = init_data(pool).await?; - - // Save two comments - let save_comment_0_form = CommentSavedForm { - person_id: data.timmy_local_user_view.person.id, - comment_id: data.inserted_comment_0.id, - }; - CommentSaved::save(pool, &save_comment_0_form).await?; - - let save_comment_2_form = CommentSavedForm { - person_id: data.timmy_local_user_view.person.id, - comment_id: data.inserted_comment_2.id, - }; - CommentSaved::save(pool, &save_comment_2_form).await?; - - // Fetch the saved comments - let comments = CommentQuery { - local_user: Some(&data.timmy_local_user_view.local_user), - saved_only: Some(true), - ..Default::default() - } - .list(&data.site, pool) - .await?; - - // There should only be two comments - assert_eq!(2, comments.len()); - - // The first comment, should be the last one saved (descending order) - assert_eq!(comments[0].comment.id, data.inserted_comment_2.id); - - // The second comment, should be the first one saved - assert_eq!(comments[1].comment.id, data.inserted_comment_0.id); - - cleanup(data, pool).await - } - async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { CommentLike::remove( pool, diff --git a/crates/db_views/src/lib.rs b/crates/db_views/src/lib.rs index 6c9b21023..c1d5ada60 100644 --- a/crates/db_views/src/lib.rs +++ b/crates/db_views/src/lib.rs @@ -12,6 +12,10 @@ pub mod local_image_view; #[cfg(feature = "full")] pub mod local_user_view; #[cfg(feature = "full")] +pub mod person_content_combined_view; +#[cfg(feature = "full")] +pub mod person_saved_combined_view; +#[cfg(feature = "full")] pub mod post_report_view; #[cfg(feature = "full")] pub mod post_tags_view; @@ -30,3 +34,10 @@ pub mod site_view; pub mod structs; #[cfg(feature = "full")] pub mod vote_view; + +pub trait InternalToCombinedView { + type CombinedView; + + /// Maps the combined DB row to an enum + fn map_to_enum(&self) -> Option; +} diff --git a/crates/db_views/src/person_content_combined_view.rs b/crates/db_views/src/person_content_combined_view.rs new file mode 100644 index 000000000..c06712df2 --- /dev/null +++ b/crates/db_views/src/person_content_combined_view.rs @@ -0,0 +1,456 @@ +use crate::{ + structs::{ + CommentView, + LocalUserView, + PersonContentCombinedPaginationCursor, + PersonContentCombinedView, + PersonContentViewInternal, + PostView, + }, + InternalToCombinedView, +}; +use diesel::{ + result::Error, + BoolExpressionMethods, + ExpressionMethods, + JoinOnDsl, + NullableExpressionMethods, + QueryDsl, + SelectableHelper, +}; +use diesel_async::RunQueryDsl; +use i_love_jesus::PaginatedQueryBuilder; +use lemmy_db_schema::{ + aliases::creator_community_actions, + newtypes::PersonId, + schema::{ + comment, + comment_actions, + comment_aggregates, + community, + community_actions, + image_details, + local_user, + person, + person_actions, + person_content_combined, + post, + post_actions, + post_aggregates, + post_tag, + tag, + }, + source::{ + combined::person_content::{person_content_combined_keys as key, PersonContentCombined}, + community::CommunityFollower, + }, + utils::{actions, actions_alias, functions::coalesce, get_conn, DbPool}, + PersonContentType, +}; +use lemmy_utils::error::LemmyResult; + +impl PersonContentCombinedPaginationCursor { + // get cursor for page that starts immediately after the given post + pub fn after_post(view: &PersonContentCombinedView) -> PersonContentCombinedPaginationCursor { + let (prefix, id) = match view { + PersonContentCombinedView::Comment(v) => ('C', v.comment.id.0), + PersonContentCombinedView::Post(v) => ('P', v.post.id.0), + }; + // hex encoding to prevent ossification + PersonContentCombinedPaginationCursor(format!("{prefix}{id:x}")) + } + + pub async fn read(&self, pool: &mut DbPool<'_>) -> Result { + let err_msg = || Error::QueryBuilderError("Could not parse pagination token".into()); + let mut query = person_content_combined::table + .select(PersonContentCombined::as_select()) + .into_boxed(); + let (prefix, id_str) = self.0.split_at_checked(1).ok_or_else(err_msg)?; + let id = i32::from_str_radix(id_str, 16).map_err(|_err| err_msg())?; + query = match prefix { + "C" => query.filter(person_content_combined::comment_id.eq(id)), + "P" => query.filter(person_content_combined::post_id.eq(id)), + _ => return Err(err_msg()), + }; + let token = query.first(&mut get_conn(pool).await?).await?; + + Ok(PaginationCursorData(token)) + } +} + +#[derive(Clone)] +pub struct PaginationCursorData(PersonContentCombined); + +#[derive(derive_new::new)] +pub struct PersonContentCombinedQuery { + pub creator_id: PersonId, + #[new(default)] + pub type_: Option, + #[new(default)] + pub page_after: Option, + #[new(default)] + pub page_back: Option, +} + +impl PersonContentCombinedQuery { + pub async fn list( + self, + pool: &mut DbPool<'_>, + user: &Option, + ) -> LemmyResult> { + let my_person_id = user.as_ref().map(|u| u.local_user.person_id); + let item_creator = person::id; + + let conn = &mut get_conn(pool).await?; + + let post_tags = post_tag::table + .inner_join(tag::table) + .select(diesel::dsl::sql::( + "json_agg(tag.*)", + )) + .filter(post_tag::post_id.eq(post::id)) + .filter(tag::deleted.eq(false)) + .single_value(); + + // Notes: since the post_id and comment_id are optional columns, + // many joins must use an OR condition. + // For example, the creator must be the person table joined to either: + // - post.creator_id + // - comment.creator_id + let query = person_content_combined::table + // The comment + .left_join(comment::table.on(person_content_combined::comment_id.eq(comment::id.nullable()))) + // The post + // It gets a bit complicated here, because since both comments and post combined have a post + // attached, you can do an inner join. + .inner_join( + post::table.on( + person_content_combined::post_id + .eq(post::id.nullable()) + .or(comment::post_id.eq(post::id)), + ), + ) + // The item creator + .inner_join( + person::table.on( + comment::creator_id + .eq(item_creator) + // Need to filter out the post rows where the post_id given is null + // Otherwise you'll get duped post rows + .or( + post::creator_id + .eq(item_creator) + .and(person_content_combined::post_id.is_not_null()), + ), + ), + ) + // The community + .inner_join(community::table.on(post::community_id.eq(community::id))) + .left_join(actions_alias( + creator_community_actions, + item_creator, + post::community_id, + )) + .left_join( + local_user::table.on( + item_creator + .eq(local_user::person_id) + .and(local_user::admin.eq(true)), + ), + ) + .left_join(actions( + community_actions::table, + my_person_id, + post::community_id, + )) + .left_join(actions(post_actions::table, my_person_id, post::id)) + .left_join(actions(person_actions::table, my_person_id, item_creator)) + .inner_join(post_aggregates::table.on(post::id.eq(post_aggregates::post_id))) + .left_join( + comment_aggregates::table + .on(person_content_combined::comment_id.eq(comment_aggregates::comment_id.nullable())), + ) + .left_join(actions(comment_actions::table, my_person_id, comment::id)) + .left_join(image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable()))) + // The creator id filter + .filter(item_creator.eq(self.creator_id)) + .select(( + // Post-specific + post_aggregates::all_columns, + coalesce( + post_aggregates::comments.nullable() - post_actions::read_comments_amount.nullable(), + post_aggregates::comments, + ), + post_actions::saved.nullable().is_not_null(), + post_actions::read.nullable().is_not_null(), + post_actions::hidden.nullable().is_not_null(), + post_actions::like_score.nullable(), + image_details::all_columns.nullable(), + post_tags, + // Comment-specific + comment::all_columns.nullable(), + comment_aggregates::all_columns.nullable(), + comment_actions::saved.nullable().is_not_null(), + comment_actions::like_score.nullable(), + // Shared + post::all_columns, + community::all_columns, + person::all_columns, + CommunityFollower::select_subscribed_type(), + local_user::admin.nullable().is_not_null(), + creator_community_actions + .field(community_actions::became_moderator) + .nullable() + .is_not_null(), + creator_community_actions + .field(community_actions::received_ban) + .nullable() + .is_not_null(), + person_actions::blocked.nullable().is_not_null(), + community_actions::received_ban.nullable().is_not_null(), + )) + .into_boxed(); + + let mut query = PaginatedQueryBuilder::new(query); + + if let Some(type_) = self.type_ { + query = match type_ { + PersonContentType::All => query, + PersonContentType::Comments => { + query.filter(person_content_combined::comment_id.is_not_null()) + } + PersonContentType::Posts => query.filter(person_content_combined::post_id.is_not_null()), + } + } + + let page_after = self.page_after.map(|c| c.0); + + if self.page_back.unwrap_or_default() { + query = query.before(page_after).limit_and_offset_from_end(); + } else { + query = query.after(page_after); + } + + // Sorting by published + query = query + .then_desc(key::published) + // Tie breaker + .then_desc(key::id); + + let res = query.load::(conn).await?; + + // Map the query results to the enum + let out = res.into_iter().filter_map(|u| u.map_to_enum()).collect(); + + Ok(out) + } +} + +impl InternalToCombinedView for PersonContentViewInternal { + type CombinedView = PersonContentCombinedView; + + fn map_to_enum(&self) -> Option { + // Use for a short alias + let v = self.clone(); + + if let (Some(comment), Some(counts)) = (v.comment, v.comment_counts) { + Some(PersonContentCombinedView::Comment(CommentView { + comment, + counts, + post: v.post, + community: v.community, + creator: v.item_creator, + creator_banned_from_community: v.item_creator_banned_from_community, + creator_is_moderator: v.item_creator_is_moderator, + creator_is_admin: v.item_creator_is_admin, + creator_blocked: v.item_creator_blocked, + subscribed: v.subscribed, + saved: v.comment_saved, + my_vote: v.my_comment_vote, + banned_from_community: v.banned_from_community, + })) + } else { + Some(PersonContentCombinedView::Post(PostView { + post: v.post, + community: v.community, + unread_comments: v.post_unread_comments, + counts: v.post_counts, + creator: v.item_creator, + creator_banned_from_community: v.item_creator_banned_from_community, + creator_is_moderator: v.item_creator_is_moderator, + creator_is_admin: v.item_creator_is_admin, + creator_blocked: v.item_creator_blocked, + subscribed: v.subscribed, + saved: v.post_saved, + read: v.post_read, + hidden: v.post_hidden, + my_vote: v.my_post_vote, + image_details: v.image_details, + banned_from_community: v.banned_from_community, + tags: v.post_tags, + })) + } + } +} + +#[cfg(test)] +#[expect(clippy::indexing_slicing)] +mod tests { + + use crate::{ + person_content_combined_view::PersonContentCombinedQuery, + structs::PersonContentCombinedView, + }; + use lemmy_db_schema::{ + source::{ + comment::{Comment, CommentInsertForm}, + community::{Community, CommunityInsertForm}, + instance::Instance, + person::{Person, PersonInsertForm}, + post::{Post, PostInsertForm}, + }, + traits::Crud, + utils::{build_db_pool_for_tests, DbPool}, + }; + use lemmy_utils::error::LemmyResult; + use pretty_assertions::assert_eq; + use serial_test::serial; + + struct Data { + instance: Instance, + timmy: Person, + sara: Person, + timmy_post: Post, + timmy_post_2: Post, + sara_post: Post, + timmy_comment: Comment, + sara_comment: Comment, + sara_comment_2: Comment, + } + + async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { + let instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; + + let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); + let timmy = Person::create(pool, &timmy_form).await?; + + let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); + let sara = Person::create(pool, &sara_form).await?; + + let community_form = CommunityInsertForm::new( + instance.id, + "test community pcv".to_string(), + "nada".to_owned(), + "pubkey".to_string(), + ); + let community = Community::create(pool, &community_form).await?; + + let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.id, community.id); + let timmy_post = Post::create(pool, &timmy_post_form).await?; + + let timmy_post_form_2 = PostInsertForm::new("timmy post prv 2".into(), timmy.id, community.id); + let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; + + let sara_post_form = PostInsertForm::new("sara post prv".into(), sara.id, community.id); + let sara_post = Post::create(pool, &sara_post_form).await?; + + let timmy_comment_form = + CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv".into()); + let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; + + let sara_comment_form = + CommentInsertForm::new(sara.id, timmy_post.id, "sara comment prv".into()); + let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; + + let sara_comment_form_2 = + CommentInsertForm::new(sara.id, timmy_post_2.id, "sara comment prv 2".into()); + let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; + + Ok(Data { + instance, + timmy, + sara, + timmy_post, + timmy_post_2, + sara_post, + timmy_comment, + sara_comment, + sara_comment_2, + }) + } + + async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { + Instance::delete(pool, data.instance.id).await?; + + Ok(()) + } + + #[tokio::test] + #[serial] + async fn test_combined() -> LemmyResult<()> { + let pool = &build_db_pool_for_tests(); + let pool = &mut pool.into(); + let data = init_data(pool).await?; + + // Do a batch read of timmy + let timmy_content = PersonContentCombinedQuery::new(data.timmy.id) + .list(pool, &None) + .await?; + assert_eq!(3, timmy_content.len()); + + // Make sure the types are correct + if let PersonContentCombinedView::Comment(v) = &timmy_content[0] { + assert_eq!(data.timmy_comment.id, v.comment.id); + assert_eq!(data.timmy.id, v.creator.id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Post(v) = &timmy_content[1] { + assert_eq!(data.timmy_post_2.id, v.post.id); + assert_eq!(data.timmy.id, v.post.creator_id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Post(v) = &timmy_content[2] { + assert_eq!(data.timmy_post.id, v.post.id); + assert_eq!(data.timmy.id, v.post.creator_id); + } else { + panic!("wrong type"); + } + + // Do a batch read of sara + let sara_content = PersonContentCombinedQuery::new(data.sara.id) + .list(pool, &None) + .await?; + assert_eq!(3, sara_content.len()); + + // Make sure the report types are correct + if let PersonContentCombinedView::Comment(v) = &sara_content[0] { + assert_eq!(data.sara_comment_2.id, v.comment.id); + assert_eq!(data.sara.id, v.creator.id); + // This one was to timmy_post_2 + assert_eq!(data.timmy_post_2.id, v.post.id); + assert_eq!(data.timmy.id, v.post.creator_id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Comment(v) = &sara_content[1] { + assert_eq!(data.sara_comment.id, v.comment.id); + assert_eq!(data.sara.id, v.creator.id); + assert_eq!(data.timmy_post.id, v.post.id); + assert_eq!(data.timmy.id, v.post.creator_id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Post(v) = &sara_content[2] { + assert_eq!(data.sara_post.id, v.post.id); + assert_eq!(data.sara.id, v.post.creator_id); + } else { + panic!("wrong type"); + } + + cleanup(data, pool).await?; + + Ok(()) + } +} diff --git a/crates/db_views/src/person_saved_combined_view.rs b/crates/db_views/src/person_saved_combined_view.rs new file mode 100644 index 000000000..45879c1f5 --- /dev/null +++ b/crates/db_views/src/person_saved_combined_view.rs @@ -0,0 +1,417 @@ +use crate::{ + structs::{ + LocalUserView, + PersonContentCombinedView, + PersonContentViewInternal, + PersonSavedCombinedPaginationCursor, + }, + InternalToCombinedView, +}; +use diesel::{ + result::Error, + BoolExpressionMethods, + ExpressionMethods, + JoinOnDsl, + NullableExpressionMethods, + QueryDsl, + SelectableHelper, +}; +use diesel_async::RunQueryDsl; +use i_love_jesus::PaginatedQueryBuilder; +use lemmy_db_schema::{ + aliases::creator_community_actions, + schema::{ + comment, + comment_actions, + comment_aggregates, + community, + community_actions, + image_details, + local_user, + person, + person_actions, + person_saved_combined, + post, + post_actions, + post_aggregates, + post_tag, + tag, + }, + source::{ + combined::person_saved::{person_saved_combined_keys as key, PersonSavedCombined}, + community::CommunityFollower, + }, + utils::{actions, actions_alias, functions::coalesce, get_conn, DbPool}, + PersonContentType, +}; +use lemmy_utils::error::LemmyResult; + +impl PersonSavedCombinedPaginationCursor { + // get cursor for page that starts immediately after the given post + pub fn after_post(view: &PersonContentCombinedView) -> PersonSavedCombinedPaginationCursor { + let (prefix, id) = match view { + PersonContentCombinedView::Comment(v) => ('C', v.comment.id.0), + PersonContentCombinedView::Post(v) => ('P', v.post.id.0), + }; + // hex encoding to prevent ossification + PersonSavedCombinedPaginationCursor(format!("{prefix}{id:x}")) + } + + pub async fn read(&self, pool: &mut DbPool<'_>) -> Result { + let err_msg = || Error::QueryBuilderError("Could not parse pagination token".into()); + let mut query = person_saved_combined::table + .select(PersonSavedCombined::as_select()) + .into_boxed(); + let (prefix, id_str) = self.0.split_at_checked(1).ok_or_else(err_msg)?; + let id = i32::from_str_radix(id_str, 16).map_err(|_err| err_msg())?; + query = match prefix { + "C" => query.filter(person_saved_combined::comment_id.eq(id)), + "P" => query.filter(person_saved_combined::post_id.eq(id)), + _ => return Err(err_msg()), + }; + let token = query.first(&mut get_conn(pool).await?).await?; + + Ok(PaginationCursorData(token)) + } +} + +#[derive(Clone)] +pub struct PaginationCursorData(PersonSavedCombined); + +#[derive(Default)] +pub struct PersonSavedCombinedQuery { + pub type_: Option, + pub page_after: Option, + pub page_back: Option, +} + +impl PersonSavedCombinedQuery { + pub async fn list( + self, + pool: &mut DbPool<'_>, + user: &LocalUserView, + ) -> LemmyResult> { + let my_person_id = user.local_user.person_id; + let item_creator = person::id; + + let conn = &mut get_conn(pool).await?; + + let post_tags = post_tag::table + .inner_join(tag::table) + .select(diesel::dsl::sql::( + "json_agg(tag.*)", + )) + .filter(post_tag::post_id.eq(post::id)) + .filter(tag::deleted.eq(false)) + .single_value(); + + // Notes: since the post_id and comment_id are optional columns, + // many joins must use an OR condition. + // For example, the creator must be the person table joined to either: + // - post.creator_id + // - comment.creator_id + let query = person_saved_combined::table + // The comment + .left_join(comment::table.on(person_saved_combined::comment_id.eq(comment::id.nullable()))) + // The post + // It gets a bit complicated here, because since both comments and post combined have a post + // attached, you can do an inner join. + .inner_join( + post::table.on( + person_saved_combined::post_id + .eq(post::id.nullable()) + .or(comment::post_id.eq(post::id)), + ), + ) + // The item creator + .inner_join( + person::table.on( + comment::creator_id + .eq(item_creator) + // Need to filter out the post rows where the post_id given is null + // Otherwise you'll get duped post rows + .or( + post::creator_id + .eq(item_creator) + .and(person_saved_combined::post_id.is_not_null()), + ), + ), + ) + // The community + .inner_join(community::table.on(post::community_id.eq(community::id))) + .left_join(actions_alias( + creator_community_actions, + item_creator, + post::community_id, + )) + .left_join( + local_user::table.on( + item_creator + .eq(local_user::person_id) + .and(local_user::admin.eq(true)), + ), + ) + .left_join(actions( + community_actions::table, + Some(my_person_id), + post::community_id, + )) + .left_join(actions(post_actions::table, Some(my_person_id), post::id)) + .left_join(actions( + person_actions::table, + Some(my_person_id), + item_creator, + )) + .inner_join(post_aggregates::table.on(post::id.eq(post_aggregates::post_id))) + .left_join( + comment_aggregates::table + .on(person_saved_combined::comment_id.eq(comment_aggregates::comment_id.nullable())), + ) + .left_join(actions( + comment_actions::table, + Some(my_person_id), + comment::id, + )) + .left_join(image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable()))) + // The person id filter + .filter(person_saved_combined::person_id.eq(my_person_id)) + .select(( + // Post-specific + post_aggregates::all_columns, + coalesce( + post_aggregates::comments.nullable() - post_actions::read_comments_amount.nullable(), + post_aggregates::comments, + ), + post_actions::saved.nullable().is_not_null(), + post_actions::read.nullable().is_not_null(), + post_actions::hidden.nullable().is_not_null(), + post_actions::like_score.nullable(), + image_details::all_columns.nullable(), + post_tags, + // Comment-specific + comment::all_columns.nullable(), + comment_aggregates::all_columns.nullable(), + comment_actions::saved.nullable().is_not_null(), + comment_actions::like_score.nullable(), + // Shared + post::all_columns, + community::all_columns, + person::all_columns, + CommunityFollower::select_subscribed_type(), + local_user::admin.nullable().is_not_null(), + creator_community_actions + .field(community_actions::became_moderator) + .nullable() + .is_not_null(), + creator_community_actions + .field(community_actions::received_ban) + .nullable() + .is_not_null(), + person_actions::blocked.nullable().is_not_null(), + community_actions::received_ban.nullable().is_not_null(), + )) + .into_boxed(); + + let mut query = PaginatedQueryBuilder::new(query); + + if let Some(type_) = self.type_ { + query = match type_ { + PersonContentType::All => query, + PersonContentType::Comments => { + query.filter(person_saved_combined::comment_id.is_not_null()) + } + PersonContentType::Posts => query.filter(person_saved_combined::post_id.is_not_null()), + } + } + + let page_after = self.page_after.map(|c| c.0); + + if self.page_back.unwrap_or_default() { + query = query.before(page_after).limit_and_offset_from_end(); + } else { + query = query.after(page_after); + } + + // Sorting by saved desc + query = query + .then_desc(key::saved) + // Tie breaker + .then_desc(key::id); + + let res = query.load::(conn).await?; + + // Map the query results to the enum + let out = res.into_iter().filter_map(|u| u.map_to_enum()).collect(); + + Ok(out) + } +} + +#[cfg(test)] +#[expect(clippy::indexing_slicing)] +mod tests { + + use crate::{ + person_saved_combined_view::PersonSavedCombinedQuery, + structs::{LocalUserView, PersonContentCombinedView}, + }; + use lemmy_db_schema::{ + source::{ + comment::{Comment, CommentInsertForm, CommentSaved, CommentSavedForm}, + community::{Community, CommunityInsertForm}, + instance::Instance, + local_user::{LocalUser, LocalUserInsertForm}, + local_user_vote_display_mode::LocalUserVoteDisplayMode, + person::{Person, PersonInsertForm}, + post::{Post, PostInsertForm, PostSaved, PostSavedForm}, + }, + traits::{Crud, Saveable}, + utils::{build_db_pool_for_tests, DbPool}, + }; + use lemmy_utils::error::LemmyResult; + use pretty_assertions::assert_eq; + use serial_test::serial; + + struct Data { + instance: Instance, + timmy: Person, + timmy_view: LocalUserView, + sara: Person, + timmy_post: Post, + sara_comment: Comment, + sara_comment_2: Comment, + } + + async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { + let instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; + + let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); + let timmy = Person::create(pool, &timmy_form).await?; + let timmy_local_user_form = LocalUserInsertForm::test_form(timmy.id); + let timmy_local_user = LocalUser::create(pool, &timmy_local_user_form, vec![]).await?; + let timmy_view = LocalUserView { + local_user: timmy_local_user, + local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), + person: timmy.clone(), + counts: Default::default(), + }; + + let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); + let sara = Person::create(pool, &sara_form).await?; + + let community_form = CommunityInsertForm::new( + instance.id, + "test community pcv".to_string(), + "nada".to_owned(), + "pubkey".to_string(), + ); + let community = Community::create(pool, &community_form).await?; + + let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.id, community.id); + let timmy_post = Post::create(pool, &timmy_post_form).await?; + + let timmy_post_form_2 = PostInsertForm::new("timmy post prv 2".into(), timmy.id, community.id); + let timmy_post_2 = Post::create(pool, &timmy_post_form_2).await?; + + let sara_post_form = PostInsertForm::new("sara post prv".into(), sara.id, community.id); + let _sara_post = Post::create(pool, &sara_post_form).await?; + + let timmy_comment_form = + CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv".into()); + let _timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; + + let sara_comment_form = + CommentInsertForm::new(sara.id, timmy_post.id, "sara comment prv".into()); + let sara_comment = Comment::create(pool, &sara_comment_form, None).await?; + + let sara_comment_form_2 = + CommentInsertForm::new(sara.id, timmy_post_2.id, "sara comment prv 2".into()); + let sara_comment_2 = Comment::create(pool, &sara_comment_form_2, None).await?; + + Ok(Data { + instance, + timmy, + timmy_view, + sara, + timmy_post, + sara_comment, + sara_comment_2, + }) + } + + async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { + Instance::delete(pool, data.instance.id).await?; + + Ok(()) + } + + #[tokio::test] + #[serial] + async fn test_combined() -> LemmyResult<()> { + let pool = &build_db_pool_for_tests(); + let pool = &mut pool.into(); + let data = init_data(pool).await?; + + // Do a batch read of timmy saved + let timmy_saved = PersonSavedCombinedQuery::default() + .list(pool, &data.timmy_view) + .await?; + assert_eq!(0, timmy_saved.len()); + + // Save a few things + let save_sara_comment_2 = + CommentSavedForm::new(data.sara_comment_2.id, data.timmy_view.person.id); + CommentSaved::save(pool, &save_sara_comment_2).await?; + + let save_sara_comment = CommentSavedForm::new(data.sara_comment.id, data.timmy_view.person.id); + CommentSaved::save(pool, &save_sara_comment).await?; + + let post_save_form = PostSavedForm::new(data.timmy_post.id, data.timmy.id); + PostSaved::save(pool, &post_save_form).await?; + + let timmy_saved = PersonSavedCombinedQuery::default() + .list(pool, &data.timmy_view) + .await?; + assert_eq!(3, timmy_saved.len()); + + // Make sure the types and order are correct + if let PersonContentCombinedView::Post(v) = &timmy_saved[0] { + assert_eq!(data.timmy_post.id, v.post.id); + assert_eq!(data.timmy.id, v.post.creator_id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Comment(v) = &timmy_saved[1] { + assert_eq!(data.sara_comment.id, v.comment.id); + assert_eq!(data.sara.id, v.comment.creator_id); + } else { + panic!("wrong type"); + } + if let PersonContentCombinedView::Comment(v) = &timmy_saved[2] { + assert_eq!(data.sara_comment_2.id, v.comment.id); + assert_eq!(data.sara.id, v.comment.creator_id); + } else { + panic!("wrong type"); + } + + // Try unsaving 2 things + CommentSaved::unsave(pool, &save_sara_comment).await?; + PostSaved::unsave(pool, &post_save_form).await?; + + let timmy_saved = PersonSavedCombinedQuery::default() + .list(pool, &data.timmy_view) + .await?; + assert_eq!(1, timmy_saved.len()); + + if let PersonContentCombinedView::Comment(v) = &timmy_saved[0] { + assert_eq!(data.sara_comment_2.id, v.comment.id); + assert_eq!(data.sara.id, v.comment.creator_id); + } else { + panic!("wrong type"); + } + + cleanup(data, pool).await?; + + Ok(()) + } +} diff --git a/crates/db_views/src/post_view.rs b/crates/db_views/src/post_view.rs index 2c32fd918..b0a0fde67 100644 --- a/crates/db_views/src/post_view.rs +++ b/crates/db_views/src/post_view.rs @@ -5,9 +5,7 @@ use diesel::{ pg::Pg, query_builder::AsQuery, result::Error, - sql_types, BoolExpressionMethods, - BoxableExpression, ExpressionMethods, JoinOnDsl, NullableExpressionMethods, @@ -97,18 +95,15 @@ fn queries<'a>() -> Queries< // If we want to filter by post tag we will have to add // separate logic below since this subquery can't affect filtering, but it is simple (`WHERE // exists (select 1 from post_community_post_tags where community_post_tag_id in (1,2,3,4)`). - let post_tags: Box< - dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable>, - > = Box::new( - post_tag::table - .inner_join(tag::table) - .select(diesel::dsl::sql::( - "json_agg(tag.*)", - )) - .filter(post_tag::post_id.eq(post_aggregates::post_id)) - .filter(tag::deleted.eq(false)) - .single_value(), - ); + let post_tags = post_tag::table + .inner_join(tag::table) + .select(diesel::dsl::sql::( + "json_agg(tag.*)", + )) + .filter(post_tag::post_id.eq(post_aggregates::post_id)) + .filter(tag::deleted.eq(false)) + .single_value(); + query .inner_join(person::table) .inner_join(community::table) @@ -311,21 +306,13 @@ fn queries<'a>() -> Queries< query = query.filter(post_aggregates::comments.eq(0)); }; - // If its saved only, then filter, and order by the saved time, not the comment creation time. - if o.saved_only.unwrap_or_default() { - query = query - .filter(post_actions::saved.is_not_null()) - .then_order_by(post_actions::saved.desc()); - } - if o.read_only.unwrap_or_default() { query = query .filter(post_actions::read.is_not_null()) .then_order_by(post_actions::read.desc()) } - // Only hide the read posts, if the saved_only is false. Otherwise ppl with the hide_read - // setting wont be able to see saved posts. - else if !o.show_read.unwrap_or(o.local_user.show_read_posts()) { + + if !o.show_read.unwrap_or(o.local_user.show_read_posts()) { // Do not hide read posts when it is a user profile view // Or, only hide read posts on non-profile views if o.creator_id.is_none() { @@ -515,7 +502,6 @@ pub struct PostQuery<'a> { pub local_user: Option<&'a LocalUser>, pub search_term: Option, pub url_only: Option, - pub saved_only: Option, pub read_only: Option, pub liked_only: Option, pub disliked_only: Option, @@ -676,14 +662,12 @@ mod tests { PostLikeForm, PostRead, PostReadForm, - PostSaved, - PostSavedForm, PostUpdateForm, }, site::Site, tag::{PostTagInsertForm, Tag, TagInsertForm}, }, - traits::{Bannable, Blockable, Crud, Followable, Joinable, Likeable, Saveable}, + traits::{Bannable, Blockable, Crud, Followable, Joinable, Likeable}, utils::{build_db_pool, get_conn, uplete, ActualDbPool, DbPool, RANK_DEFAULT}, CommunityVisibility, PostSortType, @@ -1215,34 +1199,6 @@ mod tests { Ok(()) } - #[test_context(Data)] - #[tokio::test] - #[serial] - async fn post_listing_saved_only(data: &mut Data) -> LemmyResult<()> { - let pool = &data.pool(); - let pool = &mut pool.into(); - - // Save only the bot post - // The saved_only should only show the bot post - let post_save_form = - PostSavedForm::new(data.inserted_bot_post.id, data.local_user_view.person.id); - PostSaved::save(pool, &post_save_form).await?; - - // Read the saved only - let read_saved_post_listing = PostQuery { - community_id: Some(data.inserted_community.id), - saved_only: Some(true), - ..data.default_post_query() - } - .list(&data.site, pool) - .await?; - - // This should only include the bot post, not the one you created - assert_eq!(vec![POST_BY_BOT], names(&read_saved_post_listing)); - - Ok(()) - } - #[test_context(Data)] #[tokio::test] #[serial] diff --git a/crates/db_views/src/report_combined_view.rs b/crates/db_views/src/report_combined_view.rs index 999681fe0..ec32709a6 100644 --- a/crates/db_views/src/report_combined_view.rs +++ b/crates/db_views/src/report_combined_view.rs @@ -1,11 +1,14 @@ -use crate::structs::{ - CommentReportView, - LocalUserView, - PostReportView, - PrivateMessageReportView, - ReportCombinedPaginationCursor, - ReportCombinedView, - ReportCombinedViewInternal, +use crate::{ + structs::{ + CommentReportView, + LocalUserView, + PostReportView, + PrivateMessageReportView, + ReportCombinedPaginationCursor, + ReportCombinedView, + ReportCombinedViewInternal, + }, + InternalToCombinedView, }; use diesel::{ result::Error, @@ -153,9 +156,10 @@ impl ReportCombinedQuery { user: &LocalUserView, ) -> LemmyResult> { let my_person_id = user.local_user.person_id; + let report_creator = person::id; let item_creator = aliases::person1.field(person::id); - let resolver = aliases::person2.field(person::id).nullable(); + let conn = &mut get_conn(pool).await?; // Notes: since the post_report_id and comment_report_id are optional columns, @@ -171,9 +175,9 @@ impl ReportCombinedQuery { .inner_join( person::table.on( post_report::creator_id - .eq(person::id) - .or(comment_report::creator_id.eq(person::id)) - .or(private_message_report::creator_id.eq(person::id)), + .eq(report_creator) + .or(comment_report::creator_id.eq(report_creator)) + .or(private_message_report::creator_id.eq(report_creator)), ), ) // The comment @@ -327,81 +331,84 @@ impl ReportCombinedQuery { let res = query.load::(conn).await?; // Map the query results to the enum - let out = res.into_iter().filter_map(map_to_enum).collect(); + let out = res.into_iter().filter_map(|u| u.map_to_enum()).collect(); Ok(out) } } -/// Maps the combined DB row to an enum -fn map_to_enum(view: ReportCombinedViewInternal) -> Option { - // Use for a short alias - let v = view; +impl InternalToCombinedView for ReportCombinedViewInternal { + type CombinedView = ReportCombinedView; - if let (Some(post_report), Some(post), Some(community), Some(unread_comments), Some(counts)) = ( - v.post_report, - v.post.clone(), - v.community.clone(), - v.post_unread_comments, - v.post_counts, - ) { - Some(ReportCombinedView::Post(PostReportView { - post_report, - post, - community, - unread_comments, - counts, - creator: v.report_creator, - post_creator: v.item_creator, - creator_banned_from_community: v.item_creator_banned_from_community, - creator_is_moderator: v.item_creator_is_moderator, - creator_is_admin: v.item_creator_is_admin, - creator_blocked: v.item_creator_blocked, - subscribed: v.subscribed, - saved: v.post_saved, - read: v.post_read, - hidden: v.post_hidden, - my_vote: v.my_post_vote, - resolver: v.resolver, - })) - } else if let (Some(comment_report), Some(comment), Some(counts), Some(post), Some(community)) = ( - v.comment_report, - v.comment, - v.comment_counts, - v.post.clone(), - v.community.clone(), - ) { - Some(ReportCombinedView::Comment(CommentReportView { - comment_report, - comment, - counts, - post, - community, - creator: v.report_creator, - comment_creator: v.item_creator, - creator_banned_from_community: v.item_creator_banned_from_community, - creator_is_moderator: v.item_creator_is_moderator, - creator_is_admin: v.item_creator_is_admin, - creator_blocked: v.item_creator_blocked, - subscribed: v.subscribed, - saved: v.comment_saved, - my_vote: v.my_comment_vote, - resolver: v.resolver, - })) - } else if let (Some(private_message_report), Some(private_message)) = - (v.private_message_report, v.private_message) - { - Some(ReportCombinedView::PrivateMessage( - PrivateMessageReportView { - private_message_report, - private_message, + fn map_to_enum(&self) -> Option { + // Use for a short alias + let v = self.clone(); + + if let (Some(post_report), Some(post), Some(community), Some(unread_comments), Some(counts)) = ( + v.post_report, + v.post.clone(), + v.community.clone(), + v.post_unread_comments, + v.post_counts, + ) { + Some(ReportCombinedView::Post(PostReportView { + post_report, + post, + community, + unread_comments, + counts, creator: v.report_creator, - private_message_creator: v.item_creator, + post_creator: v.item_creator, + creator_banned_from_community: v.item_creator_banned_from_community, + creator_is_moderator: v.item_creator_is_moderator, + creator_is_admin: v.item_creator_is_admin, + creator_blocked: v.item_creator_blocked, + subscribed: v.subscribed, + saved: v.post_saved, + read: v.post_read, + hidden: v.post_hidden, + my_vote: v.my_post_vote, resolver: v.resolver, - }, - )) - } else { - None + })) + } else if let (Some(comment_report), Some(comment), Some(counts), Some(post), Some(community)) = ( + v.comment_report, + v.comment, + v.comment_counts, + v.post, + v.community, + ) { + Some(ReportCombinedView::Comment(CommentReportView { + comment_report, + comment, + counts, + post, + community, + creator: v.report_creator, + comment_creator: v.item_creator, + creator_banned_from_community: v.item_creator_banned_from_community, + creator_is_moderator: v.item_creator_is_moderator, + creator_is_admin: v.item_creator_is_admin, + creator_blocked: v.item_creator_blocked, + subscribed: v.subscribed, + saved: v.comment_saved, + my_vote: v.my_comment_vote, + resolver: v.resolver, + })) + } else if let (Some(private_message_report), Some(private_message)) = + (v.private_message_report, v.private_message) + { + Some(ReportCombinedView::PrivateMessage( + PrivateMessageReportView { + private_message_report, + private_message, + creator: v.report_creator, + private_message_creator: v.item_creator, + resolver: v.resolver, + }, + )) + } else { + None + } } } diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index 7fe529eb6..41322c1d5 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -135,6 +135,18 @@ pub struct PaginationCursor(pub String); #[cfg_attr(feature = "full", ts(export))] pub struct ReportCombinedPaginationCursor(pub String); +/// like PaginationCursor but for the person_content_combined table +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(ts_rs::TS))] +#[cfg_attr(feature = "full", ts(export))] +pub struct PersonContentCombinedPaginationCursor(pub String); + +/// like PaginationCursor but for the person_saved_combined table +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(ts_rs::TS))] +#[cfg_attr(feature = "full", ts(export))] +pub struct PersonSavedCombinedPaginationCursor(pub String); + #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(TS, Queryable))] @@ -294,6 +306,47 @@ pub enum ReportCombinedView { PrivateMessage(PrivateMessageReportView), } +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(Queryable))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +/// A combined person_content view +pub struct PersonContentViewInternal { + // Post-specific + pub post_counts: PostAggregates, + pub post_unread_comments: i64, + pub post_saved: bool, + pub post_read: bool, + pub post_hidden: bool, + pub my_post_vote: Option, + pub image_details: Option, + pub post_tags: PostTags, + // Comment-specific + pub comment: Option, + pub comment_counts: Option, + pub comment_saved: bool, + pub my_comment_vote: Option, + // Shared + pub post: Post, + pub community: Community, + pub item_creator: Person, + pub subscribed: SubscribedType, + pub item_creator_is_admin: bool, + pub item_creator_is_moderator: bool, + pub item_creator_banned_from_community: bool, + pub item_creator_blocked: bool, + pub banned_from_community: bool, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +// Use serde's internal tagging, to work easier with javascript libraries +#[serde(tag = "type_")] +pub enum PersonContentCombinedView { + Post(PostView), + Comment(CommentView), +} + #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)] #[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))] #[serde(transparent)] diff --git a/migrations/2024-12-05-233704_add_person_content_combined_table/down.sql b/migrations/2024-12-05-233704_add_person_content_combined_table/down.sql new file mode 100644 index 000000000..0733315a7 --- /dev/null +++ b/migrations/2024-12-05-233704_add_person_content_combined_table/down.sql @@ -0,0 +1,4 @@ +DROP TABLE person_content_combined; + +DROP TABLE person_saved_combined; + diff --git a/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql new file mode 100644 index 000000000..805d2ca94 --- /dev/null +++ b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql @@ -0,0 +1,67 @@ +-- Creates combined tables for +-- person_content: (comment, post) +-- person_saved: (comment, post) +CREATE TABLE person_content_combined ( + id serial PRIMARY KEY, + published timestamptz NOT NULL, + post_id int UNIQUE REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, + comment_id int UNIQUE REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, + -- Make sure only one of the columns is not null + CHECK (num_nonnulls (post_id, comment_id) = 1) +); + +CREATE INDEX idx_person_content_combined_published ON person_content_combined (published DESC, id DESC); + +-- Updating the history +INSERT INTO person_content_combined (published, post_id, comment_id) +SELECT + published, + id, + NULL::int +FROM + post +UNION ALL +SELECT + published, + NULL::int, + id +FROM + comment; + +-- This one is special, because you use the saved date, not the ordinary published +CREATE TABLE person_saved_combined ( + id serial PRIMARY KEY, + saved timestamptz NOT NULL, + person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, + post_id int UNIQUE REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, + comment_id int UNIQUE REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, + -- Make sure only one of the columns is not null + CHECK (num_nonnulls (post_id, comment_id) = 1) +); + +CREATE INDEX idx_person_saved_combined_published ON person_saved_combined (saved DESC, id DESC); + +CREATE INDEX idx_person_saved_combined ON person_saved_combined (person_id); + +-- Updating the history +INSERT INTO person_saved_combined (saved, person_id, post_id, comment_id) +SELECT + saved, + person_id, + post_id, + NULL::int +FROM + post_actions +WHERE + saved IS NOT NULL +UNION ALL +SELECT + saved, + person_id, + NULL::int, + comment_id +FROM + comment_actions +WHERE + saved IS NOT NULL; + diff --git a/src/api_routes_v4.rs b/src/api_routes_v4.rs index b03be60f2..9f2b8d289 100644 --- a/src/api_routes_v4.rs +++ b/src/api_routes_v4.rs @@ -31,6 +31,7 @@ use lemmy_api::{ list_banned::list_banned_users, list_logins::list_logins, list_media::list_media, + list_saved::list_person_saved, login::login, logout::logout, notifications::{ @@ -143,6 +144,7 @@ use lemmy_api_crud::{ }; use lemmy_apub::api::{ list_comments::list_comments, + list_person_content::list_person_content, list_posts::list_posts, read_community::get_community, read_person::read_person, @@ -282,7 +284,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .route("/change_password", put().to(change_password)) .route("/totp/generate", post().to(generate_totp_secret)) .route("/totp/update", post().to(update_totp)) - .route("/verify_email", post().to(verify_email)), + .route("/verify_email", post().to(verify_email)) + .route("/saved", get().to(list_person_saved)), ) .route("/account/settings/save", put().to(save_user_settings)) .service( @@ -318,7 +321,11 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { ), ) // User actions - .route("/person", get().to(read_person)) + .service( + scope("/person") + .route("", get().to(read_person)) + .route("/content", get().to(list_person_content)), + ) // Admin Actions .service( scope("/admin")