-
Notifications
You must be signed in to change notification settings - Fork 13
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
base: master
Are you sure you want to change the base?
Changes from all commits
a061673
7cdca72
c1fa86d
27fb01f
a7c1dbf
59164d0
6e6f9c2
fd2453c
81322ac
c4fc4b3
499ba9c
e76c118
2610685
793be06
05110ea
5fc0550
4fe9a74
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -23,3 +23,6 @@ package-lock.json | |
# Coverage | ||
.nyc_output/ | ||
coverage/ | ||
|
||
# Test Artifacts | ||
test/data/**/output.graphql |
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]); |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -35,6 +35,7 @@ for option in "${OPTIONS_FLAGS[@]}"; do | |
fi | ||
done | ||
|
||
npm run test:clean | ||
npm run compile | ||
|
||
# For jest-junit | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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)); | ||
} | ||
|
||
emitAll(stream:NodeJS.WritableStream) { | ||
|
@@ -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; | ||
|
@@ -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'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: Why do just do |
||
return (content as string[]).map(s => ` ${s}`).join('\n'); | ||
} | ||
|
||
_transitiveInterfaces(node:Types.InterfaceNode):Types.InterfaceNode[] { | ||
|
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = ''; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: this check and below check should merge (https://github.com/convoyinc/ts2gql/pull/14/files#diff-7a0c154a11c76dcb49ac0bf9d958a9a9R106) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why? Don't we want to check each argument? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} |
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 | ||
} | ||
} |
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(); |
There was a problem hiding this comment.
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>
.