From 494cdefb1d08a6f3518fbc4946cdf0ffcb1aa6dd Mon Sep 17 00:00:00 2001 From: Josh Calder Date: Thu, 29 Sep 2022 14:45:59 +1000 Subject: [PATCH] Removes `graphQLSchemaExtension` export in favour of using either `graphql.extend` or `@graphql-tool/schema` (#7943) --- .changeset/witty-bees-pull.md | 5 + .github/workflows/core_tests.yml | 2 +- docs/components/docs/Navigation.tsx | 7 +- docs/pages/docs/config/config.md | 11 +- docs/pages/docs/guides/schema-extension.md | 131 ++++++++++++++++- examples/basic/schema.graphql | 10 +- examples/basic/schema.ts | 66 ++++----- examples/ecommerce/mutations/addToCart.ts | 2 +- examples/ecommerce/mutations/index.ts | 30 ++-- .../CHANGELOG.md | 0 .../README.md | 0 .../keystone.ts | 0 .../package.json | 19 +++ .../sandbox.config.json | 0 .../schema.graphql | 0 .../schema.prisma | 0 .../schema.ts | 134 ++++++++++++++++++ .../extend-graphql-schema-nexus/package.json | 2 +- examples/extend-graphql-schema/package.json | 18 --- examples/extend-graphql-schema/schema.ts | 130 ----------------- .../extend-graphql-subscriptions/package.json | 1 + .../extend-graphql-subscriptions/schema.ts | 80 ++++++----- packages/core/package.json | 1 - packages/core/src/index.ts | 2 +- packages/core/src/schema/schema.ts | 17 --- tests/api-tests/admin-meta.test.ts | 3 +- .../extend-graphql-schema.test.ts | 66 ++++----- tests/api-tests/queries/cache-hints.test.ts | 51 +++---- ...tend-graphql-schema-graphql-tools.test.ts} | 2 +- .../schemas/changed-prisma-schema.ts | 20 ++- .../live-reloading/schemas/initial.ts | 20 ++- .../live-reloading/schemas/second.ts | 20 ++- yarn.lock | 29 +--- 33 files changed, 480 insertions(+), 399 deletions(-) create mode 100644 .changeset/witty-bees-pull.md rename examples/{extend-graphql-schema => extend-graphql-schema-graphql-tools}/CHANGELOG.md (100%) rename examples/{extend-graphql-schema => extend-graphql-schema-graphql-tools}/README.md (100%) rename examples/{extend-graphql-schema => extend-graphql-schema-graphql-tools}/keystone.ts (100%) create mode 100644 examples/extend-graphql-schema-graphql-tools/package.json rename examples/{extend-graphql-schema => extend-graphql-schema-graphql-tools}/sandbox.config.json (100%) rename examples/{extend-graphql-schema => extend-graphql-schema-graphql-tools}/schema.graphql (100%) rename examples/{extend-graphql-schema => extend-graphql-schema-graphql-tools}/schema.prisma (100%) create mode 100644 examples/extend-graphql-schema-graphql-tools/schema.ts delete mode 100644 examples/extend-graphql-schema/package.json delete mode 100644 examples/extend-graphql-schema/schema.ts rename tests/examples-smoke-tests/{extend-graphql-schema.test.ts => extend-graphql-schema-graphql-tools.test.ts} (88%) diff --git a/.changeset/witty-bees-pull.md b/.changeset/witty-bees-pull.md new file mode 100644 index 00000000000..b9cf8d7ffe3 --- /dev/null +++ b/.changeset/witty-bees-pull.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/core': major +--- + +Removes the `@graphql-tools/schema` wrapping functions `graphQLSchemaExtension` and `gql`. Developers should import `@graphql-tools/schema` themselves, or use `graphql` (as exported by `@keystone-6/core`). diff --git a/.github/workflows/core_tests.yml b/.github/workflows/core_tests.yml index 28fd7b478cf..85c026c5419 100644 --- a/.github/workflows/core_tests.yml +++ b/.github/workflows/core_tests.yml @@ -276,7 +276,7 @@ jobs: 'blog.test.ts', 'document-field.test.ts', 'default-values.test.ts', - 'extend-graphql-schema.test.ts', + 'extend-graphql-schema-graphql-tools.test.ts', 'extend-graphql-schema-graphql-ts.test.ts', 'extend-graphql-schema-nexus.test.ts', 'json.test.ts', diff --git a/docs/components/docs/Navigation.tsx b/docs/components/docs/Navigation.tsx index af72eb3e3ec..f75fcfaa482 100644 --- a/docs/components/docs/Navigation.tsx +++ b/docs/components/docs/Navigation.tsx @@ -222,10 +222,9 @@ export function DocsNavigation() { Images & Files New - {/* Disable placeholder for now */} - {/* - Schema Extension - */} + + GraphQL Schema ExtensionNew + Testing Document Fields Document Field Demo diff --git a/docs/pages/docs/config/config.md b/docs/pages/docs/config/config.md index 51a99d2b847..7e59dffb352 100644 --- a/docs/pages/docs/config/config.md +++ b/docs/pages/docs/config/config.md @@ -419,18 +419,21 @@ import type { ExtendGraphqlSchema } from '@keystone-6/core/types'; The `extendGraphqlSchema` config option allows you to extend the GraphQL API which is generated by Keystone based on your schema definition. It has a TypeScript type of `ExtendGraphqlSchema`. -In general you will use the function `graphQLSchemaExtension({ typeDefs, resolvers })` to create your schema extension. +`extendGraphqlSchema` expects a function that takes the GraphQL Schema generated by Keystone and returns a valid GraphQL Schema ```typescript -import { config, graphQLSchemaExtension } from '@keystone-6/core'; +import { config, graphql } from '@keystone-6/core'; export default config({ - extendGraphqlSchema: graphQLSchemaExtension({ typeDefs, resolvers }), + extendGraphqlSchema: keystoneSchema => { + /* ... */ + return newExtendedSchema + } /* ... */ }); ``` -See the [schema extension guide](../guides/schema-extension) for more details on how to use `graphQLSchemaExtension()` to extend your GraphQL API. +See the [schema extension guide](../guides/schema-extension) for more details and tooling options on how to extend your GraphQL API. ## storage (images and files) diff --git a/docs/pages/docs/guides/schema-extension.md b/docs/pages/docs/guides/schema-extension.md index 59c176acb08..77974436f27 100644 --- a/docs/pages/docs/guides/schema-extension.md +++ b/docs/pages/docs/guides/schema-extension.md @@ -1,6 +1,131 @@ --- -title: "Schema Extension" -description: "We've planned this page but not had a chance to write it yet." +title: "GraphQL Schema Extension" +description: "Learn how to extend your GraphQL Schema using extendGraphqlSchema." --- -{% coming-soon /%} +Keystone automatically generates a GraphQL schema based on your [Keystone config](../config/config). This schema contains all GraphQL types, queries and mutations based on your lists and ends up as the generated `schema.graphql` file found in the root of your Keystone project. +Generally changing the behavior of Keystone can be performed through [Hooks](../config/hooks), however, there are times when you need an extra GraphQL type or want a custom mutation or query, for these instances, Keystone has the `extendGraphqlSchema` option. + +The `extendGraphqlSchema` option expects a function that takes the GraphQL Schema generated by Keystone and returns a valid GraphQL schema. You can then use this function to add or replace resolvers and types. + +## Using Keystone's graphql.extend + +Keystone exports `graphql` from `@keystone/core`, this uses [@graphql-ts/schema](https://docsmill.dev/npm/@graphql-ts/schema) which can be used in combination with `Context` from `.keystone/types` to extend your GraphQL schema in a type-safe way. + +You can then import this into your Keystone configuration file + +```ts +import { graphql } from '@keystone/core'; +``` + +Then you can use `graphql.extend` to add custom resolvers to your GraphQL Schema. + +The following example adds a custom mutation called `publishPost` to the `base` Keystone Schema. It accepts one argument (`args`) of `id` which cannot be null and has the type of ID (`graphql.nonNull(graphql.ID)`). It updates the Post with the corresponding `id` and sets its `status` to 'published' and `publishDate` to the current time. +It then returns the Post that is updated which has a GraphQL type of `Post` that is passed in from the `base` schema (`base.object('Post')`). + +```ts +import { graphql, config } from '@keystone/core'; +import { Context } from '.keystone/types'; + +export default config({ + {/* ... */}, + extendGraphqlSchema: graphql.extend(base => { + return { + mutation: { + publishPost: graphql.field({ + type: base.object('Post'), + args: { id: graphql.arg({ type: graphql.nonNull(graphql.ID) }) }, + resolve(source, { id }, context:Context) { + return context.db.Post.updateOne({ + where: { id }, + data: { status: 'published', publishDate: new Date().toISOString() }, + }); + }, + }), + }, + }; + }), +}); +``` + +{% hint kind="tip" %} +Note `context.db` is used in the resolver, this ensures the correct Internal object with the correct type is returned to GraphQL. +{% /hint %} + +A full example project using `graphql-ts` can be found in [examples/extend-graphql-schema-graphql-ts](https://github.com/keystonejs/keystone/tree/main/examples/extend-graphql-schema-graphql-ts) on the Keystone GitHub repo. + +## Using Third-Party Tools + +As `extendGraphqlSchema` expects a function that returns a valid GraphQL schema you can also use third-party GraphQL schema tools to help generate or merge schemas. + +### GraphQL-Tools Merge Schemas + +[GraphQL Tools](https://www.graphql-tools.com/) `mergeSchemas` is a third-party package that can help with schema merging and adding custom resolvers and types, and then return an updated GraphQL schema to Keystone. + +Start by installing `@graphql-tools/schema` + +```bash +yarn add @graphql-tools/schema +``` + +Then import into your Keystone configuration + +```ts +import { mergeSchemas } from '@graphql-tools/schema'; +``` + +You can then write custom `resolvers` and `typeDefs` to merge with your Keystone `schema`. For example, to add the same custom mutation as above (`publishPost`), you would add the `typeDefs` for `publishPost` and then the `resolvers.Mutation.publishPost` +which performs the same function as above. + +```ts +export default config({ + {/* ... */}, + extendGraphqlSchema: schema => + mergeSchemas({ + schemas: [schema], + typeDefs: ` + type Mutation { + publishPost(id: ID!): Post + `, + resolvers: { + Mutation: { + publishPost: (root, { id }, context) => { + return context.db.Post.updateOne({ + where: { id }, + data: { status: 'published', publishDate: new Date().toUTCString() }, + }); + }, + }, + }, + }), +}); +``` + +{% if $nextRelease %} +{% hint kind="tip" %} +Note - Before version `3.0.0` of `@keystone-6/core`, `@graphql-tools/schema` was exported from `@keystone/core` as `graphQLSchemaExtension` this was removed in favor of using the tool directly if required +{% /hint %} +{% else /%} +{% hint kind="tip" %} +Note - `@graphql-tools/schema` is currently exported from `@keystone/core` as `graphQLSchemaExtension` this will be removed in the next major version of `@keystone-6/core` in favor of using the tool directly if required +{% /hint %} +{% /if %} + +A full example project using `@graphql-tools/schema` can be found in [examples/extend-graphql-schema-graphql-tools](https://github.com/keystonejs/keystone/tree/main/examples/extend-graphql-schema-graphql-tools) on the Keystone GitHub repo. + +## Related resources + +{% related-content %} +{% well heading="Config API Reference" href="/docs/config/config" %} +The complete reference for the base keystone configuration +{% /well %} +{% well heading="Example Project: Extend GraphQL Schema with graphql-ts" href="https://github.com/keystonejs/keystone/tree/main/examples/extend-graphql-schema-graphql-ts" %} +A full keystone project illustrating how to extend your GraphQL schema using graphql-ts provided by Keystone. +{% /well %} +{% well heading="Example Project: Extend GraphQL Schema with GraphQL-Tools" href="https://github.com/keystonejs/keystone/tree/main/examples/extend-graphql-schema-graphql-tools" %} +A full keystone project illustrating how to extend your GraphQL schema using @graphql-tools/schema. +{% /well %} +{% well heading="Example Project: Extend GraphQL Schema with Nexus" href="https://github.com/keystonejs/keystone/tree/main/examples/extend-graphql-schema-nexus" %} +A full keystone project illustrating how to extend your GraphQL schema using Nexus and @graphql-tools/schema. +{% /well %} +{% /related-content %} diff --git a/examples/basic/schema.graphql b/examples/basic/schema.graphql index de3c3b090e2..39ee30f38bf 100644 --- a/examples/basic/schema.graphql +++ b/examples/basic/schema.graphql @@ -415,6 +415,11 @@ type Query { uuid: ID! } +type RandomNumber { + number: Int + generatedAt: Int +} + union AuthenticatedItem = User type KeystoneMeta { @@ -503,8 +508,3 @@ enum KeystoneAdminUISortDirection { ASC DESC } - -type RandomNumber { - number: Int - generatedAt: Int -} diff --git a/examples/basic/schema.ts b/examples/basic/schema.ts index b083fc6a49c..2eaa01e640f 100644 --- a/examples/basic/schema.ts +++ b/examples/basic/schema.ts @@ -1,4 +1,4 @@ -import { list, graphQLSchemaExtension, gql, graphql } from '@keystone-6/core'; +import { list, graphql } from '@keystone-6/core'; import { text, relationship, @@ -13,7 +13,7 @@ import { import { document } from '@keystone-6/fields-document'; import { v4 } from 'uuid'; import { allowAll } from '@keystone-6/core/access'; -import { Context, Lists } from '.keystone/types'; +import { Lists } from '.keystone/types'; type AccessArgs = { session?: { @@ -182,39 +182,39 @@ export const lists: Lists = { }), }; -// note this usage of the type is important because it tests that the generated types work -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: gql` - type Query { - randomNumber: RandomNumber - uuid: ID! - } - type RandomNumber { - number: Int - generatedAt: Int - } - type Mutation { - createRandomPosts: [Post!]! - } - `, - resolvers: { - RandomNumber: { - number(rootVal: { number: number }) { - return rootVal.number * 1000; - }, +export const extendGraphqlSchema = graphql.extend(base => { + const RandomNumber = graphql.object<{ number: number }>()({ + name: 'RandomNumber', + fields: { + number: graphql.field({ type: graphql.Int }), + generatedAt: graphql.field({ + type: graphql.Int, + resolve() { + return Date.now(); + }, + }), }, - Mutation: { - createRandomPosts(root, args, context) { - const data = Array.from({ length: 238 }).map((x, i) => ({ title: `Post ${i}` })); - return context.db.Post.createMany({ data }); - }, + }); + + return { + mutation: { + createRandomPosts: graphql.field({ + type: graphql.nonNull(graphql.list(graphql.nonNull(base.object('Post')))), + resolve: async (rootVal, args, context) => { + const data = Array.from({ length: 238 }).map((x, i) => ({ title: `Post ${i}` })); + return context.db.Post.createMany({ data }); + }, + }), }, - Query: { - randomNumber: () => ({ - number: randomNumber(), - generatedAt: Date.now(), + query: { + randomNumber: graphql.field({ + type: RandomNumber, + resolve: () => ({ number: randomNumber() }), + }), + uuid: graphql.field({ + type: graphql.nonNull(graphql.ID), + resolve: () => v4(), }), - uuid: () => v4(), }, - }, + }; }); diff --git a/examples/ecommerce/mutations/addToCart.ts b/examples/ecommerce/mutations/addToCart.ts index 08756d4cf90..1fe9b522b2e 100644 --- a/examples/ecommerce/mutations/addToCart.ts +++ b/examples/ecommerce/mutations/addToCart.ts @@ -3,7 +3,7 @@ import { Context } from '.keystone/types'; async function addToCart( root: any, - { productId }: { productId: string }, + { productId }: { productId?: string | null }, context: Context ): Promise { console.log('ADDING TO CART!'); diff --git a/examples/ecommerce/mutations/index.ts b/examples/ecommerce/mutations/index.ts index dcd465000da..623b4453869 100644 --- a/examples/ecommerce/mutations/index.ts +++ b/examples/ecommerce/mutations/index.ts @@ -1,20 +1,20 @@ -import { graphQLSchemaExtension } from '@keystone-6/core'; +import { graphql } from '@keystone-6/core'; import addToCart from './addToCart'; import checkout from './checkout'; -// make a fake graphql tagged template literal -const graphql = String.raw; -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: graphql` - type Mutation { - addToCart(productId: ID): CartItem - checkout(token: String!): Order - } - `, - resolvers: { - Mutation: { - addToCart, - checkout, +export const extendGraphqlSchema = graphql.extend(base => { + return { + mutation: { + addToCart: graphql.field({ + type: base.object('CartItem'), + args: { productId: graphql.arg({ type: graphql.ID }) }, + resolve: addToCart, + }), + checkout: graphql.field({ + type: base.object('Order'), + args: { token: graphql.arg({ type: graphql.nonNull(graphql.String) }) }, + resolve: checkout, + }), }, - }, + }; }); diff --git a/examples/extend-graphql-schema/CHANGELOG.md b/examples/extend-graphql-schema-graphql-tools/CHANGELOG.md similarity index 100% rename from examples/extend-graphql-schema/CHANGELOG.md rename to examples/extend-graphql-schema-graphql-tools/CHANGELOG.md diff --git a/examples/extend-graphql-schema/README.md b/examples/extend-graphql-schema-graphql-tools/README.md similarity index 100% rename from examples/extend-graphql-schema/README.md rename to examples/extend-graphql-schema-graphql-tools/README.md diff --git a/examples/extend-graphql-schema/keystone.ts b/examples/extend-graphql-schema-graphql-tools/keystone.ts similarity index 100% rename from examples/extend-graphql-schema/keystone.ts rename to examples/extend-graphql-schema-graphql-tools/keystone.ts diff --git a/examples/extend-graphql-schema-graphql-tools/package.json b/examples/extend-graphql-schema-graphql-tools/package.json new file mode 100644 index 00000000000..2efeefcebed --- /dev/null +++ b/examples/extend-graphql-schema-graphql-tools/package.json @@ -0,0 +1,19 @@ +{ + "name": "@keystone-6/example-extend-graphql-schema-graphql-tools", + "version": "0.0.4", + "private": true, + "repository": "https://github.com/keystonejs/keystone/tree/main/examples/extend-graphql-schema-graphql-tools", + "license": "MIT", + "scripts": { + "build": "keystone build", + "dev": "keystone dev", + "start": "keystone start" + }, + "dependencies": { + "@graphql-tools/schema": "^9.0.0", + "@keystone-6/core": "^2.0.0" + }, + "devDependencies": { + "typescript": "~4.7.4" + } +} diff --git a/examples/extend-graphql-schema/sandbox.config.json b/examples/extend-graphql-schema-graphql-tools/sandbox.config.json similarity index 100% rename from examples/extend-graphql-schema/sandbox.config.json rename to examples/extend-graphql-schema-graphql-tools/sandbox.config.json diff --git a/examples/extend-graphql-schema/schema.graphql b/examples/extend-graphql-schema-graphql-tools/schema.graphql similarity index 100% rename from examples/extend-graphql-schema/schema.graphql rename to examples/extend-graphql-schema-graphql-tools/schema.graphql diff --git a/examples/extend-graphql-schema/schema.prisma b/examples/extend-graphql-schema-graphql-tools/schema.prisma similarity index 100% rename from examples/extend-graphql-schema/schema.prisma rename to examples/extend-graphql-schema-graphql-tools/schema.prisma diff --git a/examples/extend-graphql-schema-graphql-tools/schema.ts b/examples/extend-graphql-schema-graphql-tools/schema.ts new file mode 100644 index 00000000000..692bd285928 --- /dev/null +++ b/examples/extend-graphql-schema-graphql-tools/schema.ts @@ -0,0 +1,134 @@ +import { list } from '@keystone-6/core'; +import type { GraphQLSchema } from 'graphql'; +import { mergeSchemas } from '@graphql-tools/schema'; +import { allowAll } from '@keystone-6/core/access'; +import { select, relationship, text, timestamp } from '@keystone-6/core/fields'; +import { Lists, Context } from '.keystone/types'; + +export const lists: Lists = { + Post: list({ + access: allowAll, + fields: { + title: text({ validation: { isRequired: true } }), + status: select({ + type: 'enum', + options: [ + { label: 'Draft', value: 'draft' }, + { label: 'Published', value: 'published' }, + ], + }), + content: text(), + publishDate: timestamp(), + author: relationship({ ref: 'Author.posts', many: false }), + }, + }), + Author: list({ + access: allowAll, + fields: { + name: text({ validation: { isRequired: true } }), + email: text({ isIndexed: 'unique', validation: { isRequired: true } }), + posts: relationship({ ref: 'Post.author', many: true }), + }, + }), +}; + +export const extendGraphqlSchema = (schema: GraphQLSchema) => + mergeSchemas({ + schemas: [schema], + typeDefs: ` + type Mutation { + """ Publish a post """ + publishPost(id: ID!): Post + + """ Create or update an author based on email """ + upsertAuthor(where: AuthorWhereUniqueInput!, create: AuthorCreateInput!, update: AuthorUpdateInput!): Author + } + + type Query { + """ Return all posts for a user from the last days """ + recentPosts(id: ID!, days: Int! = 7): [Post] + + """ Compute statistics for a user """ + stats(id: ID!): Statistics + } + + """ A custom type to represent statistics for a user """ + type Statistics { + draft: Int + published: Int + latest: Post + }`, + resolvers: { + Mutation: { + publishPost: (root, { id }, context: Context) => { + // Note we use `context.db.Post` here as we have a return type + // of Post, and this API provides results in the correct format. + // If you accidentally use `context.query.Post` here you can expect problems + // when accessing the fields in your GraphQL client. + return context.db.Post.updateOne({ + where: { id }, + data: { status: 'published', publishDate: new Date().toUTCString() }, + }); + }, + upsertAuthor: async (root, { where, update, create }, context: Context) => { + try { + // we need to await the update here so that if an error is thrown, it's caught + // by the try catch here and not returned through the graphql api + return await context.db.Author.updateOne({ where, data: update }); + } catch (updateError: any) { + // updateOne will fail with the code KS_ACCESS_DENIED if the item isn't found, + // so we try to create it. If the item does exist, the unique constraint on + // email will prevent a duplicate being created, and we catch the error + if (updateError.extensions?.code === 'KS_ACCESS_DENIED') { + return await context.db.Author.createOne({ data: create }); + } + throw updateError; + } + }, + }, + Query: { + recentPosts: (root, { id, days }, context: Context) => { + // Create a date string in the past from now() + const cutoff = new Date( + new Date().setUTCDate(new Date().getUTCDate() - days) + ).toUTCString(); + + // Note we use `context.db.Post` here as we have a return type + // of [Post], and this API provides results in the correct format. + // If you accidentally use `context.query.Post` here you can expect problems + // when accessing the fields in your GraphQL client. + return context.db.Post.findMany({ + where: { author: { id: { equals: id } }, publishDate: { gt: cutoff } }, + }); + }, + stats: async (root, { id }) => { + return { authorId: id }; + }, + }, + Statistics: { + // The stats resolver returns an object which is passed to this resolver as + // the root value. We use that object to further resolve ths specific fields. + // In this case we want to take root.authorId and get the latest post for that author + // + // As above we use the context.db.Post API to achieve this. + latest: async (val, args, context: Context) => { + const [post] = await context.db.Post.findMany({ + take: 1, + orderBy: { publishDate: 'desc' }, + where: { author: { id: { equals: val.authorId } } }, + }); + return post; + }, + draft: (val, args, context: Context) => { + return context.query.Post.count({ + where: { author: { id: { equals: val.authorId } }, status: { equals: 'draft' } }, + }); + }, + published: (val, args, context: Context) => { + return context.query.Post.count({ + where: { author: { id: { equals: val.authorId } }, status: { equals: 'published' } }, + }); + }, + }, + }, + }); diff --git a/examples/extend-graphql-schema-nexus/package.json b/examples/extend-graphql-schema-nexus/package.json index b90d0b39774..6cc0ca195c1 100644 --- a/examples/extend-graphql-schema-nexus/package.json +++ b/examples/extend-graphql-schema-nexus/package.json @@ -9,7 +9,7 @@ "build": "keystone build" }, "dependencies": { - "@graphql-tools/schema": "^8.3.1", + "@graphql-tools/schema": "^9.0.0", "@keystone-6/core": "^2.2.0", "graphql": "^16.6.0", "nexus": "1.3.0" diff --git a/examples/extend-graphql-schema/package.json b/examples/extend-graphql-schema/package.json deleted file mode 100644 index 3167f5ba92a..00000000000 --- a/examples/extend-graphql-schema/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@keystone-6/example-extend-graphql-schema", - "version": "0.0.4", - "private": true, - "license": "MIT", - "scripts": { - "dev": "keystone dev", - "start": "keystone start", - "build": "keystone build" - }, - "dependencies": { - "@keystone-6/core": "^2.2.0" - }, - "devDependencies": { - "typescript": "~4.7.4" - }, - "repository": "https://github.com/keystonejs/keystone/tree/main/examples/extend-graphql-schema" -} diff --git a/examples/extend-graphql-schema/schema.ts b/examples/extend-graphql-schema/schema.ts deleted file mode 100644 index 0ab2bc2cfb4..00000000000 --- a/examples/extend-graphql-schema/schema.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { graphQLSchemaExtension, list } from '@keystone-6/core'; -import { allowAll } from '@keystone-6/core/access'; -import { select, relationship, text, timestamp } from '@keystone-6/core/fields'; -import { Context, Lists } from '.keystone/types'; - -export const lists: Lists = { - Post: list({ - access: allowAll, - fields: { - title: text({ validation: { isRequired: true } }), - status: select({ - type: 'enum', - options: [ - { label: 'Draft', value: 'draft' }, - { label: 'Published', value: 'published' }, - ], - }), - content: text(), - publishDate: timestamp(), - author: relationship({ ref: 'Author.posts', many: false }), - }, - }), - Author: list({ - access: allowAll, - fields: { - name: text({ validation: { isRequired: true } }), - email: text({ isIndexed: 'unique', validation: { isRequired: true } }), - posts: relationship({ ref: 'Post.author', many: true }), - }, - }), -}; - -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: ` - type Mutation { - """ Publish a post """ - publishPost(id: ID!): Post - - """ Create or update an author based on email """ - upsertAuthor(where: AuthorWhereUniqueInput!, create: AuthorCreateInput!, update: AuthorUpdateInput!): Author - } - - type Query { - """ Return all posts for a user from the last days """ - recentPosts(id: ID!, days: Int! = 7): [Post] - - """ Compute statistics for a user """ - stats(id: ID!): Statistics - } - - """ A custom type to represent statistics for a user """ - type Statistics { - draft: Int - published: Int - latest: Post - }`, - resolvers: { - Mutation: { - publishPost: (root, { id }, context) => { - // Note we use `context.db.Post` here as we have a return type - // of Post, and this API provides results in the correct format. - // If you accidentally use `context.query.Post` here you can expect problems - // when accessing the fields in your GraphQL client. - return context.db.Post.updateOne({ - where: { id }, - data: { status: 'published', publishDate: new Date().toUTCString() }, - }); - }, - upsertAuthor: async (root, { where, update, create }, context) => { - try { - // we need to await the update here so that if an error is thrown, it's caught - // by the try catch here and not returned through the graphql api - return await context.db.Author.updateOne({ where, data: update }); - } catch (updateError: any) { - // updateOne will fail with the code KS_ACCESS_DENIED if the item isn't found, - // so we try to create it. If the item does exist, the unique constraint on - // email will prevent a duplicate being created, and we catch the error - if (updateError.extensions?.code === 'KS_ACCESS_DENIED') { - return await context.db.Author.createOne({ data: create }); - } - throw updateError; - } - }, - }, - Query: { - recentPosts: (root, { id, days }, context) => { - // Create a date string in the past from now() - const cutoff = new Date( - new Date().setUTCDate(new Date().getUTCDate() - days) - ).toUTCString(); - - // Note we use `context.db.Post` here as we have a return type - // of [Post], and this API provides results in the correct format. - // If you accidentally use `context.query.Post` here you can expect problems - // when accessing the fields in your GraphQL client. - return context.db.Post.findMany({ - where: { author: { id: { equals: id } }, publishDate: { gt: cutoff } }, - }); - }, - stats: async (root, { id }) => { - return { authorId: id }; - }, - }, - Statistics: { - // The stats resolver returns an object which is passed to this resolver as - // the root value. We use that object to further resolve ths specific fields. - // In this case we want to take root.authorId and get the latest post for that author - // - // As above we use the context.db.Post API to achieve this. - latest: async (val, args, context) => { - const [post] = await context.db.Post.findMany({ - take: 1, - orderBy: { publishDate: 'desc' }, - where: { author: { id: { equals: val.authorId } } }, - }); - return post; - }, - draft: (val, args, context) => { - return context.query.Post.count({ - where: { author: { id: { equals: val.authorId } }, status: { equals: 'draft' } }, - }); - }, - published: (val, args, context) => { - return context.query.Post.count({ - where: { author: { id: { equals: val.authorId } }, status: { equals: 'published' } }, - }); - }, - }, - }, -}); diff --git a/examples/extend-graphql-subscriptions/package.json b/examples/extend-graphql-subscriptions/package.json index 34c679a260d..fcfe2b127db 100644 --- a/examples/extend-graphql-subscriptions/package.json +++ b/examples/extend-graphql-subscriptions/package.json @@ -12,6 +12,7 @@ "dependencies": { "@apollo/client": "3.6.9", "@emotion/css": "^11.7.1", + "@graphql-tools/schema": "^9.0.0", "@keystone-6/core": "^2.2.0", "@keystone-ui/button": "^7.0.1", "@keystone-ui/core": "^5.0.1", diff --git a/examples/extend-graphql-subscriptions/schema.ts b/examples/extend-graphql-subscriptions/schema.ts index e1205731a52..5efbff5f72d 100644 --- a/examples/extend-graphql-subscriptions/schema.ts +++ b/examples/extend-graphql-subscriptions/schema.ts @@ -1,4 +1,6 @@ -import { list, graphQLSchemaExtension } from '@keystone-6/core'; +import { list } from '@keystone-6/core'; +import type { GraphQLSchema } from 'graphql'; +import { mergeSchemas } from '@graphql-tools/schema'; import { select, relationship, text, timestamp } from '@keystone-6/core/fields'; import { allowAll } from '@keystone-6/core/access'; import { pubSub } from './websocket'; @@ -47,8 +49,10 @@ export const lists: Lists = { }), }; -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: ` +export const extendGraphqlSchema = (schema: GraphQLSchema) => + mergeSchemas({ + schemas: [schema], + typeDefs: ` type Mutation { """ Publish a post """ publishPost(id: ID!): Post @@ -64,45 +68,45 @@ export const extendGraphqlSchema = graphQLSchemaExtension({ time: Time }`, - resolvers: { - Mutation: { - // custom mutation to publish a post - publishPost: async (root, { id }, context) => { - // we use `context.db.Post`, not `context.query.Post` - // as this matches the type needed for GraphQL resolvers - const post = context.db.Post.updateOne({ - where: { id }, - data: { status: 'published', publishDate: new Date().toISOString() }, - }); + resolvers: { + Mutation: { + // custom mutation to publish a post + publishPost: async (root, { id }, context) => { + // we use `context.db.Post`, not `context.query.Post` + // as this matches the type needed for GraphQL resolvers + const post = context.db.Post.updateOne({ + where: { id }, + data: { status: 'published', publishDate: new Date().toISOString() }, + }); - console.log('POST_PUBLISHED', { id }); + console.log('POST_PUBLISHED', { id }); - // WARNING: passing this item directly to pubSub bypasses any contextual access control - // if you want access control, you need to use a different architecture - // - // tl;dr Keystone access filters are not respected in this scenario - pubSub.publish('POST_PUBLISHED', { - postPublished: post, - }); + // WARNING: passing this item directly to pubSub bypasses any contextual access control + // if you want access control, you need to use a different architecture + // + // tl;dr Keystone access filters are not respected in this scenario + pubSub.publish('POST_PUBLISHED', { + postPublished: post, + }); - return post; + return post; + }, }, - }, - // add the subscription resolvers - Subscription: { - time: { - // @ts-ignore - subscribe: () => pubSub.asyncIterator(['TIME']), - }, - postPublished: { - // @ts-ignore - subscribe: () => pubSub.asyncIterator(['POST_PUBLISHED']), - }, - postUpdated: { - // @ts-ignore - subscribe: () => pubSub.asyncIterator(['POST_UPDATED']), + // add the subscription resolvers + Subscription: { + time: { + // @ts-ignore + subscribe: () => pubSub.asyncIterator(['TIME']), + }, + postPublished: { + // @ts-ignore + subscribe: () => pubSub.asyncIterator(['POST_PUBLISHED']), + }, + postUpdated: { + // @ts-ignore + subscribe: () => pubSub.asyncIterator(['POST_UPDATED']), + }, }, }, - }, -}); + }); diff --git a/packages/core/package.json b/packages/core/package.json index 8aa425b9f00..84f028e36f4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,7 +35,6 @@ "@babel/runtime": "^7.16.3", "@emotion/hash": "^0.9.0", "@emotion/weak-memoize": "^0.3.0", - "@graphql-tools/schema": "npm:@graphql-tools/schema@9.0.1", "@graphql-ts/extend": "^1.0.0", "@graphql-ts/schema": "^0.6.0", "@graphql-typed-document-node/core": "3.1.1", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f19da3b351..7d650b712ad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,3 @@ -export { list, gql, graphQLSchemaExtension, config } from './schema/schema'; +export { list, config } from './schema/schema'; export type { ListSchemaConfig, ListConfig, ExtendGraphqlSchema, BaseFields } from './types'; export { graphql } from './types/schema'; diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index 39ec822c1f4..70edbfa9f99 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -1,13 +1,7 @@ -import type { GraphQLSchema } from 'graphql'; -import { mergeSchemas } from '@graphql-tools/schema'; - import type { BaseFields, BaseListTypeInfo, - ExtendGraphqlSchema, - GraphQLSchemaExtension, KeystoneConfig, - KeystoneContext, BaseKeystoneTypeInfo, ListConfig, } from '../types'; @@ -22,14 +16,3 @@ export function list< >(config: ListConfig): ListConfig { return { ...config }; } - -export function gql(strings: TemplateStringsArray) { - return strings[0]; -} - -export function graphQLSchemaExtension({ - typeDefs, - resolvers, -}: GraphQLSchemaExtension): ExtendGraphqlSchema { - return (schema: GraphQLSchema) => mergeSchemas({ schemas: [schema], typeDefs, resolvers }); -} diff --git a/tests/api-tests/admin-meta.test.ts b/tests/api-tests/admin-meta.test.ts index d41c7e78511..6e351261be4 100644 --- a/tests/api-tests/admin-meta.test.ts +++ b/tests/api-tests/admin-meta.test.ts @@ -1,4 +1,4 @@ -import { gql, list } from '@keystone-6/core'; +import { list } from '@keystone-6/core'; import { allowAll } from '@keystone-6/core/access'; import { text } from '@keystone-6/core/fields'; import { staticAdminMetaQuery } from '@keystone-6/core/src/admin-ui/admin-meta-graphql'; @@ -133,6 +133,7 @@ test( }, }), })(async ({ context }) => { + const gql = ([content]: TemplateStringsArray) => content; const res = await context.sudo().graphql.raw({ query: gql` query { diff --git a/tests/api-tests/extend-graphql-schema/extend-graphql-schema.test.ts b/tests/api-tests/extend-graphql-schema/extend-graphql-schema.test.ts index 6fc79bebbca..1664adb0343 100644 --- a/tests/api-tests/extend-graphql-schema/extend-graphql-schema.test.ts +++ b/tests/api-tests/extend-graphql-schema/extend-graphql-schema.test.ts @@ -1,4 +1,4 @@ -import { list, graphQLSchemaExtension, gql } from '@keystone-6/core'; +import { list, graphql } from '@keystone-6/core'; import { allowAll } from '@keystone-6/core/access'; import { text } from '@keystone-6/core/fields'; import { setupTestRunner } from '@keystone-6/api-tests/test-runner'; @@ -23,6 +23,30 @@ const withAccessCheck = ( }; }; +const extendGraphqlSchema = graphql.extend(() => { + return { + mutation: { + triple: graphql.field({ + type: graphql.Int, + args: { x: graphql.arg({ type: graphql.nonNull(graphql.Int) }) }, + resolve: withAccessCheck(true, (_, { x }: { x: number }) => 3 * x), + }), + }, + query: { + double: graphql.field({ + type: graphql.Int, + args: { x: graphql.arg({ type: graphql.nonNull(graphql.Int) }) }, + resolve: withAccessCheck(true, (_, { x }: { x: number }) => 2 * x), + }), + quads: graphql.field({ + type: graphql.Int, + args: { x: graphql.arg({ type: graphql.nonNull(graphql.Int) }) }, + resolve: withAccessCheck(falseFn, (_, { x }: { x: number }) => 4 * x), + }), + }, + }; +}); + const runner = setupTestRunner({ config: apiTestConfig({ lists: { @@ -31,29 +55,7 @@ const runner = setupTestRunner({ fields: { name: text() }, }), }, - extendGraphqlSchema: graphQLSchemaExtension({ - typeDefs: gql` - type Query { - double(x: Int): Int - quads(x: Int): Int - } - type Mutation { - triple(x: Int): Int - } - `, - resolvers: { - Query: { - double: withAccessCheck(true, (_, { x }) => 2 * x), - quads: withAccessCheck(falseFn, (_, { x }) => 4 * x), - users: withAccessCheck(true, () => { - return [{ name: 'foo' }]; - }), - }, - Mutation: { - triple: withAccessCheck(true, (_, { x }) => 3 * x), - }, - }, - }), + extendGraphqlSchema, }), }); @@ -117,20 +119,4 @@ describe('extendGraphqlSchema', () => { expect(data).toEqual({ createUser: { name: 'Real User' } }); }) ); - it( - 'Overrides default keystone resolvers with custom resolvers', - runner(async ({ context }) => { - const data = (await context.graphql.run({ - query: ` - query { - users { - name - } - } - `, - })) as { users: { name: string }[] }; - - expect(data.users[0].name).toEqual('foo'); - }) - ); }); diff --git a/tests/api-tests/queries/cache-hints.test.ts b/tests/api-tests/queries/cache-hints.test.ts index 6f169ea9e9f..eea3d28a18c 100644 --- a/tests/api-tests/queries/cache-hints.test.ts +++ b/tests/api-tests/queries/cache-hints.test.ts @@ -1,6 +1,6 @@ import { CacheScope } from 'apollo-server-types'; import { text, relationship, integer } from '@keystone-6/core/fields'; -import { list, graphQLSchemaExtension } from '@keystone-6/core'; +import { list, graphql } from '@keystone-6/core'; import { setupTestRunner } from '@keystone-6/api-tests/test-runner'; import { allowAll } from '@keystone-6/core/access'; import { apiTestConfig, ContextFromRunner } from '../utils'; @@ -44,32 +44,33 @@ const runner = setupTestRunner({ }, }), }, - extendGraphqlSchema: graphQLSchemaExtension({ - typeDefs: ` - type MyType { - original: Int - double: Float - } - - type Mutation { - triple(x: Int): Int - } - - type Query { - double(x: Int): MyType - } - `, - resolvers: { - Query: { - double: (root, { x }, context, info) => { - info.cacheControl.setCacheHint({ scope: CacheScope.Public, maxAge: 100 }); - return { original: x, double: 2.0 * x }; - }, + extendGraphqlSchema: graphql.extend(() => { + const MyType = graphql.object<{ original: number }>()({ + name: 'MyType', + fields: { + original: graphql.field({ type: graphql.Int }), + double: graphql.field({ type: graphql.Int, resolve: ({ original }) => original * 2 }), }, - Mutation: { - triple: (root, { x }) => 3 * x, + }); + return { + query: { + double: graphql.field({ + type: MyType, + args: { x: graphql.arg({ type: graphql.nonNull(graphql.Int) }) }, + resolve: (_, { x }, context, info) => { + info.cacheControl.setCacheHint({ maxAge: 100, scope: CacheScope.Public }); + return { original: x, double: x * 2 }; + }, + }), }, - }, + mutation: { + triple: graphql.field({ + type: graphql.Int, + args: { x: graphql.arg({ type: graphql.nonNull(graphql.Int) }) }, + resolve: (_, { x }) => x * 3, + }), + }, + }; }), }), }); diff --git a/tests/examples-smoke-tests/extend-graphql-schema.test.ts b/tests/examples-smoke-tests/extend-graphql-schema-graphql-tools.test.ts similarity index 88% rename from tests/examples-smoke-tests/extend-graphql-schema.test.ts rename to tests/examples-smoke-tests/extend-graphql-schema-graphql-tools.test.ts index fb9d64339cf..0f87b07cd29 100644 --- a/tests/examples-smoke-tests/extend-graphql-schema.test.ts +++ b/tests/examples-smoke-tests/extend-graphql-schema-graphql-tools.test.ts @@ -1,7 +1,7 @@ import { Browser, Page } from 'playwright'; import { exampleProjectTests, loadIndex } from './utils'; -exampleProjectTests('extend-graphql-schema', browserType => { +exampleProjectTests('extend-graphql-schema-graphql-tools', browserType => { let browser: Browser = undefined as any; let page: Page = undefined as any; beforeAll(async () => { diff --git a/tests/test-projects/live-reloading/schemas/changed-prisma-schema.ts b/tests/test-projects/live-reloading/schemas/changed-prisma-schema.ts index cac3c01872e..8f9faa0c90c 100644 --- a/tests/test-projects/live-reloading/schemas/changed-prisma-schema.ts +++ b/tests/test-projects/live-reloading/schemas/changed-prisma-schema.ts @@ -1,4 +1,4 @@ -import { graphQLSchemaExtension, list } from '@keystone-6/core'; +import { list, graphql } from '@keystone-6/core'; import { allowAll } from '@keystone-6/core/access'; import { text } from '@keystone-6/core/fields'; @@ -12,15 +12,13 @@ export const lists = { }), }; -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: ` - extend type Query { - someNumber: Int - } - `, - resolvers: { - Query: { - someNumber: () => 1, +export const extendGraphqlSchema = graphql.extend(() => { + return { + query: { + someNumber: graphql.field({ + type: graphql.Int, + resolve: () => 1, + }), }, - }, + }; }); diff --git a/tests/test-projects/live-reloading/schemas/initial.ts b/tests/test-projects/live-reloading/schemas/initial.ts index beaa763b07f..824c3ce03cf 100644 --- a/tests/test-projects/live-reloading/schemas/initial.ts +++ b/tests/test-projects/live-reloading/schemas/initial.ts @@ -1,4 +1,4 @@ -import { graphQLSchemaExtension, list } from '@keystone-6/core'; +import { graphql, list } from '@keystone-6/core'; import { allowAll } from '@keystone-6/core/access'; import { text } from '@keystone-6/core/fields'; @@ -11,15 +11,13 @@ export const lists = { }), }; -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: ` - extend type Query { - someNumber: Int - } - `, - resolvers: { - Query: { - someNumber: () => 1, +export const extendGraphqlSchema = graphql.extend(() => { + return { + query: { + someNumber: graphql.field({ + type: graphql.Int, + resolve: () => 1, + }), }, - }, + }; }); diff --git a/tests/test-projects/live-reloading/schemas/second.ts b/tests/test-projects/live-reloading/schemas/second.ts index e2b4eaaaafc..4a612ff9572 100644 --- a/tests/test-projects/live-reloading/schemas/second.ts +++ b/tests/test-projects/live-reloading/schemas/second.ts @@ -1,4 +1,4 @@ -import { graphql, graphQLSchemaExtension, list } from '@keystone-6/core'; +import { graphql, list } from '@keystone-6/core'; import { allowAll } from '@keystone-6/core/access'; import { text, virtual } from '@keystone-6/core/fields'; @@ -19,15 +19,13 @@ export const lists = { }), }; -export const extendGraphqlSchema = graphQLSchemaExtension({ - typeDefs: ` - extend type Query { - someNumber: Int! - } - `, - resolvers: { - Query: { - someNumber: () => 1, +export const extendGraphqlSchema = graphql.extend(() => { + return { + query: { + someNumber: graphql.field({ + type: graphql.nonNull(graphql.Int), + resolve: () => 1, + }), }, - }, + }; }); diff --git a/yarn.lock b/yarn.lock index 215e5f10f8e..55761f56ecf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2723,14 +2723,6 @@ "@graphql-tools/utils" "8.9.0" tslib "^2.4.0" -"@graphql-tools/merge@8.3.3": - version "8.3.3" - resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.3.tgz#74dd4816c3fc7af38730fc59d1cba6e687d7fb2d" - integrity sha512-EfULshN2s2s2mhBwbV9WpGnoehRLe7eIMdZrKfHhxlBWOvtNUd3KSCN0PUdAMd7lj1jXUW9KYdn624JrVn6qzg== - dependencies: - "@graphql-tools/utils" "8.10.0" - tslib "^2.4.0" - "@graphql-tools/merge@8.3.6": version "8.3.6" resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.6.tgz#97a936d4c8e8f935e58a514bb516c476437b5b2c" @@ -2765,7 +2757,7 @@ "@graphql-tools/utils" "8.12.0" tslib "^2.4.0" -"@graphql-tools/schema@9.0.4": +"@graphql-tools/schema@9.0.4", "@graphql-tools/schema@^9.0.0": version "9.0.4" resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-9.0.4.tgz#1a74608b57abf90fae6fd929d25e5482c57bc05d" integrity sha512-B/b8ukjs18fq+/s7p97P8L1VMrwapYc3N2KvdG/uNThSazRRn8GsBK0Nr+FH+mVKiUfb4Dno79e3SumZVoHuOQ== @@ -2775,7 +2767,7 @@ tslib "^2.4.0" value-or-promise "1.0.11" -"@graphql-tools/schema@^8.0.0", "@graphql-tools/schema@^8.3.1": +"@graphql-tools/schema@^8.0.0": version "8.5.1" resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-8.5.1.tgz#c2f2ff1448380919a330312399c9471db2580b58" integrity sha512-0Esilsh0P/qYcB5DKQpiKeQs/jevzIadNTaT0jeWklPMwNbT7yMX4EqZany7mbeRRlSRwMzNzL5olyFdffHBZg== @@ -2785,23 +2777,6 @@ tslib "^2.4.0" value-or-promise "1.0.11" -"@graphql-tools/schema@npm:@graphql-tools/schema@9.0.1": - version "9.0.1" - resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-9.0.1.tgz#ba8629107c1f0b9ffad14c08c2a85961967682fd" - integrity sha512-Y6apeiBmvXEz082IAuS/ainnEEQrzMECP1MRIV72eo2WPa6ZtLYPycvIbd56Z5uU2LKP4XcWRgK6WUbCyN16Rw== - dependencies: - "@graphql-tools/merge" "8.3.3" - "@graphql-tools/utils" "8.10.0" - tslib "^2.4.0" - value-or-promise "1.0.11" - -"@graphql-tools/utils@8.10.0": - version "8.10.0" - resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.10.0.tgz#8e76db7487e19b60cf99fb90c2d6343b2105b331" - integrity sha512-yI+V373FdXQbYfqdarehn9vRWDZZYuvyQ/xwiv5ez2BbobHrqsexF7qs56plLRaQ8ESYpVAjMQvJWe9s23O0Jg== - dependencies: - tslib "^2.4.0" - "@graphql-tools/utils@8.12.0", "@graphql-tools/utils@^8.8.0": version "8.12.0" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.12.0.tgz#243bc4f5fc2edbc9e8fd1038189e57d837cbe31f"