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

Ability to extend PrismaClient class w/ Client Extensions before instantiation #18628

Open
sabinadams opened this issue Apr 4, 2023 · 66 comments
Labels
kind/improvement An improvement to existing feature and code. team/client Issue for team Client. tech/typescript Issue for tech TypeScript. topic: clientExtensions topic: NestJS

Comments

@sabinadams
Copy link

sabinadams commented Apr 4, 2023

Problem

Within NestJS, the common solution to implementing an injectable Prisma Client instance is to extend PrismaClient and add the onModuleInit and enableShutdownHooks functions required by NestJS.

This typically looks like:

import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect()
  }

  async enableShutdownHooks(app: INestApplication) {
    this.$on('beforeExit', async () => {
      await app.close()
    })
  }
}

You can use clientExtensions to add these functions, however, they can not adjust the type of PrismaClient and this scenario requires you to export a properly typed, uninstantiated class for NestJS to use.

An example of adding the functions is below:

const prisma = new PrismaClient().$extends({
  client: {
    async onModuleInit() {
      await Prisma.getExtensionContext(this).$connect();
    },
    async enableShutdownHooks(app: INestApplication) {
      Prisma.getExtensionContext(this).$on('beforeExit', async () => {
        await app.close();
      });
    },
  },
});

Suggested solution

Add a static method to PrismaClient that is available when using the clientExtensions preview features that allows you to add methods to the class (and adjust the type) before instantiation.

This resulting class describe above would then look something like:

const ExtendedClient = PrismaClient.$extends({
  client: {
    async onModuleInit() {
      await Prisma.getExtensionContext(this).$connect();
    },
    async enableShutdownHooks(app: INestApplication) {
      Prisma.getExtensionContext(this).$on('beforeExit', async () => {
        await app.close();
      });
    },
  },
})

export ExtendedClient

Alternatives

Additional context

This example focuses on NestJS, however, this functionality could prove useful elsewhere as well.

@millsp millsp added kind/improvement An improvement to existing feature and code. tech/typescript Issue for tech TypeScript. team/client Issue for team Client. topic: clientExtensions labels Apr 4, 2023
@millsp
Copy link
Member

millsp commented Jun 1, 2023

Related #16500 (comment) #16500 (comment)

@zackdotcomputer
Copy link

zackdotcomputer commented Jun 1, 2023

@millsp thanks for pointing me to this issue. I'd support this for simplicity's sake and because I think it will make future post-generation changes and extensions to the Prisma Client easier to support in NestJS.

For what it's worth, my team is using client extensions in NestJS currently with type-safety via the following workaround:

prisma.service.ts:

export type PrismaService = ReturnType<BasePrismaService["withExtensions"]>;

export class BasePrismaService extends PrismaClient {
  // ... optionally, a constructor that performs custom connection or middleware setup

  withExtensions() {
    return this.$extends(/* Client Extensions Here */);
  }

  // ... startup + shutdown hooks omitted
}

prisma.module.ts:

export const PRISMA_INJECTION_TOKEN = "PrismaService";

@Module({
  providers: [
    {
      provide: PRISMA_INJECTION_TOKEN,
      useFactory(): PrismaService {
        return new BasePrismaService().withExtensions();
      }
    }
  ],
  exports: [PRISMA_INJECTION_TOKEN]
})
export class PrismaModule {}

The pro of this workaround is that it creates the same with-extension typesafety this Issue is seeking to replicate using only currently-available interfaces. The con is that fully-dynamic dependency injection is not possible because the "type" PrismaService isn't available to Nest as directly as it would with the suggestion from this Issue. Because of that, with this workaround we need to annotate our usages of PrismaService with the decorator @Inject(PRISMA_INJECTION_TOKEN) to clue Nest into which provider should be injected there. For that reason, I'd still support adding a "modify before instantiation" model for features like client extensions (or, even better, moving to a before-instantiation model entirely) to avoid the need for complex workarounds like this in the future.

@millsp
Copy link
Member

millsp commented Jun 9, 2023

Hey NestJS users, I tried to come up with a workaround today so that you can easily load an extended client while keeping it type-safe. The trick was to use a helper function that would create a wrapper class while preserving type-safety.

class MyService extends getExtendedClient() {
    async getUserInfo(id: number) {
        const user = await this.user.findFirst({
            where: { id },
        })
    }
}

function getExtendedClient() {
    const client = () => new PrismaClient().$extends({
       // your extension configuration
    })

    return class { // wrapper with type-safety 🎉
        constructor() { return client() }
    } as (new () => ReturnType<typeof client>)
}

Let me know if that works for you.

@gamedevsam
Copy link

gamedevsam commented Jun 22, 2023

Thanks for your suggestion @millsp, it didn't work off the bat for me, but with a bit more effort I ended up with a solution that I'm quite happy with:

function extendPrismaClient() {
  const logger = new Logger('Prisma');
  const prisma = new PrismaClient();
  return prisma.$extends({
    client: {
      async onModuleInit() {
        // Uncomment this to establish a connection on startup, this is generally not necessary
        // https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management#connect
        // await Prisma.getExtensionContext(this).$connect();
      },
      async enableShutdownHooks(app: INestApplication) {
        Prisma.getExtensionContext(prisma).$on('beforeExit', async () => {
          await app.close();
        });
      }
    },
    query: {
      $allModels: {
        async $allOperations({ operation, model, args, query }) {
          const start = performance.now();
          const result = await query(args);
          const end = performance.now();
          const time = end - start;
          logger.debug(`${model}.${operation} took ${time}ms`);
          return result;
        }
      }
    }
  });
}

// https://github.com/prisma/prisma/issues/18628
const ExtendedPrismaClient = class {
  constructor() {
    return extendPrismaClient();
  }
} as new () => ReturnType<typeof extendPrismaClient>;

@Injectable()
export class PrismaService extends ExtendedPrismaClient {}

@Global()
@Module({
  exports: [PrismaService],
  providers: [PrismaService]
})
export class PrismaModule {}

@millsp
Copy link
Member

millsp commented Jun 22, 2023

I agree that this is sub-optimal not Nest users. We will get to future requests when we get to prioritizing them. In the mean time I'd like to be able to provide a decent-enough workaround for NestJS users. I am not super happy that you had to move Nest logic into the Prisma Client extension. Let's see if I can help further. I created this snippet, would you mind trying it?

import { Prisma, PrismaClient } from "@prisma/client";

class MyService extends getExtendedClient() {
  async displayUserModelName() {
    this.user.test()
  }
}

function getExtendedClient() {
  const client = () => new PrismaClient().$extends({
    // your extension configuration (example)
    model: {
      $allModels: {
        test() {
          const ctx = Prisma.getExtensionContext(this)
          console.log(`${ctx.name} is the model being called`)
        }
      }
     }
  })

  // some proxy magic to make the workaround
  return new Proxy(class {}, {
    construct(target, args, newTarget) {
      return Object.assign(Reflect.construct(target, args, newTarget), client())
    }
  }) as (new () => ReturnType<typeof client>)
}

const service = new MyService()

async function main() {
  const data = await service.displayUserModelName()
}

void main()

@andrewmurraydavid
Copy link

andrewmurraydavid commented Jun 22, 2023

Thanks for your suggestion @millsp, it didn't work off the bat for me, but with a bit more effort I ended up with a solution that I'm quite happy with:

@gamedevsam I'm surprised your ts compiler isn't yelling about the typing issue on extendPrismaClient. Mine is yelling the following:

The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed.ts(7056)
Exported variable 'extended' has or is using name 'PrismaPromise_2' from external module "project/node_modules/@prisma/client/runtime/index" but cannot be named.ts(4023)

@gamedevsam
Copy link

gamedevsam commented Jun 22, 2023

I'm using the latest TypeScript version (5.1.3), and latest prisma & client (4.16.0).

@millsp unfortunately your solution still doesn't work for me, I get a runtime error when getting the extension context (also need a forced typecast to get around typing issues):

/home/samuel/Dev/Personal/dokku_app/src/server/src/lib/prisma.ts:41
    Prisma.getExtensionContext(this as unknown as PrismaClient).$on('beforeExit', async () => {
                                                                ^
TypeError: client_1.Prisma.getExtensionContext(...).$on is not a function
    at PrismaService.enableShutdownHooks (/home/samuel/Dev/Personal/dokku_app/src/server/src/lib/prisma.ts:41:65)
    at bootstrap (/home/samuel/Dev/Personal/dokku_app/src/server/src/boot_server.ts:77:23)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
2
Waiting for the debugger to disconnect...
node:internal/process/promises:288
            triggerUncaughtException(err, true /* fromPromise */);

@gamedevsam
Copy link

gamedevsam commented Jun 22, 2023

Scratch that, I was able to get it to work. Can't say I love the idea of wrapping all prisma operations in another Proxy, but it's definitely an improvement over the first attempt. I'm looking forward to seeing a more elegant / official solution to integrate extended prisma clients with NestJS.

Here's the resulting working code:

const prisma = new PrismaClient();
const logger = new Logger("Prisma");

function extendedClient() {
  const extendClient = () =>
    prisma.$extends({
      query: {
        $allModels: {
          async $allOperations({ operation, model, args, query }) {
            const start = performance.now();
            const result = await query(args);
            const end = performance.now();
            const time = end - start;
            logger.debug(
              `${model}.${operation}(${JSON.stringify(args)}) took ${time}ms`
            );
            return result;
          },
        },
      },
    });

  // https://github.com/prisma/prisma/issues/18628#issuecomment-1601958220
  return new Proxy(class {}, {
    construct(target, args, newTarget) {
      return Object.assign(
        Reflect.construct(target, args, newTarget),
        extendClient()
      );
    },
  }) as new () => ReturnType<typeof extendClient>;
}

@Injectable()
export class PrismaService extends extendedClient() {
  async onModuleInit() {
    // Uncomment this to establish a connection on startup, this is generally not necessary
    // https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management#connect
    // await Prisma.getExtensionContext(prisma).$connect();
  }
  async enableShutdownHooks(app: INestApplication) {
    Prisma.getExtensionContext(prisma).$on("beforeExit", async () => {
      await app.close();
    });
  }
}

@millsp
Copy link
Member

millsp commented Jun 24, 2023

Yes @gamedevsam I expect this feature request to be top-priority once we get to implementing more features for Client Extensions.

@millsp
Copy link
Member

millsp commented Jun 24, 2023

@andrewmurraydavid are you using the latest version 4.16.1?

@Nibblesh
Copy link

@millsp could you provide a suggestion to extend the solution @gamedevsam most recently posted to include getting access to an injected service in the extendedClient function.

For example, if we get the isProd variable from the configService from '@nestjs/config' and conditionally ran the performance debug log how would we get access to the configService instance?

@andrewmurraydavid
Copy link

@andrewmurraydavid are you using the latest version 4.16.1?

At the time of writing my first comment, I was using 4.16.0. I can confirm that 4.16.1 fixed the typing issue. Thanks!

@alex-deity
Copy link

I've been trying to play around with Client Extensions, but I got some issues with it (maybe somebody else have had this problem before).

Problem

Our project uses @nestjs/schedule package, and there seems to be an conflict via reflect-metadata package (by even registering the Schedule module in the App - ScheduleModule.forRoot()).

Once I call .$extends method for Prisma Client - the app starts to fail during the startup:

/project/node_modules/reflect-metadata/Reflect.js:354
                throw new TypeError();
                      ^
TypeError:
    at Reflect.getMetadata (/project/node_modules/reflect-metadata/Reflect.js:354:23)
    at Reflector.get (/project/node_modules/@nestjs/core/services/reflector.service.js:24:24)
    at SchedulerMetadataAccessor.getSchedulerType (/project/node_modules/@nestjs/schedule/dist/schedule-metadata.accessor.js:21:31)
    at ScheduleExplorer.lookupSchedulers (/project/node_modules/@nestjs/schedule/dist/schedule.explorer.js:46:48)
    at /project/node_modules/@nestjs/schedule/dist/schedule.explorer.js:40:24
    at MetadataScanner.scanFromPrototype (/project/node_modules/@nestjs/core/metadata-scanner.js:34:31)
    at /project/node_modules/@nestjs/schedule/dist/schedule.explorer.js:39:34
    at Array.forEach (<anonymous>)
    at ScheduleExplorer.explore (/project/node_modules/@nestjs/schedule/dist/schedule.explorer.js:34:26)
    at ScheduleExplorer.onModuleInit (/project/node_modules/@nestjs/schedule/dist/schedule.explorer.js:27:14)
^C

When I remove @nestjs/schedule - everything works as expected (well, except for the schedule itself).
When I remove .$extends - everything works (except for Prisma Client extensions).

Does anyone have an idea about this?

  • nestjs - latest v10
  • prisma - latest 4.16.1
  • node v18.16

Reproduce

I've got a playground repo with the reproduced issue: https://github.com/alex-deity/test-nest-prisma (the main file is app.module.ts).

@ThallesP
Copy link

Scratch that, I was able to get it to work. Can't say I love the idea of wrapping all prisma operations in another Proxy, but it's definitely an improvement over the first attempt. I'm looking forward to seeing a more elegant / official solution to integrate extended prisma clients with NestJS.

Here's the resulting working code:

const prisma = new PrismaClient();
const logger = new Logger("Prisma");

function extendedClient() {
  const extendClient = () =>
    prisma.$extends({
      query: {
        $allModels: {
          async $allOperations({ operation, model, args, query }) {
            const start = performance.now();
            const result = await query(args);
            const end = performance.now();
            const time = end - start;
            logger.debug(
              `${model}.${operation}(${JSON.stringify(args)}) took ${time}ms`
            );
            return result;
          },
        },
      },
    });

  // https://github.com/prisma/prisma/issues/18628#issuecomment-1601958220
  return new Proxy(class {}, {
    construct(target, args, newTarget) {
      return Object.assign(
        Reflect.construct(target, args, newTarget),
        extendClient()
      );
    },
  }) as new () => ReturnType<typeof extendClient>;
}

@Injectable()
export class PrismaService extends extendedClient() {
  async onModuleInit() {
    // Uncomment this to establish a connection on startup, this is generally not necessary
    // https://www.prisma.io/docs/concepts/components/prisma-client/working-with-prismaclient/connection-management#connect
    // await Prisma.getExtensionContext(prisma).$connect();
  }
  async enableShutdownHooks(app: INestApplication) {
    Prisma.getExtensionContext(prisma).$on("beforeExit", async () => {
      await app.close();
    });
  }
}

this code makes $queryRaw undefined.
sad that there isn't a way to make this in a good way

@gamedevsam
Copy link

gamedevsam commented Jun 29, 2023

@ThallesP that's not happening for me, for some reason I cannot Ctrl+Click to go to definition, but I do get proper types for the function:

TypeScript correctly picks up type information:
image

But go to definition fails:
image

You should double check that both Prisma and TypeScript are updated to their latest version.

@ThallesP
Copy link

ThallesP commented Jun 29, 2023

@gamedevsam

Typescript types are working, but when calling the function it breaks.

@gamedevsam
Copy link

Ohh yeah that could be, I didn't test this out at runtime as I don't yet have a use for $queryRaw. I think the Prisma team deprecared the $use method of extending Prisma a bit prematurely seeing that the extension system doesn't yet play nicely with the ecosystem at large.

@Nibblesh
Copy link

Nibblesh commented Jul 3, 2023

The health check package @nestjs/terminus utilises $runCommandRaw and $queryRawUnsafe and since those methods don't seem to be working with this implementation it breaks using the prisma service in health checks.

You can work around it by creating a separate export of the client (before extending) and using it directly in the health controller but that avoids using nest's dependency injection and strays from documentation.

@jl9404
Copy link

jl9404 commented Jul 6, 2023

hello guys i came across this issue too but i didnt face any issue with the following code

@prisma/client: 4.16.2
@nestjs/core: ^10.0.0

prisma.module.ts

import { DynamicModule, Logger, Module } from '@nestjs/common';
import { PRISMA_MASTER_CONNECTION, PRISMA_READ_CONNECTION } from './constants';
import { PrismaService } from './prisma.service';
import { prismaExtensionFactory } from './prisma.extension';
import { ConfigService } from '@nestjs/config';

@Module({})
export class PrismaModule {
  static forRoot(): DynamicModule {
    return {
      global: true,
      module: PrismaModule,
      providers: [
        {
          provide: PRISMA_MASTER_CONNECTION,
          useFactory: () => {
            const logger = new Logger(PRISMA_MASTER_CONNECTION);

            return prismaExtensionFactory(
              new PrismaService({
                log: [{ emit: 'event', level: 'query' }],
              }),
              logger,
            );
          },
        },
        {
          provide: PRISMA_READ_CONNECTION,
          useFactory: (configService: ConfigService) => {
            const logger = new Logger(PRISMA_READ_CONNECTION);

            return prismaExtensionFactory(
              new PrismaService({
                datasources: {
                  db: {
                    url: configService.get('READ_REPLICATION_DATABASE_URL'),
                  },
                },
                log: [{ emit: 'event', level: 'query' }],
              }),
              logger,
            );
          },
          inject: [ConfigService],
        },
      ],
      exports: [PRISMA_MASTER_CONNECTION, PRISMA_READ_CONNECTION],
    };
  }
}

prisma.service.ts

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
}

prisma.extension.ts

import { INestApplication, Logger } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';

export const prismaExtensionFactory = (
  client: PrismaClient<Prisma.PrismaClientOptions, 'query'>,
  logger: Logger,
) => {
  client.$on('query', (e) => {
    logger.debug(`${e.query} -- ${e.params} duration: ${e.duration}ms`);
  });

  return client.$extends({
    client: {
      async enableShutdownHooks(app: INestApplication) {
        Prisma.getExtensionContext(client).$on('beforeExit', async () => {
          logger.log('Gracefully shutdown prisma');

          await app.close();
        });
      },
    },
  });
};

export type ExtendedPrismaClient = ReturnType<typeof prismaExtensionFactory>;

@Nibblesh
Copy link

Nibblesh commented Jul 7, 2023

@jl9404 The approach you shared is typesafe and didn't throw errors in the healthcheck module, and allows the use of injected dependencies by passing them through to the factory. Works well for me

@Cookie-gg
Copy link

In my case, using @jl9404’s approach with EventEmitter (@nestjs/@nestjs/event-emitter) throws an error.

/myproject/node_modules/reflect-metadata/Reflect.js:354
                throw new TypeError();
                      ^
TypeError:
    at Reflect.getMetadata (/myproject/node_modules/reflect-metadata/Reflect.js:354:23)
    at Reflector.get (/myproject/node_modules/@nestjs/core/services/reflector.service.js:24:24)
    at EventsMetadataAccessor.getEventHandlerMetadata (/myproject/node_modules/@nestjs/event-emitter/lib/events-metadata.accessor.ts:13:37)
    at EventSubscribersLoader.subscribeToEventIfListener (/myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:74:29)
    at /myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:57:18
    at MetadataScanner.scanFromPrototype (/myproject/node_modules/@nestjs/core/metadata-scanner.js:34:31)
    at /myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:53:30
    at Array.forEach (<anonymous>)
    at EventSubscribersLoader.loadEventListeners (/myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:49:8)
    at EventSubscribersLoader.onApplicationBootstrap (/myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:37:10)

Just import modules in AppModule like below.


@Module({
  imports: [
    PrismaModule.forRoot(),
    EventEmitterModule.forRoot(),
    
  ],})
export class AppModule {}

Am I using it wrong? Please give me a solution.

@jl9404
Copy link

jl9404 commented Jul 9, 2023

In my case, using @jl9404’s approach with EventEmitter (@nestjs/@nestjs/event-emitter) throws an error.

/myproject/node_modules/reflect-metadata/Reflect.js:354
                throw new TypeError();
                      ^
TypeError:
    at Reflect.getMetadata (/myproject/node_modules/reflect-metadata/Reflect.js:354:23)
    at Reflector.get (/myproject/node_modules/@nestjs/core/services/reflector.service.js:24:24)
    at EventsMetadataAccessor.getEventHandlerMetadata (/myproject/node_modules/@nestjs/event-emitter/lib/events-metadata.accessor.ts:13:37)
    at EventSubscribersLoader.subscribeToEventIfListener (/myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:74:29)
    at /myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:57:18
    at MetadataScanner.scanFromPrototype (/myproject/node_modules/@nestjs/core/metadata-scanner.js:34:31)
    at /myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:53:30
    at Array.forEach (<anonymous>)
    at EventSubscribersLoader.loadEventListeners (/myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:49:8)
    at EventSubscribersLoader.onApplicationBootstrap (/myproject/node_modules/@nestjs/event-emitter/lib/event-subscribers.loader.ts:37:10)

Just import modules in AppModule like below.


@Module({
  imports: [
    PrismaModule.forRoot(),
    EventEmitterModule.forRoot(),
    
  ],})
export class AppModule {}

Am I using it wrong? Please give me a solution.

i guess you have to patch the package cause the event emitter module scan the provider and check the prototype of prisma client

diff --git a/node_modules/@nestjs/event-emitter/dist/events-metadata.accessor.js b/node_modules/@nestjs/event-emitter/dist/events-metadata.accessor.js
index 53f038e..c06eae9 100644
--- a/node_modules/@nestjs/event-emitter/dist/events-metadata.accessor.js
+++ b/node_modules/@nestjs/event-emitter/dist/events-metadata.accessor.js
@@ -18,6 +18,9 @@ let EventsMetadataAccessor = class EventsMetadataAccessor {
         this.reflector = reflector;
     }
     getEventHandlerMetadata(target) {
+        if (!target) {
+            return undefined;
+        }
         const metadata = this.reflector.get(constants_1.EVENT_LISTENER_METADATA, target);
         if (!metadata) {
             return undefined;

@leandromatos
Copy link

Good job, @gamedevsam!

I decided to get your implementation and mix it up with mine. Here is what I got and 100% type-safe.

// database.module.ts
import { Global, Module } from '@nestjs/common'

import { DatabaseService } from '@/database/database.service'

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}
// database.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
import { Prisma, PrismaClient } from '@prisma/client'

export const prismaExtendedClient = (prismaClient: PrismaClient) =>
  prismaClient.$extends({
    model: {
      $allModels: {
        async softDelete<M, A>(
          this: M,
          where: Prisma.Args<M, 'update'>['where'],
        ): Promise<Prisma.Result<M, A, 'update'>> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          return (context as any).update({
            where,
            data: {
              deletedAt: new Date(),
            },
          })
        },
        async isDeleted<M>(this: M, where: Prisma.Args<M, 'findUnique'>['where']): Promise<boolean> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          const result = await (context as any).findUnique({ where })

          return !!result.deletedAt
        },
      },
    },
  })

@Injectable()
export class DatabaseService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  readonly extendedClient = prismaExtendedClient(this)

  constructor() {
    super()

    new Proxy(this, {
      get: (target, property) => Reflect.get(property in this.extendedClient ? this.extendedClient : target, property),
    })
  }

  async onModuleInit() {
    await this.$connect()
  }

  async onModuleDestroy() {
    await this.$disconnect()
  }
}

Now, I can use the extended methods like this:

await this.databaseService.extendedClient.user.softDelete({ id })

And, I also can use like the normal way:

return await this.databaseService.user.findFirstOrThrow({
    where: { id },
})

@gamedevsam
Copy link

Nicely done, I feel we could keep trimming things down and try to keep everything contained within the service, but to be frank the core of the solution is the Proxy + separate extended client for type safety. Everything else is just icing on the cake. Hopefully the Prisma team takes this pattern and makes it more widely available to NestJS users via official docs.

@leandromatos
Copy link

Just a last tip. It's possible to split all extended Prisma methods into separate files, but you must declare the methods using a function instead of an arrow function. Doing that will make it much easier to maintain the code.

const softDelete = async function <M, A>(
  this: M,
  where: Prisma.Args<M, 'update'>['where'],
): Promise<Prisma.Result<M, A, 'update'>> {
  const context = Prisma.getExtensionContext(this)
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
  const result = (context as any).update({
    where,
    data: {
      deletedAt: new Date(),
    },
  })

  return result
}

const isDeleted = async function <M>(this: M, where: Prisma.Args<M, 'findUnique'>['where']): Promise<boolean> {
  const context = Prisma.getExtensionContext(this)
  // eslint-disable-next-line -- There is no way to type a Prisma model
  const result = await (context as any).findUnique({ where })

  return !!result.deletedAt
}

export const prismaExtendedClient = (prismaClient: PrismaClient) =>
  prismaClient.$extends({
    model: {
      $allModels: {
        softDelete,
        isDeleted,
      },
    },
  })

@MustagheesButt
Copy link

Probably late to the party, but better late than never. here's my hack, now I'm not sure how buggy this could be, I'm open to suggestions

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();

    Object.assign(
      this,
      this.$extends({
        query: {
          $allModels: {
            async findMany({ model, operation, args, query }) {
              // set `take` and fill with the rest of `args`
              console.log(model, operation, args, query);

              return query(args);
            },
          },
        },
      }),
    );
  }
}

Thanks, looks like the simplest solution. Any way to make it work with typescript?

@leandromatos
Copy link

@MustagheesButt look here. The code works with Typescript, and it is type-safe.

@gamedevsam
Copy link

I feel my solution, or Leandro's or some combination (they are largely the same, with minor differences in style / conciseness) is a "good enough" solution. I recommend closing this issue.

@ghalibansari
Copy link

ghalibansari commented Nov 26, 2023

This worked for me.

@Global()
@Module({
  providers: [
    {
      provide: PrismaService,
      useFactory: async (config: ConfigService<EnvironmentVariables, true>) => {
        return new PrismaService(config).initPrismaWithClientExtension();
      },
      inject: [ConfigService<EnvironmentVariables, true>],
    },
  ],
  exports: [PrismaService],
})
export class PrismaModule {}
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma, PrismaClient } from '@prisma/client';
import { EnvironmentVariables } from '../config';
import { prismaExtendedClient } from './prisma.extension';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  private static instance: PrismaService;
  private readonly logger = new Logger(PrismaService.name);

  constructor(
    private readonly config: ConfigService<EnvironmentVariables, true>,
  ) {
    if (PrismaService.instance) {
      return PrismaService.instance;
    }

    super({
      log: [
        { emit: 'event', level: 'query' },
        { emit: 'stdout', level: 'error' },
        { emit: 'stdout', level: 'info' },
        { emit: 'stdout', level: 'warn' },
      ],
      datasources: { db: { url: config.get('DATABASE_URL', { infer: true }) } },
    });

    PrismaService.instance = this;
  }

  async initPrismaWithClientExtension() {
    return await prismaExtendedClient(this);
  }

  async onModuleInit() {
    await this.$connect();

    if (this.config.get('PRISMA_LOG', { infer: true })) {
      //@ts-expect-error: known bug in prism missing proper type
      this.$on('query', (e: Prisma.QueryEvent) => {
        this.logger.log(
          JSON.stringify({
            duration: `${e.duration}ms`,
            query: e.query,
            params: e.params,
          }),
        );
      });
    }
  }
}
export const prismaExtendedClient = async (client: PrismaClient) =>
  client.$extends({})

@mohamedamara1
Copy link

Good job, @gamedevsam!

I decided to get your implementation and mix it up with mine. Here is what I got and 100% type-safe.

// database.module.ts
import { Global, Module } from '@nestjs/common'

import { DatabaseService } from '@/database/database.service'

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}
// database.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
import { Prisma, PrismaClient } from '@prisma/client'

export const prismaExtendedClient = (prismaClient: PrismaClient) =>
  prismaClient.$extends({
    model: {
      $allModels: {
        async softDelete<M, A>(
          this: M,
          where: Prisma.Args<M, 'update'>['where'],
        ): Promise<Prisma.Result<M, A, 'update'>> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          return (context as any).update({
            where,
            data: {
              deletedAt: new Date(),
            },
          })
        },
        async isDeleted<M>(this: M, where: Prisma.Args<M, 'findUnique'>['where']): Promise<boolean> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          const result = await (context as any).findUnique({ where })

          return !!result.deletedAt
        },
      },
    },
  })

@Injectable()
export class DatabaseService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  readonly extendedClient = prismaExtendedClient(this)

  constructor() {
    super()

    new Proxy(this, {
      get: (target, property) => Reflect.get(property in this.extendedClient ? this.extendedClient : target, property),
    })
  }

  async onModuleInit() {
    await this.$connect()
  }

  async onModuleDestroy() {
    await this.$disconnect()
  }
}

Now, I can use the extended methods like this:

await this.databaseService.extendedClient.user.softDelete({ id })

And, I also can use like the normal way:

return await this.databaseService.user.findFirstOrThrow({
    where: { id },
})

does this still work? I've just tried it with Prisma 5.6.0 and Prisma client 5.6.0 and it hangs at Prisma.getExtensionContext()

@gamedevsam
Copy link

Try a solution based on this example, customize it for your needs. I haven't yet tried to update Prisma, but previous examples were not good starting points imo (avoid anything with this keyword used outside of class methods, it's a bad idea, and will lead you to errors / tricky bugs).

@gamedevsam
Copy link

Also this thread if getting out of hand, anyone can close it? Not everyone needs to share their minor variation on the same solution. Use mine as a starting point and customize it as you like. Only share your solution if it's significantly different plz (to combat noise).

@MorenoMdz
Copy link

@gamedevsam While I agree I do think this should be something official, not something the community has to hack around.

@zackdotcomputer
Copy link

I think there needs to be a solution from Prisma to this issue because the variants on @gamedevsam's solution still don't address the underlying issue - until Prisma gives us a way to create a PrismaClient that is extended from the start, we will not have complete typing on PrismaService. Until we get that upstream solution, we will always have to do one or more of the hack-arounds of:

  1. Requiring the developer to call into the prisma service's stored extendedClient for at least some calls - i.e. this.prismaService.extendedClient.model.call().
  2. Requiring the use of the @Inject directive, like my solution did, to inject the results of the extend function directly in place of a normal service dependency.
  3. Keep a persistent extendedClient instance in the static memory space of the server, rather than letting Nest's dependency management choose when to create the service/client.

None of our solutions thus far (afaict) avoid all three of these, and I think that's why we want to keep this issue open until Prisma gets around to giving us an extension tool that can be typed prior to instantiation.

@inhanbyeol94
Copy link

Is the issue resolved?

@renatoaraujoc
Copy link

Also this thread if getting out of hand, anyone can close it? Not everyone needs to share their minor variation on the same solution. Use mine as a starting point and customize it as you like. Only share your solution if it's significantly different plz (to combat noise).

This thread is not out of hand.

While your solution is somewhat acceptable, it's not the best way to make things work.

The best way is Prisma team to take a look on this and provide a way to "class"ify the client and provide ways to extend it thru this mechanism while keeping all the types.

@thnam1410
Copy link

Good job, @gamedevsam!
I decided to get your implementation and mix it up with mine. Here is what I got and 100% type-safe.

// database.module.ts
import { Global, Module } from '@nestjs/common'

import { DatabaseService } from '@/database/database.service'

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}
// database.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
import { Prisma, PrismaClient } from '@prisma/client'

export const prismaExtendedClient = (prismaClient: PrismaClient) =>
  prismaClient.$extends({
    model: {
      $allModels: {
        async softDelete<M, A>(
          this: M,
          where: Prisma.Args<M, 'update'>['where'],
        ): Promise<Prisma.Result<M, A, 'update'>> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          return (context as any).update({
            where,
            data: {
              deletedAt: new Date(),
            },
          })
        },
        async isDeleted<M>(this: M, where: Prisma.Args<M, 'findUnique'>['where']): Promise<boolean> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          const result = await (context as any).findUnique({ where })

          return !!result.deletedAt
        },
      },
    },
  })

@Injectable()
export class DatabaseService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  readonly extendedClient = prismaExtendedClient(this)

  constructor() {
    super()

    new Proxy(this, {
      get: (target, property) => Reflect.get(property in this.extendedClient ? this.extendedClient : target, property),
    })
  }

  async onModuleInit() {
    await this.$connect()
  }

  async onModuleDestroy() {
    await this.$disconnect()
  }
}

Now, I can use the extended methods like this:

await this.databaseService.extendedClient.user.softDelete({ id })

And, I also can use like the normal way:

return await this.databaseService.user.findFirstOrThrow({
    where: { id },
})

does this still work? I've just tried it with Prisma 5.6.0 and Prisma client 5.6.0 and it hangs at Prisma.getExtensionContext()

Same problem, have u solved this ?

@knotekbr
Copy link

I ran up against this issue today and ended up with a solution that seems to meet my needs. The actual extension and some types were adapted from this comment.

I'm a bit late to the party and this thread has gotten pretty long, so I apologize in advance if I'm rehashing a solution that's already been discussed. But I believe this approach addresses all 3 of @zackdotcomputer's concerns:

  1. Extended methods can be called directly like this.prismaService.model.method()
  2. No additional @Inject directives are required
  3. Nest still manages the DI

Additionally:

  • No proxying required
  • No Nest-specific code has to be moved into the Prisma client
  • Seems nice and type-safe

For reference, I'm using @prisma/client@5.7.1.

// src/prisma/extensions/find-many-count.extension.ts

import { Prisma } from "@prisma/client";

import type { PrismaClient } from "@prisma/client";

export function createFindManyAndCountExtension<TModel = any, TArgs = any>(prisma: PrismaClient) {
  return {
    name: "findManyAndCount",
    model: {
      $allModels: {
        findManyAndCount(
          this: TModel,
          args?: Prisma.Exact<TArgs, Prisma.Args<TModel, "findMany">>
        ): Promise<[Prisma.Result<TModel, TArgs, "findMany">, number]> {
          const context = Prisma.getExtensionContext(this);

          return prisma.$transaction([
            (context as any).findMany(args),
            (context as any).count({ where: (args as any)?.where }),
          ]);
        },
      },
    },
  };
}

export type FindManyAndCount<TModel, TArgs> = ReturnType<
  typeof createFindManyAndCountExtension<TModel, TArgs>
>["model"]["$allModels"]["findManyAndCount"];
// src/prisma/extended-client.ts

import { PrismaClient } from "@prisma/client";

import { createFindManyAndCountExtension, type FindManyAndCount } from "./extensions/find-many-count.extension";

type ModelsWithExtensions = {
  [Model in keyof PrismaClient]: PrismaClient[Model] extends { findMany: (args: infer TArgs) => Promise<any> }
    ? {
        findManyAndCount: FindManyAndCount<PrismaClient[Model], TArgs>;
      } & PrismaClient[Model]
    : PrismaClient[Model];
};

class UntypedExtendedClient extends PrismaClient {
  constructor(options?: ConstructorParameters<typeof PrismaClient>[0]) {
    super(options);

    return this.$extends(createFindManyAndCountExtension(this)) as this;
  }
}

const ExtendedPrismaClient = UntypedExtendedClient as unknown as new (
  options?: ConstructorParameters<typeof PrismaClient>[0]
) => PrismaClient & ModelsWithExtensions;

export { ExtendedPrismaClient };
// src/prisma/prisma.service.ts

import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Injectable } from "@nestjs/common";

import { ExtendedPrismaClient } from "./extended-client";

@Injectable()
export class PrismaService extends ExtendedPrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
// src/prisma/prisma.module.ts

import { Global, Module } from "@nestjs/common";

import { PrismaService } from "./prisma.service";

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

I'm sure there's a gotcha here somewhere that I haven't hit yet, but hopefully this helps!

@broisnischal
Copy link

Good job, @gamedevsam!
I decided to get your implementation and mix it up with mine. Here is what I got and 100% type-safe.

// database.module.ts
import { Global, Module } from '@nestjs/common'

import { DatabaseService } from '@/database/database.service'

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}
// database.service.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
import { Prisma, PrismaClient } from '@prisma/client'

export const prismaExtendedClient = (prismaClient: PrismaClient) =>
  prismaClient.$extends({
    model: {
      $allModels: {
        async softDelete<M, A>(
          this: M,
          where: Prisma.Args<M, 'update'>['where'],
        ): Promise<Prisma.Result<M, A, 'update'>> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          return (context as any).update({
            where,
            data: {
              deletedAt: new Date(),
            },
          })
        },
        async isDeleted<M>(this: M, where: Prisma.Args<M, 'findUnique'>['where']): Promise<boolean> {
          const context = Prisma.getExtensionContext(this)

          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- There is no way to type a Prisma model
          const result = await (context as any).findUnique({ where })

          return !!result.deletedAt
        },
      },
    },
  })

@Injectable()
export class DatabaseService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  readonly extendedClient = prismaExtendedClient(this)

  constructor() {
    super()

    new Proxy(this, {
      get: (target, property) => Reflect.get(property in this.extendedClient ? this.extendedClient : target, property),
    })
  }

  async onModuleInit() {
    await this.$connect()
  }

  async onModuleDestroy() {
    await this.$disconnect()
  }
}

Now, I can use the extended methods like this:

await this.databaseService.extendedClient.user.softDelete({ id })

And, I also can use like the normal way:

return await this.databaseService.user.findFirstOrThrow({
    where: { id },
})

does this still work? I've just tried it with Prisma 5.6.0 and Prisma client 5.6.0 and it hangs at Prisma.getExtensionContext()

yeah this works completely fine!

@janpio janpio changed the title Ability to extend PrismaClient class w/ Client Extensions before instantiation Ability to extend PrismaClient class w/ Client Extensions before instantiation Feb 19, 2024
@knotekbr
Copy link

knotekbr commented Mar 3, 2024

Just wanted to follow up on my earlier reply with some improvements. That solution wasn't leveraging Prisma's built-in extension helpers, and it relied on some pretty ugly manual typing that very quickly became a pain to manage.

This modified approach provides all the same benefits, but leans more heavily on Prisma's built-in types and requires much less boilerplate:

// src/prisma/extensions/exists.extension.ts

import { Prisma } from "@prisma/client";

export const existsExtension = Prisma.defineExtension({
  name: "exists",
  model: {
    $allModels: {
      async exists<TModel, TArgs extends Prisma.Args<TModel, "findUniqueOrThrow">>(
        this: TModel,
        args?: Prisma.Exact<TArgs, Prisma.Args<TModel, "findUniqueOrThrow">>
      ): Promise<boolean> {
        const context = Prisma.getExtensionContext(this);

        try {
          await (context as any).findUniqueOrThrow(args);
          return true;
        } catch {
          return false;
        }
      },
    },
  },
});
// src/prisma/extensions/find-many-count.extension.ts

import { Prisma } from "@prisma/client";

import type { FindManyAndCountResult } from "~types";

export const findManyAndCountExtension = Prisma.defineExtension((client) => {
  return client.$extends({
    name: "findManyAndCount",
    model: {
      $allModels: {
        async findManyAndCount<TModel, TArgs extends Prisma.Args<TModel, "findMany">>(
          this: TModel,
          args?: Prisma.Exact<TArgs, Prisma.Args<TModel, "findMany">>
        ): Promise<FindManyAndCountResult<Prisma.Result<TModel, TArgs, "findMany">>> {
          const context = Prisma.getExtensionContext(this);

          const [records, totalRecords] = await client.$transaction([
            (context as any).findMany(args),
            (context as any).count({ where: (args as any)?.where }),
          ]);

          const take = (args as any)?.take;
          let totalPages = totalRecords === 0 ? 0 : 1;

          if (take === 0) {
            totalPages = 0;
          } else if (typeof take === "number") {
            totalPages = Math.ceil(totalRecords / take);
          }

          return [records, totalRecords, totalPages];
        },
      },
    },
  });
});
// src/prisma/extended-client.ts

import { PrismaClient } from "@prisma/client";

import { existsExtension } from "./extensions/exists.extension";
import { findManyAndCountExtension } from "./extensions/find-many-count.extension";

function extendClient(base: PrismaClient) {
  // Add as many as you'd like - no ugly types required!
  return base.$extends(existsExtension).$extends(findManyAndCountExtension);
}

class UntypedExtendedClient extends PrismaClient {
  constructor(options?: ConstructorParameters<typeof PrismaClient>[0]) {
    super(options);

    return extendClient(this) as this;
  }
}

const ExtendedPrismaClient = UntypedExtendedClient as unknown as new (
  options?: ConstructorParameters<typeof PrismaClient>[0]
) => ReturnType<typeof extendClient>;

export { ExtendedPrismaClient };
// src/prisma/prisma.service.ts

import type { OnModuleInit, OnModuleDestroy } from "@nestjs/common";
import { Injectable } from "@nestjs/common";

import { ExtendedPrismaClient } from "./extended-client";

@Injectable()
export class PrismaService extends ExtendedPrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
// src/prisma/prisma.module.ts

import { Global, Module } from "@nestjs/common";

import { PrismaService } from "./prisma.service";

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

@ctliz
Copy link

ctliz commented Apr 12, 2024

I'm using this method until there's a better one:

import { Injectable, OnModuleInit, Global } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import * as runtime from '@prisma/client/runtime/library';
import $Result = runtime.Types.Result;
@Global()
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
  constructor() {
    super({
      log: ['query', 'info'],
      errorFormat: 'colorless',
    });
  }
  findAndCount<T extends Prisma.ModelName>(tableName: T) {
    return async <
      K extends Prisma.TypeMap['model'][T]['operations']['findMany']['args'],
    >({
      take,
      page,
      ...props
    }: {
      take: number;
      page: number;
    } & K) => {
      const prismaClient: any = this[tableName];
      const [list, count]: [
        $Result.GetResult<Prisma.TypeMap['model'][T]['payload'], K, 'findMany'>,
        number,
      ] = await this.$transaction([
        prismaClient.findMany({
          ...props,
          skip: (page - 1) * take,
          take: take,
        }),
        prismaClient.count({ where: props.where }),
      ]);
      return {
        take,
        page,
        count,
        list,
      };
    };
  }
}

Usage for Nestjs

 const { take, page, count, list } = await this.prismaService.findAndCount( 'employee')({
      page: getEmployeeDto.page,
      take: getEmployeeDto.take,
});

@dminglv
Copy link

dminglv commented Apr 13, 2024

Implemented an example of custom client integration for NestJS: https://github.com/dminglv/nestjs-prisma-postgres-cluster

WindSekirun pushed a commit to WindSekirun/nx-prisma-nestjs-example that referenced this issue May 11, 2024
WindSekirun added a commit to WindSekirun/nx-prisma-nestjs-example that referenced this issue May 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
kind/improvement An improvement to existing feature and code. team/client Issue for team Client. tech/typescript Issue for tech TypeScript. topic: clientExtensions topic: NestJS
Projects
None yet
Development

No branches or pull requests