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