diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index f9f055d6480..aedd827ad26 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -55,11 +55,6 @@ type ProfileUI { showTimeLine Boolean? // Undefined } -// Currently unused, so commented out. -// type ProgressTimestamp { -// timestamp Float -// } - type SavedChallengeFile { contents String ext String @@ -74,11 +69,11 @@ type SavedChallenge { lastSavedDate Float } +/// Corresponds to the `user` collection. model user { id String @id @default(auto()) @map("_id") @db.ObjectId about String acceptedPrivacyTerms Boolean - // badges Json? // Undefined | { coreTeam [][] } removed, as new API never uses it. completedChallenges CompletedChallenge[] completedExams CompletedExam[] // Undefined currentChallengeId String? @@ -88,7 +83,6 @@ model user { emailVerified Boolean emailVerifyTTL DateTime? // Null | Undefined externalId String - // github String? Removed, because value was only ever found to be Null githubProfile String? // Undefined isApisMicroservicesCert Boolean? // Undefined isBackEndCert Boolean? // Undefined @@ -127,13 +121,20 @@ model user { portfolio Portfolio[] profileUI ProfileUI? // Undefined progressTimestamps Json? // ProgressTimestamp[] | Null[] | Int64[] | Double[] - TODO: NORMALIZE - // rand Float? // Undefined removed, as new API never uses it. + /// A random number between 0 and 1. + /// + /// Valuable for selectively performing random logic. + rand Float? savedChallenges SavedChallenge[] // Undefined | SavedChallenge[] sendQuincyEmail Boolean theme String? // Undefined timezone String? // Undefined twitter String? // Null | Undefined unsubscribeId String + /// Used to track the number of times the user's record was written to. + /// + /// This has the main benefit of allowing concurrent ops to check for race conditions. + updateCount Int? @default(0) username String // TODO(Post-MVP): make this unique usernameDisplay String? // Undefined verificationToken String? // Undefined diff --git a/api/src/db/extensions.test.ts b/api/src/db/extensions.test.ts new file mode 100644 index 00000000000..2b094bff9ea --- /dev/null +++ b/api/src/db/extensions.test.ts @@ -0,0 +1,107 @@ +import { defaultUserEmail, setupServer } from '../../jest.utils'; +import { createUserInput } from '../utils/create-user'; + +describe('prisma client extensions', () => { + setupServer(); + + beforeEach(async () => { + await fastifyTestInstance.prisma.user.deleteMany({ + where: { email: defaultUserEmail } + }); + }); + + afterAll(async () => { + await fastifyTestInstance.prisma.user.deleteMany({ + where: { email: defaultUserEmail } + }); + }); + + describe('updateCount', () => { + it('should default to 0', async () => { + const user = await fastifyTestInstance.prisma.user.create({ + data: createUserInput(defaultUserEmail) + }); + + expect(user).toMatchObject({ + updateCount: 0 + }); + }); + + it('should increment by one for updates and creates', async () => { + const user = await fastifyTestInstance.prisma.user.create({ + data: createUserInput(defaultUserEmail) + }); + + const updateUser = await fastifyTestInstance.prisma.user.update({ + where: { id: user.id }, + data: { username: 'any-change' } + }); + + expect(updateUser).toMatchObject({ + username: 'any-change', + updateCount: 1 + }); + + await fastifyTestInstance.prisma.user.updateMany({ + where: { id: user.id }, + // Even no change to values updates the updateCount + data: { username: 'any-change' } + }); + + const updateManyUser = await fastifyTestInstance.prisma.user.findUnique({ + where: { id: user.id } + }); + + expect(updateManyUser).toMatchObject({ + username: 'any-change', + updateCount: 2 + }); + + const upsertUser = await fastifyTestInstance.prisma.user.upsert({ + where: { id: user.id }, + create: createUserInput(defaultUserEmail), + update: { username: 'upser-user' } + }); + + expect(upsertUser).toMatchObject({ + username: 'upser-user', + updateCount: 3 + }); + }); + + it("should not increment for 'find' queries", async () => { + const user = await fastifyTestInstance.prisma.user.create({ + data: createUserInput(defaultUserEmail) + }); + + const findUniqueUser = await fastifyTestInstance.prisma.user.findUnique({ + where: { id: user.id } + }); + + expect(findUniqueUser).toMatchObject({ + updateCount: 0 + }); + + const findManyUsers = await fastifyTestInstance.prisma.user.findMany(); + + expect(findManyUsers).toHaveLength(1); + expect(findManyUsers[0]).toMatchObject({ + updateCount: 0 + }); + + const findFirstUser = await fastifyTestInstance.prisma.user.findFirst(); + + expect(findFirstUser).toMatchObject({ + updateCount: 0 + }); + + const findRawUser = await fastifyTestInstance.prisma.user.findRaw({ + filter: { email: defaultUserEmail } + }); + + expect(findRawUser[0]).toMatchObject({ + updateCount: 0 + }); + }); + }); +}); diff --git a/api/src/db/prisma.ts b/api/src/db/prisma.ts index f3f54d8629a..397df5b78b1 100644 --- a/api/src/db/prisma.ts +++ b/api/src/db/prisma.ts @@ -7,18 +7,20 @@ import { MONGOHQ_URL } from '../utils/env'; declare module 'fastify' { interface FastifyInstance { - prisma: PrismaClient; + prisma: ReturnType; } } const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => { - const prisma = new PrismaClient({ - datasources: { - db: { - url: MONGOHQ_URL + const prisma = extendClient( + new PrismaClient({ + datasources: { + db: { + url: MONGOHQ_URL + } } - } - }); + }) + ); await prisma.$connect(); @@ -29,4 +31,32 @@ const prismaPlugin: FastifyPluginAsync = fp(async (server, _options) => { }); }); +// TODO: It would be nice to split this up into multiple update functions, +// but the types are a pain. +// TODO: Multiple extended clients can be used for different restrictions (e.g. session vs non-session users) +// TODO: Could be used to add other _easily forgotten_ fields like `progressTimestamp` +function extendClient(prisma: PrismaClient) { + return prisma.$extends({ + query: { + user: { + async update({ args, query }) { + args.data.updateCount = { increment: 1 }; + return query(args); + }, + async updateMany({ args, query }) { + args.data.updateCount = { increment: 1 }; + return query(args); + }, + async upsert({ args, query }) { + args.update.updateCount = { increment: 1 }; + return query(args); + } + // NOTE: raw ops are untouched, as it is meant to be a direct passthrough to mongodb + // async findRaw({ model, operation, args, query }) {} + // async aggregateRaw({ model, operation, args, query }) {} + } + } + }); +} + export default prismaPlugin;