Skip to content

Commit 5b66c76

Browse files
Fragment arguments parser (#252)
* wip: implement parser extensions for transforming the fragment argument transform syntax into operations without fragment arguments, which are executable by all graphql.js versions See graphql/graphql-js#3152 for reference * fixes Co-authored-by: Dotan Simha <[email protected]>
1 parent 5a616ec commit 5b66c76

File tree

9 files changed

+331
-0
lines changed

9 files changed

+331
-0
lines changed

.changeset/tall-coins-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@envelop/fragment-arguments': patch
3+
---
4+
5+
NEW PLUGIN!

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ We provide a few built-in plugins within the `@envelop/core`, and many more plug
133133
| usePersistedOperations | [`@envelop/persisted-operations`](./packages/plugins/persisted-operations) | Simple implementation of persisted operations/queries, based on custom store. |
134134
| useNewRelic | [`@envelop/newrelic`](./packages/plugins/newrelic) | Instrument your GraphQL application with New Relic reporting. |
135135
| useLiveQuery | [`@envelop/live-query`](./packages/plugins/live-query) | The easiest way of adding live queries to your GraphQL server! |
136+
| useFragmentArguments | [`@envelop/fragment-arguments`](./packages/plugins/fragment-arguments) | Adds support for using arguments on fragments |
136137

137138
## Sharing / Composing `envelop`s
138139

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@envelop/fragment-arguments",
3+
"version": "0.0.1",
4+
"author": "Dotan Simha <[email protected]>",
5+
"license": "MIT",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/dotansimha/envelop.git",
9+
"directory": "packages/plugins/fragment-arguments"
10+
},
11+
"main": "dist/index.js",
12+
"module": "dist/index.mjs",
13+
"exports": {
14+
".": {
15+
"require": "./dist/index.js",
16+
"import": "./dist/index.mjs"
17+
},
18+
"./*": {
19+
"require": "./dist/*.js",
20+
"import": "./dist/*.mjs"
21+
}
22+
},
23+
"typings": "dist/index.d.ts",
24+
"typescript": {
25+
"definition": "dist/index.d.ts"
26+
},
27+
"scripts": {
28+
"test": "jest",
29+
"prepack": "bob prepack"
30+
},
31+
"dependencies": {
32+
"@envelop/types": "0.2.1"
33+
},
34+
"devDependencies": {
35+
"@types/common-tags": "1.8.0",
36+
"@graphql-tools/utils": "7.10.0",
37+
"@graphql-tools/schema": "7.1.5",
38+
"bob-the-bundler": "1.4.1",
39+
"graphql": "15.5.1",
40+
"typescript": "4.3.4",
41+
"oneline": "1.0.3",
42+
"common-tags": "1.8.0"
43+
},
44+
"peerDependencies": {
45+
"graphql": "^14.0.0 || ^15.0.0"
46+
},
47+
"buildOptions": {
48+
"input": "./src/index.ts"
49+
},
50+
"publishConfig": {
51+
"directory": "dist",
52+
"access": "public"
53+
}
54+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Parser } from 'graphql/language/parser';
2+
import { Lexer } from 'graphql/language/lexer';
3+
import { TokenKind, Kind, Source, DocumentNode, TokenKindEnum, Token } from 'graphql';
4+
5+
declare module 'graphql/language/parser' {
6+
export class Parser {
7+
constructor(source: string | Source, options?: ParseOptions);
8+
_lexer: Lexer;
9+
expectOptionalKeyword(word: string): boolean;
10+
expectToken(token: TokenKindEnum): void;
11+
peek(token: TokenKindEnum): boolean;
12+
parseFragmentName(): string;
13+
parseArguments(flag: boolean): any;
14+
parseDirectives(flag: boolean): any;
15+
loc(start: Token): any;
16+
parseNamedType(): any;
17+
parseSelectionSet(): any;
18+
expectKeyword(keyword: string): void;
19+
parseVariableDefinitions(): void;
20+
parseDocument(): DocumentNode;
21+
}
22+
}
23+
24+
export class FragmentArgumentCompatibleParser extends Parser {
25+
parseFragment() {
26+
const start = this._lexer.token;
27+
this.expectToken(TokenKind.SPREAD);
28+
const hasTypeCondition = this.expectOptionalKeyword('on');
29+
30+
if (!hasTypeCondition && this.peek(TokenKind.NAME)) {
31+
const name = this.parseFragmentName();
32+
33+
if (this.peek(TokenKind.PAREN_L)) {
34+
return {
35+
kind: Kind.FRAGMENT_SPREAD,
36+
name,
37+
arguments: this.parseArguments(false),
38+
directives: this.parseDirectives(false),
39+
loc: this.loc(start),
40+
};
41+
}
42+
43+
return {
44+
kind: Kind.FRAGMENT_SPREAD,
45+
name: this.parseFragmentName(),
46+
directives: this.parseDirectives(false),
47+
loc: this.loc(start),
48+
};
49+
}
50+
51+
return {
52+
kind: Kind.INLINE_FRAGMENT,
53+
typeCondition: hasTypeCondition ? this.parseNamedType() : undefined,
54+
directives: this.parseDirectives(false),
55+
selectionSet: this.parseSelectionSet(),
56+
loc: this.loc(start),
57+
};
58+
}
59+
60+
parseFragmentDefinition() {
61+
const start = this._lexer.token;
62+
this.expectKeyword('fragment');
63+
const name = this.parseFragmentName();
64+
65+
if (this.peek(TokenKind.PAREN_L)) {
66+
return {
67+
kind: Kind.FRAGMENT_DEFINITION,
68+
name,
69+
variableDefinitions: this.parseVariableDefinitions(),
70+
typeCondition: (this.expectKeyword('on'), this.parseNamedType()),
71+
directives: this.parseDirectives(false),
72+
selectionSet: this.parseSelectionSet(),
73+
loc: this.loc(start),
74+
};
75+
}
76+
77+
return {
78+
kind: Kind.FRAGMENT_DEFINITION,
79+
name,
80+
typeCondition: (this.expectKeyword('on'), this.parseNamedType()),
81+
directives: this.parseDirectives(false),
82+
selectionSet: this.parseSelectionSet(),
83+
loc: this.loc(start),
84+
};
85+
}
86+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Plugin } from '@envelop/types';
2+
import { ParseOptions } from 'graphql/language/parser';
3+
import { Source, DocumentNode } from 'graphql';
4+
import { FragmentArgumentCompatibleParser } from './extended-parser';
5+
import { applySelectionSetFragmentArguments } from './utils';
6+
7+
export function parseWithFragmentArguments(source: string | Source, options?: ParseOptions): DocumentNode {
8+
const parser = new FragmentArgumentCompatibleParser(source, options);
9+
10+
return parser.parseDocument();
11+
}
12+
13+
export const useFragmentArguments = (): Plugin => {
14+
return {
15+
onParse({ setParseFn }) {
16+
setParseFn(parseWithFragmentArguments);
17+
18+
return ({ result, replaceParseResult }) => {
19+
if (result && 'kind' in result) {
20+
replaceParseResult(applySelectionSetFragmentArguments(result));
21+
}
22+
};
23+
},
24+
};
25+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { InlineFragmentNode, ArgumentNode, DocumentNode, FragmentDefinitionNode, visit } from 'graphql';
2+
3+
export function applySelectionSetFragmentArguments(document: DocumentNode): DocumentNode | Error {
4+
const fragmentList = new Map<string, FragmentDefinitionNode>();
5+
for (const def of document.definitions) {
6+
if (def.kind !== 'FragmentDefinition') {
7+
continue;
8+
}
9+
fragmentList.set(def.name.value, def);
10+
}
11+
12+
return visit(document, {
13+
FragmentSpread(fragmentNode) {
14+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
15+
// @ts-ignore
16+
if (fragmentNode.arguments != null && fragmentNode.arguments.length) {
17+
const fragmentDef = fragmentList.get(fragmentNode.name.value);
18+
if (!fragmentDef) {
19+
return;
20+
}
21+
22+
const fragmentArguments = new Map<string, ArgumentNode>();
23+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
24+
// @ts-ignore
25+
for (const arg of fragmentNode.arguments) {
26+
fragmentArguments.set(arg.name.value, arg);
27+
}
28+
29+
const selectionSet = visit(fragmentDef.selectionSet, {
30+
Variable(variableNode) {
31+
const fragArg = fragmentArguments.get(variableNode.name.value);
32+
if (fragArg) {
33+
return fragArg.value;
34+
}
35+
36+
return variableNode;
37+
},
38+
});
39+
40+
const inlineFragment: InlineFragmentNode = {
41+
kind: 'InlineFragment',
42+
typeCondition: fragmentDef.typeCondition,
43+
selectionSet,
44+
};
45+
46+
return inlineFragment;
47+
}
48+
return fragmentNode;
49+
},
50+
});
51+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { buildSchema, print } from 'graphql';
2+
import { oneLine, stripIndent } from 'common-tags';
3+
import { diff } from 'jest-diff';
4+
import { envelop, useSchema } from '@envelop/core';
5+
import { useFragmentArguments } from '../src';
6+
7+
function compareStrings(a: string, b: string): boolean {
8+
return a.includes(b);
9+
}
10+
11+
expect.extend({
12+
toBeSimilarStringTo(received: string, expected: string) {
13+
const strippedReceived = oneLine`${received}`.replace(/\s\s+/g, ' ');
14+
const strippedExpected = oneLine`${expected}`.replace(/\s\s+/g, ' ');
15+
16+
if (compareStrings(strippedReceived, strippedExpected)) {
17+
return {
18+
message: () =>
19+
`expected
20+
${received}
21+
not to be a string containing (ignoring indents)
22+
${expected}`,
23+
pass: true,
24+
};
25+
} else {
26+
const diffString = diff(stripIndent`${expected}`, stripIndent`${received}`, {
27+
expand: this.expand,
28+
});
29+
const hasExpect = diffString && diffString.includes('- Expect');
30+
31+
const message = hasExpect
32+
? `Difference:\n\n${diffString}`
33+
: `expected
34+
${received}
35+
to be a string containing (ignoring indents)
36+
${expected}`;
37+
38+
return {
39+
message: () => message,
40+
pass: false,
41+
};
42+
}
43+
},
44+
});
45+
46+
declare global {
47+
// eslint-disable-next-line no-redeclare
48+
namespace jest {
49+
interface Matchers<R, T> {
50+
/**
51+
* Normalizes whitespace and performs string comparisons
52+
*/
53+
toBeSimilarStringTo(expected: string): R;
54+
}
55+
}
56+
}
57+
58+
describe('useFragmentArguments', () => {
59+
const schema = buildSchema(/* GraphQL */ `
60+
type Query {
61+
a: TestType
62+
}
63+
64+
type TestType {
65+
a(b: String): Boolean
66+
}
67+
`);
68+
test('can inline fragment with argument', () => {
69+
const { parse } = envelop({ plugins: [useFragmentArguments(), useSchema(schema)] })({});
70+
const result = parse(/* GraphQL */ `
71+
fragment TestFragment($c: String) on Query {
72+
a(b: $c)
73+
}
74+
75+
query TestQuery($a: String) {
76+
...TestFragment(c: $a)
77+
}
78+
`);
79+
expect(print(result)).toBeSimilarStringTo(/* GraphQL */ `
80+
query TestQuery($a: String) {
81+
... on Query {
82+
a(b: $a)
83+
}
84+
}
85+
`);
86+
});
87+
});

website/src/lib/plugins.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,11 @@ export const pluginsArr: RawPlugin[] = [
204204
iconUrl: '/assets/logos/graphql.png',
205205
tags: ['utilities'],
206206
},
207+
{
208+
identifier: 'use-fragment-arguments',
209+
title: 'useFragmentArguments',
210+
npmPackage: '@envelop/fragment-arguments',
211+
iconUrl: '/assets/logos/graphql.png',
212+
tags: ['utilities'],
213+
},
207214
];

yarn.lock

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2358,6 +2358,11 @@
23582358
"@types/connect" "*"
23592359
"@types/node" "*"
23602360

2361+
2362+
version "1.8.0"
2363+
resolved "https://registry.yarnpkg.com/@types/common-tags/-/common-tags-1.8.0.tgz#79d55e748d730b997be5b7fce4b74488d8b26a6b"
2364+
integrity sha512-htRqZr5qn8EzMelhX/Xmx142z218lLyGaeZ3YR8jlze4TATRU9huKKvuBmAJEW4LCC4pnY1N6JAm6p85fMHjhg==
2365+
23612366
"@types/component-emitter@^1.2.10":
23622367
version "1.2.10"
23632368
resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea"
@@ -4188,6 +4193,11 @@ commander@^7.2.0:
41884193
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
41894194
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==
41904195

4196+
4197+
version "1.8.0"
4198+
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
4199+
integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==
4200+
41914201
commondir@^1.0.1:
41924202
version "1.0.1"
41934203
resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@@ -8684,6 +8694,11 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0:
86848694
dependencies:
86858695
wrappy "1"
86868696

8697+
8698+
version "1.0.3"
8699+
resolved "https://registry.yarnpkg.com/oneline/-/oneline-1.0.3.tgz#2f2631bd3a5716a4eeb439291697af2fc7fa39a5"
8700+
integrity sha512-KWLrLloG/ShWvvWuvmOL2jw17++ufGdbkKC2buI2Aa6AaM4AkjCtpeJZg60EK34NQVo2qu1mlPrC2uhvQgCrhQ==
8701+
86878702
onetime@^5.1.0, onetime@^5.1.2:
86888703
version "5.1.2"
86898704
resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"

0 commit comments

Comments
 (0)