tests_1-to-n.ts•29.4 kB
import { AdapterProviders, Providers, RelationModes } from '../_utils/providers'
import { checkIfEmpty } from '../_utils/relationMode/checkIfEmpty'
import { ConditionalError } from '../_utils/relationMode/conditionalError'
import testMatrix from './_matrix'
/* eslint-disable @typescript-eslint/no-unused-vars */
// @ts-ignore this is just for type checks
declare let prisma: import('./generated/prisma/client').PrismaClient
// @ts-ignore
const describeIf = (condition: boolean) => (condition ? describe : describe.skip)
// 1:n relation
async function createXUsersWith2Posts({ count, userModel, postModel, postColumn }) {
  const prismaPromises = [] as Array<Promise<any>>
  for (let i = 1; i <= count; i++) {
    const id = i.toString()
    prismaPromises.push(
      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      prisma[userModel].create({
        data: {
          id,
        },
      }),
    )
    prismaPromises.push(
      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      prisma[postModel].create({
        data: {
          id: `${id}-post-a`,
          authorId: id,
        },
      }),
    )
    prismaPromises.push(
      // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
      prisma[postModel].create({
        data: {
          id: `${id}-post-b`,
          authorId: id,
        },
      }),
    )
  }
  return await prisma.$transaction(prismaPromises)
}
testMatrix.setupTestSuite(
  (suiteConfig, suiteMeta) => {
    const conditionalError = ConditionalError.new()
      .with('provider', suiteConfig.provider)
      .with('driverAdapter', suiteConfig.driverAdapter)
      // @ts-ignore
      .with('relationMode', suiteConfig.relationMode || 'foreignKeys')
    const onUpdate = suiteConfig.onUpdate
    const onDelete = suiteConfig.onDelete
    const isMongoDB = suiteConfig.provider === Providers.MONGODB
    const isPostgreSQL = suiteConfig.provider === Providers.POSTGRESQL
    const isSQLite = suiteConfig.provider === Providers.SQLITE
    const isRelationMode_prisma = isMongoDB || suiteConfig.relationMode === RelationModes.PRISMA
    const isRelationMode_foreignKeys = !isRelationMode_prisma
    const isSchemaUsingMap = suiteConfig.isSchemaUsingMap
    // Looking at CI results
    // 30s was often not enough for vitess
    // so we put it back to 60s for now in this case
    if (suiteConfig.driverAdapter === AdapterProviders.VITESS_8) {
      jest.setTimeout(60_000)
    }
    /**
     * 1:n relationship
     */
    describe('1:n mandatory (explicit)', () => {
      const userModel = 'userOneToMany'
      const postModel = 'postOneToMany'
      const postOptionalModel = 'postOptionalOneToMany'
      const postColumn = 'posts'
      const postOptionalColumn = 'postsOptional'
      beforeEach(async () => {
        await prisma.$transaction([
          prisma[postModel].deleteMany(),
          prisma[postOptionalModel].deleteMany(),
          prisma[userModel].deleteMany(),
        ])
      })
      afterEach(async () => {
        await prisma.$disconnect()
      })
      describe('[create]', () => {
        testIf(isRelationMode_prisma)(
          'relationMode=prisma - [create] categoriesOnPostsModel with non-existing post and category id should succeed with prisma emulation',
          async () => {
            await prisma[postModel].create({
              data: {
                id: '1',
                authorId: '1',
              },
            })
            expect(
              await prisma[postModel].findMany({
                where: { authorId: '1' },
              }),
            ).toEqual([
              {
                id: '1',
                authorId: '1',
              },
            ])
          },
        )
        testIf(isRelationMode_foreignKeys)(
          'relationMode=foreignKeys [create] child with non existing parent should throw',
          async () => {
            await expect(
              prisma[postModel].create({
                data: {
                  id: '1',
                  authorId: '1',
                },
              }),
            ).rejects.toThrow(
              isSchemaUsingMap
                ? // The snapshot changes when using @@map/@map, though only the name of the table/field is different
                  // So we can be less specific here
                  `Foreign key constraint violated`
                : conditionalError.snapshot({
                    foreignKeys: {
                      [Providers.POSTGRESQL]:
                        'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                      [Providers.COCKROACHDB]:
                        'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                      [Providers.MYSQL]: 'Foreign key constraint violated on the fields: (`authorId`)',
                      [Providers.SQLSERVER]:
                        'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                      [Providers.SQLITE]: 'Foreign key constraint violated on the foreign key',
                      [AdapterProviders.JS_D1]: 'D1_ERROR: FOREIGN KEY constraint failed',
                    },
                  }),
            )
            expect(
              await prisma[postModel].findMany({
                where: { authorId: '1' },
              }),
            ).toEqual([])
          },
        )
        test('[create] child with undefined parent should throw with type error', async () => {
          await expect(
            prisma[postModel].create({
              data: {
                id: '1',
                authorId: undefined, // this would actually be a type-error, but we don't have access to types here
              },
            }),
          ).rejects.toThrow('Argument `author` is missing.')
        })
        test('[create] nested child [create] should succeed', async () => {
          await prisma[userModel].create({
            data: {
              id: '1',
              posts: {
                create: { id: '1' },
              },
            },
            include: { posts: true },
          })
          expect(
            await prisma[postModel].findMany({
              where: { authorId: '1' },
            }),
          ).toEqual([
            {
              id: '1',
              authorId: '1',
            },
          ])
          expect(
            await prisma[userModel].findUniqueOrThrow({
              where: { id: '1' },
            }),
          ).toEqual({
            id: '1',
            enabled: null,
          })
        })
        test('[create] nested child [createMany]', async () => {
          await prisma[userModel].create({
            data: {
              id: '1',
              posts: {
                createMany: {
                  data: [{ id: '1' }, { id: '2' }],
                },
              },
            },
            include: { posts: true },
          })
          expect(
            await prisma[postModel].findMany({
              where: { authorId: '1' },
              orderBy: { id: 'asc' },
            }),
          ).toEqual([
            {
              id: '1',
              authorId: '1',
            },
            {
              id: '2',
              authorId: '1',
            },
          ])
          expect(
            await prisma[userModel].findUniqueOrThrow({
              where: { id: '1' },
            }),
          ).toEqual({
            id: '1',
            enabled: null,
          })
        })
      })
      describe('[update]', () => {
        beforeEach(async () => {
          await checkIfEmpty(userModel, postModel)
          await createXUsersWith2Posts({
            count: 2,
            userModel,
            postModel,
            postColumn,
          })
        })
        test('[update] optional boolean field should succeed', async () => {
          await prisma[userModel].update({
            where: { id: '1' },
            data: {
              enabled: true,
            },
          })
          expect(
            await prisma[userModel].findMany({
              orderBy: { id: 'asc' },
            }),
          ).toEqual([
            { id: '1', enabled: true },
            { id: '2', enabled: null },
          ])
          expect(
            await prisma[postModel].findMany({
              orderBy: { id: 'asc' },
            }),
          ).toEqual([
            {
              id: '1-post-a',
              authorId: '1',
            },
            {
              id: '1-post-b',
              authorId: '1',
            },
            {
              id: '2-post-a',
              authorId: '2',
            },
            {
              id: '2-post-b',
              authorId: '2',
            },
          ])
        })
        // Not possible on MongoDB as _id is immutable
        describeIf(!isMongoDB)('mutate id tests (skipped only for MongoDB)', () => {
          describeIf(['DEFAULT', 'Cascade'].includes(onUpdate))('onUpdate: DEFAULT, Cascade', () => {
            test('[update] parent id with non-existing id should succeed', async () => {
              await prisma[userModel].update({
                where: { id: '1' },
                data: {
                  id: '3',
                },
                include: { posts: true },
              })
              expect(
                await prisma[userModel].findMany({
                  orderBy: { id: 'asc' },
                }),
              ).toEqual([
                { id: '2', enabled: null },
                { id: '3', enabled: null },
              ])
              expect(
                await prisma[postModel].findMany({
                  orderBy: { id: 'asc' },
                }),
              ).toEqual([
                {
                  id: '1-post-a',
                  authorId: '3',
                },
                {
                  id: '1-post-b',
                  authorId: '3',
                },
                {
                  id: '2-post-a',
                  authorId: '2',
                },
                {
                  id: '2-post-b',
                  authorId: '2',
                },
              ])
            })
          })
          // TODO if other than 'DEFAULT', 'CASCADE'
          test.todo('[update] parent id with non-existing id should throw')
          test('[update] parent id with existing id should throw', async () => {
            await expect(
              prisma[userModel].update({
                where: { id: '1' },
                data: {
                  id: '2',
                },
              }),
            ).rejects.toThrow(
              isSchemaUsingMap
                ? // The snapshot changes when using @@map/@map, though only the name of the table/field is different
                  // So we can ignore the error message here
                  undefined
                : conditionalError.snapshot({
                    foreignKeys: {
                      [Providers.POSTGRESQL]: 'Unique constraint failed on the fields: (`id`)',
                      [Providers.COCKROACHDB]: 'Unique constraint failed on the fields: (`id`)',
                      [Providers.MYSQL]: ['DEFAULT', 'Cascade', 'SetNull'].includes(onUpdate)
                        ? // DEFAULT / Cascade / SetNull
                          'Unique constraint failed on the constraint: `PRIMARY`'
                        : // Other
                          'Foreign key constraint violated on the fields: (`authorId`)',
                      [Providers.SQLSERVER]: 'Unique constraint failed on the constraint: `dbo.UserOneToMany`',
                      [Providers.SQLITE]: 'Unique constraint failed on the fields: (`id`)',
                    },
                    prisma: ['Restrict', 'NoAction'].includes(onUpdate)
                      ? // Restrict / NoAction
                        "The change you are trying to make would violate the required relation 'PostOneToManyToUserOneToMany' between the `PostOneToMany` and `UserOneToMany` models."
                      : // Other
                        {
                          [Providers.POSTGRESQL]: 'Unique constraint failed on the fields: (`id`)',
                          [Providers.COCKROACHDB]: 'Unique constraint failed on the fields: (`id`)',
                          [Providers.MYSQL]: 'Unique constraint failed on the constraint: `PRIMARY`',
                          [Providers.SQLSERVER]: 'Unique constraint failed on the constraint: `dbo.UserOneToMany`',
                          [Providers.SQLITE]: 'Unique constraint failed on the fields: (`id`)',
                          [AdapterProviders.VITESS_8]: 'Unique constraint failed on the (not available)',
                        },
                  }),
            )
            expect(
              await prisma[userModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '1',
                enabled: null,
              },
              {
                id: '2',
                enabled: null,
              },
            ])
          })
          test('[update] child id with non-existing id should succeed', async () => {
            await prisma[postModel].update({
              where: { id: '1-post-a' },
              data: {
                id: '1-post-c',
              },
            })
            expect(
              await prisma[userModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '1',
                enabled: null,
              },
              {
                id: '2',
                enabled: null,
              },
            ])
            expect(
              await prisma[postModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '1-post-b',
                authorId: '1',
              },
              {
                id: '1-post-c',
                authorId: '1',
              },
              {
                id: '2-post-a',
                authorId: '2',
              },
              {
                id: '2-post-b',
                authorId: '2',
              },
            ])
          })
        })
      })
      describe('[delete]', () => {
        beforeEach(async () => {
          await checkIfEmpty(userModel, postModel)
          await createXUsersWith2Posts({
            count: 2,
            userModel,
            postModel,
            postColumn,
          })
        })
        test('[delete] child should succeed', async () => {
          await prisma[postModel].delete({
            where: { id: '1-post-a' },
          })
          const usersFromDb = await prisma[userModel].findMany({})
          expect(usersFromDb).toEqual([
            {
              id: '1',
              enabled: null,
            },
            {
              id: '2',
              enabled: null,
            },
          ])
          const postsFromDb = await prisma[postModel].findMany({
            orderBy: { id: 'asc' },
          })
          expect(postsFromDb).toEqual([
            {
              id: '1-post-b',
              authorId: '1',
            },
            {
              id: '2-post-a',
              authorId: '2',
            },
            {
              id: '2-post-b',
              authorId: '2',
            },
          ])
        })
        test('[delete] children and then [delete] parent should succeed', async () => {
          await prisma[postModel].delete({
            where: { id: '1-post-a' },
          })
          await prisma[postModel].delete({
            where: { id: '1-post-b' },
          })
          await prisma[userModel].delete({
            where: { id: '1' },
          })
          const usersFromDb = await prisma[userModel].findMany({})
          expect(usersFromDb).toEqual([
            {
              id: '2',
              enabled: null,
            },
          ])
          const postsFromDb = await prisma[postModel].findMany({
            orderBy: { id: 'asc' },
          })
          expect(postsFromDb).toEqual([
            {
              id: '2-post-a',
              authorId: '2',
            },
            {
              id: '2-post-b',
              authorId: '2',
            },
          ])
        })
        // Note: The test suite does not test `SetNull` with providers that errors during migration
        // see _utils/relationMode/computeMatrix.ts
        describeIf(['DEFAULT', 'Restrict', 'NoAction', 'SetNull'].includes(onDelete))(
          'onDelete: DEFAULT, Restrict, NoAction, SetNull',
          () => {
            const expectedError =
              isSchemaUsingMap && isRelationMode_foreignKeys
                ? // The snapshot changes when using @@map/@map, though only the name of the table/field is different
                  // So we can be less specific here
                  ` constraint `
                : conditionalError.snapshot({
                    foreignKeys: {
                      [Providers.MONGODB]:
                        "The change you are trying to make would violate the required relation 'PostOneToManyToUserOneToMany' between the `PostOneToMany` and `UserOneToMany` models.",
                      [Providers.POSTGRESQL]:
                        'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                      [Providers.COCKROACHDB]:
                        'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                      [Providers.MYSQL]: 'Foreign key constraint violated on the fields: (`authorId`)',
                      [Providers.SQLSERVER]:
                        'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                      [Providers.SQLITE]: 'Foreign key constraint violated on the foreign key',
                      [AdapterProviders.JS_D1]: 'D1_ERROR: FOREIGN KEY constraint failed',
                    },
                    prisma:
                      "The change you are trying to make would violate the required relation 'PostOneToManyToUserOneToMany' between the `PostOneToMany` and `UserOneToMany` models.",
                  })
            testIf(['SetNull'].includes(onDelete))('[delete] parent should throw', async () => {
              await expect(
                prisma[userModel].delete({
                  where: { id: '1' },
                }),
              ).rejects.toThrow(expectedError)
              expect(
                await prisma[userModel].findMany({
                  orderBy: { id: 'asc' },
                }),
              ).toEqual([
                {
                  id: '1',
                  enabled: null,
                },
                {
                  id: '2',
                  enabled: null,
                },
              ])
            })
            testIf(['SetNull'].includes(onDelete))(
              '[delete] a subset of children and then [delete] parent should throw',
              async () => {
                await prisma[postModel].delete({
                  where: { id: '1-post-a' },
                })
                expect(
                  await prisma[postModel].findMany({
                    orderBy: { id: 'asc' },
                  }),
                ).toEqual([
                  {
                    id: '1-post-b',
                    authorId: '1',
                  },
                  {
                    id: '2-post-a',
                    authorId: '2',
                  },
                  {
                    id: '2-post-b',
                    authorId: '2',
                  },
                ])
                await expect(
                  prisma[userModel].delete({
                    where: { id: '1' },
                  }),
                ).rejects.toThrow(expectedError)
                expect(
                  await prisma[userModel].findMany({
                    orderBy: { id: 'asc' },
                  }),
                ).toEqual([
                  {
                    id: '1',
                    enabled: null,
                  },
                  {
                    id: '2',
                    enabled: null,
                  },
                ])
              },
            )
          },
        )
        describeIf(['NoAction'].includes(onDelete))(`onDelete: NoAction`, () => {
          const expectedError = isSchemaUsingMap
            ? // The snapshot changes when using @@map/@map, though only the name of the table/field is different
              // So we can ignore the error message
              undefined
            : conditionalError.snapshot({
                foreignKeys: {
                  [Providers.POSTGRESQL]:
                    'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                  [Providers.COCKROACHDB]:
                    'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                  [Providers.MYSQL]: 'Foreign key constraint violated on the fields: (`authorId`)',
                  [Providers.SQLSERVER]:
                    'Foreign key constraint violated on the constraint: `PostOneToMany_authorId_fkey`',
                  [Providers.SQLITE]: 'Foreign key constraint violated on the foreign key',
                  [AdapterProviders.JS_D1]: 'D1_ERROR: FOREIGN KEY constraint failed',
                },
                prisma:
                  "The change you are trying to make would violate the required relation 'PostOneToManyToUserOneToMany' between the `PostOneToMany` and `UserOneToMany` models.",
              })
          test('[delete] parent should throw', async () => {
            await expect(
              prisma[userModel].delete({
                where: { id: '1' },
              }),
            ).rejects.toThrow(expectedError)
            expect(
              await prisma[userModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '1',
                enabled: null,
              },
              {
                id: '2',
                enabled: null,
              },
            ])
          })
          test('[deleteMany] parents should throw', async () => {
            await prisma[postModel].delete({
              where: { id: '1-post-a' },
            })
            expect(
              await prisma[postModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '1-post-b',
                authorId: '1',
              },
              {
                id: '2-post-a',
                authorId: '2',
              },
              {
                id: '2-post-b',
                authorId: '2',
              },
            ])
            await expect(
              prisma[userModel].delete({
                where: { id: '1' },
              }),
            ).rejects.toThrow(expectedError)
            expect(
              await prisma[userModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '1',
                enabled: null,
              },
              {
                id: '2',
                enabled: null,
              },
            ])
          })
          // Only test for foreignKeys
          testIf(isRelationMode_foreignKeys && (isPostgreSQL || isSQLite))(
            'relationMode=foreignKeys - [delete] parent and child in "wrong" order a transaction when FK is DEFERRABLE should succeed',
            async () => {
              // NOT DEFERRABLE is the default.
              // THE FK constraint needs to be
              // DEFERRABLE with an INITIALLY DEFERRED or INITIALLY IMMEDIATE mode
              // to have an effect with NO ACTION
              // This is not supported by Prisma, so we use $executeRaw to set the constraint mode
              //
              // Feature request: https://github.com/prisma/prisma/issues/3502
              // It only supported by
              // SQLite: https://www.sqlite.org/foreignkeys.html
              // PostgreSQL: https://www.postgresql.org/docs/current/sql-set-constraints.html
              //
              // Not supported in
              // SQL Server https://docs.microsoft.com/en-us/openspecs/sql_standards/ms-tsqliso02/70d6050a-28c7-4fae-a205-200ccb363522
              // MySQL https://dev.mysql.com/doc/refman/8.0/en/ansi-diff-foreign-keys.html
              //
              // Interesting article https://begriffs.com/posts/2017-08-27-deferrable-sql-constraints.html
              //
              if (isPostgreSQL) {
                if (isSchemaUsingMap) {
                  await prisma.$executeRaw`
                  ALTER TABLE "PostOneToMany_AtAtMap"
                    ALTER CONSTRAINT "PostOneToMany_AtAtMap_authorId_AtMap_fkey" DEFERRABLE INITIALLY DEFERRED`
                } else {
                  await prisma.$executeRaw`
                  ALTER TABLE "PostOneToMany"
                    ALTER CONSTRAINT "PostOneToMany_authorId_fkey" DEFERRABLE INITIALLY DEFERRED`
                }
              } else if (isSQLite) {
                // Force enforcement of all foreign key constraints to be delayed until the outermost transaction is committed.
                // https://www.sqlite.org/pragma.html#pragma_defer_foreign_keys
                await prisma.$executeRaw`
                  PRAGMA defer_foreign_keys = 1`
              } else {
                throw new Error('unexpected provider')
              }
              await prisma.$transaction([
                // Deleting order does not matter anymore
                // NoAction allows the check to be deferred until the transaction is committed
                // (only when the FK set constraint is DEFERRABLE)
                prisma[postModel].delete({
                  where: { id: '1-post-a' },
                }),
                prisma[userModel].delete({
                  where: { id: '1' },
                }),
                prisma[postModel].delete({
                  where: { id: '1-post-b' },
                }),
              ])
              expect(
                await prisma[userModel].findMany({
                  orderBy: { id: 'asc' },
                }),
              ).toEqual([
                {
                  id: '2',
                  enabled: null,
                },
              ])
              expect(
                await prisma[postModel].findMany({
                  orderBy: { id: 'asc' },
                }),
              ).toEqual([
                {
                  id: '2-post-a',
                  authorId: '2',
                },
                {
                  id: '2-post-b',
                  authorId: '2',
                },
              ])
            },
          )
        })
        describeIf(onDelete === 'Cascade')('onDelete: Cascade', () => {
          test('[delete] parent should succeed', async () => {
            await prisma[userModel].delete({
              where: { id: '1' },
            })
            expect(await prisma[userModel].findMany({})).toEqual([
              {
                id: '2',
                enabled: null,
              },
            ])
            expect(
              await prisma[postModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '2-post-a',
                authorId: '2',
              },
              {
                id: '2-post-b',
                authorId: '2',
              },
            ])
          })
          test('[delete] a subset of children and then [delete] parent should succeed', async () => {
            await prisma[postModel].delete({
              where: { id: '1-post-a' },
            })
            expect(
              await prisma[postModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '1-post-b',
                authorId: '1',
              },
              {
                id: '2-post-a',
                authorId: '2',
              },
              {
                id: '2-post-b',
                authorId: '2',
              },
            ])
            await prisma[userModel].delete({
              where: { id: '1' },
            })
            expect(
              await prisma[userModel].findMany({
                orderBy: { id: 'asc' },
              }),
            ).toEqual([
              {
                id: '2',
                enabled: null,
              },
            ])
          })
        })
      })
    })
  },
  // Use `optOut` to opt out from testing the default selected providers
  // otherwise the suite will require all providers to be specified.
  {
    optOut: {
      from: [
        Providers.MONGODB,
        Providers.SQLSERVER,
        Providers.MYSQL,
        Providers.POSTGRESQL,
        Providers.COCKROACHDB,
        Providers.SQLITE,
      ],
      reason: 'Only testing xyz provider(s) so opting out of xxx',
    },
  },
)