diff --git a/src/__benchmarks__/benchmarks.ts b/src/__benchmarks__/benchmarks.ts index 01615467..6465805c 100644 --- a/src/__benchmarks__/benchmarks.ts +++ b/src/__benchmarks__/benchmarks.ts @@ -1,5 +1,6 @@ import Benchmark from "benchmark"; import { + createSourceEventStream, DocumentNode, execute, getIntrospectionQuery, @@ -7,6 +8,7 @@ import { parse } from "graphql"; import { compileQuery, isCompiledQuery, isPromise } from "../execution"; +import { benchmarkCreateSourceEventStream } from "./createSourceEventStream"; import { query as fewResolversQuery, schema as fewResolversSchema @@ -51,8 +53,8 @@ const benchmarks: { [key: string]: BenchmarkMaterial } = { async function runBenchmarks() { const skipJS = process.argv[2] === "skip-js"; const skipJSON = process.argv[2] === "skip-json"; - const benchs = await Promise.all( - Object.entries(benchmarks).map( + const benchs = await Promise.all([ + ...Object.entries(benchmarks).map( async ([bench, { query, schema, variables }]) => { const compiledQuery = compileQuery(schema, query, undefined, { debug: true @@ -145,7 +147,9 @@ async function runBenchmarks() { }); return suite; } - ) + ), + benchmarkCreateSourceEventStream(), + ] ); const benchsToRun = benchs.filter(isNotNull); diff --git a/src/__benchmarks__/createSourceEventStream.ts b/src/__benchmarks__/createSourceEventStream.ts new file mode 100644 index 00000000..3dd958ba --- /dev/null +++ b/src/__benchmarks__/createSourceEventStream.ts @@ -0,0 +1,153 @@ +import Benchmark from "benchmark"; +import { + createSourceEventStream, + GraphQLBoolean, + GraphQLID, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + parse + +} from "graphql"; +import { isPromise } from "../execution"; +import { compileSourceEventStream } from ".."; + +const schema = function schema() { + const BlogArticle: GraphQLObjectType = new GraphQLObjectType({ + name: "Article", + fields: { + id: { type: new GraphQLNonNull(GraphQLID) }, + isPublished: { type: GraphQLBoolean }, + title: { type: GraphQLString }, + body: { type: GraphQLString }, + keywords: { type: new GraphQLList(GraphQLString) } + } + }); + + const BlogQuery = new GraphQLObjectType({ + name: "Query", + fields: { + article: { + type: BlogArticle, + args: { id: { type: GraphQLID } }, + resolve: (_, { id }) => article(id) + }, + } + }); + const BlogSubscription = new GraphQLObjectType({ + name: "Subscription", + fields: { + news: { + type: BlogArticle, + args: {}, + subscribe: async () => { + const it: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => ({done: true, value: undefined}) + } + }, + } + return it; + } + }, + } + }); + + function article(id: number): any { + return { + id, + isPublished: true, + title: "My Article " + id, + body: "This is a post", + hidden: "This data is not exposed in the schema", + keywords: ["foo", "bar", 1, true, null] + }; + } + + return new GraphQLSchema({ + query: BlogQuery, + subscription: BlogSubscription + }); +}() + +const subscription = parse(` +subscription { + news { + ...articleFields, + } +} + +fragment articleFields on Article { + __typename + id, + isPublished, + title, + body, + hidden, + notdefined +} +`); + +// TODO +const skipJS = false; + +export function benchmarkCreateSourceEventStream() { + const compiledQuery = compileSourceEventStream(schema, subscription, undefined, { + debug: true + } as any); + if (!compiledQuery) { + // eslint-disable-next-line no-console + console.error(`failed to compile`); + return null; + } + const suite = new Benchmark.Suite('createSourceEventStream'); + if (!skipJS) { + suite.add("graphql-js", { + minSamples: 150, + defer: true, + fn(deferred: any) { + const stream = createSourceEventStream( + schema, + subscription, + {}, + ); + if (isPromise(stream)) { + return stream.then((res) => + deferred.resolve(res) + ); + } + return deferred.resolve() + } + }); + } + suite + .add("graphql-jit", { + minSamples: 150, + defer: true, + fn(deferred: any) { + const stream = compiledQuery( + {}, + undefined + ); + if (isPromise(stream)) { + return stream.then((res) => + deferred.resolve(res) + ); + } + return deferred.resolve() + } + }) + // add listeners + .on("cycle", (event: any) => { + // eslint-disable-next-line no-console + console.log(String(event.target)); + }) + .on("start", () => { + // eslint-disable-next-line no-console + console.log("Starting createSourceEventStream"); + }); + return suite; +} diff --git a/src/execution.ts b/src/execution.ts index b1fc6e7e..551d7bd1 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -183,6 +183,14 @@ export interface CompiledQuery< stringify: (v: any) => string; } +export type CreateSourceEventStream< + TVariables = { [key: string]: any } +> = ( + root: any, + context: any, + variables?: Maybe, +) => Promise | ExecutionResult>; + interface InternalCompiledQuery extends CompiledQuery { __DO_NOT_USE_THIS_OR_YOU_WILL_BE_FIRED_compilation?: string; } @@ -274,19 +282,20 @@ export function compileQuery< }; if (context.operation.operation === "subscription") { + const createSourceEventStream = compileSourceEventStreamOperation( + context, + type, + fieldMap, + ); + const subscribe = compileSubscription( + createSourceEventStream, + compiledQuery.query + ); compiledQuery.subscribe = createBoundSubscribe( context, - document, - compileSubscriptionOperation( - context, - type, - fieldMap, - compiledQuery.query - ), + subscribe, getVariables, - context.operation.name != null - ? context.operation.name.value - : undefined + context.operation.name?.value ); } @@ -304,6 +313,98 @@ export function compileQuery< } } +/** + * It compiles a GraphQL query to an executable function + * @param {GraphQLSchema} schema GraphQL schema + * @param {DocumentNode} document Query being submitted + * @param {string} operationName name of the operation + * @param partialOptions compilation options to tune the compiler features + * @returns {CompiledQuery} the cacheable result + */ +export function compileSourceEventStream< + TResult = { [key: string]: any }, + TVariables = { [key: string]: any } +>( + schema: GraphQLSchema, + document: TypedDocumentNode, + operationName?: string, + partialOptions?: Partial +): CreateSourceEventStream { + if (!schema) { + throw new Error(`Expected ${schema} to be a GraphQL schema.`); + } + if (!document) { + throw new Error("Must provide document."); + } + + if ( + partialOptions && + partialOptions.resolverInfoEnricher && + typeof partialOptions.resolverInfoEnricher !== "function" + ) { + throw new Error("resolverInfoEnricher must be a function"); + } + const options = { + disablingCapturingStackErrors: false, + customJSONSerializer: false, + disableLeafSerialization: false, + customSerializers: {}, + ...partialOptions + }; + + // If a valid context cannot be created due to incorrect arguments, + // a "Response" with only errors is returned. + const context = buildCompilationContext( + schema, + document, + options, + operationName + ); + + const getVariables = compileVariableParsing( + schema, + context.operation.variableDefinitions || [] + ); + + const type = getOperationRootType(context.schema, context.operation); + const fieldMap = collectFields( + context, + type, + context.operation.selectionSet, + Object.create(null), + Object.create(null) + ); + + context.deferred.forEach((deferredField) => { + compileDeferredField(context, deferredField) + }); + + // TODO refactor executeSubscription instead of just preparing everything for it + const fieldNodes = Object.values(fieldMap)[0]; + const fieldNode = fieldNodes[0]; + const fieldName = fieldNode.name.value; + const field = resolveFieldDef(context, type, fieldNodes); + + const responsePath = addPath(undefined, fieldName); + getExecutionInfo( context, type, field!.type, fieldName, fieldNodes, responsePath) + // end hack + + if (context.operation.operation !== "subscription") { + throw new Error("Operation must be a subscription"); + } + const createSourceEventStream = compileSourceEventStreamOperation( + context, + type, + fieldMap, + ); + return createBoundCreateSourceEventStream( + context, + createSourceEventStream, + getVariables, + context.operation.name?.value + ); +} + export function isCompiledQuery< C extends CompiledQuery, E extends ExecutionResult @@ -1678,11 +1779,10 @@ export function isAsyncIterable( return typeof Object(val)[Symbol.asyncIterator] === "function"; } -function compileSubscriptionOperation( +function compileSourceEventStreamOperation( context: CompilationContext, type: GraphQLObjectType, - fieldMap: FieldsAndNodes, - queryFn: CompiledQuery["query"] + fieldMap: FieldsAndNodes ) { const fieldNodes = Object.values(fieldMap)[0]; const fieldNode = fieldNodes[0]; @@ -1729,7 +1829,7 @@ function compileSubscriptionOperation( } } - async function createSourceEventStream(executionContext: ExecutionContext) { + return async function createSourceEventStream(executionContext: ExecutionContext) { try { const eventStream = await executeSubscription(executionContext); @@ -1751,7 +1851,12 @@ function compileSubscriptionOperation( throw error; } } +} +function compileSubscription( + createSourceEventStream: (executionContext: ExecutionContext) => Promise | { errors: GraphQLError[] }>, + queryFn: CompiledQuery["query"] +) { return async function subscribe(executionContext: ExecutionContext) { const resultOrStream = await createSourceEventStream(executionContext); @@ -1773,9 +1878,62 @@ function compileSubscriptionOperation( }; } +function createBoundCreateSourceEventStream( + compilationContext: CompilationContext, + func: ( + context: ExecutionContext + ) => Promise | ExecutionResult>, + getVariableValues: (inputs: { [key: string]: any }) => CoercedVariableValues, + operationName: string | undefined +): CreateSourceEventStream { + const { resolvers, typeResolvers, isTypeOfs, serializers, resolveInfos } = + compilationContext; + const trimmer = createNullTrimmer(compilationContext); + const fnName = operationName || "subscribe"; + + const ret = { + async [fnName]( + rootValue: any, + context: any, + variables: Maybe<{ [key: string]: any }> + ): Promise | ExecutionResult> { + // this can be shared across in a batch request + const parsedVariables = getVariableValues(variables || {}); + + // Return early errors if variable coercing failed. + if (failToParseVariables(parsedVariables)) { + return { errors: parsedVariables.errors }; + } + + const executionContext: ExecutionContext = { + rootValue, + context, + variables: parsedVariables.coerced, + safeMap, + inspect, + GraphQLError: GraphqlJitError, + resolvers, + typeResolvers, + isTypeOfs, + serializers, + resolveInfos, + trimmer, + promiseCounter: 0, + nullErrors: [], + errors: [], + data: {} + }; + + // eslint-disable-next-line no-useless-call + return func.call(null, executionContext); + } + }; + + return ret[fnName]; +} + function createBoundSubscribe( compilationContext: CompilationContext, - document: DocumentNode, func: ( context: ExecutionContext ) => Promise | ExecutionResult>, diff --git a/src/index.ts b/src/index.ts index c93f08a1..82265f96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,9 @@ export { compileQuery, isCompiledQuery, CompilerOptions, - CompiledQuery + CompiledQuery, + CreateSourceEventStream, + compileSourceEventStream } from "./execution"; export {