fix(graphql): sanitize graphql field names for schema generation (#11556)

### What? Cannot generate GraphQL schema with hyphenated field names
Using field names that do not adhere to the GraphQL `_a-z & A-Z`
standard prevent you from generating a schema, even though it will work
just fine everywhere else.

Example: `my-field-name` will prevent schema generation.

### How? Field name sanitization on generation and querying
This PR adds sanitization to the schema generation that sanitizes field
names.
- It formats field names in a GraphQL safe format for schema generation.
**It does not change your config.**
- It adds resolvers for field names that do not adhere so they can be
mapped from the config name to the GraphQL safe name.

Example:
- `my-field` will turn into `my_field` in the schema generation
- `my_field` will resolve from `my-field` when data comes out

### Other notes
- Moves code from `packages/graphql/src/schema/buildObjectType.ts` to
`packages/graphql/src/schema/fieldToSchemaMap.ts`
- Resolvers are only added when necessary: `if (formatName(field.name)
!== field.name)`.

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Jarrod Flesch
2025-03-07 09:43:09 -05:00
committed by GitHub
parent a53876d741
commit 029cac3cd3
8 changed files with 1216 additions and 934 deletions

View File

@@ -107,20 +107,20 @@ export function buildMutationInputType({
type = new GraphQLList(withNullableType({ type, field, forceNullable, parentIsLocalized })) type = new GraphQLList(withNullableType({ type, field, forceNullable, parentIsLocalized }))
return { return {
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { type }, [formatName(field.name)]: { type },
} }
}, },
blocks: (inputObjectTypeConfig: InputObjectTypeConfig, field: BlocksField) => ({ blocks: (inputObjectTypeConfig: InputObjectTypeConfig, field: BlocksField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { type: GraphQLJSON }, [formatName(field.name)]: { type: GraphQLJSON },
}), }),
checkbox: (inputObjectTypeConfig: InputObjectTypeConfig, field: CheckboxField) => ({ checkbox: (inputObjectTypeConfig: InputObjectTypeConfig, field: CheckboxField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { type: GraphQLBoolean }, [formatName(field.name)]: { type: GraphQLBoolean },
}), }),
code: (inputObjectTypeConfig: InputObjectTypeConfig, field: CodeField) => ({ code: (inputObjectTypeConfig: InputObjectTypeConfig, field: CodeField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
}, },
}), }),
@@ -134,13 +134,13 @@ export function buildMutationInputType({
}, inputObjectTypeConfig), }, inputObjectTypeConfig),
date: (inputObjectTypeConfig: InputObjectTypeConfig, field: DateField) => ({ date: (inputObjectTypeConfig: InputObjectTypeConfig, field: DateField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
}, },
}), }),
email: (inputObjectTypeConfig: InputObjectTypeConfig, field: EmailField) => ({ email: (inputObjectTypeConfig: InputObjectTypeConfig, field: EmailField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
}, },
}), }),
@@ -165,12 +165,12 @@ export function buildMutationInputType({
} }
return { return {
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { type }, [formatName(field.name)]: { type },
} }
}, },
json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({ json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }), type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }),
}, },
}), }),
@@ -178,7 +178,7 @@ export function buildMutationInputType({
const type = field.name === 'id' ? GraphQLInt : GraphQLFloat const type = field.name === 'id' ? GraphQLInt : GraphQLFloat
return { return {
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: withNullableType({
type: field.hasMany === true ? new GraphQLList(type) : type, type: field.hasMany === true ? new GraphQLList(type) : type,
field, field,
@@ -190,7 +190,7 @@ export function buildMutationInputType({
}, },
point: (inputObjectTypeConfig: InputObjectTypeConfig, field: PointField) => ({ point: (inputObjectTypeConfig: InputObjectTypeConfig, field: PointField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: withNullableType({
type: new GraphQLList(GraphQLFloat), type: new GraphQLList(GraphQLFloat),
field, field,
@@ -201,7 +201,7 @@ export function buildMutationInputType({
}), }),
radio: (inputObjectTypeConfig: InputObjectTypeConfig, field: RadioField) => ({ radio: (inputObjectTypeConfig: InputObjectTypeConfig, field: RadioField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
}, },
}), }),
@@ -247,12 +247,12 @@ export function buildMutationInputType({
return { return {
...inputObjectTypeConfig, ...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) => ({ richText: (inputObjectTypeConfig: InputObjectTypeConfig, field: RichTextField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }), type: withNullableType({ type: GraphQLJSON, field, forceNullable, parentIsLocalized }),
}, },
}), }),
@@ -292,7 +292,7 @@ export function buildMutationInputType({
return { return {
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { type }, [formatName(field.name)]: { type },
} }
}, },
tabs: (inputObjectTypeConfig: InputObjectTypeConfig, field: TabsField) => { tabs: (inputObjectTypeConfig: InputObjectTypeConfig, field: TabsField) => {
@@ -336,7 +336,7 @@ export function buildMutationInputType({
}, },
text: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextField) => ({ text: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: withNullableType({
type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString, type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString,
field, field,
@@ -347,7 +347,7 @@ export function buildMutationInputType({
}), }),
textarea: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextareaField) => ({ textarea: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextareaField) => ({
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { [formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }), type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
}, },
}), }),
@@ -393,7 +393,7 @@ export function buildMutationInputType({
return { return {
...inputObjectTypeConfig, ...inputObjectTypeConfig,
[field.name]: { type: field.hasMany ? new GraphQLList(type) : type }, [formatName(field.name)]: { type: field.hasMany ? new GraphQLList(type) : type },
} }
}, },
} }

View File

@@ -1,58 +1,12 @@
import type { GraphQLFieldConfig, GraphQLType } from 'graphql' import type { GraphQLFieldConfig } from 'graphql'
import type { import type { Field, GraphQLInfo, SanitizedConfig } from 'payload'
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 { import { GraphQLObjectType } from 'graphql'
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 { fieldToSchemaMap } from './fieldToSchemaMap.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'
export type ObjectTypeConfig = { export type ObjectTypeConfig = {
[path: string]: GraphQLFieldConfig<any, any> [path: string]: GraphQLFieldConfig<any, any, any>
} }
type Args = { type Args = {
@@ -76,867 +30,6 @@ export function buildObjectType({
parentIsLocalized, parentIsLocalized,
parentName, parentName,
}: Args): GraphQLObjectType { }: 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<any, any>[] = (
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 = { const objectSchema = {
name, name,
fields: () => fields: () =>
@@ -949,7 +42,16 @@ export function buildObjectType({
return { return {
...objectTypeConfig, ...objectTypeConfig,
...fieldSchema(objectTypeConfig, field), ...fieldSchema({
config,
field,
forceNullable,
graphqlResult,
newlyCreatedBlockType,
objectTypeConfig,
parentIsLocalized,
parentName,
}),
} }
}, baseFields), }, baseFields),
} }

View File

@@ -60,7 +60,7 @@ const buildFields = (label, fieldsToBuild) =>
return { return {
...builtFields, ...builtFields,
[field.name]: { [formatName(field.name)]: {
type: new GraphQLObjectType({ type: new GraphQLObjectType({
name: `${label}_${fieldName}`, name: `${label}_${fieldName}`,
fields: objectTypeFields, fields: objectTypeFields,

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,10 @@ export default buildConfigWithDefaults({
label: 'Title', label: 'Title',
type: 'text', type: 'text',
}, },
{
name: 'hyphenated-name',
type: 'text',
},
{ {
type: 'relationship', type: 'relationship',
relationTo: 'posts', relationTo: 'posts',

View File

@@ -57,7 +57,7 @@ describe('graphql', () => {
collection: 'posts', collection: 'posts',
id: post.id, id: post.id,
data: { 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', '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')
})
}) })
}) })

View File

@@ -64,7 +64,6 @@ export interface Config {
auth: { auth: {
users: UserAuthOperations; users: UserAuthOperations;
}; };
blocks: {};
collections: { collections: {
posts: Post; posts: Post;
users: User; users: User;
@@ -119,6 +118,7 @@ export interface UserAuthOperations {
export interface Post { export interface Post {
id: string; id: string;
title?: string | null; title?: string | null;
'hyphenated-name'?: string | null;
relationToSelf?: (string | null) | Post; relationToSelf?: (string | null) | Post;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -203,6 +203,7 @@ export interface PayloadMigration {
*/ */
export interface PostsSelect<T extends boolean = true> { export interface PostsSelect<T extends boolean = true> {
title?: T; title?: T;
'hyphenated-name'?: T;
relationToSelf?: T; relationToSelf?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;

View File

@@ -23,6 +23,7 @@ type Query {
type Post { type Post {
id: String! id: String!
title: String title: String
hyphenated_name: String
relationToSelf: Post relationToSelf: Post
updatedAt: DateTime updatedAt: DateTime
createdAt: DateTime createdAt: DateTime
@@ -49,6 +50,7 @@ type Posts {
input Post_where { input Post_where {
title: Post_title_operator title: Post_title_operator
hyphenated_name: Post_hyphenated_name_operator
relationToSelf: Post_relationToSelf_operator relationToSelf: Post_relationToSelf_operator
updatedAt: Post_updatedAt_operator updatedAt: Post_updatedAt_operator
createdAt: Post_createdAt_operator createdAt: Post_createdAt_operator
@@ -68,6 +70,17 @@ input Post_title_operator {
exists: Boolean 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 { input Post_relationToSelf_operator {
equals: JSON equals: JSON
not_equals: JSON not_equals: JSON
@@ -117,6 +130,7 @@ input Post_id_operator {
input Post_where_and { input Post_where_and {
title: Post_title_operator title: Post_title_operator
hyphenated_name: Post_hyphenated_name_operator
relationToSelf: Post_relationToSelf_operator relationToSelf: Post_relationToSelf_operator
updatedAt: Post_updatedAt_operator updatedAt: Post_updatedAt_operator
createdAt: Post_createdAt_operator createdAt: Post_createdAt_operator
@@ -127,6 +141,7 @@ input Post_where_and {
input Post_where_or { input Post_where_or {
title: Post_title_operator title: Post_title_operator
hyphenated_name: Post_hyphenated_name_operator
relationToSelf: Post_relationToSelf_operator relationToSelf: Post_relationToSelf_operator
updatedAt: Post_updatedAt_operator updatedAt: Post_updatedAt_operator
createdAt: Post_createdAt_operator createdAt: Post_createdAt_operator
@@ -149,6 +164,7 @@ type postsDocAccess {
type PostsDocAccessFields { type PostsDocAccessFields {
title: PostsDocAccessFields_title title: PostsDocAccessFields_title
hyphenated_name: PostsDocAccessFields_hyphenated_name
relationToSelf: PostsDocAccessFields_relationToSelf relationToSelf: PostsDocAccessFields_relationToSelf
updatedAt: PostsDocAccessFields_updatedAt updatedAt: PostsDocAccessFields_updatedAt
createdAt: PostsDocAccessFields_createdAt createdAt: PostsDocAccessFields_createdAt
@@ -177,6 +193,29 @@ type PostsDocAccessFields_title_Delete {
permission: Boolean! 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 { type PostsDocAccessFields_relationToSelf {
create: PostsDocAccessFields_relationToSelf_Create create: PostsDocAccessFields_relationToSelf_Create
read: PostsDocAccessFields_relationToSelf_Read read: PostsDocAccessFields_relationToSelf_Read
@@ -1094,6 +1133,7 @@ type postsAccess {
type PostsFields { type PostsFields {
title: PostsFields_title title: PostsFields_title
hyphenated_name: PostsFields_hyphenated_name
relationToSelf: PostsFields_relationToSelf relationToSelf: PostsFields_relationToSelf
updatedAt: PostsFields_updatedAt updatedAt: PostsFields_updatedAt
createdAt: PostsFields_createdAt createdAt: PostsFields_createdAt
@@ -1122,6 +1162,29 @@ type PostsFields_title_Delete {
permission: Boolean! 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 { type PostsFields_relationToSelf {
create: PostsFields_relationToSelf_Create create: PostsFields_relationToSelf_Create
read: PostsFields_relationToSelf_Read read: PostsFields_relationToSelf_Read
@@ -1649,6 +1712,7 @@ type Mutation {
input mutationPostInput { input mutationPostInput {
title: String title: String
hyphenated_name: String
relationToSelf: String relationToSelf: String
updatedAt: String updatedAt: String
createdAt: String createdAt: String
@@ -1656,6 +1720,7 @@ input mutationPostInput {
input mutationPostUpdateInput { input mutationPostUpdateInput {
title: String title: String
hyphenated_name: String
relationToSelf: String relationToSelf: String
updatedAt: String updatedAt: String
createdAt: String createdAt: String