From a3b07ea16ffc0f6741c0c0e5e281622a1831e0e7 Mon Sep 17 00:00:00 2001 From: Mitchell Hamilton Date: Tue, 8 Jun 2021 15:58:53 +1000 Subject: [PATCH] New core (#5665) * Remove legacy tests (#5669) * Update @ts-gql/schema with a fix with for the inferred type of enums and use orderBy resolvers * Unique tests (#5671) * Add tests for uniqueness * Update packages-next/fields/src/types/timestamp/index.ts Co-authored-by: Mitchell Hamilton * Fix fetching items through a relationship field * Add defaultValue to variables in executeGraphQLFieldToRootVal * Fix a type error * Fix updateOne * Use order by (#5678) * Use orderBy instead of sortBy * Use enum correctly * Progress on filter things * WIP on filters * filters! * Password filter * First pass at filter test updates for new fields (#5696) * Update filter tests for passwords (#5698) * Fix auth * Fix .count(), update tests (#5699) * Remove itemQueryName * Make the list function type errors go away * Fix nested mutations (#5700) * Decimal filters and scalar * Fix type errors in document field * Fix fetching one-sided relationships * Fix orderBy * manypkg fix * Relationship tests for next fields (#5701) * Fix one sided many-to-many queries * Update more relationship tests * Implement decimal field * Cloudinary field * Update more tests to new APIs (#5703) * Change meta query to count * Fix displayMode: 'count' * More graphql api changes * Fix some more things * Add endSession with @ts-gql/schema * Require that idFields are non null ids * Update tests, fix minor bugs (#5714) * Try fixing CodeSandbox CI * Filter to-one relationship by null * Fix find one with filter access control * Get more tests working (#5721) * Fix commands * Move prisma utils * Update todo example from default branch * Fix an error * Update tests to use new APIs (#5730) * Disconnect fixes (#5731) * Fix use of disconnect * Fix disconnect usage * And some more fixes * Fix more tests (#5732) * More test updates for new APIs (#5733) * Fix tests * WIP * Update more tests for new APIs (#5748) * Make orderBy essentially an input union * manypkg fix * sortBy -> orderBy tests * Add orderBy test * Update example schemas (#5762) * Delete a bunch of old stuff to get rid of a bunch of type errors * image and file field using interfaces * Make things nullable * Coerce and validate filters from access control * Remove unused import * Change default idField to uuid to test nested mutation behaviour with uuids * Refactor the return type of createSystem * Update orderBy things * Start of legacy filters stuff * WIP of legacy filters * more progress on legacy filters * more progress on legacy filters * Fix things * Relationship filtering * words * start of defaultValue and isRequired * fix a thing * Implement legacy defaultValue and isRequired * Reset tests from default branch * Change back to isIndexed and isUnique * Reset server-side-graphql-client * Change ordering of fields in generated prisma schema * Simplify schema construction * Revert "Simplify schema construction" This reverts commit 3c5eb59ed686912f4f2665abdf63d2a4b492d4b8. * more things * more alignment on graphql schema * More alignment * Update generated schemas * more alignment * orderBy and search * More progress * Fix a bunch of stuff * more progress * WIP * Fix resolveInput * Remove skipped thing * Fix access control * Fix some more things * Update virtual field tests * progress * a hacky fix * progress on cache hints * error ordering things * a bunch of progress on relationships * Fix some more things * Fix some more things * Fix some things * Fix some things * Fix a thing * fix a thing * WIP * FIx some more things * Revert access control API changes * Move some utils * wip * WIP * refactoring * Fix things * autoIncrement as non-id field * actually do autoIncrement as non-id field * Fix autoIncrement * Fix a thing * Fix another autoIncrement thing * i think this is it * Fix some things * Reset admin ui things * Fix things * Fix another thing * Fix things * Update schemas * ordering things * Fix some dep things * Fix cache hints * Cleanup * Update @ts-gql/schema * Remove another unused dep * Reset docs * Fix some things * Experiment with schema reordering for an easier diff * More reordering * More changes for the diff * more changes for the diff * WIP * More changes to reduce the diff size * More schema diff fixes * More schema alignment * Another diff fix * Update snapshots * Remove a bunch of dead code * explain a thing * New @ts-gql/schema stuff * Fix cloudinary descriptions * Fix some uniqueWhere stuff * Fix a thing * Fix the condition * Align error things * Fix auth things * Fix another usage * Remove some unused stuff * Start of cleanup * Some refactoring * More refactoring * More refactoring * More refactoring * Prisma schema conflicting enum error fix * More prisma schema error fixes * More cleanup * More refactoring * More refactoring * Add @ts-gql/schema dep back * Fix some things * Redorder a thing * More reordering * Fix some things * Remove misleading comments (the mentioned problem won't happen) * More reordering * Remove a non-null assertion * Fix a thing * Fix things * Remove more dead code * More reordering * More refactoring * More refactoring * More cleanup * More cleanup * More cleanup * More cleanup * More cleanup * Fix more things * Start of changesets * Revert "More cleanup" This reverts commit 1a38262f51bf097a4708a298297142fa6a5c0f4a. * Remove a thing * Fix more docs * words * Remove a thing that we shouldn't do * Add another changeset * changeset * changeset * Enable cloudinary field tests for a sec * More things * the long name is good * changeset * some more things * changeset * Add cloudinaryImage filters back because why not * Fix more things * Stop exporting some things * Use an if * Fix a comment * Fix a thing * things * Remove an unused thing * Remove an unnecessary thing * Remove commented out lines * Reset changes to a thing * Remove more dead code * More dead code * Remove usages of interfaces file * Fix a thing * Fix things * words * Use the right secret field impls * Allow virtual fields that reference other list types * Use @graphql-ts/schema * Rename `types` to `schema` * Fix afterChange not being called for nested creates * Explicitly export * Update .changeset/mean-chairs-destroy.md Co-authored-by: Tim Leslie * Remove added support for plural, label, singular and path options for now * Update docs * Remove commented out plugins thing * Fix docs for GraphQL plural and add a changeset for it Co-authored-by: Tim Leslie --- .changeset/bright-eels-wash.md | 5 + .changeset/few-hounds-doubt.md | 5 + .changeset/little-chicken-beg.md | 5 + .changeset/mean-chairs-destroy.md | 5 + .changeset/plenty-flies-think.md | 10 + .changeset/rare-coats-learn.md | 5 + .changeset/rich-worms-accept.md | 5 + .changeset/six-colts-give.md | 6 + .changeset/slow-camels-fold.md | 6 + .changeset/small-guests-relate.md | 6 + .changeset/smooth-pears-float.md | 15 + .changeset/thin-humans-cheat.md | 6 + .changeset/wicked-moles-cough.md | 5 + docs/pages/apis/access-control.mdx | 26 +- docs/pages/apis/context.mdx | 12 - docs/pages/apis/fields.mdx | 20 +- docs/pages/apis/schema.mdx | 3 +- examples-staging/assets-cloud/schema.graphql | 98 +- examples-staging/assets-cloud/schema.prisma | 6 +- examples-staging/assets-local/schema.graphql | 98 +- examples-staging/assets-local/schema.prisma | 6 +- examples-staging/auth/schema.graphql | 11 +- examples-staging/basic/schema.graphql | 180 +- examples-staging/basic/schema.prisma | 10 +- examples-staging/basic/schema.ts | 20 +- examples-staging/ecommerce/schema.graphql | 197 +-- examples-staging/ecommerce/schema.prisma | 41 +- examples-staging/ecommerce/schemas/Order.ts | 11 +- .../embedded-nextjs/schema.graphql | 5 - .../graphql-api-endpoint/schema.graphql | 75 +- .../graphql-api-endpoint/schema.prisma | 8 +- examples-staging/roles/schema.graphql | 79 +- examples-staging/roles/schema.prisma | 8 +- examples-staging/sandbox/schema.graphql | 49 +- examples-staging/sandbox/schema.prisma | 6 +- examples/blog/schema.graphql | 53 +- examples/blog/schema.prisma | 4 +- examples/default-values/schema.graphql | 55 +- examples/default-values/schema.prisma | 6 +- examples/extend-graphql-schema/schema.graphql | 53 +- examples/extend-graphql-schema/schema.prisma | 4 +- examples/json/schema.graphql | 43 +- examples/json/schema.prisma | 4 +- examples/task-manager/schema.graphql | 55 +- examples/task-manager/schema.prisma | 6 +- examples/with-auth/schema.graphql | 61 +- examples/with-auth/schema.prisma | 6 +- .../auth/src/gql/getBaseAuthSchema.ts | 22 +- .../auth/src/gql/getInitFirstItemSchema.ts | 5 +- .../auth/src/gql/getMagicAuthLinkSchema.ts | 22 +- .../auth/src/gql/getPasswordResetSchema.ts | 25 +- packages-next/auth/src/index.ts | 25 - packages-next/auth/src/lib/getErrorMessage.ts | 41 +- .../auth/src/lib/validateAuthToken.ts | 9 +- packages-next/auth/src/lib/validateSecret.ts | 9 +- packages-next/auth/src/schema.ts | 39 +- packages-next/auth/src/types.ts | 5 + packages-next/cloudinary/package.json | 1 - .../cloudinary/src/Implementation.ts | 269 --- packages-next/cloudinary/src/cloudinary.ts | 33 +- packages-next/cloudinary/src/index.ts | 194 ++- packages-next/fields-document/package.json | 1 - .../fields-document/src/Implementation.ts | 161 -- packages-next/fields-document/src/index.ts | 141 +- packages-next/fields/package.json | 16 +- packages-next/fields/src/Implementation.ts | 257 --- packages-next/fields/src/get-index-type.ts | 12 + packages-next/fields/src/index.ts | 2 - packages-next/fields/src/interfaces.ts | 6 - .../fields/src/tests/Implementation.test.ts | 124 -- .../src/types/autoIncrement/Implementation.ts | 121 -- .../fields/src/types/autoIncrement/index.ts | 137 +- .../src/types/checkbox/Implementation.ts | 62 - .../fields/src/types/checkbox/index.ts | 58 +- .../src/types/decimal/Implementation.ts | 134 -- .../fields/src/types/decimal/index.ts | 123 +- .../fields/src/types/file/Implementation.ts | 208 --- packages-next/fields/src/types/file/index.ts | 135 +- .../fields/src/types/float/Implementation.ts | 63 - packages-next/fields/src/types/float/index.ts | 71 +- .../fields/src/types/image/Implementation.ts | 236 --- packages-next/fields/src/types/image/index.ts | 154 +- .../src/types/integer/Implementation.ts | 63 - .../fields/src/types/integer/index.ts | 70 +- .../fields/src/types/json/Implementation.ts | 104 -- packages-next/fields/src/types/json/index.ts | 47 +- .../src/types/password/Implementation.ts | 188 --- .../fields/src/types/password/index.ts | 126 +- .../src/types/password/tests/test-fixtures.ts | 18 +- .../fields/src/types/password/views/index.tsx | 8 +- .../src/types/relationship/Implementation.ts | 438 ----- .../src/types/relationship/graphqlErrors.ts | 8 - .../fields/src/types/relationship/index.ts | 249 ++- .../types/relationship/nested-mutations.ts | 292 ---- .../relationship/tests/implementation.test.ts | 635 +++----- .../fields/src/types/select/Implementation.ts | 194 --- .../fields/src/types/select/index.ts | 125 +- .../fields/src/types/text/Implementation.ts | 81 - packages-next/fields/src/types/text/index.ts | 93 +- .../src/types/timestamp/Implementation.ts | 95 -- .../fields/src/types/timestamp/index.ts | 84 +- .../src/types/virtual/Implementation.ts | 102 -- .../fields/src/types/virtual/index.ts | 59 +- packages-next/keystone/package.json | 5 +- .../admin-ui/next-config.ts | 6 +- .../next-graphql.ts | 11 +- .../node-api.ts | 5 +- .../src/admin-ui/system/createAdminMeta.ts | 49 +- .../src/admin-ui/system/generateAdminUI.ts | 6 +- .../src/admin-ui/system/getAdminMetaSchema.ts | 190 +-- .../keystone/src/admin-ui/templates/app.ts | 16 +- .../keystone/src/admin-ui/templates/index.ts | 14 +- .../src/admin-ui/utils/useAdminMeta.tsx | 3 +- packages-next/keystone/src/artifacts.ts | 23 +- .../lib/coerceAndValidateForGraphQLInput.ts | 66 + .../src/lib/config/applyIdFieldDefaults.ts | 22 +- .../lib/context/createAccessControlContext.ts | 136 -- .../keystone/src/lib/context/createContext.ts | 13 +- .../keystone/src/lib/core/Keystone/index.ts | 252 --- .../keystone/src/lib/core/ListTypes/hooks.ts | 300 ---- .../keystone/src/lib/core/ListTypes/index.ts | 1 - .../keystone/src/lib/core/ListTypes/list.ts | 1442 ----------------- .../keystone/src/lib/core/ListTypes/utils.ts | 37 - .../keystone/src/lib/core/access-control.ts | 137 ++ .../keystone/src/lib/core/field-assertions.ts | 102 ++ .../graphqlErrors.ts => graphql-errors.ts} | 8 +- .../keystone/src/lib/core/graphql-schema.ts | 79 + .../src/lib/core/mutations/access-control.ts | 178 ++ .../src/lib/core/mutations/create-update.ts | 307 ++++ .../keystone/src/lib/core/mutations/delete.ts | 72 + .../keystone/src/lib/core/mutations/hooks.ts | 63 + .../keystone/src/lib/core/mutations/index.ts | 158 ++ .../nested-mutation-input-resolvers.ts | 266 +++ .../keystone/src/lib/core/prisma-schema.ts | 224 +++ .../keystone/src/lib/core/providers/index.ts | 33 - .../src/lib/core/providers/listCRUD.ts | 74 - .../keystone/src/lib/core/queries/index.ts | 83 + .../src/lib/core/queries/output-field.ts | 171 ++ .../src/lib/core/queries/resolvers.ts | 248 +++ .../src/lib/core/resolve-relationships.ts | 246 +++ .../src/lib/core/tests/Keystone.test.ts | 117 -- .../keystone/src/lib/core/tests/List.test.ts | 1076 ------------ .../keystone/src/lib/core/types-for-lists.ts | 384 +++++ packages-next/keystone/src/lib/core/utils.ts | 178 ++ .../keystone/src/lib/core/where-inputs.ts | 69 + .../keystone/src/lib/createGraphQLSchema.ts | 18 +- .../keystone/src/lib/createKeystone.ts | 54 - .../keystone/src/lib/createSystem.ts | 104 +- .../keystone/src/lib/schema-type-printer.tsx | 24 +- .../src/lib/server/createApolloServer.ts | 2 +- .../{core/Keystone => server}/format-error.ts | 0 packages-next/keystone/src/schema/schema.ts | 2 +- .../keystone/src/scripts/build/build.ts | 4 +- packages-next/keystone/src/scripts/run/dev.ts | 51 +- .../keystone/src/scripts/run/start.ts | 10 +- .../__snapshots__/artifacts.test.ts.snap | 5 +- .../fixtures/basic-project/schema.graphql | 5 - packages-next/keystone/src/session/index.ts | 46 +- packages-next/types/package.json | 7 +- packages-next/types/src/admin-meta.ts | 1 + packages-next/types/src/base.ts | 137 +- .../types/src/config/access-control.ts | 52 +- packages-next/types/src/config/fields.ts | 45 +- packages-next/types/src/config/hooks.ts | 29 +- packages-next/types/src/config/index.ts | 18 +- packages-next/types/src/config/lists.ts | 28 +- packages-next/types/src/context.ts | 13 +- packages-next/types/src/core.ts | 46 +- packages-next/types/src/index.ts | 4 + .../json-field-type-polyfill-for-sqlite.ts | 127 ++ packages-next/types/src/legacy-filters.ts | 182 +++ packages-next/types/src/next-fields.ts | 420 +++++ .../types/src/schema/graphql-ts-schema.ts | 70 + packages-next/types/src/schema/index.ts | 1 + .../src/schema/schema-api-with-context.d.ts | 6 + .../src/schema/schema-api-with-context.js | 1 + packages/access-control/.npmignore | 2 - packages/access-control/CHANGELOG.md | 354 ---- packages/access-control/README.md | 13 - packages/access-control/package.json | 16 - packages/access-control/src/access-control.ts | 289 ---- packages/access-control/src/index.ts | 9 - .../tests/access-control.test.ts | 563 ------- packages/adapter-prisma/.npmignore | 2 - packages/adapter-prisma/CHANGELOG.md | 377 ----- packages/adapter-prisma/README.md | 65 - packages/adapter-prisma/package.json | 20 - packages/adapter-prisma/src/adapter-prisma.ts | 759 --------- packages/adapter-prisma/src/index.ts | 1 - .../tests/adapter-prisma.test.ts | 34 - packages/test-utils/package.json | 1 - packages/test-utils/src/index.ts | 16 +- .../mutations-list-static.test.ts | 118 +- tests/api-tests/fields/types/Virtual.test.ts | 168 +- .../nested-mutations/create-many.test.ts | 41 + yarn.lock | 57 +- 196 files changed, 7173 insertions(+), 11517 deletions(-) create mode 100644 .changeset/bright-eels-wash.md create mode 100644 .changeset/few-hounds-doubt.md create mode 100644 .changeset/little-chicken-beg.md create mode 100644 .changeset/mean-chairs-destroy.md create mode 100644 .changeset/plenty-flies-think.md create mode 100644 .changeset/rare-coats-learn.md create mode 100644 .changeset/rich-worms-accept.md create mode 100644 .changeset/six-colts-give.md create mode 100644 .changeset/slow-camels-fold.md create mode 100644 .changeset/small-guests-relate.md create mode 100644 .changeset/smooth-pears-float.md create mode 100644 .changeset/thin-humans-cheat.md create mode 100644 .changeset/wicked-moles-cough.md delete mode 100644 packages-next/cloudinary/src/Implementation.ts delete mode 100644 packages-next/fields-document/src/Implementation.ts delete mode 100644 packages-next/fields/src/Implementation.ts create mode 100644 packages-next/fields/src/get-index-type.ts delete mode 100644 packages-next/fields/src/interfaces.ts delete mode 100644 packages-next/fields/src/tests/Implementation.test.ts delete mode 100644 packages-next/fields/src/types/autoIncrement/Implementation.ts delete mode 100644 packages-next/fields/src/types/checkbox/Implementation.ts delete mode 100644 packages-next/fields/src/types/decimal/Implementation.ts delete mode 100644 packages-next/fields/src/types/file/Implementation.ts delete mode 100644 packages-next/fields/src/types/float/Implementation.ts delete mode 100644 packages-next/fields/src/types/image/Implementation.ts delete mode 100644 packages-next/fields/src/types/integer/Implementation.ts delete mode 100644 packages-next/fields/src/types/json/Implementation.ts delete mode 100644 packages-next/fields/src/types/password/Implementation.ts delete mode 100644 packages-next/fields/src/types/relationship/Implementation.ts delete mode 100644 packages-next/fields/src/types/relationship/graphqlErrors.ts delete mode 100644 packages-next/fields/src/types/relationship/nested-mutations.ts delete mode 100644 packages-next/fields/src/types/select/Implementation.ts delete mode 100644 packages-next/fields/src/types/text/Implementation.ts delete mode 100644 packages-next/fields/src/types/timestamp/Implementation.ts delete mode 100644 packages-next/fields/src/types/virtual/Implementation.ts create mode 100644 packages-next/keystone/src/lib/coerceAndValidateForGraphQLInput.ts delete mode 100644 packages-next/keystone/src/lib/context/createAccessControlContext.ts delete mode 100644 packages-next/keystone/src/lib/core/Keystone/index.ts delete mode 100644 packages-next/keystone/src/lib/core/ListTypes/hooks.ts delete mode 100644 packages-next/keystone/src/lib/core/ListTypes/index.ts delete mode 100644 packages-next/keystone/src/lib/core/ListTypes/list.ts delete mode 100644 packages-next/keystone/src/lib/core/ListTypes/utils.ts create mode 100644 packages-next/keystone/src/lib/core/access-control.ts create mode 100644 packages-next/keystone/src/lib/core/field-assertions.ts rename packages-next/keystone/src/lib/core/{ListTypes/graphqlErrors.ts => graphql-errors.ts} (72%) create mode 100644 packages-next/keystone/src/lib/core/graphql-schema.ts create mode 100644 packages-next/keystone/src/lib/core/mutations/access-control.ts create mode 100644 packages-next/keystone/src/lib/core/mutations/create-update.ts create mode 100644 packages-next/keystone/src/lib/core/mutations/delete.ts create mode 100644 packages-next/keystone/src/lib/core/mutations/hooks.ts create mode 100644 packages-next/keystone/src/lib/core/mutations/index.ts create mode 100644 packages-next/keystone/src/lib/core/mutations/nested-mutation-input-resolvers.ts create mode 100644 packages-next/keystone/src/lib/core/prisma-schema.ts delete mode 100644 packages-next/keystone/src/lib/core/providers/index.ts delete mode 100644 packages-next/keystone/src/lib/core/providers/listCRUD.ts create mode 100644 packages-next/keystone/src/lib/core/queries/index.ts create mode 100644 packages-next/keystone/src/lib/core/queries/output-field.ts create mode 100644 packages-next/keystone/src/lib/core/queries/resolvers.ts create mode 100644 packages-next/keystone/src/lib/core/resolve-relationships.ts delete mode 100644 packages-next/keystone/src/lib/core/tests/Keystone.test.ts delete mode 100644 packages-next/keystone/src/lib/core/tests/List.test.ts create mode 100644 packages-next/keystone/src/lib/core/types-for-lists.ts create mode 100644 packages-next/keystone/src/lib/core/utils.ts create mode 100644 packages-next/keystone/src/lib/core/where-inputs.ts delete mode 100644 packages-next/keystone/src/lib/createKeystone.ts rename packages-next/keystone/src/lib/{core/Keystone => server}/format-error.ts (100%) create mode 100644 packages-next/types/src/json-field-type-polyfill-for-sqlite.ts create mode 100644 packages-next/types/src/legacy-filters.ts create mode 100644 packages-next/types/src/next-fields.ts create mode 100644 packages-next/types/src/schema/graphql-ts-schema.ts create mode 100644 packages-next/types/src/schema/index.ts create mode 100644 packages-next/types/src/schema/schema-api-with-context.d.ts create mode 100644 packages-next/types/src/schema/schema-api-with-context.js delete mode 100644 packages/access-control/.npmignore delete mode 100644 packages/access-control/CHANGELOG.md delete mode 100644 packages/access-control/README.md delete mode 100644 packages/access-control/package.json delete mode 100644 packages/access-control/src/access-control.ts delete mode 100644 packages/access-control/src/index.ts delete mode 100644 packages/access-control/tests/access-control.test.ts delete mode 100644 packages/adapter-prisma/.npmignore delete mode 100644 packages/adapter-prisma/CHANGELOG.md delete mode 100644 packages/adapter-prisma/README.md delete mode 100644 packages/adapter-prisma/package.json delete mode 100644 packages/adapter-prisma/src/adapter-prisma.ts delete mode 100644 packages/adapter-prisma/src/index.ts delete mode 100644 packages/adapter-prisma/tests/adapter-prisma.test.ts diff --git a/.changeset/bright-eels-wash.md b/.changeset/bright-eels-wash.md new file mode 100644 index 00000000000..a647029d7f0 --- /dev/null +++ b/.changeset/bright-eels-wash.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': major +--- + +The ordering of types in the generated `schema.graphql` has changed due to the re-implementation of the core of Keystone, you will need to run `keystone-next dev`/`keystone-next postinstall --fix` to update it. diff --git a/.changeset/few-hounds-doubt.md b/.changeset/few-hounds-doubt.md new file mode 100644 index 00000000000..55de4b08592 --- /dev/null +++ b/.changeset/few-hounds-doubt.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': major +--- + +`createSystem` no longer accepts a Prisma client directly and it returns `getKeystone` which accepts the generated Prisma client and returns `connect`, `disconnect` and `createContext` instead of returning a `keystone` instance object. diff --git a/.changeset/little-chicken-beg.md b/.changeset/little-chicken-beg.md new file mode 100644 index 00000000000..d2c622683fc --- /dev/null +++ b/.changeset/little-chicken-beg.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/fields': major +--- + +The relationship field now returns a `[Item!]` instead of a `[Item!]!`, this is so that if an error occurs when resolving the related items, only the relationship field will be returned as null rather than the whole item being returned as null. diff --git a/.changeset/mean-chairs-destroy.md b/.changeset/mean-chairs-destroy.md new file mode 100644 index 00000000000..6792457b17b --- /dev/null +++ b/.changeset/mean-chairs-destroy.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/fields': major +--- + +The API to configure `virtual` fields has changed to accept a `field` using the `schema` API exported from `@keystone-next/types` rather than GraphQL SDL. diff --git a/.changeset/plenty-flies-think.md b/.changeset/plenty-flies-think.md new file mode 100644 index 00000000000..bd830630871 --- /dev/null +++ b/.changeset/plenty-flies-think.md @@ -0,0 +1,10 @@ +--- +'@keystone-next/auth': major +'@keystone-next/cloudinary': major +'@keystone-next/fields': major +'@keystone-next/fields-document': major +'@keystone-next/keystone': major +'@keystone-next/types': major +--- + +The core of Keystone has been re-implemented to make implementing fields and new features in Keystone easier. While the observable changes for most users should be minimal, there could be breakage. If you implemented a custom field type, you will need to change it to the new API, see fields in the `@keystone-next/fields` package for inspiration on how to do this. diff --git a/.changeset/rare-coats-learn.md b/.changeset/rare-coats-learn.md new file mode 100644 index 00000000000..7bce15df3b4 --- /dev/null +++ b/.changeset/rare-coats-learn.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': major +--- + +The ordering of relationships fields in the generated Prisma schema has changed so that it aligns with the order specified in the list config with the opposites to one-sided relationships added at the end. The name of one-to-one and one-to-many relationships has also changed to include `_` between the list key and field key to align with many-to-many relationships. Note that these changes do **not require a migration**, only your `schema.prisma` file will need to be updated with `keystone-next dev`/`keystone-next postinstall --fix`. diff --git a/.changeset/rich-worms-accept.md b/.changeset/rich-worms-accept.md new file mode 100644 index 00000000000..7c02761233c --- /dev/null +++ b/.changeset/rich-worms-accept.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': major +--- + +The `id` field on the item returned from `context.db.lists` functions/passed to hooks/field type resolvers is now whatever the actual id field returned from Prisma is rather than a stringified version of it. diff --git a/.changeset/six-colts-give.md b/.changeset/six-colts-give.md new file mode 100644 index 00000000000..e32fc005bfa --- /dev/null +++ b/.changeset/six-colts-give.md @@ -0,0 +1,6 @@ +--- +'@keystone-next/keystone': major +'@keystone-next/types': major +--- + +Replaced `graphql.itemQueryName` with always using the list key as the singular name in GraphQL and renamed `graphql.listQueryName` to `graphql.plural` diff --git a/.changeset/slow-camels-fold.md b/.changeset/slow-camels-fold.md new file mode 100644 index 00000000000..f177b554f07 --- /dev/null +++ b/.changeset/slow-camels-fold.md @@ -0,0 +1,6 @@ +--- +'@keystone-next/keystone': major +'@keystone-next/types': major +--- + +The `KeystoneContext` type no longer has the `keystone` object or functions to run access control. diff --git a/.changeset/small-guests-relate.md b/.changeset/small-guests-relate.md new file mode 100644 index 00000000000..48f6274eccb --- /dev/null +++ b/.changeset/small-guests-relate.md @@ -0,0 +1,6 @@ +--- +'@keystone-next/keystone': major +'@keystone-next/types': major +--- + +List level create, update and delete access control is now called for each operation in a many operation rather than on all of the operations as a whole. This means that rather than recieving originalInput as an array with itemIds, your access control functions will always be called with just an itemId and/or originalInput(depending on which access control function it is). If your access control functions already worked with creating/updating/deleting one item, they will continue working (though you may get TypeScript errors if you were previously handling itemIds and originalInput as an array, to fix that, you should stop handling that case). diff --git a/.changeset/smooth-pears-float.md b/.changeset/smooth-pears-float.md new file mode 100644 index 00000000000..efafb0934a6 --- /dev/null +++ b/.changeset/smooth-pears-float.md @@ -0,0 +1,15 @@ +--- +'@keystone-next/fields': major +--- + +The `password` field type now adds a GraphQL type `PasswordState` to the GraphQL output type instead of adding `${fieldKey}_is_set`. + +```graphql +type User { + password: PasswordState +} + +type PasswordState { + isSet: Boolean! +} +``` diff --git a/.changeset/thin-humans-cheat.md b/.changeset/thin-humans-cheat.md new file mode 100644 index 00000000000..278f412c845 --- /dev/null +++ b/.changeset/thin-humans-cheat.md @@ -0,0 +1,6 @@ +--- +'@keystone-next/auth': major +'@keystone-next/fields': major +--- + +The way that the implementations of `generateHash` and `compare` are passed from the password field to auth has changed to be in the extensions object of the GraphQL output field. Unless you've written your own password field implementation or you're using mismatching versions of @keystone-next/auth and @keystone-next/fields, this won't affect you. diff --git a/.changeset/wicked-moles-cough.md b/.changeset/wicked-moles-cough.md new file mode 100644 index 00000000000..4bbe6927500 --- /dev/null +++ b/.changeset/wicked-moles-cough.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': major +--- + +Filters returned from access control now go through GraphQL validation and coercion like filters that you pass in through the GraphQL API, this will produce better errors when you return invalid values. diff --git a/docs/pages/apis/access-control.mdx b/docs/pages/apis/access-control.mdx index 0a68b70bf81..c132c22f11a 100644 --- a/docs/pages/apis/access-control.mdx +++ b/docs/pages/apis/access-control.mdx @@ -147,20 +147,14 @@ export default config({ Imperative access control functions are passed a collection of arguments which can be used to determine whether the operation is allowed. -| Argument | Description | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `listKey` | The key of the list being operated on. | -| `operation` | The CRUD operation being performed (`'create'`, `'read'`, `'update'`, `'delete'`). | -| `session` | The current session object. See the [Sessions API](./session) for details. | -| `originalInput` | For `create` and `update` operations, this is the value of `data` passed into the mutation. For `read` and `delete` operations this value is `undefined`. | -| `gqlName` | The name of the query or mutation being called. | -| `itemId` | The `id` of the item being updated/deleted in singular `update` and `delete` operations. The `id` of the item being searched for in a single item query. `undefined` for other operations. | -| `itemIds` | The `ids` of the items being updated/deleted in multiple `update` and `delete` operations. `undefined` for other operations. | -| `context` | The [`KeystoneContext`](./context) object of the originating GraphQL operation. | - -> **Important**: When writing imperative access control functions it is essential to consider both singular and multi-value operations. -> If you check the `itemId` in the singular case, then you also need to check the `itemIds` in the multi-value case. -> Similarly, `originalInput` will be different in the singular and multi-valued cases. +| Argument | Description | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `listKey` | The key of the list being operated on. | +| `operation` | The CRUD operation being performed (`'create'`, `'read'`, `'update'`, `'delete'`). | +| `session` | The current session object. See the [Sessions API](./session) for details. | +| `originalInput` | For `create` and `update` operations, this is the value of `data` passed into the mutation. For `read` and `delete` operations this value is `undefined`. | +| `itemId` | The `id` of the item being updated/deleted in `update` and `delete` operations. `undefined` for other operations. | +| `context` | The [`KeystoneContext`](./context) object of the originating GraphQL operation. | ```typescript import { config, createSchema, list } from '@keystone-next/keystone/schema'; @@ -174,9 +168,7 @@ export default config({ operation, session, originalInput, - gqlName, itemId, - itemIds, context, }) => { return true; @@ -298,7 +290,6 @@ Imperative access control functions are passed a collection of arguments which c | `operation` | The CRU operation being performed (`'create'`, `'read'`, `'update'`). | | `session` | The current session object. See the [Sessions API](./session) for details. | | `originalInput` | For `create` and `update` operations, this is the value of `data` passed into the mutation. For `read` operations this value is `undefined`. | -| `gqlName` | The name of the query or mutation being called. | | `context` | The [`KeystoneContext`](./context) object of the originating GraphQL operation. | | `item` | The item being updated, deleted, or read. This object is an unresolved list item. See the [list item API](./list-items) for more details on unresolved list items. | @@ -318,7 +309,6 @@ export default config({ operation, session, originalInput, - gqlName, context, item, }) => { diff --git a/docs/pages/apis/context.mdx b/docs/pages/apis/context.mdx index 652c5a8c54e..a0e8dd005b6 100644 --- a/docs/pages/apis/context.mdx +++ b/docs/pages/apis/context.mdx @@ -44,10 +44,6 @@ context = { // Database access prisma, - // Access control helpers - getListAccessControlForUser, - getFieldAccessControlForUser, - // Images API images: { getSrc, @@ -62,7 +58,6 @@ context = { // Deprecated gqlNames, - keystone, }; ``` @@ -123,11 +118,6 @@ The following functions will create a new `KeystoneContext` object with this beh The `KeystoneContext` object exposes the underlying database driver directly via `context.prisma`, which is a [Prisma Client](https://www.prisma.io/docs/reference/api-reference/prisma-client-reference) object. -### Access control helpers - -The functions `getListAccessControlForUser()` and `getFieldAccessControlForUser()` are used by the internal Keystone resolvers to apply access control. -They should not be called directly. - ### Images API If [support for image fields](./config#images) is enabled in the system, then an `images` API will be made available on the `context` object. @@ -171,6 +161,4 @@ They will be removed in future releases. `gqlNames`: A function which takes a `listKey` and returns an object containing the GraphQL query, mutation and type names related to that list. -`keystone`: An object representing the internal state of the system. - export default ({ children }) => {children}; diff --git a/docs/pages/apis/fields.mdx b/docs/pages/apis/fields.mdx index 083f9af7916..0433f269ee0 100644 --- a/docs/pages/apis/fields.mdx +++ b/docs/pages/apis/fields.mdx @@ -557,29 +557,27 @@ A `virtual` field represents a value which is computed a read time, rather than Options: -- `resolver` (required): -- `graphQLReturnType` (required): +- `field` (required): - `graphQLReturnFragment` (default: `undefined` ): -- `extendGraphQLTypes` (default: `[]`): -- `args` (default: `[]`): ```typescript import { config, createSchema, list } from '@keystone-next/keystone/schema'; import { virtual } from '@keystone-next/fields'; +import { schema } from '@keystone-next/types'; export default config({ lists: createSchema({ ListName: list({ fields: { fieldName: virtual({ - resolver: (item, args, context, info) => `/* ... */`, - graphQLReturnType: '...', + field: schema.field({ + type: schema.String, + args: { something: schema.arg({ type: schema.Int }) }, + resolve(item, args, context, info) { + + } + }) graphQLReturnFragment: '...', - extendGraphQLTypes: ['...'], - args: [ - { name: '...', type: 'String' }, - /* ... */ - ], }), /* ... */ }, diff --git a/docs/pages/apis/schema.mdx b/docs/pages/apis/schema.mdx index b8ec943b9a5..e11b151391b 100644 --- a/docs/pages/apis/schema.mdx +++ b/docs/pages/apis/schema.mdx @@ -179,8 +179,7 @@ Options: - `description` (default: `undefined`): Sets the description of the associated GraphQL type in the generated GraphQL API documentation. Overrides the list-level `description` config option. -- `itemQueryName` (default: List key, e.g `'User'`): Overrides the name used in singular mutations and queries (e.g. `User`, `updateUser`, etc). -- `listQueryName`: (default: Pluralised list key, e.g. `'Users'`): Overrides the name used in multiple mutations and queries (e.g. `allUsers`, `updateUsers`, etc). +- `plural`: (default: Pluralised list key, e.g. `'Users'`): Overrides the name used in multiple mutations and queries (e.g. `allUsers`, `updateUsers`, etc). - `queryLimits` (default: `undefined`): Allows you to limit the number of results returned from a query to this list in the GraphQL API. See also the global `graphql.queryLimits` option in the [System Configuration API](./config). - `cacheHint` (default: `undefined`): Allows you to specific the [dynamic cache control hints](https://www.apollographql.com/docs/apollo-server/performance/caching/#in-your-resolvers-dynamic) used for queries to this this list. diff --git a/examples-staging/assets-cloud/schema.graphql b/examples-staging/assets-cloud/schema.graphql index 6a4fb9deb96..4bc236ea2ab 100644 --- a/examples-staging/assets-cloud/schema.graphql +++ b/examples-staging/assets-cloud/schema.graphql @@ -1,27 +1,21 @@ +""" + A keystone list +""" +type Post { + id: ID! + title: String + status: PostStatusType + content: String + publishDate: String + author: Author + hero: ImageFieldOutput +} + enum PostStatusType { draft published } -input AuthorRelateToOneInput { - create: AuthorCreateInput - connect: AuthorWhereUniqueInput - disconnect: AuthorWhereUniqueInput - disconnectAll: Boolean -} - -input ImageFieldInput { - upload: Upload - ref: String -} - -enum ImageExtension { - jpg - png - webp - gif -} - interface ImageFieldOutput { id: ID! filesize: Int! @@ -32,6 +26,13 @@ interface ImageFieldOutput { src: String! } +enum ImageExtension { + jpg + png + webp + gif +} + type LocalImageFieldOutput implements ImageFieldOutput { id: ID! filesize: Int! @@ -42,19 +43,6 @@ type LocalImageFieldOutput implements ImageFieldOutput { src: String! } -""" - A keystone list -""" -type Post { - id: ID! - title: String - status: PostStatusType - content: String - publishDate: String - author: Author - hero: ImageFieldOutput -} - input PostWhereInput { AND: [PostWhereInput!] OR: [PostWhereInput!] @@ -133,6 +121,23 @@ input PostUpdateInput { hero: ImageFieldInput } +input AuthorRelateToOneInput { + create: AuthorCreateInput + connect: AuthorWhereUniqueInput + disconnect: AuthorWhereUniqueInput + disconnectAll: Boolean +} + +input ImageFieldInput { + upload: Upload + ref: String +} + +""" +The `Upload` scalar type represents a file upload. +""" +scalar Upload + input PostsUpdateInput { id: ID! data: PostUpdateInput @@ -151,13 +156,6 @@ input PostsCreateInput { data: PostCreateInput } -input PostRelateToManyInput { - create: [PostCreateInput] - connect: [PostWhereUniqueInput] - disconnect: [PostWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -173,7 +171,7 @@ type Author { orderBy: [PostOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Post!]! + ): [Post!] _postsMeta( where: PostWhereInput! = {} search: String @@ -189,6 +187,10 @@ type Author { postsCount(where: PostWhereInput! = {}): Int } +type _QueryMeta { + count: Int +} + input AuthorWhereInput { AND: [AuthorWhereInput!] OR: [AuthorWhereInput!] @@ -254,6 +256,13 @@ input AuthorUpdateInput { posts: PostRelateToManyInput } +input PostRelateToManyInput { + create: [PostCreateInput] + connect: [PostWhereUniqueInput] + disconnect: [PostWhereUniqueInput] + disconnectAll: Boolean +} + input AuthorsUpdateInput { id: ID! data: AuthorUpdateInput @@ -277,10 +286,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Post item. @@ -343,11 +348,6 @@ type Mutation { deleteAuthors(ids: [ID!]): [Author] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Post items which match the where clause. diff --git a/examples-staging/assets-cloud/schema.prisma b/examples-staging/assets-cloud/schema.prisma index 6ae4d324d6d..e5b2150782c 100644 --- a/examples-staging/assets-cloud/schema.prisma +++ b/examples-staging/assets-cloud/schema.prisma @@ -14,14 +14,14 @@ model Post { status String? content String? publishDate DateTime? + author Author? @relation("Post_author", fields: [authorId], references: [id]) + authorId Int? @map("author") hero_filesize Int? hero_extension String? hero_width Int? hero_height Int? hero_mode String? hero_id String? - author Author? @relation("Postauthor", fields: [authorId], references: [id]) - authorId Int? @map("author") @@index([authorId]) } @@ -30,5 +30,5 @@ model Author { id Int @id @default(autoincrement()) name String? email String? @unique - posts Post[] @relation("Postauthor") + posts Post[] @relation("Post_author") } \ No newline at end of file diff --git a/examples-staging/assets-local/schema.graphql b/examples-staging/assets-local/schema.graphql index 6a4fb9deb96..4bc236ea2ab 100644 --- a/examples-staging/assets-local/schema.graphql +++ b/examples-staging/assets-local/schema.graphql @@ -1,27 +1,21 @@ +""" + A keystone list +""" +type Post { + id: ID! + title: String + status: PostStatusType + content: String + publishDate: String + author: Author + hero: ImageFieldOutput +} + enum PostStatusType { draft published } -input AuthorRelateToOneInput { - create: AuthorCreateInput - connect: AuthorWhereUniqueInput - disconnect: AuthorWhereUniqueInput - disconnectAll: Boolean -} - -input ImageFieldInput { - upload: Upload - ref: String -} - -enum ImageExtension { - jpg - png - webp - gif -} - interface ImageFieldOutput { id: ID! filesize: Int! @@ -32,6 +26,13 @@ interface ImageFieldOutput { src: String! } +enum ImageExtension { + jpg + png + webp + gif +} + type LocalImageFieldOutput implements ImageFieldOutput { id: ID! filesize: Int! @@ -42,19 +43,6 @@ type LocalImageFieldOutput implements ImageFieldOutput { src: String! } -""" - A keystone list -""" -type Post { - id: ID! - title: String - status: PostStatusType - content: String - publishDate: String - author: Author - hero: ImageFieldOutput -} - input PostWhereInput { AND: [PostWhereInput!] OR: [PostWhereInput!] @@ -133,6 +121,23 @@ input PostUpdateInput { hero: ImageFieldInput } +input AuthorRelateToOneInput { + create: AuthorCreateInput + connect: AuthorWhereUniqueInput + disconnect: AuthorWhereUniqueInput + disconnectAll: Boolean +} + +input ImageFieldInput { + upload: Upload + ref: String +} + +""" +The `Upload` scalar type represents a file upload. +""" +scalar Upload + input PostsUpdateInput { id: ID! data: PostUpdateInput @@ -151,13 +156,6 @@ input PostsCreateInput { data: PostCreateInput } -input PostRelateToManyInput { - create: [PostCreateInput] - connect: [PostWhereUniqueInput] - disconnect: [PostWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -173,7 +171,7 @@ type Author { orderBy: [PostOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Post!]! + ): [Post!] _postsMeta( where: PostWhereInput! = {} search: String @@ -189,6 +187,10 @@ type Author { postsCount(where: PostWhereInput! = {}): Int } +type _QueryMeta { + count: Int +} + input AuthorWhereInput { AND: [AuthorWhereInput!] OR: [AuthorWhereInput!] @@ -254,6 +256,13 @@ input AuthorUpdateInput { posts: PostRelateToManyInput } +input PostRelateToManyInput { + create: [PostCreateInput] + connect: [PostWhereUniqueInput] + disconnect: [PostWhereUniqueInput] + disconnectAll: Boolean +} + input AuthorsUpdateInput { id: ID! data: AuthorUpdateInput @@ -277,10 +286,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Post item. @@ -343,11 +348,6 @@ type Mutation { deleteAuthors(ids: [ID!]): [Author] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Post items which match the where clause. diff --git a/examples-staging/assets-local/schema.prisma b/examples-staging/assets-local/schema.prisma index 6ae4d324d6d..e5b2150782c 100644 --- a/examples-staging/assets-local/schema.prisma +++ b/examples-staging/assets-local/schema.prisma @@ -14,14 +14,14 @@ model Post { status String? content String? publishDate DateTime? + author Author? @relation("Post_author", fields: [authorId], references: [id]) + authorId Int? @map("author") hero_filesize Int? hero_extension String? hero_width Int? hero_height Int? hero_mode String? hero_id String? - author Author? @relation("Postauthor", fields: [authorId], references: [id]) - authorId Int? @map("author") @@index([authorId]) } @@ -30,5 +30,5 @@ model Author { id Int @id @default(autoincrement()) name String? email String? @unique - posts Post[] @relation("Postauthor") + posts Post[] @relation("Post_author") } \ No newline at end of file diff --git a/examples-staging/auth/schema.graphql b/examples-staging/auth/schema.graphql index 360e28a60a7..c3a0113a8cc 100644 --- a/examples-staging/auth/schema.graphql +++ b/examples-staging/auth/schema.graphql @@ -5,10 +5,14 @@ type User { id: ID! name: String email: String - password_is_set: Boolean + password: PasswordState isAdmin: Boolean } +type PasswordState { + isSet: Boolean! +} + input UserWhereInput { AND: [UserWhereInput!] OR: [UserWhereInput!] @@ -139,11 +143,6 @@ type Mutation { endSession: Boolean! } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - union AuthenticatedItem = User union UserAuthenticationWithPasswordResult = diff --git a/examples-staging/basic/schema.graphql b/examples-staging/basic/schema.graphql index 53e889c6c69..dffa1ea16b1 100644 --- a/examples-staging/basic/schema.graphql +++ b/examples-staging/basic/schema.graphql @@ -1,68 +1,3 @@ -input ImageFieldInput { - upload: Upload - ref: String -} - -enum ImageExtension { - jpg - png - webp - gif -} - -interface ImageFieldOutput { - id: ID! - filesize: Int! - width: Int! - height: Int! - extension: ImageExtension! - ref: String! - src: String! -} - -type LocalImageFieldOutput implements ImageFieldOutput { - id: ID! - filesize: Int! - width: Int! - height: Int! - extension: ImageExtension! - ref: String! - src: String! -} - -input FileFieldInput { - upload: Upload - ref: String -} - -interface FileFieldOutput { - filename: String! - filesize: Int! - ref: String! - src: String! -} - -type LocalFileFieldOutput implements FileFieldOutput { - filename: String! - filesize: Int! - ref: String! - src: String! -} - -input PhoneNumberRelateToManyInput { - create: [PhoneNumberCreateInput] - connect: [PhoneNumberWhereUniqueInput] - disconnect: [PhoneNumberWhereUniqueInput] - disconnectAll: Boolean -} - -input PostRelateToManyInput { - create: [PostCreateInput] - connect: [PostWhereUniqueInput] - disconnect: [PostWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -72,7 +7,7 @@ type User { email: String avatar: ImageFieldOutput attachment: FileFieldOutput - password_is_set: Boolean + password: PasswordState isAdmin: Boolean roles: String phoneNumbers( @@ -83,7 +18,7 @@ type User { orderBy: [PhoneNumberOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [PhoneNumber!]! + ): [PhoneNumber!] _phoneNumbersMeta( where: PhoneNumberWhereInput! = {} search: String @@ -105,7 +40,7 @@ type User { orderBy: [PostOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Post!]! + ): [Post!] _postsMeta( where: PostWhereInput! = {} search: String @@ -122,6 +57,55 @@ type User { randomNumber: Float } +interface ImageFieldOutput { + id: ID! + filesize: Int! + width: Int! + height: Int! + extension: ImageExtension! + ref: String! + src: String! +} + +enum ImageExtension { + jpg + png + webp + gif +} + +interface FileFieldOutput { + filename: String! + filesize: Int! + ref: String! + src: String! +} + +type PasswordState { + isSet: Boolean! +} + +type _QueryMeta { + count: Int +} + +type LocalImageFieldOutput implements ImageFieldOutput { + id: ID! + filesize: Int! + width: Int! + height: Int! + extension: ImageExtension! + ref: String! + src: String! +} + +type LocalFileFieldOutput implements FileFieldOutput { + filename: String! + filesize: Int! + ref: String! + src: String! +} + input UserWhereInput { AND: [UserWhereInput!] OR: [UserWhereInput!] @@ -228,6 +212,35 @@ input UserUpdateInput { posts: PostRelateToManyInput } +input ImageFieldInput { + upload: Upload + ref: String +} + +""" +The `Upload` scalar type represents a file upload. +""" +scalar Upload + +input FileFieldInput { + upload: Upload + ref: String +} + +input PhoneNumberRelateToManyInput { + create: [PhoneNumberCreateInput] + connect: [PhoneNumberWhereUniqueInput] + disconnect: [PhoneNumberWhereUniqueInput] + disconnectAll: Boolean +} + +input PostRelateToManyInput { + create: [PostCreateInput] + connect: [PostWhereUniqueInput] + disconnect: [PostWhereUniqueInput] + disconnectAll: Boolean +} + input UsersUpdateInput { id: ID! data: UserUpdateInput @@ -249,13 +262,6 @@ input UsersCreateInput { data: UserCreateInput } -input UserRelateToOneInput { - create: UserCreateInput - connect: UserWhereUniqueInput - disconnect: UserWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -317,6 +323,13 @@ input PhoneNumberUpdateInput { value: String } +input UserRelateToOneInput { + create: UserCreateInput + connect: UserWhereUniqueInput + disconnect: UserWhereUniqueInput + disconnectAll: Boolean +} + input PhoneNumbersUpdateInput { id: ID! data: PhoneNumberUpdateInput @@ -332,10 +345,6 @@ input PhoneNumbersCreateInput { data: PhoneNumberCreateInput } -type Post_content_DocumentField { - document(hydrateRelationships: Boolean! = false): JSON! -} - """ A keystone list """ @@ -348,6 +357,10 @@ type Post { author: User } +type Post_content_DocumentField { + document(hydrateRelationships: Boolean! = false): JSON! +} + input PostWhereInput { AND: [PostWhereInput!] OR: [PostWhereInput!] @@ -436,10 +449,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single User item. @@ -541,11 +550,6 @@ type Mutation { endSession: Boolean! } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - union AuthenticatedItem = User union UserAuthenticationWithPasswordResult = diff --git a/examples-staging/basic/schema.prisma b/examples-staging/basic/schema.prisma index a3014a6dd12..fd94a55ccfa 100644 --- a/examples-staging/basic/schema.prisma +++ b/examples-staging/basic/schema.prisma @@ -24,16 +24,16 @@ model User { password String? isAdmin Boolean? roles String? - phoneNumbers PhoneNumber[] @relation("PhoneNumberuser") - posts Post[] @relation("Postauthor") + phoneNumbers PhoneNumber[] @relation("PhoneNumber_user") + posts Post[] @relation("Post_author") } model PhoneNumber { id Int @id @default(autoincrement()) + user User? @relation("PhoneNumber_user", fields: [userId], references: [id]) + userId Int? @map("user") type String? value String? - user User? @relation("PhoneNumberuser", fields: [userId], references: [id]) - userId Int? @map("user") @@index([userId]) } @@ -44,7 +44,7 @@ model Post { status String? content String? publishDate DateTime? - author User? @relation("Postauthor", fields: [authorId], references: [id]) + author User? @relation("Post_author", fields: [authorId], references: [id]) authorId Int? @map("author") @@index([authorId]) diff --git a/examples-staging/basic/schema.ts b/examples-staging/basic/schema.ts index 03ad7ff3afd..558ef856b02 100644 --- a/examples-staging/basic/schema.ts +++ b/examples-staging/basic/schema.ts @@ -12,6 +12,7 @@ import { } from '@keystone-next/fields'; import { document } from '@keystone-next/fields-document'; // import { cloudinaryImage } from '@keystone-next/cloudinary'; +import { schema } from '@keystone-next/types'; import { componentBlocks } from './admin/fieldViews/Content'; // TODO: Can we generate this type based on sessionData in the main config? @@ -82,10 +83,12 @@ export const lists = createSchema({ }), posts: relationship({ ref: 'Post.author', many: true }), randomNumber: virtual({ - graphQLReturnType: 'Float', - resolver() { - return randomNumber(); - }, + field: schema.field({ + type: schema.Float, + resolve() { + return randomNumber(); + }, + }), }), }, }), @@ -96,9 +99,12 @@ export const lists = createSchema({ }, fields: { label: virtual({ - resolver(item) { - return `${item.type} - ${item.value}`; - }, + field: schema.field({ + type: schema.String, + resolve(item) { + return `${item.type} - ${item.value}`; + }, + }), ui: { listView: { fieldMode: 'hidden', diff --git a/examples-staging/ecommerce/schema.graphql b/examples-staging/ecommerce/schema.graphql index bb5feb0d911..f36798ccc07 100644 --- a/examples-staging/ecommerce/schema.graphql +++ b/examples-staging/ecommerce/schema.graphql @@ -1,31 +1,3 @@ -input CartItemRelateToManyInput { - create: [CartItemCreateInput] - connect: [CartItemWhereUniqueInput] - disconnect: [CartItemWhereUniqueInput] - disconnectAll: Boolean -} - -input OrderRelateToManyInput { - create: [OrderCreateInput] - connect: [OrderWhereUniqueInput] - disconnect: [OrderWhereUniqueInput] - disconnectAll: Boolean -} - -input RoleRelateToOneInput { - create: RoleCreateInput - connect: RoleWhereUniqueInput - disconnect: RoleWhereUniqueInput - disconnectAll: Boolean -} - -input ProductRelateToManyInput { - create: [ProductCreateInput] - connect: [ProductWhereUniqueInput] - disconnect: [ProductWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -33,7 +5,7 @@ type User { id: ID! name: String email: String - password_is_set: Boolean + password: PasswordState cart( where: CartItemWhereInput! = {} search: String @@ -42,7 +14,7 @@ type User { orderBy: [CartItemOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [CartItem!]! + ): [CartItem!] _cartMeta( where: CartItemWhereInput! = {} search: String @@ -64,7 +36,7 @@ type User { orderBy: [OrderOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Order!]! + ): [Order!] _ordersMeta( where: OrderWhereInput! = {} search: String @@ -87,7 +59,7 @@ type User { orderBy: [ProductOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Product!]! + ): [Product!] _productsMeta( where: ProductWhereInput! = {} search: String @@ -101,11 +73,19 @@ type User { reason: "This query will be removed in a future version. Please use productsCount instead." ) productsCount(where: ProductWhereInput! = {}): Int - passwordResetToken_is_set: Boolean + passwordResetToken: PasswordState passwordResetIssuedAt: String passwordResetRedeemedAt: String } +type PasswordState { + isSet: Boolean! +} + +type _QueryMeta { + count: Int +} + input UserWhereInput { AND: [UserWhereInput!] OR: [UserWhereInput!] @@ -239,6 +219,34 @@ input UserUpdateInput { passwordResetRedeemedAt: String } +input CartItemRelateToManyInput { + create: [CartItemCreateInput] + connect: [CartItemWhereUniqueInput] + disconnect: [CartItemWhereUniqueInput] + disconnectAll: Boolean +} + +input OrderRelateToManyInput { + create: [OrderCreateInput] + connect: [OrderWhereUniqueInput] + disconnect: [OrderWhereUniqueInput] + disconnectAll: Boolean +} + +input RoleRelateToOneInput { + create: RoleCreateInput + connect: RoleWhereUniqueInput + disconnect: RoleWhereUniqueInput + disconnectAll: Boolean +} + +input ProductRelateToManyInput { + create: [ProductCreateInput] + connect: [ProductWhereUniqueInput] + disconnect: [ProductWhereUniqueInput] + disconnectAll: Boolean +} + input UsersUpdateInput { id: ID! data: UserUpdateInput @@ -261,20 +269,6 @@ input UsersCreateInput { data: UserCreateInput } -input ProductImageRelateToOneInput { - create: ProductImageCreateInput - connect: ProductImageWhereUniqueInput - disconnect: ProductImageWhereUniqueInput - disconnectAll: Boolean -} - -input UserRelateToOneInput { - create: UserCreateInput - connect: UserWhereUniqueInput - disconnect: UserWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -363,6 +357,20 @@ input ProductUpdateInput { user: UserRelateToOneInput } +input ProductImageRelateToOneInput { + create: ProductImageCreateInput + connect: ProductImageWhereUniqueInput + disconnect: ProductImageWhereUniqueInput + disconnectAll: Boolean +} + +input UserRelateToOneInput { + create: UserCreateInput + connect: UserWhereUniqueInput + disconnect: UserWhereUniqueInput + disconnectAll: Boolean +} + input ProductsUpdateInput { id: ID! data: ProductUpdateInput @@ -381,9 +389,18 @@ input ProductsCreateInput { data: ProductCreateInput } +""" + A keystone list +""" +type ProductImage { + id: ID! + image: CloudinaryImage_File + altText: String + product: Product +} + type CloudinaryImage_File { id: ID - path: String filename: String originalFilename: String mimetype: String @@ -431,23 +448,6 @@ input CloudinaryImageFormat { transformation: String } -input ProductRelateToOneInput { - create: ProductCreateInput - connect: ProductWhereUniqueInput - disconnect: ProductWhereUniqueInput - disconnectAll: Boolean -} - -""" - A keystone list -""" -type ProductImage { - id: ID! - image: CloudinaryImage_File - altText: String - product: Product -} - input ProductImageWhereInput { AND: [ProductImageWhereInput!] OR: [ProductImageWhereInput!] @@ -495,6 +495,18 @@ input ProductImageUpdateInput { product: ProductRelateToOneInput } +""" +The `Upload` scalar type represents a file upload. +""" +scalar Upload + +input ProductRelateToOneInput { + create: ProductCreateInput + connect: ProductWhereUniqueInput + disconnect: ProductWhereUniqueInput + disconnectAll: Boolean +} + input ProductImagesUpdateInput { id: ID! data: ProductImageUpdateInput @@ -582,13 +594,6 @@ input CartItemsCreateInput { data: CartItemCreateInput } -input OrderRelateToOneInput { - create: OrderCreateInput - connect: OrderWhereUniqueInput - disconnect: OrderWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -681,6 +686,13 @@ input OrderItemUpdateInput { order: OrderRelateToOneInput } +input OrderRelateToOneInput { + create: OrderCreateInput + connect: OrderWhereUniqueInput + disconnect: OrderWhereUniqueInput + disconnectAll: Boolean +} + input OrderItemsUpdateInput { id: ID! data: OrderItemUpdateInput @@ -699,13 +711,6 @@ input OrderItemsCreateInput { data: OrderItemCreateInput } -input OrderItemRelateToManyInput { - create: [OrderItemCreateInput] - connect: [OrderItemWhereUniqueInput] - disconnect: [OrderItemWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -721,7 +726,7 @@ type Order { orderBy: [OrderItemOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [OrderItem!]! + ): [OrderItem!] _itemsMeta( where: OrderItemWhereInput! = {} search: String @@ -809,6 +814,13 @@ input OrderUpdateInput { charge: String } +input OrderItemRelateToManyInput { + create: [OrderItemCreateInput] + connect: [OrderItemWhereUniqueInput] + disconnect: [OrderItemWhereUniqueInput] + disconnectAll: Boolean +} + input OrdersUpdateInput { id: ID! data: OrderUpdateInput @@ -825,13 +837,6 @@ input OrdersCreateInput { data: OrderCreateInput } -input UserRelateToManyInput { - create: [UserCreateInput] - connect: [UserWhereUniqueInput] - disconnect: [UserWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -852,7 +857,7 @@ type Role { orderBy: [UserOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [User!]! + ): [User!] _assignedToMeta( where: UserWhereInput! = {} search: String @@ -959,6 +964,13 @@ input RoleUpdateInput { assignedTo: UserRelateToManyInput } +input UserRelateToManyInput { + create: [UserCreateInput] + connect: [UserWhereUniqueInput] + disconnect: [UserWhereUniqueInput] + disconnectAll: Boolean +} + input RolesUpdateInput { id: ID! data: RoleUpdateInput @@ -987,10 +999,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single User item. @@ -1219,11 +1227,6 @@ type Mutation { endSession: Boolean! } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - union AuthenticatedItem = User union UserAuthenticationWithPasswordResult = diff --git a/examples-staging/ecommerce/schema.prisma b/examples-staging/ecommerce/schema.prisma index 703e75afab1..32d699e2114 100644 --- a/examples-staging/ecommerce/schema.prisma +++ b/examples-staging/ecommerce/schema.prisma @@ -13,14 +13,14 @@ model User { name String? email String? @unique password String? + cart CartItem[] @relation("CartItem_user") + orders Order[] @relation("Order_user") + role Role? @relation("User_role", fields: [roleId], references: [id]) + roleId Int? @map("role") + products Product[] @relation("Product_user") passwordResetToken String? passwordResetIssuedAt DateTime? passwordResetRedeemedAt DateTime? - cart CartItem[] @relation("CartItemuser") - orders Order[] @relation("Orderuser") - role Role? @relation("Userrole", fields: [roleId], references: [id]) - roleId Int? @map("role") - products Product[] @relation("Productuser") @@index([roleId]) } @@ -29,15 +29,14 @@ model Product { id Int @id @default(autoincrement()) name String? description String? + photo ProductImage? @relation("Product_photo", fields: [photoId], references: [id]) + photoId Int? @unique @map("photo") status String? price Int? - photo ProductImage? @relation("Productphoto", fields: [photoId], references: [id]) - photoId Int? @map("photo") - user User? @relation("Productuser", fields: [userId], references: [id]) + user User? @relation("Product_user", fields: [userId], references: [id]) userId Int? @map("user") - from_CartItem_product CartItem[] @relation("CartItemproduct") + from_CartItem_product CartItem[] @relation("CartItem_product") - @@index([photoId]) @@index([userId]) } @@ -45,16 +44,16 @@ model ProductImage { id Int @id @default(autoincrement()) image String? altText String? - product Product? @relation("Productphoto") - from_OrderItem_photo OrderItem[] @relation("OrderItemphoto") + product Product? @relation("Product_photo") + from_OrderItem_photo OrderItem[] @relation("OrderItem_photo") } model CartItem { id Int @id @default(autoincrement()) quantity Int? - product Product? @relation("CartItemproduct", fields: [productId], references: [id]) + product Product? @relation("CartItem_product", fields: [productId], references: [id]) productId Int? @map("product") - user User? @relation("CartItemuser", fields: [userId], references: [id]) + user User? @relation("CartItem_user", fields: [userId], references: [id]) userId Int? @map("user") @@index([productId]) @@ -65,11 +64,11 @@ model OrderItem { id Int @id @default(autoincrement()) name String? description String? + photo ProductImage? @relation("OrderItem_photo", fields: [photoId], references: [id]) + photoId Int? @map("photo") price Int? quantity Int? - photo ProductImage? @relation("OrderItemphoto", fields: [photoId], references: [id]) - photoId Int? @map("photo") - order Order? @relation("OrderItemorder", fields: [orderId], references: [id]) + order Order? @relation("OrderItem_order", fields: [orderId], references: [id]) orderId Int? @map("order") @@index([photoId]) @@ -79,10 +78,10 @@ model OrderItem { model Order { id Int @id @default(autoincrement()) total Int? - charge String? - items OrderItem[] @relation("OrderItemorder") - user User? @relation("Orderuser", fields: [userId], references: [id]) + items OrderItem[] @relation("OrderItem_order") + user User? @relation("Order_user", fields: [userId], references: [id]) userId Int? @map("user") + charge String? @@index([userId]) } @@ -96,5 +95,5 @@ model Role { canManageRoles Boolean? canManageCart Boolean? canManageOrders Boolean? - assignedTo User[] @relation("Userrole") + assignedTo User[] @relation("User_role") } \ No newline at end of file diff --git a/examples-staging/ecommerce/schemas/Order.ts b/examples-staging/ecommerce/schemas/Order.ts index 0cf872582dc..36b7d1f4df6 100644 --- a/examples-staging/ecommerce/schemas/Order.ts +++ b/examples-staging/ecommerce/schemas/Order.ts @@ -1,5 +1,6 @@ import { integer, text, relationship, virtual } from '@keystone-next/fields'; import { list } from '@keystone-next/keystone/schema'; +import { schema } from '@keystone-next/types'; import { isSignedIn, rules } from '../access'; import formatMoney from '../lib/formatMoney'; @@ -12,10 +13,12 @@ export const Order = list({ }, fields: { label: virtual({ - graphQLReturnType: 'String', - resolver(item) { - return `${formatMoney(item.total)}`; - }, + field: schema.field({ + type: schema.String, + resolve(item) { + return `${formatMoney((item as any).total)}`; + }, + }), }), total: integer(), items: relationship({ ref: 'OrderItem.order', many: true }), diff --git a/examples-staging/embedded-nextjs/schema.graphql b/examples-staging/embedded-nextjs/schema.graphql index 91cc5e7a3a7..b656b80fff0 100644 --- a/examples-staging/embedded-nextjs/schema.graphql +++ b/examples-staging/embedded-nextjs/schema.graphql @@ -131,11 +131,6 @@ type Mutation { deletePosts(ids: [ID!]): [Post] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Post items which match the where clause. diff --git a/examples-staging/graphql-api-endpoint/schema.graphql b/examples-staging/graphql-api-endpoint/schema.graphql index 4f1fe0983fb..fb54e2f7916 100644 --- a/examples-staging/graphql-api-endpoint/schema.graphql +++ b/examples-staging/graphql-api-endpoint/schema.graphql @@ -1,10 +1,3 @@ -input PostRelateToManyInput { - create: [PostCreateInput] - connect: [PostWhereUniqueInput] - disconnect: [PostWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -12,7 +5,7 @@ type User { id: ID! name: String email: String - password_is_set: Boolean + password: PasswordState posts( where: PostWhereInput! = {} search: String @@ -21,7 +14,7 @@ type User { orderBy: [PostOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Post!]! + ): [Post!] _postsMeta( where: PostWhereInput! = {} search: String @@ -37,6 +30,14 @@ type User { postsCount(where: PostWhereInput! = {}): Int } +type PasswordState { + isSet: Boolean! +} + +type _QueryMeta { + count: Int +} + input UserWhereInput { AND: [UserWhereInput!] OR: [UserWhereInput!] @@ -133,6 +134,13 @@ input UserUpdateInput { posts: PostRelateToManyInput } +input PostRelateToManyInput { + create: [PostCreateInput] + connect: [PostWhereUniqueInput] + disconnect: [PostWhereUniqueInput] + disconnectAll: Boolean +} + input UsersUpdateInput { id: ID! data: UserUpdateInput @@ -149,24 +157,6 @@ input UsersCreateInput { data: UserCreateInput } -type Post_content_DocumentField { - document(hydrateRelationships: Boolean! = false): JSON! -} - -input UserRelateToOneInput { - create: UserCreateInput - connect: UserWhereUniqueInput - disconnect: UserWhereUniqueInput - disconnectAll: Boolean -} - -input TagRelateToManyInput { - create: [TagCreateInput] - connect: [TagWhereUniqueInput] - disconnect: [TagWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -185,7 +175,7 @@ type Post { orderBy: [TagOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Tag!]! + ): [Tag!] _tagsMeta( where: TagWhereInput! = {} search: String @@ -201,6 +191,10 @@ type Post { tagsCount(where: TagWhereInput! = {}): Int } +type Post_content_DocumentField { + document(hydrateRelationships: Boolean! = false): JSON! +} + input PostWhereInput { AND: [PostWhereInput!] OR: [PostWhereInput!] @@ -292,6 +286,20 @@ input PostUpdateInput { tags: TagRelateToManyInput } +input UserRelateToOneInput { + create: UserCreateInput + connect: UserWhereUniqueInput + disconnect: UserWhereUniqueInput + disconnectAll: Boolean +} + +input TagRelateToManyInput { + create: [TagCreateInput] + connect: [TagWhereUniqueInput] + disconnect: [TagWhereUniqueInput] + disconnectAll: Boolean +} + input PostsUpdateInput { id: ID! data: PostUpdateInput @@ -324,7 +332,7 @@ type Tag { orderBy: [PostOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Post!]! + ): [Post!] _postsMeta( where: PostWhereInput! = {} search: String @@ -429,10 +437,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single User item. @@ -533,11 +537,6 @@ type Mutation { endSession: Boolean! } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - union AuthenticatedItem = User union UserAuthenticationWithPasswordResult = diff --git a/examples-staging/graphql-api-endpoint/schema.prisma b/examples-staging/graphql-api-endpoint/schema.prisma index 333e5272110..1fa240bd068 100644 --- a/examples-staging/graphql-api-endpoint/schema.prisma +++ b/examples-staging/graphql-api-endpoint/schema.prisma @@ -13,7 +13,7 @@ model User { name String? email String? @unique password String? - posts Post[] @relation("Postauthor") + posts Post[] @relation("Post_author") } model Post { @@ -22,9 +22,9 @@ model Post { status String? content Json? publishDate DateTime? - author User? @relation("Postauthor", fields: [authorId], references: [id]) + author User? @relation("Post_author", fields: [authorId], references: [id]) authorId Int? @map("author") - tags Tag[] @relation("Post_tags_Tag_posts", references: [id]) + tags Tag[] @relation("Post_tags_Tag_posts") @@index([authorId]) } @@ -32,5 +32,5 @@ model Post { model Tag { id Int @id @default(autoincrement()) name String? - posts Post[] @relation("Post_tags_Tag_posts", references: [id]) + posts Post[] @relation("Post_tags_Tag_posts") } \ No newline at end of file diff --git a/examples-staging/roles/schema.graphql b/examples-staging/roles/schema.graphql index b3f0a57dfcc..cac317ac0fe 100644 --- a/examples-staging/roles/schema.graphql +++ b/examples-staging/roles/schema.graphql @@ -1,10 +1,3 @@ -input PersonRelateToOneInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput - disconnect: PersonWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -75,6 +68,13 @@ input TodoUpdateInput { assignedTo: PersonRelateToOneInput } +input PersonRelateToOneInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput + disconnect: PersonWhereUniqueInput + disconnectAll: Boolean +} + input TodosUpdateInput { id: ID! data: TodoUpdateInput @@ -91,20 +91,6 @@ input TodosCreateInput { data: TodoCreateInput } -input RoleRelateToOneInput { - create: RoleCreateInput - connect: RoleWhereUniqueInput - disconnect: RoleWhereUniqueInput - disconnectAll: Boolean -} - -input TodoRelateToManyInput { - create: [TodoCreateInput] - connect: [TodoWhereUniqueInput] - disconnect: [TodoWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -112,7 +98,7 @@ type Person { id: ID! name: String email: String - password_is_set: Boolean + password: PasswordState role: Role tasks( where: TodoWhereInput! = {} @@ -122,7 +108,7 @@ type Person { orderBy: [TodoOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Todo!]! + ): [Todo!] _tasksMeta( where: TodoWhereInput! = {} search: String @@ -138,6 +124,14 @@ type Person { tasksCount(where: TodoWhereInput! = {}): Int } +type PasswordState { + isSet: Boolean! +} + +type _QueryMeta { + count: Int +} + input PersonWhereInput { AND: [PersonWhereInput!] OR: [PersonWhereInput!] @@ -208,6 +202,20 @@ input PersonUpdateInput { tasks: TodoRelateToManyInput } +input RoleRelateToOneInput { + create: RoleCreateInput + connect: RoleWhereUniqueInput + disconnect: RoleWhereUniqueInput + disconnectAll: Boolean +} + +input TodoRelateToManyInput { + create: [TodoCreateInput] + connect: [TodoWhereUniqueInput] + disconnect: [TodoWhereUniqueInput] + disconnectAll: Boolean +} + input PeopleUpdateInput { id: ID! data: PersonUpdateInput @@ -225,13 +233,6 @@ input PeopleCreateInput { data: PersonCreateInput } -input PersonRelateToManyInput { - create: [PersonCreateInput] - connect: [PersonWhereUniqueInput] - disconnect: [PersonWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -252,7 +253,7 @@ type Role { orderBy: [PersonOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Person!]! + ): [Person!] _assignedToMeta( where: PersonWhereInput! = {} search: String @@ -359,6 +360,13 @@ input RoleUpdateInput { assignedTo: PersonRelateToManyInput } +input PersonRelateToManyInput { + create: [PersonCreateInput] + connect: [PersonWhereUniqueInput] + disconnect: [PersonWhereUniqueInput] + disconnectAll: Boolean +} + input RolesUpdateInput { id: ID! data: RoleUpdateInput @@ -387,10 +395,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Todo item. @@ -491,11 +495,6 @@ type Mutation { endSession: Boolean! } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - union AuthenticatedItem = Person union PersonAuthenticationWithPasswordResult = diff --git a/examples-staging/roles/schema.prisma b/examples-staging/roles/schema.prisma index 64a338af826..16c3cebe0a4 100644 --- a/examples-staging/roles/schema.prisma +++ b/examples-staging/roles/schema.prisma @@ -13,7 +13,7 @@ model Todo { label String? isComplete Boolean? isPrivate Boolean? - assignedTo Person? @relation("TodoassignedTo", fields: [assignedToId], references: [id]) + assignedTo Person? @relation("Todo_assignedTo", fields: [assignedToId], references: [id]) assignedToId Int? @map("assignedTo") @@index([assignedToId]) @@ -24,9 +24,9 @@ model Person { name String? email String? password String? - role Role? @relation("Personrole", fields: [roleId], references: [id]) + role Role? @relation("Person_role", fields: [roleId], references: [id]) roleId Int? @map("role") - tasks Todo[] @relation("TodoassignedTo") + tasks Todo[] @relation("Todo_assignedTo") @@index([roleId]) } @@ -40,5 +40,5 @@ model Role { canEditOtherPeople Boolean? canManagePeople Boolean? canManageRoles Boolean? - assignedTo Person[] @relation("Personrole") + assignedTo Person[] @relation("Person_role") } \ No newline at end of file diff --git a/examples-staging/sandbox/schema.graphql b/examples-staging/sandbox/schema.graphql index fb097c58dda..431231c1467 100644 --- a/examples-staging/sandbox/schema.graphql +++ b/examples-staging/sandbox/schema.graphql @@ -1,10 +1,3 @@ -input UserRelateToOneInput { - create: UserCreateInput - connect: UserWhereUniqueInput - disconnect: UserWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -105,6 +98,13 @@ input TodoUpdateInput { finishBy: String } +input UserRelateToOneInput { + create: UserCreateInput + connect: UserWhereUniqueInput + disconnect: UserWhereUniqueInput + disconnectAll: Boolean +} + input TodosUpdateInput { id: ID! data: TodoUpdateInput @@ -121,13 +121,6 @@ input TodosCreateInput { data: TodoCreateInput } -input TodoRelateToManyInput { - create: [TodoCreateInput] - connect: [TodoWhereUniqueInput] - disconnect: [TodoWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -135,7 +128,7 @@ type User { id: ID! name: String email: String - password_is_set: Boolean + password: PasswordState tasks( where: TodoWhereInput! = {} search: String @@ -144,7 +137,7 @@ type User { orderBy: [TodoOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Todo!]! + ): [Todo!] _tasksMeta( where: TodoWhereInput! = {} search: String @@ -162,6 +155,14 @@ type User { updatedAt: String } +type PasswordState { + isSet: Boolean! +} + +type _QueryMeta { + count: Int +} + input UserWhereInput { AND: [UserWhereInput!] OR: [UserWhereInput!] @@ -251,6 +252,13 @@ input UserUpdateInput { tasks: TodoRelateToManyInput } +input TodoRelateToManyInput { + create: [TodoCreateInput] + connect: [TodoWhereUniqueInput] + disconnect: [TodoWhereUniqueInput] + disconnectAll: Boolean +} + input UsersUpdateInput { id: ID! data: UserUpdateInput @@ -275,10 +283,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Todo item. @@ -341,11 +345,6 @@ type Mutation { deleteUsers(ids: [ID!]): [User] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Todo items which match the where clause. diff --git a/examples-staging/sandbox/schema.prisma b/examples-staging/sandbox/schema.prisma index 015ad86724f..e15c79ea388 100644 --- a/examples-staging/sandbox/schema.prisma +++ b/examples-staging/sandbox/schema.prisma @@ -12,11 +12,11 @@ model Todo { id Int @id @default(autoincrement()) label String? isComplete Boolean? + assignedTo User? @relation("Todo_assignedTo", fields: [assignedToId], references: [id]) + assignedToId Int? @map("assignedTo") finishBy DateTime? createdAt DateTime? updatedAt DateTime? - assignedTo User? @relation("TodoassignedTo", fields: [assignedToId], references: [id]) - assignedToId Int? @map("assignedTo") @@index([assignedToId]) } @@ -26,7 +26,7 @@ model User { name String? email String? password String? + tasks Todo[] @relation("Todo_assignedTo") createdAt DateTime? updatedAt DateTime? - tasks Todo[] @relation("TodoassignedTo") } \ No newline at end of file diff --git a/examples/blog/schema.graphql b/examples/blog/schema.graphql index 8b9ba8cf856..a0aec6b1030 100644 --- a/examples/blog/schema.graphql +++ b/examples/blog/schema.graphql @@ -1,15 +1,3 @@ -enum PostStatusType { - draft - published -} - -input AuthorRelateToOneInput { - create: AuthorCreateInput - connect: AuthorWhereUniqueInput - disconnect: AuthorWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -22,6 +10,11 @@ type Post { author: Author } +enum PostStatusType { + draft + published +} + input PostWhereInput { AND: [PostWhereInput!] OR: [PostWhereInput!] @@ -99,6 +92,13 @@ input PostUpdateInput { author: AuthorRelateToOneInput } +input AuthorRelateToOneInput { + create: AuthorCreateInput + connect: AuthorWhereUniqueInput + disconnect: AuthorWhereUniqueInput + disconnectAll: Boolean +} + input PostsUpdateInput { id: ID! data: PostUpdateInput @@ -116,13 +116,6 @@ input PostsCreateInput { data: PostCreateInput } -input PostRelateToManyInput { - create: [PostCreateInput] - connect: [PostWhereUniqueInput] - disconnect: [PostWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -138,7 +131,7 @@ type Author { orderBy: [PostOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Post!]! + ): [Post!] _postsMeta( where: PostWhereInput! = {} search: String @@ -154,6 +147,10 @@ type Author { postsCount(where: PostWhereInput! = {}): Int } +type _QueryMeta { + count: Int +} + input AuthorWhereInput { AND: [AuthorWhereInput!] OR: [AuthorWhereInput!] @@ -219,6 +216,13 @@ input AuthorUpdateInput { posts: PostRelateToManyInput } +input PostRelateToManyInput { + create: [PostCreateInput] + connect: [PostWhereUniqueInput] + disconnect: [PostWhereUniqueInput] + disconnectAll: Boolean +} + input AuthorsUpdateInput { id: ID! data: AuthorUpdateInput @@ -242,10 +246,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Post item. @@ -308,11 +308,6 @@ type Mutation { deleteAuthors(ids: [ID!]): [Author] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Post items which match the where clause. diff --git a/examples/blog/schema.prisma b/examples/blog/schema.prisma index 501bbc7b0b6..61c4b3fcb9b 100644 --- a/examples/blog/schema.prisma +++ b/examples/blog/schema.prisma @@ -14,7 +14,7 @@ model Post { status String? content String? publishDate DateTime? - author Author? @relation("Postauthor", fields: [authorId], references: [id]) + author Author? @relation("Post_author", fields: [authorId], references: [id]) authorId Int? @map("author") @@index([authorId]) @@ -24,5 +24,5 @@ model Author { id Int @id @default(autoincrement()) name String? email String? @unique - posts Post[] @relation("Postauthor") + posts Post[] @relation("Post_author") } \ No newline at end of file diff --git a/examples/default-values/schema.graphql b/examples/default-values/schema.graphql index 7332ebabca3..10b53cba38f 100644 --- a/examples/default-values/schema.graphql +++ b/examples/default-values/schema.graphql @@ -1,16 +1,3 @@ -enum TaskPriorityType { - low - medium - high -} - -input PersonRelateToOneInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput - disconnect: PersonWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -23,6 +10,12 @@ type Task { finishBy: String } +enum TaskPriorityType { + low + medium + high +} + input TaskWhereInput { AND: [TaskWhereInput!] OR: [TaskWhereInput!] @@ -96,6 +89,13 @@ input TaskUpdateInput { finishBy: String } +input PersonRelateToOneInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput + disconnect: PersonWhereUniqueInput + disconnectAll: Boolean +} + input TasksUpdateInput { id: ID! data: TaskUpdateInput @@ -113,13 +113,6 @@ input TasksCreateInput { data: TaskCreateInput } -input TaskRelateToManyInput { - create: [TaskCreateInput] - connect: [TaskWhereUniqueInput] - disconnect: [TaskWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -134,7 +127,7 @@ type Person { orderBy: [TaskOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Task!]! + ): [Task!] _tasksMeta( where: TaskWhereInput! = {} search: String @@ -150,6 +143,10 @@ type Person { tasksCount(where: TaskWhereInput! = {}): Int } +type _QueryMeta { + count: Int +} + input PersonWhereInput { AND: [PersonWhereInput!] OR: [PersonWhereInput!] @@ -205,6 +202,13 @@ input PersonUpdateInput { tasks: TaskRelateToManyInput } +input TaskRelateToManyInput { + create: [TaskCreateInput] + connect: [TaskWhereUniqueInput] + disconnect: [TaskWhereUniqueInput] + disconnectAll: Boolean +} + input PeopleUpdateInput { id: ID! data: PersonUpdateInput @@ -227,10 +231,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Task item. @@ -293,11 +293,6 @@ type Mutation { deletePeople(ids: [ID!]): [Person] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Task items which match the where clause. diff --git a/examples/default-values/schema.prisma b/examples/default-values/schema.prisma index 6635b24e6bd..176359fb703 100644 --- a/examples/default-values/schema.prisma +++ b/examples/default-values/schema.prisma @@ -13,9 +13,9 @@ model Task { label String? priority String? isComplete Boolean? - finishBy DateTime? - assignedTo Person? @relation("TaskassignedTo", fields: [assignedToId], references: [id]) + assignedTo Person? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) assignedToId Int? @map("assignedTo") + finishBy DateTime? @@index([assignedToId]) } @@ -23,5 +23,5 @@ model Task { model Person { id Int @id @default(autoincrement()) name String? - tasks Task[] @relation("TaskassignedTo") + tasks Task[] @relation("Task_assignedTo") } \ No newline at end of file diff --git a/examples/extend-graphql-schema/schema.graphql b/examples/extend-graphql-schema/schema.graphql index 99db59583c5..4f83e17da39 100644 --- a/examples/extend-graphql-schema/schema.graphql +++ b/examples/extend-graphql-schema/schema.graphql @@ -1,15 +1,3 @@ -enum PostStatusType { - draft - published -} - -input AuthorRelateToOneInput { - create: AuthorCreateInput - connect: AuthorWhereUniqueInput - disconnect: AuthorWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -22,6 +10,11 @@ type Post { author: Author } +enum PostStatusType { + draft + published +} + input PostWhereInput { AND: [PostWhereInput!] OR: [PostWhereInput!] @@ -99,6 +92,13 @@ input PostUpdateInput { author: AuthorRelateToOneInput } +input AuthorRelateToOneInput { + create: AuthorCreateInput + connect: AuthorWhereUniqueInput + disconnect: AuthorWhereUniqueInput + disconnectAll: Boolean +} + input PostsUpdateInput { id: ID! data: PostUpdateInput @@ -116,13 +116,6 @@ input PostsCreateInput { data: PostCreateInput } -input PostRelateToManyInput { - create: [PostCreateInput] - connect: [PostWhereUniqueInput] - disconnect: [PostWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -138,7 +131,7 @@ type Author { orderBy: [PostOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Post!]! + ): [Post!] _postsMeta( where: PostWhereInput! = {} search: String @@ -154,6 +147,10 @@ type Author { postsCount(where: PostWhereInput! = {}): Int } +type _QueryMeta { + count: Int +} + input AuthorWhereInput { AND: [AuthorWhereInput!] OR: [AuthorWhereInput!] @@ -219,6 +216,13 @@ input AuthorUpdateInput { posts: PostRelateToManyInput } +input PostRelateToManyInput { + create: [PostCreateInput] + connect: [PostWhereUniqueInput] + disconnect: [PostWhereUniqueInput] + disconnectAll: Boolean +} + input AuthorsUpdateInput { id: ID! data: AuthorUpdateInput @@ -242,10 +246,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Post item. @@ -313,11 +313,6 @@ type Mutation { publishPost(id: ID!): Post } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - """ A custom type to represent statistics for a user """ diff --git a/examples/extend-graphql-schema/schema.prisma b/examples/extend-graphql-schema/schema.prisma index 501bbc7b0b6..61c4b3fcb9b 100644 --- a/examples/extend-graphql-schema/schema.prisma +++ b/examples/extend-graphql-schema/schema.prisma @@ -14,7 +14,7 @@ model Post { status String? content String? publishDate DateTime? - author Author? @relation("Postauthor", fields: [authorId], references: [id]) + author Author? @relation("Post_author", fields: [authorId], references: [id]) authorId Int? @map("author") @@index([authorId]) @@ -24,5 +24,5 @@ model Author { id Int @id @default(autoincrement()) name String? email String? @unique - posts Post[] @relation("Postauthor") + posts Post[] @relation("Post_author") } \ No newline at end of file diff --git a/examples/json/schema.graphql b/examples/json/schema.graphql index 70576187f74..56c1362bf18 100644 --- a/examples/json/schema.graphql +++ b/examples/json/schema.graphql @@ -1,10 +1,3 @@ -input PersonRelateToOneInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput - disconnect: PersonWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -70,6 +63,13 @@ input PackageUpdateInput { ownedBy: PersonRelateToOneInput } +input PersonRelateToOneInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput + disconnect: PersonWhereUniqueInput + disconnectAll: Boolean +} + input PackagesUpdateInput { id: ID! data: PackageUpdateInput @@ -86,13 +86,6 @@ input PackagesCreateInput { data: PackageCreateInput } -input PackageRelateToManyInput { - create: [PackageCreateInput] - connect: [PackageWhereUniqueInput] - disconnect: [PackageWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -107,7 +100,7 @@ type Person { orderBy: [PackageOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Package!]! + ): [Package!] _packagesMeta( where: PackageWhereInput! = {} search: String @@ -123,6 +116,10 @@ type Person { packagesCount(where: PackageWhereInput! = {}): Int } +type _QueryMeta { + count: Int +} + input PersonWhereInput { AND: [PersonWhereInput!] OR: [PersonWhereInput!] @@ -178,6 +175,13 @@ input PersonUpdateInput { packages: PackageRelateToManyInput } +input PackageRelateToManyInput { + create: [PackageCreateInput] + connect: [PackageWhereUniqueInput] + disconnect: [PackageWhereUniqueInput] + disconnectAll: Boolean +} + input PeopleUpdateInput { id: ID! data: PersonUpdateInput @@ -200,10 +204,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Package item. @@ -266,11 +266,6 @@ type Mutation { deletePeople(ids: [ID!]): [Person] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Package items which match the where clause. diff --git a/examples/json/schema.prisma b/examples/json/schema.prisma index b3364662a5c..27d0c46a2e0 100644 --- a/examples/json/schema.prisma +++ b/examples/json/schema.prisma @@ -13,7 +13,7 @@ model Package { label String? pkgjson String? isPrivate Boolean? - ownedBy Person? @relation("PackageownedBy", fields: [ownedById], references: [id]) + ownedBy Person? @relation("Package_ownedBy", fields: [ownedById], references: [id]) ownedById Int? @map("ownedBy") @@index([ownedById]) @@ -22,5 +22,5 @@ model Package { model Person { id Int @id @default(autoincrement()) name String? - packages Package[] @relation("PackageownedBy") + packages Package[] @relation("Package_ownedBy") } \ No newline at end of file diff --git a/examples/task-manager/schema.graphql b/examples/task-manager/schema.graphql index 7332ebabca3..10b53cba38f 100644 --- a/examples/task-manager/schema.graphql +++ b/examples/task-manager/schema.graphql @@ -1,16 +1,3 @@ -enum TaskPriorityType { - low - medium - high -} - -input PersonRelateToOneInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput - disconnect: PersonWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -23,6 +10,12 @@ type Task { finishBy: String } +enum TaskPriorityType { + low + medium + high +} + input TaskWhereInput { AND: [TaskWhereInput!] OR: [TaskWhereInput!] @@ -96,6 +89,13 @@ input TaskUpdateInput { finishBy: String } +input PersonRelateToOneInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput + disconnect: PersonWhereUniqueInput + disconnectAll: Boolean +} + input TasksUpdateInput { id: ID! data: TaskUpdateInput @@ -113,13 +113,6 @@ input TasksCreateInput { data: TaskCreateInput } -input TaskRelateToManyInput { - create: [TaskCreateInput] - connect: [TaskWhereUniqueInput] - disconnect: [TaskWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -134,7 +127,7 @@ type Person { orderBy: [TaskOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Task!]! + ): [Task!] _tasksMeta( where: TaskWhereInput! = {} search: String @@ -150,6 +143,10 @@ type Person { tasksCount(where: TaskWhereInput! = {}): Int } +type _QueryMeta { + count: Int +} + input PersonWhereInput { AND: [PersonWhereInput!] OR: [PersonWhereInput!] @@ -205,6 +202,13 @@ input PersonUpdateInput { tasks: TaskRelateToManyInput } +input TaskRelateToManyInput { + create: [TaskCreateInput] + connect: [TaskWhereUniqueInput] + disconnect: [TaskWhereUniqueInput] + disconnectAll: Boolean +} + input PeopleUpdateInput { id: ID! data: PersonUpdateInput @@ -227,10 +231,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Task item. @@ -293,11 +293,6 @@ type Mutation { deletePeople(ids: [ID!]): [Person] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Task items which match the where clause. diff --git a/examples/task-manager/schema.prisma b/examples/task-manager/schema.prisma index 6635b24e6bd..176359fb703 100644 --- a/examples/task-manager/schema.prisma +++ b/examples/task-manager/schema.prisma @@ -13,9 +13,9 @@ model Task { label String? priority String? isComplete Boolean? - finishBy DateTime? - assignedTo Person? @relation("TaskassignedTo", fields: [assignedToId], references: [id]) + assignedTo Person? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) assignedToId Int? @map("assignedTo") + finishBy DateTime? @@index([assignedToId]) } @@ -23,5 +23,5 @@ model Task { model Person { id Int @id @default(autoincrement()) name String? - tasks Task[] @relation("TaskassignedTo") + tasks Task[] @relation("Task_assignedTo") } \ No newline at end of file diff --git a/examples/with-auth/schema.graphql b/examples/with-auth/schema.graphql index 9b3a8893dfa..1cb67073207 100644 --- a/examples/with-auth/schema.graphql +++ b/examples/with-auth/schema.graphql @@ -1,16 +1,3 @@ -enum TaskPriorityType { - low - medium - high -} - -input PersonRelateToOneInput { - create: PersonCreateInput - connect: PersonWhereUniqueInput - disconnect: PersonWhereUniqueInput - disconnectAll: Boolean -} - """ A keystone list """ @@ -23,6 +10,12 @@ type Task { finishBy: String } +enum TaskPriorityType { + low + medium + high +} + input TaskWhereInput { AND: [TaskWhereInput!] OR: [TaskWhereInput!] @@ -96,6 +89,13 @@ input TaskUpdateInput { finishBy: String } +input PersonRelateToOneInput { + create: PersonCreateInput + connect: PersonWhereUniqueInput + disconnect: PersonWhereUniqueInput + disconnectAll: Boolean +} + input TasksUpdateInput { id: ID! data: TaskUpdateInput @@ -113,13 +113,6 @@ input TasksCreateInput { data: TaskCreateInput } -input TaskRelateToManyInput { - create: [TaskCreateInput] - connect: [TaskWhereUniqueInput] - disconnect: [TaskWhereUniqueInput] - disconnectAll: Boolean -} - """ A keystone list """ @@ -127,7 +120,7 @@ type Person { id: ID! name: String email: String - password_is_set: Boolean + password: PasswordState tasks( where: TaskWhereInput! = {} search: String @@ -136,7 +129,7 @@ type Person { orderBy: [TaskOrderByInput!]! = [] first: Int skip: Int! = 0 - ): [Task!]! + ): [Task!] _tasksMeta( where: TaskWhereInput! = {} search: String @@ -152,6 +145,14 @@ type Person { tasksCount(where: TaskWhereInput! = {}): Int } +type PasswordState { + isSet: Boolean! +} + +type _QueryMeta { + count: Int +} + input PersonWhereInput { AND: [PersonWhereInput!] OR: [PersonWhereInput!] @@ -219,6 +220,13 @@ input PersonUpdateInput { tasks: TaskRelateToManyInput } +input TaskRelateToManyInput { + create: [TaskCreateInput] + connect: [TaskWhereUniqueInput] + disconnect: [TaskWhereUniqueInput] + disconnectAll: Boolean +} + input PeopleUpdateInput { id: ID! data: PersonUpdateInput @@ -243,10 +251,6 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) -type _QueryMeta { - count: Int -} - type Mutation { """ Create a single Task item. @@ -317,11 +321,6 @@ type Mutation { endSession: Boolean! } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - union AuthenticatedItem = Person union PersonAuthenticationWithPasswordResult = diff --git a/examples/with-auth/schema.prisma b/examples/with-auth/schema.prisma index 40835e596da..089eae08d28 100644 --- a/examples/with-auth/schema.prisma +++ b/examples/with-auth/schema.prisma @@ -13,9 +13,9 @@ model Task { label String? priority String? isComplete Boolean? - finishBy DateTime? - assignedTo Person? @relation("TaskassignedTo", fields: [assignedToId], references: [id]) + assignedTo Person? @relation("Task_assignedTo", fields: [assignedToId], references: [id]) assignedToId Int? @map("assignedTo") + finishBy DateTime? @@index([assignedToId]) } @@ -25,5 +25,5 @@ model Person { name String? email String? @unique password String? - tasks Task[] @relation("TaskassignedTo") + tasks Task[] @relation("Task_assignedTo") } \ No newline at end of file diff --git a/packages-next/auth/src/gql/getBaseAuthSchema.ts b/packages-next/auth/src/gql/getBaseAuthSchema.ts index 33809699814..c5b54da3310 100644 --- a/packages-next/auth/src/gql/getBaseAuthSchema.ts +++ b/packages-next/auth/src/gql/getBaseAuthSchema.ts @@ -1,6 +1,6 @@ import type { GraphQLSchemaExtension, KeystoneContext } from '@keystone-next/types'; -import { AuthGqlNames } from '../types'; +import { AuthGqlNames, SecretFieldImpl } from '../types'; import { validateSecret } from '../lib/validateSecret'; import { getPasswordAuthError } from '../lib/getErrorMessage'; @@ -11,12 +11,14 @@ export function getBaseAuthSchema({ secretField, protectIdentities, gqlNames, + secretFieldImpl, }: { listKey: string; identityField: I; secretField: S; protectIdentities: boolean; gqlNames: AuthGqlNames; + secretFieldImpl: SecretFieldImpl; }): GraphQLSchemaExtension { return { typeDefs: ` @@ -57,10 +59,9 @@ export function getBaseAuthSchema({ throw new Error('No session implementation available on context'); } - const list = context.keystone.lists[listKey]; const dbItemAPI = context.sudo().db.lists[listKey]; const result = await validateSecret( - list, + secretFieldImpl, identityField, args[identityField], secretField, @@ -73,26 +74,25 @@ export function getBaseAuthSchema({ const message = getPasswordAuthError({ identityField, secretField, - itemSingular: list.adminUILabels.singular, - itemPlural: list.adminUILabels.plural, + listKey, + context, code: result.code, }); return { code: result.code, message }; } // Update system state - const sessionToken = await context.startSession({ listKey, itemId: result.item.id }); + const sessionToken = await context.startSession({ + listKey, + itemId: result.item.id.toString(), + }); return { sessionToken, item: result.item }; }, }, Query: { async authenticatedItem(root, args, { session, db }) { if (typeof session?.itemId === 'string' && typeof session.listKey === 'string') { - try { - return db.lists[session.listKey].findOne({ where: { id: session.itemId } }); - } catch (e) { - return null; - } + return db.lists[session.listKey].findOne({ where: { id: session.itemId } }); } return null; }, diff --git a/packages-next/auth/src/gql/getInitFirstItemSchema.ts b/packages-next/auth/src/gql/getInitFirstItemSchema.ts index bc7ed8c4ec5..e5a6648c5d2 100644 --- a/packages-next/auth/src/gql/getInitFirstItemSchema.ts +++ b/packages-next/auth/src/gql/getInitFirstItemSchema.ts @@ -54,8 +54,11 @@ export function getInitFirstItemSchema({ } // Update system state + // this is strictly speaking incorrect. the db API will do GraphQL coercion on a value which has already been coerced + // (this is also mostly fine, the chance that people are using things where + // the input value can't round-trip like the Upload scalar here is quite low) const item = await dbItemAPI.createOne({ data: { ...data, ...itemData } }); - const sessionToken = await context.startSession({ listKey, itemId: item.id }); + const sessionToken = await context.startSession({ listKey, itemId: item.id.toString() }); return { item, sessionToken }; }, }, diff --git a/packages-next/auth/src/gql/getMagicAuthLinkSchema.ts b/packages-next/auth/src/gql/getMagicAuthLinkSchema.ts index ad1174ce7f9..e0d7118a166 100644 --- a/packages-next/auth/src/gql/getMagicAuthLinkSchema.ts +++ b/packages-next/auth/src/gql/getMagicAuthLinkSchema.ts @@ -1,6 +1,6 @@ import type { GraphQLSchemaExtension } from '@keystone-next/types'; -import { AuthGqlNames, AuthTokenTypeConfig } from '../types'; +import { AuthGqlNames, AuthTokenTypeConfig, SecretFieldImpl } from '../types'; import { createAuthToken } from '../lib/createAuthToken'; import { validateAuthToken } from '../lib/validateAuthToken'; @@ -12,12 +12,14 @@ export function getMagicAuthLinkSchema({ protectIdentities, gqlNames, magicAuthLink, + magicAuthTokenSecretFieldImpl, }: { listKey: string; identityField: I; protectIdentities: boolean; gqlNames: AuthGqlNames; magicAuthLink: AuthTokenTypeConfig; + magicAuthTokenSecretFieldImpl: SecretFieldImpl; }): GraphQLSchemaExtension { return { typeDefs: ` @@ -56,7 +58,6 @@ export function getMagicAuthLinkSchema({ resolvers: { Mutation: { async [gqlNames.sendItemMagicAuthLink](root: any, args: { [P in I]: string }, context) { - const list = context.keystone.lists[listKey]; const dbItemAPI = context.sudo().db.lists[listKey]; const tokenType = 'magicAuth'; const identity = args[identityField]; @@ -73,8 +74,8 @@ export function getMagicAuthLinkSchema({ if (!result.success && result.code) { const message = getAuthTokenErrorMessage({ identityField, - itemSingular: list.adminUILabels.singular, - itemPlural: list.adminUILabels.plural, + listKey, + context, code: result.code, }); return { code: result.code, message }; @@ -106,12 +107,12 @@ export function getMagicAuthLinkSchema({ throw new Error('No session implementation available on context'); } - const list = context.keystone.lists[listKey]; const dbItemAPI = context.sudo().db.lists[listKey]; const tokenType = 'magicAuth'; const result = await validateAuthToken( + listKey, + magicAuthTokenSecretFieldImpl, tokenType, - list, identityField, args[identityField], protectIdentities, @@ -123,8 +124,8 @@ export function getMagicAuthLinkSchema({ if (!result.success) { const message = getAuthTokenErrorMessage({ identityField, - itemSingular: list.adminUILabels.singular, - itemPlural: list.adminUILabels.plural, + listKey, + context, code: result.code, }); @@ -137,7 +138,10 @@ export function getMagicAuthLinkSchema({ data: { [`${tokenType}RedeemedAt`]: new Date().toISOString() }, }); - const sessionToken = await context.startSession({ listKey, itemId: result.item.id }); + const sessionToken = await context.startSession({ + listKey, + itemId: result.item.id.toString(), + }); return { token: sessionToken, item: result.item }; }, }, diff --git a/packages-next/auth/src/gql/getPasswordResetSchema.ts b/packages-next/auth/src/gql/getPasswordResetSchema.ts index d0c402d371a..aeeb6977e1b 100644 --- a/packages-next/auth/src/gql/getPasswordResetSchema.ts +++ b/packages-next/auth/src/gql/getPasswordResetSchema.ts @@ -1,6 +1,6 @@ import type { GraphQLSchemaExtension } from '@keystone-next/types'; -import { AuthGqlNames, AuthTokenTypeConfig } from '../types'; +import { AuthGqlNames, AuthTokenTypeConfig, SecretFieldImpl } from '../types'; import { createAuthToken } from '../lib/createAuthToken'; import { validateAuthToken } from '../lib/validateAuthToken'; @@ -13,6 +13,7 @@ export function getPasswordResetSchema({ protectIdentities, gqlNames, passwordResetLink, + passwordResetTokenSecretFieldImpl, }: { listKey: string; identityField: I; @@ -20,6 +21,7 @@ export function getPasswordResetSchema({ protectIdentities: boolean; gqlNames: AuthGqlNames; passwordResetLink: AuthTokenTypeConfig; + passwordResetTokenSecretFieldImpl: SecretFieldImpl; }): GraphQLSchemaExtension { return { typeDefs: ` @@ -61,7 +63,6 @@ export function getPasswordResetSchema({ resolvers: { Mutation: { async [gqlNames.sendItemPasswordResetLink](root: any, args: { [P in I]: string }, context) { - const list = context.keystone.lists[listKey]; const dbItemAPI = context.sudo().db.lists[listKey]; const tokenType = 'passwordReset'; const identity = args[identityField]; @@ -78,8 +79,8 @@ export function getPasswordResetSchema({ if (!result.success && result.code) { const message = getAuthTokenErrorMessage({ identityField, - itemSingular: list.adminUILabels.singular, - itemPlural: list.adminUILabels.plural, + context, + listKey, code: result.code, }); return { code: result.code, message }; @@ -107,12 +108,12 @@ export function getPasswordResetSchema({ args: { [P in I]: string } & { [P in S]: string } & { token: string }, context ) { - const list = context.keystone.lists[listKey]; const dbItemAPI = context.sudo().db.lists[listKey]; const tokenType = 'passwordReset'; const result = await validateAuthToken( + listKey, + passwordResetTokenSecretFieldImpl, tokenType, - list, identityField, args[identityField], protectIdentities, @@ -127,8 +128,8 @@ export function getPasswordResetSchema({ // or 'The auth token provided has expired.' const message = getAuthTokenErrorMessage({ identityField, - itemSingular: list.adminUILabels.singular, - itemPlural: list.adminUILabels.plural, + listKey, + context, code: result.code, }); return { code: result.code, message }; @@ -159,12 +160,12 @@ export function getPasswordResetSchema({ args: { [P in I]: string } & { token: string }, context ) { - const list = context.keystone.lists[listKey]; const dbItemAPI = context.sudo().db.lists[listKey]; const tokenType = 'passwordReset'; const result = await validateAuthToken( + listKey, + passwordResetTokenSecretFieldImpl, tokenType, - list, identityField, args[identityField], protectIdentities, @@ -176,8 +177,8 @@ export function getPasswordResetSchema({ if (!result.success && result.code) { const message = getAuthTokenErrorMessage({ identityField, - itemSingular: list.adminUILabels.singular, - itemPlural: list.adminUILabels.plural, + listKey, + context, code: result.code, }); return { code: result.code, message }; diff --git a/packages-next/auth/src/index.ts b/packages-next/auth/src/index.ts index 611a05f56ba..bddf82767f5 100644 --- a/packages-next/auth/src/index.ts +++ b/packages-next/auth/src/index.ts @@ -201,31 +201,6 @@ export function createAuth({ throw new Error(msg); } - // FIXME: Until we explicity support user defined field types, should we just check against .type.type === 'Password'? - const { type } = secretFieldConfig; - const secretPrototype = type && type.implementation && type.implementation.prototype; - const secretTypename = type && type.type; - if (typeof secretPrototype.compare !== 'function' || secretPrototype.compare.length < 2) { - const s = JSON.stringify(secretField); - const st = JSON.stringify(secretTypename); - let msg = `A createAuth() invocation for the "${listKey}" list specifies ${s} as its secretField, which uses the field type ${st}. But the ${st} field type doesn't implement the required compare() functionality.`; - if (secretTypename !== 'Password') { - msg += ` Did you mean to reference a field of type Password instead?`; - } - throw new Error(msg); - } - - // Ditto here, just explicitly enforce the use of our `password` field. - if (typeof secretPrototype.generateHash !== 'function') { - const s = JSON.stringify(secretField); - const st = JSON.stringify(secretTypename); - let msg = `A createAuth() invocation for the "${listKey}" list specifies ${s} as its secretField, which uses the field type ${st}. But the ${st} field type doesn't implement the required generateHash() functionality.`; - if (secretTypename !== 'Password') { - msg += ` Did you mean to reference a field of type Password instead?`; - } - throw new Error(msg); - } - // TODO: Could also validate initFirstItem.itemData keys? for (const field of initFirstItem?.fields || []) { if (listConfig.fields[field] === undefined) { diff --git a/packages-next/auth/src/lib/getErrorMessage.ts b/packages-next/auth/src/lib/getErrorMessage.ts index b73d892ab5d..42fc18ab302 100644 --- a/packages-next/auth/src/lib/getErrorMessage.ts +++ b/packages-next/auth/src/lib/getErrorMessage.ts @@ -1,3 +1,4 @@ +import { KeystoneContext } from '@keystone-next/types'; import { AuthTokenRedemptionErrorCode, AuthTokenRequestErrorCode, @@ -8,24 +9,30 @@ export function getPasswordAuthError({ identityField, secretField, code, - itemPlural, - itemSingular, + listKey, + context, }: { identityField: string; secretField: string; - itemSingular: string; - itemPlural: string; code: PasswordAuthErrorCode; + listKey: string; + context: KeystoneContext; }): string { switch (code) { case 'FAILURE': return 'Authentication failed.'; case 'IDENTITY_NOT_FOUND': - return `The ${identityField} value provided didn't identify any ${itemPlural}.`; + return `The ${identityField} value provided didn't identify any ${context + .gqlNames(listKey) + .listQueryName.replace('all', '')}.`; case 'SECRET_NOT_SET': - return `The ${itemSingular} identified has no ${secretField} set so can not be authenticated.`; + return `The ${ + context.gqlNames(listKey).outputTypeName + } identified has no ${secretField} set so can not be authenticated.`; case 'MULTIPLE_IDENTITY_MATCHES': - return `The ${identityField} value provided identified more than one ${itemSingular}.`; + return `The ${identityField} value provided identified more than one ${ + context.gqlNames(listKey).outputTypeName + }.`; case 'SECRET_MISMATCH': return `The ${secretField} provided is incorrect.`; } @@ -34,23 +41,29 @@ export function getPasswordAuthError({ export function getAuthTokenErrorMessage({ identityField, code, - itemPlural, - itemSingular, + listKey, + context, }: { identityField: string; - itemSingular: string; - itemPlural: string; code: AuthTokenRedemptionErrorCode | AuthTokenRequestErrorCode; + listKey: string; + context: KeystoneContext; }): string { switch (code) { case 'FAILURE': return 'Auth token redemption failed.'; case 'IDENTITY_NOT_FOUND': - return `The ${identityField} value provided didn't identify any ${itemPlural}.`; + return `The ${identityField} value provided didn't identify any ${context + .gqlNames(listKey) + .listQueryName.replace('all', '')}.`; case 'MULTIPLE_IDENTITY_MATCHES': - return `The ${identityField} value provided identified more than one ${itemSingular}.`; + return `The ${identityField} value provided identified more than one ${ + context.gqlNames(listKey).outputTypeName + }.`; case 'TOKEN_NOT_SET': - return `The ${itemSingular} identified has no auth token of this type set.`; + return `The ${ + context.gqlNames(listKey).outputTypeName + } identified has no auth token of this type set.`; case 'TOKEN_MISMATCH': return 'The auth token provided is incorrect.'; case 'TOKEN_EXPIRED': diff --git a/packages-next/auth/src/lib/validateAuthToken.ts b/packages-next/auth/src/lib/validateAuthToken.ts index a002e65ce55..ae5de7b0645 100644 --- a/packages-next/auth/src/lib/validateAuthToken.ts +++ b/packages-next/auth/src/lib/validateAuthToken.ts @@ -1,5 +1,5 @@ import type { KeystoneDbAPI } from '@keystone-next/types'; -import { AuthTokenRedemptionErrorCode } from '../types'; +import { AuthTokenRedemptionErrorCode, SecretFieldImpl } from '../types'; import { validateSecret } from './validateSecret'; // The tokensValidForMins config is from userland so could be anything; make it sane @@ -10,8 +10,9 @@ function sanitiseValidForMinsConfig(input: any): number { } export async function validateAuthToken( + listKey: string, + secretFieldImpl: SecretFieldImpl, tokenType: 'passwordReset' | 'magicAuth', - list: any, identityField: string, identity: string, protectIdentities: boolean, @@ -23,7 +24,7 @@ export async function validateAuthToken( | { success: true; item: { id: any; [prop: string]: any } } > { const result = await validateSecret( - list, + secretFieldImpl, identityField, identity, `${tokenType}Token`, @@ -55,7 +56,7 @@ export async function validateAuthToken( // Check that the token has not expired if (!item[fieldKeys.issuedAt] || typeof item[fieldKeys.issuedAt].getTime !== 'function') { throw new Error( - `Error redeeming authToken: field ${list.listKey}.${fieldKeys.issuedAt} isn't a valid Date object.` + `Error redeeming authToken: field ${listKey}.${fieldKeys.issuedAt} isn't a valid Date object.` ); } const elapsedMins = (Date.now() - item[fieldKeys.issuedAt].getTime()) / (1000 * 60); diff --git a/packages-next/auth/src/lib/validateSecret.ts b/packages-next/auth/src/lib/validateSecret.ts index 3e24eb03642..63679b9c0f5 100644 --- a/packages-next/auth/src/lib/validateSecret.ts +++ b/packages-next/auth/src/lib/validateSecret.ts @@ -1,9 +1,9 @@ import type { KeystoneDbAPI } from '@keystone-next/types'; -import { PasswordAuthErrorCode } from '../types'; +import { PasswordAuthErrorCode, SecretFieldImpl } from '../types'; import { findMatchingIdentity } from './findMatchingIdentity'; export async function validateSecret( - list: any, + secretFieldImpl: SecretFieldImpl, identityField: string, identity: string, secretField: string, @@ -23,18 +23,17 @@ export async function validateSecret( code = 'SECRET_NOT_SET'; } - const secretFieldInstance = list.fieldsByPath[secretField]; if (code) { // See "Identity Protection" in the README as to why this is a thing if (protectIdentities) { - await secretFieldInstance.generateHash('simulated-password-to-counter-timing-attack'); + await secretFieldImpl.generateHash('simulated-password-to-counter-timing-attack'); code = 'FAILURE'; } return { success: false, code }; } const { item } = match as { success: true; item: { id: any; [prop: string]: any } }; - if (await secretFieldInstance.compare(secret, item[secretField])) { + if (await secretFieldImpl.compare(secret, item[secretField])) { // Authenticated! return { success: true, item }; } else { diff --git a/packages-next/auth/src/schema.ts b/packages-next/auth/src/schema.ts index 35c32373bd8..3a7c9caa447 100644 --- a/packages-next/auth/src/schema.ts +++ b/packages-next/auth/src/schema.ts @@ -1,12 +1,37 @@ import { mergeSchemas } from '@graphql-tools/merge'; import { ExtendGraphqlSchema } from '@keystone-next/types'; -import { AuthGqlNames, AuthTokenTypeConfig, InitFirstItemConfig } from './types'; +import { assertObjectType, GraphQLSchema } from 'graphql'; +import { AuthGqlNames, AuthTokenTypeConfig, InitFirstItemConfig, SecretFieldImpl } from './types'; import { getBaseAuthSchema } from './gql/getBaseAuthSchema'; import { getInitFirstItemSchema } from './gql/getInitFirstItemSchema'; import { getPasswordResetSchema } from './gql/getPasswordResetSchema'; import { getMagicAuthLinkSchema } from './gql/getMagicAuthLinkSchema'; +function assertSecretFieldImpl( + impl: any, + listKey: string, + secretField: string +): asserts impl is SecretFieldImpl { + if ( + !impl || + typeof impl.compare !== 'function' || + impl.compare.length < 2 || + typeof impl.generateHash !== 'function' + ) { + const s = JSON.stringify(secretField); + let msg = `A createAuth() invocation for the "${listKey}" list specifies ${s} as its secretField, but the field type doesn't implement the required functionality.`; + throw new Error(msg); + } +} + +export function getSecretFieldImpl(schema: GraphQLSchema, listKey: string, fieldKey: string) { + const gqlOutputType = assertObjectType(schema.getType(listKey)); + const secretFieldImpl = gqlOutputType.getFields()?.[fieldKey].extensions?.keystoneSecretField; + assertSecretFieldImpl(secretFieldImpl, listKey, fieldKey); + return secretFieldImpl; +} + export const getSchemaExtension = ({ identityField, @@ -27,14 +52,15 @@ export const getSchemaExtension = passwordResetLink?: AuthTokenTypeConfig; magicAuthLink?: AuthTokenTypeConfig; }): ExtendGraphqlSchema => - schema => - [ + schema => { + return [ getBaseAuthSchema({ identityField, listKey, protectIdentities, secretField, gqlNames, + secretFieldImpl: getSecretFieldImpl(schema, listKey, secretField), }), initFirstItem && getInitFirstItemSchema({ @@ -52,6 +78,11 @@ export const getSchemaExtension = secretField, passwordResetLink, gqlNames, + passwordResetTokenSecretFieldImpl: getSecretFieldImpl( + schema, + listKey, + 'passwordResetToken' + ), }), magicAuthLink && getMagicAuthLinkSchema({ @@ -60,7 +91,9 @@ export const getSchemaExtension = protectIdentities, magicAuthLink, gqlNames, + magicAuthTokenSecretFieldImpl: getSecretFieldImpl(schema, listKey, 'magicAuthToken'), }), ] .filter(x => x) .reduce((s, extension) => mergeSchemas({ schemas: [s], ...extension }), schema); + }; diff --git a/packages-next/auth/src/types.ts b/packages-next/auth/src/types.ts index d1d54cda1a2..91de2bd4081 100644 --- a/packages-next/auth/src/types.ts +++ b/packages-next/auth/src/types.ts @@ -76,3 +76,8 @@ export type AuthTokenRedemptionErrorCode = | 'TOKEN_MISMATCH' | 'TOKEN_EXPIRED' | 'TOKEN_REDEEMED'; + +export type SecretFieldImpl = { + generateHash: (secret: string) => Promise; + compare: (secret: string, hash: string) => Promise; +}; diff --git a/packages-next/cloudinary/package.json b/packages-next/cloudinary/package.json index 18375e22a70..274cd4bd0e6 100644 --- a/packages-next/cloudinary/package.json +++ b/packages-next/cloudinary/package.json @@ -6,7 +6,6 @@ "module": "dist/cloudinary.esm.js", "dependencies": { "@babel/runtime": "^7.14.0", - "@keystone-next/adapter-prisma-legacy": "^8.0.0", "@keystone-next/fields": "^10.0.0", "@keystone-next/types": "^19.0.0", "@keystone-ui/button": "^5.0.0", diff --git a/packages-next/cloudinary/src/Implementation.ts b/packages-next/cloudinary/src/Implementation.ts deleted file mode 100644 index 26733fa7342..00000000000 --- a/packages-next/cloudinary/src/Implementation.ts +++ /dev/null @@ -1,269 +0,0 @@ -import cuid from 'cuid'; -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Implementation, FieldConfigArgs, FieldExtraArgs } from '@keystone-next/fields'; -import { BaseKeystoneList } from '@keystone-next/types'; -import cloudinary from 'cloudinary'; -import { FileUpload } from 'graphql-upload'; -import { CloudinaryAdapter, File } from './cloudinary'; - -type StoredFile = { - id: string; - filename: string; - originalFilename: string; - mimetype: any; - encoding: any; - _meta: cloudinary.UploadApiResponse; -}; - -export type CloudinaryImageFormat = { - prettyName?: string | null; - width?: string | null; - height?: string | null; - crop?: string | null; - aspect_ratio?: string | null; - gravity?: string | null; - zoom?: string | null; - x?: string | null; - y?: string | null; - format?: string | null; - fetch_format?: string | null; - quality?: string | null; - radius?: string | null; - angle?: string | null; - effect?: string | null; - opacity?: string | null; - border?: string | null; - background?: string | null; - overlay?: string | null; - underlay?: string | null; - default_image?: string | null; - delay?: string | null; - color?: string | null; - color_space?: string | null; - dpr?: string | null; - page?: string | null; - density?: string | null; - flags?: string | null; - transformation?: string | null; -}; - -class CloudinaryImage

extends Implementation

{ - fileAdapter: CloudinaryAdapter; - graphQLOutputType: string; - constructor( - path: P, - { adapter, ...configArgs }: FieldConfigArgs & { adapter: CloudinaryAdapter }, - extraArgs: FieldExtraArgs - ) { - super(path, { adapter, ...configArgs }, extraArgs); - this.graphQLOutputType = 'CloudinaryImage_File'; - this.fileAdapter = adapter; - - if (!this.fileAdapter) { - throw new Error(`No file adapter provided for File field.`); - } - // Ducktype the adapter - if (typeof this.fileAdapter.publicUrlTransformed !== 'function') { - throw new Error('CloudinaryImage field must be used with CloudinaryAdapter'); - } - } - - get _supportsUnique() { - return false; - } - - gqlOutputFields() { - return [`${this.path}: ${this.graphQLOutputType}`]; - } - - gqlQueryInputFields() { - return [...this.equalityInputFields('String'), ...this.inInputFields('String')]; - } - - getFileUploadType() { - return 'Upload'; - } - - getGqlAuxTypes() { - return [ - ` - type ${this.graphQLOutputType} { - id: ID - path: String - filename: String - originalFilename: String - mimetype: String - encoding: String - publicUrl: String - } - `, - ` - """ - Mirrors the formatting options [Cloudinary provides](https://cloudinary.com/documentation/image_transformation_reference). - All options are strings as they ultimately end up in a URL. - """ - input CloudinaryImageFormat { - """ Rewrites the filename to be this pretty string. Do not include \`/\` or \`.\` """ - prettyName: String - width: String - height: String - crop: String - aspect_ratio: String - gravity: String - zoom: String - x: String - y: String - format: String - fetch_format: String - quality: String - radius: String - angle: String - effect: String - opacity: String - border: String - background: String - overlay: String - underlay: String - default_image: String - delay: String - color: String - color_space: String - dpr: String - page: String - density: String - flags: String - transformation: String - }`, - - `extend type ${this.graphQLOutputType} { - publicUrlTransformed(transformation: CloudinaryImageFormat): String - }`, - ]; - } - - // Called on `User.avatar` for example - gqlOutputFieldResolvers() { - return { - [this.path]: (item: Record) => { - let itemValues: undefined | null | string | File = item[this.path]; - if (itemValues === null || itemValues === undefined) { - return null; - } - if (this.adapter.listAdapter.parentAdapter.provider === 'sqlite') { - // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - try { - itemValues = JSON.parse(itemValues as string) as File; - } catch (err) {} - } - const _itemValues = itemValues as File; - return { - publicUrl: this.fileAdapter.publicUrl(_itemValues), - publicUrlTransformed: ({ transformation }: { transformation: CloudinaryImageFormat }) => - this.fileAdapter.publicUrlTransformed(_itemValues, transformation), - ..._itemValues, - }; - }, - }; - } - - async resolveInput({ - resolvedData, - existingItem, - }: { - resolvedData: Record; - existingItem?: Record; - }) { - const previousData: string | StoredFile | undefined = existingItem && existingItem[this.path]; - const uploadData = resolvedData[this.path]; - - // NOTE: The following two conditions could easily be combined into a - // single `if (!uploadData) return uploadData`, but that would lose the - // nuance of returning `undefined` vs `null`. - // Premature Optimisers; be ware! - if (typeof uploadData === 'undefined') { - // Nothing was passed in, so we can bail early. - return undefined; - } - - if (uploadData === null) { - // `null` was specifically uploaded, and we should set the field value to - // null. To do that we... return `null` - return null; - } - - const { createReadStream, filename: originalFilename, mimetype, encoding } = await uploadData; - const stream = createReadStream(); - - if (!stream && previousData) { - // TODO: FIXME: Handle when stream is null. Can happen when: - // Updating some other part of the item, but not the file (gets null - // because no File DOM element is uploaded) - return previousData; - } - - const { id, filename, _meta } = await this.fileAdapter.save({ - stream, - filename: originalFilename, - id: cuid(), - }); - - const ret: StoredFile = { id, filename, originalFilename, mimetype, encoding, _meta }; - if (this.adapter.listAdapter.parentAdapter.provider === 'sqlite') { - // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - return JSON.stringify(ret); - } - return ret; - } - - gqlUpdateInputFields() { - return [`${this.path}: ${this.getFileUploadType()}`]; - } - - gqlCreateInputFields() { - return [`${this.path}: ${this.getFileUploadType()}`]; - } - - getBackingTypes() { - return { [this.path]: { optional: true, type: 'any' } }; - } -} - -class PrismaCloudinaryImageInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: CloudinaryImage

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - // Error rather than ignoring invalid config - // We totally can index these values, it's just not trivial. See issue #1297 - if (this.config.isIndexed) { - throw ( - `The CloudinaryImage field type doesn't support indexes on Prisma. ` + - `Check the config for ${this.path} on the ${this.field.listKey} list` - ); - } - } - getPrismaSchema() { - // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - return [ - this._schemaField({ - type: this.listAdapter.parentAdapter.provider === 'sqlite' ? 'String' : 'Json', - }), - ]; - } - getQueryConditions(dbPath: string) { - return { - ...this.equalityConditions(dbPath), - ...this.inConditions(dbPath), - }; - } -} - -export { CloudinaryImage, PrismaCloudinaryImageInterface }; diff --git a/packages-next/cloudinary/src/cloudinary.ts b/packages-next/cloudinary/src/cloudinary.ts index 7db410386a5..6c3418a7ab9 100644 --- a/packages-next/cloudinary/src/cloudinary.ts +++ b/packages-next/cloudinary/src/cloudinary.ts @@ -1,9 +1,40 @@ import fs from 'fs'; import cloudinary from 'cloudinary'; -import { CloudinaryImageFormat } from './Implementation'; export type File = { id: string; filename: string; _meta: cloudinary.UploadApiResponse }; +export type CloudinaryImageFormat = { + prettyName?: string | null; + width?: string | null; + height?: string | null; + crop?: string | null; + aspect_ratio?: string | null; + gravity?: string | null; + zoom?: string | null; + x?: string | null; + y?: string | null; + format?: string | null; + fetch_format?: string | null; + quality?: string | null; + radius?: string | null; + angle?: string | null; + effect?: string | null; + opacity?: string | null; + border?: string | null; + background?: string | null; + overlay?: string | null; + underlay?: string | null; + default_image?: string | null; + delay?: string | null; + color?: string | null; + color_space?: string | null; + dpr?: string | null; + page?: string | null; + density?: string | null; + flags?: string | null; + transformation?: string | null; +}; + function uploadStream( stream: fs.ReadStream, options: cloudinary.UploadApiOptions diff --git a/packages-next/cloudinary/src/index.ts b/packages-next/cloudinary/src/index.ts index caaf9d36f2b..a96dde9233b 100644 --- a/packages-next/cloudinary/src/index.ts +++ b/packages-next/cloudinary/src/index.ts @@ -1,11 +1,31 @@ import path from 'path'; -import type { FieldType, FieldConfig, BaseGeneratedListTypes } from '@keystone-next/types'; +import { + CommonFieldConfig, + BaseGeneratedListTypes, + FieldTypeFunc, + jsonFieldTypePolyfilledForSQLite, + schema, + FieldDefaultValue, + legacyFilters, +} from '@keystone-next/types'; +import { FileUpload } from 'graphql-upload'; +import cuid from 'cuid'; +import cloudinary from 'cloudinary'; import { CloudinaryAdapter } from './cloudinary'; -import { CloudinaryImage, PrismaCloudinaryImageInterface } from './Implementation'; + +type StoredFile = { + id: string; + filename: string; + originalFilename: string; + mimetype: any; + encoding: any; + _meta: cloudinary.UploadApiResponse; +}; type CloudinaryImageFieldConfig = - FieldConfig & { + CommonFieldConfig & { isRequired?: boolean; + defaultValue?: FieldDefaultValue; cloudinary: { cloudName: string; apiKey: string; @@ -14,22 +34,158 @@ type CloudinaryImageFieldConfig({ - cloudinary, - ...config -}: CloudinaryImageFieldConfig): FieldType => ({ - type: { - type: 'CloudinaryImage', - implementation: CloudinaryImage, - adapter: PrismaCloudinaryImageInterface, +const CloudinaryImageFormat = schema.inputObject({ + name: 'CloudinaryImageFormat', + description: + 'Mirrors the formatting options [Cloudinary provides](https://cloudinary.com/documentation/image_transformation_reference).\n' + + 'All options are strings as they ultimately end up in a URL.', + fields: { + prettyName: schema.arg({ + description: ' Rewrites the filename to be this pretty string. Do not include `/` or `.`', + type: schema.String, + }), + width: schema.arg({ type: schema.String }), + height: schema.arg({ type: schema.String }), + crop: schema.arg({ type: schema.String }), + aspect_ratio: schema.arg({ type: schema.String }), + gravity: schema.arg({ type: schema.String }), + zoom: schema.arg({ type: schema.String }), + x: schema.arg({ type: schema.String }), + y: schema.arg({ type: schema.String }), + format: schema.arg({ type: schema.String }), + fetch_format: schema.arg({ type: schema.String }), + quality: schema.arg({ type: schema.String }), + radius: schema.arg({ type: schema.String }), + angle: schema.arg({ type: schema.String }), + effect: schema.arg({ type: schema.String }), + opacity: schema.arg({ type: schema.String }), + border: schema.arg({ type: schema.String }), + background: schema.arg({ type: schema.String }), + overlay: schema.arg({ type: schema.String }), + underlay: schema.arg({ type: schema.String }), + default_image: schema.arg({ type: schema.String }), + delay: schema.arg({ type: schema.String }), + color: schema.arg({ type: schema.String }), + color_space: schema.arg({ type: schema.String }), + dpr: schema.arg({ type: schema.String }), + page: schema.arg({ type: schema.String }), + density: schema.arg({ type: schema.String }), + flags: schema.arg({ type: schema.String }), + transformation: schema.arg({ type: schema.String }), }, - config: { - ...config, - // @ts-ignore - adapter: new CloudinaryAdapter(cloudinary), +}); + +type CloudinaryImage_File = { + id: string | null; + filename: string | null; + originalFilename: string | null; + mimetype: string | null; + encoding: string | null; + publicUrl: string | null; + publicUrlTransformed: (args: { + transformation: schema.InferValueFromArg>; + }) => string | null; +}; + +const outputType = schema.object()({ + name: 'CloudinaryImage_File', + fields: { + id: schema.field({ type: schema.ID }), + // path: types.field({ type: types.String }), + filename: schema.field({ type: schema.String }), + originalFilename: schema.field({ type: schema.String }), + mimetype: schema.field({ type: schema.String }), + encoding: schema.field({ type: schema.String }), + publicUrl: schema.field({ type: schema.String }), + publicUrlTransformed: schema.field({ + args: { + transformation: schema.arg({ type: CloudinaryImageFormat }), + }, + type: schema.String, + resolve(rootVal, args) { + return rootVal.publicUrlTransformed(args); + }, + }), }, - views: path.join( - path.dirname(require.resolve('@keystone-next/cloudinary/package.json')), - 'views' - ), }); + +export const cloudinaryImage = + ({ + cloudinary, + isRequired, + defaultValue, + ...config + }: CloudinaryImageFieldConfig): FieldTypeFunc => + meta => { + if ((config as any).isUnique) { + throw Error('isUnique is not a supported option for field type cloudinaryImage'); + } + const adapter = new CloudinaryAdapter(cloudinary); + const resolveInput = async ( + uploadData: Promise | undefined | null + ): Promise => { + if (uploadData == null) { + return uploadData; + } + + const { createReadStream, filename: originalFilename, mimetype, encoding } = await uploadData; + const stream = createReadStream(); + + if (!stream) { + // TODO: FIXME: Handle when stream is null. Can happen when: + // Updating some other part of the item, but not the file (gets null + // because no File DOM element is uploaded) + return undefined; + } + + const { id, filename, _meta } = await adapter.save({ + stream, + filename: originalFilename, + id: cuid(), + }); + + return { id, filename, originalFilename, mimetype, encoding, _meta }; + }; + return jsonFieldTypePolyfilledForSQLite(meta.provider, { + input: { + create: { arg: schema.arg({ type: schema.Upload }), resolve: resolveInput }, + update: { arg: schema.arg({ type: schema.Upload }), resolve: resolveInput }, + }, + output: schema.field({ + type: outputType, + resolve({ value }) { + if (value === null) { + return null; + } + const val = value as any; + return { + publicUrl: adapter.publicUrl(val), + publicUrlTransformed: ({ + transformation, + }: { + transformation: schema.InferValueFromArg>; + }) => adapter.publicUrlTransformed(val, transformation ?? {}), + ...val, + }; + }, + }), + views: path.join( + path.dirname(require.resolve('@keystone-next/cloudinary/package.json')), + 'views' + ), + __legacy: { + isRequired, + defaultValue, + filters: { + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, schema.String), + ...legacyFilters.fields.inInputFields(meta.fieldKey, schema.String), + }, + impls: { + ...legacyFilters.impls.equalityConditions(meta.fieldKey), + ...legacyFilters.impls.inConditions(meta.fieldKey), + }, + }, + }, + }); + }; diff --git a/packages-next/fields-document/package.json b/packages-next/fields-document/package.json index 182453ffc40..fd2aff57fac 100644 --- a/packages-next/fields-document/package.json +++ b/packages-next/fields-document/package.json @@ -22,7 +22,6 @@ "@babel/runtime": "^7.14.0", "@braintree/sanitize-url": "^5.0.2", "@emotion/weak-memoize": "^0.2.5", - "@keystone-next/adapter-prisma-legacy": "^8.0.0", "@keystone-next/admin-ui-utils": "^5.0.1", "@keystone-next/fields": "^10.0.0", "@keystone-next/keystone": "^19.0.0", diff --git a/packages-next/fields-document/src/Implementation.ts b/packages-next/fields-document/src/Implementation.ts deleted file mode 100644 index 2c5d038bb5e..00000000000 --- a/packages-next/fields-document/src/Implementation.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Implementation } from '@keystone-next/fields'; -import { FieldConfigArgs, FieldExtraArgs } from '@keystone-next/fields'; -import { BaseKeystoneList, KeystoneContext } from '@keystone-next/types'; -// eslint-disable-next-line import/no-unresolved -import { addRelationshipData } from './relationship-data'; -import { ComponentBlock } from './component-blocks'; -import { Relationships } from './DocumentEditor/relationship'; - -// this includes the list key and path because in the future -// there will likely be additional fields specific to a particular field -// such as exposing the relationships in the document -const outputType = (field: DocumentImplementation) => - `${field.listKey}_${field.path}_DocumentField`; - -export class DocumentImplementation

extends Implementation

{ - relationships: Relationships; - componentBlocks: Record; - ___validateAndNormalize: (data: unknown) => unknown[]; - constructor( - path: P, - { - relationships, - componentBlocks, - ___validateAndNormalize, - ...configArgs - }: FieldConfigArgs & { - relationships: Relationships; - componentBlocks: Record; - ___validateAndNormalize: (data: unknown) => unknown[]; - }, - extraArgs: FieldExtraArgs - ) { - super( - path, - { relationships, componentBlocks, ___validateAndNormalize, ...configArgs }, - extraArgs - ); - this.relationships = relationships; - this.componentBlocks = componentBlocks; - this.___validateAndNormalize = ___validateAndNormalize; - } - - get _supportsUnique() { - return false; - } - - gqlOutputFields(): string[] { - return [`${this.path}: ${outputType(this)}`]; - } - - getGqlAuxTypes(): string[] { - return [ - `type ${outputType(this)} { - document(hydrateRelationships: Boolean! = false): JSON! - }`, - ]; - } - - gqlAuxFieldResolvers(): Record { - return { - [outputType(this)]: { - document: (rootVal: any, { hydrateRelationships }: { hydrateRelationships: boolean }) => - rootVal.document(hydrateRelationships), - }, - }; - } - // Called on `User.avatar` for example - gqlOutputFieldResolvers() { - return { - [this.path]: (item: Record, _args: any, context: KeystoneContext) => { - let document = item[this.path]; - if (this.adapter.listAdapter.parentAdapter.provider === 'sqlite') { - // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - try { - document = JSON.parse(document); - } catch (err) {} - } - if (!Array.isArray(document)) return null; - return { - document: (hydrateRelationships: boolean) => - hydrateRelationships - ? addRelationshipData( - document, - context.graphql, - this.relationships, - this.componentBlocks, - context.gqlNames - ) - : document, - }; - }, - }; - } - - async resolveInput({ resolvedData }: { resolvedData: Record }) { - const data = resolvedData[this.path]; - if (data === null) { - return null; - } - if (data === undefined) { - return undefined; - } - const nodes = this.___validateAndNormalize(data); - if (this.adapter.listAdapter.parentAdapter.provider === 'sqlite') { - // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - return JSON.stringify(nodes); - } - return nodes; - } - - gqlUpdateInputFields() { - return [`${this.path}: JSON`]; - } - gqlCreateInputFields() { - return [`${this.path}: JSON`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'Record[] | null' } }; - } -} - -export class PrismaDocumentInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: DocumentImplementation

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - // Error rather than ignoring invalid config - // We totally can index these values, it's just not trivial. See issue #1297 - if (this.config.isIndexed) { - throw new Error( - `The Document field type doesn't support indexes on Prisma. ` + - `Check the config for ${this.path} on the ${this.field.listKey} list` - ); - } - } - - getPrismaSchema() { - return [ - this._schemaField({ - type: - this.listAdapter.parentAdapter.provider === 'sqlite' - ? // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - 'String' - : 'Json', - }), - ]; - } - - getQueryConditions() { - return {}; - } -} diff --git a/packages-next/fields-document/src/index.ts b/packages-next/fields-document/src/index.ts index 6b61a581243..9fbd1819af0 100644 --- a/packages-next/fields-document/src/index.ts +++ b/packages-next/fields-document/src/index.ts @@ -1,10 +1,18 @@ import path from 'path'; -import type { FieldType, BaseGeneratedListTypes, FieldConfig } from '@keystone-next/types'; -import { DocumentImplementation, PrismaDocumentInterface } from './Implementation'; +import { + BaseGeneratedListTypes, + CommonFieldConfig, + FieldTypeFunc, + jsonFieldTypePolyfilledForSQLite, + schema, + JSONValue, + FieldDefaultValue, +} from '@keystone-next/types'; import { Relationships } from './DocumentEditor/relationship'; import { ComponentBlock } from './component-blocks'; import { DocumentFeatures } from './views'; import { validateAndNormalizeDocument } from './validation'; +import { addRelationshipData } from './relationship-data'; type RelationshipsConfig = Record< string, @@ -60,13 +68,15 @@ type FormattingConfig = { }; export type DocumentFieldConfig = - FieldConfig & { + CommonFieldConfig & { relationships?: RelationshipsConfig; componentBlocks?: Record; formatting?: true | FormattingConfig; links?: true; dividers?: true; layouts?: readonly (readonly [number, ...number[]])[]; + isRequired?: boolean; + defaultValue?: FieldDefaultValue[], TGeneratedListTypes>; }; const views = path.join( @@ -74,11 +84,93 @@ const views = path.join( 'views' ); -export const document = ( - config: DocumentFieldConfig = {} -): FieldType => { +export const document = + ({ + componentBlocks = {}, + dividers, + formatting, + layouts, + relationships: configRelationships, + links, + isRequired, + defaultValue, + ...config + }: DocumentFieldConfig = {}): FieldTypeFunc => + meta => { + const documentFeatures = normaliseDocumentFeatures({ + dividers, + formatting, + layouts, + links, + }); + const relationships = normaliseRelationships(configRelationships); + + const inputResolver = (data: JSONValue | null | undefined): any => { + if (data === null || data === undefined) { + return data; + } + return validateAndNormalizeDocument(data, documentFeatures, componentBlocks, relationships); + }; + + if ((config as any).isUnique) { + throw Error('isUnique is not a supported option for field type document'); + } + + return jsonFieldTypePolyfilledForSQLite(meta.provider, { + ...config, + input: { + create: { arg: schema.arg({ type: schema.JSON }), resolve: inputResolver }, + update: { arg: schema.arg({ type: schema.JSON }), resolve: inputResolver }, + }, + output: schema.field({ + type: schema.object<{ document: JSONValue }>()({ + name: `${meta.listKey}_${meta.fieldKey}_DocumentField`, + fields: { + document: schema.field({ + args: { + hydrateRelationships: schema.arg({ + type: schema.nonNull(schema.Boolean), + defaultValue: false, + }), + }, + type: schema.nonNull(schema.JSON), + resolve({ document }, { hydrateRelationships }, context) { + return hydrateRelationships + ? addRelationshipData( + document as any, + context.graphql, + relationships, + componentBlocks, + context.gqlNames as any + ) + : (document as any); + }, + }), + }, + }), + resolve({ value }) { + if (value === null) { + return null; + } + return { document: value }; + }, + }), + views, + getAdminMeta(): Parameters[0]['fieldMeta'] { + return { + relationships, + documentFeatures, + componentBlocksPassedOnServer: Object.keys(componentBlocks), + }; + }, + __legacy: { isRequired, defaultValue }, + }); + }; + +function normaliseRelationships( + configRelationships: DocumentFieldConfig['relationships'] +) { const relationships: Relationships = {}; - const configRelationships = config.relationships; if (configRelationships) { Object.keys(configRelationships).forEach(key => { const relationship = configRelationships[key]; @@ -92,6 +184,15 @@ export const document = ( }; }); } + return relationships; +} + +function normaliseDocumentFeatures( + config: Pick< + DocumentFieldConfig, + 'formatting' | 'dividers' | 'layouts' | 'links' + > +) { const formatting: FormattingConfig = config.formatting === true ? { @@ -163,27 +264,5 @@ export const document = ( ), dividers: !!config.dividers, }; - const componentBlocks = config.componentBlocks || {}; - return { - type: { - type: 'Document', - implementation: DocumentImplementation, - adapter: PrismaDocumentInterface, - }, - config: { - ...config, - componentBlocks, - relationships, - ___validateAndNormalize: (data: unknown) => - validateAndNormalizeDocument(data, documentFeatures, componentBlocks, relationships), - } as any, - getAdminMeta(): Parameters[0]['fieldMeta'] { - return { - relationships, - documentFeatures, - componentBlocksPassedOnServer: Object.keys(componentBlocks), - }; - }, - views, - }; -}; + return documentFeatures; +} diff --git a/packages-next/fields/package.json b/packages-next/fields/package.json index 962a444e0b9..2cacf8c7095 100644 --- a/packages-next/fields/package.json +++ b/packages-next/fields/package.json @@ -8,14 +8,11 @@ "@keystone-next/test-utils-legacy": "20.0.0", "@types/bytes": "^3.1.0", "fs-extra": "^10.0.0", - "mime": "^2.5.2", - "typescript": "^4.2.4" + "graphql": "^15.5.0", + "mime": "^2.5.2" }, "dependencies": { - "@apollo/client": "^3.3.19", "@babel/runtime": "^7.14.0", - "@keystone-next/access-control-legacy": "^11.0.0", - "@keystone-next/adapter-prisma-legacy": "^8.0.0", "@keystone-next/admin-ui-utils": "^5.0.1", "@keystone-next/keystone": "^19.0.0", "@keystone-next/types": "^19.0.0", @@ -31,13 +28,10 @@ "@keystone-ui/toast": "^4.0.0", "@keystone-ui/tooltip": "^4.0.0", "@types/bcryptjs": "^2.4.2", - "@types/keystonejs__fields": "^5.1.1", "@types/react": "^17.0.9", - "apollo-errors": "^1.9.0", "bcryptjs": "^2.4.3", "bytes": "^3.1.0", "copy-to-clipboard": "^3.3.1", - "cuid": "^2.1.8", "date-fns": "^2.22.1", "decimal.js": "^10.2.1", "dumb-passwords": "^0.2.1", @@ -45,10 +39,8 @@ "graphql-upload": "^12.0.0", "inflection": "^1.13.1", "intersection-observer": "^0.12.0", - "lodash.groupby": "^4.6.0", - "mkdirp": "^1.0.4", - "p-settle": "^4.1.1", - "react": "^17.0.2" + "react": "^17.0.2", + "uuid": "^8.3.2" }, "engines": { "node": "^12.20 || >= 14.13" diff --git a/packages-next/fields/src/Implementation.ts b/packages-next/fields/src/Implementation.ts deleted file mode 100644 index aec24ffb16e..00000000000 --- a/packages-next/fields/src/Implementation.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { humanize } from '@keystone-next/utils-legacy'; -import { parseFieldAccess } from '@keystone-next/access-control-legacy'; -import { BaseKeystoneList, KeystoneContext } from '@keystone-next/types'; -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; - -export type FieldConfigArgs = { - hooks?: any; - isRequired?: boolean; - defaultValue?: any; - access?: any; - label?: string; - schemaDoc?: string; - isUnique?: boolean; -}; - -export type FieldExtraArgs = { - getListByKey: (key: string) => BaseKeystoneList | undefined; - listKey: string; - listAdapter: PrismaListAdapter; - fieldAdapterClass: typeof PrismaFieldAdapter; - schemaNames: string[]; -}; - -class Field

{ - path: P; - isPrimaryKey: boolean; - schemaDoc?: string; - config: { - isUnique?: boolean; - }; - isRequired: boolean; - defaultValue?: any; - isOrderable: boolean; - hooks: any; - getListByKey: (key: string) => BaseKeystoneList | undefined; - listKey: string; - label: string; - adapter: PrismaFieldAdapter

; - isRelationship: boolean; - access: any; - refListKey: string; - - constructor( - path: P, - { - hooks = {}, - isRequired, - defaultValue, - access, - label, - schemaDoc, - ...config - }: FieldConfigArgs & Record, - { getListByKey, listKey, listAdapter, fieldAdapterClass, schemaNames }: FieldExtraArgs - ) { - this.path = path; - this.isPrimaryKey = path === 'id'; - this.schemaDoc = schemaDoc; - this.config = config; - this.isRequired = !!isRequired; - this.defaultValue = defaultValue; - this.isOrderable = false; - this.hooks = hooks; - this.getListByKey = getListByKey; - this.listKey = listKey; - this.label = label || humanize(path); - - if (this.config.isUnique && !this._supportsUnique) { - throw new Error( - `isUnique is not a supported option for field type ${this.constructor.name} (${this.path})` - ); - } - - this.adapter = listAdapter.newFieldAdapter( - fieldAdapterClass, - this.constructor.name, - path, - this, - getListByKey, - { ...config } - ); - - // Should be overwritten by types that implement a Relationship interface - this.isRelationship = false; - this.refListKey = ''; - - this.access = this._modifyAccess( - parseFieldAccess({ schemaNames, listKey, fieldKey: path, defaultAccess: true, access }) - ); - } - - // By default we assume that fields do not support unique constraints. - // Fields should override this method if they want to support uniqueness. - get _supportsUnique() { - return false; - } - - _modifyAccess(access: any) { - return access; - } - - // Field types should replace this if they want to any fields to the output type - // eslint-disable-next-line @typescript-eslint/no-unused-vars - gqlOutputFields({ schemaName }: { schemaName: string }): string[] { - return []; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - gqlOutputFieldResolvers({ schemaName }: { schemaName: string }) { - return {}; - } - - /** - * Auxiliary Types are top-level types which a type may need or provide. - * Example: the `File` type, adds a graphql auxiliary type of `FileUpload`, as - * well as an `uploadFile()` graphql auxiliary type query resolver - * - * These are special cases, and should be used sparingly - * - * NOTE: When a naming conflict occurs, a list's types/queries/mutations will - * overwrite any auxiliary types defined by an individual type. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getGqlAuxTypes({ schemaName }: { schemaName: string }): string[] { - return []; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - gqlAuxFieldResolvers({ schemaName }: { schemaName: string }) { - return {}; - } - - getGqlAuxQueries(): any[] { - return []; - } - gqlAuxQueryResolvers() { - return {}; - } - - /** - * @param {Object} data - * @param {Object} data.resolvedData The incoming item for the mutation with - * relationships and defaults already resolved - * @param {Object} data.existingItem If this is a updateX mutation, this will - * be the existing data in the database - * @param {Object} data.context The graphQL context object of the current - * request - * @param {Object} data.originalInput The raw incoming item from the mutation - * (no relationships or defaults resolved) - */ - async resolveInput({ resolvedData }: { resolvedData: Record }): Promise { - return resolvedData[this.path]; - } - - /** - * @param {Object} data - * @param {Object} data.resolvedData The incoming item for the mutation with - * relationships and defaults already resolved - * @param {Object} data.existingItem If this is a updateX mutation, this will - * be the existing data in the database - * @param {Object} data.context The graphQL context object of the current - * request - * @param {Object} data.originalInput The raw incoming item from the mutation - * (no relationships or defaults resolved) - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async validateInput(data: { - resolvedData: Record; - existingItem?: Record; - context: KeystoneContext; - originalInput: any; - listKey: string; - fieldPath: P; - operation: 'create' | 'update'; - addValidationError: (msg: string) => void; - }) {} - - async beforeChange() {} - - async afterChange() {} - - async beforeDelete() {} - - async validateDelete() {} - - async afterDelete() {} - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - gqlQueryInputFields({ schemaName }: { schemaName: string }): string[] { - return []; - } - equalityInputFields(type: string) { - return [`${this.path}: ${type}`, `${this.path}_not: ${type}`]; - } - equalityInputFieldsInsensitive(type: string) { - return [`${this.path}_i: ${type}`, `${this.path}_not_i: ${type}`]; - } - inInputFields(type: string) { - return [`${this.path}_in: [${type}]`, `${this.path}_not_in: [${type}]`]; - } - orderingInputFields(type: string) { - return [ - `${this.path}_lt: ${type}`, - `${this.path}_lte: ${type}`, - `${this.path}_gt: ${type}`, - `${this.path}_gte: ${type}`, - ]; - } - containsInputFields(type: string) { - return [`${this.path}_contains: ${type}`, `${this.path}_not_contains: ${type}`]; - } - stringInputFields(type: string) { - return [ - ...this.containsInputFields(type), - `${this.path}_starts_with: ${type}`, - `${this.path}_not_starts_with: ${type}`, - `${this.path}_ends_with: ${type}`, - `${this.path}_not_ends_with: ${type}`, - ]; - } - stringInputFieldsInsensitive(type: string) { - return [ - `${this.path}_contains_i: ${type}`, - `${this.path}_not_contains_i: ${type}`, - `${this.path}_starts_with_i: ${type}`, - `${this.path}_not_starts_with_i: ${type}`, - `${this.path}_ends_with_i: ${type}`, - `${this.path}_not_ends_with_i: ${type}`, - ]; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - gqlCreateInputFields({ schemaName }: { schemaName: string }): string[] { - return []; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - gqlUpdateInputFields({ schemaName }: { schemaName: string }): string[] { - return []; - } - getDefaultValue({ context, originalInput }: { context: KeystoneContext; originalInput: any }) { - if (typeof this.defaultValue !== 'undefined') { - if (typeof this.defaultValue === 'function') { - return this.defaultValue({ context, originalInput }); - } else { - return this.defaultValue; - } - } - // By default, the default value is undefined - return undefined; - } - - getBackingTypes() { - // Return the typescript types of the backing item for this field type. - // This method can be helpful if you want to auto-generate typescript types. - // Future releases of Keystone will provide full typescript support - return { [this.path]: { optional: true, type: 'any' } }; - } -} - -export { Field as Implementation }; diff --git a/packages-next/fields/src/get-index-type.ts b/packages-next/fields/src/get-index-type.ts new file mode 100644 index 00000000000..de1bfe66340 --- /dev/null +++ b/packages-next/fields/src/get-index-type.ts @@ -0,0 +1,12 @@ +export function getIndexType({ + isIndexed, + isUnique, +}: { + isIndexed?: boolean; + isUnique?: boolean; +}): undefined | 'index' | 'unique' { + if (isUnique && isIndexed) { + throw new Error('Only one of isUnique and isIndexed can be passed to field types'); + } + return isIndexed ? 'index' : isUnique ? 'unique' : undefined; +} diff --git a/packages-next/fields/src/index.ts b/packages-next/fields/src/index.ts index af51da2a992..70ac5f483ed 100644 --- a/packages-next/fields/src/index.ts +++ b/packages-next/fields/src/index.ts @@ -12,5 +12,3 @@ export { select } from './types/select'; export { text } from './types/text'; export { timestamp } from './types/timestamp'; export { virtual } from './types/virtual'; -export { Implementation } from './Implementation'; -export type { FieldConfigArgs, FieldExtraArgs } from './Implementation'; diff --git a/packages-next/fields/src/interfaces.ts b/packages-next/fields/src/interfaces.ts deleted file mode 100644 index de9119a1a19..00000000000 --- a/packages-next/fields/src/interfaces.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type { FieldConfig } from '@keystone-next/types'; - -export interface FieldAdminMeta { - label: string; - type: string; -} diff --git a/packages-next/fields/src/tests/Implementation.test.ts b/packages-next/fields/src/tests/Implementation.test.ts deleted file mode 100644 index 012be2517b4..00000000000 --- a/packages-next/fields/src/tests/Implementation.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { KeystoneContext } from '@keystone-next/types'; -import { Implementation as Field } from '../'; - -const args = { - getListByKey: () => undefined, - listKey: 'key', - listAdapter: { newFieldAdapter: jest.fn() } as unknown as PrismaListAdapter, - schemaNames: ['public'], - fieldAdapterClass: {} as typeof PrismaFieldAdapter, -}; - -describe('new Implementation()', () => { - test('new Implementation() - Smoke test', () => { - const impl = new Field( - 'path', - {}, - { - getListByKey: () => undefined, - listKey: 'key', - listAdapter: { newFieldAdapter: jest.fn() } as unknown as PrismaListAdapter, - schemaNames: ['public'], - fieldAdapterClass: {} as typeof PrismaFieldAdapter, - } - ); - expect(impl).not.toBeNull(); - expect(impl.path).toEqual('path'); - expect(impl.listKey).toEqual('key'); - expect(impl.label).toEqual('Path'); - }); - - test('new Implementation - label from config', () => { - const impl = new Field('path', { label: 'config label' }, args); - expect(impl.label).toEqual('config label'); - }); -}); - -test('getGqlAuxTypes()', () => { - const impl = new Field('path', {}, args); - const schemaName = 'public'; - expect(impl.getGqlAuxTypes({ schemaName })).toEqual([]); -}); - -test('gqlAuxFieldResolvers', () => { - const impl = new Field('path', {}, args); - const schemaName = 'public'; - expect(impl.gqlAuxFieldResolvers({ schemaName })).toEqual({}); -}); - -test('getGqlAuxQueries()', () => { - const impl = new Field('path', {}, args); - - expect(impl.getGqlAuxQueries()).toEqual([]); -}); - -test('gqlAuxQueryResolvers', () => { - const impl = new Field('path', {}, args); - - expect(impl.gqlAuxQueryResolvers()).toEqual({}); -}); - -test('afterChange()', async () => { - const impl = new Field('path', {}, args); - - const value = await impl.afterChange(); - expect(value).toBe(undefined); -}); - -test('resolveInput()', async () => { - const impl = new Field('path', {}, args); - - const resolvedData = { path: 1 }; - const value = await impl.resolveInput({ resolvedData }); - expect(value).toEqual(1); -}); - -test('gqlQueryInputFields', () => { - const impl = new Field('path', {}, args); - const schemaName = 'public'; - expect(impl.gqlQueryInputFields({ schemaName })).toEqual([]); -}); - -test('gqlUpdateInputFields', () => { - const impl = new Field('path', {}, args); - const schemaName = 'public'; - expect(impl.gqlUpdateInputFields({ schemaName })).toEqual([]); -}); - -test('gqlOutputFieldResolvers', () => { - const impl = new Field('path', {}, args); - const schemaName = 'public'; - expect(impl.gqlOutputFieldResolvers({ schemaName })).toEqual({}); -}); - -describe('getDefaultValue()', () => { - test('undefined by default', () => { - const impl = new Field('path', {}, args); - const context = {} as KeystoneContext; - const originalInput = {}; - const value = impl.getDefaultValue({ context, originalInput }); - expect(value).toEqual(undefined); - }); - - test('static value is returned', () => { - const impl = new Field('path', { defaultValue: 'foobar' }, args); - const context = {} as KeystoneContext; - const originalInput = {}; - const value = impl.getDefaultValue({ context, originalInput }); - expect(value).toEqual('foobar'); - }); - - test('executes a function', () => { - const defaultValue = jest.fn(() => 'foobar'); - const context = {} as KeystoneContext; - const originalInput = {}; - - const impl = new Field('path', { defaultValue }, args); - - const value = impl.getDefaultValue({ context, originalInput }); - expect(value).toEqual('foobar'); - expect(defaultValue).toHaveBeenCalledTimes(1); - expect(defaultValue).toHaveBeenCalledWith({ context, originalInput }); - }); -}); diff --git a/packages-next/fields/src/types/autoIncrement/Implementation.ts b/packages-next/fields/src/types/autoIncrement/Implementation.ts deleted file mode 100644 index 13a41e5f383..00000000000 --- a/packages-next/fields/src/types/autoIncrement/Implementation.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class AutoIncrementImplementation

extends Implementation

{ - gqlType: 'ID' | 'Int'; - constructor( - path: P, - { - gqlType, - isUnique = true, - access = {}, - ...configArgs - }: FieldConfigArgs & { gqlType: 'ID' | 'Int'; isUnique: boolean }, - extraArgs: FieldExtraArgs - ) { - // Apply some field type defaults before we hand off to super; see README.md - if (typeof access === 'object') { - access = { create: false, update: false, delete: false, ...access }; - } - - // The base implementation takes care of everything else - super(path, { gqlType, isUnique, access, ...configArgs }, extraArgs); - - // If no valid gqlType is supplied, default based on whether or not we're the primary key - this.gqlType = ['ID', 'Int'].includes(gqlType) ? gqlType : this.isPrimaryKey ? 'ID' : 'Int'; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: ${this.gqlType}${this.isPrimaryKey ? '!' : ''}`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - gqlQueryInputFields() { - return [ - ...this.equalityInputFields(this.gqlType), - ...this.orderingInputFields(this.gqlType), - ...this.inInputFields(this.gqlType), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: ${this.gqlType}`]; - } - gqlCreateInputFields() { - return [`${this.path}: ${this.gqlType}`]; - } - - getBackingTypes() { - if (this.path === 'id') { - return { [this.path]: { optional: false, type: 'string' } }; - } - return { [this.path]: { optional: true, type: 'number | null' } }; - } -} - -export class PrismaAutoIncrementInterface

extends PrismaFieldAdapter

{ - isUnique: boolean; - isIndexed: boolean; - constructor( - fieldName: string, - path: P, - field: AutoIncrementImplementation

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - if (this.listAdapter.parentAdapter.provider === 'sqlite' && !this.field.isPrimaryKey) { - throw new Error( - `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` - ); - } - // Default isUnique to true if not specified - this.isUnique = typeof this.config.isUnique === 'undefined' ? true : !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - - getPrismaSchema() { - return [this._schemaField({ type: 'Int', extra: '@default(autoincrement())' })]; - } - - gqlToPrisma(value: any) { - // If we're an ID type then we'll be getting strings from GQL - return Number(value); - // console.log(this.field.gqlType); - // return this.field.gqlType === 'ID' ? Number(value) : value; - } - - equalityConditions(dbPath: string, f: (a: any) => any) { - return { - [this.path]: (value: T) => ({ [dbPath]: f(value) }), - [`${this.path}_not`]: (value: T) => ({ NOT: { [this.path]: f(value) } }), - }; - } - - inConditions(dbPath: string, f: (a: any) => any) { - return { - [`${this.path}_in`]: (value: (T | null)[]) => - value.includes(null) - ? { [dbPath]: { in: f(value.filter(x => x !== null)) } } - : { [dbPath]: { in: f(value) } }, - [`${this.path}_not_in`]: (value: (T | null)[]) => - value.includes(null) - ? { AND: [{ NOT: { [dbPath]: { in: f(value.filter(x => x !== null)) } } }] } - : { NOT: { [dbPath]: { in: f(value) } } }, - }; - } - - getQueryConditions(dbPath: string) { - return { - ...this.equalityConditions(dbPath, x => Number(x) || -1), - ...this.orderingConditions(dbPath, x => Number(x) || -1), - ...this.inConditions(dbPath, x => x.map((xx: any) => Number(xx) || -1)), - }; - } -} diff --git a/packages-next/fields/src/types/autoIncrement/index.ts b/packages-next/fields/src/types/autoIncrement/index.ts index ae298fbc368..ceb6ef68e0f 100644 --- a/packages-next/fields/src/types/autoIncrement/index.ts +++ b/packages-next/fields/src/types/autoIncrement/index.ts @@ -1,23 +1,128 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldDefaultValue, + fieldType, + FieldTypeFunc, + CommonFieldConfig, + legacyFilters, + orderDirectionEnum, + schema, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { AutoIncrementImplementation, PrismaAutoIncrementInterface } from './Implementation'; +import { getIndexType } from '../../get-index-type'; export type AutoIncrementFieldConfig = - FieldConfig & { + CommonFieldConfig & { + defaultValue?: FieldDefaultValue; isRequired?: boolean; + isIndexed?: boolean; isUnique?: boolean; - defaultValue?: FieldDefaultValue; + gqlType?: 'ID' | 'Int'; + }; + +export const autoIncrement = + ({ + isRequired, + defaultValue, + isIndexed, + isUnique, + gqlType, + ...config + }: AutoIncrementFieldConfig = {}): FieldTypeFunc => + meta => { + const type = meta.fieldKey === 'id' || gqlType === 'ID' ? schema.ID : schema.Int; + const __legacy = { + isRequired, + defaultValue, + filters: { + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, type), + ...legacyFilters.fields.orderingInputFields(meta.fieldKey, type), + ...legacyFilters.fields.inInputFields(meta.fieldKey, type), + }, + impls: { + ...equalityConditions(meta.fieldKey, x => Number(x) || -1), + ...legacyFilters.impls.orderingConditions(meta.fieldKey, x => Number(x) || -1), + ...inConditions(meta.fieldKey, x => x.map((xx: any) => Number(xx) || -1)), + }, + }, + }; + if (meta.fieldKey === 'id') { + return fieldType({ + kind: 'scalar', + mode: 'required', + scalar: 'Int', + default: { kind: 'autoincrement' }, + })({ + ...config, + input: { + // TODO: fix the fact that TS did not catch that a resolver is needed here + uniqueWhere: { + arg: schema.arg({ type }), + resolve(value) { + return Number(value); + }, + }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ + type: schema.nonNull(schema.ID), + resolve({ value }) { + return value.toString(); + }, + }), + views: resolveView('integer/views'), + __legacy, + }); + } + const inputResolver = (val: number | string | null | undefined) => { + if (val == null) { + return val; + } + return Number(val); + }; + return fieldType({ + kind: 'scalar', + mode: 'optional', + scalar: 'Int', + default: { kind: 'autoincrement' }, + index: getIndexType({ isIndexed, isUnique }), + })({ + ...config, + input: { + uniqueWhere: isUnique ? { arg: schema.arg({ type }), resolve: x => Number(x) } : undefined, + create: { arg: schema.arg({ type }), resolve: inputResolver }, + update: { arg: schema.arg({ type }), resolve: inputResolver }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ + type, + resolve({ value }) { + if (value === null) return null; + return type === schema.ID ? value.toString() : value; + }, + }), + views: resolveView('integer/views'), + __legacy, + }); }; -export const autoIncrement = ( - config: AutoIncrementFieldConfig = {} -): FieldType => ({ - type: { - type: 'AutoIncrement', - implementation: AutoIncrementImplementation, - adapter: PrismaAutoIncrementInterface, - }, - config, - views: resolveView('integer/views'), -}); +function equalityConditions(fieldKey: string, f: (a: any) => any) { + return { + [fieldKey]: (value: T) => ({ [fieldKey]: f(value) }), + [`${fieldKey}_not`]: (value: T) => ({ NOT: { [fieldKey]: f(value) } }), + }; +} + +function inConditions(fieldKey: string, f: (a: any) => any) { + return { + [`${fieldKey}_in`]: (value: (T | null)[]) => + value.includes(null) + ? { [fieldKey]: { in: f(value.filter(x => x !== null)) } } + : { [fieldKey]: { in: f(value) } }, + [`${fieldKey}_not_in`]: (value: (T | null)[]) => + value.includes(null) + ? { AND: [{ NOT: { [fieldKey]: { in: f(value.filter(x => x !== null)) } } }] } + : { NOT: { [fieldKey]: { in: f(value) } } }, + }; +} diff --git a/packages-next/fields/src/types/checkbox/Implementation.ts b/packages-next/fields/src/types/checkbox/Implementation.ts deleted file mode 100644 index fb2f0a1a7a3..00000000000 --- a/packages-next/fields/src/types/checkbox/Implementation.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class Checkbox

extends Implementation

{ - constructor(path: P, configArgs: FieldConfigArgs, extraArgs: FieldExtraArgs) { - super(path, configArgs, extraArgs); - this.isOrderable = true; - } - - get _supportsUnique() { - return false; - } - - gqlOutputFields() { - return [`${this.path}: Boolean`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - - gqlQueryInputFields() { - return this.equalityInputFields('Boolean'); - } - gqlUpdateInputFields() { - return [`${this.path}: Boolean`]; - } - gqlCreateInputFields() { - return [`${this.path}: Boolean`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'boolean | null' } }; - } -} - -export class PrismaCheckboxInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: Checkbox

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - - // Error rather than ignoring invalid config - if (this.config.isIndexed) { - throw ( - `The Checkbox field type doesn't support indexes on Prisma. ` + - `Check the config for ${this.path} on the ${this.field.listKey} list` - ); - } - } - getPrismaSchema() { - return [this._schemaField({ type: 'Boolean' })]; - } - - getQueryConditions(dbPath: string) { - return this.equalityConditions(dbPath); - } -} diff --git a/packages-next/fields/src/types/checkbox/index.ts b/packages-next/fields/src/types/checkbox/index.ts index f5a0d906e15..1e320de29a6 100644 --- a/packages-next/fields/src/types/checkbox/index.ts +++ b/packages-next/fields/src/types/checkbox/index.ts @@ -1,22 +1,50 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldDefaultValue, + CommonFieldConfig, + fieldType, + FieldTypeFunc, + legacyFilters, + orderDirectionEnum, + schema, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Checkbox, PrismaCheckboxInterface } from './Implementation'; export type CheckboxFieldConfig = - FieldConfig & { + CommonFieldConfig & { defaultValue?: FieldDefaultValue; isRequired?: boolean; }; -export const checkbox = ( - config: CheckboxFieldConfig = {} -): FieldType => ({ - type: { - type: 'Checkbox', - implementation: Checkbox, - adapter: PrismaCheckboxInterface, - }, - config, - views: resolveView('checkbox/views'), -}); +export const checkbox = + ({ + isRequired, + defaultValue, + ...config + }: CheckboxFieldConfig = {}): FieldTypeFunc => + meta => { + if ((config as any).isUnique) { + throw Error('isUnique is not a supported option for field type checkbox'); + } + + return fieldType({ kind: 'scalar', mode: 'optional', scalar: 'Boolean' })({ + ...config, + input: { + create: { arg: schema.arg({ type: schema.Boolean }) }, + update: { arg: schema.arg({ type: schema.Boolean }) }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ + type: schema.Boolean, + }), + views: resolveView('checkbox/views'), + __legacy: { + filters: { + fields: legacyFilters.fields.equalityInputFields(meta.fieldKey, schema.Boolean), + impls: legacyFilters.impls.equalityConditions(meta.fieldKey), + }, + isRequired, + defaultValue, + }, + }); + }; diff --git a/packages-next/fields/src/types/decimal/Implementation.ts b/packages-next/fields/src/types/decimal/Implementation.ts deleted file mode 100644 index cde58bf980b..00000000000 --- a/packages-next/fields/src/types/decimal/Implementation.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Decimal as _Decimal } from 'decimal.js'; -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class Decimal

extends Implementation

{ - symbol?: string; - constructor( - path: P, - { symbol, ...configArgs }: FieldConfigArgs & { symbol?: string }, - extraArgs: FieldExtraArgs - ) { - super(path, { symbol, ...configArgs }, extraArgs); - this.symbol = symbol; - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: String`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - - gqlQueryInputFields() { - return [ - ...this.equalityInputFields('String'), - ...this.orderingInputFields('String'), - ...(this.adapter.listAdapter.parentAdapter.provider === 'postgresql' - ? [] - : this.inInputFields('String')), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: String`]; - } - gqlCreateInputFields() { - return [`${this.path}: String`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -export class PrismaDecimalInterface

extends PrismaFieldAdapter

{ - isUnique: boolean; - isIndexed: boolean; - precision: null | number; - scale: null | number; - - constructor( - fieldName: string, - path: P, - field: Decimal

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - if (this.listAdapter.parentAdapter.provider === 'sqlite') { - throw new Error( - `PrismaAdapter provider "sqlite" does not support field type "${this.field.constructor.name}"` - ); - } - const { precision, scale } = this.config; - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - - this.precision = precision === null ? null : parseInt(precision) || 18; - this.scale = scale === null ? null : (this.precision, parseInt(scale) || 4); - if (this.scale !== null && this.precision !== null && this.scale > this.precision) { - throw ( - `The scale configured for Decimal field '${this.path}' (${this.scale}) ` + - `must not be larger than the field's precision (${this.precision})` - ); - } - } - - getPrismaSchema() { - return [ - this._schemaField({ - type: 'Decimal', - extra: `@postgresql.Decimal(${this.precision}, ${this.scale})`, - }), - ]; - } - - setupHooks({ - addPreSaveHook, - addPostReadHook, - }: { - addPreSaveHook: (hook: any) => void; - addPostReadHook: (hook: any) => void; - }) { - // Updates the relevant value in the item provided (by reference) - addPreSaveHook((item: Record) => { - // Only run the hook if the item actually contains the field - if (!(this.path in item)) { - return item; - } - - if (item[this.path]) { - if (typeof item[this.path] === 'string') { - item[this.path] = new _Decimal(item[this.path]); - } else { - // Should have been caught by the validator?? - throw `Invalid Decimal value given for '${this.path}'`; - } - } else { - item[this.path] = null; - } - - return item; - }); - - addPostReadHook((item: Record) => { - if (item[this.path]) { - item[this.path] = item[this.path].toFixed(this.scale); - } - return item; - }); - } - - getQueryConditions(dbPath: string) { - return { - ...this.equalityConditions(dbPath), - ...this.orderingConditions(dbPath), - }; - } -} diff --git a/packages-next/fields/src/types/decimal/index.ts b/packages-next/fields/src/types/decimal/index.ts index 5faafa5d0c1..a4b234cdda0 100644 --- a/packages-next/fields/src/types/decimal/index.ts +++ b/packages-next/fields/src/types/decimal/index.ts @@ -1,29 +1,112 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + fieldType, + FieldTypeFunc, + BaseGeneratedListTypes, + CommonFieldConfig, + schema, + orderDirectionEnum, + Decimal, + legacyFilters, + FieldDefaultValue, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Decimal, PrismaDecimalInterface } from './Implementation'; +import { getIndexType } from '../../get-index-type'; export type DecimalFieldConfig = - FieldConfig & { + CommonFieldConfig & { isRequired?: boolean; - isUnique?: boolean; precision?: number; scale?: number; defaultValue?: FieldDefaultValue; + isIndexed?: boolean; + isUnique?: boolean; }; -export const decimal = ( - config: DecimalFieldConfig = {} -): FieldType => ({ - type: { - type: 'Decimal', - implementation: Decimal, - adapter: PrismaDecimalInterface, - }, - config, - views: resolveView('decimal/views'), - getAdminMeta: () => ({ - precision: config.precision || null, - scale: config.scale || null, - }), -}); +export const decimal = + ({ + isIndexed, + isUnique, + precision = 18, + scale = 4, + isRequired, + defaultValue, + ...config + }: DecimalFieldConfig = {}): FieldTypeFunc => + meta => { + if (meta.provider === 'sqlite') { + throw new Error('The decimal field does not support sqlite'); + } + + if (!Number.isInteger(scale)) { + throw new Error( + `The scale for decimal fields must be an integer but the scale for the decimal field at ${meta.listKey}.${meta.fieldKey} is not an integer` + ); + } + + if (!Number.isInteger(precision)) { + throw new Error( + `The precision for decimal fields must be an integer but the precision for the decimal field at ${meta.listKey}.${meta.fieldKey} is not an integer` + ); + } + + if (scale > precision) { + throw new Error( + `The scale configured for decimal field at ${meta.listKey}.${meta.fieldKey} (${scale}) ` + + `must not be larger than the field's precision (${precision})` + ); + } + const index = getIndexType({ isIndexed, isUnique }); + + return fieldType({ + kind: 'scalar', + mode: 'optional', + scalar: 'Decimal', + nativeType: `Decimal(${precision}, ${scale})`, + index, + })({ + ...config, + input: { + create: { + arg: schema.arg({ type: schema.String }), + resolve(val) { + if (val == null) return val; + return new Decimal(val); + }, + }, + update: { + arg: schema.arg({ type: schema.String }), + resolve(val) { + if (val == null) return val; + return new Decimal(val); + }, + }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ + type: schema.String, + resolve({ value }) { + if (value === null) return null; + return value.toFixed(scale); + }, + }), + views: resolveView('decimal/views'), + getAdminMeta: () => ({ + precision, + scale, + }), + __legacy: { + filters: { + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, schema.String), + ...legacyFilters.fields.orderingInputFields(meta.fieldKey, schema.String), + }, + impls: { + ...legacyFilters.impls.equalityConditions(meta.fieldKey), + ...legacyFilters.impls.orderingConditions(meta.fieldKey), + }, + }, + isRequired, + defaultValue, + }, + }); + }; diff --git a/packages-next/fields/src/types/file/Implementation.ts b/packages-next/fields/src/types/file/Implementation.ts deleted file mode 100644 index 374c2543320..00000000000 --- a/packages-next/fields/src/types/file/Implementation.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { FileUpload } from 'graphql-upload'; -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { getFileRef } from '@keystone-next/utils-legacy'; -import { FileData, KeystoneContext, BaseKeystoneList } from '@keystone-next/types'; -import { Implementation } from '../../Implementation'; - -const MISSING_CONFIG_ERROR = - 'File context is undefined, this most likely means that you havent configurd keystone with a file config, see https://next.keystonejs.com/apis/config#files for details'; - -export class FileImplementation

extends Implementation

{ - get _supportsUnique() { - return false; - } - - gqlOutputFields() { - return [`${this.path}: FileFieldOutput`]; - } - - getGqlAuxTypes() { - return [ - ` - input FileFieldInput { - upload: Upload - ref: String - } - interface FileFieldOutput { - filename: String! - filesize: Int! - ref: String! - src: String! - } - type LocalFileFieldOutput implements FileFieldOutput { - filename: String! - filesize: Int! - ref: String! - src: String! - }`, - ]; - } - - gqlAuxFieldResolvers() { - return { - FileFieldOutput: { - __resolveType() { - return 'LocalFileFieldOutput'; - }, - }, - LocalFileFieldOutput: { - src(data: FileData, _args: any, context: KeystoneContext) { - if (!context.files) { - throw new Error(MISSING_CONFIG_ERROR); - } - return context.files.getSrc(data.mode, data.filename); - }, - ref(data: FileData, _args: any, context: KeystoneContext) { - if (!context.files) { - throw new Error(MISSING_CONFIG_ERROR); - } - return getFileRef(data.mode, data.filename); - }, - }, - }; - } - // Called on `User.avatar` for example - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - - async resolveInput({ - resolvedData, - context, - }: { - resolvedData: Record; - context: KeystoneContext; - }) { - const data = resolvedData[this.path]; - if (data === null) { - return null; - } - if (data === undefined) { - return undefined; - } - - type FileInput = { - upload?: Promise | null; - ref?: string | null; - }; - const { ref, upload }: FileInput = data; - if (ref) { - if (upload) { - throw new Error('Only one of ref and upload can be passed to FileFieldInput'); - } - return context.files!.getDataFromRef(ref); - } - if (!upload) { - throw new Error('Either ref or upload must be passed to FileFieldInput'); - } - - const uploadedFile = await upload; - return context.files!.getDataFromStream(uploadedFile.createReadStream(), uploadedFile.filename); - } - - gqlUpdateInputFields() { - return [`${this.path}: FileFieldInput`]; - } - gqlCreateInputFields() { - return [`${this.path}: FileFieldInput`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'Record | null' } }; - } -} - -export class PrismaFileInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: FileImplementation

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - // Error rather than ignoring invalid config - // We totally can index these values, it's just not trivial. See issue #1297 - if (this.config.isIndexed) { - throw new Error( - `The File field type doesn't support indexes on Prisma. ` + - `Check the config for ${this.path} on the ${this.field.listKey} list` - ); - } - } - - getPrismaSchema() { - return [ - `${this.path}_filesize Int?`, - `${this.path}_mode String?`, - `${this.path}_filename String?`, - ]; - } - - getQueryConditions() { - return {}; - } - - setupHooks({ - addPreSaveHook, - addPostReadHook, - }: { - addPreSaveHook: (hook: any) => void; - addPostReadHook: (hook: any) => void; - }) { - const field_path = this.path; - const filesize_field = `${this.path}_filesize`; - const mode_field = `${this.path}_mode`; - const name_field = `${this.path}_filename`; - - addPreSaveHook((item: Record): Record => { - if (!Object.prototype.hasOwnProperty.call(item, field_path)) { - return item; - } - if (item[field_path as P] === null) { - // If the property exists on the field but is null or falsey - // all split fields are null - // delete the original field item - // return the item - const newItem = { - [filesize_field]: null, - [name_field]: null, - [mode_field]: null, - ...item, - }; - delete newItem[field_path]; - return newItem; - } else { - const { mode, filesize, filename } = item[field_path]; - - const newItem = { - [filesize_field]: filesize, - [name_field]: filename, - [mode_field]: mode, - ...item, - }; - - delete newItem[field_path]; - - return newItem; - } - }); - addPostReadHook((item: Record): Record => { - if (!item[filesize_field] || !item[name_field] || !item[mode_field]) { - item[field_path] = null; - return item; - } - item[field_path] = { - filesize: item[filesize_field], - filename: item[name_field], - mode: item[mode_field], - }; - - delete item[filesize_field]; - delete item[name_field]; - delete item[mode_field]; - - return item; - }); - } -} diff --git a/packages-next/fields/src/types/file/index.ts b/packages-next/fields/src/types/file/index.ts index 7b8d20ee2be..7050f085b17 100644 --- a/packages-next/fields/src/types/file/index.ts +++ b/packages-next/fields/src/types/file/index.ts @@ -1,21 +1,128 @@ -import type { FieldType, BaseGeneratedListTypes } from '@keystone-next/types'; +import { + fieldType, + schema, + FieldTypeFunc, + CommonFieldConfig, + BaseGeneratedListTypes, + KeystoneContext, + FileData, + FieldDefaultValue, +} from '@keystone-next/types'; +import { getFileRef } from '@keystone-next/utils-legacy'; +import { FileUpload } from 'graphql-upload'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { FileImplementation, PrismaFileInterface } from './Implementation'; export type FileFieldConfig = - FieldConfig & { + CommonFieldConfig & { isRequired?: boolean; + defaultValue?: FieldDefaultValue; }; -export const file = ( - config: FileFieldConfig = {} -): FieldType => ({ - type: { - type: 'File', - implementation: FileImplementation, - adapter: PrismaFileInterface, - }, - config, - views: resolveView('file/views'), +const FileFieldInput = schema.inputObject({ + name: 'FileFieldInput', + fields: { upload: schema.arg({ type: schema.Upload }), ref: schema.arg({ type: schema.String }) }, }); + +type FileFieldInputType = + | undefined + | null + | { upload?: Promise | null; ref?: string | null }; + +const fileFields = schema.fields()({ + filename: schema.field({ type: schema.nonNull(schema.String) }), + filesize: schema.field({ type: schema.nonNull(schema.Int) }), + ref: schema.field({ + type: schema.nonNull(schema.String), + resolve(data) { + return getFileRef(data.mode, data.filename); + }, + }), + src: schema.field({ + type: schema.nonNull(schema.String), + resolve(data, args, context) { + if (!context.files) { + throw new Error( + 'File context is undefined, this most likely means that you havent configurd keystone with a file config, see https://next.keystonejs.com/apis/config#files for details' + ); + } + return context.files.getSrc(data.mode, data.filename); + }, + }), +}); + +const FileFieldOutput = schema.interface()({ + name: 'FileFieldOutput', + fields: fileFields, + resolveType: () => 'LocalFileFieldOutput', +}); + +const LocalFileFieldOutput = schema.object()({ + name: 'LocalFileFieldOutput', + interfaces: [FileFieldOutput], + fields: fileFields, +}); + +async function inputResolver(data: FileFieldInputType, context: KeystoneContext) { + if (data === null || data === undefined) { + return { mode: data, filename: data, filesize: data }; + } + + if (data.ref) { + if (data.upload) { + throw new Error('Only one of ref and upload can be passed to FileFieldInput'); + } + return context.files!.getDataFromRef(data.ref); + } + if (!data.upload) { + throw new Error('Either ref or upload must be passed to FileFieldInput'); + } + const upload = await data.upload; + return context.files!.getDataFromStream(upload.createReadStream(), upload.filename); +} + +export const file = + ({ + isRequired, + defaultValue, + ...config + }: FileFieldConfig = {}): FieldTypeFunc => + () => { + if ((config as any).isUnique) { + throw Error('isUnique is not a supported option for field type file'); + } + + return fieldType({ + kind: 'multi', + fields: { + filesize: { kind: 'scalar', scalar: 'Int', mode: 'optional' }, + mode: { kind: 'scalar', scalar: 'String', mode: 'optional' }, + filename: { kind: 'scalar', scalar: 'String', mode: 'optional' }, + }, + })({ + ...config, + input: { + create: { arg: schema.arg({ type: FileFieldInput }), resolve: inputResolver }, + update: { arg: schema.arg({ type: FileFieldInput }), resolve: inputResolver }, + }, + output: schema.field({ + type: FileFieldOutput, + resolve({ value: { filesize, filename, mode } }) { + if ( + filesize === null || + filename === null || + mode === null || + (mode !== 'local' && mode !== 'keystone-cloud') + ) { + return null; + } + return { mode, filename, filesize }; + }, + }), + unreferencedConcreteInterfaceImplementations: [LocalFileFieldOutput], + views: resolveView('file/views'), + __legacy: { + isRequired, + defaultValue, + }, + }); + }; diff --git a/packages-next/fields/src/types/float/Implementation.ts b/packages-next/fields/src/types/float/Implementation.ts deleted file mode 100644 index fd390e3c91f..00000000000 --- a/packages-next/fields/src/types/float/Implementation.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class Float

extends Implementation

{ - constructor(path: P, configArgs: FieldConfigArgs, extraArgs: FieldExtraArgs) { - super(path, configArgs, extraArgs); - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: Float`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - - gqlQueryInputFields() { - return [ - ...this.equalityInputFields('Float'), - ...this.orderingInputFields('Float'), - ...this.inInputFields('Float'), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: Float`]; - } - gqlCreateInputFields() { - return [`${this.path}: Float`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'number | null' } }; - } -} - -export class PrismaFloatInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: Float

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - } - - getPrismaSchema() { - return [this._schemaField({ type: 'Float' })]; - } - - getQueryConditions(dbPath: string) { - return { - ...this.equalityConditions(dbPath), - ...this.orderingConditions(dbPath), - ...this.inConditions(dbPath), - }; - } -} diff --git a/packages-next/fields/src/types/float/index.ts b/packages-next/fields/src/types/float/index.ts index 166a48b9334..c1baa891d85 100644 --- a/packages-next/fields/src/types/float/index.ts +++ b/packages-next/fields/src/types/float/index.ts @@ -1,24 +1,61 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldTypeFunc, + CommonFieldConfig, + fieldType, + schema, + orderDirectionEnum, + legacyFilters, + FieldDefaultValue, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Float, PrismaFloatInterface } from './Implementation'; +import { getIndexType } from '../../get-index-type'; export type FloatFieldConfig = - FieldConfig & { + CommonFieldConfig & { + defaultValue?: FieldDefaultValue; isRequired?: boolean; - isUnique?: boolean; isIndexed?: boolean; - defaultValue?: FieldDefaultValue; + isUnique?: boolean; }; -export const float = ( - config: FloatFieldConfig = {} -): FieldType => ({ - type: { - type: 'Float', - implementation: Float, - adapter: PrismaFloatInterface, - }, - config, - views: resolveView('float/views'), -}); +export const float = + ({ + isIndexed, + isUnique, + isRequired, + defaultValue, + ...config + }: FloatFieldConfig = {}): FieldTypeFunc => + meta => + fieldType({ + kind: 'scalar', + mode: 'optional', + scalar: 'Float', + index: getIndexType({ isIndexed, isUnique }), + })({ + ...config, + input: { + create: { arg: schema.arg({ type: schema.Float }) }, + update: { arg: schema.arg({ type: schema.Float }) }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ type: schema.Float }), + views: resolveView('float/views'), + __legacy: { + filters: { + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, schema.Float), + ...legacyFilters.fields.orderingInputFields(meta.fieldKey, schema.Float), + ...legacyFilters.fields.inInputFields(meta.fieldKey, schema.Float), + }, + impls: { + ...legacyFilters.impls.equalityConditions(meta.fieldKey), + ...legacyFilters.impls.orderingConditions(meta.fieldKey), + ...legacyFilters.impls.inConditions(meta.fieldKey), + }, + }, + isRequired, + defaultValue, + }, + }); diff --git a/packages-next/fields/src/types/image/Implementation.ts b/packages-next/fields/src/types/image/Implementation.ts deleted file mode 100644 index 9b45b4393c6..00000000000 --- a/packages-next/fields/src/types/image/Implementation.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { FileUpload } from 'graphql-upload'; -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { getImageRef, SUPPORTED_IMAGE_EXTENSIONS } from '@keystone-next/utils-legacy'; -import { ImageData, KeystoneContext, BaseKeystoneList } from '@keystone-next/types'; -import { Implementation } from '../../Implementation'; - -export class ImageImplementation

extends Implementation

{ - get _supportsUnique() { - return false; - } - - gqlOutputFields() { - return [`${this.path}: ImageFieldOutput`]; - } - - getGqlAuxTypes() { - return [ - `input ImageFieldInput { - upload: Upload - ref: String - } - enum ImageExtension { - ${SUPPORTED_IMAGE_EXTENSIONS.join('\n')} - } - interface ImageFieldOutput { - id: ID! - filesize: Int! - width: Int! - height: Int! - extension: ImageExtension! - ref: String! - src: String! - } - type LocalImageFieldOutput implements ImageFieldOutput { - id: ID! - filesize: Int! - width: Int! - height: Int! - extension: ImageExtension! - ref: String! - src: String! - }`, - ]; - } - - gqlAuxFieldResolvers() { - return { - ImageFieldOutput: { - __resolveType() { - return 'LocalImageFieldOutput'; - }, - }, - LocalImageFieldOutput: { - src(data: ImageData, _args: any, context: KeystoneContext) { - if (!context.images) { - throw new Error('Image context is undefined'); - } - return context.images.getSrc(data.mode, data.id, data.extension); - }, - ref(data: ImageData, _args: any, context: KeystoneContext) { - if (!context.images) { - throw new Error('Image context is undefined'); - } - return getImageRef(data.mode, data.id, data.extension); - }, - }, - }; - } - // Called on `User.avatar` for example - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - - async resolveInput({ - resolvedData, - context, - }: { - resolvedData: Record; - context: KeystoneContext; - }) { - const data = resolvedData[this.path]; - if (data === null) { - return null; - } - if (data === undefined) { - return undefined; - } - - type ImageInput = { - upload?: Promise | null; - ref?: string | null; - }; - const { ref, upload }: ImageInput = data; - if (ref) { - if (upload) { - throw new Error('Only one of ref and upload can be passed to ImageFieldInput'); - } - return context.images!.getDataFromRef(ref); - } - if (!upload) { - throw new Error('Either ref or upload must be passed to ImageFieldInput'); - } - return context.images!.getDataFromStream((await upload).createReadStream()); - } - - gqlUpdateInputFields() { - return [`${this.path}: ImageFieldInput`]; - } - gqlCreateInputFields() { - return [`${this.path}: ImageFieldInput`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'Record | null' } }; - } -} - -export class PrismaImageInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: ImageImplementation

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - // Error rather than ignoring invalid config - // We totally can index these values, it's just not trivial. See issue #1297 - if (this.config.isIndexed) { - throw new Error( - `The Image field type doesn't support indexes on Prisma. ` + - `Check the config for ${this.path} on the ${this.field.listKey} list` - ); - } - } - - getPrismaSchema() { - return [ - `${this.path}_filesize Int?`, - `${this.path}_extension String?`, - `${this.path}_width Int?`, - `${this.path}_height Int?`, - `${this.path}_mode String?`, - `${this.path}_id String?`, - ]; - } - - getQueryConditions() { - return {}; - } - - setupHooks({ - addPreSaveHook, - addPostReadHook, - }: { - addPreSaveHook: (hook: any) => void; - addPostReadHook: (hook: any) => void; - }) { - const field_path = this.path; - const filesize_field = `${this.path}_filesize`; - const extension_field = `${this.path}_extension`; - const width_field = `${this.path}_width`; - const height_field = `${this.path}_height`; - const mode_field = `${this.path}_mode`; - const id_field = `${this.path}_id`; - - addPreSaveHook((item: Record): Record => { - if (!Object.prototype.hasOwnProperty.call(item, field_path)) { - return item; - } - if (item[field_path as P] === null) { - // If the property exists on the field but is null or falsey - // all split fields are null - // delete the original field item - // return the item - const newItem = { - [filesize_field]: null, - [extension_field]: null, - [width_field]: null, - [height_field]: null, - [id_field]: null, - [mode_field]: null, - ...item, - }; - delete newItem[field_path]; - return newItem; - } else { - const { mode, filesize, extension, width, height, id } = item[field_path]; - - const newItem = { - [filesize_field]: filesize, - [extension_field]: extension, - [width_field]: width, - [height_field]: height, - [id_field]: id, - [mode_field]: mode, - ...item, - }; - - delete newItem[field_path]; - - return newItem; - } - }); - addPostReadHook((item: Record): Record => { - if ( - !item[filesize_field] || - !item[extension_field] || - !item[width_field] || - !item[height_field] || - !item[id_field] || - !item[mode_field] - ) { - item[field_path] = null; - return item; - } - item[field_path] = { - filesize: item[filesize_field], - extension: item[extension_field], - width: item[width_field], - height: item[height_field], - id: item[id_field], - mode: item[mode_field], - }; - - delete item[filesize_field]; - delete item[extension_field]; - delete item[width_field]; - delete item[height_field]; - delete item[id_field]; - delete item[mode_field]; - - return item; - }); - } -} diff --git a/packages-next/fields/src/types/image/index.ts b/packages-next/fields/src/types/image/index.ts index a2088c6055a..59110f6a209 100644 --- a/packages-next/fields/src/types/image/index.ts +++ b/packages-next/fields/src/types/image/index.ts @@ -1,21 +1,147 @@ -import type { FieldType, BaseGeneratedListTypes } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldDefaultValue, + fieldType, + FieldTypeFunc, + CommonFieldConfig, + ImageData, + ImageExtension, + KeystoneContext, + schema, +} from '@keystone-next/types'; +import { getImageRef, SUPPORTED_IMAGE_EXTENSIONS } from '@keystone-next/utils-legacy'; +import { FileUpload } from 'graphql-upload'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { ImageImplementation, PrismaImageInterface } from './Implementation'; export type ImageFieldConfig = - FieldConfig & { + CommonFieldConfig & { + defaultValue?: FieldDefaultValue; isRequired?: boolean; }; -export const image = ( - config: ImageFieldConfig = {} -): FieldType => ({ - type: { - type: 'Image', - implementation: ImageImplementation, - adapter: PrismaImageInterface, - }, - config, - views: resolveView('image/views'), +const ImageExtensionEnum = schema.enum({ + name: 'ImageExtension', + values: schema.enumValues(SUPPORTED_IMAGE_EXTENSIONS), }); + +const ImageFieldInput = schema.inputObject({ + name: 'ImageFieldInput', + fields: { upload: schema.arg({ type: schema.Upload }), ref: schema.arg({ type: schema.String }) }, +}); + +const imageOutputFields = schema.fields()({ + id: schema.field({ type: schema.nonNull(schema.ID) }), + filesize: schema.field({ type: schema.nonNull(schema.Int) }), + width: schema.field({ type: schema.nonNull(schema.Int) }), + height: schema.field({ type: schema.nonNull(schema.Int) }), + extension: schema.field({ type: schema.nonNull(ImageExtensionEnum) }), + ref: schema.field({ + type: schema.nonNull(schema.String), + resolve(data) { + return getImageRef(data.mode, data.id, data.extension); + }, + }), + src: schema.field({ + type: schema.nonNull(schema.String), + resolve(data, args, context) { + if (!context.images) { + throw new Error('Image context is undefined'); + } + return context.images.getSrc(data.mode, data.id, data.extension); + }, + }), +}); + +const ImageFieldOutput = schema.interface()({ + name: 'ImageFieldOutput', + fields: imageOutputFields, + resolveType: () => 'LocalImageFieldOutput', +}); + +const LocalImageFieldOutput = schema.object()({ + name: 'LocalImageFieldOutput', + interfaces: [ImageFieldOutput], + fields: imageOutputFields, +}); + +type ImageFieldInputType = + | undefined + | null + | { upload?: Promise | null; ref?: string | null }; + +async function inputResolver(data: ImageFieldInputType, context: KeystoneContext) { + if (data === null || data === undefined) { + return { extension: data, filesize: data, height: data, id: data, mode: data, width: data }; + } + + if (data.ref) { + if (data.upload) { + throw new Error('Only one of ref and upload can be passed to ImageFieldInput'); + } + return context.images!.getDataFromRef(data.ref); + } + if (!data.upload) { + throw new Error('Either ref or upload must be passed to ImageFieldInput'); + } + return context.images!.getDataFromStream((await data.upload).createReadStream()); +} + +const extensionsSet = new Set(SUPPORTED_IMAGE_EXTENSIONS); + +function isValidImageExtension(extension: string): extension is ImageExtension { + return extensionsSet.has(extension); +} + +export const image = + ({ + isRequired, + defaultValue, + ...config + }: ImageFieldConfig = {}): FieldTypeFunc => + () => { + if ((config as any).isUnique) { + throw Error('isUnique is not a supported option for field type image'); + } + + return fieldType({ + kind: 'multi', + fields: { + filesize: { kind: 'scalar', scalar: 'Int', mode: 'optional' }, + extension: { kind: 'scalar', scalar: 'String', mode: 'optional' }, + width: { kind: 'scalar', scalar: 'Int', mode: 'optional' }, + height: { kind: 'scalar', scalar: 'Int', mode: 'optional' }, + mode: { kind: 'scalar', scalar: 'String', mode: 'optional' }, + id: { kind: 'scalar', scalar: 'String', mode: 'optional' }, + }, + })({ + ...config, + input: { + create: { arg: schema.arg({ type: ImageFieldInput }), resolve: inputResolver }, + update: { arg: schema.arg({ type: ImageFieldInput }), resolve: inputResolver }, + }, + output: schema.field({ + type: ImageFieldOutput, + resolve({ value: { extension, filesize, height, id, mode, width } }) { + if ( + extension === null || + !isValidImageExtension(extension) || + filesize === null || + height === null || + width === null || + id === null || + mode === null || + (mode !== 'local' && mode !== 'keystone-cloud') + ) { + return null; + } + return { mode, extension, filesize, height, width, id }; + }, + }), + unreferencedConcreteInterfaceImplementations: [LocalImageFieldOutput], + views: resolveView('image/views'), + __legacy: { + isRequired, + defaultValue, + }, + }); + }; diff --git a/packages-next/fields/src/types/integer/Implementation.ts b/packages-next/fields/src/types/integer/Implementation.ts deleted file mode 100644 index ebd1e2cb2ec..00000000000 --- a/packages-next/fields/src/types/integer/Implementation.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class Integer

extends Implementation

{ - constructor(path: P, configArgs: FieldConfigArgs, extraArgs: FieldExtraArgs) { - super(path, configArgs, extraArgs); - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: Int`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - - gqlQueryInputFields() { - return [ - ...this.equalityInputFields('Int'), - ...this.orderingInputFields('Int'), - ...this.inInputFields('Int'), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: Int`]; - } - gqlCreateInputFields() { - return [`${this.path}: Int`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'number | null' } }; - } -} - -export class PrismaIntegerInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: Integer

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - } - - getPrismaSchema() { - return [this._schemaField({ type: 'Int' })]; - } - - getQueryConditions(dbPath: string) { - return { - ...this.equalityConditions(dbPath), - ...this.orderingConditions(dbPath), - ...this.inConditions(dbPath), - }; - } -} diff --git a/packages-next/fields/src/types/integer/index.ts b/packages-next/fields/src/types/integer/index.ts index ce256d5894a..744bc9d849a 100644 --- a/packages-next/fields/src/types/integer/index.ts +++ b/packages-next/fields/src/types/integer/index.ts @@ -1,24 +1,62 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldDefaultValue, + fieldType, + FieldTypeFunc, + CommonFieldConfig, + legacyFilters, + orderDirectionEnum, + schema, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Integer, PrismaIntegerInterface } from './Implementation'; +import { getIndexType } from '../../get-index-type'; export type IntegerFieldConfig = - FieldConfig & { + CommonFieldConfig & { + defaultValue?: FieldDefaultValue; isRequired?: boolean; isUnique?: boolean; isIndexed?: boolean; - defaultValue?: FieldDefaultValue; }; -export const integer = ( - config: IntegerFieldConfig = {} -): FieldType => ({ - type: { - type: 'Integer', - implementation: Integer, - adapter: PrismaIntegerInterface, - }, - config, - views: resolveView('integer/views'), -}); +export const integer = + ({ + isIndexed, + isUnique, + isRequired, + defaultValue, + ...config + }: IntegerFieldConfig = {}): FieldTypeFunc => + meta => + fieldType({ + kind: 'scalar', + mode: 'optional', + scalar: 'Int', + index: getIndexType({ isIndexed, isUnique }), + })({ + ...config, + input: { + uniqueWhere: isUnique ? { arg: schema.arg({ type: schema.Int }) } : undefined, + create: { arg: schema.arg({ type: schema.Int }) }, + update: { arg: schema.arg({ type: schema.Int }) }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ type: schema.Int }), + views: resolveView('integer/views'), + __legacy: { + filters: { + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, schema.Int), + ...legacyFilters.fields.orderingInputFields(meta.fieldKey, schema.Int), + ...legacyFilters.fields.inInputFields(meta.fieldKey, schema.Int), + }, + impls: { + ...legacyFilters.impls.equalityConditions(meta.fieldKey), + ...legacyFilters.impls.orderingConditions(meta.fieldKey), + ...legacyFilters.impls.inConditions(meta.fieldKey), + }, + }, + isRequired, + defaultValue, + }, + }); diff --git a/packages-next/fields/src/types/json/Implementation.ts b/packages-next/fields/src/types/json/Implementation.ts deleted file mode 100644 index a729ae258ea..00000000000 --- a/packages-next/fields/src/types/json/Implementation.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { Implementation } from '../../Implementation'; -import { FieldConfigArgs, FieldExtraArgs } from '../../index'; -// eslint-disable-next-line import/no-unresolved - -export class Json

extends Implementation

{ - constructor(path: P, configArgs: FieldConfigArgs, extraArgs: FieldExtraArgs) { - super(path, configArgs, extraArgs); - this.isOrderable = false; - } - - get _supportsUnique() { - return false; - } - - gqlOutputFields(): string[] { - return [`${this.path}: JSON`]; - } - - // Called on `User.avatar` for example - gqlOutputFieldResolvers() { - return { - [this.path]: (item: Record) => { - let document = item[this.path]; - if (this.adapter.listAdapter.parentAdapter.provider === 'sqlite') { - // we store JSON data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - try { - document = JSON.parse(document); - } catch (err) { - console.log(err); - } - } - - return document; - }, - }; - } - - async resolveInput({ resolvedData }: { resolvedData: Record }) { - const data = resolvedData[this.path]; - if (data === null) { - return null; - } - if (data === undefined) { - return undefined; - } - if (this.adapter.listAdapter.parentAdapter.provider === 'sqlite') { - // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - return JSON.stringify(data); - } - return data; - } - - gqlUpdateInputFields() { - return [`${this.path}: JSON`]; - } - gqlCreateInputFields() { - return [`${this.path}: JSON`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'any | null' } }; - } -} - -export class PrismaJsonInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: Json

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - // Error rather than ignoring invalid config - // We totally can index these values, it's just not trivial. See issue #1297 - if (this.config.isIndexed) { - throw new Error( - `The json field type doesn't support indexes on Prisma. ` + - `Check the config for ${this.path} on the ${this.field.listKey} list` - ); - } - } - - getPrismaSchema() { - return [ - this._schemaField({ - type: - this.listAdapter.parentAdapter.provider === 'sqlite' - ? // we store document data as a string on sqlite because Prisma doesn't support Json on sqlite - // https://github.com/prisma/prisma/issues/3786 - 'String' - : 'Json', - }), - ]; - } - - getQueryConditions() { - return {}; - } -} diff --git a/packages-next/fields/src/types/json/index.ts b/packages-next/fields/src/types/json/index.ts index 76c5e6c8260..2e186367943 100644 --- a/packages-next/fields/src/types/json/index.ts +++ b/packages-next/fields/src/types/json/index.ts @@ -1,27 +1,42 @@ -import type { - FieldType, +import { BaseGeneratedListTypes, FieldDefaultValue, JSONValue, + FieldTypeFunc, + CommonFieldConfig, + jsonFieldTypePolyfilledForSQLite, + schema, } from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Json, PrismaJsonInterface } from './Implementation'; export type JsonFieldConfig = - FieldConfig & { + CommonFieldConfig & { defaultValue?: FieldDefaultValue; isRequired?: boolean; }; -export const json = ( - config: JsonFieldConfig = {} -): FieldType => ({ - type: { - type: 'Json', - implementation: Json, - adapter: PrismaJsonInterface, - }, - config, - views: resolveView('json/views'), -}); +export const json = + ({ + isRequired, + defaultValue, + ...config + }: JsonFieldConfig = {}): FieldTypeFunc => + meta => { + if ((config as any).isUnique) { + throw Error('isUnique is not a supported option for field type json'); + } + + return jsonFieldTypePolyfilledForSQLite(meta.provider, { + ...config, + input: { + create: { arg: schema.arg({ type: schema.JSON }) }, + update: { arg: schema.arg({ type: schema.JSON }) }, + }, + output: schema.field({ type: schema.JSON }), + views: resolveView('json/views'), + __legacy: { + defaultValue, + isRequired, + }, + }); + }; diff --git a/packages-next/fields/src/types/password/Implementation.ts b/packages-next/fields/src/types/password/Implementation.ts deleted file mode 100644 index b94fdf3d100..00000000000 --- a/packages-next/fields/src/types/password/Implementation.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -// @ts-ignore -import dumbPasswords from 'dumb-passwords'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -const bcryptHashRegex = /^\$2[aby]?\$\d{1,2}\$[.\/A-Za-z0-9]{53}$/; - -export class Password

extends Implementation

{ - bcrypt: Pick; - rejectCommon: boolean; - minLength: number; - workFactor: number; - - constructor( - path: P, - { - rejectCommon, - minLength = 8, - workFactor = 10, - useCompiledBcrypt, - bcrypt, - ...configArgs - }: FieldConfigArgs & { - rejectCommon?: boolean; - minLength?: number; - workFactor?: number; - useCompiledBcrypt?: boolean; - bcrypt?: Pick; - }, - extraArgs: FieldExtraArgs - ) { - super( - path, - { rejectCommon, minLength, workFactor, useCompiledBcrypt, bcrypt, ...configArgs }, - extraArgs - ); - if (useCompiledBcrypt) { - throw new Error( - `The Password field at ${this.listKey}.${path} specifies the option "useCompiledBcrypt", this has been replaced with a "bcrypt" option which accepts a different implementation of bcrypt(such as the native npm package, "bcrypt")` - ); - } - this.bcrypt = bcrypt || require('bcryptjs'); - - // Sanitise field specific config - this.rejectCommon = !!rejectCommon; - this.minLength = Math.max(minLength, 1); - // Min 4, max: 31, default: 10 - this.workFactor = Math.min(Math.max(workFactor, 4), 31); - - if (this.workFactor < 6) { - console.warn( - `The workFactor for ${this.listKey}.${this.path} is very low! ` + - `This will cause weak hashes!` - ); - } - } - - get _supportsUnique() { - return false; - } - - gqlOutputFields() { - return [`${this.path}_is_set: Boolean`]; - } - gqlOutputFieldResolvers() { - return { - [`${this.path}_is_set`]: (item: Record) => { - const val = item[this.path]; - return bcryptHashRegex.test(val); - }, - }; - } - - gqlQueryInputFields() { - return [`${this.path}_is_set: Boolean`]; - } - gqlUpdateInputFields() { - return [`${this.path}: String`]; - } - gqlCreateInputFields() { - return [`${this.path}: String`]; - } - - // Wrap bcrypt functionality - // The compare() and compareSync() functions are constant-time - // The compare() and generateHash() functions will return a Promise if no call back is provided - compare(candidate: string, hash: string) { - return this.bcrypt.compare(candidate, hash); - } - compareSync(candidate: string, hash: string) { - return this.bcrypt.compareSync(candidate, hash); - } - generateHash(plaintext: string) { - this.validateNewPassword(plaintext); - return this.bcrypt.hash(plaintext, this.workFactor); - } - generateHashSync(plaintext: string) { - this.validateNewPassword(plaintext); - return this.bcrypt.hashSync(plaintext, this.workFactor); - } - - // Force values to be hashed when set - validateNewPassword(password: string) { - if (this.rejectCommon && dumbPasswords.check(password)) { - throw new Error( - `[password:rejectCommon:${this.listKey}:${this.path}] Common and frequently-used passwords are not allowed.` - ); - } - // TODO: checking string length is not simple; might need to revisit this (see https://mathiasbynens.be/notes/javascript-unicode) - if (String(password).length < this.minLength) { - throw new Error( - `[password:minLength:${this.listKey}:${this.path}] Value must be at least ${this.minLength} characters long.` - ); - } - } - - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -export class PrismaPasswordInterface

extends PrismaFieldAdapter

{ - field: Password

; - constructor( - fieldName: string, - path: P, - field: Password

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - this.field = field; - - // Error rather than ignoring invalid config - if (this.config.isUnique || this.config.isIndexed) { - throw ( - `The Password field type doesn't support indexes on Prisma. ` + - `Check the config for ${this.path} on the ${this.field.listKey} list` - ); - } - } - - getPrismaSchema() { - return [this._schemaField({ type: 'String' })]; - } - - getQueryConditions(dbPath: string) { - // JM: I wonder if performing a regex match here leaks any timing info that - // could be used to extract information about the hash.. :/ - return { - // FIXME: Prisma needs to support regex matching... - [`${this.path}_is_set`]: (value: T | null) => - value ? { NOT: { [dbPath]: null } } : { [dbPath]: null }, - // ? b.where(dbPath, '~', bcryptHashRegex.source) - // : b.where(dbPath, '!~', bcryptHashRegex.source).orWhereNull(dbPath), - }; - } - - setupHooks({ addPreSaveHook }: { addPreSaveHook: (hook: any) => void }) { - // Updates the relevant value in the item provided (by referrence) - addPreSaveHook(async (item: Record) => { - const path = this.field.path; - const plaintext = item[path]; - - // Only run the hook if the item actually contains the field - // NOTE: Can't use hasOwnProperty here, as the mongoose data object - // returned isn't a POJO - if (!(path in item)) { - return item; - } - - if (plaintext) { - if (typeof plaintext === 'string') { - item[path] = await this.field.generateHash(plaintext); - } else { - // Should have been caught by the validator?? - throw `Invalid Password value given for '${path}'`; - } - } else { - item[path] = null; - } - - return item; - }); - } -} diff --git a/packages-next/fields/src/types/password/index.ts b/packages-next/fields/src/types/password/index.ts index 08509c1b998..d2d681fd792 100644 --- a/packages-next/fields/src/types/password/index.ts +++ b/packages-next/fields/src/types/password/index.ts @@ -1,26 +1,118 @@ -import type { FieldType, BaseGeneratedListTypes } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldDefaultValue, + fieldType, + FieldTypeFunc, + CommonFieldConfig, + schema, +} from '@keystone-next/types'; +import bcryptjs from 'bcryptjs'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -// @ts-ignore -import { Password, PrismaPasswordInterface } from './Implementation'; type PasswordFieldConfig = - FieldConfig & { + CommonFieldConfig & { + /** + * @default 8 + */ minLength?: number; - isRequired?: boolean; - isIndexed?: boolean; + /** + * @default 10 + */ + workFactor?: number; bcrypt?: Pick; + defaultValue?: FieldDefaultValue; + isRequired?: boolean; }; -export const password = ( - config: PasswordFieldConfig = {} -): FieldType => ({ - type: { - type: 'Password', - implementation: Password, - adapter: PrismaPasswordInterface, +const PasswordState = schema.object<{ isSet: boolean }>()({ + name: 'PasswordState', + fields: { + isSet: schema.field({ type: schema.nonNull(schema.Boolean) }), }, - config, - views: resolveView('password/views'), - getAdminMeta: () => ({ minLength: config.minLength !== undefined ? config.minLength : 8 }), }); + +const bcryptHashRegex = /^\$2[aby]?\$\d{1,2}\$[.\/A-Za-z0-9]{53}$/; + +export const password = + ({ + bcrypt = bcryptjs, + minLength = 8, + workFactor = 10, + isRequired, + defaultValue, + ...config + }: PasswordFieldConfig = {}): FieldTypeFunc => + meta => { + // TODO: we should just throw not automatically fix it, yeah? + workFactor = Math.min(Math.max(workFactor, 4), 31); + + if (workFactor < 6) { + console.warn( + `The workFactor for ${meta.listKey}.${meta.fieldKey} is very low! ` + + `This will cause weak hashes!` + ); + } + + function inputResolver(val: string | null | undefined) { + if (val === '') { + return null; + } + if (typeof val === 'string') { + return bcrypt.hash(val, 10); + } + return val; + } + + if ((config as any).isUnique) { + throw Error('isUnique is not a supported option for field type password'); + } + + return fieldType({ + kind: 'scalar', + scalar: 'String', + mode: 'optional', + })({ + ...config, + input: { + create: { + arg: schema.arg({ type: schema.String }), + resolve: inputResolver, + }, + update: { + arg: schema.arg({ type: schema.String }), + resolve: inputResolver, + }, + }, + views: resolveView('password/views'), + getAdminMeta: () => ({ minLength: minLength }), + output: schema.field({ + type: PasswordState, + resolve(val) { + return { isSet: val.value !== null && bcryptHashRegex.test(val.value) }; + }, + extensions: { + keystoneSecretField: { + generateHash: async (secret: string) => { + return bcrypt.hash(secret, workFactor); + }, + compare: (secret: string, hash: string) => { + return bcrypt.compare(secret, hash); + }, + }, + }, + }), + __legacy: { + filters: { + fields: { + [`${meta.fieldKey}_is_set`]: schema.arg({ type: schema.Boolean }), + }, + impls: { + [`${meta.fieldKey}_is_set`]: value => + value ? { NOT: { [meta.fieldKey]: null } } : { [meta.fieldKey]: null }, + }, + }, + isRequired, + defaultValue, + }, + }); + }; diff --git a/packages-next/fields/src/types/password/tests/test-fixtures.ts b/packages-next/fields/src/types/password/tests/test-fixtures.ts index 951194bce67..6160d0bd1cd 100644 --- a/packages-next/fields/src/types/password/tests/test-fixtures.ts +++ b/packages-next/fields/src/types/password/tests/test-fixtures.ts @@ -7,7 +7,7 @@ export const exampleValue = () => 'password'; export const exampleValue2 = () => 'password2'; export const supportsUnique = false; export const fieldName = 'password'; -export const readFieldName = 'password_is_set'; +export const subfieldName = 'isSet'; export const skipCreateTest = true; export const skipUpdateTest = true; @@ -26,13 +26,13 @@ export const initItems = () => { }; export const storedValues = () => [ - { name: 'person1', password_is_set: true }, - { name: 'person2', password_is_set: false }, - { name: 'person3', password_is_set: true }, - { name: 'person4', password_is_set: true }, - { name: 'person5', password_is_set: true }, - { name: 'person6', password_is_set: false }, - { name: 'person7', password_is_set: false }, + { name: 'person1', password: { isSet: true } }, + { name: 'person2', password: { isSet: false } }, + { name: 'person3', password: { isSet: true } }, + { name: 'person4', password: { isSet: true } }, + { name: 'person5', password: { isSet: true } }, + { name: 'person6', password: { isSet: false } }, + { name: 'person7', password: { isSet: false } }, ]; -export const supportedFilters = () => ['is_set']; +export const supportedFilters = () => ['isSet']; diff --git a/packages-next/fields/src/types/password/views/index.tsx b/packages-next/fields/src/types/password/views/index.tsx index 8f5f3614c7f..0c1ecbfdb5a 100644 --- a/packages-next/fields/src/types/password/views/index.tsx +++ b/packages-next/fields/src/types/password/views/index.tsx @@ -139,14 +139,14 @@ export const Field = ({ }; export const Cell: CellComponent = ({ item, field }) => { - return {item[`${field.path}_is_set`] ? 'Is set' : 'Is not set'}; + return {item[field.path]?.isSet ? 'Is set' : 'Is not set'}; }; export const CardValue: CardValueComponent = ({ item, field }) => { return ( {field.label} - {item[`${field.path}_is_set`] ? 'Is set' : 'Is not set'} + {item[field.path]?.isSet ? 'Is set' : 'Is not set'} ); }; @@ -171,7 +171,7 @@ export const controller = ( return { path: config.path, label: config.label, - graphqlSelection: `${config.path}_is_set`, + graphqlSelection: `${config.path} {isSet}`, minLength: config.fieldMeta.minLength, defaultValue: { kind: 'initial', @@ -183,7 +183,7 @@ export const controller = ( (state.value === state.confirm && state.value.length >= config.fieldMeta.minLength) ); }, - deserialize: data => ({ kind: 'initial', isSet: data[`${config.path}_is_set`] }), + deserialize: data => ({ kind: 'initial', isSet: data[config.path]?.isSet ?? null }), serialize: value => { if (value.kind === 'initial') return {}; return { [config.path]: value.value }; diff --git a/packages-next/fields/src/types/relationship/Implementation.ts b/packages-next/fields/src/types/relationship/Implementation.ts deleted file mode 100644 index 979ce225b1e..00000000000 --- a/packages-next/fields/src/types/relationship/Implementation.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList, KeystoneContext } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; -import { resolveNestedMany, resolveNestedSingle, cleanAndValidateInput } from './nested-mutations'; - -export type RelationshipSingleOperation = { - connect?: any; - create?: any; - disconnect?: any; - disconnectAll?: boolean | null; -}; - -export type RelationshipManyOperation = { - connect?: any[] | null; - create?: any[] | null; - disconnect?: any[] | null; - disconnectAll?: boolean | null; -}; - -export type RelationshipOperation = RelationshipSingleOperation | RelationshipManyOperation; - -export class Relationship

extends Implementation

{ - many: boolean; - refFieldPath?: string; - withMeta: boolean; - ref: string; - // adapter: PrismaRelationshipInterface

; - constructor( - path: P, - { - ref, - many, - withMeta, - ...configArgs - }: FieldConfigArgs & { ref: string; many?: boolean; withMeta?: boolean }, - extraArgs: FieldExtraArgs - ) { - super(path, { ref, many, withMeta, ...configArgs }, extraArgs); - - // JM: It bugs me this is duplicated in the field adapters but initialisation order makes it hard to avoid - this.ref = ref; - const [refListKey, refFieldPath] = ref.split('.'); - this.refListKey = refListKey; - this.refFieldPath = refFieldPath; - // FIXME: We should be able to sort by the "one" side of a relationship - // but for now this isn't actually implemented, so we explicitly disable - // ordering here. - this.isOrderable = false; - - this.isRelationship = true; - this.many = !!many; - this.withMeta = typeof withMeta !== 'undefined' ? withMeta : true; - } - - get _supportsUnique() { - return true; - } - - tryResolveRefList() { - const { listKey, path, refListKey, refFieldPath } = this; - const refList = this.getListByKey(refListKey); - - if (!refList) { - throw new Error(`Unable to resolve related list '${refListKey}' from ${listKey}.${path}`); - } - - let refField; - - if (refFieldPath) { - refField = refList.fieldsByPath[refFieldPath]; - - if (!refField) { - throw new Error( - `Unable to resolve two way relationship field '${refListKey}.${refFieldPath}' from ${listKey}.${path}` - ); - } - } - - return { refList, refField }; - } - - gqlOutputFields({ schemaName }: { schemaName: string }) { - const { refList } = this.tryResolveRefList(); - - if (!refList.access[schemaName].read) { - // It's not accessible in any way, so we can't expose the related field - return []; - } - - if (this.many) { - const filterArgs = refList.getGraphqlFilterFragment().join('\n'); - return [ - `${this.path}(${filterArgs}): [${refList.gqlNames.outputTypeName}!]!`, - this.withMeta - ? `_${this.path}Meta(${filterArgs}): _QueryMeta @deprecated(reason: "This query will be removed in a future version. Please use ${this.path}Count instead.")` - : '', - this.withMeta - ? `${this.path}Count(${`where: ${refList.gqlNames.whereInputName}! = {}`}): Int` - : '', - ]; - } else { - return [`${this.path}: ${refList.gqlNames.outputTypeName}`]; - } - } - - gqlQueryInputFields({ schemaName }: { schemaName: string }) { - const { refList } = this.tryResolveRefList(); - - if (!refList.access[schemaName].read) { - // It's not accessible in any way, so we can't expose the related field - return []; - } - - if (this.many) { - return [ - `""" condition must be true for all nodes """ - ${this.path}_every: ${refList.gqlNames.whereInputName}`, - `""" condition must be true for at least 1 node """ - ${this.path}_some: ${refList.gqlNames.whereInputName}`, - `""" condition must be false for all nodes """ - ${this.path}_none: ${refList.gqlNames.whereInputName}`, - ]; - } else { - return [`${this.path}: ${refList.gqlNames.whereInputName}`, `${this.path}_is_null: Boolean`]; - } - } - - gqlOutputFieldResolvers({ schemaName }: { schemaName: string }) { - const { refList } = this.tryResolveRefList(); - - if (!refList.access[schemaName].read) { - // It's not accessible in any way, so we can't expose the related field - return []; - } - - if (this.many) { - return { - [this.path]: (item: any, args: any, context: KeystoneContext, info: any) => { - return refList.listQuery(args, context, info.fieldName, info, { - fromList: this.getListByKey(this.listKey), - fromId: item.id, - fromField: this.path, - }); - }, - - ...(this.withMeta && { - [`_${this.path}Meta`]: (item: any, args: any, context: KeystoneContext, info: any) => { - return refList.listQueryMeta(args, context, info.fieldName, info, { - fromList: this.getListByKey(this.listKey), - fromId: item.id, - fromField: this.path, - }); - }, - [`${this.path}Count`]: async ( - item: any, - args: any, - context: KeystoneContext, - info: any - ) => { - return ( - await refList.listQueryMeta(args, context, info.fieldName, info, { - fromList: this.getListByKey(this.listKey), - fromId: item.id, - fromField: this.path, - }) - ).getCount(); - }, - }), - }; - } else { - return { - [this.path]: (item: any, _: any, context: KeystoneContext, info: any) => { - // No ID set, so we return null for the value - // @ts-ignore - const id = item && (item[this.adapter.idPath] || (item[this.path] && item[this.path].id)); - if (!id) { - return null; - } - const filteredQueryArgs = { where: { id: id.toString() } }; - // We do a full query to ensure things like access control are applied - return refList - .listQuery(filteredQueryArgs, context, refList.gqlNames.listQueryName, info) - .then((items?: any[]) => (items && items.length ? items[0] : null)); - }, - }; - } - } - - /** - * @param operations {Object} - * { - * disconnectAll?: Boolean, (default: false), - * disconnect?: Array, (default: []), - * connect?: Array, (default: []), - * create?: Array, (default: []), - * } - * NOTE: If `disconnectAll` is `true`, `disconnect` is ignored. - * `where` is a WhereUniqueInput (eg; { id: "abc123" }) - * `data` is an input of the type expected for the ref list (eg; { data: { name: "Sarah" } }) - * - * @return {Object} - * { - * disconnect: Array, - * connect: Array, - * create: Array, - * } - * The indexes within the return arrays are guaranteed to match the indexes as - * passed in `operations`. - * Due to Access Control, it is possible thata some operations result in a - * value of `null`. Be sure to guard against this in your code. - * NOTE: If `disconnectAll` is true, `disconnect` will be an array of all - * previous stored values, which means indecies may not match those passed in - * `operations`. - */ - async resolveNestedOperations( - operations: RelationshipOperation, - item: any, - context: KeystoneContext, - mutationState: { afterChangeStack: any[]; transaction: {} } - ) { - const { refList, refField } = this.tryResolveRefList(); - const listInfo = { - local: { list: this.getListByKey(this.listKey)!, field: this }, - foreign: { list: refList, field: refField }, - }; - - // Possible early out for null'd field - // prettier-ignore - if ( - !operations - && ( - // If the field is not required, and the value is `null`, we can ignore - // it and move on. - !this.isRequired - // This field will be backlinked to this field's containing item, so we - // can safely ignore it now expecing the backlinking process in the - // calling code to take care of it. - || (refField && this.getListByKey(refField.refListKey) === listInfo.local.list) - ) - ) { - // Don't release the zalgo; always return a promise. - return Promise.resolve({ - create: [], - connect: [], - disconnect: [], - currentValue: [] - }); - } - - let currentValue: string | string[]; - if (this.many) { - const info = { fieldName: this.path }; - const _currentValue = item - ? await refList.listQuery( - {}, - { ...context, getListAccessControlForUser: () => true }, - info.fieldName, - info, - { fromList: this.getListByKey(this.listKey), fromId: item.id, fromField: this.path } - ) - : []; - currentValue = _currentValue.map(({ id }) => id.toString()); - } else { - // @ts-ignore - currentValue = item && (item[this.adapter.idPath] || (item[this.path] && item[this.path].id)); - currentValue = currentValue && currentValue.toString(); - } - - // Collect the IDs to be connected and disconnected. This step may trigger - // createMutation calls in order to obtain these IDs if required. - const localList = listInfo.local.list; - const localField = listInfo.local.field; - const target = `${localList.key}.${localField.path}<${refList.key}>`; - const input = cleanAndValidateInput({ input: operations, many: this.many, localField, target }); - let resolved: { create: string[]; connect: string[]; disconnect: string[] }; - if (this.many) { - resolved = await resolveNestedMany({ - currentValue: currentValue as string[], - refList: listInfo.foreign.list, - input, - context, - localField, - target, - mutationState, - }); - } else { - resolved = await resolveNestedSingle({ - currentValue: currentValue as string | undefined, - refList: listInfo.foreign.list, - input, - context, - localField, - target, - mutationState, - }); - } - const { create, connect, disconnect } = resolved; - return { create, connect, disconnect, currentValue }; - } - - getGqlAuxTypes({ schemaName }: { schemaName: string }) { - const { refList } = this.tryResolveRefList(); - const schemaAccess = refList.access[schemaName]; - // We need an input type that is specific to creating nested items when - // creating a relationship, ie; - // - // eg: Creating a new post at the same time as a new user - // mutation createUser() { - // posts: [{ create: { title: 'Foobar' } }] - // } - // - // Or, the inverse: Creating a new user at the same time as a new post - // mutation createPost() { - // author: { create: { email: 'eg@example.com' } } - // } - // - // Then there's the linking to existing records usecase: - // mutation createPost() { - // author: { connect: { id: 'abc123' } } - // } - if ( - schemaAccess.read || - schemaAccess.create || - schemaAccess.update || - schemaAccess.delete || - schemaAccess.auth - ) { - const operations = []; - if (this.many) { - if (refList.access[schemaName].create) { - operations.push(`# Provide data to create a set of new ${refList.key}. Will also connect. - create: [${refList.gqlNames.createInputName}]`); - } - - operations.push( - `# Provide a filter to link to a set of existing ${refList.key}. - connect: [${refList.gqlNames.whereUniqueInputName}]`, - `# Provide a filter to remove to a set of existing ${refList.key}. - disconnect: [${refList.gqlNames.whereUniqueInputName}]`, - `# Remove all ${refList.key} in this list. - disconnectAll: Boolean` - ); - return [ - `input ${refList.gqlNames.relateToManyInputName} { - ${operations.join('\n')} - } - `, - ]; - } else { - if (schemaAccess.create) { - operations.push(`# Provide data to create a new ${refList.key}. - create: ${refList.gqlNames.createInputName}`); - } - - operations.push( - `# Provide a filter to link to an existing ${refList.key}. - connect: ${refList.gqlNames.whereUniqueInputName}`, - `# Provide a filter to remove to an existing ${refList.key}. - disconnect: ${refList.gqlNames.whereUniqueInputName}`, - `# Remove the existing ${refList.key} (if any). - disconnectAll: Boolean` - ); - return [ - `input ${refList.gqlNames.relateToOneInputName} { - ${operations.join('\n')} - } - `, - ]; - } - } else { - return []; - } - } - gqlUpdateInputFields({ schemaName }: { schemaName: string }) { - const { refList } = this.tryResolveRefList(); - const schemaAccess = refList.access[schemaName]; - if ( - schemaAccess.read || - schemaAccess.create || - schemaAccess.update || - schemaAccess.delete || - schemaAccess.auth - ) { - if (this.many) { - return [`${this.path}: ${refList.gqlNames.relateToManyInputName}`]; - } else { - return [`${this.path}: ${refList.gqlNames.relateToOneInputName}`]; - } - } else { - return []; - } - } - gqlCreateInputFields({ schemaName }: { schemaName: string }) { - return this.gqlUpdateInputFields({ schemaName }); - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -export class PrismaRelationshipInterface

extends PrismaFieldAdapter

{ - idPath: string; - isUnique: boolean; - isIndexed: boolean; - refFieldPath?: string; - constructor( - fieldName: string, - path: P, - field: Relationship

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - this.idPath = `${this.dbPath}Id`; - this.isRelationship = true; - - // Default isIndexed to true if it's not explicitly provided - // Mutually exclusive with isUnique - this.isUnique = typeof this.config.isUnique === 'undefined' ? false : !!this.config.isUnique; - this.isIndexed = - typeof this.config.isIndexed === 'undefined' - ? !this.config.isUnique - : !!this.config.isIndexed; - - // JM: It bugs me this is duplicated in the implementation but initialisation order makes it hard to avoid - const [refListKey, refFieldPath] = this.config.ref.split('.'); - this.refListKey = refListKey; - this.refFieldPath = refFieldPath; - } - - getQueryConditions(dbPath: string) { - return { - [`${this.path}_is_null`]: (value: T | null) => - value ? { [dbPath]: null } : { NOT: { [dbPath]: null } }, - }; - } -} diff --git a/packages-next/fields/src/types/relationship/graphqlErrors.ts b/packages-next/fields/src/types/relationship/graphqlErrors.ts deleted file mode 100644 index cd176133890..00000000000 --- a/packages-next/fields/src/types/relationship/graphqlErrors.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createError } from 'apollo-errors'; - -export const ParameterError = createError('ParameterError', { - message: 'Incorrect parameters supplied', - options: { - showPath: true, - }, -}); diff --git a/packages-next/fields/src/types/relationship/index.ts b/packages-next/fields/src/types/relationship/index.ts index 902d303fefc..1369529a5cc 100644 --- a/packages-next/fields/src/types/relationship/index.ts +++ b/packages-next/fields/src/types/relationship/index.ts @@ -1,7 +1,14 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldTypeFunc, + CommonFieldConfig, + fieldType, + schema, + AdminMetaRootVal, + FieldDefaultValue, + QueryMeta, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Relationship, PrismaRelationshipInterface } from './Implementation'; // This is the default display mode for Relationships type SelectDisplayConfig = { @@ -44,58 +51,200 @@ type CountDisplayConfig = { }; export type RelationshipFieldConfig = - FieldConfig & { + CommonFieldConfig & { many?: boolean; ref: string; ui?: { hideCreate?: boolean; }; - defaultValue?: FieldDefaultValue, TGeneratedListTypes>; - isUnique?: boolean; + defaultValue?: FieldDefaultValue, TGeneratedListTypes>; + withMeta?: boolean; } & (SelectDisplayConfig | CardsDisplayConfig | CountDisplayConfig); -export const relationship = ( - config: RelationshipFieldConfig -): FieldType => ({ - type: { - type: 'Relationship', - isRelationship: true, // Used internally for this special case - implementation: Relationship, - adapter: PrismaRelationshipInterface, - }, - config, - views: resolveView('relationship/views'), - getAdminMeta: ( - listKey, - path, - adminMetaRoot - ): Parameters< - typeof import('@keystone-next/fields/types/relationship/views').controller - >[0]['fieldMeta'] => { - const refListKey = config.ref.split('.')[0]; - if (!adminMetaRoot.listsByKey[refListKey]) { - throw new Error(`The ref [${config.ref}] on relationship [${listKey}.${path}] is invalid`); - } - return { - refListKey, - many: config.many ?? false, - hideCreate: config.ui?.hideCreate ?? false, - ...(config.ui?.displayMode === 'cards' - ? { - displayMode: 'cards', - cardFields: config.ui.cardFields, - linkToItem: config.ui.linkToItem ?? false, - removeMode: config.ui.removeMode ?? 'disconnect', - inlineCreate: config.ui.inlineCreate ?? null, - inlineEdit: config.ui.inlineEdit ?? null, - inlineConnect: config.ui.inlineConnect ?? false, - } - : config.ui?.displayMode === 'count' - ? { displayMode: 'count' } - : { - displayMode: 'select', - refLabelField: adminMetaRoot.listsByKey[refListKey].labelField, - }), +export const relationship = + ({ + many = false, + ref, + defaultValue, + withMeta = true, + ...config + }: RelationshipFieldConfig): FieldTypeFunc => + meta => { + const [foreignListKey, foreignFieldKey] = ref.split('.'); + const commonConfig = { + views: resolveView('relationship/views'), + getAdminMeta: ( + adminMetaRoot: AdminMetaRootVal + ): Parameters< + typeof import('@keystone-next/fields/types/relationship/views').controller + >[0]['fieldMeta'] => { + if (!meta.lists[foreignListKey]) { + throw new Error( + `The ref [${ref}] on relationship [${meta.listKey}.${meta.fieldKey}] is invalid` + ); + } + return { + refListKey: foreignListKey, + many, + hideCreate: config.ui?.hideCreate ?? false, + ...(config.ui?.displayMode === 'cards' + ? { + displayMode: 'cards', + cardFields: config.ui.cardFields, + linkToItem: config.ui.linkToItem ?? false, + removeMode: config.ui.removeMode ?? 'disconnect', + inlineCreate: config.ui.inlineCreate ?? null, + inlineEdit: config.ui.inlineEdit ?? null, + inlineConnect: config.ui.inlineConnect ?? false, + } + : config.ui?.displayMode === 'count' + ? { displayMode: 'count' } + : { + displayMode: 'select', + refLabelField: adminMetaRoot.listsByKey[foreignListKey].labelField, + }), + }; + }, }; - }, -}); + if (!meta.lists[foreignListKey]) { + throw new Error( + `Unable to resolve related list '${foreignListKey}' from ${meta.listKey}.${meta.fieldKey}` + ); + } + const listTypes = meta.lists[foreignListKey].types; + if (many) { + const whereInputResolve = + (key: string) => async (value: any, resolve?: (val: any) => Promise) => { + return { [meta.fieldKey]: { [key]: await resolve!(value) } }; + }; + return fieldType({ + kind: 'relation', + mode: 'many', + list: foreignListKey, + field: foreignFieldKey, + })({ + ...commonConfig, + input: { + create: { + arg: schema.arg({ + type: listTypes.relateTo.many.create, + }), + async resolve(value, context, resolve) { + return resolve(value); + }, + }, + update: { + arg: schema.arg({ + type: listTypes.relateTo.many.update, + }), + async resolve(value, context, resolve) { + return resolve(value); + }, + }, + }, + output: schema.field({ + args: listTypes.findManyArgs, + type: schema.list(schema.nonNull(listTypes.output)), + resolve({ value }, args) { + return value.findMany(args); + }, + }), + extraOutputFields: withMeta + ? { + [`_${meta.fieldKey}Meta`]: schema.field({ + type: QueryMeta, + args: listTypes.findManyArgs, + deprecationReason: `This query will be removed in a future version. Please use ${meta.fieldKey}Count instead.`, + resolve({ value }, args) { + return { getCount: () => value.count(args) }; + }, + }), + [`${meta.fieldKey}Count`]: schema.field({ + type: schema.Int, + args: { + where: schema.arg({ type: schema.nonNull(listTypes.where), defaultValue: {} }), + }, + resolve({ value }, args) { + return value.count({ + where: args.where, + orderBy: [], + sortBy: undefined, + first: undefined, + search: undefined, + skip: 0, + }); + }, + }), + } + : {}, + __legacy: { + filters: { + fields: { + [`${meta.fieldKey}_every`]: schema.arg({ + type: listTypes.where, + description: ' condition must be true for all nodes', + }), + [`${meta.fieldKey}_some`]: schema.arg({ + type: listTypes.where, + description: ' condition must be true for at least 1 node', + }), + [`${meta.fieldKey}_none`]: schema.arg({ + type: listTypes.where, + description: ' condition must be false for all nodes', + }), + }, + impls: { + [`${meta.fieldKey}_every`]: whereInputResolve('every'), + [`${meta.fieldKey}_some`]: whereInputResolve('some'), + [`${meta.fieldKey}_none`]: whereInputResolve('none'), + }, + }, + defaultValue, + }, + }); + } + return fieldType({ + kind: 'relation', + mode: 'one', + list: foreignListKey, + field: foreignFieldKey, + })({ + ...commonConfig, + input: { + create: { + arg: schema.arg({ type: listTypes.relateTo.one.create }), + async resolve(value, context, resolve) { + return resolve(value); + }, + }, + update: { + arg: schema.arg({ type: listTypes.relateTo.one.update }), + async resolve(value, context, resolve) { + return resolve(value); + }, + }, + }, + output: schema.field({ + type: listTypes.output, + resolve({ value }) { + return value(); + }, + }), + __legacy: { + filters: { + fields: { + [meta.fieldKey]: schema.arg({ type: listTypes.where }), + [`${meta.fieldKey}_is_null`]: schema.arg({ type: schema.Boolean }), + }, + impls: { + [meta.fieldKey]: async (value: any, resolve?: (val: any) => Promise) => { + return { [meta.fieldKey]: await resolve!(value) }; + }, + [`${meta.fieldKey}_is_null`]: value => + value ? { [meta.fieldKey]: null } : { NOT: { [meta.fieldKey]: null } }, + }, + }, + defaultValue, + }, + }); + }; diff --git a/packages-next/fields/src/types/relationship/nested-mutations.ts b/packages-next/fields/src/types/relationship/nested-mutations.ts deleted file mode 100644 index b6fdde5911a..00000000000 --- a/packages-next/fields/src/types/relationship/nested-mutations.ts +++ /dev/null @@ -1,292 +0,0 @@ -// @ts-ignore -import groupBy from 'lodash.groupby'; -import pSettle from 'p-settle'; -import { intersection, pick } from '@keystone-next/utils-legacy'; -import { PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { KeystoneContext } from '@keystone-next/types'; -import { ParameterError } from './graphqlErrors'; -import { - Relationship, - RelationshipManyOperation, - RelationshipOperation, - RelationshipSingleOperation, -} from './Implementation'; - -type List = { - key: string; - adapter: PrismaListAdapter; - gqlNames: { itemQueryName: string }; - itemQuery: any; - createMutation: any; -}; - -type Operations = 'create' | 'connect' | 'disconnect' | 'disconnectAll'; -const NESTED_MUTATIONS: Operations[] = ['create', 'connect', 'disconnect', 'disconnectAll']; - -/*** Input validation ***/ -const throwWithErrors = (message: string, meta: Record) => { - const error = new Error(message); - throw Object.assign(error, meta); -}; - -function validateInput({ - input, - target, - many, -}: { - input: RelationshipOperation; - target: string; - many: boolean; -}) { - // Only accept mutations which we know how to handle. - let validInputMutations = intersection(Object.keys(input), NESTED_MUTATIONS) as Operations[]; - - // Filter out mutations which don't have any parameters - if (many) { - // to-many must have an array of objects - validInputMutations = validInputMutations.filter( - mutation => mutation === 'disconnectAll' || Array.isArray(input[mutation]) - ); - } else { - validInputMutations = validInputMutations.filter( - mutation => mutation === 'disconnectAll' || Object.keys(input[mutation]).length - ); - } - - // We must have at least one valid mutation - if (!validInputMutations.length) { - throw new ParameterError({ - message: `Must provide a nested mutation (${NESTED_MUTATIONS.join( - ', ' - )}) when mutating ${target}`, - }); - } - - // For a non-many relationship we can't create AND connect - only one can be set at a time - if (!many && validInputMutations.includes('create') && validInputMutations.includes('connect')) { - throw new ParameterError({ - message: `Can only provide one of 'connect' or 'create' when mutating ${target}`, - }); - } - return validInputMutations; -} - -export const cleanAndValidateInput = ({ - input, - many, - localField, - target, -}: { - input: RelationshipOperation; - many: boolean; - localField: Relationship; - target: string; -}) => { - try { - return pick(input, validateInput({ input, target, many })); - } catch (error) { - const message = `Nested mutation operation invalid for ${target}`; - error.path = ['']; - throwWithErrors(message, { errors: [error], path: [localField.path] }); - } - return {}; -}; - -const _runActions = async ( - action: (arg: any) => Promise, - targets: any[] | null | undefined, - path: string[] -) => { - const results = await pSettle((targets || []).map(action)); - const errors = results - .map((settleInfo, index) => ({ ...settleInfo, index })) - .filter(({ isRejected }) => isRejected) - // @ts-ignore - .map(({ reason, index }) => { - reason.path = [...path, index]; - return reason; - }); - // If there are no errors we know everything resolved successfully - // @ts-ignore - return [errors.length ? [] : results.map(({ value }) => value), errors]; -}; - -export async function resolveNestedMany({ - input, - currentValue, - refList, - context, - localField, - target, - mutationState, -}: { - input: RelationshipManyOperation; - currentValue: string[]; - target: string; - refList: List; - context: KeystoneContext; - localField: Relationship; - mutationState: { afterChangeStack: any[]; transaction: {} }; -}) { - // Disconnections - let disconnectIds = [] as string[]; - if (input.disconnectAll) { - disconnectIds = [...currentValue]; - } else if (input.disconnect) { - // We want to avoid DB lookups where possible, so we split the input into - // two halves; one with ids, and the other without ids - const { withId, withoutId }: { withId: { id: any }[]; withoutId: { id: any }[] } = groupBy( - input.disconnect, - ({ id }: { id: any }) => (id ? 'withId' : 'withoutId') - ); - - // We set the Ids we do find immediately - disconnectIds = (withId || []).map(({ id }) => id); - - // And any without ids (ie; other unique criteria), have to be looked up - // This will resolve access control, etc for us. - // In the future, when WhereUniqueInput accepts more than just an id, - // this will also resolve those queries for us too. - const action = (where: { id: any }) => - refList.itemQuery(where, context, refList.gqlNames.itemQueryName); - // We don't throw if any fail; we're only interested in the ones this user has - // access to read (and hence remove from the list) - const disconnectItems = (await pSettle((withoutId || []).map(action))) - .filter(({ isFulfilled }) => isFulfilled) - // @ts-ignore - .map(({ value }) => value) - .filter(itemToDisconnect => itemToDisconnect); // Possible to get null results when the id doesn't exist, or read access is denied - - disconnectIds.push(...disconnectItems.map(({ id }) => id)); - } - - // Connections - let connectedIds = [] as string[]; - let createdIds = [] as string[]; - if (input.connect || input.create) { - // This will resolve access control, etc for us. - // In the future, when WhereUniqueInput accepts more than just an id, - // this will also resolve those queries for us too. - const [connectedItems, connectErrors] = await _runActions( - where => refList.itemQuery({ where }, context, refList.gqlNames.itemQueryName), - input.connect, - ['connect'] - ); - - // Create related item. Will check for access control itself, no need to do anything extra here. - // NOTE: We don't check for read access control on the returned ids as the - // user will not have seen it, so it's ok to return it directly here. - const [createdItems, createErrors] = await _runActions( - data => refList.createMutation(data, context, mutationState), - input.create, - ['create'] - ); - - const allErrors = [...connectErrors, ...createErrors]; - if (allErrors.length) { - const message = `Unable to create and/or connect ${allErrors.length} ${target}`; - throwWithErrors(message, { errors: allErrors, path: [localField.path] }); - } - - connectedIds = connectedItems.map(item => { - if (item && item.id) { - return item.id; - } - // Possible to get null results when the id doesn't exist, or read access is denied - return null; - }); - - createdIds = createdItems.map(item => { - if (item && item.id) { - return item.id; - } - // Possible to get null results when the id doesn't exist, or read access is denied - return null; - }); - } - - return { disconnect: disconnectIds, connect: connectedIds, create: createdIds }; -} - -export async function resolveNestedSingle({ - input, - currentValue, - localField, - refList, - context, - target, - mutationState, -}: { - input: RelationshipSingleOperation; - currentValue?: string; - target: string; - localField: Relationship; - refList: List; - context: KeystoneContext; - mutationState: { afterChangeStack: any[]; transaction: {} }; -}) { - let result_ = { - create: [] as string[], - connect: [] as string[], - disconnect: [] as string[], - }; - if ((input.disconnect || input.disconnectAll) && currentValue) { - let idToDisconnect; - if (input.disconnectAll) { - idToDisconnect = currentValue; - } else if (input.disconnect.id) { - idToDisconnect = input.disconnect.id; - } else { - try { - // Support other unique fields for disconnection - idToDisconnect = ( - await refList.itemQuery( - { where: input.disconnect }, - context, - refList.gqlNames.itemQueryName - ) - ).id.toString(); - } catch (error) { - // Maybe we don't have read access, or maybe the item doesn't exist - // (recently deleted, or it's an erroneous value in the relationship field) - // So we silently ignore it - } - } - - if (currentValue === idToDisconnect) { - // Found the item, so unset it - result_.disconnect = [idToDisconnect]; - } - } - - let operation: 'connect' | 'create' | undefined; - let method; - - if (input.connect) { - operation = 'connect'; - method = () => - refList.itemQuery({ where: input.connect }, context, refList.gqlNames.itemQueryName); - } else if (input.create) { - operation = 'create'; - method = () => refList.createMutation(input.create, context, mutationState); - } - - if (operation && method) { - // override result with the connected/created value - // input is of type *RelateToOneInput - let item; - try { - item = await method(); - } catch (error) { - const message = `Unable to ${operation} a ${target}`; - error.path = [operation]; - throwWithErrors(message, { errors: [error], path: [localField.path] }); - } - - // Might not exist if the input id doesn't exist / the user doesn't have read access - if (item) { - result_[operation] = [item.id]; - } - } - return result_; -} diff --git a/packages-next/fields/src/types/relationship/tests/implementation.test.ts b/packages-next/fields/src/types/relationship/tests/implementation.test.ts index 5b8eebb6a16..4f980a4137b 100644 --- a/packages-next/fields/src/types/relationship/tests/implementation.test.ts +++ b/packages-next/fields/src/types/relationship/tests/implementation.test.ts @@ -1,510 +1,241 @@ -import { gql } from '@apollo/client'; -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { Implementation as Field } from '../../../Implementation'; -import { Relationship } from '../Implementation'; - -class MockFieldAdapter {} - -class MockListAdapter { - newFieldAdapter = () => new MockFieldAdapter(); -} - -const mockFilterFragment = ['first: Int']; - -const mockFilterAST = [ - { - kind: 'InputValueDefinition', - name: { - value: 'first', - }, - type: { - name: { - value: 'Int', - }, - }, - }, -]; - -class MockList { - key: string; - adapter: PrismaListAdapter; - gqlNames: { - relateToOneInputName: string; - whereUniqueInputName: string; - createInputName: string; - relateToManyInputName: string; - whereInputName: string; - outputTypeName: string; - listQueryName: string; - itemQueryName: string; - }; - access: Record; - fieldsByPath: Record>; - // getGraphqlFilterFragment: () => string[]; - listQuery: any; - listQueryMeta: any; - itemQuery: any; - createMutation: any; - constructor(ref: string) { - this.key = ''; - this.gqlNames = { - outputTypeName: ref, - createInputName: `${ref}CreateInput`, - whereUniqueInputName: `${ref}WhereUniqueInput`, - relateToManyInputName: `${ref}RelateToManyInput`, - relateToOneInputName: `${ref}RelateToOneInput`, - whereInputName: `${ref}WhereInput`, - listQueryName: '', - itemQueryName: '', - }; - this.access = { - public: { - create: true, - read: true, - update: true, - delete: true, - }, - }; - this.fieldsByPath = {}; - this.adapter = {} as PrismaListAdapter; - } - // The actual implementation in `@keystone-next/keystone-legacy/List/index.js` returns - // more, but we only want to test that this codepath is called - getGraphqlFilterFragment = () => mockFilterFragment; -} - -function createRelationship({ - path, - config, - getListByKey = () => new MockList(config.ref) as BaseKeystoneList, -}: { - path: string; - config: { many?: boolean; ref: string; withMeta?: boolean }; - getListByKey?: (key: string) => BaseKeystoneList | undefined; -}) { - return new Relationship(path, config, { - getListByKey, - listKey: 'FakeList', - listAdapter: new MockListAdapter() as unknown as PrismaListAdapter, - fieldAdapterClass: MockFieldAdapter as unknown as typeof PrismaFieldAdapter, - schemaNames: ['public'], - }); -} +import { createSystem, initConfig } from '@keystone-next/keystone'; +import { config, list } from '@keystone-next/keystone/schema'; +import { assertInputObjectType, printType, assertObjectType, parse } from 'graphql'; +import { relationship } from '..'; +import { text } from '../../text'; + +const fieldKey = 'foo'; + +const getSchema = (field: any) => { + return createSystem( + initConfig( + config({ + db: { url: 'file:./thing.db', provider: 'sqlite' }, + lists: { + Zip: list({ fields: { thing: text() } }), + Test: list({ + fields: { + [fieldKey]: field, + }, + }), + }, + }) + ) + ).graphQLSchema; +}; describe('Type Generation', () => { test('inputs for relationship fields in create args', () => { - const relMany = createRelationship({ - path: 'foo', - config: { many: true, ref: 'Zip' }, - }); - expect(relMany.gqlCreateInputFields({ schemaName: 'public' })).toEqual([ - 'foo: ZipRelateToManyInput', - ]); - - const relSingle = createRelationship({ - path: 'foo', - config: { many: false, ref: 'Zip' }, - }); - expect(relSingle.gqlCreateInputFields({ schemaName: 'public' })).toEqual([ - 'foo: ZipRelateToOneInput', - ]); + const relMany = getSchema(relationship({ many: true, ref: 'Zip' })); + expect( + assertInputObjectType(relMany.getType('TestCreateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToManyInput'); + + const relSingle = getSchema(relationship({ many: false, ref: 'Zip' })); + expect( + assertInputObjectType(relSingle.getType('TestCreateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToOneInput'); }); test('inputs for relationship fields in update args', () => { - const relMany = createRelationship({ - path: 'foo', - config: { many: true, ref: 'Zip' }, - }); - expect(relMany.gqlUpdateInputFields({ schemaName: 'public' })).toEqual([ - 'foo: ZipRelateToManyInput', - ]); - - const relSingle = createRelationship({ - path: 'foo', - config: { many: false, ref: 'Zip' }, - }); - expect(relSingle.gqlUpdateInputFields({ schemaName: 'public' })).toEqual([ - 'foo: ZipRelateToOneInput', - ]); + const relMany = getSchema(relationship({ many: true, ref: 'Zip' })); + expect( + assertInputObjectType(relMany.getType('TestUpdateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToManyInput'); + + const relSingle = getSchema(relationship({ many: false, ref: 'Zip' })); + expect( + assertInputObjectType(relSingle.getType('TestUpdateInput')).getFields().foo.type.toString() + ).toEqual('ZipRelateToOneInput'); }); test('to-single relationship nested mutation input', () => { - const relationship = createRelationship({ - path: 'foo', - config: { many: false, ref: 'Zip' }, - }); - const schemaName = 'public'; + const schema = getSchema(relationship({ many: false, ref: 'Zip' })); + // We're testing the AST is as we expect it to be - expect(gql(relationship.getGqlAuxTypes({ schemaName }).join('\n'))).toMatchObject({ - definitions: [ + expect(parse(printType(schema.getType('ZipRelateToOneInput')!)).definitions[0]).toMatchObject({ + kind: 'InputObjectTypeDefinition', + name: { + value: 'ZipRelateToOneInput', + }, + fields: [ { - kind: 'InputObjectTypeDefinition', + kind: 'InputValueDefinition', name: { - value: 'ZipRelateToOneInput', + value: 'create', }, - fields: [ - { - kind: 'InputValueDefinition', - name: { - value: 'create', - }, - type: { - name: { - value: 'ZipCreateInput', - }, - }, + type: { + name: { + value: 'ZipCreateInput', }, - { - kind: 'InputValueDefinition', - name: { - value: 'connect', - }, - type: { - name: { - value: 'ZipWhereUniqueInput', - }, - }, + }, + }, + { + kind: 'InputValueDefinition', + name: { + value: 'connect', + }, + type: { + name: { + value: 'ZipWhereUniqueInput', }, - { - kind: 'InputValueDefinition', - name: { - value: 'disconnect', - }, - type: { - name: { - value: 'ZipWhereUniqueInput', - }, - }, + }, + }, + { + kind: 'InputValueDefinition', + name: { + value: 'disconnect', + }, + type: { + name: { + value: 'ZipWhereUniqueInput', }, - { - kind: 'InputValueDefinition', - name: { - value: 'disconnectAll', - }, - type: { - name: { - value: 'Boolean', - }, - }, + }, + }, + { + kind: 'InputValueDefinition', + name: { + value: 'disconnectAll', + }, + type: { + name: { + value: 'Boolean', }, - ], + }, }, ], }); }); test('to-many relationship nested mutation input', () => { - const relationship = createRelationship({ - path: 'foo', - config: { many: true, ref: 'Zip' }, - }); - const schemaName = 'public'; + const schema = getSchema(relationship({ many: true, ref: 'Zip' })); + // We're testing the AST is as we expect it to be - expect(gql(relationship.getGqlAuxTypes({ schemaName }).join('\n'))).toMatchObject({ - definitions: [ + expect(parse(printType(schema.getType('ZipRelateToManyInput')!)).definitions[0]).toMatchObject({ + kind: 'InputObjectTypeDefinition', + name: { + value: 'ZipRelateToManyInput', + }, + fields: [ { - kind: 'InputObjectTypeDefinition', + kind: 'InputValueDefinition', name: { - value: 'ZipRelateToManyInput', + value: 'create', }, - fields: [ - { - kind: 'InputValueDefinition', + type: { + kind: 'ListType', + type: { name: { - value: 'create', - }, - type: { - kind: 'ListType', - type: { - name: { - value: 'ZipCreateInput', - }, - }, + value: 'ZipCreateInput', }, }, - { - kind: 'InputValueDefinition', + }, + }, + { + kind: 'InputValueDefinition', + name: { + value: 'connect', + }, + type: { + kind: 'ListType', + type: { name: { - value: 'connect', - }, - type: { - kind: 'ListType', - type: { - name: { - value: 'ZipWhereUniqueInput', - }, - }, + value: 'ZipWhereUniqueInput', }, }, - { - kind: 'InputValueDefinition', + }, + }, + { + kind: 'InputValueDefinition', + name: { + value: 'disconnect', + }, + type: { + kind: 'ListType', + type: { name: { - value: 'disconnect', - }, - type: { - kind: 'ListType', - type: { - name: { - value: 'ZipWhereUniqueInput', - }, - }, + value: 'ZipWhereUniqueInput', }, }, - { - kind: 'InputValueDefinition', - name: { - value: 'disconnectAll', - }, - type: { - name: { - value: 'Boolean', - }, - }, + }, + }, + { + kind: 'InputValueDefinition', + name: { + value: 'disconnectAll', + }, + type: { + name: { + value: 'Boolean', }, - ], + }, }, ], }); }); test('to-single relationships cannot be filtered at the field level', () => { - const path = 'foo'; - const schemaName = 'public'; - const relationship = createRelationship({ - path, - config: { many: false, ref: 'Zip' }, - }); - - // Wrap it in a mock type because all we get back is the fields - const fieldSchema = ` - type MockType { - ${relationship.gqlOutputFields({ schemaName }).join('\n')} - } - `; - - const fieldAST = gql(fieldSchema); - - expect(fieldAST).toMatchObject({ - definitions: [ - { - fields: [ - { - kind: 'FieldDefinition', - name: { - value: path, - }, - arguments: [], - type: { - name: { - value: 'Zip', - }, - }, - }, - ], + const schema = getSchema(relationship({ many: false, ref: 'Zip' })); + + expect( + ( + parse(printType(assertObjectType(schema.getType('Test')))).definitions[0] as any + ).fields.find((x: any) => x.name.value === fieldKey) + ).toMatchObject({ + kind: 'FieldDefinition', + name: { + value: fieldKey, + }, + arguments: [], + type: { + name: { + value: 'Zip', }, - ], + }, }); - - // @ts-ignore - expect(fieldAST.definitions[0].fields[0].arguments).toHaveLength(0); }); test('to-many relationships can be filtered at the field level', () => { - const path = 'foo'; - const schemaName = 'public'; - const relationship = createRelationship({ - path, - config: { many: true, ref: 'Zip' }, - }); - - // Wrap it in a mock type because all we get back is the fields - const fieldSchema = ` - type MockType { - ${relationship.gqlOutputFields({ schemaName }).join('\n')} - } - `; - - const fieldAST = gql(fieldSchema); - - expect(fieldAST).toMatchObject({ - definitions: [ - { - fields: [ - { - kind: 'FieldDefinition', - name: { - value: 'foo', - }, - arguments: mockFilterAST, - type: { - kind: 'NonNullType', - type: { - kind: 'ListType', - type: { - kind: 'NonNullType', - type: { - name: { - value: 'Zip', - }, - }, - }, - }, - }, - }, - { - kind: 'FieldDefinition', - name: { - value: `_${path}Meta`, - }, - // We don't have control over this type, so we just check for - // existence - arguments: expect.any(Array), - type: { - name: { - value: '_QueryMeta', - }, - }, - }, - { - kind: 'FieldDefinition', - name: { - value: `${path}Count`, - }, - // We don't have control over this type, so we just check for - // existence - arguments: expect.any(Array), - type: { - name: { - value: 'Int', - }, - }, - }, - ], - }, - ], - }); - - // @ts-ignore - expect(fieldAST.definitions[0].fields[0].arguments).toHaveLength(1); + const schema = getSchema(relationship({ many: true, ref: 'Zip' })); + + expect(printType(schema.getType('Test')!)).toMatchInlineSnapshot(` + "\\"\\"\\" A keystone list\\"\\"\\" + type Test { + id: ID! + foo(where: ZipWhereInput! = {}, search: String, sortBy: [SortZipsBy!] @deprecated(reason: \\"sortBy has been deprecated in favour of orderBy\\"), orderBy: [ZipOrderByInput!]! = [], first: Int, skip: Int! = 0): [Zip!] + _fooMeta(where: ZipWhereInput! = {}, search: String, sortBy: [SortZipsBy!] @deprecated(reason: \\"sortBy has been deprecated in favour of orderBy\\"), orderBy: [ZipOrderByInput!]! = [], first: Int, skip: Int! = 0): _QueryMeta @deprecated(reason: \\"This query will be removed in a future version. Please use fooCount instead.\\") + fooCount(where: ZipWhereInput! = {}): Int + }" + `); }); test('to-many relationships can have meta disabled', () => { - const path = 'foo'; - const schemaName = 'public'; - const relationship = createRelationship({ - path, - config: { many: true, ref: 'Zip', withMeta: false }, - }); - - // Wrap it in a mock type because all we get back is the fields - const fieldSchema = ` - type MockType { - ${relationship.gqlOutputFields({ schemaName }).join('\n')} - } - `; - - const fieldAST = gql(fieldSchema); - - expect(fieldAST).toMatchObject({ - definitions: [ - { - fields: [ - { - kind: 'FieldDefinition', - name: { - value: 'foo', - }, - arguments: mockFilterAST, - type: { - kind: 'NonNullType', - type: { - kind: 'ListType', - type: { - kind: 'NonNullType', - type: { - name: { - value: 'Zip', - }, - }, - }, - }, - }, - }, - ], - }, - ], - }); - - // @ts-ignore - expect(fieldAST.definitions[0].fields[0].arguments).toHaveLength(1); + const schema = getSchema(relationship({ many: true, ref: 'Zip', withMeta: false })); + + expect(printType(schema.getType('Test')!)).toMatchInlineSnapshot(` + "\\"\\"\\" A keystone list\\"\\"\\" + type Test { + id: ID! + foo(where: ZipWhereInput! = {}, search: String, sortBy: [SortZipsBy!] @deprecated(reason: \\"sortBy has been deprecated in favour of orderBy\\"), orderBy: [ZipOrderByInput!]! = [], first: Int, skip: Int! = 0): [Zip!] + }" + `); }); }); describe('Referenced list errors', () => { - const mockList = { - // ie; "not found" - getGraphqlFilterFragment: () => [], - gqlNames: { - whereInputName: '', - }, - access: { - public: { - create: true, - read: true, - update: true, - delete: true, - auth: true, - }, - }, - }; - - // Some methods are sync, others async, so we force all to be async so we can - // have a consistent testing API - async function asyncify(func: () => any | Promise) { - return await func(); - } - - (['gqlOutputFields', 'gqlQueryInputFields', 'gqlOutputFieldResolvers'] as const).forEach( - method => { - describe(`${method}()`, () => { - const schemaName = 'public'; - - test('throws when list not found', async () => { - const relMany = createRelationship({ - path: 'foo', - config: { many: true, ref: 'Zip' }, - // ie; "not found" - // @ts-ignore - getListByKey: () => {}, - }); - expect(asyncify(() => relMany[method]({ schemaName }))).rejects.toThrow( - /Unable to resolve related list 'Zip'/ - ); - }); + test('throws when list not found', async () => { + expect(() => getSchema(relationship({ ref: 'DoesNotExist' }))).toThrow( + "Unable to resolve related list 'DoesNotExist' from Test.foo" + ); + }); - test('does not throw when no two way relationship specified', async () => { - const relMany = createRelationship({ - path: 'foo', - config: { many: true, ref: 'Zip' }, - // @ts-ignore - getListByKey: () => mockList, - }); - return asyncify(() => relMany[method]({ schemaName })); - }); + test('does not throw when no two way relationship specified', async () => { + getSchema(relationship({ many: true, ref: 'Zip' })); + }); - test('throws when field on list not found', async () => { - const relMany = createRelationship({ - path: 'foo', - config: { many: true, ref: 'Zip.bar' }, - // @ts-ignore - getListByKey: () => mockList, - }); - expect(asyncify(() => relMany[method]({ schemaName }))).rejects.toThrow( - /Unable to resolve two way relationship field 'Zip.bar'/ - ); - }); - }); - } - ); + test('throws when field on list not found', async () => { + expect(() => getSchema(relationship({ many: true, ref: 'Zip.bar' }))).toThrow( + 'The relationship field at Test.foo points to Zip.bar but no field at Zip.bar exists' + ); + }); }); diff --git a/packages-next/fields/src/types/select/Implementation.ts b/packages-next/fields/src/types/select/Implementation.ts deleted file mode 100644 index 0c2cb340ea1..00000000000 --- a/packages-next/fields/src/types/select/Implementation.ts +++ /dev/null @@ -1,194 +0,0 @@ -// @ts-ignore -import inflection from 'inflection'; -import { humanize } from '@keystone-next/utils-legacy'; -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -type DataType = 'string' | 'integer' | 'enum'; - -function initOptions(options: string | any[]) { - let optionsArray = options; - if (typeof options === 'string') optionsArray = options.split(/\,\s*/); - if (!Array.isArray(optionsArray)) return null; - return optionsArray.map(i => { - return typeof i === 'string' ? { value: i, label: humanize(i) } : i; - }); -} - -const VALID_DATA_TYPES = ['enum', 'string', 'integer']; -const DOCS_URL = 'https://next.keystonejs.com/apis/fields#select'; - -function validateOptions({ - options, - dataType, - listKey, - path, -}: { - options: any; - dataType: DataType; - listKey: string; - path: string; -}) { - if (!VALID_DATA_TYPES.includes(dataType)) { - throw new Error( - ` -🚫 The select field ${listKey}.${path} is not configured with a valid data type; -📖 see ${DOCS_URL} -` - ); - } - options.forEach((option: { value: string }, i: number) => { - if (dataType === 'enum') { - if (!/^[a-zA-Z]\w*$/.test(option.value)) { - throw new Error( - ` -🚫 The select field ${listKey}.${path} contains an invalid enum value ("${option.value}") in option ${i} -👉 You may want to use the "string" dataType -📖 see ${DOCS_URL} -` - ); - } - } else if (dataType === 'string') { - if (typeof option.value !== 'string') { - const integerHint = - typeof option.value === 'number' - ? ` -👉 Did you mean to use the the "integer" dataType?` - : ''; - throw new Error( - ` -🚫 The select field ${listKey}.${path} contains an invalid value (type ${typeof option.value}) in option ${i}${integerHint} -📖 see ${DOCS_URL} -` - ); - } - } else if (dataType === 'integer') { - if (!Number.isInteger(option.value)) { - throw new Error( - ` -🚫 The select field ${listKey}.${path} contains an invalid integer value ("${option.value}") in option ${i} -📖 see ${DOCS_URL} -` - ); - } - } - }); -} - -export class Select

extends Implementation

{ - options: any; - dataType: DataType; - constructor( - path: P, - { - options, - dataType = 'string', - ...configArgs - }: FieldConfigArgs & { options: any; dataType?: DataType }, - extraArgs: FieldExtraArgs - ) { - super(path, { options, dataType, ...configArgs }, extraArgs); - this.options = initOptions(options); - validateOptions({ options: this.options, dataType, listKey: this.listKey, path }); - this.dataType = dataType; - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: ${this.getTypeName()}`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - - getTypeName() { - if (this.dataType === 'enum') { - return `${this.listKey}${inflection.classify(this.path)}Type`; - } else if (this.dataType === 'integer') { - return 'Int'; - } else { - return 'String'; - } - } - getGqlAuxTypes() { - return this.dataType === 'enum' - ? [ - ` - enum ${this.getTypeName()} { - ${this.options.map((i: { value: string }) => i.value).join('\n ')} - } - `, - ] - : []; - } - - gqlQueryInputFields() { - // TODO: This could be extended for Int type options with numeric filters - return [ - ...this.equalityInputFields(this.getTypeName()), - ...this.inInputFields(this.getTypeName()), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: ${this.getTypeName()}`]; - } - gqlCreateInputFields() { - return [`${this.path}: ${this.getTypeName()}`]; - } - - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -export class PrismaSelectInterface

extends PrismaFieldAdapter

{ - field: Select

; - isUnique: boolean; - isIndexed: boolean; - _prismaType: string; - constructor( - fieldName: string, - path: P, - field: Select

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - this.field = field; - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - this._prismaType = - this.config.dataType === 'enum' && this.listAdapter.parentAdapter.provider !== 'sqlite' - ? `${this.field.listKey}${inflection.classify(this.path)}Enum` - : this.config.dataType === 'integer' - ? 'Int' - : 'String'; - } - - getPrismaEnums() { - if (!['Int', 'String'].includes(this._prismaType)) { - return [ - `enum ${this._prismaType} { - ${this.field.options.map((i: { value: string }) => i.value).join('\n')} - }`, - ]; - } else return []; - } - - getPrismaSchema() { - return [this._schemaField({ type: this._prismaType })]; - } - - getQueryConditions(dbPath: string) { - return { - ...this.equalityConditions(dbPath), - ...this.inConditions(dbPath), - }; - } -} diff --git a/packages-next/fields/src/types/select/index.ts b/packages-next/fields/src/types/select/index.ts index 68b81ea44ed..78130da1f6b 100644 --- a/packages-next/fields/src/types/select/index.ts +++ b/packages-next/fields/src/types/select/index.ts @@ -1,10 +1,21 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldData, + FieldDefaultValue, + fieldType, + FieldTypeFunc, + CommonFieldConfig, + legacyFilters, + orderDirectionEnum, + schema, +} from '@keystone-next/types'; +// @ts-ignore +import inflection from 'inflection'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Select, PrismaSelectInterface } from './Implementation'; +import { getIndexType } from '../../get-index-type'; export type SelectFieldConfig = - FieldConfig & + CommonFieldConfig & ( | { options: { label: string; value: string }[]; @@ -25,19 +36,97 @@ export type SelectFieldConfig( - config: SelectFieldConfig -): FieldType => ({ - type: { - type: 'Select', - implementation: Select, - adapter: PrismaSelectInterface, +export const select = + ({ + isIndexed, + isUnique, + ui: { displayMode = 'select', ...ui } = {}, + isRequired, + defaultValue, + ...config + }: SelectFieldConfig): FieldTypeFunc => + meta => { + const commonConfig = { + ...config, + ui, + views: resolveView('select/views'), + getAdminMeta: () => ({ + options: config.options, + dataType: config.dataType ?? 'string', + displayMode: displayMode, + }), + }; + + const index = getIndexType({ isIndexed, isUnique }); + + if (config.dataType === 'integer') { + return fieldType({ + kind: 'scalar', + scalar: 'Int', + mode: 'optional', + index, + })({ + ...commonConfig, + input: { + create: { arg: schema.arg({ type: schema.Int }) }, + update: { arg: schema.arg({ type: schema.Int }) }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ type: schema.Int }), + __legacy: { filters: getFilters(meta, schema.Int), defaultValue, isRequired }, + }); + } + if (config.dataType === 'enum') { + const enumName = `${meta.listKey}${inflection.classify(meta.fieldKey)}Type`; + const graphQLType = schema.enum({ + name: enumName, + values: schema.enumValues(config.options.map(x => x.value)), + }); + // i do not like this "let's just magically use strings on sqlite" + return fieldType( + meta.provider === 'sqlite' + ? { kind: 'scalar', scalar: 'String', mode: 'optional', index } + : { + kind: 'enum', + values: config.options.map(x => x.value), + mode: 'optional', + name: enumName, + index, + } + )({ + ...commonConfig, + input: { + create: { arg: schema.arg({ type: graphQLType }) }, + update: { arg: schema.arg({ type: graphQLType }) }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ + type: graphQLType, + }), + __legacy: { filters: getFilters(meta, graphQLType), defaultValue, isRequired }, + }); + } + return fieldType({ kind: 'scalar', scalar: 'String', mode: 'optional', index })({ + ...commonConfig, + input: { + create: { arg: schema.arg({ type: schema.String }) }, + update: { arg: schema.arg({ type: schema.String }) }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ + type: schema.String, + }), + __legacy: { filters: getFilters(meta, schema.String), defaultValue, isRequired }, + }); + }; + +const getFilters = (meta: FieldData, type: schema.ScalarType | schema.EnumType) => ({ + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, type), + ...legacyFilters.fields.inInputFields(meta.fieldKey, type), + }, + impls: { + ...legacyFilters.impls.equalityConditions(meta.fieldKey), + ...legacyFilters.impls.inConditions(meta.fieldKey), }, - config, - views: resolveView('select/views'), - getAdminMeta: () => ({ - options: config.options, - dataType: config.dataType ?? 'string', - displayMode: config.ui?.displayMode ?? 'select', - }), }); diff --git a/packages-next/fields/src/types/text/Implementation.ts b/packages-next/fields/src/types/text/Implementation.ts deleted file mode 100644 index 8b37d27226d..00000000000 --- a/packages-next/fields/src/types/text/Implementation.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class Text

extends Implementation

{ - constructor(path: P, configArgs: FieldConfigArgs, extraArgs: FieldExtraArgs) { - super(path, configArgs, extraArgs); - this.isOrderable = true; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: String`]; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: (item: Record) => item[this.path] }; - } - gqlQueryInputFields() { - const { listAdapter } = this.adapter; - return [ - ...this.equalityInputFields('String'), - ...(listAdapter.parentAdapter.provider === 'sqlite' - ? this.containsInputFields('String') - : [ - ...this.stringInputFields('String'), - ...this.equalityInputFieldsInsensitive('String'), - ...this.stringInputFieldsInsensitive('String'), - ]), - ...this.inInputFields('String'), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: String`]; - } - gqlCreateInputFields() { - return [`${this.path}: String`]; - } - getBackingTypes() { - return { [this.path]: { optional: true, type: 'string | null' } }; - } -} - -export class PrismaTextInterface

extends PrismaFieldAdapter

{ - isUnique: boolean; - isIndexed: boolean; - constructor( - fieldName: string, - path: P, - field: Text

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - - getPrismaSchema() { - return [this._schemaField({ type: 'String' })]; - } - - getQueryConditions(dbPath: string) { - const { listAdapter } = this; - return { - ...this.equalityConditions(dbPath), - ...(listAdapter.parentAdapter.provider === 'sqlite' - ? this.containsConditions(dbPath) - : { - ...this.stringConditions(dbPath), - ...this.equalityConditionsInsensitive(dbPath), - ...this.stringConditionsInsensitive(dbPath), - }), - // These have no case-insensitive counter parts - ...this.inConditions(dbPath), - }; - } -} diff --git a/packages-next/fields/src/types/text/index.ts b/packages-next/fields/src/types/text/index.ts index 2c5a24bbe3c..8fe1e69f746 100644 --- a/packages-next/fields/src/types/text/index.ts +++ b/packages-next/fields/src/types/text/index.ts @@ -1,28 +1,87 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + FieldDefaultValue, + CommonFieldConfig, + fieldType, + schema, + orderDirectionEnum, + FieldTypeFunc, + legacyFilters, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Text, PrismaTextInterface } from './Implementation'; +import { getIndexType } from '../../get-index-type'; export type TextFieldConfig = - FieldConfig & { + CommonFieldConfig & { defaultValue?: FieldDefaultValue; - isRequired?: boolean; isIndexed?: boolean; isUnique?: boolean; + isRequired?: boolean; ui?: { displayMode?: 'input' | 'textarea'; }; }; -export const text = ( - config: TextFieldConfig = {} -): FieldType => ({ - type: { - type: 'Text', - implementation: Text, - adapter: PrismaTextInterface, - }, - config, - views: resolveView('text/views'), - getAdminMeta: () => ({ displayMode: config.ui?.displayMode ?? 'input' }), -}); +export const text = + ({ + isIndexed, + isUnique, + isRequired, + defaultValue, + ...config + }: TextFieldConfig = {}): FieldTypeFunc => + meta => + fieldType({ + kind: 'scalar', + mode: 'optional', + scalar: 'String', + index: getIndexType({ isIndexed, isUnique }), + })({ + ...config, + input: { + uniqueWhere: isUnique ? { arg: schema.arg({ type: schema.String }) } : undefined, + create: { arg: schema.arg({ type: schema.String }) }, + update: { arg: schema.arg({ type: schema.String }) }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ type: schema.String }), + views: resolveView('text/views'), + getAdminMeta() { + return { displayMode: config.ui?.displayMode ?? 'input' }; + }, + __legacy: { + filters: { + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, schema.String), + ...(meta.provider === 'sqlite' + ? legacyFilters.fields.containsInputFields(meta.fieldKey, schema.String) + : { + ...legacyFilters.fields.stringInputFields(meta.fieldKey, schema.String), + ...legacyFilters.fields.equalityInputFieldsInsensitive( + meta.fieldKey, + schema.String + ), + ...legacyFilters.fields.stringInputFieldsInsensitive( + meta.fieldKey, + schema.String + ), + }), + ...legacyFilters.fields.inInputFields(meta.fieldKey, schema.String), + }, + impls: { + ...legacyFilters.impls.equalityConditions(meta.fieldKey), + ...(meta.provider === 'sqlite' + ? legacyFilters.impls.containsConditions(meta.fieldKey) + : { + ...legacyFilters.impls.stringConditions(meta.fieldKey), + ...legacyFilters.impls.equalityConditionsInsensitive(meta.fieldKey), + ...legacyFilters.impls.stringConditionsInsensitive(meta.fieldKey), + }), + // These have no case-insensitive counter parts + ...legacyFilters.impls.inConditions(meta.fieldKey), + }, + }, + defaultValue, + isRequired, + }, + }); diff --git a/packages-next/fields/src/types/timestamp/Implementation.ts b/packages-next/fields/src/types/timestamp/Implementation.ts deleted file mode 100644 index 77ffa8697ca..00000000000 --- a/packages-next/fields/src/types/timestamp/Implementation.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class DateTimeUtcImplementation

extends Implementation

{ - format: string; - - constructor( - path: P, - { - format = 'yyyy-MM-dd[T]HH:mm:ss.SSSxx', - ...configArgs - }: FieldConfigArgs & { format?: string }, - extraArgs: FieldExtraArgs - ) { - super(path, { format, ...configArgs }, extraArgs); - this.isOrderable = true; - this.format = format; - } - - get _supportsUnique() { - return true; - } - - gqlOutputFields() { - return [`${this.path}: String`]; - } - gqlOutputFieldResolvers() { - return { - [`${this.path}`]: (item: Record) => item[this.path] && item[this.path].toISOString(), - }; - } - gqlQueryInputFields() { - return [ - ...this.equalityInputFields('String'), - ...this.orderingInputFields('String'), - ...this.inInputFields('String'), - ]; - } - gqlUpdateInputFields() { - return [`${this.path}: String`]; - } - gqlCreateInputFields() { - return [`${this.path}: String`]; - } - getGqlAuxTypes() { - return [`scalar String`]; - } - - getBackingTypes() { - return { [this.path]: { optional: true, type: 'Date | null' } }; - } -} - -export class PrismaDateTimeUtcInterface

extends PrismaFieldAdapter

{ - isUnique: boolean; - isIndexed: boolean; - constructor( - fieldName: string, - path: P, - field: DateTimeUtcImplementation

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - this.isUnique = !!this.config.isUnique; - this.isIndexed = !!this.config.isIndexed && !this.config.isUnique; - } - - getPrismaSchema() { - return [this._schemaField({ type: 'DateTime' })]; - } - - _stringToDate(s?: string) { - return s && new Date(s); - } - - getQueryConditions(dbPath: string) { - return { - ...this.equalityConditions(dbPath, this._stringToDate), - ...this.orderingConditions(dbPath, this._stringToDate), - ...this.inConditions(dbPath, this._stringToDate), - }; - } - - setupHooks({ addPreSaveHook }: { addPreSaveHook: (hook: any) => void }) { - addPreSaveHook((item: Record) => { - if (item[this.path]) { - item[this.path] = this._stringToDate(item[this.path]); - } - return item; - }); - } -} diff --git a/packages-next/fields/src/types/timestamp/index.ts b/packages-next/fields/src/types/timestamp/index.ts index da1e6ed8a27..81df675377d 100644 --- a/packages-next/fields/src/types/timestamp/index.ts +++ b/packages-next/fields/src/types/timestamp/index.ts @@ -1,24 +1,74 @@ -import type { FieldType, BaseGeneratedListTypes, FieldDefaultValue } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + fieldType, + schema, + FieldTypeFunc, + CommonFieldConfig, + orderDirectionEnum, + legacyFilters, + FieldDefaultValue, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { DateTimeUtcImplementation, PrismaDateTimeUtcInterface } from './Implementation'; +import { getIndexType } from '../../get-index-type'; export type TimestampFieldConfig = - FieldConfig & { - defaultValue?: FieldDefaultValue; - isRequired?: boolean; + CommonFieldConfig & { isIndexed?: boolean; isUnique?: boolean; + isRequired?: boolean; + defaultValue?: FieldDefaultValue; }; -export const timestamp = ( - config: TimestampFieldConfig = {} -): FieldType => ({ - type: { - type: 'DateTimeUtc', - implementation: DateTimeUtcImplementation, - adapter: PrismaDateTimeUtcInterface, - }, - config, - views: resolveView('timestamp/views'), -}); +export const timestamp = + ({ + isIndexed, + isUnique, + isRequired, + defaultValue, + ...config + }: TimestampFieldConfig = {}): FieldTypeFunc => + meta => { + const inputResolver = (value: string | null | undefined) => { + if (value === null || value === undefined) { + return value; + } + return new Date(value); + }; + return fieldType({ + kind: 'scalar', + mode: 'optional', + scalar: 'DateTime', + index: getIndexType({ isUnique, isIndexed }), + })({ + ...config, + input: { + create: { arg: schema.arg({ type: schema.String }), resolve: inputResolver }, + update: { arg: schema.arg({ type: schema.String }), resolve: inputResolver }, + orderBy: { arg: schema.arg({ type: orderDirectionEnum }) }, + }, + output: schema.field({ + type: schema.String, + resolve({ value }) { + if (value === null) return null; + return value.toISOString(); + }, + }), + views: resolveView('timestamp/views'), + __legacy: { + filters: { + fields: { + ...legacyFilters.fields.equalityInputFields(meta.fieldKey, schema.String), + ...legacyFilters.fields.orderingInputFields(meta.fieldKey, schema.String), + ...legacyFilters.fields.inInputFields(meta.fieldKey, schema.String), + }, + impls: { + ...legacyFilters.impls.equalityConditions(meta.fieldKey), + ...legacyFilters.impls.orderingConditions(meta.fieldKey), + ...legacyFilters.impls.inConditions(meta.fieldKey), + }, + }, + isRequired, + defaultValue, + }, + }); + }; diff --git a/packages-next/fields/src/types/virtual/Implementation.ts b/packages-next/fields/src/types/virtual/Implementation.ts deleted file mode 100644 index a3a549f3b67..00000000000 --- a/packages-next/fields/src/types/virtual/Implementation.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { PrismaFieldAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { BaseKeystoneList, GraphQLResolver } from '@keystone-next/types'; -import { FieldConfigArgs, FieldExtraArgs, Implementation } from '../../Implementation'; - -export class Virtual

extends Implementation

{ - resolver: GraphQLResolver; - args: { name: string; type: string }[]; - graphQLReturnType: string; - graphQLReturnFragment: string; - extendGraphQLTypes: string[]; - constructor( - path: P, - { - resolver, - graphQLReturnType = 'String', - graphQLReturnFragment = '', - extendGraphQLTypes = [], - args = [], - ...configArgs - }: FieldConfigArgs & { - resolver: GraphQLResolver; - graphQLReturnType?: string; - graphQLReturnFragment?: string; - extendGraphQLTypes?: string[]; - args?: { name: string; type: string }[]; - }, - extraArgs: FieldExtraArgs - ) { - super( - path, - { - resolver, - graphQLReturnType, - graphQLReturnFragment, - extendGraphQLTypes, - args, - ...configArgs, - }, - extraArgs - ); - this.resolver = resolver; - this.args = args; - this.graphQLReturnType = graphQLReturnType; - this.graphQLReturnFragment = graphQLReturnFragment; - this.extendGraphQLTypes = extendGraphQLTypes; - } - - get _supportsUnique() { - return false; - } - - gqlOutputFields() { - const argString = this.args.length - ? `(${this.args.map(({ name, type }) => `${name}: ${type}`).join('\n')})` - : ''; - return [`${this.path}${argString}: ${this.graphQLReturnType}`]; - } - getGqlAuxTypes() { - return this.extendGraphQLTypes; - } - gqlOutputFieldResolvers() { - return { [`${this.path}`]: this.resolver }; - } - gqlQueryInputFields() { - return []; - } - - _modifyAccess(parsedAccess: any) { - // The virtual field is not just a read-only field, it fundamentally doesn't - // mean anything to do a create/update on this field. As such, we explicitly - // set the access control to false for these operations. - return Object.keys(parsedAccess).reduce((prev, schemaName) => { - prev[schemaName] = { create: false, update: false, read: parsedAccess[schemaName].read }; - return prev; - }, {} as Record); - } - - getBackingTypes() { - return {}; - } -} - -export class PrismaVirtualInterface

extends PrismaFieldAdapter

{ - constructor( - fieldName: string, - path: P, - field: Virtual

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - super(fieldName, path, field, listAdapter, getListByKey, config); - } - - getPrismaSchema() { - return []; - } - - getQueryConditions() { - return {}; - } -} diff --git a/packages-next/fields/src/types/virtual/index.ts b/packages-next/fields/src/types/virtual/index.ts index 7da5691d085..534bf684ef4 100644 --- a/packages-next/fields/src/types/virtual/index.ts +++ b/packages-next/fields/src/types/virtual/index.ts @@ -1,26 +1,45 @@ -import type { FieldType, BaseGeneratedListTypes, KeystoneContext } from '@keystone-next/types'; +import { + BaseGeneratedListTypes, + schema, + ItemRootValue, + CommonFieldConfig, + FieldTypeFunc, + fieldType, + ListInfo, +} from '@keystone-next/types'; import { resolveView } from '../../resolve-view'; -import type { FieldConfig } from '../../interfaces'; -import { Virtual, PrismaVirtualInterface } from './Implementation'; + +type VirtualFieldGraphQLField = schema.Field; export type VirtualFieldConfig = - FieldConfig & { - resolver: (rootVal: any, args: any, context: KeystoneContext, info: any) => any; - graphQLReturnType?: string; + CommonFieldConfig & { + field: + | VirtualFieldGraphQLField + | ((lists: Record) => VirtualFieldGraphQLField); + unreferencedConcreteInterfaceImplementations?: schema.ObjectType[]; graphQLReturnFragment?: string; - extendGraphQLTypes?: string[]; - args?: { name: string; type: string }[]; }; -export const virtual = ( - config: VirtualFieldConfig -): FieldType => ({ - type: { - type: 'Virtual', - implementation: Virtual, - adapter: PrismaVirtualInterface, - }, - config, - views: resolveView('virtual/views'), - getAdminMeta: () => ({ graphQLReturnFragment: config.graphQLReturnFragment ?? '' }), -}); +export const virtual = + ({ + graphQLReturnFragment = '', + field, + ...config + }: VirtualFieldConfig): FieldTypeFunc => + meta => { + const usableField = typeof field === 'function' ? field(meta.lists) : field; + + return fieldType({ + kind: 'none', + })({ + ...config, + output: schema.field({ + ...(usableField as any), + resolve({ item }, ...args) { + return usableField.resolve!(item as any, ...args); + }, + }), + views: resolveView('virtual/views'), + getAdminMeta: () => ({ graphQLReturnFragment }), + }); + }; diff --git a/packages-next/keystone/package.json b/packages-next/keystone/package.json index 00a1d375fca..baa2a930082 100644 --- a/packages-next/keystone/package.json +++ b/packages-next/keystone/package.json @@ -30,8 +30,6 @@ "@graphql-tools/merge": "^6.2.14", "@graphql-tools/schema": "^7.1.5", "@hapi/iron": "^6.0.0", - "@keystone-next/access-control-legacy": "^11.0.0", - "@keystone-next/adapter-prisma-legacy": "^8.0.0", "@keystone-next/admin-ui-utils": "^5.0.1", "@keystone-next/fields": "^10.0.0", "@keystone-next/types": "^19.0.0", @@ -53,7 +51,6 @@ "@prisma/migrate": "2.24.1", "@prisma/sdk": "2.24.1", "@sindresorhus/slugify": "^1.1.2", - "@ts-gql/schema": "^0.7.3", "@types/apollo-upload-client": "14.1.0", "@types/babel__core": "^7.1.14", "@types/cookie": "^0.4.0", @@ -61,7 +58,6 @@ "@types/form-data": "2.5.0", "@types/fs-extra": "^9.0.11", "@types/graphql-upload": "^8.0.4", - "@types/keystonejs__keystone": "^7.0.1", "@types/node-fetch": "^2.5.10", "@types/pluralize": "^0.0.29", "@types/prettier": "^2.2.3", @@ -95,6 +91,7 @@ "next": "^10.2.3", "node-fetch": "^2.6.1", "object-hash": "^2.2.0", + "p-limit": "^2.2.0", "pirates": "^4.0.1", "pluralize": "^8.0.0", "prettier": "^2.3.1", diff --git a/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/next-config.ts b/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/next-config.ts index 1bc0086fb27..714c9aaf995 100644 --- a/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/next-config.ts +++ b/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/admin-ui/next-config.ts @@ -3,7 +3,7 @@ import Path from 'path'; import withPreconstruct from '@preconstruct/next'; export const config = withPreconstruct({ - webpack(config: any) { + webpack(config: any, { isServer }: any) { config.resolve.alias = { ...config.resolve.alias, react: Path.dirname(require.resolve('react/package.json')), @@ -12,7 +12,9 @@ export const config = withPreconstruct({ require.resolve('@keystone-next/keystone/package.json') ), }; - config.externals = [...config.externals, /prisma[\/\\]generated-client/]; + if (isServer) { + config.externals = [...config.externals, /@keystone-next\/keystone/, /@keystone-next\/types/]; + } return config; }, }); diff --git a/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts b/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts index 9060c0ef0df..0a032bfe62b 100644 --- a/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts +++ b/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts @@ -5,14 +5,15 @@ import { createApolloServerMicro } from '../lib/server/createApolloServer'; export function nextGraphQLAPIRoute(keystoneConfig: KeystoneConfig, prismaClient: any) { const initializedKeystoneConfig = initConfig(keystoneConfig); - const { graphQLSchema, keystone, createContext } = createSystem( - initializedKeystoneConfig, - prismaClient - ); + const { graphQLSchema, getKeystone } = createSystem(initializedKeystoneConfig); + + const keystone = getKeystone(prismaClient); + + keystone.connect(); const apolloServer = createApolloServerMicro({ graphQLSchema, - createContext, + createContext: keystone.createContext, sessionStrategy: initializedKeystoneConfig.session, apolloConfig: initializedKeystoneConfig.graphql?.apolloConfig, connectionPromise: keystone.connect(), diff --git a/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/node-api.ts b/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/node-api.ts index 32bee1b7f90..0ff1335df88 100644 --- a/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/node-api.ts +++ b/packages-next/keystone/src/___internal-do-not-use-will-break-in-patch/node-api.ts @@ -3,7 +3,8 @@ import { createSystem } from '../lib/createSystem'; import { initConfig } from '../lib/config/initConfig'; export function createListsAPI(config: KeystoneConfig, prismaClient: any) { - const { createContext, keystone } = createSystem(initConfig(config), prismaClient); + const { getKeystone } = createSystem(initConfig(config)); + const keystone = getKeystone(prismaClient); keystone.connect(); - return createContext().sudo().lists; + return keystone.createContext().sudo().lists; } diff --git a/packages-next/keystone/src/admin-ui/system/createAdminMeta.ts b/packages-next/keystone/src/admin-ui/system/createAdminMeta.ts index 6afc1c6690f..1b84373089a 100644 --- a/packages-next/keystone/src/admin-ui/system/createAdminMeta.ts +++ b/packages-next/keystone/src/admin-ui/system/createAdminMeta.ts @@ -1,17 +1,22 @@ -import type { KeystoneConfig, BaseKeystone, AdminMetaRootVal } from '@keystone-next/types'; +import type { KeystoneConfig, AdminMetaRootVal } from '@keystone-next/types'; +import { humanize } from '@keystone-next/utils-legacy'; +import { InitialisedList } from '../../lib/core/types-for-lists'; -export function createAdminMeta(config: KeystoneConfig, keystone: BaseKeystone) { +export function createAdminMeta( + config: KeystoneConfig, + initialisedLists: Record +) { const { ui, lists, session } = config; const adminMetaRoot: AdminMetaRootVal = { enableSessionItem: ui?.enableSessionItem || false, enableSignout: session !== undefined, listsByKey: {}, lists: [], + views: [], }; - Object.keys(lists).forEach(key => { + for (const [key, list] of Object.entries(initialisedLists)) { const listConfig = lists[key]; - const list = keystone.lists[key]; // Default the labelField to `name`, `label`, or `title` if they exist; otherwise fall back to `id` const labelField = (listConfig.ui?.labelField as string | undefined) ?? @@ -33,8 +38,8 @@ export function createAdminMeta(config: KeystoneConfig, keystone: BaseKeystone) // unless it happened to be the labelField initialColumns = [ labelField, - ...Object.keys(listConfig.fields) - .filter(fieldKey => listConfig.fields[fieldKey].config.access?.read !== false) + ...Object.keys(list.fields) + .filter(fieldKey => list.fields[fieldKey].access.read !== false) .filter(fieldKey => fieldKey !== labelField) .filter(fieldKey => fieldKey !== 'id'), ].slice(0, 3); @@ -55,44 +60,38 @@ export function createAdminMeta(config: KeystoneConfig, keystone: BaseKeystone) (listConfig.ui?.listView?.initialSort as | { field: string; direction: 'ASC' | 'DESC' } | undefined) ?? null, - itemQueryName: list.gqlNames.itemQueryName, - listQueryName: list.gqlNames.listQueryName.replace('all', ''), + // TODO: probably remove this from the GraphQL schema and here + itemQueryName: key, + listQueryName: list.pluralGraphQLName, }; adminMetaRoot.lists.push(adminMetaRoot.listsByKey[key]); - }); - + } let uniqueViewCount = -1; const stringViewsToIndex: Record = {}; - const views: string[] = []; function getViewId(view: string) { if (stringViewsToIndex[view] !== undefined) { return stringViewsToIndex[view]; } uniqueViewCount++; stringViewsToIndex[view] = uniqueViewCount; - views.push(view); + adminMetaRoot.views.push(view); return uniqueViewCount; } // Populate .fields array - Object.keys(lists).forEach(key => { - const listConfig = lists[key]; - const list = keystone.lists[key]; - for (const fieldKey of Object.keys(listConfig.fields).filter( - path => listConfig.fields[path].config.access?.read !== false - )) { - const field = listConfig.fields[fieldKey]; + for (const [key, list] of Object.entries(initialisedLists)) { + for (const [fieldKey, field] of Object.entries(list.fields)) { + if (field.access.read === false) continue; adminMetaRoot.listsByKey[key].fields.push({ - label: list.fieldsByPath[fieldKey].label, + label: field.label ?? humanize(fieldKey), viewsIndex: getViewId(field.views), - customViewsIndex: - field.config.ui?.views === undefined ? null : getViewId(field.config.ui.views), - fieldMeta: field.getAdminMeta?.(key, fieldKey, adminMetaRoot) ?? null, - isOrderable: list.fieldsByPath[fieldKey].isOrderable || fieldKey === 'id', + customViewsIndex: field.ui?.views === undefined ? null : getViewId(field.ui.views), + fieldMeta: field.getAdminMeta?.(adminMetaRoot) ?? null, + isOrderable: !!field.input?.orderBy, path: fieldKey, listKey: key, }); } - }); + } return adminMetaRoot; } diff --git a/packages-next/keystone/src/admin-ui/system/generateAdminUI.ts b/packages-next/keystone/src/admin-ui/system/generateAdminUI.ts index 525d0ce11cb..d9bfd4b2440 100644 --- a/packages-next/keystone/src/admin-ui/system/generateAdminUI.ts +++ b/packages-next/keystone/src/admin-ui/system/generateAdminUI.ts @@ -5,7 +5,7 @@ import fastGlob from 'fast-glob'; import prettier from 'prettier'; import resolve from 'resolve'; import { GraphQLSchema } from 'graphql'; -import type { KeystoneConfig, BaseKeystone } from '@keystone-next/types'; +import type { KeystoneConfig, AdminMetaRootVal } from '@keystone-next/types'; import { AdminFileToWrite } from '@keystone-next/types'; import { writeAdminFiles } from '../templates'; import { serializePathForImport } from '../utils/serializePathForImport'; @@ -47,7 +47,7 @@ async function writeAdminFile(file: AdminFileToWrite, projectAdminPath: string) export const generateAdminUI = async ( config: KeystoneConfig, graphQLSchema: GraphQLSchema, - keystone: BaseKeystone, + adminMeta: AdminMetaRootVal, projectAdminPath: string ) => { // Nuke any existing files in our target directory @@ -83,7 +83,7 @@ export const generateAdminUI = async ( const adminFiles = writeAdminFiles( config, graphQLSchema, - keystone, + adminMeta, configFileExists, projectAdminPath ); diff --git a/packages-next/keystone/src/admin-ui/system/getAdminMetaSchema.ts b/packages-next/keystone/src/admin-ui/system/getAdminMetaSchema.ts index 9951303dcc3..3773efcbbac 100644 --- a/packages-next/keystone/src/admin-ui/system/getAdminMetaSchema.ts +++ b/packages-next/keystone/src/admin-ui/system/getAdminMetaSchema.ts @@ -1,59 +1,65 @@ +import { JSONValue, schema as schemaAPIFromTypesPkg } from '@keystone-next/types'; import { KeystoneContext, KeystoneConfig, - BaseKeystone, AdminMetaRootVal, ListMetaRootVal, FieldMetaRootVal, - JSONValue, } from '@keystone-next/types'; -import { bindTypesToContext } from '@ts-gql/schema'; -import { GraphQLObjectType, GraphQLScalarType, GraphQLSchema } from 'graphql'; -import { createAdminMeta } from './createAdminMeta'; +import { GraphQLSchema, GraphQLObjectType, assertScalarType } from 'graphql'; +import { InitialisedList } from '../../lib/core/types-for-lists'; -const types = bindTypesToContext(); +const schema = { + ...schemaAPIFromTypesPkg, + ...schemaAPIFromTypesPkg.bindSchemaAPIToContext< + KeystoneContext | { isAdminUIBuildProcess: true } + >(), +}; export function getAdminMetaSchema({ - keystone, config, - schema, + graphQLSchema, + lists, + adminMeta: adminMetaRoot, }: { - keystone: BaseKeystone; + adminMeta: AdminMetaRootVal; config: KeystoneConfig; - schema: GraphQLSchema; + lists: Record; + graphQLSchema: GraphQLSchema; }) { - const adminMetaRoot = createAdminMeta(config, keystone); - const isAccessAllowed = config.session === undefined ? undefined : config.ui?.isAccessAllowed ?? (({ session }) => session !== undefined); - const jsonScalar = types.scalar(schema.getType('JSON') as GraphQLScalarType); + const jsonScalarType = graphQLSchema.getType('JSON'); + const jsonScalar = jsonScalarType + ? schema.scalar(assertScalarType(jsonScalarType)) + : schemaAPIFromTypesPkg.JSON; - const KeystoneAdminUIFieldMeta = types.object()({ + const KeystoneAdminUIFieldMeta = schema.object()({ name: 'KeystoneAdminUIFieldMeta', fields: { - path: types.field({ type: types.nonNull(types.String) }), - label: types.field({ type: types.nonNull(types.String) }), - isOrderable: types.field({ - type: types.nonNull(types.Boolean), + path: schema.field({ type: schema.nonNull(schema.String) }), + label: schema.field({ type: schema.nonNull(schema.String) }), + isOrderable: schema.field({ + type: schema.nonNull(schema.Boolean), }), - fieldMeta: types.field({ type: jsonScalar }), - viewsIndex: types.field({ type: types.nonNull(types.Int) }), - customViewsIndex: types.field({ type: types.Int }), - createView: types.field({ + fieldMeta: schema.field({ type: jsonScalar }), + viewsIndex: schema.field({ type: schema.nonNull(schema.Int) }), + customViewsIndex: schema.field({ type: schema.Int }), + createView: schema.field({ resolve(rootVal) { return { fieldPath: rootVal.path, listKey: rootVal.listKey }; }, - type: types.nonNull( - types.object()({ + type: schema.nonNull( + schema.object()({ name: 'KeystoneAdminUIFieldMetaCreateView', fields: { - fieldMode: types.field({ - type: types.nonNull( - types.enum({ + fieldMode: schema.field({ + type: schema.nonNull( + schema.enum({ name: 'KeystoneAdminUIFieldMetaCreateViewFieldMode', - values: types.enumValues(['edit', 'hidden']), + values: schema.enumValues(['edit', 'hidden']), }) ), async resolve(rootVal, args, context) { @@ -64,7 +70,7 @@ export function getAdminMetaSchema({ } const listConfig = config.lists[rootVal.listKey]; const sessionFunction = - listConfig.fields[rootVal.fieldPath].config.ui?.createView?.fieldMode ?? + lists[rootVal.listKey].fields[rootVal.fieldPath].ui?.createView?.fieldMode ?? listConfig.ui?.createView?.defaultFieldMode; return runMaybeFunction(sessionFunction, 'edit', { session: context.session }); }, @@ -73,19 +79,19 @@ export function getAdminMetaSchema({ }) ), }), - listView: types.field({ + listView: schema.field({ resolve(rootVal) { return { fieldPath: rootVal.path, listKey: rootVal.listKey }; }, - type: types.nonNull( - types.object()({ + type: schema.nonNull( + schema.object()({ name: 'KeystoneAdminUIFieldMetaListView', fields: { - fieldMode: types.field({ - type: types.nonNull( - types.enum({ + fieldMode: schema.field({ + type: schema.nonNull( + schema.enum({ name: 'KeystoneAdminUIFieldMetaListViewFieldMode', - values: types.enumValues(['read', 'hidden']), + values: schema.enumValues(['read', 'hidden']), }) ), async resolve(rootVal, args, context) { @@ -96,7 +102,7 @@ export function getAdminMetaSchema({ } const listConfig = config.lists[rootVal.listKey]; const sessionFunction = - listConfig.fields[rootVal.fieldPath].config.ui?.listView?.fieldMode ?? + lists[rootVal.listKey].fields[rootVal.fieldPath].ui?.listView?.fieldMode ?? listConfig.ui?.listView?.defaultFieldMode; return runMaybeFunction(sessionFunction, 'read', { session: context.session }); }, @@ -105,23 +111,23 @@ export function getAdminMetaSchema({ }) ), }), - itemView: types.field({ + itemView: schema.field({ args: { - id: types.arg({ - type: types.nonNull(types.ID), + id: schema.arg({ + type: schema.nonNull(schema.ID), }), }, resolve(rootVal, args) { return { fieldPath: rootVal.path, listKey: rootVal.listKey, itemId: args.id }; }, - type: types.object()({ + type: schema.object()({ name: 'KeystoneAdminUIFieldMetaItemView', fields: { - fieldMode: types.field({ - type: types.nonNull( - types.enum({ + fieldMode: schema.field({ + type: schema.nonNull( + schema.enum({ name: 'KeystoneAdminUIFieldMetaItemViewFieldMode', - values: types.enumValues(['edit', 'read', 'hidden']), + values: schema.enumValues(['edit', 'read', 'hidden']), }) ), async resolve(rootVal, args, context) { @@ -135,7 +141,7 @@ export function getAdminMetaSchema({ .db.lists[rootVal.listKey].findOne({ where: { id: rootVal.itemId } }); const listConfig = config.lists[rootVal.listKey]; const sessionFunction = - listConfig.fields[rootVal.fieldPath].config.ui?.itemView?.fieldMode ?? + lists[rootVal.listKey].fields[rootVal.fieldPath].ui?.itemView?.fieldMode ?? listConfig.ui?.itemView?.defaultFieldMode; return runMaybeFunction(sessionFunction, 'edit', { session: context.session, @@ -149,33 +155,33 @@ export function getAdminMetaSchema({ }, }); - const KeystoneAdminUISort = types.object>()({ + const KeystoneAdminUISort = schema.object>()({ name: 'KeystoneAdminUISort', fields: { - field: types.field({ type: types.nonNull(types.String) }), - direction: types.field({ - type: types.nonNull( - types.enum({ + field: schema.field({ type: schema.nonNull(schema.String) }), + direction: schema.field({ + type: schema.nonNull( + schema.enum({ name: 'KeystoneAdminUISortDirection', - values: types.enumValues(['ASC', 'DESC']), + values: schema.enumValues(['ASC', 'DESC']), }) ), }), }, }); - const KeystoneAdminUIListMeta = types.object()({ + const KeystoneAdminUIListMeta = schema.object()({ name: 'KeystoneAdminUIListMeta', fields: { - key: types.field({ type: types.nonNull(types.String) }), - itemQueryName: types.field({ - type: types.nonNull(types.String), + key: schema.field({ type: schema.nonNull(schema.String) }), + itemQueryName: schema.field({ + type: schema.nonNull(schema.String), }), - listQueryName: types.field({ - type: types.nonNull(types.String), + listQueryName: schema.field({ + type: schema.nonNull(schema.String), }), - hideCreate: types.field({ - type: types.nonNull(types.Boolean), + hideCreate: schema.field({ + type: schema.nonNull(schema.Boolean), resolve(rootVal, args, context) { if ('isAdminUIBuildProcess' in context) { throw new Error( @@ -186,8 +192,8 @@ export function getAdminMetaSchema({ return runMaybeFunction(listConfig.ui?.hideCreate, false, { session: context.session }); }, }), - hideDelete: types.field({ - type: types.nonNull(types.Boolean), + hideDelete: schema.field({ + type: schema.nonNull(schema.Boolean), resolve(rootVal, args, context) { if ('isAdminUIBuildProcess' in context) { throw new Error( @@ -198,22 +204,22 @@ export function getAdminMetaSchema({ return runMaybeFunction(listConfig.ui?.hideDelete, false, { session: context.session }); }, }), - path: types.field({ type: types.nonNull(types.String) }), - label: types.field({ type: types.nonNull(types.String) }), - singular: types.field({ type: types.nonNull(types.String) }), - plural: types.field({ type: types.nonNull(types.String) }), - description: types.field({ type: types.String }), - initialColumns: types.field({ - type: types.nonNull(types.list(types.nonNull(types.String))), + path: schema.field({ type: schema.nonNull(schema.String) }), + label: schema.field({ type: schema.nonNull(schema.String) }), + singular: schema.field({ type: schema.nonNull(schema.String) }), + plural: schema.field({ type: schema.nonNull(schema.String) }), + description: schema.field({ type: schema.String }), + initialColumns: schema.field({ + type: schema.nonNull(schema.list(schema.nonNull(schema.String))), }), - pageSize: types.field({ type: types.nonNull(types.Int) }), - labelField: types.field({ type: types.nonNull(types.String) }), - fields: types.field({ - type: types.nonNull(types.list(types.nonNull(KeystoneAdminUIFieldMeta))), + pageSize: schema.field({ type: schema.nonNull(schema.Int) }), + labelField: schema.field({ type: schema.nonNull(schema.String) }), + fields: schema.field({ + type: schema.nonNull(schema.list(schema.nonNull(KeystoneAdminUIFieldMeta))), }), - initialSort: types.field({ type: KeystoneAdminUISort }), - isHidden: types.field({ - type: types.nonNull(types.Boolean), + initialSort: schema.field({ type: KeystoneAdminUISort }), + isHidden: schema.field({ + type: schema.nonNull(schema.Boolean), resolve(rootVal, args, context) { if ('isAdminUIBuildProcess' in context) { throw new Error( @@ -227,23 +233,23 @@ export function getAdminMetaSchema({ }, }); - const adminMeta = types.object()({ + const adminMeta = schema.object()({ name: 'KeystoneAdminMeta', fields: { - enableSignout: types.field({ - type: types.nonNull(types.Boolean), + enableSignout: schema.field({ + type: schema.nonNull(schema.Boolean), }), - enableSessionItem: types.field({ - type: types.nonNull(types.Boolean), + enableSessionItem: schema.field({ + type: schema.nonNull(schema.Boolean), }), - lists: types.field({ - type: types.nonNull(types.list(types.nonNull(KeystoneAdminUIListMeta))), + lists: schema.field({ + type: schema.nonNull(schema.list(schema.nonNull(KeystoneAdminUIListMeta))), }), - list: types.field({ + list: schema.field({ type: KeystoneAdminUIListMeta, args: { - key: types.arg({ - type: types.nonNull(types.String), + key: schema.arg({ + type: schema.nonNull(schema.String), }), }, resolve(rootVal, { key }) { @@ -253,12 +259,12 @@ export function getAdminMetaSchema({ }, }); - const KeystoneMeta = types.nonNull( - types.object<{ adminMeta: AdminMetaRootVal }>()({ + const KeystoneMeta = schema.nonNull( + schema.object<{ adminMeta: AdminMetaRootVal }>()({ name: 'KeystoneMeta', fields: { - adminMeta: types.field({ - type: types.nonNull(adminMeta), + adminMeta: schema.field({ + type: schema.nonNull(adminMeta), resolve(rootVal, args, context) { if ('isAdminUIBuildProcess' in context || isAccessAllowed === undefined) { return adminMetaRoot; @@ -276,8 +282,8 @@ export function getAdminMetaSchema({ }, }) ); - const schemaConfig = schema.toConfig(); - const queryTypeConfig = schema.getQueryType()!.toConfig(); + const schemaConfig = graphQLSchema.toConfig(); + const queryTypeConfig = graphQLSchema.getQueryType()!.toConfig(); return new GraphQLSchema({ ...schemaConfig, types: schemaConfig.types.filter(x => x.name !== 'Query'), diff --git a/packages-next/keystone/src/admin-ui/templates/app.ts b/packages-next/keystone/src/admin-ui/templates/app.ts index f49562332af..532242d1d0b 100644 --- a/packages-next/keystone/src/admin-ui/templates/app.ts +++ b/packages-next/keystone/src/admin-ui/templates/app.ts @@ -1,5 +1,4 @@ import Path from 'path'; -import type { KeystoneConfig, FieldType } from '@keystone-next/types'; import hashString from '@emotion/hash'; import { executeSync, @@ -11,13 +10,14 @@ import { FragmentDefinitionNode, SelectionNode, } from 'graphql'; +import { AdminMetaRootVal } from '@keystone-next/types'; import { staticAdminMetaQuery, StaticAdminMetaQuery } from '../admin-meta-graphql'; import { serializePathForImport } from '../utils/serializePathForImport'; type AppTemplateOptions = { configFileExists: boolean; projectAdminPath: string }; export const appTemplate = ( - config: KeystoneConfig, + adminMetaRootVal: AdminMetaRootVal, graphQLSchema: GraphQLSchema, { configFileExists, projectAdminPath }: AppTemplateOptions ) => { @@ -32,17 +32,7 @@ export const appTemplate = ( const { adminMeta } = result.data!.keystone; const adminMetaQueryResultHash = hashString(JSON.stringify(adminMeta)); - const _allViews = new Set(); - Object.values(config.lists).forEach(list => { - for (const fieldKey of Object.keys(list.fields)) { - const field: FieldType = list.fields[fieldKey]; - _allViews.add(field.views); - if (field.config.ui?.views) { - _allViews.add(field.config.ui.views); - } - } - }); - const allViews = [..._allViews].map(views => { + const allViews = adminMetaRootVal.views.map(views => { const viewPath = Path.isAbsolute(views) ? Path.relative(Path.join(projectAdminPath, 'pages'), views) : views; diff --git a/packages-next/keystone/src/admin-ui/templates/index.ts b/packages-next/keystone/src/admin-ui/templates/index.ts index 08dcb8cdd7f..8115ce08475 100644 --- a/packages-next/keystone/src/admin-ui/templates/index.ts +++ b/packages-next/keystone/src/admin-ui/templates/index.ts @@ -1,5 +1,5 @@ import * as Path from 'path'; -import type { BaseKeystone, KeystoneConfig } from '@keystone-next/types'; +import type { AdminMetaRootVal, KeystoneConfig } from '@keystone-next/types'; import { AdminFileToWrite } from '@keystone-next/types'; import { GraphQLSchema } from 'graphql'; import { appTemplate } from './app'; @@ -14,7 +14,7 @@ const pkgDir = Path.dirname(require.resolve('@keystone-next/keystone/package.jso export const writeAdminFiles = ( config: KeystoneConfig, graphQLSchema: GraphQLSchema, - keystone: BaseKeystone, + adminMeta: AdminMetaRootVal, configFileExists: boolean, projectAdminPath: string ): AdminFileToWrite[] => [ @@ -25,16 +25,16 @@ export const writeAdminFiles = ( { mode: 'write', src: noAccessTemplate(config.session), outputPath: 'pages/no-access.js' }, { mode: 'write', - src: appTemplate(config, graphQLSchema, { configFileExists, projectAdminPath }), + src: appTemplate(adminMeta, graphQLSchema, { configFileExists, projectAdminPath }), outputPath: 'pages/_app.js', }, { mode: 'write', src: homeTemplate, outputPath: 'pages/index.js' }, - ...Object.values(keystone.lists).map( - ({ adminUILabels: { path }, key }) => + ...adminMeta.lists.map( + ({ path, key }) => ({ mode: 'write', src: listTemplate(key), outputPath: `pages/${path}/index.js` } as const) ), - ...Object.values(keystone.lists).map( - ({ adminUILabels: { path }, key }) => + ...adminMeta.lists.map( + ({ path, key }) => ({ mode: 'write', src: itemTemplate(key), outputPath: `pages/${path}/[id].js` } as const) ), ...(config.experimental?.enableNextJsGraphqlApiEndpoint diff --git a/packages-next/keystone/src/admin-ui/utils/useAdminMeta.tsx b/packages-next/keystone/src/admin-ui/utils/useAdminMeta.tsx index f7265f14a0c..5e5d2591b3f 100644 --- a/packages-next/keystone/src/admin-ui/utils/useAdminMeta.tsx +++ b/packages-next/keystone/src/admin-ui/utils/useAdminMeta.tsx @@ -73,8 +73,7 @@ export function useAdminMeta(adminMetaHash: string, fieldViews: FieldViews) { ...list, gqlNames: getGqlNames({ listKey: list.key, - itemQueryName: list.itemQueryName, - listQueryName: list.listQueryName, + pluralGraphQLName: list.listQueryName, }), fields: {}, }; diff --git a/packages-next/keystone/src/artifacts.ts b/packages-next/keystone/src/artifacts.ts index 17cfd464b55..5750545d5c8 100644 --- a/packages-next/keystone/src/artifacts.ts +++ b/packages-next/keystone/src/artifacts.ts @@ -7,7 +7,8 @@ import { format } from 'prettier'; import { confirmPrompt, shouldPrompt } from './lib/prompts'; import { printGeneratedTypes } from './lib/schema-type-printer'; import { ExitError } from './scripts/utils'; -import { createKeystone } from './lib/createKeystone'; +import { initialiseLists } from './lib/core/types-for-lists'; +import { printPrismaSchema } from './lib/core/prisma-schema'; import { getDBProvider } from './lib/createSystem'; export function getSchemaPaths(cwd: string) { @@ -26,14 +27,17 @@ export async function getCommittedArtifacts( graphQLSchema: GraphQLSchema, config: KeystoneConfig ): Promise { - const keystone = createKeystone(config, getDBProvider(config.db)); - const prismaSchema = keystone.adapter._generatePrismaSchema({ - rels: keystone._consolidateRelationships(), - clientDir: 'node_modules/.prisma/client', - }); + const lists = initialiseLists(config.lists, getDBProvider(config.db)); + const prismaSchema = printPrismaSchema( + lists, + getDBProvider(config.db), + 'node_modules/.prisma/client' + ); return { graphql: format(printSchema(graphQLSchema), { parser: 'graphql' }), - prisma: await formatSchema({ schema: prismaSchema }), + prisma: await formatSchema({ + schema: prismaSchema, + }), }; } @@ -171,14 +175,15 @@ export async function generateNodeModulesArtifacts( config: KeystoneConfig, cwd: string ) { - const keystone = createKeystone(config, getDBProvider(config.db)); + const lists = initialiseLists(config.lists, getDBProvider(config.db)); + const printedSchema = printSchema(graphQLSchema); const dotKeystoneDir = path.join(cwd, 'node_modules/.keystone'); await Promise.all([ generatePrismaClient(cwd), fs.outputFile( path.join(dotKeystoneDir, 'types.d.ts'), - printGeneratedTypes(printedSchema, keystone, graphQLSchema) + printGeneratedTypes(printedSchema, graphQLSchema, lists) ), fs.outputFile(path.join(dotKeystoneDir, 'types.js'), ''), ...(config.experimental?.generateNodeAPI diff --git a/packages-next/keystone/src/lib/coerceAndValidateForGraphQLInput.ts b/packages-next/keystone/src/lib/coerceAndValidateForGraphQLInput.ts new file mode 100644 index 00000000000..915ceefed25 --- /dev/null +++ b/packages-next/keystone/src/lib/coerceAndValidateForGraphQLInput.ts @@ -0,0 +1,66 @@ +import { + GraphQLSchema, + VariableDefinitionNode, + TypeNode, + GraphQLType, + GraphQLNonNull, + GraphQLList, + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLObjectType, + GraphQLScalarType, + GraphQLUnionType, + ListTypeNode, + NamedTypeNode, + GraphQLInputType, + GraphQLError, +} from 'graphql'; +import { getVariableValues } from 'graphql/execution/values'; + +function getNamedOrListTypeNodeForType( + type: + | GraphQLScalarType + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLInputObjectType + | GraphQLList +): NamedTypeNode | ListTypeNode { + if (type instanceof GraphQLList) { + return { kind: 'ListType', type: getTypeNodeForType(type.ofType) }; + } + return { kind: 'NamedType', name: { kind: 'Name', value: type.name } }; +} + +function getTypeNodeForType(type: GraphQLType): TypeNode { + if (type instanceof GraphQLNonNull) { + return { kind: 'NonNullType', type: getNamedOrListTypeNodeForType(type.ofType) }; + } + return getNamedOrListTypeNodeForType(type); +} + +const argName = 'where'; + +export function coerceAndValidateForGraphQLInput( + schema: GraphQLSchema, + type: GraphQLInputType, + value: any +): { kind: 'valid'; value: any } | { kind: 'error'; error: GraphQLError } { + const variableDefintions: VariableDefinitionNode[] = [ + { + kind: 'VariableDefinition', + type: getTypeNodeForType(type), + variable: { kind: 'Variable', name: { kind: 'Name', value: argName } }, + }, + ]; + + const coercedVariableValues = getVariableValues(schema, variableDefintions, { + [argName]: value, + }); + if (coercedVariableValues.errors) { + return { kind: 'error', error: coercedVariableValues.errors[0] }; + } + return { kind: 'valid', value: coercedVariableValues.coerced[argName] }; +} diff --git a/packages-next/keystone/src/lib/config/applyIdFieldDefaults.ts b/packages-next/keystone/src/lib/config/applyIdFieldDefaults.ts index 0a8e4b8621a..c3a222ad143 100644 --- a/packages-next/keystone/src/lib/config/applyIdFieldDefaults.ts +++ b/packages-next/keystone/src/lib/config/applyIdFieldDefaults.ts @@ -1,4 +1,4 @@ -import type { KeystoneConfig } from '@keystone-next/types'; +import type { FieldData, KeystoneConfig, NextFieldType } from '@keystone-next/types'; import { autoIncrement } from '@keystone-next/fields'; /* Validate lists config and default the id field */ @@ -13,20 +13,20 @@ export function applyIdFieldDefaults(config: KeystoneConfig): KeystoneConfig['li )} list. This is not allowed, use the idField option instead.` ); } - let idField = config.lists[key].idField ?? autoIncrement({}); - idField = { - ...idField, - config: { + const idFieldOption = config.lists[key].idField ?? autoIncrement({}); + const actualIdField = (args: FieldData): NextFieldType => { + const idField = idFieldOption(args); + return { + ...idField, ui: { - createView: { fieldMode: 'hidden', ...idField.config.ui?.createView }, - itemView: { fieldMode: 'hidden', ...idField.config.ui?.itemView }, - ...idField.config.ui, + createView: { fieldMode: 'hidden', ...idField.ui?.createView }, + itemView: { fieldMode: 'hidden', ...idField.ui?.itemView }, + ...idField.ui, }, - ...idField.config, - }, + }; }; - const fields = { id: idField, ...listConfig.fields }; + const fields = { id: actualIdField, ...listConfig.fields }; lists[key] = { ...listConfig, fields }; }); return lists; diff --git a/packages-next/keystone/src/lib/context/createAccessControlContext.ts b/packages-next/keystone/src/lib/context/createAccessControlContext.ts deleted file mode 100644 index 4b19e9f1187..00000000000 --- a/packages-next/keystone/src/lib/context/createAccessControlContext.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { KeystoneContext } from '@keystone-next/types'; - -type ListAccessArgs = { - listKey: string; - operation: string; - session: any; - originalInput: any; - gqlName: string; - itemId: any; - itemIds: any; - context: KeystoneContext; -}; - -type FieldAccessArgs = { - listKey: string; - operation: string; - session: any; - originalInput: any; - gqlName: string; - context: KeystoneContext; - item: any; - fieldKey: string; -}; - -async function validateListAccessControl({ - access, - operation, - listKey, - ...args -}: { access: any } & ListAccessArgs) { - // Either a boolean or an object describing a where clause - let result; - if (typeof access[operation] !== 'function') { - result = access[operation]; - } else { - result = await access[operation]({ listKey, operation, ...args }); - } - - const type = typeof result; - - if (!['object', 'boolean'].includes(type) || result === null) { - throw new Error( - `Must return an Object or Boolean from Imperative or Declarative access control function. Got ${type}` - ); - } - - // Special case for 'create' permission - if (operation === 'create' && type === 'object') { - throw new Error( - `Expected a Boolean for ${listKey}.access.create(), but got Object. (NOTE: 'create' cannot have a Declarative access control config)` - ); - } - - return result; -} - -async function validateFieldAccessControl({ - access, - operation, - listKey, - fieldKey, - ...args -}: { access: any } & FieldAccessArgs) { - let result; - if (typeof access[operation] !== 'function') { - result = access[operation]; - } else { - result = await access[operation]({ listKey, fieldKey, operation, ...args }); - } - - if (typeof result !== 'boolean') { - throw new Error( - `Must return a Boolean from ${listKey}.fields.${fieldKey}.access.${operation}(). Got ${typeof result}` - ); - } - - return result; -} - -export const skipAccessControlContext = { - getListAccessControlForUser: () => true, - getFieldAccessControlForUser: () => true, -}; - -// these are memoized in current Keystone but not here -// since it's useless because all of the callers of them pass in new objects -// + the functions will never be called with the same stuff (even ignoring the identity of the objects mentioned above) -// + the memoization library used has a cache size of 1 by default - -export const accessControlContext = { - async getListAccessControlForUser( - access: any, - listKey: ListAccessArgs['listKey'], - originalInput: ListAccessArgs['originalInput'], - operation: ListAccessArgs['operation'], - { - gqlName, - itemId, - itemIds, - context, - }: Pick - ) { - return validateListAccessControl({ - access: access[context.schemaName], - originalInput, - operation, - session: context.session, - listKey, - gqlName, - itemId, - itemIds, - context, - }); - }, - async getFieldAccessControlForUser( - access: any, - listKey: FieldAccessArgs['listKey'], - fieldKey: FieldAccessArgs['fieldKey'], - originalInput: FieldAccessArgs['originalInput'], - item: FieldAccessArgs['item'], - operation: FieldAccessArgs['operation'], - { gqlName, context }: Pick - ) { - return validateFieldAccessControl({ - access: access[context.schemaName], - originalInput, - item, - operation, - session: context.session, - fieldKey, - listKey, - gqlName, - context, - }); - }, -}; diff --git a/packages-next/keystone/src/lib/context/createContext.ts b/packages-next/keystone/src/lib/context/createContext.ts index 9b3606c2e4b..1e344255fda 100644 --- a/packages-next/keystone/src/lib/context/createContext.ts +++ b/packages-next/keystone/src/lib/context/createContext.ts @@ -4,29 +4,26 @@ import { SessionContext, KeystoneContext, KeystoneGraphQLAPI, - BaseKeystone, - KeystoneConfig, GqlNames, + KeystoneConfig, } from '@keystone-next/types'; +import { PrismaClient } from '../core/utils'; import { getDbAPIFactory, itemAPIForList } from './itemAPI'; -import { accessControlContext, skipAccessControlContext } from './createAccessControlContext'; import { createImagesContext } from './createImagesContext'; import { createFilesContext } from './createFilesContext'; export function makeCreateContext({ graphQLSchema, internalSchema, - keystone, - config, prismaClient, gqlNamesByList, + config, }: { graphQLSchema: GraphQLSchema; internalSchema: GraphQLSchema; - keystone: BaseKeystone; config: KeystoneConfig; - prismaClient: any; + prismaClient: PrismaClient; gqlNamesByList: Record; }) { const images = createImagesContext(config); @@ -79,11 +76,9 @@ export function makeCreateContext({ const itemAPI: KeystoneContext['lists'] = {}; const contextToReturn: KeystoneContext = { schemaName, - ...(skipAccessControl ? skipAccessControlContext : accessControlContext), db: { lists: dbAPI }, lists: itemAPI, totalResults: 0, - keystone, prisma: prismaClient, graphql: { raw: rawGraphQL, run: runGraphQL, schema }, maxTotalResults: config.graphql?.queryLimits?.maxTotalResults ?? Infinity, diff --git a/packages-next/keystone/src/lib/core/Keystone/index.ts b/packages-next/keystone/src/lib/core/Keystone/index.ts deleted file mode 100644 index b8742fac72b..00000000000 --- a/packages-next/keystone/src/lib/core/Keystone/index.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { gql } from 'apollo-server-express'; -import { GraphQLUpload } from 'graphql-upload'; -import { objMerge, flatten, unique, filterValues } from '@keystone-next/utils-legacy'; -import { - BaseKeystone, - BaseKeystoneList, - BaseListConfig, - KeystoneContext, - Rel, -} from '@keystone-next/types'; -import { PrismaAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Relationship } from '@keystone-next/fields/src/types/relationship/Implementation'; -import { List } from '../ListTypes'; -import { ListCRUDProvider } from '../providers'; - -export class Keystone implements BaseKeystone { - lists: Record; - listsArray: BaseKeystoneList[]; - getListByKey: (key: string) => BaseKeystoneList | undefined; - onConnect: (keystone: BaseKeystone, args?: { context: KeystoneContext }) => Promise; - _listCRUDProvider: any; - _providers: any[]; - adapter: PrismaAdapter; - queryLimits: { maxTotalResults: number }; - - constructor({ - adapter, - onConnect, - queryLimits = {}, - }: { - adapter: PrismaAdapter; - onConnect: (keystone: BaseKeystone, args?: { context: KeystoneContext }) => Promise; - queryLimits: { maxTotalResults?: number | undefined } | undefined; - }) { - this.lists = {}; - this.listsArray = []; - this.getListByKey = key => this.lists[key]; - this.onConnect = onConnect; - this._listCRUDProvider = new ListCRUDProvider(); - this._providers = [this._listCRUDProvider]; - this.adapter = adapter; - this.queryLimits = { maxTotalResults: Infinity, ...queryLimits }; - if (this.queryLimits.maxTotalResults < 1) { - throw new Error("queryLimits.maxTotalResults can't be < 1"); - } - } - - createList(key: string, config: BaseListConfig) { - const { getListByKey, adapter } = this; - const isReservedName = key[0] === '_'; - - if (isReservedName) { - throw new Error(`Invalid list name "${key}". List names cannot start with an underscore.`); - } - if (['Query', 'Subscription', 'Mutation'].includes(key)) { - throw new Error( - `Invalid list name "${key}". List names cannot be reserved GraphQL keywords.` - ); - } - - // Keystone automatically adds an 'Upload' scalar type to the GQL schema. Since list output - // types are named after their keys, having a list name 'Upload' will clash and cause a confusing - // error on start. - if (key === 'Upload' || key === 'upload') { - throw new Error( - `Invalid list name "Upload": Built-in GraphQL types cannot be used as a list name.` - ); - } - - const list: BaseKeystoneList = new List(key, config, { getListByKey, adapter }); - this.lists[key] = list; - this.listsArray.push(list); - this._listCRUDProvider.lists.push(list); - list.initFields(); - return list; - } - - _consolidateRelationships() { - const rels: Record = {}; - const otherSides: Record = {}; - this.listsArray.forEach(list => { - list.fields - .filter(f => f.isRelationship) - .forEach(_f => { - const f = _f as Relationship; - const myRef = `${f.listKey}.${f.path}`; - if (otherSides[myRef]) { - // I'm already there, go and update rels[otherSides[myRef]] with my info - rels[otherSides[myRef]].right = f; - - // Make sure I'm actually referencing the thing on the left - const { left } = rels[otherSides[myRef]]; - if (f.ref !== `${left.listKey}.${left.path}`) { - throw new Error(`${myRef} refers to ${f.ref}. Expected ${left.listKey}.${left.path}`); - } - } else { - // Got us a new relationship! - rels[myRef] = { left: f }; - if (f.refFieldPath) { - // Populate otherSides - otherSides[f.ref] = myRef; - } - } - }); - }); - // See if anything failed to link up. - const badRel = Object.values(rels).find(({ left, right }) => left.refFieldPath && !right); - if (badRel) { - const { left } = badRel; - throw new Error(`${left.listKey}.${left.path} refers to a non-existant field, ${left.ref}`); - } - - // Ensure that the left/right pattern is always the same no matter what order - // the lists and fields are defined. - Object.values(rels).forEach(rel => { - const { left, right } = rel; - if (right) { - const order = left.listKey.localeCompare(right.listKey); - if (order > 0) { - // left comes after right, so swap them. - rel.left = right; - rel.right = left; - } else if (order === 0) { - // self referential list, so check the paths. - if (left.path.localeCompare(right.path) > 0) { - rel.left = right; - rel.right = left; - } - } - } - }); - - Object.values(rels).forEach(rel => { - const { left, right } = rel; - let cardinality: Rel['cardinality']; - if (left.many) { - if (right) { - if (right.many) { - cardinality = 'N:N'; - } else { - cardinality = '1:N'; - } - } else { - // right not specified, have to assume that it's N:N - cardinality = 'N:N'; - } - } else { - if (right) { - if (right.many) { - cardinality = 'N:1'; - } else { - cardinality = '1:1'; - } - } else { - // right not specified, have to assume that it's N:1 - cardinality = 'N:1'; - } - } - rel.cardinality = cardinality; - - let tableName; - let columnName; - if (cardinality === 'N:N') { - tableName = right - ? `${left.listKey}_${left.path}_${right.listKey}_${right.path}` - : `${left.listKey}_${left.path}_many`; - if (right) { - const leftKey = `${left.listKey}.${left.path}`; - const rightKey = `${right.listKey}.${right.path}`; - rel.columnNames = { - [leftKey]: { near: `${left.listKey}_left_id`, far: `${right.listKey}_right_id` }, - [rightKey]: { near: `${right.listKey}_right_id`, far: `${left.listKey}_left_id` }, - }; - } else { - const leftKey = `${left.listKey}.${left.path}`; - const rightKey = `${left.ref}`; - rel.columnNames = { - [leftKey]: { near: `${left.listKey}_left_id`, far: `${left.ref}_right_id` }, - [rightKey]: { near: `${left.ref}_right_id`, far: `${left.listKey}_left_id` }, - }; - } - } else if (cardinality === '1:1') { - tableName = left.listKey; - columnName = left.path; - } else if (cardinality === '1:N') { - tableName = right!.listKey; - columnName = right!.path; - } else { - tableName = left.listKey; - columnName = left.path; - } - rel.tableName = tableName; - rel.columnName = columnName; - }); - - return Object.values(rels); - } - - async connect(args?: { context: KeystoneContext }): Promise { - await this.adapter.connect({ rels: this._consolidateRelationships() }); - - if (this.onConnect) { - return this.onConnect(this, args); - } - } - - async disconnect() { - await this.adapter.disconnect(); - } - - getTypeDefs({ schemaName }: { schemaName: string }) { - const queries = unique(flatten(this._providers.map(p => p.getQueries({ schemaName })))); - const mutations = unique(flatten(this._providers.map(p => p.getMutations({ schemaName })))); - const subscriptions = unique( - flatten(this._providers.map(p => p.getSubscriptions({ schemaName }))) - ); - - // Fields can be represented multiple times within and between lists. - // If a field defines a `getGqlAuxTypes()` method, it will be - // duplicated. - // graphql-tools will blow up (rightly so) on duplicated types. - // Deduping here avoids that problem. - return [ - ...unique(flatten(this._providers.map(p => p.getTypes({ schemaName })))), - queries.length > 0 && `type Query { ${queries.join('\n')} }`, - mutations.length > 0 && `type Mutation { ${mutations.join('\n')} }`, - subscriptions.length > 0 && `type Subscription { ${subscriptions.join('\n')} }`, - 'scalar Upload', - ] - .filter(s => s) - .map(s => gql(s)); - } - - getResolvers({ schemaName }: { schemaName: string }) { - // Like the `typeDefs`, we want to dedupe the resolvers. We rely on the - // semantics of the JS spread operator here (duplicate keys are overridden - // - last one wins) - // TODO: Document this order of precedence, because it's not obvious, and - // there's no errors thrown - // TODO: console.warn when duplicate keys are detected? - const resolvers: Record = { - // Order of spreading is important here - we don't want user-defined types - // to accidentally override important things like `Query`. - ...objMerge(this._providers.map(p => p.getTypeResolvers({ schemaName }))), - Query: objMerge(this._providers.map(p => p.getQueryResolvers({ schemaName }))), - Mutation: objMerge(this._providers.map(p => p.getMutationResolvers({ schemaName }))), - Subscription: objMerge(this._providers.map(p => p.getSubscriptionResolvers({ schemaName }))), - Upload: GraphQLUpload, - }; - return filterValues(resolvers, o => Object.entries(o).length > 0); - } -} diff --git a/packages-next/keystone/src/lib/core/ListTypes/hooks.ts b/packages-next/keystone/src/lib/core/ListTypes/hooks.ts deleted file mode 100644 index 85b5ef1fed0..00000000000 --- a/packages-next/keystone/src/lib/core/ListTypes/hooks.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { omitBy, arrayToObject } from '@keystone-next/utils-legacy'; -import { Implementation } from '@keystone-next/fields'; -import { KeystoneContext, ListHooks } from '@keystone-next/types'; -import { mapToFields } from './utils'; -import { ValidationFailureError } from './graphqlErrors'; - -type ValidationError = { msg: string; data: {}; internalData: {} }; - -export class HookManager { - fields: Implementation[]; - hooks: ListHooks; - listKey: string; - fieldsByPath: Record>; - constructor({ - fields, - hooks, - listKey, - }: { - fields: Implementation[]; - hooks: ListHooks; - listKey: string; - }) { - this.fields = fields; - this.hooks = hooks; - this.listKey = listKey; - this.fieldsByPath = arrayToObject(this.fields, 'path'); - } - - _fieldsFromObject(obj: Record) { - return Object.keys(obj) - .map(fieldPath => this.fieldsByPath[fieldPath]) - .filter(field => field); - } - - _throwValidationFailure({ - errors, - operation, - originalInput, - }: { - errors: ValidationError[]; - operation: 'create' | 'update' | 'delete'; - originalInput: Record; - }) { - throw new ValidationFailureError({ - data: { - messages: errors.map(e => e.msg), - errors: errors.map(e => e.data), - listKey: this.listKey, - operation, - }, - internalData: { errors: errors.map(e => e.internalData), data: originalInput }, - }); - } - - async resolveInput({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }: { - resolvedData: Record; - existingItem?: Record; - context: KeystoneContext; - operation: 'create' | 'update'; - originalInput: Record; - }) { - const { listKey } = this; - const args = { resolvedData, existingItem, context, originalInput, operation, listKey }; - - // First we run the field type hooks - // NOTE: resolveInput is run on _every_ field, regardless if it has a value - // passed in or not - resolvedData = await mapToFields(this.fields, (field: Implementation) => - field.resolveInput(args) - ); - - // We then filter out the `undefined` results (they should return `null` or - // a value) - resolvedData = omitBy(resolvedData, key => typeof resolvedData[key] === 'undefined'); - - // Run the schema-level field hooks, passing in the results from the field - // type hooks - resolvedData = { - ...resolvedData, - ...(await mapToFields( - this.fields.filter(field => field.hooks.resolveInput), - (field: Implementation) => - field.hooks.resolveInput({ ...args, fieldPath: field.path, resolvedData }) - )), - }; - - // And filter out the `undefined`s again. - resolvedData = omitBy(resolvedData, key => typeof resolvedData[key] === 'undefined'); - - if (this.hooks.resolveInput) { - // And run any list-level hook - resolvedData = await this.hooks.resolveInput({ ...args, resolvedData }); - if (typeof resolvedData !== 'object') { - throw new Error( - `Expected ${ - this.listKey - }.hooks.resolveInput() to return an object, but got a ${typeof resolvedData}: ${resolvedData}` - ); - } - } - - // Finally returning the amalgamated result of all the hooks. - return resolvedData; - } - - async validateInput({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }: { - resolvedData: Record; - existingItem?: Record; - context: KeystoneContext; - operation: 'create' | 'update'; - originalInput: Record; - }) { - const { listKey } = this; - const args = { resolvedData, existingItem, context, originalInput, operation, listKey }; - // Check for isRequired - const fieldValidationErrors = this.fields - .filter( - field => - field.isRequired && - !field.isRelationship && - ((operation === 'create' && - (resolvedData[field.path] === undefined || resolvedData[field.path] === null)) || - (operation === 'update' && - Object.prototype.hasOwnProperty.call(resolvedData, field.path) && - (resolvedData[field.path] === undefined || resolvedData[field.path] === null))) - ) - .map(f => ({ - msg: `Required field "${f.path}" is null or undefined.`, - data: { resolvedData, operation, originalInput }, - internalData: {}, - })); - if (fieldValidationErrors.length) { - this._throwValidationFailure({ errors: fieldValidationErrors, operation, originalInput }); - } - - const fields = this._fieldsFromObject(resolvedData); - await this._validateHook({ args, fields, operation, hookName: 'validateInput' }); - } - - async validateDelete({ - existingItem, - context, - operation, - }: { - existingItem?: Record; - context: KeystoneContext; - operation: 'delete'; - }) { - const { listKey } = this; - const args = { existingItem, context, operation, listKey }; - const fields = this.fields; - await this._validateHook({ args, fields, operation, hookName: 'validateDelete' }); - } - - async _validateHook({ - args, - fields, - operation, - hookName, - }: { - args: any; - fields: Implementation[]; - operation: 'create' | 'update' | 'delete'; - hookName: 'validateInput' | 'validateDelete'; - }) { - const { originalInput } = args; - const fieldValidationErrors: ValidationError[] = []; - // FIXME: Can we do this in a way where we simply return validation errors instead? - const fieldArgs = { - ...args, - addValidationError: (msg: string, _data = {}, internalData = {}) => - fieldValidationErrors.push({ msg, data: _data, internalData }), - // deprecated: Will be removed in a future release. - addFieldValidationError: (msg: string, _data = {}, internalData = {}) => { - console.log( - 'addFieldValidationError is deprecated. Please use addValidationError instead.' - ); - return fieldValidationErrors.push({ msg, data: _data, internalData }); - }, - }; - await mapToFields(fields, (field: Implementation) => - field[hookName]({ fieldPath: field.path, ...fieldArgs }) - ); - await mapToFields( - fields.filter(field => field.hooks[hookName]), - (field: Implementation) => field.hooks[hookName]({ fieldPath: field.path, ...fieldArgs }) - ); - if (fieldValidationErrors.length) { - this._throwValidationFailure({ errors: fieldValidationErrors, operation, originalInput }); - } - - if (this.hooks[hookName]) { - const listValidationErrors: ValidationError[] = []; - await this.hooks[hookName]!({ - ...args, - addValidationError: (msg: string, _data = {}, internalData = {}) => - listValidationErrors.push({ msg, data: _data, internalData }), - }); - if (listValidationErrors.length) { - this._throwValidationFailure({ errors: listValidationErrors, operation, originalInput }); - } - } - } - - async beforeChange({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }: { - resolvedData: Record; - existingItem?: Record; - context: KeystoneContext; - operation: 'create' | 'update'; - originalInput: Record; - }) { - const { listKey } = this; - const args = { resolvedData, existingItem, context, originalInput, operation, listKey }; - await this._runHook({ args, fieldObject: resolvedData, hookName: 'beforeChange' }); - } - - async beforeDelete({ - existingItem, - context, - operation, - }: { - existingItem: Record; - context: KeystoneContext; - operation: 'delete'; - }) { - const { listKey } = this; - const args = { existingItem, context, operation, listKey }; - await this._runHook({ args, fieldObject: existingItem, hookName: 'beforeDelete' }); - } - - async afterChange({ - updatedItem, - existingItem, - context, - operation, - originalInput, - }: { - updatedItem: Record; - existingItem?: Record; - context: KeystoneContext; - operation: 'create' | 'update'; - originalInput: Record; - }) { - const { listKey } = this; - const args = { updatedItem, originalInput, existingItem, context, operation, listKey }; - await this._runHook({ args, fieldObject: updatedItem, hookName: 'afterChange' }); - } - - async afterDelete({ - existingItem, - context, - operation, - }: { - existingItem: Record; - context: KeystoneContext; - operation: 'delete'; - }) { - const { listKey } = this; - const args = { existingItem, context, operation, listKey }; - await this._runHook({ args, fieldObject: existingItem, hookName: 'afterDelete' }); - } - - async _runHook({ - args, - fieldObject, - hookName, - }: { - args: any; - fieldObject: Record; - hookName: keyof ListHooks; - }) { - // Used to apply hooks that only produce side effects - const fields = this._fieldsFromObject(fieldObject); - await mapToFields(fields, (field: Implementation) => field[hookName](args)); - await mapToFields( - fields.filter(field => field.hooks[hookName]), - (field: Implementation) => field.hooks[hookName]({ fieldPath: field.path, ...args }) - ); - - if (this.hooks[hookName]) await this.hooks[hookName]!(args); - } -} diff --git a/packages-next/keystone/src/lib/core/ListTypes/index.ts b/packages-next/keystone/src/lib/core/ListTypes/index.ts deleted file mode 100644 index 53821c4bde3..00000000000 --- a/packages-next/keystone/src/lib/core/ListTypes/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { List } from './list'; diff --git a/packages-next/keystone/src/lib/core/ListTypes/list.ts b/packages-next/keystone/src/lib/core/ListTypes/list.ts deleted file mode 100644 index f26057ca1b8..00000000000 --- a/packages-next/keystone/src/lib/core/ListTypes/list.ts +++ /dev/null @@ -1,1442 +0,0 @@ -import pluralize from 'pluralize'; -import type { CacheHint } from 'apollo-cache-control'; -import { - mapKeys, - omit, - omitBy, - unique, - intersection, - mergeWhereClause, - objMerge, - flatten, - zipObj, - createLazyDeferred, -} from '@keystone-next/utils-legacy'; -import { parseListAccess } from '@keystone-next/access-control-legacy'; -import { - PrismaAdapter, - PrismaFieldAdapter, - PrismaListAdapter, -} from '@keystone-next/adapter-prisma-legacy'; -import { - BaseGeneratedListTypes, - BaseKeystoneList, - BaseListConfig, - GraphQLResolver, - KeystoneContext, - ListHooks, - CacheHintArgs, -} from '@keystone-next/types'; -import { Implementation } from '@keystone-next/fields'; -import { Relationship } from '@keystone-next/fields/src/types/relationship/Implementation'; -import { keyToLabel, labelToPath, labelToClass, opToType, mapToFields } from './utils'; -import { HookManager } from './hooks'; -import { LimitsExceededError, throwAccessDenied, AccessDeniedError } from './graphqlErrors'; - -type MutationState = { afterChangeStack: any[]; transaction: {} }; - -type IdType = string | number; - -type CreateUpdateData = Record; - -type FullFieldConfig = { - type: { - type: string; - implementation: typeof Implementation; - adapter: typeof PrismaFieldAdapter; - isRelationship?: boolean; - }; -}; - -export class List implements BaseKeystoneList { - key: string; - _fields: Record; - _hooks: ListHooks; - adapter: PrismaListAdapter; - access: Record; - _schemaNames: string[]; - gqlNames: BaseKeystoneList['gqlNames']; - fields: Implementation[]; - fieldsByPath: Record>; - hookManager: HookManager; - schemaDoc?: string; - adminDoc?: string; - adminUILabels: { label: string; singular: string; plural: string; path: string }; - getListByKey: (key: string) => BaseKeystoneList | undefined; - fieldsInitialised: boolean; - queryLimits: { maxResults: number }; - cacheHint?: ((args: CacheHintArgs) => CacheHint) | CacheHint; - constructor( - key: string, - { - fields, - hooks = {}, - adminDoc, - schemaDoc, - access, - itemQueryName, - listQueryName, - label, - singular, - plural, - path, - adapterConfig = {}, - queryLimits = {}, - cacheHint, - }: BaseListConfig, - { - getListByKey, - adapter, - }: { getListByKey: (key: string) => BaseKeystoneList | undefined; adapter: PrismaAdapter } - ) { - this.key = key; - this._fields = fields; - this._hooks = hooks; - this.schemaDoc = schemaDoc; - this.adminDoc = adminDoc; - - this.getListByKey = getListByKey; - - const _label = label || keyToLabel(key); - const _singular = singular || pluralize.singular(_label); - const _plural = plural || pluralize.plural(_label); - - if (_plural === _label) { - throw new Error( - `Unable to use ${_label} as a List name - it has an ambiguous plural (${_plural}). Please choose another name for your list.` - ); - } - - this.adminUILabels = { - // Fall back to the plural for the label if none was provided, not the autogenerated default from key - label: label || _plural, - singular: _singular, - plural: _plural, - path: path || labelToPath(_plural), - }; - - const _itemQueryName = itemQueryName || labelToClass(_singular); - const _listQueryName = listQueryName || labelToClass(_plural); - const _lowerListName = _listQueryName.slice(0, 1).toLowerCase() + _listQueryName.slice(1); - - this.gqlNames = { - outputTypeName: this.key, - itemQueryName: _itemQueryName, - listQueryName: `all${_listQueryName}`, - listQueryMetaName: `_all${_listQueryName}Meta`, - listQueryCountName: `${_lowerListName}Count`, - listSortName: `Sort${_listQueryName}By`, - listOrderName: `${_itemQueryName}OrderByInput`, - deleteMutationName: `delete${_itemQueryName}`, - updateMutationName: `update${_itemQueryName}`, - createMutationName: `create${_itemQueryName}`, - deleteManyMutationName: `delete${_listQueryName}`, - updateManyMutationName: `update${_listQueryName}`, - createManyMutationName: `create${_listQueryName}`, - whereInputName: `${_itemQueryName}WhereInput`, - whereUniqueInputName: `${_itemQueryName}WhereUniqueInput`, - updateInputName: `${_itemQueryName}UpdateInput`, - createInputName: `${_itemQueryName}CreateInput`, - updateManyInputName: `${_listQueryName}UpdateInput`, - createManyInputName: `${_listQueryName}CreateInput`, - relateToManyInputName: `${_itemQueryName}RelateToManyInput`, - relateToOneInputName: `${_itemQueryName}RelateToOneInput`, - }; - - this.adapter = adapter.newListAdapter(this.key, adapterConfig, this.gqlNames); - this._schemaNames = ['public']; - - this.access = parseListAccess({ - schemaNames: this._schemaNames, - listKey: key, - access, - defaultAccess: true, - }); - - this.queryLimits = { maxResults: Infinity, ...queryLimits }; - if (this.queryLimits.maxResults < 1) { - throw new Error(`List ${label}'s queryLimits.maxResults can't be < 1`); - } - - if (!['object', 'function', 'undefined'].includes(typeof cacheHint)) { - throw new Error(`List ${label}'s cacheHint must be an object or function`); - } - this.cacheHint = cacheHint; - - this.fields = []; - this.fieldsByPath = {}; - this.hookManager = {} as HookManager; - this.fieldsInitialised = false; - } - - initFields() { - if (this.fieldsInitialised) return; - this.fieldsInitialised = true; - - let sanitisedFieldsConfig = this._fields; - - // Add an 'id' field if none supplied - if (!sanitisedFieldsConfig.id) { - throw new Error(`No 'id' field given for the '${this.key}' list.`); - } - - // Helpful errors for misconfigured lists - Object.entries(sanitisedFieldsConfig).forEach(([fieldKey, fieldConfig]) => { - if (fieldKey[0] === '_') { - throw new Error( - `Invalid field name "${fieldKey}". Field names cannot start with an underscore.` - ); - } - if (typeof fieldConfig.type === 'undefined') { - throw new Error( - `The '${this.key}.${fieldKey}' field doesn't specify a valid type. ` + - `(${this.key}.${fieldKey}.type is undefined)` - ); - } - if (typeof fieldConfig.type.adapter === 'undefined') { - throw new Error( - `The type given for the '${this.key}.${fieldKey}' field doesn't define an adapter.` - ); - } - }); - - this.fieldsByPath = mapKeys( - sanitisedFieldsConfig, - ({ type, ...fieldSpec }, path) => - new type.implementation(path, fieldSpec, { - getListByKey: this.getListByKey, - listKey: this.key, - listAdapter: this.adapter, - fieldAdapterClass: type.adapter, - schemaNames: this._schemaNames, - }) - ); - this.fields = Object.values(this.fieldsByPath); - this.hookManager = new HookManager({ - fields: this.fields, - hooks: this._hooks, - listKey: this.key, - }); - } - - getFieldsWithAccess({ - schemaName, - access, - }: { - schemaName: string; - access: 'read' | 'update' | 'create'; - }) { - return this.fields - .filter(({ path }) => path !== 'id') // Exclude the id fields update types - .filter(field => field.access[schemaName][access]); // If it's globally set to false, makes sense to never let it be updated - } - - getAllFieldsWithAccess({ - schemaName, - access, - }: { - schemaName: string; - access: 'create' | 'read' | 'update' | 'create'; - }) { - // Equivalent to getFieldsWithAccess but includes `id` fields. - return this.fields.filter(field => field.access[schemaName][access]); - } - - getGraphqlFilterFragment() { - return [ - `where: ${this.gqlNames.whereInputName}! = {}`, - `search: String`, - `sortBy: [${this.gqlNames.listSortName}!] @deprecated(reason: "sortBy has been deprecated in favour of orderBy")`, - `orderBy: [${this.gqlNames.listOrderName}!]! = []`, - `first: Int`, - `skip: Int! = 0`, - ]; - } - - _wrapFieldResolver(field: Implementation, innerResolver: GraphQLResolver) { - // Wrap the "inner" resolver for a single output field with list-specific modifiers - return (async (item, args, context, info) => { - // Check access - const operation = 'read'; - const access = await context.getFieldAccessControlForUser( - field.access, - this.key, - field.path, - undefined, - item, - operation, - { context } - ); - if (!access) { - // If the client handles errors correctly, it should be able to - // receive partial data (for the fields the user has access to), - // and then an `errors` array of AccessDeniedError's - throwAccessDenied(opToType[operation], field.path, { itemId: item ? item.id : null }); - } - - // Only static cache hints are supported at the field level until a use-case makes it clear what parameters a dynamic hint would take - // @ts-ignore - if (field.config.cacheHint && info && info.cacheControl) { - // @ts-ignore - info.cacheControl.setCacheHint(field.config.cacheHint); - } - - // Execute the original/inner resolver - return innerResolver(item, args, context, info); - }) as GraphQLResolver; - } - - async checkFieldAccess( - operation: 'create' | 'read' | 'update' | 'delete', - itemsToUpdate: { - existingItem: Record | undefined; - id?: IdType; - data: Record; - }[], - context: KeystoneContext, - { gqlName, ...extraInternalData }: { gqlName: string } & Record - ) { - const restrictedFields = []; - for (const { existingItem, id, data } of itemsToUpdate) { - const fields = this.fields.filter(field => field.path in data); - - for (const field of fields) { - const access = await context.getFieldAccessControlForUser( - field.access, - this.key, - field.path, - data, - existingItem, - operation, - { gqlName, itemId: id, context, ...extraInternalData } - ); - if (!access) { - restrictedFields.push(field.path); - } - } - } - if (restrictedFields.length) { - throwAccessDenied(opToType[operation], gqlName, extraInternalData, { restrictedFields }); - } - } - - async _returnFieldAccess( - operation: 'create' | 'read' | 'update' | 'delete', - itemsToUpdate: { - existingItem: Record | undefined; - id?: IdType; - data: Record; - }[], - context: KeystoneContext, - { gqlName, ...extraInternalData }: { gqlName: string } & Record - ) { - const _access = []; - for (const item of itemsToUpdate) { - if (item === null) { - _access.push(null); - } else { - const { existingItem, id, data } = item; - const fields = this.fields.filter(field => field.path in data); - const restrictedFields = []; - for (const field of fields) { - const access = await context.getFieldAccessControlForUser( - field.access, - this.key, - field.path, - data, - existingItem, - operation, - { gqlName, itemId: id, context, ...extraInternalData } - ); - if (!access) { - restrictedFields.push(field.path); - } - } - if (restrictedFields.length) { - _access.push(restrictedFields); - } else { - _access.push(null); - } - } - } - return _access; - } - - async checkListAccess( - context: KeystoneContext, - originalInput: Record | undefined, - operation: 'create' | 'read' | 'update' | 'delete', - { gqlName, ...extraInternalData }: { gqlName?: string } & Record - ) { - const access = await context.getListAccessControlForUser( - this.access, - this.key, - originalInput, - operation, - { gqlName, context, ...extraInternalData } - ); - if (!access) { - // If the client handles errors correctly, it should be able to - // receive partial data (for the fields the user has access to), - // and then an `errors` array of AccessDeniedError's - throwAccessDenied(opToType[operation], gqlName, extraInternalData); - } - return access; - } - - async _returnListAccess( - context: KeystoneContext, - originalInput: Record | undefined, - operation: 'create' | 'read' | 'update' | 'delete', - { gqlName, ...extraInternalData }: { gqlName?: string } & Record - ) { - return context.getListAccessControlForUser(this.access, this.key, originalInput, operation, { - gqlName, - context, - ...extraInternalData, - }); - } - - async getAccessControlledItem( - id: IdType, - access: any, - { - context, - operation, - gqlName, - info, - }: { - context: KeystoneContext; - operation: 'create' | 'read' | 'update' | 'delete'; - gqlName?: string; - info?: any; - } - ) { - const _throwAccessDenied = () => { - // If the client handles errors correctly, it should be able to - // receive partial data (for the fields the user has access to), - // and then an `errors` array of AccessDeniedError's - throwAccessDenied(opToType[operation], gqlName, { itemId: id }); - }; - - let item; - if ( - (access.id && access.id !== id) || - (access.id_not && access.id_not === id) || - (access.id_in && !access.id_in.includes(id)) || - (access.id_not_in && access.id_not_in.includes(id)) - ) { - // It's odd, but conceivable the access control specifies a single id - // the user has access to. So we have to do a check here to see if the - // ID they're requesting matches that ID. - // Nice side-effect: We can throw without having to ever query the DB. - _throwAccessDenied(); - } else { - // NOTE: The fields will be filtered by the ACL checking in gqlFieldResolvers() - // We only want 1 item, don't make the DB do extra work - // NOTE: Order in where: { ... } doesn't matter, if `access.id !== id`, it will - // have been caught earlier, so this spread and overwrite can only - // ever be additive or overwrite with the same value - item = ( - (await this._itemsQuery( - { first: 1, where: { ...access, id } }, - { context, info } - )) as Record[] - )[0]; - } - if (!item) { - // Throwing an AccessDenied here if the item isn't found because we're - // strict about accidentally leaking information (that the item doesn't - // exist) - // NOTE: There is a potential security risk here if we were to - // further check the existence of an item with the given ID: It'd be - // possible to figure out if records with particular IDs exist in - // the DB even if the user doesn't have access (eg; check a bunch of - // IDs, and the ones that return AccessDenied exist, and the ones - // that return null do not exist). Similar to how S3 returns 403's - // always instead of ever returning 404's. - // Our version is to always throw if not found. - _throwAccessDenied(); - } - // Found the item, and it passed the filter test - return item as { id: IdType } & Record; - } - - async getAccessControlledItems( - ids: IdType[], - access: any, - { context, info }: { context?: KeystoneContext; info?: any } = {} - ) { - if (ids.length === 0) { - return []; - } - - const uniqueIds = unique(ids); - - // Early out - the user has full access to operate on this list - if (access === true) { - return await this._itemsQuery({ where: { id_in: uniqueIds } }, { context, info }); - } - - let idFilters: Record = {}; - - if (access.id || access.id_in) { - const accessControlIdsAllowed = unique([].concat(access.id, access.id_in).filter(id => id)); - - idFilters.id_in = intersection(accessControlIdsAllowed, uniqueIds); - } else { - idFilters.id_in = uniqueIds; - } - - if (access.id_not || access.id_not_in) { - const accessControlIdsDisallowed = unique( - [].concat(access.id_not, access.id_not_in).filter(id => id) - ); - - idFilters.id_not_in = intersection(accessControlIdsDisallowed, uniqueIds); - } - - // It's odd, but conceivable the access control specifies a single id - // the user has access to. So we have to do a check here to see if the - // ID they're requesting matches that ID. - // Nice side-effect: We can throw without having to ever query the DB. - if ( - // Only some ids are allowed, and none of them have been passed in - (idFilters.id_in && idFilters.id_in.length === 0) || - // All the passed in ids have been explicitly disallowed - (idFilters.id_not_in && idFilters.id_not_in.length === uniqueIds.length) - ) { - // NOTE: We don't throw an error for multi-actions, only return an empty - // array because there's no mechanism in GraphQL to return more than one - // error for a list result. - return []; - } - - // NOTE: The fields will be filtered by the ACL checking in gqlFieldResolvers() - // NOTE: Unlike in the single-operation variation, there is no security risk - // in returning the result of the query here, because if no items match, we - // return an empty array regardless of if that's because of lack of - // permissions or because of those items don't exist. - const remainingAccess = omit(access, ['id', 'id_not', 'id_in', 'id_not_in']); - return await this._itemsQuery( - { where: { ...remainingAccess, ...idFilters } }, - { context, info } - ); - } - - async listQuery( - args: BaseGeneratedListTypes['args']['listQuery'], - context: KeystoneContext, - gqlName: string, - info: any, - from?: any - ) { - const access = await this.checkListAccess(context, undefined, 'read', { gqlName }); - - return this._itemsQuery(mergeWhereClause(args, access), { context, info, from }) as Promise< - Record[] - >; - } - - async listQueryMeta( - args: Record, - context: KeystoneContext, - gqlName: string, - info: any, - from?: any - ) { - return { - // Return these as functions so they're lazily evaluated depending - // on what the user requested - // Evaluation takes place in ../Keystone/index.js - getCount: async () => { - const access = await this.checkListAccess(context, undefined, 'read', { gqlName }); - - const { count } = (await this._itemsQuery(mergeWhereClause(args, access), { - meta: true, - context, - info, - from, - })) as { count: number }; - - return count; - }, - }; - } - - async itemQuery( - { where: { id } }: { where: { id: string } }, - context: KeystoneContext, - gqlName?: string, - info?: any - ) { - const operation = 'read'; - - const access = await this.checkListAccess(context, undefined, operation, { - gqlName, - itemId: id, - }); - - return this.getAccessControlledItem(id, access, { context, operation, gqlName, info }); - } - - async _itemsQuery( - args: Record, - { - meta, - context, - info, - from, - }: { meta?: boolean; context?: KeystoneContext; info?: any; from?: {} } = {} - ) { - // This is private because it doesn't handle access control - - const { maxResults } = this.queryLimits; - - const throwLimitsExceeded = (args: { type: string; limit: number }) => { - throw new LimitsExceededError({ data: { list: this.key, ...args } }); - }; - - // Need to enforce List-specific query limits - const { first = Infinity } = args; - // We want to help devs by failing fast and noisily if limits are violated. - // Unfortunately, we can't always be sure of intent. - // E.g., if the query has a "first: 10", is it bad if more results could come back? - // Maybe yes, or maybe the dev is just paginating posts. - // But we can be sure there's a problem in two cases: - // * The query explicitly has a "first" that exceeds the limit - // * The query has no "first", and has more results than the limit - if (first < Infinity && first > maxResults) { - throwLimitsExceeded({ type: 'maxResults', limit: maxResults }); - } - if (!meta) { - // "first" is designed to truncate the count value, but accurate counts are still - // needed for pagination. resultsLimit is meant for protecting KS memory usage, - // not DB performance, anyway, so resultsLimit is only applied to queries that - // could return many results. - // + 1 to allow limit violation detection - const resultsLimit = Math.min(maxResults + 1, first); - if (resultsLimit < Infinity) { - args.first = resultsLimit; - } - } - const results = await this.adapter.itemsQuery(args, { meta, from }); - if (Array.isArray(results) && results.length > maxResults) { - throwLimitsExceeded({ type: 'maxResults', limit: maxResults }); - } - if (context) { - context.totalResults += Array.isArray(results) ? results.length : 1; - if (context.totalResults > context.maxTotalResults) { - throwLimitsExceeded({ type: 'maxTotalResults', limit: context.maxTotalResults }); - } - } - - if (info && info.cacheControl) { - switch (typeof this.cacheHint) { - case 'object': - info.cacheControl.setCacheHint(this.cacheHint); - break; - - case 'function': - const operationName = info.operation.name && info.operation.name.value; - info.cacheControl.setCacheHint(this.cacheHint({ results, operationName, meta: !!meta })); - break; - - case 'undefined': - break; - } - } - - return results; - } - - // Mutation resolvers - _fieldsFromObject(obj: Record) { - return Object.keys(obj) - .map(fieldPath => this.fieldsByPath[fieldPath]) - .filter(field => field); - } - - async _resolveRelationship( - data: Record, - existingItem: Record | undefined, - context: KeystoneContext, - getItem: any, - mutationState: MutationState - ) { - const fields = this._fieldsFromObject(data).filter( - field => field.isRelationship - ) as Relationship[]; - const resolvedRelationships = await mapToFields(fields, async field => { - // Treat `null` as `undefined`, e.g. a no-op - if (data[field.path] === null) return undefined; - const { create, connect, disconnect, currentValue } = await field.resolveNestedOperations( - data[field.path], - existingItem, - context, - mutationState - ); - // This code codifies the order of operations for nested mutations: - // 1. disconnectAll - // 2. disconnect - // 3. create - // 4. connect - if (field.many) { - return [ - ...(currentValue as string[]).filter((id: string) => !disconnect.includes(id)), - ...connect, - ...create, - ].filter(id => !!id); - } else { - return create && create[0] - ? create[0] - : connect && connect[0] - ? connect[0] - : disconnect && disconnect[0] - ? null - : currentValue; - } - }); - - return { ...data, ...resolvedRelationships }; - } - - async _resolveDefaults({ - context, - originalInput, - }: { - context: KeystoneContext; - originalInput: Record; - }) { - const args = { context, originalInput }; - - const fieldsWithoutValues = this.fields.filter( - field => typeof originalInput[field.path] === 'undefined' - ); - - const defaultValues = await mapToFields(fieldsWithoutValues, field => - field.getDefaultValue(args) - ); - - return { - ...omitBy(defaultValues, path => typeof defaultValues[path] === 'undefined'), - ...originalInput, - }; - } - - async _nestedMutation( - mutationState: MutationState | undefined, - mutation: (mutationState: MutationState) => Promise<{ result: any; afterHook: any }> - ) { - // Set up a fresh mutation state if we're the root mutation - const isRootMutation = !mutationState; - if (!mutationState) { - mutationState = { - afterChangeStack: [], // post-hook stack - transaction: {}, // transaction - }; - } - - // Perform the mutation - const { result, afterHook } = await mutation(mutationState); - - // Push after-hook onto the stack and resolve all if we're the root. - const { afterChangeStack } = mutationState; - afterChangeStack.push(afterHook); - if (isRootMutation) { - // TODO: Close transaction - - // Execute post-hook stack - while (afterChangeStack.length) { - await afterChangeStack.pop()(); - } - } - - // Return the result of the mutation - return result; - } - - async createMutation( - data: CreateUpdateData, - context: KeystoneContext, - mutationState?: MutationState - ) { - const operation = 'create'; - const gqlName = this.gqlNames.createMutationName; - - await this.checkListAccess(context, data, operation, { gqlName }); - - const existingItem = undefined; - - const itemsToUpdate = [{ existingItem, data }]; - - await this.checkFieldAccess(operation, itemsToUpdate, context, { gqlName }); - - return await this._createSingle(data, context, mutationState); - } - - async createManyMutation( - data: { data: CreateUpdateData }[], - context: KeystoneContext, - mutationState?: MutationState - ) { - const operation = 'create'; - const gqlName = this.gqlNames.createManyMutationName; - - const access = await this._returnListAccess(context, data, operation, { gqlName }); - if (!access) { - // Return an access denied error for all items - return data.map(() => Promise.reject(new AccessDeniedError({ data: { type: 'mutation' } }))); - } - - const itemsToUpdate = data.map(d => ({ existingItem: undefined, data: d.data })); - - const fieldAccess = await this._returnFieldAccess(operation, itemsToUpdate, context, { - gqlName, - }); - const results = await Promise.allSettled( - data.map((d, i) => { - if (fieldAccess[i] !== null) { - return Promise.reject(new AccessDeniedError({ data: { type: 'mutation' } })); - } else { - return this._createSingle(d.data, context, mutationState); - } - }) - ); - - return results.map(result => - result.status === 'fulfilled' ? Promise.resolve(result.value) : Promise.reject(result.reason) - ); - } - - async _createSingle( - originalInput: Record, - context: KeystoneContext, - mutationState?: MutationState - ) { - const operation = 'create'; - const existingItem = undefined; - return await this._nestedMutation(mutationState, async (mutationState: MutationState) => { - const defaultedItem = await this._resolveDefaults({ context, originalInput }); - - // Enable resolveRelationship to perform some action after the item is created by - // giving them a promise which will eventually resolve with the value of the - // newly created item. - const createdPromise = createLazyDeferred(); - - let resolvedData = await this._resolveRelationship( - defaultedItem, - existingItem, - context, - createdPromise.promise, - mutationState - ); - - resolvedData = await this.hookManager.resolveInput({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }); - - await this.hookManager.validateInput({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }); - - await this.hookManager.beforeChange({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }); - - let updatedItem: Record; - try { - updatedItem = (await this.adapter.create(resolvedData)) as Record; - createdPromise.resolve(updatedItem); - // Wait until next tick so the promise/micro-task queue can be flushed - // fully, ensuring the deferred handlers get executed before we move on - await new Promise(res => process.nextTick(res)); - } catch (error) { - createdPromise.reject(error); - // Wait until next tick so the promise/micro-task queue can be flushed - // fully, ensuring the deferred handlers get executed before we move on - await new Promise(res => process.nextTick(res)); - // Rethrow the error to ensure it's surfaced to Apollo - throw error; - } - - return { - result: updatedItem, - afterHook: () => - this.hookManager.afterChange({ - updatedItem, - existingItem, - context, - operation, - originalInput, - }), - }; - }); - } - - async updateMutation( - id: IdType, - data: CreateUpdateData, - context: KeystoneContext, - mutationState?: MutationState - ) { - const operation = 'update'; - const gqlName = this.gqlNames.updateMutationName; - const extraData = { gqlName, itemId: id }; - - const access = await this.checkListAccess(context, data, operation, extraData); - - const existingItem = await this.getAccessControlledItem(id, access, { - context, - operation, - gqlName, - }); - - const itemsToUpdate = [{ existingItem, data }]; - - await this.checkFieldAccess(operation, itemsToUpdate, context, extraData); - - return await this._updateSingle(id, data, existingItem, context, mutationState); - } - - async updateManyMutation( - data: { id: IdType; data: CreateUpdateData }[], - context: KeystoneContext, - mutationState?: MutationState - ) { - const operation = 'update'; - const gqlName = this.gqlNames.updateManyMutationName; - const ids = data.map(d => d.id); - const extraData = { gqlName, itemIds: ids }; - - const access = await this._returnListAccess(context, data, operation, extraData); - if (!access) { - // Return an access denied error for all items - return data.map(() => Promise.reject(new AccessDeniedError({ data: { type: 'mutation' } }))); - } - - const existingItems = (await this.getAccessControlledItems(ids, access)) as Record< - string, - any - >[]; - - // Only update those items which pass access control - const _itemsToUpdate = zipObj({ - existingItem: existingItems, - id: existingItems.map(({ id }) => id), // itemId is taken from here in checkFieldAccess - data: existingItems.map(({ id }) => data.find(d => d.id === id)!.data), - }) as { existingItem: Record; id: IdType; data: Record }[]; - - // Put items to update back into the same order as the original IDs - const itemsToUpdateById = Object.fromEntries(_itemsToUpdate.map(o => [o.id, o])); - const itemsToUpdate = ids.map(id => itemsToUpdateById[id] || null); - - const fieldAccess = await this._returnFieldAccess(operation, itemsToUpdate, context, extraData); - let results: PromiseSettledResult[] = []; - if (this.adapter.parentAdapter.provider === 'sqlite') { - // We perform these operations sequentially as a workaround for a connection - // timeout bug that happens in prisma+sqlite: https://github.com/prisma/prisma/issues/2955 - let i = 0; - for (const item of itemsToUpdate) { - if (item === null || fieldAccess[i] !== null) { - // The item either didn't exist, or was filtered out by access control - results.push({ - status: 'rejected', - reason: new AccessDeniedError({ data: { type: 'mutation' } }), - }); - } else { - const { existingItem, id, data } = item; - try { - const result = await this._updateSingle(id, data, existingItem, context, mutationState); - results.push({ status: 'fulfilled', value: result }); - } catch (e) { - results.push({ status: 'rejected', reason: e }); - } - } - i++; - } - } else { - results = await Promise.allSettled( - itemsToUpdate.map((item, i) => { - if (item === null || fieldAccess[i] !== null) { - // The item either didn't exist, or was filtered out by access control - return Promise.reject(new AccessDeniedError({ data: { type: 'mutation' } })); - } else { - const { existingItem, id, data } = item; - return this._updateSingle(id, data, existingItem, context, mutationState); - } - }) - ); - } - return results.map(result => - result.status === 'fulfilled' ? Promise.resolve(result.value) : Promise.reject(result.reason) - ); - } - - async _updateSingle( - id: IdType, - originalInput: Record, - existingItem: Record, - context: KeystoneContext, - mutationState?: MutationState - ) { - const operation = 'update'; - return await this._nestedMutation(mutationState, async (mutationState: MutationState) => { - let resolvedData = await this._resolveRelationship( - originalInput, - existingItem, - context, - undefined, - mutationState - ); - - resolvedData = await this.hookManager.resolveInput({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }); - - await this.hookManager.validateInput({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }); - - await this.hookManager.beforeChange({ - resolvedData, - existingItem, - context, - operation, - originalInput, - }); - - const updatedItem = (await this.adapter.update(id, resolvedData)) as Record; - - return { - result: updatedItem, - afterHook: () => - this.hookManager.afterChange({ - updatedItem, - existingItem, - context, - operation, - originalInput, - }), - }; - }); - } - - async deleteMutation(id: IdType, context: KeystoneContext, mutationState?: MutationState) { - const operation = 'delete'; - const gqlName = this.gqlNames.deleteMutationName; - - const access = await this.checkListAccess(context, undefined, operation, { - gqlName, - itemId: id, - }); - - const existingItem = await this.getAccessControlledItem(id, access, { - context, - operation, - gqlName, - }); - - return this._deleteSingle(existingItem, context, mutationState); - } - - async deleteManyMutation(ids: IdType[], context: KeystoneContext, mutationState?: MutationState) { - const operation = 'delete'; - const gqlName = this.gqlNames.deleteManyMutationName; - - const access = await this._returnListAccess(context, undefined, operation, { - gqlName, - itemIds: ids, - }); - if (!access) { - // Return an access denied error for all items - return ids.map(() => Promise.reject(new AccessDeniedError({ data: { type: 'mutation' } }))); - } - - const _existingItems = (await this.getAccessControlledItems(ids, access)) as any[]; - - // Put items to update back into the same order as the original IDs - const existingItemsById = Object.fromEntries(_existingItems.map(o => [o.id, o])); - const existingItems = ids.map(id => existingItemsById[id] || null); - - let results: PromiseSettledResult[] = []; - if (this.adapter.parentAdapter.provider === 'sqlite') { - // We perform these operations sequentially as a workaround for a connection - // timeout bug that happens in prisma+sqlite: https://github.com/prisma/prisma/issues/2955 - for (const existingItem of existingItems) { - if (existingItem === null) { - // The item either didn't exist, or was filtered out by access control - results.push({ - status: 'rejected', - reason: new AccessDeniedError({ data: { type: 'mutation' } }), - }); - } else { - try { - const result = await this._deleteSingle(existingItem, context, mutationState); - results.push({ status: 'fulfilled', value: result }); - } catch (e) { - results.push({ status: 'rejected', reason: e }); - } - } - } - } else { - results = await Promise.allSettled( - existingItems.map(existingItem => { - if (existingItem === null) { - // The item either didn't exist, or was filtered out by access control - return Promise.reject(new AccessDeniedError({ data: { type: 'mutation' } })); - } else { - return this._deleteSingle(existingItem, context, mutationState); - } - }) - ); - } - return results.map(result => - result.status === 'fulfilled' ? Promise.resolve(result.value) : Promise.reject(result.reason) - ); - } - - async _deleteSingle( - existingItem: { id: IdType } & Record, - context: KeystoneContext, - mutationState?: MutationState - ) { - const operation = 'delete'; - - return await this._nestedMutation(mutationState, async () => { - await this.hookManager.validateDelete({ existingItem, context, operation }); - - await this.hookManager.beforeDelete({ existingItem, context, operation }); - - await this.adapter.delete(existingItem.id); - - return { - result: existingItem, - afterHook: () => this.hookManager.afterDelete({ existingItem, context, operation }), - }; - }); - } - - // Methods called from ListCRUDProvider - getGqlTypes({ schemaName }: { schemaName: string }) { - const schemaAccess = this.access[schemaName]; - const types = []; - - // We want to include `id` fields - // If read is globally set to false, makes sense to never show it - const readFields = this.getAllFieldsWithAccess({ schemaName, access: 'read' }); - if (schemaAccess.read || schemaAccess.create || schemaAccess.update || schemaAccess.delete) { - types.push( - ...flatten(this.fields.map(field => field.getGqlAuxTypes({ schemaName }))), - ` - """ ${this.schemaDoc || 'A keystone list'} """ - type ${this.gqlNames.outputTypeName} { - ${flatten( - readFields.map(field => - field.schemaDoc - ? `""" ${field.schemaDoc} """ ${field.gqlOutputFields({ schemaName })}` - : field.gqlOutputFields({ schemaName }) - ) - ).join('\n')} - }`, - - // https://github.com/opencrud/opencrud/blob/master/spec/2-relational/2-2-queries/2-2-3-filters.md#boolean-expressions - ` - input ${this.gqlNames.whereInputName} { - AND: [${this.gqlNames.whereInputName}!] - OR: [${this.gqlNames.whereInputName}!] - - ${flatten(readFields.map(field => field.gqlQueryInputFields({ schemaName }))).join('\n')} - }`, - // TODO: Include other `unique` fields and allow filtering by them - ` - input ${this.gqlNames.whereUniqueInputName} { - id: ID! - }` - ); - - const sortOptions = flatten( - readFields.map(({ path, isOrderable }) => - // Explicitly allow sorting by id - isOrderable || path === 'id' ? [`${path}_ASC`, `${path}_DESC`] : [] - ) - ); - - if (sortOptions.length) { - types.push(` - enum ${this.gqlNames.listSortName} { - ${sortOptions.join('\n')} - } - `); - } - const orderItems = flatten( - readFields.map(({ path, isOrderable }) => - // Explicitly allow sorting by id - isOrderable || path === 'id' ? [`${path}: OrderDirection`] : [] - ) - ); - if (orderItems.length) { - types.push(` - input ${this.gqlNames.listOrderName} { - ${orderItems.join('\n')} - } - `); - types.push(`enum OrderDirection { asc desc }`); - } - } - - const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); - if (schemaAccess.update && updateFields.length) { - types.push(` - input ${this.gqlNames.updateInputName} { - ${flatten(updateFields.map(field => field.gqlUpdateInputFields({ schemaName }))).join( - '\n' - )} - } - `); - types.push(` - input ${this.gqlNames.updateManyInputName} { - id: ID! - data: ${this.gqlNames.updateInputName} - } - `); - } - - const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); - if (schemaAccess.create && createFields.length) { - types.push(` - input ${this.gqlNames.createInputName} { - ${flatten(createFields.map(field => field.gqlCreateInputFields({ schemaName }))).join( - '\n' - )} - } - `); - types.push(` - input ${this.gqlNames.createManyInputName} { - data: ${this.gqlNames.createInputName} - } - `); - } - - return types; - } - - getGqlQueries({ schemaName }: { schemaName: string }): string[] { - const schemaAccess = this.access[schemaName]; - // All the auxiliary queries the fields want to add - const queries = flatten(this.fields.map(field => field.getGqlAuxQueries())); - - // If `read` is either `true`, or a function (we don't care what the result - // of the function is, that'll get executed at a later time) - if (schemaAccess.read) { - queries.push( - ` - """ Search for all ${this.gqlNames.outputTypeName} items which match the where clause. """ - ${this.gqlNames.listQueryName}( - ${this.getGraphqlFilterFragment().join('\n')} - ): [${this.gqlNames.outputTypeName}!]`, - - ` - """ Search for the ${this.gqlNames.outputTypeName} item with the matching ID. """ - ${this.gqlNames.itemQueryName}( - where: ${this.gqlNames.whereUniqueInputName}! - ): ${this.gqlNames.outputTypeName}`, - - ` - """ Perform a meta-query on all ${ - this.gqlNames.outputTypeName - } items which match the where clause. """ - ${this.gqlNames.listQueryMetaName}( - ${this.getGraphqlFilterFragment().join('\n')} - ): _QueryMeta @deprecated(reason: "This query will be removed in a future version. Please use ${ - this.gqlNames.listQueryCountName - } instead.")`, - - ` - ${this.gqlNames.listQueryCountName}(${`where: ${this.gqlNames.whereInputName}! = {}`}): Int - ` - ); - } - - return queries; - } - - getGqlMutations({ schemaName }: { schemaName: string }) { - const schemaAccess = this.access[schemaName]; - const mutations = []; - - // NOTE: We only check for truthy as it could be `true`, or a function (the - // function is executed later in the resolver) - - const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); - if (schemaAccess.create && createFields.length) { - mutations.push(` - """ Create a single ${this.gqlNames.outputTypeName} item. """ - ${this.gqlNames.createMutationName}( - data: ${this.gqlNames.createInputName} - ): ${this.gqlNames.outputTypeName} - `); - - mutations.push(` - """ Create multiple ${this.gqlNames.outputTypeName} items. """ - ${this.gqlNames.createManyMutationName}( - data: [${this.gqlNames.createManyInputName}] - ): [${this.gqlNames.outputTypeName}] - `); - } - - const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); - if (schemaAccess.update && updateFields.length) { - mutations.push(` - """ Update a single ${this.gqlNames.outputTypeName} item by ID. """ - ${this.gqlNames.updateMutationName}( - id: ID! - data: ${this.gqlNames.updateInputName} - ): ${this.gqlNames.outputTypeName} - `); - - mutations.push(` - """ Update multiple ${this.gqlNames.outputTypeName} items by ID. """ - ${this.gqlNames.updateManyMutationName}( - data: [${this.gqlNames.updateManyInputName}] - ): [${this.gqlNames.outputTypeName}] - `); - } - - if (schemaAccess.delete) { - mutations.push(` - """ Delete a single ${this.gqlNames.outputTypeName} item by ID. """ - ${this.gqlNames.deleteMutationName}( - id: ID! - ): ${this.gqlNames.outputTypeName} - `); - - mutations.push(` - """ Delete multiple ${this.gqlNames.outputTypeName} items by ID. """ - ${this.gqlNames.deleteManyMutationName}( - ids: [ID!] - ): [${this.gqlNames.outputTypeName}] - `); - } - - return mutations; - } - - gqlAuxFieldResolvers({ schemaName }: { schemaName: string }): Record { - const schemaAccess = this.access[schemaName]; - if (schemaAccess.read || schemaAccess.create || schemaAccess.update || schemaAccess.delete) { - return objMerge(this.fields.map(field => field.gqlAuxFieldResolvers({ schemaName }))); - } - return {}; - } - - gqlFieldResolvers({ schemaName }: { schemaName: string }): Record { - const schemaAccess = this.access[schemaName]; - if (!schemaAccess.read) { - return {}; - } - - return { - [this.gqlNames.outputTypeName]: objMerge( - this.fields - .filter(field => field.access[schemaName].read) - .map(field => - // Get the resolvers for the (possibly multiple) output fields and wrap each with list-specific modifiers - mapKeys(field.gqlOutputFieldResolvers({ schemaName }), innerResolver => - this._wrapFieldResolver(field, innerResolver) - ) - ) - ), - }; - } - - gqlAuxQueryResolvers(): Record { - // TODO: Obey the same ACL rules based on parent type - return objMerge(this.fields.map(field => field.gqlAuxQueryResolvers())); - } - - gqlQueryResolvers({ schemaName }: { schemaName: string }) { - const schemaAccess = this.access[schemaName]; - let resolvers: Record = {}; - - // If set to false, we can confidently remove these resolvers entirely from - // the graphql schema - if (schemaAccess.read) { - resolvers = { - [this.gqlNames.listQueryName]: (_, args, context, info) => - this.listQuery(args, context, this.gqlNames.listQueryName, info), - - [this.gqlNames.listQueryMetaName]: (_, args, context, info) => - this.listQueryMeta(args, context, this.gqlNames.listQueryMetaName, info), - - [this.gqlNames.listQueryCountName]: async (_, args, context, info) => { - const { getCount } = await this.listQueryMeta( - args, - context, - this.gqlNames.listQueryCountName, - info - ); - return getCount(); - }, - - [this.gqlNames.itemQueryName]: (_, args, context, info) => - this.itemQuery(args, context, this.gqlNames.itemQueryName, info), - }; - } - - return resolvers; - } - - gqlMutationResolvers({ schemaName }: { schemaName: string }) { - const schemaAccess = this.access[schemaName]; - const mutationResolvers: Record = {}; - - const createFields = this.getFieldsWithAccess({ schemaName, access: 'create' }); - if (schemaAccess.create && createFields.length) { - mutationResolvers[this.gqlNames.createMutationName] = (_, { data }, context) => - this.createMutation(data, context); - - mutationResolvers[this.gqlNames.createManyMutationName] = (_, { data }, context) => - this.createManyMutation(data, context); - } - - const updateFields = this.getFieldsWithAccess({ schemaName, access: 'update' }); - if (schemaAccess.update && updateFields.length) { - mutationResolvers[this.gqlNames.updateMutationName] = (_, { id, data }, context) => - this.updateMutation(id, data, context); - - mutationResolvers[this.gqlNames.updateManyMutationName] = (_, { data }, context) => - this.updateManyMutation(data, context); - } - - if (schemaAccess.delete) { - mutationResolvers[this.gqlNames.deleteMutationName] = (_, { id }, context) => - this.deleteMutation(id, context); - - mutationResolvers[this.gqlNames.deleteManyMutationName] = (_, { ids }, context) => - this.deleteManyMutation(ids, context); - } - - return mutationResolvers; - } -} diff --git a/packages-next/keystone/src/lib/core/ListTypes/utils.ts b/packages-next/keystone/src/lib/core/ListTypes/utils.ts deleted file mode 100644 index a214e5675b4..00000000000 --- a/packages-next/keystone/src/lib/core/ListTypes/utils.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Implementation } from '@keystone-next/fields'; -import { humanize, resolveAllKeys, arrayToObject } from '@keystone-next/utils-legacy'; - -export const keyToLabel = (str: string) => { - let label = humanize(str); - - // Retain the leading underscore for auxiliary lists - if (str[0] === '_') { - label = `_${label}`; - } - return label; -}; - -export const labelToPath = (str: string) => str.split(' ').join('-').toLowerCase(); - -export const labelToClass = (str: string) => str.replace(/\s+/g, ''); - -export const opToType = { - read: 'query', - create: 'mutation', - update: 'mutation', - delete: 'mutation', -} as const; - -export const mapToFields = >( - fields: F[], - action: (f: F) => Promise -) => - resolveAllKeys(arrayToObject(fields, 'path', action)).catch(error => { - if (!error.errors) { - throw error; - } - const errorCopy = new Error(error.message || error.toString()); - // @ts-ignore - errorCopy.errors = Object.values(error.errors); - throw errorCopy; - }); diff --git a/packages-next/keystone/src/lib/core/access-control.ts b/packages-next/keystone/src/lib/core/access-control.ts new file mode 100644 index 00000000000..dc1ae1d99c1 --- /dev/null +++ b/packages-next/keystone/src/lib/core/access-control.ts @@ -0,0 +1,137 @@ +import { + BaseGeneratedListTypes, + CreateAccessControl, + DeleteListAccessControl, + FieldAccessControl, + FieldCreateAccessArgs, + FieldReadAccessArgs, + FieldUpdateAccessArgs, + IndividualFieldAccessControl, + KeystoneContext, + ListAccessControl, + ReadListAccessControl, + UpdateListAccessControl, +} from '@keystone-next/types'; +import { GraphQLInputObjectType } from 'graphql'; +import { coerceAndValidateForGraphQLInput } from '../coerceAndValidateForGraphQLInput'; +import { InputFilter } from './where-inputs'; + +export async function validateNonCreateListAccessControl< + Args extends { + listKey: string; + context: KeystoneContext; + operation: 'read' | 'update' | 'delete'; + } +>({ + access, + args, +}: { + access: ((args: Args) => boolean | Record) | boolean | Record; + args: Args; +}): Promise { + const result = typeof access === 'function' ? await access(args) : access; + + if (result === null || (typeof result !== 'object' && typeof result !== 'boolean')) { + throw new Error( + `Must return an object or boolean from Imperative or Declarative access control function. Got ${typeof result}` + ); + } + + if (typeof result === 'object') { + const internalSchema = args.context.sudo().graphql.schema; + const whereInputType = internalSchema.getType( + `${args.listKey}WhereInput` + ) as GraphQLInputObjectType; + const coercedResult = coerceAndValidateForGraphQLInput(internalSchema, whereInputType, result); + if (coercedResult.kind === 'error') { + throw new Error( + `An invalid filter was provided in ${args.listKey}.access.${args.operation}: ${coercedResult.error.message}` + ); + } + return coercedResult.value; + } + + return result; +} + +export async function validateCreateListAccessControl({ + access, + args, +}: { + access: ((args: Args) => Promise | boolean) | boolean; + args: Args; +}) { + const result = typeof access === 'function' ? await access(args) : access; + + if (typeof result !== 'boolean') { + throw new Error( + `${ + args.listKey + }.access.create() must return a boolean but it got a ${typeof result}. (NOTE: 'create' cannot have a Declarative access control config)` + ); + } + + return result; +} + +export async function validateFieldAccessControl< + Args extends { listKey: string; fieldKey: string; operation: 'read' | 'create' | 'update' } +>({ + access, + args, +}: { + access: ((args: Args) => boolean | Promise) | boolean; + args: Args; +}) { + let result = typeof access === 'function' ? await access(args) : access; + if (typeof result !== 'boolean') { + throw new Error( + `Must return a Boolean from ${args.listKey}.fields.${args.fieldKey}.access.${ + args.operation + }(). Got ${typeof result}` + ); + } + return result; +} + +export function parseFieldAccessControl( + access: FieldAccessControl | undefined +): ResolvedFieldAccessControl { + if (typeof access === 'boolean' || typeof access === 'function') { + return { create: access, read: access, update: access }; + } + // note i'm intentionally not using spread here because typescript can't express an optional property which cannot be undefined so spreading would mean there is a possibility that someone could pass {access: undefined} or {access:{read: undefined}} and bad things would happen + return { + create: access?.create ?? true, + read: access?.read ?? true, + update: access?.update ?? true, + }; +} + +export type ResolvedFieldAccessControl = { + read: IndividualFieldAccessControl>; + create: IndividualFieldAccessControl>; + update: IndividualFieldAccessControl>; +}; + +export function parseListAccessControl( + access: ListAccessControl | undefined +): ResolvedListAccessControl { + if (typeof access === 'boolean' || typeof access === 'function') { + return { create: access, read: access, update: access, delete: access }; + } + // note i'm intentionally not using spread here because typescript can't express an optional property which cannot be undefined so spreading would mean there is a possibility that someone could pass {access: undefined} or {access:{read: undefined}} and bad things would happen + return { + create: access?.create ?? true, + read: access?.read ?? true, + update: access?.update ?? true, + delete: access?.delete ?? true, + }; +} + +export type ResolvedListAccessControl = { + read: ReadListAccessControl; + create: CreateAccessControl; + update: UpdateListAccessControl; + delete: DeleteListAccessControl; +}; diff --git a/packages-next/keystone/src/lib/core/field-assertions.ts b/packages-next/keystone/src/lib/core/field-assertions.ts new file mode 100644 index 00000000000..db7d58e09b1 --- /dev/null +++ b/packages-next/keystone/src/lib/core/field-assertions.ts @@ -0,0 +1,102 @@ +import { schema } from '@keystone-next/types'; +import { InitialisedField } from './types-for-lists'; + +export type ListForValidation = { listKey: string; fields: Record }; + +export function assertFieldsValid(list: ListForValidation) { + assertNoConflictingExtraOutputFields(list); + assertIdFieldGraphQLTypesCorrect(list); + assertNoFieldKeysThatConflictWithFilterCombinators(list); + assertUniqueWhereInputsValid(list); +} + +function assertUniqueWhereInputsValid(list: ListForValidation) { + for (const [fieldKey, { dbField, input }] of Object.entries(list.fields)) { + if (input?.uniqueWhere) { + if (dbField.kind !== 'scalar' || (dbField.scalar !== 'String' && dbField.scalar !== 'Int')) { + throw new Error( + `Only String and Int scalar db fields can provide a uniqueWhere input currently but the field at ${list.listKey}.${fieldKey} specifies a uniqueWhere input` + ); + } + + if (dbField.index !== 'unique' && fieldKey !== 'id') { + throw new Error( + `Fields must have a unique index or be the idField to specify a uniqueWhere input but the field at ${list.listKey}.${fieldKey} specifies a uniqueWhere input without a unique index` + ); + } + } + } +} + +function assertNoFieldKeysThatConflictWithFilterCombinators(list: ListForValidation) { + for (const fieldKey of Object.keys(list.fields)) { + if (fieldKey === 'AND' || fieldKey === 'OR' || fieldKey === 'NOT') { + throw new Error( + `Fields cannot be named ${fieldKey} but there is a field named ${fieldKey} on ${list.listKey}` + ); + } + } +} + +function assertNoConflictingExtraOutputFields(list: ListForValidation) { + const fieldKeys = new Set(Object.keys(list.fields)); + const alreadyFoundFields: Record = {}; + for (const [fieldKey, field] of Object.entries(list.fields)) { + if (field.extraOutputFields) { + for (const outputTypeFieldName of Object.keys(field.extraOutputFields)) { + // note that this and the case handled below are fundamentally the same thing but i want different errors for each of them + if (fieldKeys.has(outputTypeFieldName)) { + throw new Error( + `The field ${fieldKey} on the ${list.listKey} list defines an extra GraphQL output field named ${outputTypeFieldName} which conflicts with the Keystone field type named ${outputTypeFieldName} on the same list` + ); + } + const alreadyFoundField = alreadyFoundFields[outputTypeFieldName]; + if (alreadyFoundField !== undefined) { + throw new Error( + `The field ${fieldKey} on the ${list.listKey} list defines an extra GraphQL output field named ${outputTypeFieldName} which conflicts with the Keystone field type named ${alreadyFoundField} which also defines an extra GraphQL output field named ${outputTypeFieldName}` + ); + } + alreadyFoundFields[outputTypeFieldName] = fieldKey; + } + } + } +} + +function assertIdFieldGraphQLTypesCorrect(list: ListForValidation) { + const idField = list.fields.id; + if (idField.input?.uniqueWhere === undefined) { + throw new Error( + `The idField on a list must define a uniqueWhere GraphQL input with the ID GraphQL scalar type but the idField for ${list.listKey} does not define one` + ); + } + if (idField.input.uniqueWhere.arg.type !== schema.ID) { + throw new Error( + `The idField on a list must define a uniqueWhere GraphQL input with the ID GraphQL scalar type but the idField for ${ + list.listKey + } defines the type ${idField.input.uniqueWhere.arg.type.graphQLType.toString()}` + ); + } + // we may want to loosen these constraints in the future + if (idField.input.create !== undefined) { + throw new Error( + `The idField on a list must not define a create GraphQL input but the idField for ${list.listKey} does define one` + ); + } + if (idField.input.update !== undefined) { + throw new Error( + `The idField on a list must not define an update GraphQL input but the idField for ${list.listKey} does define one` + ); + } + if (idField.access.read === false) { + throw new Error( + `The idField on a list must not have access.read be set to false but ${list.listKey} does` + ); + } + if (idField.output.type.kind !== 'non-null' || idField.output.type.of !== schema.ID) { + throw new Error( + `The idField on a list must define a GraphQL output field with a non-nullable ID GraphQL scalar type but the idField for ${ + list.listKey + } defines the type ${idField.output.type.graphQLType.toString()}` + ); + } +} diff --git a/packages-next/keystone/src/lib/core/ListTypes/graphqlErrors.ts b/packages-next/keystone/src/lib/core/graphql-errors.ts similarity index 72% rename from packages-next/keystone/src/lib/core/ListTypes/graphqlErrors.ts rename to packages-next/keystone/src/lib/core/graphql-errors.ts index d99c6bf762f..ce42b9e1ee0 100644 --- a/packages-next/keystone/src/lib/core/ListTypes/graphqlErrors.ts +++ b/packages-next/keystone/src/lib/core/graphql-errors.ts @@ -1,5 +1,4 @@ import { createError } from 'apollo-errors'; -import { opToType } from './utils'; export const AccessDeniedError = createError('AccessDeniedError', { message: 'You do not have access to this resource', @@ -14,12 +13,11 @@ export const LimitsExceededError = createError('LimitsExceededError', { options: { showPath: true }, }); -type ValueOf = T[keyof T]; -export const throwAccessDenied = ( - type: ValueOf, +export const accessDeniedError = ( + type: 'query' | 'mutation', target?: string, internalData = {}, extraData = {} ) => { - throw new AccessDeniedError({ data: { type, target, ...extraData }, internalData }); + return new AccessDeniedError({ data: { type, target, ...extraData }, internalData }); }; diff --git a/packages-next/keystone/src/lib/core/graphql-schema.ts b/packages-next/keystone/src/lib/core/graphql-schema.ts new file mode 100644 index 00000000000..ad902e69648 --- /dev/null +++ b/packages-next/keystone/src/lib/core/graphql-schema.ts @@ -0,0 +1,79 @@ +import { GraphQLNamedType, GraphQLSchema } from 'graphql'; +import { DatabaseProvider, schema } from '@keystone-next/types'; +import { InitialisedList } from './types-for-lists'; + +import { getMutationsForList } from './mutations'; +import { getQueriesForList } from './queries'; + +export function getGraphQLSchema( + lists: Record, + provider: DatabaseProvider +) { + const query = schema.object()({ + name: 'Query', + fields: Object.assign({}, ...Object.values(lists).map(list => getQueriesForList(list))), + }); + + const createManyByList: Record> = {}; + const updateManyByList: Record> = {}; + + const mutation = schema.object()({ + name: 'Mutation', + fields: Object.assign( + {}, + ...Object.values(lists).map(list => { + const { mutations, createManyInput, updateManyInput } = getMutationsForList(list, provider); + createManyByList[list.listKey] = createManyInput; + updateManyByList[list.listKey] = updateManyInput; + return mutations; + }) + ), + }); + const graphQLSchema = new GraphQLSchema({ + query: query.graphQLType, + mutation: mutation.graphQLType, + types: collectTypes(lists, createManyByList, updateManyByList), + }); + return graphQLSchema; +} + +function collectTypes( + lists: Record, + createManyByList: Record>, + updateManyByList: Record> +) { + const collectedTypes: GraphQLNamedType[] = []; + for (const list of Object.values(lists)) { + // adding all of these types explicitly isn't strictly necessary but we do it to create a certain order in the schema + if (list.access.read || list.access.create || list.access.update || list.access.delete) { + collectedTypes.push(list.types.output.graphQLType); + for (const field of Object.values(list.fields)) { + if ( + list.access.read !== false && + field.access.read !== false && + field.unreferencedConcreteInterfaceImplementations + ) { + // this _IS_ actually necessary since they aren't implicitly referenced by other types, unlike the types above + collectedTypes.push( + ...field.unreferencedConcreteInterfaceImplementations.map(x => x.graphQLType) + ); + } + } + collectedTypes.push(list.types.where.graphQLType); + collectedTypes.push(list.types.uniqueWhere.graphQLType); + collectedTypes.push(list.types.findManyArgs.sortBy.type.of.of.graphQLType); + collectedTypes.push(list.types.orderBy.graphQLType); + } + if (list.access.update) { + collectedTypes.push(list.types.update.graphQLType); + collectedTypes.push(updateManyByList[list.listKey].graphQLType); + } + if (list.access.create) { + collectedTypes.push(list.types.create.graphQLType); + collectedTypes.push(createManyByList[list.listKey].graphQLType); + } + } + // this is not necessary, just about ordering + collectedTypes.push(schema.JSON.graphQLType); + return collectedTypes; +} diff --git a/packages-next/keystone/src/lib/core/mutations/access-control.ts b/packages-next/keystone/src/lib/core/mutations/access-control.ts new file mode 100644 index 00000000000..4a819ca10f8 --- /dev/null +++ b/packages-next/keystone/src/lib/core/mutations/access-control.ts @@ -0,0 +1,178 @@ +import { ItemRootValue, KeystoneContext } from '@keystone-next/types'; +import { + validateCreateListAccessControl, + validateFieldAccessControl, + validateNonCreateListAccessControl, +} from '../access-control'; +import { accessDeniedError } from '../graphql-errors'; +import { mapUniqueWhereToWhere } from '../queries/resolvers'; +import { InitialisedList } from '../types-for-lists'; +import { getPrismaModelForList } from '../utils'; +import { + UniqueInputFilter, + PrismaFilter, + resolveUniqueWhereInput, + resolveWhereInput, +} from '../where-inputs'; + +export async function getAccessControlledItemForDelete( + list: InitialisedList, + context: KeystoneContext, + filter: UniqueInputFilter, + inputFilter: UniqueInputFilter +): Promise { + const itemId = await getStringifiedItemIdFromUniqueWhereInput(filter, list.listKey, context); + const access = await validateNonCreateListAccessControl({ + access: list.access.delete, + args: { context, listKey: list.listKey, operation: 'delete', session: context.session, itemId }, + }); + if (access === false) { + throw accessDeniedError('mutation'); + } + const prismaModel = getPrismaModelForList(context.prisma, list.listKey); + let where: PrismaFilter = mapUniqueWhereToWhere( + list, + await resolveUniqueWhereInput(inputFilter, list.fields, context) + ); + if (typeof access === 'object') { + where = { AND: [where, await resolveWhereInput(access, list)] }; + } + const item = await prismaModel.findFirst({ where }); + if (item === null) { + throw accessDeniedError('mutation'); + } + return item; +} + +export async function checkFieldAccessControlForUpdate( + list: InitialisedList, + context: KeystoneContext, + originalInput: Record, + item: Record +) { + const results = await Promise.all( + Object.keys(originalInput).map(fieldKey => { + const field = list.fields[fieldKey]; + return validateFieldAccessControl({ + access: field.access.update, + args: { + context, + fieldKey, + listKey: list.listKey, + operation: 'update', + originalInput, + session: context.session, + item, + itemId: item.id.toString(), + }, + }); + }) + ); + + if (results.some(canAccess => !canAccess)) { + throw accessDeniedError('mutation'); + } +} + +export async function getAccessControlledItemForUpdate( + list: InitialisedList, + context: KeystoneContext, + uniqueWhere: UniqueInputFilter, + update: Record +) { + const prismaModel = getPrismaModelForList(context.prisma, list.listKey); + const resolvedUniqueWhere = await resolveUniqueWhereInput(uniqueWhere, list.fields, context); + const itemId = await getStringifiedItemIdFromUniqueWhereInput(uniqueWhere, list.listKey, context); + const accessControl = await validateNonCreateListAccessControl({ + access: list.access.update, + args: { + context, + itemId, + listKey: list.listKey, + operation: 'update', + originalInput: update, + session: context.session, + }, + }); + if (accessControl === false) { + throw accessDeniedError('mutation'); + } + const uniqueWhereInWhereForm = mapUniqueWhereToWhere(list, resolvedUniqueWhere); + const item = await prismaModel.findFirst({ + where: + accessControl === true + ? uniqueWhereInWhereForm + : { + AND: [uniqueWhereInWhereForm, await resolveWhereInput(accessControl, list)], + }, + }); + if (!item) { + throw accessDeniedError('mutation'); + } + await checkFieldAccessControlForUpdate(list, context, update, item); + return item; +} + +export async function applyAccessControlForCreate( + list: InitialisedList, + context: KeystoneContext, + originalInput: Record +) { + const result = await validateCreateListAccessControl({ + access: list.access.create, + args: { + context, + listKey: list.listKey, + operation: 'create', + originalInput, + session: context.session, + }, + }); + if (!result) { + throw accessDeniedError('mutation'); + } + await checkFieldAccessControlForCreate(list, context, originalInput); +} + +async function checkFieldAccessControlForCreate( + list: InitialisedList, + context: KeystoneContext, + originalInput: Record +) { + const results = await Promise.all( + Object.keys(originalInput).map(fieldKey => { + const field = list.fields[fieldKey]; + return validateFieldAccessControl({ + access: field.access.create, + args: { + context, + fieldKey, + listKey: list.listKey, + operation: 'create', + originalInput, + session: context.session, + }, + }); + }) + ); + + if (results.some(canAccess => !canAccess)) { + throw accessDeniedError('mutation'); + } +} + +async function getStringifiedItemIdFromUniqueWhereInput( + uniqueWhere: UniqueInputFilter, + listKey: string, + context: KeystoneContext +): Promise { + if (uniqueWhere.id !== undefined) { + return uniqueWhere.id; + } + try { + const item = await context.sudo().lists[listKey].findOne({ where: uniqueWhere as any }); + return item.id; + } catch (err) { + throw accessDeniedError('mutation'); + } +} diff --git a/packages-next/keystone/src/lib/core/mutations/create-update.ts b/packages-next/keystone/src/lib/core/mutations/create-update.ts new file mode 100644 index 00000000000..542a3753157 --- /dev/null +++ b/packages-next/keystone/src/lib/core/mutations/create-update.ts @@ -0,0 +1,307 @@ +import { KeystoneContext, DatabaseProvider, ItemRootValue } from '@keystone-next/types'; +import pLimit from 'p-limit'; +import { ResolvedDBField } from '../resolve-relationships'; +import { InitialisedList } from '../types-for-lists'; +import { + getPrismaModelForList, + promiseAllRejectWithAllErrors, + getDBFieldKeyForFieldOnMultiField, +} from '../utils'; +import { + NestedMutationState, + resolveRelateToManyForCreateInput, + resolveRelateToManyForUpdateInput, + resolveRelateToOneForCreateInput, + resolveRelateToOneForUpdateInput, +} from './nested-mutation-input-resolvers'; +import { applyAccessControlForCreate, getAccessControlledItemForUpdate } from './access-control'; +import { runSideEffectOnlyHook, validationHook } from './hooks'; + +export function createMany( + { data }: { data: Record[] }, + list: InitialisedList, + context: KeystoneContext, + provider: DatabaseProvider +) { + const writeLimit = pLimit(provider === 'sqlite' ? 1 : Infinity); + return data.map(async rawData => { + const { afterChange, data } = await createOneState({ data: rawData }, list, context); + const item = await writeLimit(() => + getPrismaModelForList(context.prisma, list.listKey).create({ data }) + ); + await afterChange(item); + return item; + }); +} + +export async function createOneState( + { data: rawData }: { data: Record }, + list: InitialisedList, + context: KeystoneContext +) { + await applyAccessControlForCreate(list, context, rawData); + const { data, afterChange } = await resolveInputForCreateOrUpdate( + list, + context, + rawData, + undefined + ); + return { + data, + afterChange, + }; +} + +export async function createOne( + args: { data: Record }, + list: InitialisedList, + context: KeystoneContext +) { + const { afterChange, data } = await createOneState(args, list, context); + const item = await getPrismaModelForList(context.prisma, list.listKey).create({ data }); + await afterChange(item); + return item; +} + +export function updateMany( + { data }: { data: { where: Record; data: Record }[] }, + list: InitialisedList, + context: KeystoneContext, + provider: DatabaseProvider +) { + const writeLimit = pLimit(provider === 'sqlite' ? 1 : Infinity); + return data.map(async ({ data: rawData, where: rawUniqueWhere }) => { + const item = await getAccessControlledItemForUpdate(list, context, rawUniqueWhere, rawData); + const { afterChange, data } = await resolveInputForCreateOrUpdate(list, context, rawData, item); + const updatedItem = await writeLimit(() => + getPrismaModelForList(context.prisma, list.listKey).update({ + where: { id: item.id }, + data, + }) + ); + afterChange(updatedItem); + return updatedItem; + }); +} + +export async function updateOne( + { + where: rawUniqueWhere, + data: rawData, + }: { where: Record; data: Record }, + list: InitialisedList, + context: KeystoneContext +) { + const item = await getAccessControlledItemForUpdate(list, context, rawUniqueWhere, rawData); + const { afterChange, data } = await resolveInputForCreateOrUpdate(list, context, rawData, item); + + const updatedItem = await getPrismaModelForList(context.prisma, list.listKey).update({ + where: { id: item.id }, + data, + }); + + await afterChange(updatedItem); + + return updatedItem; +} + +async function resolveInputForCreateOrUpdate( + list: InitialisedList, + context: KeystoneContext, + originalInput: Record, + existingItem: Record | undefined +) { + const operation: 'create' | 'update' = existingItem === undefined ? 'create' : 'update'; + const nestedMutationState = new NestedMutationState(context); + let resolvedData = Object.fromEntries( + await promiseAllRejectWithAllErrors( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + const inputConfig = field.input?.[operation]; + let input = originalInput[fieldKey]; + if ( + operation === 'create' && + input === undefined && + field.__legacy?.defaultValue !== undefined + ) { + input = + typeof field.__legacy.defaultValue === 'function' + ? await field.__legacy.defaultValue({ originalInput, context }) + : field.__legacy.defaultValue; + } + const resolved = inputConfig?.resolve + ? await inputConfig.resolve( + input, + context, + (() => { + if (field.dbField.kind !== 'relation') { + return undefined as any; + } + const target = `${list.listKey}.${fieldKey}<${field.dbField.list}>`; + const foreignList = list.lists[field.dbField.list]; + if (field.dbField.mode === 'many') { + if (operation === 'create') { + return resolveRelateToManyForCreateInput( + nestedMutationState, + context, + foreignList, + target + ); + } + return resolveRelateToManyForUpdateInput( + nestedMutationState, + context, + foreignList, + target + ); + } + if (operation === 'create') { + return resolveRelateToOneForCreateInput( + nestedMutationState, + context, + foreignList, + target + ); + } + return resolveRelateToOneForUpdateInput( + nestedMutationState, + context, + foreignList, + target + ); + })() + ) + : input; + return [fieldKey, resolved] as const; + }) + ) + ); + + resolvedData = await resolveInputHook( + list, + context, + operation, + resolvedData, + originalInput, + existingItem + ); + + await validationHook(list.listKey, operation, originalInput, addValidationError => { + for (const [fieldKey, field] of Object.entries(list.fields)) { + // yes, this is a massive hack, it's just to make image and file fields work well enough + let val = resolvedData[fieldKey]; + if (field.dbField.kind === 'multi') { + if (Object.values(resolvedData[fieldKey]).every(x => x === null)) { + val = null; + } + if (Object.values(resolvedData[fieldKey]).every(x => x === undefined)) { + val = undefined; + } + } + if ( + field.__legacy?.isRequired && + ((operation === 'create' && val == null) || (operation === 'update' && val === null)) + ) { + addValidationError( + `Required field "${fieldKey}" is null or undefined.`, + { resolvedData, operation, originalInput }, + {} + ); + } + } + }); + + const args = { + context, + listKey: list.listKey, + operation, + originalInput, + resolvedData, + existingItem, + }; + await validationHook(list.listKey, operation, originalInput, async addValidationError => { + await promiseAllRejectWithAllErrors( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + await field.hooks.validateInput?.({ + ...args, + addValidationError, + fieldPath: fieldKey, + }); + }) + ); + }); + + await validationHook(list.listKey, operation, originalInput, async addValidationError => { + await list.hooks.validateInput?.({ ...args, addValidationError }); + }); + const originalInputKeys = new Set(Object.keys(originalInput)); + const shouldCallFieldLevelSideEffectHook = (fieldKey: string) => originalInputKeys.has(fieldKey); + await runSideEffectOnlyHook(list, 'beforeChange', args, shouldCallFieldLevelSideEffectHook); + return { + data: flattenMultiDbFields(list.fields, resolvedData), + afterChange: async (updatedItem: ItemRootValue) => { + await nestedMutationState.afterChange(); + await runSideEffectOnlyHook( + list, + 'afterChange', + { ...args, updatedItem, existingItem }, + shouldCallFieldLevelSideEffectHook + ); + }, + }; +} + +function flattenMultiDbFields( + fields: Record, + data: Record +) { + return Object.fromEntries( + Object.entries(data).flatMap(([fieldKey, value]) => { + const { dbField } = fields[fieldKey]; + if (dbField.kind === 'multi') { + return Object.entries(value).map(([innerFieldKey, fieldValue]) => { + return [getDBFieldKeyForFieldOnMultiField(fieldKey, innerFieldKey), fieldValue]; + }); + } + return [[fieldKey, value]]; + }) + ); +} + +async function resolveInputHook( + list: InitialisedList, + context: KeystoneContext, + operation: 'create' | 'update', + resolvedData: Record, + originalInput: Record, + existingItem: Record | undefined +) { + const args = { + context, + listKey: list.listKey, + operation, + originalInput, + resolvedData, + existingItem, + }; + resolvedData = Object.fromEntries( + await promiseAllRejectWithAllErrors( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + if (field.hooks.resolveInput === undefined) { + return [fieldKey, resolvedData[fieldKey]]; + } + const value = await field.hooks.resolveInput({ + ...args, + fieldPath: fieldKey, + }); + return [fieldKey, value]; + }) + ) + ); + if (list.hooks.resolveInput) { + resolvedData = (await list.hooks.resolveInput({ + ...args, + resolvedData, + })) as any; + } + return resolvedData; +} diff --git a/packages-next/keystone/src/lib/core/mutations/delete.ts b/packages-next/keystone/src/lib/core/mutations/delete.ts new file mode 100644 index 00000000000..3791a30bd0f --- /dev/null +++ b/packages-next/keystone/src/lib/core/mutations/delete.ts @@ -0,0 +1,72 @@ +import { KeystoneContext, DatabaseProvider } from '@keystone-next/types'; +import pLimit from 'p-limit'; +import { InitialisedList } from '../types-for-lists'; +import { getPrismaModelForList, promiseAllRejectWithAllErrors } from '../utils'; +import { UniqueInputFilter } from '../where-inputs'; +import { getAccessControlledItemForDelete } from './access-control'; +import { runSideEffectOnlyHook, validationHook } from './hooks'; + +export function deleteMany( + { where }: { where: UniqueInputFilter[] }, + list: InitialisedList, + context: KeystoneContext, + provider: DatabaseProvider +) { + const writeLimit = pLimit(provider === 'sqlite' ? 1 : Infinity); + return where.map(async where => { + const { afterDelete, existingItem } = await processDelete(list, context, where); + await writeLimit(() => + getPrismaModelForList(context.prisma, list.listKey).delete({ + where: { id: existingItem.id }, + }) + ); + afterDelete(); + return existingItem; + }); +} + +export async function deleteOne( + { where }: { where: UniqueInputFilter }, + list: InitialisedList, + context: KeystoneContext +) { + const { afterDelete, existingItem } = await processDelete(list, context, where); + const item = await getPrismaModelForList(context.prisma, list.listKey).delete({ + where: { id: existingItem.id }, + }); + await afterDelete(); + return item; +} + +export async function processDelete( + list: InitialisedList, + context: KeystoneContext, + filter: UniqueInputFilter +) { + const existingItem = await getAccessControlledItemForDelete(list, context, filter, filter); + + const hookArgs = { operation: 'delete' as const, listKey: list.listKey, context, existingItem }; + await validationHook(list.listKey, 'delete', undefined, async addValidationError => { + await promiseAllRejectWithAllErrors( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + await field.hooks.validateDelete?.({ + ...hookArgs, + addValidationError, + fieldPath: fieldKey, + }); + }) + ); + }); + + await validationHook(list.listKey, 'delete', undefined, async addValidationError => { + await list.hooks.validateDelete?.({ ...hookArgs, addValidationError }); + }); + + await runSideEffectOnlyHook(list, 'beforeDelete', hookArgs, () => true); + return { + existingItem, + afterDelete: async () => { + await runSideEffectOnlyHook(list, 'afterDelete', hookArgs, () => true); + }, + }; +} diff --git a/packages-next/keystone/src/lib/core/mutations/hooks.ts b/packages-next/keystone/src/lib/core/mutations/hooks.ts new file mode 100644 index 00000000000..04edbb16272 --- /dev/null +++ b/packages-next/keystone/src/lib/core/mutations/hooks.ts @@ -0,0 +1,63 @@ +import { ValidationFailureError } from '../graphql-errors'; +import { promiseAllRejectWithAllErrors } from '../utils'; + +type ValidationError = { msg: string; data: {}; internalData: {} }; + +type AddValidationError = (msg: string, data?: {}, internalData?: {}) => void; + +export async function validationHook( + listKey: string, + operation: 'create' | 'update' | 'delete', + originalInput: Record | undefined, + validationHook: (addValidationError: AddValidationError) => void | Promise +) { + const errors: ValidationError[] = []; + + await validationHook((msg, data = {}, internalData = {}) => { + errors.push({ msg, data, internalData }); + }); + + if (errors.length) { + throw new ValidationFailureError({ + data: { + messages: errors.map(e => e.msg), + errors: errors.map(e => e.data), + listKey, + operation, + }, + internalData: { errors: errors.map(e => e.internalData), data: originalInput }, + }); + } +} + +export async function runSideEffectOnlyHook< + HookName extends string, + List extends { + fields: Record< + string, + { + hooks: { + [Key in HookName]?: (args: { fieldPath: string } & Args) => Promise | void; + }; + } + >; + hooks: { + [Key in HookName]?: (args: any) => Promise | void; + }; + }, + Args extends Parameters>[0] +>( + list: List, + hookName: HookName, + args: Args, + shouldRunFieldLevelHook: (fieldKey: string) => boolean +) { + await promiseAllRejectWithAllErrors( + Object.entries(list.fields).map(async ([fieldKey, field]) => { + if (shouldRunFieldLevelHook(fieldKey)) { + await field.hooks[hookName]?.({ fieldPath: fieldKey, ...args }); + } + }) + ); + await list.hooks[hookName]?.(args); +} diff --git a/packages-next/keystone/src/lib/core/mutations/index.ts b/packages-next/keystone/src/lib/core/mutations/index.ts new file mode 100644 index 00000000000..39d5f22e3aa --- /dev/null +++ b/packages-next/keystone/src/lib/core/mutations/index.ts @@ -0,0 +1,158 @@ +import { DatabaseProvider, getGqlNames, schema } from '@keystone-next/types'; +import { InitialisedList } from '../types-for-lists'; +import * as createAndUpdate from './create-update'; +import * as deletes from './delete'; + +// this is not a thing that i really agree with but it's to make the behaviour consistent with old keystone +// basically, old keystone uses Promise.allSettled and then after that maps that into promises that resolve and reject, +// whereas the new stuff is just like "here are some promises" with no guarantees about the order they will be settled in. +// that doesn't matter when they all resolve successfully because the order they resolve successfully in +// doesn't affect anything, if some reject though, the order that they reject in will be the order in the errors array +// and some of our tests rely on the order of the graphql errors array. they shouldn't, but they do. +function promisesButSettledWhenAllSettledAndInOrder[]>(promises: T): T { + const resultsPromise = Promise.allSettled(promises); + return promises.map(async (_, i) => { + const result = (await resultsPromise)[i]; + return result.status === 'fulfilled' + ? Promise.resolve(result.value) + : Promise.reject(result.reason); + }) as T; +} + +export function getMutationsForList(list: InitialisedList, provider: DatabaseProvider) { + const names = getGqlNames(list); + + const createOneArgs = { + data: schema.arg({ + type: list.types.create, + }), + }; + const createOne = schema.field({ + type: list.types.output, + args: createOneArgs, + description: ` Create a single ${list.listKey} item.`, + resolve(_rootVal, { data }, context) { + return createAndUpdate.createOne({ data: data ?? {} }, list, context); + }, + }); + + const createManyInput = schema.inputObject({ + name: names.createManyInputName, + fields: { + data: schema.arg({ type: list.types.create }), + }, + }); + + const createMany = schema.field({ + type: schema.list(list.types.output), + args: { + data: schema.arg({ + type: schema.list(createManyInput), + }), + }, + description: ` Create multiple ${list.listKey} items.`, + resolve(_rootVal, args, context) { + return promisesButSettledWhenAllSettledAndInOrder( + createAndUpdate.createMany( + { data: (args.data || []).map(input => input?.data ?? {}) }, + list, + context, + provider + ) + ); + }, + }); + + const updateOneArgs = { + id: schema.arg({ + type: schema.nonNull(schema.ID), + }), + data: schema.arg({ + type: list.types.update, + }), + }; + const updateOne = schema.field({ + type: list.types.output, + args: updateOneArgs, + description: ` Update a single ${list.listKey} item by ID.`, + resolve(_rootVal, { data, id }, context) { + return createAndUpdate.updateOne({ data: data ?? {}, where: { id } }, list, context); + }, + }); + + const updateManyInput = schema.inputObject({ + name: names.updateManyInputName, + fields: updateOneArgs, + }); + + const updateMany = schema.field({ + type: schema.list(list.types.output), + args: { + data: schema.arg({ + type: schema.list(updateManyInput), + }), + }, + description: ` Update multiple ${list.listKey} items by ID.`, + resolve(_rootVal, { data }, context) { + return promisesButSettledWhenAllSettledAndInOrder( + createAndUpdate.updateMany( + { + data: (data || []) + .filter((x): x is NonNullable => x !== null) + .map(({ id, data }) => ({ where: { id: id }, data: data ?? {} })), + }, + list, + context, + provider + ) + ); + }, + }); + + const deleteOne = schema.field({ + type: list.types.output, + args: { + id: schema.arg({ + type: schema.nonNull(schema.ID), + }), + }, + description: ` Delete a single ${list.listKey} item by ID.`, + resolve(rootVal, { id }, context) { + return deletes.deleteOne({ where: { id } }, list, context); + }, + }); + + const deleteMany = schema.field({ + type: schema.list(list.types.output), + args: { + ids: schema.arg({ + type: schema.list(schema.nonNull(schema.ID)), + }), + }, + description: ` Delete multiple ${list.listKey} items by ID.`, + resolve(rootVal, { ids }, context) { + return promisesButSettledWhenAllSettledAndInOrder( + deletes.deleteMany({ where: (ids || []).map(id => ({ id })) }, list, context, provider) + ); + }, + }); + + return { + mutations: { + ...(list.access.create !== false && { + [names.createMutationName]: createOne, + [names.createManyMutationName]: createMany, + }), + ...(list.access.update !== false && { + [names.updateMutationName]: updateOne, + [names.updateManyMutationName]: updateMany, + }), + ...(list.access.delete !== false && { + [names.deleteMutationName]: deleteOne, + [names.deleteManyMutationName]: deleteMany, + }), + }, + updateManyInput, + createManyInput, + }; +} diff --git a/packages-next/keystone/src/lib/core/mutations/nested-mutation-input-resolvers.ts b/packages-next/keystone/src/lib/core/mutations/nested-mutation-input-resolvers.ts new file mode 100644 index 00000000000..162cef9f77d --- /dev/null +++ b/packages-next/keystone/src/lib/core/mutations/nested-mutation-input-resolvers.ts @@ -0,0 +1,266 @@ +import { KeystoneContext, TypesForList, schema } from '@keystone-next/types'; +import { resolveUniqueWhereInput, UniqueInputFilter, UniquePrismaFilter } from '../where-inputs'; +import { InitialisedList } from '../types-for-lists'; +import { + isRejected, + isFulfilled, + getPrismaModelForList, + promiseAllRejectWithAllErrors, + IdType, +} from '../utils'; +import { createOneState } from './create-update'; + +const isNotNull = (arg: T): arg is Exclude => arg !== null; + +export class NestedMutationState { + #afterChanges: (() => void | Promise)[] = []; + #context: KeystoneContext; + constructor(context: KeystoneContext) { + this.#context = context; + } + async create( + input: Record, + list: InitialisedList + ): Promise<{ kind: 'connect'; id: IdType } | { kind: 'create'; data: Record }> { + const { afterChange, data } = await createOneState({ data: input }, list, this.#context); + const item = await getPrismaModelForList(this.#context.prisma, list.listKey).create({ data }); + this.#afterChanges.push(() => afterChange(item)); + return { kind: 'connect' as const, id: item.id as any }; + } + async afterChange() { + await promiseAllRejectWithAllErrors(this.#afterChanges.map(async x => x())); + } +} + +export function resolveRelateToManyForCreateInput( + nestedMutationState: NestedMutationState, + context: KeystoneContext, + foreignList: InitialisedList, + target: string +) { + return async ( + value: schema.InferValueFromArg> + ) => { + if (value == null) { + return undefined; + } + assertValidManyOperation(value, target); + return resolveCreateAndConnect(value, nestedMutationState, context, foreignList, target); + }; +} + +async function getDisconnects( + uniqueWheres: (UniqueInputFilter | null)[], + context: KeystoneContext, + foreignList: InitialisedList +): Promise { + return ( + await Promise.all( + uniqueWheres.map(async filter => { + if (filter === null) return []; + try { + await context.sudo().db.lists[foreignList.listKey].findOne({ where: filter as any }); + } catch (err) { + return []; + } + return [await resolveUniqueWhereInput(filter, foreignList.fields, context)]; + }) + ) + ).flat(); +} + +function getConnects( + uniqueWhere: UniqueInputFilter[], + context: KeystoneContext, + foreignList: InitialisedList +): Promise[] { + return uniqueWhere.map(async filter => { + await context.db.lists[foreignList.listKey].findOne({ where: filter as any }); + return resolveUniqueWhereInput(filter, foreignList.fields, context); + }); +} + +async function resolveCreateAndConnect( + value: Exclude< + schema.InferValueFromArg>, + null | undefined + >, + nestedMutationState: NestedMutationState, + context: KeystoneContext, + foreignList: InitialisedList, + target: string +) { + const connects = Promise.allSettled( + getConnects((value.connect || []).filter(isNotNull), context, foreignList) + ); + const creates = Promise.allSettled( + (value.create || []).filter(isNotNull).map(x => nestedMutationState.create(x, foreignList)) + ); + + const [connectResult, createResult] = await Promise.all([connects, creates]); + + const errors = [...connectResult.filter(isRejected), ...createResult.filter(isRejected)].map( + x => x.reason + ); + + if (errors.length) { + throw new Error(`Unable to create and/or connect ${errors.length} ${target}`); + } + const result = { + connect: connectResult.filter(isFulfilled).map(x => x.value), + create: [] as Record[], + }; + + for (const createData of createResult.filter(isFulfilled).map(x => x.value)) { + if (createData.kind === 'create') { + result.create.push(createData.data); + } + if (createData.kind === 'connect') { + result.connect.push({ id: createData.id }); + } + } + + return result; +} + +function assertValidManyOperation( + val: Exclude< + schema.InferValueFromArg>, + undefined | null + >, + target: string +) { + if ( + !Array.isArray(val.connect) && + !Array.isArray(val.create) && + !Array.isArray(val.disconnect) && + !val.disconnectAll + ) { + throw new Error(`Nested mutation operation invalid for ${target}`); + } +} + +export function resolveRelateToManyForUpdateInput( + nestedMutationState: NestedMutationState, + context: KeystoneContext, + foreignList: InitialisedList, + target: string +) { + return async ( + value: schema.InferValueFromArg> + ) => { + if (value == null) { + return undefined; + } + assertValidManyOperation(value, target); + const disconnects = getDisconnects( + value.disconnectAll ? [] : value.disconnect || [], + context, + foreignList + ); + + const [disconnect, connectAndCreates] = await Promise.all([ + disconnects, + resolveCreateAndConnect(value, nestedMutationState, context, foreignList, target), + ]); + + return { + set: value.disconnectAll ? [] : undefined, + disconnect, + ...connectAndCreates, + }; + }; +} + +async function handleCreateAndUpdate( + value: Exclude< + schema.InferValueFromArg>, + null | undefined + >, + nestedMutationState: NestedMutationState, + context: KeystoneContext, + foreignList: InitialisedList, + target: string +) { + if (value.connect) { + try { + await context.db.lists[foreignList.listKey].findOne({ where: value.connect as any }); + } catch (err) { + throw new Error(`Unable to connect a ${target}`); + } + return { + connect: await resolveUniqueWhereInput(value.connect, foreignList.fields, context), + }; + } + if (value.create) { + const createInput = value.create; + let create = await (async () => { + try { + return await nestedMutationState.create(createInput, foreignList); + } catch (err) { + throw new Error(`Unable to create a ${target}`); + } + })(); + + if (create.kind === 'connect') { + return { connect: { id: create.id } }; + } + return { create: create.data }; + } +} + +export function resolveRelateToOneForCreateInput( + nestedMutationState: NestedMutationState, + context: KeystoneContext, + foreignList: InitialisedList, + target: string +) { + return async ( + value: schema.InferValueFromArg> + ) => { + if (value == null) { + return undefined; + } + const numOfKeys = Object.keys(value).length; + if (numOfKeys !== 1) { + throw new Error(`Nested mutation operation invalid for ${target}`); + } + return handleCreateAndUpdate(value, nestedMutationState, context, foreignList, target); + }; +} + +export function resolveRelateToOneForUpdateInput( + nestedMutationState: NestedMutationState, + context: KeystoneContext, + foreignList: InitialisedList, + target: string +) { + return async ( + value: schema.InferValueFromArg< + schema.Arg> + > + ) => { + if (value == null) { + return undefined; + } + if (value.connect && value.create) { + throw new Error(`Nested mutation operation invalid for ${target}`); + } + if (value.connect || value.create) { + return handleCreateAndUpdate(value, nestedMutationState, context, foreignList, target); + } + if (value.disconnect) { + try { + await context + .sudo() + .db.lists[foreignList.listKey].findOne({ where: value.disconnect as any }); + } catch (err) { + return; + } + return { disconnect: true }; + } + if (value.disconnectAll) { + return { disconnect: true }; + } + }; +} diff --git a/packages-next/keystone/src/lib/core/prisma-schema.ts b/packages-next/keystone/src/lib/core/prisma-schema.ts new file mode 100644 index 00000000000..21dd43c7810 --- /dev/null +++ b/packages-next/keystone/src/lib/core/prisma-schema.ts @@ -0,0 +1,224 @@ +import { ScalarDBField, ScalarDBFieldDefault, DatabaseProvider } from '@keystone-next/types'; +import { ResolvedDBField, ListsWithResolvedRelations } from './resolve-relationships'; +import { getDBFieldKeyForFieldOnMultiField } from './utils'; + +function areArraysEqual(a: unknown[], b: unknown[]) { + if (a.length !== b.length) { + return false; + } + for (var i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +const modifiers = { + required: '', + optional: '?', + many: '[]', +}; + +function printIndex(fieldPath: string, index: undefined | 'index' | 'unique') { + return { + none: '', + unique: '@unique', + index: `\n@@index([${fieldPath}])`, + }[index || ('none' as const)]; +} + +function printNativeType(nativeType: string | undefined, datasourceName: string) { + return nativeType === undefined ? '' : ` @${datasourceName}.${nativeType}`; +} + +function printScalarDefaultValue(defaultValue: ScalarDBFieldDefault): string { + if (defaultValue.kind === 'literal') { + if (typeof defaultValue.value === 'string') { + return JSON.stringify(defaultValue.value); + } + return defaultValue.value.toString(); + } + if ( + defaultValue.kind === 'now' || + defaultValue.kind === 'autoincrement' || + defaultValue.kind === 'cuid' || + defaultValue.kind === 'uuid' + ) { + return `${defaultValue.kind}()`; + } + if (defaultValue.kind === 'dbgenerated') { + return `dbgenerated(${JSON.stringify(defaultValue.value)})`; + } + assertNever(defaultValue); +} + +function assertNever(arg: never): never { + throw new Error(`expected to never be called but was called with ${arg}`); +} + +function printField( + fieldPath: string, + field: Exclude, + datasourceName: string, + lists: ListsWithResolvedRelations +): string { + if (field.kind === 'scalar') { + const nativeType = printNativeType(field.nativeType, datasourceName); + const index = printIndex(fieldPath, field.index); + const defaultValue = field.default + ? ` @default(${printScalarDefaultValue(field.default)})` + : ''; + return `${fieldPath} ${field.scalar}${ + modifiers[field.mode] + }${nativeType}${defaultValue}${index}`; + } + if (field.kind === 'enum') { + const index = printIndex(fieldPath, field.index); + const defaultValue = field.default ? ` @default(${field.default})` : ''; + return `${fieldPath} ${field.name}${modifiers[field.mode]}${defaultValue}${index}`; + } + if (field.kind === 'multi') { + return Object.entries(field.fields) + .map(([subField, field]) => + printField( + getDBFieldKeyForFieldOnMultiField(fieldPath, subField), + field, + datasourceName, + lists + ) + ) + .join('\n'); + } + if (field.kind === 'relation') { + if (field.mode === 'many') { + return `${fieldPath} ${field.list}[] @relation("${field.relationName}")`; + } + if (field.foreignIdField === 'none') { + return `${fieldPath} ${field.list}? @relation("${field.relationName}")`; + } + const relationIdFieldPath = `${fieldPath}Id`; + const relationField = `${fieldPath} ${field.list}? @relation("${field.relationName}", fields: [${relationIdFieldPath}], references: [id])`; + const foreignIdField = lists[field.list].resolvedDbFields.id; + assertDbFieldIsValidForIdField(field.list, foreignIdField); + const nativeType = printNativeType(foreignIdField.nativeType, datasourceName); + const index = printIndex( + relationIdFieldPath, + field.foreignIdField === 'owned' ? 'index' : 'unique' + ); + const relationIdField = `${relationIdFieldPath} ${foreignIdField.scalar}? @map("${fieldPath}") ${nativeType}${index}`; + return `${relationField}\n${relationIdField}`; + } + // TypeScript's control flow analysis doesn't understand that this will never happen without the assertNever + // (this will still correctly validate if any case is unhandled though) + return assertNever(field); +} + +function collectEnums(lists: ListsWithResolvedRelations) { + const enums: Record = {}; + for (const [listKey, { resolvedDbFields }] of Object.entries(lists)) { + for (const [fieldPath, field] of Object.entries(resolvedDbFields)) { + const fields = + field.kind === 'multi' + ? Object.entries(field.fields).map( + ([key, field]) => [field, `${listKey}.${fieldPath} (sub field ${key})`] as const + ) + : [[field, `${listKey}.${fieldPath}`] as const]; + + for (const [field, ref] of fields) { + if (field.kind !== 'enum') continue; + const alreadyExistingEnum = enums[field.name]; + if (alreadyExistingEnum === undefined) { + enums[field.name] = { + values: field.values, + firstDefinedByRef: ref, + }; + continue; + } + if (!areArraysEqual(alreadyExistingEnum.values, field.values)) { + throw new Error( + `The fields ${alreadyExistingEnum.firstDefinedByRef} and ${ref} both specify Prisma schema enums` + + `with the name ${field.name} but they have different values:\n` + + `enum from ${alreadyExistingEnum.firstDefinedByRef}:\n${JSON.stringify( + alreadyExistingEnum.values, + null, + 2 + )}\n` + + `enum from ${ref}:\n${JSON.stringify(field.values, null, 2)}` + ); + } + } + } + } + return Object.entries(enums) + .map(([enumName, { values }]) => `enum ${enumName} {\n${values.join('\n')}\n}`) + .join('\n'); +} + +function assertDbFieldIsValidForIdField( + listKey: string, + field: ResolvedDBField +): asserts field is ScalarDBField<'Int' | 'String', 'required'> { + if (field.kind !== 'scalar') { + throw new Error( + `id fields must be either a String or Int Prisma scalar but the id field for the ${listKey} list is not a scalar` + ); + } + // this may be loosened in the future + if (field.scalar !== 'String' && field.scalar !== 'Int') { + throw new Error( + `id fields must be either String or Int Prisma scalars but the id field for the ${listKey} list is a ${field.scalar} scalar` + ); + } + if (field.mode !== 'required') { + throw new Error( + `id fields must be a singular required field but the id field for the ${listKey} list is ${ + field.mode === 'many' ? 'a many' : 'an optional' + } field` + ); + } + if (field.index !== undefined) { + throw new Error( + `id fields must not specify indexes themselves but the id field for the ${listKey} list specifies an index` + ); + } + // this will likely be loosened in the future + if (field.default === undefined) { + throw new Error( + `id fields must specify a Prisma/database level default value but the id field for the ${listKey} list does not` + ); + } +} + +export function printPrismaSchema( + lists: ListsWithResolvedRelations, + provider: DatabaseProvider, + clientDir: string +) { + let prismaSchema = `datasource ${provider} { + url = env("DATABASE_URL") + provider = "${provider}" +} + +generator client { + provider = "prisma-client-js" + output = "${clientDir}" +} +\n`; + for (const [listKey, { resolvedDbFields }] of Object.entries(lists)) { + prismaSchema += `model ${listKey} {`; + for (const [fieldPath, field] of Object.entries(resolvedDbFields)) { + if (field.kind !== 'none') { + prismaSchema += '\n' + printField(fieldPath, field, provider, lists); + } + if (fieldPath === 'id') { + assertDbFieldIsValidForIdField(listKey, field); + prismaSchema += ' @id'; + } + } + prismaSchema += `\n}\n`; + } + prismaSchema += `\n${collectEnums(lists)}\n`; + + return prismaSchema; +} diff --git a/packages-next/keystone/src/lib/core/providers/index.ts b/packages-next/keystone/src/lib/core/providers/index.ts deleted file mode 100644 index e1dfb30e5a0..00000000000 --- a/packages-next/keystone/src/lib/core/providers/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -export { ListCRUDProvider } from './listCRUD'; - -// The GraphQL Provider Framework expects to see classes with the following API: -// -// class Provider { -// constructor() {} -// -// getTypes({ schemaName }) { -// return []; -// } -// getQueries({ schemaName }) { -// return []; -// } -// getMutations({ schemaName }) { -// return []; -// } -// getSubscriptions({ schemaName }) { -// return []; -// } -// -// getTypeResolvers({ schemaName }) { -// return {}; -// } -// getQueryResolvers({ schemaName }) { -// return {}; -// } -// getMutationResolvers({ schemaName }) { -// return {}; -// } -// getSubscriptionResolvers({ schemaName }) { -// return {}; -// } -// } diff --git a/packages-next/keystone/src/lib/core/providers/listCRUD.ts b/packages-next/keystone/src/lib/core/providers/listCRUD.ts deleted file mode 100644 index af01549005f..00000000000 --- a/packages-next/keystone/src/lib/core/providers/listCRUD.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { GraphQLJSON } from 'graphql-type-json'; -import { flatten, objMerge, unique } from '@keystone-next/utils-legacy'; -import { BaseKeystoneList } from '@keystone-next/types'; - -export class ListCRUDProvider { - lists: BaseKeystoneList[]; - constructor() { - this.lists = []; - } - - getTypes({ schemaName }: { schemaName: string }): string[] { - return unique([ - ...flatten(this.lists.map(list => list.getGqlTypes({ schemaName }))), - ...[ - ` - """ - NOTE: Can be JSON, or a Boolean/Int/String - Why not a union? GraphQL doesn't support a union including a scalar - (https://github.com/facebook/graphql/issues/215) - """ - scalar JSON - `, - ` - type _QueryMeta { - count: Int - } - `, - ], - ]); - } - - getQueries({ schemaName }: { schemaName: string }): string[] { - return flatten(this.lists.map(list => list.getGqlQueries({ schemaName }))); - } - - getMutations({ schemaName }: { schemaName: string }): string[] { - return flatten(this.lists.map(list => list.getGqlMutations({ schemaName }))); - } - - getSubscriptions({}) { - return []; - } - - getTypeResolvers({ schemaName }: { schemaName: string }) { - const queryMetaResolver = { - // meta is passed in from the list's resolver (eg; '_allUsersMeta') - count: (meta: { getCount: () => number }) => meta.getCount(), - }; - - return { - ...objMerge(this.lists.map(list => list.gqlAuxFieldResolvers({ schemaName }))), - ...objMerge(this.lists.map(list => list.gqlFieldResolvers({ schemaName }))), - JSON: GraphQLJSON, - _QueryMeta: queryMetaResolver, - }; - } - - getQueryResolvers({ schemaName }: { schemaName: string }) { - return { - // Order is also important here, any TypeQuery's defined by types - // shouldn't be able to override list-level queries - ...objMerge(this.lists.map(list => list.gqlAuxQueryResolvers({ schemaName }))), - ...objMerge(this.lists.map(list => list.gqlQueryResolvers({ schemaName }))), - }; - } - - getMutationResolvers({ schemaName }: { schemaName: string }) { - return objMerge(this.lists.map(list => list.gqlMutationResolvers({ schemaName }))); - } - - getSubscriptionResolvers({}: { schemaName: string }) { - return {}; - } -} diff --git a/packages-next/keystone/src/lib/core/queries/index.ts b/packages-next/keystone/src/lib/core/queries/index.ts new file mode 100644 index 00000000000..b3a01926438 --- /dev/null +++ b/packages-next/keystone/src/lib/core/queries/index.ts @@ -0,0 +1,83 @@ +import { getGqlNames, QueryMeta, schema } from '@keystone-next/types'; +import { InitialisedList } from '../types-for-lists'; +import { applyFirstSkipToCount } from '../utils'; +import * as queries from './resolvers'; + +export function getQueriesForList(list: InitialisedList) { + if (list.access.read === false) return {}; + const names = getGqlNames(list); + + const findOne = schema.field({ + type: list.types.output, + args: { + where: schema.arg({ + type: schema.nonNull(list.types.uniqueWhere), + }), + }, + description: ` Search for the ${list.listKey} item with the matching ID.`, + async resolve(_rootVal, args, context) { + return queries.findOne(args, list, context); + }, + }); + const findMany = schema.field({ + type: schema.list(schema.nonNull(list.types.output)), + args: list.types.findManyArgs, + description: ` Search for all ${list.listKey} items which match the where clause.`, + async resolve(_rootVal, args, context, info) { + return queries.findMany(args, list, context, info); + }, + }); + const countQuery = schema.field({ + type: schema.Int, + args: { + where: schema.arg({ type: schema.nonNull(list.types.where), defaultValue: {} }), + }, + async resolve(_rootVal, args, context, info) { + const count = await queries.count(args, list, context); + if (info && info.cacheControl && list.cacheHint) { + info.cacheControl.setCacheHint( + list.cacheHint({ + results: count, + operationName: info.operation.name?.value, + meta: true, + }) as any + ); + } + return count; + }, + }); + + const metaQuery = schema.field({ + type: QueryMeta, + args: list.types.findManyArgs, + description: ` Perform a meta-query on all ${list.listKey} items which match the where clause.`, + resolve(_rootVal, { first, search, skip, where }, context, info) { + return { + getCount: async () => { + const count = applyFirstSkipToCount({ + count: await queries.count({ where, search }, list, context), + first, + skip, + }); + if (info && info.cacheControl && list.cacheHint) { + info.cacheControl.setCacheHint( + list.cacheHint({ + results: count, + operationName: info.operation.name?.value, + meta: true, + }) as any + ); + } + return count; + }, + }; + }, + deprecationReason: `This query will be removed in a future version. Please use ${names.listQueryCountName} instead.`, + }); + return { + [names.listQueryName]: findMany, + [names.itemQueryName]: findOne, + [names.listQueryMetaName]: metaQuery, + [names.listQueryCountName]: countQuery, + }; +} diff --git a/packages-next/keystone/src/lib/core/queries/output-field.ts b/packages-next/keystone/src/lib/core/queries/output-field.ts new file mode 100644 index 00000000000..f7bedf12f44 --- /dev/null +++ b/packages-next/keystone/src/lib/core/queries/output-field.ts @@ -0,0 +1,171 @@ +import { + NextFieldType, + CacheHint, + IndividualFieldAccessControl, + FieldReadAccessArgs, + BaseGeneratedListTypes, + ItemRootValue, + schema, + FindManyArgsValue, + KeystoneContext, +} from '@keystone-next/types'; +import { GraphQLResolveInfo } from 'graphql'; +import { validateFieldAccessControl, validateNonCreateListAccessControl } from '../access-control'; +import { accessDeniedError } from '../graphql-errors'; +import { resolveWhereInput } from '../where-inputs'; +import { ResolvedDBField, ResolvedRelationDBField } from '../resolve-relationships'; +import { InitialisedList } from '../types-for-lists'; +import { + applyFirstSkipToCount, + getPrismaModelForList, + IdType, + getDBFieldKeyForFieldOnMultiField, +} from '../utils'; +import { findMany, findManyFilter } from './resolvers'; + +function assert(condition: boolean): asserts condition { + if (!condition) { + throw new Error('failed assert'); + } +} + +function getRelationVal( + dbField: ResolvedRelationDBField, + id: IdType, + foreignList: InitialisedList, + context: KeystoneContext, + info: GraphQLResolveInfo +) { + const oppositeDbField = foreignList.resolvedDbFields[dbField.field]; + assert(oppositeDbField.kind === 'relation'); + const relationFilter = { + [dbField.field]: oppositeDbField.mode === 'many' ? { some: { id } } : { id }, + }; + if (dbField.mode === 'many') { + return { + findMany: async (args: FindManyArgsValue) => { + return findMany(args, foreignList, context, info, relationFilter); + }, + count: async ({ where, search, first, skip }: FindManyArgsValue) => { + const filter = await findManyFilter(foreignList, context, where, search); + if (filter === false) { + throw accessDeniedError('query'); + } + const count = applyFirstSkipToCount({ + count: await getPrismaModelForList(context.prisma, dbField.list).count({ + where: { AND: [filter, relationFilter] }, + }), + first, + skip, + }); + if (info.cacheControl && foreignList.cacheHint) { + info.cacheControl.setCacheHint( + foreignList.cacheHint({ + results: count, + operationName: info.operation.name?.value, + meta: true, + }) as any + ); + } + return count; + }, + }; + } + + return async () => { + const access = await validateNonCreateListAccessControl({ + access: foreignList.access.read, + args: { + context, + listKey: dbField.list, + operation: 'read', + session: context.session, + }, + }); + if (access === false) { + throw accessDeniedError('query'); + } + + return getPrismaModelForList(context.prisma, dbField.list).findFirst({ + where: + access === true + ? relationFilter + : { AND: [relationFilter, await resolveWhereInput(access, foreignList)] }, + }); + }; +} + +function getValueForDBField( + rootVal: ItemRootValue, + dbField: ResolvedDBField, + id: IdType, + fieldPath: string, + context: KeystoneContext, + lists: Record, + info: GraphQLResolveInfo +) { + if (dbField.kind === 'multi') { + return Object.fromEntries( + Object.keys(dbField.fields).map(innerDBFieldKey => { + const keyOnDbValue = getDBFieldKeyForFieldOnMultiField(fieldPath, innerDBFieldKey); + return [innerDBFieldKey, rootVal[keyOnDbValue] as any]; + }) + ); + } + if (dbField.kind === 'relation') { + return getRelationVal(dbField, id, lists[dbField.list], context, info); + } + return rootVal[fieldPath] as any; +} + +export function outputTypeField( + output: NextFieldType['output'], + dbField: ResolvedDBField, + cacheHint: CacheHint | undefined, + access: IndividualFieldAccessControl>, + listKey: string, + fieldKey: string, + lists: Record +) { + return schema.field({ + type: output.type, + deprecationReason: output.deprecationReason, + description: output.description, + args: output.args, + extensions: output.extensions, + async resolve(rootVal: ItemRootValue, args, context, info) { + const id = (rootVal as any).id as IdType; + + // Check access + const canAccess = await validateFieldAccessControl({ + access, + args: { + context, + fieldKey, + item: rootVal, + listKey, + operation: 'read', + session: context.session, + }, + }); + if (!canAccess) { + // If the client handles errors correctly, it should be able to + // receive partial data (for the fields the user has access to), + // and then an `errors` array of AccessDeniedError's + throw accessDeniedError('query', fieldKey, { itemId: rootVal.id }); + } + + // Only static cache hints are supported at the field level until a use-case makes it clear what parameters a dynamic hint would take + if (cacheHint && info && info.cacheControl) { + info.cacheControl.setCacheHint(cacheHint as any); + } + + const value = getValueForDBField(rootVal, dbField, id, fieldKey, context, lists, info); + + if (output.resolve) { + return output.resolve({ value, item: rootVal }, args, context, info); + } + return value; + }, + }); +} diff --git a/packages-next/keystone/src/lib/core/queries/resolvers.ts b/packages-next/keystone/src/lib/core/queries/resolvers.ts new file mode 100644 index 00000000000..ea34251deb8 --- /dev/null +++ b/packages-next/keystone/src/lib/core/queries/resolvers.ts @@ -0,0 +1,248 @@ +import { + FindManyArgsValue, + ItemRootValue, + KeystoneContext, + OrderDirection, +} from '@keystone-next/types'; +import { GraphQLResolveInfo } from 'graphql'; +import { validateNonCreateListAccessControl } from '../access-control'; +import { + InputFilter, + PrismaFilter, + UniquePrismaFilter, + resolveUniqueWhereInput, + resolveWhereInput, +} from '../where-inputs'; +import { accessDeniedError, LimitsExceededError } from '../graphql-errors'; +import { InitialisedList } from '../types-for-lists'; +import { getPrismaModelForList, getDBFieldKeyForFieldOnMultiField } from '../utils'; + +export async function findManyFilter( + list: InitialisedList, + context: KeystoneContext, + where: InputFilter, + search: string | null | undefined +): Promise { + const access = await validateNonCreateListAccessControl({ + access: list.access.read, + args: { + context, + listKey: list.listKey, + operation: 'read', + session: context.session, + }, + }); + if (!access) { + return false; + } + let resolvedWhere = await resolveWhereInput(where || {}, list); + if (typeof access === 'object') { + resolvedWhere = { + AND: [resolvedWhere, await resolveWhereInput(access, list)], + }; + } + + return list.applySearchField(resolvedWhere, search); +} + +// doing this is a result of an optimisation to skip doing a findUnique and then a findFirst(where the second one is done with access control) +// we want to do this explicit mapping because: +// - we are passing the values into a normal where filter and we want to ensure that fields cannot do non-unique filters(we don't do validation on non-unique wheres because prisma will validate all that) +// - for multi-field unique indexes, we need to a mapping because iirc findFirst/findMany won't understand the syntax for filtering by multi-field unique indexes(which makes sense and is correct imo) +export function mapUniqueWhereToWhere( + list: InitialisedList, + uniqueWhere: UniquePrismaFilter +): PrismaFilter { + // inputResolvers.uniqueWhere validates that there is only one key + const key = Object.keys(uniqueWhere)[0]; + const dbField = list.fields[key].dbField; + if (dbField.kind !== 'scalar' || (dbField.scalar !== 'String' && dbField.scalar !== 'Int')) { + throw new Error( + 'Currently only String and Int scalar db fields can provide a uniqueWhere input' + ); + } + const val = uniqueWhere[key]; + if (dbField.scalar === 'Int' && typeof val !== 'number') { + throw new Error('uniqueWhere inputs must return an integer for Int db fields'); + } + if (dbField.scalar === 'String' && typeof val !== 'string') { + throw new Error('uniqueWhere inputs must return an string for String db fields'); + } + return { [key]: val }; +} + +async function findOneFilter( + { where }: { where: Record }, + list: InitialisedList, + context: KeystoneContext +) { + const access = await validateNonCreateListAccessControl({ + access: list.access.read, + args: { + context, + listKey: list.listKey, + operation: 'read', + session: context.session, + }, + }); + if (access === false) { + return false; + } + let resolvedUniqueWhere = await resolveUniqueWhereInput(where, list.fields, context); + const wherePrismaFilter = mapUniqueWhereToWhere(list, resolvedUniqueWhere); + return access === true + ? wherePrismaFilter + : { AND: [wherePrismaFilter, await resolveWhereInput(access, list)] }; +} + +export async function findOne( + args: { where: Record }, + list: InitialisedList, + context: KeystoneContext +) { + const filter = await findOneFilter(args, list, context); + if (filter === false) { + throw accessDeniedError('query'); + } + const item = await getPrismaModelForList(context.prisma, list.listKey).findFirst({ + where: filter, + }); + if (item === null) { + throw accessDeniedError('query'); + } + return item; +} + +export async function findMany( + { where, first, skip, orderBy: rawOrderBy, search, sortBy }: FindManyArgsValue, + list: InitialisedList, + context: KeystoneContext, + info: GraphQLResolveInfo, + extraFilter?: PrismaFilter +): Promise { + const [resolvedWhere, orderBy] = await Promise.all([ + findManyFilter(list, context, where || {}, search), + resolveOrderBy(rawOrderBy, sortBy, list, context), + ]); + applyEarlyMaxResults(first, list); + + if (resolvedWhere === false) { + throw accessDeniedError('query'); + } + const results = await getPrismaModelForList(context.prisma, list.listKey).findMany({ + where: extraFilter === undefined ? resolvedWhere : { AND: [resolvedWhere, extraFilter] }, + orderBy, + take: first ?? undefined, + skip, + }); + applyMaxResults(results, list, context); + if (info.cacheControl && list.cacheHint) { + info.cacheControl.setCacheHint( + list.cacheHint({ + results, + operationName: info.operation.name?.value, + meta: false, + }) as any + ); + } + return results; +} + +async function resolveOrderBy( + orderBy: readonly Record[], + sortBy: readonly string[] | null | undefined, + list: InitialisedList, + context: KeystoneContext +): Promise[]> { + return ( + await Promise.all( + orderBy.map(async orderBySelection => { + const keys = Object.keys(orderBySelection); + if (keys.length !== 1) { + throw new Error( + `Only a single key must be passed to ${list.types.orderBy.graphQLType.name}` + ); + } + + const fieldKey = keys[0]; + + const value = orderBySelection[fieldKey]; + + if (value === null) { + throw new Error('null cannot be passed as an order direction'); + } + + const field = list.fields[fieldKey]; + const resolveOrderBy = field.input!.orderBy!.resolve; + const resolvedValue = resolveOrderBy ? await resolveOrderBy(value, context) : value; + if (field.dbField.kind === 'multi') { + const keys = Object.keys(resolvedValue); + if (keys.length !== 1) { + throw new Error( + `Only a single key must be returned from an orderBy input resolver for a multi db field` + ); + } + const innerKey = keys[0]; + return { + [getDBFieldKeyForFieldOnMultiField(fieldKey, innerKey)]: resolvedValue[innerKey], + }; + } + return { [fieldKey]: resolvedValue }; + }) + ) + ).concat( + sortBy?.map(sort => { + if (sort.endsWith('_DESC')) { + return { [sort.slice(0, -'_DESC'.length)]: 'desc' }; + } + return { [sort.slice(0, -'_ASC'.length)]: 'asc' }; + }) || [] + ); +} + +export async function count( + { where, search }: { where: Record; search?: string | null }, + list: InitialisedList, + context: KeystoneContext +) { + const resolvedWhere = await findManyFilter(list, context, where || {}, search); + if (resolvedWhere === false) { + throw accessDeniedError('query'); + } + return getPrismaModelForList(context.prisma, list.listKey).count({ + where: resolvedWhere, + }); +} + +const limitsExceedError = (args: { type: string; limit: number; list: string }) => + new LimitsExceededError({ data: args }); + +function applyEarlyMaxResults(_first: number | null | undefined, list: InitialisedList) { + const first = _first ?? Infinity; + // We want to help devs by failing fast and noisily if limits are violated. + // Unfortunately, we can't always be sure of intent. + // E.g., if the query has a "first: 10", is it bad if more results could come back? + // Maybe yes, or maybe the dev is just paginating posts. + // But we can be sure there's a problem in two cases: + // * The query explicitly has a "first" that exceeds the limit + // * The query has no "first", and has more results than the limit + if (first < Infinity && first > list.maxResults) { + throw limitsExceedError({ list: list.listKey, type: 'maxResults', limit: list.maxResults }); + } +} + +function applyMaxResults(results: unknown[], list: InitialisedList, context: KeystoneContext) { + if (results.length > list.maxResults) { + throw limitsExceedError({ list: list.listKey, type: 'maxResults', limit: list.maxResults }); + } + if (context) { + context.totalResults += Array.isArray(results) ? results.length : 1; + if (context.totalResults > context.maxTotalResults) { + throw limitsExceedError({ + list: list.listKey, + type: 'maxTotalResults', + limit: context.maxTotalResults, + }); + } + } +} diff --git a/packages-next/keystone/src/lib/core/resolve-relationships.ts b/packages-next/keystone/src/lib/core/resolve-relationships.ts new file mode 100644 index 00000000000..bd8e01a5931 --- /dev/null +++ b/packages-next/keystone/src/lib/core/resolve-relationships.ts @@ -0,0 +1,246 @@ +import { DBField, MultiDBField, NoDBField, ScalarishDBField } from '@keystone-next/types'; + +type BaseResolvedRelationDBField = { + kind: 'relation'; + list: string; + field: string; + relationName: string; +}; + +export type ResolvedRelationDBField = + | (BaseResolvedRelationDBField & { + mode: 'many'; + }) + | (BaseResolvedRelationDBField & { + mode: 'one'; + foreignIdField: 'none' | 'owned' | 'owned-unique'; + }); + +export type ListsWithResolvedRelations = Record< + string, + { resolvedDbFields: FieldsWithResolvedRelations } +>; + +export type ResolvedDBField = + | ResolvedRelationDBField + | ScalarishDBField + | NoDBField + | MultiDBField>; + +// note: all keystone fields correspond to a field here +// not all fields here correspond to keystone fields(the implicit side of one-sided relation fields) +type FieldsWithResolvedRelations = Record; + +type Rel = { + listKey: string; + fieldPath: string; + mode: 'many' | 'one'; +}; + +function sortRelationships(left: Rel, right: Rel) { + const order = left.listKey.localeCompare(right.listKey); + if (order > 0) { + // left comes after right, so swap them. + return [right, left]; + } else if (order === 0) { + // self referential list, so check the paths. + if (left.fieldPath.localeCompare(right.fieldPath) > 0) { + return [right, left]; + } + } + return [left, right]; +} + +// what's going on here: +// - validating all the relationships +// - for relationships involving to-one: deciding which side owns the foreign key +// - turning one-sided relationships into two-sided relationships so that elsewhere in Keystone, +// you only have to reason about two-sided relationships +// (note that this means that there are "fields" in the returned ListsWithResolvedRelations +// which are not actually proper Keystone fields, they are just a db field and nothing else) +export function resolveRelationships( + lists: Record }> +): ListsWithResolvedRelations { + const alreadyResolvedTwoSidedRelationships = new Set(); + const resolvedLists: Record> = Object.fromEntries( + Object.keys(lists).map(listKey => [listKey, {}]) + ); + for (const [listKey, fields] of Object.entries(lists)) { + const resolvedList = resolvedLists[listKey]; + for (const [fieldPath, { dbField: field }] of Object.entries(fields.fields)) { + if (field.kind !== 'relation') { + resolvedList[fieldPath] = field; + continue; + } + const foreignUnresolvedList = lists[field.list]; + if (!foreignUnresolvedList) { + throw new Error( + `The relationship field at ${listKey}.${fieldPath} points to the list ${listKey} which does not exist` + ); + } + if (field.field) { + const localRef = `${listKey}.${fieldPath}`; + const foreignRef = `${field.list}.${field.field}`; + if (alreadyResolvedTwoSidedRelationships.has(localRef)) { + continue; + } + alreadyResolvedTwoSidedRelationships.add(foreignRef); + const foreignField = foreignUnresolvedList.fields[field.field]?.dbField; + if (!foreignField) { + throw new Error( + `The relationship field at ${localRef} points to ${foreignRef} but no field at ${foreignRef} exists` + ); + } + + if (foreignField.kind !== 'relation') { + throw new Error( + `The relationship field at ${localRef} points to ${foreignRef} but ${foreignRef} is not a relationship field` + ); + } + + if (foreignField.list !== listKey) { + throw new Error( + `The relationship field at ${localRef} points to ${foreignRef} but ${foreignRef} points to the list ${foreignField.list} rather than ${listKey}` + ); + } + + if (foreignField.field === undefined) { + throw new Error( + `The relationship field at ${localRef} points to ${foreignRef}, ${localRef} points to ${listKey} correctly but does not point to the ${fieldPath} field when it should` + ); + } + + if (foreignField.field !== fieldPath) { + throw new Error( + `The relationship field at ${localRef} points to ${foreignRef}, ${localRef} points to ${listKey} correctly but points to the ${foreignField.field} field instead of ${fieldPath}` + ); + } + + let [leftRel, rightRel] = sortRelationships( + { listKey, fieldPath, mode: field.mode }, + { listKey: field.list, fieldPath: field.field, mode: foreignField.mode } + ); + + if (leftRel.mode === 'one' && rightRel.mode === 'one') { + const relationName = `${leftRel.listKey}_${leftRel.fieldPath}`; + resolvedLists[leftRel.listKey][leftRel.fieldPath] = { + kind: 'relation', + mode: 'one', + field: rightRel.fieldPath, + list: rightRel.listKey, + foreignIdField: 'owned-unique', + relationName, + }; + resolvedLists[rightRel.listKey][rightRel.fieldPath] = { + kind: 'relation', + mode: 'one', + field: leftRel.fieldPath, + list: leftRel.listKey, + foreignIdField: 'none', + relationName, + }; + continue; + } + if (leftRel.mode === 'many' && rightRel.mode === 'many') { + const relationName = `${leftRel.listKey}_${leftRel.fieldPath}_${rightRel.listKey}_${rightRel.fieldPath}`; + resolvedLists[leftRel.listKey][leftRel.fieldPath] = { + kind: 'relation', + mode: 'many', + field: rightRel.fieldPath, + list: rightRel.listKey, + relationName, + }; + resolvedLists[rightRel.listKey][rightRel.fieldPath] = { + kind: 'relation', + mode: 'many', + field: leftRel.fieldPath, + list: leftRel.listKey, + relationName, + }; + continue; + } + // if we're here, we're in a 1:N + // and we want to make sure the 1 side on the left and the many on the right + // (technically only one of these checks is necessary, the other one will have to be true if one is + // but this communicates what's going on here) + if (leftRel.mode === 'many' && rightRel.mode === 'one') { + [leftRel, rightRel] = [rightRel, leftRel]; + } + const relationName = `${leftRel.listKey}_${leftRel.fieldPath}`; + resolvedLists[leftRel.listKey][leftRel.fieldPath] = { + kind: 'relation', + mode: 'one', + field: rightRel.fieldPath, + list: rightRel.listKey, + foreignIdField: 'owned', + relationName, + }; + resolvedLists[rightRel.listKey][rightRel.fieldPath] = { + kind: 'relation', + mode: 'many', + field: leftRel.fieldPath, + list: leftRel.listKey, + relationName, + }; + continue; + } + const foreignFieldPath = `from_${listKey}_${fieldPath}`; + if (foreignUnresolvedList.fields[foreignFieldPath]) { + throw new Error( + `The relationship field at ${listKey}.${fieldPath} points to the list ${field.list}, Keystone needs to a create a relationship field at ${field.list}.${foreignFieldPath} to support the relationship at ${listKey}.${fieldPath} but ${field.list} already has a field named ${foreignFieldPath}` + ); + } + + if (field.mode === 'many') { + const relationName = `${listKey}_${fieldPath}_many`; + resolvedLists[field.list][foreignFieldPath] = { + kind: 'relation', + mode: 'many', + list: listKey, + field: fieldPath, + relationName, + }; + resolvedList[fieldPath] = { + kind: 'relation', + mode: 'many', + list: field.list, + field: foreignFieldPath, + relationName, + }; + } else { + const relationName = `${listKey}_${fieldPath}`; + resolvedLists[field.list][foreignFieldPath] = { + kind: 'relation', + mode: 'many', + list: listKey, + field: fieldPath, + relationName, + }; + resolvedList[fieldPath] = { + kind: 'relation', + list: field.list, + field: foreignFieldPath, + foreignIdField: 'owned', + relationName, + mode: 'one', + }; + } + } + } + // the way we resolve the relationships means that the relationships will be in a + // different order than the order the user specified in their config + // doesn't really change the behaviour of anything but it means that the order of the fields in the prisma schema will be + // the same as the user provided + return Object.fromEntries( + Object.entries(resolvedLists).map(([listKey, outOfOrderDbFields]) => { + // this adds the fields based on the order that the user passed in + // (except it will not add the opposites to one-sided relations) + const resolvedDbFields = Object.fromEntries( + Object.keys(lists[listKey].fields).map(fieldKey => [fieldKey, outOfOrderDbFields[fieldKey]]) + ); + // then we add the opposites to one-sided relations + Object.assign(resolvedDbFields, outOfOrderDbFields); + return [listKey, { resolvedDbFields }]; + }) + ); +} diff --git a/packages-next/keystone/src/lib/core/tests/Keystone.test.ts b/packages-next/keystone/src/lib/core/tests/Keystone.test.ts deleted file mode 100644 index 8d6450a3845..00000000000 --- a/packages-next/keystone/src/lib/core/tests/Keystone.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { PrismaAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Keystone } from '../Keystone/index'; -import { List } from '../ListTypes'; - -class MockFieldAdapter {} - -class MockFieldImplementation { - name: string; - access: any; - config: any; - constructor(name: string) { - this.access = { - public: { - create: true, - read: true, - update: true, - delete: true, - }, - }; - this.config = {}; - this.name = name; - } - getGqlAuxTypes() { - return ['scalar Foo']; - } - gqlOutputFields() { - return ['foo: Boolean']; - } - gqlQueryInputFields() { - return ['zip: Boolean']; - } - gqlUpdateInputFields() { - return ['zap: Boolean']; - } - gqlCreateInputFields() { - return ['quux: Boolean']; - } - getGqlAuxQueries() { - return ['getFoo: Boolean']; - } -} - -const MockFieldType = { - implementation: MockFieldImplementation, - adapter: MockFieldAdapter, -}; - -class MockListAdapter { - parentAdapter: MockAdapter; - constructor(parentAdapter: MockAdapter) { - this.parentAdapter = parentAdapter; - } - key = 'mock'; - newFieldAdapter = () => new MockFieldAdapter(); -} - -class MockAdapter { - name = 'mock'; - newListAdapter = () => new MockListAdapter(this); -} - -test('Check require', () => { - expect(Keystone).not.toBeNull(); -}); - -describe('Keystone.createList()', () => { - test('basic', () => { - const config = { - adapter: new MockAdapter() as unknown as PrismaAdapter, - onConnect: async () => {}, - queryLimits: {}, - }; - const keystone = new Keystone(config); - - expect(keystone.lists).toEqual({}); - expect(keystone.listsArray).toEqual([]); - - keystone.createList('User', { - fields: { - id: { type: MockFieldType }, - name: { type: MockFieldType }, - email: { type: MockFieldType }, - }, - access: true, - }); - - expect(keystone.lists).toHaveProperty('User'); - expect(keystone.lists['User']).toBeInstanceOf(List); - expect(keystone.listsArray).toHaveLength(1); - expect(keystone.listsArray[0]).toBeInstanceOf(List); - - expect(keystone.listsArray[0]).toBe(keystone.lists['User']); - }); - - test('Reserved words', () => { - const config = { - adapter: new MockAdapter() as unknown as PrismaAdapter, - onConnect: async () => {}, - queryLimits: {}, - }; - const keystone = new Keystone(config); - - for (const listName of ['Query', 'Mutation', 'Subscription']) { - expect(() => keystone.createList(listName, { fields: [], access: true })).toThrow( - `Invalid list name "${listName}". List names cannot be reserved GraphQL keywords` - ); - } - }); - - /* eslint-disable jest/no-disabled-tests */ - describe('access control config', () => { - test.todo('expands shorthand acl config'); - test.todo('throws error when one of create/read/update/delete not set on object'); - test.todo('throws error when create/read/update/delete are not correct type'); - }); - /* eslint-enable jest/no-disabled-tests */ -}); diff --git a/packages-next/keystone/src/lib/core/tests/List.test.ts b/packages-next/keystone/src/lib/core/tests/List.test.ts deleted file mode 100644 index 8032edef3ad..00000000000 --- a/packages-next/keystone/src/lib/core/tests/List.test.ts +++ /dev/null @@ -1,1076 +0,0 @@ -import { gql } from 'apollo-server-express'; -import { GraphQLResolveInfo } from 'graphql'; -import { print } from 'graphql/language/printer'; -import { text, relationship } from '@keystone-next/fields'; -import { BaseListConfig, KeystoneContext } from '@keystone-next/types'; -import { PrismaAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { List } from '../ListTypes'; -import { AccessDeniedError } from '../ListTypes/graphqlErrors'; - -const Relationship = relationship({ ref: '' }).type; -const Text = text().type; - -const context = { - getListAccessControlForUser: () => true, - getFieldAccessControlForUser: ( - access: any, - listKey: string, - fieldPath: string, - originalInput: any, - existingItem: any - ) => !(existingItem && existingItem.makeFalse && fieldPath === 'name'), - getAuthAccessControlForUser: () => true, -} as unknown as KeystoneContext; - -// Convert a gql field into a normalised format for comparison. -// Needs to be wrapped in a mock type for gql to correctly parse it. -const normalise = (s: string) => print(gql(`type t { ${s} }`)); - -const getListByKey = (listKey: string) => { - if (listKey === 'Other') { - return { - // @ts-ignore - gqlNames: { - outputTypeName: 'Other', - createInputName: 'createOther', - whereInputName: 'OtherWhereInput', - relateToOneInputName: 'OtherRelateToOneInput', - whereUniqueInputName: 'OtherWhereUniqueInput', - }, - access: { - public: { - read: true, - }, - }, - } as unknown as List; - } -}; - -class MockFieldImplementation { - access: any; - config: any; - hooks: any; - constructor() { - this.access = { - public: { - create: false, - read: true, - update: false, - delete: false, - }, - }; - this.config = {}; - this.hooks = {}; - } - gqlOutputFields() { - return ['id: ID']; - } - gqlQueryInputFields() { - return ['id: ID']; - } - gqlUpdateInputFields() { - return ['id: ID']; - } - gqlCreateInputFields() { - return ['id: ID']; - } - getGqlAuxTypes() { - return []; - } - getGqlAuxQueries() { - return []; - } - gqlOutputFieldResolvers() { - return {}; - } - gqlAuxQueryResolvers() { - return {}; - } - gqlAuxFieldResolvers() { - return {}; - } - getDefaultValue() { - return; - } - async resolveInput({ resolvedData }: { resolvedData: { id: any } }) { - return resolvedData.id; - } - async validateInput() {} - async beforeChange() {} - async afterChange() {} - async beforeDelete() {} - async validateDelete() {} - async afterDelete() {} -} -class MockFieldAdapter { - listAdapter = { name: 'mock', parentAdapter: {} }; -} - -const MockIdType = { - implementation: MockFieldImplementation, - adapter: MockFieldAdapter, -}; - -class MockListAdapter { - name = 'mock'; - parentAdapter: any; - index: number; - items: Record | undefined>; - constructor(parentAdapter: any) { - this.parentAdapter = parentAdapter; - this.index = 3; - this.items = { - 0: { name: 'a', email: 'a@example.com', id: 0 }, - 1: { name: 'b', email: 'b@example.com', id: 1 }, - 2: { name: 'c', email: 'c@example.com', id: 2 }, - }; - } - newFieldAdapter = () => new MockFieldAdapter(); - create = async (item: Record) => { - this.items[this.index] = { - ...item, - index: this.index, - }; - this.index += 1; - return this.items[this.index - 1]; - }; - delete = async (id: number) => { - this.items[id] = undefined; - }; - itemsQuery = async ({ where: { id_in: ids, id, id_not_in } }: any, { meta = false } = {}) => { - if (meta) { - return { - count: (id !== undefined - ? [this.items[id]] - : ids - .filter((i: number) => !id_not_in || !id_not_in.includes(i)) - .map((i: number) => this.items[i]) - ).length, - }; - } else { - return id !== undefined - ? [this.items[id]] - : ids - .filter((i: number) => !id_not_in || !id_not_in.includes(i)) - .map((i: number) => this.items[i]); - } - }; - update = (id: number, item: Record) => { - this.items[id] = { ...this.items[id], ...item }; - return this.items[id]; - }; -} - -class MockAdapter { - name = 'mock'; - newListAdapter = () => new MockListAdapter(this); -} - -const listExtras = () => ({ - getListByKey, - adapter: new MockAdapter() as unknown as PrismaAdapter, - schemaNames: ['public'], -}); - -const config = { - fields: { - id: { type: MockIdType }, - name: { type: Text }, - email: { type: Text }, - other: { type: Relationship, ref: 'Other' }, - hidden: { type: Text, access: { read: false, create: true, update: true, delete: true } }, - writeOnce: { type: Text, access: { read: true, create: true, update: false, delete: true } }, - }, -}; - -const setup = (extraConfig?: Record) => { - const list = new List( - 'Test', - { ...config, ...extraConfig } as unknown as BaseListConfig, - listExtras() - ); - list.initFields(); - return list; -}; - -describe('new List()', () => { - test('new List() - Smoke test', () => { - const list = setup(); - expect(list).not.toBeNull(); - expect(list.key).toEqual('Test'); - expect(list.getListByKey).toBe(getListByKey); - }); - - test('new List() - Plural throws error', () => { - expect(() => new List('Tests', config as unknown as BaseListConfig, listExtras())).toThrow( - Error - ); - }); - - test('new List() - config', () => { - const list = setup(); - expect(list.fields).toBeInstanceOf(Object); - }); - - test('new List() - labels', () => { - const list = setup(); - expect(list.adminUILabels).toEqual({ - label: 'Tests', - singular: 'Test', - plural: 'Tests', - path: 'tests', - }); - }); - - test('new List() - gqlNames', () => { - const list = setup(); - expect(list.gqlNames).toEqual({ - outputTypeName: 'Test', - itemQueryName: 'Test', - listQueryName: 'allTests', - listQueryMetaName: '_allTestsMeta', - listQueryCountName: 'testsCount', - listSortName: 'SortTestsBy', - listOrderName: 'TestOrderByInput', - deleteMutationName: 'deleteTest', - deleteManyMutationName: 'deleteTests', - updateMutationName: 'updateTest', - createMutationName: 'createTest', - updateManyMutationName: 'updateTests', - createManyMutationName: 'createTests', - whereInputName: 'TestWhereInput', - whereUniqueInputName: 'TestWhereUniqueInput', - updateInputName: 'TestUpdateInput', - createInputName: 'TestCreateInput', - updateManyInputName: 'TestsUpdateInput', - createManyInputName: 'TestsCreateInput', - relateToManyInputName: 'TestRelateToManyInput', - relateToOneInputName: 'TestRelateToOneInput', - }); - }); - - test('new List() - access', () => { - const list = setup(); - expect(list.access).toEqual({ - internal: { - create: true, - read: true, - update: true, - delete: true, - auth: true, - }, - public: { - create: true, - delete: true, - read: true, - update: true, - auth: true, - }, - }); - }); - - test('new List() - fields', () => { - const list = setup(); - expect(list.fields).toHaveLength(6); - expect(list.fields[0]).toBeInstanceOf(MockIdType.implementation); - expect(list.fields[1]).toBeInstanceOf(Text.implementation); - expect(list.fields[2]).toBeInstanceOf(Text.implementation); - expect(list.fields[3]).toBeInstanceOf(Relationship.implementation); - expect(list.fields[4]).toBeInstanceOf(Text.implementation); - expect(list.fields[5]).toBeInstanceOf(Text.implementation); - - expect(list.fieldsByPath['id']).toBeInstanceOf(MockIdType.implementation); - expect(list.fieldsByPath['name']).toBeInstanceOf(Text.implementation); - expect(list.fieldsByPath['email']).toBeInstanceOf(Text.implementation); - expect(list.fieldsByPath['other']).toBeInstanceOf(Relationship.implementation); - expect(list.fieldsByPath['hidden']).toBeInstanceOf(Text.implementation); - expect(list.fieldsByPath['writeOnce']).toBeInstanceOf(Text.implementation); - - const idOnlyList = new List( - 'NoField', - { fields: { id: { type: MockIdType } }, access: {} }, - listExtras() - ); - idOnlyList.initFields(); - expect(idOnlyList.fields).toHaveLength(1); - expect(list.fields[0]).toBeInstanceOf(MockIdType.implementation); - expect(list.fieldsByPath['id']).toBeInstanceOf(MockIdType.implementation); - }); - - test('new List() - adapter', () => { - const list = setup(); - expect(list.adapter).toBeInstanceOf(MockListAdapter); - }); -}); - -describe(`getGqlTypes()`, () => { - const type = `""" A keystone list """ - type Test { - id: ID - name: String - email: String - other: Other - writeOnce: String - }`; - const whereInput = `input TestWhereInput { - AND: [TestWhereInput!] - OR: [TestWhereInput!] - id: ID - name: String - name_not: String - name_contains: String - name_not_contains: String - name_starts_with: String - name_not_starts_with: String - name_ends_with: String - name_not_ends_with: String - name_i: String - name_not_i: String - name_contains_i: String - name_not_contains_i: String - name_starts_with_i: String - name_not_starts_with_i: String - name_ends_with_i: String - name_not_ends_with_i: String - name_in: [String] - name_not_in: [String] - email: String - email_not: String - email_contains: String - email_not_contains: String - email_starts_with: String - email_not_starts_with: String - email_ends_with: String - email_not_ends_with: String - email_i: String - email_not_i: String - email_contains_i: String - email_not_contains_i: String - email_starts_with_i: String - email_not_starts_with_i: String - email_ends_with_i: String - email_not_ends_with_i: String - email_in: [String] - email_not_in: [String] - other: OtherWhereInput - other_is_null: Boolean - writeOnce: String - writeOnce_not: String - writeOnce_contains: String - writeOnce_not_contains: String - writeOnce_starts_with: String - writeOnce_not_starts_with: String - writeOnce_ends_with: String - writeOnce_not_ends_with: String - writeOnce_i: String - writeOnce_not_i: String - writeOnce_contains_i: String - writeOnce_not_contains_i: String - writeOnce_starts_with_i: String - writeOnce_not_starts_with_i: String - writeOnce_ends_with_i: String - writeOnce_not_ends_with_i: String - writeOnce_in: [String] - writeOnce_not_in: [String] - }`; - const whereUniqueInput = `input TestWhereUniqueInput { - id: ID! - }`; - const updateInput = `input TestUpdateInput { - name: String - email: String - other: OtherRelateToOneInput - hidden: String - }`; - const updateManyInput = `input TestsUpdateInput { - id: ID! - data: TestUpdateInput - }`; - const createInput = `input TestCreateInput { - name: String - email: String - other: OtherRelateToOneInput - hidden: String - writeOnce: String - }`; - const createManyInput = `input TestsCreateInput { - data: TestCreateInput - }`; - const sortTestsBy = `enum SortTestsBy { - name_ASC - name_DESC - email_ASC - email_DESC - writeOnce_ASC - writeOnce_DESC - }`; - const orderTestsBy = `input TestOrderByInput { - name: OrderDirection - email: OrderDirection - writeOnce: OrderDirection - }`; - const orderDirection = `enum OrderDirection { asc desc }`; - const otherRelateToOneInput = `input OtherRelateToOneInput { - connect: OtherWhereUniqueInput - disconnect: OtherWhereUniqueInput - disconnectAll: Boolean - }`; - const schemaName = 'public'; - test('access: true', () => { - expect( - setup({ access: true }) - .getGqlTypes({ schemaName }) - .map(s => print(gql(s))) - ).toEqual( - [ - otherRelateToOneInput, - type, - whereInput, - whereUniqueInput, - sortTestsBy, - orderTestsBy, - orderDirection, - updateInput, - updateManyInput, - createInput, - createManyInput, - ].map(s => print(gql(s))) - ); - }); - test('access: false', () => { - expect( - setup({ access: false }) - .getGqlTypes({ schemaName }) - .map(s => print(gql(s))) - ).toEqual([]); - }); - test('read: true', () => { - expect( - setup({ access: { read: true, create: false, update: false, delete: false } }) - .getGqlTypes({ schemaName }) - .map(s => print(gql(s))) - ).toEqual( - [ - otherRelateToOneInput, - type, - whereInput, - whereUniqueInput, - sortTestsBy, - orderTestsBy, - orderDirection, - ].map(s => print(gql(s))) - ); - }); - test('create: true', () => { - expect( - setup({ access: { read: false, create: true, update: false, delete: false } }) - .getGqlTypes({ schemaName }) - .map(s => print(gql(s))) - ).toEqual( - [ - otherRelateToOneInput, - type, - whereInput, - whereUniqueInput, - sortTestsBy, - orderTestsBy, - orderDirection, - createInput, - createManyInput, - ].map(s => print(gql(s))) - ); - }); - test('update: true', () => { - expect( - setup({ access: { read: false, create: false, update: true, delete: false } }) - .getGqlTypes({ schemaName }) - .map(s => print(gql(s))) - ).toEqual( - [ - otherRelateToOneInput, - type, - whereInput, - whereUniqueInput, - sortTestsBy, - orderTestsBy, - orderDirection, - updateInput, - updateManyInput, - ].map(s => print(gql(s))) - ); - }); - test('delete: true', () => { - expect( - setup({ access: { read: false, create: false, update: false, delete: true } }) - .getGqlTypes({ schemaName }) - .map(s => print(gql(s))) - ).toEqual( - [ - otherRelateToOneInput, - type, - whereInput, - whereUniqueInput, - sortTestsBy, - orderTestsBy, - orderDirection, - ].map(s => print(gql(s))) - ); - }); -}); - -test('getGraphqlFilterFragment', () => { - const list = setup(); - expect(list.getGraphqlFilterFragment()).toEqual([ - 'where: TestWhereInput! = {}', - 'search: String', - 'sortBy: [SortTestsBy!] @deprecated(reason: "sortBy has been deprecated in favour of orderBy")', - 'orderBy: [TestOrderByInput!]! = []', - 'first: Int', - 'skip: Int! = 0', - ]); -}); - -describe(`getGqlQueries()`, () => { - const schemaName = 'public'; - test('access: true', () => { - expect(setup({ access: true }).getGqlQueries({ schemaName }).map(normalise)).toEqual( - [ - `""" Search for all Test items which match the where clause. """ - allTests( - where: TestWhereInput! = {} - search: String - sortBy: [SortTestsBy!] @deprecated(reason: "sortBy has been deprecated in favour of orderBy") - orderBy: [TestOrderByInput!]! = [] - first: Int - skip: Int! = 0 - ): [Test!]`, - `""" Search for the Test item with the matching ID. """ - Test( - where: TestWhereUniqueInput! - ): Test`, - `""" Perform a meta-query on all Test items which match the where clause. """ - _allTestsMeta( - where: TestWhereInput! = {} - search: String - sortBy: [SortTestsBy!] @deprecated(reason: "sortBy has been deprecated in favour of orderBy") - orderBy: [TestOrderByInput!]! = [] - first: Int - skip: Int! = 0 - ): _QueryMeta @deprecated(reason: \"This query will be removed in a future version. Please use testsCount instead.\")`, - `testsCount(where: TestWhereInput! = {}): Int`, - ].map(normalise) - ); - }); - test('access: false', () => { - expect(setup({ access: false }).getGqlQueries({ schemaName }).map(normalise)).toEqual([]); - }); -}); - -test('_wrapFieldResolverWith', async () => { - const resolver = () => 'result'; - const list = setup(); - const newResolver = list._wrapFieldResolver(list.fieldsByPath['name'], resolver); - await expect(newResolver({}, {}, context, {} as GraphQLResolveInfo)).resolves.toEqual('result'); - await expect( - newResolver({ makeFalse: true }, {}, context, {} as GraphQLResolveInfo) - ).rejects.toThrow(AccessDeniedError); -}); - -test('gqlFieldResolvers', () => { - const schemaName = 'public'; - const resolvers = setup().gqlFieldResolvers({ schemaName }); - expect(resolvers.Test.email).toBeInstanceOf(Function); - expect(resolvers.Test.name).toBeInstanceOf(Function); - expect(resolvers.Test.other).toBeInstanceOf(Function); - expect(resolvers.Test.writeOnce).toBeInstanceOf(Function); - expect(resolvers.Test.hidden).toBe(undefined); - - expect(setup({ access: false }).gqlFieldResolvers({ schemaName })).toEqual({}); -}); - -test('gqlAuxFieldResolvers', () => { - const list = setup(); - const schemaName = 'public'; - expect(list.gqlAuxFieldResolvers({ schemaName })).toEqual({}); -}); - -test('gqlAuxQueryResolvers', () => { - const list = setup(); - expect(list.gqlAuxQueryResolvers()).toEqual({}); -}); - -describe(`getGqlMutations()`, () => { - const extraConfig = {}; - const schemaName = 'public'; - test('access: true', () => { - expect( - setup({ access: true, ...extraConfig }) - .getGqlMutations({ schemaName }) - .map(normalise) - ).toEqual( - [ - `""" Create a single Test item. """ createTest(data: TestCreateInput): Test`, - `""" Create multiple Test items. """ createTests(data: [TestsCreateInput]): [Test]`, - `""" Update a single Test item by ID. """ updateTest(id: ID! data: TestUpdateInput): Test`, - `""" Update multiple Test items by ID. """ updateTests(data: [TestsUpdateInput]): [Test]`, - `""" Delete a single Test item by ID. """ deleteTest(id: ID!): Test`, - `""" Delete multiple Test items by ID. """ deleteTests(ids: [ID!]): [Test]`, - ].map(normalise) - ); - }); - test('access: false', () => { - expect( - setup({ access: false, ...extraConfig }) - .getGqlMutations({ schemaName }) - .map(normalise) - ).toEqual([]); - }); - test('read: true', () => { - expect( - setup({ - access: { read: true, create: false, update: false, delete: false }, - ...extraConfig, - }) - .getGqlMutations({ schemaName }) - .map(normalise) - ).toEqual([].map(normalise)); - }); - test('create: true', () => { - expect( - setup({ - access: { read: false, create: true, update: false, delete: false }, - ...extraConfig, - }) - .getGqlMutations({ schemaName }) - .map(normalise) - ).toEqual( - [ - `""" Create a single Test item. """ createTest(data: TestCreateInput): Test`, - `""" Create multiple Test items. """ createTests(data: [TestsCreateInput]): [Test]`, - ].map(normalise) - ); - }); - test('update: true', () => { - expect( - setup({ - access: { read: false, create: false, update: true, delete: false }, - ...extraConfig, - }) - .getGqlMutations({ schemaName }) - .map(normalise) - ).toEqual( - [ - `""" Update a single Test item by ID. """ updateTest(id: ID! data: TestUpdateInput): Test`, - `""" Update multiple Test items by ID. """ updateTests(data: [TestsUpdateInput]): [Test]`, - ].map(normalise) - ); - }); - test('delete: true', () => { - expect( - setup({ - access: { read: false, create: false, update: false, delete: true }, - ...extraConfig, - }) - .getGqlMutations({ schemaName }) - .map(normalise) - ).toEqual( - [ - `""" Delete a single Test item by ID. """ deleteTest(id: ID!): Test`, - `""" Delete multiple Test items by ID. """ deleteTests(ids: [ID!]): [Test]`, - ].map(normalise) - ); - }); -}); - -test('checkFieldAccess', async () => { - const list = setup(); - list.checkFieldAccess( - 'read', - [{ existingItem: {}, data: { name: 'a', email: 'a@example.com' } }], - context, - { - gqlName: 'testing', - } - ); - await expect( - list.checkFieldAccess( - 'read', - [{ existingItem: { makeFalse: true }, data: { name: 'a', email: 'a@example.com' } }], - context, - { gqlName: '' } - ) - ).rejects.toThrow(AccessDeniedError); - let thrownError; - try { - await list.checkFieldAccess( - 'read', - [{ existingItem: { makeFalse: true }, data: { name: 'a', email: 'a@example.com' } }], - context, - { gqlName: 'testing', extra: 1 } - ); - } catch (error) { - thrownError = error; - } - expect(thrownError.data).toEqual({ - restrictedFields: ['name'], - target: 'testing', - type: 'query', - }); - expect(thrownError.internalData).toEqual({ extra: 1 }); -}); - -test('checkListAccess', async () => { - const list = setup(); - const originalInput = {}; - await expect( - list.checkListAccess(context, originalInput, 'read', { gqlName: 'testing' }) - ).resolves.toEqual(true); - - const newContext = { - ...context, - getListAccessControlForUser: ( - access: any, - listKey: string, - originalInput: any, - operation: string - ) => operation === 'update', - }; - await expect( - list.checkListAccess(newContext, originalInput, 'update', { gqlName: 'testing' }) - ).resolves.toEqual(true); - await expect( - list.checkListAccess(newContext, originalInput, 'read', { gqlName: 'testing' }) - ).rejects.toThrow(AccessDeniedError); -}); - -test('getAccessControlledItem', async () => { - const list = setup(); - expect( - await list.getAccessControlledItem(1, true, { context, operation: 'read', gqlName: 'testing' }) - ).toEqual({ - name: 'b', - email: 'b@example.com', - id: 1, - }); - await expect( - list.getAccessControlledItem(10, true, { context, operation: 'read', gqlName: 'testing' }) - ).rejects.toThrow(AccessDeniedError); - - expect( - await list.getAccessControlledItem( - 1, - { id: 1 }, - { context, operation: 'read', gqlName: 'testing' } - ) - ).toEqual({ - name: 'b', - email: 'b@example.com', - id: 1, - }); - await expect( - list.getAccessControlledItem(1, { id: 2 }, { context, operation: 'read', gqlName: 'testing' }) - ).rejects.toThrow(AccessDeniedError); - - expect( - await list.getAccessControlledItem( - 1, - { id_not: 2 }, - { context, operation: 'read', gqlName: 'testing' } - ) - ).toEqual({ - name: 'b', - email: 'b@example.com', - id: 1, - }); - await expect( - list.getAccessControlledItem( - 1, - { id_not: 1 }, - { context, operation: 'read', gqlName: 'testing' } - ) - ).rejects.toThrow(AccessDeniedError); - - expect( - await list.getAccessControlledItem( - 1, - { id_in: [1, 2] }, - { context, operation: 'read', gqlName: 'testing' } - ) - ).toEqual({ - name: 'b', - email: 'b@example.com', - id: 1, - }); - await expect( - list.getAccessControlledItem( - 1, - { id_in: [2, 3] }, - { context, operation: 'read', gqlName: 'testing' } - ) - ).rejects.toThrow(AccessDeniedError); - - expect( - await list.getAccessControlledItem( - 1, - { id_not_in: [2, 3] }, - { context, operation: 'read', gqlName: 'testing' } - ) - ).toEqual({ - name: 'b', - email: 'b@example.com', - id: 1, - }); - await expect( - list.getAccessControlledItem( - 1, - { id_not_in: [1, 2] }, - { context, operation: 'read', gqlName: 'testing' } - ) - ).rejects.toThrow(AccessDeniedError); -}); - -test('getAccessControlledItems', async () => { - const list = setup(); - expect(await list.getAccessControlledItems([], true)).toEqual([]); - expect(await list.getAccessControlledItems([1, 2], true)).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - { name: 'c', email: 'c@example.com', id: 2 }, - ]); - expect(await list.getAccessControlledItems([1, 2, 1, 2], true)).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - { name: 'c', email: 'c@example.com', id: 2 }, - ]); - - expect(await list.getAccessControlledItems([1, 2], { id: 1 })).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - ]); - expect(await list.getAccessControlledItems([1, 2], { id: 3 })).toEqual([]); - - expect(await list.getAccessControlledItems([1, 2], { id_in: [1, 2, 3] })).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - { name: 'c', email: 'c@example.com', id: 2 }, - ]); - expect(await list.getAccessControlledItems([1, 2], { id_in: [2, 3] })).toEqual([ - { name: 'c', email: 'c@example.com', id: 2 }, - ]); - expect(await list.getAccessControlledItems([1, 2], { id_in: [3, 4] })).toEqual([]); - - expect(await list.getAccessControlledItems([1, 2], { id_not: 2 })).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - ]); - expect(await list.getAccessControlledItems([1, 2], { id_not: 3 })).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - { name: 'c', email: 'c@example.com', id: 2 }, - ]); - - expect(await list.getAccessControlledItems([1, 2], { id_not_in: [1, 2, 3] })).toEqual([]); - expect(await list.getAccessControlledItems([1, 2], { id_not_in: [2, 3] })).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - ]); - expect(await list.getAccessControlledItems([1, 2], { id_not_in: [3, 4] })).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - { name: 'c', email: 'c@example.com', id: 2 }, - ]); -}); - -test(`gqlQueryResolvers`, () => { - const schemaName = 'public'; - const resolvers = setup({ access: true }).gqlQueryResolvers({ schemaName }); - expect(resolvers['allTests']).toBeInstanceOf(Function); // listQueryName - expect(resolvers['_allTestsMeta']).toBeInstanceOf(Function); // listQueryMetaName - expect(resolvers['Test']).toBeInstanceOf(Function); // itemQueryName - - const resolvers2 = setup({ access: false }).gqlQueryResolvers({ schemaName }); - expect(resolvers2['allTests']).toBe(undefined); // listQueryName - expect(resolvers2['_allTestsMeta']).toBe(undefined); // listQueryMetaName - expect(resolvers2['Test']).toBe(undefined); // itemQueryName -}); - -test('listQuery', async () => { - const list = setup(); - expect(await list.listQuery({ where: { id: 1 } }, context, 'testing', undefined)).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - ]); -}); - -test('listQueryMeta', async () => { - const list = setup(); - expect( - await (await list.listQueryMeta({ where: { id: 1 } }, context, 'testing', undefined)).getCount() - ).toEqual(1); - expect( - await ( - await list.listQueryMeta({ where: { id_in: [1, 2] } }, context, 'testing', undefined) - ).getCount() - ).toEqual(2); -}); - -test('itemQuery', async () => { - const list = setup(); - expect(await list.itemQuery({ where: { id: '0' } }, context)).toEqual({ - name: 'a', - email: 'a@example.com', - id: 0, - }); - await expect(list.itemQuery({ where: { id: '4' } }, context)).rejects.toThrow(AccessDeniedError); -}); - -describe(`gqlMutationResolvers`, () => { - const schemaName = 'public'; - let resolvers; - test('access: true', () => { - resolvers = setup({ access: true }).gqlMutationResolvers({ schemaName }); - expect(resolvers['createTest']).toBeInstanceOf(Function); - expect(resolvers['updateTest']).toBeInstanceOf(Function); - expect(resolvers['deleteTest']).toBeInstanceOf(Function); - expect(resolvers['deleteTests']).toBeInstanceOf(Function); - }); - test('access: false', () => { - resolvers = setup({ access: false }).gqlMutationResolvers({ schemaName }); - expect(resolvers['authenticateTestWithPassword']).toBe(undefined); - expect(resolvers['unauthenticateTest']).toBe(undefined); - expect(resolvers['updateAuthenticatedTest']).toBe(undefined); - }); - test('read: true', () => { - resolvers = setup({ - access: { read: true, create: false, update: false, delete: false }, - }).gqlMutationResolvers({ schemaName }); - }); - test('create: true', () => { - resolvers = setup({ - access: { read: false, create: true, update: false, delete: false }, - }).gqlMutationResolvers({ schemaName }); - expect(resolvers['createTest']).toBeInstanceOf(Function); - expect(resolvers['updateTest']).toBe(undefined); - expect(resolvers['deleteTest']).toBe(undefined); - expect(resolvers['deleteTests']).toBe(undefined); - }); - test('update: true', () => { - resolvers = setup({ - access: { read: false, create: false, update: true, delete: false }, - }).gqlMutationResolvers({ schemaName }); - expect(resolvers['createTest']).toBe(undefined); - expect(resolvers['updateTest']).toBeInstanceOf(Function); - expect(resolvers['deleteTest']).toBe(undefined); - expect(resolvers['deleteTests']).toBe(undefined); - }); - test('delete: true', () => { - resolvers = setup({ - access: { read: false, create: false, update: false, delete: true }, - }).gqlMutationResolvers({ schemaName }); - expect(resolvers['createTest']).toBe(undefined); - expect(resolvers['updateTest']).toBe(undefined); - expect(resolvers['deleteTest']).toBeInstanceOf(Function); - expect(resolvers['deleteTests']).toBeInstanceOf(Function); - }); -}); - -test('createMutation', async () => { - const list = setup(); - const result = await list.createMutation({ name: 'test', email: 'test@example.com' }, context); - expect(result).toEqual({ name: 'test', email: 'test@example.com', index: 3 }); -}); - -test('createManyMutation', async () => { - const list = setup(); - const result = await Promise.all( - await list.createManyMutation( - [ - { data: { name: 'test1', email: 'test1@example.com' } }, - { data: { name: 'test2', email: 'test2@example.com' } }, - ], - context - ) - ); - expect(result).toEqual([ - { name: 'test1', email: 'test1@example.com', index: 3 }, - { name: 'test2', email: 'test2@example.com', index: 4 }, - ]); -}); - -test('updateMutation', async () => { - const list = setup(); - const result = await list.updateMutation( - 1, - { name: 'update', email: 'update@example.com' }, - context - ); - expect(result).toEqual({ name: 'update', email: 'update@example.com', id: 1 }); -}); - -test('updateManyMutation', async () => { - const list = setup(); - const result = await Promise.all( - await list.updateManyMutation( - [ - { id: 1, data: { name: 'update1', email: 'update1@example.com' } }, - { id: 2, data: { email: 'update2@example.com' } }, - ], - context - ) - ); - expect(result).toEqual([ - { name: 'update1', email: 'update1@example.com', id: 1 }, - { name: 'c', email: 'update2@example.com', id: 2 }, - ]); -}); - -test('deleteMutation', async () => { - const list = setup(); - const result = await list.deleteMutation(1, context); - expect(result).toEqual({ name: 'b', email: 'b@example.com', id: 1 }); -}); - -test('deleteManyMutation', async () => { - const list = setup(); - const result = await Promise.all(await list.deleteManyMutation([1, 2], context)); - expect(result).toEqual([ - { name: 'b', email: 'b@example.com', id: 1 }, - { name: 'c', email: 'c@example.com', id: 2 }, - ]); -}); - -describe('List Hooks', () => { - describe('change mutation', () => { - test('provides the expected list API', () => { - return Promise.all( - [ - (list: List) => list.createMutation({ name: 'test', email: 'test@example.com' }, context), - (list: List) => - list.updateMutation(1, { name: 'update', email: 'update@example.com' }, context), - ].map(async action => { - const hooks = { - resolveInput: jest.fn(({ resolvedData }) => resolvedData), - validateInput: jest.fn(), - beforeChange: jest.fn(), - afterChange: jest.fn(), - }; - const list = setup({ hooks }); - await action(list); - - Object.keys(hooks).forEach(hook => { - // @ts-ignore - expect(hooks[hook]).toHaveBeenCalledWith(expect.objectContaining({})); - }); - }) - ); - }); - }); - - describe('delete mutation', () => { - test('provides the expected list API', async () => { - const hooks = { - validateDelete: jest.fn(), - beforeDelete: jest.fn(), - afterDelete: jest.fn(), - }; - const list = setup({ hooks }); - await list.deleteMutation(1, context); - - Object.keys(hooks).forEach(hook => { - // @ts-ignore - expect(hooks[hook]).toHaveBeenCalledWith(expect.objectContaining({})); - }); - }); - }); -}); diff --git a/packages-next/keystone/src/lib/core/types-for-lists.ts b/packages-next/keystone/src/lib/core/types-for-lists.ts new file mode 100644 index 00000000000..670c00d2cae --- /dev/null +++ b/packages-next/keystone/src/lib/core/types-for-lists.ts @@ -0,0 +1,384 @@ +import { + schema, + ItemRootValue, + TypesForList, + getGqlNames, + NextFieldType, + CacheHint, + BaseGeneratedListTypes, + ListInfo, + ListHooks, + KeystoneConfig, + DatabaseProvider, + FindManyArgs, + orderDirectionEnum, + CacheHintArgs, +} from '@keystone-next/types'; +import { FieldHooks } from '@keystone-next/types/src/config/hooks'; +import { GraphQLEnumType } from 'graphql'; +import { + ResolvedFieldAccessControl, + ResolvedListAccessControl, + parseListAccessControl, + parseFieldAccessControl, +} from './access-control'; +import { getNamesFromList } from './utils'; +import { ResolvedDBField, resolveRelationships } from './resolve-relationships'; +import { InputFilter, PrismaFilter, resolveWhereInput } from './where-inputs'; +import { outputTypeField } from './queries/output-field'; +import { assertFieldsValid } from './field-assertions'; + +export type InitialisedField = Omit & { + dbField: ResolvedDBField; + access: ResolvedFieldAccessControl; + hooks: FieldHooks; +}; + +export type InitialisedList = { + fields: Record; + /** This will include the opposites to one-sided relationships */ + resolvedDbFields: Record; + pluralGraphQLName: string; + filterImpls: Record PrismaFilter>; + types: TypesForList; + access: ResolvedListAccessControl; + hooks: ListHooks; + adminUILabels: { label: string; singular: string; plural: string; path: string }; + applySearchField: (filter: PrismaFilter, search: string | null | undefined) => PrismaFilter; + cacheHint: ((args: CacheHintArgs) => CacheHint) | undefined; + maxResults: number; + listKey: string; + lists: Record; +}; + +export function initialiseLists( + listsConfig: KeystoneConfig['lists'], + provider: DatabaseProvider +): Record { + const listInfos: Record = {}; + for (const [listKey, listConfig] of Object.entries(listsConfig)) { + const names = getGqlNames({ + listKey, + pluralGraphQLName: getNamesFromList(listKey, listConfig).pluralGraphQLName, + }); + + let output = schema.object()({ + name: names.outputTypeName, + description: ' A keystone list', + fields: () => { + const { fields } = lists[listKey]; + return { + ...Object.fromEntries( + Object.entries(fields).flatMap(([fieldPath, field]) => { + if (field.access.read === false) return []; + return [ + [fieldPath, field.output] as const, + ...Object.entries(field.extraOutputFields || {}), + ].map(([outputTypeFieldName, outputField]) => { + return [ + outputTypeFieldName, + outputTypeField( + outputField, + field.dbField, + field.graphql?.cacheHint, + field.access.read, + listKey, + fieldPath, + lists + ), + ]; + }); + }) + ), + }; + }, + }); + + const uniqueWhere = schema.inputObject({ + name: names.whereUniqueInputName, + fields: { + id: schema.arg({ type: schema.nonNull(schema.ID) }), + }, + }); + + const where: TypesForList['where'] = schema.inputObject({ + name: names.whereInputName, + fields: () => { + const { fields } = lists[listKey]; + return Object.assign( + { + AND: schema.arg({ + type: schema.list(schema.nonNull(where)), + }), + OR: schema.arg({ + type: schema.list(schema.nonNull(where)), + }), + }, + ...Object.values(fields).map(field => + field.access.read === false ? {} : field.__legacy?.filters?.fields ?? {} + ) + ); + }, + }); + + const create = schema.inputObject({ + name: names.createInputName, + fields: () => { + const { fields } = lists[listKey]; + return Object.fromEntries( + Object.entries(fields).flatMap(([key, field]) => { + if (!field.input?.create?.arg || field.access.create === false) return []; + return [[key, field.input.create.arg]] as const; + }) + ); + }, + }); + + const update = schema.inputObject({ + name: names.updateInputName, + fields: () => { + const { fields } = lists[listKey]; + return Object.fromEntries( + Object.entries(fields).flatMap(([key, field]) => { + if (!field.input?.update?.arg || field.access.update === false) return []; + return [[key, field.input.update.arg]] as const; + }) + ); + }, + }); + + const orderBy = schema.inputObject({ + name: names.listOrderName, + fields: () => { + const { fields } = lists[listKey]; + return Object.fromEntries( + Object.entries(fields).flatMap(([key, field]) => { + if (!field.input?.orderBy?.arg || field.access.read === false) return []; + return [[key, field.input.orderBy.arg]] as const; + }) + ); + }, + }); + + const findManyArgs: FindManyArgs = { + where: schema.arg({ + type: schema.nonNull(where), + defaultValue: {}, + }), + search: schema.arg({ + type: schema.String, + }), + sortBy: schema.arg({ + type: schema.list( + schema.nonNull( + schema.enum({ + name: names.listSortName, + values: schema.enumValues(['bad']), + }) + ) + ), + deprecationReason: 'sortBy has been deprecated in favour of orderBy', + }), + orderBy: schema.arg({ + type: schema.nonNull(schema.list(schema.nonNull(orderBy))), + defaultValue: [], + }), + // TODO: non-nullable when max results is specified in the list with the default of max results + first: schema.arg({ + type: schema.Int, + }), + skip: schema.arg({ + type: schema.nonNull(schema.Int), + defaultValue: 0, + }), + }; + + const relateToMany = schema.inputObject({ + name: names.relateToManyInputName, + fields: () => { + const list = lists[listKey]; + return { + ...(list.access.create !== false && { + create: schema.arg({ type: schema.list(create) }), + }), + connect: schema.arg({ type: schema.list(uniqueWhere) }), + disconnect: schema.arg({ type: schema.list(uniqueWhere) }), + disconnectAll: schema.arg({ type: schema.Boolean }), + }; + }, + }); + + const relateToOne = schema.inputObject({ + name: names.relateToOneInputName, + fields: () => { + const list = lists[listKey]; + + return { + ...(list.access.create !== false && { + create: schema.arg({ type: create }), + }), + connect: schema.arg({ type: uniqueWhere }), + disconnect: schema.arg({ type: uniqueWhere }), + disconnectAll: schema.arg({ type: schema.Boolean }), + }; + }, + }); + + listInfos[listKey] = { + types: { + output, + uniqueWhere, + where, + create, + orderBy, + update, + findManyArgs, + relateTo: { + many: { create: relateToMany, update: relateToMany }, + one: { create: relateToOne, update: relateToOne }, + }, + }, + }; + } + + const listsWithInitialisedFields = Object.fromEntries( + Object.entries(listsConfig).map(([listKey, list]) => [ + listKey, + { + fields: Object.fromEntries( + Object.entries(list.fields).map(([fieldKey, fieldFunc]) => { + if (typeof fieldFunc !== 'function') { + throw new Error(`The field at ${listKey}.${fieldKey} does not provide a function`); + } + return [fieldKey, fieldFunc({ fieldKey, listKey, lists: listInfos, provider })]; + }) + ), + ...getNamesFromList(listKey, list), + hooks: list.hooks, + access: list.access, + }, + ]) + ); + + const listsWithResolvedDBFields = resolveRelationships(listsWithInitialisedFields); + + const listsWithInitialisedFieldsAndResolvedDbFields = Object.fromEntries( + Object.entries(listsWithInitialisedFields).map(([listKey, list]) => { + let hasAnAccessibleCreateField = false; + let hasAnAccessibleUpdateField = false; + const fields = Object.fromEntries( + Object.entries(list.fields).map(([fieldKey, field]) => { + const access = parseFieldAccessControl(field.access); + if (access.create && field.input?.create?.arg) { + hasAnAccessibleCreateField = true; + } + if (access.update && field.input?.update) { + hasAnAccessibleUpdateField = true; + } + const dbField = listsWithResolvedDBFields[listKey].resolvedDbFields[fieldKey]; + return [fieldKey, { ...field, access, dbField, hooks: field.hooks ?? {} }]; + }) + ); + const access = parseListAccessControl(list.access); + if (!hasAnAccessibleCreateField) { + access.create = false; + } + if (!hasAnAccessibleUpdateField) { + access.update = false; + } + return [listKey, { ...list, access, fields }]; + }) + ); + + for (const [listKey, { fields, pluralGraphQLName }] of Object.entries( + listsWithInitialisedFieldsAndResolvedDbFields + )) { + assertFieldsValid({ listKey, fields }); + // this is quite a hack, we could do this in a better way if we "initialised" the fields twice, + // the first time to see if they have an orderBy and then the second time for real + // but that would be more complicated and this works + Object.assign( + listInfos[listKey].types.findManyArgs.sortBy.type.graphQLType.ofType.ofType, + new GraphQLEnumType({ + name: getGqlNames({ listKey, pluralGraphQLName }).listSortName, + values: Object.fromEntries( + Object.entries(fields).flatMap(([fieldKey, field]) => { + if ( + field.input?.orderBy?.arg.type === orderDirectionEnum && + field.input?.orderBy?.arg.defaultValue === undefined && + field.input?.orderBy?.resolve === undefined && + field.access.read !== false + ) { + return [ + [`${fieldKey}_ASC`, {}], + [`${fieldKey}_DESC`, {}], + ]; + } + return []; + }) + ), + }) + ); + } + + const lists: Record = {}; + + for (const [listKey, list] of Object.entries(listsWithInitialisedFieldsAndResolvedDbFields)) { + lists[listKey] = { + ...list, + ...listInfos[listKey], + ...listsWithResolvedDBFields[listKey], + hooks: list.hooks || {}, + filterImpls: Object.assign( + {}, + ...Object.values(list.fields).map(field => { + if (field.dbField.kind === 'relation' && field.__legacy?.filters) { + const foreignListKey = field.dbField.list; + return Object.fromEntries( + Object.entries(field.__legacy.filters.impls).map(([key, resolve]) => { + return [ + key, + (val: any) => + resolve(val, foreignListWhereInput => + resolveWhereInput(foreignListWhereInput, lists[foreignListKey]) + ), + ]; + }) + ); + } + return field.__legacy?.filters?.impls ?? {}; + }) + ), + applySearchField: (filter, search) => { + const searchFieldName = listsConfig[listKey].db?.searchField ?? 'name'; + const searchField = list.fields[searchFieldName]; + if (search != null && search !== '' && searchField) { + if (searchField.dbField.kind === 'scalar' && searchField.dbField.scalar === 'String') { + const mode = provider === 'sqlite' ? undefined : 'insensitive'; + filter = { + AND: [filter, { [searchFieldName]: { contains: search, mode } }], + }; + } else { + // Return no results + filter = { + AND: [filter, { [searchFieldName]: null }, { NOT: { [searchFieldName]: null } }], + }; + } + } + return filter; + }, + cacheHint: (() => { + const cacheHint = listsConfig[listKey].graphql?.cacheHint; + if (cacheHint === undefined) { + return undefined; + } + return typeof cacheHint === 'function' ? cacheHint : () => cacheHint; + })(), + maxResults: listsConfig[listKey].graphql?.queryLimits?.maxResults ?? Infinity, + listKey, + lists, + }; + } + + return lists; +} diff --git a/packages-next/keystone/src/lib/core/utils.ts b/packages-next/keystone/src/lib/core/utils.ts new file mode 100644 index 00000000000..e0ee3adbb65 --- /dev/null +++ b/packages-next/keystone/src/lib/core/utils.ts @@ -0,0 +1,178 @@ +import { ItemRootValue, KeystoneConfig } from '@keystone-next/types'; +import pluralize from 'pluralize'; +import { humanize } from '@keystone-next/utils-legacy'; +import { PrismaFilter, UniquePrismaFilter } from './where-inputs'; + +declare const prisma: unique symbol; + +// note prisma "promises" aren't really Promises, they have `then`, `catch` and `finally` but they don't start executation immediately +// so if you don't call .then/catch/finally/use it in $transaction, the operation will never happen +export type PrismaPromise = Promise & { [prisma]: true }; + +type PrismaModel = { + count: (arg: { + where?: PrismaFilter; + take?: number; + skip?: number; + // this is technically wrong because relation orderBy but we're not doing that yet so it's fine + orderBy?: readonly Record[]; + }) => PrismaPromise; + findMany: (arg: { + where?: PrismaFilter; + take?: number; + skip?: number; + // this is technically wrong because relation orderBy but we're not doing that yet so it's fine + orderBy?: readonly Record[]; + include?: Record; + select?: Record; + }) => PrismaPromise; + delete: (arg: { where: UniquePrismaFilter }) => PrismaPromise; + deleteMany: (arg: { where: PrismaFilter }) => PrismaPromise; + findUnique: (args: { + where: UniquePrismaFilter; + include?: Record; + select?: Record; + }) => PrismaPromise; + findFirst: (args: { + where: PrismaFilter; + include?: Record; + select?: Record; + }) => PrismaPromise; + create: (args: { + data: Record; + include?: Record; + select?: Record; + }) => PrismaPromise; + update: (args: { + where: UniquePrismaFilter; + data: Record; + include?: Record; + select?: Record; + }) => PrismaPromise; +}; + +export type UnwrapPromise> = TPromise extends Promise + ? T + : never; + +export type UnwrapPromises[]> = { + // unsure about this conditional + [Key in keyof T]: Key extends number ? UnwrapPromise : never; +}; + +// please do not make this type be the value of KeystoneContext['prisma'] +// this type is meant for generic usage, KeystoneContext should be generic over a PrismaClient +// and we should generate a KeystoneContext type in node_modules/.keystone/types which passes in the user's PrismaClient type +// so that users get right PrismaClient types specifically for their project +export type PrismaClient = { + $disconnect(): Promise; + $connect(): Promise; + $transaction[]>(promises: [...T]): UnwrapPromises; +} & Record; + +export function getPrismaModelForList(prismaClient: PrismaClient, listKey: string) { + return prismaClient[listKey[0].toLowerCase() + listKey.slice(1)]; +} + +// this is wrong +// all the things should be generic over the id type +// i don't want to deal with that right now though +declare const idTypeSymbol: unique symbol; + +export type IdType = { ___keystoneIdType: typeof idTypeSymbol; toString(): string }; + +export function applyFirstSkipToCount({ + count, + first, + skip, +}: { + count: number; + first: number | null | undefined; + skip: number | null | undefined; +}) { + if (skip !== undefined && skip !== null) { + count -= skip; + } + if (first !== undefined && first !== null) { + count = Math.min(count, first); + } + count = Math.max(0, count); // Don't want to go negative from a skip! + return count; +} + +// these aren't here out of thinking this is better syntax(i do not think it is), +// it's just because TS won't infer the arg is X bit +export const isFulfilled = (arg: PromiseSettledResult): arg is PromiseFulfilledResult => + arg.status === 'fulfilled'; +export const isRejected = (arg: PromiseSettledResult): arg is PromiseRejectedResult => + arg.status === 'rejected'; + +type Awaited = T extends PromiseLike ? U : T; + +export async function promiseAllRejectWithAllErrors( + promises: readonly [...T] +): Promise<{ [P in keyof T]: Awaited }> { + const results = await Promise.allSettled(promises); + if (!results.every(isFulfilled)) { + const errors = results.filter(isRejected).map(x => x.reason); + // AggregateError would be ideal here but it's not in Node 12 or 14 + // (also all of our error stuff is just meh. this whole thing is just to align with previous behaviour) + const error = new Error(errors[0].message || errors[0].toString()); + (error as any).errors = errors; + throw error; + } + + return results.map((x: any) => x.value) as any; +} + +export function getNamesFromList( + listKey: string, + { graphql /*plural, label, singular, path */ }: KeystoneConfig['lists'][string] +) { + const _label = /*label ||*/ keyToLabel(listKey); + const _singular = /*singular ||*/ pluralize.singular(_label); + const _plural = /*plural ||*/ pluralize.plural(_label); + + if (_plural === _label) { + throw new Error( + `Unable to use ${_label} as a List name - it has an ambiguous plural (${_plural}). Please choose another name for your list.` + ); + } + + const adminUILabels = { + // Fall back to the plural for the label if none was provided, not the autogenerated default from key + label: /*label ||*/ _plural, + singular: _singular, + plural: _plural, + path: /*path || */ labelToPath(_plural), + }; + + const pluralGraphQLName = graphql?.plural || labelToClass(_plural); + if (pluralGraphQLName === listKey) { + throw new Error( + `The list key and the plural must be different but the list key ${listKey} is the same as the ${listKey} plural GraphQL name` + ); + } + return { + pluralGraphQLName, + adminUILabels, + }; +} + +const keyToLabel = (str: string) => { + let label = humanize(str); + + // Retain the leading underscore for auxiliary lists + if (str[0] === '_') { + label = `_${label}`; + } + return label; +}; + +const labelToPath = (str: string) => str.split(' ').join('-').toLowerCase(); + +const labelToClass = (str: string) => str.replace(/\s+/g, ''); + +export function getDBFieldKeyForFieldOnMultiField(fieldKey: string, subField: string) { + return `${fieldKey}_${subField}`; +} diff --git a/packages-next/keystone/src/lib/core/where-inputs.ts b/packages-next/keystone/src/lib/core/where-inputs.ts new file mode 100644 index 00000000000..5fd4c3b33b2 --- /dev/null +++ b/packages-next/keystone/src/lib/core/where-inputs.ts @@ -0,0 +1,69 @@ +import { KeystoneContext } from '@keystone-next/types'; +import { InitialisedList } from './types-for-lists'; + +export type InputFilter = Record & { + _____?: 'input filter'; + AND?: InputFilter[]; + OR?: InputFilter[]; + NOT?: InputFilter[]; +}; +export type PrismaFilter = Record & { + _____?: 'prisma filter'; + AND?: PrismaFilter[] | PrismaFilter; + OR?: PrismaFilter[] | PrismaFilter; + NOT?: PrismaFilter[] | PrismaFilter; + // just so that if you pass an array to something expecting a PrismaFilter, you get an error + length?: undefined; + // so that if you pass a promise, you get an error + then?: undefined; +}; + +export type UniqueInputFilter = Record & { _____?: 'unique input filter' }; +export type UniquePrismaFilter = Record & { + _____?: 'unique prisma filter'; + // so that if you pass a promise, you get an error + then?: undefined; +}; + +export async function resolveUniqueWhereInput( + input: UniqueInputFilter, + fields: InitialisedList['fields'], + context: KeystoneContext +): Promise { + const inputKeys = Object.keys(input); + if (inputKeys.length !== 1) { + throw new Error( + `Exactly one key must be passed in a unique where input but ${inputKeys.length} keys were passed` + ); + } + const key = inputKeys[0]; + const val = input[key]; + if (val === null) { + throw new Error(`The unique value provided in a unique where input must not be null`); + } + const resolver = fields[key].input!.uniqueWhere!.resolve; + const resolvedVal = resolver ? await resolver(val, context) : val; + return { + [key]: resolvedVal, + }; +} + +export async function resolveWhereInput( + inputFilter: InputFilter, + list: InitialisedList +): Promise { + return { + AND: await Promise.all( + Object.entries(inputFilter).map(async ([fieldKey, value]) => { + if (fieldKey === 'OR' || fieldKey === 'AND') { + return { + [fieldKey]: await Promise.all( + value.map((value: any) => resolveWhereInput(value, list)) + ), + }; + } + return list.filterImpls[fieldKey](value); + }) + ), + }; +} diff --git a/packages-next/keystone/src/lib/createGraphQLSchema.ts b/packages-next/keystone/src/lib/createGraphQLSchema.ts index 693b203449c..0de192ec3d4 100644 --- a/packages-next/keystone/src/lib/createGraphQLSchema.ts +++ b/packages-next/keystone/src/lib/createGraphQLSchema.ts @@ -1,18 +1,17 @@ -import { makeExecutableSchema } from '@graphql-tools/schema'; -import type { KeystoneConfig, BaseKeystone } from '@keystone-next/types'; +import type { KeystoneConfig, AdminMetaRootVal } from '@keystone-next/types'; import { getAdminMetaSchema } from '../admin-ui/system'; import { sessionSchema } from '../session'; +import { InitialisedList } from './core/types-for-lists'; +import { getGraphQLSchema } from './core/graphql-schema'; +import { getDBProvider } from './createSystem'; export function createGraphQLSchema( config: KeystoneConfig, - keystone: BaseKeystone, - schemaName: 'public' | 'internal' = 'public' + lists: Record, + adminMeta: AdminMetaRootVal ) { // Start with the core keystone graphQL schema - let graphQLSchema = makeExecutableSchema({ - typeDefs: keystone.getTypeDefs({ schemaName }), - resolvers: keystone.getResolvers({ schemaName }), - }); + let graphQLSchema = getGraphQLSchema(lists, getDBProvider(config.db)); // Merge in the user defined graphQL API if (config.extendGraphqlSchema) { @@ -25,6 +24,7 @@ export function createGraphQLSchema( } // Merge in the admin-meta graphQL API - graphQLSchema = getAdminMetaSchema({ keystone, config, schema: graphQLSchema }); + graphQLSchema = getAdminMetaSchema({ adminMeta, config, graphQLSchema, lists }); + return graphQLSchema; } diff --git a/packages-next/keystone/src/lib/createKeystone.ts b/packages-next/keystone/src/lib/createKeystone.ts deleted file mode 100644 index 8953c1634b1..00000000000 --- a/packages-next/keystone/src/lib/createKeystone.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { PrismaAdapter } from '@keystone-next/adapter-prisma-legacy'; -import type { KeystoneConfig, BaseKeystone, DatabaseProvider } from '@keystone-next/types'; -import { Keystone } from './core/Keystone/index'; - -export function createKeystone( - config: KeystoneConfig, - provider: DatabaseProvider, - prismaClient?: any -) { - // Note: For backwards compatibility we may want to expose - // this as a public API so that users can start their transition process - // by using this pattern for creating their Keystone object before using - // it in their existing custom servers or original CLI systems. - const { db, graphql, lists } = config; - const adapter = new PrismaAdapter({ prismaClient, ...db, provider }); - // @ts-ignore The @types/keystonejs__keystone package has the wrong type for KeystoneOptions - const keystone: BaseKeystone = new Keystone({ - adapter, - queryLimits: graphql?.queryLimits, - // We call context.sudo() here to regenerate the `context` object *after* the keystone.connect() - // step. This ensures that context.prisma is correctly set up. - // @ts-ignore The @types/keystonejs__keystone package has the wrong type for KeystoneOptions - onConnect: (keystone, { context } = {}) => config.db.onConnect?.(context?.sudo()), - }); - - Object.entries(lists).forEach(([key, { fields, graphql, access, hooks, description, db }]) => { - keystone.createList(key, { - fields: Object.fromEntries( - Object.entries(fields).map(([key, { type, config }]: any) => [ - key, - { type, cacheHint: config.graphql?.cacheHint, ...config }, - ]) - ), - access, - queryLimits: graphql?.queryLimits, - schemaDoc: graphql?.description ?? description, - listQueryName: graphql?.listQueryName, - itemQueryName: graphql?.itemQueryName, - hooks, - adapterConfig: db, - cacheHint: graphql?.cacheHint, - // FIXME: Unsupported options: Need to work which of these we want to support with backwards - // compatibility options. - // adminDoc - // label - // singular - // plural - // path - // plugins - }); - }); - - return keystone; -} diff --git a/packages-next/keystone/src/lib/createSystem.ts b/packages-next/keystone/src/lib/createSystem.ts index f4737002e05..1a0a6114dcf 100644 --- a/packages-next/keystone/src/lib/createSystem.ts +++ b/packages-next/keystone/src/lib/createSystem.ts @@ -1,8 +1,9 @@ -import { KeystoneConfig, DatabaseProvider, getGqlNames } from '@keystone-next/types'; +import { FieldData, KeystoneConfig, DatabaseProvider, getGqlNames } from '@keystone-next/types'; +import { createAdminMeta } from '../admin-ui/system/createAdminMeta'; import { createGraphQLSchema } from './createGraphQLSchema'; import { makeCreateContext } from './context/createContext'; -import { createKeystone } from './createKeystone'; +import { initialiseLists } from './core/types-for-lists'; export function getDBProvider(db: KeystoneConfig['db']): DatabaseProvider { if (db.adapter === 'prisma_postgresql' || db.provider === 'postgresql') { @@ -16,38 +17,83 @@ export function getDBProvider(db: KeystoneConfig['db']): DatabaseProvider { } } -export function createSystem(config: KeystoneConfig, prismaClient?: any) { - const provider = getDBProvider(config.db); +function getInternalGraphQLSchema(config: KeystoneConfig, provider: DatabaseProvider) { + const transformedConfig: KeystoneConfig = { + ...config, + lists: Object.fromEntries( + Object.entries(config.lists).map(([listKey, list]) => { + return [ + listKey, + { + ...list, + access: true, + fields: Object.fromEntries( + Object.entries(list.fields).map(([fieldKey, field]) => { + return [ + fieldKey, + (data: FieldData) => { + return { ...field(data), access: true }; + }, + ]; + }) + ), + }, + ]; + }) + ), + }; + const lists = initialiseLists(transformedConfig.lists, provider); + const adminMeta = createAdminMeta(transformedConfig, lists); + return createGraphQLSchema(transformedConfig, lists, adminMeta); +} - const keystone = createKeystone(config, provider, prismaClient); +export function createSystem(config: KeystoneConfig) { + const provider = getDBProvider(config.db); + const lists = initialiseLists(config.lists, provider); - // Convert the Keystone lists into just what's needed by the createContext function - // This will in soon evolve into the code in the next-fields effort. - const gqlNamesByList = Object.fromEntries( - Object.entries(keystone.lists).map(([listKey, list]) => { - return [ - listKey, - getGqlNames({ - listKey, - itemQueryName: list.gqlNames.itemQueryName, - listQueryName: list.gqlNames.listQueryName.slice(3), - }), - ]; - }) - ); + const adminMeta = createAdminMeta(config, lists); - const graphQLSchema = createGraphQLSchema(config, keystone, 'public'); + const graphQLSchema = createGraphQLSchema(config, lists, adminMeta); - const internalSchema = createGraphQLSchema(config, keystone, 'internal'); + const internalGraphQLSchema = getInternalGraphQLSchema(config, provider); - const createContext = makeCreateContext({ - keystone, + return { graphQLSchema, - internalSchema, - config, - prismaClient, - gqlNamesByList, - }); + adminMeta, + getKeystone: (PrismaClient: any) => { + const prismaClient = new PrismaClient({ + log: config.db.enableLogging && ['query'], + datasources: { [provider]: { url: config.db.url } }, + }); + prismaClient.$on('beforeExit', async () => { + // Prisma is failing to properly clean up its child processes + // https://github.com/keystonejs/keystone/issues/5477 + // We explicitly send a SIGINT signal to the prisma child process on exit + // to ensure that the process is cleaned up appropriately. + prismaClient._engine.child.kill('SIGINT'); + }); + + const createContext = makeCreateContext({ + graphQLSchema, + internalSchema: internalGraphQLSchema, + config, + prismaClient, + gqlNamesByList: Object.fromEntries( + Object.entries(lists).map(([listKey, list]) => [listKey, getGqlNames(list)]) + ), + }); - return { keystone, graphQLSchema, createContext }; + return { + async connect() { + await prismaClient.$connect(); + const context = createContext({ skipAccessControl: true, schemaName: 'internal' }); + await config.db.onConnect?.(context); + }, + async disconnect() { + await prismaClient.$disconnect(); + }, + createContext, + }; + }, + }; } diff --git a/packages-next/keystone/src/lib/schema-type-printer.tsx b/packages-next/keystone/src/lib/schema-type-printer.tsx index a04cefe7140..9f59d3bafad 100644 --- a/packages-next/keystone/src/lib/schema-type-printer.tsx +++ b/packages-next/keystone/src/lib/schema-type-printer.tsx @@ -1,3 +1,4 @@ +import { getGqlNames } from '@keystone-next/types'; import { GraphQLSchema, parse, @@ -12,7 +13,7 @@ import { InputValueDefinitionNode, } from 'graphql'; import prettier from 'prettier'; -import type { BaseKeystone } from '@keystone-next/types'; +import { InitialisedList } from './core/types-for-lists'; let printEnumTypeDefinition = (node: EnumTypeDefinitionNode) => { return `export type ${node.name.value} =\n${node @@ -73,8 +74,8 @@ function printInputTypesFromSchema( export function printGeneratedTypes( printedSchema: string, - keystone: BaseKeystone, - graphQLSchema: GraphQLSchema + graphQLSchema: GraphQLSchema, + lists: Record ) { let scalars = { ID: 'string', @@ -121,26 +122,17 @@ export function printGeneratedTypes( return types + '}'; }; - for (const listKey in keystone.lists) { - const list = keystone.lists[listKey]; - let backingTypes = '{\n'; - for (const field of list.fields) { - for (const [key, { optional, type }] of Object.entries(field.getBackingTypes()) as any) { - backingTypes += `readonly ${JSON.stringify(key)}${optional ? '?' : ''}: ${type};\n`; - } - } - backingTypes += '}'; - - const { gqlNames } = list; + for (const [listKey, list] of Object.entries(lists)) { + const gqlNames = getGqlNames(list); let listTypeInfoName = `${listKey}ListTypeInfo`; const listQuery = queryNodeFieldsByName[gqlNames.listQueryName]; printedTypes += ` export type ${listTypeInfoName} = { key: ${JSON.stringify(listKey)}; - fields: ${Object.keys(list.fieldsByPath) + fields: ${Object.keys(list.fields) .map(x => JSON.stringify(x)) .join('|')} - backing: ${backingTypes}; + backing: import(".prisma/client").${listKey}; inputs: { where: ${gqlNames.whereInputName}; create: ${gqlNames.createInputName}; diff --git a/packages-next/keystone/src/lib/server/createApolloServer.ts b/packages-next/keystone/src/lib/server/createApolloServer.ts index 11b241c8199..2226778d4a2 100644 --- a/packages-next/keystone/src/lib/server/createApolloServer.ts +++ b/packages-next/keystone/src/lib/server/createApolloServer.ts @@ -5,7 +5,7 @@ import { ApolloServer as ApolloServerExpress } from 'apollo-server-express'; import type { Config } from 'apollo-server-express'; import type { CreateContext, SessionStrategy } from '@keystone-next/types'; import { createSessionContext } from '../../session'; -import { formatError } from '../core/Keystone/format-error'; +import { formatError } from './format-error'; export const createApolloServerMicro = ({ graphQLSchema, diff --git a/packages-next/keystone/src/lib/core/Keystone/format-error.ts b/packages-next/keystone/src/lib/server/format-error.ts similarity index 100% rename from packages-next/keystone/src/lib/core/Keystone/format-error.ts rename to packages-next/keystone/src/lib/server/format-error.ts diff --git a/packages-next/keystone/src/schema/schema.ts b/packages-next/keystone/src/schema/schema.ts index c7d7856779a..c3694e83b19 100644 --- a/packages-next/keystone/src/schema/schema.ts +++ b/packages-next/keystone/src/schema/schema.ts @@ -21,7 +21,7 @@ export function config(config: KeystoneConfig) { export function list>( config: ListConfig -) { +): ListConfig { return config; } diff --git a/packages-next/keystone/src/scripts/build/build.ts b/packages-next/keystone/src/scripts/build/build.ts index deef441ee31..32ea33f7689 100644 --- a/packages-next/keystone/src/scripts/build/build.ts +++ b/packages-next/keystone/src/scripts/build/build.ts @@ -88,7 +88,7 @@ const reexportKeystoneConfig = async (cwd: string, isDisabled?: boolean) => { export async function build(cwd: string) { const config = initConfig(requireSource(getConfigPath(cwd)).default); - const { keystone, graphQLSchema } = createSystem(config); + const { graphQLSchema, adminMeta } = createSystem(config); await validateCommittedArtifacts(graphQLSchema, config, cwd); @@ -101,7 +101,7 @@ export async function build(cwd: string) { console.log('✨ Skipping Admin UI code generation'); } else { console.log('✨ Generating Admin UI code'); - await generateAdminUI(config, graphQLSchema, keystone, getAdminPath(cwd)); + await generateAdminUI(config, graphQLSchema, adminMeta, getAdminPath(cwd)); } console.log('✨ Generating Keystone config code'); diff --git a/packages-next/keystone/src/scripts/run/dev.ts b/packages-next/keystone/src/scripts/run/dev.ts index eb97cc5fa52..63b52aa0fae 100644 --- a/packages-next/keystone/src/scripts/run/dev.ts +++ b/packages-next/keystone/src/scripts/run/dev.ts @@ -31,48 +31,47 @@ export const dev = async (cwd: string, shouldDropDatabase: boolean) => { const config = initConfig(requireSource(getConfigPath(cwd)).default); const initKeystone = async () => { - { - const { graphQLSchema } = createSystem(config); - - console.log('✨ Generating GraphQL and Prisma schemas'); - const prismaSchema = (await generateCommittedArtifacts(graphQLSchema, config, cwd)).prisma; - await generateNodeModulesArtifacts(graphQLSchema, config, cwd); - - if (config.db.useMigrations) { - await devMigrations( - config.db.url, - prismaSchema, - getSchemaPaths(cwd).prisma, - shouldDropDatabase - ); - } else { - await pushPrismaSchemaToDatabase( - config.db.url, - prismaSchema, - getSchemaPaths(cwd).prisma, - shouldDropDatabase - ); - } + const { graphQLSchema, adminMeta, getKeystone } = createSystem(config); + + console.log('✨ Generating GraphQL and Prisma schemas'); + const prismaSchema = (await generateCommittedArtifacts(graphQLSchema, config, cwd)).prisma; + await generateNodeModulesArtifacts(graphQLSchema, config, cwd); + + if (config.db.useMigrations) { + await devMigrations( + config.db.url, + prismaSchema, + getSchemaPaths(cwd).prisma, + shouldDropDatabase + ); + } else { + await pushPrismaSchemaToDatabase( + config.db.url, + prismaSchema, + getSchemaPaths(cwd).prisma, + shouldDropDatabase + ); } + const prismaClient = requirePrismaClient(cwd); - const { keystone, graphQLSchema, createContext } = createSystem(config, prismaClient); + const keystone = getKeystone(prismaClient); console.log('✨ Connecting to the database'); - await keystone.connect({ context: createContext().sudo() }); + await keystone.connect(); disconnect = () => keystone.disconnect(); if (config.ui?.isDisabled) { console.log('✨ Skipping Admin UI code generation'); } else { console.log('✨ Generating Admin UI code'); - await generateAdminUI(config, graphQLSchema, keystone, getAdminPath(cwd)); + await generateAdminUI(config, graphQLSchema, adminMeta, getAdminPath(cwd)); } console.log('✨ Creating server'); expressServer = await createExpressServer( config, graphQLSchema, - createContext, + keystone.createContext, true, getAdminPath(cwd) ); diff --git a/packages-next/keystone/src/scripts/run/start.ts b/packages-next/keystone/src/scripts/run/start.ts index 776332a3c75..5a74a9fa559 100644 --- a/packages-next/keystone/src/scripts/run/start.ts +++ b/packages-next/keystone/src/scripts/run/start.ts @@ -17,16 +17,20 @@ export const start = async (cwd: string) => { throw new ExitError(1); } const config = initConfig(require(apiFile).config); - const { keystone, graphQLSchema, createContext } = createSystem(config, requirePrismaClient(cwd)); + const { getKeystone, graphQLSchema } = createSystem(config); + + const prismaClient = requirePrismaClient(cwd); + + const keystone = getKeystone(prismaClient); console.log('✨ Connecting to the database'); - await keystone.connect({ context: createContext().sudo() }); + await keystone.connect(); console.log('✨ Creating server'); const server = await createExpressServer( config, graphQLSchema, - createContext, + keystone.createContext, false, getAdminPath(cwd) ); diff --git a/packages-next/keystone/src/scripts/tests/__snapshots__/artifacts.test.ts.snap b/packages-next/keystone/src/scripts/tests/__snapshots__/artifacts.test.ts.snap index 206f66a1d20..9f15b3e34e4 100644 --- a/packages-next/keystone/src/scripts/tests/__snapshots__/artifacts.test.ts.snap +++ b/packages-next/keystone/src/scripts/tests/__snapshots__/artifacts.test.ts.snap @@ -74,10 +74,7 @@ export type KeystoneAdminUISortDirection = 'ASC' | 'DESC'; export type TodoListTypeInfo = { key: 'Todo'; fields: 'id' | 'title'; - backing: { - readonly id: string; - readonly title?: string | null; - }; + backing: import('.prisma/client').Todo; inputs: { where: TodoWhereInput; create: TodoCreateInput; diff --git a/packages-next/keystone/src/scripts/tests/fixtures/basic-project/schema.graphql b/packages-next/keystone/src/scripts/tests/fixtures/basic-project/schema.graphql index 345df62e1c6..f362ab9d89f 100644 --- a/packages-next/keystone/src/scripts/tests/fixtures/basic-project/schema.graphql +++ b/packages-next/keystone/src/scripts/tests/fixtures/basic-project/schema.graphql @@ -107,11 +107,6 @@ type Mutation { deleteTodos(ids: [ID!]): [Todo] } -""" -The `Upload` scalar type represents a file upload. -""" -scalar Upload - type Query { """ Search for all Todo items which match the where clause. diff --git a/packages-next/keystone/src/session/index.ts b/packages-next/keystone/src/session/index.ts index 2ff29e0a82c..f5527fbd1f5 100644 --- a/packages-next/keystone/src/session/index.ts +++ b/packages-next/keystone/src/session/index.ts @@ -1,6 +1,5 @@ import { IncomingMessage, ServerResponse } from 'http'; -import { mergeSchemas } from '@graphql-tools/merge'; -import { GraphQLSchema } from 'graphql'; +import { GraphQLObjectType, GraphQLSchema } from 'graphql'; import * as cookie from 'cookie'; import Iron from '@hapi/iron'; import { @@ -9,11 +8,10 @@ import { SessionStoreFunction, SessionContext, CreateContext, - KeystoneContext, + schema, } from '@keystone-next/types'; // uid-safe is what express-session uses so let's just use it import { sync as uid } from 'uid-safe'; -import { gql } from '../schema'; function generateSessionId() { return uid(24); @@ -192,22 +190,32 @@ export async function createSessionContext( } export function sessionSchema(graphQLSchema: GraphQLSchema) { - return mergeSchemas({ - schemas: [graphQLSchema], - typeDefs: gql` - type Mutation { - endSession: Boolean! + const schemaConfig = graphQLSchema.toConfig(); + const mutationTypeConfig = graphQLSchema.getMutationType()!.toConfig(); + const endSessionField = schema.field({ + type: schema.nonNull(schema.Boolean), + async resolve(rootVal, args, context) { + if (context.endSession) { + await context.endSession(); } - `, - resolvers: { - Mutation: { - async endSession(rootVal, args, context: KeystoneContext) { - if (context.endSession) { - await context.endSession(); - } - return true; - }, - }, + return true; }, }); + const mutationType = new GraphQLObjectType({ + ...mutationTypeConfig, + fields: () => ({ + ...(typeof mutationTypeConfig.fields === 'function' + ? mutationTypeConfig.fields() + : mutationTypeConfig.fields), + endSession: { + ...endSessionField, + type: endSessionField.type.graphQLType, + }, + }), + }); + return new GraphQLSchema({ + ...schemaConfig, + types: schemaConfig.types.map(x => (x.name === 'Mutation' ? mutationType : x)), + mutation: mutationType, + }); } diff --git a/packages-next/types/package.json b/packages-next/types/package.json index 96e360b2ed8..ce25bc25b4c 100644 --- a/packages-next/types/package.json +++ b/packages-next/types/package.json @@ -8,11 +8,14 @@ "node": "^12.20 || >= 14.13" }, "dependencies": { - "@keystone-next/adapter-prisma-legacy": "^8.0.0", + "@graphql-ts/schema": "0.1.1", "@keystone-next/fields": "^10.0.0", "apollo-server-types": "^0.9.0", "cors": "^2.8.5", - "graphql": "^15.5.0" + "decimal.js": "^10.2.1", + "graphql": "^15.5.0", + "graphql-type-json": "^0.3.2", + "graphql-upload": "^12.0.0" }, "repository": "https://github.com/keystonejs/keystone/tree/master/packages-next/types" } diff --git a/packages-next/types/src/admin-meta.ts b/packages-next/types/src/admin-meta.ts index f95c2f9f0e5..13360565b15 100644 --- a/packages-next/types/src/admin-meta.ts +++ b/packages-next/types/src/admin-meta.ts @@ -153,4 +153,5 @@ export type AdminMetaRootVal = { enableSessionItem: boolean; lists: Array; listsByKey: Record; + views: string[]; }; diff --git a/packages-next/types/src/base.ts b/packages-next/types/src/base.ts index 54132103942..bd0b54ffcde 100644 --- a/packages-next/types/src/base.ts +++ b/packages-next/types/src/base.ts @@ -1,137 +1,8 @@ -import { PrismaAdapter, PrismaListAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Implementation } from '@keystone-next/fields'; -import { Relationship } from '@keystone-next/fields/src/types/relationship/Implementation'; -import { DocumentNode } from 'graphql'; -import type { CacheHint } from 'apollo-cache-control'; -import type { KeystoneContext } from './context'; -import type { BaseGeneratedListTypes, GqlNames } from './utils'; +import { CreateContext } from './core'; -export type Rel = { - left: Relationship; - right?: Relationship; - cardinality?: 'N:N' | 'N:1' | '1:N' | '1:1'; - tableName?: string; - columnName?: string; - columnNames?: Record; -}; - -export type CacheHintArgs = { results: any; operationName: string; meta: boolean }; - -export type BaseListConfig = { - fields: Record; - access: any; - queryLimits?: { maxResults?: number }; - schemaDoc?: string; - adminDoc?: string; - listQueryName?: string; - itemQueryName?: string; - label?: string; - singular?: string; - plural?: string; - path?: string; - hooks?: Record; - adapterConfig?: { searchField?: string }; - cacheHint?: ((args: CacheHintArgs) => CacheHint) | CacheHint; -}; - -// TODO: This is only a partial typing of the core Keystone class. -// We should definitely invest some time into making this more correct. +// TODO: don't call this thing BaseKeystone export type BaseKeystone = { - lists: Record; - listsArray: BaseKeystoneList[]; - getListByKey: (key: string) => BaseKeystoneList | undefined; - onConnect: (keystone: BaseKeystone, args?: { context: KeystoneContext }) => Promise; - _listCRUDProvider: any; - _providers: any[]; - adapter: PrismaAdapter; - queryLimits: { maxTotalResults: number }; - - createList: (key: string, config: BaseListConfig) => BaseKeystoneList; - _consolidateRelationships: () => Rel[]; - connect: (args?: { context: KeystoneContext }) => Promise; + connect: () => Promise; disconnect: () => Promise; - getTypeDefs: (args: { schemaName: string }) => DocumentNode[]; - getResolvers: (args: { schemaName: string }) => Record; -}; - -// TODO: This needs to be reviewed and expanded -export type BaseKeystoneList = { - key: string; - fieldsByPath: Record>; - fields: Implementation[]; - adapter: PrismaListAdapter; - access: Record; - adminUILabels: { - label: string; - singular: string; - plural: string; - path: string; - }; - gqlNames: GqlNames; - - initFields: () => void; - listQuery( - args: BaseGeneratedListTypes['args']['listQuery'], - context: KeystoneContext, - gqlName?: string, - info?: any, - from?: any - ): Promise[]>; - listQueryMeta( - args: BaseGeneratedListTypes['args']['listQuery'], - context: KeystoneContext, - gqlName?: string, - info?: any, - from?: any - ): Promise<{ - getCount: () => Promise; - }>; - itemQuery( - args: { where: { id: string } }, - context: KeystoneContext, - gqlName?: string, - info?: any - ): Promise>; - createMutation( - data: Record, - context: KeystoneContext, - mutationState?: any - ): Promise>; - createManyMutation( - data: Record[], - context: KeystoneContext, - mutationState?: any - ): Promise[]>; - updateMutation( - id: string, - data: Record, - context: KeystoneContext, - mutationState?: any - ): Promise>; - updateManyMutation( - data: Record, - context: KeystoneContext, - mutationState?: any - ): Promise[]>; - deleteMutation( - id: string, - context: KeystoneContext, - mutationState?: any - ): Promise>; - deleteManyMutation( - ids: string[], - context: KeystoneContext, - mutationState?: any - ): Promise[]>; - getGraphqlFilterFragment: () => string[]; - - getGqlTypes: ({ schemaName }: { schemaName: string }) => string[]; - getGqlQueries: ({ schemaName }: { schemaName: string }) => string[]; - getGqlMutations: ({ schemaName }: { schemaName: string }) => string[]; - - gqlAuxFieldResolvers: ({ schemaName }: { schemaName: string }) => Record; - gqlFieldResolvers: ({ schemaName }: { schemaName: string }) => Record; - gqlAuxQueryResolvers: ({ schemaName }: { schemaName: string }) => Record; - gqlQueryResolvers: ({ schemaName }: { schemaName: string }) => Record; - gqlMutationResolvers: ({ schemaName }: { schemaName: string }) => Record; + createContext: CreateContext; }; diff --git a/packages-next/types/src/config/access-control.ts b/packages-next/types/src/config/access-control.ts index b8c9582cda1..60de4038fd7 100644 --- a/packages-next/types/src/config/access-control.ts +++ b/packages-next/types/src/config/access-control.ts @@ -5,8 +5,6 @@ type BaseAccessArgs = { session: any; listKey: string; context: KeystoneContext; - // idk if this is optional or not - gqlName?: string; }; type CreateAccessArgs = BaseAccessArgs & { @@ -14,41 +12,33 @@ type CreateAccessArgs = BaseA /** * The input passed in from the GraphQL API */ - originalInput?: - | GeneratedListTypes['inputs']['create'] - | readonly { readonly data: GeneratedListTypes['inputs']['create'] }[]; + originalInput: GeneratedListTypes['inputs']['create']; }; -type CreateAccessControl = +export type CreateAccessControl = | boolean | ((args: CreateAccessArgs) => MaybePromise); type ReadAccessArgs = BaseAccessArgs & { operation: 'read' }; -type ReadListAccessControl = +export type ReadListAccessControl = | boolean | GeneratedListTypes['inputs']['where'] | ((args: ReadAccessArgs) => MaybePromise); type UpdateAccessArgs = BaseAccessArgs & { /** - * The id being updated if a single item is being updated + * The id being updated */ - itemId?: string; - /** - * The ids being updated if many items are being updated - */ - itemIds?: string[]; + itemId: string; operation: 'update'; /** * The input passed in from the GraphQL API */ - originalInput?: - | GeneratedListTypes['inputs']['update'] - | readonly { readonly id: string; readonly data: GeneratedListTypes['inputs']['update'] }[]; + originalInput: GeneratedListTypes['inputs']['update']; }; -type UpdateListAccessControl = +export type UpdateListAccessControl = | boolean | GeneratedListTypes['inputs']['where'] | (( @@ -57,17 +47,13 @@ type UpdateListAccessControl type DeleteAccessArgs = BaseAccessArgs & { /** - * The id being deleted if a single item is being deleted - */ - itemId?: string; - /** - * The ids being deleted if many items are being deleted + * The id being deleted */ - itemIds?: string[]; + itemId: string; operation: 'delete'; }; -type DeleteListAccessControl = +export type DeleteListAccessControl = | boolean | GeneratedListTypes['inputs']['where'] | ((args: DeleteAccessArgs) => MaybePromise); @@ -85,23 +71,25 @@ export type ListAccessControl | CreateAccessArgs | UpdateAccessArgs | DeleteAccessArgs - ) => MaybePromise); + ) => MaybePromise) + | boolean; -type IndividualFieldAccessControl = boolean | ((args: Args) => MaybePromise); +export type IndividualFieldAccessControl = boolean | ((args: Args) => MaybePromise); type BaseFieldAccessArgs = { fieldKey: string; }; -type FieldCreateAccessArgs = +export type FieldCreateAccessArgs = CreateAccessArgs & BaseFieldAccessArgs; -type FieldReadAccessArgs = ReadAccessArgs & - BaseFieldAccessArgs & { - item: GeneratedListTypes['backing']; - }; +export type FieldReadAccessArgs = + ReadAccessArgs & + BaseFieldAccessArgs & { + item: GeneratedListTypes['backing']; + }; -type FieldUpdateAccessArgs = +export type FieldUpdateAccessArgs = UpdateAccessArgs & BaseFieldAccessArgs & { item: GeneratedListTypes['backing']; diff --git a/packages-next/types/src/config/fields.ts b/packages-next/types/src/config/fields.ts index 17e37a953e9..2c711c3bcda 100644 --- a/packages-next/types/src/config/fields.ts +++ b/packages-next/types/src/config/fields.ts @@ -1,41 +1,18 @@ -import { PrismaFieldAdapter } from '@keystone-next/adapter-prisma-legacy'; -import { Implementation } from '@keystone-next/fields'; -import type { CacheHint } from 'apollo-cache-control'; -import { AdminMetaRootVal } from '../admin-meta'; -import type { BaseGeneratedListTypes, JSONValue } from '../utils'; -import type { CacheHintArgs } from '../base'; -import type { ListHooks } from './hooks'; -import type { FieldAccessControl } from './access-control'; -import type { MaybeSessionFunction, MaybeItemFunction } from './lists'; +import type { CacheHint } from '../next-fields'; +import { FieldTypeFunc } from '../next-fields'; +import type { BaseGeneratedListTypes } from '../utils'; +import { MaybeItemFunction, MaybeSessionFunction } from './lists'; +import { FieldHooks } from './hooks'; +import { FieldAccessControl } from './access-control'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars export type BaseFields = { - [key: string]: FieldType; + [key: string]: FieldTypeFunc; }; -export type FieldType = { - /** - * The real keystone type for the field - */ - type: { - type: string; - implementation: typeof Implementation; - adapter: typeof PrismaFieldAdapter; - isRelationship?: boolean; - }; - /** - * The config for the field - */ - config: FieldConfig; - /** - * The resolved path to the views for the field type - */ - views: string; - getAdminMeta?: (listKey: string, path: string, adminMeta: AdminMetaRootVal) => JSONValue; -}; - -export type FieldConfig = { +export type CommonFieldConfig = { access?: FieldAccessControl; - hooks?: ListHooks; // really? ListHooks? + hooks?: FieldHooks; label?: string; ui?: { views?: string; @@ -43,5 +20,5 @@ export type FieldConfig = { itemView?: { fieldMode?: MaybeItemFunction<'edit' | 'read' | 'hidden'> }; listView?: { fieldMode?: MaybeSessionFunction<'read' | 'hidden'> }; }; - graphql?: { cacheHint?: ((args: CacheHintArgs) => CacheHint) | CacheHint }; + graphql?: { cacheHint?: CacheHint }; }; diff --git a/packages-next/types/src/config/hooks.ts b/packages-next/types/src/config/hooks.ts index 23cf42a86d8..a24462837c6 100644 --- a/packages-next/types/src/config/hooks.ts +++ b/packages-next/types/src/config/hooks.ts @@ -13,11 +13,11 @@ export type ListHooks = { /** * Used to **cause side effects** before a create or update operation once all validateInput hooks have resolved */ - beforeChange?: BeforeOrAfterChangeHook; + beforeChange?: BeforeChangeHook; /** * Used to **cause side effects** after a create or update operation operation has occurred */ - afterChange?: BeforeOrAfterChangeHook; + afterChange?: AfterChangeHook; /** * Used to **validate** that a delete operation can happen after access control has occurred */ @@ -32,6 +32,19 @@ export type ListHooks = { afterDelete?: BeforeOrAfterDeleteHook; }; +// TODO: probably maybe don't do this and write it out manually +// (this is also incorrect because the return value is wrong for many of them) +type AddFieldPathToObj any> = T extends (args: infer Args) => infer Result + ? (args: Args & { fieldPath: string }) => Result + : never; + +type AddFieldPathArgToAllPropsOnObj any>> = { + [Key in keyof T]: AddFieldPathToObj; +}; + +export type FieldHooks = + AddFieldPathArgToAllPropsOnObj>; + type ArgsForCreateOrUpdateOperation = ( | { operation: 'create'; @@ -67,12 +80,10 @@ type ArgsForCreateOrUpdateOperation void; + addValidationError: (error: string, data?: {}, internalData?: {}) => void; }; type ResolveInputHook = ( @@ -93,9 +104,13 @@ type ValidateInputHook = ( args: ArgsForCreateOrUpdateOperation & ValidationArgs ) => Promise | void; -type BeforeOrAfterChangeHook = ( +type BeforeChangeHook = ( + args: ArgsForCreateOrUpdateOperation +) => Promise | void; + +type AfterChangeHook = ( args: ArgsForCreateOrUpdateOperation & { - updatedItem: TGeneratedListTypes['inputs']['create'] | TGeneratedListTypes['inputs']['update']; + updatedItem: TGeneratedListTypes['backing']; } ) => Promise | void; diff --git a/packages-next/types/src/config/index.ts b/packages-next/types/src/config/index.ts index 8252b7b2e39..5966a3d04df 100644 --- a/packages-next/types/src/config/index.ts +++ b/packages-next/types/src/config/index.ts @@ -11,9 +11,8 @@ import type { ListConfig, MaybeSessionFunction, MaybeItemFunction, - // CacheHint, } from './lists'; -import type { BaseFields, FieldType, FieldConfig } from './fields'; +import type { BaseFields } from './fields'; import type { ListAccessControl, FieldAccessControl } from './access-control'; import type { ListHooks } from './hooks'; @@ -46,8 +45,6 @@ export type { ListSchemaConfig, ListConfig, BaseFields, - FieldType, - FieldConfig, MaybeSessionFunction, MaybeItemFunction, // CacheHint, @@ -192,3 +189,16 @@ export type KeystoneCloudConfig = { // Exports from sibling packages export type { ListHooks, ListAccessControl, FieldAccessControl }; + +export type { + FieldCreateAccessArgs, + FieldReadAccessArgs, + FieldUpdateAccessArgs, + IndividualFieldAccessControl, + CreateAccessControl, + ReadListAccessControl, + DeleteListAccessControl, + UpdateListAccessControl, +} from './access-control'; +export type { CommonFieldConfig } from './fields'; +export type { CacheHintArgs } from './lists'; diff --git a/packages-next/types/src/config/lists.ts b/packages-next/types/src/config/lists.ts index c3741db8129..057577f3c23 100644 --- a/packages-next/types/src/config/lists.ts +++ b/packages-next/types/src/config/lists.ts @@ -1,11 +1,14 @@ -import type { CacheHint } from 'apollo-cache-control'; +import type { CacheHint } from '../next-fields'; import type { BaseGeneratedListTypes, MaybePromise } from '../utils'; -import type { CacheHintArgs } from '../base'; +import { FieldTypeFunc } from '../next-fields'; import type { ListHooks } from './hooks'; import type { ListAccessControl } from './access-control'; -import type { FieldType, BaseFields } from './fields'; +import type { BaseFields } from './fields'; -export type ListSchemaConfig = Record>; +export type ListSchemaConfig = Record< + string, + ListConfig> +>; export type ListConfig< TGeneratedListTypes extends BaseGeneratedListTypes, @@ -19,7 +22,7 @@ export type ListConfig< */ fields: Fields; - idField?: FieldType; + idField?: FieldTypeFunc; /** * Controls what data users of the Admin UI and GraphQL can access and change @@ -46,9 +49,6 @@ export type ListConfig< */ description?: string; // defaults both { adminUI: { description }, graphQL: { description } } - // Not currently supported - // plugins?: any[]; // array of plugins that can modify the list config - /** * The label used for the list * @default listKey.replace(/([a-z])([A-Z])/g, '$1 $2').split(/\s|_|\-/).filter(i => i).map(upcase).join(' '); @@ -176,10 +176,12 @@ export type ListGraphQLConfig = { * @default listConfig.description */ description?: string; - // was previously top-level itemQueryName - itemQueryName?: string; // the name of the graphql query for getting a single item - // was previously top-level itemQueryName - listQueryName?: string; // the name of the graphql query for getting multiple items + /** + * The plural form of the list key to use in the generated GraphQL schema. + * Note that there is no singular here because the singular used in the GraphQL schema is the list key. + */ + // was previously top-level listQueryName + plural?: string; // was previously top-level queryLimits queryLimits?: { maxResults?: number; // maximum number of items that can be returned in a query (or subquery) @@ -187,6 +189,8 @@ export type ListGraphQLConfig = { cacheHint?: ((args: CacheHintArgs) => CacheHint) | CacheHint; }; +export type CacheHintArgs = { results: any; operationName?: string; meta: boolean }; + export type ListDBConfig = { /** * The name of the field to use for `search` filter operations. diff --git a/packages-next/types/src/context.ts b/packages-next/types/src/context.ts index 15eacfed852..1e727266279 100644 --- a/packages-next/types/src/context.ts +++ b/packages-next/types/src/context.ts @@ -1,7 +1,6 @@ import { IncomingMessage } from 'http'; import { Readable } from 'stream'; import { GraphQLSchema, ExecutionResult, DocumentNode } from 'graphql'; -import { BaseKeystone } from './base'; import type { BaseGeneratedListTypes, GqlNames } from './utils'; export type KeystoneContext = { @@ -21,10 +20,7 @@ export type KeystoneContext = { schemaName: 'public' | 'internal'; /** @deprecated */ gqlNames: (listKey: string) => GqlNames; - /** @deprecated */ - keystone: BaseKeystone; -} & AccessControlContext & - Partial>; +} & Partial>; // List item API @@ -135,13 +131,6 @@ type GraphQLExecutionArguments = { variables?: Record; }; -// Access control API - -export type AccessControlContext = { - getListAccessControlForUser: any; // TODO - getFieldAccessControlForUser: any; // TODO -}; - // Session API export type SessionContext = { diff --git a/packages-next/types/src/core.ts b/packages-next/types/src/core.ts index 0a9ca91f96e..b334a7af1f1 100644 --- a/packages-next/types/src/core.ts +++ b/packages-next/types/src/core.ts @@ -44,35 +44,33 @@ export type GraphQLSchemaExtension = { // TODO: don't duplicate this between here and packages/keystone/ListTypes/list.js export function getGqlNames({ listKey, - itemQueryName: _itemQueryName, - listQueryName: _listQueryName, + pluralGraphQLName, }: { listKey: string; - itemQueryName: string; - listQueryName: string; + pluralGraphQLName: string; }): GqlNames { - const _lowerListName = _listQueryName.slice(0, 1).toLowerCase() + _listQueryName.slice(1); + const _lowerListName = pluralGraphQLName.slice(0, 1).toLowerCase() + pluralGraphQLName.slice(1); return { outputTypeName: listKey, - itemQueryName: _itemQueryName, - listQueryName: `all${_listQueryName}`, - listQueryMetaName: `_all${_listQueryName}Meta`, + itemQueryName: listKey, + listQueryName: `all${pluralGraphQLName}`, + listQueryMetaName: `_all${pluralGraphQLName}Meta`, listQueryCountName: `${_lowerListName}Count`, - listSortName: `Sort${_listQueryName}By`, - listOrderName: `${_itemQueryName}OrderByInput`, - deleteMutationName: `delete${_itemQueryName}`, - updateMutationName: `update${_itemQueryName}`, - createMutationName: `create${_itemQueryName}`, - deleteManyMutationName: `delete${_listQueryName}`, - updateManyMutationName: `update${_listQueryName}`, - createManyMutationName: `create${_listQueryName}`, - whereInputName: `${_itemQueryName}WhereInput`, - whereUniqueInputName: `${_itemQueryName}WhereUniqueInput`, - updateInputName: `${_itemQueryName}UpdateInput`, - createInputName: `${_itemQueryName}CreateInput`, - updateManyInputName: `${_listQueryName}UpdateInput`, - createManyInputName: `${_listQueryName}CreateInput`, - relateToManyInputName: `${_itemQueryName}RelateToManyInput`, - relateToOneInputName: `${_itemQueryName}RelateToOneInput`, + listSortName: `Sort${pluralGraphQLName}By`, + listOrderName: `${listKey}OrderByInput`, + deleteMutationName: `delete${listKey}`, + updateMutationName: `update${listKey}`, + createMutationName: `create${listKey}`, + deleteManyMutationName: `delete${pluralGraphQLName}`, + updateManyMutationName: `update${pluralGraphQLName}`, + createManyMutationName: `create${pluralGraphQLName}`, + whereInputName: `${listKey}WhereInput`, + whereUniqueInputName: `${listKey}WhereUniqueInput`, + updateInputName: `${listKey}UpdateInput`, + createInputName: `${listKey}CreateInput`, + updateManyInputName: `${pluralGraphQLName}UpdateInput`, + createManyInputName: `${pluralGraphQLName}CreateInput`, + relateToManyInputName: `${listKey}RelateToManyInput`, + relateToOneInputName: `${listKey}RelateToOneInput`, }; } diff --git a/packages-next/types/src/index.ts b/packages-next/types/src/index.ts index 2c875ff788f..e3a6ce3cb54 100644 --- a/packages-next/types/src/index.ts +++ b/packages-next/types/src/index.ts @@ -5,3 +5,7 @@ export * from './session'; export * from './admin-meta'; export * from './base'; export * from './context'; +export * from './next-fields'; +export * as legacyFilters from './legacy-filters'; +export * from './schema'; +export { jsonFieldTypePolyfilledForSQLite } from './json-field-type-polyfill-for-sqlite'; diff --git a/packages-next/types/src/json-field-type-polyfill-for-sqlite.ts b/packages-next/types/src/json-field-type-polyfill-for-sqlite.ts new file mode 100644 index 00000000000..5ea475b0fed --- /dev/null +++ b/packages-next/types/src/json-field-type-polyfill-for-sqlite.ts @@ -0,0 +1,127 @@ +import { + JSONValue, + ItemRootValue, + KeystoneContext, + schema, + UpdateFieldInputArg, + ScalarDBField, + CreateFieldInputArg, + DatabaseProvider, + FieldTypeWithoutDBField, + fieldType, +} from '.'; + +function mapOutputFieldToSQLite( + field: schema.Field<{ value: JSONValue; item: ItemRootValue }, {}, any, 'value'> +) { + const innerResolver = field.resolve || (({ value }) => value); + return schema.fields<{ + value: string | null; + item: ItemRootValue; + }>()({ + value: schema.field({ + type: field.type, + args: field.args, + deprecationReason: field.deprecationReason, + description: field.description, + extensions: field.extensions as any, + resolve(rootVal, ...extra) { + if (rootVal.value === null) { + return innerResolver(rootVal, ...extra); + } + let value: JSONValue = null; + try { + value = JSON.parse(rootVal.value); + } catch (err) {} + return innerResolver({ item: rootVal.item, value }, ...extra); + }, + }), + }).value; +} + +function mapUpdateInputArgToSQLite>( + arg: UpdateFieldInputArg, Arg> | undefined +): UpdateFieldInputArg, Arg> | undefined { + if (arg === undefined) { + return undefined; + } + return { + arg: arg.arg, + async resolve( + input: schema.InferValueFromArg, + context: KeystoneContext, + relationshipInputResolver: any + ) { + const resolvedInput = + arg.resolve === undefined + ? input + : await arg.resolve(input, context, relationshipInputResolver); + if (resolvedInput === undefined || resolvedInput === null) { + return resolvedInput; + } + return JSON.stringify(resolvedInput); + }, + } as any; +} + +function mapCreateInputArgToSQLite>( + arg: CreateFieldInputArg, Arg> | undefined +): CreateFieldInputArg, Arg> | undefined { + if (arg === undefined) { + return undefined; + } + return { + arg: arg.arg, + async resolve( + input: schema.InferValueFromArg, + context: KeystoneContext, + relationshipInputResolver: any + ) { + const resolvedInput = + arg.resolve === undefined + ? input + : await arg.resolve(input, context, relationshipInputResolver); + if (resolvedInput === undefined || resolvedInput === null) { + return resolvedInput; + } + return JSON.stringify(resolvedInput); + }, + } as any; +} + +export function jsonFieldTypePolyfilledForSQLite< + CreateArg extends schema.Arg, + UpdateArg extends schema.Arg +>( + provider: DatabaseProvider, + config: FieldTypeWithoutDBField< + ScalarDBField<'Json', 'optional'>, + CreateArg, + UpdateArg, + schema.Arg, + schema.Arg + > & { + input?: { + uniqueWhere?: undefined; + orderBy?: undefined; + }; + } +) { + if (provider === 'sqlite') { + return fieldType({ kind: 'scalar', mode: 'optional', scalar: 'String' })({ + ...config, + input: { + create: mapCreateInputArgToSQLite(config.input?.create), + update: mapUpdateInputArgToSQLite(config.input?.update), + }, + output: mapOutputFieldToSQLite(config.output), + extraOutputFields: Object.fromEntries( + Object.entries(config.extraOutputFields || {}).map(([key, field]) => [ + key, + mapOutputFieldToSQLite(field), + ]) + ), + }); + } + return fieldType({ kind: 'scalar', mode: 'optional', scalar: 'Json' })(config); +} diff --git a/packages-next/types/src/legacy-filters.ts b/packages-next/types/src/legacy-filters.ts new file mode 100644 index 00000000000..3cdd73840c0 --- /dev/null +++ b/packages-next/types/src/legacy-filters.ts @@ -0,0 +1,182 @@ +import { schema } from '.'; + +const identity = (x: any) => x; + +export const impls = { + equalityConditions(fieldKey: string, f: (a: any) => any = identity) { + return { + [fieldKey]: (value: T) => ({ [fieldKey]: { equals: f(value) } }), + [`${fieldKey}_not`]: (value: T | null) => + value === null + ? { NOT: { [fieldKey]: { equals: f(value) } } } + : { + OR: [{ NOT: { [fieldKey]: { equals: f(value) } } }, { [fieldKey]: { equals: null } }], + }, + }; + }, + equalityConditionsInsensitive(fieldKey: string, f: (a: any) => any = identity) { + return { + [`${fieldKey}_i`]: (value: string) => ({ + [fieldKey]: { equals: f(value), mode: 'insensitive' }, + }), + [`${fieldKey}_not_i`]: (value: string) => + value === null + ? { NOT: { [fieldKey]: { equals: f(value), mode: 'insensitive' } } } + : { + OR: [ + { NOT: { [fieldKey]: { equals: f(value), mode: 'insensitive' } } }, + { [fieldKey]: null }, + ], + }, + }; + }, + + inConditions(fieldKey: string, f: (a: any) => any = identity) { + return { + [`${fieldKey}_in`]: (value: (T | null)[]) => + (value.includes(null) + ? { + OR: [ + { [fieldKey]: { in: value.filter(x => x !== null).map(f) } }, + { [fieldKey]: null }, + ], + } + : { [fieldKey]: { in: value.map(f) } }) as Record, + [`${fieldKey}_not_in`]: (value: (T | null)[]) => + (value.includes(null) + ? { + AND: [ + { NOT: { [fieldKey]: { in: value.filter(x => x !== null).map(f) } } }, + { NOT: { [fieldKey]: null } }, + ], + } + : { + OR: [{ NOT: { [fieldKey]: { in: value.map(f) } } }, { [fieldKey]: null }], + }) as Record, + }; + }, + + orderingConditions(fieldKey: string, f: (a: any) => any = identity) { + return { + [`${fieldKey}_lt`]: (value: T) => ({ [fieldKey]: { lt: f(value) } }), + [`${fieldKey}_lte`]: (value: T) => ({ [fieldKey]: { lte: f(value) } }), + [`${fieldKey}_gt`]: (value: T) => ({ [fieldKey]: { gt: f(value) } }), + [`${fieldKey}_gte`]: (value: T) => ({ [fieldKey]: { gte: f(value) } }), + }; + }, + + containsConditions(fieldKey: string, f: (a: any) => any = identity) { + return { + [`${fieldKey}_contains`]: (value: string) => ({ [fieldKey]: { contains: f(value) } }), + [`${fieldKey}_not_contains`]: (value: string) => ({ + OR: [{ NOT: { [fieldKey]: { contains: f(value) } } }, { [fieldKey]: null }], + }), + }; + }, + + stringConditions(fieldKey: string, f: (a: any) => any = identity) { + return { + ...impls.containsConditions(fieldKey, f), + [`${fieldKey}_starts_with`]: (value: string) => ({ [fieldKey]: { startsWith: f(value) } }), + [`${fieldKey}_not_starts_with`]: (value: string) => ({ + OR: [{ NOT: { [fieldKey]: { startsWith: f(value) } } }, { [fieldKey]: null }], + }), + [`${fieldKey}_ends_with`]: (value: string) => ({ [fieldKey]: { endsWith: f(value) } }), + [`${fieldKey}_not_ends_with`]: (value: string) => ({ + OR: [{ NOT: { [fieldKey]: { endsWith: f(value) } } }, { [fieldKey]: null }], + }), + }; + }, + + stringConditionsInsensitive(fieldKey: string, f: (a: any) => any = identity) { + return { + [`${fieldKey}_contains_i`]: (value: string) => ({ + [fieldKey]: { contains: f(value), mode: 'insensitive' }, + }), + [`${fieldKey}_not_contains_i`]: (value: string) => ({ + OR: [ + { NOT: { [fieldKey]: { contains: f(value), mode: 'insensitive' } } }, + { [fieldKey]: null }, + ], + }), + [`${fieldKey}_starts_with_i`]: (value: string) => ({ + [fieldKey]: { startsWith: f(value), mode: 'insensitive' }, + }), + [`${fieldKey}_not_starts_with_i`]: (value: string) => ({ + OR: [ + { NOT: { [fieldKey]: { startsWith: f(value), mode: 'insensitive' } } }, + { [fieldKey]: null }, + ], + }), + [`${fieldKey}_ends_with_i`]: (value: string) => ({ + [fieldKey]: { endsWith: f(value), mode: 'insensitive' }, + }), + [`${fieldKey}_not_ends_with_i`]: (value: string) => ({ + OR: [ + { NOT: { [fieldKey]: { endsWith: f(value), mode: 'insensitive' } } }, + { [fieldKey]: null }, + ], + }), + }; + }, +}; + +export const fields = { + equalityInputFields(fieldKey: string, type: schema.ScalarType | schema.EnumType) { + return { + [fieldKey]: schema.arg({ type }), + [`${fieldKey}_not`]: schema.arg({ type }), + }; + }, + equalityInputFieldsInsensitive( + fieldKey: string, + type: schema.ScalarType | schema.EnumType + ) { + return { + [`${fieldKey}_i`]: schema.arg({ type }), + [`${fieldKey}_not_i`]: schema.arg({ type }), + }; + }, + inInputFields(fieldKey: string, type: schema.ScalarType | schema.EnumType) { + return { + [`${fieldKey}_in`]: schema.arg({ type: schema.list(type) }), + [`${fieldKey}_not_in`]: schema.arg({ type: schema.list(type) }), + }; + }, + orderingInputFields(fieldKey: string, type: schema.ScalarType | schema.EnumType) { + return { + [`${fieldKey}_lt`]: schema.arg({ type }), + [`${fieldKey}_lte`]: schema.arg({ type }), + [`${fieldKey}_gt`]: schema.arg({ type }), + [`${fieldKey}_gte`]: schema.arg({ type }), + }; + }, + containsInputFields(fieldKey: string, type: schema.ScalarType | schema.EnumType) { + return { + [`${fieldKey}_contains`]: schema.arg({ type }), + [`${fieldKey}_not_contains`]: schema.arg({ type }), + }; + }, + stringInputFields(fieldKey: string, type: schema.ScalarType | schema.EnumType) { + return { + ...fields.containsInputFields(fieldKey, type), + [`${fieldKey}_starts_with`]: schema.arg({ type }), + [`${fieldKey}_not_starts_with`]: schema.arg({ type }), + [`${fieldKey}_ends_with`]: schema.arg({ type }), + [`${fieldKey}_not_ends_with`]: schema.arg({ type }), + }; + }, + stringInputFieldsInsensitive( + fieldKey: string, + type: schema.ScalarType | schema.EnumType + ) { + return { + [`${fieldKey}_contains_i`]: schema.arg({ type }), + [`${fieldKey}_not_contains_i`]: schema.arg({ type }), + [`${fieldKey}_starts_with_i`]: schema.arg({ type }), + [`${fieldKey}_not_starts_with_i`]: schema.arg({ type }), + [`${fieldKey}_ends_with_i`]: schema.arg({ type }), + [`${fieldKey}_not_ends_with_i`]: schema.arg({ type }), + }; + }, +}; diff --git a/packages-next/types/src/next-fields.ts b/packages-next/types/src/next-fields.ts new file mode 100644 index 00000000000..293b89c6be8 --- /dev/null +++ b/packages-next/types/src/next-fields.ts @@ -0,0 +1,420 @@ +import Decimal from 'decimal.js'; +import { BaseGeneratedListTypes } from './utils'; +import { CommonFieldConfig } from './config'; +import { DatabaseProvider, FieldDefaultValue } from './core'; +import { schema } from './schema'; +import { AdminMetaRootVal, JSONValue, KeystoneContext, MaybePromise } from '.'; + +export { Decimal }; + +export const QueryMeta = schema.object<{ getCount: () => Promise }>()({ + name: '_QueryMeta', + fields: { + count: schema.field({ + type: schema.Int, + resolve({ getCount }) { + return getCount(); + }, + }), + }, +}); + +// CacheScope and CacheHint are sort of duplicated from apollo-cache-control +// because they use an enum which means TS users have to import the CacheScope enum from apollo-cache-control which isn't great +// so we have a copy of it but using a union of string literals instead of an enum +// (note people importing the enum from apollo-cache-control will still be able to use it because you can use enums as their literal values but not the opposite) +export type CacheScope = 'PUBLIC' | 'PRIVATE'; + +export type CacheHint = { + maxAge?: number; + scope?: CacheScope; +}; + +export type ItemRootValue = { id: { toString(): string }; [key: string]: unknown }; + +export type MaybeFunction = Ret | ((...params: Params) => Ret); + +export type ListInfo = { types: TypesForList }; + +export type FieldData = { + lists: Record; + provider: DatabaseProvider; + listKey: string; + fieldKey: string; +}; + +export type FieldTypeFunc = (data: FieldData) => NextFieldType; + +export type NextFieldType< + TDBField extends DBField = DBField, + CreateArg extends schema.Arg | undefined = + | schema.Arg + | undefined, + UpdateArg extends schema.Arg = schema.Arg, + UniqueWhereArg extends schema.Arg = schema.Arg< + schema.NullableInputType, + undefined + >, + OrderByArg extends schema.Arg = schema.Arg< + schema.NullableInputType, + undefined + > +> = { + dbField: TDBField; +} & FieldTypeWithoutDBField; + +type ScalarPrismaTypes = { + String: string; + Boolean: boolean; + Int: number; + Float: number; + DateTime: Date; + BigInt: bigint; + Json: JSONValue; + Decimal: Decimal; +}; + +type NumberLiteralDefault = { kind: 'literal'; value: number }; +type BigIntLiteralDefault = { kind: 'literal'; value: bigint }; +type BooleanLiteralDefault = { kind: 'literal'; value: boolean }; +type StringLiteralDefault = { kind: 'literal'; value: string }; +// https://github.com/prisma/prisma-engines/blob/98490f4bb05f4a47cd715617154a06c2c0d05756/libs/datamodel/connectors/dml/src/default_value.rs#L183-L194 +type DBGeneratedDefault = { kind: 'dbgenerated'; value: string }; +type AutoIncrementDefault = { kind: 'autoincrement' }; +type NowDefault = { kind: 'now' }; +type UuidDefault = { kind: 'uuid' }; +type CuidDefault = { kind: 'cuid' }; +export type ScalarDBFieldDefault< + Scalar extends keyof ScalarPrismaTypes = keyof ScalarPrismaTypes, + Mode extends 'required' | 'many' | 'optional' = 'required' | 'many' | 'optional' +> = Mode extends 'many' + ? never + : + | { + String: StringLiteralDefault | UuidDefault | CuidDefault; + Boolean: BooleanLiteralDefault; + Json: StringLiteralDefault; + Float: NumberLiteralDefault; + Int: AutoIncrementDefault | NumberLiteralDefault; + BigInt: AutoIncrementDefault | BigIntLiteralDefault; + DateTime: NowDefault | StringLiteralDefault; + Decimal: StringLiteralDefault; + }[Scalar] + | DBGeneratedDefault; + +export type ScalarDBField< + Scalar extends keyof ScalarPrismaTypes, + Mode extends 'required' | 'many' | 'optional' +> = { + kind: 'scalar'; + scalar: Scalar; + mode: Mode; + /** + * The native database type that the field should use. See https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#model-field-scalar-types for what the possible native types should be + * The native type should not include @datasourcename. so to specify the uuid type, the correct value for nativeType would be `Uuid` + */ + nativeType?: string; + default?: ScalarDBFieldDefault; + index?: 'unique' | 'index'; +}; + +export const orderDirectionEnum = schema.enum({ + name: 'OrderDirection', + values: schema.enumValues(['asc', 'desc']), +}); + +export type RelationDBField = { + kind: 'relation'; + list: string; + field?: string; + mode: Mode; +}; + +export type EnumDBField = { + kind: 'enum'; + name: string; + values: Value[]; + mode: Mode; + default?: Value; + index?: 'unique' | 'index'; +}; + +export type NoDBField = { kind: 'none' }; + +export type ScalarishDBField = + | ScalarDBField + | EnumDBField; + +export type RealDBField = ScalarishDBField | RelationDBField<'many' | 'one'>; + +export type MultiDBField> = { + kind: 'multi'; + fields: Fields; +}; + +export type DBField = RealDBField | NoDBField | MultiDBField>; + +// TODO: this isn't right for create +// for create though, db level defaults need to be taken into account for when to not allow undefined +type DBFieldToInputValue = TDBField extends ScalarDBField< + infer Scalar, + infer Mode +> + ? { + optional: ScalarPrismaTypes[Scalar] | null | undefined; + required: ScalarPrismaTypes[Scalar] | undefined; + many: ScalarPrismaTypes[Scalar][] | undefined; + }[Mode] + : TDBField extends RelationDBField<'many' | 'one'> + ? { connect?: {}; disconnect?: boolean } | undefined + : TDBField extends EnumDBField + ? { + optional: Value | null | undefined; + required: Value | undefined; + many: Value[] | undefined; + }[Mode] + : TDBField extends NoDBField + ? undefined + : TDBField extends MultiDBField + ? // note: this is very intentionally not optional and DBFieldToInputValue will add | undefined to force people to explicitly show what they are not setting + { [Key in keyof Fields]: DBFieldToInputValue } + : never; + +type DBFieldUniqueWhere = TDBField extends ScalarDBField< + infer Scalar, + 'optional' | 'required' +> + ? Scalar extends 'String' | 'Int' + ? { + String: string; + Int: number; + }[Scalar] + : any + : any; + +type DBFieldToOutputValue = TDBField extends ScalarDBField< + infer Scalar, + infer Mode +> + ? { + optional: ScalarPrismaTypes[Scalar] | null; + required: ScalarPrismaTypes[Scalar]; + many: ScalarPrismaTypes[Scalar][]; + }[Mode] + : TDBField extends RelationDBField + ? { + one: () => Promise; + many: { + findMany(args: FindManyArgsValue): Promise; + count(args: FindManyArgsValue): Promise; + }; + }[Mode] + : TDBField extends EnumDBField + ? { + optional: Value | null; + required: Value; + many: Value[]; + }[Mode] + : TDBField extends NoDBField + ? undefined + : TDBField extends MultiDBField + ? { [Key in keyof Fields]: DBFieldToOutputValue } + : never; + +export type OrderByFieldInputArg> = { + arg: TArg; +} & ResolveFunc< + ( + value: Exclude, null | undefined>, + context: KeystoneContext + ) => MaybePromise +>; + +type FieldInputResolver = ( + value: Input, + context: KeystoneContext, + relationshipInputResolver: RelationshipInputResolver +) => MaybePromise; + +export type UpdateFieldInputArg< + TDBField extends DBField, + TArg extends schema.Arg +> = { + arg: TArg; +} & ResolveFunc< + FieldInputResolver< + schema.InferValueFromArg, + DBFieldToInputValue, + any + // i think this is broken because variance? + // TDBField extends RelationDBField + // ? ( + // input: schema.InferValueFromArg> + // ) => Promise + // : undefined + > +>; + +type CreateFieldInputResolver = FieldInputResolver< + Input, + DBFieldToInputValue, + any + // i think this is broken because variance? + // TDBField extends RelationDBField + // ? ( + // input: schema.InferValueFromArg> + // ) => Promise + // : undefined +>; + +export type CreateFieldInputArg< + TDBField extends DBField, + TArg extends schema.Arg | undefined +> = { + arg: TArg; +} & (TArg extends schema.Arg + ? DBFieldToInputValue extends schema.InferValueFromArg + ? { + resolve?: CreateFieldInputResolver, TDBField>; + } + : { + resolve: CreateFieldInputResolver, TDBField>; + } + : { + resolve: CreateFieldInputResolver; + }); + +type UnwrapMaybePromise = T extends Promise ? Resolved : T; + +type ResolveFunc any> = + Parameters[0] extends UnwrapMaybePromise> + ? { resolve?: Func } + : { resolve: Func }; + +export type UniqueWhereFieldInputArg> = { + arg: TArg; +} & ResolveFunc< + ( + value: Exclude, undefined | null>, + context: KeystoneContext + ) => MaybePromise +>; + +type FieldTypeOutputField = schema.Field< + { value: DBFieldToOutputValue; item: ItemRootValue }, + any, + schema.OutputType, + 'value' +>; + +export type OrderDirection = 'asc' | 'desc'; + +type DBFieldToOrderByValue = TDBField extends ScalarishDBField + ? OrderDirection | undefined + : TDBField extends MultiDBField + ? { [Key in keyof Fields]: DBFieldToOrderByValue } + : undefined; + +export type FieldTypeWithoutDBField< + TDBField extends DBField = DBField, + CreateArg extends schema.Arg | undefined = + | schema.Arg + | undefined, + UpdateArg extends schema.Arg = schema.Arg, + UniqueWhereArg extends schema.Arg = schema.Arg< + schema.NullableInputType, + undefined + >, + OrderByArg extends schema.Arg = schema.Arg< + schema.NullableInputType, + undefined + > +> = { + input?: { + uniqueWhere?: UniqueWhereFieldInputArg, UniqueWhereArg>; + create?: CreateFieldInputArg; + update?: UpdateFieldInputArg; + orderBy?: OrderByFieldInputArg, OrderByArg>; + }; + output: FieldTypeOutputField; + views: string; + extraOutputFields?: Record>; + getAdminMeta?: (adminMeta: AdminMetaRootVal) => JSONValue; + unreferencedConcreteInterfaceImplementations?: schema.ObjectType[]; + __legacy?: { + filters?: { + fields: Record>; + impls: Record< + string, + (value: any, resolveForeignListWhereInput?: (val: any) => Promise) => any + >; + }; + isRequired?: boolean; + defaultValue?: FieldDefaultValue; + }; +} & CommonFieldConfig; + +export function fieldType(dbField: TDBField) { + return function < + CreateArg extends schema.Arg | undefined, + UpdateArg extends schema.Arg, + UniqueWhereArg extends schema.Arg, + OrderByArg extends schema.Arg + >( + stuff: FieldTypeWithoutDBField + ): NextFieldType { + return { ...stuff, dbField }; + }; +} + +type AnyInputObj = schema.InputObjectType>>; + +type RelateToOneInput = schema.InputObjectType<{ + create?: schema.Arg; + connect: schema.Arg; + disconnect: schema.Arg; + disconnectAll: schema.Arg; +}>; + +type RelateToManyInput = schema.InputObjectType<{ + create?: schema.Arg>; + connect: schema.Arg>; + disconnect: schema.Arg>; + disconnectAll: schema.Arg; +}>; + +export type TypesForList = { + update: AnyInputObj; + create: AnyInputObj; + uniqueWhere: AnyInputObj; + where: AnyInputObj; + orderBy: AnyInputObj; + output: schema.ObjectType; + findManyArgs: FindManyArgs; + relateTo: { + many: { + create: RelateToManyInput; + update: RelateToManyInput; + }; + one: { + create: RelateToOneInput; + update: RelateToOneInput; + }; + }; +}; + +export type FindManyArgs = { + where: schema.Arg, {}>; + orderBy: schema.Arg< + schema.NonNullType>>, + Record[] + >; + search: schema.Arg; + sortBy: schema.Arg< + schema.ListType>>>> + >; + first: schema.Arg; + skip: schema.Arg, number>; +}; + +export type FindManyArgsValue = schema.InferValueFromArgs; diff --git a/packages-next/types/src/schema/graphql-ts-schema.ts b/packages-next/types/src/schema/graphql-ts-schema.ts new file mode 100644 index 00000000000..2a65a3070b1 --- /dev/null +++ b/packages-next/types/src/schema/graphql-ts-schema.ts @@ -0,0 +1,70 @@ +import * as graphqlTsSchema from '@graphql-ts/schema'; +import { GraphQLJSON } from 'graphql-type-json'; +// this is imported from a specific path so that we don't import busboy here because webpack doesn't like bundling it +// @ts-ignore +import GraphQLUpload from 'graphql-upload/public/GraphQLUpload.js'; +import type { FileUpload } from 'graphql-upload'; +import { KeystoneContext } from '../context'; +import { JSONValue } from '../utils'; +export { + Boolean, + Float, + ID, + Int, + String, + enum, + enumValues, + arg, + inputObject, + list, + nonNull, + scalar, +} from '@graphql-ts/schema/api-without-context'; +export type { + Arg, + EnumType, + EnumValue, + InferValueFromArg, + InferValueFromArgs, + InferValueFromInputType, + InputObjectType, + InferValueFromOutputType, + InputType, + ListType, + NonNullType, + NullableInputType, + ScalarType, +} from '@graphql-ts/schema/api-without-context'; +export { bindSchemaAPIToContext } from '@graphql-ts/schema'; +export { field, fields, interface, interfaceField, object, union } from './schema-api-with-context'; + +export type Context = KeystoneContext; + +export const JSON = graphqlTsSchema.schema.scalar(GraphQLJSON); +export const Upload = graphqlTsSchema.schema.scalar>(GraphQLUpload); + +export type NullableType = graphqlTsSchema.NullableType; +export type Type = graphqlTsSchema.Type; +export type NullableOutputType = graphqlTsSchema.NullableOutputType; +export type OutputType = graphqlTsSchema.OutputType; +export type Field< + RootVal, + Args extends Record>, + TType extends OutputType, + Key extends string +> = graphqlTsSchema.Field; +export type FieldResolver< + RootVal, + Args extends Record>, + TType extends OutputType +> = graphqlTsSchema.FieldResolver; +export type ObjectType = graphqlTsSchema.ObjectType; +export type UnionType = graphqlTsSchema.UnionType; +export type InterfaceType< + RootVal, + Fields extends Record> +> = graphqlTsSchema.InterfaceType; +export type InterfaceField< + Args extends Record>, + TType extends OutputType +> = graphqlTsSchema.InterfaceField; diff --git a/packages-next/types/src/schema/index.ts b/packages-next/types/src/schema/index.ts new file mode 100644 index 00000000000..64ebbadc50f --- /dev/null +++ b/packages-next/types/src/schema/index.ts @@ -0,0 +1 @@ +export * as schema from './graphql-ts-schema'; diff --git a/packages-next/types/src/schema/schema-api-with-context.d.ts b/packages-next/types/src/schema/schema-api-with-context.d.ts new file mode 100644 index 00000000000..11fc9cff99a --- /dev/null +++ b/packages-next/types/src/schema/schema-api-with-context.d.ts @@ -0,0 +1,6 @@ +import { SchemaAPIWithContext } from '@graphql-ts/schema'; +import { Context } from './graphql-ts-schema'; + +declare const __schema: SchemaAPIWithContext; + +export = __schema; diff --git a/packages-next/types/src/schema/schema-api-with-context.js b/packages-next/types/src/schema/schema-api-with-context.js new file mode 100644 index 00000000000..aab11f0783e --- /dev/null +++ b/packages-next/types/src/schema/schema-api-with-context.js @@ -0,0 +1 @@ +export * from '@graphql-ts/schema/api-with-context'; diff --git a/packages/access-control/.npmignore b/packages/access-control/.npmignore deleted file mode 100644 index 851b108d115..00000000000 --- a/packages/access-control/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -**/*.md -**/*.test.js diff --git a/packages/access-control/CHANGELOG.md b/packages/access-control/CHANGELOG.md deleted file mode 100644 index 63a4064f9a8..00000000000 --- a/packages/access-control/CHANGELOG.md +++ /dev/null @@ -1,354 +0,0 @@ -# @keystonejs/access-control - -## 11.0.0 - -### Major Changes - -- [#5746](https://github.com/keystonejs/keystone/pull/5746) [`19750d2dc`](https://github.com/keystonejs/keystone/commit/19750d2dc5801cc8d2ffae1f50d1d5ca6ab9407d) Thanks [@timleslie](https://github.com/timleslie)! - Update Node.js dependency to `^12.20 || >= 14.13`. - -### Patch Changes - -- Updated dependencies [[`19750d2dc`](https://github.com/keystonejs/keystone/commit/19750d2dc5801cc8d2ffae1f50d1d5ca6ab9407d)]: - - @keystone-next/utils-legacy@11.0.0 - -## 10.0.1 - -### Patch Changes - -- Updated dependencies [[`b7aeb232d`](https://github.com/keystonejs/keystone/commit/b7aeb232db43b32cae0bca3fcb74479d6834c587), [`49dd46843`](https://github.com/keystonejs/keystone/commit/49dd468435a96c537f5649aa2fd9e21103da40e1)]: - - @keystone-next/utils-legacy@10.0.0 - -## 10.0.0 - -### Major Changes - -- [#5397](https://github.com/keystonejs/keystone/pull/5397) [`a5627304b`](https://github.com/keystonejs/keystone/commit/a5627304b7921a0f1484d6d08330115d0edbb45b) Thanks [@bladey](https://github.com/bladey)! - Updated Node engine version to 12.x due to 10.x reaching EOL on 2021-04-30. - -### Patch Changes - -- Updated dependencies [[`b0db0a7a8`](https://github.com/keystonejs/keystone/commit/b0db0a7a8d3aa46a8034022c456ea5100b129dc0), [`d0adec53f`](https://github.com/keystonejs/keystone/commit/d0adec53ff20c2246dfe955b449b7c6e1afe96fb), [`5f2673704`](https://github.com/keystonejs/keystone/commit/5f2673704e997710a088c45e9d95d22e1195b2da), [`a5627304b`](https://github.com/keystonejs/keystone/commit/a5627304b7921a0f1484d6d08330115d0edbb45b), [`ea708559f`](https://github.com/keystonejs/keystone/commit/ea708559fbd19914fe7eb52f519937e5fe50a143)]: - - @keystone-next/utils-legacy@9.0.0 - -## 9.0.1 - -### Patch Changes - -- Updated dependencies [[`97609a623`](https://github.com/keystonejs/keystone/commit/97609a623334fd8d7b9e24dd099abda2e2a37853)]: - - @keystone-next/utils-legacy@8.0.0 - -## 9.0.0 - -### Major Changes - -- [#5147](https://github.com/keystonejs/keystone/pull/5147) [`4ac9148a0`](https://github.com/keystonejs/keystone/commit/4ac9148a0fa5b302d50e0ca4293206e2ef3616b7) Thanks [@timleslie](https://github.com/timleslie)! - Removed `parseCustomAccess` and `validateCustomAccessControl`. - -## 8.0.1 - -### Patch Changes - -- Updated dependencies [[`b44f07b6a`](https://github.com/keystonejs/keystone/commit/b44f07b6a7ce1eef6d41513096eadea5aa2be5f7)]: - - @keystone-next/utils-legacy@7.0.0 - -## 8.0.0 - -### Major Changes - -- [`6f985acc7`](https://github.com/keystonejs/keystone/commit/6f985acc775d6037ac69a01215f962285de78c75) [#4861](https://github.com/keystonejs/keystone/pull/4861) Thanks [@timleslie](https://github.com/timleslie)! - The functions `validate*AccessControl` no longer set default values for the `authentication` value. If you are calling these functions directly you will need to make sure you pass in a value for `authentication`. If you are not directly calling these functions then there are no changes required. - -* [`4eb4753e4`](https://github.com/keystonejs/keystone/commit/4eb4753e45e5a6ca37bdc756aef7adda7f551da4) [#4865](https://github.com/keystonejs/keystone/pull/4865) Thanks [@timleslie](https://github.com/timleslie)! - Updated `validateAuthAccessControl` to now require an explicit `operation` argument. - -- [`891cd490a`](https://github.com/keystonejs/keystone/commit/891cd490a17026f4af29f0ed9b9ca411747d1d63) [#4875](https://github.com/keystonejs/keystone/pull/4875) Thanks [@timleslie](https://github.com/timleslie)! - Updated the `validate*AccessControl` functions to take `{ access, args: ... }`. Unless you are directly calling these functions no code changes are required. - -### Patch Changes - -- [`f4e4498c6`](https://github.com/keystonejs/keystone/commit/f4e4498c6e4c7301288f23048f4aad3c492985c7) [#5018](https://github.com/keystonejs/keystone/pull/5018) Thanks [@bladey](https://github.com/bladey)! - Updated legacy packages to the @keystone-next namespace. - -* [`15b1132d2`](https://github.com/keystonejs/keystone/commit/15b1132d20d13f79bbf1707e1897b31da887c2b7) [#4853](https://github.com/keystonejs/keystone/pull/4853) Thanks [@timleslie](https://github.com/timleslie)! - Updated types to be parameterised by imperative argument type. - -* Updated dependencies [[`f4e4498c6`](https://github.com/keystonejs/keystone/commit/f4e4498c6e4c7301288f23048f4aad3c492985c7)]: - - @keystone-next/utils-legacy@6.0.2 - -## 7.0.0 - -### Major Changes - -- [`28a61dc67`](https://github.com/keystonejs/keystone/commit/28a61dc67b990ebd16bfc4e1c0a1e9ffb0e54d81) [#4801](https://github.com/keystonejs/keystone/pull/4801) Thanks [@timleslie](https://github.com/timleslie)! - Converted the `@keystonejs/access-control` package to TypeScript. - -## 6.3.2 - -### Patch Changes - -- [`619ef5051`](https://github.com/keystonejs/keystone/commit/619ef50512c09d7cf988dc3c877eed868eba68a6) [#4730](https://github.com/keystonejs/keystone/pull/4730) Thanks [@timleslie](https://github.com/timleslie)! - Refactored access parsing to separate parsing from validation. - -* [`86b597d41`](https://github.com/keystonejs/keystone/commit/86b597d410c907ed54a4948da438de48e313302f) [#4724](https://github.com/keystonejs/keystone/pull/4724) Thanks [@timleslie](https://github.com/timleslie)! - Rearranged code to have an explicit exports group. - -- [`c1257ca83`](https://github.com/keystonejs/keystone/commit/c1257ca834ccf5a0407debe6e7d27b45ed32a26a) [#4727](https://github.com/keystonejs/keystone/pull/4727) Thanks [@timleslie](https://github.com/timleslie)! - Refactored out `parseAccess` and added `checkSchemaNames`. - -* [`5e22cc765`](https://github.com/keystonejs/keystone/commit/5e22cc765a8f18c467457fd2ba738cd90273c8c5) [#4725](https://github.com/keystonejs/keystone/pull/4725) Thanks [@timleslie](https://github.com/timleslie)! - Refactored calls to `validateGranularConfigTypes` to be more explicit. - -- [`b9ec7fff9`](https://github.com/keystonejs/keystone/commit/b9ec7fff9d96ac56e2836543d698cf0b62b5dc8f) [#4723](https://github.com/keystonejs/keystone/pull/4723) Thanks [@timleslie](https://github.com/timleslie)! - Replaced usage of `getType()` with `typeof`. - -* [`5ad7c12e8`](https://github.com/keystonejs/keystone/commit/5ad7c12e86573e73e85368076bdc1296f3f69db3) [#4726](https://github.com/keystonejs/keystone/pull/4726) Thanks [@timleslie](https://github.com/timleslie)! - Refactored `parseAccess` and inlined the code from `validateGranularConfigTypes` and `parseAccessCore`. - -* Updated dependencies [[`94c8d349d`](https://github.com/keystonejs/keystone/commit/94c8d349d3795cd9abec407f78752417623ee56f)]: - - @keystonejs/utils@6.0.1 - -## 6.3.1 - -### Patch Changes - -- Updated dependencies [[`b76241695`](https://github.com/keystonejs/keystone/commit/b7624169554b01dba2185ef43856a223d32f12be)]: - - @keystonejs/utils@6.0.0 - -## 6.3.0 - -### Minor Changes - -- [`5a3849806`](https://github.com/keystonejs/keystone/commit/5a3849806d00e62b722461d02f6e4639bc45c1eb) [#3262](https://github.com/keystonejs/keystone/pull/3262) Thanks [@MadeByMike](https://github.com/MadeByMike)! - Added a new private internal schema that will allow a better method of bypassing access control on the `executeGraphQL` function. - - The schema name `internal` is now a reserved name and if you have a schema with this name you will need to change it with this update. - - Note: You cannot change access control on the `internal` schema. - -## 6.2.0 - -### Minor Changes - -- [`dec3d336a`](https://github.com/keystonejs/keystone/commit/dec3d336adbe8156722fbe65f315a57b2f5c08e7) [#3153](https://github.com/keystonejs/keystone/pull/3153) Thanks [@timleslie](https://github.com/timleslie)! - Made `context` available to user designed access control functions. - -## 6.1.0 - -### Minor Changes - -- [`463f55233`](https://github.com/keystonejs/keystone/commit/463f552335013d5ba9ebf2e8f7a9ebf8e2b0e0db) [#3095](https://github.com/keystonejs/keystone/pull/3095) Thanks [@timleslie](https://github.com/timleslie)! - Added `{ item, args, context, info, gqlName }` to the arguments available in access control functions for custom queries/mutations. - -## 6.0.0 - -### Major Changes - -- [`839666e25`](https://github.com/keystonejs/keystone/commit/839666e25d8bffefd034e6344e11d72dd43b925b) [#2872](https://github.com/keystonejs/keystone/pull/2872) Thanks [@wcalebgray](https://github.com/wcalebgray)! - Added async capability for all Access Control resolvers. This changes the below methods to async functions, returning Promises: - - ``` - access-control - - validateCustomAccessControl - - validateListAccessControl - - validateFieldAccessControl - - validateAuthAccessControl - - keystone/List - - checkFieldAccess - - checkListAccess - - keystone/providers/custom - - computeAccess - - keystone/providers/listAuth - - checkAccess - - ``` - - Changed `keystone/Keystone`'s `getGraphQlContext` return object (context) to include async resolvers for the following methods: - - ``` - - context.getCustomAccessControlForUser - - context.getListAccessControlForUser - - context.getFieldAccessControlForUser - - context.getAuthAccessControlForUser - ``` - -## 5.2.0 - -### Minor Changes - -- [`42497b8e`](https://github.com/keystonejs/keystone/commit/42497b8ebbaeaf0f4d7881dbb76c6abafde4cace) [#2456](https://github.com/keystonejs/keystone/pull/2456) Thanks [@timleslie](https://github.com/timleslie)! - Added `validateAuthAccessControl` as a special case for validating access control on authentication queries and mutations. - -### Patch Changes - -- [`5ba330b8`](https://github.com/keystonejs/keystone/commit/5ba330b8b2609ea0033a636daf9a215a5a192c20) [#2487](https://github.com/keystonejs/keystone/pull/2487) Thanks [@Noviny](https://github.com/Noviny)! - Small changes to package.json (mostly adding a repository field) - -- Updated dependencies [[`5ba330b8`](https://github.com/keystonejs/keystone/commit/5ba330b8b2609ea0033a636daf9a215a5a192c20)]: - - @keystonejs/utils@5.2.2 - -## 5.1.0 - -### Minor Changes - -- [`517b23e4`](https://github.com/keystonejs/keystone/commit/517b23e4b17414ed1807e8d7af1e67377ba3b7bf) [#2391](https://github.com/keystonejs/keystone/pull/2391) Thanks [@timleslie](https://github.com/timleslie)! - Removed support for Node 8.x, as it is [no longer in maintenance mode](https://nodejs.org/en/about/releases/). - -### Patch Changes - -- Updated dependencies [[`517b23e4`](https://github.com/keystonejs/keystone/commit/517b23e4b17414ed1807e8d7af1e67377ba3b7bf)]: - - @keystonejs/utils@5.2.0 - -## 5.0.0 - -### Major Changes - -- [`7b4ed362`](https://github.com/keystonejs/keystone/commit/7b4ed3623f5774d7783c39962bfa1ce97938e310) [#1821](https://github.com/keystonejs/keystone/pull/1821) Thanks [@jesstelford](https://github.com/jesstelford)! - Release @keystonejs/\* packages (つ^ ◡ ^)つ - - - This is the first release of `@keystonejs/*` packages (previously `@keystone-alpha/*`). - - All packages in the `@keystone-alpha` namespace are now available in the `@keystonejs` namespace, starting at version `5.0.0`. - - To upgrade your project you must update any `@keystone-alpha/*` dependencies in `package.json` to point to `"@keystonejs/*": "^5.0.0"` and update any `require`/`import` statements in your code. - -### Patch Changes - -- Updated dependencies [[`7b4ed362`](https://github.com/keystonejs/keystone/commit/7b4ed3623f5774d7783c39962bfa1ce97938e310)]: - - @keystonejs/utils@5.0.0 - -# @keystone-alpha/access-control - -## 3.1.0 - -### Minor Changes - -- [1405eb07](https://github.com/keystonejs/keystone/commit/1405eb07): Add `listKey`, `fieldKey` (fields only), `operation`, `gqlName`, `itemId` and `itemIds` as arguments to imperative access control functions. - -## 3.0.0 - -### Major Changes - -- [9ade2b2d](https://github.com/keystonejs/keystone/commit/9ade2b2d): Add support for `access: { auth: ... }` which controls whether authentication queries and mutations are accessible on a List - - If you have a `List` which is being used as the target of an Authentication Strategy, you should set `access: { auth: true }` on that list. - -### Minor Changes - -- [b61289b4](https://github.com/keystonejs/keystone/commit/b61289b4): Add `parseCustomAccess()` for parsing the access control directives on custom types/queries/mutations. -- [0bba9f07](https://github.com/keystonejs/keystone/commit/0bba9f07): Add `validateCustomAccessControl()` for use by custom queries/mutations access control checking. - -### Patch Changes - -- [9ece715c](https://github.com/keystonejs/keystone/commit/9ece715c): Refactor access-control internals to better support future changes - -## 2.0.0 - -### Major Changes - -- [bc0b9813](https://github.com/keystonejs/keystone/commit/bc0b9813): `parseListAccess` and `parseFieldAccess` now take `schemaNames` as an argument, and return a nested access object, with the `schemaNames` as keys. - - For example, - - ```js - parseListAccess({ defaultAccess: false, access: { public: true }, schemaNames: ['public', 'private'] } - ``` - - will return - - ```js - { - public: { create: true, read: true, update: true, delete: true }, - private: { create: false, read: false, update: false, delete: false }, - } - ``` - - These changes are backwards compatible with regard to the `access` argument, so - - ```js - const access = { create: true, read: true, update: true, delete: true }; - parseListAccess({ access, schemaNames: ['public', 'private'] } - ``` - - will return - - ```js - { - public: { create: true, read: true, update: true, delete: true }, - private: { create: true, read: true, update: true, delete: true }, - } - ``` - -## 1.1.0 - -### Minor Changes - -- [e5d4ee76](https://github.com/keystonejs/keystone/commit/e5d4ee76): Expose 'originalInput' to access control functions for lists & fields - -## 1.0.5 - -### Patch Changes - -- [19fe6c1b](https://github.com/keystonejs/keystone/commit/19fe6c1b): - - Move frontmatter in docs into comments - -## 1.0.4 - -- Updated dependencies [b7a2ea9c](https://github.com/keystonejs/keystone/commit/b7a2ea9c): - - @keystone-alpha/utils@3.0.0 - -## 1.0.3 - -- [patch][10d96db2](https://github.com/keystonejs/keystone/commit/10d96db2): - - - Restructure internal code - -## 1.0.2 - -- Updated dependencies [98c02a46](https://github.com/keystonejs/keystone/commit/98c02a46): - - @keystone-alpha/utils@2.0.0 - -## 1.0.1 - -- [patch][1f0bc236](https://github.com/keystonejs/keystone/commit/1f0bc236): - - - Update the package.json author field to "The Keystone Development Team" - -- [patch][9534f98f](https://github.com/keystonejs/keystone/commit/9534f98f): - - - Add README.md to package - -## 1.0.0 - -- [major] 8b6734ae: - - - This is the first release of keystone-alpha (previously voussoir). - All packages in the `@voussoir` namespace are now available in the `@keystone-alpha` namespace, starting at version `1.0.0`. - To upgrade your project you must update any `@voussoir/` dependencies in `package.json` to point to `@keystone-alpha/: "^1.0.0"` and update any `require`/`import` statements in your code. - -# @voussoir/access-control - -## 0.4.2 - -- [patch] 113e16d4: - - - Remove unused dependencies - -## 0.4.1 - -- Updated dependencies [723371a0]: -- Updated dependencies [aca26f71]: -- Updated dependencies [a3d5454d]: - - @voussoir/utils@1.0.0 - -## 0.4.0 - -- [minor] ffc98ac4: - - - Rename the access control function parameter `item` to `existingItem` - -## 0.3.0 - -- [minor] 3ae588b7: - - - Rename test*AccessControl functions to validate*AccessControl - -## 0.2.0 - -- [minor] 47c7dcf6" - : - - - Bump all packages with a minor version to set a new baseline - -## 0.1.3 - -- [patch] Bump all packages for Babel config fixes [d51c833](d51c833) -- [patch] Updated dependencies [9c75136](9c75136) - - @voussoir/utils@0.2.0 - -## 0.1.2 - -- [patch] Rename readme files [a8b995e](a8b995e) - -## 0.1.1 - -- [patch] Remove tests and markdown from npm [dc3ee7d](dc3ee7d) diff --git a/packages/access-control/README.md b/packages/access-control/README.md deleted file mode 100644 index 999b4d45f8b..00000000000 --- a/packages/access-control/README.md +++ /dev/null @@ -1,13 +0,0 @@ - - -# Access Control - -[![View changelog](https://img.shields.io/badge/changelogs.xyz-Explore%20Changelog-brightgreen)](https://changelogs.xyz/@keystone-next/access-control-legacy) - -This package is an internal helper package used by Keystone to parse and validate access control expressions. - -You should probably not use this directly in your Keystone projects. - -For more information on how to configure access control for you Keystone application, please consult the [Access Control Guide](/docs/guides/access-control.md). diff --git a/packages/access-control/package.json b/packages/access-control/package.json deleted file mode 100644 index b9a4beb9892..00000000000 --- a/packages/access-control/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "@keystone-next/access-control-legacy", - "description": "KeystoneJS Access Control parsing and validating utilities.", - "version": "11.0.0", - "author": "The KeystoneJS Development Team", - "license": "MIT", - "main": "dist/access-control-legacy.cjs.js", - "module": "dist/access-control-legacy.esm.js", - "engines": { - "node": "^12.20 || >= 14.13" - }, - "dependencies": { - "@keystone-next/utils-legacy": "^11.0.0" - }, - "repository": "https://github.com/keystonejs/keystone/tree/master/packages/access-control" -} diff --git a/packages/access-control/src/access-control.ts b/packages/access-control/src/access-control.ts deleted file mode 100644 index 25d7f49c2dd..00000000000 --- a/packages/access-control/src/access-control.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { pick, defaultObj, intersection } from '@keystone-next/utils-legacy'; - -type MaybePromise = T | Promise; - -type Static = boolean; -type Declarative = Record; -type Imperative = (args: T) => MaybePromise; -type FieldImperative = (args: T) => MaybePromise; - -export type ListAccess = { - create: Static | Declarative | Imperative; - read: Static | Declarative | Imperative; - update: Static | Declarative | Imperative; - delete: Static | Declarative | Imperative; -}; -export type AuthAccess = { - auth: Static | Declarative | Imperative; -}; -export type FieldAccess = { - create: Static | FieldImperative; - read: Static | FieldImperative; - update: Static | FieldImperative; -}; -// Note: Declarative here (custom) is really just Record and is returned to the user -// to do whatever they want with... -export type CustomAccess = Static | Declarative | Imperative; - -const checkSchemaNames = ({ - schemaNames, - accessTypes, - access, -}: { - schemaNames: string[]; - accessTypes: string[]; - access: any; -}) => { - if (schemaNames.includes('internal')) { - throw new Error(`"internal" is a reserved word and cannot be used as a schema name.`); - } - - // Check that none of the schemaNames match the accessTypes - const matchingNames = intersection(schemaNames, accessTypes); - if (matchingNames.length > 0) { - throw new Error( - `${JSON.stringify(matchingNames)} are reserved words and cannot be used as schema names.` - ); - } - - if (typeof access === 'object') { - const accessKeys = Object.keys(access); - const providedNameCount = intersection(accessKeys, schemaNames).length; - if (providedNameCount > 0 && providedNameCount < accessKeys.length) { - // If some are in, and some are out, throw an error! - const ks = accessKeys.filter(k => !(schemaNames as readonly string[]).includes(k)); - throw new Error(`Invalid schema names: ${JSON.stringify(ks)}`); - } - } - - const keyedBySchemaName = - typeof access === 'object' && - intersection(Object.keys(access), schemaNames).length === Object.keys(access).length; - return keyedBySchemaName; -}; - -type ListAuthAccess = ListAccess & AuthAccess; -export function parseListAccess({ - listKey, - defaultAccess, - access = defaultAccess, - schemaNames, -}: { - listKey: string; - defaultAccess: ListAccess['read']; - access?: - | Partial> | ListAccess['read']>> - | Partial> - | ListAccess['read']; - schemaNames: SN[]; -}) { - const accessTypes = [ - 'create', - 'read', - 'update', - 'delete', - 'auth', - ] as (keyof ListAuthAccess)[]; - - const keyedBySchemaName = checkSchemaNames({ schemaNames, accessTypes, access }); - - type GG = Partial> | ListAccess['read']; - const fullAccess = keyedBySchemaName - ? { ...defaultObj(schemaNames, defaultAccess), ...(access as Partial>) } // Access keyed by schemaName - : defaultObj(schemaNames, access as GG | undefined); // Access not keyed by schemaName - - const parseAndValidate = (access: GG = {}) => { - if (typeof access === 'boolean' || typeof access === 'function') { - return defaultObj(accessTypes, access) as ListAccess['read']; - } else if (typeof access === 'object') { - const _access = access as Partial>; - if (Object.keys(pick(_access, accessTypes)).length === 0) { - // An object was supplied, but it has the wrong keys (it's probably a - // declarative access control config being used as a shorthand, which - // isn't possible [due to `create` not supporting declarative config]) - const at = JSON.stringify(accessTypes); - const aks = JSON.stringify(Object.keys(access)); - throw new Error( - `Must specify one of ${at} access configs, but got ${aks}. (Did you mean to specify a declarative access control config? This can be done on a granular basis only)` - ); - } - return { ...defaultObj(accessTypes, defaultAccess), ...pick(_access, accessTypes) }; - } else { - throw new Error( - `Shorthand access must be specified as either a boolean or a function, received ${typeof access}.` - ); - } - }; - const fullParsedAccess = { - ...schemaNames.reduce( - (acc, schemaName) => ({ ...acc, [schemaName]: parseAndValidate(fullAccess[schemaName]) }), - {} as Record> - ), - internal: defaultObj(accessTypes, true as const), - }; - - Object.values(fullParsedAccess).forEach(parsedAccess => { - const errors = Object.entries(parsedAccess) - .map(([accessType, access]) => { - if (accessType === 'create') { - if (!['boolean', 'function'].includes(typeof access)) { - return `Expected a Boolean, or Function for ${listKey}.access.${accessType}, but got ${typeof access}. (NOTE: 'create' cannot have a Declarative access control config)`; - } - } else { - if (!['object', 'boolean', 'function'].includes(typeof access)) { - return `Expected a Boolean, Object, or Function for ${listKey}.access.${accessType}, but got ${typeof access}`; - } - } - }) - .filter(error => error); - - if (errors.length) { - throw new Error(errors.join('\n')); - } - }); - - return fullParsedAccess; -} - -export function parseFieldAccess({ - listKey, - fieldKey, - defaultAccess, - access = defaultAccess, - schemaNames, -}: { - listKey: string; - fieldKey: string; - defaultAccess: FieldAccess['read']; - access?: - | Partial> | FieldAccess['read']>> - | Partial> - | FieldAccess['read']; - schemaNames: SN[]; -}) { - const accessTypes = ['create', 'read', 'update'] as (keyof FieldAccess)[]; - - const keyedBySchemaName = checkSchemaNames({ schemaNames, accessTypes, access }); - - type GG = Partial> | FieldAccess['read']; - const fullAccess = keyedBySchemaName - ? { ...defaultObj(schemaNames, defaultAccess), ...(access as Partial>) } // Access keyed by schemaName - : defaultObj(schemaNames, access as GG | undefined); // Access not keyed by schemaName - - const parseAndValidate = (access: GG = {}) => { - if (typeof access === 'boolean' || typeof access === 'function') { - return defaultObj(accessTypes, access); - } else if (typeof access === 'object') { - const _access = access as Partial>; - if (Object.keys(pick(_access, accessTypes)).length === 0) { - // An object was supplied, but it has the wrong keys (it's probably a - // declarative access control config being used as a shorthand, which - // isn't possible [due to `create` not supporting declarative config]) - const at = JSON.stringify(accessTypes); - const aks = JSON.stringify(Object.keys(access)); - throw new Error( - `Must specify one of ${at} access configs, but got ${aks}. (Did you mean to specify a declarative access control config? This can be done on lists only)` - ); - } - return { ...defaultObj(accessTypes, defaultAccess), ...pick(_access, accessTypes) }; - } else { - throw new Error( - `Shorthand access must be specified as either a boolean or a function, received ${typeof access}.` - ); - } - }; - const fullParsedAccess = { - ...schemaNames.reduce( - (acc, schemaName) => ({ ...acc, [schemaName]: parseAndValidate(fullAccess[schemaName]) }), - {} as Record> - ), - internal: defaultObj(accessTypes, true as const), - }; - - Object.values(fullParsedAccess).forEach(parsedAccess => { - const errors = Object.entries(parsedAccess) - .map(([accessType, access]) => { - if (!['boolean', 'function'].includes(typeof access)) { - return `Expected a Boolean or Function for ${listKey}.fields.${fieldKey}.access.${accessType}, but got ${typeof access}. (NOTE: Fields cannot have declarative access control config)`; - } - }) - .filter(error => error); - - if (errors.length) { - throw new Error(errors.join('\n')); - } - }); - - return fullParsedAccess; -} - -export async function validateListAccessControl< - Args extends { operation: keyof ListAccess; listKey: string } ->({ access, args }: { access: ListAccess; args: Args }) { - // Either a boolean or an object describing a where clause - let result: Static | Declarative = false; - const acc = access[args.operation]; - if (typeof acc !== 'function') { - result = acc; - } else { - result = await acc(args); - } - - if (!['object', 'boolean'].includes(typeof result)) { - throw new Error( - `Must return an Object or Boolean from Imperative or Declarative access control function. Got ${typeof result}` - ); - } - - // Special case for 'create' permission - if (args.operation === 'create' && typeof result === 'object') { - throw new Error( - `Expected a Boolean for ${args.listKey}.access.create(), but got Object. (NOTE: 'create' cannot have a Declarative access control config)` - ); - } - - return result; -} - -export async function validateFieldAccessControl< - Args extends { operation: keyof FieldAccess; listKey: string; fieldKey: string } ->({ access, args }: { access: FieldAccess; args: Args }) { - let result: boolean = false; - const acc = access[args.operation]; - if (typeof acc !== 'function') { - result = acc; - } else { - result = await acc(args); - } - - if (typeof result !== 'boolean') { - throw new Error( - `Must return a Boolean from ${args.listKey}.fields.${args.fieldKey}.access.${ - args.operation - }(). Got ${typeof result}` - ); - } - - return result; -} - -export async function validateAuthAccessControl< - Args extends { operation: keyof AuthAccess } ->({ access, args }: { access: AuthAccess; args: Args }) { - // Either a boolean or an object describing a where clause - let result: Static | Declarative = false; - const acc = access[args.operation]; - if (typeof acc !== 'function') { - result = acc; - } else { - result = await acc(args); - } - - if (!['object', 'boolean'].includes(typeof result)) { - throw new Error( - `Must return an Object or Boolean from Imperative or Declarative access control function. Got ${typeof result}` - ); - } - - return result; -} diff --git a/packages/access-control/src/index.ts b/packages/access-control/src/index.ts deleted file mode 100644 index 860a28c352d..00000000000 --- a/packages/access-control/src/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - parseListAccess, - parseFieldAccess, - validateListAccessControl, - validateFieldAccessControl, - validateAuthAccessControl, -} from './access-control'; - -export type { FieldAccess, AuthAccess, ListAccess, CustomAccess } from './access-control'; diff --git a/packages/access-control/tests/access-control.test.ts b/packages/access-control/tests/access-control.test.ts deleted file mode 100644 index dc87d04bda5..00000000000 --- a/packages/access-control/tests/access-control.test.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { - parseListAccess, - parseFieldAccess, - validateListAccessControl, - validateFieldAccessControl, - validateAuthAccessControl, -} from '../src'; - -const internalAccess = { - create: true, - read: true, - update: true, - delete: true, - auth: true, -}; - -describe('Access control package tests', () => { - describe('parseListAccess', () => { - const listKey = 'key'; - const statics = [true, false]; // type StaticAccess = boolean; - const imperatives = [() => true, () => false]; // type ImperativeAccess = AccessInput => boolean; - const where = { name: 'foo' }; // GraphQLWhere - const whereFn = () => where; // (AccessInput => GraphQLWhere) - const declaratives = [where, whereFn]; // type DeclarativeAccess = GraphQLWhere | (AccessInput => GraphQLWhere); - const schemaNames = ['public']; - - test('StaticAccess | ImperativeAccess are valid defaults', () => { - [...statics, ...imperatives].forEach(defaultAccess => { - expect(parseListAccess({ listKey, defaultAccess, schemaNames })).toEqual({ - internal: internalAccess, - public: { - create: defaultAccess, - read: defaultAccess, - update: defaultAccess, - delete: defaultAccess, - auth: defaultAccess, - }, - }); - }); - }); - - test('Non-function declaratives and other misc values are not value inputs for defaultAccess', () => { - [{ read: where }, 10].forEach(defaultAccess => { - // @ts-ignore - expect(() => parseListAccess({ listKey, defaultAccess, schemaNames })).toThrow(Error); - }); - }); - - test('StaticAccess | ImperativeAccess are valid access modes, and should override the defaults', () => { - [...statics, ...imperatives].forEach(defaultAccess => { - [...statics, ...imperatives].forEach(access => { - expect(parseListAccess({ listKey, defaultAccess, access, schemaNames })).toEqual({ - internal: internalAccess, - public: { - create: access, - read: access, - update: access, - delete: access, - auth: access, - }, - }); - }); - }); - }); - - test('StaticAccess | ImperativeAccess | DeclarativeAccess are valid per-operation access modes', () => { - [...statics, ...imperatives].forEach(defaultAccess => { - // NOTE: create is handled differently below - ['read', 'update', 'delete', 'auth'].forEach(operation => { - [...statics, ...imperatives, ...declaratives].forEach(opAccess => { - const access = { [operation]: opAccess }; - expect(parseListAccess({ listKey, defaultAccess, access, schemaNames })).toEqual({ - internal: internalAccess, - public: { - create: defaultAccess, - read: defaultAccess, - update: defaultAccess, - delete: defaultAccess, - auth: defaultAccess, - // Override the specific operation we are trying - ...{ [operation]: opAccess }, - }, - }); - }); - }); - - // Misc values are not valid per-operation access modes - expect(() => - parseListAccess({ listKey, defaultAccess, access: { read: 10 }, schemaNames }) - ).toThrow(Error); - }); - }); - - test('StaticAccess | ImperativeAccess are valid per-operation access modes (create)', () => { - [...statics, ...imperatives].forEach(defaultAccess => { - [...statics, ...imperatives].forEach(opAccess => { - const access = { create: opAccess }; - expect(parseListAccess({ listKey, defaultAccess, access, schemaNames })).toEqual({ - internal: internalAccess, - public: { - create: opAccess, - read: defaultAccess, - update: defaultAccess, - delete: defaultAccess, - auth: defaultAccess, - }, - }); - }); - - // DeclarativeAccess | Misc values are not valid per-operation access modes (create) - [where, 10].forEach(opAccess => { - expect(() => - parseListAccess({ listKey, defaultAccess, access: { create: opAccess }, schemaNames }) - ).toThrow(Error); - }); - }); - }); - - test('access as an object with bad fields or bad type', () => { - // @ts-ignore - expect(() => parseListAccess({ listKey, access: { a: 1 }, schemaNames })).toThrow(Error); - // @ts-ignore - expect(() => parseListAccess({ listKey, access: 10, schemaNames })).toThrow(Error); - }); - - test('Schema names which match access types should throw', () => { - expect(() => - parseListAccess({ - listKey, - defaultAccess: true, - access: true, - schemaNames: ['public', 'read'], - }) - ).toThrow(Error); - }); - - test('creating a `internal` schema should throw', () => { - expect(() => - parseListAccess({ - listKey, - defaultAccess: true, - access: true, - schemaNames: ['public', 'internal'], - }) - ).toThrow(Error); - }); - - test('Schema names matching the access keys', () => { - const schemaNames = ['public', 'private']; - const access = { public: true }; - const defaultAccess = false; - expect(parseListAccess({ listKey, defaultAccess, access, schemaNames })).toEqual({ - internal: internalAccess, - public: { create: true, read: true, update: true, delete: true, auth: true }, - private: { create: false, read: false, update: false, delete: false, auth: false }, - }); - }); - - test('Access keys which dont match the schema keys should throw', () => { - const schemaNames = ['public', 'private']; - const access = { public: true, missing: false }; - const defaultAccess = false; - expect(() => parseListAccess({ listKey, defaultAccess, access, schemaNames })).toThrow(Error); - }); - }); - - describe('parseFieldAccess', () => { - const statics = [true, false]; // type StaticAccess = boolean; - const imperatives = [async () => true, async () => false]; // type ImperativeAccess = AccessInput => boolean; - const schemaNames = ['public']; - - test('StaticAccess | ImperativeAccess are valid defaults', () => { - [...statics, ...imperatives].forEach(defaultAccess => { - expect( - parseFieldAccess({ defaultAccess, schemaNames, listKey: 'listKey', fieldKey: 'fieldKey' }) - ).toEqual({ - internal: { create: true, read: true, update: true }, - public: { create: defaultAccess, read: defaultAccess, update: defaultAccess }, - }); - }); - }); - - test('Objects and other misc values are not value inputs for defaultAccess', () => { - [{ a: 1 }, 10].forEach(defaultAccess => { - expect(() => - // @ts-ignore - parseListAccess({ listKey: 'lisKey', fieldKey: 'fieldKey', defaultAccess, schemaNames }) - ).toThrow(Error); - }); - }); - - test('StaticAccess | ImperativeAccess are valid access modes, and should override the defaults', () => { - [...statics, ...imperatives].forEach(defaultAccess => { - [...statics, ...imperatives].forEach(access => { - expect( - parseFieldAccess({ - defaultAccess, - access, - schemaNames, - listKey: 'listKey', - fieldKey: 'fieldKey', - }) - ).toEqual({ - internal: { create: true, read: true, update: true }, - public: { create: access, read: access, update: access }, - }); - }); - }); - }); - - test('StaticAccess | ImperativeAccess are valid per-operation access modes', () => { - [...statics, ...imperatives].forEach(defaultAccess => { - ['create', 'read', 'update'].forEach(operation => { - [...statics, ...imperatives].forEach(opAccess => { - const access = { [operation]: opAccess }; - expect( - parseFieldAccess({ - defaultAccess, - access, - schemaNames, - listKey: 'listKey', - fieldKey: 'fieldKey', - }) - ).toEqual({ - internal: { create: true, read: true, update: true }, - public: { - create: defaultAccess, - read: defaultAccess, - update: defaultAccess, - // Override the specific operation we are trying - ...{ [operation]: opAccess }, - }, - }); - }); - - // Misc values are not valid per-operation access modes - expect(() => - parseFieldAccess({ - defaultAccess, - access: { [operation]: 10 }, - schemaNames, - listKey: 'listKey', - fieldKey: 'fieldKey', - }) - ).toThrow(Error); - }); - }); - }); - - test('Misc', () => { - expect(() => - parseFieldAccess({ - defaultAccess: true, - // @ts-ignore - access: { a: 1 }, - schemaNames, - listKey: 'listKey', - fieldKey: 'fieldKey', - }) - ).toThrow(Error); - expect(() => - parseFieldAccess({ - defaultAccess: true, - // @ts-ignore - access: 10, - schemaNames, - listKey: 'listKey', - fieldKey: 'fieldKey', - }) - ).toThrow(Error); - }); - }); - - test('validateListAccessControl', async () => { - const operation = 'read' as const; - const access = { [operation]: true as boolean | (() => Promise) }; - const _access = { create: false, update: false, delete: false }; - // Test the static case: returning a boolean - const listArgs = { - listKey: 'listKey', - authentication: {}, - gqlName: 'gqlName', - context: {}, - }; - await expect( - validateListAccessControl({ - access: { [operation]: true, ..._access }, - args: { - operation, - ...listArgs, - }, - }) - ).resolves.toBe(true); - await expect( - validateListAccessControl({ - access: { [operation]: false, ..._access }, - args: { - operation, - ...listArgs, - }, - }) - ).resolves.toBe(false); - await expect( - validateListAccessControl({ - // @ts-ignore - access: { [operation]: 10, ..._access }, - args: { operation, ...listArgs }, - }) - ).rejects.toThrow(Error); - - const originalInput = {}; - const accessFn = jest.fn(() => true); - - await validateListAccessControl({ - access: { [operation]: accessFn, ..._access }, - args: { - operation, - ...listArgs, - originalInput, - }, - }); - - expect(accessFn).toHaveBeenCalledTimes(1); - expect(accessFn).toHaveBeenCalledWith( - expect.objectContaining({ - originalInput, - }) - ); - - const items = [{}, { item: {} }]; - for (const authentication of items) { - // Boolean function - access[operation] = async () => true; - await expect( - validateListAccessControl({ - access: { [operation]: () => true, ..._access }, - args: { - operation, - ...listArgs, - authentication, - }, - }) - ).resolves.toBe(true); - await expect( - validateListAccessControl({ - access: { [operation]: () => false, ..._access }, - args: { - operation, - ...listArgs, - authentication, - }, - }) - ).resolves.toBe(false); - // Object function - await expect( - validateListAccessControl({ - access: { [operation]: () => ({ a: 1 }), ..._access }, - args: { - operation, - ...listArgs, - authentication, - }, - }) - ).resolves.toEqual({ a: 1 }); - - // Object function with create operation - await expect( - validateListAccessControl({ - // @ts-ignore - access: { create: () => ({ a: 1 }) }, - args: { - operation: 'create', - ...listArgs, - authentication, - }, - }) - ).rejects.toThrow(Error); - - // Number function - await expect( - validateListAccessControl({ - // @ts-ignore - access: { create: () => 10 }, - args: { - operation: 'create', - ...listArgs, - authentication, - }, - }) - ).rejects.toThrow(Error); - } - }); - - test('validateFieldAccessControl', async () => { - const operation = 'read' as const; - // Test the StaticAccess case: returning a boolean - const _access = { create: false, update: false }; - const fieldArgs = { - operation, - listKey: 'listKey', - fieldKey: 'fieldKey', - originalInput: {}, - existingItem: {}, - authentication: {}, - gqlName: 'gqlName', - itemId: {}, - itemIds: [], - context: {}, - }; - await expect( - validateFieldAccessControl({ access: { [operation]: true, ..._access }, args: fieldArgs }) - ).resolves.toBe(true); - await expect( - validateFieldAccessControl({ access: { [operation]: false, ..._access }, args: fieldArgs }) - ).resolves.toBe(false); - await expect( - // @ts-ignore - validateFieldAccessControl({ access: { [operation]: 10, ..._access }, args: fieldArgs }) - ).rejects.toThrow(Error); - - const originalInput = {}; - const existingItem = {}; - const accessFn = jest.fn(async () => true); - - await validateFieldAccessControl({ - access: { [operation]: accessFn, ..._access }, - args: fieldArgs, - }); - - expect(accessFn).toHaveBeenCalledTimes(1); - expect(accessFn).toHaveBeenCalledWith( - expect.objectContaining({ - originalInput, - existingItem, - }) - ); - - const items = [{}, { item: {} }]; - for (const authentication of items) { - // Test the ImperativeAccess case: a function which should return boolean - await expect( - validateFieldAccessControl({ - access: { [operation]: async () => true, ..._access }, - args: { - ...fieldArgs, - authentication, - }, - }) - ).resolves.toBe(true); - - await expect( - validateFieldAccessControl({ - access: { [operation]: async () => false, ..._access }, - args: { - ...fieldArgs, - authentication, - }, - }) - ).resolves.toBe(false); - - await expect( - validateFieldAccessControl({ - // @ts-ignore - access: { [operation]: () => 10, ..._access }, - args: { - ...fieldArgs, - authentication, - }, - }) - ).rejects.toThrow(Error); - } - }); - - test('validateAuthAccessControl', async () => { - const operation = 'auth'; - // Test the static case: returning a boolean - const authArgs = { listKey: 'listKey', authentication: {}, gqlName: 'gqlName', context: {} }; - await expect( - validateAuthAccessControl({ access: { [operation]: true }, args: { operation, ...authArgs } }) - ).resolves.toBe(true); - await expect( - validateAuthAccessControl({ - access: { [operation]: false }, - args: { operation, ...authArgs }, - }) - ).resolves.toBe(false); - await expect( - // @ts-ignore - validateAuthAccessControl({ access: { [operation]: 10 }, args: { operation, ...authArgs } }) - ).rejects.toThrow(Error); - - const accessFn = jest.fn(() => true); - - await validateAuthAccessControl({ - access: { [operation]: accessFn }, - args: { operation, ...authArgs }, - }); - - expect(accessFn).toHaveBeenCalledTimes(1); - - const items = [{}, { item: {} }]; - for (const authentication of items) { - // Boolean function - await expect( - validateAuthAccessControl({ - access: { [operation]: () => true }, - args: { - operation, - ...authArgs, - authentication, - }, - }) - ).resolves.toBe(true); - await expect( - validateAuthAccessControl({ - access: { [operation]: () => false }, - args: { - operation, - ...authArgs, - authentication, - }, - }) - ).resolves.toBe(false); - // Object function - await expect( - validateAuthAccessControl({ - access: { [operation]: () => ({ a: 1 }) }, - args: { - operation, - ...authArgs, - authentication, - }, - }) - ).resolves.toEqual({ a: 1 }); - - // Object function with create operation - await expect( - validateAuthAccessControl({ - // @ts-ignore - access: { create: () => ({ a: 1 }) }, - args: { - operation, - ...authArgs, - authentication, - }, - }) - ).rejects.toThrow(Error); - - // Number function - await expect( - validateAuthAccessControl({ - // @ts-ignore - access: { create: () => 10 }, - args: { - operation, - ...authArgs, - authentication, - }, - }) - ).rejects.toThrow(Error); - } - }); -}); diff --git a/packages/adapter-prisma/.npmignore b/packages/adapter-prisma/.npmignore deleted file mode 100644 index 851b108d115..00000000000 --- a/packages/adapter-prisma/.npmignore +++ /dev/null @@ -1,2 +0,0 @@ -**/*.md -**/*.test.js diff --git a/packages/adapter-prisma/CHANGELOG.md b/packages/adapter-prisma/CHANGELOG.md deleted file mode 100644 index 44b031a8cde..00000000000 --- a/packages/adapter-prisma/CHANGELOG.md +++ /dev/null @@ -1,377 +0,0 @@ -# @keystonejs/adapter-prisma - -## 8.0.0 - -### Major Changes - -- [#5767](https://github.com/keystonejs/keystone/pull/5767) [`02af04c03`](https://github.com/keystonejs/keystone/commit/02af04c03c96c26c273cd49eda5b4a132e02a26a) Thanks [@timleslie](https://github.com/timleslie)! - Deprecated the `sortBy` GraphQL filter. Updated the `orderBy` GraphQL filter with an improved API. - - Previously a `User` list's `allUsers` query would have the argument: - - ```graphql - orderBy: String - ``` - - The new API gives it the argument: - - ```graphql - orderBy: [UserOrderByInput!]! = [] - ``` - - where - - ```graphql - input UserOrderByInput { - id: OrderDirection - name: OrderDirection - score: OrderDirection - } - - enum OrderDirection { - asc - desc - } - ``` - - Rather than writing `allUsers(orderBy: "name_ASC")` you now write `allUsers(orderBy: { name: asc })`. You can also now order by multiple fields, e.g. `allUsers(orderBy: [{ score: asc }, { name: asc }])`. Each `UserOrderByInput` must have exactly one key, or else an error will be returned. - -### Patch Changes - -- Updated dependencies [[`b9c828fb0`](https://github.com/keystonejs/keystone/commit/b9c828fb0d6e587976dbd0dc4e87004bce3b2ef7), [`a6a444acd`](https://github.com/keystonejs/keystone/commit/a6a444acd23f2590d9812872441cafb5d088c48e), [`59421c039`](https://github.com/keystonejs/keystone/commit/59421c0399368e56e46537c1c687daa27f5912d0), [`5cc35170f`](https://github.com/keystonejs/keystone/commit/5cc35170fd46118089a2a6f863d782aff989bbf0), [`0617c81ea`](https://github.com/keystonejs/keystone/commit/0617c81eacc88e40bdd21bacab285d674b171a4a), [`02af04c03`](https://github.com/keystonejs/keystone/commit/02af04c03c96c26c273cd49eda5b4a132e02a26a), [`08478b8a7`](https://github.com/keystonejs/keystone/commit/08478b8a7bb9fe5932c7f74f9f6d3af75a0a5394), [`7bda87ea7`](https://github.com/keystonejs/keystone/commit/7bda87ea7f11e0faceccc6ab3f715c72b07c129b), [`590bb1fe9`](https://github.com/keystonejs/keystone/commit/590bb1fe9254c2f8feff7e3a0e2e964610116f95), [`4b11c5ea8`](https://github.com/keystonejs/keystone/commit/4b11c5ea87b759c24bdbff9d18443bbc972757c0), [`bb4f4ac91`](https://github.com/keystonejs/keystone/commit/bb4f4ac91c3ed70393774f744075971453a12aba), [`19a756496`](https://github.com/keystonejs/keystone/commit/19a7564964d9dcdc94ecdda9c0a0e92c539eb309)]: - - @keystone-next/fields@10.0.0 - - @keystone-next/types@19.0.0 - - @keystone-next/utils-legacy@11.0.1 - -## 7.0.0 - -### Major Changes - -- [#5746](https://github.com/keystonejs/keystone/pull/5746) [`19750d2dc`](https://github.com/keystonejs/keystone/commit/19750d2dc5801cc8d2ffae1f50d1d5ca6ab9407d) Thanks [@timleslie](https://github.com/timleslie)! - Update Node.js dependency to `^12.20 || >= 14.13`. - -### Patch Changes - -- [#5757](https://github.com/keystonejs/keystone/pull/5757) [`d40c2a590`](https://github.com/keystonejs/keystone/commit/d40c2a5903f07e5a1e80d116ec4cea00289bbf6a) Thanks [@timleslie](https://github.com/timleslie)! - Fixed failures in GraphQL queries using multiple `sortBy` values. - -- Updated dependencies [[`19750d2dc`](https://github.com/keystonejs/keystone/commit/19750d2dc5801cc8d2ffae1f50d1d5ca6ab9407d), [`e2232a553`](https://github.com/keystonejs/keystone/commit/e2232a5537620bd82983ba3f5cff124cec8facab)]: - - @keystone-next/fields@9.0.0 - - @keystone-next/types@18.0.0 - - @keystone-next/utils-legacy@11.0.0 - -## 6.1.0 - -### Minor Changes - -- [#5616](https://github.com/keystonejs/keystone/pull/5616) [`3d3894679`](https://github.com/keystonejs/keystone/commit/3d38946798650d117c39ce522987b169e616b2b9) Thanks [@timleslie](https://github.com/timleslie)! - Added an `isIndexed` config option to the `text`, `integer`, `float`, `select`, and `timestamp` field types. - -### Patch Changes - -- Updated dependencies [[`3d3894679`](https://github.com/keystonejs/keystone/commit/3d38946798650d117c39ce522987b169e616b2b9)]: - - @keystone-next/fields@8.1.0 - -## 6.0.1 - -### Patch Changes - -- [#5571](https://github.com/keystonejs/keystone/pull/5571) [`05d4883ee`](https://github.com/keystonejs/keystone/commit/05d4883ee19bcfdfcbff7f80693a3fa85cf81aaa) Thanks [@timleslie](https://github.com/timleslie)! - Added a `beforeExit` handler to explicitly terminate the prisma child process to avoid zombie processes when the server crashes. - -* [#5552](https://github.com/keystonejs/keystone/pull/5552) [`a0c5aa307`](https://github.com/keystonejs/keystone/commit/a0c5aa30771d187253d0cfe24b4b686e136136cc) Thanks [@timleslie](https://github.com/timleslie)! - Improved handling of null filter inputs. - -* Updated dependencies [[`b7aeb232d`](https://github.com/keystonejs/keystone/commit/b7aeb232db43b32cae0bca3fcb74479d6834c587), [`f7d4c9b9f`](https://github.com/keystonejs/keystone/commit/f7d4c9b9f06cc3090b59d4b29e0907e9f3d1faee), [`7e81b52b0`](https://github.com/keystonejs/keystone/commit/7e81b52b0f2240f0c590eb8f6733360cab9fe93a), [`fddeacf79`](https://github.com/keystonejs/keystone/commit/fddeacf79d25fea15be57d1a4ec16815bcdc4ab5), [`fdebf79cc`](https://github.com/keystonejs/keystone/commit/fdebf79cc3520ffb65979ddac7d61791f4f37324), [`8577eb3ba`](https://github.com/keystonejs/keystone/commit/8577eb3baafe9cd61c48d89aca9eff252765e5a6), [`9fd7cc62a`](https://github.com/keystonejs/keystone/commit/9fd7cc62a889f8a0f8933040bb16fcc36af7795e), [`3e33cd3ff`](https://github.com/keystonejs/keystone/commit/3e33cd3ff46f824ec3516e5810a7e5027b332a5a), [`b7aeb232d`](https://github.com/keystonejs/keystone/commit/b7aeb232db43b32cae0bca3fcb74479d6834c587), [`49dd46843`](https://github.com/keystonejs/keystone/commit/49dd468435a96c537f5649aa2fd9e21103da40e1), [`49dd46843`](https://github.com/keystonejs/keystone/commit/49dd468435a96c537f5649aa2fd9e21103da40e1)]: - - @keystone-next/fields@8.0.0 - - @keystone-next/types@17.0.1 - - @keystone-next/utils-legacy@10.0.0 - -## 6.0.0 - -### Major Changes - -- [#5397](https://github.com/keystonejs/keystone/pull/5397) [`a5627304b`](https://github.com/keystonejs/keystone/commit/a5627304b7921a0f1484d6d08330115d0edbb45b) Thanks [@bladey](https://github.com/bladey)! - Updated Node engine version to 12.x due to 10.x reaching EOL on 2021-04-30. - -### Minor Changes - -- [#5284](https://github.com/keystonejs/keystone/pull/5284) [`8ab2c9bb6`](https://github.com/keystonejs/keystone/commit/8ab2c9bb6633c2f85844e658f534582c30a39a57) Thanks [@timleslie](https://github.com/timleslie)! - Converted package to TypeScript. - -* [#5406](https://github.com/keystonejs/keystone/pull/5406) [`637ae05d3`](https://github.com/keystonejs/keystone/commit/637ae05d3f8a138902c2d03c5b342cb93c440767) Thanks [@timleslie](https://github.com/timleslie)! - Added a `.containsConditions()` method to include string filters just for the `contains` and `not_contains` options. - -### Patch Changes - -- [#5406](https://github.com/keystonejs/keystone/pull/5406) [`637ae05d3`](https://github.com/keystonejs/keystone/commit/637ae05d3f8a138902c2d03c5b342cb93c440767) Thanks [@timleslie](https://github.com/timleslie)! - Fixed a bug which added unsupported string filter options to the GraphQL API for the SQLite provider. - Added a `.containsInputFields()` method to include string filters just for the `contains` and `not_contains` options. - -* [#5442](https://github.com/keystonejs/keystone/pull/5442) [`1d85d7ff4`](https://github.com/keystonejs/keystone/commit/1d85d7ff4e8d7795d6e0f82484cf7108d11925db) Thanks [@timleslie](https://github.com/timleslie)! - Updated type definitions to be more consistent and correct. - -- [#5460](https://github.com/keystonejs/keystone/pull/5460) [`2bef01aaa`](https://github.com/keystonejs/keystone/commit/2bef01aaacd32eb746353bde11dd5e37c67fb43e) Thanks [@timleslie](https://github.com/timleslie)! - Consolidated the core code from the `@keystone-next/keystone-legacy` package into `@keystone-next/keystone`. - -* [#5466](https://github.com/keystonejs/keystone/pull/5466) [`0e74d8123`](https://github.com/keystonejs/keystone/commit/0e74d81238d5d00cc3eb968c95c02f25cb3a5a78) Thanks [@timleslie](https://github.com/timleslie)! - Improved the `BaseKeystone` type to be more correct. - -* Updated dependencies [[`9e060fe83`](https://github.com/keystonejs/keystone/commit/9e060fe83459269bc5d257f31a23c164d2283624), [`637ae05d3`](https://github.com/keystonejs/keystone/commit/637ae05d3f8a138902c2d03c5b342cb93c440767), [`d0adec53f`](https://github.com/keystonejs/keystone/commit/d0adec53ff20c2246dfe955b449b7c6e1afe96fb), [`c7aecec3c`](https://github.com/keystonejs/keystone/commit/c7aecec3c768eec742e0ce9c5506331e902e5124), [`b0db0a7a8`](https://github.com/keystonejs/keystone/commit/b0db0a7a8d3aa46a8034022c456ea5100b129dc0), [`f059f6349`](https://github.com/keystonejs/keystone/commit/f059f6349bee3dce8bbf4a0584b235e97872851c), [`7498fcabb`](https://github.com/keystonejs/keystone/commit/7498fcabba3ef6b411dd3bf67a20821702442ebc), [`11f5bb631`](https://github.com/keystonejs/keystone/commit/11f5bb6316b90ec603aa034db1b9259c911204ed), [`d0adec53f`](https://github.com/keystonejs/keystone/commit/d0adec53ff20c2246dfe955b449b7c6e1afe96fb), [`5f2673704`](https://github.com/keystonejs/keystone/commit/5f2673704e997710a088c45e9d95d22e1195b2da), [`fe55e9289`](https://github.com/keystonejs/keystone/commit/fe55e9289b898bdcb937eb5e981dba2bb58a672f), [`a5627304b`](https://github.com/keystonejs/keystone/commit/a5627304b7921a0f1484d6d08330115d0edbb45b), [`ea708559f`](https://github.com/keystonejs/keystone/commit/ea708559fbd19914fe7eb52f519937e5fe50a143), [`1d85d7ff4`](https://github.com/keystonejs/keystone/commit/1d85d7ff4e8d7795d6e0f82484cf7108d11925db), [`0e74d8123`](https://github.com/keystonejs/keystone/commit/0e74d81238d5d00cc3eb968c95c02f25cb3a5a78), [`be60812f2`](https://github.com/keystonejs/keystone/commit/be60812f29d7768ce65a5f5e8c40597d4742c5d7), [`d7e8cad4f`](https://github.com/keystonejs/keystone/commit/d7e8cad4fca5d8ffefa235c2ff30ec8e2e0d6276), [`ecf07393a`](https://github.com/keystonejs/keystone/commit/ecf07393a19714f1686772bd082de7d229065aa2), [`be60812f2`](https://github.com/keystonejs/keystone/commit/be60812f29d7768ce65a5f5e8c40597d4742c5d7), [`89b869e8d`](https://github.com/keystonejs/keystone/commit/89b869e8d492151449f2146108767a7e5e5ecdfa), [`58a793988`](https://github.com/keystonejs/keystone/commit/58a7939888ec84d0f089d77ca1ce9d94ef0d9a85)]: - - @keystone-next/types@17.0.0 - - @keystone-next/fields@7.0.0 - - @keystone-next/utils-legacy@9.0.0 - -## 5.0.0 - -### Major Changes - -- [#5319](https://github.com/keystonejs/keystone/pull/5319) [`1261c398b`](https://github.com/keystonejs/keystone/commit/1261c398b94ffef2737226cceaebaed1b3c04c72) Thanks [@timleslie](https://github.com/timleslie)! - Removed legacy `PrismaAdapter.listAdapterClass`, `PrismaAdapter.postConnect()`, and `PrismaAdapter.checkDatabaseVersion()`. - -* [#5302](https://github.com/keystonejs/keystone/pull/5302) [`1e6d12f47`](https://github.com/keystonejs/keystone/commit/1e6d12f47076816d2a2441b42471176c5a7f2f8c) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Removed `CLIOptionsForCreateMigration` and `createMigration` exports - -- [#5324](https://github.com/keystonejs/keystone/pull/5324) [`e702fea44`](https://github.com/keystonejs/keystone/commit/e702fea44c3116db158d97b5ffd24440f09c9d49) Thanks [@timleslie](https://github.com/timleslie)! - Removed legacy `.find()`, `.findAll()`, `.findOne()`, `.findById()`, `.itemsQueryMeta()`, `.getFieldAdapterByPath()`, and `.getPrimaryKeyAdapter()` methods from `PrismaListAdapter`. - -* [#5287](https://github.com/keystonejs/keystone/pull/5287) [`95fefaf81`](https://github.com/keystonejs/keystone/commit/95fefaf815204d6af6e407690f44750f500602e3) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Removed `getDbSchemaName` and `getPrismaPath` database adapter options. To change the database schema that Keystone uses, you can add `?schema=whatever` to the database url. - -- [#5302](https://github.com/keystonejs/keystone/pull/5302) [`1e6d12f47`](https://github.com/keystonejs/keystone/commit/1e6d12f47076816d2a2441b42471176c5a7f2f8c) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Removed formatting of Prisma schema returned from `_generatePrismaSchema` method and made it return synchronously - -* [#5320](https://github.com/keystonejs/keystone/pull/5320) [`fda82869c`](https://github.com/keystonejs/keystone/commit/fda82869c376d05fd007bec22d7bde2604db445b) Thanks [@timleslie](https://github.com/timleslie)! - Removed legacy default ID field support. - -- [#5285](https://github.com/keystonejs/keystone/pull/5285) [`5cd94b2a3`](https://github.com/keystonejs/keystone/commit/5cd94b2a32b3eddaf00ad77229f7e9664899c3b9) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Removed `dropDatabase` method and config option - -* [#5287](https://github.com/keystonejs/keystone/pull/5287) [`95fefaf81`](https://github.com/keystonejs/keystone/commit/95fefaf815204d6af6e407690f44750f500602e3) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Removed migrationMode and all migration related methods on the adapter and instead require that a prisma client is passed to the adapter to be able to connect to the database - -### Patch Changes - -- [#5281](https://github.com/keystonejs/keystone/pull/5281) [`4f0abec0b`](https://github.com/keystonejs/keystone/commit/4f0abec0b19c3495c1ae6d7dac49fb46253cf7b3) Thanks [@timleslie](https://github.com/timleslie)! - Removed the legacy `BaseKeystoneAdapter`, `BaseListAdapter`, and `BaseFieldAdapter` exports. - -- Updated dependencies [[`e702fea44`](https://github.com/keystonejs/keystone/commit/e702fea44c3116db158d97b5ffd24440f09c9d49), [`955787055`](https://github.com/keystonejs/keystone/commit/955787055a54fb33eb45c80dd39fa86a9ff632a0), [`fda82869c`](https://github.com/keystonejs/keystone/commit/fda82869c376d05fd007bec22d7bde2604db445b), [`4f0abec0b`](https://github.com/keystonejs/keystone/commit/4f0abec0b19c3495c1ae6d7dac49fb46253cf7b3)]: - - @keystone-next/keystone-legacy@23.0.0 - -## 4.0.1 - -### Patch Changes - -- Updated dependencies [[`e944b1ebb`](https://github.com/keystonejs/keystone/commit/e944b1ebbede95500b06028c591ee8947278a479), [`ca1be4156`](https://github.com/keystonejs/keystone/commit/ca1be415663dd822b3adda1e073bd7a1d4a9b97b), [`7ae452ad1`](https://github.com/keystonejs/keystone/commit/7ae452ad144d1186225e94ff39be0eaf9983f585), [`97609a623`](https://github.com/keystonejs/keystone/commit/97609a623334fd8d7b9e24dd099abda2e2a37853), [`45272d0b1`](https://github.com/keystonejs/keystone/commit/45272d0b1dc68e6ae8dbc4cfda790b3a50cf1b25), [`ade638de0`](https://github.com/keystonejs/keystone/commit/ade638de07142e8ecd0c3bf6c805eed76fd89878), [`2a1fc416e`](https://github.com/keystonejs/keystone/commit/2a1fc416e8f0a83e108a72fcec81b380c601f3ef), [`9e78d8818`](https://github.com/keystonejs/keystone/commit/9e78d88187d8d789e5f080fd4529742f54ff1ddd), [`5510ae33f`](https://github.com/keystonejs/keystone/commit/5510ae33fb18d42e378a00f1f78b803fb01b3fad), [`4d405390c`](https://github.com/keystonejs/keystone/commit/4d405390c0f8dcc37e6fe4da7ce3866c699088f3), [`b36758a12`](https://github.com/keystonejs/keystone/commit/b36758a121c096e8776420949c77a5304957a969), [`fe9fc5e0d`](https://github.com/keystonejs/keystone/commit/fe9fc5e0de8cefb889624e43bc281ac408bcd3b8), [`b8cd13fdf`](https://github.com/keystonejs/keystone/commit/b8cd13fdfcec645140a06b0331b240583eace061), [`32578f01e`](https://github.com/keystonejs/keystone/commit/32578f01e70ea972d438a29fa1e3793c1e02750b)]: - - @keystone-next/keystone-legacy@22.0.0 - - @keystone-next/utils-legacy@8.0.0 - - @keystone-next/fields-auto-increment-legacy@9.0.0 - -## 4.0.0 - -### Major Changes - -- [#5135](https://github.com/keystonejs/keystone/pull/5135) [`cdd889db1`](https://github.com/keystonejs/keystone/commit/cdd889db10e440c46719bda5fad1d5f7eacbb714) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Updated `keystone-next dev` with the Prisma adapter so that it interactively prompts for creating and applying a migration - -* [#5118](https://github.com/keystonejs/keystone/pull/5118) [`2ff93692a`](https://github.com/keystonejs/keystone/commit/2ff93692aaef70474449f30fb249eae8aa33a64a) Thanks [@timleslie](https://github.com/timleslie)! - Updated schema generation to add an index on all foreign key values for relationship fields. - -- [#5155](https://github.com/keystonejs/keystone/pull/5155) [`215aed387`](https://github.com/keystonejs/keystone/commit/215aed387d35e9d4c896fe76991b12b54789cc55) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Removed `createOnly` migration mode - -* [#5135](https://github.com/keystonejs/keystone/pull/5135) [`cdd889db1`](https://github.com/keystonejs/keystone/commit/cdd889db10e440c46719bda5fad1d5f7eacbb714) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Changed default migrationMode from `dev` to `prototype` - -### Minor Changes - -- [#3946](https://github.com/keystonejs/keystone/pull/3946) [`8e9b04ecd`](https://github.com/keystonejs/keystone/commit/8e9b04ecd07d9c5d0e6aead4705e7a655498ae05) Thanks [@timleslie](https://github.com/timleslie)! - Added experimental support for Prisma + SQLite as a database adapter. - -* [#5102](https://github.com/keystonejs/keystone/pull/5102) [`714bdadce`](https://github.com/keystonejs/keystone/commit/714bdadce8c87a15cf3a296b44a31b9b9ca95e9d) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Added `none-skip-client-generation` migrationMode - -- [#5148](https://github.com/keystonejs/keystone/pull/5148) [`e6b16d4e9`](https://github.com/keystonejs/keystone/commit/e6b16d4e9d95be8b3d3134931cf077b92a438806) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Updated `keystone-next deploy` to use Prisma's programmatic APIs to apply migrations - -* [#5155](https://github.com/keystonejs/keystone/pull/5155) [`215aed387`](https://github.com/keystonejs/keystone/commit/215aed387d35e9d4c896fe76991b12b54789cc55) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Changed `keystone-next generate` so that it uses Prisma's programmatic APIs to generate migrations and it accepts the following options as command line arguments or as prompts: - - - `--name` to set the name of the migration - - `--accept-data-loss` to allow resetting the database when it is out of sync with the migrations - - `--allow-empty` to create an empty migration when there are no changes to the schema - -- [#5152](https://github.com/keystonejs/keystone/pull/5152) [`00f980cad`](https://github.com/keystonejs/keystone/commit/00f980cadda28c0c30da8b50ff1a033365998e02) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Updated `keystone-next reset` to use Prisma's programmatic APIs to reset the database. - -* [#5142](https://github.com/keystonejs/keystone/pull/5142) [`543232c3f`](https://github.com/keystonejs/keystone/commit/543232c3f151f2294cf63e0944d1724b7b0ac33e) Thanks [@renovate](https://github.com/apps/renovate)! - Updated Prisma to 2.19.0 - -### Patch Changes - -- [#5059](https://github.com/keystonejs/keystone/pull/5059) [`b3c4a756f`](https://github.com/keystonejs/keystone/commit/b3c4a756fd2028d1e29967392d37098419e54ec3) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Replaced usage of prisma cli when in `migrationMode: 'prototype'` with programmatic calls to `@prisma/migrate` to improve performance - -* [#5150](https://github.com/keystonejs/keystone/pull/5150) [`3a9d20ce1`](https://github.com/keystonejs/keystone/commit/3a9d20ce11463e7f73f6b6325375cdcee17d63ed) Thanks [@timleslie](https://github.com/timleslie)! - Applied eslint `import/order` rule. - -- [#5059](https://github.com/keystonejs/keystone/pull/5059) [`b3c4a756f`](https://github.com/keystonejs/keystone/commit/b3c4a756fd2028d1e29967392d37098419e54ec3) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Removed faulty optimisation that caused migrations to not be run if the prisma client directory and the prisma schema already existed - -- Updated dependencies [[`8e9b04ecd`](https://github.com/keystonejs/keystone/commit/8e9b04ecd07d9c5d0e6aead4705e7a655498ae05), [`3a9d20ce1`](https://github.com/keystonejs/keystone/commit/3a9d20ce11463e7f73f6b6325375cdcee17d63ed), [`2bccf71b1`](https://github.com/keystonejs/keystone/commit/2bccf71b152a9be65a2df6a9751f1d7a382041ae), [`a4002b045`](https://github.com/keystonejs/keystone/commit/a4002b045b3e783971c382f9373159c04845beeb), [`4ac9148a0`](https://github.com/keystonejs/keystone/commit/4ac9148a0fa5b302d50e0ca4293206e2ef3616b7), [`bafdcb7bd`](https://github.com/keystonejs/keystone/commit/bafdcb7bdcba641bb8a00689a2bcefed10f4d890)]: - - @keystone-next/fields-auto-increment-legacy@8.2.0 - - @keystone-next/keystone-legacy@21.0.0 - -## 3.3.0 - -### Minor Changes - -- [#5085](https://github.com/keystonejs/keystone/pull/5085) [`acc6e9772`](https://github.com/keystonejs/keystone/commit/acc6e9772b4a312a62ea756777034638c03a3761) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Added an option to pass in the prisma client to use instead of attempting to generate one and `require()`ing it to fix the experimental `enableNextJsGraphqlApiEndpoint` option not working on Vercel - -### Patch Changes - -- Updated dependencies [[`b44f07b6a`](https://github.com/keystonejs/keystone/commit/b44f07b6a7ce1eef6d41513096eadea5aa2be5f7), [`c45cbb9b1`](https://github.com/keystonejs/keystone/commit/c45cbb9b14010b3ced7ea012f3502998ba2ec393), [`b4b276cf6`](https://github.com/keystonejs/keystone/commit/b4b276cf66f90dce2d711c144c0d99c4752f1f5e), [`ab14e7043`](https://github.com/keystonejs/keystone/commit/ab14e70435ef89cf702d407c90396eca53bc3f4d), [`7ad7430dc`](https://github.com/keystonejs/keystone/commit/7ad7430dc377f79f7ad4024879ec2966ba0d185f)]: - - @keystone-next/utils-legacy@7.0.0 - - @keystone-next/keystone-legacy@20.0.0 - - @keystone-next/fields-auto-increment-legacy@8.1.6 - -## 3.2.0 - -### Minor Changes - -- [`7bb173018`](https://github.com/keystonejs/keystone/commit/7bb173018afc6d8af4c602dc86c5c4b471277b97) [#5040](https://github.com/keystonejs/keystone/pull/5040) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Updated Prisma to 2.18.0 - -## 3.1.0 - -### Minor Changes - -- [`5d565ea57`](https://github.com/keystonejs/keystone/commit/5d565ea57853713458329b823bde7a38776b02bc) [#4892](https://github.com/keystonejs/keystone/pull/4892) Thanks [@timleslie](https://github.com/timleslie)! - Added support for configuring the field to use for `search` filtering via the `searchField` list adapter config option. - -### Patch Changes - -- [`f4e4498c6`](https://github.com/keystonejs/keystone/commit/f4e4498c6e4c7301288f23048f4aad3c492985c7) [#5018](https://github.com/keystonejs/keystone/pull/5018) Thanks [@bladey](https://github.com/bladey)! - Updated legacy packages to the @keystone-next namespace. - -* [`f32316e6d`](https://github.com/keystonejs/keystone/commit/f32316e6deafdb9001874b08e3f4203250728676) [#4956](https://github.com/keystonejs/keystone/pull/4956) Thanks [@mikestecker](https://github.com/mikestecker)! - Fixed errors when using a schema path containing a space character. - -- [`00f19daee`](https://github.com/keystonejs/keystone/commit/00f19daee8bbd75fb58fb76caaa9a3de70ebfcac) [#4890](https://github.com/keystonejs/keystone/pull/4890) Thanks [@timleslie](https://github.com/timleslie)! - Fixed a schema generation issue when two one-sided many-to-many relationships shared the same name. - -* [`f826f15c6`](https://github.com/keystonejs/keystone/commit/f826f15c6e00fcfcef6d9153b261e8977f5117f1) [#4984](https://github.com/keystonejs/keystone/pull/4984) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Fixed crash if the prisma client directory exists but the prisma schema doesn't. - -* Updated dependencies [[`f4e4498c6`](https://github.com/keystonejs/keystone/commit/f4e4498c6e4c7301288f23048f4aad3c492985c7), [`6f985acc7`](https://github.com/keystonejs/keystone/commit/6f985acc775d6037ac69a01215f962285de78c75), [`4eb4753e4`](https://github.com/keystonejs/keystone/commit/4eb4753e45e5a6ca37bdc756aef7adda7f551da4), [`891cd490a`](https://github.com/keystonejs/keystone/commit/891cd490a17026f4af29f0ed9b9ca411747d1d63), [`a16d2cbff`](https://github.com/keystonejs/keystone/commit/a16d2cbffd9aa57d0cbdd783ff5ff0c699ff2d8b), [`5d565ea57`](https://github.com/keystonejs/keystone/commit/5d565ea57853713458329b823bde7a38776b02bc)]: - - @keystone-next/fields-auto-increment-legacy@8.1.5 - - @keystone-next/keystone-legacy@19.3.0 - - @keystone-next/utils-legacy@6.0.2 - -## 3.0.1 - -### Patch Changes - -- [`d8f64887f`](https://github.com/keystonejs/keystone/commit/d8f64887f2aa428ea8ac35d0efa50ce05534f40b) [#4795](https://github.com/keystonejs/keystone/pull/4795) Thanks [@renovate](https://github.com/apps/renovate)! - Updated to `prisma` `2.16.1`. - -- Updated dependencies [[`4035218df`](https://github.com/keystonejs/keystone/commit/4035218df390beff3d42c0d3fc21335230d8a60d), [`8d0be8a89`](https://github.com/keystonejs/keystone/commit/8d0be8a89e2d9b89826365f81f47b8d8863b93d0)]: - - @keystonejs/keystone@19.2.0 - - @keystonejs/fields-auto-increment@8.1.4 - -## 3.0.0 - -### Major Changes - -- [`749d1c86c`](https://github.com/keystonejs/keystone/commit/749d1c86c89690ef10014a4a0a12641eb24bfe1d) [#4709](https://github.com/keystonejs/keystone/pull/4709) Thanks [@timleslie](https://github.com/timleslie)! - Database adapters no longer support custom `ListAdapter` classes via the `listAdapterClass` option of `adapterConfig` in `createList()`. - -### Minor Changes - -- [`a886039a1`](https://github.com/keystonejs/keystone/commit/a886039a1fc17c9b60b2955f0e58916ab1c3d7bf) [#4707](https://github.com/keystonejs/keystone/pull/4707) Thanks [@timleslie](https://github.com/timleslie)! - Added support for the `Decimal` field type with the Prisma database adapter. - -### Patch Changes - -- Updated dependencies [[`749d1c86c`](https://github.com/keystonejs/keystone/commit/749d1c86c89690ef10014a4a0a12641eb24bfe1d), [`588be9ea1`](https://github.com/keystonejs/keystone/commit/588be9ea16ab5fb6e74f844b917ca8aeb91a9ac9), [`94c8d349d`](https://github.com/keystonejs/keystone/commit/94c8d349d3795cd9abec407f78752417623ee56f)]: - - @keystonejs/keystone@19.0.0 - - @keystonejs/utils@6.0.1 - - @keystonejs/fields-auto-increment@8.1.3 - -## 2.0.0 - -### Major Changes - -- [`fc2b7101f`](https://github.com/keystonejs/keystone/commit/fc2b7101f35f20e4d729269a005816546bb37464) [#4691](https://github.com/keystonejs/keystone/pull/4691) Thanks [@timleslie](https://github.com/timleslie)! - Upgraded Prisma to `2.15.0`, which includes the new migration framework. Added the `migrationMode` config option to the `PrismaAdapter` constructor to control how migrations are applied. - -### Patch Changes - -- [`6b95cb6e4`](https://github.com/keystonejs/keystone/commit/6b95cb6e4d5bea3a87e22765d5fcf31db2fc31ae) [#4692](https://github.com/keystonejs/keystone/pull/4692) Thanks [@timleslie](https://github.com/timleslie)! - Updated internals for easier development. - -* [`e7d4d54e5`](https://github.com/keystonejs/keystone/commit/e7d4d54e5b94e6b376d6eab28a0f2b074f2c95ed) [#4697](https://github.com/keystonejs/keystone/pull/4697) Thanks [@timleslie](https://github.com/timleslie)! - Fixed cases sensitivity and partial string search for the Prisma adapter. - -- [`a62a2d996`](https://github.com/keystonejs/keystone/commit/a62a2d996f1080051f7962b7063ae37d7e8b7e63) [#4698](https://github.com/keystonejs/keystone/pull/4698) Thanks [@timleslie](https://github.com/timleslie)! - Updated prisma schema generation to include explicit opposite field for one-sided 1:N relationships. - -- Updated dependencies []: - - @keystonejs/fields-auto-increment@8.1.2 - -## 1.1.2 - -### Patch Changes - -- [`49eec4dea`](https://github.com/keystonejs/keystone/commit/49eec4dea522c6a043b3eaf93fc8be8256b00aa6) [#4640](https://github.com/keystonejs/keystone/pull/4640) Thanks [@timleslie](https://github.com/timleslie)! - Replaced usage of deprecated `findOne()` method with `findUnique()`. - -- Updated dependencies [[`3b7a056bb`](https://github.com/keystonejs/keystone/commit/3b7a056bb835482ceb408a70bf97300741552d19), [`b76241695`](https://github.com/keystonejs/keystone/commit/b7624169554b01dba2185ef43856a223d32f12be), [`4768fbf83`](https://github.com/keystonejs/keystone/commit/4768fbf831ffff648e540c479a1954ae40e05aaa), [`74a8528ea`](https://github.com/keystonejs/keystone/commit/74a8528ea0dad739f4f16af32fe4f8926a188b61)]: - - @keystonejs/keystone@18.1.0 - - @keystonejs/utils@6.0.0 - -## 1.1.1 - -### Patch Changes - -- Updated dependencies [[`1200c3562`](https://github.com/keystonejs/keystone/commit/1200c356272ae8deea9da4267ce62c1449498e95), [`1200c3562`](https://github.com/keystonejs/keystone/commit/1200c356272ae8deea9da4267ce62c1449498e95)]: - - @keystonejs/keystone@18.0.0 - -## 1.1.0 - -### Minor Changes - -- [`defd05365`](https://github.com/keystonejs/keystone/commit/defd05365f31d0d6d4b6fd9ffe0a0c3928f97e79) [#4518](https://github.com/keystonejs/keystone/pull/4518) Thanks [@renovate](https://github.com/apps/renovate)! - `Updated prisma monorepo to`v2.12.1`. - -### Patch Changes - -- Updated dependencies []: - - @keystonejs/fields-auto-increment@8.1.1 - -## 1.0.8 - -### Patch Changes - -- [`325910f8d`](https://github.com/keystonejs/keystone/commit/325910f8ddaf2b620ce08d64dc97850d57840115) [#4188](https://github.com/keystonejs/keystone/pull/4188) Thanks [@renovate](https://github.com/apps/renovate)! - Updated Prisma dependencies to `2.11.0`. - -* [`745270261`](https://github.com/keystonejs/keystone/commit/745270261f86337206802bd4e66541c98fd4407f) [#4076](https://github.com/keystonejs/keystone/pull/4076) Thanks [@renovate](https://github.com/apps/renovate)! - Updated dependency `@prisma/sdk` to `2.10.2`. - -* Updated dependencies [[`fab97f6b4`](https://github.com/keystonejs/keystone/commit/fab97f6b416d7040cdd159be379e226142fc189c)]: - - @keystonejs/fields-auto-increment@8.1.0 - -## 1.0.7 - -### Patch Changes - -- [`f2b841b90`](https://github.com/keystonejs/keystone/commit/f2b841b90d5ac8adece645df45b8a17832391b50) [#4056](https://github.com/keystonejs/keystone/pull/4056) Thanks [@renovate](https://github.com/apps/renovate)! - Updated prisma monorepo to `2.10.0`. - -- Updated dependencies [[`3dd5c570a`](https://github.com/keystonejs/keystone/commit/3dd5c570a27d0795a689407d96fc9623c90a66df)]: - - @keystonejs/keystone@17.1.1 - - @keystonejs/fields-auto-increment@8.0.1 - -## 1.0.6 - -### Patch Changes - -- [`874fb3377`](https://github.com/keystonejs/keystone/commit/874fb337786dba2a2513f754bdfb2ab93ac81598) [#4009](https://github.com/keystonejs/keystone/pull/4009) Thanks [@timleslie](https://github.com/timleslie)! - Added a `provider` config option to `PrismaAdapter`. Only `postgresql` is currently supported, and this is the default value. - -## 1.0.5 - -### Patch Changes - -- [`29d55659c`](https://github.com/keystonejs/keystone/commit/29d55659ccbb224a5b63e608d1e6bba98d053f71) [#3942](https://github.com/keystonejs/keystone/pull/3942) Thanks [@renovate](https://github.com/apps/renovate)! - Updated `prisma` dependencies to `v2.9.0`. - -## 1.0.4 - -### Patch Changes - -- [`d157e7666`](https://github.com/keystonejs/keystone/commit/d157e7666d1057cbeab7dc274244d0e130171ec9) [#3893](https://github.com/keystonejs/keystone/pull/3893) Thanks [@renovate](https://github.com/apps/renovate)! - Updated `prisma` monorepo dependency to `v2.8.1`. - -- Updated dependencies [[`20c921c80`](https://github.com/keystonejs/keystone/commit/20c921c805f9ba8e779d0af584e6ff036c264bd4)]: - - @keystonejs/keystone@17.1.0 - -## 1.0.3 - -### Patch Changes - -- [`f30928db3`](https://github.com/keystonejs/keystone/commit/f30928db31b0c0a10a27b827b44afc1896dfbafe) [#3788](https://github.com/keystonejs/keystone/pull/3788) Thanks [@timleslie](https://github.com/timleslie)! - Added improved documentation. - -* [`bf06edbf4`](https://github.com/keystonejs/keystone/commit/bf06edbf47e69280c3a9e270daa578528d68c447) [#3856](https://github.com/keystonejs/keystone/pull/3856) Thanks [@timleslie](https://github.com/timleslie)! - Updated `prisma` dependency to `2.8.0`. Removed `insensitiveFilters` from `previewFeatures` in `prisma.schema`.. - -* Updated dependencies [[`e5efd0ef3`](https://github.com/keystonejs/keystone/commit/e5efd0ef3d6943534cb6c728afe5dbf0caf43e74)]: - - @keystonejs/fields-auto-increment@8.0.0 - -## 1.0.2 - -### Patch Changes - -- [`4072ee2b2`](https://github.com/keystonejs/keystone/commit/4072ee2b2746938fc7d41dbecedaaaf0e0b3ff68) [#3821](https://github.com/keystonejs/keystone/pull/3821) Thanks [@timleslie](https://github.com/timleslie)! - Fixed queries with `{search: ""}`, which should return all items in the list. - -## 1.0.1 - -### Patch Changes - -- [`eb8180bb8`](https://github.com/keystonejs/keystone/commit/eb8180bb87b62dc3ac317c4f04f988a122c57358) [#3806](https://github.com/keystonejs/keystone/pull/3806) Thanks [@timleslie](https://github.com/timleslie)! - Fixed an issue with the prisma client not being regenerated when the schema changed. - -## 1.0.0 - -### Major Changes - -- [`f70c9f1ba`](https://github.com/keystonejs/keystone/commit/f70c9f1ba7452b54a15ab71943a3777d5b6dade4) [#3298](https://github.com/keystonejs/keystone/pull/3298) Thanks [@timleslie](https://github.com/timleslie)! - Added support for a Prisma adapter to Keystone. - -### Patch Changes - -- Updated dependencies [[`f70c9f1ba`](https://github.com/keystonejs/keystone/commit/f70c9f1ba7452b54a15ab71943a3777d5b6dade4), [`df0687184`](https://github.com/keystonejs/keystone/commit/df068718456d23819a7cae491870be4560b2010d), [`cc56990f2`](https://github.com/keystonejs/keystone/commit/cc56990f2e9a4ecf0c112362e8d472b9286f76bc)]: - - @keystonejs/fields-auto-increment@7.0.0 - - @keystonejs/keystone@17.0.0 diff --git a/packages/adapter-prisma/README.md b/packages/adapter-prisma/README.md deleted file mode 100644 index a8c72c3814b..00000000000 --- a/packages/adapter-prisma/README.md +++ /dev/null @@ -1,65 +0,0 @@ - - -# Prisma database adapter - -[![View changelog](https://img.shields.io/badge/changelogs.xyz-Explore%20Changelog-brightgreen)](https://changelogs.xyz/@keystone-next/adapter-prisma-legacy) - -The Prisma adapter allows Keystone to connect a database using Prisma Client, a type-safe and auto-generated database client. You can learn more about Prisma Client in the [Prisma docs](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client). - -> **Tip:** Want to get started with Keystone + Prisma? [Follow the guide](/docs/guides/prisma.md)! -> -> **Warning:** The Keystone Prisma adapter is not currently production-ready. It depends on the [Prisma Migrate](https://www.prisma.io/docs/concepts/components/prisma-migrate) system which is currently flagged as `Preview`. Once Prisma Migrate is out of preview mode, we will release a production-ready version of this package. -> -> **Note:** This adapter currently only supports PostgreSQL databases, and has other limitations. For more details, see our [Prisma Adapter - Production Ready Checklist](/docs/discussions/prisma.md) - -## Usage - -```javascript -const { PrismaAdapter } = require('@keystone-next/adapter-prisma-legacy'); - -const keystone = new Keystone({ - adapter: new PrismaAdapter({ url: 'postgres://...' }), -}); -``` - -## Config - -### `url` - -_**Default:**_ `DATABASE_URL` - -The connection string for your database, in the form `postgres://:@:/`. -By default it will use the value of the environment variable `DATABASE_URL`. You can learn more about the connection string format used in the [Prisma docs](https://www.prisma.io/docs/reference/database-connectors/connection-urls). - -### `enableLogging` - -_**Default:**_ `false` - -Enables logging at the [`query`](https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-client/logging#overview) level in the Prisma client. - -## Setup - -Before running Keystone with the Prisma adapter you will need to have a PostgreSQL database to connect to. - -If you already have a database then you can use its connection string in the `url` config option. -If you don't have a database already then you can create one locally with the following commands. - -```shell allowCopy=false showLanguage=false -createdb -U postgres keystone -psql keystone -U postgres -c "CREATE USER keystone5 PASSWORD 'change_me_plz'" -psql keystone -U postgres -c "GRANT ALL ON DATABASE keystone TO keystone5;" -``` - -If using the above, you will want to set a connection string of: - -```javascript -const keystone = new Keystone({ - adapter: new PrismaAdapter({ url: `postgres://keystone5:change_me_plz@localhost:5432/keystone` }), -}); -``` - -See the [adapters setup](/docs/quick-start/adapters.md) guide for more details on how to setup a database. diff --git a/packages/adapter-prisma/package.json b/packages/adapter-prisma/package.json deleted file mode 100644 index 3824ed7c742..00000000000 --- a/packages/adapter-prisma/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@keystone-next/adapter-prisma-legacy", - "description": "KeystoneJS Prisma Database Adapter", - "main": "dist/adapter-prisma-legacy.cjs.js", - "module": "dist/adapter-prisma-legacy.esm.js", - "version": "8.0.0", - "author": "The KeystoneJS Development Team", - "license": "MIT", - "engines": { - "node": "^12.20 || >= 14.13" - }, - "dependencies": { - "@babel/runtime": "^7.14.0", - "@keystone-next/fields": "^10.0.0", - "@keystone-next/types": "^19.0.0", - "@keystone-next/utils-legacy": "^11.0.1", - "p-waterfall": "^2.1.1" - }, - "repository": "https://github.com/keystonejs/keystone/tree/master/packages/adapter-prisma" -} diff --git a/packages/adapter-prisma/src/adapter-prisma.ts b/packages/adapter-prisma/src/adapter-prisma.ts deleted file mode 100644 index 0af2e4a58ab..00000000000 --- a/packages/adapter-prisma/src/adapter-prisma.ts +++ /dev/null @@ -1,759 +0,0 @@ -import pWaterfall from 'p-waterfall'; -import { defaultObj, mapKeys, identity, flatten } from '@keystone-next/utils-legacy'; -import { Implementation } from '@keystone-next/fields'; -import { BaseKeystoneList, Rel } from '@keystone-next/types'; -import { GqlNames } from '../../../packages-next/types/src'; - -// Note: These type definitions are preliminary while we're working towards -// a full TypeScript conversion. -type Rels = Rel[]; - -type ListAdapterConfig = { searchField?: string }; - -type ItemQueryArgs = Record; - -type IdType = string | number; - -type FromType = { fromId?: IdType; fromField?: string; fromList?: BaseKeystoneList }; - -type PrismaModel = { - count: (filter: Filter) => Promise; - findMany: (filter: Filter) => Promise; - delete: (arg: { where: { id: number } }) => Promise; - findUnique: (args: { - where: { id: number }; - include?: Record; - }) => Promise | undefined>; - create: (args: { data: Record; include?: Record }) => Promise; - update: (args: { - where: { id: number }; - data: Record; - include?: Record; - }) => Promise; -}; - -type Filter = { - where?: Record; - take?: number; - skip?: number; - orderBy?: Record; - include?: Record; -}; - -class PrismaAdapter { - config: { - prismaClient?: any; - provider?: 'postgresql' | 'sqlite'; - enableLogging?: boolean; - url?: string; - }; - listAdapters: Record; - listAdapterClass?: typeof PrismaListAdapter; - name: 'prisma'; - provider: 'postgresql' | 'sqlite'; - enableLogging: boolean; - url: string; - - schemaPath?: string; - clientPath?: string; - prisma: any; - - constructor(config = {}) { - this.config = { ...config }; - this.listAdapters = {}; - this.name = 'prisma'; - this.provider = this.config.provider || 'postgresql'; - this.enableLogging = this.config.enableLogging || false; - this.url = this.config.url || process.env.DATABASE_URL || ''; - } - - newListAdapter(key: string, adapterConfig: ListAdapterConfig, gqlNames: GqlNames) { - this.listAdapters[key] = new PrismaListAdapter(key, this, adapterConfig, gqlNames); - return this.listAdapters[key]; - } - - getListAdapterByKey(key: string) { - return this.listAdapters[key]; - } - - async connect({ rels }: { rels: Rels }) { - // Connect to the database - // the adapter was already connected since we have a prisma client - // it may have been disconnected since it was connected though - // so connect but don't regenerate the prisma client - if (this.prisma) { - await this.prisma.$connect(); - return; - } - if (!this.config.prismaClient) { - throw new Error('You must pass the prismaClient option to connect to a database'); - } - this.prisma = new this.config.prismaClient({ - log: this.enableLogging && ['query'], - datasources: { [this.provider]: { url: this.url } }, - }); - this.prisma.$on('beforeExit', async () => { - // Prisma is failing to properly clean up its child processes - // https://github.com/keystonejs/keystone/issues/5477 - // We explicitly send a SIGINT signal to the prisma child process on exit - // to ensure that the process is cleaned up appropriately. - this.prisma._engine.child.kill('SIGINT'); - }); - - // Set up all list adapter models - Object.values(this.listAdapters).forEach(listAdapter => { - listAdapter._setupModel({ rels, prisma: this.prisma }); - }); - - await this.prisma.$connect(); - } - - _generatePrismaSchema({ rels, clientDir }: { rels: Rels; clientDir: string }) { - const models = Object.values(this.listAdapters).map(listAdapter => { - const scalarFields = flatten( - listAdapter.fieldAdapters.filter(f => !f.field.isRelationship).map(f => f.getPrismaSchema()) - ); - const relFields = [ - ...flatten( - listAdapter.fieldAdapters - .map(({ field }) => field) - .filter(f => f.isRelationship) - .map(f => { - const r = rels.find(r => r.left === f || r.right === f) as Rel; - const isLeft = r.left === f; - if (r.cardinality === 'N:N') { - const relName = r.tableName; - return [`${f.path} ${f.refListKey}[] @relation("${relName}", references: [id])`]; - } else { - const relName = `${r.tableName}${r.columnName}`; - if ( - (r.cardinality === 'N:1' && isLeft) || - (r.cardinality === '1:N' && !isLeft) || - (r.cardinality === '1:1' && isLeft) - ) { - // We're the owner of the foreign key column - return [ - `${f.path} ${f.refListKey}? @relation("${relName}", fields: [${f.path}Id], references: [id])`, - `${f.path}Id Int? @map("${r.columnName}")`, - ]; - } else if (r.cardinality === '1:1') { - return [`${f.path} ${f.refListKey}? @relation("${relName}")`]; - } else { - return [`${f.path} ${f.refListKey}[] @relation("${relName}")`]; - } - } - }) - ), - ...flatten( - rels - .filter(({ right }) => !right) - .filter(({ left }) => left.refListKey === listAdapter.key) - .filter(({ cardinality }) => cardinality === 'N:N') - .map(({ left: { path, listKey }, tableName }) => [ - `from_${listKey}_${path} ${listKey}[] @relation("${tableName}", references: [id])`, - ]) - ), - ...flatten( - rels - .filter(({ right }) => !right) - .filter(({ left }) => left.refListKey === listAdapter.key) - .filter(({ cardinality }) => cardinality === '1:N' || cardinality === 'N:1') - .map(({ left: { path, listKey }, tableName, columnName }) => [ - `from_${listKey}_${path} ${listKey}[] @relation("${tableName}${columnName}")`, - ]) - ), - ]; - - const indexes = [ - ...flatten( - listAdapter.fieldAdapters - .map(({ field }) => field) - .filter(f => f.isRelationship) - .map(f => { - const r = rels.find(r => r.left === f || r.right === f) as Rel; - const isLeft = r.left === f; - if ( - (r.cardinality === 'N:1' && isLeft) || - (r.cardinality === '1:N' && !isLeft) || - (r.cardinality === '1:1' && isLeft) - ) { - return [`@@index([${f.path}Id])`]; - } - return []; - }) - ), - ...flatten( - listAdapter.fieldAdapters - .filter(f => f.config.isIndexed) - .map(f => { - return [`@@index([${f.path}])`]; - }) - ), - ]; - - return ` - model ${listAdapter.key} { - ${[...scalarFields, ...relFields, ...indexes].join('\n ')} - }`; - }); - - const enums = flatten( - Object.values(this.listAdapters).map(listAdapter => - flatten( - listAdapter.fieldAdapters - .filter(f => !f.field.isRelationship) - .filter(f => f.path !== 'id') - .map(f => f.getPrismaEnums()) - ) - ) - ); - - const header = ` - datasource ${this.provider} { - url = env("DATABASE_URL") - provider = "${this.provider}" - } - generator client { - provider = "prisma-client-js" - output = "${clientDir}" - }`; - return header + models.join('\n') + '\n' + enums.join('\n'); - } - - disconnect() { - return this.prisma.$disconnect(); - } -} - -class PrismaListAdapter { - key: string; - parentAdapter: PrismaAdapter; - fieldAdapters: PrismaFieldAdapter[]; - fieldAdaptersByPath: Record>; - config: ListAdapterConfig; - gqlNames: GqlNames; - preSaveHooks: any[]; - postReadHooks: any[]; - model?: PrismaModel; - getListAdapterByKey: (key: string) => PrismaListAdapter | undefined; - - constructor( - key: string, - parentAdapter: PrismaAdapter, - config: ListAdapterConfig, - gqlNames: GqlNames - ) { - this.key = key; - this.parentAdapter = parentAdapter; - this.fieldAdapters = []; - this.fieldAdaptersByPath = {}; - this.config = config; - this.gqlNames = gqlNames; - - this.preSaveHooks = []; - this.postReadHooks = [ - (item: any) => { - // FIXME: This can hopefully be removed once graphql 14.1.0 is released. - // https://github.com/graphql/graphql-js/pull/1520 - if (item && item.id) item.id = item.id.toString(); - return item; - }, - ]; - this.getListAdapterByKey = parentAdapter.getListAdapterByKey.bind(parentAdapter); - } - - newFieldAdapter

( - fieldAdapterClass: typeof PrismaFieldAdapter, - name: string, - path: P, - field: Implementation

, - getListByKey: (key: string) => BaseKeystoneList | undefined, - config: Record - ) { - const adapter = new fieldAdapterClass(name, path, field, this, getListByKey, config); - adapter.setupHooks({ - addPreSaveHook: this.addPreSaveHook.bind(this), - addPostReadHook: this.addPostReadHook.bind(this), - }); - this.fieldAdapters.push(adapter); - this.fieldAdaptersByPath[adapter.path] = adapter; - return adapter; - } - - addPreSaveHook(hook: any) { - this.preSaveHooks.push(hook); - } - - addPostReadHook(hook: any) { - this.postReadHooks.push(hook); - } - - onPreSave(item: any) { - // We waterfall so the final item is a composed version of the input passing - // through each consecutive hook - return pWaterfall(this.preSaveHooks, item) as Promise>; - } - - async onPostRead(item: Promise) { - // We waterfall so the final item is a composed version of the input passing - // through each consecutive hook - return pWaterfall(this.postReadHooks, await item); - } - - async create(data: any) { - return this.onPostRead(this._create(await this.onPreSave(data))); - } - - async delete(id: IdType) { - return this._delete(id); - } - - async update(id: IdType, data: any) { - return this.onPostRead(this._update(id, await this.onPreSave(data))); - } - - async itemsQuery(args: ItemQueryArgs, { meta = false, from = {} } = {}) { - const results = await this._itemsQuery(args, { meta, from }); - return meta - ? results - : Promise.all((results as any[]).map((item: any) => this.onPostRead(item))); - } - - _setupModel({ rels, prisma }: { rels: Rels; prisma: any }) { - // https://www.prisma.io/docs/reference/tools-and-interfaces/prisma-schema/models#queries-crud - // "By default the name of the property is the lowercase form of the model name, - // e.g. user for a User model or post for a Post model." - this.model = prisma[this.key.slice(0, 1).toLowerCase() + this.key.slice(1)]; - this.fieldAdapters.forEach(fieldAdapter => { - fieldAdapter.rel = rels.find( - ({ left, right }) => - left.adapter === fieldAdapter || (right && right.adapter === fieldAdapter) - ); - }); - } - - ////////// Mutations ////////// - _include() { - // We don't have a "real key" (i.e. a column in the table) if: - // * We're a N:N - // * We're the right hand side of a 1:1 - // * We're the 1 side of a 1:N or N:1 (e.g we are the one with config: many) - const include = defaultObj( - this.fieldAdapters - .filter(({ isRelationship }) => isRelationship) - .filter(a => a.config.many || (a.rel!.cardinality === '1:1' && a.rel!.right!.adapter === a)) - .map(a => a.path), - { select: { id: true } } - ); - return Object.keys(include).length > 0 ? include : undefined; - } - - async _create(_data: Record) { - return this.model!.create({ - data: mapKeys(_data, (value, path) => - this.fieldAdaptersByPath[path] && this.fieldAdaptersByPath[path].isRelationship - ? { - connect: Array.isArray(value) - ? value.map(x => ({ id: Number(x) })) - : { id: Number(value) }, - } - : this.fieldAdaptersByPath[path] - ? this.fieldAdaptersByPath[path].gqlToPrisma(value) - : value - ), - include: this._include(), - }); - } - - async _update(id: IdType, _data: Record) { - const include = this._include(); - const existingItem = await this.model!.findUnique({ where: { id: Number(id) }, include }); - return this.model!.update({ - where: { id: Number(id) }, - data: mapKeys(_data, (value, path) => { - if ( - this.fieldAdaptersByPath[path] && - this.fieldAdaptersByPath[path].isRelationship && - Array.isArray(value) - ) { - const vs = value.map(x => Number(x)); - const toDisconnect = (existingItem![path] as { id: number }[]).filter( - ({ id }) => !vs.includes(id) - ); - const toConnect = vs - .filter( - id => !(existingItem![path] as { id: number }[]).map(({ id }) => id).includes(id) - ) - .map(id => ({ id })); - return { - disconnect: toDisconnect.length ? toDisconnect : undefined, - connect: toConnect.length ? toConnect : undefined, - }; - } - return this.fieldAdaptersByPath[path] && this.fieldAdaptersByPath[path].isRelationship - ? value === null - ? { disconnect: true } - : { connect: { id: Number(value) } } - : value; - }), - include, - }); - } - - async _delete(id: IdType) { - return this.model!.delete({ where: { id: Number(id) } }); - } - - ////////// Queries ////////// - async _itemsQuery( - args: ItemQueryArgs, - { meta = false, from = {} }: { meta?: boolean; from?: FromType } = {} - ) { - const filter = this.prismaFilter({ args, meta, from }); - if (meta) { - let count = await this.model!.count(filter); - const { first, skip } = args; - - // Adjust the count as appropriate - if (skip !== undefined && skip !== null) { - count -= skip; - } - if (first !== undefined && first !== null) { - count = Math.min(count, first); - } - count = Math.max(0, count); // Don't want to go negative from a skip! - return { count }; - } else { - return this.model!.findMany(filter); - } - } - - prismaFilter({ - args: { where = {}, first, skip, sortBy, orderBy, search }, - meta, - from, - }: { - args: { - where?: Record | null; - first?: number | null; - skip?: number | null; - sortBy?: string[]; - orderBy?: Record; - search?: string; - }; - meta: boolean; - from: FromType; - }) { - const ret: Filter = {}; - const allWheres = this.processWheres(where); - - if (allWheres) { - ret.where = allWheres; - } - - if (from.fromId) { - if (!ret.where) { - ret.where = {}; - } - const a = from.fromList!.adapter.fieldAdaptersByPath[from.fromField!]; - if (!a.rel) throw Error(`Relationship information missing from field adapter ${a}`); - if (a.rel.cardinality === 'N:N') { - const path = a.rel.right - ? a.field === a.rel.right // Two-sided - ? a.rel.left.path - : a.rel.right.path - : `from_${a.rel.left.listKey}_${a.rel.left.path}`; // One-sided - ret.where[path] = { some: { id: Number(from.fromId) } }; - } else { - ret.where[a.rel.columnName!] = { id: Number(from.fromId) }; - } - } - - // TODO: Implement configurable search fields for lists - const searchFieldName = this.config.searchField || 'name'; - const searchField = this.fieldAdaptersByPath[searchFieldName]; - if (search !== undefined && search !== '' && searchField) { - if (searchField.fieldName === 'Text') { - // FIXME: Think about regex - const mode = this.parentAdapter.provider === 'sqlite' ? undefined : 'insensitive'; - if (!ret.where) { - ret.where = { [searchFieldName]: { contains: search, mode } }; - } else { - ret.where = { - AND: [ret.where, { [searchFieldName]: { contains: search, mode } }], - }; - } - // const f = escapeRegExp; - // this._query.andWhere(`${baseTableAlias}.${searchFieldName}`, '~*', f(search)); - } else { - // Return no results - if (!ret.where) { - ret.where = { AND: [{ [searchFieldName]: null }, { NOT: { [searchFieldName]: null } }] }; - } else { - ret.where = { - AND: [ret.where, { [searchFieldName]: null }, { NOT: { [searchFieldName]: null } }], - }; - } - } - } - - // Add query modifiers as required - if (!meta) { - if (first !== undefined && first !== null) { - // SELECT ... LIMIT - ret.take = first; - } - if (skip !== undefined && skip !== null) { - // SELECT ... OFFSET - ret.skip = skip; - } - if (orderBy !== undefined) { - // SELECT ... ORDER BY [, , ...] - if (!ret.orderBy) ret.orderBy = []; - orderBy.forEach((order: any) => { - if (Object.keys(order).length !== 1) { - throw new Error(`Only a single key must be passed to ${this.gqlNames.listOrderName}`); - } - ret.orderBy!.push(order); - }); - } - if (sortBy !== undefined) { - // SELECT ... ORDER BY [, , ...] - if (!ret.orderBy) ret.orderBy = []; - sortBy.forEach(s => { - const [orderField, orderDirection] = s.split('_'); - const sortKey = this.fieldAdaptersByPath[orderField].sortKey || orderField; - ret.orderBy!.push({ [sortKey]: orderDirection.toLowerCase() }); - }); - } - - this.fieldAdapters - .filter(a => a.isRelationship && a.rel!.cardinality === '1:1' && a.rel!.right === a.field) - .forEach(({ path }) => { - if (!ret.include) ret.include = {}; - ret.include[path] = true; - }); - } - return ret; - } - - processWheres(where: Record | null): Record | undefined { - if (where === null) return undefined; - const processRelClause = (fieldPath: string, clause: Record) => - this.getListAdapterByKey(this.fieldAdaptersByPath[fieldPath].refListKey!)!.processWheres( - clause - ); - const wheres = Object.entries(where).map(([condition, value]) => { - if (condition === 'AND' || condition === 'OR') { - return { [condition]: (value as Record[]).map(w => this.processWheres(w)) }; - } else if ( - this.fieldAdaptersByPath[condition] && - this.fieldAdaptersByPath[condition].isRelationship - ) { - // Non-many relationship. Traverse the sub-query, using the referenced list as a root. - return { [condition]: processRelClause(condition, value) }; - } else { - // See if any of our fields know what to do with this condition - let dbPath = condition; - let fieldAdapter = this.fieldAdaptersByPath[dbPath]; - while (!fieldAdapter && dbPath.includes('_')) { - dbPath = dbPath.split('_').slice(0, -1).join('_'); - fieldAdapter = this.fieldAdaptersByPath[dbPath]; - } - - // FIXME: ask the field adapter if it supports the condition type - const supported = - fieldAdapter && fieldAdapter.getQueryConditions(fieldAdapter.dbPath)[condition]; - if (supported) { - return supported(value); - } else { - // Many relationship - const [fieldPath, constraintType] = condition.split('_'); - return { [fieldPath]: { [constraintType]: processRelClause(fieldPath, value) } }; - } - } - }); - - return wheres.length === 0 ? undefined : wheres.length === 1 ? wheres[0] : { AND: wheres }; - } -} - -// FIXME: Enumerate the valid types -type PrismaType = string; - -class PrismaFieldAdapter

{ - fieldName: string; - path: P; - field: Implementation

; - listAdapter: PrismaListAdapter; - config: Record; - getListByKey: (arg: string) => BaseKeystoneList | undefined; - dbPath: string; - isRelationship?: boolean; - rel?: Rel; - sortKey?: string; - refListKey?: string; - - constructor( - fieldName: string, - path: P, - field: Implementation

, - listAdapter: PrismaListAdapter, - getListByKey: (arg: string) => BaseKeystoneList | undefined, - config = {} - ) { - this.fieldName = fieldName; - this.path = path; - this.field = field; - this.listAdapter = listAdapter; - this.config = config; - this.getListByKey = getListByKey; - this.dbPath = path; - } - - setupHooks({}) {} - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getQueryConditions(dbPath: string) { - return {} as Record; - } - - gqlToPrisma(value: any) { - return value; - } - - _schemaField({ type, extra = '' }: { type: PrismaType; extra?: string }) { - const { isRequired, isUnique } = this.config; - return `${this.path} ${type}${isRequired || this.field.isPrimaryKey ? '' : '?'} ${ - this.field.isPrimaryKey ? '@id' : '' - } ${isUnique && !this.field.isPrimaryKey ? '@unique' : ''} ${extra}`; - } - - getPrismaSchema(): string[] { - return [this._schemaField({ type: 'String' })]; - } - - getPrismaEnums(): string[] { - return []; - } - - // The following methods provide helpers for constructing the return values of `getQueryConditions`. - // Each method takes: - // `dbPath`: The database field/column name to be used in the comparison - // `f`: (non-string methods only) A value transformation function which converts from a string type - // provided by graphQL into a native adapter type. - equalityConditions(dbPath: string, f: (a: any) => any = identity) { - return { - [this.path]: (value: T) => ({ [dbPath]: { equals: f(value) } }), - [`${this.path}_not`]: (value: T | null) => - value === null - ? { NOT: { [dbPath]: { equals: f(value) } } } - : { - OR: [{ NOT: { [dbPath]: { equals: f(value) } } }, { [dbPath]: { equals: null } }], - }, - }; - } - - equalityConditionsInsensitive(dbPath: string, f: (a: any) => any = identity) { - return { - [`${this.path}_i`]: (value: string) => ({ - [dbPath]: { equals: f(value), mode: 'insensitive' }, - }), - [`${this.path}_not_i`]: (value: string) => - value === null - ? { NOT: { [dbPath]: { equals: f(value), mode: 'insensitive' } } } - : { - OR: [ - { NOT: { [dbPath]: { equals: f(value), mode: 'insensitive' } } }, - { [dbPath]: null }, - ], - }, - }; - } - - inConditions(dbPath: string, f: (a: any) => any = identity) { - return { - [`${this.path}_in`]: (value: (T | null)[]) => - (value.includes(null) - ? { OR: [{ [dbPath]: { in: value.filter(x => x !== null).map(f) } }, { [dbPath]: null }] } - : { [dbPath]: { in: value.map(f) } }) as Record, - [`${this.path}_not_in`]: (value: (T | null)[]) => - (value.includes(null) - ? { - AND: [ - { NOT: { [dbPath]: { in: value.filter(x => x !== null).map(f) } } }, - { NOT: { [dbPath]: null } }, - ], - } - : { - OR: [{ NOT: { [dbPath]: { in: value.map(f) } } }, { [dbPath]: null }], - }) as Record, - }; - } - - orderingConditions(dbPath: string, f: (a: any) => any = identity) { - return { - [`${this.path}_lt`]: (value: T) => ({ [dbPath]: { lt: f(value) } }), - [`${this.path}_lte`]: (value: T) => ({ [dbPath]: { lte: f(value) } }), - [`${this.path}_gt`]: (value: T) => ({ [dbPath]: { gt: f(value) } }), - [`${this.path}_gte`]: (value: T) => ({ [dbPath]: { gte: f(value) } }), - }; - } - - containsConditions(dbPath: string, f: (a: any) => any = identity) { - return { - [`${this.path}_contains`]: (value: string) => ({ [dbPath]: { contains: f(value) } }), - [`${this.path}_not_contains`]: (value: string) => ({ - OR: [{ NOT: { [dbPath]: { contains: f(value) } } }, { [dbPath]: null }], - }), - }; - } - - stringConditions(dbPath: string, f: (a: any) => any = identity) { - return { - ...this.containsConditions(dbPath, f), - [`${this.path}_starts_with`]: (value: string) => ({ [dbPath]: { startsWith: f(value) } }), - [`${this.path}_not_starts_with`]: (value: string) => ({ - OR: [{ NOT: { [dbPath]: { startsWith: f(value) } } }, { [dbPath]: null }], - }), - [`${this.path}_ends_with`]: (value: string) => ({ [dbPath]: { endsWith: f(value) } }), - [`${this.path}_not_ends_with`]: (value: string) => ({ - OR: [{ NOT: { [dbPath]: { endsWith: f(value) } } }, { [dbPath]: null }], - }), - }; - } - - stringConditionsInsensitive(dbPath: string, f: (a: any) => any = identity) { - return { - [`${this.path}_contains_i`]: (value: string) => ({ - [dbPath]: { contains: f(value), mode: 'insensitive' }, - }), - [`${this.path}_not_contains_i`]: (value: string) => ({ - OR: [ - { NOT: { [dbPath]: { contains: f(value), mode: 'insensitive' } } }, - { [dbPath]: null }, - ], - }), - [`${this.path}_starts_with_i`]: (value: string) => ({ - [dbPath]: { startsWith: f(value), mode: 'insensitive' }, - }), - [`${this.path}_not_starts_with_i`]: (value: string) => ({ - OR: [ - { NOT: { [dbPath]: { startsWith: f(value), mode: 'insensitive' } } }, - { [dbPath]: null }, - ], - }), - [`${this.path}_ends_with_i`]: (value: string) => ({ - [dbPath]: { endsWith: f(value), mode: 'insensitive' }, - }), - [`${this.path}_not_ends_with_i`]: (value: string) => ({ - OR: [ - { NOT: { [dbPath]: { endsWith: f(value), mode: 'insensitive' } } }, - { [dbPath]: null }, - ], - }), - }; - } -} - -export { PrismaAdapter, PrismaListAdapter, PrismaFieldAdapter }; diff --git a/packages/adapter-prisma/src/index.ts b/packages/adapter-prisma/src/index.ts deleted file mode 100644 index ada3115c6e1..00000000000 --- a/packages/adapter-prisma/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PrismaAdapter, PrismaListAdapter, PrismaFieldAdapter } from './adapter-prisma'; diff --git a/packages/adapter-prisma/tests/adapter-prisma.test.ts b/packages/adapter-prisma/tests/adapter-prisma.test.ts deleted file mode 100644 index 2192cd010ab..00000000000 --- a/packages/adapter-prisma/tests/adapter-prisma.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { PrismaAdapter } from '..'; - -// @ts-ignore -global.console = { - error: jest.fn(), - warn: jest.fn(), - log: jest.fn(), -}; - -describe('Prisma Adapter', () => { - // eslint-disable-next-line jest/no-disabled-tests - test.skip('throws when database cannot be found using connection string', async () => { - const testAdapter = new PrismaAdapter({ url: 'postgres://localhost/undefined_database' }); - const result = await testAdapter.connect({ rels: [] }).catch(result => result); - - expect(result).toBeInstanceOf(Error); - expect(global.console.error).toHaveBeenCalledWith( - "Could not connect to database: 'undefined_database'" - ); - }); - - // eslint-disable-next-line jest/no-disabled-tests - test.skip('throws when database cannot be found using connection object', async () => { - const testAdapter = new PrismaAdapter({ - url: 'postgres://your_database_user:your_database_password@127.0.0.1/undefined_database', - }); - const result = await testAdapter.connect({ rels: [] }).catch(result => result); - - expect(result).toBeInstanceOf(Error); - expect(global.console.error).toHaveBeenCalledWith( - "Could not connect to database: 'undefined_database'" - ); - }); -}); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 19554759d1c..defa1f5d568 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -10,7 +10,6 @@ "node": "^12.20 || >= 14.13" }, "dependencies": { - "@keystone-next/adapter-prisma-legacy": "^8.0.0", "@keystone-next/keystone": "19.0.0", "express": "^4.17.1", "memoize-one": "^5.2.1", diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 4d09d066838..d87be967af6 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -47,8 +47,9 @@ async function setupFromConfig({ ui: { isDisabled: true }, }); + const { graphQLSchema, getKeystone } = createSystem(config); + const prismaClient = await (async () => { - const { graphQLSchema } = createSystem(config); const artifacts = await getCommittedArtifacts(graphQLSchema, config); const hash = hashPrismaSchema(artifacts.prisma); if (provider === 'postgresql') { @@ -70,14 +71,21 @@ async function setupFromConfig({ return requirePrismaClient(cwd); })(); - const { keystone, createContext, graphQLSchema } = createSystem(config, prismaClient); + const keystone = getKeystone(prismaClient); - const app = await createExpressServer(config, graphQLSchema, createContext, true, '', false); + const app = await createExpressServer( + config, + graphQLSchema, + keystone.createContext, + true, + '', + false + ); return { connect: () => keystone.connect(), disconnect: () => keystone.disconnect(), - context: createContext().sudo(), + context: keystone.createContext().sudo(), app, }; } diff --git a/tests/api-tests/access-control/mutations-list-static.test.ts b/tests/api-tests/access-control/mutations-list-static.test.ts index c5d806ca2aa..7ed0cc92040 100644 --- a/tests/api-tests/access-control/mutations-list-static.test.ts +++ b/tests/api-tests/access-control/mutations-list-static.test.ts @@ -14,33 +14,17 @@ function setupKeystone(provider: ProviderName) { access: { read: true, create: ({ originalInput }) => { - if (Array.isArray(originalInput)) { - return !originalInput.some(item => item.data.name === 'bad'); - } else { - return (originalInput as any).name !== 'bad'; - } + return originalInput.name !== 'bad'; }, update: ({ originalInput }) => { - if (Array.isArray(originalInput)) { - return !originalInput.some(item => item.data.name === 'bad'); - } else { - return (originalInput as any).name !== 'bad'; - } + return originalInput.name !== 'bad'; }, - delete: async ({ context, itemId, itemIds }) => { - if (itemIds !== undefined) { - const items = await context.lists.User.findMany({ - where: { id_in: itemIds }, - query: 'id name', - }); - return !items.some(item => item.name.startsWith('no delete')); - } else { - const item = await context.lists.User.findOne({ - where: { id: itemId as string }, - query: 'id name', - }); - return item?.name !== 'no delete'; - } + delete: async ({ context, itemId }) => { + const item = await context.lists.User.findOne({ + where: { id: itemId as string }, + query: 'id name', + }); + return !item.name.startsWith('no delete'); }, }, }), @@ -148,25 +132,28 @@ multiAdapterRunners().map(({ runner, provider }) => }, }); - // If one item errors, they all error, so they all return null - expect(data!.createUsers).toEqual([null, null, null, null, null]); + // Valid users are returned, invalid come back as null + expect(data!.createUsers).toEqual([ + { id: expect.any(String), name: 'good 1' }, + null, + { id: expect.any(String), name: 'good 2' }, + null, + { id: expect.any(String), name: 'good 3' }, + ]); - // Error messages for each item - expect(errors).toHaveLength(5); + // The invalid updates should have errors which point to the nulls in their path + expect(errors).toHaveLength(2); expect(errors![0].message).toEqual('You do not have access to this resource'); - expect(errors![0].path).toEqual(['createUsers', 0]); + expect(errors![0].path).toEqual(['createUsers', 1]); expect(errors![1].message).toEqual('You do not have access to this resource'); - expect(errors![1].path).toEqual(['createUsers', 1]); - expect(errors![2].message).toEqual('You do not have access to this resource'); - expect(errors![2].path).toEqual(['createUsers', 2]); - expect(errors![3].message).toEqual('You do not have access to this resource'); - expect(errors![3].path).toEqual(['createUsers', 3]); - expect(errors![4].message).toEqual('You do not have access to this resource'); - expect(errors![4].path).toEqual(['createUsers', 4]); + expect(errors![1].path).toEqual(['createUsers', 3]); - // No users should exist in the database + // The good users should exist in the database const users = await context.lists.User.findMany(); - expect(users).toEqual([]); + // the ordering isn't consistent so we order them ourselves here + expect(users.map(x => x.id).sort()).toEqual( + [data!.createUsers[0].id, data!.createUsers[2].id, data!.createUsers[4].id].sort() + ); }) ); @@ -199,19 +186,20 @@ multiAdapterRunners().map(({ runner, provider }) => }, }); - // If one item errors, they all error, so they all return null - expect(data!.updateUsers).toEqual([null, null, null, null]); + // Valid users are returned, invalid come back as null + expect(data!.updateUsers).toEqual([ + { id: users[0].id, name: 'still good 1' }, + null, + { id: users[2].id, name: 'still good 3' }, + null, + ]); - // Error messages for each item - expect(errors).toHaveLength(4); + // The invalid updates should have errors which point to the nulls in their path + expect(errors).toHaveLength(2); expect(errors![0].message).toEqual('You do not have access to this resource'); - expect(errors![0].path).toEqual(['updateUsers', 0]); + expect(errors![0].path).toEqual(['updateUsers', 1]); expect(errors![1].message).toEqual('You do not have access to this resource'); - expect(errors![1].path).toEqual(['updateUsers', 1]); - expect(errors![2].message).toEqual('You do not have access to this resource'); - expect(errors![2].path).toEqual(['updateUsers', 2]); - expect(errors![3].message).toEqual('You do not have access to this resource'); - expect(errors![3].path).toEqual(['updateUsers', 3]); + expect(errors![1].path).toEqual(['updateUsers', 3]); // All users should still exist in the database const _users = await context.lists.User.findMany({ @@ -219,11 +207,11 @@ multiAdapterRunners().map(({ runner, provider }) => query: 'id name', }); expect(_users.map(({ name }) => name)).toEqual([ - 'good 1', 'good 2', - 'good 3', 'good 4', 'good 5', + 'still good 1', + 'still good 3', ]); }) ); @@ -250,32 +238,26 @@ multiAdapterRunners().map(({ runner, provider }) => variables: { ids: [users[0].id, users[1].id, users[2].id, users[3].id] }, }); - // If one item errors, they all error, so they all return null - expect(data!.deleteUsers).toEqual([null, null, null, null]); + // Valid users are returned, invalid come back as null + expect(data!.deleteUsers).toEqual([ + { id: users[0].id, name: 'good 1' }, + null, + { id: users[2].id, name: 'good 3' }, + null, + ]); - // Error messages for each item - expect(errors).toHaveLength(4); + // The invalid updates should have errors which point to the nulls in their path + expect(errors).toHaveLength(2); expect(errors![0].message).toEqual('You do not have access to this resource'); - expect(errors![0].path).toEqual(['deleteUsers', 0]); + expect(errors![0].path).toEqual(['deleteUsers', 1]); expect(errors![1].message).toEqual('You do not have access to this resource'); - expect(errors![1].path).toEqual(['deleteUsers', 1]); - expect(errors![2].message).toEqual('You do not have access to this resource'); - expect(errors![2].path).toEqual(['deleteUsers', 2]); - expect(errors![3].message).toEqual('You do not have access to this resource'); - expect(errors![3].path).toEqual(['deleteUsers', 3]); + expect(errors![1].path).toEqual(['deleteUsers', 3]); - // All users should still exist in the database const _users = await context.lists.User.findMany({ orderBy: { name: 'asc' }, query: 'id name', }); - expect(_users.map(({ name }) => name)).toEqual([ - 'good 1', - 'good 3', - 'good 5', - 'no delete 1', - 'no delete 2', - ]); + expect(_users.map(({ name }) => name)).toEqual(['good 5', 'no delete 1', 'no delete 2']); }) ); }); diff --git a/tests/api-tests/fields/types/Virtual.test.ts b/tests/api-tests/fields/types/Virtual.test.ts index 84bd1ae7050..eefbdb81fe9 100644 --- a/tests/api-tests/fields/types/Virtual.test.ts +++ b/tests/api-tests/fields/types/Virtual.test.ts @@ -1,4 +1,4 @@ -import { integer, virtual } from '@keystone-next/fields'; +import { integer, relationship, text, virtual } from '@keystone-next/fields'; import { BaseFields, createSchema, list } from '@keystone-next/keystone/schema'; import { ProviderName, @@ -6,6 +6,7 @@ import { setupFromConfig, testConfig, } from '@keystone-next/test-utils-legacy'; +import { schema } from '@keystone-next/types'; function makeSetupKeystone(fields: BaseFields) { return function setupKeystone(provider: ProviderName) { @@ -29,27 +30,17 @@ multiAdapterRunners().map(({ runner, provider }) => describe(`Provider: ${provider}`, () => { describe('Virtual field type', () => { test( - 'Default - resolver returns a string', + 'no args', runner( makeSetupKeystone({ - foo: virtual({ resolver: () => 'Hello world!' }), - }), - async ({ context }) => { - const data = await context.lists.Post.createOne({ - data: { value: 1 }, - query: 'value foo', - }); - expect(data.value).toEqual(1); - expect(data.foo).toEqual('Hello world!'); - } - ) - ); - - test( - 'graphQLReturnType', - runner( - makeSetupKeystone({ - foo: virtual({ graphQLReturnType: 'Int', resolver: () => 42 }), + foo: virtual({ + field: schema.field({ + type: schema.Int, + resolve() { + return 42; + }, + }), + }), }), async ({ context }) => { const data = await context.lists.Post.createOne({ @@ -67,12 +58,14 @@ multiAdapterRunners().map(({ runner, provider }) => runner( makeSetupKeystone({ foo: virtual({ - graphQLReturnType: 'Int', - args: [ - { name: 'x', type: 'Int' }, - { name: 'y', type: 'Int' }, - ], - resolver: (item, { x = 5, y = 6 }) => x * y, + field: schema.field({ + type: schema.Int, + args: { + x: schema.arg({ type: schema.Int }), + y: schema.arg({ type: schema.Int }), + }, + resolve: (item, { x = 5, y = 6 }) => x! * y!, + }), }), }), async ({ context }) => { @@ -91,12 +84,14 @@ multiAdapterRunners().map(({ runner, provider }) => runner( makeSetupKeystone({ foo: virtual({ - graphQLReturnType: 'Int', - args: [ - { name: 'x', type: 'Int' }, - { name: 'y', type: 'Int' }, - ], - resolver: (item, { x = 5, y = 6 }) => x * y, + field: schema.field({ + type: schema.Int, + args: { + x: schema.arg({ type: schema.Int }), + y: schema.arg({ type: schema.Int }), + }, + resolve: (item, { x = 5, y = 6 }) => x! * y!, + }), }), }), async ({ context }) => { @@ -110,14 +105,117 @@ multiAdapterRunners().map(({ runner, provider }) => ) ); + test( + 'referencing other list type', + runner( + function setupKeystone(provider: ProviderName) { + return setupFromConfig({ + provider, + config: testConfig({ + lists: createSchema({ + Organisation: list({ + fields: { + name: text(), + authoredPosts: relationship({ ref: 'Post.organisationAuthor', many: true }), + }, + }), + Person: list({ + fields: { + name: text(), + authoredPosts: relationship({ ref: 'Post.personAuthor', many: true }), + }, + }), + Post: list({ + fields: { + organisationAuthor: relationship({ ref: 'Organisation.authoredPosts' }), + personAuthor: relationship({ ref: 'Person.authoredPosts' }), + author: virtual({ + field: lists => + schema.field({ + type: schema.union({ + name: 'Author', + types: [lists.Person.types.output, lists.Organisation.types.output], + }), + async resolve(rootVal, args, context) { + const [personAuthors, organisationAuthors] = await Promise.all([ + context.db.lists.Person.findMany({ + where: { authoredPosts_some: { id: rootVal.id.toString() } }, + }), + context.db.lists.Organisation.findMany({ + where: { authoredPosts_some: { id: rootVal.id.toString() } }, + }), + ]); + if (personAuthors.length) { + return { __typename: 'Person', ...personAuthors[0] }; + } + if (organisationAuthors.length) { + return { __typename: 'Organisation', ...organisationAuthors[0] }; + } + }, + }), + }), + }, + }), + }), + }), + }); + }, + async ({ context }) => { + const data = await context.lists.Post.createOne({ + data: { personAuthor: { create: { name: 'person author' } } }, + query: ` + author { + __typename + ... on Person { + name + } + ... on Organisation { + name + } + } + `, + }); + expect(data.author.name).toEqual('person author'); + expect(data.author.__typename).toEqual('Person'); + const data2 = await context.lists.Post.createOne({ + data: { organisationAuthor: { create: { name: 'organisation author' } } }, + query: ` + author { + __typename + ... on Person { + name + } + ... on Organisation { + name + } + } + `, + }); + expect(data2.author.name).toEqual('organisation author'); + expect(data2.author.__typename).toEqual('Organisation'); + } + ) + ); + test( 'graphQLReturnFragment', runner( makeSetupKeystone({ foo: virtual({ - extendGraphQLTypes: [`type Movie { title: String, rating: Int }`], - graphQLReturnType: '[Movie]', - resolver: () => [{ title: 'CATS!', rating: 100 }], + field: schema.field({ + type: schema.list( + schema.object<{ title: string; rating: number }>()({ + name: 'Movie', + fields: { + title: schema.field({ type: schema.String }), + rating: schema.field({ type: schema.Int }), + }, + }) + ), + resolve() { + return [{ title: 'CATS!', rating: 100 }]; + }, + }), }), }), async ({ context }) => { diff --git a/tests/api-tests/relationships/nested-mutations/create-many.test.ts b/tests/api-tests/relationships/nested-mutations/create-many.test.ts index 32a5484ce97..2355ba41981 100644 --- a/tests/api-tests/relationships/nested-mutations/create-many.test.ts +++ b/tests/api-tests/relationships/nested-mutations/create-many.test.ts @@ -63,6 +63,47 @@ function setupKeystone(provider: ProviderName) { multiAdapterRunners().map(({ runner, provider }) => describe(`Provider: ${provider}`, () => { + let afterChangeWasCalled = false; + + test( + 'afterChange is called for nested creates', + runner( + function setupKeystone(provider: ProviderName) { + return setupFromConfig({ + provider, + config: testConfig({ + lists: createSchema({ + Note: list({ + fields: { + content: text(), + }, + hooks: { + afterChange() { + afterChangeWasCalled = true; + }, + }, + }), + User: list({ + fields: { + username: text(), + notes: relationship({ ref: 'Note', many: true }), + }, + }), + }), + }), + }); + }, + async ({ context }) => { + // Update an item that does the nested create + const item = await context.lists.User.createOne({ + data: { username: 'something', notes: { create: [{ content: 'some content' }] } }, + query: 'username notes {content}', + }); + expect(item).toEqual({ username: 'something', notes: [{ content: 'some content' }] }); + expect(afterChangeWasCalled).toBe(true); + } + ) + ); describe('no access control', () => { test( 'create nested from within create mutation', diff --git a/yarn.lock b/yarn.lock index 960204de1ff..8a5d3495158 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1492,6 +1492,13 @@ camel-case "4.1.2" tslib "~2.2.0" +"@graphql-ts/schema@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@graphql-ts/schema/-/schema-0.1.1.tgz#effeb3389086ce66e9c30e71ac0c40e89e692144" + integrity sha512-I37QdhawPsK+HbIUcD0W4+yEB7/xSEy4y6MlHkIIujxeLYVt+ifoywZm72Te85MnOIl2qeW/q0wTVrzEmXGbcQ== + dependencies: + "@babel/runtime" "^7.9.2" + "@graphql-typed-document-node/core@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.0.tgz#0eee6373e11418bfe0b5638f654df7a4ca6a3950" @@ -2472,13 +2479,6 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== -"@ts-gql/schema@^0.7.3": - version "0.7.3" - resolved "https://registry.yarnpkg.com/@ts-gql/schema/-/schema-0.7.3.tgz#f7d0b711965170d60457c41ab564c1343782567f" - integrity sha512-l7olrJWmnOU+JG1M+MNdPzEv5M6yRbhWAjnSvtbE4vlgOW0yqn1dAPvPZc/NxTYyxlWAKcnY++DXWOCUcjnpNA== - dependencies: - "@babel/runtime" "^7.9.2" - "@types/accepts@*", "@types/accepts@^1.3.5": version "1.3.5" resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" @@ -2768,20 +2768,6 @@ resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72" integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw== -"@types/keystonejs__fields@*", "@types/keystonejs__fields@^5.1.1": - version "5.1.1" - resolved "https://registry.yarnpkg.com/@types/keystonejs__fields/-/keystonejs__fields-5.1.1.tgz#1c91718eeee0b2e58a2b3f57ad234967bad202df" - integrity sha512-m8Ny/rTXzzoKO33UDkcspmHK0Q/qP4teo2510+HG1NMxM7pF/jPPL/viGtAJF/eIYEsSZxzwNSTFWMw/DlPIwQ== - -"@types/keystonejs__keystone@^7.0.1": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@types/keystonejs__keystone/-/keystonejs__keystone-7.0.1.tgz#3dbb00813aff29149f0ae877514be0b077455ec0" - integrity sha512-m0LTB8aKkBQrU5+B2Qc2e5YMdi/YrmqH5doPtETqda6zMaFeV+L9DGAUQMyT8CjOK73kPDMVljNDkZ5/MvQ3RA== - dependencies: - "@types/express" "*" - "@types/keystonejs__fields" "*" - graphql "^15.4.0" - "@types/koa-compose@*": version "3.2.5" resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d" @@ -7102,7 +7088,7 @@ graphql-upload@^12.0.0: isobject "^4.0.0" object-path "^0.11.5" -graphql@^15.3.0, graphql@^15.4.0, graphql@^15.5.0: +graphql@^15.3.0, graphql@^15.5.0: version "15.5.0" resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== @@ -8915,11 +8901,6 @@ lodash.flatten@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= -lodash.groupby@^4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz#0b08a1dcf68397c397855c3239783832df7403d1" - integrity sha1-Cwih3PaDl8OXhVwyOXg4Mt90A9E= - lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" @@ -10134,7 +10115,7 @@ p-limit@^1.1.0: dependencies: p-try "^1.0.0" -p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.2.1, p-limit@^2.2.2: +p-limit@^2.0.0, p-limit@^2.2.0, p-limit@^2.2.1: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -10181,11 +10162,6 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" -p-reduce@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-2.1.0.tgz#09408da49507c6c274faa31f28df334bc712b64a" - integrity sha512-2USApvnsutq8uoxZBGbbWM0JIYLiEMJ9RlaN7fAzVNb9OZN0SHjjTTfIcb667XynS5Y1VhwDJVDa72TnPzAYWw== - p-reflect@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-reflect/-/p-reflect-2.1.0.tgz#5d67c7b3c577c4e780b9451fc9129675bd99fe67" @@ -10199,14 +10175,6 @@ p-retry@^4.2.0: "@types/retry" "^0.12.0" retry "^0.12.0" -p-settle@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/p-settle/-/p-settle-4.1.1.tgz#37fbceb2b02c9efc28658fc8d36949922266035f" - integrity sha512-6THGh13mt3gypcNMm0ADqVNCcYa3BK6DWsuJWFCuEKP1rpY+OKGp7gaZwVmLspmic01+fsg/fN57MfvDzZ/PuQ== - dependencies: - p-limit "^2.2.2" - p-reflect "^2.1.0" - p-try@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" @@ -10217,13 +10185,6 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -p-waterfall@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/p-waterfall/-/p-waterfall-2.1.1.tgz#63153a774f472ccdc4eb281cdb2967fcf158b2ee" - integrity sha512-RRTnDb2TBG/epPRI2yYXsimO0v3BXC8Yd3ogr1545IaqKK17VGhbWVeGGN+XfCm/08OK8635nH31c8bATkHuSw== - dependencies: - p-reduce "^2.0.0" - package-json@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0"