Skip to content

Commit 9c48472

Browse files
feat!: Scalars are namespaced inside GraphQLScalars
This will prevent type name collisions. In some common schemas, we were already hitting name collisions between custom scalars and Foundation types (Like `Date` and `URL`).
1 parent 13fe373 commit 9c48472

6 files changed

Lines changed: 133 additions & 90 deletions

File tree

Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -22,45 +22,48 @@ class GraphQLContext: @unchecked Sendable {
2222
}
2323
}
2424

25-
// Scalars must be represented by a Swift type of the same name, conforming to the Scalar protocol
26-
struct EmailAddress: GraphQLScalar {
27-
let email: String
28-
29-
init(email: String) {
30-
self.email = email
31-
}
25+
// Scalars must be represented by a Swift type of the same name in the GraphQLScalars namespace, conforming to
26+
// the GraphQLScalar protocol
27+
extension GraphQLScalars {
28+
struct EmailAddress: GraphQLScalar {
29+
let email: String
30+
31+
init(email: String) {
32+
self.email = email
33+
}
3234

33-
// Codability conformance. Required for usage in InputObject
34-
init(from decoder: any Decoder) throws {
35-
email = try decoder.singleValueContainer().decode(String.self)
36-
}
35+
// Codability conformance. Required for usage in InputObject
36+
init(from decoder: any Decoder) throws {
37+
email = try decoder.singleValueContainer().decode(String.self)
38+
}
3739

38-
func encode(to encoder: any Encoder) throws {
39-
try email.encode(to: encoder)
40-
}
40+
func encode(to encoder: any Encoder) throws {
41+
try email.encode(to: encoder)
42+
}
4143

42-
// Scalar conformance. Not necessary, but default methods are very inefficient.
43-
static func serialize(this: Self) throws -> Map {
44-
return .string(this.email)
45-
}
44+
// Scalar conformance. Not necessary, but default methods are very inefficient.
45+
static func serialize(this: Self) throws -> Map {
46+
return .string(this.email)
47+
}
4648

47-
static func parseValue(map: Map) throws -> Map {
48-
switch map {
49-
case .string:
50-
return map
51-
default:
52-
throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)")
49+
static func parseValue(map: Map) throws -> Map {
50+
switch map {
51+
case .string:
52+
return map
53+
default:
54+
throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)")
55+
}
5356
}
54-
}
5557

56-
static func parseLiteral(value: any Value) throws -> Map {
57-
guard let ast = value as? StringValue else {
58-
throw GraphQLError(
59-
message: "EmailAddress cannot represent non-string value: \(print(ast: value))",
60-
nodes: [value]
61-
)
58+
static func parseLiteral(value: any Value) throws -> Map {
59+
guard let ast = value as? StringValue else {
60+
throw GraphQLError(
61+
message: "EmailAddress cannot represent non-string value: \(print(ast: value))",
62+
nodes: [value]
63+
)
64+
}
65+
return .string(ast.value)
6266
}
63-
return .string(ast.value)
6467
}
6568
}
6669

@@ -88,8 +91,8 @@ struct User: GraphQLGenerated.User {
8891
return name
8992
}
9093

91-
func email(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress {
92-
return EmailAddress(email: email)
94+
func email(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> GraphQLScalars.EmailAddress {
95+
return .init(email: email)
9396
}
9497

9598
func age(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> Int? {
@@ -106,8 +109,8 @@ struct Contact: GraphQLGenerated.Contact {
106109
let email: String
107110

108111
// Required implementations
109-
func email(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> EmailAddress {
110-
return EmailAddress(email: email)
112+
func email(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> GraphQLScalars.EmailAddress {
113+
return .init(email: email)
111114
}
112115
}
113116

README.md

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,11 @@ actor GraphQLContext {
6868
}
6969
```
7070

71-
Create any scalar types (with names matching GraphQL), and conform them to `GraphQLScalar`. See the `Scalars` usage section below for details.
71+
If your schema has any custom scalar types, you must create them manually in the `GraphQLScalars` namespace. See the `Scalars` usage section below for details.
7272

7373
Create a resolvers struct with the required typealiases:
7474
```swift
75-
struct Resolvers: ResolversProtocol {
75+
struct Resolvers: GraphQLGenerated.Resolvers {
7676
typealias Query = ExamplePackage.Query
7777
typealias Mutation = ExamplePackage.Mutation
7878
typealias Subscription = ExamplePackage.Subscription
@@ -84,7 +84,7 @@ As you build the `Query`, `Mutation`, and `Subscription` types and their resolut
8484
```swift
8585
struct Query: GraphQLGenerated.Query {
8686
// This is required by `GraphQLGenerated.Query`, and used by GraphQL query resolution.
87-
static func user(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> (any UserProtocol)? {
87+
static func user(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> (any GraphQLGenerated.User)? {
8888
// You can implement resolution logic however you like.
8989
return context.user
9090
}
@@ -99,9 +99,9 @@ struct User: GraphQLGenerated.User {
9999
func name(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String {
100100
return name
101101
}
102-
func email(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> EmailAddress {
102+
func email(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> GraphQLScalars.EmailAddress {
103103
// You can implement resolution logic however you like.
104-
return EmailAddress(email: self.email)
104+
return .init(email: self.email)
105105
}
106106
}
107107
```
@@ -123,7 +123,7 @@ print(result)
123123
All generated types other than `GraphQLContext` and scalar types are namespaced inside of `GraphQLGenerated` to minimize polluting the inheriting package's type namespace.
124124

125125
### Root Types
126-
Root types (Query, Mutation, and Subscription) are modeled as Swift protocols with static method requirements for each field. The user must implement these types and provide them to the `buildGraphQLSchema` function.
126+
Root types (Query, Mutation, and Subscription) are modeled as Swift protocols with static method requirements for each field. The user must implement these types and provide them to the `buildGraphQLSchema` function via the `Resolvers` typealiases.
127127

128128
### Object Types
129129
Object types are modeled as Swift protocols with instance method requirements for each field. This is to enable maximum implementation flexibility. Internally, GraphQL passes result objects directly through to subsequent resolvers. By only specifying the interface, we allow the backing types to be incredibly dynamic - they can be simple codable structs or complex stateful actors, reference or values types, or any other type configuration.
@@ -166,52 +166,52 @@ Interfaces are modeled as a protocol with required methods for each relevant fie
166166
Union types are modeled as a marker protocol, with no required properties or functions. Related objects are marked as requiring conformance to the union protocol.
167167

168168
### Input Object Types
169-
Input object types are modeled as a deterministic Codable struct with the declared fields. If more complex objects must be created from the codable struct, this can be done in the resolver itself, since input objects only relevant for their associated resolver (they are not passed to downstream resolvers).
169+
Input object types are modeled as a deterministic Codable struct with the declared fields. If more complex objects must be created from the codable struct, this can be done in the resolver itself, since input objects are only relevant for their associated resolver (they are not passed to downstream resolvers).
170170

171171
### Enum Types
172172
Enum types are modeled as a deterministic String enum with values matching the declared fields and associated representations. If you need different values or more complex implementations, simply convert to/from a different representation inside your resolvers.
173173

174174
### Scalar Types
175-
Scalar types are not modeled by the generator. They are simply referenced using the Scalar's name, and you are expected to implement the required type. Since GraphQL uses a different serialization system than Swift, you must conform the type to Swift's `Codable` and GraphQL's `GraphQLScalar`, and have them agree on a representation.
176-
177-
Below is an example that represents a scalar struct as a raw String:
175+
Scalar types are not modeled by the generator. They are simply referenced as `GraphQLScalars.<name>`, and you are expected to implement the required type, and conform it to `GraphQLScalar`. Since GraphQL uses a different serialization system than Swift, you should be sure that the type's conformance to Swift's `Codable` and GraphQL's `GraphQLScalar` agree on a representation. Below is an example that represents a scalar struct as a raw String:
178176

179177
```swift
180-
struct EmailAddress: GraphQLScalar {
181-
let email: String
178+
extension GraphQLScalars {
179+
struct EmailAddress: GraphQLScalar {
180+
let email: String
182181

183-
init(email: String) {
184-
self.email = email
185-
}
182+
init(email: String) {
183+
self.email = email
184+
}
186185

187-
// Codability conformance. Represent simply as `email` string.
188-
init(from decoder: any Decoder) throws {
189-
self.email = try decoder.singleValueContainer().decode(String.self)
190-
}
191-
func encode(to encoder: any Encoder) throws {
192-
try self.email.encode(to: encoder)
193-
}
186+
// Codability conformance. Represent simply as `email` string.
187+
init(from decoder: any Decoder) throws {
188+
self.email = try decoder.singleValueContainer().decode(String.self)
189+
}
190+
func encode(to encoder: any Encoder) throws {
191+
try self.email.encode(to: encoder)
192+
}
194193

195-
// Scalar conformance. Parse & serialize simply as `email` string.
196-
static func serialize(this: Self) throws -> Map {
197-
return .string(this.email)
198-
}
199-
static func parseValue(map: Map) throws -> Map {
200-
switch map {
201-
case .string:
202-
return map
203-
default:
204-
throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)")
194+
// Scalar conformance. Parse & serialize simply as `email` string.
195+
static func serialize(this: Self) throws -> Map {
196+
return .string(this.email)
205197
}
206-
}
207-
static func parseLiteral(value: any Value) throws -> Map {
208-
guard let ast = value as? StringValue else {
209-
throw GraphQLError(
210-
message: "EmailAddress cannot represent non-string value: \(print(ast: value))",
211-
nodes: [value]
212-
)
198+
static func parseValue(map: Map) throws -> Map {
199+
switch map {
200+
case .string:
201+
return map
202+
default:
203+
throw GraphQLError(message: "EmailAddress cannot represent non-string value: \(map)")
204+
}
205+
}
206+
static func parseLiteral(value: any Value) throws -> Map {
207+
guard let ast = value as? StringValue else {
208+
throw GraphQLError(
209+
message: "EmailAddress cannot represent non-string value: \(print(ast: value))",
210+
nodes: [value]
211+
)
212+
}
213+
return .string(ast.value)
213214
}
214-
return .string(ast.value)
215215
}
216216
}
217217
```

Sources/GraphQLGeneratorCore/Generator/GraphQLTypesGenerator.swift

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ package struct GraphQLTypesGenerator {
1414
import GraphQL
1515
import GraphQLGeneratorRuntime
1616
17-
"""
18-
19-
// Generate Resolvers protocol
20-
output += """
17+
enum GraphQLScalars { }
2118
2219
enum GraphQLGenerated {
2320
protocol Resolvers: Sendable {

Sources/GraphQLGeneratorCore/Utilities/swiftTypeName.swift

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ func swiftTypeReference(for type: GraphQLType, includeNamespace: Bool, nameGener
3737
if namedType is GraphQLUnionType || namedType is GraphQLInterfaceType || namedType is GraphQLObjectType {
3838
// These are all interfaces, so we must wrap them in 'any' and parentheses for optionals.
3939
return "(any \(baseName))?"
40-
} else if namedType is GraphQLScalarType {
41-
let swiftScalar = mapScalarType(namedType.name, includeNamespace: includeNamespace, nameGenerator: nameGenerator)
40+
}
41+
if let scalarType = namedType as? GraphQLScalarType {
42+
let swiftScalar = mapScalarType(scalarType, includeNamespace: includeNamespace, nameGenerator: nameGenerator)
4243
return "\(swiftScalar)?"
4344
}
4445
return "\(baseName)?"
@@ -80,20 +81,17 @@ func swiftTypeDeclaration(for type: GraphQLType, includeNamespace: Bool, nameGen
8081
/// - graphQLType: The GraphQL Type to generate a reference to
8182
/// - includeNamespace: Whether to include the `GraphQLGenerated` type namespace in the result
8283
/// - nameGenerator: The name generator
83-
func mapScalarType(_ graphQLType: String, includeNamespace: Bool, nameGenerator: SafeNameGenerator) -> String {
84-
switch graphQLType {
84+
func mapScalarType(_ type: GraphQLScalarType, includeNamespace: Bool, nameGenerator: SafeNameGenerator) -> String {
85+
switch type.name {
8586
case "ID": return "String"
8687
case "String": return "String"
8788
case "Int": return "Int"
8889
case "Float": return "Double"
8990
case "Boolean": return "Bool"
9091
default:
9192
// For custom scalars, use safe name generator
92-
var name = nameGenerator.swiftTypeName(for: graphQLType)
93-
if includeNamespace {
94-
name = "GraphQLGenerated.\(name)"
95-
}
96-
return name
93+
let baseName = nameGenerator.swiftTypeName(for: type.name)
94+
return "GraphQLScalars.\(baseName)"
9795
}
9896
}
9997

Tests/GraphQLGeneratorCoreTests/SchemaGeneratorTests.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,10 +249,12 @@ struct SchemaGeneratorTests {
249249
// MARK: - Resolver Callback Tests
250250

251251
@Test func generateResolverCallbackForParent() throws {
252+
let scalar = try GraphQLScalarType(name: "Scalar")
252253
let field = GraphQLField(
253254
type: GraphQLString,
254255
args: [
255256
"filter": GraphQLArgument(type: GraphQLString),
257+
"scalar": GraphQLArgument(type: GraphQLNonNull(scalar)),
256258
]
257259
)
258260

@@ -270,8 +272,9 @@ struct SchemaGeneratorTests {
270272
fields["posts"]?.resolve = { source, args, context, info in
271273
let parent = try cast(source, to: (any GraphQLGenerated.User).self)
272274
let filter = args["filter"] != .undefined ? try decoder.decode((String?).self, from: args["filter"]) : nil
275+
let scalar = try decoder.decode((GraphQLScalars.Scalar).self, from: args["scalar"])
273276
let context = try cast(context, to: GraphQLContext.self)
274-
return try await parent.posts(filter: filter, context: context, info: info)
277+
return try await parent.posts(filter: filter, scalar: scalar, context: context, info: info)
275278
}
276279
"""
277280

Tests/GraphQLGeneratorCoreTests/TypeGeneratorTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,35 @@ struct TypeGeneratorTests {
108108
#expect(result == expected)
109109
}
110110

111+
@Test func generateInputStructWithCustomScalar() throws {
112+
let phoneNumber: GraphQLScalarType = try GraphQLScalarType(
113+
name: "PhoneNumber"
114+
)
115+
let personInput = try GraphQLInputObjectType(
116+
name: "PersonInput"
117+
)
118+
personInput.fields = {
119+
[
120+
"cellPhone": InputObjectField(type: GraphQLNonNull(phoneNumber)),
121+
"homePhone": InputObjectField(type: phoneNumber),
122+
"familyPhones": InputObjectField(type: GraphQLList(phoneNumber)),
123+
]
124+
}
125+
126+
let result = try generator.generateInputStruct(for: personInput)
127+
128+
let expected = """
129+
130+
struct PersonInput: Codable, Sendable {
131+
let cellPhone: GraphQLScalars.PhoneNumber
132+
let homePhone: GraphQLScalars.PhoneNumber?
133+
let familyPhones: [GraphQLScalars.PhoneNumber]?
134+
}
135+
"""
136+
137+
#expect(result == expected)
138+
}
139+
111140
@Test func generateInterfaceProtocol() async throws {
112141
let interfaceA = try GraphQLInterfaceType(
113142
name: "A",
@@ -160,6 +189,7 @@ struct TypeGeneratorTests {
160189
name: "A",
161190
description: "A"
162191
)
192+
let scalar = try GraphQLScalarType(name: "Scalar")
163193
let typeFoo = try GraphQLObjectType(
164194
name: "Foo",
165195
description: "Foo",
@@ -181,6 +211,15 @@ struct TypeGeneratorTests {
181211
),
182212
]
183213
),
214+
"baz": .init(
215+
type: scalar,
216+
description: "baz",
217+
args: [
218+
"baz": .init(
219+
type: GraphQLNonNull(scalar)
220+
)
221+
]
222+
),
184223
],
185224
interfaces: [interfaceA]
186225
)
@@ -201,6 +240,9 @@ struct TypeGeneratorTests {
201240
/// bar
202241
func bar(foo: String, bar: String?, context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String?
203242
243+
/// baz
244+
func baz(baz: GraphQLScalars.Scalar, context: GraphQLContext, info: GraphQLResolveInfo) async throws -> GraphQLScalars.Scalar?
245+
204246
}
205247
"""
206248
)

0 commit comments

Comments
 (0)