Community post tags (part 1) (#4997)

* partial post tags implementation

* fixes

* fix lints

* schema fix

* chore: restructure / rename tag tables

* chore: fix post view tests

* format

* lint

* expect used

* chore: update code to maybe final version

* add ts-rs optionals

* remove error context

* clippy
This commit is contained in:
phiresky 2024-12-18 14:54:35 +01:00 committed by GitHub
parent d346890b1f
commit a2a5cb091a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 648 additions and 236 deletions

2
Cargo.lock generated
View file

@ -2685,8 +2685,10 @@ dependencies = [
"lemmy_utils", "lemmy_utils",
"pretty_assertions", "pretty_assertions",
"serde", "serde",
"serde_json",
"serde_with", "serde_with",
"serial_test", "serial_test",
"test-context",
"tokio", "tokio",
"tracing", "tracing",
"ts-rs", "ts-rs",

View file

@ -1,5 +1,5 @@
use lemmy_db_schema::{ use lemmy_db_schema::{
newtypes::{CommentId, CommunityId, DbUrl, LanguageId, PostId, PostReportId}, newtypes::{CommentId, CommunityId, DbUrl, LanguageId, PostId, PostReportId, TagId},
ListingType, ListingType,
PostFeatureType, PostFeatureType,
PostSortType, PostSortType,
@ -37,6 +37,8 @@ pub struct CreatePost {
/// Instead of fetching a thumbnail, use a custom one. /// Instead of fetching a thumbnail, use a custom one.
#[cfg_attr(feature = "full", ts(optional))] #[cfg_attr(feature = "full", ts(optional))]
pub custom_thumbnail: Option<String>, pub custom_thumbnail: Option<String>,
#[cfg_attr(feature = "full", ts(optional))]
pub tags: Option<Vec<TagId>>,
/// Time when this post should be scheduled. Null means publish immediately. /// Time when this post should be scheduled. Null means publish immediately.
#[cfg_attr(feature = "full", ts(optional))] #[cfg_attr(feature = "full", ts(optional))]
pub scheduled_publish_time: Option<i64>, pub scheduled_publish_time: Option<i64>,
@ -164,6 +166,8 @@ pub struct EditPost {
/// Instead of fetching a thumbnail, use a custom one. /// Instead of fetching a thumbnail, use a custom one.
#[cfg_attr(feature = "full", ts(optional))] #[cfg_attr(feature = "full", ts(optional))]
pub custom_thumbnail: Option<String>, pub custom_thumbnail: Option<String>,
#[cfg_attr(feature = "full", ts(optional))]
pub tags: Option<Vec<TagId>>,
/// Time when this post should be scheduled. Null means publish immediately. /// Time when this post should be scheduled. Null means publish immediately.
#[cfg_attr(feature = "full", ts(optional))] #[cfg_attr(feature = "full", ts(optional))]
pub scheduled_publish_time: Option<i64>, pub scheduled_publish_time: Option<i64>,

View file

@ -35,4 +35,5 @@ pub mod private_message_report;
pub mod registration_application; pub mod registration_application;
pub mod secret; pub mod secret;
pub mod site; pub mod site;
pub mod tag;
pub mod tagline; pub mod tagline;

View file

@ -0,0 +1,53 @@
use crate::{
newtypes::TagId,
schema::{post_tag, tag},
source::tag::{PostTagInsertForm, Tag, TagInsertForm},
traits::Crud,
utils::{get_conn, DbPool},
};
use diesel::{insert_into, result::Error, QueryDsl};
use diesel_async::RunQueryDsl;
use lemmy_utils::error::LemmyResult;
#[async_trait]
impl Crud for Tag {
type InsertForm = TagInsertForm;
type UpdateForm = TagInsertForm;
type IdType = TagId;
async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?;
insert_into(tag::table)
.values(form)
.get_result::<Self>(conn)
.await
}
async fn update(
pool: &mut DbPool<'_>,
pid: TagId,
form: &Self::UpdateForm,
) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?;
diesel::update(tag::table.find(pid))
.set(form)
.get_result::<Self>(conn)
.await
}
}
impl PostTagInsertForm {
pub async fn insert_tag_associations(
pool: &mut DbPool<'_>,
tags: &[PostTagInsertForm],
) -> LemmyResult<()> {
let conn = &mut get_conn(pool).await?;
insert_into(post_tag::table)
.values(tags)
.execute(conn)
.await?;
Ok(())
}
}

View file

@ -283,3 +283,9 @@ impl InstanceId {
self.0 self.0
} }
} }
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "full", derive(DieselNewType, TS))]
#[cfg_attr(feature = "full", ts(export))]
/// The internal tag id.
pub struct TagId(pub i32);

View file

@ -826,6 +826,14 @@ diesel::table! {
} }
} }
diesel::table! {
post_tag (post_id, tag_id) {
post_id -> Int4,
tag_id -> Int4,
published -> Timestamptz,
}
}
diesel::table! { diesel::table! {
private_message (id) { private_message (id) {
id -> Int4, id -> Int4,
@ -951,6 +959,18 @@ diesel::table! {
} }
} }
diesel::table! {
tag (id) {
id -> Int4,
ap_id -> Text,
name -> Text,
community_id -> Int4,
published -> Timestamptz,
updated -> Nullable<Timestamptz>,
deleted -> Bool,
}
}
diesel::table! { diesel::table! {
tagline (id) { tagline (id) {
id -> Int4, id -> Int4,
@ -1032,6 +1052,8 @@ diesel::joinable!(post_aggregates -> instance (instance_id));
diesel::joinable!(post_aggregates -> person (creator_id)); diesel::joinable!(post_aggregates -> person (creator_id));
diesel::joinable!(post_aggregates -> post (post_id)); diesel::joinable!(post_aggregates -> post (post_id));
diesel::joinable!(post_report -> post (post_id)); diesel::joinable!(post_report -> post (post_id));
diesel::joinable!(post_tag -> post (post_id));
diesel::joinable!(post_tag -> tag (tag_id));
diesel::joinable!(private_message_report -> private_message (private_message_id)); diesel::joinable!(private_message_report -> private_message (private_message_id));
diesel::joinable!(registration_application -> local_user (local_user_id)); diesel::joinable!(registration_application -> local_user (local_user_id));
diesel::joinable!(registration_application -> person (admin_id)); diesel::joinable!(registration_application -> person (admin_id));
@ -1039,6 +1061,7 @@ diesel::joinable!(site -> instance (instance_id));
diesel::joinable!(site_aggregates -> site (site_id)); diesel::joinable!(site_aggregates -> site (site_id));
diesel::joinable!(site_language -> language (language_id)); diesel::joinable!(site_language -> language (language_id));
diesel::joinable!(site_language -> site (site_id)); diesel::joinable!(site_language -> site (site_id));
diesel::joinable!(tag -> community (community_id));
diesel::allow_tables_to_appear_in_same_query!( diesel::allow_tables_to_appear_in_same_query!(
admin_allow_instance, admin_allow_instance,
@ -1098,6 +1121,7 @@ diesel::allow_tables_to_appear_in_same_query!(
post_actions, post_actions,
post_aggregates, post_aggregates,
post_report, post_report,
post_tag,
private_message, private_message,
private_message_report, private_message_report,
received_activity, received_activity,
@ -1108,5 +1132,6 @@ diesel::allow_tables_to_appear_in_same_query!(
site, site,
site_aggregates, site_aggregates,
site_language, site_language,
tag,
tagline, tagline,
); );

View file

@ -40,6 +40,7 @@ pub mod private_message_report;
pub mod registration_application; pub mod registration_application;
pub mod secret; pub mod secret;
pub mod site; pub mod site;
pub mod tag;
pub mod tagline; pub mod tagline;
/// Default value for columns like [community::Community.inbox_url] which are marked as serde(skip). /// Default value for columns like [community::Community.inbox_url] which are marked as serde(skip).

View file

@ -0,0 +1,57 @@
use crate::newtypes::{CommunityId, DbUrl, PostId, TagId};
#[cfg(feature = "full")]
use crate::schema::{post_tag, tag};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
#[cfg(feature = "full")]
use ts_rs::TS;
/// A tag that can be assigned to a post within a community.
/// The tag object is created by the community moderators.
/// The assignment happens by the post creator and can be updated by the community moderators.
///
/// A tag is a federatable object that gives additional context to another object, which can be
/// displayed and filtered on currently, we only have community post tags, which is a tag that is
/// created by post authors as well as mods of a community, to categorize a post. in the future we
/// may add more tag types, depending on the requirements, this will lead to either expansion of
/// this table (community_id optional, addition of tag_type enum) or split of this table / creation
/// of new tables.
#[skip_serializing_none]
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "full", derive(TS, Queryable, Selectable, Identifiable))]
#[cfg_attr(feature = "full", diesel(table_name = tag))]
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
#[cfg_attr(feature = "full", ts(export))]
pub struct Tag {
pub id: TagId,
pub ap_id: DbUrl,
pub name: String,
/// the community that owns this tag
pub community_id: CommunityId,
pub published: DateTime<Utc>,
#[cfg_attr(feature = "full", ts(optional))]
pub updated: Option<DateTime<Utc>>,
pub deleted: bool,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = tag))]
pub struct TagInsertForm {
pub ap_id: DbUrl,
pub name: String,
pub community_id: CommunityId,
// default now
pub published: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
pub deleted: bool,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = post_tag))]
pub struct PostTagInsertForm {
pub post_id: PostId,
pub tag_id: TagId,
}

View file

@ -547,6 +547,11 @@ pub mod functions {
// really this function is variadic, this just adds the two-argument version // really this function is variadic, this just adds the two-argument version
define_sql_function!(fn coalesce<T: diesel::sql_types::SqlType + diesel::sql_types::SingleValue>(x: diesel::sql_types::Nullable<T>, y: T) -> T); define_sql_function!(fn coalesce<T: diesel::sql_types::SqlType + diesel::sql_types::SingleValue>(x: diesel::sql_types::Nullable<T>, y: T) -> T);
define_sql_function! {
#[aggregate]
fn json_agg<T: diesel::sql_types::SqlType + diesel::sql_types::SingleValue>(obj: T) -> Json
}
} }
pub const DELETED_REPLACEMENT_TEXT: &str = "*Permanently Deleted*"; pub const DELETED_REPLACEMENT_TEXT: &str = "*Permanently Deleted*";

View file

@ -35,6 +35,7 @@ diesel-async = { workspace = true, optional = true }
diesel_ltree = { workspace = true, optional = true } diesel_ltree = { workspace = true, optional = true }
serde = { workspace = true } serde = { workspace = true }
serde_with = { workspace = true } serde_with = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true, optional = true } tracing = { workspace = true, optional = true }
ts-rs = { workspace = true, optional = true } ts-rs = { workspace = true, optional = true }
actix-web = { workspace = true, optional = true } actix-web = { workspace = true, optional = true }
@ -46,3 +47,4 @@ serial_test = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
pretty_assertions = { workspace = true } pretty_assertions = { workspace = true }
url = { workspace = true } url = { workspace = true }
test-context = "0.3.0"

View file

@ -14,6 +14,8 @@ pub mod local_user_view;
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod post_report_view; pub mod post_report_view;
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod post_tags_view;
#[cfg(feature = "full")]
pub mod post_view; pub mod post_view;
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod private_message_report_view; pub mod private_message_report_view;

View file

@ -0,0 +1,30 @@
//! see post_view.rs for the reason for this json decoding
use crate::structs::PostTags;
use diesel::{
deserialize::FromSql,
pg::{Pg, PgValue},
serialize::ToSql,
sql_types::{self, Nullable},
};
impl FromSql<Nullable<sql_types::Json>, Pg> for PostTags {
fn from_sql(bytes: PgValue) -> diesel::deserialize::Result<Self> {
let value = <serde_json::Value as FromSql<sql_types::Json, Pg>>::from_sql(bytes)?;
Ok(serde_json::from_value::<PostTags>(value)?)
}
fn from_nullable_sql(
bytes: Option<<Pg as diesel::backend::Backend>::RawValue<'_>>,
) -> diesel::deserialize::Result<Self> {
match bytes {
Some(bytes) => Self::from_sql(bytes),
None => Ok(Self { tags: vec![] }),
}
}
}
impl ToSql<Nullable<sql_types::Json>, Pg> for PostTags {
fn to_sql(&self, out: &mut diesel::serialize::Output<Pg>) -> diesel::serialize::Result {
let value = serde_json::to_value(self)?;
<serde_json::Value as ToSql<sql_types::Json, Pg>>::to_sql(&value, &mut out.reborrow())
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
#[cfg(feature = "full")] #[cfg(feature = "full")]
use diesel::Queryable; use diesel::Queryable;
#[cfg(feature = "full")]
use diesel::{deserialize::FromSqlRow, expression::AsExpression, sql_types};
use lemmy_db_schema::{ use lemmy_db_schema::{
aggregates::structs::{CommentAggregates, PersonAggregates, PostAggregates, SiteAggregates}, aggregates::structs::{CommentAggregates, PersonAggregates, PostAggregates, SiteAggregates},
source::{ source::{
@ -20,6 +22,7 @@ use lemmy_db_schema::{
private_message_report::PrivateMessageReport, private_message_report::PrivateMessageReport,
registration_application::RegistrationApplication, registration_application::RegistrationApplication,
site::Site, site::Site,
tag::Tag,
}, },
SubscribedType, SubscribedType,
}; };
@ -151,6 +154,7 @@ pub struct PostView {
#[cfg_attr(feature = "full", ts(optional))] #[cfg_attr(feature = "full", ts(optional))]
pub my_vote: Option<i16>, pub my_vote: Option<i16>,
pub unread_comments: i64, pub unread_comments: i64,
pub tags: PostTags,
} }
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
@ -237,3 +241,12 @@ pub struct LocalImageView {
pub local_image: LocalImage, pub local_image: LocalImage,
pub person: Person, pub person: Person,
} }
#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Default)]
#[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))]
#[serde(transparent)]
#[cfg_attr(feature = "full", diesel(sql_type = Nullable<sql_types::Json>))]
/// we wrap this in a struct so we can implement FromSqlRow<Json> for it
pub struct PostTags {
pub tags: Vec<Tag>,
}

View file

@ -0,0 +1,4 @@
DROP TABLE post_tag;
DROP TABLE tag;

View file

@ -0,0 +1,23 @@
-- a tag is a federatable object that gives additional context to another object, which can be displayed and filtered on
-- currently, we only have community post tags, which is a tag that is created by post authors as well as mods of a community,
-- to categorize a post. in the future we may add more tag types, depending on the requirements,
-- this will lead to either expansion of this table (community_id optional, addition of tag_type enum)
-- or split of this table / creation of new tables.
CREATE TABLE tag (
id serial PRIMARY KEY,
ap_id text NOT NULL UNIQUE,
name text NOT NULL,
community_id int NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE,
published timestamptz NOT NULL DEFAULT now(),
updated timestamptz,
deleted boolean NOT NULL DEFAULT FALSE
);
-- an association between a post and a tag. created/updated by the post author or mods of a community
CREATE TABLE post_tag (
post_id int NOT NULL REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE,
tag_id int NOT NULL REFERENCES tag (id) ON UPDATE CASCADE ON DELETE CASCADE,
published timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, tag_id)
);