lemmy/crates/db_views/src/person_saved_combined_view.rs

393 lines
13 KiB
Rust

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<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 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?;
// 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::<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(())
}
}