use eyre::Result; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use validator::Validate; use xredis::RedisPool; use crate::{ database::models::{DBProjectId, DBVersionId}, models::{ exp::{ component::{ self, Component, ComponentEdit, ComponentKind, ComponentQuery, }, minecraft, }, ids::{ProjectId, VersionId}, }, queue::{ analytics::cache::{ MINECRAFT_SERVER_ANALYTICS, MinecraftServerAnalytics, }, server_ping, }, util::error::Context, }; pub trait ProjectComponent: Component { fn kind() -> ProjectComponentKind; } macro_rules! define_project_components { ( $(($field_name:ident, $variant_name:ident): $ty:ty),* $(,)? ) => { // kinds #[expect(dead_code, reason = "static check so $ty implements `ProjectComponent`")] const _: () = { fn assert_implements_component() {} fn assert_components_implement_trait() { $(assert_implements_component::<$ty>();)* } }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ProjectComponentKind { $($variant_name,)* } impl ComponentKind for ProjectComponentKind {} #[derive(Debug, Clone, Default, Serialize, Deserialize, Validate)] pub struct ProjectSerial { $( #[validate(nested)] #[serde(default)] pub $field_name: Option<$ty>, )* } impl ProjectSerial { #[must_use] pub fn component_kinds(&self) -> HashSet { let mut kinds = HashSet::new(); $( if self.$field_name.is_some() { kinds.insert(ProjectComponentKind::$variant_name); } )* kinds } pub fn collect_query_requirements( &self, project_id: ProjectId, requirements: &mut ProjectQueryRequirements, ) { $( if let Some(component) = &self.$field_name { <<$ty as Component>::Query as ComponentQuery>::collect_requirements( component, project_id, requirements ); } )* } pub fn into_query( self, project_id: ProjectId, context: &ProjectQueryContext, ) -> Result { Ok(ProjectQuery { $( $field_name: match self.$field_name { Some(serial) => { <$ty as Component>::Query::populate( serial, project_id, context, ) .map(Some) .wrap_err(concat!("failed to populate `", stringify!($ty), "`"))? } None => None, }, )* }) } } #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectQuery { $( pub $field_name: Option>, )* } #[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)] pub struct ProjectEdit { $( #[validate(nested)] #[serde(skip_serializing_if = "Option::is_none", default)] pub $field_name: Option>, )* } impl ProjectEdit { #[must_use] pub fn component_kinds(&self) -> HashSet { let mut kinds = HashSet::new(); $( if self.$field_name.is_some() { kinds.insert(ProjectComponentKind::$variant_name); } )* kinds } pub fn create(self) -> Result { Ok(ProjectSerial { $( $field_name: self .$field_name .map(<<$ty as Component>::Edit as ComponentEdit>::create) .transpose()?, )* }) } } }; } // needed because the `utoipa::ToSchema` macro is broken // when you have a `::` in the type path type Edit = ::Edit; type Query = ::Query; define_project_components![ (minecraft_mod, MinecraftMod): minecraft::ModProject, (minecraft_server, MinecraftServer): minecraft::ServerProject, (minecraft_java_server, MinecraftJavaServer): minecraft::JavaServerProject, (minecraft_bedrock_server, MinecraftBedrockServer): minecraft::BedrockServerProject, ]; component::relations! { pub static PROJECT_COMPONENT_RELATIONS: ProjectComponentKind = { minecraft::PROJECT_COMPONENT_RELATIONS.clone() } } // query logic #[derive(Default)] pub struct ProjectQueryRequirements { pub partial_versions: HashSet, pub minecraft_java_server_pings: HashSet, pub minecraft_server_analytics: HashSet, } pub struct ProjectQueryContext { pub partial_versions: HashMap, pub minecraft_java_server_pings: HashMap, pub minecraft_server_analytics: HashMap, } #[derive(Clone, Debug)] pub struct PartialVersion { pub project_id: ProjectId, pub project_name: String, pub project_icon: String, } pub async fn fetch_query_context( projects: &[(ProjectId, &ProjectSerial)], db: impl crate::database::Executor<'_, Database = sqlx::Postgres>, redis: &RedisPool, ) -> Result { let mut requirements = ProjectQueryRequirements::default(); for (project_id, project) in projects { project.collect_query_requirements(*project_id, &mut requirements); } let ProjectQueryRequirements { partial_versions, minecraft_java_server_pings, minecraft_server_analytics, } = requirements; let partial_versions = if partial_versions.is_empty() { HashMap::new() } else { sqlx::query!( r#" SELECT v.id AS "version_id: DBVersionId", m.id AS "project_id: DBProjectId", m.name AS "project_name!", COALESCE(m.icon_url, '') AS "project_icon!" FROM versions v INNER JOIN mods m ON m.id = v.mod_id WHERE v.id = ANY($1) "#, &partial_versions .iter() .map(|id| DBVersionId::from(*id).0) .collect::>(), ) .fetch_all(db) .await .wrap_err("failed to fetch partial versions")? .into_iter() .map(|row| { ( VersionId::from(row.version_id), PartialVersion { project_id: ProjectId::from(row.project_id), project_name: row.project_name, project_icon: row.project_icon, }, ) }) .collect::>() }; let mut redis = redis.connect().await?; let minecraft_java_server_pings = minecraft_java_server_pings.into_iter().collect::>(); let minecraft_java_server_pings = if minecraft_java_server_pings.is_empty() { HashMap::new() } else { let ping_keys = minecraft_java_server_pings .iter() .map(|project_id| { redis.key().entity(server_ping::REDIS_NAMESPACE, project_id) }) .collect::>(); redis .get_many_deserialized::(&ping_keys) .await? .into_iter() .enumerate() .filter_map(|(idx, ping)| { ping.map(|ping| (minecraft_java_server_pings[idx], ping)) }) .collect::>() }; let minecraft_server_analytics = minecraft_server_analytics.into_iter().collect::>(); let minecraft_server_analytics = if minecraft_server_analytics.is_empty() { HashMap::new() } else { let analytics_keys = minecraft_server_analytics .iter() .map(|project_id| { redis.key().entity(MINECRAFT_SERVER_ANALYTICS, project_id) }) .collect::>(); redis .get_many_deserialized::(&analytics_keys) .await? .into_iter() .enumerate() .filter_map(|(idx, data)| { data.map(|data| (minecraft_server_analytics[idx], data)) }) .collect::>() }; Ok(ProjectQueryContext { partial_versions, minecraft_java_server_pings, minecraft_server_analytics, }) }