Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shorter many to many relation names #6917

Merged
merged 14 commits into from
Nov 22, 2021
5 changes: 5 additions & 0 deletions .changeset/giant-cycles-retire.md
Original file line number Diff line number Diff line change
@@ -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.
186 changes: 186 additions & 0 deletions .changeset/tasty-buses-jog.md
Original file line number Diff line number Diff line change
@@ -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");
```
4 changes: 2 additions & 2 deletions examples-staging/graphql-api-endpoint/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ 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])
}

model Tag {
id String @id @default(cuid())
name String @default("")
posts Post[] @relation("Post_tags_Tag_posts")
posts Post[] @relation("Post_tags")
}
4 changes: 2 additions & 2 deletions packages/keystone/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export async function getCommittedArtifacts(
graphQLSchema: GraphQLSchema,
config: KeystoneConfig
): Promise<CommittedArtifacts> {
const lists = initialiseLists(config.lists, config.db.provider);
const lists = initialiseLists(config);
const prismaSchema = printPrismaSchema(
lists,
config.db.provider,
Expand Down Expand Up @@ -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');
Expand Down
4 changes: 4 additions & 0 deletions packages/keystone/src/fields/types/relationship/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ type OneDbConfig = {

type ManyDbConfig = {
many: true;
db?: {
relationName?: string;
};
};

export type RelationshipFieldConfig<TGeneratedListTypes extends BaseGeneratedListTypes> =
Expand Down Expand Up @@ -156,6 +159,7 @@ export const relationship =
mode: 'many',
list: foreignListKey,
field: foreignFieldKey,
relationName: config.db?.relationName,
})({
...commonConfig,
input: {
Expand Down
34 changes: 28 additions & 6 deletions packages/keystone/src/lib/core/resolve-relationships.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ type Rel = {
field: RelationDBField<'many' | 'one'>;
};

type RelWithoutForeignKey = Omit<Rel, 'field'> & {
field: Omit<RelationDBField<'many' | 'one'>, 'foreignKey'>;
type RelWithoutForeignKeyAndName = Omit<Rel, 'field'> & {
field: Omit<RelationDBField<'many' | 'one'>, '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(
Expand All @@ -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) {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
8 changes: 3 additions & 5 deletions packages/keystone/src/lib/core/types-for-lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
ListInfo,
ListHooks,
KeystoneConfig,
DatabaseProvider,
FindManyArgs,
CacheHintArgs,
MaybePromise,
Expand Down Expand Up @@ -69,10 +68,9 @@ export type InitialisedList = {
};
};

export function initialiseLists(
listsConfig: KeystoneConfig['lists'],
provider: DatabaseProvider
): Record<string, InitialisedList> {
export function initialiseLists(config: KeystoneConfig): Record<string, InitialisedList> {
const listsConfig = config.lists;
const { provider } = config.db;
const listInfos: Record<string, ListInfo> = {};
const isEnabled: Record<
string,
Expand Down
10 changes: 5 additions & 5 deletions packages/keystone/src/lib/createSystem.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading