From b60ccf031bb738c1959a2b19593a557535c467b8 Mon Sep 17 00:00:00 2001 From: Daniel Rearden Date: Tue, 23 Jun 2026 19:47:56 +0000 Subject: [PATCH 1/2] feat(interpreter): populate a normalized info.path instead of throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interpreted field resolvers previously threw an ImplementationError on any `info.path` access, because breadth-first execution resolves every object at a level together and there is no per-object path with list indices. However, the most common reason resolvers read `info.path` is to build a stable per-field key (e.g. an ephemeral DataLoader cache key), for which the *normalized* path — the field's response-key ancestry without list indices — is exactly right, and is fully known from the ExecutionField scope chain. This populates `info.path` with that normalized Path (each segment carrying the typename it is selected on, matching graphql-js' addPath semantics), built lazily so resolvers that never read it pay nothing. Updates the interpreter test to assert the reconstructed path/typename chain. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/interpreter.ts | 59 +++++++++++++++++++++++----- test/interpreter/interpreter.test.ts | 36 ++++++++++++----- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/interpreter.ts b/src/interpreter.ts index ede5296..4fab734 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -30,10 +30,13 @@ import { isThenable } from "./util.ts"; * `abstractType.resolveType` or falls back to walking * `isTypeOf` on each possible type. * - * Restrictions, surfaced as errors: + * Notes: * - `info.path` — breadth-first execution resolves all objects at a level - * together, so there is no per-object resolution path. Accessing - * `info.path` throws an `ImplementationError`. All other + * together, so there is no per-object resolution path with list indices. + * `info.path` is instead populated with a *normalized* `Path` (the field's + * response-key ancestry, without list indices) reconstructed from the + * execution scope chain, which is what path-keyed resolver helpers (e.g. + * per-field DataLoader cache keys) actually need. All other * `GraphQLResolveInfo` fields are populated for field resolvers. * - Abstract type resolvers (`resolveType` / `isTypeOf`) still receive a * stub `info` that throws on any access — the planner invokes them @@ -42,6 +45,41 @@ import { isThenable } from "./util.ts"; * be synchronous. Returning a `Promise` throws an `ImplementationError`. */ +/** + * graphql-js does not publicly export its `Path` type, so model its shape + * (`{ prev, key, typename }`) locally — this is what `GraphQLResolveInfo.path` is. + */ +interface ResolvePath { + readonly prev: ResolvePath | undefined; + readonly key: string | number; + readonly typename: string | undefined; +} + +/** + * Reconstruct a *normalized* `Path` for an interpreted resolver from the + * `ExecutionField` scope chain. Breadth-first execution resolves every object at + * a level together, so there is no per-object path with list indices — but the + * field's response-key ancestry (and the type each key is defined on) is known, + * which is exactly the normalized path that path-keyed helpers rely on. Each + * segment's `typename` is the type the field is selected on, matching graphql-js' + * `addPath(prev, key, parentType.name)` semantics. + */ +function buildNormalizedPath(execField: ExecutionField): ResolvePath { + const segments: Array<{ key: string; typename: string }> = []; + let field: ExecutionField | null = execField; + while (field) { + segments.push({ key: field.key, typename: field.scope.parentType.name }); + field = field.scope.parentField; + } + segments.reverse(); + + let path: ResolvePath | undefined; + for (const segment of segments) { + path = { prev: path, key: segment.key, typename: segment.typename }; + } + return path as ResolvePath; +} + function makeInfo(execField: ExecutionField): GraphQLResolveInfo { const executor = execField.executor; const info = { @@ -55,14 +93,15 @@ function makeInfo(execField: ExecutionField): GraphQLResolveInfo { operation: executor.operation, variableValues: executor.variables, }; + // Build the normalized path lazily so resolvers that never read `info.path` + // pay nothing. + let cachedPath: ResolvePath | null = null; Object.defineProperty(info, "path", { - get(): never { - throw new ImplementationError( - `Interpreted resolver for '${execField.scope.parentType.name}.${execField.name}' ` + - `accessed 'info.path', but breadth-first execution resolves all objects at a ` + - `level together so there is no per-object resolution path. All other ` + - `GraphQLResolveInfo fields are populated.`, - ); + get(): ResolvePath { + if (cachedPath === null) { + cachedPath = buildNormalizedPath(execField); + } + return cachedPath; }, enumerable: false, configurable: false, diff --git a/test/interpreter/interpreter.test.ts b/test/interpreter/interpreter.test.ts index 39c43ec..198c6e5 100644 --- a/test/interpreter/interpreter.test.ts +++ b/test/interpreter/interpreter.test.ts @@ -230,23 +230,41 @@ describe("graphql-js interpreter shim", () => { assert.deepStrictEqual(captured.variableValues, { id: "1" }); }); - test("accessing info.path throws ImplementationError", () => { + test("info.path is a normalized Path rebuilt from the scope chain", () => { + let captured: unknown; + const userType = new GraphQLObjectType({ + name: "User", + fields: { + name: { + type: GraphQLString, + resolve: (src, _args, _ctx, info) => { + captured = info.path; + return (src as { name: string }).name; + }, + }, + }, + }); const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: "Query", fields: { - usesPath: { - type: GraphQLString, - resolve: (_src, _args, _ctx, info) => String(info.path), - }, + user: { type: userType, resolve: () => ({ name: "ada" }) }, }, }), }); - assert.throws(() => execute(schema, `{ usesPath }`), ImplementationError); - assert.throws(() => execute(schema, `{ usesPath }`), - /accessed 'info\.path'.*no per-object resolution path/, - ); + const result = execute(schema, `{ user { name } }`); + assert.deepStrictEqual(result.data, { user: { name: "ada" } }); + + // No list indices (breadth resolves a level together); the path is the + // response-key ancestry with each key's parent typename. + const path = captured as { key: string; prev?: unknown; typename: string }; + assert.strictEqual(path.key, "name"); + assert.strictEqual(path.typename, "User"); + const prev = path.prev as { key: string; prev?: unknown; typename: string }; + assert.strictEqual(prev.key, "user"); + assert.strictEqual(prev.typename, "Query"); + assert.strictEqual(prev.prev, undefined); }); test("manually constructed InterpretedFieldResolver works inside a hand-built ResolverMap", () => { From e799233e2506e6e0c1ade96671dd12c2c9a11fef Mon Sep 17 00:00:00 2001 From: Daniel Rearden Date: Fri, 26 Jun 2026 21:53:19 -0400 Subject: [PATCH 2/2] feat(interpreter): add precise info.path plus static info.schemaPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interpreter previously populated info.path with a normalized path (the field's response-key ancestry, no list indices), since breadth-first execution resolves every object at a level together and there is no per-object path to read off directly. Per upstream feedback, offer both the spec-precise path and a cheap static one: - info.path is now the real graphql-js Path for the object being resolved — response keys plus real list indices — reconstructed on demand by a new PathFormatter (ported from graphql-breadth-exec's Ruby Executor::PathFormatter). Computing it indexes the scopes it walks, so it is built lazily and cached per object index; resolvers that never read info.path pay nothing. - info.schemaPath (a BreadthResolveInfo extension) carries the "fast and static" path: the field's schema-name ancestry with no aliases or indices. A dirt-cheap, stable key — what most path-keyed helpers (e.g. per-field DataLoader cache keys) actually want. The port also closes a gap the Ruby formatter never exercised (it is instantiated but never wired in): abstract-type scopes bucket objects by concrete type, so the parent field's result order does not line up 1:1 with a concrete scope's objects. Indexing distributes the result-order suffixes back to each concrete sibling by object identity, so every bucket keeps its real list indices. New tests assert info.path byte-for-byte against graphql-js's own info.path as ground truth across nested lists and interleaved abstract-type buckets, plus null-in-list index preservation and the static schemaPath shape. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- src/executor/executor.ts | 14 ++ src/executor/path_formatter.ts | 187 +++++++++++++++++++++ src/index.ts | 2 + src/interpreter.ts | 97 ++++++----- test/interpreter/interpreter.test.ts | 239 +++++++++++++++++++++++++-- 6 files changed, 485 insertions(+), 56 deletions(-) create mode 100644 src/executor/path_formatter.ts diff --git a/README.md b/README.md index 5cb8151..39091b5 100644 --- a/README.md +++ b/README.md @@ -379,7 +379,7 @@ const resolvers = interpretSchema(schema, { **Support notes:** - Resolvers that return native Promises are awaited together as one breadth-loader cycle via `InterpretedPromiseLoader`, so a list of N async resolvers still yields once, not N times. This is generally compatible though may produce different results for situations designed around a depth-based execution flow. -- Accessing resolver `info.path` is not supported. Breadth has no concept of runtime subtrees (though this gap is possible to fill with overhead). +- Resolver `info.path` is the real, spec-compliant graphql-js `Path` (response keys plus list indices) for the object being resolved, reconstructed on demand via the executor's `PathFormatter`. Because breadth resolves a level all at once, the exact path of any single object is only known when asked for, so this is the "slow and precise" option — it indexes the scopes it walks, and is built lazily, so resolvers that never read `info.path` pay nothing. For a "fast and static" alternative, read `info.schemaPath` (cast `info` to `BreadthResolveInfo`): the field's schema-name ancestry with no aliases or indices — a dirt-cheap, stable key, which is what most path-keyed helpers (e.g. per-field DataLoader cache keys) actually want. - No support for lazy abstract type resolution. `resolveType` and `isTypeOf` returning a `Promise` throw an `ImplementationError`. ## Development diff --git a/src/executor/executor.ts b/src/executor/executor.ts index 9a319b0..926e2de 100644 --- a/src/executor/executor.ts +++ b/src/executor/executor.ts @@ -42,6 +42,7 @@ import { ExecutionField } from "./execution_field.ts"; import { ExecutionPlanner } from "./execution_planner.ts"; import { ExecutionPromise } from "./execution_promise.ts"; import { ExecutionScope } from "./execution_scope.ts"; +import { PathFormatter } from "./path_formatter.ts"; import type { ResolverMap, TypeResolverFn } from "./types.ts"; export interface BuildOptions { @@ -95,6 +96,7 @@ export class Executor { private loaderCache: Map> = new Map(); private _errorResultFormatter: ErrorResultFormatter | null = null; private _planner: ExecutionPlanner | null = null; + private _paths: PathFormatter | null = null; static build(options: BuildOptions): Executor { const document = @@ -182,6 +184,18 @@ export class Executor { return this.invalidatedResults.size; } + /** + * Lazily-built formatter for *real*, spec-compliant object paths (the kind + * graphql-js exposes on `info.path`, with list indices). Indexing scopes is + * extra work, so it's only created when something actually asks for a path. + */ + get paths(): PathFormatter { + if (!this._paths) { + this._paths = new PathFormatter(); + } + return this._paths; + } + get planner(): ExecutionPlanner { if (!this._planner) { this._planner = new ExecutionPlanner({ diff --git a/src/executor/path_formatter.ts b/src/executor/path_formatter.ts new file mode 100644 index 0000000..3162d52 --- /dev/null +++ b/src/executor/path_formatter.ts @@ -0,0 +1,187 @@ +import { type GraphQLOutputType } from "graphql"; +import { isListLike, unwrapNonNull } from "../util.ts"; +import type { ExecutionField } from "./execution_field.ts"; +import type { ExecutionScope } from "./execution_scope.ts"; + +/** + * graphql-js does not publicly export its `Path` type, so model its shape + * (`{ prev, key, typename }`) locally — this is what `GraphQLResolveInfo.path` is. + * `key` is a response key (field alias) for field segments and a numeric index + * for list segments; `typename` is the concrete object type a field segment was + * selected on (and `undefined` for list-index segments), matching graphql-js' + * `addPath(prev, key, parentType.name)` semantics. + */ +export interface ResolvePath { + readonly prev: ResolvePath | undefined; + readonly key: string | number; + readonly typename: string | undefined; +} + +/** + * For each object position in a scope, the "object path suffix" relative to the + * scope's parent: `[parentObjectIndex, listIndex1, listIndex2, ...]`. The first + * element recalibrates to the parent scope's object position; the remaining + * elements are the (real, null-inclusive) list indices wrapping this object. + */ +type ScopeIndex = ReadonlyArray>; + +/** + * Builds the *real*, spec-compliant object path to a specific breadth object + * position — e.g. `["products", 0, "variants", 1]`. This is the precise path + * graphql-js would expose on `info.path`, complete with list indices. + * + * Breadth-first execution resolves every object at a level together, so the + * exact path of any single object is not known until it's asked for. Computing + * it requires indexing each scope: walking the parent field's (possibly nested, + * list-wrapped) result to recover, for every surviving object, its parent + * object position and the list indices that wrap it. That indexing is real + * overhead, so it is done lazily — and only for the scopes on the path actually + * requested — and memoized per scope. This is the "slow and precise" path; the + * cheap, static alternative is `ExecutionField.schemaPath`. + * + * Ported from graphql-breadth-exec's `Executor::PathFormatter`, with one + * addition: abstract-type scopes bucket their objects by concrete type, so the + * parent field's result order does not line up 1:1 with a concrete scope's + * objects. Indexing distributes the result-order suffixes back to each concrete + * sibling scope by object identity, preserving real list indices per bucket. + */ +export class PathFormatter { + // Identity-keyed (Map uses object identity for keys), matching the Ruby + // formatter's `compare_by_identity`. + private indicesByScope = new Map(); + + /** + * The precise object path to the object at `index` within `scope.objects`, + * as a flat array of response keys and list indices (the shape used for + * spec `errors[].path`). Walks ancestor scopes, prepending each scope's + * response key and list indices. + */ + objectPath(scope: ExecutionScope, index: number): Array { + const path: Array = []; + let current: ExecutionScope | null = scope; + let breadthIndex = index; + + while (current) { + const suffix = this.indexFor(current)[breadthIndex] ?? [breadthIndex]; + // List indices (suffix[1..]) sit after the field key; prepend them so the + // segment ends up as `[key, listIndex1, listIndex2, ...]`. + for (let i = suffix.length - 1; i >= 1; i--) path.unshift(suffix[i] as number); + const key = current.parentField?.key; + if (key != null) path.unshift(key); + breadthIndex = (suffix[0] as number) ?? 0; + current = current.parent; + } + + return path; + } + + /** + * The graphql-js `info.path` (a `{ prev, key, typename }` linked list) for a + * field resolver running against the object at `index` within + * `execField.scope`. This is the path to that source object plus the field's + * own response key as the deepest segment. + */ + resolveInfoPath(execField: ExecutionField, index: number): ResolvePath { + // Collect segments deepest-first, then reverse into root→leaf order. + const segments: Array<{ key: string | number; typename: string | undefined }> = []; + let current: ExecutionScope | null = execField.scope; + let breadthIndex = index; + + while (current) { + const suffix = this.indexFor(current)[breadthIndex] ?? [breadthIndex]; + for (let i = suffix.length - 1; i >= 1; i--) { + segments.push({ key: suffix[i] as number, typename: undefined }); + } + const parentField = current.parentField; + if (parentField) { + // A field segment's typename is the concrete type the field was + // selected on — i.e. the parent scope's object type. + segments.push({ key: parentField.key, typename: parentField.scope.parentType.name }); + } + breadthIndex = (suffix[0] as number) ?? 0; + current = current.parent; + } + + segments.reverse(); + // The field being resolved contributes the deepest segment. It has no list + // index yet (its value is what the resolver is about to produce). + segments.push({ key: execField.key, typename: execField.scope.parentType.name }); + + let path: ResolvePath | undefined; + for (const segment of segments) { + path = { prev: path, key: segment.key, typename: segment.typename }; + } + return path as ResolvePath; + } + + /** + * The per-object path suffixes for a scope, computed and memoized on first + * access. Row `i` is the suffix for `scope.objects[i]`. + */ + private indexFor(scope: ExecutionScope): ScopeIndex { + const cached = this.indicesByScope.get(scope); + if (cached) return cached; + + const parentField = scope.parentField; + const parentObjects = (parentField ? parentField.result : scope.objects) as ReadonlyArray; + const currentType: GraphQLOutputType = parentField ? parentField.type : scope.parentType; + + // Walk the parent result in order, emitting a suffix per surviving leaf + // object. `tupleObjects[k]` is the object that produced `tuples[k]`. + const tuples: number[][] = []; + const tupleObjects: unknown[] = []; + for (let i = 0; i < parentObjects.length; i++) { + this.buildIndices(currentType, parentObjects[i], [i], tuples, tupleObjects); + } + + let rows: number[][]; + if (scope.abstraction) { + // Abstract scopes are one type bucket of the parent result. Distribute the + // result-order suffixes to this scope's objects by identity, in order — + // an object resolves to exactly one concrete type, so its bucket is + // unambiguous, and a duplicated instance stays within its bucket's order. + const objects = scope.objects; + rows = new Array(objects.length); + let ptr = 0; + for (let k = 0; k < tuples.length && ptr < objects.length; k++) { + if (tupleObjects[k] === objects[ptr]) rows[ptr++] = tuples[k] as number[]; + } + } else { + // Non-abstract scopes flat-map the parent result 1:1 in order. + rows = tuples; + } + + this.indicesByScope.set(scope, rows); + return rows; + } + + /** + * Recursively walk a (possibly list-wrapped) value, recording an index suffix + * for each surviving leaf object. List positions are pushed onto `objectPath` + * so that `objectPath` reads `[parentIndex, listIndex1, listIndex2, ...]` at + * each leaf. Nulls and errors produce no object (and no suffix), but their + * list slots still advance the index, so survivors keep their real positions. + */ + private buildIndices( + currentType: GraphQLOutputType, + object: unknown, + objectPath: number[], + tuples: number[][], + tupleObjects: unknown[], + ): void { + if (object == null || object instanceof Error) return; + + if (isListLike(currentType)) { + if (!Array.isArray(object)) return; + const elementType = (unwrapNonNull(currentType) as { ofType: GraphQLOutputType }).ofType; + for (let i = 0; i < object.length; i++) { + objectPath.push(i); + this.buildIndices(elementType, object[i], objectPath, tuples, tupleObjects); + objectPath.pop(); + } + } else { + tuples.push(objectPath.slice()); + tupleObjects.push(object); + } + } +} diff --git a/src/index.ts b/src/index.ts index 511e141..4dd2dec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export type { BuildOptions, GraphQLResult } from "./executor/executor.ts"; export { ExecutionField } from "./executor/execution_field.ts"; export { ExecutionScope } from "./executor/execution_scope.ts"; export { AbstractExecutionScope } from "./executor/abstract_execution_scope.ts"; +export { PathFormatter, type ResolvePath } from "./executor/path_formatter.ts"; export { ExecutionPromise, Deferred } from "./executor/execution_promise.ts"; export { HasAttributes } from "./executor/has_attributes.ts"; export { LazyLoader, type LazyLoaderConstructor } from "./lazy_loader.ts"; @@ -23,6 +24,7 @@ export { InterpretedFieldResolver, InterpretedPromiseLoader, interpretSchema, + type BreadthResolveInfo, type InterpretSchemaOptions, } from "./interpreter.ts"; export { diff --git a/src/interpreter.ts b/src/interpreter.ts index 4fab734..5ffab13 100644 --- a/src/interpreter.ts +++ b/src/interpreter.ts @@ -9,6 +9,7 @@ import { } from "graphql"; import { ExecutionError, ImplementationError } from "./errors.ts"; import { ExecutionField } from "./executor/execution_field.ts"; +import type { ResolvePath } from "./executor/path_formatter.ts"; import type { ResolverEntry, ResolverMap, TypeResolverFn } from "./executor/types.ts"; import { FieldResolver, ObjectKeyResolver, type ResolveResult } from "./field_resolvers.ts"; import { LazyLoader, type LazyLoaderConstructor } from "./lazy_loader.ts"; @@ -31,13 +32,19 @@ import { isThenable } from "./util.ts"; * `isTypeOf` on each possible type. * * Notes: - * - `info.path` — breadth-first execution resolves all objects at a level - * together, so there is no per-object resolution path with list indices. - * `info.path` is instead populated with a *normalized* `Path` (the field's - * response-key ancestry, without list indices) reconstructed from the - * execution scope chain, which is what path-keyed resolver helpers (e.g. - * per-field DataLoader cache keys) actually need. All other - * `GraphQLResolveInfo` fields are populated for field resolvers. + * - `info.path` — populated with the *real*, spec-compliant graphql-js `Path` + * (a `{ prev, key, typename }` linked list, with list indices) for the + * object the resolver is currently handling, reconstructed on demand via the + * executor's `PathFormatter`. This is the "slow and precise" path: computing + * it indexes the relevant scopes, so it's built lazily — resolvers that + * never read `info.path` pay nothing. + * - `info.schemaPath` — a non-standard accessor carrying the "fast and static" + * path: the field's schema-name ancestry without list indices + * (`ExecutionField.schemaPath`). It's a dirt-cheap, stable key (alias- and + * index-independent), which is what most path-keyed resolver helpers (e.g. + * per-field DataLoader cache keys) actually want. Cast `info` to + * `BreadthResolveInfo` to read it. All other `GraphQLResolveInfo` fields are + * populated for field resolvers. * - Abstract type resolvers (`resolveType` / `isTypeOf`) still receive a * stub `info` that throws on any access — the planner invokes them * without an `ExecutionField` in scope. @@ -46,42 +53,30 @@ import { isThenable } from "./util.ts"; */ /** - * graphql-js does not publicly export its `Path` type, so model its shape - * (`{ prev, key, typename }`) locally — this is what `GraphQLResolveInfo.path` is. + * `GraphQLResolveInfo` as seen by interpreted resolvers, extended with the + * breadth executor's static `schemaPath` accessor. Standard graphql-js + * resolvers can read `info.path` directly; `info.schemaPath` requires this type. */ -interface ResolvePath { - readonly prev: ResolvePath | undefined; - readonly key: string | number; - readonly typename: string | undefined; +export interface BreadthResolveInfo extends GraphQLResolveInfo { + /** + * The field's schema-name ancestry (no aliases, no list indices) — a cheap, + * static, stable key. See {@link InterpretedFieldResolver}. + */ + readonly schemaPath: ReadonlyArray; } /** - * Reconstruct a *normalized* `Path` for an interpreted resolver from the - * `ExecutionField` scope chain. Breadth-first execution resolves every object at - * a level together, so there is no per-object path with list indices — but the - * field's response-key ancestry (and the type each key is defined on) is known, - * which is exactly the normalized path that path-keyed helpers rely on. Each - * segment's `typename` is the type the field is selected on, matching graphql-js' - * `addPath(prev, key, parentType.name)` semantics. + * Build the `GraphQLResolveInfo` for an interpreted field. A single `info` is + * reused across the breadth of objects the field resolves; `setObjectIndex` + * selects which object `info.path` describes. Both `path` (precise, per-object) + * and `schemaPath` (static) are lazy getters so unused ones cost nothing. */ -function buildNormalizedPath(execField: ExecutionField): ResolvePath { - const segments: Array<{ key: string; typename: string }> = []; - let field: ExecutionField | null = execField; - while (field) { - segments.push({ key: field.key, typename: field.scope.parentType.name }); - field = field.scope.parentField; - } - segments.reverse(); - - let path: ResolvePath | undefined; - for (const segment of segments) { - path = { prev: path, key: segment.key, typename: segment.typename }; - } - return path as ResolvePath; -} - -function makeInfo(execField: ExecutionField): GraphQLResolveInfo { +function makeInfo(execField: ExecutionField): { + info: GraphQLResolveInfo; + setObjectIndex: (index: number) => void; +} { const executor = execField.executor; + let objectIndex = 0; const info = { fieldName: execField.name, fieldNodes: execField.nodes, @@ -93,20 +88,35 @@ function makeInfo(execField: ExecutionField): GraphQLResolveInfo { operation: executor.operation, variableValues: executor.variables, }; - // Build the normalized path lazily so resolvers that never read `info.path` - // pay nothing. + // The precise path depends on which object in the breadth is being resolved, + // so cache per index. Resolvers read `info.path` synchronously during their + // call, before `setObjectIndex` advances to the next object. let cachedPath: ResolvePath | null = null; + let cachedPathIndex = -1; Object.defineProperty(info, "path", { get(): ResolvePath { - if (cachedPath === null) { - cachedPath = buildNormalizedPath(execField); + if (cachedPath === null || cachedPathIndex !== objectIndex) { + cachedPath = executor.paths.resolveInfoPath(execField, objectIndex); + cachedPathIndex = objectIndex; } return cachedPath; }, enumerable: false, configurable: false, }); - return info as unknown as GraphQLResolveInfo; + Object.defineProperty(info, "schemaPath", { + get(): ReadonlyArray { + return execField.schemaPath; + }, + enumerable: false, + configurable: false, + }); + return { + info: info as unknown as GraphQLResolveInfo, + setObjectIndex: (index: number): void => { + objectIndex = index; + }, + }; } /** @@ -150,7 +160,7 @@ export class InterpretedFieldResolver ext override resolve(execField: ExecutionField, context: TContext): ResolveResult { const args = execField.arguments; - const info = makeInfo(execField); + const { info, setObjectIndex } = makeInfo(execField); const objects = execField.objects; const results: unknown[] = new Array(objects.length); const promises: PromiseLike[] = []; @@ -158,6 +168,7 @@ export class InterpretedFieldResolver ext for (let i = 0; i < objects.length; i++) { let value: unknown; + setObjectIndex(i); try { value = this.resolveFn(objects[i] as TSource, args, context, info); } catch (e) { diff --git a/test/interpreter/interpreter.test.ts b/test/interpreter/interpreter.test.ts index 198c6e5..bb6e39e 100644 --- a/test/interpreter/interpreter.test.ts +++ b/test/interpreter/interpreter.test.ts @@ -7,10 +7,12 @@ import { GraphQLList, GraphQLNonNull, GraphQLObjectType, + type GraphQLResolveInfo, GraphQLSchema, GraphQLString, GraphQLUnionType, buildSchema, + graphql, } from "graphql"; import { Executor, @@ -66,6 +68,20 @@ function executeAsync( ); } +/** A graphql-js `Path` node — `info.path`'s shape, modelled locally. */ +type PathNode = { prev?: PathNode; key: string | number; typename?: string }; + +/** Flatten a `{ prev, key, typename }` linked Path into a root→leaf array. */ +function flattenPath(path: PathNode | undefined): Array<{ key: string | number; typename?: string }> { + const out: Array<{ key: string | number; typename?: string }> = []; + let node = path; + while (node) { + out.unshift({ key: node.key, typename: node.typename }); + node = node.prev; + } + return out; +} + describe("graphql-js interpreter shim", () => { describe("InterpretedFieldResolver", () => { test("invokes a sync resolver once per object and returns its values", () => { @@ -230,7 +246,7 @@ describe("graphql-js interpreter shim", () => { assert.deepStrictEqual(captured.variableValues, { id: "1" }); }); - test("info.path is a normalized Path rebuilt from the scope chain", () => { + test("info.path is the real graphql-js Path (response keys + typenames)", () => { let captured: unknown; const userType = new GraphQLObjectType({ name: "User", @@ -253,18 +269,217 @@ describe("graphql-js interpreter shim", () => { }), }); - const result = execute(schema, `{ user { name } }`); - assert.deepStrictEqual(result.data, { user: { name: "ada" } }); + const result = execute(schema, `{ alias: user { name } }`); + assert.deepStrictEqual(result.data, { alias: { name: "ada" } }); + + // Field segments use the response key (alias) and the type the field is + // selected on; no list indices since nothing here is a list. + assert.deepStrictEqual(flattenPath(captured as PathNode), [ + { key: "alias", typename: "Query" }, + { key: "name", typename: "User" }, + ]); + }); + + test("info.path carries real list indices, matching graphql-js exactly", async () => { + // Nested-list schema; the leaf resolver records the precise path keyed by + // its own (unique) value, so we can compare graphql-js vs. the breadth + // executor object-for-object. + const sdl = ` + type Variant { sku: String } + type Product { variants: [[Variant]] } + type Query { products: [Product] } + `; + const rootValue = { + products: [ + { variants: [[{ sku: "a" }, { sku: "b" }], [{ sku: "c" }]] }, + { variants: [[{ sku: "d" }]] }, + ], + }; + const query = `{ products { variants { sku } } }`; + + function buildPathCapturingSchema(): { + schema: GraphQLSchema; + captured: Map>; + } { + const schema = buildSchema(sdl); + const captured = new Map>(); + (schema.getType("Variant") as GraphQLObjectType).getFields()["sku"]!.resolve = ( + src: unknown, + _args: unknown, + _ctx: unknown, + info: GraphQLResolveInfo, + ) => { + const sku = (src as { sku: string }).sku; + captured.set(sku, flattenPath(info.path as PathNode)); + return sku; + }; + return { schema, captured }; + } + + const reference = buildPathCapturingSchema(); + await graphql({ schema: reference.schema, source: query, rootValue }); + + const breadth = buildPathCapturingSchema(); + execute(breadth.schema, query, { + rootObject: rootValue, + resolvers: interpretSchema(breadth.schema), + }); + + assert.deepStrictEqual( + [...breadth.captured.keys()].sort(), + ["a", "b", "c", "d"], + ); + // graphql-js is ground truth for the exact indices/typenames. + for (const sku of breadth.captured.keys()) { + assert.deepStrictEqual( + breadth.captured.get(sku), + reference.captured.get(sku), + `path mismatch for "${sku}"`, + ); + } + // Spot-check the actual expected shape too. + assert.deepStrictEqual(breadth.captured.get("c"), [ + { key: "products", typename: "Query" }, + { key: 0, typename: undefined }, + { key: "variants", typename: "Product" }, + { key: 1, typename: undefined }, + { key: 0, typename: undefined }, + { key: "sku", typename: "Variant" }, + ]); + }); + + test("info.path indices are correct across abstract-type buckets", async () => { + // Interleaved concrete types in one list: the breadth executor buckets + // them by type, so the second bucket's list indices must still be the + // real positions (2 and 3), not bucket-local (0 and 1). + const sdl = ` + interface Node { kind: String } + type Alpha implements Node { kind: String, tag: String } + type Beta implements Node { kind: String, tag: String } + type Query { nodes: [Node] } + `; + const rootValue = { + nodes: [ + { kind: "Alpha", tag: "a0" }, + { kind: "Beta", tag: "b1" }, + { kind: "Beta", tag: "b2" }, + { kind: "Alpha", tag: "a3" }, + ], + }; + const query = `{ nodes { ... on Alpha { tag } ... on Beta { tag } } }`; + + function build(): { + schema: GraphQLSchema; + captured: Map>; + } { + const schema = buildSchema(sdl); + const captured = new Map>(); + (schema.getType("Node") as GraphQLInterfaceType).resolveType = (value) => + (value as { kind: string }).kind; + for (const typeName of ["Alpha", "Beta"]) { + (schema.getType(typeName) as GraphQLObjectType).getFields()["tag"]!.resolve = ( + src: unknown, + _args: unknown, + _ctx: unknown, + info: GraphQLResolveInfo, + ) => { + const tag = (src as { tag: string }).tag; + captured.set(tag, flattenPath(info.path as PathNode)); + return tag; + }; + } + return { schema, captured }; + } + + const reference = build(); + await graphql({ schema: reference.schema, source: query, rootValue }); + + const breadth = build(); + execute(breadth.schema, query, { + rootObject: rootValue, + resolvers: interpretSchema(breadth.schema), + }); - // No list indices (breadth resolves a level together); the path is the - // response-key ancestry with each key's parent typename. - const path = captured as { key: string; prev?: unknown; typename: string }; - assert.strictEqual(path.key, "name"); - assert.strictEqual(path.typename, "User"); - const prev = path.prev as { key: string; prev?: unknown; typename: string }; - assert.strictEqual(prev.key, "user"); - assert.strictEqual(prev.typename, "Query"); - assert.strictEqual(prev.prev, undefined); + assert.deepStrictEqual([...breadth.captured.keys()].sort(), ["a0", "a3", "b1", "b2"]); + for (const tag of breadth.captured.keys()) { + assert.deepStrictEqual( + breadth.captured.get(tag), + reference.captured.get(tag), + `path mismatch for "${tag}"`, + ); + } + // The fourth node (Alpha "a3", an Alpha bucket sibling of "a0") keeps its + // real index 3. + assert.deepStrictEqual(breadth.captured.get("a3"), [ + { key: "nodes", typename: "Query" }, + { key: 3, typename: undefined }, + { key: "tag", typename: "Alpha" }, + ]); + // The Beta bucket's second member keeps real index 2, not bucket-local 1. + assert.deepStrictEqual(breadth.captured.get("b2"), [ + { key: "nodes", typename: "Query" }, + { key: 2, typename: undefined }, + { key: "tag", typename: "Beta" }, + ]); + }); + + test("info.path keeps real list indices when earlier list entries are null", () => { + let captured: unknown; + const schema = buildSchema(` + type Item { label: String } + type Query { items: [Item] } + `); + (schema.getType("Item") as GraphQLObjectType).getFields()["label"]!.resolve = ( + src: unknown, + _args: unknown, + _ctx: unknown, + info: GraphQLResolveInfo, + ) => { + captured = info.path; + return (src as { label: string }).label; + }; + + // First two list slots are null; the surviving item is at real index 2. + const result = execute(schema, `{ items { label } }`, { + rootObject: { items: [null, null, { label: "third" }] }, + }); + assert.deepStrictEqual(result.data, { + items: [null, null, { label: "third" }], + }); + assert.deepStrictEqual(flattenPath(captured as PathNode), [ + { key: "items", typename: "Query" }, + { key: 2, typename: undefined }, + { key: "label", typename: "Item" }, + ]); + }); + + test("info.schemaPath is the static schema-name ancestry (no aliases, no indices)", () => { + let captured: unknown; + const schema = buildSchema(` + type Variant { sku: String } + type Product { variants: [Variant] } + type Query { products: [Product] } + `); + (schema.getType("Variant") as GraphQLObjectType).getFields()["sku"]!.resolve = ( + src: unknown, + _args: unknown, + _ctx: unknown, + info: GraphQLResolveInfo, + ) => { + // schemaPath is a breadth-executor extension on info. + captured = (info as unknown as { schemaPath: ReadonlyArray }).schemaPath; + return (src as { sku: string }).sku; + }; + + const result = execute(schema, `{ shelf: products { items: variants { sku } } }`, { + rootObject: { products: [{ variants: [{ sku: "x" }, { sku: "y" }] }] }, + }); + assert.deepStrictEqual(result.data, { + shelf: [{ items: [{ sku: "x" }, { sku: "y" }] }], + }); + // Aliases (shelf/items) and list indices are absent — it's the stable, + // static schema field names. + assert.deepStrictEqual(captured, ["products", "variants", "sku"]); }); test("manually constructed InterpretedFieldResolver works inside a hand-built ResolverMap", () => {