Skip to content

Commit 3f6e32b

Browse files
committed
fix: preserve sources for fragment variable values
the new flow for coercing input literals (see graphql#3814 introduces a `replaceVariables()` prior to calling `coerceInputLiteral()` such that we go from `ValueNode` => `ConstValueNode` => coerced value. (With the main advantages being (1) that we can trivially support embedding variables within complex scalars -- despite this being non-spec defined behavior -- and (2) that implementations of custom scalar `coerceInputLiteral()` methods do not need to worry about handling variables or fragment variables. We previously did not properly handle this in the case of fragment definition variables/fragment spread arguments. `replaceVariableValues()` assumes that the uncoerced value is preserved as source, but instead of grabbing this value from the argument on the spread, we were incorrectly attempting to retrieve the already stored value from existing variables. This was not caught because we previously did not have any actual tests for this custom unspecified behavior and were using incorrect types and bespoke builders in our tests for `replaceVariables()`. This PR: (a) fixes the bug by storing the argument from the spread (b) fixes our bespoke builders in `replaceVariables()` (c) add explicit tests for embedding variables within custom scalars to protect against future changes. To consider: Within `getFragmentVariableValues()`, because we end up within a `ConstValueNode` stored on source, to coerce this value, we could consider extracting only the functionality we need from `experimentalGetArgumentValues()` (which also handles variables). This would seem to also have the side benefit of removing the need for a separate new signature for this function that can handle fragment spread arguments and we could stick with the old signature/name of just `getArgumentValues()`.
1 parent 5e62c43 commit 3f6e32b

File tree

7 files changed

+187
-41
lines changed

7 files changed

+187
-41
lines changed

src/execution/__tests__/variables-test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
import { GraphQLBoolean, GraphQLString } from '../../type/scalars.js';
3131
import { GraphQLSchema } from '../../type/schema.js';
3232

33+
import { valueFromASTUntyped } from '../../utilities/valueFromASTUntyped.js';
34+
3335
import { executeSync, experimentalExecuteIncrementally } from '../execute.js';
3436
import { getVariableValues } from '../values.js';
3537

@@ -64,6 +66,16 @@ const TestComplexScalar = new GraphQLScalarType({
6466
},
6567
});
6668

69+
const TestJSONScalar = new GraphQLScalarType({
70+
name: 'JSONScalar',
71+
coerceInputValue(value) {
72+
return value;
73+
},
74+
coerceInputLiteral(value) {
75+
return valueFromASTUntyped(value);
76+
},
77+
});
78+
6779
const NestedType: GraphQLObjectType = new GraphQLObjectType({
6880
name: 'NestedType',
6981
fields: {
@@ -151,6 +163,7 @@ const TestType = new GraphQLObjectType({
151163
fieldWithNestedInputObject: fieldWithInputArg({
152164
type: TestNestedInputObject,
153165
}),
166+
fieldWithJSONScalarInput: fieldWithInputArg({ type: TestJSONScalar }),
154167
list: fieldWithInputArg({ type: new GraphQLList(GraphQLString) }),
155168
nested: {
156169
type: NestedType,
@@ -859,6 +872,57 @@ describe('Execute: Handles inputs', () => {
859872
});
860873
});
861874

875+
// Note: the below is non-specified custom graphql-js behavior.
876+
describe('Handles custom scalars with embedded variables', () => {
877+
it('allows custom scalars', () => {
878+
const result = executeQuery(`
879+
{
880+
fieldWithJSONScalarInput(input: { a: "foo", b: ["bar"], c: "baz" })
881+
}
882+
`);
883+
884+
expectJSON(result).toDeepEqual({
885+
data: {
886+
fieldWithJSONScalarInput: '{ a: "foo", b: ["bar"], c: "baz" }',
887+
},
888+
});
889+
});
890+
891+
it('allows custom scalars with embedded operation variables', () => {
892+
const result = executeQuery(
893+
`
894+
query ($input: String) {
895+
fieldWithJSONScalarInput(input: { a: $input, b: ["bar"], c: "baz" })
896+
}
897+
`,
898+
{ input: 'foo' },
899+
);
900+
901+
expectJSON(result).toDeepEqual({
902+
data: {
903+
fieldWithJSONScalarInput: '{ a: "foo", b: ["bar"], c: "baz" }',
904+
},
905+
});
906+
});
907+
908+
it('allows custom scalars with embedded fragment variables', () => {
909+
const result = executeQueryWithFragmentArguments(`
910+
{
911+
...JSONFragment(input: "foo")
912+
}
913+
fragment JSONFragment($input: String) on TestType {
914+
fieldWithJSONScalarInput(input: { a: $input, b: ["bar"], c: "baz" })
915+
}
916+
`);
917+
918+
expectJSON(result).toDeepEqual({
919+
data: {
920+
fieldWithJSONScalarInput: '{ a: "foo", b: ["bar"], c: "baz" }',
921+
},
922+
});
923+
});
924+
});
925+
862926
describe('Handles lists and nullability', () => {
863927
it('allows lists to be null', () => {
864928
const doc = `

src/execution/collectFields.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { AccumulatorMap } from '../jsutils/AccumulatorMap.js';
2-
import type { ObjMap } from '../jsutils/ObjMap.js';
2+
import type { ObjMap, ReadOnlyObjMap } from '../jsutils/ObjMap.js';
33

44
import type {
5+
ConstValueNode,
56
DirectiveNode,
67
FieldNode,
78
FragmentDefinitionNode,
@@ -35,10 +36,20 @@ export interface DeferUsage {
3536
parentDeferUsage: DeferUsage | undefined;
3637
}
3738

39+
export interface FragmentVariableValues {
40+
readonly sources: ReadOnlyObjMap<FragmentVariableValueSource>;
41+
readonly coerced: ReadOnlyObjMap<unknown>;
42+
}
43+
44+
interface FragmentVariableValueSource {
45+
readonly signature: GraphQLVariableSignature;
46+
readonly value?: ConstValueNode;
47+
}
48+
3849
export interface FieldDetails {
3950
node: FieldNode;
4051
deferUsage?: DeferUsage | undefined;
41-
fragmentVariableValues?: VariableValues | undefined;
52+
fragmentVariableValues?: FragmentVariableValues | undefined;
4253
}
4354

4455
export type FieldDetailsList = ReadonlyArray<FieldDetails>;
@@ -168,7 +179,7 @@ function collectFieldsImpl(
168179
groupedFieldSet: AccumulatorMap<string, FieldDetails>,
169180
newDeferUsages: Array<DeferUsage>,
170181
deferUsage?: DeferUsage,
171-
fragmentVariableValues?: VariableValues,
182+
fragmentVariableValues?: FragmentVariableValues,
172183
): void {
173184
const {
174185
schema,
@@ -273,7 +284,7 @@ function collectFieldsImpl(
273284
);
274285

275286
const fragmentVariableSignatures = fragment.variableSignatures;
276-
let newFragmentVariableValues: VariableValues | undefined;
287+
let newFragmentVariableValues: FragmentVariableValues | undefined;
277288
if (fragmentVariableSignatures) {
278289
newFragmentVariableValues = getFragmentVariableValues(
279290
selection,
@@ -318,7 +329,7 @@ function collectFieldsImpl(
318329
*/
319330
function getDeferUsage(
320331
variableValues: VariableValues,
321-
fragmentVariableValues: VariableValues | undefined,
332+
fragmentVariableValues: FragmentVariableValues | undefined,
322333
node: FragmentSpreadNode | InlineFragmentNode,
323334
parentDeferUsage: DeferUsage | undefined,
324335
): DeferUsage | undefined {
@@ -351,7 +362,7 @@ function shouldIncludeNode(
351362
context: CollectFieldsContext,
352363
node: FragmentSpreadNode | FieldNode | InlineFragmentNode,
353364
variableValues: VariableValues,
354-
fragmentVariableValues: VariableValues | undefined,
365+
fragmentVariableValues: FragmentVariableValues | undefined,
355366
): boolean {
356367
const skipDirectiveNode = node.directives?.find(
357368
(directive) => directive.name.value === GraphQLSkipDirective.name,

src/execution/values.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { invariant } from '../jsutils/invariant.js';
2+
import { keyMap } from '../jsutils/keyMap.js';
23
import type { Maybe } from '../jsutils/Maybe.js';
34
import type { ObjMap, ReadOnlyObjMap } from '../jsutils/ObjMap.js';
45
import { printPathArray } from '../jsutils/printPathArray.js';
@@ -27,11 +28,13 @@ import {
2728
coerceInputLiteral,
2829
coerceInputValue,
2930
} from '../utilities/coerceInputValue.js';
31+
import { replaceVariables } from '../utilities/replaceVariables.js';
3032
import {
3133
validateInputLiteral,
3234
validateInputValue,
3335
} from '../utilities/validateInputValue.js';
3436

37+
import type { FragmentVariableValues } from './collectFields.js';
3538
import type { GraphQLVariableSignature } from './getVariableSignature.js';
3639
import { getVariableSignature } from './getVariableSignature.js';
3740

@@ -154,19 +157,29 @@ export function getFragmentVariableValues(
154157
fragmentSpreadNode: FragmentSpreadNode,
155158
fragmentSignatures: ReadOnlyObjMap<GraphQLVariableSignature>,
156159
variableValues: VariableValues,
157-
fragmentVariableValues?: Maybe<VariableValues>,
160+
fragmentVariableValues?: Maybe<FragmentVariableValues>,
158161
hideSuggestions?: Maybe<boolean>,
159-
): VariableValues {
162+
): FragmentVariableValues {
163+
const args = keyMap(
164+
fragmentSpreadNode.arguments ?? [],
165+
(arg) => arg.name.value,
166+
);
160167
const varSignatures: Array<GraphQLVariableSignature> = [];
161168
const sources = Object.create(null);
162169
for (const [varName, varSignature] of Object.entries(fragmentSignatures)) {
163170
varSignatures.push(varSignature);
164171
sources[varName] = {
165172
signature: varSignature,
166-
value:
167-
fragmentVariableValues?.sources[varName]?.value ??
168-
variableValues.sources[varName]?.value,
169173
};
174+
const arg = args[varName];
175+
if (arg !== undefined) {
176+
const value = arg.value;
177+
sources[varName].value = replaceVariables(
178+
value,
179+
variableValues,
180+
fragmentVariableValues,
181+
);
182+
}
170183
}
171184

172185
const coerced = experimentalGetArgumentValues(
@@ -207,7 +220,7 @@ export function experimentalGetArgumentValues(
207220
node: FieldNode | DirectiveNode | FragmentSpreadNode,
208221
argDefs: ReadonlyArray<GraphQLArgument | GraphQLVariableSignature>,
209222
variableValues: Maybe<VariableValues>,
210-
fragmentVariableValues?: Maybe<VariableValues>,
223+
fragmentVariableValues?: Maybe<FragmentVariableValues>,
211224
hideSuggestions?: Maybe<boolean>,
212225
): { [argument: string]: unknown } {
213226
const coercedValues: { [argument: string]: unknown } = {};
@@ -306,7 +319,7 @@ export function getDirectiveValues(
306319
directiveDef: GraphQLDirective,
307320
node: { readonly directives?: ReadonlyArray<DirectiveNode> | undefined },
308321
variableValues?: Maybe<VariableValues>,
309-
fragmentVariableValues?: Maybe<VariableValues>,
322+
fragmentVariableValues?: Maybe<FragmentVariableValues>,
310323
hideSuggestions?: Maybe<boolean>,
311324
): undefined | { [argument: string]: unknown } {
312325
const directiveNode = node.directives?.find(

src/utilities/__tests__/replaceVariables-test.ts

Lines changed: 58 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@ import { describe, it } from 'mocha';
44
import { invariant } from '../../jsutils/invariant.js';
55
import type { ReadOnlyObjMap } from '../../jsutils/ObjMap.js';
66

7-
import type { ValueNode } from '../../language/ast.js';
7+
import type {
8+
FragmentArgumentNode,
9+
FragmentSpreadNode,
10+
ValueNode,
11+
VariableDefinitionNode,
12+
} from '../../language/ast.js';
13+
import { Kind } from '../../language/kinds.js';
814
import { Parser, parseValue as _parseValue } from '../../language/parser.js';
915
import { TokenKind } from '../../language/tokenKind.js';
1016

1117
import { GraphQLInt } from '../../type/scalars.js';
1218
import { GraphQLSchema } from '../../type/schema.js';
1319

14-
import { getVariableValues } from '../../execution/values.js';
20+
import { getVariableSignature } from '../../execution/getVariableSignature.js';
21+
import {
22+
getFragmentVariableValues,
23+
getVariableValues,
24+
} from '../../execution/values.js';
1525

1626
import { replaceVariables } from '../replaceVariables.js';
1727

@@ -20,17 +30,51 @@ function parseValue(ast: string): ValueNode {
2030
}
2131

2232
function testVariables(variableDefs: string, inputs: ReadOnlyObjMap<unknown>) {
23-
const parser = new Parser(variableDefs, { noLocation: true });
24-
parser.expectToken(TokenKind.SOF);
2533
const variableValuesOrErrors = getVariableValues(
2634
new GraphQLSchema({ types: [GraphQLInt] }),
27-
parser.parseVariableDefinitions() ?? [],
35+
parseVariableDefinitions(variableDefs),
2836
inputs,
2937
);
3038
invariant(variableValuesOrErrors.variableValues !== undefined);
3139
return variableValuesOrErrors.variableValues;
3240
}
3341

42+
function parseVariableDefinitions(
43+
variableDefs: string,
44+
): ReadonlyArray<VariableDefinitionNode> {
45+
const parser = new Parser(variableDefs, { noLocation: true });
46+
parser.expectToken(TokenKind.SOF);
47+
return parser.parseVariableDefinitions() ?? [];
48+
}
49+
50+
function testFragmentVariables(variableDefs: string, fragmentArgs: string) {
51+
const schema = new GraphQLSchema({ types: [GraphQLInt] });
52+
const fragmentSignatures = Object.create(null);
53+
for (const varDef of parseVariableDefinitions(variableDefs)) {
54+
const signature = getVariableSignature(schema, varDef);
55+
fragmentSignatures[signature.name] = signature;
56+
}
57+
const spread: FragmentSpreadNode = {
58+
kind: Kind.FRAGMENT_SPREAD,
59+
name: { kind: Kind.NAME, value: 'TestFragment' },
60+
arguments: parseFragmentArguments(fragmentArgs),
61+
};
62+
return getFragmentVariableValues(
63+
spread,
64+
fragmentSignatures,
65+
Object.create(null),
66+
undefined,
67+
);
68+
}
69+
70+
function parseFragmentArguments(
71+
fragmentArguments: string,
72+
): ReadonlyArray<FragmentArgumentNode> {
73+
const parser = new Parser(fragmentArguments, { noLocation: true });
74+
parser.expectToken(TokenKind.SOF);
75+
return parser.parseFragmentArguments() ?? [];
76+
}
77+
3478
describe('replaceVariables', () => {
3579
describe('Operation Variables', () => {
3680
it('does not change simple AST', () => {
@@ -96,7 +140,7 @@ describe('replaceVariables', () => {
96140
describe('Fragment Variables', () => {
97141
it('replaces simple Fragment Variables', () => {
98142
const ast = parseValue('$var');
99-
const fragmentVars = testVariables('($var: Int)', { var: 123 });
143+
const fragmentVars = testFragmentVariables('($var: Int)', `(var: 123)`);
100144
expect(replaceVariables(ast, undefined, fragmentVars)).to.deep.equal(
101145
parseValue('123'),
102146
);
@@ -105,15 +149,15 @@ describe('replaceVariables', () => {
105149
it('replaces simple Fragment Variables even when overlapping with Operation Variables', () => {
106150
const ast = parseValue('$var');
107151
const operationVars = testVariables('($var: Int)', { var: 123 });
108-
const fragmentVars = testVariables('($var: Int)', { var: 456 });
152+
const fragmentVars = testFragmentVariables('($var: Int)', '(var: 456)');
109153
expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal(
110154
parseValue('456'),
111155
);
112156
});
113157

114158
it('replaces Fragment Variables with default values', () => {
115159
const ast = parseValue('$var');
116-
const fragmentVars = testVariables('($var: Int = 123)', {});
160+
const fragmentVars = testFragmentVariables('($var: Int = 123)', '');
117161
expect(replaceVariables(ast, undefined, fragmentVars)).to.deep.equal(
118162
parseValue('123'),
119163
);
@@ -122,15 +166,15 @@ describe('replaceVariables', () => {
122166
it('replaces Fragment Variables with default values even when overlapping with Operation Variables', () => {
123167
const ast = parseValue('$var');
124168
const operationVars = testVariables('($var: Int = 123)', {});
125-
const fragmentVars = testVariables('($var: Int = 456)', {});
169+
const fragmentVars = testFragmentVariables('($var: Int = 456)', '');
126170
expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal(
127171
parseValue('456'),
128172
);
129173
});
130174

131175
it('replaces nested Fragment Variables', () => {
132176
const ast = parseValue('{ foo: [ $var ], bar: $var }');
133-
const fragmentVars = testVariables('($var: Int)', { var: 123 });
177+
const fragmentVars = testFragmentVariables('($var: Int)', '(var: 123)');
134178
expect(replaceVariables(ast, undefined, fragmentVars)).to.deep.equal(
135179
parseValue('{ foo: [ 123 ], bar: 123 }'),
136180
);
@@ -139,7 +183,7 @@ describe('replaceVariables', () => {
139183
it('replaces nested Fragment Variables even when overlapping with Operation Variables', () => {
140184
const ast = parseValue('{ foo: [ $var ], bar: $var }');
141185
const operationVars = testVariables('($var: Int)', { var: 123 });
142-
const fragmentVars = testVariables('($var: Int)', { var: 456 });
186+
const fragmentVars = testFragmentVariables('($var: Int)', '(var: 456)');
143187
expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal(
144188
parseValue('{ foo: [ 456 ], bar: 456 }'),
145189
);
@@ -155,7 +199,7 @@ describe('replaceVariables', () => {
155199
it('replaces missing Fragment Variables with null even when overlapping with Operation Variables', () => {
156200
const ast = parseValue('$var');
157201
const operationVars = testVariables('($var: Int)', { var: 123 });
158-
const fragmentVars = testVariables('($var: Int)', {});
202+
const fragmentVars = testFragmentVariables('($var: Int)', '');
159203
expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal(
160204
parseValue('null'),
161205
);
@@ -171,7 +215,7 @@ describe('replaceVariables', () => {
171215
it('replaces missing Fragment Variables in lists with null even when overlapping with Operation Variables', () => {
172216
const ast = parseValue('[1, $var]');
173217
const operationVars = testVariables('($var: Int)', { var: 123 });
174-
const fragmentVars = testVariables('($var: Int)', {});
218+
const fragmentVars = testFragmentVariables('($var: Int)', '');
175219
expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal(
176220
parseValue('[1, null]'),
177221
);
@@ -187,7 +231,7 @@ describe('replaceVariables', () => {
187231
it('omits missing Fragment Variables from objects even when overlapping with Operation Variables', () => {
188232
const ast = parseValue('{ foo: 1, bar: $var }');
189233
const operationVars = testVariables('($var: Int)', { var: 123 });
190-
const fragmentVars = testVariables('($var: Int)', {});
234+
const fragmentVars = testFragmentVariables('($var: Int)', '');
191235
expect(replaceVariables(ast, operationVars, fragmentVars)).to.deep.equal(
192236
parseValue('{ foo: 1 }'),
193237
);

0 commit comments

Comments
 (0)