diff --git a/.changeset/honest-moles-doubt.md b/.changeset/honest-moles-doubt.md new file mode 100644 index 00000000000..b0ea3bd7d5f --- /dev/null +++ b/.changeset/honest-moles-doubt.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": patch +--- + +Update built-in fields to use newer validate hook syntax diff --git a/packages/core/src/fields/non-null-graphql.ts b/packages/core/src/fields/non-null-graphql.ts index a8561ba4318..ba87f26f7f1 100644 --- a/packages/core/src/fields/non-null-graphql.ts +++ b/packages/core/src/fields/non-null-graphql.ts @@ -13,13 +13,10 @@ export function getResolvedIsNullable ( return true } -export function resolveHasValidation ({ - db, - validation -}: { - db?: { isNullable?: boolean } +export function resolveHasValidation ( + db?: { isNullable?: boolean }, validation?: unknown -}) { +) { if (db?.isNullable === false) return true if (validation !== undefined) return true return false diff --git a/packages/core/src/fields/resolve-hooks.ts b/packages/core/src/fields/resolve-hooks.ts new file mode 100644 index 00000000000..da1bae03a51 --- /dev/null +++ b/packages/core/src/fields/resolve-hooks.ts @@ -0,0 +1,78 @@ +import type { FieldHooks, BaseListTypeInfo } from '../types' + +function splitValidateHooks ( + { validate, validateInput, validateDelete }: FieldHooks +): Exclude["validate"], Function> | undefined { + if (validateInput || validateDelete) { + return { + create: validateInput, + update: validateInput, + delete: validateDelete, + } + } + + if (!validate) return undefined + + if (typeof validate === 'function') { + return { create: validate, update: validate, delete: validate } + } + + return validate +} + +// force new syntax for built-in fields +// also, we don't allow built-in hooks to specify resolveInput, +// since they can do it via graphql resolvers +export type InternalFieldHooks = + Omit, 'validateInput' | 'validateDelete' | 'resolveInput'> + +/** + * Utility function to convert deprecated field hook syntax to the new syntax + * Handles merging any built-in field hooks into the user-provided hooks + */ +export function mergeFieldHooks ( + builtin?: InternalFieldHooks, + hooks: FieldHooks = {}, +): FieldHooks { + if (builtin === undefined) return hooks + + const result: FieldHooks = { + resolveInput: hooks?.resolveInput, + } + + if (hooks.beforeOperation || builtin.beforeOperation) { + result.beforeOperation = async (args) => { + await hooks.beforeOperation?.(args) + await builtin.beforeOperation?.(args) + } + } + + if (hooks.afterOperation || builtin.afterOperation) { + result.afterOperation = async (args) => { + await hooks.afterOperation?.(args) + await builtin.afterOperation?.(args) + } + } + + const builtinValidate = splitValidateHooks(builtin) + const fieldValidate = splitValidateHooks(hooks) + + if (builtinValidate || fieldValidate) { + result.validate = { + create: async (args) => { + await builtinValidate?.create?.(args) + await fieldValidate?.create?.(args) + }, + update: async (args) => { + await builtinValidate?.update?.(args) + await fieldValidate?.update?.(args) + }, + delete: async (args) => { + await builtinValidate?.delete?.(args) + await fieldValidate?.delete?.(args) + }, + } + } + + return result +} diff --git a/packages/core/src/fields/types/bigInt/index.ts b/packages/core/src/fields/types/bigInt/index.ts old mode 100644 new mode 100755 index 56bf38c8da2..a3f25f86717 --- a/packages/core/src/fields/types/bigInt/index.ts +++ b/packages/core/src/fields/types/bigInt/index.ts @@ -13,6 +13,7 @@ import { resolveHasValidation, } from '../../non-null-graphql' import { filters } from '../../filters' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type BigIntFieldConfig = CommonFieldConfig & { @@ -84,7 +85,37 @@ export function bigInt ( const mode = isNullable === false ? 'required' : 'optional' const fieldLabel = config.label ?? humanize(meta.fieldKey) - const hasValidation = resolveHasValidation(config) + const hasValidation = resolveHasValidation(config.db, validation) + + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + + if ( + (validation?.isRequired || isNullable === false) && + (value === null || + (operation === 'create' && value === undefined && !hasAutoIncDefault)) + ) { + addValidationError(`${fieldLabel} is required`) + } + if (typeof value === 'number') { + if (validation?.min !== undefined && value < validation.min) { + addValidationError( + `${fieldLabel} must be greater than or equal to ${validation.min}` + ) + } + + if (validation?.max !== undefined && value > validation.max) { + addValidationError( + `${fieldLabel} must be less than or equal to ${validation.max}` + ) + } + } + } + } return fieldType({ kind: 'scalar', @@ -102,35 +133,7 @@ export function bigInt ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: { - ...config.hooks, - validateInput: hasValidation ? async (args) => { - const value = args.resolvedData[meta.fieldKey] - - if ( - (validation?.isRequired || isNullable === false) && - (value === null || - (args.operation === 'create' && value === undefined && !hasAutoIncDefault)) - ) { - args.addValidationError(`${fieldLabel} is required`) - } - if (typeof value === 'number') { - if (validation?.min !== undefined && value < validation.min) { - args.addValidationError( - `${fieldLabel} must be greater than or equal to ${validation.min}` - ) - } - - if (validation?.max !== undefined && value > validation.max) { - args.addValidationError( - `${fieldLabel} must be less than or equal to ${validation.max}` - ) - } - } - - await config.hooks?.validateInput?.(args) - } : config.hooks?.validateInput - }, + hooks: mergeFieldHooks(hooks, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.BigInt }) } : undefined, diff --git a/packages/core/src/fields/types/calendarDay/index.ts b/packages/core/src/fields/types/calendarDay/index.ts index 2ff4d8507ce..bd32466d033 100644 --- a/packages/core/src/fields/types/calendarDay/index.ts +++ b/packages/core/src/fields/types/calendarDay/index.ts @@ -7,9 +7,14 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' +import { + assertReadIsNonNullAllowed, + getResolvedIsNullable, + resolveHasValidation, +} from '../../non-null-graphql' import { filters } from '../../filters' import { type CalendarDayFieldMeta } from './views' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type CalendarDayFieldConfig = CommonFieldConfig & { @@ -49,6 +54,7 @@ export const calendarDay = const mode = resolvedIsNullable === false ? 'required' : 'optional' const fieldLabel = config.label ?? humanize(meta.fieldKey) const usesNativeDateType = meta.provider === 'postgresql' || meta.provider === 'mysql' + const hasValidation = resolveHasValidation(config.db, validation) function resolveInput (value: string | null | undefined) { if (meta.provider === 'sqlite' || value == null) { @@ -59,6 +65,18 @@ export const calendarDay = const commonResolveFilter = mode === 'optional' ? filters.resolveCommon : (x: T) => x + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, addValidationError, operation }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + if ((validation?.isRequired || resolvedIsNullable === false) && value === null) { + addValidationError(`${fieldLabel} is required`) + } + } + } + return fieldType({ kind: 'scalar', mode, @@ -76,17 +94,7 @@ export const calendarDay = nativeType: usesNativeDateType ? 'Date' : undefined, })({ ...config, - hooks: { - ...config.hooks, - async validateInput (args) { - const value = args.resolvedData[meta.fieldKey] - if ((validation?.isRequired || resolvedIsNullable === false) && value === null) { - args.addValidationError(`${fieldLabel} is required`) - } - - await config.hooks?.validateInput?.(args) - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), 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 950ecc7b8c5..3b4c5c0f836 100644 --- a/packages/core/src/fields/types/decimal/index.ts +++ b/packages/core/src/fields/types/decimal/index.ts @@ -9,9 +9,14 @@ import { type FieldData, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' +import { + assertReadIsNonNullAllowed, + getResolvedIsNullable, + resolveHasValidation, +} from '../../non-null-graphql' import { filters } from '../../filters' import { type DecimalFieldMeta } from './views' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type DecimalFieldConfig = CommonFieldConfig & { @@ -104,6 +109,7 @@ export const decimal = : parseDecimalValueOption(meta, defaultValue, 'defaultValue') const isNullable = getResolvedIsNullable(validation, config.db) + const hasValidation = resolveHasValidation(config.db, validation) assertReadIsNonNullAllowed(meta, config, isNullable) @@ -120,29 +126,32 @@ export const decimal = map: config.db?.map, extendPrismaSchema: config.db?.extendPrismaSchema, } as const - return fieldType(dbField)({ - ...config, - hooks: { - ...config.hooks, - async validateInput (args) { - const val: Decimal | null | undefined = args.resolvedData[meta.fieldKey] - if (val === null && (validation?.isRequired || isNullable === false)) { - args.addValidationError(`${fieldLabel} is required`) + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, addValidationError, operation }) => { + if (operation === 'delete') return + + const val: Decimal | null | undefined = resolvedData[meta.fieldKey] + + if (val === null && (validation?.isRequired || isNullable === false)) { + addValidationError(`${fieldLabel} is required`) + } + if (val != null) { + if (min !== undefined && val.lessThan(min)) { + addValidationError(`${fieldLabel} must be greater than or equal to ${min}`) } - if (val != null) { - if (min !== undefined && val.lessThan(min)) { - args.addValidationError(`${fieldLabel} must be greater than or equal to ${min}`) - } - if (max !== undefined && val.greaterThan(max)) { - args.addValidationError(`${fieldLabel} must be less than or equal to ${max}`) - } + if (max !== undefined && val.greaterThan(max)) { + addValidationError(`${fieldLabel} must be less than or equal to ${max}`) } + } + } + } - await config.hooks?.validateInput?.(args) - }, - }, + return fieldType(dbField)({ + ...config, + hooks: mergeFieldHooks(hooks, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Decimal }) } : undefined, diff --git a/packages/core/src/fields/types/file/index.ts b/packages/core/src/fields/types/file/index.ts index 47c254d7bb4..fb8adbae548 100644 --- a/packages/core/src/fields/types/file/index.ts +++ b/packages/core/src/fields/types/file/index.ts @@ -7,6 +7,7 @@ import { fieldType, } from '../../../types' import { graphql } from '../../..' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type FileFieldConfig = CommonFieldConfig & { @@ -64,6 +65,27 @@ 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] + + // 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) + } + } + } + } + return fieldType({ kind: 'multi', extendPrismaSchema: config.db?.extendPrismaSchema, @@ -73,29 +95,7 @@ export function file (config: FileFieldC }, })({ ...config, - hooks: storage.preserve - ? config.hooks - : { - ...config.hooks, - async beforeOperation (args) { - await config.hooks?.beforeOperation?.(args) - 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) - } - } - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), 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 3596e5c52ee..98e9debf41c 100644 --- a/packages/core/src/fields/types/float/index.ts +++ b/packages/core/src/fields/types/float/index.ts @@ -8,8 +8,13 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' +import { + assertReadIsNonNullAllowed, + getResolvedIsNullable, + resolveHasValidation +} from '../../non-null-graphql' import { filters } from '../../filters' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type FloatFieldConfig = CommonFieldConfig & { @@ -78,6 +83,34 @@ export const float = const mode = isNullable === false ? 'required' : 'optional' const fieldLabel = config.label ?? humanize(meta.fieldKey) + const hasValidation = resolveHasValidation(config.db, validation) + + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, addValidationError, operation }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + + if ((validation?.isRequired || isNullable === false) && value === null) { + addValidationError(`${fieldLabel} is required`) + } + + if (typeof value === 'number') { + if (validation?.max !== undefined && value > validation.max) { + addValidationError( + `${fieldLabel} must be less than or equal to ${validation.max}` + ) + } + + if (validation?.min !== undefined && value < validation.min) { + addValidationError( + `${fieldLabel} must be greater than or equal to ${validation.min}` + ) + } + } + } + } return fieldType({ kind: 'scalar', @@ -90,32 +123,7 @@ export const float = extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: { - ...config.hooks, - async validateInput (args) { - const value = args.resolvedData[meta.fieldKey] - - if ((validation?.isRequired || isNullable === false) && value === null) { - args.addValidationError(`${fieldLabel} is required`) - } - - if (typeof value === 'number') { - if (validation?.max !== undefined && value > validation.max) { - args.addValidationError( - `${fieldLabel} must be less than or equal to ${validation.max}` - ) - } - - if (validation?.min !== undefined && value < validation.min) { - args.addValidationError( - `${fieldLabel} must be greater than or equal to ${validation.min}` - ) - } - } - - await config.hooks?.validateInput?.(args) - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Float }) } : undefined, diff --git a/packages/core/src/fields/types/image/index.ts b/packages/core/src/fields/types/image/index.ts index 21a55cec559..aca3b55f0f3 100644 --- a/packages/core/src/fields/types/image/index.ts +++ b/packages/core/src/fields/types/image/index.ts @@ -9,6 +9,7 @@ import { } from '../../../types' import { graphql } from '../../..' import { SUPPORTED_IMAGE_EXTENSIONS } from './utils' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type ImageFieldConfig = CommonFieldConfig & { @@ -89,6 +90,31 @@ 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) + } + } + } + } + return fieldType({ kind: 'multi', extendPrismaSchema: config.db?.extendPrismaSchema, @@ -101,33 +127,7 @@ export function image (config: ImageFiel }, })({ ...config, - hooks: storage.preserve - ? config.hooks - : { - ...config.hooks, - async beforeOperation (args) { - await config.hooks?.beforeOperation?.(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) - } - } - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), 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 a542bdf0146..ad44398288d 100644 --- a/packages/core/src/fields/types/integer/index.ts +++ b/packages/core/src/fields/types/integer/index.ts @@ -7,8 +7,13 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' +import { + assertReadIsNonNullAllowed, + getResolvedIsNullable, + resolveHasValidation +} from '../../non-null-graphql' import { filters } from '../../filters' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type IntegerFieldConfig = CommonFieldConfig & { @@ -96,6 +101,36 @@ export function integer ({ const mode = isNullable === false ? 'required' : 'optional' const fieldLabel = config.label ?? humanize(meta.fieldKey) + const hasValidation = resolveHasValidation(config.db, validation) + + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + + if ( + (validation?.isRequired || isNullable === false) && + (value === null || (operation === 'create' && value === undefined && !hasAutoIncDefault)) + ) { + addValidationError(`${fieldLabel} is required`) + } + if (typeof value === 'number') { + if (validation?.min !== undefined && value < validation.min) { + addValidationError( + `${fieldLabel} must be greater than or equal to ${validation.min}` + ) + } + + if (validation?.max !== undefined && value > validation.max) { + addValidationError( + `${fieldLabel} must be less than or equal to ${validation.max}` + ) + } + } + } + } return fieldType({ kind: 'scalar', @@ -113,34 +148,7 @@ export function integer ({ extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: { - ...config.hooks, - async validateInput (args) { - const value = args.resolvedData[meta.fieldKey] - - if ( - (validation?.isRequired || isNullable === false) && - (value === null || (args.operation === 'create' && value === undefined && !hasAutoIncDefault)) - ) { - args.addValidationError(`${fieldLabel} is required`) - } - if (typeof value === 'number') { - if (validation?.min !== undefined && value < validation.min) { - args.addValidationError( - `${fieldLabel} must be greater than or equal to ${validation.min}` - ) - } - - if (validation?.max !== undefined && value > validation.max) { - args.addValidationError( - `${fieldLabel} must be less than or equal to ${validation.max}` - ) - } - } - - await config.hooks?.validateInput?.(args) - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Int }) } : undefined, where: { diff --git a/packages/core/src/fields/types/multiselect/index.ts b/packages/core/src/fields/types/multiselect/index.ts index cf159de00f6..b803630aa8d 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -10,6 +10,7 @@ import { import { graphql } from '../../..' import { assertReadIsNonNullAllowed } from '../../non-null-graphql' import { userInputError } from '../../../lib/core/graphql-errors' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type MultiselectFieldConfig = CommonFieldConfig & @@ -86,30 +87,31 @@ export function multiselect ( ) } + const hooks: InternalFieldHooks = { + validate: (args) => { + if (args.operation === 'delete') return + + const selectedValues: readonly (string | number)[] | undefined = args.inputData[meta.fieldKey] + if (selectedValues !== undefined) { + for (const value of selectedValues) { + if (!possibleValues.has(value)) { + args.addValidationError(`${value} is not a possible value for ${fieldLabel}`) + } + } + const uniqueValues = new Set(selectedValues) + if (uniqueValues.size !== selectedValues.length) { + args.addValidationError(`${fieldLabel} must have a unique set of options selected`) + } + } + } + } + return jsonFieldTypePolyfilledForSQLite( meta.provider, { ...config, __ksTelemetryFieldTypeName: '@keystone-6/multiselect', - hooks: { - ...config.hooks, - async validateInput (args) { - const selectedValues: readonly (string | number)[] | undefined = args.inputData[meta.fieldKey] - if (selectedValues !== undefined) { - for (const value of selectedValues) { - if (!possibleValues.has(value)) { - args.addValidationError(`${value} is not a possible value for ${fieldLabel}`) - } - } - const uniqueValues = new Set(selectedValues) - if (uniqueValues.size !== selectedValues.length) { - args.addValidationError(`${fieldLabel} must have a unique set of options selected`) - } - } - - await config.hooks?.validateInput?.(args) - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), 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 044a81d9e0c..6fd5985d31a 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -5,8 +5,9 @@ import { userInputError } from '../../../lib/core/graphql-errors' import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, fieldType, type FieldTypeFunc, type CommonFieldConfig } from '../../../types' import { graphql } from '../../..' -import { getResolvedIsNullable } from '../../non-null-graphql' +import { getResolvedIsNullable, resolveHasValidation } from '../../non-null-graphql' import { type PasswordFieldMeta } from './views' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type PasswordFieldConfig = CommonFieldConfig & { @@ -109,6 +110,44 @@ export const password = return bcrypt.hash(val, workFactor) } + const hasValidation = resolveHasValidation(config.db, validation) + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = (args) => { + if (args.operation === 'delete') return + + const val = args.inputData[meta.fieldKey] + if ( + args.resolvedData[meta.fieldKey] === null && + (validation?.isRequired || isNullable === false) + ) { + args.addValidationError(`${fieldLabel} is required`) + } + if (val != null) { + if (val.length < validation.length.min) { + if (validation.length.min === 1) { + args.addValidationError(`${fieldLabel} must not be empty`) + } else { + args.addValidationError( + `${fieldLabel} must be at least ${validation.length.min} characters long` + ) + } + } + if (validation.length.max !== null && val.length > validation.length.max) { + args.addValidationError( + `${fieldLabel} must be no longer than ${validation.length.max} characters` + ) + } + if (validation.match && !validation.match.regex.test(val)) { + args.addValidationError(validation.match.explanation) + } + if (validation.rejectCommon && dumbPasswords.check(val)) { + args.addValidationError(`${fieldLabel} is too common and is not allowed`) + } + } + } + } + return fieldType({ kind: 'scalar', scalar: 'String', @@ -117,42 +156,7 @@ export const password = extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: { - ...config.hooks, - async validateInput (args) { - const val = args.inputData[meta.fieldKey] - if ( - args.resolvedData[meta.fieldKey] === null && - (validation?.isRequired || isNullable === false) - ) { - args.addValidationError(`${fieldLabel} is required`) - } - if (val != null) { - if (val.length < validation.length.min) { - if (validation.length.min === 1) { - args.addValidationError(`${fieldLabel} must not be empty`) - } else { - args.addValidationError( - `${fieldLabel} must be at least ${validation.length.min} characters long` - ) - } - } - if (validation.length.max !== null && val.length > validation.length.max) { - args.addValidationError( - `${fieldLabel} must be no longer than ${validation.length.max} characters` - ) - } - if (validation.match && !validation.match.regex.test(val)) { - args.addValidationError(validation.match.explanation) - } - if (validation.rejectCommon && dumbPasswords.check(val)) { - args.addValidationError(`${fieldLabel} is too common and is not allowed`) - } - } - - await config.hooks?.validateInput?.(args) - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), input: { where: isNullable === false diff --git a/packages/core/src/fields/types/select/index.ts b/packages/core/src/fields/types/select/index.ts index c0d46604ba2..d46d0d26b7a 100644 --- a/packages/core/src/fields/types/select/index.ts +++ b/packages/core/src/fields/types/select/index.ts @@ -8,8 +8,13 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' +import { + assertReadIsNonNullAllowed, + getResolvedIsNullable, + resolveHasValidation, +} from '../../non-null-graphql' import { filters } from '../../filters' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' import { type AdminSelectFieldMeta } from './views' export type SelectFieldConfig = @@ -68,6 +73,8 @@ export const select = const resolvedIsNullable = getResolvedIsNullable(validation, config.db) assertReadIsNonNullAllowed(meta, config, resolvedIsNullable) + const hasValidation = resolveHasValidation(config.db, validation) + const commonConfig = ( options: readonly { value: string | number, label: string }[] ): CommonFieldConfig & { @@ -81,25 +88,29 @@ export const select = `The select field at ${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed` ) } + + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + if (value != null && !values.has(value)) { + addValidationError(`${value} is not a possible value for ${fieldLabel}`) + } + if ( + (validation?.isRequired || resolvedIsNullable === false) && + (value === null || (value === undefined && operation === 'create')) + ) { + addValidationError(`${fieldLabel} is required`) + } + } + } + return { ...config, ui, - hooks: { - ...config.hooks, - async validateInput (args) { - const value = args.resolvedData[meta.fieldKey] - if (value != null && !values.has(value)) { - args.addValidationError(`${value} is not a possible value for ${fieldLabel}`) - } - if ( - (validation?.isRequired || resolvedIsNullable === false) && - (value === null || (value === undefined && args.operation === 'create')) - ) { - args.addValidationError(`${fieldLabel} is required`) - } - await config.hooks?.validateInput?.(args) - }, - }, + hooks: mergeFieldHooks(hooks, config.hooks), __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 6d0d92713fb..c0d1ea5df84 100644 --- a/packages/core/src/fields/types/text/index.ts +++ b/packages/core/src/fields/types/text/index.ts @@ -12,6 +12,7 @@ import { resolveHasValidation, } from '../../non-null-graphql' import { filters } from '../../filters' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' export type TextFieldConfig = CommonFieldConfig & { @@ -99,7 +100,34 @@ export function text ( const defaultValue = isNullable ? (defaultValue_ ?? null) : (defaultValue_ ?? '') const fieldLabel = config.label ?? humanize(meta.fieldKey) const mode = isNullable ? 'optional' : 'required' - const hasValidation = resolveHasValidation(config) || !isNullable // we make an exception for Text + const hasValidation = resolveHasValidation(config.db, validation) || !isNullable // we make an exception for Text + + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const val = resolvedData[meta.fieldKey] + if (val === null && (validation?.isRequired || isNullable === false)) { + addValidationError(`${fieldLabel} is required`) + } + if (val != null) { + if (validation?.length?.min !== undefined && val.length < validation.length.min) { + if (validation.length.min === 1) { + addValidationError(`${fieldLabel} must not be empty`) + } else { + addValidationError(`${fieldLabel} must be at least ${validation.length.min} characters long`) + } + } + if (validation?.length?.max !== undefined && val.length > validation.length.max) { + addValidationError(`${fieldLabel} must be no longer than ${validation.length.max} characters`) + } + if (validation?.match && !validation.match.regex.test(val)) { + addValidationError(validation.match.explanation || `${fieldLabel} must match ${validation.match.regex}`) + } + } + } + } return fieldType({ kind: 'scalar', @@ -112,32 +140,7 @@ export function text ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: { - ...config.hooks, - validateInput: hasValidation ? async (args) => { - const val = args.resolvedData[meta.fieldKey] - if (val === null && (validation?.isRequired || isNullable === false)) { - args.addValidationError(`${fieldLabel} is required`) - } - if (val != null) { - if (validation?.length?.min !== undefined && val.length < validation.length.min) { - if (validation.length.min === 1) { - args.addValidationError(`${fieldLabel} must not be empty`) - } else { - args.addValidationError(`${fieldLabel} must be at least ${validation.length.min} characters long`) - } - } - if (validation?.length?.max !== undefined && val.length > validation.length.max) { - args.addValidationError(`${fieldLabel} must be no longer than ${validation.length.max} characters`) - } - if (validation?.match && !validation.match.regex.test(val)) { - args.addValidationError(validation.match.explanation || `${fieldLabel} must match ${validation.match.regex}`) - } - } - - await config.hooks?.validateInput?.(args) - } : config.hooks?.validateInput - }, + hooks: mergeFieldHooks(hooks, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.String }) } : undefined, diff --git a/packages/core/src/fields/types/timestamp/index.ts b/packages/core/src/fields/types/timestamp/index.ts index 89dcb01cb36..6a84d0a2359 100644 --- a/packages/core/src/fields/types/timestamp/index.ts +++ b/packages/core/src/fields/types/timestamp/index.ts @@ -13,6 +13,7 @@ import { resolveHasValidation, } from '../../non-null-graphql' import { filters } from '../../filters' +import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' import { type TimestampFieldMeta } from './views' export type TimestampFieldConfig = @@ -62,7 +63,19 @@ export function timestamp ( assertReadIsNonNullAllowed(meta, config, resolvedIsNullable) const mode = resolvedIsNullable === false ? 'required' : 'optional' const fieldLabel = config.label ?? humanize(meta.fieldKey) - const hasValidation = resolveHasValidation(config) + const hasValidation = resolveHasValidation(config.db, validation) + + const hooks: InternalFieldHooks = {} + if (hasValidation) { + hooks.validate = ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + if ((validation?.isRequired || resolvedIsNullable === false) && value === null) { + addValidationError(`${fieldLabel} is required`) + } + } + } return fieldType({ kind: 'scalar', @@ -83,17 +96,7 @@ export function timestamp ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: { - ...config.hooks, - validateInput: hasValidation ? async (args) => { - const value = args.resolvedData[meta.fieldKey] - if ((validation?.isRequired || resolvedIsNullable === false) && value === null) { - args.addValidationError(`${fieldLabel} is required`) - } - - await config.hooks?.validateInput?.(args) - } : config.hooks?.validateInput, - }, + hooks: mergeFieldHooks(hooks, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.DateTime }) } : undefined, where: {