Skip to content

Commit

Permalink
wip: implement parser extensions for transforming the fragment argume…
Browse files Browse the repository at this point in the history
…nt transform syntax into operations without fragment arguments, which are executable by all graphql.js versions

See graphql/graphql-js#3152 for reference
  • Loading branch information
n1ru4l committed Jun 4, 2021
1 parent 8a2858b commit 043e660
Show file tree
Hide file tree
Showing 4 changed files with 351 additions and 0 deletions.
41 changes: 41 additions & 0 deletions packages/plugins/fragment-arguments/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"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"
},
"sideEffects": false,
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"typings": "dist/index.d.ts",
"typescript": {
"definition": "dist/index.d.ts"
},
"scripts": {
"test": "jest",
"prepack": "bob prepack"
},
"devDependencies": {
"@types/common-tags": "1.8.0",
"@graphql-tools/schema": "7.1.5",
"bob-the-bundler": "1.2.0",
"graphql": "15.5.0",
"typescript": "4.3.2",
"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"
}
}
163 changes: 163 additions & 0 deletions packages/plugins/fragment-arguments/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Plugin } from '@envelop/types';
import { Parser, ParseOptions } from 'graphql/language/parser';
import { Lexer } from 'graphql/language/lexer';

import {
TokenKind,
Kind,
Source,
DocumentNode,
visit,
FragmentDefinitionNode,
InlineFragmentNode,
ArgumentNode,
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;
}
}

class FragmentArgumentCompatible 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),
};
}
}

function pimpedParse(source: string | Source, options?: ParseOptions): DocumentNode {
const parser = new FragmentArgumentCompatible(source, options);
return parser.parseDocument();
}

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

return ({ result, replaceParseResult }) => {
if (result && 'kind' in result) {
replaceParseResult(applySelectionSetFragmentArguments(result));
}
};
},
};
};

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)
}
}
`);
});
});
Loading

0 comments on commit 043e660

Please sign in to comment.