+16
.claude/settings.json
+16
.claude/settings.json
+58
CHANGELOG.md
+58
CHANGELOG.md
···
1
# Changelog
2
3
+
## 2.1.4
4
+
5
+
### Fixed
6
+
7
+
- Variables are now preserved when executing nested object and list selections. Previously, variables were lost when traversing into nested contexts, causing variable references in nested fields to resolve as empty.
8
+
9
+
## 2.1.3
10
+
11
+
### Fixed
12
+
13
+
- Union type resolution now works for fields wrapped in NonNull (e.g., `NonNull(UnionType)`). Previously, the executor only checked for bare `UnionType`, missing cases where unions were wrapped.
14
+
15
+
## 2.1.2
16
+
17
+
### Fixed
18
+
19
+
- Union type resolution now uses a canonical type registry, ensuring resolved types have complete field definitions
20
+
- Made `build_type_map` public in introspection module for building type registries
21
+
- Added `get_union_type_resolver` to extract the type resolver function from union types
22
+
- Added `resolve_union_type_with_registry` for registry-based union resolution
23
+
24
+
## 2.1.1
25
+
26
+
### Fixed
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
+7
birdie_snapshots/execute_fragment_spread_on_non_null_type.accepted
+7
birdie_snapshots/execute_fragment_spread_on_non_null_type.accepted
+1
-1
gleam.toml
+1
-1
gleam.toml
+323
-139
src/swell/executor.gleam
+323
-139
src/swell/executor.gleam
···
2
///
3
/// Executes GraphQL queries against a schema
4
import gleam/dict.{type Dict}
5
import gleam/list
6
import gleam/option.{None, Some}
7
import gleam/set.{type Set}
···
20
Response(data: value.Value, errors: List(GraphQLError))
21
}
22
23
/// Get the response key for a field (alias if present, otherwise field name)
24
fn 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) -> {
42
// 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
}
50
51
fn 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,
64
) -> 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)
77
[] -> Error("No executable operations in document")
78
}
79
}
···
112
graphql_schema: schema.Schema,
113
ctx: schema.Context,
114
fragments: Dict(String, parser.Operation),
115
) -> Result(#(value.Value, List(GraphQLError)), String) {
116
case operation {
117
parser.Query(selection_set) -> {
···
123
ctx,
124
fragments,
125
[],
126
)
127
}
128
-
parser.NamedQuery(_, _, selection_set) -> {
129
let root_type = schema.query_type(graphql_schema)
130
execute_selection_set(
131
selection_set,
132
root_type,
133
graphql_schema,
134
-
ctx,
135
fragments,
136
[],
137
)
138
}
139
parser.Mutation(selection_set) -> {
···
147
ctx,
148
fragments,
149
[],
150
)
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) ->
158
execute_selection_set(
159
selection_set,
160
mutation_type,
161
graphql_schema,
162
-
ctx,
163
fragments,
164
[],
165
)
166
option.None -> Error("Schema does not define a mutation type")
167
}
168
}
···
177
ctx,
178
fragments,
179
[],
180
)
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) ->
188
execute_selection_set(
189
selection_set,
190
subscription_type,
191
graphql_schema,
192
-
ctx,
193
fragments,
194
[],
195
)
196
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),
212
) -> Result(#(value.Value, List(GraphQLError)), String) {
213
case selection_set {
214
parser.SelectionSet(selections) -> {
···
221
ctx,
222
fragments,
223
path,
224
)
225
})
226
···
274
ctx: schema.Context,
275
fragments: Dict(String, parser.Operation),
276
path: List(String),
277
) -> 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)
290
case type_condition == current_type_name {
291
False -> {
292
// Type condition doesn't match, skip this fragment
···
303
ctx,
304
fragments,
305
path,
306
)
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)
325
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,
342
)
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)
363
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)
461
462
-
// Create context with arguments (preserve variables from parent context)
463
-
let field_ctx = schema.Context(ctx.data, args_dict, ctx.variables)
464
465
-
// 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,
557
)
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
}
566
567
-
// 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)
586
}
587
-
})
588
-
589
-
let all_errors =
590
-
results
591
-
|> list.flat_map(fn(r) {
592
-
case r {
593
-
Ok(#(_, errs)) -> errs
594
-
Error(_) -> []
595
}
596
-
})
597
598
-
Ok(#(key, value.List(processed_items), all_errors))
599
}
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)
889
parser.StringValue(s) -> value.String(s)
890
parser.BooleanValue(b) -> value.Boolean(b)
891
parser.NullValue -> value.Null
···
925
}
926
})
927
}
···
2
///
3
/// Executes GraphQL queries against a schema
4
import gleam/dict.{type Dict}
5
+
import gleam/float
6
+
import gleam/int
7
import gleam/list
8
import gleam/option.{None, Some}
9
import gleam/set.{type Set}
···
22
Response(data: value.Value, errors: List(GraphQLError))
23
}
24
25
+
/// 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)
50
fn 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
}
80
81
+
/// Build a canonical type registry from the schema
82
+
/// Uses introspection's logic to get deduplicated types with most fields
83
+
fn build_type_registry(
84
+
graphql_schema: schema.Schema,
85
+
) -> Dict(String, schema.Type) {
86
+
let all_types = introspection.get_all_schema_types(graphql_schema)
87
+
introspection.build_type_map(all_types)
88
+
}
89
+
90
fn format_parse_error(err: parser.ParseError) -> String {
91
case err {
92
parser.UnexpectedToken(_, msg) -> msg
···
100
document: parser.Document,
101
graphql_schema: schema.Schema,
102
ctx: schema.Context,
103
+
type_registry: Dict(String, schema.Type),
104
) -> Result(#(value.Value, List(GraphQLError)), String) {
105
case document {
106
parser.Document(operations) -> {
···
113
// Execute the first executable operation
114
case executable_ops {
115
[operation, ..] ->
116
+
execute_operation(
117
+
operation,
118
+
graphql_schema,
119
+
ctx,
120
+
fragments_dict,
121
+
type_registry,
122
+
)
123
[] -> Error("No executable operations in document")
124
}
125
}
···
158
graphql_schema: schema.Schema,
159
ctx: schema.Context,
160
fragments: Dict(String, parser.Operation),
161
+
type_registry: Dict(String, schema.Type),
162
) -> Result(#(value.Value, List(GraphQLError)), String) {
163
case operation {
164
parser.Query(selection_set) -> {
···
170
ctx,
171
fragments,
172
[],
173
+
type_registry,
174
)
175
}
176
+
parser.NamedQuery(_name, variables, selection_set) -> {
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)
542
543
+
// Create context with arguments (preserve variables from parent context)
544
+
let field_ctx =
545
+
schema.Context(ctx.data, args_dict, ctx.variables)
546
547
+
// 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)
566
{
567
+
option.Some(t) -> t
568
+
option.None -> field_type_def
569
}
570
+
let type_to_use = case
571
+
schema.is_union(unwrapped_type)
572
+
{
573
True -> {
574
+
// Create context with the field value for type resolution
575
let resolve_ctx =
576
+
schema.context(option.Some(field_value))
577
case
578
+
schema.resolve_union_type_with_registry(
579
+
unwrapped_type,
580
resolve_ctx,
581
+
type_registry,
582
)
583
{
584
Ok(concrete_type) -> concrete_type
585
+
Error(_) -> field_type_def
586
// Fallback to union type if resolution fails
587
}
588
}
589
+
False -> field_type_def
590
}
591
592
+
// Execute nested selections using the resolved type
593
+
// Create new context with this object's data, preserving variables
594
+
let object_ctx =
595
+
schema.context_with_variables(
596
+
option.Some(field_value),
597
+
ctx.variables,
598
+
)
599
+
let selection_set =
600
+
parser.SelectionSet(nested_selections)
601
+
case
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
}
636
637
+
// 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
}
714
}
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
+78
-16
src/swell/introspection.gleam
···
6
import gleam/list
7
import gleam/option
8
import gleam/result
9
import swell/schema
10
import swell/value
11
···
101
!list.contains(collected_names, built_in_name)
102
})
103
104
-
list.append(unique_types, missing_built_ins)
105
}
106
107
/// Get all types from the schema
108
fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) {
109
let all_types = get_all_schema_types(graphql_schema)
110
111
-
// Convert all types to introspection values
112
-
list.map(all_types, type_introspection)
113
}
114
115
/// 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)
200
201
-
// Only traverse if this instance has more content than we've seen before
202
-
current_content_count > max_existing_content
203
}
204
False -> True
205
}
···
323
desc -> value.String(desc)
324
}
325
326
value.Object([
327
#("kind", value.String(kind)),
328
#("name", name),
···
333
#("enumValues", enum_values),
334
#("inputFields", input_fields),
335
#("ofType", of_type),
336
])
337
}
338
···
6
import gleam/list
7
import gleam/option
8
import gleam/result
9
+
import gleam/string
10
import swell/schema
11
import swell/value
12
···
102
!list.contains(collected_names, built_in_name)
103
})
104
105
+
let all_types = list.append(unique_types, missing_built_ins)
106
+
107
+
// Build a canonical type map for normalization
108
+
let type_map = build_type_map(all_types)
109
+
110
+
// Normalize union types so their possible_types reference canonical instances
111
+
list.map(all_types, fn(t) { normalize_union_possible_types(t, type_map) })
112
+
}
113
+
114
+
/// Build a map from type name to canonical type instance
115
+
/// This creates a registry that can be used to look up types by name,
116
+
/// ensuring consistent type references throughout the system.
117
+
pub fn build_type_map(
118
+
types: List(schema.Type),
119
+
) -> dict.Dict(String, schema.Type) {
120
+
list.fold(types, dict.new(), fn(acc, t) {
121
+
dict.insert(acc, schema.type_name(t), t)
122
+
})
123
+
}
124
+
125
+
/// For union types, replace possible_types with canonical instances from the type map
126
+
/// This ensures union.possible_types returns the same instances as found elsewhere
127
+
fn normalize_union_possible_types(
128
+
t: schema.Type,
129
+
type_map: dict.Dict(String, schema.Type),
130
+
) -> schema.Type {
131
+
case schema.is_union(t) {
132
+
True -> {
133
+
let original_possible = schema.get_possible_types(t)
134
+
let normalized_possible =
135
+
list.filter_map(original_possible, fn(pt) {
136
+
dict.get(type_map, schema.type_name(pt))
137
+
})
138
+
schema.union_type(
139
+
schema.type_name(t),
140
+
schema.type_description(t),
141
+
normalized_possible,
142
+
schema.get_union_type_resolver(t),
143
+
)
144
+
}
145
+
False -> t
146
+
}
147
}
148
149
/// Get all types from the schema
150
fn get_all_types(graphql_schema: schema.Schema) -> List(value.Value) {
151
let all_types = get_all_schema_types(graphql_schema)
152
153
+
// 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
}
160
161
/// 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 -> {
231
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)
254
255
+
current_content_count > max_existing_content
256
+
}
257
+
}
258
}
259
False -> True
260
}
···
378
desc -> value.String(desc)
379
}
380
381
+
// 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
+30
-8
src/swell/parser.gleam
+30
-8
src/swell/parser.gleam
···
72
73
/// Variable definition
74
pub type Variable {
75
-
Variable(name: String, type_: String)
76
}
77
78
pub type ParseError {
···
543
// Skip commas
544
[lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc)
545
546
-
// Parse a variable: $name: Type! or $name: Type or $name: [Type]! 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
-
let variable = Variable(var_name, type_str)
552
-
parse_variable_definitions_loop(rest2, [variable, ..acc])
553
}
554
Error(err) -> Error(err)
555
}
···
568
// List type: [Type] or [Type!] or [Type]! or [Type!]!
569
[lexer.BracketOpen, ..rest] -> {
570
case rest {
571
-
[lexer.Name(inner_type), lexer.Exclamation, lexer.BracketClose, lexer.Exclamation, ..rest2] ->
572
// [Type!]!
573
Ok(#("[" <> inner_type <> "!]!", rest2))
574
[lexer.Name(inner_type), lexer.Exclamation, lexer.BracketClose, ..rest2] ->
···
581
// [Type]
582
Ok(#("[" <> inner_type <> "]", rest2))
583
[] -> Error(UnexpectedEndOfInput("Expected type name in list"))
584
-
[token, ..] -> Error(UnexpectedToken(token, "Expected type name in list"))
585
}
586
}
587
// Simple type: Type! or Type
588
[lexer.Name(type_name), lexer.Exclamation, ..rest] ->
589
Ok(#(type_name <> "!", rest))
590
-
[lexer.Name(type_name), ..rest] ->
591
-
Ok(#(type_name, rest))
592
[] -> Error(UnexpectedEndOfInput("Expected type"))
593
[token, ..] -> Error(UnexpectedToken(token, "Expected type name"))
594
}
···
72
73
/// Variable definition
74
pub type Variable {
75
+
Variable(name: String, type_: String, default_value: Option(ArgumentValue))
76
}
77
78
pub type ParseError {
···
543
// Skip commas
544
[lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc)
545
546
+
// 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)
571
}
···
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] ->
···
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
}
+59
src/swell/schema.gleam
+59
src/swell/schema.gleam
···
239
}
240
}
241
242
pub fn field_name(f: Field) -> String {
243
case f {
244
Field(name, _, _, _, _) -> name
···
456
}
457
}
458
459
/// Resolve a union type to its concrete type using the type resolver
460
pub 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",
478
)
479
}
480
}
···
239
}
240
}
241
242
+
/// 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
+
257
pub fn field_name(f: Field) -> String {
258
case f {
259
Field(name, _, _, _, _) -> name
···
471
}
472
}
473
474
+
/// Get the type resolver function from a union type.
475
+
/// The type resolver is called at runtime to determine which concrete type
476
+
/// a union value represents, based on the data in the context.
477
+
/// Returns a default resolver that always errors for non-union types.
478
+
/// This is primarily used internally for union type normalization.
479
+
pub fn get_union_type_resolver(t: Type) -> fn(Context) -> Result(String, String) {
480
+
case t {
481
+
UnionType(_, _, _, type_resolver) -> type_resolver
482
+
_ -> fn(_) { Error("Not a union type") }
483
+
}
484
+
}
485
+
486
/// Resolve a union type to its concrete type using the type resolver
487
pub fn resolve_union_type(t: Type, ctx: Context) -> Result(Type, String) {
488
case t {
···
502
"Type resolver returned '"
503
<> resolved_type_name
504
<> "' which is not a possible type of this union",
505
+
)
506
+
}
507
+
}
508
+
Error(err) -> Error(err)
509
+
}
510
+
}
511
+
_ -> Error("Cannot resolve non-union type")
512
+
}
513
+
}
514
+
515
+
/// Resolve a union type using a canonical type registry
516
+
/// This looks up the concrete type by name from the registry instead of from
517
+
/// the union's possible_types, ensuring we get the most complete type definition
518
+
pub fn resolve_union_type_with_registry(
519
+
t: Type,
520
+
ctx: Context,
521
+
type_registry: dict.Dict(String, Type),
522
+
) -> Result(Type, String) {
523
+
case t {
524
+
UnionType(_, _, _, type_resolver) -> {
525
+
// Call the type resolver to get the concrete type name
526
+
case type_resolver(ctx) {
527
+
Ok(resolved_type_name) -> {
528
+
// Look up the type from the canonical registry
529
+
case dict.get(type_registry, resolved_type_name) {
530
+
Ok(concrete_type) -> Ok(concrete_type)
531
+
Error(_) ->
532
+
Error(
533
+
"Type resolver returned '"
534
+
<> resolved_type_name
535
+
<> "' which is not in the type registry. "
536
+
<> "This may indicate the schema is incomplete or the type resolver is misconfigured.",
537
)
538
}
539
}
+804
test/executor_test.gleam
+804
test/executor_test.gleam
···
206
)
207
}
208
209
+
// 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
262
pub 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
+
}
+148
test/introspection_test.gleam
+148
test/introspection_test.gleam
···
3
/// Comprehensive tests for introspection queries
4
import gleam/list
5
import gleam/option.{None}
6
+
import gleam/string
7
import gleeunit/should
8
import swell/executor
9
import swell/schema
···
675
}
676
|> should.be_true
677
}
678
+
679
+
/// Test: Introspection types are returned in alphabetical order
680
+
/// Verifies that __schema.types are sorted alphabetically by name
681
+
pub fn schema_types_alphabetical_order_test() {
682
+
let schema = test_schema()
683
+
let query = "{ __schema { types { name } } }"
684
+
685
+
let result = executor.execute(query, schema, schema.context(None))
686
+
687
+
should.be_ok(result)
688
+
|> fn(response) {
689
+
case response {
690
+
executor.Response(data: value.Object(fields), errors: []) -> {
691
+
case list.key_find(fields, "__schema") {
692
+
Ok(value.Object(schema_fields)) -> {
693
+
case list.key_find(schema_fields, "types") {
694
+
Ok(value.List(types)) -> {
695
+
// Extract type names
696
+
let names =
697
+
list.filter_map(types, fn(type_val) {
698
+
case type_val {
699
+
value.Object(type_fields) -> {
700
+
case list.key_find(type_fields, "name") {
701
+
Ok(value.String(name)) -> Ok(name)
702
+
_ -> Error(Nil)
703
+
}
704
+
}
705
+
_ -> Error(Nil)
706
+
}
707
+
})
708
+
709
+
// Check that names are sorted alphabetically
710
+
// Expected order: Boolean, Float, ID, Int, Query, String
711
+
let sorted_names = list.sort(names, string.compare)
712
+
names == sorted_names
713
+
}
714
+
_ -> False
715
+
}
716
+
}
717
+
_ -> False
718
+
}
719
+
}
720
+
_ -> False
721
+
}
722
+
}
723
+
|> should.be_true
724
+
}
725
+
726
+
/// Test: Union type introspection returns possibleTypes
727
+
/// Verifies that introspecting a union type correctly returns all possible types
728
+
pub fn union_type_possible_types_test() {
729
+
// Create object types that will be part of the union
730
+
let post_type =
731
+
schema.object_type("Post", "A blog post", [
732
+
schema.field("title", schema.string_type(), "Post title", fn(_ctx) {
733
+
Ok(value.String("test"))
734
+
}),
735
+
])
736
+
737
+
let comment_type =
738
+
schema.object_type("Comment", "A comment", [
739
+
schema.field("text", schema.string_type(), "Comment text", fn(_ctx) {
740
+
Ok(value.String("test"))
741
+
}),
742
+
])
743
+
744
+
// Type resolver for the union
745
+
let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) {
746
+
Ok("Post")
747
+
}
748
+
749
+
// Create union type
750
+
let search_result_union =
751
+
schema.union_type(
752
+
"SearchResult",
753
+
"A search result",
754
+
[post_type, comment_type],
755
+
type_resolver,
756
+
)
757
+
758
+
// Create query type that uses the union
759
+
let query_type =
760
+
schema.object_type("Query", "Root query type", [
761
+
schema.field(
762
+
"search",
763
+
schema.list_type(search_result_union),
764
+
"Search results",
765
+
fn(_ctx) { Ok(value.List([])) },
766
+
),
767
+
])
768
+
769
+
let test_schema = schema.schema(query_type, None)
770
+
771
+
// Query for union type's possibleTypes
772
+
let query =
773
+
"{ __type(name: \"SearchResult\") { name kind possibleTypes { name } } }"
774
+
775
+
let result = executor.execute(query, test_schema, schema.context(None))
776
+
777
+
should.be_ok(result)
778
+
|> fn(response) {
779
+
case response {
780
+
executor.Response(data: value.Object(fields), errors: []) -> {
781
+
case list.key_find(fields, "__type") {
782
+
Ok(value.Object(type_fields)) -> {
783
+
// Check it's a UNION
784
+
let is_union = case list.key_find(type_fields, "kind") {
785
+
Ok(value.String("UNION")) -> True
786
+
_ -> False
787
+
}
788
+
789
+
// Check possibleTypes contains both Post and Comment
790
+
let has_possible_types = case
791
+
list.key_find(type_fields, "possibleTypes")
792
+
{
793
+
Ok(value.List(possible_types)) -> {
794
+
let names =
795
+
list.filter_map(possible_types, fn(pt) {
796
+
case pt {
797
+
value.Object(pt_fields) -> {
798
+
case list.key_find(pt_fields, "name") {
799
+
Ok(value.String(name)) -> Ok(name)
800
+
_ -> Error(Nil)
801
+
}
802
+
}
803
+
_ -> Error(Nil)
804
+
}
805
+
})
806
+
807
+
// Should have exactly 2 possible types: Comment and Post
808
+
list.length(names) == 2
809
+
&& list.contains(names, "Post")
810
+
&& list.contains(names, "Comment")
811
+
}
812
+
_ -> False
813
+
}
814
+
815
+
is_union && has_possible_types
816
+
}
817
+
_ -> False
818
+
}
819
+
}
820
+
_ -> False
821
+
}
822
+
}
823
+
|> should.be_true
824
+
}
+154
-11
test/parser_test.gleam
+154
-11
test/parser_test.gleam
···
484
parser.Document([
485
parser.NamedQuery(
486
name: "Test",
487
-
variables: [parser.Variable("name", "String!")],
488
selections: parser.SelectionSet([parser.Field("user", None, [], [])]),
489
),
490
]) -> True
···
504
parser.NamedQuery(
505
name: "Test",
506
variables: [
507
-
parser.Variable("name", "String!"),
508
-
parser.Variable("age", "Int"),
509
],
510
selections: parser.SelectionSet([parser.Field("user", None, [], [])]),
511
),
···
526
parser.NamedMutation(
527
name: "CreateUser",
528
variables: [
529
-
parser.Variable("name", "String!"),
530
-
parser.Variable("email", "String!"),
531
],
532
selections: parser.SelectionSet([
533
parser.Field("createUser", None, [], []),
···
649
parser.Document([
650
parser.NamedQuery(
651
name: "Test",
652
-
variables: [parser.Variable("ids", "[Int]")],
653
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
654
),
655
]) -> True
···
668
parser.Document([
669
parser.NamedQuery(
670
name: "Test",
671
-
variables: [parser.Variable("ids", "[Int]!")],
672
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
673
),
674
]) -> True
···
687
parser.Document([
688
parser.NamedQuery(
689
name: "Test",
690
-
variables: [parser.Variable("ids", "[Int!]")],
691
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
692
),
693
]) -> True
···
706
parser.Document([
707
parser.NamedQuery(
708
name: "Test",
709
-
variables: [parser.Variable("ids", "[Int!]!")],
710
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
711
),
712
]) -> True
···
725
parser.Document([
726
parser.NamedMutation(
727
name: "AddTags",
728
-
variables: [parser.Variable("tags", "[String!]!")],
729
-
selections: parser.SelectionSet([parser.Field("addTags", None, [], [])]),
730
),
731
]) -> True
732
_ -> False
···
484
parser.Document([
485
parser.NamedQuery(
486
name: "Test",
487
+
variables: [parser.Variable("name", "String!", None)],
488
selections: parser.SelectionSet([parser.Field("user", None, [], [])]),
489
),
490
]) -> True
···
504
parser.NamedQuery(
505
name: "Test",
506
variables: [
507
+
parser.Variable("name", "String!", None),
508
+
parser.Variable("age", "Int", None),
509
],
510
selections: parser.SelectionSet([parser.Field("user", None, [], [])]),
511
),
···
526
parser.NamedMutation(
527
name: "CreateUser",
528
variables: [
529
+
parser.Variable("name", "String!", None),
530
+
parser.Variable("email", "String!", None),
531
],
532
selections: parser.SelectionSet([
533
parser.Field("createUser", None, [], []),
···
649
parser.Document([
650
parser.NamedQuery(
651
name: "Test",
652
+
variables: [parser.Variable("ids", "[Int]", None)],
653
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
654
),
655
]) -> True
···
668
parser.Document([
669
parser.NamedQuery(
670
name: "Test",
671
+
variables: [parser.Variable("ids", "[Int]!", None)],
672
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
673
),
674
]) -> True
···
687
parser.Document([
688
parser.NamedQuery(
689
name: "Test",
690
+
variables: [parser.Variable("ids", "[Int!]", None)],
691
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
692
),
693
]) -> True
···
706
parser.Document([
707
parser.NamedQuery(
708
name: "Test",
709
+
variables: [parser.Variable("ids", "[Int!]!", None)],
710
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
711
),
712
]) -> True
···
725
parser.Document([
726
parser.NamedMutation(
727
name: "AddTags",
728
+
variables: [parser.Variable("tags", "[String!]!", None)],
729
+
selections: parser.SelectionSet([
730
+
parser.Field("addTags", None, [], []),
731
+
]),
732
+
),
733
+
]) -> True
734
+
_ -> False
735
+
}
736
+
}
737
+
|> should.be_true
738
+
}
739
+
740
+
pub fn parse_variable_with_default_int_value_test() {
741
+
"query Test($count: Int = 20) { users }"
742
+
|> parser.parse
743
+
|> should.be_ok
744
+
|> fn(doc) {
745
+
case doc {
746
+
parser.Document([
747
+
parser.NamedQuery(
748
+
name: "Test",
749
+
variables: [
750
+
parser.Variable("count", "Int", option.Some(parser.IntValue("20"))),
751
+
],
752
+
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
753
+
),
754
+
]) -> True
755
+
_ -> False
756
+
}
757
+
}
758
+
|> should.be_true
759
+
}
760
+
761
+
// Additional default value type tests
762
+
pub fn parse_variable_with_default_string_value_test() {
763
+
"query Test($name: String = \"Alice\") { users }"
764
+
|> parser.parse
765
+
|> should.be_ok
766
+
|> fn(doc) {
767
+
case doc {
768
+
parser.Document([
769
+
parser.NamedQuery(
770
+
name: "Test",
771
+
variables: [
772
+
parser.Variable(
773
+
"name",
774
+
"String",
775
+
option.Some(parser.StringValue("Alice")),
776
+
),
777
+
],
778
+
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
779
+
),
780
+
]) -> True
781
+
_ -> False
782
+
}
783
+
}
784
+
|> should.be_true
785
+
}
786
+
787
+
pub fn parse_variable_with_default_boolean_value_test() {
788
+
"query Test($active: Boolean = true) { users }"
789
+
|> parser.parse
790
+
|> should.be_ok
791
+
|> fn(doc) {
792
+
case doc {
793
+
parser.Document([
794
+
parser.NamedQuery(
795
+
name: "Test",
796
+
variables: [
797
+
parser.Variable(
798
+
"active",
799
+
"Boolean",
800
+
option.Some(parser.BooleanValue(True)),
801
+
),
802
+
],
803
+
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
804
+
),
805
+
]) -> True
806
+
_ -> False
807
+
}
808
+
}
809
+
|> should.be_true
810
+
}
811
+
812
+
pub fn parse_variable_with_default_null_value_test() {
813
+
"query Test($filter: String = null) { users }"
814
+
|> parser.parse
815
+
|> should.be_ok
816
+
|> fn(doc) {
817
+
case doc {
818
+
parser.Document([
819
+
parser.NamedQuery(
820
+
name: "Test",
821
+
variables: [
822
+
parser.Variable("filter", "String", option.Some(parser.NullValue)),
823
+
],
824
+
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
825
+
),
826
+
]) -> True
827
+
_ -> False
828
+
}
829
+
}
830
+
|> should.be_true
831
+
}
832
+
833
+
pub fn parse_variable_with_default_enum_value_test() {
834
+
"query Test($sort: SortOrder = DESC) { users }"
835
+
|> parser.parse
836
+
|> should.be_ok
837
+
|> fn(doc) {
838
+
case doc {
839
+
parser.Document([
840
+
parser.NamedQuery(
841
+
name: "Test",
842
+
variables: [
843
+
parser.Variable(
844
+
"sort",
845
+
"SortOrder",
846
+
option.Some(parser.EnumValue("DESC")),
847
+
),
848
+
],
849
+
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
850
+
),
851
+
]) -> True
852
+
_ -> False
853
+
}
854
+
}
855
+
|> should.be_true
856
+
}
857
+
858
+
pub fn parse_variable_with_mixed_defaults_test() {
859
+
"query Test($name: String!, $limit: Int = 10, $offset: Int) { users }"
860
+
|> parser.parse
861
+
|> should.be_ok
862
+
|> fn(doc) {
863
+
case doc {
864
+
parser.Document([
865
+
parser.NamedQuery(
866
+
name: "Test",
867
+
variables: [
868
+
parser.Variable("name", "String!", None),
869
+
parser.Variable("limit", "Int", option.Some(parser.IntValue("10"))),
870
+
parser.Variable("offset", "Int", None),
871
+
],
872
+
selections: parser.SelectionSet([parser.Field("users", None, [], [])]),
873
),
874
]) -> True
875
_ -> False