Skip to content

Commit aee2dac

Browse files
authored
feat: ScalarFieldToObjectFieldRewriter (#7)
* wip * adding ability to rewrite nested fragment outputs properly * fixing linting * exporting ScalarFieldToObjectFieldRewriter properly * updating readme
1 parent 36a5025 commit aee2dac

File tree

9 files changed

+590
-11
lines changed

9 files changed

+590
-11
lines changed

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,69 @@ mutation createUser($username: String!, $password: String!) {
232232
}
233233
```
234234
235+
### ScalarFieldToObjectFieldRewriter
236+
237+
`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:
238+
239+
```
240+
type User {
241+
id: ID!
242+
full_name: String!
243+
...
244+
}
245+
```
246+
247+
and we want to change it to
248+
249+
```
250+
type User {
251+
id: ID!
252+
full_name: {
253+
default: String!
254+
en: String
255+
cn: String
256+
...
257+
}
258+
...
259+
}
260+
```
261+
262+
we can make this change with the following rewriter:
263+
264+
```js
265+
import { ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter';
266+
267+
// add this to the rewriters array in graphqlRewriterMiddleware(...)
268+
const rewriter = new ScalarFieldToObjectFieldRewriter({
269+
fieldName: 'full_name',
270+
objectFieldName: 'default',
271+
})
272+
```
273+
274+
For example, This would rewrite the following query:
275+
276+
```
277+
query getUser(id: ID!) {
278+
user {
279+
id
280+
full_name
281+
}
282+
}
283+
```
284+
285+
and turn it into:
286+
287+
```
288+
query getUser(id: ID!) {
289+
user {
290+
id
291+
full_name {
292+
default
293+
}
294+
}
295+
}
296+
```
297+
235298
### NestFieldOutputsRewriter
236299
237300
`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:

src/RewriteHandler.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { parse, print } from 'graphql';
2-
import { extractPath, rewriteDoc, rewriteResultsAtPath } from './ast';
1+
import { FragmentDefinitionNode, parse, print } from 'graphql';
2+
import { extractPath, FragmentTracer, rewriteDoc, rewriteResultsAtPath } from './ast';
33
import Rewriter, { Variables } from './rewriters/Rewriter';
44

55
interface RewriterMatch {
66
rewriter: Rewriter;
7-
path: ReadonlyArray<string>;
7+
paths: ReadonlyArray<ReadonlyArray<string>>;
88
}
99

1010
/**
@@ -30,6 +30,7 @@ export default class RewriteHandler {
3030
if (this.hasProcessedRequest) throw new Error('This handler has already rewritten a request');
3131
this.hasProcessedRequest = true;
3232
const doc = parse(query);
33+
const fragmentTracer = new FragmentTracer(doc);
3334
let rewrittenVariables = variables;
3435
const rewrittenDoc = rewriteDoc(doc, (nodeAndVars, parents) => {
3536
let rewrittenNodeAndVars = nodeAndVars;
@@ -38,9 +39,17 @@ export default class RewriteHandler {
3839
if (isMatch) {
3940
rewrittenVariables = rewriter.rewriteVariables(rewrittenNodeAndVars, rewrittenVariables);
4041
rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars);
42+
const simplePath = extractPath([...parents, rewrittenNodeAndVars.node]);
43+
let paths: ReadonlyArray<ReadonlyArray<string>> = [simplePath];
44+
const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as
45+
| FragmentDefinitionNode
46+
| undefined;
47+
if (fragmentDef) {
48+
paths = fragmentTracer.prependFragmentPaths(fragmentDef.name.value, simplePath);
49+
}
4150
this.matches.push({
4251
rewriter,
43-
path: extractPath([...parents, rewrittenNodeAndVars.node])
52+
paths
4453
});
4554
}
4655
return isMatch;
@@ -60,10 +69,12 @@ export default class RewriteHandler {
6069
if (this.hasProcessedResponse) throw new Error('This handler has already returned a response');
6170
this.hasProcessedResponse = true;
6271
let rewrittenResponse = response;
63-
this.matches.reverse().forEach(({ rewriter, path }) => {
64-
rewrittenResponse = rewriteResultsAtPath(rewrittenResponse, path, responseAtPath =>
65-
rewriter.rewriteResponse(responseAtPath)
66-
);
72+
this.matches.reverse().forEach(({ rewriter, paths }) => {
73+
paths.forEach(path => {
74+
rewrittenResponse = rewriteResultsAtPath(rewrittenResponse, path, responseAtPath =>
75+
rewriter.rewriteResponse(responseAtPath)
76+
);
77+
});
6778
});
6879
return rewrittenResponse;
6980
}

src/ast.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { ASTNode, DocumentNode, VariableDefinitionNode } from 'graphql';
1+
import { ASTNode, DocumentNode, FragmentDefinitionNode, VariableDefinitionNode } from 'graphql';
2+
import { pushToArrayAtKey } from './utils';
23

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

@@ -29,6 +30,135 @@ export interface NodeAndVarDefs {
2930
variableDefinitions: ReadonlyArray<VariableDefinitionNode>;
3031
}
3132

33+
/** @hidden */
34+
export interface FragmentPathMap {
35+
[fragmentName: string]: ReadonlyArray<ReadonlyArray<string>>;
36+
}
37+
38+
interface MutableFragmentPathMap {
39+
[fragmentName: string]: Array<ReadonlyArray<string>>;
40+
}
41+
42+
/** @hidden */
43+
export class FragmentTracer {
44+
private fragmentPathMap?: FragmentPathMap;
45+
private doc: DocumentNode;
46+
47+
constructor(doc: DocumentNode) {
48+
this.doc = doc;
49+
}
50+
51+
public getPathsToFragment(fragmentName: string): ReadonlyArray<ReadonlyArray<string>> {
52+
if (!this.fragmentPathMap) {
53+
this.fragmentPathMap = this.buildFragmentPathMap();
54+
}
55+
return this.fragmentPathMap[fragmentName] || [];
56+
}
57+
58+
// prepend the paths from the original document into this fragment to the inner fragment paths
59+
public prependFragmentPaths(
60+
fragmentName: string,
61+
pathWithinFragment: ReadonlyArray<string>
62+
): ReadonlyArray<ReadonlyArray<string>> {
63+
return this.getPathsToFragment(fragmentName).map(path => [...path, ...pathWithinFragment]);
64+
}
65+
66+
private getFragmentDefs(): ReadonlyArray<FragmentDefinitionNode> {
67+
return this.doc.definitions.filter(
68+
({ kind }) => kind === 'FragmentDefinition'
69+
) as FragmentDefinitionNode[];
70+
}
71+
72+
private getFragmentPartialPathMap(startNode: ASTNode): MutableFragmentPathMap {
73+
const partialPathMap: MutableFragmentPathMap = {};
74+
const recursivelyBuildFragmentPaths = (node: ASTNode, curParents: ReadonlyArray<ASTNode>) => {
75+
if (node.kind === 'FragmentSpread') {
76+
pushToArrayAtKey(partialPathMap, node.name.value, extractPath(curParents));
77+
}
78+
const nextParents = [...curParents, node];
79+
if ('selectionSet' in node && node.selectionSet) {
80+
for (const selection of node.selectionSet.selections) {
81+
recursivelyBuildFragmentPaths(selection, nextParents);
82+
}
83+
}
84+
};
85+
recursivelyBuildFragmentPaths(startNode, []);
86+
return partialPathMap;
87+
}
88+
89+
private mergeFragmentPaths(
90+
fragmentName: string,
91+
paths: Array<ReadonlyArray<string>>,
92+
fragmentPartialPathsMap: { [fragmentName: string]: FragmentPathMap }
93+
) {
94+
const mergedPaths: MutableFragmentPathMap = {};
95+
96+
const resursivelyBuildMergedPathsMap = (
97+
curFragmentName: string,
98+
curPaths: Array<ReadonlyArray<string>>,
99+
seenFragments: ReadonlySet<string>
100+
) => {
101+
// recursive fragments are invalid graphQL - just exit here. otherwise this will be an infinite loop
102+
if (seenFragments.has(curFragmentName)) return;
103+
const nextSeenFragments = new Set(seenFragments);
104+
nextSeenFragments.add(curFragmentName);
105+
const nextPartialPaths = fragmentPartialPathsMap[curFragmentName];
106+
// if there are not other fragments nested inside of this fragment, we're done
107+
if (!nextPartialPaths) return;
108+
109+
for (const [childFragmentName, childFragmentPaths] of Object.entries(nextPartialPaths)) {
110+
for (const path of curPaths) {
111+
const mergedChildPaths: Array<ReadonlyArray<string>> = [];
112+
for (const childPath of childFragmentPaths) {
113+
const mergedPath = [...path, ...childPath];
114+
mergedChildPaths.push(mergedPath);
115+
pushToArrayAtKey(mergedPaths, childFragmentName, mergedPath);
116+
}
117+
resursivelyBuildMergedPathsMap(childFragmentName, mergedChildPaths, nextSeenFragments);
118+
}
119+
}
120+
};
121+
122+
resursivelyBuildMergedPathsMap(fragmentName, paths, new Set());
123+
return mergedPaths;
124+
}
125+
126+
private buildFragmentPathMap(): FragmentPathMap {
127+
const mainOperation = this.doc.definitions.find(node => node.kind === 'OperationDefinition');
128+
if (!mainOperation) return {};
129+
130+
// partial paths are the paths inside of each fragmnt to other fragments
131+
const fragmentPartialPathsMap: { [fragmentName: string]: FragmentPathMap } = {};
132+
for (const fragmentDef of this.getFragmentDefs()) {
133+
fragmentPartialPathsMap[fragmentDef.name.value] = this.getFragmentPartialPathMap(fragmentDef);
134+
}
135+
136+
// start with the direct paths to fragments inside of the main operation
137+
const simpleFragmentPathMap: MutableFragmentPathMap = this.getFragmentPartialPathMap(
138+
mainOperation
139+
);
140+
const fragmentPathMap: MutableFragmentPathMap = { ...simpleFragmentPathMap };
141+
// next, we'll recursively trace the partials into their subpartials to fill out all possible paths to each fragment
142+
for (const [fragmentName, simplePaths] of Object.entries(simpleFragmentPathMap)) {
143+
const mergedFragmentPathsMap = this.mergeFragmentPaths(
144+
fragmentName,
145+
simplePaths,
146+
fragmentPartialPathsMap
147+
);
148+
for (const [mergedFragmentName, mergedFragmentPaths] of Object.entries(
149+
mergedFragmentPathsMap
150+
)) {
151+
fragmentPathMap[mergedFragmentName] = [
152+
...(fragmentPathMap[mergedFragmentName] || []),
153+
...mergedFragmentPaths
154+
];
155+
}
156+
}
157+
158+
return fragmentPathMap;
159+
}
160+
}
161+
32162
/**
33163
* Walk the document add rewrite nodes along the way
34164
* @param doc

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ export { default as FieldArgNameRewriter } from './rewriters/FieldArgNameRewrite
44
export { default as FieldArgsToInputTypeRewriter } from './rewriters/FieldArgsToInputTypeRewriter';
55
export { default as FieldArgTypeRewriter } from './rewriters/FieldArgTypeRewriter';
66
export { default as NestFieldOutputsRewriter } from './rewriters/NestFieldOutputsRewriter';
7+
export {
8+
default as ScalarFieldToObjectFieldRewriter
9+
} from './rewriters/ScalarFieldToObjectFieldRewriter';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ASTNode, FieldNode, SelectionSetNode } from 'graphql';
2+
import { NodeAndVarDefs } from '../ast';
3+
import Rewriter, { RewriterOpts } from './Rewriter';
4+
5+
interface ScalarFieldToObjectFieldRewriterOpts extends RewriterOpts {
6+
objectFieldName: string;
7+
}
8+
9+
/**
10+
* Rewriter which nests output fields inside of a new output object
11+
* ex: change from `field { subField }` to `field { subField { objectfield } }`
12+
*/
13+
class ScalarFieldToObjectFieldRewriter extends Rewriter {
14+
protected objectFieldName: string;
15+
16+
constructor(options: ScalarFieldToObjectFieldRewriterOpts) {
17+
super(options);
18+
this.objectFieldName = options.objectFieldName;
19+
}
20+
21+
public matches(nodeAndVars: NodeAndVarDefs, parents: ASTNode[]): boolean {
22+
if (!super.matches(nodeAndVars, parents)) return false;
23+
const node = nodeAndVars.node as FieldNode;
24+
// make sure there's no subselections on this field
25+
if (node.selectionSet) return false;
26+
return true;
27+
}
28+
29+
public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs) {
30+
const node = nodeAndVarDefs.node as FieldNode;
31+
const { variableDefinitions } = nodeAndVarDefs;
32+
// if there's a subselection already, just return
33+
if (node.selectionSet) return nodeAndVarDefs;
34+
35+
const selectionSet: SelectionSetNode = {
36+
kind: 'SelectionSet',
37+
selections: [
38+
{
39+
kind: 'Field',
40+
name: { kind: 'Name', value: this.objectFieldName }
41+
}
42+
]
43+
};
44+
45+
return {
46+
variableDefinitions,
47+
node: { ...node, selectionSet }
48+
} as NodeAndVarDefs;
49+
}
50+
51+
public rewriteResponse(response: any) {
52+
if (typeof response === 'object') {
53+
// undo the nesting in the response so it matches the original query
54+
return response[this.objectFieldName];
55+
}
56+
return response;
57+
}
58+
}
59+
60+
export default ScalarFieldToObjectFieldRewriter;

src/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
11
/** @hidden */
22
export const identifyFunc = <T>(val: T) => val;
3+
4+
/** @hidden */
5+
export const pushToArrayAtKey = <T>(mapping: { [key: string]: T[] }, key: string, val: T): void => {
6+
if (!mapping[key]) mapping[key] = [];
7+
mapping[key].push(val);
8+
};

0 commit comments

Comments
 (0)