Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
at main 295 lines 11 kB view raw
1// SQL query builder for dynamic event filtering 2// 3// Constructs and executes SQL queries based on filter criteria, 4// supporting PostGIS for location-based filtering. 5 6use sqlx::{PgPool, QueryBuilder}; 7use tracing::{debug, instrument, trace}; 8 9use super::{EventFilterCriteria, EventSortField, FilterError, LocationFilter, SortOrder}; 10use crate::storage::event::model::Event; 11use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; 12 13/// SQL query builder for event filtering 14#[derive(Debug, Clone)] 15pub struct EventQueryBuilder { 16 pool: PgPool, 17} 18 19impl EventQueryBuilder { 20 /// Create a new query builder 21 pub fn new(pool: PgPool) -> Self { 22 Self { pool } 23 } 24 25 /// Build and execute a query to get filtered events 26 #[instrument(skip(self, criteria), fields(search_term = ?criteria.search_term, page = criteria.page))] 27 pub async fn build_and_execute( 28 &self, 29 criteria: &EventFilterCriteria, 30 ) -> Result<Vec<Event>, FilterError> { 31 let mut query = QueryBuilder::new( 32 "SELECT aturi, cid, did, lexicon, record, name, updated_at FROM events" 33 ); 34 35 // Apply filters 36 self.apply_filters(&mut query, criteria); 37 38 // Apply sorting 39 self.apply_sorting(&mut query, criteria); 40 41 // Apply pagination 42 self.apply_pagination(&mut query, criteria); 43 44 trace!("Executing query: {}", query.sql()); 45 46 let events = query 47 .build_query_as::<Event>() 48 .fetch_all(&self.pool) 49 .await?; 50 51 Ok(events) 52 } 53 54 /// Build and execute a query to count total results 55 #[instrument(skip(self, criteria))] 56 pub async fn count_results(&self, criteria: &EventFilterCriteria) -> Result<i64, FilterError> { 57 let mut query = QueryBuilder::new("SELECT COUNT(*) FROM events"); 58 59 // Apply the same filters but without sorting/pagination 60 self.apply_where_clause(&mut query, criteria); 61 62 let count: (i64,) = query 63 .build_query_as() 64 .fetch_one(&self.pool) 65 .await?; 66 67 Ok(count.0) 68 } 69 70 /// Apply WHERE clause filters to the query 71 fn apply_filters<'a>( 72 &self, 73 query: &mut QueryBuilder<'a, sqlx::Postgres>, 74 criteria: &'a EventFilterCriteria, 75 ) { 76 self.apply_where_clause(query, criteria); 77 } 78 79 /// Apply WHERE clause conditions 80 fn apply_where_clause<'a>( 81 &self, 82 query: &mut QueryBuilder<'a, sqlx::Postgres>, 83 criteria: &'a EventFilterCriteria, 84 ) { 85 let mut has_where = false; 86 87 // Text search in name and description 88 if let Some(ref term) = criteria.search_term { 89 if !term.trim().is_empty() { 90 query.push(if has_where { " AND " } else { " WHERE " }); 91 query.push("(name ILIKE "); 92 query.push_bind(format!("%{}%", term)); 93 query.push(" OR record->>'description' ILIKE "); 94 query.push_bind(format!("%{}%", term)); 95 query.push(")"); 96 has_where = true; 97 } 98 } 99 100 // Date filtering - using overlap logic for events within date range 101 // An event overlaps with the filter range if: 102 // event_start <= filter_end AND event_end >= filter_start 103 if criteria.start_date.is_some() || criteria.end_date.is_some() { 104 debug!("Applying date filters: start_date={:?}, end_date={:?}", criteria.start_date, criteria.end_date); 105 106 query.push(if has_where { " AND " } else { " WHERE " }); 107 query.push("("); 108 109 let mut condition_added = false; 110 111 if let Some(filter_start) = criteria.start_date { 112 // Event must end on or after filter start date (for overlap) 113 // If no endsAt, use end of start day (startsAt date + 23:59:59) 114 query.push("COALESCE((record->>'endsAt')::timestamptz, \ 115 DATE_TRUNC('day', (record->>'startsAt')::timestamptz) + INTERVAL '1 day' - INTERVAL '1 second') >= "); 116 query.push_bind(filter_start); 117 condition_added = true; 118 } 119 120 if let Some(filter_end) = criteria.end_date { 121 // Event must start on or before filter end date (for overlap) 122 if condition_added { 123 query.push(" AND "); 124 } 125 query.push("(record->>'startsAt')::timestamptz <= "); 126 query.push_bind(filter_end); 127 } 128 129 query.push(")"); 130 has_where = true; 131 } 132 133 // Creator filtering 134 if let Some(ref creator_did) = criteria.creator_did { 135 query.push(if has_where { " AND " } else { " WHERE " }); 136 query.push("did = "); 137 query.push_bind(creator_did); 138 has_where = true; 139 } 140 141 // Mode filtering 142 if !criteria.modes.is_empty() { 143 query.push(if has_where { " AND " } else { " WHERE " }); 144 query.push("("); 145 for (i, mode) in criteria.modes.iter().enumerate() { 146 if i > 0 { 147 query.push(" OR "); 148 } 149 // Convert enum to its serde representation for database comparison 150 let mode_str = match mode { 151 Mode::InPerson => 152 "community.lexicon.calendar.event#inperson", 153 Mode::Virtual => 154 "community.lexicon.calendar.event#virtual", 155 Mode::Hybrid => 156 "community.lexicon.calendar.event#hybrid", 157 }; 158 query.push("record->>'mode' = "); 159 query.push_bind(mode_str); 160 } 161 query.push(")"); 162 has_where = true; 163 } 164 165 // Status filtering 166 if !criteria.statuses.is_empty() { 167 query.push(if has_where { " AND " } else { " WHERE " }); 168 query.push("("); 169 for (i, status) in criteria.statuses.iter().enumerate() { 170 if i > 0 { 171 query.push(" OR "); 172 } 173 // Convert enum to its serde representation for database comparison 174 let status_str = match status { 175 Status::Scheduled => 176 "community.lexicon.calendar.event#scheduled", 177 Status::Rescheduled => 178 "community.lexicon.calendar.event#rescheduled", 179 Status::Cancelled => 180 "community.lexicon.calendar.event#cancelled", 181 Status::Postponed => 182 "community.lexicon.calendar.event#postponed", 183 Status::Planned => 184 "community.lexicon.calendar.event#planned", 185 }; 186 query.push("record->>'status' = "); 187 query.push_bind(status_str); 188 } 189 query.push(")"); 190 has_where = true; 191 } 192 193 // Location filtering using PostGIS (if PostGIS extension is available) 194 if let Some(ref location) = criteria.location { 195 self.apply_location_filter(query, location, has_where); 196 } 197 } 198 199 /// Apply location-based filtering using PostGIS 200 /// Uses the cached location_point column for efficient spatial queries. 201 /// Events must have valid coordinates stored in the location_point column to be included. 202 fn apply_location_filter<'a>( 203 &self, 204 query: &mut QueryBuilder<'a, sqlx::Postgres>, 205 location: &'a LocationFilter, 206 has_where: bool, 207 ) { 208 query.push(if has_where { " AND " } else { " WHERE " }); 209 210 // Use the cached location_point column for efficient PostGIS spatial queries 211 // This column is populated during event creation/editing when coordinates are available 212 query.push("location_point IS NOT NULL AND ST_DWithin("); 213 query.push(" location_point::geography, "); 214 query.push(" ST_MakePoint("); 215 query.push_bind(location.longitude); 216 query.push(", "); 217 query.push_bind(location.latitude); 218 query.push(")::geography, "); 219 query.push_bind(location.radius_km * 1000.0); // Convert km to meters 220 query.push(")"); 221 } 222 223 /// Apply sorting to the query 224 fn apply_sorting<'a>( 225 &self, 226 query: &mut QueryBuilder<'a, sqlx::Postgres>, 227 criteria: &'a EventFilterCriteria, 228 ) { 229 query.push(" ORDER BY "); 230 231 match criteria.sort_by { 232 EventSortField::StartTime => { 233 query.push("(record->>'startsAt')::timestamptz"); 234 } 235 EventSortField::UpdatedAt => { 236 query.push("updated_at"); 237 } 238 EventSortField::Name => { 239 query.push("name"); 240 } 241 EventSortField::PopularityRsvp => { 242 // This would require a more complex query with a subquery or join 243 // For now, fall back to start time 244 query.push("(record->>'startsAt')::timestamptz"); 245 } 246 } 247 248 match criteria.sort_order { 249 SortOrder::Ascending => query.push(" ASC"), 250 SortOrder::Descending => query.push(" DESC"), 251 }; 252 } 253 254 /// Apply pagination to the query 255 fn apply_pagination<'a>( 256 &self, 257 query: &mut QueryBuilder<'a, sqlx::Postgres>, 258 criteria: &'a EventFilterCriteria, 259 ) { 260 query.push(" LIMIT "); 261 query.push_bind(criteria.page_size as i64); 262 query.push(" OFFSET "); 263 264 // Ensure page is at least 1 to avoid underflow 265 let page = criteria.page.max(1); 266 let offset = (page - 1) * criteria.page_size; 267 query.push_bind(offset as i64); 268 } 269} 270 271#[cfg(test)] 272mod tests { 273 use super::*; 274 275 #[test] 276 fn test_criteria_building() { 277 let criteria = EventFilterCriteria::new() 278 .with_search_term("conference") 279 .with_pagination(0, 10); 280 281 assert_eq!(criteria.search_term, Some("conference".to_string())); 282 assert_eq!(criteria.page, 0); 283 assert_eq!(criteria.page_size, 10); 284 } 285 286 #[test] 287 fn test_has_filters() { 288 let empty_criteria = EventFilterCriteria::new(); 289 assert!(!empty_criteria.has_filters()); 290 291 let filtered_criteria = EventFilterCriteria::new() 292 .with_search_term("test"); 293 assert!(filtered_criteria.has_filters()); 294 } 295}