diff --git a/packages/graphql/src/schema/buildMutationInputType.ts b/packages/graphql/src/schema/buildMutationInputType.ts index 6e026e7342..44e627b8ab 100644 --- a/packages/graphql/src/schema/buildMutationInputType.ts +++ b/packages/graphql/src/schema/buildMutationInputType.ts @@ -107,20 +107,20 @@ export function buildMutationInputType({ type = new GraphQLList(withNullableType({ type, field, forceNullable, parentIsLocalized })) return { ...inputObjectTypeConfig, - [field.name]: { type }, + [formatName(field.name)]: { type }, } }, blocks: (inputObjectTypeConfig: InputObjectTypeConfig, field: BlocksField) => ({ ...inputObjectTypeConfig, - [field.name]: { type: GraphQLJSON }, + [formatName(field.name)]: { type: GraphQLJSON }, }), checkbox: (inputObjectTypeConfig: InputObjectTypeConfig, field: CheckboxField) => ({ ...inputObjectTypeConfig, - [field.name]: { type: GraphQLBoolean }, + [formatName(field.name)]: { type: GraphQLBoolean }, }), code: (inputObjectTypeConfig: InputObjectTypeConfig, field: CodeField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), }, }), @@ -134,13 +134,13 @@ export function buildMutationInputType({ }, inputObjectTypeConfig), date: (inputObjectTypeConfig: InputObjectTypeConfig, field: DateField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), }, }), email: (inputObjectTypeConfig: InputObjectTypeConfig, field: EmailField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), }, }), @@ -165,12 +165,12 @@ export function buildMutationInputType({ } return { ...inputObjectTypeConfig, - [field.name]: { type }, + [formatName(field.name)]: { type }, } }, json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }), }, }), @@ -178,7 +178,7 @@ export function buildMutationInputType({ const type = field.name === 'id' ? GraphQLInt : GraphQLFloat return { ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: field.hasMany === true ? new GraphQLList(type) : type, field, @@ -190,7 +190,7 @@ export function buildMutationInputType({ }, point: (inputObjectTypeConfig: InputObjectTypeConfig, field: PointField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: new GraphQLList(GraphQLFloat), field, @@ -201,7 +201,7 @@ export function buildMutationInputType({ }), radio: (inputObjectTypeConfig: InputObjectTypeConfig, field: RadioField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), }, }), @@ -247,12 +247,12 @@ export function buildMutationInputType({ return { ...inputObjectTypeConfig, - [field.name]: { type: field.hasMany ? new GraphQLList(type) : type }, + [formatName(field.name)]: { type: field.hasMany ? new GraphQLList(type) : type }, } }, richText: (inputObjectTypeConfig: InputObjectTypeConfig, field: RichTextField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }), }, }), @@ -292,7 +292,7 @@ export function buildMutationInputType({ return { ...inputObjectTypeConfig, - [field.name]: { type }, + [formatName(field.name)]: { type }, } }, tabs: (inputObjectTypeConfig: InputObjectTypeConfig, field: TabsField) => { @@ -336,7 +336,7 @@ export function buildMutationInputType({ }, text: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString, field, @@ -347,7 +347,7 @@ export function buildMutationInputType({ }), textarea: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextareaField) => ({ ...inputObjectTypeConfig, - [field.name]: { + [formatName(field.name)]: { type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), }, }), @@ -393,7 +393,7 @@ export function buildMutationInputType({ return { ...inputObjectTypeConfig, - [field.name]: { type: field.hasMany ? new GraphQLList(type) : type }, + [formatName(field.name)]: { type: field.hasMany ? new GraphQLList(type) : type }, } }, } diff --git a/packages/graphql/src/schema/buildObjectType.ts b/packages/graphql/src/schema/buildObjectType.ts index ba8903cf9e..0ce88092e7 100644 --- a/packages/graphql/src/schema/buildObjectType.ts +++ b/packages/graphql/src/schema/buildObjectType.ts @@ -1,58 +1,12 @@ -import type { GraphQLFieldConfig, GraphQLType } from 'graphql' -import type { - ArrayField, - BlocksField, - CheckboxField, - CodeField, - CollapsibleField, - DateField, - EmailField, - Field, - GraphQLInfo, - GroupField, - JoinField, - JSONField, - NumberField, - PointField, - RadioField, - RelationshipField, - RichTextAdapter, - RichTextField, - RowField, - SanitizedConfig, - SelectField, - TabsField, - TextareaField, - TextField, - UploadField, -} from 'payload' +import type { GraphQLFieldConfig } from 'graphql' +import type { Field, GraphQLInfo, SanitizedConfig } from 'payload' -import { - GraphQLBoolean, - GraphQLEnumType, - GraphQLFloat, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLString, - GraphQLUnionType, -} from 'graphql' -import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars' -import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload' -import { tabHasName } from 'payload/shared' +import { GraphQLObjectType } from 'graphql' -import type { Context } from '../resolvers/types.js' - -import { GraphQLJSON } from '../packages/graphql-type-json/index.js' -import { combineParentName } from '../utilities/combineParentName.js' -import { formatName } from '../utilities/formatName.js' -import { formatOptions } from '../utilities/formatOptions.js' -import { isFieldNullable } from './isFieldNullable.js' -import { withNullableType } from './withNullableType.js' +import { fieldToSchemaMap } from './fieldToSchemaMap.js' export type ObjectTypeConfig = { - [path: string]: GraphQLFieldConfig + [path: string]: GraphQLFieldConfig } type Args = { @@ -76,867 +30,6 @@ export function buildObjectType({ parentIsLocalized, parentName, }: Args): GraphQLObjectType { - const fieldToSchemaMap = { - array: (objectTypeConfig: ObjectTypeConfig, field: ArrayField) => { - const interfaceName = - field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) - - if (!graphqlResult.types.arrayTypes[interfaceName]) { - const objectType = buildObjectType({ - name: interfaceName, - config, - fields: field.fields, - forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), - graphqlResult, - parentIsLocalized: field.localized || parentIsLocalized, - parentName: interfaceName, - }) - - if (Object.keys(objectType.getFields()).length) { - graphqlResult.types.arrayTypes[interfaceName] = objectType - } - } - - if (!graphqlResult.types.arrayTypes[interfaceName]) { - return objectTypeConfig - } - - const arrayType = new GraphQLList( - new GraphQLNonNull(graphqlResult.types.arrayTypes[interfaceName]), - ) - - return { - ...objectTypeConfig, - [field.name]: { type: withNullableType({ type: arrayType, field, parentIsLocalized }) }, - } - }, - blocks: (objectTypeConfig: ObjectTypeConfig, field: BlocksField) => { - const blockTypes: GraphQLObjectType[] = ( - field.blockReferences ?? field.blocks - ).reduce((acc, _block) => { - const blockSlug = typeof _block === 'string' ? _block : _block.slug - if (!graphqlResult.types.blockTypes[blockSlug]) { - // TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks - const block = - typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block - - const interfaceName = - block?.interfaceName || block?.graphQL?.singularName || toWords(block.slug, true) - - const objectType = buildObjectType({ - name: interfaceName, - config, - fields: [ - ...block.fields, - { - name: 'blockType', - type: 'text', - }, - ], - forceNullable, - graphqlResult, - parentIsLocalized, - parentName: interfaceName, - }) - - if (Object.keys(objectType.getFields()).length) { - graphqlResult.types.blockTypes[block.slug] = objectType - } - } - - if (graphqlResult.types.blockTypes[blockSlug]) { - acc.push(graphqlResult.types.blockTypes[blockSlug]) - } - - return acc - }, []) - - if (blockTypes.length === 0) { - return objectTypeConfig - } - - const fullName = combineParentName(parentName, toWords(field.name, true)) - - const type = new GraphQLList( - new GraphQLNonNull( - new GraphQLUnionType({ - name: fullName, - resolveType: (data) => graphqlResult.types.blockTypes[data.blockType].name, - types: blockTypes, - }), - ), - ) - - return { - ...objectTypeConfig, - [field.name]: { type: withNullableType({ type, field, parentIsLocalized }) }, - } - }, - checkbox: (objectTypeConfig: ObjectTypeConfig, field: CheckboxField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ type: GraphQLBoolean, field, forceNullable, parentIsLocalized }), - }, - }), - code: (objectTypeConfig: ObjectTypeConfig, field: CodeField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), - }, - }), - collapsible: (objectTypeConfig: ObjectTypeConfig, field: CollapsibleField) => - field.fields.reduce((objectTypeConfigWithCollapsibleFields, subField) => { - const addSubField = fieldToSchemaMap[subField.type] - if (addSubField) { - return addSubField(objectTypeConfigWithCollapsibleFields, subField) - } - return objectTypeConfigWithCollapsibleFields - }, objectTypeConfig), - date: (objectTypeConfig: ObjectTypeConfig, field: DateField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ type: DateTimeResolver, field, forceNullable, parentIsLocalized }), - }, - }), - email: (objectTypeConfig: ObjectTypeConfig, field: EmailField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ - type: EmailAddressResolver, - field, - forceNullable, - parentIsLocalized, - }), - }, - }), - group: (objectTypeConfig: ObjectTypeConfig, field: GroupField) => { - const interfaceName = - field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) - - if (!graphqlResult.types.groupTypes[interfaceName]) { - const objectType = buildObjectType({ - name: interfaceName, - config, - fields: field.fields, - forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), - graphqlResult, - parentIsLocalized: field.localized || parentIsLocalized, - parentName: interfaceName, - }) - - if (Object.keys(objectType.getFields()).length) { - graphqlResult.types.groupTypes[interfaceName] = objectType - } - } - - if (!graphqlResult.types.groupTypes[interfaceName]) { - return objectTypeConfig - } - - return { - ...objectTypeConfig, - [field.name]: { - type: graphqlResult.types.groupTypes[interfaceName], - resolve: (parent, args, context: Context) => { - return { - ...parent[field.name], - _id: parent._id ?? parent.id, - } - }, - }, - } - }, - join: (objectTypeConfig: ObjectTypeConfig, field: JoinField) => { - const joinName = combineParentName(parentName, toWords(field.name, true)) - - const joinType = { - type: new GraphQLObjectType({ - name: joinName, - fields: { - docs: { - type: Array.isArray(field.collection) - ? GraphQLJSON - : new GraphQLList(graphqlResult.collections[field.collection].graphQL.type), - }, - hasNextPage: { type: GraphQLBoolean }, - }, - }), - args: { - limit: { - type: GraphQLInt, - }, - page: { - type: GraphQLInt, - }, - sort: { - type: GraphQLString, - }, - where: { - type: Array.isArray(field.collection) - ? GraphQLJSON - : graphqlResult.collections[field.collection].graphQL.whereInputType, - }, - }, - extensions: { - complexity: - typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10, - }, - async resolve(parent, args, context: Context) { - const { collection } = field - const { limit, page, sort, where } = args - const { req } = context - - const fullWhere = combineQueries(where, { - [field.on]: { equals: parent._id ?? parent.id }, - }) - - if (Array.isArray(collection)) { - throw new Error('GraphQL with array of join.field.collection is not implemented') - } - - return await req.payload.find({ - collection, - depth: 0, - fallbackLocale: req.fallbackLocale, - limit, - locale: req.locale, - overrideAccess: false, - page, - req, - sort, - where: fullWhere, - }) - }, - } - - return { - ...objectTypeConfig, - [field.name]: joinType, - } - }, - json: (objectTypeConfig: ObjectTypeConfig, field: JSONField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }), - }, - }), - number: (objectTypeConfig: ObjectTypeConfig, field: NumberField) => { - const type = field?.name === 'id' ? GraphQLInt : GraphQLFloat - return { - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ - type: field?.hasMany === true ? new GraphQLList(type) : type, - field, - forceNullable, - parentIsLocalized, - }), - }, - } - }, - point: (objectTypeConfig: ObjectTypeConfig, field: PointField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ - type: new GraphQLList(new GraphQLNonNull(GraphQLFloat)), - field, - forceNullable, - parentIsLocalized, - }), - }, - }), - radio: (objectTypeConfig: ObjectTypeConfig, field: RadioField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ - type: new GraphQLEnumType({ - name: combineParentName(parentName, field.name), - values: formatOptions(field), - }), - field, - forceNullable, - parentIsLocalized, - }), - }, - }), - relationship: (objectTypeConfig: ObjectTypeConfig, field: RelationshipField) => { - const { relationTo } = field - const isRelatedToManyCollections = Array.isArray(relationTo) - const hasManyValues = field.hasMany - const relationshipName = combineParentName(parentName, toWords(field.name, true)) - - let type - let relationToType = null - - const graphQLCollections = config.collections.filter( - (collectionConfig) => collectionConfig.graphQL !== false, - ) - - if (Array.isArray(relationTo)) { - relationToType = new GraphQLEnumType({ - name: `${relationshipName}_RelationTo`, - values: relationTo - .filter((relation) => - graphQLCollections.some((collection) => collection.slug === relation), - ) - .reduce( - (relations, relation) => ({ - ...relations, - [formatName(relation)]: { - value: relation, - }, - }), - {}, - ), - }) - - // Only pass collections that are GraphQL enabled - const types = relationTo - .filter((relation) => - graphQLCollections.some((collection) => collection.slug === relation), - ) - .map((relation) => graphqlResult.collections[relation]?.graphQL.type) - - type = new GraphQLObjectType({ - name: `${relationshipName}_Relationship`, - fields: { - relationTo: { - type: relationToType, - }, - value: { - type: new GraphQLUnionType({ - name: relationshipName, - resolveType(data) { - return graphqlResult.collections[data.collection].graphQL.type.name - }, - types, - }), - }, - }, - }) - } else { - ;({ type } = graphqlResult.collections[relationTo].graphQL) - } - - // If the relationshipType is undefined at this point, - // it can be assumed that this blockType can have a relationship - // to itself. Therefore, we set the relationshipType equal to the blockType - // that is currently being created. - - type = type || newlyCreatedBlockType - - const relationshipArgs: { - draft?: unknown - fallbackLocale?: unknown - limit?: unknown - locale?: unknown - page?: unknown - where?: unknown - } = {} - - const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]) - .filter((relation) => graphQLCollections.some((collection) => collection.slug === relation)) - .some((relation) => graphqlResult.collections[relation].config.versions?.drafts) - - if (relationsUseDrafts) { - relationshipArgs.draft = { - type: GraphQLBoolean, - } - } - - if (config.localization) { - relationshipArgs.locale = { - type: graphqlResult.types.localeInputType, - } - - relationshipArgs.fallbackLocale = { - type: graphqlResult.types.fallbackLocaleInputType, - } - } - - const relationship = { - type: withNullableType({ - type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type, - field, - forceNullable, - parentIsLocalized, - }), - args: relationshipArgs, - extensions: { - complexity: - typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10, - }, - async resolve(parent, args, context: Context) { - const value = parent[field.name] - const locale = args.locale || context.req.locale - const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale - let relatedCollectionSlug = field.relationTo - const draft = Boolean(args.draft ?? context.req.query?.draft) - - if (hasManyValues) { - const results = [] - const resultPromises = [] - - const createPopulationPromise = async (relatedDoc, i) => { - let id = relatedDoc - let collectionSlug = field.relationTo - const isValidGraphQLCollection = isRelatedToManyCollections - ? graphQLCollections.some((collection) => collectionSlug.includes(collection.slug)) - : graphQLCollections.some((collection) => collectionSlug === collection.slug) - - if (isValidGraphQLCollection) { - if (isRelatedToManyCollections) { - collectionSlug = relatedDoc.relationTo - id = relatedDoc.value - } - - const result = await context.req.payloadDataLoader.load( - createDataloaderCacheKey({ - collectionSlug: collectionSlug as string, - currentDepth: 0, - depth: 0, - docID: id, - draft, - fallbackLocale, - locale, - overrideAccess: false, - showHiddenFields: false, - transactionID: context.req.transactionID, - }), - ) - - if (result) { - if (isRelatedToManyCollections) { - results[i] = { - relationTo: collectionSlug, - value: { - ...result, - collection: collectionSlug, - }, - } - } else { - results[i] = result - } - } - } - } - - if (value) { - value.forEach((relatedDoc, i) => { - resultPromises.push(createPopulationPromise(relatedDoc, i)) - }) - } - - await Promise.all(resultPromises) - return results - } - - let id = value - if (isRelatedToManyCollections && value) { - id = value.value - relatedCollectionSlug = value.relationTo - } - - if (id) { - if ( - graphQLCollections.some((collection) => collection.slug === relatedCollectionSlug) - ) { - const relatedDocument = await context.req.payloadDataLoader.load( - createDataloaderCacheKey({ - collectionSlug: relatedCollectionSlug as string, - currentDepth: 0, - depth: 0, - docID: id, - draft, - fallbackLocale, - locale, - overrideAccess: false, - showHiddenFields: false, - transactionID: context.req.transactionID, - }), - ) - - if (relatedDocument) { - if (isRelatedToManyCollections) { - return { - relationTo: relatedCollectionSlug, - value: { - ...relatedDocument, - collection: relatedCollectionSlug, - }, - } - } - - return relatedDocument - } - } - - return null - } - - return null - }, - } - - return { - ...objectTypeConfig, - [field.name]: relationship, - } - }, - richText: (objectTypeConfig: ObjectTypeConfig, field: RichTextField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }), - args: { - depth: { - type: GraphQLInt, - }, - }, - async resolve(parent, args, context: Context) { - let depth = config.defaultDepth - if (typeof args.depth !== 'undefined') { - depth = args.depth - } - if (!field?.editor) { - throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor - } - - if (typeof field?.editor === 'function') { - throw new Error('Attempted to access unsanitized rich text editor.') - } - - const editor: RichTextAdapter = field?.editor - - // RichText fields have their own depth argument in GraphQL. - // This is why the populationPromise (which populates richtext fields like uploads and relationships) - // is run here again, with the provided depth. - // In the graphql find.ts resolver, the depth is then hard-coded to 0. - // Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise. - if (editor?.graphQLPopulationPromises) { - const fieldPromises = [] - const populationPromises = [] - const populateDepth = - field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth - - editor?.graphQLPopulationPromises({ - context, - depth: populateDepth, - draft: args.draft, - field, - fieldPromises, - findMany: false, - flattenLocales: false, - overrideAccess: false, - parentIsLocalized, - populationPromises, - req: context.req, - showHiddenFields: false, - siblingDoc: parent, - }) - await Promise.all(fieldPromises) - await Promise.all(populationPromises) - } - - return parent[field.name] - }, - }, - }), - row: (objectTypeConfig: ObjectTypeConfig, field: RowField) => - field.fields.reduce((objectTypeConfigWithRowFields, subField) => { - const addSubField = fieldToSchemaMap[subField.type] - if (addSubField) { - return addSubField(objectTypeConfigWithRowFields, subField) - } - return objectTypeConfigWithRowFields - }, objectTypeConfig), - select: (objectTypeConfig: ObjectTypeConfig, field: SelectField) => { - const fullName = combineParentName(parentName, field.name) - - let type: GraphQLType = new GraphQLEnumType({ - name: fullName, - values: formatOptions(field), - }) - - type = field.hasMany ? new GraphQLList(new GraphQLNonNull(type)) : type - type = withNullableType({ type, field, forceNullable, parentIsLocalized }) - - return { - ...objectTypeConfig, - [field.name]: { type }, - } - }, - tabs: (objectTypeConfig: ObjectTypeConfig, field: TabsField) => - field.tabs.reduce((tabSchema, tab) => { - if (tabHasName(tab)) { - const interfaceName = - tab?.interfaceName || combineParentName(parentName, toWords(tab.name, true)) - - if (!graphqlResult.types.groupTypes[interfaceName]) { - const objectType = buildObjectType({ - name: interfaceName, - config, - fields: tab.fields, - forceNullable, - graphqlResult, - parentIsLocalized: tab.localized || parentIsLocalized, - parentName: interfaceName, - }) - - if (Object.keys(objectType.getFields()).length) { - graphqlResult.types.groupTypes[interfaceName] = objectType - } - } - - if (!graphqlResult.types.groupTypes[interfaceName]) { - return tabSchema - } - - return { - ...tabSchema, - [tab.name]: { - type: graphqlResult.types.groupTypes[interfaceName], - resolve(parent, args, context: Context) { - return { - ...parent[tab.name], - _id: parent._id ?? parent.id, - } - }, - }, - } - } - - return { - ...tabSchema, - ...tab.fields.reduce((subFieldSchema, subField) => { - const addSubField = fieldToSchemaMap[subField.type] - if (addSubField) { - return addSubField(subFieldSchema, subField) - } - return subFieldSchema - }, tabSchema), - } - }, objectTypeConfig), - text: (objectTypeConfig: ObjectTypeConfig, field: TextField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ - type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString, - field, - forceNullable, - parentIsLocalized, - }), - }, - }), - textarea: (objectTypeConfig: ObjectTypeConfig, field: TextareaField) => ({ - ...objectTypeConfig, - [field.name]: { - type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), - }, - }), - upload: (objectTypeConfig: ObjectTypeConfig, field: UploadField) => { - const { relationTo } = field - const isRelatedToManyCollections = Array.isArray(relationTo) - const hasManyValues = field.hasMany - const relationshipName = combineParentName(parentName, toWords(field.name, true)) - - let type - let relationToType = null - - if (Array.isArray(relationTo)) { - relationToType = new GraphQLEnumType({ - name: `${relationshipName}_RelationTo`, - values: relationTo.reduce( - (relations, relation) => ({ - ...relations, - [formatName(relation)]: { - value: relation, - }, - }), - {}, - ), - }) - - const types = relationTo.map((relation) => graphqlResult.collections[relation].graphQL.type) - - type = new GraphQLObjectType({ - name: `${relationshipName}_Relationship`, - fields: { - relationTo: { - type: relationToType, - }, - value: { - type: new GraphQLUnionType({ - name: relationshipName, - resolveType(data) { - return graphqlResult.collections[data.collection].graphQL.type.name - }, - types, - }), - }, - }, - }) - } else { - ;({ type } = graphqlResult.collections[relationTo].graphQL) - } - - // If the relationshipType is undefined at this point, - // it can be assumed that this blockType can have a relationship - // to itself. Therefore, we set the relationshipType equal to the blockType - // that is currently being created. - - type = type || newlyCreatedBlockType - - const relationshipArgs: { - draft?: unknown - fallbackLocale?: unknown - limit?: unknown - locale?: unknown - page?: unknown - where?: unknown - } = {} - - const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]).some( - (relation) => graphqlResult.collections[relation].config.versions?.drafts, - ) - - if (relationsUseDrafts) { - relationshipArgs.draft = { - type: GraphQLBoolean, - } - } - - if (config.localization) { - relationshipArgs.locale = { - type: graphqlResult.types.localeInputType, - } - - relationshipArgs.fallbackLocale = { - type: graphqlResult.types.fallbackLocaleInputType, - } - } - - const relationship = { - type: withNullableType({ - type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type, - field, - forceNullable, - parentIsLocalized, - }), - args: relationshipArgs, - extensions: { - complexity: - typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10, - }, - async resolve(parent, args, context: Context) { - const value = parent[field.name] - const locale = args.locale || context.req.locale - const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale - let relatedCollectionSlug = field.relationTo - const draft = Boolean(args.draft ?? context.req.query?.draft) - - if (hasManyValues) { - const results = [] - const resultPromises = [] - - const createPopulationPromise = async (relatedDoc, i) => { - let id = relatedDoc - let collectionSlug = field.relationTo - - if (isRelatedToManyCollections) { - collectionSlug = relatedDoc.relationTo - id = relatedDoc.value - } - - const result = await context.req.payloadDataLoader.load( - createDataloaderCacheKey({ - collectionSlug, - currentDepth: 0, - depth: 0, - docID: id, - draft, - fallbackLocale, - locale, - overrideAccess: false, - showHiddenFields: false, - transactionID: context.req.transactionID, - }), - ) - - if (result) { - if (isRelatedToManyCollections) { - results[i] = { - relationTo: collectionSlug, - value: { - ...result, - collection: collectionSlug, - }, - } - } else { - results[i] = result - } - } - } - - if (value) { - value.forEach((relatedDoc, i) => { - resultPromises.push(createPopulationPromise(relatedDoc, i)) - }) - } - - await Promise.all(resultPromises) - return results - } - - let id = value - if (isRelatedToManyCollections && value) { - id = value.value - relatedCollectionSlug = value.relationTo - } - - if (id) { - const relatedDocument = await context.req.payloadDataLoader.load( - createDataloaderCacheKey({ - collectionSlug: relatedCollectionSlug, - currentDepth: 0, - depth: 0, - docID: id, - draft, - fallbackLocale, - locale, - overrideAccess: false, - showHiddenFields: false, - transactionID: context.req.transactionID, - }), - ) - - if (relatedDocument) { - if (isRelatedToManyCollections) { - return { - relationTo: relatedCollectionSlug, - value: { - ...relatedDocument, - collection: relatedCollectionSlug, - }, - } - } - - return relatedDocument - } - - return null - } - - return null - }, - } - - return { - ...objectTypeConfig, - [field.name]: relationship, - } - }, - } - const objectSchema = { name, fields: () => @@ -949,7 +42,16 @@ export function buildObjectType({ return { ...objectTypeConfig, - ...fieldSchema(objectTypeConfig, field), + ...fieldSchema({ + config, + field, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig, + parentIsLocalized, + parentName, + }), } }, baseFields), } diff --git a/packages/graphql/src/schema/buildPoliciesType.ts b/packages/graphql/src/schema/buildPoliciesType.ts index e5757937f1..0bbb9d0d17 100644 --- a/packages/graphql/src/schema/buildPoliciesType.ts +++ b/packages/graphql/src/schema/buildPoliciesType.ts @@ -60,7 +60,7 @@ const buildFields = (label, fieldsToBuild) => return { ...builtFields, - [field.name]: { + [formatName(field.name)]: { type: new GraphQLObjectType({ name: `${label}_${fieldName}`, fields: objectTypeFields, diff --git a/packages/graphql/src/schema/fieldToSchemaMap.ts b/packages/graphql/src/schema/fieldToSchemaMap.ts new file mode 100644 index 0000000000..d268835940 --- /dev/null +++ b/packages/graphql/src/schema/fieldToSchemaMap.ts @@ -0,0 +1,1086 @@ +import type { GraphQLArgumentConfig, GraphQLFieldConfig, GraphQLOutputType } from 'graphql' +import type { + ArrayField, + BlocksField, + CheckboxField, + CodeField, + CollapsibleField, + DateField, + EmailField, + Field, + GraphQLInfo, + GroupField, + JoinField, + JSONField, + NumberField, + PointField, + RadioField, + RelationshipField, + RichTextAdapter, + RichTextField, + RowField, + SanitizedConfig, + SelectField, + TabsField, + TextareaField, + TextField, + UploadField, +} from 'payload' + +import { + GraphQLBoolean, + GraphQLEnumType, + GraphQLFloat, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLString, + GraphQLUnionType, +} from 'graphql' +import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars' +import { combineQueries, createDataloaderCacheKey, MissingEditorProp, toWords } from 'payload' +import { tabHasName } from 'payload/shared' + +import type { Context } from '../resolvers/types.js' + +import { GraphQLJSON } from '../packages/graphql-type-json/index.js' +import { combineParentName } from '../utilities/combineParentName.js' +import { formatName } from '../utilities/formatName.js' +import { formatOptions } from '../utilities/formatOptions.js' +import { buildObjectType, type ObjectTypeConfig } from './buildObjectType.js' +import { isFieldNullable } from './isFieldNullable.js' +import { withNullableType } from './withNullableType.js' + +function formattedNameResolver({ + field, + ...rest +}: { field: Field } & GraphQLFieldConfig): GraphQLFieldConfig { + if ('name' in field) { + if (formatName(field.name) !== field.name) { + return { + ...rest, + resolve: (parent) => parent[field.name], + } + } + } + return rest +} + +type SharedArgs = { + config: SanitizedConfig + forceNullable?: boolean + graphqlResult: GraphQLInfo + newlyCreatedBlockType: GraphQLObjectType + objectTypeConfig: ObjectTypeConfig + parentIsLocalized?: boolean + parentName: string +} + +type GenericFieldToSchemaMap = (args: { field: Field } & SharedArgs) => ObjectTypeConfig + +type FieldToSchemaMap = { + array: (args: { field: ArrayField } & SharedArgs) => ObjectTypeConfig + blocks: (args: { field: BlocksField } & SharedArgs) => ObjectTypeConfig + checkbox: (args: { field: CheckboxField } & SharedArgs) => ObjectTypeConfig + code: (args: { field: CodeField } & SharedArgs) => ObjectTypeConfig + collapsible: (args: { field: CollapsibleField } & SharedArgs) => ObjectTypeConfig + date: (args: { field: DateField } & SharedArgs) => ObjectTypeConfig + email: (args: { field: EmailField } & SharedArgs) => ObjectTypeConfig + group: (args: { field: GroupField } & SharedArgs) => ObjectTypeConfig + join: (args: { field: JoinField } & SharedArgs) => ObjectTypeConfig + json: (args: { field: JSONField } & SharedArgs) => ObjectTypeConfig + number: (args: { field: NumberField } & SharedArgs) => ObjectTypeConfig + point: (args: { field: PointField } & SharedArgs) => ObjectTypeConfig + radio: (args: { field: RadioField } & SharedArgs) => ObjectTypeConfig + relationship: (args: { field: RelationshipField } & SharedArgs) => ObjectTypeConfig + richText: (args: { field: RichTextField } & SharedArgs) => ObjectTypeConfig + row: (args: { field: RowField } & SharedArgs) => ObjectTypeConfig + select: (args: { field: SelectField } & SharedArgs) => ObjectTypeConfig + tabs: (args: { field: TabsField } & SharedArgs) => ObjectTypeConfig + text: (args: { field: TextField } & SharedArgs) => ObjectTypeConfig + textarea: (args: { field: TextareaField } & SharedArgs) => ObjectTypeConfig + upload: (args: { field: UploadField } & SharedArgs) => ObjectTypeConfig +} + +export const fieldToSchemaMap: FieldToSchemaMap = { + array: ({ + config, + field, + forceNullable, + graphqlResult, + objectTypeConfig, + parentIsLocalized, + parentName, + }) => { + const interfaceName = + field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) + + if (!graphqlResult.types.arrayTypes[interfaceName]) { + const objectType = buildObjectType({ + name: interfaceName, + config, + fields: field.fields, + forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), + graphqlResult, + parentIsLocalized: field.localized || parentIsLocalized, + parentName: interfaceName, + }) + + if (Object.keys(objectType.getFields()).length) { + graphqlResult.types.arrayTypes[interfaceName] = objectType + } + } + + if (!graphqlResult.types.arrayTypes[interfaceName]) { + return objectTypeConfig + } + + const arrayType = new GraphQLList( + new GraphQLNonNull(graphqlResult.types.arrayTypes[interfaceName]), + ) + + return { + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ type: arrayType, field, parentIsLocalized }) as GraphQLOutputType, + field, + }), + } + }, + blocks: ({ + config, + field, + forceNullable, + graphqlResult, + objectTypeConfig, + parentIsLocalized, + parentName, + }) => { + const blockTypes: GraphQLObjectType[] = ( + field.blockReferences ?? field.blocks + ).reduce((acc, _block) => { + const blockSlug = typeof _block === 'string' ? _block : _block.slug + if (!graphqlResult.types.blockTypes[blockSlug]) { + // TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks + const block = + typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block + + const interfaceName = + block?.interfaceName || block?.graphQL?.singularName || toWords(block.slug, true) + + const objectType = buildObjectType({ + name: interfaceName, + config, + fields: [ + ...block.fields, + { + name: 'blockType', + type: 'text', + }, + ], + forceNullable, + graphqlResult, + parentIsLocalized, + parentName: interfaceName, + }) + + if (Object.keys(objectType.getFields()).length) { + graphqlResult.types.blockTypes[block.slug] = objectType + } + } + + if (graphqlResult.types.blockTypes[blockSlug]) { + acc.push(graphqlResult.types.blockTypes[blockSlug]) + } + + return acc + }, []) + + if (blockTypes.length === 0) { + return objectTypeConfig + } + + const fullName = combineParentName(parentName, toWords(field.name, true)) + + const type = new GraphQLList( + new GraphQLNonNull( + new GraphQLUnionType({ + name: fullName, + resolveType: (data) => graphqlResult.types.blockTypes[data.blockType].name, + types: blockTypes, + }), + ), + ) as GraphQLOutputType + + return { + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ type, field, parentIsLocalized }) as GraphQLOutputType, + field, + }), + } + }, + checkbox: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: GraphQLBoolean, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + code: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: GraphQLString, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + collapsible: ({ + config, + field, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig, + parentIsLocalized, + parentName, + }) => + field.fields.reduce((objectTypeConfigWithCollapsibleFields, subField) => { + const addSubField: GenericFieldToSchemaMap = fieldToSchemaMap[subField.type] + if (addSubField) { + return addSubField({ + config, + field: subField, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig: objectTypeConfigWithCollapsibleFields, + parentIsLocalized, + parentName, + }) + } + return objectTypeConfigWithCollapsibleFields + }, objectTypeConfig), + date: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: DateTimeResolver, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + email: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: EmailAddressResolver, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + group: ({ + config, + field, + forceNullable, + graphqlResult, + objectTypeConfig, + parentIsLocalized, + parentName, + }) => { + const interfaceName = + field?.interfaceName || combineParentName(parentName, toWords(field.name, true)) + + if (!graphqlResult.types.groupTypes[interfaceName]) { + const objectType = buildObjectType({ + name: interfaceName, + config, + fields: field.fields, + forceNullable: isFieldNullable({ field, forceNullable, parentIsLocalized }), + graphqlResult, + parentIsLocalized: field.localized || parentIsLocalized, + parentName: interfaceName, + }) + + if (Object.keys(objectType.getFields()).length) { + graphqlResult.types.groupTypes[interfaceName] = objectType + } + } + + if (!graphqlResult.types.groupTypes[interfaceName]) { + return objectTypeConfig + } + + return { + ...objectTypeConfig, + [formatName(field.name)]: { + type: graphqlResult.types.groupTypes[interfaceName], + resolve: (parent, args, context: Context) => { + return { + ...parent[field.name], + _id: parent._id ?? parent.id, + } + }, + }, + } + }, + join: ({ field, graphqlResult, objectTypeConfig, parentName }) => { + const joinName = combineParentName(parentName, toWords(field.name, true)) + + const joinType = { + type: new GraphQLObjectType({ + name: joinName, + fields: { + docs: { + type: Array.isArray(field.collection) + ? GraphQLJSON + : new GraphQLList(graphqlResult.collections[field.collection].graphQL.type), + }, + hasNextPage: { type: GraphQLBoolean }, + }, + }), + args: { + limit: { + type: GraphQLInt, + }, + page: { + type: GraphQLInt, + }, + sort: { + type: GraphQLString, + }, + where: { + type: Array.isArray(field.collection) + ? GraphQLJSON + : graphqlResult.collections[field.collection].graphQL.whereInputType, + }, + }, + extensions: { + complexity: typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10, + }, + async resolve(parent, args, context: Context) { + const { collection } = field + const { limit, page, sort, where } = args + const { req } = context + + const fullWhere = combineQueries(where, { + [field.on]: { equals: parent._id ?? parent.id }, + }) + + if (Array.isArray(collection)) { + throw new Error('GraphQL with array of join.field.collection is not implemented') + } + + return await req.payload.find({ + collection, + depth: 0, + fallbackLocale: req.fallbackLocale, + limit, + locale: req.locale, + overrideAccess: false, + page, + req, + sort, + where: fullWhere, + }) + }, + } + + return { + ...objectTypeConfig, + [formatName(field.name)]: joinType, + } + }, + json: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: GraphQLJSON, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + number: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => { + const type = field?.name === 'id' ? GraphQLInt : GraphQLFloat + return { + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: field?.hasMany === true ? new GraphQLList(type) : type, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + } + }, + point: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: new GraphQLList(new GraphQLNonNull(GraphQLFloat)), + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + radio: ({ field, forceNullable, objectTypeConfig, parentIsLocalized, parentName }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: new GraphQLEnumType({ + name: combineParentName(parentName, field.name), + values: formatOptions(field), + }), + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + relationship: ({ + config, + field, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig, + parentIsLocalized, + parentName, + }) => { + const { relationTo } = field + const isRelatedToManyCollections = Array.isArray(relationTo) + const hasManyValues = field.hasMany + const relationshipName = combineParentName(parentName, toWords(field.name, true)) + + let type: GraphQLOutputType + let relationToType = null + + const graphQLCollections = config.collections.filter( + (collectionConfig) => collectionConfig.graphQL !== false, + ) + + if (Array.isArray(relationTo)) { + relationToType = new GraphQLEnumType({ + name: `${relationshipName}_RelationTo`, + values: relationTo + .filter((relation) => + graphQLCollections.some((collection) => collection.slug === relation), + ) + .reduce( + (relations, relation) => ({ + ...relations, + [formatName(relation)]: { + value: relation, + }, + }), + {}, + ), + }) + + // Only pass collections that are GraphQL enabled + const types = relationTo + .filter((relation) => graphQLCollections.some((collection) => collection.slug === relation)) + .map((relation) => graphqlResult.collections[relation]?.graphQL.type) + + type = new GraphQLObjectType({ + name: `${relationshipName}_Relationship`, + fields: { + relationTo: { + type: relationToType, + }, + value: { + type: new GraphQLUnionType({ + name: relationshipName, + resolveType(data) { + return graphqlResult.collections[data.collection].graphQL.type.name + }, + types, + }) as GraphQLOutputType, + }, + }, + }) as GraphQLOutputType + } else { + ;({ type } = graphqlResult.collections[relationTo].graphQL) + } + + // If the relationshipType is undefined at this point, + // it can be assumed that this blockType can have a relationship + // to itself. Therefore, we set the relationshipType equal to the blockType + // that is currently being created. + + type = type || newlyCreatedBlockType + + const relationshipArgs: { + draft: GraphQLArgumentConfig + fallbackLocale: GraphQLArgumentConfig + limit: GraphQLArgumentConfig + locale: GraphQLArgumentConfig + page: GraphQLArgumentConfig + where: GraphQLArgumentConfig + } = {} as any + + const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]) + .filter((relation) => graphQLCollections.some((collection) => collection.slug === relation)) + .some((relation) => graphqlResult.collections[relation].config.versions?.drafts) + + if (relationsUseDrafts) { + relationshipArgs.draft = { + type: GraphQLBoolean, + } + } + + if (config.localization) { + relationshipArgs.locale = { + type: graphqlResult.types.localeInputType, + } + + relationshipArgs.fallbackLocale = { + type: graphqlResult.types.fallbackLocaleInputType, + } + } + + const relationship: GraphQLFieldConfig = { + type: withNullableType({ + type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + args: relationshipArgs, + extensions: { + complexity: typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10, + }, + async resolve(parent, args, context: Context) { + const value = parent[field.name] + const locale = args.locale || context.req.locale + const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale + let relatedCollectionSlug = field.relationTo + const draft = Boolean(args.draft ?? context.req.query?.draft) + + if (hasManyValues) { + const results = [] + const resultPromises = [] + + const createPopulationPromise = async (relatedDoc, i) => { + let id = relatedDoc + let collectionSlug = field.relationTo + const isValidGraphQLCollection = isRelatedToManyCollections + ? graphQLCollections.some((collection) => collectionSlug.includes(collection.slug)) + : graphQLCollections.some((collection) => collectionSlug === collection.slug) + + if (isValidGraphQLCollection) { + if (isRelatedToManyCollections) { + collectionSlug = relatedDoc.relationTo + id = relatedDoc.value + } + + const result = await context.req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug: collectionSlug as string, + currentDepth: 0, + depth: 0, + docID: id, + draft, + fallbackLocale, + locale, + overrideAccess: false, + showHiddenFields: false, + transactionID: context.req.transactionID, + }), + ) + + if (result) { + if (isRelatedToManyCollections) { + results[i] = { + relationTo: collectionSlug, + value: { + ...result, + collection: collectionSlug, + }, + } + } else { + results[i] = result + } + } + } + } + + if (value) { + value.forEach((relatedDoc, i) => { + resultPromises.push(createPopulationPromise(relatedDoc, i)) + }) + } + + await Promise.all(resultPromises) + return results + } + + let id = value + if (isRelatedToManyCollections && value) { + id = value.value + relatedCollectionSlug = value.relationTo + } + + if (id) { + if (graphQLCollections.some((collection) => collection.slug === relatedCollectionSlug)) { + const relatedDocument = await context.req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug: relatedCollectionSlug as string, + currentDepth: 0, + depth: 0, + docID: id, + draft, + fallbackLocale, + locale, + overrideAccess: false, + showHiddenFields: false, + transactionID: context.req.transactionID, + }), + ) + + if (relatedDocument) { + if (isRelatedToManyCollections) { + return { + relationTo: relatedCollectionSlug, + value: { + ...relatedDocument, + collection: relatedCollectionSlug, + }, + } + } + + return relatedDocument + } + } + + return null + } + + return null + }, + } + + return { + ...objectTypeConfig, + [formatName(field.name)]: relationship, + } + }, + richText: ({ config, field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: { + type: withNullableType({ + type: GraphQLJSON, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + args: { + depth: { + type: GraphQLInt, + }, + }, + async resolve(parent, args, context: Context) { + let depth = config.defaultDepth + if (typeof args.depth !== 'undefined') { + depth = args.depth + } + if (!field?.editor) { + throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor + } + + if (typeof field?.editor === 'function') { + throw new Error('Attempted to access unsanitized rich text editor.') + } + + const editor: RichTextAdapter = field?.editor + + // RichText fields have their own depth argument in GraphQL. + // This is why the populationPromise (which populates richtext fields like uploads and relationships) + // is run here again, with the provided depth. + // In the graphql find.ts resolver, the depth is then hard-coded to 0. + // Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise. + if (editor?.graphQLPopulationPromises) { + const fieldPromises = [] + const populationPromises = [] + const populateDepth = + field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth + + editor?.graphQLPopulationPromises({ + context, + depth: populateDepth, + draft: args.draft, + field, + fieldPromises, + findMany: false, + flattenLocales: false, + overrideAccess: false, + parentIsLocalized, + populationPromises, + req: context.req, + showHiddenFields: false, + siblingDoc: parent, + }) + await Promise.all(fieldPromises) + await Promise.all(populationPromises) + } + + return parent[field.name] + }, + }, + }), + row: ({ field, objectTypeConfig, ...rest }) => + field.fields.reduce((objectTypeConfigWithRowFields, subField) => { + const addSubField: GenericFieldToSchemaMap = fieldToSchemaMap[subField.type] + if (addSubField) { + return addSubField({ + field: subField, + objectTypeConfig: objectTypeConfigWithRowFields, + ...rest, + }) + } + return objectTypeConfigWithRowFields + }, objectTypeConfig), + select: ({ field, forceNullable, objectTypeConfig, parentIsLocalized, parentName }) => { + const fullName = combineParentName(parentName, field.name) + + let type: GraphQLOutputType = new GraphQLEnumType({ + name: fullName, + values: formatOptions(field), + }) + + type = field.hasMany ? new GraphQLList(new GraphQLNonNull(type)) : type + type = withNullableType({ type, field, forceNullable, parentIsLocalized }) as GraphQLOutputType + + return { + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ type, field }), + } + }, + tabs: ({ + config, + field, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig, + parentIsLocalized, + parentName, + }) => + field.tabs.reduce((tabSchema, tab) => { + if (tabHasName(tab)) { + const interfaceName = + tab?.interfaceName || combineParentName(parentName, toWords(tab.name, true)) + + if (!graphqlResult.types.groupTypes[interfaceName]) { + const objectType = buildObjectType({ + name: interfaceName, + config, + fields: tab.fields, + forceNullable, + graphqlResult, + parentIsLocalized: tab.localized || parentIsLocalized, + parentName: interfaceName, + }) + + if (Object.keys(objectType.getFields()).length) { + graphqlResult.types.groupTypes[interfaceName] = objectType + } + } + + if (!graphqlResult.types.groupTypes[interfaceName]) { + return tabSchema + } + + return { + ...tabSchema, + [tab.name]: { + type: graphqlResult.types.groupTypes[interfaceName], + resolve(parent, args, context: Context) { + return { + ...parent[tab.name], + _id: parent._id ?? parent.id, + } + }, + }, + } + } + + return { + ...tabSchema, + ...tab.fields.reduce((subFieldSchema, subField) => { + const addSubField: GenericFieldToSchemaMap = fieldToSchemaMap[subField.type] + if (addSubField) { + return addSubField({ + config, + field: subField, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig: subFieldSchema, + parentIsLocalized, + parentName, + }) + } + return subFieldSchema + }, tabSchema), + } + }, objectTypeConfig), + text: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + textarea: ({ field, forceNullable, objectTypeConfig, parentIsLocalized }) => ({ + ...objectTypeConfig, + [formatName(field.name)]: formattedNameResolver({ + type: withNullableType({ + type: GraphQLString, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + field, + }), + }), + upload: ({ + config, + field, + forceNullable, + graphqlResult, + newlyCreatedBlockType, + objectTypeConfig, + parentIsLocalized, + parentName, + }) => { + const { relationTo } = field + const isRelatedToManyCollections = Array.isArray(relationTo) + const hasManyValues = field.hasMany + const relationshipName = combineParentName(parentName, toWords(field.name, true)) + + let type + let relationToType = null + + if (Array.isArray(relationTo)) { + relationToType = new GraphQLEnumType({ + name: `${relationshipName}_RelationTo`, + values: relationTo.reduce( + (relations, relation) => ({ + ...relations, + [formatName(relation)]: { + value: relation, + }, + }), + {}, + ), + }) + + const types = relationTo.map((relation) => graphqlResult.collections[relation].graphQL.type) + + type = new GraphQLObjectType({ + name: `${relationshipName}_Relationship`, + fields: { + relationTo: { + type: relationToType, + }, + value: { + type: new GraphQLUnionType({ + name: relationshipName, + resolveType(data) { + return graphqlResult.collections[data.collection].graphQL.type.name + }, + types, + }), + }, + }, + }) + } else { + ;({ type } = graphqlResult.collections[relationTo].graphQL) + } + + // If the relationshipType is undefined at this point, + // it can be assumed that this blockType can have a relationship + // to itself. Therefore, we set the relationshipType equal to the blockType + // that is currently being created. + + type = type || newlyCreatedBlockType + + const relationshipArgs: { + draft?: GraphQLArgumentConfig + fallbackLocale?: GraphQLArgumentConfig + limit?: GraphQLArgumentConfig + locale?: GraphQLArgumentConfig + page?: GraphQLArgumentConfig + where?: GraphQLArgumentConfig + } = {} as any + + const relationsUseDrafts = (Array.isArray(relationTo) ? relationTo : [relationTo]).some( + (relation) => graphqlResult.collections[relation].config.versions?.drafts, + ) + + if (relationsUseDrafts) { + relationshipArgs.draft = { + type: GraphQLBoolean, + } + } + + if (config.localization) { + relationshipArgs.locale = { + type: graphqlResult.types.localeInputType, + } + + relationshipArgs.fallbackLocale = { + type: graphqlResult.types.fallbackLocaleInputType, + } + } + + const relationship = { + type: withNullableType({ + type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type, + field, + forceNullable, + parentIsLocalized, + }) as GraphQLOutputType, + args: relationshipArgs, + extensions: { + complexity: typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10, + }, + async resolve(parent, args, context: Context) { + const value = parent[field.name] + const locale = args.locale || context.req.locale + const fallbackLocale = args.fallbackLocale || context.req.fallbackLocale + let relatedCollectionSlug = field.relationTo + const draft = Boolean(args.draft ?? context.req.query?.draft) + + if (hasManyValues) { + const results = [] + const resultPromises = [] + + const createPopulationPromise = async (relatedDoc, i) => { + let id = relatedDoc + let collectionSlug = field.relationTo + + if (isRelatedToManyCollections) { + collectionSlug = relatedDoc.relationTo + id = relatedDoc.value + } + + const result = await context.req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug, + currentDepth: 0, + depth: 0, + docID: id, + draft, + fallbackLocale, + locale, + overrideAccess: false, + showHiddenFields: false, + transactionID: context.req.transactionID, + }), + ) + + if (result) { + if (isRelatedToManyCollections) { + results[i] = { + relationTo: collectionSlug, + value: { + ...result, + collection: collectionSlug, + }, + } + } else { + results[i] = result + } + } + } + + if (value) { + value.forEach((relatedDoc, i) => { + resultPromises.push(createPopulationPromise(relatedDoc, i)) + }) + } + + await Promise.all(resultPromises) + return results + } + + let id = value + if (isRelatedToManyCollections && value) { + id = value.value + relatedCollectionSlug = value.relationTo + } + + if (id) { + const relatedDocument = await context.req.payloadDataLoader.load( + createDataloaderCacheKey({ + collectionSlug: relatedCollectionSlug, + currentDepth: 0, + depth: 0, + docID: id, + draft, + fallbackLocale, + locale, + overrideAccess: false, + showHiddenFields: false, + transactionID: context.req.transactionID, + }), + ) + + if (relatedDocument) { + if (isRelatedToManyCollections) { + return { + relationTo: relatedCollectionSlug, + value: { + ...relatedDocument, + collection: relatedCollectionSlug, + }, + } + } + + return relatedDocument + } + + return null + } + + return null + }, + } + + return { + ...objectTypeConfig, + [formatName(field.name)]: relationship, + } + }, +} diff --git a/test/graphql/config.ts b/test/graphql/config.ts index 1793c4aeef..5363aaa7d0 100644 --- a/test/graphql/config.ts +++ b/test/graphql/config.ts @@ -19,6 +19,10 @@ export default buildConfigWithDefaults({ label: 'Title', type: 'text', }, + { + name: 'hyphenated-name', + type: 'text', + }, { type: 'relationship', relationTo: 'posts', diff --git a/test/graphql/int.spec.ts b/test/graphql/int.spec.ts index 27a99952cd..ab4cfbdd69 100644 --- a/test/graphql/int.spec.ts +++ b/test/graphql/int.spec.ts @@ -29,7 +29,7 @@ describe('graphql', () => { it('should not be able to query introspection', async () => { const query = `query { __schema { - queryType { + queryType { name } } @@ -57,7 +57,7 @@ describe('graphql', () => { collection: 'posts', id: post.id, data: { - relatedToSelf: post.id, + relationToSelf: post.id, }, }) @@ -80,5 +80,29 @@ describe('graphql', () => { 'The query exceeds the maximum complexity of 800. Actual complexity is 804', ) }) + + it('should sanitize hyphenated field names to snake case', async () => { + const post = await payload.create({ + collection: 'posts', + data: { + title: 'example post', + 'hyphenated-name': 'example-hyphenated-name', + }, + }) + + const query = `query { + Post(id: ${idToString(post.id, payload)}) { + title + hyphenated_name + } + }` + + const { data } = await restClient + .GRAPHQL_POST({ body: JSON.stringify({ query }) }) + .then((res) => res.json()) + const res = data.Post + + expect(res.hyphenated_name).toStrictEqual('example-hyphenated-name') + }) }) }) diff --git a/test/graphql/payload-types.ts b/test/graphql/payload-types.ts index 839eea778f..c78025ce9e 100644 --- a/test/graphql/payload-types.ts +++ b/test/graphql/payload-types.ts @@ -64,7 +64,6 @@ export interface Config { auth: { users: UserAuthOperations; }; - blocks: {}; collections: { posts: Post; users: User; @@ -119,6 +118,7 @@ export interface UserAuthOperations { export interface Post { id: string; title?: string | null; + 'hyphenated-name'?: string | null; relationToSelf?: (string | null) | Post; updatedAt: string; createdAt: string; @@ -203,6 +203,7 @@ export interface PayloadMigration { */ export interface PostsSelect { title?: T; + 'hyphenated-name'?: T; relationToSelf?: T; updatedAt?: T; createdAt?: T; diff --git a/test/graphql/schema.graphql b/test/graphql/schema.graphql index 853bed9d13..b4a7185f12 100644 --- a/test/graphql/schema.graphql +++ b/test/graphql/schema.graphql @@ -23,6 +23,7 @@ type Query { type Post { id: String! title: String + hyphenated_name: String relationToSelf: Post updatedAt: DateTime createdAt: DateTime @@ -49,6 +50,7 @@ type Posts { input Post_where { title: Post_title_operator + hyphenated_name: Post_hyphenated_name_operator relationToSelf: Post_relationToSelf_operator updatedAt: Post_updatedAt_operator createdAt: Post_createdAt_operator @@ -68,6 +70,17 @@ input Post_title_operator { exists: Boolean } +input Post_hyphenated_name_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + input Post_relationToSelf_operator { equals: JSON not_equals: JSON @@ -117,6 +130,7 @@ input Post_id_operator { input Post_where_and { title: Post_title_operator + hyphenated_name: Post_hyphenated_name_operator relationToSelf: Post_relationToSelf_operator updatedAt: Post_updatedAt_operator createdAt: Post_createdAt_operator @@ -127,6 +141,7 @@ input Post_where_and { input Post_where_or { title: Post_title_operator + hyphenated_name: Post_hyphenated_name_operator relationToSelf: Post_relationToSelf_operator updatedAt: Post_updatedAt_operator createdAt: Post_createdAt_operator @@ -149,6 +164,7 @@ type postsDocAccess { type PostsDocAccessFields { title: PostsDocAccessFields_title + hyphenated_name: PostsDocAccessFields_hyphenated_name relationToSelf: PostsDocAccessFields_relationToSelf updatedAt: PostsDocAccessFields_updatedAt createdAt: PostsDocAccessFields_createdAt @@ -177,6 +193,29 @@ type PostsDocAccessFields_title_Delete { permission: Boolean! } +type PostsDocAccessFields_hyphenated_name { + create: PostsDocAccessFields_hyphenated_name_Create + read: PostsDocAccessFields_hyphenated_name_Read + update: PostsDocAccessFields_hyphenated_name_Update + delete: PostsDocAccessFields_hyphenated_name_Delete +} + +type PostsDocAccessFields_hyphenated_name_Create { + permission: Boolean! +} + +type PostsDocAccessFields_hyphenated_name_Read { + permission: Boolean! +} + +type PostsDocAccessFields_hyphenated_name_Update { + permission: Boolean! +} + +type PostsDocAccessFields_hyphenated_name_Delete { + permission: Boolean! +} + type PostsDocAccessFields_relationToSelf { create: PostsDocAccessFields_relationToSelf_Create read: PostsDocAccessFields_relationToSelf_Read @@ -1094,6 +1133,7 @@ type postsAccess { type PostsFields { title: PostsFields_title + hyphenated_name: PostsFields_hyphenated_name relationToSelf: PostsFields_relationToSelf updatedAt: PostsFields_updatedAt createdAt: PostsFields_createdAt @@ -1122,6 +1162,29 @@ type PostsFields_title_Delete { permission: Boolean! } +type PostsFields_hyphenated_name { + create: PostsFields_hyphenated_name_Create + read: PostsFields_hyphenated_name_Read + update: PostsFields_hyphenated_name_Update + delete: PostsFields_hyphenated_name_Delete +} + +type PostsFields_hyphenated_name_Create { + permission: Boolean! +} + +type PostsFields_hyphenated_name_Read { + permission: Boolean! +} + +type PostsFields_hyphenated_name_Update { + permission: Boolean! +} + +type PostsFields_hyphenated_name_Delete { + permission: Boolean! +} + type PostsFields_relationToSelf { create: PostsFields_relationToSelf_Create read: PostsFields_relationToSelf_Read @@ -1649,6 +1712,7 @@ type Mutation { input mutationPostInput { title: String + hyphenated_name: String relationToSelf: String updatedAt: String createdAt: String @@ -1656,6 +1720,7 @@ input mutationPostInput { input mutationPostUpdateInput { title: String + hyphenated_name: String relationToSelf: String updatedAt: String createdAt: String