๐ŸŒŠ A GraphQL implementation in Gleam

Compare changes

Choose any two refs to compare.

Changed files
+420 -7
src
test
+12
CHANGELOG.md
··· 1 # Changelog 2 3 ## 2.1.2 4 5 ### Fixed
··· 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
+1 -1
gleam.toml
··· 1 name = "swell" 2 - version = "2.1.2" 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" }
+19 -6
src/swell/executor.gleam
··· 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 let type_to_use = case 564 - schema.is_union(field_type_def) 565 { 566 True -> { 567 // Create context with the field value for type resolution ··· 569 schema.context(option.Some(field_value)) 570 case 571 schema.resolve_union_type_with_registry( 572 - field_type_def, 573 resolve_ctx, 574 type_registry, 575 ) ··· 583 } 584 585 // Execute nested selections using the resolved type 586 - // Create new context with this object's data 587 let object_ctx = 588 - schema.context(option.Some(field_value)) 589 let selection_set = 590 parser.SelectionSet(nested_selections) 591 case ··· 660 False -> inner_type 661 } 662 663 - // Create context with this item's data 664 let item_ctx = 665 - schema.context(option.Some(item)) 666 execute_selection_set( 667 selection_set, 668 item_type,
··· 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 ··· 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 ) ··· 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 ··· 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,
+388
test/executor_test.gleam
··· 1281 _ -> should.fail() 1282 } 1283 }
··· 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 + }