From 3da69fea025101757bedc96103b39230f9c38ec3 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Mon, 18 Sep 2023 13:23:13 +1000 Subject: [PATCH] Use required types internally for hooks (#8808) --- packages/auth/src/types.ts | 2 +- .../core/src/lib/core/initialise-lists.ts | 65 ++- .../src/lib/core/mutations/create-update.ts | 7 +- packages/core/src/lib/core/mutations/hooks.ts | 37 +- .../core/src/lib/core/mutations/validation.ts | 8 +- .../core/src/types/config/access-control.ts | 2 +- packages/core/src/types/config/fields.ts | 4 +- packages/core/src/types/config/hooks.ts | 386 +++++++++++------- 8 files changed, 293 insertions(+), 218 deletions(-) diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index fa31c7e6420..ad0c19372c5 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -1,4 +1,4 @@ -import { BaseListTypeInfo, KeystoneContext } from '@keystone-6/core/types'; +import type { BaseListTypeInfo, KeystoneContext } from '@keystone-6/core/types'; export type AuthGqlNames = { CreateInitialInput: string; diff --git a/packages/core/src/lib/core/initialise-lists.ts b/packages/core/src/lib/core/initialise-lists.ts index 04cd3063a19..1c51264b838 100644 --- a/packages/core/src/lib/core/initialise-lists.ts +++ b/packages/core/src/lib/core/initialise-lists.ts @@ -159,9 +159,19 @@ function getIsEnabled(listsConfig: KeystoneConfig['lists']) { return isEnabled; } +function defaultOperationHook() {} function defaultListHooksResolveInput({ resolvedData }: { resolvedData: any }) { return resolvedData; } +function defaultFieldHooksResolveInput({ + resolvedData, + fieldKey, +}: { + resolvedData: any; + fieldKey: string; +}) { + return resolvedData[fieldKey]; +} function parseListHooksResolveInput(f: ListHooks['resolveInput']) { if (typeof f === 'function') { @@ -171,53 +181,29 @@ function parseListHooksResolveInput(f: ListHooks['resolveInput }; } - const { create, update } = f ?? {}; - return { - create: create ?? defaultListHooksResolveInput, - update: update ?? defaultListHooksResolveInput, - }; + const { create = defaultListHooksResolveInput, update = defaultListHooksResolveInput } = f ?? {}; + return { create, update }; } function parseListHooks(hooks: ListHooks): ResolvedListHooks { return { - ...hooks, resolveInput: parseListHooksResolveInput(hooks.resolveInput), + validateInput: hooks.validateInput ?? defaultOperationHook, + validateDelete: hooks.validateDelete ?? defaultOperationHook, + beforeOperation: hooks.beforeOperation ?? defaultOperationHook, + afterOperation: hooks.afterOperation ?? defaultOperationHook, }; } -function defaultFieldHooksResolveInput({ - resolvedData, - fieldKey, -}: { - resolvedData: any; - fieldKey: string; -}) { - return resolvedData[fieldKey]; -} - -function parseFieldHooksResolveInput(f: FieldHooks['resolveInput']) { - return f ?? defaultFieldHooksResolveInput; - // TODO: one day - // if (typeof f === 'function') { - // return { - // create: f, - // update: f, - // }; - // } - // - // const { create, update } = f ?? {}; - // return { - // create: create ?? defaultFieldHooksResolveInput, - // update: update ?? defaultFieldHooksResolveInput, - // }; -} - function parseFieldHooks( hooks: FieldHooks ): ResolvedFieldHooks { return { - ...hooks, - resolveInput: parseFieldHooksResolveInput(hooks.resolveInput), + resolveInput: hooks.resolveInput ?? defaultFieldHooksResolveInput, + validateInput: hooks.validateInput ?? defaultOperationHook, + validateDelete: hooks.validateDelete ?? defaultOperationHook, + beforeOperation: hooks.beforeOperation ?? defaultOperationHook, + afterOperation: hooks.afterOperation ?? defaultOperationHook, }; } @@ -780,8 +766,9 @@ export function initialiseLists(config: KeystoneConfig): Record { - if (field.hooks.resolveInput === undefined) return [fieldKey, resolvedData[fieldKey]]; - - const resolver = field.hooks.resolveInput; try { return [ fieldKey, - await resolver({ + await field.hooks.resolveInput({ ...hookArgs, resolvedData, fieldKey, @@ -380,7 +377,7 @@ async function resolveInputForCreateOrUpdate( // `hookArgs` based on the `operation` which will make `hookArgs.item` // be the right type for `originalItem` for the operation hookArgs.operation === 'create' - ? { ...hookArgs, item: updatedItem, originalItem: hookArgs.item } + ? { ...hookArgs, item: updatedItem, originalItem: undefined } : { ...hookArgs, item: updatedItem, originalItem: hookArgs.item } ); }, diff --git a/packages/core/src/lib/core/mutations/hooks.ts b/packages/core/src/lib/core/mutations/hooks.ts index b872a0f13c1..880f0adeb8e 100644 --- a/packages/core/src/lib/core/mutations/hooks.ts +++ b/packages/core/src/lib/core/mutations/hooks.ts @@ -1,43 +1,28 @@ import { extensionError } from '../graphql-errors'; +import type { InitialisedList } from '../initialise-lists'; export async function runSideEffectOnlyHook< - HookName extends string, - List extends { - fields: Record< - string, - { - hooks: { - [Key in HookName]?: (args: { fieldKey: string } & Args) => Promise | void; - }; - } - >; - hooks: { - [Key in HookName]?: (args: any) => Promise | void; - }; - listKey: string; - }, - Args extends Parameters>[0] ->(list: List, hookName: HookName, args: Args) { - // Runs the before/after operation hooks - + HookName extends 'beforeOperation' | 'afterOperation', + Args extends Parameters>[0] +>(list: InitialisedList, hookName: HookName, args: Args) { let shouldRunFieldLevelHook: (fieldKey: string) => boolean; if (args.operation === 'delete') { - // Always run field hooks for delete operations + // always run field hooks for delete operations shouldRunFieldLevelHook = () => true; } else { - // Only run field hooks on if the field was specified in the - // original input for create and update operations. + // only run field hooks on if the field was specified in the + // original input for create and update operations. const inputDataKeys = new Set(Object.keys(args.inputData)); shouldRunFieldLevelHook = fieldKey => inputDataKeys.has(fieldKey); } - // Field hooks + // field hooks const fieldsErrors: { error: Error; tag: string }[] = []; await Promise.all( Object.entries(list.fields).map(async ([fieldKey, field]) => { if (shouldRunFieldLevelHook(fieldKey)) { try { - await field.hooks[hookName]?.({ fieldKey, ...args }); + await field.hooks[hookName]({ fieldKey, ...args } as any); // TODO: FIXME any } catch (error: any) { fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.${hookName}` }); } @@ -49,9 +34,9 @@ export async function runSideEffectOnlyHook< throw extensionError(hookName, fieldsErrors); } - // List hooks + // list hooks try { - await list.hooks[hookName]?.(args); + await list.hooks[hookName](args as any); // TODO: FIXME any } catch (error: any) { throw extensionError(hookName, [{ error, tag: `${list.listKey}.hooks.${hookName}` }]); } diff --git a/packages/core/src/lib/core/mutations/validation.ts b/packages/core/src/lib/core/mutations/validation.ts index dd58092e90e..e90b013cfc1 100644 --- a/packages/core/src/lib/core/mutations/validation.ts +++ b/packages/core/src/lib/core/mutations/validation.ts @@ -22,7 +22,7 @@ export async function validateUpdateCreate({ const addValidationError = (msg: string) => messages.push(`${list.listKey}.${fieldKey}: ${msg}`); try { - await field.hooks.validateInput?.({ ...hookArgs, addValidationError, fieldKey }); + await field.hooks.validateInput({ ...hookArgs, addValidationError, fieldKey }); } catch (error: any) { fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateInput` }); } @@ -36,7 +36,7 @@ export async function validateUpdateCreate({ // List validation hooks const addValidationError = (msg: string) => messages.push(`${list.listKey}: ${msg}`); try { - await list.hooks.validateInput?.({ ...hookArgs, addValidationError }); + await list.hooks.validateInput({ ...hookArgs, addValidationError }); } catch (error: any) { throw extensionError('validateInput', [{ error, tag: `${list.listKey}.hooks.validateInput` }]); } @@ -62,7 +62,7 @@ export async function validateDelete({ const addValidationError = (msg: string) => messages.push(`${list.listKey}.${fieldKey}: ${msg}`); try { - await field.hooks.validateDelete?.({ ...hookArgs, addValidationError, fieldKey }); + await field.hooks.validateDelete({ ...hookArgs, addValidationError, fieldKey }); } catch (error: any) { fieldsErrors.push({ error, tag: `${list.listKey}.${fieldKey}.hooks.validateDelete` }); } @@ -74,7 +74,7 @@ export async function validateDelete({ // List validation const addValidationError = (msg: string) => messages.push(`${list.listKey}: ${msg}`); try { - await list.hooks.validateDelete?.({ ...hookArgs, addValidationError }); + await list.hooks.validateDelete({ ...hookArgs, addValidationError }); } catch (error: any) { throw extensionError('validateDelete', [ { error, tag: `${list.listKey}.hooks.validateDelete` }, diff --git a/packages/core/src/types/config/access-control.ts b/packages/core/src/types/config/access-control.ts index 0636d8ad2ca..ec59e76868a 100644 --- a/packages/core/src/types/config/access-control.ts +++ b/packages/core/src/types/config/access-control.ts @@ -5,7 +5,7 @@ import type { BaseListTypeInfo } from '../type-info'; export type BaseAccessArgs = { context: KeystoneContext; session?: ListTypeInfo['all']['session']; - listKey: string; + listKey: ListTypeInfo['key']; }; export type AccessOperation = 'create' | 'query' | 'update' | 'delete'; diff --git a/packages/core/src/types/config/fields.ts b/packages/core/src/types/config/fields.ts index 31b7e61b3b7..d280eca79b6 100644 --- a/packages/core/src/types/config/fields.ts +++ b/packages/core/src/types/config/fields.ts @@ -13,8 +13,8 @@ export type BaseFields = { export type FilterOrderArgs = { context: KeystoneContext; session?: ListTypeInfo['all']['session']; - listKey: string; - fieldKey: string; + listKey: ListTypeInfo['key']; + fieldKey: ListTypeInfo['fields']; }; export type CommonFieldConfig = { diff --git a/packages/core/src/types/config/hooks.ts b/packages/core/src/types/config/hooks.ts index ea861a5032b..d6d869ea14e 100644 --- a/packages/core/src/types/config/hooks.ts +++ b/packages/core/src/types/config/hooks.ts @@ -6,7 +6,7 @@ type CommonArgs = { /** * The key of the list that the operation is occurring on */ - listKey: string; + listKey: ListTypeInfo['key']; }; type ResolveInputListHook< @@ -55,45 +55,32 @@ export type ListHooks = { /** * Used to **validate the input** for create and update operations once all resolveInput hooks resolved */ - validateInput?: ValidateInputHook; + validateInput?: ValidateHook; + /** * Used to **validate** that a delete operation can happen after access control has occurred */ - validateDelete?: ValidateDeleteHook; + validateDelete?: ValidateHook; + /** * Used to **cause side effects** before a create, update, or delete operation once all validateInput hooks have resolved */ - beforeOperation?: BeforeOperationHook; + beforeOperation?: BeforeOperationListHook; /** * Used to **cause side effects** after a create, update, or delete operation operation has occurred */ - afterOperation?: AfterOperationHook; + afterOperation?: AfterOperationListHook; }; export type ResolvedListHooks = { - /** - * Used to **modify the input** for create and update operations after default values and access control have been applied - */ resolveInput: { create: ResolveInputListHook; update: ResolveInputListHook; }; - /** - * Used to **validate the input** for create and update operations once all resolveInput hooks resolved - */ - validateInput?: ValidateInputHook; - /** - * Used to **validate** that a delete operation can happen after access control has occurred - */ - validateDelete?: ValidateDeleteHook; - /** - * Used to **cause side effects** before a create, update, or delete operation once all validateInput hooks have resolved - */ - beforeOperation?: BeforeOperationHook; - /** - * Used to **cause side effects** after a create, update, or delete operation operation has occurred - */ - afterOperation?: AfterOperationHook; + validateInput: ValidateHook; + validateDelete: ValidateHook; + beforeOperation: BeforeOperationListHook; + afterOperation: AfterOperationListHook; }; export type FieldHooks< @@ -103,39 +90,49 @@ export type FieldHooks< /** * Used to **modify the input** for create and update operations after default values and access control have been applied */ - resolveInput?: ResolveInputFieldHook; + resolveInput?: ResolveInputFieldHook; /** * Used to **validate the input** for create and update operations once all resolveInput hooks resolved */ - validateInput?: ValidateInputFieldHook; + validateInput?: ValidateFieldHook; /** * Used to **validate** that a delete operation can happen after access control has occurred */ - validateDelete?: ValidateDeleteFieldHook; + validateDelete?: ValidateFieldHook; /** * Used to **cause side effects** before a create, update, or delete operation once all validateInput hooks have resolved */ - beforeOperation?: BeforeOperationFieldHook; + beforeOperation?: BeforeOperationFieldHook< + ListTypeInfo, + 'create' | 'update' | 'delete', + FieldKey + >; /** * Used to **cause side effects** after a create, update, or delete operation operation has occurred */ afterOperation?: AfterOperationFieldHook; }; -// TODO: one day -export type ResolvedFieldHooks = FieldHooks< - ListTypeInfo, - ListTypeInfo['fields'] ->; +export type ResolvedFieldHooks< + ListTypeInfo extends BaseListTypeInfo, + FieldKey extends ListTypeInfo['fields'] = ListTypeInfo['fields'] +> = { + resolveInput: ResolveInputFieldHook; + validateInput: ValidateFieldHook; + validateDelete: ValidateFieldHook; + beforeOperation: BeforeOperationFieldHook; + afterOperation: AfterOperationFieldHook; +}; -type ArgsForCreateOrUpdateOperation = - | { +type ResolveInputFieldHook< + ListTypeInfo extends BaseListTypeInfo, + Operation extends 'create' | 'update', + FieldKey extends ListTypeInfo['fields'] +> = ( + args: { + create: { operation: 'create'; - // technically this will never actually exist for a create - // but making it optional rather than not here - // makes for a better experience - // because then people will see the right type even if they haven't refined the type of operation to 'create' - item?: ListTypeInfo['item']; + item: undefined; /** * The GraphQL input **before** default values are applied */ @@ -144,8 +141,8 @@ type ArgsForCreateOrUpdateOperation = * The GraphQL input **after** being resolved by the field type's input resolver */ resolvedData: ListTypeInfo['prisma']['create']; - } - | { + }; + update: { operation: 'update'; item: ListTypeInfo['item']; /** @@ -157,126 +154,235 @@ type ArgsForCreateOrUpdateOperation = */ resolvedData: ListTypeInfo['prisma']['update']; }; - -type ResolveInputFieldHook< - ListTypeInfo extends BaseListTypeInfo, - FieldKey extends ListTypeInfo['fields'] -> = ( - args: ArgsForCreateOrUpdateOperation & + }[Operation] & CommonArgs & { fieldKey: FieldKey } ) => MaybePromise< ListTypeInfo['prisma']['create' | 'update'][FieldKey] | undefined // undefined represents 'don't do anything' >; -type ValidateInputHook = ( - args: ArgsForCreateOrUpdateOperation & { - addValidationError: (error: string) => void; - } & CommonArgs -) => Promise | void; - -type ValidateInputFieldHook< +type ValidateHook< ListTypeInfo extends BaseListTypeInfo, - FieldKey extends ListTypeInfo['fields'] + Operation extends 'create' | 'update' | 'delete' > = ( - args: ArgsForCreateOrUpdateOperation & { - addValidationError: (error: string) => void; - } & CommonArgs & { fieldKey: FieldKey } -) => Promise | void; - -type ValidateDeleteHook = ( args: { - operation: 'delete'; - item: ListTypeInfo['item']; - addValidationError: (error: string) => void; - } & CommonArgs -) => Promise | void; + create: { + operation: 'create'; + item: undefined; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['create']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['create']; + addValidationError: (error: string) => void; + }; + update: { + operation: 'update'; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['create']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['create']; + addValidationError: (error: string) => void; + }; + delete: { + operation: 'delete'; + item: ListTypeInfo['item']; + inputData: undefined; // TODO: remove? + resolvedData: undefined; // TODO: remove? + addValidationError: (error: string) => void; + }; + }[Operation] & + CommonArgs +) => MaybePromise; -type ValidateDeleteFieldHook< +type ValidateFieldHook< ListTypeInfo extends BaseListTypeInfo, + Operation extends 'create' | 'update' | 'delete', FieldKey extends ListTypeInfo['fields'] > = ( args: { - operation: 'delete'; - item: ListTypeInfo['item']; - addValidationError: (error: string) => void; - } & CommonArgs & { fieldKey: FieldKey } -) => Promise | void; + create: { + operation: 'create'; + item: undefined; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['create']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['create']; + addValidationError: (error: string) => void; + }; + update: { + operation: 'update'; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['create']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['create']; + addValidationError: (error: string) => void; + }; + delete: { + operation: 'delete'; + item: ListTypeInfo['item']; + inputData: undefined; // TODO: remove? + resolvedData: undefined; // TODO: remove? + addValidationError: (error: string) => void; + }; + }[Operation] & + CommonArgs & { fieldKey: FieldKey } +) => MaybePromise; -type BeforeOperationHook = ( - args: ( - | ArgsForCreateOrUpdateOperation - | { - operation: 'delete'; - item: ListTypeInfo['item']; - inputData: undefined; - resolvedData: undefined; - } - ) & +type BeforeOperationListHook< + ListTypeInfo extends BaseListTypeInfo, + Operation extends 'create' | 'update' | 'delete' +> = ( + args: { + create: { + operation: 'create'; + item: undefined; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['create']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['create']; + }; + update: { + operation: 'update'; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['update']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['update']; + }; + delete: { + operation: 'delete'; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: undefined; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: undefined; + }; + }[Operation] & CommonArgs -) => Promise | void; +) => MaybePromise; type BeforeOperationFieldHook< ListTypeInfo extends BaseListTypeInfo, + Operation extends 'create' | 'update' | 'delete', FieldKey extends ListTypeInfo['fields'] > = ( - args: ( - | ArgsForCreateOrUpdateOperation - | { - operation: 'delete'; - item: ListTypeInfo['item']; - inputData: undefined; - resolvedData: undefined; - } - ) & + args: { + create: { + operation: 'create'; + item: undefined; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['create']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['create']; + }; + update: { + operation: 'update'; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['update']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['update']; + }; + delete: { + operation: 'delete'; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: undefined; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: undefined; + }; + }[Operation] & CommonArgs & { fieldKey: FieldKey } -) => Promise | void; +) => MaybePromise; -type AfterOperationHook = ( - args: ( - | { - operation: 'create'; - originalItem: undefined; - // technically this will never actually exist for a create - // but making it optional rather than not here - // makes for a better experience - // because then people will see the right type even if they haven't refined the type of operation to 'create' - item?: ListTypeInfo['item']; - /** - * The GraphQL input **before** default values are applied - */ - inputData: ListTypeInfo['inputs']['create']; - /** - * The GraphQL input **after** being resolved by the field type's input resolver - */ - resolvedData: ListTypeInfo['prisma']['create']; - } - | { - operation: 'update'; - item: ListTypeInfo['item']; - originalItem: ListTypeInfo['item']; - /** - * The GraphQL input **before** default values are applied - */ - inputData: ListTypeInfo['inputs']['update']; - /** - * The GraphQL input **after** being resolved by the field type's input resolver - */ - resolvedData: ListTypeInfo['prisma']['update']; - } - | { - operation: 'delete'; - // technically this will never actually exist for a delete - // but making it optional rather than not here - // makes for a better experience - // because then people will see the right type even if they haven't refined the type of operation to 'delete' - item: undefined; - originalItem: ListTypeInfo['item']; - inputData: undefined; - resolvedData: undefined; - } - ) & +type AfterOperationListHook< + ListTypeInfo extends BaseListTypeInfo, + Operation extends 'create' | 'update' | 'delete' +> = ( + args: { + create: { + operation: 'create'; + originalItem: undefined; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['create']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['create']; + }; + update: { + operation: 'update'; + originalItem: ListTypeInfo['item']; + item: ListTypeInfo['item']; + /** + * The GraphQL input **before** default values are applied + */ + inputData: ListTypeInfo['inputs']['update']; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: ListTypeInfo['prisma']['update']; + }; + delete: { + operation: 'delete'; + originalItem: ListTypeInfo['item']; + item: undefined; + /** + * The GraphQL input **before** default values are applied + */ + inputData: undefined; + /** + * The GraphQL input **after** being resolved by the field type's input resolver + */ + resolvedData: undefined; + }; + }[Operation] & CommonArgs -) => Promise | void; +) => MaybePromise; type AfterOperationFieldHook< ListTypeInfo extends BaseListTypeInfo, @@ -326,4 +432,4 @@ type AfterOperationFieldHook< } ) & CommonArgs & { fieldKey: FieldKey } -) => Promise | void; +) => MaybePromise;