diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index c417ea2e4..38bd4e591 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -185,6 +185,12 @@ pub struct DbUrl(pub(crate) Box); /// The report combined id pub struct ReportCombinedId(i32); +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "full", derive(DieselNewType, TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The profile combined id +pub struct ProfileCombinedId(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 925401c95..468bd2b0b 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -856,6 +856,15 @@ diesel::table! { } } +diesel::table! { + profile_combined (id) { + id -> Int4, + published -> Timestamptz, + post_id -> Nullable, + comment_id -> Nullable, + } +} + diesel::table! { received_activity (ap_id) { ap_id -> Text, @@ -1043,6 +1052,8 @@ diesel::joinable!(post_aggregates -> person (creator_id)); diesel::joinable!(post_aggregates -> post (post_id)); diesel::joinable!(post_report -> post (post_id)); diesel::joinable!(private_message_report -> private_message (private_message_id)); +diesel::joinable!(profile_combined -> comment (comment_id)); +diesel::joinable!(profile_combined -> post (post_id)); diesel::joinable!(registration_application -> local_user (local_user_id)); diesel::joinable!(registration_application -> person (admin_id)); diesel::joinable!(report_combined -> comment_report (comment_report_id)); @@ -1113,6 +1124,7 @@ diesel::allow_tables_to_appear_in_same_query!( post_report, private_message, private_message_report, + profile_combined, received_activity, registration_application, remote_image, diff --git a/crates/db_schema/src/source/combined/mod.rs b/crates/db_schema/src/source/combined/mod.rs index 7352eef8e..1d8a026d2 100644 --- a/crates/db_schema/src/source/combined/mod.rs +++ b/crates/db_schema/src/source/combined/mod.rs @@ -1 +1,2 @@ +pub mod profile; pub mod report; diff --git a/crates/db_schema/src/source/combined/profile.rs b/crates/db_schema/src/source/combined/profile.rs new file mode 100644 index 000000000..ffb656091 --- /dev/null +++ b/crates/db_schema/src/source/combined/profile.rs @@ -0,0 +1,30 @@ +use crate::newtypes::{CommentId, PostId, ProfileCombinedId}; +#[cfg(feature = "full")] +use crate::schema::profile_combined; +use chrono::{DateTime, Utc}; +#[cfg(feature = "full")] +use i_love_jesus::CursorKeysModule; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +#[cfg(feature = "full")] +use ts_rs::TS; + +#[skip_serializing_none] +#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] +#[cfg_attr( + feature = "full", + derive(Identifiable, Queryable, Selectable, TS, CursorKeysModule) +)] +#[cfg_attr(feature = "full", diesel(table_name = profile_combined))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", ts(export))] +#[cfg_attr(feature = "full", cursor_keys_module(name = profile_combined_keys))] +/// A combined profile table. +pub struct ProfileCombined { + pub id: ProfileCombinedId, + pub published: DateTime, + #[cfg_attr(feature = "full", ts(optional))] + pub post_id: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub comment_id: Option, +} diff --git a/crates/db_views/src/lib.rs b/crates/db_views/src/lib.rs index e93741be8..9a5d3cb7c 100644 --- a/crates/db_views/src/lib.rs +++ b/crates/db_views/src/lib.rs @@ -20,6 +20,8 @@ pub mod private_message_report_view; #[cfg(feature = "full")] pub mod private_message_view; #[cfg(feature = "full")] +pub mod profile_combined_view; +#[cfg(feature = "full")] pub mod registration_application_view; #[cfg(feature = "full")] pub mod report_combined_view; diff --git a/crates/db_views/src/profile_combined_view.rs b/crates/db_views/src/profile_combined_view.rs new file mode 100644 index 000000000..59d218025 --- /dev/null +++ b/crates/db_views/src/profile_combined_view.rs @@ -0,0 +1,304 @@ +use crate::structs::{ + CommentView, + LocalUserView, + PostView, + ProfileCombinedPaginationCursor, + ProfileCombinedView, + ProfileCombinedViewInternal, +}; +use diesel::{ + result::Error, + BoolExpressionMethods, + ExpressionMethods, + JoinOnDsl, + NullableExpressionMethods, + QueryDsl, + SelectableHelper, +}; +use diesel_async::RunQueryDsl; +use i_love_jesus::PaginatedQueryBuilder; +use lemmy_db_schema::{ + aliases::{self, creator_community_actions}, + newtypes::CommunityId, + schema::{ + comment, + comment_actions, + comment_aggregates, + community, + community_actions, + local_user, + person, + person_actions, + post, + post_actions, + post_aggregates, + profile_combined, + }, + source::{ + combined::profile::{profile_combined_keys as key, ProfileCombined}, + community::CommunityFollower, + }, + utils::{actions, actions_alias, functions::coalesce, get_conn, DbPool, ReverseTimestampKey}, +}; +use lemmy_utils::error::LemmyResult; + +impl ProfileCombinedPaginationCursor { + // get cursor for page that starts immediately after the given post + pub fn after_post(view: &ProfileCombinedView) -> ProfileCombinedPaginationCursor { + let (prefix, id) = match view { + ProfileCombinedView::Comment(v) => ('C', v.comment.id.0), + ProfileCombinedView::Post(v) => ('P', v.post.id.0), + }; + // hex encoding to prevent ossification + ProfileCombinedPaginationCursor(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 = profile_combined::table + .select(ProfileCombined::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(profile_combined::comment_id.eq(id)), + "P" => query.filter(profile_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(ProfileCombined); + +// TODO check these +#[derive(Default)] +pub struct ProfileCombinedQuery { + pub community_id: Option, + pub unresolved_only: Option, + pub page_after: Option, + pub page_back: Option, +} + +impl ProfileCombinedQuery { + pub async fn list( + self, + pool: &mut DbPool<'_>, + user: &LocalUserView, + ) -> LemmyResult> { + let my_person_id = user.local_user.person_id; + // let item_creator = aliases::person1.field(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 mut query = profile_combined::table + // The comment + .left_join(comment::table.on(profile_combined::comment_id.eq(comment::id.nullable()))) + // The post + .inner_join( + post::table.on( + profile_combined::post_id + .eq(post::id.nullable()) + .or(comment::post_id.nullable().eq(profile_combined::post_id)), + ), + ) + // The item creator + .inner_join( + person::table.on( + post::creator_id + .eq(person::id) + .or(comment::creator_id.eq(person::id)), + ), + ) + // The item creator + // You can now use aliases::person1.field(person::id) / item_creator for all the item actions + // .inner_join( + // aliases::person1.on( + // post::creator_id + // .eq(item_creator) + // .or(comment::creator_id.eq(item_creator)), + // ), + // ) + // 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, + )) + .left_join( + post_aggregates::table + .on(profile_combined::post_id.eq(post_aggregates::post_id.nullable())), + ) + .left_join( + comment_aggregates::table + .on(profile_combined::comment_id.eq(comment_aggregates::comment_id.nullable())), + ) + .left_join(actions( + comment_actions::table, + Some(my_person_id), + comment::id, + )) + .select(( + // Post-specific + post::all_columns.nullable(), + // post_aggregates::all_columns.nullable(), + // coalesce( + // post_aggregates::comments.nullable() - post_actions::read_comments_amount.nullable(), + // post_aggregates::comments, + // ) + // .nullable(), + // 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(), + // // Comment-specific + // comment::all_columns.nullable(), + // comment_aggregates::all_columns.nullable(), + // comment_actions::saved.nullable().is_not_null(), + // comment_actions::like_score.nullable(), + // // Private-message-specific + // private_message_profile::all_columns.nullable(), + // private_message::all_columns.nullable(), + // // Shared + // person::all_columns, + // aliases::person1.fields(person::all_columns), + // community::all_columns.nullable(), + // CommunityFollower::select_subscribed_type(), + // aliases::person2.fields(person::all_columns.nullable()), + // local_user::admin.nullable().is_not_null(), + // creator_community_actions + // .field(community_actions::received_ban) + // .nullable() + // .is_not_null(), + // creator_community_actions + // .field(community_actions::became_moderator) + // .nullable() + // .is_not_null(), + // person_actions::blocked.nullable().is_not_null(), + )) + .into_boxed(); + + if let Some(community_id) = self.community_id { + query = query.filter(community::id.eq(community_id)); + } + + // If its not an admin, get only the ones you mod + if !user.local_user.admin { + query = query.filter(community_actions::became_moderator.is_not_null()); + } + + 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); + } + + // If viewing all profiles, order by newest, but if viewing unresolved only, show the oldest + // first (FIFO) + if self.unresolved_only.unwrap_or_default() { + query = query + .filter( + post_profile::resolved + .eq(false) + .or(comment_profile::resolved.eq(false)) + .or(private_message_profile::resolved.eq(false)), + ) + // TODO: when a `then_asc` method is added, use it here, make the id sort direction match, + // and remove the separate index; unless additional columns are added to this sort + .then_desc(ReverseTimestampKey(key::published)); + } else { + query = query.then_desc(key::published); + } + + // Tie breaker + query = query.then_desc(key::id); + + let res = query.load::(conn).await?; + + // Map the query results to the enum + let out = res.into_iter().filter_map(map_to_enum).collect(); + + Ok(out) + } +} + +/// Maps the combined DB row to an enum +fn map_to_enum(view: ProfileCombinedViewInternal) -> Option { + // Use for a short alias + let v = view; + + if let (Some(post), Some(community), Some(unread_comments), Some(counts)) = + (v.post, v.community, v.post_unread_comments, v.post_counts) + { + Some(ProfileCombinedView::Post(PostView { + post, + community, + unread_comments, + 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, + })) + } else if let (Some(comment), Some(counts), Some(post), Some(community)) = ( + v.comment, + v.comment_counts, + v.post.clone(), + v.community.clone(), + ) { + Some(ProfileCombinedView::Comment(CommentView { + comment, + counts, + post, + 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, + })) + } else { + None + } +} diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index 0e3bbe6f4..5dd42684b 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -132,6 +132,12 @@ pub struct PaginationCursor(pub String); #[cfg_attr(feature = "full", ts(export))] pub struct ReportCombinedPaginationCursor(pub String); +/// like PaginationCursor but for the profile_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 ProfileCombinedPaginationCursor(pub String); + #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(TS, Queryable))] @@ -289,3 +295,44 @@ pub enum ReportCombinedView { Comment(CommentReportView), 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 profile view +pub struct ProfileCombinedViewInternal { + // 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, + // Comment-specific + pub comment: Comment, + pub comment_counts: CommentAggregates, + 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 item_saved: 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 ProfileCombinedView { + Post(PostView), + Comment(CommentView), +} diff --git a/migrations/2024-12-05-233704_add_profile_combined_table/down.sql b/migrations/2024-12-05-233704_add_profile_combined_table/down.sql new file mode 100644 index 000000000..9d9a39411 --- /dev/null +++ b/migrations/2024-12-05-233704_add_profile_combined_table/down.sql @@ -0,0 +1 @@ +DROP TABLE profile_combined; diff --git a/migrations/2024-12-05-233704_add_profile_combined_table/up.sql b/migrations/2024-12-05-233704_add_profile_combined_table/up.sql new file mode 100644 index 000000000..c0d4171f0 --- /dev/null +++ b/migrations/2024-12-05-233704_add_profile_combined_table/up.sql @@ -0,0 +1,29 @@ +-- Creates combined tables for +-- Profile: (comment, post) +CREATE TABLE profile_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 ((post_id IS NOT NULL)::integer + (comment_id IS NOT NULL)::integer = 1) +); + +CREATE INDEX idx_profile_combined_published ON profile_combined (published DESC, id DESC); + +CREATE INDEX idx_profile_combined_published_asc ON profile_combined (reverse_timestamp_sort (published) DESC, id DESC); + +-- Updating the history +INSERT INTO profile_combined (published, post_id) +SELECT + published, + id +FROM + post; + +INSERT INTO profile_combined (published, comment_id) +SELECT + published, + id +FROM + comment;