-
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 10 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 |
---|---|---|
@@ -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]); |
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) { | ||
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.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`); | ||
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: 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; | ||
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: use type annotation instead of casting 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. If I do, it says
|
||
|
||
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; | ||
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: add comment about why checking |
||
|
||
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; | ||
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 check that |
||
|
||
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') { | ||
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 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; | ||
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. Should we check for |
||
|
||
// 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); | ||
} |
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')} | ||
} | ||
} | ||
`; |
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 | ||
} | ||
} |
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.
Q: Why do just do
Array.isArray(content)
also since you are doing type guard here I don't think you need casting