diff --git a/.changeset/grumpy-toys-peel.md b/.changeset/grumpy-toys-peel.md new file mode 100644 index 0000000000..5a56d11f8f --- /dev/null +++ b/.changeset/grumpy-toys-peel.md @@ -0,0 +1,7 @@ +--- +'graphql-executor': patch +--- + +Memoize field lists created by the collectFields utility function. + +This allows functions that operate on these field lists to be memoized. diff --git a/src/execution/__tests__/collectFields-test.ts b/src/execution/__tests__/collectFields-test.ts new file mode 100644 index 0000000000..8fa74912e7 --- /dev/null +++ b/src/execution/__tests__/collectFields-test.ts @@ -0,0 +1,129 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import type { FragmentDefinitionNode, OperationDefinitionNode } from 'graphql'; +import { + GraphQLID, + GraphQLList, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + parse, +} from 'graphql'; + +import { collectFields } from '../collectFields'; + +const friendType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + }, + name: 'Friend', +}); + +const heroType = new GraphQLObjectType({ + fields: { + id: { type: GraphQLID }, + name: { type: GraphQLString }, + friends: { + type: new GraphQLList(friendType), + }, + }, + name: 'Hero', +}); + +const query = new GraphQLObjectType({ + fields: { + hero: { + type: heroType, + }, + }, + name: 'Query', +}); + +const schema = new GraphQLSchema({ query }); + +const document = parse(` +query HeroQuery($skipFirst: Boolean, $skipSecond: Boolean) { + hero { + name + } + ...HeroFragment1 @skip(if: $skipFirst) + ...HeroFragment2 @skip(if: $skipSecond) +} +fragment HeroFragment1 on Query { + hero { + name + } +} +fragment HeroFragment2 on Query { + hero { + name + } +} +`); + +const selectionSet = (document.definitions[0] as OperationDefinitionNode) + .selectionSet; +const fragments = { + HeroFragment1: document.definitions[1] as FragmentDefinitionNode, + HeroFragment2: document.definitions[2] as FragmentDefinitionNode, +}; + +describe('collectFields', () => { + it('memoizes', () => { + const { fields: fields1 } = collectFields( + schema, + fragments, + { + skipFirst: false, + skipSecond: false, + }, + query, + selectionSet, + ); + const { fields: fields2 } = collectFields( + schema, + fragments, + { + skipFirst: false, + skipSecond: false, + }, + query, + selectionSet, + ); + + const heroFieldNodes1 = fields1.get('hero'); + const heroFieldNodes2 = fields2.get('hero'); + + expect(heroFieldNodes1).to.equal(heroFieldNodes2); + }); + + it('does not yet (?) memoize everything', () => { + const { fields: fields1 } = collectFields( + schema, + fragments, + { + skipFirst: true, + skipSecond: false, + }, + query, + selectionSet, + ); + const { fields: fields2 } = collectFields( + schema, + fragments, + { + skipFirst: false, + skipSecond: true, + }, + query, + selectionSet, + ); + + const heroFieldNodes1 = fields1.get('hero'); + const heroFieldNodes2 = fields2.get('hero'); + + expect(heroFieldNodes1).to.not.equal(heroFieldNodes2); + }); +}); diff --git a/src/execution/collectFields.ts b/src/execution/collectFields.ts index cd747e30a4..b6a4f1ce04 100644 --- a/src/execution/collectFields.ts +++ b/src/execution/collectFields.ts @@ -18,6 +18,8 @@ import { import type { Maybe } from '../jsutils/Maybe'; import type { ObjMap } from '../jsutils/ObjMap'; +import { memoize1 } from '../jsutils/memoize1'; +import { memoize2 } from '../jsutils/memoize2'; import { GraphQLDeferDirective } from '../type/directives'; @@ -129,9 +131,9 @@ function collectFieldsImpl( const name = getFieldEntryKey(selection); const fieldList = fields.get(name); if (fieldList !== undefined) { - fieldList.push(selection); + fields.set(name, updateFieldList(fieldList, selection)); } else { - fields.set(name, [selection]); + fields.set(name, createFieldList(selection)); } break; } @@ -310,3 +312,20 @@ function doesFragmentConditionMatch( function getFieldEntryKey(node: FieldNode): string { return node.alias ? node.alias.value : node.name.value; } + +/** + * Creates a field list, memoizing so that functions operating on the + * field list can be memoized. + */ +const createFieldList = memoize1((node: FieldNode): Array => [node]); + +/** + * Appends to a field list, memoizing so that functions operating on the + * field list can be memoized. + */ +const updateFieldList = memoize2( + (fieldList: Array, node: FieldNode): Array => [ + ...fieldList, + node, + ], +); diff --git a/src/jsutils/memoize1.ts b/src/jsutils/memoize1.ts new file mode 100644 index 0000000000..9650196b9e --- /dev/null +++ b/src/jsutils/memoize1.ts @@ -0,0 +1,22 @@ +/** + * Memoizes the provided one-argument function. + */ +export function memoize1( + fn: (a1: A1) => R, +): (a1: A1) => R { + let cache0: WeakMap; + + return function memoized(a1) { + if (cache0 === undefined) { + cache0 = new WeakMap(); + } + + let fnResult = cache0.get(a1); + if (fnResult === undefined) { + fnResult = fn(a1); + cache0.set(a1, fnResult); + } + + return fnResult; + }; +} diff --git a/src/jsutils/memoize2.ts b/src/jsutils/memoize2.ts new file mode 100644 index 0000000000..d15d400159 --- /dev/null +++ b/src/jsutils/memoize2.ts @@ -0,0 +1,28 @@ +/** + * Memoizes the provided two-argument function. + */ +export function memoize2( + fn: (a1: A1, a2: A2) => R, +): (a1: A1, a2: A2) => R { + let cache0: WeakMap>; + + return function memoized(a1, a2) { + if (cache0 === undefined) { + cache0 = new WeakMap(); + } + + let cache1 = cache0.get(a1); + if (cache1 === undefined) { + cache1 = new WeakMap(); + cache0.set(a1, cache1); + } + + let fnResult = cache1.get(a2); + if (fnResult === undefined) { + fnResult = fn(a1, a2); + cache1.set(a2, fnResult); + } + + return fnResult; + }; +}