Skip to content

Commit 9dbcdea

Browse files
authored
feat: Extend FieldArgTypeCoercion (#50)
* enhances for argtype coercion * Adds unit tests for new functionalities * fixes linting issues
1 parent e27c442 commit 9dbcdea

File tree

4 files changed

+270
-27
lines changed

4 files changed

+270
-27
lines changed

src/RewriteHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export default class RewriteHandler {
3939
const isMatch = rewriter.matches(nodeAndVars, parents);
4040
if (isMatch) {
4141
rewrittenVariables = rewriter.rewriteVariables(rewrittenNodeAndVars, rewrittenVariables);
42-
rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars);
42+
rewrittenNodeAndVars = rewriter.rewriteQuery(rewrittenNodeAndVars, rewrittenVariables);
4343
const simplePath = extractPath([...parents, rewrittenNodeAndVars.node]);
4444
let paths: ReadonlyArray<ReadonlyArray<string>> = [simplePath];
4545
const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as

src/rewriters/FieldArgTypeRewriter.ts

Lines changed: 111 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import { ArgumentNode, ASTNode, FieldNode, parseType, TypeNode, VariableNode } from 'graphql';
1+
import {
2+
ArgumentNode,
3+
ASTNode,
4+
FieldNode,
5+
isValueNode,
6+
Kind,
7+
parseType,
8+
TypeNode,
9+
ValueNode,
10+
VariableNode
11+
} from 'graphql';
12+
import Maybe from 'graphql/tsutils/Maybe';
213
import { NodeAndVarDefs, nodesMatch } from '../ast';
314
import { identifyFunc } from '../utils';
415
import Rewriter, { RewriterOpts, Variables } from './Rewriter';
@@ -7,7 +18,17 @@ interface FieldArgTypeRewriterOpts extends RewriterOpts {
718
argName: string;
819
oldType: string;
920
newType: string;
10-
coerceVariable?: (variable: any) => any;
21+
coerceVariable?: (variable: any, context: { variables: Variables; args: ArgumentNode[] }) => any;
22+
/**
23+
* EXPERIMENTAL:
24+
* This allows to coerce value of argument when their value is not stored in a variable
25+
* but comes in the query node itself.
26+
* NOTE: At the moment, the user has to return the ast value node herself.
27+
*/
28+
coerceArgumentValue?: (
29+
variable: any,
30+
context: { variables: Variables; args: ArgumentNode[] }
31+
) => Maybe<ValueNode>;
1132
}
1233

1334
/**
@@ -18,14 +39,27 @@ class FieldArgTypeRewriter extends Rewriter {
1839
protected argName: string;
1940
protected oldTypeNode: TypeNode;
2041
protected newTypeNode: TypeNode;
21-
protected coerceVariable: (variable: any) => any;
42+
// Passes context with rest of arguments and variables.
43+
// Quite useful for variable coercion that depends on other arguments/variables
44+
// (e.g., [offset, limit] to [pageSize, pageNumber] coercion)
45+
protected coerceVariable: (
46+
variable: any,
47+
context: { variables: Variables; args: ArgumentNode[] }
48+
) => any;
49+
// (Experimental): Used to coerce arguments whose value
50+
// does not come in a variable.
51+
protected coerceArgumentValue: (
52+
variable: any,
53+
context: { variables: Variables; args: ArgumentNode[] }
54+
) => Maybe<ValueNode>;
2255

2356
constructor(options: FieldArgTypeRewriterOpts) {
2457
super(options);
2558
this.argName = options.argName;
2659
this.oldTypeNode = parseType(options.oldType);
2760
this.newTypeNode = parseType(options.newType);
2861
this.coerceVariable = options.coerceVariable || identifyFunc;
62+
this.coerceArgumentValue = options.coerceArgumentValue || identifyFunc;
2963
}
3064

3165
public matches(nodeAndVars: NodeAndVarDefs, parents: ASTNode[]) {
@@ -34,39 +68,94 @@ class FieldArgTypeRewriter extends Rewriter {
3468
const { variableDefinitions } = nodeAndVars;
3569
// is this a field with the correct fieldName and arguments?
3670
if (node.kind !== 'Field') return false;
37-
if (node.name.value !== this.fieldName || !node.arguments) return false;
71+
72+
// does this field contain arguments?
73+
if (!node.arguments) return false;
74+
3875
// is there an argument with the correct name and type in a variable?
3976
const matchingArgument = node.arguments.find(arg => arg.name.value === this.argName);
40-
if (!matchingArgument || matchingArgument.value.kind !== 'Variable') return false;
41-
const varRef = matchingArgument.value.name.value;
4277

43-
// does the referenced variable have the correct type?
44-
for (const varDefinition of variableDefinitions) {
45-
if (varDefinition.variable.name.value === varRef) {
46-
return nodesMatch(this.oldTypeNode, varDefinition.type);
78+
if (!matchingArgument) return false;
79+
80+
// argument value is stored in a variable
81+
if (matchingArgument.value.kind === 'Variable') {
82+
const varRef = matchingArgument.value.name.value;
83+
// does the referenced variable have the correct type?
84+
for (const varDefinition of variableDefinitions) {
85+
if (varDefinition.variable.name.value === varRef) {
86+
return nodesMatch(this.oldTypeNode, varDefinition.type);
87+
}
4788
}
4889
}
90+
// argument value comes in query doc.
91+
else {
92+
const argValueNode = matchingArgument.value;
93+
return isValueNode(argValueNode);
94+
// Would be ideal to do a nodesMatch in here, however argument value nodes
95+
// have different format for their values than when passed as variables.
96+
// For instance, are parsed with Kinds as "graphql.Kind" (e.g., INT="IntValue") and not "graphql.TokenKinds" (e.g., INT="Int")
97+
// So they might not match correctly. Also they dont contain additional parsed syntax
98+
// as the non-optional symbol "!". So just return true if the argument.value is a ValueNode.
99+
//
100+
// return nodesMatch(this.oldTypeNode, parseType(argRef.kind));
101+
}
102+
49103
return false;
50104
}
51105

52-
public rewriteQuery({ node, variableDefinitions }: NodeAndVarDefs) {
53-
const varRefName = this.extractMatchingVarRefName(node as FieldNode);
54-
const newVarDefs = variableDefinitions.map(varDef => {
55-
if (varDef.variable.name.value !== varRefName) return varDef;
56-
return { ...varDef, type: this.newTypeNode };
57-
});
58-
return { node, variableDefinitions: newVarDefs };
106+
public rewriteQuery(
107+
{ node: astNode, variableDefinitions }: NodeAndVarDefs,
108+
variables: Variables
109+
) {
110+
const node = astNode as FieldNode;
111+
const varRefName = this.extractMatchingVarRefName(node);
112+
// If argument value is stored in a variable
113+
if (varRefName) {
114+
const newVarDefs = variableDefinitions.map(varDef => {
115+
if (varDef.variable.name.value !== varRefName) return varDef;
116+
return { ...varDef, type: this.newTypeNode };
117+
});
118+
return { node, variableDefinitions: newVarDefs };
119+
}
120+
// If argument value is not stored in a variable but in the query node.
121+
const matchingArgument = (node.arguments || []).find(arg => arg.name.value === this.argName);
122+
if (node.arguments && matchingArgument) {
123+
const args = [...node.arguments];
124+
const newValue = this.coerceArgumentValue(matchingArgument.value, { variables, args });
125+
/**
126+
* TODO: If somewhow we can get the schema here, we could make the coerceArgumentValue
127+
* even easier, as we would be able to construct the ast node for the argument value.
128+
* as of now, the user has to take care of correctly constructing the argument value ast node herself.
129+
*
130+
* const schema = makeExecutableSchema({typeDefs})
131+
* const myCustomType = schema.getType("MY_CUSTOM_TYPE_NAME")
132+
* const newArgValue = astFromValue(newValue, myCustomType)
133+
* Object.assign(matchingArgument, { value: newArgValue })
134+
*/
135+
if (newValue) Object.assign(matchingArgument, { value: newValue });
136+
}
137+
return { node, variableDefinitions };
59138
}
60139

61-
public rewriteVariables({ node }: NodeAndVarDefs, variables: Variables) {
140+
public rewriteVariables({ node: astNode }: NodeAndVarDefs, variables: Variables) {
141+
const node = astNode as FieldNode;
62142
if (!variables) return variables;
63-
const varRefName = this.extractMatchingVarRefName(node as FieldNode);
64-
return { ...variables, [varRefName]: this.coerceVariable(variables[varRefName]) };
143+
const varRefName = this.extractMatchingVarRefName(node);
144+
const args = [...(node.arguments ? node.arguments : [])];
145+
return {
146+
...variables,
147+
...(varRefName
148+
? { [varRefName]: this.coerceVariable(variables[varRefName], { variables, args }) }
149+
: {})
150+
};
65151
}
66152

67153
private extractMatchingVarRefName(node: FieldNode) {
68-
const matchingArgument = (node.arguments || []).find(arg => arg.name.value === this.argName);
69-
return ((matchingArgument as ArgumentNode).value as VariableNode).name.value;
154+
const matchingArgument = (node.arguments || []).find(
155+
arg => arg.name.value === this.argName
156+
) as ArgumentNode;
157+
const variableNode = matchingArgument.value as VariableNode;
158+
return variableNode.kind === Kind.VARIABLE && variableNode.name.value;
70159
}
71160
}
72161

src/rewriters/Rewriter.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export type Variables = { [key: string]: any } | undefined;
66
export type RootType = 'query' | 'mutation' | 'fragment';
77

88
export interface RewriterOpts {
9-
fieldName: string;
9+
fieldName?: string;
1010
rootTypes?: RootType[];
1111
matchConditions?: matchCondition[];
1212
}
@@ -16,19 +16,33 @@ export interface RewriterOpts {
1616
* Extend this class and overwrite its methods to create a new rewriter
1717
*/
1818
abstract class Rewriter {
19-
protected fieldName: string;
2019
protected rootTypes: RootType[] = ['query', 'mutation', 'fragment'];
20+
protected fieldName?: string;
2121
protected matchConditions?: matchCondition[];
2222

2323
constructor({ fieldName, rootTypes, matchConditions }: RewriterOpts) {
2424
this.fieldName = fieldName;
2525
this.matchConditions = matchConditions;
26+
if (!this.fieldName && !this.matchConditions) {
27+
throw new Error(
28+
'Neither a fieldName or matchConditions were provided. Please choose to pass either one in order to be able to detect which fields to rewrite.'
29+
);
30+
}
2631
if (rootTypes) this.rootTypes = rootTypes;
2732
}
2833

2934
public matches(nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray<ASTNode>): boolean {
3035
const { node } = nodeAndVarDefs;
31-
if (node.kind !== 'Field' || node.name.value !== this.fieldName) return false;
36+
37+
// If no fieldName is provided, check for defined matchConditions.
38+
// This avoids having to define one rewriter for many fields individually.
39+
// Alternatively, regex matching for fieldName could be implemented.
40+
if (
41+
node.kind !== 'Field' ||
42+
(this.fieldName ? node.name.value !== this.fieldName : !this.matchConditions)
43+
) {
44+
return false;
45+
}
3246
const root = parents[0];
3347
if (
3448
root.kind === 'OperationDefinition' &&
@@ -48,7 +62,7 @@ abstract class Rewriter {
4862
return true;
4963
}
5064

51-
public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs): NodeAndVarDefs {
65+
public rewriteQuery(nodeAndVarDefs: NodeAndVarDefs, variables: Variables): NodeAndVarDefs {
5266
return nodeAndVarDefs;
5367
}
5468

test/functional/rewriteFieldArgType.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { FieldNode, astFromValue, GraphQLInt, Kind } from 'graphql';
12
import RewriteHandler from '../../src/RewriteHandler';
23
import FieldArgTypeRewriter from '../../src/rewriters/FieldArgTypeRewriter';
34
import { gqlFmt } from '../testUtils';
@@ -98,6 +99,145 @@ describe('Rewrite field arg type', () => {
9899
});
99100
});
100101

102+
it('variable coercion comes with additional variables and arguments as context.', () => {
103+
const query = gqlFmt`
104+
query doTheThings($arg1: String!, $arg2: String) {
105+
things(identifier: $arg1, arg2: $arg2, arg3: "blah") {
106+
cat
107+
}
108+
}
109+
`;
110+
const expectedRewritenQuery = gqlFmt`
111+
query doTheThings($arg1: Int!, $arg2: String) {
112+
things(identifier: $arg1, arg2: $arg2, arg3: "blah") {
113+
cat
114+
}
115+
}
116+
`;
117+
118+
const handler = new RewriteHandler([
119+
new FieldArgTypeRewriter({
120+
fieldName: 'things',
121+
argName: 'identifier',
122+
oldType: 'String!',
123+
newType: 'Int!',
124+
coerceVariable: (_, { variables = {}, args }) => {
125+
expect(args.length).toBe(3);
126+
expect(args[0].kind).toBe('Argument');
127+
expect(args[0].value.kind).toBe(Kind.VARIABLE);
128+
expect(args[1].kind).toBe('Argument');
129+
expect(args[1].value.kind).toBe(Kind.VARIABLE);
130+
expect(args[2].kind).toBe('Argument');
131+
expect(args[2].value.kind).toBe(Kind.STRING);
132+
const { arg2 = 0 } = variables;
133+
return parseInt(arg2, 10);
134+
}
135+
})
136+
]);
137+
expect(handler.rewriteRequest(query, { arg1: 'someString', arg2: '123' })).toEqual({
138+
query: expectedRewritenQuery,
139+
variables: {
140+
arg1: 123,
141+
arg2: '123'
142+
}
143+
});
144+
});
145+
146+
it('can be passed a coerceArgumentValue function to change argument values.', () => {
147+
const query = gqlFmt`
148+
query doTheThings {
149+
things(identifier: "123", arg2: "blah") {
150+
cat
151+
}
152+
}
153+
`;
154+
const expectedRewritenQuery = gqlFmt`
155+
query doTheThings {
156+
things(identifier: 123, arg2: "blah") {
157+
cat
158+
}
159+
}
160+
`;
161+
162+
const handler = new RewriteHandler([
163+
new FieldArgTypeRewriter({
164+
fieldName: 'things',
165+
argName: 'identifier',
166+
oldType: 'String!',
167+
newType: 'Int!',
168+
coerceArgumentValue: argValue => {
169+
const value = argValue.value;
170+
const newArgValue = astFromValue(parseInt(value, 10), GraphQLInt);
171+
return newArgValue;
172+
}
173+
})
174+
]);
175+
176+
expect(handler.rewriteRequest(query)).toEqual({
177+
query: expectedRewritenQuery
178+
});
179+
});
180+
181+
it('should fail if neither a fieldName or matchConditions are provided', () => {
182+
try {
183+
new FieldArgTypeRewriter({
184+
argName: 'identifier',
185+
oldType: 'String!',
186+
newType: 'Int!'
187+
});
188+
} catch (error) {
189+
console.log(error.message);
190+
expect(
191+
error.message.includes('Neither a fieldName or matchConditions were provided')
192+
).toEqual(true);
193+
}
194+
});
195+
196+
it('allows matching using matchConditions when fieldName is not provided.', () => {
197+
const query = gqlFmt`
198+
query doTheThings($arg1: String!, $arg2: String) {
199+
things(identifier: $arg1, arg2: $arg2) {
200+
cat
201+
}
202+
}
203+
`;
204+
const expectedRewritenQuery = gqlFmt`
205+
query doTheThings($arg1: Int!, $arg2: String) {
206+
things(identifier: $arg1, arg2: $arg2) {
207+
cat
208+
}
209+
}
210+
`;
211+
212+
// Tests a dummy regex to match the "things" field.
213+
const fieldNameRegExp = '.hings';
214+
215+
const handler = new RewriteHandler([
216+
new FieldArgTypeRewriter({
217+
argName: 'identifier',
218+
oldType: 'String!',
219+
newType: 'Int!',
220+
matchConditions: [
221+
nodeAndVars => {
222+
const node = nodeAndVars.node as FieldNode;
223+
const {
224+
name: { value: fieldName }
225+
} = node;
226+
return fieldName.search(new RegExp(fieldNameRegExp)) !== -1;
227+
}
228+
],
229+
coerceVariable: val => parseInt(val, 10)
230+
})
231+
]);
232+
expect(handler.rewriteRequest(query, { arg1: '123', arg2: 'blah' })).toEqual({
233+
query: expectedRewritenQuery,
234+
variables: {
235+
arg1: 123,
236+
arg2: 'blah'
237+
}
238+
});
239+
});
240+
101241
it('works on deeply nested fields', () => {
102242
const query = gqlFmt`
103243
query doTheThings($arg1: String!, $arg2: String) {

0 commit comments

Comments
 (0)