Skip to content

Commit a58e19a

Browse files
authored
Merge pull request #12875 from apollographql/pr/growing-schema-response-validation
GrowingSchema: split schema growing and response validation
2 parents fa7f4e5 + 1b7948a commit a58e19a

File tree

2 files changed

+122
-146
lines changed

2 files changed

+122
-146
lines changed

packages/ai/src/mocking/GrowingSchema.ts

Lines changed: 121 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import type {
33
DocumentNode,
44
FieldDefinitionNode,
55
FieldNode,
6+
FormattedExecutionResult,
67
GraphQLCompositeType,
78
InputValueDefinitionNode,
89
TypeNode,
910
} from "graphql";
1011
import {
12+
execute,
1113
extendSchema,
1214
FieldsOnCorrectTypeRule,
1315
GraphQLError,
@@ -21,8 +23,8 @@ import {
2123
visit,
2224
visitWithTypeInfo,
2325
} from "graphql";
24-
import { Maybe } from "graphql/jsutils/Maybe.js";
25-
import { AIAdapter } from "./AIAdapter.js";
26+
27+
import type { AIAdapter } from "./AIAdapter.js";
2628

2729
const rulesToIgnore = [
2830
FieldsOnCorrectTypeRule,
@@ -45,6 +47,8 @@ export class GrowingSchema {
4547
}),
4648
});
4749

50+
private seenQueries = new WeakSet<DocumentNode>();
51+
4852
public validateQuery(query: DocumentNode) {
4953
const errors = validate(this.schema, query, enforcedRules);
5054
if (errors.length > 0) {
@@ -64,9 +68,33 @@ export class GrowingSchema {
6468
response: AIAdapter.Result
6569
) {
6670
const query = operation.query;
71+
72+
const previousSchema = this.schema;
73+
74+
try {
75+
if (!this.seenQueries.has(query)) {
76+
this.mergeQueryIntoSchema(operation, response);
77+
}
78+
79+
this.validateResponseAgainstSchema(query, operation, response);
80+
this.seenQueries.add(query);
81+
} catch (e) {
82+
this.schema = previousSchema;
83+
throw e;
84+
}
85+
}
86+
87+
public mergeQueryIntoSchema(
88+
operation: {
89+
query: DocumentNode;
90+
variables?: Record<string, unknown>;
91+
},
92+
response: AIAdapter.Result
93+
) {
94+
const query = operation.query;
95+
6796
// @todo handle variables
6897
// const variables = operation.variables;
69-
7098
const typeInfo = new TypeInfo(this.schema);
7199
const responsePath = [response.data];
72100

@@ -78,10 +106,26 @@ export class GrowingSchema {
78106
definitions: [],
79107
} satisfies DocumentNode;
80108

81-
const mergeExtensions = () => {
109+
const mergeExtensions = ({
110+
assumeValidSDL = false,
111+
revisitAtPath,
112+
}: {
113+
assumeValidSDL?: boolean;
114+
revisitAtPath?: ReadonlyArray<string | number>;
115+
} = {}) => {
82116
this.schema = extendSchema(this.schema, accumulatedExtensions, {
83-
assumeValidSDL: true,
117+
assumeValidSDL,
84118
});
119+
120+
if (revisitAtPath) {
121+
Object.assign(typeInfo, new TypeInfo(this.schema));
122+
revisitAtPath.reduce((node: any, key: any) => {
123+
const child = node[key];
124+
typeInfo.enter(child);
125+
return child;
126+
}, query);
127+
}
128+
85129
accumulatedExtensions = {
86130
kind: Kind.DOCUMENT,
87131
definitions: [],
@@ -119,25 +163,37 @@ export class GrowingSchema {
119163
type
120164
);
121165

122-
const existingFieldDef = typeInfo.getFieldDef();
166+
const existingFieldDef = typeInfo.getFieldDef()?.astNode;
123167
if (existingFieldDef) {
124-
if (
125-
this.newFieldDefinitionMatchesExistingFieldDefinition(
126-
newFieldDef,
127-
existingFieldDef.astNode
128-
)
129-
) {
130-
// The new and existing field definitions match, so we
168+
const existingArguments = new Map(
169+
existingFieldDef.arguments?.map((arg) => [arg.name.value, arg])
170+
);
171+
const additionalArgs =
172+
newFieldDef.arguments?.filter(
173+
(arg) => !existingArguments.has(arg.name.value)
174+
) || [];
175+
176+
if (!additionalArgs.length) {
177+
// The existing field definition is sufficient, so we
131178
// can skip adding the new field definition to the schema.
132179
return;
133180
}
134-
// The new and existing field definitions don't match, so we
135-
// need to attempt to merge them.
136-
newFieldDef = this.mergeFieldDefinitions(
137-
newFieldDef,
138-
existingFieldDef.astNode,
139-
type.name
140-
);
181+
182+
accumulatedExtensions.definitions.push({
183+
kind: Kind.OBJECT_TYPE_EXTENSION,
184+
name: { kind: Kind.NAME, value: type.name },
185+
fields: [
186+
{
187+
...existingFieldDef,
188+
arguments: [
189+
...(existingFieldDef.arguments || []),
190+
...additionalArgs,
191+
],
192+
},
193+
],
194+
});
195+
mergeExtensions({ assumeValidSDL: true, revisitAtPath: path });
196+
return;
141197
}
142198

143199
if (node.name.value === "__typename") {
@@ -162,13 +218,9 @@ export class GrowingSchema {
162218
// this selection set couldn't be entered correctly before, so we
163219
// need to merge the schema now, and have the type info start
164220
// from the top to navigate to the current node
221+
mergeExtensions({ revisitAtPath: path });
222+
} else {
165223
mergeExtensions();
166-
Object.assign(typeInfo, new TypeInfo(this.schema));
167-
path.reduce((node: any, key: any) => {
168-
const child = node[key];
169-
typeInfo.enter(child);
170-
return child;
171-
}, query);
172224
}
173225
},
174226
},
@@ -177,6 +229,49 @@ export class GrowingSchema {
177229
mergeExtensions();
178230
}
179231

232+
private validateResponseAgainstSchema(
233+
query: DocumentNode,
234+
operation: { query: DocumentNode; variables?: Record<string, unknown> },
235+
response: FormattedExecutionResult<Record<string, any>, Record<string, any>>
236+
) {
237+
const result = execute({
238+
schema: this.schema,
239+
document: query,
240+
variableValues: operation.variables,
241+
fieldResolver: (source, args, context, info) => {
242+
const value = source[info.fieldName];
243+
switch (info.returnType.toString()) {
244+
case "String":
245+
if (typeof value !== "string") {
246+
throw new TypeError(`Value is not string: ${value}`);
247+
}
248+
break;
249+
case "Float":
250+
if (typeof value !== "number") {
251+
throw new TypeError(`Value is not number: ${value}`);
252+
}
253+
break;
254+
case "Boolean":
255+
if (typeof value !== "boolean") {
256+
throw new TypeError(`Value is not boolean: ${value}`);
257+
}
258+
break;
259+
}
260+
261+
return value;
262+
},
263+
rootValue: response.data,
264+
}) as FormattedExecutionResult;
265+
266+
if (result.errors?.length) {
267+
throw new GraphQLError(
268+
`Error executing query against grown schema: ${result.errors
269+
.map((e) => e.message)
270+
.join(", ")}`
271+
);
272+
}
273+
}
274+
180275
private getFieldArguments(node: FieldNode): InputValueDefinitionNode[] {
181276
// @todo we need to handle named input object arguments
182277
// For now, we'll only handle build-in scalar arguments
@@ -281,125 +376,6 @@ export class GrowingSchema {
281376
}
282377
}
283378

284-
/**
285-
* Helper function to compare two TypeNode objects for equality
286-
*/
287-
private typeNodesEqual(type1: TypeNode, type2: TypeNode): boolean {
288-
if (type1.kind !== type2.kind) {
289-
return false;
290-
}
291-
292-
switch (type1.kind) {
293-
case Kind.NAMED_TYPE:
294-
return type1.name.value === (type2 as typeof type1).name.value;
295-
case Kind.LIST_TYPE:
296-
return this.typeNodesEqual(type1.type, (type2 as typeof type1).type);
297-
case Kind.NON_NULL_TYPE:
298-
return this.typeNodesEqual(type1.type, (type2 as typeof type1).type);
299-
default:
300-
return false;
301-
}
302-
}
303-
304-
/**
305-
* Helper function to convert TypeNode to human-readable string
306-
*/
307-
private static typeNodeToString(type: TypeNode): string {
308-
switch (type.kind) {
309-
case Kind.NAMED_TYPE:
310-
return type.name.value;
311-
case Kind.LIST_TYPE:
312-
return `[${GrowingSchema.typeNodeToString(type.type)}]`;
313-
case Kind.NON_NULL_TYPE:
314-
return `${GrowingSchema.typeNodeToString(type.type)}!`;
315-
default:
316-
return "Unknown";
317-
}
318-
}
319-
320-
private newFieldDefinitionMatchesExistingFieldDefinition(
321-
newFieldDef: FieldDefinitionNode,
322-
existingFieldDef: Maybe<FieldDefinitionNode>
323-
): boolean {
324-
if (!existingFieldDef) {
325-
return false;
326-
}
327-
if (existingFieldDef.name.value !== newFieldDef.name.value) {
328-
return false;
329-
}
330-
331-
// Check arguments
332-
const newArgs = newFieldDef.arguments || [];
333-
const existingArgs = existingFieldDef.arguments || [];
334-
335-
// Check argument count
336-
if (newArgs.length !== existingArgs.length) {
337-
return false;
338-
}
339-
340-
// Check each argument by name and type
341-
for (const newArg of newArgs) {
342-
const existingArg = existingArgs.find(
343-
(arg) => arg.name.value === newArg.name.value
344-
);
345-
346-
if (!existingArg) {
347-
return false; // Argument name not found
348-
}
349-
350-
// Check argument types
351-
if (!this.typeNodesEqual(newArg.type, existingArg.type)) {
352-
return false;
353-
}
354-
}
355-
356-
// Check field return types
357-
if (!this.typeNodesEqual(newFieldDef.type, existingFieldDef.type)) {
358-
return false;
359-
}
360-
361-
return true;
362-
}
363-
364-
/**
365-
* @todo handle existing field definition that doesn't match the new field definition
366-
* We need to:
367-
*
368-
* - merge arguments
369-
* - check return type
370-
* - If the return type is different, we need to throw an error
371-
*/
372-
private mergeFieldDefinitions(
373-
newFieldDef: FieldDefinitionNode,
374-
existingFieldDef: Maybe<FieldDefinitionNode>,
375-
parentTypeName: string
376-
): FieldDefinitionNode {
377-
if (!existingFieldDef) {
378-
return newFieldDef;
379-
}
380-
381-
if (!this.typeNodesEqual(newFieldDef.type, existingFieldDef.type)) {
382-
const existingReturnTypeString = GrowingSchema.typeNodeToString(
383-
existingFieldDef.type
384-
);
385-
const newReturnTypeString = GrowingSchema.typeNodeToString(
386-
newFieldDef.type
387-
);
388-
throw new GraphQLError(
389-
`Field \`${parentTypeName}.${newFieldDef.name.value}\` return type mismatch. Previously defined return type: \`${existingReturnTypeString}\`, new return type: \`${newReturnTypeString}\``
390-
);
391-
}
392-
393-
const newArgs = newFieldDef.arguments || [];
394-
const existingArgs = existingFieldDef.arguments || [];
395-
const mergedArgs = [...existingArgs, ...newArgs];
396-
397-
return {
398-
...existingFieldDef,
399-
arguments: mergedArgs,
400-
};
401-
}
402-
403379
public toString() {
404380
return printSchema(this.schema);
405381
}

packages/ai/src/mocking/__tests__/GrowingSchema.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ describe("GrowingSchema", () => {
223223

224224
expect(error).toBeInstanceOf(GraphQLError);
225225
expect(error?.message).toEqual(
226-
"Field `Query.users` return type mismatch. Previously defined return type: `[User]`, new return type: `UserConnection`"
226+
'Error executing query against grown schema: Expected Iterable, but did not find one for field "Query.users".'
227227
);
228228
});
229229

0 commit comments

Comments
 (0)