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 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ package-lock.json
# Coverage
.nyc_output/
coverage/

# Test Artifacts
test/data/**/output.graphql
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('..').generateFragments(process.argv[2]);
3 changes: 0 additions & 3 deletions circle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ test:

- npm run test:style:
parallel: true
files:
- src/**/*.{ts,tsx}
- test/**/*.{ts,tsx}

- npm run test:unit:
parallel: true
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,35 @@
"prepublish": "./scripts/prepublish.sh",
"release": "./scripts/release.sh",
"start": "./scripts/start.sh",
"test:clean": "./scripts/test:clean.sh",
"test:compile": "./scripts/test:compile.sh",
"test:integration": "./scripts/test:integration.sh",
"test:style": "./scripts/test:style.sh",
"test:unit": "./scripts/test:unit.sh",
"test": "./scripts/test.sh"
},
"bin": {
"ts2gql": "./bin/ts2gql"
"ts2gql": "./bin/ts2gql",
"ts2gqlfragment": "./bin/ts2gqlfragment"
},
"dependencies": {
"doctrine": "^1.2.2",
"lodash": "^4.17.4"
"lodash": "^4.17.4",
"mkdirp": "^0.5.1"
},
"devDependencies": {
"@types/chai": "3.5.2",
"@types/chai-as-promised": "0.0.30",
"@types/lodash": "4.14.85",
"@types/mkdirp": "^0.5.1",
"@types/node": "8.5.1",
"@types/sinon": "2.3.4",
"@types/sinon-chai": "2.7.29",
"chai": "3.5.0",
"chai-as-promised": "6.0.0",
"chai-jest-diff": "nevir/chai-jest-diff#built-member-assertions",
"graphql": "0.12.3",
"graphql-tag": "2.4.2",
"jest": "21.1.0",
"jest-junit": "3.0.0",
"sinon": "3.2.1",
Expand Down
4 changes: 4 additions & 0 deletions scripts/test:clean.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e

rm -rf ./test/data/**/output.graphql
2 changes: 1 addition & 1 deletion scripts/test:style.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ source ./scripts/include/node.sh

FILES=("${@}")
if [[ "${#FILES[@]}" = "0" ]]; then
FILES+=($(find scripts src test ! -name "*.d.ts" -and -name "*.ts" -or -name "*.tsx"))
FILES+=($(find scripts src test ! -name "*.d.ts" -not -path "test/data/*" -and -name "*.ts" -or -name "*.tsx"))
fi

OPTIONS=(
Expand Down
1 change: 1 addition & 0 deletions scripts/test:unit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ for option in "${OPTIONS_FLAGS[@]}"; do
fi
done

npm run test:clean
npm run compile

# For jest-junit
Expand Down
10 changes: 5 additions & 5 deletions src/Emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default class Emitter {
renames:{[key:string]:string} = {};

constructor(private types:Types.TypeMap) {
this.types = <Types.TypeMap>_.omitBy(types, (node, name) => this._preprocessNode(node, name!));
this.types = <Types.TypeMap>_.omitBy(types, (node, name) => this._preprocessNode(node, name!, types));
Copy link

Choose a reason for hiding this comment

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

nit: omitBy is a generic function so instead of casting I think it is better to do _.omitBy<Types.TypeMap>.

}

emitAll(stream:NodeJS.WritableStream) {
Expand All @@ -32,9 +32,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 Down Expand Up @@ -211,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
178 changes: 178 additions & 0 deletions src/Fragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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.join(path.dirname(file.filePath), call.relativePath);
const fileName = path.basename(gqlPath, path.extname(gqlPath));
mkdirp.sync(path.dirname(gqlPath));

let contents = '';
Copy link

Choose a reason for hiding this comment

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

use template string

// it will have new line added
const contents = `fragment ${fileName} on ${call.baseName} {
emitFields(call.properties)}
`;

contents += `fragment ${fileName} on ${call.baseName} {\n`;
contents += emitFields(call.properties);
contents += '}\n';
fs.writeFileSync(gqlPath, contents);
console.log(`Created fragment at ${gqlPath}`); // tslint:disable-line
});
});
}

function getFragmentDeclaration(files:ReadonlyArray<typescript.SourceFile>):typescript.FunctionDeclaration|null {
let fragmentDeclaration:typescript.FunctionDeclaration|null = null;
// Looks for this file's (src/Fragment.ts's) .d.ts file to see if the fragment function
// is called
const thisTypeFile = files.find(f => f.fileName === `${__filename.substr(0, __filename.length - 3)}.d.ts`);
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[], indent = ' ') {
let contents = '';
fields.forEach(field => {
if (field.subfields.length) {
contents += `${indent}${field.name} {\n`;
contents += emitFields(field.subfields, `${indent} `);
contents += `${indent}}\n`;
} else {
contents += `${indent}${field.name}\n`;
}
});
return contents;
}

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;
const call = child as typescript.CallExpression;

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

if (!symbol) return;

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

// Short-circuit if a function call is not to ts2gql's fragment function
if (!type.symbol || type.symbol.valueDeclaration !== fragmentDeclaration) return;

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;

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;
});
return calls;
}

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

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

// For Arrays we want to use the type of the array
if (type.symbol && type.symbol.name === 'Array') {
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 [];

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

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

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) {
// Some pointless code to appease error TS6133: 'TFragment' is declared but its value is never read.
const ignore:TFragment|null = null;
Copy link

Choose a reason for hiding this comment

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

This is a bit weird but i don't know how to make it better either

if (ignore !== null) return;

return require(filepath);
}
2 changes: 2 additions & 0 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
12 changes: 12 additions & 0 deletions test/data/post/expected.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
fragment output on Post {
id
title
postedAt
author {
name
photo
}
editor {
name
}
}
38 changes: 38 additions & 0 deletions test/data/post/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { fragment } from '../../../dist/src';
import { Post, User } from './post_types';
import gql from '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 = gql`
query getPosts() {
posts() {
...${fragment<PostProps, Post>('./output.graphql')}
}
}
`;

// Pointless code to appease error TS6133: 'query' is declared but its value is never read.
if (!query) process.exit();
Loading