diff --git a/integrationTests/ts/basic-test.ts b/integrationTests/ts/basic-test.ts index a28bd840e7..bfbca75c3d 100644 --- a/integrationTests/ts/basic-test.ts +++ b/integrationTests/ts/basic-test.ts @@ -23,12 +23,13 @@ const queryType: GraphQLObjectType = new GraphQLObjectType({ const schema: GraphQLSchema = new GraphQLSchema({ query: queryType }); -const result: ExecutionResult = graphqlSync({ - schema, - source: ` +const result: ExecutionResult | AsyncGenerator = + graphqlSync({ + schema, + source: ` query helloWho($who: String){ test(who: $who) } `, - variableValues: { who: 'Dolly' }, -}); + variableValues: { who: 'Dolly' }, + }); diff --git a/src/__tests__/starWarsIntrospection-test.ts b/src/__tests__/starWarsIntrospection-test.ts index d637787c4a..840d246f64 100644 --- a/src/__tests__/starWarsIntrospection-test.ts +++ b/src/__tests__/starWarsIntrospection-test.ts @@ -1,6 +1,8 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; +import { isAsyncIterable } from '../jsutils/isAsyncIterable'; + import { graphqlSync } from '../graphql'; import { StarWarsSchema } from './starWarsSchema'; @@ -8,6 +10,7 @@ import { StarWarsSchema } from './starWarsSchema'; function queryStarWars(source: string) { const result = graphqlSync({ schema: StarWarsSchema, source }); expect(Object.keys(result)).to.deep.equal(['data']); + assert(!isAsyncIterable(result)); return result.data; } diff --git a/src/execution/__tests__/executor-test.ts b/src/execution/__tests__/executor-test.ts index 60b203dc05..8e8a16774a 100644 --- a/src/execution/__tests__/executor-test.ts +++ b/src/execution/__tests__/executor-test.ts @@ -4,6 +4,7 @@ import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON'; import { inspect } from '../../jsutils/inspect'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; import { Kind } from '../../language/kinds'; import { parse } from '../../language/parser'; @@ -19,7 +20,7 @@ import { import { GraphQLBoolean, GraphQLInt, GraphQLString } from '../../type/scalars'; import { GraphQLSchema } from '../../type/schema'; -import { execute, executeSync } from '../execute'; +import { execute, executeSubscriptionEvent, executeSync } from '../execute'; describe('Execute: Handles basic execution tasks', () => { it('executes arbitrary code', async () => { @@ -833,7 +834,7 @@ describe('Execute: Handles basic execution tasks', () => { expect(result).to.deep.equal({ data: { c: 'd' } }); }); - it('uses the subscription schema for subscriptions', () => { + it('uses the subscription schema for subscriptions', async () => { const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Q', @@ -852,11 +853,22 @@ describe('Execute: Handles basic execution tasks', () => { query Q { a } subscription S { a } `); - const rootValue = { a: 'b', c: 'd' }; + const rootValue = { + // eslint-disable-next-line @typescript-eslint/require-await + async *a() { + yield { a: 'b' }; /* c8 ignore start */ + } /* c8 ignore stop */, + c: 'd', + }; const operationName = 'S'; const result = executeSync({ schema, document, rootValue, operationName }); - expect(result).to.deep.equal({ data: { a: 'b' } }); + + assert(isAsyncIterable(result)); + expect(await result.next()).to.deep.equal({ + value: { data: { a: 'b' } }, + done: false, + }); }); it('resolves to an error if schema does not support operation', () => { @@ -894,6 +906,18 @@ describe('Execute: Handles basic execution tasks', () => { expectJSON( executeSync({ schema, document, operationName: 'S' }), + ).toDeepEqual({ + errors: [ + { + message: + 'Schema is not configured to execute subscription operation.', + locations: [{ line: 4, column: 7 }], + }, + ], + }); + + expectJSON( + executeSubscriptionEvent({ schema, document, operationName: 'S' }), ).toDeepEqual({ data: null, errors: [ diff --git a/src/execution/__tests__/nonnull-test.ts b/src/execution/__tests__/nonnull-test.ts index 427f2a64d6..ce3d0dcef5 100644 --- a/src/execution/__tests__/nonnull-test.ts +++ b/src/execution/__tests__/nonnull-test.ts @@ -1,8 +1,11 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; +import type { PromiseOrValue } from '../../jsutils/PromiseOrValue'; + import { parse } from '../../language/parser'; import { GraphQLNonNull, GraphQLObjectType } from '../../type/definition'; @@ -109,7 +112,9 @@ const schema = buildSchema(` function executeQuery( query: string, rootValue: unknown, -): ExecutionResult | Promise { +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { return execute({ schema, document: parse(query), rootValue }); } @@ -132,6 +137,7 @@ async function executeSyncAndAsync(query: string, rootValue: unknown) { rootValue, }); + assert(!isAsyncIterable(syncResult)); expectJSON(asyncResult).toDeepEqual(patchData(syncResult)); return syncResult; } diff --git a/src/execution/__tests__/subscribe-test.ts b/src/execution/__tests__/subscribe-test.ts index 5f256ca868..902a99b47b 100644 --- a/src/execution/__tests__/subscribe-test.ts +++ b/src/execution/__tests__/subscribe-test.ts @@ -15,7 +15,7 @@ import { GraphQLBoolean, GraphQLInt, GraphQLString } from '../../type/scalars'; import { GraphQLSchema } from '../../type/schema'; import type { ExecutionArgs, ExecutionResult } from '../execute'; -import { createSourceEventStream, subscribe } from '../execute'; +import { createSourceEventStream, execute, subscribe } from '../execute'; import { SimplePubSub } from './simplePubSub'; @@ -122,7 +122,7 @@ function createSubscription(pubsub: SimplePubSub) { }), }; - return subscribe({ schema: emailSchema, document, rootValue: data }); + return execute({ schema: emailSchema, document, rootValue: data }); } // TODO: consider adding this method to testUtils (with tests) @@ -150,22 +150,46 @@ function expectPromise(maybePromise: unknown) { }; } -// TODO: consider adding this method to testUtils (with tests) +// TODO: consider adding these method to testUtils (with tests) function expectEqualPromisesOrValues( - value1: PromiseOrValue, - value2: PromiseOrValue, + items: ReadonlyArray>, ): PromiseOrValue { - if (isPromise(value1)) { - assert(isPromise(value2)); - return Promise.all([value1, value2]).then((resolved) => { - expectJSON(resolved[1]).toDeepEqual(resolved[0]); - return resolved[0]; - }); + if (isPromise(items[0])) { + if (assertAllPromises(items)) { + return Promise.all(items).then(expectMatchingValues); + } + } else if (assertNoPromises(items)) { + return expectMatchingValues(items); } + /* c8 ignore next 3 */ + // Not reachable, all possible output types have been considered. + assert(false, 'Receives mixture of promises and values.'); +} - assert(!isPromise(value2)); - expectJSON(value2).toDeepEqual(value1); - return value1; +function expectMatchingValues(values: ReadonlyArray): T { + const remainingValues = values.slice(1); + for (const value of remainingValues) { + expectJSON(value).toDeepEqual(values[0]); + } + return values[0]; +} + +function assertAllPromises( + items: ReadonlyArray>, +): items is ReadonlyArray> { + for (const item of items) { + assert(isPromise(item)); + } + return true; +} + +function assertNoPromises( + items: ReadonlyArray>, +): items is ReadonlyArray { + for (const item of items) { + assert(!isPromise(item)); + } + return true; } const DummyQueryType = new GraphQLObjectType({ @@ -195,10 +219,11 @@ function subscribeWithBadFn( function subscribeWithBadArgs( args: ExecutionArgs, ): PromiseOrValue> { - return expectEqualPromisesOrValues( - subscribe(args), + return expectEqualPromisesOrValues([ + execute(args), createSourceEventStream(args), - ); + subscribe(args), + ]); } /* eslint-disable @typescript-eslint/require-await */ @@ -220,7 +245,7 @@ describe('Subscription Initialization Phase', () => { yield { foo: 'FooValue' }; } - const subscription = subscribe({ + const subscription = execute({ schema, document: parse('subscription { foo }'), rootValue: { foo: fooGenerator }, @@ -256,7 +281,7 @@ describe('Subscription Initialization Phase', () => { }), }); - const subscription = subscribe({ + const subscription = execute({ schema, document: parse('subscription { foo }'), }); @@ -294,7 +319,7 @@ describe('Subscription Initialization Phase', () => { }), }); - const promise = subscribe({ + const promise = execute({ schema, document: parse('subscription { foo }'), }); @@ -329,7 +354,7 @@ describe('Subscription Initialization Phase', () => { yield { foo: 'FooValue' }; } - const subscription = subscribe({ + const subscription = execute({ schema, document: parse('subscription { foo }'), rootValue: { customFoo: fooGenerator }, @@ -379,7 +404,7 @@ describe('Subscription Initialization Phase', () => { }), }); - const subscription = subscribe({ + const subscription = execute({ schema, document: parse('subscription { foo bar }'), }); @@ -530,7 +555,7 @@ describe('Subscription Initialization Phase', () => { } `); - // If we receive variables that cannot be coerced correctly, subscribe() will + // If we receive variables that cannot be coerced correctly, execute() will // resolve to an ExecutionResult that contains an informative error description. const result = subscribeWithBadArgs({ schema, document, variableValues }); expectJSON(result).toDeepEqual({ @@ -945,7 +970,7 @@ describe('Subscription Publish Phase', () => { }); const document = parse('subscription { newMessage }'); - const subscription = subscribe({ schema, document }); + const subscription = execute({ schema, document }); assert(isAsyncIterable(subscription)); expect(await subscription.next()).to.deep.equal({ @@ -1006,7 +1031,7 @@ describe('Subscription Publish Phase', () => { }); const document = parse('subscription { newMessage }'); - const subscription = subscribe({ schema, document }); + const subscription = execute({ schema, document }); assert(isAsyncIterable(subscription)); expect(await subscription.next()).to.deep.equal({ diff --git a/src/execution/execute.ts b/src/execution/execute.ts index bad4afacf1..e5032b4917 100644 --- a/src/execution/execute.ts +++ b/src/execution/execute.ts @@ -154,6 +154,14 @@ export interface ExecutionArgs { subscribeFieldResolver?: Maybe>; } +type FieldsExecutor = ( + exeContext: ExecutionContext, + parentType: GraphQLObjectType, + sourceValue: unknown, + path: Path | undefined, + fields: Map>, +) => PromiseOrValue>; + /** * Implements the "Executing requests" section of the GraphQL specification. * @@ -164,7 +172,18 @@ export interface ExecutionArgs { * If the arguments to this function do not result in a legal execution context, * a GraphQLError will be thrown immediately explaining the invalid input. */ -export function execute(args: ExecutionArgs): PromiseOrValue { +export function execute( + args: ExecutionArgs, +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { + return prepareContextAndRunFn(args, executeOperation); +} + +function prepareContextAndRunFn( + args: ExecutionArgs, + fn: (exeContext: ExecutionContext) => T, +): ExecutionResult | T { // If a valid execution context cannot be created due to incorrect arguments, // a "Response" with only errors is returned. const exeContext = buildExecutionContext(args); @@ -174,11 +193,45 @@ export function execute(args: ExecutionArgs): PromiseOrValue { return { errors: exeContext }; } - return executeImpl(exeContext); + return fn(exeContext); +} + +/** + * Implements the "Executing operations" section of the spec. + */ +function executeOperation( + exeContext: ExecutionContext, +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { + const operationType = exeContext.operation.operation; + + if (operationType === OperationTypeNode.QUERY) { + return executeQuery(exeContext); + } + + if (operationType === OperationTypeNode.MUTATION) { + return executeMutation(exeContext); + } + + return executeSubscription(exeContext); +} + +function executeQuery( + exeContext: ExecutionContext, +): PromiseOrValue { + return executeQueryOrMutation(exeContext, executeFields); } -function executeImpl( +function executeMutation( exeContext: ExecutionContext, +): PromiseOrValue { + return executeQueryOrMutation(exeContext, executeFieldsSerially); +} + +function executeQueryOrMutation( + exeContext: ExecutionContext, + fieldsExecutor: FieldsExecutor, ): PromiseOrValue { // Return a Promise that will eventually resolve to the data described by // The "Response" section of the GraphQL specification. @@ -192,7 +245,7 @@ function executeImpl( // at which point we still log the error and null the parent field, which // in this case is the entire response. try { - const result = executeOperation(exeContext); + const result = executeQueryOrMutationRootFields(exeContext, fieldsExecutor); if (isPromise(result)) { return result.then( (data) => buildResponse(data, exeContext.errors), @@ -214,7 +267,9 @@ function executeImpl( * However, it guarantees to complete synchronously (or throw an error) assuming * that all field resolvers are also synchronous. */ -export function executeSync(args: ExecutionArgs): ExecutionResult { +export function executeSync( + args: ExecutionArgs, +): ExecutionResult | AsyncGenerator { const result = execute(args); // Assert that the execution was synchronous. @@ -336,11 +391,9 @@ function buildPerEventExecutionContext( }; } -/** - * Implements the "Executing operations" section of the spec. - */ -function executeOperation( +function executeQueryOrMutationRootFields( exeContext: ExecutionContext, + fieldsExecutor: FieldsExecutor, ): PromiseOrValue> { const { operation, schema, fragments, variableValues, rootValue } = exeContext; @@ -361,22 +414,7 @@ function executeOperation( ); const path = undefined; - switch (operation.operation) { - case OperationTypeNode.QUERY: - return executeFields(exeContext, rootType, rootValue, path, rootFields); - case OperationTypeNode.MUTATION: - return executeFieldsSerially( - exeContext, - rootType, - rootValue, - path, - rootFields, - ); - case OperationTypeNode.SUBSCRIPTION: - // TODO: deprecate `subscribe` and move all logic here - // Temporary solution until we finish merging execute and subscribe together - return executeFields(exeContext, rootType, rootValue, path, rootFields); - } + return fieldsExecutor(exeContext, rootType, rootValue, path, rootFields); } /** @@ -1028,21 +1066,22 @@ export const defaultFieldResolver: GraphQLFieldResolver = * yields a stream of ExecutionResults representing the response stream. * * Accepts either an object with named arguments, or individual arguments. + * + * @deprecated subscribe will be removed in v18; use execute instead */ export function subscribe( args: ExecutionArgs, ): PromiseOrValue< AsyncGenerator | ExecutionResult > { - // If a valid execution context cannot be created due to incorrect arguments, - // a "Response" with only errors is returned. - const exeContext = buildExecutionContext(args); - - // Return early errors if execution context failed. - if (!('schema' in exeContext)) { - return { errors: exeContext }; - } + return execute(args); +} +function executeSubscription( + exeContext: ExecutionContext, +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { const resultOrStream = createSourceEventStreamImpl(exeContext); if (isPromise(resultOrStream)) { @@ -1071,7 +1110,10 @@ function mapSourceToResponse( // "ExecuteSubscriptionEvent" algorithm, as it is nearly identical to the // "ExecuteQuery" algorithm, for which `execute` is also used. return mapAsyncIterator(resultOrStream, (payload: unknown) => - executeImpl(buildPerEventExecutionContext(exeContext, payload)), + executeQueryOrMutation( + buildPerEventExecutionContext(exeContext, payload), + executeFields, + ), ); } @@ -1106,23 +1148,24 @@ function mapSourceToResponse( export function createSourceEventStream( args: ExecutionArgs, ): PromiseOrValue | ExecutionResult> { - // If a valid execution context cannot be created due to incorrect arguments, - // a "Response" with only errors is returned. - const exeContext = buildExecutionContext(args); - - // Return early errors if execution context failed. - if (!('schema' in exeContext)) { - return { errors: exeContext }; - } + return prepareContextAndRunFn(args, createSourceEventStreamImpl); +} - return createSourceEventStreamImpl(exeContext); +/** + * Implements the "ExecuteSubscriptionEvent" algorithm described in the + * GraphQL specification. + */ +export function executeSubscriptionEvent( + args: ExecutionArgs, +): PromiseOrValue { + return prepareContextAndRunFn(args, executeQuery); } function createSourceEventStreamImpl( exeContext: ExecutionContext, ): PromiseOrValue | ExecutionResult> { try { - const eventStream = executeSubscription(exeContext); + const eventStream = executeSubscriptionRootField(exeContext); if (isPromise(eventStream)) { return eventStream.then(undefined, (error) => ({ errors: [error] })); } @@ -1133,7 +1176,7 @@ function createSourceEventStreamImpl( } } -function executeSubscription( +function executeSubscriptionRootField( exeContext: ExecutionContext, ): PromiseOrValue> { const { schema, fragments, operation, variableValues, rootValue } = diff --git a/src/execution/index.ts b/src/execution/index.ts index b27a2c291c..289174d8b6 100644 --- a/src/execution/index.ts +++ b/src/execution/index.ts @@ -3,6 +3,7 @@ export { pathToArray as responsePathAsArray } from '../jsutils/Path'; export { createSourceEventStream, execute, + executeSubscriptionEvent, executeSync, defaultFieldResolver, defaultTypeResolver, diff --git a/src/graphql.ts b/src/graphql.ts index ffad9123c1..8cc5ff19fa 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -67,7 +67,9 @@ export interface GraphQLArgs { typeResolver?: Maybe>; } -export function graphql(args: GraphQLArgs): Promise { +export function graphql( + args: GraphQLArgs, +): Promise> { // Always return a Promise for a consistent API. return new Promise((resolve) => resolve(graphqlImpl(args))); } @@ -78,7 +80,9 @@ export function graphql(args: GraphQLArgs): Promise { * However, it guarantees to complete synchronously (or throw an error) assuming * that all field resolvers are also synchronous. */ -export function graphqlSync(args: GraphQLArgs): ExecutionResult { +export function graphqlSync( + args: GraphQLArgs, +): ExecutionResult | AsyncGenerator { const result = graphqlImpl(args); // Assert that the execution was synchronous. @@ -89,7 +93,11 @@ export function graphqlSync(args: GraphQLArgs): ExecutionResult { return result; } -function graphqlImpl(args: GraphQLArgs): PromiseOrValue { +function graphqlImpl( + args: GraphQLArgs, +): PromiseOrValue< + ExecutionResult | AsyncGenerator +> { const { schema, source, diff --git a/src/index.ts b/src/index.ts index bce254f808..9e7c439e83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -321,6 +321,7 @@ export { getDirectiveValues, subscribe, createSourceEventStream, + executeSubscriptionEvent, } from './execution/index'; export type { diff --git a/src/type/__tests__/enumType-test.ts b/src/type/__tests__/enumType-test.ts index 35e9f94b00..cf33869e3a 100644 --- a/src/type/__tests__/enumType-test.ts +++ b/src/type/__tests__/enumType-test.ts @@ -1,8 +1,10 @@ -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { describe, it } from 'mocha'; import { expectJSON } from '../../__testUtils__/expectJSON'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; + import { introspectionFromSchema } from '../../utilities/introspectionFromSchema'; import { graphqlSync } from '../../graphql'; @@ -104,6 +106,10 @@ const SubscriptionType = new GraphQLObjectType({ subscribeToEnum: { type: ColorType, args: { color: { type: ColorType } }, + // eslint-disable-next-line @typescript-eslint/require-await + async *subscribe(_source, { color }) { + yield { subscribeToEnum: color }; /* c8 ignore start */ + } /* c8 ignore stop */, resolve: (_source, { color }) => color, }, }, @@ -248,13 +254,15 @@ describe('Type System: Enum Values', () => { }); }); - it('accepts enum literals as input arguments to subscriptions', () => { + it('accepts enum literals as input arguments to subscriptions', async () => { const doc = 'subscription ($color: Color!) { subscribeToEnum(color: $color) }'; const result = executeQuery(doc, { color: 'GREEN' }); - expect(result).to.deep.equal({ - data: { subscribeToEnum: 'GREEN' }, + assert(isAsyncIterable(result)); + expect(await result.next()).to.deep.equal({ + value: { data: { subscribeToEnum: 'GREEN' } }, + done: false, }); }); diff --git a/src/utilities/__tests__/buildASTSchema-test.ts b/src/utilities/__tests__/buildASTSchema-test.ts index 435abc2d7a..e7945c80e5 100644 --- a/src/utilities/__tests__/buildASTSchema-test.ts +++ b/src/utilities/__tests__/buildASTSchema-test.ts @@ -3,6 +3,7 @@ import { describe, it } from 'mocha'; import { dedent } from '../../__testUtils__/dedent'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; import type { Maybe } from '../../jsutils/Maybe'; import type { ASTNode } from '../../language/ast'; @@ -76,6 +77,7 @@ describe('Schema Builder', () => { source: '{ str }', rootValue: { str: 123 }, }); + assert(!isAsyncIterable(result)); expect(result.data).to.deep.equal({ str: '123' }); }); diff --git a/src/utilities/__tests__/buildClientSchema-test.ts b/src/utilities/__tests__/buildClientSchema-test.ts index 8c043f0e77..cb90567974 100644 --- a/src/utilities/__tests__/buildClientSchema-test.ts +++ b/src/utilities/__tests__/buildClientSchema-test.ts @@ -3,6 +3,8 @@ import { describe, it } from 'mocha'; import { dedent } from '../../__testUtils__/dedent'; +import { isAsyncIterable } from '../../jsutils/isAsyncIterable'; + import { assertEnumType, GraphQLEnumType, @@ -591,6 +593,7 @@ describe('Type System: build schema from introspection', () => { variableValues: { v: 'baz' }, }); + assert(!isAsyncIterable(result)); expect(result.data).to.deep.equal({ foo: 'bar' }); }); diff --git a/src/utilities/introspectionFromSchema.ts b/src/utilities/introspectionFromSchema.ts index 78c1b30244..5daed3fb28 100644 --- a/src/utilities/introspectionFromSchema.ts +++ b/src/utilities/introspectionFromSchema.ts @@ -1,4 +1,5 @@ import { invariant } from '../jsutils/invariant'; +import { isAsyncIterable } from '../jsutils/isAsyncIterable'; import { parse } from '../language/parser'; @@ -35,6 +36,7 @@ export function introspectionFromSchema( const document = parse(getIntrospectionQuery(optionsWithDefaults)); const result = executeSync({ schema, document }); + invariant(!isAsyncIterable(result)); invariant(result.errors == null && result.data != null); return result.data as any; }