From 7f426d94bc13d061c39e19310f6e5de48ea4e219 Mon Sep 17 00:00:00 2001 From: Ewen Le Bihan Date: Fri, 8 Mar 2024 14:00:06 +0100 Subject: [PATCH] Add JSDoc comments generation in $result types and enum declarations (#1277) --- .changeset/quick-experts-rest.md | 5 + e2e/_api/schema.graphql | 35 +- .../src/codegen/generators/comments/jsdoc.ts | 10 + .../generators/definitions/enums.test.ts | 15 + .../codegen/generators/definitions/enums.ts | 42 ++- .../typescript/documentTypes.test.ts | 319 ++++++++++++++++++ .../generators/typescript/inlineType.ts | 7 + .../generators/typescript/typescript.test.ts | 7 + packages/houdini/src/test/index.ts | 19 +- 9 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 .changeset/quick-experts-rest.md create mode 100644 packages/houdini/src/codegen/generators/comments/jsdoc.ts create mode 100644 packages/houdini/src/codegen/generators/typescript/documentTypes.test.ts diff --git a/.changeset/quick-experts-rest.md b/.changeset/quick-experts-rest.md new file mode 100644 index 0000000000..b91b45c982 --- /dev/null +++ b/.changeset/quick-experts-rest.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +GraphQL documentation strings and deprecation reasons are reflected as JSDoc comments on generated type definitions. Hover over any field of a query store, an enum, or an enum's value and your IDE should show you the documentation from the GraphQL API. diff --git a/e2e/_api/schema.graphql b/e2e/_api/schema.graphql index c0da52bd75..c23483deed 100644 --- a/e2e/_api/schema.graphql +++ b/e2e/_api/schema.graphql @@ -4,9 +4,14 @@ Date custom scalar type scalar DateTime scalar File +""" +Can be Value1 or Value2. +""" enum MyEnum { + "The first value" Value1 - Value2 + "The second value" + Value2 @deprecated(reason: "Use Value1 instead") } enum TypeOfUser { @@ -15,13 +20,17 @@ enum TypeOfUser { } enum ForceReturn { + "Normal" NORMAL + "No value" NULL + "Some error" ERROR } type Mutation { addUser( + """The users birth date""" birthDate: DateTime! name: String! snapshot: String! @@ -53,6 +62,9 @@ type Mutation { createB(b: String!): B! } +""" +A node. +""" interface Node { id: ID! } @@ -93,6 +105,9 @@ type Query { rentedBooks: [RentedBook!]! animals: AnimalConnection! monkeys: MonkeyConnection! + """ + Get a monkey by its id + """ monkey(id: ID!): Monkey } @@ -105,7 +120,13 @@ type User implements Node { friendsConnection(after: String, before: String, first: Int, last: Int): UserConnection! "This is the same list as what's used globally. its here to tests fragments" usersConnection(after: String, before: String, first: Int, last: Int): UserConnection! - usersConnectionSnapshot(after: String, before: String, first: Int, last: Int, snapshot: String!): UserConnection! + usersConnectionSnapshot( + after: String + before: String + first: Int + last: Int + snapshot: String! + ): UserConnection! "This is the same list as what's used globally. its here to tests fragments" userSearch(filter: UserNameFilter!, snapshot: String!): [User!]! friendsList(limit: Int, offset: Int): [User!]! @@ -121,10 +142,20 @@ interface Animal implements Node { name: String! } +""" +A monkey. +""" type Monkey implements Node & Animal { id: ID! name: String! + """ + Whether the monkey has a banana or not + """ hasBanana: Boolean! + """ + Whether the monkey has a banana or not + """ + oldHasBanana: Boolean @deprecated(reason: "Use hasBanana") } interface AnimalConnection { diff --git a/packages/houdini/src/codegen/generators/comments/jsdoc.ts b/packages/houdini/src/codegen/generators/comments/jsdoc.ts new file mode 100644 index 0000000000..1ddc431b8d --- /dev/null +++ b/packages/houdini/src/codegen/generators/comments/jsdoc.ts @@ -0,0 +1,10 @@ +import * as recast from 'recast' + +const AST = recast.types.builders +export function jsdocComment(text: string, deprecated?: string) { + let commentContent = `*\n * ${text}\n` + if (deprecated) { + commentContent = `${commentContent} * @deprecated ${deprecated}\n` + } + return AST.commentBlock(commentContent, true) +} diff --git a/packages/houdini/src/codegen/generators/definitions/enums.test.ts b/packages/houdini/src/codegen/generators/definitions/enums.test.ts index a02669b81d..883d8b16ae 100644 --- a/packages/houdini/src/codegen/generators/definitions/enums.test.ts +++ b/packages/houdini/src/codegen/generators/definitions/enums.test.ts @@ -24,18 +24,24 @@ test('generates runtime definitions for each enum', async function () { expect(parsedQuery).toMatchInlineSnapshot(` type ValuesOf = T[keyof T] + /** Documentation of testenum1 */ export declare const TestEnum1: { + /** Documentation of Value1 */ readonly Value1: "Value1"; + /** Documentation of Value2 */ readonly Value2: "Value2"; } + /** Documentation of testenum1 */ export type TestEnum1$options = ValuesOf + /** Documentation of testenum2 */ export declare const TestEnum2: { readonly Value3: "Value3"; readonly Value2: "Value2"; } + /** Documentation of testenum2 */ export type TestEnum2$options = ValuesOf `) @@ -48,11 +54,20 @@ test('generates runtime definitions for each enum', async function () { }).program expect(parsedQuery).toMatchInlineSnapshot(` + /** Documentation of testenum1 */ export const TestEnum1 = { + /** + * Documentation of Value1 + */ "Value1": "Value1", + + /** + * Documentation of Value2 + */ "Value2": "Value2" }; + /** Documentation of testenum2 */ export const TestEnum2 = { "Value3": "Value3", "Value2": "Value2" diff --git a/packages/houdini/src/codegen/generators/definitions/enums.ts b/packages/houdini/src/codegen/generators/definitions/enums.ts index 7b33877b7d..360a65da73 100644 --- a/packages/houdini/src/codegen/generators/definitions/enums.ts +++ b/packages/houdini/src/codegen/generators/definitions/enums.ts @@ -4,6 +4,7 @@ import * as recast from 'recast' import type { Config } from '../../../lib' import { fs, path, printJS } from '../../../lib' import { moduleExport } from '../../utils' +import { jsdocComment } from '../comments/jsdoc' const AST = recast.types.builders @@ -25,19 +26,39 @@ export default async function definitionsGenerator(config: Config) { enums.map((defn) => { const name = defn.name.value - return moduleExport( + const declaration = moduleExport( config, name, AST.objectExpression( defn.values?.map((value) => { const str = value.name.value - return AST.objectProperty( + const prop = AST.objectProperty( AST.stringLiteral(str), AST.stringLiteral(str) ) + const deprecationReason = ( + value.directives + ?.find((d) => d.name.value === 'deprecated') + ?.arguments?.find((a) => a.name.value === 'reason') + ?.value as graphql.StringValueNode + )?.value + + if (value.description || deprecationReason) + prop.comments = [ + jsdocComment(value.description?.value ?? '', deprecationReason), + ] + return prop }) || [] ) ) + + if (defn.description) { + declaration.comments = [ + AST.commentBlock(`* ${defn.description.value} `, true, false), + ] + } + + return declaration }) ) ) @@ -53,11 +74,22 @@ type ValuesOf = T[keyof T] const name = definition.name.value const values = definition.values - return ` + let jsdoc = '' + if (definition.description) { + jsdoc = `\n/** ${definition.description.value} */` + } + + return `${jsdoc} export declare const ${name}: { -${values?.map((value) => ` readonly ${value.name.value}: "${value.name.value}";`).join('\n')} +${values + ?.map( + (value) => + (value.description ? ` /** ${value.description.value} */\n` : '') + + ` readonly ${value.name.value}: "${value.name.value}";` + ) + .join('\n')} } - +${jsdoc} export type ${name}$options = ValuesOf ` }) diff --git a/packages/houdini/src/codegen/generators/typescript/documentTypes.test.ts b/packages/houdini/src/codegen/generators/typescript/documentTypes.test.ts new file mode 100644 index 0000000000..af82aa3f10 --- /dev/null +++ b/packages/houdini/src/codegen/generators/typescript/documentTypes.test.ts @@ -0,0 +1,319 @@ +import * as recast from 'recast' +import * as typescriptParser from 'recast/parsers/typescript' +import { expect, test } from 'vitest' + +import { runPipeline } from '../..' +import { fs } from '../../../lib' +import { mockCollectedDoc, testConfig } from '../../../test' + +const config = testConfig({ + schema: ` + """ Documentation of MyEnum """ + enum MyEnum { + """Documentation of Hello""" + Hello + } + + type Query { + """Get a user.""" + user(id: ID, filter: UserFilter, filterList: [UserFilter!], enumArg: MyEnum): User + users( + filter: UserFilter, + list: [UserFilter!]!, + id: ID! + firstName: String! + admin: Boolean + age: Int + weight: Float + ): [User] + nodes: [Node!]! + entities: [Entity] + entity: Entity! + listOfLists: [[User]]! + node(id: ID!): Node + } + + type Mutation { + doThing( + filter: UserFilter, + list: [UserFilter!]!, + id: ID! + firstName: String! + admin: Boolean + age: Int + weight: Float + ): User + } + + input UserFilter { + middle: NestedUserFilter + listRequired: [String!]! + nullList: [String] + recursive: UserFilter + enum: MyEnum + } + + input NestedUserFilter { + id: ID! + firstName: String! + admin: Boolean + age: Int + weight: Float + } + + interface Node { + id: ID! + } + + type Cat implements Node & Animal { + id: ID! + kitty: Boolean! + isAnimal: Boolean! + names: [String]! + } + + + interface Animal { + isAnimal: Boolean! + } + + union Entity = User | Cat + + union AnotherEntity = User | Ghost + + type Ghost implements Node { + id: ID! + aka: String! + name: String! + } + + """A user in the system""" + type User implements Node { + id: ID! + + """The user's first name""" + firstName(pattern: String): String! + """The user's first name""" + firstname: String! @deprecated(reason: "Use firstName instead") + nickname: String + parent: User + friends: [User] + """An enum value""" + enumValue: MyEnum + + admin: Boolean + age: Int + weight: Float + } + `, +}) + +test('generate document types', async function () { + const documents = [ + mockCollectedDoc(`query TestQuery { + user(id: "123") { + firstName, admin, ...otherInfo, firstname + } + }`), + mockCollectedDoc(`fragment otherInfo on User { + enumValue, age, firstname + }`), + ] + await runPipeline(config, documents) + + const queryArtifactContents = await fs.readFile(config.artifactTypePath(documents[0].document)) + + expect( + recast.parse(queryArtifactContents!, { + parser: typescriptParser, + }) + ).toMatchInlineSnapshot(` + export type TestQuery = { + readonly "input": TestQuery$input; + readonly "result": TestQuery$result | undefined; + }; + + export type TestQuery$result = { + /** + * Get a user. + */ + readonly user: { + /** + * The user's first name + */ + readonly firstName: string; + readonly admin: boolean | null; + /** + * The user's first name + * @deprecated Use firstName instead + */ + readonly firstname: string; + readonly " $fragments": { + otherInfo: {}; + }; + } | null; + }; + + export type TestQuery$input = null; + + export type TestQuery$artifact = { + "name": "TestQuery"; + "kind": "HoudiniQuery"; + "hash": "588d554d8d7839596a619c1bfeaae326f42eb416c17dfeb016bee8434fae6043"; + "raw": \`query TestQuery { + user(id: "123") { + firstName + admin + ...otherInfo + firstname + id + } + } + + fragment otherInfo on User { + enumValue + age + firstname + id + __typename + } + \`; + "rootType": "Query"; + "selection": { + "fields": { + "user": { + "type": "User"; + "keyRaw": "user(id: \\"123\\")"; + "nullable": true; + "selection": { + "fields": { + "enumValue": { + "type": "MyEnum"; + "keyRaw": "enumValue"; + "nullable": true; + }; + "age": { + "type": "Int"; + "keyRaw": "age"; + "nullable": true; + }; + "firstname": { + "type": "String"; + "keyRaw": "firstname"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + }; + "firstName": { + "type": "String"; + "keyRaw": "firstName"; + "visible": true; + }; + "admin": { + "type": "Boolean"; + "keyRaw": "admin"; + "nullable": true; + "visible": true; + }; + }; + "fragments": { + "otherInfo": { + "arguments": {}; + }; + }; + }; + "visible": true; + }; + }; + }; + "pluginData": {}; + "policy": "CacheOrNetwork"; + "partial": false; + }; + `) + + const fragmentArtifactContents = await fs.readFile( + config.artifactTypePath(documents[1].document) + ) + + expect(recast.parse(fragmentArtifactContents!, { parser: typescriptParser })) + .toMatchInlineSnapshot(` + import { MyEnum } from "$houdini/graphql/enums"; + import type { ValueOf } from "$houdini/runtime/lib/types"; + export type otherInfo$input = {}; + + export type otherInfo = { + readonly "shape"?: otherInfo$data; + readonly " $fragments": { + "otherInfo": any; + }; + }; + + export type otherInfo$data = { + /** + * An enum value + */ + readonly enumValue: ValueOf | null; + readonly age: number | null; + /** + * The user's first name + * @deprecated Use firstName instead + */ + readonly firstname: string; + }; + + export type otherInfo$artifact = { + "name": "otherInfo"; + "kind": "HoudiniFragment"; + "hash": "ea797186970659edb8c7a021812ce5652a9fb1d4ca5f6b9acde4e0aa734e0a3e"; + "raw": \`fragment otherInfo on User { + enumValue + age + firstname + id + __typename + } + \`; + "rootType": "User"; + "selection": { + "fields": { + "enumValue": { + "type": "MyEnum"; + "keyRaw": "enumValue"; + "nullable": true; + "visible": true; + }; + "age": { + "type": "Int"; + "keyRaw": "age"; + "nullable": true; + "visible": true; + }; + "firstname": { + "type": "String"; + "keyRaw": "firstname"; + "visible": true; + }; + "id": { + "type": "ID"; + "keyRaw": "id"; + "visible": true; + }; + "__typename": { + "type": "String"; + "keyRaw": "__typename"; + "visible": true; + }; + }; + }; + "pluginData": {}; + }; + `) +}) diff --git a/packages/houdini/src/codegen/generators/typescript/inlineType.ts b/packages/houdini/src/codegen/generators/typescript/inlineType.ts index 15276af0ed..9316e0c974 100644 --- a/packages/houdini/src/codegen/generators/typescript/inlineType.ts +++ b/packages/houdini/src/codegen/generators/typescript/inlineType.ts @@ -4,6 +4,7 @@ import * as recast from 'recast' import type { Config } from '../../../lib' import { ensureImports, HoudiniError, TypeWrapper, unwrapType } from '../../../lib' +import { jsdocComment } from '../comments/jsdoc' import { enumReference } from './typeReference' import { nullableField, readonlyProperty, scalarPropertyValue } from './types' @@ -267,6 +268,12 @@ export function inlineType({ prop.optional = true } + if (field.description || field.deprecationReason) { + prop.comments = [ + jsdocComment(field.description ?? '', field.deprecationReason ?? undefined), + ] + } + return prop }), ]) diff --git a/packages/houdini/src/codegen/generators/typescript/typescript.test.ts b/packages/houdini/src/codegen/generators/typescript/typescript.test.ts index 8b35638c92..36567a682a 100644 --- a/packages/houdini/src/codegen/generators/typescript/typescript.test.ts +++ b/packages/houdini/src/codegen/generators/typescript/typescript.test.ts @@ -1509,7 +1509,14 @@ describe('typescript', function () { }; export type MyQuery$result = { + /** + * Get a user. + */ readonly user: { + /** + * + * @deprecated Use name instead + */ readonly firstName: string; readonly " $fragments": { Foo: {}; diff --git a/packages/houdini/src/test/index.ts b/packages/houdini/src/test/index.ts index 33f8290572..37cbcce655 100644 --- a/packages/houdini/src/test/index.ts +++ b/packages/houdini/src/test/index.ts @@ -23,7 +23,7 @@ export function testConfigFile({ plugins, ...config }: Partial = {}) id: ID! name(arg: Int): String! birthday: DateTime! - firstName: String! + firstName: String! @deprecated(reason: "Use name instead") friends: [User!]! friendsByCursor(first: Int, after: String, last: Int, before: String, filter: String): UserConnection! friendsByCursorSnapshot(snapshot: String!, first: Int, after: String, last: Int, before: String): UserConnection! @@ -53,13 +53,22 @@ export function testConfigFile({ plugins, ...config }: Partial = {}) believers(first: Int, after: String): GhostConnection } + """ + Cat's documentation + """ type Cat implements Friend & Node { id: ID! + """ + The name of the cat + """ name: String! owner: User! } type Query { + """ + Get a user. + """ user: User! entity: Entity! version: Int! @@ -241,11 +250,19 @@ export function testConfigFile({ plugins, ...config }: Partial = {}) id: ID! } + """ + Documentation of testenum1 + """ enum TestEnum1 { + "Documentation of Value1" Value1 + "Documentation of Value2" Value2 } + """ + Documentation of testenum2 + """ enum TestEnum2 { Value3 Value2