Correctly paginate PostView when read_only is enabled (#5320)

This commit is contained in:
dullbananas 2025-01-13 13:46:46 -07:00 committed by GitHub
parent 4d17eef82b
commit c08e216ae8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 159 additions and 92 deletions

View file

@ -67,7 +67,7 @@ pub async fn list_posts(
// parse pagination token // parse pagination token
let page_after = if let Some(pa) = &data.page_cursor { let page_after = if let Some(pa) = &data.page_cursor {
Some(pa.read(&mut context.pool()).await?) Some(pa.read(&mut context.pool(), local_user).await?)
} else { } else {
None None
}; };

View file

@ -163,7 +163,7 @@ async fn try_main() -> LemmyResult<()> {
if let Some(post_view) = post_views.into_iter().last() { if let Some(post_view) = post_views.into_iter().last() {
println!("👀 getting pagination cursor data for next page"); println!("👀 getting pagination cursor data for next page");
let cursor_data = PaginationCursor::after_post(&post_view) let cursor_data = PaginationCursor::after_post(&post_view)
.read(&mut conn.into()) .read(&mut conn.into(), None)
.await?; .await?;
page_after = Some(cursor_data); page_after = Some(cursor_data);
} else { } else {

View file

@ -4,6 +4,7 @@ use crate::{
schema::{community, person, post, post_actions}, schema::{community, person, post, post_actions},
source::post::{ source::post::{
Post, Post,
PostActionsCursor,
PostHide, PostHide,
PostHideForm, PostHideForm,
PostInsertForm, PostInsertForm,
@ -409,6 +410,28 @@ impl PostHide {
} }
} }
impl PostActionsCursor {
pub async fn read(
pool: &mut DbPool<'_>,
post_id: PostId,
person_id: Option<PersonId>,
) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?;
Ok(if let Some(person_id) = person_id {
post_actions::table
.find((person_id, post_id))
.select(Self::as_select())
.first(conn)
.await
.optional()?
.unwrap_or_default()
} else {
Default::default()
})
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View file

@ -4,6 +4,8 @@ use crate::schema::{post, post_actions};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
#[cfg(feature = "full")] #[cfg(feature = "full")]
use diesel::{dsl, expression_methods::NullableExpressionMethods}; use diesel::{dsl, expression_methods::NullableExpressionMethods};
#[cfg(feature = "full")]
use i_love_jesus::CursorKeysModule;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
#[cfg(feature = "full")] #[cfg(feature = "full")]
@ -204,7 +206,7 @@ pub struct PostSavedForm {
pub saved: DateTime<Utc>, pub saved: DateTime<Utc>,
} }
#[derive(PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
#[cfg_attr( #[cfg_attr(
feature = "full", feature = "full",
derive(Identifiable, Queryable, Selectable, Associations) derive(Identifiable, Queryable, Selectable, Associations)
@ -257,3 +259,13 @@ pub struct PostHideForm {
#[new(value = "Utc::now()")] #[new(value = "Utc::now()")]
pub hidden: DateTime<Utc>, pub hidden: DateTime<Utc>,
} }
#[derive(PartialEq, Debug, Clone, Default)]
#[cfg_attr(feature = "full", derive(Queryable, Selectable, CursorKeysModule))]
#[cfg_attr(feature = "full", diesel(table_name = post_actions))]
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
#[cfg_attr(feature = "full", cursor_keys_module(name = post_actions_keys))]
/// Sorted timestamps of actions on a post.
pub struct PostActionsCursor {
pub read: Option<DateTime<Utc>>,
}

View file

@ -37,7 +37,7 @@ use diesel_async::{
}; };
use diesel_bind_if_some::BindIfSome; use diesel_bind_if_some::BindIfSome;
use futures_util::{future::BoxFuture, Future, FutureExt}; use futures_util::{future::BoxFuture, Future, FutureExt};
use i_love_jesus::CursorKey; use i_love_jesus::{CursorKey, PaginatedQueryBuilder};
use lemmy_utils::{ use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
settings::SETTINGS, settings::SETTINGS,
@ -734,6 +734,28 @@ impl<RF, LF> Queries<RF, LF> {
} }
} }
pub fn paginate<Q, C>(
query: Q,
page_after: Option<C>,
page_before_or_equal: Option<C>,
page_back: bool,
) -> PaginatedQueryBuilder<C, Q> {
let mut query = PaginatedQueryBuilder::new(query);
if page_back {
query = query
.before(page_after)
.after_or_equal(page_before_or_equal)
.limit_and_offset_from_end();
} else {
query = query
.after(page_after)
.before_or_equal(page_before_or_equal);
}
query
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View file

@ -14,7 +14,6 @@ use diesel::{
QueryDsl, QueryDsl,
}; };
use diesel_async::RunQueryDsl; use diesel_async::RunQueryDsl;
use i_love_jesus::PaginatedQueryBuilder;
use lemmy_db_schema::{ use lemmy_db_schema::{
aggregates::structs::{post_aggregates_keys as key, PostAggregates}, aggregates::structs::{post_aggregates_keys as key, PostAggregates},
aliases::creator_community_actions, aliases::creator_community_actions,
@ -38,6 +37,7 @@ use lemmy_db_schema::{
source::{ source::{
community::{CommunityFollower, CommunityFollowerState}, community::{CommunityFollower, CommunityFollowerState},
local_user::LocalUser, local_user::LocalUser,
post::{post_actions_keys, PostActionsCursor},
site::Site, site::Site,
}, },
utils::{ utils::{
@ -49,6 +49,7 @@ use lemmy_db_schema::{
get_conn, get_conn,
limit_and_offset, limit_and_offset,
now, now,
paginate,
Commented, Commented,
DbConn, DbConn,
DbPool, DbPool,
@ -306,12 +307,6 @@ fn queries<'a>() -> Queries<
query = query.filter(post_aggregates::comments.eq(0)); query = query.filter(post_aggregates::comments.eq(0));
}; };
if o.read_only.unwrap_or_default() {
query = query
.filter(post_actions::read.is_not_null())
.then_order_by(post_actions::read.desc())
}
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 // Do not hide read posts when it is a user profile view
// Or, only hide read posts on non-profile views // Or, only hide read posts on non-profile views
@ -370,68 +365,70 @@ fn queries<'a>() -> Queries<
let (limit, offset) = limit_and_offset(o.page, o.limit)?; let (limit, offset) = limit_and_offset(o.page, o.limit)?;
query = query.limit(limit).offset(offset); query = query.limit(limit).offset(offset);
let mut query = PaginatedQueryBuilder::new(query); let query = if o.read_only.unwrap_or_default() {
paginate(
let page_after = o.page_after.map(|c| c.0); query,
let page_before_or_equal = o.page_before_or_equal.map(|c| c.0); o.page_after.map(|c| c.post_actions),
None,
if o.page_back.unwrap_or_default() { o.page_back.unwrap_or_default(),
query = query )
.before(page_after) .filter(post_actions::read.is_not_null())
.after_or_equal(page_before_or_equal) .then_desc(post_actions_keys::read)
.limit_and_offset_from_end(); .as_query()
} else { } else {
query = query let mut query = paginate(
.after(page_after) query,
.before_or_equal(page_before_or_equal); o.page_after.map(|c| c.post_aggregates),
} o.page_before_or_equal,
o.page_back.unwrap_or_default(),
);
// featured posts first // featured posts first
query = if o.community_id.is_none() || o.community_id_just_for_prefetch { query = if o.community_id.is_none() || o.community_id_just_for_prefetch {
query.then_desc(key::featured_local) query.then_desc(key::featured_local)
} else { } else {
query.then_desc(key::featured_community) query.then_desc(key::featured_community)
};
let time = |interval| post_aggregates::published.gt(now() - interval);
// then use the main sort
query = match o.sort.unwrap_or(Hot) {
Active => query.then_desc(key::hot_rank_active),
Hot => query.then_desc(key::hot_rank),
Scaled => query.then_desc(key::scaled_rank),
Controversial => query.then_desc(key::controversy_rank),
New => query.then_desc(key::published),
Old => query.then_desc(ReverseTimestampKey(key::published)),
NewComments => query.then_desc(key::newest_comment_time),
MostComments => query.then_desc(key::comments),
TopAll => query.then_desc(key::score),
TopYear => query.then_desc(key::score).filter(time(1.years())),
TopMonth => query.then_desc(key::score).filter(time(1.months())),
TopWeek => query.then_desc(key::score).filter(time(1.weeks())),
TopDay => query.then_desc(key::score).filter(time(1.days())),
TopHour => query.then_desc(key::score).filter(time(1.hours())),
TopSixHour => query.then_desc(key::score).filter(time(6.hours())),
TopTwelveHour => query.then_desc(key::score).filter(time(12.hours())),
TopThreeMonths => query.then_desc(key::score).filter(time(3.months())),
TopSixMonths => query.then_desc(key::score).filter(time(6.months())),
TopNineMonths => query.then_desc(key::score).filter(time(9.months())),
};
// use publish as fallback. especially useful for hot rank which reaches zero after some days.
// necessary because old posts can be fetched over federation and inserted with high post id
query = match o.sort.unwrap_or(Hot) {
// A second time-based sort would not be very useful
New | Old | NewComments => query,
_ => query.then_desc(key::published),
};
// finally use unique post id as tie breaker
query = query.then_desc(key::post_id);
query.as_query()
}; };
let time = |interval| post_aggregates::published.gt(now() - interval);
// then use the main sort
query = match o.sort.unwrap_or(Hot) {
Active => query.then_desc(key::hot_rank_active),
Hot => query.then_desc(key::hot_rank),
Scaled => query.then_desc(key::scaled_rank),
Controversial => query.then_desc(key::controversy_rank),
New => query.then_desc(key::published),
Old => query.then_desc(ReverseTimestampKey(key::published)),
NewComments => query.then_desc(key::newest_comment_time),
MostComments => query.then_desc(key::comments),
TopAll => query.then_desc(key::score),
TopYear => query.then_desc(key::score).filter(time(1.years())),
TopMonth => query.then_desc(key::score).filter(time(1.months())),
TopWeek => query.then_desc(key::score).filter(time(1.weeks())),
TopDay => query.then_desc(key::score).filter(time(1.days())),
TopHour => query.then_desc(key::score).filter(time(1.hours())),
TopSixHour => query.then_desc(key::score).filter(time(6.hours())),
TopTwelveHour => query.then_desc(key::score).filter(time(12.hours())),
TopThreeMonths => query.then_desc(key::score).filter(time(3.months())),
TopSixMonths => query.then_desc(key::score).filter(time(6.months())),
TopNineMonths => query.then_desc(key::score).filter(time(9.months())),
};
// use publish as fallback. especially useful for hot rank which reaches zero after some days.
// necessary because old posts can be fetched over federation and inserted with high post id
query = match o.sort.unwrap_or(Hot) {
// A second time-based sort would not be very useful
New | Old | NewComments => query,
_ => query.then_desc(key::published),
};
// finally use unique post id as tie breaker
query = query.then_desc(key::post_id);
// Not done by debug_query
let query = query.as_query();
debug!("Post View Query: {:?}", debug_query::<Pg, _>(&query)); debug!("Post View Query: {:?}", debug_query::<Pg, _>(&query));
Commented::new(query) Commented::new(query)
@ -466,29 +463,36 @@ impl PaginationCursor {
// hex encoding to prevent ossification // hex encoding to prevent ossification
PaginationCursor(format!("P{:x}", view.counts.post_id.0)) PaginationCursor(format!("P{:x}", view.counts.post_id.0))
} }
pub async fn read(&self, pool: &mut DbPool<'_>) -> Result<PaginationCursorData, Error> { pub async fn read(
&self,
pool: &mut DbPool<'_>,
local_user: Option<&LocalUser>,
) -> Result<PaginationCursorData, Error> {
let err_msg = || Error::QueryBuilderError("Could not parse pagination token".into()); let err_msg = || Error::QueryBuilderError("Could not parse pagination token".into());
let token = PostAggregates::read( let post_id = PostId(
pool, self
PostId( .0
self .get(1..)
.0 .and_then(|e| i32::from_str_radix(e, 16).ok())
.get(1..) .ok_or_else(err_msg)?,
.and_then(|e| i32::from_str_radix(e, 16).ok()) );
.ok_or_else(err_msg)?, let post_aggregates = PostAggregates::read(pool, post_id).await?;
), let post_actions = PostActionsCursor::read(pool, post_id, local_user.person_id()).await?;
)
.await?;
Ok(PaginationCursorData(token)) Ok(PaginationCursorData {
post_aggregates,
post_actions,
})
} }
} }
// currently we use a postaggregates struct as the pagination token. // currently we use aggregates or actions as the pagination token.
// we only use some of the properties of the post aggregates, depending on which sort type we page // we only use some of the properties, depending on which sort type we page by
// by
#[derive(Clone)] #[derive(Clone)]
pub struct PaginationCursorData(PostAggregates); pub struct PaginationCursorData {
post_aggregates: PostAggregates,
post_actions: PostActionsCursor,
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct PostQuery<'a> { pub struct PostQuery<'a> {
@ -509,7 +513,7 @@ pub struct PostQuery<'a> {
pub page: Option<i64>, pub page: Option<i64>,
pub limit: Option<i64>, pub limit: Option<i64>,
pub page_after: Option<PaginationCursorData>, pub page_after: Option<PaginationCursorData>,
pub page_before_or_equal: Option<PaginationCursorData>, pub page_before_or_equal: Option<PostAggregates>,
pub page_back: Option<bool>, pub page_back: Option<bool>,
pub show_hidden: Option<bool>, pub show_hidden: Option<bool>,
pub show_read: Option<bool>, pub show_read: Option<bool>,
@ -589,7 +593,7 @@ impl<'a> PostQuery<'a> {
} else { } else {
v.pop() v.pop()
}; };
let limit_cursor = Some(PaginationCursorData(item.expect("else case").counts)); let limit_cursor = Some(item.expect("else case").counts);
Ok(Some(PostQuery { Ok(Some(PostQuery {
page_before_or_equal: limit_cursor, page_before_or_equal: limit_cursor,
..self.clone() ..self.clone()
@ -1577,7 +1581,10 @@ mod tests {
let mut page_after = None; let mut page_after = None;
loop { loop {
let post_listings = PostQuery { let post_listings = PostQuery {
page_after, page_after: page_after.map(|p| PaginationCursorData {
post_aggregates: p,
post_actions: Default::default(),
}),
..options.clone() ..options.clone()
} }
.list(&data.site, pool) .list(&data.site, pool)
@ -1586,7 +1593,7 @@ mod tests {
listed_post_ids.extend(post_listings.iter().map(|p| p.post.id)); listed_post_ids.extend(post_listings.iter().map(|p| p.post.id));
if let Some(p) = post_listings.into_iter().last() { if let Some(p) = post_listings.into_iter().last() {
page_after = Some(PaginationCursorData(p.counts)); page_after = Some(p.counts);
} else { } else {
break; break;
} }
@ -1597,7 +1604,10 @@ mod tests {
let mut page_before = None; let mut page_before = None;
loop { loop {
let post_listings = PostQuery { let post_listings = PostQuery {
page_after: page_before, page_after: page_before.map(|p| PaginationCursorData {
post_aggregates: p,
post_actions: Default::default(),
}),
page_back: Some(true), page_back: Some(true),
..options.clone() ..options.clone()
} }
@ -1614,7 +1624,7 @@ mod tests {
listed_post_ids_forward.truncate(index); listed_post_ids_forward.truncate(index);
if let Some(p) = post_listings.into_iter().next() { if let Some(p) = post_listings.into_iter().next() {
page_before = Some(PaginationCursorData(p.counts)); page_before = Some(p.counts);
} else { } else {
break; break;
} }