+9
CHANGELOG.md
+9
CHANGELOG.md
···
1
1
# Changelog
2
2
3
+
## 2.1.2
4
+
5
+
### Fixed
6
+
7
+
- Union type resolution now uses a canonical type registry, ensuring resolved types have complete field definitions
8
+
- Made `build_type_map` public in introspection module for building type registries
9
+
- Added `get_union_type_resolver` to extract the type resolver function from union types
10
+
- Added `resolve_union_type_with_registry` for registry-based union resolution
11
+
3
12
## 2.1.1
4
13
5
14
### Fixed
+1
-1
gleam.toml
+1
-1
gleam.toml
+42
-6
src/swell/executor.gleam
+42
-6
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
531
563
let type_to_use = case
532
564
schema.is_union(field_type_def)
533
565
{
···
536
568
let resolve_ctx =
537
569
schema.context(option.Some(field_value))
538
570
case
539
-
schema.resolve_union_type(
571
+
schema.resolve_union_type_with_registry(
540
572
field_type_def,
541
573
resolve_ctx,
574
+
type_registry,
542
575
)
543
576
{
544
577
Ok(concrete_type) -> concrete_type
···
563
596
object_ctx,
564
597
fragments,
565
598
[name, ..path],
599
+
type_registry,
566
600
)
567
601
{
568
602
Ok(#(nested_data, nested_errors)) ->
···
595
629
parser.SelectionSet(nested_selections)
596
630
let results =
597
631
list.map(items, fn(item) {
598
-
// Check if inner_type is a union and resolve it
632
+
// Check if inner_type is a union and resolve it using the registry
599
633
// Need to unwrap NonNull to check for union since inner_type
600
634
// could be NonNull[Union] after unwrapping List[NonNull[Union]]
601
635
let unwrapped_inner = case
···
612
646
let resolve_ctx =
613
647
schema.context(option.Some(item))
614
648
case
615
-
schema.resolve_union_type(
649
+
schema.resolve_union_type_with_registry(
616
650
unwrapped_inner,
617
651
resolve_ctx,
652
+
type_registry,
618
653
)
619
654
{
620
655
Ok(concrete_type) -> concrete_type
···
635
670
item_ctx,
636
671
fragments,
637
672
[name, ..path],
673
+
type_registry,
638
674
)
639
675
})
640
676
+42
-1
src/swell/introspection.gleam
+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
+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
}
+197
test/executor_test.gleam
+197
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
+
}