๐ŸŒŠ A GraphQL implementation in Gleam

Compare changes

Choose any two refs to compare.

+21
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 + 3 24 ## 2.1.1 4 25 5 26 ### Fixed
+1 -1
gleam.toml
··· 1 1 name = "swell" 2 - version = "2.1.1" 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" }
+61 -12
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) -> { ··· 164 186 ctx_with_defaults, 165 187 fragments, 166 188 [], 189 + type_registry, 167 190 ) 168 191 } 169 192 parser.Mutation(selection_set) -> { ··· 177 200 ctx, 178 201 fragments, 179 202 [], 203 + type_registry, 180 204 ) 181 205 option.None -> Error("Schema does not define a mutation type") 182 206 } ··· 197 221 ctx_with_defaults, 198 222 fragments, 199 223 [], 224 + type_registry, 200 225 ) 201 226 } 202 227 option.None -> Error("Schema does not define a mutation type") ··· 213 238 ctx, 214 239 fragments, 215 240 [], 241 + type_registry, 216 242 ) 217 243 option.None -> Error("Schema does not define a subscription type") 218 244 } ··· 233 259 ctx_with_defaults, 234 260 fragments, 235 261 [], 262 + type_registry, 236 263 ) 237 264 } 238 265 option.None -> Error("Schema does not define a subscription type") ··· 251 278 ctx: schema.Context, 252 279 fragments: Dict(String, parser.Operation), 253 280 path: List(String), 281 + type_registry: Dict(String, schema.Type), 254 282 ) -> Result(#(value.Value, List(GraphQLError)), String) { 255 283 case selection_set { 256 284 parser.SelectionSet(selections) -> { ··· 263 291 ctx, 264 292 fragments, 265 293 path, 294 + type_registry, 266 295 ) 267 296 }) 268 297 ··· 316 345 ctx: schema.Context, 317 346 fragments: Dict(String, parser.Operation), 318 347 path: List(String), 348 + type_registry: Dict(String, schema.Type), 319 349 ) -> Result(#(String, value.Value, List(GraphQLError)), String) { 320 350 case selection { 321 351 parser.FragmentSpread(name) -> { ··· 346 376 ctx, 347 377 fragments, 348 378 path, 379 + type_registry, 349 380 ) 350 381 { 351 382 Ok(#(value.Object(fields), errs)) -> { ··· 383 414 ctx, 384 415 fragments, 385 416 path, 417 + type_registry, 386 418 ) 387 419 { 388 420 Ok(#(value.Object(fields), errs)) -> ··· 527 559 case field_value { 528 560 value.Object(_) -> { 529 561 // Check if field_type_def is a union type 530 - // If so, resolve it to the concrete type first 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 + } 531 570 let type_to_use = case 532 - schema.is_union(field_type_def) 571 + schema.is_union(unwrapped_type) 533 572 { 534 573 True -> { 535 574 // Create context with the field value for type resolution 536 575 let resolve_ctx = 537 576 schema.context(option.Some(field_value)) 538 577 case 539 - schema.resolve_union_type( 540 - field_type_def, 578 + schema.resolve_union_type_with_registry( 579 + unwrapped_type, 541 580 resolve_ctx, 581 + type_registry, 542 582 ) 543 583 { 544 584 Ok(concrete_type) -> concrete_type ··· 550 590 } 551 591 552 592 // Execute nested selections using the resolved type 553 - // Create new context with this object's data 593 + // Create new context with this object's data, preserving variables 554 594 let object_ctx = 555 - schema.context(option.Some(field_value)) 595 + schema.context_with_variables( 596 + option.Some(field_value), 597 + ctx.variables, 598 + ) 556 599 let selection_set = 557 600 parser.SelectionSet(nested_selections) 558 601 case ··· 563 606 object_ctx, 564 607 fragments, 565 608 [name, ..path], 609 + type_registry, 566 610 ) 567 611 { 568 612 Ok(#(nested_data, nested_errors)) -> ··· 595 639 parser.SelectionSet(nested_selections) 596 640 let results = 597 641 list.map(items, fn(item) { 598 - // Check if inner_type is a union and resolve it 642 + // Check if inner_type is a union and resolve it using the registry 599 643 // Need to unwrap NonNull to check for union since inner_type 600 644 // could be NonNull[Union] after unwrapping List[NonNull[Union]] 601 645 let unwrapped_inner = case ··· 612 656 let resolve_ctx = 613 657 schema.context(option.Some(item)) 614 658 case 615 - schema.resolve_union_type( 659 + schema.resolve_union_type_with_registry( 616 660 unwrapped_inner, 617 661 resolve_ctx, 662 + type_registry, 618 663 ) 619 664 { 620 665 Ok(concrete_type) -> concrete_type ··· 625 670 False -> inner_type 626 671 } 627 672 628 - // Create context with this item's data 673 + // Create context with this item's data, preserving variables 629 674 let item_ctx = 630 - schema.context(option.Some(item)) 675 + schema.context_with_variables( 676 + option.Some(item), 677 + ctx.variables, 678 + ) 631 679 execute_selection_set( 632 680 selection_set, 633 681 item_type, ··· 635 683 item_ctx, 636 684 fragments, 637 685 [name, ..path], 686 + type_registry, 638 687 ) 639 688 }) 640 689
+42 -1
src/swell/introspection.gleam
··· 102 102 !list.contains(collected_names, built_in_name) 103 103 }) 104 104 105 - 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 + } 106 147 } 107 148 108 149 /// Get all types from the schema
+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 }
+585
test/executor_test.gleam
··· 1084 1084 _ -> should.fail() 1085 1085 } 1086 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 + }