diff --git a/package.json b/package.json index 0d89977..ed7f402 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@types/lodash.memoize": "^4.1.7", "@types/lodash.merge": "^4.6.7", "@types/lodash.mergewith": "^4.6.7", + "@types/minimist": "^1.2.2", "@types/node": "^20.6.0", "@typescript-eslint/eslint-plugin": "6.7.0", "@typescript-eslint/parser": "6.7.0", @@ -84,6 +85,7 @@ "graphql": "^16.8.0", "jest": "^29.7.0", "lint-staged": "^14.0.1", + "minimist": "^1.2.8", "prettier": "^3.0.3", "typescript": "^5.2.2" }, diff --git a/src/__benchmarks__/benchmarks.ts b/src/__benchmarks__/benchmarks.ts index 3e977dd..6a308b9 100755 --- a/src/__benchmarks__/benchmarks.ts +++ b/src/__benchmarks__/benchmarks.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node -r @swc-node/register import Benchmark from "benchmark"; +import minimist from "minimist"; import { DocumentNode, execute, @@ -8,7 +9,12 @@ import { GraphQLSchema, parse } from "graphql"; -import { compileQuery, isCompiledQuery, isPromise } from "../execution"; +import { + compileQuery, + CompilerOptions, + isCompiledQuery, + isPromise +} from "../execution"; import { query as fewResolversQuery, schema as fewResolversSchema @@ -21,11 +27,17 @@ import { query as nestedArrayQuery, schema as nestedArraySchema } from "./schema-nested-array"; +import { + query as variablesShallowQuery, + schema as variablesShallowSchema, + variables as variablesShallowVariables +} from "./variables-parsing-shallow"; interface BenchmarkMaterial { query: DocumentNode; schema: GraphQLSchema; variables?: any; + options?: Partial; } const benchmarks: { [key: string]: BenchmarkMaterial } = { @@ -47,16 +59,72 @@ const benchmarks: { [key: string]: BenchmarkMaterial } = { schema: nestedArraySchema(), query: nestedArrayQuery, variables: { id: "2", width: 300, height: 500 } + }, + variablesWithNewJit: { + schema: variablesShallowSchema(), + query: variablesShallowQuery, + variables: variablesShallowVariables, + options: { + variableParser: "jit-new" + } + }, + variablesWithOldJit: { + schema: variablesShallowSchema(false), + query: variablesShallowQuery, + variables: variablesShallowVariables, + options: { + variableParser: "jit-old" + } + }, + variablesWithGraphQLJS: { + schema: variablesShallowSchema(), + query: variablesShallowQuery, + variables: variablesShallowVariables, + options: { + variableParser: "graphql-js" + } } }; -async function runBenchmarks() { - const skipJS = process.argv[2] === "skip-js"; - const skipJSON = process.argv[2] === "skip-json"; +async function runBenchmarks(argv: string[]) { + const args = minimist(argv); + const help = args["help"]; + + const availableBenchmarks = Object.entries(benchmarks); + + if (help) { + console.log( + ` +Usage: yarn benchmark [options] + +Options: + --skip-js Skip graphql-js benchmarks + --skip-json Skip JSON.stringify benchmarks + --help Show this help + --bench Run only the specified benchmarks (comma separated) + +Available benchmarks: +${availableBenchmarks.map(([bench]) => ` - ${bench}`).join("\n")} +`.trim() + ); + return; + } + + const skipJS = args["skip-js"]; + const skipJSON = args["skip-json"]; + const benchsToRunArg = args["bench"]; + const benchmarksToRun = + benchsToRunArg && benchsToRunArg.split(",").filter((b: string) => b); + + const filteredBenchmarks = benchmarksToRun + ? availableBenchmarks.filter(([bench]) => benchmarksToRun.includes(bench)) + : availableBenchmarks; + const benchs = await Promise.all( - Object.entries(benchmarks).map( - async ([bench, { query, schema, variables }]) => { + filteredBenchmarks.map( + async ([bench, { query, schema, variables, options }]) => { const compiledQuery = compileQuery(schema, query, undefined, { + ...options, debug: true } as any); if (!isCompiledQuery(compiledQuery)) { @@ -71,6 +139,11 @@ async function runBenchmarks() { .__DO_NOT_USE_THIS_OR_YOU_WILL_BE_FIRED_compilation.length }` ); + console.log( + `size of function for variableCompilation ${bench}: ${( + compiledQuery as any + ).__DO_NOT_USE_THIS_OR_YOU_WILL_BE_FIRED_variableCompilation?.length}` + ); const graphqlJsResult = await execute({ schema, document: query, @@ -168,7 +241,7 @@ async function runBenchmarks() { } // eslint-disable-next-line -runBenchmarks().catch(console.error); +runBenchmarks(process.argv.slice(2)).catch(console.error); function isNotNull(a: T | null | undefined): a is T { return a != null; diff --git a/src/__benchmarks__/variables-parsing-shallow.ts b/src/__benchmarks__/variables-parsing-shallow.ts new file mode 100644 index 0000000..751484a --- /dev/null +++ b/src/__benchmarks__/variables-parsing-shallow.ts @@ -0,0 +1,172 @@ +import { makeExecutableSchema } from "@graphql-tools/schema"; +import { parse } from "graphql"; + +const typeDefs = ` +type Query { + products(filter: Filter): [Product] +} +input Filter { + and: AndFilter + or: OrFilter + like: String +} +input AndFilter { + left: Filter + right: Filter +} +input OrFilter { + left: Filter + right: Filter +} +type Product { + id: ID! + name: String! +} +`; + +const typeDefsOld = ` +type Query { + products(filter: Filter): [Product] +} +input Filter { + and: AndFilter + or: OrFilter + like: String +} +input AndFilter { + left: L2Filter + right: L2Filter +} +input OrFilter { + left: L2Filter + right: L2Filter +} +input L2Filter { + and: L2AndFilter + or: L2OrFilter + like: String +} +input L2AndFilter { + left: L3Filter + right: L3Filter +} +input L2OrFilter { + left: L3Filter + right: L3Filter +} +input L3Filter { + like: String +} +type Product { + id: ID! + name: String! +} +`; + +export function schema(withRecursion = true) { + const schema = makeExecutableSchema({ + typeDefs: withRecursion ? typeDefs : typeDefsOld, + resolvers: { + Query: { + async products(_, { filter }) { + return products.filter((product) => + productSatisfiesFilter(product, filter) + ); + } + } + } + }); + + return schema; +} + +export const query = parse(` +query ($filter1: Filter) { + products(filter: $filter1) { + id + name + } +} +`); + +export const variables = { + filter1: { + and: { + left: { + like: "Chrome" + }, + right: { + or: { + left: { + like: "FreeBSD" + }, + right: { + like: "Samsung" + } + } + } + } + } +}; + +function productSatisfiesFilter( + product: (typeof products)[0], + filter: any +): boolean { + if (filter.and) { + return ( + productSatisfiesFilter(product, filter.and.left) && + productSatisfiesFilter(product, filter.and.right) + ); + } else if (filter.or) { + return ( + productSatisfiesFilter(product, filter.or.left) || + productSatisfiesFilter(product, filter.or.right) + ); + } else { + return product.name.includes(filter.like); + } +} + +const products = [ + { + id: "1", + name: "Mozilla - Android - SM-G960F - AppleWebKit - Chrome - Mobile Safari - Linux - Samsung Galaxy S9" + }, + { + id: "2", + name: "Mozilla - Linux - Ubuntu - Gecko - Firefox - - Linux - Desktop" + }, + { + id: "3", + name: "Mozilla - Mac - Intel Mac OS X 10_15_7 - AppleWebKit - Safari - - Mac - Desktop" + }, + { + id: "4", + name: "Mozilla - Windows - Windows NT 10.0 - AppleWebKit - Chrome - Safari - Windows - Desktop" + }, + { + id: "5", + name: "Mozilla - Linux - - AppleWebKit - Chrome - Safari - Linux - Desktop" + }, + { + id: "6", + name: "Mozilla - Chrome OS - CrOS x86_64 13904.93.0 - AppleWebKit - Chrome - Safari - Chrome OS - Desktop" + }, + { + id: "7", + name: "Mozilla - FreeBSD - FreeBSD amd64 - Gecko - Firefox - - FreeBSD - Desktop" + }, + { + id: "8", + name: "Mozilla - Android - SM-G960F - AppleWebKit - Chrome - Mobile Safari - Linux - Samsung Galaxy S9" + }, + { + id: "9", + name: "Mozilla - iOS - iPhone - AppleWebKit - Safari - - iOS - iPhone" + }, + { + id: "10", + name: "Mozilla - Linux - Ubuntu - Gecko - Firefox - - Linux - Desktop" + } +]; diff --git a/src/__tests__/recursive-input.test.ts b/src/__tests__/recursive-input.test.ts new file mode 100644 index 0000000..66eddb7 --- /dev/null +++ b/src/__tests__/recursive-input.test.ts @@ -0,0 +1,437 @@ +import { DocumentNode, GraphQLSchema, parse, validate } from "graphql"; +import { makeExecutableSchema } from "@graphql-tools/schema"; +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.only("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: {} } + }) + ); + }); + }); + + 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 + ); + }); + }); + + 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); + }); + }); + + 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); + }); + }); +}); + +function executeQuery( + schema: GraphQLSchema, + document: DocumentNode, + variableValues?: any +) { + const prepared: any = compileQuery(schema, document as any, undefined, { + variableParser: "jit-new" + }); + if (!isCompiledQuery(prepared)) { + return prepared; + } + return prepared.query({}, {}, variableValues || {}); +} diff --git a/src/__tests__/subscription.test.ts b/src/__tests__/subscription.test.ts index 765421f..a7a4c3c 100644 --- a/src/__tests__/subscription.test.ts +++ b/src/__tests__/subscription.test.ts @@ -488,7 +488,7 @@ describe("Subscription Initialization Phase", () => { { message: // DIFF: 'Variable "$arg" got invalid value "meow"; Int cannot represent non-integer value: "meow"', - 'Variable "$arg" got invalid value "meow"; Expected type Int; Int cannot represent non-integer value: "meow"', + 'Variable "$arg" got invalid value "meow"; Int cannot represent non-integer value: "meow"', locations: [{ line: 2, column: 21 }] } ] diff --git a/src/__tests__/variables.test.ts b/src/__tests__/variables.test.ts index eb94653..e335556 100644 --- a/src/__tests__/variables.test.ts +++ b/src/__tests__/variables.test.ts @@ -541,13 +541,13 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$a" got invalid value "SerializedValue"; Expected type ComplexThrowingScalar.', + 'Variable "$input" got invalid value "SerializedValue" at "input.a"; Expected type "ComplexThrowingScalar". complex-scalar-error', locations: [{ line: 2, column: 16 }] } ] }); expect(result.errors?.[0].originalError?.message).toBe( - "complex-scalar-error" + 'Expected type "ComplexThrowingScalar". complex-scalar-error' ); }); @@ -559,8 +559,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo", b: "bar", c: null }; ' + - "Expected non-nullable type String! not to be null at value.c.", + 'Variable "$input" got invalid value null at "input.c"; Expected non-nullable type "String!" not to be null.', locations: [{ line: 2, column: 16 }] } ] @@ -574,8 +573,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$input" got invalid value "foo bar"; ' + - "Expected type TestInputObject to be an object.", + 'Variable "$input" got invalid value "foo bar"; Expected type "TestInputObject" to be an object.', locations: [{ line: 2, column: 16 }] } ] @@ -591,8 +589,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo", b: "bar" }; ' + - "Field value.c of required type String! was not provided.", + 'Variable "$input" got invalid value { a: "foo", b: "bar" }; Field "c" of required type "String!" was not provided.', locations: [{ line: 2, column: 16 }] } ] @@ -609,8 +606,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$input" got invalid value { a: "foo", b: "bar", c: "baz", extra: "dog" }; ' + - 'Field "extra" is not defined by type TestInputObject.', + 'Variable "$input" got invalid value { a: "foo", b: "bar", c: "baz", extra: "dog" }; Field "extra" is not defined by type "TestInputObject".', locations: [{ line: 2, column: 16 }] } ] @@ -802,7 +798,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$input" got invalid value "foo bar"; Expected type TestEnum.', + 'Variable "$input" got invalid value "foo bar"; Value "foo bar" does not exist in "TestEnum" enum.', locations: [{ line: 2, column: 16 }] } ] @@ -998,8 +994,7 @@ describe("Execute: Handles inputs", () => { } ], message: - 'Variable "$int" got invalid value 9007199254740992; Expected type Int; ' + - "Int cannot represent non 32-bit signed integer value: 9007199254740992" + 'Variable "$int" got invalid value 9007199254740992; Int cannot represent non 32-bit signed integer value: 9007199254740992' } ] }); @@ -1024,8 +1019,7 @@ describe("Execute: Handles inputs", () => { } ], message: - 'Variable "$string" got invalid value ["a"]; Expected type String; ' + - 'String cannot represent a non string value: ["a"]' + 'Variable "$string" got invalid value ["a"]; String cannot represent a non string value: ["a"]' }, { locations: [ @@ -1035,8 +1029,7 @@ describe("Execute: Handles inputs", () => { } ], message: - 'Variable "$id" got invalid value ["id"]; Expected type ID; ' + - 'ID cannot represent value: ["id"]' + 'Variable "$id" got invalid value ["id"]; ID cannot represent value: ["id"]' }, { locations: [ @@ -1046,8 +1039,7 @@ describe("Execute: Handles inputs", () => { } ], message: - 'Variable "$int" got invalid value 1.5; Expected type Int; ' + - "Int cannot represent non-integer value: 1.5" + 'Variable "$int" got invalid value 1.5; Int cannot represent non-integer value: 1.5' }, { locations: [ @@ -1057,8 +1049,7 @@ describe("Execute: Handles inputs", () => { } ], message: - 'Variable "$float" got invalid value NaN; Expected type Float; ' + - "Float cannot represent non numeric value: NaN" + 'Variable "$float" got invalid value NaN; Float cannot represent non numeric value: NaN' }, { locations: [ @@ -1068,8 +1059,7 @@ describe("Execute: Handles inputs", () => { } ], message: - 'Variable "$boolean" got invalid value "hello"; Expected type Boolean; ' + - 'Boolean cannot represent a non boolean value: "hello"' + 'Variable "$boolean" got invalid value "hello"; Boolean cannot represent a non boolean value: "hello"' } ] }); @@ -1283,8 +1273,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$value" got invalid value [1, 2, 3]; ' + - "Expected type String; String cannot represent a non string value: [1, 2, 3]", + 'Variable "$value" got invalid value [1, 2, 3]; String cannot represent a non string value: [1, 2, 3]', locations: [{ line: 2, column: 16 }] } ] @@ -1492,8 +1481,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$input" got invalid value ["A", null, "B"]; ' + - "Expected non-nullable type String! not to be null at value[1].", + 'Variable "$input" got invalid value null at "input[1]"; Expected non-nullable type "String!" not to be null.', locations: [{ line: 2, column: 16 }] } ] @@ -1542,8 +1530,7 @@ describe("Execute: Handles inputs", () => { errors: [ { message: - 'Variable "$input" got invalid value ["A", null, "B"]; ' + - "Expected non-nullable type String! not to be null at value[1].", + 'Variable "$input" got invalid value null at "input[1]"; Expected non-nullable type "String!" not to be null.', locations: [{ line: 2, column: 16 }] } ] diff --git a/src/execution.ts b/src/execution.ts index 73b526a..6f86a9c 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -60,10 +60,12 @@ import { import { Maybe } from "./types"; import { CoercedVariableValues, - compileVariableParsing, - failToParseVariables + getVariablesParser, + failToParseVariables, + compileVariableParsing } from "./variables"; import { getGraphQLErrorOptions, getOperationRootType } from "./compat"; +import { compileVariableParsing as oldCompileVariableParsing } from "./old-variables"; const inspect = createInspect(); @@ -96,6 +98,10 @@ export interface CompilerOptions { * */ useExperimentalPathBasedSkipInclude: boolean; + + // Choose between compiled variables parser in JIT + // vs GraphQL-JS variables parser + variableParser: "graphql-js" | "jit-new" | "jit-old"; } interface ExecutionContext { @@ -159,6 +165,7 @@ export interface CompilationContext extends GraphQLContext { deferred: DeferredField[]; options: CompilerOptions; depth: number; + variableCompliationFnLength?: number; } // prefix for the variable used ot cache validation results @@ -200,6 +207,7 @@ export interface CompiledQuery< interface InternalCompiledQuery extends CompiledQuery { __DO_NOT_USE_THIS_OR_YOU_WILL_BE_FIRED_compilation?: string; + __DO_NOT_USE_THIS_OR_YOU_WILL_BE_FIRED_variableCompilation?: string; } /** @@ -240,6 +248,7 @@ export function compileQuery< disableLeafSerialization: false, customSerializers: {}, useExperimentalPathBasedSkipInclude: false, + variableParser: "graphql-js" as const, ...partialOptions }; @@ -259,10 +268,29 @@ export function compileQuery< } else { stringify = JSON.stringify; } - const getVariables = compileVariableParsing( - schema, - context.operation.variableDefinitions || [] - ); + + let getVariables: (inputs: { [key: string]: any }) => CoercedVariableValues; + + switch (options.variableParser) { + case "graphql-js": + getVariables = getVariablesParser( + schema, + context.operation.variableDefinitions || [] + ); + break; + case "jit-new": + getVariables = compileVariableParsing( + schema, + context.operation.variableDefinitions || [] + ); + break; + case "jit-old": + getVariables = oldCompileVariableParsing( + schema, + context.operation.variableDefinitions || [] + ); + break; + } const type = getOperationRootType(context.schema, context.operation); const fieldMap = collectFields( @@ -281,7 +309,7 @@ export function compileQuery< document, // eslint-disable-next-line no-new-func new Function("return " + functionBody)(), - getVariables, + getVariables!, context.operation.name != null ? context.operation.name.value : undefined @@ -299,7 +327,7 @@ export function compileQuery< fieldMap, compiledQuery.query ), - getVariables, + getVariables!, context.operation.name != null ? context.operation.name.value : undefined @@ -311,6 +339,8 @@ export function compileQuery< // and visualization tools like try-jit. compiledQuery.__DO_NOT_USE_THIS_OR_YOU_WILL_BE_FIRED_compilation = functionBody; + compiledQuery.__DO_NOT_USE_THIS_OR_YOU_WILL_BE_FIRED_variableCompilation = + (getVariables! as any).rawFunctionBody; } return compiledQuery as CompiledQuery; } catch (err: any) { diff --git a/src/old-variables.ts b/src/old-variables.ts new file mode 100644 index 0000000..7daf5b8 --- /dev/null +++ b/src/old-variables.ts @@ -0,0 +1,467 @@ +import genFn from "generate-function"; +import { + GraphQLBoolean, + GraphQLError, + GraphQLFloat, + GraphQLID, + GraphQLInputType, + GraphQLInt, + GraphQLSchema, + GraphQLString, + isEnumType, + isInputType, + isListType, + isNonNullType, + isScalarType, + print, + SourceLocation, + typeFromAST, + valueFromAST, + VariableDefinitionNode +} from "graphql"; +import { addPath, computeLocations, ObjectPath } from "./ast"; +import { GraphQLError as GraphQLJITError } from "./error"; +import createInspect from "./inspect"; + +const inspect = createInspect(); + +interface FailedVariableCoercion { + errors: ReadonlyArray; +} + +interface VariableValues { + coerced: { [key: string]: any }; +} + +export type CoercedVariableValues = FailedVariableCoercion | VariableValues; + +export function failToParseVariables(x: any): x is FailedVariableCoercion { + return x.errors; +} + +interface CompilationContext { + inputPath: ObjectPath; + responsePath: ObjectPath; + depth: number; + varDefNode: VariableDefinitionNode; + dependencies: Map any>; + errorMessage?: string; +} + +function createSubCompilationContext( + context: CompilationContext +): CompilationContext { + return { ...context }; +} +export function compileVariableParsing( + schema: GraphQLSchema, + varDefNodes: ReadonlyArray +): (inputs: { [key: string]: any }) => CoercedVariableValues { + const errors = []; + const coercedValues: { [key: string]: any } = Object.create(null); + + let mainBody = ""; + const dependencies = new Map(); + for (const varDefNode of varDefNodes) { + const context: CompilationContext = { + varDefNode, + depth: 0, + inputPath: addPath(undefined, "input"), + responsePath: addPath(undefined, "coerced"), + dependencies + }; + const varName = varDefNode.variable.name.value; + const varType = typeFromAST(schema, varDefNode.type as any); + if (!varType || !isInputType(varType)) { + // Must use input types for variables. This should be caught during + // validation, however is checked again here for safety. + errors.push( + new (GraphQLJITError as any)( + `Variable "$${varName}" expected value of type ` + + `"${ + varType || print(varDefNode.type) + }" which cannot be used as an input type.`, + computeLocations([varDefNode.type]) + ) + ); + continue; + } + + // Ensure a constant shape of the input map + coercedValues[varName] = undefined; + const hasValueName = hasValue(addPath(context.inputPath, varName)); + mainBody += `const ${hasValueName} = Object.prototype.hasOwnProperty.call(${getObjectPath( + context.inputPath + )}, "${varName}");\n`; + context.inputPath = addPath(context.inputPath, varName); + context.responsePath = addPath(context.responsePath, varName); + mainBody += generateInput( + context, + varType, + varName, + hasValueName, + valueFromAST(varDefNode.defaultValue, varType), + false + ); + } + + if (errors.length > 0) { + throw errors; + } + + const gen = genFn(); + gen(` + return function getVariables(input) { + const errors = []; + const coerced = ${JSON.stringify(coercedValues)} + ${mainBody} + if (errors.length > 0) { + return {errors, coerced: undefined}; + } + return {errors: undefined, coerced}; + } + `); + + const fnBody = gen.toString(); + + // eslint-disable-next-line + const ret = Function.apply( + null, + ["GraphQLJITError", "inspect"] + .concat(Array.from(dependencies.keys())) + .concat(fnBody) + ).apply( + null, + [GraphQLJITError, inspect].concat(Array.from(dependencies.values())) + ); + + Object.defineProperty(ret, "rawFunctionBody", { + value: fnBody, + enumerable: false + }); + + return ret; +} + +// Int Scalars represent 32 bits +// https://graphql.github.io/graphql-spec/June2018/#sec-Int +const MAX_32BIT_INT = 2147483647; +const MIN_32BIT_INT = -2147483648; + +function generateInput( + context: CompilationContext, + varType: GraphQLInputType, + varName: string, + hasValueName: string, + defaultValue: unknown | undefined, + wrapInList: boolean +) { + const currentOutput = getObjectPath(context.responsePath); + const currentInput = getObjectPath(context.inputPath); + const errorLocation = printErrorLocation( + computeLocations([context.varDefNode]) + ); + + const gen = genFn(); + gen(`if (${currentInput} == null) {`); + + if (isNonNullType(varType)) { + let nonNullMessage; + let omittedMessage; + if (context.errorMessage) { + const objectPath = printObjectPath(context.responsePath); + nonNullMessage = `${context.errorMessage} + \`Expected non-nullable type ${varType} not to be null at ${objectPath}.\``; + omittedMessage = `${context.errorMessage} + \`Field ${objectPath} of required type ${varType} was not provided.\``; + } else { + nonNullMessage = `'Variable "$${varName}" of non-null type "${varType}" must not be null.'`; + omittedMessage = `'Variable "$${varName}" of required type "${varType}" was not provided.'`; + } + varType = varType.ofType; + gen(` + if (${currentOutput} == null) { + errors.push(new GraphQLJITError(${hasValueName} ? ${nonNullMessage} : ${omittedMessage}, ${errorLocation})); + } + `); + } else { + gen(` + if (${hasValueName}) { ${currentOutput} = null; } + `); + if (defaultValue !== undefined) { + gen(`else { ${currentOutput} = ${JSON.stringify(defaultValue)} }`); + } + } + gen(`} else {`); + if (isScalarType(varType)) { + switch (varType.name) { + case GraphQLID.name: + gen(` + if (typeof ${currentInput} === "string") { + ${currentOutput} = ${currentInput}; + } else if (Number.isInteger(${currentInput})) { + ${currentOutput} = ${currentInput}.toString(); + } else { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}; ' + + '${varType.name} cannot represent value: ' + + inspect(${currentInput}), ${errorLocation}) + ); + } + `); + break; + case GraphQLString.name: + gen(` + if (typeof ${currentInput} === "string") { + ${currentOutput} = ${currentInput}; + } else { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}; ' + + '${varType.name} cannot represent a non string value: ' + + inspect(${currentInput}), ${errorLocation}) + ); + } + `); + break; + case GraphQLBoolean.name: + gen(` + if (typeof ${currentInput} === "boolean") { + ${currentOutput} = ${currentInput}; + } else { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}; ' + + '${varType.name} cannot represent a non boolean value: ' + + inspect(${currentInput}), ${errorLocation})); + } + `); + break; + case GraphQLInt.name: + gen(` + if (Number.isInteger(${currentInput})) { + if (${currentInput} > ${MAX_32BIT_INT} || ${currentInput} < ${MIN_32BIT_INT}) { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}; ' + + '${varType.name} cannot represent non 32-bit signed integer value: ' + + inspect(${currentInput}), ${errorLocation})); + } else { + ${currentOutput} = ${currentInput}; + } + } else { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}; ' + + '${varType.name} cannot represent non-integer value: ' + + inspect(${currentInput}), ${errorLocation}) + ); + } + `); + break; + case GraphQLFloat.name: + gen(` + if (Number.isFinite(${currentInput})) { + ${currentOutput} = ${currentInput}; + } else { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}; ' + + '${varType.name} cannot represent non numeric value: ' + + inspect(${currentInput}), ${errorLocation}) + ); + } + `); + break; + default: + context.dependencies.set( + `${varType.name}parseValue`, + varType.parseValue.bind(varType) + ); + gen(` + try { + const parseResult = ${varType.name}parseValue(${currentInput}); + if (parseResult === undefined || parseResult !== parseResult) { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}.', ${errorLocation})); + } + ${currentOutput} = parseResult; + } catch (error) { + errors.push(new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}.', ${errorLocation}, undefined, error) + ); + } + `); + } + } else if (isEnumType(varType)) { + context.dependencies.set( + `${varType.name}getValue`, + varType.getValue.bind(varType) + ); + gen(` + if (typeof ${currentInput} === "string") { + const enumValue = ${varType.name}getValue(${currentInput}); + if (enumValue) { + ${currentOutput} = enumValue.value; + } else { + errors.push( + new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}.', ${errorLocation}) + ); + } + } else { + errors.push( + new GraphQLJITError('Variable "$${varName}" got invalid value ' + + inspect(${currentInput}) + "; " + + 'Expected type ${varType.name}.', ${errorLocation}) + ); + } + `); + } else if (isListType(varType)) { + 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})) { + ${currentOutput} = []; + for (let ${index} = 0; ${index} < ${currentInput}.length; ++${index}) { + const ${hasValueName} = + ${getObjectPath(subContext.inputPath)} !== undefined; + ${generateInput( + subContext, + varType.ofType, + varName, + hasValueName, + undefined, + false + )} + } + } else { + ${generateInput( + context, + varType.ofType, + varName, + hasValueName, + undefined, + true + )} + } + `); + } else if (isInputType(varType)) { + 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} = {}; + `); + 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, + field.defaultValue, + false + )} + `); + } + + 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}`); + } + if (wrapInList) { + gen(`${currentOutput} = [${currentOutput}];`); + } + gen(`}`); + return gen.toString(); +} + +function hasValue(path: ObjectPath) { + const flattened = []; + let curr: ObjectPath | undefined = path; + while (curr) { + flattened.push(curr.key); + curr = curr.prev; + } + return `hasValue${flattened.join("_")}`; +} + +function printErrorLocation(location: SourceLocation[]) { + return JSON.stringify(location); +} + +function getObjectPath(path: ObjectPath): string { + const flattened = []; + let curr: ObjectPath | undefined = path; + while (curr) { + flattened.unshift({ key: curr.key, type: curr.type }); + curr = curr.prev; + } + let name = flattened[0].key; + for (let i = 1; i < flattened.length; ++i) { + name += + flattened[i].type === "literal" + ? `["${flattened[i].key}"]` + : `[${flattened[i].key}]`; + } + return name; +} + +function printObjectPath(path: ObjectPath) { + const flattened = []; + let curr: ObjectPath | undefined = path; + while (curr) { + flattened.unshift({ key: curr.key, type: curr.type }); + curr = curr.prev; + } + const initialIndex = Math.min(flattened.length - 1, 1); + let name = "value"; + for (let i = initialIndex + 1; i < flattened.length; ++i) { + name += + flattened[i].type === "literal" + ? `.${flattened[i].key}` + : `[$\{${flattened[i].key}}]`; + } + return name; +} diff --git a/src/variables.ts b/src/variables.ts index 582380b..2e6d9ce 100644 --- a/src/variables.ts +++ b/src/variables.ts @@ -1,14 +1,18 @@ import genFn from "generate-function"; import { + getVariableValues, GraphQLBoolean, GraphQLError, GraphQLFloat, GraphQLID, + GraphQLInputObjectType, GraphQLInputType, GraphQLInt, + GraphQLList, GraphQLSchema, GraphQLString, isEnumType, + isInputObjectType, isInputType, isListType, isNonNullType, @@ -19,12 +23,14 @@ 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"; const inspect = createInspect(); +export type CoercedVariableValues = FailedVariableCoercion | VariableValues; + interface FailedVariableCoercion { errors: ReadonlyArray; } @@ -33,12 +39,17 @@ interface VariableValues { coerced: { [key: string]: any }; } -export type CoercedVariableValues = FailedVariableCoercion | VariableValues; - export function failToParseVariables(x: any): x is FailedVariableCoercion { return x.errors; } +export function getVariablesParser( + schema: GraphQLSchema, + varDefNodes: ReadonlyArray +): (inputs: { [key: string]: any }) => CoercedVariableValues { + return (inputs) => getVariableValues(schema, varDefNodes, inputs); +} + interface CompilationContext { inputPath: ObjectPath; responsePath: ObjectPath; @@ -46,6 +57,7 @@ interface CompilationContext { varDefNode: VariableDefinitionNode; dependencies: Map any>; errorMessage?: string; + hoistedFunctions: Map; } function createSubCompilationContext( @@ -53,6 +65,7 @@ function createSubCompilationContext( ): CompilationContext { return { ...context }; } + export function compileVariableParsing( schema: GraphQLSchema, varDefNodes: ReadonlyArray @@ -62,13 +75,15 @@ export function compileVariableParsing( let mainBody = ""; const dependencies = new Map(); + const hoistedFunctions = new Map(); for (const varDefNode of varDefNodes) { const context: CompilationContext = { 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); @@ -79,30 +94,34 @@ export function compileVariableParsing( new (GraphQLJITError as any)( `Variable "$${varName}" expected value of type ` + `"${ - varType || print(varDefNode.type) + varType ? varType : print(varDefNode.type) }" which cannot be used as an input type.`, computeLocations([varDefNode.type]) ) ); continue; } + if (varDefNode.defaultValue) { + // If no value was provided to a variable with a default value, + // use the default value. + coercedValues[varName] = valueFromAST(varDefNode.defaultValue, varType); + } - // Ensure a constant shape of the input map - coercedValues[varName] = undefined; const hasValueName = hasValue(addPath(context.inputPath, varName)); mainBody += `const ${hasValueName} = Object.prototype.hasOwnProperty.call(${getObjectPath( context.inputPath )}, "${varName}");\n`; context.inputPath = addPath(context.inputPath, varName); context.responsePath = addPath(context.responsePath, varName); - mainBody += generateInput( + mainBody += generateInput({ context, varType, varName, hasValueName, - valueFromAST(varDefNode.defaultValue, varType), - false - ); + wrapInList: false, + useInputPath: false, + useResponsePath: false + }); } if (errors.length > 0) { @@ -111,6 +130,26 @@ export function compileVariableParsing( const gen = genFn(); gen(` + 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; + } + + ${Array.from(hoistedFunctions) + .map(([, value]) => value) + .join("\n")} + return function getVariables(input) { const errors = []; const coerced = ${JSON.stringify(coercedValues)} @@ -122,16 +161,25 @@ export function compileVariableParsing( } `); - // eslint-disable-next-line - return Function.apply( + const generatedFn = gen.toString(); + + const ret = Function.apply( null, ["GraphQLJITError", "inspect"] .concat(Array.from(dependencies.keys())) - .concat(gen.toString()) + .concat(generatedFn) ).apply( null, [GraphQLJITError, inspect].concat(Array.from(dependencies.values())) ); + + Object.defineProperties(ret, { + rawFunctionBody: { + value: generatedFn + } + }); + + return ret; } // Int Scalars represent 32 bits @@ -139,16 +187,32 @@ export function compileVariableParsing( const MAX_32BIT_INT = 2147483647; const MIN_32BIT_INT = -2147483648; -function generateInput( - context: CompilationContext, - varType: GraphQLInputType, - varName: string, - hasValueName: string, - defaultValue: unknown | undefined, - 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]) ); @@ -175,11 +239,8 @@ function generateInput( `); } else { gen(` - if (${hasValueName}) { ${currentOutput} = null; } + if (${hasValueName}) { setPath(coerced, ${responsePath}, null); } `); - if (defaultValue !== undefined) { - gen(`else { ${currentOutput} = ${JSON.stringify(defaultValue)} }`); - } } gen(`} else {`); if (isScalarType(varType)) { @@ -187,9 +248,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}) + "; " + @@ -203,7 +264,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}) + "; " + @@ -217,7 +278,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}) + "; " + @@ -237,7 +298,7 @@ function generateInput( '${varType.name} 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 ' + @@ -252,7 +313,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}) + "; " + @@ -276,11 +337,11 @@ 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}) + "; " + - 'Expected type ${varType.name}.', ${errorLocation}, undefined, error) + 'Expected type ${varType.name}.', ${errorLocation}) ); } `); @@ -294,7 +355,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 ' + @@ -311,103 +372,236 @@ 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}`; - - const subContext = createSubCompilationContext(context); - subContext.responsePath = addPath( - subContext.responsePath, - index, - "variable" + gen( + compileInputListType( + context, + varType, + varName, + useInputPath, + useResponsePath + ) ); - subContext.inputPath = addPath(subContext.inputPath, index, "variable"); - subContext.depth++; - 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, - undefined, - false - )} - } - } else { - ${generateInput( - context, - varType.ofType, + } 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); + + const ${hasValueName} = __inputListValue !== undefined; + + ${generateInput({ + context: subContext, + varType: varType.ofType, varName, hasValueName, - undefined, - true - )} + 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); + allowedFields.push(field.name); + const hasValueName = hasValue(addPath(subContext.inputPath, field.name)); + + gen(` + 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, - field.defaultValue, - 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); + + ${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}`); - } - if (wrapInList) { - gen(`${currentOutput} = [${currentOutput}];`); - } + } + `); + gen(`}`); + 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; diff --git a/yarn.lock b/yarn.lock index e7557f8..0be4560 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1573,6 +1573,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.117.tgz#695a7f514182771a1e0f4345d189052ee33c8778" integrity sha512-xyf2m6tRbz8qQKcxYZa7PA4SllYcay+eh25DN3jmNYY6gSTL7Htc/bttVdkqj2wfJGbeWlQiX8pIyJpKU+tubw== +"@types/minimist@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" + integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== + "@types/node@*": version "16.11.11" resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.11.tgz#6ea7342dfb379ea1210835bada87b3c512120234" @@ -3848,6 +3853,11 @@ minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + "minipass@^5.0.0 || ^6.0.2 || ^7.0.0": version "7.0.3" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.3.tgz#05ea638da44e475037ed94d1c7efcc76a25e1974"