๐ŸŒŠ A GraphQL implementation in Gleam

Compare changes

Choose any two refs to compare.

+16
.claude/settings.json
···
··· 1 + { 2 + "includeCoAuthoredBy": false, 3 + "hooks": { 4 + "PostToolUse": [ 5 + { 6 + "matcher": "Write|Edit|MultiEdit", 7 + "hooks": [ 8 + { 9 + "type": "command", 10 + "command": "gleam format" 11 + } 12 + ] 13 + } 14 + ] 15 + } 16 + }
+69
CHANGELOG.md
···
··· 1 + # Changelog 2 + 3 + ## 2.1.4 4 + 5 + ### Fixed 6 + 7 + - Variables are now preserved when executing nested object and list selections. Previously, variables were lost when traversing into nested contexts, causing variable references in nested fields to resolve as empty. 8 + 9 + ## 2.1.3 10 + 11 + ### Fixed 12 + 13 + - Union type resolution now works for fields wrapped in NonNull (e.g., `NonNull(UnionType)`). Previously, the executor only checked for bare `UnionType`, missing cases where unions were wrapped. 14 + 15 + ## 2.1.2 16 + 17 + ### Fixed 18 + 19 + - Union type resolution now uses a canonical type registry, ensuring resolved types have complete field definitions 20 + - Made `build_type_map` public in introspection module for building type registries 21 + - Added `get_union_type_resolver` to extract the type resolver function from union types 22 + - Added `resolve_union_type_with_registry` for registry-based union resolution 23 + 24 + ## 2.1.1 25 + 26 + ### Fixed 27 + 28 + - Inline fragments on union types within arrays now resolve correctly (was skipping union type resolution for `List[NonNull[Union]]` items) 29 + - Union types are now always traversed during introspection on first encounter, ensuring their `possibleTypes` are properly collected 30 + 31 + ## 2.1.0 32 + 33 + ### Added 34 + 35 + - Argument type validation: list arguments now produce helpful errors when passed objects instead of lists 36 + - `isOneOf` field in type introspection for INPUT_OBJECT types (GraphQL spec compliance) 37 + - Introspection types are now returned in alphabetical order by name 38 + 39 + ## 2.0.0 40 + 41 + ### Added 42 + 43 + - Support for variable default values (`$name: Type = defaultValue`) 44 + - Default values are applied during execution when variables are not provided 45 + - Provided variables override default values 46 + - New `schema.named_type_name` function to get the base type name without List/NonNull wrappers 47 + 48 + ### Fixed 49 + 50 + - Integer and float literal arguments are now correctly converted to `value.Int` and `value.Float` instead of `value.String` 51 + - Fragment spread type condition matching now works correctly when parent type is wrapped in NonNull or List 52 + - Inline fragment type condition matching now works correctly with wrapped types 53 + - `__typename` introspection now returns the concrete type name without modifiers 54 + 55 + ### Breaking Changes 56 + 57 + - `Variable` type now has 3 fields: `Variable(name, type_, default_value)` instead of 2 58 + - Code pattern matching on `Variable` must be updated to include the third field 59 + - Migration: Add `None` or `_` as the third argument when constructing or matching `Variable` 60 + 61 + ## 1.1.0 62 + 63 + ### Added 64 + 65 + - Support for list types in variable definitions (`[Type]`, `[Type!]`, `[Type]!`, `[Type!]!`) 66 + 67 + ## 1.0.1 68 + 69 + - Initial release
+7
birdie_snapshots/execute_fragment_spread_on_non_null_type.accepted
···
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute fragment spread on NonNull type 4 + file: ./test/executor_test.gleam 5 + test_name: execute_fragment_spread_on_non_null_type_test 6 + --- 7 + Response(Object([#("user", Object([#("id", String("123")), #("name", String("Alice"))]))]), [])
+1 -1
gleam.toml
··· 1 name = "swell" 2 - version = "1.0.1" 3 description = "๐ŸŒŠ A GraphQL implementation in Gleam" 4 licences = ["Apache-2.0"] 5 repository = { type = "github", user = "bigmoves", repo = "swell" }
··· 1 name = "swell" 2 + version = "2.1.4" 3 description = "๐ŸŒŠ A GraphQL implementation in Gleam" 4 licences = ["Apache-2.0"] 5 repository = { type = "github", user = "bigmoves", repo = "swell" }
+323 -139
src/swell/executor.gleam
··· 2 /// 3 /// Executes GraphQL queries against a schema 4 import gleam/dict.{type Dict} 5 import gleam/list 6 import gleam/option.{None, Some} 7 import gleam/set.{type Set} ··· 20 Response(data: value.Value, errors: List(GraphQLError)) 21 } 22 23 /// Get the response key for a field (alias if present, otherwise field name) 24 fn response_key(field_name: String, alias: option.Option(String)) -> String { 25 case alias { ··· 39 Error(parse_error) -> 40 Error("Parse error: " <> format_parse_error(parse_error)) 41 Ok(document) -> { 42 // Execute the document 43 - case execute_document(document, graphql_schema, ctx) { 44 Ok(#(data, errors)) -> Ok(Response(data, errors)) 45 Error(err) -> Error(err) 46 } ··· 48 } 49 } 50 51 fn format_parse_error(err: parser.ParseError) -> String { 52 case err { 53 parser.UnexpectedToken(_, msg) -> msg ··· 61 document: parser.Document, 62 graphql_schema: schema.Schema, 63 ctx: schema.Context, 64 ) -> Result(#(value.Value, List(GraphQLError)), String) { 65 case document { 66 parser.Document(operations) -> { ··· 73 // Execute the first executable operation 74 case executable_ops { 75 [operation, ..] -> 76 - execute_operation(operation, graphql_schema, ctx, fragments_dict) 77 [] -> Error("No executable operations in document") 78 } 79 } ··· 112 graphql_schema: schema.Schema, 113 ctx: schema.Context, 114 fragments: Dict(String, parser.Operation), 115 ) -> Result(#(value.Value, List(GraphQLError)), String) { 116 case operation { 117 parser.Query(selection_set) -> { ··· 123 ctx, 124 fragments, 125 [], 126 ) 127 } 128 - parser.NamedQuery(_, _, selection_set) -> { 129 let root_type = schema.query_type(graphql_schema) 130 execute_selection_set( 131 selection_set, 132 root_type, 133 graphql_schema, 134 - ctx, 135 fragments, 136 [], 137 ) 138 } 139 parser.Mutation(selection_set) -> { ··· 147 ctx, 148 fragments, 149 [], 150 ) 151 option.None -> Error("Schema does not define a mutation type") 152 } 153 } 154 - parser.NamedMutation(_, _, selection_set) -> { 155 // Get mutation root type from schema 156 case schema.get_mutation_type(graphql_schema) { 157 - option.Some(mutation_type) -> 158 execute_selection_set( 159 selection_set, 160 mutation_type, 161 graphql_schema, 162 - ctx, 163 fragments, 164 [], 165 ) 166 option.None -> Error("Schema does not define a mutation type") 167 } 168 } ··· 177 ctx, 178 fragments, 179 [], 180 ) 181 option.None -> Error("Schema does not define a subscription type") 182 } 183 } 184 - parser.NamedSubscription(_, _, selection_set) -> { 185 // Get subscription root type from schema 186 case schema.get_subscription_type(graphql_schema) { 187 - option.Some(subscription_type) -> 188 execute_selection_set( 189 selection_set, 190 subscription_type, 191 graphql_schema, 192 - ctx, 193 fragments, 194 [], 195 ) 196 option.None -> Error("Schema does not define a subscription type") 197 } 198 } ··· 209 ctx: schema.Context, 210 fragments: Dict(String, parser.Operation), 211 path: List(String), 212 ) -> Result(#(value.Value, List(GraphQLError)), String) { 213 case selection_set { 214 parser.SelectionSet(selections) -> { ··· 221 ctx, 222 fragments, 223 path, 224 ) 225 }) 226 ··· 274 ctx: schema.Context, 275 fragments: Dict(String, parser.Operation), 276 path: List(String), 277 ) -> Result(#(String, value.Value, List(GraphQLError)), String) { 278 case selection { 279 parser.FragmentSpread(name) -> { ··· 285 type_condition, 286 fragment_selection_set, 287 )) -> { 288 - // Check type condition 289 - let current_type_name = schema.type_name(parent_type) 290 case type_condition == current_type_name { 291 False -> { 292 // Type condition doesn't match, skip this fragment ··· 303 ctx, 304 fragments, 305 path, 306 ) 307 { 308 Ok(#(value.Object(fields), errs)) -> { ··· 320 } 321 } 322 parser.InlineFragment(type_condition_opt, inline_selections) -> { 323 - // Check type condition if present 324 - let current_type_name = schema.type_name(parent_type) 325 let should_execute = case type_condition_opt { 326 None -> True 327 Some(type_condition) -> type_condition == current_type_name ··· 339 ctx, 340 fragments, 341 path, 342 ) 343 { 344 Ok(#(value.Object(fields), errs)) -> ··· 359 // Handle introspection meta-fields 360 case name { 361 "__typename" -> { 362 - let type_name = schema.type_name(parent_type) 363 Ok(#(key, value.String(type_name), [])) 364 } 365 "__schema" -> { ··· 456 Ok(#(key, value.Null, [error])) 457 } 458 Some(field) -> { 459 - // Get the field's type for nested selections 460 - let field_type_def = schema.field_type(field) 461 462 - // Create context with arguments (preserve variables from parent context) 463 - let field_ctx = schema.Context(ctx.data, args_dict, ctx.variables) 464 465 - // Resolve the field 466 - case schema.resolve_field(field, field_ctx) { 467 - Error(err) -> { 468 - let error = GraphQLError(err, [name, ..path]) 469 - Ok(#(key, value.Null, [error])) 470 - } 471 - Ok(field_value) -> { 472 - // If there are nested selections, recurse 473 - case nested_selections { 474 - [] -> Ok(#(key, field_value, [])) 475 - _ -> { 476 - // Need to resolve nested fields 477 - case field_value { 478 - value.Object(_) -> { 479 - // Check if field_type_def is a union type 480 - // If so, resolve it to the concrete type first 481 - let type_to_use = case 482 - schema.is_union(field_type_def) 483 - { 484 - True -> { 485 - // Create context with the field value for type resolution 486 - let resolve_ctx = 487 - schema.context(option.Some(field_value)) 488 - case 489 - schema.resolve_union_type( 490 - field_type_def, 491 - resolve_ctx, 492 - ) 493 { 494 - Ok(concrete_type) -> concrete_type 495 - Error(_) -> field_type_def 496 - // Fallback to union type if resolution fails 497 } 498 - } 499 - False -> field_type_def 500 - } 501 - 502 - // Execute nested selections using the resolved type 503 - // Create new context with this object's data 504 - let object_ctx = 505 - schema.context(option.Some(field_value)) 506 - let selection_set = 507 - parser.SelectionSet(nested_selections) 508 - case 509 - execute_selection_set( 510 - selection_set, 511 - type_to_use, 512 - graphql_schema, 513 - object_ctx, 514 - fragments, 515 - [name, ..path], 516 - ) 517 - { 518 - Ok(#(nested_data, nested_errors)) -> 519 - Ok(#(key, nested_data, nested_errors)) 520 - Error(err) -> { 521 - let error = GraphQLError(err, [name, ..path]) 522 - Ok(#(key, value.Null, [error])) 523 - } 524 - } 525 - } 526 - value.List(items) -> { 527 - // Handle list with nested selections 528 - // Get the inner type from the LIST wrapper, unwrapping NonNull if needed 529 - let inner_type = case 530 - schema.inner_type(field_type_def) 531 - { 532 - option.Some(t) -> { 533 - // If the result is still wrapped (NonNull), unwrap it too 534 - case schema.inner_type(t) { 535 - option.Some(unwrapped) -> unwrapped 536 - option.None -> t 537 - } 538 - } 539 - option.None -> field_type_def 540 - } 541 - 542 - // Execute nested selections on each item 543 - let selection_set = 544 - parser.SelectionSet(nested_selections) 545 - let results = 546 - list.map(items, fn(item) { 547 - // Check if inner_type is a union and resolve it 548 - let item_type = case schema.is_union(inner_type) { 549 True -> { 550 - // Create context with the item value for type resolution 551 let resolve_ctx = 552 - schema.context(option.Some(item)) 553 case 554 - schema.resolve_union_type( 555 - inner_type, 556 resolve_ctx, 557 ) 558 { 559 Ok(concrete_type) -> concrete_type 560 - Error(_) -> inner_type 561 // Fallback to union type if resolution fails 562 } 563 } 564 - False -> inner_type 565 } 566 567 - // Create context with this item's data 568 - let item_ctx = schema.context(option.Some(item)) 569 - execute_selection_set( 570 - selection_set, 571 - item_type, 572 - graphql_schema, 573 - item_ctx, 574 - fragments, 575 - [name, ..path], 576 - ) 577 - }) 578 - 579 - // Collect results and errors 580 - let processed_items = 581 - results 582 - |> list.filter_map(fn(r) { 583 - case r { 584 - Ok(#(val, _)) -> Ok(val) 585 - Error(_) -> Error(Nil) 586 } 587 - }) 588 - 589 - let all_errors = 590 - results 591 - |> list.flat_map(fn(r) { 592 - case r { 593 - Ok(#(_, errs)) -> errs 594 - Error(_) -> [] 595 } 596 - }) 597 598 - Ok(#(key, value.List(processed_items), all_errors)) 599 } 600 - _ -> Ok(#(key, field_value, [])) 601 } 602 } 603 } ··· 884 ctx: schema.Context, 885 ) -> value.Value { 886 case arg_value { 887 - parser.IntValue(s) -> value.String(s) 888 - parser.FloatValue(s) -> value.String(s) 889 parser.StringValue(s) -> value.String(s) 890 parser.BooleanValue(b) -> value.Boolean(b) 891 parser.NullValue -> value.Null ··· 925 } 926 }) 927 }
··· 2 /// 3 /// Executes GraphQL queries against a schema 4 import gleam/dict.{type Dict} 5 + import gleam/float 6 + import gleam/int 7 import gleam/list 8 import gleam/option.{None, Some} 9 import gleam/set.{type Set} ··· 22 Response(data: value.Value, errors: List(GraphQLError)) 23 } 24 25 + /// Merge variable defaults with provided variables 26 + /// Provided variables take precedence over defaults 27 + fn apply_variable_defaults( 28 + variables: List(parser.Variable), 29 + provided: Dict(String, value.Value), 30 + ctx: schema.Context, 31 + ) -> Dict(String, value.Value) { 32 + list.fold(variables, provided, fn(acc, var) { 33 + case var { 34 + parser.Variable(name, _, option.Some(default_val)) -> { 35 + // Only apply default if variable not already provided 36 + case dict.get(acc, name) { 37 + Ok(_) -> acc 38 + Error(_) -> { 39 + let val = argument_value_to_value(default_val, ctx) 40 + dict.insert(acc, name, val) 41 + } 42 + } 43 + } 44 + parser.Variable(_, _, option.None) -> acc 45 + } 46 + }) 47 + } 48 + 49 /// Get the response key for a field (alias if present, otherwise field name) 50 fn response_key(field_name: String, alias: option.Option(String)) -> String { 51 case alias { ··· 65 Error(parse_error) -> 66 Error("Parse error: " <> format_parse_error(parse_error)) 67 Ok(document) -> { 68 + // Build canonical type registry for union resolution 69 + // This ensures we use the most complete version of each type 70 + let type_registry = build_type_registry(graphql_schema) 71 + 72 // Execute the document 73 + case execute_document(document, graphql_schema, ctx, type_registry) { 74 Ok(#(data, errors)) -> Ok(Response(data, errors)) 75 Error(err) -> Error(err) 76 } ··· 78 } 79 } 80 81 + /// Build a canonical type registry from the schema 82 + /// Uses introspection's logic to get deduplicated types with most fields 83 + fn build_type_registry( 84 + graphql_schema: schema.Schema, 85 + ) -> Dict(String, schema.Type) { 86 + let all_types = introspection.get_all_schema_types(graphql_schema) 87 + introspection.build_type_map(all_types) 88 + } 89 + 90 fn format_parse_error(err: parser.ParseError) -> String { 91 case err { 92 parser.UnexpectedToken(_, msg) -> msg ··· 100 document: parser.Document, 101 graphql_schema: schema.Schema, 102 ctx: schema.Context, 103 + type_registry: Dict(String, schema.Type), 104 ) -> Result(#(value.Value, List(GraphQLError)), String) { 105 case document { 106 parser.Document(operations) -> { ··· 113 // Execute the first executable operation 114 case executable_ops { 115 [operation, ..] -> 116 + execute_operation( 117 + operation, 118 + graphql_schema, 119 + ctx, 120 + fragments_dict, 121 + type_registry, 122 + ) 123 [] -> Error("No executable operations in document") 124 } 125 } ··· 158 graphql_schema: schema.Schema, 159 ctx: schema.Context, 160 fragments: Dict(String, parser.Operation), 161 + type_registry: Dict(String, schema.Type), 162 ) -> Result(#(value.Value, List(GraphQLError)), String) { 163 case operation { 164 parser.Query(selection_set) -> { ··· 170 ctx, 171 fragments, 172 [], 173 + type_registry, 174 ) 175 } 176 + parser.NamedQuery(_name, variables, selection_set) -> { 177 let root_type = schema.query_type(graphql_schema) 178 + // Apply variable defaults 179 + let merged_vars = apply_variable_defaults(variables, ctx.variables, ctx) 180 + let ctx_with_defaults = 181 + schema.Context(ctx.data, ctx.arguments, merged_vars) 182 execute_selection_set( 183 selection_set, 184 root_type, 185 graphql_schema, 186 + ctx_with_defaults, 187 fragments, 188 [], 189 + type_registry, 190 ) 191 } 192 parser.Mutation(selection_set) -> { ··· 200 ctx, 201 fragments, 202 [], 203 + type_registry, 204 ) 205 option.None -> Error("Schema does not define a mutation type") 206 } 207 } 208 + parser.NamedMutation(_name, variables, selection_set) -> { 209 // Get mutation root type from schema 210 case schema.get_mutation_type(graphql_schema) { 211 + option.Some(mutation_type) -> { 212 + // Apply variable defaults 213 + let merged_vars = 214 + apply_variable_defaults(variables, ctx.variables, ctx) 215 + let ctx_with_defaults = 216 + schema.Context(ctx.data, ctx.arguments, merged_vars) 217 execute_selection_set( 218 selection_set, 219 mutation_type, 220 graphql_schema, 221 + ctx_with_defaults, 222 fragments, 223 [], 224 + type_registry, 225 ) 226 + } 227 option.None -> Error("Schema does not define a mutation type") 228 } 229 } ··· 238 ctx, 239 fragments, 240 [], 241 + type_registry, 242 ) 243 option.None -> Error("Schema does not define a subscription type") 244 } 245 } 246 + parser.NamedSubscription(_name, variables, selection_set) -> { 247 // Get subscription root type from schema 248 case schema.get_subscription_type(graphql_schema) { 249 + option.Some(subscription_type) -> { 250 + // Apply variable defaults 251 + let merged_vars = 252 + apply_variable_defaults(variables, ctx.variables, ctx) 253 + let ctx_with_defaults = 254 + schema.Context(ctx.data, ctx.arguments, merged_vars) 255 execute_selection_set( 256 selection_set, 257 subscription_type, 258 graphql_schema, 259 + ctx_with_defaults, 260 fragments, 261 [], 262 + type_registry, 263 ) 264 + } 265 option.None -> Error("Schema does not define a subscription type") 266 } 267 } ··· 278 ctx: schema.Context, 279 fragments: Dict(String, parser.Operation), 280 path: List(String), 281 + type_registry: Dict(String, schema.Type), 282 ) -> Result(#(value.Value, List(GraphQLError)), String) { 283 case selection_set { 284 parser.SelectionSet(selections) -> { ··· 291 ctx, 292 fragments, 293 path, 294 + type_registry, 295 ) 296 }) 297 ··· 345 ctx: schema.Context, 346 fragments: Dict(String, parser.Operation), 347 path: List(String), 348 + type_registry: Dict(String, schema.Type), 349 ) -> Result(#(String, value.Value, List(GraphQLError)), String) { 350 case selection { 351 parser.FragmentSpread(name) -> { ··· 357 type_condition, 358 fragment_selection_set, 359 )) -> { 360 + // Check type condition - use named_type_name to get the base type 361 + // without NonNull/List wrappers, since fragments are defined on named types 362 + let current_type_name = schema.named_type_name(parent_type) 363 case type_condition == current_type_name { 364 False -> { 365 // Type condition doesn't match, skip this fragment ··· 376 ctx, 377 fragments, 378 path, 379 + type_registry, 380 ) 381 { 382 Ok(#(value.Object(fields), errs)) -> { ··· 394 } 395 } 396 parser.InlineFragment(type_condition_opt, inline_selections) -> { 397 + // Check type condition if present - use named_type_name to get the base type 398 + // without NonNull/List wrappers, since fragments are defined on named types 399 + let current_type_name = schema.named_type_name(parent_type) 400 let should_execute = case type_condition_opt { 401 None -> True 402 Some(type_condition) -> type_condition == current_type_name ··· 414 ctx, 415 fragments, 416 path, 417 + type_registry, 418 ) 419 { 420 Ok(#(value.Object(fields), errs)) -> ··· 435 // Handle introspection meta-fields 436 case name { 437 "__typename" -> { 438 + // Use named_type_name to return the concrete type without modifiers 439 + let type_name = schema.named_type_name(parent_type) 440 Ok(#(key, value.String(type_name), [])) 441 } 442 "__schema" -> { ··· 533 Ok(#(key, value.Null, [error])) 534 } 535 Some(field) -> { 536 + // Validate argument types before resolving 537 + case validate_arguments(field, args_dict, [name, ..path]) { 538 + Error(err) -> Ok(#(key, value.Null, [err])) 539 + Ok(_) -> { 540 + // Get the field's type for nested selections 541 + let field_type_def = schema.field_type(field) 542 543 + // Create context with arguments (preserve variables from parent context) 544 + let field_ctx = 545 + schema.Context(ctx.data, args_dict, ctx.variables) 546 547 + // Resolve the field 548 + case schema.resolve_field(field, field_ctx) { 549 + Error(err) -> { 550 + let error = GraphQLError(err, [name, ..path]) 551 + Ok(#(key, value.Null, [error])) 552 + } 553 + Ok(field_value) -> { 554 + // If there are nested selections, recurse 555 + case nested_selections { 556 + [] -> Ok(#(key, field_value, [])) 557 + _ -> { 558 + // Need to resolve nested fields 559 + case field_value { 560 + value.Object(_) -> { 561 + // Check if field_type_def is a union type 562 + // If so, resolve it to the concrete type first using the registry 563 + // Need to unwrap NonNull first since is_union only matches bare UnionType 564 + let unwrapped_type = case 565 + schema.inner_type(field_type_def) 566 { 567 + option.Some(t) -> t 568 + option.None -> field_type_def 569 } 570 + let type_to_use = case 571 + schema.is_union(unwrapped_type) 572 + { 573 True -> { 574 + // Create context with the field value for type resolution 575 let resolve_ctx = 576 + schema.context(option.Some(field_value)) 577 case 578 + schema.resolve_union_type_with_registry( 579 + unwrapped_type, 580 resolve_ctx, 581 + type_registry, 582 ) 583 { 584 Ok(concrete_type) -> concrete_type 585 + Error(_) -> field_type_def 586 // Fallback to union type if resolution fails 587 } 588 } 589 + False -> field_type_def 590 } 591 592 + // Execute nested selections using the resolved type 593 + // Create new context with this object's data, preserving variables 594 + let object_ctx = 595 + schema.context_with_variables( 596 + option.Some(field_value), 597 + ctx.variables, 598 + ) 599 + let selection_set = 600 + parser.SelectionSet(nested_selections) 601 + case 602 + execute_selection_set( 603 + selection_set, 604 + type_to_use, 605 + graphql_schema, 606 + object_ctx, 607 + fragments, 608 + [name, ..path], 609 + type_registry, 610 + ) 611 + { 612 + Ok(#(nested_data, nested_errors)) -> 613 + Ok(#(key, nested_data, nested_errors)) 614 + Error(err) -> { 615 + let error = GraphQLError(err, [name, ..path]) 616 + Ok(#(key, value.Null, [error])) 617 + } 618 } 619 + } 620 + value.List(items) -> { 621 + // Handle list with nested selections 622 + // Get the inner type from the LIST wrapper, unwrapping NonNull if needed 623 + // Field type could be: NonNull[List[NonNull[Union]]] or List[NonNull[Union]] etc. 624 + let inner_type = case 625 + schema.inner_type(field_type_def) 626 + { 627 + option.Some(t) -> { 628 + // If the result is still wrapped (NonNull), unwrap it too 629 + case schema.inner_type(t) { 630 + option.Some(unwrapped) -> unwrapped 631 + option.None -> t 632 + } 633 + } 634 + option.None -> field_type_def 635 } 636 637 + // Execute nested selections on each item 638 + let selection_set = 639 + parser.SelectionSet(nested_selections) 640 + let results = 641 + list.map(items, fn(item) { 642 + // Check if inner_type is a union and resolve it using the registry 643 + // Need to unwrap NonNull to check for union since inner_type 644 + // could be NonNull[Union] after unwrapping List[NonNull[Union]] 645 + let unwrapped_inner = case 646 + schema.inner_type(inner_type) 647 + { 648 + option.Some(t) -> t 649 + option.None -> inner_type 650 + } 651 + let item_type = case 652 + schema.is_union(unwrapped_inner) 653 + { 654 + True -> { 655 + // Create context with the item value for type resolution 656 + let resolve_ctx = 657 + schema.context(option.Some(item)) 658 + case 659 + schema.resolve_union_type_with_registry( 660 + unwrapped_inner, 661 + resolve_ctx, 662 + type_registry, 663 + ) 664 + { 665 + Ok(concrete_type) -> concrete_type 666 + Error(_) -> inner_type 667 + // Fallback to union type if resolution fails 668 + } 669 + } 670 + False -> inner_type 671 + } 672 + 673 + // Create context with this item's data, preserving variables 674 + let item_ctx = 675 + schema.context_with_variables( 676 + option.Some(item), 677 + ctx.variables, 678 + ) 679 + execute_selection_set( 680 + selection_set, 681 + item_type, 682 + graphql_schema, 683 + item_ctx, 684 + fragments, 685 + [name, ..path], 686 + type_registry, 687 + ) 688 + }) 689 + 690 + // Collect results and errors 691 + let processed_items = 692 + results 693 + |> list.filter_map(fn(r) { 694 + case r { 695 + Ok(#(val, _)) -> Ok(val) 696 + Error(_) -> Error(Nil) 697 + } 698 + }) 699 + 700 + let all_errors = 701 + results 702 + |> list.flat_map(fn(r) { 703 + case r { 704 + Ok(#(_, errs)) -> errs 705 + Error(_) -> [] 706 + } 707 + }) 708 + 709 + Ok(#(key, value.List(processed_items), all_errors)) 710 + } 711 + _ -> Ok(#(key, field_value, [])) 712 + } 713 } 714 } 715 } 716 } ··· 997 ctx: schema.Context, 998 ) -> value.Value { 999 case arg_value { 1000 + parser.IntValue(s) -> { 1001 + case int.parse(s) { 1002 + Ok(n) -> value.Int(n) 1003 + Error(_) -> value.String(s) 1004 + } 1005 + } 1006 + parser.FloatValue(s) -> { 1007 + case float.parse(s) { 1008 + Ok(f) -> value.Float(f) 1009 + Error(_) -> value.String(s) 1010 + } 1011 + } 1012 parser.StringValue(s) -> value.String(s) 1013 parser.BooleanValue(b) -> value.Boolean(b) 1014 parser.NullValue -> value.Null ··· 1048 } 1049 }) 1050 } 1051 + 1052 + /// Validate that an argument value matches its declared type 1053 + fn validate_argument_type( 1054 + arg_name: String, 1055 + expected_type: schema.Type, 1056 + actual_value: value.Value, 1057 + path: List(String), 1058 + ) -> Result(Nil, GraphQLError) { 1059 + // Unwrap NonNull wrapper to get the base type (but not List wrapper) 1060 + let base_type = case schema.is_non_null(expected_type) { 1061 + True -> 1062 + case schema.inner_type(expected_type) { 1063 + option.Some(inner) -> inner 1064 + option.None -> expected_type 1065 + } 1066 + False -> expected_type 1067 + } 1068 + 1069 + case schema.is_list(base_type), actual_value { 1070 + // List type expects a list value - reject object 1071 + True, value.Object(_) -> 1072 + Error(GraphQLError( 1073 + "Argument '" 1074 + <> arg_name 1075 + <> "' expects a list, not an object. Use [" 1076 + <> arg_name 1077 + <> ": {...}] instead of " 1078 + <> arg_name 1079 + <> ": {...}", 1080 + path, 1081 + )) 1082 + // All other cases are ok (for now - can add more validation later) 1083 + _, _ -> Ok(Nil) 1084 + } 1085 + } 1086 + 1087 + /// Validate all provided arguments against a field's declared argument types 1088 + fn validate_arguments( 1089 + field: schema.Field, 1090 + args_dict: Dict(String, value.Value), 1091 + path: List(String), 1092 + ) -> Result(Nil, GraphQLError) { 1093 + let declared_args = schema.field_arguments(field) 1094 + 1095 + // For each provided argument, check if it matches the declared type 1096 + list.try_each(dict.to_list(args_dict), fn(arg_pair) { 1097 + let #(arg_name, arg_value) = arg_pair 1098 + 1099 + // Find the declared argument 1100 + case 1101 + list.find(declared_args, fn(a) { schema.argument_name(a) == arg_name }) 1102 + { 1103 + Ok(declared_arg) -> { 1104 + let expected_type = schema.argument_type(declared_arg) 1105 + validate_argument_type(arg_name, expected_type, arg_value, path) 1106 + } 1107 + Error(_) -> Ok(Nil) 1108 + // Unknown argument - let schema handle it 1109 + } 1110 + }) 1111 + }
+78 -16
src/swell/introspection.gleam
··· 6 import gleam/list 7 import gleam/option 8 import gleam/result 9 import swell/schema 10 import swell/value 11 ··· 101 !list.contains(collected_names, built_in_name) 102 }) 103 104 - list.append(unique_types, missing_built_ins) 105 } 106 107 /// Get all types from the schema 108 fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) { 109 let all_types = get_all_schema_types(graphql_schema) 110 111 - // Convert all types to introspection values 112 - list.map(all_types, type_introspection) 113 } 114 115 /// Deduplicate types by name, keeping the version with the most fields ··· 182 schema.is_object(t) || schema.is_enum(t) || schema.is_union(t) 183 { 184 True -> { 185 - let current_content_count = get_type_content_count(t) 186 let existing_with_same_name = 187 list.filter(acc, fn(existing) { 188 schema.type_name(existing) == schema.type_name(t) 189 }) 190 - let max_existing_content = 191 - existing_with_same_name 192 - |> list.map(get_type_content_count) 193 - |> list.reduce(fn(a, b) { 194 - case a > b { 195 - True -> a 196 - False -> b 197 - } 198 - }) 199 - |> result.unwrap(0) 200 201 - // Only traverse if this instance has more content than we've seen before 202 - current_content_count > max_existing_content 203 } 204 False -> True 205 } ··· 323 desc -> value.String(desc) 324 } 325 326 value.Object([ 327 #("kind", value.String(kind)), 328 #("name", name), ··· 333 #("enumValues", enum_values), 334 #("inputFields", input_fields), 335 #("ofType", of_type), 336 ]) 337 } 338
··· 6 import gleam/list 7 import gleam/option 8 import gleam/result 9 + import gleam/string 10 import swell/schema 11 import swell/value 12 ··· 102 !list.contains(collected_names, built_in_name) 103 }) 104 105 + let all_types = list.append(unique_types, missing_built_ins) 106 + 107 + // Build a canonical type map for normalization 108 + let type_map = build_type_map(all_types) 109 + 110 + // Normalize union types so their possible_types reference canonical instances 111 + list.map(all_types, fn(t) { normalize_union_possible_types(t, type_map) }) 112 + } 113 + 114 + /// Build a map from type name to canonical type instance 115 + /// This creates a registry that can be used to look up types by name, 116 + /// ensuring consistent type references throughout the system. 117 + pub fn build_type_map( 118 + types: List(schema.Type), 119 + ) -> dict.Dict(String, schema.Type) { 120 + list.fold(types, dict.new(), fn(acc, t) { 121 + dict.insert(acc, schema.type_name(t), t) 122 + }) 123 + } 124 + 125 + /// For union types, replace possible_types with canonical instances from the type map 126 + /// This ensures union.possible_types returns the same instances as found elsewhere 127 + fn normalize_union_possible_types( 128 + t: schema.Type, 129 + type_map: dict.Dict(String, schema.Type), 130 + ) -> schema.Type { 131 + case schema.is_union(t) { 132 + True -> { 133 + let original_possible = schema.get_possible_types(t) 134 + let normalized_possible = 135 + list.filter_map(original_possible, fn(pt) { 136 + dict.get(type_map, schema.type_name(pt)) 137 + }) 138 + schema.union_type( 139 + schema.type_name(t), 140 + schema.type_description(t), 141 + normalized_possible, 142 + schema.get_union_type_resolver(t), 143 + ) 144 + } 145 + False -> t 146 + } 147 } 148 149 /// Get all types from the schema 150 fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) { 151 let all_types = get_all_schema_types(graphql_schema) 152 153 + // Sort types alphabetically by name, then convert to introspection values 154 + all_types 155 + |> list.sort(fn(a, b) { 156 + string.compare(schema.type_name(a), schema.type_name(b)) 157 + }) 158 + |> list.map(type_introspection) 159 } 160 161 /// Deduplicate types by name, keeping the version with the most fields ··· 228 schema.is_object(t) || schema.is_enum(t) || schema.is_union(t) 229 { 230 True -> { 231 let existing_with_same_name = 232 list.filter(acc, fn(existing) { 233 schema.type_name(existing) == schema.type_name(t) 234 }) 235 + 236 + // If this is the first time we've seen this type name, always traverse 237 + // This is critical for unions which have 0 content count but still need 238 + // their possible_types to be traversed 239 + case list.is_empty(existing_with_same_name) { 240 + True -> True 241 + False -> { 242 + // For subsequent encounters, only traverse if this instance has more content 243 + let current_content_count = get_type_content_count(t) 244 + let max_existing_content = 245 + existing_with_same_name 246 + |> list.map(get_type_content_count) 247 + |> list.reduce(fn(a, b) { 248 + case a > b { 249 + True -> a 250 + False -> b 251 + } 252 + }) 253 + |> result.unwrap(0) 254 255 + current_content_count > max_existing_content 256 + } 257 + } 258 } 259 False -> True 260 } ··· 378 desc -> value.String(desc) 379 } 380 381 + // isOneOf for INPUT_OBJECT types (GraphQL spec addition) 382 + let is_one_of = case kind { 383 + "INPUT_OBJECT" -> value.Boolean(False) 384 + _ -> value.Null 385 + } 386 + 387 value.Object([ 388 #("kind", value.String(kind)), 389 #("name", name), ··· 394 #("enumValues", enum_values), 395 #("inputFields", input_fields), 396 #("ofType", of_type), 397 + #("isOneOf", is_one_of), 398 ]) 399 } 400
+64 -15
src/swell/parser.gleam
··· 72 73 /// Variable definition 74 pub type Variable { 75 - Variable(name: String, type_: String) 76 } 77 78 pub type ParseError { ··· 543 // Skip commas 544 [lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc) 545 546 - // Parse a variable: $name: Type! or $name: Type 547 [lexer.Dollar, lexer.Name(var_name), lexer.Colon, ..rest] -> { 548 - // Parse the type (Name or Name!) 549 - case rest { 550 - [lexer.Name(type_name), lexer.Exclamation, ..rest2] -> { 551 - // Non-null type 552 - let variable = Variable(var_name, type_name <> "!") 553 - parse_variable_definitions_loop(rest2, [variable, ..acc]) 554 } 555 - [lexer.Name(type_name), ..rest2] -> { 556 - // Nullable type 557 - let variable = Variable(var_name, type_name) 558 - parse_variable_definitions_loop(rest2, [variable, ..acc]) 559 - } 560 - [] -> Error(UnexpectedEndOfInput("Expected type after :")) 561 - [token, ..] -> Error(UnexpectedToken(token, "Expected type name")) 562 } 563 } 564 ··· 566 [token, ..] -> Error(UnexpectedToken(token, "Expected $variableName or )")) 567 } 568 }
··· 72 73 /// Variable definition 74 pub type Variable { 75 + Variable(name: String, type_: String, default_value: Option(ArgumentValue)) 76 } 77 78 pub type ParseError { ··· 543 // Skip commas 544 [lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc) 545 546 + // Parse a variable: $name: Type = defaultValue or $name: Type 547 [lexer.Dollar, lexer.Name(var_name), lexer.Colon, ..rest] -> { 548 + // Parse the type 549 + case parse_type_reference(rest) { 550 + Ok(#(type_str, rest2)) -> { 551 + // Check for default value 552 + case rest2 { 553 + [lexer.Equals, ..rest3] -> { 554 + // Parse the default value 555 + case parse_argument_value(rest3) { 556 + Ok(#(default_val, rest4)) -> { 557 + let variable = Variable(var_name, type_str, Some(default_val)) 558 + parse_variable_definitions_loop(rest4, [variable, ..acc]) 559 + } 560 + Error(err) -> Error(err) 561 + } 562 + } 563 + _ -> { 564 + // No default value 565 + let variable = Variable(var_name, type_str, None) 566 + parse_variable_definitions_loop(rest2, [variable, ..acc]) 567 + } 568 + } 569 } 570 + Error(err) -> Error(err) 571 } 572 } 573 ··· 575 [token, ..] -> Error(UnexpectedToken(token, "Expected $variableName or )")) 576 } 577 } 578 + 579 + /// Parse a type reference (e.g., String, String!, [String], [String!], [String]!, [String!]!) 580 + fn parse_type_reference( 581 + tokens: List(lexer.Token), 582 + ) -> Result(#(String, List(lexer.Token)), ParseError) { 583 + case tokens { 584 + // List type: [Type] or [Type!] or [Type]! or [Type!]! 585 + [lexer.BracketOpen, ..rest] -> { 586 + case rest { 587 + [ 588 + lexer.Name(inner_type), 589 + lexer.Exclamation, 590 + lexer.BracketClose, 591 + lexer.Exclamation, 592 + ..rest2 593 + ] -> 594 + // [Type!]! 595 + Ok(#("[" <> inner_type <> "!]!", rest2)) 596 + [lexer.Name(inner_type), lexer.Exclamation, lexer.BracketClose, ..rest2] -> 597 + // [Type!] 598 + Ok(#("[" <> inner_type <> "!]", rest2)) 599 + [lexer.Name(inner_type), lexer.BracketClose, lexer.Exclamation, ..rest2] -> 600 + // [Type]! 601 + Ok(#("[" <> inner_type <> "]!", rest2)) 602 + [lexer.Name(inner_type), lexer.BracketClose, ..rest2] -> 603 + // [Type] 604 + Ok(#("[" <> inner_type <> "]", rest2)) 605 + [] -> Error(UnexpectedEndOfInput("Expected type name in list")) 606 + [token, ..] -> 607 + Error(UnexpectedToken(token, "Expected type name in list")) 608 + } 609 + } 610 + // Simple type: Type! or Type 611 + [lexer.Name(type_name), lexer.Exclamation, ..rest] -> 612 + Ok(#(type_name <> "!", rest)) 613 + [lexer.Name(type_name), ..rest] -> Ok(#(type_name, rest)) 614 + [] -> Error(UnexpectedEndOfInput("Expected type")) 615 + [token, ..] -> Error(UnexpectedToken(token, "Expected type name")) 616 + } 617 + }
+59
src/swell/schema.gleam
··· 239 } 240 } 241 242 pub fn field_name(f: Field) -> String { 243 case f { 244 Field(name, _, _, _, _) -> name ··· 456 } 457 } 458 459 /// Resolve a union type to its concrete type using the type resolver 460 pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) { 461 case t { ··· 475 "Type resolver returned '" 476 <> resolved_type_name 477 <> "' which is not a possible type of this union", 478 ) 479 } 480 }
··· 239 } 240 } 241 242 + /// Get the named (base) type name, unwrapping List and NonNull wrappers. 243 + /// This is used for fragment type condition matching and __typename where 244 + /// we need the concrete type name without modifiers. 245 + pub fn named_type_name(t: Type) -> String { 246 + case t { 247 + ScalarType(name) -> name 248 + ObjectType(name, _, _) -> name 249 + InputObjectType(name, _, _) -> name 250 + EnumType(name, _, _) -> name 251 + UnionType(name, _, _, _) -> name 252 + ListType(inner) -> named_type_name(inner) 253 + NonNullType(inner) -> named_type_name(inner) 254 + } 255 + } 256 + 257 pub fn field_name(f: Field) -> String { 258 case f { 259 Field(name, _, _, _, _) -> name ··· 471 } 472 } 473 474 + /// Get the type resolver function from a union type. 475 + /// The type resolver is called at runtime to determine which concrete type 476 + /// a union value represents, based on the data in the context. 477 + /// Returns a default resolver that always errors for non-union types. 478 + /// This is primarily used internally for union type normalization. 479 + pub fn get_union_type_resolver(t: Type) -> fn(Context) -> Result(String, String) { 480 + case t { 481 + UnionType(_, _, _, type_resolver) -> type_resolver 482 + _ -> fn(_) { Error("Not a union type") } 483 + } 484 + } 485 + 486 /// Resolve a union type to its concrete type using the type resolver 487 pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) { 488 case t { ··· 502 "Type resolver returned '" 503 <> resolved_type_name 504 <> "' which is not a possible type of this union", 505 + ) 506 + } 507 + } 508 + Error(err) -> Error(err) 509 + } 510 + } 511 + _ -> Error("Cannot resolve non-union type") 512 + } 513 + } 514 + 515 + /// Resolve a union type using a canonical type registry 516 + /// This looks up the concrete type by name from the registry instead of from 517 + /// the union's possible_types, ensuring we get the most complete type definition 518 + pub fn resolve_union_type_with_registry( 519 + t: Type, 520 + ctx: Context, 521 + type_registry: dict.Dict(String, Type), 522 + ) -> Result(Type, String) { 523 + case t { 524 + UnionType(_, _, _, type_resolver) -> { 525 + // Call the type resolver to get the concrete type name 526 + case type_resolver(ctx) { 527 + Ok(resolved_type_name) -> { 528 + // Look up the type from the canonical registry 529 + case dict.get(type_registry, resolved_type_name) { 530 + Ok(concrete_type) -> Ok(concrete_type) 531 + Error(_) -> 532 + Error( 533 + "Type resolver returned '" 534 + <> resolved_type_name 535 + <> "' which is not in the type registry. " 536 + <> "This may indicate the schema is incomplete or the type resolver is misconfigured.", 537 ) 538 } 539 }
+804
test/executor_test.gleam
··· 206 ) 207 } 208 209 // Test for list fields with nested selections 210 pub fn execute_list_with_nested_selections_test() { 211 // Create a schema with a list field ··· 865 content: format_response(response), 866 ) 867 }
··· 206 ) 207 } 208 209 + // Test for fragment spread on NonNull wrapped type 210 + pub fn execute_fragment_spread_on_non_null_type_test() { 211 + // Create a schema where the user field returns a NonNull type 212 + let user_type = 213 + schema.object_type("User", "A user", [ 214 + schema.field("id", schema.id_type(), "User ID", fn(_ctx) { 215 + Ok(value.String("123")) 216 + }), 217 + schema.field("name", schema.string_type(), "User name", fn(_ctx) { 218 + Ok(value.String("Alice")) 219 + }), 220 + ]) 221 + 222 + let query_type = 223 + schema.object_type("Query", "Root query type", [ 224 + // Wrap user_type in NonNull to test fragment type condition matching 225 + schema.field("user", schema.non_null(user_type), "Get user", fn(_ctx) { 226 + Ok( 227 + value.Object([ 228 + #("id", value.String("123")), 229 + #("name", value.String("Alice")), 230 + ]), 231 + ) 232 + }), 233 + ]) 234 + 235 + let test_schema = schema.schema(query_type, None) 236 + 237 + // Fragment is defined on "User" (not "User!") - this should still work 238 + let query = 239 + " 240 + fragment UserFields on User { 241 + id 242 + name 243 + } 244 + 245 + { user { ...UserFields } } 246 + " 247 + 248 + let result = executor.execute(query, test_schema, schema.context(None)) 249 + 250 + let response = case result { 251 + Ok(r) -> r 252 + Error(_) -> panic as "Execution failed" 253 + } 254 + 255 + birdie.snap( 256 + title: "Execute fragment spread on NonNull type", 257 + content: format_response(response), 258 + ) 259 + } 260 + 261 // Test for list fields with nested selections 262 pub fn execute_list_with_nested_selections_test() { 263 // Create a schema with a list field ··· 917 content: format_response(response), 918 ) 919 } 920 + 921 + pub fn execute_query_with_variable_default_value_test() { 922 + let query_type = 923 + schema.object_type("Query", "Root query type", [ 924 + schema.field_with_args( 925 + "greet", 926 + schema.string_type(), 927 + "Greet someone", 928 + [schema.argument("name", schema.string_type(), "Name to greet", None)], 929 + fn(ctx) { 930 + case schema.get_argument(ctx, "name") { 931 + option.Some(value.String(name)) -> 932 + Ok(value.String("Hello, " <> name <> "!")) 933 + _ -> Ok(value.String("Hello, stranger!")) 934 + } 935 + }, 936 + ), 937 + ]) 938 + 939 + let test_schema = schema.schema(query_type, None) 940 + let query = "query Test($name: String = \"World\") { greet(name: $name) }" 941 + 942 + // No variables provided - should use default 943 + let ctx = schema.context_with_variables(None, dict.new()) 944 + 945 + let result = executor.execute(query, test_schema, ctx) 946 + 947 + // Debug: print the actual result 948 + let assert Ok(response) = result 949 + let assert executor.Response(data: value.Object(fields), errors: _) = response 950 + let assert Ok(greet_value) = list.key_find(fields, "greet") 951 + 952 + // Should use default value "World" since no variable provided 953 + greet_value 954 + |> should.equal(value.String("Hello, World!")) 955 + } 956 + 957 + pub fn execute_query_with_variable_overriding_default_test() { 958 + let query_type = 959 + schema.object_type("Query", "Root query type", [ 960 + schema.field_with_args( 961 + "greet", 962 + schema.string_type(), 963 + "Greet someone", 964 + [schema.argument("name", schema.string_type(), "Name to greet", None)], 965 + fn(ctx) { 966 + case schema.get_argument(ctx, "name") { 967 + option.Some(value.String(name)) -> 968 + Ok(value.String("Hello, " <> name <> "!")) 969 + _ -> Ok(value.String("Hello, stranger!")) 970 + } 971 + }, 972 + ), 973 + ]) 974 + 975 + let test_schema = schema.schema(query_type, None) 976 + let query = "query Test($name: String = \"World\") { greet(name: $name) }" 977 + 978 + // Provide variable - should override default 979 + let variables = dict.from_list([#("name", value.String("Alice"))]) 980 + let ctx = schema.context_with_variables(None, variables) 981 + 982 + let result = executor.execute(query, test_schema, ctx) 983 + result 984 + |> should.be_ok 985 + |> fn(response) { 986 + case response { 987 + executor.Response(data: value.Object(fields), errors: _) -> { 988 + case list.key_find(fields, "greet") { 989 + Ok(value.String("Hello, Alice!")) -> True 990 + _ -> False 991 + } 992 + } 993 + _ -> False 994 + } 995 + } 996 + |> should.be_true 997 + } 998 + 999 + // Test: List argument rejects object value 1000 + pub fn list_argument_rejects_object_test() { 1001 + // Create a schema with a field that has a list argument 1002 + let list_arg_field = 1003 + schema.field_with_args( 1004 + "items", 1005 + schema.string_type(), 1006 + "Test field with list arg", 1007 + [ 1008 + schema.argument( 1009 + "ids", 1010 + schema.list_type(schema.string_type()), 1011 + "List of IDs", 1012 + None, 1013 + ), 1014 + ], 1015 + fn(_ctx) { Ok(value.String("test")) }, 1016 + ) 1017 + 1018 + let query_type = schema.object_type("Query", "Root", [list_arg_field]) 1019 + let s = schema.schema(query_type, None) 1020 + 1021 + // Query with object instead of list should produce an error 1022 + let query = "{ items(ids: {foo: \"bar\"}) }" 1023 + let result = executor.execute(query, s, schema.context(None)) 1024 + 1025 + case result { 1026 + Ok(executor.Response(_, errors)) -> { 1027 + // Should have exactly one error 1028 + list.length(errors) 1029 + |> should.equal(1) 1030 + 1031 + // Check error message mentions list vs object 1032 + case list.first(errors) { 1033 + Ok(executor.GraphQLError(message, _)) -> { 1034 + string.contains(message, "expects a list") 1035 + |> should.be_true 1036 + string.contains(message, "not an object") 1037 + |> should.be_true 1038 + } 1039 + Error(_) -> should.fail() 1040 + } 1041 + } 1042 + Error(_) -> should.fail() 1043 + } 1044 + } 1045 + 1046 + // Test: List argument accepts list value (sanity check) 1047 + pub fn list_argument_accepts_list_test() { 1048 + // Create a schema with a field that has a list argument 1049 + let list_arg_field = 1050 + schema.field_with_args( 1051 + "items", 1052 + schema.string_type(), 1053 + "Test field with list arg", 1054 + [ 1055 + schema.argument( 1056 + "ids", 1057 + schema.list_type(schema.string_type()), 1058 + "List of IDs", 1059 + None, 1060 + ), 1061 + ], 1062 + fn(_ctx) { Ok(value.String("success")) }, 1063 + ) 1064 + 1065 + let query_type = schema.object_type("Query", "Root", [list_arg_field]) 1066 + let s = schema.schema(query_type, None) 1067 + 1068 + // Query with proper list should work 1069 + let query = "{ items(ids: [\"a\", \"b\"]) }" 1070 + let result = executor.execute(query, s, schema.context(None)) 1071 + 1072 + case result { 1073 + Ok(executor.Response(value.Object(fields), errors)) -> { 1074 + // Should have no errors 1075 + list.length(errors) 1076 + |> should.equal(0) 1077 + 1078 + // Should return the value 1079 + case list.key_find(fields, "items") { 1080 + Ok(value.String("success")) -> should.be_true(True) 1081 + _ -> should.fail() 1082 + } 1083 + } 1084 + _ -> should.fail() 1085 + } 1086 + } 1087 + 1088 + // Test: Union resolution uses canonical type registry 1089 + // This verifies that when resolving a union type, all fields from the 1090 + // canonical type definition are accessible, not just those from the 1091 + // union's internal possible_types copy 1092 + pub fn execute_union_with_all_fields_via_registry_test() { 1093 + // Create a type with multiple fields to verify complete resolution 1094 + let article_type = 1095 + schema.object_type("Article", "An article", [ 1096 + schema.field("id", schema.id_type(), "Article ID", fn(ctx) { 1097 + case ctx.data { 1098 + option.Some(value.Object(fields)) -> { 1099 + case list.key_find(fields, "id") { 1100 + Ok(id_val) -> Ok(id_val) 1101 + Error(_) -> Ok(value.Null) 1102 + } 1103 + } 1104 + _ -> Ok(value.Null) 1105 + } 1106 + }), 1107 + schema.field("title", schema.string_type(), "Article title", fn(ctx) { 1108 + case ctx.data { 1109 + option.Some(value.Object(fields)) -> { 1110 + case list.key_find(fields, "title") { 1111 + Ok(title_val) -> Ok(title_val) 1112 + Error(_) -> Ok(value.Null) 1113 + } 1114 + } 1115 + _ -> Ok(value.Null) 1116 + } 1117 + }), 1118 + schema.field("body", schema.string_type(), "Article body", fn(ctx) { 1119 + case ctx.data { 1120 + option.Some(value.Object(fields)) -> { 1121 + case list.key_find(fields, "body") { 1122 + Ok(body_val) -> Ok(body_val) 1123 + Error(_) -> Ok(value.Null) 1124 + } 1125 + } 1126 + _ -> Ok(value.Null) 1127 + } 1128 + }), 1129 + schema.field("author", schema.string_type(), "Article author", fn(ctx) { 1130 + case ctx.data { 1131 + option.Some(value.Object(fields)) -> { 1132 + case list.key_find(fields, "author") { 1133 + Ok(author_val) -> Ok(author_val) 1134 + Error(_) -> Ok(value.Null) 1135 + } 1136 + } 1137 + _ -> Ok(value.Null) 1138 + } 1139 + }), 1140 + ]) 1141 + 1142 + let video_type = 1143 + schema.object_type("Video", "A video", [ 1144 + schema.field("id", schema.id_type(), "Video ID", fn(ctx) { 1145 + case ctx.data { 1146 + option.Some(value.Object(fields)) -> { 1147 + case list.key_find(fields, "id") { 1148 + Ok(id_val) -> Ok(id_val) 1149 + Error(_) -> Ok(value.Null) 1150 + } 1151 + } 1152 + _ -> Ok(value.Null) 1153 + } 1154 + }), 1155 + schema.field("title", schema.string_type(), "Video title", fn(ctx) { 1156 + case ctx.data { 1157 + option.Some(value.Object(fields)) -> { 1158 + case list.key_find(fields, "title") { 1159 + Ok(title_val) -> Ok(title_val) 1160 + Error(_) -> Ok(value.Null) 1161 + } 1162 + } 1163 + _ -> Ok(value.Null) 1164 + } 1165 + }), 1166 + schema.field("duration", schema.int_type(), "Video duration", fn(ctx) { 1167 + case ctx.data { 1168 + option.Some(value.Object(fields)) -> { 1169 + case list.key_find(fields, "duration") { 1170 + Ok(duration_val) -> Ok(duration_val) 1171 + Error(_) -> Ok(value.Null) 1172 + } 1173 + } 1174 + _ -> Ok(value.Null) 1175 + } 1176 + }), 1177 + ]) 1178 + 1179 + // Type resolver that examines the __typename field 1180 + let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 1181 + case ctx.data { 1182 + option.Some(value.Object(fields)) -> { 1183 + case list.key_find(fields, "__typename") { 1184 + Ok(value.String(type_name)) -> Ok(type_name) 1185 + _ -> Error("No __typename field found") 1186 + } 1187 + } 1188 + _ -> Error("No data") 1189 + } 1190 + } 1191 + 1192 + // Create union type 1193 + let content_union = 1194 + schema.union_type( 1195 + "Content", 1196 + "Content union", 1197 + [article_type, video_type], 1198 + type_resolver, 1199 + ) 1200 + 1201 + // Create query type with a field returning the union 1202 + let query_type = 1203 + schema.object_type("Query", "Root query type", [ 1204 + schema.field("content", content_union, "Get content", fn(_ctx) { 1205 + // Return an Article with all fields populated 1206 + Ok( 1207 + value.Object([ 1208 + #("__typename", value.String("Article")), 1209 + #("id", value.String("article-1")), 1210 + #("title", value.String("GraphQL Best Practices")), 1211 + #("body", value.String("Here are some tips...")), 1212 + #("author", value.String("Jane Doe")), 1213 + ]), 1214 + ) 1215 + }), 1216 + ]) 1217 + 1218 + let test_schema = schema.schema(query_type, None) 1219 + 1220 + // Query requesting ALL fields from the Article type 1221 + // If the type registry works correctly, all 4 fields should be returned 1222 + let query = 1223 + " 1224 + { 1225 + content { 1226 + ... on Article { 1227 + id 1228 + title 1229 + body 1230 + author 1231 + } 1232 + ... on Video { 1233 + id 1234 + title 1235 + duration 1236 + } 1237 + } 1238 + } 1239 + " 1240 + 1241 + let result = executor.execute(query, test_schema, schema.context(None)) 1242 + 1243 + case result { 1244 + Ok(executor.Response(value.Object(fields), errors)) -> { 1245 + // Should have no errors 1246 + list.length(errors) 1247 + |> should.equal(0) 1248 + 1249 + // Check the content field 1250 + case list.key_find(fields, "content") { 1251 + Ok(value.Object(content_fields)) -> { 1252 + // Should have all 4 Article fields 1253 + list.length(content_fields) 1254 + |> should.equal(4) 1255 + 1256 + // Verify each field is present with correct value 1257 + case list.key_find(content_fields, "id") { 1258 + Ok(value.String("article-1")) -> should.be_true(True) 1259 + _ -> should.fail() 1260 + } 1261 + case list.key_find(content_fields, "title") { 1262 + Ok(value.String("GraphQL Best Practices")) -> should.be_true(True) 1263 + _ -> should.fail() 1264 + } 1265 + case list.key_find(content_fields, "body") { 1266 + Ok(value.String("Here are some tips...")) -> should.be_true(True) 1267 + _ -> should.fail() 1268 + } 1269 + case list.key_find(content_fields, "author") { 1270 + Ok(value.String("Jane Doe")) -> should.be_true(True) 1271 + _ -> should.fail() 1272 + } 1273 + } 1274 + _ -> should.fail() 1275 + } 1276 + } 1277 + Error(err) -> { 1278 + // Print error for debugging 1279 + should.equal(err, "") 1280 + } 1281 + _ -> should.fail() 1282 + } 1283 + } 1284 + 1285 + // Test: Variables are preserved in nested object selections 1286 + // This verifies that when traversing into a nested object, variables 1287 + // from the parent context are still accessible 1288 + pub fn execute_variables_preserved_in_nested_object_test() { 1289 + // Create a nested type structure where the nested resolver needs access to variables 1290 + let post_type = 1291 + schema.object_type("Post", "A post", [ 1292 + schema.field("title", schema.string_type(), "Post title", fn(ctx) { 1293 + case ctx.data { 1294 + option.Some(value.Object(fields)) -> { 1295 + case list.key_find(fields, "title") { 1296 + Ok(title_val) -> Ok(title_val) 1297 + Error(_) -> Ok(value.Null) 1298 + } 1299 + } 1300 + _ -> Ok(value.Null) 1301 + } 1302 + }), 1303 + // This field uses a variable from the outer context 1304 + schema.field_with_args( 1305 + "formattedTitle", 1306 + schema.string_type(), 1307 + "Formatted title", 1308 + [schema.argument("prefix", schema.string_type(), "Prefix to add", None)], 1309 + fn(ctx) { 1310 + let prefix = case schema.get_argument(ctx, "prefix") { 1311 + Some(value.String(p)) -> p 1312 + _ -> "" 1313 + } 1314 + case ctx.data { 1315 + option.Some(value.Object(fields)) -> { 1316 + case list.key_find(fields, "title") { 1317 + Ok(value.String(title)) -> 1318 + Ok(value.String(prefix <> ": " <> title)) 1319 + _ -> Ok(value.Null) 1320 + } 1321 + } 1322 + _ -> Ok(value.Null) 1323 + } 1324 + }, 1325 + ), 1326 + ]) 1327 + 1328 + let query_type = 1329 + schema.object_type("Query", "Root query type", [ 1330 + schema.field("post", post_type, "Get a post", fn(_ctx) { 1331 + Ok(value.Object([#("title", value.String("Hello World"))])) 1332 + }), 1333 + ]) 1334 + 1335 + let test_schema = schema.schema(query_type, None) 1336 + 1337 + // Query using a variable in a nested field 1338 + let query = 1339 + "query GetPost($prefix: String!) { post { formattedTitle(prefix: $prefix) } }" 1340 + 1341 + // Create context with variables 1342 + let variables = dict.from_list([#("prefix", value.String("Article"))]) 1343 + let ctx = schema.context_with_variables(None, variables) 1344 + 1345 + let result = executor.execute(query, test_schema, ctx) 1346 + 1347 + case result { 1348 + Ok(executor.Response(data: value.Object(fields), errors: _)) -> { 1349 + case list.key_find(fields, "post") { 1350 + Ok(value.Object(post_fields)) -> { 1351 + case list.key_find(post_fields, "formattedTitle") { 1352 + Ok(value.String("Article: Hello World")) -> should.be_true(True) 1353 + Ok(other) -> { 1354 + // Variable was lost - this is the bug we're testing for 1355 + should.equal(other, value.String("Article: Hello World")) 1356 + } 1357 + Error(_) -> should.fail() 1358 + } 1359 + } 1360 + _ -> should.fail() 1361 + } 1362 + } 1363 + Error(err) -> should.equal(err, "") 1364 + _ -> should.fail() 1365 + } 1366 + } 1367 + 1368 + // Test: Variables are preserved in nested list item selections 1369 + // This verifies that when iterating over list items, variables 1370 + // from the parent context are still accessible to each item's resolvers 1371 + pub fn execute_variables_preserved_in_nested_list_test() { 1372 + // Create a type structure where list item resolvers need access to variables 1373 + let item_type = 1374 + schema.object_type("Item", "An item", [ 1375 + schema.field("name", schema.string_type(), "Item name", fn(ctx) { 1376 + case ctx.data { 1377 + option.Some(value.Object(fields)) -> { 1378 + case list.key_find(fields, "name") { 1379 + Ok(name_val) -> Ok(name_val) 1380 + Error(_) -> Ok(value.Null) 1381 + } 1382 + } 1383 + _ -> Ok(value.Null) 1384 + } 1385 + }), 1386 + // This field uses a variable from the outer context 1387 + schema.field_with_args( 1388 + "formattedName", 1389 + schema.string_type(), 1390 + "Formatted name", 1391 + [schema.argument("suffix", schema.string_type(), "Suffix to add", None)], 1392 + fn(ctx) { 1393 + let suffix = case schema.get_argument(ctx, "suffix") { 1394 + Some(value.String(s)) -> s 1395 + _ -> "" 1396 + } 1397 + case ctx.data { 1398 + option.Some(value.Object(fields)) -> { 1399 + case list.key_find(fields, "name") { 1400 + Ok(value.String(name)) -> 1401 + Ok(value.String(name <> " " <> suffix)) 1402 + _ -> Ok(value.Null) 1403 + } 1404 + } 1405 + _ -> Ok(value.Null) 1406 + } 1407 + }, 1408 + ), 1409 + ]) 1410 + 1411 + let query_type = 1412 + schema.object_type("Query", "Root query type", [ 1413 + schema.field("items", schema.list_type(item_type), "Get items", fn(_ctx) { 1414 + Ok( 1415 + value.List([ 1416 + value.Object([#("name", value.String("Apple"))]), 1417 + value.Object([#("name", value.String("Banana"))]), 1418 + ]), 1419 + ) 1420 + }), 1421 + ]) 1422 + 1423 + let test_schema = schema.schema(query_type, None) 1424 + 1425 + // Query using a variable in nested list item fields 1426 + let query = 1427 + "query GetItems($suffix: String!) { items { formattedName(suffix: $suffix) } }" 1428 + 1429 + // Create context with variables 1430 + let variables = dict.from_list([#("suffix", value.String("(organic)"))]) 1431 + let ctx = schema.context_with_variables(None, variables) 1432 + 1433 + let result = executor.execute(query, test_schema, ctx) 1434 + 1435 + case result { 1436 + Ok(executor.Response(data: value.Object(fields), errors: _)) -> { 1437 + case list.key_find(fields, "items") { 1438 + Ok(value.List(items)) -> { 1439 + // Should have 2 items 1440 + list.length(items) |> should.equal(2) 1441 + 1442 + // First item should have formatted name with suffix 1443 + case list.first(items) { 1444 + Ok(value.Object(item_fields)) -> { 1445 + case list.key_find(item_fields, "formattedName") { 1446 + Ok(value.String("Apple (organic)")) -> should.be_true(True) 1447 + Ok(other) -> { 1448 + // Variable was lost - this is the bug we're testing for 1449 + should.equal(other, value.String("Apple (organic)")) 1450 + } 1451 + Error(_) -> should.fail() 1452 + } 1453 + } 1454 + _ -> should.fail() 1455 + } 1456 + 1457 + // Second item should also have formatted name with suffix 1458 + case list.drop(items, 1) { 1459 + [value.Object(item_fields), ..] -> { 1460 + case list.key_find(item_fields, "formattedName") { 1461 + Ok(value.String("Banana (organic)")) -> should.be_true(True) 1462 + Ok(other) -> { 1463 + should.equal(other, value.String("Banana (organic)")) 1464 + } 1465 + Error(_) -> should.fail() 1466 + } 1467 + } 1468 + _ -> should.fail() 1469 + } 1470 + } 1471 + _ -> should.fail() 1472 + } 1473 + } 1474 + Error(err) -> should.equal(err, "") 1475 + _ -> should.fail() 1476 + } 1477 + } 1478 + 1479 + // Test: Union type wrapped in NonNull resolves correctly 1480 + // This tests the fix for fields like `node: NonNull(UnionType)` in connections 1481 + // Previously, is_union check failed because it only matched bare UnionType 1482 + pub fn execute_non_null_union_resolves_correctly_test() { 1483 + // Create object types that will be part of the union 1484 + let like_type = 1485 + schema.object_type("Like", "A like record", [ 1486 + schema.field("uri", schema.string_type(), "Like URI", fn(ctx) { 1487 + case ctx.data { 1488 + option.Some(value.Object(fields)) -> { 1489 + case list.key_find(fields, "uri") { 1490 + Ok(uri_val) -> Ok(uri_val) 1491 + Error(_) -> Ok(value.Null) 1492 + } 1493 + } 1494 + _ -> Ok(value.Null) 1495 + } 1496 + }), 1497 + ]) 1498 + 1499 + let follow_type = 1500 + schema.object_type("Follow", "A follow record", [ 1501 + schema.field("uri", schema.string_type(), "Follow URI", fn(ctx) { 1502 + case ctx.data { 1503 + option.Some(value.Object(fields)) -> { 1504 + case list.key_find(fields, "uri") { 1505 + Ok(uri_val) -> Ok(uri_val) 1506 + Error(_) -> Ok(value.Null) 1507 + } 1508 + } 1509 + _ -> Ok(value.Null) 1510 + } 1511 + }), 1512 + ]) 1513 + 1514 + // Type resolver that examines the "type" field 1515 + let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 1516 + case ctx.data { 1517 + option.Some(value.Object(fields)) -> { 1518 + case list.key_find(fields, "type") { 1519 + Ok(value.String(type_name)) -> Ok(type_name) 1520 + _ -> Error("No type field found") 1521 + } 1522 + } 1523 + _ -> Error("No data") 1524 + } 1525 + } 1526 + 1527 + // Create union type 1528 + let notification_union = 1529 + schema.union_type( 1530 + "NotificationRecord", 1531 + "A notification record", 1532 + [like_type, follow_type], 1533 + type_resolver, 1534 + ) 1535 + 1536 + // Create edge type with node wrapped in NonNull - this is the key scenario 1537 + let edge_type = 1538 + schema.object_type("NotificationEdge", "An edge in the connection", [ 1539 + schema.field( 1540 + "node", 1541 + schema.non_null(notification_union), 1542 + // NonNull wrapping union 1543 + "The notification record", 1544 + fn(ctx) { 1545 + case ctx.data { 1546 + option.Some(value.Object(fields)) -> { 1547 + case list.key_find(fields, "node") { 1548 + Ok(node_val) -> Ok(node_val) 1549 + Error(_) -> Ok(value.Null) 1550 + } 1551 + } 1552 + _ -> Ok(value.Null) 1553 + } 1554 + }, 1555 + ), 1556 + schema.field("cursor", schema.string_type(), "Cursor", fn(ctx) { 1557 + case ctx.data { 1558 + option.Some(value.Object(fields)) -> { 1559 + case list.key_find(fields, "cursor") { 1560 + Ok(cursor_val) -> Ok(cursor_val) 1561 + Error(_) -> Ok(value.Null) 1562 + } 1563 + } 1564 + _ -> Ok(value.Null) 1565 + } 1566 + }), 1567 + ]) 1568 + 1569 + // Create query type returning a list of edges 1570 + let query_type = 1571 + schema.object_type("Query", "Root query type", [ 1572 + schema.field( 1573 + "notifications", 1574 + schema.list_type(edge_type), 1575 + "Get notifications", 1576 + fn(_ctx) { 1577 + Ok( 1578 + value.List([ 1579 + value.Object([ 1580 + #( 1581 + "node", 1582 + value.Object([ 1583 + #("type", value.String("Like")), 1584 + #("uri", value.String("at://user/like/1")), 1585 + ]), 1586 + ), 1587 + #("cursor", value.String("cursor1")), 1588 + ]), 1589 + value.Object([ 1590 + #( 1591 + "node", 1592 + value.Object([ 1593 + #("type", value.String("Follow")), 1594 + #("uri", value.String("at://user/follow/1")), 1595 + ]), 1596 + ), 1597 + #("cursor", value.String("cursor2")), 1598 + ]), 1599 + ]), 1600 + ) 1601 + }, 1602 + ), 1603 + ]) 1604 + 1605 + let test_schema = schema.schema(query_type, None) 1606 + 1607 + // Query with inline fragments on the NonNull-wrapped union 1608 + let query = 1609 + " 1610 + { 1611 + notifications { 1612 + cursor 1613 + node { 1614 + __typename 1615 + ... on Like { 1616 + uri 1617 + } 1618 + ... on Follow { 1619 + uri 1620 + } 1621 + } 1622 + } 1623 + } 1624 + " 1625 + 1626 + let result = executor.execute(query, test_schema, schema.context(None)) 1627 + 1628 + case result { 1629 + Ok(response) -> { 1630 + case response.data { 1631 + value.Object(fields) -> { 1632 + case list.key_find(fields, "notifications") { 1633 + Ok(value.List(edges)) -> { 1634 + // Should have 2 edges 1635 + list.length(edges) |> should.equal(2) 1636 + 1637 + // First edge should be a Like with resolved fields 1638 + case list.first(edges) { 1639 + Ok(value.Object(edge_fields)) -> { 1640 + case list.key_find(edge_fields, "node") { 1641 + Ok(value.Object(node_fields)) -> { 1642 + // __typename should be "Like" (resolved from union) 1643 + case list.key_find(node_fields, "__typename") { 1644 + Ok(value.String("Like")) -> should.be_true(True) 1645 + Ok(value.String(other)) -> should.equal(other, "Like") 1646 + _ -> should.fail() 1647 + } 1648 + // uri should be resolved from inline fragment 1649 + case list.key_find(node_fields, "uri") { 1650 + Ok(value.String("at://user/like/1")) -> 1651 + should.be_true(True) 1652 + _ -> should.fail() 1653 + } 1654 + } 1655 + _ -> should.fail() 1656 + } 1657 + } 1658 + _ -> should.fail() 1659 + } 1660 + } 1661 + _ -> should.fail() 1662 + } 1663 + } 1664 + _ -> should.fail() 1665 + } 1666 + } 1667 + Error(err) -> { 1668 + should.equal(err, "") 1669 + } 1670 + } 1671 + }
+148
test/introspection_test.gleam
··· 3 /// Comprehensive tests for introspection queries 4 import gleam/list 5 import gleam/option.{None} 6 import gleeunit/should 7 import swell/executor 8 import swell/schema ··· 674 } 675 |> should.be_true 676 }
··· 3 /// Comprehensive tests for introspection queries 4 import gleam/list 5 import gleam/option.{None} 6 + import gleam/string 7 import gleeunit/should 8 import swell/executor 9 import swell/schema ··· 675 } 676 |> should.be_true 677 } 678 + 679 + /// Test: Introspection types are returned in alphabetical order 680 + /// Verifies that __schema.types are sorted alphabetically by name 681 + pub fn schema_types_alphabetical_order_test() { 682 + let schema = test_schema() 683 + let query = "{ __schema { types { name } } }" 684 + 685 + let result = executor.execute(query, schema, schema.context(None)) 686 + 687 + should.be_ok(result) 688 + |> fn(response) { 689 + case response { 690 + executor.Response(data: value.Object(fields), errors: []) -> { 691 + case list.key_find(fields, "__schema") { 692 + Ok(value.Object(schema_fields)) -> { 693 + case list.key_find(schema_fields, "types") { 694 + Ok(value.List(types)) -> { 695 + // Extract type names 696 + let names = 697 + list.filter_map(types, fn(type_val) { 698 + case type_val { 699 + value.Object(type_fields) -> { 700 + case list.key_find(type_fields, "name") { 701 + Ok(value.String(name)) -> Ok(name) 702 + _ -> Error(Nil) 703 + } 704 + } 705 + _ -> Error(Nil) 706 + } 707 + }) 708 + 709 + // Check that names are sorted alphabetically 710 + // Expected order: Boolean, Float, ID, Int, Query, String 711 + let sorted_names = list.sort(names, string.compare) 712 + names == sorted_names 713 + } 714 + _ -> False 715 + } 716 + } 717 + _ -> False 718 + } 719 + } 720 + _ -> False 721 + } 722 + } 723 + |> should.be_true 724 + } 725 + 726 + /// Test: Union type introspection returns possibleTypes 727 + /// Verifies that introspecting a union type correctly returns all possible types 728 + pub fn union_type_possible_types_test() { 729 + // Create object types that will be part of the union 730 + let post_type = 731 + schema.object_type("Post", "A blog post", [ 732 + schema.field("title", schema.string_type(), "Post title", fn(_ctx) { 733 + Ok(value.String("test")) 734 + }), 735 + ]) 736 + 737 + let comment_type = 738 + schema.object_type("Comment", "A comment", [ 739 + schema.field("text", schema.string_type(), "Comment text", fn(_ctx) { 740 + Ok(value.String("test")) 741 + }), 742 + ]) 743 + 744 + // Type resolver for the union 745 + let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) { 746 + Ok("Post") 747 + } 748 + 749 + // Create union type 750 + let search_result_union = 751 + schema.union_type( 752 + "SearchResult", 753 + "A search result", 754 + [post_type, comment_type], 755 + type_resolver, 756 + ) 757 + 758 + // Create query type that uses the union 759 + let query_type = 760 + schema.object_type("Query", "Root query type", [ 761 + schema.field( 762 + "search", 763 + schema.list_type(search_result_union), 764 + "Search results", 765 + fn(_ctx) { Ok(value.List([])) }, 766 + ), 767 + ]) 768 + 769 + let test_schema = schema.schema(query_type, None) 770 + 771 + // Query for union type's possibleTypes 772 + let query = 773 + "{ __type(name: \"SearchResult\") { name kind possibleTypes { name } } }" 774 + 775 + let result = executor.execute(query, test_schema, schema.context(None)) 776 + 777 + should.be_ok(result) 778 + |> fn(response) { 779 + case response { 780 + executor.Response(data: value.Object(fields), errors: []) -> { 781 + case list.key_find(fields, "__type") { 782 + Ok(value.Object(type_fields)) -> { 783 + // Check it's a UNION 784 + let is_union = case list.key_find(type_fields, "kind") { 785 + Ok(value.String("UNION")) -> True 786 + _ -> False 787 + } 788 + 789 + // Check possibleTypes contains both Post and Comment 790 + let has_possible_types = case 791 + list.key_find(type_fields, "possibleTypes") 792 + { 793 + Ok(value.List(possible_types)) -> { 794 + let names = 795 + list.filter_map(possible_types, fn(pt) { 796 + case pt { 797 + value.Object(pt_fields) -> { 798 + case list.key_find(pt_fields, "name") { 799 + Ok(value.String(name)) -> Ok(name) 800 + _ -> Error(Nil) 801 + } 802 + } 803 + _ -> Error(Nil) 804 + } 805 + }) 806 + 807 + // Should have exactly 2 possible types: Comment and Post 808 + list.length(names) == 2 809 + && list.contains(names, "Post") 810 + && list.contains(names, "Comment") 811 + } 812 + _ -> False 813 + } 814 + 815 + is_union && has_possible_types 816 + } 817 + _ -> False 818 + } 819 + } 820 + _ -> False 821 + } 822 + } 823 + |> should.be_true 824 + }
+244 -5
test/parser_test.gleam
··· 484 parser.Document([ 485 parser.NamedQuery( 486 name: "Test", 487 - variables: [parser.Variable("name", "String!")], 488 selections: parser.SelectionSet([parser.Field("user", None, [], [])]), 489 ), 490 ]) -> True ··· 504 parser.NamedQuery( 505 name: "Test", 506 variables: [ 507 - parser.Variable("name", "String!"), 508 - parser.Variable("age", "Int"), 509 ], 510 selections: parser.SelectionSet([parser.Field("user", None, [], [])]), 511 ), ··· 526 parser.NamedMutation( 527 name: "CreateUser", 528 variables: [ 529 - parser.Variable("name", "String!"), 530 - parser.Variable("email", "String!"), 531 ], 532 selections: parser.SelectionSet([ 533 parser.Field("createUser", None, [], []), ··· 638 } 639 |> should.be_true 640 }
··· 484 parser.Document([ 485 parser.NamedQuery( 486 name: "Test", 487 + variables: [parser.Variable("name", "String!", None)], 488 selections: parser.SelectionSet([parser.Field("user", None, [], [])]), 489 ), 490 ]) -> True ··· 504 parser.NamedQuery( 505 name: "Test", 506 variables: [ 507 + parser.Variable("name", "String!", None), 508 + parser.Variable("age", "Int", None), 509 ], 510 selections: parser.SelectionSet([parser.Field("user", None, [], [])]), 511 ), ··· 526 parser.NamedMutation( 527 name: "CreateUser", 528 variables: [ 529 + parser.Variable("name", "String!", None), 530 + parser.Variable("email", "String!", None), 531 ], 532 selections: parser.SelectionSet([ 533 parser.Field("createUser", None, [], []), ··· 638 } 639 |> should.be_true 640 } 641 + 642 + // List type variable tests 643 + pub fn parse_variable_with_list_type_test() { 644 + "query Test($ids: [Int]) { users }" 645 + |> parser.parse 646 + |> should.be_ok 647 + |> fn(doc) { 648 + case doc { 649 + parser.Document([ 650 + parser.NamedQuery( 651 + name: "Test", 652 + variables: [parser.Variable("ids", "[Int]", None)], 653 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 654 + ), 655 + ]) -> True 656 + _ -> False 657 + } 658 + } 659 + |> should.be_true 660 + } 661 + 662 + pub fn parse_variable_with_non_null_list_type_test() { 663 + "query Test($ids: [Int]!) { users }" 664 + |> parser.parse 665 + |> should.be_ok 666 + |> fn(doc) { 667 + case doc { 668 + parser.Document([ 669 + parser.NamedQuery( 670 + name: "Test", 671 + variables: [parser.Variable("ids", "[Int]!", None)], 672 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 673 + ), 674 + ]) -> True 675 + _ -> False 676 + } 677 + } 678 + |> should.be_true 679 + } 680 + 681 + pub fn parse_variable_with_list_of_non_null_type_test() { 682 + "query Test($ids: [Int!]) { users }" 683 + |> parser.parse 684 + |> should.be_ok 685 + |> fn(doc) { 686 + case doc { 687 + parser.Document([ 688 + parser.NamedQuery( 689 + name: "Test", 690 + variables: [parser.Variable("ids", "[Int!]", None)], 691 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 692 + ), 693 + ]) -> True 694 + _ -> False 695 + } 696 + } 697 + |> should.be_true 698 + } 699 + 700 + pub fn parse_variable_with_non_null_list_of_non_null_type_test() { 701 + "query Test($ids: [Int!]!) { users }" 702 + |> parser.parse 703 + |> should.be_ok 704 + |> fn(doc) { 705 + case doc { 706 + parser.Document([ 707 + parser.NamedQuery( 708 + name: "Test", 709 + variables: [parser.Variable("ids", "[Int!]!", None)], 710 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 711 + ), 712 + ]) -> True 713 + _ -> False 714 + } 715 + } 716 + |> should.be_true 717 + } 718 + 719 + pub fn parse_mutation_with_list_variable_test() { 720 + "mutation AddTags($tags: [String!]!) { addTags }" 721 + |> parser.parse 722 + |> should.be_ok 723 + |> fn(doc) { 724 + case doc { 725 + parser.Document([ 726 + parser.NamedMutation( 727 + name: "AddTags", 728 + variables: [parser.Variable("tags", "[String!]!", None)], 729 + selections: parser.SelectionSet([ 730 + parser.Field("addTags", None, [], []), 731 + ]), 732 + ), 733 + ]) -> True 734 + _ -> False 735 + } 736 + } 737 + |> should.be_true 738 + } 739 + 740 + pub fn parse_variable_with_default_int_value_test() { 741 + "query Test($count: Int = 20) { users }" 742 + |> parser.parse 743 + |> should.be_ok 744 + |> fn(doc) { 745 + case doc { 746 + parser.Document([ 747 + parser.NamedQuery( 748 + name: "Test", 749 + variables: [ 750 + parser.Variable("count", "Int", option.Some(parser.IntValue("20"))), 751 + ], 752 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 753 + ), 754 + ]) -> True 755 + _ -> False 756 + } 757 + } 758 + |> should.be_true 759 + } 760 + 761 + // Additional default value type tests 762 + pub fn parse_variable_with_default_string_value_test() { 763 + "query Test($name: String = \"Alice\") { users }" 764 + |> parser.parse 765 + |> should.be_ok 766 + |> fn(doc) { 767 + case doc { 768 + parser.Document([ 769 + parser.NamedQuery( 770 + name: "Test", 771 + variables: [ 772 + parser.Variable( 773 + "name", 774 + "String", 775 + option.Some(parser.StringValue("Alice")), 776 + ), 777 + ], 778 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 779 + ), 780 + ]) -> True 781 + _ -> False 782 + } 783 + } 784 + |> should.be_true 785 + } 786 + 787 + pub fn parse_variable_with_default_boolean_value_test() { 788 + "query Test($active: Boolean = true) { users }" 789 + |> parser.parse 790 + |> should.be_ok 791 + |> fn(doc) { 792 + case doc { 793 + parser.Document([ 794 + parser.NamedQuery( 795 + name: "Test", 796 + variables: [ 797 + parser.Variable( 798 + "active", 799 + "Boolean", 800 + option.Some(parser.BooleanValue(True)), 801 + ), 802 + ], 803 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 804 + ), 805 + ]) -> True 806 + _ -> False 807 + } 808 + } 809 + |> should.be_true 810 + } 811 + 812 + pub fn parse_variable_with_default_null_value_test() { 813 + "query Test($filter: String = null) { users }" 814 + |> parser.parse 815 + |> should.be_ok 816 + |> fn(doc) { 817 + case doc { 818 + parser.Document([ 819 + parser.NamedQuery( 820 + name: "Test", 821 + variables: [ 822 + parser.Variable("filter", "String", option.Some(parser.NullValue)), 823 + ], 824 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 825 + ), 826 + ]) -> True 827 + _ -> False 828 + } 829 + } 830 + |> should.be_true 831 + } 832 + 833 + pub fn parse_variable_with_default_enum_value_test() { 834 + "query Test($sort: SortOrder = DESC) { users }" 835 + |> parser.parse 836 + |> should.be_ok 837 + |> fn(doc) { 838 + case doc { 839 + parser.Document([ 840 + parser.NamedQuery( 841 + name: "Test", 842 + variables: [ 843 + parser.Variable( 844 + "sort", 845 + "SortOrder", 846 + option.Some(parser.EnumValue("DESC")), 847 + ), 848 + ], 849 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 850 + ), 851 + ]) -> True 852 + _ -> False 853 + } 854 + } 855 + |> should.be_true 856 + } 857 + 858 + pub fn parse_variable_with_mixed_defaults_test() { 859 + "query Test($name: String!, $limit: Int = 10, $offset: Int) { users }" 860 + |> parser.parse 861 + |> should.be_ok 862 + |> fn(doc) { 863 + case doc { 864 + parser.Document([ 865 + parser.NamedQuery( 866 + name: "Test", 867 + variables: [ 868 + parser.Variable("name", "String!", None), 869 + parser.Variable("limit", "Int", option.Some(parser.IntValue("10"))), 870 + parser.Variable("offset", "Int", None), 871 + ], 872 + selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 873 + ), 874 + ]) -> True 875 + _ -> False 876 + } 877 + } 878 + |> should.be_true 879 + }