diff --git a/.changeset/add-isnullable-multiselect.md b/.changeset/add-isnullable-multiselect.md new file mode 100644 index 00000000000..edf3088e6da --- /dev/null +++ b/.changeset/add-isnullable-multiselect.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/core': minor +--- + +Add `db.isNullable` support for multiselect field type, defaults to false diff --git a/.changeset/fix-bigint-validation.md b/.changeset/fix-bigint-validation.md new file mode 100644 index 00000000000..2c49d9e94d8 --- /dev/null +++ b/.changeset/fix-bigint-validation.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/core': patch +--- + +Fix bigInt field type to throw if `defaultValue: { kind: 'autoincrement' }` and `validation.isRequired` is set diff --git a/.changeset/update-field-hooks.md b/.changeset/update-field-hooks.md new file mode 100644 index 00000000000..b0ea3bd7d5f --- /dev/null +++ b/.changeset/update-field-hooks.md @@ -0,0 +1,5 @@ +--- +"@keystone-6/core": patch +--- + +Update built-in fields to use newer validate hook syntax diff --git a/examples/framework-astro/src/keystone/schema.ts b/examples/framework-astro/src/keystone/schema.ts index c9d31fd605f..dc419535a4b 100644 --- a/examples/framework-astro/src/keystone/schema.ts +++ b/examples/framework-astro/src/keystone/schema.ts @@ -46,11 +46,11 @@ export const lists = { title: text({ validation: { isRequired: true } }), // we use this field to arbitrarily restrict Posts to only be viewed on a particular browser (using Post.access.filter) browser: select({ + validation: { isRequired: true }, options: [ { label: 'Chrome', value: 'chrome' }, { label: 'Firefox', value: 'firefox' }, ], - validation: { isRequired: true }, }), }, }), diff --git a/examples/testing/example-test.ts b/examples/testing/example-test.ts index a6112dd6c34..6c7ac5eef0b 100644 --- a/examples/testing/example-test.ts +++ b/examples/testing/example-test.ts @@ -33,7 +33,7 @@ test('Check that trying to create user with no name (required field) fails', asy }) }, { - message: 'You provided invalid data for this operation.\n - User.name: Name must not be empty', + message: 'You provided invalid data for this operation.\n - User.name: value must not be empty', } ) }) diff --git a/packages/core/src/fields/non-null-graphql.ts b/packages/core/src/fields/non-null-graphql.ts index a8561ba4318..f2438b7311b 100644 --- a/packages/core/src/fields/non-null-graphql.ts +++ b/packages/core/src/fields/non-null-graphql.ts @@ -1,40 +1,92 @@ -import { type BaseListTypeInfo, type CommonFieldConfig, type FieldData } from '../types' +import { + type BaseListTypeInfo, + type FieldData, +} from '../types' +import { + type ValidateFieldHook +} from '../types/config/hooks' -export function getResolvedIsNullable ( +export function resolveDbNullable ( validation: undefined | { isRequired?: boolean }, db: undefined | { isNullable?: boolean } ): boolean { - if (db?.isNullable === false) { - return false - } + if (db?.isNullable === false) return false if (db?.isNullable === undefined && validation?.isRequired) { return false } return true } -export function resolveHasValidation ({ - db, - validation -}: { - db?: { isNullable?: boolean } - validation?: unknown -}) { - if (db?.isNullable === false) return true - if (validation !== undefined) return true - return false +export function makeValidateHook ( + meta: FieldData, + config: { + label?: string, + db?: { + isNullable?: boolean + }, + graphql?: { + isNonNull?: { + read?: boolean + } + }, + validation?: { + isRequired?: boolean + [key: string]: unknown + }, + }, + f?: ValidateFieldHook +) { + const dbNullable = resolveDbNullable(config.validation, config.db) + const mode = dbNullable ? ('optional' as const) : ('required' as const) + const valueRequired = config.validation?.isRequired || !dbNullable + + assertReadIsNonNullAllowed(meta, config, dbNullable) + const addValidation = config.db?.isNullable === false || config.validation?.isRequired + if (addValidation) { + const validate = async function (args) { + const { operation, addValidationError, resolvedData } = args + + if (valueRequired) { + const value = resolvedData?.[meta.fieldKey] + if ( + (operation === 'create' && value === undefined) + || ((operation === 'create' || operation === 'update') && (value === null)) + ) { + addValidationError(`missing value`) + } + } + + await f?.(args) + } satisfies ValidateFieldHook + + return { + mode, + validate, + } + } + + return { + mode, + validate: f + } } export function assertReadIsNonNullAllowed ( meta: FieldData, - config: CommonFieldConfig, - resolvedIsNullable: boolean + config: { + graphql?: { + isNonNull?: { + read?: boolean + } + } + }, + dbNullable: boolean ) { - if (!resolvedIsNullable) return + if (!dbNullable) return if (!config.graphql?.isNonNull?.read) return throw new Error( - `The field at ${meta.listKey}.${meta.fieldKey} sets graphql.isNonNull.read: true, but not validation.isRequired: true, or db.isNullable: false\n` + + `${meta.listKey}.${meta.fieldKey} sets graphql.isNonNull.read: true, but not validation.isRequired: true (or db.isNullable: false)\n` + `Set validation.isRequired: true, or db.isNullable: false, or graphql.isNonNull.read: 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..3ab9ede6633 --- /dev/null +++ b/packages/core/src/fields/resolve-hooks.ts @@ -0,0 +1,71 @@ +import { + type BaseListTypeInfo, + type FieldHooks, + 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'> + +/** @deprecated, TODO: remove in breaking change */ +function resolveValidateHooks ({ + validate, + validateInput, + validateDelete +}: FieldHooks): Exclude["validate"], Function> | undefined { + if (validateInput || validateDelete) { + return { + create: validateInput, + update: validateInput, + delete: validateDelete, + } + } + + if (!validate) return + if (typeof validate === 'function') { + return { + create: validate, + update: validate, + delete: validate + } + } + + return validate +} + +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) => { + await a?.(args) + await b?.(args) + } +} + +export function mergeFieldHooks ( + builtin?: InternalFieldHooks, + hooks?: FieldHooks, +) { + if (hooks === undefined) return builtin + if (builtin === undefined) return hooks + + 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, + } satisfies FieldHooks +} diff --git a/packages/core/src/fields/types/bigInt/index.ts b/packages/core/src/fields/types/bigInt/index.ts index 56bf38c8da2..ff25772a15b 100644 --- a/packages/core/src/fields/types/bigInt/index.ts +++ b/packages/core/src/fields/types/bigInt/index.ts @@ -1,4 +1,3 @@ -import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, type CommonFieldConfig, @@ -7,12 +6,12 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' +import { filters } from '../../filters' import { - assertReadIsNonNullAllowed, - getResolvedIsNullable, - resolveHasValidation, + resolveDbNullable, + makeValidateHook } from '../../non-null-graphql' -import { filters } from '../../filters' +import { mergeFieldHooks } from '../../resolve-hooks' export type BigIntFieldConfig = CommonFieldConfig & { @@ -30,19 +29,23 @@ export type BigIntFieldConfig = } } -// These are the max and min values available to a 64 bit signed integer +// these are the lowest and highest values for a signed 64-bit integer const MAX_INT = 9223372036854775807n const MIN_INT = -9223372036854775808n -export function bigInt ( - config: BigIntFieldConfig = {} -): FieldTypeFunc { +export function bigInt (config: BigIntFieldConfig = {}): FieldTypeFunc { const { - isIndexed, defaultValue: _defaultValue, - validation: _validation, + isIndexed, + validation = {}, } = config + const { + isRequired = false, + min, + max + } = validation + return (meta) => { const defaultValue = _defaultValue ?? null const hasAutoIncDefault = @@ -50,47 +53,66 @@ export function bigInt ( defaultValue !== null && defaultValue.kind === 'autoincrement' - const isNullable = getResolvedIsNullable(_validation, config.db) - if (hasAutoIncDefault) { if (meta.provider === 'sqlite' || meta.provider === 'mysql') { - throw new Error(`The bigInt field at ${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' }, this is not supported on ${meta.provider}`) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' }, this is not supported on ${meta.provider}`) } + const isNullable = resolveDbNullable(validation, config.db) if (isNullable !== false) { throw new Error( - `The bigInt field at ${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' } but doesn't specify db.isNullable: false.\n` + + `${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' } but doesn't specify db.isNullable: false.\n` + `Having nullable autoincrements on Prisma currently incorrectly creates a non-nullable column so it is not allowed.\n` + `https://github.com/prisma/prisma/issues/8663` ) } + if (isRequired) { + throw new Error(`${meta.listKey}.${meta.fieldKey} defaultValue: { kind: 'autoincrement' } conflicts with validation.isRequired: true`) + } } - - const validation = { - isRequired: _validation?.isRequired ?? false, - min: _validation?.min ?? MIN_INT, - max: _validation?.max ?? MAX_INT, + if (min !== undefined && !Number.isInteger(min)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${min} but it must be an integer`) } - - for (const type of ['min', 'max'] as const) { - if (validation[type] > MAX_INT || validation[type] < MIN_INT) { - throw new Error(`The bigInt field at ${meta.listKey}.${meta.fieldKey} specifies validation.${type}: ${validation[type]} which is outside of the range of a 64bit signed integer(${MIN_INT}n - ${MAX_INT}n) which is not allowed`) - } + if (max !== undefined && !Number.isInteger(max)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${max} but it must be an integer`) + } + if (min !== undefined && (min > MAX_INT || min < MIN_INT)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${min} which is outside of the range of a 64-bit signed integer`) } - if (validation.min > validation.max) { - throw new Error(`The bigInt field at ${meta.listKey}.${meta.fieldKey} specifies a validation.max that is less than the validation.min, and therefore has no valid options`) + if (max !== undefined && (max > MAX_INT || max < MIN_INT)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${max} which is outside of the range of a 64-bit signed integer`) } + if ( + min !== undefined && + max !== undefined && + min > max + ) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies a validation.max that is less than the validation.min, and therefore has no valid options`) + } + + const hasAdditionalValidation = min !== undefined || max !== undefined + const { + mode, + validate, + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return - assertReadIsNonNullAllowed(meta, config, isNullable) + const value = resolvedData[meta.fieldKey] + if (typeof value === 'number') { + if (min !== undefined && value < min) { + addValidationError(`value must be greater than or equal to ${min}`) + } - const mode = isNullable === false ? 'required' : 'optional' - const fieldLabel = config.label ?? humanize(meta.fieldKey) - const hasValidation = resolveHasValidation(config) + if (max !== undefined && value > max) { + addValidationError(`value must be less than or equal to ${max}`) + } + } + } : undefined) return fieldType({ kind: 'scalar', mode, scalar: 'BigInt', - // This will resolve to 'index' if the boolean is true, otherwise other values - false will be converted to undefined + // this will resolve to 'index' if the boolean is true, otherwise other values - false will be converted to undefined index: isIndexed === true ? 'index' : isIndexed || undefined, default: typeof defaultValue === 'bigint' @@ -102,38 +124,9 @@ 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({ validate }, config.hooks), input: { - uniqueWhere: - isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.BigInt }) } : undefined, + uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.BigInt }) } : undefined, where: { arg: graphql.arg({ type: filters[meta.provider].BigInt[mode] }), resolve: mode === 'optional' ? filters.resolveCommon : undefined, @@ -153,19 +146,20 @@ export function bigInt ( update: { arg: graphql.arg({ type: graphql.BigInt }) }, orderBy: { arg: graphql.arg({ type: orderDirectionEnum }) }, }, - output: graphql.field({ - type: graphql.BigInt, - }), + output: graphql.field({ type: graphql.BigInt, }), __ksTelemetryFieldTypeName: '@keystone-6/bigInt', views: '@keystone-6/core/fields/types/bigInt/views', getAdminMeta () { return { validation: { - min: validation.min.toString(), - max: validation.max.toString(), - isRequired: validation.isRequired, + min: min?.toString() ?? `${MIN_INT}`, + max: max?.toString() ?? `${MAX_INT}`, + isRequired, }, - defaultValue: typeof defaultValue === 'bigint' ? defaultValue.toString() : defaultValue, + defaultValue: + typeof defaultValue === 'bigint' + ? defaultValue.toString() + : defaultValue, } }, }) diff --git a/packages/core/src/fields/types/calendarDay/index.ts b/packages/core/src/fields/types/calendarDay/index.ts index 2ff4d8507ce..89e37f182c9 100644 --- a/packages/core/src/fields/types/calendarDay/index.ts +++ b/packages/core/src/fields/types/calendarDay/index.ts @@ -1,15 +1,15 @@ -import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, - fieldType, - type FieldTypeFunc, type CommonFieldConfig, + type FieldTypeFunc, + fieldType, orderDirectionEnum, } from '../../../types' +import { type CalendarDayFieldMeta } from './views' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' import { filters } from '../../filters' -import { type CalendarDayFieldMeta } from './views' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type CalendarDayFieldConfig = CommonFieldConfig & { @@ -25,14 +25,13 @@ export type CalendarDayFieldConfig = } } -export const calendarDay = - ({ +export function calendarDay (config: CalendarDayFieldConfig = {}): FieldTypeFunc { + const { isIndexed, validation, defaultValue, - ...config - }: CalendarDayFieldConfig = {}): FieldTypeFunc => - meta => { + } = config + return (meta) => { if (typeof defaultValue === 'string') { try { graphql.CalendarDay.graphQLType.parseValue(defaultValue) @@ -43,11 +42,6 @@ export const calendarDay = } } - const resolvedIsNullable = getResolvedIsNullable(validation, config.db) - assertReadIsNonNullAllowed(meta, config, resolvedIsNullable) - - const mode = resolvedIsNullable === false ? 'required' : 'optional' - const fieldLabel = config.label ?? humanize(meta.fieldKey) const usesNativeDateType = meta.provider === 'postgresql' || meta.provider === 'mysql' function resolveInput (value: string | null | undefined) { @@ -57,6 +51,10 @@ export const calendarDay = return dateStringToDateObjectInUTC(value) } + const { + mode, + validate, + } = makeValidateHook(meta, config) const commonResolveFilter = mode === 'optional' ? filters.resolveCommon : (x: T) => x return fieldType({ @@ -76,17 +74,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({ validate }, config.hooks), input: { uniqueWhere: isIndexed === 'unique' @@ -137,6 +125,7 @@ export const calendarDay = }, }) } +} function dateStringToDateObjectInUTC (value: string) { return new Date(`${value}T00:00Z`) diff --git a/packages/core/src/fields/types/decimal/index.ts b/packages/core/src/fields/types/decimal/index.ts index 950ecc7b8c5..b3f9d6f1d5d 100644 --- a/packages/core/src/fields/types/decimal/index.ts +++ b/packages/core/src/fields/types/decimal/index.ts @@ -1,17 +1,17 @@ -import { humanize } from '../../../lib/utils' import { - fieldType, - type FieldTypeFunc, type BaseListTypeInfo, type CommonFieldConfig, + type FieldData, + type FieldTypeFunc, + fieldType, orderDirectionEnum, Decimal, - type FieldData, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' import { filters } from '../../filters' import { type DecimalFieldMeta } from './views' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type DecimalFieldConfig = CommonFieldConfig & { @@ -48,16 +48,16 @@ function parseDecimalValueOption (meta: FieldData, value: string, name: string) return decimal } -export const decimal = - ({ +export function decimal (config: DecimalFieldConfig = {}): FieldTypeFunc { + const { isIndexed, precision = 18, scale = 4, validation, defaultValue, - ...config - }: DecimalFieldConfig = {}): FieldTypeFunc => - meta => { + } = config + + return (meta) => { if (meta.provider === 'sqlite') { throw new Error('The decimal field does not support sqlite') } @@ -81,8 +81,6 @@ export const decimal = ) } - const fieldLabel = config.label ?? humanize(meta.fieldKey) - const max = validation?.max === undefined ? undefined @@ -103,11 +101,24 @@ export const decimal = ? undefined : parseDecimalValueOption(meta, defaultValue, 'defaultValue') - const isNullable = getResolvedIsNullable(validation, config.db) - - assertReadIsNonNullAllowed(meta, config, isNullable) + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value: Decimal | null | undefined = resolvedData[meta.fieldKey] + if (value != null) { + if (min !== undefined && value.lessThan(min)) { + addValidationError(`value must be greater than or equal to ${min}`) + } + + if (max !== undefined && value.greaterThan(max)) { + addValidationError(`value must be less than or equal to ${max}`) + } + } + }) - const mode = isNullable === false ? 'required' : 'optional' const index = isIndexed === true ? 'index' : isIndexed || undefined const dbField = { kind: 'scalar', @@ -120,29 +131,10 @@ 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`) - } - 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}`) - } - } - - await config.hooks?.validateInput?.(args) - }, - }, + hooks: mergeFieldHooks({ validate }, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Decimal }) } : undefined, @@ -192,3 +184,4 @@ export const decimal = }), }) } +} diff --git a/packages/core/src/fields/types/file/index.ts b/packages/core/src/fields/types/file/index.ts index 47c254d7bb4..78882b594b1 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 & { @@ -55,15 +56,34 @@ export function file (config: FileFieldC const storage = meta.getStorage(config.storage) if (!storage) { - throw new Error( - `${meta.listKey}.${fieldKey} has storage set to ${config.storage}, but no storage configuration was found for that key` - ) + throw new Error(`${meta.listKey}.${fieldKey} has storage set to ${config.storage}, but no storage configuration was found for that key`) } if ('isIndexed' in config) { 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 +93,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..f77048b9051 100644 --- a/packages/core/src/fields/types/float/index.ts +++ b/packages/core/src/fields/types/float/index.ts @@ -1,5 +1,4 @@ // Float in GQL: A signed double-precision floating-point value. -import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, type FieldTypeFunc, @@ -8,8 +7,9 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' import { filters } from '../../filters' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type FloatFieldConfig = CommonFieldConfig & { @@ -27,98 +27,69 @@ export type FloatFieldConfig = } } -export const float = - ({ - isIndexed, - validation, +export function float (config: FloatFieldConfig = {}): FieldTypeFunc { + const { defaultValue, - ...config - }: FloatFieldConfig = {}): FieldTypeFunc => - meta => { - if ( - defaultValue !== undefined && - (typeof defaultValue !== 'number' || !Number.isFinite(defaultValue)) - ) { - throw new Error( - `The float field at ${meta.listKey}.${meta.fieldKey} specifies a default value of: ${defaultValue} but it must be a valid finite number` - ) - } + isIndexed, + validation = {}, + } = config - if ( - validation?.min !== undefined && - (typeof validation.min !== 'number' || !Number.isFinite(validation.min)) - ) { - throw new Error( - `The float field at ${meta.listKey}.${meta.fieldKey} specifies validation.min: ${validation.min} but it must be a valid finite number` - ) - } + const { + isRequired = false, + min, + max + } = validation - if ( - validation?.max !== undefined && - (typeof validation.max !== 'number' || !Number.isFinite(validation.max)) - ) { - throw new Error( - `The float field at ${meta.listKey}.${meta.fieldKey} specifies validation.max: ${validation.max} but it must be a valid finite number` - ) + return (meta) => { + if (defaultValue !== undefined && (typeof defaultValue !== 'number' || !Number.isFinite(defaultValue))) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies a default value of: ${defaultValue} but it must be a valid finite number`) + } + if (min !== undefined && (typeof min !== 'number' || !Number.isFinite(min))) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${min} but it must be a valid finite number`) + } + if (max !== undefined && (typeof max !== 'number' || !Number.isFinite(max))) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${max} but it must be a valid finite number`) } - if ( - validation?.min !== undefined && - validation?.max !== undefined && - validation.min > validation.max + min !== undefined && + max !== undefined && + min > max ) { - throw new Error( - `The float field at ${meta.listKey}.${meta.fieldKey} specifies a validation.max that is less than the validation.min, and therefore has no valid options` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies a validation.max that is less than the validation.min, and therefore has no valid options`) } - const isNullable = getResolvedIsNullable(validation, config.db) + const hasAdditionalValidation = min !== undefined || max !== undefined + const { + mode, + validate, + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return - assertReadIsNonNullAllowed(meta, config, isNullable) + const value = resolvedData[meta.fieldKey] + if (typeof value === 'number') { + if (min !== undefined && value < min) { + addValidationError(`value must be greater than or equal to ${min}`) + } - const mode = isNullable === false ? 'required' : 'optional' - const fieldLabel = config.label ?? humanize(meta.fieldKey) + if (max !== undefined && value > max) { + addValidationError(`value must be less than or equal to ${max}`) + } + } + } : undefined) return fieldType({ kind: 'scalar', mode, scalar: 'Float', index: isIndexed === true ? 'index' : isIndexed || undefined, - default: - typeof defaultValue === 'number' ? { kind: 'literal', value: defaultValue } : undefined, + default: typeof defaultValue === 'number' ? { kind: 'literal', value: defaultValue } : undefined, map: config.db?.map, 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({ validate }, config.hooks), input: { - uniqueWhere: - isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Float }) } : undefined, + uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Float }) } : undefined, where: { arg: graphql.arg({ type: filters[meta.provider].Float[mode] }), resolve: mode === 'optional' ? filters.resolveCommon : undefined, @@ -146,12 +117,13 @@ export const float = getAdminMeta () { return { validation: { - min: validation?.min || null, - max: validation?.max || null, - isRequired: validation?.isRequired ?? false, + isRequired, + min: min ?? null, + max: max ?? null, }, defaultValue: defaultValue ?? null, } }, }) } +} diff --git a/packages/core/src/fields/types/image/index.ts b/packages/core/src/fields/types/image/index.ts index 21a55cec559..7877902c6d9 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 & { @@ -80,15 +81,38 @@ export function image (config: ImageFiel const storage = meta.getStorage(config.storage) if (!storage) { - throw new Error( - `${meta.listKey}.${fieldKey} has storage set to ${config.storage}, but no storage configuration was found for that key` - ) + throw new Error(`${meta.listKey}.${fieldKey} has storage set to ${config.storage}, but no storage configuration was found for that key`) } if ('isIndexed' in config) { 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 +125,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..43e6a3499f6 100644 --- a/packages/core/src/fields/types/integer/index.ts +++ b/packages/core/src/fields/types/integer/index.ts @@ -1,14 +1,17 @@ -import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, - fieldType, - type FieldTypeFunc, type CommonFieldConfig, + type FieldTypeFunc, + fieldType, orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' import { filters } from '../../filters' +import { + resolveDbNullable, + makeValidateHook +} from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type IntegerFieldConfig = CommonFieldConfig & { @@ -26,82 +29,87 @@ export type IntegerFieldConfig = } } -// These are the max and min values available to a 32 bit signed integer +// these are the lowest and highest values for a signed 32-bit integer const MAX_INT = 2147483647 const MIN_INT = -2147483648 -export function integer ({ - isIndexed, - defaultValue: _defaultValue, - validation, - ...config -}: IntegerFieldConfig = {}): FieldTypeFunc { - return meta => { +export function integer (config: IntegerFieldConfig = {}): FieldTypeFunc { + const { + defaultValue: _defaultValue, + isIndexed, + validation = {}, + } = config + + const { + isRequired = false, + min, + max + } = validation + + return (meta) => { const defaultValue = _defaultValue ?? null const hasAutoIncDefault = typeof defaultValue == 'object' && defaultValue !== null && defaultValue.kind === 'autoincrement' - const isNullable = getResolvedIsNullable(validation, config.db) - if (hasAutoIncDefault) { if (meta.provider === 'sqlite' || meta.provider === 'mysql') { - throw new Error( - `The integer field at ${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' }, this is not supported on ${meta.provider}` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' }, this is not supported on ${meta.provider}`) } + const isNullable = resolveDbNullable(validation, config.db) if (isNullable !== false) { throw new Error( - `The integer field at ${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' } but doesn't specify db.isNullable: false.\n` + + `${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' } but doesn't specify db.isNullable: false.\n` + `Having nullable autoincrements on Prisma currently incorrectly creates a non-nullable column so it is not allowed.\n` + `https://github.com/prisma/prisma/issues/8663` ) } } - - if (validation?.min !== undefined && !Number.isInteger(validation.min)) { - throw new Error( - `The integer field at ${meta.listKey}.${meta.fieldKey} specifies validation.min: ${validation.min} but it must be an integer` - ) + if (min !== undefined && !Number.isInteger(min)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${min} but it must be an integer`) } - if (validation?.max !== undefined && !Number.isInteger(validation.max)) { - throw new Error( - `The integer field at ${meta.listKey}.${meta.fieldKey} specifies validation.max: ${validation.max} but it must be an integer` - ) + if (max !== undefined && !Number.isInteger(max)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${max} but it must be an integer`) } - - if (validation?.min !== undefined && (validation?.min > MAX_INT || validation?.min < MIN_INT)) { - throw new Error( - `The integer field at ${meta.listKey}.${meta.fieldKey} specifies validation.min: ${validation.min} which is outside of the range of a 32bit signed integer(${MIN_INT} - ${MAX_INT}) which is not allowed` - ) + if (min !== undefined && (min > MAX_INT || min < MIN_INT)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${min} which is outside of the range of a 32-bit signed integer`) } - if (validation?.max !== undefined && (validation?.max > MAX_INT || validation?.max < MIN_INT)) { - throw new Error( - `The integer field at ${meta.listKey}.${meta.fieldKey} specifies validation.max: ${validation.max} which is outside of the range of a 32bit signed integer(${MIN_INT} - ${MAX_INT}) which is not allowed` - ) + if (max !== undefined && (max > MAX_INT || max < MIN_INT)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${max} which is outside of the range of a 32-bit signed integer`) } - if ( - validation?.min !== undefined && - validation?.max !== undefined && - validation.min > validation.max + min !== undefined && + max !== undefined && + min > max ) { - throw new Error( - `The integer field at ${meta.listKey}.${meta.fieldKey} specifies a validation.max that is less than the validation.min, and therefore has no valid options` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies a validation.max that is less than the validation.min, and therefore has no valid options`) } - assertReadIsNonNullAllowed(meta, config, isNullable) + const hasAdditionalValidation = min !== undefined || max !== undefined + const { + mode, + validate, + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + if (typeof value === 'number') { + if (min !== undefined && value < min) { + addValidationError(`value must be greater than or equal to ${min}`) + } - const mode = isNullable === false ? 'required' : 'optional' - const fieldLabel = config.label ?? humanize(meta.fieldKey) + if (max !== undefined && value > max) { + addValidationError(`value must be less than or equal to ${max}`) + } + } + } : undefined) return fieldType({ kind: 'scalar', mode, scalar: 'Int', - // This will resolve to 'index' if the boolean is true, otherwise other values - false will be converted to undefined + // this will resolve to 'index' if the boolean is true, otherwise other values - false will be converted to undefined index: isIndexed === true ? 'index' : isIndexed || undefined, default: typeof defaultValue === 'number' @@ -113,34 +121,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({ validate }, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Int }) } : undefined, where: { @@ -168,9 +149,9 @@ export function integer ({ getAdminMeta () { return { validation: { - min: validation?.min ?? MIN_INT, - max: validation?.max ?? MAX_INT, - isRequired: validation?.isRequired ?? false, + min: min ?? MIN_INT, + max: max ?? MAX_INT, + isRequired, }, defaultValue: defaultValue === null || typeof defaultValue === 'number' diff --git a/packages/core/src/fields/types/multiselect/index.ts b/packages/core/src/fields/types/multiselect/index.ts index cf159de00f6..bb5614cb8f7 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -8,8 +8,8 @@ import { jsonFieldTypePolyfilledForSQLite, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed } from '../../non-null-graphql' -import { userInputError } from '../../../lib/core/graphql-errors' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type MultiselectFieldConfig = CommonFieldConfig & @@ -24,21 +24,22 @@ export type MultiselectFieldConfig = * If `enum` is provided on SQLite, it will use an enum in GraphQL but a string in the database. */ type?: 'string' | 'enum' - defaultValue?: readonly string[] + defaultValue?: readonly string[] | null } | { options: readonly { label: string, value: number }[] type: 'integer' - defaultValue?: readonly number[] + defaultValue?: readonly number[] | null } ) & { db?: { + isNullable?: boolean map?: string extendPrismaSchema?: (field: string) => string } } -// These are the max and min values available to a 32 bit signed integer +// these are the lowest and highest values for a signed 32-bit integer const MAX_INT = 2147483647 const MIN_INT = -2147483648 @@ -46,70 +47,68 @@ export function multiselect ( config: MultiselectFieldConfig ): FieldTypeFunc { const { - defaultValue = [], + defaultValue: defaultValue_, } = config + config.db ??= {} + config.db.isNullable ??= false // TODO: deprecated, remove in breaking change + const defaultValue = config.db.isNullable ? defaultValue_ : (defaultValue_ ?? []) // TODO: deprecated, remove in breaking change? + return (meta) => { if ((config as any).isIndexed === 'unique') { throw TypeError("isIndexed: 'unique' is not a supported option for field type multiselect") } - const fieldLabel = config.label ?? humanize(meta.fieldKey) - assertReadIsNonNullAllowed(meta, config, false) const output = (type: T) => nonNullList(type) const create = (type: T) => { return graphql.arg({ type: nonNullList(type) }) } - const resolveCreate = (val: T[] | null | undefined): T[] => { + const resolveCreate = (val: T[] | null | undefined): T[] | null => { const resolved = resolveUpdate(val) if (resolved === undefined) { return defaultValue as T[] } return resolved } + const resolveUpdate = ( val: T[] | null | undefined - ): T[] | undefined => { - if (val === null) { - throw userInputError('multiselect fields cannot be set to null') - } + ): T[] | null | undefined => { return val } const transformedConfig = configToOptionsAndGraphQLType(config, meta) - - const possibleValues = new Set(transformedConfig.options.map(x => x.value)) - if (possibleValues.size !== transformedConfig.options.length) { - throw new Error( - `The multiselect field at ${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed` - ) + const accepted = new Set(transformedConfig.options.map(x => x.value)) + if (accepted.size !== transformedConfig.options.length) { + throw new Error(`${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed`) } + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ inputData, operation, addValidationError }) => { + if (operation === 'delete') return + + const values: readonly (string | number)[] | null | undefined = inputData[meta.fieldKey] // resolvedData is JSON + if (values != null) { + for (const value of values) { + if (!accepted.has(value)) { + addValidationError(`'${value}' is not an accepted option`) + } + } + if (new Set(values).size !== values.length) { + addValidationError(`non-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({ validate }, config.hooks), views: '@keystone-6/core/fields/types/multiselect/views', getAdminMeta: () => ({ options: transformedConfig.options, @@ -131,10 +130,13 @@ export function multiselect ( }), }, { - mode: 'required', + mode, map: config?.db?.map, extendPrismaSchema: config.db?.extendPrismaSchema, - default: { kind: 'literal', value: JSON.stringify(defaultValue) }, + default: { + kind: 'literal', + value: JSON.stringify(defaultValue ?? null) + }, } ) } @@ -150,9 +152,7 @@ function configToOptionsAndGraphQLType ( ({ value }) => !Number.isInteger(value) || value > MAX_INT || value < MIN_INT ) ) { - throw new Error( - `The multiselect field at ${meta.listKey}.${meta.fieldKey} specifies integer values that are outside the range of a 32 bit signed integer` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies integer values that are outside the range of a 32-bit signed integer`) } return { type: 'integer' as const, @@ -190,5 +190,4 @@ function configToOptionsAndGraphQLType ( } } -const nonNullList = (type: T) => - graphql.list(graphql.nonNull(type)) +const nonNullList = (type: T) => graphql.list(graphql.nonNull(type)) diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index 044a81d9e0c..1324ca89136 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -2,11 +2,16 @@ import bcryptjs from 'bcryptjs' // @ts-expect-error import dumbPasswords from 'dumb-passwords' import { userInputError } from '../../../lib/core/graphql-errors' -import { humanize } from '../../../lib/utils' -import { type BaseListTypeInfo, fieldType, type FieldTypeFunc, type CommonFieldConfig } from '../../../types' +import { + type BaseListTypeInfo, + type CommonFieldConfig, + type FieldTypeFunc, + fieldType, +} from '../../../types' import { graphql } from '../../..' -import { getResolvedIsNullable } from '../../non-null-graphql' import { type PasswordFieldMeta } from './views' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type PasswordFieldConfig = CommonFieldConfig & { @@ -48,114 +53,94 @@ const PasswordFilter = graphql.inputObject({ const bcryptHashRegex = /^\$2[aby]?\$\d{1,2}\$[.\/A-Za-z0-9]{53}$/ -export const password = - ({ +export function password (config: PasswordFieldConfig = {}): FieldTypeFunc { + const { bcrypt = bcryptjs, workFactor = 10, - validation: _validation, - ...config - }: PasswordFieldConfig = {}): FieldTypeFunc => - meta => { + validation = {}, + } = config + const { + isRequired = false, + rejectCommon = false, + match, + length: { + max + } = {}, + } = validation + const min = isRequired ? validation.length?.min ?? 8 : validation.length?.min + + return (meta) => { if ((config as any).isIndexed === 'unique') { throw Error("isIndexed: 'unique' is not a supported option for field type password") } - - const fieldLabel = config.label ?? humanize(meta.fieldKey) - - const validation = { - isRequired: _validation?.isRequired ?? false, - rejectCommon: _validation?.rejectCommon ?? false, - match: _validation?.match - ? { - regex: _validation.match.regex, - explanation: - _validation.match.explanation ?? - `${fieldLabel} must match ${_validation.match.regex}`, - } - : null, - length: { - min: _validation?.length?.min ?? 8, - max: _validation?.length?.max ?? null, - }, + if (min !== undefined && (!Number.isInteger(min) || min < 0)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.length.min: ${min} but it must be a positive integer`) } - - const isNullable = getResolvedIsNullable(validation, config.db) - - for (const type of ['min', 'max'] as const) { - const val = validation.length[type] - if (val !== null && (!Number.isInteger(val) || val < 1)) { - throw new Error( - `The password field at ${meta.listKey}.${meta.fieldKey} specifies validation.length.${type}: ${val} but it must be a positive integer >= 1` - ) - } + if (max !== undefined && (!Number.isInteger(max) || max < 0)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.length.max: ${max} but it must be a positive integer`) } - - if (validation.length.max !== null && validation.length.min > validation.length.max) { - throw new Error( - `The password field at ${meta.listKey}.${meta.fieldKey} specifies a validation.length.max that is less than the validation.length.min, and therefore has no valid options` - ) + if (isRequired && min !== undefined && min === 0) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.isRequired: true and validation.length.min: 0, this is not allowed because validation.isRequired implies at least a min length of 1`) + } + if (isRequired && max !== undefined && max === 0) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.isRequired: true and validation.length.max: 0, this is not allowed because validation.isRequired implies at least a max length of 1`) + } + if ( + min !== undefined && + max !== undefined && + min > max + ) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies a validation.length.max that is less than the validation.length.min, and therefore has no valid options`) } - if (workFactor < 6 || workFactor > 31 || !Number.isInteger(workFactor)) { - throw new Error( - `The password field at ${meta.listKey}.${meta.fieldKey} specifies workFactor: ${workFactor} but it must be an integer between 6 and 31` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey}: workFactor must be an integer between 6 and 31`) } function inputResolver (val: string | null | undefined) { - if (val == null) { - return val - } + if (val == null) return val return bcrypt.hash(val, workFactor) } + const hasAdditionalValidation = match || rejectCommon || min !== undefined || max !== undefined + const { + mode, + validate, + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ inputData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = inputData[meta.fieldKey] // we use inputData, as resolveData is hashed + if (value != null) { + if (min !== undefined && value.length < min) { + if (min === 1) { + addValidationError(`value must not be empty`) + } else { + addValidationError(`value must be at least ${min} characters long`) + } + } + if (max !== undefined && value.length > max) { + addValidationError(`value must be no longer than ${max} characters`) + } + if (match && !match.regex.test(value)) { + addValidationError(match.explanation ?? `value must match ${match.regex}`) + } + if (rejectCommon && dumbPasswords.check(value)) { + addValidationError(`value is too common and is not allowed`) + } + } + } : undefined) + return fieldType({ kind: 'scalar', scalar: 'String', - mode: isNullable === false ? 'required' : 'optional', + mode, map: config.db?.map, 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({ validate }, config.hooks), input: { where: - isNullable === false + mode === 'required' ? undefined : { arg: graphql.arg({ type: PasswordFilter }), @@ -188,18 +173,21 @@ export const password = __ksTelemetryFieldTypeName: '@keystone-6/password', views: '@keystone-6/core/fields/types/password/views', getAdminMeta: (): PasswordFieldMeta => ({ - isNullable, + isNullable: mode === 'optional', validation: { - ...validation, - match: validation.match - ? { - regex: { - source: validation.match.regex.source, - flags: validation.match.regex.flags, - }, - explanation: validation.match.explanation, - } - : null, + isRequired, + rejectCommon, + match: match ? { + regex: { + source: match.regex.source, + flags: match.regex.flags, + }, + explanation: match.explanation ?? `value must match ${match.regex}`, + } : null, + length: { + max: max ?? null, + min: min ?? 8 + }, }, }), output: graphql.field({ @@ -220,3 +208,4 @@ export const password = }), }) } +} diff --git a/packages/core/src/fields/types/relationship/index.ts b/packages/core/src/fields/types/relationship/index.ts index 601aa93363e..d58579de9cd 100644 --- a/packages/core/src/fields/types/relationship/index.ts +++ b/packages/core/src/fields/types/relationship/index.ts @@ -1,4 +1,9 @@ -import { type BaseListTypeInfo, type FieldTypeFunc, type CommonFieldConfig, fieldType } from '../../../types' +import { + type BaseListTypeInfo, + type FieldTypeFunc, + type CommonFieldConfig, + fieldType +} from '../../../types' import { graphql } from '../../..' import { getAdminMetaForRelationshipField } from '../../../lib/create-admin-meta' import { type controller } from './views' diff --git a/packages/core/src/fields/types/select/index.ts b/packages/core/src/fields/types/select/index.ts index c0d46604ba2..ad10c77c12f 100644 --- a/packages/core/src/fields/types/select/index.ts +++ b/packages/core/src/fields/types/select/index.ts @@ -2,15 +2,15 @@ import { classify } from 'inflection' import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, - fieldType, - type FieldTypeFunc, type CommonFieldConfig, + type FieldTypeFunc, + fieldType, orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql' import { filters } from '../../filters' -import { type AdminSelectFieldMeta } from './views' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type SelectFieldConfig = CommonFieldConfig & @@ -51,67 +51,62 @@ export type SelectFieldConfig = } } -// These are the max and min values available to a 32 bit signed integer +// these are the lowest and highest values for a signed 32-bit integer const MAX_INT = 2147483647 const MIN_INT = -2147483648 -export const select = - ({ +export function select (config: SelectFieldConfig): FieldTypeFunc { + const { isIndexed, ui: { displayMode = 'select', ...ui } = {}, defaultValue, validation, - ...config - }: SelectFieldConfig): FieldTypeFunc => - meta => { - const fieldLabel = config.label ?? humanize(meta.fieldKey) - const resolvedIsNullable = getResolvedIsNullable(validation, config.db) - assertReadIsNonNullAllowed(meta, config, resolvedIsNullable) + } = config - const commonConfig = ( - options: readonly { value: string | number, label: string }[] - ): CommonFieldConfig & { - __ksTelemetryFieldTypeName: string - views: string - getAdminMeta: () => AdminSelectFieldMeta - } => { - const values = new Set(options.map(x => x.value)) - if (values.size !== options.length) { - throw new Error( - `The select field at ${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed` - ) + return (meta) => { + const options = config.options.map(option => { + if (typeof option === 'string') { + return { + label: humanize(option), + value: option, + } } - 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) - }, - }, - __ksTelemetryFieldTypeName: '@keystone-6/select', - views: '@keystone-6/core/fields/types/select/views', - getAdminMeta: () => ({ - options, - type: config.type ?? 'string', - displayMode: displayMode, - defaultValue: defaultValue ?? null, - isRequired: validation?.isRequired ?? false, - }), + return option + }) + + const accepted = new Set(options.map(x => x.value)) + if (accepted.size !== options.length) { + throw new Error(`${meta.listKey}.${meta.fieldKey}: duplicate options, this is not allowed`) + } + + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + if (value != null && !accepted.has(value)) { + addValidationError(`value is not an accepted option`) } + }) + + const commonConfig = { + ...config, + mode, + ui, + hooks: mergeFieldHooks({ validate }, config.hooks), + __ksTelemetryFieldTypeName: '@keystone-6/select', + views: '@keystone-6/core/fields/types/select/views', + getAdminMeta: () => ({ + options, + type: config.type ?? 'string', + displayMode: displayMode, + defaultValue: defaultValue ?? null, + isRequired: validation?.isRequired ?? false, + }), } - const mode = resolvedIsNullable === false ? 'required' : 'optional' + const commonDbFieldConfig = { mode, index: isIndexed === true ? 'index' : isIndexed || undefined, @@ -136,19 +131,16 @@ export const select = ({ value }) => !Number.isInteger(value) || value > MAX_INT || value < MIN_INT ) ) { - throw new Error( - `The select field at ${meta.listKey}.${meta.fieldKey} specifies integer values that are outside the range of a 32 bit signed integer` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies integer values that are outside the range of a 32-bit signed integer`) } return fieldType({ kind: 'scalar', scalar: 'Int', ...commonDbFieldConfig, })({ - ...commonConfig(config.options), + ...commonConfig, input: { - uniqueWhere: - isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Int }) } : undefined, + uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Int }) } : undefined, where: { arg: graphql.arg({ type: filters[meta.provider].Int[mode] }), resolve: mode === 'required' ? undefined : filters.resolveCommon, @@ -166,33 +158,26 @@ export const select = output: graphql.field({ type: graphql.Int }), }) } - const options = config.options.map(option => { - if (typeof option === 'string') { - return { - label: humanize(option), - value: option, - } - } - return option - }) if (config.type === 'enum') { const enumName = `${meta.listKey}${classify(meta.fieldKey)}Type` + const enumValues = options.map(x => `${x.value}`) + const graphQLType = graphql.enum({ name: enumName, - values: graphql.enumValues(options.map(x => x.value)), + values: graphql.enumValues(enumValues), }) return fieldType( meta.provider === 'sqlite' ? { kind: 'scalar', scalar: 'String', ...commonDbFieldConfig } : { kind: 'enum', - values: options.map(x => x.value), + values: enumValues, name: enumName, ...commonDbFieldConfig, } )({ - ...commonConfig(options), + ...commonConfig, input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphQLType }) } : undefined, @@ -213,8 +198,9 @@ export const select = output: graphql.field({ type: graphQLType }), }) } + return fieldType({ kind: 'scalar', scalar: 'String', ...commonDbFieldConfig })({ - ...commonConfig(options), + ...commonConfig, input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.String }) } : undefined, @@ -235,3 +221,4 @@ export const select = output: graphql.field({ type: graphql.String }), }) } +} diff --git a/packages/core/src/fields/types/text/index.ts b/packages/core/src/fields/types/text/index.ts index 6d0d92713fb..426908a2763 100644 --- a/packages/core/src/fields/types/text/index.ts +++ b/packages/core/src/fields/types/text/index.ts @@ -1,4 +1,3 @@ -import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, type CommonFieldConfig, @@ -7,11 +6,9 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { - assertReadIsNonNullAllowed, - resolveHasValidation, -} from '../../non-null-graphql' +import { makeValidateHook } from '../../non-null-graphql' import { filters } from '../../filters' +import { mergeFieldHooks } from '../../resolve-hooks' export type TextFieldConfig = CommonFieldConfig & { @@ -56,50 +53,83 @@ export type TextFieldConfig = } } +export type TextFieldMeta = { + displayMode: 'input' | 'textarea' + shouldUseModeInsensitive: boolean + isNullable: boolean + validation: { + isRequired: boolean + match: { regex: { source: string, flags: string }, explanation: string | null } | null + length: { min: number | null, max: number | null } + } + defaultValue: string | null +} + export function text ( config: TextFieldConfig = {} ): FieldTypeFunc { const { - isIndexed, defaultValue: defaultValue_, - validation: validation_ + isIndexed, + validation = {} } = config + config.db ??= {} + config.db.isNullable ??= false // TODO: sigh, remove in breaking change? + + const isRequired = validation.isRequired ?? false + const match = validation.match + const min = validation.isRequired ? validation.length?.min ?? 1 : validation.length?.min + const max = validation.length?.max + return (meta) => { - for (const type of ['min', 'max'] as const) { - const val = validation_?.length?.[type] - if (val !== undefined && (!Number.isInteger(val) || val < 0)) { - throw new Error(`The text field at ${meta.listKey}.${meta.fieldKey} specifies validation.length.${type}: ${val} but it must be a positive integer`) - } - if (validation_?.isRequired && val !== undefined && val === 0) { - throw new Error(`The text field at ${meta.listKey}.${meta.fieldKey} specifies validation.isRequired: true and validation.length.${type}: 0, this is not allowed because validation.isRequired implies at least a min length of 1`) - } + if (min !== undefined && (!Number.isInteger(min) || min < 0)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.length.min: ${min} but it must be a positive integer`) + } + if (max !== undefined && (!Number.isInteger(max) || max < 0)) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.length.max: ${max} but it must be a positive integer`) + } + if (isRequired && min !== undefined && min === 0) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.isRequired: true and validation.length.min: 0, this is not allowed because validation.isRequired implies at least a min length of 1`) + } + if (isRequired && max !== undefined && max === 0) { + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.isRequired: true and validation.length.max: 0, this is not allowed because validation.isRequired implies at least a max length of 1`) } - if ( - validation_?.length?.min !== undefined && - validation_?.length?.max !== undefined && - validation_?.length?.min > validation_?.length?.max + min !== undefined && + max !== undefined && + min > max ) { - throw new Error(`The text field at ${meta.listKey}.${meta.fieldKey} specifies a validation.length.max that is less than the validation.length.min, and therefore has no valid options`) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies a validation.length.max that is less than the validation.length.min, and therefore has no valid options`) } - const validation = validation_ ? { - ...validation_, - length: { - min: validation_?.isRequired ? validation_?.length?.min ?? 1 : validation_?.length?.min, - max: validation_?.length?.max, - }, - } : undefined - // defaulted to false as a zero length string is preferred to null const isNullable = config.db?.isNullable ?? false - assertReadIsNonNullAllowed(meta, config, isNullable) - 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 hasAdditionalValidation = match || min !== undefined || max !== undefined + const { + mode, + validate, + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + if (value != null) { + if (min !== undefined && value.length < min) { + if (min === 1) { + addValidationError(`value must not be empty`) + } else { + addValidationError(`value must be at least ${min} characters long`) + } + } + if (max !== undefined && value.length > max) { + addValidationError(`value must be no longer than ${max} characters`) + } + if (match && !match.regex.test(value)) { + addValidationError(match.explanation ?? `value must match ${match.regex}`) + } + } + } : undefined) return fieldType({ kind: 'scalar', @@ -112,35 +142,9 @@ 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({ validate }, config.hooks), input: { - uniqueWhere: - isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.String }) } : undefined, + uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.String }) } : undefined, where: { arg: graphql.arg({ type: filters[meta.provider].String[mode], @@ -153,10 +157,8 @@ export function text ( defaultValue: typeof defaultValue === 'string' ? defaultValue : undefined, }), resolve (val) { - if (val === undefined) { - return defaultValue ?? null - } - return val + if (val !== undefined) return val + return defaultValue ?? null }, }, update: { arg: graphql.arg({ type: graphql.String }) }, @@ -172,17 +174,18 @@ export function text ( displayMode: config.ui?.displayMode ?? 'input', shouldUseModeInsensitive: meta.provider === 'postgresql', validation: { - isRequired: validation?.isRequired ?? false, - match: validation?.match - ? { - regex: { - source: validation.match.regex.source, - flags: validation.match.regex.flags, - }, - explanation: validation.match.explanation ?? null, - } - : null, - length: { max: validation?.length?.max ?? null, min: validation?.length?.min ?? null }, + isRequired, + match: match ? { + regex: { + source: match.regex.source, + flags: match.regex.flags, + }, + explanation: match.explanation ?? `value must match ${match.regex}`, + } : null, + length: { + max: max ?? null, + min: min ?? null + }, }, defaultValue: defaultValue ?? (isNullable ? null : ''), isNullable, @@ -191,15 +194,3 @@ export function text ( }) } } - -export type TextFieldMeta = { - displayMode: 'input' | 'textarea' - shouldUseModeInsensitive: boolean - isNullable: boolean - validation: { - isRequired: boolean - match: { regex: { source: string, flags: string }, explanation: string | null } | null - length: { min: number | null, max: number | null } - } - defaultValue: string | null -} diff --git a/packages/core/src/fields/types/timestamp/index.ts b/packages/core/src/fields/types/timestamp/index.ts index 89dcb01cb36..d2cdd55d8ac 100644 --- a/packages/core/src/fields/types/timestamp/index.ts +++ b/packages/core/src/fields/types/timestamp/index.ts @@ -1,18 +1,14 @@ -import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, - fieldType, type FieldTypeFunc, type CommonFieldConfig, + fieldType, orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { - assertReadIsNonNullAllowed, - getResolvedIsNullable, - resolveHasValidation, -} from '../../non-null-graphql' import { filters } from '../../filters' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' import { type TimestampFieldMeta } from './views' export type TimestampFieldConfig = @@ -45,11 +41,7 @@ export function timestamp ( try { graphql.DateTime.graphQLType.parseValue(defaultValue) } catch (err) { - throw new Error( - `The timestamp field at ${meta.listKey}.${ - meta.fieldKey - } specifies defaultValue: ${defaultValue} but values must be provided as a full ISO8601 date-time string such as ${new Date().toISOString()}` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey}.defaultValue is required to be an ISO8601 date-time string such as ${new Date().toISOString()}`) } } @@ -57,12 +49,10 @@ export function timestamp ( typeof defaultValue === 'string' ? (graphql.DateTime.graphQLType.parseValue(defaultValue) as Date) : defaultValue - const resolvedIsNullable = getResolvedIsNullable(validation, config.db) - - assertReadIsNonNullAllowed(meta, config, resolvedIsNullable) - const mode = resolvedIsNullable === false ? 'required' : 'optional' - const fieldLabel = config.label ?? humanize(meta.fieldKey) - const hasValidation = resolveHasValidation(config) + const { + mode, + validate, + } = makeValidateHook(meta, config) return fieldType({ kind: 'scalar', @@ -83,17 +73,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({ validate }, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.DateTime }) } : undefined, where: { diff --git a/packages/core/src/fields/types/virtual/index.ts b/packages/core/src/fields/types/virtual/index.ts index 601e01b1255..060b1ee8df9 100644 --- a/packages/core/src/fields/types/virtual/index.ts +++ b/packages/core/src/fields/types/virtual/index.ts @@ -1,12 +1,12 @@ import { getNamedType, isLeafType } from 'graphql' import { - type BaseListTypeInfo, type BaseItem, + type BaseListTypeInfo, type CommonFieldConfig, type FieldTypeFunc, - fieldType, type KeystoneContext, type ListGraphQLTypes, + fieldType, getGqlNames, } from '../../../types' import { graphql } from '../../..' diff --git a/packages/core/src/lib/createSystem.ts b/packages/core/src/lib/createSystem.ts index 6dcfae09eff..a557ffe4bff 100644 --- a/packages/core/src/lib/createSystem.ts +++ b/packages/core/src/lib/createSystem.ts @@ -157,7 +157,7 @@ function injectNewDefaults (prismaClient: unknown, lists: Record -type ValidateHook< +export type ValidateHook< ListTypeInfo extends BaseListTypeInfo, Operation extends 'create' | 'update' | 'delete' > = ( @@ -276,7 +276,7 @@ type ValidateHook< CommonArgs ) => MaybePromise -type ValidateFieldHook< +export type ValidateFieldHook< ListTypeInfo extends BaseListTypeInfo, Operation extends 'create' | 'update' | 'delete', FieldKey extends ListTypeInfo['fields'] diff --git a/packages/core/src/types/config/index.ts b/packages/core/src/types/config/index.ts index d04e62ec978..da6e03c2813 100644 --- a/packages/core/src/types/config/index.ts +++ b/packages/core/src/types/config/index.ts @@ -276,7 +276,12 @@ export type AdminFileToWrite = | { mode: 'write', src: string, outputPath: string } | { mode: 'copy', inputPath: string, outputPath: string } -export type { ListHooks, ListAccessControl, FieldHooks, FieldAccessControl } +export type { + ListHooks, + ListAccessControl, + FieldHooks, + FieldAccessControl +} export type { FieldCreateItemAccessArgs, FieldReadItemAccessArgs, diff --git a/tests/api-tests/auth.test.ts b/tests/api-tests/auth.test.ts index 6efd0783c8f..4c1ab5f3a40 100644 --- a/tests/api-tests/auth.test.ts +++ b/tests/api-tests/auth.test.ts @@ -233,7 +233,7 @@ describe('Auth testing', () => { expectValidationError(body.errors, [ { path: ['createUser'], // I don't like this! - messages: ['User.email: Email must not be empty'], + messages: ['User.email: value must not be empty'], }, ]) expect(body.data).toEqual(null) diff --git a/tests/api-tests/fields/crud.test.ts b/tests/api-tests/fields/crud.test.ts index 70996d5cb76..bcfbe5e6b5d 100644 --- a/tests/api-tests/fields/crud.test.ts +++ b/tests/api-tests/fields/crud.test.ts @@ -4,7 +4,6 @@ import { text } from '@keystone-6/core/fields' import { type KeystoneContext } from '@keystone-6/core/types' import { setupTestRunner } from '@keystone-6/api-tests/test-runner' import { allowAll } from '@keystone-6/core/access' -import { humanize } from '../../../packages/core/src/lib/utils' import { dbProvider, expectSingleResolverError, @@ -221,7 +220,7 @@ for (const modulePath of testModules) { expectValidationError(errors, [ { path: [updateMutationName], - messages: [`Test.${fieldName}: ${humanize(fieldName)} is required`], + messages: [`Test.${fieldName}: missing value`], }, ]) } diff --git a/tests/api-tests/fields/required.test.ts b/tests/api-tests/fields/required.test.ts index 48f07ab2c91..7f19682586b 100644 --- a/tests/api-tests/fields/required.test.ts +++ b/tests/api-tests/fields/required.test.ts @@ -6,7 +6,6 @@ import { list } from '@keystone-6/core' import { text } from '@keystone-6/core/fields' import { setupTestRunner } from '@keystone-6/api-tests/test-runner' import { allowAll } from '@keystone-6/core/access' -import { humanize } from '../../../packages/core/src/lib/utils' import { dbProvider, expectValidationError @@ -53,6 +52,11 @@ for (const modulePath of testModules) { fields: { name: text(), testField: mod.typeFunction({ + ...(mod.nonNullableDefault ? { + db: { + isNullable: true + } + } : {}), ...fieldConfig, validation: { ...fieldConfig.validation, @@ -85,23 +89,22 @@ for (const modulePath of testModules) { }, }) - const messages = [`Test.testField: ${humanize('testField')} is required`] + const messages = [`Test.testField: missing value`] test( 'Create an object without the required field', runner(async ({ context }) => { const { data, errors } = await context.graphql.raw({ query: ` - mutation { - createTest(data: { name: "test entry" } ) { id } - }`, + mutation { + createTest(data: { name: "test entry" } ) { id } + }`, }) expect(data).toEqual({ createTest: null }) expectValidationError(errors, [ { path: ['createTest'], - messages: - mod.name === 'Text' ? ['Test.testField: Test Field must not be empty'] : messages, + messages, }, ]) }) @@ -112,9 +115,9 @@ for (const modulePath of testModules) { runner(async ({ context }) => { const { data, errors } = await context.graphql.raw({ query: ` - mutation { - createTest(data: { name: "test entry", testField: null } ) { id } - }`, + mutation { + createTest(data: { name: "test entry", testField: null } ) { id } + }`, }) expect(data).toEqual({ createTest: null }) expectValidationError(errors, [ diff --git a/tests/api-tests/fields/types/fixtures/decimal/test-fixtures.ts b/tests/api-tests/fields/types/fixtures/decimal/test-fixtures.ts index 9c8ea2bd0c1..0cef149b424 100644 --- a/tests/api-tests/fields/types/fixtures/decimal/test-fixtures.ts +++ b/tests/api-tests/fields/types/fixtures/decimal/test-fixtures.ts @@ -55,7 +55,7 @@ export const crudTests = (keystoneTestWrapper: any) => { expect(result.errors).toHaveLength(1) expect(result.errors![0].message).toMatchInlineSnapshot(` "You provided invalid data for this operation. - - Test.price: Price must be greater than or equal to -300" + - Test.price: value must be greater than or equal to -300" `) }) ) @@ -76,7 +76,7 @@ export const crudTests = (keystoneTestWrapper: any) => { expect(result.errors).toHaveLength(1) expect(result.errors![0].message).toMatchInlineSnapshot(` "You provided invalid data for this operation. - - Test.price: Price must be less than or equal to 50000000" + - Test.price: value must be less than or equal to 50000000" `) }) ) diff --git a/tests/api-tests/fields/types/fixtures/multiselect/test-fixtures.ts b/tests/api-tests/fields/types/fixtures/multiselect/test-fixtures.ts index 9e1bb81cc20..e1c35696301 100644 --- a/tests/api-tests/fields/types/fixtures/multiselect/test-fixtures.ts +++ b/tests/api-tests/fields/types/fixtures/multiselect/test-fixtures.ts @@ -17,10 +17,11 @@ export const exampleValue2 = (matrixValue: MatrixValue) => ? ['a string', '1number'] : [2, 4] export const supportsNullInput = false -export const neverNull = true +export const nonNullableDefault = true +export const neverNull = false export const supportsUnique = false export const supportsDbMap = true -export const skipRequiredTest = true +export const skipRequiredTest = false export const fieldConfig = (matrixValue: MatrixValue) => { if (matrixValue === 'enum' || matrixValue === 'string') { return { diff --git a/tests/api-tests/fields/types/fixtures/password/test-fixtures.ts b/tests/api-tests/fields/types/fixtures/password/test-fixtures.ts index 02cbbab34ff..4465a1d3fdf 100644 --- a/tests/api-tests/fields/types/fixtures/password/test-fixtures.ts +++ b/tests/api-tests/fields/types/fixtures/password/test-fixtures.ts @@ -48,9 +48,9 @@ export const crudTests = (keystoneTestWrapper: any) => { data: { password: '123' }, }) ).rejects.toMatchInlineSnapshot(` - [GraphQLError: You provided invalid data for this operation. - - Test.password: Password must be at least 4 characters long] - `) + [GraphQLError: You provided invalid data for this operation. + - Test.password: value must be at least 4 characters long] + `) }) ) test( @@ -61,9 +61,9 @@ export const crudTests = (keystoneTestWrapper: any) => { data: { passwordRejectCommon: 'password' }, }) ).rejects.toMatchInlineSnapshot(` - [GraphQLError: You provided invalid data for this operation. - - Test.passwordRejectCommon: Password Reject Common is too common and is not allowed] - `) + [GraphQLError: You provided invalid data for this operation. + - Test.passwordRejectCommon: value is too common and is not allowed] + `) const data = await context.query.Test.createOne({ data: { passwordRejectCommon: 'sdfinwedvhweqfoiuwdfnvjiewrijnf' }, query: `passwordRejectCommon {isSet}`, diff --git a/tests/api-tests/fields/types/fixtures/text/test-fixtures.ts b/tests/api-tests/fields/types/fixtures/text/test-fixtures.ts index 9faab743c95..4079d016f48 100644 --- a/tests/api-tests/fields/types/fixtures/text/test-fixtures.ts +++ b/tests/api-tests/fields/types/fixtures/text/test-fixtures.ts @@ -4,6 +4,7 @@ export const name = 'Text' export const typeFunction = text export const exampleValue = () => 'foo' export const exampleValue2 = () => 'bar' +export const nonNullableDefault = true export const supportsNullInput = false export const supportsUnique = true export const supportsGraphQLIsNonNull = true