Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

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

add sortBy and get pagination arguments passing through to the db query

+1826 -48
+385
README.md
··· 1 + # QuickSlice 2 + 3 + GraphQL API for AT Protocol records with automatic schema generation from Lexicon definitions. 4 + 5 + ## Warning: Work in Progress 6 + 7 + This project is currently under active development. APIs are subject to change without notice. Do not use in production. 8 + 9 + ## Overview 10 + 11 + QuickSlice provides a GraphQL interface to query AT Protocol records stored in a SQLite database. It automatically generates GraphQL schemas from Lexicon definitions and implements the Relay Cursor Connections specification for pagination. 12 + 13 + ## Features 14 + 15 + - Automatic GraphQL schema generation from AT Protocol Lexicons 16 + - Relay-compliant cursor-based pagination 17 + - Sorting support with custom sort fields per record type 18 + - Database-backed record storage and querying 19 + - Real-time ingestion via Jetstream 20 + 21 + ## GraphQL API 22 + 23 + ### Query Structure 24 + 25 + All record types are exposed as connection fields on the root Query type. Each connection supports: 26 + 27 + - Forward pagination (first/after) 28 + - Backward pagination (last/before) 29 + - Custom sorting (sortBy) 30 + 31 + ### Example Query 32 + 33 + ```graphql 34 + query { 35 + xyzStatusphereStatus( 36 + first: 10 37 + after: "cursor_value" 38 + sortBy: [ 39 + { field: "createdAt", direction: DESC } 40 + ] 41 + ) { 42 + edges { 43 + node { 44 + uri 45 + cid 46 + did 47 + collection 48 + indexedAt 49 + status 50 + createdAt 51 + } 52 + cursor 53 + } 54 + pageInfo { 55 + hasNextPage 56 + hasPreviousPage 57 + startCursor 58 + endCursor 59 + } 60 + } 61 + } 62 + ``` 63 + 64 + ### Connection Type 65 + 66 + All record queries return a Connection type following the Relay specification: 67 + 68 + ```graphql 69 + type XyzStatusphereStatusConnection { 70 + edges: [XyzStatusphereStatusEdge!]! 71 + pageInfo: PageInfo! 72 + } 73 + 74 + type XyzStatusphereStatusEdge { 75 + node: XyzStatusphereStatus! 76 + cursor: String! 77 + } 78 + 79 + type PageInfo { 80 + hasNextPage: Boolean! 81 + hasPreviousPage: Boolean! 82 + startCursor: String 83 + endCursor: String 84 + } 85 + ``` 86 + 87 + ### Record Type 88 + 89 + Each Lexicon generates a GraphQL type with standard AT Protocol fields plus custom fields from the Lexicon: 90 + 91 + ```graphql 92 + type XyzStatusphereStatus { 93 + # Standard AT Protocol fields 94 + uri: String! 95 + cid: String! 96 + did: String! 97 + collection: String! 98 + indexedAt: String! 99 + 100 + # Custom fields from Lexicon 101 + status: String 102 + createdAt: String 103 + } 104 + ``` 105 + 106 + ### Pagination Arguments 107 + 108 + All connection fields support these arguments: 109 + 110 + ```graphql 111 + type Query { 112 + xyzStatusphereStatus( 113 + # Forward pagination 114 + first: Int 115 + after: String 116 + 117 + # Backward pagination 118 + last: Int 119 + before: String 120 + 121 + # Sorting 122 + sortBy: [SortFieldInput!] 123 + ): XyzStatusphereStatusConnection! 124 + } 125 + ``` 126 + 127 + ### Sorting 128 + 129 + Each record type has a custom sort field enum with all available sortable fields: 130 + 131 + ```graphql 132 + enum XyzStatusphereStatusSortField { 133 + uri 134 + cid 135 + did 136 + collection 137 + indexedAt 138 + status 139 + createdAt 140 + } 141 + 142 + input SortFieldInput { 143 + field: XyzStatusphereStatusSortField! 144 + direction: SortDirection! 145 + } 146 + 147 + enum SortDirection { 148 + ASC 149 + DESC 150 + } 151 + ``` 152 + 153 + #### Sort Behavior 154 + 155 + - Default sort: `indexed_at DESC` (most recent first) 156 + - Multiple sort fields supported 157 + - NULL and invalid values always appear last (NULLS LAST) 158 + - Date fields validated using SQLite datetime() function 159 + 160 + #### Sort Examples 161 + 162 + Sort by creation date, newest first: 163 + ```graphql 164 + { 165 + xyzStatusphereStatus(sortBy: [{ field: "createdAt", direction: DESC }]) { 166 + edges { 167 + node { status createdAt } 168 + } 169 + } 170 + } 171 + ``` 172 + 173 + Sort by multiple fields: 174 + ```graphql 175 + { 176 + xyzStatusphereStatus( 177 + sortBy: [ 178 + { field: "did", direction: ASC } 179 + { field: "createdAt", direction: DESC } 180 + ] 181 + ) { 182 + edges { 183 + node { did status createdAt } 184 + } 185 + } 186 + } 187 + ``` 188 + 189 + ### Pagination Examples 190 + 191 + #### Forward Pagination 192 + 193 + Get first 10 records: 194 + ```graphql 195 + { 196 + xyzStatusphereStatus(first: 10) { 197 + edges { 198 + node { status } 199 + cursor 200 + } 201 + pageInfo { 202 + hasNextPage 203 + endCursor 204 + } 205 + } 206 + } 207 + ``` 208 + 209 + Get next page: 210 + ```graphql 211 + { 212 + xyzStatusphereStatus(first: 10, after: "previous_end_cursor") { 213 + edges { 214 + node { status } 215 + cursor 216 + } 217 + pageInfo { 218 + hasNextPage 219 + endCursor 220 + } 221 + } 222 + } 223 + ``` 224 + 225 + #### Backward Pagination 226 + 227 + Get last 10 records: 228 + ```graphql 229 + { 230 + xyzStatusphereStatus(last: 10) { 231 + edges { 232 + node { status } 233 + cursor 234 + } 235 + pageInfo { 236 + hasPreviousPage 237 + startCursor 238 + } 239 + } 240 + } 241 + ``` 242 + 243 + Get previous page: 244 + ```graphql 245 + { 246 + xyzStatusphereStatus(last: 10, before: "previous_start_cursor") { 247 + edges { 248 + node { status } 249 + cursor 250 + } 251 + pageInfo { 252 + hasPreviousPage 253 + startCursor 254 + } 255 + } 256 + } 257 + ``` 258 + 259 + ### Introspection 260 + 261 + The API supports full GraphQL introspection for schema discovery: 262 + 263 + ```graphql 264 + { 265 + __schema { 266 + queryType { 267 + fields { 268 + name 269 + type { 270 + name 271 + kind 272 + } 273 + } 274 + } 275 + } 276 + } 277 + ``` 278 + 279 + Query available sort fields for a record type: 280 + ```graphql 281 + { 282 + __type(name: "XyzStatusphereStatusSortField") { 283 + enumValues { 284 + name 285 + description 286 + } 287 + } 288 + } 289 + ``` 290 + 291 + ## Architecture 292 + 293 + ### Packages 294 + 295 + - `graphql/` - Core GraphQL implementation with Relay Connection support 296 + - `lexicon_graphql/` - Automatic schema generation from Lexicons 297 + - `server/` - Database layer and record storage 298 + 299 + ### Database Schema 300 + 301 + Records are stored in SQLite with the following structure: 302 + 303 + ```sql 304 + CREATE TABLE record ( 305 + uri TEXT PRIMARY KEY, 306 + cid TEXT NOT NULL, 307 + did TEXT NOT NULL, 308 + collection TEXT NOT NULL, 309 + json TEXT NOT NULL, 310 + indexed_at TEXT NOT NULL 311 + ); 312 + ``` 313 + 314 + The `json` field contains the full record value as a JSON string. 315 + 316 + ### Schema Generation 317 + 318 + 1. Load Lexicon definitions from JSON files 319 + 2. Parse Lexicon to extract record types and properties 320 + 3. Generate GraphQL types with fields for each property 321 + 4. Create connection types for pagination 322 + 5. Build custom sort field enums per record type 323 + 6. Register resolvers that query the database 324 + 325 + ## Development 326 + 327 + ### Running Tests 328 + 329 + ```sh 330 + # Test GraphQL package 331 + cd graphql 332 + gleam test 333 + 334 + # Test Lexicon GraphQL package 335 + cd lexicon_graphql 336 + gleam test 337 + 338 + # Test server package 339 + cd server 340 + gleam test 341 + ``` 342 + 343 + ### Building 344 + 345 + ```sh 346 + gleam build 347 + ``` 348 + 349 + ### Running the Server 350 + 351 + ```sh 352 + cd server 353 + gleam run 354 + ``` 355 + 356 + ## Implementation Details 357 + 358 + ### Cursor Format 359 + 360 + Cursors are opaque base64-encoded strings containing: 361 + - Sort field values 362 + - Primary key (uri) 363 + 364 + Clients should treat cursors as opaque and not attempt to decode or construct them. 365 + 366 + ### NULL Handling 367 + 368 + - NULL values always sorted last regardless of sort direction 369 + - Invalid date strings (e.g., "wowzers", "0001-01-01T00:00:00Z") treated as NULL 370 + - SQLite datetime() function used to validate date fields 371 + 372 + ### Type Safety 373 + 374 + - Sort fields validated at query time against available fields 375 + - Each record type has its own sort field enum 376 + - Prevents typos and invalid field references 377 + 378 + ## Future Enhancements 379 + 380 + - Mutations for creating/updating records 381 + - Subscriptions for real-time updates 382 + - Full-text search support 383 + - Filtering by field values 384 + - Aggregation queries (count, sum, etc.) 385 + - Support for more Lexicon features (unions, references)
+48 -4
graphql/src/graphql/executor.gleam
··· 280 280 } 281 281 } 282 282 } 283 - parser.Field(name, _alias, _arguments, nested_selections) -> { 283 + parser.Field(name, _alias, arguments, nested_selections) -> { 284 + // Convert arguments to dict 285 + let args_dict = arguments_to_dict(arguments) 286 + 284 287 // Handle introspection meta-fields 285 288 case name { 286 289 "__typename" -> { ··· 327 330 // Get the field's type for nested selections 328 331 let field_type_def = schema.field_type(field) 329 332 333 + // Create context with arguments 334 + let field_ctx = schema.Context(ctx.data, args_dict) 335 + 330 336 // Resolve the field 331 - case schema.resolve_field(field, ctx) { 337 + case schema.resolve_field(field, field_ctx) { 332 338 Error(err) -> { 333 339 let error = GraphQLError(err, [name, ..path]) 334 340 Ok(#(name, value.Null, [error])) ··· 343 349 value.Object(_) -> { 344 350 // Execute nested selections using the field's type, not parent type 345 351 // Create new context with this object's data 346 - let object_ctx = schema.Context(option.Some(field_value)) 352 + let object_ctx = schema.context(option.Some(field_value)) 347 353 let selection_set = 348 354 parser.SelectionSet(nested_selections) 349 355 case ··· 386 392 let results = 387 393 list.map(items, fn(item) { 388 394 // Create context with this item's data 389 - let item_ctx = schema.Context(option.Some(item)) 395 + let item_ctx = schema.context(option.Some(item)) 390 396 execute_selection_set( 391 397 selection_set, 392 398 inner_type, ··· 626 632 } 627 633 } 628 634 } 635 + 636 + /// Convert parser ArgumentValue to value.Value 637 + fn argument_value_to_value(arg_value: parser.ArgumentValue) -> value.Value { 638 + case arg_value { 639 + parser.IntValue(s) -> value.String(s) 640 + parser.FloatValue(s) -> value.String(s) 641 + parser.StringValue(s) -> value.String(s) 642 + parser.BooleanValue(b) -> value.Boolean(b) 643 + parser.NullValue -> value.Null 644 + parser.EnumValue(s) -> value.String(s) 645 + parser.ListValue(items) -> 646 + value.List(list.map(items, argument_value_to_value)) 647 + parser.ObjectValue(fields) -> 648 + value.Object( 649 + list.map(fields, fn(pair) { 650 + let #(name, val) = pair 651 + #(name, argument_value_to_value(val)) 652 + }), 653 + ) 654 + parser.VariableValue(_name) -> 655 + // TODO: Variables support - for now return null 656 + value.Null 657 + } 658 + } 659 + 660 + /// Convert list of Arguments to a Dict of values 661 + fn arguments_to_dict( 662 + arguments: List(parser.Argument), 663 + ) -> Dict(String, value.Value) { 664 + list.fold(arguments, dict.new(), fn(acc, arg) { 665 + case arg { 666 + parser.Argument(name, arg_value) -> { 667 + let value = argument_value_to_value(arg_value) 668 + dict.insert(acc, name, value) 669 + } 670 + } 671 + }) 672 + }
+69 -7
graphql/src/graphql/introspection.gleam
··· 80 80 let fields = schema.get_fields(t) 81 81 list.fold(fields, new_acc, fn(acc2, field) { 82 82 let field_type = schema.field_type(field) 83 - collect_types_from_type_deep(field_type, acc2) 83 + let acc3 = collect_types_from_type_deep(field_type, acc2) 84 + 85 + // Also collect types from field arguments 86 + let arguments = schema.field_arguments(field) 87 + list.fold(arguments, acc3, fn(acc4, arg) { 88 + let arg_type = schema.argument_type(arg) 89 + collect_types_from_type_deep(arg_type, acc4) 90 + }) 84 91 }) 85 92 } 86 93 False -> { 87 - // Check if it's a wrapping type (List or NonNull) 88 - case schema.inner_type(t) { 89 - option.Some(inner) -> collect_types_from_type_deep(inner, new_acc) 90 - option.None -> new_acc 94 + // Check if it's an InputObjectType 95 + let input_fields = schema.get_input_fields(t) 96 + case list.is_empty(input_fields) { 97 + False -> { 98 + // This is an InputObjectType, collect types from its fields 99 + list.fold(input_fields, new_acc, fn(acc2, input_field) { 100 + let field_type = schema.input_field_type(input_field) 101 + collect_types_from_type_deep(field_type, acc2) 102 + }) 103 + } 104 + True -> { 105 + // Check if it's a wrapping type (List or NonNull) 106 + case schema.inner_type(t) { 107 + option.Some(inner) -> collect_types_from_type_deep(inner, new_acc) 108 + option.None -> new_acc 109 + } 110 + } 91 111 } 92 112 } 93 113 } ··· 124 144 _ -> value.Null 125 145 } 126 146 147 + // Determine inputFields for INPUT_OBJECT types 148 + let input_fields = case kind { 149 + "INPUT_OBJECT" -> value.List(get_input_fields_for_type(t)) 150 + _ -> value.Null 151 + } 152 + 153 + // Determine enumValues for ENUM types 154 + let enum_values = case kind { 155 + "ENUM" -> value.List(get_enum_values_for_type(t)) 156 + _ -> value.Null 157 + } 158 + 127 159 // Handle wrapping types (LIST/NON_NULL) differently 128 160 let name = case kind { 129 161 "LIST" -> value.Null ··· 138 170 #("fields", fields), 139 171 #("interfaces", value.List([])), 140 172 #("possibleTypes", value.Null), 141 - #("enumValues", value.Null), 142 - #("inputFields", value.Null), 173 + #("enumValues", enum_values), 174 + #("inputFields", input_fields), 143 175 #("ofType", of_type), 144 176 ]) 145 177 } ··· 157 189 #("description", value.String(schema.field_description(field))), 158 190 #("args", value.List(list.map(args, argument_introspection))), 159 191 #("type", type_ref(field_type_val)), 192 + #("isDeprecated", value.Boolean(False)), 193 + #("deprecationReason", value.Null), 194 + ]) 195 + }) 196 + } 197 + 198 + /// Get input fields for a type (if it's an input object type) 199 + fn get_input_fields_for_type(t: schema.Type) -> List(value.Value) { 200 + let input_fields = schema.get_input_fields(t) 201 + 202 + list.map(input_fields, fn(input_field) { 203 + let field_type_val = schema.input_field_type(input_field) 204 + 205 + value.Object([ 206 + #("name", value.String(schema.input_field_name(input_field))), 207 + #("description", value.String(schema.input_field_description(input_field))), 208 + #("type", type_ref(field_type_val)), 209 + #("defaultValue", value.Null), 210 + ]) 211 + }) 212 + } 213 + 214 + /// Get enum values for a type (if it's an enum type) 215 + fn get_enum_values_for_type(t: schema.Type) -> List(value.Value) { 216 + let enum_values = schema.get_enum_values(t) 217 + 218 + list.map(enum_values, fn(enum_value) { 219 + value.Object([ 220 + #("name", value.String(schema.enum_value_name(enum_value))), 221 + #("description", value.String(schema.enum_value_description(enum_value))), 160 222 #("isDeprecated", value.Boolean(False)), 161 223 #("deprecationReason", value.Null), 162 224 ])
+56
graphql/src/graphql/parser.gleam
··· 58 58 StringValue(String) 59 59 BooleanValue(Bool) 60 60 NullValue 61 + EnumValue(String) 61 62 ListValue(List(ArgumentValue)) 62 63 ObjectValue(List(#(String, ArgumentValue))) 63 64 VariableValue(String) ··· 329 330 [lexer.Name("true"), ..rest] -> Ok(#(BooleanValue(True), rest)) 330 331 [lexer.Name("false"), ..rest] -> Ok(#(BooleanValue(False), rest)) 331 332 [lexer.Name("null"), ..rest] -> Ok(#(NullValue, rest)) 333 + [lexer.Name(name), ..rest] -> Ok(#(EnumValue(name), rest)) 332 334 [lexer.Dollar, lexer.Name(name), ..rest] -> Ok(#(VariableValue(name), rest)) 335 + [lexer.BracketOpen, ..rest] -> parse_list_value(rest) 336 + [lexer.BraceOpen, ..rest] -> parse_object_value(rest) 333 337 [] -> Error(UnexpectedEndOfInput("Expected value")) 334 338 [token, ..] -> Error(UnexpectedToken(token, "Expected value")) 335 339 } 336 340 } 341 + 342 + /// Parse list value: [value, value, ...] 343 + fn parse_list_value( 344 + tokens: List(lexer.Token), 345 + ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 346 + case tokens { 347 + [lexer.BracketClose, ..rest] -> Ok(#(ListValue([]), rest)) 348 + _ -> parse_list_value_items(tokens, []) 349 + } 350 + } 351 + 352 + /// Parse list value items recursively 353 + fn parse_list_value_items( 354 + tokens: List(lexer.Token), 355 + acc: List(ArgumentValue), 356 + ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 357 + case tokens { 358 + [lexer.BracketClose, ..rest] -> Ok(#(ListValue(list.reverse(acc)), rest)) 359 + [lexer.Comma, ..rest] -> parse_list_value_items(rest, acc) 360 + _ -> { 361 + use #(value, rest) <- result.try(parse_argument_value(tokens)) 362 + parse_list_value_items(rest, [value, ..acc]) 363 + } 364 + } 365 + } 366 + 367 + /// Parse object value: {field: value, field: value, ...} 368 + fn parse_object_value( 369 + tokens: List(lexer.Token), 370 + ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 371 + case tokens { 372 + [lexer.BraceClose, ..rest] -> Ok(#(ObjectValue([]), rest)) 373 + _ -> parse_object_value_fields(tokens, []) 374 + } 375 + } 376 + 377 + /// Parse object value fields recursively 378 + fn parse_object_value_fields( 379 + tokens: List(lexer.Token), 380 + acc: List(#(String, ArgumentValue)), 381 + ) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) { 382 + case tokens { 383 + [lexer.BraceClose, ..rest] -> Ok(#(ObjectValue(list.reverse(acc)), rest)) 384 + [lexer.Comma, ..rest] -> parse_object_value_fields(rest, acc) 385 + [lexer.Name(field_name), lexer.Colon, ..rest] -> { 386 + use #(value, rest2) <- result.try(parse_argument_value(rest)) 387 + parse_object_value_fields(rest2, [#(field_name, value), ..acc]) 388 + } 389 + [] -> Error(UnexpectedEndOfInput("Expected field name or }")) 390 + [token, ..] -> Error(UnexpectedToken(token, "Expected field name or }")) 391 + } 392 + }
+94 -1
graphql/src/graphql/schema.gleam
··· 2 2 /// 3 3 /// Per GraphQL spec Section 3 - Type System 4 4 /// Defines the type system including scalars, objects, enums, etc. 5 + import gleam/dict.{type Dict} 5 6 import gleam/list 6 7 import gleam/option.{type Option, None} 7 8 import graphql/value 8 9 9 10 /// Resolver context - will contain request context, data loaders, etc. 10 11 pub type Context { 11 - Context(data: Option(value.Value)) 12 + Context(data: Option(value.Value), arguments: Dict(String, value.Value)) 13 + } 14 + 15 + /// Helper to create a context without arguments 16 + pub fn context(data: Option(value.Value)) -> Context { 17 + Context(data, dict.new()) 18 + } 19 + 20 + /// Helper to get an argument value from context 21 + pub fn get_argument(ctx: Context, name: String) -> Option(value.Value) { 22 + dict.get(ctx.arguments, name) |> option.from_result 12 23 } 13 24 14 25 /// Field resolver function type ··· 19 30 pub opaque type Type { 20 31 ScalarType(name: String) 21 32 ObjectType(name: String, description: String, fields: List(Field)) 33 + InputObjectType(name: String, description: String, fields: List(InputField)) 22 34 EnumType(name: String, description: String, values: List(EnumValue)) 23 35 ListType(inner_type: Type) 24 36 NonNullType(inner_type: Type) ··· 40 52 Argument( 41 53 name: String, 42 54 arg_type: Type, 55 + description: String, 56 + default_value: Option(value.Value), 57 + ) 58 + } 59 + 60 + /// GraphQL Input Field (for InputObject types) 61 + pub opaque type InputField { 62 + InputField( 63 + name: String, 64 + field_type: Type, 43 65 description: String, 44 66 default_value: Option(value.Value), 45 67 ) ··· 93 115 EnumType(name, description, values) 94 116 } 95 117 118 + pub fn input_object_type( 119 + name: String, 120 + description: String, 121 + fields: List(InputField), 122 + ) -> Type { 123 + InputObjectType(name, description, fields) 124 + } 125 + 96 126 pub fn list_type(inner_type: Type) -> Type { 97 127 ListType(inner_type) 98 128 } ··· 131 161 Argument(name, arg_type, description, default_value) 132 162 } 133 163 164 + // Input field constructor 165 + pub fn input_field( 166 + name: String, 167 + field_type: Type, 168 + description: String, 169 + default_value: Option(value.Value), 170 + ) -> InputField { 171 + InputField(name, field_type, description, default_value) 172 + } 173 + 134 174 // Enum value constructor 135 175 pub fn enum_value(name: String, description: String) -> EnumValue { 136 176 EnumValue(name, description) ··· 146 186 case t { 147 187 ScalarType(name) -> name 148 188 ObjectType(name, _, _) -> name 189 + InputObjectType(name, _, _) -> name 149 190 EnumType(name, _, _) -> name 150 191 ListType(inner) -> "[" <> type_name(inner) <> "]" 151 192 NonNullType(inner) -> type_name(inner) <> "!" ··· 215 256 } 216 257 } 217 258 259 + /// Get all input fields from an InputObjectType 260 + pub fn get_input_fields(t: Type) -> List(InputField) { 261 + case t { 262 + InputObjectType(_, _, fields) -> fields 263 + _ -> [] 264 + } 265 + } 266 + 218 267 /// Get field description 219 268 pub fn field_description(field: Field) -> String { 220 269 case field { ··· 250 299 } 251 300 } 252 301 302 + /// Get input field type 303 + pub fn input_field_type(input_field: InputField) -> Type { 304 + case input_field { 305 + InputField(_, field_type, _, _) -> field_type 306 + } 307 + } 308 + 309 + /// Get input field name 310 + pub fn input_field_name(input_field: InputField) -> String { 311 + case input_field { 312 + InputField(name, _, _, _) -> name 313 + } 314 + } 315 + 316 + /// Get input field description 317 + pub fn input_field_description(input_field: InputField) -> String { 318 + case input_field { 319 + InputField(_, _, desc, _) -> desc 320 + } 321 + } 322 + 323 + /// Get all enum values from an EnumType 324 + pub fn get_enum_values(t: Type) -> List(EnumValue) { 325 + case t { 326 + EnumType(_, _, values) -> values 327 + _ -> [] 328 + } 329 + } 330 + 331 + /// Get enum value name 332 + pub fn enum_value_name(enum_value: EnumValue) -> String { 333 + case enum_value { 334 + EnumValue(name, _) -> name 335 + } 336 + } 337 + 338 + /// Get enum value description 339 + pub fn enum_value_description(enum_value: EnumValue) -> String { 340 + case enum_value { 341 + EnumValue(_, desc) -> desc 342 + } 343 + } 344 + 253 345 /// Check if type is a scalar 254 346 pub fn is_scalar(t: Type) -> Bool { 255 347 case t { ··· 288 380 case t { 289 381 ScalarType(_) -> "SCALAR" 290 382 ObjectType(_, _, _) -> "OBJECT" 383 + InputObjectType(_, _, _) -> "INPUT_OBJECT" 291 384 EnumType(_, _, _) -> "ENUM" 292 385 ListType(_) -> "LIST" 293 386 NonNullType(_) -> "NON_NULL"
+164 -12
graphql/test/graphql/executor_test.gleam
··· 1 1 /// Tests for GraphQL Executor 2 2 /// 3 3 /// Tests query execution combining parser + schema + resolvers 4 + import gleam/dict 4 5 import gleam/list 5 - import gleam/option.{None} 6 + import gleam/option.{None, Some} 6 7 import gleeunit/should 7 8 import graphql/executor 8 9 import graphql/schema ··· 61 62 let schema = test_schema() 62 63 let query = "{ hello }" 63 64 64 - let result = executor.execute(query, schema, schema.Context(None)) 65 + let result = executor.execute(query, schema, schema.context(None)) 65 66 66 67 should.be_ok(result) 67 68 |> fn(response) { ··· 80 81 let schema = test_schema() 81 82 let query = "{ hello number }" 82 83 83 - let result = executor.execute(query, schema, schema.Context(None)) 84 + let result = executor.execute(query, schema, schema.context(None)) 84 85 85 86 should.be_ok(result) 86 87 } ··· 89 90 let schema = nested_schema() 90 91 let query = "{ user { id name } }" 91 92 92 - let result = executor.execute(query, schema, schema.Context(None)) 93 + let result = executor.execute(query, schema, schema.context(None)) 93 94 94 95 should.be_ok(result) 95 96 } ··· 98 99 let schema = test_schema() 99 100 let query = "{ greet(name: \"Alice\") }" 100 101 101 - let result = executor.execute(query, schema, schema.Context(None)) 102 + let result = executor.execute(query, schema, schema.context(None)) 102 103 103 104 should.be_ok(result) 104 105 } ··· 107 108 let schema = test_schema() 108 109 let query = "{ invalid }" 109 110 110 - let result = executor.execute(query, schema, schema.Context(None)) 111 + let result = executor.execute(query, schema, schema.context(None)) 111 112 112 113 // Should return error since field doesn't exist 113 114 case result { ··· 121 122 let schema = test_schema() 122 123 let query = "{ invalid syntax" 123 124 124 - let result = executor.execute(query, schema, schema.Context(None)) 125 + let result = executor.execute(query, schema, schema.context(None)) 125 126 126 127 should.be_error(result) 127 128 } ··· 130 131 let schema = test_schema() 131 132 let query = "{ __typename }" 132 133 133 - let result = executor.execute(query, schema, schema.Context(None)) 134 + let result = executor.execute(query, schema, schema.context(None)) 134 135 135 136 should.be_ok(result) 136 137 |> fn(response) { ··· 149 150 let schema = test_schema() 150 151 let query = "{ __typename hello }" 151 152 152 - let result = executor.execute(query, schema, schema.Context(None)) 153 + let result = executor.execute(query, schema, schema.context(None)) 153 154 154 155 should.be_ok(result) 155 156 |> fn(response) { ··· 171 172 let schema = test_schema() 172 173 let query = "{ __schema { queryType { name } } }" 173 174 174 - let result = executor.execute(query, schema, schema.Context(None)) 175 + let result = executor.execute(query, schema, schema.context(None)) 175 176 176 177 should.be_ok(result) 177 178 |> fn(response) { ··· 206 207 { user { ...UserFields } } 207 208 " 208 209 209 - let result = executor.execute(query, schema, schema.Context(None)) 210 + let result = executor.execute(query, schema, schema.context(None)) 210 211 211 212 // Test should pass - fragment should be expanded 212 213 should.be_ok(result) ··· 304 305 // Query with nested field selection - only request id and name, not email 305 306 let query = "{ users { id name } }" 306 307 307 - let result = executor.execute(query, schema, schema.Context(None)) 308 + let result = executor.execute(query, schema, schema.context(None)) 308 309 309 310 // The result should only contain id and name fields, NOT email 310 311 should.be_ok(result) ··· 347 348 } 348 349 |> should.be_true 349 350 } 351 + 352 + // Test that arguments are actually passed to resolvers 353 + pub fn execute_field_receives_string_argument_test() { 354 + let query_type = 355 + schema.object_type("Query", "Root", [ 356 + schema.field_with_args( 357 + "echo", 358 + schema.string_type(), 359 + "Echo the input", 360 + [schema.argument("message", schema.string_type(), "Message", None)], 361 + fn(ctx) { 362 + // Extract the argument from context 363 + case schema.get_argument(ctx, "message") { 364 + Some(value.String(msg)) -> Ok(value.String("Echo: " <> msg)) 365 + _ -> Ok(value.String("No message")) 366 + } 367 + }, 368 + ), 369 + ]) 370 + 371 + let test_schema = schema.schema(query_type, None) 372 + let query = "{ echo(message: \"hello\") }" 373 + 374 + let result = executor.execute(query, test_schema, schema.context(None)) 375 + 376 + should.be_ok(result) 377 + |> fn(response) { 378 + case response { 379 + executor.Response( 380 + data: value.Object([#("echo", value.String("Echo: hello"))]), 381 + errors: [], 382 + ) -> True 383 + _ -> False 384 + } 385 + } 386 + |> should.be_true 387 + } 388 + 389 + // Test list argument 390 + pub fn execute_field_receives_list_argument_test() { 391 + let query_type = 392 + schema.object_type("Query", "Root", [ 393 + schema.field_with_args( 394 + "sum", 395 + schema.int_type(), 396 + "Sum numbers", 397 + [ 398 + schema.argument( 399 + "numbers", 400 + schema.list_type(schema.int_type()), 401 + "Numbers", 402 + None, 403 + ), 404 + ], 405 + fn(ctx) { 406 + case schema.get_argument(ctx, "numbers") { 407 + Some(value.List(_items)) -> Ok(value.String("got list")) 408 + _ -> Ok(value.String("no list")) 409 + } 410 + }, 411 + ), 412 + ]) 413 + 414 + let test_schema = schema.schema(query_type, None) 415 + let query = "{ sum(numbers: [1, 2, 3]) }" 416 + 417 + let result = executor.execute(query, test_schema, schema.context(None)) 418 + 419 + should.be_ok(result) 420 + |> fn(response) { 421 + case response { 422 + executor.Response( 423 + data: value.Object([#("sum", value.String("got list"))]), 424 + errors: [], 425 + ) -> True 426 + _ -> False 427 + } 428 + } 429 + |> should.be_true 430 + } 431 + 432 + // Test object argument (like sortBy) 433 + pub fn execute_field_receives_object_argument_test() { 434 + let query_type = 435 + schema.object_type("Query", "Root", [ 436 + schema.field_with_args( 437 + "posts", 438 + schema.list_type(schema.string_type()), 439 + "Get posts", 440 + [ 441 + schema.argument( 442 + "sortBy", 443 + schema.list_type( 444 + schema.input_object_type("SortInput", "Sort", [ 445 + schema.input_field("field", schema.string_type(), "Field", None), 446 + schema.input_field( 447 + "direction", 448 + schema.enum_type("Direction", "Direction", [ 449 + schema.enum_value("ASC", "Ascending"), 450 + schema.enum_value("DESC", "Descending"), 451 + ]), 452 + "Direction", 453 + None, 454 + ), 455 + ]), 456 + ), 457 + "Sort order", 458 + None, 459 + ), 460 + ], 461 + fn(ctx) { 462 + case schema.get_argument(ctx, "sortBy") { 463 + Some(value.List([value.Object(fields), ..])) -> { 464 + case dict.from_list(fields) { 465 + fields_dict -> { 466 + case 467 + dict.get(fields_dict, "field"), 468 + dict.get(fields_dict, "direction") 469 + { 470 + Ok(value.String(field)), Ok(value.String(dir)) -> 471 + Ok(value.String("Sorting by " <> field <> " " <> dir)) 472 + _, _ -> Ok(value.String("Invalid sort")) 473 + } 474 + } 475 + } 476 + } 477 + _ -> Ok(value.String("No sort")) 478 + } 479 + }, 480 + ), 481 + ]) 482 + 483 + let test_schema = schema.schema(query_type, None) 484 + let query = "{ posts(sortBy: [{field: \"date\", direction: DESC}]) }" 485 + 486 + let result = executor.execute(query, test_schema, schema.context(None)) 487 + 488 + should.be_ok(result) 489 + |> fn(response) { 490 + case response { 491 + executor.Response( 492 + data: value.Object([ 493 + #("posts", value.String("Sorting by date DESC")), 494 + ]), 495 + errors: [], 496 + ) -> True 497 + _ -> False 498 + } 499 + } 500 + |> should.be_true 501 + }
+8 -8
graphql/test/graphql/introspection_test.gleam
··· 30 30 let query = 31 31 "{ __schema { queryType { name } mutationType { name } subscriptionType { name } } }" 32 32 33 - let result = executor.execute(query, schema, schema.Context(None)) 33 + let result = executor.execute(query, schema, schema.context(None)) 34 34 35 35 should.be_ok(result) 36 36 |> fn(response) { ··· 77 77 let schema = test_schema() 78 78 let query = "{ __schema { queryType { name } types { name } } }" 79 79 80 - let result = executor.execute(query, schema, schema.Context(None)) 80 + let result = executor.execute(query, schema, schema.context(None)) 81 81 82 82 should.be_ok(result) 83 83 |> fn(response) { ··· 122 122 let query = 123 123 "{ __schema { queryType { name } mutationType { name } subscriptionType { name } types { name } directives { name } } }" 124 124 125 - let result = executor.execute(query, schema, schema.Context(None)) 125 + let result = executor.execute(query, schema, schema.context(None)) 126 126 127 127 should.be_ok(result) 128 128 |> fn(response) { ··· 151 151 let query1 = "{ __schema { types { name } queryType { name } } }" 152 152 let query2 = "{ __schema { queryType { name } types { name } } }" 153 153 154 - let result1 = executor.execute(query1, schema, schema.Context(None)) 155 - let result2 = executor.execute(query2, schema, schema.Context(None)) 154 + let result1 = executor.execute(query1, schema, schema.context(None)) 155 + let result2 = executor.execute(query2, schema, schema.context(None)) 156 156 157 157 // Both should succeed 158 158 should.be_ok(result1) ··· 187 187 let schema = test_schema() 188 188 let query = "{ __schema { types { name kind fields { name } } } }" 189 189 190 - let result = executor.execute(query, schema, schema.Context(None)) 190 + let result = executor.execute(query, schema, schema.context(None)) 191 191 192 192 should.be_ok(result) 193 193 |> fn(response) { ··· 240 240 let schema = test_schema() 241 241 let query = "{ __schema { mutationType { name fields { name } } } }" 242 242 243 - let result = executor.execute(query, schema, schema.Context(None)) 243 + let result = executor.execute(query, schema, schema.context(None)) 244 244 245 245 should.be_ok(result) 246 246 |> fn(response) { ··· 269 269 let schema = test_schema() 270 270 let query = "{ __schema { types { ... on __Type { kind name } } } }" 271 271 272 - let result = executor.execute(query, schema, schema.Context(None)) 272 + let result = executor.execute(query, schema, schema.context(None)) 273 273 274 274 should.be_ok(result) 275 275 |> fn(response) {
+176
graphql/test/graphql/parser_test.gleam
··· 245 245 |> parser.parse 246 246 |> should.be_ok 247 247 } 248 + 249 + // List value tests 250 + pub fn parse_empty_list_argument_test() { 251 + "{ user(tags: []) }" 252 + |> parser.parse 253 + |> should.be_ok 254 + |> fn(doc) { 255 + case doc { 256 + parser.Document([ 257 + parser.Query(parser.SelectionSet([ 258 + parser.Field( 259 + name: "user", 260 + alias: None, 261 + arguments: [parser.Argument("tags", parser.ListValue([]))], 262 + selections: [], 263 + ), 264 + ])), 265 + ]) -> True 266 + _ -> False 267 + } 268 + } 269 + |> should.be_true 270 + } 271 + 272 + pub fn parse_list_of_ints_test() { 273 + "{ user(ids: [1, 2, 3]) }" 274 + |> parser.parse 275 + |> should.be_ok 276 + |> fn(doc) { 277 + case doc { 278 + parser.Document([ 279 + parser.Query(parser.SelectionSet([ 280 + parser.Field( 281 + name: "user", 282 + alias: None, 283 + arguments: [ 284 + parser.Argument( 285 + "ids", 286 + parser.ListValue([ 287 + parser.IntValue("1"), 288 + parser.IntValue("2"), 289 + parser.IntValue("3"), 290 + ]), 291 + ), 292 + ], 293 + selections: [], 294 + ), 295 + ])), 296 + ]) -> True 297 + _ -> False 298 + } 299 + } 300 + |> should.be_true 301 + } 302 + 303 + pub fn parse_list_of_strings_test() { 304 + "{ user(tags: [\"foo\", \"bar\"]) }" 305 + |> parser.parse 306 + |> should.be_ok 307 + |> fn(doc) { 308 + case doc { 309 + parser.Document([ 310 + parser.Query(parser.SelectionSet([ 311 + parser.Field( 312 + name: "user", 313 + alias: None, 314 + arguments: [ 315 + parser.Argument( 316 + "tags", 317 + parser.ListValue([ 318 + parser.StringValue("foo"), 319 + parser.StringValue("bar"), 320 + ]), 321 + ), 322 + ], 323 + selections: [], 324 + ), 325 + ])), 326 + ]) -> True 327 + _ -> False 328 + } 329 + } 330 + |> should.be_true 331 + } 332 + 333 + // Object value tests 334 + pub fn parse_empty_object_argument_test() { 335 + "{ user(filter: {}) }" 336 + |> parser.parse 337 + |> should.be_ok 338 + |> fn(doc) { 339 + case doc { 340 + parser.Document([ 341 + parser.Query(parser.SelectionSet([ 342 + parser.Field( 343 + name: "user", 344 + alias: None, 345 + arguments: [parser.Argument("filter", parser.ObjectValue([]))], 346 + selections: [], 347 + ), 348 + ])), 349 + ]) -> True 350 + _ -> False 351 + } 352 + } 353 + |> should.be_true 354 + } 355 + 356 + pub fn parse_object_with_fields_test() { 357 + "{ user(filter: {name: \"Alice\", age: 30}) }" 358 + |> parser.parse 359 + |> should.be_ok 360 + |> fn(doc) { 361 + case doc { 362 + parser.Document([ 363 + parser.Query(parser.SelectionSet([ 364 + parser.Field( 365 + name: "user", 366 + alias: None, 367 + arguments: [ 368 + parser.Argument( 369 + "filter", 370 + parser.ObjectValue([ 371 + #("name", parser.StringValue("Alice")), 372 + #("age", parser.IntValue("30")), 373 + ]), 374 + ), 375 + ], 376 + selections: [], 377 + ), 378 + ])), 379 + ]) -> True 380 + _ -> False 381 + } 382 + } 383 + |> should.be_true 384 + } 385 + 386 + // Nested structures 387 + pub fn parse_list_of_objects_test() { 388 + "{ posts(sortBy: [{field: \"date\", direction: DESC}]) }" 389 + |> parser.parse 390 + |> should.be_ok 391 + |> fn(doc) { 392 + case doc { 393 + parser.Document([ 394 + parser.Query(parser.SelectionSet([ 395 + parser.Field( 396 + name: "posts", 397 + alias: None, 398 + arguments: [ 399 + parser.Argument( 400 + "sortBy", 401 + parser.ListValue([ 402 + parser.ObjectValue([ 403 + #("field", parser.StringValue("date")), 404 + #("direction", parser.EnumValue("DESC")), 405 + ]), 406 + ]), 407 + ), 408 + ], 409 + selections: [], 410 + ), 411 + ])), 412 + ]) -> True 413 + _ -> False 414 + } 415 + } 416 + |> should.be_true 417 + } 418 + 419 + pub fn parse_object_with_nested_list_test() { 420 + "{ user(filter: {tags: [\"a\", \"b\"]}) }" 421 + |> parser.parse 422 + |> should.be_ok 423 + }
+96
lexicon_graphql/src/lexicon_graphql/connection.gleam
··· 1 + /// Lexicon-specific Connection Extensions 2 + /// 3 + /// Adds sortBy support to the base Relay Connection specification 4 + import gleam/list 5 + import gleam/option.{None} 6 + import graphql/connection 7 + import graphql/schema 8 + 9 + /// SortDirection enum type for lexicon queries 10 + pub fn sort_direction_enum() -> schema.Type { 11 + schema.enum_type("SortDirection", "Sort direction for query results", [ 12 + schema.enum_value("ASC", "Ascending order"), 13 + schema.enum_value("DESC", "Descending order"), 14 + ]) 15 + } 16 + 17 + /// SortFieldInput type for specifying sort order 18 + pub fn sort_field_input_type() -> schema.Type { 19 + schema.input_object_type( 20 + "SortFieldInput", 21 + "Specifies a field to sort by and its direction", 22 + [ 23 + schema.input_field( 24 + "field", 25 + schema.non_null(schema.string_type()), 26 + "Field name to sort by (e.g. 'indexed_at', 'createdAt', or nested like 'author.name')", 27 + None, 28 + ), 29 + schema.input_field( 30 + "direction", 31 + schema.non_null(sort_direction_enum()), 32 + "Sort direction (ASC or DESC)", 33 + None, 34 + ), 35 + ], 36 + ) 37 + } 38 + 39 + /// Connection arguments with sortBy support for lexicon queries 40 + /// Extends the base Relay connection args with custom sortBy 41 + pub fn lexicon_connection_args() -> List(schema.Argument) { 42 + list.flatten([ 43 + connection.forward_pagination_args(), 44 + connection.backward_pagination_args(), 45 + [ 46 + schema.argument( 47 + "sortBy", 48 + schema.list_type(schema.non_null(sort_field_input_type())), 49 + "Sort order for the connection. Defaults to [{field: \"indexed_at\", direction: DESC}]", 50 + None, 51 + ), 52 + ], 53 + ]) 54 + } 55 + 56 + /// SortFieldInput type with a custom field enum 57 + pub fn sort_field_input_type_with_enum(field_enum: schema.Type) -> schema.Type { 58 + schema.input_object_type( 59 + "SortFieldInput", 60 + "Specifies a field to sort by and its direction", 61 + [ 62 + schema.input_field( 63 + "field", 64 + schema.non_null(field_enum), 65 + "Field to sort by", 66 + None, 67 + ), 68 + schema.input_field( 69 + "direction", 70 + schema.non_null(sort_direction_enum()), 71 + "Sort direction (ASC or DESC)", 72 + None, 73 + ), 74 + ], 75 + ) 76 + } 77 + 78 + /// Connection arguments with sortBy using a custom field enum 79 + pub fn lexicon_connection_args_with_field_enum( 80 + field_enum: schema.Type, 81 + ) -> List(schema.Argument) { 82 + list.flatten([ 83 + connection.forward_pagination_args(), 84 + connection.backward_pagination_args(), 85 + [ 86 + schema.argument( 87 + "sortBy", 88 + schema.list_type(schema.non_null( 89 + sort_field_input_type_with_enum(field_enum), 90 + )), 91 + "Sort order for the connection", 92 + None, 93 + ), 94 + ], 95 + ]) 96 + }
+102 -13
lexicon_graphql/src/lexicon_graphql/db_schema_builder.gleam
··· 2 2 /// 3 3 /// Builds GraphQL schemas from AT Protocol lexicon definitions with database-backed resolvers. 4 4 /// This extends the base schema_builder with actual data resolution. 5 + import gleam/int 5 6 import gleam/list 6 7 import gleam/option 7 8 import gleam/result 8 9 import graphql/connection 9 10 import graphql/schema 10 11 import graphql/value 12 + import lexicon_graphql/connection as lexicon_connection 11 13 import lexicon_graphql/nsid 12 14 import lexicon_graphql/schema_builder 13 15 import lexicon_graphql/type_mapper ··· 163 165 list.append(standard_fields, lexicon_fields) 164 166 } 165 167 168 + /// Build a SortFieldEnum for a record type with all its sortable fields 169 + fn build_sort_field_enum(record_type: RecordType) -> schema.Type { 170 + // Get field names from the record type 171 + let field_names = 172 + list.map(record_type.fields, fn(field) { schema.field_name(field) }) 173 + 174 + // Convert field names to enum values 175 + let enum_values = 176 + list.map(field_names, fn(field_name) { 177 + schema.enum_value(field_name, "Sort by " <> field_name) 178 + }) 179 + 180 + schema.enum_type( 181 + record_type.type_name <> "SortField", 182 + "Available sort fields for " <> record_type.type_name, 183 + enum_values, 184 + ) 185 + } 186 + 166 187 /// Build the root Query type with fields for each record type 167 188 fn build_query_type( 168 189 record_types: List(RecordType), ··· 182 203 let edge_type = connection.edge_type(record_type.type_name, object_type) 183 204 let connection_type = 184 205 connection.connection_type(record_type.type_name, edge_type) 206 + 207 + // Build custom SortFieldEnum for this record type 208 + let sort_field_enum = build_sort_field_enum(record_type) 209 + 210 + // Build custom connection args with type-specific sort field enum 211 + let connection_args = 212 + lexicon_connection.lexicon_connection_args_with_field_enum( 213 + sort_field_enum, 214 + ) 185 215 186 216 // Create query field that returns a Connection of this record type 187 217 // Capture the nsid and fetcher in the closure ··· 189 219 schema.field_with_args( 190 220 record_type.field_name, 191 221 connection_type, 192 - "Query " <> record_type.nsid <> " with cursor pagination", 193 - connection.connection_args(), 222 + "Query " <> record_type.nsid <> " with cursor pagination and sorting", 223 + connection_args, 194 224 fn(ctx: schema.Context) { 195 225 // Extract pagination arguments from context 196 226 let pagination_params = extract_pagination_params(ctx) ··· 237 267 238 268 /// Extract pagination parameters from GraphQL context 239 269 fn extract_pagination_params(ctx: schema.Context) -> PaginationParams { 240 - let _ = ctx 241 - // TODO: In a full implementation, arguments would be extracted from context 242 - // Currently the GraphQL executor doesn't pass arguments to resolvers 243 - // For now, we use sensible defaults: 244 - // - Default to 50 items per page 245 - // - Sort by indexed_at DESC (most recent first) 270 + // Extract sortBy argument 271 + let sort_by = case schema.get_argument(ctx, "sortBy") { 272 + option.Some(value.List(items)) -> { 273 + // Convert list of sort objects to list of tuples 274 + let sort_tuples = 275 + list.filter_map(items, fn(item) { 276 + case item { 277 + value.Object(fields) -> { 278 + // Extract field and direction from the object 279 + case list.key_find(fields, "field"), list.key_find(fields, "direction") { 280 + Ok(value.String(field)), Ok(value.String(direction)) -> { 281 + // Convert direction to lowercase for consistency 282 + let dir = case direction { 283 + "ASC" -> "asc" 284 + "DESC" -> "desc" 285 + _ -> "desc" 286 + } 287 + Ok(#(field, dir)) 288 + } 289 + _, _ -> Error(Nil) 290 + } 291 + } 292 + _ -> Error(Nil) 293 + } 294 + }) 295 + 296 + case sort_tuples { 297 + [] -> option.Some([#("indexed_at", "desc")]) 298 + _ -> option.Some(sort_tuples) 299 + } 300 + } 301 + _ -> option.Some([#("indexed_at", "desc")]) 302 + } 303 + 304 + // Extract first/after/last/before arguments 305 + let first = case schema.get_argument(ctx, "first") { 306 + option.Some(value.String(s)) -> { 307 + case int.parse(s) { 308 + Ok(n) -> option.Some(n) 309 + Error(_) -> option.Some(50) 310 + } 311 + } 312 + _ -> option.Some(50) 313 + } 314 + 315 + let after = case schema.get_argument(ctx, "after") { 316 + option.Some(value.String(s)) -> option.Some(s) 317 + _ -> option.None 318 + } 319 + 320 + let last = case schema.get_argument(ctx, "last") { 321 + option.Some(value.String(s)) -> { 322 + case int.parse(s) { 323 + Ok(n) -> option.Some(n) 324 + Error(_) -> option.None 325 + } 326 + } 327 + _ -> option.None 328 + } 329 + 330 + let before = case schema.get_argument(ctx, "before") { 331 + option.Some(value.String(s)) -> option.Some(s) 332 + _ -> option.None 333 + } 334 + 246 335 PaginationParams( 247 - first: option.Some(50), 248 - after: option.None, 249 - last: option.None, 250 - before: option.None, 251 - sort_by: option.Some([#("indexed_at", "desc")]), 336 + first: first, 337 + after: after, 338 + last: last, 339 + before: before, 340 + sort_by: sort_by, 252 341 ) 253 342 } 254 343
+161
lexicon_graphql/test/lexicon_graphql/sorting_test.gleam
··· 1 + /// Tests for sortBy schema generation 2 + /// 3 + /// These tests verify that the GraphQL schema is generated correctly with: 4 + /// - Custom SortFieldEnum for each record type 5 + /// - SortFieldInput InputObject type 6 + /// - sortBy argument on connection fields 7 + import gleam/list 8 + import gleam/option.{None, Some} 9 + import gleeunit/should 10 + import graphql/schema 11 + import lexicon_graphql/db_schema_builder 12 + import lexicon_graphql/schema_builder 13 + 14 + // Create a simple test schema 15 + fn create_test_schema() -> schema.Schema { 16 + // Mock fetcher that returns empty results (we're only testing schema generation) 17 + let fetcher = fn(_collection, _params) { Ok(#([], None, False, False)) } 18 + 19 + // Create a lexicon for xyz.statusphere.status 20 + let lexicon = 21 + schema_builder.Lexicon( 22 + "xyz.statusphere.status", 23 + schema_builder.Defs( 24 + schema_builder.RecordDef("record", [ 25 + #("status", schema_builder.Property("string", False)), 26 + #("createdAt", schema_builder.Property("string", False)), 27 + ]), 28 + ), 29 + ) 30 + 31 + case db_schema_builder.build_schema_with_fetcher([lexicon], fetcher) { 32 + Ok(s) -> s 33 + Error(_) -> panic as "Failed to build test schema" 34 + } 35 + } 36 + 37 + // Test: Schema has the query type with connection field 38 + pub fn test_schema_has_connection_field() { 39 + let test_schema = create_test_schema() 40 + let query_type = schema.query_type(test_schema) 41 + 42 + // Verify the query type has the xyzStatusphereStatus field 43 + case schema.get_field(query_type, "xyzStatusphereStatus") { 44 + None -> should.be_true(False) 45 + Some(field) -> { 46 + // Verify field name 47 + should.equal(schema.field_name(field), "xyzStatusphereStatus") 48 + 49 + // Verify it has arguments 50 + let args = schema.field_arguments(field) 51 + should.be_true(args != []) 52 + } 53 + } 54 + } 55 + 56 + // Test: Connection field has sortBy argument 57 + pub fn test_connection_has_sortby_argument() { 58 + let test_schema = create_test_schema() 59 + let query_type = schema.query_type(test_schema) 60 + 61 + case schema.get_field(query_type, "xyzStatusphereStatus") { 62 + None -> should.be_true(False) 63 + Some(field) -> { 64 + let args = schema.field_arguments(field) 65 + 66 + // Find sortBy argument 67 + let sortby_arg = 68 + list.find(args, fn(arg) { schema.argument_name(arg) == "sortBy" }) 69 + 70 + case sortby_arg { 71 + Error(_) -> should.be_true(False) 72 + Ok(arg) -> { 73 + should.equal(schema.argument_name(arg), "sortBy") 74 + 75 + // Verify it's a list type 76 + let arg_type = schema.argument_type(arg) 77 + should.be_true(schema.is_list(arg_type)) 78 + } 79 + } 80 + } 81 + } 82 + } 83 + 84 + // Test: Connection field has first/after arguments (forward pagination) 85 + pub fn test_connection_has_pagination_arguments() { 86 + let test_schema = create_test_schema() 87 + let query_type = schema.query_type(test_schema) 88 + 89 + case schema.get_field(query_type, "xyzStatusphereStatus") { 90 + None -> should.be_true(False) 91 + Some(field) -> { 92 + let args = schema.field_arguments(field) 93 + let arg_names = list.map(args, schema.argument_name) 94 + 95 + // Verify we have pagination arguments 96 + should.be_true(list.contains(arg_names, "first")) 97 + should.be_true(list.contains(arg_names, "after")) 98 + should.be_true(list.contains(arg_names, "last")) 99 + should.be_true(list.contains(arg_names, "before")) 100 + should.be_true(list.contains(arg_names, "sortBy")) 101 + } 102 + } 103 + } 104 + 105 + // Test: Query type has correct field for the lexicon 106 + pub fn test_query_type_has_lexicon_field() { 107 + let test_schema = create_test_schema() 108 + let query_type = schema.query_type(test_schema) 109 + 110 + // The field name should be camelCase version of the NSID 111 + let field = schema.get_field(query_type, "xyzStatusphereStatus") 112 + 113 + case field { 114 + None -> should.be_true(False) 115 + Some(_) -> should.be_true(True) 116 + } 117 + } 118 + 119 + // Test: Multiple lexicons create multiple fields with distinct sort enums 120 + pub fn test_multiple_lexicons_create_distinct_fields() { 121 + let fetcher = fn(_collection, _params) { Ok(#([], None, False, False)) } 122 + 123 + let lexicon1 = 124 + schema_builder.Lexicon( 125 + "xyz.statusphere.status", 126 + schema_builder.Defs( 127 + schema_builder.RecordDef("record", [ 128 + #("status", schema_builder.Property("string", False)), 129 + #("createdAt", schema_builder.Property("string", False)), 130 + ]), 131 + ), 132 + ) 133 + 134 + let lexicon2 = 135 + schema_builder.Lexicon( 136 + "app.bsky.feed.post", 137 + schema_builder.Defs( 138 + schema_builder.RecordDef("record", [ 139 + #("text", schema_builder.Property("string", False)), 140 + #("createdAt", schema_builder.Property("string", False)), 141 + ]), 142 + ), 143 + ) 144 + 145 + case db_schema_builder.build_schema_with_fetcher([lexicon1, lexicon2], fetcher) { 146 + Ok(test_schema) -> { 147 + let query_type = schema.query_type(test_schema) 148 + 149 + // Verify both fields exist 150 + let field1 = schema.get_field(query_type, "xyzStatusphereStatus") 151 + let field2 = schema.get_field(query_type, "appBskyFeedPost") 152 + 153 + case field1, field2 { 154 + None, _ -> should.be_true(False) 155 + _, None -> should.be_true(False) 156 + Some(_), Some(_) -> should.be_true(True) 157 + } 158 + } 159 + Error(_) -> should.be_true(False) 160 + } 161 + }
+13 -2
server/src/database.gleam
··· 723 723 let #(field_name, direction) = field 724 724 let field_ref = case field_name { 725 725 "uri" | "cid" | "did" | "collection" | "indexed_at" -> field_name 726 + // For JSON fields, check if they look like dates and handle accordingly 727 + "createdAt" | "indexedAt" -> { 728 + // Use CASE to treat invalid dates as NULL for sorting 729 + let json_field = "json_extract(json, '$." <> field_name <> "')" 730 + "CASE 731 + WHEN " <> json_field <> " IS NULL THEN NULL 732 + WHEN datetime(" <> json_field <> ") IS NULL THEN NULL 733 + ELSE " <> json_field <> " 734 + END" 735 + } 726 736 _ -> "json_extract(json, '$." <> field_name <> "')" 727 737 } 728 738 let dir = case string.lowercase(direction) { 729 739 "asc" -> "ASC" 730 740 _ -> "DESC" 731 741 } 732 - field_ref <> " " <> dir 742 + // Always put NULLs last regardless of sort direction 743 + field_ref <> " " <> dir <> " NULLS LAST" 733 744 }) 734 745 735 746 case list.is_empty(order_parts) { 736 - True -> "indexed_at DESC" 747 + True -> "indexed_at DESC NULLS LAST" 737 748 False -> string.join(order_parts, ", ") 738 749 } 739 750 }
+1 -1
server/src/graphql_gleam.gleam
··· 96 96 ) 97 97 98 98 // Step 5: Execute the query 99 - let ctx = schema.Context(data: option.None) 99 + let ctx = schema.context(option.None) 100 100 use response <- result.try(executor.execute( 101 101 query_string, 102 102 graphql_schema,
+453
server/test/database_sorting_test.gleam
··· 1 + /// Database sorting integration tests 2 + /// 3 + /// Tests that SQL ORDER BY clauses are generated correctly and 4 + /// that sorting works properly with the database 5 + import gleam/list 6 + import gleam/option.{None, Some} 7 + import gleeunit/should 8 + import database 9 + import sqlight 10 + 11 + // Helper to create test database with records 12 + fn create_test_db_with_records() -> sqlight.Connection { 13 + // Create in-memory database 14 + let assert Ok(conn) = sqlight.open(":memory:") 15 + 16 + // Create schema using the database module 17 + let assert Ok(_) = database.create_record_table(conn) 18 + 19 + // Insert test records with different dates 20 + let records = [ 21 + #( 22 + "at://did:plc:1/xyz.statusphere.status/1", 23 + "cid1", 24 + "did:plc:1", 25 + "xyz.statusphere.status", 26 + "{\"status\":\"😊\",\"createdAt\":\"2025-01-15T10:00:00Z\"}", 27 + "2025-01-15T10:00:00Z", 28 + ), 29 + #( 30 + "at://did:plc:2/xyz.statusphere.status/2", 31 + "cid2", 32 + "did:plc:2", 33 + "xyz.statusphere.status", 34 + "{\"status\":\"🎉\",\"createdAt\":\"2025-01-20T10:00:00Z\"}", 35 + "2025-01-20T10:00:00Z", 36 + ), 37 + #( 38 + "at://did:plc:3/xyz.statusphere.status/3", 39 + "cid3", 40 + "did:plc:3", 41 + "xyz.statusphere.status", 42 + "{\"status\":\"🤔\",\"createdAt\":\"2025-01-10T10:00:00Z\"}", 43 + "2025-01-10T10:00:00Z", 44 + ), 45 + // Record with NULL createdAt 46 + #( 47 + "at://did:plc:4/xyz.statusphere.status/4", 48 + "cid4", 49 + "did:plc:4", 50 + "xyz.statusphere.status", 51 + "{\"status\":\"😴\",\"createdAt\":null}", 52 + "2025-01-25T10:00:00Z", 53 + ), 54 + // Record with invalid createdAt 55 + #( 56 + "at://did:plc:5/xyz.statusphere.status/5", 57 + "cid5", 58 + "did:plc:5", 59 + "xyz.statusphere.status", 60 + "{\"status\":\"🤷\",\"createdAt\":\"wowzers\"}", 61 + "2025-01-18T10:00:00Z", 62 + ), 63 + ] 64 + 65 + list.each(records, fn(record) { 66 + let #(uri, cid, did, collection, json, indexed_at) = record 67 + let insert_sql = 68 + "INSERT INTO record (uri, cid, did, collection, json, indexed_at) 69 + VALUES ('" 70 + <> uri 71 + <> "', '" 72 + <> cid 73 + <> "', '" 74 + <> did 75 + <> "', '" 76 + <> collection 77 + <> "', '" 78 + <> json 79 + <> "', '" 80 + <> indexed_at 81 + <> "')" 82 + 83 + let assert Ok(_) = sqlight.exec(insert_sql, conn) 84 + Nil 85 + }) 86 + 87 + conn 88 + } 89 + 90 + // Test: Sort by indexedAt DESC (default) 91 + pub fn test_sort_by_indexed_at_desc() { 92 + let conn = create_test_db_with_records() 93 + 94 + let result = 95 + database.get_records_by_collection_paginated( 96 + conn, 97 + "xyz.statusphere.status", 98 + Some(10), 99 + None, 100 + None, 101 + None, 102 + Some([#("indexed_at", "desc")]), 103 + ) 104 + 105 + case result { 106 + Ok(#(records, _, _, _)) -> { 107 + // First record should be most recent (2025-01-25) 108 + case list.first(records) { 109 + Ok(first) -> 110 + should.equal(first.indexed_at, "2025-01-25T10:00:00Z") 111 + Error(_) -> should.be_true(False) 112 + } 113 + 114 + // Verify order: 2025-01-25, 2025-01-20, 2025-01-18, 2025-01-15, 2025-01-10 115 + let dates = list.map(records, fn(r) { r.indexed_at }) 116 + should.equal(dates, [ 117 + "2025-01-25T10:00:00Z", 118 + "2025-01-20T10:00:00Z", 119 + "2025-01-18T10:00:00Z", 120 + "2025-01-15T10:00:00Z", 121 + "2025-01-10T10:00:00Z", 122 + ]) 123 + } 124 + Error(_) -> should.be_true(False) 125 + } 126 + } 127 + 128 + // Test: Sort by indexedAt ASC 129 + pub fn test_sort_by_indexed_at_asc() { 130 + let conn = create_test_db_with_records() 131 + 132 + let result = 133 + database.get_records_by_collection_paginated( 134 + conn, 135 + "xyz.statusphere.status", 136 + Some(10), 137 + None, 138 + None, 139 + None, 140 + Some([#("indexed_at", "asc")]), 141 + ) 142 + 143 + case result { 144 + Ok(#(records, _, _, _)) -> { 145 + // First record should be oldest (2025-01-10) 146 + case list.first(records) { 147 + Ok(first) -> 148 + should.equal(first.indexed_at, "2025-01-10T10:00:00Z") 149 + Error(_) -> should.be_true(False) 150 + } 151 + 152 + // Verify ascending order 153 + let dates = list.map(records, fn(r) { r.indexed_at }) 154 + should.equal(dates, [ 155 + "2025-01-10T10:00:00Z", 156 + "2025-01-15T10:00:00Z", 157 + "2025-01-18T10:00:00Z", 158 + "2025-01-20T10:00:00Z", 159 + "2025-01-25T10:00:00Z", 160 + ]) 161 + } 162 + Error(_) -> should.be_true(False) 163 + } 164 + } 165 + 166 + // Test: Sort by JSON field (createdAt) DESC with NULLS LAST 167 + pub fn test_sort_by_json_field_desc_nulls_last() { 168 + let conn = create_test_db_with_records() 169 + 170 + let result = 171 + database.get_records_by_collection_paginated( 172 + conn, 173 + "xyz.statusphere.status", 174 + Some(10), 175 + None, 176 + None, 177 + None, 178 + Some([#("createdAt", "desc")]), 179 + ) 180 + 181 + case result { 182 + Ok(#(records, _, _, _)) -> { 183 + // First record should have newest createdAt (2025-01-20) 184 + case list.first(records) { 185 + Ok(first) -> 186 + should.equal(first.indexed_at, "2025-01-20T10:00:00Z") 187 + Error(_) -> should.be_true(False) 188 + } 189 + 190 + // Last two records should be NULL and invalid date (NULLS LAST) 191 + case list.reverse(records) { 192 + [last, second_last, ..] -> { 193 + // These should be the records with null or invalid dates 194 + let last_indexed = [last.indexed_at, second_last.indexed_at] 195 + // Should contain both the null and "wowzers" records 196 + should.be_true( 197 + list.contains(last_indexed, "2025-01-25T10:00:00Z") 198 + || list.contains(last_indexed, "2025-01-18T10:00:00Z"), 199 + ) 200 + } 201 + _ -> should.be_true(False) 202 + } 203 + } 204 + Error(_) -> should.be_true(False) 205 + } 206 + } 207 + 208 + // Test: Sort by JSON field (createdAt) ASC with NULLS LAST 209 + pub fn test_sort_by_json_field_asc_nulls_last() { 210 + let conn = create_test_db_with_records() 211 + 212 + let result = 213 + database.get_records_by_collection_paginated( 214 + conn, 215 + "xyz.statusphere.status", 216 + Some(10), 217 + None, 218 + None, 219 + None, 220 + Some([#("createdAt", "asc")]), 221 + ) 222 + 223 + case result { 224 + Ok(#(records, _, _, _)) -> { 225 + // First record should have oldest valid createdAt (2025-01-10) 226 + case list.first(records) { 227 + Ok(first) -> 228 + should.equal(first.indexed_at, "2025-01-10T10:00:00Z") 229 + Error(_) -> should.be_true(False) 230 + } 231 + 232 + // Last two should still be NULL/invalid (NULLS LAST even with ASC) 233 + case list.reverse(records) { 234 + [last, second_last, ..] -> { 235 + let last_indexed = [last.indexed_at, second_last.indexed_at] 236 + should.be_true( 237 + list.contains(last_indexed, "2025-01-25T10:00:00Z") 238 + || list.contains(last_indexed, "2025-01-18T10:00:00Z"), 239 + ) 240 + } 241 + _ -> should.be_true(False) 242 + } 243 + } 244 + Error(_) -> should.be_true(False) 245 + } 246 + } 247 + 248 + // Test: Pagination with sorting (first N records) 249 + pub fn test_pagination_with_sorting() { 250 + let conn = create_test_db_with_records() 251 + 252 + // Get first 2 records sorted by createdAt DESC 253 + let result = 254 + database.get_records_by_collection_paginated( 255 + conn, 256 + "xyz.statusphere.status", 257 + Some(2), 258 + None, 259 + None, 260 + None, 261 + Some([#("createdAt", "desc")]), 262 + ) 263 + 264 + case result { 265 + Ok(#(records, _, has_next, _)) -> { 266 + // Should get exactly 2 records 267 + should.equal(list.length(records), 2) 268 + 269 + // Should have next page 270 + should.be_true(has_next) 271 + 272 + // First should be 2025-01-20, second should be 2025-01-15 273 + case records { 274 + [first, second] -> { 275 + should.equal(first.indexed_at, "2025-01-20T10:00:00Z") 276 + should.equal(second.indexed_at, "2025-01-15T10:00:00Z") 277 + } 278 + _ -> should.be_true(False) 279 + } 280 + } 281 + Error(_) -> should.be_true(False) 282 + } 283 + } 284 + 285 + // Test: Invalid date strings are treated as NULL 286 + pub fn test_invalid_dates_treated_as_null() { 287 + let conn = create_test_db_with_records() 288 + 289 + let result = 290 + database.get_records_by_collection_paginated( 291 + conn, 292 + "xyz.statusphere.status", 293 + Some(10), 294 + None, 295 + None, 296 + None, 297 + Some([#("createdAt", "desc")]), 298 + ) 299 + 300 + case result { 301 + Ok(#(records, _, _, _)) -> { 302 + // The record with "wowzers" should be near the end (treated as NULL) 303 + // Find the "wowzers" record by its indexed_at 304 + let wowzers_position = 305 + list.index_map(records, fn(r: database.Record, idx) { 306 + case r.indexed_at == "2025-01-18T10:00:00Z" { 307 + True -> Some(idx) 308 + False -> None 309 + } 310 + }) 311 + |> list.filter_map(fn(x) { option.to_result(x, Nil) }) 312 + |> list.first 313 + 314 + case wowzers_position { 315 + Ok(pos) -> { 316 + // Should be in last 2 positions (index 3 or 4 out of 5 records) 317 + should.be_true(pos >= 3) 318 + } 319 + Error(_) -> should.be_true(False) 320 + } 321 + } 322 + Error(_) -> should.be_true(False) 323 + } 324 + } 325 + 326 + // Test: Cursor-based pagination works correctly 327 + pub fn test_cursor_pagination() { 328 + let conn = create_test_db_with_records() 329 + 330 + // Get first page of 2 records 331 + let first_page = 332 + database.get_records_by_collection_paginated( 333 + conn, 334 + "xyz.statusphere.status", 335 + Some(2), 336 + None, 337 + None, 338 + None, 339 + Some([#("indexed_at", "desc")]), 340 + ) 341 + 342 + case first_page { 343 + Ok(#(first_records, Some(end_cursor), has_next, _)) -> { 344 + // Should get exactly 2 records 345 + should.equal(list.length(first_records), 2) 346 + 347 + // Should have next page 348 + should.be_true(has_next) 349 + 350 + // First page should be most recent (2025-01-25 and 2025-01-20) 351 + case first_records { 352 + [first, second] -> { 353 + should.equal(first.indexed_at, "2025-01-25T10:00:00Z") 354 + should.equal(second.indexed_at, "2025-01-20T10:00:00Z") 355 + 356 + // Now get second page using the cursor 357 + let second_page = 358 + database.get_records_by_collection_paginated( 359 + conn, 360 + "xyz.statusphere.status", 361 + Some(2), 362 + Some(end_cursor), 363 + None, 364 + None, 365 + Some([#("indexed_at", "desc")]), 366 + ) 367 + 368 + case second_page { 369 + Ok(#(second_records, _, second_has_next, _)) -> { 370 + // Should get exactly 2 records 371 + should.equal(list.length(second_records), 2) 372 + 373 + // Should have next page (1 record remaining) 374 + should.be_true(second_has_next) 375 + 376 + // Second page should be next two (2025-01-18 and 2025-01-15) 377 + case second_records { 378 + [third, fourth] -> { 379 + should.equal(third.indexed_at, "2025-01-18T10:00:00Z") 380 + should.equal(fourth.indexed_at, "2025-01-15T10:00:00Z") 381 + 382 + // Verify no overlap - records should be different 383 + should.not_equal(first.uri, third.uri) 384 + should.not_equal(first.uri, fourth.uri) 385 + should.not_equal(second.uri, third.uri) 386 + should.not_equal(second.uri, fourth.uri) 387 + } 388 + _ -> should.be_true(False) 389 + } 390 + } 391 + Error(_) -> should.be_true(False) 392 + } 393 + } 394 + _ -> should.be_true(False) 395 + } 396 + } 397 + _ -> should.be_true(False) 398 + } 399 + } 400 + 401 + // Test: Cursor pagination with no next page 402 + pub fn test_cursor_pagination_last_page() { 403 + let conn = create_test_db_with_records() 404 + 405 + // Get first 4 records, leaving only 1 406 + let first_page = 407 + database.get_records_by_collection_paginated( 408 + conn, 409 + "xyz.statusphere.status", 410 + Some(4), 411 + None, 412 + None, 413 + None, 414 + Some([#("indexed_at", "desc")]), 415 + ) 416 + 417 + case first_page { 418 + Ok(#(_, Some(end_cursor), has_next, _)) -> { 419 + // Should have next page 420 + should.be_true(has_next) 421 + 422 + // Get last page 423 + let last_page = 424 + database.get_records_by_collection_paginated( 425 + conn, 426 + "xyz.statusphere.status", 427 + Some(2), 428 + Some(end_cursor), 429 + None, 430 + None, 431 + Some([#("indexed_at", "desc")]), 432 + ) 433 + 434 + case last_page { 435 + Ok(#(last_records, _, last_has_next, _)) -> { 436 + // Should get exactly 1 record (only 1 remaining) 437 + should.equal(list.length(last_records), 1) 438 + 439 + // Should NOT have next page 440 + should.be_false(last_has_next) 441 + 442 + // Should be the oldest record 443 + case list.first(last_records) { 444 + Ok(last) -> should.equal(last.indexed_at, "2025-01-10T10:00:00Z") 445 + Error(_) -> should.be_true(False) 446 + } 447 + } 448 + Error(_) -> should.be_true(False) 449 + } 450 + } 451 + _ -> should.be_true(False) 452 + } 453 + }