Skip to content

Commit 975acc8

Browse files
authored
feat: adding matchConditions which can be used to further restrict matches (#8)
* adding which can be used to further restrict matching * adding docs on how to use match conditions * tweaking wording * tweaking wording
1 parent aee2dac commit 975acc8

14 files changed

+847
-33
lines changed

README.md

Lines changed: 120 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ Full API docs are available at https://ef-eng.github.io/graphql-query-rewriter
1313

1414
GraphQL is great at enforcing a strict schema for APIs, but its lack of versioning makes it extremely difficult to make changes to GraphQL schemas without breaking existing clients. For example, take the following query:
1515

16-
```
16+
```graphql
1717
query getUserById($id: String!) {
1818
userById(id: $id) {
1919
...
2020
}
2121
}
2222
```
23-
Oh no! We should have used `ID!` as the type for `userById(id)` instead of `String!`, but it's already in production! Now if we change our schema to use `ID!` instead of `String!` then our old clients will start getting the error `Variable "$id" of type "String!" used in position expecting type "ID!"`. Currently your only options are to continue using the incorrect `String!` type forever (*eeew*), or make a new query with a new name, like `userByIdNew(id: ID!)` (*gross*)!
23+
24+
Oh no! We should have used `ID!` as the type for `userById(id)` instead of `String!`, but it's already in production! Now if we change our schema to use `ID!` instead of `String!` then our old clients will start getting the error `Variable "$id" of type "String!" used in position expecting type "ID!"`. Currently your only options are to continue using the incorrect `String!` type forever (_eeew_), or make a new query with a new name, like `userByIdNew(id: ID!)` (_gross_)!
2425

2526
Wouldn't it be great if you could change the schema to use `ID!`, but just silently replace `String!` in old queries with `ID!` in your middleware so the old queries will continue to work just like they had been?
2627

@@ -56,7 +57,8 @@ app.use('/graphql', graphqlHTTP( ... ));
5657
```
5758

5859
Now, when old clients send the following query:
59-
```
60+
61+
```graphql
6062
query getUserById($id: String!) {
6163
userById(id: $id) {
6264
...
@@ -65,7 +67,8 @@ query getUserById($id: String!) {
6567
```
6668

6769
It will be rewritten before it gets processed to:
68-
```
70+
71+
```graphql
6972
query getUserById($id: ID!) {
7073
userById(id: $id) {
7174
...
@@ -75,7 +78,6 @@ query getUserById($id: ID!) {
7578

7679
Now your schema is clean and up to date, and deprecated clients keep working! GraphQL Schema Rewriter can rewrite much more complex queries than just changing a single input type as well.
7780

78-
7981
## Installation
8082

8183
Installation requires the base package `graphql-query-rewriter` and a middleware adapter for the web framework you use. Currently works with `express-graphql` and `apollo-server`.
@@ -88,7 +90,7 @@ npm install graphql-query-rewriter express-graphql-query-rewriter
8890

8991
#### For Apollo-server
9092

91-
Apollo server works with `express-graphql-query-rewriter` via [Apollo server middleware](https://www.apollographql.com/docs/apollo-server/migration-two-dot/#adding-additional-middleware-to-apollo-server-2).
93+
Apollo server works with `express-graphql-query-rewriter` via [Apollo server middleware](https://www.apollographql.com/docs/apollo-server/migration-two-dot/#adding-additional-middleware-to-apollo-server-2).
9294

9395
```
9496
npm install graphql-query-rewriter express-graphql express-graphql-query-rewriter
@@ -154,10 +156,10 @@ const rewriter = new FieldArgTypeRewriter({
154156
argName: 'arg1',
155157
oldType: 'Int',
156158
newType: 'Int!'
157-
})
159+
});
158160
```
159161
160-
Sometimes, you'll need to do some preprocessing on the variables submitted to the rewritten argument to make them into the type needed by the new schema. You can do this by passing in a `coerceVariable` function which returns a new value of the variable. For example, the following changes the value of `arg1` from `Int!` to `String!`, and also changes the value of `arg1` to a string as well:
162+
Sometimes, you'll need to do some preprocessing on the variables submitted to the rewritten argument to make them into the type needed by the new schema. You can do this by passing in a `coerceVariable` function which returns a new value of the variable. For example, the following changes the value of `arg1` from `Int!` to `String!`, and also changes the value of `arg1` to a string as well:
161163
162164
```js
163165
import { FieldArgTypeRewriter } from 'graphql-query-rewriter';
@@ -184,13 +186,14 @@ const rewriter = new FieldArgNameRewriter({
184186
fieldName: 'createUser',
185187
oldArgName: 'userID',
186188
newArgName: 'userId'
187-
})
189+
});
188190
```
189191
190192
### FieldArgsToInputTypeRewriter
191193
192194
`FieldArgsToInputTypeRewriter` can be used to move mutation parameters into a single input object, by default named `input`. It's a best-practice to use a single input type for mutations in GraphQL, 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(username: String!, password: String!)` to a mutation with a proper input type like:
193-
```
195+
196+
```graphql
194197
mutation createUser(input: CreateUserInput!) { ... }
195198

196199
type CreateUserInput {
@@ -209,12 +212,12 @@ const rewriter = new FieldArgsToInputTypeRewriter({
209212
fieldName: 'createUser',
210213
argNames: ['username', 'password'],
211214
inputArgName: 'input' // inputArgName can be left out to use 'input' by default
212-
})
215+
});
213216
```
214217
215218
For example, This would rewrite the following mutation:
216219
217-
```
220+
```graphql
218221
mutation createUser($username: String!, $password: String!) {
219222
createUser(username: $username, password: $password) {
220223
...
@@ -224,7 +227,7 @@ mutation createUser($username: String!, $password: String!) {
224227
225228
and turn it into:
226229
227-
```
230+
```graphql
228231
mutation createUser($username: String!, $password: String!) {
229232
createUser(input: { username: $username, password: $password }) {
230233
...
@@ -236,7 +239,7 @@ mutation createUser($username: String!, $password: String!) {
236239
237240
`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:
238241
239-
```
242+
```graphql
240243
type User {
241244
id: ID!
242245
full_name: String!
@@ -246,7 +249,7 @@ type User {
246249
247250
and we want to change it to
248251
249-
```
252+
```graphql
250253
type User {
251254
id: ID!
252255
full_name: {
@@ -267,13 +270,13 @@ import { ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter';
267270
// add this to the rewriters array in graphqlRewriterMiddleware(...)
268271
const rewriter = new ScalarFieldToObjectFieldRewriter({
269272
fieldName: 'full_name',
270-
objectFieldName: 'default',
271-
})
273+
objectFieldName: 'default'
274+
});
272275
```
273276
274277
For example, This would rewrite the following query:
275278
276-
```
279+
```graphql
277280
query getUser(id: ID!) {
278281
user {
279282
id
@@ -284,7 +287,7 @@ query getUser(id: ID!) {
284287
285288
and turn it into:
286289
287-
```
290+
```graphql
288291
query getUser(id: ID!) {
289292
user {
290293
id
@@ -299,7 +302,7 @@ query getUser(id: ID!) {
299302
300303
`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:
301304
302-
```
305+
```graphql
303306
mutation createUser(input: CreateUserInput!) CreateUserPayload
304307

305308
type User {
@@ -322,12 +325,12 @@ const rewriter = new NestFieldOutputsRewriter({
322325
fieldName: 'createUser',
323326
newOutputName: 'user',
324327
outputsToNest: ['id', 'username']
325-
})
328+
});
326329
```
327330
328331
For example, This would rewrite the following mutation:
329332
330-
```
333+
```graphql
331334
mutation createUser(input: CreateUserInput!) {
332335
createUser(input: $input) {
333336
id
@@ -338,7 +341,7 @@ mutation createUser(input: CreateUserInput!) {
338341
339342
and turn it into:
340343
341-
```
344+
```graphql
342345
mutation createUser(input: CreateUserInput!) {
343346
createUser(input: $input) {
344347
user {
@@ -349,6 +352,100 @@ mutation createUser(input: CreateUserInput!) {
349352
}
350353
```
351354
355+
## Restricting Matches Further
356+
357+
Sometimes you need more control over which fields get rewritten to avoid accidentally rewriting fields which happen to have the same name in an unrelated query. This can be accomplished by providing a list of `matchConditions` to the `RewriteHandler`. There are 3 built-in match condition helpers you can use to make this easier, specifically `fragmentMatchCondition`, `queryMatchCondition`, and `mutationMatchCondition`. If any of the conditions passed in to `matchConditions` match, then the rewriter will proceed as normal.
358+
359+
For example, to restrict matches to only to the `title` field of fragments named `thingFragment`, on type `Thing`, we could use the following `matchConditions`:
360+
361+
```js
362+
import { fragmentMatchCondition, ScalarFieldToObjectFieldRewriter } from 'graphql-query-rewriter';
363+
364+
const rewriter = new ScalarFieldToObjectFieldRewriter({
365+
fieldName: 'title',
366+
objectFieldName: 'text',
367+
matchConditions: [
368+
fragmentMatchCondition({
369+
fragmentNames: ['thingFragment'],
370+
fragmentTypes: ['Thing']
371+
})
372+
]
373+
});
374+
```
375+
376+
Then, this will rewrite the following query as follows:
377+
378+
```graphql
379+
query {
380+
articles {
381+
title # <- This will not get rewritten, it doesn't match the matchConditions
382+
things {
383+
...thingFragment
384+
}
385+
}
386+
}
387+
388+
fragment thingFragment on Thing {
389+
id
390+
title # <- This will be rewritten, because it matches the matchConditions
391+
}
392+
```
393+
394+
You can also pass a `pathRegexes` array of regexes to `fragmentMatchCondition` if you'd like to restrict the path to the object field within the fragment that you'd like to rewrite. For example:
395+
396+
```js
397+
const rewriter = new ScalarFieldToObjectFieldRewriter({
398+
fieldName: 'title',
399+
objectFieldName: 'text',
400+
matchConditions: [
401+
fragmentMatchCondition({
402+
// rewrite only at exatly path innerThing.title
403+
pathRegexes: [/^innerThing.title$/]
404+
})
405+
]
406+
});
407+
```
408+
409+
Then, this will rewrite the query below as follows:
410+
411+
```graphql
412+
query {
413+
things {
414+
...parentThingFragment
415+
}
416+
}
417+
418+
fragment parentThingFragment on Thing {
419+
id
420+
title # <- not rewritten, it's not at the correct path
421+
innerThing {
422+
title # <- This will be rewritten, it's at path innerThing.title
423+
}
424+
}
425+
```
426+
427+
There are also `queryMatchCondition` and `mutationMatchCondition`. These work similarly to `fragmentMatchCondition`, except they match only fields directly inside of a query or a mutation, respectively.
428+
All of these matches take `pathRegexes` to search for matching paths, but `queryMatchCondition` can also take `queryNames`, to match only named queries, and likewise `mutationMatchCondition` can take `mutationNames` to match named mutations.
429+
430+
If there are multiple `matchConditions` provided, then if any of the conditions match then the rewriter will continue as normal. For example:
431+
432+
```js
433+
const rewriter = new ScalarFieldToObjectFieldRewriter({
434+
fieldName: 'title',
435+
objectFieldName: 'text',
436+
matchConditions: [
437+
fragmentMatchCondition({
438+
fragmentNames: ['thingFragment']
439+
}),
440+
queryMatchCondition({
441+
queryNames: ['getThing', 'getOtherThing']
442+
})
443+
]
444+
});
445+
```
446+
447+
The above rewriter will only match on fragments named `thingFragment`, or queries named `getThing` or `getOtherThing`.
448+
352449
## Current Limitations
353450
354451
Currently GraphQL Query Rewriter can only work with a single operation per query, and cannot properly handle aliased fields. These limitations should hopefully be fixed soon. Contributions are welcome!

src/index.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
11
export { default as RewriteHandler } from './RewriteHandler';
2-
export { default as Rewriter } from './rewriters/Rewriter';
3-
export { default as FieldArgNameRewriter } from './rewriters/FieldArgNameRewriter';
4-
export { default as FieldArgsToInputTypeRewriter } from './rewriters/FieldArgsToInputTypeRewriter';
5-
export { default as FieldArgTypeRewriter } from './rewriters/FieldArgTypeRewriter';
6-
export { default as NestFieldOutputsRewriter } from './rewriters/NestFieldOutputsRewriter';
7-
export {
8-
default as ScalarFieldToObjectFieldRewriter
9-
} from './rewriters/ScalarFieldToObjectFieldRewriter';
2+
export * from './rewriters';
3+
export * from './matchConditions';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { FragmentDefinitionNode } from 'graphql';
2+
import { extractPath } from '../ast';
3+
import matchCondition from './matchCondition';
4+
export interface FragmentMatchConditionOpts {
5+
fragmentNames?: string[];
6+
fragmentTypes?: string[];
7+
pathRegexes?: RegExp[];
8+
}
9+
10+
export default ({
11+
fragmentNames,
12+
fragmentTypes,
13+
pathRegexes
14+
}: FragmentMatchConditionOpts = {}): matchCondition => {
15+
return ({ node }, parents) => {
16+
const fragmentDef = parents.find(({ kind }) => kind === 'FragmentDefinition') as
17+
| FragmentDefinitionNode
18+
| undefined;
19+
if (!fragmentDef) return false;
20+
21+
if (fragmentNames && !fragmentNames.includes(fragmentDef.name.value)) {
22+
return false;
23+
}
24+
25+
if (fragmentTypes && !fragmentTypes.includes(fragmentDef.typeCondition.name.value)) {
26+
return false;
27+
}
28+
29+
if (pathRegexes) {
30+
const pathStr = extractPath([...parents, node]).join('.');
31+
if (!pathRegexes.find(pathRegex => pathRegex.test(pathStr))) {
32+
return false;
33+
}
34+
}
35+
36+
return true;
37+
};
38+
};

src/matchConditions/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export { default as matchCondition } from './matchCondition';
2+
export {
3+
default as fragmentMatchCondition,
4+
FragmentMatchConditionOpts
5+
} from './fragmentMatchCondition';
6+
export { default as queryMatchCondition, QueryMatchConditionOpts } from './queryMatchCondition';
7+
export {
8+
default as mutationMatchCondition,
9+
MutationMatchConditionOpts
10+
} from './mutationMatchCondition';

src/matchConditions/matchCondition.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { ASTNode } from 'graphql';
2+
import { NodeAndVarDefs } from '../ast';
3+
4+
type matchCondition = (nodeAndVarDefs: NodeAndVarDefs, parents: ReadonlyArray<ASTNode>) => boolean;
5+
6+
export default matchCondition;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import matchCondition from './matchCondition';
2+
import operationMatchCondition from './operationMatchCondition';
3+
export interface MutationMatchConditionOpts {
4+
mutationNames?: string[];
5+
pathRegexes?: RegExp[];
6+
}
7+
8+
export default ({
9+
mutationNames,
10+
pathRegexes
11+
}: MutationMatchConditionOpts = {}): matchCondition => {
12+
return operationMatchCondition({
13+
pathRegexes,
14+
operationNames: mutationNames,
15+
operationTypes: ['mutation']
16+
});
17+
};

0 commit comments

Comments
 (0)