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

Generate GQL fragments based on TS types #14

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 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
2 changes: 1 addition & 1 deletion bin/ts2gql
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ if (process.argv.length < 3) {
process.exit(1);
}

require('..').emit(process.argv[2], process.argv.slice(3), process.stdout);
require('../dist').emit(process.argv[2], process.argv.slice(3), process.stdout);
9 changes: 9 additions & 0 deletions bin/ts2gqlfragment
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env node
if (process.argv.length < 3) {
process.stderr.write('\n');
process.stderr.write('Usage: ts2gqlfragment root/module.ts\n');
process.stderr.write('\n');
process.exit(1);
}

require('../dist').generateFragments(process.argv[2]);
13 changes: 8 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@
"test:style": "./scripts/test:style"
},
"bin": {
"ts2gql": "./bin/ts2gql"
"ts2gql": "./bin/ts2gql",
"ts2gqlfragment": "./bin/ts2gqlfragment"
},
"dependencies": {
"doctrine": "^1.2.2",
"lodash": "^4.0.0"
"lodash": "^4.17.4",
"mkdirp": "^0.5.1"
},
"devDependencies": {
"tslint": "^3.13.0",
"typescript": "2.0.10",
"typings": "^1.3.1"
"@types/mkdirp": "^0.5.1",
"graphql-tag": "2.4.2",
"tslint": "5.7.0",
"typescript": "2.4.2"
}
}
2 changes: 0 additions & 2 deletions scripts/prepublish
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
#!/usr/bin/env bash
set -e

./node_modules/.bin/typings install

NPM_COMMAND=$(node -e "console.log(JSON.parse(process.env.npm_config_argv).original[0])")
# Skip builds if we're being run via a basic npm install.
if [[ "${NPM_COMMAND}" == "install" ]]; then
Expand Down
10 changes: 5 additions & 5 deletions src/Collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default class Collector {
this.checker = program.getTypeChecker();
}

addRootNode(node:typescript.Declaration):void {
addRootNode(node:typescript.InterfaceDeclaration):void {
this._walkNode(node);
const simpleNode = <types.InterfaceNode>this.types[this._nameForSymbol(this._symbolForNode(node.name))];
simpleNode.concrete = true;
Expand Down Expand Up @@ -203,11 +203,11 @@ export default class Collector {
// Type Walking

_walkType = (type:typescript.Type):types.Node => {
if (type.flags & TypeFlags.Reference) {
if (type.flags & TypeFlags.Object) {
return this._walkTypeReference(<typescript.TypeReference>type);
} else if (type.flags & TypeFlags.Interface) {
} else if (type.flags & TypeFlags.BooleanLike) {
return this._walkInterfaceType(<typescript.InterfaceType>type);
} else if (type.flags & TypeFlags.Anonymous) {
} else if (type.flags & TypeFlags.Index) {
return this._walkNode(type.getSymbol().declarations[0]);
} else if (type.flags & TypeFlags.String) {
return {type: 'string'};
Expand Down Expand Up @@ -239,7 +239,7 @@ export default class Collector {

// Utility

_addType(node:typescript.Declaration, typeBuilder:() => types.Node):types.Node {
_addType(node:typescript.InterfaceDeclaration|typescript.TypeAliasDeclaration|typescript.EnumDeclaration, typeBuilder:() => types.Node):types.Node {
const name = this._nameForSymbol(this._symbolForNode(node.name));
if (this.types[name]) return this.types[name];
const type = typeBuilder();
Expand Down
18 changes: 10 additions & 8 deletions src/Emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import * as types from './types';

// https://raw.githubusercontent.com/sogko/graphql-shorthand-notation-cheat-sheet/master/graphql-shorthand-notation-cheat-sheet.png
export default class Emitter {
private types:types.TypeMap;

renames:{[key:string]:string} = {};

constructor(private types:types.TypeMap) {
this.types = <types.TypeMap>_.omitBy(types, (node, name) => this._preprocessNode(node, name));
constructor(typeMap:types.TypeMap) {
this.types = <types.TypeMap>_.omitBy(typeMap, (node, name) => this._preprocessNode(node, name, typeMap));
}

emitAll(stream:NodeJS.WritableStream) {
Expand All @@ -31,9 +33,9 @@ export default class Emitter {

// Preprocessing

_preprocessNode(node:types.Node, name:types.SymbolName):boolean {
_preprocessNode(node:types.Node, name:types.SymbolName, typeMap:types.TypeMap):boolean {
if (node.type === 'alias' && node.target.type === 'reference') {
const referencedNode = this.types[node.target.target];
const referencedNode = typeMap[node.target.target];
if (this._isPrimitive(referencedNode) || referencedNode.type === 'enum') {
this.renames[name] = node.target.target;
return true;
Expand All @@ -54,7 +56,7 @@ export default class Emitter {
} else if (node.target.type === 'reference') {
return `union ${this._name(name)} = ${this._name(node.target.target)}`;
} else if (node.target.type === 'union') {
const types = node.target.types.map(type => {
const typeList = node.target.types.map(type => {
if (type.type !== 'reference') {
throw new Error(`GraphQL unions require that all types are references. Got a ${type.type}`);
}
Expand All @@ -66,7 +68,7 @@ export default class Emitter {
});
return this._emitEnum({
type: 'enum',
values: _.uniq(_.flatten(types)),
values: _.uniq(_.flatten(typeList)),
}, this._name(name));
} else {
throw new Error(`Can't serialize ${JSON.stringify(node.target)} as an alias`);
Expand Down Expand Up @@ -209,8 +211,8 @@ export default class Emitter {
}

_indent(content:string|string[]):string {
if (!_.isArray(content)) content = content.split('\n');
return content.map(s => ` ${s}`).join('\n');
if (!_.isArray(content)) content = (content as string).split('\n');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why do just do Array.isArray(content) also since you are doing type guard here I don't think you need casting

return (content as string[]).map(s => ` ${s}`).join('\n');
}

_transitiveInterfaces(node:types.InterfaceNode):types.InterfaceNode[] {
Expand Down
161 changes: 161 additions & 0 deletions src/Fragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as _ from 'lodash';
import * as typescript from 'typescript';
import * as path from 'path';
import * as fs from 'fs';
import * as mkdirp from 'mkdirp';

export function generateFragments(rootPath:string) {
rootPath = path.resolve(rootPath);
const program = typescript.createProgram([rootPath], {
jsx: typescript.JsxEmit.React,
});
const files = program.getSourceFiles();
const checker = program.getTypeChecker();

// Get the declaration of the fragment function contained in this file
const fragmentDeclaration = getFragmentDeclaration(files);

if (!fragmentDeclaration) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: should we try to reconcile two error throwing here in the case of you don't have a fragment import, you will get two errrors

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How so? Won't it throw once and bubble all the way up?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep you are right

throw new Error(`ts2gql.fragments is not imported and used anywhere in this program`);
}

const calls = files.map(file => ({
filePath: file.fileName,
calls: collectFragmentCalls(file, checker, fragmentDeclaration),
}));

calls.forEach(file => {
file.calls.forEach(call => {
const gqlPath = path.resolve(file.filePath, call.relativePath);
const fileName = path.basename(gqlPath, path.extname(gqlPath));
mkdirp.sync(path.dirname(gqlPath));
const stream = fs.createWriteStream(gqlPath, { autoClose: false } as any);
stream.write(`fragment ${fileName} on ${call.baseName} {\n`);
emitFields(call.properties, stream);
stream.write('}\n');
console.log(`Created fragment at ${gqlPath}`);
});
});
}

function getFragmentDeclaration(files:typescript.SourceFile[]) {
let fragmentDeclaration:typescript.FunctionDeclaration|null = null;
const thisTypeFile = files.find(f => f.fileName === `${__filename.substr(0, __filename.length - 3)}.d.ts`);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: could you add comment about the substr operation? what is the input filename and expected output?

if (!thisTypeFile) {
throw new Error(`ts2gqlfragment is not imported in the project`);
}
thisTypeFile.forEachChild(child => {
if (child.kind !== typescript.SyntaxKind.FunctionDeclaration) return;
const declaration = child as typescript.FunctionDeclaration;
if (declaration.name.text === 'fragment') {
fragmentDeclaration = declaration;
}
});
return fragmentDeclaration;
}

function emitFields(fields:Field[], stream:NodeJS.WritableStream, indent = ' ') {
fields.forEach(field => {
if (field.subfields) {
stream.write(`${indent}${field.name} {\n`);
emitFields(field.subfields, stream, `${indent} `);
stream.write(`${indent}}\n`);
} else {
stream.write(`${indent}${field.name}\n`);
}
});
}

interface FragmentCall {
properties:Field[];
baseName:string;
relativePath:string;
}

function collectFragmentCalls(node:typescript.Node, checker:typescript.TypeChecker, fragmentDeclaration:typescript.FunctionDeclaration) {

let calls:FragmentCall[] = [];
typescript.forEachChild(node, child => {
const childCalls = collectFragmentCalls(child, checker, fragmentDeclaration);
if (childCalls) {
calls = calls.concat(childCalls);
}
if (child.kind !== typescript.SyntaxKind.CallExpression) return null;
const call = child as typescript.CallExpression;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use type annotation instead of casting

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I do, it says

[ts] Type 'Node' is not assignable to type 'CallExpression'.
[ts]
Type 'Node' is not assignable to type 'CallExpression'.
  Types of property 'kind' are incompatible.
    Type 'SyntaxKind' is not assignable to type 'SyntaxKind.CallExpression'.


const symbol = checker.getSymbolAtLocation(call.expression);

if (!symbol) return null;

const type = checker.getTypeOfSymbolAtLocation(symbol, call.expression);

if (!type.symbol || type.symbol.valueDeclaration !== fragmentDeclaration) return null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add comment about why checking type.symbol.valueDeclaration !== fragmentDeclaration?


if (!call.typeArguments || call.typeArguments.length !== 2) {
throw new Error('ts2gql.fragment<TFragment, TFragmentBase>(graphQLFilePath) should have two type arguments');
}
if (call.arguments.length !== 1) {
throw new Error('ts2gql.fragment<TFragment, TFragmentBase>(graphQLFilePath): Must have one argument');
}

const data = call.typeArguments[0];
if (data.kind !== typescript.SyntaxKind.TypeReference) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? Don't we want to check each argument?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep you are right... they do report different errors 👍

throw new Error('ts2gql.fragment<TFragment, TFragmentBase>(require(relGQLPath)): TFragment must be a TypeReference');
}
const base = call.typeArguments[1];
if (base.kind !== typescript.SyntaxKind.TypeReference) {
throw new Error('ts2gql.fragment<TFragment, TFragmentBase>(require(relGQLPath)): TFragmentBase must be a TypeReference');
}
const gqlToken = call.arguments[0];
const relativePath = (gqlToken as typescript.StringLiteral).text;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: should we check that gqlToken is a string literal?


const propertyType = checker.getTypeFromTypeNode(data);
const properties = collectProperties(propertyType, checker, data);
const baseNameRaw = ((base as typescript.TypeReferenceNode).typeName as typescript.Identifier).text;
const baseName = baseNameRaw.replace(/\W/g, '_');

calls.push({
properties,
baseName,
relativePath,
});
});
return calls;
}

interface Field {
name:string;
subfields:Field[];
}

function collectProperties(type:typescript.Type, checker:typescript.TypeChecker, typeNode:typescript.TypeNode) {
const fields:Field[] = [];

// For Arrays we want to use the type of the array
if (type.symbol && type.symbol.name === 'Array') {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: should we add test for this case?

const arrayType = type as typescript.TypeReference;
type = arrayType.typeArguments[0];
}

// For unstructured types (like string, number, etc) we don't need to loop through their properties
if (!(type.flags & typescript.TypeFlags.StructuredType)) return null;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check for TypeFlags.Primtive (https://github.com/Microsoft/TypeScript/blob/master/src/compiler/types.ts#L3230) I think we don't want to loop through primitive type in general


// A bit strange, but a boolean is a union of true and false therefore a StructuredType
if (type.flags & typescript.TypeFlags.Boolean) return null;

// For Date's we don't need to loop through their properties
if (type.symbol && type.symbol.name === 'Date') return null;

const properties = checker.getPropertiesOfType(type);

properties.forEach(symbol => {
const propertyType = checker.getTypeOfSymbolAtLocation(symbol, typeNode);
const subfields = collectProperties(propertyType, checker, typeNode);
fields.push({ name: symbol.name, subfields });
});
return fields;
}

export function fragment<TFragment extends Partial<TFragmentBase>, TFragmentBase>(filepath:string) {
return require(filepath);
}
9 changes: 5 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import * as util from './util';
import Collector from './Collector';
import Emitter from './Emitter';

export { generateFragments, fragment } from './Fragment';

export function load(schemaRootPath:string, rootNodeNames:string[]):types.TypeMap {
schemaRootPath = path.resolve(schemaRootPath);
const program = typescript.createProgram([schemaRootPath], {});
Expand Down Expand Up @@ -50,12 +52,11 @@ export function load(schemaRootPath:string, rootNodeNames:string[]):types.TypeMa
}

export function emit(schemaRootPath:string, rootNodeNames:string[], stream:NodeJS.WritableStream = process.stdout):void {
const types = load(schemaRootPath, rootNodeNames);
const emitter = new Emitter(types);
const typeMap = load(schemaRootPath, rootNodeNames);
const emitter = new Emitter(typeMap);
emitter.emitAll(stream);
}

function isNodeExported(node:typescript.Node):boolean {
return (node.flags & typescript.NodeFlags.Export) !== 0
|| (node.parent && node.parent.kind === typescript.SyntaxKind.SourceFile);
return node.modifiers && node.modifiers.some(m => m.kind === typescript.SyntaxKind.ExportKeyword);
}
35 changes: 35 additions & 0 deletions test/fragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { fragment } from '../dist';
import { Post, User } from './input';
import 'graphql-tag';

interface AuthorProps {
author:Pick<User, 'name' | 'photo'>;
}

interface DateTimeProps {
post:Pick<Post, 'postedAt'>;
}

type PostProps =
// When current component directly displays properties of the entity
Pick<Post, 'id' | 'title'> &
// When passing the entire entity to another component
DateTimeProps['post'] &
// Passing a prop of the entity to another component
{
author:AuthorProps['author'];
} &
// When current component displays deep properties of an entity
{
editor:{
name:User['name'],
},
};

const query = `
query getPosts() {
posts() {
...${fragment<PostProps, Post>('../graphql/getPosts.graphql')}
}
}
`;
12 changes: 12 additions & 0 deletions test/graphql/getPosts.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
fragment getPosts on Post {
id
title
postedAt
author {
name
photo
}
editor {
name
}
}
Loading