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, }, source::{ combined::person_saved::{person_saved_combined_keys as key, PersonSavedCombined}, community::CommunityFollower, }, utils::{actions, actions_alias, functions::coalesce, get_conn, DbPool}, }; 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 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?; // 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(), // 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); 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) } } #[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(()) } }