๐ŸŒŠ A GraphQL implementation in Gleam

fix(executor): resolve union types wrapped in NonNull

Previously, when checking if a field type was a union to trigger type
resolution, the executor only matched bare UnionType. This caused
NonNull(UnionType) fields (like connection edge nodes) to skip union
resolution, resulting in incorrect __typename values and empty inline
fragment results.

The fix unwraps NonNull before checking is_union, matching how list
items are already handled.

Changed files
+208 -4
src
test
+6
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 2.1.3 4 + 5 + ### Fixed 6 + 7 + - 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. 8 + 3 9 ## 2.1.2 4 10 5 11 ### Fixed
+1 -1
gleam.toml
··· 1 1 name = "swell" 2 - version = "2.1.2" 2 + version = "2.1.3" 3 3 description = "๐ŸŒŠ A GraphQL implementation in Gleam" 4 4 licences = ["Apache-2.0"] 5 5 repository = { type = "github", user = "bigmoves", repo = "swell" }
+9 -3
src/swell/executor.gleam
··· 560 560 value.Object(_) -> { 561 561 // Check if field_type_def is a union type 562 562 // If so, resolve it to the concrete type first using the registry 563 - let type_to_use = case 564 - schema.is_union(field_type_def) 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 schema.is_union(unwrapped_type) 565 571 { 566 572 True -> { 567 573 // Create context with the field value for type resolution ··· 569 575 schema.context(option.Some(field_value)) 570 576 case 571 577 schema.resolve_union_type_with_registry( 572 - field_type_def, 578 + unwrapped_type, 573 579 resolve_ctx, 574 580 type_registry, 575 581 )
+192
test/executor_test.gleam
··· 1281 1281 _ -> should.fail() 1282 1282 } 1283 1283 } 1284 + 1285 + // Test: Union type wrapped in NonNull resolves correctly 1286 + // This tests the fix for fields like `node: NonNull(UnionType)` in connections 1287 + // Previously, is_union check failed because it only matched bare UnionType 1288 + pub fn execute_non_null_union_resolves_correctly_test() { 1289 + // Create object types that will be part of the union 1290 + let like_type = 1291 + schema.object_type("Like", "A like record", [ 1292 + schema.field("uri", schema.string_type(), "Like URI", fn(ctx) { 1293 + case ctx.data { 1294 + option.Some(value.Object(fields)) -> { 1295 + case list.key_find(fields, "uri") { 1296 + Ok(uri_val) -> Ok(uri_val) 1297 + Error(_) -> Ok(value.Null) 1298 + } 1299 + } 1300 + _ -> Ok(value.Null) 1301 + } 1302 + }), 1303 + ]) 1304 + 1305 + let follow_type = 1306 + schema.object_type("Follow", "A follow record", [ 1307 + schema.field("uri", schema.string_type(), "Follow URI", fn(ctx) { 1308 + case ctx.data { 1309 + option.Some(value.Object(fields)) -> { 1310 + case list.key_find(fields, "uri") { 1311 + Ok(uri_val) -> Ok(uri_val) 1312 + Error(_) -> Ok(value.Null) 1313 + } 1314 + } 1315 + _ -> Ok(value.Null) 1316 + } 1317 + }), 1318 + ]) 1319 + 1320 + // Type resolver that examines the "type" field 1321 + let type_resolver = fn(ctx: schema.Context) -> Result(String, String) { 1322 + case ctx.data { 1323 + option.Some(value.Object(fields)) -> { 1324 + case list.key_find(fields, "type") { 1325 + Ok(value.String(type_name)) -> Ok(type_name) 1326 + _ -> Error("No type field found") 1327 + } 1328 + } 1329 + _ -> Error("No data") 1330 + } 1331 + } 1332 + 1333 + // Create union type 1334 + let notification_union = 1335 + schema.union_type( 1336 + "NotificationRecord", 1337 + "A notification record", 1338 + [like_type, follow_type], 1339 + type_resolver, 1340 + ) 1341 + 1342 + // Create edge type with node wrapped in NonNull - this is the key scenario 1343 + let edge_type = 1344 + schema.object_type("NotificationEdge", "An edge in the connection", [ 1345 + schema.field( 1346 + "node", 1347 + schema.non_null(notification_union), 1348 + // NonNull wrapping union 1349 + "The notification record", 1350 + fn(ctx) { 1351 + case ctx.data { 1352 + option.Some(value.Object(fields)) -> { 1353 + case list.key_find(fields, "node") { 1354 + Ok(node_val) -> Ok(node_val) 1355 + Error(_) -> Ok(value.Null) 1356 + } 1357 + } 1358 + _ -> Ok(value.Null) 1359 + } 1360 + }, 1361 + ), 1362 + schema.field("cursor", schema.string_type(), "Cursor", fn(ctx) { 1363 + case ctx.data { 1364 + option.Some(value.Object(fields)) -> { 1365 + case list.key_find(fields, "cursor") { 1366 + Ok(cursor_val) -> Ok(cursor_val) 1367 + Error(_) -> Ok(value.Null) 1368 + } 1369 + } 1370 + _ -> Ok(value.Null) 1371 + } 1372 + }), 1373 + ]) 1374 + 1375 + // Create query type returning a list of edges 1376 + let query_type = 1377 + schema.object_type("Query", "Root query type", [ 1378 + schema.field("notifications", schema.list_type(edge_type), "Get notifications", fn( 1379 + _ctx, 1380 + ) { 1381 + Ok( 1382 + value.List([ 1383 + value.Object([ 1384 + #( 1385 + "node", 1386 + value.Object([ 1387 + #("type", value.String("Like")), 1388 + #("uri", value.String("at://user/like/1")), 1389 + ]), 1390 + ), 1391 + #("cursor", value.String("cursor1")), 1392 + ]), 1393 + value.Object([ 1394 + #( 1395 + "node", 1396 + value.Object([ 1397 + #("type", value.String("Follow")), 1398 + #("uri", value.String("at://user/follow/1")), 1399 + ]), 1400 + ), 1401 + #("cursor", value.String("cursor2")), 1402 + ]), 1403 + ]), 1404 + ) 1405 + }), 1406 + ]) 1407 + 1408 + let test_schema = schema.schema(query_type, None) 1409 + 1410 + // Query with inline fragments on the NonNull-wrapped union 1411 + let query = 1412 + " 1413 + { 1414 + notifications { 1415 + cursor 1416 + node { 1417 + __typename 1418 + ... on Like { 1419 + uri 1420 + } 1421 + ... on Follow { 1422 + uri 1423 + } 1424 + } 1425 + } 1426 + } 1427 + " 1428 + 1429 + let result = executor.execute(query, test_schema, schema.context(None)) 1430 + 1431 + case result { 1432 + Ok(response) -> { 1433 + case response.data { 1434 + value.Object(fields) -> { 1435 + case list.key_find(fields, "notifications") { 1436 + Ok(value.List(edges)) -> { 1437 + // Should have 2 edges 1438 + list.length(edges) |> should.equal(2) 1439 + 1440 + // First edge should be a Like with resolved fields 1441 + case list.first(edges) { 1442 + Ok(value.Object(edge_fields)) -> { 1443 + case list.key_find(edge_fields, "node") { 1444 + Ok(value.Object(node_fields)) -> { 1445 + // __typename should be "Like" (resolved from union) 1446 + case list.key_find(node_fields, "__typename") { 1447 + Ok(value.String("Like")) -> should.be_true(True) 1448 + Ok(value.String(other)) -> 1449 + should.equal(other, "Like") 1450 + _ -> should.fail() 1451 + } 1452 + // uri should be resolved from inline fragment 1453 + case list.key_find(node_fields, "uri") { 1454 + Ok(value.String("at://user/like/1")) -> 1455 + should.be_true(True) 1456 + _ -> should.fail() 1457 + } 1458 + } 1459 + _ -> should.fail() 1460 + } 1461 + } 1462 + _ -> should.fail() 1463 + } 1464 + } 1465 + _ -> should.fail() 1466 + } 1467 + } 1468 + _ -> should.fail() 1469 + } 1470 + } 1471 + Error(err) -> { 1472 + should.equal(err, "") 1473 + } 1474 + } 1475 + }