๐ŸŒŠ A GraphQL implementation in Gleam

init

Changed files
+8444
.github
workflows
birdie_snapshots
example
src
test
+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
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + build/ 4 + erl_crash.dump
+79
README.md
··· 1 + # Swell 2 + 3 + A GraphQL implementation in Gleam providing query parsing, execution, and introspection support. 4 + 5 + ![Swell](https://images.unsplash.com/photo-1616645728806-838c6bf184af?ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&q=80&w=2340) 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
··· 1 + --- 2 + version: 1.4.1 3 + title: Built-in scalar types 4 + file: ./test/sdl_test.gleam 5 + test_name: built_in_scalars_test 6 + --- 7 + scalar String 8 + 9 + scalar Int 10 + 11 + scalar Float 12 + 13 + scalar Boolean 14 + 15 + scalar ID
+8
birdie_snapshots/empty_enum.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Empty enum 4 + file: ./test/sdl_test.gleam 5 + test_name: empty_enum_test 6 + --- 7 + """An empty enum""" 8 + enum EmptyEnum {}
+8
birdie_snapshots/empty_input_object.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Empty input object 4 + file: ./test/sdl_test.gleam 5 + test_name: empty_input_object_test 6 + --- 7 + """An empty input""" 8 + input EmptyInput {}
+11
birdie_snapshots/enum_without_descriptions.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Enum without descriptions 4 + file: ./test/sdl_test.gleam 5 + test_name: enum_without_descriptions_test 6 + --- 7 + enum Color { 8 + RED 9 + GREEN 10 + BLUE 11 + }
+7
birdie_snapshots/execute_field_with_object_argument.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute field with object argument 4 + file: ./test/executor_test.gleam 5 + test_name: execute_field_receives_object_argument_test 6 + --- 7 + Response(Object([#("posts", String("Sorting by date DESC"))]), [])
+7
birdie_snapshots/execute_field_with_string_argument.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute field with string argument 4 + file: ./test/executor_test.gleam 5 + test_name: execute_field_receives_string_argument_test 6 + --- 7 + Response(Object([#("echo", String("Echo: hello"))]), [])
+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
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute mixed aliased and non-aliased fields 4 + file: ./test/executor_test.gleam 5 + test_name: execute_mixed_aliased_fields_test 6 + --- 7 + Response(Object([#("greeting", String("world")), #("number", Int(42))]), [])
+7
birdie_snapshots/execute_multiple_fields_with_aliases.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute multiple fields with aliases 4 + file: ./test/executor_test.gleam 5 + test_name: execute_multiple_fields_with_aliases_test 6 + --- 7 + Response(Object([#("greeting", String("world")), #("num", Int(42))]), [])
+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
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute query with int variable 4 + file: ./test/executor_test.gleam 5 + test_name: execute_query_with_variable_int_test 6 + --- 7 + Response(Object([#("user", String("User #42"))]), [])
+7
birdie_snapshots/execute_query_with_multiple_variables.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute query with multiple variables 4 + file: ./test/executor_test.gleam 5 + test_name: execute_query_with_multiple_variables_test 6 + --- 7 + Response(Object([#("search", String("Searching for 'graphql' (limit: 10)"))]), [])
+7
birdie_snapshots/execute_query_with_string_variable.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute query with string variable 4 + file: ./test/executor_test.gleam 5 + test_name: execute_query_with_variable_string_test 6 + --- 7 + Response(Object([#("greet", String("Hello, Alice!"))]), [])
+7
birdie_snapshots/execute_schema_introspection.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute __schema introspection 4 + file: ./test/executor_test.gleam 5 + test_name: execute_schema_introspection_query_type_test 6 + --- 7 + Response(Object([#("__schema", Object([#("queryType", Object([#("name", String("Query"))]))]))]), [])
+7
birdie_snapshots/execute_simple_fragment_spread.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute simple fragment spread 4 + file: ./test/executor_test.gleam 5 + test_name: execute_simple_fragment_spread_test 6 + --- 7 + Response(Object([#("user", Object([#("id", String("123")), #("name", String("Alice"))]))]), [])
+7
birdie_snapshots/execute_simple_mutation.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute simple mutation 4 + file: ./test/mutation_execution_test.gleam 5 + test_name: execute_simple_mutation_test 6 + --- 7 + Response(Object([#("createUser", Object([#("id", String("123")), #("name", String("Alice"))]))]), [])
+7
birdie_snapshots/execute_simple_query.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute simple query 4 + file: ./test/executor_test.gleam 5 + test_name: execute_simple_query_test 6 + --- 7 + Response(Object([#("hello", String("world"))]), [])
+7
birdie_snapshots/execute_typename_introspection.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute __typename introspection 4 + file: ./test/executor_test.gleam 5 + test_name: execute_typename_introspection_test 6 + --- 7 + Response(Object([#("__typename", String("Query"))]), [])
+7
birdie_snapshots/execute_typename_with_regular_fields.accepted
··· 1 + --- 2 + version: 1.4.1 3 + title: Execute __typename with regular fields 4 + file: ./test/executor_test.gleam 5 + test_name: execute_typename_with_regular_fields_test 6 + --- 7 + Response(Object([#("__typename", String("Query")), #("hello", String("world"))]), [])
+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
··· 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
··· 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
··· 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
··· 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
··· 1 + --- 2 + version: 1.4.1 3 + title: Mutation returning list 4 + file: ./test/mutation_sdl_test.gleam 5 + test_name: mutation_returning_list_test 6 + --- 7 + """Mutations""" 8 + type Mutation { 9 + """Create multiple users""" 10 + createUsers: [User] 11 + }
+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
··· 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
··· 1 + --- 2 + version: 1.4.1 3 + title: Mutation with non-null return type 4 + file: ./test/mutation_sdl_test.gleam 5 + test_name: mutation_with_non_null_return_test 6 + --- 7 + """Mutations""" 8 + type Mutation { 9 + """Create a user (guaranteed to return)""" 10 + createUser: User! 11 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + --- 2 + version: 1.4.1 3 + title: Simple mutation type 4 + file: ./test/mutation_sdl_test.gleam 5 + test_name: simple_mutation_type_test 6 + --- 7 + """Root mutation type""" 8 + type Mutation { 9 + """Create a new user""" 10 + createUser: User 11 + }
+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
··· 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
··· 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
··· 1 + # example 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/example)](https://hex.pm/packages/example) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + import gleeunit 2 + 3 + pub fn main() -> Nil { 4 + gleeunit.main() 5 + } 6 + 7 + // gleeunit test functions end in `_test` 8 + pub fn hello_world_test() { 9 + let name = "Joe" 10 + let greeting = "Hello, " <> name <> "!" 11 + 12 + assert greeting == "Hello, Joe!" 13 + }
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + import gleeunit 2 + 3 + pub fn main() -> Nil { 4 + gleeunit.main() 5 + }
+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 + }