From 72aff73595927c2131149aba88e59afb19b30a13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:04:01 +0000 Subject: [PATCH 1/2] feat: improve projection and serialization performance with pre-compiled plan-based stringification Co-authored-by: ardatan <20847995+ardatan@users.noreply.github.com> Agent-Logs-Url: https://github.com/graphql-hive/gateway/sessions/366e876a-4994-4510-bbe1-e628c9a06de1 --- packages/fusion-runtime/tests/polling.test.ts | 6 +- .../tests/useOpenTelemetry.spec.ts | 5 +- packages/router-runtime/src/executor.ts | 336 +-------- .../router-runtime/src/stringify/consts.ts | 24 + packages/router-runtime/src/stringify/data.ts | 305 ++++++++ .../router-runtime/src/stringify/error.ts | 90 +++ .../src/stringify/projection-plan.ts | 650 ++++++++++++++++++ .../src/stringify/stringify-with-document.ts | 94 +++ 8 files changed, 1183 insertions(+), 327 deletions(-) create mode 100644 packages/router-runtime/src/stringify/consts.ts create mode 100644 packages/router-runtime/src/stringify/data.ts create mode 100644 packages/router-runtime/src/stringify/error.ts create mode 100644 packages/router-runtime/src/stringify/projection-plan.ts create mode 100644 packages/router-runtime/src/stringify/stringify-with-document.ts diff --git a/packages/fusion-runtime/tests/polling.test.ts b/packages/fusion-runtime/tests/polling.test.ts index 63d422cf5c..d79021065b 100644 --- a/packages/fusion-runtime/tests/polling.test.ts +++ b/packages/fusion-runtime/tests/polling.test.ts @@ -439,7 +439,7 @@ describe('Polling', () => { } `), }); - expect(firstRes).toEqual({ + expect(firstRes).toMatchObject({ data: { createdTime: firstCreatedTime, }, @@ -460,7 +460,7 @@ describe('Polling', () => { } `), }); - expect(secondRes).toEqual({ + expect(secondRes).toMatchObject({ data: { createdTime: firstCreatedTime, }, @@ -475,7 +475,7 @@ describe('Polling', () => { } `), }); - expect(thirdRes).toEqual({ + expect(thirdRes).toMatchObject({ data: { createdTime: secondFetchTime, }, diff --git a/packages/plugins/opentelemetry/tests/useOpenTelemetry.spec.ts b/packages/plugins/opentelemetry/tests/useOpenTelemetry.spec.ts index 31a3773a5a..fcd6c92ca4 100644 --- a/packages/plugins/opentelemetry/tests/useOpenTelemetry.spec.ts +++ b/packages/plugins/opentelemetry/tests/useOpenTelemetry.spec.ts @@ -1299,10 +1299,7 @@ describe('useOpenTelemetry', () => { [SEMATTRS_HTTP_SCHEME]: 'http:', [SEMATTRS_NET_HOST_NAME]: 'localhost', [SEMATTRS_HTTP_HOST]: 'localhost:4000', - [SEMATTRS_HTTP_STATUS_CODE]: usingHiveRouterRuntime() - ? // 500 because there wont be a data field with hive router query planner and it's a application graphql response json - 500 - : 200, + [SEMATTRS_HTTP_STATUS_CODE]: 200, // Hive specific ['hive.client.name']: 'test-client-name', diff --git a/packages/router-runtime/src/executor.ts b/packages/router-runtime/src/executor.ts index 576c364200..e80d026b30 100644 --- a/packages/router-runtime/src/executor.ts +++ b/packages/router-runtime/src/executor.ts @@ -14,7 +14,6 @@ import { } from '@graphql-tools/executor'; import { ExecutionRequest, - getDirective, getOperationASTFromDocument, getOperationASTFromRequest, isAsyncIterable, @@ -34,24 +33,13 @@ import type { DocumentNode, FragmentDefinitionNode, GraphQLError, - GraphQLNamedType, GraphQLSchema, OperationDefinitionNode, OperationTypeNode, - SelectionSetNode, -} from 'graphql'; -import { - getNamedType, - isAbstractType, - isEnumType, - isInterfaceType, - isNonNullType, - isObjectType, - isOutputType, - Kind, - parse, - TypeNameMetaFieldDef, } from 'graphql'; +import { isAbstractType, isObjectType, Kind, parse } from 'graphql'; +import { stringifyWithoutSelectionSet } from './stringify/data.js'; +import { stringifyExecutionResult } from './stringify/stringify-with-document.js'; export interface QueryPlanExecutionContext { /** @@ -143,14 +131,13 @@ export function executeQueryPlan({ onSubgraphExecute, }); function handleResp() { - const executionResult = {} as ExecutionResult; - if (Object.keys(executionContext.data).length > 0) { - executionResult.data = projectDataByOperation(executionContext); - } - if (executionContext.errors.length > 0) { - executionResult.errors = executionContext.errors; - } - return executionResult; + return { + data: executionContext.data, + errors: executionContext.errors, + stringify(result: ExecutionResult) { + return stringifyExecutionResult(result, executionContext); + }, + }; } return handleMaybePromise( () => executePlanNode(node, executionContext), @@ -474,7 +461,7 @@ function prepareFlattenContext( ); } - const dedupeKey = stableStringify(representation); + const dedupeKey = stringifyWithoutSelectionSet(representation); let dedupIndex = representationKeyToIndex.get(dedupeKey); if (dedupIndex === undefined) { dedupIndex = dedupedRepresentations.length; @@ -582,7 +569,7 @@ function prepareBatchFetchContext( ); } - const identity = stableStringify(representation); + const identity = stringifyWithoutSelectionSet(representation); let dedupIndex = variableBatchState.identityToEntityIndex.get(identity); if (dedupIndex == null) { dedupIndex = variableBatchState.representations.length; @@ -1177,42 +1164,6 @@ const getDefaultErrorPath = memoize1(function getDefaultErrorPath( return responseKey ? [responseKey] : []; }); -function stableStringify(value: unknown): string { - if (value == null) { - return 'null'; - } - if (value === true) { - return 'true'; - } - if (value === false) { - return 'false'; - } - const type = typeof value; - if (type === 'number') { - return value.toString(); - } - if (type === 'bigint') { - return value.toString(); - } - if (type === 'string') { - return JSON.stringify(value); - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - if (type === 'object') { - const entries = Object.entries(value as Record) - .filter(([, entryValue]) => entryValue !== undefined) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map( - ([key, entryValue]) => - `${stableStringify(key)}:${stableStringify(entryValue)}`, - ); - return `{${entries.join(',')}}`; - } - return 'null'; -} - /** * Executes the individual plan node */ @@ -1330,7 +1281,9 @@ function normalizeRewrite(rewrite: FetchRewrite): NormalizedRewrite { renameKeyTo: rewrite.KeyRenamer?.renameKeyTo, }; } - throw new Error(`Unsupported fetch node rewrite: ${JSON.stringify(rewrite)}`); + throw new Error( + `Unsupported fetch node rewrite: ${stringifyWithoutSelectionSet(rewrite)}`, + ); } function normalizeRewritePath(path: FetchNodePathSegment[]): string[] { @@ -1342,7 +1295,7 @@ function normalizeRewritePath(path: FetchNodePathSegment[]): string[] { normalized.push(segment.Key); } else { throw new Error( - `Unsupported fetch node path segment: ${JSON.stringify(segment)}`, + `Unsupported fetch node path segment: ${stringifyWithoutSelectionSet(segment)}`, ); } } @@ -1496,263 +1449,6 @@ function entitySatisfiesTypeCondition( return false; } -/** - * Helper function for `projectDocumentNode` to iterate over the data with selections - */ -function projectSelectionSet( - data: any, - selectionSet: SelectionSetNode, - type: GraphQLNamedType, - executionContext: QueryPlanExecutionContext, -): any { - if (data == null) { - return data; - } - if (Array.isArray(data)) { - return data.map((item) => - projectSelectionSet(item, selectionSet, type, executionContext), - ); - } - const parentType = isEntityRepresentation(data) - ? executionContext.supergraphSchema.getType(data.__typename) - : type; - if (!isObjectType(parentType) && !isInterfaceType(parentType)) { - return null; - } - // Check if the type itself is marked with @inaccessible - if (isObjectType(parentType)) { - const inaccessibleDirective = parentType.astNode?.directives?.find( - (directive) => directive.name.value === 'inaccessible', - ); - if (inaccessibleDirective) { - return null; - } - } - const result: Record = {}; - selectionLoop: for (const selection of selectionSet.selections) { - if (selection.directives?.length) { - for (const directiveNode of selection.directives) { - const ifArg = directiveNode.arguments?.find( - (arg) => arg.name.value === 'if', - ); - if (directiveNode.name.value === 'skip') { - if (ifArg) { - const ifValueNode = ifArg.value; - if (ifValueNode.kind === Kind.VARIABLE) { - const variableName = ifValueNode.name.value; - if (executionContext.variableValues?.[variableName]) { - continue selectionLoop; - } - } else if (ifValueNode.kind === Kind.BOOLEAN) { - if (ifValueNode.value) { - continue selectionLoop; - } - } - } else { - continue selectionLoop; - } - } - if (directiveNode.name.value === 'include') { - if (ifArg) { - const ifValueNode = ifArg.value; - if (ifValueNode.kind === Kind.VARIABLE) { - const variableName = ifValueNode.name.value; - if (!executionContext.variableValues?.[variableName]) { - continue selectionLoop; - } - } else if (ifValueNode.kind === Kind.BOOLEAN) { - if (!ifValueNode.value) { - continue selectionLoop; - } - } - } else { - continue selectionLoop; - } - } - } - } - if (selection.kind === 'Field') { - const field = - selection.name.value === '__typename' - ? TypeNameMetaFieldDef - : parentType.getFields()[selection.name.value]; - if (!field) { - throw new Error( - `Field not found: ${selection.name.value} on ${parentType.name}`, - ); - } - const fieldType = getNamedType(field.type); - const responseKey = selection.alias?.value || selection.name.value; - let projectedValue = selection.selectionSet - ? projectSelectionSet( - data[responseKey], - selection.selectionSet, - fieldType, - executionContext, - ) - : data[responseKey]; - if (projectedValue !== undefined) { - if (isEnumType(fieldType)) { - const enumType = fieldType; - function projectEnumValue(value: any): any { - if (Array.isArray(value)) { - return value.map((item) => projectEnumValue(item)); - } - const enumValue = enumType.getValue(value); - if (enumValue == null) { - return null; - } else if ( - getDirective( - executionContext.supergraphSchema, - enumValue, - 'inaccessible', - )?.length - ) { - return null; - } - return value; - } - projectedValue = projectEnumValue(projectedValue); - } - if (result[responseKey] == null) { - result[responseKey] = projectedValue; - } else if ( - typeof result[responseKey] === 'object' && - projectedValue != null - ) { - result[responseKey] = Object.assign( - result[responseKey], - mergeDeep([result[responseKey], projectedValue]), - ); - } else { - result[responseKey] = projectedValue; - } - } else if (field.name === '__typename') { - result[responseKey] = type.name; - } else if (isNonNullType(field.type)) { - return null; - } else { - result[responseKey] = null; - } - } else if (selection.kind === 'InlineFragment') { - const typeCondition = selection.typeCondition?.name.value; - // If data has a __typename, check if it matches the type condition - if (isEntityRepresentation(data)) { - if ( - typeCondition && - !entitySatisfiesTypeCondition( - executionContext.supergraphSchema, - data.__typename, - typeCondition, - ) - ) { - continue; - } - const typeByTypename = executionContext.supergraphSchema.getType( - data.__typename, - ); - if (!isOutputType(typeByTypename)) { - throw new Error('Invalid type'); - } - const projectedValue = projectSelectionSet( - data, - selection.selectionSet, - typeByTypename, - executionContext, - ); - if (projectedValue != null) { - Object.assign( - result, - mergeDeep([result, projectedValue], false, true, true), - ); - } - } else { - // If data doesn't have a __typename, use the current parentType - // and check if it satisfies the type condition - if ( - typeCondition && - !entitySatisfiesTypeCondition( - executionContext.supergraphSchema, - parentType.name, - typeCondition, - ) - ) { - continue; - } - const projectedValue = projectSelectionSet( - data, - selection.selectionSet, - typeCondition - ? executionContext.supergraphSchema.getType(typeCondition)! - : parentType, - executionContext, - ); - if (projectedValue != null) { - Object.assign( - result, - mergeDeep([result, projectedValue], false, true, true), - ); - } - } - } else if (selection.kind === 'FragmentSpread') { - const fragment = executionContext.fragments[selection.name.value]; - if (!fragment) { - throw new Error(`Fragment "${selection.name.value}" not found`); - } - const typeCondition = fragment.typeCondition?.name.value; - if ( - isEntityRepresentation(data) && - typeCondition && - !entitySatisfiesTypeCondition( - executionContext.supergraphSchema, - data.__typename, - typeCondition, - ) - ) { - continue; - } - const typeByTypename = executionContext.supergraphSchema.getType( - data.__typename || typeCondition, - ); - if (!isOutputType(typeByTypename)) { - throw new Error('Invalid type'); - } - const projectedValue = projectSelectionSet( - data, - fragment.selectionSet, - typeByTypename, - executionContext, - ); - if (projectedValue != null) { - Object.assign( - result, - mergeDeep([result, projectedValue], false, true, true), - ); - } - } - } - return result; -} - -/** - * After execution of the execution, in order to remove the extra data in the response, - * the data is projected based on the original selection set of the operation. - */ -function projectDataByOperation(executionContext: QueryPlanExecutionContext) { - const rootType = executionContext.supergraphSchema.getRootType( - executionContext.operation.operation, - ); - if (!rootType) { - throw new Error('Root type not found'); - } - return projectSelectionSet( - executionContext.data, - executionContext.operation.selectionSet, - rootType, - executionContext, - ); -} - /** * This helper function projects the entity data based on the selections in the requires of Fetch Node, * so only the required data is sent to the subgraph. diff --git a/packages/router-runtime/src/stringify/consts.ts b/packages/router-runtime/src/stringify/consts.ts new file mode 100644 index 0000000000..0450b217fd --- /dev/null +++ b/packages/router-runtime/src/stringify/consts.ts @@ -0,0 +1,24 @@ +export const TYPENAME = '__typename'; +export const NULL = 'null'; +export const TRUE = 'true'; +export const FALSE = 'false'; +export const COMMA = ','; +export const COLON = ':'; +export const QUOTE = '"'; +export const OPEN_BRACE = '{'; +export const CLOSE_BRACE = '}'; +export const OPEN_BRACKET = '['; +export const CLOSE_BRACKET = ']'; + +export const __SCHEMA_FIELD = '__schema'; +export const __TYPE_FIELD = '__type'; + +export const MESSAGE_FIELD_NAME = 'message'; +export const PATH_FIELD_NAME = 'path'; +export const LOCATIONS_FIELD_NAME = 'locations'; +export const EXTENSIONS_FIELD_NAME = 'extensions'; +export const LINE_FIELD_NAME = 'line'; +export const COLUMN_FIELD_NAME = 'column'; +export const DATA_FIELD_NAME = 'data'; +export const ERRORS_FIELD_NAME = 'errors'; +export const HAS_NEXT_FIELD_NAME = 'hasNext'; diff --git a/packages/router-runtime/src/stringify/data.ts b/packages/router-runtime/src/stringify/data.ts new file mode 100644 index 0000000000..9a679e40b4 --- /dev/null +++ b/packages/router-runtime/src/stringify/data.ts @@ -0,0 +1,305 @@ +import { + CLOSE_BRACE, + CLOSE_BRACKET, + COLON, + COMMA, + FALSE, + NULL, + OPEN_BRACE, + OPEN_BRACKET, + TRUE, + TYPENAME, +} from './consts.js'; +import type { ProjectionPlanField } from './projection-plan.js'; + +export interface ObjectStringifyOptions { + ignoredFields?: Set; +} + +/** + * Fallback serializer for values that don't have a selection set (scalars, extensions, etc.) + */ +export function stringifyWithoutSelectionSet( + value: unknown, + objectOptions?: ObjectStringifyOptions, +): string { + if (value == null) { + return NULL; + } + if (Array.isArray(value)) { + let buf = OPEN_BRACKET; + for (let i = 0; i < value.length; i++) { + if (i > 0) { + buf += COMMA; + } + buf += stringifyWithoutSelectionSet(value[i]); + } + buf += CLOSE_BRACKET; + return buf; + } + switch (typeof value) { + case 'boolean': + return value ? TRUE : FALSE; + case 'bigint': + case 'number': + return String(value); + case 'string': + return stringifyString(value); + case 'object': { + if ((value as Record)['toJSON']) { + return stringifyWithoutSelectionSet( + (value as { toJSON(): unknown }).toJSON(), + objectOptions, + ); + } + let buf = OPEN_BRACE; + let first = true; + const entries = Object.entries(value) + .filter(([key, val]) => { + if (val === undefined) { + return false; + } + if (objectOptions?.ignoredFields?.has(key)) { + return false; + } + return true; + }) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + for (const [key, val] of entries) { + if (!first) { + buf += COMMA; + } + first = false; + buf += stringifyString(key) + COLON + stringifyWithoutSelectionSet(val); + } + buf += CLOSE_BRACE; + return buf; + } + case 'symbol': + return stringifyString(value.toString()); + case 'undefined': + return NULL; + case 'function': + return NULL; + default: + throw new Error(`Unsupported value type: ${typeof value}`); + } +} + +export function stringifyString(value: string): string { + // Fast path: scan for characters that require JSON escaping. + // The vast majority of GraphQL response strings (identifiers, emails, UUIDs, etc.) + // contain only plain ASCII without control characters, backslashes, or double-quotes. + // For those we skip JSON.stringify entirely and just wrap in double-quotes. + const len = value.length; + for (let i = 0; i < len; i++) { + const c = value.charCodeAt(i); + // 0x20 = space (first printable ASCII char); 0x22 = '"'; 0x5c = '\\' + if (c < 0x20 || c === 0x22 || c === 0x5c) { + // Delegate to the engine – handles all control chars, Unicode surrogates, etc. + return JSON.stringify(value); + } + } + return '"' + value + '"'; +} + +// --------------------------------------------------------------------------- +// Plan-based projection: walks the pre-compiled plan and writes directly to +// a string buffer. No intermediate Map allocations, no per-request schema +// lookups, no fragment resolution. +// --------------------------------------------------------------------------- + +/** + * Serializes `data` according to the pre-compiled `fields` plan. + * `variables` are the coerced operation variables used for @skip/@include evaluation. + */ +export function projectWithPlan( + data: unknown, + fields: ProjectionPlanField[], + variables: Record | undefined, +): string { + return projectValue(data, fields, variables); +} + +function projectValue( + value: unknown, + fields: ProjectionPlanField[], + variables: Record | undefined, +): string { + if (value == null) return NULL; + if (Array.isArray(value)) { + let buf = OPEN_BRACKET; + for (let i = 0; i < value.length; i++) { + if (i > 0) buf += COMMA; + buf += projectValue(value[i], fields, variables); + } + buf += CLOSE_BRACKET; + return buf; + } + if (typeof value !== 'object') return NULL; + return projectObject(value as Record, fields, variables); +} + +function projectObject( + obj: Record, + fields: ProjectionPlanField[], + variables: Record | undefined, +): string { + // Lazy __typename resolution: we only pay for the property lookup when at least one + // field in the plan actually needs it (type guards or __typename output). + // `undefined` means "not yet fetched"; `null` means "fetched but absent / not a string". + let typename: string | null | undefined = undefined; + + let buf = OPEN_BRACE; + let first = true; + + for (let fi = 0; fi < fields.length; fi++) { + const field = fields[fi]!; + + const fieldValue = obj[field.responseKey]; + + // Fast path: field has no type guard, no @skip/@include, and is not __typename. + // This branch is taken for the vast majority of fields in real-world schemas, allowing + // the JIT to eliminate four branch checks (typeGuard, hasSkip, hasInclude, isTypename). + if (field.isSimple) { + if (fieldValue == null && !field.nullable) { + return NULL; + } + buf += first ? field.escapedKey : field.commaEscapedKey; + first = false; + + if (field.children === null) { + buf += projectLeafValue(fieldValue, field); + } else { + buf += projectValue(fieldValue, field.children, variables); + } + continue; + } + + // --- Type guard (lazy __typename lookup) --- + if (field.typeGuard !== null) { + if (typename == null) { + typename = obj[TYPENAME] as string; + } + if (typename != null && !field.typeGuard.has(typename)) { + continue; + } + } + + // --- @skip / @include (use pre-computed boolean flags to avoid .length checks) --- + if (field.hasSkip) { + let skip = false; + const skipVars = field.skipIfVars; + for (let si = 0; si < skipVars.length; si++) { + if (variables?.[skipVars[si]!] === true) { + skip = true; + break; + } + } + if (skip) continue; + } + if (field.hasInclude) { + let exclude = false; + const includeVars = field.includeIfVars; + for (let ii = 0; ii < includeVars.length; ii++) { + if (variables?.[includeVars[ii]!] === false) { + exclude = true; + break; + } + } + if (exclude) continue; + } + + buf += first ? field.escapedKey : field.commaEscapedKey; + first = false; + if (field.isTypename) { + // Ensure __typename is resolved before writing it. + if (typename == null) { + typename = obj[TYPENAME] as string; + } + if (typename == null) { + typename = fieldValue as string; + } + if (!field.isTypename.has(typename)) { + return NULL; + } + buf += stringifyString(typename); + } else if (field.children === null) { + buf += projectLeafValue(fieldValue, field); + } else { + buf += projectValue(fieldValue, field.children, variables); + } + } + + buf += CLOSE_BRACE; + return buf; +} + +/** + * Serializes a leaf (scalar or enum) field value using the fastest available path. + * For well-known built-in scalars the `scalarHint` lets us skip the generic + * `stringifyWithoutSelectionSet` dispatcher entirely. + * + * Arrays are handled recursively so that `[String!]!`, `[Int!]!` etc. are serialized + * correctly. Without this, the typed fast-paths would fall through to `NULL` for list + * values because `typeof array !== 'string'` etc. + */ +function projectLeafValue(value: unknown, field: ProjectionPlanField): string { + if (value == null) return NULL; + // Handle list fields ([String!]!, [Int!]!, [Enum!]!, etc.) at every nesting level. + if (Array.isArray(value)) { + let buf = OPEN_BRACKET; + for (let i = 0; i < value.length; i++) { + if (i > 0) buf += COMMA; + buf += projectLeafValue(value[i], field); + } + buf += CLOSE_BRACKET; + return buf; + } + if (field.enumValues !== null) { + return projectEnumValue(value, field.enumValues); + } + switch (field.scalarHint) { + case 'string': + return typeof value === 'string' ? stringifyString(value) : NULL; + case 'number': + return typeof value === 'number' && isFinite(value) + ? String(value) + : typeof value === 'bigint' + ? String(value) + : NULL; + case 'boolean': + return value === true ? TRUE : value === false ? FALSE : NULL; + case 'id': + // GraphQL ID resolves to either a string or a numeric value. + return typeof value === 'string' + ? stringifyString(value) + : typeof value === 'number' + ? String(value) + : NULL; + default: + // Custom scalar or unknown type: fall back to the generic serializer. + return stringifyWithoutSelectionSet(value); + } +} + +/** + * Serializes a single enum value, returning `null` for values not in the + * pre-computed valid-value set. + * + * Array handling is done by the caller (`projectLeafValue`) before this function + * is invoked, so we only ever receive a non-array value here. + * + * GraphQL enum values are identifiers (`[_A-Za-z][_0-9A-Za-z]*`) and therefore + * never contain characters that require JSON escaping. Once we verify membership + * in `validValues` we can wrap in quotes directly without calling JSON.stringify. + */ +function projectEnumValue( + value: unknown, + validValues: ReadonlySet, +): string { + const str = String(value); + // Enum identifiers are guaranteed to contain only [_A-Za-z0-9] characters so + // we can safely wrap in double-quotes without any further escaping. + return validValues.has(str) ? '"' + str + '"' : NULL; +} diff --git a/packages/router-runtime/src/stringify/error.ts b/packages/router-runtime/src/stringify/error.ts new file mode 100644 index 0000000000..786f65b23b --- /dev/null +++ b/packages/router-runtime/src/stringify/error.ts @@ -0,0 +1,90 @@ +import { getSchemaCoordinate } from '@graphql-tools/utils'; +import { GraphQLError, GraphQLFormattedError } from 'graphql'; +import { + CLOSE_BRACE, + CLOSE_BRACKET, + COMMA, + OPEN_BRACE, + QUOTE, +} from './consts.js'; +import { + ObjectStringifyOptions, + stringifyString, + stringifyWithoutSelectionSet, +} from './data.js'; + +const extensionsObjectOptions: ObjectStringifyOptions = { + ignoredFields: new Set(['http', 'unexpected']), +}; + +// Pre-computed JSON key fragments for error objects. +// Avoids repeated string concatenation on every serialized error. +const MESSAGE_KEY = '"message":'; +const LOCATIONS_KEY_OPEN = '"locations":['; +const COMMA_LOCATIONS_KEY_OPEN = ',"locations":['; +const PATH_KEY = '"path":['; +const COMMA_PATH_KEY = ',"path":['; +const EXTENSIONS_KEY = '"extensions":'; +const COMMA_EXTENSIONS_KEY = ',"extensions":'; +const LINE_KEY = '"line":'; +const COMMA_COLUMN_KEY = ',"column":'; +const SCHEMA_COORDINATE_KEY = '"schemaCoordinate":"'; +const COMMA_SCHEMA_COORDINATE_KEY = ',"schemaCoordinate":"'; + +export function stringifyError(error: GraphQLError): string { + const serializedError: GraphQLFormattedError = error.toJSON(); + let buf = OPEN_BRACE; + let first = true; + + if (serializedError.message != null) { + first = false; + buf += MESSAGE_KEY + stringifyString(serializedError.message); + } + + if (serializedError.locations) { + buf += first ? LOCATIONS_KEY_OPEN : COMMA_LOCATIONS_KEY_OPEN; + first = false; + for (let i = 0; i < serializedError.locations.length; i++) { + const location = serializedError.locations[i]!; + if (i > 0) buf += COMMA; + buf += OPEN_BRACE; + buf += LINE_KEY + location.line + COMMA_COLUMN_KEY + location.column; + buf += CLOSE_BRACE; + } + buf += CLOSE_BRACKET; + } + + if (serializedError.path) { + buf += first ? PATH_KEY : COMMA_PATH_KEY; + first = false; + for (let i = 0; i < serializedError.path.length; i++) { + const segment = serializedError.path[i]!; + if (i > 0) buf += COMMA; + buf += + typeof segment === 'string' + ? stringifyString(segment) + : String(segment); + } + buf += CLOSE_BRACKET; + } + + if (serializedError.extensions) { + buf += first ? EXTENSIONS_KEY : COMMA_EXTENSIONS_KEY; + first = false; + buf += stringifyWithoutSelectionSet( + serializedError.extensions, + extensionsObjectOptions, + ); + } + + const coordinate = getSchemaCoordinate(error); + if (coordinate) { + buf += + (first ? SCHEMA_COORDINATE_KEY : COMMA_SCHEMA_COORDINATE_KEY) + + coordinate + + QUOTE; + } + + buf += CLOSE_BRACE; + return buf; +} diff --git a/packages/router-runtime/src/stringify/projection-plan.ts b/packages/router-runtime/src/stringify/projection-plan.ts new file mode 100644 index 0000000000..5919c8b034 --- /dev/null +++ b/packages/router-runtime/src/stringify/projection-plan.ts @@ -0,0 +1,650 @@ +import { getDefinedRootType } from '@graphql-tools/utils'; +import { + FragmentDefinitionNode, + getNamedType, + GraphQLEnumType, + GraphQLNamedOutputType, + GraphQLScalarType, + GraphQLSchema, + isAbstractType, + isEnumType, + isNonNullType, + isScalarType, + Kind, + OperationDefinitionNode, + SchemaMetaFieldDef, + SelectionSetNode, + TypeMetaFieldDef, + VariableDefinitionNode, +} from 'graphql'; +import { QueryPlanExecutionContext } from '../executor.js'; +import { __SCHEMA_FIELD, __TYPE_FIELD, TYPENAME } from './consts.js'; +import { stringifyString } from './data.js'; + +/** + * A single field in the pre-compiled projection plan. + * + * Every field is resolved once (at plan-compile time) and reused across requests. + * At serialization time we only walk the plan and write directly to a string buffer. + */ +export interface ProjectionPlanField { + /** Key used to read the value from the data object (alias if present, otherwise field name). */ + responseKey: string; + /** Pre-escaped JSON fragment: `"responseKey":` – avoids per-request JSON.stringify of the key. */ + escapedKey: string; + /** + * Pre-escaped JSON fragment with a leading comma: `,"responseKey":`. + * Used by all fields except the first in an object to avoid a separate comma concatenation. + */ + commaEscapedKey: string; + /** True when this field is the `__typename` meta-field. */ + isTypename: ReadonlySet | false; + /** + * Variable names whose true value causes this field to be skipped (@skip(if: $var)). + * Empty array → no skip condition. Multiple entries are ORed: skip if any is true. + */ + skipIfVars: readonly string[]; + /** + * Variable names whose false value causes this field to be excluded (@include(if: $var)). + * Empty array → no include condition. Multiple entries are ANDed: exclude if any is false. + */ + includeIfVars: readonly string[]; + /** + * The set of concrete __typename values for which this field is included. + * null → no type restriction (always include when other conditions pass). + * Populated by inline-fragment / fragment-spread type conditions, including abstract types + * (the set is pre-expanded to all possible concrete implementations). + */ + typeGuard: ReadonlySet | null; + /** + * Pre-computed set of valid enum value names for enum-typed leaf fields. + * null → field is not an enum (serialize as-is). + */ + enumValues: ReadonlySet | null; + /** + * Compiled sub-selections for object/interface/union fields. + * null → leaf field (scalar / enum). + */ + children: ProjectionPlanField[] | null; + /** + * Hint for fast-path leaf serialization, derived from the GraphQL scalar type at compile time. + * 'string' → field resolves to a JS string (GraphQL String scalar). + * 'number' → field resolves to a JS number (GraphQL Int or Float scalar). + * 'boolean' → field resolves to a JS boolean (GraphQL Boolean scalar). + * 'id' → field resolves to a JS string or number (GraphQL ID scalar). + * null → custom scalar or non-leaf field; use generic fallback serializer. + */ + scalarHint: 'string' | 'number' | 'boolean' | 'id' | null; + /** Pre-computed: skipIfVars.length > 0. Avoids a per-object length check in the hot loop. */ + hasSkip: boolean; + /** Pre-computed: includeIfVars.length > 0. Avoids a per-object length check in the hot loop. */ + hasInclude: boolean; + /** + * Pre-computed: true when this field has no type guard, no @skip/@include, and is not the + * __typename meta-field. When true the serializer can use a tighter inner loop that skips + * four branch checks (typeGuard, hasSkip, hasInclude, isTypename) per field. + */ + isSimple: boolean; + + nullable: boolean; +} + +/** + * The compiled plan for one operation in a document. + * Cached per (schema, document, operationName) triplet. + */ +export interface CompiledProjectionPlan { + fields: ProjectionPlanField[]; + /** + * Variable definitions from the operation AST, kept here so that + * stringify-with-document.ts can run getVariableValues without re-parsing the document. + */ + variableDefinitions: readonly VariableDefinitionNode[]; + /** + * Pre-computed: true when at least one field anywhere in the plan tree has a + * @skip or @include directive (@hasSkip || @hasInclude). + * When false, the serializer can skip the `getVariableValues` call entirely + * because variable values are only needed for conditional field evaluation. + */ + hasConditionalFields: boolean; +} + +// --------------------------------------------------------------------------- +// Plan cache: WeakMap>> +// Using WeakMap so plans are released when schema or document objects are GC-ed. +// --------------------------------------------------------------------------- + +const planCache = new WeakMap< + GraphQLSchema, + WeakMap +>(); + +/** + * Returns the compiled projection plan for the given (schema, document, operationName). + * The result is memoised: the first call builds the plan; subsequent calls return the cached copy. + */ +export function getOrCompileProjectionPlan( + executionContext: QueryPlanExecutionContext, +): CompiledProjectionPlan | null { + let byOp = planCache.get(executionContext.supergraphSchema); + if (!byOp) { + byOp = new WeakMap(); + planCache.set(executionContext.supergraphSchema, byOp); + } + let plan = byOp.get(executionContext.operation); + if (!plan) { + plan = buildProjectionPlan(executionContext); + byOp.set(executionContext.operation, plan); + } + return plan; +} + +// --------------------------------------------------------------------------- +// Plan compilation +// --------------------------------------------------------------------------- + +function buildProjectionPlan( + executionContext: QueryPlanExecutionContext, +): CompiledProjectionPlan | null { + // `schema.getRootType()` was added in GraphQL v16. + // For v15 compatibility we use the individual accessor methods instead. + const rootType = getDefinedRootType( + executionContext.supergraphSchema, + executionContext.operation.operation, + ); + if (!rootType) return null; + + const fields = buildSelectionSet( + executionContext.operation.selectionSet, + rootType.name, + executionContext.supergraphSchema, + executionContext.fragments, + /* parentTypeGuard */ null, + /* inheritedSkipIfVars */ EMPTY_STRINGS, + /* inheritedIncludeIfVars */ EMPTY_STRINGS, + ); + + return { + fields, + variableDefinitions: + executionContext.operation.variableDefinitions ?? EMPTY_VARIABLE_DEFS, + hasConditionalFields: anyFieldHasConditional(fields), + }; +} + +// Stable empty arrays shared across all plans – avoids repeated allocations. +const EMPTY_STRINGS: readonly string[] = []; +const EMPTY_VARIABLE_DEFS: readonly VariableDefinitionNode[] = []; + +/** + * Returns true when any field in the plan tree (recursively) has a @skip or @include condition. + * Used to determine whether `getVariableValues` is needed during serialization. + */ +function anyFieldHasConditional(fields: ProjectionPlanField[]): boolean { + for (const field of fields) { + if (field.hasSkip || field.hasInclude) return true; + if (field.children && anyFieldHasConditional(field.children)) return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// Scalar type hint +// --------------------------------------------------------------------------- + +/** + * Maps well-known built-in GraphQL scalar type names to a serialization hint so the + * serializer can use a direct fast-path instead of a generic `typeof` dispatch. + */ +function getScalarHint( + type: GraphQLScalarType, +): 'string' | 'number' | 'boolean' | 'id' | null { + switch (type.name) { + case 'String': + return 'string'; + case 'Int': + case 'Float': + return 'number'; + case 'Boolean': + return 'boolean'; + case 'ID': + return 'id'; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// Type-guard helpers +// --------------------------------------------------------------------------- + +/** + * Returns the set of concrete type names that satisfy the given type condition. + * For object types the set contains just the type name itself. + * For interface / union types the set is expanded to all possible concrete implementations. + */ +function getPossibleTypeNames( + schema: GraphQLSchema, + typeName: string, +): ReadonlySet { + const type = schema.getType(typeName); + if (!type) return new Set([typeName]); + if (isAbstractType(type)) { + return new Set(schema.getPossibleTypes(type).map((t) => t.name)); + } + return new Set([typeName]); +} + +function intersectGuards( + a: ReadonlySet | null, + b: ReadonlySet | null, +): ReadonlySet | null { + if (a === null) return b; + if (b === null) return a; + const out = new Set(); + for (const t of a) { + if (b.has(t)) out.add(t); + } + return out; +} + +function unionGuards( + a: ReadonlySet | null, + b: ReadonlySet | null, +): ReadonlySet | null { + if (a === null || b === null) return null; // null means "unrestricted" + return new Set([...a, ...b]); +} + +function unionTypenameGuards( + a: ReadonlySet | false, + b: ReadonlySet | false, +): ReadonlySet | false { + if (a === false) return b; + if (b === false) return a; + return new Set([...a, ...b]); +} + +// --------------------------------------------------------------------------- +// Directive extraction +// --------------------------------------------------------------------------- + +interface DirectiveInfo { + skipIfVars: readonly string[]; + includeIfVars: readonly string[]; + literalSkip: boolean; +} + +type DirectiveLike = readonly { + name: { value: string }; + arguments?: readonly { + name: { value: string }; + value: { kind: string; name?: { value: string }; value?: unknown }; + }[]; +}[]; + +function extractDirectives( + directives: DirectiveLike | undefined, + inheritedSkipIfVars: readonly string[], + inheritedIncludeIfVars: readonly string[], +): DirectiveInfo { + let skipIfVars: string[] | null = null; + let includeIfVars: string[] | null = null; + let literalSkip = false; + + if (directives?.length) { + for (const d of directives) { + const ifArg = d.arguments?.find((a) => a.name.value === 'if'); + if (!ifArg) continue; + if (d.name.value === 'skip') { + if (ifArg.value.kind === Kind.VARIABLE) { + (skipIfVars ??= []).push( + (ifArg.value as { name: { value: string } }).name.value, + ); + } else if ( + ifArg.value.kind === Kind.BOOLEAN && + ifArg.value.value === true + ) { + literalSkip = true; + } + } else if (d.name.value === 'include') { + if (ifArg.value.kind === Kind.VARIABLE) { + (includeIfVars ??= []).push( + (ifArg.value as { name: { value: string } }).name.value, + ); + } else if ( + ifArg.value.kind === Kind.BOOLEAN && + ifArg.value.value === false + ) { + literalSkip = true; + } + } + } + } + + // Combine with inherited conditions from enclosing fragments + const combinedSkipIfVars: readonly string[] = + inheritedSkipIfVars.length === 0 && skipIfVars === null + ? EMPTY_STRINGS + : [...inheritedSkipIfVars, ...(skipIfVars ?? [])]; + + const combinedIncludeIfVars: readonly string[] = + inheritedIncludeIfVars.length === 0 && includeIfVars === null + ? EMPTY_STRINGS + : [...inheritedIncludeIfVars, ...(includeIfVars ?? [])]; + + // If they collide, literalSkip + for (const skipIfVar of combinedSkipIfVars) { + for (const includeIfVar of combinedIncludeIfVars) { + if (skipIfVar === includeIfVar) { + literalSkip = true; + break; + } + } + } + + return { + skipIfVars: combinedSkipIfVars, + includeIfVars: combinedIncludeIfVars, + literalSkip, + }; +} + +// --------------------------------------------------------------------------- +// Field merging (same response key appearing in multiple fragments) +// --------------------------------------------------------------------------- + +function mergeField( + existing: ProjectionPlanField, + incoming: ProjectionPlanField, +): ProjectionPlanField { + // When the same response key is reached through two separate paths, include the field if + // either path's conditions allow it. + // + // skip: if one path has no skip condition it always includes the field, so the merged + // result must also always include it (empty wins over any variable list). + // When both paths carry skip variables they are unioned: the field is skipped if any + // variable in either path requests a skip. This is a conservative approximation for the + // rare case where the same response key appears in multiple type-conditioned fragments + // with different @skip variables; the alternative (intersection) would silently ignore + // individual skip directives when they don't overlap. + const skipIfVars = + existing.skipIfVars.length === 0 || incoming.skipIfVars.length === 0 + ? EMPTY_STRINGS + : dedupe([...existing.skipIfVars, ...incoming.skipIfVars]); + + // include: symmetric – if one path has no include-guard it always includes the field, + // so the merged result must also always include it. + const includeIfVars = + existing.includeIfVars.length === 0 || incoming.includeIfVars.length === 0 + ? EMPTY_STRINGS + : dedupe([...existing.includeIfVars, ...incoming.includeIfVars]); + + let children: ProjectionPlanField[] | null = null; + if (existing.children !== null || incoming.children !== null) { + children = mergeFieldLists( + existing.children ?? [], + incoming.children ?? [], + ); + } + + // For enum values: union of valid values; if either side has no restriction, clear it. + let enumValues: ReadonlySet | null = null; + if (existing.enumValues !== null && incoming.enumValues !== null) { + enumValues = new Set([...existing.enumValues, ...incoming.enumValues]); + } + + const isTypename = unionTypenameGuards( + existing.isTypename, + incoming.isTypename, + ); + const hasSkip = skipIfVars.length > 0; + const hasInclude = includeIfVars.length > 0; + const typeGuard = unionGuards(existing.typeGuard, incoming.typeGuard); + + return { + responseKey: existing.responseKey, + escapedKey: existing.escapedKey, + commaEscapedKey: existing.commaEscapedKey, + isTypename, + skipIfVars, + includeIfVars, + hasSkip, + hasInclude, + typeGuard, + enumValues, + children, + scalarHint: existing.scalarHint ?? incoming.scalarHint, + isSimple: typeGuard === null && !hasSkip && !hasInclude && !isTypename, + nullable: existing.nullable || incoming.nullable, + }; +} + +function mergeFieldLists( + a: ProjectionPlanField[], + b: ProjectionPlanField[], +): ProjectionPlanField[] { + const map = new Map(); + for (const f of a) map.set(f.responseKey, f); + for (const f of b) { + const ex = map.get(f.responseKey); + map.set(f.responseKey, ex ? mergeField(ex, f) : f); + } + return [...map.values()]; +} + +function dedupe(arr: string[]): readonly string[] { + return [...new Set(arr)]; +} + +// --------------------------------------------------------------------------- +// Core plan builder +// --------------------------------------------------------------------------- + +function buildSelectionSet( + selectionSet: SelectionSetNode, + parentTypeName: string, + schema: GraphQLSchema, + fragments: Record, + parentTypeGuard: ReadonlySet | null, + inheritedSkipIfVars: readonly string[], + inheritedIncludeIfVars: readonly string[], +): ProjectionPlanField[] { + const fields = new Map(); + + for (const selection of selectionSet.selections) { + const { skipIfVars, includeIfVars, literalSkip } = extractDirectives( + selection.directives as DirectiveLike | undefined, + inheritedSkipIfVars, + inheritedIncludeIfVars, + ); + if (literalSkip) continue; + + switch (selection.kind) { + case Kind.FIELD: { + const fieldName = selection.name.value; + const responseKey = selection.alias?.value ?? fieldName; + + if (fieldName === TYPENAME) { + const escapedKey = stringifyString(responseKey) + ':'; + const hasSkip = skipIfVars.length > 0; + const hasInclude = includeIfVars.length > 0; + const isTypenameGuards = getPossibleTypeNames(schema, parentTypeName); + const field: ProjectionPlanField = { + responseKey, + escapedKey, + commaEscapedKey: ',' + escapedKey, + isTypename: isTypenameGuards, + skipIfVars, + includeIfVars, + hasSkip, + hasInclude, + typeGuard: parentTypeGuard, + enumValues: null, + children: null, + scalarHint: 'string', + // __typename fields are never "simple" because they need the typename lookup. + isSimple: false, + nullable: false, + }; + const existing = fields.get(responseKey); + fields.set( + responseKey, + existing ? mergeField(existing, field) : field, + ); + break; + } + + // Resolve field return type from schema. + let fieldType: GraphQLNamedOutputType | undefined; + let nullable = true; + if (fieldName === __SCHEMA_FIELD) { + fieldType = getNamedType( + SchemaMetaFieldDef.type, + ) as GraphQLNamedOutputType; + nullable = false; + } else if (fieldName === __TYPE_FIELD) { + fieldType = getNamedType( + TypeMetaFieldDef.type, + ) as GraphQLNamedOutputType; + nullable = false; + } else { + const parentSchemaType = schema.getType(parentTypeName); + if (parentSchemaType && 'getFields' in parentSchemaType) { + const fieldDef = ( + parentSchemaType as { + getFields(): Record; + } + ).getFields()[fieldName]; + if (fieldDef) { + if (isNonNullType(fieldDef.type)) { + nullable = false; + } + fieldType = getNamedType( + fieldDef.type as Parameters[0], + ) as GraphQLNamedOutputType; + } + } + } + + // Unknown field: skip to avoid including unexpected data (security invariant). + if (!fieldType) continue; + + const enumValues: ReadonlySet | null = isEnumType(fieldType) + ? new Set( + (fieldType as GraphQLEnumType).getValues().map((v) => v.name), + ) + : null; + + const scalarHint = + !selection.selectionSet && + enumValues === null && + isScalarType(fieldType) + ? getScalarHint(fieldType as GraphQLScalarType) + : null; + + const children = selection.selectionSet + ? buildSelectionSet( + selection.selectionSet, + fieldType.name, + schema, + fragments, + /* parentTypeGuard */ null, + /* inherited skip/include reset for child scope */ EMPTY_STRINGS, + EMPTY_STRINGS, + ) + : null; + + const escapedKey = stringifyString(responseKey) + ':'; + const hasSkip = skipIfVars.length > 0; + const hasInclude = includeIfVars.length > 0; + const field: ProjectionPlanField = { + responseKey, + escapedKey, + commaEscapedKey: ',' + escapedKey, + isTypename: false, + skipIfVars, + includeIfVars, + hasSkip, + hasInclude, + typeGuard: parentTypeGuard, + enumValues, + children, + scalarHint, + isSimple: parentTypeGuard === null && !hasSkip && !hasInclude, + nullable, + }; + const existing = fields.get(responseKey); + fields.set(responseKey, existing ? mergeField(existing, field) : field); + break; + } + + case Kind.INLINE_FRAGMENT: { + let fragmentTypeGuard: ReadonlySet | null = null; + let fragmentTypeName = parentTypeName; + + if (selection.typeCondition) { + fragmentTypeGuard = getPossibleTypeNames( + schema, + selection.typeCondition.name.value, + ); + fragmentTypeName = selection.typeCondition.name.value; + } + + const effectiveGuard = intersectGuards( + parentTypeGuard, + fragmentTypeGuard, + ); + + const fragmentFields = buildSelectionSet( + selection.selectionSet, + fragmentTypeName, + schema, + fragments, + effectiveGuard, + // Pass any variable directives on this fragment down to its fields. + skipIfVars, + includeIfVars, + ); + + for (const f of fragmentFields) { + const existing = fields.get(f.responseKey); + fields.set(f.responseKey, existing ? mergeField(existing, f) : f); + } + break; + } + + case Kind.FRAGMENT_SPREAD: { + const fragmentDef = fragments[selection.name.value]; + if (!fragmentDef) break; + + const fragmentTypeGuard = getPossibleTypeNames( + schema, + fragmentDef.typeCondition.name.value, + ); + const effectiveGuard = intersectGuards( + parentTypeGuard, + fragmentTypeGuard, + ); + + const fragmentFields = buildSelectionSet( + fragmentDef.selectionSet, + fragmentDef.typeCondition.name.value, + schema, + fragments, + effectiveGuard, + skipIfVars, + includeIfVars, + ); + + for (const f of fragmentFields) { + const existing = fields.get(f.responseKey); + fields.set(f.responseKey, existing ? mergeField(existing, f) : f); + } + break; + } + } + } + + return [...fields.values()]; +} diff --git a/packages/router-runtime/src/stringify/stringify-with-document.ts b/packages/router-runtime/src/stringify/stringify-with-document.ts new file mode 100644 index 0000000000..0ad62ee388 --- /dev/null +++ b/packages/router-runtime/src/stringify/stringify-with-document.ts @@ -0,0 +1,94 @@ +import { ExecutionResult } from '@graphql-tools/utils'; +import { DocumentNode, GraphQLSchema } from 'graphql'; +import { QueryPlanExecutionContext } from '../executor.js'; +import { CLOSE_BRACE, CLOSE_BRACKET, COMMA, OPEN_BRACE } from './consts.js'; +import { + ObjectStringifyOptions, + projectWithPlan, + stringifyWithoutSelectionSet, +} from './data.js'; +import { stringifyError } from './error.js'; +import { getOrCompileProjectionPlan } from './projection-plan.js'; + +// Re-exported for any downstream consumers that imported this type directly. +export interface StringifyContext { + schema: GraphQLSchema; + document: DocumentNode; + operationName?: string; + variables?: Record; +} + +// Pre-computed JSON key fragments for the top-level response object. +// Avoids repeated string concatenation on every serialized response. +const DATA_KEY = '"data":'; +const ERRORS_KEY_OPEN = '"errors":['; +const COMMA_ERRORS_KEY_OPEN = ',"errors":['; +const EXTENSIONS_KEY = '"extensions":'; +const COMMA_EXTENSIONS_KEY = ',"extensions":'; + +// Strip the internal `http` extension from the top-level result extensions before +// serializing – mirrors the stripping done by omitInternalsFromResultErrors in the +// non-plan-based code path. +const RESULT_EXTENSIONS_OPTIONS: ObjectStringifyOptions = { + ignoredFields: new Set(['http']), +}; + +export function stringifyExecutionResult( + result: ExecutionResult, + executionContext: QueryPlanExecutionContext, +): string { + // Retrieve (or build and cache) the pre-compiled projection plan. + const plan = getOrCompileProjectionPlan(executionContext); + if (!plan) { + // Could not find the operation in the document – fall back to regular stringify + return stringifyWithoutSelectionSet(result); + } + + let buf = OPEN_BRACE; + let first = true; + + if (result.data !== undefined) { + first = false; + buf += + DATA_KEY + + projectWithPlan( + result.data, + plan.fields, + executionContext.variableValues, + ); + } + + if (result.errors?.length) { + buf += first ? ERRORS_KEY_OPEN : COMMA_ERRORS_KEY_OPEN; + first = false; + for (let i = 0; i < result.errors.length; i++) { + if (i > 0) buf += COMMA; + buf += stringifyError(result.errors[i]!); + } + buf += CLOSE_BRACKET; + } + + if (result.extensions != null) { + // Check whether there are any public (non-internal) extensions to emit. + // Reuse the same set from RESULT_EXTENSIONS_OPTIONS to avoid duplicating the list. + const ignoredFields = RESULT_EXTENSIONS_OPTIONS.ignoredFields!; + let hasPublicExtensions = false; + for (const key in result.extensions) { + if (!ignoredFields.has(key)) { + hasPublicExtensions = true; + break; + } + } + if (hasPublicExtensions) { + buf += first ? EXTENSIONS_KEY : COMMA_EXTENSIONS_KEY; + // first = false; (unused after this point) + buf += stringifyWithoutSelectionSet( + result.extensions, + RESULT_EXTENSIONS_OPTIONS, + ); + } + } + + buf += CLOSE_BRACE; + return buf; +} From 8e550116fb2c213228b5c116c6e46b14a77c90a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:07:53 +0000 Subject: [PATCH 2/2] fix: add labeled break to exit nested loop early on variable collision detection Co-authored-by: ardatan <20847995+ardatan@users.noreply.github.com> Agent-Logs-Url: https://github.com/graphql-hive/gateway/sessions/366e876a-4994-4510-bbe1-e628c9a06de1 --- packages/router-runtime/src/stringify/projection-plan.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router-runtime/src/stringify/projection-plan.ts b/packages/router-runtime/src/stringify/projection-plan.ts index 5919c8b034..6b147f3830 100644 --- a/packages/router-runtime/src/stringify/projection-plan.ts +++ b/packages/router-runtime/src/stringify/projection-plan.ts @@ -334,11 +334,11 @@ function extractDirectives( : [...inheritedIncludeIfVars, ...(includeIfVars ?? [])]; // If they collide, literalSkip - for (const skipIfVar of combinedSkipIfVars) { + outer: for (const skipIfVar of combinedSkipIfVars) { for (const includeIfVar of combinedIncludeIfVars) { if (skipIfVar === includeIfVar) { literalSkip = true; - break; + break outer; } } }