DEV Community

Cover image for How to validate your prisma raw query results
Jordan Quagliatini
Jordan Quagliatini

Posted on • Edited on • Originally published at blog.jquagliatini.fr

How to validate your prisma raw query results

Recently I investigated slonik, as an alternative for prisma when building raw queries. The tool use an interesting validation pattern, that I wanted to replicate with Prisma.


Slonik relies heavily on zod validators. Being the validation og, I'm not surprised. Working on a OOP focused team, I personnaly use class-validator and class-transformer from typestack.

Let's imagine that we need to retrieve all the unpaid amount for a company.


await prisma.$queryRaw`
  SELECT (
    COALESCE(SUM(due_amount), 0) -
    COALESCE(SUM(paid_amount), 0)
  ) AS "dueAmount"
  FROM (
    SELECT invoice.amount AS due_amount,
           SUM(payments.amount) AS paid_amount
    FROM invoice LEFT JOIN payments
      ON payments."invoiceId" = invoice.id
  ) _
`

Enter fullscreen mode Exit fullscreen mode

Prisma allows us to type the request:

await prisma.$queryRaw<[{ dueAmount: bigint }]>`
Enter fullscreen mode Exit fullscreen mode

But there are a few problems here:

  • Since all types are provided by us, they might change
  • The type is never validated against the query
  • We might make a transparent mistake

For example, we could mistake the bigint for number. It might work a lot of the time, but we also could stumble on cases where it won't without it being apparent.

One way to fix this, is to add runtime type validation.

With class validator, we could use the following DTO:

class StillDueAmountDto {
  @IsNumber({ allowInfinity: false, allowNaN: false })
  @Transform(({ value }) => Number(value))
  dueAmount!: number;
}
Enter fullscreen mode Exit fullscreen mode

Which will transform and validate our data:

const [stillDueAmountRaw] = await prisma.$queryRaw<[StillDueAmountDto]>`
  ...
`;

const stillDueAmount = plainToInstance(
  StillDueAmountDto,
  stillDueAmountRaw
)

await validateOrReject(stillDueAmount);
// stillDueAmount -> StillDueAmountDto { dueAmount: number }
Enter fullscreen mode Exit fullscreen mode

It would be cumbersome to do that everytime, so we could create a tiny prisma client decorator:

export class ValidatedRawClient {
  constructor(private readonly prisma: PrismaClient) {}

  async $queryRaw<T extends object>(
    ctor: ClassConstructor<T>,
  ) {
    return async (...args: Parameters<PrismaClient['$queryRaw']>) => {
      // I never saw prisma return anything
      // other than an array
      const result = await this.prisma.$queryRaw<T[]>(...args);
      const instances = plainToInstance(ctor, result);

      await Promise.all(
        ([] as T[])
          .concat(instances)
          .map(i => validateOrReject(i))
      );
      return instances;
  }
}
Enter fullscreen mode Exit fullscreen mode

Next we can use it in a similar way as slonik

const p = new ValidatedRawClient(new PrismaClient());

const stillDueAmounts = await p.$queryRaw(StillDueAmountDto)`
  SELECT ...
`;
Enter fullscreen mode Exit fullscreen mode

One caveat though, we lose the SQL syntax highlighting in VSCode. We can change the function to keep the original entry signature:

export class ValidatedRawClient {
  constructor(private readonly prisma: PrismaClient) {}

  async $queryRaw(
    ...args: Parameters<PrismaClient['$queryRaw']>
  ) {
    return {
      as: async <T extends object>(
        ctor: ClassConstructor<T>
      ) => {
        // I never saw prisma return anything
        // other than an array
        const result = await this.prisma.$queryRaw<T[]>(...args);
        const instances = plainToInstance(ctor, result);

        await Promise.all(
          ([] as T[])
            .concat(instances)
            .map(i => validateOrReject(i))
        );
        return instances;
      }
    }
  }
}

const p = new ValidatedRawClient(new PrismaClient());
const stillDueAmounts = await p.$queryRaw`
  SELECT ...
`.as(StillDueAmountDto);
Enter fullscreen mode Exit fullscreen mode

So here we have a runtime guard, which mean that we should rely on testing to catch changes in schema early, but it looks like a good starting point.

Let me know, if you see any improvements on the API.

Top comments (0)