diff --git a/.changeset/giant-cycles-retire.md b/.changeset/giant-cycles-retire.md new file mode 100644 index 00000000000..8621a2761a3 --- /dev/null +++ b/.changeset/giant-cycles-retire.md @@ -0,0 +1,5 @@ +--- +'@keystone-next/keystone': minor +--- + +Added `db.relationName` option to many to many `relationship` fields to allow explicitly setting the relation name. diff --git a/.changeset/tasty-buses-jog.md b/.changeset/tasty-buses-jog.md new file mode 100644 index 00000000000..29a1f1d0fb0 --- /dev/null +++ b/.changeset/tasty-buses-jog.md @@ -0,0 +1,186 @@ +--- +'@keystone-next/keystone': major +--- + +The names of one-sided and two-sided, many-many relationships has been shortened. Two-sided many-many relationship names contain only the left-hand side names now; and the `_many` suffix has been dropped from one-sided many-many relationships. + +This reduces the probability that you will exceed [PostgreSQL's 63 character limit for identifiers](https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS) with typical usage. + +This is a breaking change. + +There are two ways to update: + +### Set `db.relationName` on many to many relation + +Rather than doing a migration, you can set the new field property `db.relationName`, for either side of a many-to-many relationship field. +If set to the existing relation name, your database will remain unchanged. + +For example, given a schema like this: + +```ts +Post: list({ + fields: { + tags: relationship({ ref: 'Tag.posts', many: true }), + }, +}), +Tag: list({ + fields: { + posts: relationship({ ref: 'Post.tags', many: true }), + }, +}), +``` + +Before this release, the generated Prisma schema looked like this: + +```prisma +// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource postgresql { + url = env("DATABASE_URL") + provider = "postgresql" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/.prisma/client" + engineType = "binary" +} + +model Post { + id String @id @default(cuid()) + tags Tag[] @relation("Post_tags_Tag_posts") +} + +model Tag { + id String @id @default(cuid()) + posts Post[] @relation("Post_tags_Tag_posts") +} +``` + +By adding `db: { relationName: 'Post_tags_Tag_posts' }` to one side of the many-to-many relationship; you can preclude yourself from a migration. + +**Note:** It doesn't matter which side of the relationship you put this property, but it should be only on one side; otherwise you will receive an error. + +```ts +Post: list({ + fields: { + tags: relationship({ ref: 'Tag.posts', many: true, db: { relationName: 'Post_tags_Tag_posts' } }), + }, +}), +Tag: list({ + fields: { + posts: relationship({ ref: 'Post.tags', many: true }), + }, +}), +``` + + +### Rename your many relation tables using a migration + +For example, given a schema like this: + +```ts +Post: list({ + fields: { + tags: relationship({ ref: 'Tag.posts', many: true }), + }, +}), +Tag: list({ + fields: { + posts: relationship({ ref: 'Post.tags', many: true }), + }, +}), +``` + +When updating to this change, and running `yarn dev`, Keystone will prompt you to update your schema. + +- If you are using `useMigrations: true`, Keystone will follow the typical migration flow offer to apply an automatically generated migration. **DO NOT APPLY THE AUTOMATICALLY GENERATED MIGRATION** - unless you want to `DROP` your data. + +- If you are using `useMigrations: false`, Keystone will follow the typical flow and offer to automatically migrate your schema. Again, **DO NOT RUN THE AUTOMATIC MIGRATION** - unless you want to `DROP` your data. + +On PostgreSQL, Prisma will generate a migration that looks something like this: + +```sql +/* + Warnings: + + - You are about to drop the `_Post_tags_Tag_posts` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_Post_tags_Tag_posts" DROP CONSTRAINT "_Post_tags_Tag_posts_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_Post_tags_Tag_posts" DROP CONSTRAINT "_Post_tags_Tag_posts_B_fkey"; + +-- DropTable +DROP TABLE "_Post_tags_Tag_posts"; + +-- CreateTable +CREATE TABLE "_Post_tags" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_Post_tags_AB_unique" ON "_Post_tags"("A", "B"); + +-- CreateIndex +CREATE INDEX "_Post_tags_B_index" ON "_Post_tags"("B"); + +-- AddForeignKey +ALTER TABLE "_Post_tags" ADD FOREIGN KEY ("A") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_Post_tags" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; +``` + +You need to modify it so that it looks like this with the old and new table names for your schema substituted: + +```sql +ALTER TABLE "_Post_tags_Tag_posts" RENAME TO "_Post_tags"; +ALTER INDEX "_Post_tags_Tag_posts_AB_unique" RENAME TO "_Post_tags_AB_unique"; +ALTER INDEX "_Post_tags_Tag_posts_B_index" RENAME TO "_Post_tags_B_index"; +ALTER TABLE "_Post_tags" RENAME CONSTRAINT "_Post_tags_Tag_posts_A_fkey" TO "_Post_tags_A_fkey"; +ALTER TABLE "_Post_tags" RENAME CONSTRAINT "_Post_tags_Tag_posts_B_fkey" TO "_Post_tags_B_fkey"; +``` + +On SQLite, Prisma will generate a migration that looks something like this: + +```sql +/* + Warnings: + + - You are about to drop the `_Post_tags_Tag_posts` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "_Post_tags_Tag_posts"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "_Post_tags" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + FOREIGN KEY ("A") REFERENCES "Post" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY ("B") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "_Post_tags_AB_unique" ON "_Post_tags"("A", "B"); + +-- CreateIndex +CREATE INDEX "_Post_tags_B_index" ON "_Post_tags"("B"); +``` + +You need to modify it so that it looks like this with the old and new table names for your schema substituted: + +```sql +ALTER TABLE "_Post_tags_Tag_posts" RENAME TO "_Post_tags"; +DROP INDEX "_Post_tags_Tag_posts_AB_unique"; +DROP INDEX "_Post_tags_Tag_posts_B_index"; +CREATE UNIQUE INDEX "_Post_tags_AB_unique" ON "_Post_tags"("A", "B"); +CREATE INDEX "_Post_tags_B_index" ON "_Post_tags"("B"); +``` diff --git a/examples-staging/graphql-api-endpoint/schema.prisma b/examples-staging/graphql-api-endpoint/schema.prisma index 86431d10314..482d94159b7 100644 --- a/examples-staging/graphql-api-endpoint/schema.prisma +++ b/examples-staging/graphql-api-endpoint/schema.prisma @@ -27,7 +27,7 @@ model Post { publishDate DateTime? author User? @relation("Post_author", fields: [authorId], references: [id]) authorId String? @map("author") - tags Tag[] @relation("Post_tags_Tag_posts") + tags Tag[] @relation("Post_tags") @@index([authorId]) } @@ -35,5 +35,5 @@ model Post { model Tag { id String @id @default(cuid()) name String @default("") - posts Post[] @relation("Post_tags_Tag_posts") + posts Post[] @relation("Post_tags") } \ No newline at end of file diff --git a/packages/keystone/src/artifacts.ts b/packages/keystone/src/artifacts.ts index 816aaafe86f..403fea81d43 100644 --- a/packages/keystone/src/artifacts.ts +++ b/packages/keystone/src/artifacts.ts @@ -35,7 +35,7 @@ export async function getCommittedArtifacts( graphQLSchema: GraphQLSchema, config: KeystoneConfig ): Promise { - const lists = initialiseLists(config.lists, config.db.provider); + const lists = initialiseLists(config); const prismaSchema = printPrismaSchema( lists, config.db.provider, @@ -183,7 +183,7 @@ export async function generateNodeModulesArtifactsWithoutPrismaClient( config: KeystoneConfig, cwd: string ) { - const lists = initialiseLists(config.lists, config.db.provider); + const lists = initialiseLists(config); const printedSchema = printSchema(graphQLSchema); const dotKeystoneDir = path.join(cwd, 'node_modules/.keystone'); diff --git a/packages/keystone/src/fields/types/relationship/index.ts b/packages/keystone/src/fields/types/relationship/index.ts index a7c0aebfbfb..cacbb645050 100644 --- a/packages/keystone/src/fields/types/relationship/index.ts +++ b/packages/keystone/src/fields/types/relationship/index.ts @@ -61,6 +61,9 @@ type OneDbConfig = { type ManyDbConfig = { many: true; + db?: { + relationName?: string; + }; }; export type RelationshipFieldConfig = @@ -156,6 +159,7 @@ export const relationship = mode: 'many', list: foreignListKey, field: foreignFieldKey, + relationName: config.db?.relationName, })({ ...commonConfig, input: { diff --git a/packages/keystone/src/lib/core/resolve-relationships.ts b/packages/keystone/src/lib/core/resolve-relationships.ts index e34d8313ed9..3ad694935fe 100644 --- a/packages/keystone/src/lib/core/resolve-relationships.ts +++ b/packages/keystone/src/lib/core/resolve-relationships.ts @@ -37,11 +37,11 @@ type Rel = { field: RelationDBField<'many' | 'one'>; }; -type RelWithoutForeignKey = Omit & { - field: Omit, 'foreignKey'>; +type RelWithoutForeignKeyAndName = Omit & { + field: Omit, 'foreignKey' | 'relationName'>; }; -function sortRelationships(left: Rel, right: Rel): [Rel, RelWithoutForeignKey] { +function sortRelationships(left: Rel, right: Rel): readonly [Rel, RelWithoutForeignKeyAndName] { if (left.field.mode === 'one' && right.field.mode === 'one') { if (left.field.foreignKey !== undefined && right.field.foreignKey !== undefined) { throw new Error( @@ -54,7 +54,28 @@ function sortRelationships(left: Rel, right: Rel): [Rel, RelWithoutForeignKey] { } } else if (left.field.mode === 'one' || right.field.mode === 'one') { // many relationships will never have a foreign key, so return the one relationship first - return left.field.mode === 'one' ? [left, right] : [right, left]; + const rels = left.field.mode === 'one' ? ([left, right] as const) : ([right, left] as const); + // we're only doing this for rels[1] because: + // - rels[1] is the many side + // - for the one side, TypeScript will already disallow relationName + if (rels[1].field.relationName !== undefined) { + throw new Error( + `You can only set db.relationName on one side of a many to many relationship, but db.relationName is set on ${rels[1].listKey}.${rels[1].fieldPath} which is the many side of a many to one relationship with ${rels[0].listKey}.${rels[0].fieldPath}` + ); + } + return rels; + } + if ( + left.field.mode === 'many' && + right.field.mode === 'many' && + (left.field.relationName !== undefined || right.field.relationName !== undefined) + ) { + if (left.field.relationName !== undefined && right.field.relationName !== undefined) { + throw new Error( + `You can only set db.relationName on one side of a many to many relationship, but db.relationName is set on both ${left.listKey}.${left.fieldPath} and ${right.listKey}.${right.fieldPath}` + ); + } + return left.field.relationName !== undefined ? [left, right] : [right, left]; } const order = left.listKey.localeCompare(right.listKey); if (order > 0) { @@ -166,7 +187,8 @@ export function resolveRelationships( continue; } if (leftRel.field.mode === 'many' && rightRel.field.mode === 'many') { - const relationName = `${leftRel.listKey}_${leftRel.fieldPath}_${rightRel.listKey}_${rightRel.fieldPath}`; + const relationName = + leftRel.field.relationName ?? `${leftRel.listKey}_${leftRel.fieldPath}`; resolvedLists[leftRel.listKey][leftRel.fieldPath] = { kind: 'relation', mode: 'many', @@ -215,7 +237,7 @@ export function resolveRelationships( } if (field.mode === 'many') { - const relationName = `${listKey}_${fieldPath}_many`; + const relationName = field.relationName ?? `${listKey}_${fieldPath}`; resolvedLists[field.list][foreignFieldPath] = { kind: 'relation', mode: 'many', diff --git a/packages/keystone/src/lib/core/types-for-lists.ts b/packages/keystone/src/lib/core/types-for-lists.ts index 4d7067390a2..62e270750ad 100644 --- a/packages/keystone/src/lib/core/types-for-lists.ts +++ b/packages/keystone/src/lib/core/types-for-lists.ts @@ -8,7 +8,6 @@ import { ListInfo, ListHooks, KeystoneConfig, - DatabaseProvider, FindManyArgs, CacheHintArgs, MaybePromise, @@ -69,10 +68,9 @@ export type InitialisedList = { }; }; -export function initialiseLists( - listsConfig: KeystoneConfig['lists'], - provider: DatabaseProvider -): Record { +export function initialiseLists(config: KeystoneConfig): Record { + const listsConfig = config.lists; + const { provider } = config.db; const listInfos: Record = {}; const isEnabled: Record< string, diff --git a/packages/keystone/src/lib/createSystem.ts b/packages/keystone/src/lib/createSystem.ts index faada014a5d..4438e1aa219 100644 --- a/packages/keystone/src/lib/createSystem.ts +++ b/packages/keystone/src/lib/createSystem.ts @@ -1,5 +1,5 @@ import pLimit from 'p-limit'; -import { FieldData, KeystoneConfig, DatabaseProvider, getGqlNames } from '../types'; +import { FieldData, KeystoneConfig, getGqlNames } from '../types'; import { createAdminMeta } from '../admin-ui/system/createAdminMeta'; import { createGraphQLSchema } from './createGraphQLSchema'; @@ -8,7 +8,7 @@ import { initialiseLists } from './core/types-for-lists'; import { CloudAssetsAPI, getCloudAssetsAPI } from './cloud/assets'; import { setWriteLimit } from './core/utils'; -function getSudoGraphQLSchema(config: KeystoneConfig, provider: DatabaseProvider) { +function getSudoGraphQLSchema(config: KeystoneConfig) { // This function creates a GraphQLSchema based on a modified version of the provided config. // The modifications are: // * All list level access control is disabled @@ -56,19 +56,19 @@ function getSudoGraphQLSchema(config: KeystoneConfig, provider: DatabaseProvider }) ), }; - const lists = initialiseLists(transformedConfig.lists, provider); + const lists = initialiseLists(transformedConfig); const adminMeta = createAdminMeta(transformedConfig, lists); return createGraphQLSchema(transformedConfig, lists, adminMeta); } export function createSystem(config: KeystoneConfig, isLiveReload?: boolean) { - const lists = initialiseLists(config.lists, config.db.provider); + const lists = initialiseLists(config); const adminMeta = createAdminMeta(config, lists); const graphQLSchema = createGraphQLSchema(config, lists, adminMeta); - const sudoGraphQLSchema = getSudoGraphQLSchema(config, config.db.provider); + const sudoGraphQLSchema = getSudoGraphQLSchema(config); return { graphQLSchema, diff --git a/packages/keystone/src/lib/migrations.ts b/packages/keystone/src/lib/migrations.ts index 6f2473a48ac..1118f63c4a8 100644 --- a/packages/keystone/src/lib/migrations.ts +++ b/packages/keystone/src/lib/migrations.ts @@ -99,7 +99,10 @@ export async function pushPrismaSchemaToDatabase( 'If you want to keep the data in your database, set db.useMigrations to true in your config or change the data in your database so the migration can be applied' ); if ( - !(await confirmPrompt(`Do you want to continue? ${chalk.red('All data will be lost')}.`)) + !(await confirmPrompt( + `Do you want to continue? ${chalk.red('All data will be lost')}.`, + false + )) ) { console.log('Reset cancelled'); throw new ExitError(0); @@ -115,7 +118,10 @@ export async function pushPrismaSchemaToDatabase( if (migration.warnings.length) { logWarnings(migration.warnings); if ( - !(await confirmPrompt(`Do you want to continue? ${chalk.red('Some data will be lost')}.`)) + !(await confirmPrompt( + `Do you want to continue? ${chalk.red('Some data will be lost')}.`, + false + )) ) { console.log('Push cancelled.'); throw new ExitError(0); @@ -252,7 +258,8 @@ We need to reset the ${credentials.type} database "${credentials.database}" at $ console.log(`✨ A migration has been created at migrations/${generatedMigrationName}`); let shouldApplyMigration = - migrationCanBeApplied && (await confirmPrompt('Would you like to apply this migration?')); + migrationCanBeApplied && + (await confirmPrompt('Would you like to apply this migration?', false)); if (shouldApplyMigration) { await runMigrateWithDbUrl(dbUrl, () => migrate.applyMigrations()); console.log('✅ The migration has been applied'); diff --git a/packages/keystone/src/lib/prompts.ts b/packages/keystone/src/lib/prompts.ts index ca180d684cd..1aec083f230 100644 --- a/packages/keystone/src/lib/prompts.ts +++ b/packages/keystone/src/lib/prompts.ts @@ -3,12 +3,12 @@ import prompts from 'prompts'; // prompts is badly typed so we have some more specific typed APIs // prompts also returns an undefined value on SIGINT which we really just want to exit on -async function confirmPromptImpl(message: string): Promise { +async function confirmPromptImpl(message: string, initial: boolean = true): Promise { const { value } = await prompts({ name: 'value', type: 'confirm', message, - initial: true, + initial, }); if (value === undefined) { process.exit(1); diff --git a/packages/keystone/src/scripts/run/dev.ts b/packages/keystone/src/scripts/run/dev.ts index ca2b5f61238..2addd408b34 100644 --- a/packages/keystone/src/scripts/run/dev.ts +++ b/packages/keystone/src/scripts/run/dev.ts @@ -97,7 +97,7 @@ exports.default = function (req, res) { return res.send(x.toString()) } let lastVersion = ''; let lastError = undefined; const originalPrismaSchema = printPrismaSchema( - initialiseLists(config.lists, config.db.provider), + initialiseLists(config), config.db.provider, config.db.prismaPreviewFeatures ); @@ -132,7 +132,7 @@ exports.default = function (req, res) { return res.send(x.toString()) } const uninitializedConfig = (await apiRouteModule.getConfig()).default; const newConfig = initConfig(uninitializedConfig); const newPrismaSchema = printPrismaSchema( - initialiseLists(newConfig.lists, newConfig.db.provider), + initialiseLists(newConfig), newConfig.db.provider, newConfig.db.prismaPreviewFeatures ); diff --git a/packages/keystone/src/types/next-fields.ts b/packages/keystone/src/types/next-fields.ts index 8b6a6aff19a..ae23453433f 100644 --- a/packages/keystone/src/types/next-fields.ts +++ b/packages/keystone/src/types/next-fields.ts @@ -96,13 +96,8 @@ export type ScalarDBField< default?: ScalarDBFieldDefault; index?: 'unique' | 'index'; map?: string; -} & (Scalar extends 'DateTime' - ? { - updatedAt?: boolean; - } - : { - updatedAt?: undefined; - }); + updatedAt?: Scalar extends 'DateTime' ? boolean : undefined; +}; export const orderDirectionEnum = graphql.enum({ name: 'OrderDirection', @@ -120,6 +115,7 @@ export type RelationDBField = { field?: string; mode: Mode; foreignKey?: { one: true | { map: string }; many: undefined }[Mode]; + relationName?: { one: undefined; many: string }[Mode]; }; export type EnumDBField = { diff --git a/tests/api-tests/relationships/relation-name.test.ts b/tests/api-tests/relationships/relation-name.test.ts new file mode 100644 index 00000000000..44021a90254 --- /dev/null +++ b/tests/api-tests/relationships/relation-name.test.ts @@ -0,0 +1,177 @@ +import { list } from '@keystone-next/keystone'; +import { relationship } from '@keystone-next/keystone/fields'; +import { getPrismaSchema, apiTestConfig, dbProvider } from '../utils'; + +test('when not specifying relationName in a many to many relationship, the name is picked based on the lexicographic list key + field key ordering', async () => { + const prismaSchema = await getPrismaSchema( + apiTestConfig({ + lists: { + A: list({ + fields: { + b: relationship({ ref: 'B.a', many: true }), + }, + }), + B: list({ + fields: { + a: relationship({ ref: 'A.b', many: true }), + }, + }), + }, + }) + ); + expect(prismaSchema) + .toEqual(`// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource ${dbProvider} { + url = env("DATABASE_URL") + provider = "${dbProvider}" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/.prisma/client" +} + +model A { + id String @id @default(cuid()) + b B[] @relation("A_b") +} + +model B { + id String @id @default(cuid()) + a A[] @relation("A_b") +}`); +}); + +test("the ordering of the lists doesn't affect the relation name", async () => { + const prismaSchema = await getPrismaSchema( + apiTestConfig({ + lists: { + A: list({ + fields: { + b: relationship({ ref: 'B.a', many: true }), + }, + }), + B: list({ + fields: { + a: relationship({ ref: 'A.b', many: true }), + }, + }), + }, + }) + ); + expect(prismaSchema) + .toEqual(`// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource ${dbProvider} { + url = env("DATABASE_URL") + provider = "${dbProvider}" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/.prisma/client" +} + +model A { + id String @id @default(cuid()) + b B[] @relation("A_b") +} + +model B { + id String @id @default(cuid()) + a A[] @relation("A_b") +}`); +}); + +test('when specifying relationName in a many to many relationship, the relation name is set to that', async () => { + const prismaSchema = await getPrismaSchema( + apiTestConfig({ + lists: { + A: list({ + fields: { + b: relationship({ ref: 'B.a', many: true }), + }, + }), + B: list({ + fields: { + a: relationship({ ref: 'A.b', many: true, db: { relationName: 'the_relation_name' } }), + }, + }), + }, + }) + ); + expect(prismaSchema) + .toEqual(`// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource ${dbProvider} { + url = env("DATABASE_URL") + provider = "${dbProvider}" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/.prisma/client" +} + +model A { + id String @id @default(cuid()) + b B[] @relation("the_relation_name") +} + +model B { + id String @id @default(cuid()) + a A[] @relation("the_relation_name") +}`); +}); + +test('when specifying relationName on both sides of a many to many relationship, an error is thrown', async () => { + await expect( + getPrismaSchema( + apiTestConfig({ + lists: { + A: list({ + fields: { + b: relationship({ ref: 'B.a', many: true, db: { relationName: 'blah' } }), + }, + }), + + B: list({ + fields: { + a: relationship({ ref: 'A.b', many: true, db: { relationName: 'blah' } }), + }, + }), + }, + }) + ) + ).rejects.toMatchInlineSnapshot( + `[Error: You can only set db.relationName on one side of a many to many relationship, but db.relationName is set on both A.b and B.a]` + ); +}); + +test('when specifying relationName on the many side of a one to many relationship, an error is thrown', async () => { + await expect( + getPrismaSchema( + apiTestConfig({ + lists: { + A: list({ + fields: { + b: relationship({ ref: 'B.a', many: true, db: { relationName: 'blah' } }), + }, + }), + + B: list({ + fields: { + a: relationship({ ref: 'A.b' }), + }, + }), + }, + }) + ) + ).rejects.toMatchInlineSnapshot( + `[Error: You can only set db.relationName on one side of a many to many relationship, but db.relationName is set on A.b which is the many side of a many to one relationship with B.a]` + ); +});