Skip to content

Commit 4044a62

Browse files
committed
fix(replaceVariables): preserve sources for fragment variable values (graphql#4389)
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 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 d38a55d commit 4044a62

File tree

8 files changed

+337
-144
lines changed

8 files changed

+337
-144
lines changed

src/execution/__tests__/variables-test.ts

Lines changed: 101 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 } 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

Lines changed: 22 additions & 13 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,
@@ -23,14 +24,22 @@ import { typeFromAST } from '../utilities/typeFromAST.js';
2324

2425
import type { GraphQLVariableSignature } from './getVariableSignature.js';
2526
import type { VariableValues } from './values.js';
26-
import {
27-
experimentalGetArgumentValues,
28-
getFragmentVariableValues,
29-
} from './values.js';
27+
import { getArgumentValues, getFragmentVariableValues } from './values.js';
28+
29+
export interface FragmentVariableValues {
30+
readonly sources: ReadOnlyObjMap<FragmentVariableValueSource>;
31+
readonly coerced: ReadOnlyObjMap<unknown>;
32+
}
33+
34+
interface FragmentVariableValueSource {
35+
readonly signature: GraphQLVariableSignature;
36+
readonly value?: ConstValueNode;
37+
readonly fragmentVariableValues?: FragmentVariableValues;
38+
}
3039

3140
export interface FieldDetails {
3241
node: FieldNode;
33-
fragmentVariableValues?: VariableValues | undefined;
42+
fragmentVariableValues?: FragmentVariableValues | undefined;
3443
}
3544

3645
export type FieldDetailsList = ReadonlyArray<FieldDetails>;
@@ -145,7 +154,7 @@ function collectFieldsImpl(
145154
context: CollectFieldsContext,
146155
selectionSet: SelectionSetNode,
147156
groupedFieldSet: AccumulatorMap<string, FieldDetails>,
148-
fragmentVariableValues?: VariableValues,
157+
fragmentVariableValues?: FragmentVariableValues,
149158
): void {
150159
const {
151160
schema,
@@ -221,7 +230,7 @@ function collectFieldsImpl(
221230
}
222231

223232
const fragmentVariableSignatures = fragment.variableSignatures;
224-
let newFragmentVariableValues: VariableValues | undefined;
233+
let newFragmentVariableValues: FragmentVariableValues | undefined;
225234
if (fragmentVariableSignatures) {
226235
newFragmentVariableValues = getFragmentVariableValues(
227236
selection,
@@ -253,7 +262,7 @@ function shouldIncludeNode(
253262
context: CollectFieldsContext,
254263
node: FragmentSpreadNode | FieldNode | InlineFragmentNode,
255264
variableValues: VariableValues,
256-
fragmentVariableValues: VariableValues | undefined,
265+
fragmentVariableValues: FragmentVariableValues | undefined,
257266
): boolean {
258267
const skipDirectiveNode = node.directives?.find(
259268
(directive) => directive.name.value === GraphQLSkipDirective.name,
@@ -263,9 +272,9 @@ function shouldIncludeNode(
263272
return false;
264273
}
265274
const skip = skipDirectiveNode
266-
? experimentalGetArgumentValues(
275+
? getArgumentValues(
276+
GraphQLSkipDirective,
267277
skipDirectiveNode,
268-
GraphQLSkipDirective.args,
269278
variableValues,
270279
fragmentVariableValues,
271280
context.hideSuggestions,
@@ -283,9 +292,9 @@ function shouldIncludeNode(
283292
return false;
284293
}
285294
const include = includeDirectiveNode
286-
? experimentalGetArgumentValues(
295+
? getArgumentValues(
296+
GraphQLIncludeDirective,
287297
includeDirectiveNode,
288-
GraphQLIncludeDirective.args,
289298
variableValues,
290299
fragmentVariableValues,
291300
context.hideSuggestions,

src/execution/execute.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,7 @@ import {
6565
import { getVariableSignature } from './getVariableSignature.js';
6666
import { mapAsyncIterable } from './mapAsyncIterable.js';
6767
import type { VariableValues } from './values.js';
68-
import {
69-
experimentalGetArgumentValues,
70-
getArgumentValues,
71-
getVariableValues,
72-
} from './values.js';
68+
import { getArgumentValues, getVariableValues } from './values.js';
7369

7470
/* eslint-disable @typescript-eslint/max-params */
7571
// This file contains a lot of such errors but we plan to refactor it anyway
@@ -641,9 +637,9 @@ function executeField(
641637
// Build a JS object of arguments from the field.arguments AST, using the
642638
// variables scope to fulfill any variable references.
643639
// TODO: find a way to memoize, in case this field is within a List type.
644-
const args = experimentalGetArgumentValues(
640+
const args = getArgumentValues(
641+
fieldDef,
645642
firstFieldNode,
646-
fieldDef.args,
647643
variableValues,
648644
firstFieldDetails.fragmentVariableValues,
649645
hideSuggestions,
@@ -1667,6 +1663,7 @@ function executeSubscription(
16671663
fieldDef,
16681664
fieldNodes[0],
16691665
variableValues,
1666+
fieldDetailsList[0].fragmentVariableValues,
16701667
hideSuggestions,
16711668
);
16721669

0 commit comments

Comments
 (0)