]> Untitled Git - lemmy.git/blob - crates/db_views/src/post_view.rs
34847dcde4d20a5cff8ab475faa189d0c3e90d88
[lemmy.git] / crates / db_views / src / post_view.rs
1 use diesel::{pg::Pg, result::Error, *};
2 use lemmy_db_queries::{
3   aggregates::post_aggregates::PostAggregates,
4   functions::hot_rank,
5   fuzzy_search,
6   limit_and_offset,
7   ListingType,
8   MaybeOptional,
9   SortType,
10   ToSafe,
11   ViewToVec,
12 };
13 use lemmy_db_schema::{
14   schema::{
15     community,
16     community_follower,
17     community_person_ban,
18     person,
19     post,
20     post_aggregates,
21     post_like,
22     post_read,
23     post_saved,
24   },
25   source::{
26     community::{Community, CommunityFollower, CommunityPersonBan, CommunitySafe},
27     person::{Person, PersonSafe},
28     post::{Post, PostRead, PostSaved},
29   },
30   CommunityId,
31   DbUrl,
32   PersonId,
33   PostId,
34 };
35 use log::debug;
36 use serde::Serialize;
37
38 #[derive(Debug, PartialEq, Serialize, Clone)]
39 pub struct PostView {
40   pub post: Post,
41   pub creator: PersonSafe,
42   pub community: CommunitySafe,
43   pub creator_banned_from_community: bool, // Left Join to CommunityPersonBan
44   pub counts: PostAggregates,
45   pub subscribed: bool,     // Left join to CommunityFollower
46   pub saved: bool,          // Left join to PostSaved
47   pub read: bool,           // Left join to PostRead
48   pub my_vote: Option<i16>, // Left join to PostLike
49 }
50
51 type PostViewTuple = (
52   Post,
53   PersonSafe,
54   CommunitySafe,
55   Option<CommunityPersonBan>,
56   PostAggregates,
57   Option<CommunityFollower>,
58   Option<PostSaved>,
59   Option<PostRead>,
60   Option<i16>,
61 );
62
63 impl PostView {
64   pub fn read(
65     conn: &PgConnection,
66     post_id: PostId,
67     my_person_id: Option<PersonId>,
68   ) -> Result<Self, Error> {
69     // The left join below will return None in this case
70     let person_id_join = my_person_id.unwrap_or(PersonId(-1));
71
72     let (
73       post,
74       creator,
75       community,
76       creator_banned_from_community,
77       counts,
78       follower,
79       saved,
80       read,
81       post_like,
82     ) = post::table
83       .find(post_id)
84       .inner_join(person::table)
85       .inner_join(community::table)
86       .left_join(
87         community_person_ban::table.on(
88           post::community_id
89             .eq(community_person_ban::community_id)
90             .and(community_person_ban::person_id.eq(post::creator_id)),
91         ),
92       )
93       .inner_join(post_aggregates::table)
94       .left_join(
95         community_follower::table.on(
96           post::community_id
97             .eq(community_follower::community_id)
98             .and(community_follower::person_id.eq(person_id_join)),
99         ),
100       )
101       .left_join(
102         post_saved::table.on(
103           post::id
104             .eq(post_saved::post_id)
105             .and(post_saved::person_id.eq(person_id_join)),
106         ),
107       )
108       .left_join(
109         post_read::table.on(
110           post::id
111             .eq(post_read::post_id)
112             .and(post_read::person_id.eq(person_id_join)),
113         ),
114       )
115       .left_join(
116         post_like::table.on(
117           post::id
118             .eq(post_like::post_id)
119             .and(post_like::person_id.eq(person_id_join)),
120         ),
121       )
122       .select((
123         post::all_columns,
124         Person::safe_columns_tuple(),
125         Community::safe_columns_tuple(),
126         community_person_ban::all_columns.nullable(),
127         post_aggregates::all_columns,
128         community_follower::all_columns.nullable(),
129         post_saved::all_columns.nullable(),
130         post_read::all_columns.nullable(),
131         post_like::score.nullable(),
132       ))
133       .first::<PostViewTuple>(conn)?;
134
135     // If a person is given, then my_vote, if None, should be 0, not null
136     // Necessary to differentiate between other person's votes
137     let my_vote = if my_person_id.is_some() && post_like.is_none() {
138       Some(0)
139     } else {
140       post_like
141     };
142
143     Ok(PostView {
144       post,
145       creator,
146       community,
147       creator_banned_from_community: creator_banned_from_community.is_some(),
148       counts,
149       subscribed: follower.is_some(),
150       saved: saved.is_some(),
151       read: read.is_some(),
152       my_vote,
153     })
154   }
155 }
156
157 pub struct PostQueryBuilder<'a> {
158   conn: &'a PgConnection,
159   listing_type: Option<ListingType>,
160   sort: Option<SortType>,
161   creator_id: Option<PersonId>,
162   community_id: Option<CommunityId>,
163   community_actor_id: Option<DbUrl>,
164   my_person_id: Option<PersonId>,
165   search_term: Option<String>,
166   url_search: Option<String>,
167   show_nsfw: Option<bool>,
168   show_bot_accounts: Option<bool>,
169   show_read_posts: Option<bool>,
170   saved_only: Option<bool>,
171   page: Option<i64>,
172   limit: Option<i64>,
173 }
174
175 impl<'a> PostQueryBuilder<'a> {
176   pub fn create(conn: &'a PgConnection) -> Self {
177     PostQueryBuilder {
178       conn,
179       listing_type: None,
180       sort: None,
181       creator_id: None,
182       community_id: None,
183       community_actor_id: None,
184       my_person_id: None,
185       search_term: None,
186       url_search: None,
187       show_nsfw: None,
188       show_bot_accounts: None,
189       show_read_posts: None,
190       saved_only: None,
191       page: None,
192       limit: None,
193     }
194   }
195
196   pub fn listing_type<T: MaybeOptional<ListingType>>(mut self, listing_type: T) -> Self {
197     self.listing_type = listing_type.get_optional();
198     self
199   }
200
201   pub fn sort<T: MaybeOptional<SortType>>(mut self, sort: T) -> Self {
202     self.sort = sort.get_optional();
203     self
204   }
205
206   pub fn community_id<T: MaybeOptional<CommunityId>>(mut self, community_id: T) -> Self {
207     self.community_id = community_id.get_optional();
208     self
209   }
210
211   pub fn my_person_id<T: MaybeOptional<PersonId>>(mut self, my_person_id: T) -> Self {
212     self.my_person_id = my_person_id.get_optional();
213     self
214   }
215
216   pub fn community_actor_id<T: MaybeOptional<DbUrl>>(mut self, community_actor_id: T) -> Self {
217     self.community_actor_id = community_actor_id.get_optional();
218     self
219   }
220
221   pub fn creator_id<T: MaybeOptional<PersonId>>(mut self, creator_id: T) -> Self {
222     self.creator_id = creator_id.get_optional();
223     self
224   }
225
226   pub fn search_term<T: MaybeOptional<String>>(mut self, search_term: T) -> Self {
227     self.search_term = search_term.get_optional();
228     self
229   }
230
231   pub fn url_search<T: MaybeOptional<String>>(mut self, url_search: T) -> Self {
232     self.url_search = url_search.get_optional();
233     self
234   }
235
236   pub fn show_nsfw<T: MaybeOptional<bool>>(mut self, show_nsfw: T) -> Self {
237     self.show_nsfw = show_nsfw.get_optional();
238     self
239   }
240
241   pub fn show_bot_accounts<T: MaybeOptional<bool>>(mut self, show_bot_accounts: T) -> Self {
242     self.show_bot_accounts = show_bot_accounts.get_optional();
243     self
244   }
245
246   pub fn show_read_posts<T: MaybeOptional<bool>>(mut self, show_read_posts: T) -> Self {
247     self.show_read_posts = show_read_posts.get_optional();
248     self
249   }
250
251   pub fn saved_only<T: MaybeOptional<bool>>(mut self, saved_only: T) -> Self {
252     self.saved_only = saved_only.get_optional();
253     self
254   }
255
256   pub fn page<T: MaybeOptional<i64>>(mut self, page: T) -> Self {
257     self.page = page.get_optional();
258     self
259   }
260
261   pub fn limit<T: MaybeOptional<i64>>(mut self, limit: T) -> Self {
262     self.limit = limit.get_optional();
263     self
264   }
265
266   pub fn list(self) -> Result<Vec<PostView>, Error> {
267     use diesel::dsl::*;
268
269     // The left join below will return None in this case
270     let person_id_join = self.my_person_id.unwrap_or(PersonId(-1));
271
272     let mut query = post::table
273       .inner_join(person::table)
274       .inner_join(community::table)
275       .left_join(
276         community_person_ban::table.on(
277           post::community_id
278             .eq(community_person_ban::community_id)
279             .and(community_person_ban::person_id.eq(post::creator_id)),
280         ),
281       )
282       .inner_join(post_aggregates::table)
283       .left_join(
284         community_follower::table.on(
285           post::community_id
286             .eq(community_follower::community_id)
287             .and(community_follower::person_id.eq(person_id_join)),
288         ),
289       )
290       .left_join(
291         post_saved::table.on(
292           post::id
293             .eq(post_saved::post_id)
294             .and(post_saved::person_id.eq(person_id_join)),
295         ),
296       )
297       .left_join(
298         post_read::table.on(
299           post::id
300             .eq(post_read::post_id)
301             .and(post_read::person_id.eq(person_id_join)),
302         ),
303       )
304       .left_join(
305         post_like::table.on(
306           post::id
307             .eq(post_like::post_id)
308             .and(post_like::person_id.eq(person_id_join)),
309         ),
310       )
311       .select((
312         post::all_columns,
313         Person::safe_columns_tuple(),
314         Community::safe_columns_tuple(),
315         community_person_ban::all_columns.nullable(),
316         post_aggregates::all_columns,
317         community_follower::all_columns.nullable(),
318         post_saved::all_columns.nullable(),
319         post_read::all_columns.nullable(),
320         post_like::score.nullable(),
321       ))
322       .into_boxed();
323
324     if let Some(listing_type) = self.listing_type {
325       query = match listing_type {
326         ListingType::Subscribed => query.filter(community_follower::person_id.is_not_null()),
327         ListingType::Local => query.filter(community::local.eq(true)),
328         _ => query,
329       };
330     }
331
332     if let Some(community_id) = self.community_id {
333       query = query
334         .filter(post::community_id.eq(community_id))
335         .then_order_by(post_aggregates::stickied.desc());
336     }
337
338     if let Some(community_actor_id) = self.community_actor_id {
339       query = query
340         .filter(community::actor_id.eq(community_actor_id))
341         .then_order_by(post_aggregates::stickied.desc());
342     }
343
344     if let Some(url_search) = self.url_search {
345       query = query.filter(post::url.eq(url_search));
346     }
347
348     if let Some(search_term) = self.search_term {
349       let searcher = fuzzy_search(&search_term);
350       query = query.filter(
351         post::name
352           .ilike(searcher.to_owned())
353           .or(post::body.ilike(searcher)),
354       );
355     }
356
357     // If its for a specific person, show the removed / deleted
358     if let Some(creator_id) = self.creator_id {
359       query = query.filter(post::creator_id.eq(creator_id));
360     }
361
362     if !self.show_nsfw.unwrap_or(false) {
363       query = query
364         .filter(post::nsfw.eq(false))
365         .filter(community::nsfw.eq(false));
366     };
367
368     if !self.show_bot_accounts.unwrap_or(true) {
369       query = query.filter(person::bot_account.eq(false));
370     };
371
372     if !self.show_read_posts.unwrap_or(true) {
373       query = query.filter(post_read::id.is_null());
374     };
375
376     if self.saved_only.unwrap_or(false) {
377       query = query.filter(post_saved::id.is_not_null());
378     };
379
380     query = match self.sort.unwrap_or(SortType::Hot) {
381       SortType::Active => query
382         .then_order_by(
383           hot_rank(
384             post_aggregates::score,
385             post_aggregates::newest_comment_time_necro,
386           )
387           .desc(),
388         )
389         .then_order_by(post_aggregates::newest_comment_time_necro.desc()),
390       SortType::Hot => query
391         .then_order_by(hot_rank(post_aggregates::score, post_aggregates::published).desc())
392         .then_order_by(post_aggregates::published.desc()),
393       SortType::New => query.then_order_by(post_aggregates::published.desc()),
394       SortType::MostComments => query.then_order_by(post_aggregates::comments.desc()),
395       SortType::NewComments => query.then_order_by(post_aggregates::newest_comment_time.desc()),
396       SortType::TopAll => query.then_order_by(post_aggregates::score.desc()),
397       SortType::TopYear => query
398         .filter(post::published.gt(now - 1.years()))
399         .then_order_by(post_aggregates::score.desc()),
400       SortType::TopMonth => query
401         .filter(post::published.gt(now - 1.months()))
402         .then_order_by(post_aggregates::score.desc()),
403       SortType::TopWeek => query
404         .filter(post::published.gt(now - 1.weeks()))
405         .then_order_by(post_aggregates::score.desc()),
406       SortType::TopDay => query
407         .filter(post::published.gt(now - 1.days()))
408         .then_order_by(post_aggregates::score.desc()),
409     };
410
411     let (limit, offset) = limit_and_offset(self.page, self.limit);
412
413     query = query
414       .limit(limit)
415       .offset(offset)
416       .filter(post::removed.eq(false))
417       .filter(post::deleted.eq(false))
418       .filter(community::removed.eq(false))
419       .filter(community::deleted.eq(false));
420
421     debug!("Post View Query: {:?}", debug_query::<Pg, _>(&query));
422
423     let res = query.load::<PostViewTuple>(self.conn)?;
424
425     Ok(PostView::from_tuple_to_vec(res))
426   }
427 }
428
429 impl ViewToVec for PostView {
430   type DbTuple = PostViewTuple;
431   fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
432     items
433       .iter()
434       .map(|a| Self {
435         post: a.0.to_owned(),
436         creator: a.1.to_owned(),
437         community: a.2.to_owned(),
438         creator_banned_from_community: a.3.is_some(),
439         counts: a.4.to_owned(),
440         subscribed: a.5.is_some(),
441         saved: a.6.is_some(),
442         read: a.7.is_some(),
443         my_vote: a.8,
444       })
445       .collect::<Vec<Self>>()
446   }
447 }
448
449 #[cfg(test)]
450 mod tests {
451   use crate::post_view::{PostQueryBuilder, PostView};
452   use lemmy_db_queries::{
453     aggregates::post_aggregates::PostAggregates,
454     establish_unpooled_connection,
455     Crud,
456     Likeable,
457     ListingType,
458     SortType,
459   };
460   use lemmy_db_schema::source::{community::*, person::*, post::*};
461   use serial_test::serial;
462
463   #[test]
464   #[serial]
465   fn test_crud() {
466     let conn = establish_unpooled_connection();
467
468     let person_name = "tegan".to_string();
469     let community_name = "test_community_3".to_string();
470     let post_name = "test post 3".to_string();
471     let bot_post_name = "test bot post".to_string();
472
473     let new_person = PersonForm {
474       name: person_name.to_owned(),
475       ..PersonForm::default()
476     };
477
478     let inserted_person = Person::create(&conn, &new_person).unwrap();
479
480     let new_bot = PersonForm {
481       name: person_name.to_owned(),
482       bot_account: Some(true),
483       ..PersonForm::default()
484     };
485
486     let inserted_bot = Person::create(&conn, &new_bot).unwrap();
487
488     let new_community = CommunityForm {
489       name: community_name.to_owned(),
490       title: "nada".to_owned(),
491       ..CommunityForm::default()
492     };
493
494     let inserted_community = Community::create(&conn, &new_community).unwrap();
495
496     let new_post = PostForm {
497       name: post_name.to_owned(),
498       creator_id: inserted_person.id,
499       community_id: inserted_community.id,
500       ..PostForm::default()
501     };
502
503     let inserted_post = Post::create(&conn, &new_post).unwrap();
504
505     let new_bot_post = PostForm {
506       name: bot_post_name,
507       creator_id: inserted_bot.id,
508       community_id: inserted_community.id,
509       ..PostForm::default()
510     };
511
512     let _inserted_bot_post = Post::create(&conn, &new_bot_post).unwrap();
513
514     let post_like_form = PostLikeForm {
515       post_id: inserted_post.id,
516       person_id: inserted_person.id,
517       score: 1,
518     };
519
520     let inserted_post_like = PostLike::like(&conn, &post_like_form).unwrap();
521
522     let expected_post_like = PostLike {
523       id: inserted_post_like.id,
524       post_id: inserted_post.id,
525       person_id: inserted_person.id,
526       published: inserted_post_like.published,
527       score: 1,
528     };
529
530     let read_post_listings_with_person = PostQueryBuilder::create(&conn)
531       .listing_type(ListingType::Community)
532       .sort(SortType::New)
533       .show_bot_accounts(false)
534       .community_id(inserted_community.id)
535       .my_person_id(inserted_person.id)
536       .list()
537       .unwrap();
538
539     let read_post_listings_no_person = PostQueryBuilder::create(&conn)
540       .listing_type(ListingType::Community)
541       .sort(SortType::New)
542       .community_id(inserted_community.id)
543       .list()
544       .unwrap();
545
546     let read_post_listing_no_person = PostView::read(&conn, inserted_post.id, None).unwrap();
547     let read_post_listing_with_person =
548       PostView::read(&conn, inserted_post.id, Some(inserted_person.id)).unwrap();
549
550     let agg = PostAggregates::read(&conn, inserted_post.id).unwrap();
551
552     // the non person version
553     let expected_post_listing_no_person = PostView {
554       post: Post {
555         id: inserted_post.id,
556         name: post_name,
557         creator_id: inserted_person.id,
558         url: None,
559         body: None,
560         published: inserted_post.published,
561         updated: None,
562         community_id: inserted_community.id,
563         removed: false,
564         deleted: false,
565         locked: false,
566         stickied: false,
567         nsfw: false,
568         embed_title: None,
569         embed_description: None,
570         embed_html: None,
571         thumbnail_url: None,
572         ap_id: inserted_post.ap_id.to_owned(),
573         local: true,
574       },
575       my_vote: None,
576       creator: PersonSafe {
577         id: inserted_person.id,
578         name: person_name,
579         display_name: None,
580         published: inserted_person.published,
581         avatar: None,
582         actor_id: inserted_person.actor_id.to_owned(),
583         local: true,
584         admin: false,
585         bot_account: false,
586         banned: false,
587         deleted: false,
588         bio: None,
589         banner: None,
590         updated: None,
591         inbox_url: inserted_person.inbox_url.to_owned(),
592         shared_inbox_url: None,
593         matrix_user_id: None,
594       },
595       creator_banned_from_community: false,
596       community: CommunitySafe {
597         id: inserted_community.id,
598         name: community_name,
599         icon: None,
600         removed: false,
601         deleted: false,
602         nsfw: false,
603         actor_id: inserted_community.actor_id.to_owned(),
604         local: true,
605         title: "nada".to_owned(),
606         description: None,
607         updated: None,
608         banner: None,
609         published: inserted_community.published,
610       },
611       counts: PostAggregates {
612         id: agg.id,
613         post_id: inserted_post.id,
614         comments: 0,
615         score: 1,
616         upvotes: 1,
617         downvotes: 0,
618         stickied: false,
619         published: agg.published,
620         newest_comment_time_necro: inserted_post.published,
621         newest_comment_time: inserted_post.published,
622       },
623       subscribed: false,
624       read: false,
625       saved: false,
626     };
627
628     // TODO More needs to be added here
629     let mut expected_post_listing_with_user = expected_post_listing_no_person.to_owned();
630     expected_post_listing_with_user.my_vote = Some(1);
631
632     let like_removed = PostLike::remove(&conn, inserted_person.id, inserted_post.id).unwrap();
633     let num_deleted = Post::delete(&conn, inserted_post.id).unwrap();
634     Community::delete(&conn, inserted_community.id).unwrap();
635     Person::delete(&conn, inserted_person.id).unwrap();
636     Person::delete(&conn, inserted_bot.id).unwrap();
637
638     // The with user
639     assert_eq!(
640       expected_post_listing_with_user,
641       read_post_listings_with_person[0]
642     );
643     assert_eq!(
644       expected_post_listing_with_user,
645       read_post_listing_with_person
646     );
647
648     // Should be only one person, IE the bot post should be missing
649     assert_eq!(1, read_post_listings_with_person.len());
650
651     // Without the user
652     assert_eq!(
653       expected_post_listing_no_person,
654       read_post_listings_no_person[1]
655     );
656     assert_eq!(expected_post_listing_no_person, read_post_listing_no_person);
657
658     // Should be 2 posts, with the bot post
659     assert_eq!(2, read_post_listings_no_person.len());
660
661     assert_eq!(expected_post_like, inserted_post_like);
662     assert_eq!(1, like_removed);
663     assert_eq!(1, num_deleted);
664   }
665 }