Highly ambitious ATProtocol AppView service and sdks
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add graphql support for arbitrarily nested and/or statements

+622 -306
+8
api/src/api/xrpc_dynamic.rs
··· 197 197 let where_clause = Some(crate::models::WhereClause { 198 198 conditions: where_conditions, 199 199 or_conditions: None, 200 + and: None, 201 + or: None, 200 202 }); 201 203 202 204 let records_params = SliceRecordsParams { ··· 319 321 .unwrap_or(crate::models::WhereClause { 320 322 conditions: HashMap::new(), 321 323 or_conditions: None, 324 + and: None, 325 + or: None, 322 326 }); 323 327 where_clause.conditions.insert( 324 328 "collection".to_string(), ··· 401 405 .unwrap_or(crate::models::WhereClause { 402 406 conditions: HashMap::new(), 403 407 or_conditions: None, 408 + and: None, 409 + or: None, 404 410 }); 405 411 where_clause.conditions.insert( 406 412 "collection".to_string(), ··· 464 470 .unwrap_or(crate::models::WhereClause { 465 471 conditions: HashMap::new(), 466 472 or_conditions: None, 473 + and: None, 474 + or: None, 467 475 }); 468 476 where_clause.conditions.insert( 469 477 "collection".to_string(),
+279 -7
api/src/database/query_builder.rs
··· 67 67 /// Returns separate arrays for AND conditions and OR conditions 68 68 /// to be combined in the final query. 69 69 /// 70 + /// Supports both legacy flat conditions and nested and/or arrays. 71 + /// 70 72 /// # Arguments 71 73 /// * `where_clause` - Optional where clause with AND/OR conditions 72 74 /// * `param_count` - Mutable counter for parameter numbering ($1, $2, etc) ··· 81 83 let mut or_clauses = Vec::new(); 82 84 83 85 if let Some(clause) = where_clause { 86 + // Handle legacy flat conditions 84 87 for (field, condition) in &clause.conditions { 85 88 let field_clause = build_single_condition(field, condition, param_count); 86 - where_clauses.push(field_clause); 89 + if !field_clause.is_empty() { 90 + where_clauses.push(field_clause); 91 + } 87 92 } 88 93 94 + // Handle legacy or_conditions 89 95 if let Some(or_conditions) = &clause.or_conditions { 90 96 for (field, condition) in or_conditions { 91 97 let field_clause = build_single_condition(field, condition, param_count); 92 - or_clauses.push(field_clause); 98 + if !field_clause.is_empty() { 99 + or_clauses.push(field_clause); 100 + } 101 + } 102 + } 103 + 104 + // Handle nested AND array 105 + if let Some(and_clauses) = &clause.and { 106 + for nested_clause in and_clauses { 107 + let nested_sql = build_nested_clause(nested_clause, param_count); 108 + if !nested_sql.is_empty() { 109 + where_clauses.push(nested_sql); 110 + } 111 + } 112 + } 113 + 114 + // Handle nested OR array 115 + if let Some(or_clauses_nested) = &clause.or { 116 + let mut or_parts = Vec::new(); 117 + for nested_clause in or_clauses_nested { 118 + let nested_sql = build_nested_clause(nested_clause, param_count); 119 + if !nested_sql.is_empty() { 120 + or_parts.push(nested_sql); 121 + } 122 + } 123 + if !or_parts.is_empty() { 124 + let or_sql = format!("({})", or_parts.join(" OR ")); 125 + where_clauses.push(or_sql); 93 126 } 94 127 } 95 128 } ··· 97 130 (where_clauses, or_clauses) 98 131 } 99 132 133 + /// Builds a nested WHERE clause recursively. 134 + /// 135 + /// Handles arbitrarily nested and/or structures. 136 + /// 137 + /// # Arguments 138 + /// * `clause` - The WhereClause to process 139 + /// * `param_count` - Mutable counter for parameter numbering 140 + /// 141 + /// # Returns 142 + /// SQL condition string (may be wrapped in parentheses) 143 + fn build_nested_clause(clause: &WhereClause, param_count: &mut usize) -> String { 144 + let mut parts = Vec::new(); 145 + 146 + // Add flat conditions 147 + for (field, condition) in &clause.conditions { 148 + let field_clause = build_single_condition(field, condition, param_count); 149 + if !field_clause.is_empty() { 150 + parts.push(field_clause); 151 + } 152 + } 153 + 154 + // Add legacy or_conditions as an OR group 155 + if let Some(or_conditions) = &clause.or_conditions { 156 + let mut or_parts = Vec::new(); 157 + for (field, condition) in or_conditions { 158 + let field_clause = build_single_condition(field, condition, param_count); 159 + if !field_clause.is_empty() { 160 + or_parts.push(field_clause); 161 + } 162 + } 163 + if !or_parts.is_empty() { 164 + parts.push(format!("({})", or_parts.join(" OR "))); 165 + } 166 + } 167 + 168 + // Add nested AND array 169 + if let Some(and_clauses) = &clause.and { 170 + for nested_clause in and_clauses { 171 + let nested_sql = build_nested_clause(nested_clause, param_count); 172 + if !nested_sql.is_empty() { 173 + parts.push(nested_sql); 174 + } 175 + } 176 + } 177 + 178 + // Add nested OR array 179 + if let Some(or_clauses) = &clause.or { 180 + let mut or_parts = Vec::new(); 181 + for nested_clause in or_clauses { 182 + let nested_sql = build_nested_clause(nested_clause, param_count); 183 + if !nested_sql.is_empty() { 184 + or_parts.push(nested_sql); 185 + } 186 + } 187 + if !or_parts.is_empty() { 188 + parts.push(format!("({})", or_parts.join(" OR "))); 189 + } 190 + } 191 + 192 + if parts.is_empty() { 193 + String::new() 194 + } else if parts.len() == 1 { 195 + parts[0].clone() 196 + } else { 197 + format!("({})", parts.join(" AND ")) 198 + } 199 + } 200 + 100 201 /// Builds a single SQL condition clause for a field. 101 202 /// 102 203 /// Supports equality (eq), array membership (in_values), and pattern matching (contains) ··· 242 343 /// Binds WHERE clause parameters to a sqlx query. 243 344 /// 244 345 /// Iterates through all conditions and binds their values in the correct order. 346 + /// Supports both legacy flat conditions and nested and/or arrays. 245 347 /// 246 348 /// # Arguments 247 349 /// * `query_builder` - The sqlx query to bind parameters to ··· 259 361 where_clause: Option<&'q WhereClause>, 260 362 ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> { 261 363 if let Some(clause) = where_clause { 262 - for condition in clause.conditions.values() { 364 + query_builder = bind_clause_recursive(query_builder, clause); 365 + } 366 + query_builder 367 + } 368 + 369 + /// Recursively binds parameters from a WhereClause (including nested clauses). 370 + fn bind_clause_recursive<'q>( 371 + mut query_builder: sqlx::query::QueryAs< 372 + 'q, 373 + sqlx::Postgres, 374 + Record, 375 + sqlx::postgres::PgArguments, 376 + >, 377 + clause: &'q WhereClause, 378 + ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> { 379 + // Bind legacy flat conditions 380 + for condition in clause.conditions.values() { 381 + query_builder = bind_single_condition(query_builder, condition); 382 + } 383 + 384 + // Bind legacy or_conditions 385 + if let Some(or_conditions) = &clause.or_conditions { 386 + for condition in or_conditions.values() { 263 387 query_builder = bind_single_condition(query_builder, condition); 264 388 } 389 + } 265 390 266 - if let Some(or_conditions) = &clause.or_conditions { 267 - for condition in or_conditions.values() { 268 - query_builder = bind_single_condition(query_builder, condition); 269 - } 391 + // Bind nested AND array 392 + if let Some(and_clauses) = &clause.and { 393 + for nested_clause in and_clauses { 394 + query_builder = bind_clause_recursive(query_builder, nested_clause); 270 395 } 271 396 } 397 + 398 + // Bind nested OR array 399 + if let Some(or_clauses) = &clause.or { 400 + for nested_clause in or_clauses { 401 + query_builder = bind_clause_recursive(query_builder, nested_clause); 402 + } 403 + } 404 + 272 405 query_builder 273 406 } 274 407 ··· 284 417 >, 285 418 condition: &'q WhereCondition, 286 419 ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> { 420 + if let Some(eq_value) = &condition.eq { 421 + if let Some(str_val) = eq_value.as_str() { 422 + query_builder = query_builder.bind(str_val); 423 + } else { 424 + query_builder = query_builder.bind(eq_value); 425 + } 426 + } 427 + 428 + if let Some(in_values) = &condition.in_values { 429 + let str_values: Vec<String> = in_values 430 + .iter() 431 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 432 + .collect(); 433 + query_builder = query_builder.bind(str_values); 434 + } 435 + 436 + if let Some(contains_value) = &condition.contains { 437 + query_builder = query_builder.bind(contains_value); 438 + } 439 + 440 + if let Some(gt_value) = &condition.gt { 441 + if let Some(str_val) = gt_value.as_str() { 442 + query_builder = query_builder.bind(str_val); 443 + } else { 444 + query_builder = query_builder.bind(gt_value); 445 + } 446 + } 447 + 448 + if let Some(gte_value) = &condition.gte { 449 + if let Some(str_val) = gte_value.as_str() { 450 + query_builder = query_builder.bind(str_val); 451 + } else { 452 + query_builder = query_builder.bind(gte_value); 453 + } 454 + } 455 + 456 + if let Some(lt_value) = &condition.lt { 457 + if let Some(str_val) = lt_value.as_str() { 458 + query_builder = query_builder.bind(str_val); 459 + } else { 460 + query_builder = query_builder.bind(lt_value); 461 + } 462 + } 463 + 464 + if let Some(lte_value) = &condition.lte { 465 + if let Some(str_val) = lte_value.as_str() { 466 + query_builder = query_builder.bind(str_val); 467 + } else { 468 + query_builder = query_builder.bind(lte_value); 469 + } 470 + } 471 + 472 + query_builder 473 + } 474 + 475 + /// Binds WHERE clause parameters to a sqlx scalar query (for COUNT queries). 476 + /// 477 + /// Iterates through all conditions and binds their values in the correct order. 478 + /// Supports both legacy flat conditions and nested and/or arrays. 479 + /// 480 + /// # Arguments 481 + /// * `query_builder` - The sqlx scalar query to bind parameters to 482 + /// * `where_clause` - Optional where clause with parameter values 483 + /// 484 + /// # Returns 485 + /// Query builder with all parameters bound 486 + pub fn bind_where_parameters_scalar<'q, T>( 487 + mut query_builder: sqlx::query::QueryScalar< 488 + 'q, 489 + sqlx::Postgres, 490 + T, 491 + sqlx::postgres::PgArguments, 492 + >, 493 + where_clause: Option<&'q WhereClause>, 494 + ) -> sqlx::query::QueryScalar<'q, sqlx::Postgres, T, sqlx::postgres::PgArguments> 495 + where 496 + T: Send + Unpin, 497 + { 498 + if let Some(clause) = where_clause { 499 + query_builder = bind_clause_recursive_scalar(query_builder, clause); 500 + } 501 + query_builder 502 + } 503 + 504 + /// Recursively binds parameters from a WhereClause to a scalar query. 505 + fn bind_clause_recursive_scalar<'q, T>( 506 + mut query_builder: sqlx::query::QueryScalar< 507 + 'q, 508 + sqlx::Postgres, 509 + T, 510 + sqlx::postgres::PgArguments, 511 + >, 512 + clause: &'q WhereClause, 513 + ) -> sqlx::query::QueryScalar<'q, sqlx::Postgres, T, sqlx::postgres::PgArguments> 514 + where 515 + T: Send + Unpin, 516 + { 517 + // Bind legacy flat conditions 518 + for condition in clause.conditions.values() { 519 + query_builder = bind_single_condition_scalar(query_builder, condition); 520 + } 521 + 522 + // Bind legacy or_conditions 523 + if let Some(or_conditions) = &clause.or_conditions { 524 + for condition in or_conditions.values() { 525 + query_builder = bind_single_condition_scalar(query_builder, condition); 526 + } 527 + } 528 + 529 + // Bind nested AND array 530 + if let Some(and_clauses) = &clause.and { 531 + for nested_clause in and_clauses { 532 + query_builder = bind_clause_recursive_scalar(query_builder, nested_clause); 533 + } 534 + } 535 + 536 + // Bind nested OR array 537 + if let Some(or_clauses) = &clause.or { 538 + for nested_clause in or_clauses { 539 + query_builder = bind_clause_recursive_scalar(query_builder, nested_clause); 540 + } 541 + } 542 + 543 + query_builder 544 + } 545 + 546 + /// Binds parameters for a single condition to a sqlx scalar query. 547 + fn bind_single_condition_scalar<'q, T>( 548 + mut query_builder: sqlx::query::QueryScalar< 549 + 'q, 550 + sqlx::Postgres, 551 + T, 552 + sqlx::postgres::PgArguments, 553 + >, 554 + condition: &'q WhereCondition, 555 + ) -> sqlx::query::QueryScalar<'q, sqlx::Postgres, T, sqlx::postgres::PgArguments> 556 + where 557 + T: Send + Unpin, 558 + { 287 559 if let Some(eq_value) = &condition.eq { 288 560 if let Some(str_val) = eq_value.as_str() { 289 561 query_builder = query_builder.bind(str_val);
+4 -101
api/src/database/records.rs
··· 6 6 7 7 use super::client::Database; 8 8 use super::cursor::{build_cursor_where_condition, decode_cursor, generate_cursor_from_record}; 9 - use super::query_builder::{bind_where_parameters, build_order_by_clause_with_field_info, build_where_conditions}; 9 + use super::query_builder::{bind_where_parameters, bind_where_parameters_scalar, build_order_by_clause_with_field_info, build_where_conditions}; 10 10 use super::types::{SortField, WhereClause}; 11 11 use crate::errors::DatabaseError; 12 12 use crate::models::{IndexedRecord, Record}; ··· 289 289 filtered_clause = WhereClause { 290 290 conditions: filtered_conditions, 291 291 or_conditions: wc.or_conditions.clone(), 292 + and: wc.and.clone(), 293 + or: wc.or.clone(), 292 294 }; 293 295 filtered_where_clause = Some(&filtered_clause); 294 296 } ··· 414 416 415 417 let mut query_builder = sqlx::query_scalar::<_, i64>(&query); 416 418 query_builder = query_builder.bind(slice_uri); 417 - 418 - if let Some(clause) = where_clause { 419 - for condition in clause.conditions.values() { 420 - if let Some(eq_value) = &condition.eq { 421 - if let Some(str_val) = eq_value.as_str() { 422 - query_builder = query_builder.bind(str_val); 423 - } else { 424 - query_builder = query_builder.bind(eq_value); 425 - } 426 - } 427 - if let Some(in_values) = &condition.in_values { 428 - let str_values: Vec<String> = in_values 429 - .iter() 430 - .filter_map(|v| v.as_str().map(|s| s.to_string())) 431 - .collect(); 432 - query_builder = query_builder.bind(str_values); 433 - } 434 - if let Some(contains_value) = &condition.contains { 435 - query_builder = query_builder.bind(contains_value); 436 - } 437 - if let Some(gt_value) = &condition.gt { 438 - if let Some(str_val) = gt_value.as_str() { 439 - query_builder = query_builder.bind(str_val); 440 - } else { 441 - query_builder = query_builder.bind(gt_value); 442 - } 443 - } 444 - if let Some(gte_value) = &condition.gte { 445 - if let Some(str_val) = gte_value.as_str() { 446 - query_builder = query_builder.bind(str_val); 447 - } else { 448 - query_builder = query_builder.bind(gte_value); 449 - } 450 - } 451 - if let Some(lt_value) = &condition.lt { 452 - if let Some(str_val) = lt_value.as_str() { 453 - query_builder = query_builder.bind(str_val); 454 - } else { 455 - query_builder = query_builder.bind(lt_value); 456 - } 457 - } 458 - if let Some(lte_value) = &condition.lte { 459 - if let Some(str_val) = lte_value.as_str() { 460 - query_builder = query_builder.bind(str_val); 461 - } else { 462 - query_builder = query_builder.bind(lte_value); 463 - } 464 - } 465 - } 466 - 467 - if let Some(or_conditions) = &clause.or_conditions { 468 - for condition in or_conditions.values() { 469 - if let Some(eq_value) = &condition.eq { 470 - if let Some(str_val) = eq_value.as_str() { 471 - query_builder = query_builder.bind(str_val); 472 - } else { 473 - query_builder = query_builder.bind(eq_value); 474 - } 475 - } 476 - if let Some(in_values) = &condition.in_values { 477 - let str_values: Vec<String> = in_values 478 - .iter() 479 - .filter_map(|v| v.as_str().map(|s| s.to_string())) 480 - .collect(); 481 - query_builder = query_builder.bind(str_values); 482 - } 483 - if let Some(contains_value) = &condition.contains { 484 - query_builder = query_builder.bind(contains_value); 485 - } 486 - if let Some(gt_value) = &condition.gt { 487 - if let Some(str_val) = gt_value.as_str() { 488 - query_builder = query_builder.bind(str_val); 489 - } else { 490 - query_builder = query_builder.bind(gt_value); 491 - } 492 - } 493 - if let Some(gte_value) = &condition.gte { 494 - if let Some(str_val) = gte_value.as_str() { 495 - query_builder = query_builder.bind(str_val); 496 - } else { 497 - query_builder = query_builder.bind(gte_value); 498 - } 499 - } 500 - if let Some(lt_value) = &condition.lt { 501 - if let Some(str_val) = lt_value.as_str() { 502 - query_builder = query_builder.bind(str_val); 503 - } else { 504 - query_builder = query_builder.bind(lt_value); 505 - } 506 - } 507 - if let Some(lte_value) = &condition.lte { 508 - if let Some(str_val) = lte_value.as_str() { 509 - query_builder = query_builder.bind(str_val); 510 - } else { 511 - query_builder = query_builder.bind(lte_value); 512 - } 513 - } 514 - } 515 - } 516 - } 419 + query_builder = bind_where_parameters_scalar(query_builder, where_clause); 517 420 518 421 let count = query_builder.fetch_one(&self.pool).await?; 519 422 Ok(count)
+25 -7
api/src/database/types.rs
··· 32 32 33 33 /// Represents a complete WHERE clause with AND/OR conditions. 34 34 /// 35 + /// Supports both flat conditions (legacy) and nested and/or arrays (new). 35 36 /// The main conditions map is combined with AND logic. 36 37 /// The or_conditions map (if present) is combined with OR logic 37 38 /// and the entire OR group is ANDed with the main conditions. 38 39 /// 39 - /// Example JSON: 40 + /// New nested format supports arbitrarily nestable and/or: 40 41 /// ```json 41 42 /// { 42 - /// "collection": {"eq": "app.bsky.feed.post"}, 43 - /// "author": {"eq": "did:plc:123"}, 44 - /// "$or": { 45 - /// "lang": {"eq": "en"}, 46 - /// "lang": {"eq": "es"} 47 - /// } 43 + /// "and": [ 44 + /// { 45 + /// "or": [ 46 + /// { "artist": { "contains": "pearl jam" } }, 47 + /// { "genre": { "contains": "rock" } } 48 + /// ] 49 + /// }, 50 + /// { 51 + /// "and": [ 52 + /// { "uri": { "contains": "app.bsky" } }, 53 + /// { "uri": { "contains": "post" } } 54 + /// ] 55 + /// }, 56 + /// { "year": { "gte": 2000 } } 57 + /// ] 48 58 /// } 49 59 /// ``` 50 60 #[derive(Debug, Clone, Serialize, Deserialize)] ··· 55 65 56 66 #[serde(rename = "$or")] 57 67 pub or_conditions: Option<HashMap<String, WhereCondition>>, 68 + 69 + /// Nested AND conditions - array of WhereClause objects all ANDed together 70 + #[serde(rename = "and", skip_serializing_if = "Option::is_none")] 71 + pub and: Option<Vec<WhereClause>>, 72 + 73 + /// Nested OR conditions - array of WhereClause objects all ORed together 74 + #[serde(rename = "or", skip_serializing_if = "Option::is_none")] 75 + pub or: Option<Vec<WhereClause>>, 58 76 } 59 77 60 78 /// Represents a field to sort by with direction.
+4
api/src/graphql/dataloader.rs
··· 51 51 let mut where_clause = WhereClause { 52 52 conditions: HashMap::new(), 53 53 or_conditions: None, 54 + and: None, 55 + or: None, 54 56 }; 55 57 56 58 // Filter by collection ··· 177 179 let mut where_clause = WhereClause { 178 180 conditions: HashMap::new(), 179 181 or_conditions: None, 182 + and: None, 183 + or: None, 180 184 }; 181 185 182 186 // Filter by collection
+164 -179
api/src/graphql/schema_builder.rs
··· 153 153 where_input = where_input.field(InputValue::new(&field.name, TypeRef::named(filter_type))); 154 154 } 155 155 156 + // Add nested and/or support 157 + where_input = where_input 158 + .field(InputValue::new("and", TypeRef::named_list(format!("{}WhereInput", type_name)))) 159 + .field(InputValue::new("or", TypeRef::named_list(format!("{}WhereInput", type_name)))); 160 + 156 161 // Create GroupByField enum for this collection 157 162 let mut group_by_enum = Enum::new(format!("{}GroupByField", type_name)); 158 163 group_by_enum = group_by_enum.item(EnumItem::new("indexedAt")); ··· 236 241 let mut where_clause = crate::models::WhereClause { 237 242 conditions: HashMap::new(), 238 243 or_conditions: None, 244 + and: None, 245 + or: None, 239 246 }; 240 247 241 248 // Always filter by collection ··· 254 261 255 262 // Parse where argument if provided 256 263 if let Some(where_val) = ctx.args.get("where") { 257 - // Try to parse as JSON object 258 264 if let Ok(where_obj) = where_val.object() { 259 - for (field_name, condition_val) in where_obj.iter() { 260 - if let Ok(condition_obj) = condition_val.object() { 261 - let mut where_condition = crate::models::WhereCondition { 262 - gt: None, 263 - gte: None, 264 - lt: None, 265 - lte: None, 266 - eq: None, 267 - in_values: None, 268 - contains: None, 269 - }; 270 - 271 - // Parse eq condition 272 - if let Some(eq_val) = condition_obj.get("eq") { 273 - if let Ok(eq_str) = eq_val.string() { 274 - where_condition.eq = Some(serde_json::Value::String(eq_str.to_string())); 275 - } else if let Ok(eq_i64) = eq_val.i64() { 276 - where_condition.eq = Some(serde_json::Value::Number(eq_i64.into())); 277 - } 278 - } 279 - 280 - // Parse in condition 281 - if let Some(in_val) = condition_obj.get("in") { 282 - if let Ok(in_list) = in_val.list() { 283 - let mut values = Vec::new(); 284 - for item in in_list.iter() { 285 - if let Ok(s) = item.string() { 286 - values.push(serde_json::Value::String(s.to_string())); 287 - } else if let Ok(i) = item.i64() { 288 - values.push(serde_json::Value::Number(i.into())); 289 - } 290 - } 291 - where_condition.in_values = Some(values); 292 - } 293 - } 294 - 295 - // Parse contains condition 296 - if let Some(contains_val) = condition_obj.get("contains") { 297 - if let Ok(contains_str) = contains_val.string() { 298 - where_condition.contains = Some(contains_str.to_string()); 299 - } 300 - } 301 - 302 - // Parse gt condition 303 - if let Some(gt_val) = condition_obj.get("gt") { 304 - if let Ok(gt_str) = gt_val.string() { 305 - where_condition.gt = Some(serde_json::Value::String(gt_str.to_string())); 306 - } else if let Ok(gt_i64) = gt_val.i64() { 307 - where_condition.gt = Some(serde_json::Value::Number(gt_i64.into())); 308 - } 309 - } 310 - 311 - // Parse gte condition 312 - if let Some(gte_val) = condition_obj.get("gte") { 313 - if let Ok(gte_str) = gte_val.string() { 314 - where_condition.gte = Some(serde_json::Value::String(gte_str.to_string())); 315 - } else if let Ok(gte_i64) = gte_val.i64() { 316 - where_condition.gte = Some(serde_json::Value::Number(gte_i64.into())); 317 - } 318 - } 319 - 320 - // Parse lt condition 321 - if let Some(lt_val) = condition_obj.get("lt") { 322 - if let Ok(lt_str) = lt_val.string() { 323 - where_condition.lt = Some(serde_json::Value::String(lt_str.to_string())); 324 - } else if let Ok(lt_i64) = lt_val.i64() { 325 - where_condition.lt = Some(serde_json::Value::Number(lt_i64.into())); 326 - } 327 - } 328 - 329 - // Parse lte condition 330 - if let Some(lte_val) = condition_obj.get("lte") { 331 - if let Ok(lte_str) = lte_val.string() { 332 - where_condition.lte = Some(serde_json::Value::String(lte_str.to_string())); 333 - } else if let Ok(lte_i64) = lte_val.i64() { 334 - where_condition.lte = Some(serde_json::Value::Number(lte_i64.into())); 335 - } 336 - } 337 - 338 - // Convert indexedAt to indexed_at for database column 339 - let db_field_name = if field_name == "indexedAt" { 340 - "indexed_at".to_string() 341 - } else { 342 - field_name.to_string() 343 - }; 344 - 345 - where_clause.conditions.insert(db_field_name, where_condition); 346 - } 347 - } 265 + let parsed_where = parse_where_clause(where_obj); 266 + // Merge parsed conditions with existing collection filter 267 + where_clause.conditions.extend(parsed_where.conditions); 268 + where_clause.or_conditions = parsed_where.or_conditions; 269 + where_clause.and = parsed_where.and; 270 + where_clause.or = parsed_where.or; 348 271 } 349 272 } 350 273 ··· 583 506 let mut where_clause = crate::models::WhereClause { 584 507 conditions: HashMap::new(), 585 508 or_conditions: None, 509 + and: None, 510 + or: None, 586 511 }; 587 512 588 513 // Always filter by collection ··· 602 527 // Parse where argument if provided 603 528 if let Some(where_val) = ctx.args.get("where") { 604 529 if let Ok(where_obj) = where_val.object() { 605 - for (field_name, condition_val) in where_obj.iter() { 606 - if let Ok(condition_obj) = condition_val.object() { 607 - let mut where_condition = crate::models::WhereCondition { 608 - gt: None, 609 - gte: None, 610 - lt: None, 611 - lte: None, 612 - eq: None, 613 - in_values: None, 614 - contains: None, 615 - }; 616 - 617 - // Parse eq condition 618 - if let Some(eq_val) = condition_obj.get("eq") { 619 - if let Ok(eq_str) = eq_val.string() { 620 - where_condition.eq = Some(serde_json::Value::String(eq_str.to_string())); 621 - } else if let Ok(eq_i64) = eq_val.i64() { 622 - where_condition.eq = Some(serde_json::Value::Number(eq_i64.into())); 623 - } 624 - } 625 - 626 - // Parse in condition 627 - if let Some(in_val) = condition_obj.get("in") { 628 - if let Ok(in_list) = in_val.list() { 629 - let mut values = Vec::new(); 630 - for item in in_list.iter() { 631 - if let Ok(s) = item.string() { 632 - values.push(serde_json::Value::String(s.to_string())); 633 - } else if let Ok(i) = item.i64() { 634 - values.push(serde_json::Value::Number(i.into())); 635 - } 636 - } 637 - where_condition.in_values = Some(values); 638 - } 639 - } 640 - 641 - // Parse contains condition 642 - if let Some(contains_val) = condition_obj.get("contains") { 643 - if let Ok(contains_str) = contains_val.string() { 644 - where_condition.contains = Some(contains_str.to_string()); 645 - } 646 - } 647 - 648 - // Parse gt condition 649 - if let Some(gt_val) = condition_obj.get("gt") { 650 - if let Ok(gt_str) = gt_val.string() { 651 - where_condition.gt = Some(serde_json::Value::String(gt_str.to_string())); 652 - } else if let Ok(gt_i64) = gt_val.i64() { 653 - where_condition.gt = Some(serde_json::Value::Number(gt_i64.into())); 654 - } 655 - } 656 - 657 - // Parse gte condition 658 - if let Some(gte_val) = condition_obj.get("gte") { 659 - if let Ok(gte_str) = gte_val.string() { 660 - where_condition.gte = Some(serde_json::Value::String(gte_str.to_string())); 661 - } else if let Ok(gte_i64) = gte_val.i64() { 662 - where_condition.gte = Some(serde_json::Value::Number(gte_i64.into())); 663 - } 664 - } 665 - 666 - // Parse lt condition 667 - if let Some(lt_val) = condition_obj.get("lt") { 668 - if let Ok(lt_str) = lt_val.string() { 669 - where_condition.lt = Some(serde_json::Value::String(lt_str.to_string())); 670 - } else if let Ok(lt_i64) = lt_val.i64() { 671 - where_condition.lt = Some(serde_json::Value::Number(lt_i64.into())); 672 - } 673 - } 674 - 675 - // Parse lte condition 676 - if let Some(lte_val) = condition_obj.get("lte") { 677 - if let Ok(lte_str) = lte_val.string() { 678 - where_condition.lte = Some(serde_json::Value::String(lte_str.to_string())); 679 - } else if let Ok(lte_i64) = lte_val.i64() { 680 - where_condition.lte = Some(serde_json::Value::Number(lte_i64.into())); 681 - } 682 - } 683 - 684 - // Convert indexedAt to indexed_at for database column 685 - let db_field_name = if field_name == "indexedAt" { 686 - "indexed_at".to_string() 687 - } else { 688 - field_name.to_string() 689 - }; 690 - 691 - where_clause.conditions.insert(db_field_name, where_condition); 692 - } 693 - } 530 + let parsed_where = parse_where_clause(where_obj); 531 + // Merge parsed conditions with existing collection filter 532 + where_clause.conditions.extend(parsed_where.conditions); 533 + where_clause.or_conditions = parsed_where.or_conditions; 534 + where_clause.and = parsed_where.and; 535 + where_clause.or = parsed_where.or; 694 536 } 695 537 } 696 538 ··· 985 827 let mut where_clause = crate::models::WhereClause { 986 828 conditions: std::collections::HashMap::new(), 987 829 or_conditions: None, 830 + and: None, 831 + or: None, 988 832 }; 989 833 where_clause.conditions.insert( 990 834 "did".to_string(), ··· 1322 1166 let mut where_clause = crate::models::WhereClause { 1323 1167 conditions: HashMap::new(), 1324 1168 or_conditions: None, 1169 + and: None, 1170 + or: None, 1325 1171 }; 1326 1172 where_clause.conditions.insert( 1327 1173 "collection".to_string(), ··· 1526 1372 let mut where_clause = crate::models::WhereClause { 1527 1373 conditions: HashMap::new(), 1528 1374 or_conditions: None, 1375 + and: None, 1376 + or: None, 1529 1377 }; 1530 1378 1531 1379 where_clause.conditions.insert( ··· 2520 2368 2521 2369 subscription 2522 2370 } 2371 + 2372 + /// Helper function to parse GraphQL where clause recursively 2373 + fn parse_where_clause(where_obj: async_graphql::dynamic::ObjectAccessor) -> crate::models::WhereClause { 2374 + let mut where_clause = crate::models::WhereClause { 2375 + conditions: HashMap::new(), 2376 + or_conditions: None, 2377 + and: None, 2378 + or: None, 2379 + }; 2380 + 2381 + for (field_name, condition_val) in where_obj.iter() { 2382 + let field_str = field_name.as_str(); 2383 + 2384 + // Handle nested AND array 2385 + if field_str == "and" { 2386 + if let Ok(and_list) = condition_val.list() { 2387 + let mut and_clauses = Vec::new(); 2388 + for item in and_list.iter() { 2389 + if let Ok(obj) = item.object() { 2390 + and_clauses.push(parse_where_clause(obj)); 2391 + } 2392 + } 2393 + if !and_clauses.is_empty() { 2394 + where_clause.and = Some(and_clauses); 2395 + } 2396 + } 2397 + continue; 2398 + } 2399 + 2400 + // Handle nested OR array 2401 + if field_str == "or" { 2402 + if let Ok(or_list) = condition_val.list() { 2403 + let mut or_clauses = Vec::new(); 2404 + for item in or_list.iter() { 2405 + if let Ok(obj) = item.object() { 2406 + or_clauses.push(parse_where_clause(obj)); 2407 + } 2408 + } 2409 + if !or_clauses.is_empty() { 2410 + where_clause.or = Some(or_clauses); 2411 + } 2412 + } 2413 + continue; 2414 + } 2415 + 2416 + // Handle regular field conditions 2417 + if let Ok(condition_obj) = condition_val.object() { 2418 + let mut where_condition = crate::models::WhereCondition { 2419 + eq: None, 2420 + in_values: None, 2421 + contains: None, 2422 + gt: None, 2423 + gte: None, 2424 + lt: None, 2425 + lte: None, 2426 + }; 2427 + 2428 + // Parse eq condition 2429 + if let Some(eq_val) = condition_obj.get("eq") { 2430 + if let Ok(eq_str) = eq_val.string() { 2431 + where_condition.eq = Some(serde_json::Value::String(eq_str.to_string())); 2432 + } else if let Ok(eq_i64) = eq_val.i64() { 2433 + where_condition.eq = Some(serde_json::Value::Number(eq_i64.into())); 2434 + } 2435 + } 2436 + 2437 + // Parse in condition 2438 + if let Some(in_val) = condition_obj.get("in") { 2439 + if let Ok(in_list) = in_val.list() { 2440 + let mut values = Vec::new(); 2441 + for item in in_list.iter() { 2442 + if let Ok(s) = item.string() { 2443 + values.push(serde_json::Value::String(s.to_string())); 2444 + } else if let Ok(i) = item.i64() { 2445 + values.push(serde_json::Value::Number(i.into())); 2446 + } 2447 + } 2448 + where_condition.in_values = Some(values); 2449 + } 2450 + } 2451 + 2452 + // Parse contains condition 2453 + if let Some(contains_val) = condition_obj.get("contains") { 2454 + if let Ok(contains_str) = contains_val.string() { 2455 + where_condition.contains = Some(contains_str.to_string()); 2456 + } 2457 + } 2458 + 2459 + // Parse gt condition 2460 + if let Some(gt_val) = condition_obj.get("gt") { 2461 + if let Ok(gt_str) = gt_val.string() { 2462 + where_condition.gt = Some(serde_json::Value::String(gt_str.to_string())); 2463 + } else if let Ok(gt_i64) = gt_val.i64() { 2464 + where_condition.gt = Some(serde_json::Value::Number(gt_i64.into())); 2465 + } 2466 + } 2467 + 2468 + // Parse gte condition 2469 + if let Some(gte_val) = condition_obj.get("gte") { 2470 + if let Ok(gte_str) = gte_val.string() { 2471 + where_condition.gte = Some(serde_json::Value::String(gte_str.to_string())); 2472 + } else if let Ok(gte_i64) = gte_val.i64() { 2473 + where_condition.gte = Some(serde_json::Value::Number(gte_i64.into())); 2474 + } 2475 + } 2476 + 2477 + // Parse lt condition 2478 + if let Some(lt_val) = condition_obj.get("lt") { 2479 + if let Ok(lt_str) = lt_val.string() { 2480 + where_condition.lt = Some(serde_json::Value::String(lt_str.to_string())); 2481 + } else if let Ok(lt_i64) = lt_val.i64() { 2482 + where_condition.lt = Some(serde_json::Value::Number(lt_i64.into())); 2483 + } 2484 + } 2485 + 2486 + // Parse lte condition 2487 + if let Some(lte_val) = condition_obj.get("lte") { 2488 + if let Ok(lte_str) = lte_val.string() { 2489 + where_condition.lte = Some(serde_json::Value::String(lte_str.to_string())); 2490 + } else if let Ok(lte_i64) = lte_val.i64() { 2491 + where_condition.lte = Some(serde_json::Value::Number(lte_i64.into())); 2492 + } 2493 + } 2494 + 2495 + // Convert indexedAt to indexed_at for database column 2496 + let db_field_name = if field_str == "indexedAt" { 2497 + "indexed_at".to_string() 2498 + } else { 2499 + field_str.to_string() 2500 + }; 2501 + 2502 + where_clause.conditions.insert(db_field_name, where_condition); 2503 + } 2504 + } 2505 + 2506 + where_clause 2507 + }
+138 -12
docs/graphql-api.md
··· 1 1 # GraphQL API 2 2 3 - Slices provides a powerful GraphQL API for querying indexed AT Protocol data. The API automatically generates schema from your lexicons and provides efficient querying with relationship traversal. 3 + Slices provides a powerful GraphQL API for querying indexed AT Protocol data. 4 + The API automatically generates schema from your lexicons and provides efficient 5 + querying with relationship traversal. 4 6 5 7 ## Accessing the API 6 8 ··· 22 24 23 25 The GraphQL schema is automatically generated from your slice's lexicons: 24 26 25 - - **Types**: One GraphQL type per collection (e.g., `social.grain.gallery` → `SocialGrainGallery`) 27 + - **Types**: One GraphQL type per collection (e.g., `social.grain.gallery` → 28 + `SocialGrainGallery`) 26 29 - **Queries**: Collection queries with filtering, sorting, and pagination 27 30 - **Mutations**: Create, update, delete operations per collection 28 31 - **Subscriptions**: Real-time updates for record changes ··· 48 51 49 52 ### Filtering 50 53 51 - Use `where` clauses with typed filter conditions. Each collection has its own `{Collection}WhereInput` type with appropriate filters for each field. 54 + Use `where` clauses with typed filter conditions. Each collection has its own 55 + `{Collection}WhereInput` type with appropriate filters for each field. 52 56 53 57 ```graphql 54 58 query { ··· 71 75 The API provides three filter types based on field data types: 72 76 73 77 **StringFilter** - For string fields: 78 + 74 79 - `eq`: Exact match 75 80 - `in`: Match any value in array 76 81 - `contains`: Substring match (case-insensitive) ··· 80 85 - `lte`: Less than or equal to 81 86 82 87 **IntFilter** - For integer fields: 88 + 83 89 - `eq`: Exact match 84 90 - `in`: Match any value in array 85 91 - `gt`: Greater than ··· 88 94 - `lte`: Less than or equal to 89 95 90 96 **DateTimeFilter** - For datetime fields: 97 + 91 98 - `eq`: Exact match 92 99 - `gt`: After datetime 93 100 - `gte`: At or after datetime ··· 141 148 } 142 149 ``` 143 150 151 + #### Nested AND/OR Queries 152 + 153 + Build complex filter logic with arbitrarily nestable `and` and `or` arrays: 154 + 155 + **Simple OR - Match any condition:** 156 + 157 + ```graphql 158 + query { 159 + networkSlicesSlices( 160 + where: { 161 + or: [ 162 + { name: { contains: "grain" } } 163 + { name: { contains: "teal" } } 164 + ] 165 + } 166 + ) { 167 + edges { 168 + node { 169 + name 170 + } 171 + } 172 + } 173 + } 174 + ``` 175 + 176 + **Simple AND - Match all conditions:** 177 + 178 + ```graphql 179 + query { 180 + networkSlicesSlices( 181 + where: { 182 + and: [ 183 + { name: { contains: "grain" } } 184 + { name: { contains: "teal" } } 185 + ] 186 + } 187 + ) { 188 + edges { 189 + node { 190 + name 191 + } 192 + } 193 + } 194 + } 195 + ``` 196 + 197 + **Complex Nested Logic:** 198 + 199 + ```graphql 200 + query { 201 + appBskyFeedPost( 202 + where: { 203 + and: [ 204 + { 205 + or: [ 206 + { text: { contains: "music" } } 207 + { text: { contains: "song" } } 208 + ] 209 + } 210 + { 211 + and: [ 212 + { uri: { contains: "app.bsky" } } 213 + { uri: { contains: "post" } } 214 + ] 215 + } 216 + { createdAt: { gte: "2025-01-01T00:00:00Z" } } 217 + ] 218 + } 219 + ) { 220 + edges { 221 + node { 222 + uri 223 + text 224 + createdAt 225 + } 226 + } 227 + } 228 + } 229 + ``` 230 + 231 + This example finds posts where: 232 + 233 + - (text contains "music" OR text contains "song") AND 234 + - (uri contains "app.bsky" AND uri contains "post") AND 235 + - createdAt is after 2025-01-01 236 + 237 + **Key Features:** 238 + 239 + - Unlimited nesting depth - `and`/`or` can be nested arbitrarily 240 + - Mix with field filters - combine nested logic with regular field conditions 241 + - Type-safe - Each collection's `WhereInput` supports `and` and `or` arrays 242 + - Available in queries and aggregations 243 + 144 244 ### Pagination 145 245 146 246 Relay-style cursor pagination: ··· 165 265 166 266 ### Sorting 167 267 168 - Each collection has its own typed `{Collection}SortFieldInput` for type-safe sorting: 268 + Each collection has its own typed `{Collection}SortFieldInput` for type-safe 269 + sorting: 169 270 170 271 ```graphql 171 272 query { ··· 186 287 ``` 187 288 188 289 **Multi-field sorting:** 290 + 189 291 ```graphql 190 292 query { 191 293 socialGrainGalleries( ··· 206 308 } 207 309 ``` 208 310 209 - The `field` enum values are collection-specific (e.g., `SocialGrainGallerySortFieldInput`). Use GraphQL introspection or the playground to see available fields for each collection. 311 + The `field` enum values are collection-specific (e.g., 312 + `SocialGrainGallerySortFieldInput`). Use GraphQL introspection or the playground 313 + to see available fields for each collection. 210 314 211 315 ## Aggregations 212 316 213 - Aggregation queries allow you to group records and perform calculations. Each collection has a corresponding `{Collection}Aggregated` query. 317 + Aggregation queries allow you to group records and perform calculations. Each 318 + collection has a corresponding `{Collection}Aggregated` query. 214 319 215 320 ### Basic Aggregation 216 321 ··· 274 379 275 380 ### Aggregation Features 276 381 277 - - **Typed GroupBy**: Each collection has a `{Collection}GroupByField` enum for type-safe field selection 382 + - **Typed GroupBy**: Each collection has a `{Collection}GroupByField` enum for 383 + type-safe field selection 278 384 - **Typed Filters**: Use the same `{Collection}WhereInput` as regular queries 279 385 - **Sorting**: Order by `count` (ascending or descending) or any grouped field 280 386 - **Pagination**: Use `limit` to control result count 281 387 - **Multiple Fields**: Group by any combination of fields from your lexicon 282 - - **Date Truncation**: Group by time intervals (second, minute, hour, day, week, month, quarter, year) 388 + - **Date Truncation**: Group by time intervals (second, minute, hour, day, week, 389 + month, quarter, year) 283 390 284 391 ### Date Truncation 285 392 ··· 301 408 ``` 302 409 303 410 **Supported Intervals:** 411 + 304 412 - `second` - Group by second 305 413 - `minute` - Group by minute 306 414 - `hour` - Group by hour ··· 311 419 - `year` - Group by year 312 420 313 421 **Combining with Regular Fields:** 422 + 314 423 ```graphql 315 424 query TrackPlaysByDay { 316 425 fmTealAlphaFeedPlaysAggregated( ··· 329 438 ``` 330 439 331 440 **How it Works:** 441 + 332 442 - Uses PostgreSQL's `date_trunc()` function for efficient time bucketing 333 443 - Automatically handles timestamp casting for JSON fields 334 444 - Returns truncated timestamps (e.g., `2025-01-15 00:00:00` for day interval) ··· 337 447 ### Use Cases 338 448 339 449 **Daily/Weekly/Monthly Reports**: 450 + 340 451 ```graphql 341 452 query WeeklyPlays { 342 453 fmTealAlphaFeedPlaysAggregated( ··· 357 468 ``` 358 469 359 470 **Trend Analysis**: 471 + 360 472 ```graphql 361 473 query TrendingArtists { 362 474 fmTealAlphaFeedPlaysAggregated( ··· 375 487 376 488 ## Relationships 377 489 378 - The GraphQL API automatically generates relationship fields based on your lexicon's `at-uri` fields. 490 + The GraphQL API automatically generates relationship fields based on your 491 + lexicon's `at-uri` fields. 379 492 380 493 ### Forward Joins (References) 381 494 382 - When a record has an `at-uri` field, you get a **singular** field that resolves to the referenced record. 495 + When a record has an `at-uri` field, you get a **singular** field that resolves 496 + to the referenced record. 383 497 384 498 **Lexicon Schema (social.grain.gallery.item):** 499 + 385 500 ```json 386 501 { 387 502 "lexicon": 1, ··· 412 527 ``` 413 528 414 529 **Generated GraphQL Type:** 530 + 415 531 ```graphql 416 532 type SocialGrainGalleryItem { 417 533 uri: String! ··· 427 543 ``` 428 544 429 545 **Example Query:** 546 + 430 547 ```graphql 431 548 query { 432 549 socialGrainGalleryItems(limit: 5) { ··· 448 565 449 566 ### Reverse Joins (Backlinks) 450 567 451 - When other records reference this record via `at-uri` fields, you get **plural** fields that find all records pointing here. 568 + When other records reference this record via `at-uri` fields, you get **plural** 569 + fields that find all records pointing here. 452 570 453 571 **Lexicon Schema (social.grain.favorite):** 572 + 454 573 ```json 455 574 { 456 575 "lexicon": 1, ··· 476 595 ``` 477 596 478 597 **Generated GraphQL Types:** 598 + 479 599 ```graphql 480 600 type SocialGrainFavorite { 481 601 uri: String! ··· 499 619 ``` 500 620 501 621 **Example Query:** 622 + 502 623 ```graphql 503 624 query { 504 625 socialGrainGalleries(where: { ··· 582 703 The GraphQL API uses DataLoader for efficient batching: 583 704 584 705 ### CollectionDidLoader 706 + 585 707 - Batches queries by `(slice_uri, collection, did)` 586 708 - Used for forward joins where the DID is known 587 709 - Eliminates N+1 queries when following references 588 710 589 711 ### CollectionUriLoader 712 + 590 713 - Batches queries by `(slice_uri, collection, parent_uri, reference_field)` 591 714 - Used for reverse joins based on at-uri fields 592 715 - Efficiently loads all records that reference a parent URI 593 716 - Supports multiple at-uri fields (tries each until match found) 594 717 595 718 Example: Loading 100 galleries with favorites 719 + 596 720 - **Without DataLoader**: 1 + 100 queries (N+1 problem) 597 721 - **With DataLoader**: 1 + 1 query (batched) 598 722 ··· 696 820 697 821 ## Subscriptions 698 822 699 - Real-time updates for record changes. Each collection has three subscription fields: 823 + Real-time updates for record changes. Each collection has three subscription 824 + fields: 700 825 701 826 ### Created Records 702 827 ··· 757 882 ## Error Handling 758 883 759 884 GraphQL errors include: 885 + 760 886 - `"Query is nested too deep"` - Exceeds depth limit (50) 761 887 - `"Query is too complex"` - Exceeds complexity limit (5000) 762 888 - `"Schema error"` - Invalid slice or missing lexicons