@@ -3,11 +3,13 @@ import type {
33 DocumentNode ,
44 FieldDefinitionNode ,
55 FieldNode ,
6+ FormattedExecutionResult ,
67 GraphQLCompositeType ,
78 InputValueDefinitionNode ,
89 TypeNode ,
910} from "graphql" ;
1011import {
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
2729const 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 }
0 commit comments