From 6f10a81d5aa0234bb00c7bbda18314a0dac8dbbe Mon Sep 17 00:00:00 2001 From: Tim Kendall Date: Thu, 17 Dec 2020 16:09:18 -0800 Subject: [PATCH 1/2] Start copying over new Result type --- src/Client.ts | 13 +++++++++---- src/Operation.ts | 44 +++++++++++++++++++++++--------------------- src/example.ts | 35 +++++++++++++++++++++++++++-------- 3 files changed, 59 insertions(+), 33 deletions(-) diff --git a/src/Client.ts b/src/Client.ts index 55fe1f1..e753b29 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -7,11 +7,14 @@ export class Client { // @todo } -export const execute = >>( +export const execute = < + RootType, + SelectionSet extends Array> +>( endpoint: string, - operation: Operation + operation: Operation /* @todo variables?: Variables */ -): Promise>> => +): Promise>> => fetch(endpoint, { method: "post", headers: { "Content-Type": "application/json" }, @@ -20,7 +23,9 @@ export const execute = >>( // variables, query: operation.toString(), }), - }).then((res) => res.json()) as Promise>>; + }).then((res) => res.json()) as Promise< + ExecutionResult> + >; // @todo we lose out type-saftey going through this (need to fix the `Selector` type param) // diff --git a/src/Operation.ts b/src/Operation.ts index 6a9e753..7100af2 100644 --- a/src/Operation.ts +++ b/src/Operation.ts @@ -24,21 +24,27 @@ import { nonNullTypeOf, } from "./AST"; -export type Result>> = { - [Key in Selection[number]["name"]]: Selection[number] extends infer U - ? U extends Field - ? ValueOrSelection extends Primitive - ? Type // derive nullable and array scalars here - : ValueOrSelection extends Array> - ? Type extends Array - ? Array> | null - : Result // Derive nullable and array objects here - : never - : never - : never; -}; - -export class Operation>> { +export type Result< + Type, // @note Has to be object or array! (right?) + SelectionSet extends Array> +> = Type extends Array + ? T extends Primitive + ? // @note Return scalar array + Array + : // @note Wrap complex object in array + Array> + : { + // @note Build out complex object + [Key in SelectionSet[number]["name"]]: Type[Key] extends Primitive + ? Type[Key] + : SelectionSet[number] extends infer U + ? U extends Field + ? Result + : never + : never; + }; + +export class Operation>> { constructor( public readonly name: string, // @todo support `mutation` and `subscription` operations @@ -83,16 +89,12 @@ export class SelectionSet>> { export class Field< Name extends string, Arguments extends Argument[] | never = never, - SelectionSetOrValue extends - | Primitive - | Field[] - | undefined = undefined, - Type = unknown + SelectionSet extends Field[] | never = never > { constructor( public readonly name: Name, public readonly args?: Arguments, - public readonly selectionSet?: SelectionSetOrValue + public readonly selectionSet?: SelectionSet ) {} get ast(): FieldNode { diff --git a/src/example.ts b/src/example.ts index de243b4..e2ce5a9 100644 --- a/src/example.ts +++ b/src/example.ts @@ -6,18 +6,34 @@ import { SelectionSet, Variable, } from "./Operation"; -import { Selector, makeBuildQuery, execute } from "./Client"; +import { execute } from "./Client"; // @note Hardcoded `Selector` objects matching a schema // @todo Support dynamic creation with ex. `Selector`? // @todo Generate all of these from a static GraphQL schema! -const buildQuery = >>( +const buildQuery = >>( name: string, select: (t: typeof Query) => T ): Operation => new Operation(name, "query", new SelectionSet(select(Query))); +interface IQuery { + viewer: IUser; + accounts: IAccount[]; +} + +interface IUser { + id: string; + age: number; + account: IAccount; +} + +interface IAccount { + id: string; + balance: number | null; +} + const Query = { // @Restrict `Field`'s to what exists on the `User` type! viewer: >>( @@ -28,7 +44,7 @@ const Query = { variables: { id: Value }, select: (t: typeof Account) => T ) => - new Field<"accounts", [Argument<"id">], T, Array>( + new Field<"accounts", [Argument<"id">], T>( "accounts", [new Argument("id", variables.id)], select(Account) @@ -36,16 +52,16 @@ const Query = { }; const User = { - id: () => new Field<"id", [], string, string>("id"), - age: () => new Field<"age", [], number, number | null>("age"), + id: () => new Field<"id">("id"), + age: () => new Field<"age">("age"), account: >>( select: (t: typeof Account) => T ) => new Field("account", [], select(Account)), }; const Account = { - id: () => new Field<"id", [], string, string>("id"), - balance: () => new Field<"balance", [], number, number>("balance"), + id: () => new Field<"id">("id"), + balance: () => new Field<"balance">("balance"), }; // @end todo @@ -66,7 +82,10 @@ const Account = { console.log(query.toString()); - const { data, errors } = await execute("https://example.com", query); + const { data, errors } = await execute< + IQuery, + typeof query.selectionSet.selections + >("https://example.com", query); data?.viewer.id; data?.viewer.age; From b6b5b80a2c37a8ed34a37d7cf765a178b1666c5c Mon Sep 17 00:00:00 2001 From: Tim Kendall Date: Thu, 17 Dec 2020 16:34:25 -0800 Subject: [PATCH 2/2] Tweak codegen to match new Result and Field types --- __tests__/starwars.api.ts | 122 +++++++++++++++++++++++++++++-------- __tests__/starwars.test.ts | 1 + src/Codegen.ts | 37 +++++++++-- 3 files changed, 131 insertions(+), 29 deletions(-) diff --git a/__tests__/starwars.api.ts b/__tests__/starwars.api.ts index c9d5e1e..919cea3 100644 --- a/__tests__/starwars.api.ts +++ b/__tests__/starwars.api.ts @@ -33,6 +33,16 @@ export interface ColorInput { // "SearchResult" is a union type and not supported +export interface IQuery { + hero: ICharacter; + reviews: IReview[]; + search: any; + character: ICharacter; + droid: IDroid; + human: IHuman; + starship: IStarship; +} + export const Query = { hero: >>( variables: { episode?: Variable<"episode"> | Episode }, @@ -91,6 +101,10 @@ export const Query = { new Field("starship", [new Argument("id", variables.id)], select(Starship)), }; +export interface IMutation { + createReview: IReview; +} + export const Mutation = { createReview: >>( variables: { @@ -109,9 +123,17 @@ export const Mutation = { ), }; +export interface ICharacter { + id: string; + name: string; + friends: ICharacter[]; + friendsConnection: IFriendsConnection; + appearsIn: Episode[]; +} + export const Character = { - id: () => new Field<"id", [], string>("id"), - name: () => new Field<"name", [], string>("name"), + id: () => new Field<"id">("id"), + name: () => new Field<"name">("name"), friends: >>( select: (t: typeof Character) => T @@ -133,16 +155,28 @@ export const Character = { select(FriendsConnection) ), - appearsIn: () => new Field<"appearsIn", [], Episode>("appearsIn"), + appearsIn: () => new Field<"appearsIn">("appearsIn"), }; +export interface IHuman { + id: string; + name: string; + homePlanet: string; + height: number; + mass: number; + friends: ICharacter[]; + friendsConnection: IFriendsConnection; + appearsIn: Episode[]; + starships: IStarship[]; +} + export const Human = { - id: () => new Field<"id", [], string>("id"), - name: () => new Field<"name", [], string>("name"), - homePlanet: () => new Field<"homePlanet", [], string>("homePlanet"), + id: () => new Field<"id">("id"), + name: () => new Field<"name">("name"), + homePlanet: () => new Field<"homePlanet">("homePlanet"), height: (variables: { unit: unknown }) => - new Field<"height", [/* @todo */], number>("height"), - mass: () => new Field<"mass", [], number>("mass"), + new Field<"height", [/* @todo */]>("height"), + mass: () => new Field<"mass">("mass"), friends: >>( select: (t: typeof Character) => T @@ -164,16 +198,25 @@ export const Human = { select(FriendsConnection) ), - appearsIn: () => new Field<"appearsIn", [], Episode>("appearsIn"), + appearsIn: () => new Field<"appearsIn">("appearsIn"), starships: >>( select: (t: typeof Starship) => T ) => new Field("starships", [], select(Starship)), }; +export interface IDroid { + id: string; + name: string; + friends: ICharacter[]; + friendsConnection: IFriendsConnection; + appearsIn: Episode[]; + primaryFunction: string; +} + export const Droid = { - id: () => new Field<"id", [], string>("id"), - name: () => new Field<"name", [], string>("name"), + id: () => new Field<"id">("id"), + name: () => new Field<"name">("name"), friends: >>( select: (t: typeof Character) => T @@ -195,13 +238,19 @@ export const Droid = { select(FriendsConnection) ), - appearsIn: () => new Field<"appearsIn", [], Episode>("appearsIn"), - primaryFunction: () => - new Field<"primaryFunction", [], string>("primaryFunction"), + appearsIn: () => new Field<"appearsIn">("appearsIn"), + primaryFunction: () => new Field<"primaryFunction">("primaryFunction"), }; +export interface IFriendsConnection { + totalCount: number; + edges: IFriendsEdge[]; + friends: ICharacter[]; + pageInfo: IPageInfo; +} + export const FriendsConnection = { - totalCount: () => new Field<"totalCount", [], number>("totalCount"), + totalCount: () => new Field<"totalCount">("totalCount"), edges: >>( select: (t: typeof FriendsEdge) => T @@ -216,31 +265,54 @@ export const FriendsConnection = { ) => new Field("pageInfo", [], select(PageInfo)), }; +export interface IFriendsEdge { + cursor: string; + node: ICharacter; +} + export const FriendsEdge = { - cursor: () => new Field<"cursor", [], string>("cursor"), + cursor: () => new Field<"cursor">("cursor"), node: >>( select: (t: typeof Character) => T ) => new Field("node", [], select(Character)), }; +export interface IPageInfo { + startCursor: string; + endCursor: string; + hasNextPage: boolean; +} + export const PageInfo = { - startCursor: () => new Field<"startCursor", [], string>("startCursor"), - endCursor: () => new Field<"endCursor", [], string>("endCursor"), - hasNextPage: () => new Field<"hasNextPage", [], boolean>("hasNextPage"), + startCursor: () => new Field<"startCursor">("startCursor"), + endCursor: () => new Field<"endCursor">("endCursor"), + hasNextPage: () => new Field<"hasNextPage">("hasNextPage"), }; +export interface IReview { + stars: number; + commentary: string; +} + export const Review = { - stars: () => new Field<"stars", [], number>("stars"), - commentary: () => new Field<"commentary", [], string>("commentary"), + stars: () => new Field<"stars">("stars"), + commentary: () => new Field<"commentary">("commentary"), }; +export interface IStarship { + id: string; + name: string; + length: number; + coordinates: number[]; +} + export const Starship = { - id: () => new Field<"id", [], string>("id"), - name: () => new Field<"name", [], string>("name"), + id: () => new Field<"id">("id"), + name: () => new Field<"name">("name"), length: (variables: { unit: unknown }) => - new Field<"length", [/* @todo */], number>("length"), - coordinates: () => new Field<"coordinates", [], number>("coordinates"), + new Field<"length", [/* @todo */]>("length"), + coordinates: () => new Field<"coordinates">("coordinates"), }; export const query = >>( diff --git a/__tests__/starwars.test.ts b/__tests__/starwars.test.ts index 11e4e3f..8ffcdfb 100644 --- a/__tests__/starwars.test.ts +++ b/__tests__/starwars.test.ts @@ -22,6 +22,7 @@ describe("starwars schema", () => { // @fix TypeScript currently has issues deriving `Field` types when we have // two `Selector` objects with identical fields that reference each other. // + // ex. type ExampleQuery = Result // t.friends(t => [t.id(), t.name(), t.appearsIn()]), t.starships((t) => [t.id(), t.name()]), diff --git a/src/Codegen.ts b/src/Codegen.ts index 942db10..b562685 100644 --- a/src/Codegen.ts +++ b/src/Codegen.ts @@ -40,6 +40,28 @@ const toPrimitive = ( } }; +const renderInterfaceField = (field: GraphQLField): string => { + const isList = + field.type instanceof GraphQLList || + (field.type instanceof GraphQLNonNull && + field.type.ofType instanceof GraphQLList); + const isNonNull = field.type instanceof GraphQLNonNull; + const baseType = getBaseOutputType(field.type); + + if (baseType instanceof GraphQLScalarType) { + return `${field.name}: ${toPrimitive(baseType)}` + (isList ? "[]" : ""); + } else if (baseType instanceof GraphQLEnumType) { + return `${field.name}: ${baseType.name}` + (isList ? "[]" : ""); + } else if ( + baseType instanceof GraphQLInterfaceType || + baseType instanceof GraphQLObjectType + ) { + return `${field.name}: I${baseType.name}` + (isList ? "[]" : ""); + } else { + return `${field.name}: any`; + } +}; + export class Codegen { private readonly printer = ts.createPrinter(); private readonly source: ts.SourceFile; @@ -137,7 +159,12 @@ export class Codegen { private interfaceType(type: GraphQLInterfaceType): string { const fields = Object.values(type.getFields()); + // @note Render interface types and selector objects return ` + export interface I${type.name} { + ${fields.map(renderInterfaceField).join("\n")} + } + export const ${type.name} = { ${fields.map((field) => this.field(field)).join("\n")} } @@ -155,6 +182,10 @@ export class Codegen { const fields = Object.values(type.getFields()); return ` + export interface I${type.name} { + ${fields.map(renderInterfaceField).join("\n")} + } + export const ${type.name} = { ${fields.map((field) => this.field(field)).join("\n")} } @@ -211,10 +242,8 @@ export class Codegen { return args.length > 0 ? `${name}: (variables: { ${args .map((a) => `${a.name}: unknown`) - .join( - ", " - )} }) => new Field<"${name}", [/* @todo */], ${fieldType}>("${name}"),` - : `${name}: () => new Field<"${name}", [], ${fieldType}>("${name}"),`; + .join(", ")} }) => new Field<"${name}", [/* @todo */]>("${name}"),` + : `${name}: () => new Field<"${name}">("${name}"),`; } else { const renderArgument = (arg: GraphQLArgument): string => { const _base = getBaseInputType(arg.type);