+12
CHANGELOG.md
+12
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
+
3
15
## 2.1.2
4
16
5
17
### Fixed
+1
-1
gleam.toml
+1
-1
gleam.toml
+19
-6
src/swell/executor.gleam
+19
-6
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
+
// 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
+
}
563
570
let type_to_use = case
564
-
schema.is_union(field_type_def)
571
+
schema.is_union(unwrapped_type)
565
572
{
566
573
True -> {
567
574
// Create context with the field value for type resolution
···
569
576
schema.context(option.Some(field_value))
570
577
case
571
578
schema.resolve_union_type_with_registry(
572
-
field_type_def,
579
+
unwrapped_type,
573
580
resolve_ctx,
574
581
type_registry,
575
582
)
···
583
590
}
584
591
585
592
// Execute nested selections using the resolved type
586
-
// Create new context with this object's data
593
+
// Create new context with this object's data, preserving variables
587
594
let object_ctx =
588
-
schema.context(option.Some(field_value))
595
+
schema.context_with_variables(
596
+
option.Some(field_value),
597
+
ctx.variables,
598
+
)
589
599
let selection_set =
590
600
parser.SelectionSet(nested_selections)
591
601
case
···
660
670
False -> inner_type
661
671
}
662
672
663
-
// Create context with this item's data
673
+
// Create context with this item's data, preserving variables
664
674
let item_ctx =
665
-
schema.context(option.Some(item))
675
+
schema.context_with_variables(
676
+
option.Some(item),
677
+
ctx.variables,
678
+
)
666
679
execute_selection_set(
667
680
selection_set,
668
681
item_type,
+388
test/executor_test.gleam
+388
test/executor_test.gleam
···
1281
1281
_ -> should.fail()
1282
1282
}
1283
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
+
}