Skip to content

Commit

Permalink
Preserve sources of variable values
Browse files Browse the repository at this point in the history
By way of introducing type `VariableValues`, allows `getVariableValues` to return both the coerced values as well as the original sources, which are then made available in `ExecutionContext`.
  • Loading branch information
leebyron authored and yaacovCR committed Mar 20, 2024
1 parent 619c450 commit 9f85bc3
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 67 deletions.
11 changes: 6 additions & 5 deletions src/execution/collectFields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { GraphQLSchema } from '../type/schema.js';

import { typeFromAST } from '../utilities/typeFromAST.js';

import type { VariableValues } from './values.js';
import { getDirectiveValues } from './values.js';

export interface DeferUsage {
Expand All @@ -39,7 +40,7 @@ export interface FieldDetails {
interface CollectFieldsContext {
schema: GraphQLSchema;
fragments: ObjMap<FragmentDefinitionNode>;
variableValues: { [variable: string]: unknown };
variableValues: VariableValues;
operation: OperationDefinitionNode;
runtimeType: GraphQLObjectType;
visitedFragmentNames: Set<string>;
Expand All @@ -57,7 +58,7 @@ interface CollectFieldsContext {
export function collectFields(
schema: GraphQLSchema,
fragments: ObjMap<FragmentDefinitionNode>,
variableValues: { [variable: string]: unknown },
variableValues: VariableValues,
runtimeType: GraphQLObjectType,
operation: OperationDefinitionNode,
): Map<string, ReadonlyArray<FieldDetails>> {
Expand Down Expand Up @@ -89,7 +90,7 @@ export function collectFields(
export function collectSubfields(
schema: GraphQLSchema,
fragments: ObjMap<FragmentDefinitionNode>,
variableValues: { [variable: string]: unknown },
variableValues: VariableValues,
operation: OperationDefinitionNode,
returnType: GraphQLObjectType,
fieldDetails: ReadonlyArray<FieldDetails>,
Expand Down Expand Up @@ -221,7 +222,7 @@ function collectFieldsImpl(
*/
function getDeferUsage(
operation: OperationDefinitionNode,
variableValues: { [variable: string]: unknown },
variableValues: VariableValues,
node: FragmentSpreadNode | InlineFragmentNode,
parentDeferUsage: DeferUsage | undefined,
): DeferUsage | undefined {
Expand Down Expand Up @@ -251,7 +252,7 @@ function getDeferUsage(
* directives, where `@skip` has higher precedence than `@include`.
*/
function shouldIncludeNode(
variableValues: { [variable: string]: unknown },
variableValues: VariableValues,
node: FragmentSpreadNode | FieldNode | InlineFragmentNode,
): boolean {
const skip = getDirectiveValues(GraphQLSkipDirective, node, variableValues);
Expand Down
13 changes: 7 additions & 6 deletions src/execution/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
StreamRecord,
} from './IncrementalPublisher.js';
import { mapAsyncIterable } from './mapAsyncIterable.js';
import type { VariableValues } from './values.js';
import {
getArgumentValues,
getDirectiveValues,
Expand Down Expand Up @@ -139,7 +140,7 @@ export interface ExecutionContext {
rootValue: unknown;
contextValue: unknown;
operation: OperationDefinitionNode;
variableValues: { [variable: string]: unknown };
variableValues: VariableValues;
fieldResolver: GraphQLFieldResolver<any, any>;
typeResolver: GraphQLTypeResolver<any, any>;
subscribeFieldResolver: GraphQLFieldResolver<any, any>;
Expand Down Expand Up @@ -350,15 +351,15 @@ export function buildExecutionContext(
/* c8 ignore next */
const variableDefinitions = operation.variableDefinitions ?? [];

const coercedVariableValues = getVariableValues(
const variableValuesOrErrors = getVariableValues(
schema,
variableDefinitions,
rawVariableValues ?? {},
{ maxErrors: 50 },
);

if (coercedVariableValues.errors) {
return coercedVariableValues.errors;
if (variableValuesOrErrors.errors) {
return variableValuesOrErrors.errors;
}

return {
Expand All @@ -367,7 +368,7 @@ export function buildExecutionContext(
rootValue,
contextValue,
operation,
variableValues: coercedVariableValues.coerced,
variableValues: variableValuesOrErrors.variableValues,
fieldResolver: fieldResolver ?? defaultFieldResolver,
typeResolver: typeResolver ?? defaultTypeResolver,
subscribeFieldResolver: subscribeFieldResolver ?? defaultFieldResolver,
Expand Down Expand Up @@ -714,7 +715,7 @@ export function buildResolveInfo(
fragments: exeContext.fragments,
rootValue: exeContext.rootValue,
operation: exeContext.operation,
variableValues: exeContext.variableValues,
variableValues: exeContext.variableValues.coerced,
};
}

Expand Down
60 changes: 38 additions & 22 deletions src/execution/values.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { inspect } from '../jsutils/inspect.js';
import type { Maybe } from '../jsutils/Maybe.js';
import type { ObjMap } from '../jsutils/ObjMap.js';
import type { ObjMap, ReadOnlyObjMap } from '../jsutils/ObjMap.js';
import { printPathArray } from '../jsutils/printPathArray.js';

import { GraphQLError } from '../error/GraphQLError.js';
Expand All @@ -13,7 +13,7 @@ import type {
import { Kind } from '../language/kinds.js';
import { print } from '../language/printer.js';

import type { GraphQLField } from '../type/definition.js';
import type { GraphQLField, GraphQLInputType } from '../type/definition.js';
import { isInputType, isNonNullType } from '../type/definition.js';
import type { GraphQLDirective } from '../type/directives.js';
import type { GraphQLSchema } from '../type/schema.js';
Expand All @@ -25,9 +25,20 @@ import {
} from '../utilities/coerceInputValue.js';
import { typeFromAST } from '../utilities/typeFromAST.js';

type CoercedVariableValues =
| { errors: ReadonlyArray<GraphQLError>; coerced?: never }
| { coerced: { [variable: string]: unknown }; errors?: never };
export interface VariableValues {
readonly sources: ReadOnlyObjMap<VariableValueSource>;
readonly coerced: ReadOnlyObjMap<unknown>;
}

interface VariableValueSource {
readonly variable: VariableDefinitionNode;
readonly type: GraphQLInputType;
readonly value: unknown;
}

type VariableValuesOrErrors =
| { variableValues: VariableValues; errors?: never }
| { errors: ReadonlyArray<GraphQLError>; variableValues?: never };

/**
* Prepares an object map of variableValues of the correct type based on the
Expand All @@ -43,11 +54,11 @@ export function getVariableValues(
varDefNodes: ReadonlyArray<VariableDefinitionNode>,
inputs: { readonly [variable: string]: unknown },
options?: { maxErrors?: number },
): CoercedVariableValues {
const errors = [];
): VariableValuesOrErrors {
const errors: Array<GraphQLError> = [];
const maxErrors = options?.maxErrors;
try {
const coerced = coerceVariableValues(
const variableValues = coerceVariableValues(
schema,
varDefNodes,
inputs,
Expand All @@ -62,7 +73,7 @@ export function getVariableValues(
);

if (errors.length === 0) {
return { coerced };
return { variableValues };
}
} catch (error) {
errors.push(error);
Expand All @@ -76,8 +87,9 @@ function coerceVariableValues(
varDefNodes: ReadonlyArray<VariableDefinitionNode>,
inputs: { readonly [variable: string]: unknown },
onError: (error: GraphQLError) => void,
): { [variable: string]: unknown } {
const coercedValues: { [variable: string]: unknown } = {};
): VariableValues {
const sources: ObjMap<VariableValueSource> = Object.create(null);
const coerced: ObjMap<unknown> = Object.create(null);
for (const varDefNode of varDefNodes) {
const varName = varDefNode.variable.name.value;
const varType = typeFromAST(schema, varDefNode.type);
Expand All @@ -95,11 +107,14 @@ function coerceVariableValues(
}

if (!Object.hasOwn(inputs, varName)) {
if (varDefNode.defaultValue) {
coercedValues[varName] = coerceInputLiteral(
varDefNode.defaultValue,
varType,
);
const defaultValue = varDefNode.defaultValue;
if (defaultValue) {
sources[varName] = {
variable: varDefNode,
type: varType,
value: undefined,
};
coerced[varName] = coerceInputLiteral(defaultValue, varType);
} else if (isNonNullType(varType)) {
onError(
new GraphQLError(
Expand All @@ -122,7 +137,8 @@ function coerceVariableValues(
continue;
}

coercedValues[varName] = coerceInputValue(
sources[varName] = { variable: varDefNode, type: varType, value };
coerced[varName] = coerceInputValue(
value,
varType,
(path, invalidValue, error) => {
Expand All @@ -141,7 +157,7 @@ function coerceVariableValues(
);
}

return coercedValues;
return { sources, coerced };
}

/**
Expand All @@ -155,7 +171,7 @@ function coerceVariableValues(
export function getArgumentValues(
def: GraphQLField<unknown, unknown> | GraphQLDirective,
node: FieldNode | DirectiveNode,
variableValues?: Maybe<ObjMap<unknown>>,
variableValues?: Maybe<VariableValues>,
): { [argument: string]: unknown } {
const coercedValues: { [argument: string]: unknown } = {};

Expand Down Expand Up @@ -191,7 +207,7 @@ export function getArgumentValues(
const variableName = valueNode.name.value;
if (
variableValues == null ||
!Object.hasOwn(variableValues, variableName)
!Object.hasOwn(variableValues.coerced, variableName)
) {
if (argDef.defaultValue) {
coercedValues[name] = coerceDefaultValue(
Expand All @@ -207,7 +223,7 @@ export function getArgumentValues(
}
continue;
}
isNull = variableValues[variableName] == null;
isNull = variableValues.coerced[variableName] == null;
}

if (isNull && isNonNullType(argType)) {
Expand Down Expand Up @@ -248,7 +264,7 @@ export function getArgumentValues(
export function getDirectiveValues(
directiveDef: GraphQLDirective,
node: { readonly directives?: ReadonlyArray<DirectiveNode> | undefined },
variableValues?: Maybe<ObjMap<unknown>>,
variableValues?: Maybe<VariableValues>,
): undefined | { [argument: string]: unknown } {
const directiveNode = node.directives?.find(
(directive) => directive.name.value === directiveDef.name,
Expand Down
85 changes: 69 additions & 16 deletions src/utilities/__tests__/coerceInputValue-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { describe, it } from 'mocha';

import { identityFunc } from '../../jsutils/identityFunc.js';
import { invariant } from '../../jsutils/invariant.js';
import type { ObjMap } from '../../jsutils/ObjMap.js';
import type { ReadOnlyObjMap } from '../../jsutils/ObjMap.js';

import { Kind } from '../../language/kinds.js';
import { parseValue } from '../../language/parser.js';
import { Parser, parseValue } from '../../language/parser.js';
import { print } from '../../language/printer.js';
import { TokenKind } from '../../language/tokenKind.js';

import type { GraphQLInputType } from '../../type/definition.js';
import {
Expand All @@ -24,6 +25,10 @@ import {
GraphQLInt,
GraphQLString,
} from '../../type/scalars.js';
import { GraphQLSchema } from '../../type/schema.js';

import type { VariableValues } from '../../execution/values.js';
import { getVariableValues } from '../../execution/values.js';

import {
coerceDefaultValue,
Expand Down Expand Up @@ -556,20 +561,29 @@ describe('coerceInputLiteral', () => {
valueText: string,
type: GraphQLInputType,
expected: unknown,
variables?: ObjMap<unknown>,
variableValues?: VariableValues,
) {
const ast = parseValue(valueText);
const value = coerceInputLiteral(ast, type, variables);
const value = coerceInputLiteral(ast, type, variableValues);
expect(value).to.deep.equal(expected);
}

function testWithVariables(
variables: ObjMap<unknown>,
variableDefs: string,
inputs: ReadOnlyObjMap<unknown>,
valueText: string,
type: GraphQLInputType,
expected: unknown,
) {
test(valueText, type, expected, variables);
const parser = new Parser(variableDefs);
parser.expectToken(TokenKind.SOF);
const variableValuesOrErrors = getVariableValues(
new GraphQLSchema({}),
parser.parseVariableDefinitions(),
inputs,
);
invariant(variableValuesOrErrors.variableValues !== undefined);
test(valueText, type, expected, variableValuesOrErrors.variableValues);
}

it('converts according to input coercion rules', () => {
Expand Down Expand Up @@ -788,19 +802,55 @@ describe('coerceInputLiteral', () => {

it('accepts variable values assuming already coerced', () => {
test('$var', GraphQLBoolean, undefined);
testWithVariables({ var: true }, '$var', GraphQLBoolean, true);
testWithVariables({ var: null }, '$var', GraphQLBoolean, null);
testWithVariables({ var: null }, '$var', nonNullBool, undefined);
testWithVariables(
'($var: Boolean)',
{ var: true },
'$var',
GraphQLBoolean,
true,
);
testWithVariables(
'($var: Boolean)',
{ var: null },
'$var',
GraphQLBoolean,
null,
);
testWithVariables(
'($var: Boolean)',
{ var: null },
'$var',
nonNullBool,
undefined,
);
});

it('asserts variables are provided as items in lists', () => {
test('[ $foo ]', listOfBool, [null]);
test('[ $foo ]', listOfNonNullBool, undefined);
testWithVariables({ foo: true }, '[ $foo ]', listOfNonNullBool, [true]);
testWithVariables(
'($foo: Boolean)',
{ foo: true },
'[ $foo ]',
listOfNonNullBool,
[true],
);
// Note: variables are expected to have already been coerced, so we
// do not expect the singleton wrapping behavior for variables.
testWithVariables({ foo: true }, '$foo', listOfNonNullBool, true);
testWithVariables({ foo: [true] }, '$foo', listOfNonNullBool, [true]);
testWithVariables(
'($foo: Boolean)',
{ foo: true },
'$foo',
listOfNonNullBool,
true,
);
testWithVariables(
'($foo: [Boolean])',
{ foo: [true] },
'$foo',
listOfNonNullBool,
[true],
);
});

it('omits input object fields for unprovided variables', () => {
Expand All @@ -809,10 +859,13 @@ describe('coerceInputLiteral', () => {
requiredBool: true,
});
test('{ requiredBool: $foo }', testInputObj, undefined);
testWithVariables({ foo: true }, '{ requiredBool: $foo }', testInputObj, {
int: 42,
requiredBool: true,
});
testWithVariables(
'($foo: Boolean)',
{ foo: true },
'{ requiredBool: $foo }',
testInputObj,
{ int: 42, requiredBool: true },
);
});
});

Expand Down
Loading

0 comments on commit 9f85bc3

Please sign in to comment.