diff --git a/.changeset/chilled-moons-walk.md b/.changeset/chilled-moons-walk.md new file mode 100644 index 00000000000..b97e6377a37 --- /dev/null +++ b/.changeset/chilled-moons-walk.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": major +--- + +Removed deprecated `validateInput` and `validateDelete` hooks and add object hook syntax to fields. diff --git a/examples/custom-field/2-stars-field/index.ts b/examples/custom-field/2-stars-field/index.ts index 3f2bcb6bbef..ea6f09a4bb8 100644 --- a/examples/custom-field/2-stars-field/index.ts +++ b/examples/custom-field/2-stars-field/index.ts @@ -22,6 +22,15 @@ export function stars ({ maxStars = 5, ...config }: StarsFieldConfig = {}): FieldTypeFunc { + const validateCreate = typeof config.hooks?.validate === 'function' ? config.hooks.validate : config.hooks?.validate?.create + const validateUpdate = typeof config.hooks?.validate === 'function' ? config.hooks.validate : config.hooks?.validate?.update + + function validate (v: unknown) { + if (v === null) return + if (typeof v === 'number' && v >= 0 && v <= maxStars) return + return `The value must be within the range of 0-${maxStars}` + } + return meta => fieldType({ // this configures what data is stored in the database @@ -34,15 +43,21 @@ export function stars ({ ...config, hooks: { ...config.hooks, - // We use the `validateInput` hook to ensure that the user doesn't set an out of range value. + // We use the `validate` hooks to ensure that the user doesn't set an out of range value. // This hook is the key difference on the backend between the stars field type and the integer field type. - async validateInput (args) { - const val = args.resolvedData[meta.fieldKey] - if (!(val == null || (val >= 0 && val <= maxStars))) { - args.addValidationError(`The value must be within the range of 0-${maxStars}`) + validate: { + ...config.hooks?.validate, + async create (args) { + const err = validate(args.resolvedData[meta.fieldKey]) + if (err) args.addValidationError(err) + await validateCreate?.(args) + }, + async update (args) { + const err = validate(args.resolvedData[meta.fieldKey]) + if (err) args.addValidationError(err) + await validateUpdate?.(args) } - await config.hooks?.validateInput?.(args) - }, + } }, // all of these inputs are optional if they don't make sense for a particular field type input: { @@ -53,16 +68,12 @@ export function stars ({ // this function can be omitted, it is here purely to show how you could change it resolve (val, context) { // if it's null, then the value will be set to null in the database - if (val === null) { - return null - } + if (val === null) return null // if it's undefined(which means that it was omitted in the request) // returning undefined will mean "don't change the existing value" // note that this means that this function is called on every update to an item // including when the field is not updated - if (val === undefined) { - return undefined - } + if (val === undefined) return undefined // if it's not null or undefined, it must be a number return val }, diff --git a/examples/custom-field/schema.ts b/examples/custom-field/schema.ts index ac2899f29a1..33eb0189b85 100644 --- a/examples/custom-field/schema.ts +++ b/examples/custom-field/schema.ts @@ -31,14 +31,14 @@ export const lists = { return resolvedData[fieldKey] }, - validateInput: async ({ + validate: async ({ resolvedData, inputData, item, addValidationError, fieldKey, }) => { - console.log('Post.content.hooks.validateInput', { + console.log('Post.content.hooks.validate', { resolvedData, inputData, item, @@ -100,8 +100,8 @@ export const lists = { }, }, - validateInput: async ({ resolvedData, operation, inputData, item, addValidationError }) => { - console.log('Post.hooks.validateInput', { resolvedData, operation, inputData, item }) + validate: async ({ resolvedData, operation, inputData, item, addValidationError }) => { + console.log('Post.hooks.validate', { resolvedData, operation, inputData, item }) if (Math.random() > 0.95) { addValidationError('oh oh, try again, this is part of the example') diff --git a/examples/field-groups/schema.ts b/examples/field-groups/schema.ts index ad73009af9c..0cd551166b7 100644 --- a/examples/field-groups/schema.ts +++ b/examples/field-groups/schema.ts @@ -26,15 +26,12 @@ export const lists = { // for this example, we are going to use a hook for fun // defaultValue: { kind: 'now' } hooks: { - resolveInput: ({ context, operation, resolvedData }) => { - // TODO: text should allow you to prevent a defaultValue, then Prisma create could be non-null - // if (operation === 'create') return resolvedData.title.replace(/ /g, '-').toLowerCase() - if (operation === 'create') { - return resolvedData.title?.replace(/ /g, '-').toLowerCase() - } - - return resolvedData.slug - }, + resolveInput: { + create: ({ context, operation, resolvedData }) => { + // TODO: text should allow you to prevent a defaultValue, then Prisma create could be non-null + return resolvedData.title?.replace(/ /g, '-').toLowerCase() + }, + } }, }), }, diff --git a/examples/hooks/schema.ts b/examples/hooks/schema.ts index 7b5472397ed..4967fcca59e 100644 --- a/examples/hooks/schema.ts +++ b/examples/hooks/schema.ts @@ -78,18 +78,10 @@ export const lists = { // defaultValue: { kind: 'now' } hooks: { - resolveInput: ({ context, operation, resolvedData }) => { - if (operation === 'create') return new Date() - return resolvedData.createdAt - }, - }, - - // TODO: this would be nice - // hooks: { - // resolveInput: { - // create: () => new Date() - // } - // } + resolveInput: { + create: () => new Date() + } + } }), updatedBy: text({ ...readOnly }), @@ -102,18 +94,10 @@ export const lists = { // }, hooks: { - resolveInput: ({ context, operation, resolvedData }) => { - if (operation === 'update') return new Date() - return resolvedData.updatedAt - }, - }, - - // TODO: this would be nice - // hooks: { - // resolveInput: { - // update: () => new Date() - // } - // } + resolveInput: { + update: () => new Date() + } + } }), }, }), @@ -131,29 +115,37 @@ export const lists = { return resolvedData }, }, - validateInput: ({ context, operation, inputData, addValidationError }) => { - const { title, content } = inputData - - if (operation === 'update' && 'feedback' in inputData) { - const { feedback } = inputData - if (/profanity/i.test(feedback ?? '')) return addValidationError('Unacceptable feedback') - } + validate: { + create: ({ inputData, addValidationError }) => { + const { title, content } = inputData + + + // an example of a content filter, the prevents the title or content containing the word "Profanity" + if (/profanity/i.test(title)) return addValidationError('Unacceptable title') + if (/profanity/i.test(content)) return addValidationError('Unacceptable content') + }, + update: ({ inputData, addValidationError }) => { + const { title, content } = inputData - // an example of a content filter, the prevents the title or content containing the word "Profanity" - if (/profanity/i.test(title)) return addValidationError('Unacceptable title') - if (/profanity/i.test(content)) return addValidationError('Unacceptable content') - }, - validateDelete: ({ context, item, addValidationError }) => { - const { preventDelete } = item + if ('feedback' in inputData) { + const { feedback } = inputData + if (/profanity/i.test(feedback ?? '')) return addValidationError('Unacceptable feedback') + } - // an example of a content filter, the prevents the title or content containing the word "Profanity" - if (preventDelete) return addValidationError('Cannot delete Post, preventDelete is true') + // an example of a content filter, the prevents the title or content containing the word "Profanity" + if (/profanity/i.test(title)) return addValidationError('Unacceptable title') + if (/profanity/i.test(content)) return addValidationError('Unacceptable content') + }, + delete: ({ context, item, addValidationError }) => { + const { preventDelete } = item + + // an example of a content filter, the prevents the title or content containing the word "Profanity" + if (preventDelete) return addValidationError('Cannot delete Post, preventDelete is true') + }, }, - beforeOperation: ({ item, resolvedData, operation }) => { console.log(`Post beforeOperation.${operation}`, resolvedData) }, - afterOperation: { create: ({ inputData, item }) => { console.log(`Post afterOperation.create`, inputData, '->', item) @@ -162,7 +154,6 @@ export const lists = { update: ({ originalItem, item }) => { console.log(`Post afterOperation.update`, originalItem, '->', item) }, - delete: ({ originalItem }) => { console.log(`Post afterOperation.delete`, originalItem, '-> deleted') }, diff --git a/examples/reuse/schema.ts b/examples/reuse/schema.ts index 1d4642e242c..60290917fee 100644 --- a/examples/reuse/schema.ts +++ b/examples/reuse/schema.ts @@ -55,18 +55,23 @@ function trackingByHooks< // FieldKey extends 'createdBy' | 'updatedBy' // TODO: refined types for the return types > (immutable: boolean = false): FieldHooks { return { - async resolveInput ({ context, operation, resolvedData, item, fieldKey }) { - if (operation === 'update') { + resolveInput: { + async create ({ context, operation, resolvedData, item, fieldKey }) { + // TODO: refined types for the return types + // FIXME: CommonFieldConfig need not always be generalised + return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` as any + }, + async update ({ context, operation, resolvedData, item, fieldKey }) { if (immutable) return undefined // show we have refined types for compatible item.* fields if (isTrue(item.completed) && resolvedData.completed !== false) return undefined - } - - // TODO: refined types for the return types - // FIXME: CommonFieldConfig need not always be generalised - return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` as any - }, + + // TODO: refined types for the return types + // FIXME: CommonFieldConfig need not always be generalised + return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` as any + }, + } } } @@ -76,18 +81,23 @@ function trackingAtHooks< > (immutable: boolean = false): FieldHooks { return { // TODO: switch to operation routing when supported for fields - async resolveInput ({ context, operation, resolvedData, item, fieldKey }) { - if (operation === 'update') { + resolveInput: { + async create ({ context, operation, resolvedData, item, fieldKey }) { + // TODO: refined types for the return types + // FIXME: CommonFieldConfig need not always be generalised + return new Date() as any + }, + async update ({ context, operation, resolvedData, item, fieldKey }) { if (immutable) return undefined // show we have refined types for compatible item.* fields if (isTrue(item.completed) && resolvedData.completed !== false) return undefined - } - // TODO: refined types for the return types - // FIXME: CommonFieldConfig need not always be generalised - return new Date() as any - }, + // TODO: refined types for the return types + // FIXME: CommonFieldConfig need not always be generalised + return new Date() as any + }, + } } } diff --git a/examples/usecase-roles/schema.ts b/examples/usecase-roles/schema.ts index e6dffff922f..1622a07e6bd 100644 --- a/examples/usecase-roles/schema.ts +++ b/examples/usecase-roles/schema.ts @@ -69,15 +69,17 @@ export const lists: Lists = { }, }, hooks: { - resolveInput ({ operation, resolvedData, context }) { - if (operation === 'create' && !resolvedData.assignedTo && context.session) { - // Always default new todo items to the current user; this is important because users - // without canManageAllTodos don't see this field when creating new items - return { connect: { id: context.session.itemId } } + resolveInput: { + create ({ operation, resolvedData, context }) { + if (!resolvedData.assignedTo && context.session) { + // Always default new todo items to the current user; this is important because users + // without canManageAllTodos don't see this field when creating new items + return { connect: { id: context.session.itemId } } + } + return resolvedData.assignedTo } - return resolvedData.assignedTo }, - }, + } }), }, }), diff --git a/examples/usecase-versioning/schema.ts b/examples/usecase-versioning/schema.ts index 46ba7cca800..8f3637961e9 100644 --- a/examples/usecase-versioning/schema.ts +++ b/examples/usecase-versioning/schema.ts @@ -27,11 +27,12 @@ export const lists = { }, }, hooks: { - resolveInput: async ({ resolvedData, operation, item }) => { - if (operation === 'create') return resolvedData.version - if (resolvedData.version !== item.version) throw new Error('Out of sync') - - return item.version + 1 + resolveInput: { + update: async ({ resolvedData, operation, item }) => { + if (resolvedData.version !== item.version) throw new Error('Out of sync') + + return item.version + 1 + }, }, }, }), diff --git a/packages/core/src/fields/non-null-graphql.ts b/packages/core/src/fields/non-null-graphql.ts index 8e89d6aa3eb..bde5beb1b1c 100644 --- a/packages/core/src/fields/non-null-graphql.ts +++ b/packages/core/src/fields/non-null-graphql.ts @@ -2,9 +2,11 @@ import { type BaseListTypeInfo, type FieldData, } from '../types' +import type { FieldHooks } from '../types/config/hooks' import { type ValidateFieldHook } from '../types/config/hooks' +import { merge } from './resolve-hooks' export function resolveDbNullable ( validation: undefined | { isRequired?: boolean }, @@ -33,6 +35,9 @@ export function makeValidateHook ( isRequired?: boolean [key: string]: unknown } + hooks?: { + validate?: FieldHooks['validate'] + } }, f?: ValidateFieldHook ) { @@ -61,13 +66,13 @@ export function makeValidateHook ( return { mode, - validate, + validate: merge(validate, config.hooks?.validate) } } return { mode, - validate: f + validate: merge(f, config.hooks?.validate) } } diff --git a/packages/core/src/fields/resolve-hooks.ts b/packages/core/src/fields/resolve-hooks.ts index 68b21281498..c8a122e988a 100644 --- a/packages/core/src/fields/resolve-hooks.ts +++ b/packages/core/src/fields/resolve-hooks.ts @@ -1,63 +1,63 @@ -import { - type BaseListTypeInfo, - type FieldHooks, - type MaybePromise -} from '../types' +import { type MaybePromise } from '../types' -// force new syntax for built-in fields -// and block hooks from using resolveInput, they should use GraphQL resolvers -export type InternalFieldHooks = - Omit, 'validateInput' | 'validateDelete' | 'resolveInput'> - -function merge < - R, - A extends (r: R) => MaybePromise, - B extends (r: R) => MaybePromise -> (a?: A, b?: B) { - if (!a && !b) return undefined - return async (args: R) => { +function mergeVoidFn < + Args, + A extends ((args: Args) => MaybePromise) | undefined, + B extends ((args: Args) => MaybePromise) | undefined +> (a: A, b: B) { + if (!a) return b + if (!b) return a + return async (args: Args) => { await a?.(args) await b?.(args) } } -/** @deprecated, TODO: remove in breaking change */ -function resolveValidateHooks ({ - validate, - validateInput, - validateDelete -}: FieldHooks): Exclude['validate'], (...args: any) => any> | undefined { - if (!validate && !validateInput && !validateDelete) return +type ExpandedHooks = { + create?: (args: CreateArgs) => MaybePromise + update?: (args: UpdateArgs) => MaybePromise + delete?: (args: DeleteArgs) => MaybePromise +} + +type Hooks = + | ((args: CreateArgs | UpdateArgs | DeleteArgs) => MaybePromise) + | ExpandedHooks + +export function merge ( + a: Hooks | undefined, + b: Hooks | undefined, +): Hooks | undefined { + if (!a) return b + if (!b) return a + if (typeof a === 'function' && typeof b === 'function') { + return mergeVoidFn(a, b) + } + const expandedA = expandHooks(a) + const expandedB = expandHooks(b) return { - create: merge(validateInput, typeof validate === 'function' ? validate : validate?.create), - update: merge(validateInput, typeof validate === 'function' ? validate : validate?.update), - delete: merge(validateDelete, typeof validate === 'function' ? validate : validate?.delete), + create: mergeVoidFn(expandedA.create, expandedB.create), + update: mergeVoidFn(expandedA.update, expandedB.update), + delete: mergeVoidFn(expandedA.delete, expandedB.delete), } } -export function mergeFieldHooks ( - builtin?: InternalFieldHooks, - hooks?: FieldHooks, -) { - if (hooks === undefined) return builtin - if (builtin === undefined) return hooks +function expandHooks ( + fn: Hooks, +): ExpandedHooks { + return typeof fn === 'function' + ? { create: fn, update: fn, delete: fn } + : (fn) +} - const builtinValidate = resolveValidateHooks(builtin) - const hooksValidate = resolveValidateHooks(hooks) - return { - ...hooks, - // WARNING: beforeOperation is _after_ a user beforeOperation hook, TODO: this is align with user expectations about when "operations" happen - // our *Operation hooks are built-in, and should happen nearest to the database - beforeOperation: merge(hooks.beforeOperation, builtin.beforeOperation), - afterOperation: merge(builtin.afterOperation, hooks.afterOperation), - validate: (builtinValidate || hooksValidate) ? { - create: merge(builtinValidate?.create, hooksValidate?.create), - update: merge(builtinValidate?.update, hooksValidate?.update), - delete: merge(builtinValidate?.delete, hooksValidate?.delete) - } : undefined, +const emptyFn = () => {} - // TODO: remove in breaking change - validateInput: undefined, // prevent continuation - validateDelete: undefined, // prevent continuation - } satisfies FieldHooks +export function expandVoidHooks ( + hooks: Hooks | undefined, +): Required> { + const expanded = hooks ? expandHooks(hooks) : {} + return { + create: expanded.create ?? emptyFn, + update: expanded.update ?? emptyFn, + delete: expanded.delete ?? emptyFn + } } diff --git a/packages/core/src/fields/types/bigInt/index.ts b/packages/core/src/fields/types/bigInt/index.ts index 8d243078fa4..c5f2007df37 100644 --- a/packages/core/src/fields/types/bigInt/index.ts +++ b/packages/core/src/fields/types/bigInt/index.ts @@ -11,7 +11,6 @@ import { resolveDbNullable, makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' export type BigIntFieldConfig = CommonFieldConfig & { @@ -122,7 +121,10 @@ export function bigInt (config: BigIntFi extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { uniqueWhere: isIndexed === 'unique' ? { arg: g.arg({ type: g.BigInt }) } : undefined, where: { diff --git a/packages/core/src/fields/types/calendarDay/index.ts b/packages/core/src/fields/types/calendarDay/index.ts index b9705c505fa..bb3f59789b0 100644 --- a/packages/core/src/fields/types/calendarDay/index.ts +++ b/packages/core/src/fields/types/calendarDay/index.ts @@ -9,7 +9,6 @@ import { type CalendarDayFieldMeta } from './views' import { g } from '../../..' import { filters } from '../../filters' import { makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' export type CalendarDayFieldConfig = CommonFieldConfig & { @@ -74,7 +73,10 @@ export function calendarDay (config: Cal nativeType: usesNativeDateType ? 'Date' : undefined, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { uniqueWhere: isIndexed === 'unique' diff --git a/packages/core/src/fields/types/decimal/index.ts b/packages/core/src/fields/types/decimal/index.ts index 669b484d2ee..a8afb4d7fe9 100644 --- a/packages/core/src/fields/types/decimal/index.ts +++ b/packages/core/src/fields/types/decimal/index.ts @@ -9,7 +9,6 @@ import { import { g } from '../../..' import { filters } from '../../filters' import { makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' export type DecimalFieldConfig = CommonFieldConfig & { @@ -124,7 +123,10 @@ export function decimal (config: Decimal extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { uniqueWhere: isIndexed === 'unique' ? { arg: g.arg({ type: g.Decimal }) } : undefined, where: { diff --git a/packages/core/src/fields/types/file/index.ts b/packages/core/src/fields/types/file/index.ts index a8471cabfd7..738bb2e9025 100644 --- a/packages/core/src/fields/types/file/index.ts +++ b/packages/core/src/fields/types/file/index.ts @@ -7,10 +7,7 @@ import { fieldType, } from '../../../types' import { g } from '../../..' -import { - type InternalFieldHooks, - mergeFieldHooks, -} from '../../resolve-hooks' +import { merge } from '../../resolve-hooks' export type FileFieldConfig = CommonFieldConfig & { @@ -66,23 +63,20 @@ export function file (config: FileFieldC throw Error("isIndexed: 'unique' is not a supported option for field type file") } - const hooks: InternalFieldHooks = {} - if (!storage.preserve) { - hooks.beforeOperation = async function (args) { - if (args.operation === 'update' || args.operation === 'delete') { - const filenameKey = `${fieldKey}_filename` - const filename = args.item[filenameKey] + async function beforeOperationResolver (args: any) { + if (args.operation === 'update' || args.operation === 'delete') { + const filenameKey = `${fieldKey}_filename` + const filename = args.item[filenameKey] - // this will occur on an update where a file already existed but has been - // changed, or on a delete, where there is no longer an item - if ( - (args.operation === 'delete' || - typeof args.resolvedData[fieldKey].filename === 'string' || - args.resolvedData[fieldKey].filename === null) && - typeof filename === 'string' - ) { - await args.context.files(config.storage).deleteAtSource(filename) - } + // this will occur on an update where a file already existed but has been + // changed, or on a delete, where there is no longer an item + if ( + (args.operation === 'delete' || + typeof args.resolvedData[fieldKey].filename === 'string' || + args.resolvedData[fieldKey].filename === null) && + typeof filename === 'string' + ) { + await args.context.files(config.storage).deleteAtSource(filename) } } } @@ -96,7 +90,15 @@ export function file (config: FileFieldC }, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + hooks: storage.preserve + ? config.hooks + : { + ...config.hooks, + beforeOperation: merge(config.hooks?.beforeOperation, { + update: beforeOperationResolver, + delete: beforeOperationResolver, + }) + }, input: { create: { arg: inputArg, diff --git a/packages/core/src/fields/types/float/index.ts b/packages/core/src/fields/types/float/index.ts index 29ceb0048a8..27e8084d4af 100644 --- a/packages/core/src/fields/types/float/index.ts +++ b/packages/core/src/fields/types/float/index.ts @@ -8,7 +8,6 @@ import { import { g } from '../../..' import { filters } from '../../filters' import { makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' export type FloatFieldConfig = CommonFieldConfig & { @@ -90,7 +89,10 @@ export function float (config: FloatFiel extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { uniqueWhere: isIndexed === 'unique' ? { arg: g.arg({ type: g.Float }) } : undefined, where: { diff --git a/packages/core/src/fields/types/image/index.ts b/packages/core/src/fields/types/image/index.ts index 2d1f087207d..d3d5ce305c3 100644 --- a/packages/core/src/fields/types/image/index.ts +++ b/packages/core/src/fields/types/image/index.ts @@ -9,10 +9,7 @@ import type { import { fieldType, } from '../../../types' import { g } from '../../..' import { SUPPORTED_IMAGE_EXTENSIONS } from './utils' -import { - type InternalFieldHooks, - mergeFieldHooks, -} from '../../resolve-hooks' +import { merge } from '../../resolve-hooks' export type ImageFieldConfig = CommonFieldConfig & { @@ -92,27 +89,24 @@ export function image (config: ImageFiel throw Error("isIndexed: 'unique' is not a supported option for field type image") } - const hooks: InternalFieldHooks = {} - if (!storage.preserve) { - hooks.beforeOperation = async (args) => { - if (args.operation === 'update' || args.operation === 'delete') { - const idKey = `${fieldKey}_id` - const id = args.item[idKey] - const extensionKey = `${fieldKey}_extension` - const extension = args.item[extensionKey] - - // this will occur on an update where an image already existed but has been - // changed, or on a delete, where there is no longer an item - if ( - (args.operation === 'delete' || - typeof args.resolvedData[fieldKey].id === 'string' || - args.resolvedData[fieldKey].id === null) && - typeof id === 'string' && - typeof extension === 'string' && - isValidImageExtension(extension) - ) { - await args.context.images(config.storage).deleteAtSource(id, extension) - } + async function beforeOperationResolver (args: any) { // TODO: types + if (args.operation === 'update' || args.operation === 'delete') { + const idKey = `${fieldKey}_id` + const id = args.item[idKey] + const extensionKey = `${fieldKey}_extension` + const extension = args.item[extensionKey] + + // this will occur on an update where an image already existed but has been + // changed, or on a delete, where there is no longer an item + if ( + (args.operation === 'delete' || + typeof args.resolvedData[fieldKey].id === 'string' || + args.resolvedData[fieldKey].id === null) && + typeof id === 'string' && + typeof extension === 'string' && + isValidImageExtension(extension) + ) { + await args.context.images(config.storage).deleteAtSource(id, extension) } } } @@ -129,7 +123,15 @@ export function image (config: ImageFiel }, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + hooks: storage.preserve + ? config.hooks + : { + ...config.hooks, + beforeOperation: merge(config.hooks?.beforeOperation, { + update: beforeOperationResolver, + delete: beforeOperationResolver, + }), + }, input: { create: { arg: inputArg, diff --git a/packages/core/src/fields/types/integer/index.ts b/packages/core/src/fields/types/integer/index.ts index 9d531cd6741..becb2f96d9c 100644 --- a/packages/core/src/fields/types/integer/index.ts +++ b/packages/core/src/fields/types/integer/index.ts @@ -11,7 +11,6 @@ import { resolveDbNullable, makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' export type IntegerFieldConfig = CommonFieldConfig & { @@ -118,7 +117,10 @@ export function integer (config: Integer extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { uniqueWhere: isIndexed === 'unique' ? { arg: g.arg({ type: g.Int }) } : undefined, where: { diff --git a/packages/core/src/fields/types/multiselect/index.ts b/packages/core/src/fields/types/multiselect/index.ts index 53025a03917..2a5f43617c4 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -9,7 +9,6 @@ import { } from '../../../types' import { g } from '../../..' import { makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' export type MultiselectFieldConfig = CommonFieldConfig & @@ -113,7 +112,10 @@ export function multiselect ( ...config, ui, __ksTelemetryFieldTypeName: '@keystone-6/multiselect', - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, views: '@keystone-6/core/fields/types/multiselect/views', getAdminMeta: () => ({ options: transformedConfig.options, diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index dfe2128cd27..8b8f77fd2f5 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -11,7 +11,6 @@ import { import { g } from '../../..' import { type PasswordFieldMeta } from './views' import { makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' import { isObjectType, type GraphQLSchema } from 'graphql' export type PasswordFieldConfig = @@ -136,7 +135,10 @@ export function password (config: Passwo extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { where: mode === 'required' diff --git a/packages/core/src/fields/types/select/index.ts b/packages/core/src/fields/types/select/index.ts index eaf7f4e61c5..9b8a3e9b5ce 100644 --- a/packages/core/src/fields/types/select/index.ts +++ b/packages/core/src/fields/types/select/index.ts @@ -10,7 +10,6 @@ import { import { g } from '../../..' import { filters } from '../../filters' import { makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' export type SelectFieldConfig = CommonFieldConfig & @@ -95,7 +94,10 @@ export function select (config: SelectFi ...config, mode, ui, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, __ksTelemetryFieldTypeName: '@keystone-6/select', views: '@keystone-6/core/fields/types/select/views', getAdminMeta: () => ({ diff --git a/packages/core/src/fields/types/text/index.ts b/packages/core/src/fields/types/text/index.ts index a02ef3d9636..90655d99575 100644 --- a/packages/core/src/fields/types/text/index.ts +++ b/packages/core/src/fields/types/text/index.ts @@ -8,7 +8,6 @@ import { import { g } from '../../..' import { makeValidateHook } from '../../non-null-graphql' import { filters } from '../../filters' -import { mergeFieldHooks } from '../../resolve-hooks' export type TextFieldConfig = CommonFieldConfig & { @@ -142,7 +141,10 @@ export function text ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { uniqueWhere: isIndexed === 'unique' ? { arg: g.arg({ type: g.String }) } : undefined, where: { diff --git a/packages/core/src/fields/types/timestamp/index.ts b/packages/core/src/fields/types/timestamp/index.ts index 9aed981f77f..f548b7ed69f 100644 --- a/packages/core/src/fields/types/timestamp/index.ts +++ b/packages/core/src/fields/types/timestamp/index.ts @@ -8,7 +8,6 @@ import { import { g } from '../../..' import { filters } from '../../filters' import { makeValidateHook } from '../../non-null-graphql' -import { mergeFieldHooks } from '../../resolve-hooks' import { type TimestampFieldMeta } from './views' export type TimestampFieldConfig = @@ -73,7 +72,10 @@ export function timestamp ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks({ validate }, config.hooks), + hooks: { + ...config.hooks, + validate + }, input: { uniqueWhere: isIndexed === 'unique' ? { arg: g.arg({ type: g.DateTime }) } : undefined, where: { diff --git a/packages/core/src/lib/core/initialise-lists.ts b/packages/core/src/lib/core/initialise-lists.ts index 4552790c287..e1e69fbb4c5 100644 --- a/packages/core/src/lib/core/initialise-lists.ts +++ b/packages/core/src/lib/core/initialise-lists.ts @@ -44,6 +44,7 @@ import { } from './resolve-relationships' import { outputTypeField } from './queries/output-field' import { assertFieldsValid } from './field-assertions' +import { expandVoidHooks } from '../../fields/resolve-hooks' export type InitialisedField = { fieldKey: string @@ -234,83 +235,19 @@ function getIsEnabledField (f: FieldConfigType, listKey: string, list: Partially } } -function defaultOperationHook () {} function defaultListHooksResolveInput ({ resolvedData }: { resolvedData: any }) { return resolvedData } -function parseListHooksResolveInput (f: ListHooks['resolveInput']) { - if (typeof f === 'function') { - return { - create: f, - update: f, - } - } - - const { - create = defaultListHooksResolveInput, - update = defaultListHooksResolveInput - } = f ?? {} - return { create, update } -} - -function parseListHooksValidate (f: ListHooks['validate']) { - if (typeof f === 'function') { - return { - create: f, - update: f, - delete: f, - } - } - - const { - create = defaultOperationHook, - update = defaultOperationHook, - delete: delete_ = defaultOperationHook, - } = f ?? {} - return { create, update, delete: delete_ } -} - -function parseListHooksBeforeOperation (f: ListHooks['beforeOperation']) { - if (typeof f === 'function') { - return { - create: f, - update: f, - delete: f, - } - } - - const { - create = defaultOperationHook, - update = defaultOperationHook, - delete: _delete = defaultOperationHook, - } = f ?? {} - return { create, update, delete: _delete } -} - -function parseListHooksAfterOperation (f: ListHooks['afterOperation']) { - if (typeof f === 'function') { - return { - create: f, - update: f, - delete: f, - } - } - - const { - create = defaultOperationHook, - update = defaultOperationHook, - delete: _delete = defaultOperationHook, - } = f ?? {} - return { create, update, delete: _delete } -} - function parseListHooks (hooks: ListHooks): ResolvedListHooks { return { - resolveInput: parseListHooksResolveInput(hooks.resolveInput), - validate: parseListHooksValidate(hooks.validate), - beforeOperation: parseListHooksBeforeOperation(hooks.beforeOperation), - afterOperation: parseListHooksAfterOperation(hooks.afterOperation), + resolveInput: { + create: typeof hooks.resolveInput === 'function' ? hooks.resolveInput : hooks.resolveInput?.create ?? defaultListHooksResolveInput, + update: typeof hooks.resolveInput === 'function' ? hooks.resolveInput : hooks.resolveInput?.update ?? defaultListHooksResolveInput, + }, + validate: expandVoidHooks(hooks.validate), + beforeOperation: expandVoidHooks(hooks.beforeOperation), + afterOperation: expandVoidHooks(hooks.afterOperation), } } @@ -325,46 +262,16 @@ function defaultFieldHooksResolveInput ({ } function parseFieldHooks ( - fieldKey: string, hooks: FieldHooks, ): ResolvedFieldHooks { - /** @deprecated, TODO: remove in breaking change */ - if (hooks.validate !== undefined) { - if (hooks.validateInput !== undefined) throw new TypeError(`"hooks.validate" conflicts with "hooks.validateInput" for the "${fieldKey}" field`) - if (hooks.validateDelete !== undefined) throw new TypeError(`"hooks.validate" conflicts with "hooks.validateDelete" for the "${fieldKey}" field`) - - if (typeof hooks.validate === 'function') { - return parseFieldHooks(fieldKey, { - ...hooks, - validate: { - create: hooks.validate, - update: hooks.validate, - delete: hooks.validate, - } - }) - } - } - return { resolveInput: { - create: hooks.resolveInput ?? defaultFieldHooksResolveInput, - update: hooks.resolveInput ?? defaultFieldHooksResolveInput, - }, - validate: { - create: hooks.validate?.create ?? hooks.validateInput ?? defaultOperationHook, - update: hooks.validate?.update ?? hooks.validateInput ?? defaultOperationHook, - delete: hooks.validate?.delete ?? hooks.validateDelete ?? defaultOperationHook, - }, - beforeOperation: { - create: hooks.beforeOperation ?? defaultOperationHook, - update: hooks.beforeOperation ?? defaultOperationHook, - delete: hooks.beforeOperation ?? defaultOperationHook, - }, - afterOperation: { - create: hooks.afterOperation ?? defaultOperationHook, - update: hooks.afterOperation ?? defaultOperationHook, - delete: hooks.afterOperation ?? defaultOperationHook, + create: typeof hooks.resolveInput === 'function' ? hooks.resolveInput : hooks.resolveInput?.create ?? defaultFieldHooksResolveInput, + update: typeof hooks.resolveInput === 'function' ? hooks.resolveInput : hooks.resolveInput?.update ?? defaultFieldHooksResolveInput, }, + validate: expandVoidHooks(hooks.validate), + beforeOperation: expandVoidHooks(hooks.beforeOperation), + afterOperation: expandVoidHooks(hooks.afterOperation), } } @@ -695,7 +602,7 @@ function getListsWithInitialisedFields ( dbField: f.dbField as ResolvedDBField, access: parseFieldAccessControl(f.access), - hooks: parseFieldHooks(fieldKey, f.hooks ?? {}), + hooks: parseFieldHooks(f.hooks ?? {}), graphql: { cacheHint: f.graphql?.cacheHint, isEnabled: isEnabledField, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 6263985935d..c441c8d5569 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -52,25 +52,6 @@ function injectDefaults (config: KeystoneConfigPre, defaultIdField: IdFieldConfi } } - /** @deprecated, TODO: remove in breaking change */ - for (const [listKey, list] of Object.entries(updated)) { - if (list.hooks === undefined) continue - if (list.hooks.validate !== undefined) { - if (list.hooks.validateInput !== undefined) throw new TypeError(`"hooks.validate" conflicts with "hooks.validateInput" for the "${listKey}" list`) - if (list.hooks.validateDelete !== undefined) throw new TypeError(`"hooks.validate" conflicts with "hooks.validateDelete" for the "${listKey}" list`) - continue - } - - list.hooks = { - ...list.hooks, - validate: { - create: list.hooks.validateInput, - update: list.hooks.validateInput, - delete: list.hooks.validateDelete - } - } - } - return updated } diff --git a/packages/core/src/types/config/hooks.ts b/packages/core/src/types/config/hooks.ts index 046476ebd3a..a8cb1c985d1 100644 --- a/packages/core/src/types/config/hooks.ts +++ b/packages/core/src/types/config/hooks.ts @@ -69,16 +69,6 @@ export type ListHooks = { delete?: ValidateHook } - /** - * @deprecated, replaced by validate^ - */ - validateInput?: ValidateHook - - /** - * @deprecated, replaced by validate^ - */ - validateDelete?: ValidateHook - /** * Used to **cause side effects** before a create, update, or delete operation once all validateInput hooks have resolved */ @@ -133,16 +123,16 @@ export type FieldHooks< */ resolveInput?: | ResolveInputFieldHook -// TODO: add in breaking change -// | { -// create?: ResolveInputFieldHook -// update?: ResolveInputFieldHook -// } + | { + create?: ResolveInputFieldHook + update?: ResolveInputFieldHook + } + /** * Used to **validate** if a create, update or delete operation is OK */ - validate?: + validate?: | ValidateFieldHook | { create?: ValidateFieldHook @@ -150,27 +140,27 @@ export type FieldHooks< delete?: ValidateFieldHook } - /** - * @deprecated, replaced by validate^ - * Used to **validate the input** for create and update operations once all resolveInput hooks resolved - */ - validateInput?: ValidateFieldHook - - /** - * @deprecated, replaced by validate^ - * Used to **validate** that a delete operation can happen after access control has occurred - */ - validateDelete?: ValidateFieldHook - /** * Used to **cause side effects** before a create, update, or delete operation once all validateInput hooks have resolved */ - beforeOperation?: BeforeOperationFieldHook + beforeOperation?: + | BeforeOperationFieldHook + | { + create?: BeforeOperationFieldHook + update?: BeforeOperationFieldHook + delete?: BeforeOperationFieldHook + } /** * Used to **cause side effects** after a create, update, or delete operation operation has occurred */ - afterOperation?: AfterOperationFieldHook + afterOperation?: + | AfterOperationFieldHook + | { + create?: AfterOperationFieldHook + update?: AfterOperationFieldHook + delete?: AfterOperationFieldHook + } } export type ResolvedFieldHooks< diff --git a/packages/fields-document/src/structure.ts b/packages/fields-document/src/structure.ts index 45cdec8e635..f314b680171 100644 --- a/packages/fields-document/src/structure.ts +++ b/packages/fields-document/src/structure.ts @@ -42,15 +42,17 @@ export function structure ({ const unreferencedConcreteInterfaceImplementations: g.ObjectType[] = [] const name = meta.listKey + meta.fieldKey[0].toUpperCase() + meta.fieldKey.slice(1) + const innerUpdate = typeof config.hooks?.resolveInput === 'function' ? config.hooks.resolveInput : config.hooks?.resolveInput?.update return jsonFieldTypePolyfilledForSQLite( meta.provider, { ...config, hooks: { ...config.hooks, - async resolveInput (args) { - let val = args.resolvedData[meta.fieldKey] - if (args.operation === 'update') { + resolveInput: { + create: typeof config.hooks?.resolveInput === 'function' ? config.hooks.resolveInput : config.hooks?.resolveInput?.create, + update: async args => { + let val = args.resolvedData[meta.fieldKey] let prevVal = args.item[meta.fieldKey] if (meta.provider === 'sqlite') { prevVal = JSON.parse(prevVal as any) @@ -60,14 +62,13 @@ export function structure ({ if (meta.provider === 'sqlite') { val = JSON.stringify(val) } - } - - return config.hooks?.resolveInput - ? config.hooks.resolveInput({ + return innerUpdate + ? innerUpdate({ ...args, resolvedData: { ...args.resolvedData, [meta.fieldKey]: val }, }) : val + }, }, }, input: {