maoosi/prisma-appsync

Feature: Support for more granular @gql directives

sebastian-luehr opened this issue · 8 comments

Hi,
I really like the awesome generator you made and I'm currently exploring it extensively.
I have generated a schema.prisma file and try to only allow connect releations via @gql directive.
In the related model (Status) I have set mutations create to null because I only want to fill it via seeding.
Nevertheless the create and createOrConnect is showing up in aws appsync, allthough it is not possible to create an additional status.
Is it possible to achive this?

/// @gql(subscriptions: null, mutations: { deleteMany:null, updateMany: null, createMany:null, upsert: null }, fields: { statusId: null})
model Cars {
    id                       String                  @id @default(uuid())
    name                     String                  @unique
    status                   Status                  @relation(fields: [statusId], references: [id])
    statusId                 String
}
/// @gql(subscriptions: null, mutations: { create: null, createMany:null, update: null, updateMany: null, upsert: null, delete:null, deleteMany:null }, queries: { get:null, count:null })
model Status {
    id              String           @id @default(uuid())
    name            String           @unique
}
maoosi commented

Thanks @sebastian-luehr!

For now, only top-level config will work on subscriptions, mutations and queries.

/// @gql(mutations: null)   <-- supported
/// @gql(mutations: { create: null })   <-- not supported

Happy to add your request to the roadmap for later.

+1 This would be huge for us as well! We really only support soft deletes on on our data model so I'm trying to think about the best workaround. I had a few thoughts. I'm curious what others have done.

  1. Create a script that always runs post generate to pull out the mutations we wanted removed from the generated GQL
  2. Add a method to the handler to just override and return on any mutations we want to remove
  3. Perhaps look into adding the more granular level GQL... Not sure how challenging you think this would be.

Any suggest on what others have done would be great!

Thanks!

Thanks for sharing your use case @eratik! The more people interested about this, the more likely to prioritise it on future releases.

The two methods you described could work as workaround.

With method 2, the logic to override can be added inside a Before Hook:
https://prisma-appsync.vercel.app/features/hooks.html

Another possible way is to use a Prisma middleware (now deprecated for extensions, but it should work the same way):
https://www.prisma.io/docs/concepts/components/prisma-client/middleware/soft-delete-middleware

To access Prisma Client instance using Prisma-AppSync, you can do this:

const prismaAppSync = new PrismaAppSync()

prismaAppSync.prismaClient.$use(async (params, next) => {
    // Your logic here
    return next(params)
})

export const main = async (event: AppSyncResolverEvent<any>) => {
    return await prismaAppSync.resolve({ event })
}

@maoosi Thanks for the thoughtful reply! If I'm understanding you right I think what I would do with the hook approach is to just create a hook that looks at everything and if it's any delete I would adjust the params match a custom resolver that returns nothing or an error or something? Thanks again for any help, it's much appreciated!

Actually, I think I figured it out. For anyone else that's interested here is what I did. @maoosi Please do let me know if this isn't the best approach. ;)

// In a custom Hooks TS

export const customErrorHook = async (params: BeforeHookParams) => {
  if (params.context.action == "delete") {
    throw new Error("HARD DELETE IS NOT SUPPORTED");
  }
  return params;
};

// In my handler.ts

export const main = async (event: AppSyncResolverEvent<any>) => {
  return await prismaAppSync.resolve<ResolverInterface>({
    event,
    hooks: {
      "before:**": customErrorHook,
    },
    resolvers: {
      customerResolver1,
      customResovler2,
    },
  });
};

@eratik There are many different ways to implement soft deletes. What you did is simple enough and should work, yes!

A more common approach would be adding a status enum to all your models. Calling delete[Model] would set status to DELETED, while all read operations would only return non-DELETED data.

That said, the code to achieve this would be more complex to read and maintain, so it might or might not be the right approach depending on your use case. Here is a demo code for doing so with Prisma-AppSync right now:

import type { AfterHookParams, AppSyncResolverEvent, BeforeHookParams, QueryParamsCustom } from './prisma/generated/prisma-appsync/client'
import { PrismaAppSync, _ } from './prisma/generated/prisma-appsync/client'

// Instantiate Prisma-AppSync Client
const prismaAppSync = new PrismaAppSync()

// Custom resolver for soft deletes (set `DELETED` status)
const softDelete = async ({ args, prismaClient, context }: QueryParamsCustom) => {
    if (context?.model?.singular) {
        return await prismaClient[context.model.singular].update({
            data: { status: 'DELETED' },
            where: args?.where,
        })
    }

    return null
}

// Before hook to:
// 1) push `delete` mutations to `softDelete`
// 2) enforce selecting `status` on all models
const beforeAll = async (params: BeforeHookParams) => {
    if (params.context.action === 'delete')
        return { ...params, operation: 'softDelete' }

    // always select status for `get` or `list`
    if (params.context.alias === 'access') {
        const prismaArgs = _.merge(params.prismaArgs, { select: { status: true } })

        // always select status in nested (n+1)
        return {
            ...params,
            prismaArgs: await _.walk(prismaArgs, async ({ key, value }) => {
                if (key === 'select' && _.isObject(value))
                    value = _.merge(value, { status: true })

                return { key, value }
            }),
        }
    }

    return params
}

// Filter out all `DELETED` data from result
const afterAll = async (params: AfterHookParams) => {
    if (params.context.alias === 'access') {
        if (Array.isArray(params.result))
            params.result = params.result.filter(v => !(v?.status === 'DELETED'))
        else if (params.result?.status === 'DELETED')
            params.result = null

        // filter out all `DELETED` from nested (n+1)
        return {
            ...params,
            result: await _.walk(params.result, async ({ key, value }) => {
                if (Array.isArray(value))
                    value = value.filter(v => !(v?.status === 'DELETED'))
                else if (_.isObject(value) && value?.status === 'DELETED')
                    value = null

                return { key, value }
            }),
        }
    }

    return params
}

// Lambda handler (AppSync Direct Lambda Resolver)
export const main = async (event: AppSyncResolverEvent<any>) => {
    return await prismaAppSync.resolve({
        event,
        resolvers: { softDelete },
        hooks: {
            'before:**': beforeAll,
            'after:**': afterAll,
        },
    })
}

Released in v1.0.0.