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

custom crud resolvers #550

Closed
jasonkuhrt opened this issue Dec 2, 2019 · 9 comments
Closed

custom crud resolvers #550

jasonkuhrt opened this issue Dec 2, 2019 · 9 comments
Labels
effort/modest impact/high type/feat Add a new capability or enhance an existing one

Comments

@jasonkuhrt
Copy link
Contributor

Idea originally (?) raised here #381 (comment). It has since been validated by no "wait we didn't think of X" moments, and multiple thumbs up from different users (both in GH and slack).

mutationType({
  definition(t) {
      t.crud.createOneUser({
        alias: 'signUp'
        resolver(parent, args, ctx, info) {
          // ...
        } 
      })
  },
})
@jasonkuhrt jasonkuhrt added type/feat Add a new capability or enhance an existing one impact/high effort/modest labels Dec 2, 2019
@beeplin2
Copy link

beeplin2 commented Dec 2, 2019

That's great!

And similarly, it might be also useful to add middleware like authorizeto t.model and t.crud:

t.crud.createOneUser({
        alias: 'signUp',
        authorize(parent, args, ctx, info) {
          // ...
        },
      })

or maybe a general middleware to add logics both before and after the default resolver:

t.crud.createOneUser({
        alias: 'signUp',
        async middleware(next, parent, args, ctx, info) {
          // do something
          await next()
          // do something else and return
        },
      })

@jasonkuhrt
Copy link
Contributor Author

Hey @beeplin we'd probably treat that as a separate feature for consideration.

@BjoernRave could you share how your use-case is resolved by this feature? Based on what you said in Slack, we're not sure it does.

@Albert-Gao
Copy link

Albert-Gao commented Dec 9, 2019

Actually, I quite love the idea of a framework named Feathersjs, it has a concept called hooks (not that React one 😄) to attach to the before and after stage of a request, so we can adding our own logic.

I think in most situations, the CRUD is still just a CRUD, you can attach your logic before and after a CRUD. Something like authentication before, remove some fields after

The argument is:

because you can always write your own resolver by using nexus, but for the generated t.crud.createOneUser, what is the point of writing your own if you can not reuse that implementation of t.crud.createOneUser

The API looks like this, instead of exposing a resolver, we add two properties before and after

mutationType({
  definition(t) {
      t.crud.createOneUser({
        alias: 'signUp'
        before: [isAdminUser],
        after:[removePassword]
      })
  },
})

The before and after are both an array of function, the engine will:

  1. go through the before array of function
  2. the result will be pass to t.crud.createOneUser
  3. after t.crud.createOneUser being resolved, its result will go through after
  4. then a response will be sent to the user

It should be easy to implement, and won't introduce any breaking change. And I am happy to submit the PR. If you guys agree on the idea.

Side topic:

Why I think it gonna work, is from my experiences of using feathersjs, it standardized the CRUD operation to a database model called service, it will generate a fully-fledged endpoint for you for an entity, and by adding these hooks, I never run into any situation that I can not do the things I want. Because a CRUD is just a CRUD, your custom business logic can always be added by the before and afterhooks. So you get the velocity from codegen, and still can customize to adapt your use case.

It is been mentioned in this PR, #541

But the differences here is we make before and after an array, so we can compose the logic by adding function, and each hook function can be tested in an isolated manner.

@jasonkuhrt
Copy link
Contributor Author

So, resolver middleware is already in flight with the new nexus plugins system. The degree to which it isn't enough to satisfy resolver auth requirements is not clear to me yet. I'm not discounting the ideas here. But I'd like to see very clear alignment with the underlying nexus middleware system.

@thehappycoder
Copy link

I would love to be able to define custom resolvers too. In my case, when I call createOneCrossword, which accepts a list of words and their starting positions, I need the resolve function to generate cells and save them to database along with words.

@thehappycoder
Copy link

Another use case for having a custom resolve function is to be able to check if a mutation caller has a right to connect certain words to a new crossword. Otherwise, some words could be stolen from some other crosswords. I've never tried it but I assume that's how it works.

@jamesopti
Copy link

Is there a workaround today to doing AuthN/Z with t.crud.<model>? My guess is using graphql-shield?

@RafaelKr
Copy link

RafaelKr commented Mar 5, 2020

I have a use case for custom resolvers. What I especially would also need then is a feature to extend the input arguments.

I'm using Postgres with PostGIS to implement a search which finds job offers in the near area specified by coordinates (latitude, longitude) and a radius.

I now implemented a very dirty hack to accomplish my area search.

The explanation of my hack

Excerpt from my schema.prisma:

model JobOffer {
  id                  String   @default(cuid()) @id
  locations           JobLocation[]          // m:n relation

  # many other fields
}

model JobLocation {
  id            String   @default(cuid()) @id
  /// Identifier composed by zip_lat_lng
  uniqueId      String   @unique

  jobOffer      JobOffer[]  // m:n relation

  zip           String
  city          String
  state         String?
  lat           Float?
  lng           Float?
}

My queryType for Query is implemented like this:

export const Query = queryType({
	definition(t) {
		// ...

		t.crud.jobOffers({
			filtering: {
				// many filtering fields
			},
			pagination: true,
		})

		// ...
	},
})

To have a way to pass additional arguments in my jobOffers query I added the field inArea to my JobOffer objectType:

const JobOfferInAreaCenterInput = inputObjectType({
	name: 'JobOfferInAreaCenterInput',
	description: 'Defines the center of the area search',
	definition(t): void {
		t.int('radius', {
			description: 'Radius in meters',
			required: true,
		})
		t.float('lat', {
			description: 'Latitude',
			required: true,
		})
		t.float('lng', {
			description: 'Longitude',
			required: true,
		})
	}
})

export const JobOffer = objectType({
	name: 'JobOffer',
	definition(t) {
		// many other fields 

		t.field('inArea', {
			type: 'Boolean',
			description: 'Dummy type for area search.\nThis is used ONLY for jobOffers query.\nYou MUST pass the center argument as a variable named $areaSearchCenter\n\nThis is a dirty hack.',
			args: {
				center: JobOfferInAreaCenterInput,
			},
			resolve: () => true
		})
	},
})

Now I wrote a nexus plugin (used in Nexus.makeSchema) which intercepts the jobOffers resolver:

import { plugin } from 'nexus'

interface Area {
	radius: number
	lat: number
	lng: number
}

export const jobOffersAreaSearchExtension = plugin({
	name: 'JobOffersAreaSearchExtension',
	onCreateFieldResolver(config) {
		if (config.fieldConfig.name !== 'jobOffers') {
			return
		}

		return async (root, args, ctx, info, next) => {
			const areaSearchCenter: Area | undefined = info.variableValues.areaSearchCenter

			if (!areaSearchCenter) {
				return next(root, args, ctx, info)
			}

			// TODO: pg can be replaced with prisma client raw queries when this issue is resolved:
			// https://github.com/prisma/migrate/issues/357
			const jobLocationsWithinArea = await ctx.pg.query(`
SELECT
	*
FROM
	prisma2_project."JobLocation"
WHERE
	ST_DWithin(ST_MakePoint(lng, lat)::geography, ST_MakePoint($1, $2)::geography, $3)
			`, [areaSearchCenter.lng, areaSearchCenter.lat, areaSearchCenter.radius])

			let jobLocationsWithinAreaIds: string[] = jobLocationsWithinArea.rows.map((location: any) => location.id)

			// locations are not specified as filterable for t.crud.jobOffers
			// if it was specified, we would have to merge the args
			args = {
				...args,
				where: {
					...args.where,
					locations: {
						some: {
							AND: {
								id: {
									in: jobLocationsWithinAreaIds
								}
							}
						},
					}
				}
			}

			return next(root, args, ctx, info)
		}
	},
})

This makes it possible to query my jobOffers within a specified area:

query GetAllJobOffers($areaSearchCenter: JobOfferInAreaCenterInput) {
  jobOffers {
    inArea(center: $areaSearchCenter)

    id
    locations {
      id
      zip
      city
    }

    # many other fields
  }
}

# With area search:
# {
#   "areaSearchCenter": {
#     "radius": 5000,
#     "lng": 9.1919123,
#     "lat": 48.786453
#   }
# }
#
# Without area search:
# {
#   "areaSearchCenter": null
# }
#
# Alternatively "areaSearchCenter" can not be passed at all

@jasonkuhrt
Copy link
Contributor Author

Closed by #674

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
effort/modest impact/high type/feat Add a new capability or enhance an existing one
Projects
None yet
Development

No branches or pull requests

6 participants