Skip to content

Commit f808084

Browse files
authored
fix(replaceVariables): preserve sources for fragment variable values (#4389)
The new flow for coercing input literals -- see #3814 -- introduces a `replaceVariables()` prior to calling `coerceInputLiteral()` such that we go: from `ValueNode` => `ConstValueNode` => coerced value The main advantages being that: (1) we can trivially support embedding variables within complex scalars -- despite this being non-spec defined behavior! (2) userland implementations of custom scalar `coerceInputLiteral()` methods do not need to worry about handling variables or fragment variables. Prior to this PR, we 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. As a post-script, because within `getFragmentVariableValues()` we now end up within a `ValueNode` stored on source, to coerce this value, we can extract a new helper `coerceArgumentValue()` from `experimentalGetArgumentValues()`, rather than calling it after we are done for all the values, which has some additional overhead. This has the side benefit of removing the need for a separate `experimentalGetArgumentValues()` function altogether. We initially introduced it to have a more flexible signature for `getArgumentValues()` that encompasses argument for fragment spreads, but now this is taken care of using our more directed helper.
1 parent 5e62c43 commit f808084

File tree

8 files changed

+337
-138
lines changed

8 files changed

+337
-138
lines changed

src/execution/__tests__/variables-test.ts

+101
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,94 @@ 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 non-embedded variables', () => {
892+
const result = executeQuery(
893+
`
894+
query ($input: JSONScalar) {
895+
fieldWithJSONScalarInput(input: $input)
896+
}
897+
`,
898+
{ input: { a: 'foo', b: ['bar'], c: 'baz' } },
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 operation variables', () => {
909+
const result = executeQuery(
910+
`
911+
query ($input: String) {
912+
fieldWithJSONScalarInput(input: { a: $input, b: ["bar"], c: "baz" })
913+
}
914+
`,
915+
{ input: 'foo' },
916+
);
917+
918+
expectJSON(result).toDeepEqual({
919+
data: {
920+
fieldWithJSONScalarInput: '{ a: "foo", b: ["bar"], c: "baz" }',
921+
},
922+
});
923+
});
924+
925+
it('allows custom scalars with embedded fragment variables', () => {
926+
const result = executeQueryWithFragmentArguments(`
927+
{
928+
...JSONFragment(input: "foo")
929+
}
930+
fragment JSONFragment($input: String) on TestType {
931+
fieldWithJSONScalarInput(input: { a: $input, b: ["bar"], c: "baz" })
932+
}
933+
`);
934+
935+
expectJSON(result).toDeepEqual({
936+
data: {
937+
fieldWithJSONScalarInput: '{ a: "foo", b: ["bar"], c: "baz" }',
938+
},
939+
});
940+
});
941+
942+
it('allows custom scalars with embedded nested fragment variables', () => {
943+
const result = executeQueryWithFragmentArguments(`
944+
{
945+
...JSONFragment(input1: "foo")
946+
}
947+
fragment JSONFragment($input1: String) on TestType {
948+
...JSONNestedFragment(input2: $input1)
949+
}
950+
fragment JSONNestedFragment($input2: String) on TestType {
951+
fieldWithJSONScalarInput(input: { a: $input2, b: ["bar"], c: "baz" })
952+
}
953+
`);
954+
955+
expectJSON(result).toDeepEqual({
956+
data: {
957+
fieldWithJSONScalarInput: '{ a: "foo", b: ["bar"], c: "baz" }',
958+
},
959+
});
960+
});
961+
});
962+
862963
describe('Handles lists and nullability', () => {
863964
it('allows lists to be null', () => {
864965
const doc = `

src/execution/collectFields.ts

+23-11
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,
@@ -25,7 +26,7 @@ import { typeFromAST } from '../utilities/typeFromAST.js';
2526
import type { GraphQLVariableSignature } from './getVariableSignature.js';
2627
import type { VariableValues } from './values.js';
2728
import {
28-
experimentalGetArgumentValues,
29+
getArgumentValues,
2930
getDirectiveValues,
3031
getFragmentVariableValues,
3132
} from './values.js';
@@ -35,10 +36,21 @@ 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+
readonly fragmentVariableValues?: FragmentVariableValues;
48+
}
49+
3850
export interface FieldDetails {
3951
node: FieldNode;
4052
deferUsage?: DeferUsage | undefined;
41-
fragmentVariableValues?: VariableValues | undefined;
53+
fragmentVariableValues?: FragmentVariableValues | undefined;
4254
}
4355

4456
export type FieldDetailsList = ReadonlyArray<FieldDetails>;
@@ -168,7 +180,7 @@ function collectFieldsImpl(
168180
groupedFieldSet: AccumulatorMap<string, FieldDetails>,
169181
newDeferUsages: Array<DeferUsage>,
170182
deferUsage?: DeferUsage,
171-
fragmentVariableValues?: VariableValues,
183+
fragmentVariableValues?: FragmentVariableValues,
172184
): void {
173185
const {
174186
schema,
@@ -273,7 +285,7 @@ function collectFieldsImpl(
273285
);
274286

275287
const fragmentVariableSignatures = fragment.variableSignatures;
276-
let newFragmentVariableValues: VariableValues | undefined;
288+
let newFragmentVariableValues: FragmentVariableValues | undefined;
277289
if (fragmentVariableSignatures) {
278290
newFragmentVariableValues = getFragmentVariableValues(
279291
selection,
@@ -318,7 +330,7 @@ function collectFieldsImpl(
318330
*/
319331
function getDeferUsage(
320332
variableValues: VariableValues,
321-
fragmentVariableValues: VariableValues | undefined,
333+
fragmentVariableValues: FragmentVariableValues | undefined,
322334
node: FragmentSpreadNode | InlineFragmentNode,
323335
parentDeferUsage: DeferUsage | undefined,
324336
): DeferUsage | undefined {
@@ -351,7 +363,7 @@ function shouldIncludeNode(
351363
context: CollectFieldsContext,
352364
node: FragmentSpreadNode | FieldNode | InlineFragmentNode,
353365
variableValues: VariableValues,
354-
fragmentVariableValues: VariableValues | undefined,
366+
fragmentVariableValues: FragmentVariableValues | undefined,
355367
): boolean {
356368
const skipDirectiveNode = node.directives?.find(
357369
(directive) => directive.name.value === GraphQLSkipDirective.name,
@@ -361,9 +373,9 @@ function shouldIncludeNode(
361373
return false;
362374
}
363375
const skip = skipDirectiveNode
364-
? experimentalGetArgumentValues(
376+
? getArgumentValues(
377+
GraphQLSkipDirective,
365378
skipDirectiveNode,
366-
GraphQLSkipDirective.args,
367379
variableValues,
368380
fragmentVariableValues,
369381
context.hideSuggestions,
@@ -381,9 +393,9 @@ function shouldIncludeNode(
381393
return false;
382394
}
383395
const include = includeDirectiveNode
384-
? experimentalGetArgumentValues(
396+
? getArgumentValues(
397+
GraphQLIncludeDirective,
385398
includeDirectiveNode,
386-
GraphQLIncludeDirective.args,
387399
variableValues,
388400
fragmentVariableValues,
389401
context.hideSuggestions,

src/execution/execute.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,6 @@ import type {
8585
import { DeferredFragmentRecord } from './types.js';
8686
import type { VariableValues } from './values.js';
8787
import {
88-
experimentalGetArgumentValues,
8988
getArgumentValues,
9089
getDirectiveValues,
9190
getVariableValues,
@@ -877,9 +876,9 @@ function executeField(
877876
// Build a JS object of arguments from the field.arguments AST, using the
878877
// variables scope to fulfill any variable references.
879878
// TODO: find a way to memoize, in case this field is within a List type.
880-
const args = experimentalGetArgumentValues(
879+
const args = getArgumentValues(
880+
fieldDef,
881881
fieldDetailsList[0].node,
882-
fieldDef.args,
883882
variableValues,
884883
fieldDetailsList[0].fragmentVariableValues,
885884
hideSuggestions,
@@ -2298,6 +2297,7 @@ function executeSubscription(
22982297
fieldDef,
22992298
fieldNodes[0],
23002299
variableValues,
2300+
fieldDetailsList[0].fragmentVariableValues,
23012301
hideSuggestions,
23022302
);
23032303

0 commit comments

Comments
 (0)