From eb6044513f59b4d47182cae91fdb1c7d9c7f12f1 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Fri, 17 Jul 2026 08:01:05 +0300 Subject: [PATCH] Add map keyed by set membership --- .../incremental/IncrementalExecutor.ts | 3 +- .../incremental/buildExecutionPlan.ts | 16 +-- .../BranchingIncrementalExecutor.ts | 15 +-- src/jsutils/SetMap.ts | 124 ++++++++++++++++++ src/jsutils/__tests__/SetMap-test.ts | 120 +++++++++++++++++ src/jsutils/getBySet.ts | 14 -- 6 files changed, 259 insertions(+), 33 deletions(-) create mode 100644 src/jsutils/SetMap.ts create mode 100644 src/jsutils/__tests__/SetMap-test.ts delete mode 100644 src/jsutils/getBySet.ts diff --git a/src/execution/incremental/IncrementalExecutor.ts b/src/execution/incremental/IncrementalExecutor.ts index 4601546f75..b4510fdd75 100644 --- a/src/execution/incremental/IncrementalExecutor.ts +++ b/src/execution/incremental/IncrementalExecutor.ts @@ -9,6 +9,7 @@ import type { ObjMap } from '../../jsutils/ObjMap.ts'; import type { Path } from '../../jsutils/Path.ts'; import { addPath, pathToArray } from '../../jsutils/Path.ts'; import type { PromiseOrValue } from '../../jsutils/PromiseOrValue.ts'; +import type { SetMap } from '../../jsutils/SetMap.ts'; import type { GraphQLError, @@ -597,7 +598,7 @@ export class IncrementalExecutor< parentType: GraphQLObjectType, sourceValue: unknown, path: Path | undefined, - newGroupedFieldSets: Map, + newGroupedFieldSets: SetMap, deliveryGroupMap: ReadonlyMap, ): void { const createSubExecutor = this.getCreateSubExecutor(); diff --git a/src/execution/incremental/buildExecutionPlan.ts b/src/execution/incremental/buildExecutionPlan.ts index ffe8e4a662..5ec1a6113e 100644 --- a/src/execution/incremental/buildExecutionPlan.ts +++ b/src/execution/incremental/buildExecutionPlan.ts @@ -1,5 +1,5 @@ -import { getBySet } from '../../jsutils/getBySet.ts'; import { isSameSet } from '../../jsutils/isSameSet.ts'; +import { SetMap } from '../../jsutils/SetMap.ts'; import type { DeferUsage, @@ -13,7 +13,7 @@ export type DeferUsageSet = ReadonlySet; /** @internal */ export interface ExecutionPlan { groupedFieldSet: GroupedFieldSet; - newGroupedFieldSets: Map; + newGroupedFieldSets: SetMap; } /** @internal */ @@ -22,8 +22,8 @@ export function buildExecutionPlan( parentDeferUsages: DeferUsageSet = new Set(), ): ExecutionPlan { const groupedFieldSet = new Map(); - const newGroupedFieldSets = new Map< - DeferUsageSet, + const newGroupedFieldSets = new SetMap< + DeferUsage, Map >(); for (const [responseKey, fieldDetailsList] of originalGroupedFieldSet) { @@ -34,14 +34,10 @@ export function buildExecutionPlan( continue; } - let newGroupedFieldSet = getBySet( - newGroupedFieldSets, + const newGroupedFieldSet = newGroupedFieldSets.getOrInsertComputed( filteredDeferUsageSet, + () => new Map(), ); - if (newGroupedFieldSet === undefined) { - newGroupedFieldSet = new Map(); - newGroupedFieldSets.set(filteredDeferUsageSet, newGroupedFieldSet); - } newGroupedFieldSet.set(responseKey, fieldDetailsList); } diff --git a/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts b/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts index 3b9c9fc7ff..ba08668bc0 100644 --- a/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts +++ b/src/execution/legacyIncremental/BranchingIncrementalExecutor.ts @@ -1,12 +1,12 @@ /** @category Legacy Incremental Execution */ import { AccumulatorMap } from '../../jsutils/AccumulatorMap.ts'; -import { getBySet } from '../../jsutils/getBySet.ts'; import { invariant } from '../../jsutils/invariant.ts'; import { isSameSet } from '../../jsutils/isSameSet.ts'; import { memoize1 } from '../../jsutils/memoize1.ts'; import { memoize2 } from '../../jsutils/memoize2.ts'; import type { ObjMap } from '../../jsutils/ObjMap.ts'; +import { SetMap } from '../../jsutils/SetMap.ts'; import type { GraphQLError, @@ -344,8 +344,8 @@ function buildBranchingExecutionPlan( ): ExecutionPlan { const groupedFieldSet = new AccumulatorMap(); - const newGroupedFieldSets = new Map< - DeferUsageSet, + const newGroupedFieldSets = new SetMap< + DeferUsage, AccumulatorMap >(); @@ -359,11 +359,10 @@ function buildBranchingExecutionPlan( if (isSameSet(parentDeferUsages, deferUsageSet)) { groupedFieldSet.add(responseKey, fieldDetails); } else { - let newGroupedFieldSet = getBySet(newGroupedFieldSets, deferUsageSet); - if (newGroupedFieldSet === undefined) { - newGroupedFieldSet = new AccumulatorMap(); - newGroupedFieldSets.set(deferUsageSet, newGroupedFieldSet); - } + const newGroupedFieldSet = newGroupedFieldSets.getOrInsertComputed( + deferUsageSet, + () => new AccumulatorMap(), + ); newGroupedFieldSet.add(responseKey, fieldDetails); } } diff --git a/src/jsutils/SetMap.ts b/src/jsutils/SetMap.ts new file mode 100644 index 0000000000..0ca05e773a --- /dev/null +++ b/src/jsutils/SetMap.ts @@ -0,0 +1,124 @@ +interface SetMapEntry { + members: ReadonlySet; + value: V; +} + +/** + * Maps an order-independent set of member identities to a value. Unlike a + * `Map, V>`, two sets containing the same members address the + * same entry even when they are different `Set` objects. + * + * Each member receives a map-local numeric ID. Sorting those IDs produces an + * exact, order-independent key. + * + * Iteration follows insertion order and returns the first `Set` object stored + * for each key. Key sets must not be mutated after insertion. + * + * @internal + * @typeParam T - A member of a map key. + * @typeParam V - The value associated with a set of members. + */ +export class SetMap { + // Retains the first Set object for each key and preserves insertion order. + _entries: Map>; + _memberIds: Map; + _nextMemberId: number; + + constructor() { + this._entries = new Map(); + this._memberIds = new Map(); + this._nextMemberId = 0; + } + + get size(): number { + return this._entries.size; + } + + get(members: ReadonlySet): V | undefined { + return this._find(members)?.value; + } + + getOrInsert(members: ReadonlySet, value: V): V { + const entry = this._find(members); + if (entry !== undefined) { + return entry.value; + } + + this._create(members, value); + return value; + } + + getOrInsertComputed( + members: ReadonlySet, + computeValue: (members: ReadonlySet) => V, + ): V { + const entry = this._find(members); + if (entry !== undefined) { + return entry.value; + } + + const value = computeValue(members); + this.set(members, value); + return value; + } + + has(members: ReadonlySet): boolean { + return this._find(members) !== undefined; + } + + set(members: ReadonlySet, value: V): this { + const entry = this._find(members); + if (entry !== undefined) { + entry.value = value; + return this; + } + + this._create(members, value); + return this; + } + + *keys(): IterableIterator> { + for (const entry of this._entries.values()) { + yield entry.members; + } + } + + *values(): IterableIterator { + for (const entry of this._entries.values()) { + yield entry.value; + } + } + + *entries(): IterableIterator<[ReadonlySet, V]> { + for (const entry of this._entries.values()) { + yield [entry.members, entry.value]; + } + } + + [Symbol.iterator](): IterableIterator<[ReadonlySet, V]> { + return this.entries(); + } + + _find(members: ReadonlySet): SetMapEntry | undefined { + return this._entries.get(this._key(members)); + } + + _create(members: ReadonlySet, value: V): void { + const entry = { members, value }; + this._entries.set(this._key(members), entry); + } + + _key(members: ReadonlySet): string { + const ids = []; + for (const member of members) { + let id = this._memberIds.get(member); + if (id === undefined) { + id = this._nextMemberId++; + this._memberIds.set(member, id); + } + ids.push(id); + } + ids.sort((id1, id2) => id1 - id2); + return ids.join(','); + } +} diff --git a/src/jsutils/__tests__/SetMap-test.ts b/src/jsutils/__tests__/SetMap-test.ts new file mode 100644 index 0000000000..73410d5335 --- /dev/null +++ b/src/jsutils/__tests__/SetMap-test.ts @@ -0,0 +1,120 @@ +import { describe, it } from 'node:test'; + +import { expect } from 'chai'; + +import { spyOn } from '../../__testUtils__/spyOn.ts'; + +import { SetMap } from '../SetMap.ts'; + +describe('SetMap', () => { + it('maps exact sets regardless of insertion order', () => { + const cache = new SetMap(); + const a = {}; + const b = {}; + + cache.set(new Set([a, b]), 0); + + expect(cache.get(new Set([a, b]))).to.equal(0); + expect(cache.get(new Set([b, a]))).to.equal(0); + }); + + it('distinguishes sets which share members', () => { + const cache = new SetMap(); + const a = {}; + const b = {}; + const c = {}; + + cache.set(new Set([a, b]), 0); + cache.set(new Set([a, c]), 1); + cache.set(new Set([a, b, c]), 2); + cache.set(new Set([c, b]), 3); + + expect(cache.get(new Set([a, b]))).to.equal(0); + expect(cache.get(new Set([a, c]))).to.equal(1); + expect(cache.get(new Set([a, b, c]))).to.equal(2); + expect(cache.get(new Set([c, b]))).to.equal(3); + }); + + it('maps the empty set', () => { + const cache = new SetMap(); + + expect(cache.get(new Set())).to.equal(undefined); + cache.set(new Set(), 0); + expect(cache.get(new Set())).to.equal(0); + }); + + it('supports undefined values', () => { + const cache = new SetMap(); + const member = {}; + + cache.set(new Set([member]), undefined); + + expect(cache.get(new Set([member]))).to.equal(undefined); + expect(cache.has(new Set([member]))).to.equal(true); + }); + + it('iterates canonical keys and values in insertion order', () => { + const cache = new SetMap(); + const a = {}; + const b = {}; + const first = new Set([a]); + const second = new Set([a, b]); + + cache.set(first, 1); + cache.set(second, 2); + cache.set(new Set([a]), 3); + + expect(cache.size).to.equal(2); + expect(Array.from(cache.keys())).to.deep.equal([first, second]); + expect(Array.from(cache.values())).to.deep.equal([3, 2]); + expect(Array.from(cache)).to.deep.equal([ + [first, 3], + [second, 2], + ]); + }); + + it('can insert values on demand', () => { + const cache = new SetMap(); + const member = {}; + + expect(cache.getOrInsert(new Set([member]), 0)).to.equal(0); + expect(cache.getOrInsert(new Set([member]), 1)).to.equal(0); + }); + + it('can compute values on demand', () => { + const cache = new SetMap(); + const member = {}; + const compute = spyOn(() => 0); + + expect(cache.getOrInsertComputed(new Set([member]), compute)).to.equal(0); + expect(cache.getOrInsertComputed(new Set([member]), compute)).to.equal(0); + expect(compute.callCount).to.equal(1); + }); + + it('does not recreate undefined values', () => { + const cache = new SetMap(); + const member = {}; + const compute = spyOn(() => undefined); + + expect(cache.getOrInsertComputed(new Set([member]), compute)).to.equal( + undefined, + ); + expect(cache.getOrInsertComputed(new Set([member]), compute)).to.equal( + undefined, + ); + expect(compute.callCount).to.equal(1); + }); + + it('updates values inserted during computation', () => { + const cache = new SetMap(); + const member = {}; + + expect( + cache.getOrInsertComputed(new Set([member]), (members) => { + cache.set(members, 1); + return 2; + }), + ).to.equal(2); + expect(cache.get(new Set([member]))).to.equal(2); + }); +}); diff --git a/src/jsutils/getBySet.ts b/src/jsutils/getBySet.ts deleted file mode 100644 index acf1453509..0000000000 --- a/src/jsutils/getBySet.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { isSameSet } from './isSameSet.ts'; - -/** @internal */ -export function getBySet( - map: ReadonlyMap, U>, - setToMatch: ReadonlySet, -): U | undefined { - for (const set of map.keys()) { - if (isSameSet(set, setToMatch)) { - return map.get(set); - } - } - return undefined; -}