Skip to content

Commit

Permalink
Adds {field}.hooks.validate.[create|update|delete] hooks (#9057)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcousens authored Mar 25, 2024
1 parent 30413da commit 1b55b41
Show file tree
Hide file tree
Showing 27 changed files with 875 additions and 2,993 deletions.
5 changes: 5 additions & 0 deletions .changeset/deprecate-validate-hooks-f.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
----
'@keystone-6/core': minor
----

Adds `{field}.hooks.validate.[create|update|delete]` hooks, deprecates `validateInput` and `validateDelete` (throws if incompatible)
5 changes: 5 additions & 0 deletions .changeset/text-null-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
----
'@keystone-6/core': patch
----

Fixes the `text` field type to accept a `defaultValue` of `null`
12 changes: 12 additions & 0 deletions packages/core/src/fields/non-null-graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ export function getResolvedIsNullable (
return true
}

export function resolveHasValidation ({
db,
validation
}: {
db?: { isNullable?: boolean },
validation?: unknown,
}) {
if (db?.isNullable === false) return true
if (validation !== undefined) return true
return false
}

export function assertReadIsNonNullAllowed<ListTypeInfo extends BaseListTypeInfo> (
meta: FieldData,
config: CommonFieldConfig<ListTypeInfo>,
Expand Down
40 changes: 21 additions & 19 deletions packages/core/src/fields/types/bigInt/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { humanize } from '../../../lib/utils'
import {
type BaseListTypeInfo,
fieldType,
type FieldTypeFunc,
type CommonFieldConfig,
type FieldTypeFunc,
fieldType,
orderDirectionEnum,
} from '../../../types'
import { graphql } from '../../..'
import { assertReadIsNonNullAllowed, getResolvedIsNullable } from '../../non-null-graphql'
import {
assertReadIsNonNullAllowed,
getResolvedIsNullable,
resolveHasValidation,
} from '../../non-null-graphql'
import { filters } from '../../filters'

export type BigIntFieldConfig<ListTypeInfo extends BaseListTypeInfo> =
Expand All @@ -30,14 +34,16 @@ export type BigIntFieldConfig<ListTypeInfo extends BaseListTypeInfo> =
const MAX_INT = 9223372036854775807n
const MIN_INT = -9223372036854775808n

export const bigInt =
<ListTypeInfo extends BaseListTypeInfo>({
export function bigInt <ListTypeInfo extends BaseListTypeInfo>(
config: BigIntFieldConfig<ListTypeInfo> = {}
): FieldTypeFunc<ListTypeInfo> {
const {
isIndexed,
defaultValue: _defaultValue,
validation: _validation,
...config
}: BigIntFieldConfig<ListTypeInfo> = {}): FieldTypeFunc<ListTypeInfo> =>
meta => {
} = config

return (meta) => {
const defaultValue = _defaultValue ?? null
const hasAutoIncDefault =
typeof defaultValue == 'object' &&
Expand All @@ -48,9 +54,7 @@ export const bigInt =

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(`The bigInt field at ${meta.listKey}.${meta.fieldKey} specifies defaultValue: { kind: 'autoincrement' }, this is not supported on ${meta.provider}`)
}
if (isNullable !== false) {
throw new Error(
Expand All @@ -69,21 +73,18 @@ export const 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(`The bigInt field at ${meta.listKey}.${meta.fieldKey} specifies validation.${type}: ${validation[type]} which is outside of the range of a 64bit signed integer(${MIN_INT}n - ${MAX_INT}n) which is not allowed`)
}
}
if (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(`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)

return fieldType({
kind: 'scalar',
Expand All @@ -103,7 +104,7 @@ export const bigInt =
...config,
hooks: {
...config.hooks,
async validateInput (args) {
validateInput: hasValidation ? async (args) => {
const value = args.resolvedData[meta.fieldKey]

if (
Expand All @@ -128,7 +129,7 @@ export const bigInt =
}

await config.hooks?.validateInput?.(args)
},
} : config.hooks?.validateInput
},
input: {
uniqueWhere:
Expand Down Expand Up @@ -169,3 +170,4 @@ export const bigInt =
},
})
}
}
17 changes: 10 additions & 7 deletions packages/core/src/fields/types/checkbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { userInputError } from '../../../lib/core/graphql-errors'
import {
type BaseListTypeInfo,
type CommonFieldConfig,
fieldType,
type FieldTypeFunc,
fieldType,
orderDirectionEnum,
} from '../../../types'
import { graphql } from '../../..'
Expand All @@ -19,13 +19,16 @@ export type CheckboxFieldConfig<ListTypeInfo extends BaseListTypeInfo> =
}
}

export function checkbox <ListTypeInfo extends BaseListTypeInfo>({
defaultValue = false,
...config
}: CheckboxFieldConfig<ListTypeInfo> = {}): FieldTypeFunc<ListTypeInfo> {
return meta => {
export function checkbox <ListTypeInfo extends BaseListTypeInfo>(
config: CheckboxFieldConfig<ListTypeInfo> = {}
): FieldTypeFunc<ListTypeInfo> {
const {
defaultValue = false,
} = config

return (meta) => {
if ((config as any).isIndexed === 'unique') {
throw Error("isIndexed: 'unique' is not a supported option for field type checkbox")
throw TypeError("isIndexed: 'unique' is not a supported option for field type checkbox")
}

assertReadIsNonNullAllowed(meta, config, false)
Expand Down
18 changes: 10 additions & 8 deletions packages/core/src/fields/types/multiselect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,16 @@ export type MultiselectFieldConfig<ListTypeInfo extends BaseListTypeInfo> =
const MAX_INT = 2147483647
const MIN_INT = -2147483648

export const multiselect =
<ListTypeInfo extends BaseListTypeInfo>({
export function multiselect <ListTypeInfo extends BaseListTypeInfo>(
config: MultiselectFieldConfig<ListTypeInfo>
): FieldTypeFunc<ListTypeInfo> {
const {
defaultValue = [],
...config
}: MultiselectFieldConfig<ListTypeInfo>): FieldTypeFunc<ListTypeInfo> =>
meta => {
} = config

return (meta) => {
if ((config as any).isIndexed === 'unique') {
throw Error("isIndexed: 'unique' is not a supported option for field type multiselect")
throw TypeError("isIndexed: 'unique' is not a supported option for field type multiselect")
}
const fieldLabel = config.label ?? humanize(meta.fieldKey)
assertReadIsNonNullAllowed(meta, config, false)
Expand Down Expand Up @@ -92,8 +94,7 @@ export const multiselect =
hooks: {
...config.hooks,
async validateInput (args) {
const selectedValues: readonly (string | number)[] | undefined =
args.inputData[meta.fieldKey]
const selectedValues: readonly (string | number)[] | undefined = args.inputData[meta.fieldKey]
if (selectedValues !== undefined) {
for (const value of selectedValues) {
if (!possibleValues.has(value)) {
Expand Down Expand Up @@ -137,6 +138,7 @@ export const multiselect =
}
)
}
}

function configToOptionsAndGraphQLType (
config: MultiselectFieldConfig<BaseListTypeInfo>,
Expand Down
84 changes: 38 additions & 46 deletions packages/core/src/fields/types/text/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { humanize } from '../../../lib/utils'
import {
type BaseListTypeInfo,
type CommonFieldConfig,
type FieldTypeFunc,
fieldType,
orderDirectionEnum,
type FieldTypeFunc,
} from '../../../types'
import { graphql } from '../../..'
import { assertReadIsNonNullAllowed } from '../../non-null-graphql'
import {
assertReadIsNonNullAllowed,
resolveHasValidation,
} from '../../non-null-graphql'
import { filters } from '../../filters'

export type TextFieldConfig<ListTypeInfo extends BaseListTypeInfo> =
Expand All @@ -24,7 +27,7 @@ export type TextFieldConfig<ListTypeInfo extends BaseListTypeInfo> =
match?: { regex: RegExp, explanation?: string }
length?: { min?: number, max?: number }
}
defaultValue?: string
defaultValue?: string | null
db?: {
isNullable?: boolean
map?: string
Expand Down Expand Up @@ -53,62 +56,56 @@ export type TextFieldConfig<ListTypeInfo extends BaseListTypeInfo> =
}
}

export const text =
<ListTypeInfo extends BaseListTypeInfo>({
export function text <ListTypeInfo extends BaseListTypeInfo>(
config: TextFieldConfig<ListTypeInfo> = {}
): FieldTypeFunc<ListTypeInfo> {
const {
isIndexed,
defaultValue: _defaultValue,
validation: _validation,
...config
}: TextFieldConfig<ListTypeInfo> = {}): FieldTypeFunc<ListTypeInfo> =>
meta => {
defaultValue: defaultValue_,
validation: validation_
} = config

return (meta) => {
for (const type of ['min', 'max'] as const) {
const val = _validation?.length?.[type]
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(`The text field at ${meta.listKey}.${meta.fieldKey} specifies validation.length.${type}: ${val} but it must be a positive integer`)
}
if (_validation?.isRequired && val !== undefined && val === 0) {
throw new Error(
`The text field at ${meta.listKey}.${meta.fieldKey} specifies validation.isRequired: true and validation.length.${type}: 0, this is not allowed because validation.isRequired implies at least a min length of 1`
)
if (validation_?.isRequired && val !== undefined && val === 0) {
throw new Error(`The text field at ${meta.listKey}.${meta.fieldKey} specifies validation.isRequired: true and validation.length.${type}: 0, this is not allowed because validation.isRequired implies at least a min length of 1`)
}
}

if (
_validation?.length?.min !== undefined &&
_validation?.length?.max !== undefined &&
_validation?.length?.min > _validation?.length?.max
validation_?.length?.min !== undefined &&
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(`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`)
}

const validation = {
..._validation,
const validation = validation_ ? {
...validation_,
length: {
min: _validation?.isRequired ? _validation?.length?.min ?? 1 : _validation?.length?.min,
max: _validation?.length?.max,
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 fieldLabel = config.label ?? humanize(meta.fieldKey)

assertReadIsNonNullAllowed(meta, config, isNullable)

const defaultValue = isNullable ? (defaultValue_ ?? null) : (defaultValue_ ?? '')
const fieldLabel = config.label ?? humanize(meta.fieldKey)
const mode = isNullable ? 'optional' : 'required'
const hasValidation = resolveHasValidation(config) || !isNullable // we make an exception for Text

const defaultValue =
isNullable === false || _defaultValue !== undefined ? _defaultValue || '' : undefined
return fieldType({
kind: 'scalar',
mode,
scalar: 'String',
default: defaultValue === undefined ? undefined : { kind: 'literal', value: defaultValue },
default: (defaultValue === null) ? undefined : { kind: 'literal', value: defaultValue },
index: isIndexed === true ? 'index' : isIndexed || undefined,
map: config.db?.map,
nativeType: config.db?.nativeType,
Expand All @@ -117,7 +114,7 @@ export const text =
...config,
hooks: {
...config.hooks,
async validateInput (args) {
validateInput: hasValidation ? async (args) => {
const val = args.resolvedData[meta.fieldKey]
if (val === null && (validation?.isRequired || isNullable === false)) {
args.addValidationError(`${fieldLabel} is required`)
Expand All @@ -127,25 +124,19 @@ export const text =
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`
)
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`
)
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}`
)
args.addValidationError(validation.match.explanation || `${fieldLabel} must match ${validation.match.regex}`)
}
}

await config.hooks?.validateInput?.(args)
},
} : config.hooks?.validateInput
},
input: {
uniqueWhere:
Expand Down Expand Up @@ -199,6 +190,7 @@ export const text =
},
})
}
}

export type TextFieldMeta = {
displayMode: 'input' | 'textarea'
Expand Down
Loading

0 comments on commit 1b55b41

Please sign in to comment.