diff --git a/src/__tests__/recursive-input.test.ts b/src/__tests__/recursive-input.test.ts new file mode 100644 index 00000000..28a5ca84 --- /dev/null +++ b/src/__tests__/recursive-input.test.ts @@ -0,0 +1,588 @@ +import { DocumentNode, GraphQLSchema, parse, validate } from "graphql"; +import { makeExecutableSchema } from "graphql-tools"; +import { compileQuery, isCompiledQuery } from "../execution"; + +describe("recursive input types", () => { + describe("simple recursive input", () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + foo(input: FooInput): String + } + input FooInput { + foo: FooInput + } + `, + resolvers: { + Query: { + foo(_, args) { + // used as the actual value in test matchers + return JSON.stringify(args); + } + } + } + }); + + test("should not fail for recursive input without variables", () => { + const query = parse(` + { + foo(input: { + foo: { + foo: { + foo: { + foo: {} + } + } + } + }) + } + `); + + const result = executeQuery(schema, query); + + expect(result.errors).toBeUndefined(); + expect(result.data.foo).toBe( + JSON.stringify({ + input: { + foo: { foo: { foo: { foo: {} } } } + } + }) + ); + }); + + test("should not fail with variables using recursive input types", () => { + const document = parse(` + query ($f: FooInput) { + foo(input: $f) + } + `); + const variables = { + f: { + foo: { foo: { foo: {} } } + } + }; + + const result = executeQuery(schema, document, variables); + expect(result.errors).toBeUndefined(); + expect(result.data.foo).toBe( + JSON.stringify({ + input: { foo: { foo: { foo: {} } } } + }) + ); + }); + + // when the recursive variable appers at a nested level + test("should not fail with variables using recursive input types - 2", () => { + const document = parse(` + query ($f: FooInput) { + foo(input: { + foo: { foo: { foo: $f } } + }) + } + `); + const variables = { + f: { + foo: { foo: { foo: {} } } + } + }; + + const result = executeQuery(schema, document, variables); + expect(result.errors).toBeUndefined(); + expect(result.data.foo).toBe( + JSON.stringify({ + input: { foo: { foo: { foo: { foo: { foo: { foo: {} } } } } } } + }) + ); + }); + + test("should work with multiple variables using the same recursive input type", () => { + const document = parse(` + query ($f: FooInput, $g: FooInput) { + a: foo(input: $f) + b: foo(input: $g) + } + `); + const variables = { + f: { + foo: { foo: { foo: {} } } + }, + g: { + foo: {} + } + }; + + const result = executeQuery(schema, document, variables); + expect(result.errors).toBeUndefined(); + expect(result.data.a).toBe( + JSON.stringify({ + input: { foo: { foo: { foo: {} } } } + }) + ); + expect(result.data.b).toBe( + JSON.stringify({ + input: { foo: {} } + }) + ); + }); + + test("should work with multiple variables using the same recursive input type - 2 (reverse order)", () => { + const document = parse(` + query ($f: FooInput, $g: FooInput) { + a: foo(input: $g) + b: foo(input: $f) + } + `); + const variables = { + g: { + foo: {} + }, + f: { + foo: { foo: { foo: {} } } + } + }; + + const result = executeQuery(schema, document, variables); + expect(result.errors).toBeUndefined(); + expect(result.data.b).toBe( + JSON.stringify({ + input: { foo: { foo: { foo: {} } } } + }) + ); + expect(result.data.a).toBe( + JSON.stringify({ + input: { foo: {} } + }) + ); + }); + + test("should capture and throw error when recursive value is passed", () => { + const document = parse(` + query ($f: FooInput) { + foo(input: $f) + } + `); + const variables: any = { + f: { + foo: { foo: { foo: {} } } + } + }; + variables.f.foo = variables.f; + + const result = executeQuery(schema, document, variables, true); + expect(result.errors[0].message).toBe( + "Circular reference detected in input variable '$f' at foo.foo" + ); + }); + }); + + describe("simple recursive input - 2", () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + foo(input: FooInput): String + } + input FooInput { + foo: FooInput + bar: String + } + `, + resolvers: { + Query: { + foo(_, args) { + // used as the actual value in test matchers + return JSON.stringify(args); + } + } + } + }); + + test("should nòt fail for same leaf values", () => { + const document = parse(` + query ($f: FooInput) { + foo(input: $f) + } + `); + const variables = { + f: { + foo: { + bar: "bar" + }, + bar: "bar" + } + }; + + const result = executeQuery(schema, document, variables); + expect(result.errors).toBeUndefined(); + expect(JSON.parse(result.data.foo).input).toEqual(variables.f); + }); + }); + + describe("mutually recursive input types", () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + products(filter: Filter): String + } + input Filter { + and: AndFilter + or: OrFilter + like: String + } + input AndFilter { + left: Filter + right: Filter + } + input OrFilter { + left: Filter + right: Filter + } + `, + resolvers: { + Query: { + products(_, args) { + // used as the actual value in test matchers + return JSON.stringify(args); + } + } + } + }); + + test("should not fail for mutually recursive variables", () => { + const document = parse(` + query ($filter1: Filter) { + products(filter: $filter1) + } + `); + + const variables = { + filter1: { + and: { + left: { + like: "windows" + }, + right: { + or: { + left: { + like: "xp" + }, + right: { + like: "vista" + } + } + } + } + } + }; + + const result = executeQuery(schema, document, variables); + expect(JSON.parse(result.data.products).filter).toEqual( + variables.filter1 + ); + }); + + test("should not fail for mutually recursive variables - multiple variables", () => { + const document = parse(` + query ($aFilter: Filter, $bFilter: Filter) { + a: products(filter: $aFilter) + b: products(filter: $bFilter) + } + `); + + const variables = { + aFilter: { + and: { + left: { + like: "windows" + }, + right: { + or: { + left: { + like: "xp" + }, + right: { + like: "vista" + } + } + } + } + }, + bFilter: { + like: "mac", + or: { + left: { + like: "10" + }, + right: { + like: "11" + } + } + } + }; + + const result = executeQuery(schema, document, variables); + expect(JSON.parse(result.data.a).filter).toEqual(variables.aFilter); + expect(JSON.parse(result.data.b).filter).toEqual(variables.bFilter); + }); + + // when the mutually recursive input type appears at nested level + // instead of the top-level variable + test("should not fail for mutually recursive variables - 2", () => { + const document = parse(` + query ($macFilter: OrFilter) { + products(filter: { + like: "mac" + and: { + left: { like: "User" } + right: { like: "foo" } + } + or: $macFilter + }) + } + `); + + const variables = { + macFilter: { + left: { like: "Applications/Safari" }, + right: { like: "Applications/Notes" } + } + }; + + const result = executeQuery(schema, document, variables); + expect(JSON.parse(result.data.products).filter.or).toEqual( + variables.macFilter + ); + }); + + test("should throw for mutually recursive runtime values", () => { + const document = parse(` + query ($filter1: Filter) { + products(filter: $filter1) + } + `); + + const variables: any = { + filter1: { + and: { + left: {} + }, + or: { + left: {} + } + } + }; + // create mututal recursion at + // and.left.or.left.and <-> or.left.and.left.or + variables.filter1.and.left.or = variables.filter1.or; + variables.filter1.or.left.and = variables.filter1.and; + + const result = executeQuery(schema, document, variables, true); + expect(result.errors[0].message).toBe( + "Circular reference detected in input variable '$filter1' at and.left.or.left.and" + ); + }); + }); + + describe("lists", () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + items(filters: [Filter]): String + } + input Filter { + or: [Filter] + and: [Filter] + like: String + } + `, + resolvers: { + Query: { + items(_, input) { + // used as the actual value in test matchers + return JSON.stringify(input); + } + } + } + }); + + test("should work with recursive types in lists", () => { + const document = parse(` + query ($filters: [Filter]) { + items(filters: $filters) + } + `); + const variables = { + filters: [ + { + or: [ + { + like: "gallery", + or: [{ like: "photo" }, { like: "video" }] + } + ] + } + ] + }; + + const result = executeQuery(schema, document, variables); + expect(result.errors).toBeUndefined(); + expect(JSON.parse(result.data.items).filters).toEqual(variables.filters); + }); + + test("should throw error for runtime circular dependencies in lists", () => { + const document = parse(` + query ($filters: [Filter]) { + items(filters: $filters) + } + `); + const variables: any = { + filters: [{}] + }; + variables.filters[0].or = variables.filters; + + const result = executeQuery(schema, document, variables, true); + expect(result.errors[0].message).toBe( + `Circular reference detected in input variable '$filters' at 0.or.0` + ); + }); + + test("should throw error for runtime circular dependencies in lists - 2", () => { + const document = parse(` + query ($filters: [Filter]) { + items(filters: $filters) + } + `); + const variables: any = { + filters: [ + { + or: [ + { + like: "gallery", + or: [{ like: "photo" }] + } + ] + } + ] + }; + variables.filters[0].or[0].or[0].or = variables.filters; + + const result = executeQuery(schema, document, variables, true); + expect(result.errors[0].message).toBe( + `Circular reference detected in input variable '$filters' at 0.or.0.or.0.or.0` + ); + }); + + test("should throw error for runtime circular dependencies in lists - 3", () => { + const document = parse(` + query ($filters: [Filter]) { + items(filters: $filters) + } + `); + const variables: any = { + filters: [ + { + or: [ + { + and: [{ like: "foo" }, { like: "bar" }] + } + ] + } + ] + }; + variables.filters[0].or[1] = variables.filters[0]; + + const result = executeQuery(schema, document, variables, true); + expect(result.errors[0].message).toBe( + `Circular reference detected in input variable '$filters' at 0.or.1` + ); + }); + }); + + describe("lists - 2", () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + flatten(list: [[[[[Item]]]]]): String + } + input Item { + id: ID + } + `, + resolvers: { + Query: { + flatten(_, input) { + // used as the actual value in test matchers + return JSON.stringify(input); + } + } + } + }); + + test("should work with recursive types in lists", () => { + const document = parse(` + query ($list: [[[[[Item]]]]]) { + flatten(list: $list) + } + `); + const variables = { + list: [ + [[[[{ id: "1" }, { id: "2" }]]]], + [[[[{ id: "3" }, { id: "4" }]]]] + ] + }; + + const result = executeQuery(schema, document, variables); + expect(result.errors).toBeUndefined(); + expect(JSON.parse(result.data.flatten).list).toEqual(variables.list); + }); + + test("should throw when lists are circular referenced", () => { + const document = parse(` + query ($list: [[[[[Item]]]]]) { + flatten(list: $list) + } + `); + const variables: any = { + list: [] + }; + variables.list.push(variables.list); + + const result = executeQuery(schema, document, variables, true); + expect(result.errors[0].message).toBe( + "Circular reference detected in input variable '$list' at 0.0" + ); + }); + + test("should throw when lists are mutually circular referenced", () => { + const document = parse(` + query ($list: [[[[[Item]]]]]) { + flatten(list: $list) + } + `); + const variables: any = { + list: [[], []] + }; + variables.list[0].push(variables.list[1]); + variables.list[1].push(variables.list[0]); + + const result = executeQuery(schema, document, variables, true); + expect(result.errors[0].message).toBe( + "Circular reference detected in input variable '$list' at 0.0.0" + ); + }); + }); +}); + +function executeQuery( + schema: GraphQLSchema, + document: DocumentNode, + variableValues?: any, + variablesCircularReferenceCheck?: boolean +) { + const prepared: any = compileQuery(schema, document as any, undefined, { + variablesCircularReferenceCheck + }); + if (!isCompiledQuery(prepared)) { + return prepared; + } + return prepared.query({}, {}, variableValues || {}); +} diff --git a/src/execution.ts b/src/execution.ts index 592c9be3..4eea7a85 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -76,6 +76,20 @@ export interface CompilerOptions { // the key should be the name passed to the Scalar or Enum type customSerializers: { [key: string]: (v: any) => any }; + /** + * If true, the generated code for variables compilation validates + * that there are no circular references (at runtime). For most cases, + * the variables are the result of JSON.parse and in these cases, we + * do not need this. Enable this if the variables passed to the execute + * function may contain circular references. + * + * When enabled, the code checks for circular references in the + * variables input, and throws an error when found. + * + * Default: false + */ + variablesCircularReferenceCheck: boolean; + resolverInfoEnricher?: (inp: ResolveInfoEnricherInput) => object; } @@ -207,6 +221,7 @@ export function compileQuery( customJSONSerializer: false, disableLeafSerialization: false, customSerializers: {}, + variablesCircularReferenceCheck: false, ...partialOptions }; @@ -228,6 +243,9 @@ export function compileQuery( } const getVariables = compileVariableParsing( schema, + { + variablesCircularReferenceCheck: options.variablesCircularReferenceCheck + }, context.operation.variableDefinitions || [] ); diff --git a/src/variables.ts b/src/variables.ts index a7ea581f..ec492b9b 100644 --- a/src/variables.ts +++ b/src/variables.ts @@ -4,11 +4,14 @@ import { GraphQLError, GraphQLFloat, GraphQLID, + GraphQLInputObjectType, GraphQLInputType, GraphQLInt, + GraphQLList, GraphQLSchema, GraphQLString, isEnumType, + isInputObjectType, isInputType, isListType, isNonNullType, @@ -19,7 +22,7 @@ import { valueFromAST, VariableDefinitionNode } from "graphql"; -import { addPath, computeLocations, ObjectPath } from "./ast"; +import { addPath, computeLocations, flattenPath, ObjectPath } from "./ast"; import { GraphQLError as GraphQLJITError } from "./error"; import createInspect from "./inspect"; @@ -39,13 +42,31 @@ export function failToParseVariables(x: any): x is FailedVariableCoercion { return x.errors; } +export interface VariablesCompilerOptions { + /** + * If true, the generated code for variables compilation validates + * that there are no circular references (at runtime). For most cases, + * the variables are the result of JSON.parse and in these cases, we + * do not need this. Enable this if the variables passed to the execute + * function may contain circular references. + * + * When enabled, the code checks for circular references in the + * variables input, and throws an error when found. + * + * Default: false (set in execution.ts) + */ + variablesCircularReferenceCheck: boolean; +} + interface CompilationContext { + options: VariablesCompilerOptions; inputPath: ObjectPath; responsePath: ObjectPath; depth: number; varDefNode: VariableDefinitionNode; dependencies: Map any>; errorMessage?: string; + hoistedFunctions: Map; } function createSubCompilationContext( @@ -53,8 +74,10 @@ function createSubCompilationContext( ): CompilationContext { return { ...context }; } + export function compileVariableParsing( schema: GraphQLSchema, + options: VariablesCompilerOptions, varDefNodes: ReadonlyArray ): (inputs: { [key: string]: any }) => CoercedVariableValues { const errors = []; @@ -62,13 +85,16 @@ export function compileVariableParsing( let mainBody = ""; const dependencies = new Map(); + const hoistedFunctions = new Map(); for (const varDefNode of varDefNodes) { const context: CompilationContext = { + options, varDefNode, depth: 0, inputPath: addPath(undefined, "input"), responsePath: addPath(undefined, "coerced"), - dependencies + dependencies, + hoistedFunctions }; const varName = varDefNode.variable.name.value; const varType = typeFromAST(schema, varDefNode.type as any); @@ -98,7 +124,15 @@ export function compileVariableParsing( )}, "${varName}");\n`; context.inputPath = addPath(context.inputPath, varName); context.responsePath = addPath(context.responsePath, varName); - mainBody += generateInput(context, varType, varName, hasValueName, false); + mainBody += generateInput({ + context, + varType, + varName, + hasValueName, + wrapInList: false, + useInputPath: false, + useResponsePath: false + }); } if (errors.length > 0) { @@ -107,6 +141,32 @@ export function compileVariableParsing( const gen = genFn(); gen(` + const visitedInputValues = new Set(); + + function getPath(o, path) { + let current = o; + for (const part of path) { + current = current[part]; + } + return current; + } + + function setPath(o, path, value) { + let current = o; + for (let i = 0; i < path.length - 1; i++) { + current = current[path[i]]; + } + current[path[path.length - 1]] = value; + } + + function isObjectLike(o) { + return o != null && typeof o === "object"; + } + + ${Array.from(hoistedFunctions) + .map(([, value]) => value) + .join("\n")} + return function getVariables(input) { const errors = []; const coerced = ${JSON.stringify(coercedValues)} @@ -118,11 +178,13 @@ export function compileVariableParsing( } `); + const generatedFn = gen.toString(); + return Function.apply( null, ["GraphQLJITError", "inspect"] .concat(Array.from(dependencies.keys())) - .concat(gen.toString()) + .concat(generatedFn) ).apply( null, [GraphQLJITError, inspect].concat(Array.from(dependencies.values())) @@ -134,15 +196,32 @@ export function compileVariableParsing( const MAX_32BIT_INT = 2147483647; const MIN_32BIT_INT = -2147483648; -function generateInput( - context: CompilationContext, - varType: GraphQLInputType, - varName: string, - hasValueName: string, - wrapInList: boolean -) { +interface GenerateInputParams { + context: CompilationContext; + varType: GraphQLInputType; + varName: string; + hasValueName: string; + wrapInList: boolean; + useInputPath: boolean; + useResponsePath: boolean; +} + +function generateInput({ + context, + varType, + varName, + hasValueName, + wrapInList, + useInputPath, + useResponsePath +}: GenerateInputParams) { const currentOutput = getObjectPath(context.responsePath); - const currentInput = getObjectPath(context.inputPath); + const responsePath = useResponsePath + ? "responsePath" + : pathToExpression(context.responsePath); + const currentInput = useInputPath + ? `getPath(input, inputPath)` + : getObjectPath(context.inputPath); const errorLocation = printErrorLocation( computeLocations([context.varDefNode]) ); @@ -173,7 +252,7 @@ function generateInput( `); } else { gen(` - if (${hasValueName}) { ${currentOutput} = null; } + if (${hasValueName}) { setPath(coerced, ${responsePath}, null); } `); } gen(`} else {`); @@ -182,9 +261,9 @@ function generateInput( case GraphQLID.name: gen(` if (typeof ${currentInput} === "string") { - ${currentOutput} = ${currentInput}; + setPath(coerced, ${responsePath}, ${currentInput}); } else if (Number.isInteger(${currentInput})) { - ${currentOutput} = ${currentInput}.toString(); + setPath(coerced, ${responsePath}, ${currentInput}.toString()); } else { errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + "; " + @@ -198,7 +277,7 @@ function generateInput( case GraphQLString.name: gen(` if (typeof ${currentInput} === "string") { - ${currentOutput} = ${currentInput}; + setPath(coerced, ${responsePath}, ${currentInput}); } else { errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + "; " + @@ -212,7 +291,7 @@ function generateInput( case GraphQLBoolean.name: gen(` if (typeof ${currentInput} === "boolean") { - ${currentOutput} = ${currentInput}; + setPath(coerced, ${responsePath}, ${currentInput}); } else { errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + "; " + @@ -234,7 +313,7 @@ function generateInput( } cannot represent non 32-bit signed integer value: ' + inspect(${currentInput}), ${errorLocation})); } else { - ${currentOutput} = ${currentInput}; + setPath(coerced, ${responsePath}, ${currentInput}); } } else { errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + @@ -249,7 +328,7 @@ function generateInput( case GraphQLFloat.name: gen(` if (Number.isFinite(${currentInput})) { - ${currentOutput} = ${currentInput}; + setPath(coerced, ${responsePath}, ${currentInput}); } else { errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + "; " + @@ -273,7 +352,7 @@ function generateInput( inspect(${currentInput}) + "; " + 'Expected type ${varType.name}.', ${errorLocation})); } - ${currentOutput} = parseResult; + setPath(coerced, ${responsePath}, parseResult); } catch (error) { errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + "; " + @@ -291,7 +370,7 @@ function generateInput( if (typeof ${currentInput} === "string") { const enumValue = ${varType.name}getValue(${currentInput}); if (enumValue) { - ${currentOutput} = enumValue.value; + setPath(coerced, ${responsePath}, enumValue.value); } else { errors.push( new GraphQLJITError('Variable "$${varName}" got invalid value ' + @@ -308,94 +387,295 @@ function generateInput( } `); } else if (isListType(varType)) { - context.errorMessage = `'Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + '; '`; - const hasValueName = hasValue(context.inputPath); - const index = `idx${context.depth}`; + gen( + compileInputListType( + context, + varType, + varName, + useInputPath, + useResponsePath + ) + ); + } else if (isInputObjectType(varType)) { + gen( + compileInputObjectType( + context, + varType, + varName, + useInputPath, + useResponsePath + ) + ); + } else { + /* istanbul ignore next line */ + throw new Error(`unknown type: ${varType}`); + } + if (wrapInList) { + gen( + `setPath(coerced, ${responsePath}, [getPath(coerced, ${responsePath})]);` + ); + } + gen(`}`); + return gen.toString(); +} + +function compileInputListType( + context: CompilationContext, + varType: GraphQLList, + varName: string, + useInputPath: boolean, + useResponsePath: boolean +) { + const inputPath = useInputPath + ? `inputPath` + : pathToExpression(context.inputPath); + const responsePath = useResponsePath + ? "responsePath" + : pathToExpression(context.responsePath); + const currentInput = useInputPath + ? `getPath(input, inputPath)` + : getObjectPath(context.inputPath); + const errorLocation = printErrorLocation( + computeLocations([context.varDefNode]) + ); + + const gen = genFn(); + + context.errorMessage = `'Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + '; '`; + const hasValueName = hasValue(context.inputPath); + const index = `idx${context.depth}`; + + const subContext = createSubCompilationContext(context); + subContext.responsePath = addPath(subContext.responsePath, index, "variable"); + subContext.inputPath = addPath(subContext.inputPath, index, "variable"); + subContext.depth++; + + gen(` + if (Array.isArray(${currentInput})) { + setPath(coerced, ${responsePath}, []); + const previousInputPath = ${inputPath}; + const previousResponsePath = ${responsePath}; + for (let ${index} = 0; ${index} < ${currentInput}.length; ++${index}) { + const inputPath = previousInputPath.concat(${index}); + const responsePath = previousResponsePath.concat(${index}); + + const __inputListValue = getPath(input, inputPath); + + ${generateCircularReferenceCheck( + context, + "__inputListValue", + "inputPath", + errorLocation, + false + )} + + const ${hasValueName} = __inputListValue !== undefined; + + ${generateInput({ + context: subContext, + varType: varType.ofType, + varName, + hasValueName, + wrapInList: false, + useInputPath, + useResponsePath + })} + } + } else { + ${generateInput({ + context, + varType: varType.ofType, + varName, + hasValueName, + wrapInList: true, + useInputPath, + useResponsePath + })} + } + `); + + return gen.toString(); +} + +function compileInputObjectType( + context: CompilationContext, + varType: GraphQLInputObjectType, + varName: string, + useInputPath: boolean, + useResponsePath: boolean +) { + const responsePath = useResponsePath + ? "responsePath" + : pathToExpression(context.responsePath); + const currentInput = useInputPath + ? `getPath(input, inputPath)` + : getObjectPath(context.inputPath); + const errorLocation = printErrorLocation( + computeLocations([context.varDefNode]) + ); + + const gen = genFn(); + gen(` + if (typeof ${currentInput} !== 'object') { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name} to be an object.', ${errorLocation})); + } else { + setPath(coerced, ${responsePath}, {}); + `); + + const fields = varType.getFields(); + const allowedFields = []; + for (const field of Object.values(fields)) { const subContext = createSubCompilationContext(context); - subContext.responsePath = addPath( - subContext.responsePath, - index, - "variable" - ); - subContext.inputPath = addPath(subContext.inputPath, index, "variable"); - subContext.depth++; + allowedFields.push(field.name); + const hasValueName = hasValue(addPath(subContext.inputPath, field.name)); + gen(` - if (Array.isArray(${currentInput})) { - ${currentOutput} = []; - for (let ${index} = 0; ${index} < ${currentInput}.length; ++${index}) { - const ${hasValueName} = - ${getObjectPath(subContext.inputPath)} !== undefined; - ${generateInput( - subContext, - varType.ofType, - varName, - hasValueName, - false - )} - } - } else { - ${generateInput(context, varType.ofType, varName, hasValueName, true)} - } + const ${hasValueName} = Object.prototype.hasOwnProperty.call( + ${currentInput}, "${field.name}" + ); `); - } else if (isInputType(varType)) { + + subContext.inputPath = addPath(subContext.inputPath, field.name); + subContext.responsePath = addPath(subContext.responsePath, field.name); + subContext.errorMessage = `'Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + '; '`; + + const varTypeParserName = "__fieldParser" + varType.name + field.name; + + const nextInputPath = useInputPath + ? `inputPath.concat("${field.name}")` + : pathToExpression(subContext.inputPath); + + const nextResponsePath = useResponsePath + ? `responsePath.concat("${field.name}")` + : pathToExpression(subContext.responsePath); + gen(` - if (typeof ${currentInput} !== 'object') { - errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + - inspect(${currentInput}) + "; " + - 'Expected type ${varType.name} to be an object.', ${errorLocation})); - } else { - ${currentOutput} = {}; + ${varTypeParserName}( + input, + ${nextInputPath}, + coerced, + ${nextResponsePath}, + errors, + ${hasValueName} + ); `); - const fields = varType.getFields(); - const allowedFields = []; - for (const field of Object.values(fields)) { - const subContext = createSubCompilationContext(context); - allowedFields.push(field.name); - const hasValueName = hasValue(addPath(subContext.inputPath, field.name)); - gen(` - const ${hasValueName} = Object.prototype.hasOwnProperty.call( - ${getObjectPath(subContext.inputPath)}, "${field.name}" - ); - `); - subContext.inputPath = addPath(subContext.inputPath, field.name); - subContext.responsePath = addPath(subContext.responsePath, field.name); - subContext.errorMessage = `'Variable "$${varName}" got invalid value ' + inspect(${currentInput}) + '; '`; - gen(` - ${generateInput( - subContext, - field.type, - field.name, - hasValueName, - false - )} - `); + + if (!context.hoistedFunctions.has(varTypeParserName)) { + context.hoistedFunctions.set(varTypeParserName, ""); + context.hoistedFunctions.set( + varTypeParserName, + ` + function ${varTypeParserName} ( + input, + inputPath, + coerced, + responsePath, + errors, + ${hasValueName} + ) { + const __inputValue = getPath(input, inputPath); + + ${generateCircularReferenceCheck( + context, + "__inputValue", + "inputPath", + errorLocation, + true + )} + + ${generateInput({ + context: subContext, + varType: field.type, + varName: field.name, + hasValueName, + wrapInList: false, + useInputPath: true, + useResponsePath: true + })} + } + ` + ); } + } - gen(` - const allowedFields = ${JSON.stringify(allowedFields)}; - for (const fieldName of Object.keys(${currentInput})) { - if (!allowedFields.includes(fieldName)) { - errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + - inspect(${currentInput}) + "; " + - 'Field "' + fieldName + '" is not defined by type ${ - varType.name - }.', ${errorLocation})); - break; - } + gen(` + const allowedFields = ${JSON.stringify(allowedFields)}; + for (const fieldName of Object.keys(${currentInput})) { + if (!allowedFields.includes(fieldName)) { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Field "' + fieldName + '" is not defined by type ${ + varType.name + }.', ${errorLocation})); + break; } - }`); - } else { - /* istanbul ignore next line */ - throw new Error(`unknown type: ${varType}`); + } + `); + + gen(`}`); + + return gen.toString(); +} + +function generateCircularReferenceCheck( + context: CompilationContext, + inputValueName: string, + inputPathName: string, + errorLocation: string, + shouldReturn: boolean +) { + /** + * If the variablesCircularReferenceCheck is `false`, do not generate + * code to verify circular references. + */ + if (!context.options.variablesCircularReferenceCheck) { + return ""; } - if (wrapInList) { - gen(`${currentOutput} = [${currentOutput}];`); + + const gen = genFn(); + gen(`if (visitedInputValues.has(${inputValueName})) {`); + gen(` + errors.push( + new GraphQLJITError( + "Circular reference detected in input variable '$" + + ${inputPathName}[0] + + "' at " + + ${inputPathName}.slice(1).join("."), + [${errorLocation}] + ) + ); + `); + + if (shouldReturn) { + gen(`return;`); } + gen(`}`); + gen(` + if (isObjectLike(${inputValueName})) { + visitedInputValues.add(${inputValueName}); + } + `); + return gen.toString(); } +function pathToExpression(path: ObjectPath) { + const expr = flattenPath(path) + // object access pattern - flatten returns in reverse order from leaf to root + .reverse() + // remove the variable name (input/coerced - are the cases in this file) + .slice(1) + // serialize + .map(({ key, type }) => (type === "literal" ? `"${key}"` : key)) + .join(","); + + return `[${expr}]`; +} + function hasValue(path: ObjectPath) { const flattened = []; let curr: ObjectPath | undefined = path;