Skip to content

Commit

Permalink
feat: ScalarFieldToObjectFieldRewriter (#7)
Browse files Browse the repository at this point in the history
* wip

* adding ability to rewrite nested fragment outputs properly

* fixing linting

* exporting ScalarFieldToObjectFieldRewriter properly

* updating readme
  • Loading branch information
chanind authored Oct 6, 2019
1 parent 36a5025 commit aee2dac
Show file tree
Hide file tree
Showing 9 changed files with 590 additions and 11 deletions.
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,69 @@ mutation createUser($username: String!, $password: String!) {
}
```
### ScalarFieldToObjectFieldRewriter
`ScalarFieldToObjectFieldRewriter` can be used to rewrite a scalar field into an object selecing a single scalar field. For example, imagine there's a `User` type with a `full_name` field that's of type `String!`. But to internationalize, that `full_name` field needs to support different names in different languges, something like `full_name: { default: 'Jackie Chan', 'cn': '成龙', ... }`. We could use the `ScalarFieldToObjectFieldRewriter` to rewriter `full_name` to instead select the `default` name. Specifically, given we have the schema below:
```
type User {
id: ID!
full_name: String!
...
}
```
and we want to change it to
```
type User {
id: ID!
full_name: {
default: String!
en: String
cn: String
...
}
...
}
```
we can make this change with the following rewriter:
```js
import { ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter';

// add this to the rewriters array in graphqlRewriterMiddleware(...)
const rewriter = new ScalarFieldToObjectFieldRewriter({
fieldName: 'full_name',
objectFieldName: 'default',
})
```
For example, This would rewrite the following query:
```
query getUser(id: ID!) {
user {
id
full_name
}
}
```
and turn it into:
```
query getUser(id: ID!) {
user {
id
full_name {
default
}
}
}
```
### NestFieldOutputsRewriter
`NestFieldOutputsRewriter` can be used to move mutation outputs into a nested payload object. It's a best-practice for each mutation in GraphQL to have its own output type, and it's required by the [Relay GraphQL Spec](https://facebook.github.io/relay/docs/en/graphql-server-specification.html#mutations). For example, to migrate the mutation `createUser(input: CreateUserInput!): User!` to a mutation with a proper output payload type like:
Expand Down
27 changes: 19 additions & 8 deletions src/RewriteHandler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { parse, print } from 'graphql';
import { extractPath, rewriteDoc, rewriteResultsAtPath } from './ast';
import { FragmentDefinitionNode, parse, print } from 'graphql';
import { extractPath, FragmentTracer, rewriteDoc, rewriteResultsAtPath } from './ast';
import Rewriter, { Variables } from './rewriters/Rewriter';

interface RewriterMatch {
rewriter: Rewriter;
path: ReadonlyArray<string>;
paths: ReadonlyArray<ReadonlyArray<string>>;
}

/**
Expand All @@ -30,6 +30,7 @@ export default class RewriteHandler {
if (this.hasProcessedRequest) throw new Error('This handler has already rewritten a request');
this.hasProcessedRequest = true;
const doc = parse(query);
const fragmentTracer = new FragmentTracer(doc);
let rewrittenVariables = variables;
const rewrittenDoc = rewriteDoc(doc, (nodeAndVars, parents) => {
let rewrittenNodeAndVars = nodeAndVars;
Expand All @@ -38,9 +39,17 @@ export default class RewriteHandler {
if (isMatch) {
rewrittenVariables = rewriter.rewriteVariables(rewrittenNodeAndVars, rewrittenVariables);
rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars);
const simplePath = extractPath([...parents, rewrittenNodeAndVars.node]);
let paths: ReadonlyArray<ReadonlyArray<string>> = [simplePath];
const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as
| FragmentDefinitionNode
| undefined;
if (fragmentDef) {
paths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, simplePath);
}
this.matches.push({
rewriter,
path: extractPath([...parents, rewrittenNodeAndVars.node])
paths
});
}
return isMatch;
Expand All @@ -60,10 +69,12 @@ export default class RewriteHandler {
if (this.hasProcessedResponse) throw new Error('This handler has already returned a response');
this.hasProcessedResponse = true;
let rewrittenResponse = response;
this.matches.reverse().forEach(({ rewriter, path }) => {
rewrittenResponse = rewriteResultsAtPath(rewrittenResponse, path, responseAtPath =>
rewriter.rewriteResponse(responseAtPath)
);
this.matches.reverse().forEach(({ rewriter, paths }) => {
paths.forEach(path => {
rewrittenResponse = rewriteResultsAtPath(rewrittenResponse, path, responseAtPath =>
rewriter.rewriteResponse(responseAtPath)
);
});
});
return rewrittenResponse;
}
Expand Down
132 changes: 131 additions & 1 deletion src/ast.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ASTNode, DocumentNode, VariableDefinitionNode } from 'graphql';
import { ASTNode, DocumentNode, FragmentDefinitionNode, VariableDefinitionNode } from 'graphql';
import { pushToArrayAtKey } from './utils';

const ignoreKeys = new Set(['loc']);

Expand Down Expand Up @@ -29,6 +30,135 @@ export interface NodeAndVarDefs {
variableDefinitions: ReadonlyArray<VariableDefinitionNode>;
}

/** @hidden */
export interface FragmentPathMap {
[fragmentName: string]: ReadonlyArray<ReadonlyArray<string>>;
}

interface MutableFragmentPathMap {
[fragmentName: string]: Array<ReadonlyArray<string>>;
}

/** @hidden */
export class FragmentTracer {
private fragmentPathMap?: FragmentPathMap;
private doc: DocumentNode;

constructor(doc: DocumentNode) {
this.doc = doc;
}

public getPathsToFragment(fragmentName: string): ReadonlyArray<ReadonlyArray<string>> {
if (!this.fragmentPathMap) {
this.fragmentPathMap = this.buildFragmentPathMap();
}
return this.fragmentPathMap[fragmentName] || [];
}

// prepend the paths from the original document into this fragment to the inner fragment paths
public prependFragmentPaths(
fragmentName: string,
pathWithinFragment: ReadonlyArray<string>
): ReadonlyArray<ReadonlyArray<string>> {
return this.getPathsToFragment(fragmentName).map(path => [...path, ...pathWithinFragment]);
}

private getFragmentDefs(): ReadonlyArray<FragmentDefinitionNode> {
return this.doc.definitions.filter(
({ kind }) => kind === 'FragmentDefinition'
) as FragmentDefinitionNode[];
}

private getFragmentPartialPathMap(startNode: ASTNode): MutableFragmentPathMap {
const partialPathMap: MutableFragmentPathMap = {};
const recursivelyBuildFragmentPaths = (node: ASTNode, curParents: ReadonlyArray<ASTNode>) => {
if (node.kind === 'FragmentSpread') {
pushToArrayAtKey(partialPathMap, node.name.value, extractPath(curParents));
}
const nextParents = [...curParents, node];
if ('selectionSet' in node && node.selectionSet) {
for (const selection of node.selectionSet.selections) {
recursivelyBuildFragmentPaths(selection, nextParents);
}
}
};
recursivelyBuildFragmentPaths(startNode, []);
return partialPathMap;
}

private mergeFragmentPaths(
fragmentName: string,
paths: Array<ReadonlyArray<string>>,
fragmentPartialPathsMap: { [fragmentName: string]: FragmentPathMap }
) {
const mergedPaths: MutableFragmentPathMap = {};

const resursivelyBuildMergedPathsMap = (
curFragmentName: string,
curPaths: Array<ReadonlyArray<string>>,
seenFragments: ReadonlySet<string>
) => {
// recursive fragments are invalid graphQL - just exit here. otherwise this will be an infinite loop
if (seenFragments.has(curFragmentName)) return;
const nextSeenFragments = new Set(seenFragments);
nextSeenFragments.add(curFragmentName);
const nextPartialPaths = fragmentPartialPathsMap[curFragmentName];
// if there are not other fragments nested inside of this fragment, we're done
if (!nextPartialPaths) return;

for (const [childFragmentName, childFragmentPaths] of Object.entries(nextPartialPaths)) {
for (const path of curPaths) {
const mergedChildPaths: Array<ReadonlyArray<string>> = [];
for (const childPath of childFragmentPaths) {
const mergedPath = [...path, ...childPath];
mergedChildPaths.push(mergedPath);
pushToArrayAtKey(mergedPaths, childFragmentName, mergedPath);
}
resursivelyBuildMergedPathsMap(childFragmentName, mergedChildPaths, nextSeenFragments);
}
}
};

resursivelyBuildMergedPathsMap(fragmentName, paths, new Set());
return mergedPaths;
}

private buildFragmentPathMap(): FragmentPathMap {
const mainOperation = this.doc.definitions.find(node => node.kind === 'OperationDefinition');
if (!mainOperation) return {};

// partial paths are the paths inside of each fragmnt to other fragments
const fragmentPartialPathsMap: { [fragmentName: string]: FragmentPathMap } = {};
for (const fragmentDef of this.getFragmentDefs()) {
fragmentPartialPathsMap[fragmentDef.name.value] = this.getFragmentPartialPathMap(fragmentDef);
}

// start with the direct paths to fragments inside of the main operation
const simpleFragmentPathMap: MutableFragmentPathMap = this.getFragmentPartialPathMap(
mainOperation
);
const fragmentPathMap: MutableFragmentPathMap = { ...simpleFragmentPathMap };
// next, we'll recursively trace the partials into their subpartials to fill out all possible paths to each fragment
for (const [fragmentName, simplePaths] of Object.entries(simpleFragmentPathMap)) {
const mergedFragmentPathsMap = this.mergeFragmentPaths(
fragmentName,
simplePaths,
fragmentPartialPathsMap
);
for (const [mergedFragmentName, mergedFragmentPaths] of Object.entries(
mergedFragmentPathsMap
)) {
fragmentPathMap[mergedFragmentName] = [
...(fragmentPathMap[mergedFragmentName] || []),
...mergedFragmentPaths
];
}
}

return fragmentPathMap;
}
}

/**
* Walk the document add rewrite nodes along the way
* @param doc
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ export { default as FieldArgNameRewriter } from './rewriters/FieldArgNameRewrite
export { default as FieldArgsToInputTypeRewriter } from './rewriters/FieldArgsToInputTypeRewriter';
export { default as FieldArgTypeRewriter } from './rewriters/FieldArgTypeRewriter';
export { default as NestFieldOutputsRewriter } from './rewriters/NestFieldOutputsRewriter';
export {
default as ScalarFieldToObjectFieldRewriter
} from './rewriters/ScalarFieldToObjectFieldRewriter';
60 changes: 60 additions & 0 deletions src/rewriters/ScalarFieldToObjectFieldRewriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ASTNode, FieldNode, SelectionSetNode } from 'graphql';
import { NodeAndVarDefs } from '../ast';
import Rewriter, { RewriterOpts } from './Rewriter';

interface ScalarFieldToObjectFieldRewriterOpts extends RewriterOpts {
objectFieldName: string;
}

/**
* Rewriter which nests output fields inside of a new output object
* ex: change from `field { subField }` to `field { subField { objectfield } }`
*/
class ScalarFieldToObjectFieldRewriter extends Rewriter {
protected objectFieldName: string;

constructor(options: ScalarFieldToObjectFieldRewriterOpts) {
super(options);
this.objectFieldName = options.objectFieldName;
}

public matches(nodeAndVars: NodeAndVarDefs, parents: ASTNode[]): boolean {
if (!super.matches(nodeAndVars, parents)) return false;
const node = nodeAndVars.node as FieldNode;
// make sure there's no subselections on this field
if (node.selectionSet) return false;
return true;
}

public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs) {
const node = nodeAndVarDefs.node as FieldNode;
const { variableDefinitions } = nodeAndVarDefs;
// if there's a subselection already, just return
if (node.selectionSet) return nodeAndVarDefs;

const selectionSet: SelectionSetNode = {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: this.objectFieldName }
}
]
};

return {
variableDefinitions,
node: { ...node, selectionSet }
} as NodeAndVarDefs;
}

public rewriteResponse(response: any) {
if (typeof response === 'object') {
// undo the nesting in the response so it matches the original query
return response[this.objectFieldName];
}
return response;
}
}

export default ScalarFieldToObjectFieldRewriter;
6 changes: 6 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,8 @@
/** @hidden */
export const identifyFunc = <T>(val: T) => val;

/** @hidden */
export const pushToArrayAtKey = <T>(mapping: { [key: string]: T[] }, key: string, val: T): void => {
if (!mapping[key]) mapping[key] = [];
mapping[key].push(val);
};
Loading

0 comments on commit aee2dac

Please sign in to comment.