Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: emit TypedDocumentNode alias in generated code #230

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,57 @@ This option affects all editor supporting functions, results of CLI commands and

If you set this option `false`, this plugin passes through query document without removing duplication.

### `exportTypedQueryDocumentNode`

It's optional and default: `false`. This option enables an experimental feature that requires `graphql` v15.4.0 or
later. When enabled generated files export a type based on
[`TypedQueryDocumentNode`](https://github.com/graphql/graphql-js/blob/master/src/utilities/typedQueryDocumentNode.d.ts)
from GraphQL. The type extends the standard `DocumentNode` AST type but also includes types for result data and
variables as type arguments.

To use this feature you can apply a type assertion to `gql` template tag expressions that evaluate to a `DocumentNode`
value.

For example:

```ts
const query = gql`
query MyQuery($take: Int!) {
recipes(take: $take) {
id
title
}
}
` as import('./__generated__/my-query.ts').MyQueryDocument;
```

With that type assertion in place result data and variable types will automatically flow through any function that
accepts the `TypedQueryDocumentNode` type.

For example here is how you can write a wrapper for the `useQuery` function from Apollo Client:

```ts
import { gql, QueryHookOptions, QueryResult, useQuery } from '@apollo/client';
import { TypedQueryDocumentNode } from 'graphql';

function useTypedQuery<ResponseData, Variables>(
query: TypedQueryDocumentNode<ResponseData, Variables>,
options: QueryHookOptions<ResponseData, Variables>,
): QueryResult<ResponseData, Variables> {
return useQuery(query, options);
}

// example usage
const { data } = useTypedQuery(query, { variables: { take: 100 } });
// ^ ^
// inferred type is `MyQuery` |
// |
// inferred type is `MyQueryVariables`
```

The result is that generated types are associated with queries at the point where the query is defined instead of at the
points where the query is executed.

## webpack custom transformer

ts-graphql-plugin provides TypeScript custom transformer to static transform from query template strings to GraphQL AST. It's useful if you use https://github.com/apollographql/graphql-tag
Expand Down
12 changes: 12 additions & 0 deletions src/analyzer/__snapshots__/analyzer.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ export type MyQueryVariables = {};
"
`;

exports[`Analyzer typegen should export alias of \`TypedQueryDocumentNode\` 1`] = `
"/* eslint-disable */
/* This is an autogenerated file. Do not edit this file directly! */
import { TypedQueryDocumentNode } from \\"graphql\\";
export type MyQuery = {
hello: string;
};
export type MyQueryVariables = {};
export type MyQueryDocument = TypedQueryDocumentNode<MyQuery, MyQueryVariables>;
"
`;

exports[`Analyzer typegen should ignore complex operations document 1`] = `"This document node has complex operations."`;

exports[`Analyzer typegen should report error when no schema 1`] = `"No GraphQL schema. Confirm your ts-graphql-plugin's \\"schema\\" configuration at tsconfig.json's compilerOptions.plugins section."`;
Expand Down
34 changes: 33 additions & 1 deletion src/analyzer/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ type CreateTestingAnalyzerOptions = {
sdl: string;
files: { fileName: string; content: string }[];
localSchemaExtension?: { fileName: string; content: string };
exportTypedQueryDocumentNode?: boolean;
};
function createTestingAnalyzer({ files: sourceFiles, sdl, localSchemaExtension }: CreateTestingAnalyzerOptions) {
function createTestingAnalyzer({
files: sourceFiles,
sdl,
localSchemaExtension,
exportTypedQueryDocumentNode,
}: CreateTestingAnalyzerOptions) {
const files = [...sourceFiles];
files.push({ fileName: '/schema.graphql', content: sdl });
if (localSchemaExtension) {
Expand All @@ -21,6 +27,7 @@ function createTestingAnalyzer({ files: sourceFiles, sdl, localSchemaExtension }
name: 'ts-graphql-plugin',
schema: '/schema.graphql',
localSchemaExtensions: localSchemaExtension ? [localSchemaExtension.fileName] : [],
exportTypedQueryDocumentNode,
removeDuplicatedFragments: true,
tag: 'gql',
};
Expand Down Expand Up @@ -131,6 +138,21 @@ const complexOperationsPrj = {
],
};

const exportTypedQueryDocumentNode = {
sdl: `
type Query {
hello: String!
}
`,
files: [
{
fileName: 'main.ts',
content: 'const query = gql`query MyQuery { hello }`;',
},
],
exportTypedQueryDocumentNode: true,
};

describe(Analyzer, () => {
describe(Analyzer.prototype.extractToManifest, () => {
it('should extract manifest', () => {
Expand Down Expand Up @@ -229,5 +251,15 @@ describe(Analyzer, () => {
expect(errors.length).toBe(1);
expect(errors[0].message).toMatchSnapshot();
});

it('should export alias of `TypedQueryDocumentNode`', async () => {
const analyzer = createTestingAnalyzer(exportTypedQueryDocumentNode);
const { outputSourceFiles } = await analyzer.typegen();
if (!outputSourceFiles) return fail();
expect(outputSourceFiles.length).toBe(1);
expect(outputSourceFiles[0].fileName.endsWith('__generated__/my-query.ts')).toBeTruthy();
const printer = ts.createPrinter();
expect(printer.printFile(outputSourceFiles[0])).toMatchSnapshot();
});
});
});
7 changes: 6 additions & 1 deletion src/analyzer/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,12 @@ export class Analyzer {
dasherize(operationOrFragmentName) + '.ts',
);
try {
outputSourceFiles.push(visitor.visit(r.documentNode, { outputFileName }));
outputSourceFiles.push(
visitor.visit(r.documentNode, {
exportTypedQueryDocumentNode: this._pluginConfig.exportTypedQueryDocumentNode ?? false,
outputFileName,
}),
);
this._debug(
`Create type source file '${path.relative(this._prjRootPath, outputFileName)}' from '${path.relative(
this._prjRootPath,
Expand Down
18 changes: 18 additions & 0 deletions src/typegen/__snapshots__/type-gen-visitor.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,24 @@ export type MyQueryVariables = {};
"
`;

exports[`typegen result type generation with exportTypedDocumentNode enabled should export an alias of \`TypedQueryDocumentNode\` for a query 1`] = `
"import { TypedQueryDocumentNode } from \\"graphql\\";
export type MyQuery = {
hello: string;
bye: string;
};
export type MyQueryVariables = {};
export type MyQueryDocument = TypedQueryDocumentNode<MyQuery, MyQueryVariables>;
"
`;

exports[`typegen result type generation with exportTypedDocumentNode enabled should not import \`TypedQueryDocumentNode\` in a module with no query 1`] = `
"export type MyFragment = {
hello: string | null;
};
"
`;

exports[`typegen variable type generation should gen optional type with default value variable 1`] = `
"export type MyQuery = {
hello: string;
Expand Down
56 changes: 53 additions & 3 deletions src/typegen/type-gen-visitor.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { TypeGenVisitor, TypeGenError } from './type-gen-visitor';
import { TypeGenVisitor, TypeGenError, VisitOption } from './type-gen-visitor';
import ts from 'typescript';
import { parse, buildSchema } from 'graphql';

function generateAstAndPrint({ schemaSDL, documentContent }: { schemaSDL: string; documentContent: string }) {
function generateAstAndPrint({
schemaSDL,
documentContent,
visitOptions,
}: {
schemaSDL: string;
documentContent: string;
visitOptions?: Partial<VisitOption>;
}) {
const schema = buildSchema(schemaSDL);
const documentNode = parse(documentContent);
const source = new TypeGenVisitor({ schema }).visit(documentNode, { outputFileName: 'out.ts' });
const source = new TypeGenVisitor({ schema }).visit(documentNode, {
exportTypedQueryDocumentNode: false,
outputFileName: 'out.ts',
...visitOptions,
});
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed, removeComments: true });
return printer.printFile(source);
}
Expand Down Expand Up @@ -717,5 +729,43 @@ describe('typegen', () => {
expect(result).toMatchSnapshot();
});
});

describe('with exportTypedDocumentNode enabled', () => {
it('should export an alias of `TypedQueryDocumentNode` for a query', () => {
const result = generateAstAndPrint({
visitOptions: { exportTypedQueryDocumentNode: true },
schemaSDL: `
type Query {
hello: String!
bye: String!
}
`,
documentContent: `
query MyQuery {
hello
bye
}
`,
});
expect(result).toMatchSnapshot();
});

it('should not import `TypedQueryDocumentNode` in a module with no query', () => {
const result = generateAstAndPrint({
visitOptions: { exportTypedQueryDocumentNode: true },
schemaSDL: `
type Query {
hello: String
}
`,
documentContent: `
fragment MyFragment on Query {
hello
}
`,
});
expect(result).toMatchSnapshot();
});
});
});
});
84 changes: 71 additions & 13 deletions src/typegen/type-gen-visitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type TypeGenVisitorOptions = {
};

export type VisitOption = {
exportTypedQueryDocumentNode: boolean;
outputFileName: string;
};

Expand All @@ -92,7 +93,7 @@ export class TypeGenVisitor {
constructor({ schema }: TypeGenVisitorOptions) {
this._schema = schema;
}
visit(documentNode: DocumentNode, { outputFileName }: VisitOption) {
visit(documentNode: DocumentNode, { exportTypedQueryDocumentNode, outputFileName }: VisitOption) {
const statements: ts.Statement[] = [];
const parentTypeStack = new Stack<GraphQLFragmentTypeConditionNamedType>();
const resultFieldElementStack = new Stack<FieldTypeElement>(() => ({
Expand Down Expand Up @@ -139,18 +140,19 @@ export class TypeGenVisitor {
variableElementStack.stack();
},
leave: node => {
statements.push(
this._createTsTypeDeclaration(
node.name ? node.name.value : 'QueryResult',
resultFieldElementStack.consume(),
),
);
statements.push(
this._createTsTypeDeclaration(
node.name ? node.name.value + 'Variables' : 'QueryVariables',
variableElementStack.consume(),
),
);
const resultTypeName = node.name ? node.name.value : 'QueryResult';
const variablesTypeName = node.name ? node.name.value + 'Variables' : 'QueryVariables';
statements.push(this._createTsTypeDeclaration(resultTypeName, resultFieldElementStack.consume()));
statements.push(this._createTsTypeDeclaration(variablesTypeName, variableElementStack.consume()));
if (exportTypedQueryDocumentNode) {
statements.push(
this._createTypedQueryDocumentNodeAliasDeclaration({
operationName: node.name?.value,
resultTypeName,
variablesTypeName,
}),
);
}
parentTypeStack.consume();
},
},
Expand Down Expand Up @@ -315,6 +317,15 @@ export class TypeGenVisitor {
},
});

// Prepend `TypedQueryDocumentNode` import to statements if it is referenced
// in one of the existing statements.
if (
exportTypedQueryDocumentNode &&
statements.some(statement => this._isTypedQueryDocumentNodeAliasDeclaration(statement))
) {
statements.unshift(this._createTypedQueryDocumentNodeImportDeclaration());
}

const sourceFile = ts.createSourceFile(outputFileName, '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
const resultFile = ts.updateSourceFileNode(sourceFile, statements);
if (resultFile.statements.length) {
Expand Down Expand Up @@ -481,4 +492,51 @@ export class TypeGenVisitor {
}
return ts.createIntersectionTypeNode(toIntersectionElements);
}

private _isTypedQueryDocumentNodeAliasDeclaration(statement: ts.Statement): boolean {
const type = ts.isTypeAliasDeclaration(statement) && statement.type;
if (type && ts.isTypeReferenceNode(type)) {
if (ts.isIdentifier(type.typeName)) {
return type.typeName.text === 'TypedQueryDocumentNode';
}
}
return false;
}

private _createTypedQueryDocumentNodeAliasDeclaration({
operationName,
resultTypeName,
variablesTypeName,
}: {
operationName: string | undefined;
resultTypeName: string;
variablesTypeName: string;
}): ts.Statement {
const documentTypeName = operationName ? operationName + 'Document' : 'QueryDocument';
return ts.createTypeAliasDeclaration(
undefined,
ts.createModifiersFromModifierFlags(ts.ModifierFlags.Export),
documentTypeName,
undefined,
ts.createTypeReferenceNode('TypedQueryDocumentNode', [
ts.createTypeReferenceNode(resultTypeName),
ts.createTypeReferenceNode(variablesTypeName),
]),
);
}

private _createTypedQueryDocumentNodeImportDeclaration(): ts.Statement {
return ts.factory.createImportDeclaration(
undefined,
undefined,
ts.factory.createImportClause(
false,
undefined,
ts.factory.createNamedImports([
ts.factory.createImportSpecifier(undefined, ts.factory.createIdentifier('TypedQueryDocumentNode')),
]),
),
ts.factory.createStringLiteral('graphql'),
);
}
}
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SchemaConfig } from './schema-manager/types';

export type TsGraphQLPluginConfigOptions = SchemaConfig & {
exportTypedQueryDocumentNode?: boolean;
name: string;
removeDuplicatedFragments?: boolean;
tag?: string;
Expand Down