···11+# Changelog
22+33+## 2.1.4
44+55+### Fixed
66+77+- 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.
88+99+## 2.1.3
1010+1111+### Fixed
1212+1313+- 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.
1414+1515+## 2.1.2
1616+1717+### Fixed
1818+1919+- Union type resolution now uses a canonical type registry, ensuring resolved types have complete field definitions
2020+- Made `build_type_map` public in introspection module for building type registries
2121+- Added `get_union_type_resolver` to extract the type resolver function from union types
2222+- Added `resolve_union_type_with_registry` for registry-based union resolution
2323+2424+## 2.1.1
2525+2626+### Fixed
2727+2828+- Inline fragments on union types within arrays now resolve correctly (was skipping union type resolution for `List[NonNull[Union]]` items)
2929+- Union types are now always traversed during introspection on first encounter, ensuring their `possibleTypes` are properly collected
3030+3131+## 2.1.0
3232+3333+### Added
3434+3535+- Argument type validation: list arguments now produce helpful errors when passed objects instead of lists
3636+- `isOneOf` field in type introspection for INPUT_OBJECT types (GraphQL spec compliance)
3737+- Introspection types are now returned in alphabetical order by name
3838+3939+## 2.0.0
4040+4141+### Added
4242+4343+- Support for variable default values (`$name: Type = defaultValue`)
4444+- Default values are applied during execution when variables are not provided
4545+- Provided variables override default values
4646+- New `schema.named_type_name` function to get the base type name without List/NonNull wrappers
4747+4848+### Fixed
4949+5050+- Integer and float literal arguments are now correctly converted to `value.Int` and `value.Float` instead of `value.String`
5151+- Fragment spread type condition matching now works correctly when parent type is wrapped in NonNull or List
5252+- Inline fragment type condition matching now works correctly with wrapped types
5353+- `__typename` introspection now returns the concrete type name without modifiers
5454+5555+### Breaking Changes
5656+5757+- `Variable` type now has 3 fields: `Variable(name, type_, default_value)` instead of 2
5858+- Code pattern matching on `Variable` must be updated to include the third field
5959+- Migration: Add `None` or `_` as the third argument when constructing or matching `Variable`
6060+6161+## 1.1.0
6262+6363+### Added
6464+6565+- Support for list types in variable definitions (`[Type]`, `[Type!]`, `[Type]!`, `[Type!]!`)
6666+6767+## 1.0.1
6868+6969+- Initial release
+3-1
README.md
···11# Swell
2233-[](https://hex.pm/packages/swell)
33+[](https://hex.pm/packages/swell)
44[](https://hexdocs.pm/swell/)
5566A GraphQL implementation in Gleam providing query parsing, execution, and introspection support.
77+88+> **Note:** If you're looking for a GraphQL **client**, check out [Squall](https://hexdocs.pm/squall).
79810
911
···11+---
22+version: 1.4.1
33+title: Execute fragment spread on NonNull type
44+file: ./test/executor_test.gleam
55+test_name: execute_fragment_spread_on_non_null_type_test
66+---
77+Response(Object([#("user", Object([#("id", String("123")), #("name", String("Alice"))]))]), [])
+1-1
gleam.toml
···11name = "swell"
22-version = "1.0.0"
22+version = "2.1.4"
33description = "๐ A GraphQL implementation in Gleam"
44licences = ["Apache-2.0"]
55repository = { type = "github", user = "bigmoves", repo = "swell" }
+323-139
src/swell/executor.gleam
···22///
33/// Executes GraphQL queries against a schema
44import gleam/dict.{type Dict}
55+import gleam/float
66+import gleam/int
57import gleam/list
68import gleam/option.{None, Some}
79import gleam/set.{type Set}
···2022 Response(data: value.Value, errors: List(GraphQLError))
2123}
22242525+/// Merge variable defaults with provided variables
2626+/// Provided variables take precedence over defaults
2727+fn apply_variable_defaults(
2828+ variables: List(parser.Variable),
2929+ provided: Dict(String, value.Value),
3030+ ctx: schema.Context,
3131+) -> Dict(String, value.Value) {
3232+ list.fold(variables, provided, fn(acc, var) {
3333+ case var {
3434+ parser.Variable(name, _, option.Some(default_val)) -> {
3535+ // Only apply default if variable not already provided
3636+ case dict.get(acc, name) {
3737+ Ok(_) -> acc
3838+ Error(_) -> {
3939+ let val = argument_value_to_value(default_val, ctx)
4040+ dict.insert(acc, name, val)
4141+ }
4242+ }
4343+ }
4444+ parser.Variable(_, _, option.None) -> acc
4545+ }
4646+ })
4747+}
4848+2349/// Get the response key for a field (alias if present, otherwise field name)
2450fn response_key(field_name: String, alias: option.Option(String)) -> String {
2551 case alias {
···3965 Error(parse_error) ->
4066 Error("Parse error: " <> format_parse_error(parse_error))
4167 Ok(document) -> {
6868+ // Build canonical type registry for union resolution
6969+ // This ensures we use the most complete version of each type
7070+ let type_registry = build_type_registry(graphql_schema)
7171+4272 // Execute the document
4343- case execute_document(document, graphql_schema, ctx) {
7373+ case execute_document(document, graphql_schema, ctx, type_registry) {
4474 Ok(#(data, errors)) -> Ok(Response(data, errors))
4575 Error(err) -> Error(err)
4676 }
···4878 }
4979}
50808181+/// Build a canonical type registry from the schema
8282+/// Uses introspection's logic to get deduplicated types with most fields
8383+fn build_type_registry(
8484+ graphql_schema: schema.Schema,
8585+) -> Dict(String, schema.Type) {
8686+ let all_types = introspection.get_all_schema_types(graphql_schema)
8787+ introspection.build_type_map(all_types)
8888+}
8989+5190fn format_parse_error(err: parser.ParseError) -> String {
5291 case err {
5392 parser.UnexpectedToken(_, msg) -> msg
···61100 document: parser.Document,
62101 graphql_schema: schema.Schema,
63102 ctx: schema.Context,
103103+ type_registry: Dict(String, schema.Type),
64104) -> Result(#(value.Value, List(GraphQLError)), String) {
65105 case document {
66106 parser.Document(operations) -> {
···73113 // Execute the first executable operation
74114 case executable_ops {
75115 [operation, ..] ->
7676- execute_operation(operation, graphql_schema, ctx, fragments_dict)
116116+ execute_operation(
117117+ operation,
118118+ graphql_schema,
119119+ ctx,
120120+ fragments_dict,
121121+ type_registry,
122122+ )
77123 [] -> Error("No executable operations in document")
78124 }
79125 }
···112158 graphql_schema: schema.Schema,
113159 ctx: schema.Context,
114160 fragments: Dict(String, parser.Operation),
161161+ type_registry: Dict(String, schema.Type),
115162) -> Result(#(value.Value, List(GraphQLError)), String) {
116163 case operation {
117164 parser.Query(selection_set) -> {
···123170 ctx,
124171 fragments,
125172 [],
173173+ type_registry,
126174 )
127175 }
128128- parser.NamedQuery(_, _, selection_set) -> {
176176+ parser.NamedQuery(_name, variables, selection_set) -> {
129177 let root_type = schema.query_type(graphql_schema)
178178+ // Apply variable defaults
179179+ let merged_vars = apply_variable_defaults(variables, ctx.variables, ctx)
180180+ let ctx_with_defaults =
181181+ schema.Context(ctx.data, ctx.arguments, merged_vars)
130182 execute_selection_set(
131183 selection_set,
132184 root_type,
133185 graphql_schema,
134134- ctx,
186186+ ctx_with_defaults,
135187 fragments,
136188 [],
189189+ type_registry,
137190 )
138191 }
139192 parser.Mutation(selection_set) -> {
···147200 ctx,
148201 fragments,
149202 [],
203203+ type_registry,
150204 )
151205 option.None -> Error("Schema does not define a mutation type")
152206 }
153207 }
154154- parser.NamedMutation(_, _, selection_set) -> {
208208+ parser.NamedMutation(_name, variables, selection_set) -> {
155209 // Get mutation root type from schema
156210 case schema.get_mutation_type(graphql_schema) {
157157- option.Some(mutation_type) ->
211211+ option.Some(mutation_type) -> {
212212+ // Apply variable defaults
213213+ let merged_vars =
214214+ apply_variable_defaults(variables, ctx.variables, ctx)
215215+ let ctx_with_defaults =
216216+ schema.Context(ctx.data, ctx.arguments, merged_vars)
158217 execute_selection_set(
159218 selection_set,
160219 mutation_type,
161220 graphql_schema,
162162- ctx,
221221+ ctx_with_defaults,
163222 fragments,
164223 [],
224224+ type_registry,
165225 )
226226+ }
166227 option.None -> Error("Schema does not define a mutation type")
167228 }
168229 }
···177238 ctx,
178239 fragments,
179240 [],
241241+ type_registry,
180242 )
181243 option.None -> Error("Schema does not define a subscription type")
182244 }
183245 }
184184- parser.NamedSubscription(_, _, selection_set) -> {
246246+ parser.NamedSubscription(_name, variables, selection_set) -> {
185247 // Get subscription root type from schema
186248 case schema.get_subscription_type(graphql_schema) {
187187- option.Some(subscription_type) ->
249249+ option.Some(subscription_type) -> {
250250+ // Apply variable defaults
251251+ let merged_vars =
252252+ apply_variable_defaults(variables, ctx.variables, ctx)
253253+ let ctx_with_defaults =
254254+ schema.Context(ctx.data, ctx.arguments, merged_vars)
188255 execute_selection_set(
189256 selection_set,
190257 subscription_type,
191258 graphql_schema,
192192- ctx,
259259+ ctx_with_defaults,
193260 fragments,
194261 [],
262262+ type_registry,
195263 )
264264+ }
196265 option.None -> Error("Schema does not define a subscription type")
197266 }
198267 }
···209278 ctx: schema.Context,
210279 fragments: Dict(String, parser.Operation),
211280 path: List(String),
281281+ type_registry: Dict(String, schema.Type),
212282) -> Result(#(value.Value, List(GraphQLError)), String) {
213283 case selection_set {
214284 parser.SelectionSet(selections) -> {
···221291 ctx,
222292 fragments,
223293 path,
294294+ type_registry,
224295 )
225296 })
226297···274345 ctx: schema.Context,
275346 fragments: Dict(String, parser.Operation),
276347 path: List(String),
348348+ type_registry: Dict(String, schema.Type),
277349) -> Result(#(String, value.Value, List(GraphQLError)), String) {
278350 case selection {
279351 parser.FragmentSpread(name) -> {
···285357 type_condition,
286358 fragment_selection_set,
287359 )) -> {
288288- // Check type condition
289289- let current_type_name = schema.type_name(parent_type)
360360+ // Check type condition - use named_type_name to get the base type
361361+ // without NonNull/List wrappers, since fragments are defined on named types
362362+ let current_type_name = schema.named_type_name(parent_type)
290363 case type_condition == current_type_name {
291364 False -> {
292365 // Type condition doesn't match, skip this fragment
···303376 ctx,
304377 fragments,
305378 path,
379379+ type_registry,
306380 )
307381 {
308382 Ok(#(value.Object(fields), errs)) -> {
···320394 }
321395 }
322396 parser.InlineFragment(type_condition_opt, inline_selections) -> {
323323- // Check type condition if present
324324- let current_type_name = schema.type_name(parent_type)
397397+ // Check type condition if present - use named_type_name to get the base type
398398+ // without NonNull/List wrappers, since fragments are defined on named types
399399+ let current_type_name = schema.named_type_name(parent_type)
325400 let should_execute = case type_condition_opt {
326401 None -> True
327402 Some(type_condition) -> type_condition == current_type_name
···339414 ctx,
340415 fragments,
341416 path,
417417+ type_registry,
342418 )
343419 {
344420 Ok(#(value.Object(fields), errs)) ->
···359435 // Handle introspection meta-fields
360436 case name {
361437 "__typename" -> {
362362- let type_name = schema.type_name(parent_type)
438438+ // Use named_type_name to return the concrete type without modifiers
439439+ let type_name = schema.named_type_name(parent_type)
363440 Ok(#(key, value.String(type_name), []))
364441 }
365442 "__schema" -> {
···456533 Ok(#(key, value.Null, [error]))
457534 }
458535 Some(field) -> {
459459- // Get the field's type for nested selections
460460- let field_type_def = schema.field_type(field)
536536+ // Validate argument types before resolving
537537+ case validate_arguments(field, args_dict, [name, ..path]) {
538538+ Error(err) -> Ok(#(key, value.Null, [err]))
539539+ Ok(_) -> {
540540+ // Get the field's type for nested selections
541541+ let field_type_def = schema.field_type(field)
461542462462- // Create context with arguments (preserve variables from parent context)
463463- let field_ctx = schema.Context(ctx.data, args_dict, ctx.variables)
543543+ // Create context with arguments (preserve variables from parent context)
544544+ let field_ctx =
545545+ schema.Context(ctx.data, args_dict, ctx.variables)
464546465465- // Resolve the field
466466- case schema.resolve_field(field, field_ctx) {
467467- Error(err) -> {
468468- let error = GraphQLError(err, [name, ..path])
469469- Ok(#(key, value.Null, [error]))
470470- }
471471- Ok(field_value) -> {
472472- // If there are nested selections, recurse
473473- case nested_selections {
474474- [] -> Ok(#(key, field_value, []))
475475- _ -> {
476476- // Need to resolve nested fields
477477- case field_value {
478478- value.Object(_) -> {
479479- // Check if field_type_def is a union type
480480- // If so, resolve it to the concrete type first
481481- let type_to_use = case
482482- schema.is_union(field_type_def)
483483- {
484484- True -> {
485485- // Create context with the field value for type resolution
486486- let resolve_ctx =
487487- schema.context(option.Some(field_value))
488488- case
489489- schema.resolve_union_type(
490490- field_type_def,
491491- resolve_ctx,
492492- )
547547+ // Resolve the field
548548+ case schema.resolve_field(field, field_ctx) {
549549+ Error(err) -> {
550550+ let error = GraphQLError(err, [name, ..path])
551551+ Ok(#(key, value.Null, [error]))
552552+ }
553553+ Ok(field_value) -> {
554554+ // If there are nested selections, recurse
555555+ case nested_selections {
556556+ [] -> Ok(#(key, field_value, []))
557557+ _ -> {
558558+ // Need to resolve nested fields
559559+ case field_value {
560560+ value.Object(_) -> {
561561+ // Check if field_type_def is a union type
562562+ // If so, resolve it to the concrete type first using the registry
563563+ // Need to unwrap NonNull first since is_union only matches bare UnionType
564564+ let unwrapped_type = case
565565+ schema.inner_type(field_type_def)
493566 {
494494- Ok(concrete_type) -> concrete_type
495495- Error(_) -> field_type_def
496496- // Fallback to union type if resolution fails
567567+ option.Some(t) -> t
568568+ option.None -> field_type_def
497569 }
498498- }
499499- False -> field_type_def
500500- }
501501-502502- // Execute nested selections using the resolved type
503503- // Create new context with this object's data
504504- let object_ctx =
505505- schema.context(option.Some(field_value))
506506- let selection_set =
507507- parser.SelectionSet(nested_selections)
508508- case
509509- execute_selection_set(
510510- selection_set,
511511- type_to_use,
512512- graphql_schema,
513513- object_ctx,
514514- fragments,
515515- [name, ..path],
516516- )
517517- {
518518- Ok(#(nested_data, nested_errors)) ->
519519- Ok(#(key, nested_data, nested_errors))
520520- Error(err) -> {
521521- let error = GraphQLError(err, [name, ..path])
522522- Ok(#(key, value.Null, [error]))
523523- }
524524- }
525525- }
526526- value.List(items) -> {
527527- // Handle list with nested selections
528528- // Get the inner type from the LIST wrapper, unwrapping NonNull if needed
529529- let inner_type = case
530530- schema.inner_type(field_type_def)
531531- {
532532- option.Some(t) -> {
533533- // If the result is still wrapped (NonNull), unwrap it too
534534- case schema.inner_type(t) {
535535- option.Some(unwrapped) -> unwrapped
536536- option.None -> t
537537- }
538538- }
539539- option.None -> field_type_def
540540- }
541541-542542- // Execute nested selections on each item
543543- let selection_set =
544544- parser.SelectionSet(nested_selections)
545545- let results =
546546- list.map(items, fn(item) {
547547- // Check if inner_type is a union and resolve it
548548- let item_type = case schema.is_union(inner_type) {
570570+ let type_to_use = case
571571+ schema.is_union(unwrapped_type)
572572+ {
549573 True -> {
550550- // Create context with the item value for type resolution
574574+ // Create context with the field value for type resolution
551575 let resolve_ctx =
552552- schema.context(option.Some(item))
576576+ schema.context(option.Some(field_value))
553577 case
554554- schema.resolve_union_type(
555555- inner_type,
578578+ schema.resolve_union_type_with_registry(
579579+ unwrapped_type,
556580 resolve_ctx,
581581+ type_registry,
557582 )
558583 {
559584 Ok(concrete_type) -> concrete_type
560560- Error(_) -> inner_type
585585+ Error(_) -> field_type_def
561586 // Fallback to union type if resolution fails
562587 }
563588 }
564564- False -> inner_type
589589+ False -> field_type_def
565590 }
566591567567- // Create context with this item's data
568568- let item_ctx = schema.context(option.Some(item))
569569- execute_selection_set(
570570- selection_set,
571571- item_type,
572572- graphql_schema,
573573- item_ctx,
574574- fragments,
575575- [name, ..path],
576576- )
577577- })
578578-579579- // Collect results and errors
580580- let processed_items =
581581- results
582582- |> list.filter_map(fn(r) {
583583- case r {
584584- Ok(#(val, _)) -> Ok(val)
585585- Error(_) -> Error(Nil)
592592+ // Execute nested selections using the resolved type
593593+ // Create new context with this object's data, preserving variables
594594+ let object_ctx =
595595+ schema.context_with_variables(
596596+ option.Some(field_value),
597597+ ctx.variables,
598598+ )
599599+ let selection_set =
600600+ parser.SelectionSet(nested_selections)
601601+ case
602602+ execute_selection_set(
603603+ selection_set,
604604+ type_to_use,
605605+ graphql_schema,
606606+ object_ctx,
607607+ fragments,
608608+ [name, ..path],
609609+ type_registry,
610610+ )
611611+ {
612612+ Ok(#(nested_data, nested_errors)) ->
613613+ Ok(#(key, nested_data, nested_errors))
614614+ Error(err) -> {
615615+ let error = GraphQLError(err, [name, ..path])
616616+ Ok(#(key, value.Null, [error]))
617617+ }
586618 }
587587- })
588588-589589- let all_errors =
590590- results
591591- |> list.flat_map(fn(r) {
592592- case r {
593593- Ok(#(_, errs)) -> errs
594594- Error(_) -> []
619619+ }
620620+ value.List(items) -> {
621621+ // Handle list with nested selections
622622+ // Get the inner type from the LIST wrapper, unwrapping NonNull if needed
623623+ // Field type could be: NonNull[List[NonNull[Union]]] or List[NonNull[Union]] etc.
624624+ let inner_type = case
625625+ schema.inner_type(field_type_def)
626626+ {
627627+ option.Some(t) -> {
628628+ // If the result is still wrapped (NonNull), unwrap it too
629629+ case schema.inner_type(t) {
630630+ option.Some(unwrapped) -> unwrapped
631631+ option.None -> t
632632+ }
633633+ }
634634+ option.None -> field_type_def
595635 }
596596- })
597636598598- Ok(#(key, value.List(processed_items), all_errors))
637637+ // Execute nested selections on each item
638638+ let selection_set =
639639+ parser.SelectionSet(nested_selections)
640640+ let results =
641641+ list.map(items, fn(item) {
642642+ // Check if inner_type is a union and resolve it using the registry
643643+ // Need to unwrap NonNull to check for union since inner_type
644644+ // could be NonNull[Union] after unwrapping List[NonNull[Union]]
645645+ let unwrapped_inner = case
646646+ schema.inner_type(inner_type)
647647+ {
648648+ option.Some(t) -> t
649649+ option.None -> inner_type
650650+ }
651651+ let item_type = case
652652+ schema.is_union(unwrapped_inner)
653653+ {
654654+ True -> {
655655+ // Create context with the item value for type resolution
656656+ let resolve_ctx =
657657+ schema.context(option.Some(item))
658658+ case
659659+ schema.resolve_union_type_with_registry(
660660+ unwrapped_inner,
661661+ resolve_ctx,
662662+ type_registry,
663663+ )
664664+ {
665665+ Ok(concrete_type) -> concrete_type
666666+ Error(_) -> inner_type
667667+ // Fallback to union type if resolution fails
668668+ }
669669+ }
670670+ False -> inner_type
671671+ }
672672+673673+ // Create context with this item's data, preserving variables
674674+ let item_ctx =
675675+ schema.context_with_variables(
676676+ option.Some(item),
677677+ ctx.variables,
678678+ )
679679+ execute_selection_set(
680680+ selection_set,
681681+ item_type,
682682+ graphql_schema,
683683+ item_ctx,
684684+ fragments,
685685+ [name, ..path],
686686+ type_registry,
687687+ )
688688+ })
689689+690690+ // Collect results and errors
691691+ let processed_items =
692692+ results
693693+ |> list.filter_map(fn(r) {
694694+ case r {
695695+ Ok(#(val, _)) -> Ok(val)
696696+ Error(_) -> Error(Nil)
697697+ }
698698+ })
699699+700700+ let all_errors =
701701+ results
702702+ |> list.flat_map(fn(r) {
703703+ case r {
704704+ Ok(#(_, errs)) -> errs
705705+ Error(_) -> []
706706+ }
707707+ })
708708+709709+ Ok(#(key, value.List(processed_items), all_errors))
710710+ }
711711+ _ -> Ok(#(key, field_value, []))
712712+ }
599713 }
600600- _ -> Ok(#(key, field_value, []))
601714 }
602715 }
603716 }
···884997 ctx: schema.Context,
885998) -> value.Value {
886999 case arg_value {
887887- parser.IntValue(s) -> value.String(s)
888888- parser.FloatValue(s) -> value.String(s)
10001000+ parser.IntValue(s) -> {
10011001+ case int.parse(s) {
10021002+ Ok(n) -> value.Int(n)
10031003+ Error(_) -> value.String(s)
10041004+ }
10051005+ }
10061006+ parser.FloatValue(s) -> {
10071007+ case float.parse(s) {
10081008+ Ok(f) -> value.Float(f)
10091009+ Error(_) -> value.String(s)
10101010+ }
10111011+ }
8891012 parser.StringValue(s) -> value.String(s)
8901013 parser.BooleanValue(b) -> value.Boolean(b)
8911014 parser.NullValue -> value.Null
···9251048 }
9261049 })
9271050}
10511051+10521052+/// Validate that an argument value matches its declared type
10531053+fn validate_argument_type(
10541054+ arg_name: String,
10551055+ expected_type: schema.Type,
10561056+ actual_value: value.Value,
10571057+ path: List(String),
10581058+) -> Result(Nil, GraphQLError) {
10591059+ // Unwrap NonNull wrapper to get the base type (but not List wrapper)
10601060+ let base_type = case schema.is_non_null(expected_type) {
10611061+ True ->
10621062+ case schema.inner_type(expected_type) {
10631063+ option.Some(inner) -> inner
10641064+ option.None -> expected_type
10651065+ }
10661066+ False -> expected_type
10671067+ }
10681068+10691069+ case schema.is_list(base_type), actual_value {
10701070+ // List type expects a list value - reject object
10711071+ True, value.Object(_) ->
10721072+ Error(GraphQLError(
10731073+ "Argument '"
10741074+ <> arg_name
10751075+ <> "' expects a list, not an object. Use ["
10761076+ <> arg_name
10771077+ <> ": {...}] instead of "
10781078+ <> arg_name
10791079+ <> ": {...}",
10801080+ path,
10811081+ ))
10821082+ // All other cases are ok (for now - can add more validation later)
10831083+ _, _ -> Ok(Nil)
10841084+ }
10851085+}
10861086+10871087+/// Validate all provided arguments against a field's declared argument types
10881088+fn validate_arguments(
10891089+ field: schema.Field,
10901090+ args_dict: Dict(String, value.Value),
10911091+ path: List(String),
10921092+) -> Result(Nil, GraphQLError) {
10931093+ let declared_args = schema.field_arguments(field)
10941094+10951095+ // For each provided argument, check if it matches the declared type
10961096+ list.try_each(dict.to_list(args_dict), fn(arg_pair) {
10971097+ let #(arg_name, arg_value) = arg_pair
10981098+10991099+ // Find the declared argument
11001100+ case
11011101+ list.find(declared_args, fn(a) { schema.argument_name(a) == arg_name })
11021102+ {
11031103+ Ok(declared_arg) -> {
11041104+ let expected_type = schema.argument_type(declared_arg)
11051105+ validate_argument_type(arg_name, expected_type, arg_value, path)
11061106+ }
11071107+ Error(_) -> Ok(Nil)
11081108+ // Unknown argument - let schema handle it
11091109+ }
11101110+ })
11111111+}
+78-16
src/swell/introspection.gleam
···66import gleam/list
77import gleam/option
88import gleam/result
99+import gleam/string
910import swell/schema
1011import swell/value
1112···101102 !list.contains(collected_names, built_in_name)
102103 })
103104104104- list.append(unique_types, missing_built_ins)
105105+ let all_types = list.append(unique_types, missing_built_ins)
106106+107107+ // Build a canonical type map for normalization
108108+ let type_map = build_type_map(all_types)
109109+110110+ // Normalize union types so their possible_types reference canonical instances
111111+ list.map(all_types, fn(t) { normalize_union_possible_types(t, type_map) })
112112+}
113113+114114+/// Build a map from type name to canonical type instance
115115+/// This creates a registry that can be used to look up types by name,
116116+/// ensuring consistent type references throughout the system.
117117+pub fn build_type_map(
118118+ types: List(schema.Type),
119119+) -> dict.Dict(String, schema.Type) {
120120+ list.fold(types, dict.new(), fn(acc, t) {
121121+ dict.insert(acc, schema.type_name(t), t)
122122+ })
123123+}
124124+125125+/// For union types, replace possible_types with canonical instances from the type map
126126+/// This ensures union.possible_types returns the same instances as found elsewhere
127127+fn normalize_union_possible_types(
128128+ t: schema.Type,
129129+ type_map: dict.Dict(String, schema.Type),
130130+) -> schema.Type {
131131+ case schema.is_union(t) {
132132+ True -> {
133133+ let original_possible = schema.get_possible_types(t)
134134+ let normalized_possible =
135135+ list.filter_map(original_possible, fn(pt) {
136136+ dict.get(type_map, schema.type_name(pt))
137137+ })
138138+ schema.union_type(
139139+ schema.type_name(t),
140140+ schema.type_description(t),
141141+ normalized_possible,
142142+ schema.get_union_type_resolver(t),
143143+ )
144144+ }
145145+ False -> t
146146+ }
105147}
106148107149/// Get all types from the schema
108150fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) {
109151 let all_types = get_all_schema_types(graphql_schema)
110152111111- // Convert all types to introspection values
112112- list.map(all_types, type_introspection)
153153+ // Sort types alphabetically by name, then convert to introspection values
154154+ all_types
155155+ |> list.sort(fn(a, b) {
156156+ string.compare(schema.type_name(a), schema.type_name(b))
157157+ })
158158+ |> list.map(type_introspection)
113159}
114160115161/// Deduplicate types by name, keeping the version with the most fields
···182228 schema.is_object(t) || schema.is_enum(t) || schema.is_union(t)
183229 {
184230 True -> {
185185- let current_content_count = get_type_content_count(t)
186231 let existing_with_same_name =
187232 list.filter(acc, fn(existing) {
188233 schema.type_name(existing) == schema.type_name(t)
189234 })
190190- let max_existing_content =
191191- existing_with_same_name
192192- |> list.map(get_type_content_count)
193193- |> list.reduce(fn(a, b) {
194194- case a > b {
195195- True -> a
196196- False -> b
197197- }
198198- })
199199- |> result.unwrap(0)
235235+236236+ // If this is the first time we've seen this type name, always traverse
237237+ // This is critical for unions which have 0 content count but still need
238238+ // their possible_types to be traversed
239239+ case list.is_empty(existing_with_same_name) {
240240+ True -> True
241241+ False -> {
242242+ // For subsequent encounters, only traverse if this instance has more content
243243+ let current_content_count = get_type_content_count(t)
244244+ let max_existing_content =
245245+ existing_with_same_name
246246+ |> list.map(get_type_content_count)
247247+ |> list.reduce(fn(a, b) {
248248+ case a > b {
249249+ True -> a
250250+ False -> b
251251+ }
252252+ })
253253+ |> result.unwrap(0)
200254201201- // Only traverse if this instance has more content than we've seen before
202202- current_content_count > max_existing_content
255255+ current_content_count > max_existing_content
256256+ }
257257+ }
203258 }
204259 False -> True
205260 }
···323378 desc -> value.String(desc)
324379 }
325380381381+ // isOneOf for INPUT_OBJECT types (GraphQL spec addition)
382382+ let is_one_of = case kind {
383383+ "INPUT_OBJECT" -> value.Boolean(False)
384384+ _ -> value.Null
385385+ }
386386+326387 value.Object([
327388 #("kind", value.String(kind)),
328389 #("name", name),
···333394 #("enumValues", enum_values),
334395 #("inputFields", input_fields),
335396 #("ofType", of_type),
397397+ #("isOneOf", is_one_of),
336398 ])
337399}
338400
+64-15
src/swell/parser.gleam
···72727373/// Variable definition
7474pub type Variable {
7575- Variable(name: String, type_: String)
7575+ Variable(name: String, type_: String, default_value: Option(ArgumentValue))
7676}
77777878pub type ParseError {
···543543 // Skip commas
544544 [lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc)
545545546546- // Parse a variable: $name: Type! or $name: Type
546546+ // Parse a variable: $name: Type = defaultValue or $name: Type
547547 [lexer.Dollar, lexer.Name(var_name), lexer.Colon, ..rest] -> {
548548- // Parse the type (Name or Name!)
549549- case rest {
550550- [lexer.Name(type_name), lexer.Exclamation, ..rest2] -> {
551551- // Non-null type
552552- let variable = Variable(var_name, type_name <> "!")
553553- parse_variable_definitions_loop(rest2, [variable, ..acc])
548548+ // Parse the type
549549+ case parse_type_reference(rest) {
550550+ Ok(#(type_str, rest2)) -> {
551551+ // Check for default value
552552+ case rest2 {
553553+ [lexer.Equals, ..rest3] -> {
554554+ // Parse the default value
555555+ case parse_argument_value(rest3) {
556556+ Ok(#(default_val, rest4)) -> {
557557+ let variable = Variable(var_name, type_str, Some(default_val))
558558+ parse_variable_definitions_loop(rest4, [variable, ..acc])
559559+ }
560560+ Error(err) -> Error(err)
561561+ }
562562+ }
563563+ _ -> {
564564+ // No default value
565565+ let variable = Variable(var_name, type_str, None)
566566+ parse_variable_definitions_loop(rest2, [variable, ..acc])
567567+ }
568568+ }
554569 }
555555- [lexer.Name(type_name), ..rest2] -> {
556556- // Nullable type
557557- let variable = Variable(var_name, type_name)
558558- parse_variable_definitions_loop(rest2, [variable, ..acc])
559559- }
560560- [] -> Error(UnexpectedEndOfInput("Expected type after :"))
561561- [token, ..] -> Error(UnexpectedToken(token, "Expected type name"))
570570+ Error(err) -> Error(err)
562571 }
563572 }
564573···566575 [token, ..] -> Error(UnexpectedToken(token, "Expected $variableName or )"))
567576 }
568577}
578578+579579+/// Parse a type reference (e.g., String, String!, [String], [String!], [String]!, [String!]!)
580580+fn parse_type_reference(
581581+ tokens: List(lexer.Token),
582582+) -> Result(#(String, List(lexer.Token)), ParseError) {
583583+ case tokens {
584584+ // List type: [Type] or [Type!] or [Type]! or [Type!]!
585585+ [lexer.BracketOpen, ..rest] -> {
586586+ case rest {
587587+ [
588588+ lexer.Name(inner_type),
589589+ lexer.Exclamation,
590590+ lexer.BracketClose,
591591+ lexer.Exclamation,
592592+ ..rest2
593593+ ] ->
594594+ // [Type!]!
595595+ Ok(#("[" <> inner_type <> "!]!", rest2))
596596+ [lexer.Name(inner_type), lexer.Exclamation, lexer.BracketClose, ..rest2] ->
597597+ // [Type!]
598598+ Ok(#("[" <> inner_type <> "!]", rest2))
599599+ [lexer.Name(inner_type), lexer.BracketClose, lexer.Exclamation, ..rest2] ->
600600+ // [Type]!
601601+ Ok(#("[" <> inner_type <> "]!", rest2))
602602+ [lexer.Name(inner_type), lexer.BracketClose, ..rest2] ->
603603+ // [Type]
604604+ Ok(#("[" <> inner_type <> "]", rest2))
605605+ [] -> Error(UnexpectedEndOfInput("Expected type name in list"))
606606+ [token, ..] ->
607607+ Error(UnexpectedToken(token, "Expected type name in list"))
608608+ }
609609+ }
610610+ // Simple type: Type! or Type
611611+ [lexer.Name(type_name), lexer.Exclamation, ..rest] ->
612612+ Ok(#(type_name <> "!", rest))
613613+ [lexer.Name(type_name), ..rest] -> Ok(#(type_name, rest))
614614+ [] -> Error(UnexpectedEndOfInput("Expected type"))
615615+ [token, ..] -> Error(UnexpectedToken(token, "Expected type name"))
616616+ }
617617+}
+59
src/swell/schema.gleam
···239239 }
240240}
241241242242+/// Get the named (base) type name, unwrapping List and NonNull wrappers.
243243+/// This is used for fragment type condition matching and __typename where
244244+/// we need the concrete type name without modifiers.
245245+pub fn named_type_name(t: Type) -> String {
246246+ case t {
247247+ ScalarType(name) -> name
248248+ ObjectType(name, _, _) -> name
249249+ InputObjectType(name, _, _) -> name
250250+ EnumType(name, _, _) -> name
251251+ UnionType(name, _, _, _) -> name
252252+ ListType(inner) -> named_type_name(inner)
253253+ NonNullType(inner) -> named_type_name(inner)
254254+ }
255255+}
256256+242257pub fn field_name(f: Field) -> String {
243258 case f {
244259 Field(name, _, _, _, _) -> name
···456471 }
457472}
458473474474+/// Get the type resolver function from a union type.
475475+/// The type resolver is called at runtime to determine which concrete type
476476+/// a union value represents, based on the data in the context.
477477+/// Returns a default resolver that always errors for non-union types.
478478+/// This is primarily used internally for union type normalization.
479479+pub fn get_union_type_resolver(t: Type) -> fn(Context) -> Result(String, String) {
480480+ case t {
481481+ UnionType(_, _, _, type_resolver) -> type_resolver
482482+ _ -> fn(_) { Error("Not a union type") }
483483+ }
484484+}
485485+459486/// Resolve a union type to its concrete type using the type resolver
460487pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) {
461488 case t {
···475502 "Type resolver returned '"
476503 <> resolved_type_name
477504 <> "' which is not a possible type of this union",
505505+ )
506506+ }
507507+ }
508508+ Error(err) -> Error(err)
509509+ }
510510+ }
511511+ _ -> Error("Cannot resolve non-union type")
512512+ }
513513+}
514514+515515+/// Resolve a union type using a canonical type registry
516516+/// This looks up the concrete type by name from the registry instead of from
517517+/// the union's possible_types, ensuring we get the most complete type definition
518518+pub fn resolve_union_type_with_registry(
519519+ t: Type,
520520+ ctx: Context,
521521+ type_registry: dict.Dict(String, Type),
522522+) -> Result(Type, String) {
523523+ case t {
524524+ UnionType(_, _, _, type_resolver) -> {
525525+ // Call the type resolver to get the concrete type name
526526+ case type_resolver(ctx) {
527527+ Ok(resolved_type_name) -> {
528528+ // Look up the type from the canonical registry
529529+ case dict.get(type_registry, resolved_type_name) {
530530+ Ok(concrete_type) -> Ok(concrete_type)
531531+ Error(_) ->
532532+ Error(
533533+ "Type resolver returned '"
534534+ <> resolved_type_name
535535+ <> "' which is not in the type registry. "
536536+ <> "This may indicate the schema is incomplete or the type resolver is misconfigured.",
478537 )
479538 }
480539 }
+804
test/executor_test.gleam
···206206 )
207207}
208208209209+// Test for fragment spread on NonNull wrapped type
210210+pub fn execute_fragment_spread_on_non_null_type_test() {
211211+ // Create a schema where the user field returns a NonNull type
212212+ let user_type =
213213+ schema.object_type("User", "A user", [
214214+ schema.field("id", schema.id_type(), "User ID", fn(_ctx) {
215215+ Ok(value.String("123"))
216216+ }),
217217+ schema.field("name", schema.string_type(), "User name", fn(_ctx) {
218218+ Ok(value.String("Alice"))
219219+ }),
220220+ ])
221221+222222+ let query_type =
223223+ schema.object_type("Query", "Root query type", [
224224+ // Wrap user_type in NonNull to test fragment type condition matching
225225+ schema.field("user", schema.non_null(user_type), "Get user", fn(_ctx) {
226226+ Ok(
227227+ value.Object([
228228+ #("id", value.String("123")),
229229+ #("name", value.String("Alice")),
230230+ ]),
231231+ )
232232+ }),
233233+ ])
234234+235235+ let test_schema = schema.schema(query_type, None)
236236+237237+ // Fragment is defined on "User" (not "User!") - this should still work
238238+ let query =
239239+ "
240240+ fragment UserFields on User {
241241+ id
242242+ name
243243+ }
244244+245245+ { user { ...UserFields } }
246246+ "
247247+248248+ let result = executor.execute(query, test_schema, schema.context(None))
249249+250250+ let response = case result {
251251+ Ok(r) -> r
252252+ Error(_) -> panic as "Execution failed"
253253+ }
254254+255255+ birdie.snap(
256256+ title: "Execute fragment spread on NonNull type",
257257+ content: format_response(response),
258258+ )
259259+}
260260+209261// Test for list fields with nested selections
210262pub fn execute_list_with_nested_selections_test() {
211263 // Create a schema with a list field
···865917 content: format_response(response),
866918 )
867919}
920920+921921+pub fn execute_query_with_variable_default_value_test() {
922922+ let query_type =
923923+ schema.object_type("Query", "Root query type", [
924924+ schema.field_with_args(
925925+ "greet",
926926+ schema.string_type(),
927927+ "Greet someone",
928928+ [schema.argument("name", schema.string_type(), "Name to greet", None)],
929929+ fn(ctx) {
930930+ case schema.get_argument(ctx, "name") {
931931+ option.Some(value.String(name)) ->
932932+ Ok(value.String("Hello, " <> name <> "!"))
933933+ _ -> Ok(value.String("Hello, stranger!"))
934934+ }
935935+ },
936936+ ),
937937+ ])
938938+939939+ let test_schema = schema.schema(query_type, None)
940940+ let query = "query Test($name: String = \"World\") { greet(name: $name) }"
941941+942942+ // No variables provided - should use default
943943+ let ctx = schema.context_with_variables(None, dict.new())
944944+945945+ let result = executor.execute(query, test_schema, ctx)
946946+947947+ // Debug: print the actual result
948948+ let assert Ok(response) = result
949949+ let assert executor.Response(data: value.Object(fields), errors: _) = response
950950+ let assert Ok(greet_value) = list.key_find(fields, "greet")
951951+952952+ // Should use default value "World" since no variable provided
953953+ greet_value
954954+ |> should.equal(value.String("Hello, World!"))
955955+}
956956+957957+pub fn execute_query_with_variable_overriding_default_test() {
958958+ let query_type =
959959+ schema.object_type("Query", "Root query type", [
960960+ schema.field_with_args(
961961+ "greet",
962962+ schema.string_type(),
963963+ "Greet someone",
964964+ [schema.argument("name", schema.string_type(), "Name to greet", None)],
965965+ fn(ctx) {
966966+ case schema.get_argument(ctx, "name") {
967967+ option.Some(value.String(name)) ->
968968+ Ok(value.String("Hello, " <> name <> "!"))
969969+ _ -> Ok(value.String("Hello, stranger!"))
970970+ }
971971+ },
972972+ ),
973973+ ])
974974+975975+ let test_schema = schema.schema(query_type, None)
976976+ let query = "query Test($name: String = \"World\") { greet(name: $name) }"
977977+978978+ // Provide variable - should override default
979979+ let variables = dict.from_list([#("name", value.String("Alice"))])
980980+ let ctx = schema.context_with_variables(None, variables)
981981+982982+ let result = executor.execute(query, test_schema, ctx)
983983+ result
984984+ |> should.be_ok
985985+ |> fn(response) {
986986+ case response {
987987+ executor.Response(data: value.Object(fields), errors: _) -> {
988988+ case list.key_find(fields, "greet") {
989989+ Ok(value.String("Hello, Alice!")) -> True
990990+ _ -> False
991991+ }
992992+ }
993993+ _ -> False
994994+ }
995995+ }
996996+ |> should.be_true
997997+}
998998+999999+// Test: List argument rejects object value
10001000+pub fn list_argument_rejects_object_test() {
10011001+ // Create a schema with a field that has a list argument
10021002+ let list_arg_field =
10031003+ schema.field_with_args(
10041004+ "items",
10051005+ schema.string_type(),
10061006+ "Test field with list arg",
10071007+ [
10081008+ schema.argument(
10091009+ "ids",
10101010+ schema.list_type(schema.string_type()),
10111011+ "List of IDs",
10121012+ None,
10131013+ ),
10141014+ ],
10151015+ fn(_ctx) { Ok(value.String("test")) },
10161016+ )
10171017+10181018+ let query_type = schema.object_type("Query", "Root", [list_arg_field])
10191019+ let s = schema.schema(query_type, None)
10201020+10211021+ // Query with object instead of list should produce an error
10221022+ let query = "{ items(ids: {foo: \"bar\"}) }"
10231023+ let result = executor.execute(query, s, schema.context(None))
10241024+10251025+ case result {
10261026+ Ok(executor.Response(_, errors)) -> {
10271027+ // Should have exactly one error
10281028+ list.length(errors)
10291029+ |> should.equal(1)
10301030+10311031+ // Check error message mentions list vs object
10321032+ case list.first(errors) {
10331033+ Ok(executor.GraphQLError(message, _)) -> {
10341034+ string.contains(message, "expects a list")
10351035+ |> should.be_true
10361036+ string.contains(message, "not an object")
10371037+ |> should.be_true
10381038+ }
10391039+ Error(_) -> should.fail()
10401040+ }
10411041+ }
10421042+ Error(_) -> should.fail()
10431043+ }
10441044+}
10451045+10461046+// Test: List argument accepts list value (sanity check)
10471047+pub fn list_argument_accepts_list_test() {
10481048+ // Create a schema with a field that has a list argument
10491049+ let list_arg_field =
10501050+ schema.field_with_args(
10511051+ "items",
10521052+ schema.string_type(),
10531053+ "Test field with list arg",
10541054+ [
10551055+ schema.argument(
10561056+ "ids",
10571057+ schema.list_type(schema.string_type()),
10581058+ "List of IDs",
10591059+ None,
10601060+ ),
10611061+ ],
10621062+ fn(_ctx) { Ok(value.String("success")) },
10631063+ )
10641064+10651065+ let query_type = schema.object_type("Query", "Root", [list_arg_field])
10661066+ let s = schema.schema(query_type, None)
10671067+10681068+ // Query with proper list should work
10691069+ let query = "{ items(ids: [\"a\", \"b\"]) }"
10701070+ let result = executor.execute(query, s, schema.context(None))
10711071+10721072+ case result {
10731073+ Ok(executor.Response(value.Object(fields), errors)) -> {
10741074+ // Should have no errors
10751075+ list.length(errors)
10761076+ |> should.equal(0)
10771077+10781078+ // Should return the value
10791079+ case list.key_find(fields, "items") {
10801080+ Ok(value.String("success")) -> should.be_true(True)
10811081+ _ -> should.fail()
10821082+ }
10831083+ }
10841084+ _ -> should.fail()
10851085+ }
10861086+}
10871087+10881088+// Test: Union resolution uses canonical type registry
10891089+// This verifies that when resolving a union type, all fields from the
10901090+// canonical type definition are accessible, not just those from the
10911091+// union's internal possible_types copy
10921092+pub fn execute_union_with_all_fields_via_registry_test() {
10931093+ // Create a type with multiple fields to verify complete resolution
10941094+ let article_type =
10951095+ schema.object_type("Article", "An article", [
10961096+ schema.field("id", schema.id_type(), "Article ID", fn(ctx) {
10971097+ case ctx.data {
10981098+ option.Some(value.Object(fields)) -> {
10991099+ case list.key_find(fields, "id") {
11001100+ Ok(id_val) -> Ok(id_val)
11011101+ Error(_) -> Ok(value.Null)
11021102+ }
11031103+ }
11041104+ _ -> Ok(value.Null)
11051105+ }
11061106+ }),
11071107+ schema.field("title", schema.string_type(), "Article title", fn(ctx) {
11081108+ case ctx.data {
11091109+ option.Some(value.Object(fields)) -> {
11101110+ case list.key_find(fields, "title") {
11111111+ Ok(title_val) -> Ok(title_val)
11121112+ Error(_) -> Ok(value.Null)
11131113+ }
11141114+ }
11151115+ _ -> Ok(value.Null)
11161116+ }
11171117+ }),
11181118+ schema.field("body", schema.string_type(), "Article body", fn(ctx) {
11191119+ case ctx.data {
11201120+ option.Some(value.Object(fields)) -> {
11211121+ case list.key_find(fields, "body") {
11221122+ Ok(body_val) -> Ok(body_val)
11231123+ Error(_) -> Ok(value.Null)
11241124+ }
11251125+ }
11261126+ _ -> Ok(value.Null)
11271127+ }
11281128+ }),
11291129+ schema.field("author", schema.string_type(), "Article author", fn(ctx) {
11301130+ case ctx.data {
11311131+ option.Some(value.Object(fields)) -> {
11321132+ case list.key_find(fields, "author") {
11331133+ Ok(author_val) -> Ok(author_val)
11341134+ Error(_) -> Ok(value.Null)
11351135+ }
11361136+ }
11371137+ _ -> Ok(value.Null)
11381138+ }
11391139+ }),
11401140+ ])
11411141+11421142+ let video_type =
11431143+ schema.object_type("Video", "A video", [
11441144+ schema.field("id", schema.id_type(), "Video ID", fn(ctx) {
11451145+ case ctx.data {
11461146+ option.Some(value.Object(fields)) -> {
11471147+ case list.key_find(fields, "id") {
11481148+ Ok(id_val) -> Ok(id_val)
11491149+ Error(_) -> Ok(value.Null)
11501150+ }
11511151+ }
11521152+ _ -> Ok(value.Null)
11531153+ }
11541154+ }),
11551155+ schema.field("title", schema.string_type(), "Video title", fn(ctx) {
11561156+ case ctx.data {
11571157+ option.Some(value.Object(fields)) -> {
11581158+ case list.key_find(fields, "title") {
11591159+ Ok(title_val) -> Ok(title_val)
11601160+ Error(_) -> Ok(value.Null)
11611161+ }
11621162+ }
11631163+ _ -> Ok(value.Null)
11641164+ }
11651165+ }),
11661166+ schema.field("duration", schema.int_type(), "Video duration", fn(ctx) {
11671167+ case ctx.data {
11681168+ option.Some(value.Object(fields)) -> {
11691169+ case list.key_find(fields, "duration") {
11701170+ Ok(duration_val) -> Ok(duration_val)
11711171+ Error(_) -> Ok(value.Null)
11721172+ }
11731173+ }
11741174+ _ -> Ok(value.Null)
11751175+ }
11761176+ }),
11771177+ ])
11781178+11791179+ // Type resolver that examines the __typename field
11801180+ let type_resolver = fn(ctx: schema.Context) -> Result(String, String) {
11811181+ case ctx.data {
11821182+ option.Some(value.Object(fields)) -> {
11831183+ case list.key_find(fields, "__typename") {
11841184+ Ok(value.String(type_name)) -> Ok(type_name)
11851185+ _ -> Error("No __typename field found")
11861186+ }
11871187+ }
11881188+ _ -> Error("No data")
11891189+ }
11901190+ }
11911191+11921192+ // Create union type
11931193+ let content_union =
11941194+ schema.union_type(
11951195+ "Content",
11961196+ "Content union",
11971197+ [article_type, video_type],
11981198+ type_resolver,
11991199+ )
12001200+12011201+ // Create query type with a field returning the union
12021202+ let query_type =
12031203+ schema.object_type("Query", "Root query type", [
12041204+ schema.field("content", content_union, "Get content", fn(_ctx) {
12051205+ // Return an Article with all fields populated
12061206+ Ok(
12071207+ value.Object([
12081208+ #("__typename", value.String("Article")),
12091209+ #("id", value.String("article-1")),
12101210+ #("title", value.String("GraphQL Best Practices")),
12111211+ #("body", value.String("Here are some tips...")),
12121212+ #("author", value.String("Jane Doe")),
12131213+ ]),
12141214+ )
12151215+ }),
12161216+ ])
12171217+12181218+ let test_schema = schema.schema(query_type, None)
12191219+12201220+ // Query requesting ALL fields from the Article type
12211221+ // If the type registry works correctly, all 4 fields should be returned
12221222+ let query =
12231223+ "
12241224+ {
12251225+ content {
12261226+ ... on Article {
12271227+ id
12281228+ title
12291229+ body
12301230+ author
12311231+ }
12321232+ ... on Video {
12331233+ id
12341234+ title
12351235+ duration
12361236+ }
12371237+ }
12381238+ }
12391239+ "
12401240+12411241+ let result = executor.execute(query, test_schema, schema.context(None))
12421242+12431243+ case result {
12441244+ Ok(executor.Response(value.Object(fields), errors)) -> {
12451245+ // Should have no errors
12461246+ list.length(errors)
12471247+ |> should.equal(0)
12481248+12491249+ // Check the content field
12501250+ case list.key_find(fields, "content") {
12511251+ Ok(value.Object(content_fields)) -> {
12521252+ // Should have all 4 Article fields
12531253+ list.length(content_fields)
12541254+ |> should.equal(4)
12551255+12561256+ // Verify each field is present with correct value
12571257+ case list.key_find(content_fields, "id") {
12581258+ Ok(value.String("article-1")) -> should.be_true(True)
12591259+ _ -> should.fail()
12601260+ }
12611261+ case list.key_find(content_fields, "title") {
12621262+ Ok(value.String("GraphQL Best Practices")) -> should.be_true(True)
12631263+ _ -> should.fail()
12641264+ }
12651265+ case list.key_find(content_fields, "body") {
12661266+ Ok(value.String("Here are some tips...")) -> should.be_true(True)
12671267+ _ -> should.fail()
12681268+ }
12691269+ case list.key_find(content_fields, "author") {
12701270+ Ok(value.String("Jane Doe")) -> should.be_true(True)
12711271+ _ -> should.fail()
12721272+ }
12731273+ }
12741274+ _ -> should.fail()
12751275+ }
12761276+ }
12771277+ Error(err) -> {
12781278+ // Print error for debugging
12791279+ should.equal(err, "")
12801280+ }
12811281+ _ -> should.fail()
12821282+ }
12831283+}
12841284+12851285+// Test: Variables are preserved in nested object selections
12861286+// This verifies that when traversing into a nested object, variables
12871287+// from the parent context are still accessible
12881288+pub fn execute_variables_preserved_in_nested_object_test() {
12891289+ // Create a nested type structure where the nested resolver needs access to variables
12901290+ let post_type =
12911291+ schema.object_type("Post", "A post", [
12921292+ schema.field("title", schema.string_type(), "Post title", fn(ctx) {
12931293+ case ctx.data {
12941294+ option.Some(value.Object(fields)) -> {
12951295+ case list.key_find(fields, "title") {
12961296+ Ok(title_val) -> Ok(title_val)
12971297+ Error(_) -> Ok(value.Null)
12981298+ }
12991299+ }
13001300+ _ -> Ok(value.Null)
13011301+ }
13021302+ }),
13031303+ // This field uses a variable from the outer context
13041304+ schema.field_with_args(
13051305+ "formattedTitle",
13061306+ schema.string_type(),
13071307+ "Formatted title",
13081308+ [schema.argument("prefix", schema.string_type(), "Prefix to add", None)],
13091309+ fn(ctx) {
13101310+ let prefix = case schema.get_argument(ctx, "prefix") {
13111311+ Some(value.String(p)) -> p
13121312+ _ -> ""
13131313+ }
13141314+ case ctx.data {
13151315+ option.Some(value.Object(fields)) -> {
13161316+ case list.key_find(fields, "title") {
13171317+ Ok(value.String(title)) ->
13181318+ Ok(value.String(prefix <> ": " <> title))
13191319+ _ -> Ok(value.Null)
13201320+ }
13211321+ }
13221322+ _ -> Ok(value.Null)
13231323+ }
13241324+ },
13251325+ ),
13261326+ ])
13271327+13281328+ let query_type =
13291329+ schema.object_type("Query", "Root query type", [
13301330+ schema.field("post", post_type, "Get a post", fn(_ctx) {
13311331+ Ok(value.Object([#("title", value.String("Hello World"))]))
13321332+ }),
13331333+ ])
13341334+13351335+ let test_schema = schema.schema(query_type, None)
13361336+13371337+ // Query using a variable in a nested field
13381338+ let query =
13391339+ "query GetPost($prefix: String!) { post { formattedTitle(prefix: $prefix) } }"
13401340+13411341+ // Create context with variables
13421342+ let variables = dict.from_list([#("prefix", value.String("Article"))])
13431343+ let ctx = schema.context_with_variables(None, variables)
13441344+13451345+ let result = executor.execute(query, test_schema, ctx)
13461346+13471347+ case result {
13481348+ Ok(executor.Response(data: value.Object(fields), errors: _)) -> {
13491349+ case list.key_find(fields, "post") {
13501350+ Ok(value.Object(post_fields)) -> {
13511351+ case list.key_find(post_fields, "formattedTitle") {
13521352+ Ok(value.String("Article: Hello World")) -> should.be_true(True)
13531353+ Ok(other) -> {
13541354+ // Variable was lost - this is the bug we're testing for
13551355+ should.equal(other, value.String("Article: Hello World"))
13561356+ }
13571357+ Error(_) -> should.fail()
13581358+ }
13591359+ }
13601360+ _ -> should.fail()
13611361+ }
13621362+ }
13631363+ Error(err) -> should.equal(err, "")
13641364+ _ -> should.fail()
13651365+ }
13661366+}
13671367+13681368+// Test: Variables are preserved in nested list item selections
13691369+// This verifies that when iterating over list items, variables
13701370+// from the parent context are still accessible to each item's resolvers
13711371+pub fn execute_variables_preserved_in_nested_list_test() {
13721372+ // Create a type structure where list item resolvers need access to variables
13731373+ let item_type =
13741374+ schema.object_type("Item", "An item", [
13751375+ schema.field("name", schema.string_type(), "Item name", fn(ctx) {
13761376+ case ctx.data {
13771377+ option.Some(value.Object(fields)) -> {
13781378+ case list.key_find(fields, "name") {
13791379+ Ok(name_val) -> Ok(name_val)
13801380+ Error(_) -> Ok(value.Null)
13811381+ }
13821382+ }
13831383+ _ -> Ok(value.Null)
13841384+ }
13851385+ }),
13861386+ // This field uses a variable from the outer context
13871387+ schema.field_with_args(
13881388+ "formattedName",
13891389+ schema.string_type(),
13901390+ "Formatted name",
13911391+ [schema.argument("suffix", schema.string_type(), "Suffix to add", None)],
13921392+ fn(ctx) {
13931393+ let suffix = case schema.get_argument(ctx, "suffix") {
13941394+ Some(value.String(s)) -> s
13951395+ _ -> ""
13961396+ }
13971397+ case ctx.data {
13981398+ option.Some(value.Object(fields)) -> {
13991399+ case list.key_find(fields, "name") {
14001400+ Ok(value.String(name)) ->
14011401+ Ok(value.String(name <> " " <> suffix))
14021402+ _ -> Ok(value.Null)
14031403+ }
14041404+ }
14051405+ _ -> Ok(value.Null)
14061406+ }
14071407+ },
14081408+ ),
14091409+ ])
14101410+14111411+ let query_type =
14121412+ schema.object_type("Query", "Root query type", [
14131413+ schema.field("items", schema.list_type(item_type), "Get items", fn(_ctx) {
14141414+ Ok(
14151415+ value.List([
14161416+ value.Object([#("name", value.String("Apple"))]),
14171417+ value.Object([#("name", value.String("Banana"))]),
14181418+ ]),
14191419+ )
14201420+ }),
14211421+ ])
14221422+14231423+ let test_schema = schema.schema(query_type, None)
14241424+14251425+ // Query using a variable in nested list item fields
14261426+ let query =
14271427+ "query GetItems($suffix: String!) { items { formattedName(suffix: $suffix) } }"
14281428+14291429+ // Create context with variables
14301430+ let variables = dict.from_list([#("suffix", value.String("(organic)"))])
14311431+ let ctx = schema.context_with_variables(None, variables)
14321432+14331433+ let result = executor.execute(query, test_schema, ctx)
14341434+14351435+ case result {
14361436+ Ok(executor.Response(data: value.Object(fields), errors: _)) -> {
14371437+ case list.key_find(fields, "items") {
14381438+ Ok(value.List(items)) -> {
14391439+ // Should have 2 items
14401440+ list.length(items) |> should.equal(2)
14411441+14421442+ // First item should have formatted name with suffix
14431443+ case list.first(items) {
14441444+ Ok(value.Object(item_fields)) -> {
14451445+ case list.key_find(item_fields, "formattedName") {
14461446+ Ok(value.String("Apple (organic)")) -> should.be_true(True)
14471447+ Ok(other) -> {
14481448+ // Variable was lost - this is the bug we're testing for
14491449+ should.equal(other, value.String("Apple (organic)"))
14501450+ }
14511451+ Error(_) -> should.fail()
14521452+ }
14531453+ }
14541454+ _ -> should.fail()
14551455+ }
14561456+14571457+ // Second item should also have formatted name with suffix
14581458+ case list.drop(items, 1) {
14591459+ [value.Object(item_fields), ..] -> {
14601460+ case list.key_find(item_fields, "formattedName") {
14611461+ Ok(value.String("Banana (organic)")) -> should.be_true(True)
14621462+ Ok(other) -> {
14631463+ should.equal(other, value.String("Banana (organic)"))
14641464+ }
14651465+ Error(_) -> should.fail()
14661466+ }
14671467+ }
14681468+ _ -> should.fail()
14691469+ }
14701470+ }
14711471+ _ -> should.fail()
14721472+ }
14731473+ }
14741474+ Error(err) -> should.equal(err, "")
14751475+ _ -> should.fail()
14761476+ }
14771477+}
14781478+14791479+// Test: Union type wrapped in NonNull resolves correctly
14801480+// This tests the fix for fields like `node: NonNull(UnionType)` in connections
14811481+// Previously, is_union check failed because it only matched bare UnionType
14821482+pub fn execute_non_null_union_resolves_correctly_test() {
14831483+ // Create object types that will be part of the union
14841484+ let like_type =
14851485+ schema.object_type("Like", "A like record", [
14861486+ schema.field("uri", schema.string_type(), "Like URI", fn(ctx) {
14871487+ case ctx.data {
14881488+ option.Some(value.Object(fields)) -> {
14891489+ case list.key_find(fields, "uri") {
14901490+ Ok(uri_val) -> Ok(uri_val)
14911491+ Error(_) -> Ok(value.Null)
14921492+ }
14931493+ }
14941494+ _ -> Ok(value.Null)
14951495+ }
14961496+ }),
14971497+ ])
14981498+14991499+ let follow_type =
15001500+ schema.object_type("Follow", "A follow record", [
15011501+ schema.field("uri", schema.string_type(), "Follow URI", fn(ctx) {
15021502+ case ctx.data {
15031503+ option.Some(value.Object(fields)) -> {
15041504+ case list.key_find(fields, "uri") {
15051505+ Ok(uri_val) -> Ok(uri_val)
15061506+ Error(_) -> Ok(value.Null)
15071507+ }
15081508+ }
15091509+ _ -> Ok(value.Null)
15101510+ }
15111511+ }),
15121512+ ])
15131513+15141514+ // Type resolver that examines the "type" field
15151515+ let type_resolver = fn(ctx: schema.Context) -> Result(String, String) {
15161516+ case ctx.data {
15171517+ option.Some(value.Object(fields)) -> {
15181518+ case list.key_find(fields, "type") {
15191519+ Ok(value.String(type_name)) -> Ok(type_name)
15201520+ _ -> Error("No type field found")
15211521+ }
15221522+ }
15231523+ _ -> Error("No data")
15241524+ }
15251525+ }
15261526+15271527+ // Create union type
15281528+ let notification_union =
15291529+ schema.union_type(
15301530+ "NotificationRecord",
15311531+ "A notification record",
15321532+ [like_type, follow_type],
15331533+ type_resolver,
15341534+ )
15351535+15361536+ // Create edge type with node wrapped in NonNull - this is the key scenario
15371537+ let edge_type =
15381538+ schema.object_type("NotificationEdge", "An edge in the connection", [
15391539+ schema.field(
15401540+ "node",
15411541+ schema.non_null(notification_union),
15421542+ // NonNull wrapping union
15431543+ "The notification record",
15441544+ fn(ctx) {
15451545+ case ctx.data {
15461546+ option.Some(value.Object(fields)) -> {
15471547+ case list.key_find(fields, "node") {
15481548+ Ok(node_val) -> Ok(node_val)
15491549+ Error(_) -> Ok(value.Null)
15501550+ }
15511551+ }
15521552+ _ -> Ok(value.Null)
15531553+ }
15541554+ },
15551555+ ),
15561556+ schema.field("cursor", schema.string_type(), "Cursor", fn(ctx) {
15571557+ case ctx.data {
15581558+ option.Some(value.Object(fields)) -> {
15591559+ case list.key_find(fields, "cursor") {
15601560+ Ok(cursor_val) -> Ok(cursor_val)
15611561+ Error(_) -> Ok(value.Null)
15621562+ }
15631563+ }
15641564+ _ -> Ok(value.Null)
15651565+ }
15661566+ }),
15671567+ ])
15681568+15691569+ // Create query type returning a list of edges
15701570+ let query_type =
15711571+ schema.object_type("Query", "Root query type", [
15721572+ schema.field(
15731573+ "notifications",
15741574+ schema.list_type(edge_type),
15751575+ "Get notifications",
15761576+ fn(_ctx) {
15771577+ Ok(
15781578+ value.List([
15791579+ value.Object([
15801580+ #(
15811581+ "node",
15821582+ value.Object([
15831583+ #("type", value.String("Like")),
15841584+ #("uri", value.String("at://user/like/1")),
15851585+ ]),
15861586+ ),
15871587+ #("cursor", value.String("cursor1")),
15881588+ ]),
15891589+ value.Object([
15901590+ #(
15911591+ "node",
15921592+ value.Object([
15931593+ #("type", value.String("Follow")),
15941594+ #("uri", value.String("at://user/follow/1")),
15951595+ ]),
15961596+ ),
15971597+ #("cursor", value.String("cursor2")),
15981598+ ]),
15991599+ ]),
16001600+ )
16011601+ },
16021602+ ),
16031603+ ])
16041604+16051605+ let test_schema = schema.schema(query_type, None)
16061606+16071607+ // Query with inline fragments on the NonNull-wrapped union
16081608+ let query =
16091609+ "
16101610+ {
16111611+ notifications {
16121612+ cursor
16131613+ node {
16141614+ __typename
16151615+ ... on Like {
16161616+ uri
16171617+ }
16181618+ ... on Follow {
16191619+ uri
16201620+ }
16211621+ }
16221622+ }
16231623+ }
16241624+ "
16251625+16261626+ let result = executor.execute(query, test_schema, schema.context(None))
16271627+16281628+ case result {
16291629+ Ok(response) -> {
16301630+ case response.data {
16311631+ value.Object(fields) -> {
16321632+ case list.key_find(fields, "notifications") {
16331633+ Ok(value.List(edges)) -> {
16341634+ // Should have 2 edges
16351635+ list.length(edges) |> should.equal(2)
16361636+16371637+ // First edge should be a Like with resolved fields
16381638+ case list.first(edges) {
16391639+ Ok(value.Object(edge_fields)) -> {
16401640+ case list.key_find(edge_fields, "node") {
16411641+ Ok(value.Object(node_fields)) -> {
16421642+ // __typename should be "Like" (resolved from union)
16431643+ case list.key_find(node_fields, "__typename") {
16441644+ Ok(value.String("Like")) -> should.be_true(True)
16451645+ Ok(value.String(other)) -> should.equal(other, "Like")
16461646+ _ -> should.fail()
16471647+ }
16481648+ // uri should be resolved from inline fragment
16491649+ case list.key_find(node_fields, "uri") {
16501650+ Ok(value.String("at://user/like/1")) ->
16511651+ should.be_true(True)
16521652+ _ -> should.fail()
16531653+ }
16541654+ }
16551655+ _ -> should.fail()
16561656+ }
16571657+ }
16581658+ _ -> should.fail()
16591659+ }
16601660+ }
16611661+ _ -> should.fail()
16621662+ }
16631663+ }
16641664+ _ -> should.fail()
16651665+ }
16661666+ }
16671667+ Error(err) -> {
16681668+ should.equal(err, "")
16691669+ }
16701670+ }
16711671+}
+148
test/introspection_test.gleam
···33/// Comprehensive tests for introspection queries
44import gleam/list
55import gleam/option.{None}
66+import gleam/string
67import gleeunit/should
78import swell/executor
89import swell/schema
···674675 }
675676 |> should.be_true
676677}
678678+679679+/// Test: Introspection types are returned in alphabetical order
680680+/// Verifies that __schema.types are sorted alphabetically by name
681681+pub fn schema_types_alphabetical_order_test() {
682682+ let schema = test_schema()
683683+ let query = "{ __schema { types { name } } }"
684684+685685+ let result = executor.execute(query, schema, schema.context(None))
686686+687687+ should.be_ok(result)
688688+ |> fn(response) {
689689+ case response {
690690+ executor.Response(data: value.Object(fields), errors: []) -> {
691691+ case list.key_find(fields, "__schema") {
692692+ Ok(value.Object(schema_fields)) -> {
693693+ case list.key_find(schema_fields, "types") {
694694+ Ok(value.List(types)) -> {
695695+ // Extract type names
696696+ let names =
697697+ list.filter_map(types, fn(type_val) {
698698+ case type_val {
699699+ value.Object(type_fields) -> {
700700+ case list.key_find(type_fields, "name") {
701701+ Ok(value.String(name)) -> Ok(name)
702702+ _ -> Error(Nil)
703703+ }
704704+ }
705705+ _ -> Error(Nil)
706706+ }
707707+ })
708708+709709+ // Check that names are sorted alphabetically
710710+ // Expected order: Boolean, Float, ID, Int, Query, String
711711+ let sorted_names = list.sort(names, string.compare)
712712+ names == sorted_names
713713+ }
714714+ _ -> False
715715+ }
716716+ }
717717+ _ -> False
718718+ }
719719+ }
720720+ _ -> False
721721+ }
722722+ }
723723+ |> should.be_true
724724+}
725725+726726+/// Test: Union type introspection returns possibleTypes
727727+/// Verifies that introspecting a union type correctly returns all possible types
728728+pub fn union_type_possible_types_test() {
729729+ // Create object types that will be part of the union
730730+ let post_type =
731731+ schema.object_type("Post", "A blog post", [
732732+ schema.field("title", schema.string_type(), "Post title", fn(_ctx) {
733733+ Ok(value.String("test"))
734734+ }),
735735+ ])
736736+737737+ let comment_type =
738738+ schema.object_type("Comment", "A comment", [
739739+ schema.field("text", schema.string_type(), "Comment text", fn(_ctx) {
740740+ Ok(value.String("test"))
741741+ }),
742742+ ])
743743+744744+ // Type resolver for the union
745745+ let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) {
746746+ Ok("Post")
747747+ }
748748+749749+ // Create union type
750750+ let search_result_union =
751751+ schema.union_type(
752752+ "SearchResult",
753753+ "A search result",
754754+ [post_type, comment_type],
755755+ type_resolver,
756756+ )
757757+758758+ // Create query type that uses the union
759759+ let query_type =
760760+ schema.object_type("Query", "Root query type", [
761761+ schema.field(
762762+ "search",
763763+ schema.list_type(search_result_union),
764764+ "Search results",
765765+ fn(_ctx) { Ok(value.List([])) },
766766+ ),
767767+ ])
768768+769769+ let test_schema = schema.schema(query_type, None)
770770+771771+ // Query for union type's possibleTypes
772772+ let query =
773773+ "{ __type(name: \"SearchResult\") { name kind possibleTypes { name } } }"
774774+775775+ let result = executor.execute(query, test_schema, schema.context(None))
776776+777777+ should.be_ok(result)
778778+ |> fn(response) {
779779+ case response {
780780+ executor.Response(data: value.Object(fields), errors: []) -> {
781781+ case list.key_find(fields, "__type") {
782782+ Ok(value.Object(type_fields)) -> {
783783+ // Check it's a UNION
784784+ let is_union = case list.key_find(type_fields, "kind") {
785785+ Ok(value.String("UNION")) -> True
786786+ _ -> False
787787+ }
788788+789789+ // Check possibleTypes contains both Post and Comment
790790+ let has_possible_types = case
791791+ list.key_find(type_fields, "possibleTypes")
792792+ {
793793+ Ok(value.List(possible_types)) -> {
794794+ let names =
795795+ list.filter_map(possible_types, fn(pt) {
796796+ case pt {
797797+ value.Object(pt_fields) -> {
798798+ case list.key_find(pt_fields, "name") {
799799+ Ok(value.String(name)) -> Ok(name)
800800+ _ -> Error(Nil)
801801+ }
802802+ }
803803+ _ -> Error(Nil)
804804+ }
805805+ })
806806+807807+ // Should have exactly 2 possible types: Comment and Post
808808+ list.length(names) == 2
809809+ && list.contains(names, "Post")
810810+ && list.contains(names, "Comment")
811811+ }
812812+ _ -> False
813813+ }
814814+815815+ is_union && has_possible_types
816816+ }
817817+ _ -> False
818818+ }
819819+ }
820820+ _ -> False
821821+ }
822822+ }
823823+ |> should.be_true
824824+}