+21
CHANGELOG.md
+21
CHANGELOG.md
···
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
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
+1
-1
gleam.toml
+1
-1
gleam.toml
+61
-12
src/swell/executor.gleam
+61
-12
src/swell/executor.gleam
···
65
Error(parse_error) ->
66
Error("Parse error: " <> format_parse_error(parse_error))
67
Ok(document) -> {
68
// Execute the document
69
-
case execute_document(document, graphql_schema, ctx) {
70
Ok(#(data, errors)) -> Ok(Response(data, errors))
71
Error(err) -> Error(err)
72
}
···
74
}
75
}
76
77
fn format_parse_error(err: parser.ParseError) -> String {
78
case err {
79
parser.UnexpectedToken(_, msg) -> msg
···
87
document: parser.Document,
88
graphql_schema: schema.Schema,
89
ctx: schema.Context,
90
) -> Result(#(value.Value, List(GraphQLError)), String) {
91
case document {
92
parser.Document(operations) -> {
···
99
// Execute the first executable operation
100
case executable_ops {
101
[operation, ..] ->
102
-
execute_operation(operation, graphql_schema, ctx, fragments_dict)
103
[] -> Error("No executable operations in document")
104
}
105
}
···
138
graphql_schema: schema.Schema,
139
ctx: schema.Context,
140
fragments: Dict(String, parser.Operation),
141
) -> Result(#(value.Value, List(GraphQLError)), String) {
142
case operation {
143
parser.Query(selection_set) -> {
···
149
ctx,
150
fragments,
151
[],
152
)
153
}
154
parser.NamedQuery(_name, variables, selection_set) -> {
···
164
ctx_with_defaults,
165
fragments,
166
[],
167
)
168
}
169
parser.Mutation(selection_set) -> {
···
177
ctx,
178
fragments,
179
[],
180
)
181
option.None -> Error("Schema does not define a mutation type")
182
}
···
197
ctx_with_defaults,
198
fragments,
199
[],
200
)
201
}
202
option.None -> Error("Schema does not define a mutation type")
···
213
ctx,
214
fragments,
215
[],
216
)
217
option.None -> Error("Schema does not define a subscription type")
218
}
···
233
ctx_with_defaults,
234
fragments,
235
[],
236
)
237
}
238
option.None -> Error("Schema does not define a subscription type")
···
251
ctx: schema.Context,
252
fragments: Dict(String, parser.Operation),
253
path: List(String),
254
) -> Result(#(value.Value, List(GraphQLError)), String) {
255
case selection_set {
256
parser.SelectionSet(selections) -> {
···
263
ctx,
264
fragments,
265
path,
266
)
267
})
268
···
316
ctx: schema.Context,
317
fragments: Dict(String, parser.Operation),
318
path: List(String),
319
) -> Result(#(String, value.Value, List(GraphQLError)), String) {
320
case selection {
321
parser.FragmentSpread(name) -> {
···
346
ctx,
347
fragments,
348
path,
349
)
350
{
351
Ok(#(value.Object(fields), errs)) -> {
···
383
ctx,
384
fragments,
385
path,
386
)
387
{
388
Ok(#(value.Object(fields), errs)) ->
···
527
case field_value {
528
value.Object(_) -> {
529
// Check if field_type_def is a union type
530
-
// If so, resolve it to the concrete type first
531
let type_to_use = case
532
-
schema.is_union(field_type_def)
533
{
534
True -> {
535
// Create context with the field value for type resolution
536
let resolve_ctx =
537
schema.context(option.Some(field_value))
538
case
539
-
schema.resolve_union_type(
540
-
field_type_def,
541
resolve_ctx,
542
)
543
{
544
Ok(concrete_type) -> concrete_type
···
550
}
551
552
// Execute nested selections using the resolved type
553
-
// Create new context with this object's data
554
let object_ctx =
555
-
schema.context(option.Some(field_value))
556
let selection_set =
557
parser.SelectionSet(nested_selections)
558
case
···
563
object_ctx,
564
fragments,
565
[name, ..path],
566
)
567
{
568
Ok(#(nested_data, nested_errors)) ->
···
595
parser.SelectionSet(nested_selections)
596
let results =
597
list.map(items, fn(item) {
598
-
// Check if inner_type is a union and resolve it
599
// Need to unwrap NonNull to check for union since inner_type
600
// could be NonNull[Union] after unwrapping List[NonNull[Union]]
601
let unwrapped_inner = case
···
612
let resolve_ctx =
613
schema.context(option.Some(item))
614
case
615
-
schema.resolve_union_type(
616
unwrapped_inner,
617
resolve_ctx,
618
)
619
{
620
Ok(concrete_type) -> concrete_type
···
625
False -> inner_type
626
}
627
628
-
// Create context with this item's data
629
let item_ctx =
630
-
schema.context(option.Some(item))
631
execute_selection_set(
632
selection_set,
633
item_type,
···
635
item_ctx,
636
fragments,
637
[name, ..path],
638
)
639
})
640
···
65
Error(parse_error) ->
66
Error("Parse error: " <> format_parse_error(parse_error))
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
+
72
// Execute the document
73
+
case execute_document(document, graphql_schema, ctx, type_registry) {
74
Ok(#(data, errors)) -> Ok(Response(data, errors))
75
Error(err) -> Error(err)
76
}
···
78
}
79
}
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
+
90
fn format_parse_error(err: parser.ParseError) -> String {
91
case err {
92
parser.UnexpectedToken(_, msg) -> msg
···
100
document: parser.Document,
101
graphql_schema: schema.Schema,
102
ctx: schema.Context,
103
+
type_registry: Dict(String, schema.Type),
104
) -> Result(#(value.Value, List(GraphQLError)), String) {
105
case document {
106
parser.Document(operations) -> {
···
113
// Execute the first executable operation
114
case executable_ops {
115
[operation, ..] ->
116
+
execute_operation(
117
+
operation,
118
+
graphql_schema,
119
+
ctx,
120
+
fragments_dict,
121
+
type_registry,
122
+
)
123
[] -> Error("No executable operations in document")
124
}
125
}
···
158
graphql_schema: schema.Schema,
159
ctx: schema.Context,
160
fragments: Dict(String, parser.Operation),
161
+
type_registry: Dict(String, schema.Type),
162
) -> Result(#(value.Value, List(GraphQLError)), String) {
163
case operation {
164
parser.Query(selection_set) -> {
···
170
ctx,
171
fragments,
172
[],
173
+
type_registry,
174
)
175
}
176
parser.NamedQuery(_name, variables, selection_set) -> {
···
186
ctx_with_defaults,
187
fragments,
188
[],
189
+
type_registry,
190
)
191
}
192
parser.Mutation(selection_set) -> {
···
200
ctx,
201
fragments,
202
[],
203
+
type_registry,
204
)
205
option.None -> Error("Schema does not define a mutation type")
206
}
···
221
ctx_with_defaults,
222
fragments,
223
[],
224
+
type_registry,
225
)
226
}
227
option.None -> Error("Schema does not define a mutation type")
···
238
ctx,
239
fragments,
240
[],
241
+
type_registry,
242
)
243
option.None -> Error("Schema does not define a subscription type")
244
}
···
259
ctx_with_defaults,
260
fragments,
261
[],
262
+
type_registry,
263
)
264
}
265
option.None -> Error("Schema does not define a subscription type")
···
278
ctx: schema.Context,
279
fragments: Dict(String, parser.Operation),
280
path: List(String),
281
+
type_registry: Dict(String, schema.Type),
282
) -> Result(#(value.Value, List(GraphQLError)), String) {
283
case selection_set {
284
parser.SelectionSet(selections) -> {
···
291
ctx,
292
fragments,
293
path,
294
+
type_registry,
295
)
296
})
297
···
345
ctx: schema.Context,
346
fragments: Dict(String, parser.Operation),
347
path: List(String),
348
+
type_registry: Dict(String, schema.Type),
349
) -> Result(#(String, value.Value, List(GraphQLError)), String) {
350
case selection {
351
parser.FragmentSpread(name) -> {
···
376
ctx,
377
fragments,
378
path,
379
+
type_registry,
380
)
381
{
382
Ok(#(value.Object(fields), errs)) -> {
···
414
ctx,
415
fragments,
416
path,
417
+
type_registry,
418
)
419
{
420
Ok(#(value.Object(fields), errs)) ->
···
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)
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
575
let resolve_ctx =
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
)
583
{
584
Ok(concrete_type) -> concrete_type
···
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
···
606
object_ctx,
607
fragments,
608
[name, ..path],
609
+
type_registry,
610
)
611
{
612
Ok(#(nested_data, nested_errors)) ->
···
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
···
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
···
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,
···
683
item_ctx,
684
fragments,
685
[name, ..path],
686
+
type_registry,
687
)
688
})
689
+42
-1
src/swell/introspection.gleam
+42
-1
src/swell/introspection.gleam
···
102
!list.contains(collected_names, built_in_name)
103
})
104
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
+
}
147
}
148
149
/// Get all types from the schema
+44
src/swell/schema.gleam
+44
src/swell/schema.gleam
···
471
}
472
}
473
474
/// Resolve a union type to its concrete type using the type resolver
475
pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) {
476
case t {
···
490
"Type resolver returned '"
491
<> resolved_type_name
492
<> "' which is not a possible type of this union",
493
)
494
}
495
}
···
471
}
472
}
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
+
486
/// Resolve a union type to its concrete type using the type resolver
487
pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) {
488
case t {
···
502
"Type resolver returned '"
503
<> resolved_type_name
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.",
537
)
538
}
539
}
+585
test/executor_test.gleam
+585
test/executor_test.gleam
···
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
+
}