···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
27+28+- Inline fragments on union types within arrays now resolve correctly (was skipping union type resolution for `List[NonNull[Union]]` items)
29+- Union types are now always traversed during introspection on first encounter, ensuring their `possibleTypes` are properly collected
30+31+## 2.1.0
32+33+### Added
34+35+- Argument type validation: list arguments now produce helpful errors when passed objects instead of lists
36+- `isOneOf` field in type introspection for INPUT_OBJECT types (GraphQL spec compliance)
37+- Introspection types are now returned in alphabetical order by name
38+39+## 2.0.0
40+41+### Added
42+43+- Support for variable default values (`$name: Type = defaultValue`)
44+- Default values are applied during execution when variables are not provided
45+- Provided variables override default values
46+- New `schema.named_type_name` function to get the base type name without List/NonNull wrappers
47+48+### Fixed
49+50+- Integer and float literal arguments are now correctly converted to `value.Int` and `value.Float` instead of `value.String`
51+- Fragment spread type condition matching now works correctly when parent type is wrapped in NonNull or List
52+- Inline fragment type condition matching now works correctly with wrapped types
53+- `__typename` introspection now returns the concrete type name without modifiers
54+55+### Breaking Changes
56+57+- `Variable` type now has 3 fields: `Variable(name, type_, default_value)` instead of 2
58+- Code pattern matching on `Variable` must be updated to include the third field
59+- Migration: Add `None` or `_` as the third argument when constructing or matching `Variable`
60+61+## 1.1.0
62+63+### Added
64+65+- Support for list types in variable definitions (`[Type]`, `[Type!]`, `[Type]!`, `[Type!]!`)
66+67+## 1.0.1
68+69+- Initial release
+3-1
README.md
···1# Swell
23-[](https://hex.pm/packages/swell)
4[](https://hexdocs.pm/swell/)
56A GraphQL implementation in Gleam providing query parsing, execution, and introspection support.
0078
9
···1# Swell
23+[](https://hex.pm/packages/swell)
4[](https://hexdocs.pm/swell/)
56A GraphQL implementation in Gleam providing query parsing, execution, and introspection support.
7+8+> **Note:** If you're looking for a GraphQL **client**, check out [Squall](https://hexdocs.pm/squall).
910
11
···1+---
2+version: 1.4.1
3+title: Execute fragment spread on NonNull type
4+file: ./test/executor_test.gleam
5+test_name: execute_fragment_spread_on_non_null_type_test
6+---
7+Response(Object([#("user", Object([#("id", String("123")), #("name", String("Alice"))]))]), [])
+1-1
gleam.toml
···1name = "swell"
2-version = "1.0.0"
3description = "๐ A GraphQL implementation in Gleam"
4licences = ["Apache-2.0"]
5repository = { type = "github", user = "bigmoves", repo = "swell" }
···1name = "swell"
2+version = "2.1.4"
3description = "๐ A GraphQL implementation in Gleam"
4licences = ["Apache-2.0"]
5repository = { type = "github", user = "bigmoves", repo = "swell" }
+323-139
src/swell/executor.gleam
···2///
3/// Executes GraphQL queries against a schema
4import gleam/dict.{type Dict}
005import gleam/list
6import gleam/option.{None, Some}
7import gleam/set.{type Set}
···20 Response(data: value.Value, errors: List(GraphQLError))
21}
2200000000000000000000000023/// Get the response key for a field (alias if present, otherwise field name)
24fn response_key(field_name: String, alias: option.Option(String)) -> String {
25 case alias {
···39 Error(parse_error) ->
40 Error("Parse error: " <> format_parse_error(parse_error))
41 Ok(document) -> {
000042 // Execute the document
43- case execute_document(document, graphql_schema, ctx) {
44 Ok(#(data, errors)) -> Ok(Response(data, errors))
45 Error(err) -> Error(err)
46 }
···48 }
49}
5000000000051fn format_parse_error(err: parser.ParseError) -> String {
52 case err {
53 parser.UnexpectedToken(_, msg) -> msg
···61 document: parser.Document,
62 graphql_schema: schema.Schema,
63 ctx: schema.Context,
064) -> Result(#(value.Value, List(GraphQLError)), String) {
65 case document {
66 parser.Document(operations) -> {
···73 // Execute the first executable operation
74 case executable_ops {
75 [operation, ..] ->
76- execute_operation(operation, graphql_schema, ctx, fragments_dict)
00000077 [] -> Error("No executable operations in document")
78 }
79 }
···112 graphql_schema: schema.Schema,
113 ctx: schema.Context,
114 fragments: Dict(String, parser.Operation),
0115) -> Result(#(value.Value, List(GraphQLError)), String) {
116 case operation {
117 parser.Query(selection_set) -> {
···123 ctx,
124 fragments,
125 [],
0126 )
127 }
128- parser.NamedQuery(_, _, selection_set) -> {
129 let root_type = schema.query_type(graphql_schema)
0000130 execute_selection_set(
131 selection_set,
132 root_type,
133 graphql_schema,
134- ctx,
135 fragments,
136 [],
0137 )
138 }
139 parser.Mutation(selection_set) -> {
···147 ctx,
148 fragments,
149 [],
0150 )
151 option.None -> Error("Schema does not define a mutation type")
152 }
153 }
154- parser.NamedMutation(_, _, selection_set) -> {
155 // Get mutation root type from schema
156 case schema.get_mutation_type(graphql_schema) {
157- option.Some(mutation_type) ->
00000158 execute_selection_set(
159 selection_set,
160 mutation_type,
161 graphql_schema,
162- ctx,
163 fragments,
164 [],
0165 )
0166 option.None -> Error("Schema does not define a mutation type")
167 }
168 }
···177 ctx,
178 fragments,
179 [],
0180 )
181 option.None -> Error("Schema does not define a subscription type")
182 }
183 }
184- parser.NamedSubscription(_, _, selection_set) -> {
185 // Get subscription root type from schema
186 case schema.get_subscription_type(graphql_schema) {
187- option.Some(subscription_type) ->
00000188 execute_selection_set(
189 selection_set,
190 subscription_type,
191 graphql_schema,
192- ctx,
193 fragments,
194 [],
0195 )
0196 option.None -> Error("Schema does not define a subscription type")
197 }
198 }
···209 ctx: schema.Context,
210 fragments: Dict(String, parser.Operation),
211 path: List(String),
0212) -> Result(#(value.Value, List(GraphQLError)), String) {
213 case selection_set {
214 parser.SelectionSet(selections) -> {
···221 ctx,
222 fragments,
223 path,
0224 )
225 })
226···274 ctx: schema.Context,
275 fragments: Dict(String, parser.Operation),
276 path: List(String),
0277) -> Result(#(String, value.Value, List(GraphQLError)), String) {
278 case selection {
279 parser.FragmentSpread(name) -> {
···285 type_condition,
286 fragment_selection_set,
287 )) -> {
288- // Check type condition
289- let current_type_name = schema.type_name(parent_type)
0290 case type_condition == current_type_name {
291 False -> {
292 // Type condition doesn't match, skip this fragment
···303 ctx,
304 fragments,
305 path,
0306 )
307 {
308 Ok(#(value.Object(fields), errs)) -> {
···320 }
321 }
322 parser.InlineFragment(type_condition_opt, inline_selections) -> {
323- // Check type condition if present
324- let current_type_name = schema.type_name(parent_type)
0325 let should_execute = case type_condition_opt {
326 None -> True
327 Some(type_condition) -> type_condition == current_type_name
···339 ctx,
340 fragments,
341 path,
0342 )
343 {
344 Ok(#(value.Object(fields), errs)) ->
···359 // Handle introspection meta-fields
360 case name {
361 "__typename" -> {
362- let type_name = schema.type_name(parent_type)
0363 Ok(#(key, value.String(type_name), []))
364 }
365 "__schema" -> {
···456 Ok(#(key, value.Null, [error]))
457 }
458 Some(field) -> {
459- // Get the field's type for nested selections
460- let field_type_def = schema.field_type(field)
0000461462- // Create context with arguments (preserve variables from parent context)
463- let field_ctx = schema.Context(ctx.data, args_dict, ctx.variables)
0464465- // Resolve the field
466- case schema.resolve_field(field, field_ctx) {
467- Error(err) -> {
468- let error = GraphQLError(err, [name, ..path])
469- Ok(#(key, value.Null, [error]))
470- }
471- Ok(field_value) -> {
472- // If there are nested selections, recurse
473- case nested_selections {
474- [] -> Ok(#(key, field_value, []))
475- _ -> {
476- // Need to resolve nested fields
477- case field_value {
478- value.Object(_) -> {
479- // Check if field_type_def is a union type
480- // If so, resolve it to the concrete type first
481- let type_to_use = case
482- schema.is_union(field_type_def)
483- {
484- True -> {
485- // Create context with the field value for type resolution
486- let resolve_ctx =
487- schema.context(option.Some(field_value))
488- case
489- schema.resolve_union_type(
490- field_type_def,
491- resolve_ctx,
492- )
493 {
494- Ok(concrete_type) -> concrete_type
495- Error(_) -> field_type_def
496- // Fallback to union type if resolution fails
497 }
498- }
499- False -> field_type_def
500- }
501-502- // Execute nested selections using the resolved type
503- // Create new context with this object's data
504- let object_ctx =
505- schema.context(option.Some(field_value))
506- let selection_set =
507- parser.SelectionSet(nested_selections)
508- case
509- execute_selection_set(
510- selection_set,
511- type_to_use,
512- graphql_schema,
513- object_ctx,
514- fragments,
515- [name, ..path],
516- )
517- {
518- Ok(#(nested_data, nested_errors)) ->
519- Ok(#(key, nested_data, nested_errors))
520- Error(err) -> {
521- let error = GraphQLError(err, [name, ..path])
522- Ok(#(key, value.Null, [error]))
523- }
524- }
525- }
526- value.List(items) -> {
527- // Handle list with nested selections
528- // Get the inner type from the LIST wrapper, unwrapping NonNull if needed
529- let inner_type = case
530- schema.inner_type(field_type_def)
531- {
532- option.Some(t) -> {
533- // If the result is still wrapped (NonNull), unwrap it too
534- case schema.inner_type(t) {
535- option.Some(unwrapped) -> unwrapped
536- option.None -> t
537- }
538- }
539- option.None -> field_type_def
540- }
541-542- // Execute nested selections on each item
543- let selection_set =
544- parser.SelectionSet(nested_selections)
545- let results =
546- list.map(items, fn(item) {
547- // Check if inner_type is a union and resolve it
548- let item_type = case schema.is_union(inner_type) {
549 True -> {
550- // Create context with the item value for type resolution
551 let resolve_ctx =
552- schema.context(option.Some(item))
553 case
554- schema.resolve_union_type(
555- inner_type,
556 resolve_ctx,
0557 )
558 {
559 Ok(concrete_type) -> concrete_type
560- Error(_) -> inner_type
561 // Fallback to union type if resolution fails
562 }
563 }
564- False -> inner_type
565 }
566567- // Create context with this item's data
568- let item_ctx = schema.context(option.Some(item))
569- execute_selection_set(
570- selection_set,
571- item_type,
572- graphql_schema,
573- item_ctx,
574- fragments,
575- [name, ..path],
576- )
577- })
578-579- // Collect results and errors
580- let processed_items =
581- results
582- |> list.filter_map(fn(r) {
583- case r {
584- Ok(#(val, _)) -> Ok(val)
585- Error(_) -> Error(Nil)
0000000586 }
587- })
588-589- let all_errors =
590- results
591- |> list.flat_map(fn(r) {
592- case r {
593- Ok(#(_, errs)) -> errs
594- Error(_) -> []
00000000595 }
596- })
597598- Ok(#(key, value.List(processed_items), all_errors))
000000000000000000000000000000000000000000000000000000000000000000000000000599 }
600- _ -> Ok(#(key, field_value, []))
601 }
602 }
603 }
···884 ctx: schema.Context,
885) -> value.Value {
886 case arg_value {
887- parser.IntValue(s) -> value.String(s)
888- parser.FloatValue(s) -> value.String(s)
0000000000889 parser.StringValue(s) -> value.String(s)
890 parser.BooleanValue(b) -> value.Boolean(b)
891 parser.NullValue -> value.Null
···925 }
926 })
927}
0000000000000000000000000000000000000000000000000000000000000
···2///
3/// Executes GraphQL queries against a schema
4import gleam/dict.{type Dict}
5+import gleam/float
6+import gleam/int
7import gleam/list
8import gleam/option.{None, Some}
9import gleam/set.{type Set}
···22 Response(data: value.Value, errors: List(GraphQLError))
23}
2425+/// Merge variable defaults with provided variables
26+/// Provided variables take precedence over defaults
27+fn apply_variable_defaults(
28+ variables: List(parser.Variable),
29+ provided: Dict(String, value.Value),
30+ ctx: schema.Context,
31+) -> Dict(String, value.Value) {
32+ list.fold(variables, provided, fn(acc, var) {
33+ case var {
34+ parser.Variable(name, _, option.Some(default_val)) -> {
35+ // Only apply default if variable not already provided
36+ case dict.get(acc, name) {
37+ Ok(_) -> acc
38+ Error(_) -> {
39+ let val = argument_value_to_value(default_val, ctx)
40+ dict.insert(acc, name, val)
41+ }
42+ }
43+ }
44+ parser.Variable(_, _, option.None) -> acc
45+ }
46+ })
47+}
48+49/// Get the response key for a field (alias if present, otherwise field name)
50fn response_key(field_name: String, alias: option.Option(String)) -> String {
51 case alias {
···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}
8081+/// 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+90fn 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) -> {
177 let root_type = schema.query_type(graphql_schema)
178+ // Apply variable defaults
179+ let merged_vars = apply_variable_defaults(variables, ctx.variables, ctx)
180+ let ctx_with_defaults =
181+ schema.Context(ctx.data, ctx.arguments, merged_vars)
182 execute_selection_set(
183 selection_set,
184 root_type,
185 graphql_schema,
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 }
207 }
208+ parser.NamedMutation(_name, variables, selection_set) -> {
209 // Get mutation root type from schema
210 case schema.get_mutation_type(graphql_schema) {
211+ option.Some(mutation_type) -> {
212+ // Apply variable defaults
213+ let merged_vars =
214+ apply_variable_defaults(variables, ctx.variables, ctx)
215+ let ctx_with_defaults =
216+ schema.Context(ctx.data, ctx.arguments, merged_vars)
217 execute_selection_set(
218 selection_set,
219 mutation_type,
220 graphql_schema,
221+ ctx_with_defaults,
222 fragments,
223 [],
224+ type_registry,
225 )
226+ }
227 option.None -> Error("Schema does not define a mutation type")
228 }
229 }
···238 ctx,
239 fragments,
240 [],
241+ type_registry,
242 )
243 option.None -> Error("Schema does not define a subscription type")
244 }
245 }
246+ parser.NamedSubscription(_name, variables, selection_set) -> {
247 // Get subscription root type from schema
248 case schema.get_subscription_type(graphql_schema) {
249+ option.Some(subscription_type) -> {
250+ // Apply variable defaults
251+ let merged_vars =
252+ apply_variable_defaults(variables, ctx.variables, ctx)
253+ let ctx_with_defaults =
254+ schema.Context(ctx.data, ctx.arguments, merged_vars)
255 execute_selection_set(
256 selection_set,
257 subscription_type,
258 graphql_schema,
259+ ctx_with_defaults,
260 fragments,
261 [],
262+ type_registry,
263 )
264+ }
265 option.None -> Error("Schema does not define a subscription type")
266 }
267 }
···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) -> {
···357 type_condition,
358 fragment_selection_set,
359 )) -> {
360+ // Check type condition - use named_type_name to get the base type
361+ // without NonNull/List wrappers, since fragments are defined on named types
362+ let current_type_name = schema.named_type_name(parent_type)
363 case type_condition == current_type_name {
364 False -> {
365 // Type condition doesn't match, skip this fragment
···376 ctx,
377 fragments,
378 path,
379+ type_registry,
380 )
381 {
382 Ok(#(value.Object(fields), errs)) -> {
···394 }
395 }
396 parser.InlineFragment(type_condition_opt, inline_selections) -> {
397+ // Check type condition if present - use named_type_name to get the base type
398+ // without NonNull/List wrappers, since fragments are defined on named types
399+ let current_type_name = schema.named_type_name(parent_type)
400 let should_execute = case type_condition_opt {
401 None -> True
402 Some(type_condition) -> type_condition == current_type_name
···414 ctx,
415 fragments,
416 path,
417+ type_registry,
418 )
419 {
420 Ok(#(value.Object(fields), errs)) ->
···435 // Handle introspection meta-fields
436 case name {
437 "__typename" -> {
438+ // Use named_type_name to return the concrete type without modifiers
439+ let type_name = schema.named_type_name(parent_type)
440 Ok(#(key, value.String(type_name), []))
441 }
442 "__schema" -> {
···533 Ok(#(key, value.Null, [error]))
534 }
535 Some(field) -> {
536+ // Validate argument types before resolving
537+ case validate_arguments(field, args_dict, [name, ..path]) {
538+ Error(err) -> Ok(#(key, value.Null, [err]))
539+ Ok(_) -> {
540+ // Get the field's type for nested selections
541+ let field_type_def = schema.field_type(field)
542543+ // Create context with arguments (preserve variables from parent context)
544+ let field_ctx =
545+ schema.Context(ctx.data, args_dict, ctx.variables)
546547+ // Resolve the field
548+ case schema.resolve_field(field, field_ctx) {
549+ Error(err) -> {
550+ let error = GraphQLError(err, [name, ..path])
551+ Ok(#(key, value.Null, [error]))
552+ }
553+ Ok(field_value) -> {
554+ // If there are nested selections, recurse
555+ case nested_selections {
556+ [] -> Ok(#(key, field_value, []))
557+ _ -> {
558+ // Need to resolve nested fields
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)
000000000566 {
567+ option.Some(t) -> t
568+ option.None -> field_type_def
0569 }
570+ let type_to_use = case
571+ schema.is_union(unwrapped_type)
572+ {
000000000000000000000000000000000000000000000000573 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
585+ Error(_) -> field_type_def
586 // Fallback to union type if resolution fails
587 }
588 }
589+ False -> field_type_def
590 }
591592+ // 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
602+ execute_selection_set(
603+ selection_set,
604+ type_to_use,
605+ graphql_schema,
606+ object_ctx,
607+ fragments,
608+ [name, ..path],
609+ type_registry,
610+ )
611+ {
612+ Ok(#(nested_data, nested_errors)) ->
613+ Ok(#(key, nested_data, nested_errors))
614+ Error(err) -> {
615+ let error = GraphQLError(err, [name, ..path])
616+ Ok(#(key, value.Null, [error]))
617+ }
618 }
619+ }
620+ value.List(items) -> {
621+ // Handle list with nested selections
622+ // Get the inner type from the LIST wrapper, unwrapping NonNull if needed
623+ // Field type could be: NonNull[List[NonNull[Union]]] or List[NonNull[Union]] etc.
624+ let inner_type = case
625+ schema.inner_type(field_type_def)
626+ {
627+ option.Some(t) -> {
628+ // If the result is still wrapped (NonNull), unwrap it too
629+ case schema.inner_type(t) {
630+ option.Some(unwrapped) -> unwrapped
631+ option.None -> t
632+ }
633+ }
634+ option.None -> field_type_def
635 }
0636637+ // Execute nested selections on each item
638+ let selection_set =
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
646+ schema.inner_type(inner_type)
647+ {
648+ option.Some(t) -> t
649+ option.None -> inner_type
650+ }
651+ let item_type = case
652+ schema.is_union(unwrapped_inner)
653+ {
654+ True -> {
655+ // Create context with the item value for type resolution
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
666+ Error(_) -> inner_type
667+ // Fallback to union type if resolution fails
668+ }
669+ }
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,
682+ graphql_schema,
683+ item_ctx,
684+ fragments,
685+ [name, ..path],
686+ type_registry,
687+ )
688+ })
689+690+ // Collect results and errors
691+ let processed_items =
692+ results
693+ |> list.filter_map(fn(r) {
694+ case r {
695+ Ok(#(val, _)) -> Ok(val)
696+ Error(_) -> Error(Nil)
697+ }
698+ })
699+700+ let all_errors =
701+ results
702+ |> list.flat_map(fn(r) {
703+ case r {
704+ Ok(#(_, errs)) -> errs
705+ Error(_) -> []
706+ }
707+ })
708+709+ Ok(#(key, value.List(processed_items), all_errors))
710+ }
711+ _ -> Ok(#(key, field_value, []))
712+ }
713 }
0714 }
715 }
716 }
···997 ctx: schema.Context,
998) -> value.Value {
999 case arg_value {
1000+ parser.IntValue(s) -> {
1001+ case int.parse(s) {
1002+ Ok(n) -> value.Int(n)
1003+ Error(_) -> value.String(s)
1004+ }
1005+ }
1006+ parser.FloatValue(s) -> {
1007+ case float.parse(s) {
1008+ Ok(f) -> value.Float(f)
1009+ Error(_) -> value.String(s)
1010+ }
1011+ }
1012 parser.StringValue(s) -> value.String(s)
1013 parser.BooleanValue(b) -> value.Boolean(b)
1014 parser.NullValue -> value.Null
···1048 }
1049 })
1050}
1051+1052+/// Validate that an argument value matches its declared type
1053+fn validate_argument_type(
1054+ arg_name: String,
1055+ expected_type: schema.Type,
1056+ actual_value: value.Value,
1057+ path: List(String),
1058+) -> Result(Nil, GraphQLError) {
1059+ // Unwrap NonNull wrapper to get the base type (but not List wrapper)
1060+ let base_type = case schema.is_non_null(expected_type) {
1061+ True ->
1062+ case schema.inner_type(expected_type) {
1063+ option.Some(inner) -> inner
1064+ option.None -> expected_type
1065+ }
1066+ False -> expected_type
1067+ }
1068+1069+ case schema.is_list(base_type), actual_value {
1070+ // List type expects a list value - reject object
1071+ True, value.Object(_) ->
1072+ Error(GraphQLError(
1073+ "Argument '"
1074+ <> arg_name
1075+ <> "' expects a list, not an object. Use ["
1076+ <> arg_name
1077+ <> ": {...}] instead of "
1078+ <> arg_name
1079+ <> ": {...}",
1080+ path,
1081+ ))
1082+ // All other cases are ok (for now - can add more validation later)
1083+ _, _ -> Ok(Nil)
1084+ }
1085+}
1086+1087+/// Validate all provided arguments against a field's declared argument types
1088+fn validate_arguments(
1089+ field: schema.Field,
1090+ args_dict: Dict(String, value.Value),
1091+ path: List(String),
1092+) -> Result(Nil, GraphQLError) {
1093+ let declared_args = schema.field_arguments(field)
1094+1095+ // For each provided argument, check if it matches the declared type
1096+ list.try_each(dict.to_list(args_dict), fn(arg_pair) {
1097+ let #(arg_name, arg_value) = arg_pair
1098+1099+ // Find the declared argument
1100+ case
1101+ list.find(declared_args, fn(a) { schema.argument_name(a) == arg_name })
1102+ {
1103+ Ok(declared_arg) -> {
1104+ let expected_type = schema.argument_type(declared_arg)
1105+ validate_argument_type(arg_name, expected_type, arg_value, path)
1106+ }
1107+ Error(_) -> Ok(Nil)
1108+ // Unknown argument - let schema handle it
1109+ }
1110+ })
1111+}
+78-16
src/swell/introspection.gleam
···6import gleam/list
7import gleam/option
8import gleam/result
09import swell/schema
10import swell/value
11···101 !list.contains(collected_names, built_in_name)
102 })
103104- list.append(unique_types, missing_built_ins)
00000000000000000000000000000000000000000105}
106107/// Get all types from the schema
108fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) {
109 let all_types = get_all_schema_types(graphql_schema)
110111- // Convert all types to introspection values
112- list.map(all_types, type_introspection)
0000113}
114115/// Deduplicate types by name, keeping the version with the most fields
···182 schema.is_object(t) || schema.is_enum(t) || schema.is_union(t)
183 {
184 True -> {
185- let current_content_count = get_type_content_count(t)
186 let existing_with_same_name =
187 list.filter(acc, fn(existing) {
188 schema.type_name(existing) == schema.type_name(t)
189 })
190- let max_existing_content =
191- existing_with_same_name
192- |> list.map(get_type_content_count)
193- |> list.reduce(fn(a, b) {
194- case a > b {
195- True -> a
196- False -> b
197- }
198- })
199- |> result.unwrap(0)
000000000200201- // Only traverse if this instance has more content than we've seen before
202- current_content_count > max_existing_content
0203 }
204 False -> True
205 }
···323 desc -> value.String(desc)
324 }
325000000326 value.Object([
327 #("kind", value.String(kind)),
328 #("name", name),
···333 #("enumValues", enum_values),
334 #("inputFields", input_fields),
335 #("ofType", of_type),
0336 ])
337}
338
···6import gleam/list
7import gleam/option
8import gleam/result
9+import gleam/string
10import swell/schema
11import swell/value
12···102 !list.contains(collected_names, built_in_name)
103 })
104105+ 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}
148149/// Get all types from the schema
150fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) {
151 let all_types = get_all_schema_types(graphql_schema)
152153+ // Sort types alphabetically by name, then convert to introspection values
154+ all_types
155+ |> list.sort(fn(a, b) {
156+ string.compare(schema.type_name(a), schema.type_name(b))
157+ })
158+ |> list.map(type_introspection)
159}
160161/// Deduplicate types by name, keeping the version with the most fields
···228 schema.is_object(t) || schema.is_enum(t) || schema.is_union(t)
229 {
230 True -> {
0231 let existing_with_same_name =
232 list.filter(acc, fn(existing) {
233 schema.type_name(existing) == schema.type_name(t)
234 })
235+236+ // If this is the first time we've seen this type name, always traverse
237+ // This is critical for unions which have 0 content count but still need
238+ // their possible_types to be traversed
239+ case list.is_empty(existing_with_same_name) {
240+ True -> True
241+ False -> {
242+ // For subsequent encounters, only traverse if this instance has more content
243+ let current_content_count = get_type_content_count(t)
244+ let max_existing_content =
245+ existing_with_same_name
246+ |> list.map(get_type_content_count)
247+ |> list.reduce(fn(a, b) {
248+ case a > b {
249+ True -> a
250+ False -> b
251+ }
252+ })
253+ |> result.unwrap(0)
254255+ current_content_count > max_existing_content
256+ }
257+ }
258 }
259 False -> True
260 }
···378 desc -> value.String(desc)
379 }
380381+ // isOneOf for INPUT_OBJECT types (GraphQL spec addition)
382+ let is_one_of = case kind {
383+ "INPUT_OBJECT" -> value.Boolean(False)
384+ _ -> value.Null
385+ }
386+387 value.Object([
388 #("kind", value.String(kind)),
389 #("name", name),
···394 #("enumValues", enum_values),
395 #("inputFields", input_fields),
396 #("ofType", of_type),
397+ #("isOneOf", is_one_of),
398 ])
399}
400
+64-15
src/swell/parser.gleam
···7273/// Variable definition
74pub type Variable {
75- Variable(name: String, type_: String)
76}
7778pub type ParseError {
···543 // Skip commas
544 [lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc)
545546- // Parse a variable: $name: Type! or $name: Type
547 [lexer.Dollar, lexer.Name(var_name), lexer.Colon, ..rest] -> {
548- // Parse the type (Name or Name!)
549- case rest {
550- [lexer.Name(type_name), lexer.Exclamation, ..rest2] -> {
551- // Non-null type
552- let variable = Variable(var_name, type_name <> "!")
553- parse_variable_definitions_loop(rest2, [variable, ..acc])
000000000000000554 }
555- [lexer.Name(type_name), ..rest2] -> {
556- // Nullable type
557- let variable = Variable(var_name, type_name)
558- parse_variable_definitions_loop(rest2, [variable, ..acc])
559- }
560- [] -> Error(UnexpectedEndOfInput("Expected type after :"))
561- [token, ..] -> Error(UnexpectedToken(token, "Expected type name"))
562 }
563 }
564···566 [token, ..] -> Error(UnexpectedToken(token, "Expected $variableName or )"))
567 }
568}
0000000000000000000000000000000000000000
···7273/// Variable definition
74pub type Variable {
75+ Variable(name: String, type_: String, default_value: Option(ArgumentValue))
76}
7778pub type ParseError {
···543 // Skip commas
544 [lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc)
545546+ // Parse a variable: $name: Type = defaultValue or $name: Type
547 [lexer.Dollar, lexer.Name(var_name), lexer.Colon, ..rest] -> {
548+ // Parse the type
549+ case parse_type_reference(rest) {
550+ Ok(#(type_str, rest2)) -> {
551+ // Check for default value
552+ case rest2 {
553+ [lexer.Equals, ..rest3] -> {
554+ // Parse the default value
555+ case parse_argument_value(rest3) {
556+ Ok(#(default_val, rest4)) -> {
557+ let variable = Variable(var_name, type_str, Some(default_val))
558+ parse_variable_definitions_loop(rest4, [variable, ..acc])
559+ }
560+ Error(err) -> Error(err)
561+ }
562+ }
563+ _ -> {
564+ // No default value
565+ let variable = Variable(var_name, type_str, None)
566+ parse_variable_definitions_loop(rest2, [variable, ..acc])
567+ }
568+ }
569 }
570+ Error(err) -> Error(err)
000000571 }
572 }
573···575 [token, ..] -> Error(UnexpectedToken(token, "Expected $variableName or )"))
576 }
577}
578+579+/// Parse a type reference (e.g., String, String!, [String], [String!], [String]!, [String!]!)
580+fn parse_type_reference(
581+ tokens: List(lexer.Token),
582+) -> Result(#(String, List(lexer.Token)), ParseError) {
583+ case tokens {
584+ // List type: [Type] or [Type!] or [Type]! or [Type!]!
585+ [lexer.BracketOpen, ..rest] -> {
586+ case rest {
587+ [
588+ lexer.Name(inner_type),
589+ lexer.Exclamation,
590+ lexer.BracketClose,
591+ lexer.Exclamation,
592+ ..rest2
593+ ] ->
594+ // [Type!]!
595+ Ok(#("[" <> inner_type <> "!]!", rest2))
596+ [lexer.Name(inner_type), lexer.Exclamation, lexer.BracketClose, ..rest2] ->
597+ // [Type!]
598+ Ok(#("[" <> inner_type <> "!]", rest2))
599+ [lexer.Name(inner_type), lexer.BracketClose, lexer.Exclamation, ..rest2] ->
600+ // [Type]!
601+ Ok(#("[" <> inner_type <> "]!", rest2))
602+ [lexer.Name(inner_type), lexer.BracketClose, ..rest2] ->
603+ // [Type]
604+ Ok(#("[" <> inner_type <> "]", rest2))
605+ [] -> Error(UnexpectedEndOfInput("Expected type name in list"))
606+ [token, ..] ->
607+ Error(UnexpectedToken(token, "Expected type name in list"))
608+ }
609+ }
610+ // Simple type: Type! or Type
611+ [lexer.Name(type_name), lexer.Exclamation, ..rest] ->
612+ Ok(#(type_name <> "!", rest))
613+ [lexer.Name(type_name), ..rest] -> Ok(#(type_name, rest))
614+ [] -> Error(UnexpectedEndOfInput("Expected type"))
615+ [token, ..] -> Error(UnexpectedToken(token, "Expected type name"))
616+ }
617+}
+59
src/swell/schema.gleam
···239 }
240}
241000000000000000242pub fn field_name(f: Field) -> String {
243 case f {
244 Field(name, _, _, _, _) -> name
···456 }
457}
458000000000000459/// Resolve a union type to its concrete type using the type resolver
460pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) {
461 case t {
···475 "Type resolver returned '"
476 <> resolved_type_name
477 <> "' which is not a possible type of this union",
00000000000000000000000000000000478 )
479 }
480 }
···239 }
240}
241242+/// Get the named (base) type name, unwrapping List and NonNull wrappers.
243+/// This is used for fragment type condition matching and __typename where
244+/// we need the concrete type name without modifiers.
245+pub fn named_type_name(t: Type) -> String {
246+ case t {
247+ ScalarType(name) -> name
248+ ObjectType(name, _, _) -> name
249+ InputObjectType(name, _, _) -> name
250+ EnumType(name, _, _) -> name
251+ UnionType(name, _, _, _) -> name
252+ ListType(inner) -> named_type_name(inner)
253+ NonNullType(inner) -> named_type_name(inner)
254+ }
255+}
256+257pub fn field_name(f: Field) -> String {
258 case f {
259 Field(name, _, _, _, _) -> name
···471 }
472}
473474+/// 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
487pub 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 }
+804
test/executor_test.gleam
···206 )
207}
2080000000000000000000000000000000000000000000000000000209// Test for list fields with nested selections
210pub fn execute_list_with_nested_selections_test() {
211 // Create a schema with a list field
···865 content: format_response(response),
866 )
867}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···206 )
207}
208209+// Test for fragment spread on NonNull wrapped type
210+pub fn execute_fragment_spread_on_non_null_type_test() {
211+ // Create a schema where the user field returns a NonNull type
212+ let user_type =
213+ schema.object_type("User", "A user", [
214+ schema.field("id", schema.id_type(), "User ID", fn(_ctx) {
215+ Ok(value.String("123"))
216+ }),
217+ schema.field("name", schema.string_type(), "User name", fn(_ctx) {
218+ Ok(value.String("Alice"))
219+ }),
220+ ])
221+222+ let query_type =
223+ schema.object_type("Query", "Root query type", [
224+ // Wrap user_type in NonNull to test fragment type condition matching
225+ schema.field("user", schema.non_null(user_type), "Get user", fn(_ctx) {
226+ Ok(
227+ value.Object([
228+ #("id", value.String("123")),
229+ #("name", value.String("Alice")),
230+ ]),
231+ )
232+ }),
233+ ])
234+235+ let test_schema = schema.schema(query_type, None)
236+237+ // Fragment is defined on "User" (not "User!") - this should still work
238+ let query =
239+ "
240+ fragment UserFields on User {
241+ id
242+ name
243+ }
244+245+ { user { ...UserFields } }
246+ "
247+248+ let result = executor.execute(query, test_schema, schema.context(None))
249+250+ let response = case result {
251+ Ok(r) -> r
252+ Error(_) -> panic as "Execution failed"
253+ }
254+255+ birdie.snap(
256+ title: "Execute fragment spread on NonNull type",
257+ content: format_response(response),
258+ )
259+}
260+261// Test for list fields with nested selections
262pub fn execute_list_with_nested_selections_test() {
263 // Create a schema with a list field
···917 content: format_response(response),
918 )
919}
920+921+pub fn execute_query_with_variable_default_value_test() {
922+ let query_type =
923+ schema.object_type("Query", "Root query type", [
924+ schema.field_with_args(
925+ "greet",
926+ schema.string_type(),
927+ "Greet someone",
928+ [schema.argument("name", schema.string_type(), "Name to greet", None)],
929+ fn(ctx) {
930+ case schema.get_argument(ctx, "name") {
931+ option.Some(value.String(name)) ->
932+ Ok(value.String("Hello, " <> name <> "!"))
933+ _ -> Ok(value.String("Hello, stranger!"))
934+ }
935+ },
936+ ),
937+ ])
938+939+ let test_schema = schema.schema(query_type, None)
940+ let query = "query Test($name: String = \"World\") { greet(name: $name) }"
941+942+ // No variables provided - should use default
943+ let ctx = schema.context_with_variables(None, dict.new())
944+945+ let result = executor.execute(query, test_schema, ctx)
946+947+ // Debug: print the actual result
948+ let assert Ok(response) = result
949+ let assert executor.Response(data: value.Object(fields), errors: _) = response
950+ let assert Ok(greet_value) = list.key_find(fields, "greet")
951+952+ // Should use default value "World" since no variable provided
953+ greet_value
954+ |> should.equal(value.String("Hello, World!"))
955+}
956+957+pub fn execute_query_with_variable_overriding_default_test() {
958+ let query_type =
959+ schema.object_type("Query", "Root query type", [
960+ schema.field_with_args(
961+ "greet",
962+ schema.string_type(),
963+ "Greet someone",
964+ [schema.argument("name", schema.string_type(), "Name to greet", None)],
965+ fn(ctx) {
966+ case schema.get_argument(ctx, "name") {
967+ option.Some(value.String(name)) ->
968+ Ok(value.String("Hello, " <> name <> "!"))
969+ _ -> Ok(value.String("Hello, stranger!"))
970+ }
971+ },
972+ ),
973+ ])
974+975+ let test_schema = schema.schema(query_type, None)
976+ let query = "query Test($name: String = \"World\") { greet(name: $name) }"
977+978+ // Provide variable - should override default
979+ let variables = dict.from_list([#("name", value.String("Alice"))])
980+ let ctx = schema.context_with_variables(None, variables)
981+982+ let result = executor.execute(query, test_schema, ctx)
983+ result
984+ |> should.be_ok
985+ |> fn(response) {
986+ case response {
987+ executor.Response(data: value.Object(fields), errors: _) -> {
988+ case list.key_find(fields, "greet") {
989+ Ok(value.String("Hello, Alice!")) -> True
990+ _ -> False
991+ }
992+ }
993+ _ -> False
994+ }
995+ }
996+ |> should.be_true
997+}
998+999+// Test: List argument rejects object value
1000+pub fn list_argument_rejects_object_test() {
1001+ // Create a schema with a field that has a list argument
1002+ let list_arg_field =
1003+ schema.field_with_args(
1004+ "items",
1005+ schema.string_type(),
1006+ "Test field with list arg",
1007+ [
1008+ schema.argument(
1009+ "ids",
1010+ schema.list_type(schema.string_type()),
1011+ "List of IDs",
1012+ None,
1013+ ),
1014+ ],
1015+ fn(_ctx) { Ok(value.String("test")) },
1016+ )
1017+1018+ let query_type = schema.object_type("Query", "Root", [list_arg_field])
1019+ let s = schema.schema(query_type, None)
1020+1021+ // Query with object instead of list should produce an error
1022+ let query = "{ items(ids: {foo: \"bar\"}) }"
1023+ let result = executor.execute(query, s, schema.context(None))
1024+1025+ case result {
1026+ Ok(executor.Response(_, errors)) -> {
1027+ // Should have exactly one error
1028+ list.length(errors)
1029+ |> should.equal(1)
1030+1031+ // Check error message mentions list vs object
1032+ case list.first(errors) {
1033+ Ok(executor.GraphQLError(message, _)) -> {
1034+ string.contains(message, "expects a list")
1035+ |> should.be_true
1036+ string.contains(message, "not an object")
1037+ |> should.be_true
1038+ }
1039+ Error(_) -> should.fail()
1040+ }
1041+ }
1042+ Error(_) -> should.fail()
1043+ }
1044+}
1045+1046+// Test: List argument accepts list value (sanity check)
1047+pub fn list_argument_accepts_list_test() {
1048+ // Create a schema with a field that has a list argument
1049+ let list_arg_field =
1050+ schema.field_with_args(
1051+ "items",
1052+ schema.string_type(),
1053+ "Test field with list arg",
1054+ [
1055+ schema.argument(
1056+ "ids",
1057+ schema.list_type(schema.string_type()),
1058+ "List of IDs",
1059+ None,
1060+ ),
1061+ ],
1062+ fn(_ctx) { Ok(value.String("success")) },
1063+ )
1064+1065+ let query_type = schema.object_type("Query", "Root", [list_arg_field])
1066+ let s = schema.schema(query_type, None)
1067+1068+ // Query with proper list should work
1069+ let query = "{ items(ids: [\"a\", \"b\"]) }"
1070+ let result = executor.execute(query, s, schema.context(None))
1071+1072+ case result {
1073+ Ok(executor.Response(value.Object(fields), errors)) -> {
1074+ // Should have no errors
1075+ list.length(errors)
1076+ |> should.equal(0)
1077+1078+ // Should return the value
1079+ case list.key_find(fields, "items") {
1080+ Ok(value.String("success")) -> should.be_true(True)
1081+ _ -> should.fail()
1082+ }
1083+ }
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+}