From ebb0cef580ccef04d46b16b25b0895ab2aafc6a2 Mon Sep 17 00:00:00 2001 From: Laurin Quast <4292239+Goldfarbof@users.noreply.github.com> Date: Wed, 30 Jun 2021 13:49:20 +0200 Subject: [PATCH] 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 https://github.com/graphql/graphql-js/pull/3152 for reference * fixes Co-authored-by: Dotan Simha --- .changeset/tall-coins-build.md | 5 ++ README.md | 1 + .../plugins/fragment-arguments/package.json | 54 ++++++++++++ .../fragment-arguments/src/extended-parser.ts | 86 ++++++++++++++++++ .../plugins/fragment-arguments/src/index.ts | 25 ++++++ .../plugins/fragment-arguments/src/utils.ts | 51 +++++++++++ .../test/use-fragment-arguments.spec.ts | 87 +++++++++++++++++++ website/src/lib/plugins.ts | 7 ++ yarn.lock | 15 ++++ 9 files changed, 331 insertions(+) create mode 100644 .changeset/tall-coins-build.md create mode 100644 packages/plugins/fragment-arguments/package.json create mode 100644 packages/plugins/fragment-arguments/src/extended-parser.ts create mode 100644 packages/plugins/fragment-arguments/src/index.ts create mode 100644 packages/plugins/fragment-arguments/src/utils.ts create mode 100644 packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts diff --git a/.changeset/tall-coins-build.md b/.changeset/tall-coins-build.md new file mode 100644 index 0000000..11978d9 --- /dev/null +++ b/.changeset/tall-coins-build.md @@ -0,0 +1,5 @@ +--- +'@envelop/fragment-arguments': patch +--- + +NEW PLUGIN! diff --git a/README.md b/README.md index 85422db..0557cd0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/plugins/fragment-arguments/package.json b/packages/plugins/fragment-arguments/package.json new file mode 100644 index 0000000..74d1417 --- /dev/null +++ b/packages/plugins/fragment-arguments/package.json @@ -0,0 +1,54 @@ +{ + "name": "@envelop/fragment-arguments", + "version": "0.0.1", + "author": "Dotan Simha ", + "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" + } +} diff --git a/packages/plugins/fragment-arguments/src/extended-parser.ts b/packages/plugins/fragment-arguments/src/extended-parser.ts new file mode 100644 index 0000000..cc8b78f --- /dev/null +++ b/packages/plugins/fragment-arguments/src/extended-parser.ts @@ -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), + }; + } +} diff --git a/packages/plugins/fragment-arguments/src/index.ts b/packages/plugins/fragment-arguments/src/index.ts new file mode 100644 index 0000000..0ea3897 --- /dev/null +++ b/packages/plugins/fragment-arguments/src/index.ts @@ -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)); + } + }; + }, + }; +}; diff --git a/packages/plugins/fragment-arguments/src/utils.ts b/packages/plugins/fragment-arguments/src/utils.ts new file mode 100644 index 0000000..8fc6a9b --- /dev/null +++ b/packages/plugins/fragment-arguments/src/utils.ts @@ -0,0 +1,51 @@ +import { InlineFragmentNode, ArgumentNode, DocumentNode, FragmentDefinitionNode, visit } from 'graphql'; + +export function applySelectionSetFragmentArguments(document: DocumentNode): DocumentNode | Error { + const fragmentList = new Map(); + 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(); + // 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; + }, + }); +} diff --git a/packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts b/packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts new file mode 100644 index 0000000..7cb558f --- /dev/null +++ b/packages/plugins/fragment-arguments/test/use-fragment-arguments.spec.ts @@ -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 { + /** + * 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) + } + } + `); + }); +}); diff --git a/website/src/lib/plugins.ts b/website/src/lib/plugins.ts index 59ae38a..fed75bc 100644 --- a/website/src/lib/plugins.ts +++ b/website/src/lib/plugins.ts @@ -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'], + }, ]; diff --git a/yarn.lock b/yarn.lock index b650c8d..c867ebe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2358,6 +2358,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/common-tags@1.8.0": + 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" @@ -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== +common-tags@1.8.0: + 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" @@ -8684,6 +8694,11 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" +oneline@1.0.3: + 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"