Skip to content

Commit 3397a8a

Browse files
authored
chore: collect fields alternative (#3)
* patch * commit progress * this should be spec compliant * another alternative * linting * update tests * add test that Matt mentioned * remove only
1 parent 507e0dd commit 3397a8a

File tree

5 files changed

+246
-133
lines changed

5 files changed

+246
-133
lines changed

src/execution/__tests__/variables-test.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1198,11 +1198,12 @@ describe('Execute: Handles inputs', () => {
11981198
fieldWithNullableStringInput(input: $value)
11991199
}
12001200
`);
1201-
expect(result).to.deep.equal({
1202-
data: {
1203-
fieldWithNullableStringInput: null,
1204-
},
1205-
});
1201+
1202+
expect(result).to.have.property('errors');
1203+
expect(result.errors).to.have.length(1);
1204+
expect(result.errors?.at(0)?.message).to.match(
1205+
/Argument "value" of required type "String!"/,
1206+
);
12061207
});
12071208

12081209
it('when the definition has a default and is provided', () => {
@@ -1237,22 +1238,42 @@ describe('Execute: Handles inputs', () => {
12371238
});
12381239
});
12391240

1240-
it('when the definition has a non-nullable default and is provided null', () => {
1241+
it('when a definition has a default, is not provided, and spreads another fragment', () => {
12411242
const result = executeQueryWithFragmentArguments(`
12421243
query {
1243-
...a(value: null)
1244+
...a
12441245
}
1245-
fragment a($value: String! = "B") on TestType {
1246-
fieldWithNullableStringInput(input: $value)
1246+
fragment a($a: String! = "B") on TestType {
1247+
...b(b: $a)
1248+
}
1249+
fragment b($b: String!) on TestType {
1250+
fieldWithNonNullableStringInput(input: $b)
12471251
}
12481252
`);
12491253
expect(result).to.deep.equal({
12501254
data: {
1251-
fieldWithNullableStringInput: 'null',
1255+
fieldWithNonNullableStringInput: '"B"',
12521256
},
12531257
});
12541258
});
12551259

1260+
it('when the definition has a non-nullable default and is provided null', () => {
1261+
const result = executeQueryWithFragmentArguments(`
1262+
query {
1263+
...a(value: null)
1264+
}
1265+
fragment a($value: String! = "B") on TestType {
1266+
fieldWithNullableStringInput(input: $value)
1267+
}
1268+
`);
1269+
1270+
expect(result).to.have.property('errors');
1271+
expect(result.errors).to.have.length(1);
1272+
expect(result.errors?.at(0)?.message).to.match(
1273+
/Argument "value" of non-null type "String!"/,
1274+
);
1275+
});
1276+
12561277
it('when the definition has no default and is not provided', () => {
12571278
const result = executeQueryWithFragmentArguments(`
12581279
query {
@@ -1303,6 +1324,27 @@ describe('Execute: Handles inputs', () => {
13031324
});
13041325
});
13051326

1327+
it ('when a fragment-variable is shadowed by an intermediate fragment-spread but defined in the operation-variables', () => {
1328+
const result = executeQueryWithFragmentArguments(`
1329+
query($x: String = "A") {
1330+
...a
1331+
}
1332+
fragment a($x: String) on TestType {
1333+
...b
1334+
}
1335+
1336+
fragment b on TestType {
1337+
fieldWithNullableStringInput(input: $x)
1338+
}
1339+
`);
1340+
expect(result).to.deep.equal({
1341+
data: {
1342+
fieldWithNullableStringInput:
1343+
'"A"',
1344+
},
1345+
});
1346+
});
1347+
13061348
it('when a fragment is used with different args', () => {
13071349
const result = executeQueryWithFragmentArguments(`
13081350
query($x: String = "Hello") {

src/execution/collectFields.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,9 @@ import {
2222
} from '../type/directives.js';
2323
import type { GraphQLSchema } from '../type/schema.js';
2424

25-
import { substituteFragmentArguments } from '../utilities/substituteFragmentArguments.js';
2625
import { typeFromAST } from '../utilities/typeFromAST.js';
2726

28-
import { getDirectiveValues } from './values.js';
27+
import { getArgumentValuesFromSpread, getDirectiveValues } from './values.js';
2928

3029
export interface DeferUsage {
3130
label: string | undefined;
@@ -35,15 +34,16 @@ export interface DeferUsage {
3534
export interface FieldDetails {
3635
node: FieldNode;
3736
deferUsage: DeferUsage | undefined;
37+
fragmentVariableValues?: ObjMap<unknown> | undefined;
3838
}
3939

4040
interface CollectFieldsContext {
4141
schema: GraphQLSchema;
4242
fragments: ObjMap<FragmentDefinitionNode>;
43-
variableValues: { [variable: string]: unknown };
4443
operation: OperationDefinitionNode;
4544
runtimeType: GraphQLObjectType;
4645
visitedFragmentNames: Set<string>;
46+
variableValues: { [variable: string]: unknown };
4747
}
4848

4949
/**
@@ -66,13 +66,18 @@ export function collectFields(
6666
const context: CollectFieldsContext = {
6767
schema,
6868
fragments,
69-
variableValues,
7069
runtimeType,
70+
variableValues,
7171
operation,
7272
visitedFragmentNames: new Set(),
7373
};
7474

75-
collectFieldsImpl(context, operation.selectionSet, groupedFieldSet);
75+
collectFieldsImpl(
76+
context,
77+
operation.selectionSet,
78+
groupedFieldSet,
79+
variableValues,
80+
);
7681
return groupedFieldSet;
7782
}
7883

@@ -98,8 +103,8 @@ export function collectSubfields(
98103
const context: CollectFieldsContext = {
99104
schema,
100105
fragments,
101-
variableValues,
102106
runtimeType: returnType,
107+
variableValues,
103108
operation,
104109
visitedFragmentNames: new Set(),
105110
};
@@ -112,6 +117,7 @@ export function collectSubfields(
112117
context,
113118
node.selectionSet,
114119
subGroupedFieldSet,
120+
undefined,
115121
fieldDetail.deferUsage,
116122
);
117123
}
@@ -120,31 +126,35 @@ export function collectSubfields(
120126
return subGroupedFieldSet;
121127
}
122128

129+
// eslint-disable-next-line max-params
123130
function collectFieldsImpl(
124131
context: CollectFieldsContext,
125132
selectionSet: SelectionSetNode,
126133
groupedFieldSet: AccumulatorMap<string, FieldDetails>,
134+
fragmentVariableValues?: ObjMap<unknown>,
127135
parentDeferUsage?: DeferUsage,
128136
deferUsage?: DeferUsage,
129137
): void {
130138
const {
131139
schema,
132140
fragments,
133-
variableValues,
134141
runtimeType,
142+
variableValues,
135143
operation,
136144
visitedFragmentNames,
137145
} = context;
138146

139147
for (const selection of selectionSet.selections) {
140148
switch (selection.kind) {
141149
case Kind.FIELD: {
142-
if (!shouldIncludeNode(variableValues, selection)) {
150+
const vars = fragmentVariableValues ?? variableValues;
151+
if (!shouldIncludeNode(vars, selection)) {
143152
continue;
144153
}
145154
groupedFieldSet.add(getFieldEntryKey(selection), {
146155
node: selection,
147156
deferUsage: deferUsage ?? parentDeferUsage,
157+
fragmentVariableValues: fragmentVariableValues ?? undefined,
148158
});
149159
break;
150160
}
@@ -167,6 +177,7 @@ function collectFieldsImpl(
167177
context,
168178
selection.selectionSet,
169179
groupedFieldSet,
180+
fragmentVariableValues,
170181
parentDeferUsage,
171182
newDeferUsage ?? deferUsage,
172183
);
@@ -203,18 +214,34 @@ function collectFieldsImpl(
203214
visitedFragmentNames.add(fragmentName);
204215
}
205216

206-
const fragmentSelectionSet = substituteFragmentArguments(
207-
fragment,
208-
selection,
209-
);
217+
// We need to introduce a concept of shadowing:
218+
//
219+
// - when a fragment defines a variable that is in the parent scope but not given
220+
// in the fragment-spread we need to look at this variable as undefined and check
221+
// whether the definition has a defaultValue, if not remove it from the variableValues.
222+
// - when a fragment does not define a variable we need to copy it over from the parent
223+
// scope as that variable can still get used in spreads later on in the selectionSet.
224+
// - when a value is passed in through the fragment-spread we need to copy over the key-value
225+
// into our variable-values.
226+
const fragmentArgValues = fragment.variableDefinitions
227+
? getArgumentValuesFromSpread(
228+
selection,
229+
schema,
230+
fragment.variableDefinitions,
231+
variableValues,
232+
fragmentVariableValues,
233+
)
234+
: undefined;
210235

211236
collectFieldsImpl(
212237
context,
213-
fragmentSelectionSet,
238+
fragment.selectionSet,
214239
groupedFieldSet,
240+
fragmentArgValues,
215241
parentDeferUsage,
216242
newDeferUsage ?? deferUsage,
217243
);
244+
218245
break;
219246
}
220247
}

src/execution/execute.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -624,9 +624,10 @@ function executeField(
624624
// variables scope to fulfill any variable references.
625625
// TODO: find a way to memoize, in case this field is within a List type.
626626
const args = getArgumentValues(
627-
fieldDef,
628627
fieldGroup.fields[0].node,
628+
fieldDef.args,
629629
exeContext.variableValues,
630+
fieldGroup.fields[0].fragmentVariableValues,
630631
);
631632

632633
// The resolve function's optional third argument is a context value that
@@ -927,7 +928,7 @@ function getStreamUsage(
927928
const stream = getDirectiveValues(
928929
GraphQLStreamDirective,
929930
fieldGroup.fields[0].node,
930-
exeContext.variableValues,
931+
fieldGroup.fields[0].fragmentVariableValues ?? exeContext.variableValues,
931932
);
932933

933934
if (!stream) {
@@ -1846,7 +1847,12 @@ function executeSubscription(
18461847

18471848
// Build a JS object of arguments from the field.arguments AST, using the
18481849
// variables scope to fulfill any variable references.
1849-
const args = getArgumentValues(fieldDef, fieldNodes[0], variableValues);
1850+
const args = getArgumentValues(
1851+
fieldNodes[0],
1852+
fieldDef.args,
1853+
variableValues,
1854+
undefined,
1855+
);
18501856

18511857
// The resolve function's optional third argument is a context value that
18521858
// is provided to every resolve function within an execution. It is commonly

0 commit comments

Comments
 (0)