๐ŸŒŠ A GraphQL implementation in Gleam

fix(executor): use canonical type registry for union resolution

- Add type registry to ensure union types resolve to complete type definitions
- Make build_type_map public in introspection module
- Add get_union_type_resolver and resolve_union_type_with_registry functions
- Add test for union resolution with all fields via registry
- Bump version to 2.1.2

+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 name = "swell" 2 - version = "2.1.1" 2 + version = "2.1.2" 3 3 description = "๐ŸŒŠ A GraphQL implementation in Gleam" 4 4 licences = ["Apache-2.0"] 5 5 repository = { type = "github", user = "bigmoves", repo = "swell" }
+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
··· 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
··· 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
··· 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 + }