From 79e9849e1cdce2e5d2b95941f1da277f217c7bec Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sat, 28 Feb 2026 16:01:43 -0700 Subject: [PATCH 1/3] feat: Adds @graphQLResolver Macro This reduces boilerplate for simple Swift property resolvers --- Examples/HelloWorldServer/Package.resolved | 9 + Examples/HelloWorldServer/Package.swift | 1 + .../Sources/HelloWorldServer/Resolvers.swift | 59 ++-- .../Sources/HelloWorldServer/schema.graphql | 2 +- .../HelloWorldServerTests.swift | 4 +- Examples/StarWars/Package.resolved | 11 +- Examples/StarWars/Package.swift | 1 + .../Sources/StarWars/GraphQL/Relay.swift | 49 +--- .../Sources/StarWars/GraphQL/Resolvers.swift | 37 +-- Package.resolved | 11 +- Package.swift | 29 ++ README.md | 21 +- .../GraphQLGeneratorMacros.swift | 47 +++ .../GraphQLResolverMacro.swift | 90 ++++++ .../GraphQLResolverMacroTests.swift | 267 ++++++++++++++++++ 15 files changed, 522 insertions(+), 116 deletions(-) create mode 100644 Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift create mode 100644 Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift create mode 100644 Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift diff --git a/Examples/HelloWorldServer/Package.resolved b/Examples/HelloWorldServer/Package.resolved index dacc489..2829dc4 100644 --- a/Examples/HelloWorldServer/Package.resolved +++ b/Examples/HelloWorldServer/Package.resolved @@ -27,6 +27,15 @@ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", "version" : "1.3.0" } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } } ], "version" : 3 diff --git a/Examples/HelloWorldServer/Package.swift b/Examples/HelloWorldServer/Package.swift index 4504e1a..493d404 100644 --- a/Examples/HelloWorldServer/Package.swift +++ b/Examples/HelloWorldServer/Package.swift @@ -16,6 +16,7 @@ let package = Package( name: "HelloWorldServer", dependencies: [ .product(name: "GraphQL", package: "GraphQL"), + .product(name: "GraphQLGeneratorMacros", package: "graphql-generator"), .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), ], plugins: [ diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift index ad17dbb..8a99459 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/Resolvers.swift @@ -1,5 +1,6 @@ import Foundation import GraphQL +import GraphQLGeneratorMacros import GraphQLGeneratorRuntime /// Must be created by user and named `GraphQLContext`. @@ -76,39 +77,28 @@ struct Resolvers: GraphQLGenerated.Resolvers { struct User: GraphQLGenerated.User { // User can choose structure - let id: String - let name: String - let email: String - let age: Int? - let role: GraphQLGenerated.Role? - /// Required implementations - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } + // Properties with auto-generated GraphQL resolvers. - func name(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return name - } + @graphQLResolver let id: String + @graphQLResolver let name: String + @graphQLResolver let age: Int? + @graphQLResolver let role: GraphQLGenerated.Role? + + // Required implementations + // Can't use @graphQLResolver macro because we must convert from String to GraphQLScalars.EmailAddress + let email: String func email(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> GraphQLScalars.EmailAddress { return .init(email: email) } - - func age(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> Int? { - return age - } - - func role(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> GraphQLGenerated.Role? { - return role - } } struct Contact: GraphQLGenerated.Contact { - /// User can choose structure - let email: String + // User can choose structure /// Required implementations + let email: String func email(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> GraphQLScalars.EmailAddress { return .init(email: email) } @@ -116,24 +106,13 @@ struct Contact: GraphQLGenerated.Contact { struct Post: GraphQLGenerated.Post { // User can choose structure - let id: String - let title: String - let content: String - let authorId: String - /// Required implementations - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } - - func title(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return title - } - - func content(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return content - } + @graphQLResolver let id: String + @graphQLResolver let title: String + @graphQLResolver let content: String + /// Required implementations + let authorId: String func author(context: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> any GraphQLGenerated.User { return context.users[authorId]! } @@ -168,9 +147,9 @@ struct Mutation: GraphQLGenerated.Mutation { let user = User( id: userInfo.id, name: userInfo.name, - email: userInfo.email.email, age: userInfo.age, - role: userInfo.role + role: userInfo.role, + email: userInfo.email.email ) context.users[userInfo.id] = user return user diff --git a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql index 4778f48..ba9dbc9 100644 --- a/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql +++ b/Examples/HelloWorldServer/Sources/HelloWorldServer/schema.graphql @@ -12,9 +12,9 @@ union UserOrPost = User | Post input UserInfo { id: ID! name: String! - email: EmailAddress! age: Int role: Role = USER + email: EmailAddress! } """ diff --git a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift index 69cdf45..035c7f8 100644 --- a/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift +++ b/Examples/HelloWorldServer/Tests/HelloWorldServerTests/HelloWorldServerTests.swift @@ -7,7 +7,7 @@ struct HelloWorldServerTests { @Test func query() async throws { let schema = try buildGraphQLSchema(resolvers: Resolvers.self) let context = GraphQLContext( - users: ["1": .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + users: ["1": .init(id: "1", name: "John", age: 18, role: .user, email: "john@example.com")], posts: ["1": .init(id: "1", title: "Foo", content: "bar", authorId: "1")] ) let actual = try await graphql( @@ -89,7 +89,7 @@ struct HelloWorldServerTests { @Test func subscription() async throws { let schema = try buildGraphQLSchema(resolvers: Resolvers.self) let context = GraphQLContext( - users: ["1": .init(id: "1", name: "John", email: "john@example.com", age: 18, role: .user)], + users: ["1": .init(id: "1", name: "John", age: 18, role: .user, email: "john@example.com")], posts: [:] ) let stream = try await graphqlSubscribe( diff --git a/Examples/StarWars/Package.resolved b/Examples/StarWars/Package.resolved index 4bb0770..fe3a2ed 100644 --- a/Examples/StarWars/Package.resolved +++ b/Examples/StarWars/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5baf1697a71440af6f325d83e68fc9fefa98355a82ec82eda703780f1c6337f7", + "originHash" : "a12ce6a75f11a221a80bdd52740bfca5fc042477fe853ae45c03b21c726fe4eb", "pins" : [ { "identity" : "async-collections", @@ -217,6 +217,15 @@ "version" : "2.9.1" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/Examples/StarWars/Package.swift b/Examples/StarWars/Package.swift index ca87832..bd263c6 100644 --- a/Examples/StarWars/Package.swift +++ b/Examples/StarWars/Package.swift @@ -26,6 +26,7 @@ let package = Package( .product(name: "AsyncDataLoader", package: "DataLoader"), .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "GraphQL", package: "GraphQL"), + .product(name: "GraphQLGeneratorMacros", package: "graphql-generator"), .product(name: "GraphQLGeneratorRuntime", package: "graphql-generator"), ], plugins: [ diff --git a/Examples/StarWars/Sources/StarWars/GraphQL/Relay.swift b/Examples/StarWars/Sources/StarWars/GraphQL/Relay.swift index c3b8b15..6462b55 100644 --- a/Examples/StarWars/Sources/StarWars/GraphQL/Relay.swift +++ b/Examples/StarWars/Sources/StarWars/GraphQL/Relay.swift @@ -1,5 +1,6 @@ import Foundation import GraphQL +import GraphQLGeneratorMacros /// Encodes a SWAPI type and ID into a Relay Node ID. This is the base64-encoded string `:` /// - Parameters: @@ -26,33 +27,17 @@ func urlToID(_ url: String) -> String { } struct PageInfo: GraphQLGenerated.PageInfo { - let hasNextPage: Bool - let hasPreviousPage: Bool - let startCursor: String? - let endCursor: String? - - func hasNextPage(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> Bool { - return hasNextPage - } - - func hasPreviousPage(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> Bool { - return hasPreviousPage - } - - func startCursor(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String? { - return startCursor - } - - func endCursor(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String? { - return endCursor - } + @graphQLResolver let hasNextPage: Bool + @graphQLResolver let hasPreviousPage: Bool + @graphQLResolver let startCursor: String? + @graphQLResolver let endCursor: String? } /// A generalized Relay connection. struct Connection: Sendable { - let pageInfo: PageInfo - let edges: [Edge] - let totalCount: Int + @graphQLResolver let pageInfo: any GraphQLGenerated.PageInfo + @graphQLResolver let edges: [Edge] + @graphQLResolver let totalCount: Int? /// Create a connection by passing a total list of the available IDs in order. init(ids: [String], after: String?, first: Int?, before: String?, last: Int?) { @@ -93,18 +78,6 @@ struct Connection: Sendable { edges = pageIds.map { Edge(cursor: $0) } totalCount = ids.count } - - func pageInfo(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> any GraphQLGenerated.PageInfo { - return pageInfo - } - - func edges(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> [Edge]? { - return edges - } - - func totalCount(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> Int? { - return totalCount - } } extension Connection: @@ -338,11 +311,7 @@ extension Connection: } struct Edge: Sendable { - let cursor: String - - func cursor(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return cursor - } + @graphQLResolver let cursor: String } extension Edge: diff --git a/Examples/StarWars/Sources/StarWars/GraphQL/Resolvers.swift b/Examples/StarWars/Sources/StarWars/GraphQL/Resolvers.swift index a714bc5..446aa11 100644 --- a/Examples/StarWars/Sources/StarWars/GraphQL/Resolvers.swift +++ b/Examples/StarWars/Sources/StarWars/GraphQL/Resolvers.swift @@ -1,6 +1,7 @@ import AsyncHTTPClient import Foundation import GraphQL +import GraphQLGeneratorMacros import GraphQLGeneratorRuntime struct GraphQLContext { @@ -130,7 +131,7 @@ struct Root: GraphQLGenerated.Root { } extension Film: GraphQLGenerated.Film { - var id: String { + @graphQLResolver var id: String { return encodeID(type: Film.self, id: urlToID(url)) } @@ -166,10 +167,6 @@ extension Film: GraphQLGenerated.Film { return edited } - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } - func speciesConnection(after: String?, first: Int?, before: String?, last: Int?, context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> (any GraphQLGenerated.FilmSpeciesConnection)? { let filteredIDs = species.map { encodeID(type: Film.self, id: urlToID($0)) } return Connection(ids: filteredIDs, after: after, first: first, before: before, last: last) @@ -197,7 +194,7 @@ extension Film: GraphQLGenerated.Film { } extension Person: GraphQLGenerated.Person { - var id: String { + @graphQLResolver var id: String { return encodeID(type: Person.self, id: urlToID(url)) } @@ -266,14 +263,10 @@ extension Person: GraphQLGenerated.Person { func edited(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String? { return edited } - - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } } extension Planet: GraphQLGenerated.Planet { - var id: String { + @graphQLResolver var id: String { return encodeID(type: Planet.self, id: urlToID(url)) } @@ -330,14 +323,10 @@ extension Planet: GraphQLGenerated.Planet { func edited(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String? { return edited } - - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } } extension Species: GraphQLGenerated.Species { - var id: String { + @graphQLResolver var id: String { return encodeID(type: Species.self, id: urlToID(url)) } @@ -398,14 +387,10 @@ extension Species: GraphQLGenerated.Species { func edited(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String? { return edited } - - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } } extension Starship: GraphQLGenerated.Starship { - var id: String { + @graphQLResolver var id: String { return encodeID(type: Starship.self, id: urlToID(url)) } @@ -478,14 +463,10 @@ extension Starship: GraphQLGenerated.Starship { func edited(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String? { return edited } - - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } } extension Vehicle: GraphQLGenerated.Vehicle { - var id: String { + @graphQLResolver var id: String { return encodeID(type: Vehicle.self, id: urlToID(url)) } @@ -550,8 +531,4 @@ extension Vehicle: GraphQLGenerated.Vehicle { func edited(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String? { return edited } - - func id(context _: GraphQLContext, info _: GraphQL.GraphQLResolveInfo) async throws -> String { - return id - } } diff --git a/Package.resolved b/Package.resolved index 5784903..e2e0c7e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0b104106c14ed80747d129d8a009bead40d06d68c43d690c3ae5efb52a4bcc94", + "originHash" : "37898b2742ee9998877cf08f9aa8ece5501e7b816faac99f9ebd875b027dd94f", "pins" : [ { "identity" : "graphql", @@ -27,6 +27,15 @@ "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", "version" : "1.3.0" } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index fa397b6..134c10f 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,6 @@ // swift-tools-version: 6.0 +import CompilerPluginSupport import PackageDescription let package = Package( @@ -19,12 +20,18 @@ let package = Package( name: "GraphQLGeneratorRuntime", targets: ["GraphQLGeneratorRuntime"] ), + .library( + name: "GraphQLGeneratorMacros", + targets: ["GraphQLGeneratorMacros"] + ), ], dependencies: [ .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.1.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-syntax.git", "509.0.0" ..< "700.0.0"), ], targets: [ + // Build plugin .plugin( name: "GraphQLGeneratorPlugin", capability: .buildTool(), @@ -55,5 +62,27 @@ let package = Package( "GraphQLGeneratorCore", ] ), + + // Macro + .macro( + name: "GraphQLGeneratorMacrosBackend", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .target( + name: "GraphQLGeneratorMacros", + dependencies: [ + "GraphQLGeneratorMacrosBackend", + ] + ), + .testTarget( + name: "GraphQLGeneratorMacrosTests", + dependencies: [ + "GraphQLGeneratorMacrosBackend", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), ] ) diff --git a/README.md b/README.md index b8e8943..7f4e650 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,25 @@ struct User: GraphQLGenerated.User { Let the protocol conformance guide you on what resolver methods your types must define, and keep going until everything compiles. +This package also provides a `@graphQLResolver` macro to reduce boilerplate in cases where the resolver simply results in the value of a Swift property. For example, the `User` type above could be shortened to: + +```swift +import GraphQLGeneratedMacros + +struct User: GraphQLGenerated.User { + @graphQLResolver let name: String + let email: String + + // The `func name(...)` resolver is automatically generated. + + func email(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> GraphQLScalars.EmailAddress { + return .init(email: self.email) + } +} +``` + +Note that you must include the `GraphQLGeneratedMacros` library to use the macros. + ### 4. Execute GraphQL Queries You're done! You can now instantiate your GraphQL schema by calling `buildGraphQLSchema`, and run queries against it: @@ -112,7 +131,7 @@ print(result) This generator is designed with the following guiding principles: - **Protocol-based flexibility**: GraphQL types are generated as Swift protocols (except where concrete types are needed), allowing you to implement backing types however you want - structs, actors, classes, or any combination. -- **Explicit over implicit**: No default resolvers based on reflection. While more verbose, this provides better performance and clearer schema evolution handling. +- **Explicit over implicit**: No default resolvers based on reflection. While more verbose, this provides better performance and clearer schema evolution handling. Macros are provided for common boilerplate. - **Type safety**: Leverage Swift's type system to ensure compile-time conformance with your GraphQL schema. - **Namespace isolation**: All generated types (except `GraphQLContext` and custom scalars) are namespaced inside `GraphQLGenerated` to avoid polluting your package's type namespace. diff --git a/Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift b/Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift new file mode 100644 index 0000000..b5463d5 --- /dev/null +++ b/Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift @@ -0,0 +1,47 @@ +/// Generates a GraphQL resolver method for a property. +/// +/// This macro reduces boilerplate by automatically generating resolver methods +/// that simply return the property value. It's designed for simple field resolution +/// where no custom logic is needed. +/// +/// ## Usage +/// +/// Attach this macro to a property in a type conforming to a GraphQL protocol. +/// You can use it with or without a name argument: +/// +/// ```swift +/// struct User: GraphQLGenerated.User { +/// // Without name - uses property name as GraphQL field name +/// @graphQLResolver +/// let id: String +/// +/// // With name - uses custom GraphQL field name +/// @graphQLResolver(name: "fullName") +/// let name: String +/// } +/// ``` +/// +/// The macros above generate the following resolver methods: +/// +/// ```swift +/// func id(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { +/// return id +/// } +/// +/// func fullName(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { +/// return name +/// } +/// ``` +/// +/// ## Requirements +/// +/// - The property must have an explicit type annotation +/// - The property type must match the GraphQL field return type +/// +/// - Parameters: +/// - name: Optional. The GraphQL field name. If omitted, the property name is used. +@attached(peer, names: arbitrary) +public macro graphQLResolver(name: String? = nil) = #externalMacro( + module: "GraphQLGeneratorMacrosBackend", + type: "GraphQLResolverMacro" +) diff --git a/Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift b/Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift new file mode 100644 index 0000000..2afdd23 --- /dev/null +++ b/Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift @@ -0,0 +1,90 @@ +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +/// Macro implementation for `@graphQLResolver` +/// +/// This peer macro generates a GraphQL resolver method that returns the property value. +public struct GraphQLResolverMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in _: some MacroExpansionContext + ) throws -> [DeclSyntax] { + // Validate that this is attached to a property + guard let varDecl = declaration.as(VariableDeclSyntax.self) else { + throw MacroError.notAttachedToProperty + } + + // Validate that it's a stored property (has 'let' or 'var') + guard varDecl.bindingSpecifier.tokenKind == .keyword(.let) || + varDecl.bindingSpecifier.tokenKind == .keyword(.var) + else { + throw MacroError.invalidPropertyDeclaration + } + + // Extract the property name and type + guard let binding = varDecl.bindings.first, + let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier, + let type = binding.typeAnnotation?.type + else { + throw MacroError.invalidPropertyDeclaration + } + + let propertyName = identifier.text + let propertyType = type.trimmedDescription + + // Set argument defaults + var graphQLFieldName = propertyName + + // Override if arguments are provided + if case let .argumentList(arguments) = node.arguments { + if let nameArg = arguments.first(where: { $0.label?.text == "name" }) { + guard + let fieldName = nameArg.expression.as(StringLiteralExprSyntax.self)? + .segments.first?.as(StringSegmentSyntax.self)?.content.text + else { + // Invalid name argument + throw MacroError.invalidArguments + } + graphQLFieldName = fieldName + } + } + + // Generate the resolver method + let resolverMethod: DeclSyntax = """ + func \(raw: graphQLFieldName)(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> \(raw: propertyType) { + return \(raw: propertyName) + } + """ + + return [resolverMethod] + } +} + +/// Errors that can occur during macro expansion +enum MacroError: Error, CustomStringConvertible { + case notAttachedToProperty + case invalidPropertyDeclaration + case invalidArguments + + var description: String { + switch self { + case .notAttachedToProperty: + return "@graphQLResolver can only be applied to properties" + case .invalidPropertyDeclaration: + return "@graphQLResolver requires a stored property (let/var) with an explicit type annotation" + case .invalidArguments: + return "@graphQLResolver accepts either no arguments or a 'name' string argument" + } + } +} + +/// Compiler plugin that provides the GraphQLGeneratorMacros +@main +struct GraphQLGeneratorMacrosPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + GraphQLResolverMacro.self, + ] +} diff --git a/Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift b/Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift new file mode 100644 index 0000000..c65a59c --- /dev/null +++ b/Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift @@ -0,0 +1,267 @@ +import GraphQLGeneratorMacrosBackend +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class GraphQLResolverMacroTests: XCTestCase { + let testMacros: [String: Macro.Type] = [ + "graphQLResolver": GraphQLResolverMacro.self, + ] + + func testSimpleField() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + let id: String + } + """, + expandedSource: """ + struct User { + let id: String + + func id(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return id + } + } + """, + macros: testMacros + ) + } + + func testMultipleFields() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + let id: String + + @graphQLResolver + let name: String + + @graphQLResolver + let age: Int? + } + """, + expandedSource: """ + struct User { + let id: String + + func id(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return id + } + let name: String + + func name(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return name + } + let age: Int? + + func age(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> Int? { + return age + } + } + """, + macros: testMacros + ) + } + + func testFieldWithCustomName() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver(name: "emailAddress") + let email: String + } + """, + expandedSource: """ + struct User { + let email: String + + func emailAddress(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return email + } + } + """, + macros: testMacros + ) + } + + func testMixedNamedAndUnnamedFields() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + let id: String + + @graphQLResolver(name: "fullName") + let name: String + } + """, + expandedSource: """ + struct User { + let id: String + + func id(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return id + } + let name: String + + func fullName(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return name + } + } + """, + macros: testMacros + ) + } + + func testOptionalTypes() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + let email: String? + } + """, + expandedSource: """ + struct User { + let email: String? + + func email(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String? { + return email + } + } + """, + macros: testMacros + ) + } + + func testArrayTypes() { + assertMacroExpansion( + """ + struct Query { + @graphQLResolver + let users: [User] + } + """, + expandedSource: """ + struct Query { + let users: [User] + + func users(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> [User] { + return users + } + } + """, + macros: testMacros + ) + } + + func testCustomScalarTypes() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + let email: GraphQLScalars.EmailAddress + } + """, + expandedSource: """ + struct User { + let email: GraphQLScalars.EmailAddress + + func email(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> GraphQLScalars.EmailAddress { + return email + } + } + """, + macros: testMacros + ) + } + + func testExistentialTypes() { + assertMacroExpansion( + """ + struct Query { + @graphQLResolver + let user: (any User)? + } + """, + expandedSource: """ + struct Query { + let user: (any User)? + + func user(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> (any User)? { + return user + } + } + """, + macros: testMacros + ) + } + + func testVarProperties() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + var name: String + } + """, + expandedSource: """ + struct User { + var name: String + + func name(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return name + } + } + """, + macros: testMacros + ) + } + + func testErrorOnMissingTypeAnnotation() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + let id = "123" + } + """, + expandedSource: """ + struct User { + let id = "123" + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@graphQLResolver requires a stored property (let/var) with an explicit type annotation", + line: 2, + column: 5 + ), + ], + macros: testMacros + ) + } + + func testErrorOnNonProperty() { + assertMacroExpansion( + """ + @graphQLResolver + func someFunction() {} + """, + expandedSource: """ + func someFunction() {} + """, + diagnostics: [ + DiagnosticSpec( + message: "@graphQLResolver can only be applied to properties", + line: 1, + column: 1 + ), + ], + macros: testMacros + ) + } +} From 7f91fbe1bec4254966ecbea50efb6ee007d86185 Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 1 Mar 2026 16:08:43 -0700 Subject: [PATCH 2/3] feat: Adds throwing/await support to @graphQLResolver --- .../GraphQLGeneratorMacros.swift | 12 ++ .../GraphQLResolverMacro.swift | 12 +- .../GraphQLResolverMacroTests.swift | 116 ++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift b/Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift index b5463d5..10d7db5 100644 --- a/Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift +++ b/Sources/GraphQLGeneratorMacros/GraphQLGeneratorMacros.swift @@ -18,6 +18,14 @@ /// // With name - uses custom GraphQL field name /// @graphQLResolver(name: "fullName") /// let name: String +/// +/// // Async throwing computed property - automatically adds 'try await' +/// @graphQLResolver +/// var userId: String { +/// get async throws { +/// try await user.fetchID() +/// } +/// } /// } /// ``` /// @@ -31,6 +39,10 @@ /// func fullName(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { /// return name /// } +/// +/// func userId(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { +/// return try await userId +/// } /// ``` /// /// ## Requirements diff --git a/Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift b/Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift index 2afdd23..0bcb621 100644 --- a/Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift +++ b/Sources/GraphQLGeneratorMacrosBackend/GraphQLResolverMacro.swift @@ -35,6 +35,16 @@ public struct GraphQLResolverMacro: PeerMacro { let propertyName = identifier.text let propertyType = type.trimmedDescription + // Check if the property has a throwing getter + let hasThrows = binding.accessorBlock?.accessors.as(AccessorDeclListSyntax.self)?.contains { accessor in + accessor.accessorSpecifier.tokenKind == .keyword(.get) && accessor.effectSpecifiers?.throwsClause != nil + } ?? false + + // Check if the property has an async getter + let hasAsync = binding.accessorBlock?.accessors.as(AccessorDeclListSyntax.self)?.contains { accessor in + accessor.accessorSpecifier.tokenKind == .keyword(.get) && accessor.effectSpecifiers?.asyncSpecifier != nil + } ?? false + // Set argument defaults var graphQLFieldName = propertyName @@ -55,7 +65,7 @@ public struct GraphQLResolverMacro: PeerMacro { // Generate the resolver method let resolverMethod: DeclSyntax = """ func \(raw: graphQLFieldName)(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> \(raw: propertyType) { - return \(raw: propertyName) + return \(raw: hasThrows ? "try " : "")\(raw: hasAsync ? "await " : "")\(raw: propertyName) } """ diff --git a/Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift b/Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift index c65a59c..57c2956 100644 --- a/Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift +++ b/Tests/GraphQLGeneratorMacrosTests/GraphQLResolverMacroTests.swift @@ -264,4 +264,120 @@ final class GraphQLResolverMacroTests: XCTestCase { macros: testMacros ) } + + func testComputedProperty() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + var name: String { + get { + return "Test" + } + } + } + """, + expandedSource: """ + struct User { + var name: String { + get { + return "Test" + } + } + + func name(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return name + } + } + """, + macros: testMacros + ) + } + + func testThrowingComputedProperty() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + var id: String { + get throws { + try property.getID() + } + } + } + """, + expandedSource: """ + struct User { + var id: String { + get throws { + try property.getID() + } + } + + func id(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return try id + } + } + """, + macros: testMacros + ) + } + + func testAsyncComputedProperty() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + var id: String { + get async { + await property.getID() + } + } + } + """, + expandedSource: """ + struct User { + var id: String { + get async { + await property.getID() + } + } + + func id(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return await id + } + } + """, + macros: testMacros + ) + } + + func testThrowingAsyncComputedProperty() { + assertMacroExpansion( + """ + struct User { + @graphQLResolver + var id: String { + get async throws { + try await property.getID() + } + } + } + """, + expandedSource: """ + struct User { + var id: String { + get async throws { + try await property.getID() + } + } + + func id(context: GraphQLContext, info: GraphQLResolveInfo) async throws -> String { + return try await id + } + } + """, + macros: testMacros + ) + } } From 755ce724afb6be508747e2a5e15ad5bb4ec1a14a Mon Sep 17 00:00:00 2001 From: Jay Herron Date: Sun, 1 Mar 2026 16:30:26 -0700 Subject: [PATCH 3/3] build: Better manages swift-syntax versions --- Examples/HelloWorldServer/Package.resolved | 6 +++--- Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Examples/HelloWorldServer/Package.resolved b/Examples/HelloWorldServer/Package.resolved index 2829dc4..b0b05e7 100644 --- a/Examples/HelloWorldServer/Package.resolved +++ b/Examples/HelloWorldServer/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e787ee8b5ab2aa4a97e00f0096b14d03e1e013bff99c17617392b51a305117ab", + "originHash" : "5d2dc35be5bbfb684ec3b54e067186d6944830d624f032286cfe8c92ae0ff381", "pins" : [ { "identity" : "graphql", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-syntax.git", "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" } } ], diff --git a/Package.swift b/Package.swift index 134c10f..678c611 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/GraphQLSwift/GraphQL.git", from: "4.1.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), - .package(url: "https://github.com/apple/swift-syntax.git", "509.0.0" ..< "700.0.0"), + .package(url: "https://github.com/apple/swift-syntax.git", "600.0.1" ..< "603.0.0"), ], targets: [ // Build plugin