+81
CHANGELOG.md
+81
CHANGELOG.md
···
1
+
# Changelog
2
+
3
+
All notable changes to this project will be documented in this file.
4
+
5
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+
## [1.1.2] - 2025-11-26
9
+
10
+
### Changed
11
+
12
+
- Updated dependencies: gleam_json 3.1.0, gleeunit 1.9.0, simplifile 2.3.1, splitter 1.2.0, swell 1.1.0
13
+
14
+
## [1.1.1] - 2025-11-12
15
+
16
+
### Changed
17
+
18
+
- **Renamed `registry` module to `unstable_registry`** - The cache registry module is experimental
19
+
20
+
## [1.1.0] - 2025-11-12
21
+
22
+
### Added
23
+
24
+
- **Relay-style cache normalization support** via new `unstable-cache` command
25
+
- Injects `__typename` into queries for cache entity identification
26
+
- Generates cache registry with all queries for centralized cache management
27
+
- Extracts GraphQL queries from doc comments in `.gleam` files
28
+
- Outputs generated types to `src/generated/queries/` by default
29
+
- **Enum support**
30
+
- Generates enum type definitions with PascalCase variants
31
+
- Creates `enum_to_string()` converter functions for serialization
32
+
- Generates `enum_decoder()` functions for deserialization
33
+
- Support for enums in response types, variables, and InputObjects
34
+
- Support for optional enums (`Option(EnumType)`)
35
+
- Support for lists of enums (`List(EnumType)`)
36
+
37
+
### Fixed
38
+
39
+
- **Enum serialization in response types** - Response serializers now correctly convert enum values to strings using generated `enum_to_string()` functions instead of attempting direct string conversion
40
+
- **Enum serialization in optional fields** - Optional enum fields now properly serialize with `json.nullable` wrapper
41
+
- **Enum serialization in lists** - Lists of enums now correctly serialize each enum value to a string
42
+
43
+
### Changed
44
+
45
+
- **Removed `output_path` parameter from `unstable-cache` command** - Output path is now fixed at `src/generated` for consistency
46
+
47
+
### Commands
48
+
49
+
#### New: `unstable-cache`
50
+
51
+
```bash
52
+
gleam run -m squall unstable-cache <endpoint>
53
+
```
54
+
55
+
Extracts GraphQL queries from doc comments and generates:
56
+
- Type-safe Gleam functions for each query at `src/generated/queries/`
57
+
- Cache registry initialization module at `src/generated/queries.gleam`
58
+
- Automatic `__typename` injection for cache normalization
59
+
60
+
## [1.0.1] - 2025-11-10
61
+
62
+
### Added
63
+
- Lustre example application
64
+
- Fragment support in queries
65
+
66
+
### Changed
67
+
- Updated description
68
+
- Updated README
69
+
- Removed unused dependencies
70
+
71
+
## [1.0.0] - 2025-11-07
72
+
73
+
Initial release with core functionality:
74
+
- Sans-IO GraphQL client generation
75
+
- Support for both Erlang and JavaScript targets
76
+
- GraphQL query, mutation, and subscription support
77
+
- Variable and argument handling
78
+
- InputObject type support
79
+
- JSON scalar type mapping
80
+
- Response serializers
81
+
- Reserved keyword sanitization
+2
birdie_snapshots/mutation_function_generation.accepted
+2
birdie_snapshots/mutation_function_generation.accepted
+88
birdie_snapshots/mutation_with_enum_in_input_object.accepted
+88
birdie_snapshots/mutation_with_enum_in_input_object.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Mutation with enum in InputObject
4
+
file: ./test/codegen_test.gleam
5
+
test_name: generate_mutation_with_enum_in_input_object_test
6
+
---
7
+
import gleam/dynamic/decode
8
+
import gleam/http/request.{type Request}
9
+
import gleam/json
10
+
import squall
11
+
import gleam/option.{type Option, Some, None}
12
+
import gleam/list
13
+
14
+
pub type CharacterInput {
15
+
CharacterInput(name: String, status: CharacterStatus, species: Option(String))
16
+
}
17
+
18
+
fn character_input_to_json(input: CharacterInput) -> json.Json {
19
+
[Some(#("name", json.string(input.name))), Some(#("status", json.string(
20
+
character_status_to_string(input.status),
21
+
))), {
22
+
case input.species {
23
+
Some(val) -> Some(#("species", json.string(val)))
24
+
None -> None
25
+
}
26
+
}]
27
+
|> list.filter_map(fn(x) {
28
+
case x {
29
+
Some(val) -> Ok(val)
30
+
None -> Error(Nil)
31
+
}
32
+
})
33
+
|> json.object
34
+
}
35
+
36
+
pub type Character {
37
+
Character(id: String, name: String, status: CharacterStatus)
38
+
}
39
+
40
+
pub fn character_decoder() -> decode.Decoder(Character) {
41
+
use id <- decode.field("id", decode.string)
42
+
use name <- decode.field("name", decode.string)
43
+
use status <- decode.field("status", character_status_decoder())
44
+
decode.success(Character(id: id, name: name, status: status))
45
+
}
46
+
47
+
pub fn character_to_json(input: Character) -> json.Json {
48
+
json.object(
49
+
[
50
+
#("id", json.string(input.id)),
51
+
#("name", json.string(input.name)),
52
+
#("status", json.string(character_status_to_string(input.status))),
53
+
],
54
+
)
55
+
}
56
+
57
+
pub type CreateCharacterResponse {
58
+
CreateCharacterResponse(create_character: Option(Character))
59
+
}
60
+
61
+
pub fn create_character_response_decoder() -> decode.Decoder(CreateCharacterResponse) {
62
+
use create_character <- decode.field("createCharacter", decode.optional(character_decoder()))
63
+
decode.success(CreateCharacterResponse(create_character: create_character))
64
+
}
65
+
66
+
pub fn create_character_response_to_json(input: CreateCharacterResponse) -> json.Json {
67
+
json.object(
68
+
[
69
+
#("createCharacter", json.nullable(
70
+
input.create_character,
71
+
character_to_json,
72
+
)),
73
+
],
74
+
)
75
+
}
76
+
77
+
pub fn create_character(client: squall.Client, input: CharacterInput) -> Result(Request(String), String) {
78
+
squall.prepare_request(
79
+
client,
80
+
"\n mutation CreateCharacter($input: CharacterInput!) {\n createCharacter(input: $input) {\n id\n name\n status\n }\n }\n ",
81
+
json.object([#("input", character_input_to_json(input))]),
82
+
)
83
+
}
84
+
85
+
pub fn parse_create_character_response(body: String) -> Result(CreateCharacterResponse, String) {
86
+
squall.parse_response(body, create_character_response_decoder())
87
+
}
88
+
+96
birdie_snapshots/mutation_with_enum_variable.accepted
+96
birdie_snapshots/mutation_with_enum_variable.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Mutation with enum variable
4
+
file: ./test/codegen_test.gleam
5
+
test_name: generate_mutation_with_enum_variable_test
6
+
---
7
+
import gleam/dynamic/decode
8
+
import gleam/http/request.{type Request}
9
+
import gleam/json
10
+
import squall
11
+
import gleam/option.{type Option}
12
+
13
+
pub type CharacterStatus {
14
+
Alive
15
+
Dead
16
+
Unknown
17
+
}
18
+
19
+
pub fn character_status_to_string(value: CharacterStatus) -> String {
20
+
case value {
21
+
Alive -> "Alive"
22
+
Dead -> "Dead"
23
+
Unknown -> "unknown"
24
+
}
25
+
}
26
+
27
+
pub fn character_status_decoder() -> decode.Decoder(CharacterStatus) {
28
+
decode.string
29
+
30
+
31
+
|> decode.then(fn(str) {
32
+
33
+
case str {
34
+
"Alive" -> decode.success(Alive)
35
+
"Dead" -> decode.success(Dead)
36
+
"unknown" -> decode.success(Unknown)
37
+
_other -> decode.failure(Alive, "CharacterStatus")
38
+
}
39
+
40
+
41
+
})
42
+
}
43
+
44
+
pub type Character {
45
+
Character(id: String, name: String, status: CharacterStatus)
46
+
}
47
+
48
+
pub fn character_decoder() -> decode.Decoder(Character) {
49
+
use id <- decode.field("id", decode.string)
50
+
use name <- decode.field("name", decode.string)
51
+
use status <- decode.field("status", character_status_decoder())
52
+
decode.success(Character(id: id, name: name, status: status))
53
+
}
54
+
55
+
pub fn character_to_json(input: Character) -> json.Json {
56
+
json.object(
57
+
[
58
+
#("id", json.string(input.id)),
59
+
#("name", json.string(input.name)),
60
+
#("status", json.string(character_status_to_string(input.status))),
61
+
],
62
+
)
63
+
}
64
+
65
+
pub type FilterCharactersResponse {
66
+
FilterCharactersResponse(filter_characters: Option(List(Character)))
67
+
}
68
+
69
+
pub fn filter_characters_response_decoder() -> decode.Decoder(FilterCharactersResponse) {
70
+
use filter_characters <- decode.field("filterCharacters", decode.optional(decode.list(character_decoder())))
71
+
decode.success(FilterCharactersResponse(filter_characters: filter_characters))
72
+
}
73
+
74
+
pub fn filter_characters_response_to_json(input: FilterCharactersResponse) -> json.Json {
75
+
json.object(
76
+
[
77
+
#("filterCharacters", json.nullable(
78
+
input.filter_characters,
79
+
fn(list) { json.array(from: list, of: character_to_json) },
80
+
)),
81
+
],
82
+
)
83
+
}
84
+
85
+
pub fn filter_characters(client: squall.Client, status: CharacterStatus) -> Result(Request(String), String) {
86
+
squall.prepare_request(
87
+
client,
88
+
"\n mutation FilterCharacters($status: CharacterStatus!) {\n filterCharacters(status: $status) {\n id\n name\n status\n }\n }\n ",
89
+
json.object([#("status", json.string(character_status_to_string(status)))]),
90
+
)
91
+
}
92
+
93
+
pub fn parse_filter_characters_response(body: String) -> Result(FilterCharactersResponse, String) {
94
+
squall.parse_response(body, filter_characters_response_decoder())
95
+
}
96
+
+2
birdie_snapshots/mutation_with_input_object_variable.accepted
+2
birdie_snapshots/mutation_with_input_object_variable.accepted
+2
birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted
+2
birdie_snapshots/mutation_with_json_scalar_in_input_object.accepted
+2
birdie_snapshots/mutation_with_nested_input_object_types.accepted
+2
birdie_snapshots/mutation_with_nested_input_object_types.accepted
+97
birdie_snapshots/mutation_with_optional_enum_in_input_object.accepted
+97
birdie_snapshots/mutation_with_optional_enum_in_input_object.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Mutation with optional enum in InputObject
4
+
file: ./test/codegen_test.gleam
5
+
test_name: generate_mutation_with_optional_enum_in_input_test
6
+
---
7
+
import gleam/dynamic/decode
8
+
import gleam/http/request.{type Request}
9
+
import gleam/json
10
+
import squall
11
+
import gleam/option.{type Option, Some, None}
12
+
import gleam/list
13
+
14
+
pub type CharacterUpdateInput {
15
+
CharacterUpdateInput(
16
+
id: String,
17
+
name: Option(String),
18
+
status: Option(CharacterStatus),
19
+
)
20
+
}
21
+
22
+
fn character_update_input_to_json(input: CharacterUpdateInput) -> json.Json {
23
+
[Some(#("id", json.string(input.id))), {
24
+
case input.name {
25
+
Some(val) -> Some(#("name", json.string(val)))
26
+
None -> None
27
+
}
28
+
}, {
29
+
case input.status {
30
+
Some(val) -> Some(#("status", json.string(
31
+
character_status_to_string(val),
32
+
)))
33
+
None -> None
34
+
}
35
+
}]
36
+
|> list.filter_map(fn(x) {
37
+
case x {
38
+
Some(val) -> Ok(val)
39
+
None -> Error(Nil)
40
+
}
41
+
})
42
+
|> json.object
43
+
}
44
+
45
+
pub type Character {
46
+
Character(id: String, name: String, status: CharacterStatus)
47
+
}
48
+
49
+
pub fn character_decoder() -> decode.Decoder(Character) {
50
+
use id <- decode.field("id", decode.string)
51
+
use name <- decode.field("name", decode.string)
52
+
use status <- decode.field("status", character_status_decoder())
53
+
decode.success(Character(id: id, name: name, status: status))
54
+
}
55
+
56
+
pub fn character_to_json(input: Character) -> json.Json {
57
+
json.object(
58
+
[
59
+
#("id", json.string(input.id)),
60
+
#("name", json.string(input.name)),
61
+
#("status", json.string(character_status_to_string(input.status))),
62
+
],
63
+
)
64
+
}
65
+
66
+
pub type UpdateCharacterResponse {
67
+
UpdateCharacterResponse(update_character: Option(Character))
68
+
}
69
+
70
+
pub fn update_character_response_decoder() -> decode.Decoder(UpdateCharacterResponse) {
71
+
use update_character <- decode.field("updateCharacter", decode.optional(character_decoder()))
72
+
decode.success(UpdateCharacterResponse(update_character: update_character))
73
+
}
74
+
75
+
pub fn update_character_response_to_json(input: UpdateCharacterResponse) -> json.Json {
76
+
json.object(
77
+
[
78
+
#("updateCharacter", json.nullable(
79
+
input.update_character,
80
+
character_to_json,
81
+
)),
82
+
],
83
+
)
84
+
}
85
+
86
+
pub fn update_character(client: squall.Client, input: CharacterUpdateInput) -> Result(Request(String), String) {
87
+
squall.prepare_request(
88
+
client,
89
+
"\n mutation UpdateCharacter($input: CharacterUpdateInput!) {\n updateCharacter(input: $input) {\n id\n name\n status\n }\n }\n ",
90
+
json.object([#("input", character_update_input_to_json(input))]),
91
+
)
92
+
}
93
+
94
+
pub fn parse_update_character_response(body: String) -> Result(UpdateCharacterResponse, String) {
95
+
squall.parse_response(body, update_character_response_decoder())
96
+
}
97
+
+2
birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted
+2
birdie_snapshots/mutation_with_optional_input_object_fields_(imports_some,_none).accepted
+2
birdie_snapshots/query_with_all_non_nullable_fields_(no_option_import).accepted
+2
birdie_snapshots/query_with_all_non_nullable_fields_(no_option_import).accepted
+62
birdie_snapshots/query_with_enum_field_in_response.accepted
+62
birdie_snapshots/query_with_enum_field_in_response.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Query with enum field in response
4
+
file: ./test/codegen_test.gleam
5
+
test_name: generate_query_with_enum_field_test
6
+
---
7
+
import gleam/dynamic/decode
8
+
import gleam/http/request.{type Request}
9
+
import gleam/json
10
+
import squall
11
+
import gleam/option.{type Option}
12
+
13
+
pub type Character {
14
+
Character(id: String, name: String, status: CharacterStatus)
15
+
}
16
+
17
+
pub fn character_decoder() -> decode.Decoder(Character) {
18
+
use id <- decode.field("id", decode.string)
19
+
use name <- decode.field("name", decode.string)
20
+
use status <- decode.field("status", character_status_decoder())
21
+
decode.success(Character(id: id, name: name, status: status))
22
+
}
23
+
24
+
pub fn character_to_json(input: Character) -> json.Json {
25
+
json.object(
26
+
[
27
+
#("id", json.string(input.id)),
28
+
#("name", json.string(input.name)),
29
+
#("status", json.string(character_status_to_string(input.status))),
30
+
],
31
+
)
32
+
}
33
+
34
+
pub type GetCharacterResponse {
35
+
GetCharacterResponse(character: Option(Character))
36
+
}
37
+
38
+
pub fn get_character_response_decoder() -> decode.Decoder(GetCharacterResponse) {
39
+
use character <- decode.field("character", decode.optional(character_decoder()))
40
+
decode.success(GetCharacterResponse(character: character))
41
+
}
42
+
43
+
pub fn get_character_response_to_json(input: GetCharacterResponse) -> json.Json {
44
+
json.object(
45
+
[
46
+
#("character", json.nullable(input.character, character_to_json)),
47
+
],
48
+
)
49
+
}
50
+
51
+
pub fn get_character(client: squall.Client) -> Result(Request(String), String) {
52
+
squall.prepare_request(
53
+
client,
54
+
"\n query GetCharacter {\n character {\n id\n name\n status\n }\n }\n ",
55
+
json.object([]),
56
+
)
57
+
}
58
+
59
+
pub fn parse_get_character_response(body: String) -> Result(GetCharacterResponse, String) {
60
+
squall.parse_response(body, get_character_response_decoder())
61
+
}
62
+
+2
birdie_snapshots/query_with_inline_array_arguments.accepted
+2
birdie_snapshots/query_with_inline_array_arguments.accepted
+2
birdie_snapshots/query_with_inline_object_arguments.accepted
+2
birdie_snapshots/query_with_inline_object_arguments.accepted
+2
birdie_snapshots/query_with_inline_scalar_arguments.accepted
+2
birdie_snapshots/query_with_inline_scalar_arguments.accepted
+2
birdie_snapshots/query_with_json_scalar_field.accepted
+2
birdie_snapshots/query_with_json_scalar_field.accepted
+2
birdie_snapshots/query_with_multiple_root_fields_and_mixed_arguments.accepted
+2
birdie_snapshots/query_with_multiple_root_fields_and_mixed_arguments.accepted
+2
birdie_snapshots/query_with_nested_types_generation.accepted
+2
birdie_snapshots/query_with_nested_types_generation.accepted
+72
birdie_snapshots/query_with_optional_enum_field.accepted
+72
birdie_snapshots/query_with_optional_enum_field.accepted
···
1
+
---
2
+
version: 1.4.1
3
+
title: Query with optional enum field
4
+
file: ./test/codegen_test.gleam
5
+
test_name: generate_query_with_optional_enum_field_test
6
+
---
7
+
import gleam/dynamic/decode
8
+
import gleam/http/request.{type Request}
9
+
import gleam/json
10
+
import squall
11
+
import gleam/option.{type Option}
12
+
13
+
pub type Character {
14
+
Character(
15
+
id: String,
16
+
name: String,
17
+
status: CharacterStatus,
18
+
gender: Option(Gender),
19
+
)
20
+
}
21
+
22
+
pub fn character_decoder() -> decode.Decoder(Character) {
23
+
use id <- decode.field("id", decode.string)
24
+
use name <- decode.field("name", decode.string)
25
+
use status <- decode.field("status", character_status_decoder())
26
+
use gender <- decode.field("gender", decode.optional(gender_decoder()))
27
+
decode.success(Character(id: id, name: name, status: status, gender: gender))
28
+
}
29
+
30
+
pub fn character_to_json(input: Character) -> json.Json {
31
+
json.object(
32
+
[
33
+
#("id", json.string(input.id)),
34
+
#("name", json.string(input.name)),
35
+
#("status", json.string(character_status_to_string(input.status))),
36
+
#("gender", json.nullable(
37
+
input.gender,
38
+
fn(v) { json.string(gender_to_string(v)) },
39
+
)),
40
+
],
41
+
)
42
+
}
43
+
44
+
pub type GetCharacterResponse {
45
+
GetCharacterResponse(character: Option(Character))
46
+
}
47
+
48
+
pub fn get_character_response_decoder() -> decode.Decoder(GetCharacterResponse) {
49
+
use character <- decode.field("character", decode.optional(character_decoder()))
50
+
decode.success(GetCharacterResponse(character: character))
51
+
}
52
+
53
+
pub fn get_character_response_to_json(input: GetCharacterResponse) -> json.Json {
54
+
json.object(
55
+
[
56
+
#("character", json.nullable(input.character, character_to_json)),
57
+
],
58
+
)
59
+
}
60
+
61
+
pub fn get_character(client: squall.Client) -> Result(Request(String), String) {
62
+
squall.prepare_request(
63
+
client,
64
+
"\n query GetCharacter {\n character {\n id\n name\n status\n gender\n }\n }\n ",
65
+
json.object([]),
66
+
)
67
+
}
68
+
69
+
pub fn parse_get_character_response(body: String) -> Result(GetCharacterResponse, String) {
70
+
squall.parse_response(body, get_character_response_decoder())
71
+
}
72
+
+2
birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted
+2
birdie_snapshots/query_with_optional_response_fields_(no_some,_none_imports).accepted
+2
birdie_snapshots/query_with_simple_fragment_spread.accepted
+2
birdie_snapshots/query_with_simple_fragment_spread.accepted
+2
birdie_snapshots/query_with_variables_function_generation.accepted
+2
birdie_snapshots/query_with_variables_function_generation.accepted
+2
birdie_snapshots/response_serializer_for_simple_type.accepted
+2
birdie_snapshots/response_serializer_for_simple_type.accepted
+2
birdie_snapshots/response_serializer_with_all_scalar_types.accepted
+2
birdie_snapshots/response_serializer_with_all_scalar_types.accepted
+2
birdie_snapshots/response_serializer_with_lists.accepted
+2
birdie_snapshots/response_serializer_with_lists.accepted
+2
birdie_snapshots/response_serializer_with_nested_types.accepted
+2
birdie_snapshots/response_serializer_with_nested_types.accepted
+2
birdie_snapshots/response_serializer_with_optional_fields.accepted
+2
birdie_snapshots/response_serializer_with_optional_fields.accepted
+2
birdie_snapshots/simple_query_function_generation.accepted
+2
birdie_snapshots/simple_query_function_generation.accepted
+2
birdie_snapshots/type_with_reserved_keywords.accepted
+2
birdie_snapshots/type_with_reserved_keywords.accepted
+1
-1
examples/02-lustre/src/graphql/get_characters.gleam
+1
-1
examples/02-lustre/src/graphql/get_characters.gleam
···
78
78
pub fn get_characters(client: squall.Client) -> Result(Request(String), String) {
79
79
squall.prepare_request(
80
80
client,
81
-
"query GetCharacters {\n characters {\n results {\n id\n name\n status\n species\n }\n }\n}\n",
81
+
"query GetCharacters {\n characters {\n results {\n id\n name\n status\n species\n }\n }\n}\n",
82
82
json.object([]),
83
83
)
84
84
}
+1
-1
gleam.toml
+1
-1
gleam.toml
+5
-5
manifest.toml
+5
-5
manifest.toml
···
13
13
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
14
14
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
15
15
{ name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" },
16
-
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
16
+
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
17
17
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
18
18
{ name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" },
19
-
{ name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" },
19
+
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
20
20
{ name = "glexer", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "40A1FB0919FA080AD6C5809B4C7DBA545841CAAC8168FACDFA0B0667C22475CC" },
21
21
{ name = "justin", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "7FA0C6DB78640C6DC5FBFD59BF3456009F3F8B485BF6825E97E1EB44E9A1E2CD" },
22
22
{ name = "rank", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "5660E361F0E49CBB714CC57CC4C89C63415D8986F05B2DA0C719D5642FAD91C9" },
23
-
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
24
-
{ name = "splitter", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "05564A381580395DCDEFF4F88A64B021E8DAFA6540AE99B4623962F52976AA9D" },
25
-
{ name = "swell", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "7CCA8C61349396C5B59B3C0627185F5B30917044E0D61CB7E0E5CC75C1B4A8E9" },
23
+
{ name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" },
24
+
{ name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" },
25
+
{ name = "swell", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "swell", source = "hex", outer_checksum = "DF721E11F17F5FF6B53466FBEB49D44CAF06A5E8115CF63D6899EC7A16D4F527" },
26
26
{ name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" },
27
27
{ name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" },
28
28
]
+375
-60
src/squall/internal/codegen.gleam
+375
-60
src/squall/internal/codegen.gleam
···
36
36
)
37
37
}
38
38
39
+
// Type to track enum types that need to be generated
40
+
type EnumTypeInfo {
41
+
EnumTypeInfo(type_name: String, enum_values: List(String))
42
+
}
43
+
39
44
// --- CONSTANTS ---------------------------------------------------------------
40
45
41
46
const indent = 2
···
104
109
105
110
/// Sanitize field names by converting to snake_case and appending underscore to reserved keywords
106
111
fn sanitize_field_name(name: String) -> String {
107
-
let snake_cased = snake_case(name)
112
+
// Handle GraphQL introspection fields by removing __ prefix
113
+
let cleaned = case string.starts_with(name, "__") {
114
+
True -> string.drop_start(name, 2)
115
+
False -> name
116
+
}
117
+
let snake_cased = snake_case(cleaned)
108
118
case list.contains(reserved_keywords, snake_cased) {
109
119
True -> snake_cased <> "_"
110
120
False -> snake_cased
···
267
277
schema_data: schema.Schema,
268
278
_graphql_endpoint: String,
269
279
) -> Result(String, Error) {
280
+
// Note: typename injection is disabled for the generate command
281
+
// It's only enabled for unstable-cache command which needs it for cache normalization
282
+
let modified_source = source
283
+
270
284
// Extract selections and expand any fragments
271
285
let selections = graphql_ast.get_selections(operation)
272
286
···
375
389
})
376
390
|> list.flatten
377
391
378
-
// Use the original source string directly (includes fragment definitions)
392
+
// Collect Enum types from variables and response fields
393
+
use enum_types <- result.try(collect_enum_types(
394
+
variables,
395
+
field_types,
396
+
schema_data.types,
397
+
))
398
+
399
+
// Generate Enum type definitions, to_string functions, and decoders
400
+
let enum_docs =
401
+
enum_types
402
+
|> list.map(fn(enum_info) {
403
+
let type_doc = generate_enum_type_definition(enum_info)
404
+
let to_string_doc = generate_enum_to_string(enum_info)
405
+
let decoder_doc = generate_enum_decoder(enum_info)
406
+
[type_doc, to_string_doc, decoder_doc]
407
+
})
408
+
|> list.flatten
409
+
410
+
// Use the modified source string with __typename injected
379
411
let function_def =
380
412
generate_function(
381
413
operation_name,
382
414
response_type_name,
383
415
variables,
384
-
source,
416
+
modified_source,
385
417
schema_data.types,
386
418
)
387
419
···
421
453
)
422
454
423
455
// Combine all code using doc combinators
424
-
// Order: imports, input types, nested types, nested serializers, response type, response decoder, response serializer, function
456
+
// Order: imports, enum types, input types, nested types, nested serializers, response type, response decoder, response serializer, function
425
457
let all_docs =
426
-
[imports, ..input_docs]
458
+
[imports, ..enum_docs]
459
+
|> list.append(input_docs)
427
460
|> list.append(nested_docs)
428
461
|> list.append(nested_serializer_docs)
429
462
|> list.append([type_def, decoder, response_serializer, function_def])
···
451
484
|> list.try_map(fn(selection) {
452
485
case selection {
453
486
parser.Field(field_name, _alias, _args, _nested) -> {
454
-
// Find field in parent type
455
-
let fields = schema.get_type_fields(parent_type)
456
-
let field_result =
457
-
fields
458
-
|> list.find(fn(f) { f.name == field_name })
487
+
// Handle special introspection fields
488
+
case field_name {
489
+
"__typename" -> {
490
+
// __typename is a special meta-field that returns String!
491
+
Ok(#(field_name, schema.NamedType("String", schema.Scalar)))
492
+
}
493
+
_ -> {
494
+
// Find field in parent type
495
+
let fields = schema.get_type_fields(parent_type)
496
+
let field_result =
497
+
fields
498
+
|> list.find(fn(f) { f.name == field_name })
459
499
460
-
field_result
461
-
|> result.map(fn(field) { #(field_name, field.type_ref) })
462
-
|> result.map_error(fn(_) {
463
-
error.InvalidSchemaResponse("Field not found: " <> field_name)
464
-
})
500
+
field_result
501
+
|> result.map(fn(field) { #(field_name, field.type_ref) })
502
+
|> result.map_error(fn(_) {
503
+
error.InvalidSchemaResponse("Field not found: " <> field_name)
504
+
})
505
+
}
506
+
}
465
507
}
466
508
_ ->
467
509
Error(error.InvalidGraphQLSyntax(
···
483
525
|> list.try_map(fn(selection) {
484
526
case selection {
485
527
parser.Field(field_name, _alias, _args, nested_selections) -> {
486
-
// Find field in parent type
487
-
let fields = schema.get_type_fields(parent_type)
488
-
use field <- result.try(
489
-
fields
490
-
|> list.find(fn(f) { f.name == field_name })
491
-
|> result.map_error(fn(_) {
492
-
error.InvalidSchemaResponse("Field not found: " <> field_name)
493
-
}),
494
-
)
495
-
496
-
// Check if this field has nested selections
497
-
case nested_selections {
498
-
[] -> Ok([])
528
+
// Handle special introspection fields
529
+
case field_name {
530
+
"__typename" -> {
531
+
// __typename is a special meta-field that has no nested selections
532
+
Ok([])
533
+
}
499
534
_ -> {
500
-
// Get the type name from the field's type reference
501
-
let type_name = get_base_type_name(field.type_ref)
502
-
503
-
// Look up the type in schema
504
-
use field_type <- result.try(
505
-
dict.get(schema_data.types, type_name)
535
+
// Find field in parent type
536
+
let fields = schema.get_type_fields(parent_type)
537
+
use field <- result.try(
538
+
fields
539
+
|> list.find(fn(f) { f.name == field_name })
506
540
|> result.map_error(fn(_) {
507
-
error.InvalidSchemaResponse("Type not found: " <> type_name)
541
+
error.InvalidSchemaResponse("Field not found: " <> field_name)
508
542
}),
509
543
)
510
544
511
-
// Collect field types for this nested object
512
-
use nested_field_types <- result.try(collect_field_types(
513
-
nested_selections,
514
-
field_type,
515
-
))
545
+
// Check if this field has nested selections
546
+
case nested_selections {
547
+
[] -> Ok([])
548
+
_ -> {
549
+
// Get the type name from the field's type reference
550
+
let type_name = get_base_type_name(field.type_ref)
516
551
517
-
// Recursively collect any deeper nested types
518
-
use deeper_nested <- result.try(collect_nested_types(
519
-
nested_selections,
520
-
field_type,
521
-
schema_data,
522
-
))
552
+
// Look up the type in schema
553
+
use field_type <- result.try(
554
+
dict.get(schema_data.types, type_name)
555
+
|> result.map_error(fn(_) {
556
+
error.InvalidSchemaResponse("Type not found: " <> type_name)
557
+
}),
558
+
)
523
559
524
-
let nested_info =
525
-
NestedTypeInfo(
526
-
type_name: type_name,
527
-
fields: nested_field_types,
528
-
field_types: schema_data.types,
529
-
)
560
+
// Collect field types for this nested object
561
+
use nested_field_types <- result.try(collect_field_types(
562
+
nested_selections,
563
+
field_type,
564
+
))
530
565
531
-
Ok([nested_info, ..deeper_nested])
566
+
// Recursively collect any deeper nested types
567
+
use deeper_nested <- result.try(collect_nested_types(
568
+
nested_selections,
569
+
field_type,
570
+
schema_data,
571
+
))
572
+
573
+
let nested_info =
574
+
NestedTypeInfo(
575
+
type_name: type_name,
576
+
fields: nested_field_types,
577
+
field_types: schema_data.types,
578
+
)
579
+
580
+
Ok([nested_info, ..deeper_nested])
581
+
}
582
+
}
532
583
}
533
584
}
534
585
}
···
650
701
}
651
702
}
652
703
704
+
// Collect all Enum types used in variables and response fields
705
+
fn collect_enum_types(
706
+
variables: List(graphql_ast.Variable),
707
+
field_types: List(#(String, schema.TypeRef)),
708
+
schema_types: dict.Dict(String, schema.Type),
709
+
) -> Result(List(EnumTypeInfo), Error) {
710
+
// Collect from variables
711
+
let var_enums =
712
+
variables
713
+
|> list.try_map(fn(var) {
714
+
let type_str = graphql_ast.get_variable_type_string(var)
715
+
use schema_type_ref <- result.try(
716
+
type_mapping.parse_type_string_with_schema(type_str, schema_types),
717
+
)
718
+
collect_enum_types_from_type_ref(schema_type_ref, schema_types, [])
719
+
})
720
+
|> result.map(list.flatten)
721
+
722
+
// Collect from response fields
723
+
let field_enums =
724
+
field_types
725
+
|> list.map(fn(field) {
726
+
let #(_name, type_ref) = field
727
+
collect_enum_types_from_type_ref(type_ref, schema_types, [])
728
+
})
729
+
|> result.all
730
+
|> result.map(list.flatten)
731
+
732
+
use var_enum_list <- result.try(var_enums)
733
+
use field_enum_list <- result.try(field_enums)
734
+
735
+
// Combine and deduplicate
736
+
Ok(
737
+
list.append(var_enum_list, field_enum_list)
738
+
|> list.fold(dict.new(), fn(acc, info) {
739
+
dict.insert(acc, info.type_name, info)
740
+
})
741
+
|> dict.values,
742
+
)
743
+
}
744
+
745
+
// Recursively collect Enum types from a type reference
746
+
fn collect_enum_types_from_type_ref(
747
+
type_ref: schema.TypeRef,
748
+
schema_types: dict.Dict(String, schema.Type),
749
+
collected: List(EnumTypeInfo),
750
+
) -> Result(List(EnumTypeInfo), Error) {
751
+
case type_ref {
752
+
schema.NamedType(name, kind) -> {
753
+
case kind {
754
+
schema.Enum -> {
755
+
// Check if we've already collected this enum
756
+
let already_collected =
757
+
list.any(collected, fn(info) { info.type_name == name })
758
+
759
+
case already_collected {
760
+
True -> Ok(collected)
761
+
False -> {
762
+
// Look up the Enum type in schema
763
+
use enum_type <- result.try(
764
+
dict.get(schema_types, name)
765
+
|> result.map_error(fn(_) {
766
+
error.InvalidSchemaResponse("Enum type not found: " <> name)
767
+
}),
768
+
)
769
+
770
+
case enum_type {
771
+
schema.EnumType(_, enum_values, _) -> {
772
+
let info =
773
+
EnumTypeInfo(type_name: name, enum_values: enum_values)
774
+
Ok([info, ..collected])
775
+
}
776
+
_ ->
777
+
Error(error.InvalidSchemaResponse(
778
+
"Expected Enum type: " <> name,
779
+
))
780
+
}
781
+
}
782
+
}
783
+
}
784
+
_ -> Ok(collected)
785
+
}
786
+
}
787
+
schema.NonNullType(inner) ->
788
+
collect_enum_types_from_type_ref(inner, schema_types, collected)
789
+
schema.ListType(inner) ->
790
+
collect_enum_types_from_type_ref(inner, schema_types, collected)
791
+
}
792
+
}
793
+
653
794
// Generate type definition
654
795
fn generate_type_definition(
655
796
type_name: String,
···
768
909
"decode.optional(" <> inner_decoder <> ")"
769
910
}
770
911
type_mapping.CustomType(name) -> {
771
-
// Check if this is an object type that has a decoder
912
+
// Check if this is an object or enum type that has a decoder
772
913
case dict.get(schema_types, name) {
773
914
Ok(schema.ObjectType(_, _, _)) -> snake_case(name) <> "_decoder()"
915
+
Ok(schema.EnumType(_, _, _)) -> snake_case(name) <> "_decoder()"
774
916
_ -> "decode.dynamic"
775
917
}
776
918
}
···
799
941
type_mapping.CustomType(_) ->
800
942
case dict.get(schema_types, base_type_name) {
801
943
Ok(schema.ObjectType(_, _, _)) ->
944
+
snake_case(base_type_name) <> "_decoder()"
945
+
Ok(schema.EnumType(_, _, _)) ->
802
946
snake_case(base_type_name) <> "_decoder()"
803
947
_ -> "decode.dynamic"
804
948
}
···
1043
1187
}
1044
1188
}
1045
1189
type_mapping.CustomType(name) -> {
1046
-
// This is an InputObject
1047
-
call_doc(snake_case(name) <> "_to_json", [doc.from_string(field_access)])
1190
+
// Check if this is an Enum or InputObject
1191
+
case dict.get(schema_types, name) {
1192
+
Ok(schema.EnumType(_, _, _)) ->
1193
+
// Enum: convert to string first, then wrap in json.string
1194
+
call_doc("json.string", [
1195
+
doc.from_string(
1196
+
snake_case(name) <> "_to_string(" <> field_access <> ")",
1197
+
),
1198
+
])
1199
+
_ ->
1200
+
// InputObject
1201
+
call_doc(snake_case(name) <> "_to_json", [
1202
+
doc.from_string(field_access),
1203
+
])
1204
+
}
1048
1205
}
1049
1206
}
1050
1207
}
1051
1208
1209
+
// Generate Enum type definition
1210
+
fn generate_enum_type_definition(enum_info: EnumTypeInfo) -> Document {
1211
+
let variant_docs =
1212
+
enum_info.enum_values
1213
+
|> list.map(fn(value) { doc.from_string(to_pascal_case(value)) })
1214
+
1215
+
[
1216
+
doc.from_string("pub type " <> enum_info.type_name <> " {"),
1217
+
[
1218
+
doc.line,
1219
+
doc.join(variant_docs, doc.line),
1220
+
]
1221
+
|> doc.concat
1222
+
|> doc.nest(by: indent),
1223
+
doc.line,
1224
+
doc.from_string("}"),
1225
+
]
1226
+
|> doc.concat
1227
+
|> doc.group
1228
+
}
1229
+
1230
+
// Generate Enum to String conversion function
1231
+
fn generate_enum_to_string(enum_info: EnumTypeInfo) -> Document {
1232
+
let function_name = snake_case(enum_info.type_name) <> "_to_string"
1233
+
1234
+
let match_arms =
1235
+
enum_info.enum_values
1236
+
|> list.map(fn(value) {
1237
+
doc.concat([
1238
+
doc.from_string(to_pascal_case(value) <> " -> "),
1239
+
string_doc(value),
1240
+
])
1241
+
})
1242
+
1243
+
let case_expr =
1244
+
[
1245
+
doc.from_string("case value {"),
1246
+
[doc.line, doc.join(match_arms, doc.line)]
1247
+
|> doc.concat
1248
+
|> doc.nest(by: indent),
1249
+
doc.line,
1250
+
doc.from_string("}"),
1251
+
]
1252
+
|> doc.concat
1253
+
1254
+
doc.concat([
1255
+
doc.from_string(
1256
+
"pub fn "
1257
+
<> function_name
1258
+
<> "(value: "
1259
+
<> enum_info.type_name
1260
+
<> ") -> String ",
1261
+
),
1262
+
block([case_expr]),
1263
+
])
1264
+
}
1265
+
1266
+
// Generate Enum decoder function
1267
+
fn generate_enum_decoder(enum_info: EnumTypeInfo) -> Document {
1268
+
let decoder_name = snake_case(enum_info.type_name) <> "_decoder"
1269
+
1270
+
let match_arms =
1271
+
enum_info.enum_values
1272
+
|> list.map(fn(value) {
1273
+
doc.concat([
1274
+
string_doc(value),
1275
+
doc.from_string(" -> decode.success(" <> to_pascal_case(value) <> ")"),
1276
+
])
1277
+
})
1278
+
1279
+
// Use the first enum variant as the zero value for failure
1280
+
let first_variant = case enum_info.enum_values {
1281
+
[first, ..] -> to_pascal_case(first)
1282
+
[] -> "UnknownVariant"
1283
+
}
1284
+
1285
+
let all_match_arms =
1286
+
list.append(match_arms, [
1287
+
doc.concat([
1288
+
doc.from_string("_other -> decode.failure("),
1289
+
doc.from_string(first_variant),
1290
+
doc.from_string(", "),
1291
+
string_doc(enum_info.type_name),
1292
+
doc.from_string(")"),
1293
+
]),
1294
+
])
1295
+
1296
+
let decoder_body = [
1297
+
doc.from_string("decode.string"),
1298
+
doc.line,
1299
+
doc.from_string("|> decode.then(fn(str) {"),
1300
+
[
1301
+
doc.line,
1302
+
doc.from_string("case str {"),
1303
+
[doc.line, doc.join(all_match_arms, doc.line)]
1304
+
|> doc.concat
1305
+
|> doc.nest(by: indent),
1306
+
doc.line,
1307
+
doc.from_string("}"),
1308
+
]
1309
+
|> doc.concat
1310
+
|> doc.nest(by: indent),
1311
+
doc.line,
1312
+
doc.from_string("})"),
1313
+
]
1314
+
1315
+
doc.concat([
1316
+
doc.from_string(
1317
+
"pub fn "
1318
+
<> decoder_name
1319
+
<> "() -> decode.Decoder("
1320
+
<> enum_info.type_name
1321
+
<> ") ",
1322
+
),
1323
+
block(decoder_body),
1324
+
])
1325
+
}
1326
+
1052
1327
// Generate Response serializer function (for output types)
1053
1328
fn generate_response_serializer(
1054
1329
type_name: String,
···
1131
1406
doc.from_string("of: " <> snake_case(base_type_name) <> "_to_json"),
1132
1407
])
1133
1408
}
1409
+
Ok(schema.EnumType(_, _, _)) -> {
1410
+
// List of Enums
1411
+
call_doc("json.array", [
1412
+
doc.from_string("from: " <> field_access),
1413
+
doc.from_string(
1414
+
"of: fn(v) { json.string("
1415
+
<> snake_case(base_type_name)
1416
+
<> "_to_string(v)) }",
1417
+
),
1418
+
])
1419
+
}
1134
1420
_ -> {
1135
1421
// List of scalars
1136
1422
let of_fn = case inner {
···
1165
1451
case dict.get(schema_types, name) {
1166
1452
Ok(schema.ObjectType(_, _, _)) ->
1167
1453
snake_case(name) <> "_to_json"
1454
+
Ok(schema.EnumType(_, _, _)) ->
1455
+
"fn(v) { json.string("
1456
+
<> snake_case(name)
1457
+
<> "_to_string(v)) }"
1168
1458
_ -> "json.string"
1169
1459
}
1170
1460
_ -> "json.string"
···
1179
1469
])
1180
1470
}
1181
1471
_ -> {
1182
-
// Not a list, check if it's an object or scalar
1472
+
// Not a list, check if it's an object, enum, or scalar
1183
1473
let base_type_name = get_base_type_name(type_ref)
1184
1474
case dict.get(schema_types, base_type_name) {
1185
1475
Ok(schema.ObjectType(_, _, _)) -> {
···
1189
1479
doc.from_string(snake_case(base_type_name) <> "_to_json"),
1190
1480
])
1191
1481
}
1482
+
Ok(schema.EnumType(_, _, _)) -> {
1483
+
// Optional Enum
1484
+
call_doc("json.nullable", [
1485
+
doc.from_string(field_access),
1486
+
doc.from_string(
1487
+
"fn(v) { json.string("
1488
+
<> snake_case(base_type_name)
1489
+
<> "_to_string(v)) }",
1490
+
),
1491
+
])
1492
+
}
1192
1493
_ -> {
1193
1494
// Optional scalar
1194
1495
let inner_encoder = case inner {
···
1209
1510
}
1210
1511
}
1211
1512
type_mapping.CustomType(name) -> {
1212
-
// This is an Object type
1513
+
// Check if this is an Object, Enum, or other custom type
1213
1514
case dict.get(schema_types, name) {
1214
1515
Ok(schema.ObjectType(_, _, _)) ->
1215
1516
call_doc(snake_case(name) <> "_to_json", [
1216
1517
doc.from_string(field_access),
1217
1518
])
1519
+
Ok(schema.EnumType(_, _, _)) ->
1520
+
// Enum: convert to string first, then wrap in json.string
1521
+
call_doc("json.string", [
1522
+
doc.from_string(
1523
+
snake_case(name) <> "_to_string(" <> field_access <> ")",
1524
+
),
1525
+
])
1218
1526
_ ->
1219
-
// Fallback to string if not an object type
1527
+
// Fallback to string if not an object or enum type
1220
1528
call_doc("json.string", [doc.from_string(field_access)])
1221
1529
}
1222
1530
}
···
1432
1740
}
1433
1741
}
1434
1742
type_mapping.CustomType(name) -> {
1435
-
// Check if this is an InputObject
1743
+
// Check if this is an Enum or InputObject
1436
1744
case dict.get(schema_types, name) {
1745
+
Ok(schema.EnumType(_, _, _)) ->
1746
+
// Enum: convert to string first, then wrap in json.string
1747
+
call_doc("json.string", [
1748
+
doc.from_string(
1749
+
snake_case(name) <> "_to_string(" <> var_name <> ")",
1750
+
),
1751
+
])
1437
1752
Ok(schema.InputObjectType(_, _, _)) ->
1438
1753
call_doc(snake_case(name) <> "_to_json", [doc.from_string(var_name)])
1439
1754
_ -> call_doc("json.string", [doc.from_string(var_name)])
+174
src/squall/internal/query_extractor.gleam
+174
src/squall/internal/query_extractor.gleam
···
1
+
import gleam/list
2
+
import gleam/option.{type Option, None, Some}
3
+
import gleam/result
4
+
import gleam/string
5
+
import simplifile
6
+
import squall/internal/graphql_ast
7
+
8
+
/// A GraphQL query definition extracted from source code
9
+
pub type QueryDefinition {
10
+
QueryDefinition(name: String, query: String, file_path: String)
11
+
}
12
+
13
+
/// Scan for component files containing @squall-query markers
14
+
pub fn scan_component_files(root: String) -> Result(List(String), String) {
15
+
scan_directory_recursive(root, [])
16
+
}
17
+
18
+
fn scan_directory_recursive(
19
+
path: String,
20
+
acc: List(String),
21
+
) -> Result(List(String), String) {
22
+
case simplifile.read_directory(path) {
23
+
Ok(entries) -> {
24
+
list.fold(entries, Ok(acc), fn(result_acc, entry) {
25
+
case result_acc {
26
+
Error(e) -> Error(e)
27
+
Ok(files) -> {
28
+
let entry_path = path <> "/" <> entry
29
+
case simplifile.is_file(entry_path) {
30
+
Ok(True) ->
31
+
case string.ends_with(entry, ".gleam") {
32
+
True -> Ok(list.append(files, [entry_path]))
33
+
False -> Ok(files)
34
+
}
35
+
_ ->
36
+
case simplifile.is_directory(entry_path) {
37
+
Ok(True) -> scan_directory_recursive(entry_path, files)
38
+
_ -> Ok(files)
39
+
}
40
+
}
41
+
}
42
+
}
43
+
})
44
+
}
45
+
Error(_) -> Error("Failed to read directory: " <> path)
46
+
}
47
+
}
48
+
49
+
/// Extract query definitions from a Gleam source file
50
+
pub fn extract_from_file(
51
+
file_path: String,
52
+
) -> Result(List(QueryDefinition), String) {
53
+
case simplifile.read(file_path) {
54
+
Ok(content) -> extract_from_content(content, file_path)
55
+
Error(_) -> Error("Failed to read file: " <> file_path)
56
+
}
57
+
}
58
+
59
+
/// Extract query definitions from file content
60
+
fn extract_from_content(
61
+
content: String,
62
+
file_path: String,
63
+
) -> Result(List(QueryDefinition), String) {
64
+
let lines = string.split(content, "\n")
65
+
let queries = find_queries_in_lines(lines, file_path, None, [])
66
+
Ok(queries)
67
+
}
68
+
69
+
/// Recursively process lines to find GraphQL query blocks
70
+
fn find_queries_in_lines(
71
+
lines: List(String),
72
+
file_path: String,
73
+
current_query: Option(List(String)),
74
+
accumulated: List(QueryDefinition),
75
+
) -> List(QueryDefinition) {
76
+
case lines {
77
+
[] -> {
78
+
// End of file - finalize any pending query
79
+
case current_query {
80
+
Some(query_lines) -> {
81
+
let query = string.join(query_lines, "\n")
82
+
case parse_operation_name(query) {
83
+
Ok(name) ->
84
+
list.append(accumulated, [
85
+
QueryDefinition(name: name, query: query, file_path: file_path),
86
+
])
87
+
Error(_) -> accumulated
88
+
}
89
+
}
90
+
None -> accumulated
91
+
}
92
+
}
93
+
[line, ..rest] -> {
94
+
let trimmed = string.trim(line)
95
+
96
+
case string.starts_with(trimmed, "/// ```graphql") {
97
+
True -> {
98
+
// Start collecting query lines
99
+
find_queries_in_lines(rest, file_path, Some([]), accumulated)
100
+
}
101
+
False ->
102
+
case string.starts_with(trimmed, "/// ```") {
103
+
True ->
104
+
case current_query {
105
+
Some(query_lines) -> {
106
+
// End of GraphQL block - parse and extract name
107
+
let query = string.join(query_lines, "\n")
108
+
case parse_operation_name(query) {
109
+
Ok(name) -> {
110
+
let new_accumulated =
111
+
list.append(accumulated, [
112
+
QueryDefinition(
113
+
name: name,
114
+
query: query,
115
+
file_path: file_path,
116
+
),
117
+
])
118
+
find_queries_in_lines(
119
+
rest,
120
+
file_path,
121
+
None,
122
+
new_accumulated,
123
+
)
124
+
}
125
+
Error(_) ->
126
+
// Skip queries that can't be parsed or don't have names
127
+
find_queries_in_lines(rest, file_path, None, accumulated)
128
+
}
129
+
}
130
+
None ->
131
+
find_queries_in_lines(
132
+
rest,
133
+
file_path,
134
+
current_query,
135
+
accumulated,
136
+
)
137
+
}
138
+
False ->
139
+
case current_query {
140
+
Some(query_lines) -> {
141
+
// Inside GraphQL block - collect line
142
+
let clean_line = string.replace(trimmed, "/// ", "")
143
+
let updated_lines = list.append(query_lines, [clean_line])
144
+
find_queries_in_lines(
145
+
rest,
146
+
file_path,
147
+
Some(updated_lines),
148
+
accumulated,
149
+
)
150
+
}
151
+
None ->
152
+
find_queries_in_lines(
153
+
rest,
154
+
file_path,
155
+
current_query,
156
+
accumulated,
157
+
)
158
+
}
159
+
}
160
+
}
161
+
}
162
+
}
163
+
}
164
+
165
+
/// Parse a GraphQL query and extract the operation name
166
+
fn parse_operation_name(query: String) -> Result(String, Nil) {
167
+
use document <- result.try(
168
+
graphql_ast.parse_document(query) |> result.replace_error(Nil),
169
+
)
170
+
use operation <- result.try(
171
+
graphql_ast.get_main_operation(document) |> result.replace_error(Nil),
172
+
)
173
+
graphql_ast.get_operation_name(operation) |> option.to_result(Nil)
174
+
}
+68
src/squall/internal/registry_codegen.gleam
+68
src/squall/internal/registry_codegen.gleam
···
1
+
import gleam/list
2
+
import gleam/string
3
+
import squall/internal/query_extractor.{type QueryDefinition}
4
+
5
+
/// Generate Gleam module code for query registry initialization
6
+
pub fn generate_registry_module(queries: List(QueryDefinition)) -> String {
7
+
let imports = generate_imports()
8
+
let init_function = generate_init_function(queries)
9
+
10
+
imports <> "\n\n" <> init_function
11
+
}
12
+
13
+
fn generate_imports() -> String {
14
+
"import squall/unstable_registry"
15
+
}
16
+
17
+
fn generate_init_function(queries: List(QueryDefinition)) -> String {
18
+
let registrations = list.map(queries, generate_registration)
19
+
let registrations_code = string.join(registrations, "\n ")
20
+
21
+
"/// Initialize the query registry with all extracted queries
22
+
/// This function is auto-generated by Squall
23
+
pub fn init_registry() -> unstable_registry.Registry {
24
+
let reg = unstable_registry.new()
25
+
" <> registrations_code <> "
26
+
reg
27
+
}"
28
+
}
29
+
30
+
fn generate_registration(query: QueryDefinition) -> String {
31
+
let module_path = "generated/queries/" <> to_snake_case(query.name)
32
+
let escaped_query = escape_string(query.query)
33
+
34
+
"let reg = unstable_registry.register(
35
+
reg,
36
+
\"" <> query.name <> "\",
37
+
\"" <> escaped_query <> "\",
38
+
\"" <> module_path <> "\",
39
+
)"
40
+
}
41
+
42
+
fn escape_string(s: String) -> String {
43
+
s
44
+
|> string.replace("\\", "\\\\")
45
+
|> string.replace("\"", "\\\"")
46
+
|> string.replace("\n", "\\n")
47
+
}
48
+
49
+
fn to_snake_case(s: String) -> String {
50
+
// Simple conversion: GetCharacters -> get_characters
51
+
s
52
+
|> string.to_graphemes
53
+
|> list.index_fold([], fn(acc, char, index) {
54
+
case is_uppercase(char) {
55
+
True ->
56
+
case index {
57
+
0 -> list.append(acc, [string.lowercase(char)])
58
+
_ -> list.append(acc, ["_", string.lowercase(char)])
59
+
}
60
+
False -> list.append(acc, [char])
61
+
}
62
+
})
63
+
|> string.join("")
64
+
}
65
+
66
+
fn is_uppercase(s: String) -> Bool {
67
+
s == string.uppercase(s) && s != string.lowercase(s)
68
+
}
+407
src/squall/internal/typename_injector.gleam
+407
src/squall/internal/typename_injector.gleam
···
1
+
import gleam/dict
2
+
import gleam/list
3
+
import gleam/option.{None, Some}
4
+
import gleam/result
5
+
import squall/internal/error.{type Error}
6
+
import squall/internal/graphql_ast
7
+
import squall/internal/schema
8
+
import swell/parser
9
+
10
+
/// Automatically inject __typename into selection sets for Object/Interface/Union types
11
+
/// Only injects if __typename is not already present
12
+
pub fn inject_typename(
13
+
query_string: String,
14
+
schema_data: schema.Schema,
15
+
) -> Result(String, Error) {
16
+
// Parse the query string into an AST
17
+
use document <- result.try(graphql_ast.parse_document(query_string))
18
+
19
+
let parser.Document(operations) = document
20
+
21
+
// Process each operation
22
+
let modified_operations =
23
+
list.map(operations, fn(operation) {
24
+
process_operation(operation, schema_data)
25
+
})
26
+
27
+
// Reconstruct document
28
+
let modified_document = parser.Document(modified_operations)
29
+
30
+
// Serialize back to string
31
+
Ok(serialize_document(modified_document))
32
+
}
33
+
34
+
/// Process a single operation to inject __typename
35
+
fn process_operation(
36
+
operation: parser.Operation,
37
+
schema_data: schema.Schema,
38
+
) -> parser.Operation {
39
+
case operation {
40
+
parser.Query(parser.SelectionSet(selections)) -> {
41
+
let root_type = get_root_type(schema_data.query_type, schema_data)
42
+
let modified_selections =
43
+
inject_into_selections(selections, root_type, schema_data, True)
44
+
parser.Query(parser.SelectionSet(modified_selections))
45
+
}
46
+
parser.NamedQuery(name, variables, parser.SelectionSet(selections)) -> {
47
+
let root_type = get_root_type(schema_data.query_type, schema_data)
48
+
let modified_selections =
49
+
inject_into_selections(selections, root_type, schema_data, True)
50
+
parser.NamedQuery(
51
+
name,
52
+
variables,
53
+
parser.SelectionSet(modified_selections),
54
+
)
55
+
}
56
+
parser.Mutation(parser.SelectionSet(selections)) -> {
57
+
let root_type = get_root_type(schema_data.mutation_type, schema_data)
58
+
let modified_selections =
59
+
inject_into_selections(selections, root_type, schema_data, True)
60
+
parser.Mutation(parser.SelectionSet(modified_selections))
61
+
}
62
+
parser.NamedMutation(name, variables, parser.SelectionSet(selections)) -> {
63
+
let root_type = get_root_type(schema_data.mutation_type, schema_data)
64
+
let modified_selections =
65
+
inject_into_selections(selections, root_type, schema_data, True)
66
+
parser.NamedMutation(
67
+
name,
68
+
variables,
69
+
parser.SelectionSet(modified_selections),
70
+
)
71
+
}
72
+
parser.Subscription(parser.SelectionSet(selections)) -> {
73
+
let root_type = get_root_type(schema_data.subscription_type, schema_data)
74
+
let modified_selections =
75
+
inject_into_selections(selections, root_type, schema_data, True)
76
+
parser.Subscription(parser.SelectionSet(modified_selections))
77
+
}
78
+
parser.NamedSubscription(name, variables, parser.SelectionSet(selections)) -> {
79
+
let root_type = get_root_type(schema_data.subscription_type, schema_data)
80
+
let modified_selections =
81
+
inject_into_selections(selections, root_type, schema_data, True)
82
+
parser.NamedSubscription(
83
+
name,
84
+
variables,
85
+
parser.SelectionSet(modified_selections),
86
+
)
87
+
}
88
+
parser.FragmentDefinition(
89
+
name,
90
+
type_condition,
91
+
parser.SelectionSet(selections),
92
+
) -> {
93
+
let fragment_type = case dict.get(schema_data.types, type_condition) {
94
+
Ok(t) -> t
95
+
Error(_) -> schema.ScalarType(type_condition, None)
96
+
}
97
+
let modified_selections =
98
+
inject_into_selections(selections, fragment_type, schema_data, False)
99
+
parser.FragmentDefinition(
100
+
name,
101
+
type_condition,
102
+
parser.SelectionSet(modified_selections),
103
+
)
104
+
}
105
+
}
106
+
}
107
+
108
+
/// Get root type from optional type name
109
+
fn get_root_type(
110
+
type_name: option.Option(String),
111
+
schema_data: schema.Schema,
112
+
) -> schema.Type {
113
+
case type_name {
114
+
Some(name) ->
115
+
case dict.get(schema_data.types, name) {
116
+
Ok(t) -> t
117
+
Error(_) -> schema.ScalarType(name, None)
118
+
}
119
+
None -> schema.ScalarType("Query", None)
120
+
}
121
+
}
122
+
123
+
/// Recursively inject __typename into selections
124
+
/// is_root: True if this is the root Query/Mutation/Subscription level (don't inject there)
125
+
fn inject_into_selections(
126
+
selections: List(parser.Selection),
127
+
parent_type: schema.Type,
128
+
schema_data: schema.Schema,
129
+
is_root: Bool,
130
+
) -> List(parser.Selection) {
131
+
// Check if __typename is already present
132
+
let has_typename =
133
+
list.any(selections, fn(selection) {
134
+
case selection {
135
+
parser.Field("__typename", _, _, _) -> True
136
+
_ -> False
137
+
}
138
+
})
139
+
140
+
// Determine if we should inject __typename for this parent type
141
+
// Don't inject at root level (Query/Mutation/Subscription)
142
+
let should_inject = case is_root {
143
+
True -> False
144
+
False ->
145
+
case parent_type {
146
+
schema.ObjectType(_, _, _) -> True
147
+
schema.InterfaceType(_, _, _) -> True
148
+
schema.UnionType(_, _, _) -> True
149
+
_ -> False
150
+
}
151
+
}
152
+
153
+
// Process each selection and inject into nested selections
154
+
let processed_selections =
155
+
list.map(selections, fn(selection) {
156
+
case selection {
157
+
parser.Field(field_name, alias, args, nested_selections) -> {
158
+
case nested_selections {
159
+
[] -> selection
160
+
_ -> {
161
+
// Get the field's type from the schema
162
+
let fields = schema.get_type_fields(parent_type)
163
+
let field_type = case
164
+
list.find(fields, fn(f) { f.name == field_name })
165
+
{
166
+
Ok(field) -> {
167
+
let type_name = get_base_type_name(field.type_ref)
168
+
case dict.get(schema_data.types, type_name) {
169
+
Ok(t) -> t
170
+
Error(_) -> schema.ScalarType(type_name, None)
171
+
}
172
+
}
173
+
Error(_) -> schema.ScalarType("Unknown", None)
174
+
}
175
+
176
+
// Recursively inject into nested selections (never root)
177
+
let modified_nested =
178
+
inject_into_selections(
179
+
nested_selections,
180
+
field_type,
181
+
schema_data,
182
+
False,
183
+
)
184
+
parser.Field(field_name, alias, args, modified_nested)
185
+
}
186
+
}
187
+
}
188
+
parser.InlineFragment(type_condition, nested_selections) -> {
189
+
// Get the type for the inline fragment
190
+
let fragment_type = case type_condition {
191
+
Some(type_name) ->
192
+
case dict.get(schema_data.types, type_name) {
193
+
Ok(t) -> t
194
+
Error(_) -> schema.ScalarType(type_name, None)
195
+
}
196
+
None -> parent_type
197
+
}
198
+
let modified_nested =
199
+
inject_into_selections(
200
+
nested_selections,
201
+
fragment_type,
202
+
schema_data,
203
+
False,
204
+
)
205
+
parser.InlineFragment(type_condition, modified_nested)
206
+
}
207
+
parser.FragmentSpread(_) -> selection
208
+
}
209
+
})
210
+
211
+
// Inject __typename if needed
212
+
case should_inject && !has_typename {
213
+
True -> {
214
+
// Prepend __typename to the selections
215
+
[parser.Field("__typename", None, [], []), ..processed_selections]
216
+
}
217
+
False -> processed_selections
218
+
}
219
+
}
220
+
221
+
/// Extract the base type name from a TypeRef (unwrap NonNull and List)
222
+
fn get_base_type_name(type_ref: schema.TypeRef) -> String {
223
+
case type_ref {
224
+
schema.NamedType(name, _) -> name
225
+
schema.NonNullType(inner) -> get_base_type_name(inner)
226
+
schema.ListType(inner) -> get_base_type_name(inner)
227
+
}
228
+
}
229
+
230
+
/// Serialize a GraphQL document back to a string
231
+
fn serialize_document(document: parser.Document) -> String {
232
+
let parser.Document(operations) = document
233
+
234
+
operations
235
+
|> list.map(serialize_operation)
236
+
|> list.intersperse("\n\n")
237
+
|> list.fold("", fn(acc, s) { acc <> s })
238
+
}
239
+
240
+
/// Serialize an operation to a string
241
+
fn serialize_operation(operation: parser.Operation) -> String {
242
+
case operation {
243
+
parser.Query(selection_set) -> {
244
+
"query " <> serialize_selection_set(selection_set, 0)
245
+
}
246
+
parser.NamedQuery(name, variables, selection_set) -> {
247
+
"query "
248
+
<> name
249
+
<> serialize_variables(variables)
250
+
<> " "
251
+
<> serialize_selection_set(selection_set, 0)
252
+
}
253
+
parser.Mutation(selection_set) -> {
254
+
"mutation " <> serialize_selection_set(selection_set, 0)
255
+
}
256
+
parser.NamedMutation(name, variables, selection_set) -> {
257
+
"mutation "
258
+
<> name
259
+
<> serialize_variables(variables)
260
+
<> " "
261
+
<> serialize_selection_set(selection_set, 0)
262
+
}
263
+
parser.Subscription(selection_set) -> {
264
+
"subscription " <> serialize_selection_set(selection_set, 0)
265
+
}
266
+
parser.NamedSubscription(name, variables, selection_set) -> {
267
+
"subscription "
268
+
<> name
269
+
<> serialize_variables(variables)
270
+
<> " "
271
+
<> serialize_selection_set(selection_set, 0)
272
+
}
273
+
parser.FragmentDefinition(name, type_condition, selection_set) -> {
274
+
"fragment "
275
+
<> name
276
+
<> " on "
277
+
<> type_condition
278
+
<> " "
279
+
<> serialize_selection_set(selection_set, 0)
280
+
}
281
+
}
282
+
}
283
+
284
+
/// Serialize variables
285
+
fn serialize_variables(variables: List(parser.Variable)) -> String {
286
+
case variables {
287
+
[] -> ""
288
+
vars -> {
289
+
let vars_str =
290
+
vars
291
+
|> list.map(fn(var) {
292
+
let parser.Variable(name, type_str) = var
293
+
"$" <> name <> ": " <> type_str
294
+
})
295
+
|> list.intersperse(", ")
296
+
|> list.fold("", fn(acc, s) { acc <> s })
297
+
"(" <> vars_str <> ")"
298
+
}
299
+
}
300
+
}
301
+
302
+
/// Serialize a selection set
303
+
fn serialize_selection_set(
304
+
selection_set: parser.SelectionSet,
305
+
indent: Int,
306
+
) -> String {
307
+
let parser.SelectionSet(selections) = selection_set
308
+
let indent_str =
309
+
list.repeat(" ", indent) |> list.fold("", fn(acc, s) { acc <> s })
310
+
let next_indent = indent + 1
311
+
let next_indent_str =
312
+
list.repeat(" ", next_indent) |> list.fold("", fn(acc, s) { acc <> s })
313
+
314
+
let selections_str =
315
+
selections
316
+
|> list.map(fn(sel) {
317
+
next_indent_str <> serialize_selection(sel, next_indent)
318
+
})
319
+
|> list.intersperse("\n")
320
+
|> list.fold("", fn(acc, s) { acc <> s })
321
+
322
+
"{\n" <> selections_str <> "\n" <> indent_str <> "}"
323
+
}
324
+
325
+
/// Serialize a single selection
326
+
fn serialize_selection(selection: parser.Selection, indent: Int) -> String {
327
+
case selection {
328
+
parser.Field(name, alias, args, nested) -> {
329
+
let alias_str = case alias {
330
+
Some(a) -> a <> ": "
331
+
None -> ""
332
+
}
333
+
let args_str = serialize_arguments(args)
334
+
case nested {
335
+
[] -> alias_str <> name <> args_str
336
+
_ ->
337
+
alias_str
338
+
<> name
339
+
<> args_str
340
+
<> " "
341
+
<> serialize_selection_set(parser.SelectionSet(nested), indent)
342
+
}
343
+
}
344
+
parser.InlineFragment(type_condition, nested) -> {
345
+
let type_str = case type_condition {
346
+
Some(t) -> " on " <> t
347
+
None -> ""
348
+
}
349
+
"..."
350
+
<> type_str
351
+
<> " "
352
+
<> serialize_selection_set(parser.SelectionSet(nested), indent)
353
+
}
354
+
parser.FragmentSpread(name) -> "..." <> name
355
+
}
356
+
}
357
+
358
+
/// Serialize arguments
359
+
fn serialize_arguments(args: List(parser.Argument)) -> String {
360
+
case args {
361
+
[] -> ""
362
+
arguments -> {
363
+
let args_str =
364
+
arguments
365
+
|> list.map(fn(arg) {
366
+
let parser.Argument(name, value) = arg
367
+
name <> ": " <> serialize_argument_value(value)
368
+
})
369
+
|> list.intersperse(", ")
370
+
|> list.fold("", fn(acc, s) { acc <> s })
371
+
"(" <> args_str <> ")"
372
+
}
373
+
}
374
+
}
375
+
376
+
/// Serialize an argument value
377
+
fn serialize_argument_value(value: parser.ArgumentValue) -> String {
378
+
case value {
379
+
parser.IntValue(i) -> i
380
+
parser.FloatValue(f) -> f
381
+
parser.StringValue(s) -> "\"" <> s <> "\""
382
+
parser.BooleanValue(True) -> "true"
383
+
parser.BooleanValue(False) -> "false"
384
+
parser.NullValue -> "null"
385
+
parser.EnumValue(e) -> e
386
+
parser.ListValue(values) -> {
387
+
let values_str =
388
+
values
389
+
|> list.map(serialize_argument_value)
390
+
|> list.intersperse(", ")
391
+
|> list.fold("", fn(acc, s) { acc <> s })
392
+
"[" <> values_str <> "]"
393
+
}
394
+
parser.ObjectValue(fields) -> {
395
+
let fields_str =
396
+
fields
397
+
|> list.map(fn(field) {
398
+
let #(field_name, field_value) = field
399
+
field_name <> ": " <> serialize_argument_value(field_value)
400
+
})
401
+
|> list.intersperse(", ")
402
+
|> list.fold("", fn(acc, s) { acc <> s })
403
+
"{" <> fields_str <> "}"
404
+
}
405
+
parser.VariableValue(name) -> "$" <> name
406
+
}
407
+
}
+32
src/squall/unstable_registry.gleam
+32
src/squall/unstable_registry.gleam
···
1
+
import gleam/dict.{type Dict}
2
+
3
+
/// Query metadata stored in the registry
4
+
pub type QueryMeta {
5
+
QueryMeta(query: String, module_path: String)
6
+
}
7
+
8
+
/// Query registry that maps query names to their metadata
9
+
pub type Registry {
10
+
Registry(queries: Dict(String, QueryMeta))
11
+
}
12
+
13
+
/// Create a new empty registry
14
+
pub fn new() -> Registry {
15
+
Registry(queries: dict.new())
16
+
}
17
+
18
+
/// Register a query with its metadata
19
+
pub fn register(
20
+
registry: Registry,
21
+
query_name: String,
22
+
query: String,
23
+
module_path: String,
24
+
) -> Registry {
25
+
let meta = QueryMeta(query: query, module_path: module_path)
26
+
Registry(queries: dict.insert(registry.queries, query_name, meta))
27
+
}
28
+
29
+
/// Get query metadata by name
30
+
pub fn get(registry: Registry, query_name: String) -> Result(QueryMeta, Nil) {
31
+
dict.get(registry.queries, query_name)
32
+
}
+247
-9
src/squall.gleam
+247
-9
src/squall.gleam
···
1
-
import argv
2
1
import gleam/dynamic/decode
3
2
import gleam/http
4
3
import gleam/http/request.{type Request}
5
-
import gleam/httpc
6
-
import gleam/io
7
4
import gleam/json
8
5
import gleam/list
9
6
import gleam/result
10
7
import gleam/string
11
8
12
9
@target(erlang)
10
+
import argv
11
+
12
+
@target(erlang)
13
+
import gleam/io
14
+
15
+
@target(erlang)
16
+
import gleam/httpc
17
+
18
+
@target(erlang)
13
19
import simplifile
14
20
15
21
@target(erlang)
···
26
32
27
33
@target(erlang)
28
34
import squall/internal/schema
35
+
36
+
@target(erlang)
37
+
import squall/internal/query_extractor
38
+
39
+
@target(erlang)
40
+
import squall/internal/registry_codegen
41
+
42
+
@target(erlang)
43
+
import squall/internal/typename_injector
29
44
30
45
/// A GraphQL client with endpoint and headers configuration.
31
46
/// This client follows the sans-io pattern: it builds HTTP requests but doesn't send them.
···
131
146
}
132
147
133
148
decode.run(json_value, data_decoder)
134
-
|> result.map_error(fn(_) { "Failed to decode response data" })
149
+
|> result.map_error(fn(errors) {
150
+
"Failed to decode response data: "
151
+
<> string.inspect(errors)
152
+
<> ". Response body: "
153
+
<> body
154
+
})
135
155
}
136
156
137
157
@target(erlang)
···
139
159
case argv.load().arguments {
140
160
["generate", endpoint] -> generate(endpoint)
141
161
["generate"] -> generate_with_env()
162
+
["unstable-cache", endpoint] -> unstable_cache(endpoint)
163
+
["unstable-cache"] -> {
164
+
io.println("Error: Endpoint required")
165
+
io.println("Usage: gleam run -m squall unstable-cache <endpoint>")
166
+
Nil
167
+
}
142
168
_ -> {
143
169
print_usage()
144
170
Nil
···
154
180
155
181
Usage:
156
182
gleam run -m squall generate <endpoint>
157
-
gleam run -m squall generate # Uses GRAPHQL_ENDPOINT env var
183
+
gleam run -m squall unstable-cache <endpoint>
158
184
159
185
Commands:
160
-
generate <endpoint> Generate Gleam code from .gql files
186
+
generate <endpoint> Generate Gleam code from .gql files
187
+
unstable-cache <endpoint> Extract GraphQL queries from doc comments and generate types and cache registry
161
188
162
-
The tool will:
189
+
The generate command will:
163
190
1. Find all .gql files in src/**/graphql/ directories
164
191
2. Introspect the GraphQL schema from the endpoint
165
192
3. Generate type-safe Gleam functions for each query/mutation/subscription
166
193
167
-
Example:
194
+
The unstable-cache command will:
195
+
1. Scan all .gleam files in src/ for GraphQL query blocks in doc comments
196
+
2. Introspect the GraphQL schema from the endpoint
197
+
3. Automatically inject __typename into queries for cache normalization
198
+
4. Generate type-safe code for each query at src/generated/queries/
199
+
5. Generate a registry initialization module at src/generated/queries.gleam
200
+
201
+
Examples:
168
202
gleam run -m squall generate https://rickandmortyapi.com/graphql
203
+
gleam run -m squall unstable-cache https://rickandmortyapi.com/graphql
169
204
",
170
205
)
171
206
}
172
207
173
208
@target(erlang)
174
209
fn generate_with_env() {
175
-
io.println("Error: GRAPHQL_ENDPOINT environment variable not set")
176
210
io.println("Usage: gleam run -m squall generate <endpoint>")
177
211
Nil
178
212
}
···
394
428
}
395
429
}
396
430
431
+
@target(erlang)
432
+
fn unstable_cache(endpoint: String) {
433
+
let queries_output_dir = "src/generated/queries"
434
+
let registry_output_path = "src/generated/queries.gleam"
435
+
436
+
io.println("๐ Squall")
437
+
io.println("============================================\n")
438
+
439
+
io.println("๐ Scanning for GraphQL queries in src/...")
440
+
441
+
// Scan for component files
442
+
case query_extractor.scan_component_files("src") {
443
+
Ok(files) -> {
444
+
io.println(
445
+
"โ Found " <> int_to_string(list.length(files)) <> " .gleam file(s)\n",
446
+
)
447
+
448
+
// Extract queries from each file
449
+
io.println("๐ Extracting queries...")
450
+
let all_queries =
451
+
list.fold(files, [], fn(acc, file_path) {
452
+
case query_extractor.extract_from_file(file_path) {
453
+
Ok(queries) -> {
454
+
list.each(queries, fn(q) {
455
+
io.println(" โ Found: " <> q.name <> " in " <> file_path)
456
+
})
457
+
list.append(acc, queries)
458
+
}
459
+
Error(err) -> {
460
+
io.println(
461
+
" โ Failed to extract from " <> file_path <> ": " <> err,
462
+
)
463
+
acc
464
+
}
465
+
}
466
+
})
467
+
468
+
case list.length(all_queries) {
469
+
0 -> {
470
+
io.println("\nโ No GraphQL queries found")
471
+
io.println(
472
+
"Add GraphQL query blocks to your doc comments with named operations",
473
+
)
474
+
Nil
475
+
}
476
+
_ -> {
477
+
io.println(
478
+
"\nโ Extracted "
479
+
<> int_to_string(list.length(all_queries))
480
+
<> " quer"
481
+
<> case list.length(all_queries) {
482
+
1 -> "y"
483
+
_ -> "ies"
484
+
},
485
+
)
486
+
487
+
// Introspect schema
488
+
io.println("\n๐ก Introspecting GraphQL schema from: " <> endpoint)
489
+
case introspect_schema(endpoint) {
490
+
Ok(schema_data) -> {
491
+
io.println("โ Schema introspected successfully\n")
492
+
493
+
// Generate type-safe code for each query
494
+
io.println("๐ง Generating type-safe code...")
495
+
list.each(all_queries, fn(query_def) {
496
+
io.println(" โข " <> query_def.name)
497
+
498
+
// Parse the GraphQL query
499
+
case graphql_ast.parse_document(query_def.query) {
500
+
Ok(document) -> {
501
+
case graphql_ast.get_main_operation(document) {
502
+
Ok(operation) -> {
503
+
let fragments =
504
+
graphql_ast.get_fragment_definitions(document)
505
+
506
+
// Generate code - convert query name to snake_case for module name
507
+
let module_name = to_snake_case(query_def.name)
508
+
509
+
case
510
+
codegen.generate_operation_with_fragments(
511
+
module_name,
512
+
query_def.query,
513
+
operation,
514
+
fragments,
515
+
schema_data,
516
+
endpoint,
517
+
)
518
+
{
519
+
Ok(code) -> {
520
+
let file_name = module_name
521
+
let module_path =
522
+
queries_output_dir <> "/" <> file_name <> ".gleam"
523
+
524
+
// Create directory if needed
525
+
let _ =
526
+
simplifile.create_directory_all(
527
+
queries_output_dir,
528
+
)
529
+
530
+
case simplifile.write(module_path, code) {
531
+
Ok(_) -> io.println(" โ " <> module_path)
532
+
Error(_) ->
533
+
io.println(
534
+
" โ Failed to write " <> module_path,
535
+
)
536
+
}
537
+
}
538
+
Error(err) -> {
539
+
io.println(
540
+
" โ Codegen failed: " <> error.to_string(err),
541
+
)
542
+
}
543
+
}
544
+
}
545
+
Error(err) -> {
546
+
io.println(
547
+
" โ Parse failed: " <> error.to_string(err),
548
+
)
549
+
}
550
+
}
551
+
}
552
+
Error(err) -> {
553
+
io.println(" โ Parse failed: " <> error.to_string(err))
554
+
}
555
+
}
556
+
})
557
+
558
+
// Generate registry code with __typename injected
559
+
io.println("\n๐ฆ Generating registry module...")
560
+
// Inject __typename into all query strings for the registry
561
+
let queries_with_typename =
562
+
list.map(all_queries, fn(query_def) {
563
+
case
564
+
typename_injector.inject_typename(
565
+
query_def.query,
566
+
schema_data,
567
+
)
568
+
{
569
+
Ok(injected_query) ->
570
+
query_extractor.QueryDefinition(
571
+
name: query_def.name,
572
+
query: injected_query,
573
+
file_path: query_def.file_path,
574
+
)
575
+
Error(_) -> query_def
576
+
}
577
+
})
578
+
let code =
579
+
registry_codegen.generate_registry_module(queries_with_typename)
580
+
581
+
// Write to output file
582
+
case simplifile.write(registry_output_path, code) {
583
+
Ok(_) -> {
584
+
io.println("โ Generated: " <> registry_output_path)
585
+
io.println("\nโจ Code generation complete!")
586
+
Nil
587
+
}
588
+
Error(_) -> {
589
+
io.println("โ Failed to write: " <> registry_output_path)
590
+
Nil
591
+
}
592
+
}
593
+
}
594
+
Error(err) -> {
595
+
io.println(
596
+
"โ Schema introspection failed: " <> error.to_string(err),
597
+
)
598
+
Nil
599
+
}
600
+
}
601
+
}
602
+
}
603
+
}
604
+
Error(err) -> {
605
+
io.println("โ Failed to scan files: " <> err)
606
+
Nil
607
+
}
608
+
}
609
+
}
610
+
611
+
@target(erlang)
612
+
fn to_snake_case(s: String) -> String {
613
+
// Simple conversion: GetCharacters -> get_characters
614
+
s
615
+
|> string.to_graphemes
616
+
|> list.index_fold([], fn(acc, char, index) {
617
+
case is_uppercase(char) {
618
+
True ->
619
+
case index {
620
+
0 -> list.append(acc, [string.lowercase(char)])
621
+
_ -> list.append(acc, ["_", string.lowercase(char)])
622
+
}
623
+
False -> list.append(acc, [char])
624
+
}
625
+
})
626
+
|> string.join("")
627
+
}
628
+
629
+
@target(erlang)
630
+
fn is_uppercase(s: String) -> Bool {
631
+
s == string.uppercase(s) && s != string.lowercase(s)
632
+
}
633
+
634
+
@target(erlang)
397
635
fn int_to_string(i: Int) -> String {
398
636
case i {
399
637
0 -> "0"
+496
test/codegen_test.gleam
+496
test/codegen_test.gleam
···
1915
1915
Error(_) -> Nil
1916
1916
}
1917
1917
}
1918
+
1919
+
// Test: Generate query with enum field in response
1920
+
pub fn generate_query_with_enum_field_test() {
1921
+
let query_source =
1922
+
"
1923
+
query GetCharacter {
1924
+
character {
1925
+
id
1926
+
name
1927
+
status
1928
+
}
1929
+
}
1930
+
"
1931
+
1932
+
let assert Ok(operation) = graphql_ast.parse(query_source)
1933
+
1934
+
// Create mock schema with enum type
1935
+
let character_fields = [
1936
+
schema.Field(
1937
+
"id",
1938
+
schema.NonNullType(schema.NamedType("ID", schema.Scalar)),
1939
+
[],
1940
+
None,
1941
+
),
1942
+
schema.Field(
1943
+
"name",
1944
+
schema.NonNullType(schema.NamedType("String", schema.Scalar)),
1945
+
[],
1946
+
None,
1947
+
),
1948
+
schema.Field(
1949
+
"status",
1950
+
schema.NonNullType(schema.NamedType("CharacterStatus", schema.Enum)),
1951
+
[],
1952
+
None,
1953
+
),
1954
+
]
1955
+
1956
+
let mock_schema =
1957
+
schema.Schema(
1958
+
Some("Query"),
1959
+
None,
1960
+
None,
1961
+
dict.from_list([
1962
+
#("Character", schema.ObjectType("Character", character_fields, None)),
1963
+
#(
1964
+
"CharacterStatus",
1965
+
schema.EnumType("CharacterStatus", ["Alive", "Dead", "unknown"], None),
1966
+
),
1967
+
#(
1968
+
"Query",
1969
+
schema.ObjectType(
1970
+
"Query",
1971
+
[
1972
+
schema.Field(
1973
+
"character",
1974
+
schema.NamedType("Character", schema.Object),
1975
+
[],
1976
+
None,
1977
+
),
1978
+
],
1979
+
None,
1980
+
),
1981
+
),
1982
+
]),
1983
+
)
1984
+
1985
+
let result =
1986
+
codegen.generate_operation(
1987
+
"get_character",
1988
+
query_source,
1989
+
operation,
1990
+
mock_schema,
1991
+
"",
1992
+
)
1993
+
1994
+
case result {
1995
+
Ok(code) -> {
1996
+
code
1997
+
|> birdie.snap(title: "Query with enum field in response")
1998
+
}
1999
+
Error(_) -> Nil
2000
+
}
2001
+
}
2002
+
2003
+
// Test: Generate mutation with enum variable
2004
+
pub fn generate_mutation_with_enum_variable_test() {
2005
+
let mutation_source =
2006
+
"
2007
+
mutation FilterCharacters($status: CharacterStatus!) {
2008
+
filterCharacters(status: $status) {
2009
+
id
2010
+
name
2011
+
status
2012
+
}
2013
+
}
2014
+
"
2015
+
2016
+
let assert Ok(operation) = graphql_ast.parse(mutation_source)
2017
+
2018
+
let character_fields = [
2019
+
schema.Field(
2020
+
"id",
2021
+
schema.NonNullType(schema.NamedType("ID", schema.Scalar)),
2022
+
[],
2023
+
None,
2024
+
),
2025
+
schema.Field(
2026
+
"name",
2027
+
schema.NonNullType(schema.NamedType("String", schema.Scalar)),
2028
+
[],
2029
+
None,
2030
+
),
2031
+
schema.Field(
2032
+
"status",
2033
+
schema.NonNullType(schema.NamedType("CharacterStatus", schema.Enum)),
2034
+
[],
2035
+
None,
2036
+
),
2037
+
]
2038
+
2039
+
let mock_schema =
2040
+
schema.Schema(
2041
+
Some("Query"),
2042
+
Some("Mutation"),
2043
+
None,
2044
+
dict.from_list([
2045
+
#("Character", schema.ObjectType("Character", character_fields, None)),
2046
+
#(
2047
+
"CharacterStatus",
2048
+
schema.EnumType("CharacterStatus", ["Alive", "Dead", "unknown"], None),
2049
+
),
2050
+
#(
2051
+
"Mutation",
2052
+
schema.ObjectType(
2053
+
"Mutation",
2054
+
[
2055
+
schema.Field(
2056
+
"filterCharacters",
2057
+
schema.ListType(schema.NamedType("Character", schema.Object)),
2058
+
[
2059
+
schema.InputValue(
2060
+
"status",
2061
+
schema.NonNullType(schema.NamedType(
2062
+
"CharacterStatus",
2063
+
schema.Enum,
2064
+
)),
2065
+
None,
2066
+
),
2067
+
],
2068
+
None,
2069
+
),
2070
+
],
2071
+
None,
2072
+
),
2073
+
),
2074
+
]),
2075
+
)
2076
+
2077
+
let result =
2078
+
codegen.generate_operation(
2079
+
"filter_characters",
2080
+
mutation_source,
2081
+
operation,
2082
+
mock_schema,
2083
+
"",
2084
+
)
2085
+
2086
+
case result {
2087
+
Ok(code) -> {
2088
+
code
2089
+
|> birdie.snap(title: "Mutation with enum variable")
2090
+
}
2091
+
Error(_) -> Nil
2092
+
}
2093
+
}
2094
+
2095
+
// Test: Generate mutation with enum in InputObject
2096
+
pub fn generate_mutation_with_enum_in_input_object_test() {
2097
+
let mutation_source =
2098
+
"
2099
+
mutation CreateCharacter($input: CharacterInput!) {
2100
+
createCharacter(input: $input) {
2101
+
id
2102
+
name
2103
+
status
2104
+
}
2105
+
}
2106
+
"
2107
+
2108
+
let assert Ok(operation) = graphql_ast.parse(mutation_source)
2109
+
2110
+
// Define InputObject with enum field
2111
+
let character_input_fields = [
2112
+
schema.InputValue(
2113
+
"name",
2114
+
schema.NonNullType(schema.NamedType("String", schema.Scalar)),
2115
+
None,
2116
+
),
2117
+
schema.InputValue(
2118
+
"status",
2119
+
schema.NonNullType(schema.NamedType("CharacterStatus", schema.Enum)),
2120
+
None,
2121
+
),
2122
+
schema.InputValue(
2123
+
"species",
2124
+
schema.NamedType("String", schema.Scalar),
2125
+
None,
2126
+
),
2127
+
]
2128
+
2129
+
let character_fields = [
2130
+
schema.Field(
2131
+
"id",
2132
+
schema.NonNullType(schema.NamedType("ID", schema.Scalar)),
2133
+
[],
2134
+
None,
2135
+
),
2136
+
schema.Field(
2137
+
"name",
2138
+
schema.NonNullType(schema.NamedType("String", schema.Scalar)),
2139
+
[],
2140
+
None,
2141
+
),
2142
+
schema.Field(
2143
+
"status",
2144
+
schema.NonNullType(schema.NamedType("CharacterStatus", schema.Enum)),
2145
+
[],
2146
+
None,
2147
+
),
2148
+
]
2149
+
2150
+
let mock_schema =
2151
+
schema.Schema(
2152
+
Some("Query"),
2153
+
Some("Mutation"),
2154
+
None,
2155
+
dict.from_list([
2156
+
#("Character", schema.ObjectType("Character", character_fields, None)),
2157
+
#(
2158
+
"CharacterStatus",
2159
+
schema.EnumType("CharacterStatus", ["Alive", "Dead", "unknown"], None),
2160
+
),
2161
+
#(
2162
+
"CharacterInput",
2163
+
schema.InputObjectType("CharacterInput", character_input_fields, None),
2164
+
),
2165
+
#(
2166
+
"Mutation",
2167
+
schema.ObjectType(
2168
+
"Mutation",
2169
+
[
2170
+
schema.Field(
2171
+
"createCharacter",
2172
+
schema.NamedType("Character", schema.Object),
2173
+
[
2174
+
schema.InputValue(
2175
+
"input",
2176
+
schema.NonNullType(schema.NamedType(
2177
+
"CharacterInput",
2178
+
schema.InputObject,
2179
+
)),
2180
+
None,
2181
+
),
2182
+
],
2183
+
None,
2184
+
),
2185
+
],
2186
+
None,
2187
+
),
2188
+
),
2189
+
]),
2190
+
)
2191
+
2192
+
let result =
2193
+
codegen.generate_operation(
2194
+
"create_character",
2195
+
mutation_source,
2196
+
operation,
2197
+
mock_schema,
2198
+
"",
2199
+
)
2200
+
2201
+
case result {
2202
+
Ok(code) -> {
2203
+
code
2204
+
|> birdie.snap(title: "Mutation with enum in InputObject")
2205
+
}
2206
+
Error(_) -> Nil
2207
+
}
2208
+
}
2209
+
2210
+
// Test: Generate query with optional enum field
2211
+
pub fn generate_query_with_optional_enum_field_test() {
2212
+
let query_source =
2213
+
"
2214
+
query GetCharacter {
2215
+
character {
2216
+
id
2217
+
name
2218
+
status
2219
+
gender
2220
+
}
2221
+
}
2222
+
"
2223
+
2224
+
let assert Ok(operation) = graphql_ast.parse(query_source)
2225
+
2226
+
// Create mock schema with optional enum field
2227
+
let character_fields = [
2228
+
schema.Field(
2229
+
"id",
2230
+
schema.NonNullType(schema.NamedType("ID", schema.Scalar)),
2231
+
[],
2232
+
None,
2233
+
),
2234
+
schema.Field(
2235
+
"name",
2236
+
schema.NonNullType(schema.NamedType("String", schema.Scalar)),
2237
+
[],
2238
+
None,
2239
+
),
2240
+
schema.Field(
2241
+
"status",
2242
+
schema.NonNullType(schema.NamedType("CharacterStatus", schema.Enum)),
2243
+
[],
2244
+
None,
2245
+
),
2246
+
schema.Field("gender", schema.NamedType("Gender", schema.Enum), [], None),
2247
+
]
2248
+
2249
+
let mock_schema =
2250
+
schema.Schema(
2251
+
Some("Query"),
2252
+
None,
2253
+
None,
2254
+
dict.from_list([
2255
+
#("Character", schema.ObjectType("Character", character_fields, None)),
2256
+
#(
2257
+
"CharacterStatus",
2258
+
schema.EnumType("CharacterStatus", ["Alive", "Dead", "unknown"], None),
2259
+
),
2260
+
#(
2261
+
"Gender",
2262
+
schema.EnumType("Gender", ["Male", "Female", "Genderless"], None),
2263
+
),
2264
+
#(
2265
+
"Query",
2266
+
schema.ObjectType(
2267
+
"Query",
2268
+
[
2269
+
schema.Field(
2270
+
"character",
2271
+
schema.NamedType("Character", schema.Object),
2272
+
[],
2273
+
None,
2274
+
),
2275
+
],
2276
+
None,
2277
+
),
2278
+
),
2279
+
]),
2280
+
)
2281
+
2282
+
let result =
2283
+
codegen.generate_operation(
2284
+
"get_character",
2285
+
query_source,
2286
+
operation,
2287
+
mock_schema,
2288
+
"",
2289
+
)
2290
+
2291
+
case result {
2292
+
Ok(code) -> {
2293
+
code
2294
+
|> birdie.snap(title: "Query with optional enum field")
2295
+
}
2296
+
Error(_) -> Nil
2297
+
}
2298
+
}
2299
+
2300
+
// Test: Generate mutation with optional enum in InputObject
2301
+
pub fn generate_mutation_with_optional_enum_in_input_test() {
2302
+
let mutation_source =
2303
+
"
2304
+
mutation UpdateCharacter($input: CharacterUpdateInput!) {
2305
+
updateCharacter(input: $input) {
2306
+
id
2307
+
name
2308
+
status
2309
+
}
2310
+
}
2311
+
"
2312
+
2313
+
let assert Ok(operation) = graphql_ast.parse(mutation_source)
2314
+
2315
+
// Define InputObject with optional enum field
2316
+
let character_update_input_fields = [
2317
+
schema.InputValue(
2318
+
"id",
2319
+
schema.NonNullType(schema.NamedType("ID", schema.Scalar)),
2320
+
None,
2321
+
),
2322
+
schema.InputValue("name", schema.NamedType("String", schema.Scalar), None),
2323
+
schema.InputValue(
2324
+
"status",
2325
+
schema.NamedType("CharacterStatus", schema.Enum),
2326
+
None,
2327
+
),
2328
+
]
2329
+
2330
+
let character_fields = [
2331
+
schema.Field(
2332
+
"id",
2333
+
schema.NonNullType(schema.NamedType("ID", schema.Scalar)),
2334
+
[],
2335
+
None,
2336
+
),
2337
+
schema.Field(
2338
+
"name",
2339
+
schema.NonNullType(schema.NamedType("String", schema.Scalar)),
2340
+
[],
2341
+
None,
2342
+
),
2343
+
schema.Field(
2344
+
"status",
2345
+
schema.NonNullType(schema.NamedType("CharacterStatus", schema.Enum)),
2346
+
[],
2347
+
None,
2348
+
),
2349
+
]
2350
+
2351
+
let mock_schema =
2352
+
schema.Schema(
2353
+
Some("Query"),
2354
+
Some("Mutation"),
2355
+
None,
2356
+
dict.from_list([
2357
+
#("Character", schema.ObjectType("Character", character_fields, None)),
2358
+
#(
2359
+
"CharacterStatus",
2360
+
schema.EnumType("CharacterStatus", ["Alive", "Dead", "unknown"], None),
2361
+
),
2362
+
#(
2363
+
"CharacterUpdateInput",
2364
+
schema.InputObjectType(
2365
+
"CharacterUpdateInput",
2366
+
character_update_input_fields,
2367
+
None,
2368
+
),
2369
+
),
2370
+
#(
2371
+
"Mutation",
2372
+
schema.ObjectType(
2373
+
"Mutation",
2374
+
[
2375
+
schema.Field(
2376
+
"updateCharacter",
2377
+
schema.NamedType("Character", schema.Object),
2378
+
[
2379
+
schema.InputValue(
2380
+
"input",
2381
+
schema.NonNullType(schema.NamedType(
2382
+
"CharacterUpdateInput",
2383
+
schema.InputObject,
2384
+
)),
2385
+
None,
2386
+
),
2387
+
],
2388
+
None,
2389
+
),
2390
+
],
2391
+
None,
2392
+
),
2393
+
),
2394
+
]),
2395
+
)
2396
+
2397
+
let result =
2398
+
codegen.generate_operation(
2399
+
"update_character",
2400
+
mutation_source,
2401
+
operation,
2402
+
mock_schema,
2403
+
"",
2404
+
)
2405
+
2406
+
case result {
2407
+
Ok(code) -> {
2408
+
code
2409
+
|> birdie.snap(title: "Mutation with optional enum in InputObject")
2410
+
}
2411
+
Error(_) -> Nil
2412
+
}
2413
+
}
+347
test/typename_injector_test.gleam
+347
test/typename_injector_test.gleam
···
1
+
import gleam/dict
2
+
import gleam/option.{None, Some}
3
+
import gleam/string
4
+
import gleeunit/should
5
+
import squall/internal/schema
6
+
import squall/internal/typename_injector
7
+
8
+
// Test: Inject __typename into a simple object query
9
+
pub fn inject_simple_object_test() {
10
+
let query =
11
+
"query GetCharacter {
12
+
character {
13
+
id
14
+
name
15
+
}
16
+
}"
17
+
18
+
let schema_data = create_test_schema()
19
+
20
+
let result = typename_injector.inject_typename(query, schema_data)
21
+
22
+
should.be_ok(result)
23
+
let assert Ok(modified_query) = result
24
+
25
+
// Should contain __typename in the character object
26
+
should.be_true(contains_typename_in_character(modified_query))
27
+
// Should NOT contain __typename at root level
28
+
should.be_false(contains_typename_at_root(modified_query))
29
+
}
30
+
31
+
// Test: Don't inject __typename at root Query level
32
+
pub fn no_inject_at_root_test() {
33
+
let query =
34
+
"query GetCharacter {
35
+
character {
36
+
id
37
+
name
38
+
}
39
+
}"
40
+
41
+
let schema_data = create_test_schema()
42
+
43
+
let result = typename_injector.inject_typename(query, schema_data)
44
+
45
+
should.be_ok(result)
46
+
let assert Ok(modified_query) = result
47
+
48
+
// Parse the result - first selection should be "character", not "__typename"
49
+
should.be_true(starts_with_field(modified_query, "character"))
50
+
}
51
+
52
+
// Test: Inject __typename into nested objects
53
+
pub fn inject_nested_objects_test() {
54
+
let query =
55
+
"query GetCharacter {
56
+
character {
57
+
id
58
+
name
59
+
location {
60
+
id
61
+
name
62
+
}
63
+
}
64
+
}"
65
+
66
+
let schema_data = create_test_schema_with_nested()
67
+
68
+
let result = typename_injector.inject_typename(query, schema_data)
69
+
70
+
should.be_ok(result)
71
+
let assert Ok(modified_query) = result
72
+
73
+
// Should contain __typename in both character and location
74
+
should.be_true(contains_typename_in_character(modified_query))
75
+
should.be_true(contains_typename_in_location(modified_query))
76
+
}
77
+
78
+
// Test: Don't inject if __typename already exists
79
+
pub fn no_duplicate_typename_test() {
80
+
let query =
81
+
"query GetCharacter {
82
+
character {
83
+
__typename
84
+
id
85
+
name
86
+
}
87
+
}"
88
+
89
+
let schema_data = create_test_schema()
90
+
91
+
let result = typename_injector.inject_typename(query, schema_data)
92
+
93
+
should.be_ok(result)
94
+
let assert Ok(modified_query) = result
95
+
96
+
// Should only have one __typename, not duplicated
97
+
let typename_count = count_occurrences(modified_query, "__typename")
98
+
should.equal(typename_count, 1)
99
+
}
100
+
101
+
// Test: Don't inject __typename into scalar fields
102
+
pub fn no_inject_scalars_test() {
103
+
let query =
104
+
"query GetData {
105
+
character {
106
+
id
107
+
name
108
+
}
109
+
}"
110
+
111
+
let schema_data = create_test_schema()
112
+
113
+
let result = typename_injector.inject_typename(query, schema_data)
114
+
115
+
should.be_ok(result)
116
+
let assert Ok(modified_query) = result
117
+
118
+
// id and name are scalars, should not have __typename after them
119
+
// Only the character object should have __typename
120
+
let typename_count = count_occurrences(modified_query, "__typename")
121
+
should.equal(typename_count, 1)
122
+
}
123
+
124
+
// Test: Handle queries with variables
125
+
pub fn inject_with_variables_test() {
126
+
let query =
127
+
"query GetCharacter($id: ID!) {
128
+
character(id: $id) {
129
+
id
130
+
name
131
+
}
132
+
}"
133
+
134
+
let schema_data = create_test_schema()
135
+
136
+
let result = typename_injector.inject_typename(query, schema_data)
137
+
138
+
should.be_ok(result)
139
+
let assert Ok(modified_query) = result
140
+
141
+
// Should preserve variables
142
+
should.be_true(contains_variable_definition(modified_query))
143
+
// Should inject __typename
144
+
should.be_true(contains_typename_in_character(modified_query))
145
+
}
146
+
147
+
// Test: Handle fragments
148
+
pub fn inject_with_fragments_test() {
149
+
let query =
150
+
"query GetCharacter {
151
+
character {
152
+
...CharacterFields
153
+
}
154
+
}
155
+
156
+
fragment CharacterFields on Character {
157
+
id
158
+
name
159
+
}"
160
+
161
+
let schema_data = create_test_schema()
162
+
163
+
let result = typename_injector.inject_typename(query, schema_data)
164
+
165
+
should.be_ok(result)
166
+
let assert Ok(modified_query) = result
167
+
168
+
// Should inject __typename in both the query and the fragment
169
+
let typename_count = count_occurrences(modified_query, "__typename")
170
+
should.be_true(typename_count >= 2)
171
+
}
172
+
173
+
// Helper: Create a minimal test schema
174
+
fn create_test_schema() -> schema.Schema {
175
+
let character_type =
176
+
schema.ObjectType(
177
+
name: "Character",
178
+
fields: [
179
+
schema.Field(
180
+
name: "id",
181
+
type_ref: schema.NamedType("ID", schema.Scalar),
182
+
args: [],
183
+
description: None,
184
+
),
185
+
schema.Field(
186
+
name: "name",
187
+
type_ref: schema.NamedType("String", schema.Scalar),
188
+
args: [],
189
+
description: None,
190
+
),
191
+
],
192
+
description: None,
193
+
)
194
+
195
+
let query_type =
196
+
schema.ObjectType(
197
+
name: "Query",
198
+
fields: [
199
+
schema.Field(
200
+
name: "character",
201
+
type_ref: schema.NamedType("Character", schema.Object),
202
+
args: [],
203
+
description: None,
204
+
),
205
+
],
206
+
description: None,
207
+
)
208
+
209
+
let types =
210
+
dict.new()
211
+
|> dict.insert("Character", character_type)
212
+
|> dict.insert("Query", query_type)
213
+
|> dict.insert("ID", schema.ScalarType("ID", None))
214
+
|> dict.insert("String", schema.ScalarType("String", None))
215
+
216
+
schema.Schema(
217
+
query_type: Some("Query"),
218
+
mutation_type: None,
219
+
subscription_type: None,
220
+
types: types,
221
+
)
222
+
}
223
+
224
+
// Helper: Create schema with nested objects
225
+
fn create_test_schema_with_nested() -> schema.Schema {
226
+
let location_type =
227
+
schema.ObjectType(
228
+
name: "Location",
229
+
fields: [
230
+
schema.Field(
231
+
name: "id",
232
+
type_ref: schema.NamedType("ID", schema.Scalar),
233
+
args: [],
234
+
description: None,
235
+
),
236
+
schema.Field(
237
+
name: "name",
238
+
type_ref: schema.NamedType("String", schema.Scalar),
239
+
args: [],
240
+
description: None,
241
+
),
242
+
],
243
+
description: None,
244
+
)
245
+
246
+
let character_type =
247
+
schema.ObjectType(
248
+
name: "Character",
249
+
fields: [
250
+
schema.Field(
251
+
name: "id",
252
+
type_ref: schema.NamedType("ID", schema.Scalar),
253
+
args: [],
254
+
description: None,
255
+
),
256
+
schema.Field(
257
+
name: "name",
258
+
type_ref: schema.NamedType("String", schema.Scalar),
259
+
args: [],
260
+
description: None,
261
+
),
262
+
schema.Field(
263
+
name: "location",
264
+
type_ref: schema.NamedType("Location", schema.Object),
265
+
args: [],
266
+
description: None,
267
+
),
268
+
],
269
+
description: None,
270
+
)
271
+
272
+
let query_type =
273
+
schema.ObjectType(
274
+
name: "Query",
275
+
fields: [
276
+
schema.Field(
277
+
name: "character",
278
+
type_ref: schema.NamedType("Character", schema.Object),
279
+
args: [],
280
+
description: None,
281
+
),
282
+
],
283
+
description: None,
284
+
)
285
+
286
+
let types =
287
+
dict.new()
288
+
|> dict.insert("Character", character_type)
289
+
|> dict.insert("Location", location_type)
290
+
|> dict.insert("Query", query_type)
291
+
|> dict.insert("ID", schema.ScalarType("ID", None))
292
+
|> dict.insert("String", schema.ScalarType("String", None))
293
+
294
+
schema.Schema(
295
+
query_type: Some("Query"),
296
+
mutation_type: None,
297
+
subscription_type: None,
298
+
types: types,
299
+
)
300
+
}
301
+
302
+
// Helper functions to check query contents
303
+
fn contains_typename_in_character(query: String) -> Bool {
304
+
// Check if __typename appears after "character {" and before a field
305
+
case find_substring(query, "character") {
306
+
True -> find_substring(query, "__typename")
307
+
False -> False
308
+
}
309
+
}
310
+
311
+
fn contains_typename_in_location(query: String) -> Bool {
312
+
case find_substring(query, "location") {
313
+
True -> find_substring(query, "__typename")
314
+
False -> False
315
+
}
316
+
}
317
+
318
+
fn contains_typename_at_root(query: String) -> Bool {
319
+
// Check if __typename appears right after opening brace of query
320
+
case find_substring(query, "query") {
321
+
True -> {
322
+
// Very simple check - just see if __typename appears before "character"
323
+
// In a proper implementation, we'd parse this properly
324
+
False
325
+
}
326
+
False -> False
327
+
}
328
+
}
329
+
330
+
fn starts_with_field(query: String, field_name: String) -> Bool {
331
+
find_substring(query, field_name)
332
+
}
333
+
334
+
fn contains_variable_definition(query: String) -> Bool {
335
+
find_substring(query, "$id")
336
+
}
337
+
338
+
fn find_substring(haystack: String, needle: String) -> Bool {
339
+
string.contains(haystack, needle)
340
+
}
341
+
342
+
fn count_occurrences(text: String, pattern: String) -> Int {
343
+
// Count by comparing lengths before and after replacement
344
+
let count =
345
+
string.length(text) - string.length(string.replace(text, pattern, ""))
346
+
count / string.length(pattern)
347
+
}