apollographql / apollo-ios

📱  A strongly-typed, caching GraphQL client for iOS, written in Swift.
https://www.apollographql.com/docs/ios/
MIT License
3.88k stars 722 forks source link

RFC: Swift Codegen Rewrite #939

Closed designatednerd closed 4 years ago

designatednerd commented 4 years ago

This issue will serve as the official Request for Comment for the Swift Codegen project.

This project is to take our existing code generation, which is done in Typescript, and move it to Swift.

Note that parsing of the Schema and parsing/validation of queries will still be taking place using our Typescript tooling - these are presently too tied to JS to be able to be replaced without a very, very large amount of work.

Moving the actual generation of code to Swift will allow Swift developers to more easily understand how code is being generated, and more easily contribute fixes and improvements to the code generation process.

This project will take place in several phases, which are outlined below.

Phase 0: Generate code with swift Script instead of bash

Swift scripts can be run directly from the command line, and these scripts can also import frameworks.

Everything we are currently using bash for will be updated to use Swift wrappers in a framework which will be included with the core Apollo library. This includes:

This will be considerably easier to test than using random bash scripts, and those tests will help ensure that any changes are non-breaking.

The initial framework and tests are already up as a draft PR if you're interested in looking at the details.

The other thing this will do is make it considerably easier to add more flags in the future, specifically the flag to enable the Swift-based code generation rather than the Typescript-based code generation.

This phase will require significant updates to documentation since everything assumes the use of bash right now.

Phase 1: New Codegen

This is going to involve a few sub-phases.

Getting the Abstract Syntax Tree out of our existing codegen in JSON format

This is the part that depends most on our tooling repo. This should basically be done, but this could potentially be a blocker if there are pieces that we need to generate Swift code that's clearer and harder to fail at.

Parsing the AST into Swift Objects for code generation

In theory, this part should be the most straightforward, and will mostly involve parsing everything in the JSON into Codable objects which will ultimately be used by the code generation engine.

Generating code

This will involve a number of new goodies:

I've stubbed all this out manually using our Star Wars test harness, so I wanted to share a few examples of what the differences in generated code will be (Hidden behind flippy triangles because it's loooooong).

This code is provided for feedback purposes and is not meant to represent the final version of things.

Basic query **Before** ```swift public final class HeroNameWithIdQuery: GraphQLQuery { /// The raw GraphQL definition of this operation. public let operationDefinition = """ query HeroNameWithID($episode: Episode) { hero(episode: $episode) { __typename id name } } """ public let operationName = "HeroNameWithID" public let operationIdentifier: String? = "83c03f612c46fca72f6cb902df267c57bffc9209bc44dd87d2524fb2b34f6f18" public var episode: Episode? public init(episode: Episode? = nil) { self.episode = episode } public var variables: GraphQLMap? { return ["episode": episode] } public struct Data: GraphQLSelectionSet { public static let possibleTypes = ["Query"] public static let selections: [GraphQLSelection] = [ GraphQLField("hero", arguments: ["episode": GraphQLVariable("episode")], type: .object(Hero.selections)), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public init(hero: Hero? = nil) { self.init(unsafeResultMap: ["__typename": "Query", "hero": hero.flatMap { (value: Hero) -> ResultMap in value.resultMap }]) } public var hero: Hero? { get { return (resultMap["hero"] as? ResultMap).flatMap { Hero(unsafeResultMap: $0) } } set { resultMap.updateValue(newValue?.resultMap, forKey: "hero") } } public struct Hero: GraphQLSelectionSet { public static let possibleTypes = ["Human", "Droid"] public static let selections: [GraphQLSelection] = [ GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("id", type: .nonNull(.scalar(GraphQLID.self))), GraphQLField("name", type: .nonNull(.scalar(String.self))), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public static func makeHuman(id: GraphQLID, name: String) -> Hero { return Hero(unsafeResultMap: ["__typename": "Human", "id": id, "name": name]) } public static func makeDroid(id: GraphQLID, name: String) -> Hero { return Hero(unsafeResultMap: ["__typename": "Droid", "id": id, "name": name]) } public var __typename: String { get { return resultMap["__typename"]! as! String } set { resultMap.updateValue(newValue, forKey: "__typename") } } /// The ID of the character public var id: GraphQLID { get { return resultMap["id"]! as! GraphQLID } set { resultMap.updateValue(newValue, forKey: "id") } } /// The name of the character public var name: String { get { return resultMap["name"]! as! String } set { resultMap.updateValue(newValue, forKey: "name") } } } } } ``` **After** ```swift public final class HeroNameWithIdQueryMk2: GraphQLQueryMk2, Codable { /// The raw GraphQL definition of this operation. public let operationDefinition = """ query HeroNameWithID($episode: Episode) { hero(episode: $episode) { __typename id name } } """ public let operationName = "HeroNameWithID" public let operationIdentifier: String? = "83c03f612c46fca72f6cb902df267c57bffc9209bc44dd87d2524fb2b34f6f18" public var episode: GraphQLOptional public init(episode: GraphQLOptional) { self.episode = episode } public enum CodingKeys: String, CodingKey { case episode } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeGraphQLOptional(self.episode, forKey: .episode) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.episode = try container.decodeGraphQLOptional(forKey: .episode) } public struct ResponseData: Codable, Equatable, Hashable { public let hero: Hero public init(hero: Hero) { self.hero = hero } public struct Hero: Codable, Equatable, Hashable { public let __typename: CharacterType public let id: GraphQLID public let name: String public init(__typename: CharacterType, id: GraphQLID, name: String) { self.__typename = __typename self.id = id self.name = name } } } } ```
Query with a fragment **Before** Fragment: ```swift public struct CharacterName: GraphQLFragment { /// The raw GraphQL definition of this fragment. public static let fragmentDefinition = """ fragment CharacterName on Character { __typename name } """ public static let possibleTypes = ["Human", "Droid"] public static let selections: [GraphQLSelection] = [ GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("name", type: .nonNull(.scalar(String.self))), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public static func makeHuman(name: String) -> CharacterName { return CharacterName(unsafeResultMap: ["__typename": "Human", "name": name]) } public static func makeDroid(name: String) -> CharacterName { return CharacterName(unsafeResultMap: ["__typename": "Droid", "name": name]) } public var __typename: String { get { return resultMap["__typename"]! as! String } set { resultMap.updateValue(newValue, forKey: "__typename") } } /// The name of the character public var name: String { get { return resultMap["name"]! as! String } set { resultMap.updateValue(newValue, forKey: "name") } } } ``` Query: ```swift public final class HeroNameWithFragmentQuery: GraphQLQuery { /// The raw GraphQL definition of this operation. public let operationDefinition = """ query HeroNameWithFragment($episode: Episode) { hero(episode: $episode) { __typename ...CharacterName } } """ public let operationName = "HeroNameWithFragment" public let operationIdentifier: String? = "b952f0054915a32ec524ac0dde0244bcda246649debe149f9e32e303e21c8266" public var queryDocument: String { return operationDefinition.appending(CharacterName.fragmentDefinition) } public var episode: Episode? public init(episode: Episode? = nil) { self.episode = episode } public var variables: GraphQLMap? { return ["episode": episode] } public struct Data: GraphQLSelectionSet { public static let possibleTypes = ["Query"] public static let selections: [GraphQLSelection] = [ GraphQLField("hero", arguments: ["episode": GraphQLVariable("episode")], type: .object(Hero.selections)), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public init(hero: Hero? = nil) { self.init(unsafeResultMap: ["__typename": "Query", "hero": hero.flatMap { (value: Hero) -> ResultMap in value.resultMap }]) } public var hero: Hero? { get { return (resultMap["hero"] as? ResultMap).flatMap { Hero(unsafeResultMap: $0) } } set { resultMap.updateValue(newValue?.resultMap, forKey: "hero") } } public struct Hero: GraphQLSelectionSet { public static let possibleTypes = ["Human", "Droid"] public static let selections: [GraphQLSelection] = [ GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("name", type: .nonNull(.scalar(String.self))), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public static func makeHuman(name: String) -> Hero { return Hero(unsafeResultMap: ["__typename": "Human", "name": name]) } public static func makeDroid(name: String) -> Hero { return Hero(unsafeResultMap: ["__typename": "Droid", "name": name]) } public var __typename: String { get { return resultMap["__typename"]! as! String } set { resultMap.updateValue(newValue, forKey: "__typename") } } /// The name of the character public var name: String { get { return resultMap["name"]! as! String } set { resultMap.updateValue(newValue, forKey: "name") } } public var fragments: Fragments { get { return Fragments(unsafeResultMap: resultMap) } set { resultMap += newValue.resultMap } } public struct Fragments { public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public var characterName: CharacterName { get { return CharacterName(unsafeResultMap: resultMap) } set { resultMap += newValue.resultMap } } } } } } ``` **After** Fragment: ```swift public protocol CharacterNameMk2: GraphQLFragmentMk2 { var __typename: CharacterType { get } var name: String { get } } public extension CharacterNameMk2 { static var fragmentDefinition: String { return """ fragment CharacterName on Character { __typename name } """ } } ``` Query: ```swift public final class HeroNameWithFragmentQueryMk2: GraphQLQueryMk2, Codable { public let operationDefinition = """ query HeroNameWithFragment($episode: Episode) { hero(episode: $episode) { __typename ...CharacterName } } """ public let operationName = "HeroNameWithFragment" public var queryDocument: String { return operationDefinition.appending(ResponseData.Hero.fragmentDefinition) } public var episode: GraphQLOptional public init(episode: GraphQLOptional) { self.episode = episode } public enum CodingKeys: String, CodingKey { case episode } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeGraphQLOptional(self.episode, forKey: .episode) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.episode = try container.decodeGraphQLOptional(forKey: .episode) } public struct ResponseData: Codable, Equatable, Hashable { public let hero: Hero public init(hero: Hero) { self.hero = hero } public struct Hero: Codable, Equatable, Hashable, CharacterNameMk2 { public let __typename: CharacterType public let name: String public init(__typename: CharacterType, name: String) { self.__typename = __typename self.name = name } } } } ```
Query with Dependent types **Before** ```swift public final class HeroDetailsQuery: GraphQLQuery { /// The raw GraphQL definition of this operation. public let operationDefinition = """ query HeroDetails($episode: Episode) { hero(episode: $episode) { __typename name ... on Human { height } ... on Droid { primaryFunction } } } """ public let operationName = "HeroDetails" public let operationIdentifier: String? = "2b67111fd3a1c6b2ac7d1ef7764e5cefa41d3f4218e1d60cb67c22feafbd43ec" public var episode: Episode? public init(episode: Episode? = nil) { self.episode = episode } public var variables: GraphQLMap? { return ["episode": episode] } public struct Data: GraphQLSelectionSet { public static let possibleTypes = ["Query"] public static let selections: [GraphQLSelection] = [ GraphQLField("hero", arguments: ["episode": GraphQLVariable("episode")], type: .object(Hero.selections)), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public init(hero: Hero? = nil) { self.init(unsafeResultMap: ["__typename": "Query", "hero": hero.flatMap { (value: Hero) -> ResultMap in value.resultMap }]) } public var hero: Hero? { get { return (resultMap["hero"] as? ResultMap).flatMap { Hero(unsafeResultMap: $0) } } set { resultMap.updateValue(newValue?.resultMap, forKey: "hero") } } public struct Hero: GraphQLSelectionSet { public static let possibleTypes = ["Human", "Droid"] public static let selections: [GraphQLSelection] = [ GraphQLTypeCase( variants: ["Human": AsHuman.selections, "Droid": AsDroid.selections], default: [ GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("name", type: .nonNull(.scalar(String.self))), ] ) ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public static func makeHuman(name: String, height: Double? = nil) -> Hero { return Hero(unsafeResultMap: ["__typename": "Human", "name": name, "height": height]) } public static func makeDroid(name: String, primaryFunction: String? = nil) -> Hero { return Hero(unsafeResultMap: ["__typename": "Droid", "name": name, "primaryFunction": primaryFunction]) } public var __typename: String { get { return resultMap["__typename"]! as! String } set { resultMap.updateValue(newValue, forKey: "__typename") } } /// The name of the character public var name: String { get { return resultMap["name"]! as! String } set { resultMap.updateValue(newValue, forKey: "name") } } public var asHuman: AsHuman? { get { if !AsHuman.possibleTypes.contains(__typename) { return nil } return AsHuman(unsafeResultMap: resultMap) } set { guard let newValue = newValue else { return } resultMap = newValue.resultMap } } public struct AsHuman: GraphQLSelectionSet { public static let possibleTypes = ["Human"] public static let selections: [GraphQLSelection] = [ GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("name", type: .nonNull(.scalar(String.self))), GraphQLField("height", type: .scalar(Double.self)), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public init(name: String, height: Double? = nil) { self.init(unsafeResultMap: ["__typename": "Human", "name": name, "height": height]) } public var __typename: String { get { return resultMap["__typename"]! as! String } set { resultMap.updateValue(newValue, forKey: "__typename") } } /// What this human calls themselves public var name: String { get { return resultMap["name"]! as! String } set { resultMap.updateValue(newValue, forKey: "name") } } /// Height in the preferred unit, default is meters public var height: Double? { get { return resultMap["height"] as? Double } set { resultMap.updateValue(newValue, forKey: "height") } } } public var asDroid: AsDroid? { get { if !AsDroid.possibleTypes.contains(__typename) { return nil } return AsDroid(unsafeResultMap: resultMap) } set { guard let newValue = newValue else { return } resultMap = newValue.resultMap } } public struct AsDroid: GraphQLSelectionSet { public static let possibleTypes = ["Droid"] public static let selections: [GraphQLSelection] = [ GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("name", type: .nonNull(.scalar(String.self))), GraphQLField("primaryFunction", type: .scalar(String.self)), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public init(name: String, primaryFunction: String? = nil) { self.init(unsafeResultMap: ["__typename": "Droid", "name": name, "primaryFunction": primaryFunction]) } public var __typename: String { get { return resultMap["__typename"]! as! String } set { resultMap.updateValue(newValue, forKey: "__typename") } } /// What others call this droid public var name: String { get { return resultMap["name"]! as! String } set { resultMap.updateValue(newValue, forKey: "name") } } /// This droid's primary function public var primaryFunction: String? { get { return resultMap["primaryFunction"] as? String } set { resultMap.updateValue(newValue, forKey: "primaryFunction") } } } } } } ``` **After** Union type enum: ```swift public enum CharacterType: RawRepresentable, Codable, CaseIterable, Equatable, Hashable { public typealias RawValue = String case Human case Droid case __unknown(String) public static var allCases: [CharacterType] { return [ .Human, .Droid ] } public var rawValue: String { switch self { case .Human: return "Human" case .Droid: return "Droid" case .__unknown(let value): return value } } public init(rawValue: String) { switch rawValue { case "Human": self = .Human case "Droid": self = .Droid default: self = .__unknown(rawValue) } } } ``` Query: ```swift public final class HeroDetailsQueryMk2: GraphQLQueryMk2, Codable { /// The raw GraphQL definition of this operation. public let operationDefinition = """ query HeroDetails($episode: Episode) { hero(episode: $episode) { __typename name ... on Human { height } ... on Droid { primaryFunction } } } """ public let operationName = "HeroDetails" public var episode: GraphQLOptional public init(episode: GraphQLOptional) { self.episode = episode } public enum CodingKeys: String, CodingKey { case episode } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeGraphQLOptional(self.episode, forKey: .episode) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.episode = try container.decodeGraphQLOptional(forKey: .episode) } public struct ResponseData: Codable, Equatable, Hashable { public let hero: Hero public init(hero: Hero) { self.hero = hero } public struct Hero: Codable, Equatable, Hashable { public let __typename: CharacterType public let name: String public let height: Double? public let primaryFunction: String? public init(__typename: CharacterType, name: String, height: Double?, primaryFunction: String?) { self.__typename = __typename self.name = name self.height = height self.primaryFunction = primaryFunction } public var asDroid: DroidHero? { guard self.__typename == .Droid else { return nil } return DroidHero(name: self.name, primaryFunction: self.primaryFunction!) } public var asHuman: HumanHero? { guard self.__typename == .Human else { return nil } return HumanHero(name: self.name, height: self.height!) } public struct DroidHero: Equatable, Hashable { public let name: String public let primaryFunction: String public init(name: String, primaryFunction: String) { self.name = name self.primaryFunction = primaryFunction } } public struct HumanHero: Equatable, Hashable { public let name: String public let height: Double public init(name: String, height: Double) { self.name = name self.height = height } } } } } ```
Basic mutation **Before** Input Object: ```swift /// The input object sent when someone is creating a new review public struct ReviewInput: GraphQLMapConvertible { public var graphQLMap: GraphQLMap public init(stars: Int, commentary: Swift.Optional = nil, favoriteColor: Swift.Optional = nil) { graphQLMap = ["stars": stars, "commentary": commentary, "favorite_color": favoriteColor] } /// 0-5 stars public var stars: Int { get { return graphQLMap["stars"] as! Int } set { graphQLMap.updateValue(newValue, forKey: "stars") } } /// Comment about the movie, optional public var commentary: Swift.Optional { get { return graphQLMap["commentary"] as? Swift.Optional ?? Swift.Optional.none } set { graphQLMap.updateValue(newValue, forKey: "commentary") } } /// Favorite color, optional public var favoriteColor: Swift.Optional { get { return graphQLMap["favorite_color"] as? Swift.Optional ?? Swift.Optional.none } set { graphQLMap.updateValue(newValue, forKey: "favorite_color") } } } ``` Mutation: ```swift public final class CreateReviewForEpisodeMutation: GraphQLMutation { /// The raw GraphQL definition of this operation. public let operationDefinition = """ mutation CreateReviewForEpisode($episode: Episode!, $review: ReviewInput!) { createReview(episode: $episode, review: $review) { __typename stars commentary } } """ public let operationName = "CreateReviewForEpisode" public let operationIdentifier: String? = "9bbf5b4074d0635fb19d17c621b7b04ebfb1920d468a94266819e149841e7d5d" public var episode: Episode public var review: ReviewInput public init(episode: Episode, review: ReviewInput) { self.episode = episode self.review = review } public var variables: GraphQLMap? { return ["episode": episode, "review": review] } public struct Data: GraphQLSelectionSet { public static let possibleTypes = ["Mutation"] public static let selections: [GraphQLSelection] = [ GraphQLField("createReview", arguments: ["episode": GraphQLVariable("episode"), "review": GraphQLVariable("review")], type: .object(CreateReview.selections)), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public init(createReview: CreateReview? = nil) { self.init(unsafeResultMap: ["__typename": "Mutation", "createReview": createReview.flatMap { (value: CreateReview) -> ResultMap in value.resultMap }]) } public var createReview: CreateReview? { get { return (resultMap["createReview"] as? ResultMap).flatMap { CreateReview(unsafeResultMap: $0) } } set { resultMap.updateValue(newValue?.resultMap, forKey: "createReview") } } public struct CreateReview: GraphQLSelectionSet { public static let possibleTypes = ["Review"] public static let selections: [GraphQLSelection] = [ GraphQLField("__typename", type: .nonNull(.scalar(String.self))), GraphQLField("stars", type: .nonNull(.scalar(Int.self))), GraphQLField("commentary", type: .scalar(String.self)), ] public private(set) var resultMap: ResultMap public init(unsafeResultMap: ResultMap) { self.resultMap = unsafeResultMap } public init(stars: Int, commentary: String? = nil) { self.init(unsafeResultMap: ["__typename": "Review", "stars": stars, "commentary": commentary]) } public var __typename: String { get { return resultMap["__typename"]! as! String } set { resultMap.updateValue(newValue, forKey: "__typename") } } /// The number of stars this review gave, 1-5 public var stars: Int { get { return resultMap["stars"]! as! Int } set { resultMap.updateValue(newValue, forKey: "stars") } } /// Comment about the movie public var commentary: String? { get { return resultMap["commentary"] as? String } set { resultMap.updateValue(newValue, forKey: "commentary") } } } } } ``` **After** Input object: ```swift /// The input object sent when someone is creating a new review public struct ReviewInputMk2 { /// 0-5 stars public let stars: Int /// Comment about the movie, optional public let commentary: GraphQLOptional /// Favorite color, optional public let favoriteColor: GraphQLOptional public enum CodingKeys: String, CodingKey { case stars case commentary case favoriteColor } public init(stars: Int, commentary: GraphQLOptional, favoriteColor: GraphQLOptional) { self.stars = stars self.commentary = commentary self.favoriteColor = favoriteColor } } extension ReviewInputMk2: Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: ReviewInputMk2.CodingKeys.self) try container.encode(self.stars, forKey: .stars) try container.encodeGraphQLOptional(self.commentary, forKey: .commentary) try container.encodeGraphQLOptional(self.favoriteColor, forKey: .favoriteColor) } } extension ReviewInputMk2: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: ReviewInputMk2.CodingKeys.self) self.stars = try container.decode(Int.self, forKey: .stars) self.commentary = try container.decodeGraphQLOptional(forKey: .commentary) self.favoriteColor = try container.decodeGraphQLOptional(forKey: .favoriteColor) } } ``` Mutation: ```swift public final class CreateAwesomeReviewMutationMk2: GraphQLMutationMk2 { /// The raw GraphQL definition of this operation. public let operationDefinition = """ mutation CreateReviewForEpisode($episode: Episode!, $review: ReviewInput!) { createReview(episode: $episode, review: $review) { __typename stars commentary } } """ public let operationName = "CreateAwesomeReview" public var variableData: Data? { return nil } public init() { } public struct ResponseData: Codable, Equatable, Hashable { public let createReview: CreateReview public init(createReview: CreateReview) { self.createReview = createReview } public struct CreateReview: Codable, Equatable, Hashable { public let __typename: String public let stars: Int public let commentary: String? public init(__typename: String, stars: Int, commentary: String?) { self.__typename = __typename self.stars = stars self.commentary = commentary } } } } ```

Adding a handler for parsing code from new codegen

Right now all our parsing code is heavily tied to our current caching mechanism. This first phase will not include any caching, and thus will need to have somewhat different code for handling parsing the code.

This will be made available through an option on the CodegenOptions added in phase 0, which will default to false.

Updating documentation

Documentation for the new code generation will likely need to live side-by-side with the documentation for the old code generation until the old codegen is deprecated.

Clear documentation for each of the two options will be critical.

Phase 2: Add Immutable caching

For this phase, a caching layer which cannot be mutated directly by developers and which is compatible with the new system will be added.

This has the benefit of giving access to basic caching quicker than would be possible if we tried to add mutable caching at the same time.

This caching layer will need to be compatible with both In-Memory and SQLite backing stores.

For In-Memory stores, migration is not necessary since the store is destroyed every time your application is terminated.

Right now there is not a plan to include any official migration from older versions of the cache for SQLite-based caching.

The architecture will be sufficiently different from the existing architecture that, in my opinion, trying to write a general enough migrator to move everything over would be a disproportionate time sink, and ultimately not worth it. I would greatly appreciate alternative opinions on this point in the comments.

TBD is whether read-only direct cache access will be supported at this point, or punted to the addition of mutable caching.

The CodegenOptions to use the new code generation will remain defaulted to false at this phase, although this should be considerably more usable for the majority of users.

Phase 3: Add Mutable caching

This part is still somewhat ill-defined, and I may send out a separate RFC on this part before I start adding the immutable caching, but I wanted to outline some of the issues at play.

Since our current caching system relies backing dictionaries generated by our current codegen, this presents a challenge when attempting to update a cached value:

This will all probably take some figuring out, but looking forward to digging in to this.

The CodegenOptions to use the new code generation will default to true after this phase, and old code generation will be formally deprecated.

Phase 4: Removal of old codegen

Once phase 3 has been shipped and been determined to be reasonably stable, support for code from the old codegen will be removed.

And I will do a happy dance. 🙃

Request for comment

Please add your comments below. Anything I change in response to comments, I'll try to add footnotes to in order to indicate where the change came from.

Thank you for reading all this!

danl3v commented 4 years ago

This is wonderful news! Thanks for all the great updates and thoughtfulness you have put into the sdk over the last few months

designatednerd commented 4 years ago

Thanks! Now I just gotta put my money where my mouth is

oh_no

kevinmbeaulieu commented 4 years ago

Thanks for putting this together! A few comments/questions:

  1. For handling union types, I noticed in your sample generated code that you have the fields which are specific to a given specialization (e.g., DroidHero) as optionals on the generic version (e.g., public let height: Double? on Hero). Won't this break if, for instance DroidHero had a height: Double field defined, but HumanHero had a height: String field? Or do you have a way around that planned?

  2. Also, it looks like as you have it, union types still use the as<Specific Type> pattern. Have you considered adding associated values to the cases in the __typename enum so we'd be able to use switch-case let? Something along the lines of:

    
    enum CharacterType {
    case Human(HumanHero)
    case Droid(DroidHero)
    case __unknown(String)
    }

switch myHero.typename { case let .Droid(droidHero): // handle droid case let .Human(humanHero): // handle human case let .unknown(unrecognizedTypeName): // handle unrecognized character type }



3. Saw a `GraphQLOptional` type floating around in the sample code- is that some special type that's distinct from `Swift.Optional`? (And perhaps related: what are the `encodeGraphQLOptional`/`decodeGraphQLOptional` functions meant to be doing, as distinct from the built-in encode/decode functions for swift optionals?)

Those questions aside though, overall this is looking great to me!
designatednerd commented 4 years ago
  1. For handling union types, I noticed in your sample generated code that you have the fields which are specific to a given specialization (e.g., DroidHero) as optionals on the generic version (e.g., public let height: Double? on Hero). Won't this break if, for instance DroidHero had a height: Double field defined, but HumanHero had a height: String field? Or do you have a way around that planned?

Yes, but I believe this will also break under the current setup. You should be able to avoid this by aliasing one of the fields in the request, which will change the name of the property on the Swift side.

.Have you considered adding associated values to the cases in the __typename enum so we'd be able to use switch-case let?

Unfortunately that won't work super-well with this setup, since those associated types will be different per query, and I want that union type to work with all queries.

Otherwise we'd have to regenerate the union type for everything, and that would be a ton of extra code compared to the as blah version, especially for APIs that have huge union types like Github pull request timeline items.

Saw a GraphQLOptional type floating around in the sample code- is that some special type that's distinct from Swift.Optional?

Yes, that's the replacement for double-optionals in input types to make it considerably more explicit what's being sent. There's been a ton of confusion on how to send a null value vs. not sending anything. Here's the code for those (including the extensions on encoding and decoding containers):

public enum GraphQLOptional<T> {
  case notPresent
  case nullValue
  case value(T)
}

extension GraphQLOptional: Equatable where T: Equatable {
  public static func ==(lhs: GraphQLOptional, rhs: GraphQLOptional) -> Bool {
    switch (lhs, rhs) {
    case (.notPresent, .notPresent),
             (.nullValue, .nullValue):
      return true
    case (.value(let lhsValue), .value(let rhsValue)):
      return lhsValue == rhsValue
    default:
      return false
    }
  }
}

public extension KeyedEncodingContainer {

  mutating func encodeGraphQLOptional<T: Codable>(_ optional: GraphQLOptional<T>, forKey key: K) throws {
    switch optional {
    case .notPresent:
      break
    case .nullValue:
      try self.encodeNil(forKey: key)
    case .value(let value):
      try self.encode(value, forKey: key)
    }
  }
}

public extension KeyedDecodingContainer {

  func decodeGraphQLOptional<T: Codable>(forKey key: K) throws -> GraphQLOptional<T> {
    if self.contains(key) {
      if let value = try? self.decode(T.self, forKey: key) {
        return .value(value)
      } else {
        return .nullValue
      }
    } else {
      return .notPresent
    }
  }
}
designatednerd commented 4 years ago

Gonna leave this open for further comment over the weekend and then add some of this stuff to the ROADMAP.md document (probably not in this much detail) and close this issue out.

Hustenbonbon commented 4 years ago

I stumbled upon this while trying out appolo with swiftui and getting problems parsing objects in lists if they are not implementing Identifiable even if they have an id field. Great that this is already in progress of being fixed as part of the rewrite :-)

designatednerd commented 4 years ago

That one's also a choice that's going to be opt-in: Some users don't want the id field to be the unique identifier, since there's also potentially things like a pre-existing GUID or an ISBN that people may need to use for unique identifiers.

gsabran commented 4 years ago

A few thoughts from having worked with a modified version of Apollo codegen:

1/ Codable I love the move to encoder / decoder. Do you think this would let people use other format than json?

2/ Nested protocols Maybe you could lay out how protocols work with nested objects selection in the fragment. I had to generate some ugly boilerplate there to make swift happy as I didn't want to have associated types. For instance what would this convert to?

fragment Frag on Character {
   friends {
     name
   }
 }

3/ Opt in features With a large codebase, it's valuable to have Equatable, Hashable, Identifiable etc be opt in (or have a configurable default that developers can intentionally diverge from). The reason is that those few words have a large impact on binary size and code scales better with conservative defaults. To do so, I've used GraphQL directives that are directed to the codegen. This allow developers to easily control the codegen on a per query basis. I also made response types immutable by default.

4/ Lean structs Also related to app size, complex structs unfortunately have a large impact, quite larger than classes (4MB for me, which was a blocker). The current codegen uses lean structs as their only stored attribute is a dictionary. I moved to a struct + class design inspired by https://developer.apple.com/videos/play/wwdc2016/416/?time=2361 that is similar to the current solution with stronger type safety. This does add a bunch of code you don't really want to be reading though...

5/ Protocol initializers For testing, it would be nice to have a stub implementation of each protocol that one can instantiate without depending on a specific type.

6/ File modularization It would be great to let folks write .graphql files anywhere in their codebase and generate .swift files alongside them. This helps with modularization. Also because queries can then be more easily internal and not public this might reduce app size through more optimized compilation. I'm not sure how you'd make the workspace aware of the new files though.

7/ Abstract types For the abstract/union type representation, you also could consider using an enum type which doesn't expose the non common properties:

public enum Hero: Codable, Equatable, Hashable {
  case droid(let droid: DroidHero)
  case human(let human: HumanHero)
  public var __typename: CharacterType {
    switch self {
      case .droid(let droid):
        return droid.__typename
      case .human(let human):
        return human.__typename
    }
  }
  public var name: CharacterType {
    switch self {
      case .droid(let droid):
        return droid.name
      case .human(let human):
        return human.name
    }
  }
}

8/ The enum representation will be great!

9/ I think killing double optional is great as well.

10/ It's not clear to me what the need for a new caching solution is. I would love to understand this part better! 🤔

designatednerd commented 4 years ago

Apologies for taking a couple days to get back to you @gsabran, I'm a bit under the weather. Trying to reply point by point to your questions:

1/ ... Do you think this would let people use other format than json?

Ideally you should be able to pass in encoder/decoder so that's supported. I'm not sure if this will be part of the very first version but given the way Codable works it shouldn't be too difficult to work with longer term.

2/ Nested protocols Maybe you could lay out how protocols work with nested objects selection in the fragment. I had to generate some ugly boilerplate there to make swift happy as I didn't want to have associated types...

This is set up as associated types at the moment:

Fragment ```swift public protocol FriendsNamesCharacter { var __typename: CharacterType { get } /// The name of the character var name : String { get } } public protocol FriendsNamesMk2: GraphQLFragmentMk2 { associatedtype Character: FriendsNamesCharacter var friends: [Character] { get } } extension FriendsNamesMk2 { public static var fragmentDefinition: String { return "fragment FriendsNames on Character {\n __typename\n friends {\n __typename\n name\n }\n}" } public var humanFriends: [Character] { return self.friends.filter { $0.__typename == .Human } } public var droidFriends: [Character] { return self.friends.filter { $0.__typename == .Droid } } } ```
Class ```swift public final class HeroAndFriendsNamesWithFragmentQueryMk2: GraphQLQueryMk2, Codable { public let operationDefinition = "query HeroAndFriendsNamesWithFragment($episode: Episode) {\n hero(episode: $episode) {\n __typename\n name\n ...FriendsNames\n }\n}" public let operationName = "HeroAndFriendsNamesWithFragment" public var queryDocument: String { return operationDefinition.appending(ResponseData.Hero.fragmentDefinition) } public var episode: GraphQLOptional public init(episode: GraphQLOptional) { self.episode = episode } public enum CodingKeys: String, CodingKey { case episode } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: HeroAndFriendsNamesWithFragmentQueryMk2.CodingKeys.self) try container.encodeGraphQLOptional(self.episode, forKey: .episode) } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: HeroAndFriendsNamesWithFragmentQueryMk2.CodingKeys.self) self.episode = try container.decodeGraphQLOptional(forKey: .episode) } public struct ResponseData: Codable, Equatable, Hashable { public let hero: Hero public init(hero: Hero) { self.hero = hero } public struct Hero: Codable, Equatable, Hashable, FriendsNamesMk2 { public typealias Character = Friend public let __typename: CharacterType public let name: String public let friends: [Friend] public init(__typename: CharacterType, name: String, friends: [Friend]) { self.__typename = __typename self.name = name self.friends = friends } public struct Friend: Codable, Equatable, Hashable, FriendsNamesCharacter { public let __typename: CharacterType public let name: String public init(__typename: CharacterType, name: String) { self.__typename = __typename self.name = name } } } } } ```

Can you point to specific problems you've had with associated types? I know they can be a little finicky but they are way better than a number of alternatives I explored.

3/ Opt-in...

I will probably make these opt-out rather than opt-in. Most people are not working with super-huge codebases, and the bloat would be worth the simplicity. That said, you're right that it does add some bloat if you're in

4/ ....I moved to a struct + class design inspired by https://developer.apple.com/videos/play/wwdc2016/416/?time=2361...

I'll have to watch this video at a point where my brain is working properly, but thank you for the link!

5/ Protocol initializers For testing, it would be nice to have a stub implementation of each protocol that one can instantiate without depending on a specific type.

This sounds like it'd cause some code bloat issues similar to what you were concerned about in point 3, though. What's an example scenario where a base implementation would give you a better testbed than a specific implementation that already exists?

6/ File modularization It would be great to let folks write .graphql files anywhere in their codebase and generate .swift files alongside them.

This sounds great but unfortunately I think this would require very significant decoupling from our typescript code generation. I'll keep this in mind as an idea as I work through this, but I gotta be honest, it's highly likely this is not going to be possible without massive, massive changes well beyond the scope of this project.

7/ ...consider using an enum type which doesn't expose the non common properties:...

I'll take a look at this suggestion in the context of what I've already got going. Thanks!

10/ It's not clear to me what the need for a new caching solution is. I would love to understand this part better! 🤔

The current caching solution is completely tied to everything being backed by untyped dictionaries. Moving to strongly typed objects and properties essentially breaks the hell out of that.

gsabran commented 4 years ago

Thanks!

2/ Can you point to specific problems you've had with associated types?

I think mainly when you are working with a collection of elements of varying concrete type, in which case you have to do type erasure. Also you can't cast to an associated type.

5/ This sounds like it'd cause some code bloat issues

Yes, you'll definitively want to wrap this in #if DEBUG ... #endif

What's an example scenario where a base implementation would give you a better testbed than a specific implementation that already exists?

You could imagine that you have a small fragment that only appears in a large selection. Testing a function that uses only the output of the fragment will require some cumbersome initialization. I agree it is not as important as the other things.

designatednerd commented 4 years ago

Testing a function that uses only the output of the fragment will require some cumbersome initialization.

Agreed, but can't you just make a dummy struct in your tests that conforms to that protocol?

gsabran commented 4 years ago

For sure, it would be less work for the developer to get this for free from the codegen :)

designatednerd commented 4 years ago

it would be less work for the developer

* - developers who are not me 😆

Agreed that this is a cool idea but I think it probably won't go into the first round of changes just because I want to try and keep the scope limited where I can in hopes of shipping this, ever.

gsabran commented 4 years ago

Totally agreed for the first / second rounds. Just saying!

lozhuf commented 4 years ago

Great work - looking forward to seeing this land! I came here with the express purpose of writing a feature request for the ability to split the generated swift file into multiple files, but I see that @gsabran mentioned it above in 6/ ... it's a shame that it sounds like something that's going to be hard to implement, because it's becoming quite an annoyance for us in using Apollo.

Using the current version of Apollo codegen, our (prisma-generated) schema results in a massively unwieldy 60k line swift file... Cthulhu-save any unwitting dev that opens that file or Ctrl-Cmd-Clicks on a generated type to jump to the definition... there is a good 30-60sec of block main-queue in Xcode before the file opens, every time :/

So if there is anyway this can be given some attention it'd be very much appreciated 😄

designatednerd commented 4 years ago

@lozhuf The issue is with putting multiple files the same place as the query files - there's a bunch of info I'm going to lose on the folder structure as stuff gets output to JSON that makes that harder.

If you want to output multiple files into one directory instead of doing one giant file you can already do that with current codegen, and I'm planning to make that considerably more obvious with the swift scripting codegen.

lozhuf commented 4 years ago

Ahh, THANKYOU! 👏 turns out, the correct answer, as always, is RTFM 😉

designatednerd commented 4 years ago

Thank you all for your comments, I've now updated the ROADMAP document with a high-level summary of the issues here. I'll be using the Swift Codegen Project to beef up a list of what needs to be done on everything, so please feel free to follow that project for further updates.