Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/executor/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -95,6 +96,7 @@ export class Executor {
private loaderCache: Map<LazyLoaderConstructor, Map<string, LazyLoader>> = new Map();
private _errorResultFormatter: ErrorResultFormatter | null = null;
private _planner: ExecutionPlanner | null = null;
private _paths: PathFormatter | null = null;

static build(options: BuildOptions): Executor {
const document =
Expand Down Expand Up @@ -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({
Expand Down
187 changes: 187 additions & 0 deletions src/executor/path_formatter.ts
Original file line number Diff line number Diff line change
@@ -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<ReadonlyArray<number>>;

/**
* 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<ExecutionScope, ScopeIndex>();

/**
* 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<string | number> {
const path: Array<string | number> = [];
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<unknown>;
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);
}
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +24,7 @@ export {
InterpretedFieldResolver,
InterpretedPromiseLoader,
interpretSchema,
type BreadthResolveInfo,
type InterpretSchemaOptions,
} from "./interpreter.ts";
export {
Expand Down
80 changes: 65 additions & 15 deletions src/interpreter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,20 +31,52 @@ import { isThenable } from "./util.ts";
* `abstractType.resolveType` or falls back to walking
* `isTypeOf` on each possible type.
*
* Restrictions, surfaced as errors:
* - `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
* `GraphQLResolveInfo` fields are populated for field resolvers.
* Notes:
* - `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.
* - Async `resolveType` / `isTypeOf` — abstract type discrimination must
* be synchronous. Returning a `Promise` throws an `ImplementationError`.
*/

function makeInfo(execField: ExecutionField): GraphQLResolveInfo {
/**
* `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.
*/
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<string>;
}

/**
* 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 makeInfo(execField: ExecutionField): {
info: GraphQLResolveInfo;
setObjectIndex: (index: number) => void;
} {
const executor = execField.executor;
let objectIndex = 0;
const info = {
fieldName: execField.name,
fieldNodes: execField.nodes,
Expand All @@ -55,19 +88,35 @@ function makeInfo(execField: ExecutionField): GraphQLResolveInfo {
operation: executor.operation,
variableValues: executor.variables,
};
// 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(): 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 || cachedPathIndex !== objectIndex) {
cachedPath = executor.paths.resolveInfoPath(execField, objectIndex);
cachedPathIndex = objectIndex;
}
return cachedPath;
},
enumerable: false,
configurable: false,
});
Object.defineProperty(info, "schemaPath", {
get(): ReadonlyArray<string> {
return execField.schemaPath;
},
enumerable: false,
configurable: false,
});
return info as unknown as GraphQLResolveInfo;
return {
info: info as unknown as GraphQLResolveInfo,
setObjectIndex: (index: number): void => {
objectIndex = index;
},
};
}

/**
Expand Down Expand Up @@ -111,14 +160,15 @@ export class InterpretedFieldResolver<TSource = unknown, TContext = unknown> 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<unknown>[] = [];
const promiseIndices: number[] = [];

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) {
Expand Down
Loading