UNPKG

nexus

Version:

Scalable, strongly typed GraphQL schema development

1,200 lines (1,114 loc) 43 kB
import { defaultFieldResolver, GraphQLFieldResolver, GraphQLResolveInfo } from 'graphql' import { ArgsRecord, intArg, stringArg } from '../definitions/args' import type { CommonFieldConfig, FieldOutConfig, FieldOutConfigWithName, } from '../definitions/definitionBlocks' import { NexusNonNullDef, nonNull } from '../definitions/nonNull' import { NexusNullDef, nullable } from '../definitions/nullable' import { ObjectDefinitionBlock, objectType } from '../definitions/objectType' import { AllNexusNamedOutputTypeDefs, AllNexusOutputTypeDefs, applyNexusWrapping, } from '../definitions/wrapping' import type { NonNullConfig } from '../definitions/_types' import { dynamicOutputMethod } from '../dynamicMethod' import { completeValue, plugin } from '../plugin' import type { ArgsValue, FieldTypeName, GetGen, MaybePromise, MaybePromiseDeep, ResultValue, SourceValue, } from '../typegenTypeHelpers' import type { MaybePromiseLike } from '../typeHelpersInternal' import { eachObj, getOwnPackage, isPromiseLike, mapObj, pathToArray, printedGenTypingImport } from '../utils' export interface ConnectionPluginConfig { /** * The method name in the objectType definition block * * @default 'connectionField' */ nexusFieldName?: string /** * Whether to expose the "nodes" directly on the connection for convenience. * * @default false */ includeNodesField?: boolean /** * Any args to include by default on all connection fields, in addition to the ones in the spec. * * @default null */ additionalArgs?: ArgsRecord /** * Set to true to disable forward pagination. * * @default false */ disableForwardPagination?: boolean /** * Set to true to disable backward pagination. * * @default false */ disableBackwardPagination?: boolean /** * Custom logic to validate the arguments. * * Defaults to requiring that either a `first` or `last` is provided, and that after / before must be paired * with `first` or `last`, respectively. */ validateArgs?: (args: Record<string, any>, info: GraphQLResolveInfo, root: unknown, ctx: unknown) => void /** * If disableForwardPagination or disableBackwardPagination are set to true, we require the `first` or * `last` field as needed. Defaults to true, setting this to false will disable this behavior and make the * field nullable. */ strictArgs?: boolean /** * Default approach we use to transform a node into an unencoded cursor. * * Default is `cursor:${index}` * * @default field */ cursorFromNode?: ( node: any, args: PaginationArgs, ctx: GetGen<'context'>, info: GraphQLResolveInfo, forCursor: { index: number; nodes: any[] } ) => string | Promise<string> /** * Override the default behavior of determining hasNextPage / hasPreviousPage. Usually needed when * customizing the behavior of `cursorFromNode` */ pageInfoFromNodes?: ( allNodes: any[], args: PaginationArgs, ctx: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<{ hasNextPage: boolean; hasPreviousPage: boolean }> /** Conversion from a cursor string into an opaque token. Defaults to base64Encode(string) */ encodeCursor?: (value: string) => string /** Conversion from an opaque token into a cursor string. Defaults to base64Decode(string) */ decodeCursor?: (cursor: string) => string /** Extend *all* edges to include additional fields, beyond cursor and node */ extendEdge?: Record< string, Omit<FieldOutConfig<any, any>, 'resolve'> & { /** * Set requireResolver to false if you have already resolved this information during the resolve of the * edges in the parent resolve method * * @default true */ requireResolver?: boolean } > /** * Any additional fields to make available to the connection type, beyond edges / pageInfo / nodes. * * Any fields defined extended on the Connection type will automatically receive the args from the * connection. If the field also defines args, they will be merged with the args of the connection, with the * extension's field args taking precedence if there is a conflict. */ extendConnection?: Record< string, Omit<FieldOutConfig<any, any>, 'resolve'> & { /** * Set requireResolver to false if you have already resolved this information during the resolve of the * edges in the parent resolve method * * @default true */ requireResolver?: boolean } > /** Allows specifying a custom name for connection types. */ getConnectionName?(filedName: string, parentTypeName: string): string /** Allows specifying a custom name for edge types. */ getEdgeName?(filedName: string, parentTypeName: string): string /** Prefix for the Connection / Edge type */ typePrefix?: string /** * The path to the nexus package for typegen. * * This setting is only necessary when nexus is being wrapped by another library/framework such that `nexus` * is not expected to be a direct dependency at the application level. * * @default 'nexus' */ nexusSchemaImportId?: string /** * Configures the default "nonNullDefaults" settings for any connection types created globally by this * config / connection field. */ nonNullDefaults?: NonNullConfig /** Allows specifying a custom cursor type, as the name of a scalar */ cursorType?: | GetGen<'scalarNames'> | NexusNullDef<GetGen<'scalarNames'>> | NexusNonNullDef<GetGen<'scalarNames'>> } // Extract the node value from the connection for a given field. export type NodeValue<TypeName extends string = any, FieldName extends string = any> = SourceValue< EdgeTypeLookup<TypeName, FieldName> >['node'] export type ConnectionFieldConfig<TypeName extends string = any, FieldName extends string = any> = { type: GetGen<'allOutputTypes', string> | AllNexusNamedOutputTypeDefs /** * Whether the connection field can be null * * @default (depends on whether nullability is configured in type or schema) */ nullable?: boolean /** * Additional args to include for just this field * * @example * additionalArgs: { * orderBy: arg({ type: nonNull(SortOrderEnum) }) * } */ additionalArgs?: ArgsRecord /** * Whether to inherit "additional args" if they exist on the plugin definition * * @default false */ inheritAdditionalArgs?: boolean /** * Approach we use to transform a node into a cursor. * * @default nodeField */ cursorFromNode?: ( node: NodeValue<TypeName, FieldName>, args: ArgsValue<TypeName, FieldName>, ctx: GetGen<'context'>, info: GraphQLResolveInfo, forCursor: { index: number; nodes: NodeValue<TypeName, FieldName>[] } ) => string | Promise<string> /** * Override the default behavior of determining hasNextPage / hasPreviousPage. Usually needed when * customizing the behavior of `cursorFromNode` */ pageInfoFromNodes?: ( nodes: NodeValue<TypeName, FieldName>[], args: ArgsValue<TypeName, FieldName>, ctx: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<{ hasNextPage: boolean; hasPreviousPage: boolean }> /** * Whether the field allows for backward pagination * * @see https://relay.dev/graphql/connections.htm#sec-Backward-pagination-arguments */ disableForwardPagination?: boolean /** * Whether the field allows for backward pagination * * @see https://relay.dev/graphql/connections.htm#sec-Forward-pagination-arguments */ disableBackwardPagination?: boolean /** * If disableForwardPagination or disableBackwardPagination are set to true, we require the `first` or * `last` field as needed. Defaults to true, setting this to false will disable this behavior and make the * field nullable. */ strictArgs?: boolean /** * Custom logic to validate the arguments. * * Defaults to requiring that either a `first` or `last` is provided, and that after / before must be paired * with `first` or `last`, respectively. */ validateArgs?: ( args: ArgsValue<TypeName, FieldName>, info: GraphQLResolveInfo, root: SourceValue<TypeName>, ctx: GetGen<'context'> ) => void /** * Dynamically adds additional fields to the current "connection" when it is defined. This will cause the * resulting type to be prefix'ed with the name of the type/field it is branched off of, so as not to * conflict with any non-extended connections. */ extendConnection?: (def: ObjectDefinitionBlock<FieldTypeName<TypeName, FieldName>>) => void /** * Dynamically adds additional fields to the connection "edge" when it is defined. This will cause the * resulting type to be prefix'ed with the name of the type/field it is branched off of, so as not to * conflict with any non-extended connections. */ extendEdge?: ( def: ObjectDefinitionBlock<FieldTypeName<FieldTypeName<TypeName, FieldName>, 'edges'>> ) => void /** Allows specifying a custom name for connection types. */ getConnectionName?(filedName: string, parentTypeName: string): string /** Allows specifying a custom name for edge types. */ getEdgeName?(filedName: string, parentTypeName: string): string /** Configures the default "nonNullDefaults" for connection type generated for this connection */ nonNullDefaults?: NonNullConfig /** * Allows specifying a custom cursor type, as the name of a scalar * * @example * cursorType: 'CustomString' */ cursorType?: | GetGen<'scalarNames'> | NexusNullDef<GetGen<'scalarNames'>> | NexusNonNullDef<GetGen<'scalarNames'>> /** * Defined automatically if you have extended the connectionPlugin globally * * If you wish to set "requireResolver" to false on the edge field definition in the connection plugin */ edgeFields?: unknown } & ( | { /** * Nodes should resolve to an Array, with a length of one greater than the direction you are paginating. * * For example, if you're paginating forward, and assuming an Array with length 20: * * (first: 2) - [{id: 1}, {id: 2}, {id: 3}] - note: {id: 3} is extra * * (last: 2) - [{id: 18}, {id: 19}, {id: 20}] - note {id: 18} is extra * * We will then slice the array in the direction we're iterating, and if there are more than "N" * results, we will assume there's a next page. If you set `assumeExactNodeCount: true` in the config, * we will assume that a next page exists if the length >= the node count. */ nodes: ( root: SourceValue<TypeName>, args: ArgsValue<TypeName, FieldName>, ctx: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<Array<NodeValue<TypeName, FieldName>>> // resolve XOR nodes resolve?: never } | { /** * Implement the full resolve, including `edges` and `pageInfo`. Useful in more complex pagination * cases, or if you want to use utilities from other libraries like GraphQL Relay JS, and only use Nexus * for the construction and type-safety: * * Https://github.com/graphql/graphql-relay-js */ resolve: ( root: SourceValue<TypeName>, args: ArgsValue<TypeName, FieldName>, ctx: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<ResultValue<TypeName, FieldName>> | MaybePromiseDeep<ResultValue<TypeName, FieldName>> // resolve XOR nodes nodes?: never } ) & Pick<CommonFieldConfig, 'deprecation' | 'description'> & NexusGenPluginFieldConfig<TypeName, FieldName> export const ForwardPaginateArgs = { first: nullable(intArg({ description: 'Returns the first n elements from the list.' })), after: nullable( stringArg({ description: 'Returns the elements in the list that come after the specified cursor' }) ), } export const ForwardOnlyStrictArgs = { ...ForwardPaginateArgs, first: nonNull(intArg({ description: 'Returns the first n elements from the list.' })), } export const BackwardPaginateArgs = { last: nullable(intArg({ description: 'Returns the last n elements from the list.' })), before: nullable( stringArg({ description: 'Returns the elements in the list that come before the specified cursor' }) ), } export const BackwardOnlyStrictArgs = { ...BackwardPaginateArgs, last: nonNull(intArg({ description: 'Returns the last n elements from the list.' })), } function base64Encode(str: string) { return Buffer.from(str, 'utf8').toString('base64') } function base64Decode(str: string) { return Buffer.from(str, 'base64').toString('utf8') } export type EdgeTypeLookup<TypeName extends string, FieldName extends string> = FieldTypeName< FieldTypeName<TypeName, FieldName>, 'edges' > export type EdgeFieldResolver<TypeName extends string, FieldName extends string, EdgeField extends string> = ( root: SourceValue<EdgeTypeLookup<TypeName, FieldName>>, args: ArgsValue<TypeName, FieldName> & ArgsValue<EdgeTypeLookup<TypeName, FieldName>, EdgeField>, context: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<ResultValue<EdgeTypeLookup<TypeName, FieldName>, EdgeField>> export type ConnectionFieldResolver< TypeName extends string, FieldName extends string, ConnectionFieldName extends string > = ( root: SourceValue<TypeName>, args: ArgsValue<FieldTypeName<TypeName, FieldName>, ConnectionFieldName>, context: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<ResultValue<FieldTypeName<TypeName, FieldName>, ConnectionFieldName>> export type ConnectionNodesResolver<TypeName extends string, FieldName extends string> = ( root: SourceValue<TypeName>, args: ArgsValue<TypeName, FieldName>, context: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<Array<NodeValue<TypeName, FieldName>>> // Used for type-safe extensions to pageInfo export type PageInfoFieldResolver< TypeName extends string, FieldName extends string, EdgeField extends string > = ( root: SourceValue<TypeName>, args: ArgsValue<TypeName, FieldName>, context: GetGen<'context'>, info: GraphQLResolveInfo ) => MaybePromise<ResultValue<TypeName, FieldName>['pageInfo'][EdgeField]> export type EdgeLike = { cursor: string | PromiseLike<string>; node: any } export const connectionPlugin = (connectionPluginConfig?: ConnectionPluginConfig) => { const pluginConfig: ConnectionPluginConfig = { ...connectionPluginConfig } // Define the plugin with the appropriate configuration. return plugin({ name: 'ConnectionPlugin', fieldDefTypes: [ printedGenTypingImport({ module: connectionPluginConfig?.nexusSchemaImportId ?? getOwnPackage().name, bindings: ['core', 'connectionPluginCore'], }), ], // Defines the field added to the definition block: // t.connectionField('users', { // type: User // }) onInstall(b) { let dynamicConfig = [] const { additionalArgs = {}, extendConnection: pluginExtendConnection, extendEdge: pluginExtendEdge, includeNodesField = false, nexusFieldName = 'connectionField', } = pluginConfig // If to add fields to every connection, we require the resolver be defined on the // field definition, unless fromResolve: true is passed in the config if (pluginExtendConnection) { eachObj(pluginExtendConnection, (val, key) => { dynamicConfig.push( `${key}${ val.requireResolver === false ? '?:' : ':' } connectionPluginCore.ConnectionFieldResolver<TypeName, FieldName, "${key}">` ) }) } if (pluginExtendEdge) { const edgeFields = mapObj( pluginExtendEdge, (val, key) => `${key}${ val.requireResolver === false ? '?:' : ':' } connectionPluginCore.EdgeFieldResolver<TypeName, FieldName, "${key}">` ) dynamicConfig.push(`edgeFields: { ${edgeFields.join(', ')} }`) } let printedDynamicConfig = '' if (dynamicConfig.length > 0) { printedDynamicConfig = ` & { ${dynamicConfig.join(', ')} }` } // Add the t.connectionField (or something else if we've changed the name) b.addType( dynamicOutputMethod({ name: nexusFieldName, typeDescription: ` Adds a Relay-style connection to the type, with numerous options for configuration @see https://nexusjs.org/docs/plugins/connection `, typeDefinition: `<FieldName extends string>( fieldName: FieldName, config: connectionPluginCore.ConnectionFieldConfig<TypeName, FieldName>${printedDynamicConfig} ): void`, factory({ typeName: parentTypeName, typeDef: t, args: factoryArgs, stage, builder, wrapping }) { const [fieldName, fieldConfig] = factoryArgs as [string, ConnectionFieldConfig] const targetType = fieldConfig.type /* istanbul ignore if */ if (wrapping?.includes('List')) { throw new Error(`Cannot chain .list with connectionField (on ${parentTypeName}.${fieldName})`) } const { targetTypeName, connectionName, edgeName } = getTypeNames( fieldName, parentTypeName, fieldConfig, pluginConfig ) if (stage === 'build') { assertCorrectConfig(parentTypeName, fieldName, pluginConfig, fieldConfig) } // Add the "Connection" type to the schema if it doesn't exist already if (!b.hasType(connectionName)) { b.addType( objectType({ name: connectionName, definition(t2) { t2.list.field('edges', { type: edgeName as any, description: `https://facebook.github.io/relay/graphql/connections.htm#sec-Edge-Types`, }) t2.nonNull.field('pageInfo', { type: 'PageInfo' as any, description: `https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo`, }) if (includeNodesField) { t2.list.field('nodes', { type: targetType, description: `Flattened list of ${targetTypeName} type`, }) } if (pluginExtendConnection) { eachObj(pluginExtendConnection, (extensionFieldConfig, extensionFieldName) => { t2.field(extensionFieldName, extensionFieldConfig) }) } provideSourceAndArgs(t2, () => { if (fieldConfig.extendConnection instanceof Function) { fieldConfig.extendConnection(t2) } }) }, nonNullDefaults: fieldConfig.nonNullDefaults ?? pluginConfig.nonNullDefaults, }) ) } // Add the "Edge" type to the schema if it doesn't exist already if (!b.hasType(edgeName)) { b.addType( objectType({ name: edgeName, definition(t2) { t2.field('cursor', { type: cursorType ?? nonNull('String'), description: 'https://facebook.github.io/relay/graphql/connections.htm#sec-Cursor', }) t2.field('node', { type: targetType, description: 'https://facebook.github.io/relay/graphql/connections.htm#sec-Node', }) if (pluginExtendEdge) { eachObj(pluginExtendEdge, (val, key) => { t2.field(key, val) }) } provideArgs(t2, () => { if (fieldConfig.extendEdge instanceof Function) { fieldConfig.extendEdge(t2) } }) }, nonNullDefaults: fieldConfig.nonNullDefaults ?? pluginConfig.nonNullDefaults, }) ) } // Add the "PageInfo" type to the schema if it doesn't exist already if (!b.hasType('PageInfo')) { b.addType( objectType({ name: 'PageInfo', description: 'PageInfo cursor, as defined in https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo', definition(t2) { t2.nonNull.field('hasNextPage', { type: 'Boolean', description: `Used to indicate whether more edges exist following the set defined by the clients arguments.`, }) t2.nonNull.field('hasPreviousPage', { type: 'Boolean', description: `Used to indicate whether more edges exist prior to the set defined by the clients arguments.`, }) t2.nullable.field('startCursor', { type: 'String', description: `The cursor corresponding to the first nodes in edges. Null if the connection is empty.`, }) t2.nullable.field('endCursor', { type: 'String', description: `The cursor corresponding to the last nodes in edges. Null if the connection is empty.`, }) }, }) ) } const { disableBackwardPagination, disableForwardPagination, validateArgs = defaultValidateArgs, strictArgs = true, cursorType, } = { ...pluginConfig, ...fieldConfig, } let specArgs = {} if (disableForwardPagination !== true && disableBackwardPagination !== true) { specArgs = { ...ForwardPaginateArgs, ...BackwardPaginateArgs } } else if (disableForwardPagination !== true) { specArgs = strictArgs ? { ...ForwardOnlyStrictArgs } : { ...ForwardPaginateArgs } } else if (disableBackwardPagination !== true) { specArgs = strictArgs ? { ...BackwardOnlyStrictArgs } : { ...BackwardPaginateArgs } } // If we have additional args, let fieldAdditionalArgs = {} if (fieldConfig.additionalArgs) { if (additionalArgs && fieldConfig.inheritAdditionalArgs) { fieldAdditionalArgs = { ...additionalArgs, ...fieldConfig.additionalArgs, } } else { fieldAdditionalArgs = { ...fieldConfig.additionalArgs, } } } else if (additionalArgs) { fieldAdditionalArgs = { ...additionalArgs } } const fieldArgs = { ...fieldAdditionalArgs, ...specArgs, } let resolveFn: GraphQLFieldResolver<any, any> if (fieldConfig.resolve) { if (includeNodesField) { resolveFn = (root, args, ctx, info) => { return completeValue(fieldConfig.resolve(root, args, ctx, info), (val) => { if (val && val.nodes === undefined) { return withArgs(args, { get nodes() { return completeValue(val.edges, (edges) => edges.map((edge: any) => edge.node)) }, ...val, }) } return withArgs(args, { ...val }) }) } } else { resolveFn = fieldConfig.resolve } } else { resolveFn = makeResolveFn(pluginConfig, fieldConfig) } let wrappedConnectionName: AllNexusOutputTypeDefs | string = connectionName if (wrapping) { if (typeof fieldConfig.nullable === 'boolean') { throw new Error( '[connectionPlugin]: You cannot chain .null/.nonNull and also set the nullable in the connectionField definition.' ) } wrappedConnectionName = applyNexusWrapping(connectionName, wrapping) } else { if (fieldConfig.nullable === true) { wrappedConnectionName = nullable(wrappedConnectionName as any) } else if (fieldConfig.nullable === false) { wrappedConnectionName = nonNull(wrappedConnectionName as any) } } // Add the field to the type. t.field(fieldName, { ...nonConnectionFieldProps(fieldConfig), args: fieldArgs, type: wrappedConnectionName as any, resolve(root, args: PaginationArgs, ctx, info) { // TODO(2.0): Maybe switch the arguments around here to be consistent w/ resolver (breaking change)? validateArgs(args, info, root, ctx) return resolveFn(root, args, ctx, info) }, }) }, }) ) }, }) } // Extract all of the non-connection related field config we may want to apply for plugin purposes function nonConnectionFieldProps(fieldConfig: ConnectionFieldConfig) { const { additionalArgs, cursorFromNode, disableBackwardPagination, disableForwardPagination, extendConnection, extendEdge, inheritAdditionalArgs, nodes, pageInfoFromNodes, resolve, type, validateArgs, strictArgs, nullable, ...rest } = fieldConfig return rest } export function makeResolveFn( pluginConfig: ConnectionPluginConfig, fieldConfig: ConnectionFieldConfig ): GraphQLFieldResolver<any, any, any> { const mergedConfig = { ...pluginConfig, ...fieldConfig } return (root, args: PaginationArgs, ctx, info) => { const { nodes: nodesResolve } = fieldConfig const { decodeCursor = base64Decode, encodeCursor = base64Encode } = pluginConfig const { pageInfoFromNodes = defaultPageInfoFromNodes, cursorFromNode = defaultCursorFromNode } = mergedConfig if (!nodesResolve) { return null } const formattedArgs = { ...args } if (args.before) { formattedArgs.before = decodeCursor(args.before).replace(CURSOR_PREFIX, '') } if (args.after) { formattedArgs.after = decodeCursor(args.after).replace(CURSOR_PREFIX, '') } if (args.last && !args.before && cursorFromNode === defaultCursorFromNode) { throw new Error(`Cannot paginate backward without a "before" cursor by default.`) } // Local variable to cache the execution of fetching the nodes, // which is needed for all fields. let cachedNodes: MaybePromiseLike<Array<any>> let cachedEdges: MaybePromiseLike<{ edges: EdgeLike[] nodes: any[] }> let hasPromise = false // Get all the nodes, before any pagination slicing const resolveAllNodes = () => { if (cachedNodes !== undefined) { return cachedNodes } cachedNodes = completeValue(nodesResolve(root, formattedArgs, ctx, info) ?? null, (allNodes) => { return allNodes ? Array.from(allNodes) : allNodes }) return cachedNodes } const resolveEdgesAndNodes = () => { if (cachedEdges !== undefined) { return cachedEdges } cachedEdges = completeValue(resolveAllNodes(), (allNodes) => { if (!allNodes) { const arrPath = JSON.stringify(pathToArray(info.path)) console.warn( `You resolved null/undefined from nodes() at path ${arrPath}, this is likely an error. Return an empty array to suppress this warning.` ) return { edges: [], nodes: [] } } const resolvedEdgeList: MaybePromise<EdgeLike>[] = [] const resolvedNodeList: any[] = [] iterateNodes(allNodes, args, (maybeNode, i) => { if (isPromiseLike(maybeNode)) { hasPromise = true resolvedNodeList.push(maybeNode) resolvedEdgeList.push( maybeNode.then((node) => { return completeValue<string, any>( cursorFromNode(maybeNode, formattedArgs, ctx, info, { index: i, nodes: allNodes, }), (rawCursor) => wrapEdge(pluginConfig, fieldConfig, formattedArgs, { cursor: encodeCursor(rawCursor), node, }) ) }) ) } else { resolvedNodeList.push(maybeNode) resolvedEdgeList.push( wrapEdge(pluginConfig, fieldConfig, formattedArgs, { node: maybeNode, cursor: completeValue( cursorFromNode(maybeNode, formattedArgs, ctx, info, { index: i, nodes: allNodes, }), (rawCursor) => encodeCursor(rawCursor) ), }) ) } }) if (hasPromise) { return Promise.all([Promise.all(resolvedEdgeList), Promise.all(resolvedNodeList)]).then( ([edges, nodes]) => ({ edges, nodes }) ) } return { nodes: resolvedNodeList, // todo find type-safe way of doing this edges: resolvedEdgeList as EdgeLike[], } }) return cachedEdges } const resolvePageInfo = () => { return completeValue(resolveAllNodes(), (allNodes) => completeValue(resolveEdgesAndNodes(), ({ edges }) => completeValue( allNodes ? pageInfoFromNodes(allNodes, args, ctx, info) : { hasNextPage: false, hasPreviousPage: false, }, (basePageInfo) => ({ ...basePageInfo, startCursor: edges?.[0]?.cursor ? edges[0].cursor : null, endCursor: edges?.[edges.length - 1]?.cursor ?? null, }) ) ) ) } const connectionResult = withSource(root, formattedArgs, { get nodes() { return completeValue(resolveEdgesAndNodes(), (o) => o.nodes) }, get edges() { return completeValue(resolveEdgesAndNodes(), (o) => o.edges) }, get pageInfo() { return resolvePageInfo() }, }) if (pluginConfig.extendConnection) { Object.keys(pluginConfig.extendConnection).forEach((connectionField) => { const resolve = (fieldConfig as any)[connectionField] ?? defaultFieldResolver Object.defineProperty(connectionResult, connectionField, { value: (args: object, ctx: unknown, info: GraphQLResolveInfo) => { return resolve(root, { ...formattedArgs, ...args }, ctx, info) }, }) }) } return connectionResult } } function wrapEdge<T extends object>( pluginConfig: ConnectionPluginConfig, fieldConfig: ConnectionFieldConfig, formattedArgs: PaginationArgs, edgeParentType: T ): T { const edge = withArgs(formattedArgs, edgeParentType) if (pluginConfig.extendEdge) { Object.keys(pluginConfig.extendEdge).forEach((edgeField) => { const resolve = (fieldConfig as any).edgeFields?.[edgeField] ?? defaultFieldResolver Object.defineProperty(edge, edgeField, { value: (args: object, ctx: unknown, info: GraphQLResolveInfo) => { return resolve(edge, { ...formattedArgs, ...args }, ctx, info) }, }) }) } return edge } /** * Adds __connectionArgs to the object representing the Connection type, so it can be accessed by other fields * in the top level * * @param args * @param connectionParentType */ function withArgs<T extends object>(args: PaginationArgs, connectionParentType: T): T { Object.defineProperty(connectionParentType, '__connectionArgs', { value: args, enumerable: false, }) return connectionParentType } /** * Adds __connectionSource to the object representing the Connection type, so it can be accessed by other * fields in the top level * * @param args * @param connectionParentType */ function withSource<T extends object>(source: unknown, args: PaginationArgs, connectionParentType: T): T { Object.defineProperty(connectionParentType, '__connectionSource', { value: source, enumerable: false, }) return withArgs(args, connectionParentType) } /** Takes __connectionArgs from the source object and merges with the args provided by the */ function mergeArgs(obj: object, fieldArgs: ArgsValue<any, any>): ArgsValue<any, any> { return { ...(obj as any).__connectionArgs, ...fieldArgs } } /** * Takes a "builder", and a function which takes a builder, and ensures that all fields defined within that * function invocation are provided the __connectionArgs defined by the connection */ function provideArgs(block: ObjectDefinitionBlock<any>, fn: () => void) { const fieldDef = block.field block.field = function ( ...args: | [name: string, config: FieldOutConfig<any, string>] | [config: FieldOutConfigWithName<any, string>] ) { let config = args.length === 2 ? { name: args[0], ...args[1] } : args[0] const { resolve = defaultFieldResolver } = config fieldDef.call(this, { ...config, resolve(root, args, ctx, info) { return resolve(root, mergeArgs(root, args), ctx, info) }, }) } fn() block.field = fieldDef } function provideSourceAndArgs(block: ObjectDefinitionBlock<any>, fn: () => void) { const fieldDef = block.field block.field = function ( ...args: | [name: string, config: FieldOutConfig<any, string>] | [config: FieldOutConfigWithName<any, string>] ) { let config = args.length === 2 ? { name: args[0], ...args[1] } : args[0] const { resolve = defaultFieldResolver } = config fieldDef.call(this, { ...config, resolve(root, args, ctx, info) { return resolve(root.__connectionSource, mergeArgs(root, args), ctx, info) }, }) } fn() block.field = fieldDef } function iterateNodes(nodes: any[], args: PaginationArgs, cb: (node: any, i: number) => void) { // If we want the first N of an array of nodes, it's pretty straightforward. if (typeof args.first === 'number') { const len = Math.min(args.first, nodes.length) for (let i = 0; i < len; i++) { cb(nodes[i], i) } } else if (typeof args.last === 'number') { const len = Math.min(args.last, nodes.length) for (let i = 0; i < len; i++) { cb(nodes[i], i) } } else { // Only happens if we have a custom validateArgs that ignores first/last for (let i = 0; i < nodes.length; i++) { cb(nodes[i], i) } } } export type PaginationArgs = { first?: number | null after?: string | null last?: number | null before?: string | null } function defaultPageInfoFromNodes(nodes: any[], args: PaginationArgs) { return { hasNextPage: defaultHasNextPage(nodes, args), hasPreviousPage: defaultHasPreviousPage(nodes, args), } } function defaultHasNextPage(nodes: any[], args: PaginationArgs) { // If we're paginating forward, and we don't have an "after", we'll assume that we don't have // a previous page, otherwise we will assume we have one, unless the after cursor === "0". if (typeof args.first === 'number') { return nodes.length > args.first } // If we're paginating backward, and there are as many results as we asked for, then we'll assume // that we have a previous page if (typeof args.last === 'number') { if (args.before && args.before !== '0') { return true } return false } /* istanbul ignore next */ throw new Error('Unreachable') } /** A sensible default for determining "previous page". */ function defaultHasPreviousPage(nodes: any[], args: PaginationArgs) { // If we're paginating forward, and we don't have an "after", we'll assume that we don't have // a previous page, otherwise we will assume we have one, unless the after cursor === "0". if (typeof args.first === 'number') { if (args.after && args.after !== '0') { return true } return false } // If we're paginating backward, and there are as many results as we asked for, then we'll assume // that we have a previous page if (typeof args.last === 'number') { return nodes.length >= args.last } /* istanbul ignore next */ throw new Error('Unreachable') } const CURSOR_PREFIX = 'cursor:' // Assumes we're only paginating in one direction. function defaultCursorFromNode( node: any, args: PaginationArgs, ctx: any, info: GraphQLResolveInfo, { index, nodes }: { index: number; nodes: any[] } ) { let cursorIndex = index // If we're paginating forward, assume we're incrementing from the offset provided via "after", // e.g. [0...20] (first: 5, after: "cursor:5") -> [cursor:6, cursor:7, cursor:8, cursor:9, cursor: 10] if (typeof args.first === 'number') { if (args.after) { const offset = parseInt(args.after, 10) cursorIndex = offset + index + 1 } } // If we're paginating backward, assume we're working backward from the assumed length // e.g. [0...20] (last: 5, before: "cursor:20") -> [cursor:15, cursor:16, cursor:17, cursor:18, cursor:19] if (typeof args.last === 'number') { if (args.before) { const offset = parseInt(args.before, 10) const len = Math.min(nodes.length, args.last) cursorIndex = offset - len + index } else { /* istanbul ignore next */ throw new Error('Unreachable') } } return `${CURSOR_PREFIX}${cursorIndex}` } const getTypeNames = ( fieldName: string, parentTypeName: string, fieldConfig: ConnectionFieldConfig, pluginConfig: ConnectionPluginConfig ) => { const targetTypeName = typeof fieldConfig.type === 'string' ? fieldConfig.type : (fieldConfig.type.name as string) // If we have changed the config specific to this field, on either the connection, // edge, or page info, then we need a custom type for the connection & edge. let connectionName: string if (fieldConfig.getConnectionName) { connectionName = fieldConfig.getConnectionName(fieldName, parentTypeName) } else if (pluginConfig.getConnectionName) { connectionName = pluginConfig.getConnectionName(fieldName, parentTypeName) } else if (isConnectionFieldExtended(fieldConfig)) { connectionName = `${parentTypeName}${upperFirst(fieldName)}_Connection` } else { connectionName = `${pluginConfig.typePrefix || ''}${targetTypeName}Connection` } // If we have modified the "edge" at all, then we need let edgeName if (fieldConfig.getEdgeName) { edgeName = fieldConfig.getEdgeName(fieldName, parentTypeName) } else if (pluginConfig.getEdgeName) { edgeName = pluginConfig.getEdgeName(fieldName, parentTypeName) } else if (isEdgeFieldExtended(fieldConfig)) { edgeName = `${parentTypeName}${upperFirst(fieldName)}_Edge` } else { edgeName = `${pluginConfig.typePrefix || ''}${targetTypeName}Edge` } return { edgeName, targetTypeName, connectionName, } } const isConnectionFieldExtended = (fieldConfig: ConnectionFieldConfig) => { if (fieldConfig.extendConnection || isEdgeFieldExtended(fieldConfig)) { return true } return false } const isEdgeFieldExtended = (fieldConfig: ConnectionFieldConfig) => { if (fieldConfig.extendEdge || fieldConfig.cursorType) { return true } return false } const upperFirst = (fieldName: string) => { return fieldName.slice(0, 1).toUpperCase().concat(fieldName.slice(1)) } // Add some sanity checking beyond the normal type checks. const assertCorrectConfig = ( typeName: string, fieldName: string, pluginConfig: ConnectionPluginConfig, fieldConfig: any ) => { if (typeof fieldConfig.nodes !== 'function' && typeof fieldConfig.resolve !== 'function') { console.error( new Error(`Nexus Connection Plugin: Missing nodes or resolve property for ${typeName}.${fieldName}`) ) } eachObj(pluginConfig.extendConnection || {}, (val, key) => { if (typeof fieldConfig[key] !== 'function' && val.requireResolver !== false) { console.error( new Error( `Nexus Connection Plugin: Missing ${key} resolver property for ${typeName}.${fieldName}. Set requireResolver to "false" on the field config if you do not need a resolver.` ) ) } }) eachObj(pluginConfig.extendEdge || {}, (val, key) => { if (typeof fieldConfig.edgeFields?.[key] !== 'function' && val.requireResolver !== false) { console.error( new Error( `Nexus Connection Plugin: Missing edgeFields.${key} resolver property for ${typeName}.${fieldName}. Set requireResolver to "false" on the edge field config if you do not need a resolver.` ) ) } }) } function defaultValidateArgs(args: Record<string, any> = {}, info: GraphQLResolveInfo) { if (!(args.first || args.first === 0) && !(args.last || args.last === 0)) { throw new Error( `The ${info.parentType}.${info.fieldName} connection field requires a "first" or "last" argument` ) } if (args.first && args.last) { throw new Error( `The ${info.parentType}.${info.fieldName} connection field requires a "first" or "last" argument, not both` ) } if (args.first && args.before) { throw new Error( `The ${info.parentType}.${info.fieldName} connection field does not allow a "before" argument with "first"` ) } if (args.last && args.after) { throw new Error( `The ${info.parentType}.${info.fieldName} connection field does not allow a "last" argument with "after"` ) } } // Provided for use if you create a custom implementation and want to call the original. connectionPlugin.defaultCursorFromNode = defaultCursorFromNode connectionPlugin.defaultValidateArgs = defaultValidateArgs connectionPlugin.defaultHasPreviousPage = defaultHasPreviousPage connectionPlugin.defaultHasNextPage = defaultHasNextPage connectionPlugin.base64Encode = base64Encode connectionPlugin.base64Decode = base64Decode connectionPlugin.CURSOR_PREFIX = CURSOR_PREFIX