From 9cfd5a4a55129f04f12b10b60d9336e89e0c4cfc Mon Sep 17 00:00:00 2001 From: singhArmani Date: Thu, 30 Jul 2020 13:09:41 +1000 Subject: [PATCH] Enhance initial-data tutorials with `createItems` - add seeding tutorials - remove relationship-utils module - remove leftover `createItem` usage in the docs --- .changeset/hot-olives-reply.md | 4 +- docs/tutorials/initial-data.md | 216 ++++++++++++++++ packages/keystone/README.md | 1 - .../lib/Keystone/relationship-utils.js | 243 ------------------ 4 files changed, 218 insertions(+), 246 deletions(-) delete mode 100644 packages/keystone/lib/Keystone/relationship-utils.js diff --git a/.changeset/hot-olives-reply.md b/.changeset/hot-olives-reply.md index e3faf7f81c0..e0ae25f4ddb 100644 --- a/.changeset/hot-olives-reply.md +++ b/.changeset/hot-olives-reply.md @@ -2,7 +2,7 @@ '@keystonejs/keystone': major --- -Removed the `keystone.createItems` method. This has been replaced with the `createItems` function in `@keystonejs/server-side-graphql-client`. +Removed the `keystone.createItems` and `keystone.createItem` method. This has been replaced with the corresponding `createItems` and `createItem` function in `@keystonejs/server-side-graphql-client`. If you have examples like: @@ -21,4 +21,4 @@ createItems({ listName: 'User', items: [{ data: { name: 'Ticiana' } }, {data: { name: 'Lauren' } }] }) -``` \ No newline at end of file +``` diff --git a/docs/tutorials/initial-data.md b/docs/tutorials/initial-data.md index 6436bceef13..2163ff02c7e 100644 --- a/docs/tutorials/initial-data.md +++ b/docs/tutorials/initial-data.md @@ -27,6 +27,7 @@ yarn add @keystonejs/app-graphql yarn add @keystonejs/fields yarn add @keystonejs/app-admin-ui yarn add @keystonejs/auth-password +yarn add @keystonejs/server-side-graphql-client ``` ### Preparation @@ -74,3 +75,218 @@ module.exports = { ``` > **Tip:** A similar setup can be achieved by running the Keystone CLI `yarn create keystone-app` and selecting `Starter (Users + Authentication)`. This starter project has a `User` list, `PasswordAuthStrategy` and seeding of the database already configured. For now, we will proceed manually. + +## Creating items + +The [`createItems`](https://www.keystonejs.com/keystonejs/server-side-graphql-client/#createitems) utility function requires a config object argument. It has the following `required` keys: + +- `keystone`: a Keystone instance +- `listKey`: the Keystone list name +- `items`: the array of objects to be created. + + +```javascript +createItems({ + keystone, + listKey: 'User', + items: [ + { data: { name: 'John Duck', email: 'john@duck.com', password: 'dolphins' } }, + { data: { name: 'Barry', email: 'bartduisters@bartduisters.com', password: 'dolphins' } }, + ], +}); +``` + +**Note**: The format of the objects in the `items` array must align with the schema setup of the corresponding list name. +As an example in our schema, the `email` field has `isUnique:true` constraint, therefore it would not be possible for the above code to generate users with exactly same email. + +Example on how to `seed` the data upon database connection: + +```javascript +const keystone = new Keystone({ + adapter: new MongooseAdapter(), + onConnect: async keystone => { + await createItems({ + keystone, + listKey: 'User', + items: [ + { data: { name: 'John Duck', email: 'john@duck.com', password: 'dolphins' } }, + { data: { name: 'Barry', email: 'bartduisters@bartduisters.com', password: 'dolphins' } }, + ], + }); + }, +}); +``` + +Start the application and visit the Admin UI, two users are available on startup. + +> **Note:** In this example the same two users would be generated _every_ startup. Since email should be unique, this will cause a duplicate error to show up. To avoid this, clear the database before starting Keystone. + +## Relationships + +The `items` in the `createItems` config object has the data type of GraphQL `[listKey]sCreateInput`. In our example, it's the `UsersCreateInput` which keystone created for us as part of the schema. + +Consequently, while seeding it's possible to create relationships between items using keystone `connect` [nested mutations](https://www.keystonejs.com/keystonejs/fields/src/types/relationship/#nested-mutations). + +### Single relationships + +Add the `Relationship` field to the imports: + +```javascript +const { Text, Checkbox, Password, Relationship } = require('@keystonejs/fields'); +``` + +Create a list with a relationship to another list: + +```javascript +keystone.createList('Post', { + fields: { + title: { + type: Text, + }, + author: { + type: Relationship, + ref: 'User', + }, + }, +}); +``` + +As part of `connect` nested mutation, we need to provide the id of the item for which the single relationship is required. This implies that we need to extract the id of the previously created items. + +Example on how to seed an item with a relationship using `connect` nested mutation: + +```javascript + await createItems({ + keystone, + listKey: 'Post', + items: [ + {data: { + title: 'Hello World', + author: { + // Extracting the id from `users` array + connect: { id: users.find(user => user.name === 'John Duck').id }, + }, + } + }, + ], + }, + }) +``` + +The full example: + +```javascript +const keystone = new Keystone({ + adapter: new MongooseAdapter(), + onConnect: async keystone => { + + // 1. Insert the user list first as it has no associated relationship. + const users = await createItems({ + keystone, + listKey: 'User', + items: [ + {data: { name: 'John Duck', email: 'john@duck.com', password: 'dolphins' } }, + {data: { name: 'Barry', email: 'bartduisters@bartduisters.com', password: 'dolphins' } }, + ], + }); + + // 2. Insert `Post` data, with the required relationships, via `connect` nested mutation. + await createItems({ + keystone, + listKey: 'Post', + items: [ + {data: { + title: 'Hello World', + author: { + // Extracting the id of the User list item + connect: { id: users.find(user => user.name === 'John Duck').id }, + }, + } + }, + ], + }, + }); +``` + +Clear the database, then start Keystone and visit the Admin UI to see that two users are generated and one post is generated. The post has an `author` named `John Duck`. In the database `author` will be the ID of the user with name John Duck + +### Many relationships + +A user can have many posts, add the `to-many` relationship field `posts` to the `User`: + +```javascript +keystone.createList('User', { + fields: { + name: { type: Text }, + email: { + type: Text, + isUnique: true, + }, + isAdmin: { type: Checkbox }, + password: { + type: Password, + }, + posts: { + type: Relationship, + ref: 'Post', + many: true, + }, + }, +}); +``` + +Following the same pattern as discussed above, we can easily establish a `to-many` relationship via `connect` [nested mutations](https://www.keystonejs.com/keystonejs/fields/src/types/relationship/#nested-mutations) approach. Instead of passing a single item id, we are required to pass an array of item ids. + +**Note**: We need to create posts first as it has no relationship, and we require the post ids to create `to-many` relationship with user items. + +To associate all the posts where `title` contains the word `React`: + +In action: + +```javascript +const keystone = new Keystone({ + adapter: new MongooseAdapter(), + onConnect: async keystone => { + // 1. Create posts first as we need generated ids to establish relationship with user items. + const posts = await createItems({ + keystone, + listKey: 'Post', + items: [ + { data: { title: 'Hello Everyone' } }, + { data: { title: 'Talking about React' } }, + { data: { title: 'React is the Best' } }, + { data: { title: 'Keystone Rocks' } }, + ], + }); + + // 2. Insert User data with required relationship via nested mutations. `connect` requires an array of post item ids. + await createItems({ + keystone, + listKey: 'User', + items: [ + { + data: { + name: 'John Duck', + email: 'john@duck.com', + password: 'dolphins', + posts: { + // Filtering list of items where title contains the word `React` + connect: post.filter(p => /\bReact\b/i.test(p.title)).map(i => ({ id: i.id })), + }, + }, + { + data: { + name: 'Barry', + email: 'bartduisters@bartduisters.com', + password: 'dolphins', + isAdmin: true, + }, + }, + ], + }); + }, +}); +``` +Clear the database, start the Keystone application and visit the Admin UI. Take a look at the user `John Duck`, he has two posts associated with him (there were two posts with the word `React` in the `title`). + +If you want to explore other utility functions for `CRUD` operations, please refer to [server-side GraphQL client](https://www.keystonejs.com/keystonejs/server-side-graphql-client) API for more details. diff --git a/packages/keystone/README.md b/packages/keystone/README.md index eb53a9a8091..dd782decd7f 100644 --- a/packages/keystone/README.md +++ b/packages/keystone/README.md @@ -162,7 +162,6 @@ Please note: We use these internally but provide no support or assurance if used | `dumpSchema` | Dump schema to a string. | | `getTypeDefs` | Remove from user documentation? | | `getResolvers` | Remove from user documentation? | -| `createItem` | Remove from user documentation? | | `getAdminMeta` | Remove from user documentation? | --> diff --git a/packages/keystone/lib/Keystone/relationship-utils.js b/packages/keystone/lib/Keystone/relationship-utils.js deleted file mode 100644 index 7e856bf76db..00000000000 --- a/packages/keystone/lib/Keystone/relationship-utils.js +++ /dev/null @@ -1,243 +0,0 @@ -const { resolveAllKeys, mapKeys } = require('@keystonejs/utils'); - -function isRelationshipField({ list, fieldKey }) { - return !!list._fields[fieldKey].type.isRelationship; -} - -function isManyRelationship({ list, fieldKey }) { - const field = list._fields[fieldKey]; - return !!field.type.isRelationship && field.many; -} - -function splitObject(input, filterFn) { - const left = {}; - const right = {}; - Object.keys(input).forEach(key => { - if (filterFn(input[key], key, input)) { - left[key] = input[key]; - } else { - right[key] = input[key]; - } - }); - return { left, right }; -} - -/* - * Splits out the input data into relationships and non-relationships data - * - * @param input {Object} An object of arrays of data to insert - * { - * Posts: [ - * { - * id: "abc123", - * title: "Foobar", - * author: { where: { ... } } - * }, - * { - * id: "def789", - * title: "Hello", - * author: { where: { ... } } - * }, - * ] - * } - * - * @returns {Object} - * { - * data: { - * Posts: [ - * { id: "abc123", title: "Foobar" }, - * { id: "def789", title: "Hello" }, - * ] - * }, - * relationships: { - * Posts: { - * 0: { author: { where: { ... } } }, - * 7: { author: { where: { ... } } }, - * } - * } - * } - */ -const unmergeRelationships = (lists, input) => { - const relationships = {}; - - // I think this is easier to read (ง'-')ง - // prettier-ignore - const data = mapKeys(input, (listData, listKey) => listData.map((item, itemIndex) => { - const { left: relationData, right: scalarData } = splitObject(item, (fieldConditions, fieldKey) => { - const list = lists[listKey]; - if (isRelationshipField({ list, fieldKey })) { - // Array syntax can only be used with many-relationship fields - if (Array.isArray(fieldConditions) && !isManyRelationship({ list, fieldKey })) { - throw new Error(`Attempted to relate many items to ${list.key}[${itemIndex}].${fieldKey}, but ${list.key}.${fieldKey} is configured as a single Relationship.`); - } - return true; - } - - return false; - }); - - if (Object.keys(relationData).length) { - // Create a sparse array using an object - relationships[listKey] = relationships[listKey] || {}; - relationships[listKey][itemIndex] = relationData; - } - - return scalarData; - })); - - return { - relationships, - data, - }; -}; - -const relateTo = async ({ relatedTo, relatedFrom }) => { - if (isManyRelationship({ list: relatedFrom.list, fieldKey: relatedFrom.field })) { - return relateToManyItems({ relatedTo, relatedFrom }); - } else { - return relateToOneItem({ relatedTo, relatedFrom }); - } -}; - -const throwRelateError = ({ relatedTo, relatedFrom, isMany }) => { - // I know it's long, but you're making this weird. - // prettier-ignore - throw new Error(`Attempted to relate ${relatedFrom.list.key}<${relatedFrom.item.id}>.${relatedFrom.field} to${isMany ? '' : ' a'} ${relatedTo.list.key}, but no ${relatedTo.list.key} matched the conditions ${JSON.stringify({ conditions: relatedTo.conditions })}`); -}; - -const relateToOneItem = async ({ relatedTo, relatedFrom }) => { - // Use where clause provided in original data to find related item - const relatedToItems = await relatedTo.list.adapter.itemsQuery(relatedTo.conditions); - - // Sanity checking - if (!relatedToItems || !relatedToItems.length) { - throwRelateError({ relatedTo, relatedFrom, isMany: false }); - } - - const updateResult = await relatedFrom.list.adapter.update(relatedFrom.item.id, { - [relatedFrom.field]: relatedToItems[0].id, - }); - - return updateResult[relatedFrom.field]; -}; - -const relateToManyItems = async ({ relatedTo, relatedFrom }) => { - let relatedToItems; - - if (Array.isArray(relatedTo.conditions)) { - // Use where clause provided in original data to find related item - relatedToItems = await Promise.all( - relatedTo.conditions.map(condition => relatedTo.list.adapter.itemsQuery(condition)) - ); - - // Grab the first result of each - relatedToItems = relatedToItems.map(items => items[0]); - - // One of them didn't match - if (relatedToItems.some(item => !item)) { - throwRelateError({ relatedTo, relatedFrom, isMany: true }); - } - } else { - // Use where clause provided in original data to find related item - relatedToItems = await relatedTo.list.adapter.itemsQuery(relatedTo.conditions); - } - - // Sanity checking - if (!relatedToItems) { - throwRelateError({ relatedTo, relatedFrom, isMany: true }); - } - - const updateResult = await relatedFrom.list.adapter.update(relatedFrom.item.id, { - [relatedFrom.field]: relatedToItems.map(({ id }) => id), - }); - - return updateResult[relatedFrom.field]; -}; - -/** - * @param lists {Object} The lists object from keyston - * @param relationships {Object} Is an object of sparse arrays containing - * fields that have where clauses. ie; - * { - * Posts: { - * 0: { author: { where: { ... } } }, - * 7: { author: { where: { ... } } }, - * } - * } - * - * @param createdItems {Object} is an object of arrays containing items - * created with no relationships. ie; - * { - * Users: [ - * { id: "456zyx", name: "Jess" }, - * { id: "789wer", name: "Jed" }, - * ], - * Posts: [ - * { id: "abc123", title: "Foobar" }, - * { id: "def789", title: "Hello" }, - * ] - * } - * - * @returns {Object} an object of sparse arrays containing fields that have - * ids of related items. ie; - * { - * Posts: { - * 0: { author: "456zyx" }, - * 7: { author: "789wer" }, - * } - * } - */ -const createRelationships = (lists, relationships, createdItems) => { - return resolveAllKeys( - mapKeys(relationships, (relationList, listKey) => { - const listFieldsConfig = lists[listKey]._fields; - - return resolveAllKeys( - // NOTE: Sparse array / indexes match the indexes from the `createdItems` - mapKeys(relationList, (relationItem, relationItemIndex) => { - const createdItem = createdItems[listKey][relationItemIndex]; - - // Results in something like: - // Promise<{ author: , ... }> - return resolveAllKeys( - mapKeys(relationItem, (relationConditions, relationshipField) => { - const relatedListKey = listFieldsConfig[relationshipField].ref.split('.')[0]; - - return relateTo({ - relatedFrom: { - list: lists[listKey], - item: createdItem, - field: relationshipField, - }, - relatedTo: { - list: lists[relatedListKey], - conditions: relationConditions, - }, - }); - }) - ); - }) - ); - }) - ); -}; - -function mergeRelationships(created, relationships) { - return mapKeys(created, (newList, listKey) => { - const relationshipItems = relationships[listKey]; - - if (relationshipItems) { - newList = newList.map((item, itemIndex) => ({ - ...item, - ...relationshipItems[itemIndex], - })); - } - return newList; - }); -} - -module.exports = { - unmergeRelationships, - createRelationships, - mergeRelationships, -};