From 9e7b1d048cb8c084ab6a4b718b15f4d17480b4cc Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Wed, 5 Jun 2024 12:56:18 -0400 Subject: [PATCH 01/16] update built-in fields to new validate hook syntax --- .changeset/honest-moles-doubt.md | 5 ++ packages/core/src/fields/non-null-graphql.ts | 9 +-- packages/core/src/fields/resolve-hooks.ts | 78 +++++++++++++++++++ .../core/src/fields/types/bigInt/index.ts | 63 ++++++++------- .../src/fields/types/calendarDay/index.ts | 32 +++++--- .../core/src/fields/types/decimal/index.ts | 47 ++++++----- packages/core/src/fields/types/file/index.ts | 46 +++++------ packages/core/src/fields/types/float/index.ts | 62 ++++++++------- packages/core/src/fields/types/image/index.ts | 54 ++++++------- .../core/src/fields/types/integer/index.ts | 66 +++++++++------- .../src/fields/types/multiselect/index.ts | 40 +++++----- .../core/src/fields/types/password/index.ts | 78 ++++++++++--------- .../core/src/fields/types/select/index.ts | 45 +++++++---- packages/core/src/fields/types/text/index.ts | 57 +++++++------- .../core/src/fields/types/timestamp/index.ts | 27 ++++--- 15 files changed, 424 insertions(+), 285 deletions(-) create mode 100644 .changeset/honest-moles-doubt.md create mode 100644 packages/core/src/fields/resolve-hooks.ts 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..2a877628a51 --- /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 index 56bf38c8da2..a3f25f86717 100644 --- 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: { From b285e25d1c3e46b3ae0c52bbcaad7caf3158ae83 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:31:49 +1000 Subject: [PATCH 02/16] refactor resolve-hooks code --- packages/core/src/fields/resolve-hooks.ts | 105 ++++++++---------- .../core/src/fields/types/bigInt/index.ts | 5 +- packages/core/src/fields/types/text/index.ts | 1 - .../core/src/fields/types/timestamp/index.ts | 8 +- packages/core/src/types/config/index.ts | 7 +- 5 files changed, 63 insertions(+), 63 deletions(-) diff --git a/packages/core/src/fields/resolve-hooks.ts b/packages/core/src/fields/resolve-hooks.ts index 2a877628a51..690dfca0ad0 100644 --- a/packages/core/src/fields/resolve-hooks.ts +++ b/packages/core/src/fields/resolve-hooks.ts @@ -1,8 +1,20 @@ -import type { FieldHooks, BaseListTypeInfo } from '../types' +import { + type BaseListTypeInfo, + type FieldHooks, + type MaybePromise +} from '../types' -function splitValidateHooks ( - { validate, validateInput, validateDelete }: FieldHooks -): Exclude["validate"], Function> | undefined { +// 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, @@ -11,68 +23,47 @@ function splitValidateHooks ( } } - if (!validate) return undefined - + if (!validate) return if (typeof validate === 'function') { - return { create: validate, update: validate, delete: validate } + 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'> +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) + } +} -/** - * 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 { + hooks?: FieldHooks, +) { + if (hooks === undefined) return builtin 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 + const builtinValidate = resolveValidateHooks(builtin) + const hooksValidate = resolveValidateHooks(hooks) + return { + ...hooks, + beforeOperation: merge(builtin.beforeOperation, hooks.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 a3f25f86717..cc90e303eb8 100644 --- a/packages/core/src/fields/types/bigInt/index.ts +++ b/packages/core/src/fields/types/bigInt/index.ts @@ -13,7 +13,10 @@ import { resolveHasValidation, } from '../../non-null-graphql' import { filters } from '../../filters' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { + type InternalFieldHooks, + mergeFieldHooks, +} from '../../resolve-hooks' export type BigIntFieldConfig = CommonFieldConfig & { diff --git a/packages/core/src/fields/types/text/index.ts b/packages/core/src/fields/types/text/index.ts index c0d1ea5df84..6697875a591 100644 --- a/packages/core/src/fields/types/text/index.ts +++ b/packages/core/src/fields/types/text/index.ts @@ -101,7 +101,6 @@ export function text ( const fieldLabel = config.label ?? humanize(meta.fieldKey) const mode = isNullable ? 'optional' : 'required' const hasValidation = resolveHasValidation(config.db, validation) || !isNullable // we make an exception for Text - const hooks: InternalFieldHooks = {} if (hasValidation) { hooks.validate = ({ resolvedData, operation, addValidationError }) => { diff --git a/packages/core/src/fields/types/timestamp/index.ts b/packages/core/src/fields/types/timestamp/index.ts index 6a84d0a2359..4ec4c186d3d 100644 --- a/packages/core/src/fields/types/timestamp/index.ts +++ b/packages/core/src/fields/types/timestamp/index.ts @@ -1,9 +1,9 @@ import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, - fieldType, type FieldTypeFunc, type CommonFieldConfig, + fieldType, orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' @@ -13,7 +13,10 @@ import { resolveHasValidation, } from '../../non-null-graphql' import { filters } from '../../filters' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { + type InternalFieldHooks, + mergeFieldHooks, +} from '../../resolve-hooks' import { type TimestampFieldMeta } from './views' export type TimestampFieldConfig = @@ -64,7 +67,6 @@ export function timestamp ( const mode = resolvedIsNullable === 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 }) => { 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, From d9cfc5d57d8fdd0709c88c87b03dceba6b155a62 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Mon, 8 Jul 2024 18:30:54 +1000 Subject: [PATCH 03/16] dedupe makeValidationHook for mode and validate hook --- packages/core/src/fields/non-null-graphql.ts | 70 ++++++++- .../core/src/fields/types/bigInt/index.ts | 56 ++----- .../src/fields/types/calendarDay/index.ts | 39 ++--- .../core/src/fields/types/decimal/index.ts | 61 +++----- packages/core/src/fields/types/file/index.ts | 2 +- packages/core/src/fields/types/float/index.ts | 60 +++----- .../core/src/fields/types/integer/index.ts | 55 +++---- .../src/fields/types/multiselect/index.ts | 47 +++--- .../core/src/fields/types/password/index.ts | 90 +++++------ .../src/fields/types/relationship/index.ts | 7 +- .../core/src/fields/types/select/index.ts | 141 ++++++++---------- packages/core/src/fields/types/text/index.ts | 60 ++++---- .../core/src/fields/types/timestamp/index.ts | 35 +---- .../core/src/fields/types/virtual/index.ts | 4 +- packages/core/src/types/config/hooks.ts | 4 +- 15 files changed, 313 insertions(+), 418 deletions(-) diff --git a/packages/core/src/fields/non-null-graphql.ts b/packages/core/src/fields/non-null-graphql.ts index ba87f26f7f1..8302dd6acfc 100644 --- a/packages/core/src/fields/non-null-graphql.ts +++ b/packages/core/src/fields/non-null-graphql.ts @@ -1,19 +1,23 @@ -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 ( 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 ( +function resolveHasValidation ( db?: { isNullable?: boolean }, validation?: unknown ) { @@ -22,9 +26,63 @@ export function resolveHasValidation ( return false } +export function makeValidateHook ( + meta: FieldData, + config: { + label?: string, + db?: { + isNullable?: boolean + }, + graphql?: { + isNonNull?: { + read?: boolean + } + }, + validation?: { + isRequired?: boolean + }, + }, + f?: ValidateFieldHook +) { + const resolvedIsNullable = getResolvedIsNullable(config.validation, config.db) + const mode = resolvedIsNullable === false ? ('required' as const) : ('optional' as const) + + assertReadIsNonNullAllowed(meta, config, resolvedIsNullable) + const hasValidation = resolveHasValidation(config.db, config.validation) + if (hasValidation) { + const validate = async function (args) { + const { operation, addValidationError, resolvedData } = args + if (operation !== 'delete') { + const value = resolvedData[meta.fieldKey] + if ((config.validation?.isRequired || resolvedIsNullable === false) && value === null) { + addValidationError(`Missing value`) + } + } + + await f?.(args) + } satisfies ValidateFieldHook + + return { + mode, + validate, + } + } + + return { + mode, + validate: undefined + } +} + export function assertReadIsNonNullAllowed ( meta: FieldData, - config: CommonFieldConfig, + config: { + graphql?: { + isNonNull?: { + read?: boolean + } + } + }, resolvedIsNullable: boolean ) { if (!resolvedIsNullable) return diff --git a/packages/core/src/fields/types/bigInt/index.ts b/packages/core/src/fields/types/bigInt/index.ts index cc90e303eb8..0d17bbdc6fb 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, @@ -8,15 +7,11 @@ import { } from '../../../types' import { graphql } from '../../..' import { - assertReadIsNonNullAllowed, getResolvedIsNullable, - resolveHasValidation, + makeValidateHook } from '../../non-null-graphql' import { filters } from '../../filters' -import { - type InternalFieldHooks, - mergeFieldHooks, -} from '../../resolve-hooks' +import { mergeFieldHooks } from '../../resolve-hooks' export type BigIntFieldConfig = CommonFieldConfig & { @@ -84,41 +79,23 @@ export function bigInt ( 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`) } - assertReadIsNonNullAllowed(meta, config, isNullable) - - 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] + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return - if ( - (validation?.isRequired || isNullable === false) && - (value === null || - (operation === 'create' && value === undefined && !hasAutoIncDefault)) - ) { - addValidationError(`${fieldLabel} is required`) + const value = resolvedData[meta.fieldKey] + if (typeof value === 'number') { + if (validation?.min !== undefined && value < validation.min) { + addValidationError(`value must be greater than or equal to ${validation.min}`) } - 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}` - ) - } + if (validation?.max !== undefined && value > validation.max) { + addValidationError(`value must be less than or equal to ${validation.max}`) } } - } + }) return fieldType({ kind: 'scalar', @@ -136,10 +113,9 @@ export function bigInt ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + 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, diff --git a/packages/core/src/fields/types/calendarDay/index.ts b/packages/core/src/fields/types/calendarDay/index.ts index bd32466d033..ac6184b89f8 100644 --- a/packages/core/src/fields/types/calendarDay/index.ts +++ b/packages/core/src/fields/types/calendarDay/index.ts @@ -1,20 +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, - resolveHasValidation, -} from '../../non-null-graphql' import { filters } from '../../filters' -import { type CalendarDayFieldMeta } from './views' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type CalendarDayFieldConfig = CommonFieldConfig & { @@ -48,13 +43,7 @@ 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' - const hasValidation = resolveHasValidation(config.db, validation) function resolveInput (value: string | null | undefined) { if (meta.provider === 'sqlite' || value == null) { @@ -63,20 +52,12 @@ export const calendarDay = return dateStringToDateObjectInUTC(value) } + const { + mode, + validate, + } = makeValidateHook(meta, config) 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, @@ -94,7 +75,7 @@ export const calendarDay = nativeType: usesNativeDateType ? 'Date' : undefined, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + hooks: mergeFieldHooks({ validate }, 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 3b4c5c0f836..8fb956e4e98 100644 --- a/packages/core/src/fields/types/decimal/index.ts +++ b/packages/core/src/fields/types/decimal/index.ts @@ -1,22 +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, - resolveHasValidation, -} from '../../non-null-graphql' import { filters } from '../../filters' import { type DecimalFieldMeta } from './views' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type DecimalFieldConfig = CommonFieldConfig & { @@ -86,8 +81,6 @@ export const decimal = ) } - const fieldLabel = config.label ?? humanize(meta.fieldKey) - const max = validation?.max === undefined ? undefined @@ -108,12 +101,24 @@ export const decimal = ? undefined : parseDecimalValueOption(meta, defaultValue, 'defaultValue') - const isNullable = getResolvedIsNullable(validation, config.db) - const hasValidation = resolveHasValidation(config.db, validation) + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const val: Decimal | null | undefined = resolvedData[meta.fieldKey] + if (val != null) { + if (min !== undefined && val.lessThan(min)) { + addValidationError(`value must be greater than or equal to ${min}`) + } - assertReadIsNonNullAllowed(meta, config, isNullable) + if (max !== undefined && val.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', @@ -127,31 +132,9 @@ export const decimal = extendPrismaSchema: config.db?.extendPrismaSchema, } as const - 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 (max !== undefined && val.greaterThan(max)) { - addValidationError(`${fieldLabel} must be less than or equal to ${max}`) - } - } - } - } - return fieldType(dbField)({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + hooks: mergeFieldHooks({ validate }, 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 fb8adbae548..31674073c9e 100644 --- a/packages/core/src/fields/types/file/index.ts +++ b/packages/core/src/fields/types/file/index.ts @@ -72,7 +72,7 @@ export function file (config: FileFieldC const filenameKey = `${fieldKey}_filename` const filename = args.item[filenameKey] - // This will occur on an update where a file already existed but has been + // 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' || diff --git a/packages/core/src/fields/types/float/index.ts b/packages/core/src/fields/types/float/index.ts index 98e9debf41c..18446fcd0b9 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,13 +7,9 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { - assertReadIsNonNullAllowed, - getResolvedIsNullable, - resolveHasValidation -} from '../../non-null-graphql' import { filters } from '../../filters' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type FloatFieldConfig = CommonFieldConfig & { @@ -62,9 +57,7 @@ export const float = 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` - ) + throw new Error(`The float field at ${meta.listKey}.${meta.fieldKey} specifies validation.max: ${validation.max} but it must be a valid finite number`) } if ( @@ -77,53 +70,36 @@ export const float = ) } - const isNullable = getResolvedIsNullable(validation, config.db) - - assertReadIsNonNullAllowed(meta, config, isNullable) - - 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] + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return - if ((validation?.isRequired || isNullable === false) && value === null) { - addValidationError(`${fieldLabel} is required`) + const value = resolvedData[meta.fieldKey] + if (typeof value === 'number') { + if (validation?.max !== undefined && value > validation.max) { + addValidationError(`value must be less than or equal to ${validation.max}` + ) } - 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}` - ) - } + if (validation?.min !== undefined && value < validation.min) { + addValidationError(`value must be greater than or equal to ${validation.min}`) } } - } + }) 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: mergeFieldHooks(hooks, config.hooks), + hooks: mergeFieldHooks({ validate }, config.hooks), input: { uniqueWhere: isIndexed === 'unique' ? { arg: graphql.arg({ type: graphql.Float }) } : undefined, diff --git a/packages/core/src/fields/types/integer/index.ts b/packages/core/src/fields/types/integer/index.ts index ad44398288d..842a194eaef 100644 --- a/packages/core/src/fields/types/integer/index.ts +++ b/packages/core/src/fields/types/integer/index.ts @@ -1,19 +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 { filters } from '../../filters' import { - assertReadIsNonNullAllowed, getResolvedIsNullable, - resolveHasValidation + makeValidateHook } from '../../non-null-graphql' -import { filters } from '../../filters' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { mergeFieldHooks } from '../../resolve-hooks' export type IntegerFieldConfig = CommonFieldConfig & { @@ -97,40 +95,23 @@ export function integer ({ ) } - assertReadIsNonNullAllowed(meta, config, isNullable) - - 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] + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return - if ( - (validation?.isRequired || isNullable === false) && - (value === null || (operation === 'create' && value === undefined && !hasAutoIncDefault)) - ) { - addValidationError(`${fieldLabel} is required`) + const value = resolvedData[meta.fieldKey] + if (typeof value === 'number') { + if (validation?.min !== undefined && value < validation.min) { + addValidationError(`value must be greater than or equal to ${validation.min}`) } - 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}` - ) - } + if (validation?.max !== undefined && value > validation.max) { + addValidationError(`value must be less than or equal to ${validation.max}`) } } - } + }) return fieldType({ kind: 'scalar', @@ -148,7 +129,7 @@ export function integer ({ extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + hooks: mergeFieldHooks({ validate }, 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 b803630aa8d..079d6fd191d 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -8,9 +8,9 @@ import { jsonFieldTypePolyfilledForSQLite, } from '../../../types' import { graphql } from '../../..' -import { assertReadIsNonNullAllowed } from '../../non-null-graphql' import { userInputError } from '../../../lib/core/graphql-errors' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type MultiselectFieldConfig = CommonFieldConfig & @@ -34,6 +34,7 @@ export type MultiselectFieldConfig = } ) & { db?: { + isNullable?: boolean map?: string extendPrismaSchema?: (field: string) => string } @@ -50,12 +51,13 @@ export function multiselect ( defaultValue = [], } = config + config.db ??= {} + config.db.isNullable ??= false // 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) => { @@ -69,6 +71,7 @@ export function multiselect ( } return resolved } + const resolveUpdate = ( val: T[] | null | undefined ): T[] | undefined => { @@ -80,38 +83,38 @@ export function multiselect ( const transformedConfig = configToOptionsAndGraphQLType(config, meta) - const possibleValues = new Set(transformedConfig.options.map(x => x.value)) - if (possibleValues.size !== transformedConfig.options.length) { + const accepted = new Set(transformedConfig.options.map(x => x.value)) + if (accepted.size !== transformedConfig.options.length) { throw new Error( `The multiselect field at ${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed` ) } - const hooks: InternalFieldHooks = { - validate: (args) => { - if (args.operation === 'delete') return + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (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`) + const values: readonly (string | number)[] | undefined = resolvedData[meta.fieldKey] + if (values !== undefined) { + 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: mergeFieldHooks(hooks, config.hooks), + hooks: mergeFieldHooks({ validate }, config.hooks), views: '@keystone-6/core/fields/types/multiselect/views', getAdminMeta: () => ({ options: transformedConfig.options, @@ -133,7 +136,7 @@ export function multiselect ( }), }, { - mode: 'required', + mode, map: config?.db?.map, extendPrismaSchema: config.db?.extendPrismaSchema, default: { kind: 'literal', value: JSON.stringify(defaultValue) }, diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index 6fd5985d31a..e24c5f3ba20 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -3,11 +3,16 @@ import bcryptjs from 'bcryptjs' 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, resolveHasValidation } from '../../non-null-graphql' import { type PasswordFieldMeta } from './views' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' +import { getResolvedIsNullable, makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type PasswordFieldConfig = CommonFieldConfig & { @@ -61,17 +66,13 @@ export const password = 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}`, + explanation: _validation.match.explanation ?? `value must match ${_validation.match.regex}`, } : null, length: { @@ -85,22 +86,16 @@ export const password = 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` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey}: validation.length.${type} must be a positive integer >= 1`) } } 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` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey}: validation.length.max cannot be less than validation.length.min`) } 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) { @@ -110,53 +105,42 @@ 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 { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (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`) + const value = resolvedData[meta.fieldKey] + if (value != null) { + if (value.length < validation.length.min) { + if (validation.length.min === 1) { + addValidationError(`value must not be empty`) + } else { + addValidationError(`value must be at least ${validation.length.min} characters long`) } } + if (validation.length.max !== null && value.length > validation.length.max) { + addValidationError(`Value must be no longer than ${validation.length.max} characters`) + } + if (validation.match && !validation.match.regex.test(value)) { + addValidationError(validation.match.explanation) + } + if (validation.rejectCommon && dumbPasswords.check(value)) { + addValidationError(`Value is too common and is not allowed`) + } } - } - + }) + return fieldType({ kind: 'scalar', scalar: 'String', - mode: isNullable === false ? 'required' : 'optional', + mode, map: config.db?.map, extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + hooks: mergeFieldHooks({ validate }, config.hooks), input: { where: isNullable === false 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 d46d0d26b7a..20ecbf28615 100644 --- a/packages/core/src/fields/types/select/index.ts +++ b/packages/core/src/fields/types/select/index.ts @@ -2,20 +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, - resolveHasValidation, -} from '../../non-null-graphql' import { filters } from '../../filters' -import { mergeFieldHooks, type InternalFieldHooks } from '../../resolve-hooks' -import { type AdminSelectFieldMeta } from './views' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' export type SelectFieldConfig = CommonFieldConfig & @@ -60,69 +55,57 @@ export type SelectFieldConfig = const MAX_INT = 2147483647 const MIN_INT = -2147483648 -export const select = - ({ - 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) +export function select ({ + isIndexed, + ui: { displayMode = 'select', ...ui } = {}, + defaultValue, + validation, + ...config +}: SelectFieldConfig): FieldTypeFunc { + return (meta) => { + const options = config.options.map(option => { + if (typeof option === 'string') { + return { + label: humanize(option), + value: option, + } + } + return option + }) - const hasValidation = resolveHasValidation(config.db, validation) + 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 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` - ) - } - - const hooks: InternalFieldHooks = {} - if (hasValidation) { - hooks.validate = ({ resolvedData, operation, addValidationError }) => { - if (operation === 'delete') return + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ 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`) - } - } + const value = resolvedData[meta.fieldKey] + if (value != null && !accepted.has(value)) { + addValidationError(`value is not an accepted option`) } + }) - return { - ...config, - ui, - hooks: mergeFieldHooks(hooks, 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 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, @@ -156,10 +139,9 @@ export const select = 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, @@ -177,33 +159,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, @@ -224,8 +199,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, @@ -246,3 +222,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 6697875a591..3c1f70ad5fa 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,12 +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, type InternalFieldHooks } from '../../resolve-hooks' +import { mergeFieldHooks } from '../../resolve-hooks' export type TextFieldConfig = CommonFieldConfig & { @@ -66,6 +62,9 @@ export function text ( validation: validation_ } = config + config.db ??= {} + config.db.isNullable ??= false // TODO: sigh, remove in breaking change? + return (meta) => { for (const type of ['min', 'max'] as const) { const val = validation_?.length?.[type] @@ -95,38 +94,31 @@ export function text ( // 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.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}`) + const { + mode, + validate, + } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + if (operation === 'delete') return + + const value = resolvedData[meta.fieldKey] + if (value != null) { + if (validation?.length?.min !== undefined && value.length < validation.length.min) { + if (validation.length.min === 1) { + addValidationError(`value must not be empty`) + } else { + addValidationError(`value must be at least ${validation.length.min} characters long`) } } + if (validation?.length?.max !== undefined && value.length > validation.length.max) { + addValidationError(`value must be no longer than ${validation.length.max} characters`) + } + if (validation?.match && !validation.match.regex.test(value)) { + addValidationError(validation.match.explanation || `value must match ${validation.match.regex}`) + } } - } + }) return fieldType({ kind: 'scalar', @@ -139,7 +131,7 @@ export function text ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + hooks: mergeFieldHooks({ validate }, 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 4ec4c186d3d..85c2fd7b843 100644 --- a/packages/core/src/fields/types/timestamp/index.ts +++ b/packages/core/src/fields/types/timestamp/index.ts @@ -1,4 +1,3 @@ -import { humanize } from '../../../lib/utils' import { type BaseListTypeInfo, type FieldTypeFunc, @@ -7,16 +6,9 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' -import { - assertReadIsNonNullAllowed, - getResolvedIsNullable, - resolveHasValidation, -} from '../../non-null-graphql' import { filters } from '../../filters' -import { - type InternalFieldHooks, - mergeFieldHooks, -} from '../../resolve-hooks' +import { makeValidateHook } from '../../non-null-graphql' +import { mergeFieldHooks } from '../../resolve-hooks' import { type TimestampFieldMeta } from './views' export type TimestampFieldConfig = @@ -61,23 +53,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.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`) - } - } - } + const { + mode, + validate, + } = makeValidateHook(meta, config) return fieldType({ kind: 'scalar', @@ -98,7 +77,7 @@ export function timestamp ( extendPrismaSchema: config.db?.extendPrismaSchema, })({ ...config, - hooks: mergeFieldHooks(hooks, config.hooks), + 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/types/config/hooks.ts b/packages/core/src/types/config/hooks.ts index 57183d1be51..046476ebd3a 100644 --- a/packages/core/src/types/config/hooks.ts +++ b/packages/core/src/types/config/hooks.ts @@ -234,7 +234,7 @@ type ResolveInputFieldHook< ListTypeInfo['prisma']['create' | 'update'][FieldKey] | undefined // undefined represents 'don't do anything' > -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'] From 556b941636e6d64317de9832d9853ff50001e401 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Mon, 8 Jul 2024 18:31:52 +1000 Subject: [PATCH 04/16] fix select isNotNull when only validation.isRequired is set --- .../framework-astro/src/keystone/schema.ts | 2 +- packages/core/src/fields/non-null-graphql.ts | 20 +++++++++---------- .../core/src/fields/types/bigInt/index.ts | 4 ++-- .../core/src/fields/types/integer/index.ts | 4 ++-- .../core/src/fields/types/password/index.ts | 4 ++-- .../core/src/fields/types/select/index.ts | 17 ++++++++-------- 6 files changed, 26 insertions(+), 25 deletions(-) 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/packages/core/src/fields/non-null-graphql.ts b/packages/core/src/fields/non-null-graphql.ts index 8302dd6acfc..2b66ea0b4cc 100644 --- a/packages/core/src/fields/non-null-graphql.ts +++ b/packages/core/src/fields/non-null-graphql.ts @@ -6,7 +6,7 @@ import { type ValidateFieldHook } from '../types/config/hooks' -export function getResolvedIsNullable ( +export function resolveDbNullable ( validation: undefined | { isRequired?: boolean }, db: undefined | { isNullable?: boolean } ): boolean { @@ -17,7 +17,7 @@ export function getResolvedIsNullable ( return true } -function resolveHasValidation ( +function shouldAddValidation ( db?: { isNullable?: boolean }, validation?: unknown ) { @@ -44,17 +44,17 @@ export function makeValidateHook ( }, f?: ValidateFieldHook ) { - const resolvedIsNullable = getResolvedIsNullable(config.validation, config.db) - const mode = resolvedIsNullable === false ? ('required' as const) : ('optional' as const) + const dbNullable = resolveDbNullable(config.validation, config.db) + const mode = dbNullable ? ('optional' as const) : ('required' as const) - assertReadIsNonNullAllowed(meta, config, resolvedIsNullable) - const hasValidation = resolveHasValidation(config.db, config.validation) - if (hasValidation) { + assertReadIsNonNullAllowed(meta, config, dbNullable) + const addValidation = shouldAddValidation(config.db, config.validation) + if (addValidation) { const validate = async function (args) { const { operation, addValidationError, resolvedData } = args if (operation !== 'delete') { const value = resolvedData[meta.fieldKey] - if ((config.validation?.isRequired || resolvedIsNullable === false) && value === null) { + if ((config.validation?.isRequired || dbNullable === false) && value === null) { addValidationError(`Missing value`) } } @@ -83,9 +83,9 @@ export function assertReadIsNonNullAllowed ( defaultValue !== null && defaultValue.kind === 'autoincrement' - const isNullable = getResolvedIsNullable(_validation, config.db) + const isNullable = resolveDbNullable(_validation, config.db) if (hasAutoIncDefault) { if (meta.provider === 'sqlite' || meta.provider === 'mysql') { diff --git a/packages/core/src/fields/types/integer/index.ts b/packages/core/src/fields/types/integer/index.ts index 842a194eaef..8acbd1c64ee 100644 --- a/packages/core/src/fields/types/integer/index.ts +++ b/packages/core/src/fields/types/integer/index.ts @@ -8,7 +8,7 @@ import { import { graphql } from '../../..' import { filters } from '../../filters' import { - getResolvedIsNullable, + resolveDbNullable, makeValidateHook } from '../../non-null-graphql' import { mergeFieldHooks } from '../../resolve-hooks' @@ -46,7 +46,7 @@ export function integer ({ defaultValue !== null && defaultValue.kind === 'autoincrement' - const isNullable = getResolvedIsNullable(validation, config.db) + const isNullable = resolveDbNullable(validation, config.db) if (hasAutoIncDefault) { if (meta.provider === 'sqlite' || meta.provider === 'mysql') { diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index e24c5f3ba20..ce2276ec887 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -11,7 +11,7 @@ import { } from '../../../types' import { graphql } from '../../..' import { type PasswordFieldMeta } from './views' -import { getResolvedIsNullable, makeValidateHook } from '../../non-null-graphql' +import { resolveDbNullable, makeValidateHook } from '../../non-null-graphql' import { mergeFieldHooks } from '../../resolve-hooks' export type PasswordFieldConfig = @@ -81,7 +81,7 @@ export const password = }, } - const isNullable = getResolvedIsNullable(validation, config.db) + const isNullable = resolveDbNullable(validation, config.db) for (const type of ['min', 'max'] as const) { const val = validation.length[type] diff --git a/packages/core/src/fields/types/select/index.ts b/packages/core/src/fields/types/select/index.ts index 20ecbf28615..522ffa0bd4d 100644 --- a/packages/core/src/fields/types/select/index.ts +++ b/packages/core/src/fields/types/select/index.ts @@ -51,17 +51,18 @@ 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 function select ({ - isIndexed, - ui: { displayMode = 'select', ...ui } = {}, - defaultValue, - validation, - ...config -}: SelectFieldConfig): FieldTypeFunc { +export function select (config: SelectFieldConfig): FieldTypeFunc { + const { + isIndexed, + ui: { displayMode = 'select', ...ui } = {}, + defaultValue, + validation, + } = config + return (meta) => { const options = config.options.map(option => { if (typeof option === 'string') { From ed777343c95e87aad7b7c6ded7440e18b799a84c Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Mon, 8 Jul 2024 18:39:03 +1000 Subject: [PATCH 05/16] less meta at the front of error messages --- packages/core/src/fields/non-null-graphql.ts | 2 +- .../core/src/fields/types/bigInt/index.ts | 8 +++---- packages/core/src/fields/types/float/index.ts | 14 ++++------- .../core/src/fields/types/integer/index.ts | 24 ++++++------------- .../src/fields/types/multiselect/index.ts | 8 ++----- .../core/src/fields/types/select/index.ts | 4 +--- packages/core/src/fields/types/text/index.ts | 6 ++--- .../core/src/fields/types/timestamp/index.ts | 4 +--- 8 files changed, 23 insertions(+), 47 deletions(-) diff --git a/packages/core/src/fields/non-null-graphql.ts b/packages/core/src/fields/non-null-graphql.ts index 2b66ea0b4cc..b099bf82736 100644 --- a/packages/core/src/fields/non-null-graphql.ts +++ b/packages/core/src/fields/non-null-graphql.ts @@ -89,7 +89,7 @@ export function assertReadIsNonNullAllowed ( 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}`) } 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` ) @@ -72,11 +72,11 @@ export function bigInt ( 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`) + throw new Error(`${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 (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`) + 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 { diff --git a/packages/core/src/fields/types/float/index.ts b/packages/core/src/fields/types/float/index.ts index 18446fcd0b9..1f944e9f00f 100644 --- a/packages/core/src/fields/types/float/index.ts +++ b/packages/core/src/fields/types/float/index.ts @@ -39,25 +39,21 @@ export const float = 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` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies a default value of: ${defaultValue} but it must be a valid finite number`) } 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` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${validation.min} but it must be a valid finite number`) } 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`) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${validation.max} but it must be a valid finite number`) } if ( @@ -65,9 +61,7 @@ export const float = validation?.max !== undefined && validation.min > validation.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 { diff --git a/packages/core/src/fields/types/integer/index.ts b/packages/core/src/fields/types/integer/index.ts index 8acbd1c64ee..6a38f63a29f 100644 --- a/packages/core/src/fields/types/integer/index.ts +++ b/packages/core/src/fields/types/integer/index.ts @@ -51,12 +51,12 @@ export function integer ({ 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}` + `${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' }, this is not supported on ${meta.provider}` ) } 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` ) @@ -64,25 +64,17 @@ export function integer ({ } 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` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${validation.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` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${validation.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` - ) + throw new Error(`${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 (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` - ) + throw new Error(`${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 ( @@ -90,9 +82,7 @@ export function integer ({ validation?.max !== undefined && validation.min > validation.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`) } const { diff --git a/packages/core/src/fields/types/multiselect/index.ts b/packages/core/src/fields/types/multiselect/index.ts index 079d6fd191d..46154682184 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -85,9 +85,7 @@ export function multiselect ( const accepted = new Set(transformedConfig.options.map(x => x.value)) if (accepted.size !== transformedConfig.options.length) { - throw new Error( - `The multiselect field at ${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed` - ) + throw new Error(`${meta.listKey}.${meta.fieldKey} has duplicate options, this is not allowed`) } const { @@ -155,9 +153,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, diff --git a/packages/core/src/fields/types/select/index.ts b/packages/core/src/fields/types/select/index.ts index 522ffa0bd4d..ad10c77c12f 100644 --- a/packages/core/src/fields/types/select/index.ts +++ b/packages/core/src/fields/types/select/index.ts @@ -131,9 +131,7 @@ export function select (config: SelectFie ({ 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', diff --git a/packages/core/src/fields/types/text/index.ts b/packages/core/src/fields/types/text/index.ts index 3c1f70ad5fa..5dadfffd27f 100644 --- a/packages/core/src/fields/types/text/index.ts +++ b/packages/core/src/fields/types/text/index.ts @@ -69,10 +69,10 @@ export function text ( 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`) + throw new Error(`${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`) + throw new Error(`${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`) } } @@ -81,7 +81,7 @@ export function text ( validation_?.length?.max !== undefined && validation_?.length?.min > validation_?.length?.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_ ? { diff --git a/packages/core/src/fields/types/timestamp/index.ts b/packages/core/src/fields/types/timestamp/index.ts index 85c2fd7b843..74776c108f2 100644 --- a/packages/core/src/fields/types/timestamp/index.ts +++ b/packages/core/src/fields/types/timestamp/index.ts @@ -42,9 +42,7 @@ export function timestamp ( 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()}` + `${meta.listKey}.${meta.fieldKey}.defaultValue is required to be an ISO8601 date-time string such as ${new Date().toISOString()}` ) } } From 22e87fa2af1ba41b1afdd0d18c2e77c07ef02faf Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Mon, 8 Jul 2024 19:04:27 +1000 Subject: [PATCH 06/16] tidy up --- packages/core/src/fields/types/bigInt/index.ts | 12 ++++++------ packages/core/src/fields/types/password/index.ts | 1 - packages/core/src/lib/createSystem.ts | 4 ++-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/core/src/fields/types/bigInt/index.ts b/packages/core/src/fields/types/bigInt/index.ts index cd19c459303..3f117ebec36 100644 --- a/packages/core/src/fields/types/bigInt/index.ts +++ b/packages/core/src/fields/types/bigInt/index.ts @@ -29,7 +29,7 @@ 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 @@ -39,7 +39,7 @@ export function bigInt ( const { isIndexed, defaultValue: _defaultValue, - validation: _validation, + validation: validation_, } = config return (meta) => { @@ -49,7 +49,7 @@ export function bigInt ( defaultValue !== null && defaultValue.kind === 'autoincrement' - const isNullable = resolveDbNullable(_validation, config.db) + const isNullable = resolveDbNullable(validation_, config.db) if (hasAutoIncDefault) { if (meta.provider === 'sqlite' || meta.provider === 'mysql') { @@ -65,9 +65,9 @@ export function bigInt ( } const validation = { - isRequired: _validation?.isRequired ?? false, - min: _validation?.min ?? MIN_INT, - max: _validation?.max ?? MAX_INT, + isRequired: validation_?.isRequired ?? false, + min: validation_?.min ?? MIN_INT, + max: validation_?.max ?? MAX_INT, } for (const type of ['min', 'max'] as const) { diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index ce2276ec887..f5128816e16 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -2,7 +2,6 @@ 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, type CommonFieldConfig, 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 Date: Mon, 8 Jul 2024 19:27:09 +1000 Subject: [PATCH 07/16] revert password to db.isNullable: false default --- .../core/src/fields/types/password/index.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index f5128816e16..575016dcd47 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -10,7 +10,7 @@ import { } from '../../../types' import { graphql } from '../../..' import { type PasswordFieldMeta } from './views' -import { resolveDbNullable, makeValidateHook } from '../../non-null-graphql' +import { makeValidateHook } from '../../non-null-graphql' import { mergeFieldHooks } from '../../resolve-hooks' export type PasswordFieldConfig = @@ -53,14 +53,14 @@ 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 => { + } = config + + return (meta) => { if ((config as any).isIndexed === 'unique') { throw Error("isIndexed: 'unique' is not a supported option for field type password") } @@ -80,8 +80,6 @@ export const password = }, } - const isNullable = resolveDbNullable(validation, config.db) - for (const type of ['min', 'max'] as const) { const val = validation.length[type] if (val !== null && (!Number.isInteger(val) || val < 1)) { @@ -98,9 +96,7 @@ export const password = } function inputResolver (val: string | null | undefined) { - if (val == null) { - return val - } + if (val == null) return val return bcrypt.hash(val, workFactor) } @@ -142,7 +138,7 @@ export const password = hooks: mergeFieldHooks({ validate }, config.hooks), input: { where: - isNullable === false + mode === 'required' ? undefined : { arg: graphql.arg({ type: PasswordFilter }), @@ -175,7 +171,7 @@ 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 @@ -207,3 +203,4 @@ export const password = }), }) } +} From 393dc93bca1670b90bb7539704e26b0ff7a80c60 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Mon, 8 Jul 2024 20:24:05 +1000 Subject: [PATCH 08/16] unify validation and fix tests --- packages/core/src/fields/non-null-graphql.ts | 27 ++-- .../core/src/fields/types/bigInt/index.ts | 75 ++++++----- .../src/fields/types/calendarDay/index.ts | 10 +- .../core/src/fields/types/decimal/index.ts | 19 +-- packages/core/src/fields/types/float/index.ts | 54 ++++---- .../core/src/fields/types/integer/index.ts | 76 +++++------ .../src/fields/types/multiselect/index.ts | 8 +- .../core/src/fields/types/password/index.ts | 99 ++++++++------- packages/core/src/fields/types/text/index.ts | 119 +++++++++--------- .../core/src/fields/types/timestamp/index.ts | 4 +- tests/api-tests/auth.test.ts | 2 +- tests/api-tests/fields/crud.test.ts | 3 +- tests/api-tests/fields/required.test.ts | 17 ++- .../types/fixtures/decimal/test-fixtures.ts | 4 +- .../types/fixtures/password/test-fixtures.ts | 12 +- 15 files changed, 268 insertions(+), 261 deletions(-) diff --git a/packages/core/src/fields/non-null-graphql.ts b/packages/core/src/fields/non-null-graphql.ts index b099bf82736..f2438b7311b 100644 --- a/packages/core/src/fields/non-null-graphql.ts +++ b/packages/core/src/fields/non-null-graphql.ts @@ -17,15 +17,6 @@ export function resolveDbNullable ( return true } -function shouldAddValidation ( - db?: { isNullable?: boolean }, - validation?: unknown -) { - if (db?.isNullable === false) return true - if (validation !== undefined) return true - return false -} - export function makeValidateHook ( meta: FieldData, config: { @@ -40,22 +31,28 @@ export function makeValidateHook ( }, 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 = shouldAddValidation(config.db, config.validation) + const addValidation = config.db?.isNullable === false || config.validation?.isRequired if (addValidation) { const validate = async function (args) { const { operation, addValidationError, resolvedData } = args - if (operation !== 'delete') { - const value = resolvedData[meta.fieldKey] - if ((config.validation?.isRequired || dbNullable === false) && value === null) { - addValidationError(`Missing value`) + + if (valueRequired) { + const value = resolvedData?.[meta.fieldKey] + if ( + (operation === 'create' && value === undefined) + || ((operation === 'create' || operation === 'update') && (value === null)) + ) { + addValidationError(`missing value`) } } @@ -70,7 +67,7 @@ export function makeValidateHook ( return { mode, - validate: undefined + validate: f } } diff --git a/packages/core/src/fields/types/bigInt/index.ts b/packages/core/src/fields/types/bigInt/index.ts index 3f117ebec36..857441d3e7f 100644 --- a/packages/core/src/fields/types/bigInt/index.ts +++ b/packages/core/src/fields/types/bigInt/index.ts @@ -6,11 +6,11 @@ import { orderDirectionEnum, } from '../../../types' import { graphql } from '../../..' +import { filters } from '../../filters' import { resolveDbNullable, makeValidateHook } from '../../non-null-graphql' -import { filters } from '../../filters' import { mergeFieldHooks } from '../../resolve-hooks' export type BigIntFieldConfig = @@ -33,15 +33,19 @@ export type BigIntFieldConfig = 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 = @@ -49,12 +53,11 @@ export function bigInt ( defaultValue !== null && defaultValue.kind === 'autoincrement' - const isNullable = resolveDbNullable(validation_, config.db) - if (hasAutoIncDefault) { if (meta.provider === 'sqlite' || meta.provider === 'mysql') { 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( `${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' } but doesn't specify db.isNullable: false.\n` + @@ -63,45 +66,50 @@ export function bigInt ( ) } } - - 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(`${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 (validation.min > validation.max) { + 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 (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, ({ resolvedData, operation, addValidationError }) => { + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { if (operation === 'delete') return const value = resolvedData[meta.fieldKey] if (typeof value === 'number') { - if (validation?.min !== undefined && value < validation.min) { - addValidationError(`value must be greater than or equal to ${validation.min}`) + if (min !== undefined && value < min) { + addValidationError(`value must be greater than or equal to ${min}`) } - if (validation?.max !== undefined && value > validation.max) { - addValidationError(`value must be less than or equal to ${validation.max}`) + 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' @@ -135,19 +143,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 ac6184b89f8..89e37f182c9 100644 --- a/packages/core/src/fields/types/calendarDay/index.ts +++ b/packages/core/src/fields/types/calendarDay/index.ts @@ -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) @@ -126,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 8fb956e4e98..b3f9d6f1d5d 100644 --- a/packages/core/src/fields/types/decimal/index.ts +++ b/packages/core/src/fields/types/decimal/index.ts @@ -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') } @@ -107,13 +107,13 @@ export const decimal = } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { if (operation === 'delete') return - const val: Decimal | null | undefined = resolvedData[meta.fieldKey] - if (val != null) { - if (min !== undefined && val.lessThan(min)) { + 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 && val.greaterThan(max)) { + if (max !== undefined && value.greaterThan(max)) { addValidationError(`value must be less than or equal to ${max}`) } } @@ -184,3 +184,4 @@ export const decimal = }), }) } +} diff --git a/packages/core/src/fields/types/float/index.ts b/packages/core/src/fields/types/float/index.ts index 1f944e9f00f..7e3dd86b106 100644 --- a/packages/core/src/fields/types/float/index.ts +++ b/packages/core/src/fields/types/float/index.ts @@ -27,14 +27,13 @@ export type FloatFieldConfig = } } -export const float = - ({ - isIndexed, - validation, +export function float (config: FloatFieldConfig = {}): FieldTypeFunc { + const { defaultValue, - ...config - }: FloatFieldConfig = {}): FieldTypeFunc => - meta => { + isIndexed, + validation: v = {}, + } = config + return (meta) => { if ( defaultValue !== undefined && (typeof defaultValue !== 'number' || !Number.isFinite(defaultValue)) @@ -43,45 +42,46 @@ export const float = } if ( - validation?.min !== undefined && - (typeof validation.min !== 'number' || !Number.isFinite(validation.min)) + v.min !== undefined && + (typeof v.min !== 'number' || !Number.isFinite(v.min)) ) { - throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${validation.min} but it must be a valid finite number`) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${v.min} but it must be a valid finite number`) } if ( - validation?.max !== undefined && - (typeof validation.max !== 'number' || !Number.isFinite(validation.max)) + v.max !== undefined && + (typeof v.max !== 'number' || !Number.isFinite(v.max)) ) { - throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${validation.max} but it must be a valid finite number`) + throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${v.max} but it must be a valid finite number`) } if ( - validation?.min !== undefined && - validation?.max !== undefined && - validation.min > validation.max + v.min !== undefined && + v.max !== undefined && + v.min > v.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 = v.min !== undefined || v.max !== undefined const { mode, validate, - } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { if (operation === 'delete') return const value = resolvedData[meta.fieldKey] if (typeof value === 'number') { - if (validation?.max !== undefined && value > validation.max) { - addValidationError(`value must be less than or equal to ${validation.max}` + if (v.max !== undefined && value > v.max) { + addValidationError(`value must be less than or equal to ${v.max}` ) } - if (validation?.min !== undefined && value < validation.min) { - addValidationError(`value must be greater than or equal to ${validation.min}`) + if (v.min !== undefined && value < v.min) { + addValidationError(`value must be greater than or equal to ${v.min}`) } } - }) + } : undefined) return fieldType({ kind: 'scalar', @@ -95,8 +95,7 @@ export const float = ...config, 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, @@ -124,12 +123,13 @@ export const float = getAdminMeta () { return { validation: { - min: validation?.min || null, - max: validation?.max || null, - isRequired: validation?.isRequired ?? false, + isRequired: v.isRequired ?? false, + min: v.min ?? null, + max: v.max ?? null, }, defaultValue: defaultValue ?? null, } }, }) } +} diff --git a/packages/core/src/fields/types/integer/index.ts b/packages/core/src/fields/types/integer/index.ts index 6a38f63a29f..43e6a3499f6 100644 --- a/packages/core/src/fields/types/integer/index.ts +++ b/packages/core/src/fields/types/integer/index.ts @@ -29,31 +29,35 @@ 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 = resolveDbNullable(validation, config.db) - if (hasAutoIncDefault) { if (meta.provider === 'sqlite' || meta.provider === 'mysql') { - throw new Error( - `${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( `${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' } but doesn't specify db.isNullable: false.\n` + @@ -62,52 +66,50 @@ export function integer ({ ) } } - - if (validation?.min !== undefined && !Number.isInteger(validation.min)) { - throw new Error(`${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(`${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(`${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(`${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(`${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, ({ resolvedData, operation, addValidationError }) => { + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { if (operation === 'delete') return const value = resolvedData[meta.fieldKey] if (typeof value === 'number') { - if (validation?.min !== undefined && value < validation.min) { - addValidationError(`value must be greater than or equal to ${validation.min}`) + if (min !== undefined && value < min) { + addValidationError(`value must be greater than or equal to ${min}`) } - if (validation?.max !== undefined && value > validation.max) { - addValidationError(`value must be less than or equal to ${validation.max}`) + 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' @@ -147,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 46154682184..53fe9ad1830 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -40,7 +40,7 @@ export type MultiselectFieldConfig = } } -// 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 @@ -91,14 +91,14 @@ export function multiselect ( const { mode, validate, - } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + } = makeValidateHook(meta, config, ({ inputData, operation, addValidationError }) => { if (operation === 'delete') return - const values: readonly (string | number)[] | undefined = resolvedData[meta.fieldKey] + const values: readonly (string | number)[] | undefined = inputData[meta.fieldKey] // resolvedData is JSON if (values !== undefined) { for (const value of values) { if (!accepted.has(value)) { - addValidationError(`value is not an accepted option`) + addValidationError(`'${value}' is not an accepted option`) } } if (new Set(values).size !== values.length) { diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index 575016dcd47..1324ca89136 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -57,40 +57,41 @@ export function password (config: Passwor const { bcrypt = bcryptjs, workFactor = 10, - validation: _validation, + 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 validation = { - isRequired: _validation?.isRequired ?? false, - rejectCommon: _validation?.rejectCommon ?? false, - match: _validation?.match - ? { - regex: _validation.match.regex, - explanation: _validation.match.explanation ?? `value 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`) } - - for (const type of ['min', 'max'] as const) { - const val = validation.length[type] - if (val !== null && (!Number.isInteger(val) || val < 1)) { - throw new Error(`${meta.listKey}.${meta.fieldKey}: validation.length.${type} 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(`${meta.listKey}.${meta.fieldKey}: validation.length.max cannot be less than validation.length.min`) + 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(`${meta.listKey}.${meta.fieldKey}: workFactor must be an integer between 6 and 31`) } @@ -100,32 +101,33 @@ export function password (config: Passwor return bcrypt.hash(val, workFactor) } + const hasAdditionalValidation = match || rejectCommon || min !== undefined || max !== undefined const { mode, validate, - } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ inputData, operation, addValidationError }) => { if (operation === 'delete') return - const value = resolvedData[meta.fieldKey] + const value = inputData[meta.fieldKey] // we use inputData, as resolveData is hashed if (value != null) { - if (value.length < validation.length.min) { - if (validation.length.min === 1) { + if (min !== undefined && value.length < min) { + if (min === 1) { addValidationError(`value must not be empty`) } else { - addValidationError(`value must be at least ${validation.length.min} characters long`) + addValidationError(`value must be at least ${min} characters long`) } } - if (validation.length.max !== null && value.length > validation.length.max) { - addValidationError(`Value must be no longer than ${validation.length.max} characters`) + if (max !== undefined && value.length > max) { + addValidationError(`value must be no longer than ${max} characters`) } - if (validation.match && !validation.match.regex.test(value)) { - addValidationError(validation.match.explanation) + if (match && !match.regex.test(value)) { + addValidationError(match.explanation ?? `value must match ${match.regex}`) } - if (validation.rejectCommon && dumbPasswords.check(value)) { - addValidationError(`Value is too common and is not allowed`) + if (rejectCommon && dumbPasswords.check(value)) { + addValidationError(`value is too common and is not allowed`) } } - }) + } : undefined) return fieldType({ kind: 'scalar', @@ -173,16 +175,19 @@ export function password (config: Passwor getAdminMeta: (): PasswordFieldMeta => ({ 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({ diff --git a/packages/core/src/fields/types/text/index.ts b/packages/core/src/fields/types/text/index.ts index 5dadfffd27f..426908a2763 100644 --- a/packages/core/src/fields/types/text/index.ts +++ b/packages/core/src/fields/types/text/index.ts @@ -53,72 +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(`${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(`${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(`${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 const defaultValue = isNullable ? (defaultValue_ ?? null) : (defaultValue_ ?? '') - + const hasAdditionalValidation = match || min !== undefined || max !== undefined const { mode, validate, - } = makeValidateHook(meta, config, ({ resolvedData, operation, addValidationError }) => { + } = makeValidateHook(meta, config, hasAdditionalValidation ? ({ resolvedData, operation, addValidationError }) => { if (operation === 'delete') return const value = resolvedData[meta.fieldKey] if (value != null) { - if (validation?.length?.min !== undefined && value.length < validation.length.min) { - if (validation.length.min === 1) { + if (min !== undefined && value.length < min) { + if (min === 1) { addValidationError(`value must not be empty`) } else { - addValidationError(`value must be at least ${validation.length.min} characters long`) + addValidationError(`value must be at least ${min} characters long`) } } - if (validation?.length?.max !== undefined && value.length > validation.length.max) { - addValidationError(`value must be no longer than ${validation.length.max} characters`) + if (max !== undefined && value.length > max) { + addValidationError(`value must be no longer than ${max} characters`) } - if (validation?.match && !validation.match.regex.test(value)) { - addValidationError(validation.match.explanation || `value must match ${validation.match.regex}`) + if (match && !match.regex.test(value)) { + addValidationError(match.explanation ?? `value must match ${match.regex}`) } } - }) + } : undefined) return fieldType({ kind: 'scalar', @@ -133,8 +144,7 @@ export function text ( ...config, 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], @@ -147,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 }) }, @@ -166,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, @@ -185,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 74776c108f2..d2cdd55d8ac 100644 --- a/packages/core/src/fields/types/timestamp/index.ts +++ b/packages/core/src/fields/types/timestamp/index.ts @@ -41,9 +41,7 @@ export function timestamp ( try { graphql.DateTime.graphQLType.parseValue(defaultValue) } catch (err) { - throw new Error( - `${meta.listKey}.${meta.fieldKey}.defaultValue is required to be an 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()}`) } } 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..5f14b2151b0 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 @@ -85,23 +84,23 @@ 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, + mod.name === 'Text' ? ['Test.testField: value must not be empty'] : messages, }, ]) }) @@ -112,9 +111,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/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}`, From f29f4bab339e256b0ffe63c5401e38c6d1a7c54d Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 10:41:14 +1000 Subject: [PATCH 09/16] unify float to same as integer, bigInt et al --- packages/core/src/fields/types/float/index.ts | 54 +++++++++---------- 1 file changed, 24 insertions(+), 30 deletions(-) diff --git a/packages/core/src/fields/types/float/index.ts b/packages/core/src/fields/types/float/index.ts index 7e3dd86b106..f77048b9051 100644 --- a/packages/core/src/fields/types/float/index.ts +++ b/packages/core/src/fields/types/float/index.ts @@ -31,39 +31,34 @@ export function float (config: FloatField const { defaultValue, isIndexed, - validation: v = {}, + validation = {}, } = config + + const { + isRequired = false, + min, + max + } = validation + return (meta) => { - if ( - defaultValue !== undefined && - (typeof defaultValue !== 'number' || !Number.isFinite(defaultValue)) - ) { + 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 ( - v.min !== undefined && - (typeof v.min !== 'number' || !Number.isFinite(v.min)) - ) { - throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${v.min} 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 ( - v.max !== undefined && - (typeof v.max !== 'number' || !Number.isFinite(v.max)) - ) { - throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.max: ${v.max} 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 ( - v.min !== undefined && - v.max !== undefined && - v.min > v.max + 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 = v.min !== undefined || v.max !== undefined + const hasAdditionalValidation = min !== undefined || max !== undefined const { mode, validate, @@ -72,13 +67,12 @@ export function float (config: FloatField const value = resolvedData[meta.fieldKey] if (typeof value === 'number') { - if (v.max !== undefined && value > v.max) { - addValidationError(`value must be less than or equal to ${v.max}` - ) + if (min !== undefined && value < min) { + addValidationError(`value must be greater than or equal to ${min}`) } - if (v.min !== undefined && value < v.min) { - addValidationError(`value must be greater than or equal to ${v.min}`) + if (max !== undefined && value > max) { + addValidationError(`value must be less than or equal to ${max}`) } } } : undefined) @@ -123,9 +117,9 @@ export function float (config: FloatField getAdminMeta () { return { validation: { - isRequired: v.isRequired ?? false, - min: v.min ?? null, - max: v.max ?? null, + isRequired, + min: min ?? null, + max: max ?? null, }, defaultValue: defaultValue ?? null, } From eadd3b0de7c48d913bbc35b144aa1dfcf80241c0 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:00:31 +1000 Subject: [PATCH 10/16] add bigInt error when defaultValue and isRequired is set --- packages/core/src/fields/types/bigInt/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/fields/types/bigInt/index.ts b/packages/core/src/fields/types/bigInt/index.ts index 857441d3e7f..ff25772a15b 100644 --- a/packages/core/src/fields/types/bigInt/index.ts +++ b/packages/core/src/fields/types/bigInt/index.ts @@ -65,6 +65,9 @@ export function bigInt (config: BigIntFi `https://github.com/prisma/prisma/issues/8663` ) } + if (isRequired) { + throw new Error(`${meta.listKey}.${meta.fieldKey} defaultValue: { kind: 'autoincrement' } conflicts with validation.isRequired: true`) + } } if (min !== undefined && !Number.isInteger(min)) { throw new Error(`${meta.listKey}.${meta.fieldKey} specifies validation.min: ${min} but it must be an integer`) From 64069ff5eff4888785a21b9b75765ba9206a5f95 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:01:46 +1000 Subject: [PATCH 11/16] add bigInt changeset --- .changeset/fix-bigint-validation.md | 5 +++++ .changeset/{honest-moles-doubt.md => update-field-hooks.md} | 0 2 files changed, 5 insertions(+) create mode 100644 .changeset/fix-bigint-validation.md rename .changeset/{honest-moles-doubt.md => update-field-hooks.md} (100%) diff --git a/.changeset/fix-bigint-validation.md b/.changeset/fix-bigint-validation.md new file mode 100644 index 00000000000..687684cf413 --- /dev/null +++ b/.changeset/fix-bigint-validation.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/core': patch +--- + +Fix bigInt field type to throw if `defaultValue` and `validation.isRequired` is set diff --git a/.changeset/honest-moles-doubt.md b/.changeset/update-field-hooks.md similarity index 100% rename from .changeset/honest-moles-doubt.md rename to .changeset/update-field-hooks.md From cbc2eceef876d1bb0334eb617a1b09cf3d9b1c1b Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:08:47 +1000 Subject: [PATCH 12/16] fix example-test.ts --- examples/testing/example-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', } ) }) From d1dba429531935791390124befd96554a2251f48 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:17:45 +1000 Subject: [PATCH 13/16] revert builtin.beforeOperation to happen first --- packages/core/src/fields/resolve-hooks.ts | 4 +++- packages/core/src/fields/types/file/index.ts | 4 +--- packages/core/src/fields/types/image/index.ts | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/core/src/fields/resolve-hooks.ts b/packages/core/src/fields/resolve-hooks.ts index 690dfca0ad0..3ab9ede6633 100644 --- a/packages/core/src/fields/resolve-hooks.ts +++ b/packages/core/src/fields/resolve-hooks.ts @@ -58,7 +58,9 @@ export function mergeFieldHooks ( const hooksValidate = resolveValidateHooks(hooks) return { ...hooks, - beforeOperation: merge(builtin.beforeOperation, hooks.beforeOperation), + // 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), diff --git a/packages/core/src/fields/types/file/index.ts b/packages/core/src/fields/types/file/index.ts index 31674073c9e..78882b594b1 100644 --- a/packages/core/src/fields/types/file/index.ts +++ b/packages/core/src/fields/types/file/index.ts @@ -56,9 +56,7 @@ 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) { diff --git a/packages/core/src/fields/types/image/index.ts b/packages/core/src/fields/types/image/index.ts index aca3b55f0f3..7877902c6d9 100644 --- a/packages/core/src/fields/types/image/index.ts +++ b/packages/core/src/fields/types/image/index.ts @@ -81,9 +81,7 @@ 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) { From a3c80b9fe965ec8c3a7d4d38a37f9e77e8be8c09 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:24:05 +1000 Subject: [PATCH 14/16] add multiselect nullable changeset + add tests --- .changeset/add-isnullable-multiselect.md | 5 +++ .../src/fields/types/multiselect/index.ts | 27 ++++++------- tests/api-tests/fields/required.test.ts | 38 ++++++++++--------- .../fixtures/multiselect/test-fixtures.ts | 5 ++- 4 files changed, 40 insertions(+), 35 deletions(-) create mode 100644 .changeset/add-isnullable-multiselect.md 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/packages/core/src/fields/types/multiselect/index.ts b/packages/core/src/fields/types/multiselect/index.ts index 53fe9ad1830..068a59a4221 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -8,7 +8,6 @@ import { jsonFieldTypePolyfilledForSQLite, } from '../../../types' import { graphql } from '../../..' -import { userInputError } from '../../../lib/core/graphql-errors' import { makeValidateHook } from '../../non-null-graphql' import { mergeFieldHooks } from '../../resolve-hooks' @@ -25,12 +24,12 @@ 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?: { @@ -48,7 +47,7 @@ export function multiselect ( config: MultiselectFieldConfig ): FieldTypeFunc { const { - defaultValue = [], + defaultValue = [], // TODO: deprecated, remove in breaking change } = config config.db ??= {} @@ -64,7 +63,7 @@ export function multiselect ( 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[] @@ -74,15 +73,11 @@ export function multiselect ( 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 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`) @@ -94,8 +89,8 @@ export function multiselect ( } = makeValidateHook(meta, config, ({ inputData, operation, addValidationError }) => { if (operation === 'delete') return - const values: readonly (string | number)[] | undefined = inputData[meta.fieldKey] // resolvedData is JSON - if (values !== undefined) { + 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`) @@ -137,7 +132,10 @@ export function multiselect ( mode, map: config?.db?.map, extendPrismaSchema: config.db?.extendPrismaSchema, - default: { kind: 'literal', value: JSON.stringify(defaultValue) }, + default: { + kind: 'literal', + value: JSON.stringify(defaultValue ?? null) + }, } ) } @@ -191,5 +189,4 @@ function configToOptionsAndGraphQLType ( } } -const nonNullList = (type: T) => - graphql.list(graphql.nonNull(type)) +const nonNullList = (type: T) => graphql.list(graphql.nonNull(type)) diff --git a/tests/api-tests/fields/required.test.ts b/tests/api-tests/fields/required.test.ts index 5f14b2151b0..b73aeec4782 100644 --- a/tests/api-tests/fields/required.test.ts +++ b/tests/api-tests/fields/required.test.ts @@ -86,25 +86,27 @@ for (const modulePath of testModules) { 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 } - }`, + if (!mod.hasDefaultDefault) { + 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 } + }`, + }) + expect(data).toEqual({ createTest: null }) + expectValidationError(errors, [ + { + path: ['createTest'], + messages: + mod.name === 'Text' ? ['Test.testField: value must not be empty'] : messages, + }, + ]) }) - expect(data).toEqual({ createTest: null }) - expectValidationError(errors, [ - { - path: ['createTest'], - messages: - mod.name === 'Text' ? ['Test.testField: value must not be empty'] : messages, - }, - ]) - }) - ) + ) + } test( 'Create an object with an explicit null value', 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..edb819322c0 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 hasDefaultDefault = 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 { From c2420fcb7fc1bad0be7f51dcccc87e09308a2e2d Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:55:05 +1000 Subject: [PATCH 15/16] fix tests for non-nullable default fields --- .../src/fields/types/multiselect/index.ts | 3 +- tests/api-tests/fields/required.test.ts | 42 ++++++++++--------- .../fixtures/multiselect/test-fixtures.ts | 2 +- .../types/fixtures/text/test-fixtures.ts | 1 + 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/core/src/fields/types/multiselect/index.ts b/packages/core/src/fields/types/multiselect/index.ts index 068a59a4221..bb5614cb8f7 100644 --- a/packages/core/src/fields/types/multiselect/index.ts +++ b/packages/core/src/fields/types/multiselect/index.ts @@ -47,11 +47,12 @@ export function multiselect ( config: MultiselectFieldConfig ): FieldTypeFunc { const { - defaultValue = [], // TODO: deprecated, remove in breaking change + 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') { diff --git a/tests/api-tests/fields/required.test.ts b/tests/api-tests/fields/required.test.ts index b73aeec4782..7f19682586b 100644 --- a/tests/api-tests/fields/required.test.ts +++ b/tests/api-tests/fields/required.test.ts @@ -52,6 +52,11 @@ for (const modulePath of testModules) { fields: { name: text(), testField: mod.typeFunction({ + ...(mod.nonNullableDefault ? { + db: { + isNullable: true + } + } : {}), ...fieldConfig, validation: { ...fieldConfig.validation, @@ -86,27 +91,24 @@ for (const modulePath of testModules) { const messages = [`Test.testField: missing value`] - if (!mod.hasDefaultDefault) { - 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 } - }`, - }) - expect(data).toEqual({ createTest: null }) - expectValidationError(errors, [ - { - path: ['createTest'], - messages: - mod.name === 'Text' ? ['Test.testField: value must not be empty'] : messages, - }, - ]) + 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 } + }`, }) - ) - } + expect(data).toEqual({ createTest: null }) + expectValidationError(errors, [ + { + path: ['createTest'], + messages, + }, + ]) + }) + ) test( 'Create an object with an explicit null value', 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 edb819322c0..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,7 +17,7 @@ export const exampleValue2 = (matrixValue: MatrixValue) => ? ['a string', '1number'] : [2, 4] export const supportsNullInput = false -export const hasDefaultDefault = true +export const nonNullableDefault = true export const neverNull = false export const supportsUnique = false export const supportsDbMap = true 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 From c45b1532c8b8402edb5c3da061f9b060cfe1b4cf Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 9 Jul 2024 12:02:03 +1000 Subject: [PATCH 16/16] fix bigInt changeset --- .changeset/fix-bigint-validation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-bigint-validation.md b/.changeset/fix-bigint-validation.md index 687684cf413..2c49d9e94d8 100644 --- a/.changeset/fix-bigint-validation.md +++ b/.changeset/fix-bigint-validation.md @@ -2,4 +2,4 @@ '@keystone-6/core': patch --- -Fix bigInt field type to throw if `defaultValue` and `validation.isRequired` is set +Fix bigInt field type to throw if `defaultValue: { kind: 'autoincrement' }` and `validation.isRequired` is set