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 <dull.bananas0@gmail.com>

* Update crates/db_views/src/report_combined_view.rs

Co-authored-by: dullbananas <dull.bananas0@gmail.com>

* Update crates/db_views/src/report_combined_view.rs

Co-authored-by: dullbananas <dull.bananas0@gmail.com>

* Update migrations/2024-12-02-181601_add_report_combined_table/up.sql

Co-authored-by: dullbananas <dull.bananas0@gmail.com>

* Update migrations/2024-12-02-181601_add_report_combined_table/up.sql

Co-authored-by: dullbananas <dull.bananas0@gmail.com>

* 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 <dull.bananas0@gmail.com>
This commit is contained in:
Dessalines 2025-01-05 12:48:57 -05:00 committed by GitHub
parent 41421991d6
commit 0bfbd74e59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1492 additions and 309 deletions

1
Cargo.lock generated
View file

@ -2676,6 +2676,7 @@ version = "0.19.6-beta.7"
dependencies = [
"actix-web",
"chrono",
"derive-new",
"diesel",
"diesel-async",
"diesel_ltree",

View file

@ -16,10 +16,7 @@ pub async fn save_comment(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
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)

View file

@ -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<ListPersonSaved>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListPersonSavedResponse>> {
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 }))
}

View file

@ -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;

View file

@ -131,8 +131,6 @@ pub struct GetComments {
#[cfg_attr(feature = "full", ts(optional))]
pub parent_id: Option<CommentId>,
#[cfg_attr(feature = "full", ts(optional))]
pub saved_only: Option<bool>,
#[cfg_attr(feature = "full", ts(optional))]
pub liked_only: Option<bool>,
#[cfg_attr(feature = "full", ts(optional))]
pub disliked_only: Option<bool>,

View file

@ -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<String>,
#[cfg_attr(feature = "full", ts(optional))]
pub sort: Option<PostSortType>,
#[cfg_attr(feature = "full", ts(optional))]
pub page: Option<i64>,
#[cfg_attr(feature = "full", ts(optional))]
pub limit: Option<i64>,
#[cfg_attr(feature = "full", ts(optional))]
pub community_id: Option<CommunityId>,
#[cfg_attr(feature = "full", ts(optional))]
pub saved_only: Option<bool>,
}
#[skip_serializing_none]
@ -243,11 +239,62 @@ pub struct GetPersonDetailsResponse {
pub person_view: PersonView,
#[cfg_attr(feature = "full", ts(optional))]
pub site: Option<Site>,
pub comments: Vec<CommentView>,
pub posts: Vec<PostView>,
pub moderates: Vec<CommunityModeratorView>,
}
#[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<PersonContentType>,
#[cfg_attr(feature = "full", ts(optional))]
pub person_id: Option<PersonId>,
/// Example: dessalines , or dessalines@xyz.tld
#[cfg_attr(feature = "full", ts(optional))]
pub username: Option<String>,
#[cfg_attr(feature = "full", ts(optional))]
pub page_cursor: Option<PersonContentCombinedPaginationCursor>,
#[cfg_attr(feature = "full", ts(optional))]
pub page_back: Option<bool>,
}
#[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<PersonContentCombinedView>,
}
#[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<PersonContentType>,
#[cfg_attr(feature = "full", ts(optional))]
pub page_cursor: Option<PersonSavedCombinedPaginationCursor>,
#[cfg_attr(feature = "full", ts(optional))]
pub page_back: Option<bool>,
}
#[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<PersonContentCombinedView>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[cfg_attr(feature = "full", derive(TS))]
#[cfg_attr(feature = "full", ts(export))]

View file

@ -94,8 +94,6 @@ pub struct Search {
#[cfg_attr(feature = "full", ts(optional))]
pub post_url_only: Option<bool>,
#[cfg_attr(feature = "full", ts(optional))]
pub saved_only: Option<bool>,
#[cfg_attr(feature = "full", ts(optional))]
pub liked_only: Option<bool>,
#[cfg_attr(feature = "full", ts(optional))]
pub disliked_only: Option<bool>,

View file

@ -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,

View file

@ -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<ListPersonContent>,
context: Data<LemmyContext>,
local_user_view: Option<LocalUserView>,
) -> LemmyResult<Json<ListPersonContentResponse>> {
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 }))
}

View file

@ -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,

View file

@ -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<PersonId>,
username: &Option<String>,
context: &Data<LemmyContext>,
local_user_view: &Option<LocalUserView>,
) -> LemmyResult<PersonId> {
// 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::<ApubPerson, Person>(username, context, local_user_view, true)
.await?
.id
} else {
Err(LemmyErrorType::NotFound)?
}
}
})
}

View file

@ -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<LemmyContext>,
local_user_view: Option<LocalUserView>,
) -> LemmyResult<Json<GetPersonDetailsResponse>> {
// 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::<ApubPerson, Person>(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,
}))
}

View file

@ -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()
};

View file

@ -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(())
},

View file

@ -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');

View file

@ -184,10 +184,6 @@ impl Saveable for CommentSaved {
comment_saved_form: &CommentSavedForm,
) -> Result<Self, Error> {
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 {

View file

@ -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,
)]

View file

@ -184,6 +184,16 @@ pub struct DbUrl(pub(crate) Box<Url>);
/// 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

View file

@ -729,6 +729,15 @@ diesel::table! {
}
}
diesel::table! {
person_content_combined (id) {
id -> Int4,
published -> Timestamptz,
post_id -> Nullable<Int4>,
comment_id -> Nullable<Int4>,
}
}
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<Int4>,
comment_id -> Nullable<Int4>,
}
}
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,

View file

@ -1 +1,3 @@
pub mod person_content;
pub mod person_saved;
pub mod report;

View file

@ -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<Utc>,
pub post_id: Option<PostId>,
pub comment_id: Option<CommentId>,
}

View file

@ -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<Utc>,
pub person_id: PersonId,
pub post_id: Option<PostId>,
pub comment_id: Option<CommentId>,
}

View file

@ -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<Utc>,
}

View file

@ -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 }

View file

@ -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<PersonId>,
pub local_user: Option<&'a LocalUser>,
pub search_term: Option<String>,
pub saved_only: Option<bool>,
pub liked_only: Option<bool>,
pub disliked_only: Option<bool>,
pub page: Option<i64>,
@ -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,

View file

@ -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<Self::CombinedView>;
}

View file

@ -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<PaginationCursorData, Error> {
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<PersonContentType>,
#[new(default)]
pub page_after: Option<PaginationCursorData>,
#[new(default)]
pub page_back: Option<bool>,
}
impl PersonContentCombinedQuery {
pub async fn list(
self,
pool: &mut DbPool<'_>,
user: &Option<LocalUserView>,
) -> LemmyResult<Vec<PersonContentCombinedView>> {
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::<diesel::sql_types::Json>(
"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::<PersonContentViewInternal>(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<Self::CombinedView> {
// 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<Data> {
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(())
}
}

View file

@ -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<PaginationCursorData, Error> {
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<PersonContentType>,
pub page_after: Option<PaginationCursorData>,
pub page_back: Option<bool>,
}
impl PersonSavedCombinedQuery {
pub async fn list(
self,
pool: &mut DbPool<'_>,
user: &LocalUserView,
) -> LemmyResult<Vec<PersonContentCombinedView>> {
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::<diesel::sql_types::Json>(
"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::<PersonContentViewInternal>(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<Data> {
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(())
}
}

View file

@ -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<sql_types::Json>>,
> = Box::new(
post_tag::table
let post_tags = post_tag::table
.inner_join(tag::table)
.select(diesel::dsl::sql::<diesel::sql_types::Json>(
"json_agg(tag.*)",
))
.filter(post_tag::post_id.eq(post_aggregates::post_id))
.filter(tag::deleted.eq(false))
.single_value(),
);
.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<String>,
pub url_only: Option<bool>,
pub saved_only: Option<bool>,
pub read_only: Option<bool>,
pub liked_only: Option<bool>,
pub disliked_only: Option<bool>,
@ -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]

View file

@ -1,4 +1,5 @@
use crate::structs::{
use crate::{
structs::{
CommentReportView,
LocalUserView,
PostReportView,
@ -6,6 +7,8 @@ use crate::structs::{
ReportCombinedPaginationCursor,
ReportCombinedView,
ReportCombinedViewInternal,
},
InternalToCombinedView,
};
use diesel::{
result::Error,
@ -153,9 +156,10 @@ impl ReportCombinedQuery {
user: &LocalUserView,
) -> LemmyResult<Vec<ReportCombinedView>> {
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,16 +331,18 @@ impl ReportCombinedQuery {
let res = query.load::<ReportCombinedViewInternal>(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<ReportCombinedView> {
impl InternalToCombinedView for ReportCombinedViewInternal {
type CombinedView = ReportCombinedView;
fn map_to_enum(&self) -> Option<Self::CombinedView> {
// Use for a short alias
let v = view;
let v = self.clone();
if let (Some(post_report), Some(post), Some(community), Some(unread_comments), Some(counts)) = (
v.post_report,
@ -368,8 +374,8 @@ fn map_to_enum(view: ReportCombinedViewInternal) -> Option<ReportCombinedView> {
v.comment_report,
v.comment,
v.comment_counts,
v.post.clone(),
v.community.clone(),
v.post,
v.community,
) {
Some(ReportCombinedView::Comment(CommentReportView {
comment_report,
@ -403,6 +409,7 @@ fn map_to_enum(view: ReportCombinedViewInternal) -> Option<ReportCombinedView> {
} else {
None
}
}
}
#[cfg(test)]

View file

@ -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<i16>,
pub image_details: Option<ImageDetails>,
pub post_tags: PostTags,
// Comment-specific
pub comment: Option<Comment>,
pub comment_counts: Option<CommentAggregates>,
pub comment_saved: bool,
pub my_comment_vote: Option<i16>,
// 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)]

View file

@ -0,0 +1,4 @@
DROP TABLE person_content_combined;
DROP TABLE person_saved_combined;

View file

@ -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;

View file

@ -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")