Skip to content

Commit

Permalink
Fragment arguments parser (#252)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
Goldfarbof and dotansimha authored Jun 30, 2021
1 parent 02f7292 commit ebb0cef
Show file tree
Hide file tree
Showing 9 changed files with 331 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/tall-coins-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@envelop/fragment-arguments': patch
---

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

## Sharing / Composing `envelop`s

Expand Down
54 changes: 54 additions & 0 deletions packages/plugins/fragment-arguments/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@envelop/fragment-arguments",
"version": "0.0.1",
"author": "Dotan Simha <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/dotansimha/envelop.git",
"directory": "packages/plugins/fragment-arguments"
},
"main": "dist/index.js",
"module": "dist/index.mjs",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./*": {
"require": "./dist/*.js",
"import": "./dist/*.mjs"
}
},
"typings": "dist/index.d.ts",
"typescript": {
"definition": "dist/index.d.ts"
},
"scripts": {
"test": "jest",
"prepack": "bob prepack"
},
"dependencies": {
"@envelop/types": "0.2.1"
},
"devDependencies": {
"@types/common-tags": "1.8.0",
"@graphql-tools/utils": "7.10.0",
"@graphql-tools/schema": "7.1.5",
"bob-the-bundler": "1.4.1",
"graphql": "15.5.1",
"typescript": "4.3.4",
"oneline": "1.0.3",
"common-tags": "1.8.0"
},
"peerDependencies": {
"graphql": "^14.0.0 || ^15.0.0"
},
"buildOptions": {
"input": "./src/index.ts"
},
"publishConfig": {
"directory": "dist",
"access": "public"
}
}
86 changes: 86 additions & 0 deletions packages/plugins/fragment-arguments/src/extended-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Parser } from 'graphql/language/parser';
import { Lexer } from 'graphql/language/lexer';
import { TokenKind, Kind, Source, DocumentNode, TokenKindEnum, Token } from 'graphql';

declare module 'graphql/language/parser' {
export class Parser {
constructor(source: string | Source, options?: ParseOptions);
_lexer: Lexer;
expectOptionalKeyword(word: string): boolean;
expectToken(token: TokenKindEnum): void;
peek(token: TokenKindEnum): boolean;
parseFragmentName(): string;
parseArguments(flag: boolean): any;
parseDirectives(flag: boolean): any;
loc(start: Token): any;
parseNamedType(): any;
parseSelectionSet(): any;
expectKeyword(keyword: string): void;
parseVariableDefinitions(): void;
parseDocument(): DocumentNode;
}
}

export class FragmentArgumentCompatibleParser extends Parser {
parseFragment() {
const start = this._lexer.token;
this.expectToken(TokenKind.SPREAD);
const hasTypeCondition = this.expectOptionalKeyword('on');

if (!hasTypeCondition && this.peek(TokenKind.NAME)) {
const name = this.parseFragmentName();

if (this.peek(TokenKind.PAREN_L)) {
return {
kind: Kind.FRAGMENT_SPREAD,
name,
arguments: this.parseArguments(false),
directives: this.parseDirectives(false),
loc: this.loc(start),
};
}

return {
kind: Kind.FRAGMENT_SPREAD,
name: this.parseFragmentName(),
directives: this.parseDirectives(false),
loc: this.loc(start),
};
}

return {
kind: Kind.INLINE_FRAGMENT,
typeCondition: hasTypeCondition ? this.parseNamedType() : undefined,
directives: this.parseDirectives(false),
selectionSet: this.parseSelectionSet(),
loc: this.loc(start),
};
}

parseFragmentDefinition() {
const start = this._lexer.token;
this.expectKeyword('fragment');
const name = this.parseFragmentName();

if (this.peek(TokenKind.PAREN_L)) {
return {
kind: Kind.FRAGMENT_DEFINITION,
name,
variableDefinitions: this.parseVariableDefinitions(),
typeCondition: (this.expectKeyword('on'), this.parseNamedType()),
directives: this.parseDirectives(false),
selectionSet: this.parseSelectionSet(),
loc: this.loc(start),
};
}

return {
kind: Kind.FRAGMENT_DEFINITION,
name,
typeCondition: (this.expectKeyword('on'), this.parseNamedType()),
directives: this.parseDirectives(false),
selectionSet: this.parseSelectionSet(),
loc: this.loc(start),
};
}
}
25 changes: 25 additions & 0 deletions packages/plugins/fragment-arguments/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Plugin } from '@envelop/types';
import { ParseOptions } from 'graphql/language/parser';
import { Source, DocumentNode } from 'graphql';
import { FragmentArgumentCompatibleParser } from './extended-parser';
import { applySelectionSetFragmentArguments } from './utils';

export function parseWithFragmentArguments(source: string | Source, options?: ParseOptions): DocumentNode {
const parser = new FragmentArgumentCompatibleParser(source, options);

return parser.parseDocument();
}

export const useFragmentArguments = (): Plugin => {
return {
onParse({ setParseFn }) {
setParseFn(parseWithFragmentArguments);

return ({ result, replaceParseResult }) => {
if (result && 'kind' in result) {
replaceParseResult(applySelectionSetFragmentArguments(result));
}
};
},
};
};
51 changes: 51 additions & 0 deletions packages/plugins/fragment-arguments/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { InlineFragmentNode, ArgumentNode, DocumentNode, FragmentDefinitionNode, visit } from 'graphql';

export function applySelectionSetFragmentArguments(document: DocumentNode): DocumentNode | Error {
const fragmentList = new Map<string, FragmentDefinitionNode>();
for (const def of document.definitions) {
if (def.kind !== 'FragmentDefinition') {
continue;
}
fragmentList.set(def.name.value, def);
}

return visit(document, {
FragmentSpread(fragmentNode) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (fragmentNode.arguments != null && fragmentNode.arguments.length) {
const fragmentDef = fragmentList.get(fragmentNode.name.value);
if (!fragmentDef) {
return;
}

const fragmentArguments = new Map<string, ArgumentNode>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const arg of fragmentNode.arguments) {
fragmentArguments.set(arg.name.value, arg);
}

const selectionSet = visit(fragmentDef.selectionSet, {
Variable(variableNode) {
const fragArg = fragmentArguments.get(variableNode.name.value);
if (fragArg) {
return fragArg.value;
}

return variableNode;
},
});

const inlineFragment: InlineFragmentNode = {
kind: 'InlineFragment',
typeCondition: fragmentDef.typeCondition,
selectionSet,
};

return inlineFragment;
}
return fragmentNode;
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { buildSchema, print } from 'graphql';
import { oneLine, stripIndent } from 'common-tags';
import { diff } from 'jest-diff';
import { envelop, useSchema } from '@envelop/core';
import { useFragmentArguments } from '../src';

function compareStrings(a: string, b: string): boolean {
return a.includes(b);
}

expect.extend({
toBeSimilarStringTo(received: string, expected: string) {
const strippedReceived = oneLine`${received}`.replace(/\s\s+/g, ' ');
const strippedExpected = oneLine`${expected}`.replace(/\s\s+/g, ' ');

if (compareStrings(strippedReceived, strippedExpected)) {
return {
message: () =>
`expected
${received}
not to be a string containing (ignoring indents)
${expected}`,
pass: true,
};
} else {
const diffString = diff(stripIndent`${expected}`, stripIndent`${received}`, {
expand: this.expand,
});
const hasExpect = diffString && diffString.includes('- Expect');

const message = hasExpect
? `Difference:\n\n${diffString}`
: `expected
${received}
to be a string containing (ignoring indents)
${expected}`;

return {
message: () => message,
pass: false,
};
}
},
});

declare global {
// eslint-disable-next-line no-redeclare
namespace jest {
interface Matchers<R, T> {
/**
* Normalizes whitespace and performs string comparisons
*/
toBeSimilarStringTo(expected: string): R;
}
}
}

describe('useFragmentArguments', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
a: TestType
}
type TestType {
a(b: String): Boolean
}
`);
test('can inline fragment with argument', () => {
const { parse } = envelop({ plugins: [useFragmentArguments(), useSchema(schema)] })({});
const result = parse(/* GraphQL */ `
fragment TestFragment($c: String) on Query {
a(b: $c)
}
query TestQuery($a: String) {
...TestFragment(c: $a)
}
`);
expect(print(result)).toBeSimilarStringTo(/* GraphQL */ `
query TestQuery($a: String) {
... on Query {
a(b: $a)
}
}
`);
});
});
7 changes: 7 additions & 0 deletions website/src/lib/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,11 @@ export const pluginsArr: RawPlugin[] = [
iconUrl: '/assets/logos/graphql.png',
tags: ['utilities'],
},
{
identifier: 'use-fragment-arguments',
title: 'useFragmentArguments',
npmPackage: '@envelop/fragment-arguments',
iconUrl: '/assets/logos/graphql.png',
tags: ['utilities'],
},
];
15 changes: 15 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2358,6 +2358,11 @@
"@types/connect" "*"
"@types/node" "*"

"@types/[email protected]":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@types/common-tags/-/common-tags-1.8.0.tgz#79d55e748d730b997be5b7fce4b74488d8b26a6b"
integrity sha512-htRqZr5qn8EzMelhX/Xmx142z218lLyGaeZ3YR8jlze4TATRU9huKKvuBmAJEW4LCC4pnY1N6JAm6p85fMHjhg==

"@types/component-emitter@^1.2.10":
version "1.2.10"
resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea"
Expand Down Expand Up @@ -4188,6 +4193,11 @@ commander@^7.2.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7"
integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==

[email protected]:
version "1.8.0"
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==

commondir@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
Expand Down Expand Up @@ -8684,6 +8694,11 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0:
dependencies:
wrappy "1"

[email protected]:
version "1.0.3"
resolved "https://registry.yarnpkg.com/oneline/-/oneline-1.0.3.tgz#2f2631bd3a5716a4eeb439291697af2fc7fa39a5"
integrity sha512-KWLrLloG/ShWvvWuvmOL2jw17++ufGdbkKC2buI2Aa6AaM4AkjCtpeJZg60EK34NQVo2qu1mlPrC2uhvQgCrhQ==

onetime@^5.1.0, onetime@^5.1.2:
version "5.1.2"
resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
Expand Down

0 comments on commit ebb0cef

Please sign in to comment.