+23
.github/workflows/test.yml
+23
.github/workflows/test.yml
···
1
+
name: test
2
+
3
+
on:
4
+
push:
5
+
branches:
6
+
- master
7
+
- main
8
+
pull_request:
9
+
10
+
jobs:
11
+
test:
12
+
runs-on: ubuntu-latest
13
+
steps:
14
+
- uses: actions/checkout@v4
15
+
- uses: erlef/setup-beam@v1
16
+
with:
17
+
otp-version: "28"
18
+
gleam-version: "1.13.0"
19
+
rebar3-version: "3"
20
+
# elixir-version: "1"
21
+
- run: gleam deps download
22
+
- run: gleam test
23
+
- run: gleam format --check src test
+79
README.md
+79
README.md
···
1
+
# Swell
2
+
3
+
A GraphQL implementation in Gleam providing query parsing, execution, and introspection support.
4
+
5
+

6
+
7
+
## Features
8
+
9
+
- Query parsing and execution
10
+
- Mutations with input types
11
+
- Subscriptions for real-time updates
12
+
- Introspection support
13
+
- Type-safe schema builder
14
+
- Fragment support (inline and named)
15
+
16
+
## Quick Start
17
+
18
+
Check out the `/example` directory for an example with SQLite.
19
+
20
+
## Usage
21
+
22
+
```gleam
23
+
import swell/schema
24
+
import swell/executor
25
+
import swell/value
26
+
27
+
// Define your schema
28
+
let user_type = schema.object_type("User", "A user", [
29
+
schema.field("id", schema.id_type(), "User ID", fn(ctx) {
30
+
case ctx.data {
31
+
Some(value.Object(fields)) -> {
32
+
case list.key_find(fields, "id") {
33
+
Ok(id_val) -> Ok(id_val)
34
+
Error(_) -> Ok(value.Null)
35
+
}
36
+
}
37
+
_ -> Ok(value.Null)
38
+
}
39
+
}),
40
+
schema.field("name", schema.string_type(), "User name", fn(ctx) {
41
+
case ctx.data {
42
+
Some(value.Object(fields)) -> {
43
+
case list.key_find(fields, "name") {
44
+
Ok(name_val) -> Ok(name_val)
45
+
Error(_) -> Ok(value.Null)
46
+
}
47
+
}
48
+
_ -> Ok(value.Null)
49
+
}
50
+
}),
51
+
])
52
+
53
+
let query_type = schema.object_type("Query", "Root query", [
54
+
schema.field("user", user_type, "Get a user", fn(_ctx) {
55
+
Ok(value.Object([
56
+
#("id", value.String("1")),
57
+
#("name", value.String("Alice")),
58
+
]))
59
+
}),
60
+
])
61
+
62
+
let my_schema = schema.schema(query_type, None)
63
+
64
+
// Execute a query
65
+
let result = executor.execute("{ user { id name } }", my_schema, schema.context(None))
66
+
```
67
+
68
+
## Known Limitations
69
+
70
+
- Directives not implemented (`@skip`, `@include`, custom directives)
71
+
- Interface types not implemented
72
+
- Custom scalar serialization/deserialization (can define custom scalar types but no validation or coercion beyond built-in types)
73
+
74
+
## Development
75
+
76
+
```sh
77
+
gleam test # Run tests
78
+
gleam build # Build the package
79
+
```
+15
birdie_snapshots/built_in_scalar_types.accepted
+15
birdie_snapshots/built_in_scalar_types.accepted
+8
birdie_snapshots/empty_enum.accepted
+8
birdie_snapshots/empty_enum.accepted
+8
birdie_snapshots/empty_input_object.accepted
+8
birdie_snapshots/empty_input_object.accepted
+11
birdie_snapshots/enum_without_descriptions.accepted
+11
birdie_snapshots/enum_without_descriptions.accepted
+7
birdie_snapshots/execute_field_with_object_argument.accepted
+7
birdie_snapshots/execute_field_with_object_argument.accepted
+7
birdie_snapshots/execute_field_with_string_argument.accepted
+7
birdie_snapshots/execute_field_with_string_argument.accepted
+7
birdie_snapshots/execute_list_with_nested_selections.accepted
+7
birdie_snapshots/execute_list_with_nested_selections.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Execute list with nested selections
4
+
file: ./test/executor_test.gleam
5
+
test_name: execute_list_with_nested_selections_test
6
+
---
7
+
Response(Object([#("users", List([Object([#("id", String("1")), #("name", String("Alice"))]), Object([#("id", String("2")), #("name", String("Bob"))])]))]), [])
+7
birdie_snapshots/execute_mixed_aliased_and_non_aliased_fields.accepted
+7
birdie_snapshots/execute_mixed_aliased_and_non_aliased_fields.accepted
+7
birdie_snapshots/execute_multiple_fields_with_aliases.accepted
+7
birdie_snapshots/execute_multiple_fields_with_aliases.accepted
+7
birdie_snapshots/execute_multiple_mutations.accepted
+7
birdie_snapshots/execute_multiple_mutations.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Execute multiple mutations
4
+
file: ./test/mutation_execution_test.gleam
5
+
test_name: execute_multiple_mutations_test
6
+
---
7
+
Response(Object([#("createUser", Object([#("id", String("123")), #("name", String("Alice"))])), #("deleteUser", Boolean(True))]), [])
+7
birdie_snapshots/execute_query_with_int_variable.accepted
+7
birdie_snapshots/execute_query_with_int_variable.accepted
+7
birdie_snapshots/execute_query_with_multiple_variables.accepted
+7
birdie_snapshots/execute_query_with_multiple_variables.accepted
+7
birdie_snapshots/execute_query_with_string_variable.accepted
+7
birdie_snapshots/execute_query_with_string_variable.accepted
+7
birdie_snapshots/execute_schema_introspection.accepted
+7
birdie_snapshots/execute_schema_introspection.accepted
+7
birdie_snapshots/execute_simple_fragment_spread.accepted
+7
birdie_snapshots/execute_simple_fragment_spread.accepted
+7
birdie_snapshots/execute_simple_mutation.accepted
+7
birdie_snapshots/execute_simple_mutation.accepted
+7
birdie_snapshots/execute_simple_query.accepted
+7
birdie_snapshots/execute_simple_query.accepted
+7
birdie_snapshots/execute_typename_introspection.accepted
+7
birdie_snapshots/execute_typename_introspection.accepted
+7
birdie_snapshots/execute_typename_with_regular_fields.accepted
+7
birdie_snapshots/execute_typename_with_regular_fields.accepted
+7
birdie_snapshots/execute_union_list_with_inline_fragments.accepted
+7
birdie_snapshots/execute_union_list_with_inline_fragments.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Execute union list with inline fragments
4
+
file: ./test/executor_test.gleam
5
+
test_name: execute_union_list_with_inline_fragments_test
6
+
---
7
+
Response(Object([#("searchAll", List([Object([#("title", String("First Post"))]), Object([#("text", String("Great article!"))]), Object([#("title", String("Second Post"))])]))]), [])
+7
birdie_snapshots/execute_union_with_inline_fragment.accepted
+7
birdie_snapshots/execute_union_with_inline_fragment.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Execute union with inline fragment
4
+
file: ./test/executor_test.gleam
5
+
test_name: execute_union_with_inline_fragment_test
6
+
---
7
+
Response(Object([#("search", Object([#("title", String("GraphQL is awesome")), #("content", String("Learn all about GraphQL..."))]))]), [])
+13
birdie_snapshots/input_object_with_default_values.accepted
+13
birdie_snapshots/input_object_with_default_values.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Input object with default values
4
+
file: ./test/sdl_test.gleam
5
+
test_name: input_object_with_default_values_test
6
+
---
7
+
"""Filter options for queries"""
8
+
input FilterInput {
9
+
"""Maximum number of results"""
10
+
limit: Int
11
+
"""Number of results to skip"""
12
+
offset: Int
13
+
}
+15
birdie_snapshots/multiple_mutations_(_crud_operations).accepted
+15
birdie_snapshots/multiple_mutations_(_crud_operations).accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Multiple mutations (CRUD operations)
4
+
file: ./test/mutation_sdl_test.gleam
5
+
test_name: multiple_mutations_test
6
+
---
7
+
"""Mutations"""
8
+
type Mutation {
9
+
"""Create a user"""
10
+
createUser: User
11
+
"""Update a user"""
12
+
updateUser: User
13
+
"""Delete a user"""
14
+
deleteUser: DeleteResponse
15
+
}
+7
birdie_snapshots/multiple_mutations_in_one_operation.accepted
+7
birdie_snapshots/multiple_mutations_in_one_operation.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Multiple mutations in one operation
4
+
file: ./test/mutation_parser_test.gleam
5
+
test_name: parse_multiple_mutations_test
6
+
---
7
+
Document([Mutation(SelectionSet([Field("createUser", None, [Argument("name", StringValue("Alice"))], [Field("id", None, [], [])]), Field("deleteUser", None, [Argument("id", StringValue("123"))], [Field("success", None, [], [])])]))])
+11
birdie_snapshots/mutation_returning_list.accepted
+11
birdie_snapshots/mutation_returning_list.accepted
+29
birdie_snapshots/mutation_with_input_object_argument.accepted
+29
birdie_snapshots/mutation_with_input_object_argument.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Mutation with input object argument
4
+
file: ./test/mutation_sdl_test.gleam
5
+
test_name: mutation_with_input_object_test
6
+
---
7
+
"""Input for creating a user"""
8
+
input CreateUserInput {
9
+
"""User name"""
10
+
name: String!
11
+
"""Email address"""
12
+
email: String!
13
+
"""Age"""
14
+
age: Int
15
+
}
16
+
17
+
"""A user"""
18
+
type User {
19
+
"""User ID"""
20
+
id: ID
21
+
"""User name"""
22
+
name: String
23
+
}
24
+
25
+
"""Mutations"""
26
+
type Mutation {
27
+
"""Create a new user"""
28
+
createUser: User
29
+
}
+7
birdie_snapshots/mutation_with_nested_selections.accepted
+7
birdie_snapshots/mutation_with_nested_selections.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Mutation with nested selections
4
+
file: ./test/mutation_parser_test.gleam
5
+
test_name: parse_mutation_with_nested_selections_test
6
+
---
7
+
Document([Mutation(SelectionSet([Field("createPost", None, [Argument("input", ObjectValue([#("title", StringValue("Hello"))]))], [Field("id", None, [], []), Field("author", None, [], [Field("id", None, [], []), Field("name", None, [], [])]), Field("tags", None, [], [])])]))])
+11
birdie_snapshots/mutation_with_non_null_return_type.accepted
+11
birdie_snapshots/mutation_with_non_null_return_type.accepted
+7
birdie_snapshots/named_mutation.accepted
+7
birdie_snapshots/named_mutation.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Named mutation
4
+
file: ./test/mutation_parser_test.gleam
5
+
test_name: parse_named_mutation_test
6
+
---
7
+
Document([NamedMutation("CreateUser", [], SelectionSet([Field("createUser", None, [Argument("name", StringValue("Alice"))], [Field("id", None, [], []), Field("name", None, [], [])])]))])
+7
birdie_snapshots/named_subscription.accepted
+7
birdie_snapshots/named_subscription.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Named subscription
4
+
file: ./test/subscription_parser_test.gleam
5
+
test_name: parse_named_subscription_test
6
+
---
7
+
Document([NamedSubscription("OnMessage", [], SelectionSet([Field("messageAdded", None, [], [Field("id", None, [], []), Field("content", None, [], [])])]))])
+21
birdie_snapshots/nested_input_types.accepted
+21
birdie_snapshots/nested_input_types.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Nested input types
4
+
file: ./test/sdl_test.gleam
5
+
test_name: nested_input_types_test
6
+
---
7
+
"""Street address information"""
8
+
input AddressInput {
9
+
"""Street name"""
10
+
street: String
11
+
"""City name"""
12
+
city: String
13
+
}
14
+
15
+
"""User information"""
16
+
input UserInput {
17
+
"""Full name"""
18
+
name: String
19
+
"""Home address"""
20
+
address: AddressInput
21
+
}
+15
birdie_snapshots/object_type_with_list_fields.accepted
+15
birdie_snapshots/object_type_with_list_fields.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Object type with list fields
4
+
file: ./test/sdl_test.gleam
5
+
test_name: object_with_list_fields_test
6
+
---
7
+
"""A blog post"""
8
+
type Post {
9
+
"""Post ID"""
10
+
id: ID
11
+
"""Post title"""
12
+
title: String
13
+
"""Post tags"""
14
+
tags: [String!]
15
+
}
+7
birdie_snapshots/parse_mutation_with_input_object_argument.accepted
+7
birdie_snapshots/parse_mutation_with_input_object_argument.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Parse mutation with input object argument
4
+
file: ./test/mutation_parser_test.gleam
5
+
test_name: parse_mutation_with_input_object_test
6
+
---
7
+
Document([Mutation(SelectionSet([Field("createUser", None, [Argument("input", ObjectValue([#("name", StringValue("Alice")), #("email", StringValue("alice@example.com")), #("age", IntValue("30"))]))], [Field("id", None, [], []), Field("name", None, [], []), Field("email", None, [], [])])]))])
+7
birdie_snapshots/simple_anonymous_mutation.accepted
+7
birdie_snapshots/simple_anonymous_mutation.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Simple anonymous mutation
4
+
file: ./test/mutation_parser_test.gleam
5
+
test_name: parse_simple_anonymous_mutation_test
6
+
---
7
+
Document([Mutation(SelectionSet([Field("createUser", None, [Argument("name", StringValue("Alice"))], [Field("id", None, [], []), Field("name", None, [], [])])]))])
+7
birdie_snapshots/simple_anonymous_subscription.accepted
+7
birdie_snapshots/simple_anonymous_subscription.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Simple anonymous subscription
4
+
file: ./test/subscription_parser_test.gleam
5
+
test_name: parse_simple_anonymous_subscription_test
6
+
---
7
+
Document([Subscription(SelectionSet([Field("messageAdded", None, [], [Field("content", None, [], []), Field("author", None, [], [])])]))])
+17
birdie_snapshots/simple_enum_type.accepted
+17
birdie_snapshots/simple_enum_type.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Simple enum type
4
+
file: ./test/sdl_test.gleam
5
+
test_name: simple_enum_test
6
+
---
7
+
"""Order status"""
8
+
enum Status {
9
+
"""Order is pending"""
10
+
PENDING
11
+
"""Order is being processed"""
12
+
PROCESSING
13
+
"""Order has been shipped"""
14
+
SHIPPED
15
+
"""Order has been delivered"""
16
+
DELIVERED
17
+
}
+15
birdie_snapshots/simple_input_object_with_descriptions.accepted
+15
birdie_snapshots/simple_input_object_with_descriptions.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Simple input object with descriptions
4
+
file: ./test/sdl_test.gleam
5
+
test_name: simple_input_object_test
6
+
---
7
+
"""Input for creating or updating a user"""
8
+
input UserInput {
9
+
"""User's name"""
10
+
name: String
11
+
"""User's email address"""
12
+
email: String!
13
+
"""User's age"""
14
+
age: Int
15
+
}
+11
birdie_snapshots/simple_mutation_type.accepted
+11
birdie_snapshots/simple_mutation_type.accepted
+15
birdie_snapshots/simple_object_type.accepted
+15
birdie_snapshots/simple_object_type.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Simple object type
4
+
file: ./test/sdl_test.gleam
5
+
test_name: simple_object_type_test
6
+
---
7
+
"""A user in the system"""
8
+
type User {
9
+
"""User ID"""
10
+
id: ID!
11
+
"""User's name"""
12
+
name: String
13
+
"""Email address"""
14
+
email: String
15
+
}
+7
birdie_snapshots/subscription_with_nested_selections.accepted
+7
birdie_snapshots/subscription_with_nested_selections.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Subscription with nested selections
4
+
file: ./test/subscription_parser_test.gleam
5
+
test_name: parse_subscription_with_nested_selections_test
6
+
---
7
+
Document([Subscription(SelectionSet([Field("postCreated", None, [], [Field("id", None, [], []), Field("title", None, [], []), Field("author", None, [], [Field("id", None, [], []), Field("name", None, [], []), Field("email", None, [], [])]), Field("comments", None, [], [Field("content", None, [], [])])])]))])
+19
birdie_snapshots/type_with_non_null_and_list_modifiers.accepted
+19
birdie_snapshots/type_with_non_null_and_list_modifiers.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Type with NonNull and List modifiers
4
+
file: ./test/sdl_test.gleam
5
+
test_name: type_with_non_null_and_list_test
6
+
---
7
+
"""Complex type modifiers"""
8
+
input ComplexInput {
9
+
"""Required string"""
10
+
required: String!
11
+
"""Optional list of strings"""
12
+
optionalList: [String]
13
+
"""Required list of optional strings"""
14
+
requiredList: [String]!
15
+
"""Optional list of required strings"""
16
+
listOfRequired: [String!]
17
+
"""Required list of required strings"""
18
+
requiredListOfRequired: [String!]!
19
+
}
+24
example/README.md
+24
example/README.md
···
1
+
# example
2
+
3
+
[](https://hex.pm/packages/example)
4
+
[](https://hexdocs.pm/example/)
5
+
6
+
```sh
7
+
gleam add example@1
8
+
```
9
+
```gleam
10
+
import example
11
+
12
+
pub fn main() -> Nil {
13
+
// TODO: An example of the project in use
14
+
}
15
+
```
16
+
17
+
Further documentation can be found at <https://hexdocs.pm/example>.
18
+
19
+
## Development
20
+
21
+
```sh
22
+
gleam run # Run the project
23
+
gleam test # Run the tests
24
+
```
+21
example/gleam.toml
+21
example/gleam.toml
···
1
+
name = "example"
2
+
version = "1.0.0"
3
+
4
+
# Fill out these fields if you intend to generate HTML documentation or publish
5
+
# your project to the Hex package manager.
6
+
#
7
+
# description = ""
8
+
# licences = ["Apache-2.0"]
9
+
# repository = { type = "github", user = "", repo = "" }
10
+
# links = [{ title = "Website", href = "" }]
11
+
#
12
+
# For a full reference of all the available options, you can have a look at
13
+
# https://gleam.run/writing-gleam/gleam-toml/.
14
+
15
+
[dependencies]
16
+
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
17
+
sqlight = ">= 1.0.0 and < 2.0.0"
18
+
swell = { path = ".." }
19
+
20
+
[dev-dependencies]
21
+
gleeunit = ">= 1.0.0 and < 2.0.0"
+16
example/manifest.toml
+16
example/manifest.toml
···
1
+
# This file was generated by Gleam
2
+
# You typically do not need to edit this file
3
+
4
+
packages = [
5
+
{ name = "esqlite", version = "0.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "CCF72258A4EE152EC7AD92AA9A03552EB6CA1B06B65C93AD5B6E55C302E05855" },
6
+
{ name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
7
+
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
8
+
{ name = "sqlight", version = "1.0.3", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "CADD79663C9B61D4BAC960A47CC2D42CA8F48EAF5804DBEB79977287750F4B16" },
9
+
{ name = "swell", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = ".." },
10
+
]
11
+
12
+
[requirements]
13
+
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
14
+
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
15
+
sqlight = { version = ">= 1.0.0 and < 2.0.0" }
16
+
swell = { path = ".." }
+199
example/src/database.gleam
+199
example/src/database.gleam
···
1
+
import gleam/dynamic/decode
2
+
import gleam/result
3
+
import gleam/string
4
+
import sqlight
5
+
6
+
pub type User {
7
+
User(id: Int, name: String, email: String)
8
+
}
9
+
10
+
pub type Post {
11
+
Post(id: Int, title: String, content: String, author_id: Int)
12
+
}
13
+
14
+
/// Create an in-memory database with users and posts tables
15
+
pub fn setup_database() -> Result(sqlight.Connection, sqlight.Error) {
16
+
use conn <- result.try(sqlight.open(":memory:"))
17
+
18
+
// Create users table
19
+
let users_sql =
20
+
"
21
+
CREATE TABLE users (
22
+
id INTEGER PRIMARY KEY,
23
+
name TEXT NOT NULL,
24
+
email TEXT NOT NULL
25
+
)
26
+
"
27
+
28
+
use _ <- result.try(sqlight.exec(users_sql, conn))
29
+
30
+
// Create posts table
31
+
let posts_sql =
32
+
"
33
+
CREATE TABLE posts (
34
+
id INTEGER PRIMARY KEY,
35
+
title TEXT NOT NULL,
36
+
content TEXT NOT NULL,
37
+
author_id INTEGER NOT NULL,
38
+
FOREIGN KEY (author_id) REFERENCES users(id)
39
+
)
40
+
"
41
+
42
+
use _ <- result.try(sqlight.exec(posts_sql, conn))
43
+
44
+
// Insert sample users (surfers)
45
+
let insert_users =
46
+
"
47
+
INSERT INTO users (id, name, email) VALUES
48
+
(1, 'Coco Ho', 'coco@shredmail.com'),
49
+
(2, 'Carissa Moore', 'carissa@barrelmail.com'),
50
+
(3, 'Kelly Slater', 'kslater@bombmail.com')
51
+
"
52
+
53
+
use _ <- result.try(sqlight.exec(insert_users, conn))
54
+
55
+
// Insert sample posts (surf reports and stories)
56
+
let insert_posts =
57
+
"
58
+
INSERT INTO posts (id, title, content, author_id) VALUES
59
+
(1, 'Sick Barrels at Pipe', 'Bruh, Pipeline was absolutely firing today! Scored some gnarly 8ft tubes, got shacked so hard. Offshore winds all day, totally glassy. So stoked!', 1),
60
+
(2, 'Sunset Bombing', 'Just got done charging some heavy sets at Sunset. Waves were massive, super clean faces. If you are not out here you are missing out big time brah!', 1),
61
+
(3, 'Epic Dawn Patrol Session', 'Woke up at 5am for dawn patrol and it was totally worth it dude. Glassy perfection, no kooks in the lineup. Just me and the ocean vibing. Radical!', 2),
62
+
(4, 'Tow-in at Jaws Was Insane', 'Yo, just finished tow-in session at Jaws. 50 footers rolling through, absolutely gnarly! Got worked on one bomb but that is part of the game. Respect the ocean always bruh.', 3)
63
+
"
64
+
65
+
use _ <- result.try(sqlight.exec(insert_posts, conn))
66
+
67
+
Ok(conn)
68
+
}
69
+
70
+
/// Get all users from the database
71
+
pub fn get_users(conn: sqlight.Connection) -> List(User) {
72
+
let sql = "SELECT id, name, email FROM users"
73
+
74
+
let decoder = {
75
+
use id <- decode.field(0, decode.int)
76
+
use name <- decode.field(1, decode.string)
77
+
use email <- decode.field(2, decode.string)
78
+
decode.success(User(id:, name:, email:))
79
+
}
80
+
81
+
sqlight.query(sql, on: conn, with: [], expecting: decoder)
82
+
|> result.unwrap([])
83
+
}
84
+
85
+
/// Get a user by ID
86
+
pub fn get_user(
87
+
conn: sqlight.Connection,
88
+
user_id: Int,
89
+
) -> Result(User, String) {
90
+
let sql = "SELECT id, name, email FROM users WHERE id = ?"
91
+
92
+
let decoder = {
93
+
use id <- decode.field(0, decode.int)
94
+
use name <- decode.field(1, decode.string)
95
+
use email <- decode.field(2, decode.string)
96
+
decode.success(User(id:, name:, email:))
97
+
}
98
+
99
+
case
100
+
sqlight.query(sql, on: conn, with: [sqlight.int(user_id)], expecting: decoder)
101
+
{
102
+
Ok([user]) -> Ok(user)
103
+
Ok([]) -> Error("User not found")
104
+
Ok(_) -> Error("Multiple users found")
105
+
Error(err) -> Error("Database error: " <> string.inspect(err))
106
+
}
107
+
}
108
+
109
+
/// Get all posts from the database
110
+
pub fn get_posts(conn: sqlight.Connection) -> List(Post) {
111
+
let sql = "SELECT id, title, content, author_id FROM posts"
112
+
113
+
let decoder = {
114
+
use id <- decode.field(0, decode.int)
115
+
use title <- decode.field(1, decode.string)
116
+
use content <- decode.field(2, decode.string)
117
+
use author_id <- decode.field(3, decode.int)
118
+
decode.success(Post(id:, title:, content:, author_id:))
119
+
}
120
+
121
+
sqlight.query(sql, on: conn, with: [], expecting: decoder)
122
+
|> result.unwrap([])
123
+
}
124
+
125
+
/// Get a post by ID
126
+
pub fn get_post(conn: sqlight.Connection, post_id: Int) -> Result(Post, String) {
127
+
let sql = "SELECT id, title, content, author_id FROM posts WHERE id = ?"
128
+
129
+
let decoder = {
130
+
use id <- decode.field(0, decode.int)
131
+
use title <- decode.field(1, decode.string)
132
+
use content <- decode.field(2, decode.string)
133
+
use author_id <- decode.field(3, decode.int)
134
+
decode.success(Post(id:, title:, content:, author_id:))
135
+
}
136
+
137
+
case
138
+
sqlight.query(sql, on: conn, with: [sqlight.int(post_id)], expecting: decoder)
139
+
{
140
+
Ok([post]) -> Ok(post)
141
+
Ok([]) -> Error("Post not found")
142
+
Ok(_) -> Error("Multiple posts found")
143
+
Error(err) -> Error("Database error: " <> string.inspect(err))
144
+
}
145
+
}
146
+
147
+
/// Get posts by author ID
148
+
pub fn get_posts_by_author(
149
+
conn: sqlight.Connection,
150
+
author_id: Int,
151
+
) -> List(Post) {
152
+
let sql = "SELECT id, title, content, author_id FROM posts WHERE author_id = ?"
153
+
154
+
let decoder = {
155
+
use id <- decode.field(0, decode.int)
156
+
use title <- decode.field(1, decode.string)
157
+
use content <- decode.field(2, decode.string)
158
+
use author_id <- decode.field(3, decode.int)
159
+
decode.success(Post(id:, title:, content:, author_id:))
160
+
}
161
+
162
+
sqlight.query(
163
+
sql,
164
+
on: conn,
165
+
with: [sqlight.int(author_id)],
166
+
expecting: decoder,
167
+
)
168
+
|> result.unwrap([])
169
+
}
170
+
171
+
/// Create a new user
172
+
pub fn create_user(
173
+
conn: sqlight.Connection,
174
+
name: String,
175
+
email: String,
176
+
) -> Result(User, String) {
177
+
let sql = "INSERT INTO users (name, email) VALUES (?, ?) RETURNING id, name, email"
178
+
179
+
let decoder = {
180
+
use id <- decode.field(0, decode.int)
181
+
use name <- decode.field(1, decode.string)
182
+
use email <- decode.field(2, decode.string)
183
+
decode.success(User(id:, name:, email:))
184
+
}
185
+
186
+
case
187
+
sqlight.query(
188
+
sql,
189
+
on: conn,
190
+
with: [sqlight.text(name), sqlight.text(email)],
191
+
expecting: decoder,
192
+
)
193
+
{
194
+
Ok([user]) -> Ok(user)
195
+
Ok([]) -> Error("Failed to create user")
196
+
Ok(_) -> Error("Unexpected result")
197
+
Error(err) -> Error("Database error: " <> string.inspect(err))
198
+
}
199
+
}
+138
example/src/example.gleam
+138
example/src/example.gleam
···
1
+
import database
2
+
import gleam/io
3
+
import gleam/option
4
+
import gleam/string
5
+
import graphql_schema
6
+
import swell/executor
7
+
import swell/schema
8
+
9
+
pub fn main() -> Nil {
10
+
io.println("๐ Swell + SQLite Example")
11
+
io.println("=" |> string.repeat(50))
12
+
io.println("")
13
+
14
+
// Setup in-memory SQLite database
15
+
io.println("Setting up in-memory SQLite database...")
16
+
let assert Ok(conn) = database.setup_database()
17
+
io.println("โ Database initialized with sample data")
18
+
io.println("")
19
+
20
+
// Build GraphQL schema
21
+
let graphql_schema = graphql_schema.build_schema(conn)
22
+
io.println("โ GraphQL schema created")
23
+
io.println("")
24
+
25
+
// Example 1: Query all users
26
+
io.println("Query 1: Get all users")
27
+
io.println("-" |> string.repeat(50))
28
+
let query1 = "{ users { id name email } }"
29
+
io.println("GraphQL: " <> query1)
30
+
io.println("")
31
+
32
+
let ctx1 = schema.context(option.None)
33
+
case executor.execute(query1, graphql_schema, ctx1) {
34
+
Ok(executor.Response(data: data, errors: [])) -> {
35
+
io.println("Result: " <> string.inspect(data))
36
+
}
37
+
Ok(executor.Response(data: data, errors: errors)) -> {
38
+
io.println("Data: " <> string.inspect(data))
39
+
io.println("Errors: " <> string.inspect(errors))
40
+
}
41
+
Error(err) -> {
42
+
io.println("Error: " <> err)
43
+
}
44
+
}
45
+
io.println("")
46
+
47
+
// Example 2: Query a specific user by ID
48
+
io.println("Query 2: Get user with ID 1")
49
+
io.println("-" |> string.repeat(50))
50
+
let query2 = "{ user(id: 1) { id name email } }"
51
+
io.println("GraphQL: " <> query2)
52
+
io.println("")
53
+
54
+
let ctx2 = schema.context(option.None)
55
+
case executor.execute(query2, graphql_schema, ctx2) {
56
+
Ok(executor.Response(data: data, errors: [])) -> {
57
+
io.println("Result: " <> string.inspect(data))
58
+
}
59
+
Ok(executor.Response(data: data, errors: errors)) -> {
60
+
io.println("Data: " <> string.inspect(data))
61
+
io.println("Errors: " <> string.inspect(errors))
62
+
}
63
+
Error(err) -> {
64
+
io.println("Error: " <> err)
65
+
}
66
+
}
67
+
io.println("")
68
+
69
+
// Example 3: Query all posts
70
+
io.println("Query 3: Get all posts")
71
+
io.println("-" |> string.repeat(50))
72
+
let query3 = "{ posts { id title content authorId } }"
73
+
io.println("GraphQL: " <> query3)
74
+
io.println("")
75
+
76
+
let ctx3 = schema.context(option.None)
77
+
case executor.execute(query3, graphql_schema, ctx3) {
78
+
Ok(executor.Response(data: data, errors: [])) -> {
79
+
io.println("Result: " <> string.inspect(data))
80
+
}
81
+
Ok(executor.Response(data: data, errors: errors)) -> {
82
+
io.println("Data: " <> string.inspect(data))
83
+
io.println("Errors: " <> string.inspect(errors))
84
+
}
85
+
Error(err) -> {
86
+
io.println("Error: " <> err)
87
+
}
88
+
}
89
+
io.println("")
90
+
91
+
// Example 4: Create a new user with a mutation
92
+
io.println("Mutation 1: Create a new user")
93
+
io.println("-" |> string.repeat(50))
94
+
let mutation1 =
95
+
"mutation { createUser(input: { name: \"Stephanie Gilmore\", email: \"steph@surfmail.com\" }) { id name email } }"
96
+
io.println("GraphQL: " <> mutation1)
97
+
io.println("")
98
+
99
+
let ctx4 = schema.context(option.None)
100
+
case executor.execute(mutation1, graphql_schema, ctx4) {
101
+
Ok(executor.Response(data: data, errors: [])) -> {
102
+
io.println("Result: " <> string.inspect(data))
103
+
}
104
+
Ok(executor.Response(data: data, errors: errors)) -> {
105
+
io.println("Data: " <> string.inspect(data))
106
+
io.println("Errors: " <> string.inspect(errors))
107
+
}
108
+
Error(err) -> {
109
+
io.println("Error: " <> err)
110
+
}
111
+
}
112
+
io.println("")
113
+
114
+
// Example 5: Query the newly created user
115
+
io.println("Query 4: Verify the new user was created")
116
+
io.println("-" |> string.repeat(50))
117
+
let query5 = "{ users { id name email } }"
118
+
io.println("GraphQL: " <> query5)
119
+
io.println("")
120
+
121
+
let ctx5 = schema.context(option.None)
122
+
case executor.execute(query5, graphql_schema, ctx5) {
123
+
Ok(executor.Response(data: data, errors: [])) -> {
124
+
io.println("Result: " <> string.inspect(data))
125
+
}
126
+
Ok(executor.Response(data: data, errors: errors)) -> {
127
+
io.println("Data: " <> string.inspect(data))
128
+
io.println("Errors: " <> string.inspect(errors))
129
+
}
130
+
Error(err) -> {
131
+
io.println("Error: " <> err)
132
+
}
133
+
}
134
+
io.println("")
135
+
136
+
io.println("=" |> string.repeat(50))
137
+
io.println("โ All examples completed!")
138
+
}
+264
example/src/graphql_schema.gleam
+264
example/src/graphql_schema.gleam
···
1
+
import database
2
+
import gleam/int
3
+
import gleam/list
4
+
import gleam/option.{None, Some}
5
+
import sqlight
6
+
import swell/schema
7
+
import swell/value
8
+
9
+
/// Build the User type (without nested posts to avoid circular dependency)
10
+
pub fn user_type() -> schema.Type {
11
+
schema.object_type("User", "A user in the system", [
12
+
schema.field("id", schema.id_type(), "User ID", fn(ctx) {
13
+
case ctx.data {
14
+
Some(value.Object(fields)) -> {
15
+
case list.key_find(fields, "id") {
16
+
Ok(id_val) -> Ok(id_val)
17
+
Error(_) -> Ok(value.Null)
18
+
}
19
+
}
20
+
_ -> Ok(value.Null)
21
+
}
22
+
}),
23
+
schema.field("name", schema.string_type(), "User name", fn(ctx) {
24
+
case ctx.data {
25
+
Some(value.Object(fields)) -> {
26
+
case list.key_find(fields, "name") {
27
+
Ok(name_val) -> Ok(name_val)
28
+
Error(_) -> Ok(value.Null)
29
+
}
30
+
}
31
+
_ -> Ok(value.Null)
32
+
}
33
+
}),
34
+
schema.field("email", schema.string_type(), "User email", fn(ctx) {
35
+
case ctx.data {
36
+
Some(value.Object(fields)) -> {
37
+
case list.key_find(fields, "email") {
38
+
Ok(email_val) -> Ok(email_val)
39
+
Error(_) -> Ok(value.Null)
40
+
}
41
+
}
42
+
_ -> Ok(value.Null)
43
+
}
44
+
}),
45
+
])
46
+
}
47
+
48
+
/// Build the Post type (without nested author to avoid circular dependency)
49
+
pub fn post_type() -> schema.Type {
50
+
schema.object_type("Post", "A blog post", [
51
+
schema.field("id", schema.id_type(), "Post ID", fn(ctx) {
52
+
case ctx.data {
53
+
Some(value.Object(fields)) -> {
54
+
case list.key_find(fields, "id") {
55
+
Ok(id_val) -> Ok(id_val)
56
+
Error(_) -> Ok(value.Null)
57
+
}
58
+
}
59
+
_ -> Ok(value.Null)
60
+
}
61
+
}),
62
+
schema.field("title", schema.string_type(), "Post title", fn(ctx) {
63
+
case ctx.data {
64
+
Some(value.Object(fields)) -> {
65
+
case list.key_find(fields, "title") {
66
+
Ok(title_val) -> Ok(title_val)
67
+
Error(_) -> Ok(value.Null)
68
+
}
69
+
}
70
+
_ -> Ok(value.Null)
71
+
}
72
+
}),
73
+
schema.field("content", schema.string_type(), "Post content", fn(ctx) {
74
+
case ctx.data {
75
+
Some(value.Object(fields)) -> {
76
+
case list.key_find(fields, "content") {
77
+
Ok(content_val) -> Ok(content_val)
78
+
Error(_) -> Ok(value.Null)
79
+
}
80
+
}
81
+
_ -> Ok(value.Null)
82
+
}
83
+
}),
84
+
schema.field("authorId", schema.int_type(), "Author ID", fn(ctx) {
85
+
case ctx.data {
86
+
Some(value.Object(fields)) -> {
87
+
case list.key_find(fields, "author_id") {
88
+
Ok(author_id_val) -> Ok(author_id_val)
89
+
Error(_) -> Ok(value.Null)
90
+
}
91
+
}
92
+
_ -> Ok(value.Null)
93
+
}
94
+
}),
95
+
])
96
+
}
97
+
98
+
/// Convert a User to a GraphQL Value
99
+
fn user_to_value(user: database.User) -> value.Value {
100
+
value.Object([
101
+
#("id", value.Int(user.id)),
102
+
#("name", value.String(user.name)),
103
+
#("email", value.String(user.email)),
104
+
])
105
+
}
106
+
107
+
/// Convert a Post to a GraphQL Value
108
+
fn post_to_value(post: database.Post) -> value.Value {
109
+
value.Object([
110
+
#("id", value.Int(post.id)),
111
+
#("title", value.String(post.title)),
112
+
#("content", value.String(post.content)),
113
+
#("author_id", value.Int(post.author_id)),
114
+
])
115
+
}
116
+
117
+
/// Build the Query type
118
+
pub fn query_type(conn: sqlight.Connection) -> schema.Type {
119
+
schema.object_type("Query", "Root query type", [
120
+
schema.field("users", schema.list_type(user_type()), "Get all users", fn(
121
+
_ctx,
122
+
) {
123
+
let users = database.get_users(conn)
124
+
Ok(value.List(list.map(users, user_to_value)))
125
+
}),
126
+
schema.field_with_args(
127
+
"user",
128
+
user_type(),
129
+
"Get a user by ID",
130
+
[
131
+
schema.argument(
132
+
"id",
133
+
schema.non_null(schema.id_type()),
134
+
"User ID",
135
+
None,
136
+
),
137
+
],
138
+
fn(ctx) {
139
+
case schema.get_argument(ctx, "id") {
140
+
Some(value.Int(user_id)) -> {
141
+
case database.get_user(conn, user_id) {
142
+
Ok(user) -> Ok(user_to_value(user))
143
+
Error(err) -> Error(err)
144
+
}
145
+
}
146
+
Some(value.String(user_id_str)) -> {
147
+
case int.parse(user_id_str) {
148
+
Ok(user_id) -> {
149
+
case database.get_user(conn, user_id) {
150
+
Ok(user) -> Ok(user_to_value(user))
151
+
Error(err) -> Error(err)
152
+
}
153
+
}
154
+
Error(_) -> Error("Invalid user ID format")
155
+
}
156
+
}
157
+
_ -> Error("User ID is required")
158
+
}
159
+
},
160
+
),
161
+
schema.field("posts", schema.list_type(post_type()), "Get all posts", fn(
162
+
_ctx,
163
+
) {
164
+
let posts = database.get_posts(conn)
165
+
Ok(value.List(list.map(posts, post_to_value)))
166
+
}),
167
+
schema.field_with_args(
168
+
"post",
169
+
post_type(),
170
+
"Get a post by ID",
171
+
[
172
+
schema.argument(
173
+
"id",
174
+
schema.non_null(schema.id_type()),
175
+
"Post ID",
176
+
None,
177
+
),
178
+
],
179
+
fn(ctx) {
180
+
case schema.get_argument(ctx, "id") {
181
+
Some(value.Int(post_id)) -> {
182
+
case database.get_post(conn, post_id) {
183
+
Ok(post) -> Ok(post_to_value(post))
184
+
Error(err) -> Error(err)
185
+
}
186
+
}
187
+
Some(value.String(post_id_str)) -> {
188
+
case int.parse(post_id_str) {
189
+
Ok(post_id) -> {
190
+
case database.get_post(conn, post_id) {
191
+
Ok(post) -> Ok(post_to_value(post))
192
+
Error(err) -> Error(err)
193
+
}
194
+
}
195
+
Error(_) -> Error("Invalid post ID format")
196
+
}
197
+
}
198
+
_ -> Error("Post ID is required")
199
+
}
200
+
},
201
+
),
202
+
])
203
+
}
204
+
205
+
/// Input type for creating a user
206
+
pub fn create_user_input() -> schema.Type {
207
+
schema.input_object_type("CreateUserInput", "Input for creating a user", [
208
+
schema.input_field(
209
+
"name",
210
+
schema.non_null(schema.string_type()),
211
+
"User name",
212
+
None,
213
+
),
214
+
schema.input_field(
215
+
"email",
216
+
schema.non_null(schema.string_type()),
217
+
"User email",
218
+
None,
219
+
),
220
+
])
221
+
}
222
+
223
+
/// Build the Mutation type
224
+
pub fn mutation_type(conn: sqlight.Connection) -> schema.Type {
225
+
schema.object_type("Mutation", "Root mutation type", [
226
+
schema.field_with_args(
227
+
"createUser",
228
+
user_type(),
229
+
"Create a new user",
230
+
[
231
+
schema.argument(
232
+
"input",
233
+
schema.non_null(create_user_input()),
234
+
"User data",
235
+
None,
236
+
),
237
+
],
238
+
fn(ctx) {
239
+
case schema.get_argument(ctx, "input") {
240
+
Some(value.Object(fields)) -> {
241
+
let name_result = list.key_find(fields, "name")
242
+
let email_result = list.key_find(fields, "email")
243
+
244
+
case name_result, email_result {
245
+
Ok(value.String(name)), Ok(value.String(email)) -> {
246
+
case database.create_user(conn, name, email) {
247
+
Ok(user) -> Ok(user_to_value(user))
248
+
Error(err) -> Error(err)
249
+
}
250
+
}
251
+
_, _ -> Error("Invalid input: name and email are required")
252
+
}
253
+
}
254
+
_ -> Error("Invalid input format")
255
+
}
256
+
},
257
+
),
258
+
])
259
+
}
260
+
261
+
/// Build the complete GraphQL schema
262
+
pub fn build_schema(conn: sqlight.Connection) -> schema.Schema {
263
+
schema.schema(query_type(conn), Some(mutation_type(conn)))
264
+
}
+13
example/test/example_test.gleam
+13
example/test/example_test.gleam
+20
gleam.toml
+20
gleam.toml
···
1
+
name = "swell"
2
+
version = "1.0.0"
3
+
4
+
# Fill out these fields if you intend to generate HTML documentation or publish
5
+
# your project to the Hex package manager.
6
+
#
7
+
# description = ""
8
+
# licences = ["Apache-2.0"]
9
+
# repository = { type = "github", user = "", repo = "" }
10
+
# links = [{ title = "Website", href = "" }]
11
+
#
12
+
# For a full reference of all the available options, you can have a look at
13
+
# https://gleam.run/writing-gleam/gleam-toml/.
14
+
15
+
[dependencies]
16
+
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
17
+
18
+
[dev-dependencies]
19
+
gleeunit = ">= 1.0.0 and < 2.0.0"
20
+
birdie = ">= 1.0.0 and < 2.0.0"
+28
manifest.toml
+28
manifest.toml
···
1
+
# This file was generated by Gleam
2
+
# You typically do not need to edit this file
3
+
4
+
packages = [
5
+
{ name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
6
+
{ name = "birdie", version = "1.4.1", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "filepath", "glance", "gleam_community_ansi", "gleam_stdlib", "justin", "rank", "simplifile", "term_size", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "18599E478C14BD9EBD2465F0561F96EB9B58A24DB44AF86F103EF81D4B9834BF" },
7
+
{ name = "edit_distance", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "7DC465C34695F9E57D79FC65670C53C992CE342BF29E0AA41FF44F61AF62FC56" },
8
+
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
9
+
{ name = "glance", version = "5.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "7F216D97935465FF4AC46699CD1C3E0FB19CB678B002E4ACAFCE256E96312F14" },
10
+
{ name = "gleam_community_ansi", version = "1.4.3", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "8A62AE9CC6EA65BEA630D95016D6C07E4F9973565FA3D0DE68DC4200D8E0DD27" },
11
+
{ name = "gleam_community_colour", version = "2.0.2", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "E34DD2C896AC3792151EDA939DA435FF3B69922F33415ED3C4406C932FBE9634" },
12
+
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
13
+
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
14
+
{ name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
15
+
{ name = "gleeunit", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "CD701726CBCE5588B375D157B4391CFD0F2F134CD12D9B6998A395484DE05C58" },
16
+
{ name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" },
17
+
{ name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" },
18
+
{ name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" },
19
+
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
20
+
{ name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" },
21
+
{ name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" },
22
+
{ name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" },
23
+
]
24
+
25
+
[requirements]
26
+
birdie = { version = ">= 1.0.0 and < 2.0.0" }
27
+
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
28
+
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+318
src/swell/connection.gleam
+318
src/swell/connection.gleam
···
1
+
/// GraphQL Connection Types for Relay Cursor Connections
2
+
///
3
+
/// Implements the Relay Cursor Connections Specification:
4
+
/// https://relay.dev/graphql/connections.htm
5
+
import gleam/list
6
+
import gleam/option.{type Option, None, Some}
7
+
import swell/schema
8
+
import swell/value
9
+
10
+
/// PageInfo type for connection pagination metadata
11
+
pub type PageInfo {
12
+
PageInfo(
13
+
has_next_page: Bool,
14
+
has_previous_page: Bool,
15
+
start_cursor: Option(String),
16
+
end_cursor: Option(String),
17
+
)
18
+
}
19
+
20
+
/// Edge wrapper containing a node and its cursor
21
+
pub type Edge(node_type) {
22
+
Edge(node: node_type, cursor: String)
23
+
}
24
+
25
+
/// Connection wrapper containing edges and page info
26
+
pub type Connection(node_type) {
27
+
Connection(
28
+
edges: List(Edge(node_type)),
29
+
page_info: PageInfo,
30
+
total_count: Option(Int),
31
+
)
32
+
}
33
+
34
+
/// Creates the PageInfo GraphQL type
35
+
pub fn page_info_type() -> schema.Type {
36
+
schema.object_type(
37
+
"PageInfo",
38
+
"Information about pagination in a connection",
39
+
[
40
+
schema.field(
41
+
"hasNextPage",
42
+
schema.non_null(schema.boolean_type()),
43
+
"When paginating forwards, are there more items?",
44
+
fn(ctx) {
45
+
// Extract from context data
46
+
case ctx.data {
47
+
Some(value.Object(fields)) -> {
48
+
case list.key_find(fields, "hasNextPage") {
49
+
Ok(val) -> Ok(val)
50
+
Error(_) -> Ok(value.Boolean(False))
51
+
}
52
+
}
53
+
_ -> Ok(value.Boolean(False))
54
+
}
55
+
},
56
+
),
57
+
schema.field(
58
+
"hasPreviousPage",
59
+
schema.non_null(schema.boolean_type()),
60
+
"When paginating backwards, are there more items?",
61
+
fn(ctx) {
62
+
case ctx.data {
63
+
Some(value.Object(fields)) -> {
64
+
case list.key_find(fields, "hasPreviousPage") {
65
+
Ok(val) -> Ok(val)
66
+
Error(_) -> Ok(value.Boolean(False))
67
+
}
68
+
}
69
+
_ -> Ok(value.Boolean(False))
70
+
}
71
+
},
72
+
),
73
+
schema.field(
74
+
"startCursor",
75
+
schema.string_type(),
76
+
"Cursor corresponding to the first item in the page",
77
+
fn(ctx) {
78
+
case ctx.data {
79
+
Some(value.Object(fields)) -> {
80
+
case list.key_find(fields, "startCursor") {
81
+
Ok(val) -> Ok(val)
82
+
Error(_) -> Ok(value.Null)
83
+
}
84
+
}
85
+
_ -> Ok(value.Null)
86
+
}
87
+
},
88
+
),
89
+
schema.field(
90
+
"endCursor",
91
+
schema.string_type(),
92
+
"Cursor corresponding to the last item in the page",
93
+
fn(ctx) {
94
+
case ctx.data {
95
+
Some(value.Object(fields)) -> {
96
+
case list.key_find(fields, "endCursor") {
97
+
Ok(val) -> Ok(val)
98
+
Error(_) -> Ok(value.Null)
99
+
}
100
+
}
101
+
_ -> Ok(value.Null)
102
+
}
103
+
},
104
+
),
105
+
],
106
+
)
107
+
}
108
+
109
+
/// Creates an Edge type for a given node type name
110
+
pub fn edge_type(node_type_name: String, node_type: schema.Type) -> schema.Type {
111
+
let edge_type_name = node_type_name <> "Edge"
112
+
113
+
schema.object_type(
114
+
edge_type_name,
115
+
"An edge in a connection for " <> node_type_name,
116
+
[
117
+
schema.field(
118
+
"node",
119
+
schema.non_null(node_type),
120
+
"The item at the end of the edge",
121
+
fn(ctx) {
122
+
// Extract node from context data
123
+
case ctx.data {
124
+
Some(value.Object(fields)) -> {
125
+
case list.key_find(fields, "node") {
126
+
Ok(val) -> Ok(val)
127
+
Error(_) -> Ok(value.Null)
128
+
}
129
+
}
130
+
_ -> Ok(value.Null)
131
+
}
132
+
},
133
+
),
134
+
schema.field(
135
+
"cursor",
136
+
schema.non_null(schema.string_type()),
137
+
"A cursor for use in pagination",
138
+
fn(ctx) {
139
+
case ctx.data {
140
+
Some(value.Object(fields)) -> {
141
+
case list.key_find(fields, "cursor") {
142
+
Ok(val) -> Ok(val)
143
+
Error(_) -> Ok(value.String(""))
144
+
}
145
+
}
146
+
_ -> Ok(value.String(""))
147
+
}
148
+
},
149
+
),
150
+
],
151
+
)
152
+
}
153
+
154
+
/// Creates a Connection type for a given node type name
155
+
pub fn connection_type(
156
+
node_type_name: String,
157
+
edge_type: schema.Type,
158
+
) -> schema.Type {
159
+
let connection_type_name = node_type_name <> "Connection"
160
+
161
+
schema.object_type(
162
+
connection_type_name,
163
+
"A connection to a list of items for " <> node_type_name,
164
+
[
165
+
schema.field(
166
+
"edges",
167
+
schema.non_null(schema.list_type(schema.non_null(edge_type))),
168
+
"A list of edges",
169
+
fn(ctx) {
170
+
// Extract edges from context data
171
+
case ctx.data {
172
+
Some(value.Object(fields)) -> {
173
+
case list.key_find(fields, "edges") {
174
+
Ok(val) -> Ok(val)
175
+
Error(_) -> Ok(value.List([]))
176
+
}
177
+
}
178
+
_ -> Ok(value.List([]))
179
+
}
180
+
},
181
+
),
182
+
schema.field(
183
+
"pageInfo",
184
+
schema.non_null(page_info_type()),
185
+
"Information to aid in pagination",
186
+
fn(ctx) {
187
+
// Extract pageInfo from context data
188
+
case ctx.data {
189
+
Some(value.Object(fields)) -> {
190
+
case list.key_find(fields, "pageInfo") {
191
+
Ok(val) -> Ok(val)
192
+
Error(_) ->
193
+
Ok(
194
+
value.Object([
195
+
#("hasNextPage", value.Boolean(False)),
196
+
#("hasPreviousPage", value.Boolean(False)),
197
+
#("startCursor", value.Null),
198
+
#("endCursor", value.Null),
199
+
]),
200
+
)
201
+
}
202
+
}
203
+
_ ->
204
+
Ok(
205
+
value.Object([
206
+
#("hasNextPage", value.Boolean(False)),
207
+
#("hasPreviousPage", value.Boolean(False)),
208
+
#("startCursor", value.Null),
209
+
#("endCursor", value.Null),
210
+
]),
211
+
)
212
+
}
213
+
},
214
+
),
215
+
schema.field(
216
+
"totalCount",
217
+
schema.int_type(),
218
+
"Total number of items in the connection",
219
+
fn(ctx) {
220
+
case ctx.data {
221
+
Some(value.Object(fields)) -> {
222
+
case list.key_find(fields, "totalCount") {
223
+
Ok(val) -> Ok(val)
224
+
Error(_) -> Ok(value.Null)
225
+
}
226
+
}
227
+
_ -> Ok(value.Null)
228
+
}
229
+
},
230
+
),
231
+
],
232
+
)
233
+
}
234
+
235
+
/// Standard pagination arguments for forward pagination
236
+
pub fn forward_pagination_args() -> List(schema.Argument) {
237
+
[
238
+
schema.argument(
239
+
"first",
240
+
schema.int_type(),
241
+
"Returns the first n items from the list",
242
+
None,
243
+
),
244
+
schema.argument(
245
+
"after",
246
+
schema.string_type(),
247
+
"Returns items after the given cursor",
248
+
None,
249
+
),
250
+
]
251
+
}
252
+
253
+
/// Standard pagination arguments for backward pagination
254
+
pub fn backward_pagination_args() -> List(schema.Argument) {
255
+
[
256
+
schema.argument(
257
+
"last",
258
+
schema.int_type(),
259
+
"Returns the last n items from the list",
260
+
None,
261
+
),
262
+
schema.argument(
263
+
"before",
264
+
schema.string_type(),
265
+
"Returns items before the given cursor",
266
+
None,
267
+
),
268
+
]
269
+
}
270
+
271
+
/// All standard connection arguments (forward + backward)
272
+
/// Note: sortBy is not included yet as it requires InputObject type support
273
+
pub fn connection_args() -> List(schema.Argument) {
274
+
list.flatten([forward_pagination_args(), backward_pagination_args()])
275
+
}
276
+
277
+
/// Converts a PageInfo value to a GraphQL value
278
+
pub fn page_info_to_value(page_info: PageInfo) -> value.Value {
279
+
value.Object([
280
+
#("hasNextPage", value.Boolean(page_info.has_next_page)),
281
+
#("hasPreviousPage", value.Boolean(page_info.has_previous_page)),
282
+
#("startCursor", case page_info.start_cursor {
283
+
Some(cursor) -> value.String(cursor)
284
+
None -> value.Null
285
+
}),
286
+
#("endCursor", case page_info.end_cursor {
287
+
Some(cursor) -> value.String(cursor)
288
+
None -> value.Null
289
+
}),
290
+
])
291
+
}
292
+
293
+
/// Converts an Edge to a GraphQL value
294
+
pub fn edge_to_value(edge: Edge(value.Value)) -> value.Value {
295
+
value.Object([
296
+
#("node", edge.node),
297
+
#("cursor", value.String(edge.cursor)),
298
+
])
299
+
}
300
+
301
+
/// Converts a Connection to a GraphQL value
302
+
pub fn connection_to_value(connection: Connection(value.Value)) -> value.Value {
303
+
let edges_value =
304
+
connection.edges
305
+
|> list.map(edge_to_value)
306
+
|> value.List
307
+
308
+
let total_count_value = case connection.total_count {
309
+
Some(count) -> value.Int(count)
310
+
None -> value.Null
311
+
}
312
+
313
+
value.Object([
314
+
#("edges", edges_value),
315
+
#("pageInfo", page_info_to_value(connection.page_info)),
316
+
#("totalCount", total_count_value),
317
+
])
318
+
}
+927
src/swell/executor.gleam
+927
src/swell/executor.gleam
···
1
+
/// GraphQL Executor
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}
8
+
import swell/introspection
9
+
import swell/parser
10
+
import swell/schema
11
+
import swell/value
12
+
13
+
/// GraphQL Error
14
+
pub type GraphQLError {
15
+
GraphQLError(message: String, path: List(String))
16
+
}
17
+
18
+
/// GraphQL Response
19
+
pub type Response {
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 {
26
+
option.Some(alias_name) -> alias_name
27
+
option.None -> field_name
28
+
}
29
+
}
30
+
31
+
/// Execute a GraphQL query
32
+
pub fn execute(
33
+
query: String,
34
+
graphql_schema: schema.Schema,
35
+
ctx: schema.Context,
36
+
) -> Result(Response, String) {
37
+
// Parse the query
38
+
case parser.parse(query) {
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
+
}
47
+
}
48
+
}
49
+
}
50
+
51
+
fn format_parse_error(err: parser.ParseError) -> String {
52
+
case err {
53
+
parser.UnexpectedToken(_, msg) -> msg
54
+
parser.UnexpectedEndOfInput(msg) -> msg
55
+
parser.LexerError(_) -> "Lexer error"
56
+
}
57
+
}
58
+
59
+
/// Execute a document
60
+
fn execute_document(
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) -> {
67
+
// Separate fragments from executable operations
68
+
let #(fragments, executable_ops) = partition_operations(operations)
69
+
70
+
// Build fragments dictionary
71
+
let fragments_dict = build_fragments_dict(fragments)
72
+
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
+
}
80
+
}
81
+
}
82
+
83
+
/// Partition operations into fragments and executable operations
84
+
fn partition_operations(
85
+
operations: List(parser.Operation),
86
+
) -> #(List(parser.Operation), List(parser.Operation)) {
87
+
list.partition(operations, fn(op) {
88
+
case op {
89
+
parser.FragmentDefinition(_, _, _) -> True
90
+
_ -> False
91
+
}
92
+
})
93
+
}
94
+
95
+
/// Build a dictionary of fragments keyed by name
96
+
fn build_fragments_dict(
97
+
fragments: List(parser.Operation),
98
+
) -> Dict(String, parser.Operation) {
99
+
fragments
100
+
|> list.filter_map(fn(frag) {
101
+
case frag {
102
+
parser.FragmentDefinition(name, _, _) -> Ok(#(name, frag))
103
+
_ -> Error(Nil)
104
+
}
105
+
})
106
+
|> dict.from_list
107
+
}
108
+
109
+
/// Execute an operation
110
+
fn execute_operation(
111
+
operation: parser.Operation,
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) -> {
118
+
let root_type = schema.query_type(graphql_schema)
119
+
execute_selection_set(
120
+
selection_set,
121
+
root_type,
122
+
graphql_schema,
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) -> {
140
+
// Get mutation root type from schema
141
+
case schema.get_mutation_type(graphql_schema) {
142
+
option.Some(mutation_type) ->
143
+
execute_selection_set(
144
+
selection_set,
145
+
mutation_type,
146
+
graphql_schema,
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
+
}
169
+
parser.Subscription(selection_set) -> {
170
+
// Get subscription root type from schema
171
+
case schema.get_subscription_type(graphql_schema) {
172
+
option.Some(subscription_type) ->
173
+
execute_selection_set(
174
+
selection_set,
175
+
subscription_type,
176
+
graphql_schema,
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
+
}
199
+
parser.FragmentDefinition(_, _, _) ->
200
+
Error("Fragment definitions are not executable operations")
201
+
}
202
+
}
203
+
204
+
/// Execute a selection set
205
+
fn execute_selection_set(
206
+
selection_set: parser.SelectionSet,
207
+
parent_type: schema.Type,
208
+
graphql_schema: schema.Schema,
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) -> {
215
+
let results =
216
+
list.map(selections, fn(selection) {
217
+
execute_selection(
218
+
selection,
219
+
parent_type,
220
+
graphql_schema,
221
+
ctx,
222
+
fragments,
223
+
path,
224
+
)
225
+
})
226
+
227
+
// Collect all data and errors, merging fragment fields
228
+
let #(data, errors) = collect_and_merge_fields(results)
229
+
230
+
Ok(#(value.Object(data), errors))
231
+
}
232
+
}
233
+
}
234
+
235
+
/// Collect and merge fields from selection results, handling fragment fields
236
+
fn collect_and_merge_fields(
237
+
results: List(Result(#(String, value.Value, List(GraphQLError)), String)),
238
+
) -> #(List(#(String, value.Value)), List(GraphQLError)) {
239
+
let #(data, errors) =
240
+
results
241
+
|> list.fold(#([], []), fn(acc, r) {
242
+
let #(fields_acc, errors_acc) = acc
243
+
case r {
244
+
Ok(#("__fragment_fields", value.Object(fragment_fields), errs)) -> {
245
+
// Merge fragment fields into parent
246
+
#(
247
+
list.append(fields_acc, fragment_fields),
248
+
list.append(errors_acc, errs),
249
+
)
250
+
}
251
+
Ok(#("__fragment_skip", _, _errs)) -> {
252
+
// Skip fragment that didn't match type condition
253
+
acc
254
+
}
255
+
Ok(#(name, val, errs)) -> {
256
+
// Regular field
257
+
#(
258
+
list.append(fields_acc, [#(name, val)]),
259
+
list.append(errors_acc, errs),
260
+
)
261
+
}
262
+
Error(_) -> acc
263
+
}
264
+
})
265
+
266
+
#(data, errors)
267
+
}
268
+
269
+
/// Execute a selection
270
+
fn execute_selection(
271
+
selection: parser.Selection,
272
+
parent_type: schema.Type,
273
+
graphql_schema: schema.Schema,
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) -> {
280
+
// Look up the fragment definition
281
+
case dict.get(fragments, name) {
282
+
Error(_) -> Error("Fragment '" <> name <> "' not found")
283
+
Ok(parser.FragmentDefinition(
284
+
_fname,
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
293
+
// Return empty object as a placeholder that will be filtered out
294
+
Ok(#("__fragment_skip", value.Null, []))
295
+
}
296
+
True -> {
297
+
// Type condition matches, execute fragment's selections
298
+
case
299
+
execute_selection_set(
300
+
fragment_selection_set,
301
+
parent_type,
302
+
graphql_schema,
303
+
ctx,
304
+
fragments,
305
+
path,
306
+
)
307
+
{
308
+
Ok(#(value.Object(fields), errs)) -> {
309
+
// Fragment selections should be merged into parent
310
+
// For now, return as a special marker
311
+
Ok(#("__fragment_fields", value.Object(fields), errs))
312
+
}
313
+
Ok(#(val, errs)) -> Ok(#("__fragment_fields", val, errs))
314
+
Error(err) -> Error(err)
315
+
}
316
+
}
317
+
}
318
+
}
319
+
Ok(_) -> Error("Invalid fragment definition")
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
328
+
}
329
+
330
+
case should_execute {
331
+
False -> Ok(#("__fragment_skip", value.Null, []))
332
+
True -> {
333
+
let inline_selection_set = parser.SelectionSet(inline_selections)
334
+
case
335
+
execute_selection_set(
336
+
inline_selection_set,
337
+
parent_type,
338
+
graphql_schema,
339
+
ctx,
340
+
fragments,
341
+
path,
342
+
)
343
+
{
344
+
Ok(#(value.Object(fields), errs)) ->
345
+
Ok(#("__fragment_fields", value.Object(fields), errs))
346
+
Ok(#(val, errs)) -> Ok(#("__fragment_fields", val, errs))
347
+
Error(err) -> Error(err)
348
+
}
349
+
}
350
+
}
351
+
}
352
+
parser.Field(name, alias, arguments, nested_selections) -> {
353
+
// Convert arguments to dict (with variable resolution from context)
354
+
let args_dict = arguments_to_dict(arguments, ctx)
355
+
356
+
// Determine the response key (use alias if provided, otherwise field name)
357
+
let key = response_key(name, alias)
358
+
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" -> {
366
+
let schema_value = introspection.schema_introspection(graphql_schema)
367
+
// Handle nested selections on __schema
368
+
case nested_selections {
369
+
[] -> Ok(#(key, schema_value, []))
370
+
_ -> {
371
+
let selection_set = parser.SelectionSet(nested_selections)
372
+
// We don't have an actual type for __Schema, so we'll handle it specially
373
+
// For now, just return the schema value with nested execution
374
+
case
375
+
execute_introspection_selection_set(
376
+
selection_set,
377
+
schema_value,
378
+
graphql_schema,
379
+
ctx,
380
+
fragments,
381
+
["__schema", ..path],
382
+
set.new(),
383
+
)
384
+
{
385
+
Ok(#(nested_data, nested_errors)) ->
386
+
Ok(#(key, nested_data, nested_errors))
387
+
Error(err) -> {
388
+
let error = GraphQLError(err, ["__schema", ..path])
389
+
Ok(#(key, value.Null, [error]))
390
+
}
391
+
}
392
+
}
393
+
}
394
+
}
395
+
"__type" -> {
396
+
// Extract the "name" argument
397
+
case dict.get(args_dict, "name") {
398
+
Ok(value.String(type_name)) -> {
399
+
// Look up the type in the schema
400
+
case
401
+
introspection.type_by_name_introspection(
402
+
graphql_schema,
403
+
type_name,
404
+
)
405
+
{
406
+
option.Some(type_value) -> {
407
+
// Handle nested selections on __type
408
+
case nested_selections {
409
+
[] -> Ok(#(key, type_value, []))
410
+
_ -> {
411
+
let selection_set = parser.SelectionSet(nested_selections)
412
+
case
413
+
execute_introspection_selection_set(
414
+
selection_set,
415
+
type_value,
416
+
graphql_schema,
417
+
ctx,
418
+
fragments,
419
+
["__type", ..path],
420
+
set.new(),
421
+
)
422
+
{
423
+
Ok(#(nested_data, nested_errors)) ->
424
+
Ok(#(key, nested_data, nested_errors))
425
+
Error(err) -> {
426
+
let error = GraphQLError(err, ["__type", ..path])
427
+
Ok(#(key, value.Null, [error]))
428
+
}
429
+
}
430
+
}
431
+
}
432
+
}
433
+
option.None -> {
434
+
// Type not found, return null (per GraphQL spec)
435
+
Ok(#(key, value.Null, []))
436
+
}
437
+
}
438
+
}
439
+
Ok(_) -> {
440
+
let error =
441
+
GraphQLError("__type argument 'name' must be a String", path)
442
+
Ok(#(key, value.Null, [error]))
443
+
}
444
+
Error(_) -> {
445
+
let error =
446
+
GraphQLError("__type requires a 'name' argument", path)
447
+
Ok(#(key, value.Null, [error]))
448
+
}
449
+
}
450
+
}
451
+
_ -> {
452
+
// Get field from schema
453
+
case schema.get_field(parent_type, name) {
454
+
None -> {
455
+
let error = GraphQLError("Field '" <> name <> "' not found", path)
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
+
}
604
+
}
605
+
}
606
+
}
607
+
}
608
+
}
609
+
}
610
+
}
611
+
}
612
+
}
613
+
614
+
/// Execute a selection set on an introspection value (like __schema)
615
+
/// This directly reads fields from the value.Object rather than using resolvers
616
+
fn execute_introspection_selection_set(
617
+
selection_set: parser.SelectionSet,
618
+
value_obj: value.Value,
619
+
graphql_schema: schema.Schema,
620
+
ctx: schema.Context,
621
+
fragments: Dict(String, parser.Operation),
622
+
path: List(String),
623
+
visited_types: Set(String),
624
+
) -> Result(#(value.Value, List(GraphQLError)), String) {
625
+
case selection_set {
626
+
parser.SelectionSet(selections) -> {
627
+
case value_obj {
628
+
value.List(items) -> {
629
+
// For lists, execute the selection set on each item
630
+
let results =
631
+
list.map(items, fn(item) {
632
+
execute_introspection_selection_set(
633
+
selection_set,
634
+
item,
635
+
graphql_schema,
636
+
ctx,
637
+
fragments,
638
+
path,
639
+
visited_types,
640
+
)
641
+
})
642
+
643
+
// Collect the data and errors
644
+
let data_items =
645
+
results
646
+
|> list.filter_map(fn(r) {
647
+
case r {
648
+
Ok(#(val, _)) -> Ok(val)
649
+
Error(_) -> Error(Nil)
650
+
}
651
+
})
652
+
653
+
let all_errors =
654
+
results
655
+
|> list.flat_map(fn(r) {
656
+
case r {
657
+
Ok(#(_, errs)) -> errs
658
+
Error(_) -> []
659
+
}
660
+
})
661
+
662
+
Ok(#(value.List(data_items), all_errors))
663
+
}
664
+
value.Null -> {
665
+
// If the value is null, just return null regardless of selections
666
+
// This handles cases like mutationType and subscriptionType which are null
667
+
Ok(#(value.Null, []))
668
+
}
669
+
value.Object(fields) -> {
670
+
// CYCLE DETECTION: Extract type name from object to detect circular references
671
+
let type_name = case list.key_find(fields, "name") {
672
+
Ok(value.String(name)) -> option.Some(name)
673
+
_ -> option.None
674
+
}
675
+
676
+
// Check if we've already visited this type to prevent infinite loops
677
+
let is_cycle = case type_name {
678
+
option.Some(name) -> set.contains(visited_types, name)
679
+
option.None -> False
680
+
}
681
+
682
+
// If we detected a cycle, return a minimal object to break the loop
683
+
case is_cycle {
684
+
True -> {
685
+
// Return just the type name and kind to break the cycle
686
+
let minimal_fields = case type_name {
687
+
option.Some(name) -> {
688
+
let kind_value = case list.key_find(fields, "kind") {
689
+
Ok(kind) -> kind
690
+
Error(_) -> value.Null
691
+
}
692
+
[#("name", value.String(name)), #("kind", kind_value)]
693
+
}
694
+
option.None -> []
695
+
}
696
+
Ok(#(value.Object(minimal_fields), []))
697
+
}
698
+
False -> {
699
+
// Add current type to visited set before recursing
700
+
let new_visited = case type_name {
701
+
option.Some(name) -> set.insert(visited_types, name)
702
+
option.None -> visited_types
703
+
}
704
+
705
+
// For each selection, find the corresponding field in the object
706
+
let results =
707
+
list.map(selections, fn(selection) {
708
+
case selection {
709
+
parser.FragmentSpread(name) -> {
710
+
// Look up the fragment definition
711
+
case dict.get(fragments, name) {
712
+
Error(_) -> {
713
+
// Fragment not found - return error
714
+
let error =
715
+
GraphQLError(
716
+
"Fragment '" <> name <> "' not found",
717
+
path,
718
+
)
719
+
Ok(
720
+
#(
721
+
"__FRAGMENT_ERROR",
722
+
value.String("Fragment not found: " <> name),
723
+
[error],
724
+
),
725
+
)
726
+
}
727
+
Ok(parser.FragmentDefinition(
728
+
_fname,
729
+
_type_condition,
730
+
fragment_selection_set,
731
+
)) -> {
732
+
// For introspection, we don't check type conditions - just execute the fragment
733
+
// IMPORTANT: Use visited_types (not new_visited) because we're selecting from
734
+
// the SAME object, not recursing into it. The current object was already added
735
+
// to new_visited, but the fragment is just selecting different fields.
736
+
case
737
+
execute_introspection_selection_set(
738
+
fragment_selection_set,
739
+
value_obj,
740
+
graphql_schema,
741
+
ctx,
742
+
fragments,
743
+
path,
744
+
visited_types,
745
+
)
746
+
{
747
+
Ok(#(value.Object(fragment_fields), errs)) ->
748
+
Ok(#(
749
+
"__fragment_fields",
750
+
value.Object(fragment_fields),
751
+
errs,
752
+
))
753
+
Ok(#(val, errs)) ->
754
+
Ok(#("__fragment_fields", val, errs))
755
+
Error(_err) -> Error(Nil)
756
+
}
757
+
}
758
+
Ok(_) -> Error(Nil)
759
+
// Invalid fragment definition
760
+
}
761
+
}
762
+
parser.InlineFragment(
763
+
_type_condition_opt,
764
+
inline_selections,
765
+
) -> {
766
+
// For introspection, inline fragments always execute (no type checking needed)
767
+
// Execute the inline fragment's selections on this object
768
+
let inline_selection_set =
769
+
parser.SelectionSet(inline_selections)
770
+
case
771
+
execute_introspection_selection_set(
772
+
inline_selection_set,
773
+
value_obj,
774
+
graphql_schema,
775
+
ctx,
776
+
fragments,
777
+
path,
778
+
new_visited,
779
+
)
780
+
{
781
+
Ok(#(value.Object(fragment_fields), errs)) ->
782
+
// Return fragment fields to be merged
783
+
Ok(#(
784
+
"__fragment_fields",
785
+
value.Object(fragment_fields),
786
+
errs,
787
+
))
788
+
Ok(#(val, errs)) ->
789
+
Ok(#("__fragment_fields", val, errs))
790
+
Error(_err) -> Error(Nil)
791
+
}
792
+
}
793
+
parser.Field(name, alias, _arguments, nested_selections) -> {
794
+
// Determine the response key (use alias if provided, otherwise field name)
795
+
let key = response_key(name, alias)
796
+
797
+
// Find the field in the object
798
+
case list.key_find(fields, name) {
799
+
Ok(field_value) -> {
800
+
// Handle nested selections
801
+
case nested_selections {
802
+
[] -> Ok(#(key, field_value, []))
803
+
_ -> {
804
+
let selection_set =
805
+
parser.SelectionSet(nested_selections)
806
+
case
807
+
execute_introspection_selection_set(
808
+
selection_set,
809
+
field_value,
810
+
graphql_schema,
811
+
ctx,
812
+
fragments,
813
+
[name, ..path],
814
+
new_visited,
815
+
)
816
+
{
817
+
Ok(#(nested_data, nested_errors)) ->
818
+
Ok(#(key, nested_data, nested_errors))
819
+
Error(err) -> {
820
+
let error = GraphQLError(err, [name, ..path])
821
+
Ok(#(key, value.Null, [error]))
822
+
}
823
+
}
824
+
}
825
+
}
826
+
}
827
+
Error(_) -> {
828
+
let error =
829
+
GraphQLError(
830
+
"Field '" <> name <> "' not found",
831
+
path,
832
+
)
833
+
Ok(#(key, value.Null, [error]))
834
+
}
835
+
}
836
+
}
837
+
}
838
+
})
839
+
840
+
// Collect all data and errors, merging fragment fields
841
+
let #(data, errors) =
842
+
results
843
+
|> list.fold(#([], []), fn(acc, r) {
844
+
let #(fields_acc, errors_acc) = acc
845
+
case r {
846
+
Ok(#(
847
+
"__fragment_fields",
848
+
value.Object(fragment_fields),
849
+
errs,
850
+
)) -> {
851
+
// Merge fragment fields into parent
852
+
#(
853
+
list.append(fields_acc, fragment_fields),
854
+
list.append(errors_acc, errs),
855
+
)
856
+
}
857
+
Ok(#(name, val, errs)) -> {
858
+
// Regular field
859
+
#(
860
+
list.append(fields_acc, [#(name, val)]),
861
+
list.append(errors_acc, errs),
862
+
)
863
+
}
864
+
Error(_) -> acc
865
+
}
866
+
})
867
+
868
+
Ok(#(value.Object(data), errors))
869
+
}
870
+
}
871
+
}
872
+
_ ->
873
+
Error(
874
+
"Expected object, list, or null for introspection selection set",
875
+
)
876
+
}
877
+
}
878
+
}
879
+
}
880
+
881
+
/// Convert parser ArgumentValue to value.Value
882
+
fn argument_value_to_value(
883
+
arg_value: parser.ArgumentValue,
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
892
+
parser.EnumValue(s) -> value.String(s)
893
+
parser.ListValue(items) ->
894
+
value.List(
895
+
list.map(items, fn(item) { argument_value_to_value(item, ctx) }),
896
+
)
897
+
parser.ObjectValue(fields) ->
898
+
value.Object(
899
+
list.map(fields, fn(pair) {
900
+
let #(name, val) = pair
901
+
#(name, argument_value_to_value(val, ctx))
902
+
}),
903
+
)
904
+
parser.VariableValue(name) -> {
905
+
// Look up variable value from context
906
+
case schema.get_variable(ctx, name) {
907
+
option.Some(val) -> val
908
+
option.None -> value.Null
909
+
}
910
+
}
911
+
}
912
+
}
913
+
914
+
/// Convert list of Arguments to a Dict of values
915
+
fn arguments_to_dict(
916
+
arguments: List(parser.Argument),
917
+
ctx: schema.Context,
918
+
) -> Dict(String, value.Value) {
919
+
list.fold(arguments, dict.new(), fn(acc, arg) {
920
+
case arg {
921
+
parser.Argument(name, arg_value) -> {
922
+
let value = argument_value_to_value(arg_value, ctx)
923
+
dict.insert(acc, name, value)
924
+
}
925
+
}
926
+
})
927
+
}
+424
src/swell/introspection.gleam
+424
src/swell/introspection.gleam
···
1
+
/// GraphQL Introspection
2
+
///
3
+
/// Implements the GraphQL introspection system per the GraphQL spec.
4
+
/// Provides __schema, __type, and __typename meta-fields.
5
+
import gleam/dict
6
+
import gleam/list
7
+
import gleam/option
8
+
import gleam/result
9
+
import swell/schema
10
+
import swell/value
11
+
12
+
/// Build introspection value for __schema
13
+
pub fn schema_introspection(graphql_schema: schema.Schema) -> value.Value {
14
+
let query_type = schema.query_type(graphql_schema)
15
+
let mutation_type_option = schema.get_mutation_type(graphql_schema)
16
+
let subscription_type_option = schema.get_subscription_type(graphql_schema)
17
+
18
+
// Build list of all types in the schema
19
+
let all_types = get_all_types(graphql_schema)
20
+
21
+
// Build mutation type ref if it exists
22
+
let mutation_type_value = case mutation_type_option {
23
+
option.Some(mutation_type) -> type_ref(mutation_type)
24
+
option.None -> value.Null
25
+
}
26
+
27
+
// Build subscription type ref if it exists
28
+
let subscription_type_value = case subscription_type_option {
29
+
option.Some(subscription_type) -> type_ref(subscription_type)
30
+
option.None -> value.Null
31
+
}
32
+
33
+
value.Object([
34
+
#("queryType", type_ref(query_type)),
35
+
#("mutationType", mutation_type_value),
36
+
#("subscriptionType", subscription_type_value),
37
+
#("types", value.List(all_types)),
38
+
#("directives", value.List([])),
39
+
])
40
+
}
41
+
42
+
/// Build introspection value for __type(name: "TypeName")
43
+
/// Returns Some(type_introspection) if the type is found, None otherwise
44
+
pub fn type_by_name_introspection(
45
+
graphql_schema: schema.Schema,
46
+
type_name: String,
47
+
) -> option.Option(value.Value) {
48
+
let all_types = get_all_schema_types(graphql_schema)
49
+
50
+
// Find the type with the matching name
51
+
let found_type =
52
+
list.find(all_types, fn(t) { schema.type_name(t) == type_name })
53
+
54
+
case found_type {
55
+
Ok(t) -> option.Some(type_introspection(t))
56
+
Error(_) -> option.None
57
+
}
58
+
}
59
+
60
+
/// Get all types from the schema as schema.Type values
61
+
/// Useful for testing and documentation generation
62
+
pub fn get_all_schema_types(graphql_schema: schema.Schema) -> List(schema.Type) {
63
+
let query_type = schema.query_type(graphql_schema)
64
+
let mutation_type_option = schema.get_mutation_type(graphql_schema)
65
+
let subscription_type_option = schema.get_subscription_type(graphql_schema)
66
+
67
+
// Collect all types by traversing the query type
68
+
let mut_collected_types = collect_types_from_type(query_type, [])
69
+
70
+
// Also collect types from mutation type if it exists
71
+
let mutation_collected_types = case mutation_type_option {
72
+
option.Some(mutation_type) ->
73
+
collect_types_from_type(mutation_type, mut_collected_types)
74
+
option.None -> mut_collected_types
75
+
}
76
+
77
+
// Also collect types from subscription type if it exists
78
+
let all_collected_types = case subscription_type_option {
79
+
option.Some(subscription_type) ->
80
+
collect_types_from_type(subscription_type, mutation_collected_types)
81
+
option.None -> mutation_collected_types
82
+
}
83
+
84
+
// Deduplicate by type name, preferring types with more fields
85
+
// This ensures we get the "most complete" version of each type
86
+
let unique_types = deduplicate_types_by_name(all_collected_types)
87
+
88
+
// Add any built-in scalars that aren't already in the list
89
+
let all_built_ins = [
90
+
schema.string_type(),
91
+
schema.int_type(),
92
+
schema.float_type(),
93
+
schema.boolean_type(),
94
+
schema.id_type(),
95
+
]
96
+
97
+
let collected_names = list.map(unique_types, schema.type_name)
98
+
let missing_built_ins =
99
+
list.filter(all_built_ins, fn(built_in) {
100
+
let built_in_name = schema.type_name(built_in)
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
116
+
/// This ensures we get the "most complete" version of each type when
117
+
/// multiple versions exist (e.g., from different passes in schema building)
118
+
fn deduplicate_types_by_name(types: List(schema.Type)) -> List(schema.Type) {
119
+
// Group types by name
120
+
types
121
+
|> list.group(schema.type_name)
122
+
|> dict.to_list
123
+
|> list.map(fn(pair) {
124
+
let #(_name, type_list) = pair
125
+
// For each group, find the type with the most content
126
+
type_list
127
+
|> list.reduce(fn(best, current) {
128
+
// Count content: fields for object types, enum values for enums, etc.
129
+
let best_content_count = get_type_content_count(best)
130
+
let current_content_count = get_type_content_count(current)
131
+
132
+
// Prefer the type with more content
133
+
case current_content_count > best_content_count {
134
+
True -> current
135
+
False -> best
136
+
}
137
+
})
138
+
|> result.unwrap(
139
+
list.first(type_list)
140
+
|> result.unwrap(schema.string_type()),
141
+
)
142
+
})
143
+
}
144
+
145
+
/// Get the "content count" for a type (fields, enum values, input fields, etc.)
146
+
/// This helps us pick the most complete version of a type during deduplication
147
+
fn get_type_content_count(t: schema.Type) -> Int {
148
+
// For object types, count fields
149
+
let field_count = list.length(schema.get_fields(t))
150
+
151
+
// For enum types, count enum values
152
+
let enum_value_count = list.length(schema.get_enum_values(t))
153
+
154
+
// For input object types, count input fields
155
+
let input_field_count = list.length(schema.get_input_fields(t))
156
+
157
+
// Return the maximum (types will only have one of these be non-zero)
158
+
[field_count, enum_value_count, input_field_count]
159
+
|> list.reduce(fn(a, b) {
160
+
case a > b {
161
+
True -> a
162
+
False -> b
163
+
}
164
+
})
165
+
|> result.unwrap(0)
166
+
}
167
+
168
+
/// Collect all types referenced in a type (recursively)
169
+
/// Note: We collect ALL instances of each type (even duplicates by name)
170
+
/// because we want to find the "most complete" version during deduplication
171
+
fn collect_types_from_type(
172
+
t: schema.Type,
173
+
acc: List(schema.Type),
174
+
) -> List(schema.Type) {
175
+
// Always add this type - we'll deduplicate later by choosing the version with most fields
176
+
let new_acc = [t, ..acc]
177
+
178
+
// To prevent infinite recursion, check if we've already traversed this exact type instance
179
+
// We use a simple heuristic: if this type name appears multiple times AND this specific
180
+
// instance has the same or fewer content than what we've seen, skip traversing its children
181
+
let should_traverse_children = case
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
+
}
206
+
207
+
case should_traverse_children {
208
+
False -> new_acc
209
+
True -> {
210
+
// Recursively collect types from fields if this is an object type
211
+
case schema.is_object(t) {
212
+
True -> {
213
+
let fields = schema.get_fields(t)
214
+
list.fold(fields, new_acc, fn(acc2, field) {
215
+
let field_type = schema.field_type(field)
216
+
let acc3 = collect_types_from_type_deep(field_type, acc2)
217
+
218
+
// Also collect types from field arguments
219
+
let arguments = schema.field_arguments(field)
220
+
list.fold(arguments, acc3, fn(acc4, arg) {
221
+
let arg_type = schema.argument_type(arg)
222
+
collect_types_from_type_deep(arg_type, acc4)
223
+
})
224
+
})
225
+
}
226
+
False -> {
227
+
// Check if it's a union type
228
+
case schema.is_union(t) {
229
+
True -> {
230
+
// Collect types from union's possible_types
231
+
let possible_types = schema.get_possible_types(t)
232
+
list.fold(possible_types, new_acc, fn(acc2, union_type) {
233
+
collect_types_from_type_deep(union_type, acc2)
234
+
})
235
+
}
236
+
False -> {
237
+
// Check if it's an InputObjectType
238
+
let input_fields = schema.get_input_fields(t)
239
+
case list.is_empty(input_fields) {
240
+
False -> {
241
+
// This is an InputObjectType, collect types from its fields
242
+
list.fold(input_fields, new_acc, fn(acc2, input_field) {
243
+
let field_type = schema.input_field_type(input_field)
244
+
collect_types_from_type_deep(field_type, acc2)
245
+
})
246
+
}
247
+
True -> {
248
+
// Check if it's a wrapping type (List or NonNull)
249
+
case schema.inner_type(t) {
250
+
option.Some(inner) ->
251
+
collect_types_from_type_deep(inner, new_acc)
252
+
option.None -> new_acc
253
+
}
254
+
}
255
+
}
256
+
}
257
+
}
258
+
}
259
+
}
260
+
}
261
+
}
262
+
}
263
+
264
+
/// Helper to unwrap LIST and NON_NULL and collect the inner type
265
+
fn collect_types_from_type_deep(
266
+
t: schema.Type,
267
+
acc: List(schema.Type),
268
+
) -> List(schema.Type) {
269
+
// Check if this is a wrapping type (List or NonNull)
270
+
case schema.inner_type(t) {
271
+
option.Some(inner) -> collect_types_from_type_deep(inner, acc)
272
+
option.None -> collect_types_from_type(t, acc)
273
+
}
274
+
}
275
+
276
+
/// Build full type introspection value
277
+
fn type_introspection(t: schema.Type) -> value.Value {
278
+
let kind = schema.type_kind(t)
279
+
let type_name = schema.type_name(t)
280
+
281
+
// Get inner type for LIST and NON_NULL
282
+
let of_type = case schema.inner_type(t) {
283
+
option.Some(inner) -> type_ref(inner)
284
+
option.None -> value.Null
285
+
}
286
+
287
+
// Determine fields based on kind
288
+
let fields = case kind {
289
+
"OBJECT" -> value.List(get_fields_for_type(t))
290
+
_ -> value.Null
291
+
}
292
+
293
+
// Determine inputFields for INPUT_OBJECT types
294
+
let input_fields = case kind {
295
+
"INPUT_OBJECT" -> value.List(get_input_fields_for_type(t))
296
+
_ -> value.Null
297
+
}
298
+
299
+
// Determine enumValues for ENUM types
300
+
let enum_values = case kind {
301
+
"ENUM" -> value.List(get_enum_values_for_type(t))
302
+
_ -> value.Null
303
+
}
304
+
305
+
// Determine possibleTypes for UNION types
306
+
let possible_types = case kind {
307
+
"UNION" -> {
308
+
let types = schema.get_possible_types(t)
309
+
value.List(list.map(types, type_ref))
310
+
}
311
+
_ -> value.Null
312
+
}
313
+
314
+
// Handle wrapping types (LIST/NON_NULL) differently
315
+
let name = case kind {
316
+
"LIST" -> value.Null
317
+
"NON_NULL" -> value.Null
318
+
_ -> value.String(type_name)
319
+
}
320
+
321
+
let description = case schema.type_description(t) {
322
+
"" -> value.Null
323
+
desc -> value.String(desc)
324
+
}
325
+
326
+
value.Object([
327
+
#("kind", value.String(kind)),
328
+
#("name", name),
329
+
#("description", description),
330
+
#("fields", fields),
331
+
#("interfaces", value.List([])),
332
+
#("possibleTypes", possible_types),
333
+
#("enumValues", enum_values),
334
+
#("inputFields", input_fields),
335
+
#("ofType", of_type),
336
+
])
337
+
}
338
+
339
+
/// Get fields for a type (if it's an object type)
340
+
fn get_fields_for_type(t: schema.Type) -> List(value.Value) {
341
+
let fields = schema.get_fields(t)
342
+
343
+
list.map(fields, fn(field) {
344
+
let field_type_val = schema.field_type(field)
345
+
let args = schema.field_arguments(field)
346
+
347
+
value.Object([
348
+
#("name", value.String(schema.field_name(field))),
349
+
#("description", value.String(schema.field_description(field))),
350
+
#("args", value.List(list.map(args, argument_introspection))),
351
+
#("type", type_ref(field_type_val)),
352
+
#("isDeprecated", value.Boolean(False)),
353
+
#("deprecationReason", value.Null),
354
+
])
355
+
})
356
+
}
357
+
358
+
/// Get input fields for a type (if it's an input object type)
359
+
fn get_input_fields_for_type(t: schema.Type) -> List(value.Value) {
360
+
let input_fields = schema.get_input_fields(t)
361
+
362
+
list.map(input_fields, fn(input_field) {
363
+
let field_type_val = schema.input_field_type(input_field)
364
+
365
+
value.Object([
366
+
#("name", value.String(schema.input_field_name(input_field))),
367
+
#(
368
+
"description",
369
+
value.String(schema.input_field_description(input_field)),
370
+
),
371
+
#("type", type_ref(field_type_val)),
372
+
#("defaultValue", value.Null),
373
+
])
374
+
})
375
+
}
376
+
377
+
/// Get enum values for a type (if it's an enum type)
378
+
fn get_enum_values_for_type(t: schema.Type) -> List(value.Value) {
379
+
let enum_values = schema.get_enum_values(t)
380
+
381
+
list.map(enum_values, fn(enum_value) {
382
+
value.Object([
383
+
#("name", value.String(schema.enum_value_name(enum_value))),
384
+
#("description", value.String(schema.enum_value_description(enum_value))),
385
+
#("isDeprecated", value.Boolean(False)),
386
+
#("deprecationReason", value.Null),
387
+
])
388
+
})
389
+
}
390
+
391
+
/// Build introspection for an argument
392
+
fn argument_introspection(arg: schema.Argument) -> value.Value {
393
+
value.Object([
394
+
#("name", value.String(schema.argument_name(arg))),
395
+
#("description", value.String(schema.argument_description(arg))),
396
+
#("type", type_ref(schema.argument_type(arg))),
397
+
#("defaultValue", value.Null),
398
+
])
399
+
}
400
+
401
+
/// Build a type reference (simplified version of type_introspection for field types)
402
+
fn type_ref(t: schema.Type) -> value.Value {
403
+
let kind = schema.type_kind(t)
404
+
let type_name = schema.type_name(t)
405
+
406
+
// Get inner type for LIST and NON_NULL
407
+
let of_type = case schema.inner_type(t) {
408
+
option.Some(inner) -> type_ref(inner)
409
+
option.None -> value.Null
410
+
}
411
+
412
+
// Handle wrapping types (LIST/NON_NULL) differently
413
+
let name = case kind {
414
+
"LIST" -> value.Null
415
+
"NON_NULL" -> value.Null
416
+
_ -> value.String(type_name)
417
+
}
418
+
419
+
value.Object([
420
+
#("kind", value.String(kind)),
421
+
#("name", name),
422
+
#("ofType", of_type),
423
+
])
424
+
}
+301
src/swell/lexer.gleam
+301
src/swell/lexer.gleam
···
1
+
/// GraphQL Lexer - Tokenization
2
+
///
3
+
/// Per GraphQL spec Section 2 - Language
4
+
/// Converts source text into a sequence of lexical tokens
5
+
import gleam/list
6
+
import gleam/result
7
+
import gleam/string
8
+
9
+
/// GraphQL token types
10
+
pub type Token {
11
+
// Punctuators
12
+
BraceOpen
13
+
BraceClose
14
+
ParenOpen
15
+
ParenClose
16
+
BracketOpen
17
+
BracketClose
18
+
Colon
19
+
Comma
20
+
Pipe
21
+
Equals
22
+
At
23
+
Dollar
24
+
Exclamation
25
+
Spread
26
+
27
+
// Values
28
+
Name(String)
29
+
Int(String)
30
+
Float(String)
31
+
String(String)
32
+
33
+
// Ignored tokens (kept for optional whitespace preservation)
34
+
Whitespace
35
+
Comment(String)
36
+
}
37
+
38
+
pub type LexerError {
39
+
UnexpectedCharacter(String, Int)
40
+
UnterminatedString(Int)
41
+
InvalidNumber(String, Int)
42
+
}
43
+
44
+
/// Tokenize a GraphQL source string into a list of tokens
45
+
///
46
+
/// Filters out whitespace and comments by default
47
+
pub fn tokenize(source: String) -> Result(List(Token), LexerError) {
48
+
source
49
+
|> string.to_graphemes
50
+
|> tokenize_graphemes([], 0)
51
+
|> result.map(filter_ignored)
52
+
}
53
+
54
+
/// Internal: Tokenize graphemes recursively
55
+
fn tokenize_graphemes(
56
+
graphemes: List(String),
57
+
acc: List(Token),
58
+
pos: Int,
59
+
) -> Result(List(Token), LexerError) {
60
+
case graphemes {
61
+
[] -> Ok(list.reverse(acc))
62
+
63
+
// Whitespace
64
+
[" ", ..rest] | ["\t", ..rest] | ["\n", ..rest] | ["\r", ..rest] ->
65
+
tokenize_graphemes(rest, [Whitespace, ..acc], pos + 1)
66
+
67
+
// Comments
68
+
["#", ..rest] -> {
69
+
let #(comment, remaining) = take_until_newline(rest)
70
+
tokenize_graphemes(remaining, [Comment(comment), ..acc], pos + 1)
71
+
}
72
+
73
+
// Punctuators
74
+
["{", ..rest] -> tokenize_graphemes(rest, [BraceOpen, ..acc], pos + 1)
75
+
["}", ..rest] -> tokenize_graphemes(rest, [BraceClose, ..acc], pos + 1)
76
+
["(", ..rest] -> tokenize_graphemes(rest, [ParenOpen, ..acc], pos + 1)
77
+
[")", ..rest] -> tokenize_graphemes(rest, [ParenClose, ..acc], pos + 1)
78
+
["[", ..rest] -> tokenize_graphemes(rest, [BracketOpen, ..acc], pos + 1)
79
+
["]", ..rest] -> tokenize_graphemes(rest, [BracketClose, ..acc], pos + 1)
80
+
[":", ..rest] -> tokenize_graphemes(rest, [Colon, ..acc], pos + 1)
81
+
[",", ..rest] -> tokenize_graphemes(rest, [Comma, ..acc], pos + 1)
82
+
["|", ..rest] -> tokenize_graphemes(rest, [Pipe, ..acc], pos + 1)
83
+
["=", ..rest] -> tokenize_graphemes(rest, [Equals, ..acc], pos + 1)
84
+
["@", ..rest] -> tokenize_graphemes(rest, [At, ..acc], pos + 1)
85
+
["$", ..rest] -> tokenize_graphemes(rest, [Dollar, ..acc], pos + 1)
86
+
["!", ..rest] -> tokenize_graphemes(rest, [Exclamation, ..acc], pos + 1)
87
+
88
+
// Spread (...)
89
+
[".", ".", ".", ..rest] ->
90
+
tokenize_graphemes(rest, [Spread, ..acc], pos + 3)
91
+
92
+
// Strings
93
+
["\"", ..rest] -> {
94
+
case take_string(rest, []) {
95
+
Ok(#(str, remaining)) ->
96
+
tokenize_graphemes(remaining, [String(str), ..acc], pos + 1)
97
+
Error(err) -> Error(err)
98
+
}
99
+
}
100
+
101
+
// Numbers (Int or Float) - check for minus or digits
102
+
["-", ..]
103
+
| ["0", ..]
104
+
| ["1", ..]
105
+
| ["2", ..]
106
+
| ["3", ..]
107
+
| ["4", ..]
108
+
| ["5", ..]
109
+
| ["6", ..]
110
+
| ["7", ..]
111
+
| ["8", ..]
112
+
| ["9", ..] -> {
113
+
case take_number(graphemes) {
114
+
Ok(#(num_str, is_float, remaining)) -> {
115
+
let token = case is_float {
116
+
True -> Float(num_str)
117
+
False -> Int(num_str)
118
+
}
119
+
tokenize_graphemes(remaining, [token, ..acc], pos + 1)
120
+
}
121
+
Error(err) -> Error(err)
122
+
}
123
+
}
124
+
125
+
// Names (identifiers) - must start with letter or underscore
126
+
[char, ..] -> {
127
+
case is_name_start(char) {
128
+
True -> {
129
+
let #(name, remaining) = take_name(graphemes)
130
+
tokenize_graphemes(remaining, [Name(name), ..acc], pos + 1)
131
+
}
132
+
False -> Error(UnexpectedCharacter(char, pos))
133
+
}
134
+
}
135
+
}
136
+
}
137
+
138
+
/// Take characters until newline
139
+
fn take_until_newline(graphemes: List(String)) -> #(String, List(String)) {
140
+
let #(chars, rest) = take_while(graphemes, fn(c) { c != "\n" && c != "\r" })
141
+
#(string.concat(chars), rest)
142
+
}
143
+
144
+
/// Take string contents (handles escapes)
145
+
fn take_string(
146
+
graphemes: List(String),
147
+
acc: List(String),
148
+
) -> Result(#(String, List(String)), LexerError) {
149
+
case graphemes {
150
+
[] -> Error(UnterminatedString(0))
151
+
152
+
["\"", ..rest] -> Ok(#(string.concat(list.reverse(acc)), rest))
153
+
154
+
["\\", "n", ..rest] -> take_string(rest, ["\n", ..acc])
155
+
["\\", "r", ..rest] -> take_string(rest, ["\r", ..acc])
156
+
["\\", "t", ..rest] -> take_string(rest, ["\t", ..acc])
157
+
["\\", "\"", ..rest] -> take_string(rest, ["\"", ..acc])
158
+
["\\", "\\", ..rest] -> take_string(rest, ["\\", ..acc])
159
+
160
+
[char, ..rest] -> take_string(rest, [char, ..acc])
161
+
}
162
+
}
163
+
164
+
/// Take a number (int or float)
165
+
fn take_number(
166
+
graphemes: List(String),
167
+
) -> Result(#(String, Bool, List(String)), LexerError) {
168
+
let #(num_chars, rest) = take_while(graphemes, is_number_char)
169
+
let num_str = string.concat(num_chars)
170
+
171
+
let is_float =
172
+
string.contains(num_str, ".")
173
+
|| string.contains(num_str, "e")
174
+
|| string.contains(num_str, "E")
175
+
176
+
Ok(#(num_str, is_float, rest))
177
+
}
178
+
179
+
/// Take a name (identifier)
180
+
fn take_name(graphemes: List(String)) -> #(String, List(String)) {
181
+
let #(name_chars, rest) = take_while(graphemes, is_name_char)
182
+
#(string.concat(name_chars), rest)
183
+
}
184
+
185
+
/// Take characters while predicate is true
186
+
fn take_while(
187
+
graphemes: List(String),
188
+
predicate: fn(String) -> Bool,
189
+
) -> #(List(String), List(String)) {
190
+
do_take_while(graphemes, predicate, [])
191
+
}
192
+
193
+
fn do_take_while(
194
+
graphemes: List(String),
195
+
predicate: fn(String) -> Bool,
196
+
acc: List(String),
197
+
) -> #(List(String), List(String)) {
198
+
case graphemes {
199
+
[char, ..rest] -> {
200
+
case predicate(char) {
201
+
True -> do_take_while(rest, predicate, [char, ..acc])
202
+
False -> #(list.reverse(acc), graphemes)
203
+
}
204
+
}
205
+
_ -> #(list.reverse(acc), graphemes)
206
+
}
207
+
}
208
+
209
+
/// Check if character can start a name
210
+
fn is_name_start(char: String) -> Bool {
211
+
case char {
212
+
"a"
213
+
| "b"
214
+
| "c"
215
+
| "d"
216
+
| "e"
217
+
| "f"
218
+
| "g"
219
+
| "h"
220
+
| "i"
221
+
| "j"
222
+
| "k"
223
+
| "l"
224
+
| "m"
225
+
| "n"
226
+
| "o"
227
+
| "p"
228
+
| "q"
229
+
| "r"
230
+
| "s"
231
+
| "t"
232
+
| "u"
233
+
| "v"
234
+
| "w"
235
+
| "x"
236
+
| "y"
237
+
| "z" -> True
238
+
"A"
239
+
| "B"
240
+
| "C"
241
+
| "D"
242
+
| "E"
243
+
| "F"
244
+
| "G"
245
+
| "H"
246
+
| "I"
247
+
| "J"
248
+
| "K"
249
+
| "L"
250
+
| "M"
251
+
| "N"
252
+
| "O"
253
+
| "P"
254
+
| "Q"
255
+
| "R"
256
+
| "S"
257
+
| "T"
258
+
| "U"
259
+
| "V"
260
+
| "W"
261
+
| "X"
262
+
| "Y"
263
+
| "Z" -> True
264
+
"_" -> True
265
+
_ -> False
266
+
}
267
+
}
268
+
269
+
/// Check if character can be part of a name
270
+
fn is_name_char(char: String) -> Bool {
271
+
is_name_start(char) || is_digit(char)
272
+
}
273
+
274
+
/// Check if character is a digit
275
+
fn is_digit(char: String) -> Bool {
276
+
case char {
277
+
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
278
+
_ -> False
279
+
}
280
+
}
281
+
282
+
/// Check if character can be part of a number
283
+
fn is_number_char(char: String) -> Bool {
284
+
is_digit(char)
285
+
|| char == "."
286
+
|| char == "e"
287
+
|| char == "E"
288
+
|| char == "-"
289
+
|| char == "+"
290
+
}
291
+
292
+
/// Filter out ignored tokens (whitespace and comments)
293
+
fn filter_ignored(tokens: List(Token)) -> List(Token) {
294
+
list.filter(tokens, fn(token) {
295
+
case token {
296
+
Whitespace -> False
297
+
Comment(_) -> False
298
+
_ -> True
299
+
}
300
+
})
301
+
}
+568
src/swell/parser.gleam
+568
src/swell/parser.gleam
···
1
+
/// GraphQL Parser - Build AST from tokens
2
+
///
3
+
/// Per GraphQL spec Section 2 - Language
4
+
/// Converts a token stream into an Abstract Syntax Tree
5
+
import gleam/list
6
+
import gleam/option.{type Option, None, Some}
7
+
import gleam/result
8
+
import swell/lexer
9
+
10
+
/// GraphQL Document (top-level)
11
+
pub type Document {
12
+
Document(operations: List(Operation))
13
+
}
14
+
15
+
/// GraphQL Operation
16
+
pub type Operation {
17
+
Query(SelectionSet)
18
+
NamedQuery(name: String, variables: List(Variable), selections: SelectionSet)
19
+
Mutation(SelectionSet)
20
+
NamedMutation(
21
+
name: String,
22
+
variables: List(Variable),
23
+
selections: SelectionSet,
24
+
)
25
+
Subscription(SelectionSet)
26
+
NamedSubscription(
27
+
name: String,
28
+
variables: List(Variable),
29
+
selections: SelectionSet,
30
+
)
31
+
FragmentDefinition(
32
+
name: String,
33
+
type_condition: String,
34
+
selections: SelectionSet,
35
+
)
36
+
}
37
+
38
+
/// Selection Set (list of fields)
39
+
pub type SelectionSet {
40
+
SelectionSet(selections: List(Selection))
41
+
}
42
+
43
+
/// Selection (field or fragment)
44
+
pub type Selection {
45
+
Field(
46
+
name: String,
47
+
alias: Option(String),
48
+
arguments: List(Argument),
49
+
selections: List(Selection),
50
+
)
51
+
FragmentSpread(name: String)
52
+
InlineFragment(type_condition: Option(String), selections: List(Selection))
53
+
}
54
+
55
+
/// Argument (name: value)
56
+
pub type Argument {
57
+
Argument(name: String, value: ArgumentValue)
58
+
}
59
+
60
+
/// Argument value types
61
+
pub type ArgumentValue {
62
+
IntValue(String)
63
+
FloatValue(String)
64
+
StringValue(String)
65
+
BooleanValue(Bool)
66
+
NullValue
67
+
EnumValue(String)
68
+
ListValue(List(ArgumentValue))
69
+
ObjectValue(List(#(String, ArgumentValue)))
70
+
VariableValue(String)
71
+
}
72
+
73
+
/// Variable definition
74
+
pub type Variable {
75
+
Variable(name: String, type_: String)
76
+
}
77
+
78
+
pub type ParseError {
79
+
UnexpectedToken(lexer.Token, String)
80
+
UnexpectedEndOfInput(String)
81
+
LexerError(lexer.LexerError)
82
+
}
83
+
84
+
/// Parse a GraphQL query string into a Document
85
+
pub fn parse(source: String) -> Result(Document, ParseError) {
86
+
source
87
+
|> lexer.tokenize
88
+
|> result.map_error(LexerError)
89
+
|> result.try(parse_document)
90
+
}
91
+
92
+
/// Parse tokens into a Document
93
+
fn parse_document(tokens: List(lexer.Token)) -> Result(Document, ParseError) {
94
+
case tokens {
95
+
[] -> Error(UnexpectedEndOfInput("Expected query or operation"))
96
+
_ -> {
97
+
case parse_operations(tokens, []) {
98
+
Ok(#(operations, _remaining)) -> Ok(Document(operations))
99
+
Error(err) -> Error(err)
100
+
}
101
+
}
102
+
}
103
+
}
104
+
105
+
/// Parse operations (queries/mutations)
106
+
fn parse_operations(
107
+
tokens: List(lexer.Token),
108
+
acc: List(Operation),
109
+
) -> Result(#(List(Operation), List(lexer.Token)), ParseError) {
110
+
case tokens {
111
+
[] -> Ok(#(list.reverse(acc), []))
112
+
113
+
// Named query: "query Name(...) { ... }" or "query Name { ... }"
114
+
[lexer.Name("query"), lexer.Name(name), ..rest] -> {
115
+
// Check if there are variable definitions
116
+
case rest {
117
+
[lexer.ParenOpen, ..vars_rest] -> {
118
+
// Parse variable definitions
119
+
case parse_variable_definitions(vars_rest) {
120
+
Ok(#(variables, after_vars)) -> {
121
+
case parse_selection_set(after_vars) {
122
+
Ok(#(selections, remaining)) -> {
123
+
let op = NamedQuery(name, variables, selections)
124
+
parse_operations(remaining, [op, ..acc])
125
+
}
126
+
Error(err) -> Error(err)
127
+
}
128
+
}
129
+
Error(err) -> Error(err)
130
+
}
131
+
}
132
+
_ -> {
133
+
// No variables, parse selection set directly
134
+
case parse_selection_set(rest) {
135
+
Ok(#(selections, remaining)) -> {
136
+
let op = NamedQuery(name, [], selections)
137
+
parse_operations(remaining, [op, ..acc])
138
+
}
139
+
Error(err) -> Error(err)
140
+
}
141
+
}
142
+
}
143
+
}
144
+
145
+
// Named mutation: "mutation Name(...) { ... }" or "mutation Name { ... }"
146
+
[lexer.Name("mutation"), lexer.Name(name), ..rest] -> {
147
+
// Check if there are variable definitions
148
+
case rest {
149
+
[lexer.ParenOpen, ..vars_rest] -> {
150
+
// Parse variable definitions
151
+
case parse_variable_definitions(vars_rest) {
152
+
Ok(#(variables, after_vars)) -> {
153
+
case parse_selection_set(after_vars) {
154
+
Ok(#(selections, remaining)) -> {
155
+
let op = NamedMutation(name, variables, selections)
156
+
parse_operations(remaining, [op, ..acc])
157
+
}
158
+
Error(err) -> Error(err)
159
+
}
160
+
}
161
+
Error(err) -> Error(err)
162
+
}
163
+
}
164
+
_ -> {
165
+
// No variables, parse selection set directly
166
+
case parse_selection_set(rest) {
167
+
Ok(#(selections, remaining)) -> {
168
+
let op = NamedMutation(name, [], selections)
169
+
parse_operations(remaining, [op, ..acc])
170
+
}
171
+
Error(err) -> Error(err)
172
+
}
173
+
}
174
+
}
175
+
}
176
+
177
+
// Named subscription: "subscription Name(...) { ... }" or "subscription Name { ... }"
178
+
[lexer.Name("subscription"), lexer.Name(name), ..rest] -> {
179
+
// Check if there are variable definitions
180
+
case rest {
181
+
[lexer.ParenOpen, ..vars_rest] -> {
182
+
// Parse variable definitions
183
+
case parse_variable_definitions(vars_rest) {
184
+
Ok(#(variables, after_vars)) -> {
185
+
case parse_selection_set(after_vars) {
186
+
Ok(#(selections, remaining)) -> {
187
+
let op = NamedSubscription(name, variables, selections)
188
+
parse_operations(remaining, [op, ..acc])
189
+
}
190
+
Error(err) -> Error(err)
191
+
}
192
+
}
193
+
Error(err) -> Error(err)
194
+
}
195
+
}
196
+
_ -> {
197
+
// No variables, parse selection set directly
198
+
case parse_selection_set(rest) {
199
+
Ok(#(selections, remaining)) -> {
200
+
let op = NamedSubscription(name, [], selections)
201
+
parse_operations(remaining, [op, ..acc])
202
+
}
203
+
Error(err) -> Error(err)
204
+
}
205
+
}
206
+
}
207
+
}
208
+
209
+
// Anonymous query: "query { ... }"
210
+
[lexer.Name("query"), lexer.BraceOpen, ..] -> {
211
+
case parse_selection_set(list.drop(tokens, 1)) {
212
+
Ok(#(selections, remaining)) -> {
213
+
let op = Query(selections)
214
+
parse_operations(remaining, [op, ..acc])
215
+
}
216
+
Error(err) -> Error(err)
217
+
}
218
+
}
219
+
220
+
// Anonymous mutation: "mutation { ... }"
221
+
[lexer.Name("mutation"), lexer.BraceOpen, ..] -> {
222
+
case parse_selection_set(list.drop(tokens, 1)) {
223
+
Ok(#(selections, remaining)) -> {
224
+
let op = Mutation(selections)
225
+
parse_operations(remaining, [op, ..acc])
226
+
}
227
+
Error(err) -> Error(err)
228
+
}
229
+
}
230
+
231
+
// Anonymous subscription: "subscription { ... }"
232
+
[lexer.Name("subscription"), lexer.BraceOpen, ..] -> {
233
+
case parse_selection_set(list.drop(tokens, 1)) {
234
+
Ok(#(selections, remaining)) -> {
235
+
let op = Subscription(selections)
236
+
parse_operations(remaining, [op, ..acc])
237
+
}
238
+
Error(err) -> Error(err)
239
+
}
240
+
}
241
+
242
+
// Fragment definition: "fragment Name on Type { ... }"
243
+
[
244
+
lexer.Name("fragment"),
245
+
lexer.Name(name),
246
+
lexer.Name("on"),
247
+
lexer.Name(type_condition),
248
+
..rest
249
+
] -> {
250
+
case parse_selection_set(rest) {
251
+
Ok(#(selections, remaining)) -> {
252
+
let op = FragmentDefinition(name, type_condition, selections)
253
+
parse_operations(remaining, [op, ..acc])
254
+
}
255
+
Error(err) -> Error(err)
256
+
}
257
+
}
258
+
259
+
// Anonymous query: "{ ... }"
260
+
[lexer.BraceOpen, ..] -> {
261
+
case parse_selection_set(tokens) {
262
+
Ok(#(selections, remaining)) -> {
263
+
let op = Query(selections)
264
+
// Continue parsing to see if there are more operations (e.g., fragment definitions)
265
+
parse_operations(remaining, [op, ..acc])
266
+
}
267
+
Error(err) -> Error(err)
268
+
}
269
+
}
270
+
271
+
// Any other token when we have operations means we're done
272
+
_ -> {
273
+
case acc {
274
+
[] ->
275
+
Error(UnexpectedToken(
276
+
list.first(tokens) |> result.unwrap(lexer.BraceClose),
277
+
"Expected query, mutation, subscription, fragment, or '{'",
278
+
))
279
+
_ -> Ok(#(list.reverse(acc), tokens))
280
+
}
281
+
}
282
+
}
283
+
}
284
+
285
+
/// Parse selection set: { field1 field2 ... }
286
+
fn parse_selection_set(
287
+
tokens: List(lexer.Token),
288
+
) -> Result(#(SelectionSet, List(lexer.Token)), ParseError) {
289
+
case tokens {
290
+
[lexer.BraceOpen, ..rest] -> {
291
+
case parse_selections(rest, []) {
292
+
Ok(#(selections, [lexer.BraceClose, ..remaining])) ->
293
+
Ok(#(SelectionSet(selections), remaining))
294
+
Ok(#(_, _remaining)) ->
295
+
Error(UnexpectedEndOfInput("Expected '}' to close selection set"))
296
+
Error(err) -> Error(err)
297
+
}
298
+
}
299
+
[token, ..] -> Error(UnexpectedToken(token, "Expected '{'"))
300
+
[] -> Error(UnexpectedEndOfInput("Expected '{'"))
301
+
}
302
+
}
303
+
304
+
/// Parse selections (fields)
305
+
fn parse_selections(
306
+
tokens: List(lexer.Token),
307
+
acc: List(Selection),
308
+
) -> Result(#(List(Selection), List(lexer.Token)), ParseError) {
309
+
case tokens {
310
+
// End of selection set
311
+
[lexer.BraceClose, ..] -> Ok(#(list.reverse(acc), tokens))
312
+
313
+
// Inline fragment: "... on Type { ... }" - Check this BEFORE fragment spread
314
+
[lexer.Spread, lexer.Name("on"), lexer.Name(type_condition), ..rest] -> {
315
+
case parse_selection_set(rest) {
316
+
Ok(#(SelectionSet(selections), remaining)) -> {
317
+
let inline = InlineFragment(Some(type_condition), selections)
318
+
parse_selections(remaining, [inline, ..acc])
319
+
}
320
+
Error(err) -> Error(err)
321
+
}
322
+
}
323
+
324
+
// Fragment spread: "...FragmentName"
325
+
[lexer.Spread, lexer.Name(name), ..rest] -> {
326
+
let spread = FragmentSpread(name)
327
+
parse_selections(rest, [spread, ..acc])
328
+
}
329
+
330
+
// Field with alias: "alias: fieldName"
331
+
[lexer.Name(alias), lexer.Colon, lexer.Name(field_name), ..rest] -> {
332
+
case parse_field_with_alias(field_name, Some(alias), rest) {
333
+
Ok(#(field, remaining)) -> {
334
+
parse_selections(remaining, [field, ..acc])
335
+
}
336
+
Error(err) -> Error(err)
337
+
}
338
+
}
339
+
340
+
// Field without alias
341
+
[lexer.Name(name), ..rest] -> {
342
+
case parse_field_with_alias(name, None, rest) {
343
+
Ok(#(field, remaining)) -> {
344
+
parse_selections(remaining, [field, ..acc])
345
+
}
346
+
Error(err) -> Error(err)
347
+
}
348
+
}
349
+
350
+
[] -> Error(UnexpectedEndOfInput("Expected field or '}'"))
351
+
[token, ..] ->
352
+
Error(UnexpectedToken(token, "Expected field name or fragment"))
353
+
}
354
+
}
355
+
356
+
/// Parse a field with optional alias, arguments and nested selections
357
+
fn parse_field_with_alias(
358
+
name: String,
359
+
alias: Option(String),
360
+
tokens: List(lexer.Token),
361
+
) -> Result(#(Selection, List(lexer.Token)), ParseError) {
362
+
// Parse arguments if present
363
+
let #(arguments, after_args) = case tokens {
364
+
[lexer.ParenOpen, ..] -> {
365
+
case parse_arguments(tokens) {
366
+
Ok(result) -> result
367
+
Error(_err) -> #([], tokens)
368
+
// No arguments
369
+
}
370
+
}
371
+
_ -> #([], tokens)
372
+
}
373
+
374
+
// Parse nested selection set if present
375
+
case after_args {
376
+
[lexer.BraceOpen, ..] -> {
377
+
case parse_nested_selections(after_args) {
378
+
Ok(#(nested, remaining)) ->
379
+
Ok(#(Field(name, alias, arguments, nested), remaining))
380
+
Error(err) -> Error(err)
381
+
}
382
+
}
383
+
_ -> Ok(#(Field(name, alias, arguments, []), after_args))
384
+
}
385
+
}
386
+
387
+
/// Parse nested selections for a field
388
+
fn parse_nested_selections(
389
+
tokens: List(lexer.Token),
390
+
) -> Result(#(List(Selection), List(lexer.Token)), ParseError) {
391
+
case tokens {
392
+
[lexer.BraceOpen, ..rest] -> {
393
+
case parse_selections(rest, []) {
394
+
Ok(#(selections, [lexer.BraceClose, ..remaining])) ->
395
+
Ok(#(selections, remaining))
396
+
Ok(#(_, _remaining)) ->
397
+
Error(UnexpectedEndOfInput(
398
+
"Expected '}' to close nested selection set",
399
+
))
400
+
Error(err) -> Error(err)
401
+
}
402
+
}
403
+
_ -> Ok(#([], tokens))
404
+
}
405
+
}
406
+
407
+
/// Parse arguments: (arg1: value1, arg2: value2)
408
+
fn parse_arguments(
409
+
tokens: List(lexer.Token),
410
+
) -> Result(#(List(Argument), List(lexer.Token)), ParseError) {
411
+
case tokens {
412
+
[lexer.ParenOpen, ..rest] -> {
413
+
case parse_argument_list(rest, []) {
414
+
Ok(#(args, [lexer.ParenClose, ..remaining])) -> Ok(#(args, remaining))
415
+
Ok(#(_, _remaining)) ->
416
+
Error(UnexpectedEndOfInput("Expected ')' to close arguments"))
417
+
Error(err) -> Error(err)
418
+
}
419
+
}
420
+
_ -> Ok(#([], tokens))
421
+
}
422
+
}
423
+
424
+
/// Parse list of arguments
425
+
fn parse_argument_list(
426
+
tokens: List(lexer.Token),
427
+
acc: List(Argument),
428
+
) -> Result(#(List(Argument), List(lexer.Token)), ParseError) {
429
+
case tokens {
430
+
// End of arguments
431
+
[lexer.ParenClose, ..] -> Ok(#(list.reverse(acc), tokens))
432
+
433
+
// Argument: name: value
434
+
[lexer.Name(name), lexer.Colon, ..rest] -> {
435
+
case parse_argument_value(rest) {
436
+
Ok(#(value, remaining)) -> {
437
+
let arg = Argument(name, value)
438
+
// Skip optional comma
439
+
let after_comma = case remaining {
440
+
[lexer.Comma, ..r] -> r
441
+
_ -> remaining
442
+
}
443
+
parse_argument_list(after_comma, [arg, ..acc])
444
+
}
445
+
Error(err) -> Error(err)
446
+
}
447
+
}
448
+
449
+
[] -> Error(UnexpectedEndOfInput("Expected argument or ')'"))
450
+
[token, ..] -> Error(UnexpectedToken(token, "Expected argument name"))
451
+
}
452
+
}
453
+
454
+
/// Parse argument value
455
+
fn parse_argument_value(
456
+
tokens: List(lexer.Token),
457
+
) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) {
458
+
case tokens {
459
+
[lexer.Int(val), ..rest] -> Ok(#(IntValue(val), rest))
460
+
[lexer.Float(val), ..rest] -> Ok(#(FloatValue(val), rest))
461
+
[lexer.String(val), ..rest] -> Ok(#(StringValue(val), rest))
462
+
[lexer.Name("true"), ..rest] -> Ok(#(BooleanValue(True), rest))
463
+
[lexer.Name("false"), ..rest] -> Ok(#(BooleanValue(False), rest))
464
+
[lexer.Name("null"), ..rest] -> Ok(#(NullValue, rest))
465
+
[lexer.Name(name), ..rest] -> Ok(#(EnumValue(name), rest))
466
+
[lexer.Dollar, lexer.Name(name), ..rest] -> Ok(#(VariableValue(name), rest))
467
+
[lexer.BracketOpen, ..rest] -> parse_list_value(rest)
468
+
[lexer.BraceOpen, ..rest] -> parse_object_value(rest)
469
+
[] -> Error(UnexpectedEndOfInput("Expected value"))
470
+
[token, ..] -> Error(UnexpectedToken(token, "Expected value"))
471
+
}
472
+
}
473
+
474
+
/// Parse list value: [value, value, ...]
475
+
fn parse_list_value(
476
+
tokens: List(lexer.Token),
477
+
) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) {
478
+
case tokens {
479
+
[lexer.BracketClose, ..rest] -> Ok(#(ListValue([]), rest))
480
+
_ -> parse_list_value_items(tokens, [])
481
+
}
482
+
}
483
+
484
+
/// Parse list value items recursively
485
+
fn parse_list_value_items(
486
+
tokens: List(lexer.Token),
487
+
acc: List(ArgumentValue),
488
+
) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) {
489
+
case tokens {
490
+
[lexer.BracketClose, ..rest] -> Ok(#(ListValue(list.reverse(acc)), rest))
491
+
[lexer.Comma, ..rest] -> parse_list_value_items(rest, acc)
492
+
_ -> {
493
+
use #(value, rest) <- result.try(parse_argument_value(tokens))
494
+
parse_list_value_items(rest, [value, ..acc])
495
+
}
496
+
}
497
+
}
498
+
499
+
/// Parse object value: {field: value, field: value, ...}
500
+
fn parse_object_value(
501
+
tokens: List(lexer.Token),
502
+
) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) {
503
+
case tokens {
504
+
[lexer.BraceClose, ..rest] -> Ok(#(ObjectValue([]), rest))
505
+
_ -> parse_object_value_fields(tokens, [])
506
+
}
507
+
}
508
+
509
+
/// Parse object value fields recursively
510
+
fn parse_object_value_fields(
511
+
tokens: List(lexer.Token),
512
+
acc: List(#(String, ArgumentValue)),
513
+
) -> Result(#(ArgumentValue, List(lexer.Token)), ParseError) {
514
+
case tokens {
515
+
[lexer.BraceClose, ..rest] -> Ok(#(ObjectValue(list.reverse(acc)), rest))
516
+
[lexer.Comma, ..rest] -> parse_object_value_fields(rest, acc)
517
+
[lexer.Name(field_name), lexer.Colon, ..rest] -> {
518
+
use #(value, rest2) <- result.try(parse_argument_value(rest))
519
+
parse_object_value_fields(rest2, [#(field_name, value), ..acc])
520
+
}
521
+
[] -> Error(UnexpectedEndOfInput("Expected field name or }"))
522
+
[token, ..] -> Error(UnexpectedToken(token, "Expected field name or }"))
523
+
}
524
+
}
525
+
526
+
/// Parse variable definitions: ($var1: Type!, $var2: Type)
527
+
/// Returns the list of variables and remaining tokens after the closing paren
528
+
fn parse_variable_definitions(
529
+
tokens: List(lexer.Token),
530
+
) -> Result(#(List(Variable), List(lexer.Token)), ParseError) {
531
+
parse_variable_definitions_loop(tokens, [])
532
+
}
533
+
534
+
/// Parse variable definitions loop
535
+
fn parse_variable_definitions_loop(
536
+
tokens: List(lexer.Token),
537
+
acc: List(Variable),
538
+
) -> Result(#(List(Variable), List(lexer.Token)), ParseError) {
539
+
case tokens {
540
+
// End of variable definitions
541
+
[lexer.ParenClose, ..rest] -> Ok(#(list.reverse(acc), rest))
542
+
543
+
// Skip commas
544
+
[lexer.Comma, ..rest] -> parse_variable_definitions_loop(rest, acc)
545
+
546
+
// Parse a variable: $name: Type! or $name: Type
547
+
[lexer.Dollar, lexer.Name(var_name), lexer.Colon, ..rest] -> {
548
+
// Parse the type (Name or Name!)
549
+
case rest {
550
+
[lexer.Name(type_name), lexer.Exclamation, ..rest2] -> {
551
+
// Non-null type
552
+
let variable = Variable(var_name, type_name <> "!")
553
+
parse_variable_definitions_loop(rest2, [variable, ..acc])
554
+
}
555
+
[lexer.Name(type_name), ..rest2] -> {
556
+
// Nullable type
557
+
let variable = Variable(var_name, type_name)
558
+
parse_variable_definitions_loop(rest2, [variable, ..acc])
559
+
}
560
+
[] -> Error(UnexpectedEndOfInput("Expected type after :"))
561
+
[token, ..] -> Error(UnexpectedToken(token, "Expected type name"))
562
+
}
563
+
}
564
+
565
+
[] -> Error(UnexpectedEndOfInput("Expected variable definition or )"))
566
+
[token, ..] -> Error(UnexpectedToken(token, "Expected $variableName or )"))
567
+
}
568
+
}
+508
src/swell/schema.gleam
+508
src/swell/schema.gleam
···
1
+
/// GraphQL Schema - Type System
2
+
///
3
+
/// Per GraphQL spec Section 3 - Type System
4
+
/// Defines the type system including scalars, objects, enums, etc.
5
+
import gleam/dict.{type Dict}
6
+
import gleam/list
7
+
import gleam/option.{type Option, None}
8
+
import swell/value
9
+
10
+
/// Resolver context - will contain request context, data loaders, etc.
11
+
pub type Context {
12
+
Context(
13
+
data: Option(value.Value),
14
+
arguments: Dict(String, value.Value),
15
+
variables: Dict(String, value.Value),
16
+
)
17
+
}
18
+
19
+
/// Helper to create a context without arguments or variables
20
+
pub fn context(data: Option(value.Value)) -> Context {
21
+
Context(data, dict.new(), dict.new())
22
+
}
23
+
24
+
/// Helper to create a context with variables
25
+
pub fn context_with_variables(
26
+
data: Option(value.Value),
27
+
variables: Dict(String, value.Value),
28
+
) -> Context {
29
+
Context(data, dict.new(), variables)
30
+
}
31
+
32
+
/// Helper to get an argument value from context
33
+
pub fn get_argument(ctx: Context, name: String) -> Option(value.Value) {
34
+
dict.get(ctx.arguments, name) |> option.from_result
35
+
}
36
+
37
+
/// Helper to get a variable value from context
38
+
pub fn get_variable(ctx: Context, name: String) -> Option(value.Value) {
39
+
dict.get(ctx.variables, name) |> option.from_result
40
+
}
41
+
42
+
/// Field resolver function type
43
+
pub type Resolver =
44
+
fn(Context) -> Result(value.Value, String)
45
+
46
+
/// GraphQL Type
47
+
pub opaque type Type {
48
+
ScalarType(name: String)
49
+
ObjectType(name: String, description: String, fields: List(Field))
50
+
InputObjectType(name: String, description: String, fields: List(InputField))
51
+
EnumType(name: String, description: String, values: List(EnumValue))
52
+
UnionType(
53
+
name: String,
54
+
description: String,
55
+
possible_types: List(Type),
56
+
type_resolver: fn(Context) -> Result(String, String),
57
+
)
58
+
ListType(inner_type: Type)
59
+
NonNullType(inner_type: Type)
60
+
}
61
+
62
+
/// GraphQL Field
63
+
pub opaque type Field {
64
+
Field(
65
+
name: String,
66
+
field_type: Type,
67
+
description: String,
68
+
arguments: List(Argument),
69
+
resolver: Resolver,
70
+
)
71
+
}
72
+
73
+
/// GraphQL Argument
74
+
pub opaque type Argument {
75
+
Argument(
76
+
name: String,
77
+
arg_type: Type,
78
+
description: String,
79
+
default_value: Option(value.Value),
80
+
)
81
+
}
82
+
83
+
/// GraphQL Input Field (for InputObject types)
84
+
pub opaque type InputField {
85
+
InputField(
86
+
name: String,
87
+
field_type: Type,
88
+
description: String,
89
+
default_value: Option(value.Value),
90
+
)
91
+
}
92
+
93
+
/// GraphQL Enum Value
94
+
pub opaque type EnumValue {
95
+
EnumValue(name: String, description: String)
96
+
}
97
+
98
+
/// GraphQL Schema
99
+
pub opaque type Schema {
100
+
Schema(
101
+
query_type: Type,
102
+
mutation_type: Option(Type),
103
+
subscription_type: Option(Type),
104
+
)
105
+
}
106
+
107
+
// Built-in scalar types
108
+
pub fn string_type() -> Type {
109
+
ScalarType("String")
110
+
}
111
+
112
+
pub fn int_type() -> Type {
113
+
ScalarType("Int")
114
+
}
115
+
116
+
pub fn float_type() -> Type {
117
+
ScalarType("Float")
118
+
}
119
+
120
+
pub fn boolean_type() -> Type {
121
+
ScalarType("Boolean")
122
+
}
123
+
124
+
pub fn id_type() -> Type {
125
+
ScalarType("ID")
126
+
}
127
+
128
+
// Type constructors
129
+
pub fn object_type(
130
+
name: String,
131
+
description: String,
132
+
fields: List(Field),
133
+
) -> Type {
134
+
ObjectType(name, description, fields)
135
+
}
136
+
137
+
pub fn enum_type(
138
+
name: String,
139
+
description: String,
140
+
values: List(EnumValue),
141
+
) -> Type {
142
+
EnumType(name, description, values)
143
+
}
144
+
145
+
pub fn input_object_type(
146
+
name: String,
147
+
description: String,
148
+
fields: List(InputField),
149
+
) -> Type {
150
+
InputObjectType(name, description, fields)
151
+
}
152
+
153
+
pub fn union_type(
154
+
name: String,
155
+
description: String,
156
+
possible_types: List(Type),
157
+
type_resolver: fn(Context) -> Result(String, String),
158
+
) -> Type {
159
+
UnionType(name, description, possible_types, type_resolver)
160
+
}
161
+
162
+
pub fn list_type(inner_type: Type) -> Type {
163
+
ListType(inner_type)
164
+
}
165
+
166
+
pub fn non_null(inner_type: Type) -> Type {
167
+
NonNullType(inner_type)
168
+
}
169
+
170
+
// Field constructors
171
+
pub fn field(
172
+
name: String,
173
+
field_type: Type,
174
+
description: String,
175
+
resolver: Resolver,
176
+
) -> Field {
177
+
Field(name, field_type, description, [], resolver)
178
+
}
179
+
180
+
pub fn field_with_args(
181
+
name: String,
182
+
field_type: Type,
183
+
description: String,
184
+
arguments: List(Argument),
185
+
resolver: Resolver,
186
+
) -> Field {
187
+
Field(name, field_type, description, arguments, resolver)
188
+
}
189
+
190
+
// Argument constructor
191
+
pub fn argument(
192
+
name: String,
193
+
arg_type: Type,
194
+
description: String,
195
+
default_value: Option(value.Value),
196
+
) -> Argument {
197
+
Argument(name, arg_type, description, default_value)
198
+
}
199
+
200
+
// Input field constructor
201
+
pub fn input_field(
202
+
name: String,
203
+
field_type: Type,
204
+
description: String,
205
+
default_value: Option(value.Value),
206
+
) -> InputField {
207
+
InputField(name, field_type, description, default_value)
208
+
}
209
+
210
+
// Enum value constructor
211
+
pub fn enum_value(name: String, description: String) -> EnumValue {
212
+
EnumValue(name, description)
213
+
}
214
+
215
+
// Schema constructor
216
+
pub fn schema(query_type: Type, mutation_type: Option(Type)) -> Schema {
217
+
Schema(query_type, mutation_type, None)
218
+
}
219
+
220
+
// Schema constructor with subscriptions
221
+
pub fn schema_with_subscriptions(
222
+
query_type: Type,
223
+
mutation_type: Option(Type),
224
+
subscription_type: Option(Type),
225
+
) -> Schema {
226
+
Schema(query_type, mutation_type, subscription_type)
227
+
}
228
+
229
+
// Accessors
230
+
pub fn type_name(t: Type) -> String {
231
+
case t {
232
+
ScalarType(name) -> name
233
+
ObjectType(name, _, _) -> name
234
+
InputObjectType(name, _, _) -> name
235
+
EnumType(name, _, _) -> name
236
+
UnionType(name, _, _, _) -> name
237
+
ListType(inner) -> "[" <> type_name(inner) <> "]"
238
+
NonNullType(inner) -> type_name(inner) <> "!"
239
+
}
240
+
}
241
+
242
+
pub fn field_name(f: Field) -> String {
243
+
case f {
244
+
Field(name, _, _, _, _) -> name
245
+
}
246
+
}
247
+
248
+
pub fn query_type(s: Schema) -> Type {
249
+
case s {
250
+
Schema(query_type, _, _) -> query_type
251
+
}
252
+
}
253
+
254
+
pub fn get_mutation_type(s: Schema) -> Option(Type) {
255
+
case s {
256
+
Schema(_, mutation_type, _) -> mutation_type
257
+
}
258
+
}
259
+
260
+
pub fn get_subscription_type(s: Schema) -> Option(Type) {
261
+
case s {
262
+
Schema(_, _, subscription_type) -> subscription_type
263
+
}
264
+
}
265
+
266
+
pub fn is_non_null(t: Type) -> Bool {
267
+
case t {
268
+
NonNullType(_) -> True
269
+
_ -> False
270
+
}
271
+
}
272
+
273
+
pub fn is_list(t: Type) -> Bool {
274
+
case t {
275
+
ListType(_) -> True
276
+
_ -> False
277
+
}
278
+
}
279
+
280
+
pub fn is_input_object(t: Type) -> Bool {
281
+
case t {
282
+
InputObjectType(_, _, _) -> True
283
+
_ -> False
284
+
}
285
+
}
286
+
287
+
pub fn type_description(t: Type) -> String {
288
+
case t {
289
+
ObjectType(_, description, _) -> description
290
+
InputObjectType(_, description, _) -> description
291
+
EnumType(_, description, _) -> description
292
+
_ -> ""
293
+
}
294
+
}
295
+
296
+
// Field resolution helpers
297
+
pub fn resolve_field(field: Field, ctx: Context) -> Result(value.Value, String) {
298
+
case field {
299
+
Field(_, _, _, _, resolver) -> resolver(ctx)
300
+
}
301
+
}
302
+
303
+
pub fn get_field(t: Type, field_name: String) -> Option(Field) {
304
+
case t {
305
+
ObjectType(_, _, fields) -> {
306
+
list.find(fields, fn(f) {
307
+
case f {
308
+
Field(name, _, _, _, _) -> name == field_name
309
+
}
310
+
})
311
+
|> option.from_result
312
+
}
313
+
NonNullType(inner) -> get_field(inner, field_name)
314
+
_ -> None
315
+
}
316
+
}
317
+
318
+
/// Get the type of a field
319
+
pub fn field_type(field: Field) -> Type {
320
+
case field {
321
+
Field(_, ft, _, _, _) -> ft
322
+
}
323
+
}
324
+
325
+
/// Get all fields from an ObjectType
326
+
pub fn get_fields(t: Type) -> List(Field) {
327
+
case t {
328
+
ObjectType(_, _, fields) -> fields
329
+
_ -> []
330
+
}
331
+
}
332
+
333
+
/// Get all input fields from an InputObjectType
334
+
pub fn get_input_fields(t: Type) -> List(InputField) {
335
+
case t {
336
+
InputObjectType(_, _, fields) -> fields
337
+
_ -> []
338
+
}
339
+
}
340
+
341
+
/// Get field description
342
+
pub fn field_description(field: Field) -> String {
343
+
case field {
344
+
Field(_, _, desc, _, _) -> desc
345
+
}
346
+
}
347
+
348
+
/// Get field arguments
349
+
pub fn field_arguments(field: Field) -> List(Argument) {
350
+
case field {
351
+
Field(_, _, _, args, _) -> args
352
+
}
353
+
}
354
+
355
+
/// Get argument name
356
+
pub fn argument_name(arg: Argument) -> String {
357
+
case arg {
358
+
Argument(name, _, _, _) -> name
359
+
}
360
+
}
361
+
362
+
/// Get argument type
363
+
pub fn argument_type(arg: Argument) -> Type {
364
+
case arg {
365
+
Argument(_, arg_type, _, _) -> arg_type
366
+
}
367
+
}
368
+
369
+
/// Get argument description
370
+
pub fn argument_description(arg: Argument) -> String {
371
+
case arg {
372
+
Argument(_, _, desc, _) -> desc
373
+
}
374
+
}
375
+
376
+
/// Get input field type
377
+
pub fn input_field_type(input_field: InputField) -> Type {
378
+
case input_field {
379
+
InputField(_, field_type, _, _) -> field_type
380
+
}
381
+
}
382
+
383
+
/// Get input field name
384
+
pub fn input_field_name(input_field: InputField) -> String {
385
+
case input_field {
386
+
InputField(name, _, _, _) -> name
387
+
}
388
+
}
389
+
390
+
/// Get input field description
391
+
pub fn input_field_description(input_field: InputField) -> String {
392
+
case input_field {
393
+
InputField(_, _, desc, _) -> desc
394
+
}
395
+
}
396
+
397
+
/// Get all enum values from an EnumType
398
+
pub fn get_enum_values(t: Type) -> List(EnumValue) {
399
+
case t {
400
+
EnumType(_, _, values) -> values
401
+
_ -> []
402
+
}
403
+
}
404
+
405
+
/// Get enum value name
406
+
pub fn enum_value_name(enum_value: EnumValue) -> String {
407
+
case enum_value {
408
+
EnumValue(name, _) -> name
409
+
}
410
+
}
411
+
412
+
/// Get enum value description
413
+
pub fn enum_value_description(enum_value: EnumValue) -> String {
414
+
case enum_value {
415
+
EnumValue(_, desc) -> desc
416
+
}
417
+
}
418
+
419
+
/// Check if type is a scalar
420
+
pub fn is_scalar(t: Type) -> Bool {
421
+
case t {
422
+
ScalarType(_) -> True
423
+
_ -> False
424
+
}
425
+
}
426
+
427
+
/// Check if type is an object
428
+
pub fn is_object(t: Type) -> Bool {
429
+
case t {
430
+
ObjectType(_, _, _) -> True
431
+
_ -> False
432
+
}
433
+
}
434
+
435
+
/// Check if type is an enum
436
+
pub fn is_enum(t: Type) -> Bool {
437
+
case t {
438
+
EnumType(_, _, _) -> True
439
+
_ -> False
440
+
}
441
+
}
442
+
443
+
/// Check if type is a union
444
+
pub fn is_union(t: Type) -> Bool {
445
+
case t {
446
+
UnionType(_, _, _, _) -> True
447
+
_ -> False
448
+
}
449
+
}
450
+
451
+
/// Get the possible types from a union
452
+
pub fn get_possible_types(t: Type) -> List(Type) {
453
+
case t {
454
+
UnionType(_, _, possible_types, _) -> possible_types
455
+
_ -> []
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 {
462
+
UnionType(_, _, possible_types, type_resolver) -> {
463
+
// Call the type resolver to get the concrete type name
464
+
case type_resolver(ctx) {
465
+
Ok(resolved_type_name) -> {
466
+
// Find the concrete type in possible_types
467
+
case
468
+
list.find(possible_types, fn(pt) {
469
+
type_name(pt) == resolved_type_name
470
+
})
471
+
{
472
+
Ok(concrete_type) -> Ok(concrete_type)
473
+
Error(_) ->
474
+
Error(
475
+
"Type resolver returned '"
476
+
<> resolved_type_name
477
+
<> "' which is not a possible type of this union",
478
+
)
479
+
}
480
+
}
481
+
Error(err) -> Error(err)
482
+
}
483
+
}
484
+
_ -> Error("Cannot resolve non-union type")
485
+
}
486
+
}
487
+
488
+
/// Get the inner type from a wrapping type (List or NonNull)
489
+
pub fn inner_type(t: Type) -> option.Option(Type) {
490
+
case t {
491
+
ListType(inner) -> option.Some(inner)
492
+
NonNullType(inner) -> option.Some(inner)
493
+
_ -> option.None
494
+
}
495
+
}
496
+
497
+
/// Get the kind of a type as a string for introspection
498
+
pub fn type_kind(t: Type) -> String {
499
+
case t {
500
+
ScalarType(_) -> "SCALAR"
501
+
ObjectType(_, _, _) -> "OBJECT"
502
+
InputObjectType(_, _, _) -> "INPUT_OBJECT"
503
+
EnumType(_, _, _) -> "ENUM"
504
+
UnionType(_, _, _, _) -> "UNION"
505
+
ListType(_) -> "LIST"
506
+
NonNullType(_) -> "NON_NULL"
507
+
}
508
+
}
+256
src/swell/sdl.gleam
+256
src/swell/sdl.gleam
···
1
+
/// GraphQL SDL (Schema Definition Language) Printer
2
+
///
3
+
/// Generates proper SDL output from GraphQL schema types
4
+
/// Follows the GraphQL specification for schema representation
5
+
import gleam/list
6
+
import gleam/option
7
+
import gleam/string
8
+
import swell/schema
9
+
10
+
/// Print a single GraphQL type as SDL
11
+
pub fn print_type(type_: schema.Type) -> String {
12
+
print_type_internal(type_, 0, False)
13
+
}
14
+
15
+
/// Print multiple GraphQL types as SDL with blank lines between them
16
+
pub fn print_types(types: List(schema.Type)) -> String {
17
+
list.map(types, print_type)
18
+
|> string.join("\n\n")
19
+
}
20
+
21
+
// Internal function that handles indentation and inline mode
22
+
fn print_type_internal(
23
+
type_: schema.Type,
24
+
indent_level: Int,
25
+
inline: Bool,
26
+
) -> String {
27
+
let kind = schema.type_kind(type_)
28
+
29
+
case kind {
30
+
"INPUT_OBJECT" -> print_input_object(type_, indent_level, inline)
31
+
"OBJECT" -> print_object(type_, indent_level, inline)
32
+
"ENUM" -> print_enum(type_, indent_level, inline)
33
+
"UNION" -> print_union(type_, indent_level, inline)
34
+
"SCALAR" -> print_scalar(type_, indent_level, inline)
35
+
"LIST" -> {
36
+
case schema.inner_type(type_) {
37
+
option.Some(inner) ->
38
+
"[" <> print_type_internal(inner, indent_level, True) <> "]"
39
+
option.None -> "[Unknown]"
40
+
}
41
+
}
42
+
"NON_NULL" -> {
43
+
case schema.inner_type(type_) {
44
+
option.Some(inner) ->
45
+
print_type_internal(inner, indent_level, True) <> "!"
46
+
option.None -> "Unknown!"
47
+
}
48
+
}
49
+
_ -> schema.type_name(type_)
50
+
}
51
+
}
52
+
53
+
fn print_scalar(type_: schema.Type, indent_level: Int, inline: Bool) -> String {
54
+
case inline {
55
+
True -> schema.type_name(type_)
56
+
False -> {
57
+
let indent = string.repeat(" ", indent_level * 2)
58
+
let description = schema.type_description(type_)
59
+
let desc_block = case description {
60
+
"" -> ""
61
+
_ -> indent <> format_description(description) <> "\n"
62
+
}
63
+
64
+
desc_block <> indent <> "scalar " <> schema.type_name(type_)
65
+
}
66
+
}
67
+
}
68
+
69
+
fn print_union(type_: schema.Type, indent_level: Int, inline: Bool) -> String {
70
+
case inline {
71
+
True -> schema.type_name(type_)
72
+
False -> {
73
+
let type_name = schema.type_name(type_)
74
+
let indent = string.repeat(" ", indent_level * 2)
75
+
let description = schema.type_description(type_)
76
+
let desc_block = case description {
77
+
"" -> ""
78
+
_ -> indent <> format_description(description) <> "\n"
79
+
}
80
+
81
+
let possible_types = schema.get_possible_types(type_)
82
+
let type_names =
83
+
list.map(possible_types, fn(t) { schema.type_name(t) })
84
+
|> string.join(" | ")
85
+
86
+
desc_block <> indent <> "union " <> type_name <> " = " <> type_names
87
+
}
88
+
}
89
+
}
90
+
91
+
fn print_input_object(
92
+
type_: schema.Type,
93
+
indent_level: Int,
94
+
inline: Bool,
95
+
) -> String {
96
+
case inline {
97
+
True -> schema.type_name(type_)
98
+
False -> {
99
+
let type_name = schema.type_name(type_)
100
+
let indent = string.repeat(" ", indent_level * 2)
101
+
let field_indent = string.repeat(" ", { indent_level + 1 } * 2)
102
+
103
+
let description = schema.type_description(type_)
104
+
let desc_block = case description {
105
+
"" -> ""
106
+
_ -> indent <> format_description(description) <> "\n"
107
+
}
108
+
109
+
let fields = schema.get_input_fields(type_)
110
+
111
+
let field_lines =
112
+
list.map(fields, fn(field) {
113
+
let field_name = schema.input_field_name(field)
114
+
let field_type = schema.input_field_type(field)
115
+
let field_desc = schema.input_field_description(field)
116
+
let field_type_str =
117
+
print_type_internal(field_type, indent_level + 1, True)
118
+
119
+
let field_desc_block = case field_desc {
120
+
"" -> ""
121
+
_ -> field_indent <> format_description(field_desc) <> "\n"
122
+
}
123
+
124
+
field_desc_block
125
+
<> field_indent
126
+
<> field_name
127
+
<> ": "
128
+
<> field_type_str
129
+
})
130
+
131
+
case list.is_empty(fields) {
132
+
True -> desc_block <> indent <> "input " <> type_name <> " {}"
133
+
False -> {
134
+
desc_block
135
+
<> indent
136
+
<> "input "
137
+
<> type_name
138
+
<> " {\n"
139
+
<> string.join(field_lines, "\n")
140
+
<> "\n"
141
+
<> indent
142
+
<> "}"
143
+
}
144
+
}
145
+
}
146
+
}
147
+
}
148
+
149
+
fn print_object(type_: schema.Type, indent_level: Int, inline: Bool) -> String {
150
+
case inline {
151
+
True -> schema.type_name(type_)
152
+
False -> {
153
+
let type_name = schema.type_name(type_)
154
+
let indent = string.repeat(" ", indent_level * 2)
155
+
let field_indent = string.repeat(" ", { indent_level + 1 } * 2)
156
+
157
+
let description = schema.type_description(type_)
158
+
let desc_block = case description {
159
+
"" -> ""
160
+
_ -> indent <> format_description(description) <> "\n"
161
+
}
162
+
163
+
let fields = schema.get_fields(type_)
164
+
165
+
let field_lines =
166
+
list.map(fields, fn(field) {
167
+
let field_name = schema.field_name(field)
168
+
let field_type = schema.field_type(field)
169
+
let field_desc = schema.field_description(field)
170
+
let field_type_str =
171
+
print_type_internal(field_type, indent_level + 1, True)
172
+
173
+
let field_desc_block = case field_desc {
174
+
"" -> ""
175
+
_ -> field_indent <> format_description(field_desc) <> "\n"
176
+
}
177
+
178
+
field_desc_block
179
+
<> field_indent
180
+
<> field_name
181
+
<> ": "
182
+
<> field_type_str
183
+
})
184
+
185
+
case list.is_empty(fields) {
186
+
True -> desc_block <> indent <> "type " <> type_name <> " {}"
187
+
False -> {
188
+
desc_block
189
+
<> indent
190
+
<> "type "
191
+
<> type_name
192
+
<> " {\n"
193
+
<> string.join(field_lines, "\n")
194
+
<> "\n"
195
+
<> indent
196
+
<> "}"
197
+
}
198
+
}
199
+
}
200
+
}
201
+
}
202
+
203
+
fn print_enum(type_: schema.Type, indent_level: Int, inline: Bool) -> String {
204
+
case inline {
205
+
True -> schema.type_name(type_)
206
+
False -> {
207
+
let type_name = schema.type_name(type_)
208
+
let indent = string.repeat(" ", indent_level * 2)
209
+
let value_indent = string.repeat(" ", { indent_level + 1 } * 2)
210
+
211
+
let description = schema.type_description(type_)
212
+
let desc_block = case description {
213
+
"" -> ""
214
+
_ -> indent <> format_description(description) <> "\n"
215
+
}
216
+
217
+
let values = schema.get_enum_values(type_)
218
+
219
+
let value_lines =
220
+
list.map(values, fn(value) {
221
+
let value_name = schema.enum_value_name(value)
222
+
let value_desc = schema.enum_value_description(value)
223
+
224
+
let value_desc_block = case value_desc {
225
+
"" -> ""
226
+
_ -> value_indent <> format_description(value_desc) <> "\n"
227
+
}
228
+
229
+
value_desc_block <> value_indent <> value_name
230
+
})
231
+
232
+
case list.is_empty(values) {
233
+
True -> desc_block <> indent <> "enum " <> type_name <> " {}"
234
+
False -> {
235
+
desc_block
236
+
<> indent
237
+
<> "enum "
238
+
<> type_name
239
+
<> " {\n"
240
+
<> string.join(value_lines, "\n")
241
+
<> "\n"
242
+
<> indent
243
+
<> "}"
244
+
}
245
+
}
246
+
}
247
+
}
248
+
}
249
+
250
+
/// Format a description as a triple-quoted string
251
+
fn format_description(description: String) -> String {
252
+
case description {
253
+
"" -> ""
254
+
_ -> "\"\"\"" <> description <> "\"\"\""
255
+
}
256
+
}
+32
src/swell/value.gleam
+32
src/swell/value.gleam
···
1
+
/// GraphQL Value types
2
+
///
3
+
/// Per GraphQL spec Section 2 - Language, values can be scalars, enums,
4
+
/// lists, or objects. This module defines the core Value type used throughout
5
+
/// the GraphQL implementation.
6
+
/// A GraphQL value that can be used in queries, responses, and variables
7
+
pub type Value {
8
+
/// Represents null/absence of a value
9
+
Null
10
+
11
+
/// Integer value (32-bit signed integer per spec)
12
+
Int(Int)
13
+
14
+
/// Floating point value (IEEE 754 double precision per spec)
15
+
Float(Float)
16
+
17
+
/// UTF-8 string value
18
+
String(String)
19
+
20
+
/// Boolean true or false
21
+
Boolean(Bool)
22
+
23
+
/// Enum value represented as a string (e.g., "ACTIVE", "PENDING")
24
+
Enum(String)
25
+
26
+
/// Ordered list of values
27
+
List(List(Value))
28
+
29
+
/// Unordered set of key-value pairs
30
+
/// Using list of tuples for simplicity and ordering preservation
31
+
Object(List(#(String, Value)))
32
+
}
+867
test/executor_test.gleam
+867
test/executor_test.gleam
···
1
+
/// Tests for GraphQL Executor
2
+
///
3
+
/// Tests query execution combining parser + schema + resolvers
4
+
import birdie
5
+
import gleam/dict
6
+
import gleam/list
7
+
import gleam/option.{None, Some}
8
+
import gleam/string
9
+
import gleeunit/should
10
+
import swell/executor
11
+
import swell/schema
12
+
import swell/value
13
+
14
+
// Helper to create a simple test schema
15
+
fn test_schema() -> schema.Schema {
16
+
let query_type =
17
+
schema.object_type("Query", "Root query type", [
18
+
schema.field("hello", schema.string_type(), "Hello field", fn(_ctx) {
19
+
Ok(value.String("world"))
20
+
}),
21
+
schema.field("number", schema.int_type(), "Number field", fn(_ctx) {
22
+
Ok(value.Int(42))
23
+
}),
24
+
schema.field_with_args(
25
+
"greet",
26
+
schema.string_type(),
27
+
"Greet someone",
28
+
[schema.argument("name", schema.string_type(), "Name to greet", None)],
29
+
fn(_ctx) { Ok(value.String("Hello, Alice!")) },
30
+
),
31
+
])
32
+
33
+
schema.schema(query_type, None)
34
+
}
35
+
36
+
// Nested object schema for testing
37
+
fn nested_schema() -> schema.Schema {
38
+
let user_type =
39
+
schema.object_type("User", "A user", [
40
+
schema.field("id", schema.id_type(), "User ID", fn(_ctx) {
41
+
Ok(value.String("123"))
42
+
}),
43
+
schema.field("name", schema.string_type(), "User name", fn(_ctx) {
44
+
Ok(value.String("Alice"))
45
+
}),
46
+
])
47
+
48
+
let query_type =
49
+
schema.object_type("Query", "Root query type", [
50
+
schema.field("user", user_type, "Get user", fn(_ctx) {
51
+
Ok(
52
+
value.Object([
53
+
#("id", value.String("123")),
54
+
#("name", value.String("Alice")),
55
+
]),
56
+
)
57
+
}),
58
+
])
59
+
60
+
schema.schema(query_type, None)
61
+
}
62
+
63
+
pub fn execute_simple_query_test() {
64
+
let schema = test_schema()
65
+
let query = "{ hello }"
66
+
67
+
let result = executor.execute(query, schema, schema.context(None))
68
+
69
+
let response = case result {
70
+
Ok(r) -> r
71
+
Error(_) -> panic as "Execution failed"
72
+
}
73
+
74
+
birdie.snap(title: "Execute simple query", content: format_response(response))
75
+
}
76
+
77
+
pub fn execute_multiple_fields_test() {
78
+
let schema = test_schema()
79
+
let query = "{ hello number }"
80
+
81
+
let result = executor.execute(query, schema, schema.context(None))
82
+
83
+
should.be_ok(result)
84
+
}
85
+
86
+
pub fn execute_nested_query_test() {
87
+
let schema = nested_schema()
88
+
let query = "{ user { id name } }"
89
+
90
+
let result = executor.execute(query, schema, schema.context(None))
91
+
92
+
should.be_ok(result)
93
+
}
94
+
95
+
// Helper to format response for snapshots
96
+
fn format_response(response: executor.Response) -> String {
97
+
string.inspect(response)
98
+
}
99
+
100
+
pub fn execute_field_with_arguments_test() {
101
+
let schema = test_schema()
102
+
let query = "{ greet(name: \"Alice\") }"
103
+
104
+
let result = executor.execute(query, schema, schema.context(None))
105
+
106
+
should.be_ok(result)
107
+
}
108
+
109
+
pub fn execute_invalid_query_returns_error_test() {
110
+
let schema = test_schema()
111
+
let query = "{ invalid }"
112
+
113
+
let result = executor.execute(query, schema, schema.context(None))
114
+
115
+
// Should return error since field doesn't exist
116
+
case result {
117
+
Ok(executor.Response(_, [_, ..])) -> should.be_true(True)
118
+
Error(_) -> should.be_true(True)
119
+
_ -> should.be_true(False)
120
+
}
121
+
}
122
+
123
+
pub fn execute_parse_error_returns_error_test() {
124
+
let schema = test_schema()
125
+
let query = "{ invalid syntax"
126
+
127
+
let result = executor.execute(query, schema, schema.context(None))
128
+
129
+
should.be_error(result)
130
+
}
131
+
132
+
pub fn execute_typename_introspection_test() {
133
+
let schema = test_schema()
134
+
let query = "{ __typename }"
135
+
136
+
let result = executor.execute(query, schema, schema.context(None))
137
+
138
+
let response = case result {
139
+
Ok(r) -> r
140
+
Error(_) -> panic as "Execution failed"
141
+
}
142
+
143
+
birdie.snap(
144
+
title: "Execute __typename introspection",
145
+
content: format_response(response),
146
+
)
147
+
}
148
+
149
+
pub fn execute_typename_with_regular_fields_test() {
150
+
let schema = test_schema()
151
+
let query = "{ __typename hello }"
152
+
153
+
let result = executor.execute(query, schema, schema.context(None))
154
+
155
+
let response = case result {
156
+
Ok(r) -> r
157
+
Error(_) -> panic as "Execution failed"
158
+
}
159
+
160
+
birdie.snap(
161
+
title: "Execute __typename with regular fields",
162
+
content: format_response(response),
163
+
)
164
+
}
165
+
166
+
pub fn execute_schema_introspection_query_type_test() {
167
+
let schema = test_schema()
168
+
let query = "{ __schema { queryType { name } } }"
169
+
170
+
let result = executor.execute(query, schema, schema.context(None))
171
+
172
+
let response = case result {
173
+
Ok(r) -> r
174
+
Error(_) -> panic as "Execution failed"
175
+
}
176
+
177
+
birdie.snap(
178
+
title: "Execute __schema introspection",
179
+
content: format_response(response),
180
+
)
181
+
}
182
+
183
+
// Fragment execution tests
184
+
pub fn execute_simple_fragment_spread_test() {
185
+
let schema = nested_schema()
186
+
let query =
187
+
"
188
+
fragment UserFields on User {
189
+
id
190
+
name
191
+
}
192
+
193
+
{ user { ...UserFields } }
194
+
"
195
+
196
+
let result = executor.execute(query, schema, schema.context(None))
197
+
198
+
let response = case result {
199
+
Ok(r) -> r
200
+
Error(_) -> panic as "Execution failed"
201
+
}
202
+
203
+
birdie.snap(
204
+
title: "Execute simple fragment spread",
205
+
content: format_response(response),
206
+
)
207
+
}
208
+
209
+
// Test for list fields with nested selections
210
+
pub fn execute_list_with_nested_selections_test() {
211
+
// Create a schema with a list field
212
+
let user_type =
213
+
schema.object_type("User", "A user", [
214
+
schema.field("id", schema.id_type(), "User ID", fn(ctx) {
215
+
case ctx.data {
216
+
option.Some(value.Object(fields)) -> {
217
+
case list.key_find(fields, "id") {
218
+
Ok(id_val) -> Ok(id_val)
219
+
Error(_) -> Ok(value.Null)
220
+
}
221
+
}
222
+
_ -> Ok(value.Null)
223
+
}
224
+
}),
225
+
schema.field("name", schema.string_type(), "User name", fn(ctx) {
226
+
case ctx.data {
227
+
option.Some(value.Object(fields)) -> {
228
+
case list.key_find(fields, "name") {
229
+
Ok(name_val) -> Ok(name_val)
230
+
Error(_) -> Ok(value.Null)
231
+
}
232
+
}
233
+
_ -> Ok(value.Null)
234
+
}
235
+
}),
236
+
schema.field("email", schema.string_type(), "User email", fn(ctx) {
237
+
case ctx.data {
238
+
option.Some(value.Object(fields)) -> {
239
+
case list.key_find(fields, "email") {
240
+
Ok(email_val) -> Ok(email_val)
241
+
Error(_) -> Ok(value.Null)
242
+
}
243
+
}
244
+
_ -> Ok(value.Null)
245
+
}
246
+
}),
247
+
])
248
+
249
+
let list_type = schema.list_type(user_type)
250
+
251
+
let query_type =
252
+
schema.object_type("Query", "Root query type", [
253
+
schema.field("users", list_type, "Get all users", fn(_ctx) {
254
+
// Return a list of user objects
255
+
Ok(
256
+
value.List([
257
+
value.Object([
258
+
#("id", value.String("1")),
259
+
#("name", value.String("Alice")),
260
+
#("email", value.String("alice@example.com")),
261
+
]),
262
+
value.Object([
263
+
#("id", value.String("2")),
264
+
#("name", value.String("Bob")),
265
+
#("email", value.String("bob@example.com")),
266
+
]),
267
+
]),
268
+
)
269
+
}),
270
+
])
271
+
272
+
let schema = schema.schema(query_type, None)
273
+
274
+
// Query with nested field selection - only request id and name, not email
275
+
let query = "{ users { id name } }"
276
+
277
+
let result = executor.execute(query, schema, schema.context(None))
278
+
279
+
let response = case result {
280
+
Ok(r) -> r
281
+
Error(_) -> panic as "Execution failed"
282
+
}
283
+
284
+
birdie.snap(
285
+
title: "Execute list with nested selections",
286
+
content: format_response(response),
287
+
)
288
+
}
289
+
290
+
// Test that arguments are actually passed to resolvers
291
+
pub fn execute_field_receives_string_argument_test() {
292
+
let query_type =
293
+
schema.object_type("Query", "Root", [
294
+
schema.field_with_args(
295
+
"echo",
296
+
schema.string_type(),
297
+
"Echo the input",
298
+
[schema.argument("message", schema.string_type(), "Message", None)],
299
+
fn(ctx) {
300
+
// Extract the argument from context
301
+
case schema.get_argument(ctx, "message") {
302
+
Some(value.String(msg)) -> Ok(value.String("Echo: " <> msg))
303
+
_ -> Ok(value.String("No message"))
304
+
}
305
+
},
306
+
),
307
+
])
308
+
309
+
let test_schema = schema.schema(query_type, None)
310
+
let query = "{ echo(message: \"hello\") }"
311
+
312
+
let result = executor.execute(query, test_schema, schema.context(None))
313
+
314
+
let response = case result {
315
+
Ok(r) -> r
316
+
Error(_) -> panic as "Execution failed"
317
+
}
318
+
319
+
birdie.snap(
320
+
title: "Execute field with string argument",
321
+
content: format_response(response),
322
+
)
323
+
}
324
+
325
+
// Test list argument
326
+
pub fn execute_field_receives_list_argument_test() {
327
+
let query_type =
328
+
schema.object_type("Query", "Root", [
329
+
schema.field_with_args(
330
+
"sum",
331
+
schema.int_type(),
332
+
"Sum numbers",
333
+
[
334
+
schema.argument(
335
+
"numbers",
336
+
schema.list_type(schema.int_type()),
337
+
"Numbers",
338
+
None,
339
+
),
340
+
],
341
+
fn(ctx) {
342
+
case schema.get_argument(ctx, "numbers") {
343
+
Some(value.List(_items)) -> Ok(value.String("got list"))
344
+
_ -> Ok(value.String("no list"))
345
+
}
346
+
},
347
+
),
348
+
])
349
+
350
+
let test_schema = schema.schema(query_type, None)
351
+
let query = "{ sum(numbers: [1, 2, 3]) }"
352
+
353
+
let result = executor.execute(query, test_schema, schema.context(None))
354
+
355
+
should.be_ok(result)
356
+
|> fn(response) {
357
+
case response {
358
+
executor.Response(
359
+
data: value.Object([#("sum", value.String("got list"))]),
360
+
errors: [],
361
+
) -> True
362
+
_ -> False
363
+
}
364
+
}
365
+
|> should.be_true
366
+
}
367
+
368
+
// Test object argument (like sortBy)
369
+
pub fn execute_field_receives_object_argument_test() {
370
+
let query_type =
371
+
schema.object_type("Query", "Root", [
372
+
schema.field_with_args(
373
+
"posts",
374
+
schema.list_type(schema.string_type()),
375
+
"Get posts",
376
+
[
377
+
schema.argument(
378
+
"sortBy",
379
+
schema.list_type(
380
+
schema.input_object_type("SortInput", "Sort", [
381
+
schema.input_field("field", schema.string_type(), "Field", None),
382
+
schema.input_field(
383
+
"direction",
384
+
schema.enum_type("Direction", "Direction", [
385
+
schema.enum_value("ASC", "Ascending"),
386
+
schema.enum_value("DESC", "Descending"),
387
+
]),
388
+
"Direction",
389
+
None,
390
+
),
391
+
]),
392
+
),
393
+
"Sort order",
394
+
None,
395
+
),
396
+
],
397
+
fn(ctx) {
398
+
case schema.get_argument(ctx, "sortBy") {
399
+
Some(value.List([value.Object(fields), ..])) -> {
400
+
case dict.from_list(fields) {
401
+
fields_dict -> {
402
+
case
403
+
dict.get(fields_dict, "field"),
404
+
dict.get(fields_dict, "direction")
405
+
{
406
+
Ok(value.String(field)), Ok(value.String(dir)) ->
407
+
Ok(value.String("Sorting by " <> field <> " " <> dir))
408
+
_, _ -> Ok(value.String("Invalid sort"))
409
+
}
410
+
}
411
+
}
412
+
}
413
+
_ -> Ok(value.String("No sort"))
414
+
}
415
+
},
416
+
),
417
+
])
418
+
419
+
let test_schema = schema.schema(query_type, None)
420
+
let query = "{ posts(sortBy: [{field: \"date\", direction: DESC}]) }"
421
+
422
+
let result = executor.execute(query, test_schema, schema.context(None))
423
+
424
+
let response = case result {
425
+
Ok(r) -> r
426
+
Error(_) -> panic as "Execution failed"
427
+
}
428
+
429
+
birdie.snap(
430
+
title: "Execute field with object argument",
431
+
content: format_response(response),
432
+
)
433
+
}
434
+
435
+
// Variable resolution tests
436
+
pub fn execute_query_with_variable_string_test() {
437
+
let query_type =
438
+
schema.object_type("Query", "Root query type", [
439
+
schema.field_with_args(
440
+
"greet",
441
+
schema.string_type(),
442
+
"Greet someone",
443
+
[
444
+
schema.argument("name", schema.string_type(), "Name to greet", None),
445
+
],
446
+
fn(ctx) {
447
+
case schema.get_argument(ctx, "name") {
448
+
Some(value.String(name)) ->
449
+
Ok(value.String("Hello, " <> name <> "!"))
450
+
_ -> Ok(value.String("Hello, stranger!"))
451
+
}
452
+
},
453
+
),
454
+
])
455
+
456
+
let test_schema = schema.schema(query_type, None)
457
+
let query = "query Test($name: String!) { greet(name: $name) }"
458
+
459
+
// Create context with variables
460
+
let variables = dict.from_list([#("name", value.String("Alice"))])
461
+
let ctx = schema.context_with_variables(None, variables)
462
+
463
+
let result = executor.execute(query, test_schema, ctx)
464
+
465
+
let response = case result {
466
+
Ok(r) -> r
467
+
Error(_) -> panic as "Execution failed"
468
+
}
469
+
470
+
birdie.snap(
471
+
title: "Execute query with string variable",
472
+
content: format_response(response),
473
+
)
474
+
}
475
+
476
+
pub fn execute_query_with_variable_int_test() {
477
+
let query_type =
478
+
schema.object_type("Query", "Root query type", [
479
+
schema.field_with_args(
480
+
"user",
481
+
schema.string_type(),
482
+
"Get user by ID",
483
+
[
484
+
schema.argument("id", schema.int_type(), "User ID", None),
485
+
],
486
+
fn(ctx) {
487
+
case schema.get_argument(ctx, "id") {
488
+
Some(value.Int(id)) ->
489
+
Ok(value.String("User #" <> string.inspect(id)))
490
+
_ -> Ok(value.String("Unknown user"))
491
+
}
492
+
},
493
+
),
494
+
])
495
+
496
+
let test_schema = schema.schema(query_type, None)
497
+
let query = "query GetUser($userId: Int!) { user(id: $userId) }"
498
+
499
+
// Create context with variables
500
+
let variables = dict.from_list([#("userId", value.Int(42))])
501
+
let ctx = schema.context_with_variables(None, variables)
502
+
503
+
let result = executor.execute(query, test_schema, ctx)
504
+
505
+
let response = case result {
506
+
Ok(r) -> r
507
+
Error(_) -> panic as "Execution failed"
508
+
}
509
+
510
+
birdie.snap(
511
+
title: "Execute query with int variable",
512
+
content: format_response(response),
513
+
)
514
+
}
515
+
516
+
pub fn execute_query_with_multiple_variables_test() {
517
+
let query_type =
518
+
schema.object_type("Query", "Root query type", [
519
+
schema.field_with_args(
520
+
"search",
521
+
schema.string_type(),
522
+
"Search for something",
523
+
[
524
+
schema.argument("query", schema.string_type(), "Search query", None),
525
+
schema.argument("limit", schema.int_type(), "Max results", None),
526
+
],
527
+
fn(ctx) {
528
+
case
529
+
schema.get_argument(ctx, "query"),
530
+
schema.get_argument(ctx, "limit")
531
+
{
532
+
Some(value.String(q)), Some(value.Int(l)) ->
533
+
Ok(value.String(
534
+
"Searching for '"
535
+
<> q
536
+
<> "' (limit: "
537
+
<> string.inspect(l)
538
+
<> ")",
539
+
))
540
+
_, _ -> Ok(value.String("Invalid search"))
541
+
}
542
+
},
543
+
),
544
+
])
545
+
546
+
let test_schema = schema.schema(query_type, None)
547
+
let query =
548
+
"query Search($q: String!, $max: Int!) { search(query: $q, limit: $max) }"
549
+
550
+
// Create context with variables
551
+
let variables =
552
+
dict.from_list([
553
+
#("q", value.String("graphql")),
554
+
#("max", value.Int(10)),
555
+
])
556
+
let ctx = schema.context_with_variables(None, variables)
557
+
558
+
let result = executor.execute(query, test_schema, ctx)
559
+
560
+
let response = case result {
561
+
Ok(r) -> r
562
+
Error(_) -> panic as "Execution failed"
563
+
}
564
+
565
+
birdie.snap(
566
+
title: "Execute query with multiple variables",
567
+
content: format_response(response),
568
+
)
569
+
}
570
+
571
+
// Union type execution tests
572
+
pub fn execute_union_with_inline_fragment_test() {
573
+
// Create object types that will be part of the union
574
+
let post_type =
575
+
schema.object_type("Post", "A blog post", [
576
+
schema.field("title", schema.string_type(), "Post title", fn(ctx) {
577
+
case ctx.data {
578
+
option.Some(value.Object(fields)) -> {
579
+
case list.key_find(fields, "title") {
580
+
Ok(title_val) -> Ok(title_val)
581
+
Error(_) -> Ok(value.Null)
582
+
}
583
+
}
584
+
_ -> Ok(value.Null)
585
+
}
586
+
}),
587
+
schema.field("content", schema.string_type(), "Post content", fn(ctx) {
588
+
case ctx.data {
589
+
option.Some(value.Object(fields)) -> {
590
+
case list.key_find(fields, "content") {
591
+
Ok(content_val) -> Ok(content_val)
592
+
Error(_) -> Ok(value.Null)
593
+
}
594
+
}
595
+
_ -> Ok(value.Null)
596
+
}
597
+
}),
598
+
])
599
+
600
+
let comment_type =
601
+
schema.object_type("Comment", "A comment", [
602
+
schema.field("text", schema.string_type(), "Comment text", fn(ctx) {
603
+
case ctx.data {
604
+
option.Some(value.Object(fields)) -> {
605
+
case list.key_find(fields, "text") {
606
+
Ok(text_val) -> Ok(text_val)
607
+
Error(_) -> Ok(value.Null)
608
+
}
609
+
}
610
+
_ -> Ok(value.Null)
611
+
}
612
+
}),
613
+
])
614
+
615
+
// Type resolver that examines the __typename field
616
+
let type_resolver = fn(ctx: schema.Context) -> Result(String, String) {
617
+
case ctx.data {
618
+
option.Some(value.Object(fields)) -> {
619
+
case list.key_find(fields, "__typename") {
620
+
Ok(value.String(type_name)) -> Ok(type_name)
621
+
_ -> Error("No __typename field found")
622
+
}
623
+
}
624
+
_ -> Error("No data")
625
+
}
626
+
}
627
+
628
+
// Create union type
629
+
let search_result_union =
630
+
schema.union_type(
631
+
"SearchResult",
632
+
"A search result",
633
+
[post_type, comment_type],
634
+
type_resolver,
635
+
)
636
+
637
+
// Create query type with a field returning the union
638
+
let query_type =
639
+
schema.object_type("Query", "Root query type", [
640
+
schema.field(
641
+
"search",
642
+
search_result_union,
643
+
"Search for content",
644
+
fn(_ctx) {
645
+
// Return a Post
646
+
Ok(
647
+
value.Object([
648
+
#("__typename", value.String("Post")),
649
+
#("title", value.String("GraphQL is awesome")),
650
+
#("content", value.String("Learn all about GraphQL...")),
651
+
]),
652
+
)
653
+
},
654
+
),
655
+
])
656
+
657
+
let test_schema = schema.schema(query_type, None)
658
+
659
+
// Query with inline fragment
660
+
let query =
661
+
"
662
+
{
663
+
search {
664
+
... on Post {
665
+
title
666
+
content
667
+
}
668
+
... on Comment {
669
+
text
670
+
}
671
+
}
672
+
}
673
+
"
674
+
675
+
let result = executor.execute(query, test_schema, schema.context(None))
676
+
677
+
let response = case result {
678
+
Ok(r) -> r
679
+
Error(_) -> panic as "Execution failed"
680
+
}
681
+
682
+
birdie.snap(
683
+
title: "Execute union with inline fragment",
684
+
content: format_response(response),
685
+
)
686
+
}
687
+
688
+
pub fn execute_union_list_with_inline_fragments_test() {
689
+
// Create object types
690
+
let post_type =
691
+
schema.object_type("Post", "A blog post", [
692
+
schema.field("title", schema.string_type(), "Post title", fn(ctx) {
693
+
case ctx.data {
694
+
option.Some(value.Object(fields)) -> {
695
+
case list.key_find(fields, "title") {
696
+
Ok(title_val) -> Ok(title_val)
697
+
Error(_) -> Ok(value.Null)
698
+
}
699
+
}
700
+
_ -> Ok(value.Null)
701
+
}
702
+
}),
703
+
])
704
+
705
+
let comment_type =
706
+
schema.object_type("Comment", "A comment", [
707
+
schema.field("text", schema.string_type(), "Comment text", fn(ctx) {
708
+
case ctx.data {
709
+
option.Some(value.Object(fields)) -> {
710
+
case list.key_find(fields, "text") {
711
+
Ok(text_val) -> Ok(text_val)
712
+
Error(_) -> Ok(value.Null)
713
+
}
714
+
}
715
+
_ -> Ok(value.Null)
716
+
}
717
+
}),
718
+
])
719
+
720
+
// Type resolver
721
+
let type_resolver = fn(ctx: schema.Context) -> Result(String, String) {
722
+
case ctx.data {
723
+
option.Some(value.Object(fields)) -> {
724
+
case list.key_find(fields, "__typename") {
725
+
Ok(value.String(type_name)) -> Ok(type_name)
726
+
_ -> Error("No __typename field found")
727
+
}
728
+
}
729
+
_ -> Error("No data")
730
+
}
731
+
}
732
+
733
+
// Create union type
734
+
let search_result_union =
735
+
schema.union_type(
736
+
"SearchResult",
737
+
"A search result",
738
+
[post_type, comment_type],
739
+
type_resolver,
740
+
)
741
+
742
+
// Create query type with a list of unions
743
+
let query_type =
744
+
schema.object_type("Query", "Root query type", [
745
+
schema.field(
746
+
"searchAll",
747
+
schema.list_type(search_result_union),
748
+
"Search for all content",
749
+
fn(_ctx) {
750
+
// Return a list with mixed types
751
+
Ok(
752
+
value.List([
753
+
value.Object([
754
+
#("__typename", value.String("Post")),
755
+
#("title", value.String("First Post")),
756
+
]),
757
+
value.Object([
758
+
#("__typename", value.String("Comment")),
759
+
#("text", value.String("Great article!")),
760
+
]),
761
+
value.Object([
762
+
#("__typename", value.String("Post")),
763
+
#("title", value.String("Second Post")),
764
+
]),
765
+
]),
766
+
)
767
+
},
768
+
),
769
+
])
770
+
771
+
let test_schema = schema.schema(query_type, None)
772
+
773
+
// Query with inline fragments on list items
774
+
let query =
775
+
"
776
+
{
777
+
searchAll {
778
+
... on Post {
779
+
title
780
+
}
781
+
... on Comment {
782
+
text
783
+
}
784
+
}
785
+
}
786
+
"
787
+
788
+
let result = executor.execute(query, test_schema, schema.context(None))
789
+
790
+
let response = case result {
791
+
Ok(r) -> r
792
+
Error(_) -> panic as "Execution failed"
793
+
}
794
+
795
+
birdie.snap(
796
+
title: "Execute union list with inline fragments",
797
+
content: format_response(response),
798
+
)
799
+
}
800
+
801
+
// Test field aliases
802
+
pub fn execute_field_with_alias_test() {
803
+
let schema = test_schema()
804
+
let query = "{ greeting: hello }"
805
+
806
+
let result = executor.execute(query, schema, schema.context(None))
807
+
808
+
let response = case result {
809
+
Ok(r) -> r
810
+
Error(_) -> panic as "Execution failed"
811
+
}
812
+
813
+
// Response should contain "greeting" as the key, not "hello"
814
+
case response.data {
815
+
value.Object(fields) -> {
816
+
case list.key_find(fields, "greeting") {
817
+
Ok(_) -> should.be_true(True)
818
+
Error(_) -> {
819
+
// Check if it incorrectly used "hello" instead
820
+
case list.key_find(fields, "hello") {
821
+
Ok(_) ->
822
+
panic as "Alias not applied - used 'hello' instead of 'greeting'"
823
+
Error(_) ->
824
+
panic as "Neither 'greeting' nor 'hello' found in response"
825
+
}
826
+
}
827
+
}
828
+
}
829
+
_ -> panic as "Expected object response"
830
+
}
831
+
}
832
+
833
+
// Test multiple aliases
834
+
pub fn execute_multiple_fields_with_aliases_test() {
835
+
let schema = test_schema()
836
+
let query = "{ greeting: hello num: number }"
837
+
838
+
let result = executor.execute(query, schema, schema.context(None))
839
+
840
+
let response = case result {
841
+
Ok(r) -> r
842
+
Error(_) -> panic as "Execution failed"
843
+
}
844
+
845
+
birdie.snap(
846
+
title: "Execute multiple fields with aliases",
847
+
content: format_response(response),
848
+
)
849
+
}
850
+
851
+
// Test mixed aliased and non-aliased fields
852
+
pub fn execute_mixed_aliased_fields_test() {
853
+
let schema = test_schema()
854
+
let query = "{ greeting: hello number }"
855
+
856
+
let result = executor.execute(query, schema, schema.context(None))
857
+
858
+
let response = case result {
859
+
Ok(r) -> r
860
+
Error(_) -> panic as "Execution failed"
861
+
}
862
+
863
+
birdie.snap(
864
+
title: "Execute mixed aliased and non-aliased fields",
865
+
content: format_response(response),
866
+
)
867
+
}
+676
test/introspection_test.gleam
+676
test/introspection_test.gleam
···
1
+
/// Tests for GraphQL Introspection
2
+
///
3
+
/// Comprehensive tests for introspection queries
4
+
import gleam/list
5
+
import gleam/option.{None}
6
+
import gleeunit/should
7
+
import swell/executor
8
+
import swell/schema
9
+
import swell/value
10
+
11
+
// Helper to create a simple test schema
12
+
fn test_schema() -> schema.Schema {
13
+
let query_type =
14
+
schema.object_type("Query", "Root query type", [
15
+
schema.field("hello", schema.string_type(), "Hello field", fn(_ctx) {
16
+
Ok(value.String("world"))
17
+
}),
18
+
schema.field("number", schema.int_type(), "Number field", fn(_ctx) {
19
+
Ok(value.Int(42))
20
+
}),
21
+
])
22
+
23
+
schema.schema(query_type, None)
24
+
}
25
+
26
+
/// Test: Multiple scalar fields on __schema
27
+
/// This test verifies that all requested fields on __schema are returned
28
+
pub fn schema_multiple_fields_test() {
29
+
let schema = test_schema()
30
+
let query =
31
+
"{ __schema { queryType { name } mutationType { name } subscriptionType { name } } }"
32
+
33
+
let result = executor.execute(query, schema, schema.context(None))
34
+
35
+
should.be_ok(result)
36
+
|> fn(response) {
37
+
case response {
38
+
executor.Response(data: value.Object(fields), errors: []) -> {
39
+
// Check that we have __schema field
40
+
case list.key_find(fields, "__schema") {
41
+
Ok(value.Object(schema_fields)) -> {
42
+
// Check for all three fields
43
+
let has_query_type = case
44
+
list.key_find(schema_fields, "queryType")
45
+
{
46
+
Ok(value.Object(_)) -> True
47
+
_ -> False
48
+
}
49
+
let has_mutation_type = case
50
+
list.key_find(schema_fields, "mutationType")
51
+
{
52
+
Ok(value.Null) -> True
53
+
// Should be null
54
+
_ -> False
55
+
}
56
+
let has_subscription_type = case
57
+
list.key_find(schema_fields, "subscriptionType")
58
+
{
59
+
Ok(value.Null) -> True
60
+
// Should be null
61
+
_ -> False
62
+
}
63
+
has_query_type && has_mutation_type && has_subscription_type
64
+
}
65
+
_ -> False
66
+
}
67
+
}
68
+
_ -> False
69
+
}
70
+
}
71
+
|> should.be_true
72
+
}
73
+
74
+
/// Test: types field with other fields
75
+
/// Verifies that the types array is returned along with other fields
76
+
pub fn schema_types_with_other_fields_test() {
77
+
let schema = test_schema()
78
+
let query = "{ __schema { queryType { name } types { name } } }"
79
+
80
+
let result = executor.execute(query, schema, schema.context(None))
81
+
82
+
should.be_ok(result)
83
+
|> fn(response) {
84
+
case response {
85
+
executor.Response(data: value.Object(fields), errors: []) -> {
86
+
case list.key_find(fields, "__schema") {
87
+
Ok(value.Object(schema_fields)) -> {
88
+
// Check for both fields
89
+
let has_query_type = case
90
+
list.key_find(schema_fields, "queryType")
91
+
{
92
+
Ok(value.Object(qt_fields)) -> {
93
+
case list.key_find(qt_fields, "name") {
94
+
Ok(value.String("Query")) -> True
95
+
_ -> False
96
+
}
97
+
}
98
+
_ -> False
99
+
}
100
+
let has_types = case list.key_find(schema_fields, "types") {
101
+
Ok(value.List(types)) -> {
102
+
// Should have 6 types: Query + 5 scalars
103
+
list.length(types) == 6
104
+
}
105
+
_ -> False
106
+
}
107
+
has_query_type && has_types
108
+
}
109
+
_ -> False
110
+
}
111
+
}
112
+
_ -> False
113
+
}
114
+
}
115
+
|> should.be_true
116
+
}
117
+
118
+
/// Test: All __schema top-level fields
119
+
/// Verifies that a query with all possible __schema fields returns all of them
120
+
pub fn schema_all_fields_test() {
121
+
let schema = test_schema()
122
+
let query =
123
+
"{ __schema { queryType { name } mutationType { name } subscriptionType { name } types { name } directives { name } } }"
124
+
125
+
let result = executor.execute(query, schema, schema.context(None))
126
+
127
+
should.be_ok(result)
128
+
|> fn(response) {
129
+
case response {
130
+
executor.Response(data: value.Object(fields), errors: []) -> {
131
+
case list.key_find(fields, "__schema") {
132
+
Ok(value.Object(schema_fields)) -> {
133
+
// Check for all five fields
134
+
let field_count = list.length(schema_fields)
135
+
// Should have exactly 5 fields
136
+
field_count == 5
137
+
}
138
+
_ -> False
139
+
}
140
+
}
141
+
_ -> False
142
+
}
143
+
}
144
+
|> should.be_true
145
+
}
146
+
147
+
/// Test: Field order doesn't matter
148
+
/// Verifies that field order in the query doesn't affect results
149
+
pub fn schema_field_order_test() {
150
+
let schema = test_schema()
151
+
let query1 = "{ __schema { types { name } queryType { name } } }"
152
+
let query2 = "{ __schema { queryType { name } types { name } } }"
153
+
154
+
let result1 = executor.execute(query1, schema, schema.context(None))
155
+
let result2 = executor.execute(query2, schema, schema.context(None))
156
+
157
+
// Both should succeed
158
+
should.be_ok(result1)
159
+
should.be_ok(result2)
160
+
161
+
// Both should have the same fields
162
+
case result1, result2 {
163
+
Ok(executor.Response(data: value.Object(fields1), errors: [])),
164
+
Ok(executor.Response(data: value.Object(fields2), errors: []))
165
+
-> {
166
+
case
167
+
list.key_find(fields1, "__schema"),
168
+
list.key_find(fields2, "__schema")
169
+
{
170
+
Ok(value.Object(schema_fields1)), Ok(value.Object(schema_fields2)) -> {
171
+
let count1 = list.length(schema_fields1)
172
+
let count2 = list.length(schema_fields2)
173
+
// Both should have 2 fields
174
+
count1 == 2 && count2 == 2
175
+
}
176
+
_, _ -> False
177
+
}
178
+
}
179
+
_, _ -> False
180
+
}
181
+
|> should.be_true
182
+
}
183
+
184
+
/// Test: Nested introspection on types
185
+
/// Verifies that nested field selections work correctly
186
+
pub fn schema_types_nested_fields_test() {
187
+
let schema = test_schema()
188
+
let query = "{ __schema { types { name kind fields { name } } } }"
189
+
190
+
let result = executor.execute(query, schema, schema.context(None))
191
+
192
+
should.be_ok(result)
193
+
|> fn(response) {
194
+
case response {
195
+
executor.Response(data: value.Object(fields), errors: []) -> {
196
+
case list.key_find(fields, "__schema") {
197
+
Ok(value.Object(schema_fields)) -> {
198
+
case list.key_find(schema_fields, "types") {
199
+
Ok(value.List(types)) -> {
200
+
// Check that each type has name, kind, and fields
201
+
list.all(types, fn(type_val) {
202
+
case type_val {
203
+
value.Object(type_fields) -> {
204
+
let has_name = case list.key_find(type_fields, "name") {
205
+
Ok(_) -> True
206
+
_ -> False
207
+
}
208
+
let has_kind = case list.key_find(type_fields, "kind") {
209
+
Ok(_) -> True
210
+
_ -> False
211
+
}
212
+
let has_fields = case
213
+
list.key_find(type_fields, "fields")
214
+
{
215
+
Ok(_) -> True
216
+
// Can be null or list
217
+
_ -> False
218
+
}
219
+
has_name && has_kind && has_fields
220
+
}
221
+
_ -> False
222
+
}
223
+
})
224
+
}
225
+
_ -> False
226
+
}
227
+
}
228
+
_ -> False
229
+
}
230
+
}
231
+
_ -> False
232
+
}
233
+
}
234
+
|> should.be_true
235
+
}
236
+
237
+
/// Test: Empty nested selections on null fields
238
+
/// Verifies that querying nested fields on null values doesn't cause errors
239
+
pub fn schema_null_field_with_deep_nesting_test() {
240
+
let schema = test_schema()
241
+
let query = "{ __schema { mutationType { name fields { name } } } }"
242
+
243
+
let result = executor.execute(query, schema, schema.context(None))
244
+
245
+
should.be_ok(result)
246
+
|> fn(response) {
247
+
case response {
248
+
executor.Response(data: value.Object(fields), errors: []) -> {
249
+
case list.key_find(fields, "__schema") {
250
+
Ok(value.Object(schema_fields)) -> {
251
+
case list.key_find(schema_fields, "mutationType") {
252
+
Ok(value.Null) -> True
253
+
// Should be null, not error
254
+
_ -> False
255
+
}
256
+
}
257
+
_ -> False
258
+
}
259
+
}
260
+
_ -> False
261
+
}
262
+
}
263
+
|> should.be_true
264
+
}
265
+
266
+
/// Test: Inline fragments in introspection
267
+
/// Verifies that inline fragments work correctly in introspection queries (like GraphiQL uses)
268
+
pub fn schema_inline_fragment_test() {
269
+
let schema = test_schema()
270
+
let query = "{ __schema { types { ... on __Type { kind name } } } }"
271
+
272
+
let result = executor.execute(query, schema, schema.context(None))
273
+
274
+
should.be_ok(result)
275
+
|> fn(response) {
276
+
case response {
277
+
executor.Response(data: value.Object(fields), errors: []) -> {
278
+
case list.key_find(fields, "__schema") {
279
+
Ok(value.Object(schema_fields)) -> {
280
+
case list.key_find(schema_fields, "types") {
281
+
Ok(value.List(types)) -> {
282
+
// Should have 6 types with kind and name fields
283
+
list.length(types) == 6
284
+
&& list.all(types, fn(type_val) {
285
+
case type_val {
286
+
value.Object(type_fields) -> {
287
+
let has_kind = case list.key_find(type_fields, "kind") {
288
+
Ok(value.String(_)) -> True
289
+
_ -> False
290
+
}
291
+
let has_name = case list.key_find(type_fields, "name") {
292
+
Ok(value.String(_)) -> True
293
+
_ -> False
294
+
}
295
+
has_kind && has_name
296
+
}
297
+
_ -> False
298
+
}
299
+
})
300
+
}
301
+
_ -> False
302
+
}
303
+
}
304
+
_ -> False
305
+
}
306
+
}
307
+
_ -> False
308
+
}
309
+
}
310
+
|> should.be_true
311
+
}
312
+
313
+
/// Test: Basic __type query
314
+
/// Verifies that __type(name: "TypeName") returns the correct type
315
+
pub fn type_basic_query_test() {
316
+
let schema = test_schema()
317
+
let query = "{ __type(name: \"Query\") { name kind } }"
318
+
319
+
let result = executor.execute(query, schema, schema.context(None))
320
+
321
+
should.be_ok(result)
322
+
|> fn(response) {
323
+
case response {
324
+
executor.Response(data: value.Object(fields), errors: []) -> {
325
+
case list.key_find(fields, "__type") {
326
+
Ok(value.Object(type_fields)) -> {
327
+
// Check name and kind
328
+
let has_correct_name = case list.key_find(type_fields, "name") {
329
+
Ok(value.String("Query")) -> True
330
+
_ -> False
331
+
}
332
+
let has_correct_kind = case list.key_find(type_fields, "kind") {
333
+
Ok(value.String("OBJECT")) -> True
334
+
_ -> False
335
+
}
336
+
has_correct_name && has_correct_kind
337
+
}
338
+
_ -> False
339
+
}
340
+
}
341
+
_ -> False
342
+
}
343
+
}
344
+
|> should.be_true
345
+
}
346
+
347
+
/// Test: __type query with nested fields
348
+
/// Verifies that nested selections work correctly on __type
349
+
pub fn type_nested_fields_test() {
350
+
let schema = test_schema()
351
+
let query =
352
+
"{ __type(name: \"Query\") { name kind fields { name type { name kind } } } }"
353
+
354
+
let result = executor.execute(query, schema, schema.context(None))
355
+
356
+
should.be_ok(result)
357
+
|> fn(response) {
358
+
case response {
359
+
executor.Response(data: value.Object(fields), errors: []) -> {
360
+
case list.key_find(fields, "__type") {
361
+
Ok(value.Object(type_fields)) -> {
362
+
// Check that fields exists and is a list
363
+
case list.key_find(type_fields, "fields") {
364
+
Ok(value.List(field_list)) -> {
365
+
// Should have 2 fields (hello and number)
366
+
list.length(field_list) == 2
367
+
&& list.all(field_list, fn(field_val) {
368
+
case field_val {
369
+
value.Object(field_fields) -> {
370
+
let has_name = case list.key_find(field_fields, "name") {
371
+
Ok(value.String(_)) -> True
372
+
_ -> False
373
+
}
374
+
let has_type = case list.key_find(field_fields, "type") {
375
+
Ok(value.Object(_)) -> True
376
+
_ -> False
377
+
}
378
+
has_name && has_type
379
+
}
380
+
_ -> False
381
+
}
382
+
})
383
+
}
384
+
_ -> False
385
+
}
386
+
}
387
+
_ -> False
388
+
}
389
+
}
390
+
_ -> False
391
+
}
392
+
}
393
+
|> should.be_true
394
+
}
395
+
396
+
/// Test: __type query for scalar types
397
+
/// Verifies that __type works for built-in scalar types
398
+
pub fn type_scalar_query_test() {
399
+
let schema = test_schema()
400
+
let query = "{ __type(name: \"String\") { name kind } }"
401
+
402
+
let result = executor.execute(query, schema, schema.context(None))
403
+
404
+
should.be_ok(result)
405
+
|> fn(response) {
406
+
case response {
407
+
executor.Response(data: value.Object(fields), errors: []) -> {
408
+
case list.key_find(fields, "__type") {
409
+
Ok(value.Object(type_fields)) -> {
410
+
// Check name and kind
411
+
let has_correct_name = case list.key_find(type_fields, "name") {
412
+
Ok(value.String("String")) -> True
413
+
_ -> False
414
+
}
415
+
let has_correct_kind = case list.key_find(type_fields, "kind") {
416
+
Ok(value.String("SCALAR")) -> True
417
+
_ -> False
418
+
}
419
+
has_correct_name && has_correct_kind
420
+
}
421
+
_ -> False
422
+
}
423
+
}
424
+
_ -> False
425
+
}
426
+
}
427
+
|> should.be_true
428
+
}
429
+
430
+
/// Test: __type query for non-existent type
431
+
/// Verifies that __type returns null for types that don't exist
432
+
pub fn type_not_found_test() {
433
+
let schema = test_schema()
434
+
let query = "{ __type(name: \"NonExistentType\") { name kind } }"
435
+
436
+
let result = executor.execute(query, schema, schema.context(None))
437
+
438
+
should.be_ok(result)
439
+
|> fn(response) {
440
+
case response {
441
+
executor.Response(data: value.Object(fields), errors: []) -> {
442
+
case list.key_find(fields, "__type") {
443
+
Ok(value.Null) -> True
444
+
_ -> False
445
+
}
446
+
}
447
+
_ -> False
448
+
}
449
+
}
450
+
|> should.be_true
451
+
}
452
+
453
+
/// Test: __type query without name argument
454
+
/// Verifies that __type returns an error when name argument is missing
455
+
pub fn type_missing_argument_test() {
456
+
let schema = test_schema()
457
+
let query = "{ __type { name kind } }"
458
+
459
+
let result = executor.execute(query, schema, schema.context(None))
460
+
461
+
should.be_ok(result)
462
+
|> fn(response) {
463
+
case response {
464
+
executor.Response(data: value.Object(fields), errors: errors) -> {
465
+
// Should have __type field as null
466
+
let has_null_type = case list.key_find(fields, "__type") {
467
+
Ok(value.Null) -> True
468
+
_ -> False
469
+
}
470
+
// Should have an error
471
+
let has_error = errors != []
472
+
has_null_type && has_error
473
+
}
474
+
_ -> False
475
+
}
476
+
}
477
+
|> should.be_true
478
+
}
479
+
480
+
/// Test: Combined __type and __schema query
481
+
/// Verifies that __type and __schema can be queried together
482
+
pub fn type_and_schema_combined_test() {
483
+
let schema = test_schema()
484
+
let query =
485
+
"{ __schema { queryType { name } } __type(name: \"String\") { name kind } }"
486
+
487
+
let result = executor.execute(query, schema, schema.context(None))
488
+
489
+
should.be_ok(result)
490
+
|> fn(response) {
491
+
case response {
492
+
executor.Response(data: value.Object(fields), errors: []) -> {
493
+
let has_schema = case list.key_find(fields, "__schema") {
494
+
Ok(value.Object(_)) -> True
495
+
_ -> False
496
+
}
497
+
let has_type = case list.key_find(fields, "__type") {
498
+
Ok(value.Object(_)) -> True
499
+
_ -> False
500
+
}
501
+
has_schema && has_type
502
+
}
503
+
_ -> False
504
+
}
505
+
}
506
+
|> should.be_true
507
+
}
508
+
509
+
/// Test: Deep introspection queries complete without hanging
510
+
/// This test verifies that the cycle detection prevents infinite loops
511
+
/// by successfully completing a deeply nested introspection query
512
+
pub fn deep_introspection_test() {
513
+
let schema = test_schema()
514
+
515
+
// Query with deep nesting including ofType chains
516
+
// Without cycle detection, this could cause infinite loops
517
+
let query =
518
+
"{ __schema { types { name kind fields { name type { name kind ofType { name kind ofType { name } } } } } } }"
519
+
520
+
let result = executor.execute(query, schema, schema.context(None))
521
+
522
+
// The key test: should complete without hanging
523
+
should.be_ok(result)
524
+
|> fn(response) {
525
+
case response {
526
+
executor.Response(data: value.Object(fields), errors: _errors) -> {
527
+
// Should have __schema field with types
528
+
case list.key_find(fields, "__schema") {
529
+
Ok(value.Object(schema_fields)) -> {
530
+
case list.key_find(schema_fields, "types") {
531
+
Ok(value.List(types)) -> types != []
532
+
_ -> False
533
+
}
534
+
}
535
+
_ -> False
536
+
}
537
+
}
538
+
_ -> False
539
+
}
540
+
}
541
+
|> should.be_true
542
+
}
543
+
544
+
/// Test: Fragment spreads work in introspection queries
545
+
/// Verifies that fragment spreads like those used by GraphiQL work correctly
546
+
pub fn introspection_fragment_spread_test() {
547
+
// Create a schema with an ENUM type
548
+
let sort_enum =
549
+
schema.enum_type("SortDirection", "Sort direction", [
550
+
schema.enum_value("ASC", "Ascending"),
551
+
schema.enum_value("DESC", "Descending"),
552
+
])
553
+
554
+
let query_type =
555
+
schema.object_type("Query", "Root query", [
556
+
schema.field("items", schema.list_type(schema.string_type()), "", fn(_) {
557
+
Ok(value.List([value.String("a"), value.String("b")]))
558
+
}),
559
+
schema.field("sort", sort_enum, "", fn(_) { Ok(value.String("ASC")) }),
560
+
])
561
+
562
+
let test_schema = schema.schema(query_type, None)
563
+
564
+
// Use a fragment spread like GraphiQL does
565
+
let query =
566
+
"
567
+
query IntrospectionQuery {
568
+
__schema {
569
+
types {
570
+
...FullType
571
+
}
572
+
}
573
+
}
574
+
575
+
fragment FullType on __Type {
576
+
kind
577
+
name
578
+
enumValues(includeDeprecated: true) {
579
+
name
580
+
description
581
+
}
582
+
}
583
+
"
584
+
585
+
let result = executor.execute(query, test_schema, schema.context(None))
586
+
587
+
should.be_ok(result)
588
+
|> fn(response) {
589
+
case response {
590
+
executor.Response(data: value.Object(fields), errors: _) -> {
591
+
case list.key_find(fields, "__schema") {
592
+
Ok(value.Object(schema_fields)) -> {
593
+
case list.key_find(schema_fields, "types") {
594
+
Ok(value.List(types)) -> {
595
+
// Find the SortDirection enum
596
+
let enum_type =
597
+
list.find(types, fn(t) {
598
+
case t {
599
+
value.Object(type_fields) -> {
600
+
case list.key_find(type_fields, "name") {
601
+
Ok(value.String("SortDirection")) -> True
602
+
_ -> False
603
+
}
604
+
}
605
+
_ -> False
606
+
}
607
+
})
608
+
609
+
case enum_type {
610
+
Ok(value.Object(type_fields)) -> {
611
+
// Should have kind field from fragment
612
+
let has_kind = case list.key_find(type_fields, "kind") {
613
+
Ok(value.String("ENUM")) -> True
614
+
_ -> False
615
+
}
616
+
617
+
// Should have enumValues field from fragment
618
+
let has_enum_values = case
619
+
list.key_find(type_fields, "enumValues")
620
+
{
621
+
Ok(value.List(values)) -> list.length(values) == 2
622
+
_ -> False
623
+
}
624
+
625
+
has_kind && has_enum_values
626
+
}
627
+
_ -> False
628
+
}
629
+
}
630
+
_ -> False
631
+
}
632
+
}
633
+
_ -> False
634
+
}
635
+
}
636
+
_ -> False
637
+
}
638
+
}
639
+
|> should.be_true
640
+
}
641
+
642
+
/// Test: Simple fragment on __type
643
+
pub fn simple_type_fragment_test() {
644
+
let schema = test_schema()
645
+
646
+
let query =
647
+
"{ __type(name: \"Query\") { ...TypeFrag } } fragment TypeFrag on __Type { name kind }"
648
+
649
+
let result = executor.execute(query, schema, schema.context(None))
650
+
651
+
should.be_ok(result)
652
+
|> fn(response) {
653
+
case response {
654
+
executor.Response(data: value.Object(fields), errors: _) -> {
655
+
case list.key_find(fields, "__type") {
656
+
Ok(value.Object(type_fields)) -> {
657
+
// Check if we got an error about fragment not found
658
+
case list.key_find(type_fields, "__FRAGMENT_ERROR") {
659
+
Ok(value.String(msg)) -> {
660
+
// Fragment wasn't found
661
+
panic as msg
662
+
}
663
+
_ -> {
664
+
// No error, check if we have actual fields
665
+
type_fields != []
666
+
}
667
+
}
668
+
}
669
+
_ -> False
670
+
}
671
+
}
672
+
_ -> False
673
+
}
674
+
}
675
+
|> should.be_true
676
+
}
+233
test/lexer_test.gleam
+233
test/lexer_test.gleam
···
1
+
/// Tests for GraphQL Lexer (tokenization)
2
+
///
3
+
/// GraphQL spec Section 2 - Language
4
+
/// Token types: Punctuator, Name, IntValue, FloatValue, StringValue
5
+
/// Ignored: Whitespace, LineTerminator, Comment, Comma
6
+
import gleeunit/should
7
+
import swell/lexer.{
8
+
BraceClose, BraceOpen, Colon, Dollar, Exclamation, Float, Int, Name,
9
+
ParenClose, ParenOpen, String,
10
+
}
11
+
12
+
// Punctuator tests
13
+
pub fn tokenize_brace_open_test() {
14
+
lexer.tokenize("{")
15
+
|> should.equal(Ok([BraceOpen]))
16
+
}
17
+
18
+
pub fn tokenize_brace_close_test() {
19
+
lexer.tokenize("}")
20
+
|> should.equal(Ok([BraceClose]))
21
+
}
22
+
23
+
pub fn tokenize_paren_open_test() {
24
+
lexer.tokenize("(")
25
+
|> should.equal(Ok([ParenOpen]))
26
+
}
27
+
28
+
pub fn tokenize_paren_close_test() {
29
+
lexer.tokenize(")")
30
+
|> should.equal(Ok([ParenClose]))
31
+
}
32
+
33
+
pub fn tokenize_colon_test() {
34
+
lexer.tokenize(":")
35
+
|> should.equal(Ok([Colon]))
36
+
}
37
+
38
+
pub fn tokenize_exclamation_test() {
39
+
lexer.tokenize("!")
40
+
|> should.equal(Ok([Exclamation]))
41
+
}
42
+
43
+
pub fn tokenize_dollar_test() {
44
+
lexer.tokenize("$")
45
+
|> should.equal(Ok([Dollar]))
46
+
}
47
+
48
+
// Name tests (identifiers)
49
+
pub fn tokenize_simple_name_test() {
50
+
lexer.tokenize("query")
51
+
|> should.equal(Ok([Name("query")]))
52
+
}
53
+
54
+
pub fn tokenize_name_with_underscore_test() {
55
+
lexer.tokenize("user_name")
56
+
|> should.equal(Ok([Name("user_name")]))
57
+
}
58
+
59
+
pub fn tokenize_name_with_numbers_test() {
60
+
lexer.tokenize("field123")
61
+
|> should.equal(Ok([Name("field123")]))
62
+
}
63
+
64
+
// Int value tests
65
+
pub fn tokenize_positive_int_test() {
66
+
lexer.tokenize("42")
67
+
|> should.equal(Ok([Int("42")]))
68
+
}
69
+
70
+
pub fn tokenize_negative_int_test() {
71
+
lexer.tokenize("-42")
72
+
|> should.equal(Ok([Int("-42")]))
73
+
}
74
+
75
+
pub fn tokenize_zero_test() {
76
+
lexer.tokenize("0")
77
+
|> should.equal(Ok([Int("0")]))
78
+
}
79
+
80
+
// Float value tests
81
+
pub fn tokenize_simple_float_test() {
82
+
lexer.tokenize("3.14")
83
+
|> should.equal(Ok([Float("3.14")]))
84
+
}
85
+
86
+
pub fn tokenize_negative_float_test() {
87
+
lexer.tokenize("-3.14")
88
+
|> should.equal(Ok([Float("-3.14")]))
89
+
}
90
+
91
+
pub fn tokenize_float_with_exponent_test() {
92
+
lexer.tokenize("1.5e10")
93
+
|> should.equal(Ok([Float("1.5e10")]))
94
+
}
95
+
96
+
pub fn tokenize_float_with_negative_exponent_test() {
97
+
lexer.tokenize("1.5e-10")
98
+
|> should.equal(Ok([Float("1.5e-10")]))
99
+
}
100
+
101
+
// String value tests
102
+
pub fn tokenize_empty_string_test() {
103
+
lexer.tokenize("\"\"")
104
+
|> should.equal(Ok([String("")]))
105
+
}
106
+
107
+
pub fn tokenize_simple_string_test() {
108
+
lexer.tokenize("\"hello\"")
109
+
|> should.equal(Ok([String("hello")]))
110
+
}
111
+
112
+
pub fn tokenize_string_with_spaces_test() {
113
+
lexer.tokenize("\"hello world\"")
114
+
|> should.equal(Ok([String("hello world")]))
115
+
}
116
+
117
+
pub fn tokenize_string_with_escape_test() {
118
+
lexer.tokenize("\"hello\\nworld\"")
119
+
|> should.equal(Ok([String("hello\nworld")]))
120
+
}
121
+
122
+
// Whitespace handling (should be filtered out by default)
123
+
pub fn tokenize_with_spaces_test() {
124
+
lexer.tokenize("query user")
125
+
|> should.equal(Ok([Name("query"), Name("user")]))
126
+
}
127
+
128
+
pub fn tokenize_with_tabs_test() {
129
+
lexer.tokenize("query\tuser")
130
+
|> should.equal(Ok([Name("query"), Name("user")]))
131
+
}
132
+
133
+
pub fn tokenize_with_newlines_test() {
134
+
lexer.tokenize("query\nuser")
135
+
|> should.equal(Ok([Name("query"), Name("user")]))
136
+
}
137
+
138
+
// Comment tests (should be filtered out)
139
+
pub fn tokenize_with_comment_test() {
140
+
lexer.tokenize("query # this is a comment\nuser")
141
+
|> should.equal(Ok([Name("query"), Name("user")]))
142
+
}
143
+
144
+
// Complex query tests
145
+
pub fn tokenize_simple_query_test() {
146
+
lexer.tokenize("{ user }")
147
+
|> should.equal(Ok([BraceOpen, Name("user"), BraceClose]))
148
+
}
149
+
150
+
pub fn tokenize_query_with_field_test() {
151
+
lexer.tokenize("{ user { name } }")
152
+
|> should.equal(
153
+
Ok([
154
+
BraceOpen,
155
+
Name("user"),
156
+
BraceOpen,
157
+
Name("name"),
158
+
BraceClose,
159
+
BraceClose,
160
+
]),
161
+
)
162
+
}
163
+
164
+
pub fn tokenize_query_with_argument_test() {
165
+
lexer.tokenize("{ user(id: 42) }")
166
+
|> should.equal(
167
+
Ok([
168
+
BraceOpen,
169
+
Name("user"),
170
+
ParenOpen,
171
+
Name("id"),
172
+
Colon,
173
+
Int("42"),
174
+
ParenClose,
175
+
BraceClose,
176
+
]),
177
+
)
178
+
}
179
+
180
+
pub fn tokenize_query_with_string_argument_test() {
181
+
lexer.tokenize("{ user(name: \"Alice\") }")
182
+
|> should.equal(
183
+
Ok([
184
+
BraceOpen,
185
+
Name("user"),
186
+
ParenOpen,
187
+
Name("name"),
188
+
Colon,
189
+
String("Alice"),
190
+
ParenClose,
191
+
BraceClose,
192
+
]),
193
+
)
194
+
}
195
+
196
+
// Variable definition tests
197
+
pub fn tokenize_variable_definition_test() {
198
+
lexer.tokenize("$name: String!")
199
+
|> should.equal(
200
+
Ok([Dollar, Name("name"), Colon, Name("String"), Exclamation]),
201
+
)
202
+
}
203
+
204
+
pub fn tokenize_variable_in_query_test() {
205
+
lexer.tokenize("query Test($id: Int!) { user }")
206
+
|> should.equal(
207
+
Ok([
208
+
Name("query"),
209
+
Name("Test"),
210
+
ParenOpen,
211
+
Dollar,
212
+
Name("id"),
213
+
Colon,
214
+
Name("Int"),
215
+
Exclamation,
216
+
ParenClose,
217
+
BraceOpen,
218
+
Name("user"),
219
+
BraceClose,
220
+
]),
221
+
)
222
+
}
223
+
224
+
// Error cases - use a truly invalid character like backslash
225
+
pub fn tokenize_invalid_character_test() {
226
+
lexer.tokenize("query \\invalid")
227
+
|> should.be_error()
228
+
}
229
+
230
+
pub fn tokenize_unclosed_string_test() {
231
+
lexer.tokenize("\"unclosed")
232
+
|> should.be_error()
233
+
}
+171
test/mutation_execution_test.gleam
+171
test/mutation_execution_test.gleam
···
1
+
/// Tests for mutation execution
2
+
import birdie
3
+
import gleam/list
4
+
import gleam/option.{None, Some}
5
+
import gleam/string
6
+
import gleeunit
7
+
import gleeunit/should
8
+
import swell/executor
9
+
import swell/schema
10
+
import swell/value
11
+
12
+
pub fn main() {
13
+
gleeunit.main()
14
+
}
15
+
16
+
fn format_response(response: executor.Response) -> String {
17
+
string.inspect(response)
18
+
}
19
+
20
+
fn test_schema_with_mutations() -> schema.Schema {
21
+
let user_type =
22
+
schema.object_type("User", "A user", [
23
+
schema.field("id", schema.id_type(), "User ID", fn(ctx) {
24
+
case ctx.data {
25
+
Some(value.Object(fields)) -> {
26
+
case fields |> list.key_find("id") {
27
+
Ok(id) -> Ok(id)
28
+
Error(_) -> Ok(value.String("123"))
29
+
}
30
+
}
31
+
_ -> Ok(value.String("123"))
32
+
}
33
+
}),
34
+
schema.field("name", schema.string_type(), "User name", fn(ctx) {
35
+
case ctx.data {
36
+
Some(value.Object(fields)) -> {
37
+
case fields |> list.key_find("name") {
38
+
Ok(name) -> Ok(name)
39
+
Error(_) -> Ok(value.String("Unknown"))
40
+
}
41
+
}
42
+
_ -> Ok(value.String("Unknown"))
43
+
}
44
+
}),
45
+
])
46
+
47
+
let query_type =
48
+
schema.object_type("Query", "Root query", [
49
+
schema.field("dummy", schema.string_type(), "Dummy field", fn(_) {
50
+
Ok(value.String("dummy"))
51
+
}),
52
+
])
53
+
54
+
let mutation_type =
55
+
schema.object_type("Mutation", "Root mutation", [
56
+
schema.field_with_args(
57
+
"createUser",
58
+
user_type,
59
+
"Create a user",
60
+
[schema.argument("name", schema.string_type(), "User name", None)],
61
+
fn(ctx) {
62
+
case schema.get_argument(ctx, "name") {
63
+
Some(value.String(name)) ->
64
+
Ok(
65
+
value.Object([
66
+
#("id", value.String("123")),
67
+
#("name", value.String(name)),
68
+
]),
69
+
)
70
+
_ ->
71
+
Ok(
72
+
value.Object([
73
+
#("id", value.String("123")),
74
+
#("name", value.String("Default Name")),
75
+
]),
76
+
)
77
+
}
78
+
},
79
+
),
80
+
schema.field_with_args(
81
+
"deleteUser",
82
+
schema.boolean_type(),
83
+
"Delete a user",
84
+
[
85
+
schema.argument(
86
+
"id",
87
+
schema.non_null(schema.id_type()),
88
+
"User ID",
89
+
None,
90
+
),
91
+
],
92
+
fn(_) { Ok(value.Boolean(True)) },
93
+
),
94
+
])
95
+
96
+
schema.schema(query_type, Some(mutation_type))
97
+
}
98
+
99
+
pub fn execute_simple_mutation_test() {
100
+
let schema = test_schema_with_mutations()
101
+
let query = "mutation { createUser(name: \"Alice\") { id name } }"
102
+
103
+
let result = executor.execute(query, schema, schema.context(None))
104
+
105
+
let response = case result {
106
+
Ok(r) -> r
107
+
Error(_) -> panic as "Execution failed"
108
+
}
109
+
110
+
birdie.snap(
111
+
title: "Execute simple mutation",
112
+
content: format_response(response),
113
+
)
114
+
}
115
+
116
+
pub fn execute_named_mutation_test() {
117
+
let schema = test_schema_with_mutations()
118
+
let query = "mutation CreateUser { createUser(name: \"Bob\") { id name } }"
119
+
120
+
let result = executor.execute(query, schema, schema.context(None))
121
+
122
+
should.be_ok(result)
123
+
}
124
+
125
+
pub fn execute_multiple_mutations_test() {
126
+
let schema = test_schema_with_mutations()
127
+
let query =
128
+
"
129
+
mutation {
130
+
createUser(name: \"Alice\") { id name }
131
+
deleteUser(id: \"123\")
132
+
}
133
+
"
134
+
135
+
let result = executor.execute(query, schema, schema.context(None))
136
+
137
+
let response = case result {
138
+
Ok(r) -> r
139
+
Error(_) -> panic as "Execution failed"
140
+
}
141
+
142
+
birdie.snap(
143
+
title: "Execute multiple mutations",
144
+
content: format_response(response),
145
+
)
146
+
}
147
+
148
+
pub fn execute_mutation_without_argument_test() {
149
+
let schema = test_schema_with_mutations()
150
+
let query = "mutation { createUser { id name } }"
151
+
152
+
let result = executor.execute(query, schema, schema.context(None))
153
+
154
+
should.be_ok(result)
155
+
}
156
+
157
+
pub fn execute_mutation_with_context_test() {
158
+
let schema = test_schema_with_mutations()
159
+
let query = "mutation { createUser(name: \"Context User\") { id name } }"
160
+
161
+
let ctx_data =
162
+
value.Object([
163
+
#("userId", value.String("456")),
164
+
#("token", value.String("abc123")),
165
+
])
166
+
let ctx = schema.context(Some(ctx_data))
167
+
168
+
let result = executor.execute(query, schema, ctx)
169
+
170
+
should.be_ok(result)
171
+
}
+105
test/mutation_parser_test.gleam
+105
test/mutation_parser_test.gleam
···
1
+
/// Snapshot tests for mutation parsing
2
+
import birdie
3
+
import gleam/string
4
+
import gleeunit
5
+
import swell/parser
6
+
7
+
pub fn main() {
8
+
gleeunit.main()
9
+
}
10
+
11
+
// Helper to format AST as string for snapshots
12
+
fn format_ast(doc: parser.Document) -> String {
13
+
string.inspect(doc)
14
+
}
15
+
16
+
pub fn parse_simple_anonymous_mutation_test() {
17
+
let query = "mutation { createUser(name: \"Alice\") { id name } }"
18
+
19
+
let doc = case parser.parse(query) {
20
+
Ok(d) -> d
21
+
Error(_) -> panic as "Parse failed"
22
+
}
23
+
24
+
birdie.snap(title: "Simple anonymous mutation", content: format_ast(doc))
25
+
}
26
+
27
+
pub fn parse_named_mutation_test() {
28
+
let query = "mutation CreateUser { createUser(name: \"Alice\") { id name } }"
29
+
30
+
let doc = case parser.parse(query) {
31
+
Ok(d) -> d
32
+
Error(_) -> panic as "Parse failed"
33
+
}
34
+
35
+
birdie.snap(title: "Named mutation", content: format_ast(doc))
36
+
}
37
+
38
+
pub fn parse_mutation_with_input_object_test() {
39
+
let query =
40
+
"
41
+
mutation {
42
+
createUser(input: { name: \"Alice\", email: \"alice@example.com\", age: 30 }) {
43
+
id
44
+
name
45
+
email
46
+
}
47
+
}
48
+
"
49
+
50
+
let doc = case parser.parse(query) {
51
+
Ok(d) -> d
52
+
Error(_) -> panic as "Parse failed"
53
+
}
54
+
55
+
birdie.snap(
56
+
title: "Parse mutation with input object argument",
57
+
content: format_ast(doc),
58
+
)
59
+
}
60
+
61
+
pub fn parse_multiple_mutations_test() {
62
+
let query =
63
+
"
64
+
mutation {
65
+
createUser(name: \"Alice\") { id }
66
+
deleteUser(id: \"123\") { success }
67
+
}
68
+
"
69
+
70
+
let doc = case parser.parse(query) {
71
+
Ok(d) -> d
72
+
Error(_) -> panic as "Parse failed"
73
+
}
74
+
75
+
birdie.snap(
76
+
title: "Multiple mutations in one operation",
77
+
content: format_ast(doc),
78
+
)
79
+
}
80
+
81
+
pub fn parse_mutation_with_nested_selections_test() {
82
+
let query =
83
+
"
84
+
mutation {
85
+
createPost(input: { title: \"Hello\" }) {
86
+
id
87
+
author {
88
+
id
89
+
name
90
+
}
91
+
tags
92
+
}
93
+
}
94
+
"
95
+
96
+
let doc = case parser.parse(query) {
97
+
Ok(d) -> d
98
+
Error(_) -> panic as "Parse failed"
99
+
}
100
+
101
+
birdie.snap(
102
+
title: "Mutation with nested selections",
103
+
content: format_ast(doc),
104
+
)
105
+
}
+214
test/mutation_sdl_test.gleam
+214
test/mutation_sdl_test.gleam
···
1
+
/// Snapshot tests for mutation SDL generation
2
+
import birdie
3
+
import gleam/option.{None}
4
+
import gleeunit
5
+
import swell/schema
6
+
import swell/sdl
7
+
import swell/value
8
+
9
+
pub fn main() {
10
+
gleeunit.main()
11
+
}
12
+
13
+
pub fn simple_mutation_type_test() {
14
+
let user_type =
15
+
schema.object_type("User", "A user", [
16
+
schema.field("id", schema.non_null(schema.id_type()), "User ID", fn(_) {
17
+
Ok(value.String("1"))
18
+
}),
19
+
schema.field("name", schema.string_type(), "User name", fn(_) {
20
+
Ok(value.String("Alice"))
21
+
}),
22
+
])
23
+
24
+
let mutation_type =
25
+
schema.object_type("Mutation", "Root mutation type", [
26
+
schema.field_with_args(
27
+
"createUser",
28
+
user_type,
29
+
"Create a new user",
30
+
[
31
+
schema.argument(
32
+
"name",
33
+
schema.non_null(schema.string_type()),
34
+
"User name",
35
+
None,
36
+
),
37
+
],
38
+
fn(_) { Ok(value.Null) },
39
+
),
40
+
])
41
+
42
+
let serialized = sdl.print_type(mutation_type)
43
+
44
+
birdie.snap(title: "Simple mutation type", content: serialized)
45
+
}
46
+
47
+
pub fn mutation_with_input_object_test() {
48
+
let create_user_input =
49
+
schema.input_object_type("CreateUserInput", "Input for creating a user", [
50
+
schema.input_field(
51
+
"name",
52
+
schema.non_null(schema.string_type()),
53
+
"User name",
54
+
None,
55
+
),
56
+
schema.input_field(
57
+
"email",
58
+
schema.non_null(schema.string_type()),
59
+
"Email address",
60
+
None,
61
+
),
62
+
schema.input_field("age", schema.int_type(), "Age", None),
63
+
])
64
+
65
+
let user_type =
66
+
schema.object_type("User", "A user", [
67
+
schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }),
68
+
schema.field("name", schema.string_type(), "User name", fn(_) {
69
+
Ok(value.Null)
70
+
}),
71
+
])
72
+
73
+
let mutation_type =
74
+
schema.object_type("Mutation", "Mutations", [
75
+
schema.field_with_args(
76
+
"createUser",
77
+
user_type,
78
+
"Create a new user",
79
+
[
80
+
schema.argument(
81
+
"input",
82
+
schema.non_null(create_user_input),
83
+
"User data",
84
+
None,
85
+
),
86
+
],
87
+
fn(_) { Ok(value.Null) },
88
+
),
89
+
])
90
+
91
+
let serialized =
92
+
sdl.print_types([create_user_input, user_type, mutation_type])
93
+
94
+
birdie.snap(title: "Mutation with input object argument", content: serialized)
95
+
}
96
+
97
+
pub fn multiple_mutations_test() {
98
+
let user_type =
99
+
schema.object_type("User", "A user", [
100
+
schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }),
101
+
])
102
+
103
+
let delete_response =
104
+
schema.object_type("DeleteResponse", "Delete response", [
105
+
schema.field("success", schema.boolean_type(), "Success flag", fn(_) {
106
+
Ok(value.Null)
107
+
}),
108
+
])
109
+
110
+
let mutation_type =
111
+
schema.object_type("Mutation", "Mutations", [
112
+
schema.field_with_args(
113
+
"createUser",
114
+
user_type,
115
+
"Create a user",
116
+
[schema.argument("name", schema.string_type(), "Name", None)],
117
+
fn(_) { Ok(value.Null) },
118
+
),
119
+
schema.field_with_args(
120
+
"updateUser",
121
+
user_type,
122
+
"Update a user",
123
+
[
124
+
schema.argument(
125
+
"id",
126
+
schema.non_null(schema.id_type()),
127
+
"User ID",
128
+
None,
129
+
),
130
+
schema.argument("name", schema.string_type(), "New name", None),
131
+
],
132
+
fn(_) { Ok(value.Null) },
133
+
),
134
+
schema.field_with_args(
135
+
"deleteUser",
136
+
delete_response,
137
+
"Delete a user",
138
+
[
139
+
schema.argument(
140
+
"id",
141
+
schema.non_null(schema.id_type()),
142
+
"User ID",
143
+
None,
144
+
),
145
+
],
146
+
fn(_) { Ok(value.Null) },
147
+
),
148
+
])
149
+
150
+
let serialized = sdl.print_type(mutation_type)
151
+
152
+
birdie.snap(
153
+
title: "Multiple mutations (CRUD operations)",
154
+
content: serialized,
155
+
)
156
+
}
157
+
158
+
pub fn mutation_returning_list_test() {
159
+
let user_type =
160
+
schema.object_type("User", "A user", [
161
+
schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }),
162
+
])
163
+
164
+
let mutation_type =
165
+
schema.object_type("Mutation", "Mutations", [
166
+
schema.field_with_args(
167
+
"createUsers",
168
+
schema.list_type(user_type),
169
+
"Create multiple users",
170
+
[
171
+
schema.argument(
172
+
"names",
173
+
schema.list_type(schema.non_null(schema.string_type())),
174
+
"User names",
175
+
None,
176
+
),
177
+
],
178
+
fn(_) { Ok(value.Null) },
179
+
),
180
+
])
181
+
182
+
let serialized = sdl.print_type(mutation_type)
183
+
184
+
birdie.snap(title: "Mutation returning list", content: serialized)
185
+
}
186
+
187
+
pub fn mutation_with_non_null_return_test() {
188
+
let user_type =
189
+
schema.object_type("User", "A user", [
190
+
schema.field("id", schema.id_type(), "User ID", fn(_) { Ok(value.Null) }),
191
+
])
192
+
193
+
let mutation_type =
194
+
schema.object_type("Mutation", "Mutations", [
195
+
schema.field_with_args(
196
+
"createUser",
197
+
schema.non_null(user_type),
198
+
"Create a user (guaranteed to return)",
199
+
[
200
+
schema.argument(
201
+
"name",
202
+
schema.non_null(schema.string_type()),
203
+
"User name",
204
+
None,
205
+
),
206
+
],
207
+
fn(_) { Ok(value.Null) },
208
+
),
209
+
])
210
+
211
+
let serialized = sdl.print_type(mutation_type)
212
+
213
+
birdie.snap(title: "Mutation with non-null return type", content: serialized)
214
+
}
+640
test/parser_test.gleam
+640
test/parser_test.gleam
···
1
+
/// Tests for GraphQL Parser (AST building)
2
+
///
3
+
/// GraphQL spec Section 2 - Language
4
+
/// Parse tokens into Abstract Syntax Tree
5
+
import gleam/list
6
+
import gleam/option.{None}
7
+
import gleeunit/should
8
+
import swell/parser
9
+
10
+
// Simple query tests
11
+
pub fn parse_empty_query_test() {
12
+
"{ }"
13
+
|> parser.parse
14
+
|> should.be_ok
15
+
}
16
+
17
+
pub fn parse_anonymous_query_with_keyword_test() {
18
+
"query { user }"
19
+
|> parser.parse
20
+
|> should.be_ok
21
+
|> fn(doc) {
22
+
case doc {
23
+
parser.Document([
24
+
parser.Query(parser.SelectionSet([parser.Field("user", None, [], [])])),
25
+
]) -> True
26
+
_ -> False
27
+
}
28
+
}
29
+
|> should.be_true
30
+
}
31
+
32
+
pub fn parse_single_field_test() {
33
+
"{ user }"
34
+
|> parser.parse
35
+
|> should.be_ok
36
+
|> fn(doc) {
37
+
case doc {
38
+
parser.Document([
39
+
parser.Query(parser.SelectionSet([
40
+
parser.Field(name: "user", alias: None, arguments: [], selections: []),
41
+
])),
42
+
]) -> True
43
+
_ -> False
44
+
}
45
+
}
46
+
|> should.be_true
47
+
}
48
+
49
+
pub fn parse_nested_fields_test() {
50
+
"{ user { name } }"
51
+
|> parser.parse
52
+
|> should.be_ok
53
+
|> fn(doc) {
54
+
case doc {
55
+
parser.Document([
56
+
parser.Query(parser.SelectionSet([
57
+
parser.Field(
58
+
name: "user",
59
+
alias: None,
60
+
arguments: [],
61
+
selections: [parser.Field("name", None, [], [])],
62
+
),
63
+
])),
64
+
]) -> True
65
+
_ -> False
66
+
}
67
+
}
68
+
|> should.be_true
69
+
}
70
+
71
+
pub fn parse_multiple_fields_test() {
72
+
"{ user posts }"
73
+
|> parser.parse
74
+
|> should.be_ok
75
+
|> fn(doc) {
76
+
case doc {
77
+
parser.Document([
78
+
parser.Query(parser.SelectionSet([
79
+
parser.Field(name: "user", alias: None, arguments: [], selections: []),
80
+
parser.Field(
81
+
name: "posts",
82
+
alias: None,
83
+
arguments: [],
84
+
selections: [],
85
+
),
86
+
])),
87
+
]) -> True
88
+
_ -> False
89
+
}
90
+
}
91
+
|> should.be_true
92
+
}
93
+
94
+
// Arguments tests
95
+
pub fn parse_field_with_int_argument_test() {
96
+
"{ user(id: 42) }"
97
+
|> parser.parse
98
+
|> should.be_ok
99
+
|> fn(doc) {
100
+
case doc {
101
+
parser.Document([
102
+
parser.Query(parser.SelectionSet([
103
+
parser.Field(
104
+
name: "user",
105
+
alias: None,
106
+
arguments: [parser.Argument("id", parser.IntValue("42"))],
107
+
selections: [],
108
+
),
109
+
])),
110
+
]) -> True
111
+
_ -> False
112
+
}
113
+
}
114
+
|> should.be_true
115
+
}
116
+
117
+
pub fn parse_field_with_string_argument_test() {
118
+
"{ user(name: \"Alice\") }"
119
+
|> parser.parse
120
+
|> should.be_ok
121
+
|> fn(doc) {
122
+
case doc {
123
+
parser.Document([
124
+
parser.Query(parser.SelectionSet([
125
+
parser.Field(
126
+
name: "user",
127
+
alias: None,
128
+
arguments: [parser.Argument("name", parser.StringValue("Alice"))],
129
+
selections: [],
130
+
),
131
+
])),
132
+
]) -> True
133
+
_ -> False
134
+
}
135
+
}
136
+
|> should.be_true
137
+
}
138
+
139
+
pub fn parse_field_with_multiple_arguments_test() {
140
+
"{ user(id: 42, name: \"Alice\") }"
141
+
|> parser.parse
142
+
|> should.be_ok
143
+
|> fn(doc) {
144
+
case doc {
145
+
parser.Document([
146
+
parser.Query(parser.SelectionSet([
147
+
parser.Field(
148
+
name: "user",
149
+
alias: None,
150
+
arguments: [
151
+
parser.Argument("id", parser.IntValue("42")),
152
+
parser.Argument("name", parser.StringValue("Alice")),
153
+
],
154
+
selections: [],
155
+
),
156
+
])),
157
+
]) -> True
158
+
_ -> False
159
+
}
160
+
}
161
+
|> should.be_true
162
+
}
163
+
164
+
// Named operation tests
165
+
pub fn parse_named_query_test() {
166
+
"query GetUser { user }"
167
+
|> parser.parse
168
+
|> should.be_ok
169
+
|> fn(doc) {
170
+
case doc {
171
+
parser.Document([
172
+
parser.NamedQuery(
173
+
name: "GetUser",
174
+
variables: [],
175
+
selections: parser.SelectionSet([parser.Field("user", None, [], [])]),
176
+
),
177
+
]) -> True
178
+
_ -> False
179
+
}
180
+
}
181
+
|> should.be_true
182
+
}
183
+
184
+
// Complex query test
185
+
pub fn parse_complex_query_test() {
186
+
"
187
+
query GetUserPosts {
188
+
user(id: 1) {
189
+
name
190
+
posts {
191
+
title
192
+
content
193
+
}
194
+
}
195
+
}
196
+
"
197
+
|> parser.parse
198
+
|> should.be_ok
199
+
}
200
+
201
+
// Error cases
202
+
pub fn parse_invalid_syntax_test() {
203
+
"{ user"
204
+
|> parser.parse
205
+
|> should.be_error
206
+
}
207
+
208
+
pub fn parse_empty_string_test() {
209
+
""
210
+
|> parser.parse
211
+
|> should.be_error
212
+
}
213
+
214
+
pub fn parse_invalid_field_name_test() {
215
+
"{ 123 }"
216
+
|> parser.parse
217
+
|> should.be_error
218
+
}
219
+
220
+
// Fragment tests
221
+
pub fn parse_fragment_definition_test() {
222
+
"
223
+
fragment UserFields on User {
224
+
id
225
+
name
226
+
}
227
+
{ user { ...UserFields } }
228
+
"
229
+
|> parser.parse
230
+
|> should.be_ok
231
+
|> fn(doc) {
232
+
case doc {
233
+
parser.Document([
234
+
parser.FragmentDefinition(
235
+
name: "UserFields",
236
+
type_condition: "User",
237
+
selections: parser.SelectionSet([
238
+
parser.Field("id", None, [], []),
239
+
parser.Field("name", None, [], []),
240
+
]),
241
+
),
242
+
parser.Query(parser.SelectionSet([
243
+
parser.Field(
244
+
name: "user",
245
+
alias: None,
246
+
arguments: [],
247
+
selections: [parser.FragmentSpread("UserFields")],
248
+
),
249
+
])),
250
+
]) -> True
251
+
_ -> False
252
+
}
253
+
}
254
+
|> should.be_true
255
+
}
256
+
257
+
pub fn parse_fragment_single_line_test() {
258
+
// The multiline version works - let's try it
259
+
"
260
+
{ __type(name: \"Query\") { ...TypeFrag } }
261
+
fragment TypeFrag on __Type { name kind }
262
+
"
263
+
|> parser.parse
264
+
|> should.be_ok
265
+
|> fn(doc) {
266
+
case doc {
267
+
parser.Document(operations) -> list.length(operations) == 2
268
+
}
269
+
}
270
+
|> should.be_true
271
+
}
272
+
273
+
pub fn parse_fragment_truly_single_line_test() {
274
+
// This is the problematic single-line version
275
+
"{ __type(name: \"Query\") { ...TypeFrag } } fragment TypeFrag on __Type { name kind }"
276
+
|> parser.parse
277
+
|> should.be_ok
278
+
|> fn(doc) {
279
+
case doc {
280
+
parser.Document(operations) -> {
281
+
// If we only got 1 operation, the parser stopped after the query
282
+
case operations {
283
+
[parser.Query(_)] ->
284
+
panic as "Only got Query - fragment was not parsed"
285
+
_ -> list.length(operations) == 2
286
+
}
287
+
}
288
+
}
289
+
}
290
+
|> should.be_true
291
+
}
292
+
293
+
pub fn parse_inline_fragment_test() {
294
+
"
295
+
{ user { ... on User { name } } }
296
+
"
297
+
|> parser.parse
298
+
|> should.be_ok
299
+
}
300
+
301
+
// List value tests
302
+
pub fn parse_empty_list_argument_test() {
303
+
"{ user(tags: []) }"
304
+
|> parser.parse
305
+
|> should.be_ok
306
+
|> fn(doc) {
307
+
case doc {
308
+
parser.Document([
309
+
parser.Query(parser.SelectionSet([
310
+
parser.Field(
311
+
name: "user",
312
+
alias: None,
313
+
arguments: [parser.Argument("tags", parser.ListValue([]))],
314
+
selections: [],
315
+
),
316
+
])),
317
+
]) -> True
318
+
_ -> False
319
+
}
320
+
}
321
+
|> should.be_true
322
+
}
323
+
324
+
pub fn parse_list_of_ints_test() {
325
+
"{ user(ids: [1, 2, 3]) }"
326
+
|> parser.parse
327
+
|> should.be_ok
328
+
|> fn(doc) {
329
+
case doc {
330
+
parser.Document([
331
+
parser.Query(parser.SelectionSet([
332
+
parser.Field(
333
+
name: "user",
334
+
alias: None,
335
+
arguments: [
336
+
parser.Argument(
337
+
"ids",
338
+
parser.ListValue([
339
+
parser.IntValue("1"),
340
+
parser.IntValue("2"),
341
+
parser.IntValue("3"),
342
+
]),
343
+
),
344
+
],
345
+
selections: [],
346
+
),
347
+
])),
348
+
]) -> True
349
+
_ -> False
350
+
}
351
+
}
352
+
|> should.be_true
353
+
}
354
+
355
+
pub fn parse_list_of_strings_test() {
356
+
"{ user(tags: [\"foo\", \"bar\"]) }"
357
+
|> parser.parse
358
+
|> should.be_ok
359
+
|> fn(doc) {
360
+
case doc {
361
+
parser.Document([
362
+
parser.Query(parser.SelectionSet([
363
+
parser.Field(
364
+
name: "user",
365
+
alias: None,
366
+
arguments: [
367
+
parser.Argument(
368
+
"tags",
369
+
parser.ListValue([
370
+
parser.StringValue("foo"),
371
+
parser.StringValue("bar"),
372
+
]),
373
+
),
374
+
],
375
+
selections: [],
376
+
),
377
+
])),
378
+
]) -> True
379
+
_ -> False
380
+
}
381
+
}
382
+
|> should.be_true
383
+
}
384
+
385
+
// Object value tests
386
+
pub fn parse_empty_object_argument_test() {
387
+
"{ user(filter: {}) }"
388
+
|> parser.parse
389
+
|> should.be_ok
390
+
|> fn(doc) {
391
+
case doc {
392
+
parser.Document([
393
+
parser.Query(parser.SelectionSet([
394
+
parser.Field(
395
+
name: "user",
396
+
alias: None,
397
+
arguments: [parser.Argument("filter", parser.ObjectValue([]))],
398
+
selections: [],
399
+
),
400
+
])),
401
+
]) -> True
402
+
_ -> False
403
+
}
404
+
}
405
+
|> should.be_true
406
+
}
407
+
408
+
pub fn parse_object_with_fields_test() {
409
+
"{ user(filter: {name: \"Alice\", age: 30}) }"
410
+
|> parser.parse
411
+
|> should.be_ok
412
+
|> fn(doc) {
413
+
case doc {
414
+
parser.Document([
415
+
parser.Query(parser.SelectionSet([
416
+
parser.Field(
417
+
name: "user",
418
+
alias: None,
419
+
arguments: [
420
+
parser.Argument(
421
+
"filter",
422
+
parser.ObjectValue([
423
+
#("name", parser.StringValue("Alice")),
424
+
#("age", parser.IntValue("30")),
425
+
]),
426
+
),
427
+
],
428
+
selections: [],
429
+
),
430
+
])),
431
+
]) -> True
432
+
_ -> False
433
+
}
434
+
}
435
+
|> should.be_true
436
+
}
437
+
438
+
// Nested structures
439
+
pub fn parse_list_of_objects_test() {
440
+
"{ posts(sortBy: [{field: \"date\", direction: DESC}]) }"
441
+
|> parser.parse
442
+
|> should.be_ok
443
+
|> fn(doc) {
444
+
case doc {
445
+
parser.Document([
446
+
parser.Query(parser.SelectionSet([
447
+
parser.Field(
448
+
name: "posts",
449
+
alias: None,
450
+
arguments: [
451
+
parser.Argument(
452
+
"sortBy",
453
+
parser.ListValue([
454
+
parser.ObjectValue([
455
+
#("field", parser.StringValue("date")),
456
+
#("direction", parser.EnumValue("DESC")),
457
+
]),
458
+
]),
459
+
),
460
+
],
461
+
selections: [],
462
+
),
463
+
])),
464
+
]) -> True
465
+
_ -> False
466
+
}
467
+
}
468
+
|> should.be_true
469
+
}
470
+
471
+
pub fn parse_object_with_nested_list_test() {
472
+
"{ user(filter: {tags: [\"a\", \"b\"]}) }"
473
+
|> parser.parse
474
+
|> should.be_ok
475
+
}
476
+
477
+
// Variable definition tests
478
+
pub fn parse_query_with_one_variable_test() {
479
+
"query Test($name: String!) { user }"
480
+
|> parser.parse
481
+
|> should.be_ok
482
+
|> fn(doc) {
483
+
case doc {
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
491
+
_ -> False
492
+
}
493
+
}
494
+
|> should.be_true
495
+
}
496
+
497
+
pub fn parse_query_with_multiple_variables_test() {
498
+
"query Test($name: String!, $age: Int) { user }"
499
+
|> parser.parse
500
+
|> should.be_ok
501
+
|> fn(doc) {
502
+
case doc {
503
+
parser.Document([
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
+
),
512
+
]) -> True
513
+
_ -> False
514
+
}
515
+
}
516
+
|> should.be_true
517
+
}
518
+
519
+
pub fn parse_mutation_with_variables_test() {
520
+
"mutation CreateUser($name: String!, $email: String!) { createUser }"
521
+
|> parser.parse
522
+
|> should.be_ok
523
+
|> fn(doc) {
524
+
case doc {
525
+
parser.Document([
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, [], []),
534
+
]),
535
+
),
536
+
]) -> True
537
+
_ -> False
538
+
}
539
+
}
540
+
|> should.be_true
541
+
}
542
+
543
+
pub fn parse_variable_value_in_argument_test() {
544
+
"{ user(name: $userName) }"
545
+
|> parser.parse
546
+
|> should.be_ok
547
+
|> fn(doc) {
548
+
case doc {
549
+
parser.Document([
550
+
parser.Query(parser.SelectionSet([
551
+
parser.Field(
552
+
name: "user",
553
+
alias: None,
554
+
arguments: [
555
+
parser.Argument("name", parser.VariableValue("userName")),
556
+
],
557
+
selections: [],
558
+
),
559
+
])),
560
+
]) -> True
561
+
_ -> False
562
+
}
563
+
}
564
+
|> should.be_true
565
+
}
566
+
567
+
// Subscription tests
568
+
pub fn parse_anonymous_subscription_with_keyword_test() {
569
+
"subscription { messageAdded }"
570
+
|> parser.parse
571
+
|> should.be_ok
572
+
|> fn(doc) {
573
+
case doc {
574
+
parser.Document([
575
+
parser.Subscription(parser.SelectionSet([
576
+
parser.Field("messageAdded", None, [], []),
577
+
])),
578
+
]) -> True
579
+
_ -> False
580
+
}
581
+
}
582
+
|> should.be_true
583
+
}
584
+
585
+
pub fn parse_named_subscription_test() {
586
+
"subscription OnMessage { messageAdded { content } }"
587
+
|> parser.parse
588
+
|> should.be_ok
589
+
|> fn(doc) {
590
+
case doc {
591
+
parser.Document([
592
+
parser.NamedSubscription(
593
+
"OnMessage",
594
+
[],
595
+
parser.SelectionSet([
596
+
parser.Field(
597
+
name: "messageAdded",
598
+
alias: None,
599
+
arguments: [],
600
+
selections: [parser.Field("content", None, [], [])],
601
+
),
602
+
]),
603
+
),
604
+
]) -> True
605
+
_ -> False
606
+
}
607
+
}
608
+
|> should.be_true
609
+
}
610
+
611
+
pub fn parse_subscription_with_nested_fields_test() {
612
+
"subscription { postCreated { id title author { name } } }"
613
+
|> parser.parse
614
+
|> should.be_ok
615
+
|> fn(doc) {
616
+
case doc {
617
+
parser.Document([
618
+
parser.Subscription(parser.SelectionSet([
619
+
parser.Field(
620
+
name: "postCreated",
621
+
alias: None,
622
+
arguments: [],
623
+
selections: [
624
+
parser.Field("id", None, [], []),
625
+
parser.Field("title", None, [], []),
626
+
parser.Field(
627
+
name: "author",
628
+
alias: None,
629
+
arguments: [],
630
+
selections: [parser.Field("name", None, [], [])],
631
+
),
632
+
],
633
+
),
634
+
])),
635
+
]) -> True
636
+
_ -> False
637
+
}
638
+
}
639
+
|> should.be_true
640
+
}
+222
test/schema_test.gleam
+222
test/schema_test.gleam
···
1
+
/// Tests for GraphQL Schema (Type System)
2
+
///
3
+
/// GraphQL spec Section 3 - Type System
4
+
/// Defines types, fields, and schema structure
5
+
import gleam/option.{None}
6
+
import gleeunit/should
7
+
import swell/schema
8
+
import swell/value
9
+
10
+
// Type system tests
11
+
pub fn create_scalar_type_test() {
12
+
let string_type = schema.string_type()
13
+
should.equal(schema.type_name(string_type), "String")
14
+
}
15
+
16
+
pub fn create_object_type_test() {
17
+
let user_type =
18
+
schema.object_type("User", "A user in the system", [
19
+
schema.field("id", schema.id_type(), "User ID", fn(_ctx) {
20
+
Ok(value.String("123"))
21
+
}),
22
+
schema.field("name", schema.string_type(), "User name", fn(_ctx) {
23
+
Ok(value.String("Alice"))
24
+
}),
25
+
])
26
+
27
+
should.equal(schema.type_name(user_type), "User")
28
+
}
29
+
30
+
pub fn create_non_null_type_test() {
31
+
let non_null_string = schema.non_null(schema.string_type())
32
+
should.be_true(schema.is_non_null(non_null_string))
33
+
}
34
+
35
+
pub fn create_list_type_test() {
36
+
let list_of_strings = schema.list_type(schema.string_type())
37
+
should.be_true(schema.is_list(list_of_strings))
38
+
}
39
+
40
+
pub fn create_schema_test() {
41
+
let query_type =
42
+
schema.object_type("Query", "Root query type", [
43
+
schema.field("hello", schema.string_type(), "Hello field", fn(_ctx) {
44
+
Ok(value.String("world"))
45
+
}),
46
+
])
47
+
48
+
let graphql_schema = schema.schema(query_type, None)
49
+
should.equal(schema.query_type(graphql_schema), query_type)
50
+
}
51
+
52
+
pub fn field_with_arguments_test() {
53
+
let user_field =
54
+
schema.field_with_args(
55
+
"user",
56
+
schema.string_type(),
57
+
"Get user by ID",
58
+
[schema.argument("id", schema.id_type(), "User ID", None)],
59
+
fn(_ctx) { Ok(value.String("Alice")) },
60
+
)
61
+
62
+
should.equal(schema.field_name(user_field), "user")
63
+
}
64
+
65
+
pub fn enum_type_test() {
66
+
let role_enum =
67
+
schema.enum_type("Role", "User role", [
68
+
schema.enum_value("ADMIN", "Administrator"),
69
+
schema.enum_value("USER", "Regular user"),
70
+
])
71
+
72
+
should.equal(schema.type_name(role_enum), "Role")
73
+
}
74
+
75
+
pub fn scalar_types_exist_test() {
76
+
// Built-in scalar types
77
+
let _string = schema.string_type()
78
+
let _int = schema.int_type()
79
+
let _float = schema.float_type()
80
+
let _boolean = schema.boolean_type()
81
+
let _id = schema.id_type()
82
+
83
+
should.be_true(True)
84
+
}
85
+
86
+
// Union type tests
87
+
pub fn create_union_type_test() {
88
+
let post_type =
89
+
schema.object_type("Post", "A blog post", [
90
+
schema.field("title", schema.string_type(), "Post title", fn(_ctx) {
91
+
Ok(value.String("Hello"))
92
+
}),
93
+
])
94
+
95
+
let comment_type =
96
+
schema.object_type("Comment", "A comment", [
97
+
schema.field("text", schema.string_type(), "Comment text", fn(_ctx) {
98
+
Ok(value.String("Nice post"))
99
+
}),
100
+
])
101
+
102
+
let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) {
103
+
Ok("Post")
104
+
}
105
+
106
+
let union_type =
107
+
schema.union_type(
108
+
"SearchResult",
109
+
"A search result",
110
+
[post_type, comment_type],
111
+
type_resolver,
112
+
)
113
+
114
+
should.equal(schema.type_name(union_type), "SearchResult")
115
+
should.be_true(schema.is_union(union_type))
116
+
}
117
+
118
+
pub fn union_possible_types_test() {
119
+
let post_type =
120
+
schema.object_type("Post", "A blog post", [
121
+
schema.field("title", schema.string_type(), "Post title", fn(_ctx) {
122
+
Ok(value.String("Hello"))
123
+
}),
124
+
])
125
+
126
+
let comment_type =
127
+
schema.object_type("Comment", "A comment", [
128
+
schema.field("text", schema.string_type(), "Comment text", fn(_ctx) {
129
+
Ok(value.String("Nice post"))
130
+
}),
131
+
])
132
+
133
+
let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) {
134
+
Ok("Post")
135
+
}
136
+
137
+
let union_type =
138
+
schema.union_type(
139
+
"SearchResult",
140
+
"A search result",
141
+
[post_type, comment_type],
142
+
type_resolver,
143
+
)
144
+
145
+
let possible_types = schema.get_possible_types(union_type)
146
+
should.equal(possible_types, [post_type, comment_type])
147
+
}
148
+
149
+
pub fn resolve_union_type_test() {
150
+
let post_type =
151
+
schema.object_type("Post", "A blog post", [
152
+
schema.field("title", schema.string_type(), "Post title", fn(_ctx) {
153
+
Ok(value.String("Hello"))
154
+
}),
155
+
])
156
+
157
+
let comment_type =
158
+
schema.object_type("Comment", "A comment", [
159
+
schema.field("text", schema.string_type(), "Comment text", fn(_ctx) {
160
+
Ok(value.String("Nice post"))
161
+
}),
162
+
])
163
+
164
+
// Type resolver that examines the __typename field in the data
165
+
let type_resolver = fn(ctx: schema.Context) -> Result(String, String) {
166
+
case ctx.data {
167
+
None -> Error("No data")
168
+
option.Some(value.Object(fields)) -> {
169
+
case fields {
170
+
[#("__typename", value.String(type_name)), ..] -> Ok(type_name)
171
+
_ -> Error("No __typename field")
172
+
}
173
+
}
174
+
_ -> Error("Data is not an object")
175
+
}
176
+
}
177
+
178
+
let union_type =
179
+
schema.union_type(
180
+
"SearchResult",
181
+
"A search result",
182
+
[post_type, comment_type],
183
+
type_resolver,
184
+
)
185
+
186
+
// Create context with data that has __typename
187
+
let data =
188
+
value.Object([
189
+
#("__typename", value.String("Post")),
190
+
#("title", value.String("Test")),
191
+
])
192
+
let ctx = schema.context(option.Some(data))
193
+
let result = schema.resolve_union_type(union_type, ctx)
194
+
195
+
case result {
196
+
Ok(resolved_type) -> should.equal(schema.type_name(resolved_type), "Post")
197
+
Error(_) -> should.be_true(False)
198
+
}
199
+
}
200
+
201
+
pub fn union_type_kind_test() {
202
+
let post_type =
203
+
schema.object_type("Post", "A blog post", [
204
+
schema.field("title", schema.string_type(), "Post title", fn(_ctx) {
205
+
Ok(value.String("Hello"))
206
+
}),
207
+
])
208
+
209
+
let type_resolver = fn(_ctx: schema.Context) -> Result(String, String) {
210
+
Ok("Post")
211
+
}
212
+
213
+
let union_type =
214
+
schema.union_type(
215
+
"SearchResult",
216
+
"A search result",
217
+
[post_type],
218
+
type_resolver,
219
+
)
220
+
221
+
should.equal(schema.type_kind(union_type), "UNION")
222
+
}
+269
test/sdl_test.gleam
+269
test/sdl_test.gleam
···
1
+
/// Snapshot tests for SDL generation
2
+
///
3
+
/// Verifies that GraphQL types are correctly serialized to SDL format
4
+
import birdie
5
+
import gleam/option.{None, Some}
6
+
import gleeunit
7
+
import swell/schema
8
+
import swell/sdl
9
+
import swell/value
10
+
11
+
pub fn main() {
12
+
gleeunit.main()
13
+
}
14
+
15
+
// ===== Input Object Types =====
16
+
17
+
pub fn simple_input_object_test() {
18
+
let input_type =
19
+
schema.input_object_type(
20
+
"UserInput",
21
+
"Input for creating or updating a user",
22
+
[
23
+
schema.input_field("name", schema.string_type(), "User's name", None),
24
+
schema.input_field(
25
+
"email",
26
+
schema.non_null(schema.string_type()),
27
+
"User's email address",
28
+
None,
29
+
),
30
+
schema.input_field("age", schema.int_type(), "User's age", None),
31
+
],
32
+
)
33
+
34
+
let serialized = sdl.print_type(input_type)
35
+
36
+
birdie.snap(
37
+
title: "Simple input object with descriptions",
38
+
content: serialized,
39
+
)
40
+
}
41
+
42
+
pub fn input_object_with_default_values_test() {
43
+
let input_type =
44
+
schema.input_object_type("FilterInput", "Filter options for queries", [
45
+
schema.input_field(
46
+
"limit",
47
+
schema.int_type(),
48
+
"Maximum number of results",
49
+
Some(value.Int(10)),
50
+
),
51
+
schema.input_field(
52
+
"offset",
53
+
schema.int_type(),
54
+
"Number of results to skip",
55
+
Some(value.Int(0)),
56
+
),
57
+
])
58
+
59
+
let serialized = sdl.print_type(input_type)
60
+
61
+
birdie.snap(title: "Input object with default values", content: serialized)
62
+
}
63
+
64
+
pub fn nested_input_types_test() {
65
+
let address_input =
66
+
schema.input_object_type("AddressInput", "Street address information", [
67
+
schema.input_field("street", schema.string_type(), "Street name", None),
68
+
schema.input_field("city", schema.string_type(), "City name", None),
69
+
])
70
+
71
+
let user_input =
72
+
schema.input_object_type("UserInput", "User information", [
73
+
schema.input_field("name", schema.string_type(), "Full name", None),
74
+
schema.input_field("address", address_input, "Home address", None),
75
+
])
76
+
77
+
let serialized = sdl.print_types([address_input, user_input])
78
+
79
+
birdie.snap(title: "Nested input types", content: serialized)
80
+
}
81
+
82
+
// ===== Object Types =====
83
+
84
+
pub fn simple_object_type_test() {
85
+
let user_type =
86
+
schema.object_type("User", "A user in the system", [
87
+
schema.field("id", schema.non_null(schema.id_type()), "User ID", fn(_ctx) {
88
+
Ok(value.String("1"))
89
+
}),
90
+
schema.field("name", schema.string_type(), "User's name", fn(_ctx) {
91
+
Ok(value.String("Alice"))
92
+
}),
93
+
schema.field("email", schema.string_type(), "Email address", fn(_ctx) {
94
+
Ok(value.String("alice@example.com"))
95
+
}),
96
+
])
97
+
98
+
let serialized = sdl.print_type(user_type)
99
+
100
+
birdie.snap(title: "Simple object type", content: serialized)
101
+
}
102
+
103
+
pub fn object_with_list_fields_test() {
104
+
let post_type =
105
+
schema.object_type("Post", "A blog post", [
106
+
schema.field("id", schema.id_type(), "Post ID", fn(_ctx) {
107
+
Ok(value.String("1"))
108
+
}),
109
+
schema.field("title", schema.string_type(), "Post title", fn(_ctx) {
110
+
Ok(value.String("Hello"))
111
+
}),
112
+
schema.field(
113
+
"tags",
114
+
schema.list_type(schema.non_null(schema.string_type())),
115
+
"Post tags",
116
+
fn(_ctx) { Ok(value.List([])) },
117
+
),
118
+
])
119
+
120
+
let serialized = sdl.print_type(post_type)
121
+
122
+
birdie.snap(title: "Object type with list fields", content: serialized)
123
+
}
124
+
125
+
// ===== Enum Types =====
126
+
127
+
pub fn simple_enum_test() {
128
+
let status_enum =
129
+
schema.enum_type("Status", "Order status", [
130
+
schema.enum_value("PENDING", "Order is pending"),
131
+
schema.enum_value("PROCESSING", "Order is being processed"),
132
+
schema.enum_value("SHIPPED", "Order has been shipped"),
133
+
schema.enum_value("DELIVERED", "Order has been delivered"),
134
+
])
135
+
136
+
let serialized = sdl.print_type(status_enum)
137
+
138
+
birdie.snap(title: "Simple enum type", content: serialized)
139
+
}
140
+
141
+
pub fn enum_without_descriptions_test() {
142
+
let color_enum =
143
+
schema.enum_type("Color", "", [
144
+
schema.enum_value("RED", ""),
145
+
schema.enum_value("GREEN", ""),
146
+
schema.enum_value("BLUE", ""),
147
+
])
148
+
149
+
let serialized = sdl.print_type(color_enum)
150
+
151
+
birdie.snap(title: "Enum without descriptions", content: serialized)
152
+
}
153
+
154
+
// ===== Scalar Types =====
155
+
156
+
pub fn built_in_scalars_test() {
157
+
let scalars = [
158
+
schema.string_type(),
159
+
schema.int_type(),
160
+
schema.float_type(),
161
+
schema.boolean_type(),
162
+
schema.id_type(),
163
+
]
164
+
165
+
let serialized = sdl.print_types(scalars)
166
+
167
+
birdie.snap(title: "Built-in scalar types", content: serialized)
168
+
}
169
+
170
+
// ===== Complex Types =====
171
+
172
+
pub fn type_with_non_null_and_list_test() {
173
+
let input_type =
174
+
schema.input_object_type("ComplexInput", "Complex type modifiers", [
175
+
schema.input_field(
176
+
"required",
177
+
schema.non_null(schema.string_type()),
178
+
"Required string",
179
+
None,
180
+
),
181
+
schema.input_field(
182
+
"optionalList",
183
+
schema.list_type(schema.string_type()),
184
+
"Optional list of strings",
185
+
None,
186
+
),
187
+
schema.input_field(
188
+
"requiredList",
189
+
schema.non_null(schema.list_type(schema.string_type())),
190
+
"Required list of optional strings",
191
+
None,
192
+
),
193
+
schema.input_field(
194
+
"listOfRequired",
195
+
schema.list_type(schema.non_null(schema.string_type())),
196
+
"Optional list of required strings",
197
+
None,
198
+
),
199
+
schema.input_field(
200
+
"requiredListOfRequired",
201
+
schema.non_null(schema.list_type(schema.non_null(schema.string_type()))),
202
+
"Required list of required strings",
203
+
None,
204
+
),
205
+
])
206
+
207
+
let serialized = sdl.print_type(input_type)
208
+
209
+
birdie.snap(
210
+
title: "Type with NonNull and List modifiers",
211
+
content: serialized,
212
+
)
213
+
}
214
+
215
+
// ===== Multiple Related Types =====
216
+
217
+
pub fn related_types_test() {
218
+
let sort_direction =
219
+
schema.enum_type("SortDirection", "Sort direction for queries", [
220
+
schema.enum_value("ASC", "Ascending order"),
221
+
schema.enum_value("DESC", "Descending order"),
222
+
])
223
+
224
+
let sort_field_enum =
225
+
schema.enum_type("UserSortField", "Fields to sort users by", [
226
+
schema.enum_value("NAME", "Sort by name"),
227
+
schema.enum_value("EMAIL", "Sort by email"),
228
+
schema.enum_value("CREATED_AT", "Sort by creation date"),
229
+
])
230
+
231
+
let sort_input =
232
+
schema.input_object_type("SortInput", "Sort configuration", [
233
+
schema.input_field(
234
+
"field",
235
+
schema.non_null(sort_field_enum),
236
+
"Field to sort by",
237
+
None,
238
+
),
239
+
schema.input_field(
240
+
"direction",
241
+
sort_direction,
242
+
"Sort direction",
243
+
Some(value.String("ASC")),
244
+
),
245
+
])
246
+
247
+
let serialized =
248
+
sdl.print_types([sort_direction, sort_field_enum, sort_input])
249
+
250
+
birdie.snap(title: "Multiple related types", content: serialized)
251
+
}
252
+
253
+
// ===== Empty Types (Edge Cases) =====
254
+
255
+
pub fn empty_input_object_test() {
256
+
let empty_input = schema.input_object_type("EmptyInput", "An empty input", [])
257
+
258
+
let serialized = sdl.print_type(empty_input)
259
+
260
+
birdie.snap(title: "Empty input object", content: serialized)
261
+
}
262
+
263
+
pub fn empty_enum_test() {
264
+
let empty_enum = schema.enum_type("EmptyEnum", "An empty enum", [])
265
+
266
+
let serialized = sdl.print_type(empty_enum)
267
+
268
+
birdie.snap(title: "Empty enum", content: serialized)
269
+
}
+66
test/subscription_parser_test.gleam
+66
test/subscription_parser_test.gleam
···
1
+
/// Snapshot tests for subscription parsing
2
+
import birdie
3
+
import gleam/string
4
+
import gleeunit
5
+
import swell/parser
6
+
7
+
pub fn main() {
8
+
gleeunit.main()
9
+
}
10
+
11
+
// Helper to format AST as string for snapshots
12
+
fn format_ast(doc: parser.Document) -> String {
13
+
string.inspect(doc)
14
+
}
15
+
16
+
pub fn parse_simple_anonymous_subscription_test() {
17
+
let query = "subscription { messageAdded { content author } }"
18
+
19
+
let doc = case parser.parse(query) {
20
+
Ok(d) -> d
21
+
Error(_) -> panic as "Parse failed"
22
+
}
23
+
24
+
birdie.snap(title: "Simple anonymous subscription", content: format_ast(doc))
25
+
}
26
+
27
+
pub fn parse_named_subscription_test() {
28
+
let query = "subscription OnMessage { messageAdded { id content } }"
29
+
30
+
let doc = case parser.parse(query) {
31
+
Ok(d) -> d
32
+
Error(_) -> panic as "Parse failed"
33
+
}
34
+
35
+
birdie.snap(title: "Named subscription", content: format_ast(doc))
36
+
}
37
+
38
+
pub fn parse_subscription_with_nested_selections_test() {
39
+
let query =
40
+
"
41
+
subscription {
42
+
postCreated {
43
+
id
44
+
title
45
+
author {
46
+
id
47
+
name
48
+
email
49
+
}
50
+
comments {
51
+
content
52
+
}
53
+
}
54
+
}
55
+
"
56
+
57
+
let doc = case parser.parse(query) {
58
+
Ok(d) -> d
59
+
Error(_) -> panic as "Parse failed"
60
+
}
61
+
62
+
birdie.snap(
63
+
title: "Subscription with nested selections",
64
+
content: format_ast(doc),
65
+
)
66
+
}
+286
test/subscription_test.gleam
+286
test/subscription_test.gleam
···
1
+
import gleam/list
2
+
import gleam/option.{None, Some}
3
+
import gleeunit
4
+
import gleeunit/should
5
+
import swell/executor
6
+
import swell/schema
7
+
import swell/value
8
+
9
+
pub fn main() {
10
+
gleeunit.main()
11
+
}
12
+
13
+
// Test: Create a subscription type
14
+
pub fn create_subscription_type_test() {
15
+
let subscription_field =
16
+
schema.field(
17
+
"testSubscription",
18
+
schema.string_type(),
19
+
"A test subscription",
20
+
fn(_ctx) { Ok(value.String("test")) },
21
+
)
22
+
23
+
let subscription_type =
24
+
schema.object_type("Subscription", "Root subscription type", [
25
+
subscription_field,
26
+
])
27
+
28
+
schema.type_name(subscription_type)
29
+
|> should.equal("Subscription")
30
+
}
31
+
32
+
// Test: Schema with subscription type
33
+
pub fn schema_with_subscription_test() {
34
+
let query_field =
35
+
schema.field("hello", schema.string_type(), "Hello query", fn(_ctx) {
36
+
Ok(value.String("world"))
37
+
})
38
+
39
+
let query_type = schema.object_type("Query", "Root query type", [query_field])
40
+
41
+
let subscription_field =
42
+
schema.field(
43
+
"messageAdded",
44
+
schema.string_type(),
45
+
"Subscribe to new messages",
46
+
fn(_ctx) { Ok(value.String("test message")) },
47
+
)
48
+
49
+
let subscription_type =
50
+
schema.object_type("Subscription", "Root subscription type", [
51
+
subscription_field,
52
+
])
53
+
54
+
let test_schema =
55
+
schema.schema_with_subscriptions(query_type, None, Some(subscription_type))
56
+
57
+
// Schema should be created successfully
58
+
// We can't easily test inequality on opaque types, so just verify it doesn't crash
59
+
let _ = test_schema
60
+
should.be_true(True)
61
+
}
62
+
63
+
// Test: Get subscription fields
64
+
pub fn get_subscription_fields_test() {
65
+
let subscription_field1 =
66
+
schema.field(
67
+
"postCreated",
68
+
schema.string_type(),
69
+
"New post created",
70
+
fn(_ctx) { Ok(value.String("post1")) },
71
+
)
72
+
73
+
let subscription_field2 =
74
+
schema.field("postUpdated", schema.string_type(), "Post updated", fn(_ctx) {
75
+
Ok(value.String("post1"))
76
+
})
77
+
78
+
let subscription_type =
79
+
schema.object_type("Subscription", "Root subscription type", [
80
+
subscription_field1,
81
+
subscription_field2,
82
+
])
83
+
84
+
let fields = schema.get_fields(subscription_type)
85
+
86
+
list.length(fields)
87
+
|> should.equal(2)
88
+
}
89
+
90
+
// Test: Execute anonymous subscription
91
+
pub fn execute_anonymous_subscription_test() {
92
+
let query_type =
93
+
schema.object_type("Query", "Root query", [
94
+
schema.field("dummy", schema.string_type(), "Dummy", fn(_) {
95
+
Ok(value.String("dummy"))
96
+
}),
97
+
])
98
+
99
+
let message_type =
100
+
schema.object_type("Message", "A message", [
101
+
schema.field("content", schema.string_type(), "Message content", fn(ctx) {
102
+
case ctx.data {
103
+
Some(value.Object(fields)) -> {
104
+
case list.key_find(fields, "content") {
105
+
Ok(content) -> Ok(content)
106
+
Error(_) -> Ok(value.String(""))
107
+
}
108
+
}
109
+
_ -> Ok(value.String(""))
110
+
}
111
+
}),
112
+
])
113
+
114
+
let subscription_type =
115
+
schema.object_type("Subscription", "Root subscription", [
116
+
schema.field("messageAdded", message_type, "New message", fn(ctx) {
117
+
// In real usage, this would be called with event data in ctx.data
118
+
case ctx.data {
119
+
Some(data) -> Ok(data)
120
+
None -> Ok(value.Object([#("content", value.String("test"))]))
121
+
}
122
+
}),
123
+
])
124
+
125
+
let test_schema =
126
+
schema.schema_with_subscriptions(query_type, None, Some(subscription_type))
127
+
128
+
// Create context with event data
129
+
let event_data =
130
+
value.Object([#("content", value.String("Hello from subscription!"))])
131
+
let ctx = schema.context(Some(event_data))
132
+
133
+
let query = "subscription { messageAdded { content } }"
134
+
135
+
case executor.execute(query, test_schema, ctx) {
136
+
Ok(response) -> {
137
+
case response.data {
138
+
value.Object(fields) -> {
139
+
case list.key_find(fields, "messageAdded") {
140
+
Ok(value.Object(message_fields)) -> {
141
+
case list.key_find(message_fields, "content") {
142
+
Ok(value.String(content)) ->
143
+
should.equal(content, "Hello from subscription!")
144
+
_ -> should.fail()
145
+
}
146
+
}
147
+
_ -> should.fail()
148
+
}
149
+
}
150
+
_ -> should.fail()
151
+
}
152
+
}
153
+
Error(_) -> should.fail()
154
+
}
155
+
}
156
+
157
+
// Test: Execute subscription with field selection
158
+
pub fn execute_subscription_with_field_selection_test() {
159
+
let query_type =
160
+
schema.object_type("Query", "Root query", [
161
+
schema.field("dummy", schema.string_type(), "Dummy", fn(_) {
162
+
Ok(value.String("dummy"))
163
+
}),
164
+
])
165
+
166
+
let post_type =
167
+
schema.object_type("Post", "A post", [
168
+
schema.field("id", schema.id_type(), "Post ID", fn(ctx) {
169
+
case ctx.data {
170
+
Some(value.Object(fields)) -> {
171
+
case list.key_find(fields, "id") {
172
+
Ok(id) -> Ok(id)
173
+
Error(_) -> Ok(value.String(""))
174
+
}
175
+
}
176
+
_ -> Ok(value.String(""))
177
+
}
178
+
}),
179
+
schema.field("title", schema.string_type(), "Post title", fn(ctx) {
180
+
case ctx.data {
181
+
Some(value.Object(fields)) -> {
182
+
case list.key_find(fields, "title") {
183
+
Ok(title) -> Ok(title)
184
+
Error(_) -> Ok(value.String(""))
185
+
}
186
+
}
187
+
_ -> Ok(value.String(""))
188
+
}
189
+
}),
190
+
schema.field("content", schema.string_type(), "Post content", fn(ctx) {
191
+
case ctx.data {
192
+
Some(value.Object(fields)) -> {
193
+
case list.key_find(fields, "content") {
194
+
Ok(content) -> Ok(content)
195
+
Error(_) -> Ok(value.String(""))
196
+
}
197
+
}
198
+
_ -> Ok(value.String(""))
199
+
}
200
+
}),
201
+
])
202
+
203
+
let subscription_type =
204
+
schema.object_type("Subscription", "Root subscription", [
205
+
schema.field("postCreated", post_type, "New post", fn(ctx) {
206
+
case ctx.data {
207
+
Some(data) -> Ok(data)
208
+
None ->
209
+
Ok(
210
+
value.Object([
211
+
#("id", value.String("1")),
212
+
#("title", value.String("Test")),
213
+
#("content", value.String("Test content")),
214
+
]),
215
+
)
216
+
}
217
+
}),
218
+
])
219
+
220
+
let test_schema =
221
+
schema.schema_with_subscriptions(query_type, None, Some(subscription_type))
222
+
223
+
// Create context with event data
224
+
let event_data =
225
+
value.Object([
226
+
#("id", value.String("123")),
227
+
#("title", value.String("New Post")),
228
+
#("content", value.String("This is a new post")),
229
+
])
230
+
let ctx = schema.context(Some(event_data))
231
+
232
+
// Query only for id and title, not content
233
+
let query = "subscription { postCreated { id title } }"
234
+
235
+
case executor.execute(query, test_schema, ctx) {
236
+
Ok(response) -> {
237
+
case response.data {
238
+
value.Object(fields) -> {
239
+
case list.key_find(fields, "postCreated") {
240
+
Ok(value.Object(post_fields)) -> {
241
+
// Should have id and title
242
+
case list.key_find(post_fields, "id") {
243
+
Ok(value.String(id)) -> should.equal(id, "123")
244
+
_ -> should.fail()
245
+
}
246
+
case list.key_find(post_fields, "title") {
247
+
Ok(value.String(title)) -> should.equal(title, "New Post")
248
+
_ -> should.fail()
249
+
}
250
+
// Should NOT have content (field selection working)
251
+
case list.key_find(post_fields, "content") {
252
+
Error(_) -> should.be_true(True)
253
+
Ok(_) -> should.fail()
254
+
}
255
+
}
256
+
_ -> should.fail()
257
+
}
258
+
}
259
+
_ -> should.fail()
260
+
}
261
+
}
262
+
Error(_) -> should.fail()
263
+
}
264
+
}
265
+
266
+
// Test: Subscription without schema type
267
+
pub fn subscription_without_schema_type_test() {
268
+
let query_type =
269
+
schema.object_type("Query", "Root query", [
270
+
schema.field("dummy", schema.string_type(), "Dummy", fn(_) {
271
+
Ok(value.String("dummy"))
272
+
}),
273
+
])
274
+
275
+
// Schema WITHOUT subscription type
276
+
let test_schema = schema.schema(query_type, None)
277
+
278
+
let ctx = schema.context(None)
279
+
let query = "subscription { messageAdded }"
280
+
281
+
case executor.execute(query, test_schema, ctx) {
282
+
Error(msg) ->
283
+
should.equal(msg, "Schema does not define a subscription type")
284
+
Ok(_) -> should.fail()
285
+
}
286
+
}
+5
test/swell_test.gleam
+5
test/swell_test.gleam
+87
test/value_test.gleam
+87
test/value_test.gleam
···
1
+
/// Tests for GraphQL Value types
2
+
///
3
+
/// GraphQL spec Section 2 - Language
4
+
/// Values can be: Null, Int, Float, String, Boolean, Enum, List, Object
5
+
import gleeunit/should
6
+
import swell/value.{Boolean, Enum, Float, Int, List, Null, Object, String}
7
+
8
+
pub fn null_value_test() {
9
+
let val = Null
10
+
should.equal(val, Null)
11
+
}
12
+
13
+
pub fn int_value_test() {
14
+
let val = Int(42)
15
+
should.equal(val, Int(42))
16
+
}
17
+
18
+
pub fn float_value_test() {
19
+
let val = Float(3.14)
20
+
should.equal(val, Float(3.14))
21
+
}
22
+
23
+
pub fn string_value_test() {
24
+
let val = String("hello")
25
+
should.equal(val, String("hello"))
26
+
}
27
+
28
+
pub fn boolean_true_value_test() {
29
+
let val = Boolean(True)
30
+
should.equal(val, Boolean(True))
31
+
}
32
+
33
+
pub fn boolean_false_value_test() {
34
+
let val = Boolean(False)
35
+
should.equal(val, Boolean(False))
36
+
}
37
+
38
+
pub fn enum_value_test() {
39
+
let val = Enum("ACTIVE")
40
+
should.equal(val, Enum("ACTIVE"))
41
+
}
42
+
43
+
pub fn empty_list_value_test() {
44
+
let val = List([])
45
+
should.equal(val, List([]))
46
+
}
47
+
48
+
pub fn list_of_ints_test() {
49
+
let val = List([Int(1), Int(2), Int(3)])
50
+
should.equal(val, List([Int(1), Int(2), Int(3)]))
51
+
}
52
+
53
+
pub fn nested_list_test() {
54
+
let val = List([List([Int(1), Int(2)]), List([Int(3), Int(4)])])
55
+
should.equal(val, List([List([Int(1), Int(2)]), List([Int(3), Int(4)])]))
56
+
}
57
+
58
+
pub fn empty_object_test() {
59
+
let val = Object([])
60
+
should.equal(val, Object([]))
61
+
}
62
+
63
+
pub fn simple_object_test() {
64
+
let val = Object([#("name", String("Alice")), #("age", Int(30))])
65
+
should.equal(val, Object([#("name", String("Alice")), #("age", Int(30))]))
66
+
}
67
+
68
+
pub fn nested_object_test() {
69
+
let val =
70
+
Object([
71
+
#("user", Object([#("name", String("Bob")), #("active", Boolean(True))])),
72
+
#("count", Int(5)),
73
+
])
74
+
75
+
should.equal(
76
+
val,
77
+
Object([
78
+
#("user", Object([#("name", String("Bob")), #("active", Boolean(True))])),
79
+
#("count", Int(5)),
80
+
]),
81
+
)
82
+
}
83
+
84
+
pub fn mixed_types_list_test() {
85
+
let val = List([String("hello"), Int(42), Boolean(True), Null])
86
+
should.equal(val, List([String("hello"), Int(42), Boolean(True), Null]))
87
+
}