question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Middleware does not work for nested creates

See original GitHub issue

Bug description

I am trying to write middleware e.g. to lowercase all my eMail and to hash password whereever a user gets created, but unfortunately it only works for direct creations and not for nested creations.

How to reproduce

Steps to reproduce the behavior:

  1. Go to any prisma example: e.g. https://github.com/prisma/prisma-examples/tree/latest/typescript/graphql-apollo-server
  2. Change the context.ts to something like this:
prisma.$use(async (params, next) => {
  if (params.model == 'User' && params.action == 'create') {
    params.args.data.email = params.args.data.email.toLowerCase()
  }
  return next(params)
})

create some nested create like this:

prisma.post.create({
  data: {
    author: {
      create: {
        email: "SoMe@EmaiL.com"
      }
    }
  }
})
  1. Run the instructions in the repo && prisma studio
  2. See error –> SoMe@EmaiL.com in the user table

Expected behavior

some@email.com

Prisma information

Environment & setup

  • OS: MacOS
  • Database: PostgreSQL
  • Node.js version: 14.1
  • Prisma version:
@prisma/cli          : 2.10.1
@prisma/client       : 2.10.2
Current platform     : darwin
Query Engine         : query-engine 7d0087eadc7265e12d4b8d8c3516b02c4c965111 (at node_modules/@prisma/engines/query-engine-darwin)
Migration Engine     : migration-engine-cli 7d0087eadc7265e12d4b8d8c3516b02c4c965111 (at node_modules/@prisma/engines/migration-engine-darwin)
Introspection Engine : introspection-core 7d0087eadc7265e12d4b8d8c3516b02c4c965111 (at node_modules/@prisma/engines/introspection-engine-darwin)
Format Binary        : prisma-fmt 7d0087eadc7265e12d4b8d8c3516b02c4c965111 (at node_modules/@prisma/engines/prisma-fmt-darwin)
Studio               : 0.304.0
Preview Features     : connectOrCreate, transactionApi

Issue Analytics

  • State:open
  • Created 3 years ago
  • Reactions:27
  • Comments:14 (1 by maintainers)

github_iconTop GitHub Comments

16reactions
alexandrdudukacommented, Mar 17, 2021

CASE

Hi prisma people, we are trying to use prisma middleware feature to implement subscriptions (publishing to the topic on the entity being changed). With some simplification, our code looks this way:

prisma.$use(async (params, next) => {
  const result = await next(params)
  // we only publish event when one of the tracked actions is called on a "subscribable" model
  if (params.model === 'Address' && ['create', 'update', 'delete'].includes(params.action)) {
    pubsub.publish('address_changed', {
        operation: params.action,
        data: result,
    })
  }

  return result
})

This works perfectly for our case, but what we hit into are nested mutations, e.g.:

prisma.person.update({
  data: {
    addresses: {
       create: {
          ....
       }
    }
  }
  ...
})

In the case of nested mutations, middleware is not being called and we cannot react.

SOLUTION

That would be very helpful if we could specify an argument on the $use hook, to only track the operations on the root level, or on the nested entities as well.

If you could advise on if we can achieve this at the moment in a different way, or let us know if this is something we can expect from Prisma in the future, we would be grateful, thank you!

5reactions
olivierwilkinsoncommented, Sep 21, 2022

Hi there, this was a problem for our team also as our existing code uses Loopback operation hooks (on save, on delete etc). In order to move over to Prisma we needed something that would make the transition easier.

In the end I implemented something in userland that is a version of @Charioteer’s nested middleware suggestion.

The way it works is that you pass a middleware function to createNestedMiddleware which returns modified middleware that can be passed to client.$use:

client.$use(createNestedMiddleware((params, next) => {
  // update params here
  const result = await next(params)
  // update result here
  return result;
));

This calls the middleware passed for the top-level case as well as for nested writes.

Edit: I’ve published the below code as a standalone npm package: prisma-nested-middleware.

Code
import { Prisma } from '@prisma/client';
import get from 'lodash/get';
import set from 'lodash/set';

const relationsByModel: Record<string, Prisma.DMMF.Model['fields']> = {};
Prisma.dmmf.datamodel.models.forEach((model) => {
  relationsByModel[model.name] = model.fields.filter(
    (field) => field.kind === 'object' && field.relationName
  );
});

export type NestedAction = Prisma.PrismaAction | 'connectOrCreate';

export type NestedParams = Omit<Prisma.MiddlewareParams, 'action'> & {
  action: NestedAction;
  scope?: NestedParams;
};

export type NestedMiddleware<T = any> = (
  params: NestedParams,
  next: (modifiedParams: NestedParams) => Promise<T>
) => Promise<T>;

type WriteInfo = {
  params: NestedParams;
  argPath: string;
};

type PromiseCallbackRef = {
  resolve: (result?: any) => void;
  reject: (reason?: any) => void;
};

const writeOperationsSupportingNestedWrites: NestedAction[] = [
  'create',
  'update',
  'upsert',
  'connectOrCreate',
];

const writeOperations: NestedAction[] = [
  ...writeOperationsSupportingNestedWrites,
  'createMany',
  'updateMany',
  'delete',
  'deleteMany',
];

function isWriteOperation(key: any): key is NestedAction {
  return writeOperations.includes(key);
}

function extractWriteInfo(
  params: NestedParams,
  model: Prisma.ModelName,
  argPath: string
): WriteInfo[] {
  const arg = get(params.args, argPath, {});

  return Object.keys(arg)
    .filter(isWriteOperation)
    .map((operation) => ({
      argPath,
      params: {
        ...params,
        model,
        action: operation,
        args: arg[operation],
        scope: params,
      },
    }));
}

function extractNestedWriteInfo(
  params: NestedParams,
  relation: Prisma.DMMF.Field
): WriteInfo[] {
  const model = relation.type as Prisma.ModelName;

  switch (params.action) {
    case 'upsert':
      return [
        ...extractWriteInfo(params, model, `update.${relation.name}`),
        ...extractWriteInfo(params, model, `create.${relation.name}`),
      ];

    case 'create':
      // nested creates use args as data instead of including a data field.
      if (params.scope) {
        return extractWriteInfo(params, model, relation.name);
      }

      return extractWriteInfo(params, model, `data.${relation.name}`);

    case 'update':
    case 'updateMany':
    case 'createMany':
      return extractWriteInfo(params, model, `data.${relation.name}`);

    case 'connectOrCreate':
      return extractWriteInfo(params, model, `create.${relation.name}`);

    default:
      return [];
  }
}

export function createNestedMiddleware<T>(
  middleware: NestedMiddleware
): Prisma.Middleware<T> {
  const nestedMiddleware: NestedMiddleware = async (params, next) => {
    const relations = relationsByModel[params.model || ''] || [];
    const finalParams = params;
    const nestedWrites: {
      relationName: string;
      nextReached: Promise<unknown>;
      resultCallbacks: PromiseCallbackRef;
      result: Promise<any>;
    }[] = [];

    if (writeOperationsSupportingNestedWrites.includes(params.action)) {
      relations.forEach((relation) =>
        extractNestedWriteInfo(params, relation).forEach((nestedWriteInfo) => {
          // store nextReached promise callbacks to set whether next has been
          // called or if middleware has thrown beforehand
          const nextReachedCallbacks: PromiseCallbackRef = {
            resolve() {},
            reject() {},
          };

          // store result promise callbacks so we can settle it once we know how
          const resultCallbacks: PromiseCallbackRef = {
            resolve() {},
            reject() {},
          };

          const nextReached = new Promise<void>((resolve, reject) => {
            nextReachedCallbacks.resolve = resolve;
            nextReachedCallbacks.reject = reject;
          });

          const result = nestedMiddleware(
            nestedWriteInfo.params,
            (updatedParams) => {
              // Update final params to include nested middleware changes.
              // Scope updates to [argPath].[action] to avoid breaking params
              set(
                finalParams.args,
                `${nestedWriteInfo.argPath}.${updatedParams.action}`,
                updatedParams.args
              );

              // notify parent middleware that params have been updated
              nextReachedCallbacks.resolve();

              // only resolve nested next when resolveRef.resolve is called
              return new Promise((resolve, reject) => {
                resultCallbacks.resolve = resolve;
                resultCallbacks.reject = reject;
              });
            }
          ).catch((e) => {
            // reject nextReached promise so if it has not already resolved the
            // parent will catch the error when awaiting it.
            nextReachedCallbacks.reject(e);

            // rethrow error so the parent catches it when awaiting `result`
            throw e;
          });

          nestedWrites.push({
            relationName: relation.name,
            nextReached,
            resultCallbacks,
            result,
          });
        })
      );
    }

    try {
      // wait for all nested middleware to have reached next and updated params
      await Promise.all(nestedWrites.map(({ nextReached }) => nextReached));

      // evaluate result from parent middleware
      const result = await middleware(finalParams, next);

      // resolve nested middleware next functions with relevant slice of result
      await Promise.all(
        nestedWrites.map(async (nestedWrite) => {
          // result cannot be null because only writes can have nested writes.
          const nestedResult = get(result, nestedWrite.relationName);

          // if relationship hasn't been included nestedResult is undefined.
          nestedWrite.resultCallbacks.resolve(nestedResult);

          // set final result relation to be result of nested middleware
          set(result, nestedWrite.relationName, await nestedWrite.result);
        })
      );

      return result;
    } catch (e) {
      // When parent rejects also reject the nested next functions promises
      await Promise.all(
        nestedWrites.map((nestedWrite) => {
          nestedWrite.resultCallbacks.reject(e);
          return nestedWrite.result;
        })
      );
      throw e;
    }
  };

  return (nestedMiddleware as unknown) as Prisma.Middleware;
}
Usage / Explanation

What createNestedMiddleware does is call the middleware it’s been passed for every nested relation. It does this by using the dmmf object which contains information about the relations defined in schema.prisma.

So for the following update:

client.country.update({
  where: { id: 'imagination-land' },
  data: {
    nationalDish: {
      update: {
        where: { id: 'stardust-pie' },
        data: {
          keyIngredient: {
            connectOrCreate: {
              create: { name: 'Stardust' },
              connect: { id: 'stardust' },
            },
          },
        },
      },
    },
  },
});

It calls middleware function with params in the following order:

  1. { model: 'Recipe', action: 'update', args: { where: { id: 'stardust-pie' }, data: {...} } }
  2. { model: 'Food', action: 'connectOrCreate', args: { create: {...}, connect: {...} } }
  3. { model: 'Country', action: 'update', args: { where: { id: 'imagination-land', data: {...} } }

Then it waits for all the nested next functions to have been passed params, updates the top level params object with those objects and awaits the top level next function, in this case the next where model is ‘Country’.

Once the top level next function resolves with a result the next functions of the nested middleware are resolved with the slice of the result relevent to them. So the middleware called for the ‘Recipe’ model receives the recipe object, the middleware for the ‘Food’ receives the food object.

Then the return values from the nested middleware are used to modify the top level result that is finally returned from the top level middleware.

There are a couple wrinkles:

  • the list of actions that might be in params is expanded to include ‘connectOrCreate’
  • If a relation is not included using include then that middleware’s next function will resolve with undefined.
  • sometimes the parent params are relevent, for instance if being connected you need to know the parent you are being connected to. To resolve this I added a scope object to params of nested middleware which is the parent params.
  • when handling nested create actions params.args does not include a data field, that must be handled manually. You can use the existence of params.scope to know when to handle a nested create.

I haven’t raised a PR since there is probably a better way to do this internally, however I haven’t checked… if this seems like a good solution to the Prisma team I’ll happily do so 👍

Disclamer: we’ve written tests for our middleware but that doesn’t mean this will work for you! Make sure you write your own tests before shipping anything that uses this.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Express middleware with nested callback - node.js
In my server controller code i have a method that retrieves an income and an expense object based on a provided id. The...
Read more >
Mongoose v6.8.1: SubDocuments
Nested schemas can have middleware, custom validation logic, virtuals, and any other feature top-level schemas can use. The major difference is that ...
Read more >
Express.js Middleware Can Be Arbitrarily Nested Within A ...
Ben Nadel demonstrates that middleware can be arbitrarily nested within an Express.js route definition in Node.js. This is because Express ...
Read more >
Nested Middleware - make the inside Middleware an exception
But, will it work in such case of Middleware with parameters (I will test it now but ... so it implicitly creates "permission...
Read more >
Advanced Features: Middleware - Next.js
Middleware allows you to run code before a request is completed, then based on the incoming request, you can modify the response by...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found