Skip to content

Commit

Permalink
Consolidate before/after hooks (#6684)
Browse files Browse the repository at this point in the history
  • Loading branch information
timleslie authored Sep 30, 2021
1 parent 21c5d1a commit 14bfa8a
Show file tree
Hide file tree
Showing 11 changed files with 326 additions and 393 deletions.
10 changes: 10 additions & 0 deletions .changeset/healthy-otters-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@keystone-next/keystone': major
---

* Consolidated the `beforeChange`/`beforeDelete` and `afterChange`/`afterDelete` hooks into `beforeOperation` and `afterOperation`.
* Renamed the `existingItem` argument for all hooks (except `afterOperation`) to `item`.
* Renamed the `existingItem` argument for `afterOperation` to `originalItem`.
* Renamed the `updatedItem` argument for `afterOperation` to `item`.

See the [Hooks API docs](https://keystonejs.com/docs/apis/hooks) for a complete reference for the updated API.
9 changes: 7 additions & 2 deletions docs/components/docs/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ export function DocsNavigation() {
<NavItem href="/docs/guides/filters">
Query Filters <Badge look="success">Updated</Badge>
</NavItem>
<NavItem href="/docs/guides/hooks">Hooks</NavItem>
<NavItem href="/docs/guides/hooks">
Hooks <Badge look="success">Updated</Badge>
</NavItem>
<NavItem href="/docs/guides/document-fields">Document Fields</NavItem>
<NavItem href="/docs/guides/document-field-demo">Document Field Demo</NavItem>
<NavItem href="/docs/guides/virtual-fields">Virtual Fields</NavItem>
Expand Down Expand Up @@ -186,7 +188,10 @@ export function DocsNavigation() {
<NavItem href="/docs/apis/access-control">
Access Control API <Badge look="success">Updated</Badge>
</NavItem>
<NavItem href="/docs/apis/hooks"> Hooks API</NavItem>
<NavItem href="/docs/apis/hooks">
{' '}
Hooks API <Badge look="success">Updated</Badge>
</NavItem>
<NavItem href="/docs/apis/session">Session API</NavItem>
<NavItem href="/docs/apis/auth">Authentication API</NavItem>

Expand Down
241 changes: 74 additions & 167 deletions docs/pages/docs/apis/hooks.mdx

Large diffs are not rendered by default.

49 changes: 19 additions & 30 deletions docs/pages/docs/guides/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ export default config({
email: text(),
},
hooks: {
afterChange: ({ operation, updatedItem }) => {
afterOperation: ({ operation, item }) => {
if (operation === 'create') {
console.log(`New user created. Name: ${updatedItem.name}, Email: ${updatedItem.email}`);
console.log(`New user created. Name: ${item.name}, Email: ${item.email}`);
}
}
},
Expand All @@ -38,10 +38,10 @@ export default config({
});
```

This function will be triggered whenever we execute one of `createUser`, `createUsers`, `updateUser`, or `updateUsers` in our GraphQL API.
It will be executed once for each item either created or updated.
This function will be triggered whenever we execute a `create`, `update`, or `delete` mutation in our GraphQL API.
It will be executed once for each item being operated on.
Because we only want to log when a user is created, we check the value of the `operation` argument.
We then use the `updatedItem` argument to get the value of the newly created user.
We then use the `item` argument to get the value of the newly created user.

Now that we've got a sense of what a hook is, let's look at how we can use hooks to solve some common problems you'll hit when creating your system.

Expand Down Expand Up @@ -83,16 +83,15 @@ export default config({
!> We must always return the modified `resolvedData` value from our hook, even if we didn't end up changing it.

The `resolveInput` hook is called whenever we update or create an item.
The value of `resolvedData` will contain the input provided to the mutation itself, along with any `defaultValues` applied on fields.
If you just want to see what the original input before default values was, you can use the `inputData` argument.
The value of `resolvedData` will contain the input provided to the mutation itself, with any field type input resolvers applied.
For example, a `password` will field will convert the provided text input into an encrypted hash value.
If you just want to see what the original input was, before the field type resolvers were applied, you can use the `inputData` argument.

If you're performing an update operation, you might also want to access the current value of the item stored in the database.
This is available as the `existingItem` argument.
This is available as the `item` argument.

Finally, all hooks are provided with a `context` argument, which gives you access to the full [context API](../apis/context).

?> The `resolveInput` hook shouldn't be used to set default values. This is handled by the `defaultValue` field config option.

## Validating inputs

Before writing the resolved data to the database you will often want to check that it conforms to certain rules, depending on your application's needs.
Expand Down Expand Up @@ -133,15 +132,15 @@ There might be multiple problems with the input, so you can call `addValidationE

Keystone will abort the operation and convert these error messages into GraphQL errors which will be returned to the caller.

The `validateInput` hook also receives the `operation`, `inputData`, `existingItem` and `context` arguments if you want to perform more advanced checks.
The `validateInput` hook also receives the `operation`, `inputData`, `item` and `context` arguments if you want to perform more advanced checks.

?> Don't confuse data **validation** with **access control**. If you want to check whether a user is **allowed** to do something, you should set up [access control rules](./access-control).

## Triggering side-effects

When data is changed in our system we might want to trigger some external side-effect.
For example, we might want to send a welcome email to a user when they first create their account.
We can use the `beforeChange` and `afterChange` hooks to do this.
We can use the `beforeOperation` and `afterOperation` hooks to do this.
Let's send an email after a user is created.

```typescript
Expand All @@ -158,9 +157,9 @@ export default config({
email: text(),
},
hooks: {
afterChange: ({ operation, updatedItem }) => {
afterOperation: ({ operation, item }) => {
if (operation === 'create') {
sendWelcomeEmail(updatedItem.name, updatedItem.email);
sendWelcomeEmail(item.name, item.email);
}
}
},
Expand All @@ -169,23 +168,13 @@ export default config({
});
```

The `beforeChange` and `afterChange` hooks are very similar, but serve slightly different purposes.
The `beforeChange` hook receives a `resolvedData` argument, which contains the data we're about to write to the database, whereas `afterChange` recieves `updatedItem`, which contains the data that was written to the database.

If the `beforeChange` hook throws an exception then the operation will return an error, and the data will not be saved to the database.
If the `afterChange` hook throws an exception then the data will remain in the database. As such, `afterChange` hooks should be used where a failure to execute isn't a critical problem.

## Delete hooks

The hooks discussed above all relate to the `create` and `update` operations.
There are also hooks which can be defined for the `delete` operation.

The `delete` operation hooks are `validateDelete`, `beforeDelete`, and `afterDelete`.

The `validateDelete` is used to verify that deleting an item won't cause a problem in your system.
For example, deleting a user might leave a collection of blog posts without authors, which might be something you want to avoid.
The `beforeOperation` and `afterOperation` hooks are very similar, but serve slightly different purposes.
The `beforeOperation` hook receives an `item` argument, which contains the currently stored in the database before the operation.
In the `afterOperation` hook, `item` represents the newly updated data in the database, and the original data from before the update is provided as `originalItem`.
In a `create` operation, there won't be a pre-existing item, and for `delete` operations, the value of `item` in the `afterOperation` hook will be `null`.

Similary, the `beforeDelete` and `afterDelete` hooks can be used to trigger side-effects related to the delete operation.
If the `beforeOperation` hook throws an exception then the operation will return an error, and the data will not be saved to the database.
If the `afterOperation` hook throws an exception then the data will remain in the database. As such, `afterOperation` hooks should be used where a failure to execute isn't a critical problem.

## List hooks vs field hooks

Expand Down
56 changes: 30 additions & 26 deletions packages/keystone/src/lib/core/mutations/create-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async function createSingle(
// Item access control. Will throw an accessDeniedError if not allowed.
await applyAccessControlForCreate(list, context, rawData);

const { afterChange, data } = await resolveInputForCreateOrUpdate(
const { afterOperation, data } = await resolveInputForCreateOrUpdate(
list,
context,
rawData,
Expand All @@ -52,11 +52,11 @@ async function createSingle(
runWithPrisma(context, list, model => model.create({ data }))
);

return { item, afterChange };
return { item, afterOperation };
}

export class NestedMutationState {
#afterChanges: (() => void | Promise<void>)[] = [];
#afterOperations: (() => void | Promise<void>)[] = [];
#context: KeystoneContext;
constructor(context: KeystoneContext) {
this.#context = context;
Expand All @@ -68,20 +68,20 @@ export class NestedMutationState {
// Check operation permission to pass into single operation
const operationAccess = await getOperationAccess(list, context, 'create');

const { item, afterChange } = await createSingle(
const { item, afterOperation } = await createSingle(
{ data },
list,
context,
operationAccess,
writeLimit
);

this.#afterChanges.push(() => afterChange(item));
this.#afterOperations.push(() => afterOperation(item));
return { id: item.id as IdType };
}

async afterChange() {
await promiseAllRejectWithAllErrors(this.#afterChanges.map(async x => x()));
async afterOperation() {
await promiseAllRejectWithAllErrors(this.#afterOperations.map(async x => x()));
}
}

Expand All @@ -95,15 +95,15 @@ export async function createOne(
// Check operation permission to pass into single operation
const operationAccess = await getOperationAccess(list, context, 'create');

const { item, afterChange } = await createSingle(
const { item, afterOperation } = await createSingle(
createInput,
list,
context,
operationAccess,
writeLimit
);

await afterChange(item);
await afterOperation(item);

return item;
}
Expand All @@ -120,15 +120,15 @@ export async function createMany(
const operationAccess = await getOperationAccess(list, context, 'create');

return createInputs.data.map(async data => {
const { item, afterChange } = await createSingle(
const { item, afterOperation } = await createSingle(
{ data },
list,
context,
operationAccess,
writeLimit
);

await afterChange(item);
await afterOperation(item);

return item;
});
Expand Down Expand Up @@ -158,26 +158,26 @@ async function updateSingle(
await checkFilterOrderAccess([{ fieldKey, list }], context, 'filter');

// Filter and Item access control. Will throw an accessDeniedError if not allowed.
const existingItem = await getAccessControlledItemForUpdate(
const item = await getAccessControlledItemForUpdate(
list,
context,
uniqueWhere,
accessFilters,
rawData
);

const { afterChange, data } = await resolveInputForCreateOrUpdate(
const { afterOperation, data } = await resolveInputForCreateOrUpdate(
list,
context,
rawData,
existingItem
item
);

const updatedItem = await writeLimit(() =>
runWithPrisma(context, list, model => model.update({ where: { id: existingItem.id }, data }))
runWithPrisma(context, list, model => model.update({ where: { id: item.id }, data }))
);

await afterChange(updatedItem);
await afterOperation(updatedItem);

return updatedItem;
}
Expand Down Expand Up @@ -224,7 +224,7 @@ async function getResolvedData(
listKey: string;
operation: 'create' | 'update';
inputData: Record<string, any>;
existingItem: Record<string, any> | undefined;
item: Record<string, any> | undefined;
},
nestedMutationState: NestedMutationState
) {
Expand Down Expand Up @@ -361,17 +361,17 @@ async function resolveInputForCreateOrUpdate(
list: InitialisedList,
context: KeystoneContext,
inputData: Record<string, any>,
existingItem: Record<string, any> | undefined
item: Record<string, any> | undefined
) {
const operation: 'create' | 'update' = existingItem === undefined ? 'create' : 'update';
const operation: 'create' | 'update' = item === undefined ? 'create' : 'update';
const nestedMutationState = new NestedMutationState(context);
const { listKey } = list;
const hookArgs = {
context,
listKey,
operation,
inputData,
existingItem,
item,
resolvedData: {},
};

Expand All @@ -382,16 +382,20 @@ async function resolveInputForCreateOrUpdate(
// Apply all validation checks
await validateUpdateCreate({ list, hookArgs });

// Run beforeChange hooks
await runSideEffectOnlyHook(list, 'beforeChange', hookArgs);
// Run beforeOperation hooks
await runSideEffectOnlyHook(list, 'beforeOperation', hookArgs);

// Return the full resolved input (ready for prisma level operation),
// and the afterChange hook to be applied
// and the afterOperation hook to be applied
return {
data: flattenMultiDbFields(list.fields, hookArgs.resolvedData),
afterChange: async (updatedItem: ItemRootValue) => {
await nestedMutationState.afterChange();
await runSideEffectOnlyHook(list, 'afterChange', { ...hookArgs, updatedItem, existingItem });
afterOperation: async (updatedItem: ItemRootValue) => {
await nestedMutationState.afterOperation();
await runSideEffectOnlyHook(list, 'afterOperation', {
...hookArgs,
item: updatedItem,
originalItem: item,
});
},
};
}
Expand Down
32 changes: 19 additions & 13 deletions packages/keystone/src/lib/core/mutations/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,34 @@ async function deleteSingle(
await checkFilterOrderAccess([{ fieldKey, list }], context, 'filter');

// Filter and Item access control. Will throw an accessDeniedError if not allowed.
const existingItem = await getAccessControlledItemForDelete(
list,
context,
uniqueWhere,
accessFilters
);
const item = await getAccessControlledItemForDelete(list, context, uniqueWhere, accessFilters);

const hookArgs = { operation: 'delete' as const, listKey: list.listKey, context, existingItem };
const hookArgs = {
operation: 'delete' as const,
listKey: list.listKey,
context,
item,
resolvedData: undefined,
inputData: undefined,
};

// Apply all validation checks
await validateDelete({ list, hookArgs });

// Before delete
await runSideEffectOnlyHook(list, 'beforeDelete', hookArgs);
// Before operation
await runSideEffectOnlyHook(list, 'beforeOperation', hookArgs);

const item = await writeLimit(() =>
runWithPrisma(context, list, model => model.delete({ where: { id: existingItem.id } }))
const newItem = await writeLimit(() =>
runWithPrisma(context, list, model => model.delete({ where: { id: item.id } }))
);

await runSideEffectOnlyHook(list, 'afterDelete', hookArgs);
await runSideEffectOnlyHook(list, 'afterOperation', {
...hookArgs,
item: undefined,
originalItem: item,
});

return item;
return newItem;
}

export async function deleteMany(
Expand Down
13 changes: 7 additions & 6 deletions packages/keystone/src/lib/core/mutations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ export async function runSideEffectOnlyHook<
},
Args extends Parameters<NonNullable<List['hooks'][HookName]>>[0]
>(list: List, hookName: HookName, args: Args) {
// Runs the before/after change/delete hooks
// Runs the before/after operation hooks

// Only run field hooks on change operations if the field
// was specified in the original input.
let shouldRunFieldLevelHook: (fieldKey: string) => boolean;
if (hookName === 'beforeChange' || hookName === 'afterChange') {
if (args.operation === 'delete') {
// 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.
const inputDataKeys = new Set(Object.keys(args.inputData));
shouldRunFieldLevelHook = fieldKey => inputDataKeys.has(fieldKey);
} else {
shouldRunFieldLevelHook = () => true;
}

// Field hooks
Expand Down
Loading

0 comments on commit 14bfa8a

Please sign in to comment.