๐ŸŒŠ 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 + }
+36
CHANGELOG.md
··· 1 1 # Changelog 2 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 + 3 39 ## 2.0.0 4 40 5 41 ### Added
+1 -1
gleam.toml
··· 1 1 name = "swell" 2 - version = "2.0.0" 2 + version = "2.1.4" 3 3 description = "๐ŸŒŠ A GraphQL implementation in Gleam" 4 4 licences = ["Apache-2.0"] 5 5 repository = { type = "github", user = "bigmoves", repo = "swell" }
+259 -127
src/swell/executor.gleam
··· 65 65 Error(parse_error) -> 66 66 Error("Parse error: " <> format_parse_error(parse_error)) 67 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 + 68 72 // Execute the document 69 - case execute_document(document, graphql_schema, ctx) { 73 + case execute_document(document, graphql_schema, ctx, type_registry) { 70 74 Ok(#(data, errors)) -> Ok(Response(data, errors)) 71 75 Error(err) -> Error(err) 72 76 } ··· 74 78 } 75 79 } 76 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 + 77 90 fn format_parse_error(err: parser.ParseError) -> String { 78 91 case err { 79 92 parser.UnexpectedToken(_, msg) -> msg ··· 87 100 document: parser.Document, 88 101 graphql_schema: schema.Schema, 89 102 ctx: schema.Context, 103 + type_registry: Dict(String, schema.Type), 90 104 ) -> Result(#(value.Value, List(GraphQLError)), String) { 91 105 case document { 92 106 parser.Document(operations) -> { ··· 99 113 // Execute the first executable operation 100 114 case executable_ops { 101 115 [operation, ..] -> 102 - execute_operation(operation, graphql_schema, ctx, fragments_dict) 116 + execute_operation( 117 + operation, 118 + graphql_schema, 119 + ctx, 120 + fragments_dict, 121 + type_registry, 122 + ) 103 123 [] -> Error("No executable operations in document") 104 124 } 105 125 } ··· 138 158 graphql_schema: schema.Schema, 139 159 ctx: schema.Context, 140 160 fragments: Dict(String, parser.Operation), 161 + type_registry: Dict(String, schema.Type), 141 162 ) -> Result(#(value.Value, List(GraphQLError)), String) { 142 163 case operation { 143 164 parser.Query(selection_set) -> { ··· 149 170 ctx, 150 171 fragments, 151 172 [], 173 + type_registry, 152 174 ) 153 175 } 154 176 parser.NamedQuery(_name, variables, selection_set) -> { 155 177 let root_type = schema.query_type(graphql_schema) 156 178 // Apply variable defaults 157 179 let merged_vars = apply_variable_defaults(variables, ctx.variables, ctx) 158 - let ctx_with_defaults = schema.Context(ctx.data, ctx.arguments, merged_vars) 180 + let ctx_with_defaults = 181 + schema.Context(ctx.data, ctx.arguments, merged_vars) 159 182 execute_selection_set( 160 183 selection_set, 161 184 root_type, ··· 163 186 ctx_with_defaults, 164 187 fragments, 165 188 [], 189 + type_registry, 166 190 ) 167 191 } 168 192 parser.Mutation(selection_set) -> { ··· 176 200 ctx, 177 201 fragments, 178 202 [], 203 + type_registry, 179 204 ) 180 205 option.None -> Error("Schema does not define a mutation type") 181 206 } ··· 185 210 case schema.get_mutation_type(graphql_schema) { 186 211 option.Some(mutation_type) -> { 187 212 // Apply variable defaults 188 - let merged_vars = apply_variable_defaults(variables, ctx.variables, ctx) 213 + let merged_vars = 214 + apply_variable_defaults(variables, ctx.variables, ctx) 189 215 let ctx_with_defaults = 190 216 schema.Context(ctx.data, ctx.arguments, merged_vars) 191 217 execute_selection_set( ··· 195 221 ctx_with_defaults, 196 222 fragments, 197 223 [], 224 + type_registry, 198 225 ) 199 226 } 200 227 option.None -> Error("Schema does not define a mutation type") ··· 211 238 ctx, 212 239 fragments, 213 240 [], 241 + type_registry, 214 242 ) 215 243 option.None -> Error("Schema does not define a subscription type") 216 244 } ··· 220 248 case schema.get_subscription_type(graphql_schema) { 221 249 option.Some(subscription_type) -> { 222 250 // Apply variable defaults 223 - let merged_vars = apply_variable_defaults(variables, ctx.variables, ctx) 251 + let merged_vars = 252 + apply_variable_defaults(variables, ctx.variables, ctx) 224 253 let ctx_with_defaults = 225 254 schema.Context(ctx.data, ctx.arguments, merged_vars) 226 255 execute_selection_set( ··· 230 259 ctx_with_defaults, 231 260 fragments, 232 261 [], 262 + type_registry, 233 263 ) 234 264 } 235 265 option.None -> Error("Schema does not define a subscription type") ··· 248 278 ctx: schema.Context, 249 279 fragments: Dict(String, parser.Operation), 250 280 path: List(String), 281 + type_registry: Dict(String, schema.Type), 251 282 ) -> Result(#(value.Value, List(GraphQLError)), String) { 252 283 case selection_set { 253 284 parser.SelectionSet(selections) -> { ··· 260 291 ctx, 261 292 fragments, 262 293 path, 294 + type_registry, 263 295 ) 264 296 }) 265 297 ··· 313 345 ctx: schema.Context, 314 346 fragments: Dict(String, parser.Operation), 315 347 path: List(String), 348 + type_registry: Dict(String, schema.Type), 316 349 ) -> Result(#(String, value.Value, List(GraphQLError)), String) { 317 350 case selection { 318 351 parser.FragmentSpread(name) -> { ··· 343 376 ctx, 344 377 fragments, 345 378 path, 379 + type_registry, 346 380 ) 347 381 { 348 382 Ok(#(value.Object(fields), errs)) -> { ··· 380 414 ctx, 381 415 fragments, 382 416 path, 417 + type_registry, 383 418 ) 384 419 { 385 420 Ok(#(value.Object(fields), errs)) -> ··· 498 533 Ok(#(key, value.Null, [error])) 499 534 } 500 535 Some(field) -> { 501 - // Get the field's type for nested selections 502 - let field_type_def = schema.field_type(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) 503 542 504 - // Create context with arguments (preserve variables from parent context) 505 - let field_ctx = schema.Context(ctx.data, args_dict, ctx.variables) 543 + // Create context with arguments (preserve variables from parent context) 544 + let field_ctx = 545 + schema.Context(ctx.data, args_dict, ctx.variables) 506 546 507 - // Resolve the field 508 - case schema.resolve_field(field, field_ctx) { 509 - Error(err) -> { 510 - let error = GraphQLError(err, [name, ..path]) 511 - Ok(#(key, value.Null, [error])) 512 - } 513 - Ok(field_value) -> { 514 - // If there are nested selections, recurse 515 - case nested_selections { 516 - [] -> Ok(#(key, field_value, [])) 517 - _ -> { 518 - // Need to resolve nested fields 519 - case field_value { 520 - value.Object(_) -> { 521 - // Check if field_type_def is a union type 522 - // If so, resolve it to the concrete type first 523 - let type_to_use = case 524 - schema.is_union(field_type_def) 525 - { 526 - True -> { 527 - // Create context with the field value for type resolution 528 - let resolve_ctx = 529 - schema.context(option.Some(field_value)) 530 - case 531 - schema.resolve_union_type( 532 - field_type_def, 533 - resolve_ctx, 534 - ) 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) 535 566 { 536 - Ok(concrete_type) -> concrete_type 537 - Error(_) -> field_type_def 538 - // Fallback to union type if resolution fails 539 - } 540 - } 541 - False -> field_type_def 542 - } 543 - 544 - // Execute nested selections using the resolved type 545 - // Create new context with this object's data 546 - let object_ctx = 547 - schema.context(option.Some(field_value)) 548 - let selection_set = 549 - parser.SelectionSet(nested_selections) 550 - case 551 - execute_selection_set( 552 - selection_set, 553 - type_to_use, 554 - graphql_schema, 555 - object_ctx, 556 - fragments, 557 - [name, ..path], 558 - ) 559 - { 560 - Ok(#(nested_data, nested_errors)) -> 561 - Ok(#(key, nested_data, nested_errors)) 562 - Error(err) -> { 563 - let error = GraphQLError(err, [name, ..path]) 564 - Ok(#(key, value.Null, [error])) 565 - } 566 - } 567 - } 568 - value.List(items) -> { 569 - // Handle list with nested selections 570 - // Get the inner type from the LIST wrapper, unwrapping NonNull if needed 571 - let inner_type = case 572 - schema.inner_type(field_type_def) 573 - { 574 - option.Some(t) -> { 575 - // If the result is still wrapped (NonNull), unwrap it too 576 - case schema.inner_type(t) { 577 - option.Some(unwrapped) -> unwrapped 578 - option.None -> t 567 + option.Some(t) -> t 568 + option.None -> field_type_def 579 569 } 580 - } 581 - option.None -> field_type_def 582 - } 583 - 584 - // Execute nested selections on each item 585 - let selection_set = 586 - parser.SelectionSet(nested_selections) 587 - let results = 588 - list.map(items, fn(item) { 589 - // Check if inner_type is a union and resolve it 590 - let item_type = case schema.is_union(inner_type) { 570 + let type_to_use = case 571 + schema.is_union(unwrapped_type) 572 + { 591 573 True -> { 592 - // Create context with the item value for type resolution 574 + // Create context with the field value for type resolution 593 575 let resolve_ctx = 594 - schema.context(option.Some(item)) 576 + schema.context(option.Some(field_value)) 595 577 case 596 - schema.resolve_union_type( 597 - inner_type, 578 + schema.resolve_union_type_with_registry( 579 + unwrapped_type, 598 580 resolve_ctx, 581 + type_registry, 599 582 ) 600 583 { 601 584 Ok(concrete_type) -> concrete_type 602 - Error(_) -> inner_type 585 + Error(_) -> field_type_def 603 586 // Fallback to union type if resolution fails 604 587 } 605 588 } 606 - False -> inner_type 589 + False -> field_type_def 607 590 } 608 591 609 - // Create context with this item's data 610 - let item_ctx = schema.context(option.Some(item)) 611 - execute_selection_set( 612 - selection_set, 613 - item_type, 614 - graphql_schema, 615 - item_ctx, 616 - fragments, 617 - [name, ..path], 618 - ) 619 - }) 620 - 621 - // Collect results and errors 622 - let processed_items = 623 - results 624 - |> list.filter_map(fn(r) { 625 - case r { 626 - Ok(#(val, _)) -> Ok(val) 627 - Error(_) -> Error(Nil) 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 + } 628 618 } 629 - }) 630 - 631 - let all_errors = 632 - results 633 - |> list.flat_map(fn(r) { 634 - case r { 635 - Ok(#(_, errs)) -> errs 636 - Error(_) -> [] 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 637 635 } 638 - }) 639 636 640 - Ok(#(key, value.List(processed_items), all_errors)) 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 + } 641 713 } 642 - _ -> Ok(#(key, field_value, [])) 643 714 } 644 715 } 645 716 } ··· 977 1048 } 978 1049 }) 979 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 6 import gleam/list 7 7 import gleam/option 8 8 import gleam/result 9 + import gleam/string 9 10 import swell/schema 10 11 import swell/value 11 12 ··· 101 102 !list.contains(collected_names, built_in_name) 102 103 }) 103 104 104 - list.append(unique_types, missing_built_ins) 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 + } 105 147 } 106 148 107 149 /// Get all types from the schema 108 150 fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) { 109 151 let all_types = get_all_schema_types(graphql_schema) 110 152 111 - // Convert all types to introspection values 112 - list.map(all_types, type_introspection) 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) 113 159 } 114 160 115 161 /// Deduplicate types by name, keeping the version with the most fields ··· 182 228 schema.is_object(t) || schema.is_enum(t) || schema.is_union(t) 183 229 { 184 230 True -> { 185 - let current_content_count = get_type_content_count(t) 186 231 let existing_with_same_name = 187 232 list.filter(acc, fn(existing) { 188 233 schema.type_name(existing) == schema.type_name(t) 189 234 }) 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) 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) 200 254 201 - // Only traverse if this instance has more content than we've seen before 202 - current_content_count > max_existing_content 255 + current_content_count > max_existing_content 256 + } 257 + } 203 258 } 204 259 False -> True 205 260 } ··· 323 378 desc -> value.String(desc) 324 379 } 325 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 + 326 387 value.Object([ 327 388 #("kind", value.String(kind)), 328 389 #("name", name), ··· 333 394 #("enumValues", enum_values), 334 395 #("inputFields", input_fields), 335 396 #("ofType", of_type), 397 + #("isOneOf", is_one_of), 336 398 ]) 337 399 } 338 400
+44
src/swell/schema.gleam
··· 471 471 } 472 472 } 473 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 + 474 486 /// Resolve a union type to its concrete type using the type resolver 475 487 pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) { 476 488 case t { ··· 490 502 "Type resolver returned '" 491 503 <> resolved_type_name 492 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.", 493 537 ) 494 538 } 495 539 }
+674
test/executor_test.gleam
··· 995 995 } 996 996 |> should.be_true 997 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 3 /// Comprehensive tests for introspection queries 4 4 import gleam/list 5 5 import gleam/option.{None} 6 + import gleam/string 6 7 import gleeunit/should 7 8 import swell/executor 8 9 import swell/schema ··· 674 675 } 675 676 |> should.be_true 676 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 + }
+27 -5
test/parser_test.gleam
··· 746 746 parser.Document([ 747 747 parser.NamedQuery( 748 748 name: "Test", 749 - variables: [parser.Variable("count", "Int", option.Some(parser.IntValue("20")))], 749 + variables: [ 750 + parser.Variable("count", "Int", option.Some(parser.IntValue("20"))), 751 + ], 750 752 selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 751 753 ), 752 754 ]) -> True ··· 766 768 parser.Document([ 767 769 parser.NamedQuery( 768 770 name: "Test", 769 - variables: [parser.Variable("name", "String", option.Some(parser.StringValue("Alice")))], 771 + variables: [ 772 + parser.Variable( 773 + "name", 774 + "String", 775 + option.Some(parser.StringValue("Alice")), 776 + ), 777 + ], 770 778 selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 771 779 ), 772 780 ]) -> True ··· 785 793 parser.Document([ 786 794 parser.NamedQuery( 787 795 name: "Test", 788 - variables: [parser.Variable("active", "Boolean", option.Some(parser.BooleanValue(True)))], 796 + variables: [ 797 + parser.Variable( 798 + "active", 799 + "Boolean", 800 + option.Some(parser.BooleanValue(True)), 801 + ), 802 + ], 789 803 selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 790 804 ), 791 805 ]) -> True ··· 804 818 parser.Document([ 805 819 parser.NamedQuery( 806 820 name: "Test", 807 - variables: [parser.Variable("filter", "String", option.Some(parser.NullValue))], 821 + variables: [ 822 + parser.Variable("filter", "String", option.Some(parser.NullValue)), 823 + ], 808 824 selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 809 825 ), 810 826 ]) -> True ··· 823 839 parser.Document([ 824 840 parser.NamedQuery( 825 841 name: "Test", 826 - variables: [parser.Variable("sort", "SortOrder", option.Some(parser.EnumValue("DESC")))], 842 + variables: [ 843 + parser.Variable( 844 + "sort", 845 + "SortOrder", 846 + option.Some(parser.EnumValue("DESC")), 847 + ), 848 + ], 827 849 selections: parser.SelectionSet([parser.Field("users", None, [], [])]), 828 850 ), 829 851 ]) -> True